Support modern Qoder commands and verify authenticated resume

Start and resume Qoder through the existing execution-host selector when only the documented qoder command exists. Preserve legacy qodercli preference, explicit commands, quoting and session identity; disconnected SSH execution refuses without local fallback.

Mobile history uses command-at-create only with the optional owned-create capability and an existing stable mutation identity. Reconcile authoritative execution-host inventory before retrying creation, adopt the same surviving operation, preserve WSL/incarnation metadata and restore only missing original launch metadata. Changed retry settings cannot replace original capture. Bounded evidence expires after 15 minutes; unavailable inventory or original evidence refuses recovery and leaves surviving execution untouched. Authoritative inventory proving absence preserves the existing recreation behavior; this is not a durable exactly-once ledger for completed one-shot side effects. Older hosts retain the acknowledged create-then-send path.

Scope mobile launch authority to the current committed host/client generation, and recheck operation ownership after asynchronous preparation before later sends. Retire the mutation once the host acknowledges the resume; later ownership changes stop navigation without reporting a completed resume as failed or reusing a cached legacy pane. Preserve genuinely interrupted mutation identity. Preserve current-main OpenCode validation and merged Pi/Cursor behavior. Correct unchanged-main editor test fixtures to their production insertion-range and store contracts while retaining original assertions and production behavior.

Credit: Neil Parker (@nwparker); Soperf and jyang for Qoder integration/history groundwork in #24614; actual Pullfrog and CodeRabbit reviews and the independent Source reviewer for the mobile retry, launch capture, evidence lifetime and connection ownership findings. Conservative positional process recognition and existing folder-history matching-worktree limitations remain documented.
This commit is contained in:
Neil
2026-10-04 19:50:45 -07:00
committed by GitHub
parent aac1c8f9c2
commit ebeea319b2
39 changed files with 2795 additions and 267 deletions
@@ -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<Worktree[]>([])
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)
@@ -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<RpcClient['sendRequest']>(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<RpcResponse>((resolve) => {
resolveRetired = resolve
})
initialSend.mockImplementation(async () => retiredStatus)
let refresh: Promise<void> | undefined
await act(async () => {
refresh = read().onRefresh()
})
let resolveStatus: (reply: RpcResponse) => void = () => {}
const pendingStatus = new Promise<RpcResponse>((resolve) => {
resolveStatus = resolve
})
const currentSend = vi.fn<RpcClient['sendRequest']>(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()
})
}
})
@@ -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<void>
} => ({
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<string, unknown>) => 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<void>
}) => {
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<RpcResponse>((resolve, reject) => {
rejectOptional = reject
resolveOptional = resolve
})
const sendRequest: RpcClient['sendRequest'] = vi.fn<RpcClient['sendRequest']>(
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<void> | 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())
}
})
@@ -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
}
}
@@ -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<AiVaultScope>('workspace')
const [screenState, setScreenState] = useState<AgentHistoryScreenState>({ kind: 'loading' })
const [hostStatusResult, setHostStatusResult] = useState<unknown>(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<void> => {
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
@@ -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<typeof owner | null>(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]
)
}
@@ -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'] })
})
+27 -1
View File
@@ -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<AiVaultSession, 'agent' | 'sessionId' | 'cwd' | 'codexHome'> &
@@ -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<MobileReviewTerminalTab> {
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,
{
@@ -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<typeof operationModuleLoader>
@@ -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<typeof ResumeMetadata>(
'mobile/src/agent-history/mobile-agent-history-resume-metadata.ts'
).loadMobileResumeMetadata
return { action: () => load(client), state: () => ({}), dispose: () => {} }
},
+24
View File
@@ -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 = {}
@@ -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(),
@@ -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<void> {
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
}
+2
View File
@@ -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
@@ -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<typeof FsPromises>()),
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<PtySpawnIpcArgs> = {}, 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')
}
}
)
})
@@ -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())
})
})
@@ -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'
@@ -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<Set<string>> {
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
}
+7 -34
View File
@@ -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<string[]> {
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
)
}
+48 -35
View File
@@ -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<void>((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<void>((resolve) => server.close(() => resolve()))
server.stop()
await rm(sandbox.home, { recursive: true, force: true })
sandbox.home = ''
}
@@ -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<RuntimeMobileSessionCreateTerminalResult> {
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) {
@@ -121,7 +121,8 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate
}
let result: Awaited<ReturnType<NonNullable<dependencies.RuntimePtyController['spawn']>>>
try {
launchOpts.onPtySpawnDispatched?.()
const { launchAgent } = launchOpts
launchOpts.onPtySpawnDispatched?.({ launchConfig: effectiveLaunchConfig, launchAgent })
result = await this.ptyController.spawn({
cols: 120,
rows: 40,
@@ -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<TerminalCreateOptions> {
const launch = await this.buildAgentTerminalCreateOptions(workspace, opts)
return resolveQoderTerminalCommandForWorkspace(
launch,
workspace,
this.store,
this.getAgentLaunchPlatformForWorkspace(workspace)
)
}
protected async buildAgentTerminalCreateOptions(
workspace: TerminalWorkspaceLaunchScope,
opts: TerminalCreateOptions
): Promise<TerminalCreateOptions> {
// 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
)
}
}
@@ -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(
@@ -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) {
@@ -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
}))
@@ -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,
@@ -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()
}
})
@@ -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)
@@ -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()
})
})
@@ -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<TerminalCreateOptions> {
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<TerminalCreateOptions, 'launchAgent' | 'command' | 'launchConfig'>
>(
options: T,
host: {
connectionId?: string | null
context?: PreflightRuntimeContext
shell: AgentStartupShell
},
detect = detectAgentCommandsOnHost
): Promise<T> {
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)
}
}
: {})
}
}
@@ -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<RuntimeStore['getSettings']>,
platform: NodeJS.Platform,
sessionOptions: Record<string, SessionOptionValue> | undefined,
hostIdentity: string
): Promise<TerminalCreateOptions> {
// 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) } : {})
}
}
@@ -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
}
@@ -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()
}
)
@@ -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()
+6 -1
View File
@@ -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
+2
View File
@@ -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,
+64 -1
View File
@@ -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 = {}) =>
@@ -0,0 +1 @@
export const QODER_OWNED_TERMINAL_CREATE_CAPABILITY = 'session.tabs.qoderOwnedCreate.v1'
+2
View File
@@ -149,6 +149,8 @@ const TUI_AGENT_CONFIG_SOURCE: Record<TuiAgent, TuiAgentConfigSource> = {
},
qoder: {
detectCmd: 'qodercli',
// The documented `qoder` dispatcher still launches the legacy CLI binary.
detectCmdAliases: ['qoder'],
promptInjectionMode: 'flag-prompt-interactive',
preflightTrust: 'qoder'
},