This commit is contained in:
Ethan Trawick 2026-03-15 22:36:14 +00:00 committed by GitHub
commit 44e4f90617
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 408 additions and 2 deletions

View File

@ -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.

View File

@ -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. */

View File

@ -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;

View File

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

View File

@ -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:<peerId>:<name>
*/
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:<agentId>:dm-named:<peerId>:<name>
*/
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,
};
}