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