diff --git a/src/main/claude/claude-structured-dispatch.test.ts b/src/main/claude/claude-structured-dispatch.test.ts index 50efdcba615..9a1fe2a78ef 100644 --- a/src/main/claude/claude-structured-dispatch.test.ts +++ b/src/main/claude/claude-structured-dispatch.test.ts @@ -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 { diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index 08080697477..a3e5a71a541 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -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 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 } diff --git a/src/main/claude/claude-structured-image-content.test.ts b/src/main/claude/claude-structured-image-content.test.ts new file mode 100644 index 00000000000..4df264ec04d --- /dev/null +++ b/src/main/claude/claude-structured-image-content.test.ts @@ -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, '') + + 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() + }) +}) diff --git a/src/main/claude/claude-structured-image-content.ts b/src/main/claude/claude-structured-image-content.ts new file mode 100644 index 00000000000..1a221259222 --- /dev/null +++ b/src/main/claude/claude-structured-image-content.ts @@ -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 = { + '.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 = readFile +): Promise> { + 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') } + } +} diff --git a/src/main/claude/claude-structured-launch-resolution.test.ts b/src/main/claude/claude-structured-launch-resolution.test.ts index b4908f7685a..2198bda9c3a 100644 --- a/src/main/claude/claude-structured-launch-resolution.test.ts +++ b/src/main/claude/claude-structured-launch-resolution.test.ts @@ -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' diff --git a/src/main/claude/claude-structured-prompt-items.ts b/src/main/claude/claude-structured-prompt-items.ts index 2dacbffd826..0df81afc962 100644 --- a/src/main/claude/claude-structured-prompt-items.ts +++ b/src/main/claude/claude-structured-prompt-items.ts @@ -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 = { allow: 'Allow', @@ -74,7 +74,7 @@ function questionOptions( return label ? [ { - id: encodeClaudeQuestionOptionId(questionAddress, `choice-${index + 1}`), + id: encodeAgentSessionQuestionOptionId(questionAddress, `choice-${index + 1}`), label } ] diff --git a/src/main/claude/claude-structured-prompt-replies.ts b/src/main/claude/claude-structured-prompt-replies.ts index 269cd6e18a3..824a93b0322 100644 --- a/src/main/claude/claude-structured-prompt-replies.ts +++ b/src/main/claude/claude-structured-prompt-replies.ts @@ -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, 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() @@ -194,7 +180,7 @@ function questionResponse( optionId: string, boundQuestionId?: string ): Record | null { - const decoded = decodeClaudeQuestionOptionId(optionId) + const decoded = decodeAgentSessionQuestionOptionId(optionId) const decodedQuestionId = decoded ? (questionIdFromAddress(prompt, decoded.questionId) ?? (prompt.questionIds.includes(decoded.questionId) ? decoded.questionId : null)) diff --git a/src/main/native-chat/agent-session-wire/agent-session-question-option-id.ts b/src/main/native-chat/agent-session-wire/agent-session-question-option-id.ts new file mode 100644 index 00000000000..1f4f3686208 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/agent-session-question-option-id.ts @@ -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 + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts index 9d16e19a7f7..c922b963250 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts @@ -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 { @@ -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 && diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 0d5ccf2be6e..ba9629e57c3 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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 { 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 { @@ -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 diff --git a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts index 6a9372ed2c1..bcda64041cd 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts @@ -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() diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index c698cc7229c..7a68f6afebb 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -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 () => { diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 8ea85aa0e87..23c133f1b79 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -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 }) } diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index ef40bd7866d..5eba15c10d0 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -71,7 +71,9 @@ export type StructuredAgentSessionRuntimeDeps = { readClaudeProcessStartTime?: ClaudeStructuredSessionAdapterDeps['readProcessStartTime'] resolveLaunchArgs?: (provider: AgentSessionRecord['provider']) => Promise | string[] resolveLaunchEnv?: () => Promise - resolveLaunchEnvOverlay?: () => Promise> | Record + resolveLaunchEnvOverlay?: ( + provider: AgentSessionRecord['provider'] + ) => Promise> | Record resolveEnvironment?: () => Promise resolveCodexOverrides?: () => NodeJS.ProcessEnv resolveClaudeOverrides?: () => NodeJS.ProcessEnv @@ -134,7 +136,7 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise => ({ ...(await bootEnvironment), ...(await deps.resolveLaunchEnv?.()), - ...(await deps.resolveLaunchEnvOverlay?.()), + ...(await deps.resolveLaunchEnvOverlay?.(provider)), ...(provider === 'claude' ? deps.resolveClaudeOverrides?.() : deps.resolveCodexOverrides?.()) }) const store = await AgentSessionRecordStore.open({ diff --git a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx index 93c4b1c899d..b5c185f26cb 100644 --- a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx +++ b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx @@ -126,7 +126,7 @@ export function NativeChatExperimentalSetting({

