Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .agent-os/run-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
115 changes: 115 additions & 0 deletions packages/app/src/hooks/use-providers-snapshot.hook.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
}

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);
});
});
46 changes: 46 additions & 0 deletions packages/app/src/hooks/use-providers-snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { draftAgentCommandsQueryKey } from "@/hooks/agent-commands-query";
import {
applyProvidersSnapshotUpdate,
fetchProvidersSnapshot,
loadingProvidersSnapshotRefreshKey,
providersSnapshotQueryKey,
refreshAndApplyProvidersSnapshot,
selectorOpenRefetchDecision,
Expand Down Expand Up @@ -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";

Expand Down Expand Up @@ -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");
});
});
49 changes: 48 additions & 1 deletion packages/app/src/hooks/use-providers-snapshot.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<string | null>(null);

const snapshotQuery = useQuery({
queryKey,
Expand Down Expand Up @@ -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);
Expand Down