Control UI: refresh slash commands from runtime command list (#65620)

* Refresh slash commands from runtime command list

- Load live slash commands into the chat UI and command palette
- Keep builtin fallback behavior when runtime commands are unavailable

* Apply suggestion from @greptile-apps[bot]

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Control UI: harden runtime slash command discovery

* Control UI: bound runtime slash command payloads

* Control UI: use default agent for plain session keys

* Control UI: guard malformed slash command payloads

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
Val Alexander
2026-04-12 20:38:37 -05:00
committed by GitHub
parent c4764095f8
commit d0c83777fb
11 changed files with 964 additions and 132 deletions

View File

@@ -1,6 +1,19 @@
import { Type } from "@sinclair/typebox";
import { NonEmptyString } from "./primitives.js";
export const COMMAND_NAME_MAX_LENGTH = 200;
export const COMMAND_DESCRIPTION_MAX_LENGTH = 2_000;
export const COMMAND_ALIAS_MAX_ITEMS = 20;
export const COMMAND_ARGS_MAX_ITEMS = 20;
export const COMMAND_ARG_NAME_MAX_LENGTH = 200;
export const COMMAND_ARG_DESCRIPTION_MAX_LENGTH = 500;
export const COMMAND_ARG_CHOICES_MAX_ITEMS = 50;
export const COMMAND_CHOICE_VALUE_MAX_LENGTH = 200;
export const COMMAND_CHOICE_LABEL_MAX_LENGTH = 200;
export const COMMAND_LIST_MAX_ITEMS = 500;
const BoundedNonEmptyString = (maxLength: number) => Type.String({ minLength: 1, maxLength });
export const CommandSourceSchema = Type.Union([
Type.Literal("native"),
Type.Literal("skill"),
@@ -25,19 +38,21 @@ export const CommandCategorySchema = Type.Union([
export const CommandArgChoiceSchema = Type.Object(
{
value: Type.String(),
label: Type.String(),
value: Type.String({ maxLength: COMMAND_CHOICE_VALUE_MAX_LENGTH }),
label: Type.String({ maxLength: COMMAND_CHOICE_LABEL_MAX_LENGTH }),
},
{ additionalProperties: false },
);
export const CommandArgSchema = Type.Object(
{
name: NonEmptyString,
description: Type.String(),
name: BoundedNonEmptyString(COMMAND_ARG_NAME_MAX_LENGTH),
description: Type.String({ maxLength: COMMAND_ARG_DESCRIPTION_MAX_LENGTH }),
type: Type.Union([Type.Literal("string"), Type.Literal("number"), Type.Literal("boolean")]),
required: Type.Optional(Type.Boolean()),
choices: Type.Optional(Type.Array(CommandArgChoiceSchema)),
choices: Type.Optional(
Type.Array(CommandArgChoiceSchema, { maxItems: COMMAND_ARG_CHOICES_MAX_ITEMS }),
),
dynamic: Type.Optional(Type.Boolean()),
},
{ additionalProperties: false },
@@ -45,15 +60,19 @@ export const CommandArgSchema = Type.Object(
export const CommandEntrySchema = Type.Object(
{
name: NonEmptyString,
nativeName: Type.Optional(NonEmptyString),
textAliases: Type.Optional(Type.Array(NonEmptyString)),
description: Type.String(),
name: BoundedNonEmptyString(COMMAND_NAME_MAX_LENGTH),
nativeName: Type.Optional(BoundedNonEmptyString(COMMAND_NAME_MAX_LENGTH)),
textAliases: Type.Optional(
Type.Array(BoundedNonEmptyString(COMMAND_NAME_MAX_LENGTH), {
maxItems: COMMAND_ALIAS_MAX_ITEMS,
}),
),
description: Type.String({ maxLength: COMMAND_DESCRIPTION_MAX_LENGTH }),
category: Type.Optional(CommandCategorySchema),
source: CommandSourceSchema,
scope: CommandScopeSchema,
acceptsArgs: Type.Boolean(),
args: Type.Optional(Type.Array(CommandArgSchema)),
args: Type.Optional(Type.Array(CommandArgSchema, { maxItems: COMMAND_ARGS_MAX_ITEMS })),
},
{ additionalProperties: false },
);
@@ -70,7 +89,7 @@ export const CommandsListParamsSchema = Type.Object(
export const CommandsListResultSchema = Type.Object(
{
commands: Type.Array(CommandEntrySchema),
commands: Type.Array(CommandEntrySchema, { maxItems: COMMAND_LIST_MAX_ITEMS }),
},
{ additionalProperties: false },
);

View File

@@ -132,6 +132,14 @@ vi.mock("../../channels/plugins/index.js", () => ({
}));
import { ErrorCodes, errorShape } from "../protocol/index.js";
import {
COMMAND_ALIAS_MAX_ITEMS,
COMMAND_ARG_CHOICES_MAX_ITEMS,
COMMAND_ARGS_MAX_ITEMS,
COMMAND_DESCRIPTION_MAX_LENGTH,
COMMAND_LIST_MAX_ITEMS,
COMMAND_NAME_MAX_LENGTH,
} from "../protocol/schema/commands.js";
import { commandsHandlers, buildCommandsListResult } from "./commands.js";
function callHandler(params: Record<string, unknown> = {}) {
@@ -344,6 +352,57 @@ describe("commands.list handler", () => {
expect(model!.args).toBeUndefined();
});
it("caps serialized command payload size and field lengths", () => {
const originalCommands = [...mockChatCommands];
const longToken = "x".repeat(COMMAND_NAME_MAX_LENGTH + 50);
const aliasBase = "alias".repeat(20);
const longDescription = "d".repeat(COMMAND_DESCRIPTION_MAX_LENGTH + 50);
try {
mockChatCommands.length = 0;
for (let index = 0; index < COMMAND_LIST_MAX_ITEMS + 25; index += 1) {
mockChatCommands.push({
key: `cmd-${index}`,
description: longDescription,
textAliases: Array.from(
{ length: COMMAND_ALIAS_MAX_ITEMS + 5 },
(_, aliasIndex) => `/${aliasBase}-${index}-${aliasIndex}`,
),
acceptsArgs: true,
args: Array.from({ length: COMMAND_ARGS_MAX_ITEMS + 5 }, (_, argIndex) => ({
name: `${longToken}-${argIndex}`,
description: longDescription,
type: "string",
choices: Array.from(
{ length: COMMAND_ARG_CHOICES_MAX_ITEMS + 5 },
(_, choiceIndex) => ({
value: `${longToken}-${choiceIndex}`,
label: `${longToken}-${choiceIndex}`,
}),
),
})),
scope: "both",
category: "tools",
});
}
const { payload } = callHandler();
const { commands } = payload as { commands: Array<Record<string, unknown>> };
expect(commands).toHaveLength(COMMAND_LIST_MAX_ITEMS);
const first = commands[0];
expect((first.name as string).length).toBeLessThanOrEqual(COMMAND_NAME_MAX_LENGTH);
expect((first.description as string).length).toBeLessThanOrEqual(
COMMAND_DESCRIPTION_MAX_LENGTH,
);
expect((first.textAliases as unknown[]).length).toBeLessThanOrEqual(COMMAND_ALIAS_MAX_ITEMS);
expect(first.args as unknown[]).toHaveLength(COMMAND_ARGS_MAX_ITEMS);
const firstArg = (first.args as Array<Record<string, unknown>>)[0];
expect(firstArg.choices as unknown[]).toHaveLength(COMMAND_ARG_CHOICES_MAX_ITEMS);
} finally {
mockChatCommands.length = 0;
mockChatCommands.push(...originalCommands);
}
});
it("rejects unknown agentId", () => {
const { ok, error } = callHandler({ agentId: "nonexistent" });
expect(ok).toBe(false);

View File

@@ -19,11 +19,39 @@ import {
formatValidationErrors,
validateCommandsListParams,
} from "../protocol/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 "../protocol/schema/commands.js";
import type { GatewayRequestHandlers, RespondFn } 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 resolveAgentIdOrRespondError(rawAgentId: unknown, respond: RespondFn) {
const cfg = loadConfig();
const knownAgents = listAgentIds(cfg);
@@ -61,7 +89,7 @@ function resolveTextAliases(cmd: ChatCommandDefinition): string[] {
const seen = new Set<string>();
const aliases: string[] = [];
for (const alias of cmd.textAliases) {
const trimmed = alias.trim();
const trimmed = trimClampNonEmpty(alias, COMMAND_NAME_MAX_LENGTH);
if (!trimmed) {
continue;
}
@@ -71,11 +99,14 @@ function resolveTextAliases(cmd: ChatCommandDefinition): string[] {
}
seen.add(exactAlias);
aliases.push(exactAlias);
if (aliases.length >= COMMAND_ALIAS_MAX_ITEMS) {
break;
}
}
if (aliases.length > 0) {
return aliases;
}
return [`/${cmd.key}`];
return [`/${clampString(cmd.key, COMMAND_NAME_MAX_LENGTH)}`];
}
function resolvePrimaryTextName(cmd: ChatCommandDefinition): string {
@@ -84,10 +115,12 @@ function resolvePrimaryTextName(cmd: ChatCommandDefinition): string {
function serializeArg(arg: CommandArgDefinition): SerializedArg {
const isDynamic = typeof arg.choices === "function";
const staticChoices = Array.isArray(arg.choices) ? arg.choices.map(normalizeChoice) : undefined;
const staticChoices = Array.isArray(arg.choices)
? arg.choices.slice(0, COMMAND_ARG_CHOICES_MAX_ITEMS).map(normalizeChoice)
: undefined;
return {
name: arg.name,
description: arg.description,
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 } : {}),
@@ -96,7 +129,17 @@ function serializeArg(arg: CommandArgDefinition): SerializedArg {
}
function normalizeChoice(choice: CommandArgChoice): { value: string; label: string } {
return typeof choice === "string" ? { value: choice, label: choice } : choice;
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(
@@ -109,15 +152,20 @@ function mapCommand(
const shouldIncludeArgs = includeArgs && cmd.acceptsArgs && cmd.args?.length;
const nativeName = cmd.scope === "text" ? undefined : resolveNativeName(cmd, provider);
return {
name: nameSurface === "text" ? resolvePrimaryTextName(cmd) : (nativeName ?? cmd.key),
...(nativeName ? { nativeName } : {}),
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: cmd.description,
description: clampDescription(cmd.description),
...(cmd.category ? { category: cmd.category } : {}),
source,
scope: cmd.scope,
acceptsArgs: Boolean(cmd.acceptsArgs),
...(shouldIncludeArgs ? { args: cmd.args!.map(serializeArg) } : {}),
...(shouldIncludeArgs
? { args: cmd.args!.slice(0, COMMAND_ARGS_MAX_ITEMS).map(serializeArg) }
: {}),
};
}
@@ -133,10 +181,13 @@ function buildPluginCommandEntries(params: {
const nativeSpec = pluginNativeSpecs[index];
const nativeName = nativeSpec?.name;
entries.push({
name: params.nameSurface === "text" ? textSpec.name : (nativeName ?? textSpec.name),
...(nativeName ? { nativeName } : {}),
textAliases: [`/${textSpec.name}`],
description: textSpec.description,
name: clampString(
params.nameSurface === "text" ? textSpec.name : (nativeName ?? textSpec.name),
COMMAND_NAME_MAX_LENGTH,
),
...(nativeName ? { nativeName: clampString(nativeName, COMMAND_NAME_MAX_LENGTH) } : {}),
textAliases: [`/${clampString(textSpec.name, COMMAND_NAME_MAX_LENGTH)}`],
description: clampDescription(textSpec.description),
source: "plugin",
scope: "both",
acceptsArgs: textSpec.acceptsArgs,
@@ -184,7 +235,7 @@ export function buildCommandsListResult(params: {
commands.push(...buildPluginCommandEntries({ provider, nameSurface }));
return { commands };
return { commands: commands.slice(0, COMMAND_LIST_MAX_ITEMS) };
}
export const commandsHandlers: GatewayRequestHandlers = {