mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-12 06:02:59 +00: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:
@@ -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" },
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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