From 6eaecdd0f8af577a47bc33def6406adfef7f08f7 Mon Sep 17 00:00:00 2001 From: GanJiaKouN16 Date: Sat, 6 Jun 2026 14:15:42 +0800 Subject: [PATCH 1/7] feat: add reset password functionality to workspace UI Wire the existing GenerateResetLinkModal and PasswordResetLinkModal into the Actions dropdown in the workspace members table. - Add 'Reset password' menu item for workspace members (not self) - Add resetPassword API function in profile service - Show confirmation dialog before generating the reset link - Display the generated password reset link with copy functionality Closes #2572 --- .../WorkspaceManage/cellRenderers.tsx | 57 ++++++++++++++++++- web/oss/src/services/profile/index.ts | 14 +++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/web/oss/src/components/pages/settings/WorkspaceManage/cellRenderers.tsx b/web/oss/src/components/pages/settings/WorkspaceManage/cellRenderers.tsx index d689d57135..7be7d3befd 100644 --- a/web/oss/src/components/pages/settings/WorkspaceManage/cellRenderers.tsx +++ b/web/oss/src/components/pages/settings/WorkspaceManage/cellRenderers.tsx @@ -3,7 +3,7 @@ import {useState} from "react" import type {User} from "@agenta/shared/types" import {message} from "@agenta/ui/app-message" import {EditOutlined, MoreOutlined, SyncOutlined} from "@ant-design/icons" -import {ArrowClockwise, Trash} from "@phosphor-icons/react" +import {ArrowClockwise, Key, Trash} from "@phosphor-icons/react" import {Button, Dropdown, Input, Modal, Space, Tag, Tooltip, Typography} from "antd" import AlertPopup from "@/oss/components/AlertPopup/AlertPopup" @@ -12,7 +12,7 @@ import {isEmailInvitationsEnabled} from "@/oss/lib/helpers/isEE" import {useEntitlements} from "@/oss/lib/helpers/useEntitlements" import {snakeToTitle} from "@/oss/lib/helpers/utils" import {WorkspaceMember} from "@/oss/lib/Types" -import {updateUsername} from "@/oss/services/profile" +import {resetPassword, updateUsername} from "@/oss/services/profile" import { assignWorkspaceRole, removeFromWorkspace, @@ -23,6 +23,9 @@ import {useOrgData} from "@/oss/state/org" import {useProfileData} from "@/oss/state/profile" import {useWorkspaceRoles} from "@/oss/state/workspace" +import GenerateResetLinkModal from "./Modals/GenerateResetLinkModal" +import PasswordResetLinkModal from "./Modals/PasswordResetLinkModal" + export const Actions: React.FC<{ member: WorkspaceMember hidden?: boolean @@ -39,6 +42,10 @@ export const Actions: React.FC<{ const {refetch: refetchProfile} = useProfileData() const [renameOpen, setRenameOpen] = useState(false) const [renameValue, setRenameValue] = useState(user.username || "") + const [generateResetLinkOpen, setGenerateResetLinkOpen] = useState(false) + const [resetLinkOpen, setResetLinkOpen] = useState(false) + const [resetLink, setResetLink] = useState("") + const [resetLoading, setResetLoading] = useState(false) if (hidden && !selfMenu) return null @@ -90,6 +97,24 @@ export const Actions: React.FC<{ } } + const handleResetPassword = async () => { + setResetLoading(true) + try { + const link = await resetPassword(user.id) + setGenerateResetLinkOpen(false) + setResetLink(link) + setResetLinkOpen(true) + } catch (error: any) { + const detail = + error?.response?.data?.detail || + error?.message || + "Unable to generate reset password link" + message.error(detail) + } finally { + setResetLoading(false) + } + } + return ( <> , + onClick: (e: any) => { + e.domEvent.stopPropagation() + setGenerateResetLinkOpen(true) + }, + }, + ] + : []), { key: "remove", label: "Remove", @@ -165,6 +203,21 @@ export const Actions: React.FC<{ placeholder="New username" /> + + setGenerateResetLinkOpen(false)} + onOk={handleResetPassword} + confirmLoading={resetLoading} + /> + + setResetLinkOpen(false)} + /> ) } diff --git a/web/oss/src/services/profile/index.ts b/web/oss/src/services/profile/index.ts index 0b3816c710..0acfcedb48 100644 --- a/web/oss/src/services/profile/index.ts +++ b/web/oss/src/services/profile/index.ts @@ -56,3 +56,17 @@ export const changePassword = async (payload: { body: JSON.stringify(payload), }) } + +/** + * Generate a password reset link for a user (admin action). + * Returns the reset password link string. + */ +export const resetPassword = async (userId: string): Promise => { + const base = getBaseUrl() + const url = new URL("api/profile/reset-password", base) + url.searchParams.set("user_id", userId) + const data = await fetchJson(url, { + method: "POST", + }) + return data +} From 445fc3f700e0ac46c7c4e3fbb653039a56d8bbe6 Mon Sep 17 00:00:00 2001 From: GanJiaKouN16 Date: Sat, 6 Jun 2026 14:36:28 +0800 Subject: [PATCH 2/7] fix: add shouldIgnoreRowClick guard to unprotected tables Several tables with row-level click navigation were missing the shouldIgnoreRowClick guard, causing clicks on interactive elements (checkboxes, dropdowns, buttons) to accidentally trigger row navigation. Changes: - Consolidate shouldIgnoreRowClick with broader selector list (merges EvaluationRunsTablePOC's extra selectors: [role='button'], [role='menuitem'], [role='checkbox'], .ant-btn, etc.) - Export INTERACTIVE_ROW_SELECTORS constant for reuse - Add guard to ObservabilityTable (traces) - Add guard to SessionsTable - Add guard to PromptsPage - Add guard to TestcasesTableShell - Add guard to EntityTable - Replace partial data-ivt-stop-row-click check in ScenarioListView with full shouldIgnoreRowClick - Update useEntityTableState to use consolidated selectors - Remove duplicate shouldIgnoreRowClick from navigationActions.ts - Update EvaluationRunsTablePOC to import from shared utility Closes #3254 --- .../actions/navigationActions.ts | 10 ----- .../components/EvaluationRunsTable/index.tsx | 2 +- .../hooks/useTableManager.tsx | 42 ++++++++++++------- .../components/TestcasesTableShell.tsx | 8 +++- .../components/ObservabilityTable/index.tsx | 7 +++- .../components/SessionsTable/index.tsx | 7 +++- .../components/pages/prompts/PromptsPage.tsx | 6 ++- .../AnnotationSession/ScenarioListView.tsx | 4 +- .../src/shared/EntityTable.tsx | 7 +++- .../hooks/useEntityTableState.ts | 17 +++----- .../hooks/useTableManager.tsx | 42 ++++++++++++------- 11 files changed, 86 insertions(+), 66 deletions(-) diff --git a/web/oss/src/components/EvaluationRunsTablePOC/actions/navigationActions.ts b/web/oss/src/components/EvaluationRunsTablePOC/actions/navigationActions.ts index bf4160796b..8f1ac7dc66 100644 --- a/web/oss/src/components/EvaluationRunsTablePOC/actions/navigationActions.ts +++ b/web/oss/src/components/EvaluationRunsTablePOC/actions/navigationActions.ts @@ -1,5 +1,3 @@ -import type {MouseEvent} from "react" - import {message} from "@agenta/ui/app-message" import {getDefaultStore} from "jotai" import Router from "next/router" @@ -23,14 +21,6 @@ const getUrlState = (): URLState => store.get(urlAtom) as URLState const getActiveAppId = (): string | null => store.get(routerAppIdAtom) -export const shouldIgnoreRowClick = (event: MouseEvent) => { - const target = event.target as HTMLElement | null - if (!target) return false - const interactiveSelector = - "button, a, input, textarea, select, [role='button'], [role='menuitem'], [role='checkbox'], .ant-checkbox, .ant-checkbox-input, .ant-checkbox-inner, .ant-checkbox-wrapper, .ant-btn, .ant-select, .ant-dropdown-trigger" - return Boolean(target.closest(interactiveSelector)) -} - interface NavigateToRunParams { record: EvaluationRunTableRow scope: "app" | "project" diff --git a/web/oss/src/components/EvaluationRunsTablePOC/components/EvaluationRunsTable/index.tsx b/web/oss/src/components/EvaluationRunsTablePOC/components/EvaluationRunsTable/index.tsx index 381c42a3c5..e4aa5e1fd3 100644 --- a/web/oss/src/components/EvaluationRunsTablePOC/components/EvaluationRunsTable/index.tsx +++ b/web/oss/src/components/EvaluationRunsTablePOC/components/EvaluationRunsTable/index.tsx @@ -13,6 +13,7 @@ import {activePreviewProjectIdAtom} from "@/oss/components/EvalRunDetails/atoms/ import {clearAllMetricStatsCaches} from "@/oss/components/EvalRunDetails/atoms/runMetrics" import { InfiniteVirtualTableFeatureShell, + shouldIgnoreRowClick, type TableFeaturePagination, type TableScopeConfig, } from "@/oss/components/InfiniteVirtualTable" @@ -34,7 +35,6 @@ import { } from "@/oss/lib/onboarding" import {useQueryParamState} from "@/oss/state/appState" -import {shouldIgnoreRowClick} from "../../actions/navigationActions" import { evaluationRunsDeleteContextAtom, evaluationRunsTableFetchEnabledAtom, diff --git a/web/oss/src/components/InfiniteVirtualTable/hooks/useTableManager.tsx b/web/oss/src/components/InfiniteVirtualTable/hooks/useTableManager.tsx index be69f2bad5..8c38dce80f 100644 --- a/web/oss/src/components/InfiniteVirtualTable/hooks/useTableManager.tsx +++ b/web/oss/src/components/InfiniteVirtualTable/hooks/useTableManager.tsx @@ -27,26 +27,36 @@ import useTableExport from "./useTableExport" const dummySearchAtom = atom("") /** - * Helper to detect if a click event should be ignored for row navigation + * Default CSS selectors for interactive elements that should not trigger row navigation. + * Consolidated from all table implementations to ensure consistent click-through behavior. + */ +export const INTERACTIVE_ROW_SELECTORS = [ + "button", + "a", + "input", + "textarea", + "select", + "[role='button']", + "[role='menuitem']", + "[role='checkbox']", + "[data-interactive]", + ".ant-dropdown-trigger", + ".ant-checkbox-wrapper", + ".ant-checkbox", + ".ant-checkbox-input", + ".ant-checkbox-inner", + ".ant-btn", + ".ant-select", +].join(", ") + +/** + * Helper to detect if a click event should be ignored for row navigation. * Returns true if the click was on an interactive element (button, link, dropdown, etc.) */ export const shouldIgnoreRowClick = (event: MouseEvent): boolean => { const target = event.target as HTMLElement - - // Check if clicking on interactive elements - if ( - target.closest("button") || - target.closest("a") || - target.closest(".ant-dropdown-trigger") || - target.closest(".ant-checkbox-wrapper") || - target.closest(".ant-select") || - target.closest("input") || - target.closest("textarea") - ) { - return true - } - - return false + if (!target) return false + return Boolean(target.closest(INTERACTIVE_ROW_SELECTORS)) } /** Configuration for built-in search. When provided, the hook manages search state internally. */ diff --git a/web/oss/src/components/TestcasesTableNew/components/TestcasesTableShell.tsx b/web/oss/src/components/TestcasesTableNew/components/TestcasesTableShell.tsx index a046d6ec5c..a9663cd8d5 100644 --- a/web/oss/src/components/TestcasesTableNew/components/TestcasesTableShell.tsx +++ b/web/oss/src/components/TestcasesTableNew/components/TestcasesTableShell.tsx @@ -1,10 +1,11 @@ -import {useCallback, useMemo, useState} from "react" +import React, {useCallback, useMemo, useState} from "react" import { ColumnVisibilityMenuTrigger, defaultHeaderVariant, detectColumnTypes, InfiniteVirtualTableFeatureShell, + shouldIgnoreRowClick, type TableScopeConfig, type TypeChipConfig, useTypeChipFeature, @@ -758,7 +759,10 @@ export function TestcasesTableShell(props: TestcasesTableShellProps) { size: "small" as const, bordered: true, onRow: (record: TestcaseTableRow) => ({ - onClick: () => onRowClick(record), + onClick: (event: React.MouseEvent) => { + if (shouldIgnoreRowClick(event)) return + onRowClick(record) + }, className: "cursor-pointer hover:bg-gray-50", }), }), diff --git a/web/oss/src/components/pages/observability/components/ObservabilityTable/index.tsx b/web/oss/src/components/pages/observability/components/ObservabilityTable/index.tsx index d6a94663ae..9bf9056264 100644 --- a/web/oss/src/components/pages/observability/components/ObservabilityTable/index.tsx +++ b/web/oss/src/components/pages/observability/components/ObservabilityTable/index.tsx @@ -1,6 +1,6 @@ import {type Key, type ReactNode, useCallback, useEffect, useMemo, useState} from "react" -import {InfiniteVirtualTableFeatureShell} from "@agenta/ui/table" +import {InfiniteVirtualTableFeatureShell, shouldIgnoreRowClick} from "@agenta/ui/table" import type {TableFeaturePagination, TableScopeConfig} from "@agenta/ui/table" import {useAtomValue, useSetAtom, useStore} from "jotai" import dynamic from "next/dynamic" @@ -307,7 +307,10 @@ const ObservabilityTable = () => { sticky: true, style: {cursor: "pointer"}, onRow: (record, index) => ({ - onClick: () => handleTraceRowClick(record), + onClick: (event) => { + if (shouldIgnoreRowClick(event)) return + handleTraceRowClick(record) + }, "data-tour": index === 0 ? "trace-row" : undefined, }), }} diff --git a/web/oss/src/components/pages/observability/components/SessionsTable/index.tsx b/web/oss/src/components/pages/observability/components/SessionsTable/index.tsx index 0d15650646..66282cbf3a 100644 --- a/web/oss/src/components/pages/observability/components/SessionsTable/index.tsx +++ b/web/oss/src/components/pages/observability/components/SessionsTable/index.tsx @@ -1,6 +1,6 @@ import {useCallback, useEffect, useMemo, useState} from "react" -import {InfiniteVirtualTableFeatureShell} from "@agenta/ui/table" +import {InfiniteVirtualTableFeatureShell, shouldIgnoreRowClick} from "@agenta/ui/table" import type {TableFeaturePagination, TableScopeConfig} from "@agenta/ui/table" import {useAtomValue, useSetAtom} from "jotai" import dynamic from "next/dynamic" @@ -141,7 +141,10 @@ const SessionsTable: React.FC = () => { bordered: true, loading: isLoading && sessionIds.length === 0, onRow: (record) => ({ - onClick: () => openDrawer({sessionId: record.session_id}), + onClick: (event) => { + if (shouldIgnoreRowClick(event)) return + openDrawer({sessionId: record.session_id}) + }, style: {cursor: "pointer"}, }), }} diff --git a/web/oss/src/components/pages/prompts/PromptsPage.tsx b/web/oss/src/components/pages/prompts/PromptsPage.tsx index f7c12e303c..ff959eb488 100644 --- a/web/oss/src/components/pages/prompts/PromptsPage.tsx +++ b/web/oss/src/components/pages/prompts/PromptsPage.tsx @@ -12,6 +12,7 @@ import type { TableFeaturePagination, TableScopeConfig, } from "@agenta/ui/table" +import {shouldIgnoreRowClick} from "@agenta/ui/table" import {message} from "antd" import type {TableProps} from "antd/es/table" import {useAtomValue, useSetAtom} from "jotai" @@ -686,7 +687,10 @@ const PromptsPage = () => { scroll: {x: "max-content" as const}, expandable: tableExpandableConfig, onRow: (record: PromptsTableRow) => ({ - onClick: () => handleRowClick(record), + onClick: (event: React.MouseEvent) => { + if (shouldIgnoreRowClick(event)) return + handleRowClick(record) + }, className: "cursor-pointer", draggable: true, onDragStart: (event: any) => { diff --git a/web/packages/agenta-annotation-ui/src/components/AnnotationSession/ScenarioListView.tsx b/web/packages/agenta-annotation-ui/src/components/AnnotationSession/ScenarioListView.tsx index 3dec2aebb6..1d33dde571 100644 --- a/web/packages/agenta-annotation-ui/src/components/AnnotationSession/ScenarioListView.tsx +++ b/web/packages/agenta-annotation-ui/src/components/AnnotationSession/ScenarioListView.tsx @@ -38,6 +38,7 @@ import { EXPORT_RESOLVE_SKIP, InfiniteVirtualTableFeatureShell, createActionsColumn, + shouldIgnoreRowClick, type InfiniteVirtualTableRowSelection, type TableScopeConfig, type TableExportColumnContext, @@ -1674,8 +1675,7 @@ const ScenarioListView = memo(function ScenarioListView({ // Row click opens annotation drawer const handleRowClick = useCallback((_event: React.MouseEvent, record: ScenarioTableRow) => { - const target = _event.target as HTMLElement - if (target?.closest("[data-ivt-stop-row-click]")) return + if (shouldIgnoreRowClick(_event)) return setDrawerScenarioId(record.scenarioId) }, []) diff --git a/web/packages/agenta-entity-ui/src/shared/EntityTable.tsx b/web/packages/agenta-entity-ui/src/shared/EntityTable.tsx index e92044c6cf..40066616b9 100644 --- a/web/packages/agenta-entity-ui/src/shared/EntityTable.tsx +++ b/web/packages/agenta-entity-ui/src/shared/EntityTable.tsx @@ -52,6 +52,7 @@ import {bgColors, cn} from "@agenta/ui/styles" import { buildEntityColumns, InfiniteVirtualTableFeatureShell, + shouldIgnoreRowClick, type BuildEntityColumnsOptions, type RowHeightFeatureConfig, type TableScopeConfig, @@ -546,8 +547,10 @@ export function EntityTable< bordered: true, onRow: selectable ? (record) => ({ - onClick: () => - handleRowSelect(record.id, !selectedIdsSet.has(record.id)), + onClick: (event) => { + if (shouldIgnoreRowClick(event)) return + handleRowSelect(record.id, !selectedIdsSet.has(record.id)) + }, className: cn( "cursor-pointer", selectedIdsSet.has(record.id) && bgColors.subtle, diff --git a/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useEntityTableState.ts b/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useEntityTableState.ts index 4ddfe1df5e..37ab19f0aa 100644 --- a/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useEntityTableState.ts +++ b/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useEntityTableState.ts @@ -72,6 +72,8 @@ import type { } from "../paginated/createPaginatedEntityStore" import type {InfiniteTableRowBase} from "../types" +import {INTERACTIVE_ROW_SELECTORS} from "./useTableManager" + // ============================================================================ // TYPES // ============================================================================ @@ -182,19 +184,10 @@ export interface UseEntityTableStateResult { // ============================================================================ /** - * Default selectors for interactive elements that should not trigger row click + * Default selectors for interactive elements that should not trigger row click. + * Uses the consolidated selector string from useTableManager for consistency. */ -const DEFAULT_INTERACTIVE_SELECTORS = [ - "button", - "a", - ".ant-dropdown-trigger", - ".ant-checkbox-wrapper", - ".ant-select", - "input", - "textarea", - "[role='button']", - "[data-interactive]", -] +const DEFAULT_INTERACTIVE_SELECTORS = INTERACTIVE_ROW_SELECTORS.split(", ") // ============================================================================ // HOOK diff --git a/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useTableManager.tsx b/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useTableManager.tsx index 82aafa7963..bc8dde1690 100644 --- a/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useTableManager.tsx +++ b/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useTableManager.tsx @@ -28,26 +28,36 @@ import useTableExport from "./useTableExport" const dummySearchAtom = atom("") /** - * Helper to detect if a click event should be ignored for row navigation + * Default CSS selectors for interactive elements that should not trigger row navigation. + * Consolidated from all table implementations to ensure consistent click-through behavior. + */ +export const INTERACTIVE_ROW_SELECTORS = [ + "button", + "a", + "input", + "textarea", + "select", + "[role='button']", + "[role='menuitem']", + "[role='checkbox']", + "[data-interactive]", + ".ant-dropdown-trigger", + ".ant-checkbox-wrapper", + ".ant-checkbox", + ".ant-checkbox-input", + ".ant-checkbox-inner", + ".ant-btn", + ".ant-select", +].join(", ") + +/** + * Helper to detect if a click event should be ignored for row navigation. * Returns true if the click was on an interactive element (button, link, dropdown, etc.) */ export const shouldIgnoreRowClick = (event: MouseEvent): boolean => { const target = event.target as HTMLElement - - // Check if clicking on interactive elements - if ( - target.closest("button") || - target.closest("a") || - target.closest(".ant-dropdown-trigger") || - target.closest(".ant-checkbox-wrapper") || - target.closest(".ant-select") || - target.closest("input") || - target.closest("textarea") - ) { - return true - } - - return false + if (!target) return false + return Boolean(target.closest(INTERACTIVE_ROW_SELECTORS)) } /** Configuration for built-in search. When provided, the hook manages search state internally. */ From 585c6adbd8bf1f94b232b615f32bb1f09e32ae0d Mon Sep 17 00:00:00 2001 From: GanJiaKouN16 Date: Sat, 6 Jun 2026 14:54:06 +0800 Subject: [PATCH 3/7] fix: surface provider error messages in evaluation table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The evaluation table was showing a generic 'too many requests' message instead of the actual provider error because: 1. executeViaFetch never checked for body-level errors on HTTP 200. The Python SDK can return HTTP 200 with a non-200 status.code embedded in the response body (WorkflowBatchResponse.status.code). This path was silently treated as success. 2. Error stacktrace/type/code were not propagated through the pipeline. Even when the HTTP error path was taken, only the message was extracted — the SDK's status.type, status.code, and status.stacktrace were dropped. Changes: - executeViaFetch: detect body-level errors on HTTP 200 by checking responseData.status.code !== 200 and return an error result - executeViaFetch: extract stacktrace (coercing string[] to string), type, and code from both HTTP-error and body-error paths - Add stacktrace and type to ExecutionResult, RunResult, and ExecuteWorkflowRevisionResult error shapes - runInvocationAction: pass stacktrace and type through to upsertStepResultWithInvocation - upsertStepResultWithInvocation: accept type field in error param No UI changes needed — InvocationCell already renders stepError.message and stepError.stacktrace when present; extractStepError already reads error.code, error.type, error.stacktrace from persisted step data. Closes #3324 --- .../atoms/runInvocationAction.ts | 6 ++- .../services/evaluations/invocations/api.ts | 2 +- .../agenta-entities/src/runnable/types.ts | 2 + .../src/executeWorkflowRevision.ts | 2 +- .../src/state/execution/executionRunner.ts | 46 ++++++++++++++++++- .../src/state/execution/types.ts | 2 +- 6 files changed, 54 insertions(+), 6 deletions(-) diff --git a/web/oss/src/components/EvalRunDetails/atoms/runInvocationAction.ts b/web/oss/src/components/EvalRunDetails/atoms/runInvocationAction.ts index 05f72ac6b0..7b618d912a 100644 --- a/web/oss/src/components/EvalRunDetails/atoms/runInvocationAction.ts +++ b/web/oss/src/components/EvalRunDetails/atoms/runInvocationAction.ts @@ -208,7 +208,11 @@ export const triggerRunInvocationAtom = atom( traceId: result.traceId ?? undefined, status: "failure", references, - error: {message: errorMessage}, + error: { + message: errorMessage, + ...(result.error?.stacktrace ? {stacktrace: result.error.stacktrace} : {}), + ...(result.error?.type ? {type: result.error.type} : {}), + }, }) await updateScenarioStatus(scenarioId, EvaluationStatus.FAILURE) diff --git a/web/oss/src/services/evaluations/invocations/api.ts b/web/oss/src/services/evaluations/invocations/api.ts index 7e91e8a437..6563126248 100644 --- a/web/oss/src/services/evaluations/invocations/api.ts +++ b/web/oss/src/services/evaluations/invocations/api.ts @@ -69,7 +69,7 @@ export const upsertStepResultWithInvocation = async ({ status: string references?: InvocationReferences outputs?: unknown - error?: {message: string; stacktrace?: string} + error?: {message: string; stacktrace?: string; type?: string} }): Promise => { const {projectId} = getProjectValues() diff --git a/web/packages/agenta-entities/src/runnable/types.ts b/web/packages/agenta-entities/src/runnable/types.ts index 0247546f57..ee79ec5e01 100644 --- a/web/packages/agenta-entities/src/runnable/types.ts +++ b/web/packages/agenta-entities/src/runnable/types.ts @@ -204,6 +204,8 @@ export interface ExecutionResult { error?: { message: string code?: string + type?: string + stacktrace?: string } trace?: TraceInfo metrics?: ExecutionMetrics diff --git a/web/packages/agenta-playground/src/executeWorkflowRevision.ts b/web/packages/agenta-playground/src/executeWorkflowRevision.ts index cf808fd035..85d5dcaba2 100644 --- a/web/packages/agenta-playground/src/executeWorkflowRevision.ts +++ b/web/packages/agenta-playground/src/executeWorkflowRevision.ts @@ -62,7 +62,7 @@ export interface ExecuteWorkflowRevisionResult { structuredOutput?: unknown traceId?: string | null spanId?: string | null - error?: {message: string; code?: string} + error?: {message: string; code?: string; type?: string; stacktrace?: string} } // ============================================================================ diff --git a/web/packages/agenta-playground/src/state/execution/executionRunner.ts b/web/packages/agenta-playground/src/state/execution/executionRunner.ts index 83339f1478..db14743358 100644 --- a/web/packages/agenta-playground/src/state/execution/executionRunner.ts +++ b/web/packages/agenta-playground/src/state/execution/executionRunner.ts @@ -187,7 +187,7 @@ interface ExecutionSessionLifecycleCallbacks { chainResults?: RunResult["chainResults"] }) => void onComplete: (payload: {result: Partial}) => void - onFail: (payload: {error: {message: string; code?: string}; traceId?: string | null}) => void + onFail: (payload: {error: {message: string; code?: string; type?: string; stacktrace?: string}; traceId?: string | null}) => void onCancel: () => void } @@ -671,6 +671,9 @@ async function executeViaFetch(params: { if (!response.ok) { const errorText = await response.text() let errorMessage = `Request failed with status ${response.status}` + let errorCode: string | undefined + let errorType: string | undefined + let errorStacktrace: string | undefined let traceId: string | null = null try { @@ -678,6 +681,10 @@ async function executeViaFetch(params: { traceId = extractTraceIdFromPayload(errorData) if (errorData?.status?.message) { errorMessage = errorData.status.message + errorCode = errorData.status.code?.toString() + errorType = errorData.status.type + const st = errorData.status.stacktrace + errorStacktrace = Array.isArray(st) ? st.join("\n") : st } else if (errorData?.detail?.message) { errorMessage = errorData.detail.message } else if (typeof errorData?.detail === "string") { @@ -692,13 +699,48 @@ async function executeViaFetch(params: { status: "error", startedAt, completedAt: new Date().toISOString(), - error: {message: errorMessage}, + error: { + message: errorMessage, + ...(errorCode ? {code: errorCode} : {}), + ...(errorType ? {type: errorType} : {}), + ...(errorStacktrace ? {stacktrace: errorStacktrace} : {}), + }, ...(traceId ? {trace: {id: traceId}} : {}), } } const responseData = await response.json() + // Check for body-level error status (SDK returns HTTP 200 with error in body). + // The Python SDK's WorkflowBatchResponse may embed a non-200 status.code + // inside the response body even when the HTTP status is 200. + const bodyStatus = responseData?.status + if (bodyStatus && typeof bodyStatus === "object" && bodyStatus.code && bodyStatus.code !== 200) { + const traceId = extractTraceIdFromPayload(responseData) + const spanId = extractSpanIdFromPayload(responseData) + const st = bodyStatus.stacktrace + return { + executionId, + status: "error", + startedAt, + completedAt: new Date().toISOString(), + error: { + message: bodyStatus.message || "Invocation failed", + ...(bodyStatus.code ? {code: bodyStatus.code.toString()} : {}), + ...(bodyStatus.type ? {type: bodyStatus.type} : {}), + ...(st ? {stacktrace: Array.isArray(st) ? st.join("\n") : st} : {}), + }, + ...(traceId + ? { + trace: { + id: traceId, + ...(spanId ? {spanId} : {}), + }, + } + : {}), + } + } + // Delegate response parsing to entity-level normalizer when provided. // Default: unwrap `data` field if present, extract `trace_id`. const normalized = normalizeResponse diff --git a/web/packages/agenta-playground/src/state/execution/types.ts b/web/packages/agenta-playground/src/state/execution/types.ts index 58e8309dce..df02967cef 100644 --- a/web/packages/agenta-playground/src/state/execution/types.ts +++ b/web/packages/agenta-playground/src/state/execution/types.ts @@ -165,7 +165,7 @@ export interface RunResult { /** Hash of result for comparison (optional) */ resultHash?: string | null /** Error details if status is "error" */ - error?: {message: string; code?: string} | null + error?: {message: string; code?: string; type?: string; stacktrace?: string} | null /** Timestamp when execution started (ms) */ startedAt?: number /** Timestamp when execution completed (ms) */ From c9d112d2273293391a725ca393b76f1c05611083 Mon Sep 17 00:00:00 2001 From: GanJiaKouN16 Date: Sat, 6 Jun 2026 15:03:22 +0800 Subject: [PATCH 4/7] fix: use explicit env var mapping for Together AI provider in vault middleware The vault middleware built env var names using f'{provider.upper()}_API_KEY' which produces TOGETHER_AI_API_KEY for the 'together_ai' provider kind. The actual env var is TOGETHERAI_API_KEY (no underscore), matching the frontend (llmProviders.ts, transforms.ts), backend (env.py), and the Daytona sandbox runner (daytona.py). Add an explicit _PROVIDER_ENV_VAR_MAP dict (mirroring the Daytona runner pattern) that maps each provider kind to its correct env var name, with fallback to the original f-string pattern for any future providers. Closes #3659 --- .../agenta/sdk/middlewares/running/vault.py | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/sdks/python/agenta/sdk/middlewares/running/vault.py b/sdks/python/agenta/sdk/middlewares/running/vault.py index 2c4fc0d719..eae3d34544 100644 --- a/sdks/python/agenta/sdk/middlewares/running/vault.py +++ b/sdks/python/agenta/sdk/middlewares/running/vault.py @@ -37,6 +37,28 @@ if "mistral" not in _PROVIDER_KINDS: _PROVIDER_KINDS.append("mistral") +# Mapping from provider kind to environment variable name. +# Most providers follow the pattern PROVIDER_API_KEY, but some have +# underscores in their kind string (e.g. "together_ai") where the env +# var drops the underscore (TOGETHERAI_API_KEY). This explicit mapping +# mirrors the one in the Daytona runner and the frontend llmProviders.ts. +_PROVIDER_ENV_VAR_MAP: Dict[str, str] = { + "openai": "OPENAI_API_KEY", + "cohere": "COHERE_API_KEY", + "anyscale": "ANYSCALE_API_KEY", + "deepinfra": "DEEPINFRA_API_KEY", + "alephalpha": "ALEPHALPHA_API_KEY", + "groq": "GROQ_API_KEY", + "minimax": "MINIMAX_API_KEY", + "mistral": "MISTRAL_API_KEY", + "mistralai": "MISTRAL_API_KEY", + "anthropic": "ANTHROPIC_API_KEY", + "perplexityai": "PERPLEXITYAI_API_KEY", + "together_ai": "TOGETHERAI_API_KEY", + "openrouter": "OPENROUTER_API_KEY", + "gemini": "GEMINI_API_KEY", +} + _AUTH_ENABLED = ( getenv("AGENTA_SERVICES_MIDDLEWARE_AUTH_ENABLED") or getenv("AGENTA_SERVICE_MIDDLEWARE_AUTH_ENABLED") @@ -306,7 +328,7 @@ async def get_secrets( try: for provider_kind in _PROVIDER_KINDS: provider = provider_kind - key_name = f"{provider.upper()}_API_KEY" + key_name = _PROVIDER_ENV_VAR_MAP.get(provider, f"{provider.upper()}_API_KEY") key = getenv(key_name) if not key: From 01a6e6dc836c49781c5715db5c3e270a8ffcbf9f Mon Sep 17 00:00:00 2001 From: GanJiaKouN16 Date: Sat, 6 Jun 2026 15:14:29 +0800 Subject: [PATCH 5/7] fix: link evaluator button to playground instead of registry in trace drawer The 'Open evaluator registry' button in the Trace Drawer's EvaluatorDetailsPopover navigated human evaluators to the registry page (/evaluators?tab=human&openEvaluator=...) instead of the evaluator playground. Automatic evaluators already linked correctly. Unify both evaluator types to navigate to the playground (/evaluators/playground?revisions=...), consistent with how other parts of the codebase link to evaluators (EvaluatorSection, ConfigurationView). Update button text to 'Open evaluator playground'. Closes #4535 --- .../components/EvaluatorDetailsPopover.tsx | 2 +- .../TraceDrawer/hooks/useEvaluatorNavigation.ts | 11 +---------- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/web/oss/src/components/SharedDrawers/TraceDrawer/components/EvaluatorDetailsPopover.tsx b/web/oss/src/components/SharedDrawers/TraceDrawer/components/EvaluatorDetailsPopover.tsx index 81433952fc..995b5ffae4 100644 --- a/web/oss/src/components/SharedDrawers/TraceDrawer/components/EvaluatorDetailsPopover.tsx +++ b/web/oss/src/components/SharedDrawers/TraceDrawer/components/EvaluatorDetailsPopover.tsx @@ -115,7 +115,7 @@ const EvaluatorDetailsPopover = ({ navigateToEvaluator(evaluator) }} > - Open evaluator registry + Open evaluator playground ) : null} diff --git a/web/oss/src/components/SharedDrawers/TraceDrawer/hooks/useEvaluatorNavigation.ts b/web/oss/src/components/SharedDrawers/TraceDrawer/hooks/useEvaluatorNavigation.ts index 22143e14f9..6f94b20db6 100644 --- a/web/oss/src/components/SharedDrawers/TraceDrawer/hooks/useEvaluatorNavigation.ts +++ b/web/oss/src/components/SharedDrawers/TraceDrawer/hooks/useEvaluatorNavigation.ts @@ -32,18 +32,9 @@ const useEvaluatorNavigation = () => { const identifier = getEvaluatorIdentifier(evaluator) if (!identifier) return null - if (isHumanEvaluator(evaluator)) { - return { - href: `${projectURL}/evaluators?tab=human&openEvaluator=${encodeURIComponent( - identifier, - )}`, - type: "human", - } - } - return { href: `${projectURL}/evaluators/playground?revisions=${encodeURIComponent(identifier)}`, - type: "auto", + type: isHumanEvaluator(evaluator) ? "human" : "auto", } }, [projectURL], From 1706e4334fa48ecbb61ff9116ac04c6380321bad Mon Sep 17 00:00:00 2001 From: GanJiaKouN16 Date: Sat, 6 Jun 2026 15:29:07 +0800 Subject: [PATCH 6/7] feat: migrate email from SendGrid to SMTP with SendGrid fallback Replace the SendGrid-only email backend with SMTP support, keeping SendGrid as a legacy fallback for existing deployments. Changes: - email_service.py: use smtplib for SMTP (priority), SendGrid as fallback, no-op when neither is configured - env.py: add SmtpConfig (SMTP_HOST, SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORD, SMTP_FROM_ADDRESS, SMTP_USE_TLS), keep SendgridConfig for backwards compatibility; update AuthFacade.email_method to check both - OSS/EE organization_service.py: use env.smtp.enabled || env.sendgrid .enabled; use configured from_address instead of hardcoded email - user_service.py: same email-enabled check update - db_manager_ee.py: remove dead sendgrid import and unused sg client - pyproject.toml: remove sendgrid dependency (imported lazily only when SENDGRID_API_KEY is set) - env example files: add SMTP vars, mark SendGrid as legacy - docs: add SMTP config table, mark SendGrid as legacy Closes #4536 --- api/ee/src/services/db_manager_ee.py | 4 - api/ee/src/services/organization_service.py | 12 ++- api/oss/src/services/email_service.py | 86 ++++++++++++++----- api/oss/src/services/organization_service.py | 11 +-- api/oss/src/services/user_service.py | 9 +- api/oss/src/utils/env.py | 45 +++++++--- api/pyproject.toml | 1 - docs/docs/self-host/02-configuration.mdx | 13 ++- hosting/docker-compose/ee/env.ee.dev.example | 12 ++- hosting/docker-compose/ee/env.ee.gh.example | 12 ++- .../docker-compose/oss/env.oss.dev.example | 12 ++- hosting/docker-compose/oss/env.oss.gh.example | 12 ++- 12 files changed, 174 insertions(+), 55 deletions(-) diff --git a/api/ee/src/services/db_manager_ee.py b/api/ee/src/services/db_manager_ee.py index 9b57ebf3c8..5ed39712c5 100644 --- a/api/ee/src/services/db_manager_ee.py +++ b/api/ee/src/services/db_manager_ee.py @@ -2,7 +2,6 @@ import uuid from datetime import datetime, timezone -import sendgrid from fastapi import HTTPException from sqlalchemy import delete, func, update @@ -60,9 +59,6 @@ from oss.src.utils.env import env -# Initialize sendgrid api client -sg = sendgrid.SendGridAPIClient(api_key=env.sendgrid.api_key) - log = get_module_logger(__name__) diff --git a/api/ee/src/services/organization_service.py b/api/ee/src/services/organization_service.py index 5378008ae3..5e1e3dd8a6 100644 --- a/api/ee/src/services/organization_service.py +++ b/api/ee/src/services/organization_service.py @@ -104,8 +104,8 @@ async def send_invitation_email( f"&project_id={project_param}" ) - # If Sendgrid is not configured, return the link for manual sharing (URL-based invitation) - if not env.sendgrid.enabled: + # If email is not configured, return the link for manual sharing (URL-based invitation) + if not env.smtp.enabled and not env.sendgrid.enabled: return invite_link html_content = html_template.format( @@ -118,8 +118,10 @@ async def send_invitation_email( ), ) + from_address = env.smtp.from_address or env.sendgrid.from_address or "account@hello.agenta.ai" + await email_service.send_email( - from_email="account@hello.agenta.ai", + from_email=from_address, to_email=email, subject=f"{user.username} invited you to join {organization.name}", html_content=html_content, @@ -152,10 +154,12 @@ async def notify_org_admin_invitation(workspace: WorkspaceDB, user: UserDB) -> b ), ) + from_address = env.smtp.from_address or env.sendgrid.from_address or "account@hello.agenta.ai" + workspace_admins = await db_manager_ee.get_workspace_administrators(workspace) for workspace_admin in workspace_admins: await email_service.send_email( - from_email="account@hello.agenta.ai", + from_email=from_address, to_email=workspace_admin.email, subject=f"New Member Joined {organization.name}", html_content=html_content, diff --git a/api/oss/src/services/email_service.py b/api/oss/src/services/email_service.py index 8ad0540996..64d67667bf 100644 --- a/api/oss/src/services/email_service.py +++ b/api/oss/src/services/email_service.py @@ -1,7 +1,7 @@ import os - -import sendgrid -from sendgrid.helpers.mail import Mail +import smtplib +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText from fastapi import HTTPException @@ -10,16 +10,25 @@ log = get_logger(__name__) -# Initialize SendGrid only if enabled -if env.sendgrid.enabled: - sg = sendgrid.SendGridAPIClient(api_key=env.sendgrid.api_key) - log.info("✓ SendGrid enabled") +# Determine which email backend to use (SMTP > SendGrid > no-op) +_USE_SMTP = env.smtp.enabled +_USE_SENDGRID = not _USE_SMTP and env.sendgrid.enabled + +if _USE_SMTP: + log.info( + "✓ Email enabled via SMTP (%s:%s)", env.smtp.host, env.smtp.port + ) +elif _USE_SENDGRID: + import sendgrid + + _sg = sendgrid.SendGridAPIClient(api_key=env.sendgrid.api_key) + log.info("✓ Email enabled via SendGrid (legacy)") else: - sg = None + _sg = None if env.sendgrid.api_key and not env.sendgrid.from_address: - log.warn("✗ SendGrid disabled: missing sender email address") + log.warn("✗ Email disabled: missing sender email address") else: - log.warn("✗ SendGrid disabled") + log.warn("✗ Email disabled") def read_email_template(template_file_path): @@ -35,6 +44,46 @@ def read_email_template(template_file_path): return template_file.read() +def _send_via_smtp(to_email: str, subject: str, html_content: str, from_email: str) -> None: + """Send email using SMTP.""" + msg = MIMEMultipart("alternative") + msg["Subject"] = subject + msg["From"] = from_email + msg["To"] = to_email + msg.attach(MIMEText(html_content, "html")) + + smtp_host = env.smtp.host + smtp_port = env.smtp.port + + if env.smtp.use_tls: + server = smtplib.SMTP(smtp_host, smtp_port) + server.ehlo() + server.starttls() + server.ehlo() + else: + server = smtplib.SMTP(smtp_host, smtp_port) + + try: + if env.smtp.username and env.smtp.password: + server.login(env.smtp.username, env.smtp.password) + server.sendmail(from_email, [to_email], msg.as_string()) + finally: + server.quit() + + +def _send_via_sendgrid(to_email: str, subject: str, html_content: str, from_email: str) -> None: + """Send email using SendGrid (legacy fallback).""" + from sendgrid.helpers.mail import Mail + + message = Mail( + from_email=from_email, + to_emails=to_email, + subject=subject, + html_content=html_content, + ) + _sg.send(message) + + async def send_email( to_email: str, subject: str, html_content: str, from_email: str ) -> bool: @@ -54,20 +103,15 @@ async def send_email( HTTPException: If there is an error sending the email. """ - # No-op if SendGrid is disabled - if not env.sendgrid.enabled: - log.info(f"[SENDGRID] Email disabled - would send '{subject}' to {to_email}") + if not _USE_SMTP and not _USE_SENDGRID: + log.info(f"[EMAIL] Email disabled - would send '{subject}' to {to_email}") return True - message = Mail( - from_email=from_email, - to_emails=to_email, - subject=subject, - html_content=html_content, - ) - try: - sg.send(message) + if _USE_SMTP: + _send_via_smtp(to_email, subject, html_content, from_email) + else: + _send_via_sendgrid(to_email, subject, html_content, from_email) return True except Exception as e: raise HTTPException(status_code=500, detail=str(e)) diff --git a/api/oss/src/services/organization_service.py b/api/oss/src/services/organization_service.py index 18a1031949..de1c465987 100644 --- a/api/oss/src/services/organization_service.py +++ b/api/oss/src/services/organization_service.py @@ -154,8 +154,8 @@ async def send_invitation_email( f"&project_id={project_param}" ) - # If Sendgrid is not configured, return the link for manual sharing (URL-based invitation) - if not env.sendgrid.enabled: + # If email is not configured, return the link for manual sharing (URL-based invitation) + if not env.smtp.enabled and not env.sendgrid.enabled: return invite_link html_template = email_service.read_email_template("./templates/send_email.html") @@ -169,11 +169,12 @@ async def send_invitation_email( ), ) - if not env.sendgrid.from_address: - raise ValueError("Sendgrid requires a sender email address to work.") + from_address = env.smtp.from_address or env.sendgrid.from_address + if not from_address: + raise ValueError("Email requires a sender email address to work.") await email_service.send_email( - from_email=env.sendgrid.from_address, + from_email=from_address, to_email=email, subject=f"{user.username} invited you to join their organization", html_content=html_content, diff --git a/api/oss/src/services/user_service.py b/api/oss/src/services/user_service.py index d254510e72..48a7b2d504 100644 --- a/api/oss/src/services/user_service.py +++ b/api/oss/src/services/user_service.py @@ -148,7 +148,7 @@ async def generate_user_password_reset_link(user_id: str, admin_user_id: str): email=user.email, ) - if not env.sendgrid.api_key: + if not env.smtp.enabled and not env.sendgrid.enabled: return password_reset_link html_template = email_service.read_email_template("./templates/send_email.html") @@ -159,11 +159,12 @@ async def generate_user_password_reset_link(user_id: str, admin_user_id: str): call_to_action=f"""

