mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-26 07:57:40 +00:00
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:
75
src/auto-reply/reply/acp-reset-target.ts
Normal file
75
src/auto-reply/reply/acp-reset-target.ts
Normal 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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -26,6 +26,7 @@ export type CommandContext = {
|
||||
|
||||
export type HandleCommandsParams = {
|
||||
ctx: MsgContext;
|
||||
rootCtx?: MsgContext;
|
||||
cfg: OpenClawConfig;
|
||||
command: CommandContext;
|
||||
agentId?: string;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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" }]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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".
|
||||
|
||||
Reference in New Issue
Block a user