From e40e352fe7fe0d1354bc62ad95a129d2bde6c59e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 9 Aug 2026 02:56:07 -0700 Subject: [PATCH] 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 --- extensions/clickclack/README.md | 4 +- .../src/discussions/service.test.ts | 120 ++------ .../session-url-contract/npm-shrinkwrap.json | 12 - packages/session-url-contract/package.json | 3 + .../session-url-contract/src/index.test.ts | 112 +++++++- packages/session-url-contract/src/index.ts | 44 +-- .../session-url-contract/src/parse.test.ts | 179 ++++++++++++ packages/session-url-contract/src/parse.ts | 31 +- pnpm-lock.yaml | 6 +- ui/src/app-navigation.test.ts | 269 +----------------- ui/src/app/vite-config.node.test.ts | 6 + ui/vite.config.ts | 1 + 12 files changed, 347 insertions(+), 440 deletions(-) delete mode 100644 packages/session-url-contract/npm-shrinkwrap.json create mode 100644 packages/session-url-contract/src/parse.test.ts diff --git a/extensions/clickclack/README.md b/extensions/clickclack/README.md index f0cfce1a0dab..65ed65b343e8 100644 --- a/extensions/clickclack/README.md +++ b/extensions/clickclack/README.md @@ -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=` in the -OpenClaw Control UI. +`controlUrlBase` adds canonical `/chat//` links to the +OpenClaw Control UI, preserving base paths. Main sessions use `/chat/`. ClickClack-managed embed URLs explicitly advertise host-theme support. The Control UI uses that provider-owned capability to apply its full palette before diff --git a/extensions/clickclack/src/discussions/service.test.ts b/extensions/clickclack/src/discussions/service.test.ts index d102defdef3a..078b5beb058e 100644 --- a/extensions/clickclack/src/discussions/service.test.ts +++ b/extensions/clickclack/src/discussions/service.test.ts @@ -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[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 () => { diff --git a/packages/session-url-contract/npm-shrinkwrap.json b/packages/session-url-contract/npm-shrinkwrap.json deleted file mode 100644 index ba12fdf56ff1..000000000000 --- a/packages/session-url-contract/npm-shrinkwrap.json +++ /dev/null @@ -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" - } - } -} diff --git a/packages/session-url-contract/package.json b/packages/session-url-contract/package.json index bf1af2bffd86..72c9d8dff12f 100644 --- a/packages/session-url-contract/package.json +++ b/packages/session-url-contract/package.json @@ -6,5 +6,8 @@ "exports": { ".": "./src/index.ts", "./parse": "./src/parse.ts" + }, + "dependencies": { + "@openclaw/normalization-core": "workspace:*" } } diff --git a/packages/session-url-contract/src/index.test.ts b/packages/session-url-contract/src/index.test.ts index 525bbbd84013..543c662017e9 100644 --- a/packages/session-url-contract/src/index.test.ts +++ b/packages/session-url-contract/src/index.test.ts @@ -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[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", + ); + }); +}); diff --git a/packages/session-url-contract/src/index.ts b/packages/session-url-contract/src/index.ts index c5b927649389..e8cab47e92f7 100644 --- a/packages/session-url-contract/src/index.ts +++ b/packages/session-url-contract/src/index.ts @@ -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, diff --git a/packages/session-url-contract/src/parse.test.ts b/packages/session-url-contract/src/parse.test.ts new file mode 100644 index 000000000000..5df320326d11 --- /dev/null +++ b/packages/session-url-contract/src/parse.test.ts @@ -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[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, + ); + } + }); +}); diff --git a/packages/session-url-contract/src/parse.ts b/packages/session-url-contract/src/parse.ts index b1cfe6ade39e..2c04b93322e1 100644 --- a/packages/session-url-contract/src/parse.ts +++ b/packages/session-url-contract/src/parse.ts @@ -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; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 07c25c06c0f8..6ea909a71847 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/ui/src/app-navigation.test.ts b/ui/src/app-navigation.test.ts index f1a0cd6704e0..f245137f064b 100644 --- a/ui/src/app-navigation.test.ts +++ b/ui/src/app-navigation.test.ts @@ -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", () => { diff --git a/ui/src/app/vite-config.node.test.ts b/ui/src/app/vite-config.node.test.ts index 5af1ea77b95c..53bf7ba252ff 100644 --- a/ui/src/app/vite-config.node.test.ts +++ b/ui/src/app/vite-config.node.test.ts @@ -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({ diff --git a/ui/vite.config.ts b/ui/vite.config.ts index bb020937ec51..9d1576b49d53 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -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"),