feat(telegram/acp): Topic Binding, Pin Binding Message, Fix Spawn Param Parsing (#36683)

* fix(acp): normalize unicode flags and Telegram topic binding

* feat(telegram/acp): restore topic-bound ACP and session bindings

* fix(acpx): clarify permission-denied guidance

* feat(telegram/acp): pin spawn bind notice in topics

* docs(telegram): document ACP topic thread binding behavior

* refactor(reply): share Telegram conversation-id resolver

* fix(telegram/acp): preserve bound session routing semantics

* fix(telegram): respect binding persistence and expiry reporting

* refactor(telegram): simplify binding lifecycle persistence

* fix(telegram): bind acp spawns in direct messages

* fix: document telegram ACP topic binding changelog (#36683) (thanks @huntharo)

---------

Co-authored-by: Onur <2453968+osolmaz@users.noreply.github.com>
This commit is contained in:
Harold Hunt
2026-03-05 20:17:50 -05:00
committed by GitHub
parent 92b4892127
commit d58dafae88
35 changed files with 2397 additions and 453 deletions

View File

@@ -108,4 +108,22 @@ describe("commands-acp context", () => {
});
expect(resolveAcpCommandConversationId(params)).toBe("-1001234567890:topic:42");
});
it("resolves Telegram DM conversation ids from telegram targets", () => {
const params = buildCommandTestParams("/acp status", baseCfg, {
Provider: "telegram",
Surface: "telegram",
OriginatingChannel: "telegram",
OriginatingTo: "telegram:123456789",
});
expect(resolveAcpCommandBindingContext(params)).toEqual({
channel: "telegram",
accountId: "default",
threadId: undefined,
conversationId: "123456789",
parentConversationId: "123456789",
});
expect(resolveAcpCommandConversationId(params)).toBe("123456789");
});
});

View File

