From fd0fc80c8c3ba4e0ad98db70038e473d5d35b52f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 12 Aug 2026 18:53:44 -0700 Subject: [PATCH] test(microsoft-foundry): remove global test bridge (#122881) * test(microsoft-foundry): remove global test bridge * test(microsoft-foundry): type boundary fixtures * perf(ui): keep route transition out of startup --- extensions/microsoft-foundry/auth.ts | 6 - extensions/microsoft-foundry/index.test.ts | 155 +++++++----------- .../onboard.connection.test.ts | 112 ++++++++++++- extensions/microsoft-foundry/onboard.ts | 6 - extensions/microsoft-foundry/runtime.ts | 11 -- extensions/microsoft-foundry/shared.ts | 12 -- extensions/microsoft-foundry/test-support.ts | 27 --- ui/src/app/bootstrap.ts | 17 +- ui/src/app/context.ts | 2 +- .../new-session/draft-submission-flow.test.ts | 15 +- .../new-session/draft-submission-flow.ts | 27 ++- 11 files changed, 205 insertions(+), 185 deletions(-) delete mode 100644 extensions/microsoft-foundry/test-support.ts diff --git a/extensions/microsoft-foundry/auth.ts b/extensions/microsoft-foundry/auth.ts index 4882f2d333c5..92eb69c0b356 100644 --- a/extensions/microsoft-foundry/auth.ts +++ b/extensions/microsoft-foundry/auth.ts @@ -44,12 +44,6 @@ function shouldTestFoundryTextConnection(params: { ); } -if (process.env.VITEST === "true") { - const key = Symbol.for("openclaw.microsoftFoundryTestApi"); - const api = (Reflect.get(globalThis, key) as Record | undefined) ?? {}; - Reflect.set(globalThis, key, { ...api, shouldTestFoundryTextConnection }); -} - export const entraIdAuthMethod: ProviderAuthMethod = { id: "entra-id", label: "Entra ID (az login)", diff --git a/extensions/microsoft-foundry/index.test.ts b/extensions/microsoft-foundry/index.test.ts index 44d15153420e..a07503c3d263 100644 --- a/extensions/microsoft-foundry/index.test.ts +++ b/extensions/microsoft-foundry/index.test.ts @@ -1,6 +1,7 @@ // Microsoft Foundry tests cover index plugin behavior. import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import type { ProviderAuthMethod } from "openclaw/plugin-sdk/core"; import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { azLoginDeviceCodeWithOptions, getAccessTokenResultAsync } from "./cli.js"; @@ -22,19 +23,6 @@ import { requiresFoundryEntraIdClaudeAuth, usesFoundryResponsesByDefault, } from "./shared.js"; -import { microsoftFoundryTesting } from "./test-support.js"; - -const { - buildFoundryConnectionTest, - isAnthropicFoundryDeployment, - isValidTenantIdentifier, - resetFoundryRuntimeAuthCaches, - shouldTestFoundryTextConnection, - supportsFoundryImageInput, - supportsFoundryReasoningContent, - supportsFoundryReasoningEffort, -} = microsoftFoundryTesting; - const execFileMock = vi.hoisted(() => vi.fn()); const execFileSyncMock = vi.hoisted(() => vi.fn()); const runCommandWithTimeoutMock = vi.hoisted(() => vi.fn()); @@ -138,6 +126,8 @@ const defaultFoundryModelId = "gpt-5.4"; const defaultFoundryProfileId = "microsoft-foundry:entra"; const defaultFoundryAgentDir = "/tmp/test-agent"; const defaultAzureCliLoginError = "Please run 'az login' to setup account."; +let runtimeAuthTestSequence = 0; +let runtimeAuthTestTenantId = "tenant-0"; function buildFoundryModel( overrides: Partial<{ @@ -224,7 +214,7 @@ function buildEntraProfileStore( modelId: "custom-deployment", modelName: defaultFoundryModelId, api: "openai-responses", - tenantId: "tenant-id", + tenantId: runtimeAuthTestTenantId, ...overrides, }, }, @@ -291,7 +281,8 @@ function mockAzureCliLoginFailure(delayMs?: number) { describe("microsoft-foundry plugin", () => { beforeEach(() => { - resetFoundryRuntimeAuthCaches(); + runtimeAuthTestSequence += 1; + runtimeAuthTestTenantId = `tenant-${runtimeAuthTestSequence}`; execFileMock.mockReset(); execFileSyncMock.mockReset(); ensureAuthProfileStoreMock.mockReset(); @@ -862,12 +853,6 @@ describe("microsoft-foundry plugin", () => { ); }); - it("accepts tenant domains as valid tenant identifiers", () => { - expect(isValidTenantIdentifier("contoso.onmicrosoft.com")).toBe(true); - expect(isValidTenantIdentifier("00000000-0000-0000-0000-000000000000")).toBe(true); - expect(isValidTenantIdentifier("not a tenant")).toBe(false); - }); - it("defaults Azure OpenAI model families to the documented API surfaces", () => { expect(usesFoundryResponsesByDefault("gpt-5.4")).toBe(true); expect(usesFoundryResponsesByDefault("gpt-5.2-codex")).toBe(true); @@ -879,16 +864,6 @@ describe("microsoft-foundry plugin", () => { expect(requiresFoundryMaxCompletionTokens("gpt-5-chat")).toBe(true); expect(requiresFoundryMaxCompletionTokens("o3")).toBe(true); expect(requiresFoundryMaxCompletionTokens("gpt-4o")).toBe(false); - expect(supportsFoundryReasoningEffort("gpt-5.4")).toBe(true); - expect(supportsFoundryReasoningEffort("gpt-5-chat")).toBe(false); - expect(supportsFoundryReasoningEffort("gpt-5.1-chat")).toBe(true); - expect(supportsFoundryReasoningEffort("o3")).toBe(true); - expect(supportsFoundryReasoningEffort("o1-mini")).toBe(false); - expect(supportsFoundryReasoningEffort("MAI-DS-R1")).toBe(false); - expect(supportsFoundryReasoningContent("MAI-DS-R1")).toBe(true); - expect(supportsFoundryImageInput("gpt-5.4")).toBe(true); - expect(supportsFoundryImageInput("gpt-4o")).toBe(true); - expect(supportsFoundryImageInput("MAI-DS-R1")).toBe(false); expect(isFoundryMaiImageModel("MAI-Image-2.5-Flash")).toBe(true); expect(isFoundryMaiImageModel("MAI-Image-2e")).toBe(true); expect(isFoundryMaiImageModel("MAI-DS-R1")).toBe(false); @@ -940,19 +915,59 @@ describe("microsoft-foundry plugin", () => { expect(requireFoundryProviderPatch(result).models[0]?.name).toBe("MAI-Image-2.5"); }); - it("skips chat connection probes for MAI image deployments", () => { + it("skips chat connection probes for MAI image deployments", async () => { + execFileSyncMock.mockImplementation((_command, args) => { + const azArgs = args as string[]; + if (azArgs[0] === "version") { + return ""; + } + if (azArgs[0] === "account" && azArgs[1] === "show") { + return JSON.stringify({ + name: "Foundry Account", + id: "account-id", + tenantId: "tenant-id", + user: { name: "operator@example.com" }, + }); + } + if (azArgs[0] === "account" && azArgs[1] === "list") { + return "[]"; + } + throw new Error(`unexpected az command: ${azArgs.join(" ")}`); + }); + const provider = registerProvider(); + const authMethod = provider.auth.find((method: ProviderAuthMethod) => method.id === "entra-id"); + if (!authMethod) { + throw new Error("expected Microsoft Foundry Entra auth method"); + } + const text = vi + .fn() + .mockResolvedValueOnce("https://example.services.ai.azure.com") + .mockResolvedValueOnce("prod-image"); + const select = vi + .fn() + .mockResolvedValueOnce("mai-image") + .mockResolvedValueOnce("MAI-Image-2.5"); + + const result = await authMethod.run({ + config: {}, + agentDir: defaultFoundryAgentDir, + prompter: { + confirm: vi.fn(async () => true), + note: vi.fn(async () => undefined), + text, + select, + }, + } as never); + expect( - shouldTestFoundryTextConnection({ - modelId: "prod-image", - modelNameHint: "MAI-Image-2.5", + execFileSyncMock.mock.calls.some((call) => { + const args = call[1]; + return Array.isArray(args) && args[0] === "account" && args[1] === "get-access-token"; }), ).toBe(false); - expect( - shouldTestFoundryTextConnection({ - modelId: "prod-chat", - modelNameHint: "gpt-5.4", - }), - ).toBe(true); + expect(result.configPatch?.agents?.defaults?.mediaModels?.image).toEqual({ + primary: "microsoft-foundry/prod-image", + }); }); it("classifies custom API-key MAI image deployments during manual setup", async () => { @@ -1273,19 +1288,6 @@ describe("microsoft-foundry plugin", () => { expect(Object.hasOwn(provider, "headers")).toBe(true); }); - it("uses the minimum supported response token count for GPT-5 connection tests", () => { - const testRequest = buildFoundryConnectionTest({ - endpoint: "https://example.services.ai.azure.com", - modelId: "gpt-5.4", - modelNameHint: "gpt-5.4", - api: "openai-responses", - }); - - expect(testRequest.url).toContain("/responses"); - expect(testRequest.body.model).toBe("gpt-5.4"); - expect(testRequest.body.max_output_tokens).toBe(16); - }); - it("marks Foundry responses models to omit explicit store=false payloads", () => { const result = buildFoundryAuthResult({ profileId: "microsoft-foundry:entra", @@ -1855,36 +1857,6 @@ describe("microsoft-foundry plugin", () => { ).toBe("https://example.services.ai.azure.com"); }); - it("includes api-version for non GPT-5 chat completion connection tests", () => { - const testRequest = buildFoundryConnectionTest({ - endpoint: "https://example.services.ai.azure.com", - modelId: "FW-GLM-5", - modelNameHint: "FW-GLM-5", - api: "openai-completions", - }); - - expect(testRequest.url).toContain("/chat/completions"); - expect(testRequest.body.model).toBe("FW-GLM-5"); - expect(testRequest.body.max_tokens).toBe(1); - }); - - it("builds Anthropic Messages connection tests for Claude deployments", () => { - const testRequest = buildFoundryConnectionTest({ - endpoint: "https://example.services.ai.azure.com/openai/v1", - modelId: "prod-fable", - modelNameHint: "claude-fable-5", - api: "anthropic-messages", - }); - - expect(testRequest.url).toBe("https://example.services.ai.azure.com/anthropic/v1/messages"); - expect(testRequest.body).toEqual({ - model: "prod-fable", - messages: [{ role: "user", content: "hi" }], - max_tokens: 1, - thinking: { type: "adaptive" }, - }); - }); - it("returns actionable Azure CLI login errors", async () => { mockAzureCliLoginFailure(); @@ -2088,21 +2060,6 @@ describe("selectFoundryDeployment", () => { }); }); -describe("isAnthropicFoundryDeployment", () => { - it.each(["claude-opus-4-6", "Claude-Sonnet-4", "claude-3.5-haiku", "CLAUDE-instant"])( - "detects Anthropic model: %s", - (name) => { - expect(isAnthropicFoundryDeployment(name)).toBe(true); - }, - ); - - it.each(["gpt-5.4", "o4-mini", "phi-4", "llama-3", undefined, null, ""])( - "rejects non-Anthropic model: %s", - (name) => { - expect(isAnthropicFoundryDeployment(name)).toBe(false); - }, - ); -}); describe("azLoginDeviceCodeWithOptions utf-8 chunk boundary", () => { afterEach(() => { vi.restoreAllMocks(); diff --git a/extensions/microsoft-foundry/onboard.connection.test.ts b/extensions/microsoft-foundry/onboard.connection.test.ts index 3b9787183c42..b81e285feb98 100644 --- a/extensions/microsoft-foundry/onboard.connection.test.ts +++ b/extensions/microsoft-foundry/onboard.connection.test.ts @@ -1,8 +1,13 @@ // Microsoft Foundry tests cover bounded connection-test error reads. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import * as cli from "./cli.js"; -import { testFoundryConnection } from "./onboard.js"; -import { DEFAULT_API } from "./shared.js"; +import { promptTenantId, testFoundryConnection } from "./onboard.js"; +import { + ANTHROPIC_MESSAGES_API, + DEFAULT_API, + DEFAULT_GPT5_API, + type FoundryProviderApi, +} from "./shared.js"; const hoisted = vi.hoisted(() => ({ fetchWithSsrFGuard: vi.fn(), @@ -12,6 +17,59 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ fetchWithSsrFGuard: hoisted.fetchWithSsrFGuard, })); +type FoundryConnectionRequestCase = { + name: string; + endpoint: string; + modelId: string; + modelNameHint: string; + api: FoundryProviderApi; + expectedUrl: string; + expectedBody: Record; + expectedHeaders: Record; +}; + +const foundryConnectionRequestCases: FoundryConnectionRequestCase[] = [ + { + name: "Responses", + endpoint: "https://example.services.ai.azure.com", + modelId: "gpt-5.4", + modelNameHint: "gpt-5.4", + api: DEFAULT_GPT5_API, + expectedUrl: "https://example.services.ai.azure.com/openai/v1/responses", + expectedBody: { model: "gpt-5.4", input: "hi", max_output_tokens: 16 }, + expectedHeaders: {}, + }, + { + name: "Chat Completions", + endpoint: "https://example.services.ai.azure.com", + modelId: "FW-GLM-5", + modelNameHint: "FW-GLM-5", + api: DEFAULT_API, + expectedUrl: "https://example.services.ai.azure.com/openai/v1/chat/completions", + expectedBody: { + model: "FW-GLM-5", + messages: [{ role: "user", content: "hi" }], + max_tokens: 1, + }, + expectedHeaders: {}, + }, + { + name: "Anthropic Messages", + endpoint: "https://example.services.ai.azure.com/openai/v1", + modelId: "prod-fable", + modelNameHint: "claude-fable-5", + api: ANTHROPIC_MESSAGES_API, + expectedUrl: "https://example.services.ai.azure.com/anthropic/v1/messages", + expectedBody: { + model: "prod-fable", + messages: [{ role: "user", content: "hi" }], + max_tokens: 1, + thinking: { type: "adaptive" }, + }, + expectedHeaders: { "anthropic-version": "2023-06-01" }, + }, +]; + function cancelTrackedResponse( text: string, init: ResponseInit, @@ -44,6 +102,39 @@ describe("testFoundryConnection", () => { hoisted.fetchWithSsrFGuard.mockReset(); }); + it.each(foundryConnectionRequestCases)( + "sends the $name connection request through the guarded transport", + async (testCase) => { + const release = vi.fn(async () => undefined); + hoisted.fetchWithSsrFGuard.mockResolvedValue({ + response: new Response(null, { status: 200 }), + release, + }); + + await testFoundryConnection({ + ctx: { prompter: { note: vi.fn() } } as never, + endpoint: testCase.endpoint, + modelId: testCase.modelId, + modelNameHint: testCase.modelNameHint, + api: testCase.api, + }); + + const request = hoisted.fetchWithSsrFGuard.mock.calls[0]?.[0]; + expect(request?.url).toBe(testCase.expectedUrl); + expect(request?.timeoutMs).toBe(15_000); + expect(request?.init?.method).toBe("POST"); + expect(request?.init?.body).toBe(JSON.stringify(testCase.expectedBody)); + expect(new Headers(request?.init?.headers)).toEqual( + new Headers({ + Authorization: "Bearer token", + "Content-Type": "application/json", + ...testCase.expectedHeaders, + }), + ); + expect(release).toHaveBeenCalledTimes(1); + }, + ); + it("bounds connection-test error bodies without using response.text()", async () => { const note = vi.fn(); const tracked = cancelTrackedResponse(`${"foundry failure ".repeat(1024)}tail`, { @@ -107,3 +198,20 @@ describe("testFoundryConnection", () => { }, ); }); + +describe("promptTenantId", () => { + it("validates tenant domains and UUIDs through the prompt boundary", async () => { + const text = vi.fn(async (options: { validate?: (value: string) => string | undefined }) => { + expect(options.validate?.("contoso.onmicrosoft.com")).toBeUndefined(); + expect(options.validate?.("00000000-0000-0000-0000-000000000000")).toBeUndefined(); + expect(options.validate?.("not a tenant")).toBe("Enter a valid tenant ID or tenant domain"); + expect(options.validate?.("")).toBe("Tenant ID is required"); + return "contoso.onmicrosoft.com"; + }); + + await expect(promptTenantId({ prompter: { text } } as never, { required: true })).resolves.toBe( + "contoso.onmicrosoft.com", + ); + expect(text).toHaveBeenCalledTimes(1); + }); +}); diff --git a/extensions/microsoft-foundry/onboard.ts b/extensions/microsoft-foundry/onboard.ts index f63e754f7f6a..1809e06b632a 100644 --- a/extensions/microsoft-foundry/onboard.ts +++ b/extensions/microsoft-foundry/onboard.ts @@ -503,12 +503,6 @@ function isValidTenantIdentifier(value: string): boolean { return isTenantUuid || isTenantDomain; } -if (process.env.VITEST === "true") { - const key = Symbol.for("openclaw.microsoftFoundryTestApi"); - const api = (Reflect.get(globalThis, key) as Record | undefined) ?? {}; - Reflect.set(globalThis, key, { ...api, buildFoundryConnectionTest, isValidTenantIdentifier }); -} - export async function promptTenantId( ctx: ProviderAuthContext, params?: { diff --git a/extensions/microsoft-foundry/runtime.ts b/extensions/microsoft-foundry/runtime.ts index 3d61534e3285..fc17210b0d4d 100644 --- a/extensions/microsoft-foundry/runtime.ts +++ b/extensions/microsoft-foundry/runtime.ts @@ -28,17 +28,6 @@ const cachedTokens = new Map(); const refreshPromises = new Map>(); const FOUNDRY_TOKEN_FALLBACK_LIFETIME_MS = 55 * 60 * 1000; -function resetFoundryRuntimeAuthCaches(): void { - cachedTokens.clear(); - refreshPromises.clear(); -} - -if (process.env.VITEST === "true") { - const key = Symbol.for("openclaw.microsoftFoundryTestApi"); - const api = (Reflect.get(globalThis, key) as Record | undefined) ?? {}; - Reflect.set(globalThis, key, { ...api, resetFoundryRuntimeAuthCaches }); -} - async function refreshEntraToken(params?: { scope?: string; subscriptionId?: string; diff --git a/extensions/microsoft-foundry/shared.ts b/extensions/microsoft-foundry/shared.ts index fb100efb3644..bbf21b517b0b 100644 --- a/extensions/microsoft-foundry/shared.ts +++ b/extensions/microsoft-foundry/shared.ts @@ -295,18 +295,6 @@ function supportsFoundryReasoningEffort(value?: string | null): boolean { ); } -if (process.env.VITEST === "true") { - const key = Symbol.for("openclaw.microsoftFoundryTestApi"); - const api = (Reflect.get(globalThis, key) as Record | undefined) ?? {}; - Reflect.set(globalThis, key, { - ...api, - isAnthropicFoundryDeployment, - supportsFoundryImageInput, - supportsFoundryReasoningContent, - supportsFoundryReasoningEffort, - }); -} - function resolveFoundryReasoningEfforts(value?: string | null): string[] | undefined { const normalized = normalizeFoundryModelName(value); if (!normalized || !supportsFoundryReasoningEffort(normalized)) { diff --git a/extensions/microsoft-foundry/test-support.ts b/extensions/microsoft-foundry/test-support.ts deleted file mode 100644 index 452416cb3f54..000000000000 --- a/extensions/microsoft-foundry/test-support.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { FoundryProviderApi } from "./shared.js"; - -type MicrosoftFoundryTestApi = { - buildFoundryConnectionTest: (params: { - endpoint: string; - modelId: string; - modelNameHint?: string | null; - api: FoundryProviderApi; - }) => { url: string; body: Record }; - isAnthropicFoundryDeployment: (value?: string | null) => boolean; - isValidTenantIdentifier: (value: string) => boolean; - resetFoundryRuntimeAuthCaches: () => void; - shouldTestFoundryTextConnection: (params: { - modelId: string; - modelNameHint?: string | null; - }) => boolean; - supportsFoundryImageInput: (value?: string | null) => boolean; - supportsFoundryReasoningContent: (value?: string | null) => boolean; - supportsFoundryReasoningEffort: (value?: string | null) => boolean; -}; - -const api = Reflect.get(globalThis, Symbol.for("openclaw.microsoftFoundryTestApi")); -if (!api) { - throw new Error("Microsoft Foundry test API is unavailable"); -} - -export const microsoftFoundryTesting = api as MicrosoftFoundryTestApi; diff --git a/ui/src/app/bootstrap.ts b/ui/src/app/bootstrap.ts index baa2ed75861d..3a0575971f19 100644 --- a/ui/src/app/bootstrap.ts +++ b/ui/src/app/bootstrap.ts @@ -44,7 +44,6 @@ import { createNativeChatDrafts } from "./native-bridge.ts"; import { startNativeLinkRouting } from "./native-link-routing.ts"; import { createNativeNotificationsCapability } from "./native-notifications.ts"; import { createApplicationOverlays } from "./overlays.ts"; -import { navigateWithRouteTransition } from "./route-transition.ts"; import { loadSettings, patchSettings, @@ -457,19 +456,11 @@ export function bootstrapApplication( if (!routerStarted) { pendingRouterStartNavigation = { routeId, location, mode: "push" }; } - // New-session submission awaits this promise so its live progress remains - // visible until the destination route has completed the UI handoff. - return navigateWithRouteTransition({ - document, - from: router.getState().matches[0]?.routeId, - to: routeId, - prefersReducedMotion: - globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false, - prepare: () => router.preloadLocation(location, context), - navigate: () => router.navigate(routeId, context, { history: "push" }, location), - }).catch((error: unknown) => { + const navigationPromise = router.navigate(routeId, context, { history: "push" }, location); + void navigationPromise.catch((error: unknown) => { console.error("[openclaw] route navigation failed", error); }); + return navigationPromise; }; const context: ApplicationContext = { basePath, @@ -508,7 +499,7 @@ export function bootstrapApplication( }); }, revalidate: (routeId) => router.revalidate(context, routeId), - preload: (routeId) => router.preloadRoute(routeId, context), + preload: (routeId, options) => router.preloadLocation(routeLocation(routeId, options), context), }; return { context, diff --git a/ui/src/app/context.ts b/ui/src/app/context.ts index c0a695ab7ea6..31ad7a5ef217 100644 --- a/ui/src/app/context.ts +++ b/ui/src/app/context.ts @@ -123,7 +123,7 @@ export type ApplicationContext = { ) => Promise; readonly replace: (routeId: TRouteId, options?: ApplicationNavigationOptions) => void; readonly revalidate: (routeId?: TRouteId) => Promise; - readonly preload: (routeId: TRouteId) => Promise; + readonly preload: (routeId: TRouteId, options?: ApplicationNavigationOptions) => Promise; }; export const applicationContext = diff --git a/ui/src/pages/new-session/draft-submission-flow.test.ts b/ui/src/pages/new-session/draft-submission-flow.test.ts index 71e8e4f8881d..6dcebdba1fe2 100644 --- a/ui/src/pages/new-session/draft-submission-flow.test.ts +++ b/ui/src/pages/new-session/draft-submission-flow.test.ts @@ -1,6 +1,7 @@ import type { ReactiveController, ReactiveControllerHost } from "lit"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { ApplicationContext } from "../../app/context.ts"; +import { CHAT_ROUTE_READY_EVENT } from "../../app/route-transition.ts"; import { buildDraftSessionCreateParams } from "./create-params.ts"; import { DraftGatewayState } from "./draft-gateway-state.ts"; import { DraftPlaceBrowser } from "./draft-place-browser.ts"; @@ -32,12 +33,15 @@ describe("DraftSubmissionFlow", () => { ); let finishNavigation!: () => void; const navigateAndWait = vi.fn( - () => + (_routeId: string, _options?: Parameters[1]) => new Promise((resolve) => { finishNavigation = resolve; }), ); - const preload = vi.fn(async () => undefined); + const preload = vi.fn( + async (_routeId: string, _options?: Parameters[1]) => + undefined, + ); const setSessionKey = vi.fn(); const selectAgent = vi.fn(); const client = { @@ -198,7 +202,12 @@ describe("DraftSubmissionFlow", () => { await vi.waitFor(() => expect(navigateAndWait).toHaveBeenCalledOnce()); expect(flow.submitting).toBe(true); + expect(preload).toHaveBeenCalledWith("chat", navigateAndWait.mock.calls[0]?.[1]); + expect(preload.mock.invocationCallOrder[0]).toBeLessThan( + navigateAndWait.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, + ); finishNavigation(); + document.dispatchEvent(new Event(CHAT_ROUTE_READY_EVENT)); await submission; expect(start).toHaveBeenCalledOnce(); @@ -213,6 +222,6 @@ describe("DraftSubmissionFlow", () => { expect(createResult).toHaveBeenCalledOnce(); expect(setSessionKey).toHaveBeenCalledWith(start.mock.calls[0]?.[0].recovery.sessionKey); expect(selectAgent).toHaveBeenCalledWith("cloud"); - expect(preload).not.toHaveBeenCalled(); + expect(preload).toHaveBeenCalledOnce(); }); }); diff --git a/ui/src/pages/new-session/draft-submission-flow.ts b/ui/src/pages/new-session/draft-submission-flow.ts index 8f5ce6510983..0fa86b75fcec 100644 --- a/ui/src/pages/new-session/draft-submission-flow.ts +++ b/ui/src/pages/new-session/draft-submission-flow.ts @@ -1,6 +1,7 @@ import type { SessionsCatalogStartTerminalResult } from "../../../../packages/gateway-protocol/src/index.js"; import { selectApplicationSession } from "../../app/agent-selection.ts"; -import type { ApplicationContext } from "../../app/context.ts"; +import type { ApplicationContext, ApplicationNavigationOptions } from "../../app/context.ts"; +import { navigateWithRouteTransition } from "../../app/route-transition.ts"; import { t } from "../../i18n/index.ts"; import { readSessionMethodAccess, @@ -364,6 +365,22 @@ export class DraftSubmissionFlow { this.callbacks.requestUpdate(); } + private navigateToStartedSession( + context: ApplicationContext, + options: ApplicationNavigationOptions, + ): Promise { + // Keep transition code on the lazy new-session path instead of the startup bundle. + return navigateWithRouteTransition({ + document, + from: "new-session", + to: "chat", + prefersReducedMotion: + globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false, + prepare: () => context.preload("chat", options), + navigate: () => context.navigateAndWait("chat", options), + }).catch(() => undefined); + } + async submit() { const context = this.read().context; if (!context || !this.canSubmit()) { @@ -528,8 +545,8 @@ export class DraftSubmissionFlow { sessionKey: result.key, agentId: submissionAgentId, }); - await context.navigateAndWait( - "chat", + await this.navigateToStartedSession( + context, sessionNavigationTarget({ context, face: "chat", @@ -572,8 +589,8 @@ export class DraftSubmissionFlow { sessionKey: result.key, agentId: submissionAgentId, }); - await context.navigateAndWait( - "chat", + await this.navigateToStartedSession( + context, sessionNavigationTarget({ context, face: "chat",