fix(agents): align Floating discovery and launch authority (#14106)

This commit is contained in:
Jinwoo Hong
2026-08-12 21:56:07 -07:00
committed by GitHub
parent de729d6067
commit 889dd4c7d2
19 changed files with 1238 additions and 156 deletions
+124
View File
@@ -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",
@@ -35,8 +35,11 @@ const appStoreSnapshot: {
unifiedTabsByWorktree: Record<string, unknown[]>
activeGroupIdByWorktree: Record<string, string>
detectedAgentIds: string[] | null
localDetectedAgentIdsByContext: Record<string, string[] | null>
remoteDetectedAgentIds: Record<string, string[]>
isDetectingAgents: boolean
isDetectingLocalAgentsByContext: Record<string, boolean>
isRefreshingLocalAgentsByContext: Record<string, boolean>
isDetectingRemoteAgents: Record<string, boolean>
} = {
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<string, unknown[]>
activeGroupIdByWorktree: Record<string, string>
detectedAgentIds: string[] | null
localDetectedAgentIdsByContext: Record<string, string[] | null>
remoteDetectedAgentIds: Record<string, string[]>
isDetectingAgents: boolean
isDetectingLocalAgentsByContext: Record<string, boolean>
isRefreshingLocalAgentsByContext: Record<string, boolean>
isDetectingRemoteAgents: Record<string, boolean>
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' })
})
@@ -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()
@@ -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<typeof getAgentDetectionTargetKeyForWorktree>[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' })
}
)
})
@@ -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<typeof getConnectionIdFromState>[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) }
}
@@ -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<void> {
})
}
async function renderProbe(target: AgentDetectionTarget | undefined): Promise<Root> {
async function renderProbe(
target: AgentDetectionTarget | undefined,
onResult?: (result: UseDetectedAgentsResult) => void
): Promise<Root> {
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 () => {
+27 -8
View File
@@ -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 }
}
@@ -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: {},
@@ -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' }),
@@ -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,
@@ -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<string[]>((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' })],
@@ -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<TuiAgent[]>
/** 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<TuiAgent[]>
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<TuiAgent[]> } | null = null
let refreshPromise: { key: string; promise: Promise<TuiAgent[]> } | null = null
let detectedContextKey: string | null = null
let localDetectionGeneration = 0
const remoteDetectPromises = new Map<string, Promise<TuiAgent[]>>()
const remoteRefreshPromises = new Map<string, Promise<TuiAgent[]>>()
@@ -54,119 +30,10 @@ export function _getRemoteDetectPromiseCountForTest(): number {
export const createDetectedAgentsSlice: StateCreator<AppState, [], [], DetectedAgentsSlice> = (
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: {},
@@ -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<string>
detectedContextKey: string | null
legacyDetectContextKey: string | null
legacyRefreshContextKey: string | null
statePatch: Partial<LocalAgentContextState>
}
export function removeLocalAgentContextEntries<T>(
entries: Record<string, T>,
contextKeys: ReadonlySet<string>
): Record<string, T> {
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<T>(entries: Record<string, T>, contextKey: string) {
return removeLocalAgentContextEntries(entries, new Set([contextKey]))
}
export function getLocalAgentContextEviction(args: {
projectIds: readonly string[]
state: LocalAgentContextState
internalContextKeys: Iterable<string>
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
)
}
}
}
@@ -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<LocalAgentLegacyLoadingState>
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<AppState, 'isDetectingLocalAgentsByContext'>,
contextKey: string,
supersedesDetect: boolean,
clearsLegacyDetect: boolean
): Partial<AppState> {
return {
...(clearsLegacyDetect ? { isDetectingAgents: false } : {}),
...(supersedesDetect
? {
isDetectingLocalAgentsByContext: removeLocalAgentContextEntry(
state.isDetectingLocalAgentsByContext,
contextKey
)
}
: {})
}
}
@@ -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<AppState>()(
(...args) => createDetectedAgentsSlice(...args) as unknown as AppState
)
store.setState({
repos,
projects: [],
worktreesByRepo: {},
activeRepoId: repos[0]?.id ?? null,
activeWorktreeId: null
} as Partial<AppState>)
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<string[]>((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<string[]>((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<string[]>((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<string[]>((_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<string[]>((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'])
})
})
@@ -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<AppState, [], [], LocalDetectedAgentState>
export const createLocalDetectedAgentState: LocalDetectedAgentStateCreator = (set, get) => {
const detectPromises = new Map<string, Promise<TuiAgent[]>>()
const refreshPromises = new Map<string, Promise<TuiAgent[]>>()
const failedDetectContextKeys = new Set<string>()
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())
}
}
}
@@ -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<string, TuiAgent[] | null>
isDetectingLocalAgentsByContext: Record<string, boolean>
isRefreshingLocalAgentsByContext: Record<string, boolean>
pathSource: PathSource | null
pathFailureReason: ShellHydrationFailureReason | null
ensureDetectedAgents: (worktreeId?: string | null) => Promise<TuiAgent[]>
refreshDetectedAgents: (worktreeId?: string | null) => Promise<TuiAgent[]>
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
}
}
@@ -50,7 +50,11 @@ function seedTwoProjects(store: ReturnType<typeof createTestStore>): 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'])
})
})
+10
View File
@@ -3519,6 +3519,15 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (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<string>()
if (target.kind === 'environment') {
await Promise.allSettled(
@@ -3546,6 +3555,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (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 }