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