diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx index 1317c967179..8d98e682194 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx @@ -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( {}} + 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) diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 85ccfc50813..8c139e4ead2 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -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(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( diff --git a/src/renderer/src/components/NewWorkspaceComposerModal.tsx b/src/renderer/src/components/NewWorkspaceComposerModal.tsx index a530ec495ca..e6b499a99b7 100644 --- a/src/renderer/src/components/NewWorkspaceComposerModal.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerModal.tsx @@ -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( 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)} diff --git a/src/renderer/src/components/new-workspace/new-workspace-composer-card-props.ts b/src/renderer/src/components/new-workspace/new-workspace-composer-card-props.ts index bcff4c96f63..b7a5acbf8b8 100644 --- a/src/renderer/src/components/new-workspace/new-workspace-composer-card-props.ts +++ b/src/renderer/src/components/new-workspace/new-workspace-composer-card-props.ts @@ -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 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 | null + /** Legacy fixture compatibility; selection is resolved by the modal now. */ + detectedAgentIds?: Set | null onOpenAgentSettings: () => void advancedOpen: boolean onToggleAdvanced: () => void diff --git a/src/renderer/src/lib/quick-workspace-agent-selection.test.ts b/src/renderer/src/lib/quick-workspace-agent-selection.test.ts index 2a9bf259f34..306cd1b455e 100644 --- a/src/renderer/src/lib/quick-workspace-agent-selection.test.ts +++ b/src/renderer/src/lib/quick-workspace-agent-selection.test.ts @@ -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' }) }) diff --git a/src/renderer/src/lib/quick-workspace-agent-selection.ts b/src/renderer/src/lib/quick-workspace-agent-selection.ts index 09801125d0d..625aa64470d 100644 --- a/src/renderer/src/lib/quick-workspace-agent-selection.ts +++ b/src/renderer/src/lib/quick-workspace-agent-selection.ts @@ -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 | null, + selectableAgentIds: Iterable | null, disabledTuiAgents?: Iterable | 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, agent: TuiAgent): boolean { - if (detectedAgentIds instanceof Set) { - return detectedAgentIds.has(agent) +function hasSelectableAgent(selectableAgentIds: Iterable, 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, agent: TuiAgent) function isQuickWorkspaceAgentAvailable( agent: TuiAgent, - detectedAgentIds: Iterable | null, - disabledTuiAgents?: Iterable | null + selectableAgentIds: Iterable | 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 | null - disabledTuiAgents?: Iterable | null + selectableAgentIds: Iterable | 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 } diff --git a/src/renderer/src/lib/workspace-agent-options.test.ts b/src/renderer/src/lib/workspace-agent-options.test.ts new file mode 100644 index 00000000000..488b236118d --- /dev/null +++ b/src/renderer/src/lib/workspace-agent-options.test.ts @@ -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(['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(['claude', 'codex']), + disabledTuiAgents, + localAgentCatalog: snapshot() + }) + + expect(options.some((option) => option.id === CUSTOM_CODEX)).toBe(false) + } + }) +}) diff --git a/src/renderer/src/lib/workspace-agent-options.ts b/src/renderer/src/lib/workspace-agent-options.ts new file mode 100644 index 00000000000..89b88dcf564 --- /dev/null +++ b/src/renderer/src/lib/workspace-agent-options.ts @@ -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 | 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 + ) +} diff --git a/tests/e2e/custom-agent-annotations.spec.ts b/tests/e2e/custom-agent-annotations.spec.ts new file mode 100644 index 00000000000..3f959719cd1 --- /dev/null +++ b/tests/e2e/custom-agent-annotations.spec.ts @@ -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 " 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') +}) diff --git a/tests/e2e/custom-agent-authoring-to-launch.spec.ts b/tests/e2e/custom-agent-authoring-to-launch.spec.ts new file mode 100644 index 00000000000..94ae372cf5c --- /dev/null +++ b/tests/e2e/custom-agent-authoring-to-launch.spec.ts @@ -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 { + 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') +}) diff --git a/tests/e2e/custom-agent-git-actions.spec.ts b/tests/e2e/custom-agent-git-actions.spec.ts new file mode 100644 index 00000000000..babd103d4da --- /dev/null +++ b/tests/e2e/custom-agent-git-actions.spec.ts @@ -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: }. +// 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[0], + options: { + actionId: string + expectedArgs: string + launchSource: Parameters[1]['launchSource'] + } +): Promise { + 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' + }) +}) diff --git a/tests/e2e/custom-agent-launch.spec.ts b/tests/e2e/custom-agent-launch.spec.ts index a5a9b83e623..1bc5d475c15 100644 --- a/tests/e2e/custom-agent-launch.spec.ts +++ b/tests/e2e/custom-agent-launch.spec.ts @@ -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 }) => { diff --git a/tests/e2e/custom-agent-new-workspace.spec.ts b/tests/e2e/custom-agent-new-workspace.spec.ts new file mode 100644 index 00000000000..3b14dca966b --- /dev/null +++ b/tests/e2e/custom-agent-new-workspace.spec.ts @@ -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) +}) diff --git a/tests/e2e/custom-agent-settings-authoring.spec.ts b/tests/e2e/custom-agent-settings-authoring.spec.ts new file mode 100644 index 00000000000..f6681b92ecf --- /dev/null +++ b/tests/e2e/custom-agent-settings-authoring.spec.ts @@ -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 { + 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) + }) +}) diff --git a/tests/e2e/helpers/custom-agent-e2e.ts b/tests/e2e/helpers/custom-agent-e2e.ts new file mode 100644 index 00000000000..8b87b6b810a --- /dev/null +++ b/tests/e2e/helpers/custom-agent-e2e.ts @@ -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 `: 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 + 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 { + 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 { + 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 } + ) +} diff --git a/tests/e2e/ssh-custom-agent-surfaces.spec.ts b/tests/e2e/ssh-custom-agent-surfaces.spec.ts new file mode 100644 index 00000000000..fd1c77039c9 --- /dev/null +++ b/tests/e2e/ssh-custom-agent-surfaces.spec.ts @@ -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 `: 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 `, 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) + } + }) +})