Compare commits

..

10 Commits

Author SHA1 Message Date
Toran Bruce Richards
48a8a0abe1 Merge branch 'dev' into otto/secrt-1887-auto-save-binary-outputs-v2 2026-02-05 18:42:50 +01:00
Otto
ed07f02738 fix(copilot): edit_agent updates existing agent instead of creating duplicate (#11981)
## Summary

When editing an agent via CoPilot's `edit_agent` tool, the code was
always creating a new `LibraryAgent` entry instead of updating the
existing one to point to the new graph version. This caused duplicate
agents to appear in the user's library.

## Changes

In `save_agent_to_library()`:
- When `is_update=True`, now checks if there's an existing library agent
for the graph using `get_library_agent_by_graph_id()`
- If found, uses `update_agent_version_in_library()` to update the
existing library agent to point to the new version
- Falls back to creating a new library agent if no existing one is found
(e.g., if editing a graph that wasn't added to library yet)

## Testing

- Verified lint/format checks pass
- Plan reviewed and approved by Staff Engineer Plan Reviewer agent

## Related

Fixes [SECRT-1857](https://linear.app/autogpt/issue/SECRT-1857)

---------

Co-authored-by: Zamil Majdy <zamil.majdy@agpt.co>
2026-02-05 15:02:26 +00:00
Swifty
b121030c94 feat(frontend): Add progress indicator during agent generation [SECRT-1883] (#11974)
## Summary
- Add asymptotic progress bar that appears during long-running chat
tasks
- Progress bar shows after 10 seconds with "Working on it..." label and
percentage
- Uses half-life formula: ~50% at 30s, ~75% at 60s, ~87.5% at 90s, etc.
- Creates the classic "game loading bar" effect that never reaches 100%



https://github.com/user-attachments/assets/3c59289e-793c-4a08-b3fc-69e1eef28b1f



## Test plan
- [x] Start a chat that triggers agent generation
- [x] Wait 10+ seconds for the progress bar to appear
- [x] Verify progress bar is centered with label and percentage
- [x] Verify progress follows expected timing (~50% at 30s)
- [x] Verify progress bar disappears when task completes

---------

Co-authored-by: Otto <otto@agpt.co>
2026-02-05 15:37:51 +01:00
Swifty
c22c18374d feat(frontend): Add ready-to-test prompt after agent creation [SECRT-1882] (#11975)
## Summary
- Add special UI prompt when agent is successfully created in chat
- Show "Agent Created Successfully" with agent name
- Provide two action buttons:
- **Run with example values**: Sends chat message asking AI to run with
placeholders
- **Run with my inputs**: Opens RunAgentModal for custom input
configuration
- After run/schedule, automatically send chat message with execution
details for AI monitoring



https://github.com/user-attachments/assets/b11e118c-de59-4b79-a629-8bd0d52d9161



## Test plan
- [x] Create an agent through chat
- [x] Verify "Agent Created Successfully" prompt appears
- [x] Click "Run with example values" - verify chat message is sent
- [x] Click "Run with my inputs" - verify RunAgentModal opens
- [x] Fill inputs and run - verify chat message with execution ID is
sent
- [x] Fill inputs and schedule - verify chat message with schedule
details is sent

---------

Co-authored-by: Otto <otto@agpt.co>
2026-02-05 15:37:31 +01:00
Swifty
e40233a3ac fix(backend/chat): Guide find_agent users toward action with CTAs (#11976)
When users search for agents, guide them toward creating custom agents
if no results are found or after showing results. This improves user
engagement by offering a clear next step.

### Changes 🏗️

- Updated `agent_search.py` to add CTAs in search responses
- Added messaging to inform users they can create custom agents based on
their needs
- Applied to both "no results found" and "agents found" scenarios

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] Search for agents in marketplace with matching results
  - [x] Search for agents in marketplace with no results
  - [x] Search for agents in library with matching results  
  - [x] Search for agents in library with no results
  - [x] Verify CTA message appears in all cases

---------

Co-authored-by: Otto <otto@agpt.co>
2026-02-05 15:36:55 +01:00
Swifty
3ae5eabf9d fix(backend/chat): Use latest prompt label in non-production environments (#11977)
In non-production environments, the chat service now fetches prompts
with the `latest` label instead of the default production-labeled
prompt. This makes it easier to test and iterate on prompt changes in
dev/staging without needing to promote them to production first.

### Changes 🏗️

- Updated `_get_system_prompt_template()` in chat service to pass
`label="latest"` when `app_env` is not `PRODUCTION`
- Production environments continue using the default behavior
(production-labeled prompts)

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] Verified that in non-production environments, prompts with
`latest` label are fetched
- [x] Verified that production environments still use the default
(production) labeled prompts

Co-authored-by: Otto <otto@agpt.co>
2026-02-05 14:54:39 +01:00
Otto
a077ba9f03 fix(platform): YouTube block yields only error on failure (#11980)
## Summary

Fixes [SECRT-1889](https://linear.app/autogpt/issue/SECRT-1889): The
YouTube transcription block was yielding both `video_id` and `error`
when the transcript fetch failed.

## Problem

The block yielded `video_id` immediately upon extracting it from the
URL, before attempting to fetch the transcript. If the transcript fetch
failed, both outputs were present.

```python
# Before
video_id = self.extract_video_id(input_data.youtube_url)
yield "video_id", video_id  # ← Yielded before transcript attempt

transcript = self.get_transcript(video_id, credentials)  # ← Could fail here
```

## Solution

Wrap the entire operation in try/except and only yield outputs after all
operations succeed:

```python
# After
try:
    video_id = self.extract_video_id(input_data.youtube_url)
    transcript = self.get_transcript(video_id, credentials)
    transcript_text = self.format_transcript(transcript=transcript)

    # Only yield after all operations succeed
    yield "video_id", video_id
    yield "transcript", transcript_text
except Exception as e:
    yield "error", str(e)
```

This follows the established pattern in other blocks (e.g.,
`ai_image_generator_block.py`).

## Testing

- All 10 unit tests pass (`test/blocks/test_youtube.py`)
- Lint/format checks pass

Co-authored-by: Toran Bruce Richards <toran.richards@gmail.com>
2026-02-05 11:51:32 +00:00
Bently
5401d54eaa fix(backend): Handle StreamHeartbeat in CoPilot stream handler (#11928)
### Changes 🏗️

Fixes **AUTOGPT-SERVER-7JA** (123 events since Jan 27, 2026).

#### Problem

`StreamHeartbeat` was added to keep SSE connections alive during
long-running tool executions (yielded every 15s while waiting). However,
the main `stream_chat_completion` handler's `elif` chain didn't have a
case for it:

```
StreamTextStart →  handled
StreamTextDelta →  handled
StreamTextEnd →  handled
StreamToolInputStart →  handled
StreamToolInputAvailable →  handled
StreamToolOutputAvailable →  handled
StreamFinish →  handled
StreamError →  handled
StreamUsage →  handled
StreamHeartbeat →  fell through to 'Unknown chunk type' error
```

This meant every heartbeat during tool execution generated a Sentry
error instead of keeping the connection alive.

#### Fix

Add `StreamHeartbeat` to the `elif` chain and yield it through. The
route handler already calls `to_sse()` on all yielded chunks, and
`StreamHeartbeat.to_sse()` correctly returns `: heartbeat\n\n` (SSE
comment format, ignored by clients but keeps proxies/load balancers
happy).

**1 file changed, 3 insertions.**
2026-02-05 12:04:46 +01:00
Otto
5ac89d7c0b fix(test): fix timing bug in test_block_credit_reset (#11978)
## Summary
Fixes the flaky `test_block_credit_reset` test that was failing on
multiple PRs with `assert 0 == 1000`.

## Root Cause
The test calls `disable_test_user_transactions()` which sets `updatedAt`
to 35 days ago from the **actual current time**. It then mocks
`time_now` to January 1st.

**The bug**: If the test runs in early February, 35 days ago is January
— the **same month** as the mocked `time_now`. The credit refill logic
only triggers when the balance snapshot is from a *different* month, so
no refill happens and the balance stays at 0.

## Fix
After calling `disable_test_user_transactions()`, explicitly set
`updatedAt` to December of the previous year. This ensures it's always
in a different month than the mocked `month1` (January), regardless of
when the test runs.

## Testing
CI will verify the fix.
2026-02-05 11:56:26 +01:00
Otto
d6b76e672c feat(copilot): Auto-save binary block outputs to workspace
When CoPilot executes blocks that produce binary outputs (code execution
results with png/jpeg/pdf/svg fields), the data is now automatically saved
to the user's workspace and replaced with workspace:// references.

This reduces output token usage by ~97% for file generation tasks (observed:
17k -> 500 tokens for a PDF) and prevents data corruption from LLM
truncation/hallucination of base64 strings.

Detection is field-name based (png, jpeg, pdf, svg) which targets the
standard e2b CodeExecutionResult fields. Other image-producing blocks
already use store_media_file() and don't need this post-processing.

Features:
- Automatic detection and saving of binary content >1KB
- Content deduplication within single block execution via SHA-256 hash
- Graceful degradation (original value preserved on save failure)
- Support for both raw base64 and data URI formats

Closes SECRT-1887
2026-02-05 00:46:53 +00:00
26 changed files with 826 additions and 742 deletions

View File

@@ -19,3 +19,6 @@ load-tests/*.json
load-tests/*.log
load-tests/node_modules/*
migrations/*/rollback*.sql
# Workspace files
workspaces/

View File

@@ -1,174 +0,0 @@
import logging
from enum import Enum
from autogpt_libs.auth import get_user_id, requires_admin_user
from fastapi import APIRouter, Security
from pydantic import BaseModel
from backend.util.metrics import DiscordChannel, discord_send_alert
from backend.util.settings import AppEnvironment, Settings
logger = logging.getLogger(__name__)
settings = Settings()
class TestDataScriptType(str, Enum):
"""Available test data generation scripts."""
FULL = "full" # test_data_creator.py - creates 100+ users, comprehensive data
E2E = "e2e" # e2e_test_data.py - creates 15 users with API functions
class GenerateTestDataRequest(BaseModel):
"""Request model for test data generation."""
script_type: TestDataScriptType = TestDataScriptType.E2E
class GenerateTestDataResponse(BaseModel):
"""Response model for test data generation."""
success: bool
message: str
details: dict | None = None
router = APIRouter(
prefix="/admin",
tags=["admin", "test-data"],
dependencies=[Security(requires_admin_user)],
)
@router.post(
"/generate-test-data",
response_model=GenerateTestDataResponse,
summary="Generate Test Data",
)
async def generate_test_data(
request: GenerateTestDataRequest,
admin_user_id: str = Security(get_user_id),
) -> GenerateTestDataResponse:
"""
Generate test data for the platform.
This endpoint runs the test data generation scripts to populate the database
with sample users, agents, graphs, executions, store listings, and more.
Available script types:
- `e2e`: Creates 15 test users with graphs, library agents, presets, and store submissions.
Uses API functions for better compatibility. (Recommended)
- `full`: Creates 100+ users with comprehensive test data using direct Prisma calls.
Generates more data but may take longer.
**Warning**: This will add significant data to your database. Use with caution.
**Note**: This endpoint is disabled in production environments.
"""
# Block execution in production environment
if settings.config.app_env == AppEnvironment.PRODUCTION:
alert_message = (
f"🚨 **SECURITY ALERT**: Test data generation attempted in PRODUCTION!\n"
f"Admin User ID: `{admin_user_id}`\n"
f"Script Type: `{request.script_type}`\n"
f"Action: Request was blocked."
)
logger.warning(
f"Test data generation blocked in production. Admin: {admin_user_id}"
)
# Send Discord alert
try:
await discord_send_alert(alert_message, DiscordChannel.PLATFORM)
except Exception as e:
logger.error(f"Failed to send Discord alert: {e}")
return GenerateTestDataResponse(
success=False,
message="Test data generation is disabled in production environments.",
)
logger.info(
f"Admin user {admin_user_id} is generating test data with script type: {request.script_type}"
)
try:
if request.script_type == TestDataScriptType.E2E:
# Import and run the E2E test data creator
# We need to import within the function to avoid circular imports
import sys
from pathlib import Path
from backend.data.db import prisma
# Add the test directory to the path
test_dir = Path(__file__).parent.parent.parent.parent.parent / "test"
sys.path.insert(0, str(test_dir))
try:
from e2e_test_data import ( # pyright: ignore[reportMissingImports]
TestDataCreator,
)
# Connect to database if not already connected
if not prisma.is_connected():
await prisma.connect()
creator = TestDataCreator()
await creator.create_all_test_data()
return GenerateTestDataResponse(
success=True,
message="E2E test data generated successfully",
details={
"users_created": len(creator.users),
"graphs_created": len(creator.agent_graphs),
"library_agents_created": len(creator.library_agents),
"store_submissions_created": len(creator.store_submissions),
"presets_created": len(creator.presets),
"api_keys_created": len(creator.api_keys),
},
)
finally:
# Remove the test directory from the path
if str(test_dir) in sys.path:
sys.path.remove(str(test_dir))
elif request.script_type == TestDataScriptType.FULL:
# Import and run the full test data creator
import sys
from pathlib import Path
test_dir = Path(__file__).parent.parent.parent.parent.parent / "test"
sys.path.insert(0, str(test_dir))
try:
import test_data_creator # pyright: ignore[reportMissingImports]
create_full_test_data = test_data_creator.main
await create_full_test_data()
return GenerateTestDataResponse(
success=True,
message="Full test data generated successfully",
details={
"script": "test_data_creator.py",
"note": "Created 100+ users with comprehensive test data",
},
)
finally:
if str(test_dir) in sys.path:
sys.path.remove(str(test_dir))
else:
return GenerateTestDataResponse(
success=False,
message=f"Unknown script type: {request.script_type}",
)
except Exception as e:
logger.exception(f"Error generating test data: {e}")
return GenerateTestDataResponse(
success=False,
message=f"Failed to generate test data: {str(e)}",
)

View File

@@ -33,7 +33,7 @@ from backend.data.understanding import (
get_business_understanding,
)
from backend.util.exceptions import NotFoundError
from backend.util.settings import Settings
from backend.util.settings import AppEnvironment, Settings
from . import db as chat_db
from . import stream_registry
@@ -222,8 +222,18 @@ async def _get_system_prompt_template(context: str) -> str:
try:
# cache_ttl_seconds=0 disables SDK caching to always get the latest prompt
# Use asyncio.to_thread to avoid blocking the event loop
# In non-production environments, fetch the latest prompt version
# instead of the production-labeled version for easier testing
label = (
None
if settings.config.app_env == AppEnvironment.PRODUCTION
else "latest"
)
prompt = await asyncio.to_thread(
langfuse.get_prompt, config.langfuse_prompt_name, cache_ttl_seconds=0
langfuse.get_prompt,
config.langfuse_prompt_name,
label=label,
cache_ttl_seconds=0,
)
return prompt.compile(users_information=context)
except Exception as e:
@@ -618,6 +628,9 @@ async def stream_chat_completion(
total_tokens=chunk.totalTokens,
)
)
elif isinstance(chunk, StreamHeartbeat):
# Pass through heartbeat to keep SSE connection alive
yield chunk
else:
logger.error(f"Unknown chunk type: {type(chunk)}", exc_info=True)

View File

@@ -7,15 +7,7 @@ from typing import Any, NotRequired, TypedDict
from backend.api.features.library import db as library_db
from backend.api.features.store import db as store_db
from backend.data.graph import (
Graph,
Link,
Node,
create_graph,
get_graph,
get_graph_all_versions,
get_store_listed_graphs,
)
from backend.data.graph import Graph, Link, Node, get_graph, get_store_listed_graphs
from backend.util.exceptions import DatabaseError, NotFoundError
from .service import (
@@ -28,8 +20,6 @@ from .service import (
logger = logging.getLogger(__name__)
AGENT_EXECUTOR_BLOCK_ID = "e189baac-8c20-45a1-94a7-55177ea42565"
class ExecutionSummary(TypedDict):
"""Summary of a single execution for quality assessment."""
@@ -669,45 +659,6 @@ def json_to_graph(agent_json: dict[str, Any]) -> Graph:
)
def _reassign_node_ids(graph: Graph) -> None:
"""Reassign all node and link IDs to new UUIDs.
This is needed when creating a new version to avoid unique constraint violations.
"""
id_map = {node.id: str(uuid.uuid4()) for node in graph.nodes}
for node in graph.nodes:
node.id = id_map[node.id]
for link in graph.links:
link.id = str(uuid.uuid4())
if link.source_id in id_map:
link.source_id = id_map[link.source_id]
if link.sink_id in id_map:
link.sink_id = id_map[link.sink_id]
def _populate_agent_executor_user_ids(agent_json: dict[str, Any], user_id: str) -> None:
"""Populate user_id in AgentExecutorBlock nodes.
The external agent generator creates AgentExecutorBlock nodes with empty user_id.
This function fills in the actual user_id so sub-agents run with correct permissions.
Args:
agent_json: Agent JSON dict (modified in place)
user_id: User ID to set
"""
for node in agent_json.get("nodes", []):
if node.get("block_id") == AGENT_EXECUTOR_BLOCK_ID:
input_default = node.get("input_default") or {}
if not input_default.get("user_id"):
input_default["user_id"] = user_id
node["input_default"] = input_default
logger.debug(
f"Set user_id for AgentExecutorBlock node {node.get('id')}"
)
async def save_agent_to_library(
agent_json: dict[str, Any], user_id: str, is_update: bool = False
) -> tuple[Graph, Any]:
@@ -721,35 +672,10 @@ async def save_agent_to_library(
Returns:
Tuple of (created Graph, LibraryAgent)
"""
# Populate user_id in AgentExecutorBlock nodes before conversion
_populate_agent_executor_user_ids(agent_json, user_id)
graph = json_to_graph(agent_json)
if is_update:
if graph.id:
existing_versions = await get_graph_all_versions(graph.id, user_id)
if existing_versions:
latest_version = max(v.version for v in existing_versions)
graph.version = latest_version + 1
_reassign_node_ids(graph)
logger.info(f"Updating agent {graph.id} to version {graph.version}")
else:
graph.id = str(uuid.uuid4())
graph.version = 1
_reassign_node_ids(graph)
logger.info(f"Creating new agent with ID {graph.id}")
created_graph = await create_graph(graph, user_id)
library_agents = await library_db.create_library_agent(
graph=created_graph,
user_id=user_id,
sensitive_action_safe_mode=True,
create_library_agents_for_sub_graphs=False,
)
return created_graph, library_agents[0]
return await library_db.update_graph_in_library(graph, user_id)
return await library_db.create_graph_in_library(graph, user_id)
def graph_to_json(graph: Graph) -> dict[str, Any]:

View File

@@ -206,9 +206,9 @@ async def search_agents(
]
)
no_results_msg = (
f"No agents found matching '{query}'. Try different keywords or browse the marketplace."
f"No agents found matching '{query}'. Let the user know they can try different keywords or browse the marketplace. Also let them know you can create a custom agent for them based on their needs."
if source == "marketplace"
else f"No agents matching '{query}' found in your library."
else f"No agents matching '{query}' found in your library. Let the user know you can create a custom agent for them based on their needs."
)
return NoResultsResponse(
message=no_results_msg, session_id=session_id, suggestions=suggestions
@@ -224,10 +224,10 @@ async def search_agents(
message = (
"Now you have found some options for the user to choose from. "
"You can add a link to a recommended agent at: /marketplace/agent/agent_id "
"Please ask the user if they would like to use any of these agents."
"Please ask the user if they would like to use any of these agents. Let the user know we can create a custom agent for them based on their needs."
if source == "marketplace"
else "Found agents in the user's library. You can provide a link to view an agent at: "
"/library/agents/{agent_id}. Use agent_output to get execution results, or run_agent to execute."
"/library/agents/{agent_id}. Use agent_output to get execution results, or run_agent to execute. Let the user know we can create a custom agent for them based on their needs."
)
return AgentsFoundResponse(

View File

@@ -0,0 +1,204 @@
"""
Save binary block outputs to workspace, return references instead of base64.
This module post-processes block execution outputs to detect and save binary
content (from code execution results) to the workspace, returning workspace://
references instead of raw base64 data. This reduces LLM output token usage
by ~97% for file generation tasks.
Detection is field-name based, targeting the standard e2b CodeExecutionResult
fields: png, jpeg, pdf, svg. Other image-producing blocks already use
store_media_file() and don't need this post-processing.
"""
import base64
import binascii
import hashlib
import logging
import uuid
from typing import Any
from backend.util.file import sanitize_filename
from backend.util.workspace import WorkspaceManager
logger = logging.getLogger(__name__)
# Field names that contain binary data (base64 encoded)
BINARY_FIELDS = {"png", "jpeg", "pdf"}
# Field names that contain large text data (not base64, save as-is)
TEXT_FIELDS = {"svg"}
# Combined set for quick lookup
SAVEABLE_FIELDS = BINARY_FIELDS | TEXT_FIELDS
# Only process content larger than this (string length, not decoded size)
SIZE_THRESHOLD = 1024 # 1KB
async def process_binary_outputs(
outputs: dict[str, list[Any]],
workspace_manager: WorkspaceManager,
block_name: str,
) -> dict[str, list[Any]]:
"""
Replace binary data in block outputs with workspace:// references.
Scans outputs for known binary field names (png, jpeg, pdf, svg) and saves
large content to the workspace. Returns processed outputs with base64 data
replaced by workspace:// references.
Deduplicates identical content within a single call using content hashing.
Args:
outputs: Block execution outputs (dict of output_name -> list of values)
workspace_manager: WorkspaceManager instance with session scoping
block_name: Name of the block (used in generated filenames)
Returns:
Processed outputs with binary data replaced by workspace references
"""
cache: dict[str, str] = {} # content_hash -> workspace_ref
processed: dict[str, list[Any]] = {}
for name, items in outputs.items():
processed_items: list[Any] = []
for item in items:
processed_items.append(
await _process_item(item, workspace_manager, block_name, cache)
)
processed[name] = processed_items
return processed
async def _process_item(
item: Any,
wm: WorkspaceManager,
block: str,
cache: dict[str, str],
) -> Any:
"""Recursively process an item, handling dicts and lists."""
if isinstance(item, dict):
return await _process_dict(item, wm, block, cache)
if isinstance(item, list):
processed: list[Any] = []
for i in item:
processed.append(await _process_item(i, wm, block, cache))
return processed
return item
async def _process_dict(
data: dict[str, Any],
wm: WorkspaceManager,
block: str,
cache: dict[str, str],
) -> dict[str, Any]:
"""Process a dict, saving binary fields and recursing into nested structures."""
result: dict[str, Any] = {}
for key, value in data.items():
if (
key in SAVEABLE_FIELDS
and isinstance(value, str)
and len(value) > SIZE_THRESHOLD
):
# Determine content bytes based on field type
if key in BINARY_FIELDS:
content = _decode_base64(value)
if content is None:
# Decode failed, keep original value
result[key] = value
continue
else:
# TEXT_FIELDS: encode as UTF-8
content = value.encode("utf-8")
# Hash decoded content for deduplication
content_hash = hashlib.sha256(content).hexdigest()
if content_hash in cache:
# Reuse existing workspace reference
result[key] = cache[content_hash]
elif ref := await _save_content(content, key, wm, block):
# Save succeeded, cache and use reference
cache[content_hash] = ref
result[key] = ref
else:
# Save failed, keep original value
result[key] = value
elif isinstance(value, dict):
result[key] = await _process_dict(value, wm, block, cache)
elif isinstance(value, list):
processed: list[Any] = []
for i in value:
processed.append(await _process_item(i, wm, block, cache))
result[key] = processed
else:
result[key] = value
return result
async def _save_content(
content: bytes,
field: str,
wm: WorkspaceManager,
block: str,
) -> str | None:
"""
Save content to workspace, return workspace:// reference.
Args:
content: Decoded binary content to save
field: Field name (used for extension)
wm: WorkspaceManager instance
block: Block name (used in filename)
Returns:
workspace://file-id reference, or None if save failed
"""
try:
# Map field name to file extension
ext = {"jpeg": "jpg"}.get(field, field)
# Sanitize block name for safe filename
safe_block = sanitize_filename(block.lower())[:20]
filename = f"{safe_block}_{field}_{uuid.uuid4().hex[:12]}.{ext}"
file = await wm.write_file(content=content, filename=filename)
return f"workspace://{file.id}"
except Exception as e:
logger.error(f"Failed to save {field} to workspace for block '{block}': {e}")
return None
def _decode_base64(value: str) -> bytes | None:
"""
Decode base64 string, handling both raw base64 and data URI formats.
Args:
value: Base64 string or data URI (data:<mime>;base64,<payload>)
Returns:
Decoded bytes, or None if decoding failed
"""
try:
# Handle data URI format
if value.startswith("data:"):
if "," in value:
value = value.split(",", 1)[1]
else:
# Malformed data URI, no comma separator
return None
# Normalize padding (handle missing = chars)
padded = value + "=" * (-len(value) % 4)
# Strict validation to prevent corrupted data
return base64.b64decode(padded, validate=True)
except (binascii.Error, ValueError):
return None

View File

@@ -8,12 +8,16 @@ from typing import Any
from pydantic_core import PydanticUndefined
from backend.api.features.chat.model import ChatSession
from backend.api.features.chat.tools.binary_output_processor import (
process_binary_outputs,
)
from backend.data.block import get_block
from backend.data.execution import ExecutionContext
from backend.data.model import CredentialsMetaInput
from backend.data.workspace import get_or_create_workspace
from backend.integrations.creds_manager import IntegrationCredentialsManager
from backend.util.exceptions import BlockError
from backend.util.workspace import WorkspaceManager
from .base import BaseTool
from .models import (
@@ -321,11 +325,20 @@ class RunBlockTool(BaseTool):
):
outputs[output_name].append(output_data)
# Save binary outputs to workspace to prevent context bloat
# (code execution results with png/jpeg/pdf/svg fields)
workspace_manager = WorkspaceManager(
user_id, workspace.id, session.session_id
)
processed_outputs = await process_binary_outputs(
dict(outputs), workspace_manager, block.name
)
return BlockOutputResponse(
message=f"Block '{block.name}' executed successfully",
block_id=block_id,
block_name=block.name,
outputs=dict(outputs),
outputs=processed_outputs,
success=True,
session_id=session_id,
)

View File

@@ -0,0 +1,204 @@
"""Unit tests for binary_output_processor module."""
import base64
from unittest.mock import AsyncMock, MagicMock
import pytest
from backend.api.features.chat.tools.binary_output_processor import (
_decode_base64,
process_binary_outputs,
)
@pytest.fixture
def workspace_manager():
"""Create a mock WorkspaceManager."""
mock = MagicMock()
mock_file = MagicMock()
mock_file.id = "file-123"
mock.write_file = AsyncMock(return_value=mock_file)
return mock
class TestDecodeBase64:
"""Tests for _decode_base64 function."""
def test_raw_base64(self):
"""Decode raw base64 string."""
encoded = base64.b64encode(b"test content").decode()
result = _decode_base64(encoded)
assert result == b"test content"
def test_data_uri(self):
"""Decode base64 from data URI format."""
content = b"test content"
encoded = base64.b64encode(content).decode()
data_uri = f"data:image/png;base64,{encoded}"
result = _decode_base64(data_uri)
assert result == content
def test_invalid_base64(self):
"""Return None for invalid base64."""
result = _decode_base64("not valid base64!!!")
assert result is None
def test_missing_padding(self):
"""Handle base64 with missing padding."""
# base64.b64encode(b"test") = "dGVzdA=="
# Remove padding
result = _decode_base64("dGVzdA")
assert result == b"test"
def test_malformed_data_uri(self):
"""Return None for data URI without comma."""
result = _decode_base64("data:image/png;base64")
assert result is None
class TestProcessBinaryOutputs:
"""Tests for process_binary_outputs function."""
@pytest.mark.asyncio
async def test_saves_large_png(self, workspace_manager):
"""Large PNG content should be saved to workspace."""
# Create content larger than SIZE_THRESHOLD (1KB)
large_content = b"x" * 2000
encoded = base64.b64encode(large_content).decode()
outputs = {"result": [{"png": encoded}]}
result = await process_binary_outputs(outputs, workspace_manager, "TestBlock")
assert result["result"][0]["png"] == "workspace://file-123"
workspace_manager.write_file.assert_called_once()
call_kwargs = workspace_manager.write_file.call_args.kwargs
assert call_kwargs["content"] == large_content
assert "testblock_png_" in call_kwargs["filename"]
assert call_kwargs["filename"].endswith(".png")
@pytest.mark.asyncio
async def test_preserves_small_content(self, workspace_manager):
"""Small content should be preserved as-is."""
small_content = base64.b64encode(b"tiny").decode()
outputs = {"result": [{"png": small_content}]}
result = await process_binary_outputs(outputs, workspace_manager, "TestBlock")
assert result["result"][0]["png"] == small_content
workspace_manager.write_file.assert_not_called()
@pytest.mark.asyncio
async def test_deduplicates_identical_content(self, workspace_manager):
"""Identical content should only be saved once."""
large_content = b"x" * 2000
encoded = base64.b64encode(large_content).decode()
outputs = {
"main_result": [{"png": encoded}],
"results": [{"png": encoded}],
}
result = await process_binary_outputs(outputs, workspace_manager, "TestBlock")
# Both should have the same workspace reference
assert result["main_result"][0]["png"] == "workspace://file-123"
assert result["results"][0]["png"] == "workspace://file-123"
# But write_file should only be called once
assert workspace_manager.write_file.call_count == 1
@pytest.mark.asyncio
async def test_graceful_degradation_on_save_failure(self, workspace_manager):
"""Original content should be preserved if save fails."""
workspace_manager.write_file = AsyncMock(side_effect=Exception("Save failed"))
large_content = b"x" * 2000
encoded = base64.b64encode(large_content).decode()
outputs = {"result": [{"png": encoded}]}
result = await process_binary_outputs(outputs, workspace_manager, "TestBlock")
# Original content should be preserved
assert result["result"][0]["png"] == encoded
@pytest.mark.asyncio
async def test_handles_nested_structures(self, workspace_manager):
"""Should traverse nested dicts and lists."""
large_content = b"x" * 2000
encoded = base64.b64encode(large_content).decode()
outputs = {
"result": [
{
"nested": {
"deep": {
"png": encoded,
}
}
}
]
}
result = await process_binary_outputs(outputs, workspace_manager, "TestBlock")
assert result["result"][0]["nested"]["deep"]["png"] == "workspace://file-123"
@pytest.mark.asyncio
async def test_handles_svg_as_text(self, workspace_manager):
"""SVG should be saved as UTF-8 text, not base64 decoded."""
svg_content = "<svg>" + "x" * 2000 + "</svg>"
outputs = {"result": [{"svg": svg_content}]}
result = await process_binary_outputs(outputs, workspace_manager, "TestBlock")
assert result["result"][0]["svg"] == "workspace://file-123"
call_kwargs = workspace_manager.write_file.call_args.kwargs
# SVG should be UTF-8 encoded, not base64 decoded
assert call_kwargs["content"] == svg_content.encode("utf-8")
assert call_kwargs["filename"].endswith(".svg")
@pytest.mark.asyncio
async def test_ignores_unknown_fields(self, workspace_manager):
"""Fields not in SAVEABLE_FIELDS should be ignored."""
large_content = "x" * 2000 # Large text in an unknown field
outputs = {"result": [{"unknown_field": large_content}]}
result = await process_binary_outputs(outputs, workspace_manager, "TestBlock")
assert result["result"][0]["unknown_field"] == large_content
workspace_manager.write_file.assert_not_called()
@pytest.mark.asyncio
async def test_handles_jpeg_extension(self, workspace_manager):
"""JPEG files should use .jpg extension."""
large_content = b"x" * 2000
encoded = base64.b64encode(large_content).decode()
outputs = {"result": [{"jpeg": encoded}]}
await process_binary_outputs(outputs, workspace_manager, "TestBlock")
call_kwargs = workspace_manager.write_file.call_args.kwargs
assert call_kwargs["filename"].endswith(".jpg")
@pytest.mark.asyncio
async def test_handles_data_uri_in_binary_field(self, workspace_manager):
"""Data URI format in binary fields should be properly decoded."""
large_content = b"x" * 2000
encoded = base64.b64encode(large_content).decode()
data_uri = f"data:image/png;base64,{encoded}"
outputs = {"result": [{"png": data_uri}]}
result = await process_binary_outputs(outputs, workspace_manager, "TestBlock")
assert result["result"][0]["png"] == "workspace://file-123"
call_kwargs = workspace_manager.write_file.call_args.kwargs
assert call_kwargs["content"] == large_content
@pytest.mark.asyncio
async def test_invalid_base64_preserves_original(self, workspace_manager):
"""Invalid base64 in a binary field should preserve the original value."""
invalid_content = "not valid base64!!!" + "x" * 2000
outputs = {"result": [{"png": invalid_content}]}
processed = await process_binary_outputs(
outputs, workspace_manager, "TestBlock"
)
assert processed["result"][0]["png"] == invalid_content
workspace_manager.write_file.assert_not_called()

View File

@@ -19,7 +19,10 @@ from backend.data.graph import GraphSettings
from backend.data.includes import AGENT_PRESET_INCLUDE, library_agent_include
from backend.data.model import CredentialsMetaInput
from backend.integrations.creds_manager import IntegrationCredentialsManager
from backend.integrations.webhooks.graph_lifecycle_hooks import on_graph_activate
from backend.integrations.webhooks.graph_lifecycle_hooks import (
on_graph_activate,
on_graph_deactivate,
)
from backend.util.clients import get_scheduler_client
from backend.util.exceptions import DatabaseError, InvalidInputError, NotFoundError
from backend.util.json import SafeJson
@@ -537,6 +540,92 @@ async def update_agent_version_in_library(
return library_model.LibraryAgent.from_db(lib)
async def create_graph_in_library(
graph: graph_db.Graph,
user_id: str,
) -> tuple[graph_db.GraphModel, library_model.LibraryAgent]:
"""Create a new graph and add it to the user's library."""
graph.version = 1
graph_model = graph_db.make_graph_model(graph, user_id)
graph_model.reassign_ids(user_id=user_id, reassign_graph_id=True)
created_graph = await graph_db.create_graph(graph_model, user_id)
library_agents = await create_library_agent(
graph=created_graph,
user_id=user_id,
sensitive_action_safe_mode=True,
create_library_agents_for_sub_graphs=False,
)
if created_graph.is_active:
created_graph = await on_graph_activate(created_graph, user_id=user_id)
return created_graph, library_agents[0]
async def update_graph_in_library(
graph: graph_db.Graph,
user_id: str,
) -> tuple[graph_db.GraphModel, library_model.LibraryAgent]:
"""Create a new version of an existing graph and update the library entry."""
existing_versions = await graph_db.get_graph_all_versions(graph.id, user_id)
current_active_version = (
next((v for v in existing_versions if v.is_active), None)
if existing_versions
else None
)
graph.version = (
max(v.version for v in existing_versions) + 1 if existing_versions else 1
)
graph_model = graph_db.make_graph_model(graph, user_id)
graph_model.reassign_ids(user_id=user_id, reassign_graph_id=False)
created_graph = await graph_db.create_graph(graph_model, user_id)
library_agent = await get_library_agent_by_graph_id(user_id, created_graph.id)
if not library_agent:
raise NotFoundError(f"Library agent not found for graph {created_graph.id}")
library_agent = await update_library_agent_version_and_settings(
user_id, created_graph
)
if created_graph.is_active:
created_graph = await on_graph_activate(created_graph, user_id=user_id)
await graph_db.set_graph_active_version(
graph_id=created_graph.id,
version=created_graph.version,
user_id=user_id,
)
if current_active_version:
await on_graph_deactivate(current_active_version, user_id=user_id)
return created_graph, library_agent
async def update_library_agent_version_and_settings(
user_id: str, agent_graph: graph_db.GraphModel
) -> library_model.LibraryAgent:
"""Update library agent to point to new graph version and sync settings."""
library = await update_agent_version_in_library(
user_id, agent_graph.id, agent_graph.version
)
updated_settings = GraphSettings.from_graph(
graph=agent_graph,
hitl_safe_mode=library.settings.human_in_the_loop_safe_mode,
sensitive_action_safe_mode=library.settings.sensitive_action_safe_mode,
)
if updated_settings != library.settings:
library = await update_library_agent(
library_agent_id=library.id,
user_id=user_id,
settings=updated_settings,
)
return library
async def update_library_agent(
library_agent_id: str,
user_id: str,

View File

@@ -101,7 +101,6 @@ from backend.util.timezone_utils import (
from backend.util.virus_scanner import scan_content_safe
from .library import db as library_db
from .library import model as library_model
from .store.model import StoreAgentDetails
@@ -823,18 +822,16 @@ async def update_graph(
graph: graph_db.Graph,
user_id: Annotated[str, Security(get_user_id)],
) -> graph_db.GraphModel:
# Sanity check
if graph.id and graph.id != graph_id:
raise HTTPException(400, detail="Graph ID does not match ID in URI")
# Determine new version
existing_versions = await graph_db.get_graph_all_versions(graph_id, user_id=user_id)
if not existing_versions:
raise HTTPException(404, detail=f"Graph #{graph_id} not found")
latest_version_number = max(g.version for g in existing_versions)
graph.version = latest_version_number + 1
graph.version = max(g.version for g in existing_versions) + 1
current_active_version = next((v for v in existing_versions if v.is_active), None)
graph = graph_db.make_graph_model(graph, user_id)
graph.reassign_ids(user_id=user_id, reassign_graph_id=False)
graph.validate_graph(for_run=False)
@@ -842,27 +839,23 @@ async def update_graph(
new_graph_version = await graph_db.create_graph(graph, user_id=user_id)
if new_graph_version.is_active:
# Keep the library agent up to date with the new active version
await _update_library_agent_version_and_settings(user_id, new_graph_version)
# Handle activation of the new graph first to ensure continuity
await library_db.update_library_agent_version_and_settings(
user_id, new_graph_version
)
new_graph_version = await on_graph_activate(new_graph_version, user_id=user_id)
# Ensure new version is the only active version
await graph_db.set_graph_active_version(
graph_id=graph_id, version=new_graph_version.version, user_id=user_id
)
if current_active_version:
# Handle deactivation of the previously active version
await on_graph_deactivate(current_active_version, user_id=user_id)
# Fetch new graph version *with sub-graphs* (needed for credentials input schema)
new_graph_version_with_subgraphs = await graph_db.get_graph(
graph_id,
new_graph_version.version,
user_id=user_id,
include_subgraphs=True,
)
assert new_graph_version_with_subgraphs # make type checker happy
assert new_graph_version_with_subgraphs
return new_graph_version_with_subgraphs
@@ -900,33 +893,15 @@ async def set_graph_active_version(
)
# Keep the library agent up to date with the new active version
await _update_library_agent_version_and_settings(user_id, new_active_graph)
await library_db.update_library_agent_version_and_settings(
user_id, new_active_graph
)
if current_active_graph and current_active_graph.version != new_active_version:
# Handle deactivation of the previously active version
await on_graph_deactivate(current_active_graph, user_id=user_id)
async def _update_library_agent_version_and_settings(
user_id: str, agent_graph: graph_db.GraphModel
) -> library_model.LibraryAgent:
library = await library_db.update_agent_version_in_library(
user_id, agent_graph.id, agent_graph.version
)
updated_settings = GraphSettings.from_graph(
graph=agent_graph,
hitl_safe_mode=library.settings.human_in_the_loop_safe_mode,
sensitive_action_safe_mode=library.settings.sensitive_action_safe_mode,
)
if updated_settings != library.settings:
library = await library_db.update_library_agent(
library_agent_id=library.id,
user_id=user_id,
settings=updated_settings,
)
return library
@v1_router.patch(
path="/graphs/{graph_id}/settings",
summary="Update graph settings",

View File

@@ -19,7 +19,6 @@ from prisma.errors import PrismaError
import backend.api.features.admin.credit_admin_routes
import backend.api.features.admin.execution_analytics_routes
import backend.api.features.admin.store_admin_routes
import backend.api.features.admin.test_data_routes
import backend.api.features.builder
import backend.api.features.builder.routes
import backend.api.features.chat.routes as chat_routes
@@ -317,11 +316,6 @@ app.include_router(
tags=["v2", "admin"],
prefix="/api/executions",
)
app.include_router(
backend.api.features.admin.test_data_routes.router,
tags=["v2", "admin"],
prefix="/api/admin",
)
app.include_router(
backend.api.features.executions.review.routes.router,
tags=["v2", "executions", "review"],

View File

@@ -165,10 +165,13 @@ class TranscribeYoutubeVideoBlock(Block):
credentials: WebshareProxyCredentials,
**kwargs,
) -> BlockOutput:
video_id = self.extract_video_id(input_data.youtube_url)
yield "video_id", video_id
try:
video_id = self.extract_video_id(input_data.youtube_url)
transcript = self.get_transcript(video_id, credentials)
transcript_text = self.format_transcript(transcript=transcript)
transcript = self.get_transcript(video_id, credentials)
transcript_text = self.format_transcript(transcript=transcript)
yield "transcript", transcript_text
# Only yield after all operations succeed
yield "video_id", video_id
yield "transcript", transcript_text
except Exception as e:
yield "error", str(e)

View File

@@ -134,6 +134,16 @@ async def test_block_credit_reset(server: SpinTestServer):
month1 = datetime.now(timezone.utc).replace(month=1, day=1)
user_credit.time_now = lambda: month1
# IMPORTANT: Set updatedAt to December of previous year to ensure it's
# in a different month than month1 (January). This fixes a timing bug
# where if the test runs in early February, 35 days ago would be January,
# matching the mocked month1 and preventing the refill from triggering.
dec_previous_year = month1.replace(year=month1.year - 1, month=12, day=15)
await UserBalance.prisma().update(
where={"userId": DEFAULT_USER_ID},
data={"updatedAt": dec_previous_year},
)
# First call in month 1 should trigger refill
balance = await user_credit.get_credits(DEFAULT_USER_ID)
assert balance == REFILL_VALUE # Should get 1000 credits

View File

@@ -1,12 +1,7 @@
import { Sidebar } from "@/components/__legacy__/Sidebar";
import {
Users,
CurrencyDollar,
UserFocus,
FileText,
Database,
Faders,
} from "@phosphor-icons/react";
import { Users, DollarSign, UserSearch, FileText } from "lucide-react";
import { IconSliders } from "@/components/__legacy__/ui/icons";
const sidebarLinkGroups = [
{
@@ -14,32 +9,27 @@ const sidebarLinkGroups = [
{
text: "Marketplace Management",
href: "/admin/marketplace",
icon: <Users size={24} />,
icon: <Users className="h-6 w-6" />,
},
{
text: "User Spending",
href: "/admin/spending",
icon: <CurrencyDollar size={24} />,
icon: <DollarSign className="h-6 w-6" />,
},
{
text: "User Impersonation",
href: "/admin/impersonation",
icon: <UserFocus size={24} />,
icon: <UserSearch className="h-6 w-6" />,
},
{
text: "Execution Analytics",
href: "/admin/execution-analytics",
icon: <FileText size={24} />,
icon: <FileText className="h-6 w-6" />,
},
{
text: "Admin User Management",
href: "/admin/settings",
icon: <Faders size={24} />,
},
{
text: "Test Data",
href: "/admin/test-data",
icon: <Database size={24} />,
icon: <IconSliders className="h-6 w-6" />,
},
],
},

View File

@@ -1,179 +0,0 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/atoms/Button/Button";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { Select, SelectOption } from "@/components/atoms/Select/Select";
import { Text } from "@/components/atoms/Text/Text";
import { useToast } from "@/components/molecules/Toast/use-toast";
// Generated types and hooks from OpenAPI spec
// Run `npm run generate:api` to regenerate after backend changes
import { usePostAdminGenerateTestData } from "@/app/api/__generated__/endpoints/admin/admin";
import type { GenerateTestDataResponse } from "@/app/api/__generated__/models/generateTestDataResponse";
import type { TestDataScriptType } from "@/app/api/__generated__/models/testDataScriptType";
const scriptTypeOptions: SelectOption[] = [
{
value: "e2e",
label:
"E2E Test Data - 15 users with graphs, agents, and store submissions",
},
{
value: "full",
label: "Full Test Data - 100+ users with comprehensive data (takes longer)",
},
];
export function GenerateTestDataButton() {
const { toast } = useToast();
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [scriptType, setScriptType] = useState<TestDataScriptType>("e2e");
const [result, setResult] = useState<GenerateTestDataResponse | null>(null);
const generateMutation = usePostAdminGenerateTestData({
mutation: {
onSuccess: (response) => {
const data = response.data;
setResult(data);
if (data.success) {
toast({
title: "Success",
description: data.message,
});
} else {
toast({
title: "Error",
description: data.message,
variant: "destructive",
});
}
},
onError: (error) => {
console.error("Error generating test data:", error);
const errorMessage =
error instanceof Error ? error.message : "Unknown error occurred";
setResult({
success: false,
message: `Failed to generate test data: ${errorMessage}`,
});
toast({
title: "Error",
description: "Failed to generate test data. Please try again.",
variant: "destructive",
});
},
},
});
const handleGenerate = () => {
setResult(null);
generateMutation.mutate({
data: {
script_type: scriptType,
},
});
};
const handleDialogClose = () => {
setIsDialogOpen(false);
};
return (
<>
<Button
size="large"
variant="primary"
onClick={() => {
setIsDialogOpen(true);
setResult(null);
}}
>
Generate Test Data
</Button>
<Dialog
title="Generate Test Data"
controlled={{
isOpen: isDialogOpen,
set: (open) => {
if (!open) handleDialogClose();
},
}}
styling={{ maxWidth: "32rem" }}
>
<Dialog.Content>
<Text variant="body" className="pb-4 text-neutral-600">
This will populate the database with sample test data including
users, agents, graphs, store listings, and more.
</Text>
<div className="grid gap-4 py-4">
<Select
label="Script Type"
id="scriptType"
value={scriptType}
onValueChange={(value) =>
setScriptType(value as TestDataScriptType)
}
disabled={generateMutation.isPending}
options={scriptTypeOptions}
/>
<div className="rounded-md bg-yellow-50 p-3 text-yellow-800">
<Text variant="small" as="span">
<Text variant="small-medium" as="span">
Warning:
</Text>{" "}
This will add significant data to your database. This endpoint
is disabled in production environments.
</Text>
</div>
{result && (
<div
className={`rounded-md p-3 ${
result.success
? "bg-green-50 text-green-800"
: "bg-red-50 text-red-800"
}`}
>
<Text variant="small-medium">{result.message}</Text>
{result.details && (
<ul className="mt-2 list-inside list-disc">
{Object.entries(result.details).map(([key, value]) => (
<li key={key}>
<Text variant="small" as="span">
{key.replace(/_/g, " ")}: {String(value)}
</Text>
</li>
))}
</ul>
)}
</div>
)}
</div>
<Dialog.Footer>
<Button
variant="outline"
onClick={handleDialogClose}
disabled={generateMutation.isPending}
>
Cancel
</Button>
<Button
variant="primary"
onClick={handleGenerate}
disabled={generateMutation.isPending}
loading={generateMutation.isPending}
>
{generateMutation.isPending
? "Generating..."
: "Generate Test Data"}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog>
</>
);
}

View File

@@ -1,162 +0,0 @@
import { withRoleAccess } from "@/lib/withRoleAccess";
import { GenerateTestDataButton } from "./components/GenerateTestDataButton";
import { Text } from "@/components/atoms/Text/Text";
function TestDataDashboard() {
return (
<div className="mx-auto p-6">
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div>
<Text variant="h1" className="text-3xl">
Test Data Generation
</Text>
<Text variant="body" className="text-gray-500">
Generate sample data for testing and development
</Text>
</div>
</div>
<div className="rounded-lg border bg-white p-6 shadow-sm">
<Text variant="h2" className="mb-4 text-xl">
Generate Test Data
</Text>
<Text variant="body" className="mb-6 text-gray-600">
Use this tool to populate the database with sample test data. This
is useful for development and testing purposes.
</Text>
<div className="mb-6">
<Text variant="body-medium" className="mb-2">
Available Script Types:
</Text>
<ul className="list-inside list-disc space-y-2 text-gray-600">
<li>
<Text variant="body" as="span">
<Text variant="body-medium" as="span">
E2E Test Data:
</Text>{" "}
Creates 15 test users with graphs, library agents, presets,
store submissions, and API keys. Uses API functions for better
compatibility.
</Text>
</li>
<li>
<Text variant="body" as="span">
<Text variant="body-medium" as="span">
Full Test Data:
</Text>{" "}
Creates 100+ users with comprehensive test data including
agent blocks, nodes, executions, analytics, and more. Takes
longer to complete.
</Text>
</li>
</ul>
</div>
<GenerateTestDataButton />
</div>
<div className="rounded-lg border bg-gray-50 p-6">
<Text variant="body-medium" className="mb-2 text-gray-700">
What data is created?
</Text>
<div className="grid gap-4 text-sm text-gray-600 md:grid-cols-2">
<div>
<Text variant="body-medium">E2E Script:</Text>
<ul className="mt-1 list-inside list-disc">
<li>
<Text variant="small" as="span">
15 test users
</Text>
</li>
<li>
<Text variant="small" as="span">
15 graphs per user
</Text>
</li>
<li>
<Text variant="small" as="span">
Library agents
</Text>
</li>
<li>
<Text variant="small" as="span">
Agent presets
</Text>
</li>
<li>
<Text variant="small" as="span">
Store submissions
</Text>
</li>
<li>
<Text variant="small" as="span">
API keys
</Text>
</li>
<li>
<Text variant="small" as="span">
Creator profiles
</Text>
</li>
</ul>
</div>
<div>
<Text variant="body-medium">Full Script:</Text>
<ul className="mt-1 list-inside list-disc">
<li>
<Text variant="small" as="span">
100 users
</Text>
</li>
<li>
<Text variant="small" as="span">
100 agent blocks
</Text>
</li>
<li>
<Text variant="small" as="span">
Multiple graphs per user
</Text>
</li>
<li>
<Text variant="small" as="span">
Agent nodes and links
</Text>
</li>
<li>
<Text variant="small" as="span">
Graph executions
</Text>
</li>
<li>
<Text variant="small" as="span">
Store listings and reviews
</Text>
</li>
<li>
<Text variant="small" as="span">
Analytics data
</Text>
</li>
<li>
<Text variant="small" as="span">
Credit transactions
</Text>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
);
}
export default async function TestDataDashboardPage() {
"use server";
const withAdminAccess = await withRoleAccess(["admin"]);
const ProtectedTestDataDashboard = await withAdminAccess(TestDataDashboard);
return <ProtectedTestDataDashboard />;
}

View File

@@ -75,47 +75,6 @@
"security": [{ "HTTPBearerJWT": [] }]
}
},
"/api/admin/generate-test-data": {
"post": {
"tags": ["v2", "admin"],
"summary": "Generate Test Data",
"operationId": "postAdminGenerateTestData",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GenerateTestDataRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GenerateTestDataResponse"
}
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
},
"security": [{ "HTTPBearerJWT": [] }]
}
},
"/api/api-keys": {
"get": {
"tags": ["v1", "api-keys"],
@@ -7471,32 +7430,6 @@
"required": ["name", "description"],
"title": "Graph"
},
"GenerateTestDataRequest": {
"properties": {
"script_type": {
"allOf": [{ "$ref": "#/components/schemas/TestDataScriptType" }],
"default": "e2e"
}
},
"type": "object",
"title": "GenerateTestDataRequest"
},
"GenerateTestDataResponse": {
"properties": {
"success": { "type": "boolean", "title": "Success" },
"message": { "type": "string", "title": "Message" },
"details": {
"anyOf": [
{ "type": "object", "additionalProperties": true },
{ "type": "null" }
],
"title": "Details"
}
},
"type": "object",
"required": ["success", "message"],
"title": "GenerateTestDataResponse"
},
"GraphExecution": {
"properties": {
"id": { "type": "string", "title": "Id" },
@@ -10590,11 +10523,6 @@
],
"title": "SuggestionsResponse"
},
"TestDataScriptType": {
"type": "string",
"enum": ["full", "e2e"],
"title": "TestDataScriptType"
},
"TimezoneResponse": {
"properties": {
"timezone": {

View File

@@ -346,6 +346,7 @@ export function ChatMessage({
toolId={message.toolId}
toolName={message.toolName}
result={message.result}
onSendMessage={onSendMessage}
/>
</div>
);

View File

@@ -73,6 +73,7 @@ export function MessageList({
key={index}
message={message}
prevMessage={messages[index - 1]}
onSendMessage={onSendMessage}
/>
);
}

View File

@@ -5,11 +5,13 @@ import { shouldSkipAgentOutput } from "../../helpers";
export interface LastToolResponseProps {
message: ChatMessageData;
prevMessage: ChatMessageData | undefined;
onSendMessage?: (content: string) => void;
}
export function LastToolResponse({
message,
prevMessage,
onSendMessage,
}: LastToolResponseProps) {
if (message.type !== "tool_response") return null;
@@ -21,6 +23,7 @@ export function LastToolResponse({
toolId={message.toolId}
toolName={message.toolName}
result={message.result}
onSendMessage={onSendMessage}
/>
</div>
);

View File

@@ -1,6 +1,8 @@
import { Progress } from "@/components/atoms/Progress/Progress";
import { cn } from "@/lib/utils";
import { useEffect, useRef, useState } from "react";
import { AIChatBubble } from "../AIChatBubble/AIChatBubble";
import { useAsymptoticProgress } from "../ToolCallMessage/useAsymptoticProgress";
export interface ThinkingMessageProps {
className?: string;
@@ -11,18 +13,19 @@ export function ThinkingMessage({ className }: ThinkingMessageProps) {
const [showCoffeeMessage, setShowCoffeeMessage] = useState(false);
const timerRef = useRef<NodeJS.Timeout | null>(null);
const coffeeTimerRef = useRef<NodeJS.Timeout | null>(null);
const progress = useAsymptoticProgress(showCoffeeMessage);
useEffect(() => {
if (timerRef.current === null) {
timerRef.current = setTimeout(() => {
setShowSlowLoader(true);
}, 8000);
}, 3000);
}
if (coffeeTimerRef.current === null) {
coffeeTimerRef.current = setTimeout(() => {
setShowCoffeeMessage(true);
}, 10000);
}, 8000);
}
return () => {
@@ -49,9 +52,18 @@ export function ThinkingMessage({ className }: ThinkingMessageProps) {
<AIChatBubble>
<div className="transition-all duration-500 ease-in-out">
{showCoffeeMessage ? (
<span className="inline-block animate-shimmer bg-gradient-to-r from-neutral-400 via-neutral-600 to-neutral-400 bg-[length:200%_100%] bg-clip-text text-transparent">
This could take a few minutes, grab a coffee
</span>
<div className="flex flex-col items-center gap-3">
<div className="flex w-full max-w-[280px] flex-col gap-1.5">
<div className="flex items-center justify-between text-xs text-neutral-500">
<span>Working on it...</span>
<span>{Math.round(progress)}%</span>
</div>
<Progress value={progress} className="h-2 w-full" />
</div>
<span className="inline-block animate-shimmer bg-gradient-to-r from-neutral-400 via-neutral-600 to-neutral-400 bg-[length:200%_100%] bg-clip-text text-transparent">
This could take a few minutes, grab a coffee
</span>
</div>
) : showSlowLoader ? (
<span className="inline-block animate-shimmer bg-gradient-to-r from-neutral-400 via-neutral-600 to-neutral-400 bg-[length:200%_100%] bg-clip-text text-transparent">
Taking a bit more time...

View File

@@ -0,0 +1,50 @@
import { useEffect, useRef, useState } from "react";
/**
* Hook that returns a progress value that starts fast and slows down,
* asymptotically approaching but never reaching the max value.
*
* Uses a half-life formula: progress = max * (1 - 0.5^(time/halfLife))
* This creates the "game loading bar" effect where:
* - 50% is reached at halfLifeSeconds
* - 75% is reached at 2 * halfLifeSeconds
* - 87.5% is reached at 3 * halfLifeSeconds
* - and so on...
*
* @param isActive - Whether the progress should be animating
* @param halfLifeSeconds - Time in seconds to reach 50% progress (default: 30)
* @param maxProgress - Maximum progress value to approach (default: 100)
* @param intervalMs - Update interval in milliseconds (default: 100)
* @returns Current progress value (0-maxProgress)
*/
export function useAsymptoticProgress(
isActive: boolean,
halfLifeSeconds = 30,
maxProgress = 100,
intervalMs = 100,
) {
const [progress, setProgress] = useState(0);
const elapsedTimeRef = useRef(0);
useEffect(() => {
if (!isActive) {
setProgress(0);
elapsedTimeRef.current = 0;
return;
}
const interval = setInterval(() => {
elapsedTimeRef.current += intervalMs / 1000;
// Half-life approach: progress = max * (1 - 0.5^(time/halfLife))
// At t=halfLife: 50%, at t=2*halfLife: 75%, at t=3*halfLife: 87.5%, etc.
const newProgress =
maxProgress *
(1 - Math.pow(0.5, elapsedTimeRef.current / halfLifeSeconds));
setProgress(newProgress);
}, intervalMs);
return () => clearInterval(interval);
}, [isActive, halfLifeSeconds, maxProgress, intervalMs]);
return progress;
}

View File

@@ -0,0 +1,128 @@
"use client";
import { useGetV2GetLibraryAgent } from "@/app/api/__generated__/endpoints/library/library";
import { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
import { GraphExecutionMeta } from "@/app/api/__generated__/models/graphExecutionMeta";
import { RunAgentModal } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/RunAgentModal/RunAgentModal";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import {
CheckCircleIcon,
PencilLineIcon,
PlayIcon,
} from "@phosphor-icons/react";
import { AIChatBubble } from "../AIChatBubble/AIChatBubble";
interface Props {
agentName: string;
libraryAgentId: string;
onSendMessage?: (content: string) => void;
}
export function AgentCreatedPrompt({
agentName,
libraryAgentId,
onSendMessage,
}: Props) {
// Fetch library agent eagerly so modal is ready when user clicks
const { data: libraryAgentResponse, isLoading } = useGetV2GetLibraryAgent(
libraryAgentId,
{
query: {
enabled: !!libraryAgentId,
},
},
);
const libraryAgent =
libraryAgentResponse?.status === 200 ? libraryAgentResponse.data : null;
function handleRunWithPlaceholders() {
onSendMessage?.(
`Run the agent "${agentName}" with placeholder/example values so I can test it.`,
);
}
function handleRunCreated(execution: GraphExecutionMeta) {
onSendMessage?.(
`I've started the agent "${agentName}". The execution ID is ${execution.id}. Please monitor its progress and let me know when it completes.`,
);
}
function handleScheduleCreated(schedule: GraphExecutionJobInfo) {
const scheduleInfo = schedule.cron
? `with cron schedule "${schedule.cron}"`
: "to run on the specified schedule";
onSendMessage?.(
`I've scheduled the agent "${agentName}" ${scheduleInfo}. The schedule ID is ${schedule.id}.`,
);
}
return (
<AIChatBubble>
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-green-100">
<CheckCircleIcon
size={18}
weight="fill"
className="text-green-600"
/>
</div>
<div>
<Text variant="body-medium" className="text-neutral-900">
Agent Created Successfully
</Text>
<Text variant="small" className="text-neutral-500">
&quot;{agentName}&quot; is ready to test
</Text>
</div>
</div>
<div className="flex flex-col gap-2">
<Text variant="small-medium" className="text-neutral-700">
Ready to test?
</Text>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="small"
onClick={handleRunWithPlaceholders}
className="gap-2"
>
<PlayIcon size={16} />
Run with example values
</Button>
{libraryAgent ? (
<RunAgentModal
triggerSlot={
<Button variant="outline" size="small" className="gap-2">
<PencilLineIcon size={16} />
Run with my inputs
</Button>
}
agent={libraryAgent}
onRunCreated={handleRunCreated}
onScheduleCreated={handleScheduleCreated}
/>
) : (
<Button
variant="outline"
size="small"
loading={isLoading}
disabled
className="gap-2"
>
<PencilLineIcon size={16} />
Run with my inputs
</Button>
)}
</div>
<Text variant="small" className="text-neutral-500">
or just ask me
</Text>
</div>
</div>
</AIChatBubble>
);
}

View File

@@ -2,11 +2,13 @@ import { Text } from "@/components/atoms/Text/Text";
import { cn } from "@/lib/utils";
import type { ToolResult } from "@/types/chat";
import { WarningCircleIcon } from "@phosphor-icons/react";
import { AgentCreatedPrompt } from "./AgentCreatedPrompt";
import { AIChatBubble } from "../AIChatBubble/AIChatBubble";
import { MarkdownContent } from "../MarkdownContent/MarkdownContent";
import {
formatToolResponse,
getErrorMessage,
isAgentSavedResponse,
isErrorResponse,
} from "./helpers";
@@ -16,6 +18,7 @@ export interface ToolResponseMessageProps {
result?: ToolResult;
success?: boolean;
className?: string;
onSendMessage?: (content: string) => void;
}
export function ToolResponseMessage({
@@ -24,6 +27,7 @@ export function ToolResponseMessage({
result,
success: _success,
className,
onSendMessage,
}: ToolResponseMessageProps) {
if (isErrorResponse(result)) {
const errorMessage = getErrorMessage(result);
@@ -43,6 +47,18 @@ export function ToolResponseMessage({
);
}
// Check for agent_saved response - show special prompt
const agentSavedData = isAgentSavedResponse(result);
if (agentSavedData.isSaved) {
return (
<AgentCreatedPrompt
agentName={agentSavedData.agentName}
libraryAgentId={agentSavedData.libraryAgentId}
onSendMessage={onSendMessage}
/>
);
}
const formattedText = formatToolResponse(result, toolName);
return (

View File

@@ -6,6 +6,43 @@ function stripInternalReasoning(content: string): string {
.trim();
}
export interface AgentSavedData {
isSaved: boolean;
agentName: string;
agentId: string;
libraryAgentId: string;
libraryAgentLink: string;
}
export function isAgentSavedResponse(result: unknown): AgentSavedData {
if (typeof result !== "object" || result === null) {
return {
isSaved: false,
agentName: "",
agentId: "",
libraryAgentId: "",
libraryAgentLink: "",
};
}
const response = result as Record<string, unknown>;
if (response.type === "agent_saved") {
return {
isSaved: true,
agentName: (response.agent_name as string) || "Agent",
agentId: (response.agent_id as string) || "",
libraryAgentId: (response.library_agent_id as string) || "",
libraryAgentLink: (response.library_agent_link as string) || "",
};
}
return {
isSaved: false,
agentName: "",
agentId: "",
libraryAgentId: "",
libraryAgentLink: "",
};
}
export function isErrorResponse(result: unknown): boolean {
if (typeof result === "string") {
const lower = result.toLowerCase();

View File

@@ -1136,7 +1136,6 @@ export type AddUserCreditsResponse = {
new_balance: number;
transaction_key: string;
};
const _stringFormatToDataTypeMap: Partial<Record<string, DataType>> = {
date: DataType.DATE,
time: DataType.TIME,