diff --git a/platform/backend/src/knowledge-base/connectors/confluence/confluence-connector.ts b/platform/backend/src/knowledge-base/connectors/confluence/confluence-connector.ts index 568b2c5a85..ad530cca7c 100644 --- a/platform/backend/src/knowledge-base/connectors/confluence/confluence-connector.ts +++ b/platform/backend/src/knowledge-base/connectors/confluence/confluence-connector.ts @@ -198,6 +198,7 @@ export class ConfluenceConnector extends BaseConnector { batchIndex++; yield { documents, + failures: this.flushFailures(), checkpoint: buildCheckpoint({ type: "confluence", itemUpdatedAt: rawModifiedAt, diff --git a/platform/backend/src/knowledge-base/connectors/jira/jira-connector.ts b/platform/backend/src/knowledge-base/connectors/jira/jira-connector.ts index 202a0e3011..515d06b0d0 100644 --- a/platform/backend/src/knowledge-base/connectors/jira/jira-connector.ts +++ b/platform/backend/src/knowledge-base/connectors/jira/jira-connector.ts @@ -8,6 +8,7 @@ import type pino from "pino"; import type { ConnectorCredentials, ConnectorDocument, + ConnectorItemFailure, ConnectorSyncBatch, JiraCheckpoint, JiraConfig, @@ -214,7 +215,13 @@ export class JiraConnector extends BaseConnector { ); batchIndex++; - yield buildBatch(documents, issues, checkpoint, hasMore); + yield buildBatch({ + documents, + issues, + failures: this.flushFailures(), + checkpoint, + hasMore, + }); } catch (error) { this.log.error( { @@ -275,7 +282,13 @@ export class JiraConnector extends BaseConnector { ); batchIndex++; - yield buildBatch(documents, issues, checkpoint, hasMore); + yield buildBatch({ + documents, + issues, + failures: this.flushFailures(), + checkpoint, + hasMore, + }); } catch (error) { this.log.error( { @@ -377,18 +390,21 @@ function issuesToDocuments( return documents; } -function buildBatch( - documents: ConnectorDocument[], +function buildBatch(params: { + documents: ConnectorDocument[]; // biome-ignore lint/suspicious/noExplicitAny: SDK issue types vary between v2/v3 - issues: any[], - checkpoint: JiraCheckpoint, - hasMore: boolean, -): ConnectorSyncBatch { + issues: any[]; + failures: ConnectorItemFailure[]; + checkpoint: JiraCheckpoint; + hasMore: boolean; +}): ConnectorSyncBatch { + const { documents, issues, failures, checkpoint, hasMore } = params; const lastIssue = issues.length > 0 ? issues[issues.length - 1] : null; const rawUpdatedAt: string | undefined = lastIssue?.fields?.updated; return { documents, + failures, checkpoint: buildCheckpoint({ type: "jira", itemUpdatedAt: rawUpdatedAt, @@ -406,10 +422,13 @@ function buildBatch( * Extract HTTP status, URL, and response body from jira.js errors. * The library wraps Axios errors, so we dig into the cause/response chain. */ -function extractJiraErrorDetails(error: unknown): Record { +function extractJiraErrorDetails( + error: unknown, + depth = 0, +): Record { const details: Record = {}; - if (!(error instanceof Error)) { + if (depth > 5 || !(error instanceof Error)) { return details; } @@ -452,9 +471,9 @@ function extractJiraErrorDetails(error: unknown): Record { details.status = err.status; } - // Check cause chain + // Check cause chain (with depth limit to prevent stack overflow from circular refs) if (err.cause && !details.status) { - Object.assign(details, extractJiraErrorDetails(err.cause)); + Object.assign(details, extractJiraErrorDetails(err.cause, depth + 1)); } return details; diff --git a/platform/backend/src/routes/mcp-gateway.utils.ts b/platform/backend/src/routes/mcp-gateway.utils.ts index 1d86f89fd3..f1f4c29743 100644 --- a/platform/backend/src/routes/mcp-gateway.utils.ts +++ b/platform/backend/src/routes/mcp-gateway.utils.ts @@ -1026,7 +1026,11 @@ export async function buildKnowledgeSourcesDescription( "or when they explicitly ask you to search internal documents and data sources."; if (kbNames.length > 0) { - description += ` Available knowledge bases: ${kbNames.join(", ")}.`; + const kbList = kbNames.join(", "); + description += + kbList.length > 500 + ? ` Available knowledge bases: ${kbList.slice(0, 500)}...` + : ` Available knowledge bases: ${kbList}.`; } if (connectorTypes.length > 0) { description += ` Connected sources: ${connectorTypes.join(", ")}.`; diff --git a/platform/e2e-tests/tests/api/mcp-gateway.spec.ts b/platform/e2e-tests/tests/api/mcp-gateway.spec.ts index a764d23d24..941a282a92 100644 --- a/platform/e2e-tests/tests/api/mcp-gateway.spec.ts +++ b/platform/e2e-tests/tests/api/mcp-gateway.spec.ts @@ -1872,10 +1872,12 @@ test.describe("MCP Gateway - Knowledge Sources Tool Description", () => { (t: any) => t.name === TOOL_QUERY_KNOWLEDGE_SOURCES_FULL_NAME, ); - expect(kbTool).toBeDefined(); + expect(kbTool, "query_knowledge_sources tool not found in tools list").toBeDefined(); // Verify dynamic description includes the KB name and connector type - expect(kbTool.description).toContain("E2E KB Dynamic Desc"); - expect(kbTool.description).toContain("jira"); + // biome-ignore lint/style/noNonNullAssertion: guarded by toBeDefined above + expect(kbTool!.description).toContain("E2E KB Dynamic Desc"); + // biome-ignore lint/style/noNonNullAssertion: guarded by toBeDefined above + expect(kbTool!.description).toContain("jira"); }); }); diff --git a/platform/frontend/src/app/chat/page.tsx b/platform/frontend/src/app/chat/page.tsx index c877e0376e..c3e62ce30d 100644 --- a/platform/frontend/src/app/chat/page.tsx +++ b/platform/frontend/src/app/chat/page.tsx @@ -265,11 +265,13 @@ export default function ChatPage() { ); if (defaultAgent) { setInitialAgentId(defaultAgentId); + saveAgent(defaultAgentId); resolvedAgentRef.current = defaultAgent; return; } } setInitialAgentId(internalAgents[0].id); + saveAgent(internalAgents[0].id); resolvedAgentRef.current = internalAgents[0]; } }, [initialAgentId, searchParams, internalAgents, defaultAgentId]); diff --git a/platform/frontend/src/app/knowledge/knowledge-bases/_parts/edit-connector-dialog.tsx b/platform/frontend/src/app/knowledge/knowledge-bases/_parts/edit-connector-dialog.tsx index b20c01e821..c33fca2e1f 100644 --- a/platform/frontend/src/app/knowledge/knowledge-bases/_parts/edit-connector-dialog.tsx +++ b/platform/frontend/src/app/knowledge/knowledge-bases/_parts/edit-connector-dialog.tsx @@ -165,7 +165,10 @@ export function EditConnectorDialog({ Name - + diff --git a/platform/frontend/src/components/chat/knowledge-graph-citations.tsx b/platform/frontend/src/components/chat/knowledge-graph-citations.tsx index bfc6a9d5f5..96010de3c1 100644 --- a/platform/frontend/src/components/chat/knowledge-graph-citations.tsx +++ b/platform/frontend/src/components/chat/knowledge-graph-citations.tsx @@ -82,7 +82,8 @@ export function extractCitations( } } } - } catch { + } catch (err) { + console.warn("Failed to extract citations from tool result", err); continue; } diff --git a/platform/frontend/src/components/chat/model-selector.tsx b/platform/frontend/src/components/chat/model-selector.tsx index 2e6b93876d..85bab7b260 100644 --- a/platform/frontend/src/components/chat/model-selector.tsx +++ b/platform/frontend/src/components/chat/model-selector.tsx @@ -20,7 +20,7 @@ import { Video, XIcon, } from "lucide-react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { ModelSelectorContent, ModelSelectorEmpty, @@ -50,6 +50,7 @@ import { useModelsByProvider, } from "@/lib/chat-models.query"; import { useSyncChatModels } from "@/lib/chat-settings.query"; +import { resolveAutoSelectedModel } from "@/lib/use-chat-preferences"; import { cn } from "@/lib/utils"; /** Modalities that can be filtered (excludes "text" since all models support it) */ @@ -665,29 +666,20 @@ export function ModelSelector({ ); const isModelAvailable = allAvailableModelIds.includes(selectedModel); - // Auto-select the "best" model (or first) when models load and selected model - // is not in the available list (e.g. after switching API keys) - const prevApiKeyIdRef = useRef(apiKeyId); + // Auto-select the "best" model (or first) when the selected model is not + // in the available list (e.g. after switching API keys or on initial load). + // Only triggers when the model is genuinely unavailable — keeps the user's + // selection stable across API key changes if the model is still valid. useEffect(() => { - if (isLoading || allAvailableModels.length === 0) return; - // Only auto-select when apiKeyId changes or selected model is unavailable - const apiKeyChanged = prevApiKeyIdRef.current !== apiKeyId; - prevApiKeyIdRef.current = apiKeyId; - if (!apiKeyChanged && isModelAvailable) return; - - const bestModel = allAvailableModels.find((m) => m.isBest); - const modelToSelect = bestModel ?? allAvailableModels[0]; - if (modelToSelect && modelToSelect.id !== selectedModel) { - onModelChange(modelToSelect.id); + const modelToSelect = resolveAutoSelectedModel({ + selectedModel, + availableModels: allAvailableModels, + isLoading, + }); + if (modelToSelect) { + onModelChange(modelToSelect); } - }, [ - isLoading, - apiKeyId, - allAvailableModels, - isModelAvailable, - selectedModel, - onModelChange, - ]); + }, [isLoading, allAvailableModels, selectedModel, onModelChange]); // If loading, show loading state if (isLoading) { diff --git a/platform/frontend/src/components/message-thread.tsx b/platform/frontend/src/components/message-thread.tsx index 4b6bf9342b..f06e1e498a 100644 --- a/platform/frontend/src/components/message-thread.tsx +++ b/platform/frontend/src/components/message-thread.tsx @@ -9,7 +9,7 @@ import { ShieldCheck, TriangleAlert, } from "lucide-react"; -import { Fragment } from "react"; +import { Fragment, useMemo } from "react"; import { Action, Actions } from "@/components/ai-elements/actions"; import { Conversation, @@ -67,6 +67,13 @@ const MessageThread = ({ }) => { const status: ChatStatus = "streaming" as ChatStatus; + const lastAssistantMessageIndex = useMemo(() => { + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === "assistant") return i; + } + return -1; + }, [messages]); + return (
m.role === "assistant"); + idx === lastAssistantMessageIndex; const isLastTextPartInMessage = isLastAssistantMessage && message.parts diff --git a/platform/frontend/src/lib/use-chat-preferences.test.ts b/platform/frontend/src/lib/use-chat-preferences.test.ts index 957a937ef1..ff054f4818 100644 --- a/platform/frontend/src/lib/use-chat-preferences.test.ts +++ b/platform/frontend/src/lib/use-chat-preferences.test.ts @@ -6,6 +6,7 @@ import { getSavedAgent, getSavedApiKey, getSavedModel, + resolveAutoSelectedModel, resolveInitialModel, saveAgent, saveApiKey, @@ -179,3 +180,87 @@ describe("resolveInitialModel", () => { expect(result?.modelId).toBe("gpt-4o"); }); }); + +describe("resolveAutoSelectedModel", () => { + const models = [ + { id: "gpt-4o", isBest: true }, + { id: "gpt-4o-mini" }, + { id: "claude-3-5-sonnet" }, + ]; + + test("returns null while loading", () => { + expect( + resolveAutoSelectedModel({ + selectedModel: "nonexistent", + availableModels: models, + isLoading: true, + }), + ).toBeNull(); + }); + + test("returns null when no models available", () => { + expect( + resolveAutoSelectedModel({ + selectedModel: "gpt-4o", + availableModels: [], + isLoading: false, + }), + ).toBeNull(); + }); + + test("returns null when selectedModel is empty (parent still initializing)", () => { + expect( + resolveAutoSelectedModel({ + selectedModel: "", + availableModels: models, + isLoading: false, + }), + ).toBeNull(); + }); + + test("returns null when selected model is available (no change needed)", () => { + expect( + resolveAutoSelectedModel({ + selectedModel: "gpt-4o", + availableModels: models, + isLoading: false, + }), + ).toBeNull(); + }); + + test("selects best model when selected model is unavailable", () => { + expect( + resolveAutoSelectedModel({ + selectedModel: "deleted-model", + availableModels: models, + isLoading: false, + }), + ).toBe("gpt-4o"); // isBest: true + }); + + test("selects first model when no best model and selected is unavailable", () => { + const noBestModels = [{ id: "model-a" }, { id: "model-b" }]; + expect( + resolveAutoSelectedModel({ + selectedModel: "deleted-model", + availableModels: noBestModels, + isLoading: false, + }), + ).toBe("model-a"); + }); + + test("does NOT auto-select when model is available (race condition regression)", () => { + // This is the key regression test: during initialization, the API key + // transitions from null → "key1". The old code treated this as an + // "apiKey change" and force-selected the best model, overwriting + // the user's saved choice. The fix ensures we only auto-select + // when the model is genuinely unavailable. + expect( + resolveAutoSelectedModel({ + selectedModel: "claude-3-5-sonnet", // user's saved model + availableModels: models, // model IS in the list + isLoading: false, + }), + ).toBeNull(); // should NOT switch to gpt-4o + }); +}); diff --git a/platform/frontend/src/lib/use-chat-preferences.ts b/platform/frontend/src/lib/use-chat-preferences.ts index 1745c1f93b..63819bf062 100644 --- a/platform/frontend/src/lib/use-chat-preferences.ts +++ b/platform/frontend/src/lib/use-chat-preferences.ts @@ -21,7 +21,11 @@ export function getApiKeyStorageKey(provider: string): string { */ export function getSavedModel(): string | null { if (typeof window === "undefined") return null; - return localStorage.getItem(CHAT_STORAGE_KEYS.selectedModel); + try { + return localStorage.getItem(CHAT_STORAGE_KEYS.selectedModel); + } catch { + return null; + } } /** @@ -29,7 +33,11 @@ export function getSavedModel(): string | null { */ export function saveModel(modelId: string): void { if (typeof window === "undefined") return; - localStorage.setItem(CHAT_STORAGE_KEYS.selectedModel, modelId); + try { + localStorage.setItem(CHAT_STORAGE_KEYS.selectedModel, modelId); + } catch { + // QuotaExceededError or private browsing restriction + } } /** @@ -37,7 +45,11 @@ export function saveModel(modelId: string): void { */ export function clearSavedModel(): void { if (typeof window === "undefined") return; - localStorage.removeItem(CHAT_STORAGE_KEYS.selectedModel); + try { + localStorage.removeItem(CHAT_STORAGE_KEYS.selectedModel); + } catch { + // QuotaExceededError or private browsing restriction + } } /** @@ -45,7 +57,11 @@ export function clearSavedModel(): void { */ export function getSavedAgent(): string | null { if (typeof window === "undefined") return null; - return localStorage.getItem(CHAT_STORAGE_KEYS.selectedAgent); + try { + return localStorage.getItem(CHAT_STORAGE_KEYS.selectedAgent); + } catch { + return null; + } } /** @@ -53,7 +69,11 @@ export function getSavedAgent(): string | null { */ export function saveAgent(agentId: string): void { if (typeof window === "undefined") return; - localStorage.setItem(CHAT_STORAGE_KEYS.selectedAgent, agentId); + try { + localStorage.setItem(CHAT_STORAGE_KEYS.selectedAgent, agentId); + } catch { + // QuotaExceededError or private browsing restriction + } } /** @@ -61,7 +81,11 @@ export function saveAgent(agentId: string): void { */ export function getSavedApiKey(provider: string): string | null { if (typeof window === "undefined") return null; - return localStorage.getItem(getApiKeyStorageKey(provider)); + try { + return localStorage.getItem(getApiKeyStorageKey(provider)); + } catch { + return null; + } } /** @@ -69,7 +93,56 @@ export function getSavedApiKey(provider: string): string | null { */ export function saveApiKey(provider: string, keyId: string): void { if (typeof window === "undefined") return; - localStorage.setItem(getApiKeyStorageKey(provider), keyId); + try { + localStorage.setItem(getApiKeyStorageKey(provider), keyId); + } catch { + // QuotaExceededError or private browsing restriction + } +} + +// ===== Model auto-selection logic ===== + +interface AutoSelectableModel { + id: string; + isBest?: boolean; +} + +interface ResolveAutoSelectParams { + selectedModel: string; + availableModels: AutoSelectableModel[]; + isLoading: boolean; +} + +/** + * Determine whether the model selector should auto-select a different model. + * Returns the model ID to switch to, or null if no change is needed. + * + * Auto-selection only triggers when the selected model is genuinely unavailable + * (e.g., the API key changed and the model isn't offered by the new provider). + * It does NOT trigger just because the API key changed — this prevents a race + * condition during initialization where the null→keyId transition was + * incorrectly treated as a "key change" and overwrote the user's saved model. + */ +export function resolveAutoSelectedModel( + params: ResolveAutoSelectParams, +): string | null { + const { selectedModel, availableModels, isLoading } = params; + + // Not ready yet — wait for models to load + if (isLoading || availableModels.length === 0) return null; + + // Parent hasn't resolved the model yet (empty string during init) + if (!selectedModel) return null; + + // Current model is available — no change needed + if (availableModels.some((m) => m.id === selectedModel)) return null; + + // Model is unavailable — pick the best or first available + const best = availableModels.find((m) => m.isBest); + const fallback = best ?? availableModels[0]; + + // Only return a change if it's actually different + return fallback && fallback.id !== selectedModel ? fallback.id : null; } // ===== Model resolution logic =====