mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
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:
@@ -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:
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user