mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
aeee426180
* feat(control-ui): persistent Ask OpenClaw companion with global toggle The custodian surface now behaves like the persistent machine-wide agent it already is on the Gateway: the session id persists in localStorage so a reopened surface rebinds to the live engine (wizard and approval state survive close/reopen), the durable transcript is refetched when a surface opens or the gateway reconnects (idle-gated so active question/wizard cards are never clobbered), and the panel toggles from anywhere via the shared panel-toggle contract, a command-palette action, and an admin-gated lobster chrome button. One server-side line: the openclaw.chat owner-mismatch rejection now carries the existing structured session-invalidated details so persisted clients re-mint their id from a closed code instead of matching error prose. No gateway events, no protocol schema changes, no polling. Splits (session-identity/variant modules, session-lifecycle and panel-toggle test files) keep the touched files under the max-lines ratchet. * fix(control-ui): coerce custodian toggle detail without a type assertion The assertion-safety ratchet holds custodian-panel.ts at zero uncommented assertions; parse the toggle CustomEvent detail through the canonical record-coerce guard and literal narrowing instead of casting. * fix(control-ui): delete unused CustodianPanelToggleDetail export The record-coerce toggle parsing left the exported type without a production consumer; the deadcode gate rightly flags it. The palette test keeps a local shape. * test(control-ui): select the palette custodian item via keyboard Async session-search results can reflow the palette list mid-click on slow CI runners, silently dropping the positional click; keyboard selection of the asserted-active item is atomic against reflow. Also stage the reopen wait (panel section, then text) for sharper failure localization. * fix(control-ui): project live wizard state on rejoin and scope-gate the toggles Address both ClawSweeper P1 findings. The welcome-only rejoin of an existing session now routes through engine.decorateRejoinReply (the existing ChatWizardHost projection), so a reconnecting client re-renders the live wizard/question controls the session still awaits; the stale welcome question only fills in when no interaction is live. The chrome button, palette action, and deferred panel loading now use the scope-aware canCallGatewayMethod gate (operator.admin) that the session store already used, so advertised-but-read-scoped clients see nothing. * test(control-ui): fix the cloud-workers e2e flake at both roots The mocked config.get stayed frozen at the empty initial config while patch responses advanced, so a config-store reconciliation refetch could flap the snapshot to empty and saveProfile silently dropped the next save; the mock now stays consistent before each patch resolution. Also give waitForRequest an opt-in after-cursor: it is satisfied by any prior same-method request and returns the latest match, so a second wait could assert against the stale earlier request; the cloud-workers waits pin it (15x green locally, previously failing 1-in-3). * fix(ci): cover rejoin projection in sibling engine mocks; bump startup baseline The greeting-welcome and session-ownership suites' engine mocks now export decorateRejoinReply like the handler requires. The Control UI startup-JS baseline moves 337511 -> 338920 B via the documented update command: the shell chrome toggle, palette action, and scope-aware gating are genuine startup surface (~1.4 KiB gzip, within the committed ceiling). * fix(control-ui): settle interrupted structured replies and racing turns on rejoin Address both ClawSweeper reconnect P1s. A submitted question/wizard reply with an unknown outcome now triggers a full session rejoin on reconnect instead of being blocked by its own uncertainty flag: the Gateway projects whether the answer was consumed and which control is live. A restored persisted id also arms a one-shot rejoin barrier: the welcome-only request queues behind any in-flight turn on the Gateway's per-session queue, so a post-response history refresh deterministically surfaces rows a racing turn persisted after the initial fetch. The open-agent handoff moved to custodian-navigation (its owner) to keep the store under the size cap. Live-Gateway proof (isolated state dir, real gpt-5.6-luna turns): video and screenshots on the PR. * test(control-ui): reopen via the chrome toggle in the custodian e2e The palette click-through composition proved timing-flaky on loaded CI runners in three different ways while adding no coverage: the palette action's dispatch is pinned by the palette unit test and the event-opens- panel path by the chrome-toggle step. Keep the gated palette entry assertion + screenshot; reopen through the chrome path. * fix(control-ui): keep the agent-handoff path helper module-local The store now routes through performCustodianAgentHandoff, leaving the path builder without external callers; the deadcode gate rightly flags the export. * fix(control-ui): run the rejoin barrier even when a live control projects The racing-history refresh happens before the reply/control message is appended, so skipping it for projected wizard/question rejoins had no purpose and lost rows a turn persisted while the page was closed mid- wizard. Regression covers the live-step rejoin reconciling racing rows.
344 lines
12 KiB
TypeScript
344 lines
12 KiB
TypeScript
/* @vitest-environment jsdom */
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { createDeferred } from "../../../test/helpers/promise.js";
|
|
import type { GatewayBrowserClient } from "../api/gateway.ts";
|
|
import type { SessionsListResult } from "../api/types.ts";
|
|
import type { RouteId } from "../app-route-paths.ts";
|
|
import type {
|
|
ApplicationContext,
|
|
ApplicationGateway,
|
|
ApplicationGatewaySnapshot,
|
|
} from "../app/context.ts";
|
|
import { createApplicationContextProvider } from "../test-helpers/application-context.ts";
|
|
import { installDialogPolyfill } from "../test-helpers/modal-dialog.ts";
|
|
import { CommandPalette } from "./command-palette.ts";
|
|
import {
|
|
CUSTODIAN_PANEL_TOGGLE_EVENT,
|
|
DESKTOP_PANEL_TOGGLE_EVENT,
|
|
type DesktopPanelToggleDetail,
|
|
} from "./panel-toggle-contract.ts";
|
|
|
|
type CustodianPanelToggleDetail = { open?: boolean };
|
|
|
|
type GatewayHarness = {
|
|
gateway: ApplicationGateway;
|
|
setConnected: (connected: boolean) => void;
|
|
};
|
|
|
|
function createGateway(connected: boolean): GatewayHarness {
|
|
const client = {} as GatewayBrowserClient;
|
|
let snapshot: ApplicationGatewaySnapshot = {
|
|
client,
|
|
phase: connected ? "connected" : "reconnecting",
|
|
offlineStable: false,
|
|
canvasPluginSurfaceUrl: null,
|
|
hello: null,
|
|
assistantAgentId: "main",
|
|
sessionKey: "main",
|
|
lastError: null,
|
|
lastErrorCode: null,
|
|
};
|
|
const listeners = new Set<(next: ApplicationGatewaySnapshot) => void>();
|
|
const gateway = {
|
|
get snapshot() {
|
|
return snapshot;
|
|
},
|
|
connection: { gatewayUrl: "ws://localhost", token: "", bootstrapToken: "", password: "" },
|
|
eventLog: [],
|
|
connect: () => undefined,
|
|
setSessionKey: () => undefined,
|
|
start: () => undefined,
|
|
stop: () => undefined,
|
|
subscribe(listener) {
|
|
listeners.add(listener);
|
|
return () => listeners.delete(listener);
|
|
},
|
|
subscribeEventLog: () => () => undefined,
|
|
subscribeEvents: () => () => undefined,
|
|
} satisfies ApplicationGateway;
|
|
return {
|
|
gateway,
|
|
setConnected(nextConnected) {
|
|
snapshot = {
|
|
...snapshot,
|
|
phase: nextConnected ? "connected" : "reconnecting",
|
|
};
|
|
for (const listener of listeners) {
|
|
listener(snapshot);
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
function createContext(
|
|
gateway: ApplicationGateway,
|
|
list: ApplicationContext<RouteId>["sessions"]["list"],
|
|
): ApplicationContext<RouteId> {
|
|
return {
|
|
gateway,
|
|
sessions: {
|
|
list,
|
|
},
|
|
} as unknown as ApplicationContext<RouteId>;
|
|
}
|
|
|
|
function createSessionResult(key: string, displayName: string): SessionsListResult {
|
|
return {
|
|
ts: 1,
|
|
path: "",
|
|
count: 1,
|
|
defaults: {},
|
|
sessions: [{ key, kind: "direct", displayName, updatedAt: 1 }],
|
|
} as SessionsListResult;
|
|
}
|
|
|
|
async function mountPalette(context: ApplicationContext<RouteId>) {
|
|
const provider = createApplicationContextProvider(context);
|
|
const palette = document.createElement("openclaw-command-palette") as CommandPalette;
|
|
palette.onNavigate = vi.fn();
|
|
palette.onSelectSession = vi.fn();
|
|
provider.append(palette);
|
|
document.body.append(provider);
|
|
await palette.updateComplete;
|
|
return { palette, provider };
|
|
}
|
|
|
|
async function enterQuery(palette: CommandPalette, query: string) {
|
|
palette.openPalette();
|
|
await palette.updateComplete;
|
|
const input = palette.querySelector<HTMLInputElement>(".cmd-palette__input");
|
|
if (!input) {
|
|
throw new Error("Expected command palette input");
|
|
}
|
|
input.value = query;
|
|
input.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
|
|
await palette.updateComplete;
|
|
}
|
|
|
|
describe("CommandPalette lifecycle", () => {
|
|
let restoreDialogPolyfill: () => void;
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
restoreDialogPolyfill = installDialogPolyfill();
|
|
});
|
|
|
|
afterEach(() => {
|
|
document.body.replaceChildren();
|
|
restoreDialogPolyfill();
|
|
vi.useRealTimers();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("closes and clears its query before a retained element reconnects", async () => {
|
|
const { gateway } = createGateway(true);
|
|
const list = vi.fn(async () => createSessionResult("agent:main:old", "Old chat"));
|
|
const { palette, provider } = await mountPalette(createContext(gateway, list));
|
|
await enterQuery(palette, "old");
|
|
await vi.advanceTimersByTimeAsync(250);
|
|
await palette.updateComplete;
|
|
expect(palette.textContent).toContain("Old chat");
|
|
|
|
palette.remove();
|
|
provider.append(palette);
|
|
const modal = palette.querySelector("openclaw-modal-dialog");
|
|
const dialog = modal?.shadowRoot
|
|
?.querySelector("wa-dialog")
|
|
?.shadowRoot?.querySelector("dialog");
|
|
expect(dialog?.open).toBe(false);
|
|
await palette.updateComplete;
|
|
|
|
expect(palette.querySelector("dialog")).toBeNull();
|
|
palette.openPalette();
|
|
await palette.updateComplete;
|
|
expect(palette.querySelector<HTMLInputElement>(".cmd-palette__input")?.value).toBe("");
|
|
expect(palette.textContent).not.toContain("Old chat");
|
|
});
|
|
|
|
it("retries the pending query after the gateway reconnects", async () => {
|
|
const harness = createGateway(true);
|
|
const stale = createDeferred<SessionsListResult | null>();
|
|
const list = vi
|
|
.fn<ApplicationContext<RouteId>["sessions"]["list"]>()
|
|
.mockImplementationOnce(() => stale.promise)
|
|
.mockResolvedValueOnce(createSessionResult("agent:main:retry", "Retry chat"));
|
|
const { palette } = await mountPalette(createContext(harness.gateway, list));
|
|
await enterQuery(palette, "retry");
|
|
await vi.advanceTimersByTimeAsync(250);
|
|
expect(list).toHaveBeenCalledOnce();
|
|
|
|
harness.setConnected(false);
|
|
stale.resolve(createSessionResult("agent:main:stale", "Stale chat"));
|
|
await Promise.resolve();
|
|
expect(palette.textContent).not.toContain("Stale chat");
|
|
|
|
harness.setConnected(true);
|
|
await palette.updateComplete;
|
|
await vi.advanceTimersByTimeAsync(250);
|
|
await palette.updateComplete;
|
|
|
|
expect(list).toHaveBeenCalledTimes(2);
|
|
expect(list).toHaveBeenLastCalledWith(expect.objectContaining({ search: "retry" }));
|
|
expect(palette.textContent).toContain("Retry chat");
|
|
});
|
|
|
|
it("drops an old provider response and searches the replacement context", async () => {
|
|
const initial = createGateway(true);
|
|
const replacement = createGateway(true);
|
|
const stale = createDeferred<SessionsListResult | null>();
|
|
const initialList = vi.fn(() => stale.promise);
|
|
const replacementList = vi.fn(async () =>
|
|
createSessionResult("agent:main:fresh", "Fresh chat"),
|
|
);
|
|
const { palette, provider } = await mountPalette(createContext(initial.gateway, initialList));
|
|
await enterQuery(palette, "chat");
|
|
await vi.advanceTimersByTimeAsync(250);
|
|
expect(initialList).toHaveBeenCalledOnce();
|
|
|
|
stale.resolve(createSessionResult("agent:main:stale", "Stale chat"));
|
|
provider.setContext(createContext(replacement.gateway, replacementList));
|
|
await palette.updateComplete;
|
|
await vi.advanceTimersByTimeAsync(250);
|
|
await palette.updateComplete;
|
|
|
|
expect(replacementList).toHaveBeenCalledOnce();
|
|
expect(palette.textContent).toContain("Fresh chat");
|
|
expect(palette.textContent).not.toContain("Stale chat");
|
|
});
|
|
|
|
it("shows a search failure instead of a false empty result", async () => {
|
|
const { gateway } = createGateway(true);
|
|
const list = vi
|
|
.fn<ApplicationContext<RouteId>["sessions"]["list"]>()
|
|
.mockRejectedValueOnce(new Error("store needs doctor migration"))
|
|
.mockResolvedValueOnce(createSessionResult("agent:main:zz", "Recovered chat"));
|
|
const { palette } = await mountPalette(createContext(gateway, list));
|
|
// The query matches no navigation item, so a swallowed search failure
|
|
// would render the plain "No results" empty state.
|
|
await enterQuery(palette, "zzz-unmatched");
|
|
await vi.advanceTimersByTimeAsync(250);
|
|
await palette.updateComplete;
|
|
|
|
expect(list).toHaveBeenCalledOnce();
|
|
expect(palette.textContent).toContain("Chat search failed");
|
|
expect(palette.textContent).not.toContain("No results");
|
|
|
|
// A new keystroke clears the failure state and retries cleanly.
|
|
await enterQuery(palette, "zz");
|
|
await palette.updateComplete;
|
|
expect(palette.textContent).not.toContain("Chat search failed");
|
|
await vi.advanceTimersByTimeAsync(250);
|
|
await palette.updateComplete;
|
|
expect(palette.textContent).toContain("Recovered chat");
|
|
});
|
|
|
|
it("navigates to the plugin manager from search", async () => {
|
|
const { gateway } = createGateway(true);
|
|
const { palette } = await mountPalette(
|
|
createContext(
|
|
gateway,
|
|
vi.fn(async () => createSessionResult("agent:main:test", "Test")),
|
|
),
|
|
);
|
|
await enterQuery(palette, "plugins");
|
|
|
|
const item = palette.querySelector<HTMLButtonElement>("#cmd-palette-option-nav-plugins");
|
|
expect(item?.textContent).toContain("Plugins");
|
|
item?.click();
|
|
|
|
expect(palette.onNavigate).toHaveBeenCalledWith("plugins");
|
|
});
|
|
|
|
it.each([
|
|
{ available: true, expectedCount: 1 },
|
|
{ available: false, expectedCount: 0 },
|
|
])(
|
|
"shows the desktop action only when availability is $available",
|
|
async ({ available, expectedCount }) => {
|
|
const { gateway } = createGateway(true);
|
|
const { palette } = await mountPalette(
|
|
createContext(
|
|
gateway,
|
|
vi.fn(async () => createSessionResult("agent:main:test", "Test")),
|
|
),
|
|
);
|
|
palette.desktopAvailable = available;
|
|
await enterQuery(palette, "desktop");
|
|
|
|
expect(palette.querySelectorAll("#cmd-palette-option-panel-desktop")).toHaveLength(
|
|
expectedCount,
|
|
);
|
|
},
|
|
);
|
|
|
|
it("opens the desktop panel from its palette action", async () => {
|
|
const { gateway } = createGateway(true);
|
|
const { palette } = await mountPalette(
|
|
createContext(
|
|
gateway,
|
|
vi.fn(async () => createSessionResult("agent:main:test", "Test")),
|
|
),
|
|
);
|
|
palette.desktopAvailable = true;
|
|
await enterQuery(palette, "desktop");
|
|
const events: CustomEvent<DesktopPanelToggleDetail>[] = [];
|
|
const listener = (event: Event) => events.push(event as CustomEvent<DesktopPanelToggleDetail>);
|
|
window.addEventListener(DESKTOP_PANEL_TOGGLE_EVENT, listener);
|
|
try {
|
|
palette.querySelector<HTMLElement>("#cmd-palette-option-panel-desktop")?.click();
|
|
} finally {
|
|
window.removeEventListener(DESKTOP_PANEL_TOGGLE_EVENT, listener);
|
|
}
|
|
|
|
expect(events).toHaveLength(1);
|
|
expect(events[0]?.detail).toEqual({ open: true });
|
|
});
|
|
|
|
it.each([
|
|
{ available: true, expectedCount: 1 },
|
|
{ available: false, expectedCount: 0 },
|
|
])(
|
|
"shows Ask OpenClaw only when availability is $available",
|
|
async ({ available, expectedCount }) => {
|
|
const { gateway } = createGateway(true);
|
|
const { palette } = await mountPalette(
|
|
createContext(
|
|
gateway,
|
|
vi.fn(async () => createSessionResult("agent:main:test", "Test")),
|
|
),
|
|
);
|
|
palette.custodianAvailable = available;
|
|
await enterQuery(palette, "openclaw");
|
|
|
|
expect(palette.querySelectorAll("#cmd-palette-option-panel-custodian")).toHaveLength(
|
|
expectedCount,
|
|
);
|
|
},
|
|
);
|
|
|
|
it("opens Ask OpenClaw from its palette action", async () => {
|
|
const { gateway } = createGateway(true);
|
|
const { palette } = await mountPalette(
|
|
createContext(
|
|
gateway,
|
|
vi.fn(async () => createSessionResult("agent:main:test", "Test")),
|
|
),
|
|
);
|
|
palette.custodianAvailable = true;
|
|
await enterQuery(palette, "openclaw");
|
|
const events: CustomEvent<CustodianPanelToggleDetail>[] = [];
|
|
const listener = (event: Event) =>
|
|
events.push(event as CustomEvent<CustodianPanelToggleDetail>);
|
|
window.addEventListener(CUSTODIAN_PANEL_TOGGLE_EVENT, listener);
|
|
try {
|
|
palette.querySelector<HTMLElement>("#cmd-palette-option-panel-custodian")?.click();
|
|
} finally {
|
|
window.removeEventListener(CUSTODIAN_PANEL_TOGGLE_EVENT, listener);
|
|
}
|
|
|
|
expect(events).toHaveLength(1);
|
|
expect(events[0]?.detail).toEqual({ open: true });
|
|
});
|
|
});
|