mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 11:07:41 +00:00
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:
80
src/acp/conversation-id.ts
Normal file
80
src/acp/conversation-id.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
198
src/acp/persistent-bindings.lifecycle.ts
Normal file
198
src/acp/persistent-bindings.lifecycle.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
341
src/acp/persistent-bindings.resolve.ts
Normal file
341
src/acp/persistent-bindings.resolve.ts
Normal 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;
|
||||
}
|
||||
76
src/acp/persistent-bindings.route.ts
Normal file
76
src/acp/persistent-bindings.route.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
639
src/acp/persistent-bindings.test.ts
Normal file
639
src/acp/persistent-bindings.test.ts
Normal 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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
19
src/acp/persistent-bindings.ts
Normal file
19
src/acp/persistent-bindings.ts
Normal 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";
|
||||
105
src/acp/persistent-bindings.types.ts
Normal file
105
src/acp/persistent-bindings.types.ts
Normal 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 } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user