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

@@ -200,6 +200,7 @@ export const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [
{ name: "agent.identity.get", scope: "operator.read" },
{ name: "agent.wait", scope: "operator.write", startup: true },
{ name: "chat.history", scope: "operator.read", startup: true },
{ name: "chat.startup", scope: "operator.read", startup: true },
{ name: "chat.message.get", scope: "operator.read", startup: true },
{ name: "chat.abort", scope: "operator.write" },
{ name: "chat.send", scope: "operator.write" },

View File

@@ -277,7 +277,14 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
loadHandlers: loadChannelsHandlers,
}),
...createLazyCoreHandlers({
methods: ["chat.history", "chat.message.get", "chat.abort", "chat.send", "chat.inject"],
methods: [
"chat.history",
"chat.startup",
"chat.message.get",
"chat.abort",
"chat.send",
"chat.inject",
],
loadHandlers: loadChatHandlers,
}),
...createLazyCoreHandlers({

View File

@@ -139,6 +139,7 @@ import {
buildGatewaySessionInfo,
getSessionDefaults,
loadSessionEntry,
listAgentsForGateway,
readSessionMessageByIdAsync,
readSessionMessagesAsync,
resolveGatewayModelSupportsImages,
@@ -208,6 +209,8 @@ type PreRegisteredAgentRun = {
payload: PreRegisteredAgentDedupePayload;
};
type ChatHistoryMethod = "chat.history" | "chat.startup";
function normalizeUnknownText(value: unknown): string | undefined {
return typeof value === "string" ? normalizeOptionalText(value) : undefined;
}
@@ -2411,183 +2414,201 @@ function dropLocalHistoryOverreadContextMessage(
return [...messages.slice(0, index), ...messages.slice(index + 1)];
}
export const chatHandlers: GatewayRequestHandlers = {
"chat.history": async ({ params, respond, context }) => {
if (!validateChatHistoryParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid chat.history params: ${formatValidationErrors(validateChatHistoryParams.errors)}`,
),
);
return;
}
const { sessionKey, limit, maxChars } = params as {
sessionKey: string;
agentId?: string;
limit?: number;
maxChars?: number;
};
const agentIdOverride = normalizeOptionalText((params as { agentId?: string }).agentId);
const requestedAgentId = resolveRequestedChatAgentId({
cfg: (context as { getRuntimeConfig?: () => OpenClawConfig }).getRuntimeConfig?.(),
requestedSessionKey: sessionKey,
agentId: agentIdOverride,
});
const sessionLoadOptions = requestedAgentId ? { agentId: requestedAgentId } : undefined;
const { cfg, storePath, store, entry, canonicalKey } = loadSessionEntry(
sessionKey,
sessionLoadOptions,
);
const selectedAgent = validateChatSelectedAgent({
cfg,
requestedSessionKey: sessionKey,
agentId: requestedAgentId,
});
if (!selectedAgent.ok) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, selectedAgent.error));
return;
}
const sessionId = entry?.sessionId;
const sessionAgentId = resolveSessionAgentId({
sessionKey,
config: cfg,
agentId: selectedAgent.agentId,
});
const resolvedSessionModel = resolveSessionModelRef(cfg, entry, sessionAgentId);
const hardMax = 1000;
const defaultLimit = 200;
const requested = typeof limit === "number" ? limit : defaultLimit;
const max = Math.min(hardMax, requested);
const maxHistoryBytes = getMaxChatHistoryMessagesBytes();
const rawHistoryWindow = resolveSessionHistoryTailReadOptions(max);
const localHistoryReadOptions = {
maxMessages: rawHistoryWindow.maxMessages + 1,
maxLines: rawHistoryWindow.maxLines + 1,
};
const localMessages =
sessionId && storePath
? await readRecentSessionMessagesAsync(sessionId, storePath, entry?.sessionFile, {
...localHistoryReadOptions,
maxBytes: Math.max(maxHistoryBytes * 2, 1024 * 1024),
})
: [];
const overreadContextMessage =
localMessages.length > rawHistoryWindow.maxMessages ? localMessages[0] : undefined;
const localMessagesWithBoundaryFilter = dropLocalHistoryOverreadContextMessage(
dropPreSessionStartAnnouncePairs(
localMessages,
typeof entry?.sessionStartedAt === "number" ? entry.sessionStartedAt : undefined,
async function handleChatHistoryRequest({
params,
respond,
context,
method,
includeAgentsList,
}: GatewayRequestHandlerOptions & {
method: ChatHistoryMethod;
includeAgentsList?: boolean;
}) {
if (!validateChatHistoryParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid ${method} params: ${formatValidationErrors(validateChatHistoryParams.errors)}`,
),
overreadContextMessage,
);
const rawMessages = augmentChatHistoryWithCliSessionImports({
entry,
provider: resolvedSessionModel.provider,
localMessages: localMessagesWithBoundaryFilter,
});
// Drop subagent_announce pairs (user inter-session announce + adjacent
// assistant) whose record timestamp predates the current session's
// sessionStartedAt. Run after CLI history imports too, because those
// timestamped messages share the same chat.history response surface.
const recencyFilteredMessages = dropPreSessionStartAnnouncePairs(
rawMessages,
return;
}
const { sessionKey, limit, maxChars } = params as {
sessionKey: string;
agentId?: string;
limit?: number;
maxChars?: number;
};
const agentIdOverride = normalizeOptionalText((params as { agentId?: string }).agentId);
const requestedAgentId = resolveRequestedChatAgentId({
cfg: (context as { getRuntimeConfig?: () => OpenClawConfig }).getRuntimeConfig?.(),
requestedSessionKey: sessionKey,
agentId: agentIdOverride,
});
const sessionLoadOptions = requestedAgentId ? { agentId: requestedAgentId } : undefined;
const { cfg, storePath, store, entry, canonicalKey } = loadSessionEntry(
sessionKey,
sessionLoadOptions,
);
const selectedAgent = validateChatSelectedAgent({
cfg,
requestedSessionKey: sessionKey,
agentId: requestedAgentId,
});
if (!selectedAgent.ok) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, selectedAgent.error));
return;
}
const sessionId = entry?.sessionId;
const sessionAgentId = resolveSessionAgentId({
sessionKey,
config: cfg,
agentId: selectedAgent.agentId,
});
const resolvedSessionModel = resolveSessionModelRef(cfg, entry, sessionAgentId);
const hardMax = 1000;
const defaultLimit = 200;
const requested = typeof limit === "number" ? limit : defaultLimit;
const max = Math.min(hardMax, requested);
const maxHistoryBytes = getMaxChatHistoryMessagesBytes();
const rawHistoryWindow = resolveSessionHistoryTailReadOptions(max);
const localHistoryReadOptions = {
maxMessages: rawHistoryWindow.maxMessages + 1,
maxLines: rawHistoryWindow.maxLines + 1,
};
const localMessages =
sessionId && storePath
? await readRecentSessionMessagesAsync(sessionId, storePath, entry?.sessionFile, {
...localHistoryReadOptions,
maxBytes: Math.max(maxHistoryBytes * 2, 1024 * 1024),
})
: [];
const overreadContextMessage =
localMessages.length > rawHistoryWindow.maxMessages ? localMessages[0] : undefined;
const localMessagesWithBoundaryFilter = dropLocalHistoryOverreadContextMessage(
dropPreSessionStartAnnouncePairs(
localMessages,
typeof entry?.sessionStartedAt === "number" ? entry.sessionStartedAt : undefined,
),
overreadContextMessage,
);
const rawMessages = augmentChatHistoryWithCliSessionImports({
entry,
provider: resolvedSessionModel.provider,
localMessages: localMessagesWithBoundaryFilter,
});
// Drop subagent_announce pairs (user inter-session announce + adjacent
// assistant) whose record timestamp predates the current session's
// sessionStartedAt. Run after CLI history imports too, because those
// timestamped messages share the same chat.history response surface.
const recencyFilteredMessages = dropPreSessionStartAnnouncePairs(
rawMessages,
typeof entry?.sessionStartedAt === "number" ? entry.sessionStartedAt : undefined,
);
const effectiveMaxChars = resolveEffectiveChatHistoryMaxChars(cfg, maxChars);
const normalized = augmentChatHistoryWithCanvasBlocks(
projectRecentChatDisplayMessages(recencyFilteredMessages, {
maxChars: effectiveMaxChars,
maxMessages: max,
}),
);
const perMessageHardCap = Math.min(CHAT_HISTORY_MAX_SINGLE_MESSAGE_BYTES, maxHistoryBytes);
const replaced = replaceOversizedChatHistoryMessages({
messages: normalized,
maxSingleMessageBytes: perMessageHardCap,
});
scheduleChatHistoryManagedImageCleanup({
sessionKey,
...(selectedAgent.agentId ? { agentId: selectedAgent.agentId } : {}),
context,
});
const capped = capArrayByJsonBytes(replaced.messages, maxHistoryBytes).items;
const bounded = enforceChatHistoryFinalBudget({ messages: capped, maxBytes: maxHistoryBytes });
const placeholderCount = replaced.replacedCount + bounded.placeholderCount;
if (placeholderCount > 0) {
chatHistoryPlaceholderEmitCount += placeholderCount;
logLargePayload({
surface: "gateway.chat.history",
action: "truncated",
bytes: jsonUtf8Bytes(normalized),
limitBytes: maxHistoryBytes,
count: placeholderCount,
reason: "chat_history_budget",
});
context.logGateway.debug(
`chat.history omitted oversized payloads placeholders=${placeholderCount} total=${chatHistoryPlaceholderEmitCount}`,
);
const effectiveMaxChars = resolveEffectiveChatHistoryMaxChars(cfg, maxChars);
const normalized = augmentChatHistoryWithCanvasBlocks(
projectRecentChatDisplayMessages(recencyFilteredMessages, {
maxChars: effectiveMaxChars,
maxMessages: max,
}),
);
const perMessageHardCap = Math.min(CHAT_HISTORY_MAX_SINGLE_MESSAGE_BYTES, maxHistoryBytes);
const replaced = replaceOversizedChatHistoryMessages({
messages: normalized,
maxSingleMessageBytes: perMessageHardCap,
});
scheduleChatHistoryManagedImageCleanup({
sessionKey,
...(selectedAgent.agentId ? { agentId: selectedAgent.agentId } : {}),
context,
});
const capped = capArrayByJsonBytes(replaced.messages, maxHistoryBytes).items;
const bounded = enforceChatHistoryFinalBudget({ messages: capped, maxBytes: maxHistoryBytes });
const placeholderCount = replaced.replacedCount + bounded.placeholderCount;
if (placeholderCount > 0) {
chatHistoryPlaceholderEmitCount += placeholderCount;
logLargePayload({
surface: "gateway.chat.history",
action: "truncated",
bytes: jsonUtf8Bytes(normalized),
limitBytes: maxHistoryBytes,
count: placeholderCount,
reason: "chat_history_budget",
});
context.logGateway.debug(
`chat.history omitted oversized payloads placeholders=${placeholderCount} total=${chatHistoryPlaceholderEmitCount}`,
);
}
const modelCatalog = await measureDiagnosticsTimelineSpan(
"gateway.chat.history.model_catalog",
() => loadOptionalServerMethodModelCatalog(context, "chat.history"),
{
config: cfg,
phase: "chat.history",
},
);
const sessionInfo = buildGatewaySessionInfo({
cfg,
storePath,
store,
key: canonicalKey,
entry,
agentId: selectedAgent.agentId,
modelCatalog,
});
const defaultAgentId = resolveDefaultAgentId(cfg);
const activeRunAgentId =
canonicalKey === "global" ? (selectedAgent.agentId ?? defaultAgentId) : selectedAgent.agentId;
sessionInfo.hasActiveRun = hasTrackedActiveSessionRun({
context,
requestedKey: sessionKey,
canonicalKey,
...(activeRunAgentId ? { agentId: activeRunAgentId } : {}),
defaultAgentId,
});
const defaults = getSessionDefaults(cfg, modelCatalog, { allowPluginNormalization: false });
const thinkingLevel = sessionInfo.thinkingLevel ?? sessionInfo.thinkingDefault;
const verboseLevel = entry?.verboseLevel ?? cfg.agents?.defaults?.verboseDefault;
sessionInfo.verboseLevel = verboseLevel;
// Surface any run still streaming for this session+agent so a client that
// switched away (and stopped receiving the run's per-agent-delivered events)
// can restore the in-flight assistant text on switch-back.
const inFlightRun = resolveInFlightRunSnapshot({
chatAbortControllers: context.chatAbortControllers,
chatRunBuffers: context.chatRunBuffers,
requestedSessionKey: sessionKey,
canonicalSessionKey: resolveSessionStoreKey({ cfg, sessionKey }),
agentId: activeRunAgentId,
defaultAgentId,
});
const boundedInFlightRun = boundInFlightRunSnapshotForChatHistory({
snapshot: inFlightRun,
messages: bounded.messages,
maxBytes: maxHistoryBytes,
});
respond(true, {
sessionKey,
sessionId,
messages: bounded.messages,
defaults,
sessionInfo,
thinkingLevel,
fastMode: entry?.fastMode,
verboseLevel,
...(boundedInFlightRun ? { inFlightRun: boundedInFlightRun } : {}),
});
}
const modelCatalog = await measureDiagnosticsTimelineSpan(
`gateway.${method}.model_catalog`,
() => loadOptionalServerMethodModelCatalog(context, method),
{
config: cfg,
phase: method,
},
);
const sessionInfo = buildGatewaySessionInfo({
cfg,
storePath,
store,
key: canonicalKey,
entry,
agentId: selectedAgent.agentId,
modelCatalog,
});
const defaultAgentId = resolveDefaultAgentId(cfg);
const activeRunAgentId =
canonicalKey === "global" ? (selectedAgent.agentId ?? defaultAgentId) : selectedAgent.agentId;
sessionInfo.hasActiveRun = hasTrackedActiveSessionRun({
context,
requestedKey: sessionKey,
canonicalKey,
...(activeRunAgentId ? { agentId: activeRunAgentId } : {}),
defaultAgentId,
});
const defaults = getSessionDefaults(cfg, modelCatalog, { allowPluginNormalization: false });
const thinkingLevel = sessionInfo.thinkingLevel ?? sessionInfo.thinkingDefault;
const verboseLevel = entry?.verboseLevel ?? cfg.agents?.defaults?.verboseDefault;
sessionInfo.verboseLevel = verboseLevel;
// Surface any run still streaming for this session+agent so a client that
// switched away (and stopped receiving the run's per-agent-delivered events)
// can restore the in-flight assistant text on switch-back.
const inFlightRun = resolveInFlightRunSnapshot({
chatAbortControllers: context.chatAbortControllers,
chatRunBuffers: context.chatRunBuffers,
requestedSessionKey: sessionKey,
canonicalSessionKey: resolveSessionStoreKey({ cfg, sessionKey }),
agentId: activeRunAgentId,
defaultAgentId,
});
const boundedInFlightRun = boundInFlightRunSnapshotForChatHistory({
snapshot: inFlightRun,
messages: bounded.messages,
maxBytes: maxHistoryBytes,
});
const payload = {
sessionKey,
sessionId,
messages: bounded.messages,
defaults,
sessionInfo,
thinkingLevel,
fastMode: entry?.fastMode,
verboseLevel,
...(boundedInFlightRun ? { inFlightRun: boundedInFlightRun } : {}),
...(includeAgentsList ? { agentsList: listAgentsForGateway(cfg, modelCatalog) } : {}),
};
respond(true, payload);
}
export const chatHandlers: GatewayRequestHandlers = {
"chat.history": async (opts) => {
await handleChatHistoryRequest({ ...opts, method: "chat.history" });
},
"chat.startup": async (opts) => {
await handleChatHistoryRequest({ ...opts, method: "chat.startup", includeAgentsList: true });
},
"chat.message.get": async ({ params, respond, context }) => {
if (!validateChatMessageGetParams(params)) {

View File

@@ -372,6 +372,60 @@ describe("gateway server chat", () => {
});
});
test("chat.startup returns chat history with the initial agents list", async () => {
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
await connectOk(ws);
const sessionDir = await createSessionDir();
const updatedAt = Date.now();
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt,
modelProvider: "openai",
model: "gpt-5",
},
},
});
await writeMainSessionTranscript(sessionDir, [
JSON.stringify({
message: {
role: "user",
content: [{ type: "text", text: "startup hydrate" }],
timestamp: updatedAt,
},
}),
]);
const startup = await rpcReq<{
agentsList?: {
agents?: Array<{ id?: string }>;
defaultId?: string | null;
mainKey?: string | null;
};
messages?: unknown[];
sessionInfo?: { key?: string; sessionId?: string };
}>(ws, "chat.startup", { sessionKey: "main" });
expect(startup.ok).toBe(true);
expect(startup.payload?.agentsList?.defaultId).toBe("main");
expect(startup.payload?.agentsList?.mainKey).toBe("main");
expect(startup.payload?.agentsList?.agents?.map((agent) => agent.id)).toContain("main");
expect(startup.payload?.sessionInfo).toMatchObject({
key: "agent:main:main",
sessionId: "sess-main",
});
expect(startup.payload?.messages).toEqual(
expect.arrayContaining([
expect.objectContaining({
role: "user",
content: [{ type: "text", text: "startup hydrate" }],
}),
]),
);
});
});
test("chat.send returns in_flight when duplicate attachment send wins parsing race", async () => {
const sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-"));
const dispatchRelease = createDeferred<void>();

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();
}