mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-09 05:32:53 +00:00
fix(ui): unblock initial control chat send
This commit is contained in:
@@ -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");
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user