mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-18 20:51:10 +00:00
ACP: harden startup and move configured routing behind plugin seams (#48197)
* ACPX: keep plugin-local runtime installs out of dist * Gateway: harden ACP startup and service PATH * ACP: reinitialize error-state configured bindings * ACP: classify pre-turn runtime failures as session init failures * Plugins: move configured ACP routing behind channel seams * Telegram tests: align startup probe assertions after rebase * Discord: harden ACP configured binding recovery * ACP: recover Discord bindings after stale runtime exits * ACPX: replace dead sessions during ensure * Discord: harden ACP binding recovery * Discord: fix review follow-ups * ACP bindings: load channel snapshots across workspaces * ACP bindings: cache snapshot channel plugin resolution * Experiments: add ACP pluginification holy grail plan * Experiments: rename ACP pluginification plan doc * Experiments: drop old ACP pluginification doc path * ACP: move configured bindings behind plugin services * Experiments: update bindings capability architecture plan * Bindings: isolate configured binding routing and targets * Discord tests: fix runtime env helper path * Tests: fix channel binding CI regressions * Tests: normalize ACP workspace assertion on Windows * Bindings: isolate configured binding registry * Bindings: finish configured binding cleanup * Bindings: finish generic cleanup * Bindings: align runtime approval callbacks * ACP: delete residual bindings barrel * Bindings: restore legacy compatibility * Revert "Bindings: restore legacy compatibility" This reverts commit ac2ed68fa2426ecc874d68278c71c71ad363fcfe. * Tests: drop ACP route legacy helper names * Discord/ACP: fix binding regressions --------- Co-authored-by: Onur <2453968+osolmaz@users.noreply.github.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { resolveConfiguredAcpBindingRecord } from "../../acp/persistent-bindings.js";
|
||||
import { resolveConfiguredBindingRecord } from "../../channels/plugins/binding-registry.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js";
|
||||
import { DEFAULT_ACCOUNT_ID, isAcpSessionKey } from "../../routing/session-key.js";
|
||||
@@ -51,7 +51,7 @@ export function resolveEffectiveResetTargetSessionKey(params: {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const configuredBinding = resolveConfiguredAcpBindingRecord({
|
||||
const configuredBinding = resolveConfiguredBindingRecord({
|
||||
cfg: params.cfg,
|
||||
channel,
|
||||
accountId,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { resetAcpSessionInPlace } from "../../acp/persistent-bindings.js";
|
||||
import { resetConfiguredBindingTargetInPlace } from "../../channels/plugins/binding-targets.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js";
|
||||
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
|
||||
@@ -228,7 +228,7 @@ export async function handleCommands(params: HandleCommandsParams): Promise<Comm
|
||||
? boundAcpSessionKey.trim()
|
||||
: undefined;
|
||||
if (boundAcpKey) {
|
||||
const resetResult = await resetAcpSessionInPlace({
|
||||
const resetResult = await resetConfiguredBindingTargetInPlace({
|
||||
cfg: params.cfg,
|
||||
sessionKey: boundAcpKey,
|
||||
reason: commandAction,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { discordPlugin } from "../../../extensions/discord/src/channel.js";
|
||||
import { AcpRuntimeError } from "../../acp/runtime/errors.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { SessionBindingRecord } from "../../infra/outbound/session-binding-service.js";
|
||||
import type { PluginTargetedInboundClaimOutcome } from "../../plugins/hooks.js";
|
||||
import { setActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
|
||||
import {
|
||||
createChannelTestPluginBase,
|
||||
createTestRegistry,
|
||||
} from "../../test-utils/channel-plugins.js";
|
||||
import { createInternalHookEventPayload } from "../../test-utils/internal-hook-event-payload.js";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||
@@ -192,14 +193,16 @@ vi.mock("../../tts/tts.js", () => ({
|
||||
resolveTtsConfig: (cfg: OpenClawConfig) => ttsMocks.resolveTtsConfig(cfg),
|
||||
}));
|
||||
|
||||
const { dispatchReplyFromConfig } = await import("./dispatch-from-config.js");
|
||||
const { resetInboundDedupe } = await import("./inbound-dedupe.js");
|
||||
const { __testing: acpManagerTesting } = await import("../../acp/control-plane/manager.js");
|
||||
const { __testing: pluginBindingTesting } = await import("../../plugins/conversation-binding.js");
|
||||
|
||||
const noAbortResult = { handled: false, aborted: false } as const;
|
||||
const emptyConfig = {} as OpenClawConfig;
|
||||
type DispatchReplyArgs = Parameters<typeof dispatchReplyFromConfig>[0];
|
||||
let dispatchReplyFromConfig: typeof import("./dispatch-from-config.js").dispatchReplyFromConfig;
|
||||
let resetInboundDedupe: typeof import("./inbound-dedupe.js").resetInboundDedupe;
|
||||
let acpManagerTesting: typeof import("../../acp/control-plane/manager.js").__testing;
|
||||
let pluginBindingTesting: typeof import("../../plugins/conversation-binding.js").__testing;
|
||||
let AcpRuntimeErrorClass: typeof import("../../acp/runtime/errors.js").AcpRuntimeError;
|
||||
type DispatchReplyArgs = Parameters<
|
||||
typeof import("./dispatch-from-config.js").dispatchReplyFromConfig
|
||||
>[0];
|
||||
|
||||
function createDispatcher(): ReplyDispatcher {
|
||||
return {
|
||||
@@ -254,9 +257,39 @@ async function dispatchTwiceWithFreshDispatchers(params: Omit<DispatchReplyArgs,
|
||||
}
|
||||
|
||||
describe("dispatchReplyFromConfig", () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ dispatchReplyFromConfig } = await import("./dispatch-from-config.js"));
|
||||
({ resetInboundDedupe } = await import("./inbound-dedupe.js"));
|
||||
({ __testing: acpManagerTesting } = await import("../../acp/control-plane/manager.js"));
|
||||
({ __testing: pluginBindingTesting } = await import("../../plugins/conversation-binding.js"));
|
||||
({ AcpRuntimeError: AcpRuntimeErrorClass } = await import("../../acp/runtime/errors.js"));
|
||||
const discordTestPlugin = {
|
||||
...createChannelTestPluginBase({
|
||||
id: "discord",
|
||||
capabilities: {
|
||||
chatTypes: ["direct"],
|
||||
nativeCommands: true,
|
||||
},
|
||||
}),
|
||||
execApprovals: {
|
||||
shouldSuppressLocalPrompt: ({ payload }: { payload: ReplyPayload }) =>
|
||||
Boolean(
|
||||
payload.channelData &&
|
||||
typeof payload.channelData === "object" &&
|
||||
!Array.isArray(payload.channelData) &&
|
||||
payload.channelData.execApproval,
|
||||
),
|
||||
},
|
||||
};
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([{ pluginId: "discord", source: "test", plugin: discordPlugin }]),
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "discord",
|
||||
source: "test",
|
||||
plugin: discordTestPlugin,
|
||||
},
|
||||
]),
|
||||
);
|
||||
acpManagerTesting.resetAcpSessionManagerForTests();
|
||||
resetInboundDedupe();
|
||||
@@ -1733,7 +1766,7 @@ describe("dispatchReplyFromConfig", () => {
|
||||
},
|
||||
});
|
||||
acpMocks.requireAcpRuntimeBackend.mockImplementation(() => {
|
||||
throw new AcpRuntimeError(
|
||||
throw new AcpRuntimeErrorClass(
|
||||
"ACP_BACKEND_MISSING",
|
||||
"ACP runtime backend is not configured. Install and enable the acpx runtime plugin.",
|
||||
);
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
|
||||
import {
|
||||
resolveConversationBindingRecord,
|
||||
touchConversationBindingRecord,
|
||||
} from "../../bindings/records.js";
|
||||
import { shouldSuppressLocalExecApprovalPrompt } from "../../channels/plugins/exec-approval-local.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import {
|
||||
@@ -20,7 +24,6 @@ import {
|
||||
toPluginMessageReceivedEvent,
|
||||
} from "../../hooks/message-hook-mappers.js";
|
||||
import { isDiagnosticsEnabled } from "../../infra/diagnostic-events.js";
|
||||
import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js";
|
||||
import {
|
||||
logMessageProcessed,
|
||||
logMessageQueued,
|
||||
@@ -303,7 +306,7 @@ export async function dispatchReplyFromConfig(params: {
|
||||
|
||||
const pluginOwnedBindingRecord =
|
||||
inboundClaimContext.conversationId && inboundClaimContext.channelId
|
||||
? getSessionBindingService().resolveByConversation({
|
||||
? resolveConversationBindingRecord({
|
||||
channel: inboundClaimContext.channelId,
|
||||
accountId: inboundClaimContext.accountId ?? "default",
|
||||
conversationId: inboundClaimContext.conversationId,
|
||||
@@ -320,7 +323,7 @@ export async function dispatchReplyFromConfig(params: {
|
||||
| undefined;
|
||||
|
||||
if (pluginOwnedBinding) {
|
||||
getSessionBindingService().touch(pluginOwnedBinding.bindingId);
|
||||
touchConversationBindingRecord(pluginOwnedBinding.bindingId);
|
||||
logVerbose(
|
||||
`plugin-bound inbound routed to ${pluginOwnedBinding.pluginId} conversation=${pluginOwnedBinding.conversationId}`,
|
||||
);
|
||||
|
||||
@@ -99,6 +99,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
|
||||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
conversationBindingResolvedHandlers: [],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
@@ -300,7 +301,7 @@ describe("routeReply", () => {
|
||||
});
|
||||
|
||||
it("passes thread id to Telegram sends", async () => {
|
||||
mocks.sendMessageTelegram.mockClear();
|
||||
mocks.deliverOutboundPayloads.mockResolvedValue([]);
|
||||
await routeReply({
|
||||
payload: { text: "hi" },
|
||||
channel: "telegram",
|
||||
@@ -308,10 +309,12 @@ describe("routeReply", () => {
|
||||
threadId: 42,
|
||||
cfg: {} as never,
|
||||
});
|
||||
expect(mocks.sendMessageTelegram).toHaveBeenCalledWith(
|
||||
"telegram:123",
|
||||
"hi",
|
||||
expect.objectContaining({ messageThreadId: 42 }),
|
||||
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "telegram",
|
||||
to: "telegram:123",
|
||||
threadId: 42,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -346,17 +349,19 @@ describe("routeReply", () => {
|
||||
});
|
||||
|
||||
it("passes replyToId to Telegram sends", async () => {
|
||||
mocks.sendMessageTelegram.mockClear();
|
||||
mocks.deliverOutboundPayloads.mockResolvedValue([]);
|
||||
await routeReply({
|
||||
payload: { text: "hi", replyToId: "123" },
|
||||
channel: "telegram",
|
||||
to: "telegram:123",
|
||||
cfg: {} as never,
|
||||
});
|
||||
expect(mocks.sendMessageTelegram).toHaveBeenCalledWith(
|
||||
"telegram:123",
|
||||
"hi",
|
||||
expect.objectContaining({ replyToMessageId: 123 }),
|
||||
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "telegram",
|
||||
to: "telegram:123",
|
||||
replyToId: "123",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user