perf(control-ui): coalesce chat metadata startup

Add a coalesced chat.metadata Gateway method so the Control UI can fetch model and command metadata without blocking a clean first message path. Reuses existing models/commands builders, keeps compatibility fallback for older gateways, updates protocol artifacts, and adds focused gateway/UI/e2e coverage.
This commit is contained in:
Vincent Koc
2026-06-02 22:34:54 -07:00
committed by GitHub
parent 2a512025ad
commit c0b05a2100
21 changed files with 708 additions and 321 deletions

View File

@@ -6896,6 +6896,20 @@ public struct ChatHistoryParams: Codable, Sendable {
}
}
public struct ChatMetadataParams: Codable, Sendable {
public let agentid: String?
public init(
agentid: String? = nil)
{
self.agentid = agentid
}
private enum CodingKeys: String, CodingKey {
case agentid = "agentId"
}
}
public struct ChatMessageGetParams: Codable, Sendable {
public let sessionkey: String
public let agentid: String?

View File

@@ -5,6 +5,7 @@ import {
formatValidationErrors,
validateChatAbortParams,
validateChatHistoryParams,
validateChatMetadataParams,
validateChatSendParams,
validateChatEvent,
validateCommandsListParams,
@@ -104,6 +105,13 @@ describe("lazy protocol validators", () => {
).toBe(true);
});
it("accepts selected-agent scope on chat metadata params", () => {
expect(validateChatMetadataParams({})).toBe(true);
expect(validateChatMetadataParams({ agentId: "work" })).toBe(true);
expect(validateChatMetadataParams({ agentId: "" })).toBe(false);
expect(validateChatMetadataParams({ agentId: "work", view: "configured" })).toBe(false);
});
it("can still compile every exported protocol validator", () => {
const failures: string[] = [];
const validators: Array<[string, ProtocolValidator]> = [];

View File

@@ -126,6 +126,8 @@ import {
type ChatEvent,
ChatEventSchema,
ChatHistoryParamsSchema,
type ChatMetadataParams,
ChatMetadataParamsSchema,
ChatMessageGetResultSchema,
ChatMessageGetParamsSchema,
type ChatInjectParams,
@@ -846,6 +848,7 @@ export const validateExecApprovalsNodeSetParams = lazyCompile<ExecApprovalsNodeS
);
export const validateLogsTailParams = lazyCompile<LogsTailParams>(LogsTailParamsSchema);
export const validateChatHistoryParams = lazyCompile(ChatHistoryParamsSchema);
export const validateChatMetadataParams = lazyCompile<ChatMetadataParams>(ChatMetadataParamsSchema);
export const validateChatMessageGetParams = lazyCompile(ChatMessageGetParamsSchema);
export const validateChatSendParams = lazyCompile(ChatSendParamsSchema);
export const validateChatAbortParams = lazyCompile<ChatAbortParams>(ChatAbortParamsSchema);
@@ -1115,6 +1118,7 @@ export {
ExecApprovalRequestParamsSchema,
ExecApprovalResolveParamsSchema,
ChatHistoryParamsSchema,
ChatMetadataParamsSchema,
ChatSendParamsSchema,
ChatInjectParamsSchema,
UpdateRunParamsSchema,
@@ -1223,6 +1227,7 @@ export type {
ArtifactsDownloadResult,
AgentsListParams,
AgentsListResult,
ChatMetadataParams,
CommandsListParams,
CommandsListResult,
CommandEntry,

View File

@@ -34,6 +34,13 @@ export const ChatHistoryParamsSchema = Type.Object(
{ additionalProperties: false },
);
export const ChatMetadataParamsSchema = Type.Object(
{
agentId: Type.Optional(NonEmptyString),
},
{ additionalProperties: false },
);
export const ChatMessageGetParamsSchema = Type.Object(
{
sessionKey: NonEmptyString,

View File

@@ -191,6 +191,7 @@ import {
ChatEventSchema,
ChatFinalEventSchema,
ChatHistoryParamsSchema,
ChatMetadataParamsSchema,
ChatMessageGetParamsSchema,
ChatMessageGetResultSchema,
ChatInjectParamsSchema,
@@ -534,6 +535,7 @@ export const ProtocolSchemas = {
DevicePairRequestedEvent: DevicePairRequestedEventSchema,
DevicePairResolvedEvent: DevicePairResolvedEventSchema,
ChatHistoryParams: ChatHistoryParamsSchema,
ChatMetadataParams: ChatMetadataParamsSchema,
ChatMessageGetParams: ChatMessageGetParamsSchema,
ChatMessageGetResult: ChatMessageGetResultSchema,
ChatSendParams: ChatSendParamsSchema,

View File

@@ -160,6 +160,7 @@ export type AgentsListResult = SchemaType<"AgentsListResult">;
export type ModelChoice = SchemaType<"ModelChoice">;
export type ModelsListParams = SchemaType<"ModelsListParams">;
export type ModelsListResult = SchemaType<"ModelsListResult">;
export type ChatMetadataParams = SchemaType<"ChatMetadataParams">;
export type CommandEntry = SchemaType<"CommandEntry">;
export type CommandsListParams = SchemaType<"CommandsListParams">;
export type CommandsListResult = SchemaType<"CommandsListResult">;

View File

@@ -201,6 +201,7 @@ export const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [
{ 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.metadata", 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

@@ -280,6 +280,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
methods: [
"chat.history",
"chat.startup",
"chat.metadata",
"chat.message.get",
"chat.abort",
"chat.send",

View File

@@ -23,6 +23,7 @@ import {
validateChatAbortParams,
validateChatHistoryParams,
validateChatInjectParams,
validateChatMetadataParams,
validateChatMessageGetParams,
validateChatSendParams,
} from "../../../packages/gateway-protocol/src/index.js";
@@ -211,6 +212,62 @@ type PreRegisteredAgentRun = {
type ChatHistoryMethod = "chat.history" | "chat.startup";
async function handleChatMetadataRequest({
params,
respond,
context,
}: GatewayRequestHandlerOptions): Promise<void> {
if (!validateChatMetadataParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid chat.metadata params: ${formatValidationErrors(validateChatMetadataParams.errors)}`,
),
);
return;
}
const metadataParams = params;
const cfg = context.getRuntimeConfig();
const requestedAgentId =
typeof metadataParams.agentId === "string" && metadataParams.agentId.trim()
? normalizeAgentId(metadataParams.agentId)
: resolveDefaultAgentId(cfg);
if (!listAgentIds(cfg).includes(requestedAgentId)) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `Unknown agent id "${metadataParams.agentId}"`),
);
return;
}
try {
const [{ buildModelsListResult }, { buildCommandsListResult }] = await Promise.all([
import("./models-list-result.js"),
import("./commands-list-result.js"),
]);
const [models, commands] = await Promise.all([
buildModelsListResult({
context,
agentId: requestedAgentId,
params: { view: "configured" },
}),
Promise.resolve(
buildCommandsListResult({
cfg,
agentId: requestedAgentId,
includeArgs: true,
scope: "text",
}),
),
]);
respond(true, { ...models, ...commands });
} catch (err) {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(err)));
}
}
function normalizeUnknownText(value: unknown): string | undefined {
return typeof value === "string" ? normalizeOptionalText(value) : undefined;
}
@@ -2610,6 +2667,7 @@ export const chatHandlers: GatewayRequestHandlers = {
"chat.startup": async (opts) => {
await handleChatHistoryRequest({ ...opts, method: "chat.startup", includeAgentsList: true });
},
"chat.metadata": handleChatMetadataRequest,
"chat.message.get": async ({ params, respond, context }) => {
if (!validateChatMessageGetParams(params)) {
respond(

View File

@@ -0,0 +1,229 @@
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
import type {
CommandEntry,
CommandsListResult,
} from "../../../packages/gateway-protocol/src/index.js";
import {
COMMAND_ALIAS_MAX_ITEMS,
COMMAND_ARG_CHOICES_MAX_ITEMS,
COMMAND_ARG_DESCRIPTION_MAX_LENGTH,
COMMAND_ARG_NAME_MAX_LENGTH,
COMMAND_ARGS_MAX_ITEMS,
COMMAND_CHOICE_LABEL_MAX_LENGTH,
COMMAND_CHOICE_VALUE_MAX_LENGTH,
COMMAND_DESCRIPTION_MAX_LENGTH,
COMMAND_LIST_MAX_ITEMS,
COMMAND_NAME_MAX_LENGTH,
} from "../../../packages/gateway-protocol/src/schema.js";
import { listChatCommandsForConfig } from "../../auto-reply/commands-registry.js";
import type {
ChatCommandDefinition,
CommandArgChoice,
CommandArgDefinition,
} from "../../auto-reply/commands-registry.types.js";
import { getChannelPlugin } from "../../channels/plugins/index.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import {
getPluginCommandEntrySpecs,
getPluginCommandEntrySpecsFromRegistrations,
} from "../../plugins/command-specs.js";
import { getActivePluginGatewayCommandRegistry } from "../../plugins/runtime.js";
import { listSkillCommandsForAgents } from "../../skills/discovery/chat-commands.js";
type SerializedArg = NonNullable<CommandEntry["args"]>[number];
type CommandNameSurface = "text" | "native";
function clampString(value: string, maxLength: number): string {
return value.length > maxLength ? value.slice(0, maxLength) : value;
}
function trimClampNonEmpty(value: string, maxLength: number): string | null {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
return clampString(trimmed, maxLength);
}
function clampDescription(value: string | undefined): string {
return clampString(value ?? "", COMMAND_DESCRIPTION_MAX_LENGTH);
}
function resolveNativeName(cmd: ChatCommandDefinition, provider?: string): string {
const baseName = cmd.nativeName ?? cmd.key;
if (!provider || !cmd.nativeName) {
return baseName;
}
return (
getChannelPlugin(provider)?.commands?.resolveNativeCommandName?.({
commandKey: cmd.key,
defaultName: cmd.nativeName,
}) ?? baseName
);
}
function stripLeadingSlash(value: string): string {
return value.startsWith("/") ? value.slice(1) : value;
}
/** Resolves normalized text aliases, preserving slash-prefixed command names. */
function resolveTextAliases(cmd: ChatCommandDefinition): string[] {
const seen = new Set<string>();
const aliases: string[] = [];
for (const alias of cmd.textAliases) {
const trimmed = trimClampNonEmpty(alias, COMMAND_NAME_MAX_LENGTH);
if (!trimmed) {
continue;
}
const exactAlias = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
if (seen.has(exactAlias)) {
continue;
}
seen.add(exactAlias);
aliases.push(exactAlias);
if (aliases.length >= COMMAND_ALIAS_MAX_ITEMS) {
break;
}
}
if (aliases.length > 0) {
return aliases;
}
return [`/${clampString(cmd.key, COMMAND_NAME_MAX_LENGTH)}`];
}
function resolvePrimaryTextName(cmd: ChatCommandDefinition): string {
return stripLeadingSlash(resolveTextAliases(cmd)[0] ?? `/${cmd.key}`);
}
/** Serializes a command argument into the bounded gateway protocol shape. */
function serializeArg(arg: CommandArgDefinition): SerializedArg {
const isDynamic = typeof arg.choices === "function";
const staticChoices = Array.isArray(arg.choices)
? arg.choices.slice(0, COMMAND_ARG_CHOICES_MAX_ITEMS).map(normalizeChoice)
: undefined;
return {
name: clampString(arg.name, COMMAND_ARG_NAME_MAX_LENGTH),
description: clampString(arg.description, COMMAND_ARG_DESCRIPTION_MAX_LENGTH),
type: arg.type,
...(arg.required ? { required: true } : {}),
...(staticChoices ? { choices: staticChoices } : {}),
...(isDynamic ? { dynamic: true } : {}),
};
}
function normalizeChoice(choice: CommandArgChoice): { value: string; label: string } {
if (typeof choice === "string") {
const value = clampString(choice, COMMAND_CHOICE_VALUE_MAX_LENGTH);
return {
value,
label: clampString(choice, COMMAND_CHOICE_LABEL_MAX_LENGTH),
};
}
return {
value: clampString(choice.value, COMMAND_CHOICE_VALUE_MAX_LENGTH),
label: clampString(choice.label, COMMAND_CHOICE_LABEL_MAX_LENGTH),
};
}
function mapCommand(
cmd: ChatCommandDefinition,
source: "native" | "skill",
includeArgs: boolean,
nameSurface: CommandNameSurface,
provider?: string,
): CommandEntry {
const shouldIncludeArgs = includeArgs && cmd.acceptsArgs && cmd.args?.length;
const nativeName = cmd.scope === "text" ? undefined : resolveNativeName(cmd, provider);
return {
name: clampString(
nameSurface === "text" ? resolvePrimaryTextName(cmd) : (nativeName ?? cmd.key),
COMMAND_NAME_MAX_LENGTH,
),
...(nativeName ? { nativeName: clampString(nativeName, COMMAND_NAME_MAX_LENGTH) } : {}),
...(cmd.scope !== "native" ? { textAliases: resolveTextAliases(cmd) } : {}),
description: clampDescription(cmd.description),
...(cmd.category ? { category: cmd.category } : {}),
source,
scope: cmd.scope,
acceptsArgs: Boolean(cmd.acceptsArgs),
...(shouldIncludeArgs
? { args: cmd.args!.slice(0, COMMAND_ARGS_MAX_ITEMS).map(serializeArg) }
: {}),
};
}
/** Builds plugin command entries from text specs plus provider-native metadata. */
function buildPluginCommandEntries(params: {
provider?: string;
nameSurface: CommandNameSurface;
cfg: OpenClawConfig;
}): CommandEntry[] {
const gatewayRegistry = getActivePluginGatewayCommandRegistry();
const pluginSpecs = gatewayRegistry
? getPluginCommandEntrySpecsFromRegistrations(gatewayRegistry.commands, params.provider, {
config: params.cfg,
})
: getPluginCommandEntrySpecs(params.provider, { config: params.cfg });
const entries: CommandEntry[] = [];
for (const spec of pluginSpecs) {
entries.push({
name: clampString(
params.nameSurface === "text" ? spec.name : (spec.nativeName ?? spec.name),
COMMAND_NAME_MAX_LENGTH,
),
...(spec.nativeName
? { nativeName: clampString(spec.nativeName, COMMAND_NAME_MAX_LENGTH) }
: {}),
textAliases: [`/${clampString(spec.name, COMMAND_NAME_MAX_LENGTH)}`],
description: clampDescription(spec.description),
source: "plugin",
scope: "both",
acceptsArgs: spec.acceptsArgs,
});
}
if (params.nameSurface === "native") {
return entries.filter((entry) => entry.nativeName);
}
return entries;
}
/** Builds the public commands.list payload for an agent/provider/scope view. */
export function buildCommandsListResult(params: {
cfg: OpenClawConfig;
agentId: string;
provider?: string;
scope?: "native" | "text" | "both";
includeArgs?: boolean;
}): CommandsListResult {
const includeArgs = params.includeArgs !== false;
const scopeFilter = params.scope ?? "both";
const nameSurface: CommandNameSurface = scopeFilter === "text" ? "text" : "native";
const provider = normalizeOptionalLowercaseString(params.provider);
const skillCommands = listSkillCommandsForAgents({ cfg: params.cfg, agentIds: [params.agentId] });
const chatCommands = listChatCommandsForConfig(params.cfg, { skillCommands });
const skillKeys = new Set(skillCommands.map((sc) => `skill:${sc.skillName}`));
const commands: CommandEntry[] = [];
for (const cmd of chatCommands) {
if (scopeFilter !== "both" && cmd.scope !== "both" && cmd.scope !== scopeFilter) {
continue;
}
commands.push(
mapCommand(
cmd,
skillKeys.has(cmd.key) ? "skill" : "native",
includeArgs,
nameSurface,
provider,
),
);
}
commands.push(...buildPluginCommandEntries({ provider, nameSurface, cfg: params.cfg }));
return { commands: commands.slice(0, COMMAND_LIST_MAX_ITEMS) };
}

View File

@@ -1,240 +1,14 @@
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
import type {
CommandEntry,
CommandsListResult,
} from "../../../packages/gateway-protocol/src/index.js";
import {
ErrorCodes,
errorShape,
formatValidationErrors,
validateCommandsListParams,
} from "../../../packages/gateway-protocol/src/index.js";
import {
COMMAND_ALIAS_MAX_ITEMS,
COMMAND_ARG_CHOICES_MAX_ITEMS,
COMMAND_ARG_DESCRIPTION_MAX_LENGTH,
COMMAND_ARG_NAME_MAX_LENGTH,
COMMAND_ARGS_MAX_ITEMS,
COMMAND_CHOICE_LABEL_MAX_LENGTH,
COMMAND_CHOICE_VALUE_MAX_LENGTH,
COMMAND_DESCRIPTION_MAX_LENGTH,
COMMAND_LIST_MAX_ITEMS,
COMMAND_NAME_MAX_LENGTH,
} from "../../../packages/gateway-protocol/src/schema.js";
import { listChatCommandsForConfig } from "../../auto-reply/commands-registry.js";
import type {
ChatCommandDefinition,
CommandArgChoice,
CommandArgDefinition,
} from "../../auto-reply/commands-registry.types.js";
import { getChannelPlugin } from "../../channels/plugins/index.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import {
getPluginCommandEntrySpecs,
getPluginCommandEntrySpecsFromRegistrations,
} from "../../plugins/command-specs.js";
import { getActivePluginGatewayCommandRegistry } from "../../plugins/runtime.js";
import { listSkillCommandsForAgents } from "../../skills/discovery/chat-commands.js";
import { resolveAgentIdOrRespondError } from "./agent-id-shared.js";
import { buildCommandsListResult } from "./commands-list-result.js";
import type { GatewayRequestHandlers } from "./types.js";
type SerializedArg = NonNullable<CommandEntry["args"]>[number];
type CommandNameSurface = "text" | "native";
function clampString(value: string, maxLength: number): string {
return value.length > maxLength ? value.slice(0, maxLength) : value;
}
function trimClampNonEmpty(value: string, maxLength: number): string | null {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
return clampString(trimmed, maxLength);
}
function clampDescription(value: string | undefined): string {
return clampString(value ?? "", COMMAND_DESCRIPTION_MAX_LENGTH);
}
function resolveNativeName(cmd: ChatCommandDefinition, provider?: string): string {
const baseName = cmd.nativeName ?? cmd.key;
if (!provider || !cmd.nativeName) {
return baseName;
}
return (
getChannelPlugin(provider)?.commands?.resolveNativeCommandName?.({
commandKey: cmd.key,
defaultName: cmd.nativeName,
}) ?? baseName
);
}
function stripLeadingSlash(value: string): string {
return value.startsWith("/") ? value.slice(1) : value;
}
/** Resolves normalized text aliases, preserving slash-prefixed command names. */
function resolveTextAliases(cmd: ChatCommandDefinition): string[] {
const seen = new Set<string>();
const aliases: string[] = [];
for (const alias of cmd.textAliases) {
const trimmed = trimClampNonEmpty(alias, COMMAND_NAME_MAX_LENGTH);
if (!trimmed) {
continue;
}
const exactAlias = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
if (seen.has(exactAlias)) {
continue;
}
seen.add(exactAlias);
aliases.push(exactAlias);
if (aliases.length >= COMMAND_ALIAS_MAX_ITEMS) {
break;
}
}
if (aliases.length > 0) {
return aliases;
}
return [`/${clampString(cmd.key, COMMAND_NAME_MAX_LENGTH)}`];
}
function resolvePrimaryTextName(cmd: ChatCommandDefinition): string {
return stripLeadingSlash(resolveTextAliases(cmd)[0] ?? `/${cmd.key}`);
}
/** Serializes a command argument into the bounded gateway protocol shape. */
function serializeArg(arg: CommandArgDefinition): SerializedArg {
const isDynamic = typeof arg.choices === "function";
const staticChoices = Array.isArray(arg.choices)
? arg.choices.slice(0, COMMAND_ARG_CHOICES_MAX_ITEMS).map(normalizeChoice)
: undefined;
return {
name: clampString(arg.name, COMMAND_ARG_NAME_MAX_LENGTH),
description: clampString(arg.description, COMMAND_ARG_DESCRIPTION_MAX_LENGTH),
type: arg.type,
...(arg.required ? { required: true } : {}),
...(staticChoices ? { choices: staticChoices } : {}),
...(isDynamic ? { dynamic: true } : {}),
};
}
function normalizeChoice(choice: CommandArgChoice): { value: string; label: string } {
if (typeof choice === "string") {
const value = clampString(choice, COMMAND_CHOICE_VALUE_MAX_LENGTH);
return {
value,
label: clampString(choice, COMMAND_CHOICE_LABEL_MAX_LENGTH),
};
}
return {
value: clampString(choice.value, COMMAND_CHOICE_VALUE_MAX_LENGTH),
label: clampString(choice.label, COMMAND_CHOICE_LABEL_MAX_LENGTH),
};
}
function mapCommand(
cmd: ChatCommandDefinition,
source: "native" | "skill",
includeArgs: boolean,
nameSurface: CommandNameSurface,
provider?: string,
): CommandEntry {
const shouldIncludeArgs = includeArgs && cmd.acceptsArgs && cmd.args?.length;
const nativeName = cmd.scope === "text" ? undefined : resolveNativeName(cmd, provider);
return {
name: clampString(
nameSurface === "text" ? resolvePrimaryTextName(cmd) : (nativeName ?? cmd.key),
COMMAND_NAME_MAX_LENGTH,
),
...(nativeName ? { nativeName: clampString(nativeName, COMMAND_NAME_MAX_LENGTH) } : {}),
...(cmd.scope !== "native" ? { textAliases: resolveTextAliases(cmd) } : {}),
description: clampDescription(cmd.description),
...(cmd.category ? { category: cmd.category } : {}),
source,
scope: cmd.scope,
acceptsArgs: Boolean(cmd.acceptsArgs),
...(shouldIncludeArgs
? { args: cmd.args!.slice(0, COMMAND_ARGS_MAX_ITEMS).map(serializeArg) }
: {}),
};
}
/** Builds plugin command entries from text specs plus provider-native metadata. */
function buildPluginCommandEntries(params: {
provider?: string;
nameSurface: CommandNameSurface;
cfg: OpenClawConfig;
}): CommandEntry[] {
const gatewayRegistry = getActivePluginGatewayCommandRegistry();
const pluginSpecs = gatewayRegistry
? getPluginCommandEntrySpecsFromRegistrations(gatewayRegistry.commands, params.provider, {
config: params.cfg,
})
: getPluginCommandEntrySpecs(params.provider, { config: params.cfg });
const entries: CommandEntry[] = [];
for (const spec of pluginSpecs) {
entries.push({
name: clampString(
params.nameSurface === "text" ? spec.name : (spec.nativeName ?? spec.name),
COMMAND_NAME_MAX_LENGTH,
),
...(spec.nativeName
? { nativeName: clampString(spec.nativeName, COMMAND_NAME_MAX_LENGTH) }
: {}),
textAliases: [`/${clampString(spec.name, COMMAND_NAME_MAX_LENGTH)}`],
description: clampDescription(spec.description),
source: "plugin",
scope: "both",
acceptsArgs: spec.acceptsArgs,
});
}
if (params.nameSurface === "native") {
return entries.filter((entry) => entry.nativeName);
}
return entries;
}
/** Builds the public commands.list payload for an agent/provider/scope view. */
export function buildCommandsListResult(params: {
cfg: OpenClawConfig;
agentId: string;
provider?: string;
scope?: "native" | "text" | "both";
includeArgs?: boolean;
}): CommandsListResult {
const includeArgs = params.includeArgs !== false;
const scopeFilter = params.scope ?? "both";
const nameSurface: CommandNameSurface = scopeFilter === "text" ? "text" : "native";
const provider = normalizeOptionalLowercaseString(params.provider);
const skillCommands = listSkillCommandsForAgents({ cfg: params.cfg, agentIds: [params.agentId] });
const chatCommands = listChatCommandsForConfig(params.cfg, { skillCommands });
const skillKeys = new Set(skillCommands.map((sc) => `skill:${sc.skillName}`));
const commands: CommandEntry[] = [];
for (const cmd of chatCommands) {
if (scopeFilter !== "both" && cmd.scope !== "both" && cmd.scope !== scopeFilter) {
continue;
}
commands.push(
mapCommand(
cmd,
skillKeys.has(cmd.key) ? "skill" : "native",
includeArgs,
nameSurface,
provider,
),
);
}
commands.push(...buildPluginCommandEntries({ provider, nameSurface, cfg: params.cfg }));
return { commands: commands.slice(0, COMMAND_LIST_MAX_ITEMS) };
}
export { buildCommandsListResult };
/** Gateway handler for enumerating available chat/native commands. */
export const commandsHandlers: GatewayRequestHandlers = {

View File

@@ -0,0 +1,76 @@
import {
resolveAgentEffectiveModelPrimary,
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
} from "../../agents/agent-scope.js";
import { DEFAULT_PROVIDER } from "../../agents/defaults.js";
import {
loadModelCatalogForBrowse,
type ModelCatalogBrowseView,
} from "../../agents/model-catalog-browse.js";
import { resolveVisibleModelCatalog } from "../../agents/model-catalog-visibility.js";
import type { ModelCatalogEntry } from "../../agents/model-catalog.types.js";
import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js";
import type { GatewayRequestContext } from "./types.js";
type ModelsListView = ModelCatalogBrowseView;
let loggedSlowModelsListCatalog = false;
// Unknown views are rejected by protocol validation first; this helper keeps the
// handler default explicit for older clients that omit the field.
function resolveModelsListView(params: Record<string, unknown>): ModelsListView {
return typeof params.view === "string" ? (params.view as ModelsListView) : "default";
}
// Runtime-only model params are useful inside provider routing, but exposing
// them here would leak provider invocation details into the Control UI API.
function omitRuntimeModelParams(entry: ModelCatalogEntry): ModelCatalogEntry {
const { params: _params, ...rest } = entry as ModelCatalogEntry & {
params?: Record<string, unknown>;
};
return rest;
}
function omitRuntimeModelParamsFromCatalog(catalog: ModelCatalogEntry[]): ModelCatalogEntry[] {
return catalog.map(omitRuntimeModelParams);
}
export async function buildModelsListResult(params: {
context: GatewayRequestContext;
agentId?: string;
params: Record<string, unknown>;
}): Promise<{ models: ModelCatalogEntry[] }> {
const cfg = params.context.getRuntimeConfig();
const agentId = params.agentId ?? resolveDefaultAgentId(cfg);
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId) ?? resolveDefaultAgentWorkspaceDir();
const view = resolveModelsListView(params.params);
const catalog = await loadModelCatalogForBrowse({
cfg,
view,
loadCatalog: params.context.loadGatewayModelCatalog,
onTimeout: (timeoutMs) => {
if (loggedSlowModelsListCatalog) {
return;
}
loggedSlowModelsListCatalog = true;
params.context.logGateway.debug(
`models.list continuing without model catalog after ${timeoutMs}ms`,
);
},
});
if (view === "all") {
return { models: omitRuntimeModelParamsFromCatalog(catalog) };
}
const models = await resolveVisibleModelCatalog({
cfg,
catalog,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: resolveAgentEffectiveModelPrimary(cfg, agentId),
agentId,
workspaceDir,
view,
runtimeAuthDiscovery: false,
});
return { models: omitRuntimeModelParamsFromCatalog(models) };
}

View File

@@ -4,39 +4,10 @@ import {
formatValidationErrors,
validateModelsListParams,
} from "../../../packages/gateway-protocol/src/index.js";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { DEFAULT_PROVIDER } from "../../agents/defaults.js";
import {
loadModelCatalogForBrowse,
type ModelCatalogBrowseView,
} from "../../agents/model-catalog-browse.js";
import { resolveVisibleModelCatalog } from "../../agents/model-catalog-visibility.js";
import type { ModelCatalogEntry } from "../../agents/model-catalog.types.js";
import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js";
import { buildModelsListResult } from "./models-list-result.js";
import type { GatewayRequestHandlers } from "./types.js";
type ModelsListView = ModelCatalogBrowseView;
let loggedSlowModelsListCatalog = false;
// Unknown views are rejected by protocol validation first; this helper keeps the
// handler default explicit for older clients that omit the field.
function resolveModelsListView(params: Record<string, unknown>): ModelsListView {
return typeof params.view === "string" ? (params.view as ModelsListView) : "default";
}
// Runtime-only model params are useful inside provider routing, but exposing
// them here would leak provider invocation details into the Control UI API.
function omitRuntimeModelParams(entry: ModelCatalogEntry): ModelCatalogEntry {
const { params: _params, ...rest } = entry as ModelCatalogEntry & {
params?: Record<string, unknown>;
};
return rest;
}
function omitRuntimeModelParamsFromCatalog(catalog: ModelCatalogEntry[]): ModelCatalogEntry[] {
return catalog.map(omitRuntimeModelParams);
}
export { buildModelsListResult };
// The gateway model list is a browse API, not an auth probe. It reuses the
// current runtime catalog snapshot and applies visibility rules without doing
@@ -55,38 +26,7 @@ export const modelsHandlers: GatewayRequestHandlers = {
return;
}
try {
const cfg = context.getRuntimeConfig();
const workspaceDir =
resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)) ??
resolveDefaultAgentWorkspaceDir();
const view = resolveModelsListView(params);
const catalog = await loadModelCatalogForBrowse({
cfg,
view,
loadCatalog: context.loadGatewayModelCatalog,
onTimeout: (timeoutMs) => {
if (loggedSlowModelsListCatalog) {
return;
}
loggedSlowModelsListCatalog = true;
context.logGateway.debug(
`models.list continuing without model catalog after ${timeoutMs}ms`,
);
},
});
if (view === "all") {
respond(true, { models: omitRuntimeModelParamsFromCatalog(catalog) }, undefined);
return;
}
const models = await resolveVisibleModelCatalog({
cfg,
catalog,
defaultProvider: DEFAULT_PROVIDER,
workspaceDir,
view,
runtimeAuthDiscovery: false,
});
respond(true, { models: omitRuntimeModelParamsFromCatalog(models) }, undefined);
respond(true, await buildModelsListResult({ context, params }), undefined);
} catch (err) {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(err)));
}

View File

@@ -426,6 +426,72 @@ describe("gateway server chat", () => {
});
});
test("chat.metadata coalesces configured models and text commands", async () => {
await withGatewayChatHarness(async ({ ws }) => {
await writeGatewayConfig({
agents: {
defaults: {
model: {
primary: "openai/gpt-main",
fallbacks: ["openai/gpt-fallback"],
},
models: {
"openai/gpt-main": {},
},
},
list: [
{ id: "main", default: true },
{
id: "work",
model: {
primary: "minimax/MiniMax-M2.7-highspeed",
},
},
],
},
models: {
providers: {
openai: {
baseUrl: "https://openai.example.com/v1",
models: [
{ id: "gpt-main", name: "GPT Main" },
{ id: "gpt-fallback", name: "GPT Fallback" },
],
},
minimax: {
baseUrl: "https://minimax.example.com/v1",
models: [{ id: "MiniMax-M2.7-highspeed", name: "MiniMax M2.7 Highspeed" }],
},
},
},
});
await connectOk(ws);
const metadata = await rpcReq<{
commands?: Array<{ name?: string; textAliases?: string[] }>;
models?: Array<{ id?: string; provider?: string }>;
}>(ws, "chat.metadata", { agentId: "work" });
expect(metadata.ok).toBe(true);
expect(metadata.payload?.models).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "MiniMax-M2.7-highspeed",
provider: "minimax",
}),
]),
);
expect(metadata.payload?.commands).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "model",
textAliases: expect.arrayContaining(["/model"]),
}),
]),
);
});
});
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: ["chat.startup"] },
features: { events: [], methods: ["chat.metadata", "chat.startup"] },
protocol: protocolVersion,
server: { connId: "control-ui-e2e", version: "e2e" },
snapshot: {
@@ -450,6 +450,11 @@ function installControlUiMockGateway(input: {
sessionId: "control-ui-e2e-session",
thinkingLevel: null,
};
case "chat.metadata":
return {
commands: [],
models: scenario.models,
};
case "chat.send":
return {
runId:

View File

@@ -271,13 +271,10 @@ describe("refreshChat", () => {
});
expect(requestUpdate).not.toHaveBeenCalled();
await vi.waitFor(() =>
expect(request).toHaveBeenCalledWith("models.list", { view: "configured" }),
expect(request).toHaveBeenCalledWith("chat.metadata", { agentId: "main" }),
);
expect(request).toHaveBeenCalledWith("commands.list", {
agentId: "main",
includeArgs: true,
scope: "text",
});
expect(request).not.toHaveBeenCalledWith("models.list", { view: "configured" });
expect(request).not.toHaveBeenCalledWith("commands.list", expect.anything());
});
it("scopes global chat refresh session rows to the selected agent", async () => {
@@ -300,11 +297,7 @@ describe("refreshChat", () => {
});
expect(request).not.toHaveBeenCalledWith("sessions.list", expect.anything());
await vi.waitFor(() =>
expect(request).toHaveBeenCalledWith("commands.list", {
agentId: "work",
includeArgs: true,
scope: "text",
}),
expect(request).toHaveBeenCalledWith("chat.metadata", { agentId: "work" }),
);
});
@@ -421,8 +414,9 @@ describe("refreshChat", () => {
]);
expect(request).not.toHaveBeenCalledWith("models.list", { view: "configured" });
await vi.waitFor(() =>
expect(request).toHaveBeenCalledWith("models.list", { view: "configured" }),
expect(request).toHaveBeenCalledWith("chat.metadata", { agentId: "main" }),
);
expect(request).not.toHaveBeenCalledWith("models.list", { view: "configured" });
expect(requestUpdate).toHaveBeenCalled();
});
@@ -1075,19 +1069,125 @@ describe("refreshChat", () => {
]);
expect(request).not.toHaveBeenCalledWith("sessions.list", expect.anything());
await vi.waitFor(() =>
expect(request).toHaveBeenCalledWith("models.list", { view: "configured" }),
expect(request).toHaveBeenCalledWith("chat.metadata", { agentId: "main" }),
);
const commandsListPayload = findRequestPayload(
request as unknown as MockCallSource,
"commands.list",
"commands list payload",
);
expect(commandsListPayload.includeArgs).toBe(true);
expect(commandsListPayload.scope).toBe("text");
expect(request).not.toHaveBeenCalledWith("models.list", { view: "configured" });
expect(request).not.toHaveBeenCalledWith("commands.list", expect.anything());
} finally {
globalThis.fetch = previousFetch;
}
});
it("falls back to separate metadata RPCs when chat.metadata is not advertised", async () => {
const request = vi.fn(() => pendingPromise());
const host = makeHost({
client: { request } as unknown as ChatHost["client"],
sessionKey: "main",
hello: {
type: "hello-ok",
protocol: 4,
auth: { role: "operator", scopes: [] },
features: { events: [], methods: ["chat.history"] },
},
});
await refreshChat(host);
await vi.waitFor(() =>
expect(request).toHaveBeenCalledWith("models.list", { view: "configured" }),
);
expect(request).toHaveBeenCalledWith("commands.list", {
agentId: "main",
includeArgs: true,
scope: "text",
});
expect(request).not.toHaveBeenCalledWith("chat.metadata", expect.anything());
});
it("falls back to separate metadata RPCs when an older gateway rejects chat.metadata", async () => {
const { GatewayRequestError } = await import("./gateway.ts");
const request = vi.fn((method: string) => {
if (method === "chat.metadata") {
return Promise.reject(
new GatewayRequestError({
code: "INVALID_REQUEST",
message: "unknown method: chat.metadata",
}),
);
}
return pendingPromise();
});
const host = makeHost({
client: { request } as unknown as ChatHost["client"],
sessionKey: "main",
});
await refreshChat(host);
await vi.waitFor(() =>
expect(request).toHaveBeenCalledWith("models.list", { view: "configured" }),
);
expect(request).toHaveBeenCalledWith("commands.list", {
agentId: "main",
includeArgs: true,
scope: "text",
});
});
it("ignores stale chat.metadata results after the selected global agent changes", async () => {
const { resetSlashCommandsForTest, SLASH_COMMANDS } = await import("./chat/slash-commands.ts");
resetSlashCommandsForTest();
const previousFetch = globalThis.fetch;
globalThis.fetch = vi.fn().mockResolvedValue({ ok: false }) as never;
const metadata = createDeferred<unknown>();
const requestUpdate = vi.fn();
try {
const request = vi.fn((method: string) => {
if (method === "chat.history") {
return Promise.resolve({ messages: [], thinkingLevel: null });
}
if (method === "chat.metadata") {
return metadata.promise;
}
return pendingPromise();
});
const host = makeHost({
client: { request } as unknown as ChatHost["client"],
sessionKey: "global",
assistantAgentId: "work",
requestUpdate,
});
await refreshChat(host);
await vi.waitFor(() =>
expect(request).toHaveBeenCalledWith("chat.metadata", { agentId: "work" }),
);
host.assistantAgentId = "ops";
const updatesBeforeMetadata = requestUpdate.mock.calls.length;
metadata.resolve({
models: [{ id: "stale-model", name: "Stale Model", provider: "stale-provider" }],
commands: [
{
acceptsArgs: false,
description: "stale command",
name: "stale-command",
scope: "text",
source: "native",
textAliases: ["/stale-command"],
},
],
});
await vi.waitFor(() =>
expect(requestUpdate.mock.calls.length).toBeGreaterThan(updatesBeforeMetadata),
);
expect(host.chatModelCatalog).toEqual([]);
expect(SLASH_COMMANDS.some((command) => command.name === "stale-command")).toBe(false);
} finally {
resetSlashCommandsForTest();
globalThis.fetch = previousFetch;
}
});
});
describe("handleSendChat", () => {

View File

@@ -1,3 +1,4 @@
import type { CommandsListResult } from "../../../packages/gateway-protocol/src/index.js";
import { setLastActiveSessionKey } from "./app-last-active-session.ts";
import { scheduleChatScroll, resetChatScroll } from "./app-scroll.ts";
import { resetToolStream } from "./app-tool-stream.ts";
@@ -25,7 +26,11 @@ import {
import { reconcileChatRunLifecycle } from "./chat/run-lifecycle.ts";
import type { ChatSideResult } from "./chat/side-result.ts";
import { executeSlashCommand } from "./chat/slash-command-executor.ts";
import { parseSlashCommand, refreshSlashCommands } from "./chat/slash-commands.ts";
import {
applyRemoteSlashCommandsResult,
parseSlashCommand,
refreshSlashCommands,
} from "./chat/slash-commands.ts";
import { formatConnectError } from "./connect-error.ts";
import { resolveControlUiAuthHeader } from "./control-ui-auth.ts";
import {
@@ -45,8 +50,9 @@ import {
type ChatHistoryResult,
type ChatSendAck,
type ChatState,
isGatewayMethodAdvertised,
} from "./controllers/chat.ts";
import { loadModels } from "./controllers/models.ts";
import { applyModelCatalogResult, loadModels } from "./controllers/models.ts";
import {
applyChatHistorySessionInfo,
loadSessions,
@@ -127,6 +133,10 @@ type ChatAgentsListSnapshot = Partial<Omit<AgentsListResult, "agents">> & {
agents?: Array<{ id: string }>;
};
type ChatMetadataResult = CommandsListResult & {
models?: ModelCatalogEntry[];
};
function setChatError(host: ChatHost, error: string | null) {
host.lastError = error;
host.chatError = error;
@@ -1851,11 +1861,9 @@ export async function refreshChat(
if (host.sessionKey !== refreshedSessionKey || !host.connected) {
return;
}
void Promise.allSettled([
refreshChatAvatar(host),
refreshChatModels(host),
refreshChatCommands(host),
]).finally(requestUpdate);
void Promise.allSettled([refreshChatAvatar(host), refreshChatMetadata(host)]).finally(
requestUpdate,
);
});
void historyRefresh;
void secondaryRefresh;
@@ -1897,6 +1905,62 @@ async function refreshChatCommands(host: ChatHost) {
});
}
async function refreshChatMetadata(host: ChatHost) {
if (!host.client || !host.connected) {
host.chatModelsLoading = false;
host.chatModelCatalog = [];
return;
}
const client = host.client;
const sessionKey = host.sessionKey;
const agentId = resolveAgentIdForSession(host);
const metadataAdvertised = isGatewayMethodAdvertised(
host as unknown as ChatState,
"chat.metadata",
);
if (metadataAdvertised === false) {
await Promise.allSettled([refreshChatModels(host), refreshChatCommands(host)]);
return;
}
host.chatModelsLoading = true;
try {
const result = await client.request<ChatMetadataResult>(
"chat.metadata",
agentId ? { agentId } : {},
);
if (
host.client !== client ||
!host.connected ||
host.sessionKey !== sessionKey ||
resolveAgentIdForSession(host) !== agentId
) {
return;
}
const models = applyModelCatalogResult(result.models);
if (models) {
host.chatModelCatalog = models;
}
const commandsApplied = applyRemoteSlashCommandsResult({
client,
agentId,
result,
});
if (!models || !commandsApplied) {
await Promise.allSettled([
...(models ? [] : [refreshChatModels(host)]),
...(commandsApplied ? [] : [refreshChatCommands(host)]),
]);
}
} catch {
await Promise.allSettled([refreshChatModels(host), refreshChatCommands(host)]);
} finally {
if (host.client === client) {
host.chatModelsLoading = false;
}
}
}
export const flushChatQueueForEvent = flushChatQueue;
const chatAvatarRequestVersions = new WeakMap<object, number>();

View File

@@ -504,6 +504,28 @@ function loadRemoteSlashCommands(
return inFlight;
}
export function applyRemoteSlashCommandsResult(params: {
client: GatewayBrowserClient | null;
agentId?: string | null;
result: CommandsListResult | null | undefined;
}): boolean {
if (!Array.isArray(params.result?.commands)) {
return false;
}
const agentId = params.agentId?.trim();
const commands = buildSlashCommandsFromEntries(getRemoteCommandEntries(params.result));
if (params.client) {
const cache = getRemoteSlashCommandCache(params.client);
cache.set(remoteSlashCommandCacheKey(agentId), {
commands,
expiresAt: Date.now() + REMOTE_SLASH_COMMAND_CACHE_TTL_MS,
});
}
refreshSeq += 1;
replaceSlashCommands(commands);
return true;
}
export async function refreshSlashCommands(params: {
client: GatewayBrowserClient | null;
agentId?: string | null;

View File

@@ -331,7 +331,7 @@ function isUnknownGatewayMethodError(err: unknown, method: string): err is Gatew
);
}
function isGatewayMethodAdvertised(state: ChatState, method: string): boolean | null {
export function isGatewayMethodAdvertised(state: ChatState, method: string): boolean | null {
const methods = state.hello?.features?.methods;
if (!Array.isArray(methods)) {
return null;

View File

@@ -42,6 +42,13 @@ export async function loadModels(client: GatewayBrowserClient): Promise<ModelCat
return inFlight;
}
export function applyModelCatalogResult(models: unknown): ModelCatalogEntry[] | null {
if (!Array.isArray(models)) {
return null;
}
return models as ModelCatalogEntry[];
}
async function requestModels(
client: GatewayBrowserClient,
fallback: ModelCatalogEntry[] | undefined,

View File

@@ -279,7 +279,7 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
const page = await context.newPage();
const gateway = await installMockGateway(page, {
defaultAgentId: "ops",
deferredMethods: ["chat.startup"],
deferredMethods: ["chat.metadata", "chat.startup"],
historyMessages: [],
sessionKey: "global",
});
@@ -287,7 +287,10 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
try {
await page.goto(`${server.baseUrl}chat`);
await gateway.waitForRequest("chat.startup");
await gateway.waitForRequest("chat.metadata");
expect(await gateway.getRequests("agents.list")).toHaveLength(0);
expect(await gateway.getRequests("commands.list")).toHaveLength(0);
expect(await gateway.getRequests("models.list")).toHaveLength(0);
const prompt = "send before agents list completes";
await page
@@ -355,6 +358,10 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
sessionId: "control-ui-e2e-session",
thinkingLevel: null,
});
await gateway.resolveDeferred("chat.metadata", {
commands: [],
models: [],
});
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 });