diff --git a/CHANGELOG.md b/CHANGELOG.md index 6acb2fd82fb..b03cd7108e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ Docs: https://docs.openclaw.ai ### Changes +- Sessions/named DM: add `buildNamedDmSessionKey`, `parseNamedDmSessionKey`, and `isNamedDmSessionKey` key-format helpers, an `activeNamedSession` field on `SessionEntry`, and `setActiveNamedSession`/`getActiveNamedSessionKey` store helpers as infrastructure for named DM session switching. + - Android/mobile: add a system-aware dark theme across onboarding and post-onboarding screens so the app follows the device theme through setup, chat, and voice flows. (#46249) Thanks @sibbl. - Commands/btw: add `/btw` side questions for quick tool-less answers about the current session without changing future session context, with dismissible in-session TUI answers and explicit BTW replies on external channels. (#45444) Thanks @ngutman. - Gateway/health monitor: add configurable stale-event thresholds and restart limits, plus per-channel and per-account `healthMonitor.enabled` overrides, while keeping the existing global disable path on `gateway.channelHealthCheckMinutes=0`. (#42107) Thanks @rstar327. diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 4ba9b336127..4efa4578156 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -76,6 +76,12 @@ export type SessionEntry = { sessionId: string; updatedAt: number; sessionFile?: string; + /** + * Active named session for DM routing (ETH-608). + * When set, DM messages from this peer route to the named session instead of main. + * Format: "valorant", "work", etc. (session name only, not full key). + */ + activeNamedSession?: string; /** Parent session key that spawned this session (used for sandbox session-tool scoping). */ spawnedBy?: string; /** Workspace inherited by spawned sessions and reused on later turns for the same child session. */ diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 00a2cb7747e..5e2a22e08f9 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -30,7 +30,7 @@ import { normalizeMainKey, parseAgentSessionKey, } from "../routing/session-key.js"; -import { isCronRunSessionKey } from "../sessions/session-key-utils.js"; +import { buildNamedDmSessionKey, isCronRunSessionKey } from "../sessions/session-key-utils.js"; import { AVATAR_MAX_BYTES, isAvatarDataUrl, @@ -843,6 +843,57 @@ export function resolveSessionModelIdentityRef( return { provider: resolved.provider, model: resolved.model }; } +/** + * Get the active named session key for a DM peer, if one is set. + * Returns the full named session key if active, or null otherwise. + */ +export function getActiveNamedSessionKey(params: { + mainEntry: SessionEntry | undefined; + agentId: string; + peerId: string; +}): string | null { + if (!params.mainEntry || !params.mainEntry.activeNamedSession) { + return null; + } + const name = params.mainEntry.activeNamedSession.trim(); + if (!name) { + return null; + } + return buildNamedDmSessionKey({ + agentId: params.agentId, + peerId: params.peerId, + name, + }); +} + +/** + * Set or clear the active named session for a DM peer on the main session entry. + * If name is null/empty, clears the active named session (returns to main). + * Returns true if the update was applied. + */ +export function setActiveNamedSession(params: { + mainEntry: SessionEntry; + name: string | null | undefined; +}): boolean { + const trimmed = params.name?.trim(); + if (!trimmed) { + if (params.mainEntry.activeNamedSession !== undefined) { + delete params.mainEntry.activeNamedSession; + return true; + } + return false; + } + const normalized = trimmed.toLowerCase(); + if (normalized.includes(":")) { + throw new Error(`setActiveNamedSession: name must not contain ":" (got: "${normalized}")`); + } + if (params.mainEntry.activeNamedSession !== normalized) { + params.mainEntry.activeNamedSession = normalized; + return true; + } + return false; +} + export function listSessionsFromStore(params: { cfg: OpenClawConfig; storePath: string; diff --git a/src/sessions/session-key-utils.test.ts b/src/sessions/session-key-utils.test.ts new file mode 100644 index 00000000000..9e55bd5312b --- /dev/null +++ b/src/sessions/session-key-utils.test.ts @@ -0,0 +1,278 @@ +import { describe, expect, it } from "vitest"; +import type { SessionEntry } from "../config/sessions.js"; +import { getActiveNamedSessionKey, setActiveNamedSession } from "../gateway/session-utils.js"; +import { + buildNamedDmSessionKey, + isNamedDmSessionKey, + parseNamedDmSessionKey, +} from "./session-key-utils.js"; + +describe("Named DM Session Keys (ETH-608)", () => { + describe("buildNamedDmSessionKey", () => { + it("builds a valid named DM session key", () => { + const key = buildNamedDmSessionKey({ + agentId: "main", + peerId: "123456789", + name: "valorant", + }); + expect(key).toBe("agent:main:dm-named:123456789:valorant"); + }); + + it("normalizes inputs to lowercase", () => { + const key = buildNamedDmSessionKey({ + agentId: "MAIN", + peerId: "USER123", + name: "MySession", + }); + expect(key).toBe("agent:main:dm-named:user123:mysession"); + }); + + it("throws on empty agentId", () => { + expect(() => + buildNamedDmSessionKey({ + agentId: "", + peerId: "123", + name: "work", + }), + ).toThrow("agentId, peerId, and name are required"); + }); + + it("throws on empty peerId", () => { + expect(() => + buildNamedDmSessionKey({ + agentId: "main", + peerId: "", + name: "work", + }), + ).toThrow("agentId, peerId, and name are required"); + }); + + it("throws on empty name", () => { + expect(() => + buildNamedDmSessionKey({ + agentId: "main", + peerId: "123", + name: "", + }), + ).toThrow("agentId, peerId, and name are required"); + }); + + it("throws when agentId contains a colon", () => { + expect(() => + buildNamedDmSessionKey({ + agentId: "ma:in", + peerId: "123", + name: "work", + }), + ).toThrow(); + }); + + it("throws when peerId contains a colon", () => { + expect(() => + buildNamedDmSessionKey({ + agentId: "main", + peerId: "123:456", + name: "work", + }), + ).toThrow(); + }); + + it("trims whitespace", () => { + const key = buildNamedDmSessionKey({ + agentId: " main ", + peerId: " 123 ", + name: " work ", + }); + expect(key).toBe("agent:main:dm-named:123:work"); + }); + }); + + describe("isNamedDmSessionKey", () => { + it("returns true for valid named DM session keys", () => { + expect(isNamedDmSessionKey("agent:main:dm-named:123456789:valorant")).toBe(true); + expect(isNamedDmSessionKey("agent:ops:dm-named:999:work")).toBe(true); + }); + + it("returns false for non-agent keys", () => { + expect(isNamedDmSessionKey("main")).toBe(false); + expect(isNamedDmSessionKey("discord:direct:123")).toBe(false); + }); + + it("returns false for agent keys that are not named DM keys", () => { + expect(isNamedDmSessionKey("agent:main:main")).toBe(false); + expect(isNamedDmSessionKey("agent:main:direct:123")).toBe(false); + expect(isNamedDmSessionKey("agent:main:group:456")).toBe(false); + }); + + it("returns false for malformed named DM keys", () => { + expect(isNamedDmSessionKey("agent:main:dm-named")).toBe(false); + expect(isNamedDmSessionKey("agent:main:dm-named:123")).toBe(false); + expect(isNamedDmSessionKey("agent:main:dm-named:123:work:extra")).toBe(false); + }); + + it("returns false for null/undefined/empty", () => { + expect(isNamedDmSessionKey(null)).toBe(false); + expect(isNamedDmSessionKey(undefined)).toBe(false); + expect(isNamedDmSessionKey("")).toBe(false); + }); + }); + + describe("parseNamedDmSessionKey", () => { + it("parses valid named DM session keys", () => { + const result = parseNamedDmSessionKey("agent:main:dm-named:123456789:valorant"); + expect(result).toEqual({ + agentId: "main", + peerId: "123456789", + name: "valorant", + }); + }); + + it("normalizes to lowercase", () => { + const result = parseNamedDmSessionKey("AGENT:MAIN:DM-NAMED:USER123:WORK"); + expect(result).toEqual({ + agentId: "main", + peerId: "user123", + name: "work", + }); + }); + + it("returns null for non-agent keys", () => { + expect(parseNamedDmSessionKey("main")).toBe(null); + expect(parseNamedDmSessionKey("discord:direct:123")).toBe(null); + }); + + it("returns null for non-named-DM agent keys", () => { + expect(parseNamedDmSessionKey("agent:main:main")).toBe(null); + expect(parseNamedDmSessionKey("agent:main:direct:123")).toBe(null); + }); + + it("returns null for malformed named DM keys", () => { + expect(parseNamedDmSessionKey("agent:main:dm-named")).toBe(null); + expect(parseNamedDmSessionKey("agent:main:dm-named:123")).toBe(null); + }); + + it("returns null for null/undefined/empty", () => { + expect(parseNamedDmSessionKey(null)).toBe(null); + expect(parseNamedDmSessionKey(undefined)).toBe(null); + expect(parseNamedDmSessionKey("")).toBe(null); + }); + + it("handles multiple agents", () => { + const result1 = parseNamedDmSessionKey("agent:ops:dm-named:456:project-x"); + expect(result1).toEqual({ + agentId: "ops", + peerId: "456", + name: "project-x", + }); + + const result2 = parseNamedDmSessionKey("agent:dev:dm-named:789:testing"); + expect(result2).toEqual({ + agentId: "dev", + peerId: "789", + name: "testing", + }); + }); + }); + + describe("round-trip", () => { + it("build and parse round-trip correctly", () => { + const original = { + agentId: "main", + peerId: "123456789", + name: "valorant", + }; + + const key = buildNamedDmSessionKey(original); + const parsed = parseNamedDmSessionKey(key); + + expect(parsed).toEqual(original); + }); + + it("round-trip with normalization", () => { + const key = buildNamedDmSessionKey({ + agentId: "MAIN", + peerId: "USER123", + name: "MySession", + }); + + const parsed = parseNamedDmSessionKey(key); + + expect(parsed).toEqual({ + agentId: "main", + peerId: "user123", + name: "mysession", + }); + }); + }); + + describe("setActiveNamedSession", () => { + it("sets activeNamedSession on the entry and returns true", () => { + const entry = {} as SessionEntry; + const result = setActiveNamedSession({ mainEntry: entry, name: "work" }); + expect(result).toBe(true); + expect(entry.activeNamedSession).toBe("work"); + }); + + it("is idempotent — returns false when called twice with same name", () => { + const entry = {} as SessionEntry; + setActiveNamedSession({ mainEntry: entry, name: "work" }); + const result = setActiveNamedSession({ mainEntry: entry, name: "work" }); + expect(result).toBe(false); + }); + + it("clears activeNamedSession when name is null and returns true", () => { + const entry = { activeNamedSession: "work" } as SessionEntry; + const result = setActiveNamedSession({ mainEntry: entry, name: null }); + expect(result).toBe(true); + expect(entry.activeNamedSession).toBeUndefined(); + }); + + it("returns false when clearing an already-cleared entry", () => { + const entry = {} as SessionEntry; + const result = setActiveNamedSession({ mainEntry: entry, name: null }); + expect(result).toBe(false); + }); + + it("throws when name contains a colon", () => { + const entry = {} as SessionEntry; + expect(() => setActiveNamedSession({ mainEntry: entry, name: "foo:bar" })).toThrow(); + expect(entry.activeNamedSession).toBeUndefined(); + }); + + it("normalizes name to lowercase", () => { + const entry = {} as SessionEntry; + setActiveNamedSession({ mainEntry: entry, name: "Work" }); + expect(entry.activeNamedSession).toBe("work"); + }); + }); + + describe("getActiveNamedSessionKey", () => { + it("returns null when mainEntry is undefined", () => { + const result = getActiveNamedSessionKey({ + mainEntry: undefined, + agentId: "main", + peerId: "123", + }); + expect(result).toBeNull(); + }); + + it("returns null when activeNamedSession is not set", () => { + const entry = {} as SessionEntry; + const result = getActiveNamedSessionKey({ mainEntry: entry, agentId: "main", peerId: "123" }); + expect(result).toBeNull(); + }); + + it("returns null when activeNamedSession is whitespace-only", () => { + const entry = { activeNamedSession: " " } as SessionEntry; + const result = getActiveNamedSessionKey({ mainEntry: entry, agentId: "main", peerId: "123" }); + expect(result).toBeNull(); + }); + + it("returns the correct named session key on round-trip", () => { + const entry = {} as SessionEntry; + setActiveNamedSession({ mainEntry: entry, name: "work" }); + const result = getActiveNamedSessionKey({ mainEntry: entry, agentId: "main", peerId: "123" }); + expect(result).toBe("agent:main:dm-named:123:work"); + }); + }); +}); diff --git a/src/sessions/session-key-utils.ts b/src/sessions/session-key-utils.ts index c405df3a5ff..e8a6bfab72b 100644 --- a/src/sessions/session-key-utils.ts +++ b/src/sessions/session-key-utils.ts @@ -47,7 +47,7 @@ export function deriveSessionChatType(sessionKey: string | undefined | null): Se if (tokens.has("channel")) { return "channel"; } - if (tokens.has("direct") || tokens.has("dm")) { + if (tokens.has("direct") || tokens.has("dm") || tokens.has("dm-named")) { return "direct"; } // Legacy Discord keys can be shaped like: @@ -130,3 +130,72 @@ export function resolveThreadParentSessionKey( const parent = raw.slice(0, idx).trim(); return parent ? parent : null; } + +/** + * Check if a session key is a named DM session key. + * Format: agent:main:dm-named:: + */ +export function isNamedDmSessionKey(sessionKey: string | undefined | null): boolean { + const parsed = parseAgentSessionKey(sessionKey); + if (!parsed) { + return false; + } + return /^dm-named:[^:]+:[^:]+$/.test(parsed.rest); +} + +/** + * Build a named DM session key. + * Format: agent::dm-named:: + */ +export function buildNamedDmSessionKey(params: { + agentId: string; + peerId: string; + name: string; +}): string { + const agentId = params.agentId.trim().toLowerCase(); + const peerId = params.peerId.trim().toLowerCase(); + const name = params.name.trim().toLowerCase(); + if (!agentId || !peerId || !name) { + throw new Error("buildNamedDmSessionKey: agentId, peerId, and name are required"); + } + if (agentId.includes(":")) { + throw new Error(`buildNamedDmSessionKey: agentId must not contain ":" (got: "${agentId}")`); + } + if (peerId.includes(":")) { + throw new Error(`buildNamedDmSessionKey: peerId must not contain ":" (got: "${peerId}")`); + } + if (name.includes(":")) { + throw new Error(`buildNamedDmSessionKey: name must not contain ":" (got: "${name}")`); + } + return `agent:${agentId}:dm-named:${peerId}:${name}`; +} + +/** + * Parse a named DM session key. + * Returns { agentId, peerId, name } or null if not a named DM key. + */ +export function parseNamedDmSessionKey( + sessionKey: string | undefined | null, +): { agentId: string; peerId: string; name: string } | null { + const parsed = parseAgentSessionKey(sessionKey); + if (!parsed) { + return null; + } + const parts = parsed.rest.split(":"); + if (parts.length !== 3) { + return null; + } + if (parts[0] !== "dm-named") { + return null; + } + const peerId = parts[1]?.trim(); + const name = parts[2]?.trim(); + if (!peerId || !name) { + return null; + } + return { + agentId: parsed.agentId, + peerId, + name, + }; +}