mirror of
https://github.com/stablyai/orca.git
synced 2026-10-09 00:02:39 +00:00
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 head8f62858e60. * 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:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13057,7 +13057,6 @@
|
||||
"ea4788705e": "Cancel",
|
||||
"7ec6abbf2a": "Reset",
|
||||
"f4f3c9ca4a": "Prompt template",
|
||||
"1bc0bdbb5e": "Launch:",
|
||||
"fe119187bb": "--model sonnet",
|
||||
"bc8dc39f4b": "CLI arguments",
|
||||
"b99c33cec5": "Settings",
|
||||
|
||||
@@ -12838,7 +12838,6 @@
|
||||
"ea4788705e": "Cancelar",
|
||||
"7ec6abbf2a": "Reiniciar",
|
||||
"f4f3c9ca4a": "Plantilla de prompt",
|
||||
"1bc0bdbb5e": "Lanzamiento:",
|
||||
"fe119187bb": "--model sonnet",
|
||||
"bc8dc39f4b": "Argumentos CLI",
|
||||
"b99c33cec5": "Ajustes",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -12828,7 +12828,6 @@
|
||||
"38b899cc02": "すべてのリポジトリ"
|
||||
},
|
||||
"SourceControlAgentActionDialogForm": {
|
||||
"1bc0bdbb5e": "起動:",
|
||||
"7ec6abbf2a": "リセット",
|
||||
"f4f3c9ca4a": "コマンドテンプレート",
|
||||
"fe119187bb": "--model sonnet",
|
||||
|
||||
@@ -12828,7 +12828,6 @@
|
||||
"38b899cc02": "모든 리포지토리"
|
||||
},
|
||||
"SourceControlAgentActionDialogForm": {
|
||||
"1bc0bdbb5e": "실행:",
|
||||
"7ec6abbf2a": "재설정",
|
||||
"f4f3c9ca4a": "프롬프트 템플릿",
|
||||
"fe119187bb": "--model sonnet",
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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
|
||||
}))
|
||||
|
||||
@@ -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.'
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user