From 455e33a2fa910e659db76e4f65bcb3b4941fdb80 Mon Sep 17 00:00:00 2001 From: George Pickett Date: Mon, 9 Mar 2026 10:50:27 -0700 Subject: [PATCH] better connection reliability --- src/app/api/intents/agent-wait/route.ts | 8 +- src/app/api/intents/cron-run/route.ts | 8 +- src/app/api/runtime/disconnect/route.ts | 2 + src/app/api/studio/route.ts | 52 ++++-- src/app/page.tsx | 61 +++--- .../agents/components/AgentChatPanel.tsx | 9 + .../agents/components/ConnectionPanel.tsx | 6 +- .../components/GatewayConnectScreen.tsx | 7 +- src/lib/controlplane/intent-route.ts | 10 +- src/lib/controlplane/openclaw-adapter.ts | 26 ++- src/lib/controlplane/runtime.ts | 31 +++- src/lib/studio/settings.ts | 8 + src/lib/studio/useStudioGatewaySettings.ts | 21 ++- tests/unit/agentChatPanel-scroll.test.ts | 98 ++++++++-- tests/unit/agentFleetHydration.test.ts | 25 ++- .../agentFleetHydrationDerivation.test.ts | 1 + tests/unit/connectionPanel-close.test.ts | 15 ++ tests/unit/controlPlaneRuntime.test.ts | 100 ++++++++++ tests/unit/intentRoutes.test.ts | 21 ++- tests/unit/openclawAdapter.test.ts | 74 +++++++- tests/unit/studioBootstrapOperation.test.ts | 2 + tests/unit/studioBootstrapWorkflow.test.ts | 2 + tests/unit/studioSettingsRoute.test.ts | 10 +- .../unit/studioSettingsRouteReconnect.test.ts | 165 +++++++++++++++++ tests/unit/useStudioGatewaySettings.test.ts | 174 ++++++++++++++++++ 25 files changed, 850 insertions(+), 86 deletions(-) create mode 100644 tests/unit/studioSettingsRouteReconnect.test.ts create mode 100644 tests/unit/useStudioGatewaySettings.test.ts diff --git a/src/app/api/intents/agent-wait/route.ts b/src/app/api/intents/agent-wait/route.ts index 301a698..56b6618 100644 --- a/src/app/api/intents/agent-wait/route.ts +++ b/src/app/api/intents/agent-wait/route.ts @@ -1,4 +1,8 @@ -import { parseIntentBody, executeGatewayIntent } from "@/lib/controlplane/intent-route"; +import { + parseIntentBody, + executeGatewayIntent, + LONG_RUNNING_GATEWAY_INTENT_TIMEOUT_MS, +} from "@/lib/controlplane/intent-route"; export const runtime = "nodejs"; @@ -18,5 +22,7 @@ export async function POST(request: Request) { return executeGatewayIntent("agent.wait", { runId, ...(typeof timeoutMs === "number" ? { timeoutMs } : {}), + }, { + timeoutMs: typeof timeoutMs === "number" ? timeoutMs : LONG_RUNNING_GATEWAY_INTENT_TIMEOUT_MS, }); } diff --git a/src/app/api/intents/cron-run/route.ts b/src/app/api/intents/cron-run/route.ts index fbfa8f3..7a730f9 100644 --- a/src/app/api/intents/cron-run/route.ts +++ b/src/app/api/intents/cron-run/route.ts @@ -1,6 +1,10 @@ import { NextResponse } from "next/server"; -import { executeGatewayIntent, parseIntentBody } from "@/lib/controlplane/intent-route"; +import { + executeGatewayIntent, + LONG_RUNNING_GATEWAY_INTENT_TIMEOUT_MS, + parseIntentBody, +} from "@/lib/controlplane/intent-route"; export const runtime = "nodejs"; @@ -18,5 +22,7 @@ export async function POST(request: Request) { return await executeGatewayIntent("cron.run", { id, mode: "force", + }, { + timeoutMs: LONG_RUNNING_GATEWAY_INTENT_TIMEOUT_MS, }); } diff --git a/src/app/api/runtime/disconnect/route.ts b/src/app/api/runtime/disconnect/route.ts index 8a2e32b..f07c807 100644 --- a/src/app/api/runtime/disconnect/route.ts +++ b/src/app/api/runtime/disconnect/route.ts @@ -2,11 +2,13 @@ import { NextResponse } from "next/server"; import { deriveRuntimeFreshness } from "@/lib/controlplane/degraded-read"; import { peekControlPlaneRuntime } from "@/lib/controlplane/runtime"; +import { applyStudioSettingsPatch } from "@/lib/studio/settings-store"; export const runtime = "nodejs"; export async function POST() { try { + applyStudioSettingsPatch({ gatewayAutoStart: false }); const controlPlane = peekControlPlaneRuntime(); if (!controlPlane) { const summary = { diff --git a/src/app/api/studio/route.ts b/src/app/api/studio/route.ts index 02bbc63..87d83b7 100644 --- a/src/app/api/studio/route.ts +++ b/src/app/api/studio/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from "next/server"; import { type StudioSettingsPatch } from "@/lib/studio/settings"; import { defaultStudioInstallContext } from "@/lib/studio/install-context"; import { + getControlPlaneRuntime, isStudioDomainApiModeEnabled, peekControlPlaneRuntime, } from "@/lib/controlplane/runtime"; @@ -45,11 +46,15 @@ const gatewaySettingsChanged = ( return left.url !== right.url || left.token !== right.token; }; +const hasGatewayConfiguration = (settings: ReturnType) => { + const gateway = normalizeGatewaySettings(settings); + return Boolean(gateway.url && gateway.token); +}; + const reconnectRuntimeForGatewaySettingsChange = async ( previous: ReturnType, next: ReturnType ): Promise => { - if (!gatewaySettingsChanged(previous, next)) return null; if (!isStudioDomainApiModeEnabled()) { return { attempted: false, @@ -57,20 +62,42 @@ const reconnectRuntimeForGatewaySettingsChange = async ( reason: "domain_api_mode_disabled", }; } - const runtime = peekControlPlaneRuntime(); - if (!runtime) { - return { - attempted: false, - restarted: false, - reason: "runtime_not_initialized", - }; - } + const runtime = peekControlPlaneRuntime() ?? getControlPlaneRuntime(); const previousStatus = runtime.connectionStatus(); if (previousStatus === "stopped") { + if (!hasGatewayConfiguration(next)) { + return { + attempted: false, + restarted: false, + reason: "gateway_not_configured", + previousStatus, + }; + } + try { + await runtime.ensureStarted({ force: true }); + return { + attempted: true, + restarted: true, + previousStatus, + }; + } catch (error) { + const message = + error instanceof Error ? error.message : "controlplane_reconnect_failed"; + console.error("Failed to reconnect control-plane runtime after gateway settings update.", error); + return { + attempted: true, + restarted: false, + previousStatus, + error: message, + }; + } + } + if (!gatewaySettingsChanged(previous, next)) return null; + if (!hasGatewayConfiguration(next)) { return { attempted: false, restarted: false, - reason: "runtime_stopped", + reason: "gateway_not_configured", previousStatus, }; } @@ -135,7 +162,10 @@ export async function PUT(request: Request) { return NextResponse.json({ error: "Invalid settings payload." }, { status: 400 }); } const previousSettings = loadStudioSettings(); - const nextSettings = applyStudioSettingsPatch(body); + const nextSettings = applyStudioSettingsPatch({ + ...body, + gatewayAutoStart: true, + }); const runtimeReconnect = await reconnectRuntimeForGatewaySettingsChange( previousSettings, nextSettings diff --git a/src/app/page.tsx b/src/app/page.tsx index 777bfb9..54f4839 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -230,6 +230,7 @@ const AgentStudioPage = () => { testResult, saving: gatewaySaving, testing: gatewayTesting, + disconnecting: gatewayDisconnecting, saveSettings, testConnection, disconnect, @@ -838,12 +839,7 @@ const AgentStudioPage = () => { } }, []); - const { - loadSummarySnapshot, - loadAgentHistory, - loadMoreAgentHistory, - clearHistoryInFlight, - } = useRuntimeSyncController({ + const { loadAgentHistory, loadMoreAgentHistory, clearHistoryInFlight } = useRuntimeSyncController({ status: coreStatus, gatewayUrl, agents, @@ -1302,7 +1298,6 @@ const AgentStudioPage = () => { }, onRuntimeStatus: (event) => { applyRuntimeStatusEvent(event); - void loadSummarySnapshot(); }, resumeKey: runtimeStreamResumeKey ?? undefined, }); @@ -1427,6 +1422,7 @@ const AgentStudioPage = () => { testResult={testResult} saving={gatewaySaving} testing={gatewayTesting} + disconnecting={gatewayDisconnecting} onGatewayUrlChange={setGatewayUrl} onTokenChange={setToken} onUseLocalDefaults={useLocalGatewayDefaults} @@ -1471,28 +1467,35 @@ const AgentStudioPage = () => { />
{connectionPanelVisible ? ( -
-
- void saveSettings()} - onTestConnection={() => void testConnection()} - onDisconnect={() => void disconnect()} - onClose={() => setShowConnectionPanel(false)} - /> +
+
setShowConnectionPanel(false)} + /> +
+
+ void saveSettings()} + onTestConnection={() => void testConnection()} + onDisconnect={() => void disconnect()} + onClose={() => setShowConnectionPanel(false)} + /> +
) : null} diff --git a/src/features/agents/components/AgentChatPanel.tsx b/src/features/agents/components/AgentChatPanel.tsx index a3a0d65..d281590 100644 --- a/src/features/agents/components/AgentChatPanel.tsx +++ b/src/features/agents/components/AgentChatPanel.tsx @@ -624,6 +624,7 @@ const AgentChatTranscript = memo(function AgentChatTranscript({ liveAssistantCharCount, liveThinkingCharCount, runStartedAt, + scrollToBottomOnOpenKey, scrollToBottomNextOutputRef, pendingExecApprovals, onResolveExecApproval, @@ -650,6 +651,7 @@ const AgentChatTranscript = memo(function AgentChatTranscript({ liveAssistantCharCount: number; liveThinkingCharCount: number; runStartedAt: number | null; + scrollToBottomOnOpenKey: string; scrollToBottomNextOutputRef: MutableRefObject; pendingExecApprovals: PendingExecApproval[]; onResolveExecApproval?: (id: string, decision: ExecApprovalDecision) => void; @@ -710,6 +712,11 @@ const AgentChatTranscript = memo(function AgentChatTranscript({ }); }, [scrollChatToBottom]); + useEffect(() => { + setPinned(true); + scheduleScrollToBottom(); + }, [scheduleScrollToBottom, scrollToBottomOnOpenKey, setPinned]); + useEffect(() => { updatePinnedFromScroll(); }, [updatePinnedFromScroll]); @@ -1472,6 +1479,7 @@ export const AgentChatPanel = ({ const allowThinking = selectedModel?.reasoning !== false; const avatarSeed = agent.avatarSeed ?? agent.agentId; + const scrollToBottomOnOpenKey = `${agent.agentId}:${agent.sessionKey}:${agent.sessionEpoch ?? 0}`; const emptyStateTitle = useMemo( () => resolveEmptyChatIntroMessage(agent.agentId, agent.sessionEpoch), [agent.agentId, agent.sessionEpoch] @@ -1730,6 +1738,7 @@ export const AgentChatPanel = ({ liveAssistantCharCount={liveAssistantText.length} liveThinkingCharCount={liveThinkingText.length} runStartedAt={agent.runStartedAt} + scrollToBottomOnOpenKey={scrollToBottomOnOpenKey} scrollToBottomNextOutputRef={scrollToBottomNextOutputRef} pendingExecApprovals={pendingExecApprovals} onResolveExecApproval={onResolveExecApproval} diff --git a/src/features/agents/components/ConnectionPanel.tsx b/src/features/agents/components/ConnectionPanel.tsx index d2a95c6..b7d030b 100644 --- a/src/features/agents/components/ConnectionPanel.tsx +++ b/src/features/agents/components/ConnectionPanel.tsx @@ -20,6 +20,7 @@ type ConnectionPanelProps = { | null; saving: boolean; testing: boolean; + disconnecting: boolean; onGatewayUrlChange: (value: string) => void; onTokenChange: (value: string) => void; onSaveSettings: () => void; @@ -41,6 +42,7 @@ export const ConnectionPanel = ({ testResult, saving, testing, + disconnecting, onGatewayUrlChange, onTokenChange, onSaveSettings, @@ -48,7 +50,7 @@ export const ConnectionPanel = ({ onDisconnect, onClose, }: ConnectionPanelProps) => { - const actionBusy = saving || testing; + const actionBusy = saving || testing || disconnecting; const tokenHelper = hasStoredToken ? "Stored token available on this Studio host. Leave blank to keep it." : localGatewayDefaultsHasToken @@ -88,7 +90,7 @@ export const ConnectionPanel = ({ onClick={onDisconnect} disabled={actionBusy} > - Disconnect + {disconnecting ? "Disconnecting…" : "Disconnect"} ) : null}
diff --git a/src/features/agents/components/GatewayConnectScreen.tsx b/src/features/agents/components/GatewayConnectScreen.tsx index e06d6fd..f077941 100644 --- a/src/features/agents/components/GatewayConnectScreen.tsx +++ b/src/features/agents/components/GatewayConnectScreen.tsx @@ -32,6 +32,7 @@ type GatewayConnectScreenProps = { | null; saving: boolean; testing: boolean; + disconnecting: boolean; onGatewayUrlChange: (value: string) => void; onTokenChange: (value: string) => void; onUseLocalDefaults: () => void; @@ -64,6 +65,7 @@ export const GatewayConnectScreen = ({ testResult, saving, testing, + disconnecting, onGatewayUrlChange, onTokenChange, onUseLocalDefaults, @@ -162,9 +164,10 @@ export const GatewayConnectScreen = ({ } return "When Studio and OpenClaw share a host, the upstream should usually stay on localhost."; }, [selectedScenario, statusReason]); - const actionBusy = saving || testing; + const actionBusy = saving || testing || disconnecting; const saveLabel = saving ? "Saving…" : "Save settings"; const testLabel = testing ? "Testing…" : "Test connection"; + const disconnectLabel = disconnecting ? "Disconnecting…" : "Disconnect"; const statusDotClass = status === "connected" ? "ui-dot-status-connected" @@ -335,7 +338,7 @@ export const GatewayConnectScreen = ({ onClick={() => void onDisconnect()} disabled={actionBusy} > - Disconnect + {disconnectLabel} ) : null}
diff --git a/src/lib/controlplane/intent-route.ts b/src/lib/controlplane/intent-route.ts index d1cf998..ebd2fdc 100644 --- a/src/lib/controlplane/intent-route.ts +++ b/src/lib/controlplane/intent-route.ts @@ -5,6 +5,8 @@ import { serializeRuntimeInitFailure } from "@/lib/controlplane/runtime-init-err import { bootstrapDomainRuntime } from "@/lib/controlplane/runtime-route-bootstrap"; import type { ControlPlaneRuntime } from "@/lib/controlplane/runtime"; +export const LONG_RUNNING_GATEWAY_INTENT_TIMEOUT_MS = 600_000; + const isRecord = (value: unknown): value is Record => Boolean(value && typeof value === "object" && !Array.isArray(value)); @@ -56,14 +58,18 @@ export const parseIntentBody = async (request: Request): Promise( method: string, - params: unknown + params: unknown, + options?: { timeoutMs?: number } ): Promise => { const runtimeOrError = await ensureDomainIntentRuntime(); if (runtimeOrError instanceof Response) { return runtimeOrError as NextResponse; } try { - const payload = await runtimeOrError.callGateway(method, params); + const payload = + typeof options?.timeoutMs === "number" + ? await runtimeOrError.callGateway(method, params, options) + : await runtimeOrError.callGateway(method, params); return NextResponse.json({ ok: true, payload }); } catch (err) { if (err instanceof ControlPlaneGatewayError) { diff --git a/src/lib/controlplane/openclaw-adapter.ts b/src/lib/controlplane/openclaw-adapter.ts index 2692052..54f4e86 100644 --- a/src/lib/controlplane/openclaw-adapter.ts +++ b/src/lib/controlplane/openclaw-adapter.ts @@ -12,7 +12,7 @@ import type { import { loadStudioSettings } from "@/lib/studio/settings-store"; const CONNECT_TIMEOUT_MS = 8_000; -const REQUEST_TIMEOUT_MS = 15_000; +const DEFAULT_REQUEST_TIMEOUT_MS = 15_000; const INITIAL_RECONNECT_DELAY_MS = 1_000; const MAX_RECONNECT_DELAY_MS = 15_000; const CONNECT_PROTOCOL = 3; @@ -73,6 +73,13 @@ export class ControlPlaneGatewayError extends Error { const isObject = (value: unknown): value is Record => Boolean(value && typeof value === "object"); +const resolveRequestTimeoutMs = (timeoutMs?: number): number => { + if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0) { + return Math.max(1, Math.floor(timeoutMs)); + } + return DEFAULT_REQUEST_TIMEOUT_MS; +}; + const resolveOriginForUpstream = (upstreamUrl: string): string => { const url = new URL(upstreamUrl); const proto = url.protocol === "wss:" ? "https:" : "http:"; @@ -190,7 +197,11 @@ export class OpenClawGatewayAdapter { this.updateStatus("stopped", null); } - async request(method: string, params: unknown): Promise { + async request( + method: string, + params: unknown, + options?: { timeoutMs?: number } + ): Promise { const normalizedMethod = method.trim(); if (!normalizedMethod) { throw new Error("Gateway method is required."); @@ -208,13 +219,16 @@ export class OpenClawGatewayAdapter { const id = String(this.nextRequestNumber++); const frame = { type: "req", id, method: normalizedMethod, params }; + const timeoutMs = resolveRequestTimeoutMs(options?.timeoutMs); try { const response = await new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pending.delete(id); - reject(new Error(`Gateway request timed out for method: ${normalizedMethod}`)); - }, REQUEST_TIMEOUT_MS); + reject( + new Error(`Gateway request timed out after ${timeoutMs}ms for method: ${normalizedMethod}`) + ); + }, timeoutMs); this.pending.set(id, { resolve, reject, timer }); ws.send(JSON.stringify(frame), (err) => { if (!err) return; @@ -227,11 +241,11 @@ export class OpenClawGatewayAdapter { } catch (error) { if (this.isOperatorScopeMissingError(error)) { await this.switchToLegacyControlUiProfile(); - return this.request(method, params); + return this.request(method, params, options); } if (this.legacyProfileSwitchPromise && this.isTransientProfileSwitchError(error)) { await this.legacyProfileSwitchPromise; - return this.request(method, params); + return this.request(method, params, options); } throw error; } diff --git a/src/lib/controlplane/runtime.ts b/src/lib/controlplane/runtime.ts index e691006..210a0e7 100644 --- a/src/lib/controlplane/runtime.ts +++ b/src/lib/controlplane/runtime.ts @@ -8,16 +8,22 @@ import { SQLiteControlPlaneProjectionStore, type BackfillAgentOutboxResult, } from "@/lib/controlplane/projection-store"; +import { loadStudioSettings } from "@/lib/studio/settings-store"; type ControlPlaneRuntimeOptions = { adapterOptions?: OpenClawAdapterOptions; dbPath?: string; }; +type EnsureStartedOptions = { + force?: boolean; +}; + export class ControlPlaneRuntime { private readonly store: SQLiteControlPlaneProjectionStore; private readonly adapter: OpenClawGatewayAdapter; private readonly eventSubscribers = new Set<(entry: ControlPlaneOutboxEntry) => void>(); + private autoStartEnabled = true; constructor(options?: ControlPlaneRuntimeOptions) { this.store = new SQLiteControlPlaneProjectionStore(options?.dbPath); @@ -27,11 +33,20 @@ export class ControlPlaneRuntime { }); } - async ensureStarted(): Promise { + async ensureStarted(options: EnsureStartedOptions = {}): Promise { + if (options.force) { + this.autoStartEnabled = true; + } else if (loadStudioSettings().gatewayAutoStart === false) { + this.autoStartEnabled = false; + return; + } else { + this.autoStartEnabled = true; + } await this.adapter.start(); } async disconnect(): Promise { + this.autoStartEnabled = false; await this.adapter.stop(); } @@ -40,7 +55,11 @@ export class ControlPlaneRuntime { } async reconnectForGatewaySettingsChange(): Promise { - if (this.adapter.getStatus() === "stopped") return; + this.autoStartEnabled = true; + if (this.adapter.getStatus() === "stopped") { + await this.adapter.start(); + return; + } await this.adapter.stop(); await this.adapter.start(); } @@ -72,8 +91,12 @@ export class ControlPlaneRuntime { }; } - async callGateway(method: string, params: unknown): Promise { - return await this.adapter.request(method, params); + async callGateway( + method: string, + params: unknown, + options?: { timeoutMs?: number } + ): Promise { + return await this.adapter.request(method, params, options); } close(): void { diff --git a/src/lib/studio/settings.ts b/src/lib/studio/settings.ts index 42d7970..0a2dc90 100644 --- a/src/lib/studio/settings.ts +++ b/src/lib/studio/settings.ts @@ -20,12 +20,14 @@ export type StudioFocusedPreference = { export type StudioSettings = { version: 1; gateway: StudioGatewaySettings | null; + gatewayAutoStart: boolean; focused: Record; avatars: Record>; }; export type StudioSettingsPatch = { gateway?: StudioGatewaySettingsPatch | null; + gatewayAutoStart?: boolean | null; focused?: Record | null>; avatars?: Record | null>; }; @@ -180,6 +182,7 @@ const normalizeAvatars = (value: unknown): Record export const defaultStudioSettings = (): StudioSettings => ({ version: SETTINGS_VERSION, gateway: null, + gatewayAutoStart: true, focused: {}, avatars: {}, }); @@ -187,11 +190,13 @@ export const defaultStudioSettings = (): StudioSettings => ({ export const normalizeStudioSettings = (raw: unknown): StudioSettings => { if (!isRecord(raw)) return defaultStudioSettings(); const gateway = normalizeGatewaySettings(raw.gateway); + const gatewayAutoStart = typeof raw.gatewayAutoStart === "boolean" ? raw.gatewayAutoStart : true; const focused = normalizeFocused(raw.focused); const avatars = normalizeAvatars(raw.avatars); return { version: SETTINGS_VERSION, gateway, + gatewayAutoStart, focused, avatars, }; @@ -202,6 +207,8 @@ export const mergeStudioSettings = ( patch: StudioSettingsPatch ): StudioSettings => { const nextGateway = mergeGatewaySettings(current.gateway, patch.gateway); + const nextGatewayAutoStart = + typeof patch.gatewayAutoStart === "boolean" ? patch.gatewayAutoStart : current.gatewayAutoStart; const nextFocused = { ...current.focused }; const nextAvatars = { ...current.avatars }; if (patch.focused) { @@ -246,6 +253,7 @@ export const mergeStudioSettings = ( return { version: SETTINGS_VERSION, gateway: nextGateway ?? null, + gatewayAutoStart: nextGatewayAutoStart, focused: nextFocused, avatars: nextAvatars, }; diff --git a/src/lib/studio/useStudioGatewaySettings.ts b/src/lib/studio/useStudioGatewaySettings.ts index ede1be0..d98d599 100644 --- a/src/lib/studio/useStudioGatewaySettings.ts +++ b/src/lib/studio/useStudioGatewaySettings.ts @@ -92,6 +92,7 @@ type StudioGatewaySettingsState = { | null; saving: boolean; testing: boolean; + disconnecting: boolean; saveSettings: () => Promise; testConnection: () => Promise; disconnect: () => Promise; @@ -148,6 +149,7 @@ export const useStudioGatewaySettings = ( } | null>(null); const [saving, setSaving] = useState(false); const [testing, setTesting] = useState(false); + const [disconnecting, setDisconnecting] = useState(false); const [settingsLoaded, setSettingsLoaded] = useState(false); const manualDisconnectRef = useRef(false); const didAutoConnectRef = useRef(false); @@ -264,6 +266,9 @@ export const useStudioGatewaySettings = ( }, [applySettingsEnvelope, settingsCoordinator]); const saveSettings = useCallback(async () => { + if (disconnecting) { + return false; + } const trimmedGatewayUrl = draftGatewayUrl.trim(); const trimmedToken = token.trim(); const canUseExistingToken = hasStoredToken || localGatewayDefaultsHasToken; @@ -314,6 +319,7 @@ export const useStudioGatewaySettings = ( } }, [ applySettingsEnvelope, + disconnecting, draftGatewayUrl, hasStoredToken, localGatewayDefaultsHasToken, @@ -323,6 +329,9 @@ export const useStudioGatewaySettings = ( ]); const testConnection = useCallback(async () => { + if (disconnecting) { + return false; + } const trimmedGatewayUrl = draftGatewayUrl.trim(); if (!trimmedGatewayUrl) { setActionError("Gateway URL is required."); @@ -363,14 +372,14 @@ export const useStudioGatewaySettings = ( } finally { setTesting(false); } - }, [draftGatewayUrl, token]); + }, [disconnecting, draftGatewayUrl, token]); const disconnect = useCallback(async () => { + if (disconnecting) return; manualDisconnectRef.current = true; + setDisconnecting(true); setActionError(null); setTestResult(null); - setStatus("disconnected"); - setStatusReason(null); setConnectionError(null); try { const summary = await fetchJson("/api/runtime/disconnect", { @@ -382,8 +391,10 @@ export const useStudioGatewaySettings = ( setStatus("error"); setStatusReason(message); setActionError(message); + } finally { + setDisconnecting(false); } - }, [applyRuntimeSummary]); + }, [applyRuntimeSummary, disconnecting]); useEffect(() => { if (!settingsLoaded) return; @@ -451,6 +462,7 @@ export const useStudioGatewaySettings = ( testResult, saving, testing, + disconnecting, saveSettings, testConnection, disconnect, @@ -475,6 +487,7 @@ export const useStudioGatewaySettings = ( localGatewayDefaultsHasToken, saveSettings, saving, + disconnecting, setGatewayUrl, setToken, status, diff --git a/tests/unit/agentChatPanel-scroll.test.ts b/tests/unit/agentChatPanel-scroll.test.ts index b7fc946..a7236c6 100644 --- a/tests/unit/agentChatPanel-scroll.test.ts +++ b/tests/unit/agentChatPanel-scroll.test.ts @@ -5,7 +5,7 @@ import type { AgentState } from "@/features/agents/state/store"; import { AgentChatPanel } from "@/features/agents/components/AgentChatPanel"; import type { GatewayModelChoice } from "@/lib/gateway/models"; -const createAgent = (): AgentState => ({ +const createAgent = (overrides: Partial = {}): AgentState => ({ agentId: "agent-1", name: "Agent One", sessionKey: "agent:agent-1:studio:test-session", @@ -38,6 +38,7 @@ const createAgent = (): AgentState => ({ thinkingLevel: null, avatarSeed: "seed-1", avatarUrl: null, + ...overrides, }); describe("AgentChatPanel scrolling", () => { @@ -56,14 +57,14 @@ describe("AgentChatPanel scrolling", () => { createElement(AgentChatPanel, { agent: { ...agent, outputLines: ["> hello", "first answer"] }, isSelected: true, - canSend: true, - models, - stopBusy: false, - onLoadMoreHistory: vi.fn(), - onOpenSettings: vi.fn(), - onModelChange: vi.fn(), - onThinkingChange: vi.fn(), - onDraftChange: vi.fn(), + canSend: true, + models, + stopBusy: false, + onLoadMoreHistory: vi.fn(), + onOpenSettings: vi.fn(), + onModelChange: vi.fn(), + onThinkingChange: vi.fn(), + onDraftChange: vi.fn(), onSend: vi.fn(), onStopRun: vi.fn(), onAvatarShuffle: vi.fn(), @@ -81,14 +82,14 @@ describe("AgentChatPanel scrolling", () => { createElement(AgentChatPanel, { agent: { ...agent, outputLines: ["> hello", "first answer", "second answer"] }, isSelected: true, - canSend: true, - models, - stopBusy: false, - onLoadMoreHistory: vi.fn(), - onOpenSettings: vi.fn(), - onModelChange: vi.fn(), - onThinkingChange: vi.fn(), - onDraftChange: vi.fn(), + canSend: true, + models, + stopBusy: false, + onLoadMoreHistory: vi.fn(), + onOpenSettings: vi.fn(), + onModelChange: vi.fn(), + onThinkingChange: vi.fn(), + onDraftChange: vi.fn(), onSend: vi.fn(), onStopRun: vi.fn(), onAvatarShuffle: vi.fn(), @@ -107,6 +108,69 @@ describe("AgentChatPanel scrolling", () => { ).toHaveBeenCalled(); }); + it("scrolls to the bottom when a different agent is opened", async () => { + const scrollIntoView = vi.fn(); + (Element.prototype as unknown as { scrollIntoView: unknown }).scrollIntoView = scrollIntoView; + + const { rerender } = render( + createElement(AgentChatPanel, { + agent: createAgent({ + outputLines: ["> hello", "first answer"], + }), + isSelected: true, + canSend: true, + models, + stopBusy: false, + onLoadMoreHistory: vi.fn(), + onOpenSettings: vi.fn(), + onModelChange: vi.fn(), + onThinkingChange: vi.fn(), + onDraftChange: vi.fn(), + onSend: vi.fn(), + onStopRun: vi.fn(), + onAvatarShuffle: vi.fn(), + }) + ); + + const scrollEl = screen.getByTestId("agent-chat-scroll"); + Object.defineProperty(scrollEl, "clientHeight", { value: 100, configurable: true }); + Object.defineProperty(scrollEl, "scrollHeight", { value: 1000, configurable: true }); + Object.defineProperty(scrollEl, "scrollTop", { value: 0, writable: true, configurable: true }); + + await waitFor(() => { + expect(scrollIntoView).toHaveBeenCalled(); + }); + + scrollIntoView.mockClear(); + + rerender( + createElement(AgentChatPanel, { + agent: createAgent({ + agentId: "agent-2", + name: "Agent Two", + sessionKey: "agent:agent-2:studio:test-session", + outputLines: ["> another", "reply"], + }), + isSelected: true, + canSend: true, + models, + stopBusy: false, + onLoadMoreHistory: vi.fn(), + onOpenSettings: vi.fn(), + onModelChange: vi.fn(), + onThinkingChange: vi.fn(), + onDraftChange: vi.fn(), + onSend: vi.fn(), + onStopRun: vi.fn(), + onAvatarShuffle: vi.fn(), + }) + ); + + await waitFor(() => { + expect(scrollIntoView).toHaveBeenCalled(); + }); + }); + it("shows history truncation banner only when scrolled to top", () => { const agent = createAgent(); render( diff --git a/tests/unit/agentFleetHydration.test.ts b/tests/unit/agentFleetHydration.test.ts index b52d133..ce01e8b 100644 --- a/tests/unit/agentFleetHydration.test.ts +++ b/tests/unit/agentFleetHydration.test.ts @@ -10,6 +10,7 @@ describe("hydrateAgentFleetFromGateway", () => { const settings: StudioSettings = { version: 1, gateway: null, + gatewayAutoStart: true, focused: {}, avatars: { "ws://localhost:18789": { @@ -246,7 +247,13 @@ describe("hydrateAgentFleetFromGateway", () => { client: { call }, gatewayUrl: "ws://127.0.0.1:18789", cachedConfigSnapshot: null, - loadStudioSettings: async () => ({ version: 1, gateway: null, focused: {}, avatars: {} }), + loadStudioSettings: async () => ({ + version: 1, + gateway: null, + gatewayAutoStart: true, + focused: {}, + avatars: {}, + }), isDisconnectLikeError: () => false, }); @@ -288,7 +295,13 @@ describe("hydrateAgentFleetFromGateway", () => { client: { call }, gatewayUrl: "ws://127.0.0.1:18789", cachedConfigSnapshot: null, - loadStudioSettings: async () => ({ version: 1, gateway: null, focused: {}, avatars: {} }), + loadStudioSettings: async () => ({ + version: 1, + gateway: null, + gatewayAutoStart: true, + focused: {}, + avatars: {}, + }), isDisconnectLikeError: () => false, logError, }); @@ -330,7 +343,13 @@ describe("hydrateAgentFleetFromGateway", () => { client: { call }, gatewayUrl: "ws://127.0.0.1:18789", cachedConfigSnapshot: null, - loadStudioSettings: async () => ({ version: 1, gateway: null, focused: {}, avatars: {} }), + loadStudioSettings: async () => ({ + version: 1, + gateway: null, + gatewayAutoStart: true, + focused: {}, + avatars: {}, + }), isDisconnectLikeError: () => false, }); diff --git a/tests/unit/agentFleetHydrationDerivation.test.ts b/tests/unit/agentFleetHydrationDerivation.test.ts index 3ccadaa..85c6833 100644 --- a/tests/unit/agentFleetHydrationDerivation.test.ts +++ b/tests/unit/agentFleetHydrationDerivation.test.ts @@ -10,6 +10,7 @@ describe("deriveHydrateAgentFleetResult", () => { const settings: StudioSettings = { version: 1, gateway: null, + gatewayAutoStart: true, focused: {}, avatars: { "ws://localhost:18789": { diff --git a/tests/unit/connectionPanel-close.test.ts b/tests/unit/connectionPanel-close.test.ts index 776029c..81a053d 100644 --- a/tests/unit/connectionPanel-close.test.ts +++ b/tests/unit/connectionPanel-close.test.ts @@ -16,6 +16,7 @@ const buildProps = () => ({ testResult: null, saving: false, testing: false, + disconnecting: false, onGatewayUrlChange: vi.fn(), onTokenChange: vi.fn(), onSaveSettings: vi.fn(), @@ -71,4 +72,18 @@ describe("ConnectionPanel close control", () => { expect(connected).toHaveAttribute("data-status", "connected"); expect(connected).toHaveClass("ui-badge-status-connected"); }); + + it("disables connection actions while disconnecting", () => { + render( + createElement(ConnectionPanel, { + ...buildProps(), + status: "connected", + disconnecting: true, + }) + ); + + expect(screen.getByRole("button", { name: "Save settings" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Test connection" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Disconnecting…" })).toBeDisabled(); + }); }); diff --git a/tests/unit/controlPlaneRuntime.test.ts b/tests/unit/controlPlaneRuntime.test.ts index 62da8b8..1f3fa20 100644 --- a/tests/unit/controlPlaneRuntime.test.ts +++ b/tests/unit/controlPlaneRuntime.test.ts @@ -21,6 +21,23 @@ describe("control-plane runtime", () => { const makeRuntimeDbPath = () => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "controlplane-runtime-")); + process.env.OPENCLAW_STATE_DIR = tempDir; + fs.mkdirSync(path.join(tempDir, "openclaw-studio"), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, "openclaw-studio", "settings.json"), + JSON.stringify( + { + version: 1, + gateway: { url: "ws://127.0.0.1:0", token: "placeholder" }, + gatewayAutoStart: true, + focused: {}, + avatars: {}, + }, + null, + 2 + ), + "utf8" + ); return path.join(tempDir, "runtime.db"); }; @@ -29,6 +46,7 @@ describe("control-plane runtime", () => { await runtime.disconnect(); runtime.close(); resetControlPlaneRuntimeForTests(); + delete process.env.OPENCLAW_STATE_DIR; delete process.env.STUDIO_DOMAIN_API_MODE; delete process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE; if (tempDir) { @@ -95,6 +113,88 @@ describe("control-plane runtime", () => { await closeWebSocketServer(upstream); }); + it("keeps a manual disconnect stopped until a forced restart", async () => { + const upstream = new WebSocketServer({ port: 0 }); + const address = upstream.address(); + if (!address || typeof address === "string") { + throw new Error("expected upstream server to have a port"); + } + const upstreamUrl = `ws://127.0.0.1:${address.port}`; + let connectionCount = 0; + + upstream.on("connection", (ws) => { + connectionCount += 1; + ws.send(JSON.stringify({ type: "event", event: "connect.challenge", payload: { nonce: "n1" } })); + ws.on("message", (raw) => { + const parsed = JSON.parse(String(raw ?? "")); + if (parsed?.method !== "connect") return; + ws.send( + JSON.stringify({ + type: "res", + id: parsed.id, + ok: true, + payload: { type: "hello-ok", protocol: 3 }, + }) + ); + }); + }); + + const runtime = new ControlPlaneRuntime({ + dbPath: makeRuntimeDbPath(), + adapterOptions: { + loadSettings: () => ({ url: upstreamUrl, token: "upstream-token" }), + }, + }); + + await runtime.ensureStarted(); + expect(connectionCount).toBe(1); + + await runtime.disconnect(); + expect(runtime.snapshot().status).toBe("stopped"); + fs.writeFileSync( + path.join(tempDir!, "openclaw-studio", "settings.json"), + JSON.stringify( + { + version: 1, + gateway: { url: upstreamUrl, token: "upstream-token" }, + gatewayAutoStart: false, + focused: {}, + avatars: {}, + }, + null, + 2 + ), + "utf8" + ); + + await runtime.ensureStarted(); + expect(runtime.snapshot().status).toBe("stopped"); + expect(connectionCount).toBe(1); + + fs.writeFileSync( + path.join(tempDir!, "openclaw-studio", "settings.json"), + JSON.stringify( + { + version: 1, + gateway: { url: upstreamUrl, token: "upstream-token" }, + gatewayAutoStart: true, + focused: {}, + avatars: {}, + }, + null, + 2 + ), + "utf8" + ); + + await runtime.ensureStarted({ force: true }); + expect(runtime.snapshot().status).toBe("connected"); + expect(connectionCount).toBe(2); + + await runtime.disconnect(); + await closeWebSocketServer(upstream); + }); + it("enforces gateway method allowlist", async () => { const upstream = new WebSocketServer({ port: 0 }); const address = upstream.address(); diff --git a/tests/unit/intentRoutes.test.ts b/tests/unit/intentRoutes.test.ts index 9ed4bba..09d1cab 100644 --- a/tests/unit/intentRoutes.test.ts +++ b/tests/unit/intentRoutes.test.ts @@ -50,9 +50,11 @@ describe("intent routes", () => { callGateway, }), })); + const { LONG_RUNNING_GATEWAY_INTENT_TIMEOUT_MS } = await import("@/lib/controlplane/intent-route"); const resetRoute = await import("@/app/api/intents/sessions-reset/route"); const sessionSettingsRoute = await import("@/app/api/intents/session-settings-sync/route"); const waitRoute = await import("@/app/api/intents/agent-wait/route"); + const cronRunRoute = await import("@/app/api/intents/cron-run/route"); const resetResponse = await resetRoute.POST( new Request("http://localhost/api/intents/sessions-reset", { @@ -78,16 +80,33 @@ describe("intent routes", () => { body: JSON.stringify({ runId: "run-1", timeoutMs: 3000 }), }) ); + const cronRunResponse = await cronRunRoute.POST( + new Request("http://localhost/api/intents/cron-run", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: "job-1" }), + }) + ); expect(resetResponse.status).toBe(200); expect(sessionSettingsResponse.status).toBe(200); expect(waitResponse.status).toBe(200); + expect(cronRunResponse.status).toBe(200); expect(callGateway).toHaveBeenCalledWith("sessions.reset", { key: "agent:agent-1:main" }); expect(callGateway).toHaveBeenCalledWith("sessions.patch", { key: "agent:agent-1:main", model: "openai/gpt-5", }); - expect(callGateway).toHaveBeenCalledWith("agent.wait", { runId: "run-1", timeoutMs: 3000 }); + expect(callGateway).toHaveBeenCalledWith( + "agent.wait", + { runId: "run-1", timeoutMs: 3000 }, + { timeoutMs: 3000 } + ); + expect(callGateway).toHaveBeenCalledWith( + "cron.run", + { id: "job-1", mode: "force" }, + { timeoutMs: LONG_RUNNING_GATEWAY_INTENT_TIMEOUT_MS } + ); }); it("agent-create route composes workspace from config path and forwards to agents.create", async () => { diff --git a/tests/unit/openclawAdapter.test.ts b/tests/unit/openclawAdapter.test.ts index c762d8b..95bafe1 100644 --- a/tests/unit/openclawAdapter.test.ts +++ b/tests/unit/openclawAdapter.test.ts @@ -1,6 +1,6 @@ // @vitest-environment node -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { EventEmitter } from "node:events"; import { WebSocket, WebSocketServer } from "ws"; @@ -27,6 +27,78 @@ describe("OpenClawGatewayAdapter", () => { await closeWebSocketServer(upstream); upstream = null; } + vi.useRealTimers(); + }); + + it("honors per-request timeout overrides", async () => { + vi.useFakeTimers(); + + class TimeoutSocket extends EventEmitter { + readyState: number = WebSocket.OPEN; + + close() { + if (this.readyState === WebSocket.CLOSED) return; + this.readyState = WebSocket.CLOSED; + this.emit("close"); + } + + terminate() { + this.close(); + } + + send(raw: string, callback?: (err?: Error) => void) { + const parsed = JSON.parse(raw) as { id?: string; method?: string }; + callback?.(); + if (parsed.method !== "connect" || !parsed.id) { + return; + } + queueMicrotask(() => { + this.emit( + "message", + JSON.stringify({ + type: "res", + id: parsed.id, + ok: true, + payload: { type: "hello-ok", protocol: 3 }, + }) + ); + }); + } + } + + const socket = new TimeoutSocket(); + const adapter = new OpenClawGatewayAdapter({ + loadSettings: () => ({ url: "ws://127.0.0.1:9", token: "tkn" }), + createWebSocket: () => socket as unknown as WebSocket, + }); + + queueMicrotask(() => { + socket.emit("message", JSON.stringify({ type: "event", event: "connect.challenge", payload: {} })); + }); + + await adapter.start(); + + let settled = false; + const request = adapter.request("cron.run", { id: "job-1" }, { timeoutMs: 25_000 }); + void request.then( + () => { + settled = true; + }, + () => { + settled = true; + } + ); + + await vi.advanceTimersByTimeAsync(24_999); + await Promise.resolve(); + expect(settled).toBe(false); + + await vi.advanceTimersByTimeAsync(1); + await expect(request).rejects.toThrow( + "Gateway request timed out after 25000ms for method: cron.run" + ); + + await adapter.stop(); }); it("rejects in-flight requests immediately when the socket closes", async () => { diff --git a/tests/unit/studioBootstrapOperation.test.ts b/tests/unit/studioBootstrapOperation.test.ts index 0c79d56..56dab14 100644 --- a/tests/unit/studioBootstrapOperation.test.ts +++ b/tests/unit/studioBootstrapOperation.test.ts @@ -196,6 +196,7 @@ describe("studioBootstrapOperation", () => { loadStudioSettings: async () => ({ version: 1, gateway: null, + gatewayAutoStart: true, focused: { "https://gateway.test": { mode: "focused", @@ -230,6 +231,7 @@ describe("studioBootstrapOperation", () => { loadStudioSettings: async () => ({ version: 1, gateway: null, + gatewayAutoStart: true, focused: { "https://gateway.test": { mode: "focused", diff --git a/tests/unit/studioBootstrapWorkflow.test.ts b/tests/unit/studioBootstrapWorkflow.test.ts index 10dd5a4..2d79d67 100644 --- a/tests/unit/studioBootstrapWorkflow.test.ts +++ b/tests/unit/studioBootstrapWorkflow.test.ts @@ -161,6 +161,7 @@ describe("studioBootstrapWorkflow", () => { const settings: StudioSettings = { version: 1, gateway: null, + gatewayAutoStart: true, focused: { "https://gateway.test": { mode: "focused", @@ -198,6 +199,7 @@ describe("studioBootstrapWorkflow", () => { const settings: StudioSettings = { version: 1, gateway: null, + gatewayAutoStart: true, focused: { "https://gateway.test": { mode: "focused", diff --git a/tests/unit/studioSettingsRoute.test.ts b/tests/unit/studioSettingsRoute.test.ts index 10da181..5b68c83 100644 --- a/tests/unit/studioSettingsRoute.test.ts +++ b/tests/unit/studioSettingsRoute.test.ts @@ -46,6 +46,7 @@ describe("studio settings route", () => { expect(body.installContext).toBeTruthy(); expect(typeof body.domainApiModeEnabled).toBe("boolean"); expect(body.settings?.version).toBe(1); + expect(body.settings?.gatewayAutoStart).toBe(true); }); it("GET always reports domain mode enabled", async () => { @@ -130,8 +131,12 @@ describe("studio settings route", () => { const settingsPath = path.join(tempDir, "openclaw-studio", "settings.json"); expect(fs.existsSync(settingsPath)).toBe(true); const raw = fs.readFileSync(settingsPath, "utf8"); - const parsed = JSON.parse(raw) as { gateway?: { url?: string; token?: string } | null }; + const parsed = JSON.parse(raw) as { + gateway?: { url?: string; token?: string } | null; + gatewayAutoStart?: boolean; + }; expect(parsed.gateway).toEqual({ url: "ws://example.test:1234", token: "t" }); + expect(parsed.gatewayAutoStart).toBe(true); }); it("PUT url-only gateway patch preserves existing token", async () => { @@ -169,10 +174,11 @@ describe("studio settings route", () => { const persisted = JSON.parse( fs.readFileSync(path.join(tempDir, "openclaw-studio", "settings.json"), "utf8") - ) as { gateway?: { url?: string; token?: string } }; + ) as { gateway?: { url?: string; token?: string }; gatewayAutoStart?: boolean }; expect(persisted.gateway).toEqual({ url: "ws://new.example:18789", token: "secret-token", }); + expect(persisted.gatewayAutoStart).toBe(true); }); }); diff --git a/tests/unit/studioSettingsRouteReconnect.test.ts b/tests/unit/studioSettingsRouteReconnect.test.ts new file mode 100644 index 0000000..506dcbe --- /dev/null +++ b/tests/unit/studioSettingsRouteReconnect.test.ts @@ -0,0 +1,165 @@ +// @vitest-environment node + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +const makeTempDir = (name: string) => fs.mkdtempSync(path.join(os.tmpdir(), `${name}-`)); + +describe("studio settings route reconnect behavior", () => { + const priorStateDir = process.env.OPENCLAW_STATE_DIR; + let tempDir: string | null = null; + + afterEach(() => { + process.env.OPENCLAW_STATE_DIR = priorStateDir; + if (tempDir) { + fs.rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + } + vi.restoreAllMocks(); + vi.resetModules(); + }); + + it("restarts a manually disconnected runtime when settings are saved without changing the gateway", async () => { + tempDir = makeTempDir("studio-settings-reconnect-stopped-runtime"); + process.env.OPENCLAW_STATE_DIR = tempDir; + fs.mkdirSync(path.join(tempDir, "openclaw-studio"), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, "openclaw-studio", "settings.json"), + JSON.stringify( + { + version: 1, + gateway: { url: "ws://remote.example:18789", token: "secret-token" }, + focused: {}, + avatars: {}, + }, + null, + 2 + ), + "utf8" + ); + + const ensureStarted = vi.fn(async () => {}); + const reconnectForGatewaySettingsChange = vi.fn(async () => {}); + vi.doMock("@/lib/controlplane/runtime", () => ({ + isStudioDomainApiModeEnabled: () => true, + peekControlPlaneRuntime: () => ({ + connectionStatus: () => "stopped", + ensureStarted, + reconnectForGatewaySettingsChange, + }), + getControlPlaneRuntime: () => ({ + connectionStatus: () => "stopped", + ensureStarted, + reconnectForGatewaySettingsChange, + }), + })); + + const { PUT } = await import("@/app/api/studio/route"); + const response = await PUT({ + json: async () => ({ + gateway: { url: "ws://remote.example:18789" }, + }), + } as unknown as Request); + + expect(response.status).toBe(200); + expect(ensureStarted).toHaveBeenCalledWith({ force: true }); + expect(reconnectForGatewaySettingsChange).not.toHaveBeenCalled(); + + const body = (await response.json()) as { + runtimeReconnect?: { + attempted?: unknown; + restarted?: unknown; + previousStatus?: unknown; + } | null; + }; + expect(body.runtimeReconnect).toEqual({ + attempted: true, + restarted: true, + previousStatus: "stopped", + }); + }); + + it("persists manual disconnect across requests", async () => { + tempDir = makeTempDir("studio-settings-disconnect-pause"); + process.env.OPENCLAW_STATE_DIR = tempDir; + fs.mkdirSync(path.join(tempDir, "openclaw-studio"), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, "openclaw-studio", "settings.json"), + JSON.stringify( + { + version: 1, + gateway: { url: "ws://remote.example:18789", token: "secret-token" }, + gatewayAutoStart: true, + focused: {}, + avatars: {}, + }, + null, + 2 + ), + "utf8" + ); + + vi.doMock("@/lib/controlplane/runtime", () => ({ + peekControlPlaneRuntime: () => null, + })); + + const { POST } = await import("@/app/api/runtime/disconnect/route"); + const response = await POST(); + expect(response.status).toBe(200); + + const persisted = JSON.parse( + fs.readFileSync(path.join(tempDir, "openclaw-studio", "settings.json"), "utf8") + ) as { gatewayAutoStart?: boolean }; + expect(persisted.gatewayAutoStart).toBe(false); + }); + + it("creates and starts a runtime when save settings is the first reconnect request", async () => { + tempDir = makeTempDir("studio-settings-start-missing-runtime"); + process.env.OPENCLAW_STATE_DIR = tempDir; + fs.mkdirSync(path.join(tempDir, "openclaw-studio"), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, "openclaw-studio", "settings.json"), + JSON.stringify( + { + version: 1, + gateway: { url: "ws://remote.example:18789", token: "secret-token" }, + gatewayAutoStart: false, + focused: {}, + avatars: {}, + }, + null, + 2 + ), + "utf8" + ); + + const ensureStarted = vi.fn(async () => {}); + vi.doMock("@/lib/controlplane/runtime", () => ({ + isStudioDomainApiModeEnabled: () => true, + peekControlPlaneRuntime: () => null, + getControlPlaneRuntime: () => ({ + connectionStatus: () => "stopped", + ensureStarted, + reconnectForGatewaySettingsChange: vi.fn(async () => {}), + }), + })); + + const { PUT } = await import("@/app/api/studio/route"); + const response = await PUT({ + json: async () => ({ + gateway: { url: "ws://remote.example:18789" }, + }), + } as unknown as Request); + + expect(response.status).toBe(200); + expect(ensureStarted).toHaveBeenCalledWith({ force: true }); + + const persisted = JSON.parse( + fs.readFileSync(path.join(tempDir, "openclaw-studio", "settings.json"), "utf8") + ) as { gatewayAutoStart?: boolean }; + expect(persisted.gatewayAutoStart).toBe(true); + }); +}); diff --git a/tests/unit/useStudioGatewaySettings.test.ts b/tests/unit/useStudioGatewaySettings.test.ts new file mode 100644 index 0000000..d3b549f --- /dev/null +++ b/tests/unit/useStudioGatewaySettings.test.ts @@ -0,0 +1,174 @@ +import { createElement, useEffect } from "react"; +import { act, render, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { fetchJson } from "@/lib/http"; +import type { StudioSettingsResponse } from "@/lib/studio/coordinator"; +import { defaultStudioInstallContext } from "@/lib/studio/install-context"; +import { useStudioGatewaySettings } from "@/lib/studio/useStudioGatewaySettings"; + +vi.mock("@/lib/http", () => ({ + fetchJson: vi.fn(), +})); + +type HookValue = ReturnType; + +type Deferred = { + promise: Promise; + resolve: (value: T) => void; + reject: (error?: unknown) => void; +}; + +const createDeferred = (): Deferred => { + let resolve!: (value: T) => void; + let reject!: (error?: unknown) => void; + const promise = new Promise((nextResolve, nextReject) => { + resolve = nextResolve; + reject = nextReject; + }); + return { promise, resolve, reject }; +}; + +const buildEnvelope = (): StudioSettingsResponse => ({ + settings: { + version: 1, + gateway: { + url: "wss://remote.example:8443", + token: "", + }, + gatewayAutoStart: true, + focused: {}, + avatars: {}, + }, + localGatewayDefaults: null, + localGatewayDefaultsMeta: { + hasToken: false, + }, + gatewayMeta: { + hasStoredToken: true, + }, + installContext: defaultStudioInstallContext(), + domainApiModeEnabled: true, +}); + +const renderHook = () => { + const coordinator = { + loadSettings: vi.fn(async () => buildEnvelope().settings), + loadSettingsEnvelope: vi.fn(async () => buildEnvelope()), + flushPending: vi.fn(async () => {}), + }; + const valueRef: { current: HookValue | null } = { current: null }; + + const Probe = () => { + const value = useStudioGatewaySettings(coordinator); + useEffect(() => { + valueRef.current = value; + }, [value]); + return createElement("div", { "data-testid": "probe" }, "ok"); + }; + + const rendered = render(createElement(Probe)); + + return { + coordinator, + getValue: () => { + if (!valueRef.current) { + throw new Error("hook value unavailable"); + } + return valueRef.current; + }, + unmount: () => rendered.unmount(), + }; +}; + +describe("useStudioGatewaySettings", () => { + const mockedFetchJson = vi.mocked(fetchJson); + const fetchMock = vi.fn(); + + beforeEach(() => { + mockedFetchJson.mockReset(); + fetchMock.mockReset(); + vi.stubGlobal("fetch", fetchMock); + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + text: async () => + JSON.stringify({ + enabled: true, + summary: { + status: "connected", + reason: null, + }, + }), + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("blocks save while disconnect is still in flight", async () => { + const disconnectDeferred = createDeferred<{ + enabled: boolean; + summary: { + status: string; + reason: string | null; + asOf?: string | null; + outboxHead?: number; + }; + }>(); + mockedFetchJson.mockImplementation(async (input) => { + if (input === "/api/runtime/disconnect") { + return await disconnectDeferred.promise; + } + throw new Error(`Unexpected fetchJson call: ${String(input)}`); + }); + + const ctx = renderHook(); + + await waitFor(() => { + expect(ctx.getValue().status).toBe("connected"); + }); + + let disconnectPromise: Promise | undefined; + act(() => { + disconnectPromise = ctx.getValue().disconnect(); + }); + + await waitFor(() => { + expect(ctx.getValue().disconnecting).toBe(true); + }); + expect(ctx.getValue().status).toBe("connected"); + + let saveResult = true; + await act(async () => { + saveResult = await ctx.getValue().saveSettings(); + }); + + expect(saveResult).toBe(false); + expect(ctx.coordinator.flushPending).not.toHaveBeenCalled(); + expect(mockedFetchJson).not.toHaveBeenCalledWith( + "/api/studio", + expect.anything() + ); + + disconnectDeferred.resolve({ + enabled: true, + summary: { + status: "stopped", + reason: null, + }, + }); + + await act(async () => { + await disconnectPromise; + }); + + await waitFor(() => { + expect(ctx.getValue().status).toBe("disconnected"); + }); + expect(ctx.getValue().disconnecting).toBe(false); + ctx.unmount(); + }); +});