feat(native-chat): support structured Claude sessions

This commit is contained in:
Merge Sim
2026-08-31 17:44:30 -07:00
parent f289fee894
commit be6891a08e
30 changed files with 900 additions and 264 deletions
@@ -26,7 +26,7 @@ function userMessage(blocks: AgentJournalMessageItem['blocks']): AgentJournalMes
}
describe('Claude structured text dispatch', () => {
it('accepts a slash command from its result receipt when Claude omits the user replay', async () => {
it('accepts a slash command when Claude provides a result uuid', async () => {
const session = sessionFor()
const dispatched = dispatchClaudeTurn(
session,
@@ -80,7 +80,60 @@ describe('Claude structured text dispatch', () => {
})
})
it('leaves image dispatch explicitly unavailable for slice 2', async () => {
it('sends a local attachment as a base64 image Claude accepts', async () => {
const directory = await mkdtemp(join(tmpdir(), 'orca-claude-image-'))
try {
const path = join(directory, 'shot.png')
const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47])
await writeFile(path, bytes)
const session = sessionFor()
const body = userMessage([
{ type: 'text', text: 'look' },
{ type: 'image-ref', path }
])
const dispatched = dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }, 100)
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
resolveClaudeReplayWaiter(session, {
type: 'user',
parent_tool_use_id: null,
session_id: 'provider-session',
uuid: 'replayed-uuid'
})
await expect(dispatched).resolves.toEqual({
state: 'accepted',
providerIdentity: {
provider: 'claude',
sessionId: 'provider-session',
uuid: 'replayed-uuid'
}
})
expect(session.connection.send).toHaveBeenCalledWith({
type: 'user',
message: {
role: 'user',
content: [
{ type: 'text', text: 'look' },
{
type: 'image',
source: {
type: 'base64',
media_type: 'image/png',
data: bytes.toString('base64')
}
}
]
},
parent_tool_use_id: null,
session_id: 'provider-session'
})
} finally {
await rm(directory, { recursive: true, force: true })
}
})
it('rejects a message carrying more images than one turn may inline', async () => {
const session = sessionFor()
const body = userMessage(
Array.from({ length: 21 }, (_, index) => ({
@@ -93,7 +146,7 @@ describe('Claude structured text dispatch', () => {
dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }, 1)
).resolves.toEqual({
state: 'rejected',
reason: 'Claude structured image dispatch is not available yet'
reason: 'Claude accepts at most 20 images per message; this one has 21'
})
expect(session.connection.send).not.toHaveBeenCalled()
})
@@ -115,7 +168,7 @@ describe('Claude structured text dispatch', () => {
dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }, 1)
).resolves.toEqual({
state: 'rejected',
reason: 'Claude structured image dispatch is not available yet'
reason: 'Claude accepts up to 20971520 bytes of images per message'
})
expect(session.connection.send).not.toHaveBeenCalled()
} finally {
@@ -135,7 +188,7 @@ describe('Claude structured text dispatch', () => {
dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }, 1)
).resolves.toEqual({
state: 'rejected',
reason: 'Claude structured image dispatch is not available yet'
reason: `Claude accepts images up to 5242880 bytes; ${path} is 5242881`
})
expect(session.connection.send).not.toHaveBeenCalled()
} finally {
@@ -3,6 +3,7 @@ import type { NativeChatBlock } from '../../shared/native-chat-types'
import type { AgentSessionDispatchOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type { ClaudeSession } from './claude-structured-session-state'
import { readClaudeFrameString } from './claude-structured-init-proof'
import { claudeImageBudget, claudeImageContent } from './claude-structured-image-content'
export function resolveClaudeReplayWaiter(
session: ClaudeSession,
@@ -32,16 +33,18 @@ async function messageContent(body: AgentJournalMessageItem): Promise<unknown[]>
if (body.role !== 'user') {
throw new Error('Claude dispatch accepts only user messages')
}
const blocks = body.blocks as NativeChatBlock[]
const budget = claudeImageBudget(blocks.filter((block) => block.type === 'image-ref').length)
const content: unknown[] = []
for (const block of body.blocks as NativeChatBlock[]) {
for (const block of blocks) {
if (block.type === 'text' && block.text.length > 0) {
content.push({ type: 'text', text: block.text })
} else if (block.type === 'image-ref') {
throw new Error('Claude structured image dispatch is not available yet')
content.push(await claudeImageContent(block, budget))
}
}
if (content.length === 0) {
throw new Error('Claude dispatch requires text')
throw new Error('Claude dispatch requires text or an image')
}
return content
}
@@ -0,0 +1,58 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { claudeImageContent, claudeImageMediaType } from './claude-structured-image-content'
describe('Claude image content', () => {
let dir: string
beforeAll(async () => {
dir = await mkdtemp(join(tmpdir(), 'claude-image-'))
})
afterAll(async () => {
await rm(dir, { recursive: true, force: true })
})
it('inlines a local attachment as a base64 source Claude accepts', async () => {
const path = join(dir, 'shot.PNG')
const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
await writeFile(path, bytes)
expect(await claudeImageContent({ type: 'image-ref', path })).toEqual({
type: 'image',
source: { type: 'base64', media_type: 'image/png', data: bytes.toString('base64') }
})
})
it('passes a remote reference through as a url source', async () => {
expect(
await claudeImageContent({ type: 'image-ref', url: 'https://example.test/a.png' })
).toEqual({
type: 'image',
source: { type: 'url', url: 'https://example.test/a.png' }
})
})
it('refuses a format Anthropic vision does not accept before anything is sent', async () => {
const path = join(dir, 'diagram.svg')
await writeFile(path, '<svg/>')
await expect(claudeImageContent({ type: 'image-ref', path })).rejects.toThrow(
'Claude does not accept .svg images; use JPEG, PNG, GIF, or WebP'
)
})
it('reports the read failure rather than sending an empty image', async () => {
await expect(
claudeImageContent({ type: 'image-ref', path: join(dir, 'missing.png') })
).rejects.toThrow('Claude could not read the attached image')
})
it('resolves media types case-insensitively and rejects unknown extensions', () => {
expect(claudeImageMediaType('/a/b.JPEG')).toBe('image/jpeg')
expect(claudeImageMediaType('/a/b.webp')).toBe('image/webp')
expect(claudeImageMediaType('/a/b.tiff')).toBeNull()
})
})
@@ -0,0 +1,84 @@
import { readFile } from 'node:fs/promises'
import { extname } from 'node:path'
import type { NativeChatImageRefBlock } from '../../shared/native-chat-types'
// Claude's stream-json stdin takes Anthropic message content, which has no
// local-path image source — unlike Codex's `localImage`. A local attachment has
// to be read and inlined as base64, so the composer's file is resolved here
// rather than handed to the provider as a path it would silently ignore.
/** The image formats Anthropic vision accepts. An extension outside this set is
* refused locally: the provider would reject it mid-turn, which is exactly the
* unknown-outcome state the send path must never enter. */
const CLAUDE_IMAGE_MEDIA_TYPES: Record<string, string> = {
'.gif': 'image/gif',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.png': 'image/png',
'.webp': 'image/webp'
}
/** Anthropic's documented per-image ceiling. */
export const CLAUDE_MAX_IMAGE_BYTES = 5 * 1024 * 1024
/** Whole-message ceilings, so one send cannot inline an unbounded base64 body
* into the provider's stdin. */
export const CLAUDE_MAX_IMAGE_COUNT = 20
export const CLAUDE_MAX_IMAGE_TOTAL_BYTES = 20 * 1024 * 1024
export function claudeImageMediaType(path: string): string | null {
return CLAUDE_IMAGE_MEDIA_TYPES[extname(path).toLowerCase()] ?? null
}
export type ClaudeImageBudget = { remainingBytes: number }
/** Throws for a message carrying more images than one turn may inline; the
* returned budget then fails the send on the first byte over the aggregate. */
export function claudeImageBudget(imageCount: number): ClaudeImageBudget {
if (imageCount > CLAUDE_MAX_IMAGE_COUNT) {
throw new Error(
`Claude accepts at most ${CLAUDE_MAX_IMAGE_COUNT} images per message; this one has ${imageCount}`
)
}
return { remainingBytes: CLAUDE_MAX_IMAGE_TOTAL_BYTES }
}
export async function claudeImageContent(
block: NativeChatImageRefBlock,
budget: ClaudeImageBudget = claudeImageBudget(1),
readImage: (path: string) => Promise<Buffer> = readFile
): Promise<Record<string, unknown>> {
if (block.url) {
return { type: 'image', source: { type: 'url', url: block.url } }
}
if (!block.path) {
throw new Error('Claude image dispatch requires a path or url')
}
const mediaType = claudeImageMediaType(block.path)
if (!mediaType) {
throw new Error(
`Claude does not accept ${extname(block.path) || 'this'} images; use JPEG, PNG, GIF, or WebP`
)
}
let data: Buffer
try {
data = await readImage(block.path)
} catch (error) {
throw new Error(`Claude could not read the attached image: ${(error as Error).message}`)
}
// Measured on bytes actually read, never on a stat the file could have grown past.
if (data.byteLength > CLAUDE_MAX_IMAGE_BYTES) {
throw new Error(
`Claude accepts images up to ${CLAUDE_MAX_IMAGE_BYTES} bytes; ${block.path} is ${data.byteLength}`
)
}
budget.remainingBytes -= data.byteLength
if (budget.remainingBytes < 0) {
throw new Error(
`Claude accepts up to ${CLAUDE_MAX_IMAGE_TOTAL_BYTES} bytes of images per message`
)
}
return {
type: 'image',
source: { type: 'base64', media_type: mediaType, data: data.toString('base64') }
}
}
@@ -87,12 +87,7 @@ describe('claude structured launch resolution', () => {
})
it('uses the runtime environment instead of the scrubbed legacy launchEnv', async () => {
const pinned = record({
launchEnv: {
ANTHROPIC_AUTH_TOKEN: 'first-token',
ANTHROPIC_BASE_URL: 'https://gateway.example.test'
}
})
const pinned = record()
const resolver = resolverFor(pinned, () => ({
ANTHROPIC_AUTH_TOKEN: 'rotated-token',
ANTHROPIC_BASE_URL: 'https://gateway.example.test'
@@ -11,10 +11,10 @@ import {
import { claudeRecord, claudeText } from './claude-structured-item-translation'
import {
CLAUDE_APPROVAL_DECISIONS,
encodeClaudeQuestionOptionId,
type ClaudeApprovalDecision,
type ClaudePendingPrompt
} from './claude-structured-prompt-replies'
import { encodeAgentSessionQuestionOptionId } from '../native-chat/agent-session-wire/agent-session-question-option-id'
const APPROVAL_LABELS: Record<ClaudeApprovalDecision, string> = {
allow: 'Allow',
@@ -74,7 +74,7 @@ function questionOptions(
return label
? [
{
id: encodeClaudeQuestionOptionId(questionAddress, `choice-${index + 1}`),
id: encodeAgentSessionQuestionOptionId(questionAddress, `choice-${index + 1}`),
label
}
]
@@ -1,4 +1,8 @@
import type { ClaudeControlRequest } from './claude-stream-json-connection'
import {
decodeAgentSessionQuestionOptionId,
encodeAgentSessionQuestionOptionId
} from '../native-chat/agent-session-wire/agent-session-question-option-id'
export const CLAUDE_APPROVAL_DECISIONS = ['allow', 'allowForSession', 'deny', 'cancel'] as const
export type ClaudeApprovalDecision = (typeof CLAUDE_APPROVAL_DECISIONS)[number]
@@ -40,7 +44,7 @@ function questionIdFromAddress(prompt: ClaudePendingPrompt, address: string): st
}
function questionAnswer(prompt: ClaudePendingPrompt, questionId: string, optionId: string): string {
const decoded = decodeClaudeQuestionOptionId(optionId)
const decoded = decodeAgentSessionQuestionOptionId(optionId)
if (!decoded) {
return optionId
}
@@ -72,26 +76,8 @@ function questionId(question: Record<string, unknown>, index: number): string {
return readString(question.question) ?? readString(question.header) ?? `question-${index + 1}`
}
export function encodeClaudeQuestionOptionId(questionId: string, answer: string): string {
return `${encodeURIComponent(questionId)}:${encodeURIComponent(answer)}`
}
export function decodeClaudeQuestionOptionId(
optionId: string
): { questionId: string; answer: string } | null {
const separator = optionId.indexOf(':')
if (separator <= 0) {
return null
}
try {
return {
questionId: decodeURIComponent(optionId.slice(0, separator)),
answer: decodeURIComponent(optionId.slice(separator + 1))
}
} catch {
return null
}
}
export const encodeClaudeQuestionOptionId = encodeAgentSessionQuestionOptionId
export const decodeClaudeQuestionOptionId = decodeAgentSessionQuestionOptionId
export class ClaudePromptRegistry {
private readonly prompts = new Map<string, ClaudePendingPrompt>()
@@ -194,7 +180,7 @@ function questionResponse(
optionId: string,
boundQuestionId?: string
): Record<string, unknown> | null {
const decoded = decodeClaudeQuestionOptionId(optionId)
const decoded = decodeAgentSessionQuestionOptionId(optionId)
const decodedQuestionId = decoded
? (questionIdFromAddress(prompt, decoded.questionId) ??
(prompt.questionIds.includes(decoded.questionId) ? decoded.questionId : null))
@@ -0,0 +1,23 @@
/** A user-input request can carry several questions but takes ONE reply, so an
* option id has to name the question it answers. Provider-agnostic: the shared
* turn path decodes ids from every adapter, so the codec cannot live in one. */
export function encodeAgentSessionQuestionOptionId(questionId: string, answer: string): string {
return `${encodeURIComponent(questionId)}:${encodeURIComponent(answer)}`
}
export function decodeAgentSessionQuestionOptionId(
optionId: string
): { questionId: string; answer: string } | null {
const separator = optionId.indexOf(':')
if (separator <= 0) {
return null
}
try {
return {
questionId: decodeURIComponent(optionId.slice(0, separator)),
answer: decodeURIComponent(optionId.slice(separator + 1))
}
} catch {
return null
}
}
@@ -4,7 +4,7 @@ import type {
AgentJournalResolution
} from '../../../shared/agent-session-journal-types'
import type { AgentSessionPromptResult } from '../../../shared/agent-session-wire'
import { decodeCodexQuestionOptionId } from '../../codex/codex-structured-prompt-replies'
import { decodeAgentSessionQuestionOptionId } from './agent-session-question-option-id'
import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns'
function invalid(message: string): TurnOutcome<never> {
@@ -58,7 +58,7 @@ export async function performPrompt(
}
}
}
const freeText = decodeCodexQuestionOptionId(input.optionId)
const freeText = decodeAgentSessionQuestionOptionId(input.optionId)
const acceptsFreeText =
item.body.kind === 'question' &&
prompt.freeTextQuestionId !== undefined &&
+23 -12
View File
@@ -102,7 +102,8 @@ import {
import type { AgentSessionRecord } from '../../shared/agent-session-record'
import {
agentSessionProviderHandleRoot,
agentSessionProviderHandlesEqual
agentSessionProviderHandlesEqual,
type AgentSessionHandleProvider
} from '../../shared/agent-session-provider-handle'
import { SESSION_TAB_NOT_FOUND_ERROR } from '../../shared/session-tab-close'
import {
@@ -11686,9 +11687,12 @@ export class OrcaRuntimeService {
// in a plain folder lands in the folder rather than failing to resolve.
resolveWorkspacePath: async (workspaceId) =>
(await this.resolveRuntimeFileTarget(`id:${workspaceId}`)).worktree.path,
resolveLaunchArgs: () => this.resolveConfiguredCodexStructuredArgs(),
resolveLaunchEnvOverlay: () =>
resolveTuiAgentLaunchEnv('codex', this.requireStore().getSettings().agentDefaultEnv),
resolveLaunchArgs: (provider) =>
provider === 'claude'
? this.resolveConfiguredClaudeStructuredArgs()
: this.resolveConfiguredCodexStructuredArgs(),
resolveLaunchEnvOverlay: (provider) =>
resolveTuiAgentLaunchEnv(provider, this.requireStore().getSettings().agentDefaultEnv),
handoffTransport: this.createStructuredAgentSessionHandoffTransport()
})
}
@@ -11706,6 +11710,10 @@ export class OrcaRuntimeService {
)
}
private resolveConfiguredClaudeStructuredArgs(): string[] {
return []
}
private createStructuredAgentSessionHandoffTransport(): StructuredAgentSessionHandoffTransport {
return {
hostLabel: hostname(),
@@ -12533,7 +12541,7 @@ export class OrcaRuntimeService {
async getStructuredAgentSessionCreateSupport(
worktreeSelector: string,
agent: 'codex'
agent: AgentSessionHandleProvider
): Promise<{ supported: boolean; reason?: 'agent' | 'remote' | 'wsl' }> {
const location = await this.resolveStructuredAgentSessionLocation(worktreeSelector)
await this.ensureStructuredAgentSessionHost()
@@ -12598,10 +12606,13 @@ export class OrcaRuntimeService {
async resolveStructuredAgentSessionCreateIntent(input: {
envelope: { sessionId: string; clientOperationId: string }
worktree: string
agent: 'codex'
agent: AgentSessionHandleProvider
}): Promise<AgentSessionAttachParams> {
return this.resolveStructuredAgentSessionIntent(input, async ({ workspacePath, launchEnv }) => {
// A create has no process yet, so the current selection is what it must follow.
if (input.agent === 'claude') {
return launchEnv.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude')
}
const preparedHome = await this.prepareCodexStructuredLaunchFn?.({ workspacePath, launchEnv })
const configuredHome = launchEnv.CODEX_HOME
return (
@@ -12616,7 +12627,7 @@ export class OrcaRuntimeService {
input: {
envelope: { sessionId: string; clientOperationId: string }
worktree: string
agent: 'codex'
agent: AgentSessionHandleProvider
},
resolveAccountHomePath: (context: {
workspacePath: string
@@ -12642,7 +12653,7 @@ export class OrcaRuntimeService {
provider: input.agent,
agent: input.agent,
accountHome: {
variable: 'CODEX_HOME',
variable: input.agent === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME',
path: await resolveAccountHomePath({ workspacePath, launchEnv })
},
runtimeKind: 'native'
@@ -12702,7 +12713,7 @@ export class OrcaRuntimeService {
}
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession()
for (const session of host?.listSessionTabs() ?? []) {
if (session.agent !== 'codex') {
if (session.agent !== 'codex' && session.agent !== 'claude') {
continue
}
let sessionId = session.sessionId
@@ -12711,7 +12722,7 @@ export class OrcaRuntimeService {
}
await this.publishStructuredAgentSessionTab({
...session,
agent: 'codex',
agent: session.agent,
sessionId,
activate: false,
notify: false
@@ -12722,7 +12733,7 @@ export class OrcaRuntimeService {
async publishStructuredAgentSessionTab(input: {
workspaceId: string
sessionId: string
agent: 'codex'
agent: AgentSessionHandleProvider
activate: boolean
notify?: boolean
}): Promise<void> {
@@ -12738,7 +12749,7 @@ export class OrcaRuntimeService {
const tab: RuntimeMobileSessionAgentTab = {
type: 'agent-session',
id,
title: 'Codex Chat',
title: input.agent === 'claude' ? 'Claude Chat' : 'Codex Chat',
sessionId: input.sessionId,
agent: input.agent,
isActive: input.activate
@@ -98,7 +98,7 @@ export const CreateIntentParams = z
.object({
envelope: MutationEnvelope,
worktree: Identifier('Invalid worktree selector'),
agent: z.literal('codex')
agent: z.enum(['codex', 'claude'])
})
.strict()
@@ -107,7 +107,7 @@ export const CreateParams = z.union([AttachParams, CreateIntentParams])
export const CreateSupportParams = z
.object({
worktree: Identifier('Invalid worktree selector'),
agent: z.literal('codex')
agent: z.enum(['codex', 'claude'])
})
.strict()
@@ -366,13 +366,14 @@ describe('parameter validation', () => {
)
})
it('rejects Claude structured create shapes', async () => {
await rejects('agentSession.createSupport', {
it('accepts Claude structured create shapes', async () => {
const support = await call('agentSession.createSupport', {
worktree: 'id:workspace-1',
agent: 'claude'
})
expect(support).toMatchObject({ ok: true })
const fields = { worktree: 'id:workspace-1', agent: 'claude' }
await rejects('agentSession.create', {
const created = await call('agentSession.create', {
envelope: envelope({
expectedRuntimeFence: null,
payloadFingerprint: computeAgentSessionPayloadFingerprint({
@@ -383,6 +384,7 @@ describe('parameter validation', () => {
}),
...fields
})
expect(created).toMatchObject({ ok: true })
})
it('requires a sha256 fingerprint and a positive fence', async () => {
@@ -90,11 +90,11 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [
...resolved,
envelope: { ...params.envelope, payloadFingerprint: hostFingerprint }
})
if (result.ok && resolved.agent === 'codex') {
if (result.ok) {
await ctx.runtime.publishStructuredAgentSessionTab({
workspaceId: resolved.location.workspaceId,
sessionId: result.value.sessionId,
agent: 'codex',
agent: params.agent,
activate: true
})
}
@@ -71,7 +71,9 @@ export type StructuredAgentSessionRuntimeDeps = {
readClaudeProcessStartTime?: ClaudeStructuredSessionAdapterDeps['readProcessStartTime']
resolveLaunchArgs?: (provider: AgentSessionRecord['provider']) => Promise<string[]> | string[]
resolveLaunchEnv?: () => Promise<NodeJS.ProcessEnv>
resolveLaunchEnvOverlay?: () => Promise<Record<string, string>> | Record<string, string>
resolveLaunchEnvOverlay?: (
provider: AgentSessionRecord['provider']
) => Promise<Record<string, string>> | Record<string, string>
resolveEnvironment?: () => Promise<NodeJS.ProcessEnv>
resolveCodexOverrides?: () => NodeJS.ProcessEnv
resolveClaudeOverrides?: () => NodeJS.ProcessEnv
@@ -134,7 +136,7 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise<Install
): Promise<NodeJS.ProcessEnv> => ({
...(await bootEnvironment),
...(await deps.resolveLaunchEnv?.()),
...(await deps.resolveLaunchEnvOverlay?.()),
...(await deps.resolveLaunchEnvOverlay?.(provider)),
...(provider === 'claude' ? deps.resolveClaudeOverrides?.() : deps.resolveCodexOverrides?.())
})
const store = await AgentSessionRecordStore.open({
@@ -126,7 +126,7 @@ export function NativeChatExperimentalSetting({
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.ExperimentalPane.nativeChat.structuredCopy',
'Opt in to the host-owned structured Codex runtime. Off keeps the existing terminal-backed chat path.'
'Opt in to the host-owned structured Claude and Codex runtimes. Off keeps the existing terminal-backed chat path.'
)}
</p>
<p className="text-xs text-muted-foreground">
@@ -167,6 +167,45 @@ describe('client-hosted row while a real tab is activated', () => {
)
})
describe('structured session tab provider', () => {
it('passes the Claude label and launch agent to the tab surface', () => {
const item: TabBarItem = {
type: 'agent-session',
id: 'structured-agent-session-claude-1',
unifiedTabId: 'structured-agent-session-claude-1',
isPinned: false,
data: {
id: 'structured-agent-session-claude-1',
entityId: 'claude-1',
groupId: 'group-1',
worktreeId: 'wt-1',
contentType: 'agent-session',
agentSessionAgent: 'claude',
label: 'Claude Chat',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 0
}
}
const [rendered] = renderTabBarItems({
items: [item],
props: makeProps('agent-session'),
runtime: RUNTIME,
dropIndicatorByVisibleId: new Map(),
includeTopTabBorder: true,
activeClientHostedBrowserRowId: null,
togglePinned: () => {}
})
const tab = (rendered as React.ReactElement<{ tab: { title: string; launchAgent?: string } }>)
.props.tab
expect(tab.title).toBe('Claude Chat')
expect(tab.launchAgent).toBe('claude')
})
})
/**
* The fixtures above cannot cover a row kind that does not exist yet, and a new one wired straight
* to its own active-tab selector is exactly how this regressed the first time.
@@ -12,7 +12,8 @@ import {
import { useShallow } from 'zustand/react/shallow'
import { createPortal } from 'react-dom'
import type { CSSProperties } from 'react'
import type { TuiAgent } from '../../../../shared/tui-agent'
import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle'
import { resolveNativeChatRenderAgent } from './native-chat-render-agent'
import type { IDisposable } from '@xterm/xterm'
import { useAppStore } from '../../store'
import { useLinkRoutingPreferenceDialog } from '@/components/link-routing-preference-dialog'
@@ -558,6 +559,9 @@ function TerminalPane(
getCachedUnifiedTerminalTabForWorktree(store.unifiedTabsByWorktree, worktreeId, tabId)
?.agentSessionAgent
)
const validatedStructuredSessionAgent = isAgentSessionHandleProvider(structuredSessionAgent)
? structuredSessionAgent
: null
const isChatViewMode = useAppStore(
(store) =>
getCachedUnifiedTerminalTabForWorktree(store.unifiedTabsByWorktree, worktreeId, tabId)
@@ -669,15 +673,18 @@ function TerminalPane(
// foreground hook has not republished agent status after returning to TUI.
resolvedAgent: detectedAgent
? null
: ((structuredSessionAgent as TuiAgent | null) ?? resolveTitleAgentForLeaf(leafId)),
: structuredSessionId
? validatedStructuredSessionAgent
: resolveTitleAgentForLeaf(leafId),
nativeChatTranscriptIsLocalReadable
})
},
[
tabAgentTypeByLeaf,
nativeChatEnabled,
structuredSessionAgent,
validatedStructuredSessionAgent,
nativeChatTranscriptIsLocalReadable,
structuredSessionId,
terminalTab?.launchAgent,
getNativeChatLeafIds,
getTabWideAgentHintLeafId,
@@ -3109,7 +3116,11 @@ function TerminalPane(
leafId: chatPane?.leafId ?? null,
leafIds: getNativeChatLeafIds()
})
const structuredChatAgent = structuredSessionAgent ?? chatPaneResolvedAgent ?? chatPaneLaunchAgent
const structuredChatAgent = resolveNativeChatRenderAgent({
structuredSessionId,
structuredSessionAgent,
terminalAgent: chatPaneResolvedAgent ?? chatPaneLaunchAgent
})
const structuredChatTarget = useMemo(() => ({ kind: 'local' as const }), [])
// The launch draft is keyed by tab, so gate it on the same pane ownership the
// launch agent uses: a split sibling must not inherit the seeded text.
@@ -3275,17 +3286,19 @@ function TerminalPane(
{effectiveChatViewMode && chatPane?.container
? createPortal(
<div className="native-chat-pane-shell absolute inset-0 z-10 flex min-h-0 min-w-0 bg-background">
{structuredSessionId && structuredChatAgent ? (
<NativeChatView
mode="structured"
tabId={unifiedTabId ?? tabId}
sessionId={structuredSessionId}
agent={structuredChatAgent}
isVisible={isRendererVisible}
target={structuredChatTarget}
allowFileUriLinks
orchestrationDispatchStatus={chatPaneDispatchStatus}
/>
{structuredSessionId ? (
structuredChatAgent ? (
<NativeChatView
mode="structured"
tabId={unifiedTabId ?? tabId}
sessionId={structuredSessionId}
agent={structuredChatAgent}
isVisible={isRendererVisible}
target={structuredChatTarget}
allowFileUriLinks
orchestrationDispatchStatus={chatPaneDispatchStatus}
/>
) : null
) : (
<NativeChatView
terminalTabId={tabId}
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import { resolveNativeChatRenderAgent } from './native-chat-render-agent'
describe('native chat render agent', () => {
it('passes Claude through to a structured native chat surface', () => {
expect(
resolveNativeChatRenderAgent({
structuredSessionId: 'claude-session-1',
structuredSessionAgent: 'claude',
terminalAgent: 'codex'
})
).toBe('claude')
})
it.each([undefined, 'gemini'])('rejects structured provider metadata %s', (provider) => {
expect(
resolveNativeChatRenderAgent({
structuredSessionId: 'structured-session-1',
structuredSessionAgent: provider,
terminalAgent: 'codex'
})
).toBeNull()
})
it('keeps terminal-native-chat agent resolution when no structured session exists', () => {
expect(
resolveNativeChatRenderAgent({
structuredSessionId: null,
structuredSessionAgent: undefined,
terminalAgent: 'codex'
})
).toBe('codex')
})
})
@@ -0,0 +1,17 @@
import type { TuiAgent } from '../../../../shared/tui-agent'
import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle'
export function resolveNativeChatRenderAgent({
structuredSessionId,
structuredSessionAgent,
terminalAgent
}: {
structuredSessionId: string | null
structuredSessionAgent: unknown
terminalAgent: TuiAgent | null
}): TuiAgent | null {
if (!structuredSessionId) {
return terminalAgent
}
return isAgentSessionHandleProvider(structuredSessionAgent) ? structuredSessionAgent : null
}
+1 -1
View File
@@ -6920,7 +6920,7 @@
"defaultViewTerminal": "Terminal chat",
"defaultViewNative": "Chat UI",
"structuredTitle": "Use updated structured native chat",
"structuredCopy": "Opt in to the host-owned structured Codex runtime. Off keeps the existing terminal-backed chat path.",
"structuredCopy": "Opt in to the host-owned structured Claude and Codex runtimes. Off keeps the existing terminal-backed chat path.",
"structuredScope": "Local macOS and Linux sessions only for now. Windows, WSL, and remote execution hosts (including SSH) continue to use terminal chat.",
"structuredToggleLabel": "Toggle updated structured native chat"
},
@@ -30,7 +30,8 @@ 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 { canUseStructuredNativeChat } from '@/lib/structured-native-chat-availability'
import { startStructuredCodexLaunch } from '@/lib/structured-agent-session-launch'
import { startStructuredAgentLaunch } from '@/lib/structured-agent-session-launch'
import { isAgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle'
export type LaunchAgentInNewTabArgs = {
agent: TuiAgent
@@ -80,6 +81,13 @@ export function shouldQueueTerminalFocusAfterMenuClose(
* Returns `null` when no startup plan can be built (e.g. a whitespace-only prompt).
*/
export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentInNewTabResult {
return launchAgentInNewTabWithStructuredEligibility(args, true)
}
function launchAgentInNewTabWithStructuredEligibility(
args: LaunchAgentInNewTabArgs,
allowStructuredLaunch: boolean
): LaunchAgentInNewTabResult {
const {
agent,
worktreeId,
@@ -184,12 +192,15 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI
}
const launchDirectStructuredChat =
agent === 'codex' &&
allowStructuredLaunch &&
isAgentSessionHandleProvider(agent) &&
!hasPrompt &&
store.settings?.experimentalNativeChat === true &&
canUseStructuredNativeChat(store, worktreeId)
if (launchDirectStructuredChat) {
startStructuredCodexLaunch(worktreeId)
startStructuredAgentLaunch(worktreeId, agent, () => {
launchAgentInNewTabWithStructuredEligibility(args, false)
})
return {
tabId: null,
startupPlan,
@@ -5,28 +5,10 @@ const mockSetTabViewMode = vi.fn()
const mockWaitForAgentReady = vi.fn()
const mockPasteDraftWhenAgentReady = vi.fn()
const mockMarkNativeChatLaunchPromptFailed = vi.fn()
const mockCreateStructuredCodexSessionLaunchIntent = vi.fn()
const mockLaunchStructuredCodexSession = vi.fn()
const mockLaunchStructuredAgentSession = vi.fn()
const mockRefreshLocalStructuredSessionTabs = vi.fn()
const mockToastError = vi.fn()
function structuredLaunchIntent(worktreeId: string, sessionId = 'codex-session-1') {
return {
sessionId,
worktreeId,
params: {
envelope: {
sessionId,
clientOperationId: `operation-${sessionId}`,
expectedRuntimeFence: null,
payloadFingerprint: 'f'.repeat(64)
},
worktree: `id:${worktreeId}`,
agent: 'codex' as const
}
}
}
const store = {
activeRepoId: 'repo-1',
activeWorktreeId: 'wt-1',
@@ -83,21 +65,16 @@ vi.mock('@/runtime/web-runtime-session', () => ({
isWebRuntimeSessionActive: vi.fn(() => false),
isWebTerminalSurfaceTabId: vi.fn(() => false)
}))
vi.mock('@/lib/launch-structured-codex-session', () => {
class StructuredAgentSessionCreateRefusalError extends Error {}
return {
createStructuredCodexSessionLaunchIntent: mockCreateStructuredCodexSessionLaunchIntent,
launchStructuredCodexSession: mockLaunchStructuredCodexSession,
StructuredAgentSessionCreateRefusalError
}
})
vi.mock('@/lib/launch-structured-agent-session', () => ({
launchStructuredAgentSession: mockLaunchStructuredAgentSession
}))
vi.mock('@/runtime/local-structured-session-tabs-sync', () => ({
refreshLocalStructuredSessionTabs: mockRefreshLocalStructuredSessionTabs,
LOCAL_STRUCTURED_SESSION_OWNER: 'local-structured-session'
}))
/** 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
* the provider is ready; the bridge stamps `viewMode: 'chat'` on the tab up front. That
* difference is the only observable signal that the availability guard ran. */
describe('structured chat adoption guard on the launch path', () => {
beforeEach(() => {
@@ -107,10 +84,7 @@ describe('structured chat adoption guard on the launch path', () => {
mockCreateTab.mockReturnValue({ id: 'tab-1' })
mockWaitForAgentReady.mockResolvedValue({ ready: true, reason: 'foreground-match' })
mockPasteDraftWhenAgentReady.mockResolvedValue(true)
mockCreateStructuredCodexSessionLaunchIntent.mockImplementation((worktreeId: string) =>
structuredLaunchIntent(worktreeId)
)
mockLaunchStructuredCodexSession.mockResolvedValue('codex-session-1')
mockLaunchStructuredAgentSession.mockResolvedValue('codex-session-1')
mockRefreshLocalStructuredSessionTabs.mockResolvedValue([
{
worktree: 'wt-1',
@@ -118,10 +92,12 @@ describe('structured chat adoption guard on the launch path', () => {
}
])
mockToastError.mockReset()
store.settings.experimentalNativeChat = true
store.settings.experimentalStructuredNativeChat = true
store.settings.openAgentTabsInChatByDefault = true
})
it('takes the structured path when the chat-default view is selected', async () => {
it('requires the Chat UI default together with the updated structured-chat toggle', async () => {
const { launchAgentInNewTab, shouldQueueTerminalFocusAfterMenuClose } =
await import('./launch-agent-in-new-tab')
@@ -133,55 +109,111 @@ describe('structured chat adoption guard on the launch path', () => {
focusAfterMenuClose: 'structured-session'
})
expect(shouldQueueTerminalFocusAfterMenuClose(result!)).toBe(false)
expect(mockCreateStructuredCodexSessionLaunchIntent).toHaveBeenCalledWith('wt-1')
expect(mockLaunchStructuredCodexSession).toHaveBeenCalledWith(
expect.objectContaining({ worktreeId: 'wt-1' })
)
expect(mockLaunchStructuredAgentSession).toHaveBeenCalledWith('wt-1', 'codex')
expect(mockCreateTab).not.toHaveBeenCalled()
expect(mockWaitForAgentReady).not.toHaveBeenCalled()
})
/** 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 () => {
store.settings.openAgentTabsInChatByDefault = false
it('opens an unprompted local Claude launch as a provider-explicit structured session', async () => {
mockLaunchStructuredAgentSession.mockResolvedValueOnce('claude-session-1')
mockRefreshLocalStructuredSessionTabs.mockResolvedValueOnce([
{
worktree: 'wt-1',
tabs: [{ type: 'agent-session', sessionId: 'claude-session-1' }]
}
])
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
const result = launchAgentInNewTab({ agent: 'claude', worktreeId: 'wt-1' })
expect(result?.tabId).toBe('tab-1')
expect(mockLaunchStructuredCodexSession).not.toHaveBeenCalled()
expect(mockCreateTab).toHaveBeenCalledWith(
'wt-1',
undefined,
undefined,
expect.objectContaining({ launchAgent: 'codex' })
)
expect(result).toMatchObject({
tabId: null,
pasteDraftAfterLaunch: false,
focusAfterMenuClose: 'structured-session'
})
expect(mockLaunchStructuredAgentSession).toHaveBeenCalledWith('wt-1', 'claude')
expect(mockCreateTab).not.toHaveBeenCalled()
})
it('surfaces a direct structured launch failure instead of silently doing nothing', async () => {
const { StructuredAgentSessionCreateRefusalError } =
await import('./launch-structured-codex-session')
mockLaunchStructuredCodexSession.mockRejectedValueOnce(
new StructuredAgentSessionCreateRefusalError('provider unavailable')
it('does not coalesce concurrent Codex and Claude launches in one worktree', async () => {
const pendingLaunches = new Map<string, (sessionId: string) => void>()
mockRefreshLocalStructuredSessionTabs.mockResolvedValue([
{
worktree: 'wt-1',
tabs: [
{ type: 'agent-session', sessionId: 'codex-session-1' },
{ type: 'agent-session', sessionId: 'claude-session-1' }
]
}
])
mockLaunchStructuredAgentSession.mockImplementation(
(_worktreeId: string, agent: string) =>
new Promise<string>((resolve) => pendingLaunches.set(agent, resolve))
)
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
launchAgentInNewTab({ agent: 'claude', worktreeId: 'wt-1' })
expect(mockLaunchStructuredAgentSession).toHaveBeenCalledTimes(2)
expect(mockLaunchStructuredAgentSession).toHaveBeenNthCalledWith(1, 'wt-1', 'codex')
expect(mockLaunchStructuredAgentSession).toHaveBeenNthCalledWith(2, 'wt-1', 'claude')
pendingLaunches.get('codex')?.('codex-session-1')
pendingLaunches.get('claude')?.('claude-session-1')
await vi.waitFor(() => expect(mockRefreshLocalStructuredSessionTabs).toHaveBeenCalledTimes(2))
})
it.each(['claude', 'codex'] as const)(
'keeps %s terminal-backed when the parent setting is off and child setting is on',
async (agent) => {
store.settings.experimentalNativeChat = false
store.settings.experimentalStructuredNativeChat = true
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({ agent, worktreeId: 'wt-1' })
expect(result?.tabId).toBe('tab-1')
expect(mockCreateTab).toHaveBeenCalledWith(
'wt-1',
undefined,
undefined,
expect.objectContaining({ launchAgent: agent })
)
expect(mockLaunchStructuredAgentSession).not.toHaveBeenCalled()
}
)
it('surfaces a direct structured launch failure instead of silently doing nothing', async () => {
mockLaunchStructuredAgentSession.mockRejectedValueOnce(new Error('provider unavailable'))
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
expect(result).toMatchObject({ tabId: null, pasteDraftAfterLaunch: false })
await vi.waitFor(() =>
expect(mockToastError).toHaveBeenCalledWith(
'Could not open Codex chat',
expect.objectContaining({ description: 'provider unavailable' })
expect.objectContaining({
description: 'provider unavailable',
action: expect.objectContaining({ label: 'Open terminal agent' })
})
)
)
expect(mockCreateTab).not.toHaveBeenCalled()
const action = mockToastError.mock.calls[0]?.[1]?.action as { onClick: () => void } | undefined
action?.onClick()
expect(mockCreateTab).toHaveBeenCalledWith(
'wt-1',
undefined,
undefined,
expect.objectContaining({ launchAgent: 'codex' })
)
})
it('coalesces repeated structured launches for one worktree while the host is starting', async () => {
let resolveLaunch!: (sessionId: string) => void
mockLaunchStructuredCodexSession.mockImplementationOnce(
mockLaunchStructuredAgentSession.mockImplementationOnce(
() => new Promise<string>((resolve) => (resolveLaunch = resolve))
)
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
@@ -191,7 +223,7 @@ describe('structured chat adoption guard on the launch path', () => {
expect(first).toMatchObject({ focusAfterMenuClose: 'structured-session' })
expect(second).toMatchObject({ focusAfterMenuClose: 'structured-session' })
expect(mockLaunchStructuredCodexSession).toHaveBeenCalledTimes(1)
expect(mockLaunchStructuredAgentSession).toHaveBeenCalledTimes(1)
resolveLaunch('codex-session-1')
})
@@ -200,7 +232,7 @@ describe('structured chat adoption guard on the launch path', () => {
mockRefreshLocalStructuredSessionTabs.mockImplementationOnce(
() => new Promise<unknown[]>((resolve) => (resolveRefresh = resolve))
)
mockLaunchStructuredCodexSession.mockResolvedValue('codex-session-1')
mockLaunchStructuredAgentSession.mockResolvedValue('codex-session-1')
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
@@ -208,7 +240,7 @@ describe('structured chat adoption guard on the launch path', () => {
launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
expect(mockLaunchStructuredCodexSession).toHaveBeenCalledTimes(1)
expect(mockLaunchStructuredAgentSession).toHaveBeenCalledTimes(1)
resolveRefresh([
{ worktree: 'wt-1', tabs: [{ type: 'agent-session', sessionId: 'codex-session-1' }] }
])
@@ -216,44 +248,33 @@ describe('structured chat adoption guard on the launch path', () => {
})
it('does not create a sibling when post-create visibility proof is unknown', async () => {
const firstIntent = structuredLaunchIntent('wt-1', 'codex-session-1')
const secondIntent = structuredLaunchIntent('wt-1', 'codex-session-2')
mockCreateStructuredCodexSessionLaunchIntent
.mockReturnValueOnce(firstIntent)
.mockReturnValueOnce(secondIntent)
mockLaunchStructuredCodexSession
.mockResolvedValueOnce(firstIntent.sessionId)
.mockRejectedValueOnce(new Error('response lost'))
.mockResolvedValueOnce(secondIntent.sessionId)
mockLaunchStructuredAgentSession.mockResolvedValue('codex-session-1')
mockRefreshLocalStructuredSessionTabs
.mockRejectedValueOnce(new Error('inventory unavailable'))
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{ worktree: 'wt-1', tabs: [{ type: 'agent-session', sessionId: 'codex-session-1' }] }
])
.mockResolvedValueOnce([
{ worktree: 'wt-1', tabs: [{ type: 'agent-session', sessionId: 'codex-session-2' }] }
])
mockLaunchStructuredAgentSession
.mockResolvedValueOnce('codex-session-1')
.mockResolvedValueOnce('codex-session-2')
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
await vi.waitFor(() => expect(mockToastError).toHaveBeenCalledTimes(1))
launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
await vi.waitFor(() => expect(mockRefreshLocalStructuredSessionTabs).toHaveBeenCalledTimes(3))
await vi.waitFor(() => expect(mockRefreshLocalStructuredSessionTabs).toHaveBeenCalledTimes(2))
expect(mockCreateStructuredCodexSessionLaunchIntent).toHaveBeenCalledTimes(1)
expect(mockLaunchStructuredCodexSession).toHaveBeenCalledTimes(2)
expect(mockLaunchStructuredCodexSession.mock.calls[0]?.[0]).toBe(firstIntent)
expect(mockLaunchStructuredCodexSession.mock.calls[1]?.[0]).toBe(firstIntent)
expect(mockLaunchStructuredAgentSession).toHaveBeenCalledTimes(1)
await new Promise((resolve) => setTimeout(resolve, 0))
// A successful retry must release the reservation so a later launch can start normally.
launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
await vi.waitFor(() => expect(mockRefreshLocalStructuredSessionTabs).toHaveBeenCalledTimes(4))
expect(mockCreateStructuredCodexSessionLaunchIntent).toHaveBeenCalledTimes(2)
expect(mockLaunchStructuredCodexSession).toHaveBeenCalledTimes(3)
expect(mockLaunchStructuredCodexSession.mock.calls[2]?.[0]).toBe(secondIntent)
await vi.waitFor(() => expect(mockRefreshLocalStructuredSessionTabs).toHaveBeenCalledTimes(3))
expect(mockLaunchStructuredAgentSession).toHaveBeenCalledTimes(2)
})
it('keeps prompted Codex on the ordinary terminal launch path', async () => {
@@ -276,6 +297,25 @@ describe('structured chat adoption guard on the launch path', () => {
expect(mockSetTabViewMode).not.toHaveBeenCalled()
})
it('keeps prompted Claude on the ordinary terminal launch path', async () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({
agent: 'claude',
worktreeId: 'wt-1',
prompt: 'start this task'
})
expect(result?.tabId).toBe('tab-1')
expect(mockCreateTab).toHaveBeenCalledWith(
'wt-1',
undefined,
undefined,
expect.objectContaining({ launchAgent: 'claude' })
)
expect(mockLaunchStructuredAgentSession).not.toHaveBeenCalled()
})
it('shows rejected prompt delivery in chat after Codex becomes ready', async () => {
const error = new Error('prompt transport rejected')
mockPasteDraftWhenAgentReady.mockRejectedValue(error)
@@ -293,29 +333,32 @@ describe('structured chat adoption guard on the launch path', () => {
expect(mockSetTabViewMode).not.toHaveBeenCalled()
})
it('keeps an SSH Codex tab on the bridge', async () => {
it.each(['claude', 'codex'] as const)('keeps an SSH %s tab on the bridge', async (agent) => {
store.repos = [{ id: 'repo-1', connectionId: 'ssh-a', path: '/repo' }]
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
launchAgentInNewTab({ agent, worktreeId: 'wt-1' })
expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, {
launchAgent: 'codex',
launchAgent: agent,
viewMode: 'chat'
})
expect(mockWaitForAgentReady).not.toHaveBeenCalled()
})
it('keeps a runtime-paired Codex tab on the bridge', async () => {
store.repos = [{ id: 'repo-1', connectionId: 'runtime-ssh-a', path: '/repo' }]
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
it.each(['claude', 'codex'] as const)(
'keeps a runtime-paired %s tab on the bridge',
async (agent) => {
store.repos = [{ id: 'repo-1', connectionId: 'runtime-ssh-a', path: '/repo' }]
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
launchAgentInNewTab({ agent, worktreeId: 'wt-1' })
expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, {
launchAgent: 'codex',
viewMode: 'chat'
})
expect(mockWaitForAgentReady).not.toHaveBeenCalled()
})
expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, {
launchAgent: agent,
viewMode: 'chat'
})
expect(mockWaitForAgentReady).not.toHaveBeenCalled()
}
)
})
@@ -0,0 +1,57 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation'
import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle'
import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client'
import { launchStructuredAgentSession } from './launch-structured-agent-session'
vi.mock('@/runtime/structured-agent-session-client', () => ({
callStructuredAgentSession: vi.fn()
}))
describe('structured agent launch', () => {
beforeEach(() => {
vi.mocked(callStructuredAgentSession).mockReset()
})
it.each<AgentSessionHandleProvider>(['claude', 'codex'])(
'creates a %s native session with a provider-explicit host-verifiable launch intent',
async (agent) => {
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 launchStructuredAgentSession('workspace-1', agent)
const params = vi.mocked(callStructuredAgentSession).mock.calls[0]?.[2] as {
envelope: { sessionId: string; payloadFingerprint: string }
worktree: string
agent: AgentSessionHandleProvider
}
expect(sessionId).toMatch(new RegExp(`^${agent}_[A-Za-z0-9_]{36}$`))
expect(callStructuredAgentSession).toHaveBeenCalledWith(
{ kind: 'local' },
'agentSession.create',
expect.objectContaining({ worktree: 'id:workspace-1', agent })
)
expect(params.agent).toBe(agent)
expect(params.envelope.payloadFingerprint).toBe(
structuredAgentSessionPayloadFingerprint({
method: 'agentSession.create',
sessionId: params.envelope.sessionId,
fields: { worktree: 'id:workspace-1', agent }
})
)
}
)
})
@@ -0,0 +1,68 @@
import type {
AgentSessionAttachResult,
AgentSessionMutationResult
} from '../../../shared/agent-session-wire'
import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle'
import {
createStructuredAgentSessionOperationId,
structuredAgentSessionPayloadFingerprint
} from '../../../shared/structured-agent-session-mutation'
import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client'
import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector'
import { useAppStore } from '@/store'
import {
clearWebSessionFocusIntentIfMatches,
recordWebSessionFocusIntent,
resolveWebSessionVisibleTabId
} from '@/runtime/web-session-focus-intent'
import { LOCAL_STRUCTURED_SESSION_OWNER } from '@/runtime/local-structured-session-tabs-sync'
function newSessionId(agent: AgentSessionHandleProvider): string {
return `${agent}_${crypto.randomUUID().replaceAll('-', '_')}`
}
export async function launchStructuredAgentSession(
worktreeId: string,
agent: AgentSessionHandleProvider
): Promise<string> {
const sessionId = newSessionId(agent)
const fields = { worktree: toRuntimeWorktreeSelector(worktreeId), agent }
const state = useAppStore.getState()
recordWebSessionFocusIntent(
{ environmentId: LOCAL_STRUCTURED_SESSION_OWNER },
worktreeId,
`agent-session:${sessionId}`,
undefined,
resolveWebSessionVisibleTabId(state, worktreeId)
)
try {
const result = await callStructuredAgentSession<
AgentSessionMutationResult<AgentSessionAttachResult>
>({ 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
} catch (error) {
// A concurrent create may have replaced this intent. Only clear the failed
// session's slot; never erase a later successful create's focus request.
clearWebSessionFocusIntentIfMatches(
{ environmentId: LOCAL_STRUCTURED_SESSION_OWNER },
worktreeId,
`agent-session:${sessionId}`
)
throw error
}
}
@@ -1,136 +1,221 @@
import { toast } from 'sonner'
import { launchStructuredAgentSession } from '@/lib/launch-structured-agent-session'
import { refreshLocalStructuredSessionTabs } from '@/runtime/local-structured-session-tabs-sync'
import { translate } from '@/i18n/i18n'
import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle'
import { agentSessionProviderLabel } from '../../../shared/agent-session-provider-label'
import {
createStructuredCodexSessionLaunchIntent,
launchStructuredCodexSession,
StructuredAgentSessionCreateRefusalError,
type StructuredAgentSessionLaunchIntent
} from '@/lib/launch-structured-codex-session'
import { refreshLocalStructuredSessionTabs } from '@/runtime/local-structured-session-tabs-sync'
import { translate } from '@/i18n/i18n'
type StructuredLaunchState = {
intent: StructuredAgentSessionLaunchIntent
promise: Promise<string>
sessionId?: string
visibilityUnknown: boolean
}
const pendingStructuredLaunchesByWorktree = new Map<string, StructuredLaunchState>()
const pendingStructuredLaunchesByKey = new Map<string, StructuredLaunchState>()
function structuredLaunchKey(worktreeId: string, agent: AgentSessionHandleProvider): string {
return JSON.stringify([worktreeId, agent])
}
function trackLaunchSettlement(
worktreeId: string,
key: string,
state: StructuredLaunchState,
promise: Promise<string>
): void {
void promise.then(
() => {
if (
state.promise === promise &&
pendingStructuredLaunchesByWorktree.get(worktreeId) === state
) {
pendingStructuredLaunchesByWorktree.delete(worktreeId)
if (state.promise === promise && pendingStructuredLaunchesByKey.get(key) === state) {
pendingStructuredLaunchesByKey.delete(key)
}
},
() => {
if (
state.promise === promise &&
!state.visibilityUnknown &&
pendingStructuredLaunchesByWorktree.get(worktreeId) === state
pendingStructuredLaunchesByKey.get(key) === state
) {
pendingStructuredLaunchesByWorktree.delete(worktreeId)
pendingStructuredLaunchesByKey.delete(key)
}
}
)
}
async function verifyPublishedSession(intent: StructuredAgentSessionLaunchIntent): Promise<string> {
async function verifyPublishedSession(worktreeId: string, sessionId: string): Promise<string> {
const snapshots = await refreshLocalStructuredSessionTabs()
const published = snapshots.some(
(snapshot) =>
snapshot.worktree === intent.worktreeId &&
snapshot.tabs.some(
(tab) => tab.type === 'agent-session' && tab.sessionId === intent.sessionId
)
snapshot.worktree === worktreeId &&
snapshot.tabs.some((tab) => tab.type === 'agent-session' && tab.sessionId === sessionId)
)
if (!published) {
throw new Error('structured session tab publication unavailable')
}
return sessionId
}
function launchStructuredAgentSessionOnce(
worktreeId: string,
agent: AgentSessionHandleProvider
): Promise<string> {
const key = structuredLaunchKey(worktreeId, agent)
const existing = pendingStructuredLaunchesByKey.get(key)
if (existing) {
if (existing.visibilityUnknown && existing.sessionId) {
existing.visibilityUnknown = false
existing.promise = verifyPublishedSession(worktreeId, existing.sessionId).catch((error) => {
existing.visibilityUnknown = true
throw error
})
trackLaunchSettlement(key, existing, existing.promise)
}
return existing.promise
}
// Keep the single-flight reservation through the inventory refresh. The
// provider create can resolve before its published tab reaches the
// renderer; clearing here lets a rapid second click create a sibling chat.
const state: StructuredLaunchState = {
promise: Promise.resolve(''),
visibilityUnknown: false
}
state.promise = launchStructuredAgentSession(worktreeId, agent)
.then((sessionId) => {
state.sessionId = sessionId
return verifyPublishedSession(worktreeId, sessionId)
})
.catch((error) => {
if (state.sessionId) {
state.visibilityUnknown = true
}
throw error
})
pendingStructuredLaunchesByKey.set(key, state)
trackLaunchSettlement(key, state, state.promise)
return state.promise
}
export function startStructuredAgentLaunch(
worktreeId: string,
agent: AgentSessionHandleProvider,
openTerminalAgent: () => void
): void {
const alreadyOpening = pendingStructuredLaunchesByKey.has(structuredLaunchKey(worktreeId, agent))
const providerLabel = agentSessionProviderLabel(agent)
toast.message(
translate(
alreadyOpening
? 'auto.components.nativeChat.structuredSessionLaunchInProgress'
: 'auto.components.nativeChat.structuredSessionLaunchStarting',
alreadyOpening
? '{{providerLabel}} chat is still opening'
: 'Opening {{providerLabel}} chat…',
{ providerLabel }
)
)
void launchStructuredAgentSessionOnce(worktreeId, agent).catch((error) => {
toast.error(
translate(
'components.native-chat.structuredSessionLaunchFailed',
'Could not open {{providerLabel}} chat',
{ providerLabel }
),
{
description: error instanceof Error ? error.message : String(error),
action: {
label: translate(
'components.native-chat.structuredSessionLaunchTerminalAction',
'Open terminal agent'
),
onClick: openTerminalAgent
}
}
)
})
}
// Compatibility entrypoint retained for callers that only expose Codex; new callers should use
// startStructuredAgentLaunch so the provider is explicit.
const pendingCodexLaunches = new Map<
string,
{
intent: StructuredAgentSessionLaunchIntent
promise: Promise<string>
visibilityUnknown: boolean
}
>()
async function verifyCodexPublished(intent: StructuredAgentSessionLaunchIntent): Promise<string> {
const snapshots = await refreshLocalStructuredSessionTabs()
if (
!snapshots.some(
(snapshot) =>
snapshot.worktree === intent.worktreeId &&
snapshot.tabs.some(
(tab) => tab.type === 'agent-session' && tab.sessionId === intent.sessionId
)
)
) {
throw new Error('structured session tab publication unavailable')
}
return intent.sessionId
}
async function retrySameIntent(state: StructuredLaunchState, priorError: unknown): Promise<string> {
try {
await launchStructuredCodexSession(state.intent)
return await verifyPublishedSession(state.intent)
} catch (error) {
if (error instanceof StructuredAgentSessionCreateRefusalError) {
throw error
}
try {
return await verifyPublishedSession(state.intent)
} catch {
state.visibilityUnknown = true
throw error ?? priorError
}
}
}
async function launchAndReconcile(state: StructuredLaunchState): Promise<string> {
try {
await launchStructuredCodexSession(state.intent)
} catch (error) {
if (error instanceof StructuredAgentSessionCreateRefusalError) {
throw error
}
try {
return await verifyPublishedSession(state.intent)
} catch {
return retrySameIntent(state, error)
}
}
try {
return await verifyPublishedSession(state.intent)
} catch (error) {
return retrySameIntent(state, error)
}
}
async function reconcileUnknownLaunch(state: StructuredLaunchState): Promise<string> {
state.visibilityUnknown = false
try {
return await verifyPublishedSession(state.intent)
} catch (error) {
return retrySameIntent(state, error)
}
}
function launchStructuredCodexSessionOnce(worktreeId: string): Promise<string> {
const existing = pendingStructuredLaunchesByWorktree.get(worktreeId)
export function startStructuredCodexLaunch(worktreeId: string): void {
const existing = pendingCodexLaunches.get(worktreeId)
if (existing) {
if (existing.visibilityUnknown) {
existing.promise = reconcileUnknownLaunch(existing)
trackLaunchSettlement(worktreeId, existing, existing.promise)
existing.visibilityUnknown = false
existing.promise = verifyCodexPublished(existing.intent).catch(async (error) => {
try {
await launchStructuredCodexSession(existing.intent)
return await verifyCodexPublished(existing.intent)
} catch {
existing.visibilityUnknown = true
throw error
}
})
}
return existing.promise
return
}
const state: StructuredLaunchState = {
intent: createStructuredCodexSessionLaunchIntent(worktreeId),
promise: Promise.resolve(''),
visibilityUnknown: false
}
state.promise = launchAndReconcile(state)
pendingStructuredLaunchesByWorktree.set(worktreeId, state)
trackLaunchSettlement(worktreeId, state, state.promise)
return state.promise
}
export function startStructuredCodexLaunch(worktreeId: string): void {
void launchStructuredCodexSessionOnce(worktreeId).catch((error) => {
toast.error(
translate(
'components.native-chat.structuredSessionLaunchFailed',
'Could not open Codex chat'
),
{ description: error instanceof Error ? error.message : String(error) }
)
})
const intent = createStructuredCodexSessionLaunchIntent(worktreeId)
const state = { intent, promise: Promise.resolve(''), visibilityUnknown: false }
state.promise = launchStructuredCodexSession(intent)
.then(() => verifyCodexPublished(intent))
.catch(async (error) => {
if (error instanceof StructuredAgentSessionCreateRefusalError) {
throw error
}
try {
return await verifyCodexPublished(intent)
} catch {
try {
await launchStructuredCodexSession(intent)
return await verifyCodexPublished(intent)
} catch {
state.visibilityUnknown = true
throw error
}
}
})
pendingCodexLaunches.set(worktreeId, state)
void state.promise.then(
() => pendingCodexLaunches.delete(worktreeId),
(error) => {
if (!state.visibilityUnknown) {
pendingCodexLaunches.delete(worktreeId)
}
toast.error(
translate(
'components.native-chat.structuredSessionLaunchFailed',
'Could not open Codex chat'
),
{ description: error instanceof Error ? error.message : String(error) }
)
}
)
}
@@ -86,6 +86,36 @@ describe('applyWebSessionTabsSnapshot', () => {
).toBe(agentTab.id)
})
it('labels an untitled mirrored Claude session as Claude chat', () => {
const agentTab = {
type: 'agent-session' as const,
id: 'agent-session:claude-session-1',
title: ' ',
sessionId: 'claude-session-1',
agent: 'claude' as const,
isActive: true
}
const patch = applyWebSessionTabsSnapshot(
makeState(),
makeSnapshot([agentTab], {
activeTabId: agentTab.id,
activeTabType: 'agent-session',
tabGroups: [
{
id: 'host-group-1',
activeTabId: agentTab.id,
tabOrder: [agentTab.id]
}
]
}),
ENV,
NOW
)
expect(patch.unifiedTabsByWorktree?.[WT]?.[0]?.label).toBe('Claude Chat')
expect(patch.unifiedTabsByWorktree?.[WT]?.[0]?.agentSessionAgent).toBe('claude')
})
it('removes a restored structured tab when the host publishes no structured sessions', () => {
const structuredTab: Tab = {
id: 'structured-agent-session-session-1',
@@ -13,6 +13,7 @@ import {
import { agentEntryCompletionAt } from '../../../shared/agent-completion-time'
import { normalizeTurnCompletedAtField } from '../../../shared/agent-status-field-normalization'
import { agentProviderSessionsEqual } from '../../../shared/agent-session-resume'
import { agentSessionChatLabel } from '../../../shared/agent-session-provider-label'
import type {
RuntimeMobileSessionTabsResult,
RuntimeMobileSessionAgentTab,
@@ -1241,7 +1242,7 @@ function buildMirroredAgentTabs(
worktreeId: snapshot.worktree,
contentType: 'agent-session',
agentSessionAgent: tab.agent,
label: tab.title.trim() || 'Codex Chat',
label: tab.title.trim() || agentSessionChatLabel(tab.agent),
customLabel: null,
color: tab.color !== undefined ? tab.color : (existing?.color ?? null),
sortOrder: sortOffset + index,
@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest'
import { agentSessionChatLabel, agentSessionProviderLabel } from './agent-session-provider-label'
describe('agent session provider labels', () => {
it.each([
['claude', 'Claude', 'Claude Chat'],
['codex', 'Codex', 'Codex Chat']
] as const)('labels %s sessions', (provider, providerLabel, chatLabel) => {
expect(agentSessionProviderLabel(provider)).toBe(providerLabel)
expect(agentSessionChatLabel(provider)).toBe(chatLabel)
})
})
@@ -0,0 +1,9 @@
import type { AgentSessionHandleProvider } from './agent-session-provider-handle'
export function agentSessionProviderLabel(provider: AgentSessionHandleProvider): string {
return provider === 'claude' ? 'Claude' : 'Codex'
}
export function agentSessionChatLabel(provider: AgentSessionHandleProvider): string {
return `${agentSessionProviderLabel(provider)} Chat`
}
@@ -91,7 +91,7 @@ export type RuntimeMobileSessionAgentTab = {
id: string
title: string
sessionId: string
agent: 'codex'
agent: 'claude' | 'codex'
color?: string | null
isPinned?: boolean
isActive: boolean