mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
fix(plugins): refresh gateway hooks before inbound dispatch
This commit is contained in:
@@ -97,6 +97,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Plugins/loader: use cached discovery-mode snapshot loads for read-only plugin capability lookups, keep snapshot caches isolated from active Gateway registries, and make same-plugin channel/HTTP route re-registration idempotent so repeated snapshot or hot-reload paths no longer rerun full plugin side effects or accumulate duplicate surfaces. Fixes #51781, #52031, #54181, and #57514. Thanks @livingghost, @okuyam2y, @ShionEria, and @bbshih.
|
||||
- Plugins/loader: reuse the compatible active Gateway registry for broad runtime plugin ensure calls after a gateway-bindable boot load, so non-bundled plugins no longer re-run `register()` during the same boot path. Fixes #69250. Thanks @markthebest12.
|
||||
- Plugins/hooks: keep the gateway-bindable hook runner installed when later default-mode plugin loads activate a different registry, preserving Gateway subagent lifecycle hooks across runtime cache misses. Fixes #63166. Thanks @steipete.
|
||||
- Plugins/hooks: refresh live Gateway runtime hooks before inbound channel dispatch, so externally installed plugins keep `message_received`, `before_dispatch`, and reply hooks active after scoped startup plugin loads. Fixes #71167. Thanks @steipete.
|
||||
- Media/input: resolve canonical inbound media refs through the shared media loader so native prompt image replay and explicit image/PDF tools can read `media://inbound/<id>` and managed inbound replay paths under workspace-only file policy. Thanks @steipete.
|
||||
- Media/tools: centralize media reference scheme classification for image, PDF, image-generation, video-generation, and music-generation inputs so managed inbound refs are accepted consistently. Thanks @steipete.
|
||||
- Control UI/media: resolve canonical inbound media refs before serving assistant media previews, so `media://inbound/<id>` sources no longer pass access checks but fail file open. Thanks @steipete.
|
||||
|
||||
@@ -2,18 +2,27 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
resolveRuntimePluginRegistry: vi.fn(),
|
||||
getActivePluginRuntimeSubagentMode: vi.fn<() => "default" | "explicit" | "gateway-bindable">(
|
||||
() => "default",
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/loader.js", () => ({
|
||||
resolveRuntimePluginRegistry: hoisted.resolveRuntimePluginRegistry,
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/runtime.js", () => ({
|
||||
getActivePluginRuntimeSubagentMode: hoisted.getActivePluginRuntimeSubagentMode,
|
||||
}));
|
||||
|
||||
describe("ensureRuntimePluginsLoaded", () => {
|
||||
let ensureRuntimePluginsLoaded: typeof import("./runtime-plugins.js").ensureRuntimePluginsLoaded;
|
||||
|
||||
beforeEach(async () => {
|
||||
hoisted.resolveRuntimePluginRegistry.mockReset();
|
||||
hoisted.resolveRuntimePluginRegistry.mockReturnValue(undefined);
|
||||
hoisted.getActivePluginRuntimeSubagentMode.mockReset();
|
||||
hoisted.getActivePluginRuntimeSubagentMode.mockReturnValue("default");
|
||||
vi.resetModules();
|
||||
({ ensureRuntimePluginsLoaded } = await import("./runtime-plugins.js"));
|
||||
});
|
||||
@@ -45,4 +54,34 @@ describe("ensureRuntimePluginsLoaded", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not enable gateway subagent binding for normal runtime loads", async () => {
|
||||
ensureRuntimePluginsLoaded({
|
||||
config: {} as never,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
});
|
||||
|
||||
expect(hoisted.resolveRuntimePluginRegistry).toHaveBeenCalledWith({
|
||||
config: {} as never,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
runtimeOptions: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("inherits gateway-bindable mode from an active gateway registry", async () => {
|
||||
hoisted.getActivePluginRuntimeSubagentMode.mockReturnValue("gateway-bindable");
|
||||
|
||||
ensureRuntimePluginsLoaded({
|
||||
config: {} as never,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
});
|
||||
|
||||
expect(hoisted.resolveRuntimePluginRegistry).toHaveBeenCalledWith({
|
||||
config: {} as never,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
runtimeOptions: {
|
||||
allowGatewaySubagentBinding: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { resolveRuntimePluginRegistry } from "../plugins/loader.js";
|
||||
import { getActivePluginRuntimeSubagentMode } from "../plugins/runtime.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
|
||||
export function ensureRuntimePluginsLoaded(params: {
|
||||
@@ -11,10 +12,13 @@ export function ensureRuntimePluginsLoaded(params: {
|
||||
typeof params.workspaceDir === "string" && params.workspaceDir.trim()
|
||||
? resolveUserPath(params.workspaceDir)
|
||||
: undefined;
|
||||
const allowGatewaySubagentBinding =
|
||||
params.allowGatewaySubagentBinding === true ||
|
||||
getActivePluginRuntimeSubagentMode() === "gateway-bindable";
|
||||
const loadOptions = {
|
||||
config: params.config,
|
||||
workspaceDir,
|
||||
runtimeOptions: params.allowGatewaySubagentBinding
|
||||
runtimeOptions: allowGatewaySubagentBinding
|
||||
? {
|
||||
allowGatewaySubagentBinding: true,
|
||||
}
|
||||
|
||||
@@ -147,6 +147,9 @@ const replyMediaPathMocks = vi.hoisted(() => ({
|
||||
(_params?: unknown) => async (payload: ReplyPayload) => payload,
|
||||
),
|
||||
}));
|
||||
const runtimePluginMocks = vi.hoisted(() => ({
|
||||
ensureRuntimePluginsLoaded: vi.fn(),
|
||||
}));
|
||||
const threadInfoMocks = vi.hoisted(() => ({
|
||||
parseSessionThreadInfo: vi.fn<
|
||||
(sessionKey: string | undefined) => {
|
||||
@@ -337,6 +340,9 @@ vi.mock("./reply-media-paths.runtime.js", () => ({
|
||||
createReplyMediaPathNormalizer: (params: unknown) =>
|
||||
replyMediaPathMocks.createReplyMediaPathNormalizer(params),
|
||||
}));
|
||||
vi.mock("../../agents/runtime-plugins.js", () => ({
|
||||
ensureRuntimePluginsLoaded: runtimePluginMocks.ensureRuntimePluginsLoaded,
|
||||
}));
|
||||
vi.mock("../../tts/status-config.js", () => ({
|
||||
resolveStatusTtsSnapshot: () => ({
|
||||
autoMode: "always",
|
||||
@@ -657,7 +663,30 @@ describe("dispatchReplyFromConfig", () => {
|
||||
replyMediaPathMocks.createReplyMediaPathNormalizer.mockReturnValue(
|
||||
async (payload: ReplyPayload) => payload,
|
||||
);
|
||||
runtimePluginMocks.ensureRuntimePluginsLoaded.mockClear();
|
||||
});
|
||||
|
||||
it("loads runtime plugins before reading inbound hook state", async () => {
|
||||
setNoAbort();
|
||||
const cfg = emptyConfig;
|
||||
const dispatcher = createDispatcher();
|
||||
const ctx = buildTestCtx({
|
||||
Provider: "whatsapp",
|
||||
SessionKey: "agent:main:main",
|
||||
});
|
||||
|
||||
const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload;
|
||||
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
||||
|
||||
expect(runtimePluginMocks.ensureRuntimePluginsLoaded).toHaveBeenCalledWith({
|
||||
config: cfg,
|
||||
workspaceDir: expect.any(String),
|
||||
});
|
||||
expect(runtimePluginMocks.ensureRuntimePluginsLoaded.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
hookMocks.runner.hasHooks.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it("does not route when Provider matches OriginatingChannel (even if Surface is missing)", async () => {
|
||||
setNoAbort();
|
||||
mocks.routeReply.mockClear();
|
||||
|
||||
@@ -79,6 +79,7 @@ let getReplyFromConfigRuntimePromise: Promise<
|
||||
> | null = null;
|
||||
let abortRuntimePromise: Promise<typeof import("./abort.runtime.js")> | null = null;
|
||||
let ttsRuntimePromise: Promise<typeof import("../../tts/tts.runtime.js")> | null = null;
|
||||
let runtimePluginsPromise: Promise<typeof import("../../agents/runtime-plugins.js")> | null = null;
|
||||
let replyMediaPathsRuntimePromise: Promise<typeof import("./reply-media-paths.runtime.js")> | null =
|
||||
null;
|
||||
|
||||
@@ -102,6 +103,11 @@ function loadTtsRuntime() {
|
||||
return ttsRuntimePromise;
|
||||
}
|
||||
|
||||
function loadRuntimePlugins() {
|
||||
runtimePluginsPromise ??= import("../../agents/runtime-plugins.js");
|
||||
return runtimePluginsPromise;
|
||||
}
|
||||
|
||||
function loadReplyMediaPathsRuntime() {
|
||||
replyMediaPathsRuntimePromise ??= import("./reply-media-paths.runtime.js");
|
||||
return replyMediaPathsRuntimePromise;
|
||||
@@ -318,6 +324,9 @@ export async function dispatchReplyFromConfig(
|
||||
ctx.MessageThreadId ?? parseSessionThreadInfoFast(acpDispatchSessionKey).threadId;
|
||||
const inboundAudio = isInboundAudioContext(ctx);
|
||||
const sessionTtsAuto = normalizeTtsAutoMode(sessionStoreEntry.entry?.ttsAuto);
|
||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, sessionAgentId);
|
||||
const { ensureRuntimePluginsLoaded } = await loadRuntimePlugins();
|
||||
ensureRuntimePluginsLoaded({ config: cfg, workspaceDir });
|
||||
const hookRunner = getGlobalHookRunner();
|
||||
|
||||
// Extract message context for hooks (plugin and internal)
|
||||
@@ -378,7 +387,7 @@ export async function dispatchReplyFromConfig(
|
||||
const normalizeReplyMediaPaths = createReplyMediaPathNormalizer({
|
||||
cfg,
|
||||
sessionKey: acpDispatchSessionKey,
|
||||
workspaceDir: resolveAgentWorkspaceDir(cfg, sessionAgentId),
|
||||
workspaceDir,
|
||||
messageProvider: deliveryChannel,
|
||||
accountId: replyRoute.accountId,
|
||||
groupId,
|
||||
|
||||
Reference in New Issue
Block a user