mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
refactor(session-url): centralize normalization and contract tests (#120945)
* refactor(session-url): centralize contract normalization * fix(ui): resolve normalization agent-id alias * test(session-url): make grammar tables type-safe
This commit is contained in:
committed by
GitHub
parent
0df1a89e3a
commit
e40e352fe7
@@ -104,8 +104,8 @@ category changes remain reflected in the channel, but session archive, restore,
|
||||
reset, and deletion never archive or replace it. ClickClack owns channel archive
|
||||
and restore independently. `workspace`
|
||||
defaults to the account workspace, and `section` defaults to `Sessions`.
|
||||
`controlUrlBase` adds a link back to `/chat?session=<session-key>` in the
|
||||
OpenClaw Control UI.
|
||||
`controlUrlBase` adds canonical `/chat/<agent>/<session-ref>` links to the
|
||||
OpenClaw Control UI, preserving base paths. Main sessions use `/chat/<agent>`.
|
||||
|
||||
ClickClack-managed embed URLs explicitly advertise host-theme support. The
|
||||
Control UI uses that provider-owned capability to apply its full palette before
|
||||
|
||||
@@ -5,99 +5,6 @@ import { controlSessionUrl } from "./control-session-url.js";
|
||||
import { fallbackDiscussionLabel } from "./naming.js";
|
||||
import { MANAGED_CONTRACT_FIELDS, createHarness, testExternalRef } from "./service-test-support.js";
|
||||
|
||||
type SessionUrlContractCase = {
|
||||
sessionKey: string;
|
||||
agentId: string;
|
||||
mainKey: string | undefined;
|
||||
expectedPath: string | null;
|
||||
};
|
||||
|
||||
// Keep the independently published plugin wired to the host's canonical session URL contract.
|
||||
const SESSION_URL_CONTRACT_CASES = [
|
||||
{
|
||||
sessionKey: "agent:main:main",
|
||||
agentId: "main",
|
||||
mainKey: undefined,
|
||||
expectedPath: "/chat/main",
|
||||
},
|
||||
{ sessionKey: "main", agentId: "research", mainKey: undefined, expectedPath: "/chat/research" },
|
||||
{
|
||||
sessionKey: "main",
|
||||
agentId: "research",
|
||||
mainKey: "workspace",
|
||||
expectedPath: "/chat/research",
|
||||
},
|
||||
{ sessionKey: "main", agentId: "..", mainKey: undefined, expectedPath: "/chat/main" },
|
||||
{
|
||||
sessionKey: "agent:research:workspace",
|
||||
agentId: "main",
|
||||
mainKey: "workspace",
|
||||
expectedPath: "/chat/research",
|
||||
},
|
||||
{
|
||||
sessionKey: "agent:research:main",
|
||||
agentId: "main",
|
||||
mainKey: "workspace",
|
||||
expectedPath: "/chat/research/main",
|
||||
},
|
||||
{
|
||||
sessionKey: "telegram:12345",
|
||||
agentId: "research",
|
||||
mainKey: undefined,
|
||||
expectedPath: "/chat/research/telegram/12345",
|
||||
},
|
||||
{
|
||||
// Dots must be percent-escaped or the server treats the URL as a static asset
|
||||
// request and it never reaches the SPA on refresh or via an external link.
|
||||
sessionKey: "channel:release.js",
|
||||
agentId: "research",
|
||||
mainKey: undefined,
|
||||
expectedPath: "/chat/research/channel/release%2Ejs",
|
||||
},
|
||||
{
|
||||
sessionKey: "agent:main:control-link",
|
||||
agentId: "main",
|
||||
mainKey: undefined,
|
||||
expectedPath: "/chat/main/control-link",
|
||||
},
|
||||
{
|
||||
sessionKey: "agent:main:12345678",
|
||||
agentId: "main",
|
||||
mainKey: undefined,
|
||||
expectedPath: "/chat/main/~key/12345678",
|
||||
},
|
||||
{
|
||||
sessionKey: "agent:main:release-deadbeef",
|
||||
agentId: "main",
|
||||
mainKey: undefined,
|
||||
expectedPath: "/chat/main/~key/release-deadbeef",
|
||||
},
|
||||
{
|
||||
sessionKey: "agent:main:telegram:12345",
|
||||
agentId: "main",
|
||||
mainKey: undefined,
|
||||
expectedPath: "/chat/main/telegram/12345",
|
||||
},
|
||||
{
|
||||
sessionKey: "agent:main:dashboard:12345678-90ab-cdef-1234-567890abcdef",
|
||||
agentId: "main",
|
||||
mainKey: undefined,
|
||||
expectedPath: "/chat/main/12345678",
|
||||
},
|
||||
{
|
||||
sessionKey: "agent:main:dashboard:deadbeef-0aaa-4000-8000-000000000001",
|
||||
agentId: "main",
|
||||
mainKey: "deadbeef",
|
||||
expectedPath: "/chat/main/deadbeef0",
|
||||
},
|
||||
{
|
||||
sessionKey: "agent:main:cron:..:run",
|
||||
agentId: "main",
|
||||
mainKey: undefined,
|
||||
expectedPath: "/chat/main/cron/~dotdot/run",
|
||||
},
|
||||
] satisfies readonly SessionUrlContractCase[];
|
||||
|
||||
function legacyCreateResponse(
|
||||
input: Parameters<ClickClackClient["createChannel"]>[1],
|
||||
): ClickClackChannel {
|
||||
@@ -114,16 +21,23 @@ function legacyCreateResponse(
|
||||
}
|
||||
|
||||
describe("ClickClack discussion service", () => {
|
||||
it("matches the Control UI session URL contract vectors", () => {
|
||||
for (const testCase of SESSION_URL_CONTRACT_CASES) {
|
||||
const url = controlSessionUrl(
|
||||
"https://control.example",
|
||||
testCase.sessionKey,
|
||||
testCase.agentId,
|
||||
testCase.mainKey,
|
||||
);
|
||||
expect(url ? new URL(url).pathname : null).toBe(testCase.expectedPath);
|
||||
}
|
||||
it("reaches the host session URL seam through the published plugin wrapper", () => {
|
||||
expect(controlSessionUrl(undefined, "agent:main:main", "main", undefined)).toBeUndefined();
|
||||
expect(controlSessionUrl("https://control.example", "agent:main:main", "main", undefined)).toBe(
|
||||
"https://control.example/chat/main",
|
||||
);
|
||||
expect(controlSessionUrl("https://control.example", "main", "research", undefined)).toBe(
|
||||
"https://control.example/chat/research",
|
||||
);
|
||||
expect(
|
||||
controlSessionUrl(
|
||||
"https://control.example/control///?tenant=alpha#old",
|
||||
"agent:main:dashboard:12345678-90ab-cdef-1234-567890abcdef",
|
||||
"main",
|
||||
undefined,
|
||||
"Control Link",
|
||||
),
|
||||
).toBe("https://control.example/control/chat/main/control-link-12345678?tenant=alpha");
|
||||
});
|
||||
|
||||
it("opens a managed channel once and returns stable info URLs", async () => {
|
||||
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"name": "@openclaw/session-url-contract",
|
||||
"version": "0.0.0-private",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/session-url-contract",
|
||||
"version": "0.0.0-private"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,5 +6,8 @@
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./parse": "./src/parse.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@openclaw/normalization-core": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildControlUiCatalogSessionUrl } from "./index.js";
|
||||
import {
|
||||
buildControlUiCatalogSessionUrl,
|
||||
buildControlUiSessionPath,
|
||||
controlUiSessionSlug,
|
||||
} from "./index.js";
|
||||
|
||||
type ChatParams = Omit<Parameters<typeof buildControlUiSessionPath>[0], "namespace">;
|
||||
|
||||
const UUID_KEY = "agent:main:dashboard:12345678-90ab-cdef-1234-567890abcdef";
|
||||
const buildChatPath = (params: ChatParams) =>
|
||||
buildControlUiSessionPath({ namespace: "chat", ...params });
|
||||
|
||||
describe("buildControlUiCatalogSessionUrl", () => {
|
||||
it.each([
|
||||
@@ -58,3 +68,103 @@ describe("buildControlUiCatalogSessionUrl", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("buildControlUiSessionPath", () => {
|
||||
it.each([
|
||||
["scoped main", { sessionKey: "agent:main:main" }, "/chat/main"],
|
||||
["unscoped main", { sessionKey: "main", fallbackAgentId: "research" }, "/chat/research"],
|
||||
[
|
||||
"configured main",
|
||||
{ sessionKey: "agent:research:workspace", mainKey: "workspace" },
|
||||
"/chat/research",
|
||||
],
|
||||
[
|
||||
"default main under a configured key",
|
||||
{ sessionKey: "agent:research:main", mainKey: "workspace" },
|
||||
"/chat/research/main",
|
||||
],
|
||||
["global", { sessionKey: "global", fallbackAgentId: "ops" }, "/chat/ops"],
|
||||
[
|
||||
"literal segments",
|
||||
{ sessionKey: "telegram:group:12345", fallbackAgentId: "research" },
|
||||
"/chat/research/telegram/group/12345",
|
||||
],
|
||||
[
|
||||
"dotted segment",
|
||||
{ sessionKey: "channel:release.js", fallbackAgentId: "research" },
|
||||
"/chat/research/channel/release%2Ejs",
|
||||
],
|
||||
["dot escapes", { sessionKey: "agent:main:cron:.:..:run" }, "/chat/main/cron/~dot/~dotdot/run"],
|
||||
["tilde escape", { sessionKey: "agent:main:channel:~dot" }, "/chat/main/channel/~~dot"],
|
||||
["marker escape", { sessionKey: "agent:main:~key" }, "/chat/main/~~key"],
|
||||
["short-id literal", { sessionKey: "agent:main:12345678" }, "/chat/main/~key/12345678"],
|
||||
[
|
||||
"slug-shaped literal",
|
||||
{ sessionKey: "agent:main:release-deadbeef" },
|
||||
"/chat/main/~key/release-deadbeef",
|
||||
],
|
||||
["UUID", { sessionKey: UUID_KEY }, "/chat/main/12345678"],
|
||||
[
|
||||
"UUID slug",
|
||||
{ sessionKey: UUID_KEY, displayName: "Deploy Monitor" },
|
||||
"/chat/main/deploy-monitor-12345678",
|
||||
],
|
||||
[
|
||||
"reserved short ref",
|
||||
{
|
||||
sessionKey: "agent:main:dashboard:deadbeef-0aaa-4000-8000-000000000001",
|
||||
mainKey: "deadbeef",
|
||||
},
|
||||
"/chat/main/deadbeef0",
|
||||
],
|
||||
] satisfies readonly (readonly [string, ChatParams, string])[])(
|
||||
"builds $0",
|
||||
(_name, params, expected) => {
|
||||
expect(buildChatPath(params)).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves base paths and namespaces", () => {
|
||||
expect(
|
||||
buildControlUiSessionPath({
|
||||
namespace: "dashboard",
|
||||
sessionKey: "agent:ops:telegram:12345",
|
||||
basePath: " /control/// ",
|
||||
}),
|
||||
).toBe("/control/dashboard/ops/telegram/12345");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["OPS_TEAM", "ops_team"],
|
||||
["Research Agent!", "research-agent"],
|
||||
["..", "main"],
|
||||
["Kelvin", "kelvin"],
|
||||
["ſ", "main"],
|
||||
])("normalizes fallback agent %j", (fallbackAgentId, expectedAgentId) => {
|
||||
expect(buildChatPath({ sessionKey: "control-link", fallbackAgentId })).toBe(
|
||||
`/chat/${expectedAgentId}/control-link`,
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes an embedded Unicode long-s through the canonical agent helper", () => {
|
||||
expect(buildChatPath({ sessionKey: "agent:AſB:control-link" })).toBe("/chat/a-b/control-link");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ sessionKey: "", fallbackAgentId: "main" },
|
||||
{ sessionKey: "telegram:12345" },
|
||||
{ sessionKey: "agent:main" },
|
||||
{ sessionKey: "agent::control-link" },
|
||||
{ sessionKey: "agent:main:" },
|
||||
{ sessionKey: "agent:main:telegram::12345" },
|
||||
] satisfies readonly ChatParams[])("rejects invalid input %#", (params) => {
|
||||
expect(buildChatPath(params)).toBeNull();
|
||||
});
|
||||
|
||||
it("removes trailing hex tokens from UUID display slugs", () => {
|
||||
expect(controlUiSessionSlug("Deploy face deadbeef")).toBe("deploy");
|
||||
expect(buildChatPath({ sessionKey: UUID_KEY, displayName: "Deploy face deadbeef" })).toBe(
|
||||
"/chat/main/deploy-12345678",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { normalizeAgentId } from "@openclaw/normalization-core/agent-id";
|
||||
import { normalizeNullableString } from "@openclaw/normalization-core/string-coerce";
|
||||
|
||||
// Control UI session URL grammar shared by browser and plugin consumers.
|
||||
export type ControlUiSessionNamespace = "chat" | "dashboard";
|
||||
|
||||
@@ -24,35 +27,10 @@ export const SESSION_UUID_SUFFIX_RE =
|
||||
/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/iu;
|
||||
export const SHORT_SESSION_ID_RE = /^[0-9a-f]{8,32}$/iu;
|
||||
const SHORT_SESSION_REF_RE = /^(?:.*-)?([0-9a-f]{8,32})$/iu;
|
||||
const VALID_AGENT_ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/iu;
|
||||
const INVALID_AGENT_ID_CHARS_RE = /[^a-z0-9_-]+/giu;
|
||||
const SESSION_SLUG_MAX_LENGTH = 48;
|
||||
const DEFAULT_AGENT_ID = "main";
|
||||
const DEFAULT_MAIN_KEY = "main";
|
||||
const FIXED_RESERVED_SESSION_RESTS = new Set(["main", "global", "boot", "sessions"]);
|
||||
|
||||
function optionalString(value: string | undefined | null): string | null {
|
||||
const trimmed = value?.trim() ?? "";
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
function normalizeAgentId(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return DEFAULT_AGENT_ID;
|
||||
}
|
||||
if (VALID_AGENT_ID_RE.test(trimmed)) {
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
return (
|
||||
trimmed
|
||||
.toLowerCase()
|
||||
.replace(INVALID_AGENT_ID_CHARS_RE, "-")
|
||||
.replace(/^-+|-+$/gu, "")
|
||||
.slice(0, 64) || DEFAULT_AGENT_ID
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeBasePath(basePath: string | undefined): string {
|
||||
const trimmed = basePath?.trim().replace(/^\/+|\/+$/gu, "") ?? "";
|
||||
return trimmed ? `/${trimmed}` : "";
|
||||
@@ -63,7 +41,7 @@ function agentSessionKeyParts(sessionKey: string): { agentId: string; rest: stri
|
||||
if (parts.length < 3 || parts[0]?.toLowerCase() !== "agent") {
|
||||
return null;
|
||||
}
|
||||
const agentId = optionalString(parts[1]);
|
||||
const agentId = normalizeNullableString(parts[1]);
|
||||
const restSegments = parts.slice(2);
|
||||
if (!agentId || restSegments.some((segment) => !segment)) {
|
||||
return null;
|
||||
@@ -89,7 +67,7 @@ function isReservedSessionRest(rest: string, mainKey: string | undefined): boole
|
||||
const normalized = rest.toLowerCase();
|
||||
return (
|
||||
FIXED_RESERVED_SESSION_RESTS.has(normalized) ||
|
||||
normalized === (optionalString(mainKey)?.toLowerCase() ?? DEFAULT_MAIN_KEY)
|
||||
normalized === (normalizeNullableString(mainKey)?.toLowerCase() ?? DEFAULT_MAIN_KEY)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -111,9 +89,9 @@ function controlUiShortIdFromSessionRef(sessionRef: string): string | null {
|
||||
}
|
||||
|
||||
export function buildControlUiSessionPath(params: BuildControlUiSessionPathParams): string | null {
|
||||
const rawKey = optionalString(params.sessionKey);
|
||||
const rawKey = normalizeNullableString(params.sessionKey);
|
||||
const parsed = rawKey ? agentSessionKeyParts(rawKey) : null;
|
||||
const fallbackAgentId = optionalString(params.fallbackAgentId);
|
||||
const fallbackAgentId = normalizeNullableString(params.fallbackAgentId);
|
||||
const agentId = parsed?.agentId ?? (fallbackAgentId ? normalizeAgentId(fallbackAgentId) : null);
|
||||
if (!rawKey || !agentId || (!parsed && rawKey.toLowerCase().startsWith("agent:"))) {
|
||||
return null;
|
||||
@@ -122,7 +100,7 @@ export function buildControlUiSessionPath(params: BuildControlUiSessionPathParam
|
||||
const encodedAgentId = encodePathSegment(agentId);
|
||||
const rest = parsed?.rest ?? rawKey;
|
||||
const normalizedRest = rest.toLowerCase();
|
||||
const mainKey = optionalString(params.mainKey)?.toLowerCase() ?? DEFAULT_MAIN_KEY;
|
||||
const mainKey = normalizeNullableString(params.mainKey)?.toLowerCase() ?? DEFAULT_MAIN_KEY;
|
||||
if (
|
||||
(!parsed && normalizedRest === DEFAULT_MAIN_KEY) ||
|
||||
normalizedRest === mainKey ||
|
||||
@@ -164,9 +142,9 @@ export function buildControlUiSessionPath(params: BuildControlUiSessionPathParam
|
||||
export function buildControlUiCatalogSessionUrl(
|
||||
params: BuildControlUiCatalogSessionUrlParams,
|
||||
): string | null {
|
||||
const catalog = optionalString(params.catalog);
|
||||
const host = optionalString(params.host);
|
||||
const thread = optionalString(params.thread);
|
||||
const catalog = normalizeNullableString(params.catalog);
|
||||
const host = normalizeNullableString(params.host);
|
||||
const thread = normalizeNullableString(params.thread);
|
||||
const path = buildControlUiSessionPath({
|
||||
namespace: params.namespace,
|
||||
sessionKey: DEFAULT_MAIN_KEY,
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildControlUiSessionPath } from "./index.js";
|
||||
import { parseControlUiSessionPath, type ControlUiSessionPathTarget } from "./parse.js";
|
||||
|
||||
type ParseCase = {
|
||||
name: string;
|
||||
pathname: string;
|
||||
expected: ControlUiSessionPathTarget;
|
||||
basePath?: string;
|
||||
};
|
||||
type BuildCase = readonly [
|
||||
Parameters<typeof buildControlUiSessionPath>[0],
|
||||
ControlUiSessionPathTarget,
|
||||
];
|
||||
|
||||
describe("parseControlUiSessionPath", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "main",
|
||||
pathname: "/chat/main",
|
||||
expected: { namespace: "chat", kind: "main", agentId: "main" },
|
||||
},
|
||||
{
|
||||
name: "base path",
|
||||
pathname: "/control/dashboard/OPS-Team",
|
||||
expected: { namespace: "dashboard", kind: "main", agentId: "ops-team" },
|
||||
basePath: "/control",
|
||||
},
|
||||
{
|
||||
name: "short ref",
|
||||
pathname: "/dashboard/main/12345678",
|
||||
expected: { namespace: "dashboard", kind: "short", agentId: "main", shortId: "12345678" },
|
||||
},
|
||||
{
|
||||
name: "slugged short ref",
|
||||
pathname: "/chat/wrong/wrong-slug-1234567890AB",
|
||||
expected: {
|
||||
namespace: "chat",
|
||||
kind: "short",
|
||||
agentId: "wrong",
|
||||
shortId: "1234567890ab",
|
||||
slugHint: "wrong-slug",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "literal",
|
||||
pathname: "/chat/main/not-a-short-id",
|
||||
expected: {
|
||||
namespace: "chat",
|
||||
kind: "literal",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:not-a-short-id",
|
||||
slugCandidate: "not-a-short-id",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multi-segment literal",
|
||||
pathname: "/chat/ops/cron/nightly/run/8821",
|
||||
expected: {
|
||||
namespace: "chat",
|
||||
kind: "literal",
|
||||
agentId: "ops",
|
||||
sessionKey: "agent:ops:cron:nightly:run:8821",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "forced literal",
|
||||
pathname: "/chat/main/~key/release-deadbeef",
|
||||
expected: {
|
||||
namespace: "chat",
|
||||
kind: "literal",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:release-deadbeef",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dot escapes",
|
||||
pathname: "/chat/main/cron/~dot/~dotdot/run",
|
||||
expected: {
|
||||
namespace: "chat",
|
||||
kind: "literal",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:cron:.:..:run",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tilde escape",
|
||||
pathname: "/chat/main/channel/~~dot",
|
||||
expected: {
|
||||
namespace: "chat",
|
||||
kind: "literal",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:channel:~dot",
|
||||
},
|
||||
},
|
||||
] satisfies readonly ParseCase[])("parses $name", ({ pathname, expected, basePath }) => {
|
||||
expect(parseControlUiSessionPath(pathname, basePath)).toEqual(expected);
|
||||
});
|
||||
|
||||
it.each(["main", "global", "boot", "sessions"])("keeps reserved %s literal", (reserved) => {
|
||||
expect(parseControlUiSessionPath(`/chat/main/${reserved}`)).toMatchObject({
|
||||
kind: "literal",
|
||||
sessionKey: `agent:main:${reserved}`,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps configured and default main keys distinct", () => {
|
||||
expect(parseControlUiSessionPath("/chat/research", "", "workspace")).toMatchObject({
|
||||
kind: "main",
|
||||
agentId: "research",
|
||||
});
|
||||
for (const key of ["main", "workspace"]) {
|
||||
expect(parseControlUiSessionPath(`/chat/research/${key}`, "", "workspace")).toMatchObject({
|
||||
kind: "literal",
|
||||
sessionKey: `agent:research:${key}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["%C5%BF", "main"],
|
||||
["%E2%84%AAelvin", "kelvin"],
|
||||
["OPS-Team", "ops-team"],
|
||||
["..%21", "main"],
|
||||
])("normalizes URL agent %s", (encodedAgentId, agentId) => {
|
||||
expect(parseControlUiSessionPath(`/chat/${encodedAgentId}`)).toMatchObject({ agentId });
|
||||
});
|
||||
|
||||
it.each([
|
||||
"/chat/%",
|
||||
"/chat/main/%",
|
||||
"/chat/main/~key/%",
|
||||
"/chat/main/~key",
|
||||
"/chat/main/telegram//12345",
|
||||
"/other/main",
|
||||
])("rejects malformed or unrelated path %s", (pathname) => {
|
||||
expect(parseControlUiSessionPath(pathname)).toBeNull();
|
||||
});
|
||||
|
||||
it("round-trips main, literal, and slugged UUID paths", () => {
|
||||
const cases: readonly BuildCase[] = [
|
||||
[
|
||||
{ namespace: "chat", sessionKey: "agent:research:workspace", mainKey: "workspace" },
|
||||
{ namespace: "chat", kind: "main", agentId: "research" },
|
||||
],
|
||||
[
|
||||
{ namespace: "chat", sessionKey: "agent:main:telegram:group:12345" },
|
||||
{
|
||||
namespace: "chat",
|
||||
kind: "literal",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:telegram:group:12345",
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
namespace: "dashboard",
|
||||
sessionKey: "agent:main:dashboard:12345678-90ab-cdef-1234-567890abcdef",
|
||||
basePath: "/control",
|
||||
displayName: "Deploy Monitor",
|
||||
},
|
||||
{
|
||||
namespace: "dashboard",
|
||||
kind: "short",
|
||||
agentId: "main",
|
||||
shortId: "12345678",
|
||||
slugHint: "deploy-monitor",
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
for (const [params, expected] of cases) {
|
||||
const path = buildControlUiSessionPath(params);
|
||||
expect(parseControlUiSessionPath(path ?? "", params.basePath, params.mainKey)).toEqual(
|
||||
expected,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,6 @@
|
||||
import { normalizeAgentId } from "@openclaw/normalization-core/agent-id";
|
||||
import { normalizeNullableString } from "@openclaw/normalization-core/string-coerce";
|
||||
|
||||
export type ControlUiSessionPathTarget =
|
||||
| { namespace: "chat" | "dashboard"; kind: "main"; agentId: string }
|
||||
| {
|
||||
@@ -21,32 +24,8 @@ export type ControlUiSessionPathTarget =
|
||||
};
|
||||
|
||||
const SHORT_SESSION_REF_RE = /^(?:.*-)?([0-9a-f]{8,32})$/iu;
|
||||
const VALID_AGENT_ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/iu;
|
||||
const INVALID_AGENT_ID_CHARS_RE = /[^a-z0-9_-]+/giu;
|
||||
const FIXED_RESERVED_SESSION_RESTS = new Set(["main", "global", "boot", "sessions"]);
|
||||
|
||||
function optionalString(value: string | undefined | null): string | null {
|
||||
const trimmed = value?.trim() ?? "";
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
function normalizeAgentId(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return "main";
|
||||
}
|
||||
if (VALID_AGENT_ID_RE.test(trimmed)) {
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
return (
|
||||
trimmed
|
||||
.toLowerCase()
|
||||
.replace(INVALID_AGENT_ID_CHARS_RE, "-")
|
||||
.replace(/^-+|-+$/gu, "")
|
||||
.slice(0, 64) || "main"
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeBasePath(basePath: string): string {
|
||||
const trimmed = basePath.trim().replace(/^\/+|\/+$/gu, "");
|
||||
return trimmed ? `/${trimmed}` : "";
|
||||
@@ -79,12 +58,12 @@ function isReservedSessionRest(rest: string, mainKey: string | undefined): boole
|
||||
const normalized = rest.toLowerCase();
|
||||
return (
|
||||
FIXED_RESERVED_SESSION_RESTS.has(normalized) ||
|
||||
normalized === (optionalString(mainKey)?.toLowerCase() ?? "main")
|
||||
normalized === (normalizeNullableString(mainKey)?.toLowerCase() ?? "main")
|
||||
);
|
||||
}
|
||||
|
||||
function literalSessionKey(agentId: string, restSegments: readonly string[]): string | null {
|
||||
const normalizedAgentId = optionalString(agentId);
|
||||
const normalizedAgentId = normalizeNullableString(agentId);
|
||||
if (!normalizedAgentId || restSegments.length === 0 || restSegments.some((segment) => !segment)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Generated
+5
-1
@@ -2334,7 +2334,11 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../normalization-core
|
||||
|
||||
packages/session-url-contract: {}
|
||||
packages/session-url-contract:
|
||||
dependencies:
|
||||
'@openclaw/normalization-core':
|
||||
specifier: workspace:*
|
||||
version: link:../normalization-core
|
||||
|
||||
packages/terminal-core:
|
||||
dependencies:
|
||||
|
||||
@@ -19,105 +19,10 @@ import {
|
||||
workboardBoardIdFromPath,
|
||||
} from "./app-route-paths.ts";
|
||||
import { createApplicationRouter, routeIdFromPath, type RouteId } from "./app-routes.ts";
|
||||
import { pathForSession } from "./app-session-path-builder.ts";
|
||||
import { sessionRefFromPath } from "./app-session-route-paths.ts";
|
||||
import { sessionNavigationTarget } from "./lib/sessions/route-navigation.ts";
|
||||
import { pluginTabKey, pluginTabRefFromSearch, pluginTabSearch } from "./pages/plugin/route.ts";
|
||||
|
||||
type SessionUrlContractCase = {
|
||||
sessionKey: string;
|
||||
agentId: string;
|
||||
mainKey: string | undefined;
|
||||
expectedPath: string | null;
|
||||
};
|
||||
|
||||
// Keep in sync with extensions/clickclack/src/discussions/service.test.ts.
|
||||
// The publishable plugin cannot import this workspace package or its test fixtures.
|
||||
const SESSION_URL_CONTRACT_CASES = [
|
||||
{
|
||||
sessionKey: "agent:main:main",
|
||||
agentId: "main",
|
||||
mainKey: undefined,
|
||||
expectedPath: "/chat/main",
|
||||
},
|
||||
{ sessionKey: "main", agentId: "research", mainKey: undefined, expectedPath: "/chat/research" },
|
||||
{
|
||||
sessionKey: "main",
|
||||
agentId: "research",
|
||||
mainKey: "workspace",
|
||||
expectedPath: "/chat/research",
|
||||
},
|
||||
{ sessionKey: "main", agentId: "..", mainKey: undefined, expectedPath: "/chat/main" },
|
||||
{
|
||||
sessionKey: "agent:research:workspace",
|
||||
agentId: "main",
|
||||
mainKey: "workspace",
|
||||
expectedPath: "/chat/research",
|
||||
},
|
||||
{
|
||||
sessionKey: "agent:research:main",
|
||||
agentId: "main",
|
||||
mainKey: "workspace",
|
||||
expectedPath: "/chat/research/main",
|
||||
},
|
||||
{
|
||||
sessionKey: "telegram:12345",
|
||||
agentId: "research",
|
||||
mainKey: undefined,
|
||||
expectedPath: "/chat/research/telegram/12345",
|
||||
},
|
||||
{
|
||||
// Dots must be percent-escaped or the server treats the URL as a static asset
|
||||
// request and it never reaches the SPA on refresh or via an external link.
|
||||
sessionKey: "channel:release.js",
|
||||
agentId: "research",
|
||||
mainKey: undefined,
|
||||
expectedPath: "/chat/research/channel/release%2Ejs",
|
||||
},
|
||||
{
|
||||
sessionKey: "agent:main:control-link",
|
||||
agentId: "main",
|
||||
mainKey: undefined,
|
||||
expectedPath: "/chat/main/control-link",
|
||||
},
|
||||
{
|
||||
sessionKey: "agent:main:12345678",
|
||||
agentId: "main",
|
||||
mainKey: undefined,
|
||||
expectedPath: "/chat/main/~key/12345678",
|
||||
},
|
||||
{
|
||||
sessionKey: "agent:main:release-deadbeef",
|
||||
agentId: "main",
|
||||
mainKey: undefined,
|
||||
expectedPath: "/chat/main/~key/release-deadbeef",
|
||||
},
|
||||
{
|
||||
sessionKey: "agent:main:telegram:12345",
|
||||
agentId: "main",
|
||||
mainKey: undefined,
|
||||
expectedPath: "/chat/main/telegram/12345",
|
||||
},
|
||||
{
|
||||
sessionKey: "agent:main:dashboard:12345678-90ab-cdef-1234-567890abcdef",
|
||||
agentId: "main",
|
||||
mainKey: undefined,
|
||||
expectedPath: "/chat/main/12345678",
|
||||
},
|
||||
{
|
||||
sessionKey: "agent:main:dashboard:deadbeef-0aaa-4000-8000-000000000001",
|
||||
agentId: "main",
|
||||
mainKey: "deadbeef",
|
||||
expectedPath: "/chat/main/deadbeef0",
|
||||
},
|
||||
{
|
||||
sessionKey: "agent:main:cron:..:run",
|
||||
agentId: "main",
|
||||
mainKey: undefined,
|
||||
expectedPath: "/chat/main/cron/~dotdot/run",
|
||||
},
|
||||
] satisfies readonly SessionUrlContractCase[];
|
||||
|
||||
/**
|
||||
* All route identifiers derived from sidebar nav routes plus routed settings
|
||||
* slices and the Plugins hub tabs, which route without their own sidebar item.
|
||||
@@ -475,181 +380,21 @@ describe("routeIdFromPath", () => {
|
||||
expect(inferBasePathFromPathname("/ui/workboard/ops")).toBe("/ui");
|
||||
});
|
||||
|
||||
it("builds canonical chat and dashboard session paths", () => {
|
||||
const key = "agent:main:dashboard:12345678-90ab-cdef-1234-567890abcdef";
|
||||
expect(
|
||||
pathForSession("chat", "main", key, "", {
|
||||
displayName: "Deploy Monitor",
|
||||
}),
|
||||
).toBe("/chat/main/deploy-monitor-12345678");
|
||||
expect(
|
||||
pathForSession("dashboard", "ops", key, "/ui", {
|
||||
displayName: "",
|
||||
}),
|
||||
).toBe("/ui/dashboard/main/12345678");
|
||||
expect(pathForSession("chat", "main", "agent:main:main")).toBe("/chat/main");
|
||||
expect(pathForSession("chat", "main", "agent:main:telegram:12345")).toBe(
|
||||
"/chat/main/telegram/12345",
|
||||
);
|
||||
expect(pathForSession("chat", "main", "dashboard:12345678-90ab-cdef-1234-567890abcdef")).toBe(
|
||||
"/chat/main/dashboard/12345678-90ab-cdef-1234-567890abcdef",
|
||||
);
|
||||
});
|
||||
|
||||
it("requires an explicit agent fallback for unscoped session keys", () => {
|
||||
it("round-trips session navigation through the lazy contract seam", () => {
|
||||
const pathname = sessionNavigationTarget({
|
||||
face: "chat",
|
||||
sessionKey: "telegram:12345",
|
||||
fallbackAgentId: "research",
|
||||
basePath: "/ui",
|
||||
}).options.pathname;
|
||||
expect(pathname).toBe("/chat/research/telegram/12345");
|
||||
expect(sessionRefFromPath(pathname)).toMatchObject({
|
||||
|
||||
expect(pathname).toBe("/ui/chat/research/telegram/12345");
|
||||
expect(sessionRefFromPath(pathname, "/ui")).toMatchObject({
|
||||
kind: "literal",
|
||||
sessionKey: "agent:research:telegram:12345",
|
||||
});
|
||||
});
|
||||
|
||||
it("matches the publishable ClickClack session URL vectors", () => {
|
||||
for (const testCase of SESSION_URL_CONTRACT_CASES) {
|
||||
expect(
|
||||
pathForSession("chat", testCase.agentId, testCase.sessionKey, "", {
|
||||
mainKey: testCase.mainKey,
|
||||
}),
|
||||
).toBe(testCase.expectedPath);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps scoped main distinct from a configured custom main key", () => {
|
||||
expect(sessionRefFromPath("/chat/research", "", "workspace")).toEqual({
|
||||
namespace: "chat",
|
||||
kind: "main",
|
||||
agentId: "research",
|
||||
});
|
||||
expect(sessionRefFromPath("/chat/research/main", "", "workspace")).toEqual({
|
||||
namespace: "chat",
|
||||
kind: "literal",
|
||||
agentId: "research",
|
||||
sessionKey: "agent:research:main",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps trailing hex tokens out of decorative slugs", () => {
|
||||
expect(
|
||||
pathForSession(
|
||||
"chat",
|
||||
"main",
|
||||
"agent:main:dashboard:12345678-90ab-cdef-1234-567890abcdef",
|
||||
"",
|
||||
{
|
||||
displayName: "Deploy face deadbeef",
|
||||
},
|
||||
),
|
||||
).toBe("/chat/main/deploy-12345678");
|
||||
});
|
||||
|
||||
it("parses short refs and literal key segments in both namespaces", () => {
|
||||
expect(sessionRefFromPath("/chat/main")).toEqual({
|
||||
namespace: "chat",
|
||||
kind: "main",
|
||||
agentId: "main",
|
||||
});
|
||||
expect(sessionRefFromPath("/dashboard/main/12345678")).toEqual({
|
||||
namespace: "dashboard",
|
||||
kind: "short",
|
||||
agentId: "main",
|
||||
shortId: "12345678",
|
||||
});
|
||||
// The slug is captured so it can settle a tie between ids sharing this prefix, but it
|
||||
// never changes the parsed id: a wrong agent and a wrong slug both stay decorative.
|
||||
expect(sessionRefFromPath("/chat/wrong/wrong-slug-1234567890ab")).toEqual({
|
||||
namespace: "chat",
|
||||
kind: "short",
|
||||
agentId: "wrong",
|
||||
shortId: "1234567890ab",
|
||||
slugHint: "wrong-slug",
|
||||
});
|
||||
expect(sessionRefFromPath("/chat/main/telegram/12345")).toEqual({
|
||||
namespace: "chat",
|
||||
kind: "literal",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:telegram:12345",
|
||||
});
|
||||
expect(sessionRefFromPath("/chat/ops/cron/nightly/run/8821")).toEqual({
|
||||
namespace: "chat",
|
||||
kind: "literal",
|
||||
agentId: "ops",
|
||||
sessionKey: "agent:ops:cron:nightly:run:8821",
|
||||
});
|
||||
for (const reserved of ["main", "global", "boot", "sessions"]) {
|
||||
expect(sessionRefFromPath(`/chat/main/${reserved}`)).toMatchObject({
|
||||
kind: "literal",
|
||||
sessionKey: `agent:main:${reserved}`,
|
||||
});
|
||||
}
|
||||
expect(sessionRefFromPath("/chat/main/workspace", "", "workspace")).toMatchObject({
|
||||
kind: "literal",
|
||||
sessionKey: "agent:main:workspace",
|
||||
});
|
||||
expect(sessionRefFromPath("/chat/main/not-a-short-id")).toMatchObject({
|
||||
kind: "literal",
|
||||
sessionKey: "agent:main:not-a-short-id",
|
||||
});
|
||||
expect(pathForSession("chat", "main", "agent:main:not-reserved")).toBe(
|
||||
"/chat/main/not-reserved",
|
||||
);
|
||||
expect(pathForSession("chat", "main", "agent:main:12345678")).toBe("/chat/main/~key/12345678");
|
||||
expect(sessionRefFromPath("/chat/main/~key/12345678")).toMatchObject({
|
||||
kind: "literal",
|
||||
sessionKey: "agent:main:12345678",
|
||||
});
|
||||
expect(pathForSession("chat", "main", "agent:main:release-deadbeef")).toBe(
|
||||
"/chat/main/~key/release-deadbeef",
|
||||
);
|
||||
expect(sessionRefFromPath("/chat/main/~key/release-deadbeef")).toMatchObject({
|
||||
kind: "literal",
|
||||
sessionKey: "agent:main:release-deadbeef",
|
||||
});
|
||||
const collidingMainKey = "deadbeef";
|
||||
const collisionPath = pathForSession(
|
||||
"chat",
|
||||
"main",
|
||||
"agent:main:dashboard:deadbeef-0aaa-4000-8000-000000000001",
|
||||
"",
|
||||
{ mainKey: collidingMainKey },
|
||||
);
|
||||
expect(collisionPath).toBe("/chat/main/deadbeef0");
|
||||
expect(sessionRefFromPath(collisionPath ?? "", "", collidingMainKey)).toMatchObject({
|
||||
kind: "short",
|
||||
shortId: "deadbeef0",
|
||||
});
|
||||
expect(
|
||||
pathForSession("chat", "main", "agent:main:workspace", "", { mainKey: "workspace" }),
|
||||
).toBe("/chat/main");
|
||||
expect(sessionRefFromPath("/chat/main/deadbeef/child")).toMatchObject({
|
||||
kind: "literal",
|
||||
sessionKey: "agent:main:deadbeef:child",
|
||||
});
|
||||
expect(pathForSession("chat", "main", "agent:main:cron:..:run")).toBe(
|
||||
"/chat/main/cron/~dotdot/run",
|
||||
);
|
||||
expect(sessionRefFromPath("/chat/main/cron/~dotdot/run")).toMatchObject({
|
||||
kind: "literal",
|
||||
sessionKey: "agent:main:cron:..:run",
|
||||
});
|
||||
expect(pathForSession("chat", "main", "agent:main:channel:~dot")).toBe(
|
||||
"/chat/main/channel/~~dot",
|
||||
);
|
||||
expect(sessionRefFromPath("/chat/main/channel/~~dot")).toMatchObject({
|
||||
kind: "literal",
|
||||
sessionKey: "agent:main:channel:~dot",
|
||||
});
|
||||
expect(pathForSession("chat", "main", "agent:main:~key")).toBe("/chat/main/~~key");
|
||||
expect(sessionRefFromPath("/chat/main/~~key")).toMatchObject({
|
||||
kind: "literal",
|
||||
sessionKey: "agent:main:~key",
|
||||
});
|
||||
expect(routeIdFromPath("/dashboard/main/deploy-12345678")).toBe("dashboard");
|
||||
expect(inferBasePathFromPathname("/ui/chat/main/deploy-12345678")).toBe("/ui");
|
||||
expect(routeIdFromPath(pathname, "/ui")).toBe("chat");
|
||||
expect(inferBasePathFromPathname(pathname)).toBe("/ui");
|
||||
});
|
||||
|
||||
it("keeps dotted board IDs from resembling static asset paths", () => {
|
||||
|
||||
@@ -350,6 +350,12 @@ describe("Control UI Vite config", () => {
|
||||
|
||||
it("resolves Control UI dev-server source aliases for internal packages", () => {
|
||||
const aliases = resolveSourcePackageAliasesForVite();
|
||||
expect(
|
||||
aliases.find((alias) => alias.find === "@openclaw/normalization-core/agent-id"),
|
||||
)?.toEqual({
|
||||
find: "@openclaw/normalization-core/agent-id",
|
||||
replacement: path.join(repoRoot, "packages/normalization-core/src/agent-id.ts"),
|
||||
});
|
||||
expect(
|
||||
aliases.find((alias) => alias.find === "@openclaw/normalization-core/json-schema"),
|
||||
)?.toEqual({
|
||||
|
||||
@@ -310,6 +310,7 @@ function sourcePackageAlias(packageId: string, subpath?: string): ControlUiViteA
|
||||
|
||||
export function resolveSourcePackageAliasesForVite(): ControlUiViteAlias[] {
|
||||
return [
|
||||
sourcePackageAlias("normalization-core", "agent-id"),
|
||||
sourcePackageAlias("normalization-core", "json-schema"),
|
||||
sourcePackageAlias("normalization-core", "number-coercion"),
|
||||
sourcePackageAlias("normalization-core", "phone-presentation"),
|
||||
|
||||
Reference in New Issue
Block a user