mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
1609ae9b62
commit
0b85f9c174
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,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 };
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user