mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 08:01:56 +00:00
Refactor quick-workspace agent picker to a shared selectable-options bui
Extracts the built-in/custom agent merge logic from NewWorkspaceComposerCard into buildWorkspaceAgentOptions, so the composer card and the selection resolver share one source of truth for which agents are choosable. Adds e2e coverage for custom-agent launch surfaces (composer, annotations, git actions, settings authoring, SSH) to close gaps left by the earlier seededCustomAgents harness truncation bug.
This commit is contained in:
@@ -21,7 +21,6 @@ const apiMocks = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
localSnapshot: null as unknown,
|
||||
disabledTuiAgents: [] as string[],
|
||||
capturedAgents: [] as { id: string; label: string; baseAgent?: string }[]
|
||||
}))
|
||||
@@ -49,15 +48,6 @@ vi.mock('@/store', () => ({
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/useLocalAgentCatalog', () => ({
|
||||
useLocalAgentCatalog: () => ({
|
||||
snapshot: mocks.localSnapshot,
|
||||
loading: false,
|
||||
refetch: vi.fn(),
|
||||
applySnapshot: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/components/contextual-tours/use-contextual-tour', () => ({
|
||||
useContextualTour: vi.fn()
|
||||
}))
|
||||
@@ -227,6 +217,7 @@ function renderCard(
|
||||
<NewWorkspaceComposerCard
|
||||
quickAgent={null}
|
||||
onQuickAgentChange={() => {}}
|
||||
quickAgentOptions={[]}
|
||||
eligibleRepos={[]}
|
||||
repoId="repo-a"
|
||||
projectOptions={projectOptions}
|
||||
@@ -251,7 +242,6 @@ function renderCard(
|
||||
parentWorktreeId={null}
|
||||
onParentWorktreeIdChange={() => {}}
|
||||
forkPushWarning={null}
|
||||
detectedAgentIds={null}
|
||||
onOpenAgentSettings={() => {}}
|
||||
advancedOpen={false}
|
||||
onToggleAdvanced={() => {}}
|
||||
@@ -338,25 +328,22 @@ describe('NewWorkspaceComposerCard custom agents', () => {
|
||||
act(() => current?.root.unmount())
|
||||
current?.container.remove()
|
||||
current = null
|
||||
mocks.localSnapshot = null
|
||||
mocks.disabledTuiAgents = []
|
||||
mocks.capturedAgents = []
|
||||
})
|
||||
|
||||
function readyCustom(id: string, label: string, commandOverride: string) {
|
||||
return {
|
||||
status: 'ready',
|
||||
definition: { id, baseAgent: 'codex', label, args: '', syncEnv: false, commandOverride },
|
||||
envSummary: { entryCount: 0, bytes: 0 },
|
||||
availabilityReason: 'configured-executable'
|
||||
}
|
||||
}
|
||||
|
||||
it('offers a ready custom agent to the picker with its base and human label', () => {
|
||||
mocks.localSnapshot = {
|
||||
customAgents: [readyCustom('custom-agent:codex:aa', 'My Codex', '/opt/agent')]
|
||||
}
|
||||
current = renderCard()
|
||||
it('passes a custom option to the picker without changing its identity', () => {
|
||||
current = renderCard({
|
||||
quickAgentOptions: [
|
||||
{
|
||||
id: 'custom-agent:codex:aa',
|
||||
label: 'My Codex',
|
||||
cmd: '/opt/agent',
|
||||
homepageUrl: 'https://example.com',
|
||||
baseAgent: 'codex'
|
||||
}
|
||||
] as never
|
||||
})
|
||||
const custom = mocks.capturedAgents.find((entry) => entry.id === 'custom-agent:codex:aa')
|
||||
expect(custom).toMatchObject({ label: 'My Codex', baseAgent: 'codex' })
|
||||
// The displayed label is the human label, never the raw custom-agent id.
|
||||
@@ -364,15 +351,6 @@ describe('NewWorkspaceComposerCard custom agents', () => {
|
||||
expect(entry.label.startsWith('custom-agent:')).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('hides a disabled custom agent from the picker', () => {
|
||||
mocks.localSnapshot = {
|
||||
customAgents: [readyCustom('custom-agent:codex:bb', 'Hidden Codex', '/opt/hidden')]
|
||||
}
|
||||
mocks.disabledTuiAgents = ['custom-agent:codex:bb']
|
||||
current = renderCard()
|
||||
expect(mocks.capturedAgents.some((entry) => entry.id === 'custom-agent:codex:bb')).toBe(false)
|
||||
})
|
||||
})
|
||||
>>>>>>> ebaa81ab2f (Rebase custom-agents onto main (2/4): renderer)
|
||||
|
||||
|
||||
@@ -3,10 +3,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { useAppStore } from '@/store'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getAgentCatalog } from '@/lib/agent-catalog'
|
||||
import { mergeCustomAgentCatalogEntries } from '@/components/agent/custom-agent-catalog-entries'
|
||||
import { setDefaultTuiAgent } from '@/lib/agent-catalog-authoring'
|
||||
import { useLocalAgentCatalog } from '@/hooks/useLocalAgentCatalog'
|
||||
import { getScreenSubmitModifierLabel } from '@/lib/screen-submit-shortcut'
|
||||
import { resolveProjectCloneUrlPrefill } from '@/lib/project-clone-url-prefill'
|
||||
import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour'
|
||||
@@ -19,11 +16,7 @@ import { unwrapRuntimeRpcResult } from '@/runtime/runtime-rpc-client'
|
||||
import { withUiConnectTimeout } from '@/ssh/ssh-connect-ui-timeout'
|
||||
import { isSshConnectInFlight, trackSshConnect } from '@/ssh/ssh-connect-in-flight'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
DEFAULT_DISABLED_TUI_AGENTS,
|
||||
filterEnabledTuiAgents,
|
||||
toLegacyAutoPreference
|
||||
} from '../../../shared/tui-agent-selection'
|
||||
import { toLegacyAutoPreference } from '../../../shared/tui-agent-selection'
|
||||
import type { RuntimeStatus } from '../../../shared/runtime-types'
|
||||
import type { TuiAgent } from '../../../shared/tui-agent'
|
||||
import { NewWorkspaceComposerAdvancedSection } from './new-workspace/NewWorkspaceComposerAdvancedSection'
|
||||
@@ -76,10 +69,6 @@ export default function NewWorkspaceComposerCard(
|
||||
const defaultTuiAgent = toLegacyAutoPreference(
|
||||
useAppStore((state) => state.settings?.defaultTuiAgent)
|
||||
)
|
||||
const disabledTuiAgents = useAppStore(
|
||||
(state) => state.settings?.disabledTuiAgents ?? DEFAULT_DISABLED_TUI_AGENTS
|
||||
)
|
||||
const { snapshot: localAgentCatalog } = useLocalAgentCatalog()
|
||||
const projects = useAppStore((state) => state.projects)
|
||||
const repos = useAppStore((state) => state.repos)
|
||||
const nameInputFocusFrameRef = React.useRef<number | null>(null)
|
||||
@@ -135,26 +124,6 @@ export default function NewWorkspaceComposerCard(
|
||||
const setupSkipButtonLabel = setupConfig?.kind === 'setup' ? 'Skip for now' : 'Skip commands'
|
||||
const showSetupAgentStartupPolicy =
|
||||
setupControlsEnabled && setupConfig !== null && setupConfig.kind !== 'default-tabs'
|
||||
const agentCatalog = getAgentCatalog()
|
||||
const enabledAgentIds = new Set(
|
||||
filterEnabledTuiAgents(
|
||||
agentCatalog.map((candidate) => candidate.id),
|
||||
disabledTuiAgents
|
||||
)
|
||||
)
|
||||
const visibleBuiltInQuickAgents = agentCatalog.filter((agent) => {
|
||||
return (
|
||||
enabledAgentIds.has(agent.id) &&
|
||||
(props.detectedAgentIds === null || props.detectedAgentIds.has(agent.id))
|
||||
)
|
||||
})
|
||||
const visibleQuickAgents = mergeCustomAgentCatalogEntries(
|
||||
visibleBuiltInQuickAgents,
|
||||
localAgentCatalog,
|
||||
disabledTuiAgents,
|
||||
props.detectedAgentIds
|
||||
)
|
||||
|
||||
const cancelNameInputFocusFrame = React.useCallback((): void => {
|
||||
if (nameInputFocusFrameRef.current !== null) {
|
||||
cancelAnimationFrame(nameInputFocusFrameRef.current)
|
||||
@@ -306,7 +275,7 @@ export default function NewWorkspaceComposerCard(
|
||||
<NewWorkspaceComposerNameSection {...props} onNamePlainEnter={handleNamePlainEnter} />
|
||||
<NewWorkspaceComposerAgentSection
|
||||
{...props}
|
||||
visibleQuickAgents={visibleQuickAgents}
|
||||
visibleQuickAgents={props.quickAgentOptions}
|
||||
defaultTuiAgent={defaultTuiAgent}
|
||||
handleSetDefaultAgent={handleSetDefaultAgent}
|
||||
/>
|
||||
|
||||
@@ -12,10 +12,12 @@ import NewWorkspaceComposerCard from '@/components/NewWorkspaceComposerCard'
|
||||
import AgentSettingsDialog from '@/components/agent/AgentSettingsDialog'
|
||||
import type { AddRepoDialogHostedController } from '@/components/sidebar/use-add-repo-hosted-controller'
|
||||
import { useComposerState } from '@/hooks/useComposerState'
|
||||
import { useLocalAgentCatalog } from '@/hooks/useLocalAgentCatalog'
|
||||
import {
|
||||
pickQuickWorkspaceAgent,
|
||||
resolveQuickWorkspaceAgentSelection
|
||||
} from '@/lib/quick-workspace-agent-selection'
|
||||
import { buildWorkspaceAgentOptions } from '@/lib/workspace-agent-options'
|
||||
import type { LinkedWorkItemSummary } from '@/lib/new-workspace'
|
||||
import { shouldAllowComposerEnterSubmitTarget } from '@/lib/new-workspace-enter-guard'
|
||||
import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
|
||||
@@ -23,7 +25,10 @@ import type { GitHubWorkItem } from '../../../shared/github/work-item-types'
|
||||
import type { TuiAgent } from '../../../shared/tui-agent'
|
||||
import type { WorkspaceSource as WorkspaceCreateTelemetrySource } from '../../../shared/workspace-source'
|
||||
import type { WorkspaceStatus } from '../../../shared/worktree/types'
|
||||
import { toLegacyAutoPreference } from '../../../shared/tui-agent-selection'
|
||||
import {
|
||||
DEFAULT_DISABLED_TUI_AGENTS,
|
||||
toLegacyAutoPreference
|
||||
} from '../../../shared/tui-agent-selection'
|
||||
import type { TaskSourceContext } from '../../../shared/task-source-context'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { getWorkspaceComposerInitialFocusTarget } from '@/lib/workspace-composer-initial-focus'
|
||||
@@ -146,6 +151,20 @@ function QuickTabBody({
|
||||
enableIssueAutomation: modalData.enableIssueAutomation === true,
|
||||
createGateMode: 'quick'
|
||||
})
|
||||
const { snapshot: localAgentCatalog } = useLocalAgentCatalog()
|
||||
const quickAgentOptions = useMemo(
|
||||
() =>
|
||||
buildWorkspaceAgentOptions({
|
||||
detectedAgentIds: cardProps.detectedAgentIds,
|
||||
disabledTuiAgents: settings?.disabledTuiAgents ?? DEFAULT_DISABLED_TUI_AGENTS,
|
||||
localAgentCatalog
|
||||
}),
|
||||
[cardProps.detectedAgentIds, localAgentCatalog, settings?.disabledTuiAgents]
|
||||
)
|
||||
const selectableQuickAgentIds = useMemo(
|
||||
() => new Set(quickAgentOptions.map((agent) => agent.id)),
|
||||
[quickAgentOptions]
|
||||
)
|
||||
// Why: the composer's built-in `onOpenAgentSettings` handler navigates to
|
||||
// the settings page and closes the modal. For the quick-create flow we want
|
||||
// a less disruptive affordance — a nested dialog layered over the composer
|
||||
@@ -153,11 +172,10 @@ function QuickTabBody({
|
||||
// name/repo selection.
|
||||
const [agentSettingsOpen, setAgentSettingsOpen] = useState(false)
|
||||
// Why: once the user picks an agent, their choice wins and must not be
|
||||
// overwritten when the derived "preferred" value changes (e.g. detection
|
||||
// finishes and adds more installed agents to the set). Track that with an
|
||||
// override rather than an effect that mirrors a prop into state — deriving
|
||||
// during render keeps the selection in sync with the detected set without
|
||||
// triggering an extra commit.
|
||||
// overwritten when the derived "preferred" value changes (e.g. catalog
|
||||
// loading adds more selectable agents). Track that with an override rather
|
||||
// than an effect that mirrors a prop into state — deriving during render
|
||||
// keeps the selection in sync with the picker options without an extra commit.
|
||||
const [quickAgentOverride, setQuickAgentOverride] = useState<TuiAgent | null | undefined>(
|
||||
undefined
|
||||
)
|
||||
@@ -166,13 +184,12 @@ function QuickTabBody({
|
||||
const pref = toLegacyAutoPreference(settings?.defaultTuiAgent)
|
||||
// Why: detection can still be pending when quick-create submits; keep the
|
||||
// prior catalog fallback while filtering disabled agents out of that choice.
|
||||
return pickQuickWorkspaceAgent(pref, cardProps.detectedAgentIds, settings?.disabledTuiAgents)
|
||||
}, [cardProps.detectedAgentIds, settings?.defaultTuiAgent, settings?.disabledTuiAgents])
|
||||
return pickQuickWorkspaceAgent(pref, selectableQuickAgentIds, settings?.disabledTuiAgents)
|
||||
}, [selectableQuickAgentIds, settings?.defaultTuiAgent, settings?.disabledTuiAgents])
|
||||
const resolvedQuickAgentSelection = resolveQuickWorkspaceAgentSelection({
|
||||
quickAgentOverride,
|
||||
preferredQuickAgent,
|
||||
detectedAgentIds: cardProps.detectedAgentIds,
|
||||
disabledTuiAgents: settings?.disabledTuiAgents
|
||||
selectableAgentIds: selectableQuickAgentIds
|
||||
})
|
||||
if (resolvedQuickAgentSelection.quickAgentOverride !== quickAgentOverride) {
|
||||
// Why: detection/settings changes can invalidate a user-picked agent; repair
|
||||
@@ -303,6 +320,7 @@ function QuickTabBody({
|
||||
nameInputRef={nameInputRef}
|
||||
quickAgent={quickAgent}
|
||||
onQuickAgentChange={handleQuickAgentChange}
|
||||
quickAgentOptions={quickAgentOptions}
|
||||
{...cardProps}
|
||||
primaryActionLabel={primaryActionLabel}
|
||||
onOpenAgentSettings={() => setAgentSettingsOpen(true)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type RepoCombobox from '@/components/repo/RepoCombobox'
|
||||
import type { AgentCatalogEntry } from '@/lib/agent-catalog'
|
||||
import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options'
|
||||
import type {
|
||||
NeedsSetupProjectHostOption,
|
||||
@@ -35,6 +36,7 @@ export type NewWorkspaceComposerCardProps = {
|
||||
nameInputRef?: React.RefObject<HTMLInputElement | null>
|
||||
quickAgent: TuiAgent | null
|
||||
onQuickAgentChange: (agent: TuiAgent | null) => void
|
||||
quickAgentOptions: AgentCatalogEntry[]
|
||||
eligibleRepos: readonly RepoOption[]
|
||||
repoId: string
|
||||
projectOptions?: NewWorkspaceProjectOption[]
|
||||
@@ -85,7 +87,8 @@ export type NewWorkspaceComposerCardProps = {
|
||||
smartNameGitHubSourceContext?: TaskSourceContext | null
|
||||
smartNameJiraSourceContext?: TaskSourceContext | null
|
||||
forkPushWarning: string | null
|
||||
detectedAgentIds: Set<TuiAgent> | null
|
||||
/** Legacy fixture compatibility; selection is resolved by the modal now. */
|
||||
detectedAgentIds?: Set<TuiAgent> | null
|
||||
onOpenAgentSettings: () => void
|
||||
advancedOpen: boolean
|
||||
onToggleAdvanced: () => void
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { TUI_AGENT_AUTO_PICK_ORDER } from '../../../shared/tui-agent-selection'
|
||||
import type { CustomTuiAgentId } from '../../../shared/types'
|
||||
import { AGENT_CATALOG } from './agent-catalog'
|
||||
import {
|
||||
pickQuickWorkspaceAgent,
|
||||
@@ -30,16 +31,23 @@ describe('pickQuickWorkspaceAgent', () => {
|
||||
expect(pickQuickWorkspaceAgent(null, ['codex'], ['claude'])).toBe('codex')
|
||||
expect(pickQuickWorkspaceAgent('codex', ['claude', 'codex'], ['codex'])).toBe('claude')
|
||||
})
|
||||
|
||||
it('uses a selectable custom agent when it is the saved preference', () => {
|
||||
const customCodex =
|
||||
'custom-agent:codex:11111111-1111-4111-8111-111111111111' as CustomTuiAgentId
|
||||
expect(pickQuickWorkspaceAgent(customCodex, ['claude', customCodex], [])).toBe(customCodex)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveQuickWorkspaceAgentSelection', () => {
|
||||
const customCodex = 'custom-agent:codex:11111111-1111-4111-8111-111111111111' as CustomTuiAgentId
|
||||
|
||||
it('uses the preferred quick agent until the user picks an override', () => {
|
||||
expect(
|
||||
resolveQuickWorkspaceAgentSelection({
|
||||
quickAgentOverride: undefined,
|
||||
preferredQuickAgent: 'claude',
|
||||
detectedAgentIds: ['claude', 'codex'],
|
||||
disabledTuiAgents: []
|
||||
selectableAgentIds: ['claude', 'codex']
|
||||
})
|
||||
).toEqual({ quickAgent: 'claude', quickAgentOverride: undefined })
|
||||
})
|
||||
@@ -49,8 +57,7 @@ describe('resolveQuickWorkspaceAgentSelection', () => {
|
||||
resolveQuickWorkspaceAgentSelection({
|
||||
quickAgentOverride: null,
|
||||
preferredQuickAgent: 'claude',
|
||||
detectedAgentIds: ['claude'],
|
||||
disabledTuiAgents: []
|
||||
selectableAgentIds: ['claude']
|
||||
})
|
||||
).toEqual({ quickAgent: null, quickAgentOverride: null })
|
||||
})
|
||||
@@ -60,19 +67,37 @@ describe('resolveQuickWorkspaceAgentSelection', () => {
|
||||
resolveQuickWorkspaceAgentSelection({
|
||||
quickAgentOverride: 'codex',
|
||||
preferredQuickAgent: 'claude',
|
||||
detectedAgentIds: new Set(['claude', 'codex']),
|
||||
disabledTuiAgents: []
|
||||
selectableAgentIds: new Set(['claude', 'codex'])
|
||||
})
|
||||
).toEqual({ quickAgent: 'codex', quickAgentOverride: 'codex' })
|
||||
})
|
||||
|
||||
it('keeps a custom override present in the picker option set', () => {
|
||||
expect(
|
||||
resolveQuickWorkspaceAgentSelection({
|
||||
quickAgentOverride: customCodex,
|
||||
preferredQuickAgent: 'claude',
|
||||
selectableAgentIds: new Set(['claude', customCodex])
|
||||
})
|
||||
).toEqual({ quickAgent: customCodex, quickAgentOverride: customCodex })
|
||||
})
|
||||
|
||||
it('replaces a custom override removed from the picker option set', () => {
|
||||
expect(
|
||||
resolveQuickWorkspaceAgentSelection({
|
||||
quickAgentOverride: customCodex,
|
||||
preferredQuickAgent: 'claude',
|
||||
selectableAgentIds: new Set(['claude', 'codex'])
|
||||
})
|
||||
).toEqual({ quickAgent: 'claude', quickAgentOverride: 'claude' })
|
||||
})
|
||||
|
||||
it('replaces an unavailable override with the preferred quick agent', () => {
|
||||
expect(
|
||||
resolveQuickWorkspaceAgentSelection({
|
||||
quickAgentOverride: 'codex',
|
||||
preferredQuickAgent: 'claude',
|
||||
detectedAgentIds: ['claude'],
|
||||
disabledTuiAgents: []
|
||||
selectableAgentIds: ['claude']
|
||||
})
|
||||
).toEqual({ quickAgent: 'claude', quickAgentOverride: 'claude' })
|
||||
})
|
||||
|
||||
@@ -1,25 +1,21 @@
|
||||
import type { TuiAgent } from '../../../shared/tui-agent'
|
||||
import {
|
||||
isTuiAgentEnabled,
|
||||
pickTuiAgent,
|
||||
TUI_AGENT_AUTO_PICK_ORDER
|
||||
} from '../../../shared/tui-agent-selection'
|
||||
import type { TuiAgent } from '../../../shared/types'
|
||||
import { pickTuiAgent, TUI_AGENT_AUTO_PICK_ORDER } from '../../../shared/tui-agent-selection'
|
||||
|
||||
export function pickQuickWorkspaceAgent(
|
||||
preferred: TuiAgent | 'blank' | null | undefined,
|
||||
detectedAgentIds: Iterable<TuiAgent> | null,
|
||||
selectableAgentIds: Iterable<TuiAgent> | null,
|
||||
disabledTuiAgents?: Iterable<unknown> | null
|
||||
): TuiAgent | null {
|
||||
const candidates = detectedAgentIds ?? TUI_AGENT_AUTO_PICK_ORDER
|
||||
const candidates = selectableAgentIds ?? TUI_AGENT_AUTO_PICK_ORDER
|
||||
return pickTuiAgent(preferred, candidates, disabledTuiAgents)
|
||||
}
|
||||
|
||||
function hasDetectedAgent(detectedAgentIds: Iterable<TuiAgent>, agent: TuiAgent): boolean {
|
||||
if (detectedAgentIds instanceof Set) {
|
||||
return detectedAgentIds.has(agent)
|
||||
function hasSelectableAgent(selectableAgentIds: Iterable<TuiAgent>, agent: TuiAgent): boolean {
|
||||
if (selectableAgentIds instanceof Set) {
|
||||
return selectableAgentIds.has(agent)
|
||||
}
|
||||
for (const detectedAgentId of detectedAgentIds) {
|
||||
if (detectedAgentId === agent) {
|
||||
for (const selectableAgentId of selectableAgentIds) {
|
||||
if (selectableAgentId === agent) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -28,25 +24,19 @@ function hasDetectedAgent(detectedAgentIds: Iterable<TuiAgent>, agent: TuiAgent)
|
||||
|
||||
function isQuickWorkspaceAgentAvailable(
|
||||
agent: TuiAgent,
|
||||
detectedAgentIds: Iterable<TuiAgent> | null,
|
||||
disabledTuiAgents?: Iterable<unknown> | null
|
||||
selectableAgentIds: Iterable<TuiAgent> | null
|
||||
): boolean {
|
||||
if (!isTuiAgentEnabled(agent, disabledTuiAgents)) {
|
||||
return false
|
||||
}
|
||||
return detectedAgentIds === null || hasDetectedAgent(detectedAgentIds, agent)
|
||||
return selectableAgentIds === null || hasSelectableAgent(selectableAgentIds, agent)
|
||||
}
|
||||
|
||||
export function resolveQuickWorkspaceAgentSelection({
|
||||
quickAgentOverride,
|
||||
preferredQuickAgent,
|
||||
detectedAgentIds,
|
||||
disabledTuiAgents
|
||||
selectableAgentIds
|
||||
}: {
|
||||
quickAgentOverride: TuiAgent | null | undefined
|
||||
preferredQuickAgent: TuiAgent | null
|
||||
detectedAgentIds: Iterable<TuiAgent> | null
|
||||
disabledTuiAgents?: Iterable<unknown> | null
|
||||
selectableAgentIds: Iterable<TuiAgent> | null
|
||||
}): {
|
||||
quickAgent: TuiAgent | null
|
||||
quickAgentOverride: TuiAgent | null | undefined
|
||||
@@ -57,7 +47,7 @@ export function resolveQuickWorkspaceAgentSelection({
|
||||
quickAgentOverride
|
||||
}
|
||||
}
|
||||
if (isQuickWorkspaceAgentAvailable(quickAgentOverride, detectedAgentIds, disabledTuiAgents)) {
|
||||
if (isQuickWorkspaceAgentAvailable(quickAgentOverride, selectableAgentIds)) {
|
||||
return { quickAgent: quickAgentOverride, quickAgentOverride }
|
||||
}
|
||||
return { quickAgent: preferredQuickAgent, quickAgentOverride: preferredQuickAgent }
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type {
|
||||
LocalAgentCatalogSnapshot,
|
||||
LocalCustomTuiAgent
|
||||
} from '../../../shared/agent-catalog-snapshot'
|
||||
import type { CustomTuiAgentId, TuiAgent } from '../../../shared/types'
|
||||
import { buildWorkspaceAgentOptions } from './workspace-agent-options'
|
||||
|
||||
const CUSTOM_CODEX = 'custom-agent:codex:11111111-1111-4111-8111-111111111111' as CustomTuiAgentId
|
||||
|
||||
function readyCustom(): LocalCustomTuiAgent {
|
||||
return {
|
||||
status: 'ready',
|
||||
definition: {
|
||||
id: CUSTOM_CODEX,
|
||||
baseAgent: 'codex',
|
||||
label: 'Model-specific Codex',
|
||||
args: '--model custom-model',
|
||||
syncEnv: false,
|
||||
commandOverride: '/opt/bin/codex'
|
||||
},
|
||||
envSummary: { entryCount: 0, bytes: 0 },
|
||||
availabilityReason: 'configured-executable'
|
||||
}
|
||||
}
|
||||
|
||||
function snapshot(): LocalAgentCatalogSnapshot {
|
||||
return { customAgents: [readyCustom()] } as LocalAgentCatalogSnapshot
|
||||
}
|
||||
|
||||
describe('buildWorkspaceAgentOptions', () => {
|
||||
it('includes the exact ready custom identity even when its base is not detected', () => {
|
||||
const options = buildWorkspaceAgentOptions({
|
||||
detectedAgentIds: new Set<TuiAgent>(['claude']),
|
||||
disabledTuiAgents: [],
|
||||
localAgentCatalog: snapshot()
|
||||
})
|
||||
|
||||
expect(options.map((option) => option.id)).toEqual(['claude', CUSTOM_CODEX])
|
||||
expect(options.at(-1)).toMatchObject({
|
||||
id: CUSTOM_CODEX,
|
||||
label: 'Model-specific Codex',
|
||||
baseAgent: 'codex',
|
||||
cmd: '/opt/bin/codex'
|
||||
})
|
||||
})
|
||||
|
||||
it('removes a custom identity when it or its base is disabled', () => {
|
||||
for (const disabledTuiAgents of [[CUSTOM_CODEX], ['codex']] as TuiAgent[][]) {
|
||||
const options = buildWorkspaceAgentOptions({
|
||||
detectedAgentIds: new Set<TuiAgent>(['claude', 'codex']),
|
||||
disabledTuiAgents,
|
||||
localAgentCatalog: snapshot()
|
||||
})
|
||||
|
||||
expect(options.some((option) => option.id === CUSTOM_CODEX)).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { mergeCustomAgentCatalogEntries } from '@/components/agent/custom-agent-catalog-entries'
|
||||
import { getAgentCatalog, type AgentCatalogEntry } from '@/lib/agent-catalog'
|
||||
import { filterEnabledTuiAgents } from '../../../shared/tui-agent-selection'
|
||||
import type { LocalAgentCatalogSnapshot } from '../../../shared/agent-catalog-snapshot'
|
||||
import type { TuiAgent } from '../../../shared/types'
|
||||
|
||||
/** Builds the exact catalog-backed list shared by the workspace picker and its
|
||||
* selection resolver, including custom-agent readiness and base detection gates. */
|
||||
export function buildWorkspaceAgentOptions({
|
||||
detectedAgentIds,
|
||||
disabledTuiAgents,
|
||||
localAgentCatalog
|
||||
}: {
|
||||
detectedAgentIds: ReadonlySet<TuiAgent> | null
|
||||
disabledTuiAgents: readonly TuiAgent[]
|
||||
localAgentCatalog: LocalAgentCatalogSnapshot | null
|
||||
}): AgentCatalogEntry[] {
|
||||
const enabledIds = new Set(
|
||||
filterEnabledTuiAgents(
|
||||
getAgentCatalog().map((agent) => agent.id),
|
||||
disabledTuiAgents
|
||||
)
|
||||
)
|
||||
const builtIns = getAgentCatalog().filter(
|
||||
(agent) =>
|
||||
enabledIds.has(agent.id) && (detectedAgentIds === null || detectedAgentIds.has(agent.id))
|
||||
)
|
||||
return mergeCustomAgentCatalogEntries(
|
||||
builtIns,
|
||||
localAgentCatalog,
|
||||
disabledTuiAgents,
|
||||
detectedAgentIds
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { CustomTuiAgentId } from '../../src/shared/types'
|
||||
import { test } from './helpers/orca-app'
|
||||
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import {
|
||||
sendToTerminal,
|
||||
waitForActivePanePtyId,
|
||||
waitForActiveTerminalManager,
|
||||
waitForPaneCount,
|
||||
waitForTerminalOutput
|
||||
} from './helpers/terminal'
|
||||
import {
|
||||
buildCustomAgent,
|
||||
ECHO_PREFIX,
|
||||
expectCustomAgentSeeded,
|
||||
launchCustomAgentViaBoundary,
|
||||
READY_MARKER
|
||||
} from './helpers/custom-agent-e2e'
|
||||
|
||||
// Annotation surfaces (diff notes, browser annotations) launch a NEW custom
|
||||
// agent through QuickLaunchAgentMenuItems → launchAgentInNewTab with
|
||||
// launch_source 'notes_send' and promptDelivery 'submit-after-ready': the tab
|
||||
// launches bare (allowEmptyPromptLaunch) and the annotation markdown is pasted
|
||||
// and submitted once the agent is ready. This spec drives that launch boundary
|
||||
// and then proves the annotation content reaches THIS process.
|
||||
//
|
||||
// Not covered here (deliberately): the "Send notes to <running agent>" followup
|
||||
// and the terminal-context-menu fork. Both gate on agent-status readiness the
|
||||
// deterministic echo fixture cannot emit (it is not a real TUI agent), and the
|
||||
// fork path nulls custom ids in resolveTuiAgent — those are covered by unit
|
||||
// tests, not this launch-surface suite.
|
||||
const CUSTOM_AGENT = buildCustomAgent({
|
||||
uuid: '4d5e6f7a-8b9c-4d0e-8f1a-2b3c4d5e6f70',
|
||||
label: 'E2E Annotation Agent'
|
||||
})
|
||||
const CUSTOM_AGENT_ID = CUSTOM_AGENT.id as CustomTuiAgentId
|
||||
|
||||
test.use({ seededCustomAgents: { agents: [CUSTOM_AGENT] } })
|
||||
|
||||
test('launches a custom agent from an annotation send and delivers the note content', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
const worktreeId = await waitForActiveWorktree(orcaPage)
|
||||
await expectCustomAgentSeeded(orcaPage, CUSTOM_AGENT_ID, CUSTOM_AGENT.label)
|
||||
|
||||
// notes_send launches bare (allowEmptyPromptLaunch): the annotation markdown is
|
||||
// delivered post-ready, not folded into the launch. Match that shape by
|
||||
// launching without a prompt, then delivering the note once the agent is ready.
|
||||
await launchCustomAgentViaBoundary(orcaPage, {
|
||||
worktreeId,
|
||||
agentId: CUSTOM_AGENT_ID,
|
||||
launchSource: 'notes_send'
|
||||
})
|
||||
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
await waitForPaneCount(orcaPage, 1, 30_000)
|
||||
const ptyId = await waitForActivePanePtyId(orcaPage, 30_000)
|
||||
await waitForTerminalOutput(orcaPage, READY_MARKER, 30_000)
|
||||
|
||||
// The annotation content is delivered post-ready (submit-after-ready). Prove it
|
||||
// reaches the launched process: the fixture echoes the submitted line back with
|
||||
// a prefix the terminal itself never produces (raw mode, no local echo).
|
||||
const ANNOTATION_NOTE = 'review-annotation-payload'
|
||||
await sendToTerminal(orcaPage, ptyId, `${ANNOTATION_NOTE}\r`)
|
||||
await waitForTerminalOutput(orcaPage, `${ECHO_PREFIX}${ANNOTATION_NOTE}`, 15_000)
|
||||
|
||||
// Clean shutdown so the daemon/PTY teardown does not race the app close.
|
||||
await sendToTerminal(orcaPage, ptyId, '\x03')
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import type { CustomTuiAgentId } from '../../src/shared/types'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import {
|
||||
sendToTerminal,
|
||||
waitForActivePanePtyId,
|
||||
waitForActiveTerminalManager,
|
||||
waitForTerminalOutput
|
||||
} from './helpers/terminal'
|
||||
import {
|
||||
ECHO_PREFIX,
|
||||
FIXTURE_PATH,
|
||||
launchCustomAgentViaBoundary,
|
||||
READY_MARKER
|
||||
} from './helpers/custom-agent-e2e'
|
||||
|
||||
// The integration proof: author a custom agent through the real Settings UI, then
|
||||
// launch that freshly-created id through the tab boundary and confirm the host
|
||||
// resolves and spawns it. This closes the authoring → catalog → launch loop that
|
||||
// no single-surface spec covers on its own.
|
||||
|
||||
async function openAgentsSettings(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const state = window.__store!.getState()
|
||||
state.openSettingsTarget({ pane: 'agents', repoId: null })
|
||||
state.openSettingsPage()
|
||||
})
|
||||
await expect(page.getByRole('button', { name: 'New agent' })).toBeVisible({ timeout: 10_000 })
|
||||
}
|
||||
|
||||
test('authors a custom agent in Settings, then launches it through the host boundary', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
const worktreeId = await waitForActiveWorktree(orcaPage)
|
||||
|
||||
// 1. Author the agent through the real Settings UI. Its executable/args point at
|
||||
// the deterministic fixture (via the test-runner node) so the launched process
|
||||
// is observable — the authoring path must produce a genuinely launchable agent.
|
||||
await openAgentsSettings(orcaPage)
|
||||
await orcaPage.getByRole('button', { name: 'New agent' }).click()
|
||||
const dialog = orcaPage.getByRole('dialog')
|
||||
await expect(dialog.getByText('New custom agent')).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
const AGENT_LABEL = 'Authored Launchable Agent'
|
||||
const AUTHORED_ARGS = FIXTURE_PATH
|
||||
await dialog.locator('#custom-agent-name').fill(AGENT_LABEL)
|
||||
await dialog.locator('#custom-agent-executable').fill(process.execPath)
|
||||
await dialog.locator('#custom-agent-args').fill(AUTHORED_ARGS)
|
||||
await dialog.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(dialog).toBeHidden({ timeout: 10_000 })
|
||||
|
||||
// 2. Read the host-minted id back from the real catalog (the uuid is host-owned,
|
||||
// so the test cannot know it ahead of authoring).
|
||||
const authoredId = await orcaPage.evaluate(async (label) => {
|
||||
const snapshot = await window.api.settings.agentCatalog.getLocal()
|
||||
const entry = snapshot.customAgents.find(
|
||||
(candidate) => candidate.status === 'ready' && candidate.definition.label === label
|
||||
)
|
||||
return entry && entry.status === 'ready' ? entry.definition.id : null
|
||||
}, AGENT_LABEL)
|
||||
expect(authoredId).not.toBeNull()
|
||||
|
||||
// 3. Leave Settings and launch the authored agent through the tab boundary.
|
||||
await orcaPage.evaluate(() => window.__store!.getState().closeSettingsPage())
|
||||
await launchCustomAgentViaBoundary(orcaPage, {
|
||||
worktreeId,
|
||||
agentId: authoredId as CustomTuiAgentId,
|
||||
launchSource: 'tab_bar_quick_launch'
|
||||
})
|
||||
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
const ptyId = await waitForActivePanePtyId(orcaPage, 30_000)
|
||||
|
||||
// The host resolved the just-authored definition and spawned its executable:
|
||||
// the fixture (named by the authored args) prints its readiness marker.
|
||||
await waitForTerminalOutput(orcaPage, READY_MARKER, 30_000)
|
||||
|
||||
// And it is genuinely the interactive launched process: typed input reaches it
|
||||
// and comes back with a prefix the terminal never produces (raw mode, no echo).
|
||||
await sendToTerminal(orcaPage, ptyId, 'ping\r')
|
||||
await waitForTerminalOutput(orcaPage, `${ECHO_PREFIX}ping`, 15_000)
|
||||
|
||||
// Clean shutdown so the daemon/PTY teardown does not race the app close.
|
||||
await sendToTerminal(orcaPage, ptyId, '\x03')
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { CustomTuiAgentId } from '../../src/shared/types'
|
||||
import { test } from './helpers/orca-app'
|
||||
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import {
|
||||
sendToTerminal,
|
||||
waitForActivePanePtyId,
|
||||
waitForActiveTerminalManager,
|
||||
waitForTerminalOutput
|
||||
} from './helpers/terminal'
|
||||
import {
|
||||
ARGV_PREFIX,
|
||||
buildCustomAgent,
|
||||
expectCustomAgentSeeded,
|
||||
launchCustomAgentViaBoundary,
|
||||
READY_MARKER
|
||||
} from './helpers/custom-agent-e2e'
|
||||
|
||||
// Source-control git actions (resolve conflicts, fix checks, commit/push
|
||||
// recovery) launch a custom agent by naming its identity AND the source-control
|
||||
// recipe via sourceRecord { owner: 'source-control-recipe', id: <actionId> }.
|
||||
// The client sends only the owner locator; the host resolves the recipe's stored
|
||||
// agentArgs into host-owned perLaunchArgs and appends them to the launched argv.
|
||||
// These specs prove that host-owned recipe→perLaunchArgs→argv path lands through
|
||||
// the real boundary for multiple action ids and their real launch_sources.
|
||||
//
|
||||
// Not covered here (deliberately): the recovery auto-select guard — a recipe
|
||||
// whose agentId is a custom id is dropped by readSourceControlLaunchRecipeAgentId
|
||||
// so recovery falls back to a base agent. That is a pure renderer function
|
||||
// covered by unit tests; custom-agent git launches always select the custom via
|
||||
// selection.agent (the dialog path), which is exactly what these specs drive.
|
||||
const CUSTOM_AGENT = buildCustomAgent({
|
||||
uuid: '5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7081',
|
||||
label: 'E2E Git Action Agent'
|
||||
})
|
||||
const CUSTOM_AGENT_ID = CUSTOM_AGENT.id as CustomTuiAgentId
|
||||
|
||||
// Real source-control launch-action ids seeded with distinct stored agentArgs.
|
||||
const CONFLICT_ACTION_ID = 'resolveConflicts'
|
||||
const CONFLICT_ARGS = '--recipe conflicts'
|
||||
const PUSH_RECOVERY_ACTION_ID = 'fixPushFailure'
|
||||
const PUSH_RECOVERY_ARGS = '--recipe push-recovery'
|
||||
|
||||
test.use({
|
||||
seededCustomAgents: { agents: [CUSTOM_AGENT] },
|
||||
seededSourceControlActions: {
|
||||
[CONFLICT_ACTION_ID]: { agentArgs: CONFLICT_ARGS },
|
||||
[PUSH_RECOVERY_ACTION_ID]: { agentArgs: PUSH_RECOVERY_ARGS }
|
||||
}
|
||||
})
|
||||
|
||||
async function launchGitActionAndAssertArgv(
|
||||
orcaPage: Parameters<typeof waitForSessionReady>[0],
|
||||
options: {
|
||||
actionId: string
|
||||
expectedArgs: string
|
||||
launchSource: Parameters<typeof launchCustomAgentViaBoundary>[1]['launchSource']
|
||||
}
|
||||
): Promise<void> {
|
||||
await waitForSessionReady(orcaPage)
|
||||
const worktreeId = await waitForActiveWorktree(orcaPage)
|
||||
await expectCustomAgentSeeded(orcaPage, CUSTOM_AGENT_ID, CUSTOM_AGENT.label)
|
||||
|
||||
await launchCustomAgentViaBoundary(orcaPage, {
|
||||
worktreeId,
|
||||
agentId: CUSTOM_AGENT_ID,
|
||||
launchSource: options.launchSource,
|
||||
sourceRecord: { owner: 'source-control-recipe', id: options.actionId }
|
||||
})
|
||||
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
const ptyId = await waitForActivePanePtyId(orcaPage, 30_000)
|
||||
await waitForTerminalOutput(orcaPage, READY_MARKER, 30_000)
|
||||
|
||||
// The recipe's stored agentArgs reached THIS spawned process's argv — proving
|
||||
// the host-owned recipe→perLaunchArgs→argv path lands through the real boundary.
|
||||
await waitForTerminalOutput(orcaPage, `${ARGV_PREFIX}${options.expectedArgs}`, 30_000)
|
||||
|
||||
// Clean shutdown so the daemon/PTY teardown does not race the app close.
|
||||
await sendToTerminal(orcaPage, ptyId, '\x03')
|
||||
}
|
||||
|
||||
test('threads a conflict-resolution recipe’s agentArgs into the launched custom agent argv', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
await launchGitActionAndAssertArgv(orcaPage, {
|
||||
actionId: CONFLICT_ACTION_ID,
|
||||
expectedArgs: CONFLICT_ARGS,
|
||||
launchSource: 'conflict_resolution'
|
||||
})
|
||||
})
|
||||
|
||||
test('threads a push-recovery recipe’s agentArgs into the launched custom agent argv', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
await launchGitActionAndAssertArgv(orcaPage, {
|
||||
actionId: PUSH_RECOVERY_ACTION_ID,
|
||||
expectedArgs: PUSH_RECOVERY_ARGS,
|
||||
launchSource: 'source_control_recovery'
|
||||
})
|
||||
})
|
||||
@@ -48,6 +48,42 @@ test.use({
|
||||
seededSourceControlActions: { [RECIPE_ACTION_ID]: { agentArgs: RECIPE_ARGS } }
|
||||
})
|
||||
|
||||
test('launches custom args selected through the new-workspace composer UI', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
|
||||
await orcaPage.getByRole('button', { name: 'New workspace', exact: true }).click()
|
||||
const dialog = orcaPage.getByRole('dialog', { name: /Create (Workspace|Worktree)/i })
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog.locator('[data-workspace-name-input="true"]')).toBeVisible()
|
||||
|
||||
const nameInput = dialog.getByPlaceholder(/Type a name/i)
|
||||
await nameInput.fill(`e2e-custom-composer-${Date.now()}`)
|
||||
|
||||
const agentTrigger = dialog.locator('[data-agent-combobox-root="true"][role="combobox"]')
|
||||
await agentTrigger.click()
|
||||
await expect(orcaPage.getByText(CUSTOM_AGENT_LABEL, { exact: true })).toBeVisible()
|
||||
await orcaPage.getByText(CUSTOM_AGENT_LABEL, { exact: true }).click()
|
||||
await expect(agentTrigger).toContainText(CUSTOM_AGENT_LABEL)
|
||||
|
||||
const createButton = dialog.getByRole('button', { name: /Create (Workspace|Worktree)/i })
|
||||
await expect(createButton).toBeEnabled()
|
||||
await createButton.click()
|
||||
await expect(dialog).toBeHidden({ timeout: 15_000 })
|
||||
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
await waitForPaneCount(orcaPage, 1, 30_000)
|
||||
const ptyId = await waitForActivePanePtyId(orcaPage, 30_000)
|
||||
|
||||
// The fixture path is supplied only by this custom definition's args. Seeing
|
||||
// its marker proves the composer submitted the custom id through host assembly.
|
||||
await waitForTerminalOutput(orcaPage, READY_MARKER, 30_000)
|
||||
await sendToTerminal(orcaPage, ptyId, '\x03')
|
||||
})
|
||||
|
||||
test('launches a seeded custom agent through the host boundary and round-trips keyboard input', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { CustomTuiAgentId } from '../../src/shared/types'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import {
|
||||
countVisibleTerminalPanes,
|
||||
getTerminalContent,
|
||||
sendToTerminal,
|
||||
waitForActivePanePtyId,
|
||||
waitForActiveTerminalManager,
|
||||
waitForPaneCount,
|
||||
waitForTerminalOutput
|
||||
} from './helpers/terminal'
|
||||
import {
|
||||
buildCustomAgent,
|
||||
ECHO_PREFIX,
|
||||
expectCustomAgentSeeded,
|
||||
launchCustomAgentViaBoundary,
|
||||
READY_MARKER,
|
||||
readTabLaunchIdentity
|
||||
} from './helpers/custom-agent-e2e'
|
||||
|
||||
// The Cmd+N new-workspace composer (folder-workspace and git-worktree paths)
|
||||
// launches a custom agent by naming it via agentLaunch.selection with
|
||||
// launch_source 'new_workspace_composer'; the host resolves command/args/env.
|
||||
// These specs drive that exact launch-boundary contract with the composer's
|
||||
// launch_source (the driving decision: store-boundary for launch surfaces).
|
||||
const CUSTOM_AGENT = buildCustomAgent({
|
||||
uuid: '3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f',
|
||||
label: 'E2E Composer Agent'
|
||||
})
|
||||
const CUSTOM_AGENT_ID = CUSTOM_AGENT.id as CustomTuiAgentId
|
||||
const COMPOSER_SOURCE = 'new_workspace_composer' as const
|
||||
|
||||
test.use({ seededCustomAgents: { agents: [CUSTOM_AGENT] } })
|
||||
|
||||
test('launches a custom agent from the new-workspace composer boundary', async ({ orcaPage }) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
const worktreeId = await waitForActiveWorktree(orcaPage)
|
||||
await expectCustomAgentSeeded(orcaPage, CUSTOM_AGENT_ID, CUSTOM_AGENT.label)
|
||||
|
||||
const tabId = await launchCustomAgentViaBoundary(orcaPage, {
|
||||
worktreeId,
|
||||
agentId: CUSTOM_AGENT_ID,
|
||||
launchSource: COMPOSER_SOURCE
|
||||
})
|
||||
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
await waitForPaneCount(orcaPage, 1, 30_000)
|
||||
const ptyId = await waitForActivePanePtyId(orcaPage, 30_000)
|
||||
|
||||
// The fixture prints its marker on spawn: the composer's launch_source resolved
|
||||
// the seeded custom executable and started it in the pane's PTY.
|
||||
await waitForTerminalOutput(orcaPage, READY_MARKER, 30_000)
|
||||
|
||||
// Exactly one pane, and the tab carries the custom agent's identity.
|
||||
expect(await countVisibleTerminalPanes(orcaPage)).toBe(1)
|
||||
const identity = await readTabLaunchIdentity(orcaPage, worktreeId, tabId)
|
||||
expect(identity?.launchAgent).toBe(CUSTOM_AGENT_ID)
|
||||
expect(typeof identity?.ptyId).toBe('string')
|
||||
|
||||
// Clean shutdown so the daemon/PTY teardown does not race the app close.
|
||||
await sendToTerminal(orcaPage, ptyId, '\x03')
|
||||
})
|
||||
|
||||
test('folds a composer draft prompt into the launched custom agent as unsubmitted input', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
const worktreeId = await waitForActiveWorktree(orcaPage)
|
||||
await expectCustomAgentSeeded(orcaPage, CUSTOM_AGENT_ID, CUSTOM_AGENT.label)
|
||||
|
||||
const DRAFT_PROMPT = 'composer-draft-line'
|
||||
await launchCustomAgentViaBoundary(orcaPage, {
|
||||
worktreeId,
|
||||
agentId: CUSTOM_AGENT_ID,
|
||||
launchSource: COMPOSER_SOURCE,
|
||||
prompt: DRAFT_PROMPT,
|
||||
promptDelivery: 'draft'
|
||||
})
|
||||
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
const ptyId = await waitForActivePanePtyId(orcaPage, 30_000)
|
||||
await waitForTerminalOutput(orcaPage, READY_MARKER, 30_000)
|
||||
|
||||
// A 'draft' prompt lands UNSUBMITTED: the fixture only emits its transformed
|
||||
// echo line on a submitted (newline-terminated) line, so the raw draft text
|
||||
// must NOT yet appear as an echo. Prove the draft did not auto-submit.
|
||||
const content = await getTerminalContent(orcaPage)
|
||||
expect(content).not.toContain(`${ECHO_PREFIX}${DRAFT_PROMPT}`)
|
||||
|
||||
// Submitting the drafted line drives it into the process, which echoes it back
|
||||
// with the prefix the terminal itself never produces (raw mode, no local echo).
|
||||
await sendToTerminal(orcaPage, ptyId, '\r')
|
||||
await waitForTerminalOutput(orcaPage, `${ECHO_PREFIX}`, 15_000)
|
||||
|
||||
await sendToTerminal(orcaPage, ptyId, '\x03')
|
||||
})
|
||||
|
||||
test('surfaces the same client-safe recovery notice for an unknown composer launch', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
const worktreeId = await waitForActiveWorktree(orcaPage)
|
||||
|
||||
// A well-formed custom id that is deliberately never seeded: the host resolves
|
||||
// it against the catalog, finds nothing, and rejects the launch pre-spawn.
|
||||
const UNKNOWN_ID = 'custom-agent:codex:9f8e7d6c-5b4a-4938-8271-605f4e3d2c1b' as CustomTuiAgentId
|
||||
const isSeeded = await orcaPage.evaluate(async (id) => {
|
||||
const snapshot = await window.api.settings.agentCatalog.getLocal()
|
||||
return snapshot.customAgents.some(
|
||||
(candidate) => candidate.status === 'ready' && candidate.definition.id === id
|
||||
)
|
||||
}, UNKNOWN_ID)
|
||||
expect(isSeeded).toBe(false)
|
||||
|
||||
await launchCustomAgentViaBoundary(orcaPage, {
|
||||
worktreeId,
|
||||
agentId: UNKNOWN_ID,
|
||||
launchSource: COMPOSER_SOURCE
|
||||
})
|
||||
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
|
||||
// The pre-spawn failure renders the persistent in-pane recovery notice with
|
||||
// localized, client-safe copy — it never leaks the requested agent id.
|
||||
await expect(orcaPage.getByText(/no longer exists/i)).toBeVisible({ timeout: 30_000 })
|
||||
expect(await orcaPage.getByText('9f8e7d6c').count()).toBe(0)
|
||||
})
|
||||
@@ -0,0 +1,189 @@
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import type { CustomTuiAgentId } from '../../src/shared/types'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { waitForSessionReady } from './helpers/store'
|
||||
import { buildCustomAgent } from './helpers/custom-agent-e2e'
|
||||
|
||||
// The Settings → Agents pane is the authoring surface for custom agents. Unlike
|
||||
// the launch surfaces (driven at the store boundary), the authoring UI IS the
|
||||
// thing under test, so these specs drive it with real clicks and assert the
|
||||
// change lands in the real host catalog via window.api.settings.agentCatalog.
|
||||
|
||||
async function openAgentsSettings(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const state = window.__store!.getState()
|
||||
state.openSettingsTarget({ pane: 'agents', repoId: null })
|
||||
state.openSettingsPage()
|
||||
})
|
||||
await expect(page.getByPlaceholder('Search settings')).toBeVisible({ timeout: 10_000 })
|
||||
await expect(page.locator('[data-settings-section="agents"]')).toBeVisible({ timeout: 10_000 })
|
||||
// The catalog subsection header proves the Agents pane rendered its content.
|
||||
await expect(page.getByRole('button', { name: 'New agent' })).toBeVisible({ timeout: 10_000 })
|
||||
}
|
||||
|
||||
/** Read the ready-custom catalog entries (id + label) from the real host catalog. */
|
||||
async function readReadyCustomAgents(page: Page): Promise<{ id: string; label: string }[]> {
|
||||
return await page.evaluate(async () => {
|
||||
const snapshot = await window.api.settings.agentCatalog.getLocal()
|
||||
return snapshot.customAgents
|
||||
.filter((candidate) => candidate.status === 'ready')
|
||||
.map((candidate) => ({ id: candidate.definition.id, label: candidate.definition.label }))
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Custom agent authoring — create', () => {
|
||||
test('creates a custom agent from the New agent dialog and persists it as ready', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await openAgentsSettings(orcaPage)
|
||||
|
||||
await orcaPage.getByRole('button', { name: 'New agent' }).click()
|
||||
const dialog = orcaPage.getByRole('dialog')
|
||||
await expect(dialog.getByText('New custom agent')).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
const AGENT_LABEL = 'Authored Agent'
|
||||
await dialog.locator('#custom-agent-name').fill(AGENT_LABEL)
|
||||
// A concrete executable path makes the saved agent a valid `configured-executable`
|
||||
// (existence is checked at launch, not authoring), so it persists as `ready`.
|
||||
await dialog.locator('#custom-agent-executable').fill('/usr/local/bin/authored-agent')
|
||||
await dialog.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
// The dialog closes on a successful save and the row appears in the catalog.
|
||||
await expect(dialog).toBeHidden({ timeout: 10_000 })
|
||||
await expect(orcaPage.getByRole('button', { name: `Actions for ${AGENT_LABEL}` })).toBeVisible({
|
||||
timeout: 10_000
|
||||
})
|
||||
|
||||
// The authored agent landed in the real host catalog as a ready entry.
|
||||
await expect
|
||||
.poll(async () => (await readReadyCustomAgents(orcaPage)).map((entry) => entry.label), {
|
||||
timeout: 10_000
|
||||
})
|
||||
.toContain(AGENT_LABEL)
|
||||
})
|
||||
|
||||
test('blocks saving an agent with an empty name and shows a validation error', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await openAgentsSettings(orcaPage)
|
||||
|
||||
await orcaPage.getByRole('button', { name: 'New agent' }).click()
|
||||
const dialog = orcaPage.getByRole('dialog')
|
||||
await expect(dialog.getByText('New custom agent')).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
// Save with an empty name: the editor rejects it, keeps the dialog open, and
|
||||
// renders the inline field error — nothing is persisted.
|
||||
await dialog.locator('#custom-agent-executable').fill('/usr/local/bin/nameless')
|
||||
await dialog.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(orcaPage.locator('#custom-agent-name-error')).toBeVisible({ timeout: 10_000 })
|
||||
expect(await readReadyCustomAgents(orcaPage)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Custom agent authoring — manage a seeded agent', () => {
|
||||
const SEEDED = buildCustomAgent({
|
||||
uuid: '6f7a8b9c-0d1e-4f2a-8b3c-4d5e6f708192',
|
||||
label: 'Seeded Manage Agent'
|
||||
})
|
||||
const SEEDED_ID = SEEDED.id as CustomTuiAgentId
|
||||
|
||||
test.use({ seededCustomAgents: { agents: [SEEDED] } })
|
||||
|
||||
test('edits a custom agent’s name and persists the change', async ({ orcaPage }) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await openAgentsSettings(orcaPage)
|
||||
|
||||
await orcaPage.getByRole('button', { name: `Actions for ${SEEDED.label}` }).click()
|
||||
await orcaPage.getByRole('menuitem', { name: 'Edit' }).click()
|
||||
const dialog = orcaPage.getByRole('dialog')
|
||||
await expect(dialog.getByText('Edit custom agent')).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
const NEW_LABEL = 'Renamed Manage Agent'
|
||||
await dialog.locator('#custom-agent-name').fill(NEW_LABEL)
|
||||
await dialog.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(dialog).toBeHidden({ timeout: 10_000 })
|
||||
|
||||
// The same id now carries the new label in the real host catalog.
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await readReadyCustomAgents(orcaPage)).find((entry) => entry.id === SEEDED_ID)?.label,
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBe(NEW_LABEL)
|
||||
})
|
||||
|
||||
test('duplicates a custom agent into a distinct new entry', async ({ orcaPage }) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await openAgentsSettings(orcaPage)
|
||||
|
||||
await orcaPage.getByRole('button', { name: `Actions for ${SEEDED.label}` }).click()
|
||||
await orcaPage.getByRole('menuitem', { name: 'Duplicate' }).click()
|
||||
const dialog = orcaPage.getByRole('dialog')
|
||||
await expect(dialog.getByText('Duplicate agent')).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
const COPY_LABEL = 'Duplicated Manage Agent'
|
||||
await dialog.locator('#custom-agent-name').fill(COPY_LABEL)
|
||||
await dialog.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(dialog).toBeHidden({ timeout: 10_000 })
|
||||
|
||||
// Both the source and the copy now exist as distinct ready entries.
|
||||
await expect
|
||||
.poll(async () => (await readReadyCustomAgents(orcaPage)).map((entry) => entry.label), {
|
||||
timeout: 10_000
|
||||
})
|
||||
.toEqual(expect.arrayContaining([SEEDED.label, COPY_LABEL]))
|
||||
const entries = await readReadyCustomAgents(orcaPage)
|
||||
const copy = entries.find((entry) => entry.label === COPY_LABEL)
|
||||
expect(copy?.id).not.toBe(SEEDED_ID)
|
||||
})
|
||||
|
||||
test('deletes a custom agent after confirming the destructive dialog', async ({ orcaPage }) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await openAgentsSettings(orcaPage)
|
||||
|
||||
await orcaPage.getByRole('button', { name: `Actions for ${SEEDED.label}` }).click()
|
||||
await orcaPage.getByRole('menuitem', { name: 'Delete' }).click()
|
||||
const dialog = orcaPage.getByRole('dialog')
|
||||
await expect(dialog.getByText(`Delete ${SEEDED.label}?`)).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
await dialog.getByRole('button', { name: 'Delete agent' }).click()
|
||||
await expect(dialog).toBeHidden({ timeout: 10_000 })
|
||||
|
||||
// The agent is gone from the ready catalog and its row is removed.
|
||||
await expect
|
||||
.poll(async () => (await readReadyCustomAgents(orcaPage)).map((entry) => entry.id), {
|
||||
timeout: 10_000
|
||||
})
|
||||
.not.toContain(SEEDED_ID)
|
||||
await expect(orcaPage.getByRole('button', { name: `Actions for ${SEEDED.label}` })).toHaveCount(
|
||||
0
|
||||
)
|
||||
})
|
||||
|
||||
test('disables a custom agent via the row switch', async ({ orcaPage }) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await openAgentsSettings(orcaPage)
|
||||
|
||||
const enableSwitch = orcaPage.getByRole('switch', { name: `Enable ${SEEDED.label}` })
|
||||
await expect(enableSwitch).toBeChecked()
|
||||
await enableSwitch.click()
|
||||
|
||||
// The disable landed in the real host settings (disabled agents are excluded
|
||||
// from the launch pickers' merge).
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
orcaPage.evaluate(async (id) => {
|
||||
const settings = await window.api.settings.get()
|
||||
return (settings.disabledTuiAgents ?? []).includes(id)
|
||||
}, SEEDED_ID),
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
import path from 'node:path'
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import type {
|
||||
AgentLaunchSourceRecord,
|
||||
AgentLaunchSpawnRequest
|
||||
} from '../../../src/shared/agent-launch-spawn-request'
|
||||
import type { LaunchSource } from '../../../src/shared/telemetry-events'
|
||||
import type { BuiltInTuiAgent, CustomTuiAgent, CustomTuiAgentId } from '../../../src/shared/types'
|
||||
import { expect } from './orca-app'
|
||||
|
||||
// Shared scaffolding for the custom-agent launch-surface e2e specs. Each surface
|
||||
// (Cmd+N composer, new tab, annotations, git actions) converges on the SAME
|
||||
// client contract — an empty command plus an `agentLaunch` selection the host
|
||||
// resolves at spawn — so the specs differ only in the surface-specific params
|
||||
// (launch_source, sourceRecord, prompt/promptDelivery). This helper centralizes
|
||||
// the seed shape, the seed self-verification, and the boundary launch so the
|
||||
// specs stay readable and a schema drift fails loudly in one place.
|
||||
|
||||
/** The seeded custom agent's executable is `node <fixture>`: commandOverride is
|
||||
* the node binary (one argv element) and the fixture path rides the v1 args
|
||||
* template (space-free, so it tokenizes to a single argument). */
|
||||
export const FIXTURE_PATH = path.join(
|
||||
process.cwd(),
|
||||
'tests/e2e/fixtures/custom-agent-launch-fixture.cjs'
|
||||
)
|
||||
|
||||
/** Markers the deterministic fixture prints; a spec asserts on these to prove the
|
||||
* host resolved the custom executable and drove input into THIS process. */
|
||||
export const READY_MARKER = 'CUSTOM_AGENT_FIXTURE_READY'
|
||||
export const ECHO_PREFIX = 'CUSTOM_AGENT_ECHO:'
|
||||
export const ARGV_PREFIX = 'CUSTOM_AGENT_ARGV:'
|
||||
|
||||
/** Build a seeded custom-agent definition. Each spec passes a unique uuid so a
|
||||
* human reading a failing catalog snapshot can tell which spec seeded it. */
|
||||
export function buildCustomAgent(options: {
|
||||
uuid: string
|
||||
label: string
|
||||
baseAgent?: BuiltInTuiAgent
|
||||
args?: string
|
||||
env?: Record<string, string>
|
||||
syncEnv?: boolean
|
||||
}): CustomTuiAgent {
|
||||
const baseAgent = options.baseAgent ?? 'codex'
|
||||
return {
|
||||
id: `custom-agent:${baseAgent}:${options.uuid}` as CustomTuiAgentId,
|
||||
baseAgent,
|
||||
label: options.label,
|
||||
commandOverride: process.execPath,
|
||||
args: options.args ?? FIXTURE_PATH,
|
||||
env: options.env ?? {},
|
||||
syncEnv: options.syncEnv ?? false
|
||||
}
|
||||
}
|
||||
|
||||
/** Self-verify a seed loaded into the real host catalog as a `ready` entry before
|
||||
* launching, so schema drift fails loudly here instead of silently no-op'ing. */
|
||||
export async function expectCustomAgentSeeded(
|
||||
page: Page,
|
||||
agentId: CustomTuiAgentId,
|
||||
expectedLabel: string
|
||||
): Promise<void> {
|
||||
const loadedAgent = await page.evaluate(async (id) => {
|
||||
const snapshot = await window.api.settings.agentCatalog.getLocal()
|
||||
const entry = snapshot.customAgents.find(
|
||||
(candidate) => candidate.status === 'ready' && candidate.definition.id === id
|
||||
)
|
||||
return entry && entry.status === 'ready'
|
||||
? { id: entry.definition.id, label: entry.definition.label }
|
||||
: null
|
||||
}, agentId)
|
||||
expect(loadedAgent).toEqual({ id: agentId, label: expectedLabel })
|
||||
}
|
||||
|
||||
export type BoundaryLaunchOptions = {
|
||||
worktreeId: string
|
||||
agentId: CustomTuiAgentId
|
||||
/** The real surface's telemetry launch_source (e.g. 'new_workspace_composer'). */
|
||||
launchSource: LaunchSource
|
||||
/** Host-verified owner locator; drives recipe agentArgs into perLaunchArgs. */
|
||||
sourceRecord?: AgentLaunchSourceRecord
|
||||
/** Interactive draft folded into the launch (quick-launch / draft surfaces). */
|
||||
prompt?: string
|
||||
/** 'draft' lands the prompt unsubmitted; default is the host 'submit' policy. */
|
||||
promptDelivery?: 'submit' | 'draft'
|
||||
}
|
||||
|
||||
/** Launch a seeded custom agent through the exact production boundary every
|
||||
* surface funnels into (createTab → queueTabStartupCommand with an `agentLaunch`
|
||||
* selection the host resolves at spawn). Returns the launched tab id. When a
|
||||
* `prompt` is given it folds into the launch; otherwise the tab launches bare
|
||||
* with allowEmptyPromptLaunch, matching the surfaces that paste post-ready. */
|
||||
export async function launchCustomAgentViaBoundary(
|
||||
page: Page,
|
||||
options: BoundaryLaunchOptions
|
||||
): Promise<string> {
|
||||
return await page.evaluate((opts) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('Store unavailable')
|
||||
}
|
||||
const state = store.getState()
|
||||
const tab = state.createTab(opts.worktreeId, undefined, undefined, {
|
||||
launchAgent: opts.agentId
|
||||
})
|
||||
const agentLaunch: AgentLaunchSpawnRequest = {
|
||||
selection: { kind: 'agent', agent: opts.agentId },
|
||||
...(opts.prompt
|
||||
? {
|
||||
prompt: opts.prompt,
|
||||
...(opts.promptDelivery === 'draft' ? { promptDelivery: 'draft' as const } : {})
|
||||
}
|
||||
: { allowEmptyPromptLaunch: true }),
|
||||
...(opts.sourceRecord ? { sourceRecord: opts.sourceRecord } : {})
|
||||
}
|
||||
state.queueTabStartupCommand(tab.id, {
|
||||
command: '',
|
||||
agentLaunch,
|
||||
telemetry: { launch_source: opts.launchSource, request_kind: 'new' }
|
||||
})
|
||||
state.setActiveTab(tab.id)
|
||||
state.setActiveTabType('terminal')
|
||||
return tab.id
|
||||
}, options)
|
||||
}
|
||||
|
||||
/** Read a launched tab's visible launch identity (launchAgent + ptyId) so a spec
|
||||
* can assert the pane carries the custom agent's identity and a real PTY. */
|
||||
export async function readTabLaunchIdentity(
|
||||
page: Page,
|
||||
worktreeId: string,
|
||||
tabId: string
|
||||
): Promise<{ launchAgent: string | undefined; ptyId: string | null } | null> {
|
||||
return await page.evaluate(
|
||||
({ worktreeId, tabId }) => {
|
||||
const tab = (window.__store?.getState().tabsByWorktree[worktreeId] ?? []).find(
|
||||
(candidate) => candidate.id === tabId
|
||||
)
|
||||
return tab ? { launchAgent: tab.launchAgent, ptyId: tab.ptyId } : null
|
||||
},
|
||||
{ worktreeId, tabId }
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import path from 'node:path'
|
||||
import type { CustomTuiAgent, CustomTuiAgentId } from '../../src/shared/types'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import {
|
||||
sendToTerminal,
|
||||
waitForActivePanePtyId,
|
||||
waitForActiveTerminalManager,
|
||||
waitForTerminalOutput
|
||||
} from './helpers/terminal'
|
||||
import {
|
||||
cleanupDockerSshRelayTarget,
|
||||
startDockerSshRelayTarget,
|
||||
type DockerSshRelayTarget
|
||||
} from './helpers/docker-ssh-relay-target'
|
||||
import {
|
||||
connectDockerRemoteWorktree,
|
||||
observeRemoteAgentProcesses,
|
||||
placeCustomAgentFixtureInContainer,
|
||||
REMOTE_CUSTOM_AGENT_FIXTURE_PATH
|
||||
} from './helpers/docker-ssh-custom-agent-remote'
|
||||
import { ARGV_PREFIX, launchCustomAgentViaBoundary, READY_MARKER } from './helpers/custom-agent-e2e'
|
||||
|
||||
// SSH variants of the non-terminal launch surfaces (Cmd+N composer, git actions).
|
||||
// The launch surfaces converge on the same host boundary as the new-tab path, so
|
||||
// these prove the surface-specific params (launch_source, sourceRecord agentArgs)
|
||||
// resolve and spawn the custom agent on the REMOTE, observed via /proc in the
|
||||
// container — a mocked handler could never see the real remote argv.
|
||||
const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1'
|
||||
const LOCAL_FIXTURE_PATH = path.join(
|
||||
process.cwd(),
|
||||
'tests/e2e/fixtures/custom-agent-launch-fixture.cjs'
|
||||
)
|
||||
const CUSTOM_AGENT_ID =
|
||||
'custom-agent:codex:7a8b9c0d-1e2f-4a3b-8c4d-5e6f70819203' as CustomTuiAgentId
|
||||
const CUSTOM_AGENT_LABEL = 'E2E SSH Surface Agent'
|
||||
|
||||
// The custom executable is `node <remote fixture>`: commandOverride is a bare
|
||||
// `node` resolved on the remote PATH; the space-free container path rides the v1
|
||||
// args template as one argument. The host resolves and spawns this on the remote.
|
||||
const CUSTOM_AGENT: CustomTuiAgent = {
|
||||
id: CUSTOM_AGENT_ID,
|
||||
baseAgent: 'codex',
|
||||
label: CUSTOM_AGENT_LABEL,
|
||||
commandOverride: 'node',
|
||||
args: REMOTE_CUSTOM_AGENT_FIXTURE_PATH,
|
||||
env: {},
|
||||
syncEnv: false
|
||||
}
|
||||
|
||||
// A source-control recipe whose stored agentArgs the host threads into the remote
|
||||
// launch's perLaunchArgs when a git-action launch names it via sourceRecord.
|
||||
const RECIPE_ACTION_ID = 'resolveConflicts'
|
||||
const RECIPE_ARGS = '--recipe remote-conflicts'
|
||||
|
||||
test.use({
|
||||
seededCustomAgents: { agents: [CUSTOM_AGENT] },
|
||||
seededSourceControlActions: { [RECIPE_ACTION_ID]: { agentArgs: RECIPE_ARGS } }
|
||||
})
|
||||
|
||||
test.describe('SSH custom-agent surfaces', () => {
|
||||
test.skip(!RUN_DOCKER_SSH, 'Set ORCA_E2E_SSH_DOCKER=1 to run Docker-backed SSH custom-agent e2e.')
|
||||
test.skip(process.platform === 'win32', 'Docker SSH custom-agent e2e uses POSIX ssh tooling.')
|
||||
|
||||
test('resolves a new-workspace-composer launch on the SSH remote', async ({
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
test.slow()
|
||||
let target: DockerSshRelayTarget | null = null
|
||||
try {
|
||||
target = startDockerSshRelayTarget(testInfo)
|
||||
placeCustomAgentFixtureInContainer(target, LOCAL_FIXTURE_PATH)
|
||||
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
const remote = await connectDockerRemoteWorktree(orcaPage, target)
|
||||
|
||||
await launchCustomAgentViaBoundary(orcaPage, {
|
||||
worktreeId: remote.worktreeId,
|
||||
agentId: CUSTOM_AGENT_ID,
|
||||
launchSource: 'new_workspace_composer'
|
||||
})
|
||||
|
||||
await ensureTerminalVisible(orcaPage, 45_000)
|
||||
await waitForActiveTerminalManager(orcaPage, 60_000)
|
||||
const ptyId = await waitForActivePanePtyId(orcaPage, 60_000)
|
||||
await waitForTerminalOutput(orcaPage, READY_MARKER, 60_000, 80_000)
|
||||
|
||||
// Exactly one remote node agent, launched with the container fixture path.
|
||||
await expect
|
||||
.poll(() => observeRemoteAgentProcesses(target!).length, {
|
||||
timeout: 30_000,
|
||||
message: 'remote composer-launched custom agent did not appear'
|
||||
})
|
||||
.toBe(1)
|
||||
const [remoteProcess] = observeRemoteAgentProcesses(target)
|
||||
expect(remoteProcess.argv).toContain(REMOTE_CUSTOM_AGENT_FIXTURE_PATH)
|
||||
|
||||
await sendToTerminal(orcaPage, ptyId, '\x03')
|
||||
} finally {
|
||||
cleanupDockerSshRelayTarget(target)
|
||||
}
|
||||
})
|
||||
|
||||
test('threads a git-action recipe’s agentArgs into the SSH remote launch argv', async ({
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
test.slow()
|
||||
let target: DockerSshRelayTarget | null = null
|
||||
try {
|
||||
target = startDockerSshRelayTarget(testInfo)
|
||||
placeCustomAgentFixtureInContainer(target, LOCAL_FIXTURE_PATH)
|
||||
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
const remote = await connectDockerRemoteWorktree(orcaPage, target)
|
||||
|
||||
await launchCustomAgentViaBoundary(orcaPage, {
|
||||
worktreeId: remote.worktreeId,
|
||||
agentId: CUSTOM_AGENT_ID,
|
||||
launchSource: 'conflict_resolution',
|
||||
sourceRecord: { owner: 'source-control-recipe', id: RECIPE_ACTION_ID }
|
||||
})
|
||||
|
||||
await ensureTerminalVisible(orcaPage, 45_000)
|
||||
await waitForActiveTerminalManager(orcaPage, 60_000)
|
||||
const ptyId = await waitForActivePanePtyId(orcaPage, 60_000)
|
||||
await waitForTerminalOutput(orcaPage, READY_MARKER, 60_000, 80_000)
|
||||
|
||||
// The recipe's stored agentArgs reached THIS remote process's argv: the
|
||||
// fixture echoes everything past `node <fixture>`, which is the host-resolved
|
||||
// perLaunchArgs band.
|
||||
await waitForTerminalOutput(orcaPage, `${ARGV_PREFIX}${RECIPE_ARGS}`, 60_000, 80_000)
|
||||
|
||||
// Corroborate against the real remote /proc cmdline: the recipe arg tokens
|
||||
// are present in the launched argv, not just the terminal echo.
|
||||
await expect
|
||||
.poll(() => observeRemoteAgentProcesses(target!).length, {
|
||||
timeout: 30_000,
|
||||
message: 'remote git-action custom agent did not appear'
|
||||
})
|
||||
.toBe(1)
|
||||
const [remoteProcess] = observeRemoteAgentProcesses(target)
|
||||
for (const token of RECIPE_ARGS.split(' ')) {
|
||||
expect(remoteProcess.argv).toContain(token)
|
||||
}
|
||||
|
||||
testInfo.annotations.push({
|
||||
type: 'ssh-custom-agent-git-action',
|
||||
description: `remote argv=${remoteProcess.argv.join(' ')}`
|
||||
})
|
||||
|
||||
await sendToTerminal(orcaPage, ptyId, '\x03')
|
||||
} finally {
|
||||
cleanupDockerSshRelayTarget(target)
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user