From 053b89d80f6bcea0fcc2f422ea5ad37a72eaa81b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 24 Aug 2026 09:33:20 -0700 Subject: [PATCH] improve(ui): open short session links without extra lookup (#128778) * perf(ui): remove short session route waterfall * docs: clarify short link gateway requirement --- .../OpenClawProtocol/GatewayModels.swift | 26 +++++ docs/concepts/session-attachment.md | 13 +-- docs/web/urls.md | 4 - .../gateway-protocol/src/public-schema.ts | 2 + .../gateway-protocol/src/schema-modules.ts | 1 + .../protocol-schema-fragment-sessions-core.ts | 9 +- .../src/schema/sessions-resolve.test.ts | 76 +++++++++++++ .../src/schema/sessions-resolve.ts | 18 ++++ .../gateway-protocol/src/schema/sessions.ts | 1 - scripts/check-protocol-registry.mts | 4 +- .../server-methods/sessions-read.test.ts | 54 ++++++++++ src/gateway/server-methods/sessions-read.ts | 2 +- src/gateway/sessions-resolve.test.ts | 49 +++++++-- src/gateway/sessions-resolve.ts | 8 +- ui/config/control-ui-boot-modules.json | 3 +- ui/src/e2e/dashboard-fullscreen.e2e.test.ts | 50 ++++++--- .../lazy-custom-element-recovery.e2e.test.ts | 8 +- .../chat/route-loader-short-list-fallback.ts | 78 -------------- .../pages/chat/route-loader-short-resolve.ts | 68 +++--------- ui/src/pages/chat/route-loader.ts | 7 +- ui/src/pages/chat/route-resolution.test.ts | 101 ++++++++++-------- ui/src/pages/chat/route.test.ts | 39 ++++--- 22 files changed, 376 insertions(+), 245 deletions(-) create mode 100644 packages/gateway-protocol/src/schema/sessions-resolve.test.ts delete mode 100644 ui/src/pages/chat/route-loader-short-list-fallback.ts diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index c4a88a6f892e..92800077b768 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -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? diff --git a/docs/concepts/session-attachment.md b/docs/concepts/session-attachment.md index b5e2a5d1b482..ec84e5c8b4a0 100644 --- a/docs/concepts/session-attachment.md +++ b/docs/concepts/session-attachment.md @@ -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 diff --git a/docs/web/urls.md b/docs/web/urls.md index 52c2a911eb7d..0d57f16f6956 100644 --- a/docs/web/urls.md +++ b/docs/web/urls.md @@ -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). diff --git a/packages/gateway-protocol/src/public-schema.ts b/packages/gateway-protocol/src/public-schema.ts index b28152aec8d7..bc85e99aaa5c 100644 --- a/packages/gateway-protocol/src/public-schema.ts +++ b/packages/gateway-protocol/src/public-schema.ts @@ -178,7 +178,9 @@ export { SessionsCleanupParamsSchema, SessionsPreviewParamsSchema, SessionsDescribeParamsSchema, + SessionsResolveCandidateSchema, SessionsResolveParamsSchema, + SessionsResolveResultSchema, SessionFileBrowserEntrySchema, SessionFileBrowserResultSchema, SessionFileContentEncodingSchema, diff --git a/packages/gateway-protocol/src/schema-modules.ts b/packages/gateway-protocol/src/schema-modules.ts index 22c64cacf404..7e922eea252d 100644 --- a/packages/gateway-protocol/src/schema-modules.ts +++ b/packages/gateway-protocol/src/schema-modules.ts @@ -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"; diff --git a/packages/gateway-protocol/src/schema/protocol-schema-fragment-sessions-core.ts b/packages/gateway-protocol/src/schema/protocol-schema-fragment-sessions-core.ts index b55f81907929..5fa121b961f9 100644 --- a/packages/gateway-protocol/src/schema/protocol-schema-fragment-sessions-core.ts +++ b/packages/gateway-protocol/src/schema/protocol-schema-fragment-sessions-core.ts @@ -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, diff --git a/packages/gateway-protocol/src/schema/sessions-resolve.test.ts b/packages/gateway-protocol/src/schema/sessions-resolve.test.ts new file mode 100644 index 000000000000..6ccb227de6f1 --- /dev/null +++ b/packages/gateway-protocol/src/schema/sessions-resolve.test.ts @@ -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().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + 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); + }); +}); diff --git a/packages/gateway-protocol/src/schema/sessions-resolve.ts b/packages/gateway-protocol/src/schema/sessions-resolve.ts index 12ff7e83f5b1..74729e3ae088 100644 --- a/packages/gateway-protocol/src/schema/sessions-resolve.ts +++ b/packages/gateway-protocol/src/schema/sessions-resolve.ts @@ -21,3 +21,21 @@ export const SessionsResolveParamsSchema = closedObject({ }); export type SessionsResolveParams = Static; + +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; +export type SessionsResolveResult = Static; diff --git a/packages/gateway-protocol/src/schema/sessions.ts b/packages/gateway-protocol/src/schema/sessions.ts index 128eb7f3d9be..ffdf9c221891 100644 --- a/packages/gateway-protocol/src/schema/sessions.ts +++ b/packages/gateway-protocol/src/schema/sessions.ts @@ -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, diff --git a/scripts/check-protocol-registry.mts b/scripts/check-protocol-registry.mts index 587d12333153..f21d3f9d06e0 100644 --- a/scripts/check-protocol-registry.mts +++ b/scripts/check-protocol-registry.mts @@ -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, diff --git a/src/gateway/server-methods/sessions-read.test.ts b/src/gateway/server-methods/sessions-read.test.ts index f8934853e3af..1320d7b38141 100644 --- a/src/gateway/server-methods/sessions-read.test.ts +++ b/src/gateway/server-methods/sessions-read.test.ts @@ -238,6 +238,60 @@ async function configureFixedSessionStore(label = "default"): Promise { 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, diff --git a/src/gateway/server-methods/sessions-read.ts b/src/gateway/server-methods/sessions-read.ts index 9b13ea796f7d..8cb2ab099e7a 100644 --- a/src/gateway/server-methods/sessions-read.ts +++ b/src/gateway/server-methods/sessions-read.ts @@ -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 { diff --git a/src/gateway/sessions-resolve.test.ts b/src/gateway/sessions-resolve.test.ts index 4306cce349e4..fddab301bf4a 100644 --- a/src/gateway/sessions-resolve.test.ts +++ b/src/gateway/sessions-resolve.test.ts @@ -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; + }), }); }); diff --git a/src/gateway/sessions-resolve.ts b/src/gateway/sessions-resolve.ts index 249d54556e6e..de63367c7cd4 100644 --- a/src/gateway/sessions-resolve.ts +++ b/src/gateway/sessions-resolve.ts @@ -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); diff --git a/ui/config/control-ui-boot-modules.json b/ui/config/control-ui-boot-modules.json index c73892f32f2c..3e8b277b73fe 100644 --- a/ui/config/control-ui-boot-modules.json +++ b/ui/config/control-ui-boot-modules.json @@ -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", diff --git a/ui/src/e2e/dashboard-fullscreen.e2e.test.ts b/ui/src/e2e/dashboard-fullscreen.e2e.test.ts index 36f5bcc66d9e..c0f1d0aedeb8 100644 --- a/ui/src/e2e/dashboard-fullscreen.e2e.test.ts +++ b/ui/src/e2e/dashboard-fullscreen.e2e.test.ts @@ -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" }, }, diff --git a/ui/src/e2e/lazy-custom-element-recovery.e2e.test.ts b/ui/src/e2e/lazy-custom-element-recovery.e2e.test.ts index 02885d4e5db6..d09a4393c3f5 100644 --- a/ui/src/e2e/lazy-custom-element-recovery.e2e.test.ts +++ b/ui/src/e2e/lazy-custom-element-recovery.e2e.test.ts @@ -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, diff --git a/ui/src/pages/chat/route-loader-short-list-fallback.ts b/ui/src/pages/chat/route-loader-short-list-fallback.ts deleted file mode 100644 index 109cabf994d2..000000000000 --- a/ui/src/pages/chat/route-loader-short-list-fallback.ts +++ /dev/null @@ -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, - signal: AbortSignal, -): Promise { - const matches = new Map(); - 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; - } -} diff --git a/ui/src/pages/chat/route-loader-short-resolve.ts b/ui/src/pages/chat/route-loader-short-resolve.ts index bfe179a37f0f..6d9b537f1903 100644 --- a/ui/src/pages/chat/route-loader-short-resolve.ts +++ b/ui/src/pages/chat/route-loader-short-resolve.ts @@ -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; -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 { const client = await waitForGatewayClient(context.gateway, signal); signal.throwIfAborted(); - let result: SessionsResolveWireResult; - try { - result = await client.request("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("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" }; } diff --git a/ui/src/pages/chat/route-loader.ts b/ui/src/pages/chat/route-loader.ts index 43218a1584c7..dabab89bf3a4 100644 --- a/ui/src/pages/chat/route-loader.ts +++ b/ui/src/pages/chat/route-loader.ts @@ -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 | null { @@ -465,7 +466,7 @@ function resolvedMainSessionRouteData(params: { context: ApplicationContext; location: RouteLocation; face: BoardFace; - row: GatewaySessionRow; + row: SessionRoutePresentation; target: Extract; preferenceDerived: boolean; }): Extract | null { diff --git a/ui/src/pages/chat/route-resolution.test.ts b/ui/src/pages/chat/route-resolution.test.ts index 42a2d57b8c11..60e184ad814a 100644 --- a/ui/src/pages/chat/route-resolution.test.ts +++ b/ui/src/pages/chat/route-resolution.test.ts @@ -77,12 +77,23 @@ function installShortResolver( ? { ok: true, key: rows[0].key } : { ok: false }, ) { - const request = vi.fn(async (method: string, params: Record) => { + const request = vi.fn(async (method: string, _params: Record) => { 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 () => { diff --git a/ui/src/pages/chat/route.test.ts b/ui/src/pages/chat/route.test.ts index d1bc74163256..faefff9d91e0 100644 --- a/ui/src/pages/chat/route.test.ts +++ b/ui/src/pages/chat/route.test.ts @@ -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 () => {