mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 16:02:03 +00:00
feat(native-chat): support structured Claude sessions
This commit is contained in:
@@ -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 &&
|
||||
|
||||
@@ -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">
|
||||
|
||||
+39
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user