From e95300fa3e8c54e93fa8adfbb7f2942328cc2f74 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 16 Aug 2026 17:20:59 -0700 Subject: [PATCH] refactor(webui): read slash commands through the chat metadata store (#124931) --- ui/src/pages/chat/chat-commands.test.ts | 82 ++++++++++++++++++++++++- ui/src/pages/chat/chat-commands.ts | 27 ++++---- 2 files changed, 92 insertions(+), 17 deletions(-) diff --git a/ui/src/pages/chat/chat-commands.test.ts b/ui/src/pages/chat/chat-commands.test.ts index f3d8044f6b5a..2ffd46c8fb0a 100644 --- a/ui/src/pages/chat/chat-commands.test.ts +++ b/ui/src/pages/chat/chat-commands.test.ts @@ -2,13 +2,21 @@ import { describe, expect, it, vi } from "vitest"; import type { GatewayBrowserClient } from "../../api/gateway.ts"; import type { ApplicationGatewaySnapshot } from "../../app/gateway.ts"; +import { + invalidateChatMetadataStore, + rememberChatMetadata, +} from "../../lib/chat/chat-metadata-store.ts"; import { SLASH_COMMANDS, getSlashCommandCategoryLabel, getSlashCommandDescription, type SlashCommandDef, } from "../../lib/chat/commands.ts"; -import { dispatchChatSlashCommand, refreshSlashCommands } from "./chat-commands.ts"; +import { + applyRemoteSlashCommandsResult, + dispatchChatSlashCommand, + refreshSlashCommands, +} from "./chat-commands.ts"; function requireCommandByName(name: string): Record { const command = SLASH_COMMANDS.find((entry) => entry.name === name); @@ -36,6 +44,17 @@ function legacyConnectedSessionAccess() { }; } +function remoteCommand(name: string, description: string) { + return { + name, + textAliases: [`/${name}`], + description, + source: "plugin" as const, + scope: "text" as const, + acceptsArgs: false, + }; +} + describe("refreshSlashCommands", () => { it("resolves localized UI command metadata", () => { const clear = SLASH_COMMANDS.find((entry) => entry.name === "clear"); @@ -252,6 +271,67 @@ describe("refreshSlashCommands", () => { description: "Generate setup codes.", }); }); + + it("reads commands from the chat metadata store without requesting commands.list", async () => { + const request = vi.fn(); + const client = { request } as never; + rememberChatMetadata(client, "main", { + commands: [remoteCommand("metadata-command", "Loaded from chat metadata.")], + }); + + await refreshSlashCommands({ client, agentId: "main" }); + + expect(request).not.toHaveBeenCalled(); + expectRecordFields(requireCommandByName("metadata-command"), "metadata command", { + description: "Loaded from chat metadata.", + executeLocal: false, + }); + }); + + it("prefers stored metadata after the commands.list cache expires", async () => { + vi.useFakeTimers(); + try { + const request = vi.fn().mockResolvedValue({ + commands: [remoteCommand("cached-command", "Loaded from commands.list.")], + }); + const client = { request } as never; + + await refreshSlashCommands({ client, agentId: "main" }); + vi.advanceTimersByTime(60_001); + rememberChatMetadata(client, "main", { + commands: [remoteCommand("metadata-command", "Loaded from chat metadata.")], + }); + + await refreshSlashCommands({ client, agentId: "main" }); + + expect(request).toHaveBeenCalledOnce(); + expectRecordFields(requireCommandByName("metadata-command"), "metadata command", { + description: "Loaded from chat metadata.", + }); + } finally { + vi.useRealTimers(); + } + }); + + it("does not retain applied metadata commands in the commands.list cache", async () => { + const request = vi.fn().mockResolvedValue({ + commands: [remoteCommand("requested-command", "Loaded after metadata invalidation.")], + }); + const client = { request } as never; + const metadata = { + commands: [remoteCommand("metadata-command", "Loaded from chat metadata.")], + }; + rememberChatMetadata(client, "main", metadata); + applyRemoteSlashCommandsResult({ client, agentId: "main", result: metadata }); + + invalidateChatMetadataStore(client); + await refreshSlashCommands({ client, agentId: "main" }); + + expect(request).toHaveBeenCalledOnce(); + expectRecordFields(requireCommandByName("requested-command"), "requested command", { + description: "Loaded after metadata invalidation.", + }); + }); }); describe("conversation reset confirmation", () => { diff --git a/ui/src/pages/chat/chat-commands.ts b/ui/src/pages/chat/chat-commands.ts index 0f3b5cf9b733..aa6a46a25b25 100644 --- a/ui/src/pages/chat/chat-commands.ts +++ b/ui/src/pages/chat/chat-commands.ts @@ -3,6 +3,7 @@ import type { CommandsListResult } from "../../../../packages/gateway-protocol/s import type { GatewayBrowserClient } from "../../api/gateway.ts"; import type { ModelCatalogEntry, SessionsListResult } from "../../api/types.ts"; import type { ApplicationGatewaySnapshot } from "../../app/gateway.ts"; +import { peekChatMetadata } from "../../lib/chat/chat-metadata-store.ts"; import type { ChatQueueItem } from "../../lib/chat/chat-types.ts"; import { buildFallbackSlashCommands, @@ -200,17 +201,6 @@ function getRemoteSlashCommandCache( return cache; } -function storeRemoteSlashCommands( - client: GatewayBrowserClient, - agentId: string | undefined, - commands: SlashCommandDef[], -) { - getRemoteSlashCommandCache(client).set(remoteSlashCommandCacheKey(agentId), { - commands, - expiresAt: Date.now() + REMOTE_SLASH_COMMAND_CACHE_TTL_MS, - }); -} - async function requestRemoteSlashCommands( client: GatewayBrowserClient, agentId: string | undefined, @@ -226,7 +216,10 @@ async function requestRemoteSlashCommands( return buildFallbackSlashCommands(); } const commands = buildSlashCommandsFromEntries(getRemoteCommandEntries(result)); - storeRemoteSlashCommands(client, agentId, commands); + getRemoteSlashCommandCache(client).set(remoteSlashCommandCacheKey(agentId), { + commands, + expiresAt: Date.now() + REMOTE_SLASH_COMMAND_CACHE_TTL_MS, + }); return commands; } catch { return fallback ?? buildFallbackSlashCommands(); @@ -237,6 +230,12 @@ function loadRemoteSlashCommands( client: GatewayBrowserClient, agentId: string | undefined, ): Promise { + const metadata = peekChatMetadata(client, agentId); + // Store-held metadata carries app-level invalidation on config changes and logical reconnects, + // so no TTL applies here. The cache below owns only commands.list-derived entries. + if (Array.isArray(metadata?.commands)) { + return Promise.resolve(buildSlashCommandsFromEntries(getRemoteCommandEntries(metadata))); + } const cache = getRemoteSlashCommandCache(client); const key = remoteSlashCommandCacheKey(agentId); const cached = cache.get(key); @@ -269,11 +268,7 @@ export function applyRemoteSlashCommandsResult(params: { if (!Array.isArray(params.result?.commands)) { return false; } - const agentId = params.agentId?.trim(); const commands = buildSlashCommandsFromEntries(getRemoteCommandEntries(params.result)); - if (params.client) { - storeRemoteSlashCommands(params.client, agentId, commands); - } refreshSeq += 1; replaceSlashCommands(commands); return true;