mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-22 07:20:59 +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:
@@ -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({
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user