improve(ui): open short session links without extra lookup (#128778)

* perf(ui): remove short session route waterfall

* docs: clarify short link gateway requirement
This commit is contained in:
Peter Steinberger
2026-08-24 09:33:20 -07:00
committed by GitHub
parent 59a9221180
commit 053b89d80f
22 changed files with 376 additions and 245 deletions
@@ -5791,6 +5791,32 @@ public struct SessionsDescribeParams: Codable, Sendable {
}
}
public struct SessionsResolveCandidate: Codable, Sendable {
public let key: String
public let agentid: String
public let displayname: String?
public let boardface: AnyCodable?
public init(
key: String,
agentid: String,
displayname: String? = nil,
boardface: AnyCodable? = nil)
{
self.key = key
self.agentid = agentid
self.displayname = displayname
self.boardface = boardface
}
private enum CodingKeys: String, CodingKey {
case key
case agentid = "agentId"
case displayname = "displayName"
case boardface = "boardFace"
}
}
public struct SessionsResolveParams: Codable, Sendable {
public let key: String?
public let sessionid: String?
+5 -8
View File
@@ -72,15 +72,12 @@ ten recent candidates, so clients can ask you for a longer prefix without
guessing. See [Control UI URLs](/web/urls) for the complete literal encoding and
stability contract.
### Current and older Gateways
### Gateway version requirement
Current Gateways resolve short references at the session store owner. The
Control UI and CLI then use the returned canonical key.
An older Gateway may reject the additive `shortId` selector. The Control UI can
fall back to its older bounded list search, scanning at most five pages. The CLI
does not recreate that paging policy: it tells you to copy the full session key
from that Gateway's Control UI or upgrade the Gateway.
The Gateway resolves short references at the session store owner, and the
Control UI and CLI use the returned canonical key. Short links require a current
Gateway. If an older or custom Gateway rejects the `shortId` selector, upgrade
it or use a full session key.
## Choose how to continue
-4
View File
@@ -105,10 +105,6 @@ 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. Current Gateways return at most ten recent candidates; when that
bound is reached, the view treats the result as incomplete instead of guessing.
Against an older Gateway that predates short-id resolve support, the UI falls
back to the prior bounded list search, scanning at most five pages of results.
It likewise reports an incomplete search instead of guessing when that fallback
cannot prove uniqueness.
To continue one of these links in the terminal or attach a coding harness, see
[Session synchronization and attachment](/concepts/session-attachment).
@@ -178,7 +178,9 @@ export {
SessionsCleanupParamsSchema,
SessionsPreviewParamsSchema,
SessionsDescribeParamsSchema,
SessionsResolveCandidateSchema,
SessionsResolveParamsSchema,
SessionsResolveResultSchema,
SessionFileBrowserEntrySchema,
SessionFileBrowserResultSchema,
SessionFileContentEncodingSchema,
@@ -36,6 +36,7 @@ export * from "./schema/secrets.js";
export * from "./schema/session-placement.js";
export * from "./schema/session-discussion.js";
export * from "./schema/sessions.js";
export * from "./schema/sessions-resolve.js";
export * from "./schema/session-github-publication.js";
export * from "./schema/sessions-viewer-presence.js";
export * from "./schema/sessions-sharing.js";
@@ -1,4 +1,9 @@
import * as sessionsCatalog from "./sessions-catalog.js";
import {
SessionsResolveCandidateSchema,
SessionsResolveParamsSchema,
SessionsResolveResultSchema,
} from "./sessions-resolve.js";
import * as sessions from "./sessions.js";
export const SessionCoreProtocolSchemas = {
@@ -24,7 +29,9 @@ export const SessionCoreProtocolSchemas = {
SessionsCleanupParams: sessions.SessionsCleanupParamsSchema,
SessionsPreviewParams: sessions.SessionsPreviewParamsSchema,
SessionsDescribeParams: sessions.SessionsDescribeParamsSchema,
SessionsResolveParams: sessions.SessionsResolveParamsSchema,
SessionsResolveCandidate: SessionsResolveCandidateSchema,
SessionsResolveParams: SessionsResolveParamsSchema,
SessionsResolveResult: SessionsResolveResultSchema,
SessionsSearchHit: sessions.SessionsSearchHitSchema,
SessionsSearchParams: sessions.SessionsSearchParamsSchema,
SessionsSearchResult: sessions.SessionsSearchResultSchema,
@@ -0,0 +1,76 @@
import { Value } from "typebox/value";
import { describe, expect, expectTypeOf, it } from "vitest";
import {
SessionsResolveCandidateSchema as PublicSessionsResolveCandidateSchema,
SessionsResolveParamsSchema as PublicSessionsResolveParamsSchema,
SessionsResolveResultSchema as PublicSessionsResolveResultSchema,
type SessionsResolveCandidate as PublicSessionsResolveCandidate,
type SessionsResolveParams as PublicSessionsResolveParams,
type SessionsResolveResult as PublicSessionsResolveResult,
} from "../index.js";
import type * as PublicSchema from "../schema.js";
import { ProtocolSchemas } from "./protocol-schemas.js";
import {
SessionsResolveCandidateSchema,
SessionsResolveParamsSchema,
SessionsResolveResultSchema,
type SessionsResolveCandidate,
type SessionsResolveParams,
type SessionsResolveResult,
} from "./sessions-resolve.js";
describe("sessions.resolve presentation contract", () => {
const candidate = {
key: "agent:main:thread:12345678-90ab-4000-8000-000000000001",
agentId: "main",
displayName: "Deploy monitor",
boardFace: "dashboard",
} as const;
it("preserves owner-backed public exports, types, and protocol registrations", () => {
expect(PublicSessionsResolveCandidateSchema).toBe(SessionsResolveCandidateSchema);
expect(PublicSessionsResolveParamsSchema).toBe(SessionsResolveParamsSchema);
expect(PublicSessionsResolveResultSchema).toBe(SessionsResolveResultSchema);
expect(ProtocolSchemas.SessionsResolveCandidate).toBe(SessionsResolveCandidateSchema);
expect(ProtocolSchemas.SessionsResolveParams).toBe(SessionsResolveParamsSchema);
expect(ProtocolSchemas.SessionsResolveResult).toBe(SessionsResolveResultSchema);
expectTypeOf<PublicSessionsResolveCandidate>().toEqualTypeOf<SessionsResolveCandidate>();
expectTypeOf<PublicSessionsResolveParams>().toEqualTypeOf<SessionsResolveParams>();
expectTypeOf<PublicSessionsResolveResult>().toEqualTypeOf<SessionsResolveResult>();
expectTypeOf<PublicSchema.SessionsResolveResult>().toEqualTypeOf<SessionsResolveResult>();
});
it("accepts optional bounded presentation facts on unique and ambiguous results", () => {
expect(Value.Check(SessionsResolveCandidateSchema, candidate)).toBe(true);
expect(Value.Check(SessionsResolveResultSchema, { ok: true, ...candidate })).toBe(true);
expect(
Value.Check(SessionsResolveResultSchema, {
ok: true,
key: candidate.key,
agentId: candidate.agentId,
}),
).toBe(true);
expect(Value.Check(SessionsResolveResultSchema, { ok: false })).toBe(true);
expect(
Value.Check(SessionsResolveResultSchema, {
ok: false,
candidates: Array.from({ length: 10 }, () => candidate),
}),
).toBe(true);
});
it("rejects invalid faces, unexpected facts, and more than ten candidates", () => {
expect(Value.Check(SessionsResolveCandidateSchema, { ...candidate, boardFace: "grid" })).toBe(
false,
);
expect(Value.Check(SessionsResolveCandidateSchema, { ...candidate, sessionId: "opaque" })).toBe(
false,
);
expect(
Value.Check(SessionsResolveResultSchema, {
ok: false,
candidates: Array.from({ length: 11 }, () => candidate),
}),
).toBe(false);
});
});
@@ -21,3 +21,21 @@ export const SessionsResolveParamsSchema = closedObject({
});
export type SessionsResolveParams = Static<typeof SessionsResolveParamsSchema>;
export const SessionsResolveCandidateSchema = closedObject({
key: NonEmptyString,
agentId: NonEmptyString,
displayName: Type.Optional(Type.String()),
boardFace: Type.Optional(Type.Union([Type.Literal("chat"), Type.Literal("dashboard")])),
});
export const SessionsResolveResultSchema = Type.Union([
closedObject({ ok: Type.Literal(true), ...SessionsResolveCandidateSchema.properties }),
closedObject({
ok: Type.Literal(false),
candidates: Type.Optional(Type.Array(SessionsResolveCandidateSchema, { maxItems: 10 })),
}),
]);
export type SessionsResolveCandidate = Static<typeof SessionsResolveCandidateSchema>;
export type SessionsResolveResult = Static<typeof SessionsResolveResultSchema>;
@@ -23,7 +23,6 @@ export {
type SessionsDeleteResult,
type WorktreePreservationReason,
} from "./sessions-delete.js";
export { SessionsResolveParamsSchema, type SessionsResolveParams } from "./sessions-resolve.js";
export {
SESSIONS_PATCH_MANY_MAX_TARGETS,
SessionsPatchManyParamsSchema,
+2 -2
View File
@@ -113,8 +113,8 @@ const ownerModules = [
...schemaModulesSource.matchAll(/^export \* from "\.\/schema\/([^"]+)\.js";$/gmu),
].map(([, moduleName = ""]) => moduleName);
check(
ownerModules.length === 59 && new Set(ownerModules).size === ownerModules.length,
"schema-modules.ts must contain one unique 59-module owner list",
ownerModules.length === 60 && new Set(ownerModules).size === ownerModules.length,
"schema-modules.ts must contain one unique 60-module owner list",
);
check(
schemaModulesSource.split("\n").filter(Boolean).length === ownerModules.length,
@@ -238,6 +238,60 @@ async function configureFixedSessionStore(label = "default"): Promise<string> {
return storePath;
}
test("sessions.resolve preserves presentation facts on unique and ambiguous wire results", async () => {
const firstKey = "agent:main:thread:12345678-0aaa-4000-8000-000000000001";
const secondKey = "agent:main:thread:12345678-0bbb-4000-8000-000000000002";
const storePath = resolveStorePath(undefined, { agentId: "main" });
await replaceSessionEntry(
{ agentId: "main", sessionKey: firstKey, storePath },
{
sessionId: "first-session",
updatedAt: 2,
displayName: "Deploy monitor",
boardFace: "dashboard",
},
);
const unique = await directSessionReq("sessions.resolve", {
shortId: "12345678",
agentId: "main",
});
expect(unique).toMatchObject({
ok: true,
payload: {
ok: true,
key: firstKey,
agentId: "main",
displayName: "Deploy monitor",
boardFace: "dashboard",
},
});
await replaceSessionEntry(
{ agentId: "main", sessionKey: secondKey, storePath },
{
sessionId: "second-session",
updatedAt: 1,
displayName: "Release monitor",
boardFace: "chat",
},
);
const ambiguous = await directSessionReq("sessions.resolve", {
shortId: "12345678",
agentId: "main",
});
expect(ambiguous).toMatchObject({
ok: true,
payload: {
ok: false,
candidates: [
{ key: firstKey, agentId: "main", displayName: "Deploy monitor", boardFace: "dashboard" },
{ key: secondKey, agentId: "main", displayName: "Release monitor", boardFace: "chat" },
],
},
});
});
test("unknown-agent session reads return missing results without provisioning an agent", async () => {
const described = await directSessionReq<{ session: unknown }>("sessions.describe", {
key: UNKNOWN_SESSION_KEY,
+1 -1
View File
@@ -667,7 +667,7 @@ export const sessionReadHandlers: GatewayRequestHandlers = {
respond(true, { ok: false, candidates: resolved.candidates }, undefined);
return;
}
respond(true, { ok: true, key: resolved.key, agentId: resolved.agentId }, undefined);
respond(true, resolved, undefined);
},
"sessions.get": async ({ params, respond, context }) => {
const p = params as {
+39 -10
View File
@@ -281,6 +281,7 @@ describe("resolveSessionKeyFromResolveParams", () => {
updatedAt: 10,
archivedAt: 20,
displayName: "Release monitor",
boardFace: "dashboard",
},
},
});
@@ -290,7 +291,13 @@ describe("resolveSessionKeyFromResolveParams", () => {
cfg: {},
p: { shortId: "ABCDEF12", agentId: "main" },
}),
).resolves.toEqual({ ok: true, key, agentId: "main" });
).resolves.toEqual({
ok: true,
key,
agentId: "main",
displayName: "Release monitor",
boardFace: "dashboard",
});
});
it("uses a display-name slug only to narrow a short-id tie", async () => {
@@ -300,7 +307,7 @@ describe("resolveSessionKeyFromResolveParams", () => {
storePath,
store: {
[releaseKey]: { updatedAt: 2, displayName: "Release monitor" },
[deployKey]: { updatedAt: 1, displayName: "Deploy monitor" },
[deployKey]: { updatedAt: 1, displayName: "Deploy monitor", boardFace: "chat" },
},
});
@@ -309,7 +316,13 @@ describe("resolveSessionKeyFromResolveParams", () => {
cfg: {},
p: { shortId: "12345678", slugHint: "deploy-monitor" },
}),
).resolves.toEqual({ ok: true, key: deployKey, agentId: "main" });
).resolves.toEqual({
ok: true,
key: deployKey,
agentId: "main",
displayName: "Deploy monitor",
boardFace: "chat",
});
});
it("ignores a deleted-agent short-id collision before resolving a unique match", async () => {
@@ -328,7 +341,12 @@ describe("resolveSessionKeyFromResolveParams", () => {
cfg: {},
p: { shortId: "12345678", slugHint: "deleted-session" },
}),
).resolves.toEqual({ ok: true, key: survivingKey, agentId: "main" });
).resolves.toEqual({
ok: true,
key: survivingKey,
agentId: "main",
displayName: "Surviving session",
});
});
it("reports a deleted-agent-only short-id match as missing", async () => {
@@ -358,7 +376,11 @@ describe("resolveSessionKeyFromResolveParams", () => {
const suffix = index.toString(16).padStart(4, "0");
return [
`agent:main:thread:12345678-${suffix}-4000-8000-000000000000`,
{ updatedAt: 100 - index, displayName: `Candidate ${index}` },
{
updatedAt: 100 - index,
displayName: `Candidate ${index}`,
...(index % 2 === 0 ? { boardFace: "dashboard" as const } : {}),
},
];
}),
);
@@ -373,11 +395,18 @@ describe("resolveSessionKeyFromResolveParams", () => {
).resolves.toEqual({
ok: true,
ambiguous: true,
candidates: expectedKeys.map((key, index) => ({
key,
agentId: "main",
displayName: `Candidate ${index}`,
})),
candidates: expectedKeys.map((key, index) => {
const candidate: {
key: string;
agentId: string;
displayName: string;
boardFace?: "dashboard";
} = { key, agentId: "main", displayName: `Candidate ${index}` };
if (index % 2 === 0) {
candidate.boardFace = "dashboard";
}
return candidate;
}),
});
});
+4 -4
View File
@@ -6,6 +6,7 @@ import {
ErrorCodes,
type ErrorShape,
errorShape,
type SessionsResolveCandidate,
type SessionsResolveParams,
} from "../../packages/gateway-protocol/src/index.js";
import {
@@ -32,10 +33,8 @@ import {
resolveGatewaySessionStoreTargetWithStore,
} from "./session-utils.js";
type SessionsResolveCandidate = { key: string; agentId: string; displayName?: string };
export type SessionsResolveResult =
| { ok: true; key: string; agentId: string }
| ({ ok: true } & SessionsResolveCandidate)
| { ok: true; missing: true }
| { ok: true; ambiguous: true; candidates: SessionsResolveCandidate[] }
| { ok: false; error: ErrorShape };
@@ -161,6 +160,7 @@ function findVisibleShortIdMatches(params: {
"short-id session agent",
),
...(row.displayName ? { displayName: row.displayName } : {}),
...(row.boardFace ? { boardFace: row.boardFace } : {}),
},
];
});
@@ -383,7 +383,7 @@ export async function resolveSessionKeyFromResolveParams(params: {
return { ok: true, ambiguous: true, candidates: narrowed.slice(0, 10) };
}
const selected = expectDefined(narrowed[0], "short session match at 0");
return { ok: true, key: selected.key, agentId: selected.agentId };
return { ok: true, ...selected };
}
const parsedLabel = parseSessionLabel(p.label);
+2 -1
View File
@@ -1579,6 +1579,8 @@
"ui/src/lib/identity-avatar.ts",
"ui/src/lib/idle-import.ts",
"ui/src/lib/json5-runtime.ts",
"ui/src/lib/keyboard-shortcut-catalog.ts",
"ui/src/lib/keyboard-shortcut-contract.ts",
"ui/src/lib/keyboard-shortcuts.ts",
"ui/src/lib/media-file-extension.ts",
"ui/src/lib/model-auth.ts",
@@ -1888,7 +1890,6 @@
"ui/src/pages/chat/route-draft-focus-handoff.ts",
"ui/src/pages/chat/route-draft.ts",
"ui/src/pages/chat/route-loader-short-cache.ts",
"ui/src/pages/chat/route-loader-short-list-fallback.ts",
"ui/src/pages/chat/route-loader-short-resolve.ts",
"ui/src/pages/chat/route-loader.ts",
"ui/src/pages/chat/route.ts",
+33 -17
View File
@@ -108,9 +108,7 @@ suite.define(() => {
featureCapabilities: [GATEWAY_SERVER_CAPS.BOARD_WIDGET_PUT_CANVAS_DOC],
featureMethods: ["board.get", "board.update", "board.widget.grant", "board.widget.put"],
methodResponses: {
"sessions.describe": {
session: sessionRow,
},
"sessions.describe": { session: sessionRow },
"board.get": boardSnapshot,
"board.widget.grant": {
...boardSnapshot,
@@ -125,10 +123,21 @@ suite.define(() => {
await page.goto(`${suite.server.baseUrl}${initialFocusPath}`);
await gateway.waitForRequest("sessions.resolve");
expect(await gateway.getRequests("board.get")).toHaveLength(0);
await gateway.resolveDeferred("sessions.resolve", { ok: true, key: sessionKey });
expect(await gateway.getRequests("sessions.describe")).toHaveLength(0);
const initialSessionListCount = (await gateway.getRequests("sessions.list")).length;
await gateway.resolveDeferred("sessions.resolve", {
ok: true,
key: sessionKey,
agentId: "main",
displayName: sessionRow.displayName,
boardFace: sessionRow.boardFace,
});
const document = page.locator("openclaw-board-document");
await document.locator("openclaw-board-view").waitFor();
expect(await gateway.getRequests("sessions.resolve")).toHaveLength(1);
expect(await gateway.getRequests("sessions.describe")).toHaveLength(1);
expect(await gateway.getRequests("sessions.list")).toHaveLength(initialSessionListCount);
expect(await page.locator("openclaw-app-shell").count()).toBe(0);
expect(await page.locator(".agent-chat").count()).toBe(0);
expect((await gateway.getRequests("board.get"))[0]?.params).toEqual({ sessionKey });
@@ -226,17 +235,18 @@ suite.define(() => {
methodResponses: {
"sessions.resolve": {
ok: false,
candidates: [{ key: sessionKey }, { key: secondKey }],
},
"sessions.describe": {
sequence: [
{ session: sessionRow },
candidates: [
{
session: {
...sessionRow,
key: secondKey,
displayName: "Deploy monitor beta",
},
key: sessionKey,
agentId: "main",
displayName: sessionRow.displayName,
boardFace: sessionRow.boardFace,
},
{
key: secondKey,
agentId: "main",
displayName: "Deploy monitor beta",
boardFace: sessionRow.boardFace,
},
],
},
@@ -251,6 +261,8 @@ suite.define(() => {
/^\/focus\/dashboard\/main\//u,
);
}
expect(await gateway.getRequests("sessions.resolve")).toHaveLength(1);
expect(await gateway.getRequests("sessions.describe")).toHaveLength(0);
expect(await gateway.getRequests("board.get")).toHaveLength(0);
expect(await page.locator("openclaw-board-document").count()).toBe(0);
await closeFocusedView(page, "Close dashboard");
@@ -298,10 +310,14 @@ suite.define(() => {
sessionKey,
featureMethods: ["board.get"],
methodResponses: {
"sessions.resolve": { ok: true, key: sessionKey },
"sessions.describe": {
session: sessionRow,
"sessions.resolve": {
ok: true,
key: sessionKey,
agentId: "main",
displayName: sessionRow.displayName,
boardFace: sessionRow.boardFace,
},
"sessions.describe": { session: sessionRow },
"board.get": {
__mockError: { code: "UNAVAILABLE", message: "dashboard storage is unavailable" },
},
@@ -121,7 +121,13 @@ const focusedCases = [
sessionKey,
featureMethods: [...defaultControlUiFeatureMethods, "board.get"],
methodResponses: {
"sessions.resolve": { ok: true, key: sessionKey },
"sessions.resolve": {
ok: true,
key: sessionKey,
agentId: "main",
boardFace: "dashboard",
displayName: "Lazy dashboard",
},
"sessions.describe": {
session: {
key: sessionKey,
@@ -1,78 +0,0 @@
import { controlUiSessionSlug } from "@openclaw/session-url-contract";
import type { GatewaySessionRow } from "../../api/types.ts";
import type { SessionPathTarget } from "../../app-session-route-paths.ts";
import type { SessionRouteContext as ApplicationContext } from "./route-loader-context.ts";
import { sessionKeyUuid } from "./route-loader-short-cache.ts";
const SESSION_REF_SEARCH_LIMIT = 20;
const SESSION_REF_SEARCH_MAX_PAGES = 5;
export type ShortSessionListFallbackResolution =
| { kind: "not-found" }
| { kind: "unique"; session: GatewaySessionRow }
| { kind: "ambiguous"; sessions: GatewaySessionRow[]; truncated: boolean };
function narrowBySlugHint(
resolution: ShortSessionListFallbackResolution,
slugHint: string | undefined,
): ShortSessionListFallbackResolution {
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;
}
// Prior-release v4 gateways reject unknown sessions.resolve params.
// Keep this list-based resolver until v4 closed-schema gateways without shortId
// support fall out of support.
export async function resolveShortSessionReferenceWithListFallback(
context: ApplicationContext,
target: Extract<SessionPathTarget, { kind: "short" }>,
signal: AbortSignal,
): Promise<ShortSessionListFallbackResolution> {
const matches = new Map<string, GatewaySessionRow>();
const shortId = target.shortId.toLowerCase().replaceAll("-", "");
let offset = 0;
for (let page = 0; ; page += 1) {
signal.throwIfAborted();
const result = await context.sessions.list({
agentId: target.agentId,
archivedFilter: "all",
includeDerivedTitles: true,
limit: SESSION_REF_SEARCH_LIMIT,
search: shortId.slice(0, 8),
...(offset > 0 ? { offset } : {}),
});
signal.throwIfAborted();
if (!result) {
throw new Error("Session list unavailable while resolving URL.");
}
for (const session of result.sessions) {
if (sessionKeyUuid(session.key)?.startsWith(shortId)) {
matches.set(session.key, session);
}
}
const sessions = [...matches.values()];
if (sessions.length > 1) {
return narrowBySlugHint(
{ kind: "ambiguous", sessions, truncated: result.hasMore === true },
target.slugHint,
);
}
if (result.hasMore !== true) {
const session = sessions[0];
return session ? { kind: "unique", session } : { kind: "not-found" };
}
if (page === SESSION_REF_SEARCH_MAX_PAGES - 1) {
return { kind: "ambiguous", sessions, truncated: true };
}
const nextOffset = result.nextOffset ?? offset + result.sessions.length;
if (nextOffset <= offset) {
return { kind: "ambiguous", sessions, truncated: true };
}
offset = nextOffset;
}
}
+16 -52
View File
@@ -1,28 +1,14 @@
import { ErrorCodes } from "@openclaw/gateway-client/browser";
import { GatewayRequestError } from "../../api/gateway.ts";
import type { SessionsResolveResult } from "../../../../packages/gateway-protocol/src/index.js";
import type { GatewaySessionRow } from "../../api/types.ts";
import type { SessionPathTarget } from "../../app-session-route-paths.ts";
import { waitForGatewayClient } from "../../app/gateway-readiness.ts";
import type { SessionRouteContext as ApplicationContext } from "./route-loader-context.ts";
import {
resolveShortSessionReferenceWithListFallback,
type ShortSessionListFallbackResolution,
} from "./route-loader-short-list-fallback.ts";
export type SessionRoutePresentation = Pick<GatewaySessionRow, "key" | "displayName" | "boardFace">;
export type SessionReferenceResolution = ShortSessionListFallbackResolution;
type SessionsResolveWireResult =
| { ok: true; key: string }
| { ok: false; candidates?: Array<{ key: string; displayName?: string }> };
function isPriorGatewayShortIdRejection(error: unknown): boolean {
return (
error instanceof GatewayRequestError &&
error.gatewayCode === ErrorCodes.INVALID_REQUEST &&
error.message.includes("invalid sessions.resolve params:") &&
error.message.includes("unexpected property 'shortId'")
);
}
export type SessionReferenceResolution =
| { kind: "not-found" }
| { kind: "unique"; session: SessionRoutePresentation }
| { kind: "ambiguous"; sessions: SessionRoutePresentation[]; truncated: boolean };
export async function resolveShortSessionReference(
context: ApplicationContext,
@@ -31,39 +17,17 @@ export async function resolveShortSessionReference(
): Promise<SessionReferenceResolution> {
const client = await waitForGatewayClient(context.gateway, signal);
signal.throwIfAborted();
let result: SessionsResolveWireResult;
try {
result = await client.request<SessionsResolveWireResult>("sessions.resolve", {
shortId: target.shortId,
...(target.slugHint ? { slugHint: target.slugHint } : {}),
agentId: target.agentId,
allowMissing: true,
});
} catch (error) {
if (!isPriorGatewayShortIdRejection(error)) {
throw error;
}
return resolveShortSessionReferenceWithListFallback(context, target, signal);
}
signal.throwIfAborted();
const candidates = result.ok ? [{ key: result.key }] : result.candidates;
if (!candidates?.length) {
return { kind: "not-found" };
}
const rows = (
await Promise.all(
candidates.map(async ({ key }) => {
const described = await client.request<{ session?: GatewaySessionRow | null }>(
"sessions.describe",
{ key },
);
return described.session ?? null;
}),
)
).filter((row): row is GatewaySessionRow => row !== null);
const result = await client.request<SessionsResolveResult>("sessions.resolve", {
shortId: target.shortId,
...(target.slugHint ? { slugHint: target.slugHint } : {}),
agentId: target.agentId,
allowMissing: true,
});
signal.throwIfAborted();
if (result.ok) {
return rows[0] ? { kind: "unique", session: rows[0] } : { kind: "not-found" };
return { kind: "unique", session: result };
}
return { kind: "ambiguous", sessions: rows, truncated: candidates.length === 10 };
return result.candidates?.length
? { kind: "ambiguous", sessions: result.candidates, truncated: result.candidates.length === 10 }
: { kind: "not-found" };
}
+4 -3
View File
@@ -34,6 +34,7 @@ import { findCachedShortSession, sessionKeyUuid } from "./route-loader-short-cac
import {
resolveShortSessionReference,
type SessionReferenceResolution,
type SessionRoutePresentation,
} from "./route-loader-short-resolve.ts";
const SESSION_REF_SEARCH_LIMIT = 20;
@@ -341,7 +342,7 @@ function canonicalSessionLocation(params: {
context: ApplicationContext;
location: RouteLocation;
face: BoardFace;
row: GatewaySessionRow;
row: SessionRoutePresentation;
shortIdLength?: number;
}): RouteLocation | null | undefined {
const face = params.face;
@@ -433,7 +434,7 @@ function resolvedSessionRouteData(params: {
context: ApplicationContext;
location: RouteLocation;
face: BoardFace;
row: GatewaySessionRow;
row: SessionRoutePresentation;
preferenceDerived: boolean;
shortId?: string;
}): Extract<ChatRouteData, { kind: "session" }> | null {
@@ -465,7 +466,7 @@ function resolvedMainSessionRouteData(params: {
context: ApplicationContext;
location: RouteLocation;
face: BoardFace;
row: GatewaySessionRow;
row: SessionRoutePresentation;
target: Extract<SessionPathTarget, { kind: "main" }>;
preferenceDerived: boolean;
}): Extract<ChatRouteData, { kind: "session" }> | null {
+54 -47
View File
@@ -77,12 +77,23 @@ function installShortResolver(
? { ok: true, key: rows[0].key }
: { ok: false },
) {
const request = vi.fn(async (method: string, params: Record<string, unknown>) => {
const request = vi.fn(async (method: string, _params: Record<string, unknown>) => {
if (method === "sessions.resolve") {
return resolved;
}
if (method === "sessions.describe") {
return { session: rows.find((candidate) => candidate.key === params.key) ?? null };
const present = ({ key }: { key: string }) => {
const session = rows.find((candidate) => candidate.key === key);
return {
key,
agentId: session?.agentId ?? key.split(":")[1],
...(session?.displayName ? { displayName: session.displayName } : {}),
...(session?.boardFace ? { boardFace: session.boardFace } : {}),
};
};
return resolved.ok
? { ok: true, ...present(resolved) }
: {
ok: false,
...(resolved.candidates ? { candidates: resolved.candidates.map(present) } : {}),
};
}
throw new Error(`Unexpected gateway request: ${method}`);
});
@@ -472,44 +483,10 @@ describe("gateway-backed session route resolution", () => {
agentId: "roboclaw",
allowMissing: true,
});
expect(request).toHaveBeenNthCalledWith(2, "sessions.describe", { key: rows[1]?.key });
});
it("falls back to the prior list resolver when an older gateway rejects shortId", async () => {
const storedRow = row({
key: "agent:roboclaw:thread:12345678-0aaa-4000-8000-000000000001",
displayName: "Deploy monitor",
});
const { context, list } = contextFor(({ search }) =>
search === "12345678" ? result([storedRow]) : result([]),
);
const request = vi.fn(async () => {
throw new GatewayRequestError({
code: "INVALID_REQUEST",
message: "invalid sessions.resolve params: at root: unexpected property 'shortId'",
});
});
(context.gateway.snapshot.client as unknown as { request: typeof request }).request = request;
const loaded = await loadChatRoute(
context,
{ pathname: "/chat/roboclaw/deploy-monitor-123456780a", search: "", hash: "" },
"chat",
new AbortController().signal,
);
expect(loaded).toMatchObject({ kind: "session", sessionKey: storedRow.key });
expect(request).toHaveBeenCalledOnce();
expect(list).toHaveBeenCalledWith({
agentId: "roboclaw",
archivedFilter: "all",
includeDerivedTitles: true,
limit: 20,
search: "12345678",
});
});
it("does not invoke the list fallback when the gateway resolver succeeds", async () => {
it("resolves a cold short route with one gateway request and no session search", async () => {
const storedRow = row({ displayName: "Deploy monitor" });
const { context, list } = contextFor(() => result([storedRow]));
const request = installShortResolver(context, [storedRow]);
@@ -522,15 +499,16 @@ describe("gateway-backed session route resolution", () => {
);
expect(loaded).toMatchObject({ kind: "session", sessionKey: storedRow.key });
expect(request).toHaveBeenCalledTimes(2);
expect(request).toHaveBeenCalledOnce();
expect(request).not.toHaveBeenCalledWith("sessions.describe", expect.anything());
expect(list).not.toHaveBeenCalled();
});
it("does not mask unrelated sessions.resolve validation errors", async () => {
it("fails visibly when the gateway rejects authoritative short-id resolution", async () => {
const { context, list } = contextFor(() => result([]));
const rejection = new GatewayRequestError({
code: "INVALID_REQUEST",
message: "invalid sessions.resolve params: shortId must be hexadecimal",
message: "invalid sessions.resolve params: at root: unexpected property 'shortId'",
});
const request = vi.fn(async () => {
throw rejection;
@@ -548,6 +526,35 @@ describe("gateway-backed session route resolution", () => {
expect(list).not.toHaveBeenCalled();
});
it("rejects a short-route result after navigation ownership is aborted", async () => {
const storedRow = row({ displayName: "Deploy monitor" });
const { context, list } = contextFor(() => result([storedRow]));
let finishResolution:
| ((result: { ok: true; key: string; agentId: string }) => void)
| undefined;
const request = vi.fn(
async () =>
await new Promise<{ ok: true; key: string; agentId: string }>((resolve) => {
finishResolution = resolve;
}),
);
(context.gateway.snapshot.client as unknown as { request: typeof request }).request = request;
const controller = new AbortController();
const navigation = loadChatRoute(
context,
{ pathname: "/chat/roboclaw/deploy-monitor-12345678", search: "", hash: "" },
"chat",
controller.signal,
);
await vi.waitFor(() => expect(request).toHaveBeenCalledOnce());
const reason = new Error("navigation superseded");
controller.abort(reason);
finishResolution?.({ ok: true, key: storedRow.key, agentId: "roboclaw" });
await expect(navigation).rejects.toBe(reason);
expect(list).not.toHaveBeenCalled();
});
it("uses the sidebar-carried full key without issuing a session search", async () => {
const storedRow = row({ displayName: "Deploy monitor" });
const { context, list } = contextFor(() => result([storedRow]));
@@ -616,7 +623,7 @@ describe("gateway-backed session route resolution", () => {
expect(loaded).toMatchObject({ kind: "session", sessionKey: currentSession.key });
expect(list).not.toHaveBeenCalled();
expect(request).toHaveBeenCalledTimes(2);
expect(request).toHaveBeenCalledOnce();
});
it("prefers the current location key over a residual colliding handoff", async () => {
@@ -678,7 +685,7 @@ describe("gateway-backed session route resolution", () => {
expect(loaded).toMatchObject({ kind: "session", sessionKey: expected.key });
expect(list).not.toHaveBeenCalled();
expect(request).toHaveBeenCalledTimes(2);
expect(request).toHaveBeenCalledOnce();
});
it("keeps a cold cached short route on the authoritative resolution path", async () => {
@@ -695,7 +702,7 @@ describe("gateway-backed session route resolution", () => {
expect(loaded).toMatchObject({ kind: "session", sessionKey: storedRow.key });
expect(list).not.toHaveBeenCalled();
expect(request).toHaveBeenCalledTimes(2);
expect(request).toHaveBeenCalledOnce();
});
it("keeps the gateway ambiguity check when cached rows share the uuid and slug", async () => {
@@ -722,7 +729,7 @@ describe("gateway-backed session route resolution", () => {
expect(loaded).toMatchObject({ kind: "ambiguous", shortId: "12345678" });
expect(list).not.toHaveBeenCalled();
expect(request).toHaveBeenCalledTimes(3);
expect(request).toHaveBeenCalledOnce();
});
it("keeps the chooser when the slug matches neither or both tied sessions", async () => {
+24 -15
View File
@@ -41,19 +41,27 @@ function contextFor(listResult: SessionsListResult | null, mainKey = "main") {
session.key.toLowerCase().replaceAll("-", "").includes(shortId),
) ?? [];
return matches.length === 1 && matches[0]
? { ok: true, key: matches[0].key }
? {
ok: true,
key: matches[0].key,
agentId: matches[0].agentId ?? matches[0].key.split(":")[1],
displayName: matches[0].displayName,
boardFace: matches[0].boardFace,
}
: {
ok: false,
...(matches.length > 1
? { candidates: matches.map((session) => ({ key: session.key })) }
? {
candidates: matches.map((session) => ({
key: session.key,
agentId: session.agentId ?? session.key.split(":")[1],
displayName: session.displayName,
boardFace: session.boardFace,
})),
}
: {}),
};
}
if (method === "sessions.describe") {
return {
session: listResult?.sessions.find((session) => session.key === params.key) ?? null,
};
}
throw new Error(`Unexpected gateway request: ${method}`);
});
const client = { request };
@@ -86,16 +94,17 @@ describe("loadChatRoute", () => {
it("survives sessionId rotation and canonicalizes decorative short-form segments", async () => {
const { context, list, request } = contextFor(result([row()]));
let describeCount = 0;
request.mockImplementation(async (method) => {
if (method === "sessions.resolve") {
return { ok: true, key: sessionKey };
return {
ok: true,
key: sessionKey,
agentId: "main",
displayName: "Deploy Monitor",
boardFace: undefined,
};
}
return {
session: row({
sessionId: describeCount++ === 0 ? "before-compaction" : "after-compaction",
}),
};
throw new Error(`Unexpected gateway request: ${method}`);
});
const signal = new AbortController().signal;
const redirected = await loadChatRoute(
@@ -130,7 +139,7 @@ describe("loadChatRoute", () => {
),
).resolves.toEqual({ kind: "session", sessionKey, draft: "ship", face: "chat" });
expect(list).not.toHaveBeenCalled();
expect(request).toHaveBeenCalledTimes(4);
expect(request).toHaveBeenCalledTimes(2);
});
it("round-trips literal channel, peer, and cron keys without searching", async () => {