mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 00:02:29 +00:00
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:
@@ -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'] })
|
||||
})
|
||||
|
||||
@@ -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: () => {} }
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user