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:
Peter Steinberger
2026-08-09 02:56:07 -07:00
committed by GitHub
parent 0df1a89e3a
commit e40e352fe7
12 changed files with 347 additions and 440 deletions
+2 -2
View File
@@ -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
View File
@@ -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:*"
}
}
+111 -1
View File
@@ -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"],
["elvin", "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",
);
});
});
+11 -33
View File
@@ -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,
);
}
});
});
+5 -26
View File
@@ -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;
}
+5 -1
View File
@@ -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:
+7 -262
View File
@@ -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", () => {
+6
View File
@@ -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({
+1
View File
@@ -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"),