Compare commits

..

4 Commits

Author SHA1 Message Date
Zamil Majdy
826ab6ad2a fix(platform/copilot): address PR review comments
- Extract reason from decomposition_result in vague_goal path instead of hardcoding
- Change goal_type field to Literal["vague", "unachievable"] for stronger typing
- Add reason prop to SuggestedGoalCard and display it
- Pass reason to SuggestedGoalCard in CreateAgent
- Add enum constraint to goal_type in openapi.json schema
2026-02-18 21:17:25 +05:30
Zamil Majdy
ba13606e3d chore(platform/copilot): merge dev - move chat tools to backend/copilot module
Resolves merge conflict from directory rename:
- backend/api/features/chat/tools/ → backend/copilot/tools/
- Updated create_agent_test.py imports and patch paths to new module location
2026-02-18 17:59:29 +05:30
Zamil Majdy
c12894698e fix(platform/copilot): add SuggestedGoalResponse to ToolResponseUnion for OpenAPI schema
Adds SuggestedGoalResponse to the ToolResponseUnion in routes.py so it is
included in the generated OpenAPI schema. Regenerates openapi.json from the
live backend spec.
2026-02-17 12:22:16 +04:00
Zamil Majdy
2f37aeec12 feat(platform/copilot): add SuggestedGoalResponse for vague/unachievable goals
- Add SUGGESTED_GOAL response type and SuggestedGoalResponse model to backend
- Return SuggestedGoalResponse instead of ErrorResponse for vague/unachievable goals
- Update system prompt with guidance for suggested_goal and clarifying_questions feedback loops
- Add SuggestedGoalCard frontend component with amber styling and "Use this goal" button
- Add error recovery buttons ("Try again", "Simplify goal") to error output
- Update openapi.json and frontend helpers/type guards for the new response type
- Add create_agent_test.py covering all decomposition result types
2026-02-17 11:57:36 +04:00
25 changed files with 478 additions and 246 deletions

View File

@@ -50,6 +50,7 @@ from backend.copilot.tools.models import (
OperationPendingResponse, OperationPendingResponse,
OperationStartedResponse, OperationStartedResponse,
SetupRequirementsResponse, SetupRequirementsResponse,
SuggestedGoalResponse,
UnderstandingUpdatedResponse, UnderstandingUpdatedResponse,
) )
from backend.copilot.tracking import track_user_message from backend.copilot.tracking import track_user_message
@@ -984,6 +985,7 @@ ToolResponseUnion = (
| AgentPreviewResponse | AgentPreviewResponse
| AgentSavedResponse | AgentSavedResponse
| ClarificationNeededResponse | ClarificationNeededResponse
| SuggestedGoalResponse
| BlockListResponse | BlockListResponse
| BlockDetailsResponse | BlockDetailsResponse
| BlockOutputResponse | BlockOutputResponse

View File

@@ -164,23 +164,21 @@ class CoPilotExecutor(AppProcess):
self._cancel_thread, self.cancel_client, "[cleanup][cancel]" self._cancel_thread, self.cancel_client, "[cleanup][cancel]"
) )
# Clean up worker threads (closes per-loop workspace storage sessions) # Shutdown executor
if self._executor: if self._executor:
from .processor import cleanup_worker
logger.info(f"[cleanup {pid}] Cleaning up workers...")
futures = []
for _ in range(self._executor._max_workers):
futures.append(self._executor.submit(cleanup_worker))
for f in futures:
try:
f.result(timeout=10)
except Exception as e:
logger.warning(f"[cleanup {pid}] Worker cleanup error: {e}")
logger.info(f"[cleanup {pid}] Shutting down executor...") logger.info(f"[cleanup {pid}] Shutting down executor...")
self._executor.shutdown(wait=False) self._executor.shutdown(wait=False)
# Close async resources (workspace storage aiohttp session, etc.)
try:
from backend.util.workspace_storage import shutdown_workspace_storage
loop = asyncio.new_event_loop()
loop.run_until_complete(shutdown_workspace_storage())
loop.close()
except Exception as e:
logger.warning(f"[cleanup {pid}] Error closing workspace storage: {e}")
# Release any remaining locks # Release any remaining locks
for task_id, lock in list(self._task_locks.items()): for task_id, lock in list(self._task_locks.items()):
try: try:

View File

