Merge branch 'brennanb2025/claude-sdk-enable-platforms' into brennanb2025/claude-sdk-ready-mainsync

This commit is contained in:
Merge Sim
2026-09-03 13:16:57 -07:00
31 changed files with 656 additions and 329 deletions
@@ -1,35 +0,0 @@
import type {
AgentSessionExecutionLocation,
AgentSessionRecord
} from '../../../shared/agent-session-record'
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
export type StructuredAgentSessionTab = {
sessionId: string
workspaceId: string
agent: 'claude' | 'codex'
}
export function adapterSupportsAgentSessionCreate(
adapter: StructuredAgentSessionAdapter,
location: AgentSessionExecutionLocation,
agent: string
): boolean {
return (
adapter.supportsCreate?.(location, agent) ??
(agent === 'codex' && (adapter.supportsLocation?.(location) ?? false))
)
}
export function adapterSupportsAgentSessionRecord(
adapter: StructuredAgentSessionAdapter,
record: AgentSessionRecord
): boolean {
return adapter.supportsCreate
? adapter.supportsCreate(record.location, record.provider)
: record.provider === 'codex'
}
export function structuredAgentSessionTabAgent(agent: string): 'claude' | 'codex' {
return agent === 'claude' ? 'claude' : 'codex'
}
@@ -114,6 +114,7 @@ function hostStub(): StructuredAgentSessionHost {
}
}
})),
supportsCreate: vi.fn(() => true),
handoffStatus: vi.fn(async () => ({ owner: 'native' })),
readOptions: vi.fn(async () => ({
models: [{ id: 'gpt-live', label: 'GPT Live', isDefault: true, efforts: [] }],
@@ -364,6 +365,25 @@ describe('method routing', () => {
expect(ensured).toMatchObject({ ok: true })
})
/** A client-supplied location skips the worktree-resolving support check, so both attach-shaped
* entries must ask the executing host directly or a host that cannot fence a provider child
* would create one anyway. */
it.each(['agentSession.create', 'agentSession.ensure'])(
'refuses %s for a client-supplied location the executing host does not support',
async (method) => {
hostCalls.supportsCreate.mockReturnValue(false)
const refused = await call(method, attachParams())
expect(refused).toMatchObject({
ok: false,
error: { message: expect.stringContaining('structured_agent_session_unsupported') }
})
expect(hostCalls.attach).not.toHaveBeenCalled()
expect(hostCalls.supportsCreate).toHaveBeenCalledWith(attachParams().location, 'codex')
}
)
it('tags the prompt kind from the method name, not from the client', async () => {
const params = {
envelope: envelope(),
@@ -10,6 +10,7 @@ import {
agentSessionFingerprintConflict,
computeAgentSessionPayloadFingerprint
} from '../../../../shared/agent-session-mutation-envelope'
import type { z } from 'zod'
import { defineMethod, defineStreamingMethod, type RpcAnyMethod, type RpcContext } from '../core'
import {
ensureStructuredHostInstalled as ensureHostInstalled,
@@ -45,6 +46,29 @@ function subscriptionIdFor(ctx: RpcContext, sessionId: string): string {
return ctx.requestId ? `${base}:${ctx.requestId}` : base
}
/**
* The attach-shaped entries take the location from the client instead of resolving it from a
* worktree, so they never reach the worktree-resolving create-support check. Ask the executing
* host the same question directly: the answer includes host-measured facts the client cannot see
* or forge, such as whether this machine can read a provider child's process start time.
*/
async function attachClientSuppliedLocation(
params: z.infer<typeof AttachParams>,
ctx: RpcContext
): Promise<unknown> {
await ensureHostInstalled(ctx)
const host = requireHost(ctx)
if (!host.supportsCreate(params.location, params.agent)) {
throw new Error('structured_agent_session_unsupported')
}
const { agent: _attachAgent, provider: _attachProvider, ...attachWithoutAgent } = params
return host.attach(callerFor(ctx), {
...attachWithoutAgent,
provider: params.provider as 'claude' | 'codex',
agent: params.agent as 'claude' | 'codex'
} as AgentSessionAttachParams)
}
export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [
defineMethod({
name: 'agentSession.createSupport',
@@ -106,27 +130,13 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [
}
return result
}
await ensureHostInstalled(ctx)
const { agent: _attachAgent, provider: _attachProvider, ...attachWithoutAgent } = params
return requireHost(ctx).attach(callerFor(ctx), {
...attachWithoutAgent,
provider: params.provider as 'claude' | 'codex',
agent: params.agent as 'claude' | 'codex'
} as AgentSessionAttachParams)
return attachClientSuppliedLocation(params, ctx)
}
}),
defineMethod({
name: 'agentSession.ensure',
params: AttachParams,
handler: async (params, ctx) => {
await ensureHostInstalled(ctx)
const { agent: _attachAgent, provider: _attachProvider, ...attachWithoutAgent } = params
return requireHost(ctx).attach(callerFor(ctx), {
...attachWithoutAgent,
provider: params.provider as 'claude' | 'codex',
agent: params.agent as 'claude' | 'codex'
} as AgentSessionAttachParams)
}
handler: async (params, ctx) => attachClientSuppliedLocation(params, ctx)
}),
defineMethod({
name: 'agentSession.send',
@@ -17,8 +17,9 @@ import { markOnboardingProjectAdded } from '@/lib/onboarding-project-checklist'
import { translate } from '@/i18n/i18n'
import { upsertAddedRepoWithProjectHostSetup } from './add-repo-store-upsert'
import { worktreeRefreshOptions } from './add-repo-runtime-owner'
import { startStructuredCodexLaunch } from '@/lib/structured-agent-session-launch'
import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-codex-session'
import { startStructuredAgentLaunch } from '@/lib/structured-agent-session-launch'
import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle'
import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session'
const NonGitFolderDialog = React.memo(function NonGitFolderDialog() {
const activeModal = useAppStore((s) => s.activeModal)
@@ -101,8 +102,11 @@ const NonGitFolderDialog = React.memo(function NonGitFolderDialog() {
...(launch.startup ? { startup: launch.startup } : {}),
...(launch.route === 'structured-native-chat' ? { providesInitialSurface: true } : {})
})
if (launch.route === 'structured-native-chat' && launch.agent === 'codex') {
const structured = startStructuredCodexLaunch(folderWorktree.id)
if (
launch.route === 'structured-native-chat' &&
isAgentSessionHandleProvider(launch.agent)
) {
const structured = startStructuredAgentLaunch(folderWorktree.id, launch.agent)
const fallback = structured.claimDefinitiveRefusalFallback(() => {
activateAndRevealWorktree(folderWorktree.id, {
sidebarRevealBehavior: 'auto',
@@ -28,8 +28,9 @@ import {
resolveAgentLaunchRoute
} from '@/lib/agent-launch-routing'
import { readLocalRuntimeCapabilities } from '@/runtime/local-runtime-capabilities'
import { startStructuredCodexLaunch } from '@/lib/structured-agent-session-launch'
import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-codex-session'
import { startStructuredAgentLaunch } from '@/lib/structured-agent-session-launch'
import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle'
import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session'
import { useAppStore } from '@/store'
import {
buildFolderWorkspaceLinkedStartupPlan,
@@ -232,8 +233,8 @@ export async function submitFolderWorkspaceCreate({
runtimeEnvironmentId
})
let structuredLaunchAccepted = structuredLaunch
if (structuredLaunch && quickAgent === 'codex') {
const launch = startStructuredCodexLaunch(folderWorkspaceKey(workspace.id), {
if (structuredLaunch && isAgentSessionHandleProvider(quickAgent)) {
const launch = startStructuredAgentLaunch(folderWorkspaceKey(workspace.id), quickAgent, {
prompt: launchDraftPrompt ?? note
})
const refusalFallback = launch.claimDefinitiveRefusalFallback(async () => {
@@ -8,6 +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 type { TuiAgent } from '../../../../shared/tui-agent'
import type { LaunchSource } from '../../../../shared/telemetry-events'
import {
@@ -15,7 +16,7 @@ import {
filterEnabledTuiAgents
} from '../../../../shared/tui-agent-selection'
import { translate } from '@/i18n/i18n'
import { useStructuredCodexLaunchStatus } from '@/lib/structured-agent-session-launch'
import { useStructuredAgentLaunchStatus } from '@/lib/structured-agent-session-launch'
export type QuickLaunchAgentMenuItemsProps = {
worktreeId: string
@@ -117,7 +118,12 @@ function QuickLaunchAgentMenuItemsInner({
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
const newAgentShortcut = useOptionalShortcutLabel('tab.newAgent')
const structuredCodexLaunchStatus = useStructuredCodexLaunchStatus(worktreeId)
// One hook per structured provider: the launch registry is keyed by agent, and hooks cannot run
// inside the agent list's render loop.
const structuredLaunchStatusByAgent = {
claude: useStructuredAgentLaunchStatus(worktreeId, 'claude'),
codex: useStructuredAgentLaunchStatus(worktreeId, 'codex')
}
const openAgentSettings = useCallback(() => {
openSettingsTarget({ pane: 'agents', repoId: null })
@@ -199,26 +205,33 @@ function QuickLaunchAgentMenuItemsInner({
{agents.map((agent) => {
const entry = getCatalogEntry(agent)
const label = entry?.label ?? agent
const isStructuredCodexPending =
agent === 'codex' && structuredCodexLaunchStatus === 'pending'
const menuLabel = isStructuredCodexPending ? 'Starting Codex chat…' : label
const isStructuredLaunchPending =
isAgentSessionHandleProvider(agent) && structuredLaunchStatusByAgent[agent] === 'pending'
const pendingLabel = translate(
'components.native-chat.structuredSessionLaunchPending',
'Starting {{value0}} chat…',
{ value0: label }
)
const menuLabel = isStructuredLaunchPending ? pendingLabel : label
const showsDefaultAgentShortcut =
newAgentShortcut !== null && defaultAgent !== 'blank' && agent === defaultAgent
return (
<DropdownMenuItem
key={agent}
disabled={isStructuredCodexPending}
disabled={isStructuredLaunchPending}
onSelect={() => runLaunch(agent)}
className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium"
title={translate(
'auto.components.tab.bar.QuickLaunchButton.ec2adf093e',
isStructuredCodexPending
? 'Starting Codex chat…'
: 'Launch {{value0}} in a new terminal',
isStructuredCodexPending ? undefined : { value0: label }
)}
title={
isStructuredLaunchPending
? pendingLabel
: translate(
'auto.components.tab.bar.QuickLaunchButton.ec2adf093e',
'Launch {{value0}} in a new terminal',
{ value0: label }
)
}
>
{isStructuredCodexPending ? (
{isStructuredLaunchPending ? (
<Loader2 className="size-3.5 shrink-0 animate-spin" aria-hidden="true" />
) : (
<AgentIcon agent={agent} size={14} />
@@ -39,7 +39,7 @@ vi.mock('@/lib/agent-catalog', () => ({
AgentIcon: () => null
}))
vi.mock('@/lib/structured-agent-session-launch', () => ({
useStructuredCodexLaunchStatus: () => structuredLaunchMock.status
useStructuredAgentLaunchStatus: () => structuredLaunchMock.status
}))
import TabBarCreateEntry from './TabBarCreateEntry'
@@ -33,7 +33,10 @@ import {
getTabEntryOmniboxPlaceholder
} from './tab-create-entry-copy'
import { EMPTY_AGENT_OPTIONS, EMPTY_MENU_OPTIONS } from './tab-create-entry-empty-options'
import { useStructuredCodexLaunchStatus } from '@/lib/structured-agent-session-launch'
import { useStructuredAgentLaunchStatus } from '@/lib/structured-agent-session-launch'
import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle'
import type { TuiAgent } from '../../../../shared/tui-agent'
import { translate } from '@/i18n/i18n'
import type { TabEntryActionClassification } from './tab-create-entry-classifier'
import type { TabBarCreateEntryProps } from './tab-create-entry-props'
@@ -60,7 +63,14 @@ function TabBarCreateEntrySession({
const [error, setError] = useState<string | null>(null)
const [switchError, setSwitchError] = useState<string | null>(null)
const [selectionGuidance, setSelectionGuidance] = useState<string | null>(null)
const structuredCodexLaunchStatus = useStructuredCodexLaunchStatus(worktreeId)
// 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'),
codex: useStructuredAgentLaunchStatus(worktreeId, 'codex')
}
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)
@@ -228,7 +238,7 @@ function TabBarCreateEntrySession({
return
}
if (selectedOption.kind === 'agent') {
if (selectedOption.option.agent === 'codex' && structuredCodexLaunchStatus === 'pending') {
if (isStructuredLaunchPending(selectedOption.option.agent)) {
return
}
onLaunchAgent?.(selectedOption.option.agent)
@@ -380,24 +390,22 @@ function TabBarCreateEntrySession({
option={option}
selected={index === activeSelectedIndex}
labelOverride={
option.kind === 'agent' &&
option.option.agent === 'codex' &&
structuredCodexLaunchStatus === 'pending'
? 'Starting Codex chat…'
option.kind === 'agent' && isStructuredLaunchPending(option.option.agent)
? translate(
'components.native-chat.structuredSessionLaunchPending',
'Starting {{value0}} chat…',
{ value0: option.option.label }
)
: undefined
}
disabled={
disabled ||
pending ||
(option.kind === 'agent' &&
option.option.agent === 'codex' &&
structuredCodexLaunchStatus === 'pending')
(option.kind === 'agent' && isStructuredLaunchPending(option.option.agent))
}
loading={
(pending && index === activeSelectedIndex) ||
(option.kind === 'agent' &&
option.option.agent === 'codex' &&
structuredCodexLaunchStatus === 'pending')
(option.kind === 'agent' && isStructuredLaunchPending(option.option.agent))
}
onClick={() => {
setSelectionGuidance(null)
@@ -3,7 +3,7 @@ import type * as ReactModule from 'react'
const mocks = vi.hoisted(() => ({
callRuntimeRpc: vi.fn(),
cancelStructuredCodexLaunch: vi.fn(),
cancelStructuredAgentLaunch: vi.fn(),
closeBrowserTab: vi.fn(),
closeFile: vi.fn(),
closeStructuredAgentSession: vi.fn(),
@@ -73,7 +73,7 @@ vi.mock('@/runtime/structured-agent-session-close', () => ({
}))
vi.mock('@/lib/structured-agent-session-launch', () => ({
cancelStructuredCodexLaunch: mocks.cancelStructuredCodexLaunch
cancelStructuredAgentLaunch: mocks.cancelStructuredAgentLaunch
}))
vi.mock('@/runtime/runtime-worktree-selector', () => ({
@@ -129,7 +129,7 @@ describe('structured agent-session close ordering', () => {
closeItem(AGENT_TAB.id)
await vi.waitFor(() => expect(order).toEqual(['agent-close', 'tab-close', 'local-remove']))
expect(mocks.cancelStructuredCodexLaunch).toHaveBeenCalledWith('wt-1', 'session-1')
expect(mocks.cancelStructuredAgentLaunch).toHaveBeenCalledWith('wt-1', 'session-1')
})
it('keeps the tab available when owner disposal fails, so close can be retried', async () => {
@@ -154,7 +154,7 @@ describe('structured agent-session close ordering', () => {
closeMany([AGENT_TAB.id])
expect(mocks.cancelStructuredCodexLaunch).toHaveBeenCalledWith('wt-1', 'session-1')
expect(mocks.cancelStructuredAgentLaunch).toHaveBeenCalledWith('wt-1', 'session-1')
await vi.waitFor(() => expect(mocks.closeUnifiedTab).toHaveBeenCalledWith(AGENT_TAB.id))
})
})
@@ -10,7 +10,7 @@ import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner
import { closeBrowserWorkspaceTabOnHosts } from '@/runtime/browser-workspace-tab-close'
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import { closeStructuredAgentSession } from '@/runtime/structured-agent-session-close'
import { cancelStructuredCodexLaunch } from '@/lib/structured-agent-session-launch'
import { cancelStructuredAgentLaunch } from '@/lib/structured-agent-session-launch'
import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector'
import { translate } from '@/i18n/i18n'
@@ -121,12 +121,12 @@ export function useTabGroupTabCloseCommands({
worktreeId
)
if (item.contentType === 'agent-session') {
cancelStructuredCodexLaunch(worktreeId, item.entityId)
cancelStructuredAgentLaunch(worktreeId, item.entityId)
// Why: the structured session lives on the host, so the local tab close must also
// retire the host's canonical row or it reappears on the next sync.
// Cancel a still-reconciling create before closing its owner; otherwise a missing
// post-create snapshot is mistaken for an unknown outcome and retried after close.
cancelStructuredCodexLaunch(worktreeId, item.entityId)
cancelStructuredAgentLaunch(worktreeId, item.entityId)
const target = getActiveRuntimeTarget({
activeRuntimeEnvironmentId: runtimeEnvironmentId
})
@@ -198,7 +198,7 @@ export function useTabGroupTabCloseCommands({
worktreeId
)
if (item.contentType === 'agent-session') {
cancelStructuredCodexLaunch(worktreeId, item.entityId)
cancelStructuredAgentLaunch(worktreeId, item.entityId)
const target = getActiveRuntimeTarget({
activeRuntimeEnvironmentId: runtimeEnvironmentId
})
@@ -1,23 +1,23 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
startStructuredCodexLaunch: vi.fn(),
startStructuredAgentLaunch: vi.fn(),
activateStructuredAgentSessionById: vi.fn()
}))
vi.mock('@/lib/structured-agent-session-launch', () => ({
startStructuredCodexLaunch: mocks.startStructuredCodexLaunch
startStructuredAgentLaunch: mocks.startStructuredAgentLaunch
}))
vi.mock('@/lib/structured-agent-session-tab-activation', () => ({
activateStructuredAgentSessionById: mocks.activateStructuredAgentSessionById
}))
vi.mock('@/lib/launch-structured-codex-session', () => ({
vi.mock('@/lib/launch-structured-agent-session', () => ({
StructuredAgentSessionCreateRefusalError: class extends Error {}
}))
import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-codex-session'
import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session'
import { settleFullCreationStructuredLaunch } from './full-creation-structured-launch'
describe('settleFullCreationStructuredLaunch', () => {
@@ -26,7 +26,7 @@ describe('settleFullCreationStructuredLaunch', () => {
it('runs the legacy terminal fallback after a definitive refusal', async () => {
const fallbackActivation = { primaryTabId: 'fallback-tab' }
const onDefinitiveRefusal = vi.fn().mockResolvedValue(fallbackActivation)
mocks.startStructuredCodexLaunch.mockReturnValue({
mocks.startStructuredAgentLaunch.mockReturnValue({
launchResult: Promise.reject(new StructuredAgentSessionCreateRefusalError('unsupported')),
isVisibilityUnknown: () => false,
claimDefinitiveRefusalFallback: (fallback: () => Promise<unknown>) =>
@@ -54,7 +54,7 @@ describe('settleFullCreationStructuredLaunch', () => {
it('reports an unknown outcome without starting a fallback terminal', async () => {
const onDefinitiveRefusal = vi.fn()
mocks.startStructuredCodexLaunch.mockReturnValue({
mocks.startStructuredAgentLaunch.mockReturnValue({
launchResult: Promise.reject(new Error('connection lost')),
isVisibilityUnknown: () => true,
claimDefinitiveRefusalFallback: vi.fn(() => Promise.resolve(false))
@@ -1,7 +1,8 @@
import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle'
import type { TuiAgent } from '../../../../shared/tui-agent'
import type { ActivateAndRevealResult } from '@/lib/worktree-activation'
import { startStructuredCodexLaunch } from '@/lib/structured-agent-session-launch'
import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-codex-session'
import { startStructuredAgentLaunch } from '@/lib/structured-agent-session-launch'
import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session'
import { activateStructuredAgentSessionById } from '@/lib/structured-agent-session-tab-activation'
type Activation = ActivateAndRevealResult | false
@@ -20,11 +21,11 @@ export async function settleFullCreationStructuredLaunch(args: {
}> {
let activation = args.initialActivation
let structuredLaunchAccepted = args.structuredLaunch
if (!args.structuredLaunch || args.agent !== 'codex') {
if (!args.structuredLaunch || !isAgentSessionHandleProvider(args.agent)) {
return { structuredLaunchAccepted, visibilityUnknown: false, activation }
}
const launch = startStructuredCodexLaunch(args.worktreeId, { prompt: args.prompt })
const launch = startStructuredAgentLaunch(args.worktreeId, args.agent, { prompt: args.prompt })
const refusalFallback = launch.claimDefinitiveRefusalFallback(async () => {
structuredLaunchAccepted = false
activation = await args.onDefinitiveRefusal()
+2 -1
View File
@@ -16809,7 +16809,8 @@
"command": "orca orchestration check"
},
"structuredSessionCloseFailed": "Could not close this chat session",
"structuredSessionLaunchFailed": "Could not open Codex chat",
"structuredSessionLaunchFailed": "Could not open {{value0}} chat",
"structuredSessionLaunchPending": "Starting {{value0}} chat…",
"structuredSessionCloseFailedDescription": "The terminal stayed open so the provider remains recoverable.",
"handoff": {
"stage": {
@@ -27,6 +27,68 @@ function route(overrides: Partial<Parameters<typeof resolveAgentLaunchRoute>[0]>
}
describe('resolveAgentLaunchRoute', () => {
it.each(['claude', 'codex'] as const)(
'routes a supported local %s launch to structured native chat',
(agent) => {
expect(route({ agent })).toBe('structured-native-chat')
expect(
route({ agent, launchText: 'explain this change', promptDelivery: 'auto-submit' })
).toBe('structured-native-chat')
}
)
/** Boundary guard between this lane and the one that owns Windows Codex. Codex's win32 refusal is
* deliberate, so it is asserted against whatever currently lets Claude through rather than
* against one host answer — a future gate swap must not be able to flip Codex on quietly. */
describe("Codex's Windows refusal", () => {
it('holds in the exact situation that routes Claude to structured', () => {
const onWindows = { platform: 'win32' } as const
expect(route({ ...onWindows, agent: 'claude' })).toBe('structured-native-chat')
expect(route({ ...onWindows, agent: 'codex' })).toBe('legacy-native-chat')
})
it('holds for every host capability set, including ones that carry extra gates', () => {
for (const hostCapabilities of [
[STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY],
[STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, 'agent-session.structured.claude.v1'],
[STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, 'agent-session.structured.hold.v1']
]) {
expect(route({ agent: 'codex', platform: 'win32', hostCapabilities })).toBe(
'legacy-native-chat'
)
}
})
it('holds for prompted and folder-workspace launches too', () => {
expect(
route({
agent: 'codex',
platform: 'win32',
launchText: 'go',
promptDelivery: 'auto-submit'
})
).toBe('legacy-native-chat')
expect(route({ agent: 'codex', platform: 'win32', workspaceKind: 'folder' })).toBe(
'legacy-native-chat'
)
})
})
/** Pins Codex's whole platform answer, not just win32, so no platform silently changes here. */
it.each([
['darwin', 'structured-native-chat'],
['linux', 'structured-native-chat'],
['win32', 'legacy-native-chat']
] as const)('leaves Codex routing on %s unchanged', (platform, expected) => {
expect(route({ agent: 'codex', platform })).toBe(expected)
})
/** Claude's Windows answer is not a client-side platform guess: the route lets it through and the
* executing host settles it with agentSession.createSupport at create time. */
it('lets a Windows Claude launch reach the host-measured create support check', () => {
expect(route({ agent: 'claude', platform: 'win32' })).toBe('structured-native-chat')
})
it('routes a supported local Codex launch to structured native chat', () => {
expect(route()).toBe('structured-native-chat')
expect(route({ launchText: 'explain this change', promptDelivery: 'auto-submit' })).toBe(
@@ -52,7 +114,9 @@ describe('resolveAgentLaunchRoute', () => {
it('fails closed for missing capability, unsupported providers, and explicit TUI options', () => {
expect(route({ hostCapabilities: [] })).toBe('legacy-native-chat')
expect(route({ agent: 'claude' })).toBe('legacy-native-chat')
// openclaude and grok render native chat but have no structured adapter.
expect(route({ agent: 'openclaude' })).toBe('legacy-native-chat')
expect(route({ agent: 'grok' })).toBe('legacy-native-chat')
expect(route({ requiresTuiLaunchCustomization: true })).toBe('legacy-native-chat')
expect(route({ initialSessionOptions: { model: 'gpt-5.6-sol' } })).toBe('legacy-native-chat')
})
@@ -71,9 +135,11 @@ describe('resolveAgentLaunchRoute', () => {
}
)
it('keeps floating, Windows, WSL, and repair-required launches terminal-backed', () => {
it('keeps floating, WSL, and repair-required launches terminal-backed', () => {
expect(route({ workspaceKind: 'floating' })).toBe('legacy-native-chat')
expect(route({ platform: 'win32' })).toBe('legacy-native-chat')
expect(route({ agent: 'claude', workspaceKind: 'floating', platform: 'win32' })).toBe(
'legacy-native-chat'
)
expect(
route({
projectRuntime: {
+6 -2
View File
@@ -1,5 +1,6 @@
import type { GlobalSettings } from '../../../shared/global-settings-types'
import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime'
import { isAgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle'
import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../shared/protocol-version'
import type { TuiAgent } from '../../../shared/tui-agent'
import {
@@ -92,13 +93,16 @@ export function resolveAgentLaunchRoute(input: AgentLaunchRoutingInput): AgentLa
input.initialSessionOptions && Object.keys(input.initialSessionOptions).length > 0
)
const structuredSupported =
input.agent === 'codex' &&
isAgentSessionHandleProvider(input.agent) &&
input.promptDelivery !== 'draft' &&
input.workspaceKind !== 'floating' &&
input.requiresTuiLaunchCustomization !== true &&
!hasInitialSessionOptions &&
input.executionHostId === 'local' &&
input.platform !== 'win32' &&
// Codex's Windows refusal is deliberate and settled elsewhere, so it stays a client-side
// answer. Claude's is measured by the executing host at create time (agentSession.createSupport)
// because only that host knows whether it can read a provider child's start time.
(input.agent !== 'codex' || input.platform !== 'win32') &&
!runtimeRefused &&
input.hostCapabilities.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY)
@@ -31,7 +31,8 @@ import type { LaunchSource } from '../../../shared/telemetry-events'
import { getConnectionIdFromState } from '@/lib/connection-context'
import { resolveInitialNativeChatSessionOptions } from '@/components/native-chat/native-chat-launch-session-options'
import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache'
import { startStructuredCodexLaunch } from '@/lib/structured-agent-session-launch'
import { startStructuredAgentLaunch } from '@/lib/structured-agent-session-launch'
import { isAgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle'
import {
hasExplicitTuiLaunchCustomization,
hasExplicitTuiAgentArgs,
@@ -226,8 +227,8 @@ function launchAgentInNewTabInternal(
hasExplicitTuiLaunchCustomization(store.settings, agent),
initialSessionOptions: startupPlan.sessionOptions
})
if (launchRoute === 'structured-native-chat' && agent === 'codex') {
const structuredLaunch = startStructuredCodexLaunch(worktreeId, {
if (launchRoute === 'structured-native-chat' && isAgentSessionHandleProvider(agent)) {
const structuredLaunch = startStructuredAgentLaunch(worktreeId, agent, {
prompt: trimmedPrompt,
...(promptDelivery === 'submit-after-ready' ? { promptDelivery } : {}),
onPromptDelivered
@@ -13,6 +13,8 @@ const mockLaunchStructuredCodexSession = vi.fn()
const mockRefreshLocalStructuredSessionTabs = vi.fn()
const mockToastError = vi.fn()
const mockCallStructuredAgentSession = vi.fn()
const STRUCTURED_HOST_CAPABILITIES = ['agent-session.structured.v1']
let hostCapabilities: readonly string[] = STRUCTURED_HOST_CAPABILITIES
function structuredLaunchIntent(worktreeId: string, sessionId = 'codex-session-1') {
return {
@@ -91,12 +93,12 @@ vi.mock('@/runtime/web-runtime-session', () => ({
isWebRuntimeSessionActive: vi.fn(() => false),
isWebTerminalSurfaceTabId: vi.fn(() => false)
}))
vi.mock('@/lib/launch-structured-codex-session', () => {
vi.mock('@/lib/launch-structured-agent-session', () => {
class StructuredAgentSessionCreateRefusalError extends Error {}
return {
createStructuredCodexSessionLaunchIntent: mockCreateStructuredCodexSessionLaunchIntent,
createStructuredAgentSessionLaunchIntent: mockCreateStructuredCodexSessionLaunchIntent,
abandonStructuredAgentSessionLaunchIntent: mockAbandonStructuredAgentSessionLaunchIntent,
launchStructuredCodexSession: mockLaunchStructuredCodexSession,
launchStructuredAgentSession: mockLaunchStructuredCodexSession,
StructuredAgentSessionCreateRefusalError
}
})
@@ -105,7 +107,7 @@ vi.mock('@/runtime/local-structured-session-tabs-sync', () => ({
LOCAL_STRUCTURED_SESSION_OWNER: 'local-structured-session'
}))
vi.mock('@/runtime/local-runtime-capabilities', () => ({
readLocalRuntimeCapabilities: () => ['agent-session.structured.v1']
readLocalRuntimeCapabilities: () => hostCapabilities
}))
vi.mock('@/lib/worktree-runtime-owner', () => ({
getExecutionHostIdForWorktree: () =>
@@ -149,6 +151,7 @@ describe('structured chat adoption guard on the launch path', () => {
}
])
mockToastError.mockReset()
hostCapabilities = STRUCTURED_HOST_CAPABILITIES
store.settings.openAgentTabsInChatByDefault = true
})
@@ -164,7 +167,7 @@ describe('structured chat adoption guard on the launch path', () => {
focusAfterMenuClose: 'structured-session'
})
expect(shouldQueueTerminalFocusAfterMenuClose(result!)).toBe(false)
expect(mockCreateStructuredCodexSessionLaunchIntent).toHaveBeenCalledWith('wt-1')
expect(mockCreateStructuredCodexSessionLaunchIntent).toHaveBeenCalledWith('wt-1', 'codex')
expect(mockLaunchStructuredCodexSession).toHaveBeenCalledWith(
expect.objectContaining({ worktreeId: 'wt-1' })
)
@@ -172,6 +175,51 @@ describe('structured chat adoption guard on the launch path', () => {
expect(mockWaitForAgentReady).not.toHaveBeenCalled()
})
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' })
expect(result).toMatchObject({ tabId: null, focusAfterMenuClose: 'structured-session' })
expect(mockCreateStructuredCodexSessionLaunchIntent).toHaveBeenCalledWith('wt-1', 'claude')
expect(mockCreateTab).not.toHaveBeenCalled()
})
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' })
expect(mockCreateStructuredCodexSessionLaunchIntent).not.toHaveBeenCalled()
expect(mockCreateTab).toHaveBeenCalled()
})
it('fails a Claude launch closed to the terminal when the host declines create support', async () => {
const { StructuredAgentSessionCreateRefusalError } =
await import('./launch-structured-agent-session')
mockLaunchStructuredCodexSession.mockRejectedValueOnce(
new StructuredAgentSessionCreateRefusalError('structured_agent_session_unsupported')
)
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({ agent: 'claude', worktreeId: 'wt-1' })
expect(result).toMatchObject({ tabId: null })
await vi.waitFor(() => expect(mockCreateTab).toHaveBeenCalledOnce())
expect(mockToastError).not.toHaveBeenCalled()
})
it('routes every structured launch through the shared host capability gate', async () => {
hostCapabilities = []
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({ agent: 'claude', worktreeId: 'wt-1' })
launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
expect(mockCreateStructuredCodexSessionLaunchIntent).not.toHaveBeenCalled()
expect(mockCreateTab).toHaveBeenCalledTimes(2)
})
/** The toggle is hidden under Terminal chat but its persisted value survives, so the launch
* path must re-check the default view rather than trust a stale opt-in. */
it('ignores a stale structured opt-in while the default view is Terminal chat', async () => {
@@ -192,7 +240,7 @@ describe('structured chat adoption guard on the launch path', () => {
it('falls back to the preserved terminal launch on a definitive refusal', async () => {
const { StructuredAgentSessionCreateRefusalError } =
await import('./launch-structured-codex-session')
await import('./launch-structured-agent-session')
mockLaunchStructuredCodexSession.mockRejectedValueOnce(
new StructuredAgentSessionCreateRefusalError('provider unavailable')
)
@@ -207,7 +255,7 @@ describe('structured chat adoption guard on the launch path', () => {
it('reports prompt delivery from the definitive-refusal terminal fallback', async () => {
const { StructuredAgentSessionCreateRefusalError } =
await import('./launch-structured-codex-session')
await import('./launch-structured-agent-session')
mockLaunchStructuredCodexSession.mockRejectedValueOnce(
new StructuredAgentSessionCreateRefusalError('provider unavailable')
)
@@ -0,0 +1,165 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation'
import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client'
import {
createStructuredAgentSessionLaunchIntent,
launchStructuredAgentSession,
StructuredAgentSessionCreateRefusalError
} from './launch-structured-agent-session'
vi.mock('@/runtime/structured-agent-session-client', () => ({
callStructuredAgentSession: vi.fn()
}))
describe('structured agent session launch', () => {
beforeEach(() => {
vi.mocked(callStructuredAgentSession).mockReset()
})
it('creates a native session with a host-verifiable launch intent', async () => {
vi.mocked(callStructuredAgentSession).mockImplementation(async (_target, _method, params) => ({
ok: true,
replayed: false,
fence: 1,
cursor: { epoch: 'epoch-1', sequence: 0 },
value: {
sessionId: (params as { envelope: { sessionId: string } }).envelope.sessionId,
fence: 1,
page: {
sessionId: 'session-1',
epoch: 'epoch-1',
direction: 'tail',
items: [],
removedItemIds: [],
submissions: [],
window: {
oldest: null,
newest: null,
nextCursor: { epoch: 'epoch-1', sequence: 0 }
},
liveCursor: { epoch: 'epoch-1', sequence: 0 },
hasOlder: false,
hasNewer: false
},
unconfirmedClientMessageIds: []
}
}))
const intent = createStructuredAgentSessionLaunchIntent('workspace-1', 'codex')
const receipt = await launchStructuredAgentSession(intent)
const params = vi.mocked(callStructuredAgentSession).mock.calls[0]?.[2] as {
envelope: { sessionId: string; payloadFingerprint: string }
worktree: string
agent: 'codex'
}
expect(receipt).toEqual({
sessionId: expect.stringMatching(/^codex_[A-Za-z0-9_]{36}$/),
fence: 1
})
expect(callStructuredAgentSession).toHaveBeenCalledWith(
{ kind: 'local' },
'agentSession.create',
expect.objectContaining({ worktree: 'id:workspace-1', agent: 'codex' })
)
expect(params.envelope.payloadFingerprint).toBe(
structuredAgentSessionPayloadFingerprint({
method: 'agentSession.create',
sessionId: params.envelope.sessionId,
fields: { worktree: 'id:workspace-1', agent: 'codex' }
})
)
expect(params).toBe(intent.params)
})
it('names Claude as the create provider and in the session id', () => {
const intent = createStructuredAgentSessionLaunchIntent('workspace-1', 'claude')
expect(intent.sessionId).toMatch(/^claude_[A-Za-z0-9_]{36}$/)
expect(intent.params.agent).toBe('claude')
expect(intent.params.envelope.payloadFingerprint).toBe(
structuredAgentSessionPayloadFingerprint({
method: 'agentSession.create',
sessionId: intent.sessionId,
fields: { worktree: 'id:workspace-1', agent: 'claude' }
})
)
})
it('asks the executing host for create support before creating a Claude session', async () => {
vi.mocked(callStructuredAgentSession).mockImplementation(async (_target, method) =>
method === 'agentSession.createSupport'
? { supported: true }
: { ok: true, replayed: false, value: { sessionId: 'claude_1', fence: 1 } }
)
const intent = createStructuredAgentSessionLaunchIntent('workspace-1', 'claude')
await launchStructuredAgentSession(intent)
expect(vi.mocked(callStructuredAgentSession).mock.calls.map(([, method]) => method)).toEqual([
'agentSession.createSupport',
'agentSession.create'
])
expect(callStructuredAgentSession).toHaveBeenNthCalledWith(
1,
{ kind: 'local' },
'agentSession.createSupport',
{ worktree: 'id:workspace-1', agent: 'claude' }
)
})
it('refuses a Claude launch the host says it cannot support, without creating', async () => {
vi.mocked(callStructuredAgentSession).mockResolvedValue({ supported: false, reason: 'agent' })
const intent = createStructuredAgentSessionLaunchIntent('workspace-1', 'claude')
await expect(launchStructuredAgentSession(intent)).rejects.toBeInstanceOf(
StructuredAgentSessionCreateRefusalError
)
expect(vi.mocked(callStructuredAgentSession).mock.calls.map(([, method]) => method)).toEqual([
'agentSession.createSupport'
])
})
it('fails closed when the create support probe cannot be answered', async () => {
vi.mocked(callStructuredAgentSession).mockRejectedValue(new Error('runtime unreachable'))
const intent = createStructuredAgentSessionLaunchIntent('workspace-1', 'claude')
await expect(launchStructuredAgentSession(intent)).rejects.toBeInstanceOf(
StructuredAgentSessionCreateRefusalError
)
expect(callStructuredAgentSession).toHaveBeenCalledOnce()
})
/** Codex's support answer is settled by the launch route and owned elsewhere; this pins that the
* Claude probe did not change Codex's wire traffic. */
it('does not probe create support for Codex', async () => {
vi.mocked(callStructuredAgentSession).mockResolvedValue({
ok: true,
replayed: false,
value: { sessionId: 'codex_1', fence: 1 }
})
await launchStructuredAgentSession(
createStructuredAgentSessionLaunchIntent('workspace-1', 'codex')
)
expect(vi.mocked(callStructuredAgentSession).mock.calls.map(([, method]) => method)).toEqual([
'agentSession.create'
])
})
it('replays the exact create envelope when an unknown outcome is retried', async () => {
const intent = createStructuredAgentSessionLaunchIntent('workspace-retry', 'codex')
vi.mocked(callStructuredAgentSession).mockRejectedValue(new Error('response lost'))
await expect(launchStructuredAgentSession(intent)).rejects.toThrow('response lost')
await expect(launchStructuredAgentSession(intent)).rejects.toThrow('response lost')
const first = vi.mocked(callStructuredAgentSession).mock.calls[0]?.[2]
const second = vi.mocked(callStructuredAgentSession).mock.calls[1]?.[2]
expect(first).toBe(intent.params)
expect(second).toBe(first)
expect(intent.params.envelope.clientOperationId).toMatch(/^\d{13}-[0-9a-f]{32}$/)
})
})
@@ -1,3 +1,4 @@
import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle'
import type {
AgentSessionAttachResult,
AgentSessionMutationEnvelope,
@@ -20,22 +21,24 @@ import { LOCAL_STRUCTURED_SESSION_OWNER } from '@/runtime/local-structured-sessi
type StructuredAgentSessionCreateParams = {
envelope: AgentSessionMutationEnvelope
worktree: string
agent: 'codex'
agent: AgentSessionHandleProvider
}
export type StructuredAgentSessionLaunchIntent = {
sessionId: string
worktreeId: string
agent: AgentSessionHandleProvider
params: StructuredAgentSessionCreateParams
}
export class StructuredAgentSessionCreateRefusalError extends Error {}
export function createStructuredCodexSessionLaunchIntent(
worktreeId: string
export function createStructuredAgentSessionLaunchIntent(
worktreeId: string,
agent: AgentSessionHandleProvider
): StructuredAgentSessionLaunchIntent {
const sessionId = `codex_${crypto.randomUUID().replaceAll('-', '_')}`
const fields = { worktree: toRuntimeWorktreeSelector(worktreeId), agent: 'codex' as const }
const sessionId = `${agent}_${crypto.randomUUID().replaceAll('-', '_')}`
const fields = { worktree: toRuntimeWorktreeSelector(worktreeId), agent }
const state = useAppStore.getState()
recordWebSessionFocusIntent(
{ environmentId: LOCAL_STRUCTURED_SESSION_OWNER },
@@ -47,6 +50,7 @@ export function createStructuredCodexSessionLaunchIntent(
return {
sessionId,
worktreeId,
agent,
params: {
envelope: {
sessionId,
@@ -73,9 +77,38 @@ export function abandonStructuredAgentSessionLaunchIntent(
)
}
export async function launchStructuredCodexSession(
/**
* Only the host that will execute the session can answer whether it supports creating one there —
* on Windows that means reading the provider child's process start time, which a client cannot
* observe. Codex is absent on purpose: its answer is settled by the launch route and owned
* elsewhere, so probing here would change Codex's wire traffic.
*/
async function requireHostCreateSupport(intent: StructuredAgentSessionLaunchIntent): Promise<void> {
if (intent.agent !== 'claude') {
return
}
let supported = false
try {
const support = await callStructuredAgentSession<{ supported: boolean; reason?: string }>(
{ kind: 'local' },
'agentSession.createSupport',
{ worktree: intent.params.worktree, agent: intent.agent }
)
supported = support.supported === true
} catch {
// An unanswered probe is not a yes.
supported = false
}
if (!supported) {
abandonStructuredAgentSessionLaunchIntent(intent)
throw new StructuredAgentSessionCreateRefusalError('structured_agent_session_unsupported')
}
}
export async function launchStructuredAgentSession(
intent: StructuredAgentSessionLaunchIntent
): Promise<Pick<AgentSessionAttachResult, 'sessionId' | 'fence'>> {
await requireHostCreateSupport(intent)
const result = await callStructuredAgentSession<
AgentSessionMutationResult<AgentSessionAttachResult>
>({ kind: 'local' }, 'agentSession.create', intent.params)
@@ -1,87 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation'
import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client'
import {
createStructuredCodexSessionLaunchIntent,
launchStructuredCodexSession
} from './launch-structured-codex-session'
vi.mock('@/runtime/structured-agent-session-client', () => ({
callStructuredAgentSession: vi.fn()
}))
describe('structured Codex launch', () => {
beforeEach(() => {
vi.mocked(callStructuredAgentSession).mockReset()
})
it('creates a native session with a host-verifiable launch intent', async () => {
vi.mocked(callStructuredAgentSession).mockImplementation(async (_target, _method, params) => ({
ok: true,
replayed: false,
fence: 1,
cursor: { epoch: 'epoch-1', sequence: 0 },
value: {
sessionId: (params as { envelope: { sessionId: string } }).envelope.sessionId,
fence: 1,
page: {
sessionId: 'session-1',
epoch: 'epoch-1',
direction: 'tail',
items: [],
removedItemIds: [],
submissions: [],
window: {
oldest: null,
newest: null,
nextCursor: { epoch: 'epoch-1', sequence: 0 }
},
liveCursor: { epoch: 'epoch-1', sequence: 0 },
hasOlder: false,
hasNewer: false
},
unconfirmedClientMessageIds: []
}
}))
const intent = createStructuredCodexSessionLaunchIntent('workspace-1')
const receipt = await launchStructuredCodexSession(intent)
const params = vi.mocked(callStructuredAgentSession).mock.calls[0]?.[2] as {
envelope: { sessionId: string; payloadFingerprint: string }
worktree: string
agent: 'codex'
}
expect(receipt).toEqual({
sessionId: expect.stringMatching(/^codex_[A-Za-z0-9_]{36}$/),
fence: 1
})
expect(callStructuredAgentSession).toHaveBeenCalledWith(
{ kind: 'local' },
'agentSession.create',
expect.objectContaining({ worktree: 'id:workspace-1', agent: 'codex' })
)
expect(params.envelope.payloadFingerprint).toBe(
structuredAgentSessionPayloadFingerprint({
method: 'agentSession.create',
sessionId: params.envelope.sessionId,
fields: { worktree: 'id:workspace-1', agent: 'codex' }
})
)
expect(params).toBe(intent.params)
})
it('replays the exact create envelope when an unknown outcome is retried', async () => {
const intent = createStructuredCodexSessionLaunchIntent('workspace-retry')
vi.mocked(callStructuredAgentSession).mockRejectedValue(new Error('response lost'))
await expect(launchStructuredCodexSession(intent)).rejects.toThrow('response lost')
await expect(launchStructuredCodexSession(intent)).rejects.toThrow('response lost')
const first = vi.mocked(callStructuredAgentSession).mock.calls[0]?.[2]
const second = vi.mocked(callStructuredAgentSession).mock.calls[1]?.[2]
expect(first).toBe(intent.params)
expect(second).toBe(first)
expect(intent.params.envelope.clientOperationId).toMatch(/^\d{13}-[0-9a-f]{32}$/)
})
})
@@ -1,13 +1,13 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
startStructuredCodexLaunch: vi.fn(),
startStructuredAgentLaunch: vi.fn(),
activateAndRevealWorktree: vi.fn(),
preflightAgentTrust: vi.fn()
}))
vi.mock('@/lib/structured-agent-session-launch', () => ({
startStructuredCodexLaunch: mocks.startStructuredCodexLaunch
startStructuredAgentLaunch: mocks.startStructuredAgentLaunch
}))
vi.mock('@/lib/worktree-activation', () => ({
@@ -18,7 +18,7 @@ vi.mock('@/lib/agent-trust-preflight', () => ({
preflightAgentTrust: mocks.preflightAgentTrust
}))
vi.mock('@/lib/launch-structured-codex-session', () => ({
vi.mock('@/lib/launch-structured-agent-session', () => ({
StructuredAgentSessionCreateRefusalError: class extends Error {}
}))
@@ -26,7 +26,7 @@ vi.mock('@/lib/native-chat-transcript-readability', () => ({
isNativeChatTranscriptLocalReadable: vi.fn(() => true)
}))
import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-codex-session'
import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session'
import { settleDirectWorkItemStructuredLaunch } from './launch-work-item-direct-agent-routing'
const baseArgs = {
@@ -47,7 +47,7 @@ describe('settleDirectWorkItemStructuredLaunch', () => {
it('runs the legacy terminal fallback after a definitive refusal', async () => {
mocks.activateAndRevealWorktree.mockReturnValue({ primaryTabId: 'fallback-tab' })
mocks.startStructuredCodexLaunch.mockReturnValue({
mocks.startStructuredAgentLaunch.mockReturnValue({
launchResult: Promise.reject(new StructuredAgentSessionCreateRefusalError('unsupported')),
isVisibilityUnknown: () => false,
claimDefinitiveRefusalFallback: (fallback: () => Promise<unknown>) =>
@@ -65,7 +65,7 @@ describe('settleDirectWorkItemStructuredLaunch', () => {
})
it('reports an unknown outcome without starting a fallback terminal', async () => {
mocks.startStructuredCodexLaunch.mockReturnValue({
mocks.startStructuredAgentLaunch.mockReturnValue({
launchResult: Promise.reject(new Error('connection lost')),
isVisibilityUnknown: () => true,
claimDefinitiveRefusalFallback: vi.fn(() => Promise.resolve(false))
@@ -1,3 +1,4 @@
import { isAgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle'
import type { TuiAgent } from '../../../shared/tui-agent'
import type { AgentStartupPlan } from '@/lib/tui-agent-startup'
import type { LaunchSource } from '../../../shared/telemetry-events'
@@ -9,8 +10,8 @@ import {
buildDirectWorkItemAgentStartupPlan,
buildDirectWorkItemStartupOpts
} from '@/lib/launch-work-item-direct-agent'
import { startStructuredCodexLaunch } from '@/lib/structured-agent-session-launch'
import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-codex-session'
import { startStructuredAgentLaunch } from '@/lib/structured-agent-session-launch'
import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session'
import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability'
import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform'
import { preflightAgentTrust } from '@/lib/agent-trust-preflight'
@@ -127,11 +128,11 @@ export async function settleDirectWorkItemStructuredLaunch(args: {
primaryTabId: string | null
}> {
let { structuredLaunch, primaryTabId } = args
if (!structuredLaunch || args.agent !== 'codex') {
if (!structuredLaunch || !isAgentSessionHandleProvider(args.agent)) {
return { completed: false, structuredLaunch, visibilityUnknown: false, primaryTabId }
}
const launch = startStructuredCodexLaunch(args.worktreeId, {
const launch = startStructuredAgentLaunch(args.worktreeId, args.agent, {
prompt: args.draftContent,
...(args.promptDelivery === 'submit-after-ready' ? { promptDelivery: args.promptDelivery } : {})
})
@@ -1,6 +1,6 @@
import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-codex-session'
import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session'
import {
settleStructuredCodexLaunchPrompt,
settleStructuredAgentLaunchPrompt,
type StructuredPromptDeliveryResult
} from '@/lib/structured-agent-session-launch-prompt'
import type { StructuredAgentSessionOutboxEntry } from '../../../shared/structured-agent-session-outbox'
@@ -10,7 +10,7 @@ export type StructuredRefusalFallback = () =>
| StructuredPromptDeliveryResult
| Promise<void | StructuredPromptDeliveryResult>
export type StructuredCodexLaunchOptions = {
export type StructuredAgentLaunchOptions = {
prompt?: string
promptDelivery?: 'auto-submit' | 'submit-after-ready'
onPromptDelivered?: () => void
@@ -137,7 +137,7 @@ function trackPromptDelivery(
export function addStructuredLaunchCaller(args: {
group: StructuredLaunchCallerGroup
launchResult: Promise<{ sessionId: string; fence: number }>
options: StructuredCodexLaunchOptions
options: StructuredAgentLaunchOptions
stagedEntry: StructuredAgentSessionOutboxEntry | null
}): StructuredLaunchCaller {
const fallback = Promise.withResolvers<boolean>()
@@ -156,7 +156,7 @@ export function addStructuredLaunchCaller(args: {
}
}
args.group.entries.add(caller)
const promptDeliveryResult = settleStructuredCodexLaunchPrompt({
const promptDeliveryResult = settleStructuredAgentLaunchPrompt({
launchResult: args.launchResult,
options: args.options,
stagedEntry: args.stagedEntry
@@ -78,7 +78,7 @@ async function dispatchStructuredLaunchPrompt(
}
}
export function settleStructuredCodexLaunchPrompt(args: {
export function settleStructuredAgentLaunchPrompt(args: {
launchResult: Promise<LaunchReceipt>
options: StructuredLaunchPromptOptions
stagedEntry: StructuredAgentSessionOutboxEntry | null
@@ -1,18 +1,18 @@
import type { AgentSessionHistoryResult } from '../../../shared/agent-session-wire'
import {
launchStructuredCodexSession,
launchStructuredAgentSession,
StructuredAgentSessionCreateRefusalError,
type StructuredAgentSessionLaunchIntent
} from '@/lib/launch-structured-codex-session'
} from '@/lib/launch-structured-agent-session'
import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client'
import { refreshLocalStructuredSessionTabs } from '@/runtime/local-structured-session-tabs-sync'
import { useAppStore } from '@/store'
export type StructuredCodexLaunchReceipt = { sessionId: string; fence: number }
export type StructuredAgentLaunchReceipt = { sessionId: string; fence: number }
export type StructuredLaunchRecoveryState = {
intent: StructuredAgentSessionLaunchIntent
promise: Promise<StructuredCodexLaunchReceipt>
promise: Promise<StructuredAgentLaunchReceipt>
visibilityUnknown: boolean
cancelled: boolean
onVisibilityChanged?: () => void
@@ -64,7 +64,7 @@ function hasAdoptedStructuredSession(intent: StructuredAgentSessionLaunchIntent)
async function recoverPublishedSessionReceipt(
state: StructuredLaunchRecoveryState
): Promise<StructuredCodexLaunchReceipt> {
): Promise<StructuredAgentLaunchReceipt> {
await verifyPublishedSession(state)
const history = await callStructuredAgentSession<AgentSessionHistoryResult>(
{ kind: 'local' },
@@ -82,10 +82,10 @@ async function recoverPublishedSessionReceipt(
async function retrySameIntent(
state: StructuredLaunchRecoveryState,
priorError: unknown
): Promise<StructuredCodexLaunchReceipt> {
): Promise<StructuredAgentLaunchReceipt> {
throwIfLaunchCancelled(state)
try {
const receipt = await launchStructuredCodexSession(state.intent)
const receipt = await launchStructuredAgentSession(state.intent)
throwIfLaunchCancelled(state)
await verifyPublishedSession(state)
return receipt
@@ -111,11 +111,11 @@ async function retrySameIntent(
export async function launchAndReconcile(
state: StructuredLaunchRecoveryState
): Promise<StructuredCodexLaunchReceipt> {
): Promise<StructuredAgentLaunchReceipt> {
throwIfLaunchCancelled(state)
let receipt: StructuredCodexLaunchReceipt
let receipt: StructuredAgentLaunchReceipt
try {
receipt = await launchStructuredCodexSession(state.intent)
receipt = await launchStructuredAgentSession(state.intent)
} catch (error) {
if (state.cancelled) {
throw new StructuredAgentSessionLaunchCancelledError()
@@ -143,7 +143,7 @@ export async function launchAndReconcile(
export async function reconcileUnknownLaunch(
state: StructuredLaunchRecoveryState
): Promise<StructuredCodexLaunchReceipt> {
): Promise<StructuredAgentLaunchReceipt> {
throwIfLaunchCancelled(state)
state.visibilityUnknown = false
state.onVisibilityChanged?.()
@@ -20,12 +20,12 @@ vi.mock('sonner', () => ({
}
}))
vi.mock('@/lib/launch-structured-codex-session', () => {
vi.mock('@/lib/launch-structured-agent-session', () => {
class StructuredAgentSessionCreateRefusalError extends Error {}
return {
createStructuredCodexSessionLaunchIntent: mocks.createIntent,
createStructuredAgentSessionLaunchIntent: mocks.createIntent,
abandonStructuredAgentSessionLaunchIntent: mocks.abandonIntent,
launchStructuredCodexSession: mocks.launch,
launchStructuredAgentSession: mocks.launch,
StructuredAgentSessionCreateRefusalError
}
})
@@ -51,17 +51,25 @@ vi.mock('@/store', () => ({
}))
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string) => fallback
translate: (_key: string, fallback: string, options?: { value0?: string }) =>
fallback.replace('{{value0}}', options?.value0 ?? '')
}))
vi.mock('@/lib/agent-catalog', () => ({
getAgentCatalog: () => [
{ id: 'claude', label: 'Claude' },
{ id: 'codex', label: 'Codex' }
]
}))
import {
StructuredAgentSessionCreateRefusalError,
type StructuredAgentSessionLaunchIntent
} from '@/lib/launch-structured-codex-session'
} from '@/lib/launch-structured-agent-session'
import { refreshLocalStructuredSessionTabs } from '@/runtime/local-structured-session-tabs-sync'
import {
cancelStructuredCodexLaunch,
startStructuredCodexLaunch
cancelStructuredAgentLaunch,
startStructuredAgentLaunch
} from './structured-agent-session-launch'
import { readOutbox } from '@/components/native-chat/structured-agent-session-outbox-storage'
@@ -72,6 +80,7 @@ function launchIntent(
return {
worktreeId,
sessionId,
agent: 'codex',
params: {
envelope: {
sessionId,
@@ -112,13 +121,16 @@ async function flushLaunchSettlement(): Promise<void> {
}
}
describe('startStructuredCodexLaunch', () => {
describe('startStructuredAgentLaunch', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
mocks.rendererTabs = {}
mocks.listeners.clear()
mocks.createIntent.mockImplementation((worktreeId: string) => launchIntent(worktreeId))
mocks.createIntent.mockImplementation((worktreeId: string, agent: 'claude' | 'codex') => {
const intent = launchIntent(worktreeId, `${agent}-session-${worktreeId}`)
return { ...intent, agent, params: { ...intent.params, agent } }
})
mocks.callStructuredAgentSession.mockResolvedValue({
ok: true,
page: { fence: 1 }
@@ -138,7 +150,7 @@ describe('startStructuredCodexLaunch', () => {
value: { submission: { dispatchState: 'accepted' } }
})
startStructuredCodexLaunch(worktreeId)
startStructuredAgentLaunch(worktreeId, 'codex')
await flushLaunchSettlement()
expect(mocks.launch).toHaveBeenCalledOnce()
@@ -147,6 +159,42 @@ describe('startStructuredCodexLaunch', () => {
expect(toast.error).not.toHaveBeenCalled()
})
it('keeps a Claude and a Codex launch in the same worktree apart', async () => {
const worktreeId = 'wt-two-agents'
mocks.launch.mockImplementation(async (intent: StructuredAgentSessionLaunchIntent) => {
mocks.rendererTabs[worktreeId] = [
...(mocks.rendererTabs[worktreeId] ?? []),
{ contentType: 'agent-session', entityId: intent.sessionId, worktreeId }
]
return { sessionId: intent.sessionId, fence: 1 }
})
const claude = startStructuredAgentLaunch(worktreeId, 'claude')
const codex = startStructuredAgentLaunch(worktreeId, 'codex')
await flushLaunchSettlement()
expect(mocks.createIntent).toHaveBeenNthCalledWith(1, worktreeId, 'claude')
expect(mocks.createIntent).toHaveBeenNthCalledWith(2, worktreeId, 'codex')
expect(mocks.launch).toHaveBeenCalledTimes(2)
expect(vi.mocked(mocks.launch).mock.calls.map(([intent]) => intent.params.agent)).toEqual([
'claude',
'codex'
])
expect(claude.sessionId).not.toBe(codex.sessionId)
expect(toast.error).not.toHaveBeenCalled()
})
it('names the refused agent in the launch failure toast', async () => {
const worktreeId = 'wt-claude-toast'
mocks.launch.mockRejectedValue(new Error('boom'))
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([])
startStructuredAgentLaunch(worktreeId, 'claude')
await flushLaunchSettlement()
expect(toast.error).toHaveBeenCalledWith('Could not open Claude chat', expect.anything())
})
it('completes from the host-emitted projection without listing inventory', async () => {
const worktreeId = 'wt-host-frame'
const intent = launchIntent(worktreeId, 'session-host-frame')
@@ -161,7 +209,7 @@ describe('startStructuredCodexLaunch', () => {
return { sessionId: intent.sessionId, fence: 1 }
})
startStructuredCodexLaunch(worktreeId)
startStructuredAgentLaunch(worktreeId, 'codex')
await flushLaunchSettlement()
expect(mocks.launch).toHaveBeenCalledOnce()
@@ -186,8 +234,8 @@ describe('startStructuredCodexLaunch', () => {
value: { submission: { dispatchState: 'accepted' } }
})
startStructuredCodexLaunch(worktreeId)
startStructuredCodexLaunch(worktreeId)
startStructuredAgentLaunch(worktreeId, 'codex')
startStructuredAgentLaunch(worktreeId, 'codex')
expect(mocks.createIntent).toHaveBeenCalledOnce()
expect(mocks.launch).toHaveBeenCalledOnce()
@@ -213,8 +261,8 @@ describe('startStructuredCodexLaunch', () => {
ok: true,
value: { submission: { dispatchState: 'accepted' } }
})
startStructuredCodexLaunch(worktreeId)
const second = startStructuredCodexLaunch(worktreeId, { prompt: 'second prompt' })
startStructuredAgentLaunch(worktreeId, 'codex')
const second = startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'second prompt' })
resolveLaunch({ sessionId: intent.sessionId, fence: 1 })
await expect(second.promptDeliveryResult).resolves.toEqual({
@@ -251,12 +299,12 @@ describe('startStructuredCodexLaunch', () => {
() => new Promise((resolve) => (resolveDelivery = resolve))
)
startStructuredCodexLaunch(worktreeId)
const coalesced = startStructuredCodexLaunch(worktreeId, { prompt: 'second prompt' })
startStructuredAgentLaunch(worktreeId, 'codex')
const coalesced = startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'second prompt' })
resolveLaunch({ sessionId: intent.sessionId, fence: 1 })
await vi.waitFor(() => expect(mocks.callStructuredAgentSession).toHaveBeenCalledOnce())
startStructuredCodexLaunch(worktreeId)
startStructuredAgentLaunch(worktreeId, 'codex')
expect(mocks.createIntent).toHaveBeenCalledOnce()
expect(mocks.launch).toHaveBeenCalledOnce()
@@ -274,9 +322,9 @@ describe('startStructuredCodexLaunch', () => {
mocks.launch.mockRejectedValue(new Error('offline'))
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([])
startStructuredCodexLaunch(worktreeId, { prompt: 'first prompt' })
startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'first prompt' })
await flushLaunchSettlement()
startStructuredCodexLaunch(worktreeId, { prompt: 'second prompt' })
startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'second prompt' })
await flushLaunchSettlement()
expect(mocks.createIntent).toHaveBeenCalledOnce()
@@ -291,7 +339,7 @@ describe('startStructuredCodexLaunch', () => {
publishedSnapshot(worktreeId, intent.sessionId)
])
startStructuredCodexLaunch(worktreeId)
startStructuredAgentLaunch(worktreeId, 'codex')
await flushLaunchSettlement()
expect(mocks.createIntent).toHaveBeenCalledOnce()
@@ -310,7 +358,7 @@ describe('startStructuredCodexLaunch', () => {
.mockResolvedValueOnce([])
.mockResolvedValueOnce([publishedSnapshot(worktreeId, intent.sessionId)])
startStructuredCodexLaunch(worktreeId)
startStructuredAgentLaunch(worktreeId, 'codex')
await flushLaunchSettlement()
expect(mocks.launch).toHaveBeenCalledTimes(2)
@@ -327,14 +375,14 @@ describe('startStructuredCodexLaunch', () => {
mocks.launch.mockRejectedValue(new Error('offline'))
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([])
startStructuredCodexLaunch(worktreeId)
startStructuredAgentLaunch(worktreeId, 'codex')
await flushLaunchSettlement()
expect(toast.error).toHaveBeenCalledOnce()
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([
publishedSnapshot(worktreeId, intent.sessionId)
])
startStructuredCodexLaunch(worktreeId)
startStructuredAgentLaunch(worktreeId, 'codex')
await flushLaunchSettlement()
expect(mocks.createIntent).toHaveBeenCalledOnce()
@@ -357,7 +405,7 @@ describe('startStructuredCodexLaunch', () => {
})
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValueOnce([]).mockResolvedValueOnce([])
startStructuredCodexLaunch(worktreeId)
startStructuredAgentLaunch(worktreeId, 'codex')
await flushLaunchSettlement()
expect(mocks.createIntent).toHaveBeenCalledOnce()
@@ -374,7 +422,7 @@ describe('startStructuredCodexLaunch', () => {
mocks.launch.mockRejectedValue(new Error('offline'))
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([])
const first = startStructuredCodexLaunch(worktreeId, { prompt: 'only once' })
const first = startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'only once' })
const firstFallbackResult = first.claimDefinitiveRefusalFallback(firstFallback)
await expect(first.launchResult).rejects.toThrow('offline')
expect(first.releaseCallerAfterUnknownOutcome()).toBe(true)
@@ -382,7 +430,7 @@ describe('startStructuredCodexLaunch', () => {
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([
publishedSnapshot(worktreeId, intent.sessionId)
])
const retry = startStructuredCodexLaunch(worktreeId)
const retry = startStructuredAgentLaunch(worktreeId, 'codex')
await expect(retry.launchResult).resolves.toEqual({ sessionId: intent.sessionId, fence: 1 })
await expect(firstFallbackResult).resolves.toBe(false)
@@ -408,7 +456,7 @@ describe('startStructuredCodexLaunch', () => {
mocks.launch.mockRejectedValue(new Error('offline'))
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([])
const first = startStructuredCodexLaunch(worktreeId)
const first = startStructuredAgentLaunch(worktreeId, 'codex')
const firstFallbackResult = first.claimDefinitiveRefusalFallback(firstFallback)
await expect(first.launchResult).rejects.toThrow('offline')
expect(first.releaseCallerAfterUnknownOutcome()).toBe(true)
@@ -416,7 +464,7 @@ describe('startStructuredCodexLaunch', () => {
mocks.launch.mockRejectedValueOnce(
new StructuredAgentSessionCreateRefusalError('structured launch disabled')
)
const retry = startStructuredCodexLaunch(worktreeId)
const retry = startStructuredAgentLaunch(worktreeId, 'codex')
const retryFallbackResult = retry.claimDefinitiveRefusalFallback(retryFallback)
await expect(retry.launchResult).rejects.toBeInstanceOf(
@@ -440,9 +488,9 @@ describe('startStructuredCodexLaunch', () => {
publishedSnapshot(worktreeId, second.sessionId)
])
startStructuredCodexLaunch(worktreeId)
startStructuredAgentLaunch(worktreeId, 'codex')
await flushLaunchSettlement()
startStructuredCodexLaunch(worktreeId)
startStructuredAgentLaunch(worktreeId, 'codex')
await flushLaunchSettlement()
expect(mocks.createIntent).toHaveBeenCalledTimes(2)
@@ -460,7 +508,7 @@ describe('startStructuredCodexLaunch', () => {
throw new Error('storage unavailable')
})
const result = startStructuredCodexLaunch(worktreeId, { prompt: 'start this task' })
const result = startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'start this task' })
const fallbackResult = result.claimDefinitiveRefusalFallback(fallback)
await expect(result.launchResult).rejects.toBeInstanceOf(
@@ -481,8 +529,8 @@ describe('startStructuredCodexLaunch', () => {
() => new Promise((_resolve, reject) => (rejectLaunch = reject))
)
const first = startStructuredCodexLaunch(worktreeId, { prompt: 'first prompt' })
const second = startStructuredCodexLaunch(worktreeId, { prompt: 'second prompt' })
const first = startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'first prompt' })
const second = startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'second prompt' })
const firstFallback = vi.fn().mockResolvedValue({
delivered: true,
failureNotified: false
@@ -525,9 +573,9 @@ describe('startStructuredCodexLaunch', () => {
() => new Promise((resolve) => (resolveRefresh = resolve))
)
startStructuredCodexLaunch(worktreeId)
startStructuredAgentLaunch(worktreeId, 'codex')
await vi.waitFor(() => expect(refreshLocalStructuredSessionTabs).toHaveBeenCalledOnce())
expect(cancelStructuredCodexLaunch(worktreeId, intent.sessionId)).toBe(true)
expect(cancelStructuredAgentLaunch(worktreeId, intent.sessionId)).toBe(true)
resolveRefresh([])
await flushLaunchSettlement()
@@ -546,12 +594,12 @@ describe('startStructuredCodexLaunch', () => {
() => new Promise((resolve) => (resolveRefresh = resolve))
)
startStructuredCodexLaunch(worktreeId, { prompt: 'first prompt' })
startStructuredCodexLaunch(worktreeId, { prompt: 'second prompt' })
startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'first prompt' })
startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'second prompt' })
await vi.waitFor(() => expect(refreshLocalStructuredSessionTabs).toHaveBeenCalledOnce())
expect(readOutbox(intent.sessionId)).toHaveLength(2)
expect(cancelStructuredCodexLaunch(worktreeId, intent.sessionId)).toBe(true)
expect(cancelStructuredAgentLaunch(worktreeId, intent.sessionId)).toBe(true)
expect(readOutbox(intent.sessionId)).toEqual([])
resolveRefresh([])
await flushLaunchSettlement()
@@ -569,9 +617,9 @@ describe('startStructuredCodexLaunch', () => {
.mockResolvedValueOnce([])
.mockImplementationOnce(() => new Promise((resolve) => (resolveRetryRefresh = resolve)))
startStructuredCodexLaunch(worktreeId)
startStructuredAgentLaunch(worktreeId, 'codex')
await vi.waitFor(() => expect(refreshLocalStructuredSessionTabs).toHaveBeenCalledTimes(2))
expect(cancelStructuredCodexLaunch(worktreeId, intent.sessionId)).toBe(true)
expect(cancelStructuredAgentLaunch(worktreeId, intent.sessionId)).toBe(true)
resolveRetryRefresh([])
await flushLaunchSettlement()
@@ -1,11 +1,13 @@
import { useSyncExternalStore } from 'react'
import { toast } from 'sonner'
import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle'
import { getAgentCatalog } from '@/lib/agent-catalog'
import { translate } from '@/i18n/i18n'
import {
abandonStructuredAgentSessionLaunchIntent,
createStructuredCodexSessionLaunchIntent,
createStructuredAgentSessionLaunchIntent,
StructuredAgentSessionCreateRefusalError
} from '@/lib/launch-structured-codex-session'
} from '@/lib/launch-structured-agent-session'
import {
discardStructuredAgentSessionLaunchOutbox,
enqueueStructuredAgentSessionLaunchPrompt
@@ -14,7 +16,7 @@ import {
launchAndReconcile,
reconcileUnknownLaunch,
StructuredAgentSessionLaunchCancelledError,
type StructuredCodexLaunchReceipt,
type StructuredAgentLaunchReceipt,
type StructuredLaunchRecoveryState
} from '@/lib/structured-agent-session-launch-recovery'
import type { StructuredPromptDeliveryResult } from '@/lib/structured-agent-session-launch-prompt'
@@ -26,13 +28,13 @@ import {
settleStructuredLaunchCallersWithFallback,
settleStructuredLaunchCallersWithoutFallback,
structuredLaunchCallersHavePendingWork,
type StructuredCodexLaunchOptions,
type StructuredAgentLaunchOptions,
type StructuredLaunchCaller,
type StructuredLaunchCallerGroup,
type StructuredRefusalFallback
} from '@/lib/structured-agent-session-launch-callers'
export type { StructuredCodexLaunchOptions, StructuredCodexLaunchReceipt }
export type { StructuredAgentLaunchOptions, StructuredAgentLaunchReceipt }
type StructuredLaunchState = StructuredLaunchRecoveryState & {
identity: string
@@ -44,16 +46,20 @@ type StructuredLaunchStateResult = {
caller: StructuredLaunchCaller
}
export type StructuredCodexLaunchResult = {
export type StructuredAgentLaunchResult = {
sessionId: string
launchResult: Promise<StructuredCodexLaunchReceipt>
launchResult: Promise<StructuredAgentLaunchReceipt>
promptDeliveryResult?: Promise<StructuredPromptDeliveryResult>
isVisibilityUnknown: () => boolean
releaseCallerAfterUnknownOutcome: () => boolean
claimDefinitiveRefusalFallback: (fallback: StructuredRefusalFallback) => Promise<boolean>
}
export type StructuredCodexLaunchStatus = 'idle' | 'pending' | 'unknown'
export type StructuredAgentLaunchStatus = 'idle' | 'pending' | 'unknown'
function structuredAgentLabel(agent: AgentSessionHandleProvider): string {
return getAgentCatalog().find((entry) => entry.id === agent)?.label ?? agent
}
const pendingStructuredLaunchesByIdentity = new Map<string, StructuredLaunchState>()
const structuredLaunchListeners = new Set<() => void>()
@@ -64,29 +70,37 @@ function notifyStructuredLaunchListeners(): void {
}
}
export function subscribeStructuredCodexLaunchStatus(listener: () => void): () => void {
export function subscribeStructuredAgentLaunchStatus(listener: () => void): () => void {
structuredLaunchListeners.add(listener)
return () => structuredLaunchListeners.delete(listener)
}
export function getStructuredCodexLaunchStatus(worktreeId: string): StructuredCodexLaunchStatus {
const state = pendingStructuredLaunchesByIdentity.get(worktreeId)
export function getStructuredAgentLaunchStatus(
worktreeId: string,
agent: AgentSessionHandleProvider
): StructuredAgentLaunchStatus {
const state = pendingStructuredLaunchesByIdentity.get(launchIdentity(worktreeId, agent))
if (!state) {
return 'idle'
}
return state.visibilityUnknown ? 'unknown' : 'pending'
}
export function useStructuredCodexLaunchStatus(worktreeId: string): StructuredCodexLaunchStatus {
export function useStructuredAgentLaunchStatus(
worktreeId: string,
agent: AgentSessionHandleProvider
): StructuredAgentLaunchStatus {
return useSyncExternalStore(
subscribeStructuredCodexLaunchStatus,
() => getStructuredCodexLaunchStatus(worktreeId),
subscribeStructuredAgentLaunchStatus,
() => getStructuredAgentLaunchStatus(worktreeId, agent),
() => 'idle'
)
}
function launchIdentity(worktreeId: string): string {
return worktreeId
// Why keyed by agent too: one worktree can hold a Claude and a Codex launch at once, and a shared
// key would hand the second caller the first agent's intent.
function launchIdentity(worktreeId: string, agent: AgentSessionHandleProvider): string {
return `${agent}:${worktreeId}`
}
function cleanupLaunchState(state: StructuredLaunchState): void {
@@ -114,7 +128,7 @@ function settleDefinitiveRefusalFallback(state: StructuredLaunchState): void {
function trackLaunchSettlement(
state: StructuredLaunchState,
promise: Promise<StructuredCodexLaunchReceipt>
promise: Promise<StructuredAgentLaunchReceipt>
): void {
void promise.then(
() => {
@@ -155,18 +169,20 @@ function trackLaunchFailureToast(state: StructuredLaunchState): void {
toast.error(
translate(
'components.native-chat.structuredSessionLaunchFailed',
'Could not open Codex chat'
'Could not open {{value0}} chat',
{ value0: structuredAgentLabel(state.intent.agent) }
),
{ description: error instanceof Error ? error.message : String(error) }
)
})
}
function structuredCodexLaunchState(
function structuredAgentLaunchState(
worktreeId: string,
options: StructuredCodexLaunchOptions
agent: AgentSessionHandleProvider,
options: StructuredAgentLaunchOptions
): StructuredLaunchStateResult {
const identity = launchIdentity(worktreeId)
const identity = launchIdentity(worktreeId, agent)
const existing = pendingStructuredLaunchesByIdentity.get(identity)
if (existing) {
if (existing.visibilityUnknown) {
@@ -192,7 +208,7 @@ function structuredCodexLaunchState(
}
}
const intent = createStructuredCodexSessionLaunchIntent(worktreeId)
const intent = createStructuredAgentSessionLaunchIntent(worktreeId, agent)
const text = options.prompt?.trim() ?? ''
const stagedPrompt = text
? enqueueStructuredAgentSessionLaunchPrompt(intent.sessionId, text)
@@ -212,7 +228,7 @@ function structuredCodexLaunchState(
text && !stagedPrompt
? Promise.reject(
new StructuredAgentSessionCreateRefusalError(
'Could not durably stage the Codex launch prompt.'
`Could not durably stage the ${structuredAgentLabel(agent)} launch prompt.`
)
)
: launchAndReconcile(state)
@@ -232,7 +248,7 @@ function structuredCodexLaunchState(
}
}
export function cancelStructuredCodexLaunch(worktreeId: string, sessionId: string): boolean {
export function cancelStructuredAgentLaunch(worktreeId: string, sessionId: string): boolean {
const state = [...pendingStructuredLaunchesByIdentity.values()].find(
(candidate) =>
candidate.intent.worktreeId === worktreeId && candidate.intent.sessionId === sessionId
@@ -249,11 +265,12 @@ export function cancelStructuredCodexLaunch(worktreeId: string, sessionId: strin
return true
}
export function startStructuredCodexLaunch(
export function startStructuredAgentLaunch(
worktreeId: string,
options: StructuredCodexLaunchOptions = {}
): StructuredCodexLaunchResult {
const { state, caller } = structuredCodexLaunchState(worktreeId, options)
agent: AgentSessionHandleProvider,
options: StructuredAgentLaunchOptions = {}
): StructuredAgentLaunchResult {
const { state, caller } = structuredAgentLaunchState(worktreeId, agent, options)
return {
sessionId: state.intent.sessionId,
launchResult: state.promise,
@@ -13,6 +13,7 @@ import {
formatWorkspaceCreateError,
getWorkspaceCreateErrorToastMessage
} from '@/lib/workspace-create-error-format'
import { isAgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle'
import type { CreateWorktreeResult } from '../../../shared/worktree/create-types'
import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation'
import { createBrowserUuid } from '@/lib/browser-uuid'
@@ -208,7 +209,7 @@ export async function executeWorktreeCreation(
}
let structuredLaunchAccepted = structuredLaunch
if (structuredLaunch && preparedRequest.agent === 'codex') {
if (structuredLaunch && isAgentSessionHandleProvider(preparedRequest.agent)) {
const structuredSession = await launchStructuredWorktreeSession({
creationId,
request: preparedRequest,
@@ -6,8 +6,8 @@ const mocks = vi.hoisted(() => ({
},
listener: null as ((state: { pendingWorktreeCreations: Record<string, unknown> }) => void) | null,
unsubscribe: vi.fn(),
startStructuredCodexLaunch: vi.fn(),
cancelStructuredCodexLaunch: vi.fn(),
startStructuredAgentLaunch: vi.fn(),
cancelStructuredAgentLaunch: vi.fn(),
closeStructuredAgentSession: vi.fn(),
callRuntimeRpc: vi.fn(),
activateStructuredAgentSessionById: vi.fn()
@@ -26,8 +26,8 @@ vi.mock('@/store', () => ({
}))
vi.mock('@/lib/structured-agent-session-launch', () => ({
startStructuredCodexLaunch: mocks.startStructuredCodexLaunch,
cancelStructuredCodexLaunch: mocks.cancelStructuredCodexLaunch
startStructuredAgentLaunch: mocks.startStructuredAgentLaunch,
cancelStructuredAgentLaunch: mocks.cancelStructuredAgentLaunch
}))
vi.mock('@/runtime/structured-agent-session-close', () => ({
@@ -58,7 +58,7 @@ vi.mock('@/lib/agent-trust-preflight', () => ({
preflightAgentTrust: vi.fn()
}))
vi.mock('@/lib/launch-structured-codex-session', () => ({
vi.mock('@/lib/launch-structured-agent-session', () => ({
StructuredAgentSessionCreateRefusalError: class extends Error {}
}))
@@ -78,7 +78,7 @@ describe('launchStructuredWorktreeSession', () => {
const launchResult = new Promise<{ sessionId: string; fence: number }>((resolve) => {
resolveLaunch = resolve
})
mocks.startStructuredCodexLaunch.mockReturnValue({
mocks.startStructuredAgentLaunch.mockReturnValue({
sessionId: 'session-1',
launchResult,
isVisibilityUnknown: () => false,
@@ -117,7 +117,7 @@ describe('launchStructuredWorktreeSession', () => {
activation: false,
primaryTabId: null
})
expect(mocks.cancelStructuredCodexLaunch).toHaveBeenCalledWith('worktree-1', 'session-1')
expect(mocks.cancelStructuredAgentLaunch).toHaveBeenCalledWith('worktree-1', 'session-1')
expect(mocks.closeStructuredAgentSession).toHaveBeenCalledWith({ kind: 'local' }, 'session-1')
expect(mocks.callRuntimeRpc).toHaveBeenCalledWith({ kind: 'local' }, 'session.tabs.close', {
worktree: { id: 'worktree-1' },
@@ -130,7 +130,7 @@ describe('launchStructuredWorktreeSession', () => {
it('reports an unknown launch without claiming a visible surface', async () => {
const releaseCallerAfterUnknownOutcome = vi.fn()
mocks.startStructuredCodexLaunch.mockReturnValue({
mocks.startStructuredAgentLaunch.mockReturnValue({
sessionId: 'session-unknown',
launchResult: Promise.reject(new Error('connection lost')),
isVisibilityUnknown: () => true,
@@ -2,10 +2,11 @@ import { useAppStore } from '@/store'
import { ensureWorktreeHasInitialTerminal } from '@/lib/worktree-initial-terminal-seeding'
import { activateAndRevealWorktree, type ActivateAndRevealResult } from '@/lib/worktree-activation'
import {
cancelStructuredCodexLaunch,
startStructuredCodexLaunch
cancelStructuredAgentLaunch,
startStructuredAgentLaunch
} from '@/lib/structured-agent-session-launch'
import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-codex-session'
import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session'
import { isAgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle'
import { activateStructuredAgentSessionById } from '@/lib/structured-agent-session-tab-activation'
import { preflightAgentTrust } from '@/lib/agent-trust-preflight'
import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation'
@@ -48,15 +49,17 @@ export async function launchStructuredWorktreeSession(args: {
let { activation, primaryTabId } = args
let accepted = true
let visibilityUnknown = false
if (args.request.agent !== 'codex') {
const agent = args.request.agent
if (!isAgentSessionHandleProvider(agent)) {
return { accepted, cancelled: false, visibilityUnknown, activation, primaryTabId }
}
if (!useAppStore.getState().pendingWorktreeCreations[args.creationId]) {
return { accepted, cancelled: true, visibilityUnknown, activation, primaryTabId }
}
const launch = startStructuredCodexLaunch(
const launch = startStructuredAgentLaunch(
args.worktreeId,
agent,
args.recoverUnknownLaunch
? {}
: { prompt: args.request.launchDraftPrompt ?? args.request.quickPrompt }
@@ -67,7 +70,7 @@ export async function launchStructuredWorktreeSession(args: {
return
}
cancelled = true
cancelStructuredCodexLaunch(args.worktreeId, launch.sessionId)
cancelStructuredAgentLaunch(args.worktreeId, launch.sessionId)
}
const unsubscribe = useAppStore.subscribe((state) => {
if (!state.pendingWorktreeCreations[args.creationId]) {
@@ -3,6 +3,7 @@ import { toast } from 'sonner'
import type { AppState } from '../types'
import type { Repo } from '../../../../shared/repo-types'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle'
import { getRepoHostIdentity } from '../slices/repo-host-identity'
import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client'
import { resolveDismissedOnboardingFolderAgentLaunch } from '@/lib/onboarding-folder-agent-startup'
@@ -202,13 +203,16 @@ export function createRepoAddActions(
...(launch.startup ? { startup: launch.startup } : {}),
...(launch.route === 'structured-native-chat' ? { providesInitialSurface: true } : {})
})
if (launch.route === 'structured-native-chat' && launch.agent === 'codex') {
const [{ startStructuredCodexLaunch }, { StructuredAgentSessionCreateRefusalError }] =
if (
launch.route === 'structured-native-chat' &&
isAgentSessionHandleProvider(launch.agent)
) {
const [{ startStructuredAgentLaunch }, { StructuredAgentSessionCreateRefusalError }] =
await Promise.all([
import('@/lib/structured-agent-session-launch'),
import('@/lib/launch-structured-codex-session')
import('@/lib/launch-structured-agent-session')
])
const structured = startStructuredCodexLaunch(folderWorktree.id)
const structured = startStructuredAgentLaunch(folderWorktree.id, launch.agent)
const fallback = structured.claimDefinitiveRefusalFallback(() => {
activateAndRevealWorktree(folderWorktree.id, {
sidebarRevealBehavior: 'auto',