perf(control-ui): hydrate chat startup state

Add a combined chat.startup gateway method for Control UI startup hydration so first chat load can receive history and agents in one RPC, while falling back to chat.history for older/unadvertised gateways. Verified with focused UI/gateway tests, tsgo/oxlint/diff checks, clean autoreview, and Testbox changed gate tbx_01kt1dt6fqdtdbprsk48z8fn71.
This commit is contained in:
Vincent Koc
2026-06-01 12:14:19 +01:00
committed by GitHub
parent d8ebbedf45
commit c69a8d633d
12 changed files with 524 additions and 211 deletions

View File

@@ -382,7 +382,7 @@ function installControlUiMockGateway(input: {
"operator.pairing",
],
},
features: { events: [], methods: [] },
features: { events: [], methods: ["chat.startup"] },
protocol: protocolVersion,
server: { connId: "control-ui-e2e", version: "e2e" },
snapshot: {
@@ -432,6 +432,24 @@ function installControlUiMockGateway(input: {
sessionId: "control-ui-e2e-session",
thinkingLevel: null,
};
case "chat.startup":
return {
agentsList: {
agents: [
{
id: scenario.defaultAgentId,
identity: { name: scenario.assistantName },
name: scenario.assistantName,
},
],
defaultId: scenario.defaultAgentId,
mainKey: "main",
scope: "agent",
},
messages: scenario.historyMessages,
sessionId: "control-ui-e2e-session",
thinkingLevel: null,
};
case "chat.send":
return {
runId:

View File

@@ -68,7 +68,12 @@ import {
} from "./session-key.ts";
import { isSessionRunActive } from "./session-run-state.ts";
import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "./string-coerce.ts";
import type { ChatModelOverride, GatewaySessionRow, ModelCatalogEntry } from "./types.ts";
import type {
AgentsListResult,
ChatModelOverride,
GatewaySessionRow,
ModelCatalogEntry,
} from "./types.ts";
import type { SessionsListResult } from "./types.ts";
import type { ChatAttachment, ChatQueueItem, ChatSessionRefreshTarget } from "./ui-types.ts";
import { generateUUID } from "./uuid.ts";
@@ -109,7 +114,8 @@ export type ChatHost = ChatInputHistoryState & {
chatSubmitGuards?: Map<string, Promise<void>>;
chatSendTimingsByRun?: Map<string, ChatSendTimingEntry>;
assistantAgentId?: string | null;
agentsList?: { defaultId?: string | null; mainKey?: string | null } | null;
agentsList?: ChatAgentsListSnapshot | null;
agentsSelectedId?: string | null;
eventLogBuffer?: unknown[];
eventLog?: unknown[];
tab?: string;
@@ -117,6 +123,10 @@ export type ChatHost = ChatInputHistoryState & {
onSlashAction?: (action: string) => void | Promise<void>;
};
type ChatAgentsListSnapshot = Partial<Omit<AgentsListResult, "agents">> & {
agents?: Array<{ id: string }>;
};
function setChatError(host: ChatHost, error: string | null) {
host.lastError = error;
host.chatError = error;
@@ -1806,12 +1816,14 @@ function injectCommandResult(host: ChatHost, content: string) {
export async function refreshChat(
host: ChatHost,
opts?: { scheduleScroll?: boolean; awaitHistory?: boolean },
opts?: { scheduleScroll?: boolean; awaitHistory?: boolean; startup?: boolean },
) {
const refreshedSessionKey = host.sessionKey;
const requestUpdate = () => host.requestUpdate?.();
const previousSessionsResult = host.sessionsResult;
const historyLoad = loadChatHistory(host as unknown as ChatState);
const historyLoad = loadChatHistory(host as unknown as ChatState, {
startup: opts?.startup === true,
});
const historyRefresh = historyLoad.finally(() => {
if (opts?.scheduleScroll !== false) {
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0]);

View File

@@ -4,7 +4,9 @@ import { connectGateway } from "./app-gateway.ts";
import type { GatewayConnectTiming, GatewayHelloOk } from "./gateway.ts";
import type { Tab } from "./navigation.ts";
const refreshActiveTabMock = vi.hoisted(() => vi.fn(async () => undefined));
const refreshActiveTabMock = vi.hoisted(() =>
vi.fn(async (_host?: unknown, _opts?: unknown) => undefined),
);
const refreshChatAvatarMock = vi.hoisted(() => vi.fn(async () => undefined));
const loadControlUiBootstrapConfigMock = vi.hoisted(() => vi.fn(async () => undefined));
const loadAgentsMock = vi.hoisted(() => vi.fn(async () => undefined));
@@ -269,7 +271,9 @@ describe("connectGateway chat load startup work", () => {
client.emitHello();
await vi.waitFor(() => expect(refreshActiveTabMock).toHaveBeenCalledWith(host));
await vi.waitFor(() =>
expect(refreshActiveTabMock).toHaveBeenCalledWith(host, { chatStartup: true }),
);
expect(loadAgentsMock).toHaveBeenCalledWith(host);
expect(refreshActiveTabMock).toHaveBeenCalledTimes(1);
@@ -279,6 +283,27 @@ describe("connectGateway chat load startup work", () => {
expect(refreshActiveTabMock).toHaveBeenCalledTimes(1);
});
it("skips agents.list when the startup chat refresh returns agents", async () => {
refreshActiveTabMock.mockImplementationOnce(async (target: unknown) => {
(target as { agentsList: unknown }).agentsList = {
agents: [{ id: "main", name: "Main" }],
defaultId: "main",
mainKey: "main",
scope: "agent",
};
});
const { host, client } = connectHost("chat");
client.emitHello();
await vi.waitFor(() =>
expect(refreshActiveTabMock).toHaveBeenCalledWith(host, { chatStartup: true }),
);
await Promise.resolve();
expect(loadAgentsMock).not.toHaveBeenCalled();
expect(refreshActiveTabMock).toHaveBeenCalledTimes(1);
});
it("waits for startup bootstrap before the first chat refresh", async () => {
const bootstrap = createDeferred();
const { host, client } = connectHost("chat");
@@ -291,7 +316,9 @@ describe("connectGateway chat load startup work", () => {
expect(refreshActiveTabMock).not.toHaveBeenCalled();
bootstrap.resolve();
await vi.waitFor(() => expect(refreshActiveTabMock).toHaveBeenCalledWith(host));
await vi.waitFor(() =>
expect(refreshActiveTabMock).toHaveBeenCalledWith(host, { chatStartup: true }),
);
});
it("records connect timing through the Control UI performance buffer", () => {
@@ -317,7 +344,9 @@ describe("connectGateway chat load startup work", () => {
client.emitHello();
await vi.waitFor(() => expect(refreshActiveTabMock).toHaveBeenCalledWith(host));
await vi.waitFor(() =>
expect(refreshActiveTabMock).toHaveBeenCalledWith(host, { chatStartup: true }),
);
expect(loadAgentsMock).toHaveBeenCalledWith(host);
await vi.waitFor(() =>
@@ -352,7 +381,9 @@ describe("connectGateway chat load startup work", () => {
auth: { role: "operator", scopes: [] },
});
await vi.waitFor(() => expect(refreshActiveTabMock).toHaveBeenCalledWith(host));
await vi.waitFor(() =>
expect(refreshActiveTabMock).toHaveBeenCalledWith(host, { chatStartup: true }),
);
expect(loadAgentsMock).toHaveBeenCalledWith(host);
expect(refreshActiveTabMock).toHaveBeenCalledTimes(1);
@@ -452,7 +483,9 @@ describe("connectGateway chat load startup work", () => {
client.emitHello();
await vi.waitFor(() => expect(refreshActiveTabMock).toHaveBeenCalledWith(host));
await vi.waitFor(() =>
expect(refreshActiveTabMock).toHaveBeenCalledWith(host, { chatStartup: true }),
);
expect(refreshChatAvatarMock).not.toHaveBeenCalled();
});

View File

@@ -652,22 +652,29 @@ function prepareHelloScopedComposerRestore(host: GatewayHost) {
async function loadAgentsThenRefreshActiveTab(host: GatewayHost) {
let initialRefreshError: Error | undefined;
const refreshBeforeAgents = canRefreshActiveTabBeforeAgents(host);
const agentsListBeforeStartup = host.agentsList;
const initialRefresh = refreshBeforeAgents
? refreshActiveTab(host as unknown as Parameters<typeof refreshActiveTab>[0]).catch(
(err: unknown) => {
initialRefreshError = normalizeStartupRefreshError(err);
},
)
? refreshActiveTab(host as unknown as Parameters<typeof refreshActiveTab>[0], {
chatStartup: true,
}).catch((err: unknown) => {
initialRefreshError = normalizeStartupRefreshError(err);
})
: Promise.resolve();
let refreshAfterAgents = !refreshBeforeAgents;
let agentsError: Error | undefined;
await initialRefresh;
if (refreshBeforeAgents && host.agentsList && host.agentsList !== agentsListBeforeStartup) {
if (initialRefreshError) {
throw initialRefreshError;
}
return;
}
try {
await loadAgents(host as unknown as AgentsState);
refreshAfterAgents = fallbackUnconfiguredSessionSelection(host) || refreshAfterAgents;
} catch (err: unknown) {
agentsError = normalizeStartupRefreshError(err);
}
await initialRefresh;
if (refreshAfterAgents) {
await refreshActiveTab(host as unknown as Parameters<typeof refreshActiveTab>[0]);
} else if (initialRefreshError) {

View File

@@ -416,7 +416,7 @@ function loadConfigSchemaAfterPrimary(
);
}
export async function refreshActiveTab(host: SettingsHost) {
export async function refreshActiveTab(host: SettingsHost, opts?: { chatStartup?: boolean }) {
const app = host as unknown as SettingsAppHost;
const refreshRun = beginControlUiRefresh(host, host.tab);
try {
@@ -492,7 +492,10 @@ export async function refreshActiveTab(host: SettingsHost) {
break;
case "chat": {
try {
await refreshChat(host as unknown as Parameters<typeof refreshChat>[0]);
await refreshChat(host as unknown as Parameters<typeof refreshChat>[0], {
awaitHistory: opts?.chatStartup === true,
startup: opts?.chatStartup === true,
});
scheduleChatScroll(
host as unknown as Parameters<typeof scheduleChatScroll>[0],
!host.chatHasAutoScrolled,

View File

@@ -1347,6 +1347,58 @@ describe("loadChatHistory filtering", () => {
expect.objectContaining({ sessionKey: "global", agentId: "ops" }),
);
});
it("loads startup history with agents in one request", async () => {
const request = vi.fn().mockResolvedValue({
messages: [{ role: "assistant", content: [{ type: "text", text: "ready" }] }],
agentsList: {
agents: [{ id: "ops", name: "Ops" }],
defaultId: "ops",
mainKey: "main",
scope: "agent",
},
});
const state = createState({
agentsError: "previous agents.list failure",
client: { request } as unknown as ChatState["client"],
connected: true,
sessionKey: "global",
});
await loadChatHistory(state, { startup: true });
expect(request).toHaveBeenCalledWith("chat.startup", {
sessionKey: "global",
limit: 100,
});
expect(state.chatMessages).toEqual([
{ role: "assistant", content: [{ type: "text", text: "ready" }] },
]);
expect(state.agentsError).toBeNull();
expect(state.agentsList?.defaultId).toBe("ops");
expect(state.agentsSelectedId).toBe("ops");
});
it("falls back to chat.history when startup history is not advertised", async () => {
const request = vi.fn().mockResolvedValue({ messages: [] });
const state = createState({
client: { request } as unknown as ChatState["client"],
connected: true,
hello: {
type: "hello-ok",
protocol: 4,
auth: { role: "operator", scopes: [] },
features: { methods: ["chat.history"], events: [] },
},
});
await loadChatHistory(state, { startup: true });
expect(request).toHaveBeenCalledWith("chat.history", {
sessionKey: "main",
limit: 100,
});
});
});
describe("sendChatMessage", () => {
@@ -1717,6 +1769,38 @@ describe("abortChatRun", () => {
});
describe("loadChatHistory retry handling", () => {
it("falls back to chat.history when chat.startup is unknown", async () => {
const request = vi
.fn()
.mockRejectedValueOnce(
new GatewayRequestError({
code: "INVALID_REQUEST",
message: "unknown method: chat.startup",
}),
)
.mockResolvedValueOnce({
messages: [{ role: "assistant", content: [{ type: "text", text: "fallback" }] }],
});
const state = createState({
connected: true,
client: { request } as unknown as ChatState["client"],
});
await loadChatHistory(state, { startup: true });
expect(request).toHaveBeenNthCalledWith(1, "chat.startup", {
sessionKey: "main",
limit: 100,
});
expect(request).toHaveBeenNthCalledWith(2, "chat.history", {
sessionKey: "main",
limit: 100,
});
expect(state.chatMessages).toEqual([
{ role: "assistant", content: [{ type: "text", text: "fallback" }] },
]);
});
it("retries retryable startup unavailability before showing history", async () => {
vi.useFakeTimers();
try {

View File

@@ -20,7 +20,7 @@ import {
parseAgentSessionKey,
} from "../session-key.ts";
import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts";
import type { GatewaySessionRow, GatewaySessionsDefaults } from "../types.ts";
import type { AgentsListResult, GatewaySessionRow, GatewaySessionsDefaults } from "../types.ts";
import type { ChatAttachment } from "../ui-types.ts";
import { generateUUID } from "../uuid.ts";
import {
@@ -323,6 +323,22 @@ function isRetryableStartupUnavailable(err: unknown, method: string): err is Gat
return typeof detailMethod !== "string" || detailMethod === method;
}
function isUnknownGatewayMethodError(err: unknown, method: string): err is GatewayRequestError {
return (
err instanceof GatewayRequestError &&
err.gatewayCode === "INVALID_REQUEST" &&
err.message.includes(`unknown method: ${method}`)
);
}
function isGatewayMethodAdvertised(state: ChatState, method: string): boolean | null {
const methods = state.hello?.features?.methods;
if (!Array.isArray(methods)) {
return null;
}
return methods.includes(method);
}
function resolveStartupRetryDelayMs(err: GatewayRequestError): number {
const retryAfterMs =
typeof err.retryAfterMs === "number" ? err.retryAfterMs : STARTUP_CHAT_HISTORY_DEFAULT_RETRY_MS;
@@ -351,18 +367,25 @@ export type ChatState = {
chatStreamStartedAt: number | null;
lastError: string | null;
chatError?: string | null;
agentsError?: string | null;
resetChatInputHistoryNavigation?: () => void;
assistantAgentId?: string | null;
agentsList?: { defaultId?: string | null } | null;
agentsList?: ChatAgentsListSnapshot | null;
agentsSelectedId?: string | null;
hello?: GatewayHelloOk | null;
};
type ChatAgentsListSnapshot = Partial<Omit<AgentsListResult, "agents">> & {
agents?: Array<{ id: string }>;
};
export type ChatHistoryResult = {
messages?: Array<unknown>;
sessionId?: string;
thinkingLevel?: string;
defaults?: GatewaySessionsDefaults;
sessionInfo?: GatewaySessionRow;
agentsList?: AgentsListResult;
};
export type ChatEventPayload = {
@@ -506,6 +529,10 @@ type InFlightChatHistoryRequest = {
promise: Promise<ChatHistoryResult | undefined>;
};
type LoadChatHistoryOptions = {
startup?: boolean;
};
const inFlightChatHistoryRequests = new WeakMap<ChatState, InFlightChatHistoryRequest>();
function recordChatHistoryTiming(
@@ -528,7 +555,10 @@ function recordChatHistoryTiming(
);
}
export async function loadChatHistory(state: ChatState): Promise<ChatHistoryResult | undefined> {
export async function loadChatHistory(
state: ChatState,
opts: LoadChatHistoryOptions = {},
): Promise<ChatHistoryResult | undefined> {
if (!state.client || !state.connected) {
return undefined;
}
@@ -536,7 +566,10 @@ export async function loadChatHistory(state: ChatState): Promise<ChatHistoryResu
const requestAgentId = isSelectedGlobalEventSessionKey(sessionKey)
? resolveSelectedAgentId(state)
: undefined;
const requestKey = `${sessionKey}\0${requestAgentId ?? ""}`;
const startupAdvertised = isGatewayMethodAdvertised(state, "chat.startup");
const method =
opts.startup === true && startupAdvertised !== false ? "chat.startup" : "chat.history";
const requestKey = `${method}\0${sessionKey}\0${requestAgentId ?? ""}`;
const inFlight = inFlightChatHistoryRequests.get(state);
if (
inFlight?.key === requestKey &&
@@ -545,13 +578,17 @@ export async function loadChatHistory(state: ChatState): Promise<ChatHistoryResu
) {
return inFlight.promise;
}
const promise = loadChatHistoryUncached(state, state.client, sessionKey, requestAgentId).finally(
() => {
if (inFlightChatHistoryRequests.get(state)?.promise === promise) {
inFlightChatHistoryRequests.delete(state);
}
},
);
const promise = loadChatHistoryUncached(
state,
state.client,
sessionKey,
requestAgentId,
method,
).finally(() => {
if (inFlightChatHistoryRequests.get(state)?.promise === promise) {
inFlightChatHistoryRequests.delete(state);
}
});
inFlightChatHistoryRequests.set(state, {
client: state.client,
key: requestKey,
@@ -561,11 +598,31 @@ export async function loadChatHistory(state: ChatState): Promise<ChatHistoryResu
return promise;
}
function applyChatStartupAgentsList(state: ChatState, agentsList: AgentsListResult | undefined) {
if (!agentsList) {
return;
}
state.agentsList = agentsList;
state.agentsError = null;
const selectedId =
typeof state.agentsSelectedId === "string" && state.agentsSelectedId.trim()
? normalizeAgentId(state.agentsSelectedId)
: undefined;
if (selectedId && agentsList.agents.some((entry) => normalizeAgentId(entry.id) === selectedId)) {
return;
}
state.agentsSelectedId =
typeof agentsList.defaultId === "string" && agentsList.defaultId.trim()
? agentsList.defaultId
: (agentsList.agents[0]?.id ?? null);
}
async function loadChatHistoryUncached(
state: ChatState,
client: NonNullable<ChatState["client"]>,
sessionKey: string,
requestAgentId: string | undefined,
method: "chat.history" | "chat.startup",
): Promise<ChatHistoryResult | undefined> {
const requestVersion = beginChatHistoryRequest(state);
const startedAt = Date.now();
@@ -575,6 +632,7 @@ async function loadChatHistoryUncached(
recordChatHistoryTiming(state, "start", startedAtMs, {
requestSessionKey: sessionKey,
requestAgentId,
method,
previousRunId,
});
// Any pending input-history snapshot becomes invalid once we start reloading transcript state.
@@ -585,7 +643,7 @@ async function loadChatHistoryUncached(
let res: ChatHistoryResult;
for (;;) {
try {
res = await client.request<ChatHistoryResult>("chat.history", {
res = await client.request<ChatHistoryResult>(method, {
sessionKey,
...(requestAgentId ? { agentId: requestAgentId } : {}),
limit: CHAT_HISTORY_REQUEST_LIMIT,
@@ -603,7 +661,15 @@ async function loadChatHistoryUncached(
}
const withinStartupRetryWindow =
Date.now() - startedAt < STARTUP_CHAT_HISTORY_RETRY_TIMEOUT_MS;
if (withinStartupRetryWindow && isRetryableStartupUnavailable(err, "chat.history")) {
if (method === "chat.startup" && isUnknownGatewayMethodError(err, method)) {
res = await client.request<ChatHistoryResult>("chat.history", {
sessionKey,
...(requestAgentId ? { agentId: requestAgentId } : {}),
limit: CHAT_HISTORY_REQUEST_LIMIT,
});
break;
}
if (withinStartupRetryWindow && isRetryableStartupUnavailable(err, method)) {
await sleep(resolveStartupRetryDelayMs(err));
if (!state.client || !state.connected) {
return undefined;
@@ -623,6 +689,7 @@ async function loadChatHistoryUncached(
return undefined;
}
const messages = Array.isArray(res.messages) ? res.messages : [];
applyChatStartupAgentsList(state, res.agentsList);
const visibleMessages = messages.filter((message) => !shouldHideHistoryMessage(message));
const lateOptimisticTail = collectLateOptimisticTailMessages(
previousMessages,

View File

@@ -279,14 +279,15 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
const page = await context.newPage();
const gateway = await installMockGateway(page, {
defaultAgentId: "ops",
deferredMethods: ["agents.list", "chat.history"],
deferredMethods: ["chat.startup"],
historyMessages: [],
sessionKey: "global",
});
try {
await page.goto(`${server.baseUrl}chat`);
await gateway.waitForRequest("agents.list");
await gateway.waitForRequest("chat.startup");
expect(await gateway.getRequests("agents.list")).toHaveLength(0);
const prompt = "send before agents list completes";
await page
@@ -338,7 +339,13 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
(payload) => payload.phase === "first-assistant-visible" && payload.runId === runId,
),
).toBe(true);
await gateway.resolveDeferred("chat.history", {
await gateway.resolveDeferred("chat.startup", {
agentsList: {
agents: [{ id: "ops", name: "OpenClaw" }],
defaultId: "ops",
mainKey: "main",
scope: "agent",
},
messages: [],
sessionId: "control-ui-e2e-session",
thinkingLevel: null,
@@ -346,8 +353,7 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
await page.locator(".chat-thread").getByText(prompt).waitFor({ timeout: 10_000 });
await gateway.emitChatFinal({ runId, text: "History race stayed visible." });
await page.getByText("History race stayed visible.").waitFor({ timeout: 10_000 });
await gateway.resolveDeferred("agents.list");
expect(await gateway.getRequests("agents.list")).toHaveLength(0);
} finally {
await context.close();
}