diff --git a/src/renderer/src/app-shell/use-app-startup-hydration.ts b/src/renderer/src/app-shell/use-app-startup-hydration.ts index 77da19ffd20..a2fd133ae32 100644 --- a/src/renderer/src/app-shell/use-app-startup-hydration.ts +++ b/src/renderer/src/app-shell/use-app-startup-hydration.ts @@ -36,6 +36,7 @@ import { import { mapWithConcurrency } from '../../../shared/map-with-concurrency' import type { OnboardingState } from '../../../shared/onboarding-state-types' import { restoreLocalStructuredSessionTabsOnce } from '../runtime/local-structured-session-tabs-sync' +import { ensureLocalRuntimeCapabilities } from '../runtime/local-runtime-capabilities' async function listRuntimeSessionHostIdsForStartup(): Promise { try { @@ -67,6 +68,12 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta // Fetch initial data + hydrate GitHub cache from disk useEffect(() => { + // Why first and ungated: the local capability set is a static fact the main process can answer + // immediately, but its only other writer is the structured-session-tabs sync, which waits for + // workspaceSessionReady + terminalStartupRestorationReady + the experimental flag. Every + // `resolveAgentLaunchRoute` reader treats "not asked yet" as "unsupported", so leaving the + // answer behind those gates degrades a pre-hydration create to a bare terminal (#19154). + void ensureLocalRuntimeCapabilities() let cancelled = false // Why: declared outside the async block so cleanup can abort it — under StrictMode the first (unmounted) pass would otherwise keep spawning PTYs. const abortController = new AbortController() diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index fead2f6c7bb..4c6ab94f010 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -367,6 +367,24 @@ describe('renderer startup runtime routing', () => { ) }) + it('probes local runtime capabilities before any startup gate can hold the answer back', () => { + const source = readSource(STARTUP_HYDRATION_PATH) + const probeIndex = source.indexOf('void ensureLocalRuntimeCapabilities()') + const chainStart = source.indexOf('void (async () => {') + const effectStart = source.lastIndexOf('useEffect(() => {', probeIndex) + + expect(probeIndex).toBeGreaterThanOrEqual(0) + // Why pinned here: the structured-session-tabs sync is the cache's only other writer and it + // waits for workspaceSessionReady + terminalStartupRestorationReady + the experimental flag. + // Every resolveAgentLaunchRoute reader — including the three that cannot await — reads an + // unanswered cache as "unsupported", so a create in that window degrades to a bare + // terminal (#19154). The probe must therefore start before the chain and outside its gates. + expect(probeIndex).toBeLessThan(chainStart) + expect(probeIndex).toBeLessThan(source.indexOf('await ', effectStart)) + expect(source.slice(effectStart, probeIndex)).not.toContain('if (') + expect(source.slice(effectStart, probeIndex)).not.toContain('experimentalStructuredNativeChat') + }) + it('orders packaged restoration before adoption, projection, and default creation', () => { // Why this file: the startup sequence moved out of App.tsx into the hydration hook; // the ordering it asserts is unchanged, only the module that now spells it out. diff --git a/src/renderer/src/runtime/local-runtime-capabilities.test.ts b/src/renderer/src/runtime/local-runtime-capabilities.test.ts index eedd31a748a..d205e712867 100644 --- a/src/renderer/src/runtime/local-runtime-capabilities.test.ts +++ b/src/renderer/src/runtime/local-runtime-capabilities.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { + ensureLocalRuntimeCapabilities, readLocalRuntimeCapabilities, readLocalRuntimeCapabilitiesOrUnknown, refreshLocalRuntimeCapabilities, @@ -82,4 +83,54 @@ describe('local runtime capabilities', () => { await refreshLocalRuntimeCapabilities() expect(readLocalRuntimeCapabilitiesOrUnknown()).toEqual(['agent-session.structured.v1']) }) + it('ensure probes the runtime when no answer has landed yet', async () => { + setLocalRuntimeCapabilitiesForTests(null) + const getStatus = vi.fn(async () => ({ capabilities: ['agent-session.structured.v1'] })) + Object.assign(window, { api: { runtime: { getStatus } } }) + + await expect(ensureLocalRuntimeCapabilities()).resolves.toEqual(['agent-session.structured.v1']) + expect(getStatus).toHaveBeenCalledOnce() + }) + + it('ensure returns the cached answer without probing again', async () => { + setLocalRuntimeCapabilitiesForTests(['agent-session.structured.v1']) + const getStatus = vi.fn(async () => ({ capabilities: [] })) + Object.assign(window, { api: { runtime: { getStatus } } }) + + await expect(ensureLocalRuntimeCapabilities()).resolves.toEqual(['agent-session.structured.v1']) + expect(getStatus).not.toHaveBeenCalled() + }) + + it('ensure stays unknown after a failed probe and re-probes on the next call', async () => { + setLocalRuntimeCapabilitiesForTests(null) + const getStatus = vi + .fn() + .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValueOnce({ capabilities: ['agent-session.structured.v1'] }) + Object.assign(window, { api: { runtime: { getStatus } } }) + + // A failed probe is not evidence about the host, so it must not latch as a denial: + // the answer stays unknown and the next caller pays for a fresh probe. + await expect(ensureLocalRuntimeCapabilities()).resolves.toBeNull() + await expect(ensureLocalRuntimeCapabilities()).resolves.toEqual(['agent-session.structured.v1']) + expect(getStatus).toHaveBeenCalledTimes(2) + }) + + it('ensure never rejects when the preload bridge is missing', async () => { + setLocalRuntimeCapabilitiesForTests(null) + Reflect.deleteProperty(window, 'api') + + await expect(ensureLocalRuntimeCapabilities()).resolves.toBeNull() + }) + + it('coalesces concurrent ensure callers onto one probe', async () => { + setLocalRuntimeCapabilitiesForTests(null) + const getStatus = vi.fn(async () => ({ capabilities: ['agent-session.structured.v1'] })) + Object.assign(window, { api: { runtime: { getStatus } } }) + + await expect( + Promise.all([ensureLocalRuntimeCapabilities(), ensureLocalRuntimeCapabilities()]) + ).resolves.toEqual([['agent-session.structured.v1'], ['agent-session.structured.v1']]) + expect(getStatus).toHaveBeenCalledOnce() + }) }) diff --git a/src/renderer/src/runtime/local-runtime-capabilities.ts b/src/renderer/src/runtime/local-runtime-capabilities.ts index 2bb0e1916d3..f4c83880503 100644 --- a/src/renderer/src/runtime/local-runtime-capabilities.ts +++ b/src/renderer/src/runtime/local-runtime-capabilities.ts @@ -15,9 +15,37 @@ export function readLocalRuntimeCapabilitiesOrUnknown(): readonly RuntimeCapabil return localRuntimeCapabilities } +/** Like `readLocalRuntimeCapabilitiesOrUnknown`, but probes the local runtime when no answer + * has landed yet, so a caller that can wait never reads "not asked yet" as "unsupported" + * (#19154: that reads a structured-native-chat create as a bare terminal). + * + * The renderer boot chain calls this once, ungated, so the answer is normally already cached + * by the time any launch route is resolved — including for the readers that are synchronous + * and cannot await. Awaiting it at a route decision is the backstop for the residual window + * and for re-probing after a failed one. Still `null` after an actually failed probe, and + * never rejects. */ +export async function ensureLocalRuntimeCapabilities(): Promise< + readonly RuntimeCapability[] | null +> { + if (localRuntimeCapabilities !== null) { + return localRuntimeCapabilities + } + await refreshLocalRuntimeCapabilities() + return localRuntimeCapabilities +} + +/** `refreshLocalRuntimeCapabilities` is not `async`, so a missing or broken preload bridge would + * throw synchronously out of it instead of settling into the unknown state its catch owns. */ +function startLocalRuntimeCapabilityProbe(): ReturnType { + try { + return window.api.runtime.getStatus() + } catch (error) { + return Promise.reject(error) + } +} + export function refreshLocalRuntimeCapabilities(): Promise { - refreshPromise ??= window.api.runtime - .getStatus() + refreshPromise ??= startLocalRuntimeCapabilityProbe() .then((status) => { localRuntimeCapabilities = [...(status.capabilities ?? [])] return localRuntimeCapabilities @@ -35,8 +63,8 @@ export function refreshLocalRuntimeCapabilities(): Promise