diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 7b38765883cb..119359689b6f 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -4690,6 +4690,8 @@ public struct SessionsResolveParams: Codable, Sendable { public let key: String? public let sessionid: String? public let label: String? + public let shortid: String? + public let slughint: String? public let agentid: String? public let spawnedby: String? public let includeglobal: Bool? @@ -4700,6 +4702,8 @@ public struct SessionsResolveParams: Codable, Sendable { key: String? = nil, sessionid: String? = nil, label: String? = nil, + shortid: String? = nil, + slughint: String? = nil, agentid: String? = nil, spawnedby: String? = nil, includeglobal: Bool? = nil, @@ -4709,6 +4713,8 @@ public struct SessionsResolveParams: Codable, Sendable { self.key = key self.sessionid = sessionid self.label = label + self.shortid = shortid + self.slughint = slughint self.agentid = agentid self.spawnedby = spawnedby self.includeglobal = includeglobal @@ -4720,6 +4726,8 @@ public struct SessionsResolveParams: Codable, Sendable { case key case sessionid = "sessionId" case label + case shortid = "shortId" + case slughint = "slugHint" case agentid = "agentId" case spawnedby = "spawnedBy" case includeglobal = "includeGlobal" diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index b52f8a71d8e2..1addd2549648 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -619,7 +619,7 @@ methods. Treat this as feature discovery, not a full enumeration of - `sessions.messages.subscribe` and `sessions.messages.unsubscribe` toggle transcript/message event subscriptions for one session. Pass `includeApprovals: true` to also receive sanitized `session.approval` lifecycle events for approvals whose persisted audience includes that exact session and whose reviewer binding authorizes the subscribing client. The subscribe response then includes a bounded pending `approvalReplay`; it is authoritative when `truncated` is false. The opt-in is per subscribe call, not sticky: re-subscribing to the same session without `includeApprovals: true` removes an existing approval subscription. In addition to normal session-read authority, this opt-in requires `operator.admin`, or `operator.approvals` on a paired device. - `sessions.preview` returns bounded transcript previews for specific session keys. - `sessions.describe` returns one gateway session row for an exact session key. - - `sessions.resolve` resolves or canonicalizes a session target. + - `sessions.resolve` resolves or canonicalizes a session target by key, raw session ID, label, or Control UI short ID. Ambiguous short IDs return a bounded candidate list as a successful RPC result. - `sessions.create` creates a new session entry. Optional `model` and `thinkingLevel` values persist the initial model and reasoning overrides atomically. `worktree: true` provisions a managed worktree; optional `worktreeBaseRef`/`worktreeName` select the base ref and branch name, and `execNode` (`operator.admin`) binds session exec to a node host. Without `worktreeName`, OpenClaw derives a readable name from the session label or generated first-message title, then falls back to a crustacean-themed name; names already occupied by another owner, local branch, or unmanaged path receive a numeric suffix. The created worktree is echoed in the result and persisted on the session row (`worktree: { id, branch, repoRoot }`). When the entry is created but its nested initial `chat.send` is rejected, the successful result includes `runStarted: false` and `runError`; clients can preserve the prompt and retry against the returned session key. A caller that passes `parentSessionKey` with `emitCommandHooks: true` should also declare the lifecycle disposition of a distinct child: `succeedsParent: true` ends the parent with `session_end`, while `false` keeps the parent active and emits only the child's `session_start`. Omitting `succeedsParent` preserves the legacy parent-rollover behavior for existing clients. The disposition requires both parent linkage and command hooks; a fork cannot succeed its parent. Main-session reset-in-place behavior is unchanged because no distinct child is created. New rows are stamped with write-once creation provenance (`createdVia`, `createdActor`, `createdAt`) from the trusted creation seam; adopting an existing key never restamps it. For human profile actors, `createdActor.label` is resolved from the current user profile when the row is projected and is never stored on the session entry, so profile renames do not drift. Session rows also carry `parentSessionKey` (navigation parent, persisted), `controlOwnerSessionKey` (runtime controller when live), `forkSource` (exact source key + transcript generation for forks), and `previousSessionId` (prior transcript generation under the same key). - `sessions.dispatch` (`operator.admin`) moves an existing local OpenClaw session with a session-owned managed worktree to a configured cloud-worker profile. Pass `{ key, profileId, agentId? }`. The method is absent when no worker profile is configured, closes local turn admission before draining active work, and returns only after placement reaches `active` worker ownership. Dispatch is one-way; worker-to-local pull-back is not part of this RPC. - `sessions.groups.list`, `sessions.groups.put`, `sessions.groups.rename`, and `sessions.groups.delete` manage the gateway-owned custom session group catalog (names + display order). Membership stays on each session's `category` field; rename and delete update member sessions server-side. diff --git a/docs/web/urls.md b/docs/web/urls.md index e035820e998c..4b1137d2efb0 100644 --- a/docs/web/urls.md +++ b/docs/web/urls.md @@ -103,9 +103,12 @@ slug matching. 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. +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. Canonical links do not use `?session=` or `?face=`. Released links such as `/chat?session=` are accepted only at the application boundary as a diff --git a/packages/gateway-protocol/README.md b/packages/gateway-protocol/README.md index 4fbcef8843d2..4f5109a8939f 100644 --- a/packages/gateway-protocol/README.md +++ b/packages/gateway-protocol/README.md @@ -128,7 +128,7 @@ Several identifier names coexist because they identify different things: Follow each method schema rather than converting fields based on their spelling. `sessions.resolve` is the explicit bridge when a caller has a key, raw session ID, -label, or parent/agent scope. +label, Control UI short ID, or parent/agent scope. ### Intentionally open fields diff --git a/packages/gateway-protocol/src/schema/sessions-resolve.ts b/packages/gateway-protocol/src/schema/sessions-resolve.ts new file mode 100644 index 000000000000..12ff7e83f5b1 --- /dev/null +++ b/packages/gateway-protocol/src/schema/sessions-resolve.ts @@ -0,0 +1,23 @@ +import type { Static } from "typebox"; +import { Type } from "typebox"; +import { closedObject } from "./closed-object.js"; +import { NonEmptyString, SessionLabelString } from "./primitives.js"; + +/** Resolves a session by key, raw session id, label, short URL id, or parent/agent scope. */ +export const SessionsResolveParamsSchema = closedObject({ + key: Type.Optional(NonEmptyString), + sessionId: Type.Optional(NonEmptyString), + label: Type.Optional(SessionLabelString), + /** Bare 8-32 character hexadecimal prefix of a session key's trailing UUID. */ + shortId: Type.Optional(NonEmptyString), + /** Optional display-name slug used only to narrow ambiguous shortId matches. */ + slugHint: Type.Optional(NonEmptyString), + agentId: Type.Optional(NonEmptyString), + spawnedBy: Type.Optional(NonEmptyString), + includeGlobal: Type.Optional(Type.Boolean()), + includeUnknown: Type.Optional(Type.Boolean()), + /** Return a successful `{ ok: false }` response when the selector does not match a session. */ + allowMissing: Type.Optional(Type.Boolean()), +}); + +export type SessionsResolveParams = Static; diff --git a/packages/gateway-protocol/src/schema/sessions.ts b/packages/gateway-protocol/src/schema/sessions.ts index 548f5c0b328a..24185f1cb146 100644 --- a/packages/gateway-protocol/src/schema/sessions.ts +++ b/packages/gateway-protocol/src/schema/sessions.ts @@ -11,6 +11,7 @@ import { SessionsCreateParamsSchema } from "./sessions-create.js"; import { SessionToolOverridesSchema } from "./sessions-row.js"; export { SessionsCreateParamsSchema }; +export { SessionsResolveParamsSchema, type SessionsResolveParams } from "./sessions-resolve.js"; export { SESSIONS_ARCHIVE_MANY_MAX_TARGETS, SessionsArchiveManyParamsSchema, @@ -427,19 +428,6 @@ export const SessionsDescribeParamsSchema = closedObject({ includeLastMessage: Type.Optional(Type.Boolean()), }); -/** Resolves a session by key, raw session id, label, or parent/agent scope. */ -export const SessionsResolveParamsSchema = closedObject({ - key: Type.Optional(NonEmptyString), - sessionId: Type.Optional(NonEmptyString), - label: Type.Optional(SessionLabelString), - agentId: Type.Optional(NonEmptyString), - spawnedBy: Type.Optional(NonEmptyString), - includeGlobal: Type.Optional(Type.Boolean()), - includeUnknown: Type.Optional(Type.Boolean()), - /** Return a successful `{ ok: false }` response when the selector does not match a session. */ - allowMissing: Type.Optional(Type.Boolean()), -}); - export const SessionWorktreeInfoSchema = closedObject({ id: NonEmptyString, path: NonEmptyString, @@ -834,7 +822,6 @@ export type SessionsListParams = Static; export type SessionsCleanupParams = Static; export type SessionsPreviewParams = Static; export type SessionsDescribeParams = Static; -export type SessionsResolveParams = Static; export type SessionsSearchParams = Static; export type SessionsSearchHit = Static; export type SessionsSearchResult = Static; diff --git a/packages/session-url-contract/src/index.ts b/packages/session-url-contract/src/index.ts index 8290c6a024cc..ab728a231744 100644 --- a/packages/session-url-contract/src/index.ts +++ b/packages/session-url-contract/src/index.ts @@ -11,7 +11,9 @@ type BuildControlUiSessionPathParams = { shortIdLength?: number; }; -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 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; diff --git a/src/agents/tools/embedded-gateway-stub.test.ts b/src/agents/tools/embedded-gateway-stub.test.ts index d86f9c41fa27..ab53740cb7fc 100644 --- a/src/agents/tools/embedded-gateway-stub.test.ts +++ b/src/agents/tools/embedded-gateway-stub.test.ts @@ -113,10 +113,28 @@ describe("embedded gateway stub", () => { expect(result).toEqual({ ok: true, key: "agent:main:main" }); expect(runtime.resolveSessionKeyFromResolveParams).toHaveBeenCalledWith({ cfg: { agents: { list: [{ id: "main", default: true }] } }, + client: null, p: { sessionId: "sess-main", includeGlobal: true }, }); }); + it("preserves short-id ambiguity as a successful embedded response", async () => { + const candidates = [ + { key: "agent:main:thread:12345678-0aaa-4000-8000-000000000001", displayName: "One" }, + { key: "agent:main:thread:12345678-0bbb-4000-8000-000000000002", displayName: "Two" }, + ]; + runtime.resolveSessionKeyFromResolveParams.mockResolvedValueOnce({ + ok: true, + ambiguous: true, + candidates, + }); + + const callGateway = createEmbeddedCallGateway(); + await expect( + callGateway({ method: "sessions.resolve", params: { shortId: "12345678" } }), + ).resolves.toEqual({ ok: false, candidates }); + }); + it("throws resolver errors for unresolved sessions", async () => { runtime.resolveSessionKeyFromResolveParams.mockResolvedValueOnce({ ok: false, diff --git a/src/agents/tools/embedded-gateway-stub.ts b/src/agents/tools/embedded-gateway-stub.ts index 85fdaef54554..d2053d888ba5 100644 --- a/src/agents/tools/embedded-gateway-stub.ts +++ b/src/agents/tools/embedded-gateway-stub.ts @@ -88,6 +88,7 @@ interface EmbeddedGatewayRuntime { }; resolveSessionKeyFromResolveParams: (opts: { cfg: OpenClawConfig; + client: null; p: SessionsResolveParams; }) => Promise; loadSessionEntry: ( @@ -215,6 +216,7 @@ async function handleSessionsResolve(params: Record) { const cfg = rt.getRuntimeConfig(); const resolved = await rt.resolveSessionKeyFromResolveParams({ cfg, + client: null, p: params as SessionsResolveParams, }); if (!resolved.ok) { @@ -223,6 +225,9 @@ async function handleSessionsResolve(params: Record) { if ("missing" in resolved) { return { ok: false }; } + if ("ambiguous" in resolved) { + return { ok: false, candidates: resolved.candidates }; + } return { ok: true, key: resolved.key }; } diff --git a/src/gateway/server-methods/sessions-read.ts b/src/gateway/server-methods/sessions-read.ts index 0abd59c1930c..281f64a3ec20 100644 --- a/src/gateway/server-methods/sessions-read.ts +++ b/src/gateway/server-methods/sessions-read.ts @@ -646,14 +646,14 @@ export const sessionReadHandlers: GatewayRequestHandlers = { undefined, ); }, - "sessions.resolve": async ({ params, respond, context }) => { + "sessions.resolve": async ({ params, respond, context, client }) => { if (!assertValidParams(params, validateSessionsResolveParams, "sessions.resolve", respond)) { return; } const p = params; const cfg = context.getRuntimeConfig(); - const resolved = await resolveSessionKeyFromResolveParams({ cfg, p }); + const resolved = await resolveSessionKeyFromResolveParams({ cfg, client, p }); if (!resolved.ok) { respond(false, undefined, resolved.error); return; @@ -662,6 +662,10 @@ export const sessionReadHandlers: GatewayRequestHandlers = { respond(true, { ok: false }, undefined); return; } + if ("ambiguous" in resolved) { + respond(true, { ok: false, candidates: resolved.candidates }, undefined); + return; + } respond(true, { ok: true, key: resolved.key }, undefined); }, "sessions.get": async ({ params, respond, context }) => { diff --git a/src/gateway/server-methods/talk-session.ts b/src/gateway/server-methods/talk-session.ts index 1ceed20c6c76..a1367d8fd75d 100644 --- a/src/gateway/server-methods/talk-session.ts +++ b/src/gateway/server-methods/talk-session.ts @@ -193,7 +193,6 @@ export const talkSessionHandlers: GatewayRequestHandlers = { ); return; } - try { if (transport === "managed-room") { if (brain === "direct-tools" && !canUseTalkDirectTools(client)) { @@ -217,6 +216,7 @@ export const talkSessionHandlers: GatewayRequestHandlers = { } const resolvedSession = await resolveSessionKeyFromResolveParams({ cfg: context.getRuntimeConfig(), + client, p: { key: params.sessionKey, ...(spawnedBy ? { spawnedBy } : {}), @@ -228,7 +228,7 @@ export const talkSessionHandlers: GatewayRequestHandlers = { respond(false, undefined, resolvedSession.error); return; } - if ("missing" in resolvedSession) { + if ("missing" in resolvedSession || "ambiguous" in resolvedSession) { respondInvalidRequest(respond, `No session found: ${params.sessionKey}`); return; } @@ -337,14 +337,13 @@ export const talkSessionHandlers: GatewayRequestHandlers = { connId, relaySessionId: session.relaySessionId, }); - respondOk(respond, { + return respondOk(respond, { ...session, sessionId: session.relaySessionId, voiceSessionId: session.relaySessionId, mode, brain, }); - return; } if (mode === "transcription") { diff --git a/src/gateway/server-methods/talk.test.ts b/src/gateway/server-methods/talk.test.ts index 0b30e93173c1..8dc4fb23e02f 100644 --- a/src/gateway/server-methods/talk.test.ts +++ b/src/gateway/server-methods/talk.test.ts @@ -2206,6 +2206,7 @@ describe("talk.session unified handlers", () => { expect(createResult.token).toBeTypeOf("string"); expect(mocks.resolveSessionKeyFromResolveParams).toHaveBeenCalledWith({ cfg: {}, + client: { connId: "conn-1", connect: { scopes: ["operator.admin"] } }, p: { key: "session:main", includeGlobal: true, @@ -2347,6 +2348,7 @@ describe("talk.session unified handlers", () => { }); expect(mocks.resolveSessionKeyFromResolveParams).toHaveBeenCalledWith({ cfg: {}, + client: { connId: "conn-1", connect: { scopes: ["operator.write"] } }, p: { key: "agent:worker:subagent:child", spawnedBy: "agent:main:parent", diff --git a/src/gateway/server.sessions.preview-resolve.test.ts b/src/gateway/server.sessions.preview-resolve.test.ts index 98d64c625f38..2645da186ff6 100644 --- a/src/gateway/server.sessions.preview-resolve.test.ts +++ b/src/gateway/server.sessions.preview-resolve.test.ts @@ -2,6 +2,7 @@ * Gateway session preview resolve tests. */ import { expect, test } from "vitest"; +import type { GatewayClient } from "./server-methods/types.js"; import { createToolSummaryPreviewTranscriptLines } from "./session-preview.test-helpers.js"; import { rpcReq, writeSessionStore } from "./test-helpers.js"; import { @@ -13,6 +14,30 @@ import { const { createSessionStoreDir, openClient } = setupGatewaySessionsTestHarness(); +function identifiedClient(profileId: string, scopes: string[] = ["operator.read"]): GatewayClient { + return { + connect: { + minProtocol: 1, + maxProtocol: 1, + client: { + id: "openclaw-control-ui", + version: "test", + platform: "test", + mode: "webchat", + }, + role: "operator", + scopes, + }, + authenticatedUserId: `${profileId}@example.com`, + authenticatedUserProfile: { + profileId, + displayName: profileId, + hasAvatar: false, + updatedAt: 1, + }, + }; +} + test("sessions.preview returns transcript previews", async () => { const { storePath } = await createSessionStoreDir(); const sessionId = "sess-preview"; @@ -89,6 +114,163 @@ test("sessions.resolve can probe a missing selector without returning an RPC err expect(resolved.payload).toEqual({ ok: false }); }); +test("sessions.resolve returns short-id ambiguity as a protocol-success result", async () => { + await createSessionStoreDir(); + await writeSessionStore({ + entries: { + "agent:main:thread:12345678-0aaa-4000-8000-000000000001": { + sessionId: "sess-short-newer", + displayName: "Newer", + updatedAt: 20, + }, + "agent:main:thread:12345678-0bbb-4000-8000-000000000002": { + sessionId: "sess-short-older", + displayName: "Older", + updatedAt: 10, + }, + }, + }); + + const resolved = await directSessionReq<{ + ok: false; + candidates: Array<{ key: string; displayName?: string }>; + }>("sessions.resolve", { shortId: "12345678" }); + + expect(resolved.ok).toBe(true); + expect(resolved.payload).toEqual({ + ok: false, + candidates: [ + { + key: "agent:main:thread:12345678-0aaa-4000-8000-000000000001", + displayName: "Newer", + }, + { + key: "agent:main:thread:12345678-0bbb-4000-8000-000000000002", + displayName: "Older", + }, + ], + }); +}); + +test("sessions.resolve filters discovery selectors with sessions.list visibility", async () => { + await createSessionStoreDir(); + const visibleKey = "agent:main:thread:12345678-0aaa-4000-8000-000000000001"; + const secondVisibleKey = "agent:main:thread:12345678-0ccc-4000-8000-000000000005"; + const hiddenCollisionKey = "agent:main:thread:12345678-0bbb-4000-8000-000000000002"; + const hiddenOnlyKey = "agent:main:thread:deadbeef-0aaa-4000-8000-000000000003"; + const incognitoKey = "agent:main:thread:cafebabe-0aaa-4000-8000-000000000004"; + await writeSessionStore({ + entries: { + [visibleKey]: { + sessionId: "sess-collision", + label: "collision-label", + displayName: "Visible session", + updatedAt: 40, + visibility: "shared", + createdActor: { type: "human", id: "owner" }, + }, + [hiddenCollisionKey]: { + sessionId: "sess-collision", + label: "collision-label", + displayName: "Hidden collision", + updatedAt: 30, + visibility: "draft", + createdActor: { type: "human", id: "owner" }, + }, + [secondVisibleKey]: { + sessionId: "sess-second-visible", + label: "second-visible", + displayName: "Second visible session", + updatedAt: 35, + visibility: "shared", + createdActor: { type: "human", id: "owner" }, + }, + [hiddenOnlyKey]: { + sessionId: "sess-hidden-only", + label: "hidden-only", + displayName: "Hidden only", + updatedAt: 20, + visibility: "draft", + createdActor: { type: "human", id: "owner" }, + }, + [incognitoKey]: { + sessionId: "sess-incognito", + label: "incognito-only", + displayName: "Incognito only", + updatedAt: 10, + visibility: "shared", + incognito: true, + createdActor: { type: "human", id: "viewer" }, + }, + }, + }); + const client = identifiedClient("viewer"); + + for (const params of [ + { shortId: "deadbeef" }, + { shortId: "cafebabe" }, + { sessionId: "sess-hidden-only" }, + { label: "hidden-only" }, + ]) { + const hidden = await directSessionReq("sessions.resolve", params, { client }); + expect(hidden.ok).toBe(false); + expect(hidden.error?.message).toContain("No session found"); + } + + const ambiguous = await directSessionReq<{ + ok: false; + candidates: Array<{ key: string; displayName?: string }>; + }>("sessions.resolve", { shortId: "12345678" }, { client }); + expect(ambiguous).toMatchObject({ + ok: true, + payload: { + ok: false, + candidates: [{ key: visibleKey }, { key: secondVisibleKey }], + }, + }); + + for (const params of [{ sessionId: "sess-collision" }, { label: "collision-label" }]) { + const resolved = await directSessionReq<{ ok: true; key: string }>("sessions.resolve", params, { + client, + }); + expect(resolved).toMatchObject({ ok: true, payload: { ok: true, key: visibleKey } }); + } + + const exactKey = await directSessionReq<{ ok: true; key: string }>( + "sessions.resolve", + { key: hiddenOnlyKey }, + { client }, + ); + expect(exactKey).toMatchObject({ ok: true, payload: { ok: true, key: hiddenOnlyKey } }); + + const ownerDraft = await directSessionReq<{ ok: true; key: string }>( + "sessions.resolve", + { shortId: "deadbeef" }, + { client: identifiedClient("owner") }, + ); + expect(ownerDraft).toMatchObject({ ok: true, payload: { ok: true, key: hiddenOnlyKey } }); + + const adminIncognito = await directSessionReq<{ ok: true; key: string }>( + "sessions.resolve", + { shortId: "cafebabe" }, + { client: identifiedClient("admin", ["operator.admin"]) }, + ); + expect(adminIncognito).toMatchObject({ ok: true, payload: { ok: true, key: incognitoKey } }); +}); + +test.each([ + { params: { shortId: "xyz" }, message: "shortId must be 8-32 hexadecimal characters" }, + { params: { label: "release", slugHint: "release" }, message: "slugHint requires shortId" }, +])("sessions.resolve rejects invalid short-ref params: $message", async ({ params, message }) => { + await createSessionStoreDir(); + + const resolved = await directSessionReq("sessions.resolve", params); + + expect(resolved.ok).toBe(false); + expect(resolved.error?.code).toBe("INVALID_REQUEST"); + expect(resolved.error?.message).toBe(message); +}); + test("sessions.resolve by key respects spawnedBy visibility filters", async () => { await createSessionStoreDir(); const now = Date.now(); diff --git a/src/gateway/sessions-resolve-store.test.ts b/src/gateway/sessions-resolve-store.test.ts index a6f5c20a386b..6c75878f19cb 100644 --- a/src/gateway/sessions-resolve-store.test.ts +++ b/src/gateway/sessions-resolve-store.test.ts @@ -11,7 +11,13 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { withStateDirEnv as withRawStateDirEnv } from "../test-helpers/state-dir-env.js"; -import { resolveSessionKeyFromResolveParams } from "./sessions-resolve.js"; +import { resolveSessionKeyFromResolveParams as resolveSessionKeyFromResolveParamsWithClient } from "./sessions-resolve.js"; + +type ResolveParams = Parameters[0]; + +const resolveSessionKeyFromResolveParams = ( + params: Omit & { client?: ResolveParams["client"] }, +) => resolveSessionKeyFromResolveParamsWithClient({ client: null, ...params }); describe("resolveSessionKeyFromResolveParams store canonicalization", () => { const freshUpdatedAt = () => Date.now(); diff --git a/src/gateway/sessions-resolve.test.ts b/src/gateway/sessions-resolve.test.ts index a1dc8aa4673d..1074bd46e39a 100644 --- a/src/gateway/sessions-resolve.test.ts +++ b/src/gateway/sessions-resolve.test.ts @@ -32,7 +32,14 @@ vi.mock("./session-utils.js", async () => { }; }); -const { resolveSessionKeyFromResolveParams } = await import("./sessions-resolve.js"); +const { resolveSessionKeyFromResolveParams: resolveSessionKeyFromResolveParamsWithClient } = + await import("./sessions-resolve.js"); + +type ResolveParams = Parameters[0]; + +const resolveSessionKeyFromResolveParams = ( + params: Omit & { client?: ResolveParams["client"] }, +) => resolveSessionKeyFromResolveParamsWithClient({ client: null, ...params }); describe("resolveSessionKeyFromResolveParams", () => { const canonicalKey = "agent:main:canon"; @@ -262,6 +269,158 @@ describe("resolveSessionKeyFromResolveParams", () => { expect(hoisted.listSessionsFromStoreMock).not.toHaveBeenCalled(); }); + it("resolves an archived session by its trailing UUID prefix", async () => { + const key = "agent:main:thread:abcdef12-3456-4789-8abc-def012345678"; + hoisted.loadCombinedSessionStoreForGatewayMock.mockReturnValue({ + storePath, + store: { + [key]: { + sessionId: "sess-short", + updatedAt: 10, + archivedAt: 20, + displayName: "Release monitor", + }, + }, + }); + + await expect( + resolveSessionKeyFromResolveParams({ + cfg: {}, + p: { shortId: "ABCDEF12", agentId: "main" }, + }), + ).resolves.toEqual({ ok: true, key }); + }); + + it("uses a display-name slug only to narrow a short-id tie", async () => { + const releaseKey = "agent:main:thread:12345678-0aaa-4000-8000-000000000001"; + const deployKey = "agent:main:thread:12345678-0bbb-4000-8000-000000000002"; + hoisted.loadCombinedSessionStoreForGatewayMock.mockReturnValue({ + storePath, + store: { + [releaseKey]: { updatedAt: 2, displayName: "Release monitor" }, + [deployKey]: { updatedAt: 1, displayName: "Deploy monitor" }, + }, + }); + + await expect( + resolveSessionKeyFromResolveParams({ + cfg: {}, + p: { shortId: "12345678", slugHint: "deploy-monitor" }, + }), + ).resolves.toEqual({ ok: true, key: deployKey }); + }); + + it("ignores a deleted-agent short-id collision before resolving a unique match", async () => { + const survivingKey = "agent:main:thread:12345678-0aaa-4000-8000-000000000001"; + const deletedKey = "agent:deleted-agent:thread:12345678-0bbb-4000-8000-000000000002"; + hoisted.loadCombinedSessionStoreForGatewayMock.mockReturnValue({ + storePath, + store: { + [deletedKey]: { updatedAt: 2, displayName: "Deleted session" }, + [survivingKey]: { updatedAt: 1, displayName: "Surviving session" }, + }, + }); + + await expect( + resolveSessionKeyFromResolveParams({ + cfg: {}, + p: { shortId: "12345678", slugHint: "deleted-session" }, + }), + ).resolves.toEqual({ ok: true, key: survivingKey }); + }); + + it("reports a deleted-agent-only short-id match as missing", async () => { + const deletedKey = "agent:deleted-agent:thread:12345678-0bbb-4000-8000-000000000002"; + hoisted.loadCombinedSessionStoreForGatewayMock.mockReturnValue({ + storePath, + store: { [deletedKey]: { updatedAt: 1, displayName: "Deleted session" } }, + }); + + await expect( + resolveSessionKeyFromResolveParams({ + cfg: {}, + p: { shortId: "12345678" }, + }), + ).resolves.toEqual({ + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: "No session found: 12345678", + }, + }); + }); + + it("returns at most ten recent candidates and ignores a stale slug hint", async () => { + const store = Object.fromEntries( + Array.from({ length: 12 }, (_, index) => { + const suffix = index.toString(16).padStart(4, "0"); + return [ + `agent:main:thread:12345678-${suffix}-4000-8000-000000000000`, + { updatedAt: 100 - index, displayName: `Candidate ${index}` }, + ]; + }), + ); + hoisted.loadCombinedSessionStoreForGatewayMock.mockReturnValue({ storePath, store }); + + const expectedKeys = Object.keys(store).slice(0, 10); + await expect( + resolveSessionKeyFromResolveParams({ + cfg: {}, + p: { shortId: "12345678", slugHint: "renamed-session" }, + }), + ).resolves.toEqual({ + ok: true, + ambiguous: true, + candidates: expectedKeys.map((key, index) => ({ + key, + displayName: `Candidate ${index}`, + })), + }); + }); + + it("applies agent scoping to short-id matches", async () => { + const mainKey = "agent:main:thread:feedface-0000-4000-8000-000000000001"; + const workKey = "agent:work:thread:feedface-0000-4000-8000-000000000002"; + hoisted.loadCombinedSessionStoreForGatewayMock.mockReturnValue({ + storePath, + store: { + [mainKey]: { updatedAt: 1 }, + [workKey]: { updatedAt: 2 }, + }, + }); + + await expect( + resolveSessionKeyFromResolveParams({ + cfg: { agents: { list: [{ id: "main", default: true }, { id: "work" }] } }, + p: { shortId: "feedface", agentId: "main" }, + }), + ).resolves.toEqual({ ok: true, key: mainKey }); + }); + + it("supports allowMissing for short ids", async () => { + hoisted.loadCombinedSessionStoreForGatewayMock.mockReturnValue({ storePath, store: {} }); + + await expect( + resolveSessionKeyFromResolveParams({ + cfg: {}, + p: { shortId: "deadbeef", allowMissing: true }, + }), + ).resolves.toEqual({ ok: true, missing: true }); + }); + + it.each([ + { + p: { shortId: "too-short" }, + message: "shortId must be 8-32 hexadecimal characters", + }, + { p: { label: "release", slugHint: "release" }, message: "slugHint requires shortId" }, + ])("rejects invalid short reference params: $message", async ({ p, message }) => { + await expect(resolveSessionKeyFromResolveParams({ cfg: {}, p })).resolves.toMatchObject({ + ok: false, + error: { code: ErrorCodes.INVALID_REQUEST, message }, + }); + }); + it("rejects sessions belonging to a deleted agent (label-based lookup)", async () => { const deletedAgentKey = "agent:deleted-agent:main"; hoisted.loadCombinedSessionStoreForGatewayMock.mockReturnValue({ diff --git a/src/gateway/sessions-resolve.ts b/src/gateway/sessions-resolve.ts index 26d356a74141..bbda88e1e4e6 100644 --- a/src/gateway/sessions-resolve.ts +++ b/src/gateway/sessions-resolve.ts @@ -1,6 +1,6 @@ import { expectDefined } from "@openclaw/normalization-core"; // Gateway sessions.resolve implementation helper. -// Resolves key/sessionId/label selectors into one canonical session key. +// Resolves key/sessionId/label/shortId selectors into one canonical session key. import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { ErrorCodes, @@ -8,11 +8,20 @@ import { errorShape, type SessionsResolveParams, } from "../../packages/gateway-protocol/src/index.js"; +import { + controlUiSessionSlug, + SESSION_UUID_SUFFIX_RE, + SHORT_SESSION_ID_RE, +} from "../../packages/session-url-contract/src/index.js"; import type { SessionEntry } from "../config/sessions.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { parseAgentSessionKey } from "../routing/session-key.js"; import { resolveSessionIdMatchSelection } from "../sessions/session-id-resolution.js"; import { parseSessionLabel } from "../sessions/session-label.js"; +import type { GatewayClient } from "./server-methods/types.js"; +import { createSessionListEntryFilter } from "./session-sharing.js"; import { + buildGatewaySessionInfo, filterAndSortSessionEntries, listSessionsFromStore, loadCombinedSessionStoreForGateway, @@ -20,9 +29,12 @@ import { resolveGatewaySessionStoreTargetWithStore, } from "./session-utils.js"; +type SessionsResolveCandidate = { key: string; displayName?: string }; + export type SessionsResolveResult = | { ok: true; key: string } | { ok: true; missing: true } + | { ok: true; ambiguous: true; candidates: SessionsResolveCandidate[] } | { ok: false; error: ErrorShape }; function resolveSessionVisibilityFilterOptions(p: SessionsResolveParams) { @@ -86,6 +98,7 @@ function findVisibleSessionIdMatches(params: { store: Record; p: SessionsResolveParams; sessionId: string; + entryFilter?: (key: string, entry: SessionEntry) => boolean; }): Array<[string, SessionEntry]> { const now = Date.now(); const entries = filterAndSortSessionEntries({ @@ -95,39 +108,99 @@ function findVisibleSessionIdMatches(params: { opts: resolveSessionVisibilityFilterOptions(params.p), }); return entries.filter( - ([key, entry]) => entry?.sessionId === params.sessionId || key === params.sessionId, + ([key, entry]) => + (params.entryFilter?.(key, entry) ?? true) && + (entry?.sessionId === params.sessionId || key === params.sessionId), ); } +function normalizeShortSessionId(shortId: string): string | null { + return SHORT_SESSION_ID_RE.test(shortId) ? shortId.toLowerCase() : null; +} + +function findVisibleShortIdMatches(params: { + cfg: OpenClawConfig; + storePath: string; + store: Record; + p: SessionsResolveParams; + shortId: string; + entryFilter?: (key: string, entry: SessionEntry) => boolean; +}): SessionsResolveCandidate[] { + const now = Date.now(); + const entries = filterAndSortSessionEntries({ + cfg: params.cfg, + store: params.store, + now, + opts: { ...resolveSessionVisibilityFilterOptions(params.p), archived: "all" }, + }); + return entries.flatMap(([key, entry]) => { + if (params.entryFilter && !params.entryFilter(key, entry)) { + return []; + } + const uuid = parseAgentSessionKey(key)?.rest.match(SESSION_UUID_SUFFIX_RE)?.[1]; + if (!uuid?.toLowerCase().replaceAll("-", "").startsWith(params.shortId)) { + return []; + } + if (resolveDeletedAgentIdFromSessionKey(params.cfg, key, entry) !== null) { + return []; + } + const row = buildGatewaySessionInfo({ + cfg: params.cfg, + storePath: params.storePath, + store: params.store, + key, + entry, + now, + }); + return [{ key, ...(row.displayName ? { displayName: row.displayName } : {}) }]; + }); +} + export async function resolveSessionKeyFromResolveParams(params: { cfg: OpenClawConfig; + client: GatewayClient | null; p: SessionsResolveParams; }): Promise { - const { cfg, p } = params; + const { cfg, client, p } = params; + const entryFilter = createSessionListEntryFilter({ client }); const key = normalizeOptionalString(p.key) ?? ""; const hasKey = key.length > 0; const sessionId = normalizeOptionalString(p.sessionId) ?? ""; const hasSessionId = sessionId.length > 0; const hasLabel = (normalizeOptionalString(p.label) ?? "").length > 0; - const selectionCount = [hasKey, hasSessionId, hasLabel].filter(Boolean).length; + const rawShortId = normalizeOptionalString(p.shortId) ?? ""; + const hasShortId = rawShortId.length > 0; + const hasSlugHint = p.slugHint !== undefined; + if (hasSlugHint && !hasShortId) { + return { + ok: false, + error: errorShape(ErrorCodes.INVALID_REQUEST, "slugHint requires shortId"), + }; + } + const selectionCount = [hasKey, hasSessionId, hasLabel, hasShortId].filter(Boolean).length; if (selectionCount > 1) { return { ok: false, error: errorShape( ErrorCodes.INVALID_REQUEST, - "Provide either key, sessionId, or label (not multiple)", + "Provide either key, sessionId, label, or shortId (not multiple)", ), }; } if (selectionCount === 0) { return { ok: false, - error: errorShape(ErrorCodes.INVALID_REQUEST, "Either key, sessionId, or label is required"), + error: errorShape( + ErrorCodes.INVALID_REQUEST, + "Either key, sessionId, label, or shortId is required", + ), }; } if (hasKey) { + // Exact-key lookup follows the proof-of-knowledge read semantics of get/describe/history; + // only discovery selectors use list visibility. Incognito keys are gated pre-dispatch. const target = resolveGatewaySessionStoreTargetWithStore({ cfg, key, clone: false }); const store = target.store; if (store[target.canonicalKey]) { @@ -159,7 +232,7 @@ export async function resolveSessionKeyFromResolveParams(params: { // sessionId can collide across stores; delegate selection so exact key // matches and ambiguity rules stay shared with other session-id callers. const { store } = loadCombinedSessionStoreForGateway(cfg, { agentId: p.agentId }); - const matches = findVisibleSessionIdMatches({ cfg, store, p, sessionId }); + const matches = findVisibleSessionIdMatches({ cfg, store, p, sessionId, entryFilter }); const selection = resolveSessionIdMatchSelection(matches, sessionId); if (selection.kind === "none") { return noSessionFoundResult({ p, message: `No session found: ${sessionId}` }); @@ -186,6 +259,43 @@ export async function resolveSessionKeyFromResolveParams(params: { return { ok: true, key: selection.sessionKey }; } + if (hasShortId) { + const shortId = normalizeShortSessionId(rawShortId); + if (!shortId) { + return { + ok: false, + error: errorShape( + ErrorCodes.INVALID_REQUEST, + "shortId must be 8-32 hexadecimal characters", + ), + }; + } + const { storePath, store } = loadCombinedSessionStoreForGateway(cfg, { agentId: p.agentId }); + const matches = findVisibleShortIdMatches({ + cfg, + storePath, + store, + p, + shortId, + entryFilter, + }); + const slugHint = normalizeOptionalString(p.slugHint); + const slugMatches = slugHint + ? matches.filter((candidate) => controlUiSessionSlug(candidate.displayName) === slugHint) + : []; + // A stale display-name hint may narrow a tie, but it must never invalidate the id. + const narrowed = slugMatches.length > 0 ? slugMatches : matches; + if (narrowed.length === 0) { + return noSessionFoundResult({ p, message: `No session found: ${shortId}` }); + } + if (narrowed.length > 1) { + // Bound the ambiguity payload; callers treat a full ten rows as possibly truncated. + 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 }; + } + const parsedLabel = parseSessionLabel(p.label); if (!parsedLabel.ok) { return { @@ -197,6 +307,7 @@ export async function resolveSessionKeyFromResolveParams(params: { const { storePath, store } = loadCombinedSessionStoreForGateway(cfg, { agentId: p.agentId }); const list = listSessionsFromStore({ cfg, + ...(entryFilter ? { entryFilter } : {}), storePath, store, lightweightListRows: true, diff --git a/ui/src/pages/chat/route-loader-short-cache.ts b/ui/src/pages/chat/route-loader-short-cache.ts index 96fff052ac21..45a839585067 100644 --- a/ui/src/pages/chat/route-loader-short-cache.ts +++ b/ui/src/pages/chat/route-loader-short-cache.ts @@ -1,4 +1,4 @@ -import { controlUiSessionSlug } from "@openclaw/session-url-contract"; +import { controlUiSessionSlug, SESSION_UUID_SUFFIX_RE } from "@openclaw/session-url-contract"; import type { RouteLocation } from "@openclaw/uirouter"; import type { GatewaySessionRow } from "../../api/types.ts"; import type { SessionPathTarget } from "../../app-session-route-paths.ts"; @@ -13,8 +13,6 @@ import { } from "../../lib/sessions/route-navigation.ts"; import { normalizeAgentId, parseAgentSessionKey } from "../../lib/sessions/session-key.ts"; -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 function sessionKeyUuid(sessionKey: string): string | null { const uuid = parseAgentSessionKey(sessionKey)?.rest.match(SESSION_UUID_SUFFIX_RE)?.[1]; return uuid ? uuid.toLowerCase().replaceAll("-", "") : null; @@ -36,45 +34,6 @@ type CachedShortSession = { row?: GatewaySessionRow; }; -export type ShortSessionResolution = - | { kind: "not-found" } - | { kind: "unique"; session: GatewaySessionRow } - | { kind: "ambiguous"; sessions: GatewaySessionRow[]; truncated: boolean }; - -export function narrowShortResolutionBySlugHint( - resolution: ShortSessionResolution, - slugHint: string | undefined, -): ShortSessionResolution { - 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; -} - -export function incompleteShortSessionResolution( - kind: "exact" | "short" | "slug", - sessions: GatewaySessionRow[], -): ShortSessionResolution { - if (kind === "slug" && sessions.length === 0) { - // Slugs are best-effort: an incomplete exact-key search must retain the - // authoritative literal route, while a bounded zero-match slug is a 404. - return { kind: "not-found" }; - } - return { kind: "ambiguous", sessions, truncated: true }; -} - -export function requireShortSessionResolution( - resolution: ShortSessionResolution | null, -): ShortSessionResolution { - if (!resolution) { - throw new Error("Session list unavailable while resolving URL."); - } - return resolution; -} - export function findCachedShortSession( context: ApplicationContext, location: RouteLocation, diff --git a/ui/src/pages/chat/route-loader-short-list-fallback.ts b/ui/src/pages/chat/route-loader-short-list-fallback.ts new file mode 100644 index 000000000000..25b1218eb01d --- /dev/null +++ b/ui/src/pages/chat/route-loader-short-list-fallback.ts @@ -0,0 +1,78 @@ +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 { ApplicationContext } from "../../app/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 new file mode 100644 index 000000000000..278ef49bc9d3 --- /dev/null +++ b/ui/src/pages/chat/route-loader-short-resolve.ts @@ -0,0 +1,69 @@ +import { ErrorCodes } from "@openclaw/gateway-client/browser"; +import { GatewayRequestError } from "../../api/gateway.ts"; +import type { GatewaySessionRow } from "../../api/types.ts"; +import type { SessionPathTarget } from "../../app-session-route-paths.ts"; +import type { ApplicationContext } from "../../app/context.ts"; +import { waitForGatewayClient } from "../../app/gateway-readiness.ts"; +import { + resolveShortSessionReferenceWithListFallback, + type ShortSessionListFallbackResolution, +} from "./route-loader-short-list-fallback.ts"; + +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 async function resolveShortSessionReference( + context: ApplicationContext, + target: Extract, + signal: AbortSignal, +): 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); + signal.throwIfAborted(); + if (result.ok) { + return rows[0] ? { kind: "unique", session: rows[0] } : { kind: "not-found" }; + } + return { kind: "ambiguous", sessions: rows, truncated: candidates.length === 10 }; +} diff --git a/ui/src/pages/chat/route-loader.ts b/ui/src/pages/chat/route-loader.ts index 3cfd8469bec4..84134756ae53 100644 --- a/ui/src/pages/chat/route-loader.ts +++ b/ui/src/pages/chat/route-loader.ts @@ -1,4 +1,4 @@ -import { controlUiSessionSlug } from "@openclaw/session-url-contract"; +import { controlUiSessionSlug, SHORT_SESSION_ID_RE } from "@openclaw/session-url-contract"; import type { RouteLocation } from "@openclaw/uirouter"; import { notFound } from "@openclaw/uirouter"; import type { GatewayBrowserClient } from "../../api/gateway.ts"; @@ -31,19 +31,14 @@ import { resolveUiGlobalAliasAgentId, } from "../../lib/sessions/session-key.ts"; import { draftRouteDataFromLocation, draftSearchFromLocation } from "./route-draft.ts"; +import { findCachedShortSession, sessionKeyUuid } from "./route-loader-short-cache.ts"; import { - findCachedShortSession, - incompleteShortSessionResolution, - narrowShortResolutionBySlugHint, - requireShortSessionResolution, - sessionKeyUuid, - type ShortSessionResolution as SessionReferenceResolution, -} from "./route-loader-short-cache.ts"; + resolveShortSessionReference, + type SessionReferenceResolution, +} from "./route-loader-short-resolve.ts"; const SESSION_REF_SEARCH_LIMIT = 20; const SESSION_REF_SEARCH_MAX_PAGES = 5; -// A uuid's first block is the longest run that is contiguous in both the hyphenated -// stored key and the hyphen-stripped short id used in URLs. type SessionCandidate = { agentId: string; @@ -90,7 +85,6 @@ export function locationWithoutDraft(location: RouteLocation): RouteLocation { } type SessionReferenceSearch = { agentId: string } & ( - | { kind: "short"; value: string } | { kind: "exact"; value: string } | { kind: "slug"; value: string } ); @@ -109,7 +103,7 @@ function uniqueShortIdPrefix( truncated: boolean, ): string | null { const uuid = value.toLowerCase().replaceAll("-", ""); - if (!/^[0-9a-f]{8,32}$/u.test(uuid)) { + if (!SHORT_SESSION_ID_RE.test(uuid)) { return null; } if (truncated) { @@ -159,19 +153,11 @@ function sessionReferenceSearchText( } return search.value; } - if (search.kind === "slug") { - // controlUiSessionSlug builds every token from a contiguous alphanumeric run of the - // lowercased display name, so one token always matches while the joined slug would - // miss any name whose separators were punctuation ("Fix: auth bug" -> "fix-auth-bug"). - // The longest token is the most selective of those. - return search.value - .split("-") - .reduce((longest, token) => (token.length > longest.length ? token : longest), ""); - } - // Short ids are compared hyphen-stripped, but the stored key holds a hyphenated uuid, - // so only its first block survives as a contiguous substring. Anything longer (from a - // disambiguation link or a canonicalized slug) would match nothing server-side. - return search.value.slice(0, 8); + // controlUiSessionSlug builds every token from a contiguous alphanumeric run of the + // lowercased display name, so the longest token is the safest selective search term. + return search.value + .split("-") + .reduce((longest, token) => (token.length > longest.length ? token : longest), ""); } function sessionReferenceMatches( @@ -187,25 +173,12 @@ function sessionReferenceMatches( (isUiGlobalSessionKey(row.key) && aliasAgentId === normalizeAgentId(search.agentId)), ); } - if (search.kind === "slug") { - return result.sessions.filter( - (row) => - sessionKeyUuid(row.key) !== null && controlUiSessionSlug(row.displayName) === search.value, - ); - } - const prefix = search.value.toLowerCase().replaceAll("-", ""); - return result.sessions.filter((row) => sessionKeyUuid(row.key)?.startsWith(prefix) === true); + return result.sessions.filter( + (row) => + sessionKeyUuid(row.key) !== null && controlUiSessionSlug(row.displayName) === search.value, + ); } -// 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. async function querySessionReference( context: ApplicationContext, search: SessionReferenceSearch, @@ -258,6 +231,18 @@ async function querySessionReference( } } +function incompleteSessionReferenceResolution( + kind: SessionReferenceSearch["kind"], + sessions: GatewaySessionRow[], +): SessionReferenceResolution { + if (kind === "slug" && sessions.length === 0) { + // Slugs are best-effort: an incomplete exact-key search must retain the + // authoritative literal route, while a bounded zero-match slug is a 404. + return { kind: "not-found" }; + } + return { kind: "ambiguous", sessions, truncated: true }; +} + async function querySessionReferencePages( context: ApplicationContext, search: SessionReferenceSearch, @@ -294,11 +279,11 @@ async function querySessionReferencePages( return session ? { kind: "unique", session } : { kind: "not-found" }; } if (page === SESSION_REF_SEARCH_MAX_PAGES - 1) { - return incompleteShortSessionResolution(search.kind, sessions); + return incompleteSessionReferenceResolution(search.kind, sessions); } const nextOffset = result.nextOffset ?? offset + result.sessions.length; if (nextOffset <= offset) { - return incompleteShortSessionResolution(search.kind, sessions); + return incompleteSessionReferenceResolution(search.kind, sessions); } offset = nextOffset; } @@ -727,16 +712,7 @@ export async function loadChatRoute( } const resolution = cached?.row ? ({ kind: "unique", session: cached.row } as const) - : narrowShortResolutionBySlugHint( - requireShortSessionResolution( - await querySessionReference( - context, - { kind: "short", value: target.shortId, agentId: target.agentId }, - signal, - ), - ), - target.slugHint, - ); + : await resolveShortSessionReference(context, target, signal); 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 b61b4fdfb41e..f0a5e953e38a 100644 --- a/ui/src/pages/chat/route-resolution.test.ts +++ b/ui/src/pages/chat/route-resolution.test.ts @@ -1,5 +1,6 @@ // @vitest-environment node import { describe, expect, it, vi } from "vitest"; +import { GatewayRequestError } from "../../api/gateway.ts"; import type { GatewaySessionRow, SessionsListResult } from "../../api/types.ts"; import { INTERNAL_SESSION_PATH_PARAM } from "../../app-route-paths.ts"; import type { ApplicationContext } from "../../app/context.ts"; @@ -69,6 +70,26 @@ function contextFor( return { context, list }; } +function installShortResolver( + context: ApplicationContext, + rows: GatewaySessionRow[], + resolved: { ok: true; key: string } | { ok: false; candidates?: Array<{ key: string }> } = rows[0] + ? { ok: true, key: rows[0].key } + : { ok: false }, +) { + 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 }; + } + throw new Error(`Unexpected gateway request: ${method}`); + }); + (context.gateway.snapshot.client as unknown as { request: typeof request }).request = request; + return request; +} + // The router navigates with `options`, not the shareable `href`, so route-loader // coverage has to start from the same location the app actually pushes. function targetLocation(target: ReturnType) { @@ -149,6 +170,7 @@ describe("gateway-backed session route resolution", () => { it("applies an uncached stored face to a preference-derived open", async () => { const dashboardRow = row({ boardFace: "dashboard" }); const { context } = contextFor(() => result([dashboardRow])); + installShortResolver(context, [dashboardRow]); const face = resolveSessionPreferredFaceForKey(context, dashboardRow.key); const target = sessionNavigationTarget({ context, @@ -198,6 +220,7 @@ describe("gateway-backed session route resolution", () => { it("applies face canonicalization through the router's normalized location", async () => { const dashboardRow = row({ boardFace: "dashboard" }); const { context } = contextFor(() => result([dashboardRow])); + installShortResolver(context, [dashboardRow]); const target = sessionNavigationTarget({ context, face: "chat", @@ -263,6 +286,7 @@ describe("gateway-backed session route resolution", () => { ] as const) { const storedRow = row({ boardFace: storedFace }); const { context } = contextFor(() => result([storedRow])); + installShortResolver(context, [storedRow]); const pathname = `/${face}/roboclaw/default-mode-with-rare-surprises-12345678`; const loaded = await loadChatRoute( context, @@ -431,6 +455,7 @@ describe("gateway-backed session route resolution", () => { }), ]; const { context } = contextFor(() => result(rows)); + const request = installShortResolver(context, rows, { ok: true, key: rows[1]?.key ?? "" }); const loaded = await loadChatRoute( context, { pathname: "/chat/roboclaw/deploy-monitor-12345678", search: "", hash: "" }, @@ -441,6 +466,86 @@ describe("gateway-backed session route resolution", () => { // 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 }); + expect(request).toHaveBeenNthCalledWith(1, "sessions.resolve", { + shortId: "12345678", + slugHint: "deploy-monitor", + 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 () => { + const storedRow = row({ displayName: "Deploy monitor" }); + const { context, list } = contextFor(() => result([storedRow])); + const request = installShortResolver(context, [storedRow]); + + const loaded = await loadChatRoute( + context, + { pathname: "/chat/roboclaw/deploy-monitor-12345678", search: "", hash: "" }, + "chat", + new AbortController().signal, + ); + + expect(loaded).toMatchObject({ kind: "session", sessionKey: storedRow.key }); + expect(request).toHaveBeenCalledTimes(2); + expect(list).not.toHaveBeenCalled(); + }); + + it("does not mask unrelated sessions.resolve validation errors", async () => { + const { context, list } = contextFor(() => result([])); + const rejection = new GatewayRequestError({ + code: "INVALID_REQUEST", + message: "invalid sessions.resolve params: shortId must be hexadecimal", + }); + const request = vi.fn(async () => { + throw rejection; + }); + (context.gateway.snapshot.client as unknown as { request: typeof request }).request = request; + + await expect( + loadChatRoute( + context, + { pathname: "/chat/roboclaw/deploy-monitor-12345678", search: "", hash: "" }, + "chat", + new AbortController().signal, + ), + ).rejects.toBe(rejection); + expect(list).not.toHaveBeenCalled(); }); it("uses the sidebar-carried full key without issuing a session search", async () => { @@ -471,7 +576,8 @@ describe("gateway-backed session route resolution", () => { { connectionChange: "gateway client replacement", replaceConnection: (snapshot: ApplicationContext["gateway"]["snapshot"]) => { - snapshot.client = {} as NonNullable; + const request = (snapshot.client as { request?: unknown } | null)?.request; + snapshot.client = { request } as NonNullable; }, }, { @@ -492,6 +598,7 @@ describe("gateway-backed session route resolution", () => { displayName: "Deploy monitor", }); const { context, list } = contextFor(() => result([currentSession])); + const request = installShortResolver(context, [currentSession]); context.gateway.snapshot.hello = { snapshot: { sessionDefaults: { mainKey: "main" } }, } as NonNullable; @@ -508,7 +615,8 @@ describe("gateway-backed session route resolution", () => { ); expect(loaded).toMatchObject({ kind: "session", sessionKey: currentSession.key }); - expect(list).toHaveBeenCalledOnce(); + expect(list).not.toHaveBeenCalled(); + expect(request).toHaveBeenCalledTimes(2); }); it("prefers the current location key over a residual colliding handoff", async () => { @@ -555,6 +663,7 @@ describe("gateway-backed session route resolution", () => { }); const staleKey = "agent:roboclaw:thread:12345678-0bbb-4000-8000-000000000002"; const { context, list } = contextFor(() => result([expected])); + const request = installShortResolver(context, [expected]); const loaded = await loadChatRoute( context, @@ -568,12 +677,14 @@ describe("gateway-backed session route resolution", () => { ); expect(loaded).toMatchObject({ kind: "session", sessionKey: expected.key }); - expect(list).toHaveBeenCalledOnce(); + expect(list).not.toHaveBeenCalled(); + expect(request).toHaveBeenCalledTimes(2); }); it("keeps a cold cached short route on the authoritative resolution path", async () => { const storedRow = row({ displayName: "Deploy monitor" }); const { context, list } = contextFor(() => result([storedRow]), [storedRow]); + const request = installShortResolver(context, [storedRow]); const loaded = await loadChatRoute( context, @@ -583,7 +694,8 @@ describe("gateway-backed session route resolution", () => { ); expect(loaded).toMatchObject({ kind: "session", sessionKey: storedRow.key }); - expect(list).toHaveBeenCalledOnce(); + expect(list).not.toHaveBeenCalled(); + expect(request).toHaveBeenCalledTimes(2); }); it("keeps the gateway ambiguity check when cached rows share the uuid and slug", async () => { @@ -592,6 +704,10 @@ describe("gateway-backed session route resolution", () => { row({ key: "agent:roboclaw:thread:12345678-0bbb-4000-8000-000000000002" }), ]; const { context, list } = contextFor(() => result(rows), rows); + const request = installShortResolver(context, rows, { + ok: false, + candidates: rows.map(({ key }) => ({ key })), + }); const loaded = await loadChatRoute( context, @@ -605,7 +721,8 @@ describe("gateway-backed session route resolution", () => { ); expect(loaded).toMatchObject({ kind: "ambiguous", shortId: "12345678" }); - expect(list).toHaveBeenCalledOnce(); + expect(list).not.toHaveBeenCalled(); + expect(request).toHaveBeenCalledTimes(3); }); it("keeps the chooser when the slug matches neither or both tied sessions", async () => { @@ -614,6 +731,10 @@ describe("gateway-backed session route resolution", () => { row({ key: "agent:roboclaw:thread:12345678-0bbb-4000-8000-000000000002" }), ]; const { context } = contextFor(() => result(rows)); + installShortResolver(context, rows, { + ok: false, + candidates: rows.map(({ key }) => ({ key })), + }); for (const pathname of [ // Stale slug: the session was renamed since the link was made. "/chat/roboclaw/an-old-name-12345678", @@ -631,19 +752,21 @@ describe("gateway-backed session route resolution", () => { } }); - 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 }), + it("treats a full ten-candidate response as conservatively truncated", async () => { + const rows = Array.from({ length: 10 }, (_, index) => + row({ + key: `agent:roboclaw:thread:12345678-${index.toString(16).padStart(4, "0")}-4000-8000-000000000000`, + displayName: `Candidate ${index}`, + }), ); + const { context } = contextFor(() => result([])); + installShortResolver(context, rows, { + ok: false, + candidates: rows.map(({ key }) => ({ key })), + }); const loaded = await loadChatRoute( context, - { pathname: "/chat/roboclaw/deploy-monitor-12345678", search: "", hash: "" }, + { pathname: "/chat/roboclaw/12345678", search: "", hash: "" }, "chat", new AbortController().signal, ); @@ -651,6 +774,22 @@ describe("gateway-backed session route resolution", () => { expect(loaded).toMatchObject({ kind: "ambiguous", shortId: "12345678", truncated: true }); }); + it("returns not found when the gateway has no short-id match", async () => { + const { context, list } = contextFor(() => result([])); + const request = installShortResolver(context, [], { ok: false }); + + const loaded = await loadChatRoute( + context, + { pathname: "/chat/roboclaw/deadbeef", search: "", hash: "" }, + "chat", + new AbortController().signal, + ); + + expect(loaded).not.toHaveProperty("kind", "session"); + expect(list).not.toHaveBeenCalled(); + expect(request).toHaveBeenCalledOnce(); + }); + it("prefers an exact literal key over slug matches", async () => { const literal = row({ key: "agent:roboclaw:default-mode-with-rare-surprises", @@ -680,6 +819,7 @@ describe("gateway-backed session route resolution", () => { displayName: "Default mode deadbeef", }); const { context, list } = contextFor(() => result([slug, short])); + const request = installShortResolver(context, [short]); const loaded = await loadChatRoute( context, { pathname: "/chat/roboclaw/default-mode-deadbeef", search: "", hash: "" }, @@ -688,8 +828,13 @@ describe("gateway-backed session route resolution", () => { ); expect(loaded).toMatchObject({ kind: "session", sessionKey: short.key }); - expect(list).toHaveBeenCalledOnce(); - expect(list).toHaveBeenCalledWith(expect.objectContaining({ search: "deadbeef" })); + expect(list).not.toHaveBeenCalled(); + expect(request).toHaveBeenNthCalledWith(1, "sessions.resolve", { + shortId: "deadbeef", + slugHint: "default-mode", + agentId: "roboclaw", + allowMissing: true, + }); }); it("returns not found when neither a literal key nor slug resolves", async () => { diff --git a/ui/src/pages/chat/route.test.ts b/ui/src/pages/chat/route.test.ts index 2dd84f5823a7..d1bc74163256 100644 --- a/ui/src/pages/chat/route.test.ts +++ b/ui/src/pages/chat/route.test.ts @@ -33,7 +33,30 @@ function result( } function contextFor(listResult: SessionsListResult | null, mainKey = "main") { - const client = {}; + const request = vi.fn(async (method: string, params: Record) => { + if (method === "sessions.resolve") { + const shortId = typeof params.shortId === "string" ? params.shortId.toLowerCase() : ""; + const matches = + listResult?.sessions.filter((session) => + session.key.toLowerCase().replaceAll("-", "").includes(shortId), + ) ?? []; + return matches.length === 1 && matches[0] + ? { ok: true, key: matches[0].key } + : { + ok: false, + ...(matches.length > 1 + ? { candidates: matches.map((session) => ({ key: session.key })) } + : {}), + }; + } + 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 }; const list = vi.fn(async (_options?: { offset?: number; search?: string }) => listResult); const context = { basePath: "", @@ -44,7 +67,7 @@ function contextFor(listResult: SessionsListResult | null, mainKey = "main") { agents: { state: { agentsList: { mainKey } } }, sessions: { list }, } as unknown as ApplicationContext; - return { context, list }; + return { context, list, request }; } describe("loadChatRoute", () => { @@ -62,14 +85,22 @@ describe("loadChatRoute", () => { }); it("survives sessionId rotation and canonicalizes decorative short-form segments", async () => { - const { context, list } = contextFor(result([row()])); - list - .mockResolvedValueOnce(result([row({ sessionId: "before-compaction" })])) - .mockResolvedValueOnce(result([row({ sessionId: "after-compaction" })])); + const { context, list, request } = contextFor(result([row()])); + let describeCount = 0; + request.mockImplementation(async (method) => { + if (method === "sessions.resolve") { + return { ok: true, key: sessionKey }; + } + return { + session: row({ + sessionId: describeCount++ === 0 ? "before-compaction" : "after-compaction", + }), + }; + }); const signal = new AbortController().signal; const redirected = await loadChatRoute( context, - { pathname: "/chat/wrong/not-the-name-12345678", search: "?draft=ship", hash: "" }, + { pathname: "/chat/main/not-the-name-12345678", search: "?draft=ship", hash: "" }, "chat", signal, ); @@ -84,7 +115,7 @@ describe("loadChatRoute", () => { hash: "", }, canonicalLocationSource: { - pathname: "/chat/wrong/not-the-name-12345678", + pathname: "/chat/main/not-the-name-12345678", search: "?draft=ship", hash: "", }, @@ -98,10 +129,8 @@ describe("loadChatRoute", () => { signal, ), ).resolves.toEqual({ kind: "session", sessionKey, draft: "ship", face: "chat" }); - expect(list).toHaveBeenCalledTimes(2); - expect(list).toHaveBeenCalledWith( - expect.objectContaining({ search: "12345678", limit: 20, archivedFilter: "all" }), - ); + expect(list).not.toHaveBeenCalled(); + expect(request).toHaveBeenCalledTimes(4); }); it("round-trips literal channel, peer, and cron keys without searching", async () => { @@ -128,9 +157,9 @@ describe("loadChatRoute", () => { expect(list).not.toHaveBeenCalled(); }); - it("queries the first uuid block so longer disambiguation links can resolve", async () => { + it("passes longer disambiguation prefixes directly to the gateway resolver", async () => { const target = row({ key: "agent:main:dashboard:12345678-0aaa-4000-8000-000000000001" }); - const { context, list } = contextFor(result([target])); + const { context, list, request } = contextFor(result([target])); await expect( loadChatRoute( context, @@ -145,141 +174,13 @@ describe("loadChatRoute", () => { face: "chat", shortId: "123456780a", }); - // Stored keys hold a hyphenated uuid, so "123456780a" is not a substring of anything - // the gateway searches. Only the first block survives as a needle; the full prefix is - // still applied per row, so the longer link resolves instead of 404ing. - expect(list).toHaveBeenCalledWith(expect.objectContaining({ search: "12345678" })); - expect(list).not.toHaveBeenCalledWith(expect.objectContaining({ search: "123456780a" })); - }); - - it("stops prefix pagination at the fixed bound and reports an incomplete result", async () => { - const target = row({ key: "agent:main:dashboard:12345678-0aaa-4000-8000-000000000001" }); - const { context, list } = contextFor(result([])); - for (let page = 0; page < 5; page += 1) { - list.mockResolvedValueOnce( - result(page === 0 ? [target] : [], { - hasMore: true, - nextOffset: (page + 1) * 20, - offset: page * 20, - }), - ); - } - await expect( - loadChatRoute( - context, - { pathname: "/dashboard/main/deploy-12345678", search: "", hash: "" }, - "dashboard", - new AbortController().signal, - ), - ).resolves.toMatchObject({ - kind: "ambiguous", - shortId: "12345678", - truncated: true, - candidates: [{ href: expect.stringContaining("123456780aaa40008000000000000001") }], + expect(list).not.toHaveBeenCalled(); + expect(request).toHaveBeenNthCalledWith(1, "sessions.resolve", { + shortId: "123456780a", + slugHint: "deploy-monitor", + agentId: "main", + allowMissing: true, }); - expect(list).toHaveBeenCalledTimes(5); - expect(list.mock.calls.map(([options]) => options?.offset)).toEqual([ - undefined, - 20, - 40, - 60, - 80, - ]); - }); - - it("does not reinterpret a bounded incomplete search with zero matches as literal", async () => { - const { context, list } = contextFor(result([])); - for (let page = 0; page < 5; page += 1) { - list.mockResolvedValueOnce( - result([], { - hasMore: true, - nextOffset: (page + 1) * 20, - offset: page * 20, - }), - ); - } - await expect( - loadChatRoute( - context, - { pathname: "/chat/main/deadbeef", search: "", hash: "" }, - "chat", - new AbortController().signal, - ), - ).resolves.toEqual({ - kind: "ambiguous", - shortId: "deadbeef", - candidates: [], - truncated: true, - face: "chat", - }); - expect(list).toHaveBeenCalledTimes(5); - }); - - it("stops paginating once the route navigation is aborted", async () => { - const { context, list } = contextFor(result([])); - const navigation = new AbortController(); - list.mockImplementation(async (options) => { - navigation.abort(); - return result([], { - hasMore: true, - nextOffset: (options?.offset ?? 0) + 20, - }); - }); - - await expect( - loadChatRoute( - context, - { pathname: "/chat/main/deadbeef", search: "", hash: "" }, - "chat", - navigation.signal, - ), - ).rejects.toMatchObject({ name: "AbortError" }); - expect(list).toHaveBeenCalledOnce(); - }); - - it("keeps a shared session lookup alive while another navigation still owns it", async () => { - const matching = row({ - key: "agent:main:dashboard:deadbeef-0000-4000-8000-000000000001", - displayName: "Shared lookup", - }); - const { context, list } = contextFor(result([])); - let releaseLookup: ((value: SessionsListResult) => void) | undefined; - list.mockImplementation( - () => - new Promise((resolve) => { - releaseLookup = resolve; - }), - ); - const staleNavigation = new AbortController(); - const activeNavigation = new AbortController(); - const location = { pathname: "/chat/main/deadbeef", search: "", hash: "" }; - const stale = loadChatRoute(context, location, "chat", staleNavigation.signal); - const active = loadChatRoute(context, location, "chat", activeNavigation.signal); - await vi.waitFor(() => expect(list).toHaveBeenCalledOnce()); - - staleNavigation.abort(); - releaseLookup?.(result([matching])); - - await expect(stale).rejects.toMatchObject({ name: "AbortError" }); - await expect(active).resolves.toMatchObject({ - kind: "session", - sessionKey: matching.key, - face: "chat", - }); - expect(list).toHaveBeenCalledOnce(); - }); - - it("stops after one unavailable session-list result", async () => { - const { context, list } = contextFor(null); - await expect( - loadChatRoute( - context, - { pathname: "/chat/main/deadbeef", search: "", hash: "" }, - "chat", - new AbortController().signal, - ), - ).rejects.toThrow("Session list unavailable while resolving URL"); - expect(list).toHaveBeenCalledTimes(1); }); it("builds distinct working links for ambiguous prefixes", async () => {