@@ -6,6 +6,7 @@ import { DISCORD_THREAD_BINDING_CHANNEL } from "../../../channels/thread-binding
import { resolveConversationIdFromTargets } from "../../../infra/outbound/conversation-id.js";
import { parseAgentSessionKey } from "../../../routing/session-key.js";
import type { HandleCommandsParams } from "../commands-types.js";
import { resolveTelegramConversationId } from "../telegram-context.js";
function normalizeString(value: unknown): string {
if (typeof value === "string") {
@@ -40,19 +41,28 @@ export function resolveAcpCommandThreadId(params: HandleCommandsParams): string
export function resolveAcpCommandConversationId(params: HandleCommandsParams): string | undefined {
const channel = resolveAcpCommandChannel(params);
if (channel === "telegram") {
const telegramConversationId = resolveTelegramConversationId({
ctx: {
MessageThreadId: params.ctx.MessageThreadId,
OriginatingTo: params.ctx.OriginatingTo,
To: params.ctx.To,
},
command: {
to: params.command.to,
},
});
if (telegramConversationId) {
return telegramConversationId;
}
const threadId = resolveAcpCommandThreadId(params);
const parentConversationId = resolveAcpCommandParentConversationId(params);
if (threadId && parentConversationId) {
const canonical = buildTelegramTopicConversationId({
chatId: parentConversationId,
topicId: threadId,
});
if (canonical) {
return canonical;
}
}
if (threadId) {
return threadId;
return (
buildTelegramTopicConversationId({
chatId: parentConversationId,
topicId: threadId,
}) ?? threadId
);
}
}
return resolveConversationIdFromTargets({

View File

@@ -37,7 +37,7 @@ import type { CommandHandlerResult, HandleCommandsParams } from "../commands-typ
import {
resolveAcpCommandAccountId,
resolveAcpCommandBindingContext,
resolveAcpCommandThreadId,
resolveAcpCommandConversationId,
} from "./context.js";
import {
ACP_STEER_OUTPUT_LIMIT,
@@ -123,25 +123,27 @@ async function bindSpawnedAcpSessionToThread(params: {
}
const currentThreadId = bindingContext.threadId ?? "";
if (threadMode === "here" && !currentThreadId) {
const currentConversationId = bindingContext.conversationId?.trim() || "";
const requiresThreadIdForHere = channel !== "telegram";
if (
threadMode === "here" &&
((requiresThreadIdForHere && !currentThreadId) ||
(!requiresThreadIdForHere && !currentConversationId))
) {
return {
ok: false,
error: `--thread here requires running /acp spawn inside an active ${channel} thread/conversation.`,
};
}
const threadId = currentThreadId || undefined;
const placement = threadId ? "current" : "child";
const placement = channel === "telegram" ? "current" : currentThreadId ? "current" : "child";
if (!capabilities.placements.includes(placement)) {
return {
ok: false,
error: `Thread bindings do not support ${placement} placement for ${channel}.`,
};
}
const channelId = placement === "child" ? bindingContext.conversationId : undefined;
if (placement === "child" && !channelId) {
if (!currentConversationId) {
return {
ok: false,
error: `Could not resolve a ${channel} conversation for ACP thread spawn.`,
@@ -149,11 +151,11 @@ async function bindSpawnedAcpSessionToThread(params: {
}
const senderId = commandParams.command.senderId?.trim() || "";
if (threadId) {
if (placement === "current") {
const existingBinding = bindingService.resolveByConversation({
channel: spawnPolicy.channel,
accountId: spawnPolicy.accountId,
conversationId: threadId,
conversationId: currentConversationId,
});
const boundBy =
typeof existingBinding?.metadata?.boundBy === "string"
@@ -162,19 +164,13 @@ async function bindSpawnedAcpSessionToThread(params: {
if (existingBinding && boundBy && boundBy !== "system" && senderId && senderId !== boundBy) {
return {
ok: false,
error: `Only ${boundBy} can rebind this thread.`,
error: `Only ${boundBy} can rebind this ${channel === "telegram" ? "conversation" : "thread"}.`,
};
}
}
const label = params.label || params.agentId;
const conversationId = threadId || channelId;
if (!conversationId) {
return {
ok: false,
error: `Could not resolve a ${channel} conversation for ACP thread spawn.`,
};
}
const conversationId = currentConversationId;
try {
const binding = await bindingService.bind({
@@ -344,12 +340,13 @@ export async function handleAcpSpawnAction(
`✅ Spawned ACP session ${sessionKey} (${spawn.mode}, backend ${initializedBackend}).`,
];
if (binding) {
const currentThreadId = resolveAcpCommandThreadId(params) ?? "";
const currentConversationId = resolveAcpCommandConversationId(params)?.trim() || "";
const boundConversationId = binding.conversation.conversationId.trim();
if (currentThreadId && boundConversationId === currentThreadId) {
parts.push(`Bound this thread to ${sessionKey}.`);
const placementLabel = binding.conversation.channel === "telegram" ? "conversation" : "thread";
if (currentConversationId && boundConversationId === currentConversationId) {
parts.push(`Bound this ${placementLabel} to ${sessionKey}.`);
} else {
parts.push(`Created thread ${boundConversationId} and bound it to ${sessionKey}.`);
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).");
@@ -360,6 +357,19 @@ export async function handleAcpSpawnAction(
parts.push(` ${dispatchNote}`);
}
const shouldPinBindingNotice =
binding?.conversation.channel === "telegram" &&
binding.conversation.conversationId.includes(":topic:");
if (shouldPinBindingNotice) {
return {
shouldContinue: false,
reply: {
text: parts.join(" "),
channelData: { telegram: { pin: true } },
},
};
}
return stopWithText(parts.join(" "));
}

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { parseSteerInput } from "./shared.js";
describe("parseSteerInput", () => {
it("preserves non-option instruction tokens while normalizing unicode-dash flags", () => {
const parsed = parseSteerInput([
"\u2014session",
"agent:codex:acp:s1",
"\u2014briefly",
"summarize",
"this",
]);
expect(parsed).toEqual({
ok: true,
value: {
sessionToken: "agent:codex:acp:s1",
instruction: "\u2014briefly summarize this",
},
});
});
});

View File

@@ -11,7 +11,7 @@ export { resolveAcpInstallCommandHint, resolveConfiguredAcpBackendId } from "./i
export const COMMAND = "/acp";
export const ACP_SPAWN_USAGE =
"Usage: /acp spawn [agentId] [--mode persistent|oneshot] [--thread auto|here|off] [--cwd <path>] [--label <label>].";
"Usage: /acp spawn [harness-id] [--mode persistent|oneshot] [--thread auto|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 =
@@ -77,6 +77,9 @@ export type ParsedSetCommandInput = {
sessionToken?: string;
};
const ACP_UNICODE_DASH_PREFIX_RE =
/^[\u2010\u2011\u2012\u2013\u2014\u2015\u2212\uFE58\uFE63\uFF0D]+/;
export function stopWithText(text: string): CommandHandlerResult {
return {
shouldContinue: false,
@@ -118,9 +121,9 @@ function readOptionValue(params: { tokens: string[]; index: number; flag: string
error?: string;
}
| { matched: false } {
const token = params.tokens[params.index] ?? "";
const token = normalizeAcpOptionToken(params.tokens[params.index] ?? "");
if (token === params.flag) {
const nextValue = params.tokens[params.index + 1]?.trim() ?? "";
const nextValue = normalizeAcpOptionToken(params.tokens[params.index + 1] ?? "");
if (!nextValue || nextValue.startsWith("--")) {
return {
matched: true,
@@ -152,6 +155,18 @@ function readOptionValue(params: { tokens: string[]; index: number; flag: string
return { matched: false };
}
function normalizeAcpOptionToken(raw: string): string {
const token = raw.trim();
if (!token || token.startsWith("--")) {
return token;
}
const dashPrefix = token.match(ACP_UNICODE_DASH_PREFIX_RE)?.[0];
if (!dashPrefix) {
return token;
}
return `--${token.slice(dashPrefix.length)}`;
}
function resolveDefaultSpawnThreadMode(params: HandleCommandsParams): AcpSpawnThreadMode {
if (resolveAcpCommandChannel(params) !== DISCORD_THREAD_BINDING_CHANNEL) {
return "off";
@@ -164,16 +179,17 @@ export function parseSpawnInput(
params: HandleCommandsParams,
tokens: string[],
): { ok: true; value: ParsedSpawnInput } | { ok: false; error: string } {
const normalizedTokens = tokens.map((token) => normalizeAcpOptionToken(token));
let mode: AcpRuntimeSessionMode = "persistent";
let thread = resolveDefaultSpawnThreadMode(params);
let cwd: string | undefined;
let label: string | undefined;
let rawAgentId: string | undefined;
for (let i = 0; i < tokens.length; ) {
const token = tokens[i] ?? "";
for (let i = 0; i < normalizedTokens.length; ) {
const token = normalizedTokens[i] ?? "";
const modeOption = readOptionValue({ tokens, index: i, flag: "--mode" });
const modeOption = readOptionValue({ tokens: normalizedTokens, index: i, flag: "--mode" });
if (modeOption.matched) {
if (modeOption.error) {
return { ok: false, error: `${modeOption.error}. ${ACP_SPAWN_USAGE}` };
@@ -190,7 +206,11 @@ export function parseSpawnInput(
continue;
}
const threadOption = readOptionValue({ tokens, index: i, flag: "--thread" });
const threadOption = readOptionValue({
tokens: normalizedTokens,
index: i,
flag: "--thread",
});
if (threadOption.matched) {
if (threadOption.error) {
return { ok: false, error: `${threadOption.error}. ${ACP_SPAWN_USAGE}` };
@@ -207,7 +227,7 @@ export function parseSpawnInput(
continue;
}
const cwdOption = readOptionValue({ tokens, index: i, flag: "--cwd" });
const cwdOption = readOptionValue({ tokens: normalizedTokens, index: i, flag: "--cwd" });
if (cwdOption.matched) {
if (cwdOption.error) {
return { ok: false, error: `${cwdOption.error}. ${ACP_SPAWN_USAGE}` };
@@ -217,7 +237,7 @@ export function parseSpawnInput(
continue;
}
const labelOption = readOptionValue({ tokens, index: i, flag: "--label" });
const labelOption = readOptionValue({ tokens: normalizedTokens, index: i, flag: "--label" });
if (labelOption.matched) {
if (labelOption.error) {
return { ok: false, error: `${labelOption.error}. ${ACP_SPAWN_USAGE}` };
@@ -251,7 +271,7 @@ export function parseSpawnInput(
if (!selectedAgent) {
return {
ok: false,
error: `ACP target agent is required. Pass an agent id or configure acp.defaultAgent. ${ACP_SPAWN_USAGE}`,
error: `ACP target harness id is required. Pass an ACP harness id (for example codex) or configure acp.defaultAgent. ${ACP_SPAWN_USAGE}`,
};
}
const normalizedAgentId = normalizeAgentId(selectedAgent);
@@ -271,12 +291,13 @@ export function parseSpawnInput(
export function parseSteerInput(
tokens: string[],
): { ok: true; value: ParsedSteerInput } | { ok: false; error: string } {
const normalizedTokens = tokens.map((token) => normalizeAcpOptionToken(token));
let sessionToken: string | undefined;
const instructionTokens: string[] = [];
for (let i = 0; i < tokens.length; ) {
for (let i = 0; i < normalizedTokens.length; ) {
const sessionOption = readOptionValue({
tokens,
tokens: normalizedTokens,
index: i,
flag: "--session",
});
@@ -292,7 +313,7 @@ export function parseSteerInput(
continue;
}
instructionTokens.push(tokens[i]);
instructionTokens.push(tokens[i] ?? "");
i += 1;
}
@@ -380,7 +401,7 @@ export function resolveAcpHelpText(): string {
return [
"ACP commands:",
"-----",
"/acp spawn [agentId] [--mode persistent|oneshot] [--thread auto|here|off] [--cwd <path>] [--label <label>]",
"/acp spawn [harness-id] [--mode persistent|oneshot] [--thread auto|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]",
@@ -397,6 +418,7 @@ export function resolveAcpHelpText(): string {
"/acp sessions",
"",
"Notes:",
"- /acp spawn harness-id is an ACP runtime harness alias (for example codex), not an OpenClaw agents.list id.",
"- /focus and /unfocus also work with ACP session keys.",
"- ACP dispatch of normal thread messages is controlled by acp.dispatch.enabled.",
].join("\n");