{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.' )}

diff --git a/src/renderer/src/components/tab-bar/tab-bar-item-surface.client-hosted-active-state.test.tsx b/src/renderer/src/components/tab-bar/tab-bar-item-surface.client-hosted-active-state.test.tsx index 2cdff8b1931..b96bc112393 100644 --- a/src/renderer/src/components/tab-bar/tab-bar-item-surface.client-hosted-active-state.test.tsx +++ b/src/renderer/src/components/tab-bar/tab-bar-item-surface.client-hosted-active-state.test.tsx @@ -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. diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index ac13f577d36..1780385bd11 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -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(

- {structuredSessionId && structuredChatAgent ? ( - + {structuredSessionId ? ( + structuredChatAgent ? ( + + ) : null ) : ( { + 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') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/native-chat-render-agent.ts b/src/renderer/src/components/terminal-pane/native-chat-render-agent.ts new file mode 100644 index 00000000000..813c5ee9c46 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/native-chat-render-agent.ts @@ -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 +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 84d6659ed96..6f537adfe80 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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" }, 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 b6cbbb736d8..103a2583871 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -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, 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 c9d63359267..26dbb8b7223 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,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 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((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((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((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() + } + ) }) diff --git a/src/renderer/src/lib/launch-structured-agent-session.test.ts b/src/renderer/src/lib/launch-structured-agent-session.test.ts new file mode 100644 index 00000000000..d54772f03fa --- /dev/null +++ b/src/renderer/src/lib/launch-structured-agent-session.test.ts @@ -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(['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 } + }) + ) + } + ) +}) diff --git a/src/renderer/src/lib/launch-structured-agent-session.ts b/src/renderer/src/lib/launch-structured-agent-session.ts new file mode 100644 index 00000000000..8d695311319 --- /dev/null +++ b/src/renderer/src/lib/launch-structured-agent-session.ts @@ -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 { + 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 + >({ 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 + } +} diff --git a/src/renderer/src/lib/structured-agent-session-launch.ts b/src/renderer/src/lib/structured-agent-session-launch.ts index f826ab58cee..01a4cd386ac 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.ts @@ -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 + sessionId?: string visibilityUnknown: boolean } -const pendingStructuredLaunchesByWorktree = new Map() +const pendingStructuredLaunchesByKey = new Map() + +function structuredLaunchKey(worktreeId: string, agent: AgentSessionHandleProvider): string { + return JSON.stringify([worktreeId, agent]) +} function trackLaunchSettlement( - worktreeId: string, + key: string, state: StructuredLaunchState, promise: Promise ): 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 { +async function verifyPublishedSession(worktreeId: string, sessionId: string): Promise { 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 { + 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 + visibilityUnknown: boolean + } +>() + +async function verifyCodexPublished(intent: StructuredAgentSessionLaunchIntent): Promise { + 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 { - 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 { - 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 { - state.visibilityUnknown = false - try { - return await verifyPublishedSession(state.intent) - } catch (error) { - return retrySameIntent(state, error) - } -} - -function launchStructuredCodexSessionOnce(worktreeId: string): Promise { - 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) } + ) + } + ) } diff --git a/src/renderer/src/runtime/web-session-tabs-sync.test.ts b/src/renderer/src/runtime/web-session-tabs-sync.test.ts index 4b9633410a6..22e974a2fa3 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.test.ts @@ -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', diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 4d7070c8368..712dfd32a55 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -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, diff --git a/src/shared/agent-session-provider-label.test.ts b/src/shared/agent-session-provider-label.test.ts new file mode 100644 index 00000000000..5bd0001688e --- /dev/null +++ b/src/shared/agent-session-provider-label.test.ts @@ -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) + }) +}) diff --git a/src/shared/agent-session-provider-label.ts b/src/shared/agent-session-provider-label.ts new file mode 100644 index 00000000000..911a8db5a40 --- /dev/null +++ b/src/shared/agent-session-provider-label.ts @@ -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` +} diff --git a/src/shared/runtime-mobile-session-tab-contracts.ts b/src/shared/runtime-mobile-session-tab-contracts.ts index 40f60230218..2f5c256ce31 100644 --- a/src/shared/runtime-mobile-session-tab-contracts.ts +++ b/src/shared/runtime-mobile-session-tab-contracts.ts @@ -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