mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix(agents): align Floating discovery and launch authority (#14106)
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user