better connection reliability

This commit is contained in:
George Pickett
2026-03-09 10:50:27 -07:00
parent 367fa18cd2
commit 455e33a2fa
25 changed files with 850 additions and 86 deletions
+7 -1
View File
@@ -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,
});
}
+7 -1
View File
@@ -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,
});
}
+2
View File
@@ -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 = {
+41 -11
View File
@@ -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<typeof loadStudioSettings>) => {
const gateway = normalizeGatewaySettings(settings);
return Boolean(gateway.url && gateway.token);
};
const reconnectRuntimeForGatewaySettingsChange = async (
previous: ReturnType<typeof loadStudioSettings>,
next: ReturnType<typeof loadStudioSettings>
): Promise<RuntimeReconnectMetadata | null> => {
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
+32 -29
View File
@@ -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 = () => {
/>
<div className="flex min-h-0 flex-1 flex-col gap-3 px-3 pb-3 pt-2 sm:px-4 sm:pb-4 sm:pt-3 md:px-5 md:pb-5 md:pt-3">
{connectionPanelVisible ? (
<div className="pointer-events-none fixed inset-x-0 top-12 z-[140] flex justify-center px-3 sm:px-4 md:px-5">
<div className="glass-panel pointer-events-auto w-full max-w-4xl !bg-card px-4 py-4 sm:px-6 sm:py-6">
<ConnectionPanel
savedGatewayUrl={gatewayUrl}
draftGatewayUrl={draftGatewayUrl}
token={token}
hasStoredToken={hasStoredToken}
localGatewayDefaultsHasToken={localGatewayDefaultsHasToken}
hasUnsavedChanges={hasUnsavedChanges}
status={gatewayStatus}
statusReason={statusReason}
error={gatewayError}
testResult={testResult}
saving={gatewaySaving}
testing={gatewayTesting}
onGatewayUrlChange={setGatewayUrl}
onTokenChange={setToken}
onSaveSettings={() => void saveSettings()}
onTestConnection={() => void testConnection()}
onDisconnect={() => void disconnect()}
onClose={() => setShowConnectionPanel(false)}
/>
<div className="fixed inset-0 z-[140]" data-testid="gateway-connection-overlay">
<div
className="absolute inset-0 bg-transparent"
onClick={() => setShowConnectionPanel(false)}
/>
<div className="pointer-events-none absolute inset-x-0 top-12 flex justify-center px-3 sm:px-4 md:px-5">
<div className="glass-panel pointer-events-auto w-full max-w-4xl !bg-card px-4 py-4 sm:px-6 sm:py-6">
<ConnectionPanel
savedGatewayUrl={gatewayUrl}
draftGatewayUrl={draftGatewayUrl}
token={token}
hasStoredToken={hasStoredToken}
localGatewayDefaultsHasToken={localGatewayDefaultsHasToken}
hasUnsavedChanges={hasUnsavedChanges}
status={gatewayStatus}
statusReason={statusReason}
error={gatewayError}
testResult={testResult}
saving={gatewaySaving}
testing={gatewayTesting}
disconnecting={gatewayDisconnecting}
onGatewayUrlChange={setGatewayUrl}
onTokenChange={setToken}
onSaveSettings={() => void saveSettings()}
onTestConnection={() => void testConnection()}
onDisconnect={() => void disconnect()}
onClose={() => setShowConnectionPanel(false)}
/>
</div>
</div>
</div>
) : null}
@@ -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<boolean>;
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}
@@ -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"}
</button>
) : null}
</div>
@@ -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}
</button>
) : null}
</div>
+8 -2
View File
@@ -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<string, unknown> =>
Boolean(value && typeof value === "object" && !Array.isArray(value));
@@ -56,14 +58,18 @@ export const parseIntentBody = async (request: Request): Promise<Record<string,
export const executeGatewayIntent = async <T>(
method: string,
params: unknown
params: unknown,
options?: { timeoutMs?: number }
): Promise<NextResponse> => {
const runtimeOrError = await ensureDomainIntentRuntime();
if (runtimeOrError instanceof Response) {
return runtimeOrError as NextResponse;
}
try {
const payload = await runtimeOrError.callGateway<T>(method, params);
const payload =
typeof options?.timeoutMs === "number"
? await runtimeOrError.callGateway<T>(method, params, options)
: await runtimeOrError.callGateway<T>(method, params);
return NextResponse.json({ ok: true, payload });
} catch (err) {
if (err instanceof ControlPlaneGatewayError) {
+20 -6
View File
@@ -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<string, unknown> =>
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<T = unknown>(method: string, params: unknown): Promise<T> {
async request<T = unknown>(
method: string,
params: unknown,
options?: { timeoutMs?: number }
): Promise<T> {
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<unknown>((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<T>(method, params);
return this.request<T>(method, params, options);
}
if (this.legacyProfileSwitchPromise && this.isTransientProfileSwitchError(error)) {
await this.legacyProfileSwitchPromise;
return this.request<T>(method, params);
return this.request<T>(method, params, options);
}
throw error;
}
+27 -4
View File
@@ -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<void> {
async ensureStarted(options: EnsureStartedOptions = {}): Promise<void> {
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<void> {
this.autoStartEnabled = false;
await this.adapter.stop();
}
@@ -40,7 +55,11 @@ export class ControlPlaneRuntime {
}
async reconnectForGatewaySettingsChange(): Promise<void> {
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<T = unknown>(method: string, params: unknown): Promise<T> {
return await this.adapter.request<T>(method, params);
async callGateway<T = unknown>(
method: string,
params: unknown,
options?: { timeoutMs?: number }
): Promise<T> {
return await this.adapter.request<T>(method, params, options);
}
close(): void {
+8
View File
@@ -20,12 +20,14 @@ export type StudioFocusedPreference = {
export type StudioSettings = {
version: 1;
gateway: StudioGatewaySettings | null;
gatewayAutoStart: boolean;
focused: Record<string, StudioFocusedPreference>;
avatars: Record<string, Record<string, string>>;
};
export type StudioSettingsPatch = {
gateway?: StudioGatewaySettingsPatch | null;
gatewayAutoStart?: boolean | null;
focused?: Record<string, Partial<StudioFocusedPreference> | null>;
avatars?: Record<string, Record<string, string | null> | null>;
};
@@ -180,6 +182,7 @@ const normalizeAvatars = (value: unknown): Record<string, Record<string, string>
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,
};
+17 -4
View File
@@ -92,6 +92,7 @@ type StudioGatewaySettingsState = {
| null;
saving: boolean;
testing: boolean;
disconnecting: boolean;
saveSettings: () => Promise<boolean>;
testConnection: () => Promise<boolean>;
disconnect: () => Promise<void>;
@@ -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<RuntimeSummaryEnvelope>("/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,
+81 -17
View File
@@ -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> = {}): 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(
+22 -3
View File
@@ -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,
});
@@ -10,6 +10,7 @@ describe("deriveHydrateAgentFleetResult", () => {
const settings: StudioSettings = {
version: 1,
gateway: null,
gatewayAutoStart: true,
focused: {},
avatars: {
"ws://localhost:18789": {
+15
View File
@@ -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();
});
});
+100
View File
@@ -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();
+20 -1
View File
@@ -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 () => {
+73 -1
View File
@@ -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 () => {
@@ -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",
@@ -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",
+8 -2
View File
@@ -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);
});
});
@@ -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);
});
});
+174
View File
@@ -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<typeof useStudioGatewaySettings>;
type Deferred<T> = {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error?: unknown) => void;
};
const createDeferred = <T,>(): Deferred<T> => {
let resolve!: (value: T) => void;
let reject!: (error?: unknown) => void;
const promise = new Promise<T>((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<void> | 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();
});
});