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);