@@ -60,18 +60,6 @@ def init_worker():
_tls.processor.on_executor_start() _tls.processor.on_executor_start()
def cleanup_worker():
"""Clean up the processor for the current worker thread.
Should be called before the worker thread's event loop is destroyed so
that event-loop-bound resources (e.g. ``aiohttp.ClientSession``) are
closed on the correct loop.
"""
processor: CoPilotProcessor | None = getattr(_tls, "processor", None)
if processor is not None:
processor.cleanup()
# ============ Processor Class ============ # # ============ Processor Class ============ #
@@ -110,28 +98,6 @@ class CoPilotProcessor:
logger.info(f"[CoPilotExecutor] Worker {self.tid} started") logger.info(f"[CoPilotExecutor] Worker {self.tid} started")
def cleanup(self):
"""Clean up event-loop-bound resources before the loop is destroyed.
Shuts down the workspace storage instance that belongs to this
worker's event loop, ensuring ``aiohttp.ClientSession.close()``
runs on the same loop that created the session.
"""
from backend.util.workspace_storage import shutdown_workspace_storage
try:
future = asyncio.run_coroutine_threadsafe(
shutdown_workspace_storage(), self.execution_loop
)
future.result(timeout=5)
except Exception as e:
logger.warning(f"[CoPilotExecutor] Worker {self.tid} cleanup error: {e}")
# Stop the event loop
self.execution_loop.call_soon_threadsafe(self.execution_loop.stop)
self.execution_thread.join(timeout=5)
logger.info(f"[CoPilotExecutor] Worker {self.tid} cleaned up")
@error_logged(swallow=False) @error_logged(swallow=False)
def execute( def execute(
self, self,

View File

@@ -693,15 +693,11 @@ async def stream_chat_completion_sdk(
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
raw_transcript = read_transcript_file(captured_transcript.path) raw_transcript = read_transcript_file(captured_transcript.path)
if raw_transcript: if raw_transcript:
try: task = asyncio.create_task(
async with asyncio.timeout(30): _upload_transcript_bg(user_id, session_id, raw_transcript)
await _upload_transcript_bg( )
user_id, session_id, raw_transcript _background_tasks.add(task)
) task.add_done_callback(_background_tasks.discard)
except asyncio.TimeoutError:
logger.warning(
f"[SDK] Transcript upload timed out for {session_id}"
)
else: else:
logger.debug("[SDK] Stop hook fired but transcript not usable") logger.debug("[SDK] Stop hook fired but transcript not usable")

View File

@@ -118,6 +118,8 @@ Adapt flexibly to the conversation context. Not every interaction requires all s
- Find reusable components with `find_block` - Find reusable components with `find_block`
- Create custom solutions with `create_agent` if nothing suitable exists - Create custom solutions with `create_agent` if nothing suitable exists
- Modify existing library agents with `edit_agent` - Modify existing library agents with `edit_agent`
- **When `create_agent` returns `suggested_goal`**: Present the suggestion to the user and ask "Would you like me to proceed with this refined goal?" If they accept, call `create_agent` again with the suggested goal.
- **When `create_agent` returns `clarifying_questions`**: After the user answers, call `create_agent` again with the original description AND the answers in the `context` parameter.
5. **Execute**: Run automations immediately, schedule them, or set up webhooks using `run_agent`. Test specific components with `run_block`. 5. **Execute**: Run automations immediately, schedule them, or set up webhooks using `run_agent`. Test specific components with `run_block`.
@@ -164,6 +166,11 @@ Adapt flexibly to the conversation context. Not every interaction requires all s
- Use `add_understanding` to capture valuable business context - Use `add_understanding` to capture valuable business context
- When tool calls fail, try alternative approaches - When tool calls fail, try alternative approaches
**Handle Feedback Loops:**
- When a tool returns a suggested alternative (like a refined goal), present it clearly and ask the user for confirmation before proceeding
- When clarifying questions are answered, immediately re-call the tool with the accumulated context
- Don't ask redundant questions if the user has already provided context in the conversation
## CRITICAL REMINDER ## CRITICAL REMINDER
You are NOT a chatbot. You are NOT documentation. You are a partner who helps busy business owners get value quickly by showing proof through working automations. Bias toward action over explanation.""" You are NOT a chatbot. You are NOT documentation. You are a partner who helps busy business owners get value quickly by showing proof through working automations. Bias toward action over explanation."""

View File

@@ -22,6 +22,7 @@ from .models import (
ClarificationNeededResponse, ClarificationNeededResponse,
ClarifyingQuestion, ClarifyingQuestion,
ErrorResponse, ErrorResponse,
SuggestedGoalResponse,
ToolResponseBase, ToolResponseBase,
) )
@@ -186,26 +187,28 @@ class CreateAgentTool(BaseTool):
if decomposition_result.get("type") == "unachievable_goal": if decomposition_result.get("type") == "unachievable_goal":
suggested = decomposition_result.get("suggested_goal", "") suggested = decomposition_result.get("suggested_goal", "")
reason = decomposition_result.get("reason", "") reason = decomposition_result.get("reason", "")
return ErrorResponse( return SuggestedGoalResponse(
message=( message=(
f"This goal cannot be accomplished with the available blocks. " f"This goal cannot be accomplished with the available blocks. {reason}"
f"{reason} "
f"Suggestion: {suggested}"
), ),
error="unachievable_goal", suggested_goal=suggested,
details={"suggested_goal": suggested, "reason": reason}, reason=reason,
original_goal=description,
goal_type="unachievable",
session_id=session_id, session_id=session_id,
) )
if decomposition_result.get("type") == "vague_goal": if decomposition_result.get("type") == "vague_goal":
suggested = decomposition_result.get("suggested_goal", "") suggested = decomposition_result.get("suggested_goal", "")
return ErrorResponse( reason = decomposition_result.get(
message=( "reason", "The goal needs more specific details"
f"The goal is too vague to create a specific workflow. " )
f"Suggestion: {suggested}" return SuggestedGoalResponse(
), message="The goal is too vague to create a specific workflow.",
error="vague_goal", suggested_goal=suggested,
details={"suggested_goal": suggested}, reason=reason,
original_goal=description,
goal_type="vague",
session_id=session_id, session_id=session_id,
) )

View File

@@ -0,0 +1,142 @@
"""Tests for CreateAgentTool response types."""
from unittest.mock import AsyncMock, patch
import pytest
from backend.copilot.tools.create_agent import CreateAgentTool
from backend.copilot.tools.models import (
ClarificationNeededResponse,
ErrorResponse,
SuggestedGoalResponse,
)
from ._test_data import make_session
_TEST_USER_ID = "test-user-create-agent"
@pytest.fixture
def tool():
return CreateAgentTool()
@pytest.fixture
def session():
return make_session(_TEST_USER_ID)
@pytest.mark.asyncio
async def test_missing_description_returns_error(tool, session):
"""Missing description returns ErrorResponse."""
result = await tool._execute(user_id=_TEST_USER_ID, session=session, description="")
assert isinstance(result, ErrorResponse)
assert result.error == "Missing description parameter"
@pytest.mark.asyncio
async def test_vague_goal_returns_suggested_goal_response(tool, session):
"""vague_goal decomposition result returns SuggestedGoalResponse, not ErrorResponse."""
vague_result = {
"type": "vague_goal",
"suggested_goal": "Monitor Twitter mentions for a specific keyword and send a daily digest email",
}
with (
patch(
"backend.copilot.tools.create_agent.get_all_relevant_agents_for_generation",
new_callable=AsyncMock,
return_value=[],
),
patch(
"backend.copilot.tools.create_agent.decompose_goal",
new_callable=AsyncMock,
return_value=vague_result,
),
):
result = await tool._execute(
user_id=_TEST_USER_ID,
session=session,
description="monitor social media",
)
assert isinstance(result, SuggestedGoalResponse)
assert result.goal_type == "vague"
assert result.suggested_goal == vague_result["suggested_goal"]
assert result.original_goal == "monitor social media"
assert result.reason == "The goal needs more specific details"
assert not isinstance(result, ErrorResponse)
@pytest.mark.asyncio
async def test_unachievable_goal_returns_suggested_goal_response(tool, session):
"""unachievable_goal decomposition result returns SuggestedGoalResponse, not ErrorResponse."""
unachievable_result = {
"type": "unachievable_goal",
"suggested_goal": "Summarize the latest news articles on a topic and send them by email",
"reason": "There are no blocks for mind-reading.",
}
with (
patch(
"backend.copilot.tools.create_agent.get_all_relevant_agents_for_generation",
new_callable=AsyncMock,
return_value=[],
),
patch(
"backend.copilot.tools.create_agent.decompose_goal",
new_callable=AsyncMock,
return_value=unachievable_result,
),
):
result = await tool._execute(
user_id=_TEST_USER_ID,
session=session,
description="read my mind",
)
assert isinstance(result, SuggestedGoalResponse)
assert result.goal_type == "unachievable"
assert result.suggested_goal == unachievable_result["suggested_goal"]
assert result.original_goal == "read my mind"
assert result.reason == unachievable_result["reason"]
assert not isinstance(result, ErrorResponse)
@pytest.mark.asyncio
async def test_clarifying_questions_returns_clarification_needed_response(
tool, session
):
"""clarifying_questions decomposition result returns ClarificationNeededResponse."""
clarifying_result = {
"type": "clarifying_questions",
"questions": [
{
"question": "What platform should be monitored?",
"keyword": "platform",
"example": "Twitter, Reddit",
}
],
}
with (
patch(
"backend.copilot.tools.create_agent.get_all_relevant_agents_for_generation",
new_callable=AsyncMock,
return_value=[],
),
patch(
"backend.copilot.tools.create_agent.decompose_goal",
new_callable=AsyncMock,
return_value=clarifying_result,
),
):
result = await tool._execute(
user_id=_TEST_USER_ID,
session=session,
description="monitor social media and alert me",
)
assert isinstance(result, ClarificationNeededResponse)
assert len(result.questions) == 1
assert result.questions[0].keyword == "platform"

View File

@@ -33,6 +33,7 @@ query SearchFeatureRequests($term: String!, $filter: IssueFilter, $first: Int) {
id id
identifier identifier
title title
description
} }
} }
} }
@@ -204,6 +205,7 @@ class SearchFeatureRequestsTool(BaseTool):
id=node["id"], id=node["id"],
identifier=node["identifier"], identifier=node["identifier"],
title=node["title"], title=node["title"],
description=node.get("description"),
) )
for node in nodes for node in nodes
] ]
@@ -237,11 +239,7 @@ class CreateFeatureRequestTool(BaseTool):
"Create a new feature request or add a customer need to an existing one. " "Create a new feature request or add a customer need to an existing one. "
"Always search first with search_feature_requests to avoid duplicates. " "Always search first with search_feature_requests to avoid duplicates. "
"If a matching request exists, pass its ID as existing_issue_id to add " "If a matching request exists, pass its ID as existing_issue_id to add "
"the user's need to it instead of creating a duplicate. " "the user's need to it instead of creating a duplicate."
"IMPORTANT: Never include personally identifiable information (PII) in "
"the title or description — no names, emails, phone numbers, company "
"names, or other identifying details. Write titles and descriptions in "
"generic, feature-focused language."
) )
@property @property
@@ -251,20 +249,11 @@ class CreateFeatureRequestTool(BaseTool):
"properties": { "properties": {
"title": { "title": {
"type": "string", "type": "string",
"description": ( "description": "Title for the feature request.",
"Title for the feature request. Must be generic and "
"feature-focused — do not include any user names, emails, "
"company names, or other PII."
),
}, },
"description": { "description": {
"type": "string", "type": "string",
"description": ( "description": "Detailed description of what the user wants and why.",
"Detailed description of what the user wants and why. "
"Must not contain any personally identifiable information "
"(PII) — describe the feature need generically without "
"referencing specific users, companies, or contact details."
),
}, },
"existing_issue_id": { "existing_issue_id": {
"type": "string", "type": "string",

View File

@@ -117,11 +117,13 @@ class TestSearchFeatureRequestsTool:
"id": "id-1", "id": "id-1",
"identifier": "FR-1", "identifier": "FR-1",
"title": "Dark mode", "title": "Dark mode",
"description": "Add dark mode support",
}, },
{ {
"id": "id-2", "id": "id-2",
"identifier": "FR-2", "identifier": "FR-2",
"title": "Dark theme", "title": "Dark theme",
"description": None,
}, },
] ]
patcher, _ = _mock_linear_config(query_return=_search_response(nodes)) patcher, _ = _mock_linear_config(query_return=_search_response(nodes))

