fix(commands): preserve async skill commands

This commit is contained in:
Peter Steinberger
2026-05-26 23:49:16 +01:00
parent 3f524a6423
commit c0d778d512
4 changed files with 60 additions and 17 deletions

View File

@@ -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",

View File

@@ -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 })

View File

@@ -65,6 +65,7 @@ export type HandleCommandsParams = {
contextTokens: number;
isGroup: boolean;
skillCommands?: SkillCommandSpec[];
loadSkillCommands?: () => Promise<SkillCommandSpec[]>;
typing?: TypingController;
};

View File

@@ -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<NonNullable<OpenClawConfig["agents"]>["defaults"]> | undefined;
type SkillCommandsRuntime = typeof import("../skill-commands.runtime.js");
const commandsRuntimeLoader = createLazyImportLoader(() => import("./commands.runtime.js"));
const skillCommandsRuntimeLoader = createLazyImportLoader<SkillCommandsRuntime>(
() => 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,