diff --git a/docs/web/urls.md b/docs/web/urls.md index 06bbebc5976f..7bad70df9080 100644 --- a/docs/web/urls.md +++ b/docs/web/urls.md @@ -77,10 +77,15 @@ The following parts are stable URL contracts: - The key UUID short id in short-id URLs. - The arity and short-versus-literal parsing rules above. -In short-id form, the agent segment and slug are explicitly decorative. They may -change without notice and are not used to identify or validate the session. -After resolution, the Control UI replaces the address bar with the current -agent id and current display-name slug without adding a browser-history entry. +In short-id form, the agent segment is decorative and the slug is almost +decorative. Neither identifies the session on its own, and both may change +without notice. The one exception is a tie: if the short id matches more than +one session and exactly one of them still carries the slug in the link, that +session is used, so a generated link keeps working even when two ids happen to +share a prefix. A slug that matches none or several of the tied sessions is +ignored and the disambiguation view is shown. After resolution, the Control UI +replaces the address bar with the current agent id and current display-name slug +without adding a browser-history entry. In literal-key form, the agent segment is authoritative because it is part of the reconstructed session key. The remaining literal segments are authoritative @@ -95,9 +100,10 @@ the slug, the UI shows the same disambiguation view used for short-id ties instead of guessing. Exact short-id and literal-key references always win over slug matching. -If one short id matches more than one session, the UI does not guess. It shows a -small disambiguation view with the matching display names, agents, and longer id -prefixes. Use a longer prefix to make the URL unique. Resolution examines at +If one short id matches more than one session and the slug does not settle it, +the UI does not guess. It shows a small disambiguation view with the matching +display names, agents, and longer id prefixes. Use a longer prefix to make the +URL unique. Resolution examines at most five pages of search results; if more remain, the view says that the search was incomplete instead of guessing. diff --git a/packages/session-url-contract/src/parse.ts b/packages/session-url-contract/src/parse.ts index 8e1ee74c28cf..b1cfe6ade39e 100644 --- a/packages/session-url-contract/src/parse.ts +++ b/packages/session-url-contract/src/parse.ts @@ -5,6 +5,12 @@ export type ControlUiSessionPathTarget = kind: "short"; agentId: string; shortId: string; + /** + * Display-name slug that preceded the id, when the reference carried one. The id + * stays authoritative; this only breaks a tie between sessions whose ids share the + * given prefix, so a short link keeps resolving to one session. + */ + slugHint?: string; } | { namespace: "chat" | "dashboard"; @@ -126,9 +132,13 @@ export function parseControlUiSessionPath( return { namespace, kind: "literal", agentId, sessionKey }; } const shortId = segment.match(SHORT_SESSION_REF_RE)?.[1]?.toLowerCase(); - return shortId - ? { namespace, kind: "short", agentId, shortId } - : { namespace, kind: "literal", agentId, sessionKey, slugCandidate: segment }; + if (!shortId) { + return { namespace, kind: "literal", agentId, sessionKey, slugCandidate: segment }; + } + const slugHint = segment.slice(0, segment.length - shortId.length).replace(/-+$/u, ""); + return slugHint + ? { namespace, kind: "short", agentId, shortId, slugHint } + : { namespace, kind: "short", agentId, shortId }; } return null; } diff --git a/ui/src/app-navigation.test.ts b/ui/src/app-navigation.test.ts index 6465f457d232..dd0c2e355114 100644 --- a/ui/src/app-navigation.test.ts +++ b/ui/src/app-navigation.test.ts @@ -540,11 +540,14 @@ describe("routeIdFromPath", () => { 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", diff --git a/ui/src/pages/chat/route-loader.ts b/ui/src/pages/chat/route-loader.ts index 4f49642411a1..a01a517e3c47 100644 --- a/ui/src/pages/chat/route-loader.ts +++ b/ui/src/pages/chat/route-loader.ts @@ -192,6 +192,28 @@ function sessionReferenceMatches( return result.sessions.filter((row) => sessionKeyUuid(row.key)?.startsWith(prefix) === true); } +// Two sessions can share a short id's prefix, which would send an otherwise exact link to +// the disambiguation view. When the link also carries a display-name slug, that slug says +// which one was meant, so it settles the tie and keeps generated links durable at their +// normal length. It can only narrow: a hint that matches nothing (a stale or hand-edited +// name) leaves the original candidates for the chooser rather than dropping the session. +// +// A truncated set is not a tie, it is an unfinished search. Another page could hold the +// same prefix under the same slug, so settling here would be the guess the bounded search +// exists to avoid. +function narrowBySlugHint( + resolution: SessionReferenceResolution, + slugHint: string | undefined, +): SessionReferenceResolution { + if (resolution.kind !== "ambiguous" || resolution.truncated || !slugHint) { + return resolution; + } + const matched = resolution.sessions.filter( + (row) => controlUiSessionSlug(row.displayName) === slugHint, + ); + return matched.length === 1 && matched[0] ? { kind: "unique", session: matched[0] } : resolution; +} + function incompleteSessionReferenceResolution( search: SessionReferenceSearch, sessions: GatewaySessionRow[], @@ -685,12 +707,15 @@ export async function loadChatRoute( ...(canonicalLocationReady ? { canonicalLocationReady } : {}), }; } - const resolution = requireSessionReferenceResolution( - await querySessionReference( - context, - { kind: "short", value: target.shortId, agentId: target.agentId }, - signal, + const resolution = narrowBySlugHint( + requireSessionReferenceResolution( + await querySessionReference( + context, + { kind: "short", value: target.shortId, agentId: target.agentId }, + signal, + ), ), + target.slugHint, ); if (resolution.kind === "not-found") { return notFound({ routeId: face }); diff --git a/ui/src/pages/chat/route-resolution.test.ts b/ui/src/pages/chat/route-resolution.test.ts index c711ee090aa1..898e2201c767 100644 --- a/ui/src/pages/chat/route-resolution.test.ts +++ b/ui/src/pages/chat/route-resolution.test.ts @@ -390,6 +390,70 @@ describe("gateway-backed session route resolution", () => { ]); }); + it("settles a shared short-id prefix with the slug the link carries", async () => { + const rows = [ + row({ key: "agent:roboclaw:thread:12345678-0aaa-4000-8000-000000000001" }), + row({ + key: "agent:roboclaw:thread:12345678-0bbb-4000-8000-000000000002", + displayName: "Deploy monitor", + }), + ]; + const { context } = contextFor(() => result(rows)); + const loaded = await loadChatRoute( + context, + { pathname: "/chat/roboclaw/deploy-monitor-12345678", search: "", hash: "" }, + "chat", + new AbortController().signal, + ); + + // Both ids start with 12345678; the slug says which one, so the short link still + // resolves instead of bouncing to the chooser. + expect(loaded).toMatchObject({ kind: "session", sessionKey: rows[1]?.key }); + }); + + it("keeps the chooser when the slug matches neither or both tied sessions", async () => { + const rows = [ + row({ key: "agent:roboclaw:thread:12345678-0aaa-4000-8000-000000000001" }), + row({ key: "agent:roboclaw:thread:12345678-0bbb-4000-8000-000000000002" }), + ]; + const { context } = contextFor(() => result(rows)); + for (const pathname of [ + // Stale slug: the session was renamed since the link was made. + "/chat/roboclaw/an-old-name-12345678", + // Both tied sessions share the slug, so it cannot decide. + "/chat/roboclaw/default-mode-with-rare-surprises-12345678", + ]) { + const loaded = await loadChatRoute( + context, + { pathname, search: "", hash: "" }, + "chat", + new AbortController().signal, + ); + + expect(loaded).toMatchObject({ kind: "ambiguous", shortId: "12345678" }); + } + }); + + it("does not settle a slug tie while the bounded search is incomplete", async () => { + // Only one loaded row carries the slug, but pagination stopped early: an unexamined + // page could hold the same prefix under the same name, so the chooser has to stand. + const storedRow = row({ + key: "agent:roboclaw:thread:12345678-0aaa-4000-8000-000000000001", + displayName: "Deploy monitor", + }); + const { context } = contextFor(({ offset = 0 }) => + result(offset === 0 ? [storedRow] : [], { hasMore: true, nextOffset: offset + 20, offset }), + ); + const loaded = await loadChatRoute( + context, + { pathname: "/chat/roboclaw/deploy-monitor-12345678", search: "", hash: "" }, + "chat", + new AbortController().signal, + ); + + expect(loaded).toMatchObject({ kind: "ambiguous", shortId: "12345678", truncated: true }); + }); + it("prefers an exact literal key over slug matches", async () => { const literal = row({ key: "agent:roboclaw:default-mode-with-rare-surprises",