View File

@@ -2,7 +2,7 @@
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
from typing import Any from typing import Any, Literal
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -50,6 +50,8 @@ class ResponseType(str, Enum):
# Feature request types # Feature request types
FEATURE_REQUEST_SEARCH = "feature_request_search" FEATURE_REQUEST_SEARCH = "feature_request_search"
FEATURE_REQUEST_CREATED = "feature_request_created" FEATURE_REQUEST_CREATED = "feature_request_created"
# Goal refinement
SUGGESTED_GOAL = "suggested_goal"
# Base response model # Base response model
@@ -296,6 +298,22 @@ class ClarificationNeededResponse(ToolResponseBase):
questions: list[ClarifyingQuestion] = Field(default_factory=list) questions: list[ClarifyingQuestion] = Field(default_factory=list)
class SuggestedGoalResponse(ToolResponseBase):
"""Response when the goal needs refinement with a suggested alternative."""
type: ResponseType = ResponseType.SUGGESTED_GOAL
suggested_goal: str = Field(description="The suggested alternative goal")
reason: str = Field(
default="", description="Why the original goal needs refinement"
)
original_goal: str = Field(
default="", description="The user's original goal for context"
)
goal_type: Literal["vague", "unachievable"] = Field(
default="vague", description="Type: 'vague' or 'unachievable'"
)
# Documentation search models # Documentation search models
class DocSearchResult(BaseModel): class DocSearchResult(BaseModel):
"""A single documentation search result.""" """A single documentation search result."""
@@ -486,6 +504,7 @@ class FeatureRequestInfo(BaseModel):
id: str id: str
identifier: str identifier: str
title: str title: str
description: str | None = None
class FeatureRequestSearchResponse(ToolResponseBase): class FeatureRequestSearchResponse(ToolResponseBase):

View File

