mirror of
https://github.com/stablyai/orca.git
synced 2026-10-06 16:02:25 +00:00
Support custom agents with host-aware launch recovery routing
- Custom agents route through agentLaunch contract for dynamic args/env instead of legacy fields - Mobile UI projects available custom agents based on detected base harnesses - SSH Windows hosts resolve default shell for correct startup command quoting - Capability probe errors now distinguish permanent (old relay method-not-found) from transient (connection) failures - Background launch recovery routes to the execution host where the attempt occurred, not the repo owner - Kimi agent removed from resumable agent list
This commit is contained in:
@@ -153,6 +153,7 @@ import { normalizeBrowserUrl } from '../../../../src/browser/browser-url'
|
||||
import { StatusDot } from '../../../../src/components/StatusDot'
|
||||
import { ActionSheetModal } from '../../../../src/components/ActionSheetModal'
|
||||
import { MobileAgentIcon } from '../../../../src/components/MobileAgentIcon'
|
||||
import { useAgentCatalogSnapshot } from '../../../../src/components/use-agent-catalog-snapshot'
|
||||
import { TextInputModal } from '../../../../src/components/TextInputModal'
|
||||
import { ConfirmModal } from '../../../../src/components/ConfirmModal'
|
||||
import { MobileMarkdownReader } from '../../../../src/session/MobileMarkdownReader'
|
||||
@@ -189,6 +190,8 @@ import {
|
||||
} from '../../../../src/session/mobile-terminal-tab-agent'
|
||||
import type { MobileNewTabAgentOption } from '../../../../src/session/mobile-new-tab-agent-options'
|
||||
import { loadMobileNewTabAgentOptions } from '../../../../src/session/mobile-new-tab-agent-loader'
|
||||
import { buildMobileNewTabCreateFields } from '../../../../src/session/mobile-new-tab-create-fields'
|
||||
import { readMobileNewTabCreatedTerminal } from '../../../../src/session/mobile-new-tab-create-result'
|
||||
import { useMobileSessionImageAttachments } from '../../../../src/session/use-mobile-session-image-attachments'
|
||||
import { useMobileAttachmentInputLeaseGate } from '../../../../src/session/use-mobile-attachment-input-lease-gate'
|
||||
import { useMobileTerminalPaste } from '../../../../src/session/use-mobile-terminal-paste'
|
||||
@@ -302,7 +305,6 @@ import type {
|
||||
RuntimeRepoSummary,
|
||||
SessionTabsResult,
|
||||
Terminal,
|
||||
TerminalCreateResult,
|
||||
TerminalGestureInputBucket,
|
||||
TerminalGestureInputQueue
|
||||
} from '../../../../src/session/mobile-session-route-types'
|
||||
@@ -599,6 +601,7 @@ export default function SessionScreen() {
|
||||
const insets = useSafeAreaInsets()
|
||||
// Why: shared client per host owned by RpcClientProvider (docs/mobile-shared-client-per-host.md).
|
||||
const { client, state: connState } = useHostClient(hostId)
|
||||
const agentCatalog = useAgentCatalogSnapshot(hostId)
|
||||
const reconnectAttempts = useReconnectAttempt(hostId)
|
||||
const lastConnectedAt = useLastConnectedAt(hostId)
|
||||
const relayRecovery = useRelayRecoveryStatus(hostId)
|
||||
@@ -3555,7 +3558,8 @@ export default function SessionScreen() {
|
||||
void (async () => {
|
||||
const options = await loadMobileNewTabAgentOptions({
|
||||
client,
|
||||
worktreeId
|
||||
worktreeId,
|
||||
catalogSnapshot: agentCatalog
|
||||
})
|
||||
if (stale) {
|
||||
return
|
||||
@@ -3572,7 +3576,7 @@ export default function SessionScreen() {
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [client, connState, pendingDiffNotesDelivery, showCreateTabDrawer, worktreeId])
|
||||
}, [agentCatalog, client, connState, pendingDiffNotesDelivery, showCreateTabDrawer, worktreeId])
|
||||
|
||||
async function handleCreateTerminal(
|
||||
agent?: MobileNewTabAgentOption['agent'],
|
||||
@@ -3599,19 +3603,18 @@ export default function SessionScreen() {
|
||||
worktree: `id:${worktreeId}`,
|
||||
afterTabId: activeSessionTabId ?? undefined,
|
||||
clientMutationId,
|
||||
...(options?.startupCommand ? { command: options.startupCommand } : {}),
|
||||
...(options?.startupCommandDelivery
|
||||
? { startupCommandDelivery: options.startupCommandDelivery }
|
||||
: {}),
|
||||
...(options?.agentPrompt ? { agentPrompt: options.agentPrompt } : {}),
|
||||
...(agent ? { agent } : {}),
|
||||
...buildMobileNewTabCreateFields({
|
||||
agent,
|
||||
agentPrompt: options?.agentPrompt,
|
||||
startupCommand: options?.startupCommand,
|
||||
startupCommandDelivery: options?.startupCommandDelivery
|
||||
}),
|
||||
activate: false,
|
||||
select: true,
|
||||
navigation: 'caller'
|
||||
})
|
||||
if (response.ok) {
|
||||
const result = (response as RpcSuccess).result as TerminalCreateResult
|
||||
const created = result.tab
|
||||
const created = readMobileNewTabCreatedTerminal((response as RpcSuccess).result)
|
||||
// Why: unsubscribe the old terminal so the server restores its desktop dims; otherwise its restore timer is never set.
|
||||
const prev = activeHandleRef.current
|
||||
if (prev) {
|
||||
@@ -3705,16 +3708,18 @@ export default function SessionScreen() {
|
||||
setCreateError(message)
|
||||
if (options?.errorToast) {
|
||||
triggerError()
|
||||
showToast(message, 1800)
|
||||
}
|
||||
showToast(message, 1800)
|
||||
}
|
||||
} catch {
|
||||
const message = options?.errorToast ?? 'Failed to create terminal'
|
||||
} catch (error) {
|
||||
const message =
|
||||
options?.errorToast ??
|
||||
(error instanceof Error ? error.message : 'Failed to create terminal')
|
||||
setCreateError(message)
|
||||
if (options?.errorToast) {
|
||||
triggerError()
|
||||
showToast(message, 1800)
|
||||
}
|
||||
showToast(message, 1800)
|
||||
} finally {
|
||||
creatingTerminalRef.current = false
|
||||
setCreating(false)
|
||||
|
||||
@@ -47,6 +47,7 @@ import { BottomDrawer } from '../../../src/components/BottomDrawer'
|
||||
import { ConfirmModal } from '../../../src/components/ConfirmModal'
|
||||
import { MobileMarkdown } from '../../../src/components/MobileMarkdown'
|
||||
import { MobileAgentIcon } from '../../../src/components/MobileAgentIcon'
|
||||
import { useAgentCatalogSnapshot } from '../../../src/components/use-agent-catalog-snapshot'
|
||||
import { MobileWorkspaceNameInput } from '../../../src/components/MobileWorkspaceNameInput'
|
||||
import { MobileSearchField } from '../../../src/components/MobileSearchField'
|
||||
import { MobileSyntaxSegments } from '../../../src/components/MobileSyntaxSegments'
|
||||
@@ -69,15 +70,17 @@ import {
|
||||
getHostedChecksLabel
|
||||
} from '../../../src/tasks/mobile-hosted-check-status'
|
||||
import { buildTaskWorkspaceCreateParams } from '../../../src/tasks/workspace-create-params'
|
||||
import { readTaskWorkspaceCreatedResult } from '../../../src/tasks/task-workspace-create-result'
|
||||
import { MOBILE_TASKS_CAPABILITY } from '../../../src/tasks/mobile-tasks-capability'
|
||||
import {
|
||||
filterWorkspaceAgents,
|
||||
isWorkspaceAgentEnabled,
|
||||
normalizeWorkspaceAgent,
|
||||
pickWorkspaceAgent,
|
||||
resolveWorkspaceAgentSelection,
|
||||
workspaceAgentLabel,
|
||||
type WorkspaceAgentChoice
|
||||
} from '../../../src/tasks/workspace-agent-selection'
|
||||
import { buildTaskWorkspaceAgentCatalog } from '../../../src/tasks/task-workspace-agent-catalog'
|
||||
import { shouldResolveHostedReviewStartPoint } from '../../../src/tasks/hosted-review-start-point'
|
||||
import { getLinkedWorkItemSuggestedName } from '../../../src/tasks/mobile-workspace-name'
|
||||
import {
|
||||
@@ -143,7 +146,6 @@ import {
|
||||
extractLinearIssueReadItems,
|
||||
type LinearMobileIssue
|
||||
} from '../../../src/tasks/linear-mobile-issue-read'
|
||||
import { MOBILE_TUI_AGENT_AUTO_PICK_ORDER } from '../../../src/tasks/mobile-tui-agents'
|
||||
import { resolveComposerBranchSelection } from '../../../src/tasks/mobile-composer-branch-selection'
|
||||
import {
|
||||
clearMobileTaskCopyFeedbackTimer,
|
||||
@@ -2072,6 +2074,7 @@ export default function MobileTasksScreen() {
|
||||
const router = useRouter()
|
||||
const insets = useSafeAreaInsets()
|
||||
const { client, state: connState } = useHostClient(hostId)
|
||||
const agentCatalog = useAgentCatalogSnapshot(hostId)
|
||||
const reconnectAttempts = useReconnectAttempt(hostId)
|
||||
const lastConnectedAt = useLastConnectedAt(hostId)
|
||||
const relayRecovery = useRelayRecoveryStatus(hostId)
|
||||
@@ -4826,30 +4829,22 @@ export default function MobileTasksScreen() {
|
||||
!workspaceSparseDraftError &&
|
||||
workspaceSparseDraftParsed !== null
|
||||
|
||||
const workspaceAgentOptions = useMemo<PickerOption<WorkspaceAgentChoice>[]>(() => {
|
||||
const enabledAgents = filterWorkspaceAgents(
|
||||
MOBILE_TUI_AGENT_AUTO_PICK_ORDER,
|
||||
runtimeTaskSettings.disabledTuiAgents
|
||||
)
|
||||
const availableAgents =
|
||||
workspaceDetectedAgentIds === null
|
||||
? new Set<TuiAgent>(enabledAgents)
|
||||
: new Set<TuiAgent>(enabledAgents.filter((agent) => workspaceDetectedAgentIds.has(agent)))
|
||||
if (
|
||||
workspaceAgent &&
|
||||
workspaceAgent !== 'blank' &&
|
||||
isWorkspaceAgentEnabled(workspaceAgent, runtimeTaskSettings.disabledTuiAgents) &&
|
||||
(workspaceDetectedAgentIds === null || workspaceDetectedAgentIds.has(workspaceAgent))
|
||||
) {
|
||||
availableAgents.add(workspaceAgent)
|
||||
}
|
||||
const agents = MOBILE_TUI_AGENT_AUTO_PICK_ORDER.filter((agent) => availableAgents.has(agent))
|
||||
return [
|
||||
...agents.map((agent) => ({
|
||||
value: agent,
|
||||
label: workspaceAgentLabel(agent),
|
||||
subtitle: agent,
|
||||
renderIcon: () => <MobileAgentIcon agentId={workspaceAgentIconId(agent)} size={18} />
|
||||
const workspaceAgentCatalog = useMemo(
|
||||
() =>
|
||||
buildTaskWorkspaceAgentCatalog(
|
||||
agentCatalog,
|
||||
workspaceDetectedAgentIds,
|
||||
runtimeTaskSettings.disabledTuiAgents
|
||||
),
|
||||
[agentCatalog, runtimeTaskSettings.disabledTuiAgents, workspaceDetectedAgentIds]
|
||||
)
|
||||
const workspaceAgentOptions = useMemo<PickerOption<WorkspaceAgentChoice>[]>(
|
||||
() => [
|
||||
...workspaceAgentCatalog.rows.map((row) => ({
|
||||
value: row.id,
|
||||
label: row.label,
|
||||
subtitle: row.isCustom ? `Custom ${workspaceAgentLabel(row.baseAgent!)}` : row.id,
|
||||
renderIcon: () => <MobileAgentIcon agentId={row.baseAgent ?? row.id} size={18} />
|
||||
})),
|
||||
{
|
||||
value: 'blank' as const,
|
||||
@@ -4857,8 +4852,9 @@ export default function MobileTasksScreen() {
|
||||
subtitle: 'Open a shell',
|
||||
renderIcon: () => <MobileAgentIcon agentId="__blank__" size={18} />
|
||||
}
|
||||
]
|
||||
}, [runtimeTaskSettings.disabledTuiAgents, workspaceAgent, workspaceDetectedAgentIds])
|
||||
],
|
||||
[workspaceAgentCatalog.rows]
|
||||
)
|
||||
const openWorkspaceCreate = useCallback((item: ActionableTaskItem, repoIdOverride?: string) => {
|
||||
const suggestedName = taskWorkspaceSuggestedName(item)
|
||||
setWorkspaceCreateDraft({ item, ...(repoIdOverride ? { repoIdOverride } : {}) })
|
||||
@@ -5323,6 +5319,7 @@ export default function MobileTasksScreen() {
|
||||
selectionActive: tasksSupported && workspaceCreateDraft !== null,
|
||||
settings: runtimeTaskSettings,
|
||||
detectedAgentIds: workspaceDetectedAgentIds,
|
||||
customAgentBases: workspaceAgentCatalog.customAgentBases,
|
||||
agent: workspaceAgent,
|
||||
overridden: workspaceAgentOverridden
|
||||
})
|
||||
@@ -5336,10 +5333,13 @@ export default function MobileTasksScreen() {
|
||||
setWorkspaceAgentOverridden(workspaceAgentSelection.overridden)
|
||||
}
|
||||
|
||||
const resolvedWorkspaceAgent = useMemo(
|
||||
() => workspaceAgent ?? pickWorkspaceAgent(runtimeTaskSettings, workspaceDetectedAgentIds),
|
||||
[runtimeTaskSettings, workspaceAgent, workspaceDetectedAgentIds]
|
||||
)
|
||||
const resolvedWorkspaceAgent =
|
||||
workspaceAgent ??
|
||||
pickWorkspaceAgent(
|
||||
runtimeTaskSettings,
|
||||
workspaceDetectedAgentIds,
|
||||
workspaceAgentCatalog.customAgentBases
|
||||
)
|
||||
const workspaceAgentDetectionPending =
|
||||
workspaceCreateDraft != null &&
|
||||
workspaceCreateTargetRepo != null &&
|
||||
@@ -5430,17 +5430,20 @@ export default function MobileTasksScreen() {
|
||||
} catch {
|
||||
// Best-effort refresh; the runtime still validates agent availability before spawning.
|
||||
}
|
||||
const selectedAgent =
|
||||
agentOverride &&
|
||||
(agentOverride === 'blank' ||
|
||||
const agentOverrideAvailable =
|
||||
agentOverride === 'blank' ||
|
||||
(agentOverride !== undefined &&
|
||||
normalizeWorkspaceAgent(agentOverride, workspaceAgentCatalog.customAgentBases) ===
|
||||
agentOverride &&
|
||||
isWorkspaceAgentEnabled(agentOverride, latestRuntimeTaskSettings.disabledTuiAgents))
|
||||
? agentOverride
|
||||
: pickWorkspaceAgent(latestRuntimeTaskSettings, workspaceDetectedAgentIds)
|
||||
if (
|
||||
agentOverride &&
|
||||
agentOverride !== 'blank' &&
|
||||
!isWorkspaceAgentEnabled(agentOverride, latestRuntimeTaskSettings.disabledTuiAgents)
|
||||
) {
|
||||
const selectedAgent = agentOverrideAvailable
|
||||
? agentOverride!
|
||||
: pickWorkspaceAgent(
|
||||
latestRuntimeTaskSettings,
|
||||
workspaceDetectedAgentIds,
|
||||
workspaceAgentCatalog.customAgentBases
|
||||
)
|
||||
if (agentOverride && agentOverride !== 'blank' && !agentOverrideAvailable) {
|
||||
setWorkspaceAgent(selectedAgent)
|
||||
setWorkspaceAgentOverridden(false)
|
||||
throw new Error('Selected agent is disabled. Choose an enabled agent before creating.')
|
||||
@@ -5606,10 +5609,7 @@ export default function MobileTasksScreen() {
|
||||
if (!isSuccess(response)) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
const result = response.result as {
|
||||
worktree: { id: string; displayName?: string }
|
||||
warning?: string
|
||||
}
|
||||
const result = readTaskWorkspaceCreatedResult(response.result)
|
||||
setActionItem(null)
|
||||
setWorkspaceCreateDraft(null)
|
||||
setSetupPrompt(null)
|
||||
@@ -5638,6 +5638,7 @@ export default function MobileTasksScreen() {
|
||||
taskStateHydrated,
|
||||
tasksSupported,
|
||||
trustedOrcaHooks,
|
||||
workspaceAgentCatalog.customAgentBases,
|
||||
workspaceDetectedAgentIds
|
||||
]
|
||||
)
|
||||
@@ -11034,7 +11035,10 @@ export default function MobileTasksScreen() {
|
||||
? 'Connect repository first'
|
||||
: workspaceAgentDetectionPending
|
||||
? 'Detecting agents...'
|
||||
: workspaceAgentLabel(resolvedWorkspaceAgent)}
|
||||
: workspaceAgentLabel(
|
||||
resolvedWorkspaceAgent,
|
||||
workspaceAgentCatalog.customAgentLabels
|
||||
)}
|
||||
</Text>
|
||||
<ChevronDown size={14} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentCatalogSnapshot } from '../../../src/shared/agent-catalog-snapshot'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { newWorktreeAgentOptionFor } from './new-worktree-agent-selection'
|
||||
import { resolveNewWorktreeAgentIdentitySupport } from './new-worktree-agent-identity-support'
|
||||
|
||||
const catalog: AgentCatalogSnapshot = {
|
||||
version: 1,
|
||||
revision: 1,
|
||||
defaultAgent: 'auto',
|
||||
disabledAgents: [],
|
||||
customAgents: [
|
||||
{
|
||||
id: 'custom-agent:claude:one',
|
||||
baseAgent: 'claude',
|
||||
label: 'My Claude',
|
||||
args: '',
|
||||
syncEnv: false,
|
||||
status: 'ready',
|
||||
envState: 'none',
|
||||
availabilityCheck: 'baseline-detection'
|
||||
}
|
||||
],
|
||||
deletedCustomAgents: []
|
||||
}
|
||||
|
||||
describe('new worktree agent identity support', () => {
|
||||
it('keeps a catalog-backed custom on identity launch after a transient status failure', async () => {
|
||||
const client = { sendRequest: vi.fn().mockRejectedValue(new Error('temporary')) }
|
||||
await expect(
|
||||
resolveNewWorktreeAgentIdentitySupport({
|
||||
client: client as unknown as RpcClient,
|
||||
selectedAgent: newWorktreeAgentOptionFor('custom-agent:claude:one', catalog),
|
||||
catalogSnapshot: catalog
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('does not infer identity support for a built-in from the custom catalog', async () => {
|
||||
const client = { sendRequest: vi.fn().mockRejectedValue(new Error('temporary')) }
|
||||
await expect(
|
||||
resolveNewWorktreeAgentIdentitySupport({
|
||||
client: client as unknown as RpcClient,
|
||||
selectedAgent: newWorktreeAgentOptionFor('claude'),
|
||||
catalogSnapshot: catalog
|
||||
})
|
||||
).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('honors an explicit status response that lacks identity support', async () => {
|
||||
const client = { sendRequest: vi.fn().mockResolvedValue({ ok: true, result: {} }) }
|
||||
await expect(
|
||||
resolveNewWorktreeAgentIdentitySupport({
|
||||
client: client as unknown as RpcClient,
|
||||
selectedAgent: newWorktreeAgentOptionFor('custom-agent:claude:one', catalog),
|
||||
catalogSnapshot: catalog
|
||||
})
|
||||
).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
import type { AgentCatalogValue } from '../transport/agent-catalog-sync'
|
||||
import { hostSupportsAgentLaunchIdentity } from '../session/agent-launch-identity-capability'
|
||||
import { buildMobileAgentPickerRows } from '../tasks/mobile-agent-catalog-projection'
|
||||
import type { NewWorktreeAgentOption } from './new-worktree-agent-selection'
|
||||
|
||||
export async function resolveNewWorktreeAgentIdentitySupport(args: {
|
||||
client: Pick<RpcClient, 'sendRequest'>
|
||||
selectedAgent: NewWorktreeAgentOption
|
||||
catalogSnapshot: AgentCatalogValue | null
|
||||
}): Promise<boolean> {
|
||||
const status = await args.client.sendRequest('status.get').catch(() => null)
|
||||
if (status?.ok) {
|
||||
return hostSupportsAgentLaunchIdentity((status as RpcSuccess).result)
|
||||
}
|
||||
if (!args.selectedAgent.isCustom) {
|
||||
return false
|
||||
}
|
||||
return buildMobileAgentPickerRows(args.catalogSnapshot, { includeCustomAgents: true }).some(
|
||||
(row) => row.isCustom && row.id === args.selectedAgent.id
|
||||
)
|
||||
}
|
||||
@@ -27,8 +27,8 @@ import type { MobileWorkspaceRepo, SetupRunPolicy } from './new-worktree-modal-t
|
||||
import type { SetupTrustPrompt } from './SetupHookTrustDrawer'
|
||||
import type { NewWorktreeDrawerView } from './use-new-worktree-drawer-navigation'
|
||||
import { getSuggestedCreatureName } from './worktree-name-suggestion'
|
||||
import { hostSupportsAgentLaunchIdentity } from '../session/agent-launch-identity-capability'
|
||||
import { buildInteractiveLaunchParams } from './interactive-worktree-launch-params'
|
||||
import { resolveNewWorktreeAgentIdentitySupport } from './new-worktree-agent-identity-support'
|
||||
|
||||
type CreateOptions = {
|
||||
setupOverride?: Exclude<WorkspaceCreateSetupDecision, 'inherit'>
|
||||
@@ -106,17 +106,11 @@ export function useNewWorkspaceCreateSubmit(args: {
|
||||
} catch {
|
||||
// The runtime validates the same setting before spawning.
|
||||
}
|
||||
let hasIdentityCapability = false
|
||||
try {
|
||||
const statusResponse = await client.sendRequest('status.get')
|
||||
if (statusResponse.ok) {
|
||||
hasIdentityCapability = hostSupportsAgentLaunchIdentity(
|
||||
(statusResponse as RpcSuccess).result
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// Best-effort probe; older hosts keep the legacy launch path.
|
||||
}
|
||||
const hasIdentityCapability = await resolveNewWorktreeAgentIdentitySupport({
|
||||
client,
|
||||
selectedAgent: args.selectedAgent,
|
||||
catalogSnapshot: args.agentCatalog
|
||||
})
|
||||
if (
|
||||
args.selectedAgent.id !== '__blank__' &&
|
||||
!isMobileTuiAgentEnabled(args.selectedAgent.id, latestRuntimeSettings?.disabledTuiAgents)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { FLOATING_WORKSPACE_WORKTREE_ID } from './floating-workspace'
|
||||
import { loadMobileNewTabAgentOptions } from './mobile-new-tab-agent-loader'
|
||||
import type { AgentCatalogSnapshot } from '../../../src/shared/agent-catalog-snapshot'
|
||||
|
||||
function createClient(
|
||||
handler: (method: string, params?: unknown) => Promise<unknown>
|
||||
@@ -73,4 +74,46 @@ describe('mobile new-tab agent loading', () => {
|
||||
'preflight.detectRemoteAgents'
|
||||
])
|
||||
})
|
||||
|
||||
it('projects custom agents from the already-synced host catalog', async () => {
|
||||
const catalogSnapshot: AgentCatalogSnapshot = {
|
||||
version: 1,
|
||||
revision: 2,
|
||||
defaultAgent: 'auto',
|
||||
disabledAgents: [],
|
||||
customAgents: [
|
||||
{
|
||||
id: 'custom-agent:claude:one',
|
||||
baseAgent: 'claude',
|
||||
label: 'My Claude',
|
||||
args: '',
|
||||
syncEnv: false,
|
||||
status: 'ready',
|
||||
envState: 'none',
|
||||
availabilityCheck: 'baseline-detection'
|
||||
}
|
||||
],
|
||||
deletedCustomAgents: []
|
||||
}
|
||||
const client = createClient(async (method) => {
|
||||
if (method === 'settings.get') {
|
||||
return { ok: true, result: { settings: {} } }
|
||||
}
|
||||
if (method === 'preflight.detectAgents') {
|
||||
return { ok: true, result: ['claude'] }
|
||||
}
|
||||
throw new Error(`unexpected request: ${method}`)
|
||||
})
|
||||
|
||||
await expect(
|
||||
loadMobileNewTabAgentOptions({
|
||||
client,
|
||||
worktreeId: FLOATING_WORKSPACE_WORKTREE_ID,
|
||||
catalogSnapshot
|
||||
})
|
||||
).resolves.toEqual([
|
||||
{ agent: 'claude', label: 'Claude' },
|
||||
{ agent: 'custom-agent:claude:one', label: 'My Claude' }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcFailure, RpcSuccess } from '../transport/types'
|
||||
import type { AgentCatalogValue } from '../transport/agent-catalog-sync'
|
||||
import { isFloatingWorkspaceWorktreeId } from './floating-workspace'
|
||||
import { getRepoIdFromMobileWorktreeId } from './mobile-session-route-helpers'
|
||||
import {
|
||||
@@ -16,8 +17,9 @@ type RuntimeRepoSummary = {
|
||||
export async function loadMobileNewTabAgentOptions(args: {
|
||||
client: RpcClient
|
||||
worktreeId: string
|
||||
catalogSnapshot?: AgentCatalogValue | null
|
||||
}): Promise<MobileNewTabAgentOption[]> {
|
||||
const { client, worktreeId } = args
|
||||
const { client, worktreeId, catalogSnapshot = null } = args
|
||||
// Why: the floating workspace runs on the paired host, so it has no repo connection to resolve.
|
||||
const detectedAgentsRequest = isFloatingWorkspaceWorktreeId(worktreeId)
|
||||
? client.sendRequest('preflight.detectAgents')
|
||||
@@ -40,7 +42,8 @@ export async function loadMobileNewTabAgentOptions(args: {
|
||||
).settings
|
||||
return buildMobileNewTabAgentOptions(
|
||||
settings,
|
||||
(detectedResponse as RpcSuccess).result as unknown[]
|
||||
(detectedResponse as RpcSuccess).result as unknown[],
|
||||
catalogSnapshot
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentCatalogSnapshot } from '../../../src/shared/agent-catalog-snapshot'
|
||||
|
||||
import {
|
||||
buildMobileNewTabAgentOptions,
|
||||
@@ -6,6 +7,26 @@ import {
|
||||
} from './mobile-new-tab-agent-options'
|
||||
|
||||
describe('mobile new-tab agent options', () => {
|
||||
const catalog: AgentCatalogSnapshot = {
|
||||
version: 1,
|
||||
revision: 1,
|
||||
defaultAgent: 'auto',
|
||||
disabledAgents: [],
|
||||
customAgents: [
|
||||
{
|
||||
id: 'custom-agent:claude:one',
|
||||
baseAgent: 'claude',
|
||||
label: 'My Claude',
|
||||
args: '',
|
||||
syncEnv: false,
|
||||
status: 'ready',
|
||||
envState: 'none',
|
||||
availabilityCheck: 'baseline-detection'
|
||||
}
|
||||
],
|
||||
deletedCustomAgents: []
|
||||
}
|
||||
|
||||
it('orders the enabled detected default first', () => {
|
||||
expect(orderMobileNewTabAgents('codex', ['gemini', 'codex', 'claude'], ['gemini'])).toEqual([
|
||||
'codex',
|
||||
@@ -26,4 +47,32 @@ describe('mobile new-tab agent options', () => {
|
||||
it('does not show stale presets while detection is pending', () => {
|
||||
expect(buildMobileNewTabAgentOptions({ defaultTuiAgent: 'codex' }, null)).toEqual([])
|
||||
})
|
||||
|
||||
it('includes catalog-backed custom agents when their base is detected', () => {
|
||||
expect(buildMobileNewTabAgentOptions({}, ['claude', 'codex'], catalog)).toEqual([
|
||||
{ agent: 'claude', label: 'Claude' },
|
||||
{ agent: 'custom-agent:claude:one', label: 'My Claude' },
|
||||
{ agent: 'codex', label: 'Codex' }
|
||||
])
|
||||
})
|
||||
|
||||
it('orders an available custom default first', () => {
|
||||
expect(
|
||||
buildMobileNewTabAgentOptions(
|
||||
{ defaultTuiAgent: 'custom-agent:claude:one' },
|
||||
['claude', 'codex'],
|
||||
catalog
|
||||
)
|
||||
).toEqual([
|
||||
{ agent: 'custom-agent:claude:one', label: 'My Claude' },
|
||||
{ agent: 'claude', label: 'Claude' },
|
||||
{ agent: 'codex', label: 'Codex' }
|
||||
])
|
||||
})
|
||||
|
||||
it('hides a custom agent when its base is not detected', () => {
|
||||
expect(buildMobileNewTabAgentOptions({}, ['codex'], catalog)).toEqual([
|
||||
{ agent: 'codex', label: 'Codex' }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { BuiltInTuiAgent, TuiAgent } from '../../../src/shared/types'
|
||||
import type { AgentCatalogValue } from '../transport/agent-catalog-sync'
|
||||
import { buildMobileAgentPickerRows } from '../tasks/mobile-agent-catalog-projection'
|
||||
import {
|
||||
filterEnabledMobileTuiAgents,
|
||||
isMobileTuiAgent,
|
||||
MOBILE_TUI_AGENT_AUTO_PICK_ORDER,
|
||||
MOBILE_TUI_AGENT_LABELS
|
||||
MOBILE_TUI_AGENT_AUTO_PICK_ORDER
|
||||
} from '../tasks/mobile-tui-agents'
|
||||
|
||||
export type MobileNewTabAgentSettings = {
|
||||
@@ -40,17 +41,33 @@ export function orderMobileNewTabAgents(
|
||||
|
||||
export function buildMobileNewTabAgentOptions(
|
||||
settings: MobileNewTabAgentSettings | null | undefined,
|
||||
detectedAgentIds: Iterable<unknown> | null
|
||||
detectedAgentIds: Iterable<unknown> | null,
|
||||
catalogSnapshot: AgentCatalogValue | null = null
|
||||
): MobileNewTabAgentOption[] {
|
||||
if (!detectedAgentIds) {
|
||||
return []
|
||||
}
|
||||
return orderMobileNewTabAgents(
|
||||
settings?.defaultTuiAgent,
|
||||
detectedAgentIds,
|
||||
settings?.disabledTuiAgents
|
||||
).map((agent) => ({
|
||||
agent,
|
||||
label: MOBILE_TUI_AGENT_LABELS[agent]
|
||||
}))
|
||||
const detected = new Set([...detectedAgentIds].filter(isMobileTuiAgent))
|
||||
const options = buildMobileAgentPickerRows(catalogSnapshot, {
|
||||
includeCustomAgents: true
|
||||
})
|
||||
.filter((row) => {
|
||||
if (row.isCustom) {
|
||||
return row.baseAgent !== undefined && detected.has(row.baseAgent)
|
||||
}
|
||||
return (
|
||||
detected.has(row.id as BuiltInTuiAgent) &&
|
||||
filterEnabledMobileTuiAgents([row.id], settings?.disabledTuiAgents).length > 0
|
||||
)
|
||||
})
|
||||
.map((row) => ({ agent: row.id, label: row.label }))
|
||||
const preferred = settings?.defaultTuiAgent
|
||||
if (!preferred || preferred === 'blank') {
|
||||
return options
|
||||
}
|
||||
const preferredIndex = options.findIndex((option) => option.agent === preferred)
|
||||
if (preferredIndex <= 0) {
|
||||
return options
|
||||
}
|
||||
return [options[preferredIndex]!, ...options.filter((_, index) => index !== preferredIndex)]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { TuiAgent } from '../../../src/shared/tui-agent'
|
||||
import { buildMobileNewTabCreateFields } from './mobile-new-tab-create-fields'
|
||||
|
||||
const customAgent = 'custom-agent:claude:0f8b7c6a-1d2e-4a3b-9c4d-5e6f7a8b9c0d' as TuiAgent
|
||||
|
||||
describe('buildMobileNewTabCreateFields', () => {
|
||||
it('routes a custom picker row through the custom-admitting agentLaunch field', () => {
|
||||
expect(buildMobileNewTabCreateFields({ agent: customAgent })).toEqual({
|
||||
agentLaunch: {
|
||||
selection: { kind: 'agent', agent: customAgent },
|
||||
allowEmptyPromptLaunch: true
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('puts a custom agent prompt inside the atomic launch request', () => {
|
||||
expect(buildMobileNewTabCreateFields({ agent: customAgent, agentPrompt: 'Review it' })).toEqual(
|
||||
{
|
||||
agentLaunch: {
|
||||
selection: { kind: 'agent', agent: customAgent },
|
||||
prompt: 'Review it'
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves the legacy fields for built-in agents', () => {
|
||||
expect(buildMobileNewTabCreateFields({ agent: 'codex', agentPrompt: 'Review it' })).toEqual({
|
||||
agent: 'codex',
|
||||
agentPrompt: 'Review it'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { isCustomTuiAgentId } from '../../../src/shared/custom-tui-agent-identity'
|
||||
import type { TuiAgent } from '../../../src/shared/tui-agent'
|
||||
|
||||
export function buildMobileNewTabCreateFields(args: {
|
||||
agent?: TuiAgent
|
||||
agentPrompt?: string
|
||||
startupCommand?: string
|
||||
startupCommandDelivery?: 'shell-ready'
|
||||
}): Record<string, unknown> {
|
||||
if (args.agent && isCustomTuiAgentId(args.agent)) {
|
||||
return {
|
||||
agentLaunch: {
|
||||
selection: { kind: 'agent', agent: args.agent },
|
||||
...(args.agentPrompt ? { prompt: args.agentPrompt } : { allowEmptyPromptLaunch: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
...(args.startupCommand ? { command: args.startupCommand } : {}),
|
||||
...(args.startupCommandDelivery ? { startupCommandDelivery: args.startupCommandDelivery } : {}),
|
||||
...(args.agentPrompt ? { agentPrompt: args.agentPrompt } : {}),
|
||||
...(args.agent ? { agent: args.agent } : {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readMobileNewTabCreatedTerminal } from './mobile-new-tab-create-result'
|
||||
|
||||
describe('readMobileNewTabCreatedTerminal', () => {
|
||||
it('returns a created terminal tab', () => {
|
||||
const tab = { id: 'tab-1', type: 'terminal', title: 'Agent', terminal: 'pty-1' } as const
|
||||
expect(readMobileNewTabCreatedTerminal({ tab })).toBe(tab)
|
||||
})
|
||||
|
||||
it('surfaces a disabled custom agent from the success-without-tab envelope', () => {
|
||||
expect(() =>
|
||||
readMobileNewTabCreatedTerminal({
|
||||
agentLaunch: {
|
||||
status: 'failed',
|
||||
failure: { code: 'custom_agent_disabled' }
|
||||
}
|
||||
})
|
||||
).toThrow("Couldn't start the agent (custom_agent_disabled).")
|
||||
})
|
||||
|
||||
it('preserves an admission rejection code', () => {
|
||||
expect(() =>
|
||||
readMobileNewTabCreatedTerminal({
|
||||
agentLaunch: {
|
||||
status: 'rejected',
|
||||
requestError: { code: 'untrusted_reference' }
|
||||
}
|
||||
})
|
||||
).toThrow("Couldn't create the terminal (untrusted_reference).")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import type {
|
||||
AgentLaunchFailureCode,
|
||||
AgentLaunchRequestError
|
||||
} from '../../../src/shared/agent-launch-contract'
|
||||
import type { TerminalCreateResult } from './mobile-session-route-types'
|
||||
|
||||
export function readMobileNewTabCreatedTerminal(result: unknown): TerminalCreateResult['tab'] {
|
||||
const envelope = result as {
|
||||
tab?: TerminalCreateResult['tab']
|
||||
agentLaunch?:
|
||||
| { status: 'failed'; failure: { code: AgentLaunchFailureCode } }
|
||||
| { status: 'rejected'; requestError: AgentLaunchRequestError }
|
||||
} | null
|
||||
if (envelope?.tab) {
|
||||
return envelope.tab
|
||||
}
|
||||
if (envelope?.agentLaunch?.status === 'failed') {
|
||||
throw new Error(`Couldn't start the agent (${envelope.agentLaunch.failure.code}).`)
|
||||
}
|
||||
if (envelope?.agentLaunch?.status === 'rejected') {
|
||||
throw new Error(`Couldn't create the terminal (${envelope.agentLaunch.requestError.code}).`)
|
||||
}
|
||||
throw new Error('Created terminal response was invalid')
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentCatalogSnapshot } from '../../../src/shared/agent-catalog-snapshot'
|
||||
import { buildTaskWorkspaceAgentCatalog } from './task-workspace-agent-catalog'
|
||||
|
||||
const catalog: AgentCatalogSnapshot = {
|
||||
version: 1,
|
||||
revision: 1,
|
||||
defaultAgent: 'auto',
|
||||
disabledAgents: [],
|
||||
customAgents: [
|
||||
{
|
||||
id: 'custom-agent:claude:one',
|
||||
baseAgent: 'claude',
|
||||
label: 'My Claude',
|
||||
args: '',
|
||||
syncEnv: false,
|
||||
status: 'ready',
|
||||
envState: 'none',
|
||||
availabilityCheck: 'baseline-detection'
|
||||
}
|
||||
],
|
||||
deletedCustomAgents: []
|
||||
}
|
||||
|
||||
describe('task workspace agent catalog', () => {
|
||||
it('provides custom bases and labels when the base harness is detected', () => {
|
||||
const projection = buildTaskWorkspaceAgentCatalog(catalog, new Set(['claude']), [])
|
||||
expect(projection.rows.map((row) => row.id)).toEqual(['claude', 'custom-agent:claude:one'])
|
||||
expect(projection.customAgentBases.get('custom-agent:claude:one')).toBe('claude')
|
||||
expect(projection.customAgentLabels.get('custom-agent:claude:one')).toBe('My Claude')
|
||||
})
|
||||
|
||||
it('hides custom rows when their base harness is unavailable', () => {
|
||||
const projection = buildTaskWorkspaceAgentCatalog(catalog, new Set(['codex']))
|
||||
expect(projection.rows.some((row) => row.isCustom)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { TuiAgent } from '../../../src/shared/types'
|
||||
import type { AgentCatalogValue } from '../transport/agent-catalog-sync'
|
||||
import {
|
||||
buildMobileAgentPickerRows,
|
||||
type MobileAgentPickerRow
|
||||
} from './mobile-agent-catalog-projection'
|
||||
import {
|
||||
isWorkspaceAgentEnabled,
|
||||
type WorkspaceCustomAgentBases
|
||||
} from './workspace-agent-selection'
|
||||
|
||||
export type TaskWorkspaceAgentCatalog = {
|
||||
rows: MobileAgentPickerRow[]
|
||||
customAgentBases: WorkspaceCustomAgentBases
|
||||
customAgentLabels: ReadonlyMap<TuiAgent, string>
|
||||
}
|
||||
|
||||
export function buildTaskWorkspaceAgentCatalog(
|
||||
snapshot: AgentCatalogValue | null,
|
||||
detectedAgentIds: Set<string> | null,
|
||||
disabledTuiAgents?: unknown
|
||||
): TaskWorkspaceAgentCatalog {
|
||||
const rows = buildMobileAgentPickerRows(snapshot, { includeCustomAgents: true }).filter((row) => {
|
||||
const detectedId = row.isCustom ? row.baseAgent : row.id
|
||||
return (
|
||||
detectedId !== undefined &&
|
||||
(row.isCustom || isWorkspaceAgentEnabled(row.id, disabledTuiAgents)) &&
|
||||
(detectedAgentIds === null || detectedAgentIds.has(detectedId))
|
||||
)
|
||||
})
|
||||
const customAgentBases = new Map<TuiAgent, NonNullable<MobileAgentPickerRow['baseAgent']>>()
|
||||
const customAgentLabels = new Map<TuiAgent, string>()
|
||||
for (const row of rows) {
|
||||
if (row.isCustom && row.baseAgent) {
|
||||
customAgentBases.set(row.id, row.baseAgent)
|
||||
customAgentLabels.set(row.id, row.label)
|
||||
}
|
||||
}
|
||||
return { rows, customAgentBases, customAgentLabels }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readTaskWorkspaceCreatedResult } from './task-workspace-create-result'
|
||||
|
||||
describe('readTaskWorkspaceCreatedResult', () => {
|
||||
it('returns a created workspace', () => {
|
||||
expect(
|
||||
readTaskWorkspaceCreatedResult({ worktree: { id: 'wt-1', displayName: 'Task' } })
|
||||
).toEqual({ worktree: { id: 'wt-1', displayName: 'Task' } })
|
||||
})
|
||||
|
||||
it('surfaces a pre-create custom-agent failure without dereferencing worktree', () => {
|
||||
expect(() =>
|
||||
readTaskWorkspaceCreatedResult({
|
||||
created: false,
|
||||
agentLaunchResult: {
|
||||
status: 'failed',
|
||||
failure: { code: 'custom_agent_disabled' }
|
||||
}
|
||||
})
|
||||
).toThrow("Couldn't start the agent (custom_agent_disabled).")
|
||||
})
|
||||
|
||||
it('surfaces an admission rejection without dereferencing worktree', () => {
|
||||
expect(() =>
|
||||
readTaskWorkspaceCreatedResult({
|
||||
created: false,
|
||||
agentLaunchResult: {
|
||||
status: 'rejected',
|
||||
requestError: { code: 'untrusted_reference' }
|
||||
}
|
||||
})
|
||||
).toThrow("Couldn't create the workspace (untrusted_reference).")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { AgentLaunchFailureCode } from '../../../src/shared/agent-launch-contract'
|
||||
|
||||
export type TaskWorkspaceCreatedResult = {
|
||||
worktree: { id: string; displayName?: string }
|
||||
warning?: string
|
||||
}
|
||||
|
||||
export function readTaskWorkspaceCreatedResult(result: unknown): TaskWorkspaceCreatedResult {
|
||||
if (!result || typeof result !== 'object') {
|
||||
throw new Error('Created workspace response was invalid')
|
||||
}
|
||||
const value = result as
|
||||
| TaskWorkspaceCreatedResult
|
||||
| {
|
||||
agentLaunchResult?:
|
||||
| { status: 'failed'; failure: { code: AgentLaunchFailureCode } }
|
||||
| { status: 'rejected'; requestError: { code: string } }
|
||||
}
|
||||
if ('worktree' in value && typeof value.worktree?.id === 'string') {
|
||||
return value
|
||||
}
|
||||
const agentLaunchResult = 'agentLaunchResult' in value ? value.agentLaunchResult : undefined
|
||||
if (agentLaunchResult?.status === 'failed') {
|
||||
throw new Error(`Couldn't start the agent (${agentLaunchResult.failure.code}).`)
|
||||
}
|
||||
if (agentLaunchResult?.status === 'rejected') {
|
||||
throw new Error(`Couldn't create the workspace (${agentLaunchResult.requestError.code}).`)
|
||||
}
|
||||
throw new Error('Created workspace response was invalid')
|
||||
}
|
||||
@@ -58,6 +58,9 @@ describe('workspace agent selection', () => {
|
||||
it('normalizes legacy blank sentinel and labels known choices', () => {
|
||||
expect(normalizeWorkspaceAgent('__blank__')).toBe('blank')
|
||||
expect(workspaceAgentLabel('codex')).toBe('Codex')
|
||||
expect(workspaceAgentLabel(customClaudeId, new Map([[customClaudeId, 'My Claude']]))).toBe(
|
||||
'My Claude'
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts a custom default only when the catalog vouches for it', () => {
|
||||
|
||||
@@ -32,13 +32,18 @@ type ResolveWorkspaceAgentSelectionArgs = WorkspaceAgentSelectionState & {
|
||||
customAgentBases?: WorkspaceCustomAgentBases
|
||||
}
|
||||
|
||||
export function workspaceAgentLabel(agent: WorkspaceAgentChoice): string {
|
||||
export function workspaceAgentLabel(
|
||||
agent: WorkspaceAgentChoice,
|
||||
customAgentLabels?: ReadonlyMap<TuiAgent, string>
|
||||
): string {
|
||||
if (agent === 'blank') {
|
||||
return 'Blank Terminal'
|
||||
}
|
||||
// Custom ids get their label from the synced catalog (later units); the
|
||||
// static parity table only knows built-ins.
|
||||
return isMobileTuiAgent(agent) ? MOBILE_TUI_AGENT_LABELS[agent] : agent
|
||||
return isMobileTuiAgent(agent)
|
||||
? MOBILE_TUI_AGENT_LABELS[agent]
|
||||
: (customAgentLabels?.get(agent) ?? agent)
|
||||
}
|
||||
|
||||
export function normalizeWorkspaceAgent(
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { agentLaunchCreateFields, buildTaskWorkspaceCreateParams } from './workspace-create-params'
|
||||
import type { TuiAgent } from '../../../src/shared/tui-agent'
|
||||
|
||||
const customAgent = 'custom-agent:claude:0f8b7c6a-1d2e-4a3b-9c4d-5e6f7a8b9c0d' as TuiAgent
|
||||
|
||||
describe('agentLaunchCreateFields', () => {
|
||||
it('sends startupAgent + createdWithAgent so the host resolves launch args', () => {
|
||||
@@ -15,6 +18,33 @@ describe('agentLaunchCreateFields', () => {
|
||||
})
|
||||
|
||||
describe('task workspace create params', () => {
|
||||
it('routes a custom choice through the host-atomic launch contract', () => {
|
||||
const params = buildTaskWorkspaceCreateParams({
|
||||
item: {
|
||||
provider: 'github',
|
||||
source: {
|
||||
type: 'issue',
|
||||
repoId: 'repo-1',
|
||||
number: 42,
|
||||
title: 'Custom agent task',
|
||||
url: 'https://github.com/acme/app/issues/42'
|
||||
}
|
||||
},
|
||||
targetRepoId: 'repo-1',
|
||||
setupDecision: 'inherit',
|
||||
agent: customAgent
|
||||
})
|
||||
|
||||
expect(params).toMatchObject({
|
||||
agentLaunch: {
|
||||
selection: { kind: 'agent', agent: customAgent },
|
||||
prompt: 'https://github.com/acme/app/issues/42'
|
||||
}
|
||||
})
|
||||
expect(params).not.toHaveProperty('createdWithAgent')
|
||||
expect(params).not.toHaveProperty('startupDraft')
|
||||
})
|
||||
|
||||
it('passes a GitHub PR URL as an agent draft and links the PR', () => {
|
||||
expect(
|
||||
buildTaskWorkspaceCreateParams({
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
SetupDecision
|
||||
} from '../../../src/shared/worktree/create-types'
|
||||
import type { GitPushTarget } from '../../../src/shared/worktree/types'
|
||||
import { isCustomTuiAgentId } from '../../../src/shared/custom-tui-agent-identity'
|
||||
import { getWorkspaceSourceName } from '../../../src/shared/new-workspace/workspace-source'
|
||||
import { resolveMobileWorkspaceCreateName } from './mobile-workspace-name'
|
||||
import type { WorkspaceAgentChoice } from './workspace-agent-selection'
|
||||
@@ -105,6 +106,15 @@ export function buildTaskWorkspaceCreateParams(args: {
|
||||
} = args
|
||||
const shouldLaunchAgent = agent !== 'blank'
|
||||
const createdWithAgent = shouldLaunchAgent ? (agent as TuiAgent) : undefined
|
||||
const customAgentLaunch =
|
||||
createdWithAgent && isCustomTuiAgentId(createdWithAgent)
|
||||
? {
|
||||
agentLaunch: {
|
||||
selection: { kind: 'agent' as const, agent: createdWithAgent },
|
||||
prompt: item.source.url
|
||||
}
|
||||
}
|
||||
: null
|
||||
const comment = note?.trim()
|
||||
const selectedBaseBranch = baseBranch || hostedStartPoint?.baseBranch
|
||||
const selectedPushTarget = pushTarget ?? hostedStartPoint?.pushTarget
|
||||
@@ -128,8 +138,9 @@ export function buildTaskWorkspaceCreateParams(args: {
|
||||
const common = {
|
||||
setupDecision,
|
||||
activate: true,
|
||||
...(shouldLaunchAgent ? { startupDraft: item.source.url } : {}),
|
||||
...(createdWithAgent ? { createdWithAgent } : {}),
|
||||
...(shouldLaunchAgent && !customAgentLaunch ? { startupDraft: item.source.url } : {}),
|
||||
...(createdWithAgent && !customAgentLaunch ? { createdWithAgent } : {}),
|
||||
...customAgentLaunch,
|
||||
...(selectedBaseBranch ? { baseBranch: selectedBaseBranch } : {}),
|
||||
...(compareBaseRef ? { compareBaseRef } : {}),
|
||||
...(branchNameOverride ? { branchNameOverride } : {}),
|
||||
|
||||
@@ -30,6 +30,7 @@ export type HostClientContextValue = {
|
||||
getLastConnectedAt: (hostId: string) => number | null
|
||||
getActivePath: (hostId: string) => MobileConnectionPath
|
||||
getPendingPath: (hostId: string) => MobileConnectionPath | null
|
||||
isPairingRejected: (hostId: string) => boolean
|
||||
subscribeHostState: (hostId: string, listener: (state: ConnectionState) => void) => () => void
|
||||
getAllClients: () => Array<{ hostId: string; client: RpcClient }>
|
||||
subscribeAllHosts: (listener: () => void) => () => void
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
deriveAgentLaunchHostState,
|
||||
detectionBaseAgentsForLaunch,
|
||||
defaultTransportConfidentiality,
|
||||
describeSpawnExecutionHost,
|
||||
detectionUnavailable,
|
||||
@@ -31,10 +32,18 @@ describe('executionHostIdForDescriptor', () => {
|
||||
'wsl:Ubuntu%2022.04'
|
||||
)
|
||||
expect(
|
||||
executionHostIdForDescriptor({ kind: 'ssh', connectionId: 'my host', platform: 'linux' })
|
||||
executionHostIdForDescriptor({
|
||||
kind: 'ssh',
|
||||
connectionId: 'my host',
|
||||
platform: 'linux'
|
||||
})
|
||||
).toBe('ssh:my%20host')
|
||||
expect(
|
||||
executionHostIdForDescriptor({ kind: 'runtime', environmentId: 'env/1', platform: 'linux' })
|
||||
executionHostIdForDescriptor({
|
||||
kind: 'runtime',
|
||||
environmentId: 'env/1',
|
||||
platform: 'linux'
|
||||
})
|
||||
).toBe('runtime:env%2F1')
|
||||
})
|
||||
})
|
||||
@@ -43,18 +52,32 @@ describe('platformForDescriptor / isRemoteForDescriptor', () => {
|
||||
it('forces linux for WSL and keeps the named platform otherwise', () => {
|
||||
expect(platformForDescriptor({ kind: 'wsl', distro: 'Ubuntu' })).toBe('linux')
|
||||
expect(platformForDescriptor({ kind: 'local', platform: 'win32' })).toBe('win32')
|
||||
expect(platformForDescriptor({ kind: 'ssh', connectionId: 'h', platform: 'linux' })).toBe(
|
||||
'linux'
|
||||
)
|
||||
expect(
|
||||
platformForDescriptor({
|
||||
kind: 'ssh',
|
||||
connectionId: 'h',
|
||||
platform: 'linux'
|
||||
})
|
||||
).toBe('linux')
|
||||
})
|
||||
|
||||
it('treats SSH and default runtime as remote, local and WSL as local', () => {
|
||||
expect(isRemoteForDescriptor({ kind: 'local', platform: 'darwin' })).toBe(false)
|
||||
expect(isRemoteForDescriptor({ kind: 'wsl', distro: 'Ubuntu' })).toBe(false)
|
||||
expect(isRemoteForDescriptor({ kind: 'ssh', connectionId: 'h', platform: 'linux' })).toBe(true)
|
||||
expect(isRemoteForDescriptor({ kind: 'runtime', environmentId: 'e', platform: 'linux' })).toBe(
|
||||
true
|
||||
)
|
||||
expect(
|
||||
isRemoteForDescriptor({
|
||||
kind: 'ssh',
|
||||
connectionId: 'h',
|
||||
platform: 'linux'
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
isRemoteForDescriptor({
|
||||
kind: 'runtime',
|
||||
environmentId: 'e',
|
||||
platform: 'linux'
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
isRemoteForDescriptor({
|
||||
kind: 'runtime',
|
||||
@@ -71,10 +94,18 @@ describe('defaultTransportConfidentiality', () => {
|
||||
expect(defaultTransportConfidentiality({ kind: 'local', platform: 'darwin' })).toBeUndefined()
|
||||
expect(defaultTransportConfidentiality({ kind: 'wsl', distro: 'Ubuntu' })).toBeUndefined()
|
||||
expect(
|
||||
defaultTransportConfidentiality({ kind: 'ssh', connectionId: 'h', platform: 'linux' })
|
||||
defaultTransportConfidentiality({
|
||||
kind: 'ssh',
|
||||
connectionId: 'h',
|
||||
platform: 'linux'
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
defaultTransportConfidentiality({ kind: 'runtime', environmentId: 'e', platform: 'linux' })
|
||||
defaultTransportConfidentiality({
|
||||
kind: 'runtime',
|
||||
environmentId: 'e',
|
||||
platform: 'linux'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -105,7 +136,10 @@ describe('deriveAgentLaunchHostState', () => {
|
||||
expect([...state.target.detectedStockBaseAgents!].sort()).toEqual(['claude', 'codex'])
|
||||
// Same-host: confidentiality is omitted (undefined), not false.
|
||||
expect('transportConfidentialityAvailable' in state.target).toBe(false)
|
||||
expect(state.variables).toEqual({ repoPath: '/repo', worktreePath: '/repo/wt' })
|
||||
expect(state.variables).toEqual({
|
||||
repoPath: '/repo',
|
||||
worktreePath: '/repo/wt'
|
||||
})
|
||||
expect(state.getCatalogRevision()).toBe(3)
|
||||
})
|
||||
|
||||
@@ -122,6 +156,33 @@ describe('deriveAgentLaunchHostState', () => {
|
||||
expect(state.target.transportConfidentialityAvailable).toBe(true)
|
||||
})
|
||||
|
||||
it('uses the target-owned SSH shell when the descriptor has no shell', async () => {
|
||||
const resolveStartupShell = vi.fn(async () => 'cmd' as const)
|
||||
const descriptor: AgentLaunchHostDescriptor = {
|
||||
kind: 'ssh',
|
||||
connectionId: 'box-1',
|
||||
platform: 'win32'
|
||||
}
|
||||
const state = await deriveAgentLaunchHostState(
|
||||
makeDeps({ resolveStartupShell }),
|
||||
descriptor,
|
||||
{}
|
||||
)
|
||||
expect(resolveStartupShell).toHaveBeenCalledWith(descriptor)
|
||||
expect(state.target.shell).toBe('cmd')
|
||||
})
|
||||
|
||||
it('does not replace a shell already proven by the descriptor', async () => {
|
||||
const resolveStartupShell = vi.fn(async () => 'powershell' as const)
|
||||
const state = await deriveAgentLaunchHostState(
|
||||
makeDeps({ resolveStartupShell }),
|
||||
{ kind: 'ssh', connectionId: 'box-1', platform: 'win32', shell: 'cmd' },
|
||||
{}
|
||||
)
|
||||
expect(resolveStartupShell).not.toHaveBeenCalled()
|
||||
expect(state.target.shell).toBe('cmd')
|
||||
})
|
||||
|
||||
it('derives a WSL target as local linux with a wsl host id', async () => {
|
||||
const state = await deriveAgentLaunchHostState(
|
||||
makeDeps({ resolveTargetHomePath: async () => null }),
|
||||
@@ -191,11 +252,55 @@ describe('deriveAgentLaunchHostState', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectionBaseAgentsForLaunch', () => {
|
||||
const customId = 'custom-agent:codex:01234567-89ab-4cde-8f01-23456789abcd' as const
|
||||
it('narrows only immutable built-in identities to one stock base', () => {
|
||||
expect(detectionBaseAgentsForLaunch({ selection: { kind: 'agent', agent: 'claude' } })).toEqual(
|
||||
['claude']
|
||||
)
|
||||
expect(detectionBaseAgentsForLaunch({ selection: { kind: 'agent', agent: customId } })).toBe(
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps full detection for mutable defaults and trusts snapshot base identity', () => {
|
||||
expect(detectionBaseAgentsForLaunch({ selection: { kind: 'default' } })).toBeUndefined()
|
||||
expect(
|
||||
detectionBaseAgentsForLaunch(
|
||||
{ selection: { kind: 'default' } },
|
||||
{
|
||||
version: 1,
|
||||
requestedAgent: 'claude',
|
||||
baseAgent: 'claude',
|
||||
displayLabel: 'Claude',
|
||||
mode: 'built-in',
|
||||
argv: ['claude'],
|
||||
agentEnv: {},
|
||||
capturedEnvPolicy: 'none',
|
||||
target: {
|
||||
platform: 'linux',
|
||||
execution: 'native',
|
||||
shell: 'posix',
|
||||
isRemote: false,
|
||||
executionHostId: 'local'
|
||||
}
|
||||
}
|
||||
)
|
||||
).toEqual(['claude'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('describeSpawnExecutionHost', () => {
|
||||
it('describes a local target with this machine platform', () => {
|
||||
const descriptor = describeSpawnExecutionHost({ connectionId: null, cwd: '/repo' })
|
||||
const descriptor = describeSpawnExecutionHost({
|
||||
connectionId: null,
|
||||
cwd: '/repo'
|
||||
})
|
||||
expect(descriptor.kind).toBe('local')
|
||||
expect(descriptor).toMatchObject({ kind: 'local', platform: process.platform })
|
||||
expect(descriptor).toMatchObject({
|
||||
kind: 'local',
|
||||
platform: process.platform
|
||||
})
|
||||
})
|
||||
|
||||
it('describes an SSH target and infers linux from a POSIX cwd', () => {
|
||||
@@ -203,7 +308,11 @@ describe('describeSpawnExecutionHost', () => {
|
||||
connectionId: 'host-1',
|
||||
cwd: '/home/user/repo'
|
||||
})
|
||||
expect(descriptor).toEqual({ kind: 'ssh', connectionId: 'host-1', platform: 'linux' })
|
||||
expect(descriptor).toEqual({
|
||||
kind: 'ssh',
|
||||
connectionId: 'host-1',
|
||||
platform: 'linux'
|
||||
})
|
||||
})
|
||||
|
||||
it('infers win32 for an SSH target with a Windows-shaped cwd', () => {
|
||||
@@ -211,12 +320,20 @@ describe('describeSpawnExecutionHost', () => {
|
||||
connectionId: 'host-1',
|
||||
cwd: 'C:\\Users\\me\\repo'
|
||||
})
|
||||
expect(descriptor).toEqual({ kind: 'ssh', connectionId: 'host-1', platform: 'win32' })
|
||||
expect(descriptor).toEqual({
|
||||
kind: 'ssh',
|
||||
connectionId: 'host-1',
|
||||
platform: 'win32'
|
||||
})
|
||||
})
|
||||
|
||||
it('defaults an SSH target to linux when the cwd is unknown', () => {
|
||||
const descriptor = describeSpawnExecutionHost({ connectionId: 'host-1' })
|
||||
expect(descriptor).toEqual({ kind: 'ssh', connectionId: 'host-1', platform: 'linux' })
|
||||
expect(descriptor).toEqual({
|
||||
kind: 'ssh',
|
||||
connectionId: 'host-1',
|
||||
platform: 'linux'
|
||||
})
|
||||
})
|
||||
|
||||
// A WSL UNC cwd runs a Linux userland; win32 here picks the Windows executable
|
||||
@@ -237,7 +354,10 @@ describe('describeSpawnExecutionHost', () => {
|
||||
// PowerShell quotes typed into a cmd.exe tab reach the agent verbatim.
|
||||
it('quotes for the tab shell override ahead of the global Windows shell', () => {
|
||||
const original = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true })
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'win32',
|
||||
configurable: true
|
||||
})
|
||||
try {
|
||||
expect(
|
||||
describeSpawnExecutionHost({
|
||||
@@ -263,17 +383,27 @@ describe('describeSpawnExecutionHost', () => {
|
||||
|
||||
it('describes a legacy \\\\wsl$ UNC cwd as the same wsl host', () => {
|
||||
expect(
|
||||
describeSpawnExecutionHost({ connectionId: null, cwd: '\\\\wsl$\\Debian\\srv\\app' })
|
||||
describeSpawnExecutionHost({
|
||||
connectionId: null,
|
||||
cwd: '\\\\wsl$\\Debian\\srv\\app'
|
||||
})
|
||||
).toEqual({ kind: 'wsl', distro: 'Debian' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveLocalTargetHomePath', () => {
|
||||
it('returns a home dir for local and null for every other surface', async () => {
|
||||
const local: AgentLaunchHostDescriptor = { kind: 'local', platform: process.platform }
|
||||
const local: AgentLaunchHostDescriptor = {
|
||||
kind: 'local',
|
||||
platform: process.platform
|
||||
}
|
||||
await expect(resolveLocalTargetHomePath(local)).resolves.toEqual(expect.any(String))
|
||||
await expect(
|
||||
resolveLocalTargetHomePath({ kind: 'ssh', connectionId: 'h', platform: 'linux' })
|
||||
resolveLocalTargetHomePath({
|
||||
kind: 'ssh',
|
||||
connectionId: 'h',
|
||||
platform: 'linux'
|
||||
})
|
||||
).resolves.toBeNull()
|
||||
await expect(resolveLocalTargetHomePath({ kind: 'wsl', distro: 'Ubuntu' })).resolves.toBeNull()
|
||||
})
|
||||
|
||||
@@ -14,8 +14,12 @@
|
||||
|
||||
import { homedir } from 'node:os'
|
||||
import type { BuiltInTuiAgent, GlobalSettings } from '../../shared/types'
|
||||
import type { AgentLaunchSpawnRequest } from '../../shared/agent-launch-spawn-request'
|
||||
import type { AgentStartupShell } from '../../shared/tui-agent-startup-shell'
|
||||
import type { AgentLaunchExecutionHostId } from '../../shared/agent-launch-host-contract'
|
||||
import type {
|
||||
AgentLaunchExecutionHostId,
|
||||
AgentLaunchSnapshot
|
||||
} from '../../shared/agent-launch-host-contract'
|
||||
import { isBuiltInTuiAgent } from '../../shared/tui-agent-config'
|
||||
import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../../shared/execution-host'
|
||||
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
|
||||
@@ -47,7 +51,12 @@ export type AgentLaunchHostDescriptor =
|
||||
// resolver translate Windows/UNC values to Linux form. Detection/home stay
|
||||
// honest unknowns until a caller can probe the distro's own PATH/$HOME.
|
||||
| { kind: 'wsl'; distro: string; shell?: AgentStartupShell }
|
||||
| { kind: 'ssh'; connectionId: string; platform: NodeJS.Platform; shell?: AgentStartupShell }
|
||||
| {
|
||||
kind: 'ssh'
|
||||
connectionId: string
|
||||
platform: NodeJS.Platform
|
||||
shell?: AgentStartupShell
|
||||
}
|
||||
| {
|
||||
kind: 'runtime'
|
||||
environmentId: string
|
||||
@@ -180,11 +189,17 @@ export type AgentLaunchHostStateDeps = {
|
||||
/** Detect stock base agents on the target's baseline PATH. Return null when
|
||||
* detection is genuinely unavailable — never an empty list to mean unknown. */
|
||||
detectStockBaseAgents: (
|
||||
descriptor: AgentLaunchHostDescriptor
|
||||
descriptor: AgentLaunchHostDescriptor,
|
||||
baseAgents?: readonly BuiltInTuiAgent[]
|
||||
) => Promise<readonly string[] | null>
|
||||
/** Resolve the target host's home dir for `~` expansion, or null when the host
|
||||
* has not resolved it (SSH before resolveHome, an unknown WSL distro $HOME). */
|
||||
resolveTargetHomePath: (descriptor: AgentLaunchHostDescriptor) => Promise<string | null>
|
||||
/** Resolve a target-owned shell when the descriptor cannot carry one
|
||||
* synchronously (notably a native-Windows SSH relay). */
|
||||
resolveStartupShell?: (
|
||||
descriptor: AgentLaunchHostDescriptor
|
||||
) => Promise<AgentStartupShell | undefined>
|
||||
/** Override the default confidentiality derivation when a cross-host channel's
|
||||
* binding is identifiable (e.g. a runtime env reached over SSH). */
|
||||
resolveTransportConfidentiality?: (descriptor: AgentLaunchHostDescriptor) => boolean | undefined
|
||||
@@ -201,20 +216,40 @@ export type AgentLaunchHostState = {
|
||||
variables: { repoPath: string | null; worktreePath: string | null }
|
||||
}
|
||||
|
||||
/** Restrict an eligibility probe only when the base identity is immutable during
|
||||
* the async probe. Defaults and custom definitions use full detection because a
|
||||
* settings edit can change their base before synchronous launch resolution. */
|
||||
export function detectionBaseAgentsForLaunch(
|
||||
request: AgentLaunchSpawnRequest,
|
||||
snapshot?: AgentLaunchSnapshot
|
||||
): readonly BuiltInTuiAgent[] | undefined {
|
||||
if (snapshot) {
|
||||
return [snapshot.baseAgent]
|
||||
}
|
||||
if (request.selection.kind === 'default') {
|
||||
return undefined
|
||||
}
|
||||
return isBuiltInTuiAgent(request.selection.agent) ? [request.selection.agent] : undefined
|
||||
}
|
||||
|
||||
/** Derive the per-surface host state for a launch. Performs the async host reads
|
||||
* (detection, target home) once, up front, so the boundary's synchronous
|
||||
* re-resolution inside the admission coordinator only re-reads settings. */
|
||||
export async function deriveAgentLaunchHostState(
|
||||
deps: AgentLaunchHostStateDeps,
|
||||
descriptor: AgentLaunchHostDescriptor,
|
||||
variables: { repoPath?: string | null; worktreePath?: string | null }
|
||||
variables: { repoPath?: string | null; worktreePath?: string | null },
|
||||
options: { detectionBaseAgents?: readonly BuiltInTuiAgent[] } = {}
|
||||
): Promise<AgentLaunchHostState> {
|
||||
const platform = platformForDescriptor(descriptor)
|
||||
const isRemote = isRemoteForDescriptor(descriptor)
|
||||
const executionHostId = executionHostIdForDescriptor(descriptor)
|
||||
const [detected, targetHomePath] = await Promise.all([
|
||||
deps.detectStockBaseAgents(descriptor),
|
||||
deps.resolveTargetHomePath(descriptor)
|
||||
const [detected, targetHomePath, resolvedShell] = await Promise.all([
|
||||
options.detectionBaseAgents === undefined
|
||||
? deps.detectStockBaseAgents(descriptor)
|
||||
: deps.detectStockBaseAgents(descriptor, options.detectionBaseAgents),
|
||||
deps.resolveTargetHomePath(descriptor),
|
||||
descriptor.shell ? Promise.resolve(undefined) : deps.resolveStartupShell?.(descriptor)
|
||||
])
|
||||
const confidentiality = (deps.resolveTransportConfidentiality ?? defaultTransportConfidentiality)(
|
||||
descriptor
|
||||
@@ -222,7 +257,7 @@ export async function deriveAgentLaunchHostState(
|
||||
|
||||
const target: AgentLaunchSpawnTarget = {
|
||||
platform,
|
||||
...(descriptor.shell ? { shell: descriptor.shell } : {}),
|
||||
...(descriptor.shell || resolvedShell ? { shell: descriptor.shell ?? resolvedShell } : {}),
|
||||
isRemote,
|
||||
executionHostId,
|
||||
targetHomePath: targetHomePath ?? null,
|
||||
|
||||
@@ -53,7 +53,10 @@ function makeLaunch(
|
||||
displayLabel: 'Claude',
|
||||
argv: snapshot.argv,
|
||||
agentEnv: snapshot.agentEnv,
|
||||
variables: { values: { repoPath: '/repo', worktreePath }, referenced: ['worktreePath'] },
|
||||
variables: {
|
||||
values: { repoPath: '/repo', worktreePath },
|
||||
referenced: ['worktreePath']
|
||||
},
|
||||
snapshot,
|
||||
policy: {
|
||||
intent: 'interactive',
|
||||
@@ -101,12 +104,77 @@ const CONTEXT: WorktreeAgentLaunchContext = {
|
||||
}
|
||||
|
||||
describe('two-stage worktree agent-launch resolution', () => {
|
||||
it('keeps default-agent detection valid when settings change during the host probe', async () => {
|
||||
const claudeId = 'custom-agent:claude:01234567-89ab-4cde-8f01-23456789abcd' as const
|
||||
const codexId = 'custom-agent:codex:12345678-9abc-4def-8012-3456789abcde' as const
|
||||
const customTuiAgents = [
|
||||
{
|
||||
id: claudeId,
|
||||
baseAgent: 'claude' as const,
|
||||
label: 'Claude Review',
|
||||
args: '',
|
||||
env: {},
|
||||
syncEnv: false
|
||||
},
|
||||
{
|
||||
id: codexId,
|
||||
baseAgent: 'codex' as const,
|
||||
label: 'Codex Review',
|
||||
args: '',
|
||||
env: {},
|
||||
syncEnv: false
|
||||
}
|
||||
]
|
||||
let settings = {
|
||||
agentCatalogRevision: 1,
|
||||
defaultTuiAgent: claudeId,
|
||||
customTuiAgents
|
||||
} as unknown as GlobalSettings
|
||||
const { deps } = makeSetup(() => ({
|
||||
ok: false,
|
||||
failure: { code: 'base_agent_unavailable', baseAgent: 'codex' }
|
||||
}))
|
||||
deps.getSettings = () => settings
|
||||
delete deps.resolve
|
||||
const detect = vi.fn(
|
||||
async (_descriptor, baseAgents?: readonly string[]): Promise<readonly string[]> => {
|
||||
settings = {
|
||||
agentCatalogRevision: 2,
|
||||
defaultTuiAgent: codexId,
|
||||
customTuiAgents
|
||||
} as unknown as GlobalSettings
|
||||
return baseAgents ?? ['claude', 'codex']
|
||||
}
|
||||
)
|
||||
deps.detectStockBaseAgents = detect
|
||||
|
||||
const prepared = await prepareWorktreeAgentLaunch(deps, CONTEXT, {
|
||||
repoPath: '/repo',
|
||||
worktreePath: '/wt-provisional'
|
||||
})
|
||||
|
||||
expect(detect).toHaveBeenCalledWith(CONTEXT.descriptor)
|
||||
expect(prepared.ok).toBe(true)
|
||||
if (prepared.ok) {
|
||||
expect(prepared.requestedAgent).toBe(codexId)
|
||||
}
|
||||
})
|
||||
|
||||
it('pins the config digest pre-git and admits it post-git across a changed path', async () => {
|
||||
const resolve = vi
|
||||
.fn<(request: ResolveAgentLaunchRequest) => ResolveAgentLaunchOutcome>()
|
||||
.mockReturnValueOnce({ ok: true, launch: makeLaunch('fp-prov', 'sd-1', '/wt-provisional') })
|
||||
.mockReturnValueOnce({ ok: true, launch: makeLaunch('fp-real', 'sd-1', '/wt-real') })
|
||||
.mockReturnValueOnce({ ok: true, launch: makeLaunch('fp-real', 'sd-1', '/wt-real') })
|
||||
.mockReturnValueOnce({
|
||||
ok: true,
|
||||
launch: makeLaunch('fp-prov', 'sd-1', '/wt-provisional')
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
ok: true,
|
||||
launch: makeLaunch('fp-real', 'sd-1', '/wt-real')
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
ok: true,
|
||||
launch: makeLaunch('fp-real', 'sd-1', '/wt-real')
|
||||
})
|
||||
const { deps, store } = makeSetup(resolve)
|
||||
|
||||
const prepared = await prepareWorktreeAgentLaunch(deps, CONTEXT, {
|
||||
@@ -146,8 +214,14 @@ describe('two-stage worktree agent-launch resolution', () => {
|
||||
it('releases the reservation and reports a config change when the digest moved', async () => {
|
||||
const resolve = vi
|
||||
.fn<(request: ResolveAgentLaunchRequest) => ResolveAgentLaunchOutcome>()
|
||||
.mockReturnValueOnce({ ok: true, launch: makeLaunch('fp-prov', 'sd-1', '/wt-provisional') })
|
||||
.mockReturnValueOnce({ ok: true, launch: makeLaunch('fp-real', 'sd-2', '/wt-real') })
|
||||
.mockReturnValueOnce({
|
||||
ok: true,
|
||||
launch: makeLaunch('fp-prov', 'sd-1', '/wt-provisional')
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
ok: true,
|
||||
launch: makeLaunch('fp-real', 'sd-2', '/wt-real')
|
||||
})
|
||||
const { deps, store } = makeSetup(resolve)
|
||||
|
||||
const prepared = await prepareWorktreeAgentLaunch(deps, CONTEXT, {
|
||||
@@ -181,7 +255,10 @@ describe('two-stage worktree agent-launch resolution', () => {
|
||||
it('threads the stage-2 worktree into admission so the per-worktree cap counts it (L3-#3)', async () => {
|
||||
const resolve = vi
|
||||
.fn<(request: ResolveAgentLaunchRequest) => ResolveAgentLaunchOutcome>()
|
||||
.mockReturnValue({ ok: true, launch: makeLaunch('fp-real', 'sd-1', '/wt-real') })
|
||||
.mockReturnValue({
|
||||
ok: true,
|
||||
launch: makeLaunch('fp-real', 'sd-1', '/wt-real')
|
||||
})
|
||||
const { deps, store } = makeSetup(resolve)
|
||||
const prepared = await prepareWorktreeAgentLaunch(deps, CONTEXT, {
|
||||
repoPath: '/repo',
|
||||
@@ -195,7 +272,10 @@ describe('two-stage worktree agent-launch resolution', () => {
|
||||
deps,
|
||||
{ ...CONTEXT, worktreeId: 'wt-created' },
|
||||
{ repoPath: '/repo', worktreePath: '/wt-real' },
|
||||
{ reservationId: prepared.reservationId, expectedStableInputDigest: 'sd-1' }
|
||||
{
|
||||
reservationId: prepared.reservationId,
|
||||
expectedStableInputDigest: 'sd-1'
|
||||
}
|
||||
)
|
||||
expect(executed.ok).toBe(true)
|
||||
expect(store.pendingForWorktree('wt-created')).toBe(1)
|
||||
@@ -204,11 +284,18 @@ describe('two-stage worktree agent-launch resolution', () => {
|
||||
it("defaults the admission worktree to a background intent's own worktree (L3-#3)", async () => {
|
||||
const resolve = vi
|
||||
.fn<(request: ResolveAgentLaunchRequest) => ResolveAgentLaunchOutcome>()
|
||||
.mockReturnValue({ ok: true, launch: makeLaunch('fp-real', 'sd-1', '/wt-real') })
|
||||
.mockReturnValue({
|
||||
ok: true,
|
||||
launch: makeLaunch('fp-real', 'sd-1', '/wt-real')
|
||||
})
|
||||
const { deps, store } = makeSetup(resolve)
|
||||
const context: WorktreeAgentLaunchContext = {
|
||||
...CONTEXT,
|
||||
intent: { kind: 'background', attemptId: 'attempt-1', worktreeId: 'wt-bg' },
|
||||
intent: {
|
||||
kind: 'background',
|
||||
attemptId: 'attempt-1',
|
||||
worktreeId: 'wt-bg'
|
||||
},
|
||||
scope: 'attempt-1'
|
||||
}
|
||||
const prepared = await prepareWorktreeAgentLaunch(deps, context, {
|
||||
@@ -223,7 +310,10 @@ describe('two-stage worktree agent-launch resolution', () => {
|
||||
deps,
|
||||
context,
|
||||
{ repoPath: '/repo', worktreePath: '/wt-real' },
|
||||
{ reservationId: prepared.reservationId, expectedStableInputDigest: 'sd-1' }
|
||||
{
|
||||
reservationId: prepared.reservationId,
|
||||
expectedStableInputDigest: 'sd-1'
|
||||
}
|
||||
)
|
||||
expect(executed.ok).toBe(true)
|
||||
// Scope is the attempt id, yet the cap counts the attempt's worktree.
|
||||
@@ -234,7 +324,10 @@ describe('two-stage worktree agent-launch resolution', () => {
|
||||
it('frees the hold when a stage-2 host read rejects, so repeated races keep capacity', async () => {
|
||||
const resolve = vi
|
||||
.fn<(request: ResolveAgentLaunchRequest) => ResolveAgentLaunchOutcome>()
|
||||
.mockReturnValue({ ok: true, launch: makeLaunch('fp-real', 'sd-1', '/wt-real') })
|
||||
.mockReturnValue({
|
||||
ok: true,
|
||||
launch: makeLaunch('fp-real', 'sd-1', '/wt-real')
|
||||
})
|
||||
const { deps, store } = makeSetup(resolve)
|
||||
|
||||
// More rounds than MAX_PENDING_LAUNCHES_PER_PRINCIPAL: a leaked hold per round
|
||||
@@ -257,7 +350,10 @@ describe('two-stage worktree agent-launch resolution', () => {
|
||||
deps,
|
||||
CONTEXT,
|
||||
{ repoPath: '/repo', worktreePath: '/wt-real' },
|
||||
{ reservationId: prepared.reservationId, expectedStableInputDigest: 'sd-1' }
|
||||
{
|
||||
reservationId: prepared.reservationId,
|
||||
expectedStableInputDigest: 'sd-1'
|
||||
}
|
||||
)
|
||||
expect(executed.ok).toBe(false)
|
||||
if (executed.ok) {
|
||||
@@ -272,7 +368,10 @@ describe('two-stage worktree agent-launch resolution', () => {
|
||||
it('takes no reservation when pre-git resolution fails', async () => {
|
||||
const resolve = vi
|
||||
.fn<(request: ResolveAgentLaunchRequest) => ResolveAgentLaunchOutcome>()
|
||||
.mockReturnValueOnce({ ok: false, failure: { code: 'custom_agent_disabled' } })
|
||||
.mockReturnValueOnce({
|
||||
ok: false,
|
||||
failure: { code: 'custom_agent_disabled' }
|
||||
})
|
||||
const { deps, store } = makeSetup(resolve)
|
||||
|
||||
const prepared = await prepareWorktreeAgentLaunch(deps, CONTEXT, {
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { GlobalSettings } from '../../shared/types'
|
||||
import type { AgentLaunchSpawnRequest } from '../../shared/agent-launch-spawn-request'
|
||||
import type { LaunchIntent, ResolvedAgentLaunch } from '../../shared/agent-launch-host-contract'
|
||||
import {
|
||||
detectionBaseAgentsForLaunch,
|
||||
deriveAgentLaunchHostState,
|
||||
type AgentLaunchHostDescriptor,
|
||||
type AgentLaunchHostStateDeps
|
||||
@@ -61,6 +62,7 @@ export type WorktreeAgentLaunchDeps = {
|
||||
getCatalogRevision: () => number
|
||||
detectStockBaseAgents: AgentLaunchHostStateDeps['detectStockBaseAgents']
|
||||
resolveTargetHomePath: AgentLaunchHostStateDeps['resolveTargetHomePath']
|
||||
resolveStartupShell?: AgentLaunchHostStateDeps['resolveStartupShell']
|
||||
resolveTransportConfidentiality?: AgentLaunchHostStateDeps['resolveTransportConfidentiality']
|
||||
/** Best-effort workspace trust for the resolved base agent, run as the
|
||||
* boundary's pre-admission preflight OUTSIDE the coordinator. A throw maps to
|
||||
@@ -110,18 +112,21 @@ export async function prepareWorktreeAgentLaunch(
|
||||
context: WorktreeAgentLaunchContext,
|
||||
provisionalPaths: { repoPath: string | null; worktreePath: string | null }
|
||||
): Promise<PrepareReservedAgentLaunchResult> {
|
||||
const detectionBaseAgents = detectionBaseAgentsForLaunch(context.request)
|
||||
const hostState = await deriveAgentLaunchHostState(
|
||||
{
|
||||
getSettings: deps.getSettings,
|
||||
getCatalogRevision: deps.getCatalogRevision,
|
||||
detectStockBaseAgents: deps.detectStockBaseAgents,
|
||||
resolveTargetHomePath: deps.resolveTargetHomePath,
|
||||
...(deps.resolveStartupShell ? { resolveStartupShell: deps.resolveStartupShell } : {}),
|
||||
...(deps.resolveTransportConfidentiality
|
||||
? { resolveTransportConfidentiality: deps.resolveTransportConfidentiality }
|
||||
: {})
|
||||
},
|
||||
context.descriptor,
|
||||
provisionalPaths
|
||||
provisionalPaths,
|
||||
detectionBaseAgents === undefined ? {} : { detectionBaseAgents }
|
||||
)
|
||||
const resolve = buildHostStateResolve(toSpawnDeps(deps), {
|
||||
request: context.request,
|
||||
@@ -145,18 +150,21 @@ export async function executeWorktreeAgentLaunch(
|
||||
reservation: { reservationId: string; expectedStableInputDigest: string }
|
||||
): Promise<ExecuteAgentLaunchResult> {
|
||||
try {
|
||||
const detectionBaseAgents = detectionBaseAgentsForLaunch(context.request)
|
||||
const hostState = await deriveAgentLaunchHostState(
|
||||
{
|
||||
getSettings: deps.getSettings,
|
||||
getCatalogRevision: deps.getCatalogRevision,
|
||||
detectStockBaseAgents: deps.detectStockBaseAgents,
|
||||
resolveTargetHomePath: deps.resolveTargetHomePath,
|
||||
...(deps.resolveStartupShell ? { resolveStartupShell: deps.resolveStartupShell } : {}),
|
||||
...(deps.resolveTransportConfidentiality
|
||||
? { resolveTransportConfidentiality: deps.resolveTransportConfidentiality }
|
||||
: {})
|
||||
},
|
||||
context.descriptor,
|
||||
authoritativePaths
|
||||
authoritativePaths,
|
||||
detectionBaseAgents === undefined ? {} : { detectionBaseAgents }
|
||||
)
|
||||
const resolve = buildHostStateResolve(toSpawnDeps(deps), {
|
||||
request: context.request,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('automation production agent-launch gate wiring', () => {
|
||||
const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8')
|
||||
|
||||
it('classifies the resolved agent before desktop and headless dispatch', () => {
|
||||
const serviceStart = source.indexOf('automations = new AutomationService(store, {')
|
||||
const dispatcherStart = source.indexOf('headlessDispatcher:', serviceStart)
|
||||
const classifierStart = source.indexOf('classifyAgentLaunch:', serviceStart)
|
||||
|
||||
expect(serviceStart).toBeGreaterThanOrEqual(0)
|
||||
expect(classifierStart).toBeGreaterThan(serviceStart)
|
||||
expect(classifierStart).toBeLessThan(dispatcherStart)
|
||||
expect(source.slice(classifierStart, dispatcherStart)).toContain(
|
||||
'runtimeService.classifyAgentLaunchForAutomation('
|
||||
)
|
||||
expect(source.slice(classifierStart, dispatcherStart)).toContain('target.cwd')
|
||||
})
|
||||
})
|
||||
@@ -87,6 +87,7 @@ vi.mock('../gitea/client', () => ({
|
||||
}))
|
||||
|
||||
import { registerPreflightHandlers } from './preflight'
|
||||
import { detectRemoteAgentsIfReachable } from '../preflight/agent-detection'
|
||||
import { resetPreflightMocks, type HandlerMap } from './preflight-test-harness'
|
||||
|
||||
describe('preflight', () => {
|
||||
@@ -141,6 +142,34 @@ describe('preflight', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('restricts launch eligibility detection to the requested stock base', async () => {
|
||||
const request = vi.fn().mockResolvedValue({ agents: ['codex'] })
|
||||
getActiveMultiplexerMock.mockReturnValue({
|
||||
isDisposed: () => false,
|
||||
request
|
||||
})
|
||||
|
||||
await expect(
|
||||
detectRemoteAgentsIfReachable({ connectionId: 'ssh-1', baseAgents: ['codex'] })
|
||||
).resolves.toEqual(['codex'])
|
||||
const commands = request.mock.calls[0]?.[1]?.commands as { id: string }[]
|
||||
expect(commands.length).toBeGreaterThan(0)
|
||||
expect(commands.every((command) => command.id === 'codex')).toBe(true)
|
||||
})
|
||||
|
||||
it('skips the relay when launch eligibility has no resolvable base', async () => {
|
||||
const request = vi.fn()
|
||||
getActiveMultiplexerMock.mockReturnValue({
|
||||
isDisposed: () => false,
|
||||
request
|
||||
})
|
||||
|
||||
await expect(
|
||||
detectRemoteAgentsIfReachable({ connectionId: 'ssh-1', baseAgents: [] })
|
||||
).resolves.toEqual([])
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns no remote agents when the SSH connection is unavailable', async () => {
|
||||
getActiveMultiplexerMock.mockReturnValue(null)
|
||||
|
||||
|
||||
@@ -163,4 +163,47 @@ describe('registerPtyHandlers agent resume', () => {
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('quotes a native-Windows SSH resume for the relay default shell', async () => {
|
||||
const spawn = vi.fn(async () => ({ id: 'remote-win-pty' }))
|
||||
const getDefaultShell = vi.fn(async () => 'C:\\Windows\\System32\\cmd.exe')
|
||||
registerSshPtyProvider('ssh-win-resume', {
|
||||
spawn,
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
shutdown: vi.fn(),
|
||||
sendSignal: vi.fn(),
|
||||
getCwd: vi.fn(),
|
||||
getInitialCwd: vi.fn(),
|
||||
clearBuffer: vi.fn(),
|
||||
onData: vi.fn(() => () => {}),
|
||||
onReplay: vi.fn(() => () => {}),
|
||||
onExit: vi.fn(() => () => {}),
|
||||
listProcesses: vi.fn(),
|
||||
hasChildProcesses: vi.fn(),
|
||||
getForegroundProcess: vi.fn(),
|
||||
serialize: vi.fn(),
|
||||
revive: vi.fn(),
|
||||
getDefaultShell,
|
||||
getProfiles: vi.fn(),
|
||||
acknowledgeDataEvent: vi.fn()
|
||||
} as never)
|
||||
handlers.clear()
|
||||
registerPtyHandlers(mainWindow as never, undefined, undefined, (() => ({})) as never)
|
||||
|
||||
await handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
connectionId: 'ssh-win-resume',
|
||||
cwd: 'C:\\Users\\me\\wt-1',
|
||||
agentLaunch: resumeLaunch('codex-ssh-win-cmd'),
|
||||
launchConfig: LEGACY_LAUNCH_CONFIG,
|
||||
legacyResumeRecordedConnectionId: 'ssh-win-resume'
|
||||
})
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ command: `codex "resume" "codex-ssh-win-cmd"` })
|
||||
)
|
||||
expect(getDefaultShell).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
} from '../../../../shared/agent-launch-spawn-request'
|
||||
import { resolveStartupShell } from '../../../../shared/tui-agent-startup-shell'
|
||||
import type { TuiAgent } from '../../../../shared/types'
|
||||
import { resolveWindowsShellStartupFamily } from '../../../../shared/windows-terminal-shell'
|
||||
import {
|
||||
describeSpawnExecutionHost,
|
||||
deriveAgentLaunchHostState,
|
||||
@@ -55,7 +56,7 @@ export async function resolvePtyIpcAgentLaunch(
|
||||
const getLaunchSettings = ctx.deps.getSettings
|
||||
const descriptor = describeSpawnExecutionHost({
|
||||
connectionId: args.connectionId,
|
||||
cwd: ctx.cwd,
|
||||
cwd: ctx.cwd ?? args.cwd,
|
||||
shellOverride: args.shellOverride,
|
||||
terminalWindowsShell: getLaunchSettings()?.terminalWindowsShell,
|
||||
projectRuntime: args.projectRuntime
|
||||
@@ -65,7 +66,14 @@ export async function resolvePtyIpcAgentLaunch(
|
||||
getSettings: getLaunchSettings,
|
||||
getCatalogRevision: () => getLaunchSettings()?.agentCatalogRevision ?? 1,
|
||||
detectStockBaseAgents: detectionUnavailable,
|
||||
resolveTargetHomePath: resolveLocalTargetHomePath
|
||||
resolveTargetHomePath: resolveLocalTargetHomePath,
|
||||
resolveStartupShell: async (target) => {
|
||||
if (target.kind !== 'ssh' || target.platform !== 'win32') {
|
||||
return undefined
|
||||
}
|
||||
const shell = args.shellOverride ?? (await ctx.provider.getDefaultShell())
|
||||
return resolveWindowsShellStartupFamily(shell)
|
||||
}
|
||||
},
|
||||
descriptor,
|
||||
{ worktreePath: ctx.cwd ?? null, repoPath: null }
|
||||
|
||||
@@ -567,7 +567,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
const result = (await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-ssh',
|
||||
name: 'improve-dashboard'
|
||||
})) as CreateWorktreeResult
|
||||
})) as CreatedWorktreeResult
|
||||
|
||||
expect(provider.exec).toHaveBeenCalledWith(
|
||||
['show-ref', '--verify', '--quiet', '--', 'refs/heads/main'],
|
||||
@@ -583,7 +583,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
const result = (await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-ssh',
|
||||
name: 'improve-dashboard'
|
||||
})) as CreateWorktreeResult
|
||||
})) as CreatedWorktreeResult
|
||||
|
||||
expect(result.localBaseRefRefresh).toEqual({
|
||||
status: 'skipped_not_fast_forward',
|
||||
@@ -600,7 +600,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
const result = (await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-ssh',
|
||||
name: 'improve-dashboard'
|
||||
})) as CreateWorktreeResult
|
||||
})) as CreatedWorktreeResult
|
||||
|
||||
expect(result.localBaseRefRefresh).toEqual({
|
||||
status: 'skipped_not_fast_forward',
|
||||
|
||||
@@ -242,13 +242,21 @@ export async function refreshShellPathAndDetectAgents(
|
||||
* unavailable and provides no evidence that the host lacks an agent. */
|
||||
export async function detectRemoteAgentsIfReachable(args: {
|
||||
connectionId: string
|
||||
/** Omitted for full catalog discovery; present for launch eligibility. */
|
||||
baseAgents?: readonly string[]
|
||||
}): Promise<string[] | null> {
|
||||
const mux = getActiveMultiplexer(args.connectionId)
|
||||
if (!mux || mux.isDisposed()) {
|
||||
return null
|
||||
}
|
||||
const commands = args.baseAgents
|
||||
? KNOWN_TUI_AGENT_DETECTION_COMMANDS.filter((command) => args.baseAgents?.includes(command.id))
|
||||
: KNOWN_TUI_AGENT_DETECTION_COMMANDS
|
||||
if (commands.length === 0) {
|
||||
return []
|
||||
}
|
||||
const result = (await mux.request('preflight.detectAgents', {
|
||||
commands: KNOWN_TUI_AGENT_DETECTION_COMMANDS
|
||||
commands
|
||||
})) as { agents: string[] }
|
||||
return uniqueAgentIds(result.agents)
|
||||
}
|
||||
|
||||
@@ -20,9 +20,13 @@ export class SshAgentSessionCapabilities {
|
||||
await waitForSshCapabilityProbe(probe, options.signal)
|
||||
this.claimSupported = true
|
||||
return true
|
||||
} catch {
|
||||
if (!options.signal?.aborted && this.claimProbe === probe) {
|
||||
// Why: negative physical probes must follow a relay upgraded on this connection.
|
||||
} catch (error) {
|
||||
if (
|
||||
!options.signal?.aborted &&
|
||||
this.claimProbe === probe &&
|
||||
(error as { capabilityProbeTransportFailure?: unknown }).capabilityProbeTransportFailure ===
|
||||
true
|
||||
) {
|
||||
this.claimProbe = null
|
||||
this.claimSupported = false
|
||||
}
|
||||
@@ -41,13 +45,12 @@ export class SshAgentSessionCapabilities {
|
||||
try {
|
||||
supported = await waitForSshCapabilityProbe(probe, options.signal)
|
||||
} catch {
|
||||
// Why: one canceled waiter must not cancel or evict the shared physical probe used by peers.
|
||||
// Why: a transport failure is unknown and may recover on this connection.
|
||||
if (!options.signal?.aborted && this.createOperationProbe === probe) {
|
||||
this.createOperationProbe = null
|
||||
}
|
||||
return false
|
||||
}
|
||||
if (!supported && this.createOperationProbe === probe) {
|
||||
// Why: negative capability results must follow a relay upgraded on the same connection.
|
||||
this.createOperationProbe = null
|
||||
}
|
||||
return supported
|
||||
}
|
||||
|
||||
@@ -58,13 +61,12 @@ export class SshAgentSessionCapabilities {
|
||||
try {
|
||||
supported = await waitForSshCapabilityProbe(probe, options.signal)
|
||||
} catch {
|
||||
// Why: one canceled waiter must not cancel or evict the shared physical probe used by peers.
|
||||
// Why: a transport failure is unknown and may recover on this connection.
|
||||
if (!options.signal?.aborted && this.launchTokenEchoProbe === probe) {
|
||||
this.launchTokenEchoProbe = null
|
||||
}
|
||||
return false
|
||||
}
|
||||
if (!supported && this.launchTokenEchoProbe === probe) {
|
||||
// Why: negative capability results must follow a relay upgraded on the same connection.
|
||||
this.launchTokenEchoProbe = null
|
||||
}
|
||||
this.launchTokenEchoSupported = supported
|
||||
return supported
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { JsonRpcErrorCode } from '../ssh/relay-protocol'
|
||||
|
||||
export const SSH_AGENT_SESSION_CAPABILITY_PROBE_TIMEOUT_MS = 5_000
|
||||
|
||||
export function isSshCapabilityMethodUnavailable(error: unknown): boolean {
|
||||
return (error as { code?: unknown })?.code === JsonRpcErrorCode.MethodNotFound
|
||||
}
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
} from '../../shared/agent-session-host-authority'
|
||||
import type { PtySpawnResult } from './pty-spawn-result'
|
||||
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
|
||||
import { SSH_AGENT_SESSION_CAPABILITY_PROBE_TIMEOUT_MS } from './ssh-agent-session-create-operation'
|
||||
import {
|
||||
isSshCapabilityMethodUnavailable,
|
||||
SSH_AGENT_SESSION_CAPABILITY_PROBE_TIMEOUT_MS
|
||||
} from './ssh-agent-session-capability-probe'
|
||||
import { isPtyIncarnationId } from '../../shared/pty-incarnation'
|
||||
|
||||
export type ClaimedSshSpawnValidation =
|
||||
@@ -17,17 +20,23 @@ export async function proveSshAgentSessionClaimCapability(
|
||||
mux: SshChannelMultiplexer,
|
||||
options: { signal?: AbortSignal } = {}
|
||||
): Promise<void> {
|
||||
let result: { agentSessionClaimVersion?: unknown }
|
||||
try {
|
||||
const result = (await mux.request('pty.getCapabilities', undefined, {
|
||||
result = (await mux.request('pty.getCapabilities', undefined, {
|
||||
signal: options.signal,
|
||||
timeoutMs: SSH_AGENT_SESSION_CAPABILITY_PROBE_TIMEOUT_MS
|
||||
})) as {
|
||||
agentSessionClaimVersion?: unknown
|
||||
}
|
||||
if (result.agentSessionClaimVersion !== AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION) {
|
||||
throw new Error('unsupported')
|
||||
} catch (error) {
|
||||
if (isSshCapabilityMethodUnavailable(error)) {
|
||||
throw new Error('agent_session_claim_unavailable')
|
||||
}
|
||||
} catch {
|
||||
throw Object.assign(new Error('agent_session_claim_unavailable'), {
|
||||
capabilityProbeTransportFailure: true
|
||||
})
|
||||
}
|
||||
if (result.agentSessionClaimVersion !== AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION) {
|
||||
throw new Error('agent_session_claim_unavailable')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ import {
|
||||
type PtySourceReceivingActivation
|
||||
} from '../../shared/pty-source-receiving-activation'
|
||||
import { validateClaimedSshSpawn } from './ssh-agent-session-claim-validation'
|
||||
|
||||
export const SSH_AGENT_SESSION_CAPABILITY_PROBE_TIMEOUT_MS = 5_000
|
||||
import {
|
||||
isSshCapabilityMethodUnavailable,
|
||||
SSH_AGENT_SESSION_CAPABILITY_PROBE_TIMEOUT_MS
|
||||
} from './ssh-agent-session-capability-probe'
|
||||
|
||||
export function assertSshAgentSessionCreateResult(
|
||||
result: unknown
|
||||
@@ -46,9 +48,11 @@ export async function sshSupportsAgentSessionCreateOperations(
|
||||
return (
|
||||
result.agentSessionCreateOperationVersion === AGENT_SESSION_CREATE_OPERATION_PROTOCOL_VERSION
|
||||
)
|
||||
} catch {
|
||||
// Why: capability probing does not spawn, so an old relay can safely keep legacy behavior.
|
||||
return false
|
||||
} catch (error) {
|
||||
if (isSshCapabilityMethodUnavailable(error)) {
|
||||
return false
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
|
||||
import { LAUNCH_TOKEN_ECHO_PROTOCOL_VERSION } from '../../shared/agent-launch-token-echo-protocol'
|
||||
import { SSH_AGENT_SESSION_CAPABILITY_PROBE_TIMEOUT_MS } from './ssh-agent-session-create-operation'
|
||||
import {
|
||||
isSshCapabilityMethodUnavailable,
|
||||
SSH_AGENT_SESSION_CAPABILITY_PROBE_TIMEOUT_MS
|
||||
} from './ssh-agent-session-capability-probe'
|
||||
|
||||
export async function sshEchoesLaunchTokens(
|
||||
mux: SshChannelMultiplexer,
|
||||
@@ -12,8 +15,10 @@ export async function sshEchoesLaunchTokens(
|
||||
timeoutMs: SSH_AGENT_SESSION_CAPABILITY_PROBE_TIMEOUT_MS
|
||||
})) as { launchTokenEchoVersion?: unknown }
|
||||
return result.launchTokenEchoVersion === LAUNCH_TOKEN_ECHO_PROTOCOL_VERSION
|
||||
} catch {
|
||||
// Why: probing never spawns, so an unreachable or old relay just keeps the tokenless path.
|
||||
return false
|
||||
} catch (error) {
|
||||
if (isSshCapabilityMethodUnavailable(error)) {
|
||||
return false
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,14 +158,14 @@ describe('SSH fresh agent-session create operations', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('re-probes a negative capability after an in-place relay upgrade', async () => {
|
||||
it('caches an old-relay negative for the provider connection', async () => {
|
||||
request.mockResolvedValueOnce({}).mockResolvedValueOnce({
|
||||
agentSessionCreateOperationVersion: AGENT_SESSION_CREATE_OPERATION_PROTOCOL_VERSION
|
||||
})
|
||||
|
||||
await expect(provider.supportsAgentSessionCreateOperations()).resolves.toBe(false)
|
||||
await expect(provider.supportsAgentSessionCreateOperations()).resolves.toBe(true)
|
||||
expect(request).toHaveBeenCalledTimes(2)
|
||||
await expect(provider.supportsAgentSessionCreateOperations()).resolves.toBe(false)
|
||||
expect(request).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps a shared capability probe alive when one waiter disconnects', async () => {
|
||||
|
||||
@@ -30,9 +30,13 @@ describe('SSH launch-token echo negotiation', () => {
|
||||
)
|
||||
|
||||
await provider.spawn({ cols: 80, rows: 24, command: 'claude', launchToken: 'tok-1' })
|
||||
await provider.spawn({ cols: 80, rows: 24, command: 'claude', launchToken: 'tok-2' })
|
||||
|
||||
expect('launchToken' in spawnParams()).toBe(false)
|
||||
expect(provider.providesLaunchTokenListings()).toBe(false)
|
||||
expect(request.mock.calls.filter(([method]) => method === 'pty.getCapabilities')).toHaveLength(
|
||||
1
|
||||
)
|
||||
})
|
||||
|
||||
it('sends the token once the relay advertises the echo', async () => {
|
||||
@@ -59,13 +63,48 @@ describe('SSH launch-token echo negotiation', () => {
|
||||
expect('launchToken' in spawnParams()).toBe(false)
|
||||
})
|
||||
|
||||
it('re-probes a negative echo capability after an in-place relay upgrade', async () => {
|
||||
it('caches an old-relay negative for the provider connection', async () => {
|
||||
request.mockResolvedValueOnce({}).mockResolvedValueOnce({
|
||||
launchTokenEchoVersion: LAUNCH_TOKEN_ECHO_PROTOCOL_VERSION
|
||||
})
|
||||
|
||||
await expect(provider.supportsLaunchTokenEcho()).resolves.toBe(false)
|
||||
await expect(provider.supportsLaunchTokenEcho()).resolves.toBe(false)
|
||||
expect(request).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('caches an old relay without the capability method', async () => {
|
||||
request.mockRejectedValue(Object.assign(new Error('Method not found'), { code: -32601 }))
|
||||
|
||||
await expect(provider.supportsLaunchTokenEcho()).resolves.toBe(false)
|
||||
await expect(provider.supportsLaunchTokenEcho()).resolves.toBe(false)
|
||||
expect(request).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('retries an unverifiable transport failure on the same connection', async () => {
|
||||
request.mockRejectedValueOnce(new Error('connection stalled')).mockResolvedValueOnce({
|
||||
launchTokenEchoVersion: LAUNCH_TOKEN_ECHO_PROTOCOL_VERSION
|
||||
})
|
||||
|
||||
await expect(provider.supportsLaunchTokenEcho()).resolves.toBe(false)
|
||||
await expect(provider.supportsLaunchTokenEcho()).resolves.toBe(true)
|
||||
expect(request).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('re-probes capabilities on a replacement provider connection', async () => {
|
||||
request.mockResolvedValueOnce({}).mockResolvedValueOnce({
|
||||
launchTokenEchoVersion: LAUNCH_TOKEN_ECHO_PROTOCOL_VERSION
|
||||
})
|
||||
|
||||
await expect(provider.supportsLaunchTokenEcho()).resolves.toBe(false)
|
||||
const replacement = new SshPtyProvider('conn-1', {
|
||||
request,
|
||||
notify: vi.fn(),
|
||||
onNotification: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
isDisposed: vi.fn(() => false)
|
||||
} as never)
|
||||
await expect(replacement.supportsLaunchTokenEcho()).resolves.toBe(true)
|
||||
expect(request).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -50,6 +50,24 @@ describe('SshPtyProvider', () => {
|
||||
expect(mux.request).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('caches an old-relay claim negative for the provider connection', async () => {
|
||||
mux.request.mockResolvedValue({})
|
||||
|
||||
await expect(provider.supportsAgentSessionClaims()).resolves.toBe(false)
|
||||
await expect(provider.supportsAgentSessionClaims()).resolves.toBe(false)
|
||||
expect(mux.request).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('retries an unverifiable claim probe transport failure', async () => {
|
||||
mux.request.mockRejectedValueOnce(new Error('connection stalled')).mockResolvedValueOnce({
|
||||
agentSessionClaimVersion: AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION
|
||||
})
|
||||
|
||||
await expect(provider.supportsAgentSessionClaims()).resolves.toBe(false)
|
||||
await expect(provider.supportsAgentSessionClaims()).resolves.toBe(true)
|
||||
expect(mux.request).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('attach sends pty.attach request', async () => {
|
||||
await provider.attach(scopedPty1)
|
||||
expectRequest(mux.request, 'pty.attach', { id: 'pty-1' })
|
||||
|
||||
@@ -90,9 +90,8 @@ async function spawnFreshSession(
|
||||
): Promise<PtySpawnResult> {
|
||||
// Why probed before dispatch: an old relay accepts the token and never re-lists it, so
|
||||
// withhold it and let reconciliation keep its pre-token identification for this host.
|
||||
// Concurrent because the two probes memoize separately and each evicts on a negative
|
||||
// answer — serialized, a create-capable pre-echo relay paid two full `pty.getCapabilities`
|
||||
// round-trips (5 s ceiling each) on every fresh spawn.
|
||||
// Concurrent because the two capabilities are independent; their results are
|
||||
// cached for this provider generation, including definitive old-relay negatives.
|
||||
// Skipped entirely when nothing is probed: awaiting settled promises would push a plain
|
||||
// spawn's `pty.spawn` dispatch out of the caller's turn.
|
||||
const [supportsCreateOperation, supportsLaunchTokenEcho] =
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import type { Repo } from '../../shared/types'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import type { CustomTuiAgentId, Repo } from '../../shared/types'
|
||||
import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
|
||||
const testState = { dir: '' }
|
||||
@@ -26,6 +27,7 @@ vi.mock('electron', () => ({
|
||||
|
||||
async function createStore() {
|
||||
vi.resetModules()
|
||||
installFakeAppEnvironment({ getPath: () => testState.dir })
|
||||
const { Store, initDataPath } = await import('../persistence')
|
||||
initDataPath()
|
||||
return new Store()
|
||||
@@ -38,6 +40,7 @@ const REPO: Repo = {
|
||||
badgeColor: '#fff',
|
||||
addedAt: 1
|
||||
}
|
||||
const CUSTOM_AGENT_ID: CustomTuiAgentId = 'custom-agent:claude:01234567-89ab-4cde-8f01-23456789abcd'
|
||||
|
||||
describe('OrcaRuntimeService.classifyAgentLaunchForAutomation (U6)', () => {
|
||||
beforeEach(() => {
|
||||
@@ -54,7 +57,7 @@ describe('OrcaRuntimeService.classifyAgentLaunchForAutomation (U6)', () => {
|
||||
store.updateSettings({ disabledTuiAgents: ['claude'] })
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
|
||||
const failure = runtime.classifyAgentLaunchForAutomation('claude', REPO, 'run-1')
|
||||
const failure = runtime.classifyAgentLaunchForAutomation('claude', REPO, 'run-1', '/repo')
|
||||
|
||||
expect(failure).not.toBeNull()
|
||||
expect(failure?.code).toBe('base_agent_disabled')
|
||||
@@ -70,6 +73,71 @@ describe('OrcaRuntimeService.classifyAgentLaunchForAutomation (U6)', () => {
|
||||
store.addRepo(REPO)
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
|
||||
expect(runtime.classifyAgentLaunchForAutomation('claude', REPO, 'run-1')).toBeNull()
|
||||
expect(runtime.classifyAgentLaunchForAutomation('claude', REPO, 'run-1', '/repo')).toBeNull()
|
||||
})
|
||||
|
||||
it('classifies custom agents with automation repo and worktree variables', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo(REPO)
|
||||
store.updateSettings({
|
||||
customTuiAgents: [
|
||||
{
|
||||
id: CUSTOM_AGENT_ID,
|
||||
baseAgent: 'claude',
|
||||
label: 'Context Agent',
|
||||
args: '--repo {repoPath} --worktree {worktreePath}',
|
||||
env: { AUTOMATION_ROOT: '{worktreePath}' },
|
||||
syncEnv: false
|
||||
}
|
||||
]
|
||||
})
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
|
||||
expect(
|
||||
runtime.classifyAgentLaunchForAutomation(
|
||||
CUSTOM_AGENT_ID,
|
||||
REPO,
|
||||
'run-context',
|
||||
'/repo/worktrees/context'
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('classifies local custom command overrides against the target home', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo(REPO)
|
||||
store.updateSettings({
|
||||
customTuiAgents: [
|
||||
{
|
||||
id: CUSTOM_AGENT_ID,
|
||||
baseAgent: 'claude',
|
||||
label: 'Home Agent',
|
||||
commandOverride: '~/my agent/bin/claude',
|
||||
args: '',
|
||||
env: {},
|
||||
syncEnv: false
|
||||
}
|
||||
]
|
||||
})
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
|
||||
expect(
|
||||
runtime.classifyAgentLaunchForAutomation(CUSTOM_AGENT_ID, REPO, 'run-home', '/repo')
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the legacy resolve-only target home unknown', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo(REPO)
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const internals = runtime as unknown as {
|
||||
buildResolveOnlySpawnTarget: (
|
||||
repo: Repo,
|
||||
includeLocalTargetHome?: boolean
|
||||
) => { targetHomePath: string | null }
|
||||
}
|
||||
|
||||
expect(internals.buildResolveOnlySpawnTarget(REPO).targetHomePath).toBeNull()
|
||||
expect(internals.buildResolveOnlySpawnTarget(REPO, true).targetHomePath).toBe(homedir())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -135,7 +135,7 @@ describe('resolveTerminalAgentLaunch', () => {
|
||||
followupPrompt: 'go'
|
||||
})
|
||||
// Detection ran against the target descriptor.
|
||||
expect(detectStockBaseAgents).toHaveBeenCalledWith(DESCRIPTOR)
|
||||
expect(detectStockBaseAgents).toHaveBeenCalledWith(DESCRIPTOR, ['claude'])
|
||||
// Trust preflight marked the workspace for the resolved launch before admission.
|
||||
expect(trusted).toHaveLength(1)
|
||||
expect(trusted[0]!.baseAgent).toBe('claude')
|
||||
@@ -280,7 +280,7 @@ describe('resolveTerminalAgentLaunch target-host planning (U7 oracle-14)', () =>
|
||||
expect(captured!.shell).toBe(shell)
|
||||
expect(captured!.isRemote).toBe(descriptor.kind === 'ssh')
|
||||
// Stock detection ran against the TARGET descriptor, not a client host.
|
||||
expect(detectStockBaseAgents).toHaveBeenCalledWith(descriptor)
|
||||
expect(detectStockBaseAgents).toHaveBeenCalledWith(descriptor, ['claude'])
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
// so it is unit-testable.
|
||||
|
||||
import type { BuiltInTuiAgent, GlobalSettings, Repo } from '../../shared/types'
|
||||
import type { AgentStartupShell } from '../../shared/tui-agent-startup-shell'
|
||||
import type { AgentLaunchReceipt } from '../../shared/agent-launch-contract'
|
||||
import type {
|
||||
AgentLaunchInput,
|
||||
@@ -27,6 +28,7 @@ import type {
|
||||
} from '../../shared/agent-session-resume'
|
||||
import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery'
|
||||
import {
|
||||
detectionBaseAgentsForLaunch,
|
||||
deriveAgentLaunchHostState,
|
||||
type AgentLaunchHostDescriptor
|
||||
} from '../agent-launch/agent-launch-host-state'
|
||||
@@ -86,9 +88,13 @@ export type TerminalAgentLaunchDeps = {
|
||||
getSettings: () => GlobalSettings
|
||||
getCatalogRevision: () => number
|
||||
detectStockBaseAgents: (
|
||||
descriptor: AgentLaunchHostDescriptor
|
||||
descriptor: AgentLaunchHostDescriptor,
|
||||
baseAgents?: readonly BuiltInTuiAgent[]
|
||||
) => Promise<readonly string[] | null>
|
||||
resolveTargetHomePath: (descriptor: AgentLaunchHostDescriptor) => Promise<string | null>
|
||||
resolveStartupShell?: (
|
||||
descriptor: AgentLaunchHostDescriptor
|
||||
) => Promise<AgentStartupShell | undefined>
|
||||
/** Best-effort workspace trust for the resolved base agent, run as the
|
||||
* boundary's pre-admission preflight. Must not throw for a routine no-trust
|
||||
* agent; a throw maps to trust_preflight_failed with no admission record. */
|
||||
@@ -174,20 +180,26 @@ export async function resolveTerminalAgentLaunch(
|
||||
deps: TerminalAgentLaunchDeps,
|
||||
args: TerminalAgentLaunchArgs
|
||||
): Promise<TerminalAgentLaunchResolution> {
|
||||
const spawnInput = resolveTerminalSpawnInput(args, deps.sessionRecordStore)
|
||||
if (!spawnInput.ok) {
|
||||
return { kind: 'failed', outcome: { status: 'failed', failure: spawnInput.failure } }
|
||||
}
|
||||
const detectionBaseAgents = detectionBaseAgentsForLaunch(
|
||||
spawnInput.input.request,
|
||||
spawnInput.input.persistedSnapshot
|
||||
)
|
||||
const hostState = await deriveAgentLaunchHostState(
|
||||
{
|
||||
getSettings: deps.getSettings,
|
||||
getCatalogRevision: deps.getCatalogRevision,
|
||||
detectStockBaseAgents: deps.detectStockBaseAgents,
|
||||
resolveTargetHomePath: deps.resolveTargetHomePath
|
||||
resolveTargetHomePath: deps.resolveTargetHomePath,
|
||||
...(deps.resolveStartupShell ? { resolveStartupShell: deps.resolveStartupShell } : {})
|
||||
},
|
||||
args.descriptor,
|
||||
{ worktreePath: args.worktreePath, repoPath: args.repoPath }
|
||||
{ worktreePath: args.worktreePath, repoPath: args.repoPath },
|
||||
detectionBaseAgents === undefined ? {} : { detectionBaseAgents }
|
||||
)
|
||||
const spawnInput = resolveTerminalSpawnInput(args, deps.sessionRecordStore)
|
||||
if (!spawnInput.ok) {
|
||||
return { kind: 'failed', outcome: { status: 'failed', failure: spawnInput.failure } }
|
||||
}
|
||||
const resolution = await resolveAgentLaunchSpawn(
|
||||
{
|
||||
getSettings: hostState.getSettings,
|
||||
|
||||
@@ -80,7 +80,10 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
||||
return (
|
||||
<>
|
||||
<WorktreeCardSurface card={card} />
|
||||
<WorktreeCardBackgroundLaunchFailures worktreeId={worktree.id} />
|
||||
<WorktreeCardBackgroundLaunchFailures
|
||||
worktreeId={worktree.id}
|
||||
executionHostId={worktree.hostId ?? 'local'}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -11,11 +11,12 @@ import type {
|
||||
import type { BackgroundAgentLaunchAttempt } from '../../../../shared/background-agent-launch'
|
||||
|
||||
const WORKTREE_ID = 'repo1::/tmp/wt'
|
||||
const HOST_ID = 'ssh:devbox' as const
|
||||
|
||||
type WorktreeShape = { backgroundAgentLaunches?: BackgroundAgentLaunchAttempt[] }
|
||||
|
||||
const storeBox = vi.hoisted(() => ({ state: null as unknown }))
|
||||
const worktreeBox = vi.hoisted(() => ({ worktree: null as WorktreeShape | null }))
|
||||
const worktreeBox = vi.hoisted(() => ({ byHost: new Map<string, WorktreeShape>() }))
|
||||
// Holds what the soft confirm hook returns; a null value reproduces a
|
||||
// provider-less render (the crash class that took out the WorktreeCard family).
|
||||
const confirmBox = vi.hoisted(() => ({ value: null as unknown }))
|
||||
@@ -41,8 +42,13 @@ vi.mock('@/components/confirmation-dialog-context', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/store/selectors', () => ({
|
||||
getWorktreeMapFromState: () =>
|
||||
new Map(worktreeBox.worktree ? [[WORKTREE_ID, worktreeBox.worktree]] : [])
|
||||
getWorktreeOnHostFromState: (_state: unknown, _worktreeId: string, hostId: string) =>
|
||||
worktreeBox.byHost.get(hostId)
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/worktree-operation-route', () => ({
|
||||
resolveWorktreeOperationRouteForHost: vi.fn(),
|
||||
settingsForWorktreeOperationRoute: vi.fn()
|
||||
}))
|
||||
|
||||
function failure(code: AgentLaunchFailureCode): PersistedAgentLaunchFailure {
|
||||
@@ -75,7 +81,9 @@ async function render(): Promise<void> {
|
||||
const root = createRoot(container)
|
||||
mountedRoots.push(root)
|
||||
await act(async () => {
|
||||
root.render(<WorktreeCardBackgroundLaunchFailures worktreeId={WORKTREE_ID} />)
|
||||
root.render(
|
||||
<WorktreeCardBackgroundLaunchFailures worktreeId={WORKTREE_ID} executionHostId={HOST_ID} />
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -99,7 +107,7 @@ beforeEach(() => {
|
||||
mocks.forgetUnknownAgentLaunchSiblings.mockResolvedValue({ forgottenCount: 0 })
|
||||
mocks.confirm.mockResolvedValue(true)
|
||||
confirmBox.value = mocks.confirm
|
||||
worktreeBox.worktree = null
|
||||
worktreeBox.byHost.clear()
|
||||
storeBox.state = {
|
||||
// The container guards a missing worktreesByRepo slice (partial sibling-suite
|
||||
// mocks) before projecting meta; production always carries it, so seed a
|
||||
@@ -127,6 +135,26 @@ describe('WorktreeCardBackgroundLaunchFailures', () => {
|
||||
expect(document.body.querySelector('[role="alert"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('selects the attempt row on the card host when worktree ids collide', async () => {
|
||||
worktreeBox.byHost.set('local', {
|
||||
backgroundAgentLaunches: [attempt({ failure: failure('unknown_agent') })]
|
||||
})
|
||||
worktreeBox.byHost.set(HOST_ID, {
|
||||
backgroundAgentLaunches: [
|
||||
attempt({ state: 'pending', failure: failure('launch_state_unknown') })
|
||||
]
|
||||
})
|
||||
|
||||
await render()
|
||||
|
||||
expect(buttonByLabel('Reconnect')).toBeTruthy()
|
||||
expect(
|
||||
[...document.body.querySelectorAll('button')].some(
|
||||
(button) => button.textContent === 'Choose agent'
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('renders nothing without throwing when the store omits the worktreesByRepo slice', async () => {
|
||||
// Reproduces the minimal sibling-suite store shape that made the indexed
|
||||
// worktree selector throw on Object.values(undefined) during G6's full run.
|
||||
@@ -141,18 +169,18 @@ describe('WorktreeCardBackgroundLaunchFailures', () => {
|
||||
})
|
||||
|
||||
it('renders nothing for launched/forgotten attempts (no surfacing failure)', async () => {
|
||||
worktreeBox.worktree = {
|
||||
worktreeBox.byHost.set(HOST_ID, {
|
||||
backgroundAgentLaunches: [
|
||||
attempt({ attemptId: 'a', state: 'launched', failure: null }),
|
||||
attempt({ attemptId: 'b', state: 'forgotten', failure: failure('spawn_failed') })
|
||||
]
|
||||
}
|
||||
})
|
||||
await render()
|
||||
expect(document.body.querySelector('[role="alert"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('retries a failed attempt against its failure id, keyed by the attempt id', async () => {
|
||||
worktreeBox.worktree = { backgroundAgentLaunches: [attempt()] }
|
||||
worktreeBox.byHost.set(HOST_ID, { backgroundAgentLaunches: [attempt()] })
|
||||
await render()
|
||||
await act(async () => {
|
||||
buttonByLabel('Retry').click()
|
||||
@@ -160,17 +188,18 @@ describe('WorktreeCardBackgroundLaunchFailures', () => {
|
||||
expect(mocks.retryBackgroundAgentLaunch).toHaveBeenCalledExactlyOnceWith({
|
||||
attemptId: 'attempt-1',
|
||||
worktreeId: WORKTREE_ID,
|
||||
executionHostId: HOST_ID,
|
||||
expectedFailureId: 'failure-7',
|
||||
action: { kind: 'retry-same' }
|
||||
})
|
||||
})
|
||||
|
||||
it('forgets an unknown attempt after the destructive confirmation, using its operation id as the guard', async () => {
|
||||
worktreeBox.worktree = {
|
||||
worktreeBox.byHost.set(HOST_ID, {
|
||||
backgroundAgentLaunches: [
|
||||
attempt({ state: 'pending', failure: failure('launch_state_unknown') })
|
||||
]
|
||||
}
|
||||
})
|
||||
await render()
|
||||
await act(async () => {
|
||||
buttonByLabel('Forget launch…').click()
|
||||
@@ -185,17 +214,18 @@ describe('WorktreeCardBackgroundLaunchFailures', () => {
|
||||
expect(mocks.forgetBackgroundAgentLaunch).toHaveBeenCalledExactlyOnceWith({
|
||||
attemptId: 'attempt-1',
|
||||
worktreeId: WORKTREE_ID,
|
||||
executionHostId: HOST_ID,
|
||||
expectedOperationId: 'op-9'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not forget an unknown attempt when the destructive confirmation is declined', async () => {
|
||||
mocks.confirm.mockResolvedValue(false)
|
||||
worktreeBox.worktree = {
|
||||
worktreeBox.byHost.set(HOST_ID, {
|
||||
backgroundAgentLaunches: [
|
||||
attempt({ state: 'pending', failure: failure('launch_state_unknown') })
|
||||
]
|
||||
}
|
||||
})
|
||||
await render()
|
||||
await act(async () => {
|
||||
buttonByLabel('Forget launch…').click()
|
||||
@@ -212,17 +242,18 @@ describe('WorktreeCardBackgroundLaunchFailures', () => {
|
||||
return true
|
||||
}
|
||||
)
|
||||
worktreeBox.worktree = {
|
||||
worktreeBox.byHost.set(HOST_ID, {
|
||||
backgroundAgentLaunches: [
|
||||
attempt({ state: 'pending', failure: failure('launch_state_unknown') })
|
||||
]
|
||||
}
|
||||
})
|
||||
await render()
|
||||
await act(async () => {
|
||||
buttonByLabel('Forget launch…').click()
|
||||
})
|
||||
expect(mocks.unknownAgentLaunchSiblingPreflight).toHaveBeenCalledExactlyOnceWith({
|
||||
worktreeId: WORKTREE_ID
|
||||
worktreeId: WORKTREE_ID,
|
||||
executionHostId: HOST_ID
|
||||
})
|
||||
expect(mocks.confirm).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({
|
||||
@@ -234,20 +265,22 @@ describe('WorktreeCardBackgroundLaunchFailures', () => {
|
||||
expect(mocks.forgetBackgroundAgentLaunch).toHaveBeenCalledExactlyOnceWith({
|
||||
attemptId: 'attempt-1',
|
||||
worktreeId: WORKTREE_ID,
|
||||
executionHostId: HOST_ID,
|
||||
expectedOperationId: 'op-9'
|
||||
})
|
||||
expect(mocks.forgetUnknownAgentLaunchSiblings).toHaveBeenCalledExactlyOnceWith({
|
||||
worktreeId: WORKTREE_ID
|
||||
worktreeId: WORKTREE_ID,
|
||||
executionHostId: HOST_ID
|
||||
})
|
||||
})
|
||||
|
||||
it('omits the opt-in and still forgets the attempt when the sibling preflight fails', async () => {
|
||||
mocks.unknownAgentLaunchSiblingPreflight.mockRejectedValue(new Error('unreachable'))
|
||||
worktreeBox.worktree = {
|
||||
worktreeBox.byHost.set(HOST_ID, {
|
||||
backgroundAgentLaunches: [
|
||||
attempt({ state: 'pending', failure: failure('launch_state_unknown') })
|
||||
]
|
||||
}
|
||||
})
|
||||
await render()
|
||||
await act(async () => {
|
||||
buttonByLabel('Forget launch…').click()
|
||||
@@ -265,11 +298,11 @@ describe('WorktreeCardBackgroundLaunchFailures', () => {
|
||||
// The card must still render and the destructive forget must not fire
|
||||
// unconfirmed, rather than throwing and crashing the whole family.
|
||||
confirmBox.value = null
|
||||
worktreeBox.worktree = {
|
||||
worktreeBox.byHost.set(HOST_ID, {
|
||||
backgroundAgentLaunches: [
|
||||
attempt({ state: 'pending', failure: failure('launch_state_unknown') })
|
||||
]
|
||||
}
|
||||
})
|
||||
await render()
|
||||
expect(document.body.querySelector('[role="alert"]')).not.toBeNull()
|
||||
await act(async () => {
|
||||
@@ -280,11 +313,11 @@ describe('WorktreeCardBackgroundLaunchFailures', () => {
|
||||
})
|
||||
|
||||
it('routes reconnect on an unknown attempt to the ssh settings pane', async () => {
|
||||
worktreeBox.worktree = {
|
||||
worktreeBox.byHost.set(HOST_ID, {
|
||||
backgroundAgentLaunches: [
|
||||
attempt({ state: 'pending', failure: failure('launch_state_unknown') })
|
||||
]
|
||||
}
|
||||
})
|
||||
await render()
|
||||
await act(async () => {
|
||||
buttonByLabel('Reconnect').click()
|
||||
@@ -294,9 +327,9 @@ describe('WorktreeCardBackgroundLaunchFailures', () => {
|
||||
})
|
||||
|
||||
it('routes selection recovery to the desktop-host agents settings pane', async () => {
|
||||
worktreeBox.worktree = {
|
||||
worktreeBox.byHost.set(HOST_ID, {
|
||||
backgroundAgentLaunches: [attempt({ failure: failure('unknown_agent') })]
|
||||
}
|
||||
})
|
||||
await render()
|
||||
await act(async () => {
|
||||
buttonByLabel('Choose agent').click()
|
||||
@@ -309,13 +342,13 @@ describe('WorktreeCardBackgroundLaunchFailures', () => {
|
||||
})
|
||||
|
||||
it('renders one card per surfacing attempt', async () => {
|
||||
worktreeBox.worktree = {
|
||||
worktreeBox.byHost.set(HOST_ID, {
|
||||
backgroundAgentLaunches: [
|
||||
attempt({ attemptId: 'a' }),
|
||||
attempt({ attemptId: 'b', state: 'pending', failure: failure('launch_state_unknown') }),
|
||||
attempt({ attemptId: 'c', state: 'launched', failure: null })
|
||||
]
|
||||
}
|
||||
})
|
||||
await render()
|
||||
expect(document.body.querySelectorAll('[role="alert"]').length).toBe(2)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getWorktreeMapFromState } from '@/store/selectors'
|
||||
import { getWorktreeOnHostFromState } from '@/store/selectors'
|
||||
import { useOptionalConfirmationDialog } from '@/components/confirmation-dialog-context'
|
||||
import { WorktreeAgentLaunchFailure } from './WorktreeAgentLaunchFailure'
|
||||
import {
|
||||
@@ -15,11 +15,15 @@ import {
|
||||
AGENTS_SETTINGS_ACTIONS,
|
||||
RETRY_SAME_ACTIONS
|
||||
} from '@/lib/agent-launch-recovery-action-dispatch'
|
||||
import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner'
|
||||
import {
|
||||
resolveWorktreeOperationRouteForHost,
|
||||
settingsForWorktreeOperationRoute
|
||||
} from '@/lib/worktree-operation-route'
|
||||
import { getActiveRuntimeTarget } from '@/runtime/runtime-client-target'
|
||||
import type { AgentLaunchRecoveryActionId } from '@/lib/agent-launch-recovery-card'
|
||||
import type { AppState } from '@/store/types'
|
||||
import type { BackgroundAgentLaunchAttempt } from '../../../../shared/background-agent-launch'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
|
||||
// Stable empty reference so the selector never returns a fresh array identity and
|
||||
// re-renders in a loop when a worktree carries no background attempts.
|
||||
@@ -31,12 +35,16 @@ const NO_ATTEMPTS: readonly BackgroundAgentLaunchAttempt[] = []
|
||||
// Object.values(undefined)) rather than hardening the shared selector.
|
||||
function selectBackgroundAttempts(
|
||||
state: AppState,
|
||||
worktreeId: string
|
||||
worktreeId: string,
|
||||
executionHostId: ExecutionHostId
|
||||
): readonly BackgroundAgentLaunchAttempt[] {
|
||||
if (!state.worktreesByRepo) {
|
||||
return NO_ATTEMPTS
|
||||
}
|
||||
return getWorktreeMapFromState(state).get(worktreeId)?.backgroundAgentLaunches ?? NO_ATTEMPTS
|
||||
return (
|
||||
getWorktreeOnHostFromState(state, worktreeId, executionHostId)?.backgroundAgentLaunches ??
|
||||
NO_ATTEMPTS
|
||||
)
|
||||
}
|
||||
|
||||
type SurfacedCard = {
|
||||
@@ -53,11 +61,13 @@ type SurfacedCard = {
|
||||
* reconciles launched/forgotten back out of the meta, so this holds no attempt
|
||||
* state of its own. Renders nothing until an attempt has a surfacing failure. */
|
||||
export function WorktreeCardBackgroundLaunchFailures({
|
||||
worktreeId
|
||||
worktreeId,
|
||||
executionHostId
|
||||
}: {
|
||||
worktreeId: string
|
||||
executionHostId: ExecutionHostId
|
||||
}): React.JSX.Element | null {
|
||||
const attempts = useAppStore((s) => selectBackgroundAttempts(s, worktreeId))
|
||||
const attempts = useAppStore((s) => selectBackgroundAttempts(s, worktreeId, executionHostId))
|
||||
const retryBackgroundAgentLaunch = useAppStore((s) => s.retryBackgroundAgentLaunch)
|
||||
const forgetBackgroundAgentLaunch = useAppStore((s) => s.forgetBackgroundAgentLaunch)
|
||||
const unknownAgentLaunchSiblingPreflight = useAppStore(
|
||||
@@ -96,6 +106,7 @@ export function WorktreeCardBackgroundLaunchFailures({
|
||||
await retryBackgroundAgentLaunch({
|
||||
attemptId: attempt.attemptId,
|
||||
worktreeId,
|
||||
executionHostId,
|
||||
expectedFailureId,
|
||||
action: { kind: 'retry-same' }
|
||||
})
|
||||
@@ -117,7 +128,10 @@ export function WorktreeCardBackgroundLaunchFailures({
|
||||
let siblingCount = 0
|
||||
let siblingHostName = ''
|
||||
try {
|
||||
const preflight = await unknownAgentLaunchSiblingPreflight({ worktreeId })
|
||||
const preflight = await unknownAgentLaunchSiblingPreflight({
|
||||
worktreeId,
|
||||
executionHostId
|
||||
})
|
||||
siblingCount = preflight.count
|
||||
siblingHostName = preflight.hostName
|
||||
} catch {
|
||||
@@ -153,13 +167,14 @@ export function WorktreeCardBackgroundLaunchFailures({
|
||||
await forgetBackgroundAgentLaunch({
|
||||
attemptId: attempt.attemptId,
|
||||
worktreeId,
|
||||
executionHostId,
|
||||
expectedOperationId: attempt.operationId
|
||||
})
|
||||
// The bulk is worktree-scoped and clears only interactive siblings (the
|
||||
// structural guarantee keeps background-owned rows out of the count), so it
|
||||
// rides after the single attempt forget.
|
||||
if (forgetSiblings) {
|
||||
await forgetUnknownAgentLaunchSiblings({ worktreeId })
|
||||
await forgetUnknownAgentLaunchSiblings({ worktreeId, executionHostId })
|
||||
}
|
||||
} finally {
|
||||
setBusy(attempt.attemptId, false)
|
||||
@@ -179,17 +194,20 @@ export function WorktreeCardBackgroundLaunchFailures({
|
||||
if (id === 'recover-capacity') {
|
||||
// Query the host that rejected the launch: this worktree's runtime owner,
|
||||
// not the local host (whose summary knows nothing of a remote's capacity).
|
||||
openModal('agent-launch-capacity-recovery', {
|
||||
target: getActiveRuntimeTarget(
|
||||
getSettingsForWorktreeRuntimeOwner(useAppStore.getState(), worktreeId)
|
||||
)
|
||||
})
|
||||
const state = useAppStore.getState()
|
||||
const route = resolveWorktreeOperationRouteForHost(state, worktreeId, executionHostId)
|
||||
if (route) {
|
||||
openModal('agent-launch-capacity-recovery', {
|
||||
target: getActiveRuntimeTarget(settingsForWorktreeOperationRoute(state.settings, route))
|
||||
})
|
||||
}
|
||||
}
|
||||
// open-terminal routes to an affordance not owned by this wave; the no-op
|
||||
// keeps the card honest rather than firing a wrong action.
|
||||
},
|
||||
[
|
||||
worktreeId,
|
||||
executionHostId,
|
||||
confirm,
|
||||
setBusy,
|
||||
retryBackgroundAgentLaunch,
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft'
|
||||
import { showAutomationPromptNotSentToast } from '@/lib/agent-background-session-timeout-toast'
|
||||
import type { PtyConnectResult } from '../pty-transport'
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
import type { ColdRestoreAgentResumeStartup } from './fresh-spawn-types'
|
||||
|
||||
export function applyFreshSpawnAgentLaunchResult(
|
||||
session: ConnectPanePtySession,
|
||||
result: PtyConnectResult,
|
||||
coldRestore: ColdRestoreAgentResumeStartup | null
|
||||
): void {
|
||||
const receipt = result.agentLaunch?.status === 'launched' ? result.agentLaunch.receipt : null
|
||||
if (receipt) {
|
||||
session.launchToken = receipt.launchToken
|
||||
useAppStore.getState().backfillTabLaunchAgent(session.deps.tabId, receipt.requestedAgent)
|
||||
if (receipt.notices.length > 0) {
|
||||
useAppStore.getState().attachLaunchNotices({
|
||||
worktreeId: session.deps.worktreeId,
|
||||
tabId: session.deps.tabId,
|
||||
launchToken: receipt.launchToken,
|
||||
notices: receipt.notices
|
||||
})
|
||||
}
|
||||
}
|
||||
if (result.launchNotices) {
|
||||
useAppStore.getState().attachLaunchNotices({
|
||||
worktreeId: session.deps.worktreeId,
|
||||
tabId: session.deps.tabId,
|
||||
launchToken: result.launchNotices.launchToken,
|
||||
notices: result.launchNotices.notices
|
||||
})
|
||||
}
|
||||
session.registerEffectiveLaunchConfig(result.launchConfig, {
|
||||
...(coldRestore?.launchToken ? { launchToken: coldRestore.launchToken } : {}),
|
||||
...(receipt ? { launchToken: receipt.launchToken } : {}),
|
||||
...(coldRestore ? { launchAgent: coldRestore.agent } : {}),
|
||||
...(receipt ? { launchAgent: receipt.baseAgent } : {})
|
||||
})
|
||||
const followupPrompt = result.followupPrompt ?? null
|
||||
const prompt = followupPrompt ?? result.draftPrompt ?? null
|
||||
const agent = receipt?.baseAgent ?? session.paneStartup?.launchAgent
|
||||
if (session.startupDraftPromptNeedsPaste || !agent || !prompt) {
|
||||
return
|
||||
}
|
||||
void pasteDraftWhenAgentReady({
|
||||
tabId: session.deps.tabId,
|
||||
content: prompt,
|
||||
agent,
|
||||
submit: followupPrompt !== null,
|
||||
forcePaste: true,
|
||||
onTimeout: () => showAutomationPromptNotSentToast(agent)
|
||||
}).catch(() => {})
|
||||
}
|
||||
@@ -2,14 +2,13 @@ import { useAppStore } from '@/store'
|
||||
import { hasPtySerializer } from '../pty-buffer-serializer'
|
||||
import { writeTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler'
|
||||
import { agentLaunchOutcomeErrorMessage } from '@/lib/agent-launch-failure-copy'
|
||||
import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft'
|
||||
import { showAutomationPromptNotSentToast } from '@/lib/agent-background-session-timeout-toast'
|
||||
|
||||
import { STARTUP_CWD_FALLBACK_NOTICE } from './startup-cwd-fallback-notice'
|
||||
import { pendingSpawnByPaneKey, pendingSpawnGenerationByPaneKey } from './pty-connect-limits'
|
||||
import { shouldWritePtyOutputForeground } from './foreground-output-scan'
|
||||
import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
|
||||
import { toProcessExitStartup } from './process-exit-startup'
|
||||
import { applyFreshSpawnAgentLaunchResult } from './fresh-spawn-agent-launch-result'
|
||||
import type {
|
||||
PendingStartupCommand,
|
||||
FreshSpawnOptions,
|
||||
@@ -118,13 +117,10 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
|
||||
: {}),
|
||||
...(coldRestoreOverride?.legacyResumeRecordedConnectionId !== undefined
|
||||
? {
|
||||
legacyResumeRecordedConnectionId:
|
||||
coldRestoreOverride.legacyResumeRecordedConnectionId
|
||||
legacyResumeRecordedConnectionId: coldRestoreOverride.legacyResumeRecordedConnectionId
|
||||
}
|
||||
: {}),
|
||||
...(coldRestoreOverride?.launchToken
|
||||
? { launchToken: coldRestoreOverride.launchToken }
|
||||
: {}),
|
||||
...(coldRestoreOverride?.launchToken ? { launchToken: coldRestoreOverride.launchToken } : {}),
|
||||
...(coldRestoreOverride ? { launchAgent: coldRestoreOverride.agent } : {}),
|
||||
...(session.shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}),
|
||||
shouldContinue: () => {
|
||||
@@ -245,56 +241,7 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
|
||||
return accepted ? resolvedPtyId : null
|
||||
}
|
||||
if (spawnedPtyId && typeof spawnedPtyId === 'object' && 'id' in spawnedPtyId) {
|
||||
const launchedReceipt =
|
||||
spawnedPtyId.agentLaunch?.status === 'launched'
|
||||
? spawnedPtyId.agentLaunch.receipt
|
||||
: null
|
||||
if (launchedReceipt) {
|
||||
session.launchToken = launchedReceipt.launchToken
|
||||
useAppStore
|
||||
.getState()
|
||||
.backfillTabLaunchAgent(session.deps.tabId, launchedReceipt.requestedAgent)
|
||||
if (launchedReceipt.notices.length > 0) {
|
||||
useAppStore.getState().attachLaunchNotices({
|
||||
worktreeId: session.deps.worktreeId,
|
||||
tabId: session.deps.tabId,
|
||||
launchToken: launchedReceipt.launchToken,
|
||||
notices: launchedReceipt.notices
|
||||
})
|
||||
}
|
||||
}
|
||||
if (spawnedPtyId.launchNotices) {
|
||||
useAppStore.getState().attachLaunchNotices({
|
||||
worktreeId: session.deps.worktreeId,
|
||||
tabId: session.deps.tabId,
|
||||
launchToken: spawnedPtyId.launchNotices.launchToken,
|
||||
notices: spawnedPtyId.launchNotices.notices
|
||||
})
|
||||
}
|
||||
session.registerEffectiveLaunchConfig(spawnedPtyId.launchConfig, {
|
||||
...(coldRestoreOverride?.launchToken
|
||||
? { launchToken: coldRestoreOverride.launchToken }
|
||||
: {}),
|
||||
...(launchedReceipt ? { launchToken: launchedReceipt.launchToken } : {}),
|
||||
...(coldRestoreOverride ? { launchAgent: coldRestoreOverride.agent } : {}),
|
||||
...(launchedReceipt ? { launchAgent: launchedReceipt.baseAgent } : {})
|
||||
})
|
||||
const hostFollowupPrompt = spawnedPtyId.followupPrompt ?? null
|
||||
const hostDraftPrompt = spawnedPtyId.draftPrompt ?? null
|
||||
const hostDeliveredAgent = launchedReceipt?.baseAgent ?? session.paneStartup?.launchAgent
|
||||
if (!session.startupDraftPromptNeedsPaste && hostDeliveredAgent) {
|
||||
const hostDeliveredPrompt = hostFollowupPrompt ?? hostDraftPrompt
|
||||
if (hostDeliveredPrompt) {
|
||||
void pasteDraftWhenAgentReady({
|
||||
tabId: session.deps.tabId,
|
||||
content: hostDeliveredPrompt,
|
||||
agent: hostDeliveredAgent,
|
||||
submit: hostFollowupPrompt !== null,
|
||||
forcePaste: true,
|
||||
onTimeout: () => showAutomationPromptNotSentToast(hostDeliveredAgent)
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
applyFreshSpawnAgentLaunchResult(session, spawnedPtyId, coldRestoreOverride)
|
||||
}
|
||||
if (resolvedPtyId) {
|
||||
if (
|
||||
|
||||
@@ -237,19 +237,23 @@ export type WorktreeSlice = {
|
||||
retryBackgroundAgentLaunch: (args: {
|
||||
attemptId: string
|
||||
worktreeId: string
|
||||
executionHostId?: ExecutionHostId
|
||||
expectedFailureId: string
|
||||
action: RetryAgentLaunchAction
|
||||
}) => Promise<WorktreeRetryAgentLaunchResult>
|
||||
forgetBackgroundAgentLaunch: (args: {
|
||||
attemptId: string
|
||||
worktreeId: string
|
||||
executionHostId?: ExecutionHostId
|
||||
expectedOperationId: string
|
||||
}) => Promise<ForgetUnknownAgentLaunchResult>
|
||||
unknownAgentLaunchSiblingPreflight: (args: {
|
||||
worktreeId: string
|
||||
executionHostId?: ExecutionHostId
|
||||
}) => Promise<{ count: number; hostName: string }>
|
||||
forgetUnknownAgentLaunchSiblings: (args: {
|
||||
worktreeId: string
|
||||
executionHostId?: ExecutionHostId
|
||||
}) => Promise<{ forgottenCount: number }>
|
||||
fetchPendingAgentLaunchSummary: (
|
||||
target?: RuntimeClientTarget
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createWorktreeAgentLaunchActions } from './worktree-agent-launch-actions'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
callRuntimeRpc: vi.fn(),
|
||||
getActiveRuntimeTarget: vi.fn((settings: { activeRuntimeEnvironmentId?: string | null }) =>
|
||||
settings.activeRuntimeEnvironmentId
|
||||
? { kind: 'environment' as const, environmentId: settings.activeRuntimeEnvironmentId }
|
||||
: { kind: 'local' as const }
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('../../../runtime/runtime-rpc-client', () => ({
|
||||
callRuntimeRpc: mocks.callRuntimeRpc,
|
||||
getActiveRuntimeTarget: mocks.getActiveRuntimeTarget
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/browser-uuid', () => ({
|
||||
createBrowserUuid: () => 'mutation-1'
|
||||
}))
|
||||
|
||||
describe('background agent launch recovery host routing', () => {
|
||||
it('routes a same-id card action to its explicit runtime host', async () => {
|
||||
mocks.callRuntimeRpc.mockResolvedValue({ status: 'launched', receipt: {} })
|
||||
const get = () => ({
|
||||
settings: { activeRuntimeEnvironmentId: 'focused-host' },
|
||||
runtimeEnvironments: [],
|
||||
worktreesByRepo: {},
|
||||
detectedWorktreesByRepo: {}
|
||||
})
|
||||
const actions = createWorktreeAgentLaunchActions(vi.fn() as never, get as never)
|
||||
|
||||
await actions.retryBackgroundAgentLaunch({
|
||||
attemptId: 'attempt-1',
|
||||
worktreeId: 'repo::/same/path',
|
||||
executionHostId: 'runtime:card-host',
|
||||
expectedFailureId: 'failure-1',
|
||||
action: { kind: 'retry-same' }
|
||||
})
|
||||
|
||||
expect(mocks.callRuntimeRpc).toHaveBeenCalledExactlyOnceWith(
|
||||
{ kind: 'environment', environmentId: 'card-host' },
|
||||
'worktree.retryBackgroundAgentLaunch',
|
||||
{
|
||||
attemptId: 'attempt-1',
|
||||
expectedFailureId: 'failure-1',
|
||||
clientMutationId: 'mutation-1',
|
||||
action: { kind: 'retry-same' }
|
||||
},
|
||||
{ timeoutMs: 10 * 60_000 }
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -14,6 +14,26 @@ import { toRuntimeWorktreeSelector } from '../../../runtime/runtime-worktree-sel
|
||||
import { getRepoIdFromWorktreeId } from '../worktree-helpers'
|
||||
import { settingsForRepoOwner } from './listing/worktree-owner-settings'
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
import type { ExecutionHostId } from '../../../../../shared/execution-host'
|
||||
import {
|
||||
resolveWorktreeOperationRouteForHost,
|
||||
settingsForWorktreeOperationRoute
|
||||
} from '@/lib/worktree-operation-route'
|
||||
|
||||
function targetForBackgroundRecovery(
|
||||
state: ReturnType<WorktreeSliceGet>,
|
||||
worktreeId: string,
|
||||
executionHostId?: ExecutionHostId
|
||||
): RuntimeClientTarget {
|
||||
if (executionHostId) {
|
||||
const route = resolveWorktreeOperationRouteForHost(state, worktreeId, executionHostId)
|
||||
if (!route) {
|
||||
throw new Error('The workspace host is no longer available.')
|
||||
}
|
||||
return getActiveRuntimeTarget(settingsForWorktreeOperationRoute(state.settings, route))
|
||||
}
|
||||
return getActiveRuntimeTarget(settingsForRepoOwner(state, getRepoIdFromWorktreeId(worktreeId)))
|
||||
}
|
||||
|
||||
export function createWorktreeAgentLaunchActions(
|
||||
_set: WorktreeSliceSet,
|
||||
@@ -77,10 +97,15 @@ export function createWorktreeAgentLaunchActions(
|
||||
)
|
||||
},
|
||||
|
||||
retryBackgroundAgentLaunch: async ({ attemptId, worktreeId, expectedFailureId, action }) => {
|
||||
const repoId = getRepoIdFromWorktreeId(worktreeId)
|
||||
retryBackgroundAgentLaunch: async ({
|
||||
attemptId,
|
||||
worktreeId,
|
||||
executionHostId,
|
||||
expectedFailureId,
|
||||
action
|
||||
}) => {
|
||||
const clientMutationId = createBrowserUuid()
|
||||
const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), repoId))
|
||||
const target = targetForBackgroundRecovery(get(), worktreeId, executionHostId)
|
||||
if (target.kind === 'local') {
|
||||
return window.api.worktrees.retryBackgroundAgentLaunch({
|
||||
attemptId,
|
||||
@@ -97,10 +122,14 @@ export function createWorktreeAgentLaunchActions(
|
||||
)
|
||||
},
|
||||
|
||||
forgetBackgroundAgentLaunch: async ({ attemptId, worktreeId, expectedOperationId }) => {
|
||||
const repoId = getRepoIdFromWorktreeId(worktreeId)
|
||||
forgetBackgroundAgentLaunch: async ({
|
||||
attemptId,
|
||||
worktreeId,
|
||||
executionHostId,
|
||||
expectedOperationId
|
||||
}) => {
|
||||
const clientMutationId = createBrowserUuid()
|
||||
const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), repoId))
|
||||
const target = targetForBackgroundRecovery(get(), worktreeId, executionHostId)
|
||||
if (target.kind === 'local') {
|
||||
return window.api.worktrees.forgetBackgroundAgentLaunch({
|
||||
attemptId,
|
||||
@@ -116,9 +145,8 @@ export function createWorktreeAgentLaunchActions(
|
||||
)
|
||||
},
|
||||
|
||||
unknownAgentLaunchSiblingPreflight: async ({ worktreeId }) => {
|
||||
const repoId = getRepoIdFromWorktreeId(worktreeId)
|
||||
const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), repoId))
|
||||
unknownAgentLaunchSiblingPreflight: async ({ worktreeId, executionHostId }) => {
|
||||
const target = targetForBackgroundRecovery(get(), worktreeId, executionHostId)
|
||||
if (target.kind === 'local') {
|
||||
const { count } = await window.api.worktrees.unknownAgentLaunchSiblingCount({ worktreeId })
|
||||
return { count, hostName: '' }
|
||||
@@ -129,13 +157,14 @@ export function createWorktreeAgentLaunchActions(
|
||||
{ worktree: toRuntimeWorktreeSelector(worktreeId) },
|
||||
{ timeoutMs: 30_000 }
|
||||
)
|
||||
const environment = get().runtimeEnvironments.find((entry) => entry.id === target.environmentId)
|
||||
const environment = get().runtimeEnvironments.find(
|
||||
(entry) => entry.id === target.environmentId
|
||||
)
|
||||
return { count, hostName: environment?.name || target.environmentId }
|
||||
},
|
||||
|
||||
forgetUnknownAgentLaunchSiblings: async ({ worktreeId }) => {
|
||||
const repoId = getRepoIdFromWorktreeId(worktreeId)
|
||||
const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), repoId))
|
||||
forgetUnknownAgentLaunchSiblings: async ({ worktreeId, executionHostId }) => {
|
||||
const target = targetForBackgroundRecovery(get(), worktreeId, executionHostId)
|
||||
if (target.kind === 'local') {
|
||||
return window.api.worktrees.forgetUnknownAgentLaunchSiblings({ worktreeId })
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
RESUMABLE_TUI_AGENTS,
|
||||
agentProviderSessionsEqual,
|
||||
extractAgentProviderSession,
|
||||
getAgentResumeArgv,
|
||||
@@ -8,6 +9,10 @@ import {
|
||||
} from './agent-session-resume'
|
||||
|
||||
describe('agent session resume metadata', () => {
|
||||
it('lists every resumable agent exactly once', () => {
|
||||
expect(new Set(RESUMABLE_TUI_AGENTS).size).toBe(RESUMABLE_TUI_AGENTS.length)
|
||||
})
|
||||
|
||||
it('treats devin as a resumable TUI agent', () => {
|
||||
expect(isResumableTuiAgent('devin')).toBe(true)
|
||||
})
|
||||
|
||||
@@ -16,8 +16,7 @@ export const RESUMABLE_TUI_AGENTS = [
|
||||
'devin',
|
||||
'omp',
|
||||
'prime-agent',
|
||||
'copilot',
|
||||
'kimi'
|
||||
'copilot'
|
||||
] as const satisfies readonly TuiAgent[]
|
||||
|
||||
export type ResumableTuiAgent = (typeof RESUMABLE_TUI_AGENTS)[number]
|
||||
@@ -332,8 +331,5 @@ export function getAgentResumeArgv(
|
||||
// resume commands, so local and remote resumes agree on one spelling.
|
||||
case 'copilot':
|
||||
return providerSession.key === 'session_id' ? ['copilot', `--resume=${id}`] : null
|
||||
// Why: Kimi resumes by id with --session; sessions are work-dir-scoped (enforced by callers).
|
||||
case 'kimi':
|
||||
return providerSession.key === 'session_id' ? ['kimi', '--session', id] : null
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user