diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 52077867e57..31a74480501 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -3253,6 +3253,130 @@ ], "demotionRule": "Demote or quarantine if identity-mismatched completion can retire newer work, cleanup retains compact authority, or the focused gate flakes without a product or harness bug." }, + { + "id": "agent-launch.discovery-authority", + "title": "Agent discovery and launch use the same workspace authority", + "maturity": "experimental", + "protection": "partial", + "owner": "agent-session", + "layer": "renderer-store-contract", + "surfaces": ["Quick Launch agent inventory", "agent launch", "Floating Workspace"], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "wsl", "ssh", "remote-runtime"], + "coveredPlatforms": ["macos", "windows"], + "coveredProviders": ["local", "wsl"], + "coverageNotes": "A deterministic Windows-mode renderer oracle keeps an active WSL project and explicit-local Floating Workspace mounted together with disjoint agent inventories, then independently asserts discovery and launch authority. A headed physical Windows 2 journey with real Ubuntu WSL2 independently verified the rendered inventory and native PowerShell launch; SSH and paired-runtime behavior retain their existing contract coverage.", + "motivatingLinks": [ + "https://linear.app/stably/issue/STA-4027/p1-floating-workspace-agent-detection-still-uses-active-wsl-project", + "https://github.com/stablyai/orca/pull/13995" + ], + "invariant": "Every agent launch surface must advertise the provider set detected on its explicit execution authority and launch on that same authority; a local Floating Workspace must never inherit an unrelated active project's WSL, SSH, or paired-runtime context.", + "oracle": "Model an active WSL project and a native-Windows Floating Workspace with WSL-only Claude and host-only Codex. Keep both detection consumers mounted, require their advertised sets to stay disjoint through refresh, require the legacy active-project inventory to remain unchanged, then independently assert the Floating launch call and PTY transport select local IPC with no WSL project runtime.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useDetectedAgents.test.tsx src/renderer/src/hooks/useAgentDetectionTarget.test.ts src/renderer/src/store/slices/detected-agents.test.ts src/renderer/src/store/slices/local-detected-agent-state.test.ts src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts src/renderer/src/lib/local-preflight-context.test.ts src/renderer/src/lib/launch-agent-in-new-tab.test.ts --reporter=verbose", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts --reporter=dot", + "Headed Electron/CDP STA-4027 journey on Orca environment windows 2 with a selected Ubuntu-24.04 WSL project and local Floating Workspace" + ], + "testFiles": [ + "src/renderer/src/hooks/useDetectedAgents.test.tsx", + "src/renderer/src/hooks/useAgentDetectionTarget.test.ts", + "src/renderer/src/store/slices/detected-agents.test.ts", + "src/renderer/src/store/slices/local-detected-agent-state.test.ts", + "src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts", + "src/renderer/src/lib/local-preflight-context.test.ts", + "src/renderer/src/lib/launch-agent-in-new-tab.test.ts", + "src/renderer/src/components/terminal-pane/pty-connection.test.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/hooks/useDetectedAgents.test.tsx", + "assertions": [ + "the active WSL project advertises only its WSL agent while Floating advertises only the native-host agent", + "Floating refresh preserves both scoped inventories and does not overwrite the legacy active-project inventory" + ] + }, + { + "file": "src/renderer/src/store/slices/local-detected-agent-state.test.ts", + "assertions": [ + "same-context callers deduplicate without cached broadcast fanout", + "refresh supersedes stale detection and failed cold refresh remains retryable", + "project removal evicts settled and in-flight context state" + ] + }, + { + "file": "src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts", + "assertions": [ + "removing one project evicts its authority-scoped agent inventory without disturbing another project" + ] + }, + { + "file": "src/renderer/src/lib/launch-agent-in-new-tab.test.ts", + "assertions": [ + "the advertised Floating agent launches in a Floating tab with null runtime ownership and no paired-runtime create call" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts", + "assertions": [ + "the Floating agent's actual PTY selects local IPC, carries executionHostId local, and omits the active project's WSL runtime" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useDetectedAgents.test.tsx src/renderer/src/hooks/useAgentDetectionTarget.test.ts src/renderer/src/store/slices/detected-agents.test.ts src/renderer/src/store/slices/local-detected-agent-state.test.ts src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts src/renderer/src/lib/local-preflight-context.test.ts src/renderer/src/lib/launch-agent-in-new-tab.test.ts --reporter=verbose", + "result": "passed", + "durationSeconds": 2, + "summary": "Seven focused files passed independent WSL/local inventory, refresh isolation, cache lifecycle, project eviction, and Floating launch-call assertions." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts --reporter=dot", + "result": "passed", + "durationSeconds": 20, + "summary": "The full PTY connection suite passed the independent Floating native-host transport oracle." + }, + { + "date": "2026-08-12", + "runner": "manual", + "platform": "windows", + "command": "Headed Electron/CDP STA-4027 journey on Orca environment windows 2 with a selected Ubuntu-24.04 WSL project and local Floating Workspace", + "result": "passed", + "durationSeconds": 22.3, + "summary": "Environment abfee683-80eb-4ac3-ada0-da5d1b6303a6, runtime b3bea13e-f6b7-41e7-b850-0a118f7eda38: Floating advertised native Claude/Codex without WSL-only Gemini and launched Claude through native PowerShell with local host ownership while the WSL project remained active." + } + ], + "runtimeBudget": { + "p95Seconds": 40, + "scope": "eight focused renderer authority, cache, eviction, and launch test files" + }, + "flakeHistory": { + "status": "soaking", + "evidence": "The deterministic oracle uses mocked provider results and React effect barriers. Exact-head PR run 31644438349 passed the full Node 24/26 matrix; an earlier patch-identical run's transient shard failures passed on rerun, but long-term soak history is not yet available." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "The byte-identical oracle fails on merged PR #13995 and the tested latest-main snapshot ebe5125476 because Floating receives the WSL-only Claude inventory while launch remains local. The candidate passes; disabling the scoped Floating target reproduces the mismatch." + }, + "performanceBudget": { + "required": true, + "evidence": "Floating uses one stable host context key, retained tab bars do not rescan worktree indexes on store writes, concurrent probes deduplicate per context, and in-flight promise entries are deleted on settlement." + }, + "promotionCriteria": [ + "Collect 100 consecutive focused CI passes or 14 days of soak history.", + "Keep the physical Windows/WSL inventory and launch journey green when authority routing changes.", + "Keep disjoint provider inventories, refresh isolation, launch authority, and bounded in-flight state green." + ], + "knownGaps": [ + "Live SSH and paired-runtime launch journeys are covered by existing authority contracts rather than this focused oracle." + ], + "demotionRule": "Demote if discovery and launch can resolve different authorities, a scoped Floating probe overwrites the active-project inventory, or local context selection adds recurring scans or retained in-flight entries." + }, { "id": "agent-session.provider-ownership", "title": "Provider sessions are resumed once per workspace ownership claim", diff --git a/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts b/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts index 34ed3da7c09..32d74d051c6 100644 --- a/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts @@ -35,8 +35,11 @@ const appStoreSnapshot: { unifiedTabsByWorktree: Record activeGroupIdByWorktree: Record detectedAgentIds: string[] | null + localDetectedAgentIdsByContext: Record remoteDetectedAgentIds: Record isDetectingAgents: boolean + isDetectingLocalAgentsByContext: Record + isRefreshingLocalAgentsByContext: Record isDetectingRemoteAgents: Record } = { activeRepoId: null, @@ -51,8 +54,11 @@ const appStoreSnapshot: { unifiedTabsByWorktree: {}, activeGroupIdByWorktree: {}, detectedAgentIds: null, + localDetectedAgentIdsByContext: {}, remoteDetectedAgentIds: {}, isDetectingAgents: false, + isDetectingLocalAgentsByContext: {}, + isRefreshingLocalAgentsByContext: {}, isDetectingRemoteAgents: {} } const pinTabMock: (tabId: string) => void = vi.fn() @@ -73,8 +79,11 @@ const useAppStoreMock = vi.fn( unifiedTabsByWorktree: Record activeGroupIdByWorktree: Record detectedAgentIds: string[] | null + localDetectedAgentIdsByContext: Record remoteDetectedAgentIds: Record isDetectingAgents: boolean + isDetectingLocalAgentsByContext: Record + isRefreshingLocalAgentsByContext: Record isDetectingRemoteAgents: Record pinTab: typeof pinTabMock unpinTab: typeof unpinTabMock @@ -99,8 +108,11 @@ const useAppStoreMock = vi.fn( unifiedTabsByWorktree: appStoreSnapshot.unifiedTabsByWorktree, activeGroupIdByWorktree: appStoreSnapshot.activeGroupIdByWorktree, detectedAgentIds: appStoreSnapshot.detectedAgentIds, + localDetectedAgentIdsByContext: appStoreSnapshot.localDetectedAgentIdsByContext, remoteDetectedAgentIds: appStoreSnapshot.remoteDetectedAgentIds, isDetectingAgents: appStoreSnapshot.isDetectingAgents, + isDetectingLocalAgentsByContext: appStoreSnapshot.isDetectingLocalAgentsByContext, + isRefreshingLocalAgentsByContext: appStoreSnapshot.isRefreshingLocalAgentsByContext, isDetectingRemoteAgents: appStoreSnapshot.isDetectingRemoteAgents, pinTab: pinTabMock, unpinTab: unpinTabMock, @@ -168,8 +180,11 @@ useAppStoreExport.getState = vi.fn(() => ({ unifiedTabsByWorktree: appStoreSnapshot.unifiedTabsByWorktree, activeGroupIdByWorktree: appStoreSnapshot.activeGroupIdByWorktree, detectedAgentIds: appStoreSnapshot.detectedAgentIds, + localDetectedAgentIdsByContext: appStoreSnapshot.localDetectedAgentIdsByContext, remoteDetectedAgentIds: appStoreSnapshot.remoteDetectedAgentIds, isDetectingAgents: appStoreSnapshot.isDetectingAgents, + isDetectingLocalAgentsByContext: appStoreSnapshot.isDetectingLocalAgentsByContext, + isRefreshingLocalAgentsByContext: appStoreSnapshot.isRefreshingLocalAgentsByContext, isDetectingRemoteAgents: appStoreSnapshot.isDetectingRemoteAgents, pinTab: pinTabMock, unpinTab: unpinTabMock, @@ -348,6 +363,9 @@ describe('TabBar PowerShell launch wiring', () => { appStoreSnapshot.worktreesByRepo = {} appStoreSnapshot.unifiedTabsByWorktree = {} appStoreSnapshot.activeGroupIdByWorktree = {} + appStoreSnapshot.localDetectedAgentIdsByContext = {} + appStoreSnapshot.isDetectingLocalAgentsByContext = {} + appStoreSnapshot.isRefreshingLocalAgentsByContext = {} vi.stubGlobal('navigator', { userAgent: 'Windows' }) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index e73521d1ca3..26ec23e5e1c 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -25,6 +25,7 @@ import type { SshConnectionState } from '../../../../shared/ssh-types' import type { TerminalLayoutSnapshot, TuiAgent } from '../../../../shared/types' import { YOLO_TUI_AGENT_ARGS } from '../../../../shared/tui-agent-permissions' import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../../../shared/setup-agent-sequencing' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { beginAgentStartupDeliveryAttempt, resetAgentStartupDelayedDeliveryForTests @@ -2328,6 +2329,53 @@ describe('connectPanePty', () => { expect(createdTransportOptions[0]?.projectRuntime).toBeUndefined() }) + it('spawns a Floating agent on native Windows beside an active WSL project', async () => { + const { connectPanePty } = await import('./pty-connection') + const { createIpcPtyTransport } = await import('./pty-transport') + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + activeWorktreeId: 'wt-1', + tabsByWorktree: { + ...mockStoreState.tabsByWorktree, + [FLOATING_TERMINAL_WORKTREE_ID]: [ + { id: 'tab-floating-agent', ptyId: null, launchAgent: 'codex' } + ] + }, + projects: [{ id: 'repo1', localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } }], + settings: { + ...mockStoreState.settings, + terminalWindowsShell: 'wsl.exe', + terminalWindowsWslDistro: 'Ubuntu', + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } + } + } + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ + tabId: 'tab-floating-agent', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + cwd: 'C:\\Users\\alice', + startup: { launchAgent: 'codex' } + }) as never + ) + await flushAsyncTicks() + + expect(createIpcPtyTransport).toHaveBeenCalledOnce() + expect(createRemoteRuntimePtyTransport).not.toHaveBeenCalled() + expect(createdTransportOptions[0]).toMatchObject({ + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + executionHostId: 'local', + connectionId: null, + launchAgent: 'codex' + }) + expect(createdTransportOptions[0]?.projectRuntime).toBeUndefined() + }) + it('observes live terminal GitHub PR URLs before agent completion', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() diff --git a/src/renderer/src/hooks/useAgentDetectionTarget.test.ts b/src/renderer/src/hooks/useAgentDetectionTarget.test.ts index 22ad26ae016..dd8f1b8176c 100644 --- a/src/renderer/src/hooks/useAgentDetectionTarget.test.ts +++ b/src/renderer/src/hooks/useAgentDetectionTarget.test.ts @@ -1,9 +1,31 @@ import { describe, expect, it } from 'vitest' import type { FolderWorkspace, ProjectGroup, Repo } from '../../../shared/types' import { folderWorkspaceKey } from '../../../shared/workspace-scope' -import { getAgentDetectionTargetKeyForWorktree } from './useAgentDetectionTarget' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import { + getAgentDetectionTargetKeyForWorktree, + parseAgentDetectionTargetKey +} from './useAgentDetectionTarget' describe('getAgentDetectionTargetKeyForWorktree', () => { + it('carries explicit local Floating Workspace authority into detection', () => { + const state = { + settings: { activeRuntimeEnvironmentId: 'active-wsl-project' }, + folderWorkspaces: [], + projectGroups: [], + repos: [], + worktreesByRepo: {} + } as Parameters[0] + + const key = getAgentDetectionTargetKeyForWorktree(state, FLOATING_TERMINAL_WORKTREE_ID) + + expect(parseAgentDetectionTargetKey(key)).toEqual({ + kind: 'local', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + contextKey: 'host' + }) + }) + it('uses an explicit runtime owner without scanning ambiguous child SSH repos', () => { let projectGroupReads = 0 const repos: readonly Repo[] = Array.from({ length: 100 }, (_, index) => { @@ -139,3 +161,12 @@ describe('getAgentDetectionTargetKeyForWorktree', () => { expect(repoIdReads).toBe(100) }) }) + +describe('parseAgentDetectionTargetKey', () => { + it.each(['local:missing-context', 'local:%:host'])( + 'falls back to unscoped local detection for malformed key %s', + (key) => { + expect(parseAgentDetectionTargetKey(key)).toEqual({ kind: 'local' }) + } + ) +}) diff --git a/src/renderer/src/hooks/useAgentDetectionTarget.ts b/src/renderer/src/hooks/useAgentDetectionTarget.ts index 5f00d3100b8..f8c5787a901 100644 --- a/src/renderer/src/hooks/useAgentDetectionTarget.ts +++ b/src/renderer/src/hooks/useAgentDetectionTarget.ts @@ -10,9 +10,16 @@ import { getResolvedExecutionHostIdForWorktree } from '@/lib/resolved-worktree-e import { parseExecutionHostId } from '../../../shared/execution-host' import { parseWorkspaceKey } from '../../../shared/workspace-scope' import type { AgentDetectionTarget } from './useDetectedAgents' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' export const AGENT_DETECTION_LOCAL_TARGET_KEY = 'local' +function getLocalAgentDetectionTargetKey(worktreeId: string): string { + return worktreeId === FLOATING_TERMINAL_WORKTREE_ID + ? `${AGENT_DETECTION_LOCAL_TARGET_KEY}:${encodeURIComponent(worktreeId)}:host` + : AGENT_DETECTION_LOCAL_TARGET_KEY +} + type AgentDetectionOwnerState = Parameters[0] & WorktreeRuntimeOwnerState @@ -57,7 +64,7 @@ export function getAgentDetectionTargetKeyForWorktree( if (executionHost?.kind === 'runtime') { return `runtime:${executionHost.environmentId}` } - return AGENT_DETECTION_LOCAL_TARGET_KEY + return getLocalAgentDetectionTargetKey(worktreeId) } export function parseAgentDetectionTargetKey( @@ -69,6 +76,23 @@ export function parseAgentDetectionTargetKey( if (key === AGENT_DETECTION_LOCAL_TARGET_KEY) { return { kind: 'local' } } + if (key.startsWith(`${AGENT_DETECTION_LOCAL_TARGET_KEY}:`)) { + const [encodedWorktreeId, encodedContextKey] = key + .slice(`${AGENT_DETECTION_LOCAL_TARGET_KEY}:`.length) + .split(':') + if (!encodedWorktreeId || !encodedContextKey) { + return { kind: 'local' } + } + try { + return { + kind: 'local', + worktreeId: decodeURIComponent(encodedWorktreeId), + contextKey: decodeURIComponent(encodedContextKey) + } + } catch { + return { kind: 'local' } + } + } if (key.startsWith('ssh:')) { return { kind: 'ssh', connectionId: key.slice('ssh:'.length) } } diff --git a/src/renderer/src/hooks/useDetectedAgents.test.tsx b/src/renderer/src/hooks/useDetectedAgents.test.tsx index a4f0d7f3f96..cc1c49b5b90 100644 --- a/src/renderer/src/hooks/useDetectedAgents.test.tsx +++ b/src/renderer/src/hooks/useDetectedAgents.test.tsx @@ -14,7 +14,11 @@ import { RUNTIME_PROTOCOL_VERSION } from '../../../shared/protocol-version' import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +const detectLocalAgents = vi.fn() const detectRemoteAgents = vi.fn() const refreshLocalAgents = vi.fn() const runtimeEnvironmentCall = vi.fn() @@ -22,8 +26,15 @@ const initialAppState = useAppStore.getInitialState() const roots: Root[] = [] let latestHookResult: UseDetectedAgentsResult | null = null -function HookProbe({ target }: { target: AgentDetectionTarget | undefined }): null { +function HookProbe({ + target, + onResult +}: { + target: AgentDetectionTarget | undefined + onResult?: (result: UseDetectedAgentsResult) => void +}): null { latestHookResult = useDetectedAgents(target) + onResult?.(latestHookResult) return null } @@ -34,13 +45,16 @@ async function flushEffects(): Promise { }) } -async function renderProbe(target: AgentDetectionTarget | undefined): Promise { +async function renderProbe( + target: AgentDetectionTarget | undefined, + onResult?: (result: UseDetectedAgentsResult) => void +): Promise { const container = document.createElement('div') document.body.appendChild(container) const root = createRoot(container) roots.push(root) await act(async () => { - root.render(createElement(HookProbe, { target })) + root.render(createElement(HookProbe, { target, onResult })) }) await flushEffects() return root @@ -51,6 +65,11 @@ beforeEach(() => { useAppStore.setState(initialAppState, true) latestHookResult = null detectRemoteAgents.mockReset().mockResolvedValue([]) + detectLocalAgents + .mockReset() + .mockImplementation((context) => + Promise.resolve(context?.projectRuntime?.runtime.kind === 'wsl' ? ['claude'] : ['codex']) + ) refreshLocalAgents.mockReset().mockResolvedValue({ agents: [], addedPathSegments: [], @@ -80,11 +99,100 @@ beforeEach(() => { }) }) globalThis.window.api = { - preflight: { detectRemoteAgents, refreshAgents: refreshLocalAgents }, - runtimeEnvironments: { call: runtimeEnvironmentCall } + preflight: { + detectAgents: detectLocalAgents, + detectRemoteAgents, + refreshAgents: refreshLocalAgents + }, + runtimeEnvironments: { call: runtimeEnvironmentCall }, + platform: { get: () => ({ platform: 'win32' }) } } as unknown as Window['api'] }) +describe('Floating Workspace authority', () => { + it('advertises native Windows agents beside an active WSL project', async () => { + const activeResult: { current: UseDetectedAgentsResult | null } = { current: null } + const floatingResult: { current: UseDetectedAgentsResult | null } = { current: null } + useAppStore.getState().clearLocalDetectedAgents() + useAppStore.setState({ + activeRepoId: 'repo-wsl', + activeWorktreeId: 'worktree-wsl', + projects: [ + { + id: 'repo-wsl', + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + } + ], + repos: [ + { + id: 'repo-wsl', + path: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo', + displayName: 'WSL project', + badgeColor: '#000000', + addedAt: 0 + } + ], + worktreesByRepo: { + 'repo-wsl': [ + { + id: 'worktree-wsl', + repoId: 'repo-wsl', + path: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo', + displayName: 'main' + } + ] + } + } as never) + + await renderProbe( + { kind: 'local', worktreeId: 'worktree-wsl' } as AgentDetectionTarget, + (result) => { + activeResult.current = result + } + ) + await renderProbe( + { + kind: 'local', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + contextKey: 'host' + } as AgentDetectionTarget, + (result) => { + floatingResult.current = result + } + ) + + expect(activeResult.current?.detectedIds).toEqual(['claude']) + expect(floatingResult.current?.detectedIds).toEqual(['codex']) + expect(useAppStore.getState().detectedAgentIds).toEqual(['claude']) + const detectedContexts = detectLocalAgents.mock.calls.map(([context]) => context) + expect(detectedContexts).toEqual([ + expect.objectContaining({ + projectRuntime: expect.objectContaining({ + runtime: expect.objectContaining({ kind: 'wsl', distro: 'Ubuntu' }) + }) + }), + undefined + ]) + + refreshLocalAgents.mockResolvedValueOnce({ + agents: ['codex'], + addedPathSegments: [], + shellHydrationOk: true, + pathSource: 'process_env', + pathFailureReason: 'none' + }) + await act(async () => { + await floatingResult.current?.refresh() + }) + await flushEffects() + + expect(activeResult.current?.detectedIds).toEqual(['claude']) + expect(floatingResult.current?.detectedIds).toEqual(['codex']) + expect(useAppStore.getState().detectedAgentIds).toEqual(['claude']) + expect(refreshLocalAgents).toHaveBeenLastCalledWith(undefined) + }) +}) + afterEach(async () => { for (const root of roots) { await act(async () => { diff --git a/src/renderer/src/hooks/useDetectedAgents.ts b/src/renderer/src/hooks/useDetectedAgents.ts index 2776f2b69da..3015e524aa4 100644 --- a/src/renderer/src/hooks/useDetectedAgents.ts +++ b/src/renderer/src/hooks/useDetectedAgents.ts @@ -17,7 +17,7 @@ export type UseDetectedAgentsResult = { } export type AgentDetectionTarget = - | { kind: 'local' } + | { kind: 'local'; worktreeId?: string | null; contextKey?: string } | { kind: 'ssh'; connectionId: string } | { kind: 'runtime'; environmentId: string } @@ -65,6 +65,8 @@ export function useDetectedAgents( : target?.kind === 'runtime' ? target.environmentId : null + const localWorktreeId = target?.kind === 'local' ? target.worktreeId : undefined + const localContextKey = target?.kind === 'local' ? target.contextKey : undefined const remoteTargetKey = targetKind === 'ssh' && targetId ? `ssh:${targetId}` @@ -82,7 +84,9 @@ export function useDetectedAgents( if (targetKind === 'runtime' && targetId) { return s.runtimeDetectedAgentIds[targetId] ?? null } - return s.detectedAgentIds + return localContextKey + ? (s.localDetectedAgentIdsByContext[localContextKey] ?? null) + : s.detectedAgentIds }) const isLoading = useAppStore((s) => { if (isUnknown) { @@ -94,7 +98,9 @@ export function useDetectedAgents( if (targetKind === 'runtime' && targetId) { return s.isDetectingRuntimeAgents[targetId] ?? false } - return s.isDetectingAgents + return localContextKey + ? (s.isDetectingLocalAgentsByContext[localContextKey] ?? false) + : s.isDetectingAgents }) const isRefreshing = useAppStore((s) => { if (targetKind === 'runtime' && targetId) { @@ -103,7 +109,12 @@ export function useDetectedAgents( if (targetKind === 'ssh' && targetId) { return s.isDetectingRemoteAgents[targetId] ?? false } - return targetKind === 'local' ? s.isRefreshingAgents : false + if (targetKind !== 'local') { + return false + } + return localContextKey + ? (s.isRefreshingLocalAgentsByContext[localContextKey] ?? false) + : s.isRefreshingAgents }) const detectionFailed = detectedIds === null && @@ -126,8 +137,8 @@ export function useDetectedAgents( if (targetKind === 'ssh' && targetId) { return state.refreshRemoteDetectedAgents(targetId) } - return state.refreshDetectedAgents() - }, [isUnknown, targetKind, targetId]) + return state.refreshDetectedAgents(localWorktreeId) + }, [isUnknown, localWorktreeId, targetKind, targetId]) useEffect(() => { if (isUnknown) { @@ -159,10 +170,18 @@ export function useDetectedAgents( } } else { if (detectedIds === null) { - void state.ensureDetectedAgents() + void state.ensureDetectedAgents(localWorktreeId) } } - }, [isUnknown, targetKind, targetId, remoteTargetKey, detectedIds]) + }, [ + isUnknown, + targetKind, + targetId, + remoteTargetKey, + detectedIds, + localWorktreeId, + localContextKey + ]) return { detectedIds, isLoading, detectionFailed, isRefreshing, refresh } } diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index 38fb4f1d566..8515379d418 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { toAppSshPtyId } from '../../../shared/ssh-pty-id' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' const mockCreateTab = vi.fn() const mockQueueTabStartupCommand = vi.fn() @@ -185,6 +186,32 @@ describe('launchAgentInNewTab', () => { }) }) + it('keeps Floating Workspace authority on native Windows beside an active WSL project', async () => { + store.projects = [ + { + id: 'repo-1', + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + } + ] + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + const result = launchAgentInNewTab({ + agent: 'codex', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + launchPlatform: 'win32' + }) + + expect(result).not.toBeNull() + expect(mockIsWebRuntimeSessionActive).toHaveBeenLastCalledWith(null) + expect(mockCreateWebRuntimeSessionTerminal).not.toHaveBeenCalled() + expect(mockCreateTab).toHaveBeenCalledWith( + FLOATING_TERMINAL_WORKTREE_ID, + undefined, + undefined, + { launchAgent: 'codex' } + ) + }) + it('opens supported submit-after-ready launches in chat and seeds a launch prompt echo', async () => { store.settings = { agentCmdOverrides: {}, diff --git a/src/renderer/src/lib/local-preflight-context.test.ts b/src/renderer/src/lib/local-preflight-context.test.ts index b6d020f3a80..69d38f9ced6 100644 --- a/src/renderer/src/lib/local-preflight-context.test.ts +++ b/src/renderer/src/lib/local-preflight-context.test.ts @@ -486,6 +486,23 @@ describe('local preflight context', () => { ).toBeUndefined() }) + it('keeps Floating on the host despite global and explicit WSL agent settings', () => { + const state = { + ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), + settings: { + terminalWindowsShell: 'wsl.exe', + terminalWindowsWslDistro: 'Debian', + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Debian' }, + localAgentRuntime: 'wsl', + localAgentWslDistro: 'Ubuntu' + } + } as AppState + + expect( + getLocalAgentPreflightContext(state, 'win32', {}, FLOATING_TERMINAL_WORKTREE_ID) + ).toBeUndefined() + }) + it('keeps the active project runtime fallback for a detected-only worktree', () => { const state = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), diff --git a/src/renderer/src/lib/local-preflight-context.ts b/src/renderer/src/lib/local-preflight-context.ts index fc794535447..28da8a3d5bd 100644 --- a/src/renderer/src/lib/local-preflight-context.ts +++ b/src/renderer/src/lib/local-preflight-context.ts @@ -169,6 +169,10 @@ export function getLocalAgentPreflightContext( wslContext: LocalProjectRuntimeWslContext = getCachedLocalProjectRuntimeWslContext(), worktreeId?: string | null ): LocalPreflightContext { + // Why: Floating owns native host authority and must not inherit any agent runtime fallback. + if (worktreeId === FLOATING_TERMINAL_WORKTREE_ID) { + return undefined + } const projectRuntime = getLocalProjectExecutionRuntimeContext( state, worktreeId, diff --git a/src/renderer/src/store/slices/detected-agents.test.ts b/src/renderer/src/store/slices/detected-agents.test.ts index c4776039e13..f85b5977960 100644 --- a/src/renderer/src/store/slices/detected-agents.test.ts +++ b/src/renderer/src/store/slices/detected-agents.test.ts @@ -12,6 +12,7 @@ import { RUNTIME_PROTOCOL_VERSION } from '../../../../shared/protocol-version' import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' const detectAgents = vi.fn() const refreshAgents = vi.fn() @@ -139,6 +140,59 @@ describe('createDetectedAgentsSlice WSL context', () => { }) }) + it('publishes a Floating-first host probe to a later ordinary local caller', async () => { + let resolveDetection: (agents: string[]) => void = () => {} + detectAgents.mockReturnValueOnce( + new Promise((resolve) => { + resolveDetection = resolve + }) + ) + const store = createTestStore() + + const floating = store.getState().ensureDetectedAgents(FLOATING_TERMINAL_WORKTREE_ID) + const ordinary = store.getState().ensureDetectedAgents() + + expect(detectAgents).toHaveBeenCalledTimes(1) + expect(store.getState().isDetectingAgents).toBe(true) + resolveDetection(['codex']) + await expect(Promise.all([floating, ordinary])).resolves.toEqual([['codex'], ['codex']]) + expect(store.getState().detectedAgentIds).toEqual(['codex']) + expect(store.getState().isDetectingAgents).toBe(false) + }) + + it('restores the legacy inventory when returning to a cached local context', async () => { + detectAgents.mockImplementation(async (context) => + context?.projectRuntime?.runtime.kind === 'wsl' ? ['claude'] : ['codex'] + ) + const store = createTestStore({ + repos: [makeRepo({ id: 'repo-1', path: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo' })], + activeRepoId: 'repo-1', + activeWorktreeId: null + }) + + await store.getState().ensureDetectedAgents() + store.setState({ activeRepoId: null }) + await store.getState().ensureDetectedAgents() + store.setState({ activeRepoId: 'repo-1' }) + + await expect(store.getState().ensureDetectedAgents()).resolves.toEqual(['claude']) + expect(detectAgents).toHaveBeenCalledTimes(2) + expect(store.getState().detectedAgentIds).toEqual(['claude']) + }) + + it('retries a local context after a transient detection failure', async () => { + detectAgents + .mockRejectedValueOnce(new Error('cold-start timeout')) + .mockResolvedValueOnce(['codex']) + const store = createTestStore() + + await expect(store.getState().ensureDetectedAgents()).resolves.toEqual([]) + await expect(store.getState().ensureDetectedAgents()).resolves.toEqual(['codex']) + + expect(detectAgents).toHaveBeenCalledTimes(2) + expect(store.getState().detectedAgentIds).toEqual(['codex']) + }) + it('refreshes local agents inside the active WSL repo distro when no worktree is selected', async () => { const store = createTestStore({ repos: [makeRepo({ id: 'repo-1', path: '\\\\wsl$\\Debian\\home\\alice\\repo' })], diff --git a/src/renderer/src/store/slices/detected-agents.ts b/src/renderer/src/store/slices/detected-agents.ts index e3a4f4947a0..87e34b712ff 100644 --- a/src/renderer/src/store/slices/detected-agents.ts +++ b/src/renderer/src/store/slices/detected-agents.ts @@ -1,30 +1,10 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' -import type { PathSource, ShellHydrationFailureReason, TuiAgent } from '../../../../shared/types' -import { - getLocalAgentPreflightContext, - localPreflightContextKey -} from '@/lib/local-preflight-context' - -export type DetectedAgentsSlice = { - detectedAgentIds: TuiAgent[] | null - isDetectingAgents: boolean - isRefreshingAgents: boolean - /** Telemetry classification of the most recent refreshAgents() run. `null` - * before the first refresh resolves. Read by the wizard at agent-pick time - * to attach `path_source` / `path_failure_reason` to `onboarding_agent_picked` - * — see docs/agent-on-path-detection.md. */ - pathSource: PathSource | null - pathFailureReason: ShellHydrationFailureReason | null - /** Runs `preflight.detectAgents` once per session. Subsequent callers reuse - * the in-flight promise so every surface sees the same result. */ - ensureDetectedAgents: (worktreeId?: string | null) => Promise - /** Re-runs `preflight.refreshAgents` (re-reads shell PATH). Concurrent callers - * receive the same pending promise; store fields update once on resolve so - * every subscribed surface re-renders in the same tick. */ - refreshDetectedAgents: () => Promise - clearLocalDetectedAgents: () => void +import type { TuiAgent } from '../../../../shared/types' +import { createLocalDetectedAgentState } from './local-detected-agent-state' +import type { LocalDetectedAgentState } from './local-detected-agent-store-state' +export type DetectedAgentsSlice = LocalDetectedAgentState & { // Why: remote worktrees need per-connection agent detection. The local // detectedAgentIds field is connection-unaware, so remote state lives in a // separate map keyed by SSH connectionId. @@ -41,10 +21,6 @@ export type DetectedAgentsSlice = { // Why: these are module-scoped (not in the store) so we can deduplicate // concurrent callers without storing a Promise in Zustand state. -let detectPromise: { key: string; promise: Promise } | null = null -let refreshPromise: { key: string; promise: Promise } | null = null -let detectedContextKey: string | null = null -let localDetectionGeneration = 0 const remoteDetectPromises = new Map>() const remoteRefreshPromises = new Map>() @@ -54,119 +30,10 @@ export function _getRemoteDetectPromiseCountForTest(): number { export const createDetectedAgentsSlice: StateCreator = ( set, - get + get, + store ) => ({ - detectedAgentIds: null, - isDetectingAgents: false, - isRefreshingAgents: false, - pathSource: null, - pathFailureReason: null, - - ensureDetectedAgents: (worktreeId) => { - const context = getLocalAgentPreflightContext(get(), undefined, undefined, worktreeId) - const contextKey = localPreflightContextKey(context) - const existing = get().detectedAgentIds - if (existing && detectedContextKey === contextKey) { - return Promise.resolve(existing) - } - if (detectPromise?.key === contextKey) { - return detectPromise.promise - } - const contextChanged = detectedContextKey !== contextKey - set({ - detectedAgentIds: contextChanged ? null : get().detectedAgentIds, - isDetectingAgents: true - }) - const requestGeneration = localDetectionGeneration - const pending = window.api.preflight - .detectAgents(context) - .then((ids) => { - const typed = ids as TuiAgent[] - if (requestGeneration === localDetectionGeneration) { - set({ detectedAgentIds: typed, isDetectingAgents: false }) - detectedContextKey = contextKey - } - return typed - }) - .catch(() => { - // Why: allow a retry on the next call if detection blew up (IPC timeout - // during cold start). Do not cache the failure or show stale context. - if (requestGeneration === localDetectionGeneration) { - detectPromise = null - set({ - detectedAgentIds: contextChanged ? [] : get().detectedAgentIds, - isDetectingAgents: false - }) - } - return [] as TuiAgent[] - }) - detectPromise = { key: contextKey, promise: pending } - return pending - }, - - refreshDetectedAgents: () => { - const context = getLocalAgentPreflightContext(get()) - const contextKey = localPreflightContextKey(context) - if (refreshPromise?.key === contextKey) { - return refreshPromise.promise - } - const contextChanged = detectedContextKey !== contextKey - set({ - detectedAgentIds: contextChanged ? null : get().detectedAgentIds, - isRefreshingAgents: true - }) - const requestGeneration = localDetectionGeneration - const pending = window.api.preflight - .refreshAgents(context) - .then((result) => { - const typed = result.agents as TuiAgent[] - if (requestGeneration === localDetectionGeneration) { - set({ - detectedAgentIds: typed, - isRefreshingAgents: false, - pathSource: result.pathSource, - pathFailureReason: result.pathFailureReason - }) - // Why: once refresh has run, treat its result as the current detection - // snapshot so `ensureDetectedAgents` short-circuits. - detectedContextKey = contextKey - detectPromise = { key: contextKey, promise: Promise.resolve(typed) } - } - return typed - }) - .catch(() => { - const fallback = contextChanged ? [] : (get().detectedAgentIds ?? []) - if (requestGeneration === localDetectionGeneration) { - set({ - detectedAgentIds: fallback, - isRefreshingAgents: false - }) - } - return fallback - }) - .finally(() => { - if (refreshPromise?.promise === pending) { - refreshPromise = null - } - }) - refreshPromise = { key: contextKey, promise: pending } - return pending - }, - - clearLocalDetectedAgents: () => { - localDetectionGeneration += 1 - detectPromise = null - refreshPromise = null - detectedContextKey = null - set({ - detectedAgentIds: null, - isDetectingAgents: false, - isRefreshingAgents: false, - pathSource: null, - pathFailureReason: null - }) - }, - + ...createLocalDetectedAgentState(set, get, store), remoteDetectedAgentIds: {}, isDetectingRemoteAgents: {}, diff --git a/src/renderer/src/store/slices/local-agent-context-eviction.ts b/src/renderer/src/store/slices/local-agent-context-eviction.ts new file mode 100644 index 00000000000..92f25c1d336 --- /dev/null +++ b/src/renderer/src/store/slices/local-agent-context-eviction.ts @@ -0,0 +1,118 @@ +import type { AppState } from '../types' + +type LocalAgentContextState = Pick< + AppState, + | 'detectedAgentIds' + | 'isDetectingAgents' + | 'isRefreshingAgents' + | 'localDetectedAgentIdsByContext' + | 'isDetectingLocalAgentsByContext' + | 'isRefreshingLocalAgentsByContext' + | 'pathSource' + | 'pathFailureReason' +> + +export type LocalAgentContextEviction = { + removedContextKeys: ReadonlySet + detectedContextKey: string | null + legacyDetectContextKey: string | null + legacyRefreshContextKey: string | null + statePatch: Partial +} + +export function removeLocalAgentContextEntries( + entries: Record, + contextKeys: ReadonlySet +): Record { + let filtered = entries + for (const contextKey of contextKeys) { + if (!(contextKey in filtered)) { + continue + } + if (filtered === entries) { + filtered = { ...entries } + } + delete filtered[contextKey] + } + return filtered +} + +export function removeLocalAgentContextEntry(entries: Record, contextKey: string) { + return removeLocalAgentContextEntries(entries, new Set([contextKey])) +} + +export function getLocalAgentContextEviction(args: { + projectIds: readonly string[] + state: LocalAgentContextState + internalContextKeys: Iterable + detectedContextKey: string | null + legacyDetectContextKey: string | null + legacyRefreshContextKey: string | null +}): LocalAgentContextEviction | null { + const prefixes = args.projectIds + .map((projectId) => projectId.trim()) + .filter(Boolean) + .map((projectId) => `${projectId}:`) + if (prefixes.length === 0) { + return null + } + const contextKeys = new Set([ + ...Object.keys(args.state.localDetectedAgentIdsByContext), + ...Object.keys(args.state.isDetectingLocalAgentsByContext), + ...Object.keys(args.state.isRefreshingLocalAgentsByContext), + ...args.internalContextKeys + ]) + const removedContextKeys = new Set( + [...contextKeys].filter((contextKey) => + prefixes.some((prefix) => contextKey.startsWith(prefix)) + ) + ) + if (removedContextKeys.size === 0) { + return null + } + const clearDetected = Boolean( + (args.detectedContextKey && removedContextKeys.has(args.detectedContextKey)) || + (args.legacyDetectContextKey && removedContextKeys.has(args.legacyDetectContextKey)) + ) + const clearRefreshing = Boolean( + args.legacyRefreshContextKey && removedContextKeys.has(args.legacyRefreshContextKey) + ) + return { + removedContextKeys, + detectedContextKey: + args.detectedContextKey && removedContextKeys.has(args.detectedContextKey) + ? null + : args.detectedContextKey, + legacyDetectContextKey: + args.legacyDetectContextKey && removedContextKeys.has(args.legacyDetectContextKey) + ? null + : args.legacyDetectContextKey, + legacyRefreshContextKey: + args.legacyRefreshContextKey && removedContextKeys.has(args.legacyRefreshContextKey) + ? null + : args.legacyRefreshContextKey, + statePatch: { + ...(clearDetected + ? { + detectedAgentIds: null, + isDetectingAgents: false, + pathSource: null, + pathFailureReason: null + } + : {}), + ...(clearRefreshing ? { isRefreshingAgents: false } : {}), + localDetectedAgentIdsByContext: removeLocalAgentContextEntries( + args.state.localDetectedAgentIdsByContext, + removedContextKeys + ), + isDetectingLocalAgentsByContext: removeLocalAgentContextEntries( + args.state.isDetectingLocalAgentsByContext, + removedContextKeys + ), + isRefreshingLocalAgentsByContext: removeLocalAgentContextEntries( + args.state.isRefreshingLocalAgentsByContext, + removedContextKeys + ) + } + } +} diff --git a/src/renderer/src/store/slices/local-agent-legacy-loading.ts b/src/renderer/src/store/slices/local-agent-legacy-loading.ts new file mode 100644 index 00000000000..18e99768003 --- /dev/null +++ b/src/renderer/src/store/slices/local-agent-legacy-loading.ts @@ -0,0 +1,43 @@ +import type { AppState } from '../types' +import { removeLocalAgentContextEntry } from './local-agent-context-eviction' + +type LocalAgentLegacyLoadingState = Pick< + AppState, + 'detectedAgentIds' | 'isDetectingAgents' | 'isRefreshingAgents' +> + +type LocalAgentLegacyLoadingPatch = Partial + +export function getLegacyLoadingPatch( + state: LocalAgentLegacyLoadingState, + contextMatches: boolean, + phase: 'detect' | 'refresh' +): LocalAgentLegacyLoadingPatch | null { + const detectedAgentIds = contextMatches ? state.detectedAgentIds : null + const alreadyLoading = phase === 'detect' ? state.isDetectingAgents : state.isRefreshingAgents + if (state.detectedAgentIds === detectedAgentIds && alreadyLoading) { + return null + } + return phase === 'detect' + ? { detectedAgentIds, isDetectingAgents: true } + : { detectedAgentIds, isRefreshingAgents: true } +} + +export function getSupersededDetectPatch( + state: LocalAgentLegacyLoadingState & Pick, + contextKey: string, + supersedesDetect: boolean, + clearsLegacyDetect: boolean +): Partial { + return { + ...(clearsLegacyDetect ? { isDetectingAgents: false } : {}), + ...(supersedesDetect + ? { + isDetectingLocalAgentsByContext: removeLocalAgentContextEntry( + state.isDetectingLocalAgentsByContext, + contextKey + ) + } + : {}) + } +} diff --git a/src/renderer/src/store/slices/local-detected-agent-state.test.ts b/src/renderer/src/store/slices/local-detected-agent-state.test.ts new file mode 100644 index 00000000000..77303b8bc89 --- /dev/null +++ b/src/renderer/src/store/slices/local-detected-agent-state.test.ts @@ -0,0 +1,222 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { create } from 'zustand' +import type { AppState } from '../types' +import type { Repo } from '../../../../shared/types' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { createDetectedAgentsSlice } from './detected-agents' + +const detectAgents = vi.fn() +const refreshAgents = vi.fn() + +globalThis.window = { + api: { + preflight: { detectAgents, refreshAgents }, + platform: { get: () => ({ platform: 'win32' }) } + } as unknown as Window['api'] +} as Window & typeof globalThis + +function makeRepo(id: string): Repo { + return { + id, + path: `C:\\${id}`, + displayName: id, + badgeColor: '#000000', + addedAt: 0 + } +} + +function createTestStore(repos: Repo[]) { + const store = create()( + (...args) => createDetectedAgentsSlice(...args) as unknown as AppState + ) + store.setState({ + repos, + projects: [], + worktreesByRepo: {}, + activeRepoId: repos[0]?.id ?? null, + activeWorktreeId: null + } as Partial) + return store +} + +describe('local detected agent context lifecycle', () => { + beforeEach(() => { + detectAgents.mockReset().mockResolvedValue(['claude']) + refreshAgents.mockReset().mockResolvedValue({ + agents: ['codex'], + pathSource: 'process_env', + pathFailureReason: 'none' + }) + }) + + it('deduplicates Floating-first callers without cached or settlement broadcast fanout', async () => { + let resolveDetection: (agents: string[]) => void = () => {} + detectAgents.mockReturnValueOnce( + new Promise((resolve) => { + resolveDetection = resolve + }) + ) + const store = createTestStore([]) + let broadcasts = 0 + const unsubscribe = store.subscribe(() => { + broadcasts += 1 + }) + + const floating = store.getState().ensureDetectedAgents(FLOATING_TERMINAL_WORKTREE_ID) + const ordinary = store.getState().ensureDetectedAgents() + const joinedOrdinary = store.getState().ensureDetectedAgents() + + expect(detectAgents).toHaveBeenCalledTimes(1) + expect(ordinary).toBe(floating) + expect(joinedOrdinary).toBe(floating) + broadcasts = 0 + resolveDetection(['codex']) + await Promise.all([floating, ordinary, joinedOrdinary]) + expect(broadcasts).toBe(1) + + broadcasts = 0 + await store.getState().ensureDetectedAgents() + await store.getState().ensureDetectedAgents() + expect(broadcasts).toBe(0) + unsubscribe() + }) + + it('evicts removed project contexts without retaining settled loading entries', async () => { + const repo1 = makeRepo('repo-1') + const repo2 = makeRepo('repo-2') + const store = createTestStore([repo1, repo2]) + + await store.getState().ensureDetectedAgents() + store.setState({ activeRepoId: 'repo-2' }) + await store.getState().ensureDetectedAgents() + + expect(Object.keys(store.getState().localDetectedAgentIdsByContext)).toEqual([ + 'repo-1:windows-host', + 'repo-2:windows-host' + ]) + expect(store.getState().isDetectingLocalAgentsByContext).toEqual({}) + expect(store.getState().isRefreshingLocalAgentsByContext).toEqual({}) + + store.setState({ repos: [repo2] }) + store.getState().clearLocalDetectedAgentContextsForProjects(['repo-1']) + + expect(store.getState().localDetectedAgentIdsByContext).toEqual({ + 'repo-2:windows-host': ['claude'] + }) + + store.setState({ repos: [repo1, repo2], activeRepoId: 'repo-1' }) + await expect(store.getState().ensureDetectedAgents()).resolves.toEqual(['claude']) + expect(detectAgents).toHaveBeenCalledTimes(3) + }) + + it('does not restore a project context cleared while detection is in flight', async () => { + let resolveDetection: (agents: string[]) => void = () => {} + detectAgents.mockReturnValueOnce( + new Promise((resolve) => { + resolveDetection = resolve + }) + ) + const store = createTestStore([makeRepo('repo-1')]) + + const pending = store.getState().ensureDetectedAgents() + store.getState().clearLocalDetectedAgentContextsForProjects(['repo-1']) + resolveDetection(['claude']) + + await expect(pending).resolves.toEqual(['claude']) + expect(store.getState().localDetectedAgentIdsByContext).toEqual({}) + expect(store.getState().isDetectingLocalAgentsByContext).toEqual({}) + expect(store.getState().detectedAgentIds).toBeNull() + }) + + it('does not let an older detect overwrite a successful refresh', async () => { + let resolveDetection: (agents: string[]) => void = () => {} + detectAgents.mockReturnValueOnce( + new Promise((resolve) => { + resolveDetection = resolve + }) + ) + const store = createTestStore([]) + + const detect = store.getState().ensureDetectedAgents() + await expect(store.getState().refreshDetectedAgents()).resolves.toEqual(['codex']) + resolveDetection(['claude']) + await expect(detect).resolves.toEqual(['claude']) + + expect(store.getState().detectedAgentIds).toEqual(['codex']) + expect(store.getState().localDetectedAgentIdsByContext.host).toEqual(['codex']) + expect(store.getState().isDetectingAgents).toBe(false) + expect(store.getState().isDetectingLocalAgentsByContext).toEqual({}) + }) + + it('does not let an older failed detect erase a successful refresh', async () => { + let rejectDetection: (error: Error) => void = () => {} + detectAgents.mockReturnValueOnce( + new Promise((_resolve, reject) => { + rejectDetection = reject + }) + ) + const store = createTestStore([]) + + const detect = store.getState().ensureDetectedAgents() + await expect(store.getState().refreshDetectedAgents()).resolves.toEqual(['codex']) + rejectDetection(new Error('older detect failed')) + await expect(detect).resolves.toEqual([]) + + expect(store.getState().detectedAgentIds).toEqual(['codex']) + expect(store.getState().localDetectedAgentIdsByContext.host).toEqual(['codex']) + await expect(store.getState().ensureDetectedAgents()).resolves.toEqual(['codex']) + expect(detectAgents).toHaveBeenCalledTimes(1) + }) + + it('joins an authoritative refresh instead of starting a later detect', async () => { + let resolveRefresh: (result: { + agents: string[] + pathSource: string + pathFailureReason: string + }) => void = () => {} + refreshAgents.mockReturnValueOnce( + new Promise((resolve) => { + resolveRefresh = resolve + }) + ) + const store = createTestStore([]) + + const refresh = store.getState().refreshDetectedAgents() + const ensure = store.getState().ensureDetectedAgents() + expect(ensure).toBe(refresh) + expect(detectAgents).not.toHaveBeenCalled() + + resolveRefresh({ + agents: ['codex'], + pathSource: 'process_env', + pathFailureReason: 'none' + }) + await expect(Promise.all([refresh, ensure])).resolves.toEqual([['codex'], ['codex']]) + expect(store.getState().detectedAgentIds).toEqual(['codex']) + expect(store.getState().localDetectedAgentIdsByContext.host).toEqual(['codex']) + }) + + it('retries after an authoritative refresh fails without a usable cache', async () => { + let resolveDetection: (agents: string[]) => void = () => {} + detectAgents.mockReturnValueOnce( + new Promise((resolve) => { + resolveDetection = resolve + }) + ) + refreshAgents.mockRejectedValueOnce(new Error('transient refresh failure')) + const store = createTestStore([]) + + const supersededDetect = store.getState().ensureDetectedAgents(FLOATING_TERMINAL_WORKTREE_ID) + await expect( + store.getState().refreshDetectedAgents(FLOATING_TERMINAL_WORKTREE_ID) + ).resolves.toEqual([]) + resolveDetection(['stale']) + await expect(supersededDetect).resolves.toEqual(['stale']) + + await expect( + store.getState().ensureDetectedAgents(FLOATING_TERMINAL_WORKTREE_ID) + ).resolves.toEqual(['claude']) + expect(detectAgents).toHaveBeenCalledTimes(2) + expect(store.getState().localDetectedAgentIdsByContext.host).toEqual(['claude']) + }) +}) diff --git a/src/renderer/src/store/slices/local-detected-agent-state.ts b/src/renderer/src/store/slices/local-detected-agent-state.ts new file mode 100644 index 00000000000..43b39ff5980 --- /dev/null +++ b/src/renderer/src/store/slices/local-detected-agent-state.ts @@ -0,0 +1,302 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { TuiAgent } from '../../../../shared/types' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { + getLocalAgentPreflightContext, + localPreflightContextKey +} from '@/lib/local-preflight-context' +import * as contextEviction from './local-agent-context-eviction' +import { getLegacyLoadingPatch, getSupersededDetectPatch } from './local-agent-legacy-loading' +import { createEmptyLocalDetectedAgentState } from './local-detected-agent-store-state' +import type { LocalDetectedAgentState } from './local-detected-agent-store-state' + +type LocalDetectedAgentStateCreator = StateCreator + +export const createLocalDetectedAgentState: LocalDetectedAgentStateCreator = (set, get) => { + const detectPromises = new Map>() + const refreshPromises = new Map>() + const failedDetectContextKeys = new Set() + let detectedContextKey: string | null = null + let legacyDetectContextKey: string | null = null + let legacyRefreshContextKey: string | null = null + let localDetectionGeneration = 0 + + return { + ...createEmptyLocalDetectedAgentState(), + + ensureDetectedAgents: (worktreeId) => { + const isFloating = worktreeId === FLOATING_TERMINAL_WORKTREE_ID + const context = getLocalAgentPreflightContext(get(), undefined, undefined, worktreeId) + const contextKey = localPreflightContextKey(context) + const existing = get().localDetectedAgentIdsByContext[contextKey] + const inflightRefresh = refreshPromises.get(contextKey) + if (inflightRefresh) { + if (!isFloating) { + legacyRefreshContextKey = contextKey + } + return inflightRefresh + } + if (existing != null && !failedDetectContextKeys.has(contextKey)) { + if (!isFloating) { + detectedContextKey = contextKey + const state = get() + if (state.detectedAgentIds !== existing || state.isDetectingAgents) { + set({ detectedAgentIds: existing, isDetectingAgents: false }) + } + } + return Promise.resolve(existing) + } + const requestGeneration = localDetectionGeneration + const exposeInflightToLegacy = (): void => { + if (!isFloating) { + legacyDetectContextKey = contextKey + } + if (isFloating) { + return + } + const state = get() + const patch = getLegacyLoadingPatch(state, detectedContextKey === contextKey, 'detect') + if (patch) { + set(patch) + } + } + const inflight = detectPromises.get(contextKey) + if (inflight) { + exposeInflightToLegacy() + return inflight + } + if (!isFloating) { + legacyDetectContextKey = contextKey + } + set((state) => ({ + ...(isFloating + ? {} + : (getLegacyLoadingPatch(state, detectedContextKey === contextKey, 'detect') ?? {})), + localDetectedAgentIdsByContext: { + ...state.localDetectedAgentIdsByContext, + [contextKey]: existing ?? null + }, + isDetectingLocalAgentsByContext: { + ...state.isDetectingLocalAgentsByContext, + [contextKey]: true + } + })) + const pending = window.api.preflight + .detectAgents(context) + .then((ids) => { + const typed = ids as TuiAgent[] + if ( + requestGeneration === localDetectionGeneration && + detectPromises.get(contextKey) === pending + ) { + failedDetectContextKeys.delete(contextKey) + const exposeToLegacy = legacyDetectContextKey === contextKey + if (exposeToLegacy) { + legacyDetectContextKey = null + detectedContextKey = contextKey + } + set((state) => ({ + ...(exposeToLegacy ? { detectedAgentIds: typed, isDetectingAgents: false } : {}), + localDetectedAgentIdsByContext: { + ...state.localDetectedAgentIdsByContext, + [contextKey]: typed + }, + isDetectingLocalAgentsByContext: contextEviction.removeLocalAgentContextEntry( + state.isDetectingLocalAgentsByContext, + contextKey + ) + })) + } + return typed + }) + .catch(() => { + if ( + requestGeneration === localDetectionGeneration && + detectPromises.get(contextKey) === pending + ) { + failedDetectContextKeys.add(contextKey) + const exposeToLegacy = legacyDetectContextKey === contextKey + if (exposeToLegacy) { + legacyDetectContextKey = null + } + set((state) => ({ + ...(exposeToLegacy ? { detectedAgentIds: [], isDetectingAgents: false } : {}), + localDetectedAgentIdsByContext: { + ...state.localDetectedAgentIdsByContext, + [contextKey]: [] + }, + isDetectingLocalAgentsByContext: contextEviction.removeLocalAgentContextEntry( + state.isDetectingLocalAgentsByContext, + contextKey + ) + })) + } + return [] as TuiAgent[] + }) + .finally(() => { + if (detectPromises.get(contextKey) === pending) { + detectPromises.delete(contextKey) + } + }) + detectPromises.set(contextKey, pending) + return pending + }, + + refreshDetectedAgents: (worktreeId) => { + const isFloating = worktreeId === FLOATING_TERMINAL_WORKTREE_ID + const context = getLocalAgentPreflightContext(get(), undefined, undefined, worktreeId) + const contextKey = localPreflightContextKey(context) + const cached = get().localDetectedAgentIdsByContext[contextKey] + const hadUsableCache = cached != null && !failedDetectContextKeys.has(contextKey) + const requestGeneration = localDetectionGeneration + const exposeInflightToLegacy = (): void => { + if (!isFloating) { + legacyRefreshContextKey = contextKey + } + if (isFloating) { + return + } + const state = get() + const patch = getLegacyLoadingPatch(state, detectedContextKey === contextKey, 'refresh') + if (patch) { + set(patch) + } + } + const inflight = refreshPromises.get(contextKey) + if (inflight) { + exposeInflightToLegacy() + return inflight + } + if (!isFloating) { + legacyRefreshContextKey = contextKey + } + const supersedesDetect = detectPromises.delete(contextKey) + const clearsLegacyDetect = legacyDetectContextKey === contextKey + if (clearsLegacyDetect) { + legacyDetectContextKey = null + } + set((state) => ({ + ...(isFloating + ? {} + : (getLegacyLoadingPatch(state, detectedContextKey === contextKey, 'refresh') ?? {})), + ...getSupersededDetectPatch(state, contextKey, supersedesDetect, clearsLegacyDetect), + isRefreshingLocalAgentsByContext: { + ...state.isRefreshingLocalAgentsByContext, + [contextKey]: true + } + })) + const pending = window.api.preflight + .refreshAgents(context) + .then((result) => { + const typed = result.agents as TuiAgent[] + if ( + requestGeneration === localDetectionGeneration && + refreshPromises.get(contextKey) === pending + ) { + failedDetectContextKeys.delete(contextKey) + const exposeToLegacy = legacyRefreshContextKey === contextKey + if (exposeToLegacy) { + legacyRefreshContextKey = null + detectedContextKey = contextKey + } + set((state) => ({ + ...(exposeToLegacy + ? { + detectedAgentIds: typed, + isRefreshingAgents: false, + pathSource: result.pathSource, + pathFailureReason: result.pathFailureReason + } + : {}), + localDetectedAgentIdsByContext: { + ...state.localDetectedAgentIdsByContext, + [contextKey]: typed + }, + isRefreshingLocalAgentsByContext: contextEviction.removeLocalAgentContextEntry( + state.isRefreshingLocalAgentsByContext, + contextKey + ) + })) + } + return typed + }) + .catch(() => { + const fallback = isFloating + ? (get().localDetectedAgentIdsByContext[contextKey] ?? []) + : detectedContextKey !== contextKey + ? [] + : (get().detectedAgentIds ?? []) + if ( + requestGeneration === localDetectionGeneration && + refreshPromises.get(contextKey) === pending + ) { + if (!hadUsableCache) { + failedDetectContextKeys.add(contextKey) + } + const exposeToLegacy = legacyRefreshContextKey === contextKey + if (exposeToLegacy) { + legacyRefreshContextKey = null + } + set((state) => ({ + ...(exposeToLegacy ? { detectedAgentIds: fallback, isRefreshingAgents: false } : {}), + localDetectedAgentIdsByContext: { + ...state.localDetectedAgentIdsByContext, + [contextKey]: fallback + }, + isRefreshingLocalAgentsByContext: contextEviction.removeLocalAgentContextEntry( + state.isRefreshingLocalAgentsByContext, + contextKey + ) + })) + } + return fallback + }) + .finally(() => { + if (refreshPromises.get(contextKey) === pending) { + refreshPromises.delete(contextKey) + } + }) + refreshPromises.set(contextKey, pending) + return pending + }, + + clearLocalDetectedAgentContextsForProjects: (projectIds) => { + const eviction = contextEviction.getLocalAgentContextEviction({ + projectIds, + state: get(), + internalContextKeys: [ + ...detectPromises.keys(), + ...refreshPromises.keys(), + ...failedDetectContextKeys + ], + detectedContextKey, + legacyDetectContextKey, + legacyRefreshContextKey + }) + if (!eviction) { + return + } + for (const contextKey of eviction.removedContextKeys) { + detectPromises.delete(contextKey) + refreshPromises.delete(contextKey) + failedDetectContextKeys.delete(contextKey) + } + detectedContextKey = eviction.detectedContextKey + legacyDetectContextKey = eviction.legacyDetectContextKey + legacyRefreshContextKey = eviction.legacyRefreshContextKey + set(eviction.statePatch) + }, + + clearLocalDetectedAgents: () => { + localDetectionGeneration += 1 + detectPromises.clear() + refreshPromises.clear() + failedDetectContextKeys.clear() + detectedContextKey = null + legacyDetectContextKey = null + legacyRefreshContextKey = null + set(createEmptyLocalDetectedAgentState()) + } + } +} diff --git a/src/renderer/src/store/slices/local-detected-agent-store-state.ts b/src/renderer/src/store/slices/local-detected-agent-store-state.ts new file mode 100644 index 00000000000..c961bb5114f --- /dev/null +++ b/src/renderer/src/store/slices/local-detected-agent-store-state.ts @@ -0,0 +1,40 @@ +import type { AppState } from '../types' +import type { PathSource, ShellHydrationFailureReason, TuiAgent } from '../../../../shared/types' + +export type LocalDetectedAgentState = { + detectedAgentIds: TuiAgent[] | null + isDetectingAgents: boolean + isRefreshingAgents: boolean + localDetectedAgentIdsByContext: Record + isDetectingLocalAgentsByContext: Record + isRefreshingLocalAgentsByContext: Record + pathSource: PathSource | null + pathFailureReason: ShellHydrationFailureReason | null + ensureDetectedAgents: (worktreeId?: string | null) => Promise + refreshDetectedAgents: (worktreeId?: string | null) => Promise + clearLocalDetectedAgentContextsForProjects: (projectIds: readonly string[]) => void + clearLocalDetectedAgents: () => void +} + +export function createEmptyLocalDetectedAgentState(): Pick< + AppState, + | 'detectedAgentIds' + | 'isDetectingAgents' + | 'isRefreshingAgents' + | 'localDetectedAgentIdsByContext' + | 'isDetectingLocalAgentsByContext' + | 'isRefreshingLocalAgentsByContext' + | 'pathSource' + | 'pathFailureReason' +> { + return { + detectedAgentIds: null, + isDetectingAgents: false, + isRefreshingAgents: false, + localDetectedAgentIdsByContext: {}, + isDetectingLocalAgentsByContext: {}, + isRefreshingLocalAgentsByContext: {}, + pathSource: null, + pathFailureReason: null + } +} diff --git a/src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts b/src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts index 15ca69fda3c..69bebba9240 100644 --- a/src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts +++ b/src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts @@ -50,7 +50,11 @@ function seedTwoProjects(store: ReturnType): void { groupsByWorktree: { [W1]: [], [W2]: [] }, browserTabsByWorktree: { [W1]: [], [W2]: [] }, gitStatusHugeByWorktree: { [W1]: { limit: 1000 }, [W2]: { limit: 2000 } }, - everActivatedWorktreeIds: new Set([W1, W2]) + everActivatedWorktreeIds: new Set([W1, W2]), + localDetectedAgentIdsByContext: { + 'repo-1:windows-host': ['claude'], + 'repo-2:windows-host': ['codex'] + } }) } @@ -68,6 +72,7 @@ describe('removeProject purges per-worktree state (leak regression)', () => { expect(s.browserTabsByWorktree[W1]).toBeUndefined() expect(s.gitStatusHugeByWorktree[W1]).toBeUndefined() expect(s.everActivatedWorktreeIds.has(W1)).toBe(false) + expect(s.localDetectedAgentIdsByContext['repo-1:windows-host']).toBeUndefined() }) it('keeps per-worktree state for projects that are NOT removed', async () => { @@ -83,5 +88,6 @@ describe('removeProject purges per-worktree state (leak regression)', () => { expect(s.browserTabsByWorktree[W2]).toBeDefined() expect(s.gitStatusHugeByWorktree[W2]).toEqual({ limit: 2000 }) expect(s.everActivatedWorktreeIds.has(W2)).toBe(true) + expect(s.localDetectedAgentIdsByContext['repo-2:windows-host']).toEqual(['codex']) }) }) diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index dbc55c8c320..8bdda9f41e3 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -3519,6 +3519,15 @@ export const createRepoSlice: StateCreator = (set, // Kill PTYs for all worktrees belonging to this repo const worktreeIds = getKnownRepoWorktreeIds(get(), projectId, ownerHostId) + const localAgentContextProjectIds = + ownerHostId === LOCAL_EXECUTION_HOST_ID + ? [ + projectId, + ...(get().worktreesByRepo[projectId] ?? []) + .filter((worktree) => worktreeBelongsToHost(worktree, ownerHostId)) + .flatMap((worktree) => (worktree.projectId ? [worktree.projectId] : [])) + ] + : [] const killedTabIds = new Set() if (target.kind === 'environment') { await Promise.allSettled( @@ -3546,6 +3555,7 @@ export const createRepoSlice: StateCreator = (set, // Why: use the canonical per-worktree purge to evict all worktree-scoped maps (hand-deletion leaked most); runs before the set() below so it still sees tabsByWorktree. get().purgeWorktreeTerminalState(worktreeIds) + get().clearLocalDetectedAgentContextsForProjects(localAgentContextProjectIds) set((s) => { const nextWorktrees = { ...s.worktreesByRepo }