mirror of https://github.com/openclaw/openclaw.git
Merge a0cbe0a87c into 392ddb56e2
This commit is contained in:
commit
44e4f90617
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue