feat(acp): add conversation binds for message channels

This commit is contained in:
Peter Steinberger
2026-03-28 01:53:53 +00:00
parent 067f8db4c9
commit c42ec81e37
30 changed files with 1922 additions and 80 deletions

View File

@@ -116,7 +116,7 @@ type FakeBinding = {
targetSessionKey: string;
targetKind: "subagent" | "session";
conversation: {
channel: "discord" | "matrix" | "telegram" | "feishu";
channel: "discord" | "matrix" | "telegram" | "feishu" | "bluebubbles" | "imessage";
accountId: string;
conversationId: string;
parentConversationId?: string;
@@ -241,7 +241,7 @@ function createSessionBindingCapabilities() {
type AcpBindInput = {
targetSessionKey: string;
conversation: {
channel?: "discord" | "matrix" | "telegram" | "feishu";
channel?: "discord" | "matrix" | "telegram" | "feishu" | "bluebubbles" | "imessage";
accountId: string;
conversationId: string;
parentConversationId?: string;
@@ -279,11 +279,23 @@ function createAcpThreadBinding(input: AcpBindInput): FakeBinding {
accountId: input.conversation.accountId,
conversationId: nextConversationId,
}
: {
channel: "telegram" as const,
accountId: input.conversation.accountId,
conversationId: nextConversationId,
};
: channel === "bluebubbles"
? {
channel: "bluebubbles" as const,
accountId: input.conversation.accountId,
conversationId: nextConversationId,
}
: channel === "imessage"
? {
channel: "imessage" as const,
accountId: input.conversation.accountId,
conversationId: nextConversationId,
}
: {
channel: "telegram" as const,
accountId: input.conversation.accountId,
conversationId: nextConversationId,
};
return createSessionBinding({
targetSessionKey: input.targetSessionKey,
conversation,
@@ -409,6 +421,38 @@ async function runFeishuDmAcpCommand(commandBody: string, cfg: OpenClawConfig =
return handleAcpCommand(createFeishuDmParams(commandBody, cfg), true);
}
function createBlueBubblesDmParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
const params = buildCommandTestParams(commandBody, cfg, {
Provider: "bluebubbles",
Surface: "bluebubbles",
OriginatingChannel: "bluebubbles",
OriginatingTo: "bluebubbles:+15555550123",
AccountId: "default",
});
params.command.senderId = "user-1";
return params;
}
async function runBlueBubblesDmAcpCommand(commandBody: string, cfg: OpenClawConfig = baseCfg) {
return handleAcpCommand(createBlueBubblesDmParams(commandBody, cfg), true);
}
function createIMessageDmParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
const params = buildCommandTestParams(commandBody, cfg, {
Provider: "imessage",
Surface: "imessage",
OriginatingChannel: "imessage",
OriginatingTo: "imessage:+15555550123",
AccountId: "default",
});
params.command.senderId = "user-1";
return params;
}
async function runIMessageDmAcpCommand(commandBody: string, cfg: OpenClawConfig = baseCfg) {
return handleAcpCommand(createIMessageDmParams(commandBody, cfg), true);
}
async function runInternalAcpCommand(params: {
commandBody: string;
scopes: string[];
@@ -742,6 +786,66 @@ describe("/acp command", () => {
);
});
it("binds the current Discord channel with --bind here without creating a child thread", async () => {
const cfg = {
...baseCfg,
channels: {
discord: {
threadBindings: {
enabled: true,
spawnAcpSessions: false,
},
},
},
} satisfies OpenClawConfig;
const result = await runDiscordAcpCommand("/acp spawn codex --bind here", cfg);
expect(result?.reply?.text).toContain("Bound this channel to");
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
expect.objectContaining({
placement: "current",
conversation: expect.objectContaining({
channel: "discord",
accountId: "default",
conversationId: "parent-1",
}),
}),
);
});
it("binds BlueBubbles DMs with --bind here", async () => {
const result = await runBlueBubblesDmAcpCommand("/acp spawn codex --bind here");
expect(result?.reply?.text).toContain("Bound this conversation to");
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
expect.objectContaining({
placement: "current",
conversation: expect.objectContaining({
channel: "bluebubbles",
accountId: "default",
conversationId: "+15555550123",
}),
}),
);
});
it("binds iMessage DMs with --bind here", async () => {
const result = await runIMessageDmAcpCommand("/acp spawn codex --bind here");
expect(result?.reply?.text).toContain("Bound this conversation to");
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
expect.objectContaining({
placement: "current",
conversation: expect.objectContaining({
channel: "imessage",
accountId: "default",
conversationId: "+15555550123",
}),
}),
);
});
it("binds Telegram topic ACP spawns to full conversation ids", async () => {
const result = await runTelegramAcpCommand("/acp spawn codex --thread here");
@@ -859,6 +963,14 @@ describe("/acp command", () => {
expect(hoisted.ensureSessionMock).not.toHaveBeenCalled();
});
it("rejects mixing --thread and --bind on the same /acp spawn", async () => {
const result = await runDiscordAcpCommand("/acp spawn codex --thread here --bind here");
expect(result?.reply?.text).toContain("Use either --thread or --bind");
expect(hoisted.ensureSessionMock).not.toHaveBeenCalled();
expect(hoisted.sessionBindingBindMock).not.toHaveBeenCalled();
});
it("rejects thread-bound ACP spawn when spawnAcpSessions is disabled", async () => {
const cfg = {
...baseCfg,

View File

@@ -190,6 +190,80 @@ describe("commands-acp context", () => {
expect(resolveAcpCommandParentConversationId(params)).toBe("!room:example.org");
});
it("resolves BlueBubbles DM conversation ids from current targets", () => {
const params = buildCommandTestParams("/acp status", baseCfg, {
Provider: "bluebubbles",
Surface: "bluebubbles",
OriginatingChannel: "bluebubbles",
OriginatingTo: "bluebubbles:+15555550123",
});
expect(resolveAcpCommandBindingContext(params)).toEqual({
channel: "bluebubbles",
accountId: "default",
threadId: undefined,
conversationId: "+15555550123",
parentConversationId: undefined,
});
expect(resolveAcpCommandConversationId(params)).toBe("+15555550123");
});
it("resolves BlueBubbles group conversation ids from explicit chat targets", () => {
const params = buildCommandTestParams("/acp status", baseCfg, {
Provider: "bluebubbles",
Surface: "bluebubbles",
OriginatingChannel: "bluebubbles",
OriginatingTo: "bluebubbles:chat_guid:iMessage;+;chat123",
AccountId: "work",
});
expect(resolveAcpCommandBindingContext(params)).toEqual({
channel: "bluebubbles",
accountId: "work",
threadId: undefined,
conversationId: "iMessage;+;chat123",
parentConversationId: undefined,
});
expect(resolveAcpCommandConversationId(params)).toBe("iMessage;+;chat123");
});
it("resolves iMessage DM conversation ids from current targets", () => {
const params = buildCommandTestParams("/acp status", baseCfg, {
Provider: "imessage",
Surface: "imessage",
OriginatingChannel: "imessage",
OriginatingTo: "imessage:+15555550123",
});
expect(resolveAcpCommandBindingContext(params)).toEqual({
channel: "imessage",
accountId: "default",
threadId: undefined,
conversationId: "+15555550123",
parentConversationId: undefined,
});
expect(resolveAcpCommandConversationId(params)).toBe("+15555550123");
});
it("resolves iMessage group conversation ids from chat_id targets", () => {
const params = buildCommandTestParams("/acp status", baseCfg, {
Provider: "imessage",
Surface: "imessage",
OriginatingChannel: "imessage",
OriginatingTo: "chat_id:12345",
AccountId: "work",
});
expect(resolveAcpCommandBindingContext(params)).toEqual({
channel: "imessage",
accountId: "work",
threadId: undefined,
conversationId: "12345",
parentConversationId: undefined,
});
expect(resolveAcpCommandConversationId(params)).toBe("12345");
});
it("builds Feishu topic conversation ids from chat target + root message id", () => {
const params = buildCommandTestParams("/acp status", baseCfg, {
Provider: "feishu",

View File

@@ -6,11 +6,13 @@ import {
import { DISCORD_THREAD_BINDING_CHANNEL } from "../../../channels/thread-bindings-policy.js";
import { resolveConversationIdFromTargets } from "../../../infra/outbound/conversation-id.js";
import { getSessionBindingService } from "../../../infra/outbound/session-binding-service.js";
import { resolveBlueBubblesConversationIdFromTarget } from "../../../plugin-sdk/bluebubbles.js";
import {
buildFeishuConversationId,
parseFeishuDirectConversationId,
parseFeishuTargetId,
} from "../../../plugin-sdk/feishu.js";
import { resolveIMessageConversationIdFromTarget } from "../../../plugin-sdk/imessage-core.js";
import { parseAgentSessionKey } from "../../../routing/session-key.js";
import type { HandleCommandsParams } from "../commands-types.js";
import { parseDiscordParentChannelFromSessionKey } from "../discord-parent-channel.js";
@@ -160,6 +162,20 @@ export function resolveAcpCommandConversationId(params: HandleCommandsParams): s
parseFeishuDirectConversationId(params.ctx.To)
);
}
if (channel === "bluebubbles") {
return (
resolveBlueBubblesConversationIdFromTarget(params.ctx.OriginatingTo ?? "") ??
resolveBlueBubblesConversationIdFromTarget(params.command.to ?? "") ??
resolveBlueBubblesConversationIdFromTarget(params.ctx.To ?? "")
);
}
if (channel === "imessage") {
return (
resolveIMessageConversationIdFromTarget(params.ctx.OriginatingTo ?? "") ??
resolveIMessageConversationIdFromTarget(params.command.to ?? "") ??
resolveIMessageConversationIdFromTarget(params.ctx.To ?? "")
);
}
return resolveConversationIdFromTargets({
threadId: params.ctx.MessageThreadId,
targets: [params.ctx.OriginatingTo, params.command.to, params.ctx.To],

View File

@@ -39,6 +39,7 @@ import {
resolveAcpCommandAccountId,
resolveAcpCommandBindingContext,
resolveAcpCommandConversationId,
resolveAcpCommandThreadId,
} from "./context.js";
import {
ACP_STEER_OUTPUT_LIMIT,
@@ -47,11 +48,172 @@ import {
parseSteerInput,
resolveCommandRequestId,
stopWithText,
type AcpSpawnBindMode,
type AcpSpawnThreadMode,
withAcpCommandErrorBoundary,
} from "./shared.js";
import { resolveAcpTargetSessionKey } from "./targets.js";
function resolveAcpBindingLabelNoun(params: {
channel: string;
threadId?: string;
bindMode: "thread" | "current";
}): string {
if (params.bindMode === "current") {
if (params.channel === "discord" && !params.threadId) {
return "channel";
}
if (
params.channel === "telegram" ||
params.channel === "bluebubbles" ||
params.channel === "imessage"
) {
return "conversation";
}
}
return params.channel === "telegram" ? "conversation" : "thread";
}
async function bindSpawnedAcpSessionToCurrentConversation(params: {
commandParams: HandleCommandsParams;
sessionKey: string;
agentId: string;
label?: string;
bindMode: AcpSpawnBindMode;
sessionMeta?: SessionAcpMeta;
}): Promise<{ ok: true; binding: SessionBindingRecord } | { ok: false; error: string }> {
if (params.bindMode === "off") {
return {
ok: false,
error: "internal: conversation binding is disabled for this spawn",
};
}
const bindingContext = resolveAcpCommandBindingContext(params.commandParams);
const channel = bindingContext.channel;
if (!channel) {
return {
ok: false,
error: "ACP current-conversation binding requires a channel context.",
};
}
const accountId = resolveAcpCommandAccountId(params.commandParams);
const bindingPolicy = resolveThreadBindingSpawnPolicy({
cfg: params.commandParams.cfg,
channel,
accountId,
kind: "acp",
});
if (!bindingPolicy.enabled) {
return {
ok: false,
error: formatThreadBindingDisabledError({
channel: bindingPolicy.channel,
accountId: bindingPolicy.accountId,
kind: "acp",
}),
};
}
const bindingService = getSessionBindingService();
const capabilities = bindingService.getCapabilities({
channel: bindingPolicy.channel,
accountId: bindingPolicy.accountId,
});
if (!capabilities.adapterAvailable || !capabilities.bindSupported) {
return {
ok: false,
error: `Conversation bindings are unavailable for ${channel}.`,
};
}
if (!capabilities.placements.includes("current")) {
return {
ok: false,
error: `Conversation bindings do not support current placement for ${channel}.`,
};
}
const currentConversationId = bindingContext.conversationId?.trim() || "";
if (!currentConversationId) {
return {
ok: false,
error: `--bind here requires running /acp spawn inside an active ${channel} conversation.`,
};
}
const senderId = params.commandParams.command.senderId?.trim() || "";
const parentConversationId = bindingContext.parentConversationId?.trim() || undefined;
const conversationRef = {
channel: bindingPolicy.channel,
accountId: bindingPolicy.accountId,
conversationId: currentConversationId,
...(parentConversationId && parentConversationId !== currentConversationId
? { parentConversationId }
: {}),
};
const existingBinding = bindingService.resolveByConversation(conversationRef);
const boundBy =
typeof existingBinding?.metadata?.boundBy === "string"
? existingBinding.metadata.boundBy.trim()
: "";
if (existingBinding && boundBy && boundBy !== "system" && senderId && senderId !== boundBy) {
return {
ok: false,
error: `Only ${boundBy} can rebind this ${channel === "discord" && !bindingContext.threadId ? "channel" : "conversation"}.`,
};
}
const label = params.label || params.agentId;
try {
const binding = await bindingService.bind({
targetSessionKey: params.sessionKey,
targetKind: "session",
conversation: conversationRef,
placement: "current",
metadata: {
threadName: resolveThreadBindingThreadName({
agentId: params.agentId,
label,
}),
agentId: params.agentId,
label,
boundBy: senderId || "unknown",
introText: resolveThreadBindingIntroText({
agentId: params.agentId,
label,
idleTimeoutMs: resolveThreadBindingIdleTimeoutMsForChannel({
cfg: params.commandParams.cfg,
channel: bindingPolicy.channel,
accountId: bindingPolicy.accountId,
}),
maxAgeMs: resolveThreadBindingMaxAgeMsForChannel({
cfg: params.commandParams.cfg,
channel: bindingPolicy.channel,
accountId: bindingPolicy.accountId,
}),
sessionCwd: resolveAcpSessionCwd(params.sessionMeta),
sessionDetails: resolveAcpThreadSessionDetailLines({
sessionKey: params.sessionKey,
meta: params.sessionMeta,
}),
}),
},
});
return {
ok: true,
binding,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
ok: false,
error:
message || `Failed to bind the current ${channel} conversation to the new ACP session.`,
};
}
}
async function bindSpawnedAcpSessionToThread(params: {
commandParams: HandleCommandsParams;
sessionKey: string;
@@ -308,7 +470,26 @@ export async function handleAcpSpawnAction(
}
let binding: SessionBindingRecord | null = null;
if (spawn.thread !== "off") {
if (spawn.bind !== "off") {
const bound = await bindSpawnedAcpSessionToCurrentConversation({
commandParams: params,
sessionKey,
agentId: spawn.agentId,
label: spawn.label,
bindMode: spawn.bind,
sessionMeta: initializedMeta,
});
if (!bound.ok) {
await cleanupFailedSpawn({
cfg: params.cfg,
sessionKey,
shouldDeleteSession: true,
initializedRuntime,
});
return stopWithText(`⚠️ ${bound.error}`);
}
binding = bound.binding;
} else if (spawn.thread !== "off") {
const bound = await bindSpawnedAcpSessionToThread({
commandParams: params,
sessionKey,
@@ -355,14 +536,20 @@ export async function handleAcpSpawnAction(
if (binding) {
const currentConversationId = resolveAcpCommandConversationId(params)?.trim() || "";
const boundConversationId = binding.conversation.conversationId.trim();
const placementLabel = binding.conversation.channel === "telegram" ? "conversation" : "thread";
const placementLabel = resolveAcpBindingLabelNoun({
channel: binding.conversation.channel,
threadId: resolveAcpCommandThreadId(params),
bindMode: spawn.bind !== "off" ? "current" : "thread",
});
if (currentConversationId && boundConversationId === currentConversationId) {
parts.push(`Bound this ${placementLabel} to ${sessionKey}.`);
} else {
parts.push(`Created ${placementLabel} ${boundConversationId} and bound it to ${sessionKey}.`);
}
} else {
parts.push("Session is unbound (use /focus <session-key> to bind this thread/conversation).");
parts.push(
"Session is unbound (use /acp spawn ... --bind here to bind this conversation, or /focus <session-key> where supported).",
);
}
const dispatchNote = resolveAcpDispatchPolicyMessage(params.cfg);

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { parseSteerInput } from "./shared.js";
import { parseSpawnInput, parseSteerInput } from "./shared.js";
describe("parseSteerInput", () => {
it("preserves non-option instruction tokens while normalizing unicode-dash flags", () => {
@@ -20,3 +20,22 @@ describe("parseSteerInput", () => {
});
});
});
describe("parseSpawnInput", () => {
it("rejects mixing --thread and --bind on the same spawn", () => {
const parsed = parseSpawnInput(
{
cfg: {},
ctx: {},
command: {},
} as never,
["codex", "--thread", "here", "--bind", "here"],
);
expect(parsed).toEqual({
ok: false,
error:
"Use either --thread or --bind for /acp spawn, not both. Usage: /acp spawn [harness-id] [--mode persistent|oneshot] [--thread auto|here|off] [--bind here|off] [--cwd <path>] [--label <label>].",
});
});
});

View File

@@ -14,7 +14,7 @@ export { resolveAcpInstallCommandHint, resolveConfiguredAcpBackendId } from "./i
export const COMMAND = "/acp";
export const ACP_SPAWN_USAGE =
"Usage: /acp spawn [harness-id] [--mode persistent|oneshot] [--thread auto|here|off] [--cwd <path>] [--label <label>].";
"Usage: /acp spawn [harness-id] [--mode persistent|oneshot] [--thread auto|here|off] [--bind here|off] [--cwd <path>] [--label <label>].";
export const ACP_STEER_USAGE =
"Usage: /acp steer [--session <session-key|session-id|session-label>] <instruction>";
export const ACP_SET_MODE_USAGE =
@@ -55,11 +55,13 @@ export type AcpAction =
| "help";
export type AcpSpawnThreadMode = "auto" | "here" | "off";
export type AcpSpawnBindMode = "here" | "off";
export type ParsedSpawnInput = {
agentId: string;
mode: AcpRuntimeSessionMode;
thread: AcpSpawnThreadMode;
bind: AcpSpawnBindMode;
cwd?: string;
label?: string;
};
@@ -186,6 +188,8 @@ export function parseSpawnInput(
const normalizedTokens = tokens.map((token) => normalizeAcpOptionToken(token));
let mode: AcpRuntimeSessionMode = "persistent";
let thread = resolveDefaultSpawnThreadMode(params);
let sawThreadOption = false;
let bind: AcpSpawnBindMode = "off";
let cwd: string | undefined;
let label: string | undefined;
let rawAgentId: string | undefined;
@@ -210,6 +214,23 @@ export function parseSpawnInput(
continue;
}
const bindOption = readOptionValue({ tokens: normalizedTokens, index: i, flag: "--bind" });
if (bindOption.matched) {
if (bindOption.error) {
return { ok: false, error: `${bindOption.error}. ${ACP_SPAWN_USAGE}` };
}
const raw = bindOption.value?.trim().toLowerCase();
if (raw !== "here" && raw !== "off") {
return {
ok: false,
error: `Invalid --bind value "${bindOption.value}". Use here or off.`,
};
}
bind = raw;
i = bindOption.nextIndex;
continue;
}
const threadOption = readOptionValue({
tokens: normalizedTokens,
index: i,
@@ -227,6 +248,7 @@ export function parseSpawnInput(
};
}
thread = raw;
sawThreadOption = true;
i = threadOption.nextIndex;
continue;
}
@@ -279,6 +301,15 @@ export function parseSpawnInput(
};
}
const normalizedAgentId = normalizeAgentId(selectedAgent);
if (bind !== "off" && !sawThreadOption) {
thread = "off";
}
if (thread !== "off" && bind !== "off") {
return {
ok: false,
error: `Use either --thread or --bind for /acp spawn, not both. ${ACP_SPAWN_USAGE}`,
};
}
return {
ok: true,
@@ -286,6 +317,7 @@ export function parseSpawnInput(
agentId: normalizedAgentId,
mode,
thread,
bind,
cwd,
label: label || undefined,
},
@@ -405,7 +437,7 @@ export function resolveAcpHelpText(): string {
return [
"ACP commands:",
"-----",
"/acp spawn [harness-id] [--mode persistent|oneshot] [--thread auto|here|off] [--cwd <path>] [--label <label>]",
"/acp spawn [harness-id] [--mode persistent|oneshot] [--thread auto|here|off] [--bind here|off] [--cwd <path>] [--label <label>]",
"/acp cancel [session-key|session-id|session-label]",
"/acp steer [--session <session-key|session-id|session-label>] <instruction>",
"/acp close [session-key|session-id|session-label]",
@@ -423,6 +455,7 @@ export function resolveAcpHelpText(): string {
"",
"Notes:",
"- /acp spawn harness-id is an ACP runtime harness alias (for example codex), not an OpenClaw agents.list id.",
"- Use --bind here to pin the current conversation to the ACP session without creating a child thread.",
"- /focus and /unfocus also work with ACP session keys.",
"- ACP dispatch of normal thread messages is controlled by acp.dispatch.enabled.",
].join("\n");