ACP: add persistent Discord channel and Telegram topic bindings (#34873)

* docs: add ACP persistent binding experiment plan

* docs: align ACP persistent binding spec to channel-local config

* docs: scope Telegram ACP bindings to forum topics only

* docs: lock bound /new and /reset behavior to in-place ACP reset

* ACP: add persistent discord/telegram conversation bindings

* ACP: fix persistent binding reuse and discord thread parent context

* docs: document channel-specific persistent ACP bindings

* ACP: split persistent bindings and share conversation id helpers

* ACP: defer configured binding init until preflight passes

* ACP: fix discord thread parent fallback and explicit disable inheritance

* ACP: keep bound /new and /reset in-place

* ACP: honor configured bindings in native command flows

* ACP: avoid configured fallback after runtime bind failure

* docs: refine ACP bindings experiment config examples

* acp: cut over to typed top-level persistent bindings

* ACP bindings: harden reset recovery and native command auth

* Docs: add ACP bound command auth proposal

* Tests: normalize i18n registry zh-CN assertion encoding

* ACP bindings: address review findings for reset and fallback routing

* ACP reset: gate hooks on success and preserve /new arguments

* ACP bindings: fix auth and binding-priority review findings

* Telegram ACP: gate ensure on auth and accepted messages

* ACP bindings: fix session-key precedence and unavailable handling

* ACP reset/native commands: honor fallback targets and abort on bootstrap failure

* Config schema: validate ACP binding channel and Telegram topic IDs

* Discord ACP: apply configured DM bindings to native commands

* ACP reset tails: dispatch through ACP after command handling

* ACP tails/native reset auth: fix target dispatch and restore full auth

* ACP reset detection: fallback to active ACP keys for DM contexts

* Tests: type runTurn mock input in ACP dispatch test

* ACP: dedup binding route bootstrap and reset target resolution

* reply: align ACP reset hooks with bound session key

* docs: replace personal discord ids with placeholders

* fix: add changelog entry for ACP persistent bindings (#34873) (thanks @dutifulbob)

---------

Co-authored-by: Onur <2453968+osolmaz@users.noreply.github.com>
This commit is contained in:
Bob
2026-03-05 09:38:12 +01:00
committed by GitHub
parent 2c8ee593b9
commit 6a705a37f2
50 changed files with 4830 additions and 186 deletions

View File

@@ -0,0 +1,80 @@
export type ParsedTelegramTopicConversation = {
chatId: string;
topicId: string;
canonicalConversationId: string;
};
function normalizeText(value: unknown): string {
if (typeof value === "string") {
return value.trim();
}
if (typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") {
return `${value}`.trim();
}
return "";
}
export function parseTelegramChatIdFromTarget(raw: unknown): string | undefined {
const text = normalizeText(raw);
if (!text) {
return undefined;
}
const match = text.match(/^telegram:(-?\d+)$/);
if (!match?.[1]) {
return undefined;
}
return match[1];
}
export function buildTelegramTopicConversationId(params: {
chatId: string;
topicId: string;
}): string | null {
const chatId = params.chatId.trim();
const topicId = params.topicId.trim();
if (!/^-?\d+$/.test(chatId) || !/^\d+$/.test(topicId)) {
return null;
}
return `${chatId}:topic:${topicId}`;
}
export function parseTelegramTopicConversation(params: {
conversationId: string;
parentConversationId?: string;
}): ParsedTelegramTopicConversation | null {
const conversation = params.conversationId.trim();
const directMatch = conversation.match(/^(-?\d+):topic:(\d+)$/);
if (directMatch?.[1] && directMatch[2]) {
const canonicalConversationId = buildTelegramTopicConversationId({
chatId: directMatch[1],
topicId: directMatch[2],
});
if (!canonicalConversationId) {
return null;
}
return {
chatId: directMatch[1],
topicId: directMatch[2],
canonicalConversationId,
};
}
if (!/^\d+$/.test(conversation)) {
return null;
}
const parent = params.parentConversationId?.trim();
if (!parent || !/^-?\d+$/.test(parent)) {
return null;
}
const canonicalConversationId = buildTelegramTopicConversationId({
chatId: parent,
topicId: conversation,
});
if (!canonicalConversationId) {
return null;
}
return {
chatId: parent,
topicId: conversation,
canonicalConversationId,
};
}

View File

@@ -0,0 +1,198 @@
import type { OpenClawConfig } from "../config/config.js";
import type { SessionAcpMeta } from "../config/sessions/types.js";
import { logVerbose } from "../globals.js";
import { getAcpSessionManager } from "./control-plane/manager.js";
import { resolveAcpAgentFromSessionKey } from "./control-plane/manager.utils.js";
import { resolveConfiguredAcpBindingSpecBySessionKey } from "./persistent-bindings.resolve.js";
import {
buildConfiguredAcpSessionKey,
normalizeText,
type ConfiguredAcpBindingSpec,
} from "./persistent-bindings.types.js";
import { readAcpSessionEntry } from "./runtime/session-meta.js";
function sessionMatchesConfiguredBinding(params: {
cfg: OpenClawConfig;
spec: ConfiguredAcpBindingSpec;
meta: SessionAcpMeta;
}): boolean {
const desiredAgent = (params.spec.acpAgentId ?? params.spec.agentId).trim().toLowerCase();
const currentAgent = (params.meta.agent ?? "").trim().toLowerCase();
if (!currentAgent || currentAgent !== desiredAgent) {
return false;
}
if (params.meta.mode !== params.spec.mode) {
return false;
}
const desiredBackend = params.spec.backend?.trim() || params.cfg.acp?.backend?.trim() || "";
if (desiredBackend) {
const currentBackend = (params.meta.backend ?? "").trim();
if (!currentBackend || currentBackend !== desiredBackend) {
return false;
}
}
const desiredCwd = params.spec.cwd?.trim();
if (desiredCwd !== undefined) {
const currentCwd = (params.meta.runtimeOptions?.cwd ?? params.meta.cwd ?? "").trim();
if (desiredCwd !== currentCwd) {
return false;
}
}
return true;
}
export async function ensureConfiguredAcpBindingSession(params: {
cfg: OpenClawConfig;
spec: ConfiguredAcpBindingSpec;
}): Promise<{ ok: true; sessionKey: string } | { ok: false; sessionKey: string; error: string }> {
const sessionKey = buildConfiguredAcpSessionKey(params.spec);
const acpManager = getAcpSessionManager();
try {
const resolution = acpManager.resolveSession({
cfg: params.cfg,
sessionKey,
});
if (
resolution.kind === "ready" &&
sessionMatchesConfiguredBinding({
cfg: params.cfg,
spec: params.spec,
meta: resolution.meta,
})
) {
return {
ok: true,
sessionKey,
};
}
if (resolution.kind !== "none") {
await acpManager.closeSession({
cfg: params.cfg,
sessionKey,
reason: "config-binding-reconfigure",
clearMeta: false,
allowBackendUnavailable: true,
requireAcpSession: false,
});
}
await acpManager.initializeSession({
cfg: params.cfg,
sessionKey,
agent: params.spec.acpAgentId ?? params.spec.agentId,
mode: params.spec.mode,
cwd: params.spec.cwd,
backendId: params.spec.backend,
});
return {
ok: true,
sessionKey,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logVerbose(
`acp-persistent-binding: failed ensuring ${params.spec.channel}:${params.spec.accountId}:${params.spec.conversationId} -> ${sessionKey}: ${message}`,
);
return {
ok: false,
sessionKey,
error: message,
};
}
}
export async function resetAcpSessionInPlace(params: {
cfg: OpenClawConfig;
sessionKey: string;
reason: "new" | "reset";
}): Promise<{ ok: true } | { ok: false; skipped?: boolean; error?: string }> {
const sessionKey = params.sessionKey.trim();
if (!sessionKey) {
return {
ok: false,
skipped: true,
};
}
const configuredBinding = resolveConfiguredAcpBindingSpecBySessionKey({
cfg: params.cfg,
sessionKey,
});
const meta = readAcpSessionEntry({
cfg: params.cfg,
sessionKey,
})?.acp;
if (!meta) {
if (configuredBinding) {
const ensured = await ensureConfiguredAcpBindingSession({
cfg: params.cfg,
spec: configuredBinding,
});
if (ensured.ok) {
return { ok: true };
}
return {
ok: false,
error: ensured.error,
};
}
return {
ok: false,
skipped: true,
};
}
const acpManager = getAcpSessionManager();
const agent =
normalizeText(meta.agent) ??
configuredBinding?.acpAgentId ??
configuredBinding?.agentId ??
resolveAcpAgentFromSessionKey(sessionKey, "main");
const mode = meta.mode === "oneshot" ? "oneshot" : "persistent";
const runtimeOptions = { ...meta.runtimeOptions };
const cwd = normalizeText(runtimeOptions.cwd ?? meta.cwd);
try {
await acpManager.closeSession({
cfg: params.cfg,
sessionKey,
reason: `${params.reason}-in-place-reset`,
clearMeta: false,
allowBackendUnavailable: true,
requireAcpSession: false,
});
await acpManager.initializeSession({
cfg: params.cfg,
sessionKey,
agent,
mode,
cwd,
backendId: normalizeText(meta.backend) ?? normalizeText(params.cfg.acp?.backend),
});
const runtimeOptionsPatch = Object.fromEntries(
Object.entries(runtimeOptions).filter(([, value]) => value !== undefined),
) as SessionAcpMeta["runtimeOptions"];
if (runtimeOptionsPatch && Object.keys(runtimeOptionsPatch).length > 0) {
await acpManager.updateSessionRuntimeOptions({
cfg: params.cfg,
sessionKey,
patch: runtimeOptionsPatch,
});
}
return { ok: true };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logVerbose(`acp-persistent-binding: failed reset for ${sessionKey}: ${message}`);
return {
ok: false,
error: message,
};
}
}

View File

@@ -0,0 +1,341 @@
import { listAcpBindings } from "../config/bindings.js";
import type { OpenClawConfig } from "../config/config.js";
import type { AgentAcpBinding } from "../config/types.js";
import { pickFirstExistingAgentId } from "../routing/resolve-route.js";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
parseAgentSessionKey,
} from "../routing/session-key.js";
import { parseTelegramTopicConversation } from "./conversation-id.js";
import {
buildConfiguredAcpSessionKey,
normalizeBindingConfig,
normalizeMode,
normalizeText,
toConfiguredAcpBindingRecord,
type ConfiguredAcpBindingChannel,
type ConfiguredAcpBindingSpec,
type ResolvedConfiguredAcpBinding,
} from "./persistent-bindings.types.js";
function normalizeBindingChannel(value: string | undefined): ConfiguredAcpBindingChannel | null {
const normalized = (value ?? "").trim().toLowerCase();
if (normalized === "discord" || normalized === "telegram") {
return normalized;
}
return null;
}
function resolveAccountMatchPriority(match: string | undefined, actual: string): 0 | 1 | 2 {
const trimmed = (match ?? "").trim();
if (!trimmed) {
return actual === DEFAULT_ACCOUNT_ID ? 2 : 0;
}
if (trimmed === "*") {
return 1;
}
return normalizeAccountId(trimmed) === actual ? 2 : 0;
}
function resolveBindingConversationId(binding: AgentAcpBinding): string | null {
const id = binding.match.peer?.id?.trim();
return id ? id : null;
}
function parseConfiguredBindingSessionKey(params: {
sessionKey: string;
}): { channel: ConfiguredAcpBindingChannel; accountId: string } | null {
const parsed = parseAgentSessionKey(params.sessionKey);
const rest = parsed?.rest?.trim().toLowerCase() ?? "";
if (!rest) {
return null;
}
const tokens = rest.split(":");
if (tokens.length !== 5 || tokens[0] !== "acp" || tokens[1] !== "binding") {
return null;
}
const channel = normalizeBindingChannel(tokens[2]);
if (!channel) {
return null;
}
const accountId = normalizeAccountId(tokens[3]);
return {
channel,
accountId,
};
}
function resolveAgentRuntimeAcpDefaults(params: { cfg: OpenClawConfig; ownerAgentId: string }): {
acpAgentId?: string;
mode?: string;
cwd?: string;
backend?: string;
} {
const agent = params.cfg.agents?.list?.find(
(entry) => entry.id?.trim().toLowerCase() === params.ownerAgentId.toLowerCase(),
);
if (!agent || agent.runtime?.type !== "acp") {
return {};
}
return {
acpAgentId: normalizeText(agent.runtime.acp?.agent),
mode: normalizeText(agent.runtime.acp?.mode),
cwd: normalizeText(agent.runtime.acp?.cwd),
backend: normalizeText(agent.runtime.acp?.backend),
};
}
function toConfiguredBindingSpec(params: {
cfg: OpenClawConfig;
channel: ConfiguredAcpBindingChannel;
accountId: string;
conversationId: string;
parentConversationId?: string;
binding: AgentAcpBinding;
}): ConfiguredAcpBindingSpec {
const accountId = normalizeAccountId(params.accountId);
const agentId = pickFirstExistingAgentId(params.cfg, params.binding.agentId ?? "main");
const runtimeDefaults = resolveAgentRuntimeAcpDefaults({
cfg: params.cfg,
ownerAgentId: agentId,
});
const bindingOverrides = normalizeBindingConfig(params.binding.acp);
const acpAgentId = normalizeText(runtimeDefaults.acpAgentId);
const mode = normalizeMode(bindingOverrides.mode ?? runtimeDefaults.mode);
return {
channel: params.channel,
accountId,
conversationId: params.conversationId,
parentConversationId: params.parentConversationId,
agentId,
acpAgentId,
mode,
cwd: bindingOverrides.cwd ?? runtimeDefaults.cwd,
backend: bindingOverrides.backend ?? runtimeDefaults.backend,
label: bindingOverrides.label,
};
}
export function resolveConfiguredAcpBindingSpecBySessionKey(params: {
cfg: OpenClawConfig;
sessionKey: string;
}): ConfiguredAcpBindingSpec | null {
const sessionKey = params.sessionKey.trim();
if (!sessionKey) {
return null;
}
const parsedSessionKey = parseConfiguredBindingSessionKey({ sessionKey });
if (!parsedSessionKey) {
return null;
}
let wildcardMatch: ConfiguredAcpBindingSpec | null = null;
for (const binding of listAcpBindings(params.cfg)) {
const channel = normalizeBindingChannel(binding.match.channel);
if (!channel || channel !== parsedSessionKey.channel) {
continue;
}
const accountMatchPriority = resolveAccountMatchPriority(
binding.match.accountId,
parsedSessionKey.accountId,
);
if (accountMatchPriority === 0) {
continue;
}
const targetConversationId = resolveBindingConversationId(binding);
if (!targetConversationId) {
continue;
}
if (channel === "discord") {
const spec = toConfiguredBindingSpec({
cfg: params.cfg,
channel: "discord",
accountId: parsedSessionKey.accountId,
conversationId: targetConversationId,
binding,
});
if (buildConfiguredAcpSessionKey(spec) === sessionKey) {
if (accountMatchPriority === 2) {
return spec;
}
if (!wildcardMatch) {
wildcardMatch = spec;
}
}
continue;
}
const parsedTopic = parseTelegramTopicConversation({
conversationId: targetConversationId,
});
if (!parsedTopic || !parsedTopic.chatId.startsWith("-")) {
continue;
}
const spec = toConfiguredBindingSpec({
cfg: params.cfg,
channel: "telegram",
accountId: parsedSessionKey.accountId,
conversationId: parsedTopic.canonicalConversationId,
parentConversationId: parsedTopic.chatId,
binding,
});
if (buildConfiguredAcpSessionKey(spec) === sessionKey) {
if (accountMatchPriority === 2) {
return spec;
}
if (!wildcardMatch) {
wildcardMatch = spec;
}
}
}
return wildcardMatch;
}
export function resolveConfiguredAcpBindingRecord(params: {
cfg: OpenClawConfig;
channel: string;
accountId: string;
conversationId: string;
parentConversationId?: string;
}): ResolvedConfiguredAcpBinding | null {
const channel = params.channel.trim().toLowerCase();
const accountId = normalizeAccountId(params.accountId);
const conversationId = params.conversationId.trim();
const parentConversationId = params.parentConversationId?.trim() || undefined;
if (!conversationId) {
return null;
}
if (channel === "discord") {
const bindings = listAcpBindings(params.cfg);
const resolveDiscordBindingForConversation = (
targetConversationId: string,
): ResolvedConfiguredAcpBinding | null => {
let wildcardMatch: AgentAcpBinding | null = null;
for (const binding of bindings) {
if (normalizeBindingChannel(binding.match.channel) !== "discord") {
continue;
}
const accountMatchPriority = resolveAccountMatchPriority(
binding.match.accountId,
accountId,
);
if (accountMatchPriority === 0) {
continue;
}
const bindingConversationId = resolveBindingConversationId(binding);
if (!bindingConversationId || bindingConversationId !== targetConversationId) {
continue;
}
if (accountMatchPriority === 2) {
const spec = toConfiguredBindingSpec({
cfg: params.cfg,
channel: "discord",
accountId,
conversationId: targetConversationId,
binding,
});
return {
spec,
record: toConfiguredAcpBindingRecord(spec),
};
}
if (!wildcardMatch) {
wildcardMatch = binding;
}
}
if (wildcardMatch) {
const spec = toConfiguredBindingSpec({
cfg: params.cfg,
channel: "discord",
accountId,
conversationId: targetConversationId,
binding: wildcardMatch,
});
return {
spec,
record: toConfiguredAcpBindingRecord(spec),
};
}
return null;
};
const directMatch = resolveDiscordBindingForConversation(conversationId);
if (directMatch) {
return directMatch;
}
if (parentConversationId && parentConversationId !== conversationId) {
const inheritedMatch = resolveDiscordBindingForConversation(parentConversationId);
if (inheritedMatch) {
return inheritedMatch;
}
}
return null;
}
if (channel === "telegram") {
const parsed = parseTelegramTopicConversation({
conversationId,
parentConversationId,
});
if (!parsed || !parsed.chatId.startsWith("-")) {
return null;
}
let wildcardMatch: AgentAcpBinding | null = null;
for (const binding of listAcpBindings(params.cfg)) {
if (normalizeBindingChannel(binding.match.channel) !== "telegram") {
continue;
}
const accountMatchPriority = resolveAccountMatchPriority(binding.match.accountId, accountId);
if (accountMatchPriority === 0) {
continue;
}
const targetConversationId = resolveBindingConversationId(binding);
if (!targetConversationId) {
continue;
}
const targetParsed = parseTelegramTopicConversation({
conversationId: targetConversationId,
});
if (!targetParsed || !targetParsed.chatId.startsWith("-")) {
continue;
}
if (targetParsed.canonicalConversationId !== parsed.canonicalConversationId) {
continue;
}
if (accountMatchPriority === 2) {
const spec = toConfiguredBindingSpec({
cfg: params.cfg,
channel: "telegram",
accountId,
conversationId: parsed.canonicalConversationId,
parentConversationId: parsed.chatId,
binding,
});
return {
spec,
record: toConfiguredAcpBindingRecord(spec),
};
}
if (!wildcardMatch) {
wildcardMatch = binding;
}
}
if (wildcardMatch) {
const spec = toConfiguredBindingSpec({
cfg: params.cfg,
channel: "telegram",
accountId,
conversationId: parsed.canonicalConversationId,
parentConversationId: parsed.chatId,
binding: wildcardMatch,
});
return {
spec,
record: toConfiguredAcpBindingRecord(spec),
};
}
return null;
}
return null;
}

View File

@@ -0,0 +1,76 @@
import type { OpenClawConfig } from "../config/config.js";
import type { ResolvedAgentRoute } from "../routing/resolve-route.js";
import { resolveAgentIdFromSessionKey } from "../routing/session-key.js";
import {
ensureConfiguredAcpBindingSession,
resolveConfiguredAcpBindingRecord,
type ConfiguredAcpBindingChannel,
type ResolvedConfiguredAcpBinding,
} from "./persistent-bindings.js";
export function resolveConfiguredAcpRoute(params: {
cfg: OpenClawConfig;
route: ResolvedAgentRoute;
channel: ConfiguredAcpBindingChannel;
accountId: string;
conversationId: string;
parentConversationId?: string;
}): {
configuredBinding: ResolvedConfiguredAcpBinding | null;
route: ResolvedAgentRoute;
boundSessionKey?: string;
boundAgentId?: string;
} {
const configuredBinding = resolveConfiguredAcpBindingRecord({
cfg: params.cfg,
channel: params.channel,
accountId: params.accountId,
conversationId: params.conversationId,
parentConversationId: params.parentConversationId,
});
if (!configuredBinding) {
return {
configuredBinding: null,
route: params.route,
};
}
const boundSessionKey = configuredBinding.record.targetSessionKey?.trim() ?? "";
if (!boundSessionKey) {
return {
configuredBinding,
route: params.route,
};
}
const boundAgentId = resolveAgentIdFromSessionKey(boundSessionKey) || params.route.agentId;
return {
configuredBinding,
boundSessionKey,
boundAgentId,
route: {
...params.route,
sessionKey: boundSessionKey,
agentId: boundAgentId,
matchedBy: "binding.channel",
},
};
}
export async function ensureConfiguredAcpRouteReady(params: {
cfg: OpenClawConfig;
configuredBinding: ResolvedConfiguredAcpBinding | null;
}): Promise<{ ok: true } | { ok: false; error: string }> {
if (!params.configuredBinding) {
return { ok: true };
}
const ensured = await ensureConfiguredAcpBindingSession({
cfg: params.cfg,
spec: params.configuredBinding.spec,
});
if (ensured.ok) {
return { ok: true };
}
return {
ok: false,
error: ensured.error ?? "unknown error",
};
}

View File

@@ -0,0 +1,639 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
const managerMocks = vi.hoisted(() => ({
resolveSession: vi.fn(),
closeSession: vi.fn(),
initializeSession: vi.fn(),
updateSessionRuntimeOptions: vi.fn(),
}));
const sessionMetaMocks = vi.hoisted(() => ({
readAcpSessionEntry: vi.fn(),
}));
vi.mock("./control-plane/manager.js", () => ({
getAcpSessionManager: () => ({
resolveSession: managerMocks.resolveSession,
closeSession: managerMocks.closeSession,
initializeSession: managerMocks.initializeSession,
updateSessionRuntimeOptions: managerMocks.updateSessionRuntimeOptions,
}),
}));
vi.mock("./runtime/session-meta.js", () => ({
readAcpSessionEntry: sessionMetaMocks.readAcpSessionEntry,
}));
import {
buildConfiguredAcpSessionKey,
ensureConfiguredAcpBindingSession,
resetAcpSessionInPlace,
resolveConfiguredAcpBindingRecord,
resolveConfiguredAcpBindingSpecBySessionKey,
} from "./persistent-bindings.js";
const baseCfg = {
session: { mainKey: "main", scope: "per-sender" },
agents: {
list: [{ id: "codex" }, { id: "claude" }],
},
} satisfies OpenClawConfig;
beforeEach(() => {
managerMocks.resolveSession.mockReset();
managerMocks.closeSession.mockReset().mockResolvedValue({
runtimeClosed: true,
metaCleared: true,
});
managerMocks.initializeSession.mockReset().mockResolvedValue(undefined);
managerMocks.updateSessionRuntimeOptions.mockReset().mockResolvedValue(undefined);
sessionMetaMocks.readAcpSessionEntry.mockReset().mockReturnValue(undefined);
});
describe("resolveConfiguredAcpBindingRecord", () => {
it("resolves discord channel ACP binding from top-level typed bindings", () => {
const cfg = {
...baseCfg,
bindings: [
{
type: "acp",
agentId: "codex",
match: {
channel: "discord",
accountId: "default",
peer: { kind: "channel", id: "1478836151241412759" },
},
acp: {
cwd: "/repo/openclaw",
},
},
],
} satisfies OpenClawConfig;
const resolved = resolveConfiguredAcpBindingRecord({
cfg,
channel: "discord",
accountId: "default",
conversationId: "1478836151241412759",
});
expect(resolved?.spec.channel).toBe("discord");
expect(resolved?.spec.conversationId).toBe("1478836151241412759");
expect(resolved?.spec.agentId).toBe("codex");
expect(resolved?.record.targetSessionKey).toContain("agent:codex:acp:binding:discord:default:");
expect(resolved?.record.metadata?.source).toBe("config");
});
it("falls back to parent discord channel when conversation is a thread id", () => {
const cfg = {
...baseCfg,
bindings: [
{
type: "acp",
agentId: "codex",
match: {
channel: "discord",
accountId: "default",
peer: { kind: "channel", id: "channel-parent-1" },
},
},
],
} satisfies OpenClawConfig;
const resolved = resolveConfiguredAcpBindingRecord({
cfg,
channel: "discord",
accountId: "default",
conversationId: "thread-123",
parentConversationId: "channel-parent-1",
});
expect(resolved?.spec.conversationId).toBe("channel-parent-1");
expect(resolved?.record.conversation.conversationId).toBe("channel-parent-1");
});
it("prefers direct discord thread binding over parent channel fallback", () => {
const cfg = {
...baseCfg,
bindings: [
{
type: "acp",
agentId: "codex",
match: {
channel: "discord",
accountId: "default",
peer: { kind: "channel", id: "channel-parent-1" },
},
},
{
type: "acp",
agentId: "claude",
match: {
channel: "discord",
accountId: "default",
peer: { kind: "channel", id: "thread-123" },
},
},
],
} satisfies OpenClawConfig;
const resolved = resolveConfiguredAcpBindingRecord({
cfg,
channel: "discord",
accountId: "default",
conversationId: "thread-123",
parentConversationId: "channel-parent-1",
});
expect(resolved?.spec.conversationId).toBe("thread-123");
expect(resolved?.spec.agentId).toBe("claude");
});
it("prefers exact account binding over wildcard for the same discord conversation", () => {
const cfg = {
...baseCfg,
bindings: [
{
type: "acp",
agentId: "codex",
match: {
channel: "discord",
accountId: "*",
peer: { kind: "channel", id: "1478836151241412759" },
},
},
{
type: "acp",
agentId: "claude",
match: {
channel: "discord",
accountId: "default",
peer: { kind: "channel", id: "1478836151241412759" },
},
},
],
} satisfies OpenClawConfig;
const resolved = resolveConfiguredAcpBindingRecord({
cfg,
channel: "discord",
accountId: "default",
conversationId: "1478836151241412759",
});
expect(resolved?.spec.agentId).toBe("claude");
});
it("returns null when no top-level ACP binding matches the conversation", () => {
const cfg = {
...baseCfg,
bindings: [
{
type: "acp",
agentId: "codex",
match: {
channel: "discord",
accountId: "default",
peer: { kind: "channel", id: "different-channel" },
},
},
],
} satisfies OpenClawConfig;
const resolved = resolveConfiguredAcpBindingRecord({
cfg,
channel: "discord",
accountId: "default",
conversationId: "thread-123",
parentConversationId: "channel-parent-1",
});
expect(resolved).toBeNull();
});
it("resolves telegram forum topic bindings using canonical conversation ids", () => {
const cfg = {
...baseCfg,
bindings: [
{
type: "acp",
agentId: "claude",
match: {
channel: "telegram",
accountId: "default",
peer: { kind: "group", id: "-1001234567890:topic:42" },
},
acp: {
backend: "acpx",
},
},
],
} satisfies OpenClawConfig;
const canonical = resolveConfiguredAcpBindingRecord({
cfg,
channel: "telegram",
accountId: "default",
conversationId: "-1001234567890:topic:42",
});
const splitIds = resolveConfiguredAcpBindingRecord({
cfg,
channel: "telegram",
accountId: "default",
conversationId: "42",
parentConversationId: "-1001234567890",
});
expect(canonical?.spec.conversationId).toBe("-1001234567890:topic:42");
expect(splitIds?.spec.conversationId).toBe("-1001234567890:topic:42");
expect(canonical?.spec.agentId).toBe("claude");
expect(canonical?.spec.backend).toBe("acpx");
expect(splitIds?.record.targetSessionKey).toBe(canonical?.record.targetSessionKey);
});
it("skips telegram non-group topic configs", () => {
const cfg = {
...baseCfg,
bindings: [
{
type: "acp",
agentId: "claude",
match: {
channel: "telegram",
accountId: "default",
peer: { kind: "group", id: "123456789:topic:42" },
},
},
],
} satisfies OpenClawConfig;
const resolved = resolveConfiguredAcpBindingRecord({
cfg,
channel: "telegram",
accountId: "default",
conversationId: "123456789:topic:42",
});
expect(resolved).toBeNull();
});
it("applies agent runtime ACP defaults for bound conversations", () => {
const cfg = {
...baseCfg,
agents: {
list: [
{ id: "main" },
{
id: "coding",
runtime: {
type: "acp",
acp: {
agent: "codex",
backend: "acpx",
mode: "oneshot",
cwd: "/workspace/repo-a",
},
},
},
],
},
bindings: [
{
type: "acp",
agentId: "coding",
match: {
channel: "discord",
accountId: "default",
peer: { kind: "channel", id: "1478836151241412759" },
},
},
],
} satisfies OpenClawConfig;
const resolved = resolveConfiguredAcpBindingRecord({
cfg,
channel: "discord",
accountId: "default",
conversationId: "1478836151241412759",
});
expect(resolved?.spec.agentId).toBe("coding");
expect(resolved?.spec.acpAgentId).toBe("codex");
expect(resolved?.spec.mode).toBe("oneshot");
expect(resolved?.spec.cwd).toBe("/workspace/repo-a");
expect(resolved?.spec.backend).toBe("acpx");
});
});
describe("resolveConfiguredAcpBindingSpecBySessionKey", () => {
it("maps a configured discord binding session key back to its spec", () => {
const cfg = {
...baseCfg,
bindings: [
{
type: "acp",
agentId: "codex",
match: {
channel: "discord",
accountId: "default",
peer: { kind: "channel", id: "1478836151241412759" },
},
acp: {
backend: "acpx",
},
},
],
} satisfies OpenClawConfig;
const resolved = resolveConfiguredAcpBindingRecord({
cfg,
channel: "discord",
accountId: "default",
conversationId: "1478836151241412759",
});
const spec = resolveConfiguredAcpBindingSpecBySessionKey({
cfg,
sessionKey: resolved?.record.targetSessionKey ?? "",
});
expect(spec?.channel).toBe("discord");
expect(spec?.conversationId).toBe("1478836151241412759");
expect(spec?.agentId).toBe("codex");
expect(spec?.backend).toBe("acpx");
});
it("returns null for unknown session keys", () => {
const spec = resolveConfiguredAcpBindingSpecBySessionKey({
cfg: baseCfg,
sessionKey: "agent:main:acp:binding:discord:default:notfound",
});
expect(spec).toBeNull();
});
it("prefers exact account ACP settings over wildcard when session keys collide", () => {
const cfg = {
...baseCfg,
bindings: [
{
type: "acp",
agentId: "codex",
match: {
channel: "discord",
accountId: "*",
peer: { kind: "channel", id: "1478836151241412759" },
},
acp: {
backend: "wild",
},
},
{
type: "acp",
agentId: "codex",
match: {
channel: "discord",
accountId: "default",
peer: { kind: "channel", id: "1478836151241412759" },
},
acp: {
backend: "exact",
},
},
],
} satisfies OpenClawConfig;
const resolved = resolveConfiguredAcpBindingRecord({
cfg,
channel: "discord",
accountId: "default",
conversationId: "1478836151241412759",
});
const spec = resolveConfiguredAcpBindingSpecBySessionKey({
cfg,
sessionKey: resolved?.record.targetSessionKey ?? "",
});
expect(spec?.backend).toBe("exact");
});
});
describe("buildConfiguredAcpSessionKey", () => {
it("is deterministic for the same conversation binding", () => {
const sessionKeyA = buildConfiguredAcpSessionKey({
channel: "discord",
accountId: "default",
conversationId: "1478836151241412759",
agentId: "codex",
mode: "persistent",
});
const sessionKeyB = buildConfiguredAcpSessionKey({
channel: "discord",
accountId: "default",
conversationId: "1478836151241412759",
agentId: "codex",
mode: "persistent",
});
expect(sessionKeyA).toBe(sessionKeyB);
});
});
describe("ensureConfiguredAcpBindingSession", () => {
it("keeps an existing ready session when configured binding omits cwd", async () => {
const spec = {
channel: "discord" as const,
accountId: "default",
conversationId: "1478836151241412759",
agentId: "codex",
mode: "persistent" as const,
};
const sessionKey = buildConfiguredAcpSessionKey(spec);
managerMocks.resolveSession.mockReturnValue({
kind: "ready",
sessionKey,
meta: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "existing",
mode: "persistent",
runtimeOptions: { cwd: "/workspace/openclaw" },
state: "idle",
lastActivityAt: Date.now(),
},
});
const ensured = await ensureConfiguredAcpBindingSession({
cfg: baseCfg,
spec,
});
expect(ensured).toEqual({ ok: true, sessionKey });
expect(managerMocks.closeSession).not.toHaveBeenCalled();
expect(managerMocks.initializeSession).not.toHaveBeenCalled();
});
it("reinitializes a ready session when binding config explicitly sets mismatched cwd", async () => {
const spec = {
channel: "discord" as const,
accountId: "default",
conversationId: "1478836151241412759",
agentId: "codex",
mode: "persistent" as const,
cwd: "/workspace/repo-a",
};
const sessionKey = buildConfiguredAcpSessionKey(spec);
managerMocks.resolveSession.mockReturnValue({
kind: "ready",
sessionKey,
meta: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "existing",
mode: "persistent",
runtimeOptions: { cwd: "/workspace/other-repo" },
state: "idle",
lastActivityAt: Date.now(),
},
});
const ensured = await ensureConfiguredAcpBindingSession({
cfg: baseCfg,
spec,
});
expect(ensured).toEqual({ ok: true, sessionKey });
expect(managerMocks.closeSession).toHaveBeenCalledTimes(1);
expect(managerMocks.closeSession).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey,
clearMeta: false,
}),
);
expect(managerMocks.initializeSession).toHaveBeenCalledTimes(1);
});
it("initializes ACP session with runtime agent override when provided", async () => {
const spec = {
channel: "discord" as const,
accountId: "default",
conversationId: "1478836151241412759",
agentId: "coding",
acpAgentId: "codex",
mode: "persistent" as const,
};
managerMocks.resolveSession.mockReturnValue({ kind: "none" });
const ensured = await ensureConfiguredAcpBindingSession({
cfg: baseCfg,
spec,
});
expect(ensured.ok).toBe(true);
expect(managerMocks.initializeSession).toHaveBeenCalledWith(
expect.objectContaining({
agent: "codex",
}),
);
});
});
describe("resetAcpSessionInPlace", () => {
it("reinitializes from configured binding when ACP metadata is missing", async () => {
const cfg = {
...baseCfg,
bindings: [
{
type: "acp",
agentId: "claude",
match: {
channel: "discord",
accountId: "default",
peer: { kind: "channel", id: "1478844424791396446" },
},
acp: {
mode: "persistent",
backend: "acpx",
},
},
],
} satisfies OpenClawConfig;
const sessionKey = buildConfiguredAcpSessionKey({
channel: "discord",
accountId: "default",
conversationId: "1478844424791396446",
agentId: "claude",
mode: "persistent",
backend: "acpx",
});
managerMocks.resolveSession.mockReturnValue({ kind: "none" });
const result = await resetAcpSessionInPlace({
cfg,
sessionKey,
reason: "new",
});
expect(result).toEqual({ ok: true });
expect(managerMocks.initializeSession).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey,
agent: "claude",
mode: "persistent",
backendId: "acpx",
}),
);
});
it("does not clear ACP metadata before reinitialize succeeds", async () => {
const sessionKey = "agent:claude:acp:binding:discord:default:9373ab192b2317f4";
sessionMetaMocks.readAcpSessionEntry.mockReturnValue({
acp: {
agent: "claude",
mode: "persistent",
backend: "acpx",
runtimeOptions: { cwd: "/home/bob/clawd" },
},
});
managerMocks.initializeSession.mockRejectedValueOnce(new Error("backend unavailable"));
const result = await resetAcpSessionInPlace({
cfg: baseCfg,
sessionKey,
reason: "reset",
});
expect(result).toEqual({ ok: false, error: "backend unavailable" });
expect(managerMocks.closeSession).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey,
clearMeta: false,
}),
);
});
it("preserves harness agent ids during in-place reset even when not in agents.list", async () => {
const cfg = {
...baseCfg,
agents: {
list: [{ id: "main" }, { id: "coding" }],
},
} satisfies OpenClawConfig;
const sessionKey = "agent:coding:acp:binding:discord:default:9373ab192b2317f4";
sessionMetaMocks.readAcpSessionEntry.mockReturnValue({
acp: {
agent: "codex",
mode: "persistent",
backend: "acpx",
},
});
const result = await resetAcpSessionInPlace({
cfg,
sessionKey,
reason: "reset",
});
expect(result).toEqual({ ok: true });
expect(managerMocks.initializeSession).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey,
agent: "codex",
}),
);
});
});

