From 6453fdbf70ec986de3d5f86a9aa79032ea9044df Mon Sep 17 00:00:00 2001 From: quotentiroler Date: Sun, 8 Feb 2026 15:12:11 -0800 Subject: [PATCH] feat: backward compat for resetByType.dm config key --- docs/gateway/configuration.md | 4 +- src/config/sessions/reset.test.ts | 72 +++++++++++++++++++++++++++++++ src/config/sessions/reset.ts | 8 +++- src/config/types.base.ts | 2 + src/config/zod-schema.agents.ts | 8 +++- src/config/zod-schema.core.ts | 8 +++- src/config/zod-schema.session.ts | 10 ++++- src/routing/resolve-route.test.ts | 26 +++++++++++ src/routing/resolve-route.ts | 4 +- 9 files changed, 135 insertions(+), 7 deletions(-) create mode 100644 src/config/sessions/reset.test.ts diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index d8ed36d30e..8bb61e65c0 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -2760,7 +2760,7 @@ Controls session scoping, reset policy, reset triggers, and where the session st }, resetByType: { thread: { mode: "daily", atHour: 4 }, - dm: { mode: "idle", idleMinutes: 240 }, + direct: { mode: "idle", idleMinutes: 240 }, group: { mode: "idle", idleMinutes: 120 }, }, resetTriggers: ["/new", "/reset"], @@ -2797,7 +2797,7 @@ Fields: - `mode`: `daily` or `idle` (default: `daily` when `reset` is present). - `atHour`: local hour (0-23) for the daily reset boundary. - `idleMinutes`: sliding idle window in minutes. When daily + idle are both configured, whichever expires first wins. -- `resetByType`: per-session overrides for `dm`, `group`, and `thread`. +- `resetByType`: per-session overrides for `direct`, `group`, and `thread`. Legacy `dm` key is accepted as an alias for `direct`. - If you only set legacy `session.idleMinutes` without any `reset`/`resetByType`, OpenClaw stays in idle-only mode for backward compatibility. - `heartbeatIdleMinutes`: optional idle override for heartbeat checks (daily reset still applies when enabled). - `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (0–5, default 5). diff --git a/src/config/sessions/reset.test.ts b/src/config/sessions/reset.test.ts new file mode 100644 index 0000000000..01962a887e --- /dev/null +++ b/src/config/sessions/reset.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import type { SessionConfig } from "../types.base.js"; +import { resolveSessionResetPolicy } from "./reset.js"; + +describe("resolveSessionResetPolicy", () => { + describe("backward compatibility: resetByType.dm → direct", () => { + it("uses resetByType.direct when available", () => { + const sessionCfg = { + resetByType: { + direct: { mode: "idle" as const, idleMinutes: 30 }, + }, + } satisfies SessionConfig; + + const policy = resolveSessionResetPolicy({ + sessionCfg, + resetType: "direct", + }); + + expect(policy.mode).toBe("idle"); + expect(policy.idleMinutes).toBe(30); + }); + + it("falls back to resetByType.dm (legacy) when direct is missing", () => { + // Simulating legacy config with "dm" key instead of "direct" + const sessionCfg = { + resetByType: { + dm: { mode: "idle" as const, idleMinutes: 45 }, + }, + } as unknown as SessionConfig; + + const policy = resolveSessionResetPolicy({ + sessionCfg, + resetType: "direct", + }); + + expect(policy.mode).toBe("idle"); + expect(policy.idleMinutes).toBe(45); + }); + + it("prefers resetByType.direct over resetByType.dm when both present", () => { + const sessionCfg = { + resetByType: { + direct: { mode: "daily" as const }, + dm: { mode: "idle" as const, idleMinutes: 99 }, + }, + } as unknown as SessionConfig; + + const policy = resolveSessionResetPolicy({ + sessionCfg, + resetType: "direct", + }); + + expect(policy.mode).toBe("daily"); + }); + + it("does not use dm fallback for group/thread types", () => { + const sessionCfg = { + resetByType: { + dm: { mode: "idle" as const, idleMinutes: 45 }, + }, + } as unknown as SessionConfig; + + const groupPolicy = resolveSessionResetPolicy({ + sessionCfg, + resetType: "group", + }); + + // Should use default mode since group has no config and dm doesn't apply + expect(groupPolicy.mode).toBe("daily"); + }); + }); +}); diff --git a/src/config/sessions/reset.ts b/src/config/sessions/reset.ts index 8c3a62cf75..50fb911148 100644 --- a/src/config/sessions/reset.ts +++ b/src/config/sessions/reset.ts @@ -88,7 +88,13 @@ export function resolveSessionResetPolicy(params: { }): SessionResetPolicy { const sessionCfg = params.sessionCfg; const baseReset = params.resetOverride ?? sessionCfg?.reset; - const typeReset = params.resetOverride ? undefined : sessionCfg?.resetByType?.[params.resetType]; + // Backward compat: accept legacy "dm" key as alias for "direct" + const typeReset = params.resetOverride + ? undefined + : (sessionCfg?.resetByType?.[params.resetType] ?? + (params.resetType === "direct" + ? (sessionCfg?.resetByType as { dm?: SessionResetConfig } | undefined)?.dm + : undefined)); const hasExplicitReset = Boolean(baseReset || sessionCfg?.resetByType); const legacyIdleMinutes = params.resetOverride ? undefined : sessionCfg?.idleMinutes; const mode = diff --git a/src/config/types.base.ts b/src/config/types.base.ts index c1fcc93e0b..9d713b816d 100644 --- a/src/config/types.base.ts +++ b/src/config/types.base.ts @@ -72,6 +72,8 @@ export type SessionResetConfig = { }; export type SessionResetByTypeConfig = { direct?: SessionResetConfig; + /** @deprecated Use `direct` instead. Kept for backward compatibility. */ + dm?: SessionResetConfig; group?: SessionResetConfig; thread?: SessionResetConfig; }; diff --git a/src/config/zod-schema.agents.ts b/src/config/zod-schema.agents.ts index f89568cc18..92947c2a8e 100644 --- a/src/config/zod-schema.agents.ts +++ b/src/config/zod-schema.agents.ts @@ -22,7 +22,13 @@ export const BindingsSchema = z accountId: z.string().optional(), peer: z .object({ - kind: z.union([z.literal("direct"), z.literal("group"), z.literal("channel")]), + kind: z.union([ + z.literal("direct"), + z.literal("group"), + z.literal("channel"), + /** @deprecated Use `direct` instead. Kept for backward compatibility. */ + z.literal("dm"), + ]), id: z.string(), }) .strict() diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 627a898733..721d6252c0 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -378,7 +378,13 @@ export const MediaUnderstandingScopeSchema = z .object({ channel: z.string().optional(), chatType: z - .union([z.literal("direct"), z.literal("group"), z.literal("channel")]) + .union([ + z.literal("direct"), + z.literal("group"), + z.literal("channel"), + /** @deprecated Use `direct` instead. Kept for backward compatibility. */ + z.literal("dm"), + ]) .optional(), keyPrefix: z.string().optional(), }) diff --git a/src/config/zod-schema.session.ts b/src/config/zod-schema.session.ts index 8e471f9c96..555b921cda 100644 --- a/src/config/zod-schema.session.ts +++ b/src/config/zod-schema.session.ts @@ -27,7 +27,13 @@ export const SessionSendPolicySchema = z .object({ channel: z.string().optional(), chatType: z - .union([z.literal("direct"), z.literal("group"), z.literal("channel")]) + .union([ + z.literal("direct"), + z.literal("group"), + z.literal("channel"), + /** @deprecated Use `direct` instead. Kept for backward compatibility. */ + z.literal("dm"), + ]) .optional(), keyPrefix: z.string().optional(), }) @@ -58,6 +64,8 @@ export const SessionSchema = z resetByType: z .object({ direct: SessionResetConfigSchema.optional(), + /** @deprecated Use `direct` instead. Kept for backward compatibility. */ + dm: SessionResetConfigSchema.optional(), group: SessionResetConfigSchema.optional(), thread: SessionResetConfigSchema.optional(), }) diff --git a/src/routing/resolve-route.test.ts b/src/routing/resolve-route.test.ts index 15f4fe75ed..7d99cdb146 100644 --- a/src/routing/resolve-route.test.ts +++ b/src/routing/resolve-route.test.ts @@ -409,3 +409,29 @@ describe("parentPeer binding inheritance (thread support)", () => { expect(route.matchedBy).toBe("default"); }); }); + +describe("backward compatibility: peer.kind dm → direct", () => { + test("legacy dm in config matches runtime direct peer", () => { + const cfg: OpenClawConfig = { + bindings: [ + { + agentId: "alex", + match: { + channel: "whatsapp", + // Legacy config uses "dm" instead of "direct" + peer: { kind: "dm", id: "+15551234567" }, + }, + }, + ], + }; + const route = resolveAgentRoute({ + cfg, + channel: "whatsapp", + accountId: null, + // Runtime uses canonical "direct" + peer: { kind: "direct", id: "+15551234567" }, + }); + expect(route.agentId).toBe("alex"); + expect(route.matchedBy).toBe("binding.peer"); + }); +}); diff --git a/src/routing/resolve-route.ts b/src/routing/resolve-route.ts index ab6b762473..70917841ab 100644 --- a/src/routing/resolve-route.ts +++ b/src/routing/resolve-route.ts @@ -1,6 +1,7 @@ import type { ChatType } from "../channels/chat-type.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { normalizeChatType } from "../channels/chat-type.js"; import { listBindings } from "./bindings.js"; import { buildAgentMainSessionKey, @@ -139,7 +140,8 @@ function matchesPeer( if (!m) { return false; } - const kind = normalizeToken(m.kind); + // Backward compat: normalize "dm" to "direct" in config match rules + const kind = normalizeChatType(m.kind); const id = normalizeId(m.id); if (!kind || !id) { return false;