@@ -93,15 +93,7 @@ from backend.data.user import (
get_user_notification_preference, get_user_notification_preference,
update_user_integrations, update_user_integrations,
) )
from backend.data.workspace import ( from backend.data.workspace import get_or_create_workspace
count_workspace_files,
create_workspace_file,
get_or_create_workspace,
get_workspace_file,
get_workspace_file_by_path,
list_workspace_files,
soft_delete_workspace_file,
)
from backend.util.service import ( from backend.util.service import (
AppService, AppService,
AppServiceClient, AppServiceClient,
@@ -282,13 +274,7 @@ class DatabaseManager(AppService):
get_user_execution_summary_data = _(get_user_execution_summary_data) get_user_execution_summary_data = _(get_user_execution_summary_data)
# ============ Workspace ============ # # ============ Workspace ============ #
count_workspace_files = _(count_workspace_files)
create_workspace_file = _(create_workspace_file)
get_or_create_workspace = _(get_or_create_workspace) get_or_create_workspace = _(get_or_create_workspace)
get_workspace_file = _(get_workspace_file)
get_workspace_file_by_path = _(get_workspace_file_by_path)
list_workspace_files = _(list_workspace_files)
soft_delete_workspace_file = _(soft_delete_workspace_file)
# ============ Understanding ============ # # ============ Understanding ============ #
get_business_understanding = _(get_business_understanding) get_business_understanding = _(get_business_understanding)
@@ -452,13 +438,7 @@ class DatabaseManagerAsyncClient(AppServiceClient):
get_user_execution_summary_data = d.get_user_execution_summary_data get_user_execution_summary_data = d.get_user_execution_summary_data
# ============ Workspace ============ # # ============ Workspace ============ #
count_workspace_files = d.count_workspace_files
create_workspace_file = d.create_workspace_file
get_or_create_workspace = d.get_or_create_workspace get_or_create_workspace = d.get_or_create_workspace
get_workspace_file = d.get_workspace_file
get_workspace_file_by_path = d.get_workspace_file_by_path
list_workspace_files = d.list_workspace_files
soft_delete_workspace_file = d.soft_delete_workspace_file
# ============ Understanding ============ # # ============ Understanding ============ #
get_business_understanding = d.get_business_understanding get_business_understanding = d.get_business_understanding

View File

@@ -164,23 +164,21 @@ async def create_workspace_file(
async def get_workspace_file( async def get_workspace_file(
file_id: str, file_id: str,
workspace_id: str, workspace_id: Optional[str] = None,
) -> Optional[WorkspaceFile]: ) -> Optional[WorkspaceFile]:
""" """
Get a workspace file by ID. Get a workspace file by ID.
Args: Args:
file_id: The file ID file_id: The file ID
workspace_id: Workspace ID for scoping (required) workspace_id: Optional workspace ID for validation
Returns: Returns:
WorkspaceFile instance or None WorkspaceFile instance or None
""" """
where_clause: UserWorkspaceFileWhereInput = { where_clause: dict = {"id": file_id, "isDeleted": False}
"id": file_id, if workspace_id:
"isDeleted": False, where_clause["workspaceId"] = workspace_id
"workspaceId": workspace_id,
}
file = await UserWorkspaceFile.prisma().find_first(where=where_clause) file = await UserWorkspaceFile.prisma().find_first(where=where_clause)
return WorkspaceFile.from_db(file) if file else None return WorkspaceFile.from_db(file) if file else None
@@ -270,7 +268,7 @@ async def count_workspace_files(
Returns: Returns:
Number of files Number of files
""" """
where_clause: UserWorkspaceFileWhereInput = {"workspaceId": workspace_id} where_clause: dict = {"workspaceId": workspace_id}
if not include_deleted: if not include_deleted:
where_clause["isDeleted"] = False where_clause["isDeleted"] = False
@@ -285,7 +283,7 @@ async def count_workspace_files(
async def soft_delete_workspace_file( async def soft_delete_workspace_file(
file_id: str, file_id: str,
workspace_id: str, workspace_id: Optional[str] = None,
) -> Optional[WorkspaceFile]: ) -> Optional[WorkspaceFile]:
""" """
Soft-delete a workspace file. Soft-delete a workspace file.
@@ -295,7 +293,7 @@ async def soft_delete_workspace_file(
Args: Args:
file_id: The file ID file_id: The file ID
workspace_id: Workspace ID for scoping (required) workspace_id: Optional workspace ID for validation
Returns: Returns:
Updated WorkspaceFile instance or None if not found Updated WorkspaceFile instance or None if not found

View File

@@ -28,7 +28,7 @@ from typing import (
import httpx import httpx
import uvicorn import uvicorn
from fastapi import FastAPI, Request, responses from fastapi import FastAPI, Request, responses
from prisma.errors import DataError, UniqueViolationError from prisma.errors import DataError
from pydantic import BaseModel, TypeAdapter, create_model from pydantic import BaseModel, TypeAdapter, create_model
import backend.util.exceptions as exceptions import backend.util.exceptions as exceptions
@@ -201,7 +201,6 @@ EXCEPTION_MAPPING = {
UnhealthyServiceError, UnhealthyServiceError,
HTTPClientError, HTTPClientError,
HTTPServerError, HTTPServerError,
UniqueViolationError,
*[ *[
ErrorType ErrorType
for _, ErrorType in inspect.getmembers(exceptions) for _, ErrorType in inspect.getmembers(exceptions)
@@ -417,9 +416,6 @@ class AppService(BaseAppService, ABC):
self.fastapi_app.add_exception_handler( self.fastapi_app.add_exception_handler(
DataError, self._handle_internal_http_error(400) DataError, self._handle_internal_http_error(400)
) )
self.fastapi_app.add_exception_handler(
UniqueViolationError, self._handle_internal_http_error(400)
)
self.fastapi_app.add_exception_handler( self.fastapi_app.add_exception_handler(
Exception, self._handle_internal_http_error(500) Exception, self._handle_internal_http_error(500)
) )
@@ -482,7 +478,6 @@ def get_service_client(
# Don't retry these specific exceptions that won't be fixed by retrying # Don't retry these specific exceptions that won't be fixed by retrying
ValueError, # Invalid input/parameters ValueError, # Invalid input/parameters
DataError, # Prisma data integrity errors (foreign key, unique constraints) DataError, # Prisma data integrity errors (foreign key, unique constraints)
UniqueViolationError, # Unique constraint violations
KeyError, # Missing required data KeyError, # Missing required data
TypeError, # Wrong data types TypeError, # Wrong data types
AttributeError, # Missing attributes AttributeError, # Missing attributes

View File

@@ -12,8 +12,15 @@ from typing import Optional
from prisma.errors import UniqueViolationError from prisma.errors import UniqueViolationError
from backend.data.db_accessors import workspace_db from backend.data.workspace import (
from backend.data.workspace import WorkspaceFile WorkspaceFile,
count_workspace_files,
create_workspace_file,
get_workspace_file,
get_workspace_file_by_path,
list_workspace_files,
soft_delete_workspace_file,
)
from backend.util.settings import Config from backend.util.settings import Config
from backend.util.virus_scanner import scan_content_safe from backend.util.virus_scanner import scan_content_safe
from backend.util.workspace_storage import compute_file_checksum, get_workspace_storage from backend.util.workspace_storage import compute_file_checksum, get_workspace_storage
@@ -118,9 +125,8 @@ class WorkspaceManager:
Raises: Raises:
FileNotFoundError: If file doesn't exist FileNotFoundError: If file doesn't exist
""" """
db = workspace_db()
resolved_path = self._resolve_path(path) resolved_path = self._resolve_path(path)
file = await db.get_workspace_file_by_path(self.workspace_id, resolved_path) file = await get_workspace_file_by_path(self.workspace_id, resolved_path)
if file is None: if file is None:
raise FileNotFoundError(f"File not found at path: {resolved_path}") raise FileNotFoundError(f"File not found at path: {resolved_path}")
@@ -140,8 +146,7 @@ class WorkspaceManager:
Raises: Raises:
FileNotFoundError: If file doesn't exist FileNotFoundError: If file doesn't exist
""" """
db = workspace_db() file = await get_workspace_file(file_id, self.workspace_id)
file = await db.get_workspace_file(file_id, self.workspace_id)
if file is None: if file is None:
raise FileNotFoundError(f"File not found: {file_id}") raise FileNotFoundError(f"File not found: {file_id}")
@@ -199,10 +204,8 @@ class WorkspaceManager:
# For overwrite=True, we let the write proceed and handle via UniqueViolationError # For overwrite=True, we let the write proceed and handle via UniqueViolationError
# This ensures the new file is written to storage BEFORE the old one is deleted, # This ensures the new file is written to storage BEFORE the old one is deleted,
# preventing data loss if the new write fails # preventing data loss if the new write fails
db = workspace_db()
if not overwrite: if not overwrite:
existing = await db.get_workspace_file_by_path(self.workspace_id, path) existing = await get_workspace_file_by_path(self.workspace_id, path)
if existing is not None: if existing is not None:
raise ValueError(f"File already exists at path: {path}") raise ValueError(f"File already exists at path: {path}")
@@ -229,7 +232,7 @@ class WorkspaceManager:
# Create database record - handle race condition where another request # Create database record - handle race condition where another request
# created a file at the same path between our check and create # created a file at the same path between our check and create
try: try:
file = await db.create_workspace_file( file = await create_workspace_file(
workspace_id=self.workspace_id, workspace_id=self.workspace_id,
file_id=file_id, file_id=file_id,
name=filename, name=filename,
@@ -243,12 +246,12 @@ class WorkspaceManager:
# Race condition: another request created a file at this path # Race condition: another request created a file at this path
if overwrite: if overwrite:
# Re-fetch and delete the conflicting file, then retry # Re-fetch and delete the conflicting file, then retry
existing = await db.get_workspace_file_by_path(self.workspace_id, path) existing = await get_workspace_file_by_path(self.workspace_id, path)
if existing: if existing:
await self.delete_file(existing.id) await self.delete_file(existing.id)
# Retry the create - if this also fails, clean up storage file # Retry the create - if this also fails, clean up storage file
try: try:
file = await db.create_workspace_file( file = await create_workspace_file(
workspace_id=self.workspace_id, workspace_id=self.workspace_id,
file_id=file_id, file_id=file_id,
name=filename, name=filename,
@@ -311,9 +314,8 @@ class WorkspaceManager:
List of WorkspaceFile instances List of WorkspaceFile instances
""" """
effective_path = self._get_effective_path(path, include_all_sessions) effective_path = self._get_effective_path(path, include_all_sessions)
db = workspace_db()
return await db.list_workspace_files( return await list_workspace_files(
workspace_id=self.workspace_id, workspace_id=self.workspace_id,
path_prefix=effective_path, path_prefix=effective_path,
limit=limit, limit=limit,
@@ -330,8 +332,7 @@ class WorkspaceManager:
Returns: Returns:
True if deleted, False if not found True if deleted, False if not found
""" """
db = workspace_db() file = await get_workspace_file(file_id, self.workspace_id)
file = await db.get_workspace_file(file_id, self.workspace_id)
if file is None: if file is None:
return False return False
@@ -344,7 +345,7 @@ class WorkspaceManager:
# Continue with database soft-delete even if storage delete fails # Continue with database soft-delete even if storage delete fails
# Soft-delete database record # Soft-delete database record
result = await db.soft_delete_workspace_file(file_id, self.workspace_id) result = await soft_delete_workspace_file(file_id, self.workspace_id)
return result is not None return result is not None
async def get_download_url(self, file_id: str, expires_in: int = 3600) -> str: async def get_download_url(self, file_id: str, expires_in: int = 3600) -> str:
@@ -361,8 +362,7 @@ class WorkspaceManager:
Raises: Raises:
FileNotFoundError: If file doesn't exist FileNotFoundError: If file doesn't exist
""" """
db = workspace_db() file = await get_workspace_file(file_id, self.workspace_id)
file = await db.get_workspace_file(file_id, self.workspace_id)
if file is None: if file is None:
raise FileNotFoundError(f"File not found: {file_id}") raise FileNotFoundError(f"File not found: {file_id}")
@@ -379,8 +379,7 @@ class WorkspaceManager:
Returns: Returns:
WorkspaceFile instance or None WorkspaceFile instance or None
""" """
db = workspace_db() return await get_workspace_file(file_id, self.workspace_id)
return await db.get_workspace_file(file_id, self.workspace_id)
async def get_file_info_by_path(self, path: str) -> Optional[WorkspaceFile]: async def get_file_info_by_path(self, path: str) -> Optional[WorkspaceFile]:
""" """
@@ -395,9 +394,8 @@ class WorkspaceManager:
Returns: Returns:
WorkspaceFile instance or None WorkspaceFile instance or None
""" """
db = workspace_db()
resolved_path = self._resolve_path(path) resolved_path = self._resolve_path(path)
return await db.get_workspace_file_by_path(self.workspace_id, resolved_path) return await get_workspace_file_by_path(self.workspace_id, resolved_path)
async def get_file_count( async def get_file_count(
self, self,
@@ -419,8 +417,7 @@ class WorkspaceManager:
Number of files Number of files
""" """
effective_path = self._get_effective_path(path, include_all_sessions) effective_path = self._get_effective_path(path, include_all_sessions)
db = workspace_db()
return await db.count_workspace_files( return await count_workspace_files(
self.workspace_id, path_prefix=effective_path self.workspace_id, path_prefix=effective_path
) )

View File

@@ -93,14 +93,7 @@ class WorkspaceStorageBackend(ABC):
class GCSWorkspaceStorage(WorkspaceStorageBackend): class GCSWorkspaceStorage(WorkspaceStorageBackend):
"""Google Cloud Storage implementation for workspace storage. """Google Cloud Storage implementation for workspace storage."""
Each instance owns a single ``aiohttp.ClientSession`` and GCS async
client. Because ``ClientSession`` is bound to the event loop on which it
was created, callers that run on separate loops (e.g. copilot executor
worker threads) **must** obtain their own ``GCSWorkspaceStorage`` instance
via :func:`get_workspace_storage` which is event-loop-aware.
"""
def __init__(self, bucket_name: str): def __init__(self, bucket_name: str):
self.bucket_name = bucket_name self.bucket_name = bucket_name
@@ -344,73 +337,60 @@ class LocalWorkspaceStorage(WorkspaceStorageBackend):
raise ValueError(f"Invalid storage path format: {storage_path}") raise ValueError(f"Invalid storage path format: {storage_path}")
# --------------------------------------------------------------------------- # Global storage backend instance
# Storage instance management _workspace_storage: Optional[WorkspaceStorageBackend] = None
# ---------------------------------------------------------------------------
# ``aiohttp.ClientSession`` is bound to the event loop where it is created.
# The copilot executor runs each worker in its own thread with a dedicated
# event loop, so a single global ``GCSWorkspaceStorage`` instance would break.
#
# For **local storage** a single shared instance is fine (no async I/O).
# For **GCS storage** we keep one instance *per event loop* so every loop
# gets its own ``ClientSession``.
# ---------------------------------------------------------------------------
_local_storage: Optional[LocalWorkspaceStorage] = None
_gcs_storages: dict[int, GCSWorkspaceStorage] = {}
_storage_lock = asyncio.Lock() _storage_lock = asyncio.Lock()
async def get_workspace_storage() -> WorkspaceStorageBackend: async def get_workspace_storage() -> WorkspaceStorageBackend:
"""Return a workspace storage backend for the **current** event loop.
* Local storage → single shared instance (no event-loop affinity).
* GCS storage → one instance per event loop to avoid cross-loop
``aiohttp`` errors.
""" """
global _local_storage Get the workspace storage backend instance.
config = Config() Uses GCS if media_gcs_bucket_name is configured, otherwise uses local storage.
"""
global _workspace_storage
# --- Local storage (shared) --- if _workspace_storage is None:
if not config.media_gcs_bucket_name: async with _storage_lock:
if _local_storage is None: if _workspace_storage is None:
storage_dir = ( config = Config()
config.workspace_storage_dir if config.workspace_storage_dir else None
)
logger.info(f"Using local workspace storage: {storage_dir or 'default'}")
_local_storage = LocalWorkspaceStorage(storage_dir)
return _local_storage
# --- GCS storage (per event loop) --- if config.media_gcs_bucket_name:
loop_id = id(asyncio.get_running_loop()) logger.info(
if loop_id not in _gcs_storages: f"Using GCS workspace storage: {config.media_gcs_bucket_name}"
logger.info( )
f"Creating GCS workspace storage for loop {loop_id}: " _workspace_storage = GCSWorkspaceStorage(
f"{config.media_gcs_bucket_name}" config.media_gcs_bucket_name
) )
_gcs_storages[loop_id] = GCSWorkspaceStorage(config.media_gcs_bucket_name) else:
return _gcs_storages[loop_id] storage_dir = (
config.workspace_storage_dir
if config.workspace_storage_dir
else None
)
logger.info(
f"Using local workspace storage: {storage_dir or 'default'}"
)
_workspace_storage = LocalWorkspaceStorage(storage_dir)
return _workspace_storage
async def shutdown_workspace_storage() -> None: async def shutdown_workspace_storage() -> None:
"""Shut down workspace storage for the **current** event loop.
Closes the ``aiohttp`` session owned by the current loop's GCS instance.
Each worker thread should call this on its own loop before the loop is
destroyed. The REST API lifespan hook calls it for the main server loop.
""" """
global _local_storage Properly shutdown the global workspace storage backend.
loop_id = id(asyncio.get_running_loop()) Closes aiohttp sessions and other resources for GCS backend.
storage = _gcs_storages.pop(loop_id, None) Should be called during application shutdown.
if storage is not None: """
await storage.close() global _workspace_storage
# Clear local storage only when the last GCS instance is gone if _workspace_storage is not None:
# (i.e. full shutdown, not just a single worker stopping). async with _storage_lock:
if not _gcs_storages: if _workspace_storage is not None:
_local_storage = None if isinstance(_workspace_storage, GCSWorkspaceStorage):
await _workspace_storage.close()
_workspace_storage = None
def compute_file_checksum(content: bytes) -> str: def compute_file_checksum(content: bytes) -> str:

View File

@@ -26,6 +26,7 @@ import {
} from "./components/ClarificationQuestionsCard"; } from "./components/ClarificationQuestionsCard";
import sparklesImg from "./components/MiniGame/assets/sparkles.png"; import sparklesImg from "./components/MiniGame/assets/sparkles.png";
import { MiniGame } from "./components/MiniGame/MiniGame"; import { MiniGame } from "./components/MiniGame/MiniGame";
import { SuggestedGoalCard } from "./components/SuggestedGoalCard";
import { import {
AccordionIcon, AccordionIcon,
formatMaybeJson, formatMaybeJson,
@@ -38,6 +39,7 @@ import {
isOperationInProgressOutput, isOperationInProgressOutput,
isOperationPendingOutput, isOperationPendingOutput,
isOperationStartedOutput, isOperationStartedOutput,
isSuggestedGoalOutput,
ToolIcon, ToolIcon,
truncateText, truncateText,
type CreateAgentToolOutput, type CreateAgentToolOutput,
@@ -77,6 +79,13 @@ function getAccordionMeta(output: CreateAgentToolOutput) {
expanded: true, expanded: true,
}; };
} }
if (isSuggestedGoalOutput(output)) {
return {
icon,
title: "Goal needs refinement",
expanded: true,
};
}
if ( if (
isOperationStartedOutput(output) || isOperationStartedOutput(output) ||
isOperationPendingOutput(output) || isOperationPendingOutput(output) ||
@@ -125,8 +134,13 @@ export function CreateAgentTool({ part }: Props) {
isAgentPreviewOutput(output) || isAgentPreviewOutput(output) ||
isAgentSavedOutput(output) || isAgentSavedOutput(output) ||
isClarificationNeededOutput(output) || isClarificationNeededOutput(output) ||
isSuggestedGoalOutput(output) ||
isErrorOutput(output)); isErrorOutput(output));
function handleUseSuggestedGoal(goal: string) {
onSend(`Please create an agent with this goal: ${goal}`);
}
function handleClarificationAnswers(answers: Record<string, string>) { function handleClarificationAnswers(answers: Record<string, string>) {
const questions = const questions =
output && isClarificationNeededOutput(output) output && isClarificationNeededOutput(output)
@@ -245,6 +259,16 @@ export function CreateAgentTool({ part }: Props) {
/> />
)} )}
{isSuggestedGoalOutput(output) && (
<SuggestedGoalCard
message={output.message}
suggestedGoal={output.suggested_goal}
reason={output.reason}
goalType={output.goal_type ?? "vague"}
onUseSuggestedGoal={handleUseSuggestedGoal}
/>
)}
{isErrorOutput(output) && ( {isErrorOutput(output) && (
<ContentGrid> <ContentGrid>
<ContentMessage>{output.message}</ContentMessage> <ContentMessage>{output.message}</ContentMessage>
@@ -258,6 +282,22 @@ export function CreateAgentTool({ part }: Props) {
{formatMaybeJson(output.details)} {formatMaybeJson(output.details)}
</ContentCodeBlock> </ContentCodeBlock>
)} )}
<div className="flex gap-2">
<Button
variant="outline"
size="small"
onClick={() => onSend("Please try creating the agent again.")}
>
Try again
</Button>
<Button
variant="outline"
size="small"
onClick={() => onSend("Can you help me simplify this goal?")}
>
Simplify goal
</Button>
</div>
</ContentGrid> </ContentGrid>
)} )}
</ToolAccordion> </ToolAccordion>

View File

@@ -0,0 +1,63 @@
"use client";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { ArrowRightIcon, LightbulbIcon } from "@phosphor-icons/react";
interface Props {
message: string;
suggestedGoal: string;
reason?: string;
goalType: string;
onUseSuggestedGoal: (goal: string) => void;
}
export function SuggestedGoalCard({
message,
suggestedGoal,
reason,
goalType,
onUseSuggestedGoal,
}: Props) {
return (
<div className="rounded-xl border border-amber-200 bg-amber-50/50 p-4">
<div className="flex items-start gap-3">
<LightbulbIcon
size={20}
weight="fill"
className="mt-0.5 text-amber-600"
/>
<div className="flex-1 space-y-3">
<div>
<Text variant="body-medium" className="font-medium text-slate-900">
{goalType === "unachievable"
? "Goal cannot be accomplished"
: "Goal needs more detail"}
</Text>
<Text variant="small" className="text-slate-600">
{reason || message}
</Text>
</div>
<div className="rounded-lg border border-amber-300 bg-white p-3">
<Text variant="small" className="mb-1 font-semibold text-amber-800">
Suggested alternative:
</Text>
<Text variant="body-medium" className="text-slate-900">
{suggestedGoal}
</Text>
</div>
<Button
onClick={() => onUseSuggestedGoal(suggestedGoal)}
variant="primary"
>
<span className="inline-flex items-center gap-1.5">
Use this goal <ArrowRightIcon size={14} weight="bold" />
</span>
</Button>
</div>
</div>
</div>
);
}

View File

@@ -6,6 +6,7 @@ import type { OperationInProgressResponse } from "@/app/api/__generated__/models
import type { OperationPendingResponse } from "@/app/api/__generated__/models/operationPendingResponse"; import type { OperationPendingResponse } from "@/app/api/__generated__/models/operationPendingResponse";
import type { OperationStartedResponse } from "@/app/api/__generated__/models/operationStartedResponse"; import type { OperationStartedResponse } from "@/app/api/__generated__/models/operationStartedResponse";
import { ResponseType } from "@/app/api/__generated__/models/responseType"; import { ResponseType } from "@/app/api/__generated__/models/responseType";
import type { SuggestedGoalResponse } from "@/app/api/__generated__/models/suggestedGoalResponse";
import { import {
PlusCircleIcon, PlusCircleIcon,
PlusIcon, PlusIcon,
@@ -21,6 +22,7 @@ export type CreateAgentToolOutput =
| AgentPreviewResponse | AgentPreviewResponse
| AgentSavedResponse | AgentSavedResponse
| ClarificationNeededResponse | ClarificationNeededResponse
| SuggestedGoalResponse
| ErrorResponse; | ErrorResponse;
function parseOutput(output: unknown): CreateAgentToolOutput | null { function parseOutput(output: unknown): CreateAgentToolOutput | null {
@@ -43,6 +45,7 @@ function parseOutput(output: unknown): CreateAgentToolOutput | null {
type === ResponseType.agent_preview || type === ResponseType.agent_preview ||
type === ResponseType.agent_saved || type === ResponseType.agent_saved ||
type === ResponseType.clarification_needed || type === ResponseType.clarification_needed ||
type === ResponseType.suggested_goal ||
type === ResponseType.error type === ResponseType.error
) { ) {
return output as CreateAgentToolOutput; return output as CreateAgentToolOutput;
@@ -55,6 +58,7 @@ function parseOutput(output: unknown): CreateAgentToolOutput | null {
if ("agent_id" in output && "library_agent_id" in output) if ("agent_id" in output && "library_agent_id" in output)
return output as AgentSavedResponse; return output as AgentSavedResponse;
if ("questions" in output) return output as ClarificationNeededResponse; if ("questions" in output) return output as ClarificationNeededResponse;
if ("suggested_goal" in output) return output as SuggestedGoalResponse;
if ("error" in output || "details" in output) if ("error" in output || "details" in output)
return output as ErrorResponse; return output as ErrorResponse;
} }
@@ -114,6 +118,14 @@ export function isClarificationNeededOutput(
); );
} }
export function isSuggestedGoalOutput(
output: CreateAgentToolOutput,
): output is SuggestedGoalResponse {
return (
output.type === ResponseType.suggested_goal || "suggested_goal" in output
);
}
export function isErrorOutput( export function isErrorOutput(
output: CreateAgentToolOutput, output: CreateAgentToolOutput,
): output is ErrorResponse { ): output is ErrorResponse {
@@ -139,6 +151,7 @@ export function getAnimationText(part: {
if (isAgentSavedOutput(output)) return `Saved ${output.agent_name}`; if (isAgentSavedOutput(output)) return `Saved ${output.agent_name}`;
if (isAgentPreviewOutput(output)) return `Preview "${output.agent_name}"`; if (isAgentPreviewOutput(output)) return `Preview "${output.agent_name}"`;
if (isClarificationNeededOutput(output)) return "Needs clarification"; if (isClarificationNeededOutput(output)) return "Needs clarification";
if (isSuggestedGoalOutput(output)) return "Goal needs refinement";
return "Error creating agent"; return "Error creating agent";
} }
case "output-error": case "output-error":

View File

@@ -1052,6 +1052,7 @@
{ {
"$ref": "#/components/schemas/ClarificationNeededResponse" "$ref": "#/components/schemas/ClarificationNeededResponse"
}, },
{ "$ref": "#/components/schemas/SuggestedGoalResponse" },
{ "$ref": "#/components/schemas/BlockListResponse" }, { "$ref": "#/components/schemas/BlockListResponse" },
{ "$ref": "#/components/schemas/BlockDetailsResponse" }, { "$ref": "#/components/schemas/BlockDetailsResponse" },
{ "$ref": "#/components/schemas/BlockOutputResponse" }, { "$ref": "#/components/schemas/BlockOutputResponse" },
@@ -10796,7 +10797,8 @@
"bash_exec", "bash_exec",
"operation_status", "operation_status",
"feature_request_search", "feature_request_search",
"feature_request_created" "feature_request_created",
"suggested_goal"
], ],
"title": "ResponseType", "title": "ResponseType",
"description": "Types of tool responses." "description": "Types of tool responses."
@@ -11677,6 +11679,47 @@
"enum": ["DRAFT", "PENDING", "APPROVED", "REJECTED"], "enum": ["DRAFT", "PENDING", "APPROVED", "REJECTED"],
"title": "SubmissionStatus" "title": "SubmissionStatus"
}, },
"SuggestedGoalResponse": {
"properties": {
"type": {
"$ref": "#/components/schemas/ResponseType",
"default": "suggested_goal"
},
"message": { "type": "string", "title": "Message" },
"session_id": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Session Id"
},
"suggested_goal": {
"type": "string",
"title": "Suggested Goal",
"description": "The suggested alternative goal"
},
"reason": {
"type": "string",
"title": "Reason",
"description": "Why the original goal needs refinement",
"default": ""
},
"original_goal": {
"type": "string",
"title": "Original Goal",
"description": "The user's original goal for context",
"default": ""
},
"goal_type": {
"type": "string",
"enum": ["vague", "unachievable"],
"title": "Goal Type",
"description": "Type: 'vague' or 'unachievable'",
"default": "vague"
}
},
"type": "object",
"required": ["message", "suggested_goal"],
"title": "SuggestedGoalResponse",
"description": "Response when the goal needs refinement with a suggested alternative."
},
"SuggestionsResponse": { "SuggestionsResponse": {
"properties": { "properties": {
"otto_suggestions": { "otto_suggestions": {

View File

@@ -69,11 +69,12 @@ test.describe("Marketplace Creator Page Basic Functionality", () => {
await marketplacePage.getFirstCreatorProfile(page); await marketplacePage.getFirstCreatorProfile(page);
await firstCreatorProfile.click(); await firstCreatorProfile.click();
await page.waitForURL("**/marketplace/creator/**"); await page.waitForURL("**/marketplace/creator/**");
await page.waitForLoadState("networkidle").catch(() => {});
const firstAgent = page const firstAgent = page
.locator('[data-testid="store-card"]:visible') .locator('[data-testid="store-card"]:visible')
.first(); .first();
await firstAgent.waitFor({ state: "visible", timeout: 15000 }); await firstAgent.waitFor({ state: "visible", timeout: 30000 });
await firstAgent.click(); await firstAgent.click();
await page.waitForURL("**/marketplace/agent/**"); await page.waitForURL("**/marketplace/agent/**");

View File

@@ -115,11 +115,18 @@ test.describe("Marketplace Basic Functionality", () => {
const searchTerm = page.getByText("DummyInput").first(); const searchTerm = page.getByText("DummyInput").first();
await isVisible(searchTerm); await isVisible(searchTerm);
await expect await page.waitForLoadState("networkidle").catch(() => {});
.poll(() => marketplacePage.getSearchResultsCount(page), {
timeout: 15000, await page
}) .waitForFunction(
.toBeGreaterThan(0); () =>
document.querySelectorAll('[data-testid="store-card"]').length > 0,
{ timeout: 15000 },
)
.catch(() => console.log("No search results appeared within timeout"));
const results = await marketplacePage.getSearchResultsCount(page);
expect(results).toBeGreaterThan(0);
console.log("Complete search flow works correctly test passed ✅"); console.log("Complete search flow works correctly test passed ✅");
}); });
@@ -128,9 +135,7 @@ test.describe("Marketplace Basic Functionality", () => {
}); });
test.describe("Marketplace Edge Cases", () => { test.describe("Marketplace Edge Cases", () => {
test("Search for non-existent item renders search page correctly", async ({ test("Search for non-existent item shows no results", async ({ page }) => {
page,
}) => {
const marketplacePage = new MarketplacePage(page); const marketplacePage = new MarketplacePage(page);
await marketplacePage.goto(page); await marketplacePage.goto(page);
@@ -146,18 +151,9 @@ test.describe("Marketplace Edge Cases", () => {
const searchTerm = page.getByText("xyznonexistentitemxyz123"); const searchTerm = page.getByText("xyznonexistentitemxyz123");
await isVisible(searchTerm); await isVisible(searchTerm);
// The search page should render either results or a "No results found" message const results = await marketplacePage.getSearchResultsCount(page);
await page.waitForLoadState("networkidle").catch(() => {}); expect(results).toBe(0);
const hasResults =
(await page.locator('[data-testid="store-card"]').count()) > 0;
const hasNoResultsMsg = await page
.getByText("No results found")
.isVisible()
.catch(() => false);
expect(hasResults || hasNoResultsMsg).toBe(true);
console.log( console.log("Search for non-existent item shows no results test passed ✅");
"Search for non-existent item renders search page correctly test passed ✅",
);
}); });
}); });

View File

@@ -125,8 +125,16 @@ export class BuildPage extends BasePage {
`[data-id="block-card-${blockCardId}"]`, `[data-id="block-card-${blockCardId}"]`,
); );
await blockCard.waitFor({ state: "visible", timeout: 10000 }); try {
await blockCard.click(); // Wait for the block card to be visible with a reasonable timeout
await blockCard.waitFor({ state: "visible", timeout: 10000 });
await blockCard.click();
} catch (error) {
console.log(
`Block ${block.name} (display: ${displayName}) returned from the API but not found in block list`,
);
console.log(`Error: ${error}`);
}
} }
async hasBlock(_block: Block) { async hasBlock(_block: Block) {

View File

@@ -65,7 +65,7 @@ export class LoginPage {
await this.page.waitForLoadState("load", { timeout: 10_000 }); await this.page.waitForLoadState("load", { timeout: 10_000 });
console.log("➡️ Navigating to /marketplace ..."); console.log("➡️ Navigating to /marketplace ...");
await this.page.goto("/marketplace", { timeout: 20_000 }); await this.page.goto("/marketplace", { timeout: 10_000 });
console.log("✅ Login process complete"); console.log("✅ Login process complete");
// If Wallet popover auto-opens, close it to avoid blocking account menu interactions // If Wallet popover auto-opens, close it to avoid blocking account menu interactions

View File

@@ -9,12 +9,7 @@ export class MarketplacePage extends BasePage {
async goto(page: Page) { async goto(page: Page) {
await page.goto("/marketplace"); await page.goto("/marketplace");
await page await page.waitForLoadState("networkidle").catch(() => {});
.locator(
'[data-testid="store-card"], [data-testid="featured-store-card"]',
)
.first()
.waitFor({ state: "visible", timeout: 20000 });
} }
async getMarketplaceTitle(page: Page) { async getMarketplaceTitle(page: Page) {
@@ -116,7 +111,7 @@ export class MarketplacePage extends BasePage {
async getFirstFeaturedAgent(page: Page) { async getFirstFeaturedAgent(page: Page) {
const { getId } = getSelectors(page); const { getId } = getSelectors(page);
const card = getId("featured-store-card").first(); const card = getId("featured-store-card").first();
await card.waitFor({ state: "visible", timeout: 15000 }); await card.waitFor({ state: "visible", timeout: 30000 });
return card; return card;
} }
@@ -124,14 +119,14 @@ export class MarketplacePage extends BasePage {
const card = this.page const card = this.page
.locator('[data-testid="store-card"]:visible') .locator('[data-testid="store-card"]:visible')
.first(); .first();
await card.waitFor({ state: "visible", timeout: 15000 }); await card.waitFor({ state: "visible", timeout: 30000 });
return card; return card;
} }
async getFirstCreatorProfile(page: Page) { async getFirstCreatorProfile(page: Page) {
const { getId } = getSelectors(page); const { getId } = getSelectors(page);
const card = getId("creator-card").first(); const card = getId("creator-card").first();
await card.waitFor({ state: "visible", timeout: 15000 }); await card.waitFor({ state: "visible", timeout: 30000 });
return card; return card;
} }

View File

@@ -45,9 +45,8 @@ export async function isEnabled(el: Locator) {
} }
export async function hasMinCount(el: Locator, minCount: number) { export async function hasMinCount(el: Locator, minCount: number) {
await expect const count = await el.count();
.poll(async () => await el.count(), { timeout: 10000 }) expect(count).toBeGreaterThanOrEqual(minCount);
.toBeGreaterThanOrEqual(minCount);
} }
export async function matchesUrl(page: Page, pattern: RegExp) { export async function matchesUrl(page: Page, pattern: RegExp) {