mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
Merge branch 'brennanb2025/claude-sdk-enable-platforms' into brennanb2025/claude-sdk-ready-mainsync
This commit is contained in:
@@ -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)
|
||||
|
||||
+4
-4
@@ -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()
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}$/)
|
||||
})
|
||||
})
|
||||
+39
-6
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user