



















@@ -12,13 +12,19 @@ import { stripInlineStatus } from "./reply-inline.js";
1212import { buildTestCtx } from "./test-ctx.js";
1313import type { TypingController } from "./typing.js";
141415-const { buildStatusReplyMock, createOpenClawToolsMock, getChannelPluginMock, handleCommandsMock } =
16-vi.hoisted(() => ({
17-buildStatusReplyMock: vi.fn(),
18-createOpenClawToolsMock: vi.fn(),
19-getChannelPluginMock: vi.fn(),
20-handleCommandsMock: vi.fn(),
21-}));
15+const {
16+ buildStatusReplyMock,
17+ createOpenClawToolsMock,
18+ getChannelPluginMock,
19+ handleCommandsMock,
20+ listSkillCommandsForWorkspaceMock,
21+} = vi.hoisted(() => ({
22+buildStatusReplyMock: vi.fn(),
23+createOpenClawToolsMock: vi.fn(),
24+getChannelPluginMock: vi.fn(),
25+handleCommandsMock: vi.fn(),
26+listSkillCommandsForWorkspaceMock: vi.fn(),
27+}));
22282329type HandleInlineActionsInput = Parameters<
2430typeof import("./get-reply-inline-actions.js").handleInlineActions
@@ -29,6 +35,10 @@ vi.mock("./commands.runtime.js", () => ({
2935buildStatusReply: (...args: unknown[]) => buildStatusReplyMock(...args),
3036}));
313738+vi.mock("../../skills/discovery/chat-commands.runtime.js", () => ({
39+listSkillCommandsForWorkspace: (...args: unknown[]) => listSkillCommandsForWorkspaceMock(...args),
40+}));
41+3242vi.mock("../../agents/openclaw-tools.runtime.js", () => ({
3343createOpenClawTools: (...args: unknown[]) => createOpenClawToolsMock(...args),
3444}));
@@ -180,10 +190,47 @@ function mockCallArgs(mock: ReturnType<typeof vi.fn>, label: string, callIndex =
180190return call;
181191}
182192193+function mockToolDispatchedSkillCommand() {
194+const toolExecute = vi.fn(async () => ({ text: "sent" }));
195+createOpenClawToolsMock.mockReturnValue([
196+{
197+name: "send_status",
198+execute: toolExecute,
199+},
200+]);
201+listSkillCommandsForWorkspaceMock.mockReturnValue([
202+{
203+name: "send_status",
204+skillName: "send-status",
205+description: "Send status",
206+dispatch: {
207+kind: "tool",
208+toolName: "send_status",
209+argMode: "raw",
210+},
211+},
212+] satisfies SkillCommandSpec[]);
213+return toolExecute;
214+}
215+216+function officeHoursSkillCommands(): SkillCommandSpec[] {
217+return [
218+{
219+name: "office_hours",
220+skillName: "office-hours",
221+description: "Office hours",
222+promptTemplate: "Act as an engineering advisor.\n\nFocus on:\n$ARGUMENTS",
223+sourceFilePath: "/tmp/plugin/commands/office-hours.md",
224+},
225+];
226+}
227+183228describe("handleInlineActions", () => {
184229beforeEach(() => {
185230handleCommandsMock.mockReset();
186231handleCommandsMock.mockResolvedValue({ shouldContinue: true, reply: undefined });
232+listSkillCommandsForWorkspaceMock.mockReset();
233+listSkillCommandsForWorkspaceMock.mockReturnValue([]);
187234getChannelPluginMock.mockReset();
188235createOpenClawToolsMock.mockReset();
189236buildStatusReplyMock.mockReset();
@@ -467,6 +514,76 @@ describe("handleInlineActions", () => {
467514});
468515});
469516517+it("skips stale queued /skill messages before loading or dispatching skills", async () => {
518+const typing = createTypingController();
519+const toolExecute = mockToolDispatchedSkillCommand();
520+const sessionEntry: SessionEntry = {
521+sessionId: "session-skill",
522+updatedAt: Date.now(),
523+abortCutoffMessageSid: "42",
524+abortedLastRun: true,
525+};
526+const sessionStore = { "s:main": sessionEntry };
527+const ctx = buildTestCtx({
528+Body: "/skill send_status now",
529+CommandBody: "/skill send_status now",
530+MessageSid: "41",
531+});
532+533+await expectInlineActionSkipped({
534+ ctx,
535+ typing,
536+cleanedBody: "/skill send_status now",
537+command: {
538+isAuthorizedSender: true,
539+rawBodyNormalized: "/skill send_status now",
540+commandBodyNormalized: "/skill send_status now",
541+},
542+overrides: {
543+allowTextCommands: true,
544+cfg: { commands: { text: true } },
545+ sessionEntry,
546+ sessionStore,
547+skillCommands: [],
548+},
549+});
550+551+expect(listSkillCommandsForWorkspaceMock).not.toHaveBeenCalled();
552+expect(createOpenClawToolsMock).not.toHaveBeenCalled();
553+expect(toolExecute).not.toHaveBeenCalled();
554+});
555+556+it("skips empty-config /skill tool dispatch before loading skills", async () => {
557+const typing = createTypingController();
558+const toolExecute = mockToolDispatchedSkillCommand();
559+const ctx = buildTestCtx({
560+From: "whatsapp:+999",
561+To: "whatsapp:+123",
562+Body: "/skill send_status now",
563+CommandBody: "/skill send_status now",
564+});
565+566+await expectInlineActionSkipped({
567+ ctx,
568+ typing,
569+cleanedBody: "/skill send_status now",
570+command: {
571+isAuthorizedSender: true,
572+to: "whatsapp:+123",
573+rawBodyNormalized: "/skill send_status now",
574+commandBodyNormalized: "/skill send_status now",
575+},
576+overrides: {
577+allowTextCommands: true,
578+skillCommands: [],
579+},
580+});
581+582+expect(listSkillCommandsForWorkspaceMock).not.toHaveBeenCalled();
583+expect(createOpenClawToolsMock).not.toHaveBeenCalled();
584+expect(toolExecute).not.toHaveBeenCalled();
585+});
586+470587it("clears /stop cutoff when a newer message arrives", async () => {
471588const typing = createTypingController();
472589const sessionEntry: SessionEntry = {
@@ -554,15 +671,7 @@ describe("handleInlineActions", () => {
554671Body: "/office_hours build me a deployment plan",
555672CommandBody: "/office_hours build me a deployment plan",
556673});
557-const skillCommands: SkillCommandSpec[] = [
558-{
559-name: "office_hours",
560-skillName: "office-hours",
561-description: "Office hours",
562-promptTemplate: "Act as an engineering advisor.\n\nFocus on:\n$ARGUMENTS",
563-sourceFilePath: "/tmp/plugin/commands/office-hours.md",
564-},
565-];
674+const skillCommands = officeHoursSkillCommands();
566675567676const result = await handleInlineActions(
568677createHandleInlineActionsInput({
@@ -592,6 +701,43 @@ describe("handleInlineActions", () => {
592701);
593702});
594703704+it("loads workspace skills when /skill gets an empty preloaded command list", async () => {
705+const typing = createTypingController();
706+handleCommandsMock.mockResolvedValue({ shouldContinue: false, reply: { text: "done" } });
707+const ctx = buildTestCtx({
708+Body: "/skill office_hours build me a deployment plan",
709+CommandBody: "/skill office_hours build me a deployment plan",
710+});
711+const skillCommands = officeHoursSkillCommands();
712+listSkillCommandsForWorkspaceMock.mockReturnValue(skillCommands);
713+714+const result = await handleInlineActions(
715+createHandleInlineActionsInput({
716+ ctx,
717+ typing,
718+cleanedBody: "/skill office_hours build me a deployment plan",
719+command: {
720+isAuthorizedSender: true,
721+rawBodyNormalized: "/skill office_hours build me a deployment plan",
722+commandBodyNormalized: "/skill office_hours build me a deployment plan",
723+},
724+overrides: {
725+allowTextCommands: true,
726+cfg: { commands: { text: true } },
727+skillCommands: [],
728+},
729+}),
730+);
731+732+expect(result).toEqual({ kind: "reply", reply: { text: "done" } });
733+expect(listSkillCommandsForWorkspaceMock).toHaveBeenCalledOnce();
734+expect(ctx.Body).toBe(
735+"Act as an engineering advisor.\n\nFocus on:\nbuild me a deployment plan",
736+);
737+const commandArgs = mockObjectArg(handleCommandsMock, "handleCommands");
738+expect(commandArgs.skillCommands).toEqual(skillCommands);
739+});
740+595741it("passes requesterAgentIdOverride into inline tool runtimes", async () => {
596742const typing = createTypingController();
597743const toolExecute = vi.fn(async () => ({ text: "spawned" }));
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。