feat: resolve short session references in the gateway (#120512)

* feat(gateway): resolve short session references

* refactor(ui): delegate short session resolution to gateway

* fix: complete short session resolver integration

* fix(gateway): filter deleted-agent short sessions

* fix(ui): support older gateway short links

* docs(web): qualify short-link candidate limits for older gateways

* refactor(protocol): split session resolve schema

* fix(gateway): filter session resolver visibility
This commit is contained in:
Peter Steinberger
2026-08-08 12:52:06 -07:00
committed by GitHub
parent 1609ae9b62
commit 0b85f9c174
22 changed files with 933 additions and 296 deletions
@@ -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"
+1 -1
View File
@@ -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.
+6 -3
View File
@@ -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=<sessionKey>` are accepted only at the application boundary as a
+1 -1
View File
@@ -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
@@ -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<typeof SessionsResolveParamsSchema>;
@@ -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<typeof SessionsListParamsSchema>;
export type SessionsCleanupParams = Static<typeof SessionsCleanupParamsSchema>;
export type SessionsPreviewParams = Static<typeof SessionsPreviewParamsSchema>;
export type SessionsDescribeParams = Static<typeof SessionsDescribeParamsSchema>;
export type SessionsResolveParams = Static<typeof SessionsResolveParamsSchema>;
export type SessionsSearchParams = Static<typeof SessionsSearchParamsSchema>;
export type SessionsSearchHit = Static<typeof SessionsSearchHitSchema>;
export type SessionsSearchResult = Static<typeof SessionsSearchResultSchema>;
+3 -1
View File
@@ -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;
@@ -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,
@@ -88,6 +88,7 @@ interface EmbeddedGatewayRuntime {
};
resolveSessionKeyFromResolveParams: (opts: {
cfg: OpenClawConfig;
client: null;
p: SessionsResolveParams;
}) => Promise<SessionsResolveResult>;
loadSessionEntry: (
@@ -215,6 +216,7 @@ async function handleSessionsResolve(params: Record<string, unknown>) {
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<string, unknown>) {
if ("missing" in resolved) {
return { ok: false };
}
if ("ambiguous" in resolved) {
return { ok: false, candidates: resolved.candidates };
}
return { ok: true, key: resolved.key };
}
+6 -2
View File
@@ -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 }) => {
+3 -4
View File
@@ -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") {
+2
View File
@@ -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",
@@ -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();
+7 -1
View File
@@ -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<typeof resolveSessionKeyFromResolveParamsWithClient>[0];
const resolveSessionKeyFromResolveParams = (
params: Omit<ResolveParams, "client"> & { client?: ResolveParams["client"] },
) => resolveSessionKeyFromResolveParamsWithClient({ client: null, ...params });
describe("resolveSessionKeyFromResolveParams store canonicalization", () => {
const freshUpdatedAt = () => Date.now();
+160 -1
View File
@@ -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<typeof resolveSessionKeyFromResolveParamsWithClient>[0];
const resolveSessionKeyFromResolveParams = (
params: Omit<ResolveParams, "client"> & { 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({
+118 -7
View File
@@ -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<string, SessionEntry>;
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<string, SessionEntry>;
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<SessionsResolveResult> {
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,
+1 -42
View File
@@ -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,
@@ -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<SessionPathTarget, { kind: "short" }>,
signal: AbortSignal,
): Promise<ShortSessionListFallbackResolution> {
const matches = new Map<string, GatewaySessionRow>();
const shortId = target.shortId.toLowerCase().replaceAll("-", "");
let offset = 0;
for (let page = 0; ; page += 1) {
signal.throwIfAborted();
const result = await context.sessions.list({
agentId: target.agentId,
archivedFilter: "all",
includeDerivedTitles: true,
limit: SESSION_REF_SEARCH_LIMIT,
search: shortId.slice(0, 8),
...(offset > 0 ? { offset } : {}),
});
signal.throwIfAborted();
if (!result) {
throw new Error("Session list unavailable while resolving URL.");
}
for (const session of result.sessions) {
if (sessionKeyUuid(session.key)?.startsWith(shortId)) {
matches.set(session.key, session);
}
}
const sessions = [...matches.values()];
if (sessions.length > 1) {
return narrowBySlugHint(
{ kind: "ambiguous", sessions, truncated: result.hasMore === true },
target.slugHint,
);
}
if (result.hasMore !== true) {
const session = sessions[0];
return session ? { kind: "unique", session } : { kind: "not-found" };
}
if (page === SESSION_REF_SEARCH_MAX_PAGES - 1) {
return { kind: "ambiguous", sessions, truncated: true };
}
const nextOffset = result.nextOffset ?? offset + result.sessions.length;
if (nextOffset <= offset) {
return { kind: "ambiguous", sessions, truncated: true };
}
offset = nextOffset;
}
}
@@ -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<SessionPathTarget, { kind: "short" }>,
signal: AbortSignal,
): Promise<SessionReferenceResolution> {
const client = await waitForGatewayClient(context.gateway, signal);
signal.throwIfAborted();
let result: SessionsResolveWireResult;
try {
result = await client.request<SessionsResolveWireResult>("sessions.resolve", {
shortId: target.shortId,
...(target.slugHint ? { slugHint: target.slugHint } : {}),
agentId: target.agentId,
allowMissing: true,
});
} catch (error) {
if (!isPriorGatewayShortIdRejection(error)) {
throw error;
}
return resolveShortSessionReferenceWithListFallback(context, target, signal);
}
signal.throwIfAborted();
const candidates = result.ok ? [{ key: result.key }] : result.candidates;
if (!candidates?.length) {
return { kind: "not-found" };
}
const rows = (
await Promise.all(
candidates.map(async ({ key }) => {
const described = await client.request<{ session?: GatewaySessionRow | null }>(
"sessions.describe",
{ key },
);
return described.session ?? null;
}),
)
).filter((row): row is GatewaySessionRow => row !== null);
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 };
}
+30 -54
View File
@@ -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 });
}
+162 -17
View File
@@ -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<string, unknown>) => {
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<typeof sessionNavigationTarget>) {
@@ -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<typeof snapshot.client>;
const request = (snapshot.client as { request?: unknown } | null)?.request;
snapshot.client = { request } as NonNullable<typeof snapshot.client>;
},
},
{
@@ -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<typeof context.gateway.snapshot.hello>;
@@ -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 () => {
+49 -148
View File
@@ -33,7 +33,30 @@ function result(
}
function contextFor(listResult: SessionsListResult | null, mainKey = "main") {
const client = {};
const request = vi.fn(async (method: string, params: Record<string, unknown>) => {
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<SessionsListResult>((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 () => {