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:
Jinjing
2026-09-01 03:54:23 -07:00
parent b617aedf51
commit fda448740e
54 changed files with 1492 additions and 304 deletions
+20 -15
View File
@@ -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)
+51 -47
View File
@@ -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({
+13 -2
View File
@@ -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')
})
})
+29
View File
@@ -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 -2
View File
@@ -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',
+9 -1
View File
@@ -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' })
+2 -3
View File
@@ -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,
@@ -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
@@ -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 })
}
+5
View File
@@ -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)
})
+1 -5
View File
@@ -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
}
}