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:
Bob
2026-03-17 17:27:52 +01:00
committed by GitHub
parent 8139f83175
commit ea15819ecf
102 changed files with 6606 additions and 1199 deletions

View File

@@ -21,8 +21,8 @@ const {
mockResolveAgentRoute,
mockReadSessionUpdatedAt,
mockResolveStorePath,
mockResolveConfiguredAcpRoute,
mockEnsureConfiguredAcpRouteReady,
mockResolveConfiguredBindingRoute,
mockEnsureConfiguredBindingRouteReady,
mockResolveBoundConversation,
mockTouchBinding,
} = vi.hoisted(() => ({
@@ -50,11 +50,12 @@ const {
})),
mockReadSessionUpdatedAt: vi.fn(),
mockResolveStorePath: vi.fn(() => "/tmp/feishu-sessions.json"),
mockResolveConfiguredAcpRoute: vi.fn(({ route }) => ({
mockResolveConfiguredBindingRoute: vi.fn(({ route }) => ({
bindingResolution: null,
configuredBinding: null,
route,
})),
mockEnsureConfiguredAcpRouteReady: vi.fn(async (_params?: unknown) => ({ ok: true })),
mockEnsureConfiguredBindingRouteReady: vi.fn(async (_params?: unknown) => ({ ok: true })),
mockResolveBoundConversation: vi.fn(() => null),
mockTouchBinding: vi.fn(),
}));
@@ -78,12 +79,12 @@ vi.mock("./client.js", () => ({
}));
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
const original =
await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
return {
...original,
resolveConfiguredAcpRoute: (params: unknown) => mockResolveConfiguredAcpRoute(params),
ensureConfiguredAcpRouteReady: (params: unknown) => mockEnsureConfiguredAcpRouteReady(params),
...actual,
resolveConfiguredBindingRoute: (params: unknown) => mockResolveConfiguredBindingRoute(params),
ensureConfiguredBindingRouteReady: (params: unknown) =>
mockEnsureConfiguredBindingRouteReady(params),
getSessionBindingService: () => ({
resolveByConversation: mockResolveBoundConversation,
touch: mockTouchBinding,
@@ -91,6 +92,13 @@ vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
};
});
vi.mock("../../../src/infra/outbound/session-binding-service.js", () => ({
getSessionBindingService: () => ({
resolveByConversation: mockResolveBoundConversation,
touch: mockTouchBinding,
}),
}));
function createRuntimeEnv(): RuntimeEnv {
return {
log: vi.fn(),
@@ -138,14 +146,15 @@ describe("buildFeishuAgentBody", () => {
describe("handleFeishuMessage ACP routing", () => {
beforeEach(() => {
vi.clearAllMocks();
mockResolveConfiguredAcpRoute.mockReset().mockImplementation(
mockResolveConfiguredBindingRoute.mockReset().mockImplementation(
({ route }) =>
({
bindingResolution: null,
configuredBinding: null,
route,
}) as any,
);
mockEnsureConfiguredAcpRouteReady.mockReset().mockResolvedValue({ ok: true });
mockEnsureConfiguredBindingRouteReady.mockReset().mockResolvedValue({ ok: true });
mockResolveBoundConversation.mockReset().mockReturnValue(null);
mockTouchBinding.mockReset();
mockResolveAgentRoute.mockReset().mockReturnValue({
@@ -218,7 +227,37 @@ describe("handleFeishuMessage ACP routing", () => {
});
it("ensures configured ACP routes for Feishu DMs", async () => {
mockResolveConfiguredAcpRoute.mockReturnValue({
mockResolveConfiguredBindingRoute.mockReturnValue({
bindingResolution: {
configuredBinding: {
spec: {
channel: "feishu",
accountId: "default",
conversationId: "ou_sender_1",
agentId: "codex",
mode: "persistent",
},
record: {
bindingId: "config:acp:feishu:default:ou_sender_1",
targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123",
targetKind: "session",
conversation: {
channel: "feishu",
accountId: "default",
conversationId: "ou_sender_1",
},
status: "active",
boundAt: 0,
metadata: { source: "config" },
},
},
statefulTarget: {
kind: "stateful",
driverId: "acp",
sessionKey: "agent:codex:acp:binding:feishu:default:abc123",
agentId: "codex",
},
},
configuredBinding: {
spec: {
channel: "feishu",
@@ -268,12 +307,42 @@ describe("handleFeishuMessage ACP routing", () => {
},
});
expect(mockResolveConfiguredAcpRoute).toHaveBeenCalledTimes(1);
expect(mockEnsureConfiguredAcpRouteReady).toHaveBeenCalledTimes(1);
expect(mockResolveConfiguredBindingRoute).toHaveBeenCalledTimes(1);
expect(mockEnsureConfiguredBindingRouteReady).toHaveBeenCalledTimes(1);
});
it("surfaces configured ACP initialization failures to the Feishu conversation", async () => {
mockResolveConfiguredAcpRoute.mockReturnValue({
mockResolveConfiguredBindingRoute.mockReturnValue({
bindingResolution: {
configuredBinding: {
spec: {
channel: "feishu",
accountId: "default",
conversationId: "ou_sender_1",
agentId: "codex",
mode: "persistent",
},
record: {
bindingId: "config:acp:feishu:default:ou_sender_1",
targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123",
targetKind: "session",
conversation: {
channel: "feishu",
accountId: "default",
conversationId: "ou_sender_1",
},
status: "active",
boundAt: 0,
metadata: { source: "config" },
},
},
statefulTarget: {
kind: "stateful",
driverId: "acp",
sessionKey: "agent:codex:acp:binding:feishu:default:abc123",
agentId: "codex",
},
},
configuredBinding: {
spec: {
channel: "feishu",
@@ -305,7 +374,7 @@ describe("handleFeishuMessage ACP routing", () => {
matchedBy: "binding.channel",
},
} as any);
mockEnsureConfiguredAcpRouteReady.mockResolvedValue({
mockEnsureConfiguredBindingRouteReady.mockResolvedValue({
ok: false,
error: "runtime unavailable",
} as any);
@@ -433,14 +502,15 @@ describe("handleFeishuMessage command authorization", () => {
mockListFeishuThreadMessages.mockReset().mockResolvedValue([]);
mockReadSessionUpdatedAt.mockReturnValue(undefined);
mockResolveStorePath.mockReturnValue("/tmp/feishu-sessions.json");
mockResolveConfiguredAcpRoute.mockReset().mockImplementation(
mockResolveConfiguredBindingRoute.mockReset().mockImplementation(
({ route }) =>
({
bindingResolution: null,
configuredBinding: null,
route,
}) as any,
);
mockEnsureConfiguredAcpRouteReady.mockReset().mockResolvedValue({ ok: true });
mockEnsureConfiguredBindingRouteReady.mockReset().mockResolvedValue({ ok: true });
mockResolveBoundConversation.mockReset().mockReturnValue(null);
mockTouchBinding.mockReset();
mockResolveAgentRoute.mockReturnValue({

View File

@@ -1,6 +1,6 @@
import {
ensureConfiguredAcpRouteReady,
resolveConfiguredAcpRoute,
ensureConfiguredBindingRouteReady,
resolveConfiguredBindingRoute,
} from "openclaw/plugin-sdk/conversation-runtime";
import { getSessionBindingService } from "openclaw/plugin-sdk/conversation-runtime";
import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk/feishu";
@@ -1251,15 +1251,17 @@ export async function handleFeishuMessage(params: {
const parentConversationId = isGroup ? (parentPeer?.id ?? ctx.chatId) : undefined;
let configuredBinding = null;
if (feishuAcpConversationSupported) {
const configuredRoute = resolveConfiguredAcpRoute({
const configuredRoute = resolveConfiguredBindingRoute({
cfg: effectiveCfg,
route,
channel: "feishu",
accountId: account.accountId,
conversationId: currentConversationId,
parentConversationId,
conversation: {
channel: "feishu",
accountId: account.accountId,
conversationId: currentConversationId,
parentConversationId,
},
});
configuredBinding = configuredRoute.configuredBinding;
configuredBinding = configuredRoute.bindingResolution;
route = configuredRoute.route;
// Bound Feishu conversations intentionally require an exact live conversation-id match.
@@ -1292,9 +1294,9 @@ export async function handleFeishuMessage(params: {
}
if (configuredBinding) {
const ensured = await ensureConfiguredAcpRouteReady({
const ensured = await ensureConfiguredBindingRouteReady({
cfg: effectiveCfg,
configuredBinding,
bindingResolution: configuredBinding,
});
if (!ensured.ok) {
const replyTargetMessageId =

View File

@@ -822,11 +822,15 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
});
},
},
acpBindings: {
normalizeConfiguredBindingTarget: ({ conversationId }) =>
bindings: {
compileConfiguredBinding: ({ conversationId }) =>
normalizeFeishuAcpConversationId(conversationId),
matchConfiguredBinding: ({ bindingConversationId, conversationId, parentConversationId }) =>
matchFeishuAcpConversation({ bindingConversationId, conversationId, parentConversationId }),
matchInboundConversation: ({ compiledBinding, conversationId, parentConversationId }) =>
matchFeishuAcpConversation({
bindingConversationId: compiledBinding.conversationId,
conversationId,
parentConversationId,
}),
},
setup: feishuSetupAdapter,
setupWizard: feishuSetupWizard,

View File

@@ -1,19 +1,20 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const clientCtorMock = vi.hoisted(() => vi.fn());
const mockBaseHttpInstance = vi.hoisted(() => ({
request: vi.fn().mockResolvedValue({}),
get: vi.fn().mockResolvedValue({}),
post: vi.fn().mockResolvedValue({}),
put: vi.fn().mockResolvedValue({}),
patch: vi.fn().mockResolvedValue({}),
delete: vi.fn().mockResolvedValue({}),
head: vi.fn().mockResolvedValue({}),
options: vi.fn().mockResolvedValue({}),
const createFeishuClientMock = vi.hoisted(() => vi.fn());
vi.mock("./client.js", () => ({
createFeishuClient: createFeishuClientMock,
}));
import { clearClientCache, setFeishuClientRuntimeForTest } from "./client.js";
import { FEISHU_PROBE_REQUEST_TIMEOUT_MS, probeFeishu, clearProbeCache } from "./probe.js";
async function importProbeModule(scope: string) {
void scope;
vi.resetModules();
return await import("./probe.js");
}
let FEISHU_PROBE_REQUEST_TIMEOUT_MS: typeof import("./probe.js").FEISHU_PROBE_REQUEST_TIMEOUT_MS;
let probeFeishu: typeof import("./probe.js").probeFeishu;
let clearProbeCache: typeof import("./probe.js").clearProbeCache;
const DEFAULT_CREDS = { appId: "cli_123", appSecret: "secret" } as const; // pragma: allowlist secret
const DEFAULT_SUCCESS_RESPONSE = {
@@ -35,15 +36,9 @@ function makeRequestFn(response: Record<string, unknown>) {
return vi.fn().mockResolvedValue(response);
}
function installClientCtor(requestFn: unknown) {
clientCtorMock.mockImplementation(function MockFeishuClient(this: { request: unknown }) {
this.request = requestFn;
} as never);
}
function setupClient(response: Record<string, unknown>) {
const requestFn = makeRequestFn(response);
installClientCtor(requestFn);
createFeishuClientMock.mockReturnValue({ request: requestFn });
return requestFn;
}
@@ -53,7 +48,12 @@ function setupSuccessClient() {
async function expectDefaultSuccessResult(
creds = DEFAULT_CREDS,
expected: Awaited<ReturnType<typeof probeFeishu>> = DEFAULT_SUCCESS_RESULT,
expected: {
ok: true;
appId: string;
botName: string;
botOpenId: string;
} = DEFAULT_SUCCESS_RESULT,
) {
const result = await probeFeishu(creds);
expect(result).toEqual(expected);
@@ -73,7 +73,7 @@ async function expectErrorResultCached(params: {
expectedError: string;
ttlMs: number;
}) {
installClientCtor(params.requestFn);
createFeishuClientMock.mockReturnValue({ request: params.requestFn });
const first = await probeFeishu(DEFAULT_CREDS);
const second = await probeFeishu(DEFAULT_CREDS);
@@ -106,27 +106,16 @@ async function readSequentialDefaultProbePair() {
}
describe("probeFeishu", () => {
beforeEach(() => {
beforeEach(async () => {
({ FEISHU_PROBE_REQUEST_TIMEOUT_MS, probeFeishu, clearProbeCache } = await importProbeModule(
`probe-${Date.now()}-${Math.random()}`,
));
clearProbeCache();
clearClientCache();
vi.clearAllMocks();
setFeishuClientRuntimeForTest({
sdk: {
AppType: { SelfBuild: "self" } as never,
Domain: {
Feishu: "https://open.feishu.cn",
Lark: "https://open.larksuite.com",
} as never,
Client: clientCtorMock as never,
defaultHttpInstance: mockBaseHttpInstance as never,
},
});
vi.restoreAllMocks();
});
afterEach(() => {
clearProbeCache();
clearClientCache();
setFeishuClientRuntimeForTest();
});
it("returns error when credentials are missing", async () => {
@@ -168,7 +157,7 @@ describe("probeFeishu", () => {
it("returns timeout error when request exceeds timeout", async () => {
await withFakeTimers(async () => {
const requestFn = vi.fn().mockImplementation(() => new Promise(() => {}));
installClientCtor(requestFn);
createFeishuClientMock.mockReturnValue({ request: requestFn });
const promise = probeFeishu(DEFAULT_CREDS, { timeoutMs: 1_000 });
await vi.advanceTimersByTimeAsync(1_000);
@@ -179,6 +168,7 @@ describe("probeFeishu", () => {
});
it("returns aborted when abort signal is already aborted", async () => {
createFeishuClientMock.mockClear();
const abortController = new AbortController();
abortController.abort();
@@ -188,7 +178,7 @@ describe("probeFeishu", () => {
);
expect(result).toMatchObject({ ok: false, error: "probe aborted" });
expect(clientCtorMock).not.toHaveBeenCalled();
expect(createFeishuClientMock).not.toHaveBeenCalled();
});
it("returns cached result on subsequent calls within TTL", async () => {
const requestFn = setupSuccessClient();