feat(agent-launch): the desktop AI buttons start their agent through agent.launch (#25624)

* feat(agent-launch): host-assigned caller identity and a launch record written when the surface exists

Step 1 of the agent-launch unification, on main.

- The dispatcher stamps every request's caller from what its connection proved (runtime socket:
  the local CLI; the desktop's IPC: the desktop; a paired socket: its device). Params never set it.
- The launch record is written twice: once when the tab exists (what creation settled: on the
  launch command, a draft, or a submit still `unconfirmed`), and again once the prompt's fate is
  known. A restart in between finds the running agent instead of answering "unknown".
- A replay re-derives its terminal handle from the pane key in the running host, and shows
  `unconfirmed` only to callers that advertise agent.launch.prompt-unconfirmed.v1.
- The record store opens in its own slot, without building the chat host; the chat host is built
  on that same store.

Rebuilt from this PR's own commits (b9adf88da0, 0d7d9b5b30, c83f44dd72, 39d15d52c0) onto
main, without #24080/#24081. Conflicts: the delivery doc table (main's "line fits" row plus the
`unconfirmed` row), and main's journal-database open in install(), which now goes through the
record-store slot.

* feat(agent-launch): show an agent's tab at once, where the caller asked for it

An agent.launch now shows its terminal tab before admission and spawn, in
the requested placement (group and/or anchor tab), and the pane attaches to
the agent as soon as it runs. A pane whose agent can't start, or whose start
can't be confirmed, says so instead of becoming a plain shell, and keeps
saying so across restarts. A user's close of the tab or its pane during the
launch stops it and answers agent_launch_tab_closed. Whose view moves is
unchanged from main for every caller.

