diff --git a/src/renderer/src/runtime/runtime-rpc-client.ts b/src/renderer/src/runtime/runtime-rpc-client.ts index 9ff3e26e6b0..79a15f8ac0d 100644 --- a/src/renderer/src/runtime/runtime-rpc-client.ts +++ b/src/renderer/src/runtime/runtime-rpc-client.ts @@ -133,6 +133,14 @@ export function clearRuntimeCompatibilityCache(environmentId?: string | null): v compatibleRuntimeEnvironments.clear() } +export function markRuntimeEnvironmentCompatible(environmentId: string): void { + const trimmed = environmentId.trim() + if (!trimmed) { + return + } + rememberRuntimeEnvironmentCompatibility(trimmed, Promise.resolve()) +} + export function clearRuntimeCompatibilityCacheForTests(): void { clearRuntimeCompatibilityCache() } diff --git a/src/renderer/src/store/slices/settings.test.ts b/src/renderer/src/store/slices/settings.test.ts index 92edb39c33a..41040c4c8ea 100644 --- a/src/renderer/src/store/slices/settings.test.ts +++ b/src/renderer/src/store/slices/settings.test.ts @@ -21,6 +21,7 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { }) const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentGetStatus = vi.fn() const settingsSet = vi.fn().mockResolvedValue(undefined) const env2Lineage: WorktreeLineage = { @@ -37,6 +38,17 @@ beforeEach(() => { delete (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ clearRuntimeCompatibilityCacheForTests() vi.clearAllMocks() + runtimeEnvironmentGetStatus.mockResolvedValue({ + id: 'status-rpc-1', + ok: true, + result: { + runtimeId: 'runtime-2', + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION + }, + _meta: { runtimeId: 'runtime-2' } + }) runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => { const result = method === 'status.get' @@ -100,7 +112,7 @@ beforeEach(() => { vi.stubGlobal('window', { api: { settings: { set: settingsSet }, - runtimeEnvironments: { call: runtimeEnvironmentCall } + runtimeEnvironments: { call: runtimeEnvironmentCall, getStatus: runtimeEnvironmentGetStatus } } }) }) @@ -212,7 +224,11 @@ describe('createSettingsSlice runtime switching', () => { await expect(store.getState().switchRuntimeEnvironment('env-2')).resolves.toBe(true) expect(settingsSet).toHaveBeenCalledWith({ activeRuntimeEnvironmentId: 'env-2' }) - expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect(runtimeEnvironmentGetStatus).toHaveBeenCalledWith({ + selector: 'env-2', + timeoutMs: 15_000 + }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( expect.objectContaining({ selector: 'env-2', method: 'status.get' }) ) expect(runtimeEnvironmentCall).toHaveBeenCalledWith( @@ -348,7 +364,7 @@ describe('createSettingsSlice runtime switching', () => { }) it('keeps the current environment when the selected remote server is unreachable', async () => { - runtimeEnvironmentCall.mockRejectedValueOnce( + runtimeEnvironmentGetStatus.mockRejectedValueOnce( new Error('Remote Orca runtime closed the connection.') ) const store = createTestStore() @@ -362,9 +378,11 @@ describe('createSettingsSlice runtime switching', () => { await expect(store.getState().switchRuntimeEnvironment('env-2')).resolves.toBe(false) expect(settingsSet).not.toHaveBeenCalled() - expect(runtimeEnvironmentCall).toHaveBeenCalledWith( - expect.objectContaining({ selector: 'env-2', method: 'status.get' }) - ) + expect(runtimeEnvironmentGetStatus).toHaveBeenCalledWith({ + selector: 'env-2', + timeoutMs: 15_000 + }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( expect.objectContaining({ selector: 'env-1', method: 'terminal.close' }) ) @@ -377,22 +395,16 @@ describe('createSettingsSlice runtime switching', () => { }) it('keeps the current environment when the selected server is protocol-incompatible', async () => { - runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => { - const result = - method === 'status.get' - ? { - runtimeId: 'runtime-old', - graphStatus: 'ready', - runtimeProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION - 1, - minCompatibleRuntimeClientVersion: 0 - } - : {} - return Promise.resolve({ - id: 'rpc-1', - ok: true, - result, - _meta: { runtimeId: 'runtime-old' } - }) + runtimeEnvironmentGetStatus.mockResolvedValueOnce({ + id: 'status-rpc-old', + ok: true, + result: { + runtimeId: 'runtime-old', + graphStatus: 'ready', + runtimeProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION - 1, + minCompatibleRuntimeClientVersion: 0 + }, + _meta: { runtimeId: 'runtime-old' } }) const store = createTestStore() store.setState({ @@ -404,12 +416,11 @@ describe('createSettingsSlice runtime switching', () => { await expect(store.getState().switchRuntimeEnvironment('env-old')).resolves.toBe(false) expect(settingsSet).not.toHaveBeenCalled() - expect(runtimeEnvironmentCall).toHaveBeenCalledWith( - expect.objectContaining({ selector: 'env-old', method: 'status.get' }) - ) - expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( - expect.objectContaining({ selector: 'env-old', method: 'repo.list' }) - ) + expect(runtimeEnvironmentGetStatus).toHaveBeenCalledWith({ + selector: 'env-old', + timeoutMs: 15_000 + }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() expect(toast.error).toHaveBeenCalledWith('Failed to switch servers', { description: expect.stringContaining('server is too old') }) diff --git a/src/renderer/src/store/slices/settings.ts b/src/renderer/src/store/slices/settings.ts index d7bd1ddf6d5..2b575e1ef5d 100644 --- a/src/renderer/src/store/slices/settings.ts +++ b/src/renderer/src/store/slices/settings.ts @@ -3,11 +3,18 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { GlobalSettings } from '../../../../shared/types' import { toast } from 'sonner' -import { callRuntimeRpc, clearRuntimeCompatibilityCache } from '@/runtime/runtime-rpc-client' +import { + callRuntimeRpc, + clearRuntimeCompatibilityCache, + markRuntimeEnvironmentCompatible, + unwrapRuntimeRpcResult +} from '@/runtime/runtime-rpc-client' +import { assertRuntimeStatusCompatible } from '@/runtime/runtime-protocol-compat' import { getRemoteRuntimePtyEnvironmentId, getRemoteRuntimeTerminalHandle } from '@/runtime/runtime-terminal-stream' +import type { RuntimeStatus } from '../../../../shared/runtime-types' import { normalizeTerminalQuickCommands } from '../../../../shared/terminal-quick-commands' import { normalizeTaskProviderSettings } from '../../../../shared/task-providers' import { normalizeOpenInApplications } from '../../../../shared/open-in-applications' @@ -236,9 +243,15 @@ async function verifyRuntimeEnvironmentReachable(environmentId: string | null): if (!environmentId) { return } - await callRuntimeRpc({ kind: 'environment', environmentId }, 'repo.list', undefined, { + const response = await window.api.runtimeEnvironments.getStatus({ + selector: environmentId, timeoutMs: 15_000 }) + const status = unwrapRuntimeRpcResult(response) + assertRuntimeStatusCompatible(status) + // Why: the switch probe already proved compatibility; avoid immediately + // re-probing through the heavier generic runtime RPC path during hydration. + markRuntimeEnvironmentCompatible(environmentId) } export const createSettingsSlice: StateCreator = (set, get) => ({