ACP: add persistent Discord channel and Telegram topic bindings (#34873)

* docs: add ACP persistent binding experiment plan

* docs: align ACP persistent binding spec to channel-local config

* docs: scope Telegram ACP bindings to forum topics only

* docs: lock bound /new and /reset behavior to in-place ACP reset

* ACP: add persistent discord/telegram conversation bindings

* ACP: fix persistent binding reuse and discord thread parent context

* docs: document channel-specific persistent ACP bindings

* ACP: split persistent bindings and share conversation id helpers

* ACP: defer configured binding init until preflight passes

* ACP: fix discord thread parent fallback and explicit disable inheritance

* ACP: keep bound /new and /reset in-place

* ACP: honor configured bindings in native command flows

* ACP: avoid configured fallback after runtime bind failure

* docs: refine ACP bindings experiment config examples

* acp: cut over to typed top-level persistent bindings

* ACP bindings: harden reset recovery and native command auth

* Docs: add ACP bound command auth proposal

* Tests: normalize i18n registry zh-CN assertion encoding

* ACP bindings: address review findings for reset and fallback routing

* ACP reset: gate hooks on success and preserve /new arguments

* ACP bindings: fix auth and binding-priority review findings

* Telegram ACP: gate ensure on auth and accepted messages

* ACP bindings: fix session-key precedence and unavailable handling

* ACP reset/native commands: honor fallback targets and abort on bootstrap failure

* Config schema: validate ACP binding channel and Telegram topic IDs

* Discord ACP: apply configured DM bindings to native commands

* ACP reset tails: dispatch through ACP after command handling

* ACP tails/native reset auth: fix target dispatch and restore full auth

* ACP reset detection: fallback to active ACP keys for DM contexts

* Tests: type runTurn mock input in ACP dispatch test

* ACP: dedup binding route bootstrap and reset target resolution

* reply: align ACP reset hooks with bound session key

* docs: replace personal discord ids with placeholders

* fix: add changelog entry for ACP persistent bindings (#34873) (thanks @dutifulbob)

---------

Co-authored-by: Onur <2453968+osolmaz@users.noreply.github.com>
This commit is contained in:
Bob
2026-03-05 09:38:12 +01:00
committed by GitHub
parent 2c8ee593b9
commit 6a705a37f2
50 changed files with 4830 additions and 186 deletions

View File

@@ -0,0 +1,75 @@
import { resolveConfiguredAcpBindingRecord } from "../../acp/persistent-bindings.js";
import type { OpenClawConfig } from "../../config/config.js";
import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js";
import { DEFAULT_ACCOUNT_ID, isAcpSessionKey } from "../../routing/session-key.js";
function normalizeText(value: string | undefined | null): string {
return value?.trim() ?? "";
}
export function resolveEffectiveResetTargetSessionKey(params: {
cfg: OpenClawConfig;
channel?: string | null;
accountId?: string | null;
conversationId?: string | null;
parentConversationId?: string | null;
activeSessionKey?: string | null;
allowNonAcpBindingSessionKey?: boolean;
skipConfiguredFallbackWhenActiveSessionNonAcp?: boolean;
fallbackToActiveAcpWhenUnbound?: boolean;
}): string | undefined {
const activeSessionKey = normalizeText(params.activeSessionKey);
const activeAcpSessionKey =
activeSessionKey && isAcpSessionKey(activeSessionKey) ? activeSessionKey : undefined;
const activeIsNonAcp = Boolean(activeSessionKey) && !activeAcpSessionKey;
const channel = normalizeText(params.channel).toLowerCase();
const conversationId = normalizeText(params.conversationId);
if (!channel || !conversationId) {
return activeAcpSessionKey;
}
const accountId = normalizeText(params.accountId) || DEFAULT_ACCOUNT_ID;
const parentConversationId = normalizeText(params.parentConversationId) || undefined;
const allowNonAcpBindingSessionKey = Boolean(params.allowNonAcpBindingSessionKey);
const serviceBinding = getSessionBindingService().resolveByConversation({
channel,
accountId,
conversationId,
parentConversationId,
});
const serviceSessionKey =
serviceBinding?.targetKind === "session" ? serviceBinding.targetSessionKey.trim() : "";
if (serviceSessionKey) {
if (allowNonAcpBindingSessionKey) {
return serviceSessionKey;
}
return isAcpSessionKey(serviceSessionKey) ? serviceSessionKey : undefined;
}
if (activeIsNonAcp && params.skipConfiguredFallbackWhenActiveSessionNonAcp) {
return undefined;
}
const configuredBinding = resolveConfiguredAcpBindingRecord({
cfg: params.cfg,
channel,
accountId,
conversationId,
parentConversationId,
});
const configuredSessionKey =
configuredBinding?.record.targetKind === "session"
? configuredBinding.record.targetSessionKey.trim()
: "";
if (configuredSessionKey) {
if (allowNonAcpBindingSessionKey) {
return configuredSessionKey;
}
return isAcpSessionKey(configuredSessionKey) ? configuredSessionKey : undefined;
}
if (params.fallbackToActiveAcpWhenUnbound === false) {
return undefined;
}
return activeAcpSessionKey;
}

View File

@@ -27,10 +27,51 @@ describe("commands-acp context", () => {
accountId: "work",
threadId: "thread-42",
conversationId: "thread-42",
parentConversationId: "parent-1",
});
expect(isAcpCommandDiscordChannel(params)).toBe(true);
});
it("resolves discord thread parent from ParentSessionKey when targets point at the thread", () => {
const params = buildCommandTestParams("/acp sessions", baseCfg, {
Provider: "discord",
Surface: "discord",
OriginatingChannel: "discord",
OriginatingTo: "channel:thread-42",
AccountId: "work",
MessageThreadId: "thread-42",
ParentSessionKey: "agent:codex:discord:channel:parent-9",
});
expect(resolveAcpCommandBindingContext(params)).toEqual({
channel: "discord",
accountId: "work",
threadId: "thread-42",
conversationId: "thread-42",
parentConversationId: "parent-9",
});
});
it("resolves discord thread parent from native context when ParentSessionKey is absent", () => {
const params = buildCommandTestParams("/acp sessions", baseCfg, {
Provider: "discord",
Surface: "discord",
OriginatingChannel: "discord",
OriginatingTo: "channel:thread-42",
AccountId: "work",
MessageThreadId: "thread-42",
ThreadParentId: "parent-11",
});
expect(resolveAcpCommandBindingContext(params)).toEqual({
channel: "discord",
accountId: "work",
threadId: "thread-42",
conversationId: "thread-42",
parentConversationId: "parent-11",
});
});
it("falls back to default account and target-derived conversation id", () => {
const params = buildCommandTestParams("/acp status", baseCfg, {
Provider: "slack",
@@ -48,4 +89,23 @@ describe("commands-acp context", () => {
expect(resolveAcpCommandConversationId(params)).toBe("123456789");
expect(isAcpCommandDiscordChannel(params)).toBe(false);
});
it("builds canonical telegram topic conversation ids from originating chat + thread", () => {
const params = buildCommandTestParams("/acp status", baseCfg, {
Provider: "telegram",
Surface: "telegram",
OriginatingChannel: "telegram",
OriginatingTo: "telegram:-1001234567890",
MessageThreadId: "42",
});
expect(resolveAcpCommandBindingContext(params)).toEqual({
channel: "telegram",
accountId: "default",
threadId: "42",
conversationId: "-1001234567890:topic:42",
parentConversationId: "-1001234567890",
});
expect(resolveAcpCommandConversationId(params)).toBe("-1001234567890:topic:42");
});
});

View File

@@ -1,5 +1,10 @@
import {
buildTelegramTopicConversationId,
parseTelegramChatIdFromTarget,
} from "../../../acp/conversation-id.js";
import { DISCORD_THREAD_BINDING_CHANNEL } from "../../../channels/thread-bindings-policy.js";
import { resolveConversationIdFromTargets } from "../../../infra/outbound/conversation-id.js";
import { parseAgentSessionKey } from "../../../routing/session-key.js";
import type { HandleCommandsParams } from "../commands-types.js";
function normalizeString(value: unknown): string {
@@ -33,12 +38,84 @@ export function resolveAcpCommandThreadId(params: HandleCommandsParams): string
}
export function resolveAcpCommandConversationId(params: HandleCommandsParams): string | undefined {
const channel = resolveAcpCommandChannel(params);
if (channel === "telegram") {
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 resolveConversationIdFromTargets({
threadId: params.ctx.MessageThreadId,
targets: [params.ctx.OriginatingTo, params.command.to, params.ctx.To],
});
}
function parseDiscordParentChannelFromSessionKey(raw: unknown): string | undefined {
const sessionKey = normalizeString(raw);
if (!sessionKey) {
return undefined;
}
const scoped = parseAgentSessionKey(sessionKey)?.rest ?? sessionKey.toLowerCase();
const match = scoped.match(/(?:^|:)channel:([^:]+)$/);
if (!match?.[1]) {
return undefined;
}
return match[1];
}
function parseDiscordParentChannelFromContext(raw: unknown): string | undefined {
const parentId = normalizeString(raw);
if (!parentId) {
return undefined;
}
return parentId;
}
export function resolveAcpCommandParentConversationId(
params: HandleCommandsParams,
): string | undefined {
const channel = resolveAcpCommandChannel(params);
if (channel === "telegram") {
return (
parseTelegramChatIdFromTarget(params.ctx.OriginatingTo) ??
parseTelegramChatIdFromTarget(params.command.to) ??
parseTelegramChatIdFromTarget(params.ctx.To)
);
}
if (channel === DISCORD_THREAD_BINDING_CHANNEL) {
const threadId = resolveAcpCommandThreadId(params);
if (!threadId) {
return undefined;
}
const fromContext = parseDiscordParentChannelFromContext(params.ctx.ThreadParentId);
if (fromContext && fromContext !== threadId) {
return fromContext;
}
const fromParentSession = parseDiscordParentChannelFromSessionKey(params.ctx.ParentSessionKey);
if (fromParentSession && fromParentSession !== threadId) {
return fromParentSession;
}
const fromTargets = resolveConversationIdFromTargets({
targets: [params.ctx.OriginatingTo, params.command.to, params.ctx.To],
});
if (fromTargets && fromTargets !== threadId) {
return fromTargets;
}
}
return undefined;
}
export function isAcpCommandDiscordChannel(params: HandleCommandsParams): boolean {
return resolveAcpCommandChannel(params) === DISCORD_THREAD_BINDING_CHANNEL;
}
@@ -48,11 +125,14 @@ export function resolveAcpCommandBindingContext(params: HandleCommandsParams): {
accountId: string;
threadId?: string;
conversationId?: string;
parentConversationId?: string;
} {
const parentConversationId = resolveAcpCommandParentConversationId(params);
return {
channel: resolveAcpCommandChannel(params),
accountId: resolveAcpCommandAccountId(params),
threadId: resolveAcpCommandThreadId(params),
conversationId: resolveAcpCommandConversationId(params),
...(parentConversationId ? { parentConversationId } : {}),
};
}

View File

@@ -1,5 +1,5 @@
import { callGateway } from "../../../gateway/call.js";
import { getSessionBindingService } from "../../../infra/outbound/session-binding-service.js";
import { resolveEffectiveResetTargetSessionKey } from "../acp-reset-target.js";
import { resolveRequesterSessionKey } from "../commands-subagents/shared.js";
import type { HandleCommandsParams } from "../commands-types.js";
import { resolveAcpCommandBindingContext } from "./context.js";
@@ -35,19 +35,22 @@ async function resolveSessionKeyByToken(token: string): Promise<string | null> {
}
export function resolveBoundAcpThreadSessionKey(params: HandleCommandsParams): string | undefined {
const commandTargetSessionKey =
typeof params.ctx.CommandTargetSessionKey === "string"
? params.ctx.CommandTargetSessionKey.trim()
: "";
const activeSessionKey = commandTargetSessionKey || params.sessionKey.trim();
const bindingContext = resolveAcpCommandBindingContext(params);
if (!bindingContext.channel || !bindingContext.conversationId) {
return undefined;
}
const binding = getSessionBindingService().resolveByConversation({
return resolveEffectiveResetTargetSessionKey({
cfg: params.cfg,
channel: bindingContext.channel,
accountId: bindingContext.accountId,
conversationId: bindingContext.conversationId,
parentConversationId: bindingContext.parentConversationId,
activeSessionKey,
allowNonAcpBindingSessionKey: true,
skipConfiguredFallbackWhenActiveSessionNonAcp: false,
});
if (!binding || binding.targetKind !== "session") {
return undefined;
}
return binding.targetSessionKey.trim() || undefined;
}
export async function resolveAcpTargetSessionKey(params: {

View File

@@ -1,10 +1,13 @@
import fs from "node:fs/promises";
import { resetAcpSessionInPlace } from "../../acp/persistent-bindings.js";
import { logVerbose } from "../../globals.js";
import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import { isAcpSessionKey } from "../../routing/session-key.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js";
import { shouldHandleTextCommands } from "../commands-registry.js";
import { handleAcpCommand } from "./commands-acp.js";
import { resolveBoundAcpThreadSessionKey } from "./commands-acp/targets.js";
import { handleAllowlistCommand } from "./commands-allowlist.js";
import { handleApproveCommand } from "./commands-approve.js";
import { handleBashCommand } from "./commands-bash.js";
@@ -130,6 +133,40 @@ export async function emitResetCommandHooks(params: {
}
}
function applyAcpResetTailContext(ctx: HandleCommandsParams["ctx"], resetTail: string): void {
const mutableCtx = ctx as Record<string, unknown>;
mutableCtx.Body = resetTail;
mutableCtx.RawBody = resetTail;
mutableCtx.CommandBody = resetTail;
mutableCtx.BodyForCommands = resetTail;
mutableCtx.BodyForAgent = resetTail;
mutableCtx.BodyStripped = resetTail;
mutableCtx.AcpDispatchTailAfterReset = true;
}
function resolveSessionEntryForHookSessionKey(
sessionStore: HandleCommandsParams["sessionStore"] | undefined,
sessionKey: string,
): HandleCommandsParams["sessionEntry"] | undefined {
if (!sessionStore) {
return undefined;
}
const directEntry = sessionStore[sessionKey];
if (directEntry) {
return directEntry;
}
const normalizedTarget = sessionKey.trim().toLowerCase();
if (!normalizedTarget) {
return undefined;
}
for (const [candidateKey, candidateEntry] of Object.entries(sessionStore)) {
if (candidateKey.trim().toLowerCase() === normalizedTarget) {
return candidateEntry;
}
}
return undefined;
}
export async function handleCommands(params: HandleCommandsParams): Promise<CommandHandlerResult> {
if (HANDLERS === null) {
HANDLERS = [
@@ -172,6 +209,74 @@ export async function handleCommands(params: HandleCommandsParams): Promise<Comm
// Trigger internal hook for reset/new commands
if (resetRequested && params.command.isAuthorizedSender) {
const commandAction: ResetCommandAction = resetMatch?.[1] === "reset" ? "reset" : "new";
const resetTail =
resetMatch != null
? params.command.commandBodyNormalized.slice(resetMatch[0].length).trimStart()
: "";
const boundAcpSessionKey = resolveBoundAcpThreadSessionKey(params);
const boundAcpKey =
boundAcpSessionKey && isAcpSessionKey(boundAcpSessionKey)
? boundAcpSessionKey.trim()
: undefined;
if (boundAcpKey) {
const resetResult = await resetAcpSessionInPlace({
cfg: params.cfg,
sessionKey: boundAcpKey,
reason: commandAction,
});
if (!resetResult.ok && !resetResult.skipped) {
logVerbose(
`acp reset-in-place failed for ${boundAcpKey}: ${resetResult.error ?? "unknown error"}`,
);
}
if (resetResult.ok) {
const hookSessionEntry =
boundAcpKey === params.sessionKey
? params.sessionEntry
: resolveSessionEntryForHookSessionKey(params.sessionStore, boundAcpKey);
const hookPreviousSessionEntry =
boundAcpKey === params.sessionKey
? params.previousSessionEntry
: resolveSessionEntryForHookSessionKey(params.sessionStore, boundAcpKey);
await emitResetCommandHooks({
action: commandAction,
ctx: params.ctx,
cfg: params.cfg,
command: params.command,
sessionKey: boundAcpKey,
sessionEntry: hookSessionEntry,
previousSessionEntry: hookPreviousSessionEntry,
workspaceDir: params.workspaceDir,
});
if (resetTail) {
applyAcpResetTailContext(params.ctx, resetTail);
if (params.rootCtx && params.rootCtx !== params.ctx) {
applyAcpResetTailContext(params.rootCtx, resetTail);
}
return {
shouldContinue: false,
};
}
return {
shouldContinue: false,
reply: { text: "✅ ACP session reset in place." },
};
}
if (resetResult.skipped) {
return {
shouldContinue: false,
reply: {
text: "⚠️ ACP session reset unavailable for this bound conversation. Rebind with /acp bind or /acp spawn.",
},
};
}
return {
shouldContinue: false,
reply: {
text: "⚠️ ACP session reset failed. Check /acp status and try again.",
},
};
}
await emitResetCommandHooks({
action: commandAction,
ctx: params.ctx,

View File

@@ -26,6 +26,7 @@ export type CommandContext = {
export type HandleCommandsParams = {
ctx: MsgContext;
rootCtx?: MsgContext;
cfg: OpenClawConfig;
command: CommandContext;
agentId?: string;

View File

@@ -104,6 +104,27 @@ vi.mock("../../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGatewayMock(opts),
}));
type ResetAcpSessionInPlaceResult = { ok: true } | { ok: false; skipped?: boolean; error?: string };
const resetAcpSessionInPlaceMock = vi.hoisted(() =>
vi.fn(
async (_params: unknown): Promise<ResetAcpSessionInPlaceResult> => ({
ok: false,
skipped: true,
}),
),
);
vi.mock("../../acp/persistent-bindings.js", async () => {
const actual = await vi.importActual<typeof import("../../acp/persistent-bindings.js")>(
"../../acp/persistent-bindings.js",
);
return {
...actual,
resetAcpSessionInPlace: (params: unknown) => resetAcpSessionInPlaceMock(params),
};
});
import { buildConfiguredAcpSessionKey } from "../../acp/persistent-bindings.js";
import type { HandleCommandsParams } from "./commands-types.js";
import { buildCommandContext, handleCommands } from "./commands.js";
@@ -136,6 +157,11 @@ function buildParams(commandBody: string, cfg: OpenClawConfig, ctxOverrides?: Pa
return buildCommandTestParams(commandBody, cfg, ctxOverrides, { workspaceDir: testWorkspaceDir });
}
beforeEach(() => {
resetAcpSessionInPlaceMock.mockReset();
resetAcpSessionInPlaceMock.mockResolvedValue({ ok: false, skipped: true } as const);
});
describe("handleCommands gating", () => {
it("blocks gated commands when disabled or not elevated-allowlisted", async () => {
const cases = typedCases<{
@@ -973,6 +999,226 @@ describe("handleCommands hooks", () => {
});
});
describe("handleCommands ACP-bound /new and /reset", () => {
const discordChannelId = "1478836151241412759";
const buildDiscordBoundConfig = (): OpenClawConfig =>
({
commands: { text: true },
bindings: [
{
type: "acp",
agentId: "codex",
match: {
channel: "discord",
accountId: "default",
peer: {
kind: "channel",
id: discordChannelId,
},
},
acp: {
mode: "persistent",
},
},
],
channels: {
discord: {
allowFrom: ["*"],
guilds: { "1459246755253325866": { channels: { [discordChannelId]: {} } } },
},
},
}) as OpenClawConfig;
const buildDiscordBoundParams = (body: string) => {
const params = buildParams(body, buildDiscordBoundConfig(), {
Provider: "discord",
Surface: "discord",
OriginatingChannel: "discord",
AccountId: "default",
SenderId: "12345",
From: "discord:12345",
To: discordChannelId,
OriginatingTo: discordChannelId,
SessionKey: "agent:main:acp:binding:discord:default:feedface",
});
params.sessionKey = "agent:main:acp:binding:discord:default:feedface";
return params;
};
it("handles /new as ACP in-place reset for bound conversations", async () => {
resetAcpSessionInPlaceMock.mockResolvedValue({ ok: true } as const);
const result = await handleCommands(buildDiscordBoundParams("/new"));
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("ACP session reset in place");
expect(resetAcpSessionInPlaceMock).toHaveBeenCalledTimes(1);
expect(resetAcpSessionInPlaceMock.mock.calls[0]?.[0]).toMatchObject({
reason: "new",
});
});
it("continues with trailing prompt text after successful ACP-bound /new", async () => {
resetAcpSessionInPlaceMock.mockResolvedValue({ ok: true } as const);
const params = buildDiscordBoundParams("/new continue with deployment");
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply).toBeUndefined();
const mutableCtx = params.ctx as Record<string, unknown>;
expect(mutableCtx.BodyStripped).toBe("continue with deployment");
expect(mutableCtx.CommandBody).toBe("continue with deployment");
expect(mutableCtx.AcpDispatchTailAfterReset).toBe(true);
expect(resetAcpSessionInPlaceMock).toHaveBeenCalledTimes(1);
});
it("handles /reset failures without falling back to normal session reset flow", async () => {
resetAcpSessionInPlaceMock.mockResolvedValue({ ok: false, error: "backend unavailable" });
const result = await handleCommands(buildDiscordBoundParams("/reset"));
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("ACP session reset failed");
expect(resetAcpSessionInPlaceMock).toHaveBeenCalledTimes(1);
expect(resetAcpSessionInPlaceMock.mock.calls[0]?.[0]).toMatchObject({
reason: "reset",
});
});
it("does not emit reset hooks when ACP reset fails", async () => {
resetAcpSessionInPlaceMock.mockResolvedValue({ ok: false, error: "backend unavailable" });
const spy = vi.spyOn(internalHooks, "triggerInternalHook").mockResolvedValue();
const result = await handleCommands(buildDiscordBoundParams("/reset"));
expect(result.shouldContinue).toBe(false);
expect(spy).not.toHaveBeenCalled();
spy.mockRestore();
});
it("keeps existing /new behavior for non-ACP sessions", async () => {
const cfg = {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as OpenClawConfig;
const result = await handleCommands(buildParams("/new", cfg));
expect(result.shouldContinue).toBe(true);
expect(resetAcpSessionInPlaceMock).not.toHaveBeenCalled();
});
it("still targets configured ACP binding when runtime routing falls back to a non-ACP session", async () => {
const fallbackSessionKey = `agent:main:discord:channel:${discordChannelId}`;
const configuredAcpSessionKey = buildConfiguredAcpSessionKey({
channel: "discord",
accountId: "default",
conversationId: discordChannelId,
agentId: "codex",
mode: "persistent",
});
const params = buildDiscordBoundParams("/new");
params.sessionKey = fallbackSessionKey;
params.ctx.SessionKey = fallbackSessionKey;
params.ctx.CommandTargetSessionKey = fallbackSessionKey;
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("ACP session reset unavailable");
expect(resetAcpSessionInPlaceMock).toHaveBeenCalledTimes(1);
expect(resetAcpSessionInPlaceMock.mock.calls[0]?.[0]).toMatchObject({
sessionKey: configuredAcpSessionKey,
reason: "new",
});
});
it("emits reset hooks for the ACP session key when routing falls back to non-ACP session", async () => {
resetAcpSessionInPlaceMock.mockResolvedValue({ ok: true } as const);
const hookSpy = vi.spyOn(internalHooks, "triggerInternalHook").mockResolvedValue();
const fallbackSessionKey = `agent:main:discord:channel:${discordChannelId}`;
const configuredAcpSessionKey = buildConfiguredAcpSessionKey({
channel: "discord",
accountId: "default",
conversationId: discordChannelId,
agentId: "codex",
mode: "persistent",
});
const fallbackEntry = {
sessionId: "fallback-session-id",
sessionFile: "/tmp/fallback-session.jsonl",
} as SessionEntry;
const configuredEntry = {
sessionId: "configured-acp-session-id",
sessionFile: "/tmp/configured-acp-session.jsonl",
} as SessionEntry;
const params = buildDiscordBoundParams("/new");
params.sessionKey = fallbackSessionKey;
params.ctx.SessionKey = fallbackSessionKey;
params.ctx.CommandTargetSessionKey = fallbackSessionKey;
params.sessionEntry = fallbackEntry;
params.previousSessionEntry = fallbackEntry;
params.sessionStore = {
[fallbackSessionKey]: fallbackEntry,
[configuredAcpSessionKey]: configuredEntry,
};
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("ACP session reset in place");
expect(hookSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: "command",
action: "new",
sessionKey: configuredAcpSessionKey,
context: expect.objectContaining({
sessionEntry: configuredEntry,
previousSessionEntry: configuredEntry,
}),
}),
);
hookSpy.mockRestore();
});
it("uses active ACP command target when conversation binding context is missing", async () => {
resetAcpSessionInPlaceMock.mockResolvedValue({ ok: true } as const);
const activeAcpTarget = "agent:codex:acp:binding:discord:default:feedface";
const params = buildParams(
"/new",
{
commands: { text: true },
channels: {
discord: {
allowFrom: ["*"],
},
},
} as OpenClawConfig,
{
Provider: "discord",
Surface: "discord",
OriginatingChannel: "discord",
AccountId: "default",
SenderId: "12345",
From: "discord:12345",
},
);
params.sessionKey = "discord:slash:12345";
params.ctx.SessionKey = "discord:slash:12345";
params.ctx.CommandSource = "native";
params.ctx.CommandTargetSessionKey = activeAcpTarget;
params.ctx.To = "user:12345";
params.ctx.OriginatingTo = "user:12345";
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("ACP session reset in place");
expect(resetAcpSessionInPlaceMock).toHaveBeenCalledTimes(1);
expect(resetAcpSessionInPlaceMock.mock.calls[0]?.[0]).toMatchObject({
sessionKey: activeAcpTarget,
reason: "new",
});
});
});
describe("handleCommands context", () => {
it("returns expected details for /context commands", async () => {
const cfg = {

View File

@@ -178,7 +178,7 @@ function createAcpRuntime(events: Array<Record<string, unknown>>) {
runtimeSessionName: `${input.sessionKey}:${input.mode}`,
}) as { sessionKey: string; backend: string; runtimeSessionName: string },
),
runTurn: vi.fn(async function* () {
runTurn: vi.fn(async function* (_params: { text?: string }) {
for (const event of events) {
yield event;
}
@@ -912,6 +912,73 @@ describe("dispatchReplyFromConfig", () => {
});
});
it("routes ACP reset tails through ACP after command handling", async () => {
setNoAbort();
const runtime = createAcpRuntime([
{ type: "text_delta", text: "tail accepted" },
{ type: "done" },
]);
acpMocks.readAcpSessionEntry.mockReturnValue({
sessionKey: "agent:codex-acp:session-1",
storeSessionKey: "agent:codex-acp:session-1",
cfg: {},
storePath: "/tmp/mock-sessions.json",
entry: {},
acp: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime:1",
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
},
});
acpMocks.requireAcpRuntimeBackend.mockReturnValue({
id: "acpx",
runtime,
});
const cfg = {
acp: {
enabled: true,
dispatch: { enabled: true },
},
session: {
sendPolicy: {
default: "deny",
},
},
} as OpenClawConfig;
const dispatcher = createDispatcher();
const ctx = buildTestCtx({
Provider: "discord",
Surface: "discord",
CommandSource: "native",
SessionKey: "discord:slash:owner",
CommandTargetSessionKey: "agent:codex-acp:session-1",
CommandBody: "/new continue with deployment",
BodyForCommands: "/new continue with deployment",
BodyForAgent: "/new continue with deployment",
});
const replyResolver = vi.fn(async (resolverCtx: MsgContext) => {
resolverCtx.Body = "continue with deployment";
resolverCtx.RawBody = "continue with deployment";
resolverCtx.CommandBody = "continue with deployment";
resolverCtx.BodyForCommands = "continue with deployment";
resolverCtx.BodyForAgent = "continue with deployment";
resolverCtx.AcpDispatchTailAfterReset = true;
return undefined;
});
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
expect(replyResolver).toHaveBeenCalledTimes(1);
expect(runtime.runTurn).toHaveBeenCalledTimes(1);
expect(runtime.runTurn.mock.calls[0]?.[0]).toMatchObject({
text: "continue with deployment",
});
});
it("does not bypass ACP slash aliases when text commands are disabled on native surfaces", async () => {
setNoAbort();
const runtime = createAcpRuntime([{ type: "done" }]);

View File

@@ -165,6 +165,7 @@ export async function dispatchReplyFromConfig(params: {
}
const sessionStoreEntry = resolveSessionStoreEntry(ctx, cfg);
const acpDispatchSessionKey = sessionStoreEntry.sessionKey ?? sessionKey;
const inboundAudio = isInboundAudioContext(ctx);
const sessionTtsAuto = normalizeTtsAutoMode(sessionStoreEntry.entry?.ttsAuto);
const hookRunner = getGlobalHookRunner();
@@ -328,7 +329,7 @@ export async function dispatchReplyFromConfig(params: {
ctx,
cfg,
dispatcher,
sessionKey,
sessionKey: acpDispatchSessionKey,
inboundAudio,
sessionTtsAuto,
ttsChannel,
@@ -434,6 +435,32 @@ export async function dispatchReplyFromConfig(params: {
cfg,
);
if (ctx.AcpDispatchTailAfterReset === true) {
// Command handling prepared a trailing prompt after ACP in-place reset.
// Route that tail through ACP now (same turn) instead of embedded dispatch.
ctx.AcpDispatchTailAfterReset = false;
const acpTailDispatch = await tryDispatchAcpReply({
ctx,
cfg,
dispatcher,
sessionKey: acpDispatchSessionKey,
inboundAudio,
sessionTtsAuto,
ttsChannel,
shouldRouteToOriginating,
originatingChannel,
originatingTo,
shouldSendToolSummaries,
bypassForCommand: false,
onReplyStart: params.replyOptions?.onReplyStart,
recordProcessed,
markIdle,
});
if (acpTailDispatch) {
return acpTailDispatch;
}
}
const replies = replyResult ? (Array.isArray(replyResult) ? replyResult : [replyResult]) : [];
let queuedFinal = false;

View File

@@ -330,7 +330,10 @@ export async function handleInlineActions(params: {
const runCommands = (commandInput: typeof command) =>
handleCommands({
ctx,
// Pass sessionCtx so command handlers can mutate stripped body for same-turn continuation.
ctx: sessionCtx,
// Keep original finalized context in sync when command handlers need outer-dispatch side effects.
rootCtx: ctx,
cfg,
command: commandInput,
agentId,

View File

@@ -6,6 +6,10 @@ import { buildModelAliasIndex } from "../../agents/model-selection.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
import { formatZonedTimestamp } from "../../infra/format-time/format-datetime.ts";
import {
__testing as sessionBindingTesting,
registerSessionBindingAdapter,
} from "../../infra/outbound/session-binding-service.js";
import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system-events.js";
import { applyResetModelOverride } from "./session-reset-model.js";
import { drainFormattedSystemEvents } from "./session-updates.js";
@@ -456,6 +460,353 @@ describe("initSessionState RawBody", () => {
expect(result.triggerBodyNormalized).toBe("/NEW KeepThisCase");
});
it("does not rotate local session state for /new on bound ACP sessions", async () => {
const root = await makeCaseDir("openclaw-rawbody-acp-reset-");
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:codex:acp:binding:discord:default:feedface";
const existingSessionId = "session-existing";
const now = Date.now();
await writeSessionStoreFast(storePath, {
[sessionKey]: {
sessionId: existingSessionId,
updatedAt: now,
systemSent: true,
},
});
const cfg = {
session: { store: storePath },
bindings: [
{
type: "acp",
agentId: "codex",
match: {
channel: "discord",
accountId: "default",
peer: { kind: "channel", id: "1478836151241412759" },
},
acp: { mode: "persistent" },
},
],
channels: {
discord: {
allowFrom: ["*"],
},
},
} as OpenClawConfig;
const result = await initSessionState({
ctx: {
RawBody: "/new",
CommandBody: "/new",
Provider: "discord",
Surface: "discord",
SenderId: "12345",
From: "discord:12345",
To: "1478836151241412759",
SessionKey: sessionKey,
},
cfg,
commandAuthorized: true,
});
expect(result.resetTriggered).toBe(false);
expect(result.sessionId).toBe(existingSessionId);
expect(result.isNewSession).toBe(false);
});
it("does not rotate local session state for ACP /new when conversation IDs are unavailable", async () => {
const root = await makeCaseDir("openclaw-rawbody-acp-reset-no-conversation-");
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:codex:acp:binding:discord:default:feedface";
const existingSessionId = "session-existing";
const now = Date.now();
await writeSessionStoreFast(storePath, {
[sessionKey]: {
sessionId: existingSessionId,
updatedAt: now,
systemSent: true,
},
});
const cfg = {
session: { store: storePath },
channels: {
discord: {
allowFrom: ["*"],
},
},
} as OpenClawConfig;
const result = await initSessionState({
ctx: {
RawBody: "/new",
CommandBody: "/new",
Provider: "discord",
Surface: "discord",
SenderId: "12345",
From: "discord:12345",
To: "user:12345",
OriginatingTo: "user:12345",
SessionKey: sessionKey,
},
cfg,
commandAuthorized: true,
});
expect(result.resetTriggered).toBe(false);
expect(result.sessionId).toBe(existingSessionId);
expect(result.isNewSession).toBe(false);
});
it("keeps custom reset triggers working on bound ACP sessions", async () => {
const root = await makeCaseDir("openclaw-rawbody-acp-custom-reset-");
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:codex:acp:binding:discord:default:feedface";
const existingSessionId = "session-existing";
const now = Date.now();
await writeSessionStoreFast(storePath, {
[sessionKey]: {
sessionId: existingSessionId,
updatedAt: now,
systemSent: true,
},
});
const cfg = {
session: {
store: storePath,
resetTriggers: ["/fresh"],
},
bindings: [
{
type: "acp",
agentId: "codex",
match: {
channel: "discord",
accountId: "default",
peer: { kind: "channel", id: "1478836151241412759" },
},
acp: { mode: "persistent" },
},
],
channels: {
discord: {
allowFrom: ["*"],
},
},
} as OpenClawConfig;
const result = await initSessionState({
ctx: {
RawBody: "/fresh",
CommandBody: "/fresh",
Provider: "discord",
Surface: "discord",
SenderId: "12345",
From: "discord:12345",
To: "1478836151241412759",
SessionKey: sessionKey,
},
cfg,
commandAuthorized: true,
});
expect(result.resetTriggered).toBe(true);
expect(result.isNewSession).toBe(true);
expect(result.sessionId).not.toBe(existingSessionId);
});
it("keeps normal /new behavior for unbound ACP-shaped session keys", async () => {
const root = await makeCaseDir("openclaw-rawbody-acp-unbound-reset-");
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:codex:acp:binding:discord:default:feedface";
const existingSessionId = "session-existing";
const now = Date.now();
await writeSessionStoreFast(storePath, {
[sessionKey]: {
sessionId: existingSessionId,
updatedAt: now,
systemSent: true,
},
});
const cfg = {
session: { store: storePath },
channels: {
discord: {
allowFrom: ["*"],
},
},
} as OpenClawConfig;
const result = await initSessionState({
ctx: {
RawBody: "/new",
CommandBody: "/new",
Provider: "discord",
Surface: "discord",
SenderId: "12345",
From: "discord:12345",
To: "1478836151241412759",
SessionKey: sessionKey,
},
cfg,
commandAuthorized: true,
});
expect(result.resetTriggered).toBe(true);
expect(result.isNewSession).toBe(true);
expect(result.sessionId).not.toBe(existingSessionId);
});
it("does not suppress /new when active conversation binding points to a non-ACP session", async () => {
const root = await makeCaseDir("openclaw-rawbody-acp-nonacp-binding-");
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:codex:acp:binding:discord:default:feedface";
const existingSessionId = "session-existing";
const now = Date.now();
const channelId = "1478836151241412759";
const nonAcpFocusSessionKey = "agent:main:discord:channel:focus-target";
await writeSessionStoreFast(storePath, {
[sessionKey]: {
sessionId: existingSessionId,
updatedAt: now,
systemSent: true,
},
});
const cfg = {
session: { store: storePath },
bindings: [
{
type: "acp",
agentId: "codex",
match: {
channel: "discord",
accountId: "default",
peer: { kind: "channel", id: channelId },
},
acp: { mode: "persistent" },
},
],
channels: {
discord: {
allowFrom: ["*"],
},
},
} as OpenClawConfig;
sessionBindingTesting.resetSessionBindingAdaptersForTests();
registerSessionBindingAdapter({
channel: "discord",
accountId: "default",
capabilities: { bindSupported: false, unbindSupported: false, placements: ["current"] },
listBySession: () => [],
resolveByConversation: (ref) => {
if (ref.conversationId !== channelId) {
return null;
}
return {
bindingId: "focus-binding",
targetSessionKey: nonAcpFocusSessionKey,
targetKind: "session",
conversation: {
channel: "discord",
accountId: "default",
conversationId: channelId,
},
status: "active",
boundAt: now,
};
},
});
try {
const result = await initSessionState({
ctx: {
RawBody: "/new",
CommandBody: "/new",
Provider: "discord",
Surface: "discord",
SenderId: "12345",
From: "discord:12345",
To: channelId,
SessionKey: sessionKey,
},
cfg,
commandAuthorized: true,
});
expect(result.resetTriggered).toBe(true);
expect(result.isNewSession).toBe(true);
expect(result.sessionId).not.toBe(existingSessionId);
} finally {
sessionBindingTesting.resetSessionBindingAdaptersForTests();
}
});
it("does not suppress /new when active target session key is non-ACP even with configured ACP binding", async () => {
const root = await makeCaseDir("openclaw-rawbody-acp-configured-fallback-target-");
const storePath = path.join(root, "sessions.json");
const channelId = "1478836151241412759";
const fallbackSessionKey = "agent:main:discord:channel:focus-target";
const existingSessionId = "session-existing";
const now = Date.now();
await writeSessionStoreFast(storePath, {
[fallbackSessionKey]: {
sessionId: existingSessionId,
updatedAt: now,
systemSent: true,
},
});
const cfg = {
session: { store: storePath },
bindings: [
{
type: "acp",
agentId: "codex",
match: {
channel: "discord",
accountId: "default",
peer: { kind: "channel", id: channelId },
},
acp: { mode: "persistent" },
},
],
channels: {
discord: {
allowFrom: ["*"],
},
},
} as OpenClawConfig;
const result = await initSessionState({
ctx: {
RawBody: "/new",
CommandBody: "/new",
Provider: "discord",
Surface: "discord",
SenderId: "12345",
From: "discord:12345",
To: channelId,
SessionKey: fallbackSessionKey,
},
cfg,
commandAuthorized: true,
});
expect(result.resetTriggered).toBe(true);
expect(result.isNewSession).toBe(true);
expect(result.sessionId).not.toBe(existingSessionId);
});
it("uses the default per-agent sessions store when config store is unset", async () => {
const root = await makeCaseDir("openclaw-session-store-default-");
const stateDir = path.join(root, ".openclaw");

View File

@@ -1,5 +1,9 @@
import crypto from "node:crypto";
import path from "node:path";
import {
buildTelegramTopicConversationId,
parseTelegramChatIdFromTarget,
} from "../../acp/conversation-id.js";
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import { normalizeChatType } from "../../channels/chat-type.js";
import type { OpenClawConfig } from "../../config/config.js";
@@ -24,13 +28,15 @@ import {
} from "../../config/sessions.js";
import type { TtsAutoMode } from "../../config/types.tts.js";
import { archiveSessionTranscripts } from "../../gateway/session-utils.fs.js";
import { resolveConversationIdFromTargets } from "../../infra/outbound/conversation-id.js";
import { deliverSessionMaintenanceWarning } from "../../infra/session-maintenance-warning.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import { normalizeMainKey } from "../../routing/session-key.js";
import { normalizeMainKey, parseAgentSessionKey } from "../../routing/session-key.js";
import { normalizeSessionDeliveryFields } from "../../utils/delivery-context.js";
import { resolveCommandAuthorization } from "../command-auth.js";
import type { MsgContext, TemplateContext } from "../templating.js";
import { resolveEffectiveResetTargetSessionKey } from "./acp-reset-target.js";
import { normalizeInboundTextNewlines } from "./inbound-text.js";
import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
import {
@@ -62,6 +68,124 @@ export type SessionInitResult = {
triggerBodyNormalized: string;
};
function normalizeSessionText(value: unknown): string {
if (typeof value === "string") {
return value.trim();
}
if (typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") {
return `${value}`.trim();
}
return "";
}
function parseDiscordParentChannelFromSessionKey(raw: unknown): string | undefined {
const sessionKey = normalizeSessionText(raw);
if (!sessionKey) {
return undefined;
}
const scoped = parseAgentSessionKey(sessionKey)?.rest ?? sessionKey.toLowerCase();
const match = scoped.match(/(?:^|:)channel:([^:]+)$/);
if (!match?.[1]) {
return undefined;
}
return match[1];
}
function resolveAcpResetBindingContext(ctx: MsgContext): {
channel: string;
accountId: string;
conversationId: string;
parentConversationId?: string;
} | null {
const channelRaw = normalizeSessionText(
ctx.OriginatingChannel ?? ctx.Surface ?? ctx.Provider ?? "",
).toLowerCase();
if (!channelRaw) {
return null;
}
const accountId = normalizeSessionText(ctx.AccountId) || "default";
const normalizedThreadId =
ctx.MessageThreadId != null ? normalizeSessionText(String(ctx.MessageThreadId)) : "";
if (channelRaw === "telegram") {
const parentConversationId =
parseTelegramChatIdFromTarget(ctx.OriginatingTo) ?? parseTelegramChatIdFromTarget(ctx.To);
let conversationId =
resolveConversationIdFromTargets({
threadId: normalizedThreadId || undefined,
targets: [ctx.OriginatingTo, ctx.To],
}) ?? "";
if (normalizedThreadId && parentConversationId) {
conversationId =
buildTelegramTopicConversationId({
chatId: parentConversationId,
topicId: normalizedThreadId,
}) ?? conversationId;
}
if (!conversationId) {
return null;
}
return {
channel: channelRaw,
accountId,
conversationId,
...(parentConversationId ? { parentConversationId } : {}),
};
}
const conversationId = resolveConversationIdFromTargets({
threadId: normalizedThreadId || undefined,
targets: [ctx.OriginatingTo, ctx.To],
});
if (!conversationId) {
return null;
}
let parentConversationId: string | undefined;
if (channelRaw === "discord" && normalizedThreadId) {
const fromContext = normalizeSessionText(ctx.ThreadParentId);
if (fromContext && fromContext !== conversationId) {
parentConversationId = fromContext;
} else {
const fromParentSession = parseDiscordParentChannelFromSessionKey(ctx.ParentSessionKey);
if (fromParentSession && fromParentSession !== conversationId) {
parentConversationId = fromParentSession;
} else {
const fromTargets = resolveConversationIdFromTargets({
targets: [ctx.OriginatingTo, ctx.To],
});
if (fromTargets && fromTargets !== conversationId) {
parentConversationId = fromTargets;
}
}
}
}
return {
channel: channelRaw,
accountId,
conversationId,
...(parentConversationId ? { parentConversationId } : {}),
};
}
function resolveBoundAcpSessionForReset(params: {
cfg: OpenClawConfig;
ctx: MsgContext;
}): string | undefined {
const activeSessionKey = normalizeSessionText(params.ctx.SessionKey);
const bindingContext = resolveAcpResetBindingContext(params.ctx);
return resolveEffectiveResetTargetSessionKey({
cfg: params.cfg,
channel: bindingContext?.channel,
accountId: bindingContext?.accountId,
conversationId: bindingContext?.conversationId,
parentConversationId: bindingContext?.parentConversationId,
activeSessionKey,
allowNonAcpBindingSessionKey: false,
skipConfiguredFallbackWhenActiveSessionNonAcp: true,
fallbackToActiveAcpWhenUnbound: false,
});
}
export async function initSessionState(params: {
ctx: MsgContext;
cfg: OpenClawConfig;
@@ -140,6 +264,15 @@ export async function initSessionState(params: {
const strippedForReset = isGroup
? stripMentions(triggerBodyNormalized, ctx, cfg, agentId)
: triggerBodyNormalized;
const shouldUseAcpInPlaceReset = Boolean(
resolveBoundAcpSessionForReset({
cfg,
ctx: sessionCtxForState,
}),
);
const shouldBypassAcpResetForTrigger = (triggerLower: string): boolean =>
shouldUseAcpInPlaceReset &&
DEFAULT_RESET_TRIGGERS.some((defaultTrigger) => defaultTrigger.toLowerCase() === triggerLower);
// Reset triggers are configured as lowercased commands (e.g. "/new"), but users may type
// "/NEW" etc. Match case-insensitively while keeping the original casing for any stripped body.
@@ -155,6 +288,12 @@ export async function initSessionState(params: {
}
const triggerLower = trigger.toLowerCase();
if (trimmedBodyLower === triggerLower || strippedForResetLower === triggerLower) {
if (shouldBypassAcpResetForTrigger(triggerLower)) {
// ACP-bound conversations handle /new and /reset in command handling
// so the bound ACP runtime can be reset in place without rotating the
// normal OpenClaw session/transcript.
break;
}
isNewSession = true;
bodyStripped = "";
resetTriggered = true;
@@ -165,6 +304,9 @@ export async function initSessionState(params: {
trimmedBodyLower.startsWith(triggerPrefixLower) ||
strippedForResetLower.startsWith(triggerPrefixLower)
) {
if (shouldBypassAcpResetForTrigger(triggerLower)) {
break;
}
isNewSession = true;
bodyStripped = strippedForReset.slice(trigger.length).trimStart();
resetTriggered = true;

View File

@@ -133,6 +133,11 @@ export type MsgContext = {
CommandAuthorized?: boolean;
CommandSource?: "text" | "native";
CommandTargetSessionKey?: string;
/**
* Internal flag: command handling prepared trailing prompt text for ACP dispatch.
* Used for `/new <prompt>` and `/reset <prompt>` on ACP-bound sessions.
*/
AcpDispatchTailAfterReset?: boolean;
/** Gateway client scopes when the message originates from the gateway. */
GatewayClientScopes?: string[];
/** Thread identifier (Telegram topic id or Matrix thread event id). */
@@ -152,6 +157,11 @@ export type MsgContext = {
* The chat/channel/user ID where the reply should be sent.
*/
OriginatingTo?: string;
/**
* Provider-specific parent conversation id for threaded contexts.
* For Discord threads, this is the parent channel id.
*/
ThreadParentId?: string;
/**
* Messages from hooks to be included in the response.
* Used for hook confirmation messages like "Session context saved to memory".