Files
openclaw/src/gateway/server.sessions.face.test.ts
T
Peter Steinberger 8b66fc103d feat(ui): durable session board face and dashboards index (#114262)
* feat(ui): durable session board face and dashboards index

Board face lived only in client-side boardSessionViews, capped at 50 entries,
so the preference never followed the user to another device, evicted as
sessions accumulated, and could not be seen as a set.

Persist it as SessionEntry.boardFace, which rides the existing entry_json blob
and so needs no SQLite schema change or version bump. Expose it on the session
list row and add it to the sessions.patch write-scope allowlist alongside label,
pinned, and archived: setting your own view preference is user-level chat
organization, not policy. Unknown patch fields still fail closed to
operator.admin.

Generic navigation now reads the stored face, so the sidebar and session list
open a thread on the face you left it on. boardSessionViews keeps only
activeTabId and reopenDockByTab, which are genuinely per-device.

Add /dashboards listing threads whose preferred face is dashboard. Filtering
runs server-side in filterSessionEntries before pagination, because the client
holds only a capped page and a client-side filter would silently omit
dashboards.

* test(protocol): assert the pre-rename face param is rejected

The gateway-protocol validator test still passed the pre-rename 'face' key,
which the closed schema rejects. Use boardFace, and pin the old name as a
negative case so it cannot silently return.

* chore(protocol): regenerate Swift bindings and docs map for boardFace

Adding boardFace to the sessions schema changes two committed generated
artifacts: the Swift gateway models (pnpm protocol:gen:swift) and the docs map
(pnpm docs:map:gen), which now lists the dashboards index section.
2026-07-27 00:35:34 -04:00

85 lines
2.6 KiB
TypeScript

/** Gateway durable session-face behavior. */
import { expect, test } from "vitest";
import { rpcReq, writeSessionStore } from "./test-helpers.js";
import {
directSessionReq,
setupGatewaySessionsTestHarness,
} from "./test/server-sessions.test-helpers.js";
const { createSessionStoreDir, openClient } = setupGatewaySessionsTestHarness();
test("a write-scoped face patch is visible to another client", async () => {
await createSessionStoreDir();
await writeSessionStore({
entries: {
main: { sessionId: "sess-main", updatedAt: Date.now() },
},
});
const firstClient = await openClient({ scopes: ["operator.read", "operator.write"] });
try {
const patched = await rpcReq<{ ok: true; entry: { boardFace?: string } }>(
firstClient.ws,
"sessions.patch",
{ key: "agent:main:main", boardFace: "dashboard" },
);
expect(patched.ok).toBe(true);
expect(patched.payload?.entry.boardFace).toBe("dashboard");
const unknownField = await rpcReq(firstClient.ws, "sessions.patch", {
key: "agent:main:main",
futureFace: "dashboard",
});
expect(unknownField.ok).toBe(false);
expect(unknownField.error?.message).toContain("missing scope: operator.admin");
} finally {
firstClient.ws.close();
}
const secondClient = await openClient({ scopes: ["operator.read"] });
try {
const listed = await rpcReq<{ sessions: Array<{ key: string; boardFace?: string }> }>(
secondClient.ws,
"sessions.list",
{ boardFace: "dashboard" },
);
expect(listed.ok).toBe(true);
expect(listed.payload?.sessions).toMatchObject([
{ key: "agent:main:main", boardFace: "dashboard" },
]);
} finally {
secondClient.ws.close();
}
});
test("sessions.list applies face filtering before pagination", async () => {
await createSessionStoreDir();
const now = Date.now();
await writeSessionStore({
entries: {
...Object.fromEntries(
Array.from({ length: 51 }, (_, index) => [
`chat-${index}`,
{ sessionId: `sess-chat-${index}`, updatedAt: now - index },
]),
),
dashboard: {
sessionId: "sess-dashboard",
updatedAt: now - 10_000,
boardFace: "dashboard",
},
},
});
const listed = await directSessionReq<{
sessions: Array<{ key: string; boardFace?: string }>;
totalCount: number;
}>("sessions.list", { boardFace: "dashboard", limit: 50 });
expect(listed.ok).toBe(true);
expect(listed.payload?.totalCount).toBe(1);
expect(listed.payload?.sessions).toEqual([
expect.objectContaining({ key: "agent:main:dashboard", boardFace: "dashboard" }),
]);
});