diff --git a/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx b/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx index dde5593278e..e3e13505134 100644 --- a/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx +++ b/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx @@ -1,13 +1,4 @@ -import { optionalSettingsRead } from '../transport/settings-read-operations' -import { interpretOrThrowRefusalMessage } from '../transport/rpc-refusal-message' -import { rpcPayloadMember } from '../transport/rpc-reader-payload' -import { readAcceptedResumeList } from './resume-metadata-lists' -import { - resumeFolderWorkspaceListRead, - resumeProjectGroupListRead, - resumeRepoListRead, - resumeWorktreeListRead -} from './mobile-agent-history-operations' +import { loadMobileResumeMetadata } from './mobile-agent-history-resume-metadata' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' @@ -15,22 +6,17 @@ import { useRouteHandoff } from '../navigation/route-handoff' import { ChevronLeft, RefreshCw } from 'lucide-react-native' import { colors } from '../theme/mobile-theme' import { useHostClient } from '../transport/client-context' -import type { RpcClient } from '../transport/rpc-client' -import { readMobileRuntimeHostPlatform } from '../transport/mobile-runtime-host-platform' import { worktreeCatalogRead } from '../worktree/worktree-catalog-operations' import { getWorktreeLabel } from '../session/worktree-label' import { buildMobileAiVaultResumeLaunch, createMobileAiVaultResumeMutationRegistry, + readMobileAiVaultResumeHost, readMobileRuntimeTerminalWindowsShell, resolveMobileAiVaultResumePlatform, - resumeAiVaultSessionInTerminal, - type MobileAiVaultResumeSettings + resumeAiVaultSessionInTerminal } from '../session/ai-vault-resume-launch' -import { - prepareMobileAiVaultSessionResume, - RESUME_RPC_TIMEOUT_MS -} from '../session/ai-vault-resume-preparation' +import { prepareMobileAiVaultSessionResume } from '../session/ai-vault-resume-preparation' import { triggerError, triggerSuccess } from '../platform/haptics' import type { AiVaultScope, AiVaultSession } from '../../../src/shared/ai-vault-types' import type { Worktree } from '../worktree/workspace-list-types' @@ -38,15 +24,11 @@ import { useMobileAgentHistoryState } from './use-mobile-agent-history-state' import { buildMobileAgentHistorySections } from './agent-history-sections' import { shouldShowMobileCurrentWorktreeBadge } from './agent-history-current-worktree-badge' import { MobileAgentSessionHistoryList } from './MobileAgentSessionHistoryList' -import { - resolveMobileAiVaultSessionResumeTarget, - type MobileAiVaultResumeFolderWorkspace, - type MobileAiVaultResumeProjectGroup, - type MobileAiVaultResumeRepo -} from './agent-history-resume-target' +import { resolveMobileAiVaultSessionResumeTarget } from './agent-history-resume-target' import { buildMobileAgentHistoryResumeActionState } from './agent-history-session-card' import { styles } from './agent-history-styles' import { useNow } from '../hooks/use-now' +import { useMobileResumeOperationOwnership } from './use-mobile-resume-operation-ownership' export type MobileAgentSessionHistoryPanelProps = { hostId: string @@ -69,6 +51,12 @@ export function MobileAgentSessionHistoryPanel({ // screen, and the session it resumes into is a native route the shell has to push. const router = useRouteHandoff() const { client, state: connState } = useHostClient(hostId) + const claimResumeOwnership = useMobileResumeOperationOwnership( + hostId, + worktreeId, + client, + connState + ) const [worktrees, setWorktrees] = useState([]) const [worktreesLoaded, setWorktreesLoaded] = useState(false) const [query, setQuery] = useState('') @@ -147,8 +135,8 @@ export function MobileAgentSessionHistoryPanel({ [sessions, query, scope, scopeFilterPaths, activeWorktreePath, now] ) - const hostPlatform = useMemo( - () => readMobileRuntimeHostPlatform(hostStatusResult), + const resumeHost = useMemo( + () => readMobileAiVaultResumeHost(hostStatusResult), [hostStatusResult] ) const hostTerminalWindowsShell = useMemo( @@ -177,6 +165,7 @@ export function MobileAgentSessionHistoryPanel({ return } + const assertCurrentOwner = claimResumeOwnership() resumeLaunchInFlightRef.current = true setResumingSessionId(session.id) setResumeMessage(null) @@ -188,6 +177,7 @@ export function MobileAgentSessionHistoryPanel({ settings, worktrees: freshWorktrees } = await loadMobileResumeMetadata(client) + assertCurrentOwner() const target = resolveMobileAiVaultSessionResumeTarget({ session, activeWorktreeId: worktreeId, @@ -207,7 +197,7 @@ export function MobileAgentSessionHistoryPanel({ const platform = resolveMobileAiVaultResumePlatform( target.targetStatus, - hostPlatform, + resumeHost.platform, target.workspacePath, target.terminalPlatform ) @@ -218,18 +208,31 @@ export function MobileAgentSessionHistoryPanel({ } const preparedSession = await prepareMobileAiVaultSessionResume(client, session) + assertCurrentOwner() const launch = buildMobileAiVaultResumeLaunch({ session: preparedSession, hostPlatform: platform, hostTerminalWindowsShell, settings }) - await resumeAiVaultSessionInTerminal(client, target.worktreeId, { - ...launch, - clientMutationId: resumeMutationRegistryRef.current.claim(session.id) - }) + await resumeAiVaultSessionInTerminal( + client, + target.worktreeId, + { + ...launch, + hostCapabilities: resumeHost.capabilities, + clientMutationId: resumeMutationRegistryRef.current.claim(session.id) + }, + assertCurrentOwner + ) resumeMutationRegistryRef.current.releaseOnSuccess(session.id) triggerSuccess() + // The host accepted the resume; a cutover now only stops navigation. + try { + assertCurrentOwner() + } catch { + return + } setResumeMessage('Agent session queued.') router.push( `/h/${encodeURIComponent(hostId)}/session/${encodeURIComponent(target.worktreeId)}` as Parameters< @@ -248,11 +251,12 @@ export function MobileAgentSessionHistoryPanel({ client, connState, hostId, - hostPlatform, + resumeHost, hostTerminalWindowsShell, router, worktreeId, - worktrees + worktrees, + claimResumeOwnership ] ) @@ -377,57 +381,6 @@ export function MobileAgentSessionHistoryPanel({ const EMPTY_SESSIONS: AiVaultSession[] = [] const EMPTY_ISSUES: { agent: AiVaultSession['agent']; path: string; message: string }[] = [] -async function loadMobileResumeMetadata(client: RpcClient): Promise<{ - repos: MobileAiVaultResumeRepo[] - folderWorkspaces: MobileAiVaultResumeFolderWorkspace[] - projectGroups: MobileAiVaultResumeProjectGroup[] - settings: MobileAiVaultResumeSettings | null - worktrees: Worktree[] | null -}> { - // Why: repo.list can enrich repo remote identities, so fetch resume-only - // metadata after explicit user intent instead of delaying history browsing. - // timeoutMs: without it a socket drop parks these on the reconnect waiter - // for minutes, pinning the resume spinner (see RESUME_RPC_TIMEOUT_MS). - const [repoReply, folderWorkspaceReply, projectGroupReply, settingsReply, worktreeReply] = - await Promise.all([ - resumeRepoListRead.request(client, undefined, { timeoutMs: RESUME_RPC_TIMEOUT_MS }), - resumeFolderWorkspaceListRead - .request(client, undefined, { timeoutMs: RESUME_RPC_TIMEOUT_MS }) - .catch(() => null), - resumeProjectGroupListRead - .request(client, undefined, { timeoutMs: RESUME_RPC_TIMEOUT_MS }) - .catch(() => null), - optionalSettingsRead - .request(client, undefined, { timeoutMs: RESUME_RPC_TIMEOUT_MS }) - .catch(() => null), - resumeWorktreeListRead - .request(client, { limit: 10000 }, { timeoutMs: RESUME_RPC_TIMEOUT_MS }) - .catch(() => null) - ]) - const repoResult = interpretOrThrowRefusalMessage( - () => resumeRepoListRead.interpret(repoReply), - 'Unable to load workspace metadata.' - ) - const folderWorkspaceResult = - folderWorkspaceReply && resumeFolderWorkspaceListRead.interpret(folderWorkspaceReply) - const projectGroupResult = - projectGroupReply && resumeProjectGroupListRead.interpret(projectGroupReply) - const settingsResult = settingsReply ? optionalSettingsRead.interpret(settingsReply) : null - const settings = settingsResult?.accepted - ? // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - (settingsResult.value as MobileAiVaultResumeSettings | null | undefined) - : null - const worktreeResult = worktreeReply && resumeWorktreeListRead.interpret(worktreeReply) - return { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - repos: (rpcPayloadMember(repoResult, 'repos') as MobileAiVaultResumeRepo[] | undefined) ?? [], - folderWorkspaces: readAcceptedResumeList(folderWorkspaceResult, 'folderWorkspaces') ?? [], - projectGroups: readAcceptedResumeList(projectGroupResult, 'groups') ?? [], - settings: settings ?? null, - worktrees: readAcceptedResumeList(worktreeResult, 'worktrees') ?? null - } -} - function createMobileAiVaultResumeMutationId(sessionId: string): string { const sessionPart = sessionId.replace(/[^a-zA-Z0-9_.:-]/g, '_').slice(0, 64) || 'session' const randomPart = Math.random().toString(36).slice(2, 10) diff --git a/mobile/src/agent-history/agent-history-current-host-capability.test.tsx b/mobile/src/agent-history/agent-history-current-host-capability.test.tsx new file mode 100644 index 00000000000..51d4a91f955 --- /dev/null +++ b/mobile/src/agent-history/agent-history-current-host-capability.test.tsx @@ -0,0 +1,196 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import type { ConnectionState, RpcResponse } from '../transport/types' +import type { Worktree } from '../worktree/workspace-list-types' +import { QODER_OWNED_TERMINAL_CREATE_CAPABILITY } from '../../../src/shared/qoder-terminal-create-capability' + +const connection = vi.hoisted((): { client: RpcClient | null; state: ConnectionState } => ({ + client: null, + state: 'connected' +})) +vi.mock('../transport/client-context', () => ({ + useHostClient: () => connection, + useForceReconnect: () => vi.fn() +})) +import { + useMobileAgentHistoryState, + type MobileAgentHistoryState +} from './use-mobile-agent-history-state' +import { + buildMobileAiVaultResumeLaunch, + readMobileAiVaultResumeHost, + resumeAiVaultSessionInTerminal +} from '../session/ai-vault-resume-launch' + +function fakeClient(sendRequest: RpcClient['sendRequest'], generation?: () => number): RpcClient { + return { + sendRequest, + subscribe: () => () => {}, + updateTerminalSubscriptionViewport: () => {}, + getState: () => connection.state, + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + onStateChange: () => () => {}, + notifyForeground: () => {}, + close: () => {}, + ...(generation ? { getGeneration: generation } : {}) + } +} + +it.each([ + 'host/client replacement', + 'host replacement', + 'same-client generation', + 'legacy-client reconnect' +])('requires current host evidence before owned resume: %s', async (scenario) => { + const cap = QODER_OWNED_TERMINAL_CREATE_CAPABILITY + const worktrees: Worktree[] = [] + const observed: { current: MobileAgentHistoryState | null } = { current: null } + const rendered: { tree: ReactTestRenderer | null } = { tree: null } + let generation = 1 + let hostId = 'owned-host-a' + const read = () => { + if (!observed.current) { + throw new Error('missing history state') + } + return observed.current + } + function Probe() { + observed.current = useMobileAgentHistoryState({ + hostId, + worktreeId: 'owned-worktree', + worktrees, + worktreesLoaded: true + }) + return null + } + const firstStatus: RpcResponse = { + id: 'owned-reply', + ok: true, + result: { hostPlatform: 'darwin', capabilities: ['aiVault.v1', cap] } + } + const initialSend = vi.fn(async (method) => + method === 'status.get' + ? firstStatus + : { id: 'owned-reply', ok: true, result: { sessions: [], issues: [] } } + ) + const initialClient = fakeClient( + initialSend, + scenario === 'legacy-client reconnect' ? undefined : () => generation + ) + connection.client = initialClient + connection.state = 'connected' + await act(async () => { + rendered.tree = create(createElement(Probe)) + }) + try { + expect(read().screenState.kind).toBe('ready') + expect(readMobileAiVaultResumeHost(read().hostStatusResult).capabilities).toContain(cap) + let resolveRetired: (reply: RpcResponse) => void = () => {} + const retiredStatus = new Promise((resolve) => { + resolveRetired = resolve + }) + initialSend.mockImplementation(async () => retiredStatus) + let refresh: Promise | undefined + await act(async () => { + refresh = read().onRefresh() + }) + let resolveStatus: (reply: RpcResponse) => void = () => {} + const pendingStatus = new Promise((resolve) => { + resolveStatus = resolve + }) + const currentSend = vi.fn(async (method) => { + if (method === 'status.get') { + return pendingStatus + } + if (method === 'session.tabs.createTerminal') { + return { + id: 'owned-reply', + ok: true, + result: { + tab: { type: 'terminal', id: 'owned-tab', terminal: 'owned-pty', title: 'Terminal' } + } + } + } + if (method === 'terminal.send') { + return { id: 'owned-reply', ok: true, result: { send: { accepted: true } } } + } + return { id: 'owned-reply', ok: true, result: { sessions: [], issues: [] } } + }) + let currentClient = initialClient + if (scenario === 'host/client replacement') { + currentClient = fakeClient(currentSend, () => 2) + connection.client = currentClient + hostId = 'owned-host-b' + } else { + initialSend.mockImplementation(currentSend) + if (scenario === 'host replacement') { + hostId = 'owned-host-b' + } else if (scenario === 'same-client generation') { + generation = 2 + } else { + connection.state = 'reconnecting' + await act(async () => { + rendered.tree?.update(createElement(Probe)) + }) + connection.state = 'connected' + } + } + await act(async () => { + rendered.tree?.update(createElement(Probe)) + }) + expect(read().screenState.kind).toBe('ready') + const launch = buildMobileAiVaultResumeLaunch({ + session: { + agent: 'qoder', + sessionId: 'owned-session', + cwd: '/owned/workspace', + codexHome: null + }, + hostPlatform: 'darwin' + }) + await act(async () => { + resolveRetired(firstStatus) + await refresh + }) + const pendingHost = readMobileAiVaultResumeHost(read().hostStatusResult) + expect(pendingHost.capabilities?.includes(cap) ?? false).toBe(false) + await resumeAiVaultSessionInTerminal(currentClient, 'owned-worktree', { + ...launch, + hostCapabilities: pendingHost.capabilities, + clientMutationId: 'pending-probe-resume' + }) + const firstCreate = currentSend.mock.calls.find( + ([method]) => method === 'session.tabs.createTerminal' + ) + expect(firstCreate?.[1]).not.toHaveProperty('command') + expect(currentSend.mock.calls.map(([method]) => method)).toContain('terminal.send') + await act(async () => { + resolveStatus({ + id: 'owned-reply', + ok: true, + result: { hostPlatform: 'linux', capabilities: ['aiVault.v1', cap] } + }) + }) + expect(readMobileAiVaultResumeHost(read().hostStatusResult)).toEqual({ + platform: 'linux', + capabilities: ['aiVault.v1', cap] + }) + currentSend.mockClear() + await resumeAiVaultSessionInTerminal(currentClient, 'owned-worktree', { + ...launch, + hostCapabilities: readMobileAiVaultResumeHost(read().hostStatusResult).capabilities, + clientMutationId: 'current-probe-resume' + }) + expect( + currentSend.mock.calls.find(([method]) => method === 'session.tabs.createTerminal')?.[1] + ).toEqual(expect.objectContaining({ command: launch.command })) + expect(currentSend.mock.calls.map(([method]) => method)).not.toContain('terminal.send') + } finally { + await act(async () => { + rendered.tree?.unmount() + }) + } +}) diff --git a/mobile/src/agent-history/agent-history-pending-resume-ownership.test.tsx b/mobile/src/agent-history/agent-history-pending-resume-ownership.test.tsx new file mode 100644 index 00000000000..ecfdd4bae9c --- /dev/null +++ b/mobile/src/agent-history/agent-history-pending-resume-ownership.test.tsx @@ -0,0 +1,350 @@ +import { expect, it, vi } from 'vitest' +import React from 'react' +import type { AiVaultSession } from '../../../src/shared/ai-vault-types' +import { CreateTerminalTab } from '../../../src/shared/rpc-contract/session-tabs-schemas-params' +import type { RpcClient } from '../transport/rpc-client' +import type { ConnectionState, RpcResponse } from '../transport/types' +import { createFakeRpcClient } from '../mobile-web-shell/bridge-host-test-fakes' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +const state = vi.hoisted( + (): { + generation: number + client: RpcClient | null + connection: ConnectionState + legacy: boolean + completedBoundary: boolean + session: AiVaultSession | null + onResume?: (session: AiVaultSession) => Promise + } => ({ + generation: 1, + client: null, + connection: 'connected', + legacy: false, + completedBoundary: false, + session: null + }) +) +vi.mock('react-native', () => ({ + ActivityIndicator: 'ActivityIndicator', + Pressable: 'Pressable', + RefreshControl: 'RefreshControl', + SectionList: 'SectionList', + Text: 'Text', + TextInput: 'TextInput', + View: 'View', + Platform: { OS: 'web', select: (choices: Record) => choices.web }, + AppState: { currentState: 'active', addEventListener: () => ({ remove() {} }) }, + StyleSheet: { create: (value: unknown) => value, hairlineWidth: 1 } +})) +vi.mock('react-native-safe-area-context', () => ({ + SafeAreaView: 'SafeAreaView', + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }) +})) +vi.mock('react-native-svg', () => ({ default: 'Svg', Path: 'Path' })) +vi.mock('lucide-react-native', () => ({ ChevronLeft: 'Icon', Play: 'Icon', RefreshCw: 'Icon' })) +const notifications = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn(), push: vi.fn() })) +vi.mock('../platform/haptics', () => ({ + triggerError: notifications.error, + triggerSuccess: notifications.success +})) +vi.mock('../components/MobileAgentIcon', () => ({ MobileAgentIcon: () => null })) +vi.mock('../navigation/route-handoff', () => ({ + useRouteHandoff: () => ({ push: notifications.push, back: vi.fn(), canGoBack: () => false }) +})) +vi.mock('../transport/client-context', () => ({ + useHostClient: () => ({ client: state.client, state: state.connection }), + useForceReconnect: () => vi.fn() +})) +vi.mock('./use-mobile-agent-history-state', () => ({ + useMobileAgentHistoryState: () => ({ + scope: 'workspace', + screenState: { kind: 'ready', sessions: [state.session], issues: [] }, + refreshing: false, + hostStatusResult: + state.generation === 1 + ? { + hostPlatform: 'darwin', + capabilities: state.legacy ? [] : ['aiVault.v1', 'session.tabs.qoderOwnedCreate.v1'] + } + : state.completedBoundary + ? { hostPlatform: 'darwin', capabilities: [] } + : null, + activeWorktreePath: '/owned/workspace', + scopeFilterPaths: ['/owned/workspace'], + onSelectScope: vi.fn(), + onRefresh: vi.fn(), + retry: vi.fn() + }) +})) +vi.mock('./MobileAgentSessionHistoryList', () => ({ + MobileAgentSessionHistoryList: (props: { + onResume: (session: AiVaultSession) => Promise + }) => { + state.onResume = props.onResume + return null + } +})) +import { MobileAgentSessionHistoryPanel } from './MobileAgentSessionHistoryPanel' + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) +it.each([ + ['stable', 'metadata'], + ['generation', 'metadata'], + ['generation without render', 'metadata'], + ['client', 'metadata'], + ['host', 'metadata'], + ['legacy reconnect', 'metadata'], + ['unmount', 'metadata'], + ['stable', 'preparation'], + ['generation', 'preparation'], + ['disconnect', 'preparation'], + ['stable', 'terminal creation'], + ['generation', 'terminal creation'], + ['stable', 'accepted send'], + ['generation', 'accepted send'], + ['generation without render', 'accepted send'], + ['host', 'accepted send'], + ['unmount', 'accepted send'], + ['generation', 'owned create accepted'] +])('pending resume keeps its owner: %s during %s', async (scenario, boundary) => { + const cutover = scenario !== 'stable' + let hostId = 'owned-host' + notifications.error.mockClear() + notifications.success.mockClear() + notifications.push.mockClear() + state.legacy = boundary === 'accepted send' + state.completedBoundary = state.legacy || boundary === 'owned create accepted' + state.generation = 1 + state.connection = 'connected' + state.session = { + id: 'qoder:owned-session', + executionHostId: 'local', + agent: 'qoder', + sessionId: 'owned-session', + title: 'Owned session', + cwd: '/owned/workspace', + branch: null, + model: null, + filePath: '/owned/qoder-session.json', + codexHome: null, + createdAt: null, + updatedAt: null, + modifiedAt: '2026-10-04T00:00:00.000Z', + messageCount: 2, + totalTokens: 10, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: '', + subagent: null + } + if (boundary === 'preparation' || boundary === 'terminal creation') { + state.session.agent = 'codex' + state.session.codexHome = '/Users/ada/Library/Application Support/orca/codex-runtime-home/home' + } + const worktrees = [ + { + worktreeId: 'owned-worktree', + repoId: 'owned-repo', + repo: 'Owned', + path: '/owned/workspace', + branch: 'main', + displayName: 'Owned', + liveTerminalCount: 0, + hasAttachedPty: false, + preview: '', + unread: false, + isPinned: false, + linkedPR: null + } + ] + const calls: { method: string; params: unknown; generation: number }[] = [] + let rejectOptional: (error: Error) => void = () => {} + let resolveOptional: (reply: RpcResponse) => void = () => {} + const pendingOptional = new Promise((resolve, reject) => { + rejectOptional = reject + resolveOptional = resolve + }) + const sendRequest: RpcClient['sendRequest'] = vi.fn( + async (method, params) => { + calls.push({ method, params, generation: state.generation }) + if (method === 'repo.list') { + return { + id: 'owned-reply', + ok: true, + result: { repos: [{ id: 'owned-repo', path: '/owned/workspace', connectionId: null }] } + } + } + if (method === 'folderWorkspace.list') { + return boundary === 'metadata' + ? pendingOptional + : { id: 'owned-reply', ok: true, result: { workspaces: [] } } + } + if (method === 'aiVault.prepareSessionResume') { + return boundary === 'preparation' + ? pendingOptional + : { id: 'owned-reply', ok: true, result: { useRealCodexHome: true } } + } + if (method === 'projectGroup.list') { + return { id: 'owned-reply', ok: true, result: { groups: [] } } + } + if (method === 'worktree.ps') { + return { id: 'owned-reply', ok: true, result: { worktrees } } + } + if (method === 'settings.get') { + return { id: 'owned-reply', ok: true, result: {} } + } + if (method === 'session.tabs.createTerminal') { + return boundary === 'terminal creation' || boundary === 'owned create accepted' + ? pendingOptional + : { + id: 'owned-reply', + ok: true, + result: { + tab: { type: 'terminal', id: 'owned-tab', terminal: 'owned-pty', title: 'Terminal' } + } + } + } + if (method === 'terminal.send') { + if (boundary === 'accepted send') { + return pendingOptional + } + return { id: 'owned-reply', ok: true, result: { send: { accepted: true } } } + } + throw new Error(`Unexpected method ${method}`) + } + ) + const client = { + ...createFakeRpcClient( + scenario === 'legacy reconnect' ? {} : { getGeneration: () => state.generation } + ), + sendRequest + } + state.client = client + const rendered: { tree: ReactTestRenderer | null } = { tree: null } + let resume: Promise | undefined + const panel = () => + React.createElement( + React.StrictMode, + null, + React.createElement(MobileAgentSessionHistoryPanel, { hostId, worktreeId: 'owned-worktree' }) + ) + await act(async () => { + rendered.tree = create(panel()) + }) + try { + await act(async () => { + if (!rendered.tree) { + throw new Error('missing panel') + } + if (!state.session || !state.onResume) { + throw new Error('missing resume callback') + } + resume = state.onResume(state.session) + }) + expect(calls.some((call) => call.method === 'repo.list')).toBe(true) + expect(calls.some((call) => call.method === 'folderWorkspace.list')).toBe(true) + if ( + boundary === 'terminal creation' || + boundary === 'owned create accepted' || + boundary === 'accepted send' + ) { + expect(calls.some((call) => call.method === 'session.tabs.createTerminal')).toBe(true) + } + if (boundary === 'preparation') { + expect(calls.some((call) => call.method === 'aiVault.prepareSessionResume')).toBe(true) + } + if (scenario === 'generation' || scenario === 'generation without render') { + state.generation = 2 + } + if (scenario === 'client') { + state.client = { ...createFakeRpcClient(), sendRequest } + } + if (scenario === 'host') { + hostId = 'replacement-host' + } + if (scenario === 'legacy reconnect' || scenario === 'disconnect') { + state.connection = 'reconnecting' + await act(async () => { + rendered.tree?.update(panel()) + }) + if (scenario === 'legacy reconnect') { + state.connection = 'connected' + } + } + if (scenario === 'unmount') { + await act(async () => { + rendered.tree?.unmount() + }) + } else if (scenario !== 'generation without render' && cutover) { + await act(async () => { + rendered.tree?.update(panel()) + }) + } + await act(async () => { + if (boundary === 'terminal creation' || boundary === 'owned create accepted') { + resolveOptional({ + id: 'owned-reply', + ok: true, + result: { + tab: { type: 'terminal', id: 'owned-tab', terminal: 'owned-pty', title: 'Terminal' } + } + }) + } else if (boundary === 'accepted send') { + resolveOptional({ id: 'owned-reply', ok: true, result: { send: { accepted: true } } }) + } else if (boundary === 'preparation' && !cutover) { + resolveOptional({ id: 'owned-reply', ok: true, result: { useRealCodexHome: true } }) + } else { + rejectOptional(new Error('client_disconnected')) + } + await resume + }) + const createCall = calls.find((call) => call.method === 'session.tabs.createTerminal') + if (boundary === 'accepted send' || boundary === 'owned create accepted') { + expect(createCall).toBeDefined() + expect(calls.filter((call) => call.method === 'terminal.send')).toHaveLength( + state.legacy ? 1 : 0 + ) + const completion = { + errors: notifications.error.mock.calls.length, + successes: notifications.success.mock.calls.length, + navigations: notifications.push.mock.calls.length + } + if (scenario !== 'unmount') { + await act(async () => { + if (!state.session || !state.onResume) { + throw new Error('missing retry') + } + await state.onResume(state.session) + }) + const creates = calls.filter((call) => call.method === 'session.tabs.createTerminal') + expect(creates).toHaveLength(2) + const originalCreate = CreateTerminalTab.parse(createCall?.params) + const retryCreate = CreateTerminalTab.parse(creates[1].params) + expect(originalCreate.clientMutationId).toEqual(expect.any(String)) + expect(retryCreate.clientMutationId).toEqual(expect.any(String)) + expect(retryCreate.clientMutationId).not.toBe(originalCreate.clientMutationId) + } + expect(completion).toEqual({ errors: 0, successes: 1, navigations: cutover ? 0 : 1 }) + } else if (cutover) { + if (boundary === 'terminal creation') { + expect(createCall).toBeDefined() + } else { + expect(createCall).toBeUndefined() + } + expect(calls.some((call) => call.method === 'terminal.send')).toBe(false) + } else { + expect(createCall, JSON.stringify(calls)).toBeDefined() + if (boundary === 'metadata') { + expect(createCall?.params).toEqual( + expect.objectContaining({ command: expect.stringContaining('owned-session') }) + ) + } else { + expect(createCall?.params).not.toHaveProperty('command') + expect(calls.some((call) => call.method === 'terminal.send')).toBe(true) + } + } + } finally { + await act(async () => rendered.tree?.unmount()) + } +}) diff --git a/mobile/src/agent-history/mobile-agent-history-resume-metadata.ts b/mobile/src/agent-history/mobile-agent-history-resume-metadata.ts new file mode 100644 index 00000000000..5b09eddb703 --- /dev/null +++ b/mobile/src/agent-history/mobile-agent-history-resume-metadata.ts @@ -0,0 +1,70 @@ +import { optionalSettingsRead } from '../transport/settings-read-operations' +import { interpretOrThrowRefusalMessage } from '../transport/rpc-refusal-message' +import { rpcPayloadMember } from '../transport/rpc-reader-payload' +import { readAcceptedResumeList } from './resume-metadata-lists' +import { + resumeFolderWorkspaceListRead, + resumeProjectGroupListRead, + resumeRepoListRead, + resumeWorktreeListRead +} from './mobile-agent-history-operations' +import type { RpcClient } from '../transport/rpc-client' +import type { Worktree } from '../worktree/workspace-list-types' +import type { MobileAiVaultResumeSettings } from '../session/ai-vault-resume-launch' +import { RESUME_RPC_TIMEOUT_MS } from '../session/ai-vault-resume-preparation' +import type { + MobileAiVaultResumeRepo, + MobileAiVaultResumeFolderWorkspace, + MobileAiVaultResumeProjectGroup +} from './agent-history-resume-target' + +export async function loadMobileResumeMetadata(client: RpcClient): Promise<{ + repos: MobileAiVaultResumeRepo[] + folderWorkspaces: MobileAiVaultResumeFolderWorkspace[] + projectGroups: MobileAiVaultResumeProjectGroup[] + settings: MobileAiVaultResumeSettings | null + worktrees: Worktree[] | null +}> { + // Why: repo.list can enrich repo remote identities, so fetch resume-only + // metadata after explicit user intent instead of delaying history browsing. + // timeoutMs: without it a socket drop parks these on the reconnect waiter + // for minutes, pinning the resume spinner (see RESUME_RPC_TIMEOUT_MS). + const [repoReply, folderWorkspaceReply, projectGroupReply, settingsReply, worktreeReply] = + await Promise.all([ + resumeRepoListRead.request(client, undefined, { timeoutMs: RESUME_RPC_TIMEOUT_MS }), + resumeFolderWorkspaceListRead + .request(client, undefined, { timeoutMs: RESUME_RPC_TIMEOUT_MS }) + .catch(() => null), + resumeProjectGroupListRead + .request(client, undefined, { timeoutMs: RESUME_RPC_TIMEOUT_MS }) + .catch(() => null), + optionalSettingsRead + .request(client, undefined, { timeoutMs: RESUME_RPC_TIMEOUT_MS }) + .catch(() => null), + resumeWorktreeListRead + .request(client, { limit: 10000 }, { timeoutMs: RESUME_RPC_TIMEOUT_MS }) + .catch(() => null) + ]) + const repoResult = interpretOrThrowRefusalMessage( + () => resumeRepoListRead.interpret(repoReply), + 'Unable to load workspace metadata.' + ) + const folderWorkspaceResult = + folderWorkspaceReply && resumeFolderWorkspaceListRead.interpret(folderWorkspaceReply) + const projectGroupResult = + projectGroupReply && resumeProjectGroupListRead.interpret(projectGroupReply) + const settingsResult = settingsReply ? optionalSettingsRead.interpret(settingsReply) : null + const settings = settingsResult?.accepted + ? // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + (settingsResult.value as MobileAiVaultResumeSettings | null | undefined) + : null + const worktreeResult = worktreeReply && resumeWorktreeListRead.interpret(worktreeReply) + return { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + repos: (rpcPayloadMember(repoResult, 'repos') as MobileAiVaultResumeRepo[] | undefined) ?? [], + folderWorkspaces: readAcceptedResumeList(folderWorkspaceResult, 'folderWorkspaces') ?? [], + projectGroups: readAcceptedResumeList(projectGroupResult, 'groups') ?? [], + settings: settings ?? null, + worktrees: readAcceptedResumeList(worktreeResult, 'worktrees') ?? null + } +} diff --git a/mobile/src/agent-history/use-mobile-agent-history-state.ts b/mobile/src/agent-history/use-mobile-agent-history-state.ts index d69262acff6..f73ec61a8a1 100644 --- a/mobile/src/agent-history/use-mobile-agent-history-state.ts +++ b/mobile/src/agent-history/use-mobile-agent-history-state.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useHostClient, useForceReconnect } from '../transport/client-context' +import type { RpcClient } from '../transport/rpc-client' import { connectionRetryAction } from '../transport/connection-retry-action' import type { AiVaultScanIssue, @@ -43,7 +44,20 @@ export function useMobileAgentHistoryState(params: MobileAgentHistoryStateParams const forceReconnect = useForceReconnect() const [scope, setScope] = useState('workspace') const [screenState, setScreenState] = useState({ kind: 'loading' }) - const [hostStatusResult, setHostStatusResult] = useState(null) + const clientGeneration = client?.getGeneration?.() ?? 0 + const [hostStatusProbe, setHostStatusProbe] = useState<{ + hostId: string + client: RpcClient + generation: number + result: unknown + } | null>(null) + const hostStatusResult = + connState === 'connected' && + hostStatusProbe?.hostId === hostId && + hostStatusProbe.client === client && + hostStatusProbe.generation === clientGeneration + ? hostStatusProbe.result + : null const [refreshing, setRefreshing] = useState(false) const generationRef = useRef(0) const mountedRef = useRef(true) @@ -72,10 +86,14 @@ export function useMobileAgentHistoryState(params: MobileAgentHistoryStateParams async (options: { scope: AiVaultScope; force: boolean }): Promise => { const generation = generationRef.current + 1 generationRef.current = generation - const isCurrent = () => mountedRef.current && generationRef.current === generation + const isCurrent = () => + mountedRef.current && + generationRef.current === generation && + (client?.getGeneration?.() ?? 0) === clientGeneration if (!client || connState !== 'connected') { if (isCurrent()) { + setHostStatusProbe(null) // Why: keep the stale list visible through transient reconnects // (connState flips re-run the load effect) instead of tearing it // down to a full-screen error, matching the host list screen. @@ -98,7 +116,7 @@ export function useMobileAgentHistoryState(params: MobileAgentHistoryStateParams () => agentHistoryHostStatusRead.interpret(statusReply), 'Unable to reach host' ) - setHostStatusResult(status) + setHostStatusProbe({ hostId, client, generation: clientGeneration, result: status }) if (!status.capabilities?.includes(MOBILE_AI_VAULT_CAPABILITY)) { setScreenState({ kind: 'unsupported' }) return @@ -140,11 +158,11 @@ export function useMobileAgentHistoryState(params: MobileAgentHistoryStateParams return } const message = err instanceof Error ? err.message : 'Unable to load agent sessions' - setHostStatusResult(null) + setHostStatusProbe(null) setScreenState({ kind: 'error', message }) } }, - [activeWorktree, client, connState, worktrees, worktreesLoaded] + [activeWorktree, client, clientGeneration, connState, hostId, worktrees, worktreesLoaded] ) // Initial + reconnect load. Why: scope switches reuse the host's 15s cache diff --git a/mobile/src/agent-history/use-mobile-resume-operation-ownership.ts b/mobile/src/agent-history/use-mobile-resume-operation-ownership.ts new file mode 100644 index 00000000000..bf93486483d --- /dev/null +++ b/mobile/src/agent-history/use-mobile-resume-operation-ownership.ts @@ -0,0 +1,37 @@ +import { useCallback, useLayoutEffect, useMemo, useRef } from 'react' +import type { RpcClient } from '../transport/rpc-client' +import type { ConnectionState } from '../transport/types' + +export function useMobileResumeOperationOwnership( + hostId: string, + worktreeId: string, + client: RpcClient | null, + connection: ConnectionState +) { + const generation = client?.getGeneration?.() + const owner = useMemo( + () => ({ hostId, worktreeId, client, connection, generation }), + [hostId, worktreeId, client, connection, generation] + ) + const currentOwner = useRef(owner) + useLayoutEffect(() => { + currentOwner.current = owner + return () => { + currentOwner.current = null + } + }, [owner]) + return useCallback( + () => () => { + if ( + currentOwner.current !== owner || + owner.connection !== 'connected' || + !owner.client || + owner.client.getState() !== 'connected' || + owner.client.getGeneration?.() !== owner.generation + ) { + throw new Error('Host connection changed. Retry resume.') + } + }, + [owner] + ) +} diff --git a/mobile/src/session/ai-vault-resume-launch.test.ts b/mobile/src/session/ai-vault-resume-launch.test.ts index d588aea30a2..5f709d5aaf2 100644 --- a/mobile/src/session/ai-vault-resume-launch.test.ts +++ b/mobile/src/session/ai-vault-resume-launch.test.ts @@ -5,6 +5,7 @@ import { buildMobileAiVaultResumeLaunch, buildMobileAiVaultResumeCommand, createMobileAiVaultResumeMutationRegistry, + readMobileAiVaultResumeHost, readMobileRuntimeTerminalWindowsShell, resolveMobileAiVaultResumePlatform, resumeAiVaultSessionInTerminal @@ -242,6 +243,75 @@ describe('buildMobileAiVaultResumeLaunch', () => { }) describe('resumeAiVaultSessionInTerminal', () => { + it.each(['darwin', 'linux', 'win32'] as const)( + 'lets the %s execution host select the Qoder resume command at creation', + async (hostPlatform) => { + const launch = buildMobileAiVaultResumeLaunch({ + session: session({ agent: 'qoder', sessionId: 'same-qoder-session' }), + hostPlatform + }) + const sendRequest = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + result: { tab: { type: 'terminal', id: 'tab-1', terminal: 'pty-1', title: 'Terminal' } } + }) + .mockResolvedValueOnce({ ok: true, result: { send: { accepted: true } } }) + await resumeAiVaultSessionInTerminal({ sendRequest }, 'worktree-1', { + ...launch, + clientMutationId: 'qoder-resume', + hostCapabilities: ['session.tabs.qoderOwnedCreate.v1'] + }) + expect(sendRequest).toHaveBeenCalledTimes(1) + expect(sendRequest).toHaveBeenCalledWith( + 'session.tabs.createTerminal', + expect.objectContaining({ + command: launch.command, + launchAgent: 'qoder', + launchConfig: launch.launchConfig + }), + { timeoutMs: RESUME_RPC_TIMEOUT_MS } + ) + expect(launch.command).toContain('same-qoder-session') + } + ) + + it.each( + [undefined, [], ['aiVault.v1']].flatMap((hostCapabilities) => + (['darwin', 'linux', 'win32'] as const).map((hostPlatform) => ({ + hostCapabilities, + hostPlatform + })) + ) + )( + 'keeps the acknowledged-create fallback on an older $hostPlatform host', + async ({ hostCapabilities, hostPlatform }) => { + const launch = buildMobileAiVaultResumeLaunch({ + session: session({ agent: 'qoder', sessionId: 'legacy-session' }), + hostPlatform + }) + const sendRequest = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + result: { tab: { type: 'terminal', id: 'tab-1', terminal: 'pty-1', title: 'Terminal' } } + }) + .mockResolvedValueOnce({ ok: true, result: { send: { accepted: true } } }) + await resumeAiVaultSessionInTerminal({ sendRequest }, 'worktree-1', { + ...launch, + hostCapabilities, + clientMutationId: 'same-legacy-resume' + }) + expect(sendRequest).toHaveBeenCalledTimes(2) + expect(sendRequest.mock.calls[0]?.[1]).not.toHaveProperty('command') + expect(sendRequest.mock.calls[1]).toEqual([ + 'terminal.send', + { terminal: 'pty-1', text: launch.command, enter: true }, + { timeoutMs: RESUME_RPC_TIMEOUT_MS } + ]) + } + ) + it('creates a fresh terminal and sends the command with Enter', async () => { const sendRequest = vi .fn() @@ -401,3 +471,19 @@ describe('resume platform helpers', () => { expect(resolveMobileAiVaultResumePlatform('runtime', 'linux')).toBeNull() }) }) + +it.each([null, {}, { capabilities: 'session.tabs.qoderOwnedCreate.v1' }, { capabilities: [1] }])( + 'does not infer safe Qoder creation from unreadable host capabilities', + (status) => { + expect(readMobileAiVaultResumeHost(status).capabilities).toBeUndefined() + } +) + +it('reads the execution platform and advertised safe-create capability together', () => { + expect( + readMobileAiVaultResumeHost({ + hostPlatform: 'win32', + capabilities: ['session.tabs.qoderOwnedCreate.v1'] + }) + ).toEqual({ platform: 'win32', capabilities: ['session.tabs.qoderOwnedCreate.v1'] }) +}) diff --git a/mobile/src/session/ai-vault-resume-launch.ts b/mobile/src/session/ai-vault-resume-launch.ts index fb99f199b79..95005666872 100644 --- a/mobile/src/session/ai-vault-resume-launch.ts +++ b/mobile/src/session/ai-vault-resume-launch.ts @@ -21,6 +21,17 @@ import { interpretOrThrowRefusalMessage } from '../transport/rpc-refusal-message import { reviewTerminalCreateRun, reviewTerminalSendRun } from './mobile-review-terminal-operations' import type { MobileReviewTerminalTab } from './review-terminal-reply-schema' import type { MobileAiVaultResumeTargetStatus } from '../agent-history/agent-history-resume-target' +import { QODER_OWNED_TERMINAL_CREATE_CAPABILITY } from '../../../src/shared/qoder-terminal-create-capability' +import { agentHistoryHostStatusSchema } from '../agent-history/agent-history-reply-schema' +import { readMobileRuntimeHostPlatform } from '../transport/mobile-runtime-host-platform' + +export function readMobileAiVaultResumeHost(statusResult: unknown) { + const status = agentHistoryHostStatusSchema.safeParse(statusResult) + return { + platform: readMobileRuntimeHostPlatform(statusResult), + capabilities: status.success ? status.data.capabilities : undefined + } +} export function buildMobileAiVaultResumeCommand(args: { session: Pick & @@ -151,14 +162,25 @@ function normalizeMobileAiVaultResumeCommandOverrides( export async function resumeAiVaultSessionInTerminal( client: RpcOperationSender, worktreeId: string, - launch: MobileAiVaultResumeLaunch & { clientMutationId?: string } + launch: MobileAiVaultResumeLaunch & { + clientMutationId?: string + hostCapabilities?: readonly string[] + }, + assertCurrentOwner?: () => void ): Promise { + assertCurrentOwner?.() + // Qoder's execution host must select its installed executable before the resume starts. + const launchAtCreate = + launch.launchAgent === 'qoder' && + Boolean(launch.clientMutationId) && + launch.hostCapabilities?.includes(QODER_OWNED_TERMINAL_CREATE_CAPABILITY) === true // Each request is awaited outside its catch so a transport drop propagates as the original error // object; only a refusal is rewritten into this step's own copy. const created = await reviewTerminalCreateRun.request( client, { worktree: `id:${worktreeId}`, + ...(launchAtCreate ? { command: launch.command } : {}), ...(launch.env ? { env: launch.env } : {}), ...(launch.envToDelete ? { envToDelete: launch.envToDelete } : {}), ...(launch.launchConfig ? { launchConfig: launch.launchConfig } : {}), @@ -175,6 +197,10 @@ export async function resumeAiVaultSessionInTerminal( () => reviewTerminalCreateRun.interpret(created), 'Failed to create terminal' ) + if (launchAtCreate) { + return terminalTab + } + assertCurrentOwner?.() const sent = await reviewTerminalSendRun.request( client, { diff --git a/mobile/src/test-support/rpc-recording/adapters/settings-mount-adapters.ts b/mobile/src/test-support/rpc-recording/adapters/settings-mount-adapters.ts index 5c6fe0ef14d..3de45356c27 100644 --- a/mobile/src/test-support/rpc-recording/adapters/settings-mount-adapters.ts +++ b/mobile/src/test-support/rpc-recording/adapters/settings-mount-adapters.ts @@ -1,16 +1,11 @@ +import type * as ResumeMetadata from '../../../agent-history/mobile-agent-history-resume-metadata' import type { OperationExposure } from '../operation-module-loader' import type { MountAdapter } from '../recording-scenario' import { hookMount } from '../hook-mount' import { observableModel, projectObservable } from '../observable-model' import { operationModuleLoader } from '../operation-module-loader' -/** `loadMobileResumeMetadata` is module-private in the panel; exposing it beats editing pinned source. */ -export const settingsMountExposures: readonly OperationExposure[] = [ - [ - 'MobileAgentSessionHistoryPanel.tsx', - '\nexports.loadMobileResumeMetadata = loadMobileResumeMetadata;' - ] -] +export const settingsMountExposures: readonly OperationExposure[] = [] export function settingsMountAdapters( modules: ReturnType @@ -110,8 +105,8 @@ export function settingsMountAdapters( } }, 'settings.resume-metadata': ({ client }) => { - const load = modules.load( - 'mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx' + const load = modules.load( + 'mobile/src/agent-history/mobile-agent-history-resume-metadata.ts' ).loadMobileResumeMetadata return { action: () => load(client), state: () => ({}), dispose: () => {} } }, diff --git a/src/main/ipc/preflight-remote-ssh.test.ts b/src/main/ipc/preflight-remote-ssh.test.ts index 4ad02ec5d04..6fad3ec344f 100644 --- a/src/main/ipc/preflight-remote-ssh.test.ts +++ b/src/main/ipc/preflight-remote-ssh.test.ts @@ -92,9 +92,33 @@ vi.mock('../gitea/client', () => ({ })) import { registerPreflightHandlers } from './preflight' +import { detectAgentCommandsOnHost } from '../preflight/agent-detection' import { resetPreflightMocks, type HandlerMap } from './preflight-test-harness' describe('preflight', () => { + it('resolves exact Qoder command labels on the SSH host using the existing request', async () => { + const request = vi.fn().mockResolvedValue({ agents: ['qoder', 'unrelated'] }) + getActiveMultiplexerMock.mockReturnValue({ isDisposed: () => false, request }) + await expect( + detectAgentCommandsOnHost(['qodercli', 'qoder'], { connectionId: 'ssh-qoder' }) + ).resolves.toEqual(new Set(['qoder'])) + expect(request).toHaveBeenCalledWith('preflight.detectAgents', { + commands: [ + { id: 'qodercli', cmd: 'qodercli' }, + { id: 'qoder', cmd: 'qoder' } + ] + }) + expect(isCommandOnLocalPathMock).not.toHaveBeenCalled() + }) + + it('refuses command selection after SSH contact loss without probing the local host', async () => { + getActiveMultiplexerMock.mockReturnValue(null) + await expect( + detectAgentCommandsOnHost(['qodercli', 'qoder'], { connectionId: 'ssh-qoder' }) + ).rejects.toThrow('execution host connection') + expect(isCommandOnLocalPathMock).not.toHaveBeenCalled() + }) + const originalPlatform = process.platform const handlers: HandlerMap = {} diff --git a/src/main/ipc/pty-ipc-suite-environment.ts b/src/main/ipc/pty-ipc-suite-environment.ts index c7256a57f3e..fbab71bead1 100644 --- a/src/main/ipc/pty-ipc-suite-environment.ts +++ b/src/main/ipc/pty-ipc-suite-environment.ts @@ -58,6 +58,7 @@ import { _resetWslCachesForTests } from '../wsl' /** The mocked webContents each suite asserts sends against. */ export type PtyIpcTestWebContents = { + id: number on: Mock send: Mock removeListener: Mock @@ -92,6 +93,7 @@ export function createPtyIpcSuiteEnvironment(): PtyIpcSuiteEnvironment { isVisible: () => true, isMinimized: () => false, webContents: { + id: 1, on: vi.fn(), send: vi.fn(), removeListener: vi.fn(), @@ -101,6 +103,7 @@ export function createPtyIpcSuiteEnvironment(): PtyIpcSuiteEnvironment { const mainWindowIpcEvent = { sender: mainWindow.webContents } const foreignWindowIpcEvent = { sender: { + id: 2, on: vi.fn(), send: vi.fn(), removeListener: vi.fn(), diff --git a/src/main/ipc/pty/ipc/spawn-qoder-command.ts b/src/main/ipc/pty/ipc/spawn-qoder-command.ts new file mode 100644 index 00000000000..27106c6bf63 --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-qoder-command.ts @@ -0,0 +1,34 @@ +import { resolveQoderTerminalCommand } from '../../../runtime/qoder-terminal-command-resolution' +import { resolveStartupShell } from '../../../../shared/tui-agent-startup-shell' +import { resolveLocalWindowsAgentStartupShell } from '../../../../shared/windows-terminal-shell' +import type { PtyIpcSpawnState } from './spawn-state' + +export async function preparePtyIpcQoderCommand(ctx: PtyIpcSpawnState): Promise { + if (ctx.preAdoptedStablePane || ctx.args.launchAgent !== 'qoder') { + return + } + const connectionId = ctx.args.connectionId + const isWsl = ctx.codexSelectionTarget.runtime === 'wsl' + const platform = connectionId || isWsl ? 'linux' : process.platform + const launch = await resolveQoderTerminalCommand( + { + launchAgent: ctx.args.launchAgent, + command: ctx.launchCommand, + launchConfig: ctx.effectiveLaunchConfig + }, + { + connectionId, + context: connectionId ? undefined : { wslDistro: ctx.expectedWslDistro, wslDefault: isWsl }, + shell: resolveStartupShell( + platform, + resolveLocalWindowsAgentStartupShell({ + platform, + isRemote: Boolean(connectionId), + terminalWindowsShell: ctx.effectiveShellOverride + }) + ) + } + ) + ctx.launchCommand = launch.command + ctx.effectiveLaunchConfig = launch.launchConfig +} diff --git a/src/main/ipc/pty/ipc/spawn-run.ts b/src/main/ipc/pty/ipc/spawn-run.ts index 7efeb23538d..d6abc77edce 100644 --- a/src/main/ipc/pty/ipc/spawn-run.ts +++ b/src/main/ipc/pty/ipc/spawn-run.ts @@ -3,6 +3,7 @@ import { ptySizes } from '../delivery/visibility-state' import { beginPtyIpcSpawn, resolveEarlyPaneSpawnReservationKey } from './spawn-begin' import { preparePtyIpcSpawnPreflight } from './spawn-preflight' import { assemblePtyIpcSpawnEnv } from './spawn-env' +import { preparePtyIpcQoderCommand } from './spawn-qoder-command' import { buildPtyIpcSpawnOptions } from './spawn-options' import { executePtyIpcSpawn } from './spawn-execute' import { commitPtyIpcSpawn } from './spawn-commit' @@ -53,6 +54,7 @@ export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArg } await preparePtyIpcSpawnPreflight(ctx) await assemblePtyIpcSpawnEnv(ctx) + await preparePtyIpcQoderCommand(ctx) const earlyReserved = await buildPtyIpcSpawnOptions(ctx).catch((error: unknown) => { restoreProvisionalPtySize(ctx) throw error diff --git a/src/main/ipc/qoder-desktop-execution-host.test.ts b/src/main/ipc/qoder-desktop-execution-host.test.ts new file mode 100644 index 00000000000..9d5955d9835 --- /dev/null +++ b/src/main/ipc/qoder-desktop-execution-host.test.ts @@ -0,0 +1,299 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { createDaemonActiveProviderFixtures } from './pty-ipc-daemon-provider-fixtures' +import { + registerPtyHandlers, + registerSshPtyProvider, + unregisterSshPtyProvider, + getLocalPtyProvider +} from './pty' +import { isCommandOnPath } from './preflight-command-exec' +import { detectWslCommandsOnPath } from './preflight-wsl-agent-detection' +import type * as FsPromises from 'node:fs/promises' +import type { Store } from '../persistence' +import type { FolderWorkspace } from '../../shared/folder-workspace-types' +import { buildAgentResumeStartupPlan } from '../../shared/tui-agent-startup' +import type { PtySpawnIpcArgs } from './pty/ipc/spawn-types' + +vi.mock('./preflight-command-exec', () => ({ isCommandOnPath: vi.fn() })) +vi.mock('./local-agent-install-dir-detection', () => ({ + detectCommandsInInstallDirs: () => new Set() +})) +vi.mock('./preflight-wsl-agent-detection', () => ({ detectWslCommandsOnPath: vi.fn() })) +const { mux } = vi.hoisted(() => ({ mux: vi.fn() })) +vi.mock('../ssh/ssh-target-registry', () => ({ getActiveMultiplexer: mux })) +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + stat: vi.fn(async () => ({ isDirectory: () => true })) +})) +vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) +vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) +vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock())) +vi.mock('node:child_process', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal()) +) +vi.mock('../opencode/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock()) +) +vi.mock('../mimo/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock()) +) +vi.mock('../agent-hooks/server', () => + import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock()) +) +vi.mock('../pi/titlebar-extension-service', () => + import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock()) +) +vi.mock('../pwsh', () => import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock())) +vi.mock('../wsl', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal()) +) +vi.mock('../telemetry/client', () => + import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock()) +) +vi.mock('../telemetry/classify-error', () => + import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock()) +) +vi.mock('../cli/linux-terminal-orca-cli-shim', () => + import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock()) +) +vi.mock('../memory/pty-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock()) +) +vi.mock('../agent-hooks/migration-unsupported-pty-state', () => + import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock()) +) +vi.mock('../codex/codex-pane-account-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock()) +) +vi.mock('../codex/codex-state-db-backfill-recovery', () => + import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) +) + +describe('desktop Qoder execution-host selection', () => { + const { handlers, mainWindow } = setupPtyIpcSuite() + const { setupDaemonAdapter, withWin32Platform } = createDaemonActiveProviderFixtures({ + handlers, + mainWindow + }) + const nativeProbe = vi.mocked(isCommandOnPath) + const wslProbe = vi.mocked(detectWslCommandsOnPath) + beforeEach(() => { + nativeProbe.mockReset().mockImplementation(async (cmd) => cmd === 'qodercli') + wslProbe.mockReset().mockResolvedValue(new Set(['qoder'])) + mux.mockReset() + }) + async function spawn(args: Partial = {}, store?: Store) { + const providerSpawn = setupDaemonAdapter() + registerPtyHandlers(mainWindow, undefined, undefined, undefined, undefined, store) + const result = await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: process.cwd(), + worktreeId: `repo-review::${process.cwd()}`, + launchAgent: 'qoder', + command: 'qodercli --resume original-id', + ...args + }) + return { providerSpawn, result } + } + function folderStore(folderPath: string): Store { + const folder: FolderWorkspace = { + id: 'review-folder', + projectGroupId: 'review-group', + name: 'review', + folderPath, + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + createdAt: 0, + updatedAt: 0 + } + const store: Pick< + Store, + 'getFolderWorkspace' | 'getFolderWorkspaces' | 'getProjectGroups' | 'getRepos' + > = { + getFolderWorkspace: () => folder, + getFolderWorkspaces: () => [folder], + getProjectGroups: () => [], + getRepos: () => [] + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: These launches omit pane metadata; only folder path/status methods are reached, trust is disabled by the suite. + return store as Store + } + it.each([ + ['legacy-only', ['qodercli']], + ['both', ['qoder', 'qodercli']] + ] as const)('keeps legacy preference on %s native hosts', async (_, commands) => { + nativeProbe.mockImplementation(async (cmd) => commands.some((found) => found === cmd)) + const { providerSpawn } = await spawn() + expect(providerSpawn).toHaveBeenCalledWith( + expect.objectContaining({ command: 'qodercli --resume original-id' }) + ) + expect(nativeProbe).toHaveBeenCalledWith('qodercli') + expect(nativeProbe).toHaveBeenCalledWith('qoder') + expect(wslProbe).not.toHaveBeenCalled() + }) + it('preserves exact resume tokens and captured configuration on a native folder', async () => { + nativeProbe.mockImplementation(async (cmd) => cmd === 'qoder') + const plan = buildAgentResumeStartupPlan({ + agent: 'qoder', + providerSession: { key: 'session_id', id: 'session with spaces' }, + platform: 'darwin', + cmdOverrides: {} + }) + if (!plan) { + throw new Error('Missing Qoder resume plan') + } + const { providerSpawn, result } = await spawn( + { + worktreeId: 'folder:review-folder', + command: plan.launchCommand, + launchConfig: plan.launchConfig + }, + folderStore(process.cwd()) + ) + expect(providerSpawn).toHaveBeenCalledWith( + expect.objectContaining({ + command: "qoder '--resume' 'session with spaces'", + cwd: process.cwd(), + worktreeId: 'folder:review-folder' + }) + ) + expect(result).toMatchObject({ launchConfig: { ...plan.launchConfig, agentCommand: 'qoder' } }) + }) + it.each([ + { command: "echo 'qodercli --resume text'", launchAgent: 'qoder' }, + { command: 'qodercli --resume original-id', launchAgent: undefined }, + { command: '/caller/qodercli --resume original-id', launchAgent: 'qoder' }, + { command: 'qoder --resume original-id', launchAgent: 'qoder' } + ] as const)('leaves caller and shell commands unchanged: $command', async (args) => { + const { providerSpawn } = await spawn(args) + expect(providerSpawn).toHaveBeenCalledWith(expect.objectContaining({ command: args.command })) + expect(nativeProbe).not.toHaveBeenCalled() + expect(wslProbe).not.toHaveBeenCalled() + expect(mux).not.toHaveBeenCalled() + }) + it('uses the selected project WSL distro rather than installed native legacy', async () => { + await withWin32Platform(async () => { + const { providerSpawn } = await spawn({ + cwd: '/work/project', + worktreeId: 'repo-review::/work/project', + projectRuntime: { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'project-review', + distro: 'Debian', + reason: 'project-override', + cacheKey: 'review' + } + } + }) + expect(providerSpawn).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'qoder --resume original-id', + shellOverride: 'wsl.exe', + terminalWindowsWslDistro: 'Debian' + }) + ) + expect(wslProbe).toHaveBeenCalledWith({ distro: 'Debian' }, ['qodercli', 'qoder']) + expect(nativeProbe).not.toHaveBeenCalled() + }) + }) + it('retains a repo-less UNC folder execution distro', async () => { + await withWin32Platform(async () => { + const { providerSpawn } = await spawn( + { + cwd: String.raw`\\wsl.localhost\Ubuntu\home\review\folder`, + worktreeId: 'folder:review-folder', + shellOverride: 'wsl.exe' + }, + folderStore(String.raw`\\wsl.localhost\Ubuntu\home\review\folder`) + ) + expect(providerSpawn).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'qoder --resume original-id', + terminalWindowsWslDistro: 'Ubuntu', + cwd: '//wsl.localhost/Ubuntu/home/review/folder' + }) + ) + expect(wslProbe).toHaveBeenCalledWith({ distro: 'Ubuntu' }, ['qodercli', 'qoder']) + expect(nativeProbe).not.toHaveBeenCalled() + }) + }) + it('keeps native Windows launches on the host shell', async () => { + await withWin32Platform(async () => { + nativeProbe.mockImplementation(async (cmd) => cmd === 'qoder') + const { providerSpawn } = await spawn({ + cwd: 'C:\\review', + worktreeId: 'repo-review::C:\\review', + shellOverride: 'cmd.exe' + }) + expect(providerSpawn).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'qoder --resume original-id', + shellOverride: 'cmd.exe', + terminalWindowsWslDistro: null + }) + ) + expect(wslProbe).not.toHaveBeenCalled() + }) + }) + it.each(['modern-only', 'legacy-only', 'missing', 'disposed'] as const)( + 'lets SSH own detection and refuse absent contact: %s', + async (host) => { + const remoteSpawn = setupDaemonAdapter() + registerSshPtyProvider('ssh-qoder-review', getLocalPtyProvider()) + const localSpawn = setupDaemonAdapter() + const request = vi + .fn() + .mockResolvedValue({ agents: [host === 'modern-only' ? 'qoder' : 'qodercli'] }) + mux.mockReturnValue( + host === 'missing' ? null : { isDisposed: () => host === 'disposed', request } + ) + registerPtyHandlers(mainWindow) + try { + const pending = handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/remote/folder', + worktreeId: 'repo-ssh::/remote/folder', + connectionId: 'ssh-qoder-review', + launchAgent: 'qoder', + command: "qodercli '--resume' 'same remote id'" + }) + if (host === 'missing' || host === 'disposed') { + await expect(pending).rejects.toThrow('execution host connection') + expect(remoteSpawn).not.toHaveBeenCalled() + expect(request).not.toHaveBeenCalled() + } else { + await pending + expect(remoteSpawn).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: '/remote/folder', + command: `${host === 'modern-only' ? 'qoder' : 'qodercli'} '--resume' 'same remote id'` + }) + ) + expect(request).toHaveBeenCalledWith('preflight.detectAgents', { + commands: [ + { id: 'qodercli', cmd: 'qodercli' }, + { id: 'qoder', cmd: 'qoder' } + ] + }) + } + expect(localSpawn).not.toHaveBeenCalled() + expect(nativeProbe).not.toHaveBeenCalled() + expect(wslProbe).not.toHaveBeenCalled() + expect(mux).toHaveBeenCalledWith('ssh-qoder-review') + } finally { + unregisterSshPtyProvider('ssh-qoder-review') + } + } + ) +}) diff --git a/src/main/ipc/qoder-independent-renderer-spawn-review.test.ts b/src/main/ipc/qoder-independent-renderer-spawn-review.test.ts new file mode 100644 index 00000000000..a9c20cc9ad3 --- /dev/null +++ b/src/main/ipc/qoder-independent-renderer-spawn-review.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it, vi } from 'vitest' +import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { createDaemonActiveProviderFixtures } from './pty-ipc-daemon-provider-fixtures' +import { registerPtyHandlers } from './pty' +import { detectAgentCommandsOnHost } from '../preflight/agent-detection' +import { buildAgentStartupPlan, buildAgentResumeStartupPlan } from '../../shared/tui-agent-startup' +import { agentStartedTelemetry } from '../agent-launch/agent-started-telemetry' +import { trackMock } from './pty-ipc-mock-registry' + +vi.mock('../preflight/agent-detection', () => ({ + detectAgentCommandsOnHost: vi.fn(async () => new Set(['qoder'])) +})) +vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) +vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) +vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock())) +vi.mock('node:child_process', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal()) +) +vi.mock('../opencode/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock()) +) +vi.mock('../mimo/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock()) +) +vi.mock('../agent-hooks/server', () => + import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock()) +) +vi.mock('../pi/titlebar-extension-service', () => + import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock()) +) +vi.mock('../pwsh', () => import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock())) +vi.mock('../wsl', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal()) +) +vi.mock('../telemetry/client', () => + import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock()) +) +vi.mock('../telemetry/classify-error', () => + import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock()) +) +vi.mock('../cli/linux-terminal-orca-cli-shim', () => + import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock()) +) +vi.mock('../memory/pty-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock()) +) +vi.mock('../agent-hooks/migration-unsupported-pty-state', () => + import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock()) +) +vi.mock('../codex/codex-pane-account-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock()) +) +vi.mock('../codex/codex-state-db-backfill-recovery', () => + import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) +) + +describe('independent renderer Qoder provider spawn boundary', () => { + const { handlers, mainWindow } = setupPtyIpcSuite() + const { setupDaemonAdapter } = createDaemonActiveProviderFixtures({ handlers, mainWindow }) + it.each(['start', 'resume'] as const)( + 'selects modern-only executable for desktop %s', + async (mode) => { + const plan = + mode === 'start' + ? buildAgentStartupPlan({ + agent: 'qoder', + prompt: 'review prompt', + agentArgs: null, + cmdOverrides: {}, + platform: 'darwin' + }) + : buildAgentResumeStartupPlan({ + agent: 'qoder', + providerSession: { key: 'session_id', id: 'original-review-session' }, + cmdOverrides: {}, + platform: 'darwin' + }) + if (!plan) { + throw new Error('Missing existing Qoder startup plan') + } + const physicalSpawn = setupDaemonAdapter() + registerPtyHandlers(mainWindow) + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: process.cwd(), + command: plan.launchCommand, + launchAgent: 'qoder', + launchConfig: plan.launchConfig, + ...(mode === 'start' ? { telemetry: agentStartedTelemetry('qoder', 'orchestration') } : {}) + }) + expect(physicalSpawn.mock.calls.at(-1)?.[0].command).toBe( + plan.launchCommand.replace(/^qodercli/, 'qoder') + ) + expect(detectAgentCommandsOnHost).toHaveBeenCalled() + const events = trackMock.mock.calls.filter(([event]) => event === 'agent_started') + if (mode === 'start') { + expect(events).toEqual([ + [ + 'agent_started', + { agent_kind: 'qoder', launch_source: 'orchestration', request_kind: 'new' } + ] + ]) + } else { + expect(events).toEqual([]) + } + } + ) + it.each([ + 'qoder --resume original-review-session', + '/caller/qodercli --resume original-review-session' + ])('preserves an explicitly chosen command: %s', async (command) => { + const physicalSpawn = setupDaemonAdapter() + registerPtyHandlers(mainWindow) + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: process.cwd(), + command, + launchAgent: 'qoder' + }) + expect(physicalSpawn.mock.calls.at(-1)?.[0].command).toBe(command) + expect(trackMock).not.toHaveBeenCalledWith('agent_started', expect.anything()) + }) +}) diff --git a/src/main/ipc/tui-agent-detection-commands.test.ts b/src/main/ipc/tui-agent-detection-commands.test.ts index b6d536f732b..4d1ca779012 100644 --- a/src/main/ipc/tui-agent-detection-commands.test.ts +++ b/src/main/ipc/tui-agent-detection-commands.test.ts @@ -6,6 +6,24 @@ import { } from './tui-agent-detection-commands' describe('tui agent detection commands', () => { + it.each(['darwin', 'linux', 'win32', 'wsl'] as const)( + 'detects modern and legacy Qoder once on %s', + (runtime) => { + const commands = KNOWN_TUI_AGENT_DETECTION_COMMANDS.filter( + (command) => command.id === 'qoder' + ) + for (const found of [ + new Set(['qoder']), + new Set(['qodercli']), + new Set(['qoder', 'qodercli']) + ]) { + expect(resolveDetectedTuiAgentIds(commands, found, runtime)).toEqual(['qoder']) + } + expect(resolveDetectedTuiAgentIds(commands, new Set(['qoder-unrelated']), runtime)).toEqual( + [] + ) + } + ) it('requires Claude before reporting Claude Agent Teams', () => { const commands = KNOWN_TUI_AGENT_DETECTION_COMMANDS.filter( (command) => command.id === 'claude-agent-teams' diff --git a/src/main/preflight/agent-command-detection.ts b/src/main/preflight/agent-command-detection.ts new file mode 100644 index 00000000000..3712036337c --- /dev/null +++ b/src/main/preflight/agent-command-detection.ts @@ -0,0 +1,48 @@ +import { z } from 'zod' +import { getActiveMultiplexer } from '../ssh/ssh-target-registry' +import { detectWslCommandsOnPath } from '../ipc/preflight-wsl-agent-detection' +import { detectCommandsInInstallDirs } from '../ipc/local-agent-install-dir-detection' +import { + getPreflightWslTarget, + type PreflightRuntimeContext +} from '../ipc/preflight-runtime-target' +import { isCommandOnPath } from '../ipc/preflight-command-exec' + +export async function detectAgentCommandsOnHost( + commands: readonly string[], + options: { connectionId?: string | null; context?: PreflightRuntimeContext } = {} +): Promise> { + if (options.connectionId) { + const mux = getActiveMultiplexer(options.connectionId) + if (!mux || mux.isDisposed()) { + throw new Error('Agent command resolution requires the execution host connection.') + } + const result = z.object({ agents: z.array(z.string()) }).parse( + await mux.request('preflight.detectAgents', { + commands: commands.map((cmd) => ({ id: cmd, cmd })) + }) + ) + return new Set(result.agents.filter((cmd) => commands.includes(cmd))) + } + const context = options.context + const wslTarget = getPreflightWslTarget(context) + if (wslTarget) { + return detectWslCommandsOnPath(wslTarget, commands) + } + const pathChecks = await Promise.all( + commands.map(async (cmd) => ({ + cmd, + installedOnPath: await isCommandOnPath(cmd) + })) + ) + const missedCommands = pathChecks.filter((check) => !check.installedOnPath).map(({ cmd }) => cmd) + // Why: PATH may still be unhydrated on a cold GUI launch; bulk resolution + // computes user install dirs once instead of blocking once per missed CLI. + const installDirCommands = detectCommandsInInstallDirs(missedCommands) + const foundCommands = new Set( + pathChecks + .filter(({ cmd, installedOnPath }) => installedOnPath || installDirCommands.has(cmd)) + .map(({ cmd }) => cmd) + ) + return foundCommands +} diff --git a/src/main/preflight/agent-detection.ts b/src/main/preflight/agent-detection.ts index c06f9ee7f12..92484812c3b 100644 --- a/src/main/preflight/agent-detection.ts +++ b/src/main/preflight/agent-detection.ts @@ -16,11 +16,7 @@ import { getGiteaAuthStatus } from '../gitea/client' import { _resetKnownHostsCache } from '../gitlab/gl-utils' import { mergePersistedWindowsPathAsync } from '../pty/windows-environment-path' import { getActiveMultiplexer } from '../ssh/ssh-target-registry' -import { - detectWslCommandsOnPath, - type WslPreflightTarget -} from '../ipc/preflight-wsl-agent-detection' -import { detectCommandsInInstallDirs } from '../ipc/local-agent-install-dir-detection' +import type { WslPreflightTarget } from '../ipc/preflight-wsl-agent-detection' import { getPreflightWslTarget, type PreflightRuntimeContext @@ -33,7 +29,6 @@ import { execLocalPreflightCommandOrThrow, findRunnableLocalCommand, isCommandAvailable, - isCommandOnPath, shellQuote } from '../ipc/preflight-command-exec' import { @@ -47,6 +42,8 @@ import { } from '../ipc/tui-agent-detection-commands' import { invalidateWslGuestEnvironment } from '../wsl/wsl-guest-environment' import { prunePreflightWslCache } from '../preflight-wsl-cache' +import { detectAgentCommandsOnHost } from './agent-command-detection' +export { detectAgentCommandsOnHost } from './agent-command-detection' export type PreflightStatus = { git: { installed: boolean } @@ -148,38 +145,14 @@ async function detectCommandRuntime( } export async function detectInstalledAgents(context?: PreflightRuntimeContext): Promise { - const wslTarget = getPreflightWslTarget(context) - if (wslTarget) { - const foundCommands = await detectWslCommandsOnPath( - wslTarget, - getTuiAgentDetectionProbeCommands(KNOWN_TUI_AGENT_DETECTION_COMMANDS, 'wsl') - ) - return resolveDetectedTuiAgentIds(KNOWN_TUI_AGENT_DETECTION_COMMANDS, foundCommands, 'wsl') - } - - const probeCommands = getTuiAgentDetectionProbeCommands( + const commands = getTuiAgentDetectionProbeCommands( KNOWN_TUI_AGENT_DETECTION_COMMANDS, - process.platform - ) - const pathChecks = await Promise.all( - probeCommands.map(async (cmd) => ({ - cmd, - installedOnPath: await isCommandOnPath(cmd) - })) - ) - const missedCommands = pathChecks.filter((check) => !check.installedOnPath).map(({ cmd }) => cmd) - // Why: PATH may still be unhydrated on a cold GUI launch; bulk resolution - // computes user install dirs once instead of blocking once per missed CLI. - const installDirCommands = detectCommandsInInstallDirs(missedCommands) - const foundCommands = new Set( - pathChecks - .filter(({ cmd, installedOnPath }) => installedOnPath || installDirCommands.has(cmd)) - .map(({ cmd }) => cmd) + getPreflightWslTarget(context) ? 'wsl' : process.platform ) return resolveDetectedTuiAgentIds( KNOWN_TUI_AGENT_DETECTION_COMMANDS, - foundCommands, - process.platform + await detectAgentCommandsOnHost(commands, { context }), + getPreflightWslTarget(context) ? 'wsl' : process.platform ) } diff --git a/src/main/qoder/real-cli.test.ts b/src/main/qoder/real-cli.test.ts index 7cd1e8a139d..5048c14bef0 100644 --- a/src/main/qoder/real-cli.test.ts +++ b/src/main/qoder/real-cli.test.ts @@ -9,14 +9,13 @@ import { stat, writeFile } from 'node:fs/promises' -import { createServer } from 'node:http' +import { randomUUID } from 'node:crypto' import { homedir, tmpdir } from 'node:os' import type * as Os from 'node:os' import { join } from 'node:path' import { expect, it, vi } from 'vitest' import { runProcess } from '../../shared/child-process/run-process' -import { createHookListenerState } from '../../shared/agent-hook-listener/listener-state' -import { normalizeAndAccept } from '../../shared/agent-hook-listener-test-harness' +import { makePaneKey } from '../../shared/stable-pane-id' import { parseQoderSessionFile } from '../ai-vault/session-scanner-qoder-parser' const sandbox = vi.hoisted(() => ({ home: '' })) @@ -27,6 +26,7 @@ vi.mock('node:os', async (importOriginal) => { vi.mock('electron', () => ({ app: { getPath: () => sandbox.home } })) import { qoderHookService } from './hook-service' import { markQoderWorkspaceTrusted } from './workspace-trust' +import { AgentHookServer } from '../agent-hooks/server' it.skipIf(process.env.ORCA_REAL_QODER_CLI_TEST !== '1')( 'generates and resumes a real task through managed hooks', @@ -35,28 +35,22 @@ it.skipIf(process.env.ORCA_REAL_QODER_CLI_TEST !== '1')( sandbox.home = await realpath(await mkdtemp(join(tmpdir(), 'orca-qoder-real-'))) const config = join(sandbox.home, '.qoder') const workspace = join(sandbox.home, 'folder') + const proofFile = `qoder-proof-${randomUUID()}.txt` + const marker = `QODER_MANAGED_PROOF_${randomUUID()}` const statuses: { event: unknown state: unknown agent: unknown session: string | undefined }[] = [] - const listener = createHookListenerState() - const server = createServer(async (request, response) => { - let content = '' - for await (const chunk of request) { - content += chunk - } - const fields = new URLSearchParams(content) - const payload = JSON.parse(fields.get('payload') ?? '{}') - const normalized = normalizeAndAccept(listener, 'qoder', payload) + const server = new AgentHookServer() + server.subscribeEnrichedStatus((normalized) => { statuses.push({ - event: payload.hook_event_name, - state: normalized?.payload.state, - agent: normalized?.payload.agentType, - session: normalized?.providerSession?.id + event: normalized.hookEventName, + state: normalized.payload.state, + agent: normalized.payload.agentType, + session: normalized.providerSession?.id }) - response.writeHead(request.url === '/hook/qoder' ? 200 : 404).end() }) try { await mkdir(workspace) @@ -68,23 +62,18 @@ it.skipIf(process.env.ORCA_REAL_QODER_CLI_TEST !== '1')( ) expect(qoderHookService.install().state).toBe('installed') markQoderWorkspaceTrusted(workspace, sandbox.home) - await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) - const address = server.address() - if (!address || typeof address === 'string') { - throw new Error('No test receiver port') - } + await server.start({ env: 'production', userDataPath: join(sandbox.home, 'orca') }) const env = { ...process.env, HOME: sandbox.home, USERPROFILE: sandbox.home, - ORCA_AGENT_HOOK_PORT: String(address.port), - ORCA_AGENT_HOOK_TOKEN: 'test-token', - ORCA_PANE_KEY: 'qoder-proof-pane', - ORCA_AGENT_HOOK_ENDPOINT: '', - ORCA_AGENT_HOOK_TRANSPORT: '', + ...server.buildPtyEnv(), + ORCA_PANE_KEY: makePaneKey(randomUUID(), randomUUID()), ORCA_BACKGROUND_LAUNCH: '1' } const command = process.env.ORCA_QODER_CLI_PATH ?? join(realHome, '.local', 'bin', 'qodercli') + const version = await runProcess({ program: command, args: ['--version'], env }) + expect(version.code).toBe(0) const args = [ '--config-dir', config, @@ -100,15 +89,14 @@ it.skipIf(process.env.ORCA_REAL_QODER_CLI_TEST !== '1')( ...args, '--permission-mode', 'accept_edits', - 'Create proof.txt containing exactly QODER_MANAGED_PROOF. Then reply QODER_MANAGED_COMPLETE.' + `Create ${proofFile} containing exactly ${marker}. Then reply QODER_MANAGED_COMPLETE.` ], env, timeoutMs: 90000 }) expect(generated.code).toBe(0) - expect((await readFile(join(workspace, 'proof.txt'), 'utf8')).trim()).toBe( - 'QODER_MANAGED_PROOF' - ) + expect(generated.stdout.trim()).toBe('QODER_MANAGED_COMPLETE') + expect((await readFile(join(workspace, proofFile), 'utf8')).trim()).toBe(marker) const session = statuses.find((s) => s.event === 'SessionStart')?.session expect(session).toBeTruthy() if (!session) { @@ -120,13 +108,18 @@ it.skipIf(process.env.ORCA_REAL_QODER_CLI_TEST !== '1')( ...args, '--resume', session, - 'What exact marker did you write? Reply only with that marker.' + '--permission-mode', + 'accept_edits', + `Recall the exact marker you wrote earlier without reading any file. Create resumed.txt containing that marker followed by a newline and QODER_RESUMED_CHANGE. Reply only with the original marker.` ], env, timeoutMs: 90000 }) expect(resumed.code).toBe(0) - expect(resumed.stdout.trim()).toBe('QODER_MANAGED_PROOF') + expect(resumed.stdout.trim()).toBe(marker) + expect((await readFile(join(workspace, 'resumed.txt'), 'utf8')).trim()).toBe( + `${marker}\nQODER_RESUMED_CHANGE` + ) const projects = join(config, 'projects') const transcript = (await readdir(projects, { recursive: true })).find( (path) => path.endsWith(`${session}.jsonl`) && !path.includes('subagents') @@ -142,8 +135,17 @@ it.skipIf(process.env.ORCA_REAL_QODER_CLI_TEST !== '1')( modifiedAt: modified.mtime.toISOString() }) expect(history).toMatchObject({ agent: 'qoder', sessionId: session, cwd: workspace }) - expect(history?.previewMessages.at(-1)?.text).toBe('QODER_MANAGED_PROOF') + expect(history?.previewMessages.at(-1)?.text).toBe(marker) expect(history?.resumeCommand).toContain(`qodercli --resume '${session}'`) + expect(server.getStatusSnapshot()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + agentType: 'qoder', + state: 'done', + providerSession: expect.objectContaining({ id: session }) + }) + ]) + ) expect(statuses).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -162,9 +164,20 @@ it.skipIf(process.env.ORCA_REAL_QODER_CLI_TEST !== '1')( evidencePath, JSON.stringify( { + command, + version: version.stdout.trim(), + proofFile, + marker, + generatedExitCode: generated.code, + resumedExitCode: resumed.code, generated: generated.stdout.trim(), resumed: resumed.stdout.trim(), statuses, + canonicalSnapshot: server.getStatusSnapshot().map((row) => ({ + agent: row.agentType, + state: row.state, + session: row.providerSession?.id + })), history: { agent: history?.agent, sessionId: history?.sessionId, @@ -178,7 +191,7 @@ it.skipIf(process.env.ORCA_REAL_QODER_CLI_TEST !== '1')( ) } } finally { - await new Promise((resolve) => server.close(() => resolve())) + server.stop() await rm(sandbox.home, { recursive: true, force: true }) sandbox.home = '' } diff --git a/src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts b/src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts index f1b8fa16af5..0799fd1b17a 100644 --- a/src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts +++ b/src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts @@ -9,6 +9,8 @@ import type { RuntimeMobileSessionTerminalTab } from '../../shared/runtime-types' import { randomUUID } from 'node:crypto' +import { copySleepingAgentLaunchConfig } from './runtime-agent-launch-resolution' +import { deriveRemoteRuntimeTerminalCreateHandle } from './remote-runtime-terminal-create-identity' import { parsePaneKey } from '../../shared/stable-pane-id' import { placeCreatedSessionTab } from '../../shared/session-tab-placement' import { @@ -17,7 +19,28 @@ import { getHeadlessMobileSessionGroupId } from './mobile-session-layout-projection' +const OWNED_MOBILE_DISPATCH_RECIPE_TTL_MS = 15 * 60_000 + export class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal extends OrcaRuntimeWithResolveMobileSessionTerminalCommand { + private readonly ownedMobileDispatchRecipes = new Map< + string, + { + launchConfig?: SleepingAgentLaunchConfig + launchAgent?: TuiAgent + connectionId: string | null + recordedAt: number + } + >() + + private pruneOwnedMobileDispatchRecipes(): void { + const now = Date.now() + for (const [handle, recipe] of this.ownedMobileDispatchRecipes) { + if (now - recipe.recordedAt >= OWNED_MOBILE_DISPATCH_RECIPE_TTL_MS) { + this.ownedMobileDispatchRecipes.delete(handle) + } + } + } + protected async createRuntimeOwnedMobileSessionTerminal( worktreeId: string, activate: boolean, @@ -29,6 +52,7 @@ export class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal extends Orca envToDelete?: string[] startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] identity?: { tabId: string; leafId: string; sessionId?: string } + createMutation?: { clientIdentity: string; id: string } launchAgent?: TuiAgent viewMode?: 'terminal' | 'chat' targetGroupId?: string @@ -37,41 +61,122 @@ export class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal extends Orca signal?: AbortSignal } = {} ): Promise { + this.pruneOwnedMobileDispatchRecipes() const workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${worktreeId}`) const cwd = this.resolveWorkspaceTerminalStartupCwd(workspace, opts.cwd) // Why: SshPtyProvider treats sessionId as a relay reattach; only synthesize local serve ids so SSH fresh terminals still call pty.spawn. const stableSessionId = opts.identity?.sessionId ?? (workspace.connectionId ? undefined : `serve-${randomUUID()}`) const isNewSession = stableSessionId !== undefined && opts.identity?.sessionId === undefined - const terminal = await this.createTerminal(`id:${worktreeId}`, { - focus: false, - command: opts.command, - cwd, - env: opts.env, - envToDelete: opts.envToDelete, - ...(opts.launchConfig ? { launchConfig: opts.launchConfig } : {}), - ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), - ...(opts.viewMode ? { viewMode: opts.viewMode } : {}), - startupCommandDelivery: opts.startupCommandDelivery, - ...(opts.identity - ? { - tabId: opts.identity.tabId, - leafId: opts.identity.leafId, - ...(stableSessionId ? { sessionId: stableSessionId } : {}) - } - : stableSessionId - ? { sessionId: stableSessionId } + const mutationHandle = opts.createMutation + ? deriveRemoteRuntimeTerminalCreateHandle( + opts.createMutation.clientIdentity, + worktreeId, + opts.createMutation.id + ) + : undefined + const create = (preAllocatedHandle?: string) => + this.createTerminal(`id:${worktreeId}`, { + focus: false, + ...(mutationHandle + ? { + onPtySpawnDispatched: (launch) => { + if (!launch) { + return + } + if ( + !this.ownedMobileDispatchRecipes.has(mutationHandle) && + this.ownedMobileDispatchRecipes.size >= 4096 + ) { + throw new Error('runtime_unavailable') + } + // Retain original dispatch evidence across an ambiguous response; retry payloads cannot replace it. + this.ownedMobileDispatchRecipes.set(mutationHandle, { + connectionId: workspace.connectionId ?? null, + recordedAt: Date.now(), + ...(launch.launchConfig + ? { launchConfig: copySleepingAgentLaunchConfig(launch.launchConfig) } + : {}), + ...(launch.launchAgent ? { launchAgent: launch.launchAgent } : {}) + }) + } + } : {}), - ...(isNewSession ? { isNewSession: true } : {}), - persistHostSessionBinding: true, - // Why: this method publishes the authoritative snapshot below; skip the intermediate publish to avoid a wrong-group flash. - deferMobileSessionPublish: true, - signal: opts.signal - }) + ...(preAllocatedHandle ? { preAllocatedHandle } : {}), + command: opts.command, + cwd, + env: opts.env, + envToDelete: opts.envToDelete, + ...(opts.launchConfig ? { launchConfig: opts.launchConfig } : {}), + ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), + ...(opts.viewMode ? { viewMode: opts.viewMode } : {}), + startupCommandDelivery: opts.startupCommandDelivery, + ...(opts.identity + ? { + tabId: opts.identity.tabId, + leafId: opts.identity.leafId, + ...(stableSessionId ? { sessionId: stableSessionId } : {}) + } + : stableSessionId + ? { sessionId: stableSessionId } + : {}), + ...(isNewSession ? { isNewSession: true } : {}), + persistHostSessionBinding: true, + // Why: this method publishes the authoritative snapshot below; skip the intermediate publish to avoid a wrong-group flash. + deferMobileSessionPublish: true, + signal: opts.signal + }) + const terminal = opts.createMutation + ? await this.dedupeTerminalCreate( + opts.createMutation.clientIdentity, + `id:${worktreeId}`, + opts.createMutation.id, + true, + (_selector, handle) => create(handle) + ) + : await create() const livePty = this.getLivePtyForHandle(terminal.handle) if (!livePty) { throw new Error('terminal_handle_stale') } + if (opts.createMutation) { + if ( + livePty.pty.launchAgent && + opts.launchAgent && + livePty.pty.launchAgent !== opts.launchAgent + ) { + throw new Error('terminal_create_identity_conflict') + } + const dispatchedRecipe = + mutationHandle === terminal.handle && this.ownedMobileDispatchRecipes.get(terminal.handle) + const recipe = + dispatchedRecipe && + dispatchedRecipe.connectionId === (workspace.connectionId ?? null) && + (!livePty.pty.launchAgent || livePty.pty.launchAgent === dispatchedRecipe.launchAgent) + ? dispatchedRecipe + : null + if (opts.launchConfig && !livePty.pty.launchConfig && !recipe?.launchConfig) { + // Missing original dispatch evidence cannot authorize a retry's captured recipe. + throw new Error('runtime_unavailable') + } + if (recipe) { + livePty.pty.launchAgent ??= recipe.launchAgent ?? null + if (!livePty.pty.launchConfig && recipe.launchConfig) { + livePty.pty.launchConfig = copySleepingAgentLaunchConfig(recipe.launchConfig) + } + } + livePty.pty.runtimeSessionOwned = true + if (mutationHandle) { + this.ownedMobileDispatchRecipes.delete(mutationHandle) + } + } + if (opts.createMutation && opts.identity && terminal.ptyId) { + this.registerPty(terminal.ptyId, worktreeId, workspace.connectionId ?? null, { + tabId: opts.identity.tabId, + leafId: opts.identity.leafId, + terminalHandle: terminal.handle + }) + } const parentTabId = livePty.pty.tabId ?? `pty:${livePty.pty.ptyId}` const leafId = parsePaneKey(livePty.pty.paneKey ?? '')?.leafId ?? randomUUID() if (opts.viewMode) { diff --git a/src/main/runtime/orca-runtime-create-terminal.ts b/src/main/runtime/orca-runtime-create-terminal.ts index 4b8ff9cc744..eba6d03a29a 100644 --- a/src/main/runtime/orca-runtime-create-terminal.ts +++ b/src/main/runtime/orca-runtime-create-terminal.ts @@ -121,7 +121,8 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate } let result: Awaited>> try { - launchOpts.onPtySpawnDispatched?.() + const { launchAgent } = launchOpts + launchOpts.onPtySpawnDispatched?.({ launchConfig: effectiveLaunchConfig, launchAgent }) result = await this.ptyController.spawn({ cols: 120, rows: 40, diff --git a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts index 3febb444395..115a5be8f0d 100644 --- a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts +++ b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts @@ -20,14 +20,9 @@ import type { import type { RuntimeTerminalRename } from '../../shared/runtime-types' import type { TerminalWorkspaceLaunchScope } from './runtime-legacy-worker-terminal-recovery-types' import type { TerminalCreateOptions } from './runtime-terminal-contracts' -import { isTuiAgentEnabled } from '../../shared/tui-agent-selection' import { terminalShellOverrideRefusal } from './terminal-shell-override-host-support' import { resolveTerminalStartupCwd } from '../../shared/terminal-startup-cwd' import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtime-resolution' -import { resolveBareAgentLaunchCommand } from './runtime-agent-launch-resolution' -import { buildExecutionHostAgentStartupPlan } from '../opencode/opencode-model-startup-plan' -import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' -import { agentStartedTelemetry } from '../agent-launch/agent-started-telemetry' import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId } from '../../shared/execution-host' import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth' import { @@ -38,6 +33,8 @@ import { import { interruptedLocalWorktreeRemovalJob } from './runtime-interrupted-local-worktree-removal' import { retryFailedRemovalUnlessRegistered } from '../worktree-removal-table' import type { GitWorktreeInfo } from '../../shared/worktree/types' +import { resolveQoderTerminalCommandForWorkspace } from './qoder-terminal-command-resolution' +import { buildRuntimeAgentTerminalStartupOptions } from './runtime-agent-terminal-startup' export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWithRemoveManagedWorktree { protected async resolveWorktreeRemovalTarget( @@ -243,6 +240,19 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith protected async resolveAgentTerminalCreateOptions( workspace: TerminalWorkspaceLaunchScope, opts: TerminalCreateOptions + ): Promise { + const launch = await this.buildAgentTerminalCreateOptions(workspace, opts) + return resolveQoderTerminalCommandForWorkspace( + launch, + workspace, + this.store, + this.getAgentLaunchPlatformForWorkspace(workspace) + ) + } + + protected async buildAgentTerminalCreateOptions( + workspace: TerminalWorkspaceLaunchScope, + opts: TerminalCreateOptions ): Promise { // Before any early return: every create lane funnels through here, and a host that cannot // apply the requested shell must refuse rather than spawn its default one. @@ -287,64 +297,13 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith return opts } - const settings = store.getSettings() - const platform = this.getAgentLaunchPlatformForWorkspace(workspace) - // Why: `workspace.repo` is display metadata and may be a row from another host; the launch - // shape must match the PTY route this scope already resolved. - const isRemote = Boolean(workspace.connectionId) - if (opts.startupAgent && !isTuiAgentEnabled(opts.startupAgent, settings.disabledTuiAgents)) { - throw new Error(`Agent ${opts.startupAgent} is disabled. Choose an enabled agent.`) - } - const agent = - opts.startupAgent ?? - resolveBareAgentLaunchCommand({ - command: opts.command, - settings, - platform, - isRemote - }) - if (!agent) { - return opts - } - - const startupPlan = await buildExecutionHostAgentStartupPlan({ - inputs: resolveAgentStartupPlanInputs({ - agent, - settings, - platform, - isRemote, - ...(opts.agentArgs !== undefined ? { agentArgs: opts.agentArgs } : {}), - // A requested shell is the one this PTY will actually be, so it owns the quoting family. - windowsShellOverride: opts.shellOverride, - sessionOptions: this.toAgentSessionOptions(opts.launchPreferences) - }), - prompt: opts.startupPrompt ?? '', - cwd: resolveTerminalStartupCwd(workspace.path, opts.cwd) ?? workspace.path, - hostIdentity: this.runtimeId - }) - if (!startupPlan) { - // Why: an explicit agent that yields no plan would otherwise spawn a bare - // shell that never reaches agent readiness. - if (opts.startupAgent) { - throw new Error(`Could not build launch command for ${opts.startupAgent}.`) - } - return opts - } - // A prompt this launch command cannot carry has nowhere to go from here — the create returns - // options, not a live PTY — so refuse rather than spawn the agent and drop the text. - if (opts.startupPrompt && startupPlan.followupPrompt) { - throw new Error(`Agent ${agent} does not take a startup prompt on its launch command.`) - } - - return { - ...opts, - command: startupPlan.launchCommand, - ...(startupPlan.env ? { env: startupPlan.env } : {}), - launchConfig: startupPlan.launchConfig, - launchAgent: agent, - startupCommandDelivery: startupPlan.startupCommandDelivery, - // A bare command the user typed stays out of launch accounting, as before. - ...(opts.startupAgent ? { telemetry: agentStartedTelemetry(agent, opts.launchSource) } : {}) - } + return buildRuntimeAgentTerminalStartupOptions( + workspace, + opts, + store.getSettings(), + this.getAgentLaunchPlatformForWorkspace(workspace), + this.toAgentSessionOptions(opts.launchPreferences), + this.runtimeId + ) } } diff --git a/src/main/runtime/orca-runtime-run-create-mobile-session-terminal.ts b/src/main/runtime/orca-runtime-run-create-mobile-session-terminal.ts index 193bf3cb12d..5e3c6458252 100644 --- a/src/main/runtime/orca-runtime-run-create-mobile-session-terminal.ts +++ b/src/main/runtime/orca-runtime-run-create-mobile-session-terminal.ts @@ -13,6 +13,7 @@ import { isClientDisconnectedError } from './orca-runtime-core' import { rendererPublicationThrottle } from '../window/renderer-publication-throttle' +import { deterministicAgentSessionUuid } from './runtime-agent-launch-resolution' export class OrcaRuntimeWithRunCreateMobileSessionTerminal extends OrcaRuntimeWithCreateMobileSessionTerminal { protected async runCreateMobileSessionTerminal( @@ -66,6 +67,29 @@ export class OrcaRuntimeWithRunCreateMobileSessionTerminal extends OrcaRuntimeWi if (opts.signal?.aborted) { throw new Error('client_disconnected') } + if (opts.clientMutationId && startupCommand.command && startupCommand.launchAgent === 'qoder') { + const clientIdentity = opts.clientNavigationId ?? 'local' + const seed = `${clientIdentity}\0${worktreeId}\0${opts.clientMutationId}` + // A command-bearing retry must reconcile the owning PTY before delivering the resume again. + return await this.createRuntimeOwnedMobileSessionTerminal( + worktreeId, + opts.activate !== false, + afterTabId, + { + ...startupCommand, + cwd, + identity: { + tabId: deterministicAgentSessionUuid(`mobile-qoder-tab\0${seed}`), + leafId: deterministicAgentSessionUuid(`mobile-qoder-leaf\0${seed}`) + }, + createMutation: { clientIdentity, id: opts.clientMutationId }, + viewMode: opts.viewMode, + targetGroupId: opts.targetGroupId, + supportsSplitGroupPlacement: opts.supportsSplitGroupPlacement, + signal: opts.signal + } + ) + } const win = this.getAvailableAuthoritativeWindow() if (!win) { return await this.createRuntimeOwnedMobileSessionTerminal( diff --git a/src/main/runtime/orca-runtime-terminal-create-deduplication.ts b/src/main/runtime/orca-runtime-terminal-create-deduplication.ts index 59e13676e63..64041d6d8da 100644 --- a/src/main/runtime/orca-runtime-terminal-create-deduplication.ts +++ b/src/main/runtime/orca-runtime-terminal-create-deduplication.ts @@ -101,7 +101,11 @@ export class OrcaRuntimeWithTerminalCreateDeduplication extends OrcaRuntimeWithC this.adoptControllerTerminalHandle(session.id, terminalHandle) const pty = this.recordPtyWorktree(session.id, worktreeId, { connected: true, - title: session.title + title: session.title, + ...(session.incarnationId ? { incarnationId: session.incarnationId } : {}), + ...(session.wslDistro !== undefined + ? { wslDistro: session.wslDistro, isWsl: session.wslDistro !== null } + : {}) }) const adoptedHandle = this.issuePtyHandle(pty) if (adoptedHandle !== terminalHandle) { diff --git a/src/main/runtime/orca-runtime-test-mocks/setup.spec.ts b/src/main/runtime/orca-runtime-test-mocks/setup.spec.ts index 30d6c61ff79..93ef7412f47 100644 --- a/src/main/runtime/orca-runtime-test-mocks/setup.spec.ts +++ b/src/main/runtime/orca-runtime-test-mocks/setup.spec.ts @@ -350,6 +350,7 @@ vi.mock('../../ssh/ssh-target-registry', () => ({ })) vi.mock('../../preflight/agent-detection', () => ({ + detectAgentCommandsOnHost: vi.fn(async () => new Set(['qodercli'])), detectInstalledAgentsWithShellPathHydration: detectInstalledAgentsWithShellPathHydrationMock, detectRemoteAgents: detectRemoteAgentsMock })) diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-03.spec.ts index 47d7236dd9d..de21af9f013 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-03.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { detectAgentCommandsOnHost } from '../../preflight/agent-detection' import { FLOATING_TERMINAL_WORKTREE_ID, OrcaRuntimeService, @@ -28,6 +29,29 @@ import { } from '../orca-runtime-test-fixtures.spec' describe('OrcaRuntimeService', () => { + it.each(['qoder', 'qodercli'] as const)( + 'spawns the available Qoder command for a captured same-session resume: %s', + async (command) => { + vi.mocked(detectAgentCommandsOnHost).mockResolvedValueOnce(new Set([command])) + const spawn = vi.fn().mockResolvedValue({ id: 'pty-qoder-resume' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + launchAgent: 'qoder', + command: "qodercli --resume 'existing-qoder-session'", + launchConfig: { agentCommand: 'qodercli', agentArgs: '', agentEnv: {} } + }) + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ command: `${command} --resume 'existing-qoder-session'` }) + ) + } + ) + it('does not use the local Windows shell setting for remote Windows bare agent creates', async () => { const remoteRepo = { id: TEST_REPO_ID, diff --git a/src/main/runtime/qoder-independent-spawn-review.test.ts b/src/main/runtime/qoder-independent-spawn-review.test.ts new file mode 100644 index 00000000000..87f2c7cef56 --- /dev/null +++ b/src/main/runtime/qoder-independent-spawn-review.test.ts @@ -0,0 +1,328 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import filesystem from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { buildAgentResumeStartupPlan } from '../../shared/tui-agent-startup' +import { buildAiVaultResumeShellCommand } from '../../shared/ai-vault-resume-command' + +await import('./orca-runtime-test-mocks.spec') +await import('./orca-runtime-test-lifecycle.spec') +const { OrcaRuntimeService } = await import('./orca-runtime-test-mocks.spec') +const { + store, + TEST_WORKTREE_PATH, + TEST_FOLDER_WORKSPACE_KEY, + createFolderWorkspaceRuntimeStore, + makeFolderWorkspace, + makeFolderProjectGroup +} = await import('./orca-runtime-test-fixtures.spec') +const { detectAgentCommandsOnHost } = await import('../preflight/agent-detection') +beforeEach(() => + vi + .mocked(detectAgentCommandsOnHost) + .mockReset() + .mockResolvedValue(new Set(['qodercli'])) +) + +it.each([ + ['modern-only', ['qoder'], 'qoder'], + ['legacy-only', ['qodercli'], 'qodercli'], + ['both', ['qoder', 'qodercli'], 'qodercli'] +] as const)( + 'fresh managed start reaches the production spawn boundary: %s', + async (_, found, selected) => { + vi.mocked(detectAgentCommandsOnHost).mockResolvedValueOnce(new Set(found)) + const spawn = vi.fn().mockResolvedValue({ id: 'pty-independent-start' }) + const runtime = new OrcaRuntimeService({ + ...store, + getSettings: () => ({ ...store.getSettings(), disabledTuiAgents: [], agentCmdOverrides: {} }) + }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + startupAgent: 'qoder', + agentArgs: null, + launchSource: 'orchestration', + startupPrompt: 'qodercli remains prompt text' + }) + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + launchAgent: 'qoder', + command: `${selected} --prompt-interactive 'qodercli remains prompt text'` + }) + ) + expect(spawn).toHaveBeenCalledTimes(1) + expect(spawn.mock.calls[0]?.[0].telemetry).toEqual({ + agent_kind: 'qoder', + launch_source: 'orchestration', + request_kind: 'new' + }) + } +) + +it('a modern-only repo-less folder start reaches the same production spawn boundary', async () => { + vi.mocked(detectAgentCommandsOnHost).mockResolvedValueOnce(new Set(['qoder'])) + const spawn = vi.fn().mockResolvedValue({ id: 'pty-independent-folder' }) + const createTempDirectory = filesystem.mkdtemp + const guard = vi.spyOn(filesystem, 'mkdtemp').mockImplementation((prefix, options) => { + // CI has no .context parent; reject that prerequisite even on a developer checkout. + expect(String(prefix)).not.toContain('.context') + return createTempDirectory(prefix, options) + }) + let folderPath: string | undefined + try { + folderPath = await filesystem.mkdtemp(join(tmpdir(), 'qoder-independent-folder-')) + const folderStore = createFolderWorkspaceRuntimeStore( + makeFolderWorkspace({ folderPath }), + makeFolderProjectGroup({ parentPath: folderPath }) + ) + const runtime = new OrcaRuntimeService({ + ...folderStore, + getSettings: () => ({ ...store.getSettings(), disabledTuiAgents: [], agentCmdOverrides: {} }) + }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + await runtime.createTerminal(`id:${TEST_FOLDER_WORKSPACE_KEY}`, { + startupAgent: 'qoder', + agentArgs: null + }) + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ launchAgent: 'qoder', command: 'qoder' }) + ) + expect(spawn).toHaveBeenCalledTimes(1) + expect(spawn.mock.calls[0]?.[0].cwd).toBe(folderPath) + expect(spawn.mock.calls[0]?.[0].telemetry).toEqual({ + agent_kind: 'qoder', + launch_source: 'unknown', + request_kind: 'new' + }) + expect((await filesystem.stat(folderPath)).isDirectory()).toBe(true) + await expect(filesystem.stat(join(folderPath, '.git'))).rejects.toMatchObject({ + code: 'ENOENT' + }) + } finally { + guard.mockRestore() + if (folderPath) { + await filesystem.rm(folderPath, { recursive: true, force: true }) + } + } +}) + +it('selects the installed command for a mobile Qoder history resume at creation', async () => { + vi.mocked(detectAgentCommandsOnHost).mockResolvedValueOnce(new Set(['qoder'])) + const spawn = vi.fn().mockResolvedValue({ id: 'pty-mobile-qoder-resume' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + const plan = buildAgentResumeStartupPlan({ + agent: 'qoder', + providerSession: { key: 'session_id', id: 'same-qoder-session' }, + cmdOverrides: {}, + platform: 'darwin' + }) + if (!plan) { + throw new Error('Missing Qoder resume plan') + } + const launch = { + command: buildAiVaultResumeShellCommand({ + resumeCommand: plan.launchCommand, + cwd: TEST_WORKTREE_PATH, + platform: 'darwin' + }), + launchAgent: plan.agent, + launchConfig: plan.launchConfig + } + await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command: launch.command, + launchAgent: launch.launchAgent, + launchConfig: launch.launchConfig + }) + expect(spawn).toHaveBeenCalledTimes(1) + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + command: launch.command.replace(/qodercli(?=\s)/, 'qoder'), + launchAgent: 'qoder' + }) + ) +}) + +it('host discovery refusal prevents any production spawn', async () => { + vi.mocked(detectAgentCommandsOnHost).mockRejectedValueOnce( + new Error('execution host unavailable') + ) + const spawn = vi.fn() + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + await expect( + runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + launchAgent: 'qoder', + command: 'qodercli --resume original-id' + }) + ).rejects.toThrow('execution host unavailable') + expect(spawn).not.toHaveBeenCalled() +}) + +it('caller-owned explicit executable survives production resume without discovery', async () => { + const detect = vi.mocked(detectAgentCommandsOnHost) + detect.mockClear() + const spawn = vi.fn().mockResolvedValue({ id: 'pty-independent-explicit' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + launchAgent: 'qoder', + command: '/caller/qodercli --resume original-id' + }) + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ command: '/caller/qodercli --resume original-id' }) + ) + expect(detect).not.toHaveBeenCalled() + expect(spawn).toHaveBeenCalledTimes(1) + expect(spawn.mock.calls[0]?.[0].telemetry).toBeUndefined() +}) + +it.each([ + ['bare', 'qodercli'], + ['resume', 'qodercli --resume original-id'] +] as const)('does not falsely attribute a %s command as a fresh start', async (_, command) => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-independent-unattributed' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command, + agentArgs: null, + launchSource: 'orchestration', + ...(command.includes('--resume') ? { launchAgent: 'qoder' as const } : {}) + }) + expect(spawn).toHaveBeenCalledTimes(1) + expect(spawn.mock.calls[0]?.[0].command).toBe(command) + expect(spawn.mock.calls[0]?.[0].telemetry).toBeUndefined() +}) + +it.each(['offline', 'legacy-identity'])( + 'refuses a Qoder command-bearing create with unverifiable inventory: %s', + async (state) => { + const { TEST_WORKTREE_ID } = await import('./orca-runtime-test-fixtures.spec') + const runtime = new OrcaRuntimeService(store) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + const spawn = vi.fn() + const kill = vi.fn() + runtime.setPtyController({ + spawn, + kill, + write: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => { + if (state === 'offline') { + throw new Error('host offline') + } + return [ + { + id: 'older-live-pty', + cwd: TEST_WORKTREE_PATH, + title: 'shell', + worktreeId: TEST_WORKTREE_ID + } + ] + } + }) + await expect( + runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + command: "qodercli '--resume' 'same-session'", + launchAgent: 'qoder', + clientNavigationId: 'paired-phone', + clientMutationId: 'same-resume', + select: false, + activate: false + }) + ).rejects.toThrow('runtime_unavailable') + expect(spawn).not.toHaveBeenCalled() + expect(kill).not.toHaveBeenCalled() + } +) + +it('reconciles after the reply-cache expires and isolates deliberate forks and paired callers', async () => { + const { TEST_WORKTREE_ID } = await import('./orca-runtime-test-fixtures.spec') + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + const live: { + id: string + cwd: string + title: string + worktreeId: string + terminalHandle: string + }[] = [] + const spawn = vi.fn(async (args: { preAllocatedHandle?: string }) => { + const id = `qoder-${live.length + 1}` + live.push({ + id, + cwd: TEST_WORKTREE_PATH, + title: 'Qoder', + worktreeId: TEST_WORKTREE_ID, + terminalHandle: args.preAllocatedHandle ?? '' + }) + return { id } + }) + runtime.setPtyController({ + spawn, + listProcesses: async () => live, + kill: vi.fn(), + write: () => true, + getForegroundProcess: async () => null + }) + const resume = { + command: "qodercli '--resume' 'same-session'", + launchAgent: 'qoder' as const, + clientNavigationId: 'phone-a', + clientMutationId: 'resume-a', + select: false, + activate: false + } + const first = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, resume) + await vi.advanceTimersByTimeAsync(61_000) + const retry = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, resume) + expect(retry.tab.id).toBe(first.tab.id) + expect(retry.tab.ptyId).toBe(first.tab.ptyId) + expect(spawn).toHaveBeenCalledTimes(1) + const fork = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + ...resume, + clientMutationId: 'resume-b' + }) + const otherPhone = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + ...resume, + clientNavigationId: 'phone-b' + }) + expect(spawn).toHaveBeenCalledTimes(3) + expect(new Set([first.tab.id, fork.tab.id, otherPhone.tab.id]).size).toBe(3) + } finally { + vi.useRealTimers() + } +}) diff --git a/src/main/runtime/qoder-mobile-resume-adoption.test.ts b/src/main/runtime/qoder-mobile-resume-adoption.test.ts new file mode 100644 index 00000000000..cde2dabacd5 --- /dev/null +++ b/src/main/runtime/qoder-mobile-resume-adoption.test.ts @@ -0,0 +1,449 @@ +import { expect, it, vi } from 'vitest' +import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' +import { buildAgentResumeStartupPlan } from '../../shared/tui-agent-startup' + +await import('./orca-runtime-test-mocks.spec') +await import('./orca-runtime-test-lifecycle.spec') +const { OrcaRuntimeService } = await import('./orca-runtime-test-mocks.spec') +const { store, TEST_WORKTREE_PATH } = await import('./orca-runtime-test-fixtures.spec') +const { detectAgentCommandsOnHost } = await import('../preflight/agent-detection') + +class QoderAdoptionRuntime extends OrcaRuntimeService { + retainedRecipe(ptyId: string, worktreeId: string) { + return this.recordPtyWorktree(ptyId, worktreeId) + } +} + +it.each([ + 'client_disconnected', + 'Terminal creation timed out', + 'quoted-agent-command', + 'wsl-inventory', + 'changed-replay', + 'inventory-outage', + 'surviving-recipe' +])('reconciles a paired Qoder resume after ambiguous creation: %s', async (scenario) => { + const failure = scenario === 'Terminal creation timed out' ? scenario : 'client_disconnected' + const { withPlatform } = await import('./orca-runtime-test-fixtures.spec') + await withPlatform(scenario === 'wsl-inventory' ? 'win32' : 'darwin', async () => { + vi.useFakeTimers() + try { + const { electronMocks } = await import('./orca-runtime-test-mocks.spec') + const { TEST_WORKTREE_ID } = await import('./orca-runtime-test-fixtures.spec') + vi.mocked(detectAgentCommandsOnHost).mockResolvedValue(new Set(['qoder'])) + const runtime = new QoderAdoptionRuntime(store) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + let revealedRecipe: SleepingAgentLaunchConfig | undefined + const revealTerminalSession = vi.fn( + (_workspace: string, reveal: { launchConfig?: SleepingAgentLaunchConfig }) => { + revealedRecipe = reveal.launchConfig + } + ) + runtime.setNotifier({ + focusTerminal: vi.fn(), + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + closeTerminal: vi.fn(), + closeSessionTab: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + const acceptedCommands: string[] = [] + const live: { + id: string + cwd: string + title: string + worktreeId: string + terminalHandle: string + wslDistro?: string + }[] = [] + let connection = new AbortController() + let inventoryUnavailable = false + const kill = vi.fn((id: string) => { + const index = live.findIndex((entry) => entry.id === id) + if (index !== -1) { + live.splice(index, 1) + } + return true + }) + const spawn = vi.fn(async (args: { command?: string; preAllocatedHandle?: string }) => { + if (args.command) { + acceptedCommands.push(args.command) + } + live.push({ + id: 'qoder-live-resume', + cwd: TEST_WORKTREE_PATH, + title: 'Qoder', + worktreeId: TEST_WORKTREE_ID, + terminalHandle: args.preAllocatedHandle ?? '', + ...(scenario === 'wsl-inventory' ? { wslDistro: 'Ubuntu-Orca' } : {}) + }) + if (scenario === 'surviving-recipe') { + const retained = runtime.retainedRecipe('qoder-live-resume', TEST_WORKTREE_ID) + retained.launchAgent = 'qoder' + retained.launchConfig = { + agentCommand: 'trusted-qoder', + agentArgs: 'trusted args', + agentEnv: { TRUSTED: 'retained' } + } + } + if (failure === 'client_disconnected') { + connection.abort() + } + throw new Error(failure) + }) + runtime.setPtyController({ + spawn, + listProcesses: async () => { + if (inventoryUnavailable) { + throw new Error('host offline') + } + return live + }, + write: () => true, + kill, + getForegroundProcess: async () => null + }) + const send = vi.fn((channel: string, payload: { command?: string }) => { + if (channel === 'terminal:requestTabCreate') { + if (payload.command) { + acceptedCommands.push(payload.command) + } + if (failure === 'client_disconnected') { + connection.abort() + } + } + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + electronMocks.BrowserWindow.fromId.mockReturnValue({ + isDestroyed: () => false, + webContents: { isDestroyed: () => false, send, setBackgroundThrottling: vi.fn() } + }) + const plan = buildAgentResumeStartupPlan({ + agent: 'qoder', + providerSession: { key: 'session_id', id: 'same-qoder-session' }, + cmdOverrides: {}, + platform: 'darwin', + agentArgs: '--captured-option', + ...(scenario === 'quoted-agent-command' ? { agentCommand: "'qodercli'" } : {}) + }) + expect(plan).not.toBeNull() + if (!plan) { + throw new Error('missing resume plan') + } + const resume = { + command: plan.launchCommand, + launchAgent: 'qoder' as const, + launchConfig: { ...plan.launchConfig, agentEnv: { OWNED_RECIPE: 'original value' } }, + activate: false, + select: false, + navigation: 'caller' as const, + clientNavigationId: 'paired-phone', + clientMutationId: 'stable-resume-mutation' + } + const first = runtime + .createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + ...resume, + signal: connection.signal + }) + .then( + () => null, + (error: unknown) => error + ) + await vi.advanceTimersByTimeAsync(10_001) + expect(await first).toBeInstanceOf(Error) + expect(kill).not.toHaveBeenCalled() + connection = new AbortController() + if (scenario === 'inventory-outage') { + inventoryUnavailable = true + await expect( + runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + ...resume, + signal: connection.signal + }) + ).rejects.toThrow('runtime_unavailable') + inventoryUnavailable = false + } + const retryPromise = runtime + .createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + ...resume, + ...(scenario === 'changed-replay' + ? { launchConfig: { ...resume.launchConfig, agentEnv: { CHANGED: 'retry' } } } + : {}), + signal: connection.signal + }) + .catch(() => null) + await vi.advanceTimersByTimeAsync(10_001) + const retry = await retryPromise + expect(acceptedCommands).toHaveLength(1) + expect(acceptedCommands[0]).toBe( + scenario === 'quoted-agent-command' + ? "qoder '--resume' 'same-qoder-session'" + : "qoder '--captured-option' '--resume' 'same-qoder-session'" + ) + expect(retry?.tab).toMatchObject({ ptyId: 'qoder-live-resume', launchAgent: 'qoder' }) + expect(spawn).toHaveBeenCalledTimes(1) + expect(send).not.toHaveBeenCalled() + if (!retry) { + throw new Error('missing adopted resume') + } + const pane = runtime.resolveTerminalPane(`${retry.tab.parentTabId}:${retry.tab.leafId}`) + await runtime.focusTerminal(pane.handle) + expect(revealTerminalSession).toHaveBeenCalledWith( + TEST_WORKTREE_ID, + expect.objectContaining({ + launchAgent: 'qoder', + launchConfig: + scenario === 'surviving-recipe' + ? { + agentCommand: 'trusted-qoder', + agentArgs: 'trusted args', + agentEnv: { TRUSTED: 'retained' } + } + : expect.objectContaining({ + agentCommand: + scenario === 'quoted-agent-command' + ? "'qodercli'" + : "qoder '--captured-option'", + agentArgs: plan.launchConfig.agentArgs, + agentEnv: { OWNED_RECIPE: 'original value' } + }) + }) + ) + if (!revealedRecipe) { + throw new Error('missing revealed recipe') + } + const relaunch = buildAgentResumeStartupPlan({ + agent: 'qoder', + providerSession: { key: 'session_id', id: 'same-qoder-session' }, + cmdOverrides: { qoder: 'changed-default-command' }, + platform: 'darwin', + agentCommand: revealedRecipe.agentCommand, + agentArgs: revealedRecipe.agentArgs, + agentEnv: revealedRecipe.agentEnv + }) + if (!relaunch) { + throw new Error('missing relaunch') + } + const restoredSpawn = vi.fn().mockResolvedValue({ id: 'intentional-restored-qoder' }) + runtime.setPtyController({ + spawn: restoredSpawn, + write: () => true, + kill, + getForegroundProcess: async () => null + }) + await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + command: relaunch.launchCommand, + launchAgent: 'qoder', + launchConfig: relaunch.launchConfig, + env: relaunch.env, + focus: false + }) + expect(restoredSpawn).toHaveBeenCalledWith( + expect.objectContaining({ + command: + scenario === 'surviving-recipe' + ? "trusted-qoder '--resume' 'same-qoder-session'" + : acceptedCommands[0], + env: expect.objectContaining( + scenario === 'surviving-recipe' + ? { TRUSTED: 'retained' } + : { OWNED_RECIPE: 'original value' } + ) + }) + ) + expect(kill).not.toHaveBeenCalled() + if (scenario === 'wsl-inventory' && retry) { + expect( + runtime.resolveTerminalPane(`${retry.tab.parentTabId}:${retry.tab.leafId}`).hostPlatform + ).toBe('linux') + } + } finally { + vi.useRealTimers() + } + }) +}) + +it.each(['missing-original', 'different-agent'])( + 'refuses unproven adopted launch recipe: %s', + async (state) => { + const { TEST_WORKTREE_ID } = await import('./orca-runtime-test-fixtures.spec') + const { deriveRemoteRuntimeTerminalCreateHandle } = + await import('./remote-runtime-terminal-create-identity') + const handle = deriveRemoteRuntimeTerminalCreateHandle( + 'owned-phone', + TEST_WORKTREE_ID, + 'unknown-operation' + ) + const runtime = new QoderAdoptionRuntime(store) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + const tabId = 'original-owned-tab' + const leafId = '55555555-5555-4555-8555-555555555555' + runtime.registerPty('unknown-owned-pty', TEST_WORKTREE_ID, null, { + tabId, + leafId, + terminalHandle: handle, + incarnationId: 'owned-prior-incarnation' + }) + const retained = runtime.retainedRecipe('unknown-owned-pty', TEST_WORKTREE_ID) + const originalPaneKey = retained.paneKey + const originalOwnership = retained.runtimeSessionOwned + if (state === 'different-agent') { + retained.launchAgent = 'codex' + } + const spawn = vi.fn() + const kill = vi.fn() + runtime.setPtyController({ + spawn, + kill, + write: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [ + { + id: 'unknown-owned-pty', + worktreeId: TEST_WORKTREE_ID, + terminalHandle: handle, + cwd: TEST_WORKTREE_PATH, + title: 'Unknown' + } + ] + }) + await expect( + runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + command: 'qodercli --resume original', + launchAgent: 'qoder', + launchConfig: { + agentCommand: "'qodercli'", + agentArgs: '--guessed-retry', + agentEnv: { GUESSED: 'not-authoritative' } + }, + clientNavigationId: 'owned-phone', + clientMutationId: 'unknown-operation', + select: false + }) + ).rejects.toThrow( + state === 'different-agent' ? 'terminal_create_identity_conflict' : 'runtime_unavailable' + ) + expect(spawn).not.toHaveBeenCalled() + expect(kill).not.toHaveBeenCalled() + expect(retained.launchConfig).toBeNull() + expect(retained.launchAgent).toBe(state === 'different-agent' ? 'codex' : null) + expect(retained.launchToken).toBeNull() + expect(retained.launchIncarnationId).toBeNull() + expect(retained.tabId).toBe(tabId) + expect(retained.paneKey).toBe(originalPaneKey) + expect(retained.runtimeSessionOwned).toBe(originalOwnership) + expect(runtime.resolveTerminalPane(`${tabId}:${leafId}`).handle).toBe(handle) + } +) + +it('releases abandoned dispatch capacity without guessing an expired live recipe', async () => { + vi.useFakeTimers() + try { + const { TEST_WORKTREE_ID } = await import('./orca-runtime-test-fixtures.spec') + const { deriveRemoteRuntimeTerminalCreateHandle } = + await import('./remote-runtime-terminal-create-identity') + vi.mocked(detectAgentCommandsOnHost) + .mockReset() + .mockResolvedValue(new Set(['qoder'])) + const runtime = new OrcaRuntimeService(store) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + let accept = false + const inventory: { + id: string + worktreeId: string + terminalHandle: string + cwd: string + title: string + }[] = [] + const spawn = vi.fn(async () => { + if (accept) { + return { id: 'fresh-after-expiry' } + } + throw new Error('abandoned-create') + }) + const kill = vi.fn() + runtime.setPtyController({ + spawn, + kill, + write: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => inventory + }) + const request = { + command: 'qodercli --resume original', + launchAgent: 'qoder' as const, + launchConfig: { + agentCommand: 'qodercli', + agentArgs: '--original', + agentEnv: { ORIGINAL: 'owned' } + }, + clientNavigationId: 'owned-phone', + select: false + } + for (let index = 0; index < 4096; index++) { + await runtime + .createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + ...request, + clientMutationId: `abandoned-${index}` + }) + .catch((error: unknown) => { + if (!(error instanceof Error) || error.message !== 'abandoned-create') { + throw error + } + }) + } + accept = true + await expect( + runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + ...request, + clientMutationId: 'fresh-mutation' + }) + ).rejects.toThrow('runtime_unavailable') + expect(spawn).toHaveBeenCalledTimes(4096) + await vi.advanceTimersByTimeAsync(14 * 60_000) + await expect( + runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + ...request, + clientMutationId: 'fresh-mutation' + }) + ).rejects.toThrow('runtime_unavailable') + expect(spawn).toHaveBeenCalledTimes(4096) + await vi.advanceTimersByTimeAsync(2 * 60_000) + const fresh = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + ...request, + clientMutationId: 'fresh-mutation' + }) + expect(fresh.tab.ptyId).toBe('fresh-after-expiry') + inventory.push({ + id: 'expired-live-pty', + worktreeId: TEST_WORKTREE_ID, + terminalHandle: deriveRemoteRuntimeTerminalCreateHandle( + 'owned-phone', + TEST_WORKTREE_ID, + 'abandoned-0' + ), + cwd: TEST_WORKTREE_PATH, + title: 'Qoder' + }) + await expect( + runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + ...request, + launchConfig: { ...request.launchConfig, agentEnv: { GUESSED: 'retry' } }, + clientMutationId: 'abandoned-0' + }) + ).rejects.toThrow('runtime_unavailable') + expect(spawn).toHaveBeenCalledTimes(4097) + expect(kill).not.toHaveBeenCalled() + expect(inventory).toHaveLength(1) + } finally { + vi.useRealTimers() + } +}, 30_000) diff --git a/src/main/runtime/qoder-terminal-command-resolution.test.ts b/src/main/runtime/qoder-terminal-command-resolution.test.ts new file mode 100644 index 00000000000..30c2e189bd0 --- /dev/null +++ b/src/main/runtime/qoder-terminal-command-resolution.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from 'vitest' +import { buildAgentStartupPlan, buildAgentResumeStartupPlan } from '../../shared/tui-agent-startup' +import { buildAiVaultResumeShellCommand } from '../../shared/ai-vault-resume-command' +import { resolveQoderTerminalCommand } from './qoder-terminal-command-resolution' + +vi.mock('../preflight/agent-detection', () => ({ detectAgentCommandsOnHost: vi.fn() })) + +describe('execution-host Qoder command selection', () => { + it.each(['darwin', 'linux', 'win32'] as const)( + 'starts and resumes modern-only and legacy-only installs on %s', + async (platform) => { + const startup = buildAgentStartupPlan({ + agent: 'qoder', + prompt: 'qodercli is a prompt word', + cmdOverrides: {}, + platform + }) + const resume = buildAgentResumeStartupPlan({ + agent: 'qoder', + providerSession: { key: 'session_id', id: 'existing-session' }, + cmdOverrides: {}, + platform + }) + if (!startup || !resume) { + throw new Error('Missing Qoder plans') + } + const shell = platform === 'win32' ? 'powershell' : 'posix' + for (const found of [ + new Set(['qoder']), + new Set(['qodercli']), + new Set(['qoder', 'qodercli']) + ]) { + const selected = found.has('qodercli') ? 'qodercli' : 'qoder' + const detect = vi.fn(async () => found) + for (const plan of [startup, resume]) { + const result = await resolveQoderTerminalCommand( + { launchAgent: 'qoder', command: plan.launchCommand, launchConfig: plan.launchConfig }, + { shell }, + detect + ) + expect(result.command).toBe(plan.launchCommand.replace(/^qodercli/, selected)) + expect(result.launchConfig?.agentCommand).toBe(selected) + } + const historyCommand = buildAiVaultResumeShellCommand({ + resumeCommand: resume.launchCommand, + cwd: '/folder workspace', + platform, + shell + }) + const history = await resolveQoderTerminalCommand( + { launchAgent: 'qoder', command: historyCommand, launchConfig: resume.launchConfig }, + { shell }, + detect + ) + expect(history.command).toBe(historyCommand.replace(/qodercli(?=\s)/, selected)) + } + } + ) + + it('queries each execution host without sharing a result or falling back after contact loss', async () => { + const options = { launchAgent: 'qoder' as const, command: 'qodercli --resume existing-session' } + const detect = vi + .fn() + .mockResolvedValueOnce(new Set(['qoder'])) + .mockResolvedValueOnce(new Set(['qodercli'])) + .mockRejectedValueOnce(new Error('Host disconnected')) + const wsl = { shell: 'posix' as const, context: { wslDistro: 'Ubuntu' } } + const ssh = { shell: 'posix' as const, connectionId: 'ssh-host' } + expect((await resolveQoderTerminalCommand(options, wsl, detect)).command).toBe( + 'qoder --resume existing-session' + ) + expect((await resolveQoderTerminalCommand(options, ssh, detect)).command).toBe(options.command) + await expect(resolveQoderTerminalCommand(options, ssh, detect)).rejects.toThrow( + 'Host disconnected' + ) + expect(detect.mock.calls).toEqual([ + [['qodercli', 'qoder'], wsl], + [['qodercli', 'qoder'], ssh], + [['qodercli', 'qoder'], ssh] + ]) + }) + + it.each([ + '/custom/qodercli --resume id', + 'qoder --prompt-interactive hello', + 'echo qodercli', + 'qodercli-other' + ])('preserves explicit paths, modern commands and unrelated commands: %s', async (command) => { + const detect = vi.fn() + expect( + ( + await resolveQoderTerminalCommand( + { launchAgent: 'qoder', command }, + { shell: 'posix' }, + detect + ) + ).command + ).toBe(command) + expect(detect).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/qoder-terminal-command-resolution.ts b/src/main/runtime/qoder-terminal-command-resolution.ts new file mode 100644 index 00000000000..7e872db933c --- /dev/null +++ b/src/main/runtime/qoder-terminal-command-resolution.ts @@ -0,0 +1,96 @@ +import type { PreflightRuntimeContext } from '../preflight/agent-detection' +import { detectAgentCommandsOnHost } from '../preflight/agent-detection' +import { getTuiAgentDetectCommands, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' +import { + resolveStartupShell, + tokenizeStartupCommand, + type AgentStartupShell +} from '../../shared/tui-agent-startup-shell' +import type { TerminalCreateOptions } from './runtime-terminal-contracts' +import type { RuntimeStore } from './runtime-store-contract' +import type { TerminalWorkspaceLaunchScope } from './runtime-legacy-worker-terminal-recovery-types' +import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtime-resolution' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { resolveLocalWindowsAgentStartupShell } from '../../shared/windows-terminal-shell' + +export async function resolveQoderTerminalCommandForWorkspace( + launch: TerminalCreateOptions, + workspace: TerminalWorkspaceLaunchScope, + store: RuntimeStore | null | undefined, + platform: NodeJS.Platform +): Promise { + if (launch.launchAgent !== 'qoder') { + return launch + } + return resolveQoderTerminalCommand(launch, { + connectionId: workspace.connectionId, + context: + !workspace.connectionId && store + ? { + projectRuntime: resolveLocalProjectRuntimeForWorktreeId(store, workspace.id), + wslDistro: parseWslUncPath(workspace.path)?.distro + } + : undefined, + shell: resolveStartupShell( + platform, + resolveLocalWindowsAgentStartupShell({ + platform, + isRemote: Boolean(workspace.connectionId), + terminalWindowsShell: launch.shellOverride ?? store?.getSettings().terminalWindowsShell + }) + ) + }) +} + +export async function resolveQoderTerminalCommand< + T extends Pick +>( + options: T, + host: { + connectionId?: string | null + context?: PreflightRuntimeContext + shell: AgentStartupShell + }, + detect = detectAgentCommandsOnHost +): Promise { + if (options.launchAgent !== 'qoder' || !options.command) { + return options + } + const parsed = tokenizeStartupCommand(options.command, host.shell) + // Explicit paths and configured recipes remain owned by the caller. + if (!parsed.ok) { + return options + } + const executableIndex = parsed.tokens.findIndex( + (token, index) => + token === TUI_AGENT_CONFIG.qoder.detectCmd && + (index === 0 || + parsed.tokens[index - 1] === '&&' || + (parsed.tokens[0] === 'Set-Location' && parsed.tokens[index - 1].endsWith(';'))) + ) + if (executableIndex === -1) { + return options + } + const candidates = getTuiAgentDetectCommands(TUI_AGENT_CONFIG.qoder) + const found = await detect(candidates, host) + const selected = candidates.find((candidate) => found.has(candidate)) + if (!selected || selected === parsed.tokens[executableIndex]) { + return options + } + const first = parsed.spans[executableIndex] + if (!first) { + return options + } + return { + ...options, + command: options.command.slice(0, first.start) + selected + options.command.slice(first.end), + ...(options.launchConfig + ? { + launchConfig: { + ...options.launchConfig, + agentCommand: options.launchConfig.agentCommand?.replace(/^qodercli(?=\s|$)/, selected) + } + } + : {}) + } +} diff --git a/src/main/runtime/runtime-agent-terminal-startup.ts b/src/main/runtime/runtime-agent-terminal-startup.ts new file mode 100644 index 00000000000..5cd0c01ae82 --- /dev/null +++ b/src/main/runtime/runtime-agent-terminal-startup.ts @@ -0,0 +1,77 @@ +import type { SessionOptionValue } from '../../shared/native-chat-session-options' +import type { RuntimeStore } from './runtime-store-contract' +import type { TerminalWorkspaceLaunchScope } from './runtime-legacy-worker-terminal-recovery-types' +import type { TerminalCreateOptions } from './runtime-terminal-contracts' +import { isTuiAgentEnabled } from '../../shared/tui-agent-selection' +import { resolveBareAgentLaunchCommand } from './runtime-agent-launch-resolution' +import { buildExecutionHostAgentStartupPlan } from '../opencode/opencode-model-startup-plan' +import { resolveTerminalStartupCwd } from '../../shared/terminal-startup-cwd' +import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' +import { agentStartedTelemetry } from '../agent-launch/agent-started-telemetry' + +export async function buildRuntimeAgentTerminalStartupOptions( + workspace: TerminalWorkspaceLaunchScope, + opts: TerminalCreateOptions, + settings: ReturnType, + platform: NodeJS.Platform, + sessionOptions: Record | undefined, + hostIdentity: string +): Promise { + // Why: `workspace.repo` is display metadata and may be a row from another host; the launch + // shape must match the PTY route this scope already resolved. + const isRemote = Boolean(workspace.connectionId) + if (opts.startupAgent && !isTuiAgentEnabled(opts.startupAgent, settings.disabledTuiAgents)) { + throw new Error(`Agent ${opts.startupAgent} is disabled. Choose an enabled agent.`) + } + const agent = + opts.startupAgent ?? + resolveBareAgentLaunchCommand({ + command: opts.command, + settings, + platform, + isRemote + }) + if (!agent) { + return opts + } + + const startupPlan = await buildExecutionHostAgentStartupPlan({ + inputs: resolveAgentStartupPlanInputs({ + agent, + settings, + platform, + isRemote, + ...(opts.agentArgs !== undefined ? { agentArgs: opts.agentArgs } : {}), + // A requested shell is the one this PTY will actually be, so it owns the quoting family. + windowsShellOverride: opts.shellOverride, + sessionOptions: sessionOptions + }), + prompt: opts.startupPrompt ?? '', + cwd: resolveTerminalStartupCwd(workspace.path, opts.cwd) ?? workspace.path, + hostIdentity + }) + if (!startupPlan) { + // Why: an explicit agent that yields no plan would otherwise spawn a bare + // shell that never reaches agent readiness. + if (opts.startupAgent) { + throw new Error(`Could not build launch command for ${opts.startupAgent}.`) + } + return opts + } + // A prompt this launch command cannot carry has nowhere to go from here — the create returns + // options, not a live PTY — so refuse rather than spawn the agent and drop the text. + if (opts.startupPrompt && 'followupPrompt' in startupPlan && startupPlan.followupPrompt) { + throw new Error(`Agent ${agent} does not take a startup prompt on its launch command.`) + } + + return { + ...opts, + command: startupPlan.launchCommand, + ...(startupPlan.env ? { env: startupPlan.env } : {}), + launchConfig: startupPlan.launchConfig, + launchAgent: agent, + startupCommandDelivery: startupPlan.startupCommandDelivery, + // A bare command the user typed stays out of launch accounting, as before. + ...(opts.startupAgent ? { telemetry: agentStartedTelemetry(agent, opts.launchSource) } : {}) + } +} diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index d623a3f1fd1..ea0803eb4df 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -4,7 +4,10 @@ import type { AgentSessionExecutionClaim, RuntimeCreateAgentSessionResult } from '../../shared/agent-session-host-authority' -import type { AgentProviderSessionMetadata } from '../../shared/agent-session-resume' +import type { + AgentProviderSessionMetadata, + SleepingAgentLaunchConfig +} from '../../shared/agent-session-resume' import type { OrchestrationCompatibilityHostStamp } from '../../shared/orchestration-compatibility-evidence' import type { TerminalOscColorQueryReplyColors } from '../../shared/terminal-osc-color-reply' import type { @@ -84,7 +87,10 @@ export type TerminalCreateOptions = { signal?: AbortSignal onPtySpawnCommitted?: () => void /** Called before the spawn request leaves this process; a throw before it proves nothing spawned. */ - onPtySpawnDispatched?: () => void + onPtySpawnDispatched?: (launch?: { + launchConfig?: SleepingAgentLaunchConfig + launchAgent?: TuiAgent + }) => void deferMobileSessionPublish?: boolean } diff --git a/src/renderer/src/components/editor/rich-markdown-image-insert.test.ts b/src/renderer/src/components/editor/rich-markdown-image-insert.test.ts index 576e9567545..71495d4ceaa 100644 --- a/src/renderer/src/components/editor/rich-markdown-image-insert.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-image-insert.test.ts @@ -180,7 +180,7 @@ describe('insertRichMarkdownImageFromPath', () => { worktreesByRepo: { repo1: [{ id: 'wt-1', path: '/repo' }] } }) vi.mocked(getConnectionIdForFile).mockReturnValue(connectionId) - const { editor } = editorWithRunResult(true) + const { editor, insertContentAt } = editorWithRunResult(true) await insertRichMarkdownImageFromPath({ editor: editor as never, @@ -191,6 +191,12 @@ describe('insertRichMarkdownImageFromPath', () => { }) expect(vi.mocked(importExternalPathsToRuntime).mock.calls[0]?.[3]).toEqual({ access }) + expect(insertContentAt).toHaveBeenCalledWith( + 4, + { type: 'image', attrs: { src: 'image.png' } }, + { updateSelection: true } + ) + expect(toast.error).not.toHaveBeenCalled() } ) diff --git a/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts b/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts index 1393c92e5a1..83e7d2a5a36 100644 --- a/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts +++ b/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts @@ -82,11 +82,15 @@ export function useHostModelCatalogUpgrade(args: { ) let leave: (() => void) | null = null const waitForListing = (): void => { - leave = joinHostModelListingWait(waitKey, () => read(true), (catalog) => { - if (catalog) { - apply(catalog) + leave = joinHostModelListingWait( + waitKey, + () => read(true), + (catalog) => { + if (catalog) { + apply(catalog) + } } - }) + ) } if (isHostModelListingWaitInFlight(waitKey)) { waitForListing() diff --git a/src/shared/agent-process-recognition.ts b/src/shared/agent-process-recognition.ts index b9a5620f6df..45c28f67966 100644 --- a/src/shared/agent-process-recognition.ts +++ b/src/shared/agent-process-recognition.ts @@ -171,7 +171,8 @@ export function isExpectedAgentProcess( normalizedProcess.startsWith(`${normalizedExpected}.`) || (['qoderclicn', 'qodercn'].includes(normalizedExpected) && /^(?:qoderclicn|qodercn)(?:-\d.*)?$/.test(normalizedProcess)) || - (normalizedExpected === 'qodercli' && /^qodercli-\d/.test(normalizedProcess)) || + (['qoder', 'qodercli'].includes(normalizedExpected) && + /^(?:qoder|qodercli(?:-\d.*)?)$/.test(normalizedProcess)) || (normalizedExpected === 'muse' && normalizedProcess.startsWith('muse-bin-')) ) } @@ -200,6 +201,10 @@ export function recognizeAgentProcessFromCommandLine( const tokens = tokenizeCommandLine(commandLine) const firstNormalized = normalizeProcessName(tokens[0]) let direct = recognizedAgentForProcess(firstNormalized) + // Qoder's public dispatcher routes these commands to the IDE rather than the agent CLI. + if (firstNormalized === 'qoder' && tokens[1] && !tokens[1].startsWith('-')) { + return null + } // Why: the generic Orca CLI is not an agent; only this subcommand launches its TUI mode. if (direct?.agent === 'claude-agent-teams' && tokens[1]?.toLowerCase() !== 'claude-teams') { direct = null diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 0ab7a210580..e07c656d5a2 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -1,4 +1,5 @@ import { AGENT_SESSION_RESUME_RUNTIME_CAPABILITIES } from './agent-session-resume-runtime-capabilities' +import { QODER_OWNED_TERMINAL_CREATE_CAPABILITY } from './qoder-terminal-create-capability' export { AGENT_SESSION_CURSOR_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY, @@ -354,6 +355,7 @@ export const AGENT_SESSION_CREATE_TAB_ID_RUNTIME_CAPABILITY = 'agentSession.create.tab-id.v1' as const export const RUNTIME_CAPABILITIES = [ + QODER_OWNED_TERMINAL_CREATE_CAPABILITY, ...AGENT_SESSION_STOP_RUNTIME_CAPABILITIES, AGENT_SESSION_CREATE_TAB_ID_RUNTIME_CAPABILITY, ANTIGRAVITY_CONFIGURED_MODEL_RUNTIME_CAPABILITY, diff --git a/src/shared/qoder-agent.test.ts b/src/shared/qoder-agent.test.ts index f7600368a10..5078ee686ef 100644 --- a/src/shared/qoder-agent.test.ts +++ b/src/shared/qoder-agent.test.ts @@ -11,7 +11,7 @@ import { normalizeTerminalTitle, detectAgentStatusFromTitle } from './agent-detection' -import { buildAgentStartupPlan } from './tui-agent-startup' +import { buildAgentStartupPlan, buildAgentResumeStartupPlan } from './tui-agent-startup' import { getAgentResumeArgv } from './agent-session-resume' import { createHookListenerState } from './agent-hook-listener/listener-state' import { normalizeAndAccept } from './agent-hook-listener-test-harness' @@ -19,12 +19,16 @@ import { normalizeAndAccept } from './agent-hook-listener-test-harness' describe('Qoder agent identity and lifecycle', () => { it('recognizes the installed versioned binary on native platforms and excludes headless runs', () => { for (const command of [ + 'qoder', + '/home/dev/.qoder/entry/qoder', + 'C:\\Qoder\\qoder.cmd', 'qodercli', '/home/dev/.qoder/bin/qodercli/qodercli-1.1.64', 'C:\\Qoder\\qodercli-1.1.64.exe' ]) { expect(recognizeAgentProcessFromCommandLine(command)?.agent).toBe('qoder') expect(isExpectedAgentProcess(command, 'qodercli')).toBe(true) + expect(isExpectedAgentProcess(command, 'qoder')).toBe(true) } expect(recognizeAgentProcessFromCommandLine('qodercli --print hello')).toBeNull() for (const args of [ @@ -40,8 +44,42 @@ describe('Qoder agent identity and lifecycle', () => { 'qoder' ) expect(recognizeAgentProcessFromCommandLine('qodercli-unrelated')).toBeNull() + expect(recognizeAgentProcessFromCommandLine('qoder-unrelated')).toBeNull() }) + it.each(['qoder', 'qodercli'])( + 'excludes headless %s and preserves interactive resume', + (command) => { + for (const args of [ + '--print hello', + '-p hello', + '--remote task', + '--list-sessions', + '--input-format stream-json', + '--output-format stream-json', + '--remote-control session' + ]) { + expect(recognizeAgentProcessFromCommandLine(`${command} ${args}`)).toBeNull() + } + expect( + recognizeAgentProcessFromCommandLine(`${command} --resume proof --prompt-interactive hello`) + ?.agent + ).toBe('qoder') + } + ) + + it.each(['ide', 'chat', 'serve-web', 'tunnel', './project', '/home/dev/project', 'C:\\project'])( + 'does not identify qoder %s as an agent', + (subcommand) => { + expect(recognizeAgentProcessFromCommandLine(`qoder ${subcommand}`)).toBeNull() + expect( + recognizeAgentProcessFromCommandLine(`qoder ${subcommand}`, { + includeHeadlessOneShot: true + }) + ).toBeNull() + } + ) + it.each([ ['◇ Qoder CLI | Ready', 'idle'], ['✦ Implement feature | Thinking', 'working'], @@ -144,6 +182,31 @@ it('does not rewrite DSH titles containing a pipe as Qoder', () => { expect(detectAgentStatusFromTitle(title)).not.toBe('working') }) +it.each(['darwin', 'linux', 'win32'] as const)( + 'starts and resumes a configured modern Qoder entry on %s', + (platform) => { + const plan = buildAgentStartupPlan({ + agent: 'qoder', + prompt: 'proof', + cmdOverrides: { qoder: 'qoder' }, + platform, + isRemote: platform === 'linux' + }) + expect(plan?.launchCommand).toBe("qoder --prompt-interactive 'proof'") + expect(plan?.launchConfig.agentCommand).toBe('qoder') + const resume = buildAgentResumeStartupPlan({ + agent: 'qoder', + providerSession: { key: 'session_id', id: 'proof-session' }, + cmdOverrides: {}, + agentCommand: plan?.launchConfig.agentCommand, + platform, + isRemote: platform === 'linux' + }) + expect(resume?.launchCommand).toBe("qoder '--resume' 'proof-session'") + expect(isExpectedAgentProcess('qodercli-1.1.65', resume?.expectedProcess ?? '')).toBe(true) + } +) + it('settles manual compaction without interrupting automatic compaction', () => { const state = createHookListenerState() const send = (hook_event_name: string, extra = {}) => diff --git a/src/shared/qoder-terminal-create-capability.ts b/src/shared/qoder-terminal-create-capability.ts new file mode 100644 index 00000000000..f5b4526ff65 --- /dev/null +++ b/src/shared/qoder-terminal-create-capability.ts @@ -0,0 +1 @@ +export const QODER_OWNED_TERMINAL_CREATE_CAPABILITY = 'session.tabs.qoderOwnedCreate.v1' diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index 0d69189ea3f..c10c0e82e6f 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -149,6 +149,8 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { }, qoder: { detectCmd: 'qodercli', + // The documented `qoder` dispatcher still launches the legacy CLI binary. + detectCmdAliases: ['qoder'], promptInjectionMode: 'flag-prompt-interactive', preflightTrust: 'qoder' },