mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
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:
@@ -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?
|
||||
|
||||
@@ -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]> = [];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">;
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -280,6 +280,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
|
||||
methods: [
|
||||
"chat.history",
|
||||
"chat.startup",
|
||||
"chat.metadata",
|
||||
"chat.message.get",
|
||||
"chat.abort",
|
||||
"chat.send",
|
||||
|
||||
@@ -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(
|
||||
|
||||
229
src/gateway/server-methods/commands-list-result.ts
Normal file
229
src/gateway/server-methods/commands-list-result.ts
Normal 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) };
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
76
src/gateway/server-methods/models-list-result.ts
Normal file
76
src/gateway/server-methods/models-list-result.ts
Normal 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) };
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user