Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ export class ConfluenceConnector extends BaseConnector {
batchIndex++;
yield {
documents,
failures: this.flushFailures(),
checkpoint: buildCheckpoint({
type: "confluence",
itemUpdatedAt: rawModifiedAt,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type pino from "pino";
import type {
ConnectorCredentials,
ConnectorDocument,
ConnectorItemFailure,
ConnectorSyncBatch,
JiraCheckpoint,
JiraConfig,
Expand Down Expand Up @@ -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(
{
Expand Down Expand Up @@ -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(
{
Expand Down Expand Up @@ -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,
Expand All @@ -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<string, unknown> {
function extractJiraErrorDetails(
error: unknown,
depth = 0,
): Record<string, unknown> {
const details: Record<string, unknown> = {};

if (!(error instanceof Error)) {
if (depth > 5 || !(error instanceof Error)) {
return details;
}

Expand Down Expand Up @@ -452,9 +471,9 @@ function extractJiraErrorDetails(error: unknown): Record<string, unknown> {
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;
Expand Down
6 changes: 5 additions & 1 deletion platform/backend/src/routes/mcp-gateway.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(", ")}.`;
Expand Down
8 changes: 5 additions & 3 deletions platform/e2e-tests/tests/api/mcp-gateway.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
2 changes: 2 additions & 0 deletions platform/frontend/src/app/chat/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,10 @@ export function EditConnectorDialog({
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Connector name" {...field} />
<Input
placeholder="Engineering Jira Connector"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@ export function extractCitations(
}
}
}
} catch {
} catch (err) {
console.warn("Failed to extract citations from tool result", err);
continue;
}

Expand Down
36 changes: 14 additions & 22 deletions platform/frontend/src/components/chat/model-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) */
Expand Down Expand Up @@ -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) {
Expand Down
16 changes: 9 additions & 7 deletions platform/frontend/src/components/message-thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
<div
className={cn(
Expand Down Expand Up @@ -161,12 +168,7 @@ const MessageThread = ({
}
const isLastAssistantMessage =
message.role === "assistant" &&
idx ===
messages.length -
1 -
[...messages]
.reverse()
.findIndex((m) => m.role === "assistant");
idx === lastAssistantMessageIndex;
const isLastTextPartInMessage =
isLastAssistantMessage &&
message.parts
Expand Down
85 changes: 85 additions & 0 deletions platform/frontend/src/lib/use-chat-preferences.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
getSavedAgent,
getSavedApiKey,
getSavedModel,
resolveAutoSelectedModel,
resolveInitialModel,
saveAgent,
saveApiKey,
Expand Down Expand Up @@ -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
});
});
Loading
Loading