Skip to content

Commit 49b6207

Browse files
committed
fix(ui): unblock initial control chat send
1 parent 432312a commit 49b6207

3 files changed

Lines changed: 201 additions & 7 deletions

File tree

ui/src/ui/app-gateway-chat-load.node.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const loadDevicesMock = vi.hoisted(() => vi.fn(async () => undefined));
1313
const loadHealthStateMock = vi.hoisted(() => vi.fn(async () => undefined));
1414
const loadNodesMock = vi.hoisted(() => vi.fn(async () => undefined));
1515
const subscribeSessionsMock = vi.hoisted(() => vi.fn(async () => undefined));
16+
const syncUrlWithSessionKeyMock = vi.hoisted(() => vi.fn());
1617
const verifyPushMock = vi.hoisted(() => vi.fn(async () => undefined));
1718

1819
type GatewayClientMock = {
@@ -23,6 +24,16 @@ type GatewayClientMock = {
2324

2425
const gatewayClients: GatewayClientMock[] = [];
2526

27+
function createDeferred() {
28+
let resolve: () => void = () => undefined;
29+
let reject: (reason?: unknown) => void = () => undefined;
30+
const promise = new Promise<undefined>((res, rej) => {
31+
resolve = () => res(undefined);
32+
reject = rej;
33+
});
34+
return { promise, reject, resolve };
35+
}
36+
2637
vi.mock("./gateway.ts", async (importOriginal) => {
2738
const actual = await importOriginal<typeof import("./gateway.ts")>();
2839

@@ -87,6 +98,7 @@ vi.mock("./app-settings.ts", () => ({
8798
loadCron: vi.fn(),
8899
refreshActiveTab: refreshActiveTabMock,
89100
setLastActiveSessionKey: vi.fn(),
101+
syncUrlWithSessionKey: syncUrlWithSessionKeyMock,
90102
}));
91103

92104
vi.mock("./controllers/agents.ts", () => ({
@@ -218,10 +230,112 @@ beforeEach(() => {
218230
loadHealthStateMock.mockClear();
219231
loadNodesMock.mockClear();
220232
subscribeSessionsMock.mockClear();
233+
syncUrlWithSessionKeyMock.mockClear();
221234
verifyPushMock.mockClear();
222235
});
223236

224237
describe("connectGateway chat load startup work", () => {
238+
it("starts the active chat refresh before agents.list finishes", async () => {
239+
const agentsList = createDeferred();
240+
loadAgentsMock.mockReturnValueOnce(agentsList.promise);
241+
const { host, client } = connectHost("chat");
242+
243+
client.emitHello();
244+
245+
await vi.waitFor(() => expect(refreshActiveTabMock).toHaveBeenCalledWith(host));
246+
expect(loadAgentsMock).toHaveBeenCalledWith(host);
247+
expect(refreshActiveTabMock).toHaveBeenCalledTimes(1);
248+
249+
agentsList.resolve();
250+
await agentsList.promise;
251+
await Promise.resolve();
252+
expect(refreshActiveTabMock).toHaveBeenCalledTimes(1);
253+
});
254+
255+
it("waits for agents.list when a stale agent session may need fallback", async () => {
256+
const agentsList = createDeferred();
257+
const { host, client } = connectHost("chat");
258+
loadAgentsMock.mockImplementationOnce(async () => {
259+
await agentsList.promise;
260+
host.agentsList = {
261+
defaultId: "new-default",
262+
mainKey: "main",
263+
scope: "global",
264+
agents: [{ id: "new-default" }],
265+
};
266+
});
267+
host.sessionKey = "agent:old-default:main";
268+
host.agentsList = {
269+
defaultId: "old-default",
270+
mainKey: "main",
271+
scope: "global",
272+
agents: [{ id: "old-default" }],
273+
};
274+
275+
client.emitHello({
276+
type: "hello-ok",
277+
protocol: 4,
278+
snapshot: {
279+
sessionDefaults: {
280+
defaultAgentId: "new-default",
281+
mainKey: "main",
282+
mainSessionKey: "agent:new-default:main",
283+
},
284+
},
285+
auth: { role: "operator", scopes: [] },
286+
});
287+
288+
await vi.waitFor(() => expect(loadAgentsMock).toHaveBeenCalledWith(host));
289+
expect(refreshActiveTabMock).not.toHaveBeenCalled();
290+
291+
agentsList.resolve();
292+
await vi.waitFor(() => expect(refreshActiveTabMock).toHaveBeenCalledWith(host));
293+
expect(host.sessionKey).toBe("agent:new-default:main");
294+
expect(refreshActiveTabMock).toHaveBeenCalledTimes(1);
295+
});
296+
297+
it("waits for agents.list before refreshing selected-global chat", async () => {
298+
const agentsList = createDeferred();
299+
const { host, client } = connectHost("chat");
300+
loadAgentsMock.mockImplementationOnce(async () => {
301+
await agentsList.promise;
302+
host.agentsList = {
303+
defaultId: "new-default",
304+
mainKey: "main",
305+
scope: "global",
306+
agents: [{ id: "new-default" }],
307+
};
308+
});
309+
host.sessionKey = "global";
310+
host.agentsList = {
311+
defaultId: "old-default",
312+
mainKey: "main",
313+
scope: "global",
314+
agents: [{ id: "old-default" }],
315+
};
316+
317+
client.emitHello({
318+
type: "hello-ok",
319+
protocol: 4,
320+
snapshot: {
321+
sessionDefaults: {
322+
defaultAgentId: "new-default",
323+
mainKey: "main",
324+
mainSessionKey: "agent:new-default:main",
325+
},
326+
},
327+
auth: { role: "operator", scopes: [] },
328+
});
329+
330+
await vi.waitFor(() => expect(loadAgentsMock).toHaveBeenCalledWith(host));
331+
expect(refreshActiveTabMock).not.toHaveBeenCalled();
332+
333+
agentsList.resolve();
334+
await vi.waitFor(() => expect(refreshActiveTabMock).toHaveBeenCalledWith(host));
335+
expect(host.sessionKey).toBe("global");
336+
expect(refreshActiveTabMock).toHaveBeenCalledTimes(1);
337+
});
338+
225339
it("lets the active chat refresh own avatar loading on initial chat hello", async () => {
226340
const { host, client } = connectHost("chat");
227341

ui/src/ui/app-gateway.ts

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -481,10 +481,23 @@ function resolveDefaultAgentId(host: GatewayHost): string {
481481
return normalizeAgentId(
482482
host.agentsList?.defaultId?.trim() ||
483483
snapshot?.sessionDefaults?.defaultAgentId?.trim() ||
484-
"main",
484+
"main",
485485
);
486486
}
487487

488+
function resolveFreshDefaultAgentId(host: GatewayHost): string | undefined {
489+
const snapshot = host.hello?.snapshot as
490+
| { sessionDefaults?: SessionDefaultsSnapshot }
491+
| undefined;
492+
const defaults = snapshot?.sessionDefaults;
493+
const defaultAgentId = defaults?.defaultAgentId?.trim();
494+
if (defaultAgentId) {
495+
return normalizeAgentId(defaultAgentId);
496+
}
497+
const parsedMainSession = parseAgentSessionKey(defaults?.mainSessionKey ?? "");
498+
return parsedMainSession ? normalizeAgentId(parsedMainSession.agentId) : undefined;
499+
}
500+
488501
function resolveSelectedGlobalAgentId(host: GatewayHost): string {
489502
return normalizeAgentId(host.assistantAgentId?.trim() || resolveDefaultAgentId(host));
490503
}
@@ -549,16 +562,16 @@ function chatSideResultAgentScopeMatches(host: GatewayHost, sideResult: ChatSide
549562
return globalAgentScopeMatches(host, sideResult.sessionKey, sideResult.agentId);
550563
}
551564

552-
function fallbackUnconfiguredSessionSelection(host: GatewayHost) {
565+
function fallbackUnconfiguredSessionSelection(host: GatewayHost): boolean {
553566
const parsed = parseAgentSessionKey(host.sessionKey);
554567
if (!parsed) {
555-
return;
568+
return false;
556569
}
557570
const configuredAgentIds = new Set(
558571
(host.agentsList?.agents ?? []).map((entry) => normalizeAgentId(entry.id)),
559572
);
560573
if (configuredAgentIds.size === 0 || configuredAgentIds.has(normalizeAgentId(parsed.agentId))) {
561-
return;
574+
return false;
562575
}
563576
const nextSessionKey = resolveMainSessionFallback(host);
564577
host.sessionKey = nextSessionKey;
@@ -572,14 +585,47 @@ function fallbackUnconfiguredSessionSelection(host: GatewayHost) {
572585
nextSessionKey,
573586
true,
574587
);
588+
return true;
589+
}
590+
591+
function canRefreshActiveTabBeforeAgents(host: GatewayHost): boolean {
592+
if (host.tab !== "chat") {
593+
return false;
594+
}
595+
if (isGlobalSessionKey(host.sessionKey)) {
596+
return false;
597+
}
598+
const parsed = parseAgentSessionKey(host.sessionKey);
599+
if (!parsed) {
600+
return true;
601+
}
602+
return normalizeAgentId(parsed.agentId) === resolveFreshDefaultAgentId(host);
575603
}
576604

577605
async function loadAgentsThenRefreshActiveTab(host: GatewayHost) {
606+
let initialRefreshError: unknown;
607+
const refreshBeforeAgents = canRefreshActiveTabBeforeAgents(host);
608+
const initialRefresh = refreshBeforeAgents
609+
? refreshActiveTab(host as unknown as Parameters<typeof refreshActiveTab>[0]).catch((err) => {
610+
initialRefreshError = err;
611+
})
612+
: Promise.resolve();
613+
let refreshAfterAgents = !refreshBeforeAgents;
614+
let agentsError: unknown;
578615
try {
579616
await loadAgents(host as unknown as AgentsState);
580-
fallbackUnconfiguredSessionSelection(host);
581-
} finally {
617+
refreshAfterAgents = fallbackUnconfiguredSessionSelection(host) || refreshAfterAgents;
618+
} catch (err) {
619+
agentsError = err;
620+
}
621+
await initialRefresh;
622+
if (refreshAfterAgents) {
582623
await refreshActiveTab(host as unknown as Parameters<typeof refreshActiveTab>[0]);
624+
} else if (initialRefreshError) {
625+
throw initialRefreshError;
626+
}
627+
if (agentsError) {
628+
throw agentsError;
583629
}
584630
}
585631

ui/src/ui/e2e/chat-flow.e2e.test.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
8080
beforeAll(async () => {
8181
if (!chromiumAvailable) {
8282
throw new Error(
83-
`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.`,
83+
`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.`,
8484
);
8585
}
8686
server = await startControlUiE2eServer();
@@ -179,6 +179,40 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
179179
}
180180
});
181181

182+
it("sends the first chat turn while agents startup loading is still pending", async () => {
183+
const context = await browser.newContext({
184+
locale: "en-US",
185+
serviceWorkers: "block",
186+
viewport: { height: 900, width: 1280 },
187+
});
188+
const page = await context.newPage();
189+
const gateway = await installMockGateway(page, {
190+
deferredMethods: ["agents.list"],
191+
historyMessages: [],
192+
});
193+
194+
try {
195+
await page.goto(`${server.baseUrl}chat`);
196+
await gateway.waitForRequest("agents.list");
197+
198+
const prompt = "send before agents list completes";
199+
await page
200+
.locator(".agent-chat__composer-combobox textarea")
201+
.waitFor({ state: "visible", timeout: 10_000 });
202+
await page.locator(".agent-chat__composer-combobox textarea").fill(prompt);
203+
await page.getByRole("button", { name: "Send message" }).click();
204+
205+
const sendRequest = await gateway.waitForRequest("chat.send");
206+
const params = requireRecord(sendRequest.params);
207+
expect(params.message).toBe(prompt);
208+
expect(params.sessionKey).toBe("main");
209+
210+
await gateway.resolveDeferred("agents.list");
211+
} finally {
212+
await context.close();
213+
}
214+
});
215+
182216
it("keeps a delayed chat.send ACK visible as pending until the ACK resolves", async () => {
183217
const context = await browser.newContext({
184218
locale: "en-US",

0 commit comments

Comments
 (0)