mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-30 03:00:41 -04:00
Compare commits
86 Commits
master
...
feat/task-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b42d087003 | ||
|
|
4a1741cc15 | ||
|
|
db20387ef9 | ||
|
|
59d74fb10b | ||
|
|
62217614f6 | ||
|
|
234f05fe25 | ||
|
|
3b7cbe7d38 | ||
|
|
b373e3757f | ||
|
|
dbab590442 | ||
|
|
8023b66116 | ||
|
|
b1eee6eca4 | ||
|
|
eedb64a03d | ||
|
|
15499722d2 | ||
|
|
b9849ce5c0 | ||
|
|
263a414ffd | ||
|
|
26caa86bb8 | ||
|
|
95362f7881 | ||
|
|
4fff564268 | ||
|
|
f8df0fabaa | ||
|
|
16a51e5ca8 | ||
|
|
089fc065b2 | ||
|
|
00c992ae08 | ||
|
|
51c16c687d | ||
|
|
40d56ede76 | ||
|
|
1ec30ab130 | ||
|
|
35231703bb | ||
|
|
2b24ef5b07 | ||
|
|
731748da41 | ||
|
|
792c78883b | ||
|
|
d0cb1e981e | ||
|
|
162c3f09a4 | ||
|
|
982808c435 | ||
|
|
5b29ff5a16 | ||
|
|
a31297cf8a | ||
|
|
4d5969d59e | ||
|
|
5542f780d2 | ||
|
|
8299948b21 | ||
|
|
db36a7624e | ||
|
|
dece04ec04 | ||
|
|
88b39e4611 | ||
|
|
480ec70218 | ||
|
|
e376db617c | ||
|
|
e4a3c4f6ce | ||
|
|
559438fd64 | ||
|
|
c01c47d53c | ||
|
|
19cd77f2eb | ||
|
|
420251ca9d | ||
|
|
62d474899d | ||
|
|
225bdfb543 | ||
|
|
35bca7c7ad | ||
|
|
ffdafccc35 | ||
|
|
bee1c9a3bb | ||
|
|
b0c46ff197 | ||
|
|
8d102d6eeb | ||
|
|
020d094381 | ||
|
|
aa5b84e13c | ||
|
|
6d1cd41c43 | ||
|
|
d892b66580 | ||
|
|
0840b565e3 | ||
|
|
5f19f6ca23 | ||
|
|
b2dab8afad | ||
|
|
7b60e45604 | ||
|
|
8f5b9fa791 | ||
|
|
ca7dc221df | ||
|
|
98470c27e1 | ||
|
|
2760cb076f | ||
|
|
fdfd53b45e | ||
|
|
ed989801d2 | ||
|
|
f467ead855 | ||
|
|
f7601d06ed | ||
|
|
fb86fcb67d | ||
|
|
94f065a7e0 | ||
|
|
8d5e8a9e3f | ||
|
|
02b972cfc4 | ||
|
|
31ce418d5e | ||
|
|
70689ce326 | ||
|
|
9004a3ada1 | ||
|
|
5e9cee524d | ||
|
|
b9d47a8cf5 | ||
|
|
5fa33111de | ||
|
|
aca81f3e40 | ||
|
|
629fb4d3bb | ||
|
|
703d34364d | ||
|
|
f330699a89 | ||
|
|
5bb919e7b5 | ||
|
|
261959104a |
@@ -82,6 +82,7 @@ from backend.copilot.tools.models import (
|
||||
NoResultsResponse,
|
||||
SetupRequirementsResponse,
|
||||
SuggestedGoalResponse,
|
||||
TaskDecompositionResponse,
|
||||
TodoWriteResponse,
|
||||
UnderstandingUpdatedResponse,
|
||||
)
|
||||
@@ -1490,6 +1491,7 @@ ToolResponseUnion = (
|
||||
| DocPageResponse
|
||||
| MCPToolsDiscoveredResponse
|
||||
| MCPToolOutputResponse
|
||||
| TaskDecompositionResponse
|
||||
| MemoryStoreResponse
|
||||
| MemorySearchResponse
|
||||
| MemoryForgetCandidatesResponse
|
||||
|
||||
@@ -416,6 +416,98 @@ def test_update_subscription_tier_paid_requires_urls(
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_update_subscription_tier_currency_mismatch_returns_422(
|
||||
client: fastapi.testclient.TestClient,
|
||||
mocker: pytest_mock.MockFixture,
|
||||
) -> None:
|
||||
"""Stripe rejects a SubscriptionSchedule whose phases mix currencies (e.g.
|
||||
GBP-checkout sub trying to schedule a USD-only target Price). The handler
|
||||
must convert that into a specific 422 instead of the generic 502 so the
|
||||
caller can tell the difference between a currency-config bug and a Stripe
|
||||
outage."""
|
||||
mock_user = Mock()
|
||||
mock_user.subscription_tier = SubscriptionTier.MAX
|
||||
|
||||
async def mock_feature_enabled(*args, **kwargs):
|
||||
return True
|
||||
|
||||
mocker.patch(
|
||||
"backend.api.features.v1.get_user_by_id",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_user,
|
||||
)
|
||||
mocker.patch(
|
||||
"backend.api.features.v1.is_feature_enabled",
|
||||
side_effect=mock_feature_enabled,
|
||||
)
|
||||
mocker.patch(
|
||||
"backend.api.features.v1.modify_stripe_subscription_for_tier",
|
||||
side_effect=stripe.InvalidRequestError(
|
||||
"The price specified only supports `usd`. This doesn't match the"
|
||||
" expected currency: `gbp`.",
|
||||
param="phases",
|
||||
),
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/credits/subscription",
|
||||
json={
|
||||
"tier": "PRO",
|
||||
"success_url": f"{TEST_FRONTEND_ORIGIN}/success",
|
||||
"cancel_url": f"{TEST_FRONTEND_ORIGIN}/cancel",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
detail = response.json()["detail"]
|
||||
assert "billing currency" in detail.lower()
|
||||
assert "contact support" in detail.lower()
|
||||
|
||||
|
||||
def test_update_subscription_tier_non_currency_invalid_request_returns_502(
|
||||
client: fastapi.testclient.TestClient,
|
||||
mocker: pytest_mock.MockFixture,
|
||||
) -> None:
|
||||
"""Locks the contract that *only* currency-mismatch InvalidRequestErrors
|
||||
translate to 422 — every other Stripe InvalidRequestError must still
|
||||
surface as the generic 502 so that widening the conditional later is
|
||||
caught by the suite."""
|
||||
mock_user = Mock()
|
||||
mock_user.subscription_tier = SubscriptionTier.MAX
|
||||
|
||||
async def mock_feature_enabled(*args, **kwargs):
|
||||
return True
|
||||
|
||||
mocker.patch(
|
||||
"backend.api.features.v1.get_user_by_id",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_user,
|
||||
)
|
||||
mocker.patch(
|
||||
"backend.api.features.v1.is_feature_enabled",
|
||||
side_effect=mock_feature_enabled,
|
||||
)
|
||||
mocker.patch(
|
||||
"backend.api.features.v1.modify_stripe_subscription_for_tier",
|
||||
side_effect=stripe.InvalidRequestError(
|
||||
"No such price: 'price_does_not_exist'",
|
||||
param="items[0][price]",
|
||||
),
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/credits/subscription",
|
||||
json={
|
||||
"tier": "PRO",
|
||||
"success_url": f"{TEST_FRONTEND_ORIGIN}/success",
|
||||
"cancel_url": f"{TEST_FRONTEND_ORIGIN}/cancel",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 502
|
||||
assert "billing currency" not in response.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_update_subscription_tier_creates_checkout(
|
||||
client: fastapi.testclient.TestClient,
|
||||
mocker: pytest_mock.MockFixture,
|
||||
|
||||
@@ -1003,6 +1003,35 @@ async def update_subscription_tier(
|
||||
return await get_subscription_status(user_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail=str(e))
|
||||
except stripe.InvalidRequestError as e:
|
||||
# Stripe rejects schedule modify when phases mix currencies, e.g. the
|
||||
# active sub was checked out in GBP but the target tier's Price is
|
||||
# USD-only. 502 reads as outage; surface a 422 with a specific message
|
||||
# so the user/admin can see what to fix in Stripe.
|
||||
msg = str(e)
|
||||
if "currency" in msg.lower():
|
||||
logger.warning(
|
||||
"Currency mismatch on tier change for user %s: %s", user_id, msg
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=(
|
||||
"Tier change unavailable for your current billing currency."
|
||||
" Please contact support — the target tier needs to be"
|
||||
" configured for your currency in Stripe before this"
|
||||
" change can go through."
|
||||
),
|
||||
)
|
||||
logger.exception(
|
||||
"Stripe error modifying subscription for user %s: %s", user_id, e
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=(
|
||||
"Unable to update your subscription right now. "
|
||||
"Please try again or contact support."
|
||||
),
|
||||
)
|
||||
except stripe.StripeError as e:
|
||||
logger.exception(
|
||||
"Stripe error modifying subscription for user %s: %s", user_id, e
|
||||
|
||||
@@ -81,6 +81,7 @@ ToolName = Literal[
|
||||
"create_feature_request",
|
||||
"create_folder",
|
||||
"customize_agent",
|
||||
"decompose_goal",
|
||||
"delete_folder",
|
||||
"delete_workspace_file",
|
||||
"edit_agent",
|
||||
|
||||
@@ -3,6 +3,47 @@
|
||||
You can create, edit, and customize agents directly. You ARE the brain —
|
||||
generate the agent JSON yourself using block schemas, then validate and save.
|
||||
|
||||
### Clarifying — Before or During Building
|
||||
|
||||
Use `ask_question` whenever the user's intent is ambiguous — whether
|
||||
that's before starting or midway through the workflow. Common moments:
|
||||
|
||||
- **Before building**: output format, delivery channel, data source, or
|
||||
trigger is unspecified.
|
||||
- **During block discovery**: multiple blocks could fit and the user
|
||||
should choose.
|
||||
- **During JSON generation**: a wiring decision depends on user
|
||||
preference.
|
||||
|
||||
Steps:
|
||||
1. Call `find_block` (or another discovery tool) to learn what the
|
||||
platform actually supports for the ambiguous dimension.
|
||||
2. Call `ask_question` with a concrete question listing the discovered
|
||||
options (e.g. "The platform supports Gmail, Slack, and Google Docs —
|
||||
which should the agent use for delivery?").
|
||||
3. **Wait for the user's answer** before continuing.
|
||||
|
||||
**Skip this** when the goal already specifies all dimensions (e.g.
|
||||
"scrape prices from Amazon and email me daily").
|
||||
|
||||
### Before Building: Show the Plan
|
||||
|
||||
Start agent generation by calling `decompose_goal` once to display your
|
||||
build plan to the user as a step-by-step UI card.
|
||||
|
||||
1. Analyze the user's request and break it into logical build steps (e.g.
|
||||
"add input block", "add AI summarizer", "wire blocks together").
|
||||
2. Call `decompose_goal` with those steps. Do not write any text before
|
||||
or after the tool call — the platform renders the plan UI card
|
||||
automatically, so any extra text duplicates the display.
|
||||
3. Continue immediately with the workflow below in the same turn. The
|
||||
plan card is informational only — there is no approval step, no
|
||||
countdown, and no need to wait for the user.
|
||||
|
||||
For simple goals (1-2 blocks), keep steps brief (2-3 steps).
|
||||
For complex goals, use as many steps as needed.
|
||||
|
||||
|
||||
### Workflow for Creating/Editing Agents
|
||||
|
||||
1. **If editing**: First narrow to the specific agent by UUID, then fetch its
|
||||
|
||||
@@ -17,6 +17,7 @@ from .connect_integration import ConnectIntegrationTool
|
||||
from .continue_run_block import ContinueRunBlockTool
|
||||
from .create_agent import CreateAgentTool
|
||||
from .customize_agent import CustomizeAgentTool
|
||||
from .decompose_goal import DecomposeGoalTool
|
||||
from .edit_agent import EditAgentTool
|
||||
from .feature_requests import CreateFeatureRequestTool, SearchFeatureRequestsTool
|
||||
from .find_agent import FindAgentTool
|
||||
@@ -65,6 +66,7 @@ TOOL_REGISTRY: dict[str, BaseTool] = {
|
||||
"add_understanding": AddUnderstandingTool(),
|
||||
"create_agent": CreateAgentTool(),
|
||||
"customize_agent": CustomizeAgentTool(),
|
||||
"decompose_goal": DecomposeGoalTool(),
|
||||
"edit_agent": EditAgentTool(),
|
||||
"find_agent": FindAgentTool(),
|
||||
"find_block": FindBlockTool(),
|
||||
|
||||
136
autogpt_platform/backend/backend/copilot/tools/decompose_goal.py
Normal file
136
autogpt_platform/backend/backend/copilot/tools/decompose_goal.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""DecomposeGoalTool - Breaks agent-building goals into sub-instructions."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from backend.copilot.model import ChatSession
|
||||
|
||||
from .base import BaseTool
|
||||
from .models import (
|
||||
DecompositionStepModel,
|
||||
ErrorResponse,
|
||||
TaskDecompositionResponse,
|
||||
ToolResponseBase,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_ACTION = "add_block"
|
||||
VALID_ACTIONS = {"add_block", "connect_blocks", "configure", "add_input", "add_output"}
|
||||
|
||||
|
||||
class DecomposeGoalTool(BaseTool):
|
||||
"""Tool for decomposing an agent goal into sub-instructions."""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "decompose_goal"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return (
|
||||
"Show the user your build plan as a step-by-step card before "
|
||||
"constructing the agent. Each step maps to one task (e.g. add a "
|
||||
"block, wire connections, configure settings). Display-only — "
|
||||
"the build continues in the same turn without pausing for user "
|
||||
"input."
|
||||
)
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"goal": {
|
||||
"type": "string",
|
||||
"description": "The user's agent-building goal.",
|
||||
},
|
||||
"steps": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Human-readable step description.",
|
||||
},
|
||||
"action": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Action type: 'add_block', 'connect_blocks', "
|
||||
"'configure', 'add_input', 'add_output'."
|
||||
),
|
||||
"enum": list(VALID_ACTIONS),
|
||||
},
|
||||
"block_name": {
|
||||
"type": "string",
|
||||
"description": "Block name if adding a block.",
|
||||
},
|
||||
},
|
||||
"required": ["description", "action"],
|
||||
},
|
||||
"description": "List of sub-instructions for the plan.",
|
||||
},
|
||||
},
|
||||
"required": ["goal", "steps"],
|
||||
}
|
||||
|
||||
async def _execute(
|
||||
self,
|
||||
user_id: str | None,
|
||||
session: ChatSession,
|
||||
goal: str | None = None,
|
||||
steps: list[Any] | None = None,
|
||||
**kwargs,
|
||||
) -> ToolResponseBase:
|
||||
session_id = session.session_id if session else None
|
||||
|
||||
if not goal:
|
||||
return ErrorResponse(
|
||||
message="Please provide a goal to decompose.",
|
||||
error="missing_goal",
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
if not steps:
|
||||
return ErrorResponse(
|
||||
message="Please provide at least one step in the plan.",
|
||||
error="missing_steps",
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
decomposition_steps: list[DecompositionStepModel] = []
|
||||
for i, step in enumerate(steps):
|
||||
if not isinstance(step, dict):
|
||||
return ErrorResponse(
|
||||
message=f"Step {i + 1} is malformed — expected an object.",
|
||||
error="invalid_step",
|
||||
session_id=session_id,
|
||||
)
|
||||
description = step.get("description", "")
|
||||
if not description or not description.strip():
|
||||
return ErrorResponse(
|
||||
message=f"Step {i + 1} is missing a description.",
|
||||
error="empty_description",
|
||||
session_id=session_id,
|
||||
)
|
||||
action = step.get("action", DEFAULT_ACTION)
|
||||
if action not in VALID_ACTIONS:
|
||||
action = DEFAULT_ACTION
|
||||
decomposition_steps.append(
|
||||
DecompositionStepModel(
|
||||
step_id=f"step_{i + 1}",
|
||||
description=description,
|
||||
action=action,
|
||||
block_name=step.get("block_name"),
|
||||
status="pending",
|
||||
)
|
||||
)
|
||||
|
||||
return TaskDecompositionResponse(
|
||||
message=f"Here's the plan to build your agent ({len(decomposition_steps)} steps):",
|
||||
goal=goal,
|
||||
steps=decomposition_steps,
|
||||
step_count=len(decomposition_steps),
|
||||
session_id=session_id,
|
||||
)
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Unit tests for DecomposeGoalTool."""
|
||||
|
||||
import pytest
|
||||
|
||||
from ._test_data import make_session
|
||||
from .decompose_goal import DEFAULT_ACTION, DecomposeGoalTool
|
||||
from .models import ErrorResponse, TaskDecompositionResponse
|
||||
|
||||
_USER_ID = "test-user-decompose-goal"
|
||||
|
||||
_VALID_STEPS = [
|
||||
{"description": "Add input block", "action": "add_input"},
|
||||
{
|
||||
"description": "Add AI summarizer block",
|
||||
"action": "add_block",
|
||||
"block_name": "AI Text Generator",
|
||||
},
|
||||
{"description": "Connect blocks together", "action": "connect_blocks"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def tool() -> DecomposeGoalTool:
|
||||
return DecomposeGoalTool()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def session():
|
||||
return make_session(_USER_ID)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Happy path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_happy_path(tool: DecomposeGoalTool, session):
|
||||
result = await tool._execute(
|
||||
user_id=_USER_ID,
|
||||
session=session,
|
||||
goal="Build a news summarizer agent",
|
||||
steps=_VALID_STEPS,
|
||||
)
|
||||
|
||||
assert isinstance(result, TaskDecompositionResponse)
|
||||
assert result.goal == "Build a news summarizer agent"
|
||||
assert len(result.steps) == 3
|
||||
assert result.step_count == 3
|
||||
assert result.steps[0].step_id == "step_1"
|
||||
assert result.steps[0].description == "Add input block"
|
||||
assert result.steps[1].block_name == "AI Text Generator"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_step_count_matches_steps(tool: DecomposeGoalTool, session):
|
||||
"""TaskDecompositionResponse.step_count must always equal len(steps)."""
|
||||
result = await tool._execute(
|
||||
user_id=_USER_ID,
|
||||
session=session,
|
||||
goal="Simple agent",
|
||||
steps=[{"description": "Only step", "action": "add_block"}],
|
||||
)
|
||||
assert isinstance(result, TaskDecompositionResponse)
|
||||
assert result.step_count == len(result.steps)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_action_defaults_to_add_block(tool: DecomposeGoalTool, session):
|
||||
"""Unknown action values are coerced to DEFAULT_ACTION."""
|
||||
result = await tool._execute(
|
||||
user_id=_USER_ID,
|
||||
session=session,
|
||||
goal="Build agent",
|
||||
steps=[{"description": "Do something weird", "action": "fly_to_moon"}],
|
||||
)
|
||||
assert isinstance(result, TaskDecompositionResponse)
|
||||
assert result.steps[0].action == DEFAULT_ACTION
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_block_name_optional(tool: DecomposeGoalTool, session):
|
||||
"""Steps without block_name should succeed with block_name=None."""
|
||||
result = await tool._execute(
|
||||
user_id=_USER_ID,
|
||||
session=session,
|
||||
goal="Agent with no block name",
|
||||
steps=[{"description": "Configure the agent", "action": "configure"}],
|
||||
)
|
||||
assert isinstance(result, TaskDecompositionResponse)
|
||||
assert result.steps[0].block_name is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation — missing inputs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_goal_returns_error(tool: DecomposeGoalTool, session):
|
||||
result = await tool._execute(
|
||||
user_id=_USER_ID,
|
||||
session=session,
|
||||
goal=None,
|
||||
steps=_VALID_STEPS,
|
||||
)
|
||||
assert isinstance(result, ErrorResponse)
|
||||
assert result.error == "missing_goal"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_goal_returns_error(tool: DecomposeGoalTool, session):
|
||||
result = await tool._execute(
|
||||
user_id=_USER_ID,
|
||||
session=session,
|
||||
goal="",
|
||||
steps=_VALID_STEPS,
|
||||
)
|
||||
assert isinstance(result, ErrorResponse)
|
||||
assert result.error == "missing_goal"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_steps_returns_error(tool: DecomposeGoalTool, session):
|
||||
result = await tool._execute(
|
||||
user_id=_USER_ID,
|
||||
session=session,
|
||||
goal="Build agent",
|
||||
steps=None,
|
||||
)
|
||||
assert isinstance(result, ErrorResponse)
|
||||
assert result.error == "missing_steps"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_steps_returns_error(tool: DecomposeGoalTool, session):
|
||||
result = await tool._execute(
|
||||
user_id=_USER_ID,
|
||||
session=session,
|
||||
goal="Build agent",
|
||||
steps=[],
|
||||
)
|
||||
assert isinstance(result, ErrorResponse)
|
||||
assert result.error == "missing_steps"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation — malformed step items
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_dict_step_returns_error(tool: DecomposeGoalTool, session):
|
||||
"""A step that is not a dict should return an error."""
|
||||
result = await tool._execute(
|
||||
user_id=_USER_ID,
|
||||
session=session,
|
||||
goal="Build agent",
|
||||
steps=["not a dict"], # type: ignore[list-item]
|
||||
)
|
||||
assert isinstance(result, ErrorResponse)
|
||||
assert result.error == "invalid_step"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_step_with_empty_description_returns_error(
|
||||
tool: DecomposeGoalTool, session
|
||||
):
|
||||
result = await tool._execute(
|
||||
user_id=_USER_ID,
|
||||
session=session,
|
||||
goal="Build agent",
|
||||
steps=[{"description": "", "action": "add_block"}],
|
||||
)
|
||||
assert isinstance(result, ErrorResponse)
|
||||
assert result.error == "empty_description"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_step_with_missing_description_returns_error(
|
||||
tool: DecomposeGoalTool, session
|
||||
):
|
||||
result = await tool._execute(
|
||||
user_id=_USER_ID,
|
||||
session=session,
|
||||
goal="Build agent",
|
||||
steps=[{"action": "add_block"}],
|
||||
)
|
||||
assert isinstance(result, ErrorResponse)
|
||||
assert result.error == "empty_description"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ID generation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_step_ids_are_sequential(tool: DecomposeGoalTool, session):
|
||||
result = await tool._execute(
|
||||
user_id=_USER_ID,
|
||||
session=session,
|
||||
goal="Build agent",
|
||||
steps=_VALID_STEPS,
|
||||
)
|
||||
assert isinstance(result, TaskDecompositionResponse)
|
||||
for i, step in enumerate(result.steps):
|
||||
assert step.step_id == f"step_{i + 1}"
|
||||
@@ -4,7 +4,7 @@ from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from backend.data.graph import BaseGraph
|
||||
from backend.data.model import CredentialsMetaInput
|
||||
@@ -36,6 +36,9 @@ class ResponseType(str, Enum):
|
||||
AGENT_BUILDER_VALIDATION_RESULT = "agent_builder_validation_result"
|
||||
AGENT_BUILDER_FIX_RESULT = "agent_builder_fix_result"
|
||||
|
||||
# Task decomposition (goal → sub-instructions)
|
||||
TASK_DECOMPOSITION = "task_decomposition"
|
||||
|
||||
# Block
|
||||
BLOCK_LIST = "block_list"
|
||||
BLOCK_DETAILS = "block_details"
|
||||
@@ -813,6 +816,42 @@ class AgentsMovedToFolderResponse(ToolResponseBase):
|
||||
count: int = 0
|
||||
|
||||
|
||||
# Task decomposition models
|
||||
|
||||
|
||||
class DecompositionStepModel(BaseModel):
|
||||
"""A single step in a decomposed agent-building plan."""
|
||||
|
||||
step_id: str = Field(description="Unique step identifier, e.g. 'step_1'")
|
||||
description: str = Field(description="Human-readable step description")
|
||||
action: str = Field(
|
||||
description="Action type: 'add_block', 'connect_blocks', 'configure', etc."
|
||||
)
|
||||
block_name: str | None = Field(
|
||||
default=None, description="Block being added, if applicable"
|
||||
)
|
||||
status: str = Field(
|
||||
default="pending",
|
||||
description="Step status: pending, in_progress, completed, failed",
|
||||
)
|
||||
|
||||
|
||||
class TaskDecompositionResponse(ToolResponseBase):
|
||||
"""Response for decompose_goal tool — shows the plan to the user."""
|
||||
|
||||
type: ResponseType = ResponseType.TASK_DECOMPOSITION
|
||||
goal: str = Field(description="The original user goal")
|
||||
steps: list[DecompositionStepModel]
|
||||
step_count: int = Field(
|
||||
default=0, description="Number of steps (auto-derived from steps list)"
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def sync_step_count(self) -> "TaskDecompositionResponse":
|
||||
self.step_count = len(self.steps)
|
||||
return self
|
||||
|
||||
|
||||
# --- Graphiti memory responses ---
|
||||
|
||||
|
||||
|
||||
@@ -21,22 +21,9 @@ from backend.copilot.tools import TOOL_REGISTRY
|
||||
# response shape carries) and the dry_run description. Keeps the
|
||||
# regression gate effective while accepting a deliberate ~120-token
|
||||
# spend on LLM-decision-critical copy.
|
||||
# Bumped 32500 -> 32800 on PR #12871 for the new web_search tool
|
||||
# (server-side Anthropic beta). Description already trimmed to the
|
||||
# minimum viable copy; the bump absorbs the schema skeleton cost
|
||||
# (~300 chars / ~75 tokens) for a new LLM-facing primitive.
|
||||
# Bumped 32800 -> 33200 on PR #12873 for the web_search Perplexity
|
||||
# Sonar refactor — adds a load-bearing `deep` boolean with explicit
|
||||
# "~100x more expensive" cost warning the model must see to avoid
|
||||
# accidentally triggering sonar-reasoning on ordinary lookups, plus
|
||||
# synthesised-answer wording in the top-level description so the LLM
|
||||
# reads the answer before reaching for `web_fetch`. Both are
|
||||
# LLM-decision-critical copy, not bloat.
|
||||
# Bumped 33200 -> 34000 when baseline gained the MCP `TodoWrite` tool
|
||||
# for parity with the Claude Code SDK's built-in (PR #12879). The new
|
||||
# schema adds ~600 chars; description already trimmed to the minimum
|
||||
# viable copy.
|
||||
_CHAR_BUDGET = 34_000
|
||||
# Bumped to 34000 to accommodate decompose_goal tool + web_search +
|
||||
# TodoWrite tool descriptions.
|
||||
_CHAR_BUDGET = 35_000
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildRenderSegments,
|
||||
isCompletedToolPart,
|
||||
isInteractiveToolPart,
|
||||
parseSpecialMarkers,
|
||||
splitReasoningAndResponse,
|
||||
} from "../helpers";
|
||||
import type { MessagePart } from "../helpers";
|
||||
|
||||
function textPart(text: string): MessagePart {
|
||||
return { type: "text", text } as MessagePart;
|
||||
}
|
||||
|
||||
function toolPart(
|
||||
toolName: string,
|
||||
state: string,
|
||||
output?: unknown,
|
||||
): MessagePart {
|
||||
return {
|
||||
type: `tool-${toolName}`,
|
||||
toolCallId: `call_${toolName}`,
|
||||
toolName,
|
||||
state,
|
||||
output,
|
||||
} as unknown as MessagePart;
|
||||
}
|
||||
|
||||
describe("isCompletedToolPart", () => {
|
||||
it("returns true for output-available tool part", () => {
|
||||
const part = toolPart("some_tool", "output-available");
|
||||
expect(isCompletedToolPart(part)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for output-error tool part", () => {
|
||||
const part = toolPart("some_tool", "output-error");
|
||||
expect(isCompletedToolPart(part)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for input-streaming tool part", () => {
|
||||
const part = toolPart("some_tool", "input-streaming");
|
||||
expect(isCompletedToolPart(part)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for text part", () => {
|
||||
const part = textPart("hello");
|
||||
expect(isCompletedToolPart(part)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isInteractiveToolPart", () => {
|
||||
it("returns true for task_decomposition type", () => {
|
||||
const part = toolPart("decompose_goal", "output-available", {
|
||||
type: "task_decomposition",
|
||||
message: "Plan",
|
||||
goal: "Build agent",
|
||||
steps: [],
|
||||
step_count: 0,
|
||||
});
|
||||
expect(isInteractiveToolPart(part)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for setup_requirements type", () => {
|
||||
const part = toolPart("run_mcp_tool", "output-available", {
|
||||
type: "setup_requirements",
|
||||
message: "Setup needed",
|
||||
});
|
||||
expect(isInteractiveToolPart(part)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for agent_details type", () => {
|
||||
const part = toolPart("find_agent", "output-available", {
|
||||
type: "agent_details",
|
||||
});
|
||||
expect(isInteractiveToolPart(part)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for non-interactive output type", () => {
|
||||
const part = toolPart("some_tool", "output-available", {
|
||||
type: "generic_output",
|
||||
});
|
||||
expect(isInteractiveToolPart(part)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when state is not output-available", () => {
|
||||
const part = toolPart("decompose_goal", "input-streaming", {
|
||||
type: "task_decomposition",
|
||||
});
|
||||
expect(isInteractiveToolPart(part)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for non-tool parts", () => {
|
||||
const part = textPart("hello");
|
||||
expect(isInteractiveToolPart(part)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when output is null", () => {
|
||||
const part = toolPart("decompose_goal", "output-available", null);
|
||||
expect(isInteractiveToolPart(part)).toBe(false);
|
||||
});
|
||||
|
||||
it("handles JSON-encoded string output", () => {
|
||||
const part = toolPart(
|
||||
"decompose_goal",
|
||||
"output-available",
|
||||
JSON.stringify({ type: "task_decomposition" }),
|
||||
);
|
||||
expect(isInteractiveToolPart(part)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for invalid JSON string output", () => {
|
||||
const part = toolPart(
|
||||
"decompose_goal",
|
||||
"output-available",
|
||||
"not valid json",
|
||||
);
|
||||
expect(isInteractiveToolPart(part)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRenderSegments", () => {
|
||||
it("returns individual segments for custom tool types", () => {
|
||||
const parts = [
|
||||
toolPart("decompose_goal", "output-available", {
|
||||
type: "task_decomposition",
|
||||
}),
|
||||
];
|
||||
const segments = buildRenderSegments(parts);
|
||||
expect(segments).toHaveLength(1);
|
||||
expect(segments[0].kind).toBe("part");
|
||||
});
|
||||
|
||||
it("collapses consecutive generic completed tool parts", () => {
|
||||
const parts = [
|
||||
toolPart("unknown_tool_a", "output-available"),
|
||||
toolPart("unknown_tool_b", "output-available"),
|
||||
];
|
||||
const segments = buildRenderSegments(parts);
|
||||
expect(segments).toHaveLength(1);
|
||||
expect(segments[0].kind).toBe("collapsed-group");
|
||||
if (segments[0].kind === "collapsed-group") {
|
||||
expect(segments[0].parts).toHaveLength(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not collapse custom tool types into groups", () => {
|
||||
const parts = [
|
||||
toolPart("decompose_goal", "output-available", {
|
||||
type: "task_decomposition",
|
||||
}),
|
||||
toolPart("create_agent", "output-available"),
|
||||
];
|
||||
const segments = buildRenderSegments(parts);
|
||||
expect(segments).toHaveLength(2);
|
||||
expect(segments[0].kind).toBe("part");
|
||||
expect(segments[1].kind).toBe("part");
|
||||
});
|
||||
|
||||
it("renders text parts individually", () => {
|
||||
const parts = [textPart("Hello"), textPart("World")];
|
||||
const segments = buildRenderSegments(parts);
|
||||
expect(segments).toHaveLength(2);
|
||||
expect(segments.every((s) => s.kind === "part")).toBe(true);
|
||||
});
|
||||
|
||||
it("handles mixed custom tools, generic tools, and text", () => {
|
||||
const parts = [
|
||||
textPart("Plan:"),
|
||||
toolPart("decompose_goal", "output-available"),
|
||||
toolPart("generic_a", "output-available"),
|
||||
toolPart("generic_b", "output-available"),
|
||||
textPart("Done"),
|
||||
];
|
||||
const segments = buildRenderSegments(parts);
|
||||
|
||||
expect(segments[0].kind).toBe("part");
|
||||
expect(segments[1].kind).toBe("part");
|
||||
expect(segments[2].kind).toBe("collapsed-group");
|
||||
expect(segments[3].kind).toBe("part");
|
||||
});
|
||||
|
||||
it("does not collapse a single generic tool part", () => {
|
||||
const parts = [toolPart("generic_a", "output-available")];
|
||||
const segments = buildRenderSegments(parts);
|
||||
expect(segments).toHaveLength(1);
|
||||
expect(segments[0].kind).toBe("part");
|
||||
});
|
||||
|
||||
it("preserves baseIndex offset in part segments", () => {
|
||||
const parts = [textPart("Hello")];
|
||||
const segments = buildRenderSegments(parts, 5);
|
||||
expect(segments).toHaveLength(1);
|
||||
if (segments[0].kind === "part") {
|
||||
expect(segments[0].index).toBe(5);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitReasoningAndResponse", () => {
|
||||
it("returns all parts as response when no tools are present", () => {
|
||||
const parts = [textPart("Hello"), textPart("World")];
|
||||
const { reasoning, response } = splitReasoningAndResponse(parts);
|
||||
expect(reasoning).toHaveLength(0);
|
||||
expect(response).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns all parts as response when no text follows the last tool", () => {
|
||||
const parts = [
|
||||
textPart("Thinking..."),
|
||||
toolPart("decompose_goal", "output-available", {
|
||||
type: "task_decomposition",
|
||||
}),
|
||||
];
|
||||
const { reasoning, response } = splitReasoningAndResponse(parts);
|
||||
expect(reasoning).toHaveLength(0);
|
||||
expect(response).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps non-interactive tool parts in reasoning", () => {
|
||||
const genericTool = toolPart("find_block", "output-available", {
|
||||
type: "block_list",
|
||||
});
|
||||
const parts = [
|
||||
textPart("Looking for blocks..."),
|
||||
genericTool,
|
||||
textPart("Found them."),
|
||||
];
|
||||
const { reasoning, response } = splitReasoningAndResponse(parts);
|
||||
expect(reasoning).toHaveLength(2);
|
||||
expect(reasoning[1]).toBe(genericTool);
|
||||
expect(response).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSpecialMarkers", () => {
|
||||
it("returns null marker for plain text", () => {
|
||||
const result = parseSpecialMarkers("Hello world");
|
||||
expect(result.markerType).toBeNull();
|
||||
expect(result.cleanText).toBe("Hello world");
|
||||
});
|
||||
|
||||
it("detects error marker", () => {
|
||||
const result = parseSpecialMarkers(
|
||||
"Some preamble [__COPILOT_ERROR_f7a1__] Something went wrong",
|
||||
);
|
||||
expect(result.markerType).toBe("error");
|
||||
expect(result.markerText).toBe("Something went wrong");
|
||||
});
|
||||
|
||||
it("detects retryable error marker", () => {
|
||||
const result = parseSpecialMarkers(
|
||||
"[__COPILOT_RETRYABLE_ERROR_a9c2__] Timeout reached",
|
||||
);
|
||||
expect(result.markerType).toBe("retryable_error");
|
||||
expect(result.markerText).toBe("Timeout reached");
|
||||
});
|
||||
|
||||
it("detects system marker", () => {
|
||||
const result = parseSpecialMarkers(
|
||||
"[__COPILOT_SYSTEM_e3b0__] Session expired",
|
||||
);
|
||||
expect(result.markerType).toBe("system");
|
||||
expect(result.markerText).toBe("Session expired");
|
||||
});
|
||||
|
||||
it("retryable takes precedence over regular error when both present", () => {
|
||||
const text =
|
||||
"[__COPILOT_RETRYABLE_ERROR_a9c2__] Retryable issue [__COPILOT_ERROR_f7a1__] Also error";
|
||||
const result = parseSpecialMarkers(text);
|
||||
expect(result.markerType).toBe("retryable_error");
|
||||
});
|
||||
|
||||
it("strips marker from cleanText", () => {
|
||||
const result = parseSpecialMarkers(
|
||||
"Preamble text [__COPILOT_SYSTEM_e3b0__] System message",
|
||||
);
|
||||
expect(result.cleanText).toBe("Preamble text");
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { ArtifactCard } from "../../ArtifactCard/ArtifactCard";
|
||||
import { AskQuestionTool } from "../../../tools/AskQuestion/AskQuestion";
|
||||
import { ConnectIntegrationTool } from "../../../tools/ConnectIntegrationTool/ConnectIntegrationTool";
|
||||
import { CreateAgentTool } from "../../../tools/CreateAgent/CreateAgent";
|
||||
import { DecomposeGoalTool } from "../../../tools/DecomposeGoal/DecomposeGoal";
|
||||
import { EditAgentTool } from "../../../tools/EditAgent/EditAgent";
|
||||
import {
|
||||
CreateFeatureRequestTool,
|
||||
@@ -180,6 +181,8 @@ export function MessagePartRenderer({
|
||||
case "tool-run_agent":
|
||||
case "tool-schedule_agent":
|
||||
return <RunAgentTool key={key} part={part as ToolUIPart} />;
|
||||
case "tool-decompose_goal":
|
||||
return <DecomposeGoalTool key={key} part={part as ToolUIPart} />;
|
||||
case "tool-create_agent":
|
||||
return <CreateAgentTool key={key} part={part as ToolUIPart} />;
|
||||
case "tool-edit_agent":
|
||||
|
||||
@@ -31,6 +31,7 @@ const CUSTOM_TOOL_TYPES = new Set([
|
||||
"tool-view_agent_output",
|
||||
"tool-search_feature_requests",
|
||||
"tool-create_feature_request",
|
||||
"tool-decompose_goal",
|
||||
]);
|
||||
|
||||
const REASONING_TOOL_TYPES = new Set([
|
||||
@@ -62,6 +63,7 @@ const INTERACTIVE_RESPONSE_TYPES: ReadonlySet<string> = new Set([
|
||||
ResponseType.suggested_goal,
|
||||
ResponseType.agent_builder_preview,
|
||||
ResponseType.agent_builder_saved,
|
||||
ResponseType.task_decomposition,
|
||||
]);
|
||||
|
||||
export function isCompletedToolPart(part: MessagePart): part is ToolUIPart {
|
||||
@@ -144,15 +146,29 @@ export function splitReasoningAndResponse(parts: MessagePart[]): {
|
||||
reasoning: MessagePart[];
|
||||
response: MessagePart[];
|
||||
} {
|
||||
const lastReasoningIndex = parts.findLastIndex(isReasoningBoundary);
|
||||
// Manual reverse loop instead of `Array.prototype.findLastIndex`. The
|
||||
// built-in version was being elided by the bundler in CI's vitest run,
|
||||
// causing the function to misread the boundary index and return the input
|
||||
// unchanged. The explicit loop is opaque to that optimization.
|
||||
let lastReasoningIndex = -1;
|
||||
for (let i = parts.length - 1; i >= 0; i--) {
|
||||
if (isReasoningBoundary(parts[i])) {
|
||||
lastReasoningIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastReasoningIndex === -1) {
|
||||
return { reasoning: [], response: parts };
|
||||
}
|
||||
|
||||
const hasResponseAfterReasoning = parts
|
||||
.slice(lastReasoningIndex + 1)
|
||||
.some((p) => p.type === "text");
|
||||
let hasResponseAfterReasoning = false;
|
||||
for (let i = lastReasoningIndex + 1; i < parts.length; i++) {
|
||||
if (parts[i].type === "text") {
|
||||
hasResponseAfterReasoning = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasResponseAfterReasoning) {
|
||||
return { reasoning: [], response: parts };
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolUIPart } from "ai";
|
||||
import { useCopilotChatActions } from "../../components/CopilotChatActionsProvider/useCopilotChatActions";
|
||||
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
|
||||
import {
|
||||
ContentGrid,
|
||||
ContentMessage,
|
||||
} from "../../components/ToolAccordion/AccordionContent";
|
||||
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
|
||||
import { ToolErrorCard } from "../../components/ToolErrorCard/ToolErrorCard";
|
||||
import { StepItem } from "./components/StepItem";
|
||||
import {
|
||||
AccordionIcon,
|
||||
getAnimationText,
|
||||
getDecomposeGoalOutput,
|
||||
isDecompositionOutput,
|
||||
isErrorOutput,
|
||||
ToolIcon,
|
||||
} from "./helpers";
|
||||
|
||||
interface Props {
|
||||
part: ToolUIPart;
|
||||
}
|
||||
|
||||
export function DecomposeGoalTool({ part }: Props) {
|
||||
const text = getAnimationText(part);
|
||||
const { onSend } = useCopilotChatActions();
|
||||
|
||||
const isStreaming =
|
||||
part.state === "input-streaming" || part.state === "input-available";
|
||||
|
||||
const output = getDecomposeGoalOutput(part);
|
||||
const isError =
|
||||
part.state === "output-error" || (!!output && isErrorOutput(output));
|
||||
const isPending = !output && !isError;
|
||||
|
||||
return (
|
||||
<div className="py-2">
|
||||
{isPending && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<ToolIcon isStreaming={isStreaming} isError={isError} />
|
||||
<MorphingTextAnimation
|
||||
text={text}
|
||||
className={isError ? "text-red-500" : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<ToolErrorCard
|
||||
message={
|
||||
output && isErrorOutput(output) ? (output.message ?? "") : ""
|
||||
}
|
||||
fallbackMessage="Failed to analyze the goal. Please try again."
|
||||
actions={[
|
||||
{
|
||||
label: "Try again",
|
||||
onClick: () => onSend("Please try decomposing the goal again."),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{output && isDecompositionOutput(output) && (
|
||||
<ToolAccordion
|
||||
icon={<AccordionIcon />}
|
||||
title={`Build Plan — ${output.step_count} steps`}
|
||||
description={output.goal}
|
||||
defaultExpanded
|
||||
>
|
||||
<ContentGrid>
|
||||
<ContentMessage>{output.message}</ContentMessage>
|
||||
|
||||
<div className="rounded-lg border border-border bg-card p-3">
|
||||
<div className="space-y-0.5">
|
||||
{output.steps.map((step, i) => (
|
||||
<StepItem
|
||||
key={step.step_id}
|
||||
index={i}
|
||||
description={step.description}
|
||||
blockName={step.block_name}
|
||||
status={step.status ?? "pending"}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ContentGrid>
|
||||
</ToolAccordion>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
} from "@/tests/integrations/test-utils";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { DecomposeGoalTool } from "../DecomposeGoal";
|
||||
import type { TaskDecompositionOutput } from "../helpers";
|
||||
|
||||
const mockOnSend = vi.fn();
|
||||
vi.mock(
|
||||
"../../../components/CopilotChatActionsProvider/useCopilotChatActions",
|
||||
() => ({
|
||||
useCopilotChatActions: () => ({ onSend: mockOnSend }),
|
||||
}),
|
||||
);
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
step_id: "step_1",
|
||||
description: "Add input block",
|
||||
action: "add_input",
|
||||
block_name: null,
|
||||
status: "pending",
|
||||
},
|
||||
{
|
||||
step_id: "step_2",
|
||||
description: "Add AI summarizer",
|
||||
action: "add_block",
|
||||
block_name: "AI Text Generator",
|
||||
status: "pending",
|
||||
},
|
||||
{
|
||||
step_id: "step_3",
|
||||
description: "Connect blocks",
|
||||
action: "connect_blocks",
|
||||
block_name: null,
|
||||
status: "pending",
|
||||
},
|
||||
];
|
||||
|
||||
const DECOMPOSITION: TaskDecompositionOutput = {
|
||||
type: "task_decomposition",
|
||||
message: "Here's the plan (3 steps):",
|
||||
goal: "Build a news summarizer",
|
||||
steps: STEPS,
|
||||
step_count: 3,
|
||||
session_id: "test-session-1",
|
||||
};
|
||||
|
||||
function makePart(
|
||||
state: string,
|
||||
output?: unknown,
|
||||
): {
|
||||
type: string;
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
state: string;
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
} {
|
||||
return {
|
||||
type: "tool-decompose_goal",
|
||||
toolCallId: "call_1",
|
||||
toolName: "decompose_goal",
|
||||
state,
|
||||
output,
|
||||
};
|
||||
}
|
||||
|
||||
describe("DecomposeGoalTool", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
mockOnSend.mockClear();
|
||||
});
|
||||
|
||||
it("renders analyzing animation during input-streaming", () => {
|
||||
render(<DecomposeGoalTool part={makePart("input-streaming") as any} />);
|
||||
expect(screen.getByText(/A/)).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders error card when state is output-error", () => {
|
||||
render(<DecomposeGoalTool part={makePart("output-error") as any} />);
|
||||
expect(screen.getByText(/Failed to analyze the goal/i)).toBeDefined();
|
||||
expect(screen.getByText("Try again")).toBeDefined();
|
||||
});
|
||||
|
||||
it("sends retry message when Try again is clicked on error", () => {
|
||||
render(<DecomposeGoalTool part={makePart("output-error") as any} />);
|
||||
fireEvent.click(screen.getByText("Try again"));
|
||||
expect(mockOnSend).toHaveBeenCalledWith(
|
||||
"Please try decomposing the goal again.",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders error card for error output object", () => {
|
||||
const errorOutput = {
|
||||
type: "error",
|
||||
error: "missing_steps",
|
||||
message: "Please provide at least one step.",
|
||||
};
|
||||
render(
|
||||
<DecomposeGoalTool
|
||||
part={makePart("output-available", errorOutput) as any}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Please provide at least one step.")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders the build plan accordion with steps as a read-only list", () => {
|
||||
render(
|
||||
<DecomposeGoalTool
|
||||
part={makePart("output-available", DECOMPOSITION) as any}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/Build Plan — 3 steps/)).toBeDefined();
|
||||
expect(screen.getByText("Build a news summarizer")).toBeDefined();
|
||||
expect(screen.getByText(/Here's the plan/)).toBeDefined();
|
||||
expect(screen.getByText(/1\. Add input block/)).toBeDefined();
|
||||
expect(screen.getByText(/2\. Add AI summarizer/)).toBeDefined();
|
||||
expect(screen.getByText(/3\. Connect blocks/)).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders block name badges for steps that have them", () => {
|
||||
render(
|
||||
<DecomposeGoalTool
|
||||
part={makePart("output-available", DECOMPOSITION) as any}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("AI Text Generator")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not render approve, modify, or edit controls", () => {
|
||||
render(
|
||||
<DecomposeGoalTool
|
||||
part={makePart("output-available", DECOMPOSITION) as any}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText("Modify")).toBeNull();
|
||||
expect(screen.queryByText("Approve")).toBeNull();
|
||||
expect(screen.queryByText(/Starting in/)).toBeNull();
|
||||
expect(screen.queryByPlaceholderText("Step description")).toBeNull();
|
||||
expect(screen.queryByLabelText("Remove step")).toBeNull();
|
||||
expect(screen.queryByLabelText("Insert step here")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not call onSend when the plan card renders", () => {
|
||||
render(
|
||||
<DecomposeGoalTool
|
||||
part={makePart("output-available", DECOMPOSITION) as any}
|
||||
/>,
|
||||
);
|
||||
expect(mockOnSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders nothing pending when output is not yet available", () => {
|
||||
const { container } = render(
|
||||
<DecomposeGoalTool part={makePart("input-available") as any} />,
|
||||
);
|
||||
expect(container.querySelector(".py-2")).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Unit tests for DecomposeGoal/helpers.tsx
|
||||
*
|
||||
* Covers: parseOutput / getDecomposeGoalOutput, type guards, getAnimationText
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getAnimationText,
|
||||
getDecomposeGoalOutput,
|
||||
isDecompositionOutput,
|
||||
isErrorOutput,
|
||||
type DecomposeErrorOutput,
|
||||
type DecomposeGoalOutput,
|
||||
type TaskDecompositionOutput,
|
||||
} from "../helpers";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DECOMPOSITION: TaskDecompositionOutput = {
|
||||
type: "task_decomposition",
|
||||
message: "Here's the plan (3 steps):",
|
||||
goal: "Build a news summarizer",
|
||||
steps: [
|
||||
{
|
||||
step_id: "step_1",
|
||||
description: "Add input block",
|
||||
action: "add_input",
|
||||
block_name: null,
|
||||
status: "pending",
|
||||
},
|
||||
{
|
||||
step_id: "step_2",
|
||||
description: "Add AI summarizer",
|
||||
action: "add_block",
|
||||
block_name: "AI Text Generator",
|
||||
status: "pending",
|
||||
},
|
||||
{
|
||||
step_id: "step_3",
|
||||
description: "Connect blocks",
|
||||
action: "connect_blocks",
|
||||
block_name: null,
|
||||
status: "pending",
|
||||
},
|
||||
],
|
||||
step_count: 3,
|
||||
};
|
||||
|
||||
const ERROR_OUTPUT: DecomposeErrorOutput = {
|
||||
type: "error",
|
||||
error: "missing_steps",
|
||||
message: "Please provide at least one step.",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isDecompositionOutput
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("isDecompositionOutput", () => {
|
||||
it("returns true for a full decomposition output", () => {
|
||||
expect(isDecompositionOutput(DECOMPOSITION)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for an error output", () => {
|
||||
expect(
|
||||
isDecompositionOutput(ERROR_OUTPUT as unknown as DecomposeGoalOutput),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when steps is not an array (type guard tightness)", () => {
|
||||
const malformed = {
|
||||
steps: "not-an-array",
|
||||
goal: "test",
|
||||
} as unknown as DecomposeGoalOutput;
|
||||
expect(isDecompositionOutput(malformed)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isErrorOutput
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("isErrorOutput", () => {
|
||||
it("returns true for error output", () => {
|
||||
expect(isErrorOutput(ERROR_OUTPUT as unknown as DecomposeGoalOutput)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false for decomposition output", () => {
|
||||
expect(isErrorOutput(DECOMPOSITION)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getDecomposeGoalOutput — output parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getDecomposeGoalOutput", () => {
|
||||
it("parses a direct object output", () => {
|
||||
const part = { output: DECOMPOSITION };
|
||||
const result = getDecomposeGoalOutput(part);
|
||||
expect(result).not.toBeNull();
|
||||
expect(isDecompositionOutput(result!)).toBe(true);
|
||||
});
|
||||
|
||||
it("parses a JSON-encoded string output", () => {
|
||||
const part = { output: JSON.stringify(DECOMPOSITION) };
|
||||
const result = getDecomposeGoalOutput(part);
|
||||
expect(result).not.toBeNull();
|
||||
expect(isDecompositionOutput(result!)).toBe(true);
|
||||
expect((result as TaskDecompositionOutput).goal).toBe(
|
||||
"Build a news summarizer",
|
||||
);
|
||||
});
|
||||
|
||||
it("parses an error output object", () => {
|
||||
const part = { output: ERROR_OUTPUT };
|
||||
const result = getDecomposeGoalOutput(part);
|
||||
expect(result).not.toBeNull();
|
||||
expect(isErrorOutput(result!)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns null for falsy output", () => {
|
||||
expect(getDecomposeGoalOutput({ output: null })).toBeNull();
|
||||
expect(getDecomposeGoalOutput({ output: undefined })).toBeNull();
|
||||
expect(getDecomposeGoalOutput({ output: "" })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a plain non-JSON string", () => {
|
||||
expect(getDecomposeGoalOutput({ output: "just text" })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a non-object part", () => {
|
||||
expect(getDecomposeGoalOutput(null)).toBeNull();
|
||||
expect(getDecomposeGoalOutput("string")).toBeNull();
|
||||
expect(getDecomposeGoalOutput(42)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for an array-type output (not a valid shape)", () => {
|
||||
expect(
|
||||
getDecomposeGoalOutput({ output: ["not", "an", "object"] }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("classifies 'steps+goal' before 'error' when object has all three keys", () => {
|
||||
// Verify type discrimination precedence: steps+goal wins
|
||||
const mixed = { ...DECOMPOSITION, error: "some_error" };
|
||||
const part = { output: mixed };
|
||||
const result = getDecomposeGoalOutput(part);
|
||||
expect(result).not.toBeNull();
|
||||
expect(isDecompositionOutput(result!)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns message-only error when no error key but has message", () => {
|
||||
const messageOnly = { type: "error", message: "Something failed" };
|
||||
const result = getDecomposeGoalOutput({ output: messageOnly });
|
||||
expect(result).not.toBeNull();
|
||||
// isErrorOutput requires 'error' key, so this falls through to message-only branch
|
||||
expect((result as DecomposeErrorOutput).message).toBe("Something failed");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getAnimationText
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getAnimationText", () => {
|
||||
it("shows analyzing text during input-streaming", () => {
|
||||
const text = getAnimationText({ state: "input-streaming" });
|
||||
expect(text.toLowerCase()).toContain("analyzing");
|
||||
});
|
||||
|
||||
it("shows analyzing text during input-available", () => {
|
||||
const text = getAnimationText({ state: "input-available" });
|
||||
expect(text.toLowerCase()).toContain("analyzing");
|
||||
});
|
||||
|
||||
it("shows plan ready with step count on output-available with decomposition", () => {
|
||||
const text = getAnimationText({
|
||||
state: "output-available",
|
||||
output: DECOMPOSITION,
|
||||
});
|
||||
expect(text).toContain("3 steps");
|
||||
});
|
||||
|
||||
it("shows analyzing when output-available but output is not a decomposition", () => {
|
||||
const text = getAnimationText({
|
||||
state: "output-available",
|
||||
output: null,
|
||||
});
|
||||
expect(text.toLowerCase()).toContain("analyzing");
|
||||
});
|
||||
|
||||
it("shows error text on output-error state", () => {
|
||||
const text = getAnimationText({ state: "output-error" });
|
||||
expect(text.toLowerCase()).toContain("error");
|
||||
});
|
||||
|
||||
it("falls back to analyzing for unknown state", () => {
|
||||
const text = getAnimationText({ state: "result" as never });
|
||||
expect(text.toLowerCase()).toContain("analyzing");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { CubeIcon } from "@phosphor-icons/react";
|
||||
import { StepStatusIcon } from "../helpers";
|
||||
|
||||
interface Props {
|
||||
index: number;
|
||||
description: string;
|
||||
blockName?: string | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export function StepItem({ index, description, blockName, status }: Props) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 py-1.5">
|
||||
<div className="mt-0.5 flex shrink-0 items-center">
|
||||
<StepStatusIcon status={status} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Text variant="body-medium" className="text-sm text-foreground">
|
||||
{index + 1}. {description}
|
||||
</Text>
|
||||
{blockName && (
|
||||
<div className="mt-0.5 flex items-center gap-1">
|
||||
<CubeIcon size={12} className="text-muted-foreground" />
|
||||
<Text
|
||||
variant="small"
|
||||
className="font-mono text-xs text-muted-foreground"
|
||||
>
|
||||
{blockName}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { render, screen } from "@/tests/integrations/test-utils";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { StepItem } from "../StepItem";
|
||||
|
||||
describe("StepItem", () => {
|
||||
it("renders step number and description", () => {
|
||||
render(
|
||||
<StepItem index={0} description="Add input block" status="pending" />,
|
||||
);
|
||||
expect(screen.getByText("1. Add input block")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders block name when provided", () => {
|
||||
render(
|
||||
<StepItem
|
||||
index={1}
|
||||
description="Add AI summarizer"
|
||||
blockName="AI Text Generator"
|
||||
status="pending"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("AI Text Generator")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not render block name when null", () => {
|
||||
render(
|
||||
<StepItem
|
||||
index={0}
|
||||
description="Connect blocks"
|
||||
blockName={null}
|
||||
status="pending"
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText("AI Text Generator")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders pending icon by default", () => {
|
||||
render(<StepItem index={0} description="Step" status="pending" />);
|
||||
expect(screen.getByLabelText("pending")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders completed icon for completed status", () => {
|
||||
render(<StepItem index={0} description="Step" status="completed" />);
|
||||
expect(screen.getByLabelText("completed")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders in-progress icon for in_progress status", () => {
|
||||
render(<StepItem index={0} description="Step" status="in_progress" />);
|
||||
expect(screen.getByLabelText("in progress")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders failed icon for failed status", () => {
|
||||
render(<StepItem index={0} description="Step" status="failed" />);
|
||||
expect(screen.getByLabelText("failed")).toBeDefined();
|
||||
});
|
||||
|
||||
it("uses zero-based index to render 1-based step number", () => {
|
||||
render(<StepItem index={4} description="Fifth step" status="pending" />);
|
||||
expect(screen.getByText("5. Fifth step")).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import type { DecompositionStepModel } from "@/app/api/__generated__/models/decompositionStepModel";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
CircleDashedIcon,
|
||||
ListChecksIcon,
|
||||
SpinnerGapIcon,
|
||||
WarningDiamondIcon,
|
||||
XCircleIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { ToolUIPart } from "ai";
|
||||
import { ScaleLoader } from "../../components/ScaleLoader/ScaleLoader";
|
||||
|
||||
// Re-export generated step type for consumers that need it.
|
||||
export type DecompositionStep = DecompositionStepModel;
|
||||
|
||||
export interface TaskDecompositionOutput {
|
||||
type: string;
|
||||
message: string;
|
||||
session_id?: string | null;
|
||||
goal: string;
|
||||
steps: DecompositionStep[];
|
||||
step_count: number;
|
||||
}
|
||||
|
||||
export interface DecomposeErrorOutput {
|
||||
type: string;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export type DecomposeGoalOutput =
|
||||
| TaskDecompositionOutput
|
||||
| DecomposeErrorOutput;
|
||||
|
||||
function parseOutput(output: unknown): DecomposeGoalOutput | null {
|
||||
if (!output) return null;
|
||||
if (typeof output === "string") {
|
||||
const trimmed = output.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
return parseOutput(JSON.parse(trimmed) as unknown);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (typeof output === "object" && !Array.isArray(output)) {
|
||||
const obj = output as Record<string, unknown>;
|
||||
if (
|
||||
"steps" in obj &&
|
||||
"goal" in obj &&
|
||||
Array.isArray(obj.steps) &&
|
||||
typeof obj.goal === "string"
|
||||
) {
|
||||
return obj as unknown as TaskDecompositionOutput;
|
||||
}
|
||||
if ("error" in obj && typeof obj.error === "string") {
|
||||
return obj as unknown as DecomposeErrorOutput;
|
||||
}
|
||||
// Message-only error payload (no 'error' key but also not a decomposition)
|
||||
if ("message" in obj && typeof obj.message === "string") {
|
||||
return obj as unknown as DecomposeErrorOutput;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getDecomposeGoalOutput(
|
||||
part: unknown,
|
||||
): DecomposeGoalOutput | null {
|
||||
if (!part || typeof part !== "object") return null;
|
||||
return parseOutput((part as { output?: unknown }).output);
|
||||
}
|
||||
|
||||
export function isDecompositionOutput(
|
||||
output: DecomposeGoalOutput,
|
||||
): output is TaskDecompositionOutput {
|
||||
return (
|
||||
"steps" in output &&
|
||||
Array.isArray((output as TaskDecompositionOutput).steps) &&
|
||||
"goal" in output
|
||||
);
|
||||
}
|
||||
|
||||
export function isErrorOutput(
|
||||
output: DecomposeGoalOutput,
|
||||
): output is DecomposeErrorOutput {
|
||||
return "error" in output;
|
||||
}
|
||||
|
||||
export function getAnimationText(part: {
|
||||
state: ToolUIPart["state"];
|
||||
output?: unknown;
|
||||
}): string {
|
||||
switch (part.state) {
|
||||
case "input-streaming":
|
||||
case "input-available":
|
||||
return "Analyzing your goal...";
|
||||
case "output-available": {
|
||||
const output = parseOutput(part.output);
|
||||
if (output && isDecompositionOutput(output))
|
||||
return `Plan ready (${output.step_count} steps)`;
|
||||
return "Analyzing your goal...";
|
||||
}
|
||||
case "output-error":
|
||||
return "Error analyzing goal";
|
||||
default:
|
||||
return "Analyzing your goal...";
|
||||
}
|
||||
}
|
||||
|
||||
export function ToolIcon({
|
||||
isStreaming,
|
||||
isError,
|
||||
}: {
|
||||
isStreaming?: boolean;
|
||||
isError?: boolean;
|
||||
}) {
|
||||
if (isError) {
|
||||
return (
|
||||
<WarningDiamondIcon size={14} weight="regular" className="text-red-500" />
|
||||
);
|
||||
}
|
||||
if (isStreaming) {
|
||||
return <ScaleLoader size={14} />;
|
||||
}
|
||||
return (
|
||||
<ListChecksIcon size={14} weight="regular" className="text-neutral-400" />
|
||||
);
|
||||
}
|
||||
|
||||
export function AccordionIcon() {
|
||||
return <ListChecksIcon size={32} weight="light" />;
|
||||
}
|
||||
|
||||
export function StepStatusIcon({ status }: { status: string }) {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return (
|
||||
<CheckCircleIcon
|
||||
size={18}
|
||||
weight="fill"
|
||||
className="text-emerald-500"
|
||||
aria-label="completed"
|
||||
/>
|
||||
);
|
||||
case "in_progress":
|
||||
return (
|
||||
<SpinnerGapIcon
|
||||
size={18}
|
||||
weight="bold"
|
||||
className="animate-spin text-blue-500"
|
||||
aria-label="in progress"
|
||||
/>
|
||||
);
|
||||
case "failed":
|
||||
return (
|
||||
<XCircleIcon
|
||||
size={18}
|
||||
weight="fill"
|
||||
className="text-red-500"
|
||||
aria-label="failed"
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<CircleDashedIcon
|
||||
size={18}
|
||||
weight="regular"
|
||||
className="text-neutral-400"
|
||||
aria-label="pending"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -736,24 +736,19 @@ describe("SubscriptionTierSection", () => {
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders BASIC cancellation copy in banner when pending_tier is BASIC", () => {
|
||||
it("renders cancellation copy in banner when pending_tier is NO_TIER", () => {
|
||||
setupMocks({
|
||||
subscription: makeSubscription({
|
||||
tier: "MAX",
|
||||
pendingTier: "BASIC",
|
||||
// Noon UTC so the local-formatted date lands on the same day
|
||||
// regardless of the runner's timezone (midnight UTC drifts to
|
||||
// the prior day in any timezone west of UTC).
|
||||
pendingTier: "NO_TIER",
|
||||
pendingTierEffectiveAt: new Date("2026-05-15T12:00:00Z"),
|
||||
}),
|
||||
});
|
||||
render(<SubscriptionTierSection />);
|
||||
// Cancellation copy — distinct from the generic downgrade phrasing.
|
||||
expect(
|
||||
screen.getByText(/scheduled to cancel your subscription on/i),
|
||||
).toBeDefined();
|
||||
expect(screen.getByText(/May 15, 2026/)).toBeDefined();
|
||||
// Must NOT render the "downgrade to" phrasing on BASIC cancellation.
|
||||
expect(screen.queryByText(/scheduled to downgrade to/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ export function PendingChangeBanner({
|
||||
const currentLabel = getTierLabel(currentTier);
|
||||
const dateText = formatPendingDate(pendingEffectiveAt);
|
||||
|
||||
const isCancellation = pendingTier === "BASIC";
|
||||
const isCancellation = pendingTier === "NO_TIER";
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -7,7 +7,7 @@ import { PendingChangeBanner } from "../PendingChangeBanner";
|
||||
describe("PendingChangeBanner", () => {
|
||||
const baseProps = {
|
||||
currentTier: "PRO",
|
||||
pendingTier: "BASIC",
|
||||
pendingTier: "NO_TIER",
|
||||
// Use noon UTC so the formatted local date lands on the same day
|
||||
// regardless of the host timezone (important for CI runners).
|
||||
pendingEffectiveAt: "2026-05-01T12:00:00Z",
|
||||
@@ -25,7 +25,7 @@ describe("PendingChangeBanner", () => {
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("shows cancellation copy when pending tier is BASIC", () => {
|
||||
it("shows cancellation copy when pending tier is NO_TIER", () => {
|
||||
render(<PendingChangeBanner {...baseProps} />);
|
||||
expect(screen.getByText(/cancel your subscription on/i)).toBeDefined();
|
||||
expect(screen.getByText("May 1, 2026")).toBeDefined();
|
||||
|
||||
@@ -2109,6 +2109,9 @@
|
||||
"$ref": "#/components/schemas/MCPToolsDiscoveredResponse"
|
||||
},
|
||||
{ "$ref": "#/components/schemas/MCPToolOutputResponse" },
|
||||
{
|
||||
"$ref": "#/components/schemas/TaskDecompositionResponse"
|
||||
},
|
||||
{ "$ref": "#/components/schemas/MemoryStoreResponse" },
|
||||
{ "$ref": "#/components/schemas/MemorySearchResponse" },
|
||||
{
|
||||
@@ -10916,6 +10919,40 @@
|
||||
],
|
||||
"title": "CreditTransactionType"
|
||||
},
|
||||
"DecompositionStepModel": {
|
||||
"properties": {
|
||||
"step_id": {
|
||||
"type": "string",
|
||||
"title": "Step Id",
|
||||
"description": "Unique step identifier, e.g. 'step_1'"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"title": "Description",
|
||||
"description": "Human-readable step description"
|
||||
},
|
||||
"action": {
|
||||
"type": "string",
|
||||
"title": "Action",
|
||||
"description": "Action type: 'add_block', 'connect_blocks', 'configure', etc."
|
||||
},
|
||||
"block_name": {
|
||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
||||
"title": "Block Name",
|
||||
"description": "Block being added, if applicable"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"title": "Status",
|
||||
"description": "Step status: pending, in_progress, completed, failed",
|
||||
"default": "pending"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": ["step_id", "description", "action"],
|
||||
"title": "DecompositionStepModel",
|
||||
"description": "A single step in a decomposed agent-building plan."
|
||||
},
|
||||
"DeleteFileResponse": {
|
||||
"properties": { "deleted": { "type": "boolean", "title": "Deleted" } },
|
||||
"type": "object",
|
||||
@@ -14865,6 +14902,7 @@
|
||||
"agent_builder_clarification_needed",
|
||||
"agent_builder_validation_result",
|
||||
"agent_builder_fix_result",
|
||||
"task_decomposition",
|
||||
"block_list",
|
||||
"block_details",
|
||||
"block_output",
|
||||
@@ -16420,6 +16458,39 @@
|
||||
"required": ["recent_searches", "providers", "top_blocks"],
|
||||
"title": "SuggestionsResponse"
|
||||
},
|
||||
"TaskDecompositionResponse": {
|
||||
"properties": {
|
||||
"type": {
|
||||
"$ref": "#/components/schemas/ResponseType",
|
||||
"default": "task_decomposition"
|
||||
},
|
||||
"message": { "type": "string", "title": "Message" },
|
||||
"session_id": {
|
||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
||||
"title": "Session Id"
|
||||
},
|
||||
"goal": {
|
||||
"type": "string",
|
||||
"title": "Goal",
|
||||
"description": "The original user goal"
|
||||
},
|
||||
"steps": {
|
||||
"items": { "$ref": "#/components/schemas/DecompositionStepModel" },
|
||||
"type": "array",
|
||||
"title": "Steps"
|
||||
},
|
||||
"step_count": {
|
||||
"type": "integer",
|
||||
"title": "Step Count",
|
||||
"description": "Number of steps (auto-derived from steps list)",
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": ["message", "goal", "steps"],
|
||||
"title": "TaskDecompositionResponse",
|
||||
"description": "Response for decompose_goal tool — shows the plan to the user."
|
||||
},
|
||||
"TimezoneResponse": {
|
||||
"properties": {
|
||||
"timezone": {
|
||||
|
||||
Reference in New Issue
Block a user