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=
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:
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/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/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/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],
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/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/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++
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/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
+}
diff --git a/web/oss/tests/playwright/acceptance/annotation-queue/annotation-queue.spec.ts b/web/oss/tests/playwright/acceptance/annotation-queue/annotation-queue.spec.ts
new file mode 100644
index 0000000000..4c92f20b13
--- /dev/null
+++ b/web/oss/tests/playwright/acceptance/annotation-queue/annotation-queue.spec.ts
@@ -0,0 +1,4 @@
+import {test} from "@agenta/web-tests/tests/fixtures/base.fixture"
+import annotationQueueTests from "."
+
+test.describe("Annotation Queue", annotationQueueTests)
diff --git a/web/oss/tests/playwright/acceptance/annotation-queue/assets/types.ts b/web/oss/tests/playwright/acceptance/annotation-queue/assets/types.ts
new file mode 100644
index 0000000000..20e130b5ab
--- /dev/null
+++ b/web/oss/tests/playwright/acceptance/annotation-queue/assets/types.ts
@@ -0,0 +1,6 @@
+import type {BaseFixture} from "@agenta/web-tests/tests/fixtures/base.fixture/types"
+
+export interface AnnotationQueueFixtures extends BaseFixture {
+ navigateToAnnotations: () => Promise
+ createAnnotationQueue: (config: {name: string; kind: "traces" | "testcases"}) => Promise
+}
diff --git a/web/oss/tests/playwright/acceptance/annotation-queue/index.ts b/web/oss/tests/playwright/acceptance/annotation-queue/index.ts
new file mode 100644
index 0000000000..d5120664f6
--- /dev/null
+++ b/web/oss/tests/playwright/acceptance/annotation-queue/index.ts
@@ -0,0 +1,219 @@
+import {
+ createTagString,
+ TestCoverage,
+ TestPath,
+ TestSpeedType,
+ TestScope,
+} from "@agenta/web-tests/playwright/config/testTags"
+import {getProjectScopedBasePath} from "@agenta/web-tests/tests/fixtures/base.fixture/apiHelpers"
+
+import {expect, test as baseTest} from "./tests"
+
+const QUEUE_NAME_PREFIX = "e2e-annotation-queue"
+
+const annotationQueueTests = () => {
+ // WEB-ACC-ANNOTATION-001
+ baseTest(
+ "should navigate to annotation queues page and see the queue list or empty state",
+ {
+ tag: [
+ createTagString("scope", TestScope.EVALUATIONS),
+ createTagString("coverage", TestCoverage.SMOKE),
+ createTagString("coverage", TestCoverage.LIGHT),
+ createTagString("coverage", TestCoverage.FULL),
+ createTagString("path", TestPath.HAPPY),
+ createTagString("license", "oss"),
+ ],
+ },
+ async ({navigateToAnnotations, page}) => {
+ await navigateToAnnotations()
+
+ // The page should render either the empty state or the queue table
+ const hasEmptyState = await page
+ .getByText("Create your first annotation queue")
+ .isVisible()
+ .catch(() => false)
+ const hasTableRows = (await page.locator("[data-row-key]").count()) > 0
+ expect(hasEmptyState || hasTableRows).toBe(true)
+
+ // The "New Queue" button should always be visible
+ await expect(page.getByRole("button", {name: "New Queue"})).toBeVisible()
+ },
+ )
+
+ // WEB-ACC-ANNOTATION-002
+ baseTest(
+ "should create a new annotation queue with testcases kind",
+ {
+ tag: [
+ createTagString("scope", TestScope.EVALUATIONS),
+ createTagString("coverage", TestCoverage.SMOKE),
+ createTagString("coverage", TestCoverage.LIGHT),
+ createTagString("coverage", TestCoverage.FULL),
+ createTagString("path", TestPath.HAPPY),
+ createTagString("license", "oss"),
+ ],
+ },
+ async ({navigateToAnnotations, createAnnotationQueue, page}) => {
+ await navigateToAnnotations()
+
+ const queueName = `${QUEUE_NAME_PREFIX}-testcases-${Date.now()}`
+ await createAnnotationQueue({name: queueName, kind: "testcases"})
+
+ // After creation, the queue should appear in the list
+ await expect(page.getByText(queueName).first()).toBeVisible({timeout: 15000})
+ },
+ )
+
+ // WEB-ACC-ANNOTATION-003
+ baseTest(
+ "should create a new annotation queue with traces kind",
+ {
+ tag: [
+ createTagString("scope", TestScope.EVALUATIONS),
+ createTagString("coverage", TestCoverage.SMOKE),
+ createTagString("coverage", TestCoverage.LIGHT),
+ createTagString("coverage", TestCoverage.FULL),
+ createTagString("path", TestPath.HAPPY),
+ createTagString("license", "oss"),
+ ],
+ },
+ async ({navigateToAnnotations, createAnnotationQueue, page}) => {
+ await navigateToAnnotations()
+
+ const queueName = `${QUEUE_NAME_PREFIX}-traces-${Date.now()}`
+ await createAnnotationQueue({name: queueName, kind: "traces"})
+
+ // After creation, the queue should appear in the list
+ await expect(page.getByText(queueName).first()).toBeVisible({timeout: 15000})
+ },
+ )
+
+ // WEB-ACC-ANNOTATION-004
+ baseTest(
+ "should open a queue detail page when clicking on a queue row",
+ {
+ tag: [
+ createTagString("scope", TestScope.EVALUATIONS),
+ createTagString("coverage", TestCoverage.LIGHT),
+ createTagString("coverage", TestCoverage.FULL),
+ createTagString("path", TestPath.HAPPY),
+ createTagString("license", "oss"),
+ createTagString("speed", TestSpeedType.SLOW),
+ ],
+ },
+ async ({navigateToAnnotations, createAnnotationQueue, page}, testInfo) => {
+ testInfo.setTimeout(120000)
+
+ await navigateToAnnotations()
+
+ const queueName = `${QUEUE_NAME_PREFIX}-detail-${Date.now()}`
+ await createAnnotationQueue({name: queueName, kind: "testcases"})
+
+ // Click on the queue row to navigate to detail page
+ const queueRow = page.locator("[data-row-key]").filter({hasText: queueName}).first()
+ await expect(queueRow).toBeVisible({timeout: 15000})
+ await queueRow.click()
+
+ // Should navigate to the queue detail page
+ await expect
+ .poll(() => new URL(page.url()).pathname, {timeout: 15000})
+ .toContain(`${getProjectScopedBasePath(page)}/annotations/`)
+
+ // The queue name should be visible on the detail page
+ await expect(page.getByText(queueName).first()).toBeVisible({timeout: 10000})
+ },
+ )
+
+ // WEB-ACC-ANNOTATION-005
+ baseTest(
+ "should search for annotation queues by name",
+ {
+ tag: [
+ createTagString("scope", TestScope.EVALUATIONS),
+ createTagString("coverage", TestCoverage.LIGHT),
+ createTagString("coverage", TestCoverage.FULL),
+ createTagString("path", TestPath.HAPPY),
+ createTagString("license", "oss"),
+ createTagString("speed", TestSpeedType.SLOW),
+ ],
+ },
+ async ({navigateToAnnotations, createAnnotationQueue, page}, testInfo) => {
+ testInfo.setTimeout(120000)
+
+ await navigateToAnnotations()
+
+ // Create a uniquely named queue so search is deterministic
+ const uniqueSuffix = Date.now()
+ const queueName = `${QUEUE_NAME_PREFIX}-search-${uniqueSuffix}`
+ await createAnnotationQueue({name: queueName, kind: "testcases"})
+
+ // Use the search input to filter
+ const searchInput = page.locator('input[placeholder="Search queues"]').first()
+ await expect(searchInput).toBeVisible({timeout: 10000})
+ await searchInput.click()
+ await searchInput.fill("")
+ await searchInput.pressSequentially(queueName, {delay: 30})
+
+ // The matching queue should be visible
+ await expect(page.getByText(queueName).first()).toBeVisible({timeout: 15000})
+
+ // Non-matching queues should be filtered out (if any other queues exist)
+ const allRows = page.locator("[data-row-key]")
+ const rowCount = await allRows.count()
+ if (rowCount > 0) {
+ // All visible rows should contain the search term
+ for (let i = 0; i < rowCount; i++) {
+ await expect(allRows.nth(i)).toContainText(queueName)
+ }
+ }
+ },
+ )
+
+ // WEB-ACC-ANNOTATION-006
+ baseTest(
+ "should delete an annotation queue via the actions menu",
+ {
+ tag: [
+ createTagString("scope", TestScope.EVALUATIONS),
+ createTagString("coverage", TestCoverage.LIGHT),
+ createTagString("coverage", TestCoverage.FULL),
+ createTagString("path", TestPath.HAPPY),
+ createTagString("license", "oss"),
+ createTagString("speed", TestSpeedType.SLOW),
+ ],
+ },
+ async ({navigateToAnnotations, createAnnotationQueue, page}, testInfo) => {
+ testInfo.setTimeout(120000)
+
+ await navigateToAnnotations()
+
+ const queueName = `${QUEUE_NAME_PREFIX}-delete-${Date.now()}`
+ await createAnnotationQueue({name: queueName, kind: "testcases"})
+
+ // Find the queue row
+ const queueRow = page.locator("[data-row-key]").filter({hasText: queueName}).first()
+ await expect(queueRow).toBeVisible({timeout: 15000})
+
+ // Click the actions (gear/more) button in the row
+ const actionsButton = queueRow.locator("button").last()
+ await actionsButton.click()
+
+ // Click Delete from the dropdown menu
+ const deleteMenuItem = page.getByText("Delete", {exact: true}).last()
+ await expect(deleteMenuItem).toBeVisible({timeout: 5000})
+ await deleteMenuItem.click()
+
+ // Confirm the deletion in the modal/alert
+ const confirmButton = page.getByRole("button", {name: /Delete|Confirm|OK/i}).last()
+ if (await confirmButton.isVisible().catch(() => false)) {
+ await confirmButton.click()
+ }
+
+ // The queue should no longer appear in the list
+ await expect(page.getByText(queueName)).toBeHidden({timeout: 15000})
+ },
+ )
+}
+
+export default annotationQueueTests
diff --git a/web/oss/tests/playwright/acceptance/annotation-queue/tests.ts b/web/oss/tests/playwright/acceptance/annotation-queue/tests.ts
new file mode 100644
index 0000000000..42df4fb7d5
--- /dev/null
+++ b/web/oss/tests/playwright/acceptance/annotation-queue/tests.ts
@@ -0,0 +1,83 @@
+import {test as baseTest} from "@agenta/web-tests/tests/fixtures/base.fixture"
+import {getProjectScopedBasePath} from "@agenta/web-tests/tests/fixtures/base.fixture/apiHelpers"
+import {expect} from "@agenta/web-tests/utils"
+import type {Page} from "@playwright/test"
+
+import type {AnnotationQueueFixtures} from "./assets/types"
+
+const getAnnotationsPath = (page: Page) =>
+ `${getProjectScopedBasePath(page)}/annotations`
+
+const waitForQueueListLoad = async (page: Page) => {
+ // Wait for the annotations page to finish loading — either the table
+ // renders rows or the empty state appears.
+ await expect
+ .poll(
+ async () => {
+ const hasTable = (await page.locator("[data-row-key]").count()) > 0
+ const hasEmptyState = await page
+ .getByText("Create your first annotation queue")
+ .isVisible()
+ .catch(() => false)
+ const hasNewQueueButton = await page
+ .getByRole("button", {name: "New Queue"})
+ .isVisible()
+ .catch(() => false)
+ return hasTable || hasEmptyState || hasNewQueueButton
+ },
+ {timeout: 30000},
+ )
+ .toBe(true)
+}
+
+const testWithAnnotationFixtures = baseTest.extend({
+ navigateToAnnotations: async ({page}, use) => {
+ await use(async () => {
+ const annotationsPath = getAnnotationsPath(page)
+ await page.goto(annotationsPath, {waitUntil: "domcontentloaded"})
+ await expect.poll(() => new URL(page.url()).pathname).toBe(annotationsPath)
+ await waitForQueueListLoad(page)
+ })
+ },
+
+ createAnnotationQueue: async ({page}, use) => {
+ await use(async ({name, kind}) => {
+ // Click the "New Queue" button
+ const newQueueButton = page.getByRole("button", {name: "New Queue"})
+ await expect(newQueueButton).toBeVisible({timeout: 10000})
+ await newQueueButton.click()
+
+ // Wait for the drawer to open
+ const drawer = page.locator(".ant-drawer-content-wrapper").last()
+ await expect(drawer).toBeVisible({timeout: 10000})
+ await expect(drawer.getByText("Create annotation queue")).toBeVisible()
+
+ // Fill in the queue name
+ const nameInput = drawer.locator('input[placeholder="Enter name"]').first()
+ await expect(nameInput).toBeVisible()
+ await nameInput.click()
+ await nameInput.fill("")
+ await nameInput.pressSequentially(name, {delay: 20})
+ await expect(nameInput).toHaveValue(name)
+
+ // Select queue type if different from default ("traces")
+ if (kind === "testcases") {
+ const kindSelect = drawer.locator(".ant-select").first()
+ await kindSelect.click()
+ const testcaseOption = page.getByText("Test cases", {exact: true}).last()
+ await expect(testcaseOption).toBeVisible()
+ await testcaseOption.click()
+ }
+
+ // Click Create button
+ const createButton = drawer.getByRole("button", {name: "Create"}).last()
+ await expect(createButton).toBeEnabled({timeout: 10000})
+ await createButton.click()
+
+ // Wait for drawer to close (indicating success)
+ await expect(drawer).toBeHidden({timeout: 30000})
+ })
+ },
+})
+
+export {testWithAnnotationFixtures as test, expect}
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-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-entities/src/workflow/state/store.ts b/web/packages/agenta-entities/src/workflow/state/store.ts
index 7443c378e1..c9f73d5502 100644
--- a/web/packages/agenta-entities/src/workflow/state/store.ts
+++ b/web/packages/agenta-entities/src/workflow/state/store.ts
@@ -1623,7 +1623,9 @@ export const workflowIsDirtyAtomFamily = atomFamily((workflowId: string) =>
const serverData = get(workflowServerDataSelectorFamily(workflowId))
if (!serverData) {
- return !!entityData
+ // No server baseline (ephemeral / newly created entity) —
+ // treat as clean until the user makes actual edits.
+ return false
}
if (!entityData) return false
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-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) */
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. */