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

@@ -17,19 +17,29 @@ type DiscordAccountParams = {
};
export function isDiscordSurface(params: DiscordSurfaceParams): boolean {
return resolveCommandSurfaceChannel(params) === "discord";
}
export function isTelegramSurface(params: DiscordSurfaceParams): boolean {
return resolveCommandSurfaceChannel(params) === "telegram";
}
export function resolveCommandSurfaceChannel(params: DiscordSurfaceParams): string {
const channel =
params.ctx.OriginatingChannel ??
params.command.channel ??
params.ctx.Surface ??
params.ctx.Provider;
return (
String(channel ?? "")
.trim()
.toLowerCase() === "discord"
);
return String(channel ?? "")
.trim()
.toLowerCase();
}
export function resolveDiscordAccountId(params: DiscordAccountParams): string {
return resolveChannelAccountId(params);
}
export function resolveChannelAccountId(params: DiscordAccountParams): string {
const accountId = typeof params.ctx.AccountId === "string" ? params.ctx.AccountId.trim() : "";
return accountId || "default";
}

View File

@@ -118,7 +118,7 @@ type FakeBinding = {
targetSessionKey: string;
targetKind: "subagent" | "session";
conversation: {
channel: "discord";
channel: "discord" | "telegram";
accountId: string;
conversationId: string;
parentConversationId?: string;
@@ -242,7 +242,11 @@ function createSessionBindingCapabilities() {
type AcpBindInput = {
targetSessionKey: string;
conversation: { accountId: string; conversationId: string };
conversation: {
channel?: "discord" | "telegram";
accountId: string;
conversationId: string;
};
placement: "current" | "child";
metadata?: Record<string, unknown>;
};
@@ -251,14 +255,22 @@ function createAcpThreadBinding(input: AcpBindInput): FakeBinding {
const nextConversationId =
input.placement === "child" ? "thread-created" : input.conversation.conversationId;
const boundBy = typeof input.metadata?.boundBy === "string" ? input.metadata.boundBy : "user-1";
const channel = input.conversation.channel ?? "discord";
return createSessionBinding({
targetSessionKey: input.targetSessionKey,
conversation: {
channel: "discord",
accountId: input.conversation.accountId,
conversationId: nextConversationId,
parentConversationId: "parent-1",
},
conversation:
channel === "discord"
? {
channel: "discord",
accountId: input.conversation.accountId,
conversationId: nextConversationId,
parentConversationId: "parent-1",
}
: {
channel: "telegram",
accountId: input.conversation.accountId,
conversationId: nextConversationId,
},
metadata: { boundBy, webhookId: "wh-1" },
});
}
@@ -297,6 +309,31 @@ function createThreadParams(commandBody: string, cfg: OpenClawConfig = baseCfg)
return params;
}
function createTelegramTopicParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
const params = buildCommandTestParams(commandBody, cfg, {
Provider: "telegram",
Surface: "telegram",
OriginatingChannel: "telegram",
OriginatingTo: "telegram:-1003841603622",
AccountId: "default",
MessageThreadId: "498",
});
params.command.senderId = "user-1";
return params;
}
function createTelegramDmParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
const params = buildCommandTestParams(commandBody, cfg, {
Provider: "telegram",
Surface: "telegram",
OriginatingChannel: "telegram",
OriginatingTo: "telegram:123456789",
AccountId: "default",
});
params.command.senderId = "user-1";
return params;
}
async function runDiscordAcpCommand(commandBody: string, cfg: OpenClawConfig = baseCfg) {
return handleAcpCommand(createDiscordParams(commandBody, cfg), true);
}
@@ -305,6 +342,14 @@ async function runThreadAcpCommand(commandBody: string, cfg: OpenClawConfig = ba
return handleAcpCommand(createThreadParams(commandBody, cfg), true);
}
async function runTelegramAcpCommand(commandBody: string, cfg: OpenClawConfig = baseCfg) {
return handleAcpCommand(createTelegramTopicParams(commandBody, cfg), true);
}
async function runTelegramDmAcpCommand(commandBody: string, cfg: OpenClawConfig = baseCfg) {
return handleAcpCommand(createTelegramDmParams(commandBody, cfg), true);
}
describe("/acp command", () => {
beforeEach(() => {
acpManagerTesting.resetAcpSessionManagerForTests();
@@ -448,10 +493,70 @@ describe("/acp command", () => {
expect(seededWithoutEntry?.runtimeSessionName).toContain(":runtime");
});
it("accepts unicode dash option prefixes in /acp spawn args", async () => {
const result = await runThreadAcpCommand(
"/acp spawn codex \u2014mode oneshot \u2014thread here \u2014cwd /home/bob/clawd \u2014label jeerreview",
);
expect(result?.reply?.text).toContain("Spawned ACP session agent:codex:acp:");
expect(result?.reply?.text).toContain("Bound this thread to");
expect(hoisted.ensureSessionMock).toHaveBeenCalledWith(
expect.objectContaining({
agent: "codex",
mode: "oneshot",
cwd: "/home/bob/clawd",
}),
);
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
expect.objectContaining({
placement: "current",
metadata: expect.objectContaining({
label: "jeerreview",
}),
}),
);
});
it("binds Telegram topic ACP spawns to full conversation ids", async () => {
const result = await runTelegramAcpCommand("/acp spawn codex --thread here");
expect(result?.reply?.text).toContain("Spawned ACP session agent:codex:acp:");
expect(result?.reply?.text).toContain("Bound this conversation to");
expect(result?.reply?.channelData).toEqual({ telegram: { pin: true } });
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
expect.objectContaining({
placement: "current",
conversation: expect.objectContaining({
channel: "telegram",
accountId: "default",
conversationId: "-1003841603622:topic:498",
}),
}),
);
});
it("binds Telegram DM ACP spawns to the DM conversation id", async () => {
const result = await runTelegramDmAcpCommand("/acp spawn codex --thread here");
expect(result?.reply?.text).toContain("Spawned ACP session agent:codex:acp:");
expect(result?.reply?.text).toContain("Bound this conversation to");
expect(result?.reply?.channelData).toBeUndefined();
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
expect.objectContaining({
placement: "current",
conversation: expect.objectContaining({
channel: "telegram",
accountId: "default",
conversationId: "123456789",
}),
}),
);
});
it("requires explicit ACP target when acp.defaultAgent is not configured", async () => {
const result = await runDiscordAcpCommand("/acp spawn");
expect(result?.reply?.text).toContain("ACP target agent is required");
expect(result?.reply?.text).toContain("ACP target harness id is required");
expect(hoisted.ensureSessionMock).not.toHaveBeenCalled();
});
@@ -528,6 +633,42 @@ describe("/acp command", () => {
expect(result?.reply?.text).toContain("Applied steering.");
});
it("resolves bound Telegram topic ACP sessions for /acp steer without explicit target", async () => {
hoisted.sessionBindingResolveByConversationMock.mockImplementation(
(ref: { channel?: string; accountId?: string; conversationId?: string }) =>
ref.channel === "telegram" &&
ref.accountId === "default" &&
ref.conversationId === "-1003841603622:topic:498"
? createSessionBinding({
targetSessionKey: defaultAcpSessionKey,
conversation: {
channel: "telegram",
accountId: "default",
conversationId: "-1003841603622:topic:498",
},
})
: null,
);
hoisted.readAcpSessionEntryMock.mockReturnValue(createAcpSessionEntry());
hoisted.runTurnMock.mockImplementation(async function* () {
yield { type: "text_delta", text: "Viewed diver package." };
yield { type: "done" };
});
const result = await runTelegramAcpCommand("/acp steer use npm to view package diver");
expect(hoisted.runTurnMock).toHaveBeenCalledWith(
expect.objectContaining({
handle: expect.objectContaining({
sessionKey: defaultAcpSessionKey,
}),
mode: "steer",
text: "use npm to view package diver",
}),
);
expect(result?.reply?.text).toContain("Viewed diver package.");
});
it("blocks /acp steer when ACP dispatch is disabled by policy", async () => {
const cfg = {
...baseCfg,

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");

View File

@@ -1,14 +1,21 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionBindingRecord } from "../../infra/outbound/session-binding-service.js";
const hoisted = vi.hoisted(() => {
const getThreadBindingManagerMock = vi.fn();
const setThreadBindingIdleTimeoutBySessionKeyMock = vi.fn();
const setThreadBindingMaxAgeBySessionKeyMock = vi.fn();
const setTelegramThreadBindingIdleTimeoutBySessionKeyMock = vi.fn();
const setTelegramThreadBindingMaxAgeBySessionKeyMock = vi.fn();
const sessionBindingResolveByConversationMock = vi.fn();
return {
getThreadBindingManagerMock,
setThreadBindingIdleTimeoutBySessionKeyMock,
setThreadBindingMaxAgeBySessionKeyMock,
setTelegramThreadBindingIdleTimeoutBySessionKeyMock,
setTelegramThreadBindingMaxAgeBySessionKeyMock,
sessionBindingResolveByConversationMock,
};
});
@@ -22,6 +29,33 @@ vi.mock("../../discord/monitor/thread-bindings.js", async (importOriginal) => {
};
});
vi.mock("../../telegram/thread-bindings.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../telegram/thread-bindings.js")>();
return {
...actual,
setTelegramThreadBindingIdleTimeoutBySessionKey:
hoisted.setTelegramThreadBindingIdleTimeoutBySessionKeyMock,
setTelegramThreadBindingMaxAgeBySessionKey:
hoisted.setTelegramThreadBindingMaxAgeBySessionKeyMock,
};
});
vi.mock("../../infra/outbound/session-binding-service.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../infra/outbound/session-binding-service.js")>();
return {
...actual,
getSessionBindingService: () => ({
bind: vi.fn(),
getCapabilities: vi.fn(),
listBySession: vi.fn(),
resolveByConversation: (ref: unknown) => hoisted.sessionBindingResolveByConversationMock(ref),
touch: vi.fn(),
unbind: vi.fn(),
}),
};
});
const { handleSessionCommand } = await import("./commands-session.js");
const { buildCommandTestParams } = await import("./commands.test-harness.js");
@@ -55,6 +89,18 @@ function createDiscordCommandParams(commandBody: string, overrides?: Record<stri
});
}
function createTelegramCommandParams(commandBody: string, overrides?: Record<string, unknown>) {
return buildCommandTestParams(commandBody, baseCfg, {
Provider: "telegram",
Surface: "telegram",
OriginatingChannel: "telegram",
OriginatingTo: "-100200300:topic:77",
AccountId: "default",
MessageThreadId: "77",
...overrides,
});
}
function createFakeBinding(overrides: Partial<FakeBinding> = {}): FakeBinding {
const now = Date.now();
return {
@@ -71,6 +117,28 @@ function createFakeBinding(overrides: Partial<FakeBinding> = {}): FakeBinding {
};
}
function createTelegramBinding(overrides?: Partial<SessionBindingRecord>): SessionBindingRecord {
return {
bindingId: "default:-100200300:topic:77",
targetSessionKey: "agent:main:subagent:child",
targetKind: "subagent",
conversation: {
channel: "telegram",
accountId: "default",
conversationId: "-100200300:topic:77",
},
status: "active",
boundAt: Date.now(),
metadata: {
boundBy: "user-1",
lastActivityAt: Date.now(),
idleTimeoutMs: 24 * 60 * 60 * 1000,
maxAgeMs: 0,
},
...overrides,
};
}
function createFakeThreadBindingManager(binding: FakeBinding | null) {
return {
getByThreadId: vi.fn((_threadId: string) => binding),
@@ -81,13 +149,16 @@ function createFakeThreadBindingManager(binding: FakeBinding | null) {
describe("/session idle and /session max-age", () => {
beforeEach(() => {
hoisted.getThreadBindingManagerMock.mockClear();
hoisted.setThreadBindingIdleTimeoutBySessionKeyMock.mockClear();
hoisted.setThreadBindingMaxAgeBySessionKeyMock.mockClear();
hoisted.getThreadBindingManagerMock.mockReset();
hoisted.setThreadBindingIdleTimeoutBySessionKeyMock.mockReset();
hoisted.setThreadBindingMaxAgeBySessionKeyMock.mockReset();
hoisted.setTelegramThreadBindingIdleTimeoutBySessionKeyMock.mockReset();
hoisted.setTelegramThreadBindingMaxAgeBySessionKeyMock.mockReset();
hoisted.sessionBindingResolveByConversationMock.mockReset().mockReturnValue(null);
vi.useRealTimers();
});
it("sets idle timeout for the focused session", async () => {
it("sets idle timeout for the focused Discord session", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
@@ -128,7 +199,7 @@ describe("/session idle and /session max-age", () => {
expect(result?.reply?.text).toContain("2026-02-20T02:00:00.000Z");
});
it("sets max age for the focused session", async () => {
it("sets max age for the focused Discord session", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
@@ -157,6 +228,67 @@ describe("/session idle and /session max-age", () => {
expect(text).toContain("2026-02-20T03:00:00.000Z");
});
it("sets idle timeout for focused Telegram conversations", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(createTelegramBinding());
hoisted.setTelegramThreadBindingIdleTimeoutBySessionKeyMock.mockReturnValue([
{
targetSessionKey: "agent:main:subagent:child",
boundAt: Date.now(),
lastActivityAt: Date.now(),
idleTimeoutMs: 2 * 60 * 60 * 1000,
},
]);
const result = await handleSessionCommand(
createTelegramCommandParams("/session idle 2h"),
true,
);
const text = result?.reply?.text ?? "";
expect(hoisted.setTelegramThreadBindingIdleTimeoutBySessionKeyMock).toHaveBeenCalledWith({
targetSessionKey: "agent:main:subagent:child",
accountId: "default",
idleTimeoutMs: 2 * 60 * 60 * 1000,
});
expect(text).toContain("Idle timeout set to 2h");
expect(text).toContain("2026-02-20T02:00:00.000Z");
});
it("reports Telegram max-age expiry from the original bind time", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
const boundAt = Date.parse("2026-02-19T22:00:00.000Z");
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(
createTelegramBinding({ boundAt }),
);
hoisted.setTelegramThreadBindingMaxAgeBySessionKeyMock.mockReturnValue([
{
targetSessionKey: "agent:main:subagent:child",
boundAt,
lastActivityAt: Date.now(),
maxAgeMs: 3 * 60 * 60 * 1000,
},
]);
const result = await handleSessionCommand(
createTelegramCommandParams("/session max-age 3h"),
true,
);
const text = result?.reply?.text ?? "";
expect(hoisted.setTelegramThreadBindingMaxAgeBySessionKeyMock).toHaveBeenCalledWith({
targetSessionKey: "agent:main:subagent:child",
accountId: "default",
maxAgeMs: 3 * 60 * 60 * 1000,
});
expect(text).toContain("Max age set to 3h");
expect(text).toContain("2026-02-20T01:00:00.000Z");
});
it("disables max age when set to off", async () => {
const binding = createFakeBinding({ maxAgeMs: 2 * 60 * 60 * 1000 });
hoisted.getThreadBindingManagerMock.mockReturnValue(createFakeThreadBindingManager(binding));
@@ -175,10 +307,12 @@ describe("/session idle and /session max-age", () => {
expect(result?.reply?.text).toContain("Max age disabled");
});
it("is unavailable outside discord", async () => {
it("is unavailable outside discord and telegram", async () => {
const params = buildCommandTestParams("/session idle 2h", baseCfg);
const result = await handleSessionCommand(params, true);
expect(result?.reply?.text).toContain("currently available for Discord thread-bound sessions");
expect(result?.reply?.text).toContain(
"currently available for Discord and Telegram bound sessions",
);
});
it("requires binding owner for lifecycle updates", async () => {

View File

@@ -11,16 +11,23 @@ import {
setThreadBindingMaxAgeBySessionKey,
} from "../../discord/monitor/thread-bindings.js";
import { logVerbose } from "../../globals.js";
import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js";
import type { SessionBindingRecord } from "../../infra/outbound/session-binding-service.js";
import { scheduleGatewaySigusr1Restart, triggerOpenClawRestart } from "../../infra/restart.js";
import { loadCostUsageSummary, loadSessionCostSummary } from "../../infra/session-cost-usage.js";
import {
setTelegramThreadBindingIdleTimeoutBySessionKey,
setTelegramThreadBindingMaxAgeBySessionKey,
} from "../../telegram/thread-bindings.js";
import { formatTokenCount, formatUsd } from "../../utils/usage-format.js";
import { parseActivationCommand } from "../group-activation.js";
import { parseSendPolicyCommand } from "../send-policy.js";
import { normalizeUsageDisplay, resolveResponseUsageMode } from "../thinking.js";
import { isDiscordSurface, isTelegramSurface, resolveChannelAccountId } from "./channel-context.js";
import { handleAbortTrigger, handleStopCommand } from "./commands-session-abort.js";
import { persistSessionEntry } from "./commands-session-store.js";
import type { CommandHandler } from "./commands-types.js";
import { isDiscordSurface, resolveDiscordAccountId } from "./discord-context.js";
import { resolveTelegramConversationId } from "./telegram-context.js";
const SESSION_COMMAND_PREFIX = "/session";
const SESSION_DURATION_OFF_VALUES = new Set(["off", "disable", "disabled", "none", "0"]);
@@ -53,6 +60,72 @@ function formatSessionExpiry(expiresAt: number) {
return new Date(expiresAt).toISOString();
}
function resolveTelegramBindingDurationMs(
binding: SessionBindingRecord,
key: "idleTimeoutMs" | "maxAgeMs",
fallbackMs: number,
): number {
const raw = binding.metadata?.[key];
if (typeof raw !== "number" || !Number.isFinite(raw)) {
return fallbackMs;
}
return Math.max(0, Math.floor(raw));
}
function resolveTelegramBindingLastActivityAt(binding: SessionBindingRecord): number {
const raw = binding.metadata?.lastActivityAt;
if (typeof raw !== "number" || !Number.isFinite(raw)) {
return binding.boundAt;
}
return Math.max(Math.floor(raw), binding.boundAt);
}
function resolveTelegramBindingBoundBy(binding: SessionBindingRecord): string {
const raw = binding.metadata?.boundBy;
return typeof raw === "string" ? raw.trim() : "";
}
type UpdatedLifecycleBinding = {
boundAt: number;
lastActivityAt: number;
idleTimeoutMs?: number;
maxAgeMs?: number;
};
function resolveUpdatedBindingExpiry(params: {
action: typeof SESSION_ACTION_IDLE | typeof SESSION_ACTION_MAX_AGE;
bindings: UpdatedLifecycleBinding[];
}): number | undefined {
const expiries = params.bindings
.map((binding) => {
if (params.action === SESSION_ACTION_IDLE) {
const idleTimeoutMs =
typeof binding.idleTimeoutMs === "number" && Number.isFinite(binding.idleTimeoutMs)
? Math.max(0, Math.floor(binding.idleTimeoutMs))
: 0;
if (idleTimeoutMs <= 0) {
return undefined;
}
return Math.max(binding.lastActivityAt, binding.boundAt) + idleTimeoutMs;
}
const maxAgeMs =
typeof binding.maxAgeMs === "number" && Number.isFinite(binding.maxAgeMs)
? Math.max(0, Math.floor(binding.maxAgeMs))
: 0;
if (maxAgeMs <= 0) {
return undefined;
}
return binding.boundAt + maxAgeMs;
})
.filter((expiresAt): expiresAt is number => typeof expiresAt === "number");
if (expiries.length === 0) {
return undefined;
}
return Math.min(...expiries);
}
export const handleActivationCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) {
return null;
@@ -243,59 +316,98 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
};
}
if (!isDiscordSurface(params)) {
const onDiscord = isDiscordSurface(params);
const onTelegram = isTelegramSurface(params);
if (!onDiscord && !onTelegram) {
return {
shouldContinue: false,
reply: {
text: "⚠️ /session idle and /session max-age are currently available for Discord thread-bound sessions.",
text: "⚠️ /session idle and /session max-age are currently available for Discord and Telegram bound sessions.",
},
};
}
const accountId = resolveChannelAccountId(params);
const sessionBindingService = getSessionBindingService();
const threadId =
params.ctx.MessageThreadId != null ? String(params.ctx.MessageThreadId).trim() : "";
if (!threadId) {
return {
shouldContinue: false,
reply: {
text: "⚠️ /session idle and /session max-age must be run inside a focused Discord thread.",
},
};
}
const telegramConversationId = onTelegram ? resolveTelegramConversationId(params) : undefined;
const accountId = resolveDiscordAccountId(params);
const threadBindings = getThreadBindingManager(accountId);
if (!threadBindings) {
const discordManager = onDiscord ? getThreadBindingManager(accountId) : null;
if (onDiscord && !discordManager) {
return {
shouldContinue: false,
reply: { text: "⚠️ Discord thread bindings are unavailable for this account." },
};
}
const binding = threadBindings.getByThreadId(threadId);
if (!binding) {
const discordBinding =
onDiscord && threadId ? discordManager?.getByThreadId(threadId) : undefined;
const telegramBinding =
onTelegram && telegramConversationId
? sessionBindingService.resolveByConversation({
channel: "telegram",
accountId,
conversationId: telegramConversationId,
})
: null;
if (onDiscord && !discordBinding) {
if (onDiscord && !threadId) {
return {
shouldContinue: false,
reply: {
text: "⚠️ /session idle and /session max-age must be run inside a focused Discord thread.",
},
};
}
return {
shouldContinue: false,
reply: { text: " This thread is not currently focused." },
};
}
if (onTelegram && !telegramBinding) {
if (!telegramConversationId) {
return {
shouldContinue: false,
reply: {
text: "⚠️ /session idle and /session max-age on Telegram require a topic context in groups, or a direct-message conversation.",
},
};
}
return {
shouldContinue: false,
reply: { text: " This conversation is not currently focused." },
};
}
const idleTimeoutMs = resolveThreadBindingIdleTimeoutMs({
record: binding,
defaultIdleTimeoutMs: threadBindings.getIdleTimeoutMs(),
});
const idleExpiresAt = resolveThreadBindingInactivityExpiresAt({
record: binding,
defaultIdleTimeoutMs: threadBindings.getIdleTimeoutMs(),
});
const maxAgeMs = resolveThreadBindingMaxAgeMs({
record: binding,
defaultMaxAgeMs: threadBindings.getMaxAgeMs(),
});
const maxAgeExpiresAt = resolveThreadBindingMaxAgeExpiresAt({
record: binding,
defaultMaxAgeMs: threadBindings.getMaxAgeMs(),
});
const idleTimeoutMs = onDiscord
? resolveThreadBindingIdleTimeoutMs({
record: discordBinding!,
defaultIdleTimeoutMs: discordManager!.getIdleTimeoutMs(),
})
: resolveTelegramBindingDurationMs(telegramBinding!, "idleTimeoutMs", 24 * 60 * 60 * 1000);
const idleExpiresAt = onDiscord
? resolveThreadBindingInactivityExpiresAt({
record: discordBinding!,
defaultIdleTimeoutMs: discordManager!.getIdleTimeoutMs(),
})
: idleTimeoutMs > 0
? resolveTelegramBindingLastActivityAt(telegramBinding!) + idleTimeoutMs
: undefined;
const maxAgeMs = onDiscord
? resolveThreadBindingMaxAgeMs({
record: discordBinding!,
defaultMaxAgeMs: discordManager!.getMaxAgeMs(),
})
: resolveTelegramBindingDurationMs(telegramBinding!, "maxAgeMs", 0);
const maxAgeExpiresAt = onDiscord
? resolveThreadBindingMaxAgeExpiresAt({
record: discordBinding!,
defaultMaxAgeMs: discordManager!.getMaxAgeMs(),
})
: maxAgeMs > 0
? telegramBinding!.boundAt + maxAgeMs
: undefined;
const durationArgRaw = tokens.slice(1).join("");
if (!durationArgRaw) {
@@ -337,11 +449,16 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
}
const senderId = params.command.senderId?.trim() || "";
if (binding.boundBy && binding.boundBy !== "system" && senderId && senderId !== binding.boundBy) {
const boundBy = onDiscord
? discordBinding!.boundBy
: resolveTelegramBindingBoundBy(telegramBinding!);
if (boundBy && boundBy !== "system" && senderId && senderId !== boundBy) {
return {
shouldContinue: false,
reply: {
text: `⚠️ Only ${binding.boundBy} can update session lifecycle settings for this thread.`,
text: onDiscord
? `⚠️ Only ${boundBy} can update session lifecycle settings for this thread.`
: `⚠️ Only ${boundBy} can update session lifecycle settings for this conversation.`,
},
};
}
@@ -356,18 +473,32 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
};
}
const updatedBindings =
action === SESSION_ACTION_IDLE
? setThreadBindingIdleTimeoutBySessionKey({
targetSessionKey: binding.targetSessionKey,
const updatedBindings = (() => {
if (onDiscord) {
return action === SESSION_ACTION_IDLE
? setThreadBindingIdleTimeoutBySessionKey({
targetSessionKey: discordBinding!.targetSessionKey,
accountId,
idleTimeoutMs: durationMs,
})
: setThreadBindingMaxAgeBySessionKey({
targetSessionKey: discordBinding!.targetSessionKey,
accountId,
maxAgeMs: durationMs,
});
}
return action === SESSION_ACTION_IDLE
? setTelegramThreadBindingIdleTimeoutBySessionKey({
targetSessionKey: telegramBinding!.targetSessionKey,
accountId,
idleTimeoutMs: durationMs,
})
: setThreadBindingMaxAgeBySessionKey({
targetSessionKey: binding.targetSessionKey,
: setTelegramThreadBindingMaxAgeBySessionKey({
targetSessionKey: telegramBinding!.targetSessionKey,
accountId,
maxAgeMs: durationMs,
});
})();
if (updatedBindings.length === 0) {
return {
shouldContinue: false,
@@ -392,17 +523,10 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
};
}
const nextBinding = updatedBindings[0];
const nextExpiry =
action === SESSION_ACTION_IDLE
? resolveThreadBindingInactivityExpiresAt({
record: nextBinding,
defaultIdleTimeoutMs: threadBindings.getIdleTimeoutMs(),
})
: resolveThreadBindingMaxAgeExpiresAt({
record: nextBinding,
defaultMaxAgeMs: threadBindings.getMaxAgeMs(),
});
const nextExpiry = resolveUpdatedBindingExpiry({
action,
bindings: updatedBindings,
});
const expiryLabel =
typeof nextExpiry === "number" && Number.isFinite(nextExpiry)
? formatSessionExpiry(nextExpiry)

View File

@@ -9,8 +9,6 @@ import { installSubagentsCommandCoreMocks } from "./commands-subagents.test-mock
const hoisted = vi.hoisted(() => {
const callGatewayMock = vi.fn();
const getThreadBindingManagerMock = vi.fn();
const resolveThreadBindingThreadNameMock = vi.fn(() => "🤖 codex");
const readAcpSessionEntryMock = vi.fn();
const sessionBindingCapabilitiesMock = vi.fn();
const sessionBindingBindMock = vi.fn();
@@ -19,8 +17,6 @@ const hoisted = vi.hoisted(() => {
const sessionBindingUnbindMock = vi.fn();
return {
callGatewayMock,
getThreadBindingManagerMock,
resolveThreadBindingThreadNameMock,
readAcpSessionEntryMock,
sessionBindingCapabilitiesMock,
sessionBindingBindMock,
@@ -31,7 +27,7 @@ const hoisted = vi.hoisted(() => {
});
function buildFocusSessionBindingService() {
const service = {
return {
touch: vi.fn(),
listBySession(targetSessionKey: string) {
return hoisted.sessionBindingListBySessionMock(targetSessionKey);
@@ -49,7 +45,6 @@ function buildFocusSessionBindingService() {
return hoisted.sessionBindingUnbindMock(input);
},
};
return service;
}
vi.mock("../../gateway/call.js", () => ({
@@ -64,15 +59,6 @@ vi.mock("../../acp/runtime/session-meta.js", async (importOriginal) => {
};
});
vi.mock("../../discord/monitor/thread-bindings.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../discord/monitor/thread-bindings.js")>();
return {
...actual,
getThreadBindingManager: hoisted.getThreadBindingManagerMock,
resolveThreadBindingThreadName: hoisted.resolveThreadBindingThreadNameMock,
};
});
vi.mock("../../infra/outbound/session-binding-service.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../infra/outbound/session-binding-service.js")>();
@@ -87,95 +73,6 @@ installSubagentsCommandCoreMocks();
const { handleSubagentsCommand } = await import("./commands-subagents.js");
const { buildCommandTestParams } = await import("./commands-spawn.test-harness.js");
type FakeBinding = {
accountId: string;
channelId: string;
threadId: string;
targetKind: "subagent" | "acp";
targetSessionKey: string;
agentId: string;
label?: string;
webhookId?: string;
webhookToken?: string;
boundBy: string;
boundAt: number;
};
function createFakeBinding(
overrides: Pick<FakeBinding, "threadId" | "targetKind" | "targetSessionKey" | "agentId"> &
Partial<FakeBinding>,
): FakeBinding {
return {
accountId: "default",
channelId: "parent-1",
boundBy: "user-1",
boundAt: Date.now(),
...overrides,
};
}
function expectAgentListContainsThreadBinding(text: string, label: string, threadId: string): void {
expect(text).toContain("agents:");
expect(text).toContain(label);
expect(text).toContain(`thread:${threadId}`);
}
function createFakeThreadBindingManager(initialBindings: FakeBinding[] = []) {
const byThread = new Map<string, FakeBinding>(
initialBindings.map((binding) => [binding.threadId, binding]),
);
const manager = {
getIdleTimeoutMs: vi.fn(() => 24 * 60 * 60 * 1000),
getMaxAgeMs: vi.fn(() => 0),
getByThreadId: vi.fn((threadId: string) => byThread.get(threadId)),
listBySessionKey: vi.fn((targetSessionKey: string) =>
[...byThread.values()].filter((binding) => binding.targetSessionKey === targetSessionKey),
),
listBindings: vi.fn(() => [...byThread.values()]),
bindTarget: vi.fn(async (params: Record<string, unknown>) => {
const threadId =
typeof params.threadId === "string" && params.threadId.trim()
? params.threadId.trim()
: "thread-created";
const targetSessionKey =
typeof params.targetSessionKey === "string" ? params.targetSessionKey.trim() : "";
const agentId =
typeof params.agentId === "string" && params.agentId.trim()
? params.agentId.trim()
: "main";
const binding: FakeBinding = {
accountId: "default",
channelId:
typeof params.channelId === "string" && params.channelId.trim()
? params.channelId.trim()
: "parent-1",
threadId,
targetKind:
params.targetKind === "subagent" || params.targetKind === "acp"
? params.targetKind
: "acp",
targetSessionKey,
agentId,
label: typeof params.label === "string" ? params.label : undefined,
boundBy: typeof params.boundBy === "string" ? params.boundBy : "system",
boundAt: Date.now(),
};
byThread.set(threadId, binding);
return binding;
}),
unbindThread: vi.fn((params: { threadId: string }) => {
const binding = byThread.get(params.threadId) ?? null;
if (binding) {
byThread.delete(params.threadId);
}
return binding;
}),
};
return { manager, byThread };
}
const baseCfg = {
session: { mainKey: "main", scope: "per-sender" },
} satisfies OpenClawConfig;
@@ -193,19 +90,17 @@ function createDiscordCommandParams(commandBody: string) {
return params;
}
function createStoredBinding(overrides?: Partial<FakeBinding>): FakeBinding {
return {
accountId: "default",
channelId: "parent-1",
threadId: "thread-1",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:child",
agentId: "main",
label: "child",
boundBy: "user-1",
boundAt: Date.now(),
...overrides,
};
function createTelegramTopicCommandParams(commandBody: string) {
const params = buildCommandTestParams(commandBody, baseCfg, {
Provider: "telegram",
Surface: "telegram",
OriginatingChannel: "telegram",
OriginatingTo: "-100200300:topic:77",
AccountId: "default",
MessageThreadId: "77",
});
params.command.senderId = "user-1";
return params;
}
function createSessionBindingRecord(
@@ -240,38 +135,24 @@ function createSessionBindingCapabilities() {
};
}
async function runUnfocusAndExpectManualUnbind(initialBindings: FakeBinding[]) {
const fake = createFakeThreadBindingManager(initialBindings);
hoisted.getThreadBindingManagerMock.mockReturnValue(fake.manager);
const params = createDiscordCommandParams("/unfocus");
const result = await handleSubagentsCommand(params, true);
expect(result?.reply?.text).toContain("Thread unfocused");
expect(fake.manager.unbindThread).toHaveBeenCalledWith(
expect.objectContaining({
threadId: "thread-1",
reason: "manual",
}),
);
}
async function focusCodexAcpInThread(options?: { existingBinding?: SessionBindingRecord | null }) {
async function focusCodexAcp(
params = createDiscordCommandParams("/focus codex-acp"),
options?: { existingBinding?: SessionBindingRecord | null },
) {
hoisted.sessionBindingCapabilitiesMock.mockReturnValue(createSessionBindingCapabilities());
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(options?.existingBinding ?? null);
hoisted.sessionBindingBindMock.mockImplementation(
async (input: {
targetSessionKey: string;
conversation: { accountId: string; conversationId: string };
conversation: { channel: string; accountId: string; conversationId: string };
metadata?: Record<string, unknown>;
}) =>
createSessionBindingRecord({
targetSessionKey: input.targetSessionKey,
conversation: {
channel: "discord",
channel: input.conversation.channel,
accountId: input.conversation.accountId,
conversationId: input.conversation.conversationId,
parentConversationId: "parent-1",
},
metadata: {
boundBy: typeof input.metadata?.boundBy === "string" ? input.metadata.boundBy : "user-1",
@@ -285,23 +166,13 @@ async function focusCodexAcpInThread(options?: { existingBinding?: SessionBindin
}
return {};
});
const params = createDiscordCommandParams("/focus codex-acp");
const result = await handleSubagentsCommand(params, true);
return { result };
}
async function runAgentsCommandAndText(): Promise<string> {
const params = createDiscordCommandParams("/agents");
const result = await handleSubagentsCommand(params, true);
return result?.reply?.text ?? "";
return await handleSubagentsCommand(params, true);
}
describe("/focus, /unfocus, /agents", () => {
beforeEach(() => {
resetSubagentRegistryForTests();
hoisted.callGatewayMock.mockClear();
hoisted.getThreadBindingManagerMock.mockClear().mockReturnValue(null);
hoisted.resolveThreadBindingThreadNameMock.mockClear().mockReturnValue("🤖 codex");
hoisted.callGatewayMock.mockReset();
hoisted.readAcpSessionEntryMock.mockReset().mockReturnValue(null);
hoisted.sessionBindingCapabilitiesMock
.mockReset()
@@ -313,7 +184,7 @@ describe("/focus, /unfocus, /agents", () => {
});
it("/focus resolves ACP sessions and binds the current Discord thread", async () => {
const { result } = await focusCodexAcpInThread();
const result = await focusCodexAcp();
expect(result?.reply?.text).toContain("bound this thread");
expect(result?.reply?.text).toContain("(acp)");
@@ -322,6 +193,10 @@ describe("/focus, /unfocus, /agents", () => {
placement: "current",
targetKind: "session",
targetSessionKey: "agent:codex-acp:session-1",
conversation: expect.objectContaining({
channel: "discord",
conversationId: "thread-1",
}),
metadata: expect.objectContaining({
introText:
"⚙️ codex-acp session active (idle auto-unfocus after 24h inactivity). Messages here go directly to this session.",
@@ -330,6 +205,21 @@ describe("/focus, /unfocus, /agents", () => {
);
});
it("/focus binds Telegram topics as current conversations", async () => {
const result = await focusCodexAcp(createTelegramTopicCommandParams("/focus codex-acp"));
expect(result?.reply?.text).toContain("bound this conversation");
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
expect.objectContaining({
placement: "current",
conversation: expect.objectContaining({
channel: "telegram",
conversationId: "-100200300:topic:77",
}),
}),
);
});
it("/focus includes ACP session identifiers in intro text when available", async () => {
hoisted.readAcpSessionEntryMock.mockReturnValue({
sessionKey: "agent:codex-acp:session-1",
@@ -350,9 +240,8 @@ describe("/focus, /unfocus, /agents", () => {
lastActivityAt: Date.now(),
},
});
const { result } = await focusCodexAcpInThread();
await focusCodexAcp();
expect(result?.reply?.text).toContain("bound this thread");
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
expect.objectContaining({
metadata: expect.objectContaining({
@@ -376,27 +265,28 @@ describe("/focus, /unfocus, /agents", () => {
);
});
it("/unfocus removes an active thread binding for the binding owner", async () => {
await runUnfocusAndExpectManualUnbind([createStoredBinding()]);
});
it("/unfocus also unbinds ACP-focused thread bindings", async () => {
await runUnfocusAndExpectManualUnbind([
createStoredBinding({
targetKind: "acp",
targetSessionKey: "agent:codex:acp:session-1",
agentId: "codex",
label: "codex-session",
it("/unfocus removes an active binding for the binding owner", async () => {
const params = createDiscordCommandParams("/unfocus");
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(
createSessionBindingRecord({
bindingId: "default:thread-1",
metadata: { boundBy: "user-1" },
}),
]);
);
const result = await handleSubagentsCommand(params, true);
expect(result?.reply?.text).toContain("Thread unfocused");
expect(hoisted.sessionBindingUnbindMock).toHaveBeenCalledWith({
bindingId: "default:thread-1",
reason: "manual",
});
});
it("/focus rejects rebinding when the thread is focused by another user", async () => {
const { result } = await focusCodexAcpInThread({
const result = await focusCodexAcp(undefined, {
existingBinding: createSessionBindingRecord({
metadata: {
boundBy: "user-2",
},
metadata: { boundBy: "user-2" },
}),
});
@@ -404,7 +294,7 @@ describe("/focus, /unfocus, /agents", () => {
expect(hoisted.sessionBindingBindMock).not.toHaveBeenCalled();
});
it("/agents includes bound persistent sessions and requester-scoped ACP bindings", async () => {
it("/agents includes active conversation bindings on the current channel/account", async () => {
addSubagentRunForTests({
runId: "run-1",
childSessionKey: "agent:main:subagent:child-1",
@@ -416,41 +306,61 @@ describe("/focus, /unfocus, /agents", () => {
createdAt: Date.now(),
});
const fake = createFakeThreadBindingManager([
createFakeBinding({
threadId: "thread-1",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:child-1",
agentId: "main",
label: "child-1",
}),
createFakeBinding({
threadId: "thread-2",
targetKind: "acp",
targetSessionKey: "agent:main:main",
agentId: "codex-acp",
label: "main-session",
}),
createFakeBinding({
threadId: "thread-3",
targetKind: "acp",
targetSessionKey: "agent:codex-acp:session-2",
agentId: "codex-acp",
label: "codex-acp",
}),
]);
hoisted.getThreadBindingManagerMock.mockReturnValue(fake.manager);
hoisted.sessionBindingListBySessionMock.mockImplementation((sessionKey: string) => {
if (sessionKey === "agent:main:subagent:child-1") {
return [
createSessionBindingRecord({
bindingId: "default:thread-1",
targetSessionKey: sessionKey,
targetKind: "subagent",
conversation: {
channel: "discord",
accountId: "default",
conversationId: "thread-1",
},
}),
];
}
if (sessionKey === "agent:main:main") {
return [
createSessionBindingRecord({
bindingId: "default:thread-2",
targetSessionKey: sessionKey,
targetKind: "session",
conversation: {
channel: "discord",
accountId: "default",
conversationId: "thread-2",
},
metadata: { label: "main-session" },
}),
// Mismatched channel should be filtered.
createSessionBindingRecord({
bindingId: "default:tg-1",
targetSessionKey: sessionKey,
targetKind: "session",
conversation: {
channel: "telegram",
accountId: "default",
conversationId: "12345",
},
}),
];
}
return [];
});
const text = await runAgentsCommandAndText();
const result = await handleSubagentsCommand(createDiscordCommandParams("/agents"), true);
const text = result?.reply?.text ?? "";
expect(text).toContain("agents:");
expect(text).toContain("thread:thread-1");
expect(text).toContain("acp/session bindings:");
expect(text).toContain("session:agent:main:main");
expect(text).not.toContain("session:agent:codex-acp:session-2");
expect(text).not.toContain("default:tg-1");
});
it("/agents keeps finished session-mode runs visible while their thread binding remains", async () => {
it("/agents keeps finished session-mode runs visible while binding remains", async () => {
addSubagentRunForTests({
runId: "run-session-1",
childSessionKey: "agent:main:subagent:persistent-1",
@@ -463,26 +373,34 @@ describe("/focus, /unfocus, /agents", () => {
createdAt: Date.now(),
endedAt: Date.now(),
});
hoisted.sessionBindingListBySessionMock.mockImplementation((sessionKey: string) => {
if (sessionKey !== "agent:main:subagent:persistent-1") {
return [];
}
return [
createSessionBindingRecord({
bindingId: "default:thread-persistent-1",
targetSessionKey: sessionKey,
targetKind: "subagent",
conversation: {
channel: "discord",
accountId: "default",
conversationId: "thread-persistent-1",
},
}),
];
});
const fake = createFakeThreadBindingManager([
createFakeBinding({
threadId: "thread-persistent-1",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:persistent-1",
agentId: "main",
label: "persistent-1",
}),
]);
hoisted.getThreadBindingManagerMock.mockReturnValue(fake.manager);
const result = await handleSubagentsCommand(createDiscordCommandParams("/agents"), true);
const text = result?.reply?.text ?? "";
const text = await runAgentsCommandAndText();
expectAgentListContainsThreadBinding(text, "persistent-1", "thread-persistent-1");
expect(text).toContain("persistent-1");
expect(text).toContain("thread:thread-persistent-1");
});
it("/focus is discord-only", async () => {
it("/focus rejects unsupported channels", async () => {
const params = buildCommandTestParams("/focus codex-acp", baseCfg);
const result = await handleSubagentsCommand(params, true);
expect(result?.reply?.text).toContain("only available on Discord");
expect(result?.reply?.text).toContain("only available on Discord and Telegram");
});
});

View File

@@ -70,7 +70,7 @@ export const handleSubagentsCommand: CommandHandler = async (params, allowTextCo
case "focus":
return await handleSubagentsFocusAction(ctx);
case "unfocus":
return handleSubagentsUnfocusAction(ctx);
return await handleSubagentsUnfocusAction(ctx);
case "list":
return handleSubagentsListAction(ctx);
case "kill":

View File

@@ -1,23 +1,55 @@
import { getThreadBindingManager } from "../../../discord/monitor/thread-bindings.js";
import { getSessionBindingService } from "../../../infra/outbound/session-binding-service.js";
import type { CommandHandlerResult } from "../commands-types.js";
import { formatRunLabel, sortSubagentRuns } from "../subagents-utils.js";
import {
type SubagentsCommandContext,
isDiscordSurface,
resolveDiscordAccountId,
resolveChannelAccountId,
resolveCommandSurfaceChannel,
stopWithText,
} from "./shared.js";
function formatConversationBindingText(params: {
channel: string;
conversationId: string;
}): string {
if (params.channel === "discord") {
return `thread:${params.conversationId}`;
}
if (params.channel === "telegram") {
return `conversation:${params.conversationId}`;
}
return `binding:${params.conversationId}`;
}
export function handleSubagentsAgentsAction(ctx: SubagentsCommandContext): CommandHandlerResult {
const { params, requesterKey, runs } = ctx;
const isDiscord = isDiscordSurface(params);
const accountId = isDiscord ? resolveDiscordAccountId(params) : undefined;
const threadBindings = accountId ? getThreadBindingManager(accountId) : null;
const channel = resolveCommandSurfaceChannel(params);
const accountId = resolveChannelAccountId(params);
const bindingService = getSessionBindingService();
const bindingsBySession = new Map<string, ReturnType<typeof bindingService.listBySession>>();
const resolveSessionBindings = (sessionKey: string) => {
const cached = bindingsBySession.get(sessionKey);
if (cached) {
return cached;
}
const resolved = bindingService
.listBySession(sessionKey)
.filter(
(entry) =>
entry.status === "active" &&
entry.conversation.channel === channel &&
entry.conversation.accountId === accountId,
);
bindingsBySession.set(sessionKey, resolved);
return resolved;
};
const visibleRuns = sortSubagentRuns(runs).filter((entry) => {
if (!entry.endedAt) {
return true;
}
return Boolean(threadBindings?.listBySessionKey(entry.childSessionKey)[0]);
return resolveSessionBindings(entry.childSessionKey).length > 0;
});
const lines = ["agents:", "-----"];
@@ -26,28 +58,36 @@ export function handleSubagentsAgentsAction(ctx: SubagentsCommandContext): Comma
} else {
let index = 1;
for (const entry of visibleRuns) {
const threadBinding = threadBindings?.listBySessionKey(entry.childSessionKey)[0];
const bindingText = threadBinding
? `thread:${threadBinding.threadId}`
: isDiscord
const binding = resolveSessionBindings(entry.childSessionKey)[0];
const bindingText = binding
? formatConversationBindingText({
channel,
conversationId: binding.conversation.conversationId,
})
: channel === "discord" || channel === "telegram"
? "unbound"
: "bindings available on discord";
: "bindings available on discord/telegram";
lines.push(`${index}. ${formatRunLabel(entry)} (${bindingText})`);
index += 1;
}
}
if (threadBindings) {
const acpBindings = threadBindings
.listBindings()
.filter((entry) => entry.targetKind === "acp" && entry.targetSessionKey === requesterKey);
if (acpBindings.length > 0) {
lines.push("", "acp/session bindings:", "-----");
for (const binding of acpBindings) {
lines.push(
`- ${binding.label ?? binding.targetSessionKey} (thread:${binding.threadId}, session:${binding.targetSessionKey})`,
);
}
const requesterBindings = resolveSessionBindings(requesterKey).filter(
(entry) => entry.targetKind === "session",
);
if (requesterBindings.length > 0) {
lines.push("", "acp/session bindings:", "-----");
for (const binding of requesterBindings) {
const label =
typeof binding.metadata?.label === "string" && binding.metadata.label.trim()
? binding.metadata.label.trim()
: binding.targetSessionKey;
lines.push(
`- ${label} (${formatConversationBindingText({
channel,
conversationId: binding.conversation.conversationId,
})}, session:${binding.targetSessionKey})`,
);
}
}

View File

@@ -4,28 +4,77 @@ import {
} from "../../../acp/runtime/session-identifiers.js";
import { readAcpSessionEntry } from "../../../acp/runtime/session-meta.js";
import {
resolveDiscordThreadBindingIdleTimeoutMs,
resolveDiscordThreadBindingMaxAgeMs,
resolveThreadBindingIntroText,
resolveThreadBindingThreadName,
} from "../../../discord/monitor/thread-bindings.js";
} from "../../../channels/thread-bindings-messages.js";
import {
resolveThreadBindingIdleTimeoutMsForChannel,
resolveThreadBindingMaxAgeMsForChannel,
} from "../../../channels/thread-bindings-policy.js";
import { getSessionBindingService } from "../../../infra/outbound/session-binding-service.js";
import type { CommandHandlerResult } from "../commands-types.js";
import {
type SubagentsCommandContext,
isDiscordSurface,
resolveDiscordAccountId,
isTelegramSurface,
resolveChannelAccountId,
resolveCommandSurfaceChannel,
resolveDiscordChannelIdForFocus,
resolveFocusTargetSession,
resolveTelegramConversationId,
stopWithText,
} from "./shared.js";
type FocusBindingContext = {
channel: "discord" | "telegram";
accountId: string;
conversationId: string;
placement: "current" | "child";
labelNoun: "thread" | "conversation";
};
function resolveFocusBindingContext(
params: SubagentsCommandContext["params"],
): FocusBindingContext | null {
if (isDiscordSurface(params)) {
const currentThreadId =
params.ctx.MessageThreadId != null ? String(params.ctx.MessageThreadId).trim() : "";
const parentChannelId = currentThreadId ? undefined : resolveDiscordChannelIdForFocus(params);
const conversationId = currentThreadId || parentChannelId;
if (!conversationId) {
return null;
}
return {
channel: "discord",
accountId: resolveChannelAccountId(params),
conversationId,
placement: currentThreadId ? "current" : "child",
labelNoun: "thread",
};
}
if (isTelegramSurface(params)) {
const conversationId = resolveTelegramConversationId(params);
if (!conversationId) {
return null;
}
return {
channel: "telegram",
accountId: resolveChannelAccountId(params),
conversationId,
placement: "current",
labelNoun: "conversation",
};
}
return null;
}
export async function handleSubagentsFocusAction(
ctx: SubagentsCommandContext,
): Promise<CommandHandlerResult> {
const { params, runs, restTokens } = ctx;
if (!isDiscordSurface(params)) {
return stopWithText("⚠️ /focus is only available on Discord.");
const channel = resolveCommandSurfaceChannel(params);
if (channel !== "discord" && channel !== "telegram") {
return stopWithText("⚠️ /focus is only available on Discord and Telegram.");
}
const token = restTokens.join(" ").trim();
@@ -33,14 +82,15 @@ export async function handleSubagentsFocusAction(
return stopWithText("Usage: /focus <subagent-label|session-key|session-id|session-label>");
}
const accountId = resolveDiscordAccountId(params);
const accountId = resolveChannelAccountId(params);
const bindingService = getSessionBindingService();
const capabilities = bindingService.getCapabilities({
channel: "discord",
channel,
accountId,
});
if (!capabilities.adapterAvailable || !capabilities.bindSupported) {
return stopWithText("⚠️ Discord thread bindings are unavailable for this account.");
const label = channel === "discord" ? "Discord thread" : "Telegram conversation";
return stopWithText(`⚠️ ${label} bindings are unavailable for this account.`);
}
const focusTarget = await resolveFocusTargetSession({ runs, token });
@@ -48,27 +98,28 @@ export async function handleSubagentsFocusAction(
return stopWithText(`⚠️ Unable to resolve focus target: ${token}`);
}
const currentThreadId =
params.ctx.MessageThreadId != null ? String(params.ctx.MessageThreadId).trim() : "";
const parentChannelId = currentThreadId ? undefined : resolveDiscordChannelIdForFocus(params);
if (!currentThreadId && !parentChannelId) {
const bindingContext = resolveFocusBindingContext(params);
if (!bindingContext) {
if (channel === "telegram") {
return stopWithText(
"⚠️ /focus on Telegram requires a topic context in groups, or a direct-message conversation.",
);
}
return stopWithText("⚠️ Could not resolve a Discord channel for /focus.");
}
const senderId = params.command.senderId?.trim() || "";
if (currentThreadId) {
const existingBinding = bindingService.resolveByConversation({
channel: "discord",
accountId,
conversationId: currentThreadId,
});
const boundBy =
typeof existingBinding?.metadata?.boundBy === "string"
? existingBinding.metadata.boundBy.trim()
: "";
if (existingBinding && boundBy && boundBy !== "system" && senderId && senderId !== boundBy) {
return stopWithText(`⚠️ Only ${boundBy} can refocus this thread.`);
}
const existingBinding = bindingService.resolveByConversation({
channel: bindingContext.channel,
accountId: bindingContext.accountId,
conversationId: bindingContext.conversationId,
});
const boundBy =
typeof existingBinding?.metadata?.boundBy === "string"
? existingBinding.metadata.boundBy.trim()
: "";
if (existingBinding && boundBy && boundBy !== "system" && senderId && senderId !== boundBy) {
return stopWithText(`⚠️ Only ${boundBy} can refocus this ${bindingContext.labelNoun}.`);
}
const label = focusTarget.label || token;
@@ -79,13 +130,8 @@ export async function handleSubagentsFocusAction(
sessionKey: focusTarget.targetSessionKey,
})?.acp
: undefined;
const placement = currentThreadId ? "current" : "child";
if (!capabilities.placements.includes(placement)) {
return stopWithText("⚠️ Discord thread bindings are unavailable for this account.");
}
const conversationId = currentThreadId || parentChannelId;
if (!conversationId) {
return stopWithText("⚠️ Could not resolve a Discord channel for /focus.");
if (!capabilities.placements.includes(bindingContext.placement)) {
return stopWithText(`⚠️ ${channel} bindings are unavailable for this account.`);
}
let binding;
@@ -94,11 +140,11 @@ export async function handleSubagentsFocusAction(
targetSessionKey: focusTarget.targetSessionKey,
targetKind: focusTarget.targetKind === "acp" ? "session" : "subagent",
conversation: {
channel: "discord",
accountId,
conversationId,
channel: bindingContext.channel,
accountId: bindingContext.accountId,
conversationId: bindingContext.conversationId,
},
placement,
placement: bindingContext.placement,
metadata: {
threadName: resolveThreadBindingThreadName({
agentId: focusTarget.agentId,
@@ -110,12 +156,14 @@ export async function handleSubagentsFocusAction(
introText: resolveThreadBindingIntroText({
agentId: focusTarget.agentId,
label,
idleTimeoutMs: resolveDiscordThreadBindingIdleTimeoutMs({
idleTimeoutMs: resolveThreadBindingIdleTimeoutMsForChannel({
cfg: params.cfg,
channel: bindingContext.channel,
accountId,
}),
maxAgeMs: resolveDiscordThreadBindingMaxAgeMs({
maxAgeMs: resolveThreadBindingMaxAgeMsForChannel({
cfg: params.cfg,
channel: bindingContext.channel,
accountId,
}),
sessionCwd: focusTarget.targetKind === "acp" ? resolveAcpSessionCwd(acpMeta) : undefined,
@@ -130,11 +178,14 @@ export async function handleSubagentsFocusAction(
},
});
} catch {
return stopWithText("⚠️ Failed to bind a Discord thread to the target session.");
return stopWithText(
`⚠️ Failed to bind this ${bindingContext.labelNoun} to the target session.`,
);
}
const actionText = currentThreadId
? `bound this thread to ${binding.targetSessionKey}`
: `created thread ${binding.conversation.conversationId} and bound it to ${binding.targetSessionKey}`;
const actionText =
bindingContext.placement === "child"
? `created thread ${binding.conversation.conversationId} and bound it to ${binding.targetSessionKey}`
: `bound this ${bindingContext.labelNoun} to ${binding.targetSessionKey}`;
return stopWithText(`${actionText} (${focusTarget.targetKind}).`);
}

View File

@@ -1,42 +1,76 @@
import { getThreadBindingManager } from "../../../discord/monitor/thread-bindings.js";
import { getSessionBindingService } from "../../../infra/outbound/session-binding-service.js";
import type { CommandHandlerResult } from "../commands-types.js";
import {
type SubagentsCommandContext,
isDiscordSurface,
resolveDiscordAccountId,
isTelegramSurface,
resolveChannelAccountId,
resolveCommandSurfaceChannel,
resolveTelegramConversationId,
stopWithText,
} from "./shared.js";
export function handleSubagentsUnfocusAction(ctx: SubagentsCommandContext): CommandHandlerResult {
export async function handleSubagentsUnfocusAction(
ctx: SubagentsCommandContext,
): Promise<CommandHandlerResult> {
const { params } = ctx;
if (!isDiscordSurface(params)) {
return stopWithText("⚠️ /unfocus is only available on Discord.");
const channel = resolveCommandSurfaceChannel(params);
if (channel !== "discord" && channel !== "telegram") {
return stopWithText("⚠️ /unfocus is only available on Discord and Telegram.");
}
const threadId = params.ctx.MessageThreadId != null ? String(params.ctx.MessageThreadId) : "";
if (!threadId.trim()) {
return stopWithText("⚠️ /unfocus must be run inside a Discord thread.");
const accountId = resolveChannelAccountId(params);
const bindingService = getSessionBindingService();
const conversationId = (() => {
if (isDiscordSurface(params)) {
const threadId = params.ctx.MessageThreadId != null ? String(params.ctx.MessageThreadId) : "";
return threadId.trim() || undefined;
}
if (isTelegramSurface(params)) {
return resolveTelegramConversationId(params);
}
return undefined;
})();
if (!conversationId) {
if (channel === "discord") {
return stopWithText("⚠️ /unfocus must be run inside a Discord thread.");
}
return stopWithText(
"⚠️ /unfocus on Telegram requires a topic context in groups, or a direct-message conversation.",
);
}
const threadBindings = getThreadBindingManager(resolveDiscordAccountId(params));
if (!threadBindings) {
return stopWithText("⚠️ Discord thread bindings are unavailable for this account.");
}
const binding = threadBindings.getByThreadId(threadId);
const binding = bindingService.resolveByConversation({
channel,
accountId,
conversationId,
});
if (!binding) {
return stopWithText(" This thread is not currently focused.");
return stopWithText(
channel === "discord"
? " This thread is not currently focused."
: " This conversation is not currently focused.",
);
}
const senderId = params.command.senderId?.trim() || "";
if (binding.boundBy && binding.boundBy !== "system" && senderId && senderId !== binding.boundBy) {
return stopWithText(`⚠️ Only ${binding.boundBy} can unfocus this thread.`);
const boundBy =
typeof binding.metadata?.boundBy === "string" ? binding.metadata.boundBy.trim() : "";
if (boundBy && boundBy !== "system" && senderId && senderId !== boundBy) {
return stopWithText(
channel === "discord"
? `⚠️ Only ${boundBy} can unfocus this thread.`
: `⚠️ Only ${boundBy} can unfocus this conversation.`,
);
}
threadBindings.unbindThread({
threadId,
await bindingService.unbind({
bindingId: binding.bindingId,
reason: "manual",
sendFarewell: true,
});
return stopWithText("✅ Thread unfocused.");
return stopWithText(
channel === "discord" ? "✅ Thread unfocused." : "✅ Conversation unfocused.",
);
}

View File

@@ -21,17 +21,31 @@ import {
formatTokenUsageDisplay,
truncateLine,
} from "../../../shared/subagents-format.js";
import {
isDiscordSurface,
isTelegramSurface,
resolveCommandSurfaceChannel,
resolveDiscordAccountId,
resolveChannelAccountId,
} from "../channel-context.js";
import type { CommandHandler, CommandHandlerResult } from "../commands-types.js";
import { isDiscordSurface, resolveDiscordAccountId } from "../discord-context.js";
import {
formatRunLabel,
formatRunStatus,
resolveSubagentTargetFromRuns,
type SubagentTargetResolution,
} from "../subagents-utils.js";
import { resolveTelegramConversationId } from "../telegram-context.js";
export { extractAssistantText, stripToolMessages };
export { isDiscordSurface, resolveDiscordAccountId };
export {
isDiscordSurface,
isTelegramSurface,
resolveCommandSurfaceChannel,
resolveDiscordAccountId,
resolveChannelAccountId,
resolveTelegramConversationId,
};
export const COMMAND = "/subagents";
export const COMMAND_KILL = "/kill";

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { resolveTelegramConversationId } from "./telegram-context.js";
describe("resolveTelegramConversationId", () => {
it("builds canonical topic ids from chat target and message thread id", () => {
const conversationId = resolveTelegramConversationId({
ctx: {
OriginatingTo: "-100200300",
MessageThreadId: "77",
},
command: {},
});
expect(conversationId).toBe("-100200300:topic:77");
});
it("returns the direct-message chat id when no topic id is present", () => {
const conversationId = resolveTelegramConversationId({
ctx: {
OriginatingTo: "123456",
},
command: {},
});
expect(conversationId).toBe("123456");
});
it("does not treat non-topic groups as globally bindable conversations", () => {
const conversationId = resolveTelegramConversationId({
ctx: {
OriginatingTo: "-100200300",
},
command: {},
});
expect(conversationId).toBeUndefined();
});
it("falls back to command target when originating target is missing", () => {
const conversationId = resolveTelegramConversationId({
ctx: {
To: "123456",
},
command: {
to: "78910",
},
});
expect(conversationId).toBe("78910");
});
});

View File

@@ -0,0 +1,41 @@
import { parseTelegramTarget } from "../../telegram/targets.js";
type TelegramConversationParams = {
ctx: {
MessageThreadId?: string | number | null;
OriginatingTo?: string;
To?: string;
};
command: {
to?: string;
};
};
export function resolveTelegramConversationId(
params: TelegramConversationParams,
): string | undefined {
const rawThreadId =
params.ctx.MessageThreadId != null ? String(params.ctx.MessageThreadId).trim() : "";
const threadId = rawThreadId || undefined;
const toCandidates = [
typeof params.ctx.OriginatingTo === "string" ? params.ctx.OriginatingTo : "",
typeof params.command.to === "string" ? params.command.to : "",
typeof params.ctx.To === "string" ? params.ctx.To : "",
]
.map((value) => value.trim())
.filter(Boolean);
const chatId = toCandidates
.map((candidate) => parseTelegramTarget(candidate).chatId.trim())
.find((candidate) => candidate.length > 0);
if (!chatId) {
return undefined;
}
if (threadId) {
return `${chatId}:topic:${threadId}`;
}
// Non-topic groups should not become globally focused conversations.
if (chatId.startsWith("-")) {
return undefined;
}
return chatId;
}