diff --git a/src/gateway/methods/core-descriptors.ts b/src/gateway/methods/core-descriptors.ts index e9fec2a659ea..7950cd1b758d 100644 --- a/src/gateway/methods/core-descriptors.ts +++ b/src/gateway/methods/core-descriptors.ts @@ -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" }, diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index ed21f04a8c01..64abaa9f750f 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -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({ diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 670ad66f2203..497024cc3f98 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -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)) { diff --git a/src/gateway/server.chat.gateway-server-chat-b.test.ts b/src/gateway/server.chat.gateway-server-chat-b.test.ts index 078192b63757..f726b54f2635 100644 --- a/src/gateway/server.chat.gateway-server-chat-b.test.ts +++ b/src/gateway/server.chat.gateway-server-chat-b.test.ts @@ -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(); diff --git a/ui/src/test-helpers/control-ui-e2e.ts b/ui/src/test-helpers/control-ui-e2e.ts index 34f1ea17fa1e..561439309516 100644 --- a/ui/src/test-helpers/control-ui-e2e.ts +++ b/ui/src/test-helpers/control-ui-e2e.ts @@ -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: diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts index 2224435f4be0..b66eaa2aa050 100644 --- a/ui/src/ui/app-chat.ts +++ b/ui/src/ui/app-chat.ts @@ -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>; chatSendTimingsByRun?: Map; 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; }; +type ChatAgentsListSnapshot = Partial> & { + 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[0]); diff --git a/ui/src/ui/app-gateway-chat-load.node.test.ts b/ui/src/ui/app-gateway-chat-load.node.test.ts index 438184cb93be..c68f8ba66d01 100644 --- a/ui/src/ui/app-gateway-chat-load.node.test.ts +++ b/ui/src/ui/app-gateway-chat-load.node.test.ts @@ -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(); }); diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index e8ddf24433bb..3cb6fbd25eb8 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -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[0]).catch( - (err: unknown) => { - initialRefreshError = normalizeStartupRefreshError(err); - }, - ) + ? refreshActiveTab(host as unknown as Parameters[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[0]); } else if (initialRefreshError) { diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index a3f002cea90c..220a2d9535d7 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -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[0]); + await refreshChat(host as unknown as Parameters[0], { + awaitHistory: opts?.chatStartup === true, + startup: opts?.chatStartup === true, + }); scheduleChatScroll( host as unknown as Parameters[0], !host.chatHasAutoScrolled, diff --git a/ui/src/ui/controllers/chat.test.ts b/ui/src/ui/controllers/chat.test.ts index f29d63ad2a67..18762c51f50d 100644 --- a/ui/src/ui/controllers/chat.test.ts +++ b/ui/src/ui/controllers/chat.test.ts @@ -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 { diff --git a/ui/src/ui/controllers/chat.ts b/ui/src/ui/controllers/chat.ts index da9545cd83fc..11cd640b3df6 100644 --- a/ui/src/ui/controllers/chat.ts +++ b/ui/src/ui/controllers/chat.ts @@ -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> & { + agents?: Array<{ id: string }>; +}; + export type ChatHistoryResult = { messages?: Array; sessionId?: string; thinkingLevel?: string; defaults?: GatewaySessionsDefaults; sessionInfo?: GatewaySessionRow; + agentsList?: AgentsListResult; }; export type ChatEventPayload = { @@ -506,6 +529,10 @@ type InFlightChatHistoryRequest = { promise: Promise; }; +type LoadChatHistoryOptions = { + startup?: boolean; +}; + const inFlightChatHistoryRequests = new WeakMap(); function recordChatHistoryTiming( @@ -528,7 +555,10 @@ function recordChatHistoryTiming( ); } -export async function loadChatHistory(state: ChatState): Promise { +export async function loadChatHistory( + state: ChatState, + opts: LoadChatHistoryOptions = {}, +): Promise { if (!state.client || !state.connected) { return undefined; } @@ -536,7 +566,10 @@ export async function loadChatHistory(state: ChatState): Promise { - 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 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, sessionKey: string, requestAgentId: string | undefined, + method: "chat.history" | "chat.startup", ): Promise { 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("chat.history", { + res = await client.request(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("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, diff --git a/ui/src/ui/e2e/chat-flow.e2e.test.ts b/ui/src/ui/e2e/chat-flow.e2e.test.ts index f0c385f669a5..9764ce002733 100644 --- a/ui/src/ui/e2e/chat-flow.e2e.test.ts +++ b/ui/src/ui/e2e/chat-flow.e2e.test.ts @@ -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(); }