From ebeafbd2058cab07cf4c0a1765684da2958704a5 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 9 Oct 2026 23:21:39 -0700 Subject: [PATCH] fix(agents): an old Windows or unknown-platform host asks to update instead of answering its default list (#27164) * fix(agents): an old Windows or unknown-platform host asks to update instead of answering its default list A host without preflight.workspace-scoped.v1 answers a workspace's agent request with its own default list. On a Windows host that list misses a WSL workspace's agents, and a host that does not report its platform may be Windows. Only a known non-Windows host's default is still used; the others record an empty list plus an update notice, shown in the quick-launch menu. Co-Authored-By: Claude * fix(agents): decide the old-host fallback from a fresh status Reading the platform from the shared capability cache could see a probe another caller had just started (no status yet) and flag a Linux host. A fresh status is race-free and also catches an in-place upgrade. Co-Authored-By: Claude * test(agents): name the served host status for its role Co-Authored-By: Claude --------- Co-authored-by: m4air Co-authored-by: Claude --- .../components/tab-bar/QuickLaunchButton.tsx | 22 ++- src/renderer/src/hooks/useDetectedAgents.ts | 9 +- src/renderer/src/i18n/locales/en.json | 3 +- src/renderer/src/i18n/locales/es.json | 3 +- src/renderer/src/i18n/locales/fr.json | 3 +- src/renderer/src/i18n/locales/ja.json | 3 +- src/renderer/src/i18n/locales/ko.json | 3 +- src/renderer/src/i18n/locales/zh.json | 3 +- .../slices/runtime-detected-agents.test.ts | 146 ++++++++++++++++++ .../store/slices/runtime-detected-agents.ts | 90 ++++++++--- 10 files changed, 251 insertions(+), 34 deletions(-) create mode 100644 src/renderer/src/store/slices/runtime-detected-agents.test.ts diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx index 11958d71d15..eb0c0ad9743 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx @@ -117,7 +117,7 @@ function QuickLaunchAgentMenuItemsInner({ // instead of the remote server's. Use the same ssh/runtime/local owner // resolution as the rest of the tab bar. const agentDetectionTarget = useAgentDetectionTargetForWorktree(worktreeId) - const { detectedIds } = useDetectedAgents(agentDetectionTarget) + const { detectedIds, needsServerUpdate } = useDetectedAgents(agentDetectionTarget) const defaultAgent = useAppStore((s) => s.settings?.defaultTuiAgent) const disabledAgents = useAppStore( (s) => s.settings?.disabledTuiAgents ?? DEFAULT_DISABLED_TUI_AGENTS @@ -230,12 +230,20 @@ function QuickLaunchAgentMenuItemsInner({ disabled className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 text-muted-foreground" > - {detectedIds && detectedIds.length > 0 - ? translate('auto.components.tab.bar.QuickLaunchButton.8dea9b5cdf', 'No enabled agents') - : translate( - 'auto.components.tab.bar.QuickLaunchButton.e518f544b1', - 'No agents detected' - )} + {needsServerUpdate + ? translate( + 'auto.components.tab.bar.QuickLaunchButton.needsServerUpdate', + 'Update Orca on this server to list this workspace’s agents' + ) + : detectedIds && detectedIds.length > 0 + ? translate( + 'auto.components.tab.bar.QuickLaunchButton.8dea9b5cdf', + 'No enabled agents' + ) + : translate( + 'auto.components.tab.bar.QuickLaunchButton.e518f544b1', + 'No agents detected' + )} ) : null} {agents.map((agent) => { diff --git a/src/renderer/src/hooks/useDetectedAgents.ts b/src/renderer/src/hooks/useDetectedAgents.ts index 11f7bb6266c..deb241052f2 100644 --- a/src/renderer/src/hooks/useDetectedAgents.ts +++ b/src/renderer/src/hooks/useDetectedAgents.ts @@ -10,6 +10,8 @@ export type UseDetectedAgentsResult = { /** True when the first probe for this mounted remote target finished without a result. */ detectionFailed: boolean isRefreshing: boolean + /** True when the runtime host is too old to list this workspace's agents. */ + needsServerUpdate: boolean /** Forces a re-detect on the target host (`preflight.refreshAgents` for * local/runtime targets, a fresh probe for SSH) and updates every * subscribed surface in the same tick. Idempotent while in flight: @@ -124,6 +126,11 @@ export function useDetectedAgents( ? (s.isRefreshingLocalAgentsByContext[localContextKey] ?? false) : s.isRefreshingAgents }) + const needsServerUpdate = useAppStore((s) => + targetKind === 'runtime' && recordKey + ? s.runtimeAgentDetectionNeedsServerUpdate[recordKey] === true + : false + ) const detectionFailed = detectedIds === null && !isLoading && @@ -198,5 +205,5 @@ export function useDetectedAgents( localContextKey ]) - return { detectedIds, isLoading, detectionFailed, isRefreshing, refresh } + return { detectedIds, isLoading, detectionFailed, isRefreshing, needsServerUpdate, refresh } } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index b2eb528c55b..b9893ea9a6f 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3640,7 +3640,8 @@ "ec2adf093e": "Launch {{value0}} in a new terminal", "465e432ef1": "Could not build launch command for {{value0}}.", "e518f544b1": "No agents detected", - "8dea9b5cdf": "No enabled agents" + "8dea9b5cdf": "No enabled agents", + "needsServerUpdate": "Update Orca on this server to list this workspace’s agents" }, "RecentTabSwitcher": { "329638ff6f": "Switch Tab", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 1968ae26c53..fd461e13613 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -3426,7 +3426,8 @@ "ec2adf093e": "Iniciar {{value0}} en un nuevo terminal", "465e432ef1": "No se pudo generar el comando de inicio para {{value0}}.", "e518f544b1": "No se detectaron agentes", - "8dea9b5cdf": "No hay agentes habilitados" + "8dea9b5cdf": "No hay agentes habilitados", + "needsServerUpdate": "Actualiza Orca en este servidor para ver los agentes de este espacio de trabajo" }, "RecentTabSwitcher": { "329638ff6f": "Cambiar pestaña", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 68cf17368c8..3c20a21befb 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -3531,7 +3531,8 @@ "ec2adf093e": "Lancer {{value0}} dans un nouveau terminal", "465e432ef1": "Impossible de construire la commande de lancement pour {{value0}}.", "e518f544b1": "Aucun agent détecté", - "8dea9b5cdf": "Aucun agent activé" + "8dea9b5cdf": "Aucun agent activé", + "needsServerUpdate": "Mettez à jour Orca sur ce serveur pour lister les agents de cet espace de travail" }, "RecentTabSwitcher": { "329638ff6f": "Changer d'onglet", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index af6b1509ce1..71a55e8dd37 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -3426,7 +3426,8 @@ "ec2adf093e": "新規ターミナルで {{value0}} を起動", "465e432ef1": "{{value0}} の起動コマンドを作成できませんでした。", "e518f544b1": "Agent が検出されませんでした", - "8dea9b5cdf": "有効な Agent がありません" + "8dea9b5cdf": "有効な Agent がありません", + "needsServerUpdate": "このワークスペースの Agent を表示するには、このサーバーの Orca を更新してください" }, "RecentTabSwitcher": { "329638ff6f": "タブの切り替え", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 5b2b737859c..cb599463cd4 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -3426,7 +3426,8 @@ "ec2adf093e": "새 terminal에서 {{value0}} 실행", "465e432ef1": "{{value0}}에 대한 실행 명령을 작성할 수 없습니다.", "e518f544b1": "감지된 agents가 없습니다.", - "8dea9b5cdf": "활성화된 agents가 없습니다." + "8dea9b5cdf": "활성화된 agents가 없습니다.", + "needsServerUpdate": "이 워크스페이스의 에이전트를 보려면 이 서버의 Orca를 업데이트하세요" }, "RecentTabSwitcher": { "329638ff6f": "탭 전환", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index b4dc013e794..590cb456dd9 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -3426,7 +3426,8 @@ "ec2adf093e": "在新终端中启动 {{value0}}", "465e432ef1": "无法为 {{value0}} 构建启动命令。", "e518f544b1": "未检测到智能体", - "8dea9b5cdf": "没有启用智能体" + "8dea9b5cdf": "没有启用智能体", + "needsServerUpdate": "请更新此服务器上的 Orca 以列出此工作区的智能体" }, "RecentTabSwitcher": { "329638ff6f": "切换选项卡", diff --git a/src/renderer/src/store/slices/runtime-detected-agents.test.ts b/src/renderer/src/store/slices/runtime-detected-agents.test.ts new file mode 100644 index 00000000000..83bc3b6e890 --- /dev/null +++ b/src/renderer/src/store/slices/runtime-detected-agents.test.ts @@ -0,0 +1,146 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestStore } from './store-test-helpers' +import { getRuntimeAgentInventoryKey } from './runtime-agent-inventory-key' +import { + MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + PREFLIGHT_WORKSPACE_SCOPED_RUNTIME_CAPABILITY, + RUNTIME_PROTOCOL_VERSION +} from '../../../../shared/protocol-version' +import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client' + +const runtimeEnvironmentCall = vi.fn() + +vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeEnvironmentCall } } }) + +type ServedHostStatus = { hostPlatform?: NodeJS.Platform; scoped: boolean } + +function serveHost(host: ServedHostStatus): void { + runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => { + const result = + method === 'status.get' + ? { + runtimeId: 'remote-runtime', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + capabilities: host.scoped ? [PREFLIGHT_WORKSPACE_SCOPED_RUNTIME_CAPABILITY] : [], + ...(host.hostPlatform ? { hostPlatform: host.hostPlatform } : {}) + } + : method === 'preflight.refreshAgents' + ? { agents: ['codex'] } + : ['codex'] + return Promise.resolve({ id: method, ok: true, result, _meta: { runtimeId: 'remote-runtime' } }) + }) +} + +function agentCalls(): unknown[] { + return runtimeEnvironmentCall.mock.calls + .map(([request]) => request) + .filter((request: { method: string }) => request.method !== 'status.get') +} + +const KEY = getRuntimeAgentInventoryKey('env-1', 'wt-1') + +describe('workspace agent detection on a host without workspace-scoped detection', () => { + beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + runtimeEnvironmentCall.mockReset() + }) + + it.each([ + ['a Windows host', { hostPlatform: 'win32' as const, scoped: false }], + ['a host that does not report its platform', { scoped: false }] + ])('asks to update %s instead of answering its default list', async (_label, host) => { + serveHost(host) + const store = createTestStore() + + await expect(store.getState().ensureRuntimeDetectedAgents('env-1', 'wt-1')).resolves.toEqual([]) + + expect(agentCalls()).toEqual([]) + expect(store.getState().runtimeDetectedAgentIds[KEY]).toEqual([]) + expect(store.getState().runtimeAgentDetectionNeedsServerUpdate[KEY]).toBe(true) + expect(store.getState().isDetectingRuntimeAgents[KEY]).toBe(false) + }) + + it('replaces a cached list with the update notice on refresh', async () => { + serveHost({ hostPlatform: 'win32', scoped: false }) + const store = createTestStore() + store.setState({ runtimeDetectedAgentIds: { [KEY]: ['claude'] } }) + + await expect(store.getState().refreshRuntimeDetectedAgents('env-1', 'wt-1')).resolves.toEqual( + [] + ) + + expect(agentCalls()).toEqual([]) + expect(store.getState().runtimeDetectedAgentIds[KEY]).toEqual([]) + expect(store.getState().runtimeAgentDetectionNeedsServerUpdate[KEY]).toBe(true) + expect(store.getState().isRefreshingRuntimeAgents[KEY]).toBe(false) + }) + + it('still answers with the default list of a known non-Windows host', async () => { + serveHost({ hostPlatform: 'linux', scoped: false }) + const store = createTestStore() + + await expect(store.getState().ensureRuntimeDetectedAgents('env-1', 'wt-1')).resolves.toEqual([ + 'codex' + ]) + + expect(agentCalls()).toEqual([ + expect.objectContaining({ method: 'preflight.detectAgents', params: undefined }) + ]) + expect(store.getState().runtimeAgentDetectionNeedsServerUpdate[KEY]).toBeUndefined() + }) + + it('answers concurrent workspaces of a known non-Windows host with its default list', async () => { + serveHost({ hostPlatform: 'linux', scoped: false }) + const store = createTestStore() + + const lists = await Promise.all([ + store.getState().ensureRuntimeDetectedAgents('env-1', 'wt-1'), + store.getState().ensureRuntimeDetectedAgents('env-1', 'wt-2') + ]) + + expect(lists).toEqual([['codex'], ['codex']]) + expect(store.getState().runtimeAgentDetectionNeedsServerUpdate).toEqual({}) + }) + + it('still answers the host default list of an old Windows host', async () => { + serveHost({ hostPlatform: 'win32', scoped: false }) + const store = createTestStore() + + await expect(store.getState().ensureRuntimeDetectedAgents('env-1')).resolves.toEqual(['codex']) + + expect(store.getState().runtimeAgentDetectionNeedsServerUpdate).toEqual({}) + }) + + it('clears the notice once the host is updated', async () => { + serveHost({ hostPlatform: 'win32', scoped: false }) + const store = createTestStore() + await store.getState().ensureRuntimeDetectedAgents('env-1', 'wt-1') + + serveHost({ hostPlatform: 'win32', scoped: true }) + await expect(store.getState().ensureRuntimeDetectedAgents('env-1', 'wt-1')).resolves.toEqual([ + 'codex' + ]) + + expect(agentCalls()).toEqual([ + expect.objectContaining({ method: 'preflight.detectAgents', params: { worktreeId: 'wt-1' } }) + ]) + expect(store.getState().runtimeDetectedAgentIds[KEY]).toEqual(['codex']) + expect(store.getState().runtimeAgentDetectionNeedsServerUpdate[KEY]).toBeUndefined() + }) + + it('drops the notice with its environment', async () => { + serveHost({ hostPlatform: 'win32', scoped: false }) + const store = createTestStore() + await store.getState().ensureRuntimeDetectedAgents('env-1', 'wt-1') + + store.getState().clearRuntimeDetectedAgents('env-1') + + expect(store.getState().runtimeAgentDetectionNeedsServerUpdate).toEqual({}) + }) +}) diff --git a/src/renderer/src/store/slices/runtime-detected-agents.ts b/src/renderer/src/store/slices/runtime-detected-agents.ts index d990c304d05..6b5c8df21dd 100644 --- a/src/renderer/src/store/slices/runtime-detected-agents.ts +++ b/src/renderer/src/store/slices/runtime-detected-agents.ts @@ -3,6 +3,7 @@ import type { AppState } from '../types' import type { TuiAgent } from '../../../../shared/tui-agent' import { callRuntimeRpc, + getRuntimeEnvironmentStatus, RuntimeRpcCallError, runtimeEnvironmentSupportsCapability } from '@/runtime/runtime-rpc-client' @@ -21,6 +22,8 @@ export type RuntimeDetectedAgentsSlice = { runtimeDetectedAgentIds: Record isDetectingRuntimeAgents: Record isRefreshingRuntimeAgents: Record + /** Keys whose host is too old to list that workspace's agents; their list is empty. */ + runtimeAgentDetectionNeedsServerUpdate: Record ensureRuntimeDetectedAgents: ( environmentId: string, worktreeId?: string | null @@ -48,10 +51,17 @@ function isRuntimeMethodNotFoundError(error: unknown): boolean { return error instanceof RuntimeRpcCallError && error.code === 'method_not_found' } +export class RuntimeAgentDetectionNeedsServerUpdateError extends Error { + constructor() { + super("Update Orca on this server to list this workspace's agents.") + this.name = 'RuntimeAgentDetectionNeedsServerUpdateError' + } +} + /** * Sends `preflight.detectAgents` / `refreshAgents` scoped to a workspace. A host without the - * workspace-scoped capability would discard the params and answer with its own default, so it is - * asked for exactly that. The host default goes out synchronously, as it always has. + * workspace-scoped capability can only answer its own default, which is the workspace's list only + * on a known non-Windows host. The host default goes out synchronously, as it always has. */ export function callRuntimeAgentDetection( environmentId: string, @@ -61,10 +71,22 @@ export function callRuntimeAgentDetection( if (!worktreeId) { return call(undefined) } - return runtimeEnvironmentSupportsCapability( - environmentId, - PREFLIGHT_WORKSPACE_SCOPED_RUNTIME_CAPABILITY - ).then((supported) => call(supported ? { worktreeId } : undefined)) + const capability = PREFLIGHT_WORKSPACE_SCOPED_RUNTIME_CAPABILITY + return runtimeEnvironmentSupportsCapability(environmentId, capability).then(async (supported) => { + if (supported) { + return call({ worktreeId }) + } + // Why: a fresh status both reports the platform and catches an in-place upgrade. + const status = await getRuntimeEnvironmentStatus(environmentId) + if (status.capabilities?.includes(capability)) { + return call({ worktreeId }) + } + // Why: an old Windows host's default omits a WSL workspace's agents; an absent platform may be one. + if (!status.hostPlatform || status.hostPlatform === 'win32') { + throw new RuntimeAgentDetectionNeedsServerUpdateError() + } + return call(undefined) + }) } export function _getRuntimeDetectPromiseCountForTest(): number { @@ -104,18 +126,41 @@ export const createRuntimeDetectedAgentsSlice: StateCreator< const runtimeDetectedAgentIds = withoutKeys(s.runtimeDetectedAgentIds, dropKey) const isDetectingRuntimeAgents = withoutKeys(s.isDetectingRuntimeAgents, dropKey) const isRefreshingRuntimeAgents = withoutKeys(s.isRefreshingRuntimeAgents, dropKey) + const runtimeAgentDetectionNeedsServerUpdate = withoutKeys( + s.runtimeAgentDetectionNeedsServerUpdate, + dropKey + ) return runtimeDetectedAgentIds === s.runtimeDetectedAgentIds && isDetectingRuntimeAgents === s.isDetectingRuntimeAgents && - isRefreshingRuntimeAgents === s.isRefreshingRuntimeAgents + isRefreshingRuntimeAgents === s.isRefreshingRuntimeAgents && + runtimeAgentDetectionNeedsServerUpdate === s.runtimeAgentDetectionNeedsServerUpdate ? s - : { runtimeDetectedAgentIds, isDetectingRuntimeAgents, isRefreshingRuntimeAgents } + : { + runtimeDetectedAgentIds, + isDetectingRuntimeAgents, + isRefreshingRuntimeAgents, + runtimeAgentDetectionNeedsServerUpdate + } }) } + // Commits a settled detection; an old host commits an empty list, never its default. + const commitDetection = (key: string, agents: TuiAgent[], needsServerUpdate: boolean): void => { + set((s) => ({ + runtimeDetectedAgentIds: { ...s.runtimeDetectedAgentIds, [key]: agents }, + isDetectingRuntimeAgents: { ...s.isDetectingRuntimeAgents, [key]: false }, + isRefreshingRuntimeAgents: { ...s.isRefreshingRuntimeAgents, [key]: false }, + runtimeAgentDetectionNeedsServerUpdate: needsServerUpdate + ? { ...s.runtimeAgentDetectionNeedsServerUpdate, [key]: true } + : withoutKeys(s.runtimeAgentDetectionNeedsServerUpdate, (k) => k === key) + })) + } + return { runtimeDetectedAgentIds: {}, isDetectingRuntimeAgents: {}, isRefreshingRuntimeAgents: {}, + runtimeAgentDetectionNeedsServerUpdate: {}, ensureRuntimeDetectedAgents: (environmentId: string, worktreeId?: string | null) => { const key = getRuntimeAgentInventoryKey(environmentId, worktreeId) @@ -148,18 +193,20 @@ export const createRuntimeDetectedAgentsSlice: StateCreator< // while the detect was in flight — otherwise it re-adds a stale entry // that retainRuntimeDetectedAgents just pruned. if (runtimeDetectPromises.get(key) === pending) { - set((s) => ({ - runtimeDetectedAgentIds: { ...s.runtimeDetectedAgentIds, [key]: typed }, - isDetectingRuntimeAgents: { ...s.isDetectingRuntimeAgents, [key]: false } - })) + commitDetection(key, typed, false) } return typed }) - .catch((): TuiAgent[] => { + .catch((error: unknown): TuiAgent[] => { // Why: a remote runtime may be disconnected or version-incompatible. // Keep the menu retryable instead of pinning a failed probe forever. // Same in-flight guard as the .then() above. - if (runtimeDetectPromises.get(key) === pending) { + if (runtimeDetectPromises.get(key) !== pending) { + return [] + } + if (error instanceof RuntimeAgentDetectionNeedsServerUpdateError) { + commitDetection(key, [], true) + } else { set((s) => ({ isDetectingRuntimeAgents: { ...s.isDetectingRuntimeAgents, [key]: false } })) @@ -207,15 +254,18 @@ export const createRuntimeDetectedAgentsSlice: StateCreator< // Why: same guard as ensureRuntimeDetectedAgents — if the environment // was retained out mid-refresh, don't re-add a pruned entry. if (runtimeRefreshPromises.get(key) === pending) { - set((s) => ({ - runtimeDetectedAgentIds: { ...s.runtimeDetectedAgentIds, [key]: typed }, - isDetectingRuntimeAgents: { ...s.isDetectingRuntimeAgents, [key]: false }, - isRefreshingRuntimeAgents: { ...s.isRefreshingRuntimeAgents, [key]: false } - })) + commitDetection(key, typed, false) } return typed }) - .catch(() => { + .catch((error: unknown) => { + if ( + error instanceof RuntimeAgentDetectionNeedsServerUpdateError && + runtimeRefreshPromises.get(key) === pending + ) { + commitDetection(key, [], true) + return [] + } // Why: a disconnected runtime must keep Refresh retryable and must not // wipe the last known agent list. if (runtimeRefreshPromises.get(key) === pending) {