diff --git a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts new file mode 100644 index 00000000000..a7c84006d86 --- /dev/null +++ b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockCreateTab = vi.fn() +const mockSetTabViewMode = vi.fn() +const mockWaitForAgentReady = vi.fn() + +const store = { + activeRepoId: 'repo-1', + activeWorktreeId: 'wt-1', + settings: { + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {}, + activeRuntimeEnvironmentId: null, + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true + }, + projects: [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'inherit-global' as const } }], + repos: [{ id: 'repo-1', connectionId: null as string | null, path: '/repo' }], + sshConnectionStates: new Map(), + transientClearedAgentStatusConnectionIds: {}, + worktreesByRepo: { + 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', projectId: 'repo-1', path: '/repo/worktree' }] + }, + detectedWorktreesByRepo: {}, + allWorktrees: vi.fn(() => store.worktreesByRepo['repo-1']), + tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] }, + openFiles: [] as { id: string; worktreeId: string }[], + browserTabsByWorktree: {} as Record, + tabBarOrderByWorktree: {} as Record, + terminalLayoutsByTabId: {}, + ptyIdsByTabId: {}, + createTab: mockCreateTab, + closeTab: vi.fn(), + queueTabStartupCommand: vi.fn(), + setActiveTabType: vi.fn(), + setTabViewMode: mockSetTabViewMode, + setTabBarOrder: vi.fn(), + setAgentStatus: vi.fn(), + seedNativeChatLaunchPrompt: vi.fn(), + seedNativeChatLaunchDraft: vi.fn(), + markNativeChatLaunchPromptFailed: vi.fn() +} + +vi.mock('@/store', () => ({ useAppStore: { getState: () => store } })) +vi.mock('sonner', () => ({ toast: { message: vi.fn(), error: vi.fn() } })) +vi.mock('@/components/tab-bar/reconcile-order', () => ({ reconcileTabOrder: vi.fn(() => []) })) +vi.mock('@/lib/agent-paste-draft', () => ({ pasteDraftWhenAgentReady: vi.fn() })) +vi.mock('@/lib/agent-ready-wait', () => ({ waitForAgentReady: mockWaitForAgentReady })) +vi.mock('@/lib/telemetry', () => ({ + track: vi.fn(), + tuiAgentToAgentKind: (agent: string) => agent +})) +vi.mock('@/runtime/web-runtime-session', () => ({ + createWebRuntimeSessionTerminal: vi.fn(), + createWebRuntimeAgentSessionTerminalWithLaunchDraft: vi.fn(), + isWebRuntimeSessionActive: vi.fn(() => false), + isWebTerminalSurfaceTabId: vi.fn(() => false) +})) + +/** 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 + * difference is the only observable signal that the availability guard ran. */ +describe('structured chat adoption guard on the launch path', () => { + beforeEach(() => { + vi.clearAllMocks() + store.repos = [{ id: 'repo-1', connectionId: null, path: '/repo' }] + store.projects = [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'inherit-global' } }] + mockCreateTab.mockReturnValue({ id: 'tab-1' }) + mockWaitForAgentReady.mockResolvedValue({ ready: true, reason: 'foreground-match' }) + }) + + it('adopts a local Codex tab into the structured stack', async () => { + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) + + expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { + launchAgent: 'codex' + }) + await vi.waitFor(() => expect(mockSetTabViewMode).toHaveBeenCalledWith('tab-1', 'chat')) + }) + + it('keeps an SSH Codex tab on the bridge', async () => { + 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' }) + + expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { + launchAgent: 'codex', + 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') + + launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) + + expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { + launchAgent: 'codex', + viewMode: 'chat' + }) + expect(mockWaitForAgentReady).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/structured-native-chat-availability.test.ts b/src/renderer/src/lib/structured-native-chat-availability.test.ts new file mode 100644 index 00000000000..939c27bf5f4 --- /dev/null +++ b/src/renderer/src/lib/structured-native-chat-availability.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store/types' +import { canUseStructuredNativeChat } from './structured-native-chat-availability' + +const { mockGetRendererAppPlatform } = vi.hoisted(() => ({ + mockGetRendererAppPlatform: vi.fn<() => NodeJS.Platform>(() => 'darwin') +})) + +vi.mock('@/lib/renderer-app-platform', () => ({ + getRendererAppPlatform: mockGetRendererAppPlatform +})) + +function stateFor(input: { + connectionId?: string | null + windowsRuntime?: 'windows-host' | 'wsl' + worktreePath?: string +}): AppState { + return { + activeRepoId: 'repo-1', + activeWorktreeId: 'wt-1', + projects: [ + { + id: 'repo-1', + localWindowsRuntimePreference: + input.windowsRuntime === 'wsl' + ? { kind: 'wsl', distro: 'Ubuntu' } + : { kind: 'windows-host' } + } + ], + repos: [{ id: 'repo-1', connectionId: input.connectionId ?? null, path: 'C:\\repo' }], + settings: {}, + worktreesByRepo: { + 'repo-1': [ + { + id: 'wt-1', + repoId: 'repo-1', + projectId: 'repo-1', + path: input.worktreePath ?? 'C:\\repo\\worktree' + } + ] + }, + detectedWorktreesByRepo: {} + } as unknown as AppState +} + +describe('canUseStructuredNativeChat', () => { + beforeEach(() => { + mockGetRendererAppPlatform.mockReturnValue('darwin') + }) + + it('allows the structured stack on a local worktree', () => { + expect(canUseStructuredNativeChat(stateFor({}), 'wt-1')).toBe(true) + }) + + it('refuses an SSH worktree so the pane stays on the bridge', () => { + expect(canUseStructuredNativeChat(stateFor({ connectionId: 'ssh-a' }), 'wt-1')).toBe(false) + }) + + it('refuses a runtime-paired worktree so the pane stays on the bridge', () => { + expect(canUseStructuredNativeChat(stateFor({ connectionId: 'runtime-ssh-a' }), 'wt-1')).toBe( + false + ) + }) + + it('refuses a WSL project on Windows so the pane stays on the bridge', () => { + mockGetRendererAppPlatform.mockReturnValue('win32') + expect(canUseStructuredNativeChat(stateFor({ windowsRuntime: 'wsl' }), 'wt-1')).toBe(false) + }) + + it('allows a Windows-host project on Windows', () => { + mockGetRendererAppPlatform.mockReturnValue('win32') + expect(canUseStructuredNativeChat(stateFor({ windowsRuntime: 'windows-host' }), 'wt-1')).toBe( + true + ) + }) +}) diff --git a/src/renderer/src/store/slices/structured-native-chat-toggle.test.ts b/src/renderer/src/store/slices/structured-native-chat-toggle.test.ts index 666ac07eb22..fe3a756be69 100644 --- a/src/renderer/src/store/slices/structured-native-chat-toggle.test.ts +++ b/src/renderer/src/store/slices/structured-native-chat-toggle.test.ts @@ -229,10 +229,36 @@ describe('native chat routing', () => { ).resolves.toBe('bridge') expect(callStructuredAgentSession).not.toHaveBeenCalled() - expect(patch).toHaveBeenCalledWith(tab.id, { - viewMode: 'chat', - structuredSessionId: undefined + expect(patch).toHaveBeenCalledTimes(1) + const [patchedTabId, bridgePatch] = patch.mock.calls[0] as [string, Record] + expect(patchedTabId).toBe(tab.id) + expect(bridgePatch.viewMode).toBe('chat') + // Deep equality treats an absent key as undefined, so presence is the only proof it was cleared. + expect(Object.keys(bridgePatch)).toContain('structuredSessionId') + expect(bridgePatch.structuredSessionId).toBeUndefined() + }) + + it('keeps a Codex binding when the same agent falls back to the bridge', async () => { + const { tab, state } = terminalState({ + agent: 'codex', + structuredSessionId: 'codex_thread-codex', + connectionId: 'ssh-1' }) + const patch = vi.fn() + + await expect( + setTerminalNativeChatMode({ + getState: () => state as never, + patch, + tabId: tab.id, + mode: 'chat' + }) + ).resolves.toBe('bridge') + + expect(callStructuredAgentSession).not.toHaveBeenCalled() + expect(patch).toHaveBeenCalledTimes(1) + const [, codexBridgePatch] = patch.mock.calls[0] as [string, Record] + expect(Object.keys(codexBridgePatch)).not.toContain('structuredSessionId') }) it('names the terminal direction when returning ownership fails', async () => {