diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx index ec087422f70..a5592bb9fc0 100644 --- a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx @@ -18,7 +18,7 @@ const mocks = vi.hoisted(() => ({ onSaveAgentDefault: vi.fn(), onLaunched: vi.fn(), onStart: vi.fn(), - planSourceControlAgentActionLaunch: vi.fn(), + checkSourceControlAgentActionLaunch: vi.fn(), toastError: vi.fn() })) vi.mock('@/components/agent/AgentCombobox', () => ({ @@ -51,8 +51,8 @@ vi.mock('@/components/ui/select', () => ({ vi.mock('../source-control/SourceControlActionVariableChips', () => ({ SourceControlActionVariableChips: () => React.createElement('div') })) -vi.mock('@/lib/source-control-agent-action-plan', () => ({ - planSourceControlAgentActionLaunch: mocks.planSourceControlAgentActionLaunch +vi.mock('@/lib/source-control-agent-action-launch-check', () => ({ + checkSourceControlAgentActionLaunch: mocks.checkSourceControlAgentActionLaunch })) vi.mock('sonner', () => ({ toast: { error: mocks.toastError } @@ -181,12 +181,7 @@ describe('SourceControlAgentActionDialog', () => { mocks.ensureDetectedAgents.mockResolvedValue(['codex']) mocks.ensureRemoteDetectedAgents.mockResolvedValue(['codex']) mocks.onStart.mockResolvedValue(true) - mocks.planSourceControlAgentActionLaunch.mockReturnValue({ - ok: true, - summary: 'Ready to launch.', - commandLabel: 'codex', - caveat: 'The prompt will be submitted after the agent is ready.' - }) + mocks.checkSourceControlAgentActionLaunch.mockReturnValue({ ok: true }) container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx index dcaf2caf584..cc0a2707de9 100644 --- a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx @@ -1,13 +1,5 @@ import React from 'react' -import { - CheckCircle2, - Info, - RefreshCw, - RotateCcw, - Settings, - Sparkles, - TriangleAlert -} from 'lucide-react' +import { Info, RefreshCw, RotateCcw, Settings, Sparkles, TriangleAlert } from 'lucide-react' import AgentCombobox from '@/components/agent/AgentCombobox' import { Button } from '@/components/ui/button' import { DialogFooter } from '@/components/ui/dialog' @@ -32,9 +24,9 @@ import { sourceControlActionRecipeMatchesTarget } from './source-control-action- import type { SourceControlAgentScopeNote } from './source-control-agent-action-dialog-result' import { translate } from '@/i18n/i18n' +/** What the dialog shows before it starts an agent: only an error the user can fix. */ export type SourceControlAgentActionDeliveryPlanState = | { status: 'idle' } - | { status: 'success'; summary: string; commandLabel: string; caveat: string } | { status: 'error'; error: string } type SourceControlAgentActionDialogFormProps = { @@ -345,36 +337,12 @@ export function SourceControlAgentActionDialogForm({ ) : null} - {deliveryPlan.status !== 'idle' ? ( -
- {deliveryPlan.status === 'error' ? ( - - - {deliveryPlan.error} - - ) : ( -
-
- - {deliveryPlan.summary} -
-
- {translate( - 'auto.components.right.sidebar.SourceControlAgentActionDialogForm.1bc0bdbb5e', - 'Launch:' - )}{' '} - {deliveryPlan.commandLabel} -
-
{deliveryPlan.caveat}
-
- )} + {deliveryPlan.status === 'error' ? ( +
+ + + {deliveryPlan.error} +
) : null}
diff --git a/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts b/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts index d8320dd1075..cca65a8bb5c 100644 --- a/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts +++ b/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts @@ -1,28 +1,25 @@ -import { planSourceControlAgentActionLaunch } from '@/lib/source-control-agent-action-plan' +import { checkSourceControlAgentActionLaunch } from '@/lib/source-control-agent-action-launch-check' import { useAppStore } from '@/store' import type { TuiAgent } from '../../../../shared/tui-agent' import type { SourceControlAgentActionDeliveryPlanState } from './SourceControlAgentActionDialogForm' import { buildSourceControlAgentConnectionErrorPlan } from './source-control-agent-action-dialog-support' -import { resolveInitialNativeChatSessionOptions } from '@/components/native-chat/native-chat-launch-session-options' type BuildSourceControlAgentDeliveryPlanArgs = { selectedAgent: TuiAgent | null commandInput: string agentArgs?: string | undefined - promptDelivery: 'auto-submit' | 'draft' | 'submit-after-ready' detectedAgents: TuiAgent[] connectionUnavailable: boolean launchPlatform?: NodeJS.Platform - /** Why: keep the previewed command label in sync with the real remote launch, - * which omits the Linux-only `orca-ide` rename for SSH hosts. */ + /** Why: SSH remotes run the plain `orca` shim, so the check builds the command they would. */ isRemote?: boolean } +/** The dialog's check before it starts an agent: an error the user can fix, else nothing to show. */ export function buildSourceControlAgentDeliveryPlan({ selectedAgent, commandInput, agentArgs, - promptDelivery, detectedAgents, connectionUnavailable, launchPlatform, @@ -32,19 +29,10 @@ export function buildSourceControlAgentDeliveryPlan({ return buildSourceControlAgentConnectionErrorPlan() } const settings = useAppStore.getState().settings - const result = planSourceControlAgentActionLaunch({ + const result = checkSourceControlAgentActionLaunch({ agent: selectedAgent, commandInput, agentArgs, - sessionOptions: selectedAgent - ? resolveInitialNativeChatSessionOptions(settings, { - agent: selectedAgent, - promptDelivery, - launchDraftText: commandInput.trim(), - nativeChatTranscriptIsLocalReadable: !isRemote - }) - : undefined, - promptDelivery, detectedAgents, disabledAgents: settings?.disabledTuiAgents, cmdOverrides: settings?.agentCmdOverrides, @@ -52,13 +40,5 @@ export function buildSourceControlAgentDeliveryPlan({ platform: launchPlatform, isRemote }) - if (!result.ok) { - return { status: 'error', error: result.error } - } - return { - status: 'success', - summary: result.summary, - commandLabel: result.commandLabel, - caveat: result.caveat - } + return result.ok ? { status: 'idle' } : { status: 'error', error: result.error } } diff --git a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts index 2209c1621fe..4ca1519080c 100644 --- a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts +++ b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts @@ -110,9 +110,8 @@ export function useSourceControlAgentActionStart({ return buildSourceControlAgentDeliveryPlan({ selectedAgent, commandInput, - // Why: the previewed command must show what the launch will really apply. + // Why: the check must judge the arguments the launch will really apply. agentArgs: agentArgsApply ? agentArgs : undefined, - promptDelivery, detectedAgents: currentDetectedAgents, connectionUnavailable, launchPlatform, @@ -124,7 +123,6 @@ export function useSourceControlAgentActionStart({ agentArgsApply, commandInput, connectionUnavailable, - promptDelivery, refreshDetectedAgents, selectedAgent, launchPlatform, diff --git a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts index e8c0c2caf4f..dfc7f71755a 100644 --- a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts +++ b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts @@ -14,6 +14,7 @@ import { waitAtTerminalPtyPreSpawnE2EBarrier } from './terminal-pty-pre-spawn-e2 import type { IpcPtySessionHandlers } from './ipc-pty-session-handlers' import { isSshSessionGoneError } from './pty-connection/pty-connect-limits' import { spawnIpcPty } from './ipc-pty-spawn-request' +import { agentLaunchPaneSpawnHold } from '@/lib/agent-launch-pane-spawn-hold' import type { IpcPtyTransportOptions, PtyConnectResult, PtyTransport } from './pty-transport-types' const SSH_PTY_CONNECTION_MISMATCH_MARKER = 'belongs to SSH connection' @@ -81,6 +82,14 @@ export async function connectIpcPty( return } } + // A launch pane this window made spawns only once the host has taken it. + const launchPaneHold = agentLaunchPaneSpawnHold(transportOptions.tabId, transportOptions.leafId) + if (launchPaneHold) { + await launchPaneHold + if (context.isDestroyed()) { + return + } + } if (options.shouldContinue && !options.shouldContinue()) { return } diff --git a/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts b/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts index 42171d9368d..4d6b0aa911b 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts @@ -100,6 +100,34 @@ describe('createIpcPtyTransport', () => { expect(spawn).not.toHaveBeenCalled() }) + it("spawns a launch pane this window made only once the host has taken it, and never once it's gone", async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const { holdAgentLaunchPaneSpawn, releaseAgentLaunchPaneSpawn } = + await import('@/lib/agent-launch-pane-spawn-hold') + const spawn = window.api.pty.spawn as unknown as ReturnType + const pane = { worktreeId: 'wt', tabId: 'tab-held', leafId: 'leaf-held' } + + holdAgentLaunchPaneSpawn(pane.tabId, pane.leafId) + const transport = createIpcPtyTransport(pane) + const connecting = transport.connect({ url: '', callbacks: {} }) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(spawn).not.toHaveBeenCalled() + + releaseAgentLaunchPaneSpawn(pane.tabId, pane.leafId) + await connecting + expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ tabId: 'tab-held' })) + transport.disconnect() + + spawn.mockClear() + const release = holdAgentLaunchPaneSpawn(pane.tabId, pane.leafId) + const closed = createIpcPtyTransport(pane) + const abandoned = closed.connect({ url: '', callbacks: {} }) + closed.destroy?.() + release() + await expect(abandoned).resolves.toBeUndefined() + expect(spawn).not.toHaveBeenCalled() + }) + it('keeps the recovery hint and raw diagnostic from a wrapped spawn error', async () => { const { createIpcPtyTransport } = await import('./pty-transport') vi.mocked(window.api.pty.spawn).mockRejectedValueOnce( diff --git a/src/renderer/src/hooks/ipc-events-terminal-create-scenario-types.ts b/src/renderer/src/hooks/ipc-events-terminal-create-scenario-types.ts index 63e82795433..af3ef297368 100644 --- a/src/renderer/src/hooks/ipc-events-terminal-create-scenario-types.ts +++ b/src/renderer/src/hooks/ipc-events-terminal-create-scenario-types.ts @@ -79,7 +79,10 @@ export type TerminalCreateSurfacingStore = { clearAgentLaunchConfig: SpyMock updateTabPtyId: Mock<(tabId: string, ptyId: string) => void> setTabLayout: Mock<(tabId: string, layout: unknown) => void> - tabsByWorktree: Record + tabsByWorktree: Record< + string, + { id: string; ptyId?: string | null; title?: string; agentLaunchPane?: { leafId: string } }[] + > folderWorkspaces: unknown[] projectGroups: unknown[] repos: { id: string; connectionId: string | null; executionHostId: string }[] diff --git a/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts index 368bb66413e..e978865a34e 100644 --- a/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts @@ -1,3 +1,4 @@ +import { releaseAgentLaunchPaneSpawn } from '@/lib/agent-launch-pane-spawn-hold' import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount' import { hasRegisteredRuntimeTerminalTab } from '@/runtime/sync-runtime-graph' import { planMobileTerminalTabMount } from '@/lib/mobile-terminal-tab-mount' @@ -88,6 +89,11 @@ export function registerTerminalPresentationIpcBridge(unsubs: (() => void)[]): v throw new Error(`Terminal tab ${tabId} not found`) } const reusedTab = existingTab ?? splitTargetTab + if (ptyId && tabId && leafId && reusedTab?.agentLaunchPane?.leafId === leafId) { + // A launch pane this window made and the host never showed early: the host now holds it, + // so its spawn may attach instead of waiting out the whole launch. + releaseAgentLaunchPaneSpawn(tabId, leafId) + } const tab = reusedTab ?? (ptyId diff --git a/src/renderer/src/hooks/useIpcEvents-agent-launch-closed-tab.test.ts b/src/renderer/src/hooks/useIpcEvents-agent-launch-closed-tab.test.ts index 1730fb08497..a108deb9b37 100644 --- a/src/renderer/src/hooks/useIpcEvents-agent-launch-closed-tab.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-agent-launch-closed-tab.test.ts @@ -62,3 +62,37 @@ describe('a launch tab while its agent is starting', () => { ) }) }) + +describe('a launch tab this window made, which the host never showed early', () => { + it('takes the agent the host reveals under its own id, and lets its pane attach at once', async () => { + const scenario = await setupTerminalCreateSurfacing(() => false) + const { createTab, updateTabPtyId, createTerminalListenerRef, storeState } = scenario + if (!createTerminalListenerRef.current) { + throw new Error('Expected the create-terminal listener to be registered') + } + Object.assign(window.api, { pty: { kill: vi.fn(async () => {}) } }) + // The harness resets modules; the bridge reads the instance it imported. + const { holdAgentLaunchPaneSpawn, agentLaunchPaneSpawnHold } = + await import('@/lib/agent-launch-pane-spawn-hold') + holdAgentLaunchPaneSpawn('tab-made', LEAF) + storeState.tabsByWorktree['wt-1'] = [ + { id: 'tab-made', ptyId: null, agentLaunchPane: { leafId: LEAF } } + ] + storeState.terminalLayoutsByTabId['tab-made'] = { root: { type: 'leaf', leafId: LEAF } } + const held = agentLaunchPaneSpawnHold('tab-made', LEAF) + + createTerminalListenerRef.current({ + requestId: 'reveal-window-made-launch-tab', + worktreeId: 'wt-1', + ptyId: 'pty-agent', + tabId: 'tab-made', + leafId: LEAF + }) + + // Never a second tab under the same id, and the waiting pane is let go to attach. + expect(createTab).not.toHaveBeenCalled() + expect(updateTabPtyId).toHaveBeenCalledWith('tab-made', 'pty-agent') + await expect(held).resolves.toBeUndefined() + expect(agentLaunchPaneSpawnHold('tab-made', LEAF)).toBeNull() + }) +}) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index d988e3b0019..d8eb86b05d2 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -13057,7 +13057,6 @@ "ea4788705e": "Cancel", "7ec6abbf2a": "Reset", "f4f3c9ca4a": "Prompt template", - "1bc0bdbb5e": "Launch:", "fe119187bb": "--model sonnet", "bc8dc39f4b": "CLI arguments", "b99c33cec5": "Settings", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index e1dbb80d724..44af8babf19 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -12838,7 +12838,6 @@ "ea4788705e": "Cancelar", "7ec6abbf2a": "Reiniciar", "f4f3c9ca4a": "Plantilla de prompt", - "1bc0bdbb5e": "Lanzamiento:", "fe119187bb": "--model sonnet", "bc8dc39f4b": "Argumentos CLI", "b99c33cec5": "Ajustes", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index dbdc7c9b11f..f8d9923227a 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -12943,7 +12943,6 @@ "ea4788705e": "Annuler", "7ec6abbf2a": "Réinitialiser", "f4f3c9ca4a": "Modèle de prompt", - "1bc0bdbb5e": "Lancement :", "fe119187bb": "--model sonnet", "bc8dc39f4b": "Arguments CLI", "b99c33cec5": "Paramètres", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 32b68602e37..a57404afde0 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -12828,7 +12828,6 @@ "38b899cc02": "すべてのリポジトリ" }, "SourceControlAgentActionDialogForm": { - "1bc0bdbb5e": "起動:", "7ec6abbf2a": "リセット", "f4f3c9ca4a": "コマンドテンプレート", "fe119187bb": "--model sonnet", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index c8576f0cc5d..0ba0c0adef0 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -12828,7 +12828,6 @@ "38b899cc02": "모든 리포지토리" }, "SourceControlAgentActionDialogForm": { - "1bc0bdbb5e": "실행:", "7ec6abbf2a": "재설정", "f4f3c9ca4a": "프롬프트 템플릿", "fe119187bb": "--model sonnet", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index f9f16bd79b0..cd6971d81a1 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -12828,7 +12828,6 @@ "38b899cc02": "所有仓库" }, "SourceControlAgentActionDialogForm": { - "1bc0bdbb5e": "启动:", "7ec6abbf2a": "重置", "f4f3c9ca4a": "提示词模板", "fe119187bb": "--model sonnet", diff --git a/src/renderer/src/lib/agent-launch-caller-permissions.test.ts b/src/renderer/src/lib/agent-launch-caller-permissions.test.ts index 0c1b3066f17..dfc416260f8 100644 --- a/src/renderer/src/lib/agent-launch-caller-permissions.test.ts +++ b/src/renderer/src/lib/agent-launch-caller-permissions.test.ts @@ -10,10 +10,12 @@ import { } from './agent-launch-caller-profiles-test-harness' import { createLaunchFunnelStore, + hostLaunchRequest, queuedStartupCommand, queuedStartupPayload, resetLaunchFunnelStore } from './agent-launch-funnel-test-harness' +import { newTabPromptLaunchesThroughHost } from './launch-agent-new-tab-host-route' const store = createLaunchFunnelStore() @@ -45,6 +47,31 @@ vi.mock('@/lib/agent-ready-wait', () => ({ vi.mock('@/runtime/local-runtime-capabilities', () => ({ readLocalRuntimeCapabilitiesOrUnknown: () => [] })) +// A launch the host delivers waits on its reply; these tests read only what was sent. +const callRuntimeRpc = vi.hoisted(() => vi.fn(() => new Promise(() => {}))) +vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc, RuntimeRpcCallError: Error })) + +function launchesThroughHost(profile: AgentLaunchCallerProfile): boolean { + return newTabPromptLaunchesThroughHost({ + promptDelivery: profile.args.promptDelivery ?? 'auto-submit', + pastesPrompt: (profile.args.prompt?.trim() ?? '').length > 0 + }) +} + +/** + * A call site whose prompt the host delivers states its arguments in the request, and the host + * builds the command from them by the same rule (its startup plan inputs); absent means the + * setting, shipped bypass default included. + */ +function expectHostRequestArguments(profile: AgentLaunchCallerProfile): void { + const request = hostLaunchRequest(callRuntimeRpc) + expect(queuedStartupCommand(store)).toBeUndefined() + if (profile.args.agentArgs === undefined) { + expect(request).not.toHaveProperty('agentArgs') + } else { + expect(request?.agentArgs).toBe(profile.args.agentArgs) + } +} const CODEX_BYPASS = '--dangerously-bypass-approvals-and-sandbox' @@ -73,6 +100,10 @@ describe('agent launch caller arguments and permission bypass', () => { it.each(cases)('puts %s on the command line its own arguments describe', async (_id, profile) => { await launch(profile) + if (launchesThroughHost(profile)) { + expectHostRequestArguments(profile) + return + } const command = queuedStartupCommand(store) expect(command).toBeDefined() @@ -87,6 +118,10 @@ describe('agent launch caller arguments and permission bypass', () => { it.each(cases)('keeps %s on the bypass posture its arguments encode', async (_id, profile) => { await launch(profile) + if (launchesThroughHost(profile)) { + expectHostRequestArguments(profile) + return + } const command = queuedStartupCommand(store) ?? '' // Why: the three recipe-driven call sites hand in saved arguments, which REPLACE the shipped @@ -99,6 +134,10 @@ describe('agent launch caller arguments and permission bypass', () => { 'forwards an explicit argument override from %s to the tab', async (_id, profile) => { await launch(profile) + if (launchesThroughHost(profile)) { + expectHostRequestArguments(profile) + return + } const payload = queuedStartupPayload(store) if (profile.args.agentArgs === undefined) { @@ -221,7 +260,7 @@ describe('agent launch caller arguments and permission bypass', () => { worktreeId: 'wt-1' }) - expect(result?.startupPlan.sessionOptions).toEqual({ + expect(result?.startupPlan?.sessionOptions).toEqual({ model: 'gpt-5.2-codex', effort: 'medium' }) @@ -246,7 +285,7 @@ describe('agent launch caller arguments and permission bypass', () => { worktreeId: 'wt-1' }) - expect(result?.startupPlan.sessionOptions).toBeUndefined() + expect(result?.startupPlan?.sessionOptions).toBeUndefined() expect(queuedStartupCommand(store)).not.toContain("'-m'") }) }) diff --git a/src/renderer/src/lib/agent-launch-caller-placement.test.ts b/src/renderer/src/lib/agent-launch-caller-placement.test.ts index e20414efc5c..3e070ee854a 100644 --- a/src/renderer/src/lib/agent-launch-caller-placement.test.ts +++ b/src/renderer/src/lib/agent-launch-caller-placement.test.ts @@ -11,9 +11,11 @@ import { createdTabGroupId, createdTabOptions, createLaunchFunnelStore, + hostLaunchRequest, queuedStartupPayload, resetLaunchFunnelStore } from './agent-launch-funnel-test-harness' +import { newTabPromptLaunchesThroughHost } from './launch-agent-new-tab-host-route' const store = createLaunchFunnelStore() @@ -45,6 +47,16 @@ vi.mock('@/lib/agent-ready-wait', () => ({ vi.mock('@/runtime/local-runtime-capabilities', () => ({ readLocalRuntimeCapabilitiesOrUnknown: () => [] })) +// A launch the host delivers waits on its reply; these tests read only what was sent. +const callRuntimeRpc = vi.hoisted(() => vi.fn(() => new Promise(() => {}))) +vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc, RuntimeRpcCallError: Error })) + +function launchesThroughHost(profile: AgentLaunchCallerProfile): boolean { + return newTabPromptLaunchesThroughHost({ + promptDelivery: profile.args.promptDelivery ?? 'auto-submit', + pastesPrompt: (profile.args.prompt?.trim() ?? '').length > 0 + }) +} const cases = callerProfileCases() @@ -109,6 +121,12 @@ describe('agent launch caller placement and telemetry', () => { async (_id, profile) => { await launch(profile) + if (launchesThroughHost(profile)) { + // The host starts the agent where the request names, so nothing waits on the tab. + expect(store.queueTabInitialCwd).not.toHaveBeenCalled() + expect(hostLaunchRequest(callRuntimeRpc)?.cwd).toBe(profile.args.initialCwd) + return + } if (profile.args.initialCwd) { expect(store.queueTabInitialCwd).toHaveBeenCalledExactlyOnceWith( 'tab-1', @@ -127,6 +145,15 @@ describe('agent launch caller placement and telemetry', () => { it.each(cases)('stamps the launch %s started with its telemetry source', async (_id, profile) => { await launch(profile) + if (launchesThroughHost(profile)) { + // The host stamps `agent_started` from the request; the window queues no command of its own. + expect(queuedStartupPayload(store)).toBeUndefined() + expect(hostLaunchRequest(callRuntimeRpc)).toMatchObject({ + agent: profile.args.agent, + launchSource: profile.args.launchSource ?? 'tab_bar_quick_launch' + }) + return + } expect(queuedStartupPayload(store)?.telemetry).toEqual({ agent_kind: `kind:${profile.args.agent}`, // git-history-explain-commit names no source, so it reports as a tab-bar quick launch. diff --git a/src/renderer/src/lib/agent-launch-caller-prompt-transport.test.ts b/src/renderer/src/lib/agent-launch-caller-prompt-transport.test.ts index ed8d689c90d..3f3acb5e6d4 100644 --- a/src/renderer/src/lib/agent-launch-caller-prompt-transport.test.ts +++ b/src/renderer/src/lib/agent-launch-caller-prompt-transport.test.ts @@ -51,6 +51,20 @@ vi.mock('@/lib/agent-ready-wait', () => ({ vi.mock('@/runtime/local-runtime-capabilities', () => ({ readLocalRuntimeCapabilitiesOrUnknown: () => [] })) +// The host starts an AI button's agent in the tab's pane and answers; the window then pastes. +const callRuntimeRpc = vi.hoisted(() => + vi.fn(async (_target: unknown, _method: string, params: Record) => ({ + outcome: { kind: 'terminal', handle: 'term_1', paneKey: params.paneKey }, + worktreeId: 'wt-1', + receipt: { mode: 'terminal', preferred: 'terminal', reason: 'user_default', detail: 'x' } + })) +) +vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc, RuntimeRpcCallError: Error })) + +/** What the window's own launch command carries; a launch started by the host queues none. */ +function commandCarries(text: string): boolean { + return queuedStartupCommand(store)?.includes(text) ?? false +} const PROMPT = 'Explain the failing check and propose a fix.' @@ -147,14 +161,18 @@ describe('agent launch caller prompt transport', () => { if (profile.args.prompt === undefined) { expect(result?.pasteDraftAfterLaunch).toBe(false) expect(mockPasteDraftWhenAgentReady).not.toHaveBeenCalled() - expect(queuedStartupCommand(store)).not.toContain(PROMPT) + expect(commandCarries(PROMPT)).toBe(false) return } // quick-command is the only prompt-carrying call site that names no delivery mode, so its text // rides argv; every other one asks for draft or submit-after-ready and pastes. const ridesArgv = id === 'quick-command' expect(result?.pasteDraftAfterLaunch).toBe(!ridesArgv) - expect(queuedStartupCommand(store)?.includes(PROMPT)).toBe(ridesArgv) + expect(commandCarries(PROMPT)).toBe(ridesArgv) + // The host is never handed the text: the window pastes it, as main does. + for (const [, , params] of callRuntimeRpc.mock.calls) { + expect(params).not.toHaveProperty('prompt') + } } ) @@ -211,12 +229,15 @@ describe('agent launch caller prompt transport', () => { }) expect(result?.pasteDraftAfterLaunch).toBe(row.transport === 'paste') - expect(queuedStartupCommand(store)?.includes(PROMPT)).toBe(row.transport === 'argv') + expect(commandCarries(PROMPT)).toBe(row.transport === 'argv') if (row.transport === 'paste') { - expect(mockPasteDraftWhenAgentReady.mock.calls[0]?.[0]).toMatchObject({ - content: PROMPT, - submit: row.submits - }) + // Through the host, the paste waits for the agent to be started in the tab. + await vi.waitFor(() => + expect(mockPasteDraftWhenAgentReady.mock.calls[0]?.[0]).toMatchObject({ + content: PROMPT, + submit: row.submits + }) + ) } else { expect(mockPasteDraftWhenAgentReady).not.toHaveBeenCalled() } diff --git a/src/renderer/src/lib/agent-launch-funnel-test-harness.ts b/src/renderer/src/lib/agent-launch-funnel-test-harness.ts index 8292a054cb6..c1fc80cafa2 100644 --- a/src/renderer/src/lib/agent-launch-funnel-test-harness.ts +++ b/src/renderer/src/lib/agent-launch-funnel-test-harness.ts @@ -108,3 +108,10 @@ export function createdTabOptions(store: LaunchFunnelStore): Record +): Record | undefined { + return callRuntimeRpc.mock.calls.find(([, method]) => method === 'agent.launchReplay')?.[2] +} diff --git a/src/renderer/src/lib/agent-launch-pane-spawn-hold.test.ts b/src/renderer/src/lib/agent-launch-pane-spawn-hold.test.ts new file mode 100644 index 00000000000..bcbc87d7e9a --- /dev/null +++ b/src/renderer/src/lib/agent-launch-pane-spawn-hold.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { + agentLaunchPaneSpawnHold, + holdAgentLaunchPaneSpawn, + isAgentLaunchPaneSpawnHeld, + releaseAgentLaunchPaneSpawn +} from './agent-launch-pane-spawn-hold' + +const TAB = '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d' +const LEAF = '3f2504e0-4f89-41d3-9a0c-0305e82c3301' + +describe('a launch pane this window made', () => { + it('holds its spawn until the host takes the pane', async () => { + holdAgentLaunchPaneSpawn(TAB, LEAF) + const held = agentLaunchPaneSpawnHold(TAB, LEAF) + let spawned = false + void held?.then(() => { + spawned = true + }) + await Promise.resolve() + expect(spawned).toBe(false) + expect(isAgentLaunchPaneSpawnHeld(TAB, LEAF)).toBe(true) + + expect(releaseAgentLaunchPaneSpawn(TAB, LEAF)).toBe(true) + await held + expect(spawned).toBe(true) + expect(isAgentLaunchPaneSpawnHeld(TAB, LEAF)).toBe(false) + expect(releaseAgentLaunchPaneSpawn(TAB, LEAF)).toBe(false) + }) + + it('ends the hold when the launch is over, and a second end is harmless', async () => { + const release = holdAgentLaunchPaneSpawn(TAB, LEAF) + const held = agentLaunchPaneSpawnHold(TAB, LEAF) + release() + release() + await expect(held).resolves.toBeUndefined() + expect(agentLaunchPaneSpawnHold(TAB, LEAF)).toBeNull() + }) + + it('holds nothing for any other pane', () => { + const release = holdAgentLaunchPaneSpawn(TAB, LEAF) + expect(agentLaunchPaneSpawnHold(TAB, 'another-leaf')).toBeNull() + expect(agentLaunchPaneSpawnHold(undefined, LEAF)).toBeNull() + release() + }) +}) diff --git a/src/renderer/src/lib/agent-launch-pane-spawn-hold.ts b/src/renderer/src/lib/agent-launch-pane-spawn-hold.ts new file mode 100644 index 00000000000..4278d413b7e --- /dev/null +++ b/src/renderer/src/lib/agent-launch-pane-spawn-hold.ts @@ -0,0 +1,58 @@ +/** + * Launch panes this window made before asking the host to launch into them. + * + * Such a pane must not spawn until the host has taken it: before that, main knows of no launch for + * it and would give it a shell, which the host's own spawn would then be refused over. The host + * takes the pane when it asks this window to show the launch's tab, so the hold ends there, or when + * the launch is over. Session-only: after a reload the pane's spawn reads the launch record like any + * launch pane. + */ + +type Hold = { released: Promise; release: () => void } + +const holds = new Map() + +// Not `makePaneKey`: that validates the leaf id, and a launch must never fail over bookkeeping. +function holdKey(tabId: string, leafId: string): string { + return JSON.stringify([tabId, leafId]) +} + +/** Holds the pane's spawn; the returned function ends the hold and is safe to call twice. */ +export function holdAgentLaunchPaneSpawn(tabId: string, leafId: string): () => void { + const key = holdKey(tabId, leafId) + let release!: () => void + const hold: Hold = { + released: new Promise((resolve) => { + release = resolve + }), + release: () => { + if (holds.get(key) === hold) { + holds.delete(key) + } + release() + } + } + holds.get(key)?.release() + holds.set(key, hold) + return hold.release +} + +/** The host has taken the pane: its spawn now waits in main for the host's agent. */ +export function releaseAgentLaunchPaneSpawn(tabId: string, leafId: string): boolean { + const hold = holds.get(holdKey(tabId, leafId)) + hold?.release() + return hold !== undefined +} + +/** What the pane's spawn awaits first, or null when nothing holds it. */ +export function agentLaunchPaneSpawnHold( + tabId: string | undefined, + leafId: string | undefined +): Promise | null { + return tabId && leafId ? (holds.get(holdKey(tabId, leafId))?.released ?? null) : null +} + +/** Still held: the host has not taken the pane. */ +export function isAgentLaunchPaneSpawnHeld(tabId: string, leafId: string): boolean { + return holds.has(holdKey(tabId, leafId)) +} diff --git a/src/renderer/src/lib/agent-launch-prompt-delivery.ts b/src/renderer/src/lib/agent-launch-prompt-delivery.ts index ad9e1f1d8c8..50b0a879680 100644 --- a/src/renderer/src/lib/agent-launch-prompt-delivery.ts +++ b/src/renderer/src/lib/agent-launch-prompt-delivery.ts @@ -35,23 +35,38 @@ export function deliverLaunchPromptToAgentTab(args: { onTimeout?: () => void /** The paste was written without ever observing the agent's composer. */ onUnconfirmedDelivery?: () => void + /** Whether the paste may be written; the chat copy is seeded only once it opens. */ + sendGate?: Promise }): Promise { const { tabId, agent, content, submit, forcePaste, timeoutMs, onTimeout, onUnconfirmedDelivery } = args const shouldSeed = submit === true && content.trim().length > 0 && isNativeChatSupportedAgent(agent) - - if (shouldSeed) { - useAppStore.getState().seedNativeChatLaunchPrompt({ - tabId, - agent, - text: content, - createdAt: Date.now() - }) - } else if (submit !== true) { - // Why: an unsubmitted draft lives only in the TUI input buffer; seed the - // chat-composer copy so the context isn't invisible in the GUI view. - seedNativeChatLaunchDraftForAgentTab({ tabId, agent, text: content }) + let seeded = false + const seedChatCopy = (): void => { + if (shouldSeed) { + seeded = true + useAppStore.getState().seedNativeChatLaunchPrompt({ + tabId, + agent, + text: content, + createdAt: Date.now() + }) + } else if (submit !== true) { + // Why: an unsubmitted draft lives only in the TUI input buffer; seed the + // chat-composer copy so the context isn't invisible in the GUI view. + seedNativeChatLaunchDraftForAgentTab({ tabId, agent, text: content }) + } + } + // Chained ahead of the paste's own wait on the gate, so the copy always precedes the send. + const sendGate = args.sendGate?.then((open) => { + if (open) { + seedChatCopy() + } + return open + }) + if (!sendGate) { + seedChatCopy() } // Why: native-prefill agents (claude/openclaude etc.) get the prompt at launch, @@ -67,16 +82,17 @@ export function deliverLaunchPromptToAgentTab(args: { forcePaste, timeoutMs, onTimeout, - onUnconfirmedDelivery + onUnconfirmedDelivery, + ...(sendGate ? { sendGate } : {}) }).then( (delivered) => { - if (shouldSeed && !delivered && !deliversViaNativePrefill) { + if (seeded && !delivered && !deliversViaNativePrefill) { useAppStore.getState().markNativeChatLaunchPromptFailed(tabId) } return delivered || deliversViaNativePrefill }, (error) => { - if (shouldSeed && !deliversViaNativePrefill) { + if (seeded && !deliversViaNativePrefill) { useAppStore.getState().markNativeChatLaunchPromptFailed(tabId) } throw error diff --git a/src/renderer/src/lib/agent-launch-tab-publication.test.ts b/src/renderer/src/lib/agent-launch-tab-publication.test.ts index 183c4d95eb0..8d9ec4c3f0b 100644 --- a/src/renderer/src/lib/agent-launch-tab-publication.test.ts +++ b/src/renderer/src/lib/agent-launch-tab-publication.test.ts @@ -122,6 +122,27 @@ describe('publishing a launch tab before its agent exists', () => { expect(launchPane()).toEqual({ leafId: LEAF_ID, operationId: 'op-2' }) }) + it('hands a tab this window made for the launch to the host, as the launch to take back', async () => { + const { holdAgentLaunchPaneSpawn, agentLaunchPaneSpawnHold } = + await import('./agent-launch-pane-spawn-hold') + holdAgentLaunchPaneSpawn(TAB_ID, LEAF_ID) + store.getState().createTab(WT, undefined, undefined, { + id: TAB_ID, + initialLeafId: LEAF_ID, + agentLaunchPane: { leafId: LEAF_ID } + }) + const held = agentLaunchPaneSpawnHold(TAB_ID, LEAF_ID) + + const published = publishAgentLaunchTab(request()) + + // The host now owns the pane, so a refused launch takes the tab back rather than leaving a shell. + expect(published).toMatchObject({ tabId: TAB_ID, created: true }) + await expect(held).resolves.toBeUndefined() + expect(agentLaunchPaneSpawnHold(TAB_ID, LEAF_ID)).toBeNull() + // A retry finds no hold: the tab is no longer only this launch's. + expect(publishAgentLaunchTab(request({ requestId: 'request-2' })).created).toBe(false) + }) + it("remounts a pane that showed an earlier launch's outcome, so it spawns for the new launch", () => { const generation = () => store.getState().tabsByWorktree[WT]?.find((tab) => tab.id === TAB_ID)?.generation ?? 0 diff --git a/src/renderer/src/lib/agent-launch-tab-publication.ts b/src/renderer/src/lib/agent-launch-tab-publication.ts index ac678d981a8..8ec7279e747 100644 --- a/src/renderer/src/lib/agent-launch-tab-publication.ts +++ b/src/renderer/src/lib/agent-launch-tab-publication.ts @@ -20,6 +20,7 @@ import { useAppStore } from '../store' import { resolveUnifiedTabCreatePlacement } from '../store/slices/tabs/tabs-create-placement' import { insertUnifiedTabAfterAnchor } from './unified-tab-anchor-insertion' import { rememberAgentLaunchPanePrompt } from './agent-launch-pane-prompt' +import { releaseAgentLaunchPaneSpawn } from './agent-launch-pane-spawn-hold' function landedGroupId(worktreeId: string, tabId: string): string | undefined { return useAppStore @@ -79,7 +80,9 @@ export function publishAgentLaunchTab( remount: kept?.leafId === leafId && kept.outcome !== undefined }) } - return { tabId, created: false, placement: { groupId } } + // A tab this window made for this launch is the launch's to take back if it never runs. + const madeForThisLaunch = releaseAgentLaunchPaneSpawn(tabId, leafId) + return { tabId, created: madeForThisLaunch, placement: { groupId } } } const placement = resolveUnifiedTabCreatePlacement({ diff --git a/src/renderer/src/lib/agent-launch-through-host.test.ts b/src/renderer/src/lib/agent-launch-through-host.test.ts new file mode 100644 index 00000000000..1967e6d8097 --- /dev/null +++ b/src/renderer/src/lib/agent-launch-through-host.test.ts @@ -0,0 +1,243 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as AgentStatusModule from '@/lib/agent-status' +import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result' +import { createTabsSliceMockApi } from '../store/slices/tabs-slice-test-harness' +import { createTestStore } from '../store/slices/store-test-helpers' + +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) +vi.mock('@/lib/agent-status', async (importOriginal) => ({ + ...(await importOriginal()), + detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) +})) + +const testStore = vi.hoisted(() => { + const ref: { current: ReturnType | null } = { current: null } + return ref +}) +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => { + if (!testStore.current) { + throw new Error('no test store') + } + return testStore.current.getState() + } + } +})) +const callRuntimeRpc = vi.hoisted(() => + vi.fn<(target: unknown, method: string, params: Record) => Promise>() +) +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => ({ + ...(await importOriginal()), + callRuntimeRpc +})) + +createTabsSliceMockApi() + +const { launchAgentThroughHost } = await import('./agent-launch-through-host') +const { agentLaunchPaneSpawnHold, releaseAgentLaunchPaneSpawn } = + await import('./agent-launch-pane-spawn-hold') +const { agentLaunchPanePrompt } = await import('./agent-launch-pane-prompt') + +const WT = 'repo1::/tmp/feature' +let store: ReturnType + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +function rpcError(code: string): RuntimeRpcCallError { + return new RuntimeRpcCallError({ + id: 'desktop-ipc', + ok: false, + error: { code, message: code }, + _meta: { runtimeId: 'runtime-1' } + }) +} + +function terminalResult(paneKey: string) { + return { + outcome: { kind: 'terminal', handle: 'term_1', paneKey }, + worktreeId: WT, + receipt: { mode: 'terminal', preferred: 'terminal', reason: 'user_default', detail: 'x' } + } +} + +function launchTab(tabId: string) { + return store.getState().tabsByWorktree[WT]?.find((tab) => tab.id === tabId) +} + +function lastParams(): Record { + return callRuntimeRpc.mock.calls.at(-1)?.[2] ?? {} +} + +function lastPaneKey(): string { + return String(lastParams().paneKey) +} + +function launch() { + return launchAgentThroughHost({ + agent: 'claude', + worktreeId: WT, + groupId: store.getState().activeGroupIdByWorktree[WT], + prompt: 'fix the failing checks', + agentArgs: null, + launchSource: 'source_control_recovery' + }) +} + +/** What the host does first: it takes the pane, by asking this window to show the tab. */ +function hostTakesPane(tabId: string): void { + const tab = launchTab(tabId)! + releaseAgentLaunchPaneSpawn(tab.id, tab.agentLaunchPane!.leafId) +} + +beforeEach(() => { + store = createTestStore() + testStore.current = store + callRuntimeRpc.mockReset() + store.getState().setActiveWorktree(WT) + store.getState().createUnifiedTab(WT, 'terminal') +}) + +describe('a desktop launch through the host', () => { + it('shows its tab at the click, in its split, waiting for the host before it spawns', () => { + const reply = deferred() + callRuntimeRpc.mockReturnValue(reply.promise) + + const { tabId } = launch() + + const tab = launchTab(tabId)! + expect(tab).toMatchObject({ ptyId: null, launchAgent: 'claude' }) + const leafId = tab.agentLaunchPane!.leafId + expect(agentLaunchPaneSpawnHold(tab.id, leafId)).not.toBeNull() + expect(agentLaunchPanePrompt(tab.id)).toBe('fix the failing checks') + expect(store.getState().activeTabId).toBe(tabId) + // No prompt: the window pastes it, as main does, once the agent runs. + expect(callRuntimeRpc).toHaveBeenCalledWith({ kind: 'local' }, 'agent.launchReplay', { + agent: 'claude', + target: { kind: 'existing', worktree: `id:${WT}` }, + agentArgs: null, + launchSource: 'source_control_recovery', + placement: { groupId: store.getState().activeGroupIdByWorktree[WT] }, + presentation: 'focused', + operationId: expect.stringMatching(/^\d+-[0-9a-f]{32}$/), + paneKey: `${tabId}:${leafId}` + }) + }) + + it('names every click as its own operation', () => { + callRuntimeRpc.mockReturnValue(new Promise(() => {})) + launch() + const first = lastParams() + launch() + const second = lastParams() + expect(second.operationId).not.toBe(first.operationId) + expect(second.paneKey).not.toBe(first.paneKey) + }) + + it('says the agent started only once the host answers, with its pane attached', async () => { + const reply = deferred() + callRuntimeRpc.mockReturnValue(reply.promise) + const { tabId, outcome } = launch() + const settled = vi.fn() + void outcome.then(settled) + hostTakesPane(tabId) + await Promise.resolve() + expect(settled).not.toHaveBeenCalled() + + reply.resolve(terminalResult(lastPaneKey())) + + await expect(outcome).resolves.toEqual({ kind: 'started' }) + expect(launchTab(tabId)).toBeDefined() + }) + + it('takes its tab back on a refusal, before the pane ever spawns', async () => { + const reply = deferred() + callRuntimeRpc.mockReturnValue(reply.promise) + const { tabId, outcome } = launch() + + reply.reject(rpcError('agent_session_operation_conflict')) + + await expect(outcome).resolves.toEqual({ + kind: 'not-started', + unconfirmed: false, + code: 'agent_session_operation_conflict' + }) + expect(launchTab(tabId)).toBeUndefined() + }) + + it('leaves a launch the host took to its pane, which says how it ended', async () => { + const reply = deferred() + callRuntimeRpc.mockReturnValue(reply.promise) + const { tabId, outcome } = launch() + hostTakesPane(tabId) + + reply.reject(rpcError('agent_session_operation_unknown')) + + await expect(outcome).resolves.toEqual({ kind: 'pane-says' }) + expect(launchTab(tabId)).toBeDefined() + }) + + it('never leaves a pane the host did not take, which would open as a shell', async () => { + const reply = deferred() + callRuntimeRpc.mockReturnValue(reply.promise) + const { tabId, outcome } = launch() + + reply.reject(rpcError('worktree_not_found')) + + await expect(outcome).resolves.toEqual({ + kind: 'not-started', + unconfirmed: false, + code: 'worktree_not_found' + }) + expect(launchTab(tabId)).toBeUndefined() + }) + + // Why: the close was the user's own, as on the phone's "+". + it('says nothing more when the user closed the tab while it started', async () => { + callRuntimeRpc.mockRejectedValueOnce(rpcError('agent_launch_tab_closed')) + await expect(launch().outcome).resolves.toEqual({ kind: 'closed-by-user' }) + }) + + it('still starts the agent in the same tab when the launch record is full', async () => { + const unrecorded = deferred() + callRuntimeRpc + .mockRejectedValueOnce(rpcError('agent_session_operation_capacity')) + .mockReturnValueOnce(unrecorded.promise) + const { tabId, outcome } = launch() + const paneKey = lastPaneKey() + await vi.waitFor(() => expect(callRuntimeRpc).toHaveBeenCalledTimes(2)) + + const [, method, params] = callRuntimeRpc.mock.calls[1]! + expect(method).toBe('agent.launch') + expect(params).not.toHaveProperty('operationId') + // The same pane, held until the answer. + expect(params.paneKey).toBe(paneKey) + const leafId = launchTab(tabId)!.agentLaunchPane!.leafId + expect(agentLaunchPaneSpawnHold(tabId, leafId)).not.toBeNull() + + unrecorded.resolve(terminalResult(paneKey)) + await expect(outcome).resolves.toEqual({ kind: 'started' }) + }) + + it('takes its tab back when the unrecorded launch fails before the host revealed it', async () => { + callRuntimeRpc + .mockRejectedValueOnce(rpcError('agent_session_operation_capacity')) + .mockRejectedValueOnce(rpcError('worktree_not_found')) + const { tabId, outcome } = launch() + + await expect(outcome).resolves.toEqual({ + kind: 'not-started', + unconfirmed: false, + code: 'worktree_not_found' + }) + expect(launchTab(tabId)).toBeUndefined() + }) +}) diff --git a/src/renderer/src/lib/agent-launch-through-host.ts b/src/renderer/src/lib/agent-launch-through-host.ts new file mode 100644 index 00000000000..89a1c36fb26 --- /dev/null +++ b/src/renderer/src/lib/agent-launch-through-host.ts @@ -0,0 +1,206 @@ +/** + * A desktop launch started through the host's `agent.launch`, with no prompt. + * + * This window owns the workspace's tab layout, so it makes the agent's tab at the click, in the + * split it was asked for, as any new agent tab is made. The host records the launch under this + * click's operation id and starts the agent into that tab's pane. The pane's spawn waits until the + * host has taken it (`agent-launch-pane-spawn-hold`), then attaches to the agent or says why it could + * not start. The prompt stays this window's to paste, as main pastes it, once the agent has started. + */ + +import { useAppStore } from '@/store' +import { createBrowserUuid } from '@/lib/browser-uuid' +import { persistAgentLaunchTabOrder } from '@/lib/launch-agent-tab-order' +import { rememberAgentLaunchPanePrompt } from '@/lib/agent-launch-pane-prompt' +import { + holdAgentLaunchPaneSpawn, + isAgentLaunchPaneSpawnHeld +} from '@/lib/agent-launch-pane-spawn-hold' +import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache' +import { callRuntimeRpc, RuntimeRpcCallError } from '@/runtime/runtime-rpc-client' +import { createAgentSessionOperationId } from '@/runtime/agent-session-operation-id' +import { isAgentLaunchResult } from '../../../shared/agent-launch-intent' +import { AGENT_LAUNCH_TAB_CLOSED_CODE } from '../../../shared/agent-launch-tab-closed' +import { makePaneKey } from '../../../shared/stable-pane-id' +import { prefersStructuredNativeChatByDefault } from '../../../shared/structured-native-chat-launch-route' +import type { TuiAgent } from '../../../shared/tui-agent' +import type { LaunchSource } from '../../../shared/telemetry-events' +import type { Tab } from '../../../shared/tab-types' +import type { SessionOptionValue } from '../../../shared/native-chat-session-options' + +export type HostAgentLaunchArgs = { + agent: TuiAgent + worktreeId: string + /** The split the launch was made from; the tab joins it. */ + groupId?: string + /** Kept by the window: a pane whose agent could not start offers to copy it. */ + prompt: string + /** Absent uses the settings default; `null` means no arguments. */ + agentArgs?: string | null + cwd?: string + /** The launch's session options; only string values reach the host, which reads no others. */ + sessionOptions?: Record + launchSource?: LaunchSource + quickCommandLabel?: string | null + /** The launch seeds a workspace being opened, so its spawn must not reshuffle Recent. */ + pendingActivationSpawn?: boolean + /** The view the tab opens in, decided as for any new agent tab. */ + viewMode?: Tab['viewMode'] +} + +/** What became of the launch, as this window must tell it. */ +export type HostAgentLaunchOutcome = + /** The agent was started in this tab's pane, which is attached to it. */ + | { kind: 'started' } + /** The pane shows how the launch ended: couldn't start, or couldn't confirm it started. */ + | { kind: 'pane-says' } + /** The user closed the tab while it started, and with it the launch: nothing more to say. */ + | { kind: 'closed-by-user' } + /** The tab is gone, so the window says it: nothing started, or whether it did is unknown. */ + | { kind: 'not-started'; unconfirmed: boolean; code?: string } + +/** Refused at admission: nothing ran under this click, and the host takes back the tab it was shown. */ +const ADMISSION_REFUSAL_CODES = new Set([ + 'agent_session_operation_invalid', + 'agent_session_operation_conflict', + 'agent_session_operation_expired', + 'agent_session_operation_capacity' +]) + +function tabExists(worktreeId: string, tabId: string): boolean { + return (useAppStore.getState().tabsByWorktree[worktreeId] ?? []).some((tab) => tab.id === tabId) +} + +function closeLaunchTab(worktreeId: string, tabId: string): void { + if (tabExists(worktreeId, tabId)) { + useAppStore.getState().closeTab(tabId, { recordInteraction: false }) + } +} + +// Only a terminal in this pane is one the window can paste into; anything else, its pane explains. +function outcomeFromResult(result: unknown): HostAgentLaunchOutcome { + return isAgentLaunchResult(result) && result.outcome.kind === 'terminal' + ? { kind: 'started' } + : { kind: 'pane-says' } +} + +function stringSessionOptions(options: Record | undefined): { + sessionOptions?: Record +} { + const strings = Object.entries(options ?? {}).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string' + ) + return strings.length > 0 ? { sessionOptions: Object.fromEntries(strings) } : {} +} + +function launchParams(args: HostAgentLaunchArgs) { + return { + agent: args.agent, + target: { kind: 'existing', worktree: `id:${args.worktreeId}` }, + ...(args.agentArgs !== undefined ? { agentArgs: args.agentArgs } : {}), + ...(args.cwd ? { cwd: args.cwd } : {}), + ...stringSessionOptions(args.sessionOptions), + ...(args.launchSource ? { launchSource: args.launchSource } : {}), + ...(args.groupId ? { placement: { groupId: args.groupId } } : {}), + presentation: 'focused' + } +} + +/** + * The ledger is bookkeeping: a desktop past its per-caller row cap still gets its agent, through the + * host's unrecorded launch into the same pane. The refused launch already took back the tab it was + * shown, so the tab closes and returns at spawn (rare: 512 launches a day); the host's reveal at + * spawn hands the pane over (`terminal-presentation-ipc-bridge`). + */ +async function launchWithoutRecord( + args: HostAgentLaunchArgs, + pane: { tabId: string; leafId: string } +): Promise { + try { + return outcomeFromResult( + await callRuntimeRpc({ kind: 'local' }, 'agent.launch', { + ...launchParams(args), + paneKey: makePaneKey(pane.tabId, pane.leafId) + }) + ) + } catch (error) { + const code = error instanceof RuntimeRpcCallError ? error.code : undefined + // A pane the host never revealed would open as a shell; one it did holds the agent's terminal. + if (isAgentLaunchPaneSpawnHeld(pane.tabId, pane.leafId)) { + closeLaunchTab(args.worktreeId, pane.tabId) + } + return { kind: 'not-started', unconfirmed: false, ...(code ? { code } : {}) } + } +} + +async function settleLaunch( + args: HostAgentLaunchArgs, + pane: { tabId: string; leafId: string }, + send: Promise, + releaseHold: () => void +): Promise { + try { + return outcomeFromResult(await send) + } catch (error) { + const code = error instanceof RuntimeRpcCallError ? error.code : undefined + if (code === AGENT_LAUNCH_TAB_CLOSED_CODE) { + return { kind: 'closed-by-user' } + } + if (code === 'agent_session_operation_capacity') { + // Awaited: the pane stays held until the unrecorded launch has its answer. + return await launchWithoutRecord(args, pane) + } + // The host took the pane once it showed the tab; a pane it never took would open as a shell, + // and a refused one is the host's to take back. + const hostTookPane = !isAgentLaunchPaneSpawnHeld(pane.tabId, pane.leafId) + if (!hostTookPane || (code !== undefined && ADMISSION_REFUSAL_CODES.has(code))) { + closeLaunchTab(args.worktreeId, pane.tabId) + return { + kind: 'not-started', + unconfirmed: code === 'agent_session_operation_unknown', + ...(code ? { code } : {}) + } + } + return { kind: 'pane-says' } + } finally { + releaseHold() + } +} + +/** Where the host could turn a launch into a chat (chat is the default), this window's paste has no + * terminal to go to, so such a launch keeps main's own path. */ +export function windowMakesHostLaunchTab(): boolean { + return !prefersStructuredNativeChatByDefault(useAppStore.getState().settings) +} + +export function launchAgentThroughHost(args: HostAgentLaunchArgs): { + tabId: string + outcome: Promise +} { + const store = useAppStore.getState() + const tabId = createBrowserUuid() + const leafId = createBrowserUuid() + // Before the tab exists, so its first mount already waits. + const releaseHold = holdAgentLaunchPaneSpawn(tabId, leafId) + const send = callRuntimeRpc({ kind: 'local' }, 'agent.launchReplay', { + ...launchParams(args), + // A new click is a new operation; the pane is this click's too. + operationId: createAgentSessionOperationId(), + paneKey: makePaneKey(tabId, leafId) + }) + store.createTab(args.worktreeId, args.groupId, undefined, { + id: tabId, + initialLeafId: leafId, + agentLaunchPane: { leafId }, + launchAgent: args.agent, + quickCommandLabel: args.quickCommandLabel, + ...(args.pendingActivationSpawn ? { pendingActivationSpawn: true } : {}), + ...(args.viewMode ? { viewMode: args.viewMode } : {}) + }) + rememberAgentLaunchPanePrompt(tabId, args.prompt) + seedNativeChatAppliedSessionOptions(tabId, args.agent, args.sessionOptions) + // Why: without it an activated launch can stay hidden behind an editor. + store.setActiveTabType('terminal', args.worktreeId) + persistAgentLaunchTabOrder(args.worktreeId, tabId) + return { tabId, outcome: settleLaunch(args, { tabId, leafId }, send, releaseHold) } +} diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts index 042fe0a6956..8a6fd962925 100644 --- a/src/renderer/src/lib/agent-paste-draft.ts +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -84,6 +84,8 @@ export async function pasteDraftWhenAgentReady(args: { timeoutMs?: number onTimeout?: () => void onUnconfirmedDelivery?: () => void + /** Whether the paste may be written at all; readiness is observed while it is pending. */ + sendGate?: Promise }): Promise { const { tabId, content, agent, submit, forcePaste, timeoutMs, onTimeout, onUnconfirmedDelivery } = args @@ -102,13 +104,18 @@ export async function pasteDraftWhenAgentReady(args: { const readySignal = resolvePasteReadySignal(agentConfig, submit === true) const settings = getSettingsForAgentTabRuntimeOwner(tabId) const readinessTimeoutMs = resolveDraftPasteReadyTimeoutMs(agent, timeoutMs) - const readiness = await waitForAgentDraftInputReadyOnTab({ + const readinessResult = waitForAgentDraftInputReadyOnTab({ tabId, spawnTimeoutMs: PTY_SPAWN_TIMEOUT_MS, readinessTimeoutMs, readySignal, settings }) + // Why: a closed gate is the caller's own outcome to report; the bounded wait above just lapses. + if (args.sendGate && !(await args.sendGate)) { + return false + } + const readiness = await readinessResult if (!readiness) { onTimeout?.() return false diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index adf90ead68b..f9c41da3ef0 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -116,6 +116,13 @@ vi.mock('@/lib/agent-paste-draft', () => ({ pasteDraftWhenAgentReady: mockPasteDraftWhenAgentReady })) +// Why: this file pins main's window launch and its paste, which chat-default and paired launches +// still take; an AI button's host launch reuses that paste and is pinned in its own tests. +vi.mock('@/lib/launch-agent-new-tab-host-route', () => ({ + newTabPromptLaunchesThroughHost: () => false, + launchNewTabPromptThroughHost: vi.fn() +})) + vi.mock('@/lib/agent-ready-wait', () => ({ waitForAgentReady: mockWaitForAgentReady })) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index deabaa32a59..9fa639d00ed 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -3,11 +3,12 @@ import type { AgentStartupPlan } from '@/lib/tui-agent-startup' import { planLaunchAgentStartupPrompt } from '@/lib/launch-agent-startup-prompt-plan' import { persistAgentLaunchTabOrder } from '@/lib/launch-agent-tab-order' import { tuiAgentToAgentKind } from '@/lib/telemetry' -import { createPasteReadinessTimeoutNotice } from '@/lib/launch-agent-paste-timeout-notice' +import { seedNativeChatLaunchDraftForAgentTab } from '@/lib/agent-launch-prompt-delivery' +import { pasteAgentLaunchPromptOnceReady } from '@/lib/launch-agent-tab-prompt-paste' import { - deliverLaunchPromptToAgentTab, - seedNativeChatLaunchDraftForAgentTab -} from '@/lib/agent-launch-prompt-delivery' + launchNewTabPromptThroughHost, + newTabPromptLaunchesThroughHost +} from '@/lib/launch-agent-new-tab-host-route' import { initialAgentTabViewModeProps } from '@/lib/native-chat-initial-view-mode' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' @@ -18,7 +19,6 @@ import { resolveTuiAgentLaunchEnv } from '../../../shared/tui-agent-launch-defaults' import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' -import { seedCommandCodeSubmittedPromptStatus } from '@/lib/command-code-prompt-status-seed' import type { TuiAgent } from '../../../shared/tui-agent' import type { LaunchSource } from '../../../shared/telemetry-events' import { resolveAgentLaunchExecutionContext } from '@/lib/launch-agent-execution-context' @@ -247,6 +247,35 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent if (beforeSurfaceOpen?.({ kind: 'local-terminal' }) === false) { return null } + if ( + pasteDraftAfterLaunch !== null && + newTabPromptLaunchesThroughHost({ promptDelivery, pastesPrompt: true }) + ) { + const launched = launchNewTabPromptThroughHost({ + agent, + worktreeId, + ...(groupId ? { groupId } : {}), + prompt: trimmedPrompt, + ...(agentArgs !== undefined ? { agentArgs } : {}), + ...(initialCwd?.trim() ? { cwd: initialCwd } : {}), + ...(startupPlan.sessionOptions ? { sessionOptions: startupPlan.sessionOptions } : {}), + // The same source main's window stamps on its own launches. + launchSource: launchSource ?? 'tab_bar_quick_launch', + quickCommandLabel, + ...(pendingActivationSpawn ? { pendingActivationSpawn: true } : {}), + ...(initialViewModeProps.viewMode ? { viewMode: initialViewModeProps.viewMode } : {}), + pasteContent: pasteDraftAfterLaunch, + submit: submitPastedPrompt, + ...(onPromptDelivered ? { onPromptDelivered } : {}), + ...(onPromptDeliveryUnconfirmed ? { onPromptDeliveryUnconfirmed } : {}) + }) + return { + surface: { kind: 'local-terminal', tabId: launched.tabId }, + startupPlan, + pasteDraftAfterLaunch: true, + promptDeliveryResult: launched.promptDeliveryResult + } + } // Why: queue startup BEFORE TerminalPane mounts — it snapshots pendingStartupByTabId in useState on first render. // Why: followup path pastes an unsubmitted draft, so gate the initial chat view like a draft launch, not auto-submit. const tab = store.createTab(worktreeId, groupId, undefined, { @@ -288,30 +317,15 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent seedNativeChatLaunchDraftForAgentTab({ tabId: tab.id, agent, text: trimmedPrompt }) } if (pasteDraftAfterLaunch !== null) { - const timeoutNotice = createPasteReadinessTimeoutNotice({ + const deliveryPromise = pasteAgentLaunchPromptOnceReady({ worktreeId, tabId: tab.id, agent, - submitted: submitPastedPrompt - }) - const deliveryPromise = deliverLaunchPromptToAgentTab({ - tabId: tab.id, content: pasteDraftAfterLaunch, - agent, submit: submitPastedPrompt, - forcePaste: true, - onTimeout: timeoutNotice.onTimeout, - ...(onPromptDeliveryUnconfirmed ? { onUnconfirmedDelivery: onPromptDeliveryUnconfirmed } : {}) - }).then((delivered) => { - if (delivered) { - if (agent === 'command-code' && submitPastedPrompt) { - // Why: Command Code has no prompt-submit hook; when Orca submits a - // generated prompt after readiness, seed working at delivery time. - seedCommandCodeSubmittedPromptStatus(worktreeId, tab.id, trimmedPrompt) - } - onPromptDelivered?.() - } - return { delivered, failureNotified: !delivered && timeoutNotice.wasNotified() } + prompt: trimmedPrompt, + ...(onPromptDelivered ? { onPromptDelivered } : {}), + ...(onPromptDeliveryUnconfirmed ? { onPromptDeliveryUnconfirmed } : {}) }) if (promptDelivery === 'submit-after-ready') { promptDeliveryResult = deliveryPromise diff --git a/src/renderer/src/lib/launch-agent-new-tab-host-route-readiness.test.ts b/src/renderer/src/lib/launch-agent-new-tab-host-route-readiness.test.ts new file mode 100644 index 00000000000..23206c87435 --- /dev/null +++ b/src/renderer/src/lib/launch-agent-new-tab-host-route-readiness.test.ts @@ -0,0 +1,156 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { HostAgentLaunchOutcome } from './agent-launch-through-host' + +const TAB = '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d' + +const testState = vi.hoisted(() => { + const ptyIdsByTabId: Record = {} + const writes: { at: number; data: string }[] = [] + return { + appState: { + settings: {}, + activeWorktreeId: 'wt-1', + tabsByWorktree: {}, + ptyIdsByTabId, + seedNativeChatLaunchPrompt: vi.fn(), + seedNativeChatLaunchDraft: vi.fn(), + markNativeChatLaunchPromptFailed: vi.fn() + }, + storeSubscribers: new Set<(state: unknown) => void>(), + ptyWatchers: new Map void>(), + writes, + launchAgentThroughHost: vi.fn() + } +}) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => testState.appState, + subscribe: (subscriber: (state: unknown) => void) => { + testState.storeSubscribers.add(subscriber) + return () => testState.storeSubscribers.delete(subscriber) + } + } +})) +vi.mock('@/lib/agent-launch-through-host', () => ({ + launchAgentThroughHost: testState.launchAgentThroughHost, + windowMakesHostLaunchTab: () => true +})) +vi.mock('@/components/terminal-pane/pty-data-sidecar-subscriptions', () => ({ + subscribeToPtyData: (ptyId: string, watcher: (data: string) => void) => { + testState.ptyWatchers.set(ptyId, watcher) + return () => testState.ptyWatchers.delete(ptyId) + } +})) +vi.mock('@/components/terminal-pane/pty-pre-handler-buffer', () => ({ + replayPreHandlerPtyData: vi.fn() +})) +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ + isRemoteRuntimePtyId: () => false, + inspectRuntimeTerminalProcess: vi.fn(async () => null), + sendRuntimePtyInputVerified: async (_settings: unknown, _ptyId: string, data: string) => { + testState.writes.push({ at: Date.now(), data }) + return true + } +})) +vi.mock('@/runtime/runtime-terminal-stream', () => ({ subscribeToRuntimeTerminalData: vi.fn() })) +vi.mock('./agent-ready-wait', () => ({ waitForAgentReady: vi.fn(async () => ({ ready: false })) })) +vi.mock('@/lib/telemetry', () => ({ track: vi.fn(), tuiAgentToAgentKind: () => 'claude' })) +vi.mock('sonner', () => ({ toast: { error: vi.fn(), message: vi.fn() } })) + +const { launchNewTabPromptThroughHost } = await import('./launch-agent-new-tab-host-route') + +function attachHostPty(ptyId: string): void { + testState.appState.ptyIdsByTabId = { [TAB]: [ptyId] } + for (const subscriber of testState.storeSubscribers) { + subscriber(testState.appState) + } +} + +describe('an AI button whose agent is ready before the host answers', () => { + beforeEach(() => { + vi.useFakeTimers({ now: 0 }) + vi.stubGlobal('window', { + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout + }) + testState.appState.ptyIdsByTabId = {} + testState.storeSubscribers.clear() + testState.ptyWatchers.clear() + testState.writes = [] + testState.appState.seedNativeChatLaunchPrompt.mockClear() + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.useRealTimers() + }) + + // Regression: the paste watched the terminal only after the host's reply, so an agent that had + // already enabled bracketed paste was never seen ready and its prompt waited out the budget. + it('is pasted at its own ready signal plus the quiet window, not after the timeout', async () => { + let answer!: (outcome: HostAgentLaunchOutcome) => void + testState.launchAgentThroughHost.mockReturnValue({ + tabId: TAB, + outcome: new Promise((done) => (answer = done)) + }) + const onPromptDeliveryUnconfirmed = vi.fn() + const { promptDeliveryResult } = launchNewTabPromptThroughHost({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'resolve the conflicts', + pasteContent: 'resolve the conflicts', + submit: true, + onPromptDeliveryUnconfirmed + }) + + await vi.advanceTimersByTimeAsync(50) + attachHostPty('pty-1') + await vi.advanceTimersByTimeAsync(10) + testState.ptyWatchers.get('pty-1')?.('claude drawn\r\n\x1b[?2004h> ') + await vi.advanceTimersByTimeAsync(240) + expect(testState.writes).toEqual([]) + expect(testState.appState.seedNativeChatLaunchPrompt).not.toHaveBeenCalled() + + answer({ kind: 'started' }) + await vi.advanceTimersByTimeAsync(1200) + expect(testState.writes).toEqual([]) + + // Ready at 60 ms + the 1.5 s quiet window. + await vi.advanceTimersByTimeAsync(60) + expect(testState.writes[0]).toEqual({ + at: 1560, + data: '\x1b[200~resolve the conflicts\x1b[201~' + }) + expect(testState.appState.seedNativeChatLaunchPrompt).toHaveBeenCalledOnce() + + await vi.advanceTimersByTimeAsync(2000) + await expect(promptDeliveryResult).resolves.toEqual({ delivered: true, failureNotified: false }) + expect(onPromptDeliveryUnconfirmed).not.toHaveBeenCalled() + }) + + it('writes nothing, and seeds no chat copy, when the host says its agent did not start', async () => { + let answer!: (outcome: HostAgentLaunchOutcome) => void + testState.launchAgentThroughHost.mockReturnValue({ + tabId: TAB, + outcome: new Promise((done) => (answer = done)) + }) + const { promptDeliveryResult } = launchNewTabPromptThroughHost({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'resolve the conflicts', + pasteContent: 'resolve the conflicts', + submit: true + }) + + attachHostPty('pty-1') + testState.ptyWatchers.get('pty-1')?.('\x1b[?2004h> ') + answer({ kind: 'pane-says' }) + await vi.advanceTimersByTimeAsync(10_000) + + await expect(promptDeliveryResult).resolves.toEqual({ delivered: false, failureNotified: true }) + expect(testState.writes).toEqual([]) + expect(testState.appState.seedNativeChatLaunchPrompt).not.toHaveBeenCalled() + expect(testState.appState.markNativeChatLaunchPromptFailed).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/launch-agent-new-tab-host-route.test.ts b/src/renderer/src/lib/launch-agent-new-tab-host-route.test.ts new file mode 100644 index 00000000000..54213717101 --- /dev/null +++ b/src/renderer/src/lib/launch-agent-new-tab-host-route.test.ts @@ -0,0 +1,118 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { HostAgentLaunchOutcome } from './agent-launch-through-host' + +const host = vi.hoisted(() => ({ + launchAgentThroughHost: vi.fn(), + windowMakesHostLaunchTab: vi.fn(() => true) +})) +vi.mock('@/lib/agent-launch-through-host', () => host) +const pasteAgentLaunchPromptOnceReady = vi.hoisted(() => + vi.fn(async (_args: { sendGate?: Promise }) => ({ + delivered: true, + failureNotified: false + })) +) +vi.mock('@/lib/launch-agent-tab-prompt-paste', () => ({ pasteAgentLaunchPromptOnceReady })) +const toast = vi.hoisted(() => ({ error: vi.fn() })) +vi.mock('sonner', () => ({ toast })) + +const { launchNewTabPromptThroughHost, newTabPromptLaunchesThroughHost } = + await import('./launch-agent-new-tab-host-route') + +const TAB = '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d' + +function deferredOutcome() { + let resolve!: (outcome: HostAgentLaunchOutcome) => void + const promise = new Promise((done) => (resolve = done)) + host.launchAgentThroughHost.mockReturnValue({ tabId: TAB, outcome: promise }) + return resolve +} + +function launch() { + return launchNewTabPromptThroughHost({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'fix the failing checks', + pasteContent: 'fix the failing checks', + submit: true + }) +} + +beforeEach(() => { + vi.clearAllMocks() + host.windowMakesHostLaunchTab.mockReturnValue(true) +}) + +describe('an AI button launched through the host', () => { + function sendGate(): Promise { + const gate = pasteAgentLaunchPromptOnceReady.mock.calls.at(-1)?.[0].sendGate + if (!gate) { + throw new Error('the paste was set up without a send gate') + } + return gate + } + + // Why: readiness is watched from the tab's first output, as main watches it, but the paste must + // meet the host's agent, never a shell this window spawned first. + it('watches for readiness at once but writes only once the host has its agent in the tab', async () => { + const answer = deferredOutcome() + const { tabId, promptDeliveryResult } = launch() + expect(pasteAgentLaunchPromptOnceReady).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ tabId, worktreeId: 'wt-1', submit: true }) + ) + let gateOpen: boolean | undefined + void sendGate().then((open) => (gateOpen = open)) + await Promise.resolve() + expect(gateOpen).toBeUndefined() + + answer({ kind: 'started' }) + + await expect(sendGate()).resolves.toBe(true) + await expect(promptDeliveryResult).resolves.toEqual({ delivered: true, failureNotified: false }) + expect(tabId).toBe(TAB) + }) + + it('never pastes for a launch refused before the agent ran, and says so once', async () => { + deferredOutcome()({ kind: 'not-started', unconfirmed: false, code: 'worktree_not_found' }) + + await expect(launch().promptDeliveryResult).resolves.toEqual({ + delivered: false, + failureNotified: true + }) + await expect(sendGate()).resolves.toBe(false) + expect(toast.error).toHaveBeenCalledOnce() + }) + + it('says nothing and pastes nothing when the user closed the tab, or its pane explains', async () => { + for (const outcome of [{ kind: 'closed-by-user' }, { kind: 'pane-says' }] as const) { + deferredOutcome()(outcome) + await expect(launch().promptDeliveryResult).resolves.toEqual({ + delivered: false, + failureNotified: true + }) + await expect(sendGate()).resolves.toBe(false) + } + expect(toast.error).not.toHaveBeenCalled() + }) +}) + +describe('which new agent tabs start through the host', () => { + it('an AI button whose prompt is pasted once ready, in a terminal this window makes', () => { + expect( + newTabPromptLaunchesThroughHost({ promptDelivery: 'submit-after-ready', pastesPrompt: true }) + ).toBe(true) + }) + + it('never a typed prompt, nor a launch the host could turn into a chat', () => { + expect( + newTabPromptLaunchesThroughHost({ promptDelivery: 'auto-submit', pastesPrompt: true }) + ).toBe(false) + expect(newTabPromptLaunchesThroughHost({ promptDelivery: 'draft', pastesPrompt: true })).toBe( + false + ) + host.windowMakesHostLaunchTab.mockReturnValue(false) + expect( + newTabPromptLaunchesThroughHost({ promptDelivery: 'submit-after-ready', pastesPrompt: true }) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/launch-agent-new-tab-host-route.ts b/src/renderer/src/lib/launch-agent-new-tab-host-route.ts new file mode 100644 index 00000000000..47a7f2675e2 --- /dev/null +++ b/src/renderer/src/lib/launch-agent-new-tab-host-route.ts @@ -0,0 +1,94 @@ +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { agentLaunchPaneNoticeText } from '@/components/terminal-pane/agent-launch-pane-notice-text' +import { + launchAgentThroughHost, + windowMakesHostLaunchTab, + type HostAgentLaunchArgs, + type HostAgentLaunchOutcome +} from '@/lib/agent-launch-through-host' +import { pasteAgentLaunchPromptOnceReady } from '@/lib/launch-agent-tab-prompt-paste' + +/** + * Whether a new agent tab starts through the host's `agent.launch`: an AI button's launch, whose + * prompt is pasted once the agent is ready, in a terminal this window makes. Temporary: the window + * keeps pasting the prompt as main does until the host delivers it. A typed prompt (`auto-submit`, + * `draft`) keeps main's launch, and so does a launch the host could turn into a chat. + */ +export function newTabPromptLaunchesThroughHost(args: { + promptDelivery: 'auto-submit' | 'draft' | 'submit-after-ready' + pastesPrompt: boolean +}): boolean { + return ( + args.promptDelivery === 'submit-after-ready' && args.pastesPrompt && windowMakesHostLaunchTab() + ) +} + +/** The tab is gone, so the pane's own words go in a notice, with its prompt to copy. */ +function showLaunchNotStartedNotice(outcome: HostAgentLaunchOutcome, prompt: string): void { + if (outcome.kind !== 'not-started') { + return + } + toast.error( + agentLaunchPaneNoticeText( + outcome.unconfirmed + ? { kind: 'unconfirmed' } + : { kind: 'not-started', code: outcome.code ?? '' } + ), + { + action: { + label: translate( + 'auto.components.terminal.pane.AgentLaunchPaneNotice.copyPrompt', + 'Copy prompt' + ), + onClick: () => void window.api.ui.writeClipboardText(prompt) + } + } + ) +} + +/** + * Starts the agent through the host with no prompt and pastes the prompt as main does. Readiness is + * watched from the moment the tab's terminal exists, as main watches it, so an agent that is ready + * before the host answers is not missed; the paste is written only once the host has started its + * agent in this tab, so it never meets a shell this window spawned. + */ +export function launchNewTabPromptThroughHost( + args: HostAgentLaunchArgs & { + /** What is pasted, which can differ from the prompt the user wrote. */ + pasteContent: string + submit: boolean + onPromptDelivered?: () => void + onPromptDeliveryUnconfirmed?: () => void + } +): { + tabId: string + promptDeliveryResult: Promise<{ delivered: boolean; failureNotified: boolean }> +} { + const { pasteContent, submit, onPromptDelivered, onPromptDeliveryUnconfirmed, ...launch } = args + const { tabId, outcome } = launchAgentThroughHost(launch) + // Only the host's agent can fill this tab's terminal while the window's own spawn is held. + const pasted = pasteAgentLaunchPromptOnceReady({ + worktreeId: args.worktreeId, + tabId, + agent: args.agent, + content: pasteContent, + submit, + prompt: args.prompt, + sendGate: outcome.then( + (launched) => launched.kind === 'started', + () => false + ), + ...(onPromptDelivered ? { onPromptDelivered } : {}), + ...(onPromptDeliveryUnconfirmed ? { onPromptDeliveryUnconfirmed } : {}) + }) + const promptDeliveryResult = outcome.then((launched) => { + if (launched.kind === 'started') { + return pasted + } + // The pane, or this notice for a tab that went, already says why: never a second notice. + showLaunchNotStartedNotice(launched, args.prompt) + return { delivered: false, failureNotified: true } + }) + return { tabId, promptDeliveryResult } +} diff --git a/src/renderer/src/lib/launch-agent-tab-prompt-paste.ts b/src/renderer/src/lib/launch-agent-tab-prompt-paste.ts new file mode 100644 index 00000000000..de3fa40cf45 --- /dev/null +++ b/src/renderer/src/lib/launch-agent-tab-prompt-paste.ts @@ -0,0 +1,52 @@ +import { createPasteReadinessTimeoutNotice } from '@/lib/launch-agent-paste-timeout-notice' +import { deliverLaunchPromptToAgentTab } from '@/lib/agent-launch-prompt-delivery' +import { seedCommandCodeSubmittedPromptStatus } from '@/lib/command-code-prompt-status-seed' +import type { TuiAgent } from '../../../shared/tui-agent' + +/** + * Pastes a new agent tab's prompt once its agent is ready, and says whether it landed. The one copy + * of this delivery, for a tab whose terminal this window spawned and for one an `agent.launch` + * spawned into it. + */ +export function pasteAgentLaunchPromptOnceReady(args: { + worktreeId: string + tabId: string + agent: TuiAgent + /** What is pasted, which can differ from the prompt the user wrote. */ + content: string + submit: boolean + /** The prompt as written, which Command Code's working row shows. */ + prompt: string + onPromptDelivered?: () => void + onPromptDeliveryUnconfirmed?: () => void + /** Whether the paste may be written; readiness is observed while it is pending. */ + sendGate?: Promise +}): Promise<{ delivered: boolean; failureNotified: boolean }> { + const { worktreeId, tabId, agent, submit, onPromptDelivered, onPromptDeliveryUnconfirmed } = args + const timeoutNotice = createPasteReadinessTimeoutNotice({ + worktreeId, + tabId, + agent, + submitted: submit + }) + return deliverLaunchPromptToAgentTab({ + tabId, + content: args.content, + agent, + submit, + forcePaste: true, + onTimeout: timeoutNotice.onTimeout, + ...(args.sendGate ? { sendGate: args.sendGate } : {}), + ...(onPromptDeliveryUnconfirmed ? { onUnconfirmedDelivery: onPromptDeliveryUnconfirmed } : {}) + }).then((delivered) => { + if (delivered) { + if (agent === 'command-code' && submit) { + // Why: Command Code has no prompt-submit hook; when Orca submits a + // generated prompt after readiness, seed working at delivery time. + seedCommandCodeSubmittedPromptStatus(worktreeId, tabId, args.prompt) + } + onPromptDelivered?.() + } + return { delivered, failureNotified: !delivered && timeoutNotice.wasNotified() } + }) +} diff --git a/src/renderer/src/lib/source-control-agent-action-launch-check.test.ts b/src/renderer/src/lib/source-control-agent-action-launch-check.test.ts new file mode 100644 index 00000000000..390af35ebe5 --- /dev/null +++ b/src/renderer/src/lib/source-control-agent-action-launch-check.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import { checkSourceControlAgentActionLaunch } from './source-control-agent-action-launch-check' + +const BASE = { + agent: 'codex', + commandInput: 'Fix checks', + detectedAgents: ['codex'], + platform: 'linux' +} as const + +describe('checkSourceControlAgentActionLaunch', () => { + it('passes a launch the user has nothing to fix in', () => { + expect(checkSourceControlAgentActionLaunch({ ...BASE, detectedAgents: ['codex'] })).toEqual({ + ok: true + }) + }) + + it('rejects disabled agents', () => { + expect( + checkSourceControlAgentActionLaunch({ + ...BASE, + detectedAgents: ['codex'], + disabledAgents: ['codex'] + }) + ).toEqual({ ok: false, error: 'The selected agent is disabled in Settings.' }) + }) + + it('rejects agents not detected on the current host', () => { + expect( + checkSourceControlAgentActionLaunch({ ...BASE, agent: 'claude', detectedAgents: ['codex'] }) + ).toEqual({ ok: false, error: 'The selected agent was not detected on this workspace host.' }) + }) + + it('rejects an empty command input', () => { + expect( + checkSourceControlAgentActionLaunch({ + ...BASE, + detectedAgents: ['codex'], + commandInput: ' ' + }) + ).toEqual({ ok: false, error: 'Command input is empty.' }) + }) + + it('rejects invalid per-action CLI arguments', () => { + expect( + checkSourceControlAgentActionLaunch({ + ...BASE, + detectedAgents: ['codex'], + agentArgs: '--model "unterminated' + }) + ).toEqual({ + ok: false, + error: 'CLI arguments are invalid: Unclosed quote in command template.' + }) + }) +}) diff --git a/src/renderer/src/lib/source-control-agent-action-launch-check.ts b/src/renderer/src/lib/source-control-agent-action-launch-check.ts new file mode 100644 index 00000000000..cd45284f200 --- /dev/null +++ b/src/renderer/src/lib/source-control-agent-action-launch-check.ts @@ -0,0 +1,100 @@ +import { buildAgentStartupPlan, planAgentCliArgsSuffix } from '@/lib/tui-agent-startup' +import { CLIENT_PLATFORM } from '@/lib/new-workspace' +import { isTuiAgentEnabled } from '../../../shared/tui-agent-selection' +import type { TuiAgent } from '../../../shared/tui-agent' +import { translate } from '@/i18n/i18n' +import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell' + +/** + * What the source-control dialog checks before it starts an agent: what the user can fix here (the + * agent, its arguments, the input). How the prompt then reaches the agent is the host's to decide + * (`agent.launch`), so nothing here predicts it. + */ +export type SourceControlLaunchCheckResult = { ok: true } | { ok: false; error: string } + +export function checkSourceControlAgentActionLaunch(args: { + agent: TuiAgent | null + commandInput: string + detectedAgents: TuiAgent[] + disabledAgents?: TuiAgent[] + cmdOverrides?: Partial> + agentArgs?: string | null + platform?: NodeJS.Platform + terminalWindowsShell?: string | null + /** Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only + * `orca-ide` rename must not be applied for remote launches. */ + isRemote?: boolean +}): SourceControlLaunchCheckResult { + const agent = args.agent + if (!agent) { + return { + ok: false, + error: translate( + 'auto.lib.source.control.agent.action.plan.a7ac8717c7', + 'Choose an agent before starting.' + ) + } + } + if (!isTuiAgentEnabled(agent, args.disabledAgents)) { + return { + ok: false, + error: translate( + 'auto.lib.source.control.agent.action.plan.b96e091fc9', + 'The selected agent is disabled in Settings.' + ) + } + } + if (!args.detectedAgents.includes(agent)) { + return { + ok: false, + error: translate( + 'auto.lib.source.control.agent.action.plan.8eb541cc83', + 'The selected agent was not detected on this workspace host.' + ) + } + } + + if (!args.commandInput.trim()) { + return { + ok: false, + error: translate( + 'auto.lib.source.control.agent.action.plan.46f1a2c9bd', + 'Command input is empty.' + ) + } + } + + const platform = args.platform ?? CLIENT_PLATFORM + const isRemote = args.isRemote ?? false + const shell = + resolveLocalWindowsAgentStartupShell({ + platform, + isRemote, + terminalWindowsShell: args.terminalWindowsShell + }) ?? (platform === 'win32' ? 'powershell' : 'posix') + const plannedArgs = planAgentCliArgsSuffix(args.agentArgs, shell) + if (!plannedArgs.ok) { + return { ok: false, error: plannedArgs.error } + } + // The agent's own command, prompt aside: an override or arguments it cannot build are the user's. + const launchable = buildAgentStartupPlan({ + agent, + prompt: '', + cmdOverrides: args.cmdOverrides ?? {}, + platform, + shell, + isRemote, + agentArgs: args.agentArgs, + allowEmptyPromptLaunch: true + }) + if (!launchable) { + return { + ok: false, + error: translate( + 'auto.lib.source.control.agent.action.plan.3f0ea9aa0d', + 'Could not build the agent launch command.' + ) + } + } + return { ok: true } +} diff --git a/src/renderer/src/lib/source-control-agent-action-plan.test.ts b/src/renderer/src/lib/source-control-agent-action-plan.test.ts deleted file mode 100644 index a1cb9b6d3ed..00000000000 --- a/src/renderer/src/lib/source-control-agent-action-plan.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { planSourceControlAgentActionLaunch } from './source-control-agent-action-plan' - -describe('planSourceControlAgentActionLaunch', () => { - it('rejects disabled agents', () => { - expect( - planSourceControlAgentActionLaunch({ - agent: 'codex', - commandInput: 'Fix checks', - promptDelivery: 'submit-after-ready', - detectedAgents: ['codex'], - disabledAgents: ['codex'], - platform: 'darwin' - }) - ).toEqual({ ok: false, error: 'The selected agent is disabled in Settings.' }) - }) - - it('rejects agents not detected on the current host', () => { - expect( - planSourceControlAgentActionLaunch({ - agent: 'claude', - commandInput: 'Fix checks', - promptDelivery: 'submit-after-ready', - detectedAgents: ['codex'], - platform: 'linux' - }) - ).toEqual({ ok: false, error: 'The selected agent was not detected on this workspace host.' }) - }) - - it('mirrors submit-after-ready delivery without embedding the prompt in the command', () => { - const result = planSourceControlAgentActionLaunch({ - agent: 'codex', - commandInput: 'Fix checks', - promptDelivery: 'submit-after-ready', - detectedAgents: ['codex'], - platform: 'linux' - }) - - expect(result.ok && result.delivery).toBe('paste-submit') - expect(result.ok && result.commandLabel).toBe('codex') - expect(result.ok && result.summary).toContain('pastes and submits') - expect(result.ok && result.caveat).toContain('PATH') - }) - - it('includes per-action CLI arguments in submit-after-ready launch plans', () => { - const result = planSourceControlAgentActionLaunch({ - agent: 'codex', - commandInput: 'Fix checks', - agentArgs: '--model gpt-5.5', - promptDelivery: 'submit-after-ready', - detectedAgents: ['codex'], - platform: 'linux' - }) - - expect(result.ok && result.commandLabel).toBe("codex '--model' 'gpt-5.5'") - }) - - it.each([ - { - terminalWindowsShell: 'cmd.exe', - expectedCommand: 'powershell.exe -NoProfile -EncodedCommand' - }, - { - terminalWindowsShell: 'git-bash', - expectedCommand: 'ORCA_HERMES_STARTUP_QUERY' - } - ])( - 'uses $terminalWindowsShell quoting for Hermes source-control prompts', - ({ terminalWindowsShell, expectedCommand }) => { - const result = planSourceControlAgentActionLaunch({ - agent: 'hermes', - commandInput: 'Review the change', - promptDelivery: 'auto-submit', - detectedAgents: ['hermes'], - platform: 'win32', - terminalWindowsShell - }) - - expect(result.ok && result.plan.launchCommand).toContain(expectedCommand) - expect(result.ok && result.plan.env?.ORCA_HERMES_STARTUP_QUERY).toBe('Review the change') - } - ) - - it('rejects invalid per-action CLI arguments', () => { - expect( - planSourceControlAgentActionLaunch({ - agent: 'codex', - commandInput: 'Fix checks', - agentArgs: '--model "unterminated', - promptDelivery: 'submit-after-ready', - detectedAgents: ['codex'], - platform: 'linux' - }) - ).toEqual({ - ok: false, - error: 'CLI arguments are invalid: Unclosed quote in command template.' - }) - }) - - it('uses native draft launch when the selected agent supports it', () => { - const result = planSourceControlAgentActionLaunch({ - agent: 'claude', - commandInput: 'Fix checks', - promptDelivery: 'draft', - detectedAgents: ['claude'], - platform: 'darwin' - }) - - expect(result.ok && result.delivery).toBe('draft-native') - expect(result.ok && result.commandLabel).toContain('--prefill') - }) -}) diff --git a/src/renderer/src/lib/source-control-agent-action-plan.ts b/src/renderer/src/lib/source-control-agent-action-plan.ts deleted file mode 100644 index 5e6681975e6..00000000000 --- a/src/renderer/src/lib/source-control-agent-action-plan.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { - buildAgentDraftLaunchPlan, - buildAgentStartupPlan, - planAgentCliArgsSuffix, - type AgentStartupPlan -} from '@/lib/tui-agent-startup' -import { CLIENT_PLATFORM } from '@/lib/new-workspace' -import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' -import { isTuiAgentEnabled } from '../../../shared/tui-agent-selection' -import type { TuiAgent } from '../../../shared/tui-agent' -import { translate } from '@/i18n/i18n' -import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell' -import type { SessionOptionValue } from '../../../shared/native-chat-session-options' - -export type SourceControlLaunchPlanDelivery = - | 'argv' - | 'draft-native' - | 'draft-paste' - | 'paste-submit' - -export type SourceControlLaunchPlanResult = - | { - ok: true - plan: AgentStartupPlan - delivery: SourceControlLaunchPlanDelivery - commandLabel: string - summary: string - caveat: string - } - | { ok: false; error: string } - -export function planSourceControlAgentActionLaunch(args: { - agent: TuiAgent | null - commandInput: string - promptDelivery: 'auto-submit' | 'draft' | 'submit-after-ready' - detectedAgents: TuiAgent[] - disabledAgents?: TuiAgent[] - cmdOverrides?: Partial> - agentArgs?: string | null - sessionOptions?: Record - platform?: NodeJS.Platform - terminalWindowsShell?: string | null - /** Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only - * `orca-ide` rename must not be applied for remote launches. */ - isRemote?: boolean -}): SourceControlLaunchPlanResult { - const agent = args.agent - if (!agent) { - return { - ok: false, - error: translate( - 'auto.lib.source.control.agent.action.plan.a7ac8717c7', - 'Choose an agent before starting.' - ) - } - } - if (!isTuiAgentEnabled(agent, args.disabledAgents)) { - return { - ok: false, - error: translate( - 'auto.lib.source.control.agent.action.plan.b96e091fc9', - 'The selected agent is disabled in Settings.' - ) - } - } - if (!args.detectedAgents.includes(agent)) { - return { - ok: false, - error: translate( - 'auto.lib.source.control.agent.action.plan.8eb541cc83', - 'The selected agent was not detected on this workspace host.' - ) - } - } - - const trimmedInput = args.commandInput.trim() - if (!trimmedInput) { - return { - ok: false, - error: translate( - 'auto.lib.source.control.agent.action.plan.46f1a2c9bd', - 'Command input is empty.' - ) - } - } - - const cmdOverrides = args.cmdOverrides ?? {} - const platform = args.platform ?? CLIENT_PLATFORM - const isRemote = args.isRemote ?? false - const shell = - resolveLocalWindowsAgentStartupShell({ - platform, - isRemote, - terminalWindowsShell: args.terminalWindowsShell - }) ?? (platform === 'win32' ? 'powershell' : 'posix') - const plannedArgs = planAgentCliArgsSuffix(args.agentArgs, shell) - if (!plannedArgs.ok) { - return { ok: false, error: plannedArgs.error } - } - let startupPlan: AgentStartupPlan | null = null - let delivery: SourceControlLaunchPlanDelivery - - if (args.promptDelivery === 'submit-after-ready') { - startupPlan = buildAgentStartupPlan({ - agent, - prompt: '', - cmdOverrides, - platform, - shell, - isRemote, - agentArgs: args.agentArgs, - sessionOptions: args.sessionOptions, - allowEmptyPromptLaunch: true - }) - delivery = 'paste-submit' - } else if (args.promptDelivery === 'draft') { - const draftLaunchPlan = buildAgentDraftLaunchPlan({ - agent, - draft: trimmedInput, - cmdOverrides, - platform, - shell, - isRemote, - agentArgs: args.agentArgs, - sessionOptions: args.sessionOptions - }) - if (draftLaunchPlan) { - startupPlan = { - agent: draftLaunchPlan.agent, - launchCommand: draftLaunchPlan.launchCommand, - expectedProcess: draftLaunchPlan.expectedProcess, - followupPrompt: null, - launchConfig: draftLaunchPlan.launchConfig, - ...(draftLaunchPlan.sessionOptions - ? { sessionOptions: draftLaunchPlan.sessionOptions } - : {}), - ...(draftLaunchPlan.startupCommandDelivery - ? { startupCommandDelivery: draftLaunchPlan.startupCommandDelivery } - : {}), - ...(draftLaunchPlan.env ? { env: draftLaunchPlan.env } : {}) - } - delivery = 'draft-native' - } else { - startupPlan = buildAgentStartupPlan({ - agent, - prompt: '', - cmdOverrides, - platform, - shell, - isRemote, - agentArgs: args.agentArgs, - sessionOptions: args.sessionOptions, - allowEmptyPromptLaunch: true - }) - delivery = 'draft-paste' - } - } else if (TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start') { - startupPlan = buildAgentStartupPlan({ - agent, - prompt: '', - cmdOverrides, - platform, - shell, - isRemote, - agentArgs: args.agentArgs, - sessionOptions: args.sessionOptions, - allowEmptyPromptLaunch: true - }) - delivery = 'draft-paste' - } else { - startupPlan = buildAgentStartupPlan({ - agent, - prompt: trimmedInput, - cmdOverrides, - platform, - shell, - isRemote, - agentArgs: args.agentArgs, - sessionOptions: args.sessionOptions, - allowEmptyPromptLaunch: false - }) - delivery = 'argv' - } - - if (!startupPlan) { - return { - ok: false, - error: translate( - 'auto.lib.source.control.agent.action.plan.3f0ea9aa0d', - 'Could not build the agent launch command.' - ) - } - } - - const summary = - delivery === 'paste-submit' - ? 'The agent starts with no prompt, then Orca pastes and submits the command input after the TUI is ready.' - : delivery === 'draft-native' - ? 'The command input is prefilled as an editable draft by the agent launch command.' - : delivery === 'draft-paste' - ? 'The agent starts with no prompt, then Orca pastes the command input as an editable draft after the TUI is ready.' - : 'The command input is included in the launch command and submitted as the first turn.' - - return { - ok: true, - plan: startupPlan, - delivery, - commandLabel: startupPlan.launchCommand, - summary, - caveat: - 'This check builds Orca’s launch plan only. PATH, binary availability, account setup, and terminal startup failures are still caught by the real launch watchdog.' - } -} diff --git a/src/shared/agent-launch-tab-publication.ts b/src/shared/agent-launch-tab-publication.ts index 2eb8741988d..b4defa0e688 100644 --- a/src/shared/agent-launch-tab-publication.ts +++ b/src/shared/agent-launch-tab-publication.ts @@ -43,7 +43,8 @@ export type AgentLaunchTabPublishReply = | { requestId: string tabId: string - /** False when a tab with this id already existed and was reused: a retry. */ + /** True when the tab exists only for this launch: the window made it now, or made it for this + * launch just before asking (a desktop launch). False for a retry that found the tab. */ created: boolean placement: AgentLaunchPlacementReceipt }