Rebuilt on main (with #24934) from the previous branch head 8f62858e60.

* feat(agent-launch): the desktop AI buttons start their agent through agent.launch

Part 3 of 3 of the agent-launch unification, on main. The AI buttons (Fix
checks, the source-control actions, commit/push recovery, Explain commit,
notes and annotation sends, session continuation) started their agent from
the window. They now ask the host's agent.launch to start it, with no
prompt, into the tab the window makes at the click, under one operation id
per click; the window's pane waits for the host's agent instead of spawning
a shell. The window then pastes the prompt with main's own paste, moved out
verbatim and started only once the host's agent holds the tab, so delivery
and follow-ups are exactly main's. A typed new-tab prompt and launches while
chat is the default keep main's path. The source-control dialog drops its
launch-command preview, which the host builds now.

* style(runtime): one-line the tab-order map so the headless browser-tabs runtime stays under its line limit

The merge of main (#25724's emitMobileSessionTabsSnapshot metadata) plus this branch's close mark put
the file one line over max-lines. Formatting only.

* fix(agent-launch): watch an AI button's agent for readiness from its first output

The window started watching for the agent's ready signal only after the host
answered agent.launch, so an agent that had already turned on bracketed paste
by then was never seen ready and its prompt waited out the readiness budget.

Readiness is now watched from the moment the tab's terminal exists, as main
watches it; the paste is written, and the chat copy seeded, only once the host
says its agent started in that tab.
This commit is contained in:
Brennan Benson
2026-10-07 01:18:57 -07:00
committed by GitHub
parent 385fe8ab4e
commit 47f4d3f527
38 changed files with 1442 additions and 460 deletions
@@ -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)
@@ -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({
</div>
) : null}
{deliveryPlan.status !== 'idle' ? (
<div
className={cn(
'rounded-md border px-3 py-2 text-xs',
deliveryPlan.status === 'error'
? 'border-destructive/30 bg-destructive/5 text-destructive'
: 'border-border bg-muted/30 text-muted-foreground'
)}
>
{deliveryPlan.status === 'error' ? (
<span className="inline-flex items-start gap-2">
<TriangleAlert className="mt-px size-3.5 shrink-0" />
{deliveryPlan.error}
</span>
) : (
<div className="space-y-1.5">
<div className="flex items-start gap-2 text-foreground">
<CheckCircle2 className="mt-px size-3.5 shrink-0 text-status-success" />
<span>{deliveryPlan.summary}</span>
</div>
<div className="truncate font-mono text-[11px]">
{translate(
'auto.components.right.sidebar.SourceControlAgentActionDialogForm.1bc0bdbb5e',
'Launch:'
)}{' '}
{deliveryPlan.commandLabel}
</div>
<div className="text-[11px]">{deliveryPlan.caveat}</div>
</div>
)}
{deliveryPlan.status === 'error' ? (
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
<span className="inline-flex items-start gap-2">
<TriangleAlert className="mt-px size-3.5 shrink-0" />
{deliveryPlan.error}
</span>
</div>
) : null}
</div>
@@ -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 }
}
@@ -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,
@@ -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
}
@@ -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<typeof vi.fn>
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(
@@ -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<string, { id: string; ptyId?: string | null; title?: string }[]>
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 }[]
@@ -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
@@ -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()
})
})
-1
View File
@@ -13057,7 +13057,6 @@
"ea4788705e": "Cancel",
"7ec6abbf2a": "Reset",
"f4f3c9ca4a": "Prompt template",
"1bc0bdbb5e": "Launch:",
"fe119187bb": "--model sonnet",
"bc8dc39f4b": "CLI arguments",
"b99c33cec5": "Settings",
-1
View File
@@ -12838,7 +12838,6 @@
"ea4788705e": "Cancelar",
"7ec6abbf2a": "Reiniciar",
"f4f3c9ca4a": "Plantilla de prompt",
"1bc0bdbb5e": "Lanzamiento:",
"fe119187bb": "--model sonnet",
"bc8dc39f4b": "Argumentos CLI",
"b99c33cec5": "Ajustes",
-1
View File
@@ -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",
-1
View File
@@ -12828,7 +12828,6 @@
"38b899cc02": "すべてのリポジトリ"
},
"SourceControlAgentActionDialogForm": {
"1bc0bdbb5e": "起動:",
"7ec6abbf2a": "リセット",
"f4f3c9ca4a": "コマンドテンプレート",
"fe119187bb": "--model sonnet",
-1
View File
@@ -12828,7 +12828,6 @@
"38b899cc02": "모든 리포지토리"
},
"SourceControlAgentActionDialogForm": {
"1bc0bdbb5e": "실행:",
"7ec6abbf2a": "재설정",
"f4f3c9ca4a": "프롬프트 템플릿",
"fe119187bb": "--model sonnet",
-1
View File
@@ -12828,7 +12828,6 @@
"38b899cc02": "所有仓库"
},
"SourceControlAgentActionDialogForm": {
"1bc0bdbb5e": "启动:",
"7ec6abbf2a": "重置",
"f4f3c9ca4a": "提示词模板",
"fe119187bb": "--model sonnet",
@@ -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'")
})
})
@@ -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.
@@ -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<string, unknown>) => ({
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()
}
@@ -108,3 +108,10 @@ export function createdTabOptions(store: LaunchFunnelStore): Record<string, unkn
export function createdTabGroupId(store: LaunchFunnelStore): string | undefined {
return store.createTab.mock.calls[0]?.[1]
}
/** The `agent.launchReplay` request a launch sent its host, or undefined when the window planned it. */
export function hostLaunchRequest(
callRuntimeRpc: ReturnType<typeof vi.fn>
): Record<string, unknown> | undefined {
return callRuntimeRpc.mock.calls.find(([, method]) => method === 'agent.launchReplay')?.[2]
}
@@ -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()
})
})
@@ -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<void>; release: () => void }
const holds = new Map<string, Hold>()
// 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<void> | 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))
}
@@ -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<boolean>
}): Promise<boolean> {
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
@@ -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
@@ -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({
@@ -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<typeof AgentStatusModule>()),
detectAgentStatusFromTitle: vi.fn().mockReturnValue(null)
}))
const testStore = vi.hoisted(() => {
const ref: { current: ReturnType<typeof createTestStore> | 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<string, unknown>) => Promise<unknown>>()
)
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => ({
...(await importOriginal<object>()),
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<typeof createTestStore>
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (error: unknown) => void
const promise = new Promise<T>((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<string, unknown> {
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<unknown>()
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<unknown>()
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<unknown>()
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<unknown>()
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<unknown>()
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<unknown>()
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()
})
})
@@ -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<string, SessionOptionValue>
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<string, SessionOptionValue> | undefined): {
sessionOptions?: Record<string, string>
} {
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<HostAgentLaunchOutcome> {
try {
return outcomeFromResult(
await callRuntimeRpc<unknown>({ 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<unknown>,
releaseHold: () => void
): Promise<HostAgentLaunchOutcome> {
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<HostAgentLaunchOutcome>
} {
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<unknown>({ 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) }
}
+8 -1
View File
@@ -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<boolean>
}): Promise<boolean> {
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
@@ -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
}))
+38 -24
View File
@@ -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
@@ -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<string, string[]> = {}
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<string, (data: string) => 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<HostAgentLaunchOutcome>((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<HostAgentLaunchOutcome>((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()
})
})
@@ -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<boolean> }) => ({
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<HostAgentLaunchOutcome>((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<boolean> {
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)
})
})
@@ -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 }
}
@@ -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<boolean>
}): 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() }
})
}
@@ -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.'
})
})
})
@@ -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<Record<TuiAgent, string>>
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 }
}
@@ -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')
})
})
@@ -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<Record<TuiAgent, string>>
agentArgs?: string | null
sessionOptions?: Record<string, SessionOptionValue>
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.'
}
}
+2 -1
View File
@@ -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
}