mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 16:02:29 +00:00
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:
@@ -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]
|
||||
)
|
||||
|
||||
+3
-3
@@ -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,
|
||||
|
||||
+7
-7
@@ -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: {
|
||||
|
||||
+3
-3
@@ -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,
|
||||
|
||||
+2
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
}
|
||||
|
||||
+23
-8
@@ -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 } : {})
|
||||
|
||||
Reference in New Issue
Block a user