View File

@@ -0,0 +1,19 @@
export {
buildConfiguredAcpSessionKey,
normalizeBindingConfig,
normalizeMode,
normalizeText,
toConfiguredAcpBindingRecord,
type AcpBindingConfigShape,
type ConfiguredAcpBindingChannel,
type ConfiguredAcpBindingSpec,
type ResolvedConfiguredAcpBinding,
} from "./persistent-bindings.types.js";
export {
ensureConfiguredAcpBindingSession,
resetAcpSessionInPlace,
} from "./persistent-bindings.lifecycle.js";
export {
resolveConfiguredAcpBindingRecord,
resolveConfiguredAcpBindingSpecBySessionKey,
} from "./persistent-bindings.resolve.js";

View File

@@ -0,0 +1,105 @@
import { createHash } from "node:crypto";
import type { SessionBindingRecord } from "../infra/outbound/session-binding-service.js";
import { sanitizeAgentId } from "../routing/session-key.js";
import type { AcpRuntimeSessionMode } from "./runtime/types.js";
export type ConfiguredAcpBindingChannel = "discord" | "telegram";
export type ConfiguredAcpBindingSpec = {
channel: ConfiguredAcpBindingChannel;
accountId: string;
conversationId: string;
parentConversationId?: string;
/** Owning OpenClaw agent id (used for session identity/storage). */
agentId: string;
/** ACP harness agent id override (falls back to agentId when omitted). */
acpAgentId?: string;
mode: AcpRuntimeSessionMode;
cwd?: string;
backend?: string;
label?: string;
};
export type ResolvedConfiguredAcpBinding = {
spec: ConfiguredAcpBindingSpec;
record: SessionBindingRecord;
};
export type AcpBindingConfigShape = {
mode?: string;
cwd?: string;
backend?: string;
label?: string;
};
export function normalizeText(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed || undefined;
}
export function normalizeMode(value: unknown): AcpRuntimeSessionMode {
const raw = normalizeText(value)?.toLowerCase();
return raw === "oneshot" ? "oneshot" : "persistent";
}
export function normalizeBindingConfig(raw: unknown): AcpBindingConfigShape {
if (!raw || typeof raw !== "object") {
return {};
}
const shape = raw as AcpBindingConfigShape;
const mode = normalizeText(shape.mode);
return {
mode: mode ? normalizeMode(mode) : undefined,
cwd: normalizeText(shape.cwd),
backend: normalizeText(shape.backend),
label: normalizeText(shape.label),
};
}
function buildBindingHash(params: {
channel: ConfiguredAcpBindingChannel;
accountId: string;
conversationId: string;
}): string {
return createHash("sha256")
.update(`${params.channel}:${params.accountId}:${params.conversationId}`)
.digest("hex")
.slice(0, 16);
}
export function buildConfiguredAcpSessionKey(spec: ConfiguredAcpBindingSpec): string {
const hash = buildBindingHash({
channel: spec.channel,
accountId: spec.accountId,
conversationId: spec.conversationId,
});
return `agent:${sanitizeAgentId(spec.agentId)}:acp:binding:${spec.channel}:${spec.accountId}:${hash}`;
}
export function toConfiguredAcpBindingRecord(spec: ConfiguredAcpBindingSpec): SessionBindingRecord {
return {
bindingId: `config:acp:${spec.channel}:${spec.accountId}:${spec.conversationId}`,
targetSessionKey: buildConfiguredAcpSessionKey(spec),
targetKind: "session",
conversation: {
channel: spec.channel,
accountId: spec.accountId,
conversationId: spec.conversationId,
parentConversationId: spec.parentConversationId,
},
status: "active",
boundAt: 0,
metadata: {
source: "config",
mode: spec.mode,
agentId: spec.agentId,
...(spec.acpAgentId ? { acpAgentId: spec.acpAgentId } : {}),
label: spec.label,
...(spec.backend ? { backend: spec.backend } : {}),
...(spec.cwd ? { cwd: spec.cwd } : {}),
},
};
}