fix(ui): unblock initial control chat send

This commit is contained in:
Vincent Koc
2026-06-01 00:31:08 +01:00
parent 432312a17c
commit 49b62079f7
3 changed files with 201 additions and 7 deletions

View File

@@ -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<undefined>((res, rej) => {
resolve = () => res(undefined);
reject = rej;
});
return { promise, reject, resolve };
}
vi.mock("./gateway.ts", async (importOriginal) => {
const actual = await importOriginal<typeof import("./gateway.ts")>();
@@ -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");

View File

@@ -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<typeof refreshActiveTab>[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<typeof refreshActiveTab>[0]);
} else if (initialRefreshError) {
throw initialRefreshError;
}
if (agentsError) {
throw agentsError;
}
}

View File

@@ -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",