diff --git a/src/auto-reply/reply/commands-info.test.ts b/src/auto-reply/reply/commands-info.test.ts index bf8c337a740..4eec926d41f 100644 --- a/src/auto-reply/reply/commands-info.test.ts +++ b/src/auto-reply/reply/commands-info.test.ts @@ -213,6 +213,25 @@ describe("info command handlers", () => { expect(result).toBeNull(); }); + it("loads skills asynchronously before deciding named /skill invocations", async () => { + const params = buildInfoParams("/skill demo_skill input", { + commands: { text: true }, + } as OpenClawConfig); + params.loadSkillCommands = vi.fn(async () => [ + { + name: "demo_skill", + skillName: "demo-skill", + description: "Demo skill", + }, + ]); + + const result = await handleSkillCommandUsage(params, true); + + expect(result).toBeNull(); + expect(params.loadSkillCommands).toHaveBeenCalledOnce(); + expect(listSkillCommandsForAgentsMock).not.toHaveBeenCalled(); + }); + it("uses the canonical command sender identity for /whoami AllowFrom", async () => { const params = buildInfoParams( "/whoami", diff --git a/src/auto-reply/reply/commands-info.ts b/src/auto-reply/reply/commands-info.ts index 102ac69d059..6c7462f9528 100644 --- a/src/auto-reply/reply/commands-info.ts +++ b/src/auto-reply/reply/commands-info.ts @@ -21,6 +21,22 @@ import { resolveReplyToMode } from "./reply-threading.js"; export { handleContextCommand } from "./commands-context-command.js"; export { handleWhoamiCommand } from "./commands-whoami.js"; +async function resolveSkillCommands(params: HandleCommandsParams) { + if (params.skillCommands !== undefined) { + return params.skillCommands; + } + if (params.loadSkillCommands) { + return params.loadSkillCommands(); + } + const agentId = params.sessionKey + ? resolveSessionAgentId({ sessionKey: params.sessionKey, config: params.cfg }) + : params.agentId; + return listSkillCommandsForAgents({ + cfg: params.cfg, + agentIds: agentId ? [agentId] : undefined, + }); +} + export const handleHelpCommand: CommandHandler = async (params, allowTextCommands) => { if (!allowTextCommands) { return null; @@ -56,12 +72,7 @@ export const handleCommandsListCommand: CommandHandler = async (params, allowTex const agentId = params.sessionKey ? resolveSessionAgentId({ sessionKey: params.sessionKey, config: params.cfg }) : params.agentId; - const skillCommands = - params.skillCommands ?? - listSkillCommandsForAgents({ - cfg: params.cfg, - agentIds: agentId ? [agentId] : undefined, - }); + const skillCommands = await resolveSkillCommands(params); const surface = params.ctx.Surface; const commandPlugin = surface ? getChannelPlugin(surface) : null; const paginated = buildCommandsMessagePaginated(params.cfg, skillCommands, { @@ -123,15 +134,7 @@ export const handleSkillCommandUsage: CommandHandler = async (params, allowTextC } const [, rawName] = normalized.match(/^\/skill(?:\s+([^\s]+))?/u) ?? []; - const agentId = params.sessionKey - ? resolveSessionAgentId({ sessionKey: params.sessionKey, config: params.cfg }) - : params.agentId; - const skillCommands = - params.skillCommands ?? - listSkillCommandsForAgents({ - cfg: params.cfg, - agentIds: agentId ? [agentId] : undefined, - }); + const skillCommands = await resolveSkillCommands(params); if ( rawName && resolveSkillCommandInvocation({ commandBodyNormalized: normalized, skillCommands }) diff --git a/src/auto-reply/reply/commands-types.ts b/src/auto-reply/reply/commands-types.ts index c4e045c1c9f..c22be8a57e6 100644 --- a/src/auto-reply/reply/commands-types.ts +++ b/src/auto-reply/reply/commands-types.ts @@ -65,6 +65,7 @@ export type HandleCommandsParams = { contextTokens: number; isGroup: boolean; skillCommands?: SkillCommandSpec[]; + loadSkillCommands?: () => Promise; typing?: TypingController; }; diff --git a/src/auto-reply/reply/get-reply-native-slash-fast-path.ts b/src/auto-reply/reply/get-reply-native-slash-fast-path.ts index 7baaafc2ef3..20df07f85fe 100644 --- a/src/auto-reply/reply/get-reply-native-slash-fast-path.ts +++ b/src/auto-reply/reply/get-reply-native-slash-fast-path.ts @@ -3,6 +3,7 @@ import { resolveThinkingDefaultWithRuntimeCatalog, type ModelAliasIndex, } from "../../agents/model-selection.js"; +import type { SkillCommandSpec } from "../../agents/skills.js"; import type { OpenClawConfig } from "../../config/config.js"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; @@ -20,14 +21,22 @@ import { stripStructuralPrefixes } from "./mentions.js"; import type { createTypingController } from "./typing.js"; type AgentDefaults = NonNullable["defaults"]> | undefined; +type SkillCommandsRuntime = typeof import("../skill-commands.runtime.js"); const commandsRuntimeLoader = createLazyImportLoader(() => import("./commands.runtime.js")); +const skillCommandsRuntimeLoader = createLazyImportLoader( + () => import("../skill-commands.runtime.js"), +); const statusCommandRuntimeLoader = createLazyImportLoader(() => import("./commands-status.js")); function loadCommandsRuntime() { return commandsRuntimeLoader.load(); } +function loadSkillCommandsRuntime() { + return skillCommandsRuntimeLoader.load(); +} + function loadStatusCommandRuntime() { return statusCommandRuntimeLoader.load(); } @@ -139,6 +148,17 @@ export async function maybeResolveNativeSlashCommandFastReply(params: { }; } + let loadedSkillCommands: SkillCommandSpec[] | undefined; + const loadNativeSkillCommands = async () => { + loadedSkillCommands ??= (await loadSkillCommandsRuntime()).listSkillCommandsForWorkspace({ + workspaceDir: params.workspaceDir, + cfg: params.cfg, + agentId: params.agentId, + skillFilter: params.skillFilter, + }); + return loadedSkillCommands; + }; + const commandResult = await ( await loadCommandsRuntime() ).handleCommands({ @@ -174,7 +194,7 @@ export async function maybeResolveNativeSlashCommandFastReply(params: { model: params.model, contextTokens: params.agentCfg?.contextTokens ?? 0, isGroup: sessionState.isGroup, - skillCommands: [], + loadSkillCommands: loadNativeSkillCommands, typing: params.typing, }); if (!commandResult.shouldContinue) { @@ -232,7 +252,7 @@ export async function maybeResolveNativeSlashCommandFastReply(params: { allowTextCommands: directiveResult.result.allowTextCommands, inlineStatusRequested: directiveResult.result.inlineStatusRequested, command: directiveResult.result.command, - skillCommands: directiveResult.result.skillCommands, + skillCommands: loadedSkillCommands ?? directiveResult.result.skillCommands, directives: directiveResult.result.directives, cleanedBody: directiveResult.result.cleanedBody, elevatedEnabled: directiveResult.result.elevatedEnabled,