From 497dd6f357f159f4d658237b838fb2d50df305b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rouzbeh=E2=80=A0?= <78313022+rqzbeh@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:20:54 +0330 Subject: [PATCH] fix(memory): auto-check Qdrant health on mount and stop false-red badge (#10489) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(memory): auto-check Qdrant health on mount and stop false-red badge The Qdrant engine card on /dashboard/memory?tab=engine showed a red "Error" badge after every page refresh even when Qdrant was healthy: the badge derives its state from a health check, but the mount effect only fetched settings + embedding models — health started as null and the render treated `health?.ok` (undefined) as a failure. Clicking "Test connection" (which runs the same server-side /readyz check) immediately turned it green, proving the connection was fine. Two changes: - Auto-run the health check on mount once settings load and Qdrant is enabled, so a refreshed page reflects the real state (verified live: /api/settings/qdrant/health returns ok:true in ~2ms on a healthy compose deployment). - While health has not been checked yet (null), render a neutral gray "Testing..." state instead of red — red is now reserved for an actual failed health check. Regression test added (fails on the old code): with enabled settings and a healthy mock, the card must hit /api/settings/qdrant/health on mount and show statusActive, never statusError. * chore(changelog): fragment for #10489 * Merge branch 'release/v3.8.50' into fix/qdrant-health-badge * test(fix): refresh expired alibaba quota sample validity and onnxruntime pin for v3.8.50 base - alibaba-free-tier-quota-fetcher.test.ts: sample quotaValidityPeriod (2026-08-16 16:00 UTC) is in the past, making every quota entry classify as expired/not_capable; bump to 2028-01-01 UTC so the text/merge classification tests exercise the intended path again. - optional-transformers-dependency.test.ts: onnxruntime-node pin assertion updated from ~1.24.3 to ~1.27.0 to match package.json (bumped by #10403); the regular-not-optional intent is unchanged. * test(fix): align optional-transformers-dependency with onnxruntime ~1.24.3 pin (base #10543) * docs(fix): sync 150-migration count and document PROXY_LOG_INCLUDE_IPS (base drift #10348/#10507) * fix(memory): re-check Qdrant health after saving settings save() optimistically flipped enabled and started the PUT while the mount effect could immediately GET /api/settings/qdrant/health against the OLD persisted settings. If that GET won, it returned not_configured/failed and - because health was non-null - the effect never retried after the PUT succeeded, leaving a healthy Qdrant red until a manual Test connection. Invalidate health (generation counter + setHealth(null)) at save start and after a successful PUT, then explicitly schedule a fresh check: setting health to null alone is not enough, React bails on the no-op when health is already null (the exact GET-wins ordering). Stale responses are dropped via the sequence guard so an in-flight pre-save check can never overwrite the post-save result. Adds a regression test covering enable ordering. Addresses PR #10489 review finding (issuecomment-5312271806). * fix: narrow omniglyph transform result union (merge base aa912c42a typecheck gate) * test(compression): align contract tests with base aa912c42a merge (providerTransport shape, engine metadata) * fix(memory): silence set-state-in-effect on Qdrant auto health-check The health-check re-check fix (3469234) introduced an effect that calls checkHealth() (an async fetch that eventually calls setState) directly from a useEffect gated on loading/enabled/health. The react-hooks/set-state-in-effect rule flags this as a potential cascading render, matching the same pattern already accepted elsewhere in the dashboard (FreePoolTab.tsx, ConnectionsTable.tsx) for gated async data-fetch effects. Suppress with the established inline convention; no behavior change. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(memory): drop unused set-state-in-effect disable (rule inert on pinned react-hooks 7.0.1) The eslint-disable-next-line for react-hooks/set-state-in-effect is unused: eslint-plugin-react-hooks@7.0.1 (lockfile-pinned) does not report this rule, so the directive itself was flagged as a warning and the 'No new ESLint warnings' CI gate failed with --max-warnings 0. The effect body only calls checkHealth() (async fetch) with no raw setState, so no disable is needed. * ci(quality): sync ratchet configs to release/v3.8.50 (0a74bfbde) merge - re-freeze open-sse typecheck baseline at merged-tree live counts (64 stale entries dropped, 11 frozen; base video/usage drift covered) - register tests/unit/video-bridge-drilldown-route.test.ts in stryker tap.testFiles - regenerate skills/cli-contexts/SKILL.md (contexts migrate docs from CLI closure) --------- Co-authored-by: Rouzbeh Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- .env.example | 5 + .../fixes/10489-qdrant-health-badge.md | 2 + .../quality/open-sse-typecheck-baseline.json | 171 +------------- docs/reference/ENVIRONMENT.md | 1 + skills/cli-contexts/SKILL.md | 14 ++ .../memory/components/QdrantConfigCard.tsx | 97 ++++++-- stryker.conf.json | 11 +- .../omniglyph-chatcore-plumbing.test.ts | 2 +- .../pipeline-circuit-breaker.test.ts | 1 + tests/unit/ui/qdrant-config-card.test.tsx | 217 +++++++++++++----- 10 files changed, 270 insertions(+), 251 deletions(-) create mode 100644 changelog.d/fixes/10489-qdrant-health-badge.md diff --git a/.env.example b/.env.example index 800a7801c8..228acc9a87 100644 --- a/.env.example +++ b/.env.example @@ -246,6 +246,11 @@ OMNIROUTE_USE_TURBOPACK=1 # hints in production logs. # OMNIROUTE_PROXY_FETCH_DEBUG=true +# Set to "true" or "1" to include client/egress IPs and the account prefix in +# the verbose `[ProxyEgress]` process-log line (src/lib/proxyLogger.ts). Kept +# OFF by default so the process log does not leak IPs or the account prefix. +# PROXY_LOG_INCLUDE_IPS=true + # Set to any non-empty value to emit `[omniroute completion]` diagnostics from # the CLI shell-completion cache paths (read/refresh/write) in # bin/cli/commands/completion.mjs. Off by default — these caches fail silently diff --git a/changelog.d/fixes/10489-qdrant-health-badge.md b/changelog.d/fixes/10489-qdrant-health-badge.md new file mode 100644 index 0000000000..f9c216e8a5 --- /dev/null +++ b/changelog.d/fixes/10489-qdrant-health-badge.md @@ -0,0 +1,2 @@ +- **fix(memory):** auto-check Qdrant health on mount and stop the false-red status badge on `/dashboard/memory?tab=engine` — the badge treated "not yet checked" (`health === null`) as a failure, so a healthy Qdrant showed red after every page refresh until "Test connection" was clicked; settings changes now also invalidate the stale result and re-check after the save persists, so a health check racing the settings PUT can no longer keep the badge red until a manual re-test ([#10489](https://github.com/diegosouzapw/OmniRoute/pull/10489)) +- **test(compression):** align source-contract tests with the merged `release/v3.8.50` base (`aa912c42a`) — accept the multi-line `providerTransport` shape in `omniglyph-chatcore-plumbing` and give the pipeline-circuit-breaker fixture a `metadata.executionStages` (both structural changes landed in the base merge) ([#10489](https://github.com/diegosouzapw/OmniRoute/pull/10489)) diff --git a/config/quality/open-sse-typecheck-baseline.json b/config/quality/open-sse-typecheck-baseline.json index dc91ce1890..c6de98b418 100644 --- a/config/quality/open-sse-typecheck-baseline.json +++ b/config/quality/open-sse-typecheck-baseline.json @@ -1,176 +1,15 @@ { - "open-sse/executors/azure-openai.ts": { - "TS2345": 1 - }, - "open-sse/executors/chatgpt-web.ts": { - "TS2339": 1 - }, - "open-sse/executors/claude-web/stream.ts": { - "TS2322": 1, - "TS2345": 1 - }, - "open-sse/executors/copilot-web.ts": { - "TS2353": 1 - }, - "open-sse/executors/deepseek-web.ts": { - "TS2352": 1 - }, - "open-sse/executors/default.ts": { - "TS2352": 1 - }, - "open-sse/executors/duckduckgo-web.ts": { - "TS2345": 2 - }, - "open-sse/executors/duckduckgo-web/challenge.ts": { - "TS2304": 1 - }, - "open-sse/executors/edgeTts.ts": { - "TS2345": 1 - }, - "open-sse/executors/gemini-business.ts": { - "TS2339": 1 - }, - "open-sse/executors/ghe-copilot.ts": { - "TS2554": 1 - }, - "open-sse/executors/inner-ai.ts": { - "TS2352": 2 - }, - "open-sse/executors/theoldllm.ts": { - "TS2322": 1 - }, - "open-sse/executors/veoaifree-web.ts": { - "TS2322": 1 - }, - "open-sse/executors/windsurf.ts": { - "TS2322": 1 - }, - "open-sse/handlers/chatCore.ts": { - "TS2339": 30, - "TS2322": 1, - "TS2345": 11 - }, - "open-sse/handlers/chatCore/claudeUpstreamMessages.ts": { - "TS2345": 1 - }, "open-sse/handlers/chatCore/clientUsageBuffer.ts": { - "TS2345": 1 - }, - "open-sse/handlers/chatCore/clineResponseEnvelope.ts": { - "TS2698": 1 - }, - "open-sse/handlers/chatCore/compressionAnalyticsWrite.ts": { - "TS2724": 1 - }, - "open-sse/handlers/chatCore/nonStreamingResponseHeaders.ts": { - "TS2322": 2 - }, - "open-sse/handlers/chatCore/sanitization.ts": { - "TS2339": 1, - "TS2537": 1 - }, - "open-sse/handlers/chatCore/semanticCacheStore.ts": { - "TS2345": 1 - }, - "open-sse/handlers/chatCore/streamingPipeline.ts": { "TS2345": 2 }, - "open-sse/handlers/chatCore/streamingSemanticCacheStore.ts": { - "TS2345": 1 - }, - "open-sse/handlers/chatCore/thinkingSignatureRecovery.ts": { - "TS2339": 2 - }, - "open-sse/handlers/imageGeneration.ts": { - "TS2554": 2 - }, - "open-sse/handlers/responsesHandler.ts": { - "TS2339": 1, - "TS2345": 1 - }, - "open-sse/handlers/sseParser.ts": { - "TS2322": 2 - }, - "open-sse/handlers/videoGeneration.ts": { - "TS2339": 2 - }, - "open-sse/mcp-server/tools/compressionTools.ts": { - "TS2339": 2 - }, - "open-sse/services/__tests__/specificityDetector.test.ts": { - "TS2353": 2 - }, - "open-sse/services/browserBackedChat.ts": { - "TS2322": 1, - "TS2794": 1 - }, - "open-sse/services/claudeAdaptiveThinking.ts": { - "TS2352": 2 - }, - "open-sse/services/comboManifestMetrics.ts": { - "TS2307": 1 - }, - "open-sse/services/compression/engines/ccr/index.ts": { - "TS2339": 1 - }, - "open-sse/services/payloadRules.ts": { - "TS2677": 1 - }, - "open-sse/services/tokenLimitCounter.ts": { - "TS2551": 1 - }, - "open-sse/transformer/responsesTransformer.ts": { - "TS2339": 1 - }, "open-sse/utils/stream.ts": { - "TS2339": 7, - "TS2345": 1, - "TS2556": 1 + "TS2345": 2, + "TS2322": 2 }, - "src/app/api/v1/_shared/mediaGenerationRoute.ts": { - "TS2339": 2 - }, - "src/app/api/v1/models/catalog.ts": { - "TS2345": 1 - }, - "src/app/api/v1/models/catalogVision.ts": { - "TS2322": 1 - }, - "src/app/api/v1/videos/generations/route.ts": { + "src/lib/guardrails/videoBridgeHelpers.ts": { + "TS2488": 1, + "TS2365": 2, "TS2322": 1, "TS2345": 1 - }, - "src/lib/guardrails/visionBridge.ts": { - "TS2345": 1 - }, - "src/lib/providers/codexFastTier.ts": { - "TS2367": 1 - }, - "src/lib/skills/builtins.ts": { - "TS2322": 1 - }, - "src/lib/skills/injection.ts": { - "TS2339": 1 - }, - "src/lib/skills/webFetchExecution.ts": { - "TS2322": 1 - }, - "src/lib/streamingPiiTransform.ts": { - "TS2345": 1 - }, - "src/shared/providers/webSessionCredentials.ts": { - "TS2353": 1, - "TS2322": 1 - }, - "src/shared/validation/helpers.ts": { - "TS2339": 1 - }, - "src/sse/handlers/chat.ts": { - "TS2352": 1, - "TS2322": 2, - "TS2339": 1 - }, - "src/sse/services/model.ts": { - "TS2339": 4 } } diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 91a21fdec8..dac6106abd 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -104,6 +104,7 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari | `OMNIROUTE_SPEND_FLUSH_INTERVAL_MS` | _(default in code)_ | `src/lib/spend/batchWriter.ts` | Flush interval (ms) for the batched spend/cost writer. Lower values reduce write coalescing; higher values reduce DB contention. | | `OMNIROUTE_SPEND_MAX_BUFFER_SIZE` | _(default in code)_ | `src/lib/spend/batchWriter.ts` | Max buffered spend entries before a forced flush. Raise on high-QPS deployments; lower when bounded memory matters more. | | `OMNIROUTE_PROXY_FETCH_DEBUG` | _(unset)_ | `open-sse/utils/proxyFetch.ts` | Set to `"true"` to emit `[ProxyFetch]` debug logs on the Vercel relay path. Off by default to avoid leaking routing hints. | +| `PROXY_LOG_INCLUDE_IPS` | `false` | `src/lib/proxyLogger.ts` | Set to `"true"` or `"1"` to include client/egress IPs and the account prefix in the verbose `[ProxyEgress]` process-log line. Kept OFF by default so the process log does not leak IPs or the account prefix. | | `OMNIROUTE_DEBUG_COMPLETION` | _(unset)_ | `bin/cli/commands/completion.mjs` | Set to any non-empty value to emit `[omniroute completion]` diagnostics from the CLI shell-completion cache paths (read/refresh/write). Off by default — those caches fail silently so a missing/corrupt cache never breaks tab-completion. | | `BATCH_RETRY_DURATION_MS` | `86400000` (24h) | `open-sse/services/batchProcessor.ts` | Maximum retry window for individual batch items (ms). Items exceeding this duration are marked failed. | | `BATCH_BACKOFF_BASE_MS` | `5000` | `open-sse/services/batchProcessor.ts` | Base delay (ms) for exponential backoff on batch item retries. | diff --git a/skills/cli-contexts/SKILL.md b/skills/cli-contexts/SKILL.md index c25640c92f..ae23e91f53 100644 --- a/skills/cli-contexts/SKILL.md +++ b/skills/cli-contexts/SKILL.md @@ -325,6 +325,20 @@ Import contexts from a JSON file omniroute contexts import ``` +### `contexts migrate` + +Move legacy plaintext context credentials to the OS keychain + +**Flags:** + +- `--yes` + +**Example:** + +```bash +omniroute contexts migrate +``` + ### `sessions` **Example:** diff --git a/src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard.tsx b/src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard.tsx index 5fc31527fd..c05b5ac4ee 100644 --- a/src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard.tsx +++ b/src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import { useTranslations } from "next-intl"; import { Card } from "@/shared/components"; @@ -47,6 +47,12 @@ export default function QdrantConfigCard() { const [embeddingOptions, setEmbeddingOptions] = useState([]); const [loading, setLoading] = useState(true); + // Generation counter for health checks. Bumping it invalidates any in-flight + // or already-resolved check so a stale result (for example one that raced a + // settings save and read the pre-save configuration) can never be applied + // out of order. + const healthSeqRef = useRef(0); + useEffect(() => { Promise.all([ fetch("/api/settings/qdrant").then((r) => (r.ok ? r.json() : null)), @@ -65,10 +71,40 @@ export default function QdrantConfigCard() { .finally(() => setLoading(false)); }, []); + const checkHealth = useCallback(async () => { + const seq = ++healthSeqRef.current; + setChecking(true); + try { + const res = await fetch("/api/settings/qdrant/health"); + if (res.ok) { + const data = await res.json(); + // Drop the result if a newer save/check invalidated this one. + if (healthSeqRef.current !== seq) return; + setHealth(data); + } else { + if (healthSeqRef.current !== seq) return; + setHealth({ ok: false, latencyMs: 0, error: "HTTP error" }); + } + } catch (e) { + if (healthSeqRef.current !== seq) return; + setHealth({ + ok: false, + latencyMs: 0, + error: e instanceof Error ? e.message : String(e), + }); + } finally { + if (healthSeqRef.current === seq) setChecking(false); + } + }, []); + const save = useCallback( async (updates: Partial & { apiKey?: string }) => { const prev = qdrant; const next = { ...qdrant, ...updates }; + // Settings are changing, so any prior health result is stale: drop it and + // invalidate in-flight checks so they cannot overwrite the new state. + healthSeqRef.current += 1; + setHealth(null); setQdrant(next); setSaving(true); setSaveStatus(""); @@ -91,6 +127,16 @@ export default function QdrantConfigCard() { setQdrant(data); setApiKeyInput(""); setSaveStatus("saved"); + // A health check started during the optimistic window (enabled just + // flipped and health was null) can race the PUT and read the OLD + // persisted settings -> not_configured/failed. Invalidate it and + // schedule a fresh check against the just-persisted settings. This + // must be explicit: if health was still null the mount effect bails + // on the setHealth(null) no-op, so a healthy Qdrant would stay red + // until a manual test. + healthSeqRef.current += 1; + setHealth(null); + void checkHealth(); setTimeout(() => setSaveStatus(""), 2000); } else { setQdrant(prev); @@ -103,25 +149,18 @@ export default function QdrantConfigCard() { setSaving(false); } }, - [qdrant] + [qdrant, checkHealth] ); - const checkHealth = useCallback(async () => { - setChecking(true); - try { - const res = await fetch("/api/settings/qdrant/health"); - if (res.ok) setHealth(await res.json()); - else setHealth({ ok: false, latencyMs: 0, error: "HTTP error" }); - } catch (e) { - setHealth({ - ok: false, - latencyMs: 0, - error: e instanceof Error ? e.message : String(e), - }); - } finally { - setChecking(false); + // Auto-check on mount once settings load: without this the status badge + // renders red after a page refresh because `health` starts as null and the + // old code treated "not checked yet" the same as "failed". The Test + // connection button still drives the same check manually. + useEffect(() => { + if (!loading && qdrant.enabled && health === null) { + void checkHealth(); } - }, []); + }, [loading, qdrant.enabled, health, checkHealth]); const runSearch = useCallback(async () => { const q = searchQuery.trim(); @@ -185,18 +224,32 @@ export default function QdrantConfigCard() { {qdrant.enabled - ? health?.ok - ? t("qdrant.statusActive") - : t("qdrant.statusError") + ? health === null + ? t("qdrant.testing") + : health.ok + ? t("qdrant.statusActive") + : t("qdrant.statusError") : t("qdrant.statusDisabled")} diff --git a/stryker.conf.json b/stryker.conf.json index bd69fbc147..61c50f3db9 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -39,9 +39,7 @@ "incremental": true, "incrementalFile": "reports/mutation/stryker-incremental.json", "testRunner": "tap", - "plugins": [ - "@stryker-mutator/tap-runner" - ], + "plugins": ["@stryker-mutator/tap-runner"], "tap": { "testFiles": [ "tests/unit/7993-noauth-proxy-routing.test.ts", @@ -358,6 +356,7 @@ "tests/unit/usage-service-hardening.test.ts", "tests/unit/validate-response-quality.test.ts", "tests/unit/vertex-passthrough-model-lockout.test.ts", + "tests/unit/video-bridge-drilldown-route.test.ts", "tests/unit/video-bridge-route-security.test.ts", "tests/unit/xai-agent-tools-passthrough.test.ts" ], @@ -468,11 +467,7 @@ ".worktrees", ".stryker-tmp" ], - "reporters": [ - "progress", - "html", - "json" - ], + "reporters": ["progress", "html", "json"], "htmlReporter": { "fileName": "reports/mutation/mutation.html" }, diff --git a/tests/unit/compression/omniglyph-chatcore-plumbing.test.ts b/tests/unit/compression/omniglyph-chatcore-plumbing.test.ts index a21a3af9ff..73f7af4875 100644 --- a/tests/unit/compression/omniglyph-chatcore-plumbing.test.ts +++ b/tests/unit/compression/omniglyph-chatcore-plumbing.test.ts @@ -7,6 +7,6 @@ test("chatCore treats both Anthropic providers as direct OmniGlyph transports", assert.match( chatCore, - /providerTransport:\s*provider === "anthropic" \|\| provider === "claude"[\s\S]{0,80}?"direct"/ + /providerTransport:\s*provider === "anthropic"\s*\|\|\s*provider === "claude"[\s\S]{0,160}?"direct"/ ); }); diff --git a/tests/unit/compression/pipeline-circuit-breaker.test.ts b/tests/unit/compression/pipeline-circuit-breaker.test.ts index 49fec449a1..e914ad3224 100644 --- a/tests/unit/compression/pipeline-circuit-breaker.test.ts +++ b/tests/unit/compression/pipeline-circuit-breaker.test.ts @@ -100,6 +100,7 @@ describe("pipelineEngineBreaker — pipeline integration", () => { name: "throwing test engine", targets: ["messages"], stackable: true, + metadata: { executionStages: ["pre-translation"] }, apply() { calls += 1; throw new Error("boom"); diff --git a/tests/unit/ui/qdrant-config-card.test.tsx b/tests/unit/ui/qdrant-config-card.test.tsx index 2f61d68a83..846c11a8cc 100644 --- a/tests/unit/ui/qdrant-config-card.test.tsx +++ b/tests/unit/ui/qdrant-config-card.test.tsx @@ -37,8 +37,9 @@ function makeContainer(): HTMLElement { describe("QdrantConfigCard", () => { beforeEach(() => { - (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = - true; + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; globalThis.fetch = vi.fn().mockImplementation((url: string) => { if (url === "/api/settings/qdrant") { return Promise.resolve({ @@ -68,9 +69,8 @@ describe("QdrantConfigCard", () => { }); it("renders after loading qdrant settings", async () => { - const { default: QdrantConfigCard } = await import( - "../../../src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard" - ); + const { default: QdrantConfigCard } = + await import("../../../src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard"); const container = makeContainer(); const root = createRoot(container); await act(async () => { @@ -110,9 +110,8 @@ describe("QdrantConfigCard", () => { }); globalThis.fetch = fetchMock; - const { default: QdrantConfigCard } = await import( - "../../../src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard" - ); + const { default: QdrantConfigCard } = + await import("../../../src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard"); const container = makeContainer(); const root = createRoot(container); await act(async () => { @@ -123,7 +122,7 @@ describe("QdrantConfigCard", () => { }); const toggleBtn = container.querySelector( - "[data-testid='qdrant-enabled-switch']", + "[data-testid='qdrant-enabled-switch']" ) as HTMLButtonElement | null; expect(toggleBtn).toBeTruthy(); await act(async () => { @@ -135,9 +134,7 @@ describe("QdrantConfigCard", () => { const putCalls = fetchMock.mock.calls.filter( (c: [string, { method?: string }]) => - typeof c[0] === "string" && - c[0] === "/api/settings/qdrant" && - c[1]?.method === "PUT", + typeof c[0] === "string" && c[0] === "/api/settings/qdrant" && c[1]?.method === "PUT" ); expect(putCalls.length).toBeGreaterThan(0); }); @@ -166,9 +163,8 @@ describe("QdrantConfigCard", () => { }); globalThis.fetch = fetchMock; - const { default: QdrantConfigCard } = await import( - "../../../src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard" - ); + const { default: QdrantConfigCard } = + await import("../../../src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard"); const container = makeContainer(); const root = createRoot(container); await act(async () => { @@ -179,7 +175,7 @@ describe("QdrantConfigCard", () => { }); const testBtn = container.querySelector( - "[data-testid='qdrant-test-connection']", + "[data-testid='qdrant-test-connection']" ) as HTMLButtonElement | null; expect(testBtn).toBeTruthy(); await act(async () => { @@ -190,7 +186,7 @@ describe("QdrantConfigCard", () => { }); const healthCalls = fetchMock.mock.calls.filter( - (c: [string]) => typeof c[0] === "string" && c[0] === "/api/settings/qdrant/health", + (c: [string]) => typeof c[0] === "string" && c[0] === "/api/settings/qdrant/health" ); expect(healthCalls.length).toBeGreaterThan(0); // Health OK result should be shown @@ -198,37 +194,36 @@ describe("QdrantConfigCard", () => { }); it("search test button calls /api/settings/qdrant/search and renders results", async () => { - const fetchMock = vi.fn().mockImplementation((url: string, opts?: { method?: string; body?: string }) => { - if (url === "/api/settings/qdrant") { - return Promise.resolve({ - ok: true, - json: async () => MOCK_QDRANT_SETTINGS, - }); - } - if (url === "/api/settings/qdrant/embedding-models") { - return Promise.resolve({ - ok: true, - json: async () => ({ models: [] }), - }); - } - if (url === "/api/settings/qdrant/search" && opts?.method === "POST") { - return Promise.resolve({ - ok: true, - json: async () => ({ + const fetchMock = vi + .fn() + .mockImplementation((url: string, opts?: { method?: string; body?: string }) => { + if (url === "/api/settings/qdrant") { + return Promise.resolve({ ok: true, - results: [ - { id: "r1", score: 0.9876, payload: { content: "test content" } }, - ], - }), - }); - } - return Promise.resolve({ ok: true, json: async () => ({}) }); - }); + json: async () => MOCK_QDRANT_SETTINGS, + }); + } + if (url === "/api/settings/qdrant/embedding-models") { + return Promise.resolve({ + ok: true, + json: async () => ({ models: [] }), + }); + } + if (url === "/api/settings/qdrant/search" && opts?.method === "POST") { + return Promise.resolve({ + ok: true, + json: async () => ({ + ok: true, + results: [{ id: "r1", score: 0.9876, payload: { content: "test content" } }], + }), + }); + } + return Promise.resolve({ ok: true, json: async () => ({}) }); + }); globalThis.fetch = fetchMock; - const { default: QdrantConfigCard } = await import( - "../../../src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard" - ); + const { default: QdrantConfigCard } = + await import("../../../src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard"); const container = makeContainer(); const root = createRoot(container); await act(async () => { @@ -240,7 +235,7 @@ describe("QdrantConfigCard", () => { // Set search query by manipulating the input const searchInputs = Array.from(container.querySelectorAll("input")).filter( - (i) => i.type !== "password" && i.type !== "number", + (i) => i.type !== "password" && i.type !== "number" ); // The search query input is the one with the search placeholder const searchInput = searchInputs[searchInputs.length - 1] as HTMLInputElement | null; @@ -250,7 +245,7 @@ describe("QdrantConfigCard", () => { if (searchInput) { const nativeSetter = Object.getOwnPropertyDescriptor( window.HTMLInputElement.prototype, - "value", + "value" )?.set; nativeSetter?.call(searchInput, "test query"); searchInput.dispatchEvent(new Event("change", { bubbles: true })); @@ -258,7 +253,7 @@ describe("QdrantConfigCard", () => { }); const searchTestBtn = container.querySelector( - "[data-testid='qdrant-search-test']", + "[data-testid='qdrant-search-test']" ) as HTMLButtonElement | null; expect(searchTestBtn).toBeTruthy(); await act(async () => { @@ -272,7 +267,7 @@ describe("QdrantConfigCard", () => { (c: [string, { method?: string }]) => typeof c[0] === "string" && c[0] === "/api/settings/qdrant/search" && - c[1]?.method === "POST", + c[1]?.method === "POST" ); expect(searchCalls.length).toBeGreaterThan(0); }); @@ -301,9 +296,8 @@ describe("QdrantConfigCard", () => { }); globalThis.fetch = fetchMock; - const { default: QdrantConfigCard } = await import( - "../../../src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard" - ); + const { default: QdrantConfigCard } = + await import("../../../src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard"); const container = makeContainer(); const root = createRoot(container); await act(async () => { @@ -314,7 +308,7 @@ describe("QdrantConfigCard", () => { }); const cleanupBtn = container.querySelector( - "[data-testid='qdrant-cleanup']", + "[data-testid='qdrant-cleanup']" ) as HTMLButtonElement | null; expect(cleanupBtn).toBeTruthy(); await act(async () => { @@ -328,10 +322,125 @@ describe("QdrantConfigCard", () => { (c: [string, { method?: string }]) => typeof c[0] === "string" && c[0] === "/api/settings/qdrant/cleanup" && - c[1]?.method === "POST", + c[1]?.method === "POST" ); expect(cleanupCalls.length).toBeGreaterThan(0); // Shows cleanup success message expect(container.textContent).toContain("qdrant.cleanupSuccess"); }); + + it("auto-checks health on mount when enabled (no red error after refresh)", async () => { + const fetchMock = vi.fn().mockImplementation((url: string) => { + if (url === "/api/settings/qdrant") { + return Promise.resolve({ + ok: true, + json: async () => ({ ...MOCK_QDRANT_SETTINGS, enabled: true }), + }); + } + if (url === "/api/settings/qdrant/embedding-models") { + return Promise.resolve({ + ok: true, + json: async () => ({ models: [] }), + }); + } + if (url === "/api/settings/qdrant/health") { + return Promise.resolve({ + ok: true, + json: async () => ({ ok: true, latencyMs: 2 }), + }); + } + return Promise.resolve({ ok: true, json: async () => ({}) }); + }); + globalThis.fetch = fetchMock; + + const { default: QdrantConfigCard } = + await import("../../../src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard"); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + + // The health endpoint must be hit automatically on mount — no manual + // "Test connection" click required. Regression: the badge used to render + // red after a page refresh because health started as null and the mount + // effect never checked it. + const healthCalls = fetchMock.mock.calls.filter( + (c: [string]) => typeof c[0] === "string" && c[0] === "/api/settings/qdrant/health" + ); + expect(healthCalls.length).toBeGreaterThan(0); + // Badge shows the real healthy state, not a red error. + expect(container.textContent).toContain("qdrant.statusActive"); + expect(container.textContent).not.toContain("qdrant.statusError"); + }); + + it("re-checks health after a successful save so a stale optimistic-window result cannot leave the badge red (enable ordering)", async () => { + let healthFetchCount = 0; + const fetchMock = vi.fn().mockImplementation((url: string, opts?: { method?: string }) => { + if (url === "/api/settings/qdrant" && opts?.method === "PUT") { + return Promise.resolve({ + ok: true, + json: async () => ({ ...MOCK_QDRANT_SETTINGS, enabled: true }), + }); + } + if (url === "/api/settings/qdrant") { + return Promise.resolve({ ok: true, json: async () => MOCK_QDRANT_SETTINGS }); + } + if (url === "/api/settings/qdrant/embedding-models") { + return Promise.resolve({ ok: true, json: async () => ({ models: [] }) }); + } + if (url === "/api/settings/qdrant/health") { + healthFetchCount += 1; + // The first GET races the settings PUT and sees the OLD persisted + // config (enabled=false) -> not_configured. Post-PUT checks see a + // healthy Qdrant. + if (healthFetchCount === 1) { + return Promise.resolve({ + ok: true, + json: async () => ({ ok: false, latencyMs: 0, error: "not configured" }), + }); + } + return Promise.resolve({ ok: true, json: async () => ({ ok: true, latencyMs: 2 }) }); + } + return Promise.resolve({ ok: true, json: async () => ({}) }); + }); + globalThis.fetch = fetchMock; + + const { default: QdrantConfigCard } = + await import("../../../src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard"); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + + // Enable Qdrant: save() optimistically flips the switch and starts the + // PUT while the mount effect immediately GETs health against the OLD + // persisted settings (returned as not_configured above). + const toggleBtn = container.querySelector( + "[data-testid='qdrant-enabled-switch']" + ) as HTMLButtonElement | null; + expect(toggleBtn).toBeTruthy(); + await act(async () => { + toggleBtn?.click(); + }); + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + + // The stale first check must not be the last word: a fresh health GET must + // be scheduled after the PUT succeeds so the badge ends green. + const allHealthCalls = fetchMock.mock.calls.filter( + (c: [string]) => typeof c[0] === "string" && c[0] === "/api/settings/qdrant/health" + ); + expect(allHealthCalls.length).toBeGreaterThanOrEqual(2); + expect(container.textContent).toContain("qdrant.statusActive"); + expect(container.textContent).not.toContain("qdrant.statusError"); + }); });