Click the link below to reset your password:


Reset Password""", ) - if not env.sendgrid.from_address: - raise ValueError("Sendgrid requires a sender email address to work.") + from_address = env.smtp.from_address or env.sendgrid.from_address + if not from_address: + raise ValueError("Email requires a sender email address to work.") await email_service.send_email( - from_email=env.sendgrid.from_address, + from_email=from_address, to_email=user.email, subject=f"{admin_user.username} requested a password reset for you in their workspace", html_content=html_content, diff --git a/api/oss/src/utils/env.py b/api/oss/src/utils/env.py index 89c6262bb2..1c02b83303 100644 --- a/api/oss/src/utils/env.py +++ b/api/oss/src/utils/env.py @@ -922,12 +922,40 @@ def enabled(self) -> bool: # --------------------------------------------------------------------------- -# sendgrid +# smtp +# --------------------------------------------------------------------------- + + +class SmtpConfig(BaseModel): + """SMTP Email configuration""" + + host: str | None = os.getenv("SMTP_HOST") + port: int = int(os.getenv("SMTP_PORT", "587")) + username: str | None = os.getenv("SMTP_USERNAME") + password: str | None = os.getenv("SMTP_PASSWORD") + from_address: str | None = ( + os.getenv("SMTP_FROM_ADDRESS") + or os.getenv("SENDGRID_FROM_ADDRESS") + or os.getenv("AGENTA_AUTHN_EMAIL_FROM") + or os.getenv("AGENTA_SEND_EMAIL_FROM_ADDRESS") + ) + use_tls: bool = os.getenv("SMTP_USE_TLS", "true").lower() in ("true", "1", "yes") + + model_config = ConfigDict(extra="ignore") + + @property + def enabled(self) -> bool: + """SMTP enabled if host and from address are present""" + return bool(self.host and self.from_address) + + +# --------------------------------------------------------------------------- +# sendgrid (legacy — kept for backwards compatibility) # --------------------------------------------------------------------------- class SendgridConfig(BaseModel): - """SendGrid Email configuration""" + """SendGrid Email configuration (legacy)""" api_key: str | None = os.getenv("SENDGRID_API_KEY") from_address: str | None = ( @@ -1037,15 +1065,9 @@ def email_method(self) -> str: if env.agenta.access.email_disabled: return "" - sendgrid_enabled = bool( - os.getenv("SENDGRID_API_KEY") - and ( - os.getenv("SENDGRID_FROM_ADDRESS") - or os.getenv("AGENTA_AUTHN_EMAIL_FROM") - or os.getenv("AGENTA_SEND_EMAIL_FROM_ADDRESS") - ) - ) - return "otp" if sendgrid_enabled else "password" + # SMTP takes priority, then SendGrid fallback + email_configured = env.smtp.enabled or env.sendgrid.enabled + return "otp" if email_configured else "password" @property def email_enabled(self) -> bool: @@ -1101,6 +1123,7 @@ class EnvironSettings(BaseModel): posthog: PostHogConfig = PostHogConfig() redis: RedisConfig = RedisConfig() sendgrid: SendgridConfig = SendgridConfig() + smtp: SmtpConfig = SmtpConfig() stripe: StripeConfig = StripeConfig() supertokens: SuperTokensConfig = SuperTokensConfig() diff --git a/api/pyproject.toml b/api/pyproject.toml index f189225c27..4d720854d0 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -34,7 +34,6 @@ dependencies = [ "cachetools>=7,<8", "supertokens-python>=0.31,<0.32", "openai>=2,<3", - "sendgrid>=6,<7", "stripe>=15,<16", "posthog>=7,<8", "newrelic>=13,<14", diff --git a/docs/docs/self-host/02-configuration.mdx b/docs/docs/self-host/02-configuration.mdx index 4525c66f18..796d368d33 100644 --- a/docs/docs/self-host/02-configuration.mdx +++ b/docs/docs/self-host/02-configuration.mdx @@ -254,7 +254,18 @@ This key has no env-var or `env.py` equivalent. | `REDIS_URI_DURABLE` | `redis.uri_durable` | `redis.uriDurable` | | `REDIS_URI_VOLATILE` | `redis.uri_volatile` | `redis.uriVolatile` | -## sendgrid +## smtp + +| Env var | env.py path | values.yaml path | +|---|---|---| +| `SMTP_HOST` | `smtp.host` | `smtp.host` | +| `SMTP_PORT` | `smtp.port` | `smtp.port` | +| `SMTP_USERNAME` | `smtp.username` | `smtp.username` | +| `SMTP_PASSWORD` | `smtp.password` | `smtp.password` | +| `SMTP_FROM_ADDRESS` | `smtp.from_address` | `smtp.fromAddress` | +| `SMTP_USE_TLS` | `smtp.use_tls` | `smtp.useTls` | + +## sendgrid (legacy) | Env var | env.py path | values.yaml path | |---|---|---| diff --git a/hosting/docker-compose/ee/env.ee.dev.example b/hosting/docker-compose/ee/env.ee.dev.example index d4aa299188..edda71a14e 100644 --- a/hosting/docker-compose/ee/env.ee.dev.example +++ b/hosting/docker-compose/ee/env.ee.dev.example @@ -215,7 +215,17 @@ POSTHOG_API_KEY=phc_3urGRy5TL1HhaHnRYL0JSHxJxigRVackhphHtozUmdp # REDIS_URI_VOLATILE=redis://redis-volatile:6379/0 # ================================================================== # -# sendgrid +# smtp +# ================================================================== # +# SMTP_HOST= +# SMTP_PORT=587 +# SMTP_USERNAME= +# SMTP_PASSWORD= +# SMTP_FROM_ADDRESS= +# SMTP_USE_TLS=true + +# ================================================================== # +# sendgrid (legacy — use SMTP instead) # ================================================================== # # SENDGRID_API_KEY= # SENDGRID_FROM_ADDRESS= diff --git a/hosting/docker-compose/ee/env.ee.gh.example b/hosting/docker-compose/ee/env.ee.gh.example index 0310e956b0..f7dc1a7d3b 100644 --- a/hosting/docker-compose/ee/env.ee.gh.example +++ b/hosting/docker-compose/ee/env.ee.gh.example @@ -215,7 +215,17 @@ POSTHOG_API_KEY=phc_3urGRy5TL1HhaHnRYL0JSHxJxigRVackhphHtozUmdp # REDIS_URI_VOLATILE=redis://redis-volatile:6379/0 # ================================================================== # -# sendgrid +# smtp +# ================================================================== # +# SMTP_HOST= +# SMTP_PORT=587 +# SMTP_USERNAME= +# SMTP_PASSWORD= +# SMTP_FROM_ADDRESS= +# SMTP_USE_TLS=true + +# ================================================================== # +# sendgrid (legacy — use SMTP instead) # ================================================================== # # SENDGRID_API_KEY= # SENDGRID_FROM_ADDRESS= diff --git a/hosting/docker-compose/oss/env.oss.dev.example b/hosting/docker-compose/oss/env.oss.dev.example index 53aca58e84..90d6340914 100644 --- a/hosting/docker-compose/oss/env.oss.dev.example +++ b/hosting/docker-compose/oss/env.oss.dev.example @@ -215,7 +215,17 @@ POSTHOG_API_KEY=phc_hmVSxIjTW1REBHXgj2aw4HW9X6CXb6FzerBgP9XenC7 # REDIS_URI_VOLATILE=redis://redis-volatile:6379/0 # ================================================================== # -# sendgrid +# smtp +# ================================================================== # +# SMTP_HOST= +# SMTP_PORT=587 +# SMTP_USERNAME= +# SMTP_PASSWORD= +# SMTP_FROM_ADDRESS= +# SMTP_USE_TLS=true + +# ================================================================== # +# sendgrid (legacy — use SMTP instead) # ================================================================== # # SENDGRID_API_KEY= # SENDGRID_FROM_ADDRESS= diff --git a/hosting/docker-compose/oss/env.oss.gh.example b/hosting/docker-compose/oss/env.oss.gh.example index 4fc6b1244e..947614525a 100644 --- a/hosting/docker-compose/oss/env.oss.gh.example +++ b/hosting/docker-compose/oss/env.oss.gh.example @@ -215,7 +215,17 @@ POSTHOG_API_KEY=phc_hmVSxIjTW1REBHXgj2aw4HW9X6CXb6FzerBgP9XenC7 # REDIS_URI_VOLATILE=redis://redis-volatile:6379/0 # ================================================================== # -# sendgrid +# smtp +# ================================================================== # +# SMTP_HOST= +# SMTP_PORT=587 +# SMTP_USERNAME= +# SMTP_PASSWORD= +# SMTP_FROM_ADDRESS= +# SMTP_USE_TLS=true + +# ================================================================== # +# sendgrid (legacy — use SMTP instead) # ================================================================== # # SENDGRID_API_KEY= # SENDGRID_FROM_ADDRESS= From ad6e140f465765e3258f9a554a1a011866bc4089 Mon Sep 17 00:00:00 2001 From: GanJiaKouN16 Date: Sat, 6 Jun 2026 15:38:08 +0800 Subject: [PATCH 7/7] fix: show correct breadcrumb label for SDK evaluations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The breadcrumb always showed 'Auto Evals' for SDK evaluations because: 1. test.tsx normalized type='custom' to 'auto' before passing to EvalRunPreviewPage, losing the SDK type information 2. Page.tsx typeMap had no 'custom' entry 3. buildBreadcrumbs.ts hardcoded 'auto evaluation' as fallback label Fix: - Remove the custom→auto normalization in test.tsx - Add 'custom' → 'SDK Evals' entry to Page.tsx typeMap (matches the tab label in EvaluationsView.tsx) - Change buildBreadcrumbs.ts fallback from 'auto evaluation' to 'Evaluations' Closes #4549 --- web/oss/src/components/EvalRunDetails/components/Page.tsx | 1 + web/oss/src/components/EvalRunDetails/test.tsx | 3 +-- web/oss/src/lib/helpers/buildBreadcrumbs.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/web/oss/src/components/EvalRunDetails/components/Page.tsx b/web/oss/src/components/EvalRunDetails/components/Page.tsx index 6433566ed1..6a83a989e1 100644 --- a/web/oss/src/components/EvalRunDetails/components/Page.tsx +++ b/web/oss/src/components/EvalRunDetails/components/Page.tsx @@ -48,6 +48,7 @@ const EvalRunPreviewPage = ({runId, evaluationType, projectId = null}: EvalRunPr const evaluationTypeBreadcrumb = useMemo(() => { const typeMap: Record = { auto: {label: "Auto Evals", kind: "auto"}, + custom: {label: "SDK Evals", kind: "custom"}, human: {label: "Human Evals", kind: "human"}, online: {label: "Live Evals", kind: "online"}, } diff --git a/web/oss/src/components/EvalRunDetails/test.tsx b/web/oss/src/components/EvalRunDetails/test.tsx index 9c71b4e627..2e954c99de 100644 --- a/web/oss/src/components/EvalRunDetails/test.tsx +++ b/web/oss/src/components/EvalRunDetails/test.tsx @@ -8,8 +8,7 @@ import EvalResultsOnboarding from "./EvalResultsOnboarding" type EvalRunKind = "auto" | "human" | "online" | "custom" const EvalRunTestPage = ({type = "auto"}: {type?: EvalRunKind}) => { - // Normalize "custom" to "auto", but keep "online" as-is - const evaluationType = type === "custom" ? "auto" : type + const evaluationType = type const router = useRouter() const evaluationIdParam = router.query?.evaluation_id const projectIdParam = router.query?.project_id diff --git a/web/oss/src/lib/helpers/buildBreadcrumbs.ts b/web/oss/src/lib/helpers/buildBreadcrumbs.ts index f877bbe216..cbd3e97185 100644 --- a/web/oss/src/lib/helpers/buildBreadcrumbs.ts +++ b/web/oss/src/lib/helpers/buildBreadcrumbs.ts @@ -81,7 +81,7 @@ export const buildBreadcrumbSegments = ({ if (seg === "evaluations") { const hasResults = next === "results" const evaluationsHref = `${baseAppsPath}/${appId}/evaluations` - items["appPage"] = {label: "auto evaluation", href: evaluationsHref} + items["appPage"] = {label: "Evaluations", href: evaluationsHref} if (hasResults) { i++