Merge #24917's ready head (one launch per user action) into C2

Takes #24917's request ids everywhere a launch is started: a launch joins another only on an equal
id during its first attempt, and every launch call site passes one. C2's notes hold stays the only
one: the staged message carries the notes' keys and the hold derives from the saved outbox, so
#24917's in-memory hold and new-agent-prompt-outcome stay deleted. Its new test case (a chat
still starting is closed without waiting on its create) is ported into notes-carried-by-chat.
This commit is contained in:
Brennan Benson
2026-10-04 15:20:39 -07:00
73 changed files with 805 additions and 416 deletions
@@ -40,6 +40,7 @@ describe('launchDashboardAgent', () => {
expect(mocks.getKnownWorktreeById).toHaveBeenCalledWith('folder:docs', 'ssh:docs')
expect(mocks.setActiveWorktree).toHaveBeenCalledWith('folder:docs', 'ssh:docs')
expect(mocks.launchAgentInNewTab).toHaveBeenCalledWith({
requestId: expect.any(String),
agent: 'codex',
worktreeId: 'folder:docs',
launchSource: 'unknown'
@@ -1,4 +1,5 @@
import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab'
import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id'
import { useAppStore } from '@/store'
import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
import type { DashboardSpawnAgentArgs } from '../../../../shared/dashboard-snapshot'
@@ -15,6 +16,7 @@ export function launchDashboardAgent({ worktreeId, agent }: DashboardSpawnAgentA
state.setActiveWorktree(worktreeId, executionHostId)
return (
launchAgentInNewTab({
requestId: newAgentLaunchRequestId(),
agent,
worktreeId,
launchSource: 'unknown'
@@ -174,6 +174,7 @@ describe('FloatingTerminalWindowControls default-agent launch', () => {
clickLaunch()
expect(mocks.launchAgentInNewTab).toHaveBeenCalledExactlyOnceWith({
requestId: expect.any(String),
agent: 'claude',
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
launchSource: 'shortcut'
@@ -6,6 +6,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { getAgentCatalog, AgentIcon } from '@/lib/agent-catalog'
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab'
import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id'
import { useAppStore } from '@/store'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import {
@@ -67,6 +68,7 @@ export function FloatingTerminalWindowControls({
// Floating resolves the terminal-backed lane: a chat view over a PTY when the chat default is
// on, never a structured session.
const result = launchAgentInNewTab({
requestId: newAgentLaunchRequestId(),
agent: defaultAgent,
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
launchSource: 'shortcut'
@@ -127,8 +127,8 @@ describe('a chat pane over its own launch', () => {
it('keeps the selection its create seeded when a pick in another chat saves a new one', () => {
saveSelection('gpt-5.5')
startStructuredAgentLaunch('wt-first', 'codex')
const second = startStructuredAgentLaunch('wt-second', 'codex')
startStructuredAgentLaunch('wt-first', 'codex', { requestId: 'request-1' })
const second = startStructuredAgentLaunch('wt-second', 'codex', { requestId: 'request-2' })
const { result, rerender } = renderLaunchedChat('wt-second', second.sessionId)
expect(currentModel(result.current.optionSnapshot)).toBe('gpt-5.5')
@@ -156,7 +156,7 @@ describe('a chat pane over its own launch', () => {
})
: new Promise(() => {})
)
const launch = startStructuredAgentLaunch('wt-refused', 'codex')
const launch = startStructuredAgentLaunch('wt-refused', 'codex', { requestId: 'request-3' })
const { sessionId } = launch
const { result, rerender } = renderLaunchedChat('wt-refused', sessionId)
await act(async () => {
@@ -16,6 +16,7 @@ import { prepareAiVaultSessionContinuation } from './ai-vault-session-continuati
import type { AgentSessionContinuationRequest } from '@/lib/agent-session-continuation'
import { activateAiVaultStructuredSession } from '@/lib/activate-ai-vault-structured-session'
import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle'
import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id'
import {
activateAiVaultResumeWorkspace,
resumeAiVaultSessionInNewChat
@@ -161,7 +162,12 @@ export function useAiVaultSessionLaunchActions({
)
return
}
void resumeAiVaultSessionInNewChat(session, session.agent, worktreeId)
void resumeAiVaultSessionInNewChat(
session,
session.agent,
worktreeId,
newAgentLaunchRequestId()
)
},
[activeWorktree?.id, activeWorktreeId]
)
@@ -83,7 +83,7 @@ describe('resumeAiVaultSessionInNewChat', () => {
return { sessionId: 'session-1', tab: { id: 'agent-session:session-1' }, settlement }
})
await resumeAiVaultSessionInNewChat(session, 'codex', 'worktree-1')
await resumeAiVaultSessionInNewChat(session, 'codex', 'worktree-1', 'resume-click')
expect(mocks.beginStructuredAgentSessionProvisionalLaunch).toHaveBeenCalledWith(
expect.objectContaining({
@@ -104,7 +104,7 @@ describe('resumeAiVaultSessionInNewChat', () => {
settlement: Promise.resolve({ kind: 'failed', error })
})
await resumeAiVaultSessionInNewChat(session, 'codex', 'worktree-1')
await resumeAiVaultSessionInNewChat(session, 'codex', 'worktree-1', 'resume-click')
await vi.waitFor(() =>
expect(mocks.toastError).toHaveBeenCalledWith(
'Another chat is already holding this conversation.'
@@ -119,7 +119,7 @@ describe('resumeAiVaultSessionInNewChat', () => {
settlement: Promise.resolve({ kind: 'visibility-unknown', sessionId: 'session-1' })
})
await resumeAiVaultSessionInNewChat(session, 'codex', 'worktree-1')
await resumeAiVaultSessionInNewChat(session, 'codex', 'worktree-1', 'resume-click')
await Promise.resolve()
expect(mocks.toastError).not.toHaveBeenCalled()
})
@@ -7,6 +7,7 @@ import { hasRuntimeRpcErrorCode } from '../../../../shared/runtime-rpc-error-cod
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
import { prepareAiVaultSessionForResume } from '@/lib/ai-vault-session-resume-preparation'
import { adoptAgentSessionLaunchVerdict } from '@/lib/agent-session-launch-plan'
import type { AgentLaunchRequestId } from '@/lib/agent-launch-request-id'
import {
activateAndRevealFolderWorkspace,
activateAndRevealWorktree
@@ -28,7 +29,8 @@ export function activateAiVaultResumeWorkspace(workspaceId: string): boolean {
export async function resumeAiVaultSessionInNewChat(
session: AiVaultSession,
agent: AgentSessionHandleProvider,
worktreeId: string
worktreeId: string,
requestId: AgentLaunchRequestId
): Promise<void> {
try {
// Codex rows can live under a shared legacy home; the same preparation the terminal resume
@@ -36,6 +38,7 @@ export async function resumeAiVaultSessionInNewChat(
const preparedSession = await prepareAiVaultSessionForResume(session)
const plan = adoptAgentSessionLaunchVerdict({
route: 'structured-native-chat',
requestId,
agent,
worktreeId,
resumeFrom: { providerSessionId: preparedSession.sessionId }
@@ -1,5 +1,6 @@
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab'
import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type { Repo } from '../../../../shared/repo-types'
import type { TuiAgent } from '../../../../shared/tui-agent'
@@ -102,6 +103,7 @@ export async function runSourceControlAgentActionStart({
}
} else if (worktreeId) {
const result = launchAgentInNewTab({
requestId: newAgentLaunchRequestId(),
agent: selectedAgent,
worktreeId,
groupId: groupId ?? worktreeId,
@@ -1,12 +1,12 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
planAgentSessionLaunch: vi.fn(),
resolveAgentSessionLaunchRoute: vi.fn(),
getState: vi.fn(() => ({}))
}))
vi.mock('@/lib/agent-session-launch-plan', () => ({
planAgentSessionLaunch: mocks.planAgentSessionLaunch
resolveAgentSessionLaunchRoute: mocks.resolveAgentSessionLaunchRoute
}))
vi.mock('@/store', () => ({ useAppStore: { getState: mocks.getState } }))
@@ -15,7 +15,7 @@ import { sourceControlLaunchAppliesAgentArgs } from './source-control-launch-age
describe('sourceControlLaunchAppliesAgentArgs', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.planAgentSessionLaunch.mockReturnValue({ route: 'terminal-tui' })
mocks.resolveAgentSessionLaunchRoute.mockReturnValue('terminal-tui')
})
it('applies arguments when the user launches into a terminal by default', () => {
@@ -28,7 +28,7 @@ describe('sourceControlLaunchAppliesAgentArgs', () => {
})
it('drops arguments only when this launch would really be a structured session', () => {
mocks.planAgentSessionLaunch.mockReturnValue({ route: 'structured-native-chat' })
mocks.resolveAgentSessionLaunchRoute.mockReturnValue('structured-native-chat')
expect(
sourceControlLaunchAppliesAgentArgs({
agent: 'codex',
@@ -39,7 +39,7 @@ describe('sourceControlLaunchAppliesAgentArgs', () => {
it('keeps arguments for a chat-by-default user whose launch falls back to a terminal', () => {
// A remote host, an agent without a structured session, or a floating workspace all land here.
mocks.planAgentSessionLaunch.mockReturnValue({ route: 'legacy-native-chat' })
mocks.resolveAgentSessionLaunchRoute.mockReturnValue('legacy-native-chat')
expect(
sourceControlLaunchAppliesAgentArgs({
agent: 'codex',
@@ -55,7 +55,7 @@ describe('sourceControlLaunchAppliesAgentArgs', () => {
worktreeId: 'wt-1'
})
).toBe(true)
expect(mocks.planAgentSessionLaunch).not.toHaveBeenCalled()
expect(mocks.resolveAgentSessionLaunchRoute).not.toHaveBeenCalled()
})
it('names the repo and its host when the workspace does not exist yet', () => {
@@ -64,7 +64,7 @@ describe('sourceControlLaunchAppliesAgentArgs', () => {
repoId: 'repo-1',
executionHostId: 'ssh:build-box'
})
expect(mocks.planAgentSessionLaunch).toHaveBeenCalledWith(
expect(mocks.resolveAgentSessionLaunchRoute).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
workspace: {
@@ -4,7 +4,7 @@ import {
workspaceKindForWorktreeId,
type ProspectiveWorkspace
} from '@/lib/agent-launch-route-input'
import { planAgentSessionLaunch } from '@/lib/agent-session-launch-plan'
import { resolveAgentSessionLaunchRoute } from '@/lib/agent-session-launch-plan'
import { useAppStore } from '@/store'
export type SourceControlLaunchAgentArgsApplicabilityInput = {
@@ -47,9 +47,9 @@ export function sourceControlLaunchAppliesAgentArgs(
return true
}
return (
planAgentSessionLaunch(useAppStore.getState(), {
resolveAgentSessionLaunchRoute(useAppStore.getState(), {
agent: input.agent,
workspace: prospectiveWorkspace(input)
}).route !== 'structured-native-chat'
}) !== 'structured-native-chat'
)
}
@@ -2,6 +2,7 @@ import { toast } from 'sonner'
import type { AppState } from '@/store'
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab'
import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id'
import { getConnectionId } from '@/lib/connection-context'
import { planAgentCliArgsSuffix } from '@/lib/tui-agent-startup'
import {
@@ -156,6 +157,7 @@ export async function launchSourceControlRecoveryAgentWithDefault({
return false
}
const result = launchAgentInNewTab({
requestId: newAgentLaunchRequestId(),
agent,
worktreeId: activeWorktreeId,
groupId: activeGroupId ?? activeWorktreeId,
@@ -9,6 +9,7 @@ import {
import { getConnectionId } from '@/lib/connection-context'
import { detectLanguage } from '@/lib/language-detect'
import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab'
import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id'
import { resolveDefaultAgentForNewTab } from '@/lib/agent-tab-shortcuts'
import { translate } from '@/i18n/i18n'
import type { GitHistoryItem } from '../../../../../../shared/git-history'
@@ -280,6 +281,7 @@ export function useGitHistoryCommitActions({
`Run \`git show --no-ext-diff ${item.id}\` to inspect the full diff, then summarize what changed and why at a high level, calling out the most important files and any risks.`
].join('\n')
launchAgentInNewTab({
requestId: newAgentLaunchRequestId(),
agent,
worktreeId: activeWorktreeId,
prompt: explainPrompt,
@@ -18,6 +18,7 @@ import {
toFolderWorkspaceLinkedTask
} from './folder-workspace-composer-helpers'
import { planAgentSessionLaunch } from '@/lib/agent-session-launch-plan'
import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id'
import { beginStructuredAgentSessionProvisionalLaunch } from '@/lib/structured-agent-session-provisional-tab'
import { getNewWorkspaceProjectGroupHostId } from '@/lib/new-workspace-project-options'
import { useAppStore } from '@/store'
@@ -132,6 +133,7 @@ export async function submitFolderWorkspaceCreate({
quickAgent && linkedWorkItem ? resolveFolderWorkspaceLaunchDraft(linkedWorkItem, note) : null
const plan = quickAgent
? planAgentSessionLaunch(useAppStore.getState(), {
requestId: newAgentLaunchRequestId(),
agent: quickAgent,
workspace: {
kind: 'folder',
@@ -4,11 +4,7 @@ import type { ReactNode } from 'react'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { StructuredLaunchState } from '@/lib/structured-agent-session-launch-registry'
import {
BLANK_STRUCTURED_LAUNCH_REQUEST,
structuredLaunchRequest,
type StructuredLaunchAttempt
} from '@/lib/structured-agent-session-launch-request'
import type { StructuredLaunchAttempt } from '@/lib/structured-agent-session-launch-request'
vi.mock('@/hooks/useDetectedAgents', () => ({
useDetectedAgents: () => ({ detectedIds: ['claude', 'codex'] })
@@ -69,7 +65,8 @@ function registerLaunch(
outcome: 'pending' | 'failed',
attempt: StructuredLaunchAttempt = {
kind: 'first',
request: BLANK_STRUCTURED_LAUNCH_REQUEST,
requestId: `${agent}-pick`,
blank: true,
stagedEntry: null
}
): void {
@@ -114,14 +111,15 @@ function agentRowDisabled(label: string): string | null | undefined {
?.getAttribute('aria-disabled')
}
describe('QuickLaunchAgentMenuItems launch status', () => {
describe('QuickLaunchAgentMenuItems launches', () => {
beforeEach(() => {
localStorage.clear()
resetStructuredAgentLaunchRegistryForTests()
})
afterEach(cleanup)
it('keeps an agent whose chat failed to start launchable while a starting one waits', () => {
// Each pick is its own request, so a chat starting, failing or retrying never blocks one.
it('keeps every agent launchable while chats start, fail or retry', () => {
registerLaunch('claude', 'pending')
registerLaunch('codex', 'failed')
@@ -132,52 +130,43 @@ describe('QuickLaunchAgentMenuItems launch status', () => {
onFocusTerminal={vi.fn()}
/>
)
expect(agentRowDisabled('Claude')).toBe('true')
expect(agentRowDisabled('Claude')).toBe('false')
expect(agentRowDisabled('Codex')).toBe('false')
})
cleanup()
// A pick then opens a new chat; only a new start's own create would be joined.
it("keeps an agent launchable while a failed chat's Retry is in flight", () => {
registerLaunch('claude', 'pending')
registerLaunch('codex', 'pending', { kind: 'retry' })
render(
<QuickLaunchAgentMenuItems
worktreeId={WORKTREE_ID}
groupId="group-1"
onFocusTerminal={vi.fn()}
prompt="review notes"
/>
)
expect(agentRowDisabled('Claude')).toBe('true')
expect(agentRowDisabled('Codex')).toBe('false')
})
// A pick joins only a start of the same request; any other opens its own chat.
it('disables an agent only for the request its starting chat carries', () => {
registerLaunch('codex', 'pending', {
kind: 'first',
request: structuredLaunchRequest({ prompt: 'review notes' }),
stagedEntry: null
it('gives each pick its own request, so a second pick opens its own chat', () => {
launchMock.mockReset()
launchMock.mockReturnValue({
surface: { kind: 'local-agent-session', tabId: 'tab-1', sessionId: 'codex-session' }
})
const menu = (prompt?: string) => (
render(
<QuickLaunchAgentMenuItems
worktreeId={WORKTREE_ID}
groupId="group-1"
onFocusTerminal={vi.fn()}
{...(prompt ? { prompt } : {})}
prompt="review notes"
/>
)
const codexRow = document.querySelector('[title="Launch Codex in a new terminal"]')!
fireEvent.click(codexRow)
fireEvent.click(codexRow)
render(menu())
expect(agentRowDisabled('Codex')).toBe('false')
cleanup()
render(menu('other notes'))
expect(agentRowDisabled('Codex')).toBe('false')
cleanup()
render(menu('review notes'))
expect(agentRowDisabled('Codex')).toBe('true')
const requestIds = launchMock.mock.calls.map(([args]) => args.requestId)
expect(requestIds).toHaveLength(2)
expect(requestIds[0]).toEqual(expect.any(String))
expect(requestIds[1]).not.toBe(requestIds[0])
})
// Why: a new chat saves the notes' keys with its message; the launch's own result holds them
@@ -1,5 +1,5 @@
import React, { useCallback } from 'react'
import { Loader2, Settings as SettingsIcon } from 'lucide-react'
import { Settings as SettingsIcon } from 'lucide-react'
import { toast } from 'sonner'
import { DropdownMenuItem, DropdownMenuShortcut } from '@/components/ui/dropdown-menu'
import { getAgentCatalog, AgentIcon } from '@/lib/agent-catalog'
@@ -8,7 +8,7 @@ import { useAgentDetectionTargetForWorktree } from '@/hooks/useAgentDetectionTar
import { useDetectedAgents } from '@/hooks/useDetectedAgents'
import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel'
import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab'
import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle'
import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id'
import type { TuiAgent } from '../../../../shared/tui-agent'
import type { LaunchSource } from '../../../../shared/telemetry-events'
import {
@@ -16,8 +16,6 @@ import {
filterEnabledTuiAgents
} from '../../../../shared/tui-agent-selection'
import { translate } from '@/i18n/i18n'
import { useStructuredAgentLaunchStatus } from '@/lib/structured-agent-session-launch'
import { structuredLaunchRequest } from '@/lib/structured-agent-session-launch-request'
import type { NotesSendHandOff } from '@/lib/notes-send-in-flight'
export type QuickLaunchAgentMenuItemsProps = {
@@ -127,13 +125,6 @@ function QuickLaunchAgentMenuItemsInner({
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
const newAgentShortcut = useOptionalShortcutLabel('tab.newAgent')
// One hook per structured provider: the launch registry is keyed by agent, and hooks cannot run
// inside the agent list's render loop. Only a start of this menu's own request is joined.
const launchRequest = structuredLaunchRequest({ prompt, promptDelivery })
const structuredLaunchStatusByAgent = {
claude: useStructuredAgentLaunchStatus(worktreeId, 'claude', launchRequest),
codex: useStructuredAgentLaunchStatus(worktreeId, 'codex', launchRequest)
}
const openAgentSettings = useCallback(() => {
openSettingsTarget({ pane: 'agents', repoId: null })
@@ -148,6 +139,7 @@ function QuickLaunchAgentMenuItemsInner({
const entry = getCatalogEntry(agent)
const label = entry?.label ?? agent
const result = launchAgentInNewTab({
requestId: newAgentLaunchRequestId(),
agent,
worktreeId,
groupId,
@@ -230,14 +222,12 @@ function QuickLaunchAgentMenuItemsInner({
{agents.map((agent) => {
const entry = getCatalogEntry(agent)
const label = entry?.label ?? agent
const isStructuredLaunchPending =
isAgentSessionHandleProvider(agent) && structuredLaunchStatusByAgent[agent] === 'pending'
const showsDefaultAgentShortcut =
newAgentShortcut !== null && defaultAgent !== 'blank' && agent === defaultAgent
return (
<DropdownMenuItem
key={agent}
disabled={disabled || isStructuredLaunchPending}
disabled={disabled}
onSelect={() => runLaunch(agent)}
className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium"
title={translate(
@@ -246,11 +236,7 @@ function QuickLaunchAgentMenuItemsInner({
{ value0: label }
)}
>
{isStructuredLaunchPending ? (
<Loader2 className="size-3.5 shrink-0 animate-spin" aria-hidden="true" />
) : (
<AgentIcon agent={agent} size={14} />
)}
<AgentIcon agent={agent} size={14} />
<span className="flex-1">{label}</span>
{showsDefaultAgentShortcut ? (
<DropdownMenuShortcut>{newAgentShortcut}</DropdownMenuShortcut>
@@ -18,9 +18,6 @@ import type { AppState } from '@/store/types'
// Why: the real entry-action module pulls in runtime IPC + the app store; the
// keyboard behavior under test only needs a controllable option list.
const entryOptionsMock = vi.hoisted(() => ({ options: [] as TabEntryOption[] }))
const structuredLaunchMock = vi.hoisted(() => ({
status: 'idle' as 'idle' | 'pending' | 'unknown'
}))
vi.mock('./tab-create-entry-action', () => ({
getTabEntryOptions: () => entryOptionsMock.options,
createTabEntryAllowAbsolutePathsSelector: () => () => true,
@@ -38,9 +35,6 @@ vi.mock('@/lib/agent-catalog', () => ({
getAgentCatalog: () => [],
AgentIcon: () => null
}))
vi.mock('@/lib/structured-agent-session-launch', () => ({
useStructuredAgentLaunchStatus: () => structuredLaunchMock.status
}))
import TabBarCreateEntry from './TabBarCreateEntry'
@@ -176,7 +170,6 @@ afterEach(() => {
act(() => root.unmount())
container.remove()
vi.clearAllMocks()
structuredLaunchMock.status = 'idle'
})
describe('TabBarCreateEntry keyboard navigation', () => {
@@ -267,29 +260,6 @@ describe('TabBarCreateEntry keyboard navigation', () => {
expect(onLaunchAgent).toHaveBeenCalledWith('gemini')
})
it('does not relaunch Codex when a structured launch is already pending', () => {
structuredLaunchMock.status = 'pending'
const agentOptions: TabAgentLaunchOption[] = [
{ agent: 'codex', aliases: ['codex'], label: 'Codex' }
]
const onLaunchAgent = vi.fn()
mount(
<TabBarCreateEntry
worktreeId="wt"
groupId="g"
menuOpen
agentOptions={agentOptions}
onOpenEntry={vi.fn().mockResolvedValue(undefined)}
onLaunchAgent={onLaunchAgent}
/>
)
setQuery('cod')
submitForm()
expect(onLaunchAgent).not.toHaveBeenCalled()
})
it('exposes the highlighted row to assistive tech via aria-activedescendant', () => {
entryOptionsMock.options = [fileOption('a.ts'), fileOption('b.ts'), fileOption('c.ts')]
mount(
@@ -33,10 +33,6 @@ import {
getTabEntryOmniboxPlaceholder
} from './tab-create-entry-copy'
import { EMPTY_AGENT_OPTIONS, EMPTY_MENU_OPTIONS } from './tab-create-entry-empty-options'
import { useStructuredAgentLaunchStatus } from '@/lib/structured-agent-session-launch'
import { BLANK_STRUCTURED_LAUNCH_REQUEST } from '@/lib/structured-agent-session-launch-request'
import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle'
import type { TuiAgent } from '../../../../shared/tui-agent'
import type { TabEntryActionClassification } from './tab-create-entry-classifier'
import type { TabBarCreateEntryProps } from './tab-create-entry-props'
@@ -63,14 +59,6 @@ function TabBarCreateEntrySession({
const [error, setError] = useState<string | null>(null)
const [switchError, setSwitchError] = useState<string | null>(null)
const [selectionGuidance, setSelectionGuidance] = useState<string | null>(null)
// One hook per structured provider: the launch registry is keyed by agent, and hooks cannot run
// inside the option render loop.
const structuredLaunchStatusByAgent = {
claude: useStructuredAgentLaunchStatus(worktreeId, 'claude', BLANK_STRUCTURED_LAUNCH_REQUEST),
codex: useStructuredAgentLaunchStatus(worktreeId, 'codex', BLANK_STRUCTURED_LAUNCH_REQUEST)
}
const isStructuredLaunchPending = (agent: TuiAgent): boolean =>
isAgentSessionHandleProvider(agent) && structuredLaunchStatusByAgent[agent] === 'pending'
// null = follow ranking (deferred tabs can prepend); set on arrow keys only.
const [pinnedOptionId, setPinnedOptionId] = useState<string | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
@@ -239,9 +227,6 @@ function TabBarCreateEntrySession({
return
}
if (selectedOption.kind === 'agent') {
if (isStructuredLaunchPending(selectedOption.option.agent)) {
return
}
onLaunchAgent?.(selectedOption.option.agent)
onDidOpenEntry?.()
return
@@ -390,15 +375,8 @@ function TabBarCreateEntrySession({
id={resultOptionDomId(index)}
option={option}
selected={index === activeSelectedIndex}
disabled={
disabled ||
pending ||
(option.kind === 'agent' && isStructuredLaunchPending(option.option.agent))
}
loading={
(pending && index === activeSelectedIndex) ||
(option.kind === 'agent' && isStructuredLaunchPending(option.option.agent))
}
disabled={disabled || pending}
loading={pending && index === activeSelectedIndex}
onClick={() => {
setSelectionGuidance(null)
submitOption(option)
@@ -7,6 +7,7 @@ import {
launchAgentInNewTab,
shouldQueueTerminalFocusAfterMenuClose
} from '@/lib/launch-agent-in-new-tab'
import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id'
import type { WindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities'
import { useAppStore } from '../../store'
import type { TabAgentLaunchOption } from './tab-agent-launch-options'
@@ -223,6 +224,7 @@ export function useTabBarCreateMenuController({
const launchAgentFromNewTabEntry = (agent: TuiAgent): void => {
const option = agentLaunchOptions.find((candidate) => candidate.agent === agent)
const result = launchAgentInNewTab({
requestId: newAgentLaunchRequestId(),
agent,
worktreeId,
groupId: resolvedGroupId,
@@ -16,6 +16,7 @@ import type { TuiAgent } from '../../../../shared/tui-agent'
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
import { translate } from '@/i18n/i18n'
import { planAgentSessionLaunch } from '@/lib/agent-session-launch-plan'
import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id'
type ForkAgentSessionFromPaneArgs = {
pane: ManagedPane
@@ -230,6 +231,7 @@ export async function startAgentSessionFork(fork: PreparedAgentSessionFork): Pro
return copyAgentSessionForkContext(fork)
}
const agentSessionLaunchPlan = planAgentSessionLaunch(useAppStore.getState(), {
requestId: newAgentLaunchRequestId(),
agent: fork.agent,
workspace: { kind: 'git-worktree', worktreeId: forkWorktreeId },
prompt: fork.prompt,
@@ -10,6 +10,7 @@ import {
} from '@/runtime/web-runtime-session'
import { openMobileEmulatorTab } from '@/lib/open-mobile-emulator-tab'
import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab'
import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id'
import { buildDuplicatedBrowserTabOptions } from '@/lib/duplicate-browser-tab-options'
import { browserWorkspaceHasRemoteOwner } from '@/runtime/remote-browser-tab-ownership'
import { getClientCreationActionPolicy } from '@/lib/client-creation-action-policy'
@@ -96,6 +97,7 @@ export function useTerminalCreateActions(controller: TerminalColdActivationContr
state.activeGroupIdByWorktree[activeWorktreeId] ??
state.groupsByWorktree[activeWorktreeId]?.[0]?.id
const result = launchAgentInNewTab({
requestId: newAgentLaunchRequestId(),
agent,
worktreeId: activeWorktreeId,
groupId: targetGroupId,
@@ -38,6 +38,7 @@ import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/na
import { queueWorkspaceActivationTerminalFocus } from '@/lib/workspace-activation-terminal-focus'
import { useAppStore } from '@/store'
import { planAgentSessionLaunch } from '@/lib/agent-session-launch-plan'
import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id'
import { beginFullCreationStructuredLaunch } from './full-creation-structured-launch'
import { finalizeFullCreation } from './full-creation-finalization'
import { buildFullCreationIssueCommand } from './full-creation-issue-command'
@@ -130,6 +131,7 @@ export function useFullCreationExecution(input: FullCreationExecutionInput) {
}
const launchPlan = planAgentSessionLaunch(useAppStore.getState(), {
requestId: newAgentLaunchRequestId(),
agent: tuiAgent,
workspace: {
kind: selectedRepoIsGit ? 'git-worktree' : 'folder',
@@ -46,7 +46,7 @@ import { resolveQuickCreateLinkedWorkItemPrompt } from '@/lib/linked-work-item-c
import { buildQuickComposerStartup } from './quick-startup-plan'
import { buildQuickCreationRequest } from './quick-creation-request'
import type { PendingSmartGitHubSubmitResolution } from './source-selection-decisions'
import { planAgentSessionLaunch } from '@/lib/agent-session-launch-plan'
import { resolveAgentSessionLaunchRoute } from '@/lib/agent-session-launch-plan'
export function useQuickCreationExecution(input: QuickCreationExecutionInput) {
const {
@@ -197,7 +197,7 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) {
const promptDelivery = quickDraftPrompt ? 'draft' : 'auto-submit'
// Why: the verdict is persisted on the request as data and re-entered once the worktree exists.
const agentLaunchRoute = agent
? planAgentSessionLaunch(useAppStore.getState(), {
? resolveAgentSessionLaunchRoute(useAppStore.getState(), {
agent,
workspace: {
kind: selectedRepoIsGit ? 'git-worktree' : 'folder',
@@ -209,7 +209,7 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) {
prompt: quickDraftPrompt ?? quickPrompt,
promptDelivery,
initialSessionOptions: startupPlan?.sessionOptions
}).route
})
: 'terminal-tui'
const structuredLaunch = agentLaunchRoute === 'structured-native-chat'
@@ -62,7 +62,7 @@ const cases = callerProfileCases()
async function launch(profile: AgentLaunchCallerProfile) {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
return launchAgentInNewTab({ ...profile.args })
return launchAgentInNewTab({ requestId: 'request-1', ...profile.args })
}
describe('agent launch caller arguments and permission bypass', () => {
@@ -116,7 +116,7 @@ describe('agent launch caller arguments and permission bypass', () => {
async (agent, _mode, bypassFlag) => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({ agent, worktreeId: 'wt-1' })
launchAgentInNewTab({ requestId: 'request-2', agent, worktreeId: 'wt-1' })
expect(queuedStartupCommand(store)).toContain(bypassFlag)
}
@@ -128,7 +128,7 @@ describe('agent launch caller arguments and permission bypass', () => {
store.settings = { ...store.settings, agentDefaultArgs: { [agent]: '' } }
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({ agent, worktreeId: 'wt-1' })
launchAgentInNewTab({ requestId: 'request-3', agent, worktreeId: 'wt-1' })
// A stored empty string owns the key, so it beats the shipped bypass default.
expect(queuedStartupCommand(store)).not.toContain(bypassFlag)
@@ -138,7 +138,7 @@ describe('agent launch caller arguments and permission bypass', () => {
it('carries a bypass posture that lives in the environment rather than in argv', async () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({ agent: 'goose', worktreeId: 'wt-1' })
launchAgentInNewTab({ requestId: 'request-4', agent: 'goose', worktreeId: 'wt-1' })
// Goose has no bypass flag; its default posture is an env var, and a migration that carried
// only argv would silently downgrade it.
@@ -148,7 +148,12 @@ describe('agent launch caller arguments and permission bypass', () => {
it('restores the shipped bypass default when a caller passes agentArgs as undefined', async () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1', agentArgs: undefined })
launchAgentInNewTab({
requestId: 'request-5',
agent: 'codex',
worktreeId: 'wt-1',
agentArgs: undefined
})
// Characterized, not endorsed: an explicit `undefined` is indistinguishable from an omitted
// key here, so a caller that resolved "apply no saved arguments" to `undefined` gets the
@@ -160,7 +165,12 @@ describe('agent launch caller arguments and permission bypass', () => {
it('launches without any arguments when a caller passes agentArgs as null', async () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1', agentArgs: null })
launchAgentInNewTab({
requestId: 'request-6',
agent: 'codex',
worktreeId: 'wt-1',
agentArgs: null
})
expect(queuedStartupCommand(store)).toBe('codex')
expect(queuedStartupPayload(store)?.agentArgsOverride).toBeNull()
@@ -170,7 +180,12 @@ describe('agent launch caller arguments and permission bypass', () => {
store.settings = { ...store.settings, agentDefaultArgs: { codex: '--model stored' } }
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1', agentArgs: '--model per-launch' })
launchAgentInNewTab({
requestId: 'request-7',
agent: 'codex',
worktreeId: 'wt-1',
agentArgs: '--model per-launch'
})
expect(queuedStartupCommand(store)).toBe("codex '--model' 'per-launch'")
})
@@ -179,7 +194,7 @@ describe('agent launch caller arguments and permission bypass', () => {
store.settings = { ...store.settings, agentDefaultEnv: { codex: { CODEX_PROFILE: 'team' } } }
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
launchAgentInNewTab({ requestId: 'request-8', agent: 'codex', worktreeId: 'wt-1' })
const payload = queuedStartupPayload(store)
expect(payload?.env).toEqual({ CODEX_PROFILE: 'team' })
@@ -200,7 +215,11 @@ describe('agent launch caller arguments and permission bypass', () => {
}
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
const result = launchAgentInNewTab({
requestId: 'request-9',
agent: 'codex',
worktreeId: 'wt-1'
})
expect(result?.startupPlan.sessionOptions).toEqual({
model: 'gpt-5.2-codex',
@@ -221,7 +240,11 @@ describe('agent launch caller arguments and permission bypass', () => {
}
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
const result = launchAgentInNewTab({
requestId: 'request-10',
agent: 'codex',
worktreeId: 'wt-1'
})
expect(result?.startupPlan.sessionOptions).toBeUndefined()
expect(queuedStartupCommand(store)).not.toContain("'-m'")
@@ -50,7 +50,7 @@ const cases = callerProfileCases()
async function launch(profile: AgentLaunchCallerProfile) {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
return launchAgentInNewTab({ ...profile.args })
return launchAgentInNewTab({ requestId: 'request-1', ...profile.args })
}
describe('agent launch caller placement and telemetry', () => {
@@ -138,7 +138,7 @@ describe('agent launch caller placement and telemetry', () => {
it('creates the tab before queueing its startup command', async () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
launchAgentInNewTab({ requestId: 'request-2', agent: 'codex', worktreeId: 'wt-1' })
// Why: the terminal pane snapshots pending startup in useState on first render, so a startup
// queued after mount is never seen.
@@ -150,7 +150,12 @@ describe('agent launch caller placement and telemetry', () => {
it('seeds working status for a Command Code prompt that rides argv', async () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({ agent: 'command-code', worktreeId: 'wt-1', prompt: 'fix the spinner' })
launchAgentInNewTab({
requestId: 'request-3',
agent: 'command-code',
worktreeId: 'wt-1',
prompt: 'fix the spinner'
})
expect(queuedStartupPayload(store)?.initialAgentStatus).toEqual({
agent: 'command-code',
@@ -161,7 +166,12 @@ describe('agent launch caller placement and telemetry', () => {
it('leaves initial agent status unset for every other argv prompt launch', async () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1', prompt: 'fix the spinner' })
launchAgentInNewTab({
requestId: 'request-4',
agent: 'codex',
worktreeId: 'wt-1',
prompt: 'fix the spinner'
})
expect(queuedStartupPayload(store)).not.toHaveProperty('initialAgentStatus')
})
@@ -8,9 +8,10 @@ import type { LaunchAgentInNewTabArgs } from '@/lib/launch-agent-in-new-tab'
* module graphs: a migration rewrites the funnel's internals, not what a caller hands it, so the
* observable outcome of each caller's argument shape is the thing that must survive.
*/
/** Each call site mints its own request id, so a profile carries none. */
export type CallerLaunchArgs = Omit<
LaunchAgentInNewTabArgs,
'beforeSurfaceOpen' | 'agentSessionLaunchPlan' | 'onPromptDelivered'
'beforeSurfaceOpen' | 'agentSessionLaunchPlan' | 'onPromptDelivered' | 'requestId'
>
export type AgentLaunchCallerProfile = {
@@ -129,7 +129,7 @@ const cases = callerProfileCases()
async function launch(profile: AgentLaunchCallerProfile) {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
return launchAgentInNewTab({ ...profile.args })
return launchAgentInNewTab({ requestId: 'request-1', ...profile.args })
}
describe('agent launch caller prompt transport', () => {
@@ -181,7 +181,11 @@ describe('agent launch caller prompt transport', () => {
const onPromptDelivered = vi.fn()
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({ ...profile.args, onPromptDelivered })
const result = launchAgentInNewTab({
requestId: 'request-2',
...profile.args,
onPromptDelivered
})
await result?.promptDeliveryResult
if (profile.args.prompt === undefined) {
@@ -199,6 +203,7 @@ describe('agent launch caller prompt transport', () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({
requestId: 'request-3',
agent: row.agent,
worktreeId: 'wt-1',
prompt: PROMPT,
@@ -226,6 +231,7 @@ describe('agent launch caller prompt transport', () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({
requestId: 'request-4',
agent: 'claude',
worktreeId: 'wt-1',
prompt: PROMPT,
@@ -241,6 +247,7 @@ describe('agent launch caller prompt transport', () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({
requestId: 'request-5',
agent: 'codex',
worktreeId: 'wt-1',
prompt: ' \n ',
@@ -257,6 +264,7 @@ describe('agent launch caller prompt transport', () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({
requestId: 'request-6',
agent: 'codex',
worktreeId: 'wt-1',
prompt: PROMPT,
@@ -90,7 +90,7 @@ const cases = callerProfileCases()
async function launch(profile: AgentLaunchCallerProfile) {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
return launchAgentInNewTab({ ...profile.args })
return launchAgentInNewTab({ requestId: 'request-1', ...profile.args })
}
describe('agent launch caller routing', () => {
@@ -200,7 +200,11 @@ describe('agent launch caller routing', () => {
cancel: vi.fn()
})
const result = launchAgentInNewTab({ agent: 'claude', worktreeId: 'wt-1' })
const result = launchAgentInNewTab({
requestId: 'request-2',
agent: 'claude',
worktreeId: 'wt-1'
})
// The server admits the chat before any of it exists here, so the surface is the host's.
expect(result?.surface.kind).toBe('host-published')
@@ -227,6 +231,7 @@ describe('agent launch caller routing', () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({
requestId: 'request-3',
agent: 'claude',
worktreeId: 'wt-1',
prompt: 'fix the flaky test',
@@ -261,7 +266,11 @@ describe('agent launch caller routing', () => {
serverReports([])
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({ agent: 'claude', worktreeId: 'wt-1' })
const result = launchAgentInNewTab({
requestId: 'request-4',
agent: 'claude',
worktreeId: 'wt-1'
})
expect(result?.surface).toEqual({ kind: 'host-published' })
expect(mockLaunchAgentInStructuredNewTab).not.toHaveBeenCalled()
@@ -271,7 +280,12 @@ describe('agent launch caller routing', () => {
const beforeSurfaceOpen = vi.fn(() => false)
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1', beforeSurfaceOpen })
const result = launchAgentInNewTab({
requestId: 'request-5',
agent: 'codex',
worktreeId: 'wt-1',
beforeSurfaceOpen
})
expect(result).toBeNull()
expect(beforeSurfaceOpen).toHaveBeenCalledExactlyOnceWith({ kind: 'local-terminal' })
@@ -284,7 +298,12 @@ describe('agent launch caller routing', () => {
const beforeSurfaceOpen = vi.fn(() => false)
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1', beforeSurfaceOpen })
const result = launchAgentInNewTab({
requestId: 'request-6',
agent: 'codex',
worktreeId: 'wt-1',
beforeSurfaceOpen
})
expect(result).toBeNull()
expect(beforeSurfaceOpen).toHaveBeenCalledExactlyOnceWith({ kind: 'host-published' })
@@ -296,7 +315,12 @@ describe('agent launch caller routing', () => {
const beforeSurfaceOpen = vi.fn(() => false)
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1', beforeSurfaceOpen })
launchAgentInNewTab({
requestId: 'request-7',
agent: 'codex',
worktreeId: 'wt-1',
beforeSurfaceOpen
})
// Why: the structured route cannot name its surface before the session id exists, so the
// funnel wraps the caller's hook and the structured executor decides when to ask.
@@ -314,7 +338,12 @@ describe('agent launch caller routing', () => {
const beforeSurfaceOpen = vi.fn(() => undefined)
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1', beforeSurfaceOpen })
const result = launchAgentInNewTab({
requestId: 'request-8',
agent: 'codex',
worktreeId: 'wt-1',
beforeSurfaceOpen
})
expect(result?.surface.kind).toBe('local-terminal')
expect(store.createTab).toHaveBeenCalledTimes(1)
@@ -326,6 +355,7 @@ describe('agent launch caller routing', () => {
// Why: unbalanced quoting is the real shape behind every caller's "could not build the launch
// command" toast — the arguments cannot be tokenized, so no surface should be opened at all.
const result = launchAgentInNewTab({
requestId: 'request-9',
agent: 'codex',
worktreeId: 'wt-1',
agentArgs: "--model 'gpt-5.5"
@@ -0,0 +1,10 @@
import { createBrowserUuid } from '@/lib/browser-uuid'
/** Names one user action that starts an agent: a click, a menu pick, a send. Minted once where that
* action is handled and passed down unchanged, so a re-delivery of the action (a double click, a
* caller retrying its own call) is the same request and every other action is a new one. */
export type AgentLaunchRequestId = string
export function newAgentLaunchRequestId(): AgentLaunchRequestId {
return createBrowserUuid()
}
@@ -41,17 +41,19 @@ describe('launch route transcript readability', () => {
buildAgentLaunchRouteInput(store, { agent: 'grok', workspace: WORKSPACE })
.nativeChatTranscriptIsLocalReadable
).toBe(true)
expect(planAgentSessionLaunch(store, { agent: 'grok', workspace: WORKSPACE }).route).toBe(
'legacy-native-chat'
)
expect(
planAgentSessionLaunch(store, { requestId: 'request-1', agent: 'grok', workspace: WORKSPACE })
.route
).toBe('legacy-native-chat')
})
it('keeps a remote repo off native chat through the same fallback', () => {
const store = storeWithAmbiguousWorktreeRows('build-box')
expect(planAgentSessionLaunch(store, { agent: 'grok', workspace: WORKSPACE }).route).toBe(
'terminal-tui'
)
expect(
planAgentSessionLaunch(store, { requestId: 'request-2', agent: 'grok', workspace: WORKSPACE })
.route
).toBe('terminal-tui')
})
it('has no repo to fall back to when the workspace names none', () => {
@@ -59,6 +61,7 @@ describe('launch route transcript readability', () => {
expect(
planAgentSessionLaunch(store, {
requestId: 'request-3',
agent: 'grok',
workspace: { kind: 'git-worktree', worktreeId: WORKTREE_ID }
}).route
@@ -53,7 +53,7 @@ import {
} from './agent-session-launch-plan'
const routeFor = (appStore: AgentLaunchRouteStore, args: AgentLaunchRouteArgs) =>
planAgentSessionLaunch(appStore, args).route
planAgentSessionLaunch(appStore, { ...args, requestId: 'route-check' }).route
const structuredFeasibleFor = (appStore: AgentLaunchRouteStore, args: AgentLaunchRouteArgs) =>
structuredAgentSessionLaunchFeasible(appStore, { ...args, settings: STRUCTURED_SETTINGS })
@@ -44,6 +44,7 @@ describe('planAgentSessionLaunch', () => {
it('decides the route once, from the builder input, and never again on launch', async () => {
const plan = planAgentSessionLaunch(store, {
requestId: 'request-1',
agent: 'codex',
workspace: { kind: 'git-worktree', worktreeId: 'wt-1' },
prompt: 'Fix it',
@@ -64,10 +65,11 @@ describe('planAgentSessionLaunch', () => {
expect(mocks.resolveAgentLaunchRoute).toHaveBeenCalledOnce()
})
it('hands the settle loop exactly the prompt, mode, resume source, and delivery hook it planned on', async () => {
it('hands the settle loop exactly the request, prompt, mode, resume source, and delivery hook it planned on', async () => {
const onPromptDelivered = vi.fn()
const resumeFrom = { providerSessionId: 'provider-1' }
const plan = planAgentSessionLaunch(store, {
requestId: 'request-2',
agent: 'claude',
workspace: { kind: 'folder', worktreeId: 'folder:ws-1' },
prompt: 'Review this',
@@ -81,6 +83,7 @@ describe('planAgentSessionLaunch', () => {
'folder:ws-1',
'claude',
{
requestId: 'request-2',
prompt: 'Review this',
promptDelivery: 'submit-after-ready',
resumeFrom,
@@ -94,6 +97,7 @@ describe('planAgentSessionLaunch', () => {
it('sends no delivery fields the request did not carry', async () => {
const plan = planAgentSessionLaunch(store, {
requestId: 'request-3',
agent: 'codex',
workspace: { kind: 'folder', worktreeId: 'folder:ws-1' }
})
@@ -102,13 +106,14 @@ describe('planAgentSessionLaunch', () => {
expect(mocks.beginStructuredAgentLaunchSettlement).toHaveBeenCalledWith(
'folder:ws-1',
'codex',
{ executionHostId: 'local' },
{ requestId: 'request-3', executionHostId: 'local' },
hooks
)
})
it('returns null for an agent that cannot hold a structured session even on the structured route', async () => {
const plan = planAgentSessionLaunch(store, {
requestId: 'request-4',
agent: 'gemini',
workspace: { kind: 'git-worktree', worktreeId: 'wt-1' }
})
@@ -120,6 +125,7 @@ describe('planAgentSessionLaunch', () => {
it('launches into the workspace created after planning when the target names one', async () => {
const plan = planAgentSessionLaunch(store, {
requestId: 'request-5',
agent: 'codex',
workspace: { kind: 'git-worktree', repoId: 'repo-1' },
prompt: 'Fix it',
@@ -130,13 +136,19 @@ describe('planAgentSessionLaunch', () => {
expect(mocks.beginStructuredAgentLaunchSettlement).toHaveBeenCalledWith(
'wt-created',
'codex',
{ prompt: 'Fix it', promptDelivery: 'auto-submit', executionHostId: 'local' },
{
requestId: 'request-5',
prompt: 'Fix it',
promptDelivery: 'auto-submit',
executionHostId: 'local'
},
hooks
)
})
it('refuses to launch a prospective workspace that was never created', async () => {
const plan = planAgentSessionLaunch(store, {
requestId: 'request-6',
agent: 'codex',
workspace: { kind: 'git-worktree', repoId: 'repo-1' }
})
@@ -198,6 +210,7 @@ describe('adoptAgentSessionLaunchVerdict', () => {
it('re-enters a persisted verdict without resolving the route again', async () => {
const plan = adoptAgentSessionLaunchVerdict({
requestId: 'request-7',
route: 'structured-native-chat',
agent: 'codex',
prompt: 'Fix it',
@@ -212,13 +225,14 @@ describe('adoptAgentSessionLaunchVerdict', () => {
expect(mocks.beginStructuredAgentLaunchSettlement).toHaveBeenCalledWith(
'wt-recovered',
'codex',
{ prompt: 'Fix it', promptDelivery: 'draft' },
{ requestId: 'request-7', prompt: 'Fix it', promptDelivery: 'draft' },
hooks
)
})
it('keeps a non-structured verdict out of the loop', async () => {
const plan = adoptAgentSessionLaunchVerdict({
requestId: 'request-8',
route: 'terminal-tui',
agent: 'codex',
worktreeId: 'wt-1'
@@ -24,8 +24,11 @@ import {
type StructuredAgentLaunchSettlement
} from '@/lib/structured-agent-launch-settlement'
import type { StructuredAgentLaunchOptions } from '@/lib/structured-agent-session-launch'
import type { AgentLaunchRequestId } from '@/lib/agent-launch-request-id'
export type AgentSessionLaunchRequest = AgentLaunchRouteArgs & {
/** The user action this launch serves, minted where that action is handled. */
requestId: AgentLaunchRequestId
resumeFrom?: StructuredAgentSessionResumeSource
onPromptDelivered?: () => void
carriedNoteKeys?: readonly string[]
@@ -38,6 +41,8 @@ export type AgentSessionLaunchRequest = AgentLaunchRouteArgs & {
*/
export type AgentSessionLaunchVerdict = {
route: AgentLaunchRoute
/** The user action this launch serves; a re-entry with this verdict is that same action. */
requestId: AgentLaunchRequestId
agent: TuiAgent
worktreeId?: string
/** The host the structured route was decided for; the chat is created there. */
@@ -80,6 +85,7 @@ export type AgentSessionLaunchPlan = Readonly<AgentSessionLaunchVerdict> & {
function structuredLaunchOptions(verdict: AgentSessionLaunchVerdict): StructuredAgentLaunchOptions {
return {
requestId: verdict.requestId,
...(verdict.prompt !== undefined ? { prompt: verdict.prompt } : {}),
...(verdict.promptDelivery ? { promptDelivery: verdict.promptDelivery } : {}),
...(verdict.resumeFrom ? { resumeFrom: verdict.resumeFrom } : {}),
@@ -156,6 +162,14 @@ export function structuredAgentSessionLaunchFeasible(
return structuredAgentLaunchSupported({ ...buildAgentLaunchRouteInput(store, args), settings })
}
/** The route a launch would take, for a caller that only branches on it and launches nothing. */
export function resolveAgentSessionLaunchRoute(
store: AgentLaunchRouteStore,
request: AgentLaunchRouteArgs
): AgentLaunchRoute {
return resolveAgentLaunchRoute(buildAgentLaunchRouteInput(store, request))
}
/** The one place a launch route is decided. Delivery mode is fixed here too, so the settle loop
* later receives exactly the prompt and mode the route was decided on. */
export function planAgentSessionLaunch(
@@ -168,6 +182,7 @@ export function planAgentSessionLaunch(
route === 'structured-native-chat' ? parseExecutionHostId(input.executionHostId)?.id : undefined
return adoptAgentSessionLaunchVerdict({
route,
requestId: request.requestId,
agent: request.agent,
...(executionHostId ? { executionHostId } : {}),
...(request.workspace.worktreeId ? { worktreeId: request.workspace.worktreeId } : {}),
@@ -11,6 +11,7 @@ import {
import type { AgentDetectionTarget } from '@/hooks/useDetectedAgents'
import { workspaceKindForWorktreeId } from '@/lib/agent-launch-route-input'
import { planAgentSessionLaunch } from '@/lib/agent-session-launch-plan'
import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id'
import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab'
// Why bounded: detection only decides chat vs shell, so a slow host must not hold the workspace empty.
@@ -91,7 +92,9 @@ export function openDefaultAgentChatInEmptyWorkspace(
if (!agent) {
return null
}
// No user gesture: opening this empty workspace is the one action this chat serves.
const agentSessionLaunchPlan = planAgentSessionLaunch(state, {
requestId: newAgentLaunchRequestId(),
agent,
workspace: { kind: workspaceKindForWorktreeId(worktreeId), worktreeId }
})
@@ -3,6 +3,7 @@ import { getConnectionId } from '@/lib/connection-context'
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import { findGithubPrWorkspaceAttachment } from '@/lib/github-work-item-workspace-attachment'
import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab'
import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id'
import { launchWorkItemDirect } from '@/lib/launch-work-item-direct'
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
import { CLIENT_PLATFORM } from '@/lib/new-workspace'
@@ -188,6 +189,7 @@ export async function startFixChecksAgent(args: StartFixChecksAgentArgs): Promis
}
let revealFailed = false
const result = launchAgentInNewTab({
requestId: newAgentLaunchRequestId(),
agent,
worktreeId: targetWorktreeId,
groupId: args.groupId ?? targetWorktreeId,
@@ -78,6 +78,7 @@ describe('launchAgentInNewTab initial cwd', () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({
requestId: 'request-1',
agent: 'claude',
worktreeId: 'wt-1',
initialCwd: '/repo/worktree/packages/app'
@@ -91,6 +92,7 @@ describe('launchAgentInNewTab initial cwd', () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({
requestId: 'request-2',
agent: 'claude',
worktreeId: 'wt-1',
groupId: 'group-1',
@@ -113,6 +115,7 @@ describe('launchAgentInNewTab initial cwd', () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({
requestId: 'request-3',
agent: 'claude',
worktreeId: 'wt-1',
prompt: 'continue the unfinished task',
@@ -92,7 +92,12 @@ function worktreeOn(hostId: string, path: string): StoreWorktree {
async function launchOnLinux(): Promise<void> {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({ agent: 'claude-agent-teams', worktreeId: 'wt-1', launchPlatform: 'linux' })
launchAgentInNewTab({
requestId: 'request-1',
agent: 'claude-agent-teams',
worktreeId: 'wt-1',
launchPlatform: 'linux'
})
}
function queuedCommand(): string {
@@ -52,6 +52,7 @@ describe('launchAgentInNewTab main-window surface', () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({
requestId: 'request-1',
agent: 'opencode',
worktreeId: FLOATING_TERMINAL_WORKTREE_ID
})
@@ -73,7 +74,11 @@ describe('launchAgentInNewTab main-window surface', () => {
const store = seedMainWindowOnEditor()
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({ agent: 'opencode', worktreeId: MAIN_WORKTREE_ID })
const result = launchAgentInNewTab({
requestId: 'request-2',
agent: 'opencode',
worktreeId: MAIN_WORKTREE_ID
})
const tabId = result?.surface.kind === 'local-terminal' ? result.surface.tabId : null
expect(tabId).not.toBeNull()
@@ -94,7 +94,7 @@ describe('launchAgentInNewTab terminal tab activation', () => {
it('shows terminals in the worktree it launched into', async () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
launchAgentInNewTab({ requestId: 'request-1', agent: 'codex', worktreeId: 'wt-1' })
expect(mockCreateTab.mock.calls[0]?.[3]).not.toHaveProperty('activate')
// Why: an unscoped call targets the active worktree — the main window — whatever worktree the
@@ -117,6 +117,7 @@ describe('launchAgentInNewTab terminal tab activation', () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({
requestId: 'request-2',
agent: 'codex',
worktreeId: FLOATING_TERMINAL_WORKTREE_ID
})
@@ -73,6 +73,7 @@ export function launchStructuredAgentFromNewTab(args: {
const terminal = args.openTerminal(
adoptAgentSessionLaunchVerdict({
route: 'terminal-tui',
requestId: plan.requestId,
agent: plan.agent,
worktreeId: args.worktreeId
})
@@ -27,6 +27,7 @@ import { launchAgentInStructuredNewTab } from './launch-agent-in-new-tab-structu
type Delivery = 'auto-submit' | 'submit-after-ready' | 'draft'
const structuredPlan = (prompt: string, promptDelivery: Delivery) =>
adoptAgentSessionLaunchVerdict({
requestId: 'request-1',
route: 'structured-native-chat',
agent: 'codex',
worktreeId: 'wt-1',
@@ -80,6 +80,7 @@ describe('launchAgentInNewTab paired web runtime', () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({
requestId: 'request-1',
agent: 'claude',
worktreeId: 'wt-1',
groupId: 'group-1'
@@ -113,6 +114,7 @@ describe('launchAgentInNewTab paired web runtime', () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({
requestId: 'request-2',
agent: 'codex',
worktreeId: 'wt-1',
prompt: 'fix the spinner',
@@ -164,6 +164,7 @@ describe('launchAgentInNewTab Windows shell quoting', () => {
const prompt = 'x'.repeat(25_000)
launchAgentInNewTab({
requestId: 'request-1',
agent: 'claude',
worktreeId: 'wt-1',
prompt,
@@ -185,6 +186,7 @@ describe('launchAgentInNewTab Windows shell quoting', () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({
requestId: 'request-2',
agent: 'claude',
worktreeId: 'wt-1',
prompt: "review Bob's change",
@@ -205,6 +207,7 @@ describe('launchAgentInNewTab Windows shell quoting', () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({
requestId: 'request-3',
agent: 'claude',
worktreeId: 'wt-1',
launchPlatform: 'win32'
@@ -223,6 +226,7 @@ describe('launchAgentInNewTab Windows shell quoting', () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({
requestId: 'request-4',
agent: 'claude',
worktreeId: 'wt-1',
launchPlatform: 'win32'
@@ -241,6 +245,7 @@ describe('launchAgentInNewTab Windows shell quoting', () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({
requestId: 'request-5',
agent: 'codex',
worktreeId: 'wt-1',
prompt: 'fix the spinner',
@@ -262,6 +267,7 @@ describe('launchAgentInNewTab Windows shell quoting', () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({
requestId: 'request-6',
agent: 'claude',
worktreeId: 'wt-1',
prompt: "review Bob's change",
@@ -282,10 +288,7 @@ describe('launchAgentInNewTab Windows shell quoting', () => {
store.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: 'C:\\remote\\repo' }]
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({
agent: 'claude',
worktreeId: 'wt-1'
})
launchAgentInNewTab({ requestId: 'request-7', agent: 'claude', worktreeId: 'wt-1' })
expect(mockQueueTabStartupCommand).toHaveBeenCalledWith(
'tab-1',
@@ -318,6 +321,7 @@ describe('launchAgentInNewTab Windows shell quoting', () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({
requestId: 'request-8',
agent: 'claude',
worktreeId: 'wt-1',
prompt: "review Bob's change",
@@ -357,7 +361,7 @@ describe('launchAgentInNewTab Windows shell quoting', () => {
}
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
launchAgentInNewTab({ requestId: 'request-9', agent: 'codex', worktreeId: 'wt-1' })
const queued = mockQueueTabStartupCommand.mock.calls.at(-1)?.[1] as { command: string }
expect(queued.command).toContain(`'don'"'"'t'`)
@@ -137,6 +137,13 @@ vi.mock('@/runtime/web-runtime-session', () => ({
isWebTerminalSurfaceTabId: vi.fn(() => false)
}))
/** One click that launches Command Code in wt-1, a terminal-route agent. */
const COMMAND_CODE_CLICK = {
requestId: 'command-code-click',
agent: 'command-code',
worktreeId: 'wt-1'
} as const
describe('launchAgentInNewTab', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -185,10 +192,7 @@ describe('launchAgentInNewTab', () => {
it('stamps the launched agent on the new tab for immediate provider icon bootstrap', async () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({
agent: 'codex',
worktreeId: 'wt-1'
})
launchAgentInNewTab({ requestId: 'request-1', agent: 'codex', worktreeId: 'wt-1' })
expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, {
launchAgent: 'codex'
@@ -204,6 +208,7 @@ describe('launchAgentInNewTab', () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({
requestId: 'request-2',
agent: 'codex',
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
launchPlatform: 'win32'
@@ -233,6 +238,7 @@ describe('launchAgentInNewTab', () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({
requestId: 'request-3',
agent: 'codex',
worktreeId: 'wt-1',
prompt: 'large generated prompt',
@@ -271,6 +277,7 @@ describe('launchAgentInNewTab', () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({
requestId: 'request-4',
agent: 'grok',
worktreeId: 'wt-1',
prompt: 'large generated prompt',
@@ -303,7 +310,7 @@ describe('launchAgentInNewTab', () => {
store.repos = [{ id: 'repo-1', connectionId: 'ssh-target-1', path: '/repo' }]
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({ agent: 'grok', worktreeId: 'wt-1' })
launchAgentInNewTab({ requestId: 'request-5', agent: 'grok', worktreeId: 'wt-1' })
expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, {
launchAgent: 'grok',
@@ -324,6 +331,7 @@ describe('launchAgentInNewTab', () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({
requestId: 'request-6',
agent: 'claude',
worktreeId: 'wt-1',
prompt: 'https://github.com/o/r/issues/12',
@@ -356,6 +364,7 @@ describe('launchAgentInNewTab', () => {
const prompt = 'Reproduce first\n\nhttps://github.com/o/r/issues/12'
launchAgentInNewTab({
requestId: 'request-7',
agent: 'claude',
worktreeId: 'wt-1',
prompt,
@@ -372,6 +381,7 @@ describe('launchAgentInNewTab', () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({
requestId: 'request-8',
agent: 'codex',
worktreeId: 'wt-1',
quickCommandLabel: 'Review'
@@ -402,6 +412,7 @@ describe('launchAgentInNewTab', () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({
requestId: 'request-9',
agent: 'codex',
worktreeId: 'wt-1',
prompt: 'Review this diff',
@@ -435,6 +446,7 @@ describe('launchAgentInNewTab', () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({
requestId: 'request-10',
agent: 'codex',
worktreeId: 'wt-1',
prompt: 'Review this diff',
@@ -475,6 +487,7 @@ describe('launchAgentInNewTab', () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({
requestId: 'request-11',
agent: 'claude',
worktreeId: 'wt-1',
prompt: 'review before sending',
@@ -512,7 +525,7 @@ describe('launchAgentInNewTab', () => {
}
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
launchAgentInNewTab({ requestId: 'request-12', agent: 'codex', worktreeId: 'wt-1' })
expect(mockCreateWebRuntimeSessionTerminal).toHaveBeenCalledWith(
expect.objectContaining({
@@ -538,7 +551,7 @@ describe('launchAgentInNewTab', () => {
}
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
launchAgentInNewTab({ requestId: 'request-13', agent: 'codex', worktreeId: 'wt-1' })
expect(mockCreateWebRuntimeSessionTerminal).toHaveBeenCalledWith(
expect.objectContaining({
@@ -565,10 +578,7 @@ describe('launchAgentInNewTab', () => {
}
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({
agent: 'claude',
worktreeId: 'wt-1'
})
launchAgentInNewTab({ requestId: 'request-14', agent: 'claude', worktreeId: 'wt-1' })
await Promise.resolve()
expect(mockToastError).toHaveBeenCalledWith(
@@ -581,8 +591,7 @@ describe('launchAgentInNewTab', () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({
agent: 'command-code',
worktreeId: 'wt-1',
...COMMAND_CODE_CLICK,
prompt: 'fix the spinner'
})
@@ -602,6 +611,7 @@ describe('launchAgentInNewTab', () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({
requestId: 'request-16',
agent: 'claude',
worktreeId: 'wt-1',
prompt: 'review this before sending',
@@ -616,6 +626,7 @@ describe('launchAgentInNewTab', () => {
const prompt = 'x'.repeat(25_000)
const result = launchAgentInNewTab({
requestId: 'request-17',
agent: 'claude',
worktreeId: 'wt-1',
prompt,
@@ -652,6 +663,7 @@ describe('launchAgentInNewTab', () => {
try {
const result = launchAgentInNewTab({
requestId: 'request-18',
agent: 'claude',
worktreeId: 'wt-1',
prompt,
@@ -672,8 +684,7 @@ describe('launchAgentInNewTab', () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({
agent: 'command-code',
worktreeId: 'wt-1',
...COMMAND_CODE_CLICK,
prompt: 'large generated prompt',
promptDelivery: 'submit-after-ready'
})
@@ -734,8 +745,7 @@ describe('launchAgentInNewTab', () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({
agent: 'command-code',
worktreeId: 'wt-1',
...COMMAND_CODE_CLICK,
prompt: 'pending prompt',
promptDelivery: 'submit-after-ready'
})
@@ -761,8 +771,7 @@ describe('launchAgentInNewTab', () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({
agent: 'command-code',
worktreeId: 'wt-1',
...COMMAND_CODE_CLICK,
prompt: 'large generated prompt',
promptDelivery: 'submit-after-ready'
})
@@ -784,8 +793,7 @@ describe('launchAgentInNewTab', () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({
agent: 'command-code',
worktreeId: 'wt-1',
...COMMAND_CODE_CLICK,
prompt: 'large generated prompt',
promptDelivery: 'submit-after-ready'
})
@@ -809,8 +817,7 @@ describe('launchAgentInNewTab', () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({
agent: 'command-code',
worktreeId: 'wt-1',
...COMMAND_CODE_CLICK,
prompt: 'large generated prompt',
promptDelivery: 'submit-after-ready'
})
@@ -831,8 +838,7 @@ describe('launchAgentInNewTab', () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({
agent: 'command-code',
worktreeId: 'wt-1',
...COMMAND_CODE_CLICK,
prompt: 'large generated prompt',
promptDelivery: 'submit-after-ready'
})
@@ -853,8 +859,7 @@ describe('launchAgentInNewTab', () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({
agent: 'command-code',
worktreeId: 'wt-1',
...COMMAND_CODE_CLICK,
prompt: 'large generated prompt',
promptDelivery: 'submit-after-ready'
})
@@ -870,6 +875,7 @@ describe('launchAgentInNewTab', () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({
requestId: 'request-26',
agent: 'codex',
worktreeId: 'wt-1',
prompt: 'large generated prompt',
+26 -15
View File
@@ -31,8 +31,19 @@ import {
planAgentSessionLaunch,
type AgentSessionLaunchPlan
} from '@/lib/agent-session-launch-plan'
import type { AgentLaunchRequestId } from '@/lib/agent-launch-request-id'
export type LaunchAgentInNewTabArgs = {
/** The user action this launch serves: minted where that action is handled, or carried by the
* route the caller already planned for it. */
type LaunchAgentInNewTabRequest =
| { requestId: AgentLaunchRequestId; agentSessionLaunchPlan?: undefined }
| {
/** Keeps a preflighted route authoritative across workspace creation. */
agentSessionLaunchPlan: AgentSessionLaunchPlan
requestId?: undefined
}
export type LaunchAgentInNewTabArgs = LaunchAgentInNewTabRequest & {
agent: TuiAgent
worktreeId: string
/** Tab group the user launched from; keeps split-group launches in that pane instead of the active group. */
@@ -60,8 +71,6 @@ export type LaunchAgentInNewTabArgs = {
* terminal route, whose readiness signal the client watches itself.
*/
onPromptDeliveryUnconfirmed?: () => void
/** Keeps a preflighted route authoritative across workspace creation. */
agentSessionLaunchPlan?: AgentSessionLaunchPlan
/** The launch seeds a workspace being opened, so its PTY spawn must not reshuffle Recent. */
pendingActivationSpawn?: boolean
/** Lets a workspace reveal itself before the selected surface opens. */
@@ -118,7 +127,6 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent
onPromptDelivered,
onPromptDeliveryUnconfirmed,
carriedNoteKeys,
agentSessionLaunchPlan,
pendingActivationSpawn,
beforeSurfaceOpen
} = args
@@ -174,17 +182,19 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent
// Why first: a structured chat is created on whichever runtime owns the workspace, a paired
// server included, so only a non-structured route falls through to the host-published terminal.
const plan =
agentSessionLaunchPlan ??
planAgentSessionLaunch(store, {
agent,
workspace: { kind: workspaceKind, worktreeId },
prompt: trimmedPrompt,
promptDelivery: viewModePromptDelivery,
tuiCustomization: { cwd: initialCwd },
initialSessionOptions: startupPlan.sessionOptions,
onPromptDelivered,
...(carriedNoteKeys ? { carriedNoteKeys } : {})
})
args.requestId === undefined
? args.agentSessionLaunchPlan
: planAgentSessionLaunch(store, {
requestId: args.requestId,
agent,
workspace: { kind: workspaceKind, worktreeId },
prompt: trimmedPrompt,
promptDelivery: viewModePromptDelivery,
tuiCustomization: { cwd: initialCwd },
initialSessionOptions: startupPlan.sessionOptions,
onPromptDelivered,
...(carriedNoteKeys ? { carriedNoteKeys } : {})
})
if (plan?.route === 'structured-native-chat') {
const structured = launchStructuredAgentFromNewTab({
plan,
@@ -196,6 +206,7 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent
launchAgentInNewTabInternal({
...args,
beforeSurfaceOpen: undefined,
requestId: undefined,
agentSessionLaunchPlan: terminalPlan
})
})
@@ -2,6 +2,7 @@ import { toast } from 'sonner'
import { getAgentLabel } from '@/lib/agent-catalog'
import { getConnectionIdFromState } from '@/lib/connection-context'
import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab'
import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { useAppStore } from '@/store'
import { isTuiAgentEnabled } from '../../../shared/tui-agent-selection'
@@ -84,6 +85,7 @@ export async function launchAgentSessionContinuation({
// could vanish silently (#22479).
let deliveryUnconfirmed = false
const result = launchAgentInNewTab({
requestId: newAgentLaunchRequestId(),
agent,
worktreeId,
...(groupId ? { groupId } : {}),
@@ -255,7 +255,11 @@ describe('structured chat adoption guard on the launch path', () => {
const { launchAgentInNewTab, shouldQueueTerminalFocusAfterMenuClose } =
await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
const result = launchAgentInNewTab({
requestId: 'request-1',
agent: 'codex',
worktreeId: 'wt-1'
})
expect(result).toMatchObject({
surface: {
@@ -295,7 +299,11 @@ describe('structured chat adoption guard on the launch path', () => {
}
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
const result = launchAgentInNewTab({
requestId: 'request-2',
agent: 'codex',
worktreeId: 'wt-1'
})
expect(result).toMatchObject({
surface: { kind: 'local-agent-session', sessionId: 'codex-session-1' }
@@ -313,7 +321,11 @@ describe('structured chat adoption guard on the launch path', () => {
it('takes the structured path for Claude, naming Claude as the create provider', async () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({ agent: 'claude', worktreeId: 'wt-1' })
const result = launchAgentInNewTab({
requestId: 'request-3',
agent: 'claude',
worktreeId: 'wt-1'
})
expect(result).toMatchObject({
surface: { kind: 'local-agent-session', sessionId: 'codex-session-1' }
@@ -331,7 +343,7 @@ describe('structured chat adoption guard on the launch path', () => {
it('keeps a native-chat agent with no structured adapter on the terminal-backed path', async () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({ agent: 'openclaude', worktreeId: 'wt-1' })
launchAgentInNewTab({ requestId: 'request-4', agent: 'openclaude', worktreeId: 'wt-1' })
expect(mockCreateStructuredCodexSessionLaunchIntent).not.toHaveBeenCalled()
expect(mockCreateTab).toHaveBeenCalled()
@@ -343,8 +355,8 @@ describe('structured chat adoption guard on the launch path', () => {
hostCapabilities = capabilities
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({ agent: 'claude', worktreeId: 'wt-1' })
launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
launchAgentInNewTab({ requestId: 'request-5', agent: 'claude', worktreeId: 'wt-1' })
launchAgentInNewTab({ requestId: 'request-6', agent: 'codex', worktreeId: 'wt-1' })
expect(mockCreateStructuredCodexSessionLaunchIntent).not.toHaveBeenCalled()
expect(mockCreateTab).toHaveBeenCalledTimes(2)
@@ -357,7 +369,11 @@ describe('structured chat adoption guard on the launch path', () => {
store.settings.openAgentTabsInChatByDefault = false
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
const result = launchAgentInNewTab({
requestId: 'request-7',
agent: 'codex',
worktreeId: 'wt-1'
})
expect(result?.surface).toEqual({ kind: 'local-terminal', tabId: 'tab-1' })
expect(mockLaunchStructuredCodexSession).not.toHaveBeenCalled()
@@ -376,7 +392,11 @@ describe('structured chat adoption guard on the launch path', () => {
mockLaunchStructuredCodexSession.mockRejectedValueOnce(refusal)
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
const result = launchAgentInNewTab({
requestId: 'request-8',
agent: 'codex',
worktreeId: 'wt-1'
})
expect(result).toMatchObject({
surface: { kind: 'local-agent-session', sessionId: 'codex-session-1' },
@@ -404,6 +424,7 @@ describe('structured chat adoption guard on the launch path', () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({
requestId: 'request-9',
agent: 'codex',
worktreeId: 'wt-1',
prompt: 'start this task',
@@ -421,7 +442,7 @@ describe('structured chat adoption guard on the launch path', () => {
expect(mockPasteDraftWhenAgentReady).not.toHaveBeenCalled()
})
it('coalesces repeated structured launches for one worktree while the host is starting', async () => {
it('coalesces one action delivered twice for one worktree while the host is starting', async () => {
let resolveLaunch!: (receipt: { sessionId: string; fence: number }) => void
mockLaunchStructuredCodexSession.mockImplementationOnce(
() =>
@@ -429,8 +450,16 @@ describe('structured chat adoption guard on the launch path', () => {
)
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const first = launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
const second = launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
const first = launchAgentInNewTab({
requestId: 'double-click',
agent: 'codex',
worktreeId: 'wt-1'
})
const second = launchAgentInNewTab({
requestId: 'double-click',
agent: 'codex',
worktreeId: 'wt-1'
})
expect(first).toMatchObject({ surface: { kind: 'local-agent-session' } })
expect(second).toMatchObject({ surface: { kind: 'local-agent-session' } })
@@ -450,10 +479,10 @@ describe('structured chat adoption guard on the launch path', () => {
})
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
launchAgentInNewTab({ requestId: 'double-click', agent: 'codex', worktreeId: 'wt-1' })
await vi.waitFor(() => expect(mockRefreshLocalStructuredSessionTabs).toHaveBeenCalledTimes(1))
launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
launchAgentInNewTab({ requestId: 'double-click', agent: 'codex', worktreeId: 'wt-1' })
expect(mockLaunchStructuredCodexSession).toHaveBeenCalledTimes(1)
store.unifiedTabsByWorktree['wt-1'] = [
@@ -505,7 +534,11 @@ describe('structured chat adoption guard on the launch path', () => {
])
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const unknown = launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
const unknown = launchAgentInNewTab({
requestId: 'request-14',
agent: 'codex',
worktreeId: 'wt-1'
})
await expect(unknown?.structuredSettlement).resolves.toEqual({
kind: 'visibility-unknown',
sessionId: firstIntent.sessionId
@@ -540,7 +573,7 @@ describe('structured chat adoption guard on the launch path', () => {
groupId: 'group-1'
}
]
launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
launchAgentInNewTab({ requestId: 'request-15', agent: 'codex', worktreeId: 'wt-1' })
await vi.waitFor(() => expect(mockLaunchStructuredCodexSession).toHaveBeenCalledTimes(3))
expect(mockCreateStructuredCodexSessionLaunchIntent).toHaveBeenCalledTimes(2)
expect(mockLaunchStructuredCodexSession).toHaveBeenCalledTimes(3)
@@ -551,6 +584,7 @@ describe('structured chat adoption guard on the launch path', () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({
requestId: 'request-16',
agent: 'codex',
worktreeId: 'wt-1',
prompt: 'start this task'
@@ -575,6 +609,7 @@ describe('structured chat adoption guard on the launch path', () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({
requestId: 'request-17',
agent: 'codex',
worktreeId: 'wt-1',
prompt: 'large generated prompt',
@@ -592,7 +627,7 @@ describe('structured chat adoption guard on the launch path', () => {
store.repos = [{ id: 'repo-1', connectionId: 'ssh-a', path: '/repo' }]
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
launchAgentInNewTab({ requestId: 'request-18', agent: 'codex', worktreeId: 'wt-1' })
expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, {
launchAgent: 'codex',
@@ -605,7 +640,7 @@ describe('structured chat adoption guard on the launch path', () => {
store.repos = [{ id: 'repo-1', connectionId: 'runtime-ssh-a', path: '/repo' }]
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
launchAgentInNewTab({ requestId: 'request-19', agent: 'codex', worktreeId: 'wt-1' })
expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, {
launchAgent: 'codex',
@@ -16,6 +16,7 @@ import { adoptAgentSessionLaunchVerdict } from './agent-session-launch-plan'
import { beginDirectWorkItemStructuredLaunch } from './launch-work-item-direct-agent-routing'
const structuredPlan: AgentSessionLaunchPlan = adoptAgentSessionLaunchVerdict({
requestId: 'request-1',
route: 'structured-native-chat',
agent: 'codex',
worktreeId: 'worktree-1',
@@ -69,7 +70,10 @@ describe('beginDirectWorkItemStructuredLaunch', () => {
it('skips structured opening for non-structured routes', () => {
expect(
beginDirectWorkItemStructuredLaunch({
plan: adoptAgentSessionLaunchVerdict({ ...structuredPlan, route: 'legacy-native-chat' }),
plan: adoptAgentSessionLaunchVerdict({
...structuredPlan,
route: 'legacy-native-chat'
}),
primaryTabId: null,
beforeOpen: vi.fn()
})
@@ -7,6 +7,7 @@ import type {
AgentSessionLaunchPlan,
planAgentSessionLaunch
} from '@/lib/agent-session-launch-plan'
import type { AgentLaunchRequestId } from '@/lib/agent-launch-request-id'
import {
buildDirectWorkItemStartup,
resolveDirectWorkItemAgent
@@ -39,6 +40,8 @@ export async function prepareDirectWorkItemAgentLaunch(args: {
launchPlatform?: NodeJS.Platform
repoProjectRuntime?: Parameters<typeof buildDirectWorkItemStartup>[0]['repoProjectRuntime']
planLaunch: typeof planAgentSessionLaunch
/** The start action this launch serves. */
requestId: AgentLaunchRequestId
}): Promise<DirectWorkItemAgentLaunchPreparation> {
const launchConnectionId = getConnectionId(args.worktreeId) ?? args.repoConnectionId
const agentSelection = await resolveDirectWorkItemAgent({
@@ -93,6 +96,7 @@ export async function prepareDirectWorkItemAgentLaunch(args: {
effectiveAgent === null
? null
: args.planLaunch(args.latestStore, {
requestId: args.requestId,
agent: effectiveAgent,
workspace: { kind: 'git-worktree', worktreeId: args.worktreeId, repoId: args.repoId },
prompt: args.draftContent,
@@ -40,6 +40,7 @@ import {
planAgentSessionLaunch,
type AgentSessionLaunchPlan
} from '@/lib/agent-session-launch-plan'
import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id'
/**
* "Use" flow: create the workspace, activate it, launch the default agent,
@@ -214,7 +215,8 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom
promptDelivery,
launchPlatform: args.launchPlatform,
repoProjectRuntime,
planLaunch: planAgentSessionLaunch
planLaunch: planAgentSessionLaunch,
requestId: newAgentLaunchRequestId()
})
if (launchPreparation.unavailable) {
activateAndRevealWorktree(worktreeId, {
@@ -274,7 +274,9 @@ afterEach(() => {
/** "Send notes to > New agent": the launch saves the notes' keys with its staged message. */
function sendNotesToNewAgent() {
// One user gesture, one request id: a launch joins another only on an equal id.
return startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
requestId: 'request-1',
prompt: NOTES,
promptDelivery: 'submit-after-ready',
carriedNoteKeys: [KEY_A]
@@ -341,6 +343,17 @@ describe('notes sent to a new agent', () => {
expect(isNoteInFlight(KEY_A)).toBe(false)
})
it('come back when a chat still starting is closed, without waiting on its create', async () => {
mocks.launch.mockImplementation(() => new Promise(() => undefined))
sendNotesToNewAgent()
expect(isNoteInFlight(KEY_A)).toBe(true)
cancelStructuredAgentLaunch(WORKTREE_ID, chat.sessionId)
await settle()
expect(isNoteInFlight(KEY_A)).toBe(false)
expect(mocks.clearDeliveredDiffComments).not.toHaveBeenCalled()
})
it('come back when this window closes the failed chat, which hands no text back', async () => {
mocks.launch.mockRejectedValue(new StructuredAgentSessionCreateRefusalError('unsupported'))
sendNotesToNewAgent()
@@ -6,6 +6,7 @@ import {
planAgentSessionLaunch,
type AgentSessionLaunchPlan
} from '@/lib/agent-session-launch-plan'
import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id'
import {
buildDismissedOnboardingFolderAgentStartup,
type OnboardingFolderAgentStartup
@@ -39,7 +40,9 @@ export function resolveDismissedOnboardingFolderAgentLaunch(args: {
if (!startup || !agent) {
return { agent: null, plan: null }
}
// Resolved once per added folder: adding it is the one action this chat serves.
const plan = planAgentSessionLaunch(args.store, {
requestId: newAgentLaunchRequestId(),
agent,
workspace: { kind: 'folder', executionHostId: args.executionHostId },
initialSessionOptions: startup.sessionOptions
@@ -141,6 +141,7 @@ describe('runQuickCommandInNewTab', () => {
expect(result).toEqual({ tabId: 'tab-agent' })
expect(mocks.launchAgentInNewTab).toHaveBeenCalledWith({
requestId: expect.any(String),
agent: 'codex',
prompt: 'Review this diff',
worktreeId: 'repo::worktree',
@@ -173,6 +174,7 @@ describe('runQuickCommandInNewTab', () => {
})
expect(mocks.launchAgentInNewTab).toHaveBeenCalledWith({
requestId: expect.any(String),
agent: 'opencode2',
prompt: 'Review this diff',
promptDelivery: 'submit-after-ready',
@@ -228,6 +230,7 @@ describe('runQuickCommandInNewTab', () => {
expect(result).toEqual({ tabId: 'tab-agent' })
expect(mocks.launchAgentInNewTab).toHaveBeenCalledWith({
requestId: expect.any(String),
agent: 'codex',
prompt: 'Review this diff',
worktreeId: 'repo::worktree',
@@ -1,6 +1,7 @@
import { useAppStore } from '@/store'
import { reconcileTabOrder } from '@/components/tab-bar/reconcile-order'
import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab'
import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id'
import {
flattenTerminalQuickCommand,
isTerminalAgentQuickCommand,
@@ -65,6 +66,7 @@ export function runQuickCommandInNewTab({
return null
}
const result = launchAgentInNewTab({
requestId: newAgentLaunchRequestId(),
agent: command.agent,
prompt: command.prompt,
worktreeId,
@@ -65,9 +65,15 @@ describe('settleStructuredAgentLaunch', () => {
const onStructuredReady = vi.fn()
await expect(
settleStructuredAgentLaunch('worktree-1', 'codex', { prompt: 'Fix' }, { onStructuredReady })
settleStructuredAgentLaunch(
'worktree-1',
'codex',
{ requestId: 'request-1', prompt: 'Fix' },
{ onStructuredReady }
)
).resolves.toEqual({ kind: 'structured', sessionId: 'session-1', promptDeliveryResult })
expect(mocks.startStructuredAgentLaunch).toHaveBeenCalledWith('worktree-1', 'codex', {
requestId: 'request-1',
prompt: 'Fix'
})
expect(onStructuredReady).toHaveBeenCalledWith('session-1')
@@ -81,7 +87,12 @@ describe('settleStructuredAgentLaunch', () => {
})
})
const handle = beginStructuredAgentLaunchSettlement('worktree-1', 'codex', {}, {})
const handle = beginStructuredAgentLaunchSettlement(
'worktree-1',
'codex',
{ requestId: 'request-2' },
{}
)
expect(handle.sessionId).toBe('session-1')
resolveLaunch({ sessionId: 'session-1', fence: 1 })
@@ -95,7 +106,9 @@ describe('settleStructuredAgentLaunch', () => {
const error = new StructuredAgentSessionCreateRefusalError('unsupported')
fakeLaunch({ launchResult: Promise.reject(error) })
await expect(settleStructuredAgentLaunch('worktree-1', 'codex', {}, {})).resolves.toEqual({
await expect(
settleStructuredAgentLaunch('worktree-1', 'codex', { requestId: 'request-3' }, {})
).resolves.toEqual({
kind: 'failed',
error
})
@@ -107,7 +120,9 @@ describe('settleStructuredAgentLaunch', () => {
visibilityUnknown: true
})
await expect(settleStructuredAgentLaunch('worktree-1', 'codex', {}, {})).resolves.toEqual({
await expect(
settleStructuredAgentLaunch('worktree-1', 'codex', { requestId: 'request-4' }, {})
).resolves.toEqual({
kind: 'visibility-unknown',
sessionId: 'session-1'
})
@@ -118,7 +133,9 @@ describe('settleStructuredAgentLaunch', () => {
const error = new Error('boom')
const { releaseCallerAfterUnknownOutcome } = fakeLaunch({ launchResult: Promise.reject(error) })
await expect(settleStructuredAgentLaunch('worktree-1', 'codex', {}, {})).resolves.toEqual({
await expect(
settleStructuredAgentLaunch('worktree-1', 'codex', { requestId: 'request-5' }, {})
).resolves.toEqual({
kind: 'failed',
error
})
@@ -133,7 +150,7 @@ describe('settleStructuredAgentLaunch', () => {
settleStructuredAgentLaunch(
'worktree-1',
'codex',
{},
{ requestId: 'request-6' },
{
onStructuredReady,
signal: fakeCancellation(true).signal
@@ -151,7 +168,7 @@ describe('settleStructuredAgentLaunch', () => {
settleStructuredAgentLaunch(
'worktree-1',
'codex',
{},
{ requestId: 'request-7' },
{
signal: fakeCancellation(true).signal
}
@@ -172,7 +189,7 @@ describe('settleStructuredAgentLaunch', () => {
const settlement = settleStructuredAgentLaunch(
'worktree-1',
'codex',
{},
{ requestId: 'request-8' },
{ onStructuredReady, signal: cancellation.signal }
)
expect(mocks.cancelStructuredAgentLaunch).not.toHaveBeenCalled()
@@ -197,7 +214,7 @@ describe('settleStructuredAgentLaunch', () => {
const settlement = settleStructuredAgentLaunch(
'worktree-1',
'codex',
{},
{ requestId: 'request-9' },
{ signal: cancellation.signal }
)
expect(mocks.cancelStructuredAgentLaunch).toHaveBeenCalledExactlyOnceWith(
@@ -213,7 +230,12 @@ describe('settleStructuredAgentLaunch', () => {
const cancellation = fakeCancellation()
await expect(
settleStructuredAgentLaunch('worktree-1', 'codex', {}, { signal: cancellation.signal })
settleStructuredAgentLaunch(
'worktree-1',
'codex',
{ requestId: 'request-10' },
{ signal: cancellation.signal }
)
).resolves.toEqual({ kind: 'structured', sessionId: 'session-1' })
expect(mocks.cancelStructuredAgentLaunch).not.toHaveBeenCalled()
expect(cancellation.removeEventListener).toHaveBeenCalledOnce()
@@ -125,7 +125,7 @@ const fresh = launchIntent('session-new')
async function refuseFirstLaunch(): Promise<void> {
mocks.createIntent.mockReturnValueOnce(failed).mockReturnValueOnce(fresh)
mocks.launch.mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('unsupported'))
startStructuredAgentLaunch(WORKTREE_ID, 'codex', { prompt: 'first task' })
startStructuredAgentLaunch(WORKTREE_ID, 'codex', { requestId: 'request-1', prompt: 'first task' })
await flushLaunchSettlement()
expect(getStructuredAgentSessionLaunchLifecycle(WORKTREE_ID, failed.sessionId)).toBe('failed')
}
@@ -163,6 +163,7 @@ describe('a new launch after a failed one', () => {
mocks.launch.mockResolvedValueOnce({ sessionId: fresh.sessionId, fence: 1 })
const notes = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
requestId: 'request-2',
prompt: 'review notes',
promptDelivery: 'submit-after-ready'
})
@@ -200,12 +201,14 @@ describe('a new launch after a failed one', () => {
.mockImplementationOnce(
() => new Promise<Receipt>((_resolve, reject) => (rejectRetry = reject))
)
const next = startStructuredAgentLaunch(WORKTREE_ID, 'codex')
const next = startStructuredAgentLaunch(WORKTREE_ID, 'codex', { requestId: 'plus-pick' })
expect(retryStructuredAgentSessionLaunch(WORKTREE_ID, failed.sessionId)).toBe(true)
expect(mocks.launch.mock.calls[2]?.[0]).toMatchObject({ sessionId: failed.sessionId })
// A third start joins the new launch, not the retried chat.
expect(startStructuredAgentLaunch(WORKTREE_ID, 'codex').sessionId).toBe(fresh.sessionId)
// A re-delivery of the new pick joins the new launch, not the retried chat.
expect(
startStructuredAgentLaunch(WORKTREE_ID, 'codex', { requestId: 'plus-pick' }).sessionId
).toBe(fresh.sessionId)
expect(mocks.createIntent).toHaveBeenCalledTimes(2)
// Closing the retried chat leaves the new launch registered and starting.
@@ -228,7 +231,7 @@ describe('a new launch after a failed one', () => {
mocks.launch
.mockImplementationOnce(() => new Promise<Receipt>((resolve) => (resolveFresh = resolve)))
.mockResolvedValueOnce({ sessionId: failed.sessionId, fence: 1 })
const next = startStructuredAgentLaunch(WORKTREE_ID, 'codex')
const next = startStructuredAgentLaunch(WORKTREE_ID, 'codex', { requestId: 'request-5' })
expect(retryStructuredAgentSessionLaunch(WORKTREE_ID, failed.sessionId)).toBe(true)
await flushLaunchSettlement()
@@ -246,12 +249,13 @@ describe('a new launch after a failed one', () => {
mocks.launch
.mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('unsupported'))
.mockResolvedValueOnce({ sessionId: fresh.sessionId, fence: 1 })
startStructuredAgentLaunch(WORKTREE_ID, 'codex', { resumeFrom })
startStructuredAgentLaunch(WORKTREE_ID, 'codex', { requestId: 'request-6', resumeFrom })
await flushLaunchSettlement()
expect(getStructuredAgentLaunchStatus(WORKTREE_ID, 'codex')).toBe('idle')
expect(startStructuredAgentLaunch(WORKTREE_ID, 'codex', { resumeFrom }).sessionId).toBe(
fresh.sessionId
)
expect(
startStructuredAgentLaunch(WORKTREE_ID, 'codex', { requestId: 'request-7', resumeFrom })
.sessionId
).toBe(fresh.sessionId)
})
})
@@ -5,8 +5,11 @@ import type { StructuredAgentSessionResumeSource } from '../../../shared/structu
import type { RuntimeClientTarget } from '@/runtime/runtime-client-target'
import type { ExecutionHostId } from '../../../shared/execution-host'
import type { StructuredLaunchAttempt } from './structured-agent-session-launch-request'
import type { AgentLaunchRequestId } from './agent-launch-request-id'
export type StructuredAgentLaunchOptions = {
/** The user action this start serves; only a re-delivery of it joins its chat. */
requestId: AgentLaunchRequestId
prompt?: string
promptDelivery?: 'auto-submit' | 'submit-after-ready' | 'draft'
onPromptDelivered?: () => void
@@ -4,7 +4,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types'
import { suppressCancelledStructuredSessionTabs } from '@/runtime/structured-agent-session-tab-retirement'
import type { StructuredLaunchState } from './structured-agent-session-launch-registry'
import { BLANK_STRUCTURED_LAUNCH_REQUEST } from './structured-agent-session-launch-request'
import {
hasStructuredAgentSessionLaunchCancellationTombstone,
markStructuredAgentSessionLaunchCancelled,
@@ -83,7 +82,7 @@ describe('structured launch cancellation retirement', () => {
promptDelivery: 'auto-submit',
callers: {
outcome: 'pending',
attempt: { kind: 'first', request: BLANK_STRUCTURED_LAUNCH_REQUEST, stagedEntry: null },
attempt: { kind: 'first', requestId: 'plus-pick', blank: true, stagedEntry: null },
entries: new Set(),
promptDeliveryResults: new Set(),
onSettled: () => undefined
@@ -163,7 +162,7 @@ describe('structured launch cancellation retirement', () => {
promptDelivery: 'auto-submit',
callers: {
outcome: 'pending',
attempt: { kind: 'first', request: BLANK_STRUCTURED_LAUNCH_REQUEST, stagedEntry: null },
attempt: { kind: 'first', requestId: 'plus-pick', blank: true, stagedEntry: null },
entries: new Set(),
promptDeliveryResults: new Set(),
onSettled: () => undefined
@@ -159,7 +159,7 @@ describe('a close that races a structured launch', () => {
() => new Promise((resolve) => (resolveRefresh = resolve))
)
startStructuredAgentLaunch(worktreeId, 'codex')
startStructuredAgentLaunch(worktreeId, 'codex', { requestId: 'request-1' })
await vi.waitFor(() => expect(refreshLocalStructuredSessionTabs).toHaveBeenCalledOnce())
expect(cancelStructuredAgentLaunch(worktreeId, intent.sessionId)).toBe(true)
expect(hasStructuredAgentSessionLaunchCancellationTombstone(worktreeId, intent.sessionId)).toBe(
@@ -187,8 +187,14 @@ describe('a close that races a structured launch', () => {
() => new Promise((resolve) => (resolveRefresh = resolve))
)
startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'first prompt' })
startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'first prompt' })
startStructuredAgentLaunch(worktreeId, 'codex', {
requestId: 'double-click',
prompt: 'first prompt'
})
startStructuredAgentLaunch(worktreeId, 'codex', {
requestId: 'double-click',
prompt: 'first prompt'
})
await vi.waitFor(() => expect(refreshLocalStructuredSessionTabs).toHaveBeenCalledOnce())
expect(readOutbox(intent.sessionId)).toHaveLength(1)
@@ -210,7 +216,7 @@ describe('a close that races a structured launch', () => {
.mockResolvedValueOnce([])
.mockImplementationOnce(() => new Promise((resolve) => (resolveRetryRefresh = resolve)))
startStructuredAgentLaunch(worktreeId, 'codex')
startStructuredAgentLaunch(worktreeId, 'codex', { requestId: 'request-4' })
await vi.waitFor(() => expect(refreshLocalStructuredSessionTabs).toHaveBeenCalledTimes(2))
expect(cancelStructuredAgentLaunch(worktreeId, intent.sessionId)).toBe(true)
resolveRetryRefresh([])
@@ -73,13 +73,11 @@ import {
} from '../../../shared/structured-agent-session-projection'
import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session'
import {
getStructuredAgentLaunchStatus,
getStructuredAgentSessionLaunchLifecycle,
startStructuredAgentLaunch
} from './structured-agent-session-launch'
import { resetStructuredAgentLaunchPersistenceForTests } from './structured-agent-session-launch-persistence'
import { resetStructuredAgentLaunchRegistryForTests } from './structured-agent-session-launch-registry'
import { structuredLaunchRequest } from './structured-agent-session-launch-request'
const WORKTREE_ID = 'wt-different-request'
@@ -132,7 +130,7 @@ function sends(): [string, string][] {
.map((call) => [call[2].envelope.sessionId, call[2].body.blocks[0].text])
}
describe('a different new request while the first chat is still starting', () => {
describe('a different action while the first chat is still starting', () => {
let resolveFirstLaunch!: (receipt: { sessionId: string; fence: number }) => void
beforeEach(() => {
@@ -157,10 +155,12 @@ describe('a different new request while the first chat is still starting', () =>
it('opens a new chat with its own text while the first create is in flight', async () => {
const checkA = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
requestId: 'fix-check-a-click',
prompt: 'Fix check A',
promptDelivery: 'submit-after-ready'
})
const checkB = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
requestId: 'fix-check-b-click',
prompt: 'Fix check B',
promptDelivery: 'submit-after-ready'
})
@@ -187,6 +187,7 @@ describe('a different new request while the first chat is still starting', () =>
() => new Promise((resolve) => (resolveFirstSend = resolve))
)
const checkA = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
requestId: 'fix-check-a-click',
prompt: 'Fix check A',
promptDelivery: 'submit-after-ready'
})
@@ -194,6 +195,7 @@ describe('a different new request while the first chat is still starting', () =>
await vi.waitFor(() => expect(sends()).toEqual([[first.sessionId, 'Fix check A']]))
const checkB = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
requestId: 'fix-check-b-click',
prompt: 'Fix check B',
promptDelivery: 'submit-after-ready'
})
@@ -213,9 +215,38 @@ describe('a different new request while the first chat is still starting', () =>
[second.sessionId, 'Fix check B']
])
})
it('opens two chats for two actions with identical text', async () => {
const firstClick = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
requestId: 'fix-click-1',
prompt: 'Fix check A',
promptDelivery: 'submit-after-ready'
})
const secondClick = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
requestId: 'fix-click-2',
prompt: 'Fix check A',
promptDelivery: 'submit-after-ready'
})
resolveFirstLaunch({ sessionId: first.sessionId, fence: 1 })
expect(secondClick.sessionId).toBe(second.sessionId)
for (const click of [firstClick, secondClick]) {
await expect(click.promptDeliveryResult).resolves.toEqual({
delivered: true,
failureNotified: false
})
}
expect(sends()).toEqual(
expect.arrayContaining([
[first.sessionId, 'Fix check A'],
[second.sessionId, 'Fix check A']
])
)
expect(sends()).toHaveLength(2)
})
})
describe('the same request repeated while the first chat is still starting', () => {
describe('one action delivered twice while its chat is still starting', () => {
beforeEach(() => {
vi.resetAllMocks()
localStorage.clear()
@@ -223,7 +254,7 @@ describe('the same request repeated while the first chat is still starting', ()
resetStructuredAgentLaunchRegistryForTests()
mocks.createIntent.mockReturnValueOnce(first).mockReturnValueOnce(second)
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([
publishedSnapshot(first.sessionId)
publishedSnapshot(first.sessionId, second.sessionId)
])
mocks.callStructuredAgentSession.mockResolvedValue({
ok: true,
@@ -234,8 +265,9 @@ describe('the same request repeated while the first chat is still starting', ()
it('makes one chat from a double click with no text', () => {
mocks.launch.mockImplementation(() => new Promise(() => undefined))
const pick = startStructuredAgentLaunch(WORKTREE_ID, 'codex')
const pick = startStructuredAgentLaunch(WORKTREE_ID, 'codex', { requestId: 'pick' })
const again = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
requestId: 'pick',
promptDelivery: 'submit-after-ready'
})
@@ -250,11 +282,13 @@ describe('the same request repeated while the first chat is still starting', ()
const onRepeatDelivered = vi.fn()
const click = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
requestId: 'fix-click',
prompt: 'Fix check A',
promptDelivery: 'submit-after-ready',
onPromptDelivered: onFirstDelivered
})
const repeat = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
requestId: 'fix-click',
prompt: 'Fix check A',
promptDelivery: 'submit-after-ready',
onPromptDelivered: onRepeatDelivered
@@ -273,14 +307,46 @@ describe('the same request repeated while the first chat is still starting', ()
expect(onRepeatDelivered).toHaveBeenCalledOnce()
})
// "Fix with AI" builds its text from logs fetched at click time; a re-delivery of that one click
// can carry newer logs and is still the same request.
it("makes one chat that sends the first delivery's text once when a re-delivery carries other text", async () => {
let resolveLaunch!: (receipt: { sessionId: string; fence: number }) => void
mocks.launch.mockImplementation(() => new Promise((resolve) => (resolveLaunch = resolve)))
const click = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
requestId: 'fix-click',
prompt: 'Fix check A (logs at 10:00)',
promptDelivery: 'submit-after-ready'
})
const redelivery = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
requestId: 'fix-click',
prompt: 'Fix check A (logs at 10:01)',
promptDelivery: 'submit-after-ready'
})
resolveLaunch({ sessionId: first.sessionId, fence: 1 })
expect(redelivery.sessionId).toBe(click.sessionId)
expect(mocks.createIntent).toHaveBeenCalledOnce()
for (const caller of [click, redelivery]) {
await expect(caller.promptDeliveryResult).resolves.toEqual({
delivered: true,
failureNotified: false
})
}
expect(sends()).toEqual([[first.sessionId, 'Fix check A (logs at 10:00)']])
expect(readOutbox(first.sessionId)).toEqual([])
})
it('seeds a repeated draft once', () => {
mocks.launch.mockImplementation(() => new Promise(() => undefined))
const click = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
requestId: 'continue-click',
prompt: 'PR context',
promptDelivery: 'draft'
})
const repeat = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
requestId: 'continue-click',
prompt: 'PR context',
promptDelivery: 'draft'
})
@@ -288,12 +354,42 @@ describe('the same request repeated while the first chat is still starting', ()
expect(repeat.sessionId).toBe(click.sessionId)
expect(mocks.seedDraft).toHaveBeenCalledOnce()
})
it('opens a new chat for a click after the start failed', async () => {
mocks.launch
.mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('refused'))
.mockImplementation(() => new Promise(() => undefined))
const failed = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
requestId: 'fix-click-1',
prompt: 'Fix check A',
promptDelivery: 'submit-after-ready'
})
await expect(failed.launchResult).rejects.toBeInstanceOf(
StructuredAgentSessionCreateRefusalError
)
const reclick = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
requestId: 'fix-click-2',
prompt: 'Fix check A',
promptDelivery: 'submit-after-ready'
})
expect(reclick.sessionId).toBe(second.sessionId)
expect(getStructuredAgentSessionLaunchLifecycle(WORKTREE_ID, first.sessionId)).toBe('failed')
expect(readOutbox(second.sessionId).map((entry) => entry.body.blocks)).toEqual([
[{ type: 'text', text: 'Fix check A' }]
])
})
})
describe('an empty chat still starting', () => {
let resolveFirstLaunch!: (receipt: { sessionId: string; fence: number }) => void
const third = launchIntent('session-third')
const notesRequest = { prompt: 'review notes', promptDelivery: 'submit-after-ready' } as const
const notesSend = {
requestId: 'notes-send',
prompt: 'review notes',
promptDelivery: 'submit-after-ready'
} as const
beforeEach(() => {
vi.resetAllMocks()
@@ -320,13 +416,9 @@ describe('an empty chat still starting', () => {
})
it('takes notes sent to a new agent instead of opening a second chat', async () => {
const blank = startStructuredAgentLaunch(WORKTREE_ID, 'codex')
// The notes menu stays enabled: its pick fills the empty chat rather than repeating a start.
expect(
getStructuredAgentLaunchStatus(WORKTREE_ID, 'codex', structuredLaunchRequest(notesRequest))
).toBe('idle')
const blank = startStructuredAgentLaunch(WORKTREE_ID, 'codex', { requestId: 'plus-pick' })
const notes = startStructuredAgentLaunch(WORKTREE_ID, 'codex', notesRequest)
const notes = startStructuredAgentLaunch(WORKTREE_ID, 'codex', notesSend)
resolveFirstLaunch({ sessionId: first.sessionId, fence: 1 })
expect(notes.sessionId).toBe(blank.sessionId)
@@ -338,15 +430,17 @@ describe('an empty chat still starting', () => {
expect(sends()).toEqual([[first.sessionId, 'review notes']])
})
it('opens a new chat for any other request once its notes claimed it', async () => {
startStructuredAgentLaunch(WORKTREE_ID, 'codex')
startStructuredAgentLaunch(WORKTREE_ID, 'codex', notesRequest)
it('opens a new chat for any other action once its notes claimed it', async () => {
startStructuredAgentLaunch(WORKTREE_ID, 'codex', { requestId: 'plus-pick' })
startStructuredAgentLaunch(WORKTREE_ID, 'codex', notesSend)
const fix = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
requestId: 'fix-click',
prompt: 'Fix check B',
promptDelivery: 'submit-after-ready'
})
const pick = startStructuredAgentLaunch(WORKTREE_ID, 'codex')
// The pick that opened the empty chat is now the notes' chat, not the pick's.
const pick = startStructuredAgentLaunch(WORKTREE_ID, 'codex', { requestId: 'plus-pick' })
expect(fix.sessionId).toBe(second.sessionId)
expect(pick.sessionId).toBe(third.sessionId)
@@ -358,8 +452,11 @@ describe('an empty chat still starting', () => {
})
it('delivers the claiming text the way its own request asked', async () => {
startStructuredAgentLaunch(WORKTREE_ID, 'codex', { promptDelivery: 'draft' })
const notes = startStructuredAgentLaunch(WORKTREE_ID, 'codex', notesRequest)
startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
requestId: 'plus-pick',
promptDelivery: 'draft'
})
const notes = startStructuredAgentLaunch(WORKTREE_ID, 'codex', notesSend)
resolveFirstLaunch({ sessionId: first.sessionId, fence: 1 })
await expect(notes.promptDeliveryResult).resolves.toEqual({
@@ -370,10 +467,10 @@ describe('an empty chat still starting', () => {
expect(mocks.seedDraft).not.toHaveBeenCalled()
})
it('sends the same notes once when they are sent again', async () => {
const blank = startStructuredAgentLaunch(WORKTREE_ID, 'codex')
const notes = startStructuredAgentLaunch(WORKTREE_ID, 'codex', notesRequest)
const again = startStructuredAgentLaunch(WORKTREE_ID, 'codex', notesRequest)
it('sends the claiming notes once when that send is delivered twice', async () => {
const blank = startStructuredAgentLaunch(WORKTREE_ID, 'codex', { requestId: 'plus-pick' })
const notes = startStructuredAgentLaunch(WORKTREE_ID, 'codex', notesSend)
const again = startStructuredAgentLaunch(WORKTREE_ID, 'codex', notesSend)
resolveFirstLaunch({ sessionId: first.sessionId, fence: 1 })
expect(again.sessionId).toBe(blank.sessionId)
@@ -387,11 +484,28 @@ describe('an empty chat still starting', () => {
expect(sends()).toEqual([[first.sessionId, 'review notes']])
})
it('opens a new chat for a second notes send with the same text', async () => {
const blank = startStructuredAgentLaunch(WORKTREE_ID, 'codex', { requestId: 'plus-pick' })
startStructuredAgentLaunch(WORKTREE_ID, 'codex', notesSend)
const secondSend = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
...notesSend,
requestId: 'notes-send-2'
})
expect(secondSend.sessionId).toBe(second.sessionId)
expect(secondSend.sessionId).not.toBe(blank.sessionId)
await expect(secondSend.promptDeliveryResult).resolves.toEqual({
delivered: true,
failureNotified: false
})
expect(sends()).toEqual([[second.sessionId, 'review notes']])
})
it('leaves a chat its user already sent into to them, and opens a new chat for the notes', async () => {
const blank = startStructuredAgentLaunch(WORKTREE_ID, 'codex')
const blank = startStructuredAgentLaunch(WORKTREE_ID, 'codex', { requestId: 'plus-pick' })
appendStructuredAgentSessionOutboxMessage(blank.sessionId, 'my own question')
const notes = startStructuredAgentLaunch(WORKTREE_ID, 'codex', notesRequest)
const notes = startStructuredAgentLaunch(WORKTREE_ID, 'codex', notesSend)
expect(notes.sessionId).toBe(second.sessionId)
await expect(notes.promptDeliveryResult).resolves.toEqual({
@@ -405,14 +519,14 @@ describe('an empty chat still starting', () => {
})
it('leaves a chat its user is typing into to them, and opens a new chat for the notes', async () => {
const blank = startStructuredAgentLaunch(WORKTREE_ID, 'codex')
const blank = startStructuredAgentLaunch(WORKTREE_ID, 'codex', { requestId: 'plus-pick' })
const paneKey = structuredAgentSessionPaneKey(
structuredAgentSessionTabId(blank.sessionId),
blank.sessionId
)
writeNativeChatDraftCache(paneKey, 'half a question')
const notes = startStructuredAgentLaunch(WORKTREE_ID, 'codex', notesRequest)
const notes = startStructuredAgentLaunch(WORKTREE_ID, 'codex', notesSend)
expect(notes.sessionId).toBe(second.sessionId)
await expect(notes.promptDeliveryResult).resolves.toEqual({
@@ -424,11 +538,11 @@ describe('an empty chat still starting', () => {
})
it('opens a new chat that shows the failure when the claiming text cannot be saved', async () => {
const blank = startStructuredAgentLaunch(WORKTREE_ID, 'codex')
const blank = startStructuredAgentLaunch(WORKTREE_ID, 'codex', { requestId: 'plus-pick' })
const storageFailure = vi.spyOn(localStorage, 'setItem').mockImplementation(() => {
throw new Error('storage unavailable')
})
const notes = startStructuredAgentLaunch(WORKTREE_ID, 'codex', notesRequest)
const notes = startStructuredAgentLaunch(WORKTREE_ID, 'codex', notesSend)
storageFailure.mockRestore()
// The new chat fails with its Retry line, the failure the caller is told was shown.
@@ -441,10 +555,13 @@ describe('an empty chat still starting', () => {
failureNotified: true
})
expect(getStructuredAgentSessionLaunchLifecycle(WORKTREE_ID, second.sessionId)).toBe('failed')
// No claim was recorded: the blank chat is still blank and still claimable.
expect(startStructuredAgentLaunch(WORKTREE_ID, 'codex').sessionId).toBe(blank.sessionId)
expect(startStructuredAgentLaunch(WORKTREE_ID, 'codex', notesRequest).sessionId).toBe(
blank.sessionId
)
// No claim was recorded: the empty chat is still the pick's, and still claimable.
expect(
startStructuredAgentLaunch(WORKTREE_ID, 'codex', { requestId: 'plus-pick' }).sessionId
).toBe(blank.sessionId)
expect(
startStructuredAgentLaunch(WORKTREE_ID, 'codex', { ...notesSend, requestId: 'notes-retry' })
.sessionId
).toBe(blank.sessionId)
})
})
@@ -94,7 +94,7 @@ async function settle(sessionId: string): Promise<void> {
}
function launch(): string {
const started = startStructuredAgentLaunch(WORKTREE, 'codex')
const started = startStructuredAgentLaunch(WORKTREE, 'codex', { requestId: 'request-1' })
void started.launchResult.catch(() => undefined)
return started.sessionId
}
@@ -3,19 +3,20 @@ import {
structuredLaunchStates,
type StructuredLaunchState
} from './structured-agent-session-launch-registry'
import {
joinsFirstLaunchAttempt,
type StructuredLaunchAttempt,
type StructuredLaunchRequest
import type {
StructuredLaunchAttempt,
StructuredLaunchRequest
} from './structured-agent-session-launch-request'
import type { AgentLaunchRequestId } from './agent-launch-request-id'
import { isStructuredLaunchChatEmpty } from './structured-agent-session-launch-empty-chat'
// Why: coalescing stops a repeat of one request (a double click) racing into two chats. A different
// request, a failed or unconfirmed launch, or a Retry/re-check of one is not that race: a new start
// opens a new chat carrying its own text. A resume keeps holding: the host refuses a second adoption.
// Why: coalescing stops one user action delivered twice (a double click) racing into two chats. Any
// other action, a failed or unconfirmed launch, or a Retry/re-check of one is not that race: a new
// start opens a new chat carrying its own text. A resume keeps holding: the host refuses a second
// adoption.
function holdsLaunchIdentity(
state: StructuredLaunchState,
request?: StructuredLaunchRequest
requestId?: AgentLaunchRequestId
): boolean {
const lifecycle = launchStateLifecycle(state)
if (lifecycle === 'failed' || lifecycle === 'cancelled') {
@@ -24,18 +25,21 @@ function holdsLaunchIdentity(
if (state.intent.params.resumeFrom) {
return true
}
const { attempt } = state.callers
return (
lifecycle !== 'visibility-unknown' && joinsFirstLaunchAttempt(state.callers.attempt, request)
lifecycle !== 'visibility-unknown' &&
attempt.kind === 'first' &&
(requestId === undefined || attempt.requestId === requestId)
)
}
/** Launches a start of `request` would repeat; without `request`, every new start's own create. */
/** Launches a start of `requestId` would join; without it, every new start's own create. */
export function structuredLaunchesHoldingIdentity(
matches: (identity: string) => boolean,
request?: StructuredLaunchRequest
requestId?: AgentLaunchRequestId
): StructuredLaunchState[] {
return [...structuredLaunchStates()].filter(
(state) => matches(state.identity) && holdsLaunchIdentity(state, request)
(state) => matches(state.identity) && holdsLaunchIdentity(state, requestId)
)
}
@@ -49,14 +53,14 @@ export function claimableStructuredLaunchAttempt(
const { attempt } = state.callers
return !state.intent.params.resumeFrom &&
attempt.kind === 'first' &&
attempt.request.text === '' &&
request.text !== '' &&
attempt.blank &&
request.hasText &&
isStructuredLaunchChatEmpty(state.intent.sessionId)
? attempt
: undefined
}
/** The launch a new start of `request` joins: one it repeats, else an empty chat it claims. The
/** The launch a new start of `request` joins: one it re-delivers, else an empty chat it claims. The
* newest wins if a retried resume holds the identity too. */
export function getJoinableStructuredLaunchState(
identity: string,
@@ -64,7 +68,7 @@ export function getJoinableStructuredLaunchState(
): StructuredLaunchState | undefined {
const matches = (candidate: string): boolean => candidate === identity
return (
structuredLaunchesHoldingIdentity(matches, request).at(-1) ??
structuredLaunchesHoldingIdentity(matches, request.id).at(-1) ??
structuredLaunchesHoldingIdentity(matches).findLast((state) =>
claimableStructuredLaunchAttempt(state, request)
)
@@ -163,8 +163,13 @@ describe('coalesced launch delivery mode', () => {
const { intent, joiner } = await coalesce({
worktreeId: 'wt-unset-delivery-mode',
sessionId: 'unset-delivery-session',
established: { resumeFrom },
joining: { resumeFrom, prompt: 'PR context', promptDelivery: 'draft' }
established: { requestId: 'resume-click', resumeFrom },
joining: {
requestId: 'another-resume-click',
resumeFrom,
prompt: 'PR context',
promptDelivery: 'draft'
}
})
// Why: an unset established mode must not read as submit; the joiner never consented to send.
@@ -188,7 +188,10 @@ describe('picks made while a chat launches', () => {
it('applies them against the receipt fence before the first turn and before publishing', async () => {
const created = deferred<{ sessionId: string; fence: number }>()
mocks.launch.mockReturnValue(created.promise)
const launch = startStructuredAgentLaunch(WORKTREE_ID, 'codex', { prompt: 'first turn' })
const launch = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
requestId: 'request-1',
prompt: 'first turn'
})
const modelPick = holdStructuredAgentSessionLaunchOption(SESSION_ID, 'model', 'gpt-picked')
holdStructuredAgentSessionLaunchOption(SESSION_ID, 'effort', 'high')
@@ -224,7 +227,7 @@ describe('picks made while a chat launches', () => {
it('applies a pick made while the earlier ones are being applied', async () => {
mocks.launch.mockResolvedValue({ sessionId: SESSION_ID, fence: 1 })
const launch = startStructuredAgentLaunch(WORKTREE_ID, 'codex')
const launch = startStructuredAgentLaunch(WORKTREE_ID, 'codex', { requestId: 'request-2' })
holdStructuredAgentSessionLaunchOption(SESSION_ID, 'model', 'gpt-picked')
await settle()
expect(setOptionReplies).toHaveLength(1)
@@ -241,7 +244,10 @@ describe('picks made while a chat launches', () => {
it('reports a refused pick to its picker and publishes all the same', async () => {
mocks.launch.mockResolvedValue({ sessionId: SESSION_ID, fence: 1 })
const launch = startStructuredAgentLaunch(WORKTREE_ID, 'codex', { prompt: 'first turn' })
const launch = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
requestId: 'request-3',
prompt: 'first turn'
})
const pick = holdStructuredAgentSessionLaunchOption(SESSION_ID, 'model', 'gpt-missing')
await settle()
setOptionReplies[0]!.resolve({
@@ -269,7 +275,7 @@ describe('picks made while a chat launches', () => {
it('reports a refusal the host threw as its fact, never the bare code it carries as a message', async () => {
mocks.launch.mockResolvedValue({ sessionId: SESSION_ID, fence: 1 })
startStructuredAgentLaunch(WORKTREE_ID, 'codex')
startStructuredAgentLaunch(WORKTREE_ID, 'codex', { requestId: 'request-4' })
const pick = holdStructuredAgentSessionLaunchOption(SESSION_ID, 'model', 'gpt-missing')
await settle()
setOptionReplies[0]!.resolve(
@@ -306,7 +312,7 @@ describe('picks made while a chat launches', () => {
mocks.launch
.mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('unsupported'))
.mockResolvedValueOnce({ sessionId: SESSION_ID, fence: 2 })
startStructuredAgentLaunch(WORKTREE_ID, 'codex')
startStructuredAgentLaunch(WORKTREE_ID, 'codex', { requestId: 'request-5' })
holdStructuredAgentSessionLaunchOption(SESSION_ID, 'model', 'gpt-picked')
await settle()
expect(lifecycle()).toBe('failed')
@@ -327,7 +333,7 @@ describe('picks made while a chat launches', () => {
it('discards them when the tab closes before the launch publishes', async () => {
const created = deferred<{ sessionId: string; fence: number }>()
mocks.launch.mockReturnValue(created.promise)
startStructuredAgentLaunch(WORKTREE_ID, 'codex')
startStructuredAgentLaunch(WORKTREE_ID, 'codex', { requestId: 'request-6' })
holdStructuredAgentSessionLaunchOption(SESSION_ID, 'model', 'gpt-picked')
cancelStructuredAgentLaunch(WORKTREE_ID, SESSION_ID)
@@ -340,7 +346,7 @@ describe('picks made while a chat launches', () => {
it('folds an accepted pick into what the launch reports it runs', async () => {
mocks.launch.mockResolvedValue({ sessionId: SESSION_ID, fence: 1 })
startStructuredAgentLaunch(WORKTREE_ID, 'codex')
startStructuredAgentLaunch(WORKTREE_ID, 'codex', { requestId: 'request-7' })
holdStructuredAgentSessionLaunchOption(SESSION_ID, 'effort', 'high')
await settle()
const seen: unknown[] = []
@@ -1,51 +1,32 @@
import type { StructuredAgentSessionOutboxEntry } from '../../../shared/structured-agent-session-outbox'
import type { AgentLaunchRequestId } from './agent-launch-request-id'
/** What a new start asks its chat to receive. Callers carry no request id, so this is what tells a
* repeat of one request (a double click, a retried call) from a different request. */
export type StructuredLaunchRequest = { text: string; draft: boolean }
/** What a new start brings: the user action it serves, and whether it carries text. */
export type StructuredLaunchRequest = { id: AgentLaunchRequestId; hasText: boolean }
/** A new start's own create keeps its request and the text it staged; a Retry or re-check of an
* existing chat is no request of its own. */
export type StructuredLaunchAttempt =
| {
kind: 'first'
request: StructuredLaunchRequest
requestId: AgentLaunchRequestId
/** The request carried no text: the chat is blank until something claims it. */
blank: boolean
stagedEntry: StructuredAgentSessionOutboxEntry | null
}
| { kind: 'retry' }
/** A pick that carries no text, as the + menu and new-tab search make. */
export const BLANK_STRUCTURED_LAUNCH_REQUEST: StructuredLaunchRequest = { text: '', draft: false }
export function structuredLaunchRequest(options: {
requestId: AgentLaunchRequestId
prompt?: string
promptDelivery?: string
}): StructuredLaunchRequest {
const text = options.prompt?.trim() ?? ''
// Without text the delivery mode carries nothing: two blank starts are one request.
return { text, draft: text !== '' && options.promptDelivery === 'draft' }
return { id: options.requestId, hasText: (options.prompt?.trim() ?? '') !== '' }
}
/** The first attempt `request` repeats, whose text is already staged or seeded. */
/** The first attempt `requestId` re-delivers, whose text is already staged or seeded. */
export function repeatedStructuredLaunchAttempt(
attempt: StructuredLaunchAttempt,
request: StructuredLaunchRequest
requestId: AgentLaunchRequestId
): Extract<StructuredLaunchAttempt, { kind: 'first' }> | undefined {
return attempt.kind === 'first' &&
attempt.request.text === request.text &&
attempt.request.draft === request.draft
? attempt
: undefined
}
/** Only a new start's own create is joined, and only by a repeat of its request. Without `request`,
* any new start's own create counts. */
export function joinsFirstLaunchAttempt(
attempt: StructuredLaunchAttempt,
request?: StructuredLaunchRequest
): boolean {
return (
attempt.kind === 'first' &&
(!request || repeatedStructuredLaunchAttempt(attempt, request) !== undefined)
)
return attempt.kind === 'first' && attempt.requestId === requestId ? attempt : undefined
}
@@ -80,8 +80,9 @@ describe('a launch that adopts a conversation is its own identity', () => {
// A joining caller is handed the EXISTING intent and contributes only its prompt, so joining
// here would silently drop the adoption and open a blank chat instead.
const worktreeId = 'wt-resume-vs-blank'
const blank = startStructuredAgentLaunch(worktreeId, 'codex')
const blank = startStructuredAgentLaunch(worktreeId, 'codex', { requestId: 'request-1' })
const resume = startStructuredAgentLaunch(worktreeId, 'codex', {
requestId: 'request-2',
resumeFrom: { providerSessionId: 'thread-1' }
})
@@ -100,9 +101,10 @@ describe('a launch that adopts a conversation is its own identity', () => {
it('does not hand a blank launch the resume already pending for the same worktree', async () => {
const worktreeId = 'wt-blank-vs-resume'
const resume = startStructuredAgentLaunch(worktreeId, 'codex', {
requestId: 'request-3',
resumeFrom: { providerSessionId: 'thread-1' }
})
const blank = startStructuredAgentLaunch(worktreeId, 'codex')
const blank = startStructuredAgentLaunch(worktreeId, 'codex', { requestId: 'request-4' })
await flushLaunchDispatch()
@@ -113,9 +115,11 @@ describe('a launch that adopts a conversation is its own identity', () => {
it('keeps two resumes of different rows apart', async () => {
const worktreeId = 'wt-two-rows'
const first = startStructuredAgentLaunch(worktreeId, 'codex', {
requestId: 'request-5',
resumeFrom: { providerSessionId: 'thread-1' }
})
const second = startStructuredAgentLaunch(worktreeId, 'codex', {
requestId: 'request-6',
resumeFrom: { providerSessionId: 'thread-2' }
})
@@ -131,8 +135,14 @@ describe('a launch that adopts a conversation is its own identity', () => {
it('coalesces a duplicate click on the same row', async () => {
const worktreeId = 'wt-same-row-twice'
const resumeFrom = { providerSessionId: 'thread-1' }
const first = startStructuredAgentLaunch(worktreeId, 'codex', { resumeFrom })
const second = startStructuredAgentLaunch(worktreeId, 'codex', { resumeFrom })
const first = startStructuredAgentLaunch(worktreeId, 'codex', {
requestId: 'request-7',
resumeFrom
})
const second = startStructuredAgentLaunch(worktreeId, 'codex', {
requestId: 'request-8',
resumeFrom
})
await flushLaunchDispatch()
@@ -142,8 +152,14 @@ describe('a launch that adopts a conversation is its own identity', () => {
it('keeps the same row apart across worktrees and agents', async () => {
const resumeFrom = { providerSessionId: 'thread-1' }
const here = startStructuredAgentLaunch('wt-here', 'codex', { resumeFrom })
const there = startStructuredAgentLaunch('wt-there', 'codex', { resumeFrom })
const here = startStructuredAgentLaunch('wt-here', 'codex', {
requestId: 'request-9',
resumeFrom
})
const there = startStructuredAgentLaunch('wt-there', 'codex', {
requestId: 'request-10',
resumeFrom
})
await flushLaunchDispatch()
@@ -157,6 +173,7 @@ describe('a launch that adopts a conversation is its own identity', () => {
expect(getStructuredAgentLaunchStatus(worktreeId, 'codex')).toBe('idle')
startStructuredAgentLaunch(worktreeId, 'codex', {
requestId: 'request-11',
resumeFrom: { providerSessionId: 'thread-1' }
})
@@ -1,40 +1,23 @@
import { useSyncExternalStore } from 'react'
import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle'
import { structuredLaunchesHoldingIdentity } from './structured-agent-session-launch-holders'
import {
structuredLaunchIdentity,
subscribeStructuredAgentLaunchStatus,
type StructuredAgentLaunchStatus
} from './structured-agent-session-launch-registry'
import type { StructuredLaunchRequest } from './structured-agent-session-launch-request'
/** With `request`, only launches a start of it would repeat: any other request is new work. */
/** Whether a chat for this pair is being created here: its own first create, or an adoption. */
export function getStructuredAgentLaunchStatus(
worktreeId: string,
agent: AgentSessionHandleProvider,
request?: StructuredLaunchRequest
agent: AgentSessionHandleProvider
): StructuredAgentLaunchStatus {
// Any launch holding an identity for this pair, adopted conversations included, is starting here.
// A failed chat is not, nor an unconfirmed or retried blank one: a new launch opens its own chat.
const identity = structuredLaunchIdentity(worktreeId, agent)
const states = structuredLaunchesHoldingIdentity(
(candidate) => candidate === identity || candidate.startsWith(`${identity}:resume:`),
request
(candidate) => candidate === identity || candidate.startsWith(`${identity}:resume:`)
)
if (states.length === 0) {
return 'idle'
}
return states.some((state) => state.visibilityUnknown) ? 'unknown' : 'pending'
}
export function useStructuredAgentLaunchStatus(
worktreeId: string,
agent: AgentSessionHandleProvider,
request?: StructuredLaunchRequest
): StructuredAgentLaunchStatus {
return useSyncExternalStore(
subscribeStructuredAgentLaunchStatus,
() => getStructuredAgentLaunchStatus(worktreeId, agent, request),
() => 'idle'
)
}
@@ -65,11 +65,13 @@ vi.mock('@/lib/launch-agent-in-new-tab', async () => {
const launch = await import('./structured-agent-session-launch')
return {
launchAgentInNewTab: (args: {
requestId: string
worktreeId: string
prompt: string
promptDelivery: 'auto-submit' | 'draft' | 'submit-after-ready'
}) => {
const started = launch.startStructuredAgentLaunch(args.worktreeId, 'codex', {
requestId: args.requestId,
prompt: args.prompt,
promptDelivery: args.promptDelivery
})
@@ -158,6 +160,7 @@ async function leaveFirstLaunchUnconfirmed(options: { resume?: boolean } = {}):
)
.mockReturnValueOnce(fresh)
startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
requestId: 'request-1',
prompt: 'first task',
...(options.resume ? { resumeFrom } : {})
})
@@ -235,6 +238,7 @@ describe('a new start beside an unconfirmed or retried chat', () => {
await leaveFirstLaunchUnconfirmed()
const notes = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
requestId: 'request-2',
prompt: 'review notes',
promptDelivery: 'submit-after-ready'
})
@@ -255,7 +259,10 @@ describe('a new start beside an unconfirmed or retried chat', () => {
const failed = launchIntent('session-failed')
mocks.createIntent.mockReturnValueOnce(failed).mockReturnValueOnce(fresh)
mocks.launch.mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('unsupported'))
startStructuredAgentLaunch(WORKTREE_ID, 'codex', { prompt: 'first task' })
startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
requestId: 'request-3',
prompt: 'first task'
})
await flushLaunchSettlement()
// Reload: the registry is memory; the failed record is what survives.
resetStructuredAgentLaunchRegistryForTests()
@@ -264,6 +271,7 @@ describe('a new start beside an unconfirmed or retried chat', () => {
mocks.seedDraft.mockClear()
const next = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
requestId: 'request-4',
prompt: 'fix it',
promptDelivery: 'submit-after-ready'
})
@@ -282,7 +290,7 @@ describe('a new start beside an unconfirmed or retried chat', () => {
// Why: the + menu disables an agent only while a pick would join a start in flight.
expect(getStructuredAgentLaunchStatus(WORKTREE_ID, 'codex')).toBe('idle')
const pick = startStructuredAgentLaunch(WORKTREE_ID, 'codex')
const pick = startStructuredAgentLaunch(WORKTREE_ID, 'codex', { requestId: 'request-5' })
expect(pick.sessionId).toBe(fresh.sessionId)
expect(getStructuredAgentLaunchStatus(WORKTREE_ID, 'codex')).toBe('pending')
@@ -291,12 +299,18 @@ describe('a new start beside an unconfirmed or retried chat', () => {
await expect(pick.launchResult).resolves.toEqual({ sessionId: fresh.sessionId, fence: 1 })
})
it('still coalesces a repeat of one request racing for one chat', async () => {
it('still coalesces one action delivered twice racing for one chat', async () => {
mocks.createIntent.mockReturnValueOnce(fresh)
mocks.launch.mockImplementationOnce(() => new Promise(() => undefined))
const first = startStructuredAgentLaunch(WORKTREE_ID, 'codex', { prompt: 'one' })
const second = startStructuredAgentLaunch(WORKTREE_ID, 'codex', { prompt: 'one' })
const first = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
requestId: 'double-click',
prompt: 'one'
})
const second = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
requestId: 'double-click',
prompt: 'one'
})
expect(second.sessionId).toBe(first.sessionId)
expect(mocks.createIntent).toHaveBeenCalledOnce()
@@ -306,9 +320,10 @@ describe('a new start beside an unconfirmed or retried chat', () => {
await leaveFirstLaunchUnconfirmed({ resume: true })
expect(getStructuredAgentLaunchStatus(WORKTREE_ID, 'codex')).toBe('unknown')
expect(startStructuredAgentLaunch(WORKTREE_ID, 'codex', { resumeFrom }).sessionId).toBe(
unconfirmed.sessionId
)
expect(
startStructuredAgentLaunch(WORKTREE_ID, 'codex', { requestId: 'request-8', resumeFrom })
.sessionId
).toBe(unconfirmed.sessionId)
expect(mocks.createIntent).toHaveBeenCalledOnce()
})
})
@@ -184,6 +184,7 @@ describe('startStructuredAgentLaunch', () => {
])
const launch = startStructuredAgentLaunch(worktreeId, 'codex', {
requestId: 'request-1',
prompt: 'PR #19423 — review this change',
promptDelivery: 'draft'
})
@@ -213,6 +214,7 @@ describe('startStructuredAgentLaunch', () => {
const sixtyLineDraft = Array.from({ length: 60 }, (_, i) => `line ${i + 1}`).join('\n')
const launch = startStructuredAgentLaunch(worktreeId, 'codex', {
requestId: 'request-2',
prompt: sixtyLineDraft,
promptDelivery: 'draft'
})
@@ -234,6 +236,7 @@ describe('startStructuredAgentLaunch', () => {
mocks.launch.mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('refused'))
const launch = startStructuredAgentLaunch(worktreeId, 'codex', {
requestId: 'request-3',
prompt: 'review this',
promptDelivery: 'draft'
})
@@ -256,6 +259,7 @@ describe('startStructuredAgentLaunch', () => {
vi.mocked(launchAndReconcile).mockRejectedValueOnce(new Error('boom'))
const launch = startStructuredAgentLaunch(worktreeId, 'codex', {
requestId: 'request-4',
prompt: 'review this',
promptDelivery: 'draft'
})
@@ -278,6 +282,7 @@ describe('startStructuredAgentLaunch', () => {
)
startStructuredAgentLaunch(worktreeId, 'codex', {
requestId: 'request-5',
prompt: 'review this',
promptDelivery: 'draft'
})
@@ -304,7 +309,7 @@ describe('startStructuredAgentLaunch', () => {
value: { submission: { dispatchState: 'accepted' } }
})
startStructuredAgentLaunch(worktreeId, 'codex')
startStructuredAgentLaunch(worktreeId, 'codex', { requestId: 'request-6' })
await flushLaunchSettlement()
expect(mocks.launch).toHaveBeenCalledOnce()
@@ -324,8 +329,8 @@ describe('startStructuredAgentLaunch', () => {
publishedSnapshot(worktreeId, `codex-session-${worktreeId}`)
])
const claude = startStructuredAgentLaunch(worktreeId, 'claude')
const codex = startStructuredAgentLaunch(worktreeId, 'codex')
const claude = startStructuredAgentLaunch(worktreeId, 'claude', { requestId: 'request-7' })
const codex = startStructuredAgentLaunch(worktreeId, 'codex', { requestId: 'request-8' })
await flushLaunchSettlement()
expect(mocks.createIntent).toHaveBeenNthCalledWith(
@@ -358,7 +363,9 @@ describe('startStructuredAgentLaunch', () => {
const worktreeId = 'wt-claude-refused'
mocks.launch.mockRejectedValue(new StructuredAgentSessionCreateRefusalError('unsupported'))
const { sessionId } = startStructuredAgentLaunch(worktreeId, 'claude')
const { sessionId } = startStructuredAgentLaunch(worktreeId, 'claude', {
requestId: 'request-9'
})
await flushLaunchSettlement()
expect(getStructuredAgentSessionLaunchLifecycle(worktreeId, sessionId)).toBe('failed')
@@ -388,7 +395,7 @@ describe('startStructuredAgentLaunch', () => {
publishedSnapshot(worktreeId, intent.sessionId)
])
startStructuredAgentLaunch(worktreeId, 'codex')
startStructuredAgentLaunch(worktreeId, 'codex', { requestId: 'request-10' })
await flushLaunchSettlement()
expect(mocks.launch).toHaveBeenCalledOnce()
@@ -415,8 +422,8 @@ describe('startStructuredAgentLaunch', () => {
value: { submission: { dispatchState: 'accepted' } }
})
startStructuredAgentLaunch(worktreeId, 'codex')
startStructuredAgentLaunch(worktreeId, 'codex')
startStructuredAgentLaunch(worktreeId, 'codex', { requestId: 'double-click' })
startStructuredAgentLaunch(worktreeId, 'codex', { requestId: 'double-click' })
expect(mocks.createIntent).toHaveBeenCalledOnce()
expect(mocks.launch).toHaveBeenCalledOnce()
@@ -442,8 +449,14 @@ describe('startStructuredAgentLaunch', () => {
ok: true,
value: { submission: { dispatchState: 'accepted' } }
})
const first = startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'second prompt' })
const second = startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'second prompt' })
const first = startStructuredAgentLaunch(worktreeId, 'codex', {
requestId: 'double-click',
prompt: 'second prompt'
})
const second = startStructuredAgentLaunch(worktreeId, 'codex', {
requestId: 'double-click',
prompt: 'second prompt'
})
resolveLaunch({ sessionId: intent.sessionId, fence: 1 })
for (const caller of [first, second]) {
@@ -485,12 +498,19 @@ describe('startStructuredAgentLaunch', () => {
() => new Promise((resolve) => pendingSends.push(resolve))
)
startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'second prompt' })
const coalesced = startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'second prompt' })
startStructuredAgentLaunch(worktreeId, 'codex', {
requestId: 'double-click',
prompt: 'second prompt'
})
const coalesced = startStructuredAgentLaunch(worktreeId, 'codex', {
requestId: 'double-click',
prompt: 'second prompt'
})
resolveLaunch({ sessionId: intent.sessionId, fence: 1 })
await vi.waitFor(() => expect(mocks.callStructuredAgentSession).toHaveBeenCalledOnce())
const whileSending = startStructuredAgentLaunch(worktreeId, 'codex', {
requestId: 'double-click',
prompt: 'second prompt'
})
expect(mocks.createIntent).toHaveBeenCalledOnce()
@@ -516,9 +536,15 @@ describe('startStructuredAgentLaunch', () => {
mocks.launch.mockRejectedValue(new Error('offline'))
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([])
startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'first prompt' })
startStructuredAgentLaunch(worktreeId, 'codex', {
requestId: 'request-18',
prompt: 'first prompt'
})
await flushLaunchSettlement()
const second = startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'second prompt' })
const second = startStructuredAgentLaunch(worktreeId, 'codex', {
requestId: 'request-19',
prompt: 'second prompt'
})
await flushLaunchSettlement()
expect(mocks.createIntent).toHaveBeenCalledTimes(2)
@@ -539,7 +565,7 @@ describe('startStructuredAgentLaunch', () => {
publishedSnapshot(worktreeId, intent.sessionId)
])
startStructuredAgentLaunch(worktreeId, 'codex')
startStructuredAgentLaunch(worktreeId, 'codex', { requestId: 'request-20' })
await flushLaunchSettlement()
expect(mocks.createIntent).toHaveBeenCalledOnce()
@@ -557,7 +583,7 @@ describe('startStructuredAgentLaunch', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
startStructuredAgentLaunch(worktreeId, 'codex')
startStructuredAgentLaunch(worktreeId, 'codex', { requestId: 'request-21' })
await flushLaunchSettlement()
expect(getStructuredAgentSessionLaunchLifecycle(worktreeId, intent.sessionId)).toBe(
@@ -581,7 +607,7 @@ describe('startStructuredAgentLaunch', () => {
.mockResolvedValueOnce([])
.mockResolvedValueOnce([publishedSnapshot(worktreeId, intent.sessionId)])
startStructuredAgentLaunch(worktreeId, 'codex')
startStructuredAgentLaunch(worktreeId, 'codex', { requestId: 'request-22' })
await flushLaunchSettlement()
expect(mocks.launch).toHaveBeenCalledTimes(2)
@@ -600,13 +626,13 @@ describe('startStructuredAgentLaunch', () => {
mocks.launch.mockRejectedValue(new Error('offline'))
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([])
startStructuredAgentLaunch(worktreeId, 'codex', { resumeFrom })
startStructuredAgentLaunch(worktreeId, 'codex', { requestId: 'request-23', resumeFrom })
await flushLaunchSettlement()
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([
publishedSnapshot(worktreeId, intent.sessionId)
])
startStructuredAgentLaunch(worktreeId, 'codex', { resumeFrom })
startStructuredAgentLaunch(worktreeId, 'codex', { requestId: 'request-24', resumeFrom })
await flushLaunchSettlement()
expect(mocks.createIntent).toHaveBeenCalledOnce()
@@ -621,7 +647,10 @@ describe('startStructuredAgentLaunch', () => {
mocks.launch.mockRejectedValue(new Error('offline'))
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([])
const first = startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'only once' })
const first = startStructuredAgentLaunch(worktreeId, 'codex', {
requestId: 'request-25',
prompt: 'only once'
})
await expect(first.launchResult).rejects.toThrow('offline')
expect(first.releaseCallerAfterUnknownOutcome()).toBe(true)
@@ -656,7 +685,7 @@ describe('startStructuredAgentLaunch', () => {
)
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([])
const launch = startStructuredAgentLaunch(worktreeId, 'codex')
const launch = startStructuredAgentLaunch(worktreeId, 'codex', { requestId: 'request-26' })
await expect(launch.launchResult).rejects.toMatchObject({
code: 'agent_session_operation_unknown'
@@ -678,7 +707,7 @@ describe('startStructuredAgentLaunch', () => {
publishedSnapshot(worktreeId, first.sessionId)
])
startStructuredAgentLaunch(worktreeId, 'codex')
startStructuredAgentLaunch(worktreeId, 'codex', { requestId: 'request-27' })
await flushLaunchSettlement()
expect(retryStructuredAgentSessionLaunch(worktreeId, first.sessionId)).toBe(true)
await flushLaunchSettlement()
@@ -712,7 +741,7 @@ describe('startStructuredAgentLaunch', () => {
}
)
startStructuredAgentLaunch(worktreeId, 'codex')
startStructuredAgentLaunch(worktreeId, 'codex', { requestId: 'request-28' })
await flushLaunchSettlement()
expect(getStructuredAgentSessionLaunchSelection(intent.sessionId)?.seed).toEqual({
model: 'gpt-5.5'
@@ -743,6 +772,7 @@ describe('startStructuredAgentLaunch', () => {
])
startStructuredAgentLaunch(worktreeId, 'codex', {
requestId: 'request-29',
resumeFrom: { providerSessionId: 'provider-session-1' }
})
await flushLaunchSettlement()
@@ -761,7 +791,10 @@ describe('startStructuredAgentLaunch', () => {
throw new Error('storage unavailable')
})
const result = startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'start this task' })
const result = startStructuredAgentLaunch(worktreeId, 'codex', {
requestId: 'request-30',
prompt: 'start this task'
})
await expect(result.launchResult).rejects.toBeInstanceOf(
StructuredAgentSessionCreateRefusalError
@@ -785,8 +818,14 @@ describe('startStructuredAgentLaunch', () => {
() => new Promise((_resolve, reject) => (rejectLaunch = reject))
)
const first = startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'first prompt' })
const second = startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'first prompt' })
const first = startStructuredAgentLaunch(worktreeId, 'codex', {
requestId: 'double-click',
prompt: 'first prompt'
})
const second = startStructuredAgentLaunch(worktreeId, 'codex', {
requestId: 'double-click',
prompt: 'first prompt'
})
expect(readOutbox(intent.sessionId)).toHaveLength(1)
rejectLaunch(new StructuredAgentSessionCreateRefusalError('unsupported'))
@@ -804,7 +843,7 @@ describe('startStructuredAgentLaunch', () => {
expect(readOutbox(intent.sessionId)).toHaveLength(1)
})
it('opens a new chat for the same text asked to be sent instead of drafted', async () => {
it('keeps a drafted chat out of a second action that sends the same text', async () => {
const worktreeId = 'wt-coalesced-delivery-mode'
const drafted = launchIntent(worktreeId, 'coalesced-delivery-session')
const sent = launchIntent(worktreeId, 'sent-delivery-session')
@@ -822,15 +861,17 @@ describe('startStructuredAgentLaunch', () => {
})
startStructuredAgentLaunch(worktreeId, 'codex', {
requestId: 'continue-click',
prompt: 'PR #1 context',
promptDelivery: 'draft'
})
const other = startStructuredAgentLaunch(worktreeId, 'codex', {
requestId: 'fix-click',
prompt: 'PR #1 context',
promptDelivery: 'auto-submit'
})
// Why: a draft and a send are different requests; neither lands in the other's chat.
// Why: two actions are two chats, whatever their text; neither lands in the other's chat.
expect(other.sessionId).toBe(sent.sessionId)
await expect(other.promptDeliveryResult).resolves.toEqual({
delivered: true,
@@ -160,10 +160,10 @@ function joinStructuredLaunchState(
options: StructuredAgentLaunchOptions,
request: StructuredLaunchRequest
): StructuredLaunchStateResult | undefined {
// A repeat (a double click) shares the text the first click staged, so it is sent once.
const repeat = repeatedStructuredLaunchAttempt(existing.callers.attempt, request)
// A re-delivery of the same action (a double click) shares the text it staged, so it is sent once.
const repeat = repeatedStructuredLaunchAttempt(existing.callers.attempt, request.id)
// An empty chat takes the first text sent to it, delivered the way that request asked.
const claim = claimableStructuredLaunchAttempt(existing, request)
const claim = repeat ? undefined : claimableStructuredLaunchAttempt(existing, request)
const retrying = existing.visibilityUnknown
const joined = joinLaunchDelivery(
options,
@@ -187,13 +187,14 @@ function joinStructuredLaunchState(
}
if (claim) {
existing.promptDelivery = options.promptDelivery
Object.assign(claim, { request, stagedEntry: stagedPrompt })
Object.assign(claim, { requestId: request.id, blank: false, stagedEntry: stagedPrompt })
}
if (!retrying && !repeat) {
launchDraft.seedStructuredAgentLaunchDraft(existing.intent.sessionId, agent, joined)
}
// A re-delivery waits on the text its action staged, if any, and never stages its own.
const { prompt: _retryPrompt, ...joinedWithoutPrompt } = joined
const callerOptions = retrying ? joinedWithoutPrompt : joined
const callerOptions = retrying || (repeat && !repeat.stagedEntry) ? joinedWithoutPrompt : joined
return {
state: existing,
caller: addStructuredLaunchCaller({
@@ -233,7 +234,8 @@ function structuredAgentLaunchState(
launchDraft.seedStructuredAgentLaunchDraft(intent.sessionId, agent, options)
const callers = createStructuredLaunchCallerGroup({
kind: 'first',
request,
requestId: request.id,
blank: !request.hasText,
stagedEntry: stagedPrompt
})
const state: StructuredLaunchState = {
@@ -289,7 +291,7 @@ export function cancelStructuredAgentLaunch(worktreeId: string, sessionId: strin
export function startStructuredAgentLaunch(
worktreeId: string,
agent: AgentSessionHandleProvider,
options: StructuredAgentLaunchOptions = {}
options: StructuredAgentLaunchOptions
): StructuredAgentLaunchResult {
const { state, caller } = structuredAgentLaunchState(worktreeId, agent, options)
return {
@@ -38,6 +38,7 @@ const INITIAL_SETTINGS = useAppStore.getState().settings
function pairedPlan(overrides: { resumeFrom?: { providerSessionId: string } } = {}) {
return adoptAgentSessionLaunchVerdict({
requestId: 'request-1',
route: 'structured-native-chat',
agent: 'claude',
worktreeId: WORKTREE,
@@ -149,7 +150,7 @@ describe('a structured chat launch on a paired server', () => {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: resume reads only the id fields the preparation mock ignores.
const session = { id: 'vault-1', sessionId: 'provider-1' } as AiVaultSession
await resumeAiVaultSessionInNewChat(session, 'claude', WORKTREE)
await resumeAiVaultSessionInNewChat(session, 'claude', WORKTREE, 'resume-click')
await vi.waitFor(() => expect(mocks.toastError).toHaveBeenCalled())
await new Promise((resolve) => setTimeout(resolve, 0))
@@ -111,6 +111,7 @@ export async function openDeclinedStructuredLaunchTerminal(args: {
...(args.plan.onPromptDelivered ? { onPromptDelivered: args.plan.onPromptDelivered } : {}),
agentSessionLaunchPlan: adoptAgentSessionLaunchVerdict({
route: 'terminal-tui',
requestId: args.plan.requestId,
agent: args.plan.agent,
worktreeId: args.worktreeId
})
@@ -62,6 +62,8 @@ export async function launchStructuredWorktreeSession(
// keeps a retry from re-resolving against a host that has changed since.
const plan = adoptAgentSessionLaunchVerdict({
route: args.agentLaunchRoute,
// One create is one user action: a retry of it re-delivers the same request.
requestId: args.creationId,
agent,
prompt: args.request.launchDraftPrompt ?? args.request.quickPrompt,
...(args.request.promptDelivery ? { promptDelivery: args.request.promptDelivery } : {})