mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 22:04:45 +00:00
fix(control-ui): keep channel statuses responsive
Summary: - Keep Channels responsive by opening on cached/runtime snapshots, bounding live probes, and preventing stale slow probe results from replacing newer snapshots. - Reduce Control UI churn by scoping Nodes polling to the active Nodes tab, debouncing sessions.changed reconciliation, and bounding secondary chat/session refreshes. - Scope config schema analysis before section-limited renders so excluded root sections are not fully analyzed. Verification: - pnpm test ui/src/ui/app-channels.test.ts ui/src/ui/controllers/channels.test.ts ui/src/ui/app-settings.refresh-active-tab.node.test.ts ui/src/ui/app-gateway.sessions.node.test.ts ui/src/ui/app-lifecycle-connect.node.test.ts ui/src/ui/controllers/sessions.test.ts ui/src/ui/views/config.browser.test.ts src/gateway/server-methods/channels.status.test.ts src/gateway/control-ui.http.test.ts ui/src/ui/app-polling.node.test.ts ui/src/ui/app-gateway-chat-load.node.test.ts ui/src/ui/app-gateway.node.test.ts ui/src/ui/app-chat.test.ts ui/src/ui/app-render.helpers.node.test.ts ui/src/ui/app-lifecycle.node.test.ts - pnpm exec oxfmt --check --threads=1 <changed files> - git diff --check origin/main...HEAD - node scripts/run-oxlint.mjs --tsconfig config/tsconfig/oxlint.core.json <changed TypeScript files> - pnpm changed:lanes --json Note: local pnpm check:changed reached core lint and failed on src/gateway/server-methods/nodes.invoke-wake.test.ts, which is unchanged in this PR and already present on current origin/main; changed-file lint passed under the same repo wrapper.
This commit is contained in:
@@ -59,6 +59,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Control UI/performance: scope Nodes polling to the active Nodes tab, debounce stale session-list reconciliation, and bound chat-side session refreshes so long-running dashboards avoid background reload churn. Thanks @BunsDev.
|
||||
- Bonjour/Gateway: treat active ciao probing and fresh name-conflict renames as in-progress so the mDNS watchdog waits for probe settlement before retrying, preventing rapid re-advertise loops on Windows, WSL, and other multicast-hostile hosts. (#74778) Refs #74242. Thanks @fuller-stack-dev.
|
||||
- Providers/MiniMax: send a minimal Anthropic-compatible user fallback when message conversion filters a turn to an empty payload, so MiniMax M2.7 no longer returns `chat content is empty` after tool-heavy sessions. Fixes #74589. Thanks @neeravmakwana and @DerekEXS.
|
||||
- Tools/media: preserve implicit allow-all semantics from `tools.alsoAllow`-only policies when preconstructing built-in media generation and PDF tools, so configured media tools become live without forcing `tools.allow: ["*", ...]`. Fixes #77841. Thanks @trialanderrorstudios.
|
||||
@@ -396,6 +397,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway/performance: reuse the compatible plugin metadata snapshot across dashboard and channel agent turns so auto-enabled runtime config does not repeatedly rescan plugin metadata before provider calls. Thanks @shakkernerd.
|
||||
- Gateway/performance: reuse current plugin metadata for provider activation, auth/env candidate lookup, and bundle settings during dashboard and channel agent turns while keeping the configless secret-target cache unscoped and refusing stale unscoped reuse when plugin discovery roots differ. Thanks @shakkernerd.
|
||||
- Gateway/performance: avoid resolving plugin auto-enable metadata twice in one runtime config pass, reducing repeated dashboard turn metadata scans. Thanks @shakkernerd.
|
||||
- Control UI/performance: pre-scope config tab schemas before rendering, load Channels with cached/runtime status before manual probes, preserve channel rows through failed status summaries, and keep stale slow probes from replacing newer snapshots. Thanks @BunsDev.
|
||||
- Auth/providers: pass `config` and `workspaceDir` lookup context through to provider-id resolution so workspace-scoped auth aliases resolve correctly when no explicit alias map is supplied. Thanks @shakkernerd.
|
||||
- Gateway/diagnostics: add startup phase spans, active work labels, stale terminal bridge markers, and opt-in sync-I/O tracing in `pnpm gateway:watch` so slow Gateway turns are easier to attribute from logs and stability diagnostics.
|
||||
- QA/Mantis: add an opt-in Discord thread attachment before/after scenario that creates a real thread, calls `message.thread-reply` with `filePath`, and captures baseline/candidate screenshot evidence.
|
||||
|
||||
@@ -44,6 +44,11 @@ vi.mock("../../infra/channel-activity.js", () => ({
|
||||
|
||||
import { channelsHandlers } from "./channels.js";
|
||||
|
||||
function getSuccessPayload(respond: ReturnType<typeof vi.fn>): Record<string, unknown> {
|
||||
expect(respond).toHaveBeenCalledWith(true, expect.any(Object), undefined);
|
||||
return respond.mock.calls[0]?.[1] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function createOptions(
|
||||
params: Record<string, unknown>,
|
||||
overrides?: Partial<GatewayRequestHandlerOptions>,
|
||||
@@ -172,6 +177,55 @@ describe("channelsHandlers channels.status", () => {
|
||||
expect(probeArgs.cfg).toBe(autoEnabledConfig);
|
||||
});
|
||||
|
||||
it("preserves channel account rows when a live probe throws", async () => {
|
||||
const autoEnabledConfig = { autoEnabled: true };
|
||||
const probeAccount = vi.fn(async () => {
|
||||
throw new Error("probe failed");
|
||||
});
|
||||
const respond = vi.fn();
|
||||
mocks.applyPluginAutoEnable.mockReturnValue({ config: autoEnabledConfig, changes: [] });
|
||||
mocks.buildChannelAccountSnapshot.mockImplementation(async ({ accountId, probe }) => ({
|
||||
accountId,
|
||||
configured: true,
|
||||
probe,
|
||||
}));
|
||||
mocks.listChannelPlugins.mockReturnValue([
|
||||
{
|
||||
id: "whatsapp",
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
resolveAccount: () => ({}),
|
||||
isEnabled: () => true,
|
||||
isConfigured: async () => true,
|
||||
},
|
||||
status: {
|
||||
probeAccount,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
await channelsHandlers["channels.status"](
|
||||
createOptions({ probe: true, timeoutMs: 1000 }, { respond }),
|
||||
);
|
||||
|
||||
const payload = getSuccessPayload(respond);
|
||||
const channelAccounts = payload.channelAccounts as Record<
|
||||
string,
|
||||
Array<Record<string, unknown>>
|
||||
>;
|
||||
expect(channelAccounts.whatsapp).toEqual([
|
||||
expect.objectContaining({
|
||||
accountId: "default",
|
||||
lastError: expect.stringContaining("probe failed"),
|
||||
lastProbeAt: expect.any(Number),
|
||||
probe: expect.objectContaining({
|
||||
ok: false,
|
||||
error: expect.stringContaining("probe failed"),
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns a partial snapshot when a channel probe exceeds the status budget", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
@@ -211,6 +265,47 @@ describe("channelsHandlers channels.status", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to account-derived channel summaries when summary building fails", async () => {
|
||||
const autoEnabledConfig = { autoEnabled: true };
|
||||
const respond = vi.fn();
|
||||
mocks.applyPluginAutoEnable.mockReturnValue({ config: autoEnabledConfig, changes: [] });
|
||||
mocks.buildChannelAccountSnapshot.mockResolvedValue({
|
||||
accountId: "default",
|
||||
configured: true,
|
||||
});
|
||||
mocks.listChannelPlugins.mockReturnValue([
|
||||
{
|
||||
id: "whatsapp",
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
resolveAccount: () => ({}),
|
||||
isEnabled: () => true,
|
||||
isConfigured: async () => true,
|
||||
},
|
||||
status: {
|
||||
buildChannelSummary: async () => {
|
||||
throw new Error("summary failed");
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
await channelsHandlers["channels.status"](
|
||||
createOptions({ probe: false, timeoutMs: 1000 }, { respond }),
|
||||
);
|
||||
|
||||
const payload = getSuccessPayload(respond);
|
||||
expect(payload.channels).toEqual({
|
||||
whatsapp: expect.objectContaining({
|
||||
configured: true,
|
||||
lastError: expect.stringContaining("summary failed"),
|
||||
}),
|
||||
});
|
||||
expect(payload.channelAccounts).toEqual({
|
||||
whatsapp: [expect.objectContaining({ accountId: "default", configured: true })],
|
||||
});
|
||||
});
|
||||
|
||||
it("annotates unhealthy channel snapshots and includes event-loop health", async () => {
|
||||
const now = Date.now();
|
||||
mocks.applyPluginAutoEnable.mockReturnValue({ config: { autoEnabled: true }, changes: [] });
|
||||
|
||||
@@ -65,15 +65,16 @@ function channelStatusTimeoutPayload(step: string, timeoutMs: number): Record<st
|
||||
};
|
||||
}
|
||||
|
||||
async function runChannelStatusHook(params: {
|
||||
accountId: string;
|
||||
channelId: ChannelId;
|
||||
step: "audit" | "probe";
|
||||
type TimeoutRaceResult<T> =
|
||||
| { kind: "value"; value: T }
|
||||
| { kind: "error"; error: unknown }
|
||||
| { kind: "timeout" };
|
||||
|
||||
async function raceWithTimeout<T>(params: {
|
||||
timeoutMs: number;
|
||||
warnings: string[];
|
||||
run: () => Promise<unknown>;
|
||||
}): Promise<unknown> {
|
||||
const timeoutMs = Math.max(1, params.timeoutMs);
|
||||
run: () => Promise<T> | T;
|
||||
}): Promise<TimeoutRaceResult<T>> {
|
||||
const timeoutMs = params.timeoutMs;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const timeout = new Promise<{ kind: "timeout" }>((resolve) => {
|
||||
timer = setTimeout(() => resolve({ kind: "timeout" }), timeoutMs);
|
||||
@@ -93,6 +94,22 @@ async function runChannelStatusHook(params: {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function runChannelStatusHook(params: {
|
||||
accountId: string;
|
||||
channelId: ChannelId;
|
||||
step: "audit" | "probe";
|
||||
timeoutMs: number;
|
||||
warnings: string[];
|
||||
run: () => Promise<unknown>;
|
||||
}): Promise<unknown> {
|
||||
const timeoutMs = Math.max(1, params.timeoutMs);
|
||||
const result = await raceWithTimeout({
|
||||
timeoutMs,
|
||||
run: params.run,
|
||||
});
|
||||
if (result.kind === "value") {
|
||||
return result.value;
|
||||
}
|
||||
@@ -109,6 +126,46 @@ async function runChannelStatusHook(params: {
|
||||
};
|
||||
}
|
||||
|
||||
type ChannelStatusSummaryOutcome =
|
||||
| { ok: true; value: unknown }
|
||||
| { ok: false; error: string; timedOut?: boolean };
|
||||
|
||||
async function runChannelStatusSummary(params: {
|
||||
channelId: ChannelId;
|
||||
timeoutMs: number;
|
||||
warnings: string[];
|
||||
run: () => unknown;
|
||||
}): Promise<ChannelStatusSummaryOutcome> {
|
||||
const timeoutMs = Math.max(1, params.timeoutMs);
|
||||
const result = await raceWithTimeout({
|
||||
timeoutMs,
|
||||
run: params.run,
|
||||
});
|
||||
const warningPrefix = `${params.channelId} summary`;
|
||||
if (result.kind === "value") {
|
||||
return { ok: true, value: result.value };
|
||||
}
|
||||
if (result.kind === "timeout") {
|
||||
const error = `summary timed out after ${timeoutMs}ms`;
|
||||
params.warnings.push(`${warningPrefix} timed out after ${timeoutMs}ms`);
|
||||
return { ok: false, timedOut: true, error };
|
||||
}
|
||||
const message = formatForLog(result.error);
|
||||
params.warnings.push(`${warningPrefix} failed: ${message}`);
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
|
||||
function channelStatusFailureMessage(value: unknown): string | null {
|
||||
if (!value || typeof value !== "object") {
|
||||
return null;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.ok !== false || typeof record.error !== "string" || record.error.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return record.error;
|
||||
}
|
||||
|
||||
function resolveChannelsStatusTimeoutMs(params: { probe: boolean; timeoutMsRaw: unknown }): number {
|
||||
const fallback = params.probe ? CHANNEL_STATUS_MAX_TIMEOUT_MS : 10_000;
|
||||
if (typeof params.timeoutMsRaw !== "number" || !Number.isFinite(params.timeoutMsRaw)) {
|
||||
@@ -338,6 +395,11 @@ export const channelsHandlers: GatewayRequestHandlers = {
|
||||
probe: probeResult,
|
||||
audit: auditResult,
|
||||
});
|
||||
const hookError =
|
||||
channelStatusFailureMessage(auditResult) ?? channelStatusFailureMessage(probeResult);
|
||||
if (hookError && !snapshot.lastError) {
|
||||
snapshot.lastError = hookError;
|
||||
}
|
||||
if (lastProbeAt) {
|
||||
snapshot.lastProbeAt = lastProbeAt;
|
||||
}
|
||||
@@ -421,20 +483,30 @@ export const channelsHandlers: GatewayRequestHandlers = {
|
||||
await buildChannelAccounts(plugin.id);
|
||||
const fallbackAccount =
|
||||
resolvedAccounts[defaultAccountId] ?? plugin.config.resolveAccount(cfg, defaultAccountId);
|
||||
const summary = plugin.status?.buildChannelSummary
|
||||
? await plugin.status.buildChannelSummary({
|
||||
account: fallbackAccount,
|
||||
cfg,
|
||||
defaultAccountId,
|
||||
snapshot:
|
||||
defaultAccount ??
|
||||
({
|
||||
accountId: defaultAccountId,
|
||||
} as ChannelAccountSnapshot),
|
||||
})
|
||||
: {
|
||||
configured: defaultAccount?.configured ?? false,
|
||||
};
|
||||
const fallbackSummary = (lastError?: string) => ({
|
||||
configured: defaultAccount?.configured ?? false,
|
||||
...(lastError ? { lastError } : {}),
|
||||
});
|
||||
let summary: unknown = fallbackSummary();
|
||||
if (plugin.status?.buildChannelSummary) {
|
||||
const summaryResult = await runChannelStatusSummary({
|
||||
channelId: plugin.id,
|
||||
timeoutMs,
|
||||
warnings: statusWarnings,
|
||||
run: () =>
|
||||
plugin.status!.buildChannelSummary!({
|
||||
account: fallbackAccount,
|
||||
cfg,
|
||||
defaultAccountId,
|
||||
snapshot:
|
||||
defaultAccount ??
|
||||
({
|
||||
accountId: defaultAccountId,
|
||||
} as ChannelAccountSnapshot),
|
||||
}),
|
||||
});
|
||||
summary = summaryResult.ok ? summaryResult.value : fallbackSummary(summaryResult.error);
|
||||
}
|
||||
return { pluginId: plugin.id, summary, accounts, defaultAccountId };
|
||||
}),
|
||||
limit: probe ? CHANNEL_STATUS_PROBE_CONCURRENCY : plugins.length || 1,
|
||||
|
||||
@@ -576,9 +576,11 @@ describe("refreshChat", () => {
|
||||
"sessions.list",
|
||||
"sessions list payload",
|
||||
);
|
||||
expect(sessionsListPayload.activeMinutes).toBe(120);
|
||||
expect(sessionsListPayload.agentId).toBe("main");
|
||||
expect(sessionsListPayload.includeGlobal).toBe(true);
|
||||
expect(sessionsListPayload.includeUnknown).toBe(true);
|
||||
expect(sessionsListPayload.limit).toBe(100);
|
||||
expect(request).toHaveBeenCalledWith("models.list", { view: "configured" });
|
||||
const commandsListPayload = findRequestPayload(
|
||||
request as unknown as MockCallSource,
|
||||
|
||||
@@ -84,6 +84,7 @@ export type ChatAbortOptions = {
|
||||
};
|
||||
|
||||
export const CHAT_SESSIONS_ACTIVE_MINUTES = 120;
|
||||
export const CHAT_SESSIONS_REFRESH_LIMIT = 100;
|
||||
export {
|
||||
handleChatDraftChange,
|
||||
handleChatInputHistoryKey,
|
||||
@@ -768,8 +769,8 @@ export async function refreshChat(
|
||||
});
|
||||
const secondaryRefresh = Promise.allSettled([
|
||||
loadSessions(host as unknown as SessionsState, {
|
||||
activeMinutes: 0,
|
||||
limit: 0,
|
||||
activeMinutes: CHAT_SESSIONS_ACTIVE_MINUTES,
|
||||
limit: CHAT_SESSIONS_REFRESH_LIMIT,
|
||||
includeGlobal: true,
|
||||
includeUnknown: true,
|
||||
agentId: resolveAgentIdForSession(host) ?? undefined,
|
||||
|
||||
@@ -58,6 +58,7 @@ vi.mock("./gateway.ts", async (importOriginal) => {
|
||||
|
||||
vi.mock("./app-chat.ts", () => ({
|
||||
CHAT_SESSIONS_ACTIVE_MINUTES: 60,
|
||||
CHAT_SESSIONS_REFRESH_LIMIT: 100,
|
||||
clearPendingQueueItemsForRun: vi.fn(),
|
||||
flushChatQueueForEvent: vi.fn(),
|
||||
refreshChatAvatar: refreshChatAvatarMock,
|
||||
@@ -217,4 +218,14 @@ describe("connectGateway chat load startup work", () => {
|
||||
await vi.waitFor(() => expect(refreshActiveTabMock).toHaveBeenCalledWith(host));
|
||||
expect(refreshChatAvatarMock).toHaveBeenCalledWith(host);
|
||||
});
|
||||
|
||||
it("lets the active tab refresh own node and device loading after hello", async () => {
|
||||
const { host, client } = connectHost("overview");
|
||||
|
||||
client.emitHello();
|
||||
|
||||
await vi.waitFor(() => expect(refreshActiveTabMock).toHaveBeenCalledWith(host));
|
||||
expect(loadNodesMock).not.toHaveBeenCalled();
|
||||
expect(loadDevicesMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -450,6 +450,23 @@ describe("connectGateway", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("clears pending session reload timers when the active client closes", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { host, client } = connectHostGateway();
|
||||
const pendingReload = vi.fn();
|
||||
host.sessionsChangedReloadTimer = globalThis.setTimeout(pendingReload, 1_000);
|
||||
|
||||
client.emitClose({ code: 1005 });
|
||||
|
||||
expect(host.sessionsChangedReloadTimer).toBeNull();
|
||||
vi.advanceTimersByTime(1_000);
|
||||
expect(pendingReload).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves pending approval requests across reconnect", () => {
|
||||
const host = createHost();
|
||||
host.execApprovalQueue = [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment node
|
||||
import { afterAll, describe, expect, it, vi } from "vitest";
|
||||
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const loadSessionsMock = vi.fn();
|
||||
const loadChatHistoryMock = vi.fn();
|
||||
@@ -8,6 +8,7 @@ const handleChatEventMock = vi.fn(() => "idle");
|
||||
|
||||
vi.mock("./app-chat.ts", () => ({
|
||||
CHAT_SESSIONS_ACTIVE_MINUTES: 10,
|
||||
CHAT_SESSIONS_REFRESH_LIMIT: 25,
|
||||
clearPendingQueueItemsForRun: vi.fn(),
|
||||
flushChatQueueForEvent: vi.fn(),
|
||||
refreshChatAvatar: vi.fn(),
|
||||
@@ -96,7 +97,7 @@ function createHost() {
|
||||
},
|
||||
password: "",
|
||||
clientInstanceId: "instance-test",
|
||||
client: null,
|
||||
client: {},
|
||||
connected: true,
|
||||
hello: null,
|
||||
lastError: null,
|
||||
@@ -132,6 +133,10 @@ function createHost() {
|
||||
}
|
||||
|
||||
describe("handleGatewayEvent sessions.changed", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("scopes post-chat final session refreshes to the run's agent", () => {
|
||||
loadSessionsMock.mockReset();
|
||||
handleChatEventMock.mockReset().mockReturnValue("final");
|
||||
@@ -149,6 +154,7 @@ describe("handleGatewayEvent sessions.changed", () => {
|
||||
expect(loadSessionsMock).toHaveBeenCalledWith(host, {
|
||||
activeMinutes: 10,
|
||||
agentId: "ops",
|
||||
limit: 25,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -175,7 +181,8 @@ describe("handleGatewayEvent sessions.changed", () => {
|
||||
expect(loadSessionsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reloads sessions when a change event cannot be applied locally", () => {
|
||||
it("debounces session reloads when a change event cannot be applied locally", () => {
|
||||
vi.useFakeTimers();
|
||||
loadSessionsMock.mockReset();
|
||||
applySessionsChangedEventMock.mockReset().mockReturnValue({ applied: false });
|
||||
const host = createHost();
|
||||
@@ -187,9 +194,77 @@ describe("handleGatewayEvent sessions.changed", () => {
|
||||
seq: 1,
|
||||
});
|
||||
|
||||
expect(loadSessionsMock).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(4_999);
|
||||
expect(loadSessionsMock).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(loadSessionsMock).toHaveBeenCalledWith(host);
|
||||
});
|
||||
|
||||
it("coalesces unapplied session change reloads into one reconciliation", () => {
|
||||
vi.useFakeTimers();
|
||||
loadSessionsMock.mockReset();
|
||||
applySessionsChangedEventMock.mockReset().mockReturnValue({ applied: false });
|
||||
const host = createHost();
|
||||
|
||||
handleGatewayEvent(host, {
|
||||
type: "event",
|
||||
event: "sessions.changed",
|
||||
payload: { sessionKey: "agent:main:a", reason: "cleanup" },
|
||||
seq: 1,
|
||||
});
|
||||
vi.advanceTimersByTime(2_500);
|
||||
handleGatewayEvent(host, {
|
||||
type: "event",
|
||||
event: "sessions.changed",
|
||||
payload: { sessionKey: "agent:main:b", reason: "cleanup" },
|
||||
seq: 2,
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(4_999);
|
||||
expect(loadSessionsMock).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(loadSessionsMock).toHaveBeenCalledTimes(1);
|
||||
expect(loadSessionsMock).toHaveBeenCalledWith(host);
|
||||
});
|
||||
|
||||
it("skips a delayed session reload after the user returns to chat", () => {
|
||||
vi.useFakeTimers();
|
||||
loadSessionsMock.mockReset();
|
||||
applySessionsChangedEventMock.mockReset().mockReturnValue({ applied: false });
|
||||
const host = createHost();
|
||||
|
||||
handleGatewayEvent(host, {
|
||||
type: "event",
|
||||
event: "sessions.changed",
|
||||
payload: { sessionKey: "agent:main:main", reason: "cleanup" },
|
||||
seq: 1,
|
||||
});
|
||||
host.tab = "chat";
|
||||
vi.advanceTimersByTime(5_000);
|
||||
|
||||
expect(loadSessionsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips a delayed session reload after disconnect", () => {
|
||||
vi.useFakeTimers();
|
||||
loadSessionsMock.mockReset();
|
||||
applySessionsChangedEventMock.mockReset().mockReturnValue({ applied: false });
|
||||
const host = createHost();
|
||||
|
||||
handleGatewayEvent(host, {
|
||||
type: "event",
|
||||
event: "sessions.changed",
|
||||
payload: { sessionKey: "agent:main:main", reason: "cleanup" },
|
||||
seq: 1,
|
||||
});
|
||||
host.connected = false;
|
||||
host.client = null;
|
||||
vi.advanceTimersByTime(5_000);
|
||||
|
||||
expect(loadSessionsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not reload sessions for applied message-phase session patches to existing rows", () => {
|
||||
loadSessionsMock.mockReset();
|
||||
applySessionsChangedEventMock.mockReset().mockReturnValue({ applied: true, change: "updated" });
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import { ConnectErrorDetailCodes } from "../../../src/gateway/protocol/connect-error-details.js";
|
||||
import {
|
||||
CHAT_SESSIONS_ACTIVE_MINUTES,
|
||||
CHAT_SESSIONS_REFRESH_LIMIT,
|
||||
clearPendingQueueItemsForRun,
|
||||
flushChatQueueForEvent,
|
||||
refreshChatAvatar,
|
||||
@@ -45,7 +46,6 @@ import {
|
||||
removeExecApproval,
|
||||
} from "./controllers/exec-approval.ts";
|
||||
import { loadHealthState, type HealthState } from "./controllers/health.ts";
|
||||
import { loadNodes, type NodesState } from "./controllers/nodes.ts";
|
||||
import {
|
||||
applySessionsChangedEvent,
|
||||
loadSessions,
|
||||
@@ -110,6 +110,7 @@ type GatewayHost = {
|
||||
execApprovalError: string | null;
|
||||
updateAvailable: UpdateAvailable | null;
|
||||
reconcileWebPushState?: () => Promise<void> | void;
|
||||
sessionsChangedReloadTimer?: number | ReturnType<typeof globalThis.setTimeout> | null;
|
||||
};
|
||||
|
||||
type GatewayHostWithDeferredSessionMessageReload = GatewayHost & {
|
||||
@@ -133,6 +134,8 @@ type GatewayHostWithSideResults = GatewayHost & {
|
||||
chatSideResultTerminalRuns?: Set<string>;
|
||||
};
|
||||
|
||||
const SESSIONS_CHANGED_RELOAD_DEBOUNCE_MS = 5_000;
|
||||
|
||||
function enqueueApprovalRequest(host: GatewayHost, entry: ExecApprovalRequest | null) {
|
||||
if (!entry) {
|
||||
return;
|
||||
@@ -173,6 +176,29 @@ function isChatTurnSessionChangedPayload(payload: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function clearSessionsChangedReloadTimer(host: GatewayHost) {
|
||||
if (host.sessionsChangedReloadTimer == null) {
|
||||
return;
|
||||
}
|
||||
globalThis.clearTimeout(host.sessionsChangedReloadTimer);
|
||||
host.sessionsChangedReloadTimer = null;
|
||||
}
|
||||
|
||||
function shouldRunDeferredSessionsReload(host: GatewayHost): boolean {
|
||||
return host.connected && Boolean(host.client) && host.tab !== "chat";
|
||||
}
|
||||
|
||||
function scheduleSessionsChangedReload(host: GatewayHost) {
|
||||
clearSessionsChangedReloadTimer(host);
|
||||
host.sessionsChangedReloadTimer = globalThis.setTimeout(() => {
|
||||
host.sessionsChangedReloadTimer = null;
|
||||
if (!shouldRunDeferredSessionsReload(host)) {
|
||||
return;
|
||||
}
|
||||
void loadSessions(host as unknown as SessionsState);
|
||||
}, SESSIONS_CHANGED_RELOAD_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
type ConnectGatewayOptions = {
|
||||
reason?: "initial" | "seq-gap";
|
||||
};
|
||||
@@ -462,6 +488,7 @@ export function connectGateway(host: GatewayHost, options?: ConnectGatewayOption
|
||||
const reconnectReason = options?.reason ?? "initial";
|
||||
shutdownHost.pendingShutdownMessage = null;
|
||||
shutdownHost.resumeChatQueueAfterReconnect = false;
|
||||
clearSessionsChangedReloadTimer(host);
|
||||
host.lastError = null;
|
||||
host.lastErrorCode = null;
|
||||
host.hello = null;
|
||||
@@ -543,8 +570,6 @@ export function connectGateway(host: GatewayHost, options?: ConnectGatewayOption
|
||||
void refreshChatAvatar(host as unknown as Parameters<typeof refreshChatAvatar>[0]);
|
||||
}
|
||||
void loadHealthState(host as unknown as HealthState);
|
||||
void loadNodes(host as unknown as NodesState, { quiet: true });
|
||||
void loadDevices(host as unknown as DevicesState, { quiet: true });
|
||||
void loadAgentsThenRefreshActiveTab(host);
|
||||
// Re-run push reconciliation now that the gateway client is available.
|
||||
void host.reconcileWebPushState?.();
|
||||
@@ -555,6 +580,7 @@ export function connectGateway(host: GatewayHost, options?: ConnectGatewayOption
|
||||
return;
|
||||
}
|
||||
host.connected = false;
|
||||
clearSessionsChangedReloadTimer(host);
|
||||
// Code 1012 = Service Restart (expected during config saves, don't show as error)
|
||||
host.lastErrorCode =
|
||||
resolveGatewayErrorDetailCode(error) ??
|
||||
@@ -642,6 +668,7 @@ function handleTerminalChatEvent(
|
||||
void loadSessions(host as unknown as SessionsState, {
|
||||
activeMinutes: CHAT_SESSIONS_ACTIVE_MINUTES,
|
||||
agentId: resolveChatEventSessionListAgentId(host, payload),
|
||||
limit: CHAT_SESSIONS_REFRESH_LIMIT,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -832,7 +859,7 @@ function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) {
|
||||
if (result.applied || isChatTurnSessionChangedPayload(evt.payload)) {
|
||||
return;
|
||||
}
|
||||
void loadSessions(host as unknown as SessionsState);
|
||||
scheduleSessionsChangedReload(host);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,9 @@ vi.mock("./app-scroll.ts", () => ({
|
||||
}));
|
||||
|
||||
import { handleConnected } from "./app-lifecycle.ts";
|
||||
import { startNodesPolling } from "./app-polling.ts";
|
||||
|
||||
const startNodesPollingMock = vi.mocked(startNodesPolling);
|
||||
|
||||
function createDeferred() {
|
||||
let resolve: (() => void) | undefined;
|
||||
@@ -82,6 +85,7 @@ describe("handleConnected", () => {
|
||||
applySettingsFromUrlMock.mockReset();
|
||||
connectGatewayMock.mockReset();
|
||||
loadBootstrapMock.mockReset();
|
||||
startNodesPollingMock.mockReset();
|
||||
vi.stubGlobal("window", {
|
||||
addEventListener: vi.fn(),
|
||||
});
|
||||
@@ -129,4 +133,17 @@ describe("handleConnected", () => {
|
||||
loadBootstrapMock.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it("starts Nodes polling only when the Nodes tab is active on connect", () => {
|
||||
loadBootstrapMock.mockResolvedValue(undefined);
|
||||
const chatHost = createHost();
|
||||
|
||||
handleConnected(chatHost as never);
|
||||
expect(startNodesPollingMock).not.toHaveBeenCalled();
|
||||
|
||||
const nodesHost = createHost();
|
||||
nodesHost.tab = "nodes";
|
||||
handleConnected(nodesHost as never);
|
||||
expect(startNodesPollingMock).toHaveBeenCalledWith(nodesHost);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { handleDisconnected } from "./app-lifecycle.ts";
|
||||
|
||||
function createHost() {
|
||||
@@ -22,12 +22,17 @@ function createHost() {
|
||||
logsAutoFollow: false,
|
||||
logsAtBottom: true,
|
||||
logsEntries: [],
|
||||
sessionsChangedReloadTimer: null as number | ReturnType<typeof globalThis.setTimeout> | null,
|
||||
popStateHandler: vi.fn(),
|
||||
topbarObserver: { disconnect: vi.fn() } as unknown as ResizeObserver,
|
||||
};
|
||||
}
|
||||
|
||||
describe("handleDisconnected", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("stops and clears gateway client on teardown", () => {
|
||||
vi.stubGlobal("window", {
|
||||
removeEventListener: vi.fn(),
|
||||
@@ -49,4 +54,21 @@ describe("handleDisconnected", () => {
|
||||
removeSpy.mockRestore();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("clears pending session reload timers on teardown", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal("window", {
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
const host = createHost();
|
||||
const pendingReload = vi.fn();
|
||||
host.sessionsChangedReloadTimer = globalThis.setTimeout(pendingReload, 1_000);
|
||||
|
||||
handleDisconnected(host as unknown as Parameters<typeof handleDisconnected>[0]);
|
||||
|
||||
expect(host.sessionsChangedReloadTimer).toBeNull();
|
||||
vi.advanceTimersByTime(1_000);
|
||||
expect(pendingReload).not.toHaveBeenCalled();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,6 +52,7 @@ type LifecycleHost = {
|
||||
chatScrollFrame?: number | null;
|
||||
chatScrollTimeout?: number | null;
|
||||
logsScrollFrame?: number | null;
|
||||
sessionsChangedReloadTimer?: number | ReturnType<typeof globalThis.setTimeout> | null;
|
||||
controlUiTabPaintSeq?: number;
|
||||
controlUiResponsivenessObserver?: { disconnect: () => void } | null;
|
||||
popStateHandler: () => void;
|
||||
@@ -72,7 +73,9 @@ export function handleConnected(host: LifecycleHost) {
|
||||
}
|
||||
connectGateway(host as unknown as Parameters<typeof connectGateway>[0]);
|
||||
});
|
||||
startNodesPolling(host as unknown as Parameters<typeof startNodesPolling>[0]);
|
||||
if (host.tab === "nodes") {
|
||||
startNodesPolling(host as unknown as Parameters<typeof startNodesPolling>[0]);
|
||||
}
|
||||
if (host.tab === "logs") {
|
||||
startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[0]);
|
||||
}
|
||||
@@ -100,6 +103,14 @@ function clearHostTimeout(timeout: number | null | undefined) {
|
||||
}
|
||||
}
|
||||
|
||||
function clearHostGlobalTimeout(
|
||||
timeout: number | ReturnType<typeof globalThis.setTimeout> | null | undefined,
|
||||
) {
|
||||
if (timeout != null) {
|
||||
globalThis.clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export function handleDisconnected(host: LifecycleHost) {
|
||||
host.connectGeneration += 1;
|
||||
host.controlUiTabPaintSeq = (host.controlUiTabPaintSeq ?? 0) + 1;
|
||||
@@ -113,6 +124,8 @@ export function handleDisconnected(host: LifecycleHost) {
|
||||
host.logsScrollFrame = null;
|
||||
clearHostTimeout(host.chatScrollTimeout);
|
||||
host.chatScrollTimeout = null;
|
||||
clearHostGlobalTimeout(host.sessionsChangedReloadTimer);
|
||||
host.sessionsChangedReloadTimer = null;
|
||||
host.realtimeTalkSession?.stop();
|
||||
host.realtimeTalkSession = null;
|
||||
host.realtimeTalkActive = false;
|
||||
|
||||
62
ui/src/ui/app-polling.node.test.ts
Normal file
62
ui/src/ui/app-polling.node.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const loadNodesMock = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
|
||||
vi.mock("./controllers/debug.ts", () => ({
|
||||
loadDebug: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.mock("./controllers/logs.ts", () => ({
|
||||
loadLogs: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.mock("./controllers/nodes.ts", () => ({
|
||||
loadNodes: loadNodesMock,
|
||||
}));
|
||||
|
||||
const { NODES_ACTIVE_POLL_INTERVAL_MS, startNodesPolling, stopNodesPolling } =
|
||||
await import("./app-polling.ts");
|
||||
|
||||
function createHost() {
|
||||
return {
|
||||
client: {},
|
||||
connected: true,
|
||||
nodesPollInterval: null,
|
||||
logsPollInterval: null,
|
||||
debugPollInterval: null,
|
||||
tab: "overview",
|
||||
};
|
||||
}
|
||||
|
||||
describe("startNodesPolling", () => {
|
||||
let testHost: ReturnType<typeof createHost> | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (testHost) {
|
||||
stopNodesPolling(testHost as never);
|
||||
testHost = null;
|
||||
}
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
loadNodesMock.mockReset();
|
||||
});
|
||||
|
||||
it("does not poll nodes while another tab is active", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal("window", {
|
||||
clearInterval: globalThis.clearInterval,
|
||||
setInterval: globalThis.setInterval,
|
||||
});
|
||||
const host = createHost();
|
||||
testHost = host;
|
||||
|
||||
startNodesPolling(host as never);
|
||||
vi.advanceTimersByTime(NODES_ACTIVE_POLL_INTERVAL_MS);
|
||||
expect(loadNodesMock).not.toHaveBeenCalled();
|
||||
|
||||
host.tab = "nodes";
|
||||
vi.advanceTimersByTime(NODES_ACTIVE_POLL_INTERVAL_MS);
|
||||
expect(loadNodesMock).toHaveBeenCalledWith(host, { quiet: true });
|
||||
|
||||
stopNodesPolling(host as never);
|
||||
});
|
||||
});
|
||||
@@ -12,14 +12,18 @@ type PollingHost = {
|
||||
tab: string;
|
||||
};
|
||||
|
||||
export const NODES_ACTIVE_POLL_INTERVAL_MS = 30_000;
|
||||
|
||||
export function startNodesPolling(host: PollingHost) {
|
||||
if (host.nodesPollInterval != null) {
|
||||
return;
|
||||
}
|
||||
host.nodesPollInterval = window.setInterval(
|
||||
() => void loadNodes(host as unknown as NodesState, { quiet: true }),
|
||||
5000,
|
||||
);
|
||||
host.nodesPollInterval = window.setInterval(() => {
|
||||
if (host.tab !== "nodes") {
|
||||
return;
|
||||
}
|
||||
void loadNodes(host as unknown as NodesState, { quiet: true });
|
||||
}, NODES_ACTIVE_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
export function stopNodesPolling(host: PollingHost) {
|
||||
|
||||
@@ -17,6 +17,8 @@ const {
|
||||
}));
|
||||
|
||||
vi.mock("./app-chat.ts", () => ({
|
||||
CHAT_SESSIONS_ACTIVE_MINUTES: 120,
|
||||
CHAT_SESSIONS_REFRESH_LIMIT: 100,
|
||||
refreshChat: refreshChatMock,
|
||||
refreshChatAvatar: refreshChatAvatarMock,
|
||||
}));
|
||||
@@ -734,8 +736,8 @@ describe("createChatSession", () => {
|
||||
emitCommandHooks: true,
|
||||
},
|
||||
{
|
||||
activeMinutes: 0,
|
||||
limit: 0,
|
||||
activeMinutes: 120,
|
||||
limit: 100,
|
||||
includeGlobal: true,
|
||||
includeUnknown: true,
|
||||
showArchived: false,
|
||||
@@ -936,8 +938,8 @@ describe("switchChatSession", () => {
|
||||
});
|
||||
expect(loadChatHistoryMock).toHaveBeenCalledWith(state);
|
||||
expect(loadSessionsMock).toHaveBeenCalledWith(state, {
|
||||
activeMinutes: 0,
|
||||
limit: 0,
|
||||
activeMinutes: 120,
|
||||
limit: 100,
|
||||
includeGlobal: true,
|
||||
includeUnknown: true,
|
||||
showArchived: false,
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { t } from "../i18n/index.ts";
|
||||
import { refreshChat, refreshChatAvatar } from "./app-chat.ts";
|
||||
import {
|
||||
CHAT_SESSIONS_ACTIVE_MINUTES,
|
||||
CHAT_SESSIONS_REFRESH_LIMIT,
|
||||
refreshChat,
|
||||
refreshChatAvatar,
|
||||
} from "./app-chat.ts";
|
||||
import { syncUrlWithSessionKey } from "./app-settings.ts";
|
||||
import type { AppViewState } from "./app-view-state.ts";
|
||||
import {
|
||||
@@ -642,8 +647,8 @@ export async function createChatSession(state: AppViewState) {
|
||||
emitCommandHooks: parentSessionKey !== undefined ? true : undefined,
|
||||
},
|
||||
{
|
||||
activeMinutes: 0,
|
||||
limit: 0,
|
||||
activeMinutes: CHAT_SESSIONS_ACTIVE_MINUTES,
|
||||
limit: CHAT_SESSIONS_REFRESH_LIMIT,
|
||||
includeGlobal: true,
|
||||
includeUnknown: true,
|
||||
showArchived: state.sessionsShowArchived,
|
||||
@@ -674,8 +679,8 @@ export async function createChatSession(state: AppViewState) {
|
||||
|
||||
async function refreshSessionOptions(state: AppViewState) {
|
||||
await loadSessions(state as unknown as Parameters<typeof loadSessions>[0], {
|
||||
activeMinutes: 0,
|
||||
limit: 0,
|
||||
activeMinutes: CHAT_SESSIONS_ACTIVE_MINUTES,
|
||||
limit: CHAT_SESSIONS_REFRESH_LIMIT,
|
||||
includeGlobal: true,
|
||||
includeUnknown: true,
|
||||
showArchived: state.sessionsShowArchived,
|
||||
|
||||
@@ -2,7 +2,12 @@ import { html, nothing } from "lit";
|
||||
import { styleMap } from "lit/directives/style-map.js";
|
||||
import { t } from "../i18n/index.ts";
|
||||
import { getSafeLocalStorage } from "../local-storage.ts";
|
||||
import { hasAbortableSessionRun, refreshChat } from "./app-chat.ts";
|
||||
import {
|
||||
CHAT_SESSIONS_ACTIVE_MINUTES,
|
||||
CHAT_SESSIONS_REFRESH_LIMIT,
|
||||
hasAbortableSessionRun,
|
||||
refreshChat,
|
||||
} from "./app-chat.ts";
|
||||
import { DEFAULT_CRON_FORM } from "./app-defaults.ts";
|
||||
import { renderUsageTab } from "./app-render-usage-tab.ts";
|
||||
import {
|
||||
@@ -2516,8 +2521,8 @@ export function renderApp(state: AppViewState) {
|
||||
state.sessionsExpandedCheckpointKey = state.sessionKey;
|
||||
state.setTab("sessions" as import("./navigation.ts").Tab);
|
||||
void loadSessions(state, {
|
||||
activeMinutes: 0,
|
||||
limit: 0,
|
||||
activeMinutes: CHAT_SESSIONS_ACTIVE_MINUTES,
|
||||
limit: CHAT_SESSIONS_REFRESH_LIMIT,
|
||||
includeGlobal: true,
|
||||
includeUnknown: true,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment node
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type CronRunsLoadStatus = "ok" | "error" | "skipped";
|
||||
|
||||
@@ -55,11 +55,25 @@ const mocks = vi.hoisted(() => ({
|
||||
loadSessionsMock: vi.fn(async () => {}),
|
||||
loadSkillsMock: vi.fn(async () => {}),
|
||||
loadUsageMock: vi.fn(async () => {}),
|
||||
startDebugPollingMock: vi.fn(),
|
||||
startLogsPollingMock: vi.fn(),
|
||||
startNodesPollingMock: vi.fn(),
|
||||
stopDebugPollingMock: vi.fn(),
|
||||
stopLogsPollingMock: vi.fn(),
|
||||
stopNodesPollingMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./app-chat.ts", () => ({
|
||||
refreshChat: mocks.refreshChatMock,
|
||||
}));
|
||||
vi.mock("./app-polling.ts", () => ({
|
||||
startDebugPolling: mocks.startDebugPollingMock,
|
||||
startLogsPolling: mocks.startLogsPollingMock,
|
||||
startNodesPolling: mocks.startNodesPollingMock,
|
||||
stopDebugPolling: mocks.stopDebugPollingMock,
|
||||
stopLogsPolling: mocks.stopLogsPollingMock,
|
||||
stopNodesPolling: mocks.stopNodesPollingMock,
|
||||
}));
|
||||
vi.mock("./app-scroll.ts", () => ({
|
||||
scheduleChatScroll: mocks.scheduleChatScrollMock,
|
||||
scheduleLogsScroll: mocks.scheduleLogsScrollMock,
|
||||
@@ -120,7 +134,7 @@ vi.mock("./controllers/usage.ts", () => ({
|
||||
loadUsage: mocks.loadUsageMock,
|
||||
}));
|
||||
|
||||
import { refreshActiveTab, setTab } from "./app-settings.ts";
|
||||
import { loadChannelsTab, refreshActiveTab, setTab } from "./app-settings.ts";
|
||||
|
||||
function createHost() {
|
||||
return {
|
||||
@@ -141,6 +155,7 @@ function createHost() {
|
||||
updateComplete: Promise.resolve(),
|
||||
cronRunsScope: "all",
|
||||
cronRunsJobId: null as string | null,
|
||||
sessionsChangedReloadTimer: null as number | ReturnType<typeof globalThis.setTimeout> | null,
|
||||
sessionKey: "main",
|
||||
settings: {},
|
||||
basePath: "",
|
||||
@@ -184,6 +199,10 @@ describe("refreshActiveTab", () => {
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const expectCommonAgentsTabRefresh = (host: ReturnType<typeof createHost>) => {
|
||||
expect(mocks.loadAgentsMock).toHaveBeenCalledOnce();
|
||||
expect(mocks.loadConfigMock).toHaveBeenCalledOnce();
|
||||
@@ -239,6 +258,16 @@ describe("refreshActiveTab", () => {
|
||||
expect(mocks.loadAgentSkillsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loads the Channels tab without automatic live probes", async () => {
|
||||
const host = createHost();
|
||||
|
||||
await loadChannelsTab(host as never);
|
||||
|
||||
expect(mocks.loadChannelsMock).toHaveBeenCalledWith(host, false);
|
||||
expect(mocks.loadConfigSchemaMock).toHaveBeenCalledWith(host);
|
||||
expect(mocks.loadConfigMock).toHaveBeenCalledWith(host);
|
||||
});
|
||||
|
||||
it("refreshes logs tab by resetting bottom-follow and scheduling scroll", async () => {
|
||||
const host = createHost();
|
||||
host.tab = "logs";
|
||||
@@ -269,6 +298,26 @@ describe("refreshActiveTab", () => {
|
||||
sessions.resolve();
|
||||
});
|
||||
|
||||
it("starts node polling on Nodes tab entry and clears pending session reloads on tab changes", () => {
|
||||
vi.useFakeTimers();
|
||||
const host = createHost();
|
||||
host.tab = "overview";
|
||||
const pendingReload = vi.fn();
|
||||
host.sessionsChangedReloadTimer = globalThis.setTimeout(pendingReload, 1_000);
|
||||
|
||||
setTab(host as never, "nodes");
|
||||
|
||||
expect(host.sessionsChangedReloadTimer).toBeNull();
|
||||
expect(mocks.startNodesPollingMock).toHaveBeenCalledWith(host);
|
||||
expect(mocks.stopLogsPollingMock).toHaveBeenCalledWith(host);
|
||||
expect(mocks.stopDebugPollingMock).toHaveBeenCalledWith(host);
|
||||
vi.advanceTimersByTime(1_000);
|
||||
expect(pendingReload).not.toHaveBeenCalled();
|
||||
|
||||
setTab(host as never, "sessions");
|
||||
expect(mocks.stopNodesPollingMock).toHaveBeenCalledWith(host);
|
||||
});
|
||||
|
||||
it("does not wait for secondary overview refreshes before resolving", async () => {
|
||||
const host = createHost();
|
||||
host.tab = "overview";
|
||||
@@ -304,31 +353,24 @@ describe("refreshActiveTab", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders channels from the cheap snapshot before starting slow probes", async () => {
|
||||
it("renders channels from the cheap snapshot without waiting for config schema", async () => {
|
||||
const host = createHost();
|
||||
host.tab = "channels";
|
||||
const schema = createDeferred();
|
||||
const channelProbe = createDeferred();
|
||||
mocks.loadConfigSchemaMock.mockReturnValueOnce(schema.promise);
|
||||
mocks.loadChannelsMock.mockImplementation(async (_host, probe) => {
|
||||
if (probe) {
|
||||
await channelProbe.promise;
|
||||
}
|
||||
});
|
||||
|
||||
const refresh = refreshActiveTab(host as never);
|
||||
const outcome = await raceWithNextMacrotask(refresh);
|
||||
|
||||
expect(outcome).toBe("resolved");
|
||||
expect(mocks.loadChannelsMock.mock.calls.map(([, probe]) => probe)).toEqual([false, true]);
|
||||
expect(mocks.loadChannelsMock.mock.calls.map(([, probe]) => probe)).toEqual([false]);
|
||||
expect(mocks.loadConfigMock).toHaveBeenCalledOnce();
|
||||
expect(host.requestUpdate).not.toHaveBeenCalled();
|
||||
|
||||
schema.resolve();
|
||||
channelProbe.resolve();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(host.requestUpdate).toHaveBeenCalledTimes(2);
|
||||
expect(host.requestUpdate).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@ import { t } from "../i18n/index.ts";
|
||||
import { refreshChat } from "./app-chat.ts";
|
||||
import {
|
||||
startLogsPolling,
|
||||
startNodesPolling,
|
||||
stopLogsPolling,
|
||||
stopNodesPolling,
|
||||
startDebugPolling,
|
||||
stopDebugPolling,
|
||||
} from "./app-polling.ts";
|
||||
@@ -106,6 +108,7 @@ type SettingsHost = {
|
||||
controlUiTabPaintSeq?: number;
|
||||
controlUiOverviewRefreshSeq?: number;
|
||||
controlUiCronRefreshSeq?: number;
|
||||
sessionsChangedReloadTimer?: number | ReturnType<typeof globalThis.setTimeout> | null;
|
||||
dreamingStatusLoading: boolean;
|
||||
dreamingStatusError: string | null;
|
||||
dreamingStatus: import("./controllers/dreaming.js").DreamingStatus | null;
|
||||
@@ -549,6 +552,14 @@ export function setTabFromRoute(host: SettingsHost, next: Tab) {
|
||||
applyTabSelection(host, next, { refreshPolicy: "connected" });
|
||||
}
|
||||
|
||||
function clearPendingSessionsChangedReload(host: SettingsHost) {
|
||||
if (host.sessionsChangedReloadTimer == null) {
|
||||
return;
|
||||
}
|
||||
globalThis.clearTimeout(host.sessionsChangedReloadTimer);
|
||||
host.sessionsChangedReloadTimer = null;
|
||||
}
|
||||
|
||||
function updateBrowserHistory(url: URL, replace: boolean) {
|
||||
const history = typeof window === "undefined" ? undefined : window.history;
|
||||
if (!history) {
|
||||
@@ -569,6 +580,7 @@ function applyTabSelection(
|
||||
host.tab = next;
|
||||
if (prev !== next) {
|
||||
scheduleControlUiTabVisibleTiming(host, prev, next);
|
||||
clearPendingSessionsChangedReload(host);
|
||||
}
|
||||
|
||||
// Cleanup chat module state when navigating away from chat
|
||||
@@ -582,6 +594,9 @@ function applyTabSelection(
|
||||
(next === "logs" ? startLogsPolling : stopLogsPolling)(
|
||||
host as unknown as Parameters<typeof startLogsPolling>[0],
|
||||
);
|
||||
(next === "nodes" ? startNodesPolling : stopNodesPolling)(
|
||||
host as unknown as Parameters<typeof startNodesPolling>[0],
|
||||
);
|
||||
(next === "debug" ? startDebugPolling : stopDebugPolling)(
|
||||
host as unknown as Parameters<typeof startDebugPolling>[0],
|
||||
);
|
||||
@@ -839,7 +854,6 @@ export async function loadChannelsTab(host: SettingsHost) {
|
||||
const app = host as unknown as SettingsAppHost;
|
||||
void loadConfigSchema(app).finally(() => host.requestUpdate?.());
|
||||
await Promise.all([loadChannels(app, false), loadConfig(app)]);
|
||||
void loadChannels(app, true).finally(() => host.requestUpdate?.());
|
||||
}
|
||||
|
||||
export async function loadCron(host: SettingsHost) {
|
||||
|
||||
@@ -598,6 +598,7 @@ export class OpenClawApp extends LitElement {
|
||||
nodesPollInterval: number | null = null;
|
||||
logsPollInterval: number | null = null;
|
||||
debugPollInterval: number | null = null;
|
||||
sessionsChangedReloadTimer: number | ReturnType<typeof globalThis.setTimeout> | null = null;
|
||||
logsScrollFrame: number | null = null;
|
||||
controlUiResponsivenessObserver: { disconnect: () => void } | null = null;
|
||||
toolStreamById = new Map<string, ToolStreamEntry>();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { html } from "lit";
|
||||
import { repeat } from "lit/directives/repeat.js";
|
||||
import { CHAT_SESSIONS_ACTIVE_MINUTES, CHAT_SESSIONS_REFRESH_LIMIT } from "../app-chat.ts";
|
||||
import type { AppViewState } from "../app-view-state.ts";
|
||||
import { createChatModelOverride } from "../chat-model-ref.ts";
|
||||
import {
|
||||
@@ -138,8 +139,8 @@ function renderChatAgentSelect(
|
||||
|
||||
async function refreshSessionOptions(state: AppViewState) {
|
||||
await loadSessions(state as unknown as Parameters<typeof loadSessions>[0], {
|
||||
activeMinutes: 0,
|
||||
limit: 0,
|
||||
activeMinutes: CHAT_SESSIONS_ACTIVE_MINUTES,
|
||||
limit: CHAT_SESSIONS_REFRESH_LIMIT,
|
||||
includeGlobal: true,
|
||||
includeUnknown: true,
|
||||
showArchived: state.sessionsShowArchived,
|
||||
|
||||
@@ -15,6 +15,17 @@ function createDeferred<T>() {
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function createChannelsSnapshot(label: string): ChannelsStatusSnapshot {
|
||||
return {
|
||||
ts: Date.now(),
|
||||
channelOrder: ["test"],
|
||||
channelLabels: { test: label },
|
||||
channels: {},
|
||||
channelAccounts: {},
|
||||
channelDefaultAccountId: {},
|
||||
};
|
||||
}
|
||||
|
||||
function createState(): ChannelsState {
|
||||
return {
|
||||
client: {
|
||||
@@ -70,6 +81,34 @@ describe("channels controller WhatsApp wait", () => {
|
||||
});
|
||||
|
||||
describe("loadChannels", () => {
|
||||
it("keeps a stale slow probe from replacing a newer non-probe snapshot", async () => {
|
||||
const state = createState();
|
||||
const request = vi.mocked(state.client!.request);
|
||||
const slowProbe = createDeferred<ChannelsStatusSnapshot | null>();
|
||||
const fastRuntime = createDeferred<ChannelsStatusSnapshot | null>();
|
||||
request.mockImplementation(async (_method: string, params?: unknown) => {
|
||||
if ((params as { probe?: boolean } | undefined)?.probe) {
|
||||
return slowProbe.promise;
|
||||
}
|
||||
return fastRuntime.promise;
|
||||
});
|
||||
|
||||
const probeLoad = loadChannels(state, true, { softTimeoutMs: 1 });
|
||||
await probeLoad;
|
||||
const runtimeLoad = loadChannels(state, false);
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
|
||||
fastRuntime.resolve(createChannelsSnapshot("fresh"));
|
||||
await runtimeLoad;
|
||||
expect(state.channelsSnapshot?.channelLabels.test).toBe("fresh");
|
||||
|
||||
slowProbe.resolve(createChannelsSnapshot("stale"));
|
||||
await Promise.resolve();
|
||||
|
||||
expect(state.channelsSnapshot?.channelLabels.test).toBe("fresh");
|
||||
expect(state.channelsLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("returns after a soft timeout while preserving the stale snapshot", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
|
||||
@@ -23,10 +23,13 @@ export async function loadChannels(
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
if (state.channelsLoading) {
|
||||
if (state.channelsLoading && (!state.channelsLoadingProbe || probe)) {
|
||||
return;
|
||||
}
|
||||
const refreshSeq = (state.channelsRefreshSeq ?? 0) + 1;
|
||||
state.channelsRefreshSeq = refreshSeq;
|
||||
state.channelsLoading = true;
|
||||
state.channelsLoadingProbe = probe;
|
||||
state.channelsError = null;
|
||||
const refresh = (async () => {
|
||||
try {
|
||||
@@ -34,9 +37,15 @@ export async function loadChannels(
|
||||
probe,
|
||||
timeoutMs: 8000,
|
||||
});
|
||||
if (state.channelsRefreshSeq !== refreshSeq) {
|
||||
return;
|
||||
}
|
||||
state.channelsSnapshot = res;
|
||||
state.channelsLastSuccess = Date.now();
|
||||
} catch (err) {
|
||||
if (state.channelsRefreshSeq !== refreshSeq) {
|
||||
return;
|
||||
}
|
||||
if (isMissingOperatorReadScopeError(err)) {
|
||||
state.channelsSnapshot = null;
|
||||
state.channelsError = formatMissingOperatorReadScopeMessage("channel status");
|
||||
@@ -44,7 +53,10 @@ export async function loadChannels(
|
||||
state.channelsError = String(err);
|
||||
}
|
||||
} finally {
|
||||
state.channelsLoading = false;
|
||||
if (state.channelsRefreshSeq === refreshSeq) {
|
||||
state.channelsLoading = false;
|
||||
state.channelsLoadingProbe = null;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ export type ChannelsState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
channelsLoading: boolean;
|
||||
channelsLoadingProbe?: boolean | null;
|
||||
channelsRefreshSeq?: number;
|
||||
channelsSnapshot: ChannelsStatusSnapshot | null;
|
||||
channelsError: string | null;
|
||||
channelsLastSuccess: number | null;
|
||||
|
||||
@@ -401,6 +401,43 @@ describe("config view", () => {
|
||||
expect(content.scrollLeft).toBe(0);
|
||||
});
|
||||
|
||||
it("does not normalize off-scope schema sections for scoped config tabs", () => {
|
||||
const offScopeSchema = { type: "object" } as Record<string, unknown>;
|
||||
Object.defineProperty(offScopeSchema, "properties", {
|
||||
get() {
|
||||
throw new Error("off-scope schema was normalized");
|
||||
},
|
||||
});
|
||||
|
||||
const { container } = renderConfigView({
|
||||
activeSection: "channels",
|
||||
navRootLabel: "Communication",
|
||||
includeSections: ["channels"],
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
channels: {
|
||||
type: "object",
|
||||
properties: {
|
||||
telegram: { type: "string", title: "Telegram" },
|
||||
},
|
||||
},
|
||||
models: offScopeSchema,
|
||||
},
|
||||
},
|
||||
formValue: {
|
||||
channels: { telegram: "enabled" },
|
||||
models: {},
|
||||
},
|
||||
originalValue: {
|
||||
channels: { telegram: "enabled" },
|
||||
models: {},
|
||||
},
|
||||
});
|
||||
|
||||
expect(normalizedText(container)).toContain("Telegram");
|
||||
});
|
||||
|
||||
it("renders and wires the search field controls", () => {
|
||||
const container = document.createElement("div");
|
||||
const onSearchChange = vi.fn();
|
||||
|
||||
@@ -478,40 +478,23 @@ function scopeSchemaSections(
|
||||
const include = params.include;
|
||||
const exclude = params.exclude;
|
||||
const nextProps: Record<string, JsonSchema> = {};
|
||||
for (const [key, value] of Object.entries(schema.properties)) {
|
||||
for (const key of Object.keys(schema.properties)) {
|
||||
if (include && include.size > 0 && !include.has(key)) {
|
||||
continue;
|
||||
}
|
||||
if (exclude && exclude.size > 0 && exclude.has(key)) {
|
||||
continue;
|
||||
}
|
||||
nextProps[key] = value;
|
||||
nextProps[key] = schema.properties[key];
|
||||
}
|
||||
return { ...schema, properties: nextProps };
|
||||
}
|
||||
|
||||
function scopeUnsupportedPaths(
|
||||
unsupportedPaths: string[],
|
||||
params: { include?: ReadonlySet<string> | null; exclude?: ReadonlySet<string> | null },
|
||||
): string[] {
|
||||
const include = params.include;
|
||||
const exclude = params.exclude;
|
||||
if ((!include || include.size === 0) && (!exclude || exclude.size === 0)) {
|
||||
return unsupportedPaths;
|
||||
function asConfigSchema(value: unknown): JsonSchema | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return unsupportedPaths.filter((entry) => {
|
||||
if (entry === "<root>") {
|
||||
return true;
|
||||
}
|
||||
const [top] = entry.split(".");
|
||||
if (include && include.size > 0) {
|
||||
return include.has(top);
|
||||
}
|
||||
if (exclude && exclude.size > 0) {
|
||||
return !exclude.has(top);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return value as JsonSchema;
|
||||
}
|
||||
|
||||
function resolveSectionMeta(
|
||||
@@ -1177,11 +1160,8 @@ export function renderConfig(props: ConfigProps) {
|
||||
const includeVirtualSections = props.includeVirtualSections ?? true;
|
||||
const include = props.includeSections?.length ? new Set(props.includeSections) : null;
|
||||
const exclude = props.excludeSections?.length ? new Set(props.excludeSections) : null;
|
||||
const rawAnalysis = analyzeConfigSchema(props.schema);
|
||||
const analysis = {
|
||||
schema: scopeSchemaSections(rawAnalysis.schema, { include, exclude }),
|
||||
unsupportedPaths: scopeUnsupportedPaths(rawAnalysis.unsupportedPaths, { include, exclude }),
|
||||
};
|
||||
const scopedSchema = scopeSchemaSections(asConfigSchema(props.schema), { include, exclude });
|
||||
const analysis = analyzeConfigSchema(scopedSchema);
|
||||
const formUnsafe = analysis.schema ? analysis.unsupportedPaths.length > 0 : false;
|
||||
const rawAvailable = props.rawAvailable ?? true;
|
||||
const formMode = showModeToggle && rawAvailable ? props.formMode : "form";
|
||||
|
||||
Reference in New Issue
Block a user