mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-30 03:00:41 -04:00
Compare commits
11 Commits
spare/5
...
fix/small-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6b605f936 | ||
|
|
29215e9523 | ||
|
|
6456e0f317 | ||
|
|
12fae3132a | ||
|
|
1a38974f19 | ||
|
|
f410929560 | ||
|
|
9c5d1e1019 | ||
|
|
2bbec09e1a | ||
|
|
31b88a6e56 | ||
|
|
d357956d98 | ||
|
|
697ffa81f0 |
@@ -43,6 +43,25 @@ config = Config()
|
||||
integration_creds_manager = IntegrationCredentialsManager()
|
||||
|
||||
|
||||
async def _fetch_execution_counts(user_id: str, graph_ids: list[str]) -> dict[str, int]:
|
||||
"""Fetch execution counts per graph in a single batched query."""
|
||||
if not graph_ids:
|
||||
return {}
|
||||
rows = await prisma.models.AgentGraphExecution.prisma().group_by(
|
||||
by=["agentGraphId"],
|
||||
where={
|
||||
"userId": user_id,
|
||||
"agentGraphId": {"in": graph_ids},
|
||||
"isDeleted": False,
|
||||
},
|
||||
count=True,
|
||||
)
|
||||
return {
|
||||
row["agentGraphId"]: int((row.get("_count") or {}).get("_all") or 0)
|
||||
for row in rows
|
||||
}
|
||||
|
||||
|
||||
async def list_library_agents(
|
||||
user_id: str,
|
||||
search_term: Optional[str] = None,
|
||||
@@ -137,12 +156,18 @@ async def list_library_agents(
|
||||
|
||||
logger.debug(f"Retrieved {len(library_agents)} library agents for user #{user_id}")
|
||||
|
||||
graph_ids = [a.agentGraphId for a in library_agents if a.agentGraphId]
|
||||
execution_counts = await _fetch_execution_counts(user_id, graph_ids)
|
||||
|
||||
# Only pass valid agents to the response
|
||||
valid_library_agents: list[library_model.LibraryAgent] = []
|
||||
|
||||
for agent in library_agents:
|
||||
try:
|
||||
library_agent = library_model.LibraryAgent.from_db(agent)
|
||||
library_agent = library_model.LibraryAgent.from_db(
|
||||
agent,
|
||||
execution_count_override=execution_counts.get(agent.agentGraphId),
|
||||
)
|
||||
valid_library_agents.append(library_agent)
|
||||
except Exception as e:
|
||||
# Skip this agent if there was an error
|
||||
@@ -214,12 +239,18 @@ async def list_favorite_library_agents(
|
||||
f"Retrieved {len(library_agents)} favorite library agents for user #{user_id}"
|
||||
)
|
||||
|
||||
graph_ids = [a.agentGraphId for a in library_agents if a.agentGraphId]
|
||||
execution_counts = await _fetch_execution_counts(user_id, graph_ids)
|
||||
|
||||
# Only pass valid agents to the response
|
||||
valid_library_agents: list[library_model.LibraryAgent] = []
|
||||
|
||||
for agent in library_agents:
|
||||
try:
|
||||
library_agent = library_model.LibraryAgent.from_db(agent)
|
||||
library_agent = library_model.LibraryAgent.from_db(
|
||||
agent,
|
||||
execution_count_override=execution_counts.get(agent.agentGraphId),
|
||||
)
|
||||
valid_library_agents.append(library_agent)
|
||||
except Exception as e:
|
||||
# Skip this agent if there was an error
|
||||
|
||||
@@ -65,6 +65,11 @@ async def test_get_library_agents(mocker):
|
||||
)
|
||||
mock_library_agent.return_value.count = mocker.AsyncMock(return_value=1)
|
||||
|
||||
mocker.patch(
|
||||
"backend.api.features.library.db._fetch_execution_counts",
|
||||
new=mocker.AsyncMock(return_value={}),
|
||||
)
|
||||
|
||||
# Call function
|
||||
result = await db.list_library_agents("test-user")
|
||||
|
||||
@@ -353,3 +358,136 @@ async def test_create_library_agent_uses_upsert():
|
||||
# Verify update branch restores soft-deleted/archived agents
|
||||
assert data["update"]["isDeleted"] is False
|
||||
assert data["update"]["isArchived"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_favorite_library_agents(mocker):
|
||||
mock_library_agents = [
|
||||
prisma.models.LibraryAgent(
|
||||
id="fav1",
|
||||
userId="test-user",
|
||||
agentGraphId="agent-fav",
|
||||
settings="{}", # type: ignore
|
||||
agentGraphVersion=1,
|
||||
isCreatedByUser=False,
|
||||
isDeleted=False,
|
||||
isArchived=False,
|
||||
createdAt=datetime.now(),
|
||||
updatedAt=datetime.now(),
|
||||
isFavorite=True,
|
||||
useGraphIsActiveVersion=True,
|
||||
AgentGraph=prisma.models.AgentGraph(
|
||||
id="agent-fav",
|
||||
version=1,
|
||||
name="Favorite Agent",
|
||||
description="My Favorite",
|
||||
userId="other-user",
|
||||
isActive=True,
|
||||
createdAt=datetime.now(),
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
mock_library_agent = mocker.patch("prisma.models.LibraryAgent.prisma")
|
||||
mock_library_agent.return_value.find_many = mocker.AsyncMock(
|
||||
return_value=mock_library_agents
|
||||
)
|
||||
mock_library_agent.return_value.count = mocker.AsyncMock(return_value=1)
|
||||
|
||||
mocker.patch(
|
||||
"backend.api.features.library.db._fetch_execution_counts",
|
||||
new=mocker.AsyncMock(return_value={"agent-fav": 7}),
|
||||
)
|
||||
|
||||
result = await db.list_favorite_library_agents("test-user")
|
||||
|
||||
assert len(result.agents) == 1
|
||||
assert result.agents[0].id == "fav1"
|
||||
assert result.agents[0].name == "Favorite Agent"
|
||||
assert result.agents[0].graph_id == "agent-fav"
|
||||
assert result.pagination.total_items == 1
|
||||
assert result.pagination.total_pages == 1
|
||||
assert result.pagination.current_page == 1
|
||||
assert result.pagination.page_size == 50
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_library_agents_skips_failed_agent(mocker):
|
||||
"""Agents that fail parsing should be skipped — covers the except branch."""
|
||||
mock_library_agents = [
|
||||
prisma.models.LibraryAgent(
|
||||
id="ua-bad",
|
||||
userId="test-user",
|
||||
agentGraphId="agent-bad",
|
||||
settings="{}", # type: ignore
|
||||
agentGraphVersion=1,
|
||||
isCreatedByUser=False,
|
||||
isDeleted=False,
|
||||
isArchived=False,
|
||||
createdAt=datetime.now(),
|
||||
updatedAt=datetime.now(),
|
||||
isFavorite=False,
|
||||
useGraphIsActiveVersion=True,
|
||||
AgentGraph=prisma.models.AgentGraph(
|
||||
id="agent-bad",
|
||||
version=1,
|
||||
name="Bad Agent",
|
||||
description="",
|
||||
userId="other-user",
|
||||
isActive=True,
|
||||
createdAt=datetime.now(),
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
mock_library_agent = mocker.patch("prisma.models.LibraryAgent.prisma")
|
||||
mock_library_agent.return_value.find_many = mocker.AsyncMock(
|
||||
return_value=mock_library_agents
|
||||
)
|
||||
mock_library_agent.return_value.count = mocker.AsyncMock(return_value=1)
|
||||
|
||||
mocker.patch(
|
||||
"backend.api.features.library.db._fetch_execution_counts",
|
||||
new=mocker.AsyncMock(return_value={}),
|
||||
)
|
||||
mocker.patch(
|
||||
"backend.api.features.library.model.LibraryAgent.from_db",
|
||||
side_effect=Exception("parse error"),
|
||||
)
|
||||
|
||||
result = await db.list_library_agents("test-user")
|
||||
|
||||
assert len(result.agents) == 0
|
||||
assert result.pagination.total_items == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_execution_counts_empty_graph_ids():
|
||||
result = await db._fetch_execution_counts("user-1", [])
|
||||
assert result == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_execution_counts_uses_group_by(mocker):
|
||||
mock_prisma = mocker.patch("prisma.models.AgentGraphExecution.prisma")
|
||||
mock_prisma.return_value.group_by = mocker.AsyncMock(
|
||||
return_value=[
|
||||
{"agentGraphId": "graph-1", "_count": {"_all": 5}},
|
||||
{"agentGraphId": "graph-2", "_count": {"_all": 2}},
|
||||
]
|
||||
)
|
||||
|
||||
result = await db._fetch_execution_counts(
|
||||
"user-1", ["graph-1", "graph-2", "graph-3"]
|
||||
)
|
||||
|
||||
assert result == {"graph-1": 5, "graph-2": 2}
|
||||
mock_prisma.return_value.group_by.assert_called_once_with(
|
||||
by=["agentGraphId"],
|
||||
where={
|
||||
"userId": "user-1",
|
||||
"agentGraphId": {"in": ["graph-1", "graph-2", "graph-3"]},
|
||||
"isDeleted": False,
|
||||
},
|
||||
count=True,
|
||||
)
|
||||
|
||||
@@ -223,6 +223,7 @@ class LibraryAgent(pydantic.BaseModel):
|
||||
sub_graphs: Optional[list[prisma.models.AgentGraph]] = None,
|
||||
store_listing: Optional[prisma.models.StoreListing] = None,
|
||||
profile: Optional[prisma.models.Profile] = None,
|
||||
execution_count_override: Optional[int] = None,
|
||||
) -> "LibraryAgent":
|
||||
"""
|
||||
Factory method that constructs a LibraryAgent from a Prisma LibraryAgent
|
||||
@@ -258,10 +259,14 @@ class LibraryAgent(pydantic.BaseModel):
|
||||
status = status_result.status
|
||||
new_output = status_result.new_output
|
||||
|
||||
execution_count = len(executions)
|
||||
execution_count = (
|
||||
execution_count_override
|
||||
if execution_count_override is not None
|
||||
else len(executions)
|
||||
)
|
||||
success_rate: float | None = None
|
||||
avg_correctness_score: float | None = None
|
||||
if execution_count > 0:
|
||||
if executions and execution_count > 0:
|
||||
success_count = sum(
|
||||
1
|
||||
for e in executions
|
||||
|
||||
@@ -1,11 +1,66 @@
|
||||
import datetime
|
||||
|
||||
import prisma.enums
|
||||
import prisma.models
|
||||
import pytest
|
||||
|
||||
from . import model as library_model
|
||||
|
||||
|
||||
def _make_library_agent(
|
||||
*,
|
||||
graph_id: str = "g1",
|
||||
executions: list | None = None,
|
||||
) -> prisma.models.LibraryAgent:
|
||||
return prisma.models.LibraryAgent(
|
||||
id="la1",
|
||||
userId="u1",
|
||||
agentGraphId=graph_id,
|
||||
settings="{}", # type: ignore
|
||||
agentGraphVersion=1,
|
||||
isCreatedByUser=True,
|
||||
isDeleted=False,
|
||||
isArchived=False,
|
||||
createdAt=datetime.datetime.now(),
|
||||
updatedAt=datetime.datetime.now(),
|
||||
isFavorite=False,
|
||||
useGraphIsActiveVersion=True,
|
||||
AgentGraph=prisma.models.AgentGraph(
|
||||
id=graph_id,
|
||||
version=1,
|
||||
name="Agent",
|
||||
description="Desc",
|
||||
userId="u1",
|
||||
isActive=True,
|
||||
createdAt=datetime.datetime.now(),
|
||||
Executions=executions,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_from_db_execution_count_override_covers_success_rate():
|
||||
"""Covers execution_count_override is not None branch and executions/count > 0 block."""
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
exec1 = prisma.models.AgentGraphExecution(
|
||||
id="exec-1",
|
||||
agentGraphId="g1",
|
||||
agentGraphVersion=1,
|
||||
userId="u1",
|
||||
executionStatus=prisma.enums.AgentExecutionStatus.COMPLETED,
|
||||
createdAt=now,
|
||||
updatedAt=now,
|
||||
isDeleted=False,
|
||||
isShared=False,
|
||||
)
|
||||
agent = _make_library_agent(executions=[exec1])
|
||||
|
||||
result = library_model.LibraryAgent.from_db(agent, execution_count_override=1)
|
||||
|
||||
assert result.execution_count == 1
|
||||
assert result.success_rate is not None
|
||||
assert result.success_rate == 100.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_preset_from_db(test_user_id: str):
|
||||
# Create mock DB agent
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,8 @@ import time
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated, Any, Literal, Sequence, get_args
|
||||
from typing import Annotated, Any, Literal, Sequence, cast, get_args
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import pydantic
|
||||
import stripe
|
||||
@@ -54,8 +55,11 @@ from backend.data.credit import (
|
||||
cancel_stripe_subscription,
|
||||
create_subscription_checkout,
|
||||
get_auto_top_up,
|
||||
get_proration_credit_cents,
|
||||
get_subscription_price_id,
|
||||
get_user_credit_model,
|
||||
handle_subscription_payment_failure,
|
||||
modify_stripe_subscription_for_tier,
|
||||
set_auto_top_up,
|
||||
set_subscription_tier,
|
||||
sync_subscription_from_stripe,
|
||||
@@ -699,9 +703,72 @@ class SubscriptionCheckoutResponse(BaseModel):
|
||||
|
||||
|
||||
class SubscriptionStatusResponse(BaseModel):
|
||||
tier: str
|
||||
monthly_cost: int
|
||||
tier_costs: dict[str, int]
|
||||
tier: Literal["FREE", "PRO", "BUSINESS", "ENTERPRISE"]
|
||||
monthly_cost: int # amount in cents (Stripe convention)
|
||||
tier_costs: dict[str, int] # tier name -> amount in cents
|
||||
proration_credit_cents: int # unused portion of current sub to convert on upgrade
|
||||
|
||||
|
||||
def _validate_checkout_redirect_url(url: str) -> bool:
|
||||
"""Return True if `url` matches the configured frontend origin.
|
||||
|
||||
Prevents open-redirect: attackers must not be able to supply arbitrary
|
||||
success_url/cancel_url that Stripe will redirect users to after checkout.
|
||||
|
||||
Pre-parse rejection rules (applied before urlparse):
|
||||
- Backslashes (``\\``) are normalised differently across parsers/browsers.
|
||||
- Control characters (U+0000–U+001F) are not valid in URLs and may confuse
|
||||
some URL-parsing implementations.
|
||||
"""
|
||||
# Reject characters that can confuse URL parsers before any parsing.
|
||||
if "\\" in url:
|
||||
return False
|
||||
if any(ord(c) < 0x20 for c in url):
|
||||
return False
|
||||
|
||||
allowed = settings.config.frontend_base_url or settings.config.platform_base_url
|
||||
if not allowed:
|
||||
# No configured origin — refuse to validate rather than allow arbitrary URLs.
|
||||
return False
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
allowed_parsed = urlparse(allowed)
|
||||
except ValueError:
|
||||
return False
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
return False
|
||||
# Reject ``user:pass@host`` authority tricks — ``@`` in the netloc component
|
||||
# can trick browsers into connecting to a different host than displayed.
|
||||
# ``@`` in query/fragment is harmless and must be allowed.
|
||||
if "@" in parsed.netloc:
|
||||
return False
|
||||
return (
|
||||
parsed.scheme == allowed_parsed.scheme
|
||||
and parsed.netloc == allowed_parsed.netloc
|
||||
)
|
||||
|
||||
|
||||
@cached(ttl_seconds=300, maxsize=32, cache_none=False)
|
||||
async def _get_stripe_price_amount(price_id: str) -> int | None:
|
||||
"""Return the unit_amount (cents) for a Stripe Price ID, cached for 5 minutes.
|
||||
|
||||
Returns ``None`` on transient Stripe errors. ``cache_none=False`` opts out
|
||||
of caching the ``None`` sentinel so the next request retries Stripe instead
|
||||
of being served a stale "no price" for the rest of the TTL window. Callers
|
||||
should treat ``None`` as an unknown price and fall back to 0.
|
||||
|
||||
Stripe prices rarely change; caching avoids a ~200-600 ms Stripe round-trip on
|
||||
every GET /credits/subscription page load and reduces quota consumption.
|
||||
"""
|
||||
try:
|
||||
price = await run_in_threadpool(stripe.Price.retrieve, price_id)
|
||||
return price.unit_amount or 0
|
||||
except stripe.StripeError:
|
||||
logger.warning(
|
||||
"Failed to retrieve Stripe price %s — returning None (not cached)",
|
||||
price_id,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@v1_router.get(
|
||||
@@ -722,21 +789,26 @@ async def get_subscription_status(
|
||||
*[get_subscription_price_id(t) for t in paid_tiers]
|
||||
)
|
||||
|
||||
tier_costs: dict[str, int] = {"FREE": 0, "ENTERPRISE": 0}
|
||||
for t, price_id in zip(paid_tiers, price_ids):
|
||||
cost = 0
|
||||
if price_id:
|
||||
try:
|
||||
price = await run_in_threadpool(stripe.Price.retrieve, price_id)
|
||||
cost = price.unit_amount or 0
|
||||
except stripe.StripeError:
|
||||
pass
|
||||
tier_costs: dict[str, int] = {
|
||||
SubscriptionTier.FREE.value: 0,
|
||||
SubscriptionTier.ENTERPRISE.value: 0,
|
||||
}
|
||||
|
||||
async def _cost(pid: str | None) -> int:
|
||||
return (await _get_stripe_price_amount(pid) or 0) if pid else 0
|
||||
|
||||
costs = await asyncio.gather(*[_cost(pid) for pid in price_ids])
|
||||
for t, cost in zip(paid_tiers, costs):
|
||||
tier_costs[t.value] = cost
|
||||
|
||||
current_monthly_cost = tier_costs.get(tier.value, 0)
|
||||
proration_credit = await get_proration_credit_cents(user_id, current_monthly_cost)
|
||||
|
||||
return SubscriptionStatusResponse(
|
||||
tier=tier.value,
|
||||
monthly_cost=tier_costs.get(tier.value, 0),
|
||||
monthly_cost=current_monthly_cost,
|
||||
tier_costs=tier_costs,
|
||||
proration_credit_cents=proration_credit,
|
||||
)
|
||||
|
||||
|
||||
@@ -766,24 +838,125 @@ async def update_subscription_tier(
|
||||
Flag.ENABLE_PLATFORM_PAYMENT, user_id, default=False
|
||||
)
|
||||
|
||||
# Downgrade to FREE: cancel active Stripe subscription, then update the DB tier.
|
||||
# Downgrade to FREE: schedule Stripe cancellation at period end so the user
|
||||
# keeps their tier for the time they already paid for. The DB tier is NOT
|
||||
# updated here when a subscription exists — the customer.subscription.deleted
|
||||
# webhook fires at period end and downgrades to FREE then.
|
||||
# Exception: if the user has no active Stripe subscription (e.g. admin-granted
|
||||
# tier), cancel_stripe_subscription returns False and we update the DB tier
|
||||
# immediately since no webhook will ever fire.
|
||||
# When payment is disabled entirely, update the DB tier directly.
|
||||
if tier == SubscriptionTier.FREE:
|
||||
if payment_enabled:
|
||||
await cancel_stripe_subscription(user_id)
|
||||
try:
|
||||
had_subscription = await cancel_stripe_subscription(user_id)
|
||||
except stripe.StripeError as e:
|
||||
# Log full Stripe error server-side but return a generic message
|
||||
# to the client — raw Stripe errors can leak customer/sub IDs and
|
||||
# infrastructure config details.
|
||||
logger.exception(
|
||||
"Stripe error cancelling subscription for user %s: %s",
|
||||
user_id,
|
||||
e,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=(
|
||||
"Unable to cancel your subscription right now. "
|
||||
"Please try again or contact support."
|
||||
),
|
||||
)
|
||||
if not had_subscription:
|
||||
# No active Stripe subscription found — the user was on an
|
||||
# admin-granted tier. Update DB immediately since the
|
||||
# subscription.deleted webhook will never fire.
|
||||
await set_subscription_tier(user_id, tier)
|
||||
return SubscriptionCheckoutResponse(url="")
|
||||
await set_subscription_tier(user_id, tier)
|
||||
return SubscriptionCheckoutResponse(url="")
|
||||
|
||||
# Beta users (payment not enabled) → update tier directly without Stripe.
|
||||
# Paid tier changes require payment to be enabled — block self-service upgrades
|
||||
# when the flag is off. Admins use the /api/admin/ routes to set tiers directly.
|
||||
if not payment_enabled:
|
||||
await set_subscription_tier(user_id, tier)
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Subscription not available for tier {tier}",
|
||||
)
|
||||
|
||||
# No-op short-circuit: if the user is already on the requested paid tier,
|
||||
# do NOT create a new Checkout Session. Without this guard, a duplicate
|
||||
# request (double-click, retried POST, stale page) creates a second
|
||||
# subscription for the same price; the user would be charged for both
|
||||
# until `_cleanup_stale_subscriptions` runs from the resulting webhook —
|
||||
# which only fires after the second charge has cleared.
|
||||
if (user.subscription_tier or SubscriptionTier.FREE) == tier:
|
||||
return SubscriptionCheckoutResponse(url="")
|
||||
|
||||
# Paid upgrade → create Stripe Checkout Session.
|
||||
# Paid→paid tier change: if the user already has a Stripe subscription,
|
||||
# modify it in-place with proration instead of creating a new Checkout
|
||||
# Session. This preserves remaining paid time and avoids double-charging.
|
||||
# The customer.subscription.updated webhook fires and updates the DB tier.
|
||||
current_tier = user.subscription_tier or SubscriptionTier.FREE
|
||||
if current_tier in (SubscriptionTier.PRO, SubscriptionTier.BUSINESS):
|
||||
try:
|
||||
modified = await modify_stripe_subscription_for_tier(user_id, tier)
|
||||
if modified:
|
||||
return SubscriptionCheckoutResponse(url="")
|
||||
# modify_stripe_subscription_for_tier returns False when no active
|
||||
# Stripe subscription exists — i.e. the user has an admin-granted
|
||||
# paid tier with no Stripe record. In that case, update the DB
|
||||
# tier directly (same as the FREE-downgrade path for admin-granted
|
||||
# users) rather than sending them through a new Checkout Session.
|
||||
await set_subscription_tier(user_id, tier)
|
||||
return SubscriptionCheckoutResponse(url="")
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail=str(e))
|
||||
except stripe.StripeError as e:
|
||||
logger.exception(
|
||||
"Stripe error modifying subscription for user %s: %s", user_id, e
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=(
|
||||
"Unable to update your subscription right now. "
|
||||
"Please try again or contact support."
|
||||
),
|
||||
)
|
||||
|
||||
# Paid upgrade from FREE → create Stripe Checkout Session.
|
||||
if not request.success_url or not request.cancel_url:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="success_url and cancel_url are required for paid tier upgrades",
|
||||
)
|
||||
# Open-redirect protection: both URLs must point to the configured frontend
|
||||
# origin, otherwise an attacker could use our Stripe integration as a
|
||||
# redirector to arbitrary phishing sites.
|
||||
#
|
||||
# Fail early with a clear 503 if the server is misconfigured (neither
|
||||
# frontend_base_url nor platform_base_url set), so operators get an
|
||||
# actionable error instead of the misleading "must match the platform
|
||||
# frontend origin" 422 that _validate_checkout_redirect_url would otherwise
|
||||
# produce when `allowed` is empty.
|
||||
if not (settings.config.frontend_base_url or settings.config.platform_base_url):
|
||||
logger.error(
|
||||
"update_subscription_tier: neither frontend_base_url nor "
|
||||
"platform_base_url is configured; cannot validate checkout redirect URLs"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=(
|
||||
"Payment redirect URLs cannot be validated: "
|
||||
"frontend_base_url or platform_base_url must be set on the server."
|
||||
),
|
||||
)
|
||||
if not _validate_checkout_redirect_url(
|
||||
request.success_url
|
||||
) or not _validate_checkout_redirect_url(request.cancel_url):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="success_url and cancel_url must match the platform frontend origin",
|
||||
)
|
||||
try:
|
||||
url = await create_subscription_checkout(
|
||||
user_id=user_id,
|
||||
@@ -791,8 +964,19 @@ async def update_subscription_tier(
|
||||
success_url=request.success_url,
|
||||
cancel_url=request.cancel_url,
|
||||
)
|
||||
except (ValueError, stripe.StripeError) as e:
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail=str(e))
|
||||
except stripe.StripeError as e:
|
||||
logger.exception(
|
||||
"Stripe error creating checkout session for user %s: %s", user_id, e
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=(
|
||||
"Unable to start checkout right now. "
|
||||
"Please try again or contact support."
|
||||
),
|
||||
)
|
||||
|
||||
return SubscriptionCheckoutResponse(url=url)
|
||||
|
||||
@@ -801,44 +985,78 @@ async def update_subscription_tier(
|
||||
path="/credits/stripe_webhook", summary="Handle Stripe webhooks", tags=["credits"]
|
||||
)
|
||||
async def stripe_webhook(request: Request):
|
||||
webhook_secret = settings.secrets.stripe_webhook_secret
|
||||
if not webhook_secret:
|
||||
# Guard: an empty secret allows HMAC forgery (attacker can compute a valid
|
||||
# signature over the same empty key). Reject all webhook calls when unconfigured.
|
||||
logger.error(
|
||||
"stripe_webhook: STRIPE_WEBHOOK_SECRET is not configured — "
|
||||
"rejecting request to prevent signature bypass"
|
||||
)
|
||||
raise HTTPException(status_code=503, detail="Webhook not configured")
|
||||
|
||||
# Get the raw request body
|
||||
payload = await request.body()
|
||||
# Get the signature header
|
||||
sig_header = request.headers.get("stripe-signature")
|
||||
|
||||
try:
|
||||
event = stripe.Webhook.construct_event(
|
||||
payload, sig_header, settings.secrets.stripe_webhook_secret
|
||||
)
|
||||
except ValueError as e:
|
||||
event = stripe.Webhook.construct_event(payload, sig_header, webhook_secret)
|
||||
except ValueError:
|
||||
# Invalid payload
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Invalid payload: {str(e) or type(e).__name__}"
|
||||
)
|
||||
except stripe.SignatureVerificationError as e:
|
||||
raise HTTPException(status_code=400, detail="Invalid payload")
|
||||
except stripe.SignatureVerificationError:
|
||||
# Invalid signature
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Invalid signature: {str(e) or type(e).__name__}"
|
||||
raise HTTPException(status_code=400, detail="Invalid signature")
|
||||
|
||||
# Defensive payload extraction. A malformed payload (missing/non-dict
|
||||
# `data.object`, missing `id`) would otherwise raise KeyError/TypeError
|
||||
# AFTER signature verification — which Stripe interprets as a delivery
|
||||
# failure and retries forever, while spamming Sentry with no useful info.
|
||||
# Acknowledge with 200 and a warning so Stripe stops retrying.
|
||||
event_type = event.get("type", "")
|
||||
event_data = event.get("data") or {}
|
||||
data_object = event_data.get("object") if isinstance(event_data, dict) else None
|
||||
if not isinstance(data_object, dict):
|
||||
logger.warning(
|
||||
"stripe_webhook: %s missing or non-dict data.object; ignoring",
|
||||
event_type,
|
||||
)
|
||||
return Response(status_code=200)
|
||||
|
||||
if (
|
||||
event["type"] == "checkout.session.completed"
|
||||
or event["type"] == "checkout.session.async_payment_succeeded"
|
||||
if event_type in (
|
||||
"checkout.session.completed",
|
||||
"checkout.session.async_payment_succeeded",
|
||||
):
|
||||
await UserCredit().fulfill_checkout(session_id=event["data"]["object"]["id"])
|
||||
session_id = data_object.get("id")
|
||||
if not session_id:
|
||||
logger.warning(
|
||||
"stripe_webhook: %s missing data.object.id; ignoring", event_type
|
||||
)
|
||||
return Response(status_code=200)
|
||||
await UserCredit().fulfill_checkout(session_id=session_id)
|
||||
|
||||
if event["type"] in (
|
||||
if event_type in (
|
||||
"customer.subscription.created",
|
||||
"customer.subscription.updated",
|
||||
"customer.subscription.deleted",
|
||||
):
|
||||
await sync_subscription_from_stripe(event["data"]["object"])
|
||||
await sync_subscription_from_stripe(data_object)
|
||||
|
||||
if event["type"] == "charge.dispute.created":
|
||||
await UserCredit().handle_dispute(event["data"]["object"])
|
||||
if event_type == "invoice.payment_failed":
|
||||
await handle_subscription_payment_failure(data_object)
|
||||
|
||||
if event["type"] == "refund.created" or event["type"] == "charge.dispute.closed":
|
||||
await UserCredit().deduct_credits(event["data"]["object"])
|
||||
# `handle_dispute` and `deduct_credits` expect Stripe SDK typed objects
|
||||
# (Dispute/Refund). The Stripe webhook payload's `data.object` is a
|
||||
# StripeObject (a dict subclass) carrying that runtime shape, so we cast
|
||||
# to satisfy the type checker without changing runtime behaviour.
|
||||
if event_type == "charge.dispute.created":
|
||||
await UserCredit().handle_dispute(cast(stripe.Dispute, data_object))
|
||||
|
||||
if event_type == "refund.created" or event_type == "charge.dispute.closed":
|
||||
await UserCredit().deduct_credits(
|
||||
cast("stripe.Refund | stripe.Dispute", data_object)
|
||||
)
|
||||
|
||||
return Response(status_code=200)
|
||||
|
||||
|
||||
@@ -106,7 +106,6 @@ class LlmModelMeta(EnumMeta):
|
||||
|
||||
|
||||
class LlmModel(str, Enum, metaclass=LlmModelMeta):
|
||||
|
||||
@classmethod
|
||||
def _missing_(cls, value: object) -> "LlmModel | None":
|
||||
"""Handle provider-prefixed model names like 'anthropic/claude-sonnet-4-6'."""
|
||||
@@ -203,6 +202,8 @@ class LlmModel(str, Enum, metaclass=LlmModelMeta):
|
||||
GROK_4 = "x-ai/grok-4"
|
||||
GROK_4_FAST = "x-ai/grok-4-fast"
|
||||
GROK_4_1_FAST = "x-ai/grok-4.1-fast"
|
||||
GROK_4_20 = "x-ai/grok-4.20"
|
||||
GROK_4_20_MULTI_AGENT = "x-ai/grok-4.20-multi-agent"
|
||||
GROK_CODE_FAST_1 = "x-ai/grok-code-fast-1"
|
||||
KIMI_K2 = "moonshotai/kimi-k2"
|
||||
QWEN3_235B_A22B_THINKING = "qwen/qwen3-235b-a22b-thinking-2507"
|
||||
@@ -627,6 +628,18 @@ MODEL_METADATA = {
|
||||
LlmModel.GROK_4_1_FAST: ModelMetadata(
|
||||
"open_router", 2000000, 30000, "Grok 4.1 Fast", "OpenRouter", "xAI", 1
|
||||
),
|
||||
LlmModel.GROK_4_20: ModelMetadata(
|
||||
"open_router", 2000000, 100000, "Grok 4.20", "OpenRouter", "xAI", 3
|
||||
),
|
||||
LlmModel.GROK_4_20_MULTI_AGENT: ModelMetadata(
|
||||
"open_router",
|
||||
2000000,
|
||||
100000,
|
||||
"Grok 4.20 Multi-Agent",
|
||||
"OpenRouter",
|
||||
"xAI",
|
||||
3,
|
||||
),
|
||||
LlmModel.GROK_CODE_FAST_1: ModelMetadata(
|
||||
"open_router", 256000, 10000, "Grok Code Fast 1", "OpenRouter", "xAI", 1
|
||||
),
|
||||
@@ -987,7 +1000,6 @@ async def llm_call(
|
||||
reasoning=reasoning,
|
||||
)
|
||||
elif provider == "anthropic":
|
||||
|
||||
an_tools = convert_openai_tool_fmt_to_anthropic(tools)
|
||||
# Cache tool definitions alongside the system prompt.
|
||||
# Placing cache_control on the last tool caches all tool schemas as a
|
||||
|
||||
@@ -125,7 +125,12 @@ config = ChatConfig()
|
||||
|
||||
|
||||
class _SystemPromptPreset(SystemPromptPreset, total=False):
|
||||
"""Extends SystemPromptPreset with fields added in claude-agent-sdk 0.1.59."""
|
||||
"""Extends :class:`SystemPromptPreset` with ``exclude_dynamic_sections``.
|
||||
|
||||
The field was added to the upstream TypedDict in claude-agent-sdk 0.1.59.
|
||||
Until the package is pinned to that version we declare it locally so Pyright
|
||||
accepts the kwarg without a ``# type: ignore`` comment.
|
||||
"""
|
||||
|
||||
exclude_dynamic_sections: NotRequired[bool]
|
||||
|
||||
@@ -893,7 +898,7 @@ def _write_cli_session_to_disk(
|
||||
return False
|
||||
|
||||
|
||||
def _read_cli_session_from_disk(
|
||||
def read_cli_session_from_disk(
|
||||
sdk_cwd: str,
|
||||
session_id: str,
|
||||
log_prefix: str,
|
||||
@@ -973,7 +978,7 @@ def _read_cli_session_from_disk(
|
||||
return stripped_bytes
|
||||
|
||||
|
||||
def _process_cli_restore(
|
||||
def process_cli_restore(
|
||||
cli_restore: TranscriptDownload,
|
||||
sdk_cwd: str,
|
||||
session_id: str,
|
||||
@@ -2489,9 +2494,7 @@ async def _restore_cli_session_for_turn(
|
||||
# session path, so we validate BEFORE any disk write.
|
||||
stripped = ""
|
||||
if cli_restore is not None and sdk_cwd:
|
||||
stripped, ok = _process_cli_restore(
|
||||
cli_restore, sdk_cwd, session_id, log_prefix
|
||||
)
|
||||
stripped, ok = process_cli_restore(cli_restore, sdk_cwd, session_id, log_prefix)
|
||||
if not ok:
|
||||
result.transcript_covers_prefix = False
|
||||
cli_restore = None
|
||||
@@ -3636,7 +3639,7 @@ async def stream_chat_completion_sdk(
|
||||
# this turn ran without --resume (restore failed or first T2+ on a new
|
||||
# pod), the T1 session file at the expected path may still be present
|
||||
# and should be re-uploaded so the next turn can resume from it.
|
||||
# _read_cli_session_from_disk returns None when the file is absent, so
|
||||
# read_cli_session_from_disk returns None when the file is absent, so
|
||||
# this is always safe.
|
||||
#
|
||||
# Intentionally NOT gated on skip_transcript_upload: that flag is set
|
||||
@@ -3665,7 +3668,7 @@ async def stream_chat_completion_sdk(
|
||||
try:
|
||||
# Read the CLI's native session file from disk (written by the CLI
|
||||
# after the turn), then upload the bytes to GCS.
|
||||
_cli_content = _read_cli_session_from_disk(
|
||||
_cli_content = read_cli_session_from_disk(
|
||||
sdk_cwd, session_id, log_prefix
|
||||
)
|
||||
if _cli_content:
|
||||
|
||||
@@ -1371,7 +1371,7 @@ class TestStripStaleThinkingBlocks:
|
||||
|
||||
|
||||
class TestProcessCliRestore:
|
||||
"""``_process_cli_restore`` validates, strips, and writes CLI session to disk."""
|
||||
"""``process_cli_restore`` validates, strips, and writes CLI session to disk."""
|
||||
|
||||
def test_writes_stripped_bytes_not_raw(self, tmp_path):
|
||||
"""Stripped bytes (not raw bytes) must be written to disk for --resume."""
|
||||
@@ -1380,7 +1380,7 @@ class TestProcessCliRestore:
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from backend.copilot.sdk.service import _process_cli_restore
|
||||
from backend.copilot.sdk.service import process_cli_restore
|
||||
from backend.copilot.transcript import TranscriptDownload
|
||||
|
||||
session_id = "12345678-0000-0000-0000-abcdef000001"
|
||||
@@ -1406,7 +1406,7 @@ class TestProcessCliRestore:
|
||||
return_value=projects_base_dir,
|
||||
),
|
||||
):
|
||||
stripped_str, ok = _process_cli_restore(
|
||||
stripped_str, ok = process_cli_restore(
|
||||
restore, sdk_cwd, session_id, "[Test]"
|
||||
)
|
||||
|
||||
@@ -1433,7 +1433,7 @@ class TestProcessCliRestore:
|
||||
|
||||
def test_invalid_content_returns_false(self):
|
||||
"""Content that fails validation after strip returns (empty, False)."""
|
||||
from backend.copilot.sdk.service import _process_cli_restore
|
||||
from backend.copilot.sdk.service import process_cli_restore
|
||||
from backend.copilot.transcript import TranscriptDownload
|
||||
|
||||
# A single progress-only entry — stripped result will be empty/invalid
|
||||
@@ -1442,7 +1442,7 @@ class TestProcessCliRestore:
|
||||
content=raw_content.encode("utf-8"), message_count=1, mode="sdk"
|
||||
)
|
||||
|
||||
stripped_str, ok = _process_cli_restore(
|
||||
stripped_str, ok = process_cli_restore(
|
||||
restore,
|
||||
"/tmp/nonexistent-sdk-cwd",
|
||||
"12345678-0000-0000-0000-000000000099",
|
||||
@@ -1454,7 +1454,7 @@ class TestProcessCliRestore:
|
||||
|
||||
|
||||
class TestReadCliSessionFromDisk:
|
||||
"""``_read_cli_session_from_disk`` reads, strips, and optionally writes back the session."""
|
||||
"""``read_cli_session_from_disk`` reads, strips, and optionally writes back the session."""
|
||||
|
||||
def _build_session_file(self, tmp_path, session_id: str):
|
||||
"""Build the session file path inside tmp_path using the same encoding as cli_session_path."""
|
||||
@@ -1472,7 +1472,7 @@ class TestReadCliSessionFromDisk:
|
||||
"""Non-UTF-8 bytes trigger UnicodeDecodeError — returns raw bytes (upload-raw fallback)."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from backend.copilot.sdk.service import _read_cli_session_from_disk
|
||||
from backend.copilot.sdk.service import read_cli_session_from_disk
|
||||
|
||||
session_id = "12345678-0000-0000-0000-aabbccdd0001"
|
||||
projects_base_dir = str(tmp_path)
|
||||
@@ -1491,7 +1491,7 @@ class TestReadCliSessionFromDisk:
|
||||
return_value=projects_base_dir,
|
||||
),
|
||||
):
|
||||
result = _read_cli_session_from_disk(sdk_cwd, session_id, "[Test]")
|
||||
result = read_cli_session_from_disk(sdk_cwd, session_id, "[Test]")
|
||||
|
||||
# UnicodeDecodeError path returns the raw bytes (upload-raw fallback)
|
||||
assert result == b"\xff\xfe invalid utf-8\n"
|
||||
@@ -1500,7 +1500,7 @@ class TestReadCliSessionFromDisk:
|
||||
"""OSError on write-back returns stripped bytes for GCS upload (not raw)."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from backend.copilot.sdk.service import _read_cli_session_from_disk
|
||||
from backend.copilot.sdk.service import read_cli_session_from_disk
|
||||
|
||||
session_id = "12345678-0000-0000-0000-aabbccdd0002"
|
||||
projects_base_dir = str(tmp_path)
|
||||
@@ -1527,7 +1527,7 @@ class TestReadCliSessionFromDisk:
|
||||
return_value=projects_base_dir,
|
||||
),
|
||||
):
|
||||
result = _read_cli_session_from_disk(sdk_cwd, session_id, "[Test]")
|
||||
result = read_cli_session_from_disk(sdk_cwd, session_id, "[Test]")
|
||||
finally:
|
||||
session_file.chmod(0o644)
|
||||
|
||||
|
||||
@@ -880,31 +880,12 @@ class TestUploadCliSession:
|
||||
assert meta_content["mode"] == "baseline"
|
||||
assert meta_content["message_count"] == 4
|
||||
|
||||
def test_strips_session_before_upload_and_writes_back(self, tmp_path):
|
||||
"""Strippable entries (progress, thinking blocks) are removed before upload.
|
||||
|
||||
The stripped content is written back to disk (so same-pod turns benefit)
|
||||
and the smaller bytes are uploaded to GCS.
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from .transcript import _sanitize_id, upload_cli_session
|
||||
|
||||
projects_base = str(tmp_path)
|
||||
session_id = "12345678-0000-0000-0000-000000000010"
|
||||
sdk_cwd = str(tmp_path)
|
||||
|
||||
encoded_cwd = re.sub(r"[^a-zA-Z0-9]", "-", os.path.realpath(sdk_cwd))
|
||||
session_dir = tmp_path / encoded_cwd
|
||||
session_dir.mkdir(parents=True, exist_ok=True)
|
||||
session_file = session_dir / f"{_sanitize_id(session_id)}.jsonl"
|
||||
|
||||
# A CLI session with a progress entry (strippable) and a real assistant message.
|
||||
def test_strips_session_before_upload_and_writes_back(self):
|
||||
"""strip_for_upload removes progress entries and returns smaller content."""
|
||||
import json
|
||||
|
||||
from .transcript import strip_for_upload
|
||||
|
||||
progress_entry = {
|
||||
"type": "progress",
|
||||
"uuid": "p1",
|
||||
@@ -930,64 +911,22 @@ class TestUploadCliSession:
|
||||
+ json.dumps(asst_entry)
|
||||
+ "\n"
|
||||
)
|
||||
raw_bytes = raw_content.encode("utf-8")
|
||||
session_file.write_bytes(raw_bytes)
|
||||
|
||||
mock_storage = AsyncMock()
|
||||
stripped = strip_for_upload(raw_content)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"backend.copilot.transcript._projects_base",
|
||||
return_value=projects_base,
|
||||
),
|
||||
patch(
|
||||
"backend.copilot.transcript.get_workspace_storage",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_storage,
|
||||
),
|
||||
):
|
||||
asyncio.run(
|
||||
upload_cli_session(
|
||||
user_id="user-1",
|
||||
session_id=session_id,
|
||||
sdk_cwd=sdk_cwd,
|
||||
)
|
||||
)
|
||||
|
||||
# Upload should have been called with stripped bytes (no progress entry).
|
||||
mock_storage.store.assert_called_once()
|
||||
stored_content: bytes = mock_storage.store.call_args.kwargs["content"]
|
||||
stored_lines = stored_content.decode("utf-8").strip().split("\n")
|
||||
stored_lines = stripped.strip().split("\n")
|
||||
stored_types = [json.loads(line).get("type") for line in stored_lines]
|
||||
assert "progress" not in stored_types
|
||||
assert "user" in stored_types
|
||||
assert "assistant" in stored_types
|
||||
# Stripped bytes should be smaller than raw.
|
||||
assert len(stored_content) < len(raw_bytes)
|
||||
# File on disk should also be the stripped version.
|
||||
disk_content = session_file.read_bytes()
|
||||
assert disk_content == stored_content
|
||||
assert len(stripped.encode()) < len(raw_content.encode())
|
||||
|
||||
def test_strips_stale_thinking_blocks_before_upload(self, tmp_path):
|
||||
"""Thinking blocks in non-last assistant turns are stripped to reduce size."""
|
||||
import asyncio
|
||||
def test_strips_stale_thinking_blocks_before_upload(self):
|
||||
"""strip_for_upload removes thinking blocks from non-last assistant turns."""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from .transcript import _sanitize_id, upload_cli_session
|
||||
from .transcript import strip_for_upload
|
||||
|
||||
projects_base = str(tmp_path)
|
||||
session_id = "12345678-0000-0000-0000-000000000011"
|
||||
sdk_cwd = str(tmp_path)
|
||||
|
||||
encoded_cwd = re.sub(r"[^a-zA-Z0-9]", "-", os.path.realpath(sdk_cwd))
|
||||
session_dir = tmp_path / encoded_cwd
|
||||
session_dir.mkdir(parents=True, exist_ok=True)
|
||||
session_file = session_dir / f"{_sanitize_id(session_id)}.jsonl"
|
||||
|
||||
# Two turns: first assistant has thinking block (stale), second doesn't.
|
||||
u1 = {
|
||||
"type": "user",
|
||||
"uuid": "u1",
|
||||
@@ -1032,32 +971,10 @@ class TestUploadCliSession:
|
||||
+ json.dumps(a2_no_thinking)
|
||||
+ "\n"
|
||||
)
|
||||
raw_bytes = raw_content.encode("utf-8")
|
||||
session_file.write_bytes(raw_bytes)
|
||||
|
||||
mock_storage = AsyncMock()
|
||||
stripped = strip_for_upload(raw_content)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"backend.copilot.transcript._projects_base",
|
||||
return_value=projects_base,
|
||||
),
|
||||
patch(
|
||||
"backend.copilot.transcript.get_workspace_storage",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_storage,
|
||||
),
|
||||
):
|
||||
asyncio.run(
|
||||
upload_cli_session(
|
||||
user_id="user-1",
|
||||
session_id=session_id,
|
||||
sdk_cwd=sdk_cwd,
|
||||
)
|
||||
)
|
||||
|
||||
stored_content: bytes = mock_storage.store.call_args.kwargs["content"]
|
||||
stored_lines = stored_content.decode("utf-8").strip().split("\n")
|
||||
stored_lines = stripped.strip().split("\n")
|
||||
|
||||
# a1 should have its thinking block stripped (it's not the last assistant turn).
|
||||
a1_stored = json.loads(stored_lines[1])
|
||||
@@ -1073,9 +990,6 @@ class TestUploadCliSession:
|
||||
a2_stored = json.loads(stored_lines[3])
|
||||
assert a2_stored["message"]["content"] == [{"type": "text", "text": "answer2"}]
|
||||
|
||||
# Stripped bytes smaller than raw.
|
||||
assert len(stored_content) < len(raw_bytes)
|
||||
|
||||
|
||||
class TestRestoreCliSession:
|
||||
def test_returns_none_when_file_not_found_in_storage(self):
|
||||
|
||||
@@ -143,6 +143,8 @@ MODEL_COST: dict[LlmModel, int] = {
|
||||
LlmModel.GROK_4: 9,
|
||||
LlmModel.GROK_4_FAST: 1,
|
||||
LlmModel.GROK_4_1_FAST: 1,
|
||||
LlmModel.GROK_4_20: 5,
|
||||
LlmModel.GROK_4_20_MULTI_AGENT: 5,
|
||||
LlmModel.GROK_CODE_FAST_1: 1,
|
||||
LlmModel.KIMI_K2: 1,
|
||||
LlmModel.QWEN3_235B_A22B_THINKING: 1,
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
import stripe
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from prisma.enums import (
|
||||
CreditRefundRequestStatus,
|
||||
CreditTransactionType,
|
||||
@@ -31,6 +34,7 @@ from backend.data.model import (
|
||||
from backend.data.notifications import NotificationEventModel, RefundRequestData
|
||||
from backend.data.user import get_user_by_id, get_user_email_by_id
|
||||
from backend.notifications.notifications import queue_notification_async
|
||||
from backend.util.cache import cached
|
||||
from backend.util.exceptions import InsufficientBalanceError
|
||||
from backend.util.feature_flag import Flag, get_feature_flag_value, is_feature_enabled
|
||||
from backend.util.json import SafeJson, dumps
|
||||
@@ -432,7 +436,7 @@ class UserCreditBase(ABC):
|
||||
current_balance, _ = await self._get_credits(user_id)
|
||||
if current_balance >= ceiling_balance:
|
||||
raise ValueError(
|
||||
f"You already have enough balance of ${current_balance/100}, top-up is not required when you already have at least ${ceiling_balance/100}"
|
||||
f"You already have enough balance of ${current_balance / 100}, top-up is not required when you already have at least ${ceiling_balance / 100}"
|
||||
)
|
||||
|
||||
# Single unified atomic operation for all transaction types using UserBalance
|
||||
@@ -571,7 +575,7 @@ class UserCreditBase(ABC):
|
||||
if amount < 0 and fail_insufficient_credits:
|
||||
current_balance, _ = await self._get_credits(user_id)
|
||||
raise InsufficientBalanceError(
|
||||
message=f"Insufficient balance of ${current_balance/100}, where this will cost ${abs(amount)/100}",
|
||||
message=f"Insufficient balance of ${current_balance / 100}, where this will cost ${abs(amount) / 100}",
|
||||
user_id=user_id,
|
||||
balance=current_balance,
|
||||
amount=amount,
|
||||
@@ -582,7 +586,6 @@ class UserCreditBase(ABC):
|
||||
|
||||
|
||||
class UserCredit(UserCreditBase):
|
||||
|
||||
async def _send_refund_notification(
|
||||
self,
|
||||
notification_request: RefundRequestData,
|
||||
@@ -734,7 +737,7 @@ class UserCredit(UserCreditBase):
|
||||
)
|
||||
if request.amount <= 0 or request.amount > transaction.amount:
|
||||
raise AssertionError(
|
||||
f"Invalid amount to deduct ${request.amount/100} from ${transaction.amount/100} top-up"
|
||||
f"Invalid amount to deduct ${request.amount / 100} from ${transaction.amount / 100} top-up"
|
||||
)
|
||||
|
||||
balance, _ = await self._add_transaction(
|
||||
@@ -788,12 +791,12 @@ class UserCredit(UserCreditBase):
|
||||
|
||||
# If the user has enough balance, just let them win the dispute.
|
||||
if balance - amount >= settings.config.refund_credit_tolerance_threshold:
|
||||
logger.warning(f"Accepting dispute from {user_id} for ${amount/100}")
|
||||
logger.warning(f"Accepting dispute from {user_id} for ${amount / 100}")
|
||||
dispute.close()
|
||||
return
|
||||
|
||||
logger.warning(
|
||||
f"Adding extra info for dispute from {user_id} for ${amount/100}"
|
||||
f"Adding extra info for dispute from {user_id} for ${amount / 100}"
|
||||
)
|
||||
# Retrieve recent transaction history to support our evidence.
|
||||
# This provides a concise timeline that shows service usage and proper credit application.
|
||||
@@ -1237,14 +1240,23 @@ async def get_stripe_customer_id(user_id: str) -> str:
|
||||
if user.stripe_customer_id:
|
||||
return user.stripe_customer_id
|
||||
|
||||
customer = stripe.Customer.create(
|
||||
# Race protection: two concurrent calls (e.g. user double-clicks "Upgrade",
|
||||
# or any retried request) would each pass the check above and create their
|
||||
# own Stripe Customer, leaving an orphaned billable customer in Stripe.
|
||||
# Pass an idempotency_key so Stripe collapses concurrent + retried calls
|
||||
# into the same Customer object server-side. The 24h Stripe idempotency
|
||||
# window comfortably covers any realistic in-flight retry scenario.
|
||||
customer = await run_in_threadpool(
|
||||
stripe.Customer.create,
|
||||
name=user.name or "",
|
||||
email=user.email,
|
||||
metadata={"user_id": user_id},
|
||||
idempotency_key=f"customer-create-{user_id}",
|
||||
)
|
||||
await User.prisma().update(
|
||||
where={"id": user_id}, data={"stripeCustomerId": customer.id}
|
||||
)
|
||||
get_user_by_id.cache_delete(user_id)
|
||||
return customer.id
|
||||
|
||||
|
||||
@@ -1263,23 +1275,203 @@ async def set_subscription_tier(user_id: str, tier: SubscriptionTier) -> None:
|
||||
data={"subscriptionTier": tier},
|
||||
)
|
||||
get_user_by_id.cache_delete(user_id)
|
||||
# Also invalidate the rate-limit tier cache so CoPilot picks up the new
|
||||
# tier immediately rather than waiting up to 5 minutes for the TTL to expire.
|
||||
from backend.copilot.rate_limit import get_user_tier # local import avoids circular
|
||||
|
||||
get_user_tier.cache_delete(user_id) # type: ignore[attr-defined]
|
||||
|
||||
|
||||
async def cancel_stripe_subscription(user_id: str) -> None:
|
||||
"""Cancel all active Stripe subscriptions for a user (called on downgrade to FREE)."""
|
||||
customer_id = await get_stripe_customer_id(user_id)
|
||||
subscriptions = stripe.Subscription.list(
|
||||
customer=customer_id, status="active", limit=10
|
||||
)
|
||||
for sub in subscriptions.auto_paging_iter():
|
||||
try:
|
||||
stripe.Subscription.cancel(sub["id"])
|
||||
except stripe.StripeError:
|
||||
logger.warning(
|
||||
"cancel_stripe_subscription: failed to cancel sub %s for user %s",
|
||||
sub["id"],
|
||||
user_id,
|
||||
async def _cancel_customer_subscriptions(
|
||||
customer_id: str,
|
||||
exclude_sub_id: str | None = None,
|
||||
at_period_end: bool = False,
|
||||
) -> int:
|
||||
"""Cancel all billable Stripe subscriptions for a customer, optionally excluding one.
|
||||
|
||||
Cancels both ``active`` and ``trialing`` subscriptions, since trialing subs will
|
||||
start billing once the trial ends and must be cleaned up on downgrade/upgrade to
|
||||
avoid double-charging or charging users who intended to cancel.
|
||||
|
||||
When ``at_period_end=True``, schedules cancellation at the end of the current
|
||||
billing period instead of cancelling immediately — the user keeps their tier
|
||||
until the period ends, then ``customer.subscription.deleted`` fires and the
|
||||
webhook downgrades them to FREE.
|
||||
|
||||
Wraps every synchronous Stripe SDK call with run_in_threadpool so the async event
|
||||
loop is never blocked. Raises stripe.StripeError on list/cancel failure so callers
|
||||
that need strict consistency can react; cleanup callers can catch and log instead.
|
||||
|
||||
Returns the number of subscriptions cancelled/scheduled for cancellation.
|
||||
"""
|
||||
# Query active and trialing separately; Stripe's list API accepts a single status
|
||||
# filter at a time (no OR), and we explicitly want to skip canceled/incomplete/
|
||||
# past_due subs rather than filter them out client-side via status="all".
|
||||
seen_ids: set[str] = set()
|
||||
for status in ("active", "trialing"):
|
||||
subscriptions = await run_in_threadpool(
|
||||
stripe.Subscription.list, customer=customer_id, status=status, limit=10
|
||||
)
|
||||
# Iterate only the first page (up to 10); avoid auto_paging_iter which would
|
||||
# trigger additional sync HTTP calls inside the event loop.
|
||||
if subscriptions.has_more:
|
||||
logger.error(
|
||||
"_cancel_customer_subscriptions: customer %s has more than 10 %s"
|
||||
" subscriptions — only the first page was processed; remaining"
|
||||
" subscriptions were NOT cancelled",
|
||||
customer_id,
|
||||
status,
|
||||
)
|
||||
for sub in subscriptions.data:
|
||||
sub_id = sub["id"]
|
||||
if exclude_sub_id and sub_id == exclude_sub_id:
|
||||
continue
|
||||
if sub_id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(sub_id)
|
||||
if at_period_end:
|
||||
await run_in_threadpool(
|
||||
stripe.Subscription.modify, sub_id, cancel_at_period_end=True
|
||||
)
|
||||
else:
|
||||
await run_in_threadpool(stripe.Subscription.cancel, sub_id)
|
||||
return len(seen_ids)
|
||||
|
||||
|
||||
async def cancel_stripe_subscription(user_id: str) -> bool:
|
||||
"""Schedule cancellation of all active/trialing Stripe subscriptions at period end.
|
||||
|
||||
The subscription stays active until the end of the billing period so the user
|
||||
keeps their tier for the time they already paid for. The ``customer.subscription.deleted``
|
||||
webhook fires at period end and downgrades the DB tier to FREE.
|
||||
|
||||
Returns True if at least one subscription was found and scheduled for cancellation,
|
||||
False if the customer had no active/trialing subscriptions (e.g., admin-granted tier
|
||||
with no associated Stripe subscription). When False, the caller should update the
|
||||
DB tier directly since no webhook will fire to do it.
|
||||
|
||||
Raises stripe.StripeError if any modification fails, so the caller can avoid
|
||||
updating the DB tier when Stripe is inconsistent.
|
||||
"""
|
||||
# Guard: only proceed if the user already has a Stripe customer ID. Calling
|
||||
# get_stripe_customer_id for a user who has never had a paid subscription would
|
||||
# create an orphaned, potentially-billable Stripe Customer object — we avoid that
|
||||
# by returning False early so the caller can downgrade the DB tier directly.
|
||||
user = await get_user_by_id(user_id)
|
||||
if not user.stripe_customer_id:
|
||||
return False
|
||||
|
||||
customer_id = user.stripe_customer_id
|
||||
try:
|
||||
cancelled_count = await _cancel_customer_subscriptions(
|
||||
customer_id, at_period_end=True
|
||||
)
|
||||
return cancelled_count > 0
|
||||
except stripe.StripeError:
|
||||
logger.warning(
|
||||
"cancel_stripe_subscription: Stripe error while cancelling subs for user %s",
|
||||
user_id,
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
async def get_proration_credit_cents(user_id: str, monthly_cost_cents: int) -> int:
|
||||
"""Return the prorated credit (in cents) the user would receive if they upgraded now.
|
||||
|
||||
Fetches the user's active Stripe subscription to determine how many seconds
|
||||
remain in the current billing period, then calculates the unused portion of
|
||||
the monthly cost. Returns 0 for FREE/ENTERPRISE users or when no active sub
|
||||
is found.
|
||||
"""
|
||||
if monthly_cost_cents <= 0:
|
||||
return 0
|
||||
# Guard: only query Stripe if the user already has a customer ID. Admin-granted
|
||||
# paid tiers have no Stripe record; calling get_stripe_customer_id would create an
|
||||
# orphaned customer on every billing-page load for those users.
|
||||
user = await get_user_by_id(user_id)
|
||||
if not user.stripe_customer_id:
|
||||
return 0
|
||||
try:
|
||||
customer_id = user.stripe_customer_id
|
||||
subscriptions = await run_in_threadpool(
|
||||
stripe.Subscription.list, customer=customer_id, status="active", limit=1
|
||||
)
|
||||
if not subscriptions.data:
|
||||
return 0
|
||||
sub = subscriptions.data[0]
|
||||
period_start: int = sub["current_period_start"]
|
||||
period_end: int = sub["current_period_end"]
|
||||
now = int(time.time())
|
||||
total_seconds = period_end - period_start
|
||||
remaining_seconds = max(period_end - now, 0)
|
||||
if total_seconds <= 0:
|
||||
return 0
|
||||
return int(monthly_cost_cents * remaining_seconds / total_seconds)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"get_proration_credit_cents: failed to compute proration for user %s",
|
||||
user_id,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
async def modify_stripe_subscription_for_tier(
|
||||
user_id: str, tier: SubscriptionTier
|
||||
) -> bool:
|
||||
"""Modify an existing Stripe subscription to a new paid tier using proration.
|
||||
|
||||
For paid→paid tier changes (e.g. PRO↔BUSINESS), modifying the existing
|
||||
subscription is preferable to cancelling + creating a new one via Checkout:
|
||||
Stripe handles proration automatically, crediting unused time on the old plan
|
||||
and charging the pro-rated amount for the new plan in the same billing cycle.
|
||||
|
||||
Returns:
|
||||
True — a subscription was found and modified successfully.
|
||||
False — no active/trialing subscription exists (e.g. admin-granted tier or
|
||||
first-time paid signup); caller should fall back to Checkout.
|
||||
|
||||
Raises stripe.StripeError on API failures so callers can propagate a 502.
|
||||
Raises ValueError when no Stripe price ID is configured for the tier.
|
||||
"""
|
||||
price_id = await get_subscription_price_id(tier)
|
||||
if not price_id:
|
||||
raise ValueError(f"No Stripe price ID configured for tier {tier}")
|
||||
|
||||
# Guard: only proceed if the user already has a Stripe customer ID. Calling
|
||||
# get_stripe_customer_id for a user with no Stripe record (e.g. admin-granted tier)
|
||||
# would create an orphaned customer object if the subsequent Subscription.list call
|
||||
# fails. Return False early so the API layer falls back to Checkout instead.
|
||||
user = await get_user_by_id(user_id)
|
||||
if not user.stripe_customer_id:
|
||||
return False
|
||||
|
||||
customer_id = user.stripe_customer_id
|
||||
for status in ("active", "trialing"):
|
||||
subscriptions = await run_in_threadpool(
|
||||
stripe.Subscription.list, customer=customer_id, status=status, limit=1
|
||||
)
|
||||
if not subscriptions.data:
|
||||
continue
|
||||
sub = subscriptions.data[0]
|
||||
sub_id = sub["id"]
|
||||
items = sub.get("items", {}).get("data", [])
|
||||
if not items:
|
||||
continue
|
||||
item_id = items[0]["id"]
|
||||
await run_in_threadpool(
|
||||
stripe.Subscription.modify,
|
||||
sub_id,
|
||||
items=[{"id": item_id, "price": price_id}],
|
||||
proration_behavior="create_prorations",
|
||||
)
|
||||
logger.info(
|
||||
"modify_stripe_subscription_for_tier: modified sub %s for user %s → %s",
|
||||
sub_id,
|
||||
user_id,
|
||||
tier,
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def get_auto_top_up(user_id: str) -> AutoTopUpConfig:
|
||||
@@ -1291,8 +1483,19 @@ async def get_auto_top_up(user_id: str) -> AutoTopUpConfig:
|
||||
return AutoTopUpConfig.model_validate(user.top_up_config)
|
||||
|
||||
|
||||
@cached(ttl_seconds=60, maxsize=8, cache_none=False)
|
||||
async def get_subscription_price_id(tier: SubscriptionTier) -> str | None:
|
||||
"""Return Stripe Price ID for a tier from LaunchDarkly. None = not configured."""
|
||||
"""Return Stripe Price ID for a tier from LaunchDarkly, cached for 60 seconds.
|
||||
|
||||
Price IDs are LaunchDarkly flag values that change only at deploy time.
|
||||
Caching for 60 seconds avoids hitting the LD SDK on every webhook delivery
|
||||
and every GET /credits/subscription page load (called 2x per request).
|
||||
|
||||
``cache_none=False`` prevents a transient LD failure from caching ``None``
|
||||
and blocking subscription upgrades for the full 60-second TTL window.
|
||||
A tier with no configured flag (FREE, ENTERPRISE) returns ``None`` from an
|
||||
O(1) dict lookup before hitting LD, so the extra LD call is never made.
|
||||
"""
|
||||
flag_map = {
|
||||
SubscriptionTier.PRO: Flag.STRIPE_PRICE_PRO,
|
||||
SubscriptionTier.BUSINESS: Flag.STRIPE_PRICE_BUSINESS,
|
||||
@@ -1300,7 +1503,7 @@ async def get_subscription_price_id(tier: SubscriptionTier) -> str | None:
|
||||
flag = flag_map.get(tier)
|
||||
if flag is None:
|
||||
return None
|
||||
price_id = await get_feature_flag_value(flag.value, user_id="", default="")
|
||||
price_id = await get_feature_flag_value(flag.value, user_id="system", default="")
|
||||
return price_id if isinstance(price_id, str) and price_id else None
|
||||
|
||||
|
||||
@@ -1315,7 +1518,8 @@ async def create_subscription_checkout(
|
||||
if not price_id:
|
||||
raise ValueError(f"Subscription not available for tier {tier.value}")
|
||||
customer_id = await get_stripe_customer_id(user_id)
|
||||
session = stripe.checkout.Session.create(
|
||||
session = await run_in_threadpool(
|
||||
stripe.checkout.Session.create,
|
||||
customer=customer_id,
|
||||
mode="subscription",
|
||||
line_items=[{"price": price_id, "quantity": 1}],
|
||||
@@ -1323,26 +1527,111 @@ async def create_subscription_checkout(
|
||||
cancel_url=cancel_url,
|
||||
subscription_data={"metadata": {"user_id": user_id, "tier": tier.value}},
|
||||
)
|
||||
return session.url or ""
|
||||
if not session.url:
|
||||
# An empty checkout URL for a paid upgrade is always an error; surfacing it
|
||||
# as ValueError means the API handler returns 422 instead of silently
|
||||
# redirecting the client to an empty URL.
|
||||
raise ValueError("Stripe did not return a checkout session URL")
|
||||
return session.url
|
||||
|
||||
|
||||
async def _cleanup_stale_subscriptions(customer_id: str, new_sub_id: str) -> None:
|
||||
"""Best-effort cancel of any active subs for the customer other than new_sub_id.
|
||||
|
||||
Called from the webhook handler after a new subscription becomes active. Failures
|
||||
are logged but not raised so a transient Stripe error doesn't crash the webhook —
|
||||
a periodic reconciliation job is the intended backstop for persistent drift.
|
||||
|
||||
NOTE: until that reconcile job lands, a failure here means the user is silently
|
||||
billed for two simultaneous subscriptions. The error log below is intentionally
|
||||
`logger.exception` so it surfaces in Sentry with the customer/sub IDs needed to
|
||||
manually reconcile, and the metric `stripe_stale_subscription_cleanup_failed`
|
||||
is bumped so on-call can alert on persistent drift.
|
||||
TODO(#stripe-reconcile-job): replace this best-effort cleanup with a periodic
|
||||
reconciliation job that queries Stripe for customers with >1 active sub.
|
||||
"""
|
||||
try:
|
||||
await _cancel_customer_subscriptions(customer_id, exclude_sub_id=new_sub_id)
|
||||
except stripe.StripeError:
|
||||
# Use exception() (not warning) so this surfaces as an error in Sentry —
|
||||
# any failure here means a paid-to-paid upgrade may have left the user
|
||||
# with two simultaneous active subscriptions.
|
||||
logger.exception(
|
||||
"stripe_stale_subscription_cleanup_failed: customer=%s new_sub=%s —"
|
||||
" user may be billed for two simultaneous subscriptions; manual"
|
||||
" reconciliation required",
|
||||
customer_id,
|
||||
new_sub_id,
|
||||
)
|
||||
|
||||
|
||||
async def sync_subscription_from_stripe(stripe_subscription: dict) -> None:
|
||||
"""Update User.subscriptionTier from a Stripe subscription object."""
|
||||
customer_id = stripe_subscription["customer"]
|
||||
"""Update User.subscriptionTier from a Stripe subscription object.
|
||||
|
||||
Expected shape of stripe_subscription (subset of Stripe's Subscription object):
|
||||
customer: str — Stripe customer ID
|
||||
status: str — "active" | "trialing" | "canceled" | ...
|
||||
id: str — Stripe subscription ID
|
||||
items.data[].price.id: str — Stripe price ID identifying the tier
|
||||
"""
|
||||
customer_id = stripe_subscription.get("customer")
|
||||
if not customer_id:
|
||||
logger.warning(
|
||||
"sync_subscription_from_stripe: missing 'customer' field in event, "
|
||||
"skipping (keys: %s)",
|
||||
list(stripe_subscription.keys()),
|
||||
)
|
||||
return
|
||||
user = await User.prisma().find_first(where={"stripeCustomerId": customer_id})
|
||||
if not user:
|
||||
logger.warning(
|
||||
"sync_subscription_from_stripe: no user for customer %s", customer_id
|
||||
)
|
||||
return
|
||||
# Cross-check: if the subscription carries a metadata.user_id (set during
|
||||
# Checkout Session creation), verify it matches the user we found via
|
||||
# stripeCustomerId. A mismatch indicates a customer↔user mapping
|
||||
# inconsistency — updating the wrong user's tier would be a data-corruption
|
||||
# bug, so we log loudly and bail out. Absence of metadata.user_id (e.g.
|
||||
# subscriptions created outside the Checkout flow) is not an error — we
|
||||
# simply skip the check and proceed with the customer-ID-based lookup.
|
||||
metadata = stripe_subscription.get("metadata") or {}
|
||||
metadata_user_id = metadata.get("user_id") if isinstance(metadata, dict) else None
|
||||
if metadata_user_id and metadata_user_id != user.id:
|
||||
logger.error(
|
||||
"sync_subscription_from_stripe: metadata.user_id=%s does not match"
|
||||
" user.id=%s found via stripeCustomerId=%s — refusing to update tier"
|
||||
" to avoid corrupting the wrong user's subscription state",
|
||||
metadata_user_id,
|
||||
user.id,
|
||||
customer_id,
|
||||
)
|
||||
return
|
||||
# ENTERPRISE tiers are admin-managed. Never let a Stripe webhook flip an
|
||||
# ENTERPRISE user to a different tier — if a user on ENTERPRISE somehow has
|
||||
# a self-service Stripe sub, it's a data-consistency issue for an operator,
|
||||
# not something the webhook should automatically "fix".
|
||||
current_tier = user.subscriptionTier or SubscriptionTier.FREE
|
||||
if current_tier == SubscriptionTier.ENTERPRISE:
|
||||
logger.warning(
|
||||
"sync_subscription_from_stripe: refusing to overwrite ENTERPRISE tier"
|
||||
" for user %s (customer %s); event status=%s",
|
||||
user.id,
|
||||
customer_id,
|
||||
stripe_subscription.get("status", ""),
|
||||
)
|
||||
return
|
||||
status = stripe_subscription.get("status", "")
|
||||
new_sub_id = stripe_subscription.get("id", "")
|
||||
if status in ("active", "trialing"):
|
||||
price_id = ""
|
||||
items = stripe_subscription.get("items", {}).get("data", [])
|
||||
if items:
|
||||
price_id = items[0].get("price", {}).get("id", "")
|
||||
pro_price = await get_subscription_price_id(SubscriptionTier.PRO)
|
||||
biz_price = await get_subscription_price_id(SubscriptionTier.BUSINESS)
|
||||
pro_price, biz_price = await asyncio.gather(
|
||||
get_subscription_price_id(SubscriptionTier.PRO),
|
||||
get_subscription_price_id(SubscriptionTier.BUSINESS),
|
||||
)
|
||||
if price_id and pro_price and price_id == pro_price:
|
||||
tier = SubscriptionTier.PRO
|
||||
elif price_id and biz_price and price_id == biz_price:
|
||||
@@ -1359,10 +1648,206 @@ async def sync_subscription_from_stripe(stripe_subscription: dict) -> None:
|
||||
)
|
||||
return
|
||||
else:
|
||||
# A subscription was cancelled or ended. DO NOT unconditionally downgrade
|
||||
# to FREE — Stripe does not guarantee webhook delivery order, so a
|
||||
# `customer.subscription.deleted` for the OLD sub can arrive after we've
|
||||
# already processed `customer.subscription.created` for a new paid sub.
|
||||
# Ask Stripe whether any OTHER active/trialing subs exist for this
|
||||
# customer; if they do, keep the user's current tier (the other sub's
|
||||
# own event will/has already set the correct tier).
|
||||
try:
|
||||
other_subs_active, other_subs_trialing = await asyncio.gather(
|
||||
run_in_threadpool(
|
||||
stripe.Subscription.list,
|
||||
customer=customer_id,
|
||||
status="active",
|
||||
limit=10,
|
||||
),
|
||||
run_in_threadpool(
|
||||
stripe.Subscription.list,
|
||||
customer=customer_id,
|
||||
status="trialing",
|
||||
limit=10,
|
||||
),
|
||||
)
|
||||
except stripe.StripeError:
|
||||
logger.warning(
|
||||
"sync_subscription_from_stripe: could not verify other active"
|
||||
" subs for customer %s on cancel event %s; preserving current"
|
||||
" tier to avoid an unsafe downgrade",
|
||||
customer_id,
|
||||
new_sub_id,
|
||||
)
|
||||
return
|
||||
# Filter out the cancelled subscription to check if other active subs
|
||||
# exist. When new_sub_id is empty (malformed event with no 'id' field),
|
||||
# we cannot safely exclude any sub — preserve current tier to avoid
|
||||
# an unsafe downgrade on a malformed webhook payload.
|
||||
if not new_sub_id:
|
||||
logger.warning(
|
||||
"sync_subscription_from_stripe: cancel event missing 'id' field"
|
||||
" for customer %s; preserving current tier",
|
||||
customer_id,
|
||||
)
|
||||
return
|
||||
other_active_ids = {sub["id"] for sub in other_subs_active.data} - {new_sub_id}
|
||||
other_trialing_ids = {sub["id"] for sub in other_subs_trialing.data} - {
|
||||
new_sub_id
|
||||
}
|
||||
still_has_active_sub = bool(other_active_ids or other_trialing_ids)
|
||||
if still_has_active_sub:
|
||||
logger.info(
|
||||
"sync_subscription_from_stripe: sub %s cancelled but customer %s"
|
||||
" still has another active sub; keeping tier %s",
|
||||
new_sub_id,
|
||||
customer_id,
|
||||
current_tier.value,
|
||||
)
|
||||
return
|
||||
tier = SubscriptionTier.FREE
|
||||
# Idempotency: Stripe retries webhooks on delivery failure, and several event
|
||||
# types map to the same final tier. Skip the DB write + cache invalidation
|
||||
# when the tier is already correct to avoid redundant writes on replay.
|
||||
if current_tier == tier:
|
||||
return
|
||||
# When a new subscription becomes active (e.g. paid-to-paid tier upgrade
|
||||
# via a fresh Checkout Session), cancel any OTHER active subscriptions for
|
||||
# the same customer so the user isn't billed twice. We do this in the
|
||||
# webhook rather than the API handler so that abandoning the checkout
|
||||
# doesn't leave the user without a subscription.
|
||||
# IMPORTANT: this runs AFTER the idempotency check above so that webhook
|
||||
# replays for an already-applied event do NOT trigger another cleanup round
|
||||
# (which could otherwise cancel a legitimately new subscription the user
|
||||
# signed up for between the original event and its replay).
|
||||
if status in ("active", "trialing") and new_sub_id:
|
||||
# NOTE: paid-to-paid upgrade race (e.g. PRO → BUSINESS):
|
||||
# _cleanup_stale_subscriptions cancels the old PRO sub before
|
||||
# set_subscription_tier writes BUSINESS to the DB. If Stripe delivers
|
||||
# the PRO `customer.subscription.deleted` event concurrently and it
|
||||
# processes after the PRO cancel but before set_subscription_tier
|
||||
# commits, the user could momentarily appear as FREE in the DB.
|
||||
# This window is very short in practice (two sequential awaits),
|
||||
# but is a known limitation of the current webhook-driven approach.
|
||||
# A future improvement would be to write the new tier first, then
|
||||
# cancel the old sub.
|
||||
await _cleanup_stale_subscriptions(customer_id, new_sub_id)
|
||||
await set_subscription_tier(user.id, tier)
|
||||
|
||||
|
||||
async def handle_subscription_payment_failure(invoice: dict) -> None:
|
||||
"""Handle a failed Stripe subscription payment.
|
||||
|
||||
Tries to cover the invoice amount from the user's credit balance.
|
||||
|
||||
- Balance sufficient → deduct from balance, then pay the Stripe invoice so
|
||||
Stripe stops retrying it. The sub stays intact and the user keeps their tier.
|
||||
- Balance insufficient → cancel Stripe sub immediately, downgrade to FREE.
|
||||
Cancelling here avoids further Stripe retries on an invoice we cannot cover.
|
||||
"""
|
||||
customer_id = invoice.get("customer")
|
||||
if not customer_id:
|
||||
logger.warning(
|
||||
"handle_subscription_payment_failure: missing customer in invoice; skipping"
|
||||
)
|
||||
return
|
||||
|
||||
user = await User.prisma().find_first(where={"stripeCustomerId": customer_id})
|
||||
if not user:
|
||||
logger.warning(
|
||||
"handle_subscription_payment_failure: no user found for customer %s",
|
||||
customer_id,
|
||||
)
|
||||
return
|
||||
|
||||
current_tier = user.subscriptionTier or SubscriptionTier.FREE
|
||||
if current_tier == SubscriptionTier.ENTERPRISE:
|
||||
logger.warning(
|
||||
"handle_subscription_payment_failure: skipping ENTERPRISE user %s"
|
||||
" (customer %s) — tier is admin-managed",
|
||||
user.id,
|
||||
customer_id,
|
||||
)
|
||||
return
|
||||
|
||||
amount_due: int = invoice.get("amount_due", 0)
|
||||
sub_id: str = invoice.get("subscription", "")
|
||||
invoice_id: str = invoice.get("id", "")
|
||||
|
||||
if amount_due <= 0:
|
||||
logger.info(
|
||||
"handle_subscription_payment_failure: amount_due=%d for user %s;"
|
||||
" nothing to deduct",
|
||||
amount_due,
|
||||
user.id,
|
||||
)
|
||||
return
|
||||
|
||||
credit_model = UserCredit()
|
||||
try:
|
||||
await credit_model._add_transaction(
|
||||
user_id=user.id,
|
||||
amount=-amount_due,
|
||||
transaction_type=CreditTransactionType.SUBSCRIPTION,
|
||||
fail_insufficient_credits=True,
|
||||
# Use invoice_id as the idempotency key so that Stripe webhook retries
|
||||
# (e.g. on a transient stripe.Invoice.pay failure) do not double-charge.
|
||||
transaction_key=invoice_id or None,
|
||||
metadata=SafeJson(
|
||||
{
|
||||
"stripe_customer_id": customer_id,
|
||||
"stripe_subscription_id": sub_id,
|
||||
"reason": "subscription_payment_failure_covered_by_balance",
|
||||
}
|
||||
),
|
||||
)
|
||||
# Balance covered the invoice. Pay the Stripe invoice so Stripe's dunning
|
||||
# system stops retrying it — without this call Stripe would retry automatically
|
||||
# and re-trigger this webhook, causing double-deductions each retry cycle.
|
||||
if invoice_id:
|
||||
try:
|
||||
await run_in_threadpool(stripe.Invoice.pay, invoice_id)
|
||||
except stripe.StripeError:
|
||||
logger.warning(
|
||||
"handle_subscription_payment_failure: balance deducted for user"
|
||||
" %s but failed to mark invoice %s as paid; Stripe may retry",
|
||||
user.id,
|
||||
invoice_id,
|
||||
)
|
||||
logger.info(
|
||||
"handle_subscription_payment_failure: deducted %d cents from balance"
|
||||
" for user %s; Stripe invoice %s paid, sub %s intact, tier preserved",
|
||||
amount_due,
|
||||
user.id,
|
||||
invoice_id,
|
||||
sub_id,
|
||||
)
|
||||
except InsufficientBalanceError:
|
||||
# Balance insufficient — cancel Stripe subscription first, then downgrade DB.
|
||||
# Order matters: if we downgrade the DB first and the Stripe cancel fails, the
|
||||
# user is permanently stuck on FREE while Stripe continues billing them.
|
||||
# Cancelling Stripe first is safe: if the DB write then fails, the webhook
|
||||
# customer.subscription.deleted will fire and correct the tier eventually.
|
||||
logger.info(
|
||||
"handle_subscription_payment_failure: insufficient balance for user %s;"
|
||||
" cancelling Stripe sub %s then downgrading to FREE",
|
||||
user.id,
|
||||
sub_id,
|
||||
)
|
||||
try:
|
||||
await _cancel_customer_subscriptions(customer_id)
|
||||
except stripe.StripeError:
|
||||
logger.warning(
|
||||
"handle_subscription_payment_failure: failed to cancel Stripe sub %s"
|
||||
" for user %s (customer %s); skipping tier downgrade to avoid"
|
||||
" inconsistency — Stripe may continue retrying the invoice",
|
||||
sub_id,
|
||||
user.id,
|
||||
customer_id,
|
||||
)
|
||||
return
|
||||
await set_subscription_tier(user.id, SubscriptionTier.FREE)
|
||||
|
||||
|
||||
async def admin_get_user_history(
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -73,6 +73,31 @@ def _get_redis() -> Redis:
|
||||
return r
|
||||
|
||||
|
||||
class _MissingType:
|
||||
"""Singleton sentinel type — distinct from ``None`` (a valid cached value).
|
||||
|
||||
Using a dedicated class (instead of ``Any = object()``) lets mypy prove
|
||||
that comparisons ``result is _MISSING`` narrow the type correctly and
|
||||
prevents accidental use of the sentinel where a real value is expected.
|
||||
"""
|
||||
|
||||
_instance: "_MissingType | None" = None
|
||||
|
||||
def __new__(cls) -> "_MissingType":
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "<MISSING>"
|
||||
|
||||
|
||||
# Sentinel returned by ``_get_from_memory`` / ``_get_from_redis`` to mean
|
||||
# "no entry exists" — distinct from a cached ``None`` value, which is a
|
||||
# valid result for callers that opt into caching it.
|
||||
_MISSING = _MissingType()
|
||||
|
||||
|
||||
@dataclass
|
||||
class CachedValue:
|
||||
"""Wrapper for cached values with timestamp to avoid tuple ambiguity."""
|
||||
@@ -160,6 +185,7 @@ def cached(
|
||||
ttl_seconds: int,
|
||||
shared_cache: bool = False,
|
||||
refresh_ttl_on_get: bool = False,
|
||||
cache_none: bool = True,
|
||||
) -> Callable[[Callable[P, R]], CachedFunction[P, R]]:
|
||||
"""
|
||||
Thundering herd safe cache decorator for both sync and async functions.
|
||||
@@ -172,6 +198,10 @@ def cached(
|
||||
ttl_seconds: Time to live in seconds. Required - entries must expire.
|
||||
shared_cache: If True, use Redis for cross-process caching
|
||||
refresh_ttl_on_get: If True, refresh TTL when cache entry is accessed (LRU behavior)
|
||||
cache_none: If True (default) ``None`` is cached like any other value.
|
||||
Set to ``False`` for functions that return ``None`` to signal a
|
||||
transient error and should be re-tried on the next call without
|
||||
poisoning the cache (e.g. external API calls that may fail).
|
||||
|
||||
Returns:
|
||||
Decorated function with caching capabilities
|
||||
@@ -184,6 +214,12 @@ def cached(
|
||||
@cached(ttl_seconds=600, shared_cache=True, refresh_ttl_on_get=True)
|
||||
async def expensive_async_operation(param: str) -> dict:
|
||||
return {"result": param}
|
||||
|
||||
@cached(ttl_seconds=300, cache_none=False)
|
||||
async def fetch_external(id: str) -> dict | None:
|
||||
# Returns None on transient error — won't be stored,
|
||||
# next call retries instead of returning the stale None.
|
||||
...
|
||||
"""
|
||||
|
||||
def decorator(target_func: Callable[P, R]) -> CachedFunction[P, R]:
|
||||
@@ -191,9 +227,14 @@ def cached(
|
||||
cache_storage: dict[tuple, CachedValue] = {}
|
||||
_event_loop_locks: dict[Any, asyncio.Lock] = {}
|
||||
|
||||
def _get_from_redis(redis_key: str) -> Any | None:
|
||||
def _get_from_redis(redis_key: str) -> Any:
|
||||
"""Get value from Redis, optionally refreshing TTL.
|
||||
|
||||
Returns the cached value (which may be ``None``) on a hit, or the
|
||||
module-level ``_MISSING`` sentinel on a miss / corrupt entry.
|
||||
Callers must compare with ``is _MISSING`` so cached ``None`` values
|
||||
are not mistaken for misses.
|
||||
|
||||
Values are expected to carry an HMAC-SHA256 prefix for integrity
|
||||
verification. Unsigned (legacy) or tampered entries are silently
|
||||
discarded and treated as cache misses, so the caller recomputes and
|
||||
@@ -213,11 +254,11 @@ def cached(
|
||||
f"for {func_name}, discarding entry: "
|
||||
"possible tampering or legacy unsigned value"
|
||||
)
|
||||
return None
|
||||
return _MISSING
|
||||
return pickle.loads(payload)
|
||||
except Exception as e:
|
||||
logger.error(f"Redis error during cache check for {func_name}: {e}")
|
||||
return None
|
||||
return _MISSING
|
||||
|
||||
def _set_to_redis(redis_key: str, value: Any) -> None:
|
||||
"""Set HMAC-signed pickled value in Redis with TTL."""
|
||||
@@ -227,8 +268,13 @@ def cached(
|
||||
except Exception as e:
|
||||
logger.error(f"Redis error storing cache for {func_name}: {e}")
|
||||
|
||||
def _get_from_memory(key: tuple) -> Any | None:
|
||||
"""Get value from in-memory cache, checking TTL."""
|
||||
def _get_from_memory(key: tuple) -> Any:
|
||||
"""Get value from in-memory cache, checking TTL.
|
||||
|
||||
Returns the cached value (which may be ``None``) on a hit, or the
|
||||
``_MISSING`` sentinel on a miss / TTL expiry. See
|
||||
``_get_from_redis`` for the rationale.
|
||||
"""
|
||||
if key in cache_storage:
|
||||
cached_data = cache_storage[key]
|
||||
if time.time() - cached_data.timestamp < ttl_seconds:
|
||||
@@ -236,7 +282,7 @@ def cached(
|
||||
f"Cache hit for {func_name} args: {key[0]} kwargs: {key[1]}"
|
||||
)
|
||||
return cached_data.result
|
||||
return None
|
||||
return _MISSING
|
||||
|
||||
def _set_to_memory(key: tuple, value: Any) -> None:
|
||||
"""Set value in in-memory cache with timestamp."""
|
||||
@@ -270,11 +316,11 @@ def cached(
|
||||
# Fast path: check cache without lock
|
||||
if shared_cache:
|
||||
result = _get_from_redis(redis_key)
|
||||
if result is not None:
|
||||
if result is not _MISSING:
|
||||
return result
|
||||
else:
|
||||
result = _get_from_memory(key)
|
||||
if result is not None:
|
||||
if result is not _MISSING:
|
||||
return result
|
||||
|
||||
# Slow path: acquire lock for cache miss/expiry
|
||||
@@ -282,22 +328,24 @@ def cached(
|
||||
# Double-check: another coroutine might have populated cache
|
||||
if shared_cache:
|
||||
result = _get_from_redis(redis_key)
|
||||
if result is not None:
|
||||
if result is not _MISSING:
|
||||
return result
|
||||
else:
|
||||
result = _get_from_memory(key)
|
||||
if result is not None:
|
||||
if result is not _MISSING:
|
||||
return result
|
||||
|
||||
# Cache miss - execute function
|
||||
logger.debug(f"Cache miss for {func_name}")
|
||||
result = await target_func(*args, **kwargs)
|
||||
|
||||
# Store result
|
||||
if shared_cache:
|
||||
_set_to_redis(redis_key, result)
|
||||
else:
|
||||
_set_to_memory(key, result)
|
||||
# Store result (skip ``None`` if the caller opted out of
|
||||
# caching it — used for transient-error sentinels).
|
||||
if cache_none or result is not None:
|
||||
if shared_cache:
|
||||
_set_to_redis(redis_key, result)
|
||||
else:
|
||||
_set_to_memory(key, result)
|
||||
|
||||
return result
|
||||
|
||||
@@ -315,11 +363,11 @@ def cached(
|
||||
# Fast path: check cache without lock
|
||||
if shared_cache:
|
||||
result = _get_from_redis(redis_key)
|
||||
if result is not None:
|
||||
if result is not _MISSING:
|
||||
return result
|
||||
else:
|
||||
result = _get_from_memory(key)
|
||||
if result is not None:
|
||||
if result is not _MISSING:
|
||||
return result
|
||||
|
||||
# Slow path: acquire lock for cache miss/expiry
|
||||
@@ -327,22 +375,24 @@ def cached(
|
||||
# Double-check: another thread might have populated cache
|
||||
if shared_cache:
|
||||
result = _get_from_redis(redis_key)
|
||||
if result is not None:
|
||||
if result is not _MISSING:
|
||||
return result
|
||||
else:
|
||||
result = _get_from_memory(key)
|
||||
if result is not None:
|
||||
if result is not _MISSING:
|
||||
return result
|
||||
|
||||
# Cache miss - execute function
|
||||
logger.debug(f"Cache miss for {func_name}")
|
||||
result = target_func(*args, **kwargs)
|
||||
|
||||
# Store result
|
||||
if shared_cache:
|
||||
_set_to_redis(redis_key, result)
|
||||
else:
|
||||
_set_to_memory(key, result)
|
||||
# Store result (skip ``None`` if the caller opted out of
|
||||
# caching it — used for transient-error sentinels).
|
||||
if cache_none or result is not None:
|
||||
if shared_cache:
|
||||
_set_to_redis(redis_key, result)
|
||||
else:
|
||||
_set_to_memory(key, result)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -1223,3 +1223,123 @@ class TestCacheHMAC:
|
||||
assert call_count == 2
|
||||
|
||||
legacy_test_fn.cache_clear()
|
||||
|
||||
|
||||
class TestCacheNoneHandling:
|
||||
"""Tests for the ``cache_none`` parameter on the @cached decorator.
|
||||
|
||||
Sentry bug PRRT_kwDOJKSTjM56RTEu (HIGH): the cache previously could not
|
||||
distinguish "no entry" from "entry is None", so any function returning
|
||||
``None`` was effectively re-executed on every call. The fix is a
|
||||
sentinel-based check inside the wrappers, plus an opt-out
|
||||
``cache_none=False`` flag for callers that *want* errors to retry.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_none_is_cached_by_default(self):
|
||||
"""With ``cache_none=True`` (default), cached ``None`` is returned
|
||||
from the cache instead of triggering re-execution."""
|
||||
call_count = 0
|
||||
|
||||
@cached(ttl_seconds=300)
|
||||
async def maybe_none(x: int) -> int | None:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return None
|
||||
|
||||
assert await maybe_none(1) is None
|
||||
assert call_count == 1
|
||||
|
||||
# Second call should hit the cache, not re-execute.
|
||||
assert await maybe_none(1) is None
|
||||
assert call_count == 1
|
||||
|
||||
# Different argument is a different cache key — re-executes.
|
||||
assert await maybe_none(2) is None
|
||||
assert call_count == 2
|
||||
|
||||
def test_sync_none_is_cached_by_default(self):
|
||||
call_count = 0
|
||||
|
||||
@cached(ttl_seconds=300)
|
||||
def maybe_none(x: int) -> int | None:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return None
|
||||
|
||||
assert maybe_none(1) is None
|
||||
assert maybe_none(1) is None
|
||||
assert call_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_cache_none_false_skips_storing_none(self):
|
||||
"""``cache_none=False`` skips storing ``None`` so transient errors
|
||||
are retried on the next call instead of poisoning the cache."""
|
||||
call_count = 0
|
||||
results: list[int | None] = [None, None, 42]
|
||||
|
||||
@cached(ttl_seconds=300, cache_none=False)
|
||||
async def maybe_none(x: int) -> int | None:
|
||||
nonlocal call_count
|
||||
result = results[call_count]
|
||||
call_count += 1
|
||||
return result
|
||||
|
||||
# First call: returns None, NOT stored.
|
||||
assert await maybe_none(1) is None
|
||||
assert call_count == 1
|
||||
|
||||
# Second call with same key: re-executes (None wasn't cached).
|
||||
assert await maybe_none(1) is None
|
||||
assert call_count == 2
|
||||
|
||||
# Third call: returns 42, this time it IS stored.
|
||||
assert await maybe_none(1) == 42
|
||||
assert call_count == 3
|
||||
|
||||
# Fourth call: cache hit on the stored 42.
|
||||
assert await maybe_none(1) == 42
|
||||
assert call_count == 3
|
||||
|
||||
def test_sync_cache_none_false_skips_storing_none(self):
|
||||
call_count = 0
|
||||
results: list[int | None] = [None, 99]
|
||||
|
||||
@cached(ttl_seconds=300, cache_none=False)
|
||||
def maybe_none(x: int) -> int | None:
|
||||
nonlocal call_count
|
||||
result = results[call_count]
|
||||
call_count += 1
|
||||
return result
|
||||
|
||||
assert maybe_none(1) is None
|
||||
assert call_count == 1
|
||||
|
||||
# None was not stored — re-executes.
|
||||
assert maybe_none(1) == 99
|
||||
assert call_count == 2
|
||||
|
||||
# 99 IS stored — no re-execution.
|
||||
assert maybe_none(1) == 99
|
||||
assert call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_shared_cache_none_is_cached_by_default(self):
|
||||
"""Shared (Redis) cache also properly returns cached ``None`` values."""
|
||||
call_count = 0
|
||||
|
||||
@cached(ttl_seconds=30, shared_cache=True)
|
||||
async def maybe_none_redis(x: int) -> int | None:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return None
|
||||
|
||||
maybe_none_redis.cache_clear()
|
||||
|
||||
assert await maybe_none_redis(1) is None
|
||||
assert call_count == 1
|
||||
|
||||
assert await maybe_none_redis(1) is None
|
||||
assert call_count == 1
|
||||
|
||||
maybe_none_redis.cache_clear()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from enum import Enum
|
||||
from functools import wraps
|
||||
from typing import Any, Awaitable, Callable, TypeVar
|
||||
@@ -101,6 +102,12 @@ async def _fetch_user_context_data(user_id: str) -> Context:
|
||||
"""
|
||||
builder = Context.builder(user_id).kind("user").anonymous(True)
|
||||
|
||||
try:
|
||||
uuid.UUID(user_id)
|
||||
except ValueError:
|
||||
# Non-UUID key (e.g. "system") — skip Supabase lookup, return anonymous context.
|
||||
return builder.build()
|
||||
|
||||
try:
|
||||
from backend.util.clients import get_supabase
|
||||
|
||||
|
||||
@@ -155,6 +155,7 @@
|
||||
"@types/twemoji": "13.1.2",
|
||||
"@vitejs/plugin-react": "5.1.2",
|
||||
"@vitest/coverage-v8": "4.0.17",
|
||||
"agentation": "3.0.2",
|
||||
"axe-playwright": "2.2.2",
|
||||
"chromatic": "13.3.3",
|
||||
"concurrently": "9.2.1",
|
||||
|
||||
19
autogpt_platform/frontend/pnpm-lock.yaml
generated
19
autogpt_platform/frontend/pnpm-lock.yaml
generated
@@ -376,6 +376,9 @@ importers:
|
||||
'@vitest/coverage-v8':
|
||||
specifier: 4.0.17
|
||||
version: 4.0.17(vitest@4.0.17(@opentelemetry/api@1.9.0)(@types/node@24.10.0)(happy-dom@20.3.4)(jiti@2.6.1)(jsdom@27.4.0)(msw@2.11.6(@types/node@24.10.0)(typescript@5.9.3))(terser@5.44.1)(yaml@2.8.2))
|
||||
agentation:
|
||||
specifier: 3.0.2
|
||||
version: 3.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
axe-playwright:
|
||||
specifier: 2.2.2
|
||||
version: 2.2.2(playwright@1.56.1)
|
||||
@@ -4119,6 +4122,17 @@ packages:
|
||||
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
agentation@3.0.2:
|
||||
resolution: {integrity: sha512-iGzBxFVTuZEIKzLY6AExSLAQH6i6SwxV4pAu7v7m3X6bInZ7qlZXAwrEqyc4+EfP4gM7z2RXBF6SF4DeH0f2lA==}
|
||||
peerDependencies:
|
||||
react: '>=18.0.0'
|
||||
react-dom: '>=18.0.0'
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
ai@6.0.134:
|
||||
resolution: {integrity: sha512-YalNEaavld/kE444gOcsMKXdVVRGEe0SK77fAFcWYcqLg+a7xKnEet8bdfrEAJTfnMjj01rhgrIL10903w1a5Q==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -13119,6 +13133,11 @@ snapshots:
|
||||
agent-base@7.1.4:
|
||||
optional: true
|
||||
|
||||
agentation@3.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
optionalDependencies:
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
ai@6.0.134(zod@3.25.76):
|
||||
dependencies:
|
||||
'@ai-sdk/gateway': 3.0.77(zod@3.25.76)
|
||||
|
||||
@@ -86,11 +86,11 @@ export function ChatInput({
|
||||
title:
|
||||
next === "advanced"
|
||||
? "Switched to Advanced model"
|
||||
: "Switched to Standard model",
|
||||
: "Switched to Balanced model",
|
||||
description:
|
||||
next === "advanced"
|
||||
? "Using the highest-capability model."
|
||||
: "Using the balanced standard model.",
|
||||
: "Using the balanced default model.",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -162,10 +162,15 @@ describe("ChatInput mode toggle", () => {
|
||||
expect(mockSetCopilotChatMode).toHaveBeenCalledWith("extended_thinking");
|
||||
});
|
||||
|
||||
it("hides toggle button when streaming", () => {
|
||||
it("hides toggle buttons when streaming", () => {
|
||||
mockFlagValue = true;
|
||||
render(<ChatInput onSend={mockOnSend} isStreaming />);
|
||||
expect(screen.queryByLabelText(/switch to/i)).toBeNull();
|
||||
expect(
|
||||
screen.queryByLabelText(/switch to (fast|extended thinking) mode/i),
|
||||
).toBeNull();
|
||||
expect(
|
||||
screen.queryByLabelText(/switch to (advanced|balanced|standard) model/i),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("shows mode toggle when hasSession is true and not streaming", () => {
|
||||
@@ -234,7 +239,7 @@ describe("ChatInput model toggle", () => {
|
||||
mockFlagValue = true;
|
||||
mockCopilotLlmModel = "advanced";
|
||||
render(<ChatInput onSend={mockOnSend} />);
|
||||
fireEvent.click(screen.getByLabelText(/switch to standard model/i));
|
||||
fireEvent.click(screen.getByLabelText(/switch to balanced model/i));
|
||||
expect(mockSetCopilotLlmModel).toHaveBeenCalledWith("standard");
|
||||
});
|
||||
|
||||
@@ -288,10 +293,10 @@ describe("ChatInput model toggle", () => {
|
||||
mockFlagValue = true;
|
||||
mockCopilotLlmModel = "advanced";
|
||||
render(<ChatInput onSend={mockOnSend} />);
|
||||
fireEvent.click(screen.getByLabelText(/switch to standard model/i));
|
||||
fireEvent.click(screen.getByLabelText(/switch to balanced model/i));
|
||||
expect(toast).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: expect.stringMatching(/switched to standard model/i),
|
||||
title: expect.stringMatching(/switched to balanced model/i),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Flask } from "@phosphor-icons/react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
// This button is only rendered on NEW chats (no active session).
|
||||
// Once a session exists, it is hidden — the session's dry_run flag is
|
||||
@@ -14,27 +19,31 @@ interface Props {
|
||||
|
||||
export function DryRunToggleButton({ isDryRun, onToggle }: Props) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={isDryRun}
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
"inline-flex min-h-11 min-w-11 items-center justify-center gap-1 rounded-md px-2 py-1 text-xs font-medium transition-colors",
|
||||
isDryRun
|
||||
? "bg-amber-100 text-amber-900 hover:bg-amber-200"
|
||||
: "text-neutral-500 hover:bg-neutral-100 hover:text-neutral-700",
|
||||
)}
|
||||
aria-label={
|
||||
isDryRun ? "Test mode active — click to disable" : "Enable Test mode"
|
||||
}
|
||||
title={
|
||||
isDryRun
|
||||
? "Test mode ON — new chats run agents as simulation (click to disable)"
|
||||
: "Enable Test mode — new chats will run agents as simulation"
|
||||
}
|
||||
>
|
||||
<Flask size={14} />
|
||||
{isDryRun && "Test"}
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={isDryRun}
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-center gap-1 rounded-full border border-neutral-200 bg-white px-2.5 text-xs font-medium shadow-sm transition-colors hover:bg-neutral-50",
|
||||
isDryRun
|
||||
? "text-amber-900"
|
||||
: "text-neutral-500 hover:text-neutral-700",
|
||||
)}
|
||||
aria-label={isDryRun ? "Test mode active" : "Enable Test mode"}
|
||||
>
|
||||
<Flask size={14} />
|
||||
<span className="hidden sm:inline">
|
||||
{isDryRun ? "Test mode enabled" : "Enable test mode"}
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isDryRun
|
||||
? "Test mode on — new sessions run without performing real actions (click to turn off)."
|
||||
: "Turn on test mode to try prompts without performing real actions."}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Brain, Lightning } from "@phosphor-icons/react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { CopilotMode } from "../../../store";
|
||||
|
||||
interface Props {
|
||||
@@ -11,37 +16,42 @@ interface Props {
|
||||
|
||||
export function ModeToggleButton({ mode, onToggle }: Props) {
|
||||
const isExtended = mode === "extended_thinking";
|
||||
|
||||
const tooltipText = isExtended
|
||||
? "Extended Thinking — deeper reasoning (click to switch to Fast)"
|
||||
: "Fast mode — quicker responses (click to switch to Thinking)";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={isExtended}
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
"inline-flex min-h-11 min-w-11 items-center justify-center gap-1 rounded-md px-2 py-1 text-xs font-medium transition-colors",
|
||||
isExtended
|
||||
? "bg-purple-100 text-purple-900 hover:bg-purple-200"
|
||||
: "bg-amber-100 text-amber-900 hover:bg-amber-200",
|
||||
)}
|
||||
aria-label={
|
||||
isExtended ? "Switch to Fast mode" : "Switch to Extended Thinking mode"
|
||||
}
|
||||
title={
|
||||
isExtended
|
||||
? "Extended Thinking mode — deeper reasoning (click to switch to Fast mode)"
|
||||
: "Fast mode — quicker responses (click to switch to Extended Thinking)"
|
||||
}
|
||||
>
|
||||
{isExtended ? (
|
||||
<>
|
||||
<Brain size={14} />
|
||||
Thinking
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Lightning size={14} />
|
||||
Fast
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={isExtended}
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
"ml-2 inline-flex h-9 items-center justify-center gap-1 rounded-full border border-neutral-200 bg-white px-2.5 text-xs font-medium shadow-sm transition-colors hover:bg-neutral-50",
|
||||
isExtended ? "text-purple-900" : "text-amber-900",
|
||||
)}
|
||||
aria-label={
|
||||
isExtended
|
||||
? "Switch to Fast mode"
|
||||
: "Switch to Extended Thinking mode"
|
||||
}
|
||||
>
|
||||
{isExtended ? (
|
||||
<>
|
||||
<Brain size={14} />
|
||||
Thinking
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Lightning size={14} />
|
||||
Fast
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tooltipText}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Cpu } from "@phosphor-icons/react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { CopilotLlmModel } from "../../../store";
|
||||
|
||||
interface Props {
|
||||
@@ -12,27 +17,33 @@ interface Props {
|
||||
export function ModelToggleButton({ model, onToggle }: Props) {
|
||||
const isAdvanced = model === "advanced";
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={isAdvanced}
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
"inline-flex min-h-11 min-w-11 items-center justify-center gap-1 rounded-md px-2 py-1 text-xs font-medium transition-colors",
|
||||
isAdvanced
|
||||
? "bg-sky-100 text-sky-900 hover:bg-sky-200"
|
||||
: "text-neutral-500 hover:bg-neutral-100 hover:text-neutral-700",
|
||||
)}
|
||||
aria-label={
|
||||
isAdvanced ? "Switch to Standard model" : "Switch to Advanced model"
|
||||
}
|
||||
title={
|
||||
isAdvanced
|
||||
? "Advanced model — highest capability (click to switch to Standard)"
|
||||
: "Standard model — click to switch to Advanced"
|
||||
}
|
||||
>
|
||||
<Cpu size={14} />
|
||||
{isAdvanced && "Advanced"}
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={isAdvanced}
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-center gap-1 rounded-full border border-neutral-200 bg-white px-2.5 text-xs font-medium shadow-sm transition-colors hover:bg-neutral-50",
|
||||
isAdvanced
|
||||
? "text-sky-900"
|
||||
: "text-neutral-500 hover:text-neutral-700",
|
||||
)}
|
||||
aria-label={
|
||||
isAdvanced ? "Switch to Balanced model" : "Switch to Advanced model"
|
||||
}
|
||||
>
|
||||
<Cpu size={14} />
|
||||
<span className="hidden sm:inline">
|
||||
{isAdvanced ? "Advanced" : "Balanced"}
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isAdvanced
|
||||
? "Using the highest-capability model (click to switch to Balanced)."
|
||||
: "Using the balanced default model (click to switch to Advanced)."}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,32 @@
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import {
|
||||
render as rtlRender,
|
||||
screen,
|
||||
fireEvent,
|
||||
cleanup,
|
||||
} from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ReactElement } from "react";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { DryRunToggleButton } from "../DryRunToggleButton";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function render(ui: ReactElement) {
|
||||
return rtlRender(<TooltipProvider>{ui}</TooltipProvider>);
|
||||
}
|
||||
|
||||
// DryRunToggleButton only appears on new chats (no active session).
|
||||
// It has no readOnly/isStreaming props — those scenarios are handled by hiding
|
||||
// the button entirely at the ChatInput level when hasSession is true.
|
||||
describe("DryRunToggleButton", () => {
|
||||
it("shows Test label when isDryRun is true", () => {
|
||||
it("shows enabled label when isDryRun is true", () => {
|
||||
render(<DryRunToggleButton isDryRun={true} onToggle={vi.fn()} />);
|
||||
expect(screen.getByText("Test")).toBeTruthy();
|
||||
expect(screen.getByText("Test mode enabled")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows no text label when isDryRun is false", () => {
|
||||
it("shows enable label when isDryRun is false", () => {
|
||||
render(<DryRunToggleButton isDryRun={false} onToggle={vi.fn()} />);
|
||||
expect(screen.queryByText("Test")).toBeNull();
|
||||
expect(screen.getByText("Enable test mode")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("calls onToggle when clicked", () => {
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import {
|
||||
render as rtlRender,
|
||||
screen,
|
||||
fireEvent,
|
||||
cleanup,
|
||||
} from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ReactElement } from "react";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { ModelToggleButton } from "../ModelToggleButton";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function render(ui: ReactElement) {
|
||||
return rtlRender(<TooltipProvider>{ui}</TooltipProvider>);
|
||||
}
|
||||
|
||||
describe("ModelToggleButton", () => {
|
||||
it("shows no text label when model is standard", () => {
|
||||
render(<ModelToggleButton model="standard" onToggle={vi.fn()} />);
|
||||
@@ -31,7 +42,7 @@ describe("ModelToggleButton", () => {
|
||||
|
||||
it("sets aria-pressed=true for advanced", () => {
|
||||
render(<ModelToggleButton model="advanced" onToggle={vi.fn()} />);
|
||||
const btn = screen.getByLabelText("Switch to Standard model");
|
||||
const btn = screen.getByLabelText("Switch to Balanced model");
|
||||
expect(btn.getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -246,7 +246,7 @@ export function ChatSidebar() {
|
||||
</SidebarHeader>
|
||||
)}
|
||||
{!isCollapsed && (
|
||||
<SidebarHeader className="shrink-0 px-4 pb-4 pt-4 shadow-[0_4px_6px_-1px_rgba(0,0,0,0.05)]">
|
||||
<SidebarHeader className="shrink-0 px-4 pb-3 pt-3 shadow-[0_4px_6px_-1px_rgba(0,0,0,0.05)]">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
|
||||
@@ -13,6 +13,10 @@ import {
|
||||
getSuggestionThemes,
|
||||
} from "./helpers";
|
||||
import { SuggestionThemes } from "./components/SuggestionThemes/SuggestionThemes";
|
||||
import { PulseChips } from "../PulseChips/PulseChips";
|
||||
import { usePulseChips } from "../PulseChips/usePulseChips";
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
import { EditNameDialog } from "./components/EditNameDialog/EditNameDialog";
|
||||
|
||||
interface Props {
|
||||
inputLayoutId: string;
|
||||
@@ -34,6 +38,8 @@ export function EmptySession({
|
||||
}: Props) {
|
||||
const { user } = useSupabase();
|
||||
const greetingName = getGreetingName(user);
|
||||
const isAgentBriefingEnabled = useGetFlag(Flag.AGENT_BRIEFING);
|
||||
const pulseChips = usePulseChips();
|
||||
|
||||
const { data: suggestedPromptsResponse, isLoading: isLoadingPrompts } =
|
||||
useGetV2GetSuggestedPrompts({
|
||||
@@ -75,11 +81,16 @@ export function EmptySession({
|
||||
<div className="mx-auto max-w-[52rem]">
|
||||
<Text variant="h3" className="mb-1 !text-[1.375rem] text-zinc-700">
|
||||
Hey, <span className="text-violet-600">{greetingName}</span>
|
||||
<EditNameDialog currentName={greetingName} />
|
||||
</Text>
|
||||
<Text variant="h3" className="mb-8 !font-normal">
|
||||
Tell me about your work — I'll find what to automate.
|
||||
</Text>
|
||||
|
||||
{isAgentBriefingEnabled && (
|
||||
<PulseChips chips={pulseChips} onChipClick={onSend} />
|
||||
)}
|
||||
|
||||
<div className="mb-6">
|
||||
<motion.div
|
||||
layoutId={inputLayoutId}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||
import { PencilSimpleIcon } from "@phosphor-icons/react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface Props {
|
||||
currentName: string;
|
||||
}
|
||||
|
||||
export function EditNameDialog({ currentName }: Props) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [name, setName] = useState(currentName);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const { refreshSession } = useSupabase();
|
||||
const { toast } = useToast();
|
||||
|
||||
function handleOpenChange(open: boolean) {
|
||||
if (open) setName(currentName);
|
||||
setIsOpen(open);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const res = await fetch("/api/auth/user", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ full_name: trimmed }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json();
|
||||
toast({
|
||||
title: "Failed to update name",
|
||||
description: body.error ?? "Unknown error",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await refreshSession();
|
||||
if (session?.error) {
|
||||
toast({
|
||||
title: "Name saved, but session refresh failed",
|
||||
description: session.error,
|
||||
variant: "destructive",
|
||||
});
|
||||
setIsOpen(false);
|
||||
return;
|
||||
}
|
||||
setIsOpen(false);
|
||||
toast({ title: "Name updated" });
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title="Edit display name"
|
||||
styling={{ maxWidth: "24rem" }}
|
||||
controlled={{ isOpen, set: handleOpenChange }}
|
||||
>
|
||||
<Dialog.Trigger>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 inline-flex items-center text-violet-500 transition-colors hover:text-violet-700"
|
||||
>
|
||||
<PencilSimpleIcon size={16} />
|
||||
</button>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Content>
|
||||
<div className="flex flex-col gap-4 px-1">
|
||||
<Input
|
||||
id="display-name"
|
||||
label="Display name"
|
||||
placeholder="Your name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
disabled={!name.trim() || isSaving}
|
||||
loading={isSaving}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@/tests/integrations/test-utils";
|
||||
import { server } from "@/mocks/mock-server";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { EditNameDialog } from "../EditNameDialog";
|
||||
|
||||
const mockToast = vi.hoisted(() => vi.fn());
|
||||
const mockRefreshSession = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/components/molecules/Toast/use-toast", () => ({
|
||||
useToast: () => ({ toast: mockToast }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/supabase/hooks/useSupabase", () => ({
|
||||
useSupabase: () => ({
|
||||
refreshSession: mockRefreshSession,
|
||||
}),
|
||||
}));
|
||||
|
||||
function mockUpdateNameSuccess() {
|
||||
server.use(
|
||||
http.put("/api/auth/user", () => {
|
||||
return HttpResponse.json({ user: { id: "u1" } });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function mockUpdateNameError(message = "Network error") {
|
||||
server.use(
|
||||
http.put("/api/auth/user", () => {
|
||||
return HttpResponse.json({ error: message }, { status: 400 });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function openDialogAndGetInput() {
|
||||
const trigger = screen.getByRole("button");
|
||||
fireEvent.click(trigger);
|
||||
await screen.findAllByLabelText(/display name/i);
|
||||
const inputs =
|
||||
document.querySelectorAll<HTMLInputElement>("input#display-name");
|
||||
return inputs[0];
|
||||
}
|
||||
|
||||
function getSaveButton() {
|
||||
const saves = screen.getAllByRole("button", { name: /save/i });
|
||||
return saves[0] as HTMLButtonElement;
|
||||
}
|
||||
|
||||
describe("EditNameDialog", () => {
|
||||
beforeEach(() => {
|
||||
mockToast.mockReset();
|
||||
mockRefreshSession.mockReset();
|
||||
mockRefreshSession.mockResolvedValue({ user: { id: "u1" } });
|
||||
});
|
||||
|
||||
test("opens dialog with current name prefilled", async () => {
|
||||
mockUpdateNameSuccess();
|
||||
render(<EditNameDialog currentName="Alice" />);
|
||||
|
||||
const input = await openDialogAndGetInput();
|
||||
expect(input.value).toBe("Alice");
|
||||
});
|
||||
|
||||
test("saves name via API route and closes dialog", async () => {
|
||||
mockUpdateNameSuccess();
|
||||
render(<EditNameDialog currentName="Alice" />);
|
||||
|
||||
const input = await openDialogAndGetInput();
|
||||
fireEvent.change(input, { target: { value: "Bob" } });
|
||||
fireEvent.click(getSaveButton());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRefreshSession).toHaveBeenCalled();
|
||||
});
|
||||
expect(mockToast).toHaveBeenCalledWith({ title: "Name updated" });
|
||||
});
|
||||
|
||||
test("shows error toast when API returns error", async () => {
|
||||
mockUpdateNameError("Network error");
|
||||
render(<EditNameDialog currentName="Alice" />);
|
||||
|
||||
const input = await openDialogAndGetInput();
|
||||
fireEvent.change(input, { target: { value: "Bob" } });
|
||||
fireEvent.click(getSaveButton());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToast).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "Failed to update name",
|
||||
description: "Network error",
|
||||
variant: "destructive",
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(mockRefreshSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("shows warning toast when refreshSession returns an error", async () => {
|
||||
mockUpdateNameSuccess();
|
||||
mockRefreshSession.mockResolvedValue({ error: "refresh failed" });
|
||||
|
||||
render(<EditNameDialog currentName="Alice" />);
|
||||
|
||||
const input = await openDialogAndGetInput();
|
||||
fireEvent.change(input, { target: { value: "Bob" } });
|
||||
fireEvent.click(getSaveButton());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToast).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "Name saved, but session refresh failed",
|
||||
description: "refresh failed",
|
||||
variant: "destructive",
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(mockToast).not.toHaveBeenCalledWith({ title: "Name updated" });
|
||||
});
|
||||
|
||||
test("disables Save button while input is empty", async () => {
|
||||
mockUpdateNameSuccess();
|
||||
render(<EditNameDialog currentName="Alice" />);
|
||||
|
||||
const input = await openDialogAndGetInput();
|
||||
fireEvent.change(input, { target: { value: " " } });
|
||||
|
||||
expect(getSaveButton().disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
.glassPanel {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.glassPanel::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
padding: 1px;
|
||||
background: conic-gradient(
|
||||
from var(--border-angle, 0deg),
|
||||
rgba(129, 120, 228, 0.08),
|
||||
rgba(129, 120, 228, 0.28),
|
||||
rgba(168, 130, 255, 0.18),
|
||||
rgba(129, 120, 228, 0.08),
|
||||
rgba(99, 102, 241, 0.24),
|
||||
rgba(129, 120, 228, 0.08)
|
||||
);
|
||||
-webkit-mask:
|
||||
linear-gradient(#000 0 0) content-box,
|
||||
linear-gradient(#000 0 0);
|
||||
mask:
|
||||
linear-gradient(#000 0 0) content-box,
|
||||
linear-gradient(#000 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
animation: rotate-border 6s linear infinite;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
@property --border-angle {
|
||||
syntax: "<angle>";
|
||||
initial-value: 0deg;
|
||||
inherits: false;
|
||||
}
|
||||
|
||||
@keyframes rotate-border {
|
||||
to {
|
||||
--border-angle: 360deg;
|
||||
}
|
||||
}
|
||||
|
||||
.chip {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.chip {
|
||||
padding-bottom: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.chip {
|
||||
padding-bottom: 2.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.chipActions {
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.chipActions {
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
transition:
|
||||
opacity 0.2s ease-out,
|
||||
transform 0.2s ease-out;
|
||||
}
|
||||
|
||||
.chip:hover .chipActions {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.chipContent {
|
||||
transition: filter 0.2s ease-out;
|
||||
}
|
||||
|
||||
.chip:hover .chipContent {
|
||||
filter: blur(2px);
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
EyeIcon,
|
||||
ChatCircleDotsIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import NextLink from "next/link";
|
||||
import { StatusBadge } from "@/app/(platform)/library/components/StatusBadge/StatusBadge";
|
||||
import styles from "./PulseChips.module.css";
|
||||
import type { PulseChipData } from "./types";
|
||||
|
||||
interface Props {
|
||||
chips: PulseChipData[];
|
||||
onChipClick?: (prompt: string) => void;
|
||||
}
|
||||
|
||||
export function PulseChips({ chips, onChipClick }: Props) {
|
||||
if (chips.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.glassPanel} mx-[0.6875rem] mb-5 rounded-large p-5`}
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<Text variant="body-medium" className="text-zinc-600">
|
||||
What's happening with your agents
|
||||
</Text>
|
||||
<NextLink
|
||||
href="/library"
|
||||
className="flex items-center gap-1 text-xs text-zinc-500 hover:text-zinc-700"
|
||||
>
|
||||
View all <ArrowRightIcon size={12} />
|
||||
</NextLink>
|
||||
</div>
|
||||
<div className="flex gap-2 overflow-x-auto pb-1 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-zinc-300">
|
||||
{chips.map((chip) => (
|
||||
<PulseChip key={chip.id} chip={chip} onAsk={onChipClick} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ChipProps {
|
||||
chip: PulseChipData;
|
||||
onAsk?: (prompt: string) => void;
|
||||
}
|
||||
|
||||
function PulseChip({ chip, onAsk }: ChipProps) {
|
||||
function handleAsk() {
|
||||
const prompt = buildChipPrompt(chip);
|
||||
onAsk?.(prompt);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.chip} relative flex w-[15rem] shrink-0 flex-col items-start gap-2 rounded-medium border border-zinc-100 bg-white px-3 py-2`}
|
||||
>
|
||||
<div className={`${styles.chipContent} w-full text-left`}>
|
||||
{chip.priority === "success" ? (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium text-emerald-600">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
|
||||
Completed
|
||||
</span>
|
||||
) : (
|
||||
<StatusBadge status={chip.status} />
|
||||
)}
|
||||
<div className="mt-2 min-w-0">
|
||||
<Text variant="small-medium" className="truncate text-zinc-900">
|
||||
{chip.name}
|
||||
</Text>
|
||||
<Text variant="small" className="truncate text-zinc-500">
|
||||
{chip.shortMessage}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`${styles.chipActions} flex items-center justify-center gap-1.5 rounded-b-medium px-3 py-1.5`}
|
||||
>
|
||||
<NextLink
|
||||
href={`/library/agents/${chip.agentID}`}
|
||||
className="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-700"
|
||||
>
|
||||
<EyeIcon size={14} />
|
||||
See
|
||||
</NextLink>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAsk}
|
||||
className="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-700"
|
||||
>
|
||||
<ChatCircleDotsIcon size={14} />
|
||||
Ask
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildChipPrompt(chip: PulseChipData): string {
|
||||
if (chip.priority === "success") {
|
||||
return `${chip.name} just finished a run — can you summarize what it did?`;
|
||||
}
|
||||
switch (chip.status) {
|
||||
case "error":
|
||||
return `What happened with ${chip.name}? It has an error — can you check?`;
|
||||
case "running":
|
||||
return `Give me a status update on ${chip.name} — what has it done so far?`;
|
||||
case "idle":
|
||||
return `${chip.name} hasn't run recently. Should I keep it or update and re-run it?`;
|
||||
default:
|
||||
return `Tell me about ${chip.name} — what's its current status?`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@/tests/integrations/test-utils";
|
||||
import { PulseChips } from "../PulseChips";
|
||||
import type { PulseChipData } from "../types";
|
||||
|
||||
function makeChip(overrides: Partial<PulseChipData> = {}): PulseChipData {
|
||||
return {
|
||||
id: "chip-1",
|
||||
agentID: "agent-1",
|
||||
name: "Test Agent",
|
||||
status: "running",
|
||||
priority: "running",
|
||||
shortMessage: "Doing work…",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("PulseChips", () => {
|
||||
test("renders nothing when chips array is empty", () => {
|
||||
const { container } = render(<PulseChips chips={[]} />);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
test("renders chip names and messages", () => {
|
||||
const chips = [
|
||||
makeChip({ id: "1", name: "Alpha Bot", shortMessage: "Running task A" }),
|
||||
makeChip({ id: "2", name: "Beta Bot", shortMessage: "Running task B" }),
|
||||
];
|
||||
|
||||
render(<PulseChips chips={chips} />);
|
||||
|
||||
expect(screen.getByText("Alpha Bot")).toBeDefined();
|
||||
expect(screen.getByText("Running task A")).toBeDefined();
|
||||
expect(screen.getByText("Beta Bot")).toBeDefined();
|
||||
expect(screen.getByText("Running task B")).toBeDefined();
|
||||
});
|
||||
|
||||
test("renders section heading and View all link", () => {
|
||||
render(<PulseChips chips={[makeChip()]} />);
|
||||
|
||||
expect(screen.getByText("What's happening with your agents")).toBeDefined();
|
||||
expect(screen.getByText("View all")).toBeDefined();
|
||||
});
|
||||
|
||||
test("shows Completed badge for success priority chips", () => {
|
||||
render(
|
||||
<PulseChips
|
||||
chips={[makeChip({ priority: "success", status: "idle" })]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Completed")).toBeDefined();
|
||||
});
|
||||
|
||||
test("calls onChipClick with generated prompt when Ask is clicked", () => {
|
||||
const onChipClick = vi.fn();
|
||||
render(
|
||||
<PulseChips
|
||||
chips={[
|
||||
makeChip({
|
||||
name: "Error Agent",
|
||||
status: "error",
|
||||
priority: "error",
|
||||
}),
|
||||
]}
|
||||
onChipClick={onChipClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Ask"));
|
||||
|
||||
expect(onChipClick).toHaveBeenCalledWith(
|
||||
"What happened with Error Agent? It has an error — can you check?",
|
||||
);
|
||||
});
|
||||
|
||||
test("generates success prompt for completed chips", () => {
|
||||
const onChipClick = vi.fn();
|
||||
render(
|
||||
<PulseChips
|
||||
chips={[
|
||||
makeChip({
|
||||
name: "Done Agent",
|
||||
priority: "success",
|
||||
status: "idle",
|
||||
}),
|
||||
]}
|
||||
onChipClick={onChipClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Ask"));
|
||||
|
||||
expect(onChipClick).toHaveBeenCalledWith(
|
||||
"Done Agent just finished a run — can you summarize what it did?",
|
||||
);
|
||||
});
|
||||
|
||||
test("renders See link pointing to agent detail page", () => {
|
||||
render(<PulseChips chips={[makeChip({ agentID: "agent-xyz" })]} />);
|
||||
|
||||
const seeLink = screen.getByText("See").closest("a");
|
||||
expect(seeLink?.getAttribute("href")).toBe("/library/agents/agent-xyz");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import type {
|
||||
AgentStatus,
|
||||
SitrepPriority,
|
||||
} from "@/app/(platform)/library/types";
|
||||
|
||||
export interface PulseChipData {
|
||||
id: string;
|
||||
agentID: string;
|
||||
name: string;
|
||||
status: AgentStatus;
|
||||
priority: SitrepPriority;
|
||||
shortMessage: string;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useLibraryAgents } from "@/hooks/useLibraryAgents/useLibraryAgents";
|
||||
import { useSitrepItems } from "@/app/(platform)/library/components/SitrepItem/useSitrepItems";
|
||||
import type { PulseChipData } from "./types";
|
||||
import { useMemo } from "react";
|
||||
|
||||
// TODO: remove QA fakes before merging
|
||||
const QA_FAKES: PulseChipData[] = [
|
||||
{
|
||||
id: "qa-1",
|
||||
agentID: "fake-1",
|
||||
name: "SEO Blog Writer with Advanced Keyword Research and Content Optimization",
|
||||
status: "running",
|
||||
priority: "running",
|
||||
shortMessage:
|
||||
"Writing a comprehensive long-form article on the latest AI trends in enterprise software development and deployment",
|
||||
},
|
||||
{
|
||||
id: "qa-2",
|
||||
agentID: "fake-2",
|
||||
name: "Multi-Cloud Data Pipeline Monitor and Alerting System",
|
||||
status: "error",
|
||||
priority: "error",
|
||||
shortMessage:
|
||||
"Connection to the primary data warehouse timed out after 30 retries — fallback region also unreachable",
|
||||
},
|
||||
{
|
||||
id: "qa-3",
|
||||
agentID: "fake-3",
|
||||
name: "Social Media Cross-Platform Scheduler and Analytics Dashboard",
|
||||
status: "idle",
|
||||
priority: "success",
|
||||
shortMessage:
|
||||
"All 12 scheduled posts across Twitter, LinkedIn, and Instagram were published successfully with engagement tracking enabled",
|
||||
},
|
||||
{
|
||||
id: "qa-4",
|
||||
agentID: "fake-4",
|
||||
name: "Customer Support Triage and Automatic Escalation Handler",
|
||||
status: "running",
|
||||
priority: "stale",
|
||||
shortMessage:
|
||||
"3 high-priority tickets awaiting classification — SLA breach warning for 2 enterprise accounts pending review",
|
||||
},
|
||||
];
|
||||
|
||||
export function usePulseChips(): PulseChipData[] {
|
||||
const { agents } = useLibraryAgents();
|
||||
|
||||
const sitrepItems = useSitrepItems(agents, 5);
|
||||
|
||||
return useMemo(() => {
|
||||
const real = sitrepItems.map((item) => ({
|
||||
id: item.id,
|
||||
agentID: item.agentID,
|
||||
name: item.agentName,
|
||||
status: item.status,
|
||||
priority: item.priority,
|
||||
shortMessage: item.message,
|
||||
}));
|
||||
return [...real, ...QA_FAKES];
|
||||
}, [sitrepItems]);
|
||||
}
|
||||
@@ -6,6 +6,9 @@ import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useResetRateLimit } from "../../hooks/useResetRateLimit";
|
||||
import { formatCents } from "../usageHelpers";
|
||||
|
||||
export { formatCents };
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
@@ -18,10 +21,6 @@ interface Props {
|
||||
onCreditChange?: () => void;
|
||||
}
|
||||
|
||||
export function formatCents(cents: number): string {
|
||||
return `$${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
export function RateLimitResetDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
|
||||
@@ -1,35 +1,10 @@
|
||||
import type { CoPilotUsageStatus } from "@/app/api/__generated__/models/coPilotUsageStatus";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import Link from "next/link";
|
||||
import { formatCents } from "../RateLimitResetDialog/RateLimitResetDialog";
|
||||
import { formatCents, formatResetTime } from "../usageHelpers";
|
||||
import { useResetRateLimit } from "../../hooks/useResetRateLimit";
|
||||
|
||||
export function formatResetTime(
|
||||
resetsAt: Date | string,
|
||||
now: Date = new Date(),
|
||||
): string {
|
||||
const resetDate =
|
||||
typeof resetsAt === "string" ? new Date(resetsAt) : resetsAt;
|
||||
const diffMs = resetDate.getTime() - now.getTime();
|
||||
if (diffMs <= 0) return "now";
|
||||
|
||||
const hours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
|
||||
// Under 24h: show relative time ("in 4h 23m")
|
||||
if (hours < 24) {
|
||||
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||
if (hours > 0) return `in ${hours}h ${minutes}m`;
|
||||
return `in ${minutes}m`;
|
||||
}
|
||||
|
||||
// Over 24h: show day and time in local timezone ("Mon 12:00 AM PST")
|
||||
return resetDate.toLocaleString(undefined, {
|
||||
weekday: "short",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
timeZoneName: "short",
|
||||
});
|
||||
}
|
||||
export { formatResetTime };
|
||||
|
||||
function UsageBar({
|
||||
label,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
export function formatCents(cents: number): string {
|
||||
return `$${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
export function formatResetTime(
|
||||
resetsAt: Date | string,
|
||||
now: Date = new Date(),
|
||||
): string {
|
||||
const resetDate =
|
||||
typeof resetsAt === "string" ? new Date(resetsAt) : resetsAt;
|
||||
const diffMs = resetDate.getTime() - now.getTime();
|
||||
if (diffMs <= 0) return "now";
|
||||
|
||||
const hours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
|
||||
if (hours < 24) {
|
||||
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||
if (hours > 0) return `in ${hours}h ${minutes}m`;
|
||||
return `in ${minutes}m`;
|
||||
}
|
||||
|
||||
return resetDate.toLocaleString(undefined, {
|
||||
weekday: "short",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
timeZoneName: "short",
|
||||
});
|
||||
}
|
||||
@@ -2,14 +2,17 @@ import { Navbar } from "@/components/layout/Navbar/Navbar";
|
||||
import { NetworkStatusMonitor } from "@/services/network-status/NetworkStatusMonitor";
|
||||
import { ReactNode } from "react";
|
||||
import { AdminImpersonationBanner } from "./admin/components/AdminImpersonationBanner";
|
||||
import { AutoPilotBridgeProvider } from "@/contexts/AutoPilotBridgeContext";
|
||||
|
||||
export default function PlatformLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<main className="flex h-screen w-full flex-col">
|
||||
<NetworkStatusMonitor />
|
||||
<Navbar />
|
||||
<AdminImpersonationBanner />
|
||||
<section className="flex-1">{children}</section>
|
||||
</main>
|
||||
<AutoPilotBridgeProvider>
|
||||
<main className="flex h-screen w-full flex-col">
|
||||
<NetworkStatusMonitor />
|
||||
<Navbar />
|
||||
<AdminImpersonationBanner />
|
||||
<section className="flex-1">{children}</section>
|
||||
</main>
|
||||
</AutoPilotBridgeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -137,8 +137,10 @@ describe("LibraryPage", () => {
|
||||
user_id: "test-user",
|
||||
name: "Work Agents",
|
||||
agent_count: 3,
|
||||
subfolder_count: 0,
|
||||
color: null,
|
||||
icon: null,
|
||||
parent_id: null,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
},
|
||||
@@ -147,8 +149,10 @@ describe("LibraryPage", () => {
|
||||
user_id: "test-user",
|
||||
name: "Personal",
|
||||
agent_count: 1,
|
||||
subfolder_count: 0,
|
||||
color: null,
|
||||
icon: null,
|
||||
parent_id: null,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
},
|
||||
@@ -158,12 +162,14 @@ describe("LibraryPage", () => {
|
||||
|
||||
render(<LibraryPage />);
|
||||
|
||||
await waitForAgentsToLoad();
|
||||
|
||||
expect(await screen.findByText("Work Agents")).toBeDefined();
|
||||
expect(screen.getByText("Personal")).toBeDefined();
|
||||
expect(screen.getAllByTestId("library-folder")).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("shows See runs link on agent card", async () => {
|
||||
test("shows See tasks link on agent card", async () => {
|
||||
setupHandlers({
|
||||
agents: [makeAgent({ name: "Linked Agent", can_access_graph: true })],
|
||||
});
|
||||
@@ -172,7 +178,7 @@ describe("LibraryPage", () => {
|
||||
|
||||
await screen.findByText("Linked Agent");
|
||||
|
||||
const runLinks = screen.getAllByText("See runs");
|
||||
const runLinks = screen.getAllByText("See tasks");
|
||||
expect(runLinks.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
@@ -190,7 +196,7 @@ describe("LibraryPage", () => {
|
||||
expect(importButtons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("renders Jump Back In when there is an active execution", async () => {
|
||||
test("renders running agent card when execution is active", async () => {
|
||||
const agent = makeAgent({
|
||||
id: "lib-1",
|
||||
graph_id: "g-1",
|
||||
@@ -218,6 +224,6 @@ describe("LibraryPage", () => {
|
||||
|
||||
render(<LibraryPage />);
|
||||
|
||||
expect(await screen.findByText("Jump Back In")).toBeDefined();
|
||||
expect(await screen.findByText("Running Agent")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
.glassPanel {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.glassPanel::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
padding: 1px;
|
||||
background: conic-gradient(
|
||||
from var(--border-angle, 0deg),
|
||||
rgba(129, 120, 228, 0.04),
|
||||
rgba(129, 120, 228, 0.14),
|
||||
rgba(168, 130, 255, 0.09),
|
||||
rgba(129, 120, 228, 0.04),
|
||||
rgba(99, 102, 241, 0.12),
|
||||
rgba(129, 120, 228, 0.04)
|
||||
);
|
||||
-webkit-mask:
|
||||
linear-gradient(#000 0 0) content-box,
|
||||
linear-gradient(#000 0 0);
|
||||
mask:
|
||||
linear-gradient(#000 0 0) content-box,
|
||||
linear-gradient(#000 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
animation: rotate-border 6s linear infinite;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
@property --border-angle {
|
||||
syntax: "<angle>";
|
||||
initial-value: 0deg;
|
||||
inherits: false;
|
||||
}
|
||||
|
||||
@keyframes rotate-border {
|
||||
to {
|
||||
--border-angle: 360deg;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { useState } from "react";
|
||||
import type { FleetSummary, AgentStatusFilter } from "../../types";
|
||||
import { BriefingTabContent } from "./BriefingTabContent";
|
||||
import { StatsGrid } from "./StatsGrid";
|
||||
import styles from "./AgentBriefingPanel.module.css";
|
||||
|
||||
interface Props {
|
||||
summary: FleetSummary;
|
||||
agents: LibraryAgent[];
|
||||
}
|
||||
|
||||
export function AgentBriefingPanel({ summary, agents }: Props) {
|
||||
const [userTab, setUserTab] = useState<AgentStatusFilter | null>(null);
|
||||
const activeTab: AgentStatusFilter =
|
||||
userTab ?? (summary.running > 0 ? "running" : "all");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.glassPanel} min-h-[14.75rem] rounded-large bg-gradient-to-br from-indigo-50/30 via-white/90 to-purple-50/25 px-5 pb-5 pt-[1.125rem] shadow-sm backdrop-blur-md`}
|
||||
>
|
||||
<Text variant="h5">Agent Briefing</Text>
|
||||
<div className="mt-4 space-y-5">
|
||||
<StatsGrid
|
||||
summary={summary}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setUserTab}
|
||||
/>
|
||||
<BriefingTabContent activeTab={activeTab} agents={agents} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
"use client";
|
||||
|
||||
import type { CoPilotUsageStatus } from "@/app/api/__generated__/models/coPilotUsageStatus";
|
||||
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { useGetV2GetCopilotUsage } from "@/app/api/__generated__/endpoints/chat/chat";
|
||||
import {
|
||||
formatResetTime,
|
||||
formatCents,
|
||||
} from "@/app/(platform)/copilot/components/usageHelpers";
|
||||
import { useResetRateLimit } from "@/app/(platform)/copilot/hooks/useResetRateLimit";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Badge } from "@/components/atoms/Badge/Badge";
|
||||
import useCredits from "@/hooks/useCredits";
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
import { useSitrepItems } from "../SitrepItem/useSitrepItems";
|
||||
import { SitrepItem } from "../SitrepItem/SitrepItem";
|
||||
import { useAgentStatusMap } from "../../hooks/useAgentStatus";
|
||||
import type { AgentStatusFilter } from "../../types";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
interface Props {
|
||||
activeTab: AgentStatusFilter;
|
||||
agents: LibraryAgent[];
|
||||
}
|
||||
|
||||
export function BriefingTabContent({ activeTab, agents }: Props) {
|
||||
if (activeTab === "all") {
|
||||
return <UsageSection />;
|
||||
}
|
||||
|
||||
if (
|
||||
activeTab === "running" ||
|
||||
activeTab === "attention" ||
|
||||
activeTab === "completed"
|
||||
) {
|
||||
return <ExecutionListSection activeTab={activeTab} agents={agents} />;
|
||||
}
|
||||
|
||||
return <AgentListSection activeTab={activeTab} agents={agents} />;
|
||||
}
|
||||
|
||||
function UsageSection() {
|
||||
const { data: usage } = useGetV2GetCopilotUsage({
|
||||
query: {
|
||||
select: (res) => res.data as CoPilotUsageStatus,
|
||||
refetchInterval: 30000,
|
||||
staleTime: 10000,
|
||||
},
|
||||
});
|
||||
|
||||
const isBillingEnabled = useGetFlag(Flag.ENABLE_PLATFORM_PAYMENT);
|
||||
const { credits, fetchCredits } = useCredits({ fetchInitialCredits: true });
|
||||
const resetCost = usage?.reset_cost;
|
||||
const hasInsufficientCredits =
|
||||
credits !== null && resetCost != null && credits < resetCost;
|
||||
|
||||
if (!usage?.daily || !usage?.weekly) return null;
|
||||
|
||||
return (
|
||||
<div className="py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Text variant="h5" className="text-neutral-800">
|
||||
Usage limits
|
||||
</Text>
|
||||
{usage.tier && (
|
||||
<Badge variant="info" size="small" className="bg-[rgb(224,237,255)]">
|
||||
{usage.tier.charAt(0) + usage.tier.slice(1).toLowerCase()} plan
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{isBillingEnabled && (
|
||||
<Link
|
||||
href="/profile/credits"
|
||||
className="text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
Manage billing
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
{usage.daily.limit > 0 && (
|
||||
<UsageMeter
|
||||
label="Today"
|
||||
used={usage.daily.used}
|
||||
limit={usage.daily.limit}
|
||||
resetsAt={usage.daily.resets_at}
|
||||
/>
|
||||
)}
|
||||
{usage.weekly.limit > 0 && (
|
||||
<UsageMeter
|
||||
label="This week"
|
||||
used={usage.weekly.used}
|
||||
limit={usage.weekly.limit}
|
||||
resetsAt={usage.weekly.resets_at}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<UsageFooter
|
||||
usage={usage}
|
||||
hasInsufficientCredits={hasInsufficientCredits}
|
||||
onCreditChange={fetchCredits}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const MAX_VISIBLE = 6;
|
||||
|
||||
function ExecutionListSection({
|
||||
activeTab,
|
||||
agents,
|
||||
}: {
|
||||
activeTab: AgentStatusFilter;
|
||||
agents: LibraryAgent[];
|
||||
}) {
|
||||
const allItems = useSitrepItems(agents, 50);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
const filtered = allItems.filter((item) => {
|
||||
if (activeTab === "running") return item.priority === "running";
|
||||
if (activeTab === "attention") return item.priority === "error";
|
||||
if (activeTab === "completed") return item.priority === "success";
|
||||
return false;
|
||||
});
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return <EmptyMessage tab={activeTab} />;
|
||||
}
|
||||
|
||||
const visible = showAll ? filtered : filtered.slice(0, MAX_VISIBLE);
|
||||
const hasMore = filtered.length > MAX_VISIBLE;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
{visible.map((item) => (
|
||||
<SitrepItem key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
{hasMore && (
|
||||
<div className="mt-3 flex justify-center">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={() => setShowAll(!showAll)}
|
||||
>
|
||||
{showAll ? "Collapse" : `Show all (${filtered.length})`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TAB_STATUS_LABEL: Record<string, string> = {
|
||||
listening: "Waiting for trigger event",
|
||||
scheduled: "Has a scheduled run",
|
||||
idle: "No recent activity",
|
||||
};
|
||||
|
||||
function AgentListSection({
|
||||
activeTab,
|
||||
agents,
|
||||
}: {
|
||||
activeTab: AgentStatusFilter;
|
||||
agents: LibraryAgent[];
|
||||
}) {
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const statusMap = useAgentStatusMap(agents);
|
||||
|
||||
const filtered = agents.filter((agent) => {
|
||||
const status = statusMap.get(agent.graph_id)?.status;
|
||||
if (activeTab === "listening") return status === "listening";
|
||||
if (activeTab === "scheduled") return status === "scheduled";
|
||||
if (activeTab === "idle") return status === "idle";
|
||||
return false;
|
||||
});
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return <EmptyMessage tab={activeTab} />;
|
||||
}
|
||||
|
||||
const status =
|
||||
activeTab === "listening"
|
||||
? ("listening" as const)
|
||||
: activeTab === "scheduled"
|
||||
? ("scheduled" as const)
|
||||
: ("idle" as const);
|
||||
|
||||
const visible = showAll ? filtered : filtered.slice(0, MAX_VISIBLE);
|
||||
const hasMore = filtered.length > MAX_VISIBLE;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
{visible.map((agent) => (
|
||||
<SitrepItem
|
||||
key={agent.id}
|
||||
item={{
|
||||
id: agent.id,
|
||||
agentID: agent.id,
|
||||
agentName: agent.name,
|
||||
agentImageUrl: agent.image_url,
|
||||
priority: status,
|
||||
message: TAB_STATUS_LABEL[activeTab] ?? "",
|
||||
status,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{hasMore && (
|
||||
<div className="mt-3 flex justify-center">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={() => setShowAll(!showAll)}
|
||||
>
|
||||
{showAll ? "Collapse" : `Show all (${filtered.length})`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UsageFooter({
|
||||
usage,
|
||||
hasInsufficientCredits,
|
||||
onCreditChange,
|
||||
}: {
|
||||
usage: CoPilotUsageStatus;
|
||||
hasInsufficientCredits: boolean;
|
||||
onCreditChange?: () => void;
|
||||
}) {
|
||||
const isDailyExhausted =
|
||||
usage.daily.limit > 0 && usage.daily.used >= usage.daily.limit;
|
||||
const isWeeklyExhausted =
|
||||
usage.weekly.limit > 0 && usage.weekly.used >= usage.weekly.limit;
|
||||
const resetCost = usage.reset_cost ?? 0;
|
||||
const { resetUsage, isPending } = useResetRateLimit({ onCreditChange });
|
||||
|
||||
const showReset =
|
||||
isDailyExhausted &&
|
||||
!isWeeklyExhausted &&
|
||||
resetCost > 0 &&
|
||||
!hasInsufficientCredits;
|
||||
|
||||
const showAddCredits =
|
||||
isDailyExhausted && !isWeeklyExhausted && hasInsufficientCredits;
|
||||
|
||||
if (!showReset && !showAddCredits) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
{showReset && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
onClick={() => resetUsage()}
|
||||
loading={isPending}
|
||||
>
|
||||
{isPending
|
||||
? "Resetting..."
|
||||
: `Reset daily limit for ${formatCents(resetCost)}`}
|
||||
</Button>
|
||||
)}
|
||||
{showAddCredits && (
|
||||
<Link
|
||||
href="/profile/credits"
|
||||
className="inline-flex items-center justify-center rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Add credits to reset
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UsageMeter({
|
||||
label,
|
||||
used,
|
||||
limit,
|
||||
resetsAt,
|
||||
}: {
|
||||
label: string;
|
||||
used: number;
|
||||
limit: number;
|
||||
resetsAt: Date | string;
|
||||
}) {
|
||||
if (limit <= 0) return null;
|
||||
|
||||
const rawPercent = (used / limit) * 100;
|
||||
const percent = Math.min(100, Math.round(rawPercent));
|
||||
const isHigh = percent >= 80;
|
||||
const percentLabel =
|
||||
used > 0 && percent === 0 ? "<1% used" : `${percent}% used`;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<Text variant="body-medium" className="text-neutral-700">
|
||||
{label}
|
||||
</Text>
|
||||
<Text variant="body" className="tabular-nums text-neutral-500">
|
||||
{percentLabel}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-neutral-200">
|
||||
<div
|
||||
className={`h-full rounded-full transition-[width] duration-300 ease-out ${
|
||||
isHigh ? "bg-orange-500" : "bg-blue-500"
|
||||
}`}
|
||||
style={{ width: `${Math.max(used > 0 ? 1 : 0, percent)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<Text variant="small" className="tabular-nums text-neutral-500">
|
||||
{used.toLocaleString()} / {limit.toLocaleString()}
|
||||
</Text>
|
||||
<Text variant="small" className="text-neutral-400">
|
||||
Resets {formatResetTime(resetsAt)}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const EMPTY_MESSAGES: Record<string, string> = {
|
||||
running: "No agents running right now",
|
||||
attention: "No agents that need attention",
|
||||
completed: "No recently completed runs",
|
||||
listening: "No agents listening for events",
|
||||
scheduled: "No agents with scheduled runs",
|
||||
idle: "No idle agents",
|
||||
};
|
||||
|
||||
function EmptyMessage({ tab }: { tab: AgentStatusFilter }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center pt-4">
|
||||
<Text variant="body-medium" className="text-zinc-600">
|
||||
{EMPTY_MESSAGES[tab] ?? "No agents in this category"}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { OverflowText } from "@/components/atoms/OverflowText/OverflowText";
|
||||
import { Emoji } from "@/components/atoms/Emoji/Emoji";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { FleetSummary, AgentStatusFilter } from "../../types";
|
||||
|
||||
interface Props {
|
||||
summary: FleetSummary;
|
||||
activeTab: AgentStatusFilter;
|
||||
onTabChange: (tab: AgentStatusFilter) => void;
|
||||
}
|
||||
|
||||
const TILES: {
|
||||
label: string;
|
||||
key: keyof FleetSummary;
|
||||
format?: (v: number) => string;
|
||||
filter: AgentStatusFilter;
|
||||
emoji: string;
|
||||
color: string;
|
||||
}[] = [
|
||||
{
|
||||
label: "Spent this month",
|
||||
key: "monthlySpend",
|
||||
format: (v) => `$${v.toLocaleString()}`,
|
||||
filter: "all",
|
||||
emoji: "💵",
|
||||
color: "text-zinc-700",
|
||||
},
|
||||
{
|
||||
label: "Running now",
|
||||
key: "running",
|
||||
filter: "running",
|
||||
emoji: "🚩",
|
||||
color: "text-blue-600",
|
||||
},
|
||||
{
|
||||
label: "Recently completed",
|
||||
key: "completed",
|
||||
filter: "completed",
|
||||
emoji: "🗃️",
|
||||
color: "text-green-600",
|
||||
},
|
||||
{
|
||||
label: "Needs attention",
|
||||
key: "error",
|
||||
filter: "attention",
|
||||
emoji: "⚠️",
|
||||
color: "text-red-500",
|
||||
},
|
||||
{
|
||||
label: "Scheduled",
|
||||
key: "scheduled",
|
||||
filter: "scheduled",
|
||||
emoji: "📅",
|
||||
color: "text-yellow-600",
|
||||
},
|
||||
{
|
||||
label: "Idle",
|
||||
key: "idle",
|
||||
filter: "idle",
|
||||
emoji: "💤",
|
||||
color: "text-zinc-400",
|
||||
},
|
||||
];
|
||||
|
||||
export function StatsGrid({ summary, activeTab, onTabChange }: Props) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 min-[450px]:grid-cols-2 sm:grid-cols-3 lg:grid-cols-6">
|
||||
{TILES.map((tile) => {
|
||||
const rawValue = summary[tile.key];
|
||||
const value = tile.format ? tile.format(rawValue) : rawValue;
|
||||
const isActive = activeTab === tile.filter;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tile.label}
|
||||
type="button"
|
||||
onClick={() => onTabChange(tile.filter)}
|
||||
className={cn(
|
||||
"flex min-w-0 flex-col gap-1 rounded-medium border p-3 text-left shadow-md transition-all hover:shadow-lg",
|
||||
isActive
|
||||
? "border-zinc-900 bg-zinc-50"
|
||||
: "border-zinc-100 bg-white",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<Emoji text={tile.emoji} size={18} />
|
||||
<OverflowText
|
||||
value={tile.label}
|
||||
variant="body"
|
||||
className="text-zinc-800"
|
||||
/>
|
||||
</div>
|
||||
<Text variant="h4">{value}</Text>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import type { SelectOption } from "@/components/atoms/Select/Select";
|
||||
import { Select } from "@/components/atoms/Select/Select";
|
||||
import { FunnelIcon } from "@phosphor-icons/react";
|
||||
import type { AgentStatusFilter, FleetSummary } from "../../types";
|
||||
|
||||
interface Props {
|
||||
value: AgentStatusFilter;
|
||||
onChange: (value: AgentStatusFilter) => void;
|
||||
summary: FleetSummary;
|
||||
}
|
||||
|
||||
function buildOptions(summary: FleetSummary): SelectOption[] {
|
||||
return [
|
||||
{ value: "all", label: "All Agents" },
|
||||
{ value: "running", label: `Running (${summary.running})` },
|
||||
{ value: "attention", label: `Needs Attention (${summary.error})` },
|
||||
{ value: "listening", label: `Listening (${summary.listening})` },
|
||||
{ value: "scheduled", label: `Scheduled (${summary.scheduled})` },
|
||||
{ value: "idle", label: `Idle / Stale (${summary.idle})` },
|
||||
{ value: "healthy", label: "Healthy" },
|
||||
];
|
||||
}
|
||||
|
||||
export function AgentFilterMenu({ value, onChange, summary }: Props) {
|
||||
function handleChange(val: string) {
|
||||
onChange(val as AgentStatusFilter);
|
||||
}
|
||||
|
||||
const options = buildOptions(summary);
|
||||
|
||||
return (
|
||||
<div className="flex items-center" data-testid="agent-filter-dropdown">
|
||||
<span className="hidden whitespace-nowrap text-sm text-zinc-500 sm:inline">
|
||||
filter
|
||||
</span>
|
||||
<FunnelIcon className="ml-1 h-4 w-4 sm:hidden" />
|
||||
<Select
|
||||
id="agent-status-filter"
|
||||
label="Filter agents"
|
||||
hideLabel
|
||||
value={value}
|
||||
onValueChange={handleChange}
|
||||
options={options}
|
||||
size="small"
|
||||
className="ml-1 w-fit border-none !bg-transparent text-sm underline underline-offset-4 shadow-none"
|
||||
wrapperClassName="mb-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
EyeIcon,
|
||||
ArrowsClockwiseIcon,
|
||||
MonitorPlayIcon,
|
||||
PlayIcon,
|
||||
ArrowCounterClockwiseIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { AgentStatus } from "../../types";
|
||||
|
||||
interface Props {
|
||||
status: AgentStatus;
|
||||
agentID: string;
|
||||
executionID?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ContextualActionButton({
|
||||
status,
|
||||
agentID,
|
||||
executionID,
|
||||
className,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
const config = ACTION_CONFIG[status];
|
||||
if (!config) return null;
|
||||
|
||||
const Icon = config.icon;
|
||||
|
||||
function handleClick(e: React.MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (executionID) params.set("activeItem", executionID);
|
||||
const query = params.toString();
|
||||
router.push(`/library/agents/${agentID}${query ? `?${query}` : ""}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-[13px] font-medium text-zinc-600 transition-colors hover:bg-zinc-50 hover:text-zinc-800",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Icon size={12} className="shrink-0" />
|
||||
{config.label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const ACTION_CONFIG: Record<
|
||||
AgentStatus,
|
||||
{ label: string; icon: typeof EyeIcon }
|
||||
> = {
|
||||
error: { label: "View error", icon: EyeIcon },
|
||||
listening: { label: "Reconnect", icon: ArrowsClockwiseIcon },
|
||||
running: { label: "Watch live", icon: MonitorPlayIcon },
|
||||
idle: { label: "Start", icon: PlayIcon },
|
||||
scheduled: { label: "Start", icon: ArrowCounterClockwiseIcon },
|
||||
};
|
||||
@@ -1,46 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowRight, Lightning } from "@phosphor-icons/react";
|
||||
import NextLink from "next/link";
|
||||
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { useJumpBackIn } from "./useJumpBackIn";
|
||||
|
||||
export function JumpBackIn() {
|
||||
const { execution, isLoading } = useJumpBackIn();
|
||||
|
||||
if (isLoading || !execution) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const href = execution.libraryAgentId
|
||||
? `/library/agents/${execution.libraryAgentId}?activeTab=runs&activeItem=${execution.id}`
|
||||
: "#";
|
||||
|
||||
return (
|
||||
<div className="rounded-large bg-gradient-to-r from-zinc-200 via-zinc-200/60 to-indigo-200/50 p-[1px]">
|
||||
<div className="flex items-center justify-between rounded-large bg-[#F6F7F8] px-5 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-zinc-900">
|
||||
<Lightning size={18} weight="fill" className="text-white" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<Text variant="small" className="text-zinc-500">
|
||||
{execution.statusLabel} · {execution.duration}
|
||||
</Text>
|
||||
<Text variant="body-medium" className="text-zinc-900">
|
||||
{execution.agentName}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<NextLink href={href}>
|
||||
<Button variant="secondary" size="small" className="gap-1.5">
|
||||
Jump Back In
|
||||
<ArrowRight size={16} />
|
||||
</Button>
|
||||
</NextLink>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useGetV1ListAllExecutions } from "@/app/api/__generated__/endpoints/graphs/graphs";
|
||||
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
|
||||
import { okData } from "@/app/api/helpers";
|
||||
import { useLibraryAgents } from "@/hooks/useLibraryAgents/useLibraryAgents";
|
||||
import { useMemo } from "react";
|
||||
|
||||
function isActive(status: AgentExecutionStatus) {
|
||||
return (
|
||||
status === AgentExecutionStatus.RUNNING ||
|
||||
status === AgentExecutionStatus.QUEUED ||
|
||||
status === AgentExecutionStatus.REVIEW
|
||||
);
|
||||
}
|
||||
|
||||
function formatDuration(startedAt: Date | string | null | undefined): string {
|
||||
if (!startedAt) return "";
|
||||
|
||||
const start = new Date(startedAt);
|
||||
if (isNaN(start.getTime())) return "";
|
||||
|
||||
const ms = Date.now() - start.getTime();
|
||||
if (ms < 0) return "";
|
||||
|
||||
const sec = Math.floor(ms / 1000);
|
||||
if (sec < 5) return "a few seconds";
|
||||
if (sec < 60) return `${sec}s`;
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return `${min}m ${sec % 60}s`;
|
||||
const hr = Math.floor(min / 60);
|
||||
return `${hr}h ${min % 60}m`;
|
||||
}
|
||||
|
||||
function getStatusLabel(status: AgentExecutionStatus) {
|
||||
if (status === AgentExecutionStatus.RUNNING) return "Running";
|
||||
if (status === AgentExecutionStatus.QUEUED) return "Queued";
|
||||
if (status === AgentExecutionStatus.REVIEW) return "Awaiting approval";
|
||||
return "";
|
||||
}
|
||||
|
||||
export function useJumpBackIn() {
|
||||
const { data: executions, isLoading: executionsLoading } =
|
||||
useGetV1ListAllExecutions({
|
||||
query: { select: okData },
|
||||
});
|
||||
|
||||
const { agentInfoMap, isRefreshing: agentsLoading } = useLibraryAgents();
|
||||
|
||||
const activeExecution = useMemo(() => {
|
||||
if (!executions) return null;
|
||||
|
||||
const active = executions
|
||||
.filter((e) => isActive(e.status))
|
||||
.sort((a, b) => {
|
||||
const aTime = a.started_at ? new Date(a.started_at).getTime() : 0;
|
||||
const bTime = b.started_at ? new Date(b.started_at).getTime() : 0;
|
||||
return bTime - aTime;
|
||||
});
|
||||
|
||||
return active[0] ?? null;
|
||||
}, [executions]);
|
||||
|
||||
const enriched = useMemo(() => {
|
||||
if (!activeExecution) return null;
|
||||
|
||||
const info = agentInfoMap.get(activeExecution.graph_id);
|
||||
return {
|
||||
id: activeExecution.id,
|
||||
agentName: info?.name ?? "Unknown Agent",
|
||||
libraryAgentId: info?.library_agent_id,
|
||||
status: activeExecution.status,
|
||||
statusLabel: getStatusLabel(activeExecution.status),
|
||||
duration: formatDuration(activeExecution.started_at),
|
||||
};
|
||||
}, [activeExecution, agentInfoMap]);
|
||||
|
||||
return {
|
||||
execution: enriched,
|
||||
isLoading: executionsLoading || agentsLoading,
|
||||
};
|
||||
}
|
||||
@@ -8,7 +8,7 @@ interface Props {
|
||||
export function LibraryActionHeader({ setSearchTerm }: Props) {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-[32px] hidden items-center justify-center gap-4 md:flex">
|
||||
<div className="mb-7 hidden items-center justify-center gap-4 md:flex">
|
||||
<LibrarySearchBar setSearchTerm={setSearchTerm} />
|
||||
<LibraryImportDialog />
|
||||
</div>
|
||||
|
||||
@@ -1,29 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { CaretCircleRightIcon } from "@phosphor-icons/react";
|
||||
import { EyeIcon, ChatCircleDotsIcon } from "@phosphor-icons/react";
|
||||
import Image from "next/image";
|
||||
import NextLink from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import Avatar, {
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@/components/atoms/Avatar/Avatar";
|
||||
import { Link } from "@/components/atoms/Link/Link";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AgentCardMenu } from "./components/AgentCardMenu";
|
||||
import { FavoriteButton } from "./components/FavoriteButton";
|
||||
import { useLibraryAgentCard } from "./useLibraryAgentCard";
|
||||
import { useFavoriteAnimation } from "../../context/FavoriteAnimationContext";
|
||||
import { StatusBadge } from "../StatusBadge/StatusBadge";
|
||||
import { ContextualActionButton } from "../ContextualActionButton/ContextualActionButton";
|
||||
import type { AgentStatusInfo } from "../../types";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
interface Props {
|
||||
agent: LibraryAgent;
|
||||
statusInfo: AgentStatusInfo;
|
||||
draggable?: boolean;
|
||||
}
|
||||
|
||||
export function LibraryAgentCard({ agent, draggable = true }: Props) {
|
||||
const { id, name, graph_id, can_access_graph, image_url } = agent;
|
||||
export function LibraryAgentCard({
|
||||
agent,
|
||||
statusInfo,
|
||||
draggable = true,
|
||||
}: Props) {
|
||||
const { id, name, image_url } = agent;
|
||||
const router = useRouter();
|
||||
const { triggerFavoriteAnimation } = useFavoriteAnimation();
|
||||
|
||||
function handleDragStart(e: React.DragEvent<HTMLDivElement>) {
|
||||
@@ -31,18 +42,14 @@ export function LibraryAgentCard({ agent, draggable = true }: Props) {
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}
|
||||
|
||||
const {
|
||||
isFromMarketplace,
|
||||
isFavorite,
|
||||
profile,
|
||||
creator_image_url,
|
||||
handleToggleFavorite,
|
||||
} = useLibraryAgentCard({
|
||||
const { isFavorite, handleToggleFavorite } = useLibraryAgentCard({
|
||||
agent,
|
||||
onFavoriteAdd: triggerFavoriteAnimation,
|
||||
});
|
||||
|
||||
return (
|
||||
const hasError = statusInfo.status === "error";
|
||||
|
||||
const card = (
|
||||
<div
|
||||
draggable={draggable}
|
||||
onDragStart={handleDragStart}
|
||||
@@ -52,7 +59,10 @@ export function LibraryAgentCard({ agent, draggable = true }: Props) {
|
||||
layoutId={`agent-card-${id}`}
|
||||
data-testid="library-agent-card"
|
||||
data-agent-id={id}
|
||||
className="group relative inline-flex h-[10.625rem] w-full max-w-[25rem] flex-col items-start justify-start gap-2.5 rounded-medium border border-zinc-100 bg-white hover:shadow-md"
|
||||
className={cn(
|
||||
"group relative inline-flex h-auto min-h-[10.625rem] w-full max-w-[25rem] flex-col items-start justify-start gap-2.5 rounded-medium border bg-white hover:shadow-md",
|
||||
hasError ? "border-red-400" : "border-zinc-100",
|
||||
)}
|
||||
transition={{
|
||||
type: "spring",
|
||||
damping: 25,
|
||||
@@ -61,23 +71,10 @@ export function LibraryAgentCard({ agent, draggable = true }: Props) {
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
<NextLink href={`/library/agents/${id}`} className="flex-shrink-0">
|
||||
<div className="relative flex items-center gap-2 px-4 pt-3">
|
||||
<Avatar className="h-4 w-4 rounded-full">
|
||||
<AvatarImage
|
||||
src={
|
||||
isFromMarketplace
|
||||
? creator_image_url || "/avatar-placeholder.png"
|
||||
: profile?.avatar_url || "/avatar-placeholder.png"
|
||||
}
|
||||
alt={`${name} creator avatar`}
|
||||
/>
|
||||
<AvatarFallback size={48}>{name.charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<Text
|
||||
variant="small-medium"
|
||||
className="uppercase tracking-wide text-zinc-400"
|
||||
>
|
||||
{isFromMarketplace ? "FROM MARKETPLACE" : "Built by you"}
|
||||
<div className="relative flex items-center gap-3 pl-2 pr-4 pt-3">
|
||||
<StatusBadge status={statusInfo.status} />
|
||||
<Text variant="small" className="text-zinc-400">
|
||||
{statusInfo.totalRuns} tasks
|
||||
</Text>
|
||||
</div>
|
||||
</NextLink>
|
||||
@@ -89,7 +86,7 @@ export function LibraryAgentCard({ agent, draggable = true }: Props) {
|
||||
<AgentCardMenu agent={agent} />
|
||||
|
||||
<div className="flex w-full flex-1 flex-col px-4 pb-2">
|
||||
<Link
|
||||
<NextLink
|
||||
href={`/library/agents/${id}`}
|
||||
className="flex w-full items-start justify-between gap-2 no-underline hover:no-underline focus:ring-0"
|
||||
>
|
||||
@@ -126,30 +123,52 @@ export function LibraryAgentCard({ agent, draggable = true }: Props) {
|
||||
className="flex-shrink-0 rounded-small object-cover"
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
</NextLink>
|
||||
|
||||
<div className="mt-auto flex w-full justify-start gap-6 border-t border-zinc-100 pb-1 pt-3">
|
||||
<Link
|
||||
href={`/library/agents/${id}`}
|
||||
<div className="mt-4 flex w-full items-center justify-end gap-1 border-t border-zinc-100 pb-0 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push(`/library/agents/${id}`)}
|
||||
data-testid="library-agent-card-see-runs-link"
|
||||
className="flex items-center gap-1 text-[13px]"
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-[13px] font-medium text-zinc-600 transition-colors hover:bg-zinc-50 hover:text-zinc-800"
|
||||
>
|
||||
See runs <CaretCircleRightIcon size={20} />
|
||||
</Link>
|
||||
|
||||
{can_access_graph && (
|
||||
<Link
|
||||
href={`/build?flowID=${graph_id}`}
|
||||
data-testid="library-agent-card-open-in-builder-link"
|
||||
className="flex items-center gap-1 text-[13px]"
|
||||
isExternal
|
||||
>
|
||||
Open in builder <CaretCircleRightIcon size={20} />
|
||||
</Link>
|
||||
)}
|
||||
<EyeIcon size={14} className="shrink-0" />
|
||||
See tasks
|
||||
</button>
|
||||
<ContextualActionButton
|
||||
status={statusInfo.status}
|
||||
agentID={id}
|
||||
executionID={statusInfo.activeExecutionID ?? undefined}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const prompt = encodeURIComponent(
|
||||
`Tell me about ${name}, its current status, recent runs and how can I get the most out of it`,
|
||||
);
|
||||
router.push(`/copilot?autosubmit=true#prompt=${prompt}`);
|
||||
}}
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-[13px] font-medium text-zinc-600 transition-colors hover:bg-zinc-50 hover:text-zinc-800"
|
||||
>
|
||||
<ChatCircleDotsIcon size={14} className="shrink-0" />
|
||||
Chat
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (hasError && statusInfo.lastError) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{card}</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs text-red-600">
|
||||
{statusInfo.lastError}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
@@ -169,6 +169,7 @@ export function AgentCardMenu({ agent }: AgentCardMenuProps) {
|
||||
href={`/build?flowID=${agent.graph_id}&flowVersion=${agent.graph_version}`}
|
||||
target="_blank"
|
||||
className="flex items-center gap-2"
|
||||
data-testid="library-agent-card-open-in-builder-link"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Edit agent
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { LibraryAgentSort } from "@/app/api/__generated__/models/libraryAgentSort";
|
||||
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
|
||||
import { InfiniteScroll } from "@/components/contextual/InfiniteScroll/InfiniteScroll";
|
||||
import { LibraryAgentCard } from "../LibraryAgentCard/LibraryAgentCard";
|
||||
@@ -16,8 +17,11 @@ import {
|
||||
} from "framer-motion";
|
||||
import { LibraryFolderEditDialog } from "../LibraryFolderEditDialog/LibraryFolderEditDialog";
|
||||
import { LibraryFolderDeleteDialog } from "../LibraryFolderDeleteDialog/LibraryFolderDeleteDialog";
|
||||
import { LibraryTab } from "../../types";
|
||||
import type { LibraryTab, AgentStatusFilter, FleetSummary } from "../../types";
|
||||
import { useLibraryAgentList } from "./useLibraryAgentList";
|
||||
import { AgentBriefingPanel } from "../AgentBriefingPanel/AgentBriefingPanel";
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
import { useAgentStatusMap, getAgentStatus } from "../../hooks/useAgentStatus";
|
||||
|
||||
// cancels the current spring and starts a new one from current state.
|
||||
const containerVariants = {
|
||||
@@ -70,6 +74,10 @@ interface Props {
|
||||
tabs: LibraryTab[];
|
||||
activeTab: string;
|
||||
onTabChange: (tabId: string) => void;
|
||||
statusFilter?: AgentStatusFilter;
|
||||
onStatusFilterChange?: (filter: AgentStatusFilter) => void;
|
||||
fleetSummary?: FleetSummary;
|
||||
briefingAgents?: LibraryAgent[];
|
||||
}
|
||||
|
||||
export function LibraryAgentList({
|
||||
@@ -81,7 +89,12 @@ export function LibraryAgentList({
|
||||
tabs,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
statusFilter = "all",
|
||||
onStatusFilterChange,
|
||||
fleetSummary,
|
||||
briefingAgents,
|
||||
}: Props) {
|
||||
const isAgentBriefingEnabled = useGetFlag(Flag.AGENT_BRIEFING);
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const activeContainerVariants = shouldReduceMotion
|
||||
? reducedContainerVariants
|
||||
@@ -95,7 +108,7 @@ export function LibraryAgentList({
|
||||
const {
|
||||
isFavoritesTab,
|
||||
agentLoading,
|
||||
allAgentsCount,
|
||||
displayedCount,
|
||||
favoritesCount,
|
||||
agents,
|
||||
hasNextPage,
|
||||
@@ -116,18 +129,37 @@ export function LibraryAgentList({
|
||||
selectedFolderId,
|
||||
onFolderSelect,
|
||||
activeTab,
|
||||
statusFilter,
|
||||
});
|
||||
|
||||
const agentStatusMap = useAgentStatusMap(agents);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isAgentBriefingEnabled &&
|
||||
!selectedFolderId &&
|
||||
fleetSummary &&
|
||||
briefingAgents &&
|
||||
briefingAgents.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<AgentBriefingPanel
|
||||
summary={fleetSummary}
|
||||
agents={briefingAgents}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!selectedFolderId && (
|
||||
<LibrarySubSection
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={onTabChange}
|
||||
allCount={allAgentsCount}
|
||||
allCount={displayedCount}
|
||||
favoritesCount={favoritesCount}
|
||||
setLibrarySort={setLibrarySort}
|
||||
statusFilter={statusFilter}
|
||||
onStatusFilterChange={onStatusFilterChange}
|
||||
fleetSummary={fleetSummary}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -219,7 +251,13 @@ export function LibraryAgentList({
|
||||
0.04,
|
||||
}}
|
||||
>
|
||||
<LibraryAgentCard agent={agent} />
|
||||
<LibraryAgentCard
|
||||
agent={agent}
|
||||
statusInfo={getAgentStatus(
|
||||
agentStatusMap,
|
||||
agent.graph_id,
|
||||
)}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
@@ -21,7 +21,12 @@ import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { useFavoriteAgents } from "../../hooks/useFavoriteAgents";
|
||||
import { getQueryClient } from "@/lib/react-query/queryClient";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { AgentStatusFilter } from "../../types";
|
||||
import { useGetV1ListAllExecutions } from "@/app/api/__generated__/endpoints/graphs/graphs";
|
||||
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
|
||||
|
||||
const FILTER_EXHAUST_THRESHOLD = 3;
|
||||
|
||||
interface Props {
|
||||
searchTerm: string;
|
||||
@@ -29,6 +34,7 @@ interface Props {
|
||||
selectedFolderId: string | null;
|
||||
onFolderSelect: (folderId: string | null) => void;
|
||||
activeTab: string;
|
||||
statusFilter?: AgentStatusFilter;
|
||||
}
|
||||
|
||||
export function useLibraryAgentList({
|
||||
@@ -37,12 +43,16 @@ export function useLibraryAgentList({
|
||||
selectedFolderId,
|
||||
onFolderSelect,
|
||||
activeTab,
|
||||
statusFilter = "all",
|
||||
}: Props) {
|
||||
const isFavoritesTab = activeTab === "favorites";
|
||||
const { toast } = useToast();
|
||||
const stableQueryClient = getQueryClient();
|
||||
const queryClient = useQueryClient();
|
||||
const prevSortRef = useRef<LibraryAgentSort | null>(null);
|
||||
const [consecutiveEmptyPages, setConsecutiveEmptyPages] = useState(0);
|
||||
const prevFilteredLengthRef = useRef(0);
|
||||
const prevAgentsLengthRef = useRef(0);
|
||||
|
||||
const [editingFolder, setEditingFolder] = useState<LibraryFolder | null>(
|
||||
null,
|
||||
@@ -199,6 +209,90 @@ export function useLibraryAgentList({
|
||||
|
||||
const showFolders = !isFavoritesTab;
|
||||
|
||||
const { data: executions } = useGetV1ListAllExecutions({
|
||||
query: { select: okData },
|
||||
});
|
||||
|
||||
const { activeGraphIds, errorGraphIds, completedGraphIds } = useMemo(() => {
|
||||
const active = new Set<string>();
|
||||
const errors = new Set<string>();
|
||||
const completed = new Set<string>();
|
||||
const cutoff = Date.now() - 72 * 60 * 60 * 1000;
|
||||
for (const exec of executions ?? []) {
|
||||
if (
|
||||
exec.status === AgentExecutionStatus.RUNNING ||
|
||||
exec.status === AgentExecutionStatus.QUEUED ||
|
||||
exec.status === AgentExecutionStatus.REVIEW
|
||||
) {
|
||||
active.add(exec.graph_id);
|
||||
}
|
||||
const endedTs = exec.ended_at
|
||||
? exec.ended_at instanceof Date
|
||||
? exec.ended_at.getTime()
|
||||
: new Date(String(exec.ended_at)).getTime()
|
||||
: 0;
|
||||
if (
|
||||
(exec.status === AgentExecutionStatus.FAILED ||
|
||||
exec.status === AgentExecutionStatus.TERMINATED) &&
|
||||
endedTs > cutoff
|
||||
) {
|
||||
errors.add(exec.graph_id);
|
||||
}
|
||||
if (exec.status === AgentExecutionStatus.COMPLETED && endedTs > cutoff) {
|
||||
completed.add(exec.graph_id);
|
||||
}
|
||||
}
|
||||
return {
|
||||
activeGraphIds: active,
|
||||
errorGraphIds: errors,
|
||||
completedGraphIds: completed,
|
||||
};
|
||||
}, [executions]);
|
||||
|
||||
const filteredAgents = filterAgentsByStatus(
|
||||
agents,
|
||||
statusFilter,
|
||||
activeGraphIds,
|
||||
errorGraphIds,
|
||||
completedGraphIds,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (statusFilter === "all") {
|
||||
setConsecutiveEmptyPages(0);
|
||||
prevFilteredLengthRef.current = filteredAgents.length;
|
||||
prevAgentsLengthRef.current = agents.length;
|
||||
return;
|
||||
}
|
||||
|
||||
if (agents.length > prevAgentsLengthRef.current) {
|
||||
const newFilteredCount = filteredAgents.length;
|
||||
const previousCount = prevFilteredLengthRef.current;
|
||||
|
||||
if (newFilteredCount > previousCount) {
|
||||
setConsecutiveEmptyPages(0);
|
||||
} else {
|
||||
setConsecutiveEmptyPages((prev) => prev + 1);
|
||||
}
|
||||
}
|
||||
|
||||
prevAgentsLengthRef.current = agents.length;
|
||||
prevFilteredLengthRef.current = filteredAgents.length;
|
||||
}, [agents.length, filteredAgents.length, statusFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setConsecutiveEmptyPages(0);
|
||||
prevFilteredLengthRef.current = 0;
|
||||
prevAgentsLengthRef.current = 0;
|
||||
}, [statusFilter]);
|
||||
|
||||
const filteredExhausted =
|
||||
statusFilter !== "all" && consecutiveEmptyPages >= FILTER_EXHAUST_THRESHOLD;
|
||||
|
||||
// When a filter is active, show the filtered count instead of the API total.
|
||||
const displayedCount =
|
||||
statusFilter === "all" ? allAgentsCount : filteredAgents.length;
|
||||
|
||||
function handleFolderDeleted() {
|
||||
if (selectedFolderId === deletingFolder?.id) {
|
||||
onFolderSelect(null);
|
||||
@@ -210,9 +304,10 @@ export function useLibraryAgentList({
|
||||
agentLoading,
|
||||
agentCount,
|
||||
allAgentsCount,
|
||||
displayedCount,
|
||||
favoritesCount: favoriteAgentsData.agentCount,
|
||||
agents,
|
||||
hasNextPage: agentsHasNextPage,
|
||||
agents: filteredAgents,
|
||||
hasNextPage: agentsHasNextPage && !filteredExhausted,
|
||||
isFetchingNextPage: agentsIsFetchingNextPage,
|
||||
fetchNextPage: agentsFetchNextPage,
|
||||
foldersData,
|
||||
@@ -226,3 +321,46 @@ export function useLibraryAgentList({
|
||||
handleFolderDeleted,
|
||||
};
|
||||
}
|
||||
|
||||
function filterAgentsByStatus<
|
||||
T extends {
|
||||
graph_id: string;
|
||||
has_external_trigger: boolean;
|
||||
recommended_schedule_cron?: string | null;
|
||||
},
|
||||
>(
|
||||
agents: T[],
|
||||
statusFilter: AgentStatusFilter,
|
||||
activeGraphIds: Set<string>,
|
||||
errorGraphIds: Set<string>,
|
||||
completedGraphIds: Set<string>,
|
||||
): T[] {
|
||||
if (statusFilter === "all") return agents;
|
||||
return agents.filter((agent) => {
|
||||
const isRunning = activeGraphIds.has(agent.graph_id);
|
||||
const hasError = errorGraphIds.has(agent.graph_id);
|
||||
|
||||
if (statusFilter === "running") return isRunning;
|
||||
if (statusFilter === "attention") return hasError && !isRunning;
|
||||
if (statusFilter === "completed")
|
||||
return completedGraphIds.has(agent.graph_id);
|
||||
if (statusFilter === "listening")
|
||||
return !isRunning && !hasError && agent.has_external_trigger;
|
||||
if (statusFilter === "scheduled")
|
||||
return (
|
||||
!isRunning &&
|
||||
!hasError &&
|
||||
!agent.has_external_trigger &&
|
||||
!!agent.recommended_schedule_cron
|
||||
);
|
||||
if (statusFilter === "idle")
|
||||
return (
|
||||
!isRunning &&
|
||||
!hasError &&
|
||||
!agent.has_external_trigger &&
|
||||
!agent.recommended_schedule_cron
|
||||
);
|
||||
if (statusFilter === "healthy") return !hasError;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,14 +2,11 @@
|
||||
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import {
|
||||
FolderIcon,
|
||||
FolderColor,
|
||||
folderCardStyles,
|
||||
resolveColor,
|
||||
} from "./FolderIcon";
|
||||
import { FolderIcon, FolderColor } from "./FolderIcon";
|
||||
import { useState } from "react";
|
||||
import { PencilSimpleIcon, TrashIcon } from "@phosphor-icons/react";
|
||||
import type { AgentStatus } from "../../types";
|
||||
import { StatusBadge } from "../StatusBadge/StatusBadge";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
@@ -21,6 +18,8 @@ interface Props {
|
||||
onDelete?: () => void;
|
||||
onAgentDrop?: (agentId: string, folderId: string) => void;
|
||||
onClick?: () => void;
|
||||
/** Worst status among child agents (optional, for status aggregation). */
|
||||
worstStatus?: AgentStatus;
|
||||
}
|
||||
|
||||
export function LibraryFolder({
|
||||
@@ -33,11 +32,10 @@ export function LibraryFolder({
|
||||
onDelete,
|
||||
onAgentDrop,
|
||||
onClick,
|
||||
worstStatus,
|
||||
}: Props) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const resolvedColor = resolveColor(color);
|
||||
const cardStyle = folderCardStyles[resolvedColor];
|
||||
|
||||
function handleDragOver(e: React.DragEvent<HTMLDivElement>) {
|
||||
if (e.dataTransfer.types.includes("application/agent-id")) {
|
||||
@@ -64,10 +62,10 @@ export function LibraryFolder({
|
||||
<div
|
||||
data-testid="library-folder"
|
||||
data-folder-id={id}
|
||||
className={`group relative inline-flex h-[10.625rem] w-full max-w-[25rem] cursor-pointer flex-col items-start justify-between gap-2.5 rounded-medium border p-4 transition-all duration-200 hover:shadow-md ${
|
||||
className={`group relative inline-flex h-[10.625rem] w-full max-w-[25rem] cursor-pointer flex-col items-start justify-between gap-2.5 rounded-medium border p-4 shadow-sm backdrop-blur-md transition-all duration-200 hover:shadow-md ${
|
||||
isDragOver
|
||||
? "border-blue-400 bg-blue-50 ring-2 ring-blue-200"
|
||||
: `${cardStyle.border} ${cardStyle.bg}`
|
||||
: "border-indigo-200/40 bg-gradient-to-br from-indigo-50/40 via-white/70 to-purple-50/30"
|
||||
}`}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
@@ -76,7 +74,7 @@ export function LibraryFolder({
|
||||
onDrop={handleDrop}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex w-full items-start justify-between gap-4">
|
||||
<div className="flex w-full items-center justify-between gap-4">
|
||||
{/* Left side - Folder name and agent count */}
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Text
|
||||
@@ -86,17 +84,22 @@ export function LibraryFolder({
|
||||
>
|
||||
{name}
|
||||
</Text>
|
||||
<Text
|
||||
variant="small"
|
||||
className="text-zinc-500"
|
||||
data-testid="library-folder-agent-count"
|
||||
>
|
||||
{agentCount} {agentCount === 1 ? "agent" : "agents"}
|
||||
</Text>
|
||||
<div className="flex items-center gap-2">
|
||||
<Text
|
||||
variant="small"
|
||||
className="text-zinc-500"
|
||||
data-testid="library-folder-agent-count"
|
||||
>
|
||||
{agentCount} {agentCount === 1 ? "agent" : "agents"}
|
||||
</Text>
|
||||
{worstStatus && worstStatus !== "idle" && (
|
||||
<StatusBadge status={worstStatus} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Custom folder icon */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="relative top-5 flex flex-shrink-0 items-center">
|
||||
<FolderIcon isOpen={isHovered} color={color} icon={icon} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -114,7 +117,7 @@ export function LibraryFolder({
|
||||
e.stopPropagation();
|
||||
onEdit?.();
|
||||
}}
|
||||
className={`h-8 w-8 border p-2 ${cardStyle.buttonBase} ${cardStyle.buttonHover}`}
|
||||
className="h-8 w-8 border border-neutral-200 bg-white/80 p-2 text-neutral-500 hover:bg-white hover:text-neutral-700"
|
||||
>
|
||||
<PencilSimpleIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -126,7 +129,7 @@ export function LibraryFolder({
|
||||
e.stopPropagation();
|
||||
onDelete?.();
|
||||
}}
|
||||
className={`h-8 w-8 border p-2 ${cardStyle.buttonBase} ${cardStyle.buttonHover}`}
|
||||
className="h-8 w-8 border border-neutral-200 bg-white/80 p-2 text-neutral-500 hover:bg-white hover:text-neutral-700"
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -19,11 +19,11 @@ export function LibrarySortMenu({ setLibrarySort }: Props) {
|
||||
const { handleSortChange } = useLibrarySortMenu({ setLibrarySort });
|
||||
return (
|
||||
<div className="flex items-center" data-testid="sort-by-dropdown">
|
||||
<span className="hidden whitespace-nowrap text-sm sm:inline">
|
||||
<span className="hidden whitespace-nowrap text-sm text-zinc-500 sm:inline">
|
||||
sort by
|
||||
</span>
|
||||
<Select onValueChange={handleSortChange}>
|
||||
<SelectTrigger className="ml-1 w-fit space-x-1 border-none px-0 text-sm underline underline-offset-4 shadow-none">
|
||||
<SelectTrigger className="!m-0 ml-1 w-fit space-x-1 border-none !bg-transparent px-[1rem] text-sm underline underline-offset-4 !shadow-none !ring-offset-transparent">
|
||||
<ArrowDownNarrowWideIcon className="h-4 w-4 sm:hidden" />
|
||||
<SelectValue placeholder="Last Modified" />
|
||||
</SelectTrigger>
|
||||
|
||||
@@ -6,9 +6,10 @@ import {
|
||||
} from "@/components/molecules/TabsLine/TabsLine";
|
||||
import { LibraryAgentSort } from "@/app/api/__generated__/models/libraryAgentSort";
|
||||
import { useFavoriteAnimation } from "../../context/FavoriteAnimationContext";
|
||||
import { LibraryTab } from "../../types";
|
||||
import type { LibraryTab, AgentStatusFilter, FleetSummary } from "../../types";
|
||||
import LibraryFolderCreationDialog from "../LibraryFolderCreationDialog/LibraryFolderCreationDialog";
|
||||
import { LibrarySortMenu } from "../LibrarySortMenu/LibrarySortMenu";
|
||||
import { AgentFilterMenu } from "../AgentFilterMenu/AgentFilterMenu";
|
||||
|
||||
interface Props {
|
||||
tabs: LibraryTab[];
|
||||
@@ -17,6 +18,9 @@ interface Props {
|
||||
allCount: number;
|
||||
favoritesCount: number;
|
||||
setLibrarySort: (value: LibraryAgentSort) => void;
|
||||
statusFilter?: AgentStatusFilter;
|
||||
onStatusFilterChange?: (filter: AgentStatusFilter) => void;
|
||||
fleetSummary?: FleetSummary;
|
||||
}
|
||||
|
||||
export function LibrarySubSection({
|
||||
@@ -26,6 +30,9 @@ export function LibrarySubSection({
|
||||
allCount,
|
||||
favoritesCount,
|
||||
setLibrarySort,
|
||||
statusFilter = "all",
|
||||
onStatusFilterChange,
|
||||
fleetSummary,
|
||||
}: Props) {
|
||||
const { registerFavoritesTabRef } = useFavoriteAnimation();
|
||||
const favoritesRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -68,8 +75,15 @@ export function LibrarySubSection({
|
||||
))}
|
||||
</TabsLineList>
|
||||
</TabsLine>
|
||||
<div className="hidden items-center gap-6 md:flex">
|
||||
<div className="relative top-1.5 hidden items-center gap-6 md:flex">
|
||||
<LibraryFolderCreationDialog />
|
||||
{fleetSummary && onStatusFilterChange && (
|
||||
<AgentFilterMenu
|
||||
value={statusFilter}
|
||||
onChange={onStatusFilterChange}
|
||||
summary={fleetSummary}
|
||||
/>
|
||||
)}
|
||||
<LibrarySortMenu setLibrarySort={setLibrarySort} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
.spinner {
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
background:
|
||||
radial-gradient(farthest-side, currentColor 94%, #0000) top/3px 3px
|
||||
no-repeat,
|
||||
conic-gradient(#0000 30%, currentColor);
|
||||
-webkit-mask: radial-gradient(farthest-side, #0000 calc(100% - 3px), #000 0);
|
||||
mask: radial-gradient(farthest-side, #0000 calc(100% - 3px), #000 0);
|
||||
animation: spin 1s infinite linear;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
100% {
|
||||
transform: rotate(1turn);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import {
|
||||
WarningCircleIcon,
|
||||
ClockCountdownIcon,
|
||||
CheckCircleIcon,
|
||||
ChatCircleDotsIcon,
|
||||
EarIcon,
|
||||
CalendarDotsIcon,
|
||||
MoonIcon,
|
||||
EyeIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import NextLink from "next/link";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { SitrepItemData, SitrepPriority } from "../../types";
|
||||
import { ContextualActionButton } from "../ContextualActionButton/ContextualActionButton";
|
||||
import styles from "./SitrepItem.module.css";
|
||||
|
||||
interface Props {
|
||||
item: SitrepItemData;
|
||||
}
|
||||
|
||||
const PRIORITY_CONFIG: Record<
|
||||
SitrepPriority,
|
||||
{
|
||||
icon?: typeof WarningCircleIcon;
|
||||
color: string;
|
||||
bg: string;
|
||||
cssSpinner?: boolean;
|
||||
}
|
||||
> = {
|
||||
error: {
|
||||
icon: WarningCircleIcon,
|
||||
color: "text-red-500",
|
||||
bg: "bg-red-50",
|
||||
},
|
||||
running: {
|
||||
color: "text-zinc-800",
|
||||
bg: "",
|
||||
cssSpinner: true,
|
||||
},
|
||||
stale: {
|
||||
icon: ClockCountdownIcon,
|
||||
color: "text-yellow-600",
|
||||
bg: "bg-yellow-50",
|
||||
},
|
||||
success: {
|
||||
icon: CheckCircleIcon,
|
||||
color: "text-green-600",
|
||||
bg: "bg-green-50",
|
||||
},
|
||||
listening: {
|
||||
icon: EarIcon,
|
||||
color: "text-purple-500",
|
||||
bg: "bg-purple-50",
|
||||
},
|
||||
scheduled: {
|
||||
icon: CalendarDotsIcon,
|
||||
color: "text-yellow-600",
|
||||
bg: "bg-yellow-50",
|
||||
},
|
||||
idle: {
|
||||
icon: MoonIcon,
|
||||
color: "text-zinc-400",
|
||||
bg: "bg-zinc-100",
|
||||
},
|
||||
};
|
||||
|
||||
export function SitrepItem({ item }: Props) {
|
||||
const config = PRIORITY_CONFIG[item.priority];
|
||||
const router = useRouter();
|
||||
|
||||
function handleAskAutoPilot() {
|
||||
const prompt = buildAutoPilotPrompt(item);
|
||||
const encoded = encodeURIComponent(prompt);
|
||||
router.push(`/copilot?autosubmit=true#prompt=${encoded}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-2 rounded-medium border border-zinc-200/50 bg-transparent p-2 sm:flex-row sm:items-center sm:gap-3",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
{item.agentImageUrl ? (
|
||||
<img
|
||||
src={item.agentImageUrl}
|
||||
alt={item.agentName}
|
||||
className="h-6 w-6 flex-shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full",
|
||||
config.bg,
|
||||
)}
|
||||
>
|
||||
{config.cssSpinner ? (
|
||||
<div
|
||||
className={cn(
|
||||
styles.spinner,
|
||||
"h-[21px] w-[21px] text-zinc-800",
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
config.icon && (
|
||||
<config.icon size={14} className={config.color} weight="fill" />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<Text variant="body-medium" className="leading-tight text-zinc-900">
|
||||
{item.agentName}
|
||||
</Text>
|
||||
<Text variant="small" className="leading-tight text-zinc-500">
|
||||
{item.message}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-shrink-0 flex-wrap items-center justify-center gap-1.5 sm:flex-nowrap sm:justify-end">
|
||||
{item.priority === "success" ? (
|
||||
<NextLink
|
||||
href={`/library/agents/${item.agentID}${item.executionID ? `?activeItem=${item.executionID}` : ""}`}
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-[13px] font-medium text-zinc-600 transition-colors hover:bg-zinc-50 hover:text-zinc-800"
|
||||
>
|
||||
<EyeIcon size={14} className="shrink-0" />
|
||||
See task
|
||||
</NextLink>
|
||||
) : (
|
||||
<ContextualActionButton
|
||||
status={item.status}
|
||||
agentID={item.agentID}
|
||||
executionID={item.executionID}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAskAutoPilot}
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-[13px] font-medium text-zinc-600 transition-colors hover:bg-zinc-50 hover:text-zinc-800"
|
||||
>
|
||||
<ChatCircleDotsIcon size={14} className="shrink-0" />
|
||||
Ask AutoPilot
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildAutoPilotPrompt(item: SitrepItemData): string {
|
||||
switch (item.priority) {
|
||||
case "error":
|
||||
return `What happened with ${item.agentName}? It says "${item.message}" — can you check the logs and tell me what to fix?`;
|
||||
case "running":
|
||||
return `Give me a status update on the ${item.agentName} run — what has it found so far?`;
|
||||
case "stale":
|
||||
return `${item.agentName} hasn't run recently. Should I keep it or update and re-run it?`;
|
||||
case "success":
|
||||
return `Show me what ${item.agentName} found in its last run — summarize the results and any key takeaways.`;
|
||||
case "listening":
|
||||
return `What is ${item.agentName} listening for? Give me a summary of its trigger configuration.`;
|
||||
case "scheduled":
|
||||
return `When is ${item.agentName} scheduled to run next?`;
|
||||
case "idle":
|
||||
return `${item.agentName} has been idle. Should I keep it or update and re-run it?`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { ClockCounterClockwise } from "@phosphor-icons/react";
|
||||
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { useSitrepItems } from "./useSitrepItems";
|
||||
import { SitrepItem } from "./SitrepItem";
|
||||
|
||||
interface Props {
|
||||
agents: LibraryAgent[];
|
||||
maxItems?: number;
|
||||
}
|
||||
|
||||
export function SitrepList({ agents, maxItems = 10 }: Props) {
|
||||
const items = useSitrepItems(agents, maxItems);
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<ClockCounterClockwise size={16} className="text-zinc-700" />
|
||||
<Text variant="body-medium" className="text-zinc-700">
|
||||
Recent tasks
|
||||
</Text>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-1 lg:grid-cols-2">
|
||||
{items.map((item) => (
|
||||
<SitrepItem key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { useGetV1ListAllExecutions } from "@/app/api/__generated__/endpoints/graphs/graphs";
|
||||
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
|
||||
import type { GraphExecutionMeta } from "@/app/api/__generated__/models/graphExecutionMeta";
|
||||
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { okData } from "@/app/api/helpers";
|
||||
import { useMemo } from "react";
|
||||
import type { SitrepItemData, SitrepPriority } from "../../types";
|
||||
import {
|
||||
isActive,
|
||||
isFailed,
|
||||
toEndTime,
|
||||
endedAfter,
|
||||
runningMessage,
|
||||
SEVENTY_TWO_HOURS_MS,
|
||||
} from "../../hooks/executionHelpers";
|
||||
|
||||
export function useSitrepItems(
|
||||
agents: LibraryAgent[],
|
||||
maxItems: number,
|
||||
): SitrepItemData[] {
|
||||
const { data: executions } = useGetV1ListAllExecutions({
|
||||
query: { select: okData },
|
||||
});
|
||||
|
||||
return useMemo(() => {
|
||||
if (!executions || agents.length === 0) return [];
|
||||
|
||||
const graphIdToAgent = new Map(agents.map((a) => [a.graph_id, a]));
|
||||
const agentExecutions = groupByAgent(executions, graphIdToAgent);
|
||||
const items: SitrepItemData[] = [];
|
||||
|
||||
for (const [agent, execs] of agentExecutions) {
|
||||
const item = buildSitrepFromExecutions(agent, execs);
|
||||
if (item) items.push(item);
|
||||
}
|
||||
|
||||
const order: Record<SitrepPriority, number> = {
|
||||
error: 0,
|
||||
running: 1,
|
||||
stale: 2,
|
||||
success: 3,
|
||||
listening: 4,
|
||||
scheduled: 5,
|
||||
idle: 6,
|
||||
};
|
||||
items.sort((a, b) => order[a.priority] - order[b.priority]);
|
||||
|
||||
return items.slice(0, maxItems);
|
||||
}, [agents, executions, maxItems]);
|
||||
}
|
||||
|
||||
function groupByAgent(
|
||||
executions: GraphExecutionMeta[],
|
||||
graphIdToAgent: Map<string, LibraryAgent>,
|
||||
): Map<LibraryAgent, GraphExecutionMeta[]> {
|
||||
const map = new Map<LibraryAgent, GraphExecutionMeta[]>();
|
||||
|
||||
for (const exec of executions) {
|
||||
const agent = graphIdToAgent.get(exec.graph_id);
|
||||
if (!agent) continue;
|
||||
const list = map.get(agent);
|
||||
if (list) {
|
||||
list.push(exec);
|
||||
} else {
|
||||
map.set(agent, [exec]);
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
function buildSitrepFromExecutions(
|
||||
agent: LibraryAgent,
|
||||
executions: GraphExecutionMeta[],
|
||||
): SitrepItemData | null {
|
||||
const active = executions.find((e) => isActive(e.status));
|
||||
if (active) {
|
||||
return {
|
||||
id: `${agent.id}-${active.id}`,
|
||||
agentID: agent.id,
|
||||
agentName: agent.name,
|
||||
executionID: active.id,
|
||||
priority: "running",
|
||||
message:
|
||||
active.stats?.activity_status ??
|
||||
runningMessage(active.status, active.started_at),
|
||||
status: "running",
|
||||
};
|
||||
}
|
||||
|
||||
const cutoff = Date.now() - SEVENTY_TWO_HOURS_MS;
|
||||
const recent = executions
|
||||
.filter((e) => endedAfter(e, cutoff))
|
||||
.sort((a, b) => toEndTime(b) - toEndTime(a));
|
||||
|
||||
const lastFailed = recent.find((e) => isFailed(e.status));
|
||||
if (lastFailed) {
|
||||
const errorMsg =
|
||||
lastFailed.stats?.error ??
|
||||
lastFailed.stats?.activity_status ??
|
||||
"Execution failed";
|
||||
return {
|
||||
id: `${agent.id}-${lastFailed.id}`,
|
||||
agentID: agent.id,
|
||||
agentName: agent.name,
|
||||
executionID: lastFailed.id,
|
||||
priority: "error",
|
||||
message: typeof errorMsg === "string" ? errorMsg : "Execution failed",
|
||||
status: "error",
|
||||
};
|
||||
}
|
||||
|
||||
const lastCompleted = recent.find(
|
||||
(e) => e.status === AgentExecutionStatus.COMPLETED,
|
||||
);
|
||||
if (lastCompleted) {
|
||||
const summary =
|
||||
lastCompleted.stats?.activity_status ?? "Completed successfully";
|
||||
return {
|
||||
id: `${agent.id}-${lastCompleted.id}`,
|
||||
agentID: agent.id,
|
||||
agentName: agent.name,
|
||||
executionID: lastCompleted.id,
|
||||
priority: "success",
|
||||
message: typeof summary === "string" ? summary : "Completed successfully",
|
||||
status: "idle",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AgentStatus } from "../../types";
|
||||
|
||||
const STATUS_CONFIG: Record<
|
||||
AgentStatus,
|
||||
{ label: string; bg: string; text: string; pulse: boolean }
|
||||
> = {
|
||||
running: {
|
||||
label: "Running",
|
||||
bg: "",
|
||||
text: "text-blue-600",
|
||||
pulse: true,
|
||||
},
|
||||
error: {
|
||||
label: "Error",
|
||||
bg: "",
|
||||
text: "text-red-500",
|
||||
pulse: false,
|
||||
},
|
||||
listening: {
|
||||
label: "Listening",
|
||||
bg: "",
|
||||
text: "text-purple-500",
|
||||
pulse: true,
|
||||
},
|
||||
scheduled: {
|
||||
label: "Scheduled",
|
||||
bg: "",
|
||||
text: "text-yellow-600",
|
||||
pulse: false,
|
||||
},
|
||||
idle: {
|
||||
label: "Idle",
|
||||
bg: "",
|
||||
text: "text-zinc-500",
|
||||
pulse: false,
|
||||
},
|
||||
};
|
||||
|
||||
interface Props {
|
||||
status: AgentStatus;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function StatusBadge({ status, className }: Props) {
|
||||
const config = STATUS_CONFIG[status];
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
config.bg,
|
||||
config.text,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-1.5 w-1.5 rounded-full",
|
||||
config.pulse && "animate-pulse",
|
||||
statusDotColor(status),
|
||||
)}
|
||||
/>
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function statusDotColor(status: AgentStatus): string {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return "bg-blue-500";
|
||||
case "error":
|
||||
return "bg-red-500";
|
||||
case "listening":
|
||||
return "bg-purple-500";
|
||||
case "scheduled":
|
||||
return "bg-yellow-500";
|
||||
case "idle":
|
||||
return "bg-zinc-400";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
|
||||
import type { GraphExecutionMeta } from "@/app/api/__generated__/models/graphExecutionMeta";
|
||||
|
||||
export const SEVENTY_TWO_HOURS_MS = 72 * 60 * 60 * 1000;
|
||||
|
||||
export function isActive(status: string): boolean {
|
||||
return (
|
||||
status === AgentExecutionStatus.RUNNING ||
|
||||
status === AgentExecutionStatus.QUEUED ||
|
||||
status === AgentExecutionStatus.REVIEW
|
||||
);
|
||||
}
|
||||
|
||||
export function isFailed(status: string): boolean {
|
||||
return (
|
||||
status === AgentExecutionStatus.FAILED ||
|
||||
status === AgentExecutionStatus.TERMINATED
|
||||
);
|
||||
}
|
||||
|
||||
export function toEndTime(exec: GraphExecutionMeta): number {
|
||||
if (!exec.ended_at) return 0;
|
||||
return exec.ended_at instanceof Date
|
||||
? exec.ended_at.getTime()
|
||||
: new Date(exec.ended_at).getTime();
|
||||
}
|
||||
|
||||
export function endedAfter(exec: GraphExecutionMeta, cutoff: number): boolean {
|
||||
if (!exec.ended_at) return false;
|
||||
return toEndTime(exec) > cutoff;
|
||||
}
|
||||
|
||||
export function runningMessage(
|
||||
status: string,
|
||||
startedAt?: string | Date | null,
|
||||
): string {
|
||||
if (status === AgentExecutionStatus.QUEUED) return "Queued for execution";
|
||||
if (status === AgentExecutionStatus.REVIEW) return "Awaiting review";
|
||||
if (!startedAt) return "Currently executing";
|
||||
const ms =
|
||||
Date.now() -
|
||||
(startedAt instanceof Date
|
||||
? startedAt.getTime()
|
||||
: new Date(startedAt).getTime());
|
||||
return `Running for ${formatRelativeDuration(ms)}`;
|
||||
}
|
||||
|
||||
export function formatRelativeDuration(ms: number): string {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
if (seconds < 60) return "a few seconds";
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMin = minutes % 60;
|
||||
if (hours < 24)
|
||||
return remainingMin > 0 ? `${hours}h ${remainingMin}m` : `${hours}h`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ${hours % 24}h`;
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useGetV1ListAllExecutions } from "@/app/api/__generated__/endpoints/graphs/graphs";
|
||||
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
|
||||
import type { GraphExecutionMeta } from "@/app/api/__generated__/models/graphExecutionMeta";
|
||||
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { okData } from "@/app/api/helpers";
|
||||
import type {
|
||||
AgentStatus,
|
||||
AgentHealth,
|
||||
AgentStatusInfo,
|
||||
FleetSummary,
|
||||
} from "../types";
|
||||
import {
|
||||
isActive,
|
||||
isFailed,
|
||||
toEndTime,
|
||||
SEVENTY_TWO_HOURS_MS,
|
||||
} from "./executionHelpers";
|
||||
|
||||
function deriveHealth(
|
||||
status: AgentStatus,
|
||||
lastRunAt: string | null,
|
||||
): AgentHealth {
|
||||
if (status === "error") return "attention";
|
||||
if (status === "idle" && lastRunAt) {
|
||||
const daysSince =
|
||||
(Date.now() - new Date(lastRunAt).getTime()) / (1000 * 60 * 60 * 24);
|
||||
if (daysSince > 14) return "stale";
|
||||
}
|
||||
return "good";
|
||||
}
|
||||
|
||||
function computeAgentStatus(
|
||||
agent: LibraryAgent,
|
||||
agentExecutions: GraphExecutionMeta[],
|
||||
): AgentStatusInfo {
|
||||
const activeExec = agentExecutions.find((e) => isActive(e.status));
|
||||
|
||||
let status: AgentStatus;
|
||||
let lastError: string | null = null;
|
||||
let lastRunAt: string | null = null;
|
||||
const activeExecutionID = activeExec?.id ?? null;
|
||||
|
||||
if (activeExec) {
|
||||
status = "running";
|
||||
} else {
|
||||
const cutoff = Date.now() - SEVENTY_TWO_HOURS_MS;
|
||||
const recentFailed = agentExecutions.find(
|
||||
(e) =>
|
||||
isFailed(e.status) &&
|
||||
e.ended_at &&
|
||||
new Date(
|
||||
e.ended_at instanceof Date ? e.ended_at.getTime() : e.ended_at,
|
||||
).getTime() > cutoff,
|
||||
);
|
||||
|
||||
if (recentFailed) {
|
||||
status = "error";
|
||||
lastError =
|
||||
(recentFailed.stats?.error as string) ??
|
||||
(recentFailed.stats?.activity_status as string) ??
|
||||
"Execution failed";
|
||||
} else if (agent.has_external_trigger) {
|
||||
status = "listening";
|
||||
} else if (agent.recommended_schedule_cron) {
|
||||
status = "scheduled";
|
||||
} else {
|
||||
status = "idle";
|
||||
}
|
||||
}
|
||||
|
||||
const completedExecs = agentExecutions.filter((e) => e.ended_at);
|
||||
if (completedExecs.length > 0) {
|
||||
const sorted = completedExecs.sort((a, b) => toEndTime(b) - toEndTime(a));
|
||||
const endedAt = sorted[0].ended_at;
|
||||
lastRunAt =
|
||||
endedAt instanceof Date ? endedAt.toISOString() : String(endedAt);
|
||||
}
|
||||
|
||||
const totalRuns = agent.execution_count ?? agentExecutions.length;
|
||||
|
||||
return {
|
||||
status,
|
||||
health: deriveHealth(status, lastRunAt),
|
||||
progress: null,
|
||||
totalRuns,
|
||||
lastRunAt,
|
||||
lastError,
|
||||
activeExecutionID,
|
||||
monthlySpend: 0,
|
||||
nextScheduledRun: null,
|
||||
triggerType: agent.has_external_trigger ? "webhook" : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function useAgentStatusMap(
|
||||
agents: LibraryAgent[],
|
||||
): Map<string, AgentStatusInfo> {
|
||||
const { data: executions } = useGetV1ListAllExecutions({
|
||||
query: { select: okData },
|
||||
});
|
||||
|
||||
return useMemo(() => {
|
||||
const map = new Map<string, AgentStatusInfo>();
|
||||
const execsByGraph = new Map<string, GraphExecutionMeta[]>();
|
||||
|
||||
for (const exec of executions ?? []) {
|
||||
const list = execsByGraph.get(exec.graph_id);
|
||||
if (list) {
|
||||
list.push(exec);
|
||||
} else {
|
||||
execsByGraph.set(exec.graph_id, [exec]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const agent of agents) {
|
||||
const agentExecs = execsByGraph.get(agent.graph_id) ?? [];
|
||||
map.set(agent.graph_id, computeAgentStatus(agent, agentExecs));
|
||||
}
|
||||
|
||||
return map;
|
||||
}, [agents, executions]);
|
||||
}
|
||||
|
||||
const DEFAULT_STATUS: AgentStatusInfo = {
|
||||
status: "idle",
|
||||
health: "good",
|
||||
progress: null,
|
||||
totalRuns: 0,
|
||||
lastRunAt: null,
|
||||
lastError: null,
|
||||
activeExecutionID: null,
|
||||
monthlySpend: 0,
|
||||
nextScheduledRun: null,
|
||||
triggerType: null,
|
||||
};
|
||||
|
||||
export function getAgentStatus(
|
||||
statusMap: Map<string, AgentStatusInfo>,
|
||||
graphID: string,
|
||||
): AgentStatusInfo {
|
||||
return statusMap.get(graphID) ?? DEFAULT_STATUS;
|
||||
}
|
||||
|
||||
export function useFleetSummary(agents: LibraryAgent[]): FleetSummary {
|
||||
const { data: executions } = useGetV1ListAllExecutions({
|
||||
query: { select: okData },
|
||||
});
|
||||
|
||||
return useMemo(() => {
|
||||
const counts: FleetSummary = {
|
||||
running: 0,
|
||||
error: 0,
|
||||
completed: 0,
|
||||
listening: 0,
|
||||
scheduled: 0,
|
||||
idle: 0,
|
||||
monthlySpend: 0,
|
||||
};
|
||||
|
||||
const activeGraphIds = new Set<string>();
|
||||
const errorGraphIds = new Set<string>();
|
||||
const completedGraphIds = new Set<string>();
|
||||
|
||||
if (executions) {
|
||||
const cutoff = Date.now() - SEVENTY_TWO_HOURS_MS;
|
||||
for (const exec of executions) {
|
||||
if (isActive(exec.status)) {
|
||||
activeGraphIds.add(exec.graph_id);
|
||||
}
|
||||
const endedTs = exec.ended_at
|
||||
? new Date(
|
||||
exec.ended_at instanceof Date
|
||||
? exec.ended_at.getTime()
|
||||
: exec.ended_at,
|
||||
).getTime()
|
||||
: 0;
|
||||
if (isFailed(exec.status) && endedTs > cutoff) {
|
||||
errorGraphIds.add(exec.graph_id);
|
||||
}
|
||||
if (
|
||||
exec.status === AgentExecutionStatus.COMPLETED &&
|
||||
endedTs > cutoff
|
||||
) {
|
||||
completedGraphIds.add(exec.graph_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const agent of agents) {
|
||||
if (activeGraphIds.has(agent.graph_id)) {
|
||||
counts.running += 1;
|
||||
} else if (errorGraphIds.has(agent.graph_id)) {
|
||||
counts.error += 1;
|
||||
} else if (agent.has_external_trigger) {
|
||||
counts.listening += 1;
|
||||
} else if (agent.recommended_schedule_cron) {
|
||||
counts.scheduled += 1;
|
||||
} else {
|
||||
counts.idle += 1;
|
||||
}
|
||||
if (completedGraphIds.has(agent.graph_id)) {
|
||||
counts.completed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return counts;
|
||||
}, [agents, executions]);
|
||||
}
|
||||
|
||||
export { deriveHealth };
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
getGetV1ListAllExecutionsQueryKey,
|
||||
useGetV1ListAllExecutions,
|
||||
} from "@/app/api/__generated__/endpoints/graphs/graphs";
|
||||
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
|
||||
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { okData } from "@/app/api/helpers";
|
||||
import { useExecutionEvents } from "@/hooks/useExecutionEvents";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import type { FleetSummary } from "../types";
|
||||
import { isActive, isFailed, SEVENTY_TWO_HOURS_MS } from "./executionHelpers";
|
||||
|
||||
function isRecentFailure(
|
||||
status: string,
|
||||
endedAt?: string | Date | null,
|
||||
): boolean {
|
||||
if (!isFailed(status)) return false;
|
||||
if (!endedAt) return false;
|
||||
const ts =
|
||||
endedAt instanceof Date ? endedAt.getTime() : new Date(endedAt).getTime();
|
||||
return Date.now() - ts < SEVENTY_TWO_HOURS_MS;
|
||||
}
|
||||
|
||||
function isRecentCompletion(
|
||||
status: string,
|
||||
endedAt?: string | Date | null,
|
||||
): boolean {
|
||||
if (status !== AgentExecutionStatus.COMPLETED) return false;
|
||||
if (!endedAt) return false;
|
||||
const ts =
|
||||
endedAt instanceof Date ? endedAt.getTime() : new Date(endedAt).getTime();
|
||||
return Date.now() - ts < SEVENTY_TWO_HOURS_MS;
|
||||
}
|
||||
|
||||
export function useLibraryFleetSummary(
|
||||
agents: LibraryAgent[],
|
||||
): FleetSummary | undefined {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: executions, isSuccess } = useGetV1ListAllExecutions({
|
||||
query: { select: okData },
|
||||
});
|
||||
|
||||
const graphIDs = useMemo(() => agents.map((a) => a.graph_id), [agents]);
|
||||
|
||||
const handleExecutionUpdate = useCallback(() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGetV1ListAllExecutionsQueryKey(),
|
||||
});
|
||||
}, [queryClient]);
|
||||
|
||||
useExecutionEvents({
|
||||
graphIds: graphIDs.length > 0 ? graphIDs : undefined,
|
||||
enabled: graphIDs.length > 0,
|
||||
onExecutionUpdate: handleExecutionUpdate,
|
||||
});
|
||||
|
||||
return useMemo(() => {
|
||||
if (!isSuccess || !executions) return undefined;
|
||||
|
||||
const agentsWithActiveExecution = new Set<string>();
|
||||
const agentsWithRecentFailure = new Set<string>();
|
||||
const agentsWithRecentCompletion = new Set<string>();
|
||||
|
||||
for (const exec of executions) {
|
||||
if (isActive(exec.status)) {
|
||||
agentsWithActiveExecution.add(exec.graph_id);
|
||||
}
|
||||
if (isRecentFailure(exec.status, exec.ended_at)) {
|
||||
agentsWithRecentFailure.add(exec.graph_id);
|
||||
}
|
||||
if (isRecentCompletion(exec.status, exec.ended_at)) {
|
||||
agentsWithRecentCompletion.add(exec.graph_id);
|
||||
}
|
||||
}
|
||||
|
||||
const summary: FleetSummary = {
|
||||
running: 0,
|
||||
error: 0,
|
||||
completed: 0,
|
||||
listening: 0,
|
||||
scheduled: 0,
|
||||
idle: 0,
|
||||
monthlySpend: 0,
|
||||
};
|
||||
|
||||
for (const agent of agents) {
|
||||
if (agentsWithActiveExecution.has(agent.graph_id)) {
|
||||
summary.running += 1;
|
||||
} else if (agentsWithRecentFailure.has(agent.graph_id)) {
|
||||
summary.error += 1;
|
||||
} else if (agent.has_external_trigger) {
|
||||
summary.listening += 1;
|
||||
} else if (agent.recommended_schedule_cron) {
|
||||
summary.scheduled += 1;
|
||||
} else {
|
||||
summary.idle += 1;
|
||||
}
|
||||
// Parallel counter: mutually exclusive with running/error (which match
|
||||
// the sitrep priority order used by the "Recently completed" tab list)
|
||||
// but orthogonal to listening/scheduled/idle.
|
||||
if (
|
||||
!agentsWithActiveExecution.has(agent.graph_id) &&
|
||||
!agentsWithRecentFailure.has(agent.graph_id) &&
|
||||
agentsWithRecentCompletion.has(agent.graph_id)
|
||||
) {
|
||||
summary.completed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
}, [agents, executions, isSuccess]);
|
||||
}
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { HeartIcon, ListIcon } from "@phosphor-icons/react";
|
||||
import { JumpBackIn } from "./components/JumpBackIn/JumpBackIn";
|
||||
import { LibraryActionHeader } from "./components/LibraryActionHeader/LibraryActionHeader";
|
||||
import { LibraryAgentList } from "./components/LibraryAgentList/LibraryAgentList";
|
||||
import { useLibraryListPage } from "./components/useLibraryListPage";
|
||||
import { FavoriteAnimationProvider } from "./context/FavoriteAnimationContext";
|
||||
import { LibraryTab } from "./types";
|
||||
import type { LibraryTab, AgentStatusFilter } from "./types";
|
||||
import { useLibraryFleetSummary } from "./hooks/useLibraryFleetSummary";
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
import { useLibraryAgents } from "@/hooks/useLibraryAgents/useLibraryAgents";
|
||||
|
||||
const LIBRARY_TABS: LibraryTab[] = [
|
||||
{ id: "all", title: "All", icon: ListIcon },
|
||||
@@ -19,6 +21,10 @@ export default function LibraryPage() {
|
||||
useLibraryListPage();
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState(LIBRARY_TABS[0].id);
|
||||
const [statusFilter, setStatusFilter] = useState<AgentStatusFilter>("all");
|
||||
const isAgentBriefingEnabled = useGetFlag(Flag.AGENT_BRIEFING);
|
||||
const { agents } = useLibraryAgents();
|
||||
const fleetSummary = useLibraryFleetSummary(agents);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = "Library – AutoGPT Platform";
|
||||
@@ -40,7 +46,6 @@ export default function LibraryPage() {
|
||||
>
|
||||
<main className="pt-160 container min-h-screen space-y-4 pb-20 pt-16 sm:px-8 md:px-12">
|
||||
<LibraryActionHeader setSearchTerm={setSearchTerm} />
|
||||
<JumpBackIn />
|
||||
<LibraryAgentList
|
||||
searchTerm={searchTerm}
|
||||
librarySort={librarySort}
|
||||
@@ -50,6 +55,10 @@ export default function LibraryPage() {
|
||||
tabs={LIBRARY_TABS}
|
||||
activeTab={activeTab}
|
||||
onTabChange={handleTabChange}
|
||||
statusFilter={statusFilter}
|
||||
onStatusFilterChange={setStatusFilter}
|
||||
fleetSummary={isAgentBriefingEnabled ? fleetSummary : undefined}
|
||||
briefingAgents={isAgentBriefingEnabled ? agents : undefined}
|
||||
/>
|
||||
</main>
|
||||
</FavoriteAnimationProvider>
|
||||
|
||||
@@ -1,7 +1,76 @@
|
||||
import { Icon } from "@phosphor-icons/react";
|
||||
import type { Icon } from "@phosphor-icons/react";
|
||||
|
||||
export interface LibraryTab {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: Icon;
|
||||
}
|
||||
|
||||
/** Agent execution status — drives StatusBadge visuals & filtering. */
|
||||
export type AgentStatus =
|
||||
| "running"
|
||||
| "error"
|
||||
| "listening"
|
||||
| "scheduled"
|
||||
| "idle";
|
||||
|
||||
/** Derived health bucket for quick triage. */
|
||||
export type AgentHealth = "good" | "attention" | "stale";
|
||||
|
||||
/** Real-time metadata that powers the Intelligence Layer features. */
|
||||
export interface AgentStatusInfo {
|
||||
status: AgentStatus;
|
||||
health: AgentHealth;
|
||||
/** 0-100 progress for currently running agents. */
|
||||
progress: number | null;
|
||||
totalRuns: number;
|
||||
lastRunAt: string | null;
|
||||
lastError: string | null;
|
||||
/** ID of the currently active execution (when status is "running"). */
|
||||
activeExecutionID: string | null;
|
||||
monthlySpend: number;
|
||||
nextScheduledRun: string | null;
|
||||
triggerType: string | null;
|
||||
}
|
||||
|
||||
/** Fleet-wide aggregate counts used by the Briefing Panel stats grid. */
|
||||
export interface FleetSummary {
|
||||
running: number;
|
||||
error: number;
|
||||
completed: number;
|
||||
listening: number;
|
||||
scheduled: number;
|
||||
idle: number;
|
||||
monthlySpend: number;
|
||||
}
|
||||
|
||||
export type SitrepPriority =
|
||||
| "error"
|
||||
| "running"
|
||||
| "stale"
|
||||
| "success"
|
||||
| "listening"
|
||||
| "scheduled"
|
||||
| "idle";
|
||||
|
||||
export interface SitrepItemData {
|
||||
id: string;
|
||||
agentID: string;
|
||||
agentName: string;
|
||||
agentImageUrl?: string | null;
|
||||
executionID?: string;
|
||||
priority: SitrepPriority;
|
||||
message: string;
|
||||
status: AgentStatus;
|
||||
}
|
||||
|
||||
/** Filter options for the agent filter dropdown. */
|
||||
export type AgentStatusFilter =
|
||||
| "all"
|
||||
| "running"
|
||||
| "attention"
|
||||
| "completed"
|
||||
| "listening"
|
||||
| "scheduled"
|
||||
| "idle"
|
||||
| "healthy";
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import { Skeleton } from "@/components/atoms/Skeleton/Skeleton";
|
||||
import { useSubscriptionTierSection } from "./useSubscriptionTierSection";
|
||||
|
||||
type TierInfo = {
|
||||
@@ -15,39 +17,70 @@ const TIERS: TierInfo[] = [
|
||||
key: "FREE",
|
||||
label: "Free",
|
||||
multiplier: "1x",
|
||||
description: "Base rate limits",
|
||||
description: "Base AutoPilot capacity with standard rate limits",
|
||||
},
|
||||
{
|
||||
key: "PRO",
|
||||
label: "Pro",
|
||||
multiplier: "5x",
|
||||
description: "5x more AutoPilot capacity",
|
||||
description: "5x AutoPilot capacity — run 5× more tasks per day/week",
|
||||
},
|
||||
{
|
||||
key: "BUSINESS",
|
||||
label: "Business",
|
||||
multiplier: "20x",
|
||||
description: "20x more AutoPilot capacity",
|
||||
description: "20x AutoPilot capacity — ideal for teams and heavy workloads",
|
||||
},
|
||||
];
|
||||
|
||||
function formatCost(cents: number): string {
|
||||
if (cents === 0) return "Free";
|
||||
const TIER_ORDER = ["FREE", "PRO", "BUSINESS", "ENTERPRISE"];
|
||||
|
||||
function formatCost(cents: number, tierKey: string): string {
|
||||
if (tierKey === "FREE") return "Free";
|
||||
if (cents === 0) return "Pricing available soon";
|
||||
return `$${(cents / 100).toFixed(2)}/mo`;
|
||||
}
|
||||
|
||||
export function SubscriptionTierSection() {
|
||||
const { subscription, isLoading, error, isPending, changeTier } =
|
||||
useSubscriptionTierSection();
|
||||
const [tierError, setTierError] = useState<string | null>(null);
|
||||
const {
|
||||
subscription,
|
||||
isLoading,
|
||||
error,
|
||||
tierError,
|
||||
isPending,
|
||||
pendingTier,
|
||||
pendingUpgradeTier,
|
||||
setPendingUpgradeTier,
|
||||
confirmUpgrade,
|
||||
isPaymentEnabled,
|
||||
changeTier,
|
||||
handleTierChange,
|
||||
} = useSubscriptionTierSection();
|
||||
const [confirmDowngradeTo, setConfirmDowngradeTo] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
if (isLoading) return null;
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<Skeleton className="h-40 rounded-lg" />
|
||||
<Skeleton className="h-40 rounded-lg" />
|
||||
<Skeleton className="h-40 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">Subscription Plan</h3>
|
||||
<p className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400">
|
||||
<p
|
||||
role="alert"
|
||||
className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
@@ -56,10 +89,30 @@ export function SubscriptionTierSection() {
|
||||
|
||||
if (!subscription) return null;
|
||||
|
||||
async function handleTierChange(tierKey: string) {
|
||||
setTierError(null);
|
||||
const err = await changeTier(tierKey);
|
||||
if (err) setTierError(err);
|
||||
const currentTier = subscription.tier;
|
||||
|
||||
if (currentTier === "ENTERPRISE") {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">Subscription Plan</h3>
|
||||
<div className="rounded-lg border border-violet-500 bg-violet-50 p-4 dark:bg-violet-900/20">
|
||||
<p className="font-semibold text-violet-700 dark:text-violet-200">
|
||||
Enterprise Plan
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Your Enterprise plan is managed by your administrator. Contact your
|
||||
account team for changes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function confirmDowngrade() {
|
||||
if (!confirmDowngradeTo) return;
|
||||
const tier = confirmDowngradeTo;
|
||||
setConfirmDowngradeTo(null);
|
||||
await changeTier(tier);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -67,24 +120,28 @@ export function SubscriptionTierSection() {
|
||||
<h3 className="text-lg font-medium">Subscription Plan</h3>
|
||||
|
||||
{tierError && (
|
||||
<p className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400">
|
||||
<p
|
||||
role="alert"
|
||||
className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400"
|
||||
>
|
||||
{tierError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
{TIERS.map((tier) => {
|
||||
const isCurrent = subscription.tier === tier.key;
|
||||
const isCurrent = currentTier === tier.key;
|
||||
const cost = subscription.tier_costs[tier.key] ?? 0;
|
||||
const currentTierOrder = ["FREE", "PRO", "BUSINESS", "ENTERPRISE"];
|
||||
const currentIdx = currentTierOrder.indexOf(subscription.tier);
|
||||
const targetIdx = currentTierOrder.indexOf(tier.key);
|
||||
const currentIdx = TIER_ORDER.indexOf(currentTier);
|
||||
const targetIdx = TIER_ORDER.indexOf(tier.key);
|
||||
const isUpgrade = targetIdx > currentIdx;
|
||||
const isDowngrade = targetIdx < currentIdx;
|
||||
const isThisPending = pendingTier === tier.key;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tier.key}
|
||||
aria-current={isCurrent ? "true" : undefined}
|
||||
className={`rounded-lg border p-4 ${
|
||||
isCurrent
|
||||
? "border-violet-500 bg-violet-50 dark:bg-violet-900/20"
|
||||
@@ -100,7 +157,9 @@ export function SubscriptionTierSection() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mb-1 text-2xl font-bold">{formatCost(cost)}</p>
|
||||
<p className="mb-1 text-2xl font-bold">
|
||||
{formatCost(cost, tier.key)}
|
||||
</p>
|
||||
<p className="mb-1 text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
||||
{tier.multiplier} rate limits
|
||||
</p>
|
||||
@@ -108,14 +167,20 @@ export function SubscriptionTierSection() {
|
||||
{tier.description}
|
||||
</p>
|
||||
|
||||
{!isCurrent && (
|
||||
{!isCurrent && isPaymentEnabled && (
|
||||
<Button
|
||||
className="w-full"
|
||||
variant={isUpgrade ? "default" : "outline"}
|
||||
disabled={isPending}
|
||||
onClick={() => handleTierChange(tier.key)}
|
||||
onClick={() =>
|
||||
handleTierChange(
|
||||
tier.key,
|
||||
currentTier,
|
||||
setConfirmDowngradeTo,
|
||||
)
|
||||
}
|
||||
>
|
||||
{isPending
|
||||
{isThisPending
|
||||
? "Updating..."
|
||||
: isUpgrade
|
||||
? `Upgrade to ${tier.label}`
|
||||
@@ -129,12 +194,79 @@ export function SubscriptionTierSection() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{subscription.tier !== "FREE" && (
|
||||
{currentTier !== "FREE" && isPaymentEnabled && (
|
||||
<p className="text-sm text-neutral-500">
|
||||
Your subscription is managed through Stripe. Changes take effect
|
||||
immediately.
|
||||
Your subscription is managed through Stripe. Upgrades and paid-tier
|
||||
changes take effect immediately; downgrades to Free are scheduled for
|
||||
the end of the current billing period.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
title="Confirm Downgrade"
|
||||
controlled={{
|
||||
isOpen: !!confirmDowngradeTo,
|
||||
set: (open) => {
|
||||
if (!open) setConfirmDowngradeTo(null);
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Dialog.Content>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{confirmDowngradeTo === "FREE"
|
||||
? "Downgrading to Free will schedule your subscription to cancel at the end of your current billing period. You keep your current plan until then."
|
||||
: `Switching to ${TIERS.find((t) => t.key === confirmDowngradeTo)?.label ?? confirmDowngradeTo} will take effect immediately.`}{" "}
|
||||
Are you sure?
|
||||
</p>
|
||||
<Dialog.Footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setConfirmDowngradeTo(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => void confirmDowngrade()}
|
||||
>
|
||||
Confirm Downgrade
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
title="Confirm Upgrade"
|
||||
controlled={{
|
||||
isOpen: !!pendingUpgradeTier,
|
||||
set: (open) => {
|
||||
if (!open) setPendingUpgradeTier(null);
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Dialog.Content>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{subscription &&
|
||||
subscription.proration_credit_cents > 0 &&
|
||||
`Your unused ${currentTier.charAt(0) + currentTier.slice(1).toLowerCase()} subscription ($${(subscription.proration_credit_cents / 100).toFixed(2)}) will be applied as a credit to your next Stripe invoice. `}
|
||||
You will be redirected to Stripe to complete your upgrade to{" "}
|
||||
{TIERS.find((t) => t.key === pendingUpgradeTier)?.label ??
|
||||
pendingUpgradeTier}
|
||||
.
|
||||
</p>
|
||||
<Dialog.Footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPendingUpgradeTier(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => void confirmUpgrade()}>
|
||||
Continue to Checkout
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
cleanup,
|
||||
} from "@/tests/integrations/test-utils";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { SubscriptionTierSection } from "../SubscriptionTierSection";
|
||||
|
||||
// Mock next/navigation
|
||||
const mockSearchParams = new URLSearchParams();
|
||||
const mockRouterReplace = vi.fn();
|
||||
vi.mock("next/navigation", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("next/navigation")>();
|
||||
return {
|
||||
...actual,
|
||||
useSearchParams: () => mockSearchParams,
|
||||
useRouter: () => ({ push: vi.fn(), replace: mockRouterReplace }),
|
||||
usePathname: () => "/profile/credits",
|
||||
};
|
||||
});
|
||||
|
||||
// Mock toast
|
||||
const mockToast = vi.fn();
|
||||
vi.mock("@/components/molecules/Toast/use-toast", () => ({
|
||||
useToast: () => ({ toast: mockToast }),
|
||||
}));
|
||||
|
||||
// Mock feature flags — default to payment enabled so button tests work
|
||||
let mockPaymentEnabled = true;
|
||||
vi.mock("@/services/feature-flags/use-get-flag", () => ({
|
||||
Flag: { ENABLE_PLATFORM_PAYMENT: "enable-platform-payment" },
|
||||
useGetFlag: () => mockPaymentEnabled,
|
||||
}));
|
||||
|
||||
// Mock generated API hooks
|
||||
const mockUseGetSubscriptionStatus = vi.fn();
|
||||
const mockUseUpdateSubscriptionTier = vi.fn();
|
||||
vi.mock("@/app/api/__generated__/endpoints/credits/credits", () => ({
|
||||
useGetSubscriptionStatus: (opts: unknown) =>
|
||||
mockUseGetSubscriptionStatus(opts),
|
||||
useUpdateSubscriptionTier: () => mockUseUpdateSubscriptionTier(),
|
||||
}));
|
||||
|
||||
// Mock Dialog (Radix portals don't work in happy-dom)
|
||||
const MockDialogContent = ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
);
|
||||
const MockDialogFooter = ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
);
|
||||
function MockDialog({
|
||||
controlled,
|
||||
children,
|
||||
}: {
|
||||
controlled?: { isOpen: boolean; set: (open: boolean) => void };
|
||||
children: React.ReactNode;
|
||||
[key: string]: unknown;
|
||||
}) {
|
||||
return controlled?.isOpen ? <div role="dialog">{children}</div> : null;
|
||||
}
|
||||
MockDialog.Content = MockDialogContent;
|
||||
MockDialog.Footer = MockDialogFooter;
|
||||
vi.mock("@/components/molecules/Dialog/Dialog", () => ({
|
||||
Dialog: MockDialog,
|
||||
}));
|
||||
|
||||
function makeSubscription({
|
||||
tier = "FREE",
|
||||
monthlyCost = 0,
|
||||
tierCosts = { FREE: 0, PRO: 1999, BUSINESS: 4999, ENTERPRISE: 0 },
|
||||
prorationCreditCents = 0,
|
||||
}: {
|
||||
tier?: string;
|
||||
monthlyCost?: number;
|
||||
tierCosts?: Record<string, number>;
|
||||
prorationCreditCents?: number;
|
||||
} = {}) {
|
||||
return {
|
||||
tier,
|
||||
monthly_cost: monthlyCost,
|
||||
tier_costs: tierCosts,
|
||||
proration_credit_cents: prorationCreditCents,
|
||||
};
|
||||
}
|
||||
|
||||
function setupMocks({
|
||||
subscription = makeSubscription(),
|
||||
isLoading = false,
|
||||
queryError = null as Error | null,
|
||||
mutateFn = vi.fn().mockResolvedValue({ status: 200, data: { url: "" } }),
|
||||
isPending = false,
|
||||
variables = undefined as { data?: { tier?: string } } | undefined,
|
||||
} = {}) {
|
||||
// The hook uses select: (data) => (data.status === 200 ? data.data : null)
|
||||
// so the data value returned by the hook is already the transformed subscription object.
|
||||
// We simulate that by returning the subscription directly as data.
|
||||
mockUseGetSubscriptionStatus.mockReturnValue({
|
||||
data: subscription,
|
||||
isLoading,
|
||||
error: queryError,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
mockUseUpdateSubscriptionTier.mockReturnValue({
|
||||
mutateAsync: mutateFn,
|
||||
isPending,
|
||||
variables,
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
mockUseGetSubscriptionStatus.mockReset();
|
||||
mockUseUpdateSubscriptionTier.mockReset();
|
||||
mockToast.mockReset();
|
||||
mockRouterReplace.mockReset();
|
||||
mockSearchParams.delete("subscription");
|
||||
mockPaymentEnabled = true;
|
||||
});
|
||||
|
||||
describe("SubscriptionTierSection", () => {
|
||||
it("renders skeleton cards while loading", () => {
|
||||
setupMocks({ isLoading: true });
|
||||
render(<SubscriptionTierSection />);
|
||||
// Just verify we're rendering something (not null) and no tier cards
|
||||
expect(screen.queryByText("Pro")).toBeNull();
|
||||
expect(screen.queryByText("Business")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders error message when subscription fetch fails", () => {
|
||||
setupMocks({
|
||||
queryError: new Error("Network error"),
|
||||
subscription: makeSubscription(),
|
||||
});
|
||||
// Override the data to simulate failed state
|
||||
mockUseGetSubscriptionStatus.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
error: new Error("Network error"),
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
render(<SubscriptionTierSection />);
|
||||
expect(screen.getByRole("alert")).toBeDefined();
|
||||
expect(screen.getByText(/failed to load subscription info/i)).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders all three tier cards for FREE user", () => {
|
||||
setupMocks();
|
||||
render(<SubscriptionTierSection />);
|
||||
// Use getAllByText to account for the tier label AND cost display both containing "Free"
|
||||
expect(screen.getAllByText("Free").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("Pro")).toBeDefined();
|
||||
expect(screen.getByText("Business")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows Current badge on the active tier", () => {
|
||||
setupMocks({ subscription: makeSubscription({ tier: "PRO" }) });
|
||||
render(<SubscriptionTierSection />);
|
||||
expect(screen.getByText("Current")).toBeDefined();
|
||||
// Upgrade to PRO button should NOT exist; Upgrade to BUSINESS and Downgrade to Free should
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /upgrade to pro/i }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /upgrade to business/i }),
|
||||
).toBeDefined();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /downgrade to free/i }),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("displays tier costs from the API", () => {
|
||||
setupMocks({
|
||||
subscription: makeSubscription({
|
||||
tier: "FREE",
|
||||
tierCosts: { FREE: 0, PRO: 1999, BUSINESS: 4999, ENTERPRISE: 0 },
|
||||
}),
|
||||
});
|
||||
render(<SubscriptionTierSection />);
|
||||
expect(screen.getByText("$19.99/mo")).toBeDefined();
|
||||
expect(screen.getByText("$49.99/mo")).toBeDefined();
|
||||
// FREE tier label should still be visible (there may be multiple "Free" elements)
|
||||
expect(screen.getAllByText("Free").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("shows 'Pricing available soon' when tier cost is 0 for a paid tier", () => {
|
||||
setupMocks({
|
||||
subscription: makeSubscription({
|
||||
tier: "FREE",
|
||||
tierCosts: { FREE: 0, PRO: 0, BUSINESS: 0, ENTERPRISE: 0 },
|
||||
}),
|
||||
});
|
||||
render(<SubscriptionTierSection />);
|
||||
// PRO and BUSINESS with cost=0 should show "Pricing available soon"
|
||||
expect(screen.getAllByText("Pricing available soon")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("calls changeTier on upgrade click after confirmation dialog", async () => {
|
||||
const mutateFn = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ status: 200, data: { url: "" } });
|
||||
setupMocks({ mutateFn });
|
||||
render(<SubscriptionTierSection />);
|
||||
|
||||
// Clicking upgrade opens the confirmation dialog first
|
||||
fireEvent.click(screen.getByRole("button", { name: /upgrade to pro/i }));
|
||||
// Confirm via the dialog's "Continue to Checkout" button
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: /continue to checkout/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mutateFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ tier: "PRO" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows confirmation dialog on downgrade click", () => {
|
||||
setupMocks({ subscription: makeSubscription({ tier: "PRO" }) });
|
||||
render(<SubscriptionTierSection />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /downgrade to free/i }));
|
||||
|
||||
expect(screen.getByRole("dialog")).toBeDefined();
|
||||
// The dialog title text appears in both a div and a button — just check the dialog is open
|
||||
expect(screen.getAllByText(/confirm downgrade/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("calls changeTier after downgrade confirmation", async () => {
|
||||
const mutateFn = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ status: 200, data: { url: "" } });
|
||||
setupMocks({
|
||||
subscription: makeSubscription({ tier: "PRO" }),
|
||||
mutateFn,
|
||||
});
|
||||
render(<SubscriptionTierSection />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /downgrade to free/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /confirm downgrade/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mutateFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ tier: "FREE" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("dismisses dialog when Cancel is clicked", () => {
|
||||
setupMocks({ subscription: makeSubscription({ tier: "PRO" }) });
|
||||
render(<SubscriptionTierSection />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /downgrade to free/i }));
|
||||
expect(screen.getByRole("dialog")).toBeDefined();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /^cancel$/i }));
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("redirects to Stripe when checkout URL is returned", async () => {
|
||||
// Replace window.location with a plain object so assigning .href doesn't
|
||||
// trigger jsdom navigation (which would throw or reload the test page).
|
||||
const mockLocation = { href: "" };
|
||||
vi.stubGlobal("location", mockLocation);
|
||||
|
||||
const mutateFn = vi.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
data: { url: "https://checkout.stripe.com/pay/cs_test" },
|
||||
});
|
||||
setupMocks({ mutateFn });
|
||||
render(<SubscriptionTierSection />);
|
||||
|
||||
// Upgrade opens confirmation dialog first — confirm via "Continue to Checkout"
|
||||
fireEvent.click(screen.getByRole("button", { name: /upgrade to pro/i }));
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: /continue to checkout/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockLocation.href).toBe("https://checkout.stripe.com/pay/cs_test");
|
||||
});
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("shows an error alert when tier change fails", async () => {
|
||||
const mutateFn = vi.fn().mockRejectedValue(new Error("Stripe unavailable"));
|
||||
setupMocks({ mutateFn });
|
||||
render(<SubscriptionTierSection />);
|
||||
|
||||
// Upgrade opens confirmation dialog first — confirm to trigger the mutation
|
||||
fireEvent.click(screen.getByRole("button", { name: /upgrade to pro/i }));
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: /continue to checkout/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("alert")).toBeDefined();
|
||||
expect(screen.getByText(/stripe unavailable/i)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("hides action buttons when payment flag is disabled", () => {
|
||||
mockPaymentEnabled = false;
|
||||
setupMocks({ subscription: makeSubscription({ tier: "FREE" }) });
|
||||
render(<SubscriptionTierSection />);
|
||||
// Tier cards still visible
|
||||
expect(screen.getByText("Pro")).toBeDefined();
|
||||
expect(screen.getByText("Business")).toBeDefined();
|
||||
// No upgrade/downgrade buttons
|
||||
expect(screen.queryByRole("button", { name: /upgrade/i })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /downgrade/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("shows ENTERPRISE message for ENTERPRISE tier users", () => {
|
||||
setupMocks({ subscription: makeSubscription({ tier: "ENTERPRISE" }) });
|
||||
render(<SubscriptionTierSection />);
|
||||
// Enterprise heading text appears in a <p> (may match multiple), just verify it exists
|
||||
expect(screen.getAllByText(/enterprise plan/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText(/managed by your administrator/i)).toBeDefined();
|
||||
// No standard tier cards should be rendered
|
||||
expect(screen.queryByText("Pro")).toBeNull();
|
||||
expect(screen.queryByText("Business")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows success toast and clears URL param when ?subscription=success is present", async () => {
|
||||
mockSearchParams.set("subscription", "success");
|
||||
setupMocks();
|
||||
render(<SubscriptionTierSection />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToast).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: "Subscription upgraded" }),
|
||||
);
|
||||
});
|
||||
// URL param must be stripped so a page refresh doesn't re-trigger the toast
|
||||
expect(mockRouterReplace).toHaveBeenCalledWith("/profile/credits");
|
||||
});
|
||||
|
||||
it("clears URL param but shows no toast when ?subscription=cancelled is present", async () => {
|
||||
mockSearchParams.set("subscription", "cancelled");
|
||||
setupMocks();
|
||||
render(<SubscriptionTierSection />);
|
||||
|
||||
// The cancelled param must be stripped from the URL (same hygiene as success)
|
||||
await waitFor(() => {
|
||||
expect(mockRouterReplace).toHaveBeenCalledWith("/profile/credits");
|
||||
});
|
||||
// No toast should fire — the user simply abandoned checkout
|
||||
expect(mockToast).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,30 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import {
|
||||
useGetSubscriptionStatus,
|
||||
useUpdateSubscriptionTier,
|
||||
} from "@/app/api/__generated__/endpoints/credits/credits";
|
||||
import type { SubscriptionStatusResponse } from "@/app/api/__generated__/models/subscriptionStatusResponse";
|
||||
import type { SubscriptionTierRequestTier } from "@/app/api/__generated__/models/subscriptionTierRequestTier";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
|
||||
export type SubscriptionStatus = SubscriptionStatusResponse;
|
||||
|
||||
const TIER_ORDER = ["FREE", "PRO", "BUSINESS", "ENTERPRISE"];
|
||||
|
||||
export function useSubscriptionTierSection() {
|
||||
const isPaymentEnabled = useGetFlag(Flag.ENABLE_PLATFORM_PAYMENT);
|
||||
const searchParams = useSearchParams();
|
||||
const subscriptionStatus = searchParams.get("subscription");
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { toast } = useToast();
|
||||
const [tierError, setTierError] = useState<string | null>(null);
|
||||
const [pendingUpgradeTier, setPendingUpgradeTier] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const {
|
||||
data: subscription,
|
||||
isLoading,
|
||||
@@ -17,11 +34,39 @@ export function useSubscriptionTierSection() {
|
||||
query: { select: (data) => (data.status === 200 ? data.data : null) },
|
||||
});
|
||||
|
||||
const error = queryError ? "Failed to load subscription info" : null;
|
||||
const fetchError = queryError ? "Failed to load subscription info" : null;
|
||||
|
||||
const { mutateAsync: doUpdateTier, isPending } = useUpdateSubscriptionTier();
|
||||
const {
|
||||
mutateAsync: doUpdateTier,
|
||||
isPending,
|
||||
variables,
|
||||
} = useUpdateSubscriptionTier();
|
||||
|
||||
async function changeTier(tier: string): Promise<string | null> {
|
||||
useEffect(() => {
|
||||
if (subscriptionStatus === "success") {
|
||||
refetch();
|
||||
toast({
|
||||
title: "Subscription upgraded",
|
||||
description:
|
||||
"Your plan has been updated. It may take a moment to reflect.",
|
||||
});
|
||||
}
|
||||
// Strip ?subscription=success|cancelled from the URL so a page refresh
|
||||
// does not re-trigger side-effects, and so a second checkout in the same
|
||||
// session correctly fires the toast again.
|
||||
if (
|
||||
subscriptionStatus === "success" ||
|
||||
subscriptionStatus === "cancelled"
|
||||
) {
|
||||
router.replace(pathname);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- refetch and toast
|
||||
// are new references each render but are stable in practice; the effect must
|
||||
// only re-run when subscriptionStatus/pathname changes.
|
||||
}, [subscriptionStatus, refetch, toast, router, pathname]);
|
||||
|
||||
async function changeTier(tier: string) {
|
||||
setTierError(null);
|
||||
try {
|
||||
const successUrl = `${window.location.origin}${window.location.pathname}?subscription=success`;
|
||||
const cancelUrl = `${window.location.origin}${window.location.pathname}?subscription=cancelled`;
|
||||
@@ -34,22 +79,59 @@ export function useSubscriptionTierSection() {
|
||||
});
|
||||
if (result.status === 200 && result.data.url) {
|
||||
window.location.href = result.data.url;
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
await refetch();
|
||||
return null;
|
||||
toast({
|
||||
title: "Subscription updated",
|
||||
description:
|
||||
tier === "FREE"
|
||||
? "Your plan will be downgraded to Free at the end of your current billing period."
|
||||
: "Your subscription has been updated.",
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
const msg =
|
||||
e instanceof Error ? e.message : "Failed to change subscription tier";
|
||||
return msg;
|
||||
setTierError(msg);
|
||||
}
|
||||
}
|
||||
|
||||
function handleTierChange(
|
||||
targetTierKey: string,
|
||||
currentTier: string,
|
||||
onConfirmDowngrade: (tier: string) => void,
|
||||
) {
|
||||
const currentIdx = TIER_ORDER.indexOf(currentTier);
|
||||
const targetIdx = TIER_ORDER.indexOf(targetTierKey);
|
||||
if (targetIdx < currentIdx) {
|
||||
onConfirmDowngrade(targetTierKey);
|
||||
return;
|
||||
}
|
||||
setPendingUpgradeTier(targetTierKey);
|
||||
}
|
||||
|
||||
async function confirmUpgrade() {
|
||||
if (!pendingUpgradeTier) return;
|
||||
const tier = pendingUpgradeTier;
|
||||
setPendingUpgradeTier(null);
|
||||
await changeTier(tier);
|
||||
}
|
||||
|
||||
const pendingTier =
|
||||
isPending && variables?.data?.tier ? variables.data.tier : null;
|
||||
|
||||
return {
|
||||
subscription: subscription ?? null,
|
||||
isLoading,
|
||||
error,
|
||||
error: fetchError,
|
||||
tierError,
|
||||
isPending,
|
||||
pendingTier,
|
||||
pendingUpgradeTier,
|
||||
setPendingUpgradeTier,
|
||||
confirmUpgrade,
|
||||
isPaymentEnabled,
|
||||
changeTier,
|
||||
handleTierChange,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,15 +15,35 @@ export async function GET() {
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const supabase = await getServerSupabase();
|
||||
const { email } = await request.json();
|
||||
|
||||
if (!email) {
|
||||
return NextResponse.json({ error: "Email is required" }, { status: 400 });
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { data, error } = await supabase.auth.updateUser({
|
||||
email,
|
||||
});
|
||||
const { email: rawEmail, full_name: rawFullName } = body as {
|
||||
email?: unknown;
|
||||
full_name?: unknown;
|
||||
};
|
||||
|
||||
const email = typeof rawEmail === "string" ? rawEmail.trim() : undefined;
|
||||
const fullName =
|
||||
typeof rawFullName === "string" ? rawFullName.trim() : undefined;
|
||||
|
||||
if (!email && !fullName) {
|
||||
return NextResponse.json(
|
||||
{ error: "Email or full_name is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const updatePayload: Parameters<typeof supabase.auth.updateUser>[0] = {};
|
||||
if (email) updatePayload.email = email;
|
||||
if (fullName) updatePayload.data = { full_name: fullName };
|
||||
|
||||
const { data, error } = await supabase.auth.updateUser(updatePayload);
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
@@ -32,7 +52,7 @@ export async function PUT(request: Request) {
|
||||
return NextResponse.json(data);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update user email" },
|
||||
{ error: "Failed to update user" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14122,16 +14122,29 @@
|
||||
},
|
||||
"SubscriptionStatusResponse": {
|
||||
"properties": {
|
||||
"tier": { "type": "string", "title": "Tier" },
|
||||
"tier": {
|
||||
"type": "string",
|
||||
"enum": ["FREE", "PRO", "BUSINESS", "ENTERPRISE"],
|
||||
"title": "Tier"
|
||||
},
|
||||
"monthly_cost": { "type": "integer", "title": "Monthly Cost" },
|
||||
"tier_costs": {
|
||||
"additionalProperties": { "type": "integer" },
|
||||
"type": "object",
|
||||
"title": "Tier Costs"
|
||||
},
|
||||
"proration_credit_cents": {
|
||||
"type": "integer",
|
||||
"title": "Proration Credit Cents"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": ["tier", "monthly_cost", "tier_costs"],
|
||||
"required": [
|
||||
"tier",
|
||||
"monthly_cost",
|
||||
"tier_costs",
|
||||
"proration_credit_cents"
|
||||
],
|
||||
"title": "SubscriptionStatusResponse"
|
||||
},
|
||||
"SubscriptionTier": {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Toaster } from "@/components/molecules/Toast/toaster";
|
||||
import { SetupAnalytics } from "@/services/analytics";
|
||||
import { VercelAnalyticsWrapper } from "@/services/analytics/VercelAnalyticsWrapper";
|
||||
import { environment } from "@/services/environment";
|
||||
import AgentationDevtool from "@/components/AgentationDevtool";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
@@ -77,6 +78,7 @@ export default async function RootLayout({
|
||||
</div>
|
||||
<Toaster />
|
||||
<CookieConsentBanner />
|
||||
{(isLocal || isDev) && <AgentationDevtool />}
|
||||
</Providers>
|
||||
</ErrorBoundary>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const Agentation = dynamic(
|
||||
() => import("agentation").then((mod) => mod.Agentation),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
export default function AgentationDevtool() {
|
||||
return <Agentation />;
|
||||
}
|
||||
@@ -60,7 +60,7 @@ export function Navbar() {
|
||||
<PreviewBanner branchName={previewBranchName} />
|
||||
) : null}
|
||||
<nav
|
||||
className="inline-flex w-full items-center bg-[#FAFAFA]/80 p-3 backdrop-blur-xl"
|
||||
className="inline-flex w-full items-center border-b border-[#f1f1f1] bg-[#FAFAFA]/80 p-3 backdrop-blur-xl"
|
||||
style={{ height: NAVBAR_HEIGHT_PX }}
|
||||
>
|
||||
{/* Left section */}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const STORAGE_KEY = "autopilot_pending_prompt";
|
||||
|
||||
interface AutoPilotBridgeState {
|
||||
pendingPrompt: string | null;
|
||||
sendPrompt: (prompt: string) => void;
|
||||
consumePrompt: () => string | null;
|
||||
}
|
||||
|
||||
const AutoPilotBridgeContext = createContext<AutoPilotBridgeState | null>(null);
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AutoPilotBridgeProvider({ children }: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
const [pendingPrompt, setPendingPrompt] = useState<string | null>(() => {
|
||||
if (typeof window === "undefined") return null;
|
||||
return sessionStorage.getItem(STORAGE_KEY);
|
||||
});
|
||||
|
||||
function sendPrompt(prompt: string) {
|
||||
sessionStorage.setItem(STORAGE_KEY, prompt);
|
||||
setPendingPrompt(prompt);
|
||||
router.push("/");
|
||||
}
|
||||
|
||||
function consumePrompt(): string | null {
|
||||
const prompt = pendingPrompt ?? sessionStorage.getItem(STORAGE_KEY);
|
||||
if (prompt !== null) {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
setPendingPrompt(null);
|
||||
}
|
||||
return prompt;
|
||||
}
|
||||
|
||||
return (
|
||||
<AutoPilotBridgeContext.Provider
|
||||
value={{ pendingPrompt, sendPrompt, consumePrompt }}
|
||||
>
|
||||
{children}
|
||||
</AutoPilotBridgeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAutoPilotBridge(): AutoPilotBridgeState {
|
||||
const context = useContext(AutoPilotBridgeContext);
|
||||
if (!context) {
|
||||
// Return a no-op implementation when used outside the provider
|
||||
// (e.g. in tests or isolated component renders).
|
||||
return {
|
||||
pendingPrompt: null,
|
||||
sendPrompt: () => {},
|
||||
consumePrompt: () => null,
|
||||
};
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -194,26 +194,6 @@ export default class BackendAPI {
|
||||
return this._request("PATCH", "/credits");
|
||||
}
|
||||
|
||||
getSubscription(): Promise<{
|
||||
tier: string;
|
||||
monthly_cost: number;
|
||||
tier_costs: Record<string, number>;
|
||||
}> {
|
||||
return this._get("/credits/subscription");
|
||||
}
|
||||
|
||||
setSubscriptionTier(
|
||||
tier: string,
|
||||
successUrl?: string,
|
||||
cancelUrl?: string,
|
||||
): Promise<{ url: string }> {
|
||||
return this._request("POST", "/credits/subscription", {
|
||||
tier,
|
||||
success_url: successUrl ?? "",
|
||||
cancel_url: cancelUrl ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
////////////////////////////////////////
|
||||
//////////////// GRAPHS ////////////////
|
||||
////////////////////////////////////////
|
||||
|
||||
@@ -385,7 +385,9 @@ test("library happy path: user can edit a saved agent from Library and keep chan
|
||||
.context()
|
||||
.waitForEvent("page", { timeout: 10000 })
|
||||
.catch(() => null);
|
||||
await agentCard
|
||||
// "Edit agent" link is inside the three-dot dropdown menu
|
||||
await agentCard.getByRole("button", { name: "More actions" }).first().click();
|
||||
await page
|
||||
.getByTestId("library-agent-card-open-in-builder-link")
|
||||
.first()
|
||||
.click();
|
||||
|
||||
@@ -262,13 +262,19 @@ export class LibraryPage extends BasePage {
|
||||
async clickOpenInBuilder(agent: Agent): Promise<void> {
|
||||
console.log(`clicking open in builder for agent: ${agent.name}`);
|
||||
|
||||
const { getId } = getSelectors(this.page);
|
||||
const agentCard = getId("library-agent-card").filter({
|
||||
hasText: agent.name,
|
||||
const agentCard = this.page
|
||||
.getByTestId("library-agent-card")
|
||||
.filter({ hasText: agent.name });
|
||||
|
||||
// The "Edit agent" link is inside the three-dot dropdown menu.
|
||||
// Open the menu first, then click the builder link.
|
||||
const menuTrigger = agentCard.getByRole("button", {
|
||||
name: "More actions",
|
||||
});
|
||||
const builderLink = getId(
|
||||
await menuTrigger.first().click();
|
||||
|
||||
const builderLink = this.page.getByTestId(
|
||||
"library-agent-card-open-in-builder-link",
|
||||
agentCard,
|
||||
);
|
||||
await builderLink.first().click();
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export enum Flag {
|
||||
ARTIFACTS = "artifacts",
|
||||
CHAT_MODE_OPTION = "chat-mode-option",
|
||||
BUILDER_CHAT_PANEL = "builder-chat-panel",
|
||||
AGENT_BRIEFING = "agent-briefing",
|
||||
}
|
||||
|
||||
const isPwMockEnabled = process.env.NEXT_PUBLIC_PW_TEST === "true";
|
||||
@@ -22,6 +23,7 @@ const defaultFlags = {
|
||||
[Flag.ARTIFACTS]: false,
|
||||
[Flag.CHAT_MODE_OPTION]: false,
|
||||
[Flag.BUILDER_CHAT_PANEL]: false,
|
||||
[Flag.AGENT_BRIEFING]: false,
|
||||
};
|
||||
|
||||
type FlagValues = typeof defaultFlags;
|
||||
|
||||
@@ -65,7 +65,7 @@ The result routes data to yes_output or no_output, enabling intelligent branchin
|
||||
| condition | A plaintext English description of the condition to evaluate | str | Yes |
|
||||
| yes_value | (Optional) Value to output if the condition is true. If not provided, input_value will be used. | Yes Value | No |
|
||||
| no_value | (Optional) Value to output if the condition is false. If not provided, input_value will be used. | No Value | No |
|
||||
| model | The language model to use for evaluating the condition. | "o3-mini" \| "o3-2025-04-16" \| "o1" \| "o1-mini" \| "gpt-5.2-2025-12-11" \| "gpt-5.1-2025-11-13" \| "gpt-5-2025-08-07" \| "gpt-5-mini-2025-08-07" \| "gpt-5-nano-2025-08-07" \| "gpt-5-chat-latest" \| "gpt-4.1-2025-04-14" \| "gpt-4.1-mini-2025-04-14" \| "gpt-4o-mini" \| "gpt-4o" \| "gpt-4-turbo" \| "claude-opus-4-1-20250805" \| "claude-opus-4-20250514" \| "claude-sonnet-4-20250514" \| "claude-opus-4-5-20251101" \| "claude-sonnet-4-5-20250929" \| "claude-haiku-4-5-20251001" \| "claude-opus-4-6" \| "claude-sonnet-4-6" \| "claude-3-haiku-20240307" \| "Qwen/Qwen2.5-72B-Instruct-Turbo" \| "nvidia/llama-3.1-nemotron-70b-instruct" \| "meta-llama/Llama-3.3-70B-Instruct-Turbo" \| "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo" \| "meta-llama/Llama-3.2-3B-Instruct-Turbo" \| "llama-3.3-70b-versatile" \| "llama-3.1-8b-instant" \| "llama3.3" \| "llama3.2" \| "llama3" \| "llama3.1:405b" \| "dolphin-mistral:latest" \| "openai/gpt-oss-120b" \| "openai/gpt-oss-20b" \| "google/gemini-2.5-pro-preview-03-25" \| "google/gemini-2.5-pro" \| "google/gemini-3.1-pro-preview" \| "google/gemini-3-flash-preview" \| "google/gemini-2.5-flash" \| "google/gemini-2.0-flash-001" \| "google/gemini-3.1-flash-lite-preview" \| "google/gemini-2.5-flash-lite-preview-06-17" \| "google/gemini-2.0-flash-lite-001" \| "mistralai/mistral-nemo" \| "mistralai/mistral-large-2512" \| "mistralai/mistral-medium-3.1" \| "mistralai/mistral-small-3.2-24b-instruct" \| "mistralai/codestral-2508" \| "cohere/command-r-08-2024" \| "cohere/command-r-plus-08-2024" \| "cohere/command-a-03-2025" \| "cohere/command-a-translate-08-2025" \| "cohere/command-a-reasoning-08-2025" \| "cohere/command-a-vision-07-2025" \| "deepseek/deepseek-chat" \| "deepseek/deepseek-r1-0528" \| "perplexity/sonar" \| "perplexity/sonar-pro" \| "perplexity/sonar-reasoning-pro" \| "perplexity/sonar-deep-research" \| "nousresearch/hermes-3-llama-3.1-405b" \| "nousresearch/hermes-3-llama-3.1-70b" \| "amazon/nova-lite-v1" \| "amazon/nova-micro-v1" \| "amazon/nova-pro-v1" \| "microsoft/wizardlm-2-8x22b" \| "microsoft/phi-4" \| "gryphe/mythomax-l2-13b" \| "meta-llama/llama-4-scout" \| "meta-llama/llama-4-maverick" \| "x-ai/grok-3" \| "x-ai/grok-4" \| "x-ai/grok-4-fast" \| "x-ai/grok-4.1-fast" \| "x-ai/grok-code-fast-1" \| "moonshotai/kimi-k2" \| "qwen/qwen3-235b-a22b-thinking-2507" \| "qwen/qwen3-coder" \| "z-ai/glm-4-32b" \| "z-ai/glm-4.5" \| "z-ai/glm-4.5-air" \| "z-ai/glm-4.5-air:free" \| "z-ai/glm-4.5v" \| "z-ai/glm-4.6" \| "z-ai/glm-4.6v" \| "z-ai/glm-4.7" \| "z-ai/glm-4.7-flash" \| "z-ai/glm-5" \| "z-ai/glm-5-turbo" \| "z-ai/glm-5v-turbo" \| "Llama-4-Scout-17B-16E-Instruct-FP8" \| "Llama-4-Maverick-17B-128E-Instruct-FP8" \| "Llama-3.3-8B-Instruct" \| "Llama-3.3-70B-Instruct" \| "v0-1.5-md" \| "v0-1.5-lg" \| "v0-1.0-md" | No |
|
||||
| model | The language model to use for evaluating the condition. | "o3-mini" \| "o3-2025-04-16" \| "o1" \| "o1-mini" \| "gpt-5.2-2025-12-11" \| "gpt-5.1-2025-11-13" \| "gpt-5-2025-08-07" \| "gpt-5-mini-2025-08-07" \| "gpt-5-nano-2025-08-07" \| "gpt-5-chat-latest" \| "gpt-4.1-2025-04-14" \| "gpt-4.1-mini-2025-04-14" \| "gpt-4o-mini" \| "gpt-4o" \| "gpt-4-turbo" \| "claude-opus-4-1-20250805" \| "claude-opus-4-20250514" \| "claude-sonnet-4-20250514" \| "claude-opus-4-5-20251101" \| "claude-sonnet-4-5-20250929" \| "claude-haiku-4-5-20251001" \| "claude-opus-4-6" \| "claude-sonnet-4-6" \| "claude-3-haiku-20240307" \| "Qwen/Qwen2.5-72B-Instruct-Turbo" \| "nvidia/llama-3.1-nemotron-70b-instruct" \| "meta-llama/Llama-3.3-70B-Instruct-Turbo" \| "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo" \| "meta-llama/Llama-3.2-3B-Instruct-Turbo" \| "llama-3.3-70b-versatile" \| "llama-3.1-8b-instant" \| "llama3.3" \| "llama3.2" \| "llama3" \| "llama3.1:405b" \| "dolphin-mistral:latest" \| "openai/gpt-oss-120b" \| "openai/gpt-oss-20b" \| "google/gemini-2.5-pro-preview-03-25" \| "google/gemini-2.5-pro" \| "google/gemini-3.1-pro-preview" \| "google/gemini-3-flash-preview" \| "google/gemini-2.5-flash" \| "google/gemini-2.0-flash-001" \| "google/gemini-3.1-flash-lite-preview" \| "google/gemini-2.5-flash-lite-preview-06-17" \| "google/gemini-2.0-flash-lite-001" \| "mistralai/mistral-nemo" \| "mistralai/mistral-large-2512" \| "mistralai/mistral-medium-3.1" \| "mistralai/mistral-small-3.2-24b-instruct" \| "mistralai/codestral-2508" \| "cohere/command-r-08-2024" \| "cohere/command-r-plus-08-2024" \| "cohere/command-a-03-2025" \| "cohere/command-a-translate-08-2025" \| "cohere/command-a-reasoning-08-2025" \| "cohere/command-a-vision-07-2025" \| "deepseek/deepseek-chat" \| "deepseek/deepseek-r1-0528" \| "perplexity/sonar" \| "perplexity/sonar-pro" \| "perplexity/sonar-reasoning-pro" \| "perplexity/sonar-deep-research" \| "nousresearch/hermes-3-llama-3.1-405b" \| "nousresearch/hermes-3-llama-3.1-70b" \| "amazon/nova-lite-v1" \| "amazon/nova-micro-v1" \| "amazon/nova-pro-v1" \| "microsoft/wizardlm-2-8x22b" \| "microsoft/phi-4" \| "gryphe/mythomax-l2-13b" \| "meta-llama/llama-4-scout" \| "meta-llama/llama-4-maverick" \| "x-ai/grok-3" \| "x-ai/grok-4" \| "x-ai/grok-4-fast" \| "x-ai/grok-4.1-fast" \| "x-ai/grok-4.20" \| "x-ai/grok-4.20-multi-agent" \| "x-ai/grok-code-fast-1" \| "moonshotai/kimi-k2" \| "qwen/qwen3-235b-a22b-thinking-2507" \| "qwen/qwen3-coder" \| "z-ai/glm-4-32b" \| "z-ai/glm-4.5" \| "z-ai/glm-4.5-air" \| "z-ai/glm-4.5-air:free" \| "z-ai/glm-4.5v" \| "z-ai/glm-4.6" \| "z-ai/glm-4.6v" \| "z-ai/glm-4.7" \| "z-ai/glm-4.7-flash" \| "z-ai/glm-5" \| "z-ai/glm-5-turbo" \| "z-ai/glm-5v-turbo" \| "Llama-4-Scout-17B-16E-Instruct-FP8" \| "Llama-4-Maverick-17B-128E-Instruct-FP8" \| "Llama-3.3-8B-Instruct" \| "Llama-3.3-70B-Instruct" \| "v0-1.5-md" \| "v0-1.5-lg" \| "v0-1.0-md" | No |
|
||||
|
||||
### Outputs
|
||||
|
||||
@@ -103,7 +103,7 @@ The block sends the entire conversation history to the chosen LLM, including sys
|
||||
|-------|-------------|------|----------|
|
||||
| prompt | The prompt to send to the language model. | str | No |
|
||||
| messages | List of messages in the conversation. | List[Any] | Yes |
|
||||
| model | The language model to use for the conversation. | "o3-mini" \| "o3-2025-04-16" \| "o1" \| "o1-mini" \| "gpt-5.2-2025-12-11" \| "gpt-5.1-2025-11-13" \| "gpt-5-2025-08-07" \| "gpt-5-mini-2025-08-07" \| "gpt-5-nano-2025-08-07" \| "gpt-5-chat-latest" \| "gpt-4.1-2025-04-14" \| "gpt-4.1-mini-2025-04-14" \| "gpt-4o-mini" \| "gpt-4o" \| "gpt-4-turbo" \| "claude-opus-4-1-20250805" \| "claude-opus-4-20250514" \| "claude-sonnet-4-20250514" \| "claude-opus-4-5-20251101" \| "claude-sonnet-4-5-20250929" \| "claude-haiku-4-5-20251001" \| "claude-opus-4-6" \| "claude-sonnet-4-6" \| "claude-3-haiku-20240307" \| "Qwen/Qwen2.5-72B-Instruct-Turbo" \| "nvidia/llama-3.1-nemotron-70b-instruct" \| "meta-llama/Llama-3.3-70B-Instruct-Turbo" \| "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo" \| "meta-llama/Llama-3.2-3B-Instruct-Turbo" \| "llama-3.3-70b-versatile" \| "llama-3.1-8b-instant" \| "llama3.3" \| "llama3.2" \| "llama3" \| "llama3.1:405b" \| "dolphin-mistral:latest" \| "openai/gpt-oss-120b" \| "openai/gpt-oss-20b" \| "google/gemini-2.5-pro-preview-03-25" \| "google/gemini-2.5-pro" \| "google/gemini-3.1-pro-preview" \| "google/gemini-3-flash-preview" \| "google/gemini-2.5-flash" \| "google/gemini-2.0-flash-001" \| "google/gemini-3.1-flash-lite-preview" \| "google/gemini-2.5-flash-lite-preview-06-17" \| "google/gemini-2.0-flash-lite-001" \| "mistralai/mistral-nemo" \| "mistralai/mistral-large-2512" \| "mistralai/mistral-medium-3.1" \| "mistralai/mistral-small-3.2-24b-instruct" \| "mistralai/codestral-2508" \| "cohere/command-r-08-2024" \| "cohere/command-r-plus-08-2024" \| "cohere/command-a-03-2025" \| "cohere/command-a-translate-08-2025" \| "cohere/command-a-reasoning-08-2025" \| "cohere/command-a-vision-07-2025" \| "deepseek/deepseek-chat" \| "deepseek/deepseek-r1-0528" \| "perplexity/sonar" \| "perplexity/sonar-pro" \| "perplexity/sonar-reasoning-pro" \| "perplexity/sonar-deep-research" \| "nousresearch/hermes-3-llama-3.1-405b" \| "nousresearch/hermes-3-llama-3.1-70b" \| "amazon/nova-lite-v1" \| "amazon/nova-micro-v1" \| "amazon/nova-pro-v1" \| "microsoft/wizardlm-2-8x22b" \| "microsoft/phi-4" \| "gryphe/mythomax-l2-13b" \| "meta-llama/llama-4-scout" \| "meta-llama/llama-4-maverick" \| "x-ai/grok-3" \| "x-ai/grok-4" \| "x-ai/grok-4-fast" \| "x-ai/grok-4.1-fast" \| "x-ai/grok-code-fast-1" \| "moonshotai/kimi-k2" \| "qwen/qwen3-235b-a22b-thinking-2507" \| "qwen/qwen3-coder" \| "z-ai/glm-4-32b" \| "z-ai/glm-4.5" \| "z-ai/glm-4.5-air" \| "z-ai/glm-4.5-air:free" \| "z-ai/glm-4.5v" \| "z-ai/glm-4.6" \| "z-ai/glm-4.6v" \| "z-ai/glm-4.7" \| "z-ai/glm-4.7-flash" \| "z-ai/glm-5" \| "z-ai/glm-5-turbo" \| "z-ai/glm-5v-turbo" \| "Llama-4-Scout-17B-16E-Instruct-FP8" \| "Llama-4-Maverick-17B-128E-Instruct-FP8" \| "Llama-3.3-8B-Instruct" \| "Llama-3.3-70B-Instruct" \| "v0-1.5-md" \| "v0-1.5-lg" \| "v0-1.0-md" | No |
|
||||
| model | The language model to use for the conversation. | "o3-mini" \| "o3-2025-04-16" \| "o1" \| "o1-mini" \| "gpt-5.2-2025-12-11" \| "gpt-5.1-2025-11-13" \| "gpt-5-2025-08-07" \| "gpt-5-mini-2025-08-07" \| "gpt-5-nano-2025-08-07" \| "gpt-5-chat-latest" \| "gpt-4.1-2025-04-14" \| "gpt-4.1-mini-2025-04-14" \| "gpt-4o-mini" \| "gpt-4o" \| "gpt-4-turbo" \| "claude-opus-4-1-20250805" \| "claude-opus-4-20250514" \| "claude-sonnet-4-20250514" \| "claude-opus-4-5-20251101" \| "claude-sonnet-4-5-20250929" \| "claude-haiku-4-5-20251001" \| "claude-opus-4-6" \| "claude-sonnet-4-6" \| "claude-3-haiku-20240307" \| "Qwen/Qwen2.5-72B-Instruct-Turbo" \| "nvidia/llama-3.1-nemotron-70b-instruct" \| "meta-llama/Llama-3.3-70B-Instruct-Turbo" \| "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo" \| "meta-llama/Llama-3.2-3B-Instruct-Turbo" \| "llama-3.3-70b-versatile" \| "llama-3.1-8b-instant" \| "llama3.3" \| "llama3.2" \| "llama3" \| "llama3.1:405b" \| "dolphin-mistral:latest" \| "openai/gpt-oss-120b" \| "openai/gpt-oss-20b" \| "google/gemini-2.5-pro-preview-03-25" \| "google/gemini-2.5-pro" \| "google/gemini-3.1-pro-preview" \| "google/gemini-3-flash-preview" \| "google/gemini-2.5-flash" \| "google/gemini-2.0-flash-001" \| "google/gemini-3.1-flash-lite-preview" \| "google/gemini-2.5-flash-lite-preview-06-17" \| "google/gemini-2.0-flash-lite-001" \| "mistralai/mistral-nemo" \| "mistralai/mistral-large-2512" \| "mistralai/mistral-medium-3.1" \| "mistralai/mistral-small-3.2-24b-instruct" \| "mistralai/codestral-2508" \| "cohere/command-r-08-2024" \| "cohere/command-r-plus-08-2024" \| "cohere/command-a-03-2025" \| "cohere/command-a-translate-08-2025" \| "cohere/command-a-reasoning-08-2025" \| "cohere/command-a-vision-07-2025" \| "deepseek/deepseek-chat" \| "deepseek/deepseek-r1-0528" \| "perplexity/sonar" \| "perplexity/sonar-pro" \| "perplexity/sonar-reasoning-pro" \| "perplexity/sonar-deep-research" \| "nousresearch/hermes-3-llama-3.1-405b" \| "nousresearch/hermes-3-llama-3.1-70b" \| "amazon/nova-lite-v1" \| "amazon/nova-micro-v1" \| "amazon/nova-pro-v1" \| "microsoft/wizardlm-2-8x22b" \| "microsoft/phi-4" \| "gryphe/mythomax-l2-13b" \| "meta-llama/llama-4-scout" \| "meta-llama/llama-4-maverick" \| "x-ai/grok-3" \| "x-ai/grok-4" \| "x-ai/grok-4-fast" \| "x-ai/grok-4.1-fast" \| "x-ai/grok-4.20" \| "x-ai/grok-4.20-multi-agent" \| "x-ai/grok-code-fast-1" \| "moonshotai/kimi-k2" \| "qwen/qwen3-235b-a22b-thinking-2507" \| "qwen/qwen3-coder" \| "z-ai/glm-4-32b" \| "z-ai/glm-4.5" \| "z-ai/glm-4.5-air" \| "z-ai/glm-4.5-air:free" \| "z-ai/glm-4.5v" \| "z-ai/glm-4.6" \| "z-ai/glm-4.6v" \| "z-ai/glm-4.7" \| "z-ai/glm-4.7-flash" \| "z-ai/glm-5" \| "z-ai/glm-5-turbo" \| "z-ai/glm-5v-turbo" \| "Llama-4-Scout-17B-16E-Instruct-FP8" \| "Llama-4-Maverick-17B-128E-Instruct-FP8" \| "Llama-3.3-8B-Instruct" \| "Llama-3.3-70B-Instruct" \| "v0-1.5-md" \| "v0-1.5-lg" \| "v0-1.0-md" | No |
|
||||
| max_tokens | The maximum number of tokens to generate in the chat completion. | int | No |
|
||||
| ollama_host | Ollama host for local models | str | No |
|
||||
|
||||
@@ -257,7 +257,7 @@ The block formulates a prompt based on the given focus or source data, sends it
|
||||
|-------|-------------|------|----------|
|
||||
| focus | The focus of the list to generate. | str | No |
|
||||
| source_data | The data to generate the list from. | str | No |
|
||||
| model | The language model to use for generating the list. | "o3-mini" \| "o3-2025-04-16" \| "o1" \| "o1-mini" \| "gpt-5.2-2025-12-11" \| "gpt-5.1-2025-11-13" \| "gpt-5-2025-08-07" \| "gpt-5-mini-2025-08-07" \| "gpt-5-nano-2025-08-07" \| "gpt-5-chat-latest" \| "gpt-4.1-2025-04-14" \| "gpt-4.1-mini-2025-04-14" \| "gpt-4o-mini" \| "gpt-4o" \| "gpt-4-turbo" \| "claude-opus-4-1-20250805" \| "claude-opus-4-20250514" \| "claude-sonnet-4-20250514" \| "claude-opus-4-5-20251101" \| "claude-sonnet-4-5-20250929" \| "claude-haiku-4-5-20251001" \| "claude-opus-4-6" \| "claude-sonnet-4-6" \| "claude-3-haiku-20240307" \| "Qwen/Qwen2.5-72B-Instruct-Turbo" \| "nvidia/llama-3.1-nemotron-70b-instruct" \| "meta-llama/Llama-3.3-70B-Instruct-Turbo" \| "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo" \| "meta-llama/Llama-3.2-3B-Instruct-Turbo" \| "llama-3.3-70b-versatile" \| "llama-3.1-8b-instant" \| "llama3.3" \| "llama3.2" \| "llama3" \| "llama3.1:405b" \| "dolphin-mistral:latest" \| "openai/gpt-oss-120b" \| "openai/gpt-oss-20b" \| "google/gemini-2.5-pro-preview-03-25" \| "google/gemini-2.5-pro" \| "google/gemini-3.1-pro-preview" \| "google/gemini-3-flash-preview" \| "google/gemini-2.5-flash" \| "google/gemini-2.0-flash-001" \| "google/gemini-3.1-flash-lite-preview" \| "google/gemini-2.5-flash-lite-preview-06-17" \| "google/gemini-2.0-flash-lite-001" \| "mistralai/mistral-nemo" \| "mistralai/mistral-large-2512" \| "mistralai/mistral-medium-3.1" \| "mistralai/mistral-small-3.2-24b-instruct" \| "mistralai/codestral-2508" \| "cohere/command-r-08-2024" \| "cohere/command-r-plus-08-2024" \| "cohere/command-a-03-2025" \| "cohere/command-a-translate-08-2025" \| "cohere/command-a-reasoning-08-2025" \| "cohere/command-a-vision-07-2025" \| "deepseek/deepseek-chat" \| "deepseek/deepseek-r1-0528" \| "perplexity/sonar" \| "perplexity/sonar-pro" \| "perplexity/sonar-reasoning-pro" \| "perplexity/sonar-deep-research" \| "nousresearch/hermes-3-llama-3.1-405b" \| "nousresearch/hermes-3-llama-3.1-70b" \| "amazon/nova-lite-v1" \| "amazon/nova-micro-v1" \| "amazon/nova-pro-v1" \| "microsoft/wizardlm-2-8x22b" \| "microsoft/phi-4" \| "gryphe/mythomax-l2-13b" \| "meta-llama/llama-4-scout" \| "meta-llama/llama-4-maverick" \| "x-ai/grok-3" \| "x-ai/grok-4" \| "x-ai/grok-4-fast" \| "x-ai/grok-4.1-fast" \| "x-ai/grok-code-fast-1" \| "moonshotai/kimi-k2" \| "qwen/qwen3-235b-a22b-thinking-2507" \| "qwen/qwen3-coder" \| "z-ai/glm-4-32b" \| "z-ai/glm-4.5" \| "z-ai/glm-4.5-air" \| "z-ai/glm-4.5-air:free" \| "z-ai/glm-4.5v" \| "z-ai/glm-4.6" \| "z-ai/glm-4.6v" \| "z-ai/glm-4.7" \| "z-ai/glm-4.7-flash" \| "z-ai/glm-5" \| "z-ai/glm-5-turbo" \| "z-ai/glm-5v-turbo" \| "Llama-4-Scout-17B-16E-Instruct-FP8" \| "Llama-4-Maverick-17B-128E-Instruct-FP8" \| "Llama-3.3-8B-Instruct" \| "Llama-3.3-70B-Instruct" \| "v0-1.5-md" \| "v0-1.5-lg" \| "v0-1.0-md" | No |
|
||||
| model | The language model to use for generating the list. | "o3-mini" \| "o3-2025-04-16" \| "o1" \| "o1-mini" \| "gpt-5.2-2025-12-11" \| "gpt-5.1-2025-11-13" \| "gpt-5-2025-08-07" \| "gpt-5-mini-2025-08-07" \| "gpt-5-nano-2025-08-07" \| "gpt-5-chat-latest" \| "gpt-4.1-2025-04-14" \| "gpt-4.1-mini-2025-04-14" \| "gpt-4o-mini" \| "gpt-4o" \| "gpt-4-turbo" \| "claude-opus-4-1-20250805" \| "claude-opus-4-20250514" \| "claude-sonnet-4-20250514" \| "claude-opus-4-5-20251101" \| "claude-sonnet-4-5-20250929" \| "claude-haiku-4-5-20251001" \| "claude-opus-4-6" \| "claude-sonnet-4-6" \| "claude-3-haiku-20240307" \| "Qwen/Qwen2.5-72B-Instruct-Turbo" \| "nvidia/llama-3.1-nemotron-70b-instruct" \| "meta-llama/Llama-3.3-70B-Instruct-Turbo" \| "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo" \| "meta-llama/Llama-3.2-3B-Instruct-Turbo" \| "llama-3.3-70b-versatile" \| "llama-3.1-8b-instant" \| "llama3.3" \| "llama3.2" \| "llama3" \| "llama3.1:405b" \| "dolphin-mistral:latest" \| "openai/gpt-oss-120b" \| "openai/gpt-oss-20b" \| "google/gemini-2.5-pro-preview-03-25" \| "google/gemini-2.5-pro" \| "google/gemini-3.1-pro-preview" \| "google/gemini-3-flash-preview" \| "google/gemini-2.5-flash" \| "google/gemini-2.0-flash-001" \| "google/gemini-3.1-flash-lite-preview" \| "google/gemini-2.5-flash-lite-preview-06-17" \| "google/gemini-2.0-flash-lite-001" \| "mistralai/mistral-nemo" \| "mistralai/mistral-large-2512" \| "mistralai/mistral-medium-3.1" \| "mistralai/mistral-small-3.2-24b-instruct" \| "mistralai/codestral-2508" \| "cohere/command-r-08-2024" \| "cohere/command-r-plus-08-2024" \| "cohere/command-a-03-2025" \| "cohere/command-a-translate-08-2025" \| "cohere/command-a-reasoning-08-2025" \| "cohere/command-a-vision-07-2025" \| "deepseek/deepseek-chat" \| "deepseek/deepseek-r1-0528" \| "perplexity/sonar" \| "perplexity/sonar-pro" \| "perplexity/sonar-reasoning-pro" \| "perplexity/sonar-deep-research" \| "nousresearch/hermes-3-llama-3.1-405b" \| "nousresearch/hermes-3-llama-3.1-70b" \| "amazon/nova-lite-v1" \| "amazon/nova-micro-v1" \| "amazon/nova-pro-v1" \| "microsoft/wizardlm-2-8x22b" \| "microsoft/phi-4" \| "gryphe/mythomax-l2-13b" \| "meta-llama/llama-4-scout" \| "meta-llama/llama-4-maverick" \| "x-ai/grok-3" \| "x-ai/grok-4" \| "x-ai/grok-4-fast" \| "x-ai/grok-4.1-fast" \| "x-ai/grok-4.20" \| "x-ai/grok-4.20-multi-agent" \| "x-ai/grok-code-fast-1" \| "moonshotai/kimi-k2" \| "qwen/qwen3-235b-a22b-thinking-2507" \| "qwen/qwen3-coder" \| "z-ai/glm-4-32b" \| "z-ai/glm-4.5" \| "z-ai/glm-4.5-air" \| "z-ai/glm-4.5-air:free" \| "z-ai/glm-4.5v" \| "z-ai/glm-4.6" \| "z-ai/glm-4.6v" \| "z-ai/glm-4.7" \| "z-ai/glm-4.7-flash" \| "z-ai/glm-5" \| "z-ai/glm-5-turbo" \| "z-ai/glm-5v-turbo" \| "Llama-4-Scout-17B-16E-Instruct-FP8" \| "Llama-4-Maverick-17B-128E-Instruct-FP8" \| "Llama-3.3-8B-Instruct" \| "Llama-3.3-70B-Instruct" \| "v0-1.5-md" \| "v0-1.5-lg" \| "v0-1.0-md" | No |
|
||||
| max_retries | Maximum number of retries for generating a valid list. | int | No |
|
||||
| force_json_output | Whether to force the LLM to produce a JSON-only response. This can increase the block's reliability, but may also reduce the quality of the response because it prohibits the LLM from reasoning before providing its JSON response. | bool | No |
|
||||
| max_tokens | The maximum number of tokens to generate in the chat completion. | int | No |
|
||||
@@ -424,7 +424,7 @@ The block sends the input prompt to a chosen LLM, along with any system prompts
|
||||
| prompt | The prompt to send to the language model. | str | Yes |
|
||||
| expected_format | Expected format of the response. If provided, the response will be validated against this format. The keys should be the expected fields in the response, and the values should be the description of the field. | Dict[str, str] | Yes |
|
||||
| list_result | Whether the response should be a list of objects in the expected format. | bool | No |
|
||||
| model | The language model to use for answering the prompt. | "o3-mini" \| "o3-2025-04-16" \| "o1" \| "o1-mini" \| "gpt-5.2-2025-12-11" \| "gpt-5.1-2025-11-13" \| "gpt-5-2025-08-07" \| "gpt-5-mini-2025-08-07" \| "gpt-5-nano-2025-08-07" \| "gpt-5-chat-latest" \| "gpt-4.1-2025-04-14" \| "gpt-4.1-mini-2025-04-14" \| "gpt-4o-mini" \| "gpt-4o" \| "gpt-4-turbo" \| "claude-opus-4-1-20250805" \| "claude-opus-4-20250514" \| "claude-sonnet-4-20250514" \| "claude-opus-4-5-20251101" \| "claude-sonnet-4-5-20250929" \| "claude-haiku-4-5-20251001" \| "claude-opus-4-6" \| "claude-sonnet-4-6" \| "claude-3-haiku-20240307" \| "Qwen/Qwen2.5-72B-Instruct-Turbo" \| "nvidia/llama-3.1-nemotron-70b-instruct" \| "meta-llama/Llama-3.3-70B-Instruct-Turbo" \| "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo" \| "meta-llama/Llama-3.2-3B-Instruct-Turbo" \| "llama-3.3-70b-versatile" \| "llama-3.1-8b-instant" \| "llama3.3" \| "llama3.2" \| "llama3" \| "llama3.1:405b" \| "dolphin-mistral:latest" \| "openai/gpt-oss-120b" \| "openai/gpt-oss-20b" \| "google/gemini-2.5-pro-preview-03-25" \| "google/gemini-2.5-pro" \| "google/gemini-3.1-pro-preview" \| "google/gemini-3-flash-preview" \| "google/gemini-2.5-flash" \| "google/gemini-2.0-flash-001" \| "google/gemini-3.1-flash-lite-preview" \| "google/gemini-2.5-flash-lite-preview-06-17" \| "google/gemini-2.0-flash-lite-001" \| "mistralai/mistral-nemo" \| "mistralai/mistral-large-2512" \| "mistralai/mistral-medium-3.1" \| "mistralai/mistral-small-3.2-24b-instruct" \| "mistralai/codestral-2508" \| "cohere/command-r-08-2024" \| "cohere/command-r-plus-08-2024" \| "cohere/command-a-03-2025" \| "cohere/command-a-translate-08-2025" \| "cohere/command-a-reasoning-08-2025" \| "cohere/command-a-vision-07-2025" \| "deepseek/deepseek-chat" \| "deepseek/deepseek-r1-0528" \| "perplexity/sonar" \| "perplexity/sonar-pro" \| "perplexity/sonar-reasoning-pro" \| "perplexity/sonar-deep-research" \| "nousresearch/hermes-3-llama-3.1-405b" \| "nousresearch/hermes-3-llama-3.1-70b" \| "amazon/nova-lite-v1" \| "amazon/nova-micro-v1" \| "amazon/nova-pro-v1" \| "microsoft/wizardlm-2-8x22b" \| "microsoft/phi-4" \| "gryphe/mythomax-l2-13b" \| "meta-llama/llama-4-scout" \| "meta-llama/llama-4-maverick" \| "x-ai/grok-3" \| "x-ai/grok-4" \| "x-ai/grok-4-fast" \| "x-ai/grok-4.1-fast" \| "x-ai/grok-code-fast-1" \| "moonshotai/kimi-k2" \| "qwen/qwen3-235b-a22b-thinking-2507" \| "qwen/qwen3-coder" \| "z-ai/glm-4-32b" \| "z-ai/glm-4.5" \| "z-ai/glm-4.5-air" \| "z-ai/glm-4.5-air:free" \| "z-ai/glm-4.5v" \| "z-ai/glm-4.6" \| "z-ai/glm-4.6v" \| "z-ai/glm-4.7" \| "z-ai/glm-4.7-flash" \| "z-ai/glm-5" \| "z-ai/glm-5-turbo" \| "z-ai/glm-5v-turbo" \| "Llama-4-Scout-17B-16E-Instruct-FP8" \| "Llama-4-Maverick-17B-128E-Instruct-FP8" \| "Llama-3.3-8B-Instruct" \| "Llama-3.3-70B-Instruct" \| "v0-1.5-md" \| "v0-1.5-lg" \| "v0-1.0-md" | No |
|
||||
| model | The language model to use for answering the prompt. | "o3-mini" \| "o3-2025-04-16" \| "o1" \| "o1-mini" \| "gpt-5.2-2025-12-11" \| "gpt-5.1-2025-11-13" \| "gpt-5-2025-08-07" \| "gpt-5-mini-2025-08-07" \| "gpt-5-nano-2025-08-07" \| "gpt-5-chat-latest" \| "gpt-4.1-2025-04-14" \| "gpt-4.1-mini-2025-04-14" \| "gpt-4o-mini" \| "gpt-4o" \| "gpt-4-turbo" \| "claude-opus-4-1-20250805" \| "claude-opus-4-20250514" \| "claude-sonnet-4-20250514" \| "claude-opus-4-5-20251101" \| "claude-sonnet-4-5-20250929" \| "claude-haiku-4-5-20251001" \| "claude-opus-4-6" \| "claude-sonnet-4-6" \| "claude-3-haiku-20240307" \| "Qwen/Qwen2.5-72B-Instruct-Turbo" \| "nvidia/llama-3.1-nemotron-70b-instruct" \| "meta-llama/Llama-3.3-70B-Instruct-Turbo" \| "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo" \| "meta-llama/Llama-3.2-3B-Instruct-Turbo" \| "llama-3.3-70b-versatile" \| "llama-3.1-8b-instant" \| "llama3.3" \| "llama3.2" \| "llama3" \| "llama3.1:405b" \| "dolphin-mistral:latest" \| "openai/gpt-oss-120b" \| "openai/gpt-oss-20b" \| "google/gemini-2.5-pro-preview-03-25" \| "google/gemini-2.5-pro" \| "google/gemini-3.1-pro-preview" \| "google/gemini-3-flash-preview" \| "google/gemini-2.5-flash" \| "google/gemini-2.0-flash-001" \| "google/gemini-3.1-flash-lite-preview" \| "google/gemini-2.5-flash-lite-preview-06-17" \| "google/gemini-2.0-flash-lite-001" \| "mistralai/mistral-nemo" \| "mistralai/mistral-large-2512" \| "mistralai/mistral-medium-3.1" \| "mistralai/mistral-small-3.2-24b-instruct" \| "mistralai/codestral-2508" \| "cohere/command-r-08-2024" \| "cohere/command-r-plus-08-2024" \| "cohere/command-a-03-2025" \| "cohere/command-a-translate-08-2025" \| "cohere/command-a-reasoning-08-2025" \| "cohere/command-a-vision-07-2025" \| "deepseek/deepseek-chat" \| "deepseek/deepseek-r1-0528" \| "perplexity/sonar" \| "perplexity/sonar-pro" \| "perplexity/sonar-reasoning-pro" \| "perplexity/sonar-deep-research" \| "nousresearch/hermes-3-llama-3.1-405b" \| "nousresearch/hermes-3-llama-3.1-70b" \| "amazon/nova-lite-v1" \| "amazon/nova-micro-v1" \| "amazon/nova-pro-v1" \| "microsoft/wizardlm-2-8x22b" \| "microsoft/phi-4" \| "gryphe/mythomax-l2-13b" \| "meta-llama/llama-4-scout" \| "meta-llama/llama-4-maverick" \| "x-ai/grok-3" \| "x-ai/grok-4" \| "x-ai/grok-4-fast" \| "x-ai/grok-4.1-fast" \| "x-ai/grok-4.20" \| "x-ai/grok-4.20-multi-agent" \| "x-ai/grok-code-fast-1" \| "moonshotai/kimi-k2" \| "qwen/qwen3-235b-a22b-thinking-2507" \| "qwen/qwen3-coder" \| "z-ai/glm-4-32b" \| "z-ai/glm-4.5" \| "z-ai/glm-4.5-air" \| "z-ai/glm-4.5-air:free" \| "z-ai/glm-4.5v" \| "z-ai/glm-4.6" \| "z-ai/glm-4.6v" \| "z-ai/glm-4.7" \| "z-ai/glm-4.7-flash" \| "z-ai/glm-5" \| "z-ai/glm-5-turbo" \| "z-ai/glm-5v-turbo" \| "Llama-4-Scout-17B-16E-Instruct-FP8" \| "Llama-4-Maverick-17B-128E-Instruct-FP8" \| "Llama-3.3-8B-Instruct" \| "Llama-3.3-70B-Instruct" \| "v0-1.5-md" \| "v0-1.5-lg" \| "v0-1.0-md" | No |
|
||||
| force_json_output | Whether to force the LLM to produce a JSON-only response. This can increase the block's reliability, but may also reduce the quality of the response because it prohibits the LLM from reasoning before providing its JSON response. | bool | No |
|
||||
| sys_prompt | The system prompt to provide additional context to the model. | str | No |
|
||||
| conversation_history | The conversation history to provide context for the prompt. | List[Dict[str, Any]] | No |
|
||||
@@ -464,7 +464,7 @@ The block sends the input prompt to a chosen LLM, processes the response, and re
|
||||
| Input | Description | Type | Required |
|
||||
|-------|-------------|------|----------|
|
||||
| prompt | The prompt to send to the language model. You can use any of the {keys} from Prompt Values to fill in the prompt with values from the prompt values dictionary by putting them in curly braces. | str | Yes |
|
||||
| model | The language model to use for answering the prompt. | "o3-mini" \| "o3-2025-04-16" \| "o1" \| "o1-mini" \| "gpt-5.2-2025-12-11" \| "gpt-5.1-2025-11-13" \| "gpt-5-2025-08-07" \| "gpt-5-mini-2025-08-07" \| "gpt-5-nano-2025-08-07" \| "gpt-5-chat-latest" \| "gpt-4.1-2025-04-14" \| "gpt-4.1-mini-2025-04-14" \| "gpt-4o-mini" \| "gpt-4o" \| "gpt-4-turbo" \| "claude-opus-4-1-20250805" \| "claude-opus-4-20250514" \| "claude-sonnet-4-20250514" \| "claude-opus-4-5-20251101" \| "claude-sonnet-4-5-20250929" \| "claude-haiku-4-5-20251001" \| "claude-opus-4-6" \| "claude-sonnet-4-6" \| "claude-3-haiku-20240307" \| "Qwen/Qwen2.5-72B-Instruct-Turbo" \| "nvidia/llama-3.1-nemotron-70b-instruct" \| "meta-llama/Llama-3.3-70B-Instruct-Turbo" \| "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo" \| "meta-llama/Llama-3.2-3B-Instruct-Turbo" \| "llama-3.3-70b-versatile" \| "llama-3.1-8b-instant" \| "llama3.3" \| "llama3.2" \| "llama3" \| "llama3.1:405b" \| "dolphin-mistral:latest" \| "openai/gpt-oss-120b" \| "openai/gpt-oss-20b" \| "google/gemini-2.5-pro-preview-03-25" \| "google/gemini-2.5-pro" \| "google/gemini-3.1-pro-preview" \| "google/gemini-3-flash-preview" \| "google/gemini-2.5-flash" \| "google/gemini-2.0-flash-001" \| "google/gemini-3.1-flash-lite-preview" \| "google/gemini-2.5-flash-lite-preview-06-17" \| "google/gemini-2.0-flash-lite-001" \| "mistralai/mistral-nemo" \| "mistralai/mistral-large-2512" \| "mistralai/mistral-medium-3.1" \| "mistralai/mistral-small-3.2-24b-instruct" \| "mistralai/codestral-2508" \| "cohere/command-r-08-2024" \| "cohere/command-r-plus-08-2024" \| "cohere/command-a-03-2025" \| "cohere/command-a-translate-08-2025" \| "cohere/command-a-reasoning-08-2025" \| "cohere/command-a-vision-07-2025" \| "deepseek/deepseek-chat" \| "deepseek/deepseek-r1-0528" \| "perplexity/sonar" \| "perplexity/sonar-pro" \| "perplexity/sonar-reasoning-pro" \| "perplexity/sonar-deep-research" \| "nousresearch/hermes-3-llama-3.1-405b" \| "nousresearch/hermes-3-llama-3.1-70b" \| "amazon/nova-lite-v1" \| "amazon/nova-micro-v1" \| "amazon/nova-pro-v1" \| "microsoft/wizardlm-2-8x22b" \| "microsoft/phi-4" \| "gryphe/mythomax-l2-13b" \| "meta-llama/llama-4-scout" \| "meta-llama/llama-4-maverick" \| "x-ai/grok-3" \| "x-ai/grok-4" \| "x-ai/grok-4-fast" \| "x-ai/grok-4.1-fast" \| "x-ai/grok-code-fast-1" \| "moonshotai/kimi-k2" \| "qwen/qwen3-235b-a22b-thinking-2507" \| "qwen/qwen3-coder" \| "z-ai/glm-4-32b" \| "z-ai/glm-4.5" \| "z-ai/glm-4.5-air" \| "z-ai/glm-4.5-air:free" \| "z-ai/glm-4.5v" \| "z-ai/glm-4.6" \| "z-ai/glm-4.6v" \| "z-ai/glm-4.7" \| "z-ai/glm-4.7-flash" \| "z-ai/glm-5" \| "z-ai/glm-5-turbo" \| "z-ai/glm-5v-turbo" \| "Llama-4-Scout-17B-16E-Instruct-FP8" \| "Llama-4-Maverick-17B-128E-Instruct-FP8" \| "Llama-3.3-8B-Instruct" \| "Llama-3.3-70B-Instruct" \| "v0-1.5-md" \| "v0-1.5-lg" \| "v0-1.0-md" | No |
|
||||
| model | The language model to use for answering the prompt. | "o3-mini" \| "o3-2025-04-16" \| "o1" \| "o1-mini" \| "gpt-5.2-2025-12-11" \| "gpt-5.1-2025-11-13" \| "gpt-5-2025-08-07" \| "gpt-5-mini-2025-08-07" \| "gpt-5-nano-2025-08-07" \| "gpt-5-chat-latest" \| "gpt-4.1-2025-04-14" \| "gpt-4.1-mini-2025-04-14" \| "gpt-4o-mini" \| "gpt-4o" \| "gpt-4-turbo" \| "claude-opus-4-1-20250805" \| "claude-opus-4-20250514" \| "claude-sonnet-4-20250514" \| "claude-opus-4-5-20251101" \| "claude-sonnet-4-5-20250929" \| "claude-haiku-4-5-20251001" \| "claude-opus-4-6" \| "claude-sonnet-4-6" \| "claude-3-haiku-20240307" \| "Qwen/Qwen2.5-72B-Instruct-Turbo" \| "nvidia/llama-3.1-nemotron-70b-instruct" \| "meta-llama/Llama-3.3-70B-Instruct-Turbo" \| "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo" \| "meta-llama/Llama-3.2-3B-Instruct-Turbo" \| "llama-3.3-70b-versatile" \| "llama-3.1-8b-instant" \| "llama3.3" \| "llama3.2" \| "llama3" \| "llama3.1:405b" \| "dolphin-mistral:latest" \| "openai/gpt-oss-120b" \| "openai/gpt-oss-20b" \| "google/gemini-2.5-pro-preview-03-25" \| "google/gemini-2.5-pro" \| "google/gemini-3.1-pro-preview" \| "google/gemini-3-flash-preview" \| "google/gemini-2.5-flash" \| "google/gemini-2.0-flash-001" \| "google/gemini-3.1-flash-lite-preview" \| "google/gemini-2.5-flash-lite-preview-06-17" \| "google/gemini-2.0-flash-lite-001" \| "mistralai/mistral-nemo" \| "mistralai/mistral-large-2512" \| "mistralai/mistral-medium-3.1" \| "mistralai/mistral-small-3.2-24b-instruct" \| "mistralai/codestral-2508" \| "cohere/command-r-08-2024" \| "cohere/command-r-plus-08-2024" \| "cohere/command-a-03-2025" \| "cohere/command-a-translate-08-2025" \| "cohere/command-a-reasoning-08-2025" \| "cohere/command-a-vision-07-2025" \| "deepseek/deepseek-chat" \| "deepseek/deepseek-r1-0528" \| "perplexity/sonar" \| "perplexity/sonar-pro" \| "perplexity/sonar-reasoning-pro" \| "perplexity/sonar-deep-research" \| "nousresearch/hermes-3-llama-3.1-405b" \| "nousresearch/hermes-3-llama-3.1-70b" \| "amazon/nova-lite-v1" \| "amazon/nova-micro-v1" \| "amazon/nova-pro-v1" \| "microsoft/wizardlm-2-8x22b" \| "microsoft/phi-4" \| "gryphe/mythomax-l2-13b" \| "meta-llama/llama-4-scout" \| "meta-llama/llama-4-maverick" \| "x-ai/grok-3" \| "x-ai/grok-4" \| "x-ai/grok-4-fast" \| "x-ai/grok-4.1-fast" \| "x-ai/grok-4.20" \| "x-ai/grok-4.20-multi-agent" \| "x-ai/grok-code-fast-1" \| "moonshotai/kimi-k2" \| "qwen/qwen3-235b-a22b-thinking-2507" \| "qwen/qwen3-coder" \| "z-ai/glm-4-32b" \| "z-ai/glm-4.5" \| "z-ai/glm-4.5-air" \| "z-ai/glm-4.5-air:free" \| "z-ai/glm-4.5v" \| "z-ai/glm-4.6" \| "z-ai/glm-4.6v" \| "z-ai/glm-4.7" \| "z-ai/glm-4.7-flash" \| "z-ai/glm-5" \| "z-ai/glm-5-turbo" \| "z-ai/glm-5v-turbo" \| "Llama-4-Scout-17B-16E-Instruct-FP8" \| "Llama-4-Maverick-17B-128E-Instruct-FP8" \| "Llama-3.3-8B-Instruct" \| "Llama-3.3-70B-Instruct" \| "v0-1.5-md" \| "v0-1.5-lg" \| "v0-1.0-md" | No |
|
||||
| sys_prompt | The system prompt to provide additional context to the model. | str | No |
|
||||
| retry | Number of times to retry the LLM call if the response does not match the expected format. | int | No |
|
||||
| prompt_values | Values used to fill in the prompt. The values can be used in the prompt by putting them in a double curly braces, e.g. {{variable_name}}. | Dict[str, str] | No |
|
||||
@@ -501,7 +501,7 @@ The block splits the input text into smaller chunks, sends each chunk to an LLM
|
||||
| Input | Description | Type | Required |
|
||||
|-------|-------------|------|----------|
|
||||
| text | The text to summarize. | str | Yes |
|
||||
| model | The language model to use for summarizing the text. | "o3-mini" \| "o3-2025-04-16" \| "o1" \| "o1-mini" \| "gpt-5.2-2025-12-11" \| "gpt-5.1-2025-11-13" \| "gpt-5-2025-08-07" \| "gpt-5-mini-2025-08-07" \| "gpt-5-nano-2025-08-07" \| "gpt-5-chat-latest" \| "gpt-4.1-2025-04-14" \| "gpt-4.1-mini-2025-04-14" \| "gpt-4o-mini" \| "gpt-4o" \| "gpt-4-turbo" \| "claude-opus-4-1-20250805" \| "claude-opus-4-20250514" \| "claude-sonnet-4-20250514" \| "claude-opus-4-5-20251101" \| "claude-sonnet-4-5-20250929" \| "claude-haiku-4-5-20251001" \| "claude-opus-4-6" \| "claude-sonnet-4-6" \| "claude-3-haiku-20240307" \| "Qwen/Qwen2.5-72B-Instruct-Turbo" \| "nvidia/llama-3.1-nemotron-70b-instruct" \| "meta-llama/Llama-3.3-70B-Instruct-Turbo" \| "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo" \| "meta-llama/Llama-3.2-3B-Instruct-Turbo" \| "llama-3.3-70b-versatile" \| "llama-3.1-8b-instant" \| "llama3.3" \| "llama3.2" \| "llama3" \| "llama3.1:405b" \| "dolphin-mistral:latest" \| "openai/gpt-oss-120b" \| "openai/gpt-oss-20b" \| "google/gemini-2.5-pro-preview-03-25" \| "google/gemini-2.5-pro" \| "google/gemini-3.1-pro-preview" \| "google/gemini-3-flash-preview" \| "google/gemini-2.5-flash" \| "google/gemini-2.0-flash-001" \| "google/gemini-3.1-flash-lite-preview" \| "google/gemini-2.5-flash-lite-preview-06-17" \| "google/gemini-2.0-flash-lite-001" \| "mistralai/mistral-nemo" \| "mistralai/mistral-large-2512" \| "mistralai/mistral-medium-3.1" \| "mistralai/mistral-small-3.2-24b-instruct" \| "mistralai/codestral-2508" \| "cohere/command-r-08-2024" \| "cohere/command-r-plus-08-2024" \| "cohere/command-a-03-2025" \| "cohere/command-a-translate-08-2025" \| "cohere/command-a-reasoning-08-2025" \| "cohere/command-a-vision-07-2025" \| "deepseek/deepseek-chat" \| "deepseek/deepseek-r1-0528" \| "perplexity/sonar" \| "perplexity/sonar-pro" \| "perplexity/sonar-reasoning-pro" \| "perplexity/sonar-deep-research" \| "nousresearch/hermes-3-llama-3.1-405b" \| "nousresearch/hermes-3-llama-3.1-70b" \| "amazon/nova-lite-v1" \| "amazon/nova-micro-v1" \| "amazon/nova-pro-v1" \| "microsoft/wizardlm-2-8x22b" \| "microsoft/phi-4" \| "gryphe/mythomax-l2-13b" \| "meta-llama/llama-4-scout" \| "meta-llama/llama-4-maverick" \| "x-ai/grok-3" \| "x-ai/grok-4" \| "x-ai/grok-4-fast" \| "x-ai/grok-4.1-fast" \| "x-ai/grok-code-fast-1" \| "moonshotai/kimi-k2" \| "qwen/qwen3-235b-a22b-thinking-2507" \| "qwen/qwen3-coder" \| "z-ai/glm-4-32b" \| "z-ai/glm-4.5" \| "z-ai/glm-4.5-air" \| "z-ai/glm-4.5-air:free" \| "z-ai/glm-4.5v" \| "z-ai/glm-4.6" \| "z-ai/glm-4.6v" \| "z-ai/glm-4.7" \| "z-ai/glm-4.7-flash" \| "z-ai/glm-5" \| "z-ai/glm-5-turbo" \| "z-ai/glm-5v-turbo" \| "Llama-4-Scout-17B-16E-Instruct-FP8" \| "Llama-4-Maverick-17B-128E-Instruct-FP8" \| "Llama-3.3-8B-Instruct" \| "Llama-3.3-70B-Instruct" \| "v0-1.5-md" \| "v0-1.5-lg" \| "v0-1.0-md" | No |
|
||||
| model | The language model to use for summarizing the text. | "o3-mini" \| "o3-2025-04-16" \| "o1" \| "o1-mini" \| "gpt-5.2-2025-12-11" \| "gpt-5.1-2025-11-13" \| "gpt-5-2025-08-07" \| "gpt-5-mini-2025-08-07" \| "gpt-5-nano-2025-08-07" \| "gpt-5-chat-latest" \| "gpt-4.1-2025-04-14" \| "gpt-4.1-mini-2025-04-14" \| "gpt-4o-mini" \| "gpt-4o" \| "gpt-4-turbo" \| "claude-opus-4-1-20250805" \| "claude-opus-4-20250514" \| "claude-sonnet-4-20250514" \| "claude-opus-4-5-20251101" \| "claude-sonnet-4-5-20250929" \| "claude-haiku-4-5-20251001" \| "claude-opus-4-6" \| "claude-sonnet-4-6" \| "claude-3-haiku-20240307" \| "Qwen/Qwen2.5-72B-Instruct-Turbo" \| "nvidia/llama-3.1-nemotron-70b-instruct" \| "meta-llama/Llama-3.3-70B-Instruct-Turbo" \| "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo" \| "meta-llama/Llama-3.2-3B-Instruct-Turbo" \| "llama-3.3-70b-versatile" \| "llama-3.1-8b-instant" \| "llama3.3" \| "llama3.2" \| "llama3" \| "llama3.1:405b" \| "dolphin-mistral:latest" \| "openai/gpt-oss-120b" \| "openai/gpt-oss-20b" \| "google/gemini-2.5-pro-preview-03-25" \| "google/gemini-2.5-pro" \| "google/gemini-3.1-pro-preview" \| "google/gemini-3-flash-preview" \| "google/gemini-2.5-flash" \| "google/gemini-2.0-flash-001" \| "google/gemini-3.1-flash-lite-preview" \| "google/gemini-2.5-flash-lite-preview-06-17" \| "google/gemini-2.0-flash-lite-001" \| "mistralai/mistral-nemo" \| "mistralai/mistral-large-2512" \| "mistralai/mistral-medium-3.1" \| "mistralai/mistral-small-3.2-24b-instruct" \| "mistralai/codestral-2508" \| "cohere/command-r-08-2024" \| "cohere/command-r-plus-08-2024" \| "cohere/command-a-03-2025" \| "cohere/command-a-translate-08-2025" \| "cohere/command-a-reasoning-08-2025" \| "cohere/command-a-vision-07-2025" \| "deepseek/deepseek-chat" \| "deepseek/deepseek-r1-0528" \| "perplexity/sonar" \| "perplexity/sonar-pro" \| "perplexity/sonar-reasoning-pro" \| "perplexity/sonar-deep-research" \| "nousresearch/hermes-3-llama-3.1-405b" \| "nousresearch/hermes-3-llama-3.1-70b" \| "amazon/nova-lite-v1" \| "amazon/nova-micro-v1" \| "amazon/nova-pro-v1" \| "microsoft/wizardlm-2-8x22b" \| "microsoft/phi-4" \| "gryphe/mythomax-l2-13b" \| "meta-llama/llama-4-scout" \| "meta-llama/llama-4-maverick" \| "x-ai/grok-3" \| "x-ai/grok-4" \| "x-ai/grok-4-fast" \| "x-ai/grok-4.1-fast" \| "x-ai/grok-4.20" \| "x-ai/grok-4.20-multi-agent" \| "x-ai/grok-code-fast-1" \| "moonshotai/kimi-k2" \| "qwen/qwen3-235b-a22b-thinking-2507" \| "qwen/qwen3-coder" \| "z-ai/glm-4-32b" \| "z-ai/glm-4.5" \| "z-ai/glm-4.5-air" \| "z-ai/glm-4.5-air:free" \| "z-ai/glm-4.5v" \| "z-ai/glm-4.6" \| "z-ai/glm-4.6v" \| "z-ai/glm-4.7" \| "z-ai/glm-4.7-flash" \| "z-ai/glm-5" \| "z-ai/glm-5-turbo" \| "z-ai/glm-5v-turbo" \| "Llama-4-Scout-17B-16E-Instruct-FP8" \| "Llama-4-Maverick-17B-128E-Instruct-FP8" \| "Llama-3.3-8B-Instruct" \| "Llama-3.3-70B-Instruct" \| "v0-1.5-md" \| "v0-1.5-lg" \| "v0-1.0-md" | No |
|
||||
| focus | The topic to focus on in the summary | str | No |
|
||||
| style | The style of the summary to generate. | "concise" \| "detailed" \| "bullet points" \| "numbered list" | No |
|
||||
| max_tokens | The maximum number of tokens to generate in the chat completion. | int | No |
|
||||
@@ -721,7 +721,7 @@ _Add technical explanation here._
|
||||
| Input | Description | Type | Required |
|
||||
|-------|-------------|------|----------|
|
||||
| prompt | The prompt to send to the language model. | str | Yes |
|
||||
| model | The language model to use for answering the prompt. | "o3-mini" \| "o3-2025-04-16" \| "o1" \| "o1-mini" \| "gpt-5.2-2025-12-11" \| "gpt-5.1-2025-11-13" \| "gpt-5-2025-08-07" \| "gpt-5-mini-2025-08-07" \| "gpt-5-nano-2025-08-07" \| "gpt-5-chat-latest" \| "gpt-4.1-2025-04-14" \| "gpt-4.1-mini-2025-04-14" \| "gpt-4o-mini" \| "gpt-4o" \| "gpt-4-turbo" \| "claude-opus-4-1-20250805" \| "claude-opus-4-20250514" \| "claude-sonnet-4-20250514" \| "claude-opus-4-5-20251101" \| "claude-sonnet-4-5-20250929" \| "claude-haiku-4-5-20251001" \| "claude-opus-4-6" \| "claude-sonnet-4-6" \| "claude-3-haiku-20240307" \| "Qwen/Qwen2.5-72B-Instruct-Turbo" \| "nvidia/llama-3.1-nemotron-70b-instruct" \| "meta-llama/Llama-3.3-70B-Instruct-Turbo" \| "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo" \| "meta-llama/Llama-3.2-3B-Instruct-Turbo" \| "llama-3.3-70b-versatile" \| "llama-3.1-8b-instant" \| "llama3.3" \| "llama3.2" \| "llama3" \| "llama3.1:405b" \| "dolphin-mistral:latest" \| "openai/gpt-oss-120b" \| "openai/gpt-oss-20b" \| "google/gemini-2.5-pro-preview-03-25" \| "google/gemini-2.5-pro" \| "google/gemini-3.1-pro-preview" \| "google/gemini-3-flash-preview" \| "google/gemini-2.5-flash" \| "google/gemini-2.0-flash-001" \| "google/gemini-3.1-flash-lite-preview" \| "google/gemini-2.5-flash-lite-preview-06-17" \| "google/gemini-2.0-flash-lite-001" \| "mistralai/mistral-nemo" \| "mistralai/mistral-large-2512" \| "mistralai/mistral-medium-3.1" \| "mistralai/mistral-small-3.2-24b-instruct" \| "mistralai/codestral-2508" \| "cohere/command-r-08-2024" \| "cohere/command-r-plus-08-2024" \| "cohere/command-a-03-2025" \| "cohere/command-a-translate-08-2025" \| "cohere/command-a-reasoning-08-2025" \| "cohere/command-a-vision-07-2025" \| "deepseek/deepseek-chat" \| "deepseek/deepseek-r1-0528" \| "perplexity/sonar" \| "perplexity/sonar-pro" \| "perplexity/sonar-reasoning-pro" \| "perplexity/sonar-deep-research" \| "nousresearch/hermes-3-llama-3.1-405b" \| "nousresearch/hermes-3-llama-3.1-70b" \| "amazon/nova-lite-v1" \| "amazon/nova-micro-v1" \| "amazon/nova-pro-v1" \| "microsoft/wizardlm-2-8x22b" \| "microsoft/phi-4" \| "gryphe/mythomax-l2-13b" \| "meta-llama/llama-4-scout" \| "meta-llama/llama-4-maverick" \| "x-ai/grok-3" \| "x-ai/grok-4" \| "x-ai/grok-4-fast" \| "x-ai/grok-4.1-fast" \| "x-ai/grok-code-fast-1" \| "moonshotai/kimi-k2" \| "qwen/qwen3-235b-a22b-thinking-2507" \| "qwen/qwen3-coder" \| "z-ai/glm-4-32b" \| "z-ai/glm-4.5" \| "z-ai/glm-4.5-air" \| "z-ai/glm-4.5-air:free" \| "z-ai/glm-4.5v" \| "z-ai/glm-4.6" \| "z-ai/glm-4.6v" \| "z-ai/glm-4.7" \| "z-ai/glm-4.7-flash" \| "z-ai/glm-5" \| "z-ai/glm-5-turbo" \| "z-ai/glm-5v-turbo" \| "Llama-4-Scout-17B-16E-Instruct-FP8" \| "Llama-4-Maverick-17B-128E-Instruct-FP8" \| "Llama-3.3-8B-Instruct" \| "Llama-3.3-70B-Instruct" \| "v0-1.5-md" \| "v0-1.5-lg" \| "v0-1.0-md" | No |
|
||||
| model | The language model to use for answering the prompt. | "o3-mini" \| "o3-2025-04-16" \| "o1" \| "o1-mini" \| "gpt-5.2-2025-12-11" \| "gpt-5.1-2025-11-13" \| "gpt-5-2025-08-07" \| "gpt-5-mini-2025-08-07" \| "gpt-5-nano-2025-08-07" \| "gpt-5-chat-latest" \| "gpt-4.1-2025-04-14" \| "gpt-4.1-mini-2025-04-14" \| "gpt-4o-mini" \| "gpt-4o" \| "gpt-4-turbo" \| "claude-opus-4-1-20250805" \| "claude-opus-4-20250514" \| "claude-sonnet-4-20250514" \| "claude-opus-4-5-20251101" \| "claude-sonnet-4-5-20250929" \| "claude-haiku-4-5-20251001" \| "claude-opus-4-6" \| "claude-sonnet-4-6" \| "claude-3-haiku-20240307" \| "Qwen/Qwen2.5-72B-Instruct-Turbo" \| "nvidia/llama-3.1-nemotron-70b-instruct" \| "meta-llama/Llama-3.3-70B-Instruct-Turbo" \| "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo" \| "meta-llama/Llama-3.2-3B-Instruct-Turbo" \| "llama-3.3-70b-versatile" \| "llama-3.1-8b-instant" \| "llama3.3" \| "llama3.2" \| "llama3" \| "llama3.1:405b" \| "dolphin-mistral:latest" \| "openai/gpt-oss-120b" \| "openai/gpt-oss-20b" \| "google/gemini-2.5-pro-preview-03-25" \| "google/gemini-2.5-pro" \| "google/gemini-3.1-pro-preview" \| "google/gemini-3-flash-preview" \| "google/gemini-2.5-flash" \| "google/gemini-2.0-flash-001" \| "google/gemini-3.1-flash-lite-preview" \| "google/gemini-2.5-flash-lite-preview-06-17" \| "google/gemini-2.0-flash-lite-001" \| "mistralai/mistral-nemo" \| "mistralai/mistral-large-2512" \| "mistralai/mistral-medium-3.1" \| "mistralai/mistral-small-3.2-24b-instruct" \| "mistralai/codestral-2508" \| "cohere/command-r-08-2024" \| "cohere/command-r-plus-08-2024" \| "cohere/command-a-03-2025" \| "cohere/command-a-translate-08-2025" \| "cohere/command-a-reasoning-08-2025" \| "cohere/command-a-vision-07-2025" \| "deepseek/deepseek-chat" \| "deepseek/deepseek-r1-0528" \| "perplexity/sonar" \| "perplexity/sonar-pro" \| "perplexity/sonar-reasoning-pro" \| "perplexity/sonar-deep-research" \| "nousresearch/hermes-3-llama-3.1-405b" \| "nousresearch/hermes-3-llama-3.1-70b" \| "amazon/nova-lite-v1" \| "amazon/nova-micro-v1" \| "amazon/nova-pro-v1" \| "microsoft/wizardlm-2-8x22b" \| "microsoft/phi-4" \| "gryphe/mythomax-l2-13b" \| "meta-llama/llama-4-scout" \| "meta-llama/llama-4-maverick" \| "x-ai/grok-3" \| "x-ai/grok-4" \| "x-ai/grok-4-fast" \| "x-ai/grok-4.1-fast" \| "x-ai/grok-4.20" \| "x-ai/grok-4.20-multi-agent" \| "x-ai/grok-code-fast-1" \| "moonshotai/kimi-k2" \| "qwen/qwen3-235b-a22b-thinking-2507" \| "qwen/qwen3-coder" \| "z-ai/glm-4-32b" \| "z-ai/glm-4.5" \| "z-ai/glm-4.5-air" \| "z-ai/glm-4.5-air:free" \| "z-ai/glm-4.5v" \| "z-ai/glm-4.6" \| "z-ai/glm-4.6v" \| "z-ai/glm-4.7" \| "z-ai/glm-4.7-flash" \| "z-ai/glm-5" \| "z-ai/glm-5-turbo" \| "z-ai/glm-5v-turbo" \| "Llama-4-Scout-17B-16E-Instruct-FP8" \| "Llama-4-Maverick-17B-128E-Instruct-FP8" \| "Llama-3.3-8B-Instruct" \| "Llama-3.3-70B-Instruct" \| "v0-1.5-md" \| "v0-1.5-lg" \| "v0-1.0-md" | No |
|
||||
| multiple_tool_calls | Whether to allow multiple tool calls in a single response. | bool | No |
|
||||
| sys_prompt | The system prompt to provide additional context to the model. | str | No |
|
||||
| conversation_history | The conversation history to provide context for the prompt. | List[Dict[str, Any]] | No |
|
||||
|
||||
@@ -58,7 +58,7 @@ Tool and block identifiers provided in `tools` and `blocks` are validated at run
|
||||
| system_context | Optional additional context prepended to the prompt. Use this to constrain autopilot behavior, provide domain context, or set output format requirements. | str | No |
|
||||
| session_id | Session ID to continue an existing autopilot conversation. Leave empty to start a new session. Use the session_id output from a previous run to continue. | str | No |
|
||||
| max_recursion_depth | Maximum nesting depth when the autopilot calls this block recursively (sub-agent pattern). Prevents infinite loops. | int | No |
|
||||
| tools | Tool names to filter. Works with tools_exclude to form an allow-list or deny-list. Leave empty to apply no tool filter. | List["add_understanding" \| "ask_question" \| "bash_exec" \| "browser_act" \| "browser_navigate" \| "browser_screenshot" \| "connect_integration" \| "continue_run_block" \| "create_agent" \| "create_feature_request" \| "create_folder" \| "customize_agent" \| "delete_folder" \| "delete_workspace_file" \| "edit_agent" \| "find_agent" \| "find_block" \| "find_library_agent" \| "fix_agent_graph" \| "get_agent_building_guide" \| "get_doc_page" \| "get_mcp_guide" \| "list_folders" \| "list_workspace_files" \| "memory_search" \| "memory_store" \| "move_agents_to_folder" \| "move_folder" \| "read_workspace_file" \| "run_agent" \| "run_block" \| "run_mcp_tool" \| "search_docs" \| "search_feature_requests" \| "update_folder" \| "validate_agent_graph" \| "view_agent_output" \| "web_fetch" \| "write_workspace_file" \| "Agent" \| "Edit" \| "Glob" \| "Grep" \| "Read" \| "Task" \| "TodoWrite" \| "WebSearch" \| "Write"] | No |
|
||||
| tools | Tool names to filter. Works with tools_exclude to form an allow-list or deny-list. Leave empty to apply no tool filter. | List["add_understanding" \| "ask_question" \| "bash_exec" \| "browser_act" \| "browser_navigate" \| "browser_screenshot" \| "connect_integration" \| "continue_run_block" \| "create_agent" \| "create_feature_request" \| "create_folder" \| "customize_agent" \| "delete_folder" \| "delete_workspace_file" \| "edit_agent" \| "find_agent" \| "find_block" \| "find_library_agent" \| "fix_agent_graph" \| "get_agent_building_guide" \| "get_doc_page" \| "get_mcp_guide" \| "list_folders" \| "list_workspace_files" \| "memory_forget_confirm" \| "memory_forget_search" \| "memory_search" \| "memory_store" \| "move_agents_to_folder" \| "move_folder" \| "read_workspace_file" \| "run_agent" \| "run_block" \| "run_mcp_tool" \| "search_docs" \| "search_feature_requests" \| "update_folder" \| "validate_agent_graph" \| "view_agent_output" \| "web_fetch" \| "write_workspace_file" \| "Agent" \| "Edit" \| "Glob" \| "Grep" \| "Read" \| "Task" \| "TodoWrite" \| "WebSearch" \| "Write"] | No |
|
||||
| tools_exclude | Controls how the 'tools' list is interpreted. True (default): 'tools' is a deny-list — listed tools are blocked, all others are allowed. An empty 'tools' list means allow everything. False: 'tools' is an allow-list — only listed tools are permitted. | bool | No |
|
||||
| blocks | Block identifiers to filter when the copilot uses run_block. Each entry can be: a block name (e.g. 'HTTP Request'), a full block UUID, or the first 8 hex characters of the UUID (e.g. 'c069dc6b'). Works with blocks_exclude. Leave empty to apply no block filter. | List[str] | No |
|
||||
| blocks_exclude | Controls how the 'blocks' list is interpreted. True (default): 'blocks' is a deny-list — listed blocks are blocked, all others are allowed. An empty 'blocks' list means allow everything. False: 'blocks' is an allow-list — only listed blocks are permitted. | bool | No |
|
||||
|
||||
Reference in New Issue
Block a user