mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 20:20:42 +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,5 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import type { ResolvedAgentRoute } from "../../../src/routing/resolve-route.js";
|
||||
import {
|
||||
createDeferred,
|
||||
createNativeCommandTestParams,
|
||||
@@ -14,10 +15,10 @@ import {
|
||||
|
||||
// All mocks scoped to this file only — does not affect bot-native-commands.test.ts
|
||||
|
||||
type ResolveConfiguredAcpBindingRecordFn =
|
||||
typeof import("../../../src/acp/persistent-bindings.js").resolveConfiguredAcpBindingRecord;
|
||||
type EnsureConfiguredAcpBindingSessionFn =
|
||||
typeof import("../../../src/acp/persistent-bindings.js").ensureConfiguredAcpBindingSession;
|
||||
type ResolveConfiguredBindingRouteFn =
|
||||
typeof import("openclaw/plugin-sdk/conversation-runtime").resolveConfiguredBindingRoute;
|
||||
type EnsureConfiguredBindingRouteReadyFn =
|
||||
typeof import("openclaw/plugin-sdk/conversation-runtime").ensureConfiguredBindingRouteReady;
|
||||
type DispatchReplyWithBufferedBlockDispatcherFn =
|
||||
typeof import("../../../src/auto-reply/reply/provider-dispatcher.js").dispatchReplyWithBufferedBlockDispatcher;
|
||||
type DispatchReplyWithBufferedBlockDispatcherParams =
|
||||
@@ -34,10 +35,12 @@ const dispatchReplyResult: DispatchReplyWithBufferedBlockDispatcherResult = {
|
||||
};
|
||||
|
||||
const persistentBindingMocks = vi.hoisted(() => ({
|
||||
resolveConfiguredAcpBindingRecord: vi.fn<ResolveConfiguredAcpBindingRecordFn>(() => null),
|
||||
ensureConfiguredAcpBindingSession: vi.fn<EnsureConfiguredAcpBindingSessionFn>(async () => ({
|
||||
resolveConfiguredBindingRoute: vi.fn<ResolveConfiguredBindingRouteFn>(({ route }) => ({
|
||||
bindingResolution: null,
|
||||
route,
|
||||
})),
|
||||
ensureConfiguredBindingRouteReady: vi.fn<EnsureConfiguredBindingRouteReadyFn>(async () => ({
|
||||
ok: true,
|
||||
sessionKey: "agent:codex:acp:binding:telegram:default:seed",
|
||||
})),
|
||||
}));
|
||||
const sessionMocks = vi.hoisted(() => ({
|
||||
@@ -59,12 +62,58 @@ const sessionBindingMocks = vi.hoisted(() => ({
|
||||
touch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../src/acp/persistent-bindings.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../src/acp/persistent-bindings.js")>();
|
||||
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveConfiguredAcpBindingRecord: persistentBindingMocks.resolveConfiguredAcpBindingRecord,
|
||||
ensureConfiguredAcpBindingSession: persistentBindingMocks.ensureConfiguredAcpBindingSession,
|
||||
resolveConfiguredBindingRoute: persistentBindingMocks.resolveConfiguredBindingRoute,
|
||||
ensureConfiguredBindingRouteReady: persistentBindingMocks.ensureConfiguredBindingRouteReady,
|
||||
getSessionBindingService: () => ({
|
||||
bind: vi.fn(),
|
||||
getCapabilities: vi.fn(),
|
||||
listBySession: vi.fn(),
|
||||
resolveByConversation: (ref: unknown) => sessionBindingMocks.resolveByConversation(ref),
|
||||
touch: (bindingId: string, at?: number) => sessionBindingMocks.touch(bindingId, at),
|
||||
unbind: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock("openclaw/plugin-sdk/channel-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
createReplyPrefixOptions: vi.fn(() => ({ onModelSelected: () => {} })),
|
||||
recordInboundSessionMetaSafe: vi.fn(
|
||||
async (params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
sessionKey: string;
|
||||
ctx: unknown;
|
||||
onError?: (error: unknown) => void;
|
||||
}) => {
|
||||
const storePath = sessionMocks.resolveStorePath(params.cfg.session?.store, {
|
||||
agentId: params.agentId,
|
||||
});
|
||||
try {
|
||||
await sessionMocks.recordSessionMetaFromInbound({
|
||||
storePath,
|
||||
sessionKey: params.sessionKey,
|
||||
ctx: params.ctx,
|
||||
});
|
||||
} catch (error) {
|
||||
params.onError?.(error);
|
||||
}
|
||||
},
|
||||
),
|
||||
};
|
||||
});
|
||||
vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
finalizeInboundContext: vi.fn((ctx: unknown) => ctx),
|
||||
dispatchReplyWithBufferedBlockDispatcher: replyMocks.dispatchReplyWithBufferedBlockDispatcher,
|
||||
listSkillCommandsForAgents: vi.fn(() => []),
|
||||
};
|
||||
});
|
||||
vi.mock("../../../src/config/sessions.js", () => ({
|
||||
@@ -74,15 +123,6 @@ vi.mock("../../../src/config/sessions.js", () => ({
|
||||
vi.mock("../../../src/pairing/pairing-store.js", () => ({
|
||||
readChannelAllowFromStore: vi.fn(async () => []),
|
||||
}));
|
||||
vi.mock("../../../src/auto-reply/reply/inbound-context.js", () => ({
|
||||
finalizeInboundContext: vi.fn((ctx: unknown) => ctx),
|
||||
}));
|
||||
vi.mock("../../../src/auto-reply/reply/provider-dispatcher.js", () => ({
|
||||
dispatchReplyWithBufferedBlockDispatcher: replyMocks.dispatchReplyWithBufferedBlockDispatcher,
|
||||
}));
|
||||
vi.mock("../../../src/channels/reply-prefix.js", () => ({
|
||||
createReplyPrefixOptions: vi.fn(() => ({ onModelSelected: () => {} })),
|
||||
}));
|
||||
vi.mock("../../../src/infra/outbound/session-binding-service.js", () => ({
|
||||
getSessionBindingService: () => ({
|
||||
bind: vi.fn(),
|
||||
@@ -93,10 +133,6 @@ vi.mock("../../../src/infra/outbound/session-binding-service.js", () => ({
|
||||
unbind: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../../src/auto-reply/skill-commands.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../src/auto-reply/skill-commands.js")>();
|
||||
return { ...actual, listSkillCommandsForAgents: vi.fn(() => []) };
|
||||
});
|
||||
vi.mock("../../../src/plugins/commands.js", () => ({
|
||||
getPluginCommandSpecs: vi.fn(() => []),
|
||||
matchPluginCommand: vi.fn(() => null),
|
||||
@@ -233,13 +269,93 @@ function createConfiguredAcpTopicBinding(boundSessionKey: string) {
|
||||
status: "active",
|
||||
boundAt: 0,
|
||||
},
|
||||
} satisfies import("../../../src/acp/persistent-bindings.js").ResolvedConfiguredAcpBinding;
|
||||
} as const;
|
||||
}
|
||||
|
||||
function createConfiguredBindingRoute(
|
||||
route: ResolvedAgentRoute,
|
||||
binding: ReturnType<typeof createConfiguredAcpTopicBinding> | null,
|
||||
) {
|
||||
return {
|
||||
bindingResolution: binding
|
||||
? {
|
||||
conversation: binding.record.conversation,
|
||||
compiledBinding: {
|
||||
channel: "telegram" as const,
|
||||
binding: {
|
||||
type: "acp" as const,
|
||||
agentId: binding.spec.agentId,
|
||||
match: {
|
||||
channel: "telegram",
|
||||
accountId: binding.spec.accountId,
|
||||
peer: {
|
||||
kind: "group" as const,
|
||||
id: binding.spec.conversationId,
|
||||
},
|
||||
},
|
||||
acp: {
|
||||
mode: binding.spec.mode,
|
||||
},
|
||||
},
|
||||
bindingConversationId: binding.spec.conversationId,
|
||||
target: {
|
||||
conversationId: binding.spec.conversationId,
|
||||
...(binding.spec.parentConversationId
|
||||
? { parentConversationId: binding.spec.parentConversationId }
|
||||
: {}),
|
||||
},
|
||||
agentId: binding.spec.agentId,
|
||||
provider: {
|
||||
compileConfiguredBinding: () => ({
|
||||
conversationId: binding.spec.conversationId,
|
||||
...(binding.spec.parentConversationId
|
||||
? { parentConversationId: binding.spec.parentConversationId }
|
||||
: {}),
|
||||
}),
|
||||
matchInboundConversation: () => ({
|
||||
conversationId: binding.spec.conversationId,
|
||||
...(binding.spec.parentConversationId
|
||||
? { parentConversationId: binding.spec.parentConversationId }
|
||||
: {}),
|
||||
}),
|
||||
},
|
||||
targetFactory: {
|
||||
driverId: "acp" as const,
|
||||
materialize: () => ({
|
||||
record: binding.record,
|
||||
statefulTarget: {
|
||||
kind: "stateful" as const,
|
||||
driverId: "acp" as const,
|
||||
sessionKey: binding.record.targetSessionKey,
|
||||
agentId: binding.spec.agentId,
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
match: {
|
||||
conversationId: binding.spec.conversationId,
|
||||
...(binding.spec.parentConversationId
|
||||
? { parentConversationId: binding.spec.parentConversationId }
|
||||
: {}),
|
||||
},
|
||||
record: binding.record,
|
||||
statefulTarget: {
|
||||
kind: "stateful" as const,
|
||||
driverId: "acp" as const,
|
||||
sessionKey: binding.record.targetSessionKey,
|
||||
agentId: binding.spec.agentId,
|
||||
},
|
||||
}
|
||||
: null,
|
||||
...(binding ? { boundSessionKey: binding.record.targetSessionKey } : {}),
|
||||
route,
|
||||
};
|
||||
}
|
||||
|
||||
function expectUnauthorizedNewCommandBlocked(sendMessage: ReturnType<typeof vi.fn>) {
|
||||
expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||
expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).not.toHaveBeenCalled();
|
||||
expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).not.toHaveBeenCalled();
|
||||
expect(persistentBindingMocks.resolveConfiguredBindingRoute).not.toHaveBeenCalled();
|
||||
expect(persistentBindingMocks.ensureConfiguredBindingRouteReady).not.toHaveBeenCalled();
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
-1001234567890,
|
||||
"You are not authorized to use this command.",
|
||||
@@ -249,13 +365,12 @@ function expectUnauthorizedNewCommandBlocked(sendMessage: ReturnType<typeof vi.f
|
||||
|
||||
describe("registerTelegramNativeCommands — session metadata", () => {
|
||||
beforeEach(() => {
|
||||
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockClear();
|
||||
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue(null);
|
||||
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockClear();
|
||||
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
|
||||
ok: true,
|
||||
sessionKey: "agent:codex:acp:binding:telegram:default:seed",
|
||||
});
|
||||
persistentBindingMocks.resolveConfiguredBindingRoute.mockClear();
|
||||
persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) =>
|
||||
createConfiguredBindingRoute(route, null),
|
||||
);
|
||||
persistentBindingMocks.ensureConfiguredBindingRouteReady.mockClear();
|
||||
persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({ ok: true });
|
||||
sessionMocks.recordSessionMetaFromInbound.mockClear().mockResolvedValue(undefined);
|
||||
sessionMocks.resolveStorePath.mockClear().mockReturnValue("/tmp/openclaw-sessions.json");
|
||||
replyMocks.dispatchReplyWithBufferedBlockDispatcher
|
||||
@@ -403,13 +518,18 @@ describe("registerTelegramNativeCommands — session metadata", () => {
|
||||
|
||||
it("routes Telegram native commands through configured ACP topic bindings", async () => {
|
||||
const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface";
|
||||
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue(
|
||||
createConfiguredAcpTopicBinding(boundSessionKey),
|
||||
persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) =>
|
||||
createConfiguredBindingRoute(
|
||||
{
|
||||
...route,
|
||||
sessionKey: boundSessionKey,
|
||||
agentId: "codex",
|
||||
matchedBy: "binding.channel",
|
||||
},
|
||||
createConfiguredAcpTopicBinding(boundSessionKey),
|
||||
),
|
||||
);
|
||||
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
|
||||
ok: true,
|
||||
sessionKey: boundSessionKey,
|
||||
});
|
||||
persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({ ok: true });
|
||||
|
||||
const { handler } = registerAndResolveStatusHandler({
|
||||
cfg: {},
|
||||
@@ -418,8 +538,8 @@ describe("registerTelegramNativeCommands — session metadata", () => {
|
||||
});
|
||||
await handler(createTelegramTopicCommandContext());
|
||||
|
||||
expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).toHaveBeenCalledTimes(1);
|
||||
expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).toHaveBeenCalledTimes(1);
|
||||
expect(persistentBindingMocks.resolveConfiguredBindingRoute).toHaveBeenCalledTimes(1);
|
||||
expect(persistentBindingMocks.ensureConfiguredBindingRouteReady).toHaveBeenCalledTimes(1);
|
||||
const dispatchCall = (
|
||||
replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array<
|
||||
[{ ctx?: { CommandTargetSessionKey?: string } }]
|
||||
@@ -488,12 +608,19 @@ describe("registerTelegramNativeCommands — session metadata", () => {
|
||||
|
||||
it("aborts native command dispatch when configured ACP topic binding cannot initialize", async () => {
|
||||
const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface";
|
||||
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue(
|
||||
createConfiguredAcpTopicBinding(boundSessionKey),
|
||||
persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) =>
|
||||
createConfiguredBindingRoute(
|
||||
{
|
||||
...route,
|
||||
sessionKey: boundSessionKey,
|
||||
agentId: "codex",
|
||||
matchedBy: "binding.channel",
|
||||
},
|
||||
createConfiguredAcpTopicBinding(boundSessionKey),
|
||||
),
|
||||
);
|
||||
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
|
||||
persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({
|
||||
ok: false,
|
||||
sessionKey: boundSessionKey,
|
||||
error: "gateway unavailable",
|
||||
});
|
||||
|
||||
@@ -514,13 +641,18 @@ describe("registerTelegramNativeCommands — session metadata", () => {
|
||||
|
||||
it("keeps /new blocked in ACP-bound Telegram topics when sender is unauthorized", async () => {
|
||||
const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface";
|
||||
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue(
|
||||
createConfiguredAcpTopicBinding(boundSessionKey),
|
||||
persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) =>
|
||||
createConfiguredBindingRoute(
|
||||
{
|
||||
...route,
|
||||
sessionKey: boundSessionKey,
|
||||
agentId: "codex",
|
||||
matchedBy: "binding.channel",
|
||||
},
|
||||
createConfiguredAcpTopicBinding(boundSessionKey),
|
||||
),
|
||||
);
|
||||
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
|
||||
ok: true,
|
||||
sessionKey: boundSessionKey,
|
||||
});
|
||||
persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({ ok: true });
|
||||
|
||||
const { handler, sendMessage } = registerAndResolveCommandHandler({
|
||||
commandName: "new",
|
||||
@@ -535,7 +667,9 @@ describe("registerTelegramNativeCommands — session metadata", () => {
|
||||
});
|
||||
|
||||
it("keeps /new blocked for unbound Telegram topics when sender is unauthorized", async () => {
|
||||
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue(null);
|
||||
persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) =>
|
||||
createConfiguredBindingRoute(route, null),
|
||||
);
|
||||
|
||||
const { handler, sendMessage } = registerAndResolveCommandHandler({
|
||||
commandName: "new",
|
||||
|
||||
Reference in New Issue
Block a user