refactor(acp): centralize conversation binding context

This commit is contained in:
Peter Steinberger
2026-03-28 03:46:17 +00:00
parent 09e35e69b2
commit d0d4b73d25
5 changed files with 505 additions and 107 deletions

View File

@@ -3,7 +3,7 @@ summary: "Use ACP runtime sessions for Codex, Claude Code, Cursor, Gemini CLI, O
read_when:
- Running coding harnesses through ACP
- Setting up conversation-bound ACP sessions on messaging channels
- Binding Discord channels, Telegram topics, BlueBubbles chats, or iMessage chats to persistent ACP sessions
- Binding a message channel conversation to a persistent ACP session
- Troubleshooting ACP backend and plugin wiring
- Operating /acp commands from chat
title: "ACP Agents"
@@ -104,12 +104,11 @@ Examples:
- `/acp spawn codex --thread auto`: OpenClaw may create a child thread/topic and bind the ACP session there
- `/acp spawn codex --bind here --cwd /workspace/repo`: same chat binding as above, but Codex runs in `/workspace/repo`
Built-in current-conversation binding support:
Current-conversation binding support:
- Discord current channel or current thread
- Telegram current chat or current topic
- BlueBubbles DM or group chat
- iMessage DM or group chat
- Any message channel can use `--bind here` through the shared conversation-binding path.
- Channels with custom thread/topic semantics can still provide channel-specific canonicalization behind the same shared interface.
- `--bind here` always means "bind the current conversation in place"; it does not require a per-channel ACP adapter anymore.
Notes:

View File

@@ -1,6 +1,5 @@
import { normalizeConversationText } from "../../../acp/conversation-id.js";
import { resolveChannelConfiguredBindingProviderByChannel } from "../../../channels/plugins/binding-provider.js";
import { resolveConversationIdFromTargets } from "../../../infra/outbound/conversation-id.js";
import { resolveConversationBindingContext } from "../../../channels/conversation-binding-context.js";
import type { HandleCommandsParams } from "../commands-types.js";
export function resolveAcpCommandChannel(params: HandleCommandsParams): string {
@@ -29,40 +28,29 @@ function resolveAcpCommandConversationRef(params: HandleCommandsParams): {
conversationId: string;
parentConversationId?: string;
} | null {
const channel = resolveAcpCommandChannel(params);
const threadId = resolveAcpCommandThreadId(params);
const provider = resolveChannelConfiguredBindingProviderByChannel(channel);
const resolvedByProvider = provider?.resolveCommandConversation?.({
const resolved = resolveConversationBindingContext({
cfg: params.cfg,
channel: resolveAcpCommandChannel(params),
accountId: resolveAcpCommandAccountId(params),
threadId,
threadParentId: normalizeConversationText(params.ctx.ThreadParentId),
senderId: normalizeConversationText(params.command.senderId ?? params.ctx.SenderId),
chatType: params.ctx.ChatType,
threadId: resolveAcpCommandThreadId(params),
threadParentId: params.ctx.ThreadParentId,
senderId: params.command.senderId ?? params.ctx.SenderId,
sessionKey: params.sessionKey,
parentSessionKey: normalizeConversationText(params.ctx.ParentSessionKey),
parentSessionKey: params.ctx.ParentSessionKey,
originatingTo: params.ctx.OriginatingTo,
commandTo: params.command.to,
fallbackTo: params.ctx.To,
from: params.ctx.From,
nativeChannelId: params.ctx.NativeChannelId,
});
if (resolvedByProvider?.conversationId) {
return resolvedByProvider;
}
const targets = [params.ctx.OriginatingTo, params.command.to, params.ctx.To];
const conversationId = resolveConversationIdFromTargets({
threadId,
targets,
});
if (!conversationId) {
if (!resolved) {
return null;
}
const parentConversationId = threadId
? resolveConversationIdFromTargets({
targets,
})
: undefined;
return {
conversationId,
...(parentConversationId && parentConversationId !== conversationId
? { parentConversationId }
conversationId: resolved.conversationId,
...(resolved.parentConversationId && resolved.parentConversationId !== resolved.conversationId
? { parentConversationId: resolved.parentConversationId }
: {}),
};
}

View File

@@ -1,13 +1,10 @@
import crypto from "node:crypto";
import path from "node:path";
import {
buildTelegramTopicConversationId,
normalizeConversationText,
parseTelegramChatIdFromTarget,
} from "../../acp/conversation-id.js";
import { normalizeConversationText } from "../../acp/conversation-id.js";
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import { clearBootstrapSnapshotOnSessionRollover } from "../../agents/bootstrap-cache.js";
import { normalizeChatType } from "../../channels/chat-type.js";
import { resolveConversationBindingContext } from "../../channels/conversation-binding-context.js";
import type { OpenClawConfig } from "../../config/config.js";
import { resolveGroupSessionKey } from "../../config/sessions/group.js";
import { deriveSessionMetaPatch } from "../../config/sessions/metadata.js";
@@ -29,7 +26,7 @@ import {
type SessionScope,
} from "../../config/sessions/types.js";
import type { TtsAutoMode } from "../../config/types.tts.js";
import { resolveConversationIdFromTargets } from "../../infra/outbound/conversation-id.js";
import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js";
import { deliverSessionMaintenanceWarning } from "../../infra/session-maintenance-warning.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
@@ -39,7 +36,6 @@ import { isInternalMessageChannel } from "../../utils/message-channel.js";
import { resolveCommandAuthorization } from "../command-auth.js";
import type { MsgContext, TemplateContext } from "../templating.js";
import { resolveEffectiveResetTargetSessionKey } from "./acp-reset-target.js";
import { parseDiscordParentChannelFromSessionKey } from "./discord-parent-channel.js";
import { normalizeInboundTextNewlines } from "./inbound-text.js";
import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
import {
@@ -102,79 +98,40 @@ function isResetAuthorizedForContext(params: {
return scopes.includes("operator.admin");
}
function resolveAcpResetBindingContext(ctx: MsgContext): {
function resolveAcpResetBindingContext(
cfg: OpenClawConfig,
ctx: MsgContext,
): {
channel: string;
accountId: string;
conversationId: string;
parentConversationId?: string;
} | null {
const channelRaw = normalizeConversationText(
ctx.OriginatingChannel ?? ctx.Surface ?? ctx.Provider ?? "",
).toLowerCase();
if (!channelRaw) {
return null;
}
const accountId = normalizeConversationText(ctx.AccountId) || "default";
const normalizedThreadId =
ctx.MessageThreadId != null ? normalizeConversationText(String(ctx.MessageThreadId)) : "";
if (channelRaw === "telegram") {
const parentConversationId =
parseTelegramChatIdFromTarget(ctx.OriginatingTo) ?? parseTelegramChatIdFromTarget(ctx.To);
let conversationId =
resolveConversationIdFromTargets({
threadId: normalizedThreadId || undefined,
targets: [ctx.OriginatingTo, ctx.To],
}) ?? "";
if (normalizedThreadId && parentConversationId) {
conversationId =
buildTelegramTopicConversationId({
chatId: parentConversationId,
topicId: normalizedThreadId,
}) ?? conversationId;
}
if (!conversationId) {
return null;
}
return {
channel: channelRaw,
accountId,
conversationId,
...(parentConversationId ? { parentConversationId } : {}),
};
}
const conversationId = resolveConversationIdFromTargets({
threadId: normalizedThreadId || undefined,
targets: [ctx.OriginatingTo, ctx.To],
const bindingContext = resolveConversationBindingContext({
cfg,
channel: ctx.OriginatingChannel ?? ctx.Surface ?? ctx.Provider,
accountId: ctx.AccountId,
chatType: ctx.ChatType,
threadId: ctx.MessageThreadId,
threadParentId: ctx.ThreadParentId,
senderId: ctx.SenderId,
sessionKey: ctx.SessionKey,
parentSessionKey: ctx.ParentSessionKey,
originatingTo: ctx.OriginatingTo,
fallbackTo: ctx.To,
from: ctx.From,
nativeChannelId: ctx.NativeChannelId,
});
if (!conversationId) {
if (!bindingContext) {
return null;
}
let parentConversationId: string | undefined;
if (channelRaw === "discord" && normalizedThreadId) {
const fromContext = normalizeConversationText(ctx.ThreadParentId);
if (fromContext && fromContext !== conversationId) {
parentConversationId = fromContext;
} else {
const fromParentSession = parseDiscordParentChannelFromSessionKey(ctx.ParentSessionKey);
if (fromParentSession && fromParentSession !== conversationId) {
parentConversationId = fromParentSession;
} else {
const fromTargets = resolveConversationIdFromTargets({
targets: [ctx.OriginatingTo, ctx.To],
});
if (fromTargets && fromTargets !== conversationId) {
parentConversationId = fromTargets;
}
}
}
}
return {
channel: channelRaw,
accountId,
conversationId,
...(parentConversationId ? { parentConversationId } : {}),
channel: bindingContext.channel,
accountId: bindingContext.accountId,
conversationId: bindingContext.conversationId,
...(bindingContext.parentConversationId
? { parentConversationId: bindingContext.parentConversationId }
: {}),
};
}
@@ -183,7 +140,7 @@ function resolveBoundAcpSessionForReset(params: {
ctx: MsgContext;
}): string | undefined {
const activeSessionKey = normalizeConversationText(params.ctx.SessionKey);
const bindingContext = resolveAcpResetBindingContext(params.ctx);
const bindingContext = resolveAcpResetBindingContext(params.cfg, params.ctx);
return resolveEffectiveResetTargetSessionKey({
cfg: params.cfg,
channel: bindingContext?.channel,
@@ -197,6 +154,43 @@ function resolveBoundAcpSessionForReset(params: {
});
}
function resolveBoundConversationSessionKey(params: {
cfg: OpenClawConfig;
ctx: MsgContext;
}): string | undefined {
const bindingContext = resolveConversationBindingContext({
cfg: params.cfg,
channel: params.ctx.OriginatingChannel ?? params.ctx.Surface ?? params.ctx.Provider,
accountId: params.ctx.AccountId,
chatType: params.ctx.ChatType,
threadId: params.ctx.MessageThreadId,
threadParentId: params.ctx.ThreadParentId,
senderId: params.ctx.SenderId,
sessionKey: params.ctx.SessionKey,
parentSessionKey: params.ctx.ParentSessionKey,
originatingTo: params.ctx.OriginatingTo,
fallbackTo: params.ctx.To,
from: params.ctx.From,
nativeChannelId: params.ctx.NativeChannelId,
});
if (!bindingContext) {
return undefined;
}
const binding = getSessionBindingService().resolveByConversation({
channel: bindingContext.channel,
accountId: bindingContext.accountId,
conversationId: bindingContext.conversationId,
...(bindingContext.parentConversationId
? { parentConversationId: bindingContext.parentConversationId }
: {}),
});
if (!binding?.targetSessionKey) {
return undefined;
}
getSessionBindingService().touch(binding.bindingId);
return binding.targetSessionKey;
}
export async function initSessionState(params: {
ctx: MsgContext;
cfg: OpenClawConfig;
@@ -205,8 +199,13 @@ export async function initSessionState(params: {
const { ctx, cfg, commandAuthorized } = params;
// Native slash commands (Telegram/Discord/Slack) are delivered on a separate
// "slash session" key, but should mutate the target chat session.
const targetSessionKey =
const commandTargetSessionKey =
ctx.CommandSource === "native" ? ctx.CommandTargetSessionKey?.trim() : undefined;
const targetSessionKey =
resolveBoundConversationSessionKey({
cfg,
ctx,
}) ?? commandTargetSessionKey;
const sessionCtxForState =
targetSessionKey && targetSessionKey !== ctx.SessionKey
? { ...ctx, SessionKey: targetSessionKey }

View File

@@ -0,0 +1,213 @@
import { normalizeConversationText } from "../acp/conversation-id.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveConversationIdFromTargets } from "../infra/outbound/conversation-id.js";
import { getActivePluginChannelRegistry } from "../plugins/runtime.js";
import { parseExplicitTargetForChannel } from "./plugins/target-parsing.js";
import type { ChannelPlugin } from "./plugins/types.js";
import { normalizeAnyChannelId, normalizeChannelId } from "./registry.js";
export type ConversationBindingContext = {
channel: string;
accountId: string;
conversationId: string;
parentConversationId?: string;
threadId?: string;
};
export type ResolveConversationBindingContextInput = {
cfg: OpenClawConfig;
channel?: string | null;
accountId?: string | null;
chatType?: string | null;
threadId?: string | number | null;
threadParentId?: string | null;
senderId?: string | null;
sessionKey?: string | null;
parentSessionKey?: string | null;
originatingTo?: string | null;
commandTo?: string | null;
fallbackTo?: string | null;
from?: string | null;
nativeChannelId?: string | null;
};
const CANONICAL_TARGET_PREFIXES = [
"user:",
"channel:",
"conversation:",
"group:",
"room:",
"dm:",
"spaces/",
] as const;
function normalizeText(value: unknown): string | undefined {
const normalized = normalizeConversationText(value);
return normalized || undefined;
}
function getLoadedChannelPlugin(rawChannel: string): ChannelPlugin | undefined {
const normalized = normalizeAnyChannelId(rawChannel) ?? normalizeText(rawChannel);
if (!normalized) {
return undefined;
}
return getActivePluginChannelRegistry()?.channels.find((entry) => entry.plugin.id === normalized)
?.plugin;
}
function resolveChannelTargetId(params: {
channel: string;
target?: string | null;
}): string | undefined {
const target = normalizeText(params.target);
if (!target) {
return undefined;
}
const parsed = parseExplicitTargetForChannel(params.channel, target);
const parsedTarget = normalizeText(parsed?.to);
if (parsedTarget) {
return (
resolveConversationIdFromTargets({
targets: [parsedTarget],
}) ?? parsedTarget
);
}
const lower = target.toLowerCase();
const channelPrefix = `${params.channel}:`;
if (lower.startsWith(channelPrefix)) {
return normalizeText(target.slice(channelPrefix.length));
}
if (CANONICAL_TARGET_PREFIXES.some((prefix) => lower.startsWith(prefix))) {
return target;
}
const explicitConversationId = resolveConversationIdFromTargets({
targets: [target],
});
return explicitConversationId ?? target;
}
function buildThreadingContext(params: {
fallbackTo?: string;
originatingTo?: string;
threadId?: string;
from?: string;
chatType?: string;
nativeChannelId?: string;
}) {
const to = normalizeText(params.originatingTo) ?? normalizeText(params.fallbackTo);
return {
...(to ? { To: to } : {}),
...(params.from ? { From: params.from } : {}),
...(params.chatType ? { ChatType: params.chatType } : {}),
...(params.threadId ? { MessageThreadId: params.threadId } : {}),
...(params.nativeChannelId ? { NativeChannelId: params.nativeChannelId } : {}),
};
}
export function resolveConversationBindingContext(
params: ResolveConversationBindingContextInput,
): ConversationBindingContext | null {
const channel =
normalizeAnyChannelId(params.channel) ??
normalizeChannelId(params.channel) ??
normalizeText(params.channel)?.toLowerCase();
if (!channel) {
return null;
}
const accountId = normalizeText(params.accountId) || "default";
const threadId = normalizeText(params.threadId != null ? String(params.threadId) : undefined);
const loadedPlugin = getLoadedChannelPlugin(channel);
const resolvedByProvider = loadedPlugin?.bindings?.resolveCommandConversation?.({
accountId,
threadId,
threadParentId: normalizeText(params.threadParentId),
senderId: normalizeText(params.senderId),
sessionKey: normalizeText(params.sessionKey),
parentSessionKey: normalizeText(params.parentSessionKey),
originatingTo: params.originatingTo ?? undefined,
commandTo: params.commandTo ?? undefined,
fallbackTo: params.fallbackTo ?? undefined,
});
if (resolvedByProvider?.conversationId) {
const resolvedParentConversationId =
channel === "telegram" && !threadId && !resolvedByProvider.parentConversationId
? resolvedByProvider.conversationId
: resolvedByProvider.parentConversationId;
return {
channel,
accountId,
conversationId: resolvedByProvider.conversationId,
...(resolvedParentConversationId
? { parentConversationId: resolvedParentConversationId }
: {}),
...(threadId ? { threadId } : {}),
};
}
const focusedBinding = loadedPlugin?.threading?.resolveFocusedBinding?.({
cfg: params.cfg,
accountId,
context: buildThreadingContext({
fallbackTo: params.fallbackTo ?? undefined,
originatingTo: params.originatingTo ?? undefined,
threadId,
from: normalizeText(params.from),
chatType: normalizeText(params.chatType),
nativeChannelId: normalizeText(params.nativeChannelId),
}),
});
if (focusedBinding?.conversationId) {
return {
channel,
accountId,
conversationId: focusedBinding.conversationId,
...(focusedBinding.parentConversationId
? { parentConversationId: focusedBinding.parentConversationId }
: {}),
...(threadId ? { threadId } : {}),
};
}
const baseConversationId =
resolveChannelTargetId({
channel,
target: params.originatingTo,
}) ??
resolveChannelTargetId({
channel,
target: params.commandTo,
}) ??
resolveChannelTargetId({
channel,
target: params.fallbackTo,
});
const parentConversationId =
resolveChannelTargetId({
channel,
target: params.threadParentId,
}) ??
(threadId && baseConversationId && baseConversationId !== threadId
? baseConversationId
: undefined);
const conversationId = threadId || baseConversationId;
if (!conversationId) {
return null;
}
const normalizedParentConversationId =
channel === "telegram" && !threadId && !parentConversationId
? conversationId
: parentConversationId;
return {
channel,
accountId,
conversationId,
...(normalizedParentConversationId
? { parentConversationId: normalizedParentConversationId }
: {}),
...(threadId ? { threadId } : {}),
};
}

View File

@@ -1,3 +1,5 @@
import { normalizeAnyChannelId, normalizeChannelId } from "../../channels/registry.js";
import { getActivePluginChannelRegistry } from "../../plugins/runtime.js";
import { normalizeAccountId } from "../../routing/session-key.js";
import { resolveGlobalMap } from "../../shared/global-singleton.js";
@@ -157,6 +159,14 @@ const ADAPTERS_BY_CHANNEL_ACCOUNT = resolveGlobalMap<string, SessionBindingAdapt
SESSION_BINDING_ADAPTERS_KEY,
);
const GENERIC_SESSION_BINDINGS_KEY = Symbol.for("openclaw.sessionBinding.genericBindings");
const GENERIC_BINDINGS_BY_CONVERSATION = resolveGlobalMap<string, SessionBindingRecord>(
GENERIC_SESSION_BINDINGS_KEY,
);
const GENERIC_BINDING_ID_PREFIX = "generic:";
function getActiveAdapterForKey(key: string): SessionBindingAdapter | null {
const registrations = ADAPTERS_BY_CHANNEL_ACCOUNT.get(key);
return registrations?.[0]?.normalizedAdapter ?? null;
@@ -229,6 +239,146 @@ function resolveAdapterForChannelAccount(params: {
return getActiveAdapterForKey(key);
}
function supportsGenericCurrentConversationBindings(params: {
channel: string;
accountId: string;
}): boolean {
void params.accountId;
return Boolean(
normalizeChannelId(params.channel) ||
normalizeAnyChannelId(params.channel) ||
getActivePluginChannelRegistry()?.channels.some(
(entry) => entry.plugin.id === params.channel.trim().toLowerCase(),
),
);
}
function buildGenericConversationKey(ref: ConversationRef): string {
const normalized = normalizeConversationRef(ref);
return [
normalized.channel,
normalized.accountId,
normalized.parentConversationId ?? "",
normalized.conversationId,
].join("\u241f");
}
function buildGenericBindingId(ref: ConversationRef): string {
return `${GENERIC_BINDING_ID_PREFIX}${buildGenericConversationKey(ref)}`;
}
function isGenericBindingExpired(record: SessionBindingRecord, now = Date.now()): boolean {
return typeof record.expiresAt === "number" && Number.isFinite(record.expiresAt)
? record.expiresAt <= now
: false;
}
function pruneExpiredGenericBinding(key: string): SessionBindingRecord | null {
const record = GENERIC_BINDINGS_BY_CONVERSATION.get(key) ?? null;
if (!record) {
return null;
}
if (!isGenericBindingExpired(record)) {
return record;
}
GENERIC_BINDINGS_BY_CONVERSATION.delete(key);
return null;
}
function bindGenericConversation(input: SessionBindingBindInput): SessionBindingRecord | null {
const conversation = normalizeConversationRef(input.conversation);
const targetSessionKey = input.targetSessionKey.trim();
if (!conversation.channel || !conversation.conversationId || !targetSessionKey) {
return null;
}
const now = Date.now();
const key = buildGenericConversationKey(conversation);
const existing = pruneExpiredGenericBinding(key);
const ttlMs =
typeof input.ttlMs === "number" && Number.isFinite(input.ttlMs)
? Math.max(0, Math.floor(input.ttlMs))
: undefined;
const metadata = {
...existing?.metadata,
...input.metadata,
lastActivityAt: now,
};
const record: SessionBindingRecord = {
bindingId: buildGenericBindingId(conversation),
targetSessionKey,
targetKind: input.targetKind,
conversation,
status: "active",
boundAt: now,
...(ttlMs != null ? { expiresAt: now + ttlMs } : {}),
metadata,
};
GENERIC_BINDINGS_BY_CONVERSATION.set(key, record);
return record;
}
function listGenericBindingsBySession(targetSessionKey: string): SessionBindingRecord[] {
const results: SessionBindingRecord[] = [];
for (const key of GENERIC_BINDINGS_BY_CONVERSATION.keys()) {
const active = pruneExpiredGenericBinding(key);
if (!active || active.targetSessionKey !== targetSessionKey) {
continue;
}
results.push(active);
}
return results;
}
function resolveGenericBindingByConversation(ref: ConversationRef): SessionBindingRecord | null {
const key = buildGenericConversationKey(ref);
return pruneExpiredGenericBinding(key);
}
function touchGenericBinding(bindingId: string, at = Date.now()): void {
if (!bindingId.startsWith(GENERIC_BINDING_ID_PREFIX)) {
return;
}
const key = bindingId.slice(GENERIC_BINDING_ID_PREFIX.length);
const record = pruneExpiredGenericBinding(key);
if (!record) {
return;
}
GENERIC_BINDINGS_BY_CONVERSATION.set(key, {
...record,
metadata: {
...record.metadata,
lastActivityAt: at,
},
});
}
function unbindGenericBindings(input: SessionBindingUnbindInput): SessionBindingRecord[] {
const removed: SessionBindingRecord[] = [];
const normalizedBindingId = input.bindingId?.trim();
const normalizedTargetSessionKey = input.targetSessionKey?.trim();
if (normalizedBindingId?.startsWith(GENERIC_BINDING_ID_PREFIX)) {
const key = normalizedBindingId.slice(GENERIC_BINDING_ID_PREFIX.length);
const record = pruneExpiredGenericBinding(key);
if (record) {
GENERIC_BINDINGS_BY_CONVERSATION.delete(key);
removed.push(record);
}
return removed;
}
if (!normalizedTargetSessionKey) {
return removed;
}
for (const key of GENERIC_BINDINGS_BY_CONVERSATION.keys()) {
const active = pruneExpiredGenericBinding(key);
if (!active || active.targetSessionKey !== normalizedTargetSessionKey) {
continue;
}
GENERIC_BINDINGS_BY_CONVERSATION.delete(key);
removed.push(active);
}
return removed;
}
function getActiveRegisteredAdapters(): SessionBindingAdapter[] {
return [...ADAPTERS_BY_CHANNEL_ACCOUNT.values()]
.map((registrations) => registrations[0]?.normalizedAdapter ?? null)
@@ -252,6 +402,43 @@ function createDefaultSessionBindingService(): SessionBindingService {
const normalizedConversation = normalizeConversationRef(input.conversation);
const adapter = resolveAdapterForConversation(normalizedConversation);
if (!adapter) {
if (
supportsGenericCurrentConversationBindings({
channel: normalizedConversation.channel,
accountId: normalizedConversation.accountId,
})
) {
const placement =
normalizePlacement(input.placement) ?? inferDefaultPlacement(normalizedConversation);
if (placement !== "current") {
throw new SessionBindingError(
"BINDING_CAPABILITY_UNSUPPORTED",
`Session binding placement "${placement}" is not supported for ${normalizedConversation.channel}:${normalizedConversation.accountId}`,
{
channel: normalizedConversation.channel,
accountId: normalizedConversation.accountId,
placement,
},
);
}
const bound = bindGenericConversation({
...input,
conversation: normalizedConversation,
placement,
});
if (!bound) {
throw new SessionBindingError(
"BINDING_CREATE_FAILED",
"Session binding adapter failed to bind target conversation",
{
channel: normalizedConversation.channel,
accountId: normalizedConversation.accountId,
placement,
},
);
}
return bound;
}
throw new SessionBindingError(
"BINDING_ADAPTER_UNAVAILABLE",
`Session binding adapter unavailable for ${normalizedConversation.channel}:${normalizedConversation.accountId}`,
@@ -308,6 +495,14 @@ function createDefaultSessionBindingService(): SessionBindingService {
channel: params.channel,
accountId: params.accountId,
});
if (!adapter && supportsGenericCurrentConversationBindings(params)) {
return {
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current"],
};
}
return resolveAdapterCapabilities(adapter);
},
listBySession: (targetSessionKey) => {
@@ -322,6 +517,7 @@ function createDefaultSessionBindingService(): SessionBindingService {
results.push(...entries);
}
}
results.push(...listGenericBindingsBySession(key));
return dedupeBindings(results);
},
resolveByConversation: (ref) => {
@@ -331,7 +527,7 @@ function createDefaultSessionBindingService(): SessionBindingService {
}
const adapter = resolveAdapterForConversation(normalized);
if (!adapter) {
return null;
return resolveGenericBindingByConversation(normalized);
}
return adapter.resolveByConversation(normalized);
},
@@ -343,6 +539,7 @@ function createDefaultSessionBindingService(): SessionBindingService {
for (const adapter of getActiveRegisteredAdapters()) {
adapter.touch?.(normalizedBindingId, at);
}
touchGenericBinding(normalizedBindingId, at);
},
unbind: async (input) => {
const removed: SessionBindingRecord[] = [];
@@ -355,6 +552,7 @@ function createDefaultSessionBindingService(): SessionBindingService {
removed.push(...entries);
}
}
removed.push(...unbindGenericBindings(input));
return dedupeBindings(removed);
},
};
@@ -369,6 +567,7 @@ export function getSessionBindingService(): SessionBindingService {
export const __testing = {
resetSessionBindingAdaptersForTests() {
ADAPTERS_BY_CHANNEL_ACCOUNT.clear();
GENERIC_BINDINGS_BY_CONVERSATION.clear();
},
getRegisteredAdapterKeys() {
return [...ADAPTERS_BY_CHANNEL_ACCOUNT.keys()];