feat: push session list updates over websocket

This commit is contained in:
Tyler Yust
2026-03-12 01:22:24 -07:00
parent c0e5e8db22
commit 761e5ce5f8
8 changed files with 223 additions and 6 deletions

View File

@@ -0,0 +1,114 @@
import { describe, expect, it, vi } from "vitest";
const loadSessionsMock = vi.fn();
vi.mock("./app-chat.ts", () => ({
CHAT_SESSIONS_ACTIVE_MINUTES: 10,
flushChatQueueForEvent: vi.fn(),
}));
vi.mock("./app-settings.ts", () => ({
applySettings: vi.fn(),
loadCron: vi.fn(),
refreshActiveTab: vi.fn(),
setLastActiveSessionKey: vi.fn(),
}));
vi.mock("./app-tool-stream.ts", () => ({
handleAgentEvent: vi.fn(),
resetToolStream: vi.fn(),
}));
vi.mock("./controllers/agents.ts", () => ({
loadAgents: vi.fn(),
loadToolsCatalog: vi.fn(),
}));
vi.mock("./controllers/assistant-identity.ts", () => ({
loadAssistantIdentity: vi.fn(),
}));
vi.mock("./controllers/chat.ts", () => ({
loadChatHistory: vi.fn(),
handleChatEvent: vi.fn(() => "idle"),
}));
vi.mock("./controllers/devices.ts", () => ({
loadDevices: vi.fn(),
}));
vi.mock("./controllers/exec-approval.ts", () => ({
addExecApproval: vi.fn(),
parseExecApprovalRequested: vi.fn(() => null),
parseExecApprovalResolved: vi.fn(() => null),
removeExecApproval: vi.fn(),
}));
vi.mock("./controllers/nodes.ts", () => ({
loadNodes: vi.fn(),
}));
vi.mock("./controllers/sessions.ts", () => ({
loadSessions: loadSessionsMock,
subscribeSessions: vi.fn(),
}));
vi.mock("./gateway.ts", () => ({
GatewayBrowserClient: class {},
resolveGatewayErrorDetailCode: () => null,
}));
const { handleGatewayEvent } = await import("./app-gateway.ts");
function createHost() {
return {
settings: {
gatewayUrl: "ws://127.0.0.1:18789",
token: "",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "system",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
navCollapsed: false,
navGroupsCollapsed: {},
},
password: "",
clientInstanceId: "instance-test",
client: null,
connected: true,
hello: null,
lastError: null,
lastErrorCode: null,
eventLogBuffer: [],
eventLog: [],
tab: "overview",
presenceEntries: [],
presenceError: null,
presenceStatus: null,
agentsLoading: false,
agentsList: null,
agentsError: null,
toolsCatalogLoading: false,
toolsCatalogError: null,
toolsCatalogResult: null,
debugHealth: null,
assistantName: "OpenClaw",
assistantAvatar: null,
assistantAgentId: null,
serverVersion: null,
sessionKey: "main",
chatRunId: null,
refreshSessionsAfterChat: new Set<string>(),
execApprovalQueue: [],
execApprovalError: null,
updateAvailable: null,
} as Parameters<typeof handleGatewayEvent>[0];
}
describe("handleGatewayEvent sessions.changed", () => {
it("reloads sessions when the gateway pushes a sessions.changed event", () => {
loadSessionsMock.mockReset();
const host = createHost();
handleGatewayEvent(host, {
event: "sessions.changed",
payload: { sessionKey: "agent:main:main", reason: "patch" },
seq: 1,
});
expect(loadSessionsMock).toHaveBeenCalledTimes(1);
expect(loadSessionsMock).toHaveBeenCalledWith(host);
});
});

View File

@@ -28,7 +28,7 @@ import {
} from "./controllers/exec-approval.ts";
import { loadHealthState } from "./controllers/health.ts";
import { loadNodes } from "./controllers/nodes.ts";
import { loadSessions } from "./controllers/sessions.ts";
import { loadSessions, subscribeSessions } from "./controllers/sessions.ts";
import {
resolveGatewayErrorDetailCode,
type GatewayEventFrame,
@@ -220,6 +220,7 @@ export function connectGateway(host: GatewayHost) {
(host as unknown as { chatStream: string | null }).chatStream = null;
(host as unknown as { chatStreamStartedAt: number | null }).chatStreamStartedAt = null;
resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]);
void subscribeSessions(host as unknown as OpenClawApp);
void loadAssistantIdentity(host as unknown as OpenClawApp);
void loadAgents(host as unknown as OpenClawApp);
void loadHealthState(host as unknown as OpenClawApp);
@@ -368,6 +369,11 @@ function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) {
return;
}
if (evt.event === "sessions.changed") {
void loadSessions(host as unknown as OpenClawApp);
return;
}
if (evt.event === "cron" && host.tab === "cron") {
void loadCron(host as unknown as Parameters<typeof loadCron>[0]);
}

View File

@@ -1,8 +1,21 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { deleteSession, deleteSessionAndRefresh, type SessionsState } from "./sessions.ts";
import {
deleteSession,
deleteSessionAndRefresh,
subscribeSessions,
type SessionsState,
} from "./sessions.ts";
type RequestFn = (method: string, params?: unknown) => Promise<unknown>;
if (!("window" in globalThis)) {
Object.assign(globalThis, {
window: {
confirm: () => false,
},
});
}
function createState(request: RequestFn, overrides: Partial<SessionsState> = {}): SessionsState {
return {
client: { request } as unknown as SessionsState["client"],
@@ -22,6 +35,18 @@ afterEach(() => {
vi.restoreAllMocks();
});
describe("subscribeSessions", () => {
it("registers for session change events", async () => {
const request = vi.fn(async () => ({ subscribed: true }));
const state = createState(request);
await subscribeSessions(state);
expect(request).toHaveBeenCalledWith("sessions.subscribe", {});
expect(state.sessionsError).toBeNull();
});
});
describe("deleteSessionAndRefresh", () => {
it("refreshes sessions after a successful delete", async () => {
const request = vi.fn(async (method: string) => {

View File

@@ -14,6 +14,17 @@ export type SessionsState = {
sessionsIncludeUnknown: boolean;
};
export async function subscribeSessions(state: SessionsState) {
if (!state.client || !state.connected) {
return;
}
try {
await state.client.request("sessions.subscribe", {});
} catch (err) {
state.sessionsError = String(err);
}
}
export async function loadSessions(
state: SessionsState,
overrides?: {