mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-02 07:14:56 +00:00
Summary: - The PR adds `openclaw agent --session-key`, normalizes explicit session keys through Gateway and embedded agent execution, and updates docs, tests, and changelog. - Reproducibility: yes. Current main's `openclaw agent` registration and gateway CLI option type lack `--sessi ... Gateway agent protocol already accepts `sessionKey`; this is source-reproducible without executing the CLI. Automerge notes: - PR branch already contained follow-up commit before automerge: fix(agent): support explicit CLI session keys Validation: - ClawSweeper review passed for head2c76dd339f. - Required merge gates passed before the squash merge. Prepared head SHA:2c76dd339fReview: https://github.com/openclaw/openclaw/pull/85121#issuecomment-4513508932 Co-authored-by: Kaspre <kaspre@gmail.com> Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com> Approved-by: takhoffman Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
388 lines
14 KiB
TypeScript
388 lines
14 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { deriveSessionChatTypeFromKey } from "../sessions/session-chat-type-shared.js";
|
|
import {
|
|
getSubagentDepth,
|
|
isCronSessionKey,
|
|
parseThreadSessionSuffix,
|
|
resolveThreadParentSessionKey,
|
|
} from "../sessions/session-key-utils.js";
|
|
import {
|
|
buildAgentPeerSessionKey,
|
|
buildGroupHistoryKey,
|
|
classifySessionKeyShape,
|
|
isValidAgentId,
|
|
parseAgentSessionKey,
|
|
resolveEventSessionKey,
|
|
scopedHeartbeatWakeOptions,
|
|
isUnscopedSessionKeySentinel,
|
|
scopeLegacySessionKeyToAgent,
|
|
toAgentStoreSessionKey,
|
|
} from "./session-key.js";
|
|
|
|
describe("classifySessionKeyShape", () => {
|
|
it.each([
|
|
{ input: undefined, expected: "missing" },
|
|
{ input: " ", expected: "missing" },
|
|
{ input: "agent:main:main", expected: "agent" },
|
|
{ input: "agent:research:subagent:worker", expected: "agent" },
|
|
{ input: "agent::broken", expected: "malformed_agent" },
|
|
{ input: "agent:main", expected: "malformed_agent" },
|
|
{ input: "main", expected: "legacy_or_alias" },
|
|
{ input: "custom-main", expected: "legacy_or_alias" },
|
|
{ input: "subagent:worker", expected: "legacy_or_alias" },
|
|
] as const)("classifies %j as $expected", ({ input, expected }) => {
|
|
expect(classifySessionKeyShape(input)).toBe(expected);
|
|
});
|
|
});
|
|
|
|
describe("scopeLegacySessionKeyToAgent", () => {
|
|
it("scopes legacy aliases to the requested agent", () => {
|
|
expect(scopeLegacySessionKeyToAgent({ agentId: "Ops", sessionKey: "Incident-42" })).toBe(
|
|
"agent:ops:incident-42",
|
|
);
|
|
});
|
|
|
|
it("honors configured main-key aliases when scoping legacy keys", () => {
|
|
expect(
|
|
scopeLegacySessionKeyToAgent({ agentId: "ops", sessionKey: "main", mainKey: "work" }),
|
|
).toBe("agent:ops:work");
|
|
});
|
|
|
|
it("preserves already agent-prefixed keys", () => {
|
|
expect(
|
|
scopeLegacySessionKeyToAgent({
|
|
agentId: "ops",
|
|
sessionKey: "agent:main:incident-42",
|
|
}),
|
|
).toBe("agent:main:incident-42");
|
|
});
|
|
|
|
it("scopes global and unknown legacy aliases to the requested agent", () => {
|
|
expect(scopeLegacySessionKeyToAgent({ agentId: "ops", sessionKey: "global" })).toBe(
|
|
"agent:ops:global",
|
|
);
|
|
expect(scopeLegacySessionKeyToAgent({ agentId: "ops", sessionKey: "UNKNOWN" })).toBe(
|
|
"agent:ops:unknown",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("isUnscopedSessionKeySentinel", () => {
|
|
it("recognizes literal global and unknown sentinels", () => {
|
|
expect(isUnscopedSessionKeySentinel("global")).toBe(true);
|
|
expect(isUnscopedSessionKeySentinel("UNKNOWN")).toBe(true);
|
|
expect(isUnscopedSessionKeySentinel("agent:ops:global")).toBe(false);
|
|
expect(isUnscopedSessionKeySentinel("incident-42")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("session key backward compatibility", () => {
|
|
function expectBackwardCompatibleDirectSessionKey(key: string) {
|
|
expect(classifySessionKeyShape(key)).toBe("agent");
|
|
}
|
|
|
|
it.each([
|
|
"agent:main:telegram:dm:123456",
|
|
"agent:main:whatsapp:dm:+15551234567",
|
|
"agent:main:discord:dm:user123",
|
|
"agent:main:telegram:direct:123456",
|
|
"agent:main:whatsapp:direct:+15551234567",
|
|
"agent:main:discord:direct:user123",
|
|
] as const)("classifies backward-compatible direct session key %s as valid", (key) => {
|
|
expectBackwardCompatibleDirectSessionKey(key);
|
|
});
|
|
});
|
|
|
|
describe("getSubagentDepth", () => {
|
|
it.each([
|
|
{ key: "agent:main:main", expected: 0 },
|
|
{ key: "main", expected: 0 },
|
|
{ key: undefined, expected: 0 },
|
|
{ key: "agent:main:subagent:parent:subagent:child", expected: 2 },
|
|
] as const)("returns $expected for session key %j", ({ key, expected }) => {
|
|
expect(getSubagentDepth(key)).toBe(expected);
|
|
});
|
|
});
|
|
|
|
describe("isCronSessionKey", () => {
|
|
it.each([
|
|
{ key: "agent:main:cron:job-1", expected: true },
|
|
{ key: "agent:main:cron:job-1:run:run-1", expected: true },
|
|
{ key: "agent:main:cron:job-1:run:run-1:subagent:worker", expected: true },
|
|
{ key: "agent:main:main", expected: false },
|
|
{ key: "agent:main:subagent:worker", expected: false },
|
|
{ key: "cron:job-1", expected: false },
|
|
{ key: undefined, expected: false },
|
|
] as const)("matches cron key %j => $expected", ({ key, expected }) => {
|
|
expect(isCronSessionKey(key)).toBe(expected);
|
|
});
|
|
});
|
|
|
|
describe("deriveSessionChatTypeFromKey", () => {
|
|
it.each([
|
|
{ key: "agent:main:discord:direct:user1", expected: "direct" },
|
|
{ key: "agent:main:telegram:group:g1", expected: "group" },
|
|
{ key: "agent:main:discord:channel:c1", expected: "channel" },
|
|
{ key: "agent:main:discord:guild-123:channel-456", expected: "channel" },
|
|
{ key: "agent:main:whatsapp:123@g.us", expected: "group" },
|
|
{ key: "agent:main:telegram:dm:123456", expected: "direct" },
|
|
{ key: "telegram:dm:123456", expected: "direct" },
|
|
{ key: "agent:main:main", expected: "unknown" },
|
|
{ key: "agent:main", expected: "unknown" },
|
|
{ key: "", expected: "unknown" },
|
|
] as const)("derives chat type for %j => $expected", ({ key, expected }) => {
|
|
expect(deriveSessionChatTypeFromKey(key)).toBe(expected);
|
|
});
|
|
|
|
it("uses plugin-owned legacy chat-type hooks after generic token parsing", () => {
|
|
expect(
|
|
deriveSessionChatTypeFromKey("legacy-room:abc", [
|
|
(sessionKey) => (sessionKey.startsWith("legacy-room:") ? "channel" : undefined),
|
|
]),
|
|
).toBe("channel");
|
|
});
|
|
});
|
|
|
|
describe("thread session suffix parsing", () => {
|
|
it("preserves feishu conversation ids that embed :topic: in the base id", () => {
|
|
expect(
|
|
parseThreadSessionSuffix(
|
|
"agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user",
|
|
),
|
|
).toEqual({
|
|
baseSessionKey:
|
|
"agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user",
|
|
threadId: undefined,
|
|
});
|
|
expect(
|
|
resolveThreadParentSessionKey(
|
|
"agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user",
|
|
),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("does not treat telegram :topic: as a generic thread suffix", () => {
|
|
expect(parseThreadSessionSuffix("agent:main:telegram:group:-100123:topic:77")).toEqual({
|
|
baseSessionKey: "agent:main:telegram:group:-100123:topic:77",
|
|
threadId: undefined,
|
|
});
|
|
expect(resolveThreadParentSessionKey("agent:main:telegram:group:-100123:topic:77")).toBeNull();
|
|
});
|
|
|
|
it("parses mixed-case :thread: markers without lowercasing the stored key", () => {
|
|
expect(
|
|
parseThreadSessionSuffix("agent:main:slack:channel:General:Thread:1699999999.0001"),
|
|
).toEqual({
|
|
baseSessionKey: "agent:main:slack:channel:General",
|
|
threadId: "1699999999.0001",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("session key canonicalization", () => {
|
|
function expectSessionKeyCanonicalizationCase(params: { run: () => void }) {
|
|
params.run();
|
|
}
|
|
|
|
it.each([
|
|
{
|
|
name: "parses agent keys case-insensitively and returns lowercase tokens",
|
|
run: () =>
|
|
expect(parseAgentSessionKey("AGENT:Main:Hook:Webhook:42")).toEqual({
|
|
agentId: "main",
|
|
rest: "hook:webhook:42",
|
|
}),
|
|
},
|
|
{
|
|
name: "does not double-prefix already-qualified agent keys",
|
|
run: () =>
|
|
expect(
|
|
toAgentStoreSessionKey({
|
|
agentId: "main",
|
|
requestKey: "agent:main:main",
|
|
}),
|
|
).toBe("agent:main:main"),
|
|
},
|
|
{
|
|
name: "preserves Signal group ids while lowercasing structural tokens",
|
|
run: () => {
|
|
const mixedGroupId = "VWATodkf2hc8zdOS76q9Tb0+5Bi522E03qLdaQ/9ypg=";
|
|
expect(parseAgentSessionKey(`Agent:Main:Signal:Group:${mixedGroupId}`)).toEqual({
|
|
agentId: "main",
|
|
rest: `signal:group:${mixedGroupId}`,
|
|
});
|
|
expect(
|
|
buildAgentPeerSessionKey({
|
|
agentId: "Main",
|
|
channel: "Signal",
|
|
peerKind: "group",
|
|
peerId: mixedGroupId,
|
|
}),
|
|
).toBe(`agent:main:signal:group:${mixedGroupId}`);
|
|
expect(
|
|
buildGroupHistoryKey({
|
|
channel: "Signal",
|
|
peerKind: "group",
|
|
peerId: mixedGroupId,
|
|
}),
|
|
).toBe(`signal:default:group:${mixedGroupId}`);
|
|
},
|
|
},
|
|
{
|
|
name: "keeps non-Signal opaque-looking group ids lowercase",
|
|
run: () =>
|
|
expect(
|
|
buildAgentPeerSessionKey({
|
|
agentId: "Main",
|
|
channel: "Telegram",
|
|
peerKind: "group",
|
|
peerId: "MiXeDGroup",
|
|
}),
|
|
).toBe("agent:main:telegram:group:mixedgroup"),
|
|
},
|
|
] as const)("$name", ({ run }) => {
|
|
expectSessionKeyCanonicalizationCase({ run });
|
|
});
|
|
});
|
|
|
|
describe("scopedHeartbeatWakeOptions", () => {
|
|
it("remaps ephemeral cron run sessions to agent main key", () => {
|
|
const result = scopedHeartbeatWakeOptions("agent:main:cron:backup:run:abc", {
|
|
reason: "exec:123:exit",
|
|
});
|
|
expect(result).toEqual({ reason: "exec:123:exit", sessionKey: "agent:main:main" });
|
|
});
|
|
|
|
it("preserves durable cron base sessions (not remapped)", () => {
|
|
const result = scopedHeartbeatWakeOptions("agent:main:cron:backup", {
|
|
reason: "exec:123:exit",
|
|
});
|
|
expect(result).toEqual({ reason: "exec:123:exit", sessionKey: "agent:main:cron:backup" });
|
|
});
|
|
|
|
it("preserves sessionKey for regular agent sessions", () => {
|
|
const result = scopedHeartbeatWakeOptions("agent:main:main", {
|
|
reason: "exec:123:exit",
|
|
});
|
|
expect(result).toEqual({ reason: "exec:123:exit", sessionKey: "agent:main:main" });
|
|
});
|
|
|
|
it("strips sessionKey for non-agent keys", () => {
|
|
const result = scopedHeartbeatWakeOptions("main", { reason: "test" });
|
|
expect(result).toEqual({ reason: "test" });
|
|
expect("sessionKey" in result).toBe(false);
|
|
});
|
|
|
|
it("strips sessionKey for global-scope sessions to preserve unscoped wake behavior", () => {
|
|
// In session.scope = "global" setups, resolveMainSessionKeyFromConfig() returns "global".
|
|
// Passing "global" as sessionKey into requestHeartbeatNow would create a targeted wake
|
|
// that can fail to resolve, breaking hook-triggered heartbeats. scopedHeartbeatWakeOptions
|
|
// must strip it to preserve the old unscoped behavior.
|
|
const result = scopedHeartbeatWakeOptions("global", { reason: "hook:wake" });
|
|
expect(result).toEqual({ reason: "hook:wake" });
|
|
expect("sessionKey" in result).toBe(false);
|
|
});
|
|
|
|
it("drops sessionKey but preserves agentId for cron-run keys when scope is global", () => {
|
|
// Global-scope agents drain the "global" queue automatically; a targeted
|
|
// wake on agent:<id>:main would be unresolvable. Carry the agent target
|
|
// so multi-agent global-scope setups still wake the originating agent.
|
|
const result = scopedHeartbeatWakeOptions(
|
|
"agent:ops:cron:job-1:run:xyz",
|
|
{ reason: "exec-event" },
|
|
undefined,
|
|
"global",
|
|
);
|
|
expect(result).toEqual({ reason: "exec-event", agentId: "ops" });
|
|
expect("sessionKey" in result).toBe(false);
|
|
});
|
|
|
|
it("threads custom mainKey for cron-run keys under per-sender scope", () => {
|
|
const result = scopedHeartbeatWakeOptions(
|
|
"agent:main:cron:backup:run:abc",
|
|
{ reason: "exec-event" },
|
|
"primary",
|
|
"per-sender",
|
|
);
|
|
expect(result).toEqual({ reason: "exec-event", sessionKey: "agent:main:primary" });
|
|
});
|
|
});
|
|
|
|
describe("resolveEventSessionKey", () => {
|
|
it("remaps ephemeral cron run session keys to agent main session key", () => {
|
|
expect(resolveEventSessionKey("agent:main:cron:backup:run:abc123")).toBe("agent:main:main");
|
|
expect(resolveEventSessionKey("agent:ops:cron:job-1:run:xyz")).toBe("agent:ops:main");
|
|
});
|
|
|
|
it("collapses cron-run descendant session keys to the agent main session key", () => {
|
|
expect(resolveEventSessionKey("agent:main:cron:backup:run:abc123:subagent:worker")).toBe(
|
|
"agent:main:main",
|
|
);
|
|
expect(resolveEventSessionKey("agent:ops:cron:job-1:run:xyz:thread:reply")).toBe(
|
|
"agent:ops:main",
|
|
);
|
|
});
|
|
|
|
it("preserves durable cron base session keys", () => {
|
|
expect(resolveEventSessionKey("agent:ops:cron:job-1")).toBe("agent:ops:cron:job-1");
|
|
expect(resolveEventSessionKey("agent:main:cron:backup")).toBe("agent:main:cron:backup");
|
|
});
|
|
|
|
it("respects custom mainKey for ephemeral cron session remapping", () => {
|
|
expect(resolveEventSessionKey("agent:main:cron:backup:run:abc123", "primary")).toBe(
|
|
"agent:main:primary",
|
|
);
|
|
expect(resolveEventSessionKey("agent:ops:cron:job-1:run:xyz", "primary")).toBe(
|
|
"agent:ops:primary",
|
|
);
|
|
});
|
|
|
|
it("passes through non-cron session keys unchanged", () => {
|
|
expect(resolveEventSessionKey("agent:main:main")).toBe("agent:main:main");
|
|
expect(resolveEventSessionKey("agent:main:discord:direct:user1")).toBe(
|
|
"agent:main:discord:direct:user1",
|
|
);
|
|
});
|
|
|
|
it("passes through non-agent keys unchanged", () => {
|
|
expect(resolveEventSessionKey("main")).toBe("main");
|
|
expect(resolveEventSessionKey("global")).toBe("global");
|
|
});
|
|
|
|
it("routes cron-run keys to the global queue when scope is global", () => {
|
|
// resolveHeartbeatSession drains the literal "global" queue for global-scope
|
|
// sessions; remapping to agent:<id>:main would strand the event.
|
|
expect(resolveEventSessionKey("agent:ops:cron:job-1:run:xyz", undefined, "global")).toBe(
|
|
"global",
|
|
);
|
|
expect(resolveEventSessionKey("agent:main:cron:backup:run:abc", "primary", "global")).toBe(
|
|
"global",
|
|
);
|
|
expect(
|
|
resolveEventSessionKey("agent:main:cron:backup:run:abc:subagent:worker", "primary", "global"),
|
|
).toBe("global");
|
|
});
|
|
|
|
it("treats explicit per-sender scope identically to omitted scope", () => {
|
|
expect(
|
|
resolveEventSessionKey("agent:main:cron:backup:run:abc123", undefined, "per-sender"),
|
|
).toBe("agent:main:main");
|
|
expect(
|
|
resolveEventSessionKey("agent:main:cron:backup:run:abc123", "primary", "per-sender"),
|
|
).toBe("agent:main:primary");
|
|
});
|
|
});
|
|
|
|
describe("isValidAgentId", () => {
|
|
it.each([
|
|
{ input: "main", expected: true },
|
|
{ input: "my-research_agent01", expected: true },
|
|
{ input: "", expected: false },
|
|
{ input: "Agent not found: xyz", expected: false },
|
|
{ input: "../../../etc/passwd", expected: false },
|
|
{ input: "a".repeat(65), expected: false },
|
|
] as const)("validates agent id %j => $expected", ({ input, expected }) => {
|
|
expect(isValidAgentId(input)).toBe(expected);
|
|
});
|
|
});
|