diff --git a/src/main/codex/codex-tui-rollout-proof.test.ts b/src/main/codex/codex-tui-rollout-proof.test.ts index 957e835a655..aa0588e7e7a 100644 --- a/src/main/codex/codex-tui-rollout-proof.test.ts +++ b/src/main/codex/codex-tui-rollout-proof.test.ts @@ -21,6 +21,17 @@ describe('Codex TUI rollout proof', () => { expect(parseCodexTuiStatusSessionId('Session: not-a-session')).toBeNull() }) + it('parses the Codex 0.148 status screen and semantic thread labels', () => { + const status = [ + '\u001b[2m│ >_ OpenAI Codex (v0.148.0) │', + '│ Model: gpt-5.6-sol (reasoning high, summaries auto) │', + `│ Session: \u001b[22m${THREAD}\u001b[2m │` + ].join('\n') + + expect(parseCodexTuiStatusSessionId(status)).toBe(THREAD) + expect(parseCodexTuiStatusSessionId(`Thread ID: ${OTHER_THREAD}`)).toBe(OTHER_THREAD) + }) + it('uses Kitty Enter only while the TUI negotiated Kitty input', () => { expect(codexTuiStatusProbeInput(0)).toEqual({ command: '\u001b[200~/status\u001b[201~', @@ -151,4 +162,33 @@ describe('Codex TUI rollout proof', () => { '\u001b' ]) }) + + it('accepts a proven blank Codex 0.148 session before its lazy rollout exists', async () => { + let reads = 0 + const write = vi.fn((_data: string) => true) + const resolveRollout = vi.fn(async () => null) + + await expect( + resolveLiveCodexTuiRollout({ + codexHome: '/pinned', + kittyKeyboardFlags: 1, + readOutput: () => ({ + text: + reads++ > 0 + ? `│ >_ OpenAI Codex (v0.148.0) │\n│ Session: \u001b[22m${THREAD}\u001b[2m │` + : '', + lastOutputAt: reads > 1 ? 2 : 1 + }), + write, + resolveRollout, + delay: async () => undefined + }) + ).resolves.toEqual({ threadId: THREAD }) + expect(resolveRollout).toHaveBeenCalledTimes(5) + expect(write.mock.calls.map(([data]) => data)).toEqual([ + '\u001b[200~/status\u001b[201~', + '\u001b[13u', + '\u001b' + ]) + }) }) diff --git a/src/main/codex/codex-tui-rollout-proof.ts b/src/main/codex/codex-tui-rollout-proof.ts index 4572b1d68ba..9b303d104f9 100644 --- a/src/main/codex/codex-tui-rollout-proof.ts +++ b/src/main/codex/codex-tui-rollout-proof.ts @@ -5,7 +5,10 @@ import { relativePathInsideRoot } from '../../shared/cross-platform-path' import { listCodexSessionJsonlFilesIncrementally } from './codex-session-file-listing' const SESSION_ID_PATTERN = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' -const STATUS_SESSION_RE = new RegExp(`\\bSession(?: ID)?\\s*:\\s*(${SESSION_ID_PATTERN})\\b`, 'gi') +const STATUS_SESSION_RE = new RegExp( + `\\b(?:Session|Thread)(?:\\s+ID)?\\s*:\\s*(${SESSION_ID_PATTERN})\\b`, + 'gi' +) const ROLLOUT_READ_LIMIT = 64 * 1024 const STATUS_COMMAND_PASTE = '\u001b[200~/status\u001b[201~' const KITTY_ENTER = '\u001b[13u' @@ -125,7 +128,7 @@ export async function resolveLiveCodexTuiRollout(input: { timeoutMs?: number resolveRollout?: (codexHome: string, threadId: string) => Promise delay?: (ms: number) => Promise -}): Promise<{ threadId: string; transcriptPath: string }> { +}): Promise<{ threadId: string; transcriptPath?: string }> { const baselineOutputAt = input.readOutput().lastOutputAt const probe = codexTuiStatusProbeInput(input.kittyKeyboardFlags) if (!input.write(probe.command)) { @@ -144,14 +147,19 @@ export async function resolveLiveCodexTuiRollout(input: { const threadId = parseCodexTuiStatusSessionId(output.text) if (threadId) { const resolveRollout = input.resolveRollout ?? resolvePinnedCodexRolloutProof - const transcriptPath = await resolveRollout(input.codexHome, threadId) + let transcriptPath: string | null = null + // Codex 0.148 allocates a session before it writes a rollout; give a just-written + // file a short visibility window without rejecting a genuinely blank conversation. + for (let attempt = 0; attempt < 5 && !transcriptPath; attempt += 1) { + transcriptPath = await resolveRollout(input.codexHome, threadId) + if (!transcriptPath && attempt < 4) { + await delay(100) + } + } if (!input.write('\u001b')) { throw new Error('The agent terminal could not finish Codex session verification.') } - if (!transcriptPath) { - throw new Error('Could not find the rollout for this Codex conversation.') - } - return { threadId, transcriptPath } + return { threadId, ...(transcriptPath ? { transcriptPath } : {}) } } } await delay(100) @@ -178,12 +186,20 @@ async function readCodexRolloutSessionMetaId(filePath: string): Promise 0 ? id : null } catch { return null diff --git a/src/main/runtime/orca-runtime-structured-session-restore.test.ts b/src/main/runtime/orca-runtime-structured-session-restore.test.ts index a5b2d7b6887..52db8dcf0df 100644 --- a/src/main/runtime/orca-runtime-structured-session-restore.test.ts +++ b/src/main/runtime/orca-runtime-structured-session-restore.test.ts @@ -55,6 +55,8 @@ describe('structured session cold restoration', () => { it('normalizes a restored tab id and removes it when closed', async () => { const runtime = new OrcaRuntimeService() + const closeSessionTab = vi.fn(async () => undefined) + runtime.setNotifier({ closeSessionTab } as never) const internal = runtime as unknown as { getKnownWorkspaceSessionWorktreeIds(): Set hydrateHeadlessMobileSessionTabsFromWorkspaceSession(): Set @@ -146,6 +148,11 @@ describe('structured session cold restoration', () => { reason: 'user' }) + expect(closeSessionTab).toHaveBeenCalledWith( + 'structured-agent-session-restored-session', + 'workspace-1' + ) + const closed = await runtime.listMobileSessionTabs('id:workspace-1') expect(closed.tabs.map((tab) => tab.id)).toEqual([ 'terminal-tab::leaf-1', @@ -153,4 +160,26 @@ describe('structured session cold restoration', () => { ]) expect(closed.tabGroups?.[0]?.tabOrder).toEqual(['terminal-tab']) }) + + it('commits the host close when the renderer already removed the structured tab', async () => { + const runtime = new OrcaRuntimeService() + runtime.setNotifier({ + closeSessionTab: vi.fn(async () => { + throw new Error('session_tab_not_found') + }) + } as never) + runtime.publishStructuredAgentSessionTab({ + workspaceId: 'workspace-1', + sessionId: 'session-1', + agent: 'codex', + activate: true + }) + + await runtime.closeMobileSessionTab('id:workspace-1', 'agent-session:session-1', { + reason: 'user' + }) + + const snapshot = await runtime.listMobileSessionTabs('id:workspace-1') + expect(snapshot.tabs).toEqual([]) + }) }) diff --git a/src/main/runtime/orca-runtime-structured-tui-adoption-write-gate.test.ts b/src/main/runtime/orca-runtime-structured-tui-adoption-write-gate.test.ts index fab6a74542d..c14e57b6e4b 100644 --- a/src/main/runtime/orca-runtime-structured-tui-adoption-write-gate.test.ts +++ b/src/main/runtime/orca-runtime-structured-tui-adoption-write-gate.test.ts @@ -76,7 +76,10 @@ function installProofPty( ptysById: Map } const pty = record.ptysById.get(PTY_ID)! - pty.tailBuffer = [`Session ID: ${threadId}`] + pty.tailBuffer = [ + '\u001b[2m│ >_ OpenAI Codex (v0.148.0) │', + `│ Session: \u001b[22m${threadId}\u001b[2m │` + ] pty.lastOutputAt = ptyRecord.lastOutputAt + 1_000 } return true @@ -237,6 +240,21 @@ describe('structured Codex adoption against the real PTY write gate', () => { }) }, 20_000) + it('adopts a blank Codex 0.148 session before the lazy rollout exists', async () => { + await rm(join(rig.codexHome, 'sessions'), { recursive: true, force: true }) + + const result = await rig.runtime.adoptStructuredAgentSessionTerminal(adoptInput(), { + callerKey: 'renderer-1' + }) + + expect(result).toMatchObject({ ok: true }) + expect(rig.refusedWrites).toEqual([]) + expect(rig.store.getRecord(SESSION_ID)).toMatchObject({ + providerHandleChain: [{ origin: 'adopted', handle: { threadId: THREAD_ID } }], + lease: { runtimeKind: 'tui', claimStatus: 'live' } + }) + }, 20_000) + it('leaves nothing latched when the proof fails, so the next attempt still succeeds', async () => { await rm(join(rig.codexHome, 'sessions'), { recursive: true, force: true }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 1a4e291d160..4619d890fc9 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -94,6 +94,7 @@ import { type StructuredTuiOwner } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types' import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { SESSION_TAB_NOT_FOUND_ERROR } from '../../shared/session-tab-close' import { agentSessionOwnerBindingsEqual, cloneAgentSessionOwnerBinding, @@ -9471,7 +9472,19 @@ export class OrcaRuntimeService { // snapshot so paired clients stop showing it. await this.closeHeadlessMobileBrowserTab(worktreeId, snapshot, tab) } else if (tab.type === 'agent-session') { - this.notifier?.closeSessionTab?.(tab.id, worktreeId) + if (this.notifier?.closeSessionTab) { + try { + await this.notifier.closeSessionTab( + structuredAgentSessionTabId(tab.sessionId), + worktreeId + ) + } catch (error) { + // The renderer already having removed the tab is an idempotent close, not a veto. + if (!(error instanceof Error && error.message === SESSION_TAB_NOT_FOUND_ERROR)) { + throw error + } + } + } this.closeStructuredAgentSessionTab(worktreeId, snapshot, tab) } else { if (!this.notifier?.closeSessionTab) { @@ -11616,7 +11629,7 @@ export class OrcaRuntimeService { spawnToken }) ?? false } - let transcriptPath: string + let transcriptPath: string | undefined if (threadId) { const proof = await proveCodexTuiRollout({ ...proofInput, threadId }) transcriptPath = proof.transcriptPath @@ -11651,7 +11664,7 @@ export class OrcaRuntimeService { fence, observedAt: Date.now() }), - transcriptPath, + ...(transcriptPath ? { transcriptPath } : {}), historySource: 'provider-resume', adoptedTerminal: true } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts index 932d17fd192..99f0625aa0d 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts @@ -159,6 +159,31 @@ describe('a client that disappears without cleanup', () => { expect(closeSession).not.toHaveBeenCalled() expect(host.hasSession(SESSION)).toBe(true) }) + + it('releases a desktop subscription when its renderer transport dies', async () => { + const transport = new AbortController() + await dispatcher.dispatchStreaming( + { + id: 'desktop-subscription', + authToken: 'token', + method: 'agentSession.subscribe', + params: { sessionId: SESSION } + }, + () => {}, + { + signal: transport.signal, + clientId: 'desktop-renderer', + clientKind: 'runtime', + clientCapabilities: CLIENT.clientCapabilities + } + ) + expect(host.isHeld(SESSION)).toBe(true) + + transport.abort() + + await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) + expect(closeSession).toHaveBeenCalledWith(SESSION) + }) }) // Paired builds ship on their own schedule, and the ones already in the field never learned to diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 4ab47bb6752..58d34f72341 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -252,15 +252,23 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ ) let closed = false let dispose = (): void => {} - ctx.runtime.registerSubscriptionCleanup( + let releaseTransportSubscription = (): void => {} + const onTransportAbort = (): void => releaseTransportSubscription() + const registration = ctx.runtime.registerOwnedSubscriptionCleanup( subscriptionId, () => { closed = true + ctx.signal?.removeEventListener('abort', onTransportAbort) dispose() host.release(params.sessionId, streamHolder) }, ctx.connectionId ) + releaseTransportSubscription = registration.releaseIfCurrent + ctx.signal?.addEventListener('abort', onTransportAbort, { once: true }) + if (ctx.signal?.aborted) { + onTransportAbort() + } if (closed) { return } diff --git a/src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.ts b/src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.ts index 8caae122e7b..f27be0a02c5 100644 --- a/src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.ts +++ b/src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.ts @@ -1,4 +1,5 @@ import { useCallback } from 'react' +import { toast } from 'sonner' import type { Tab } from '../../../../shared/tab-types' import { useAppStore } from '../../store' import { destroyWorkspaceWebviews } from '../../store/slices/browser-webview-cleanup' @@ -12,6 +13,17 @@ import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner import { browserWorkspaceHasRemoteOwner } from '@/runtime/remote-browser-tab-ownership' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' +import { translate } from '@/i18n/i18n' + +function reportStructuredSessionCloseError(error: unknown): void { + toast.error( + translate( + 'components.native-chat.structuredSessionCloseFailed', + 'Could not close this Codex chat' + ), + { description: error instanceof Error ? error.message : String(error) } + ) +} export function useTabGroupTabCloseCommands({ worktreeId, @@ -87,10 +99,13 @@ export function useTabGroupTabCloseCommands({ tabId: `agent-session:${item.entityId}`, reason: 'user' }) - closeUnifiedTab(item.id) - if (!opts?.skipEmptyCheck) { - leaveWorktreeIfEmpty() - } + .then(() => { + closeUnifiedTab(item.id) + if (!opts?.skipEmptyCheck) { + leaveWorktreeIfEmpty() + } + }) + .catch(reportStructuredSessionCloseError) return } if (item.contentType === 'terminal') { @@ -164,7 +179,8 @@ export function useTabGroupTabCloseCommands({ tabId: `agent-session:${item.entityId}`, reason: 'user' }) - closeUnifiedTab(item.id) + .then(() => closeUnifiedTab(item.id)) + .catch(reportStructuredSessionCloseError) continue } if (item.contentType === 'terminal' && isWebRuntimeSessionActive(runtimeEnvironmentId)) { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index c77ebb15ceb..09003f10f7e 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -16113,7 +16113,9 @@ "orchestrationPaused": { "label": "Orchestration paused", "message": "Structured Chat blocks terminal prompts and sends. Orchestration messages remain queued; switch to Terminal, then check the Orca inbox with" - } + }, + "structuredSessionCloseFailed": "Could not close this Codex chat", + "structuredSessionLaunchFailed": "Could not open Codex chat" }, "tab": { "bar": { diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index 8573079003f..fd5d08054ae 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -1,3 +1,4 @@ +import { toast } from 'sonner' import { useAppStore } from '@/store' import type { AgentStartupPlan } from '@/lib/tui-agent-startup' import { planLaunchAgentStartupPrompt } from '@/lib/launch-agent-startup-prompt-plan' @@ -31,6 +32,8 @@ import { resolveInitialNativeChatSessionOptions } from '@/components/native-chat import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache' import { canUseStructuredNativeChat } from '@/lib/structured-native-chat-availability' import { waitForAgentReady } from '@/lib/agent-ready-wait' +import { launchStructuredCodexSession } from '@/lib/launch-structured-codex-session' +import { translate } from '@/i18n/i18n' export type LaunchAgentInNewTabArgs = { agent: TuiAgent @@ -176,6 +179,26 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI } } + const launchDirectStructuredChat = + agent === 'codex' && + !hasPrompt && + initialViewModeProps.viewMode === 'chat' && + canUseStructuredNativeChat(store, worktreeId) + if (launchDirectStructuredChat) { + void launchStructuredCodexSession(worktreeId).catch((error) => { + toast.error( + translate( + 'components.native-chat.structuredSessionLaunchFailed', + 'Could not open Codex chat' + ), + { + description: error instanceof Error ? error.message : String(error) + } + ) + }) + return { tabId: null, startupPlan, pasteDraftAfterLaunch: false } + } + // Why: queue startup BEFORE TerminalPane mounts — it snapshots pendingStartupByTabId in useState on first render. // Why: followup path pastes an unsubmitted draft, so gate the initial chat view like a draft launch, not auto-submit. const adoptStructuredChat = diff --git a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts index 632127fa065..a6a3f02a73e 100644 --- a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts +++ b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts @@ -5,6 +5,7 @@ const mockSetTabViewMode = vi.fn() const mockWaitForAgentReady = vi.fn() const mockPasteDraftWhenAgentReady = vi.fn() const mockMarkNativeChatLaunchPromptFailed = vi.fn() +const mockLaunchStructuredCodexSession = vi.fn() const store = { activeRepoId: 'repo-1', @@ -61,6 +62,9 @@ vi.mock('@/runtime/web-runtime-session', () => ({ isWebRuntimeSessionActive: vi.fn(() => false), isWebTerminalSurfaceTabId: vi.fn(() => false) })) +vi.mock('@/lib/launch-structured-codex-session', () => ({ + launchStructuredCodexSession: mockLaunchStructuredCodexSession +})) /** Structured adoption creates the tab in terminal mode and flips it to chat once * Codex is ready; the bridge stamps `viewMode: 'chat'` on the tab up front. That @@ -73,24 +77,25 @@ describe('structured chat adoption guard on the launch path', () => { mockCreateTab.mockReturnValue({ id: 'tab-1' }) mockWaitForAgentReady.mockResolvedValue({ ready: true, reason: 'foreground-match' }) mockPasteDraftWhenAgentReady.mockResolvedValue(true) + mockLaunchStructuredCodexSession.mockResolvedValue('codex-session-1') }) - it('adopts a local Codex tab into the structured stack', async () => { + it('honors the persisted chat default by launching Codex directly as a structured session', async () => { const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') - launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) + const result = launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) - expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { - launchAgent: 'codex' - }) - await vi.waitFor(() => expect(mockSetTabViewMode).toHaveBeenCalledWith('tab-1', 'chat')) + expect(result).toMatchObject({ tabId: null, pasteDraftAfterLaunch: false }) + expect(mockLaunchStructuredCodexSession).toHaveBeenCalledWith('wt-1') + expect(mockCreateTab).not.toHaveBeenCalled() + expect(mockWaitForAgentReady).not.toHaveBeenCalled() }) it('stays in terminal view when Codex never became ready', async () => { mockWaitForAgentReady.mockResolvedValue({ ready: false, reason: 'timeout' }) const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') - launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) + launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1', prompt: 'start this task' }) await vi.waitFor(() => expect(mockWaitForAgentReady).toHaveBeenCalled()) // Let every continuation the ready wait could have queued run before asserting it did not flip. diff --git a/src/renderer/src/lib/launch-structured-codex-session.test.ts b/src/renderer/src/lib/launch-structured-codex-session.test.ts new file mode 100644 index 00000000000..f8d82809363 --- /dev/null +++ b/src/renderer/src/lib/launch-structured-codex-session.test.ts @@ -0,0 +1,50 @@ +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 { 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, + snapshot: { cursor: { epoch: 'epoch-1', sequence: 0 }, items: [] }, + unconfirmedClientMessageIds: [] + } + })) + + const sessionId = await launchStructuredCodexSession('workspace-1') + const params = vi.mocked(callStructuredAgentSession).mock.calls[0]?.[2] as { + envelope: { sessionId: string; payloadFingerprint: string } + worktree: string + agent: 'codex' + } + + expect(sessionId).toMatch(/^codex_[A-Za-z0-9_]{36}$/) + 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' } + }) + ) + }) +}) diff --git a/src/renderer/src/lib/launch-structured-codex-session.ts b/src/renderer/src/lib/launch-structured-codex-session.ts new file mode 100644 index 00000000000..390187da124 --- /dev/null +++ b/src/renderer/src/lib/launch-structured-codex-session.ts @@ -0,0 +1,38 @@ +import type { + AgentSessionAttachResult, + AgentSessionMutationResult +} from '../../../shared/agent-session-wire' +import { + createStructuredAgentSessionOperationId, + structuredAgentSessionPayloadFingerprint +} from '../../../shared/structured-agent-session-mutation' +import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' +import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' + +function newSessionId(): string { + return `codex_${crypto.randomUUID().replaceAll('-', '_')}` +} + +export async function launchStructuredCodexSession(worktreeId: string): Promise { + const sessionId = newSessionId() + const fields = { worktree: toRuntimeWorktreeSelector(worktreeId), agent: 'codex' as const } + const result = await callStructuredAgentSession< + AgentSessionMutationResult + >({ kind: 'local' }, 'agentSession.create', { + envelope: { + sessionId, + clientOperationId: createStructuredAgentSessionOperationId(() => crypto.randomUUID()), + expectedRuntimeFence: null, + payloadFingerprint: structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.create', + sessionId, + fields + }) + }, + ...fields + }) + if (!result.ok) { + throw new Error(result.refusal.message) + } + return result.value.sessionId +}