From 49b62079f7d46f68b5697b3930bbd9a5fdab99d6 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 1 Jun 2026 00:31:08 +0100 Subject: [PATCH] fix(ui): unblock initial control chat send --- ui/src/ui/app-gateway-chat-load.node.test.ts | 114 +++++++++++++++++++ ui/src/ui/app-gateway.ts | 58 +++++++++- ui/src/ui/e2e/chat-flow.e2e.test.ts | 36 +++++- 3 files changed, 201 insertions(+), 7 deletions(-) diff --git a/ui/src/ui/app-gateway-chat-load.node.test.ts b/ui/src/ui/app-gateway-chat-load.node.test.ts index f6c415ba432..fd1289b146d 100644 --- a/ui/src/ui/app-gateway-chat-load.node.test.ts +++ b/ui/src/ui/app-gateway-chat-load.node.test.ts @@ -13,6 +13,7 @@ const loadDevicesMock = vi.hoisted(() => vi.fn(async () => undefined)); const loadHealthStateMock = vi.hoisted(() => vi.fn(async () => undefined)); const loadNodesMock = vi.hoisted(() => vi.fn(async () => undefined)); const subscribeSessionsMock = vi.hoisted(() => vi.fn(async () => undefined)); +const syncUrlWithSessionKeyMock = vi.hoisted(() => vi.fn()); const verifyPushMock = vi.hoisted(() => vi.fn(async () => undefined)); type GatewayClientMock = { @@ -23,6 +24,16 @@ type GatewayClientMock = { const gatewayClients: GatewayClientMock[] = []; +function createDeferred() { + let resolve: () => void = () => undefined; + let reject: (reason?: unknown) => void = () => undefined; + const promise = new Promise((res, rej) => { + resolve = () => res(undefined); + reject = rej; + }); + return { promise, reject, resolve }; +} + vi.mock("./gateway.ts", async (importOriginal) => { const actual = await importOriginal(); @@ -87,6 +98,7 @@ vi.mock("./app-settings.ts", () => ({ loadCron: vi.fn(), refreshActiveTab: refreshActiveTabMock, setLastActiveSessionKey: vi.fn(), + syncUrlWithSessionKey: syncUrlWithSessionKeyMock, })); vi.mock("./controllers/agents.ts", () => ({ @@ -218,10 +230,112 @@ beforeEach(() => { loadHealthStateMock.mockClear(); loadNodesMock.mockClear(); subscribeSessionsMock.mockClear(); + syncUrlWithSessionKeyMock.mockClear(); verifyPushMock.mockClear(); }); describe("connectGateway chat load startup work", () => { + it("starts the active chat refresh before agents.list finishes", async () => { + const agentsList = createDeferred(); + loadAgentsMock.mockReturnValueOnce(agentsList.promise); + const { host, client } = connectHost("chat"); + + client.emitHello(); + + await vi.waitFor(() => expect(refreshActiveTabMock).toHaveBeenCalledWith(host)); + expect(loadAgentsMock).toHaveBeenCalledWith(host); + expect(refreshActiveTabMock).toHaveBeenCalledTimes(1); + + agentsList.resolve(); + await agentsList.promise; + await Promise.resolve(); + expect(refreshActiveTabMock).toHaveBeenCalledTimes(1); + }); + + it("waits for agents.list when a stale agent session may need fallback", async () => { + const agentsList = createDeferred(); + const { host, client } = connectHost("chat"); + loadAgentsMock.mockImplementationOnce(async () => { + await agentsList.promise; + host.agentsList = { + defaultId: "new-default", + mainKey: "main", + scope: "global", + agents: [{ id: "new-default" }], + }; + }); + host.sessionKey = "agent:old-default:main"; + host.agentsList = { + defaultId: "old-default", + mainKey: "main", + scope: "global", + agents: [{ id: "old-default" }], + }; + + client.emitHello({ + type: "hello-ok", + protocol: 4, + snapshot: { + sessionDefaults: { + defaultAgentId: "new-default", + mainKey: "main", + mainSessionKey: "agent:new-default:main", + }, + }, + auth: { role: "operator", scopes: [] }, + }); + + await vi.waitFor(() => expect(loadAgentsMock).toHaveBeenCalledWith(host)); + expect(refreshActiveTabMock).not.toHaveBeenCalled(); + + agentsList.resolve(); + await vi.waitFor(() => expect(refreshActiveTabMock).toHaveBeenCalledWith(host)); + expect(host.sessionKey).toBe("agent:new-default:main"); + expect(refreshActiveTabMock).toHaveBeenCalledTimes(1); + }); + + it("waits for agents.list before refreshing selected-global chat", async () => { + const agentsList = createDeferred(); + const { host, client } = connectHost("chat"); + loadAgentsMock.mockImplementationOnce(async () => { + await agentsList.promise; + host.agentsList = { + defaultId: "new-default", + mainKey: "main", + scope: "global", + agents: [{ id: "new-default" }], + }; + }); + host.sessionKey = "global"; + host.agentsList = { + defaultId: "old-default", + mainKey: "main", + scope: "global", + agents: [{ id: "old-default" }], + }; + + client.emitHello({ + type: "hello-ok", + protocol: 4, + snapshot: { + sessionDefaults: { + defaultAgentId: "new-default", + mainKey: "main", + mainSessionKey: "agent:new-default:main", + }, + }, + auth: { role: "operator", scopes: [] }, + }); + + await vi.waitFor(() => expect(loadAgentsMock).toHaveBeenCalledWith(host)); + expect(refreshActiveTabMock).not.toHaveBeenCalled(); + + agentsList.resolve(); + await vi.waitFor(() => expect(refreshActiveTabMock).toHaveBeenCalledWith(host)); + expect(host.sessionKey).toBe("global"); + expect(refreshActiveTabMock).toHaveBeenCalledTimes(1); + }); + it("lets the active chat refresh own avatar loading on initial chat hello", async () => { const { host, client } = connectHost("chat"); diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index ee6f2c29907..d52c5ffc3f3 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -481,10 +481,23 @@ function resolveDefaultAgentId(host: GatewayHost): string { return normalizeAgentId( host.agentsList?.defaultId?.trim() || snapshot?.sessionDefaults?.defaultAgentId?.trim() || - "main", + "main", ); } +function resolveFreshDefaultAgentId(host: GatewayHost): string | undefined { + const snapshot = host.hello?.snapshot as + | { sessionDefaults?: SessionDefaultsSnapshot } + | undefined; + const defaults = snapshot?.sessionDefaults; + const defaultAgentId = defaults?.defaultAgentId?.trim(); + if (defaultAgentId) { + return normalizeAgentId(defaultAgentId); + } + const parsedMainSession = parseAgentSessionKey(defaults?.mainSessionKey ?? ""); + return parsedMainSession ? normalizeAgentId(parsedMainSession.agentId) : undefined; +} + function resolveSelectedGlobalAgentId(host: GatewayHost): string { return normalizeAgentId(host.assistantAgentId?.trim() || resolveDefaultAgentId(host)); } @@ -549,16 +562,16 @@ function chatSideResultAgentScopeMatches(host: GatewayHost, sideResult: ChatSide return globalAgentScopeMatches(host, sideResult.sessionKey, sideResult.agentId); } -function fallbackUnconfiguredSessionSelection(host: GatewayHost) { +function fallbackUnconfiguredSessionSelection(host: GatewayHost): boolean { const parsed = parseAgentSessionKey(host.sessionKey); if (!parsed) { - return; + return false; } const configuredAgentIds = new Set( (host.agentsList?.agents ?? []).map((entry) => normalizeAgentId(entry.id)), ); if (configuredAgentIds.size === 0 || configuredAgentIds.has(normalizeAgentId(parsed.agentId))) { - return; + return false; } const nextSessionKey = resolveMainSessionFallback(host); host.sessionKey = nextSessionKey; @@ -572,14 +585,47 @@ function fallbackUnconfiguredSessionSelection(host: GatewayHost) { nextSessionKey, true, ); + return true; +} + +function canRefreshActiveTabBeforeAgents(host: GatewayHost): boolean { + if (host.tab !== "chat") { + return false; + } + if (isGlobalSessionKey(host.sessionKey)) { + return false; + } + const parsed = parseAgentSessionKey(host.sessionKey); + if (!parsed) { + return true; + } + return normalizeAgentId(parsed.agentId) === resolveFreshDefaultAgentId(host); } async function loadAgentsThenRefreshActiveTab(host: GatewayHost) { + let initialRefreshError: unknown; + const refreshBeforeAgents = canRefreshActiveTabBeforeAgents(host); + const initialRefresh = refreshBeforeAgents + ? refreshActiveTab(host as unknown as Parameters[0]).catch((err) => { + initialRefreshError = err; + }) + : Promise.resolve(); + let refreshAfterAgents = !refreshBeforeAgents; + let agentsError: unknown; try { await loadAgents(host as unknown as AgentsState); - fallbackUnconfiguredSessionSelection(host); - } finally { + refreshAfterAgents = fallbackUnconfiguredSessionSelection(host) || refreshAfterAgents; + } catch (err) { + agentsError = err; + } + await initialRefresh; + if (refreshAfterAgents) { await refreshActiveTab(host as unknown as Parameters[0]); + } else if (initialRefreshError) { + throw initialRefreshError; + } + if (agentsError) { + throw agentsError; } } diff --git a/ui/src/ui/e2e/chat-flow.e2e.test.ts b/ui/src/ui/e2e/chat-flow.e2e.test.ts index bdaa307934f..4c34dcf9fdc 100644 --- a/ui/src/ui/e2e/chat-flow.e2e.test.ts +++ b/ui/src/ui/e2e/chat-flow.e2e.test.ts @@ -80,7 +80,7 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => { beforeAll(async () => { if (!chromiumAvailable) { throw new Error( - `Playwright Chromium is not installed at ${chromiumExecutablePath}. Run \`pnpm --dir ui exec playwright install chromium\`, or set OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM=1 only when intentionally skipping this lane.`, + `Playwright Chromium is not installed at ${chromiumExecutablePath}. Run \`pnpm --dir ui exec playwright install chromium\`, set PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH to a compatible browser, or set OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM=1 only when intentionally skipping this lane.`, ); } server = await startControlUiE2eServer(); @@ -179,6 +179,40 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => { } }); + it("sends the first chat turn while agents startup loading is still pending", async () => { + const context = await browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + deferredMethods: ["agents.list"], + historyMessages: [], + }); + + try { + await page.goto(`${server.baseUrl}chat`); + await gateway.waitForRequest("agents.list"); + + const prompt = "send before agents list completes"; + await page + .locator(".agent-chat__composer-combobox textarea") + .waitFor({ state: "visible", timeout: 10_000 }); + await page.locator(".agent-chat__composer-combobox textarea").fill(prompt); + await page.getByRole("button", { name: "Send message" }).click(); + + const sendRequest = await gateway.waitForRequest("chat.send"); + const params = requireRecord(sendRequest.params); + expect(params.message).toBe(prompt); + expect(params.sessionKey).toBe("main"); + + await gateway.resolveDeferred("agents.list"); + } finally { + await context.close(); + } + }); + it("keeps a delayed chat.send ACK visible as pending until the ACK resolves", async () => { const context = await browser.newContext({ locale: "en-US",