From 54c7976107d6162025e034d87626a91edfcef7c5 Mon Sep 17 00:00:00 2001
From: Zero <249002031@qq.com>
Date: Sun, 26 Jul 2026 22:30:45 +0800
Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dprovider=E5=88=B7=E4=B8=8D?=
=?UTF-8?q?=E5=87=BA=E6=9D=A5=E7=9A=84=E9=97=AE=E9=A2=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.agent-os/run-log.md | 24 ++++
.../use-providers-snapshot.hook.test.tsx | 115 ++++++++++++++++++
.../src/hooks/use-providers-snapshot.test.ts | 46 +++++++
.../app/src/hooks/use-providers-snapshot.ts | 49 +++++++-
4 files changed, 233 insertions(+), 1 deletion(-)
create mode 100644 packages/app/src/hooks/use-providers-snapshot.hook.test.tsx
diff --git a/.agent-os/run-log.md b/.agent-os/run-log.md
index 856cc9dc..0f3b6cc0 100644
--- a/.agent-os/run-log.md
+++ b/.agent-os/run-log.md
@@ -4355,3 +4355,27 @@ build:web`, `npm run check:foundation`, `npm run format:check` and `git diff --c
A fresh complete Foundation gate then passed lint/build/typecheck and all `565/565` tests
(`66 + 351 + 29 + 119`). The closeout is ready for one normal fast-forward of `agent/dev/mvp`; all Release and
independent-service boundaries remain unchanged.
+
+## 2026-07-26 [Provider settings loading recovery fixed]
+
+- Investigated the Providers settings page hanging on `Claude`, `Codex` and `OpenCode` as `loading`. The daemon-side
+ provider snapshot and manual refresh path were healthy: the live daemon on `127.0.0.1:6688` reported Claude and
+ Codex as ready with models and OpenCode as unavailable, so the failure was isolated to the App query/subscription
+ path.
+- Fixed `useProvidersSnapshot` to recover a first `loading` snapshot through the formal daemon
+ `refreshProvidersSnapshot -> getProvidersSnapshot` path. The recovery is scoped to enabled loading providers,
+ avoids missed push-event races, resets after non-loading snapshots and does not loop on changing snapshot
+ timestamps.
+- Added focused coverage for the loading-provider refresh key plus a hook-level race test where the first snapshot
+ returns `loading`, no websocket update arrives and the hook must refresh then fetch a resolved snapshot.
+ Verification passed:
+ `npm --workspace=@thoth/app run test -- src/hooks/use-providers-snapshot.hook.test.tsx src/hooks/use-providers-snapshot.test.ts src/hooks/providers-snapshot-query.test.ts`,
+ `git diff --check`, targeted `oxfmt --check` on the two changed files and `npm run build:web`.
+- Rebuilt the Web export and verified the live `http://127.0.0.1:8082/settings/hosts/srv_jwDukZXM1TnznKQAg7nwkg/providers`
+ page in the in-app browser: Claude and Codex display `available`, OpenCode displays `not installed`, and
+ `loading` is absent. The existing static web server and daemon remained running; reserved Paseo/legacy
+ `127.0.0.1:6767` was not touched.
+- Full `npm --workspace=@thoth/app run typecheck` remains blocked by unrelated existing App errors across
+ `react-dom` declarations, workspace/project tests, terminal/native styling and other files. Full
+ `npm run format:check` remains blocked by existing formatting findings in tracked `CLAUDE.md` link targets. No
+ top-next-action, Release, Relay, Provider installation or external service boundary changed.
diff --git a/packages/app/src/hooks/use-providers-snapshot.hook.test.tsx b/packages/app/src/hooks/use-providers-snapshot.hook.test.tsx
new file mode 100644
index 00000000..4e10656d
--- /dev/null
+++ b/packages/app/src/hooks/use-providers-snapshot.hook.test.tsx
@@ -0,0 +1,115 @@
+// @vitest-environment jsdom
+
+import React, { type ReactNode } from "react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { renderHook, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { ProviderSnapshotEntry } from "@thoth/protocol/agent-types";
+import type {
+ ProvidersSnapshotClient,
+ ProvidersSnapshotUpdateMessage,
+} from "./use-providers-snapshot";
+
+const hostState = vi.hoisted(() => ({
+ client: null as
+ | (ProvidersSnapshotClient & {
+ on: (
+ event: "providers_snapshot_update",
+ listener: (message: ProvidersSnapshotUpdateMessage) => void,
+ ) => () => void;
+ })
+ | null,
+ connected: true,
+ supportsSnapshot: true,
+}));
+
+vi.mock("@/runtime/host-runtime", () => ({
+ useHostRuntimeClient: () => hostState.client,
+ useHostRuntimeIsConnected: () => hostState.connected,
+}));
+
+vi.mock("@/runtime/host-features", () => ({
+ useHostFeature: () => hostState.supportsSnapshot,
+}));
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}));
+
+import { useProvidersSnapshot } from "./use-providers-snapshot";
+
+function snapshot(entries: ProviderSnapshotEntry[], generatedAt: string) {
+ return {
+ entries,
+ generatedAt,
+ requestId: generatedAt,
+ };
+}
+
+function providerEntry(status: ProviderSnapshotEntry["status"]): ProviderSnapshotEntry {
+ return {
+ provider: "codex",
+ status,
+ enabled: true,
+ ...(status === "ready"
+ ? { models: [{ provider: "codex" as const, id: "gpt-5.4", label: "GPT-5.4" }] }
+ : {}),
+ };
+}
+
+function createWrapper(): React.ComponentType<{ children: ReactNode }> {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ });
+ return function Wrapper({ children }: { children: ReactNode }) {
+ return {children};
+ };
+}
+
+describe("useProvidersSnapshot", () => {
+ beforeEach(() => {
+ hostState.connected = true;
+ hostState.supportsSnapshot = true;
+ });
+
+ afterEach(() => {
+ hostState.client = null;
+ vi.clearAllMocks();
+ });
+
+ it("recovers an initial loading snapshot by refreshing and fetching the resolved snapshot", async () => {
+ const readySnapshot = snapshot([providerEntry("ready")], "2026-01-01T00:00:01.000Z");
+ const getProvidersSnapshot = vi
+ .fn()
+ .mockResolvedValueOnce(snapshot([providerEntry("loading")], "2026-01-01T00:00:00.000Z"))
+ .mockResolvedValue(readySnapshot);
+ const refreshProvidersSnapshot = vi.fn().mockResolvedValue({
+ acknowledged: true,
+ requestId: "refresh-1",
+ });
+ const on = vi.fn(() => () => undefined);
+ hostState.client = {
+ getProvidersSnapshot,
+ refreshProvidersSnapshot,
+ on,
+ };
+
+ const { result } = renderHook(() => useProvidersSnapshot("server-1"), {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => {
+ expect(result.current.entries?.[0]?.status).toBe("ready");
+ });
+
+ expect(refreshProvidersSnapshot).toHaveBeenCalledTimes(1);
+ expect(refreshProvidersSnapshot).toHaveBeenCalledWith({});
+ expect(getProvidersSnapshot.mock.calls.length).toBeGreaterThanOrEqual(2);
+ expect(result.current.entries?.[0]?.models).toHaveLength(1);
+ });
+});
diff --git a/packages/app/src/hooks/use-providers-snapshot.test.ts b/packages/app/src/hooks/use-providers-snapshot.test.ts
index 7ca08028..150e7e51 100644
--- a/packages/app/src/hooks/use-providers-snapshot.test.ts
+++ b/packages/app/src/hooks/use-providers-snapshot.test.ts
@@ -7,6 +7,7 @@ import { draftAgentCommandsQueryKey } from "@/hooks/agent-commands-query";
import {
applyProvidersSnapshotUpdate,
fetchProvidersSnapshot,
+ loadingProvidersSnapshotRefreshKey,
providersSnapshotQueryKey,
refreshAndApplyProvidersSnapshot,
selectorOpenRefetchDecision,
@@ -77,6 +78,18 @@ function codexEntry(
};
}
+function providerEntry(
+ provider: string,
+ status: ProviderSnapshotEntry["status"],
+ enabled = true,
+): ProviderSnapshotEntry {
+ return {
+ provider,
+ status,
+ enabled,
+ };
+}
+
const readyCodexModel = { provider: "codex", id: "gpt-5.4", label: "GPT-5.4" } as const;
const serverId = "server-1";
@@ -351,3 +364,36 @@ describe("selectorOpenRefetchDecision", () => {
).toBe("refetch-stale");
});
});
+
+describe("loadingProvidersSnapshotRefreshKey", () => {
+ it("returns null when there are no loading entries", () => {
+ expect(
+ loadingProvidersSnapshotRefreshKey({
+ entries: [providerEntry("codex", "ready"), providerEntry("claude", "unavailable")],
+ scopeKey: "server-1/home",
+ }),
+ ).toBeNull();
+ });
+
+ it("ignores disabled loading entries", () => {
+ expect(
+ loadingProvidersSnapshotRefreshKey({
+ entries: [providerEntry("codex", "loading", false)],
+ scopeKey: "server-1/home",
+ }),
+ ).toBeNull();
+ });
+
+ it("builds a stable key from enabled loading providers and scope", () => {
+ expect(
+ loadingProvidersSnapshotRefreshKey({
+ entries: [
+ providerEntry("opencode", "loading"),
+ providerEntry("codex", "ready"),
+ providerEntry("claude", "loading"),
+ ],
+ scopeKey: "server-1/home",
+ }),
+ ).toBe("server-1/home:claude,opencode");
+ });
+});
diff --git a/packages/app/src/hooks/use-providers-snapshot.ts b/packages/app/src/hooks/use-providers-snapshot.ts
index cbd7af32..61e193ba 100644
--- a/packages/app/src/hooks/use-providers-snapshot.ts
+++ b/packages/app/src/hooks/use-providers-snapshot.ts
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useMemo } from "react";
+import { useCallback, useEffect, useMemo, useRef } from "react";
import { useMutation, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import type { AgentProvider, ProviderSnapshotEntry } from "@thoth/protocol/agent-types";
@@ -102,6 +102,21 @@ export function selectorOpenRefetchDecision(input: {
return "refetch-stale";
}
+export function loadingProvidersSnapshotRefreshKey(input: {
+ entries: ProviderSnapshotEntry[] | undefined;
+ scopeKey: string;
+}): string | null {
+ const loadingProviders =
+ input.entries
+ ?.filter((entry) => entry.enabled && entry.status === "loading")
+ .map((entry) => entry.provider)
+ .sort() ?? [];
+ if (loadingProviders.length === 0) {
+ return null;
+ }
+ return `${input.scopeKey}:${loadingProviders.join(",")}`;
+}
+
interface UseProvidersSnapshotResult {
entries: ProviderSnapshotEntry[] | undefined;
isLoading: boolean;
@@ -131,6 +146,8 @@ export function useProvidersSnapshot(
const supportsSnapshot = useHostFeature(serverId, "providersSnapshot");
const queryKey = useMemo(() => providersSnapshotQueryKey(serverId, cwd), [cwd, serverId]);
+ const scopeKey = useMemo(() => queryKey.join("\u0000"), [queryKey]);
+ const lastLoadingRefreshKey = useRef(null);
const snapshotQuery = useQuery({
queryKey,
@@ -173,6 +190,36 @@ export function useProvidersSnapshot(
});
}, [client, enabled, isConnected, queryClient, serverId, supportsSnapshot]);
+ useEffect(() => {
+ if (!enabled || !supportsSnapshot || !client || !isConnected || !serverId || isRefreshing) {
+ return;
+ }
+ const refreshKey = loadingProvidersSnapshotRefreshKey({
+ entries: snapshotQuery.data?.entries,
+ scopeKey,
+ });
+ if (!refreshKey) {
+ lastLoadingRefreshKey.current = null;
+ return;
+ }
+ if (lastLoadingRefreshKey.current === refreshKey) {
+ return;
+ }
+
+ lastLoadingRefreshKey.current = refreshKey;
+ void refreshSnapshot(undefined);
+ }, [
+ client,
+ enabled,
+ isConnected,
+ isRefreshing,
+ refreshSnapshot,
+ scopeKey,
+ serverId,
+ snapshotQuery.data?.entries,
+ supportsSnapshot,
+ ]);
+
const refresh = useCallback(
async (providers?: AgentProvider[]) => {
await refreshSnapshot(providers);