diff --git a/CHANGELOG.md b/CHANGELOG.md index c164b1879c..17a5f5092b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ ### 🐛 Bug Fixes +- **fix(dashboard):** the Home provider-topology widget now trusts the live provider-metrics snapshot — it uses `topology.errorProvider` and live `activeRequests` directly instead of re-deriving state from a stale `lastErrorAt` or applying a frontend timeout filter, so the topology reflects real-time provider health. Regression guard: `tests/unit/home-provider-topology-live-state.test.ts`. (thanks @xz-dev) - **fix(sse):** strip zero-width markers from streamed **tool-call arguments** — a follow-up to [#5857](https://github.com/diegosouzapw/OmniRoute/pull/5857). That PR removed injected zero-width joiners (U+200D) from streamed assistant text/reasoning but deliberately left tool-call argument JSON byte-exact. The request-side obfuscation (`open-sse/services/claudeCodeObfuscation.ts`) injects ZWJ into agent words — including the temp path inside the Bash tool description — and Claude models copy that verbatim into generated commands, which are delivered as tool-call arguments rather than assistant text. As a result the ZWJ survived and corrupted code blocks (e.g. a temp path rendered with an invisible joiner). Now `open-sse/handlers/responseSanitizer.ts` strips zero-width code points from tool-call argument strings at every emit site (OpenAI non-stream/stream chat `tool_calls` + legacy `function_call`, native Responses `function_call` items, the OpenAI→Responses conversion, and the native Responses streaming `response.function_call_arguments.delta/.done` events). Only zero-width code points are removed; JSON structure and all other bytes stay identical (no parse/restringify), so normal arguments remain byte-exact. Regression guard: 6 new cases in `tests/unit/response-sanitizer.test.ts` (suite 50/50). - **fix(nodejs):** the default app log path now resolves under `DATA_DIR` (`~/.omniroute/logs/application/app.log`) instead of `process.cwd()` ([#6197](https://github.com/diegosouzapw/OmniRoute/issues/6197)) — the globally-installed CLI runs from an arbitrary working directory, so anchoring the default to cwd made file logging silently write to (or no-op under) an unrelated directory, contradicting the documented `.env.example` default. `getAppLogFilePath()` now computes the default lazily via the pure `resolveDataDir()` resolver (honours a per-process `DATA_DIR`, no directory-creation side effect); an explicit `APP_LOG_FILE_PATH` still wins. Regression guard: `tests/unit/logenv-datadir-path-6197.test.ts` (3). diff --git a/src/app/(dashboard)/dashboard/HomePageClient.tsx b/src/app/(dashboard)/dashboard/HomePageClient.tsx index 37db2baa6b..1f376c147a 100644 --- a/src/app/(dashboard)/dashboard/HomePageClient.tsx +++ b/src/app/(dashboard)/dashboard/HomePageClient.tsx @@ -112,6 +112,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { const [baseUrl, setBaseUrl] = useState("/v1"); const [selectedProvider, setSelectedProvider] = useState(null); const [providerMetrics, setProviderMetrics] = useState>({}); + const [providerTopology, setProviderTopology] = useState({ lastProvider: "", errorProvider: "" }); const [providerNodes, setProviderNodes] = useState< Array<{ id?: string; prefix?: string; name?: string }> >([]); @@ -304,6 +305,10 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { const data = await metricsRes.json(); if (!cancelled) { setProviderMetrics(data.metrics || {}); + setProviderTopology({ + lastProvider: normalizeProviderId(data.topology?.lastProvider), + errorProvider: normalizeProviderId(data.topology?.errorProvider), + }); } } } catch (error) { @@ -494,28 +499,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { return Array.from(byProvider.values()); }, [providerStats, providerMetrics, providerNodes]); - const { lastProvider, errorProvider } = useMemo(() => { - let recentProvider = ""; - let recentTimestamp = 0; - let recentErrorProvider = ""; - let recentErrorTimestamp = 0; - - for (const [provider, metrics] of Object.entries(providerMetrics)) { - const requestTimestamp = metrics.lastRequestAt ? Date.parse(metrics.lastRequestAt) : 0; - if (Number.isFinite(requestTimestamp) && requestTimestamp > recentTimestamp) { - recentProvider = normalizeProviderId(provider); - recentTimestamp = requestTimestamp; - } - - const errorTimestamp = metrics.lastErrorAt ? Date.parse(metrics.lastErrorAt) : 0; - if (Number.isFinite(errorTimestamp) && errorTimestamp > recentErrorTimestamp) { - recentErrorProvider = normalizeProviderId(provider); - recentErrorTimestamp = errorTimestamp; - } - } - - return { lastProvider: recentProvider, errorProvider: recentErrorProvider }; - }, [providerMetrics]); + const { lastProvider, errorProvider } = providerTopology; const pollBackgroundUpdate = useCallback( async ({ diff --git a/src/app/(dashboard)/home/ProviderTopology.tsx b/src/app/(dashboard)/home/ProviderTopology.tsx index 6cef0924b3..da2503b8a1 100644 --- a/src/app/(dashboard)/home/ProviderTopology.tsx +++ b/src/app/(dashboard)/home/ProviderTopology.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo, useState, useEffect, useRef } from "react"; +import { useMemo } from "react"; import { useTranslations } from "next-intl"; import { Handle, Position, type Node, type Edge, type NodeTypes } from "@xyflow/react"; import { AI_PROVIDERS } from "@/shared/constants/providers"; @@ -10,9 +10,6 @@ import { StatusDot } from "@/shared/components/flow/StatusDot"; import { edgeStyle } from "@/shared/components/flow/edgeStyles"; import { resolveTopologyNodeLabel } from "./topologyLabel"; -const FE_ACTIVE_TIMEOUT_MS = 60_000; -const FE_ACTIVE_TICK_MS = 1_000; - // Rings: [capacity, rx, ry]. Each successive ring fits ~6 more nodes. const RINGS: [number, number, number][] = [ [8, 210, 132], @@ -271,44 +268,13 @@ export default function ProviderTopology({ const lastKey = lastProvider.toLowerCase(); const errorKey = errorProvider.toLowerCase(); - const rawActiveSet = useMemo( + const activeSet = useMemo( () => new Set(activeKey ? activeKey.split(",") : []), [activeKey] ); const lastSet = useMemo(() => new Set(lastKey ? [lastKey] : []), [lastKey]); const errorSet = useMemo(() => new Set(errorKey ? [errorKey] : []), [errorKey]); - const firstSeenRef = useRef>({}); - const [tick, setTick] = useState(0); - - useEffect(() => { - const seen = firstSeenRef.current; - const now = Date.now(); - for (const p of rawActiveSet) { - if (!seen[p]) seen[p] = now; - } - for (const p of Object.keys(seen)) { - if (!rawActiveSet.has(p)) delete seen[p]; - } - }, [rawActiveSet]); - - useEffect(() => { - if (rawActiveSet.size === 0) return; - const id = setInterval(() => setTick((t) => t + 1), FE_ACTIVE_TICK_MS); - return () => clearInterval(id); - }, [rawActiveSet]); - - const activeSet = useMemo(() => { - const now = Date.now(); - const filtered = new Set(); - for (const p of rawActiveSet) { - const ts = firstSeenRef.current[p]; - if (!ts || now - ts < FE_ACTIVE_TIMEOUT_MS) filtered.add(p); - } - return filtered; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [rawActiveSet, tick]); - const { nodes, edges } = useMemo( () => buildLayout(providers, activeSet, lastSet, errorSet), // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/tests/unit/home-provider-topology-live-state.test.ts b/tests/unit/home-provider-topology-live-state.test.ts new file mode 100644 index 0000000000..1c0e2bffbc --- /dev/null +++ b/tests/unit/home-provider-topology-live-state.test.ts @@ -0,0 +1,45 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const homePageClientSrc = readFileSync( + fileURLToPath(new URL("../../src/app/(dashboard)/dashboard/HomePageClient.tsx", import.meta.url)), + "utf8" +); + +const providerTopologySrc = readFileSync( + fileURLToPath(new URL("../../src/app/(dashboard)/home/ProviderTopology.tsx", import.meta.url)), + "utf8" +); + +test("home topology uses provider-metrics topology.errorProvider instead of re-deriving from stale lastErrorAt", () => { + assert.match( + homePageClientSrc, + /errorProvider:\s*normalizeProviderId\(data\.topology\?\.errorProvider\)/, + "HomePageClient should trust /api/provider-metrics topology.errorProvider" + ); + + const localTopologyDerivation = homePageClientSrc.match( + /const \{ lastProvider, errorProvider \} = useMemo[\s\S]*?\}, \[providerMetrics\]\);/ + ); + assert.equal( + localTopologyDerivation, + null, + "HomePageClient must not re-derive topology error state from providerMetrics.lastErrorAt" + ); +}); + +test("ProviderTopology treats live activeRequests as the current snapshot without frontend timeout filtering", () => { + assert.doesNotMatch( + providerTopologySrc, + /FE_ACTIVE_TIMEOUT_MS|FE_ACTIVE_TICK_MS|firstSeenRef|setInterval\(/, + "ProviderTopology must not expire long-running live requests on its own" + ); + + assert.match( + providerTopologySrc, + /const activeSet = useMemo\(\s*\(\) => new Set\(activeKey \? activeKey\.split\(","\) : \[\]\),\s*\[activeKey\]\s*\);/, + "activeSet should be derived directly from activeRequests/current live snapshot" + ); +});