Merge remote-tracking branch 'origin/brennanb2025/claude-nc-s1-renderer' into brennanb2025/claude-nc-s1-integrated

This commit is contained in:
Brennan Benson
2026-08-27 07:44:25 -07:00
19 changed files with 591 additions and 131 deletions
@@ -145,6 +145,39 @@ describe('StructuredAgentSessionStatusBridge', () => {
expect(mocks.setAgentStatus.mock.calls.at(-1)?.[5]?.providerSession?.id).not.toBe('session-1')
})
it('publishes Claude as the structured status agent type', async () => {
mocks.store?.setState({
unifiedTabsByWorktree: {
'wt-1': [{ ...structuredTab, label: 'Claude Chat', agentSessionAgent: 'claude' }]
}
})
mocks.call.mockResolvedValueOnce({
...historyResult,
page: {
...historyResult.page,
items: [
{
itemId: 'item-1',
revision: 1,
sequence: 1,
observedAt: 1,
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }
}
],
window: {
...historyResult.page.window,
newest: { epoch: 'epoch-1', sequence: 1 }
},
liveCursor: { epoch: 'epoch-1', sequence: 1 }
}
})
render(<StructuredAgentSessionStatusBridge />)
await waitFor(() => expect(mocks.setAgentStatus).toHaveBeenCalled())
expect(mocks.setAgentStatus.mock.calls.at(-1)?.[1]?.agentType).toBe('claude')
expect(mocks.setAgentStatus.mock.calls.at(-1)?.[2]).toBe('Claude Chat')
})
it('does not publish a completed status for an untouched session', async () => {
render(<StructuredAgentSessionStatusBridge />)
@@ -240,6 +240,9 @@ describe('ExperimentalPane', () => {
const { root, container } = await renderExperimentalPane({ updateSettings, settings })
expect(container.textContent).toContain('Use updated structured native chat')
expect(container.textContent).toContain(
'Opt in to the host-owned structured Claude and Codex runtimes. Off keeps the existing terminal-backed chat path.'
)
expect(container.textContent).toContain(
'Local macOS and Linux sessions only for now. Windows, WSL, remote, and SSH sessions continue to use terminal chat.'
)
@@ -66,7 +66,7 @@ export function NativeChatExperimentalSetting({
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.ExperimentalPane.nativeChat.structuredCopy',
'Opt in to the host-owned structured Codex runtime. Off keeps the existing terminal-backed chat path.'
'Opt in to the host-owned structured Claude and Codex runtimes. Off keeps the existing terminal-backed chat path.'
)}
</p>
<p className="text-xs text-muted-foreground">
@@ -167,6 +167,45 @@ describe('client-hosted row while a real tab is activated', () => {
)
})
describe('structured session tab provider', () => {
it('passes the Claude label and launch agent to the tab surface', () => {
const item: TabBarItem = {
type: 'agent-session',
id: 'structured-agent-session-claude-1',
unifiedTabId: 'structured-agent-session-claude-1',
isPinned: false,
data: {
id: 'structured-agent-session-claude-1',
entityId: 'claude-1',
groupId: 'group-1',
worktreeId: 'wt-1',
contentType: 'agent-session',
agentSessionAgent: 'claude',
label: 'Claude Chat',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 0
}
}
const [rendered] = renderTabBarItems({
items: [item],
props: makeProps('agent-session'),
runtime: RUNTIME,
dropIndicatorByVisibleId: new Map(),
includeTopTabBorder: true,
activeClientHostedBrowserRowId: null,
togglePinned: () => {}
})
const tab = (rendered as React.ReactElement<{ tab: { title: string; launchAgent?: string } }>)
.props.tab
expect(tab.title).toBe('Claude Chat')
expect(tab.launchAgent).toBe('claude')
})
})
/**
* The fixtures above cannot cover a row kind that does not exist yet, and a new one wired straight
* to its own active-tab selector is exactly how this regressed the first time.
@@ -0,0 +1,106 @@
// @vitest-environment happy-dom
import { cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
activeTab: {
id: 'structured-agent-session-claude-1',
entityId: 'claude-1',
groupId: 'group-1',
worktreeId: 'wt-1',
contentType: 'agent-session' as const,
agentSessionAgent: 'claude' as unknown,
label: 'Claude Chat',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 0
}
}))
vi.mock('../../store', () => ({
useAppStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
rightSidebarOpen: false,
sidebarOpen: false,
groupsByWorktree: { 'wt-1': [{ id: 'group-1' }] }
})
}))
vi.mock('./useTabGroupWorkspaceModel', () => ({
useTabGroupWorkspaceModel: () => ({
activeTab: mocks.activeTab,
agentSessionItems: [],
browserItems: [],
commands: new Proxy({}, { get: () => vi.fn() }),
editorItems: [],
expandedPaneByTabId: {},
groupTabs: [mocks.activeTab],
tabBarOrder: [],
terminalTabs: []
})
}))
vi.mock('@dnd-kit/core', () => ({
useDroppable: () => ({ setNodeRef: vi.fn() })
}))
vi.mock('../tab-bar/TabBar', () => ({ default: () => null }))
vi.mock('../tab-bar/TabBarQuickCommandsButton', () => ({
TabBarQuickCommandsButton: () => null
}))
vi.mock('@/lib/lazy-with-retry', () => ({ lazyWithRetry: () => () => null }))
vi.mock('@/lib/pane-manager/client-hosted-browser-row-state', () => ({
useClientHostedBrowserRows: () => []
}))
vi.mock('../tab-bar/client-hosted-browser-row-strip-placement', () => ({
resolveClientHostedBrowserRowStripGroupId: () => 'group-1'
}))
vi.mock('@/lib/worktree-runtime-owner', () => ({
getExecutionHostIdForWorktree: () => 'local',
getRuntimeEnvironmentIdForWorktree: () => null
}))
vi.mock('@/runtime/runtime-rpc-client', () => ({
getActiveRuntimeTarget: () => ({ kind: 'local' })
}))
vi.mock('../native-chat/NativeChatView', () => ({
default: ({ agent, sessionId }: { agent: string; sessionId: string }) => (
<div data-testid="structured-chat" data-agent={agent} data-session-id={sessionId} />
)
}))
import TabGroupPanel from './TabGroupPanel'
afterEach(cleanup)
function renderPanel(): ReturnType<typeof render> {
return render(
<TabGroupPanel
groupId="group-1"
worktreeId="wt-1"
isVisible
isFocused
hasSplitGroups={false}
touchesRightEdge
touchesLeftEdge
reserveClosedExplorerToggleSpace={false}
reserveCollapsedSidebarHeaderSpace={false}
/>
)
}
describe('TabGroupPanel structured provider rendering', () => {
it('passes Claude to the structured native chat surface', () => {
mocks.activeTab.agentSessionAgent = 'claude'
const view = renderPanel().getByTestId('structured-chat')
expect(view.getAttribute('data-agent')).toBe('claude')
expect(view.getAttribute('data-session-id')).toBe('claude-1')
})
it('renders no structured surface for an unknown provider', () => {
mocks.activeTab.agentSessionAgent = 'gemini'
expect(renderPanel().queryByTestId('structured-chat')).toBeNull()
})
})
@@ -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'
@@ -528,6 +529,9 @@ function TerminalPane(
getCachedUnifiedTerminalTabForWorktree(store.unifiedTabsByWorktree, worktreeId, tabId)
?.agentSessionAgent
)
const validatedStructuredSessionAgent = isAgentSessionHandleProvider(structuredSessionAgent)
? structuredSessionAgent
: null
const isChatViewMode = useAppStore(
(store) =>
getCachedUnifiedTerminalTabForWorktree(store.unifiedTabsByWorktree, worktreeId, tabId)
@@ -639,15 +643,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,
@@ -2981,7 +2988,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 }), [])
// A split can host different agents, so continuation resolves the specific leaf before using tab-wide hints.
const resolveAgentForLeaf = (leafId: string | null): string | null => {
@@ -3136,17 +3147,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
}
+5 -4
View File
@@ -6875,7 +6875,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, remote, and SSH sessions continue to use terminal chat.",
"structuredToggleLabel": "Toggle updated structured native chat"
},
@@ -16064,8 +16064,8 @@
"contextMenu": {
"copy": "Copy"
},
"structuredSessionLaunchStarting": "Opening Codex chat…",
"structuredSessionLaunchInProgress": "Codex chat is still opening"
"structuredSessionLaunchStarting": "Opening {{providerLabel}} chat…",
"structuredSessionLaunchInProgress": "{{providerLabel}} chat is still opening"
},
"orca": {
"profiles": {
@@ -16507,7 +16507,8 @@
"command": "orca orchestration check"
},
"structuredSessionCloseFailed": "Could not close this Codex chat",
"structuredSessionLaunchFailed": "Could not open Codex chat",
"structuredSessionLaunchFailed": "Could not open {{providerLabel}} chat",
"structuredSessionLaunchTerminalAction": "Open terminal agent",
"structuredSessionCloseFailedDescription": "The terminal stayed open so the provider remains recoverable."
},
"tab": {
@@ -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,7 +5,7 @@ const mockSetTabViewMode = vi.fn()
const mockWaitForAgentReady = vi.fn()
const mockPasteDraftWhenAgentReady = vi.fn()
const mockMarkNativeChatLaunchPromptFailed = vi.fn()
const mockLaunchStructuredCodexSession = vi.fn()
const mockLaunchStructuredAgentSession = vi.fn()
const mockRefreshLocalStructuredSessionTabs = vi.fn()
const mockToastError = vi.fn()
@@ -65,8 +65,8 @@ vi.mock('@/runtime/web-runtime-session', () => ({
isWebRuntimeSessionActive: vi.fn(() => false),
isWebTerminalSurfaceTabId: vi.fn(() => false)
}))
vi.mock('@/lib/launch-structured-codex-session', () => ({
launchStructuredCodexSession: mockLaunchStructuredCodexSession
vi.mock('@/lib/launch-structured-agent-session', () => ({
launchStructuredAgentSession: mockLaunchStructuredAgentSession
}))
vi.mock('@/runtime/local-structured-session-tabs-sync', () => ({
refreshLocalStructuredSessionTabs: mockRefreshLocalStructuredSessionTabs,
@@ -74,7 +74,7 @@ vi.mock('@/runtime/local-structured-session-tabs-sync', () => ({
}))
/** 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(() => {
@@ -84,7 +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)
mockLaunchStructuredCodexSession.mockResolvedValue('codex-session-1')
mockLaunchStructuredAgentSession.mockResolvedValue('codex-session-1')
mockRefreshLocalStructuredSessionTabs.mockResolvedValue([
{
worktree: 'wt-1',
@@ -92,6 +92,8 @@ describe('structured chat adoption guard on the launch path', () => {
}
])
mockToastError.mockReset()
store.settings.experimentalNativeChat = true
store.settings.experimentalStructuredNativeChat = true
store.settings.openAgentTabsInChatByDefault = true
})
@@ -108,13 +110,82 @@ describe('structured chat adoption guard on the launch path', () => {
focusAfterMenuClose: 'structured-session'
})
expect(shouldQueueTerminalFocusAfterMenuClose(result!)).toBe(false)
expect(mockLaunchStructuredCodexSession).toHaveBeenCalledWith('wt-1')
expect(mockLaunchStructuredAgentSession).toHaveBeenCalledWith('wt-1', 'codex')
expect(mockCreateTab).not.toHaveBeenCalled()
expect(mockWaitForAgentReady).not.toHaveBeenCalled()
})
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: 'claude', worktreeId: 'wt-1' })
expect(result).toMatchObject({
tabId: null,
pasteDraftAfterLaunch: false,
focusAfterMenuClose: 'structured-session'
})
expect(mockLaunchStructuredAgentSession).toHaveBeenCalledWith('wt-1', 'claude')
expect(mockCreateTab).not.toHaveBeenCalled()
})
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 () => {
mockLaunchStructuredCodexSession.mockRejectedValueOnce(new Error('provider unavailable'))
mockLaunchStructuredAgentSession.mockRejectedValueOnce(new Error('provider unavailable'))
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
@@ -123,15 +194,44 @@ describe('structured chat adoption guard on the launch path', () => {
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('names Claude in its structured launch failure', async () => {
mockLaunchStructuredAgentSession.mockRejectedValueOnce(new Error('provider unavailable'))
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({ agent: 'claude', worktreeId: 'wt-1' })
await vi.waitFor(() =>
expect(mockToastError).toHaveBeenCalledWith(
'Could not open Claude chat',
expect.objectContaining({
description: 'provider unavailable',
action: expect.objectContaining({ label: 'Open terminal agent' })
})
)
)
})
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')
@@ -141,7 +241,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')
})
@@ -150,7 +250,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' })
@@ -158,7 +258,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' }] }
])
@@ -166,7 +266,7 @@ describe('structured chat adoption guard on the launch path', () => {
})
it('does not create a sibling when post-create visibility proof is unknown', async () => {
mockLaunchStructuredCodexSession.mockResolvedValue('codex-session-1')
mockLaunchStructuredAgentSession.mockResolvedValue('codex-session-1')
mockRefreshLocalStructuredSessionTabs
.mockRejectedValueOnce(new Error('inventory unavailable'))
.mockResolvedValueOnce([
@@ -175,7 +275,7 @@ describe('structured chat adoption guard on the launch path', () => {
.mockResolvedValueOnce([
{ worktree: 'wt-1', tabs: [{ type: 'agent-session', sessionId: 'codex-session-2' }] }
])
mockLaunchStructuredCodexSession
mockLaunchStructuredAgentSession
.mockResolvedValueOnce('codex-session-1')
.mockResolvedValueOnce('codex-session-2')
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
@@ -186,13 +286,13 @@ describe('structured chat adoption guard on the launch path', () => {
launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
await vi.waitFor(() => expect(mockRefreshLocalStructuredSessionTabs).toHaveBeenCalledTimes(2))
expect(mockLaunchStructuredCodexSession).toHaveBeenCalledTimes(1)
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(3))
expect(mockLaunchStructuredCodexSession).toHaveBeenCalledTimes(2)
expect(mockLaunchStructuredAgentSession).toHaveBeenCalledTimes(2)
})
it('keeps prompted Codex on the ordinary terminal launch path', async () => {
@@ -215,6 +315,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)
@@ -232,29 +351,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 }
})
)
}
)
})
@@ -2,6 +2,7 @@ import type {
AgentSessionAttachResult,
AgentSessionMutationResult
} from '../../../shared/agent-session-wire'
import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle'
import {
createStructuredAgentSessionOperationId,
structuredAgentSessionPayloadFingerprint
@@ -16,13 +17,13 @@ import {
} from '@/runtime/web-session-focus-intent'
import { LOCAL_STRUCTURED_SESSION_OWNER } from '@/runtime/local-structured-session-tabs-sync'
function newSessionId(agent: 'claude' | 'codex'): string {
function newSessionId(agent: AgentSessionHandleProvider): string {
return `${agent}_${crypto.randomUUID().replaceAll('-', '_')}`
}
export async function launchStructuredAgentSession(
worktreeId: string,
agent: 'claude' | 'codex'
agent: AgentSessionHandleProvider
): Promise<string> {
const sessionId = newSessionId(agent)
const fields = { worktree: toRuntimeWorktreeSelector(worktreeId), agent }
@@ -65,7 +66,3 @@ export async function launchStructuredAgentSession(
throw error
}
}
export function launchStructuredCodexSession(worktreeId: string): Promise<string> {
return launchStructuredAgentSession(worktreeId, 'codex')
}
@@ -1,50 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation'
import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client'
import { launchStructuredCodexSession } from './launch-structured-codex-session'
vi.mock('@/runtime/structured-agent-session-client', () => ({
callStructuredAgentSession: vi.fn()
}))
describe('structured Codex launch', () => {
beforeEach(() => {
vi.mocked(callStructuredAgentSession).mockReset()
})
it('creates a native session with a host-verifiable launch intent', async () => {
vi.mocked(callStructuredAgentSession).mockImplementation(async (_target, _method, params) => ({
ok: true,
replayed: false,
fence: 1,
cursor: { epoch: 'epoch-1', sequence: 0 },
value: {
sessionId: (params as { envelope: { sessionId: string } }).envelope.sessionId,
fence: 1,
snapshot: { cursor: { epoch: 'epoch-1', sequence: 0 }, items: [] },
unconfirmedClientMessageIds: []
}
}))
const sessionId = await launchStructuredCodexSession('workspace-1')
const params = vi.mocked(callStructuredAgentSession).mock.calls[0]?.[2] as {
envelope: { sessionId: string; payloadFingerprint: string }
worktree: string
agent: 'codex'
}
expect(sessionId).toMatch(/^codex_[A-Za-z0-9_]{36}$/)
expect(callStructuredAgentSession).toHaveBeenCalledWith(
{ kind: 'local' },
'agentSession.create',
expect.objectContaining({ worktree: 'id:workspace-1', agent: 'codex' })
)
expect(params.envelope.payloadFingerprint).toBe(
structuredAgentSessionPayloadFingerprint({
method: 'agentSession.create',
sessionId: params.envelope.sessionId,
fields: { worktree: 'id:workspace-1', agent: 'codex' }
})
)
})
})
@@ -1,7 +1,9 @@
import { toast } from 'sonner'
import { launchStructuredCodexSession } from '@/lib/launch-structured-codex-session'
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'
type StructuredLaunchState = {
promise: Promise<string>
@@ -9,29 +11,30 @@ type StructuredLaunchState = {
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)
}
}
)
@@ -50,8 +53,12 @@ async function verifyPublishedSession(worktreeId: string, sessionId: string): Pr
return sessionId
}
function launchStructuredCodexSessionOnce(worktreeId: string): Promise<string> {
const existing = pendingStructuredLaunchesByWorktree.get(worktreeId)
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
@@ -59,7 +66,7 @@ function launchStructuredCodexSessionOnce(worktreeId: string): Promise<string> {
existing.visibilityUnknown = true
throw error
})
trackLaunchSettlement(worktreeId, existing, existing.promise)
trackLaunchSettlement(key, existing, existing.promise)
}
return existing.promise
}
@@ -70,7 +77,7 @@ function launchStructuredCodexSessionOnce(worktreeId: string): Promise<string> {
promise: Promise.resolve(''),
visibilityUnknown: false
}
state.promise = launchStructuredCodexSession(worktreeId)
state.promise = launchStructuredAgentSession(worktreeId, agent)
.then((sessionId) => {
state.sessionId = sessionId
return verifyPublishedSession(worktreeId, sessionId)
@@ -81,28 +88,46 @@ function launchStructuredCodexSessionOnce(worktreeId: string): Promise<string> {
}
throw error
})
pendingStructuredLaunchesByWorktree.set(worktreeId, state)
trackLaunchSettlement(worktreeId, state, state.promise)
pendingStructuredLaunchesByKey.set(key, state)
trackLaunchSettlement(key, state, state.promise)
return state.promise
}
export function startStructuredCodexLaunch(worktreeId: string): void {
const alreadyOpening = pendingStructuredLaunchesByWorktree.has(worktreeId)
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 ? 'Codex chat is still opening' : 'Opening Codex chat…'
alreadyOpening
? '{{providerLabel}} chat is still opening'
: 'Opening {{providerLabel}} chat…',
{ providerLabel }
)
)
void launchStructuredCodexSessionOnce(worktreeId).catch((error) => {
void launchStructuredAgentSessionOnce(worktreeId, agent).catch((error) => {
toast.error(
translate(
'components.native-chat.structuredSessionLaunchFailed',
'Could not open Codex chat'
'Could not open {{providerLabel}} chat',
{ providerLabel }
),
{ description: error instanceof Error ? error.message : String(error) }
{
description: error instanceof Error ? error.message : String(error),
action: {
label: translate(
'components.native-chat.structuredSessionLaunchTerminalAction',
'Open terminal agent'
),
onClick: openTerminalAgent
}
}
)
})
}
@@ -85,6 +85,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,
@@ -997,7 +998,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`
}