mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-30 03:00:41 -04:00
refactor(copilot): make decompose_goal a visibility-only tool
Strip the approval gate, server auto-approve timer, /cancel-auto-approve endpoint, and the frontend Approve/Modify/edit-mode UI. The plan card now renders read-only and the LLM continues building in the same turn. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -817,28 +817,6 @@ async def cancel_session_task(
|
||||
return CancelSessionResponse(cancelled=True)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sessions/{session_id}/cancel-auto-approve",
|
||||
status_code=200,
|
||||
)
|
||||
async def cancel_auto_approve_task(
|
||||
session_id: str,
|
||||
user_id: Annotated[str, Security(auth.get_user_id)],
|
||||
) -> CancelSessionResponse:
|
||||
"""Cancel the pending auto-approve timer for a decompose_goal plan.
|
||||
|
||||
Called by the frontend when the user clicks "Modify" on the build-plan
|
||||
box. Without this, the server-side timer would fire the default
|
||||
"Approved" message while the user is still editing.
|
||||
"""
|
||||
await _validate_and_get_session(session_id, user_id)
|
||||
|
||||
from backend.copilot.tools.decompose_goal import cancel_auto_approve
|
||||
|
||||
await cancel_auto_approve(session_id)
|
||||
return CancelSessionResponse(cancelled=True)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sessions/{session_id}/stream",
|
||||
responses={
|
||||
|
||||
@@ -26,24 +26,19 @@ Steps:
|
||||
**Skip this** when the goal already specifies all dimensions (e.g.
|
||||
"scrape prices from Amazon and email me daily").
|
||||
|
||||
### Before Building: Goal Decomposition (REQUIRED)
|
||||
### Before Building: Show the Plan
|
||||
|
||||
Before running the workflow below, ALWAYS decompose the goal first:
|
||||
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 a rich UI card for the
|
||||
plan automatically. Any text you write will duplicate the plan display.
|
||||
3. **STOP your turn immediately after `decompose_goal` returns.** Do not
|
||||
call any other tools. Do not generate any text. End the turn so the
|
||||
user can review the plan and respond.
|
||||
4. Only after the user responds, continue with "Workflow for Creating/
|
||||
Editing Agents".
|
||||
|
||||
`decompose_goal` MUST be the only tool call in the turn, with no
|
||||
accompanying text. Never combine it with `find_block`, `create_agent`,
|
||||
or any other tool in the same turn.
|
||||
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.
|
||||
|
||||
@@ -8,7 +8,6 @@ from backend.copilot.model import ChatSession
|
||||
|
||||
from .agent_generator.pipeline import fetch_library_agents, fix_validate_and_save
|
||||
from .base import BaseTool
|
||||
from .decompose_goal import needs_build_plan_approval
|
||||
from .helpers import require_guide_read
|
||||
from .models import ErrorResponse, ToolResponseBase
|
||||
|
||||
@@ -77,25 +76,6 @@ class CreateAgentTool(BaseTool):
|
||||
if guide_gate is not None:
|
||||
return guide_gate
|
||||
|
||||
# Enforce the decompose_goal approval gate at the code level.
|
||||
# Prompt-only "STOP" is unreliable: the LLM has been observed
|
||||
# (a) calling decompose_goal + create_agent in the same turn and
|
||||
# (b) skipping decompose_goal entirely on follow-up build requests.
|
||||
# Require that the most recent user message is an approval AND a
|
||||
# decompose_goal call exists before it in the session.
|
||||
if session and needs_build_plan_approval(session):
|
||||
return ErrorResponse(
|
||||
message=(
|
||||
"You must call decompose_goal first and wait for user "
|
||||
"approval before calling create_agent. Call decompose_goal "
|
||||
"now with the build steps, then end your turn — the "
|
||||
"platform will resume the conversation after the user "
|
||||
"responds with Approved (or Approved with modifications)."
|
||||
),
|
||||
error="build_plan_approval_required",
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
if not agent_json:
|
||||
return ErrorResponse(
|
||||
message=(
|
||||
|
||||
@@ -4,7 +4,6 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.copilot.model import ChatMessage
|
||||
from backend.copilot.tools.create_agent import CreateAgentTool
|
||||
from backend.copilot.tools.models import AgentPreviewResponse, ErrorResponse
|
||||
|
||||
@@ -14,28 +13,6 @@ _TEST_USER_ID = "test-user-create-agent"
|
||||
_PIPELINE = "backend.copilot.tools.agent_generator.pipeline"
|
||||
|
||||
|
||||
def _add_approval_history(session):
|
||||
"""Add decompose_goal + user approval to the session so the
|
||||
needs_build_plan_approval gate passes."""
|
||||
session.messages.append(
|
||||
ChatMessage(
|
||||
role="assistant",
|
||||
content="",
|
||||
tool_calls=[
|
||||
{
|
||||
"id": "call_decompose",
|
||||
"type": "function",
|
||||
"function": {"name": "decompose_goal", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
)
|
||||
)
|
||||
session.messages.append(ChatMessage(role="tool", content="{plan}"))
|
||||
session.messages.append(
|
||||
ChatMessage(role="user", content="Approved. Please build the agent.")
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tool():
|
||||
return CreateAgentTool()
|
||||
@@ -43,9 +20,7 @@ def tool():
|
||||
|
||||
@pytest.fixture
|
||||
def session():
|
||||
s = make_session(_TEST_USER_ID)
|
||||
_add_approval_history(s)
|
||||
return s
|
||||
return make_session(_TEST_USER_ID)
|
||||
|
||||
|
||||
# ── Input validation tests ──────────────────────────────────────────────
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
"""DecomposeGoalTool - Breaks agent-building goals into sub-instructions."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from backend.copilot.model import ChatSession
|
||||
from backend.data.redis_client import get_redis_async
|
||||
|
||||
from .base import BaseTool
|
||||
from .models import (
|
||||
@@ -20,185 +18,6 @@ logger = logging.getLogger(__name__)
|
||||
DEFAULT_ACTION = "add_block"
|
||||
VALID_ACTIONS = {"add_block", "connect_blocks", "configure", "add_input", "add_output"}
|
||||
|
||||
# Auto-approve countdown — the frontend reads ``auto_approve_seconds`` from the
|
||||
# tool response and runs the visible countdown (60s). The server fires 5s later
|
||||
# as a fallback for the "user closed the tab" case. The 5s gap ensures the
|
||||
# client always fires first when present, creating the SSE subscription that
|
||||
# lets the user see the build in real-time. When the server wakes at 65s, it
|
||||
# checks the predicate and skips (the client's message is already there).
|
||||
AUTO_APPROVE_CLIENT_SECONDS = 60
|
||||
AUTO_APPROVE_SERVER_GRACE_SECONDS = 5
|
||||
AUTO_APPROVE_SERVER_SECONDS = (
|
||||
AUTO_APPROVE_CLIENT_SECONDS + AUTO_APPROVE_SERVER_GRACE_SECONDS
|
||||
)
|
||||
AUTO_APPROVE_MESSAGE = "Approved. Please build the agent."
|
||||
|
||||
# Redis key prefix for cross-process cancel signalling. The cancel
|
||||
# endpoint (AgentServer process) SETs the key; _run_auto_approve
|
||||
# (CoPilotExecutor process) checks it before firing.
|
||||
_CANCEL_KEY_PREFIX = "copilot:cancel_auto_approve:"
|
||||
_CANCEL_KEY_TTL_SECONDS = AUTO_APPROVE_SERVER_SECONDS + 30
|
||||
|
||||
# In-process dict for best-effort cancel when both the cancel call and
|
||||
# the asyncio task happen to live in the same process (single-worker).
|
||||
_pending_auto_approvals: dict[str, asyncio.Task] = {}
|
||||
|
||||
|
||||
def needs_build_plan_approval(session: ChatSession) -> bool:
|
||||
"""Return True if the current build must be blocked pending user response.
|
||||
|
||||
Enforces the "STOP — do not proceed until the user responds" gate from
|
||||
``agent_generation_guide.md`` at the *code* level. Natural-language
|
||||
instruction alone is not enough — the LLM has been observed calling
|
||||
``decompose_goal`` and ``create_agent`` in the same turn.
|
||||
|
||||
Rule: a ``decompose_goal`` tool call must exist in the session AND at
|
||||
least one user message must appear after it. The gate does NOT check
|
||||
*what* the user said — the LLM interprets the intent (build, modify,
|
||||
or reject). The gate only blocks same-turn builds where the user hasn't
|
||||
responded at all.
|
||||
|
||||
- No decompose_goal in session → block (must decompose first).
|
||||
- decompose_goal called but no user response yet → block.
|
||||
- Any user message after decompose_goal → allow (LLM decides).
|
||||
"""
|
||||
# Walk backward to find the latest decompose_goal tool call.
|
||||
decompose_idx = -1
|
||||
for i in range(len(session.messages) - 1, -1, -1):
|
||||
msg = session.messages[i]
|
||||
if msg.role == "assistant" and msg.tool_calls:
|
||||
for tc in msg.tool_calls:
|
||||
name = (tc.get("function") or {}).get("name") or tc.get("name")
|
||||
if name == "decompose_goal":
|
||||
decompose_idx = i
|
||||
break
|
||||
if decompose_idx >= 0:
|
||||
break
|
||||
|
||||
if decompose_idx < 0:
|
||||
return True
|
||||
|
||||
# Any user message after the decompose_goal call unblocks the gate.
|
||||
for msg in session.messages[decompose_idx + 1 :]:
|
||||
if msg.role == "user":
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def _run_auto_approve(session_id: str, user_id: str | None) -> None:
|
||||
"""Wait the server-side timeout and dispatch the approval via
|
||||
``run_copilot_turn_via_queue`` — the canonical helper that queues the
|
||||
message if a turn is already in flight, or starts a new turn if idle.
|
||||
|
||||
Cancelled when the user clicks "Modify" (via ``cancel_auto_approve``).
|
||||
"""
|
||||
try:
|
||||
await asyncio.sleep(AUTO_APPROVE_SERVER_SECONDS)
|
||||
|
||||
# Check the cross-process cancel flag set by cancel_auto_approve().
|
||||
redis = await get_redis_async()
|
||||
if await redis.get(f"{_CANCEL_KEY_PREFIX}{session_id}"):
|
||||
logger.info(
|
||||
"decompose_goal auto-approve skipped (cancelled) for session %s",
|
||||
session_id,
|
||||
)
|
||||
return
|
||||
|
||||
# Skip if a turn is already in flight — the client already sent
|
||||
# "Approved" and started the build. Only fire when the session is
|
||||
# idle (client closed the tab).
|
||||
from backend.copilot.pending_message_helpers import is_turn_in_flight
|
||||
|
||||
if await is_turn_in_flight(session_id):
|
||||
logger.info(
|
||||
"decompose_goal auto-approve skipped (turn in flight) for session %s",
|
||||
session_id,
|
||||
)
|
||||
return
|
||||
|
||||
from backend.copilot.sdk.session_waiter import run_copilot_turn_via_queue
|
||||
|
||||
outcome, result = await run_copilot_turn_via_queue(
|
||||
session_id=session_id,
|
||||
user_id=user_id or "",
|
||||
message=AUTO_APPROVE_MESSAGE,
|
||||
timeout=0,
|
||||
tool_call_id="auto_approve",
|
||||
tool_name="decompose_goal_auto_approve",
|
||||
)
|
||||
logger.info(
|
||||
"decompose_goal auto-approve fired for session %s (outcome=%s)",
|
||||
session_id,
|
||||
outcome,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"decompose_goal auto-approve task failed for session %s",
|
||||
session_id,
|
||||
)
|
||||
|
||||
|
||||
async def cancel_auto_approve(session_id: str) -> bool:
|
||||
"""Cancel the pending auto-approve task for a session.
|
||||
|
||||
Called by the ``/sessions/{session_id}/cancel-auto-approve`` endpoint
|
||||
when the user clicks "Modify" in the build-plan UI.
|
||||
|
||||
Uses **two** cancellation channels:
|
||||
1. **Redis flag** (cross-process) — the executor checks this before
|
||||
firing. Works even when the cancel endpoint runs in the AgentServer
|
||||
process and the asyncio task lives in the CoPilotExecutor process.
|
||||
2. **In-process task cancel** (best-effort) — if both happen to share
|
||||
the same process, cancels the asyncio task directly.
|
||||
"""
|
||||
redis = await get_redis_async()
|
||||
await redis.set(
|
||||
f"{_CANCEL_KEY_PREFIX}{session_id}",
|
||||
"1",
|
||||
ex=_CANCEL_KEY_TTL_SECONDS,
|
||||
)
|
||||
logger.info(
|
||||
"decompose_goal auto-approve cancel flag set for session %s", session_id
|
||||
)
|
||||
|
||||
# Best-effort in-process cancel (no-op if the task is in another process).
|
||||
task = _pending_auto_approvals.pop(session_id, None)
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def _schedule_auto_approve(
|
||||
session_id: str | None, user_id: str | None, session: ChatSession
|
||||
) -> None:
|
||||
"""Schedule the fire-and-forget auto-approve task for this session."""
|
||||
if not session_id:
|
||||
return
|
||||
# Cancel any existing pending approval for this session (e.g. if the
|
||||
# LLM called decompose_goal twice in one turn).
|
||||
old_task = _pending_auto_approvals.pop(session_id, None)
|
||||
if old_task is not None and not old_task.done():
|
||||
old_task.cancel()
|
||||
# Clear any stale Redis cancel flag from a previous Modify click so
|
||||
# the new auto-approve task isn't incorrectly suppressed.
|
||||
redis = await get_redis_async()
|
||||
await redis.delete(f"{_CANCEL_KEY_PREFIX}{session_id}")
|
||||
task = asyncio.create_task(_run_auto_approve(session_id, user_id))
|
||||
_pending_auto_approvals[session_id] = task
|
||||
# Only remove from dict if this task is still the current one — a
|
||||
# cancelled old task's callback must not clobber a newly-scheduled one.
|
||||
task.add_done_callback(
|
||||
lambda t: (
|
||||
_pending_auto_approvals.pop(session_id, None)
|
||||
if _pending_auto_approvals.get(session_id) is t
|
||||
else None
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class DecomposeGoalTool(BaseTool):
|
||||
"""Tool for decomposing an agent goal into sub-instructions."""
|
||||
@@ -210,10 +29,11 @@ class DecomposeGoalTool(BaseTool):
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return (
|
||||
"Break down an agent-building goal into logical sub-instructions. "
|
||||
"Each step maps to one task (e.g. add a block, wire connections, "
|
||||
"configure settings). ALWAYS call this before create_agent to show "
|
||||
"the user your plan and get approval."
|
||||
"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
|
||||
@@ -307,14 +127,10 @@ class DecomposeGoalTool(BaseTool):
|
||||
)
|
||||
)
|
||||
|
||||
await _schedule_auto_approve(session_id, user_id, session)
|
||||
|
||||
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),
|
||||
requires_approval=True,
|
||||
auto_approve_seconds=AUTO_APPROVE_CLIENT_SECONDS,
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
@@ -1,27 +1,11 @@
|
||||
"""Unit tests for DecomposeGoalTool."""
|
||||
|
||||
import asyncio
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.copilot.model import ChatMessage
|
||||
|
||||
from . import decompose_goal as decompose_goal_module
|
||||
from ._test_data import make_session
|
||||
from .decompose_goal import (
|
||||
AUTO_APPROVE_CLIENT_SECONDS,
|
||||
DEFAULT_ACTION,
|
||||
DecomposeGoalTool,
|
||||
cancel_auto_approve,
|
||||
needs_build_plan_approval,
|
||||
)
|
||||
from .decompose_goal import DEFAULT_ACTION, DecomposeGoalTool
|
||||
from .models import ErrorResponse, TaskDecompositionResponse
|
||||
|
||||
# Captured before the autouse fixture stubs the real scheduler.
|
||||
_REAL_SCHEDULE_AUTO_APPROVE = decompose_goal_module._schedule_auto_approve
|
||||
|
||||
_USER_ID = "test-user-decompose-goal"
|
||||
|
||||
_VALID_STEPS = [
|
||||
@@ -35,20 +19,6 @@ _VALID_STEPS = [
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stub_auto_approve_scheduler():
|
||||
"""The existing happy-path tests don't have a database; stub the
|
||||
fire-and-forget scheduler so they don't kick off real timers that try to
|
||||
hit Redis/Postgres. Tests that exercise auto-approve override this with
|
||||
their own patches inside the test body."""
|
||||
|
||||
async def _noop(*a, **kw):
|
||||
pass
|
||||
|
||||
with patch.object(decompose_goal_module, "_schedule_auto_approve", _noop):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def tool() -> DecomposeGoalTool:
|
||||
return DecomposeGoalTool()
|
||||
@@ -77,7 +47,6 @@ async def test_happy_path(tool: DecomposeGoalTool, session):
|
||||
assert result.goal == "Build a news summarizer agent"
|
||||
assert len(result.steps) == 3
|
||||
assert result.step_count == 3
|
||||
assert result.requires_approval is True
|
||||
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"
|
||||
@@ -96,20 +65,6 @@ async def test_step_count_matches_steps(tool: DecomposeGoalTool, session):
|
||||
assert result.step_count == len(result.steps)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_requires_approval_always_true(tool: DecomposeGoalTool, session):
|
||||
"""requires_approval must always be True regardless of kwargs."""
|
||||
result = await tool._execute(
|
||||
user_id=_USER_ID,
|
||||
session=session,
|
||||
goal="Build agent",
|
||||
steps=_VALID_STEPS,
|
||||
require_approval=False, # should be ignored
|
||||
)
|
||||
assert isinstance(result, TaskDecompositionResponse)
|
||||
assert result.requires_approval is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_action_defaults_to_add_block(tool: DecomposeGoalTool, session):
|
||||
"""Unknown action values are coerced to DEFAULT_ACTION."""
|
||||
@@ -251,293 +206,3 @@ async def test_step_ids_are_sequential(tool: DecomposeGoalTool, session):
|
||||
assert isinstance(result, TaskDecompositionResponse)
|
||||
for i, step in enumerate(result.steps):
|
||||
assert step.step_id == f"step_{i + 1}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# auto_approve_seconds field
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_response_includes_auto_approve_seconds(tool: DecomposeGoalTool, session):
|
||||
"""The response carries the countdown so the frontend has a single
|
||||
source of truth instead of a hard-coded constant."""
|
||||
result = await tool._execute(
|
||||
user_id=_USER_ID,
|
||||
session=session,
|
||||
goal="Build agent",
|
||||
steps=_VALID_STEPS,
|
||||
)
|
||||
assert isinstance(result, TaskDecompositionResponse)
|
||||
assert result.auto_approve_seconds == AUTO_APPROVE_CLIENT_SECONDS
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_response_includes_created_at(tool: DecomposeGoalTool, session):
|
||||
"""created_at must be stamped at execution time so the client can
|
||||
compute remaining countdown when the user reopens the session."""
|
||||
before = datetime.now(UTC)
|
||||
result = await tool._execute(
|
||||
user_id=_USER_ID,
|
||||
session=session,
|
||||
goal="Build agent",
|
||||
steps=_VALID_STEPS,
|
||||
)
|
||||
after = datetime.now(UTC)
|
||||
|
||||
assert isinstance(result, TaskDecompositionResponse)
|
||||
assert isinstance(result.created_at, datetime)
|
||||
# Stamped during the call.
|
||||
assert before <= result.created_at <= after
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# needs_build_plan_approval — build-tool approval gate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _decompose_tool_call() -> dict:
|
||||
return {
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "decompose_goal", "arguments": "{}"},
|
||||
}
|
||||
|
||||
|
||||
def test_needs_approval_blocks_when_no_decompose_in_session():
|
||||
"""LLM tries to build without calling decompose_goal at all."""
|
||||
session = make_session(_USER_ID)
|
||||
session.messages.append(ChatMessage(role="user", content="Build me an agent"))
|
||||
assert needs_build_plan_approval(session) is True
|
||||
|
||||
|
||||
def test_needs_approval_allows_any_user_response():
|
||||
"""Any user message after decompose_goal unblocks the gate."""
|
||||
session = make_session(_USER_ID)
|
||||
session.messages.append(ChatMessage(role="user", content="Build me an agent"))
|
||||
session.messages.append(
|
||||
ChatMessage(role="assistant", content="", tool_calls=[_decompose_tool_call()])
|
||||
)
|
||||
session.messages.append(ChatMessage(role="tool", content="{plan}"))
|
||||
session.messages.append(ChatMessage(role="user", content="Sure"))
|
||||
assert needs_build_plan_approval(session) is False
|
||||
|
||||
|
||||
def test_needs_approval_allows_explicit_approval():
|
||||
"""Explicit 'Approved' also passes (common from button/auto-approve)."""
|
||||
session = make_session(_USER_ID)
|
||||
session.messages.append(ChatMessage(role="user", content="Build me an agent"))
|
||||
session.messages.append(
|
||||
ChatMessage(role="assistant", content="", tool_calls=[_decompose_tool_call()])
|
||||
)
|
||||
session.messages.append(ChatMessage(role="tool", content="{plan}"))
|
||||
session.messages.append(
|
||||
ChatMessage(role="user", content="Approved. Please build the agent.")
|
||||
)
|
||||
assert needs_build_plan_approval(session) is False
|
||||
|
||||
|
||||
def test_needs_approval_allows_modification_request():
|
||||
"""User asking to modify the plan also passes — LLM decides what to do."""
|
||||
session = make_session(_USER_ID)
|
||||
session.messages.append(ChatMessage(role="user", content="Build me an agent"))
|
||||
session.messages.append(
|
||||
ChatMessage(role="assistant", content="", tool_calls=[_decompose_tool_call()])
|
||||
)
|
||||
session.messages.append(ChatMessage(role="tool", content="{plan}"))
|
||||
session.messages.append(
|
||||
ChatMessage(role="user", content="Change step 3 to use Gmail instead")
|
||||
)
|
||||
assert needs_build_plan_approval(session) is False
|
||||
|
||||
|
||||
def test_needs_approval_blocks_same_turn_decompose_and_build():
|
||||
"""LLM calls decompose_goal then immediately tries create_agent in the
|
||||
same turn — no user message after decompose_goal yet."""
|
||||
session = make_session(_USER_ID)
|
||||
session.messages.append(ChatMessage(role="user", content="Build me an agent"))
|
||||
session.messages.append(
|
||||
ChatMessage(role="assistant", content="", tool_calls=[_decompose_tool_call()])
|
||||
)
|
||||
session.messages.append(ChatMessage(role="tool", content="{plan}"))
|
||||
assert needs_build_plan_approval(session) is True
|
||||
|
||||
|
||||
def test_needs_approval_blocks_without_prior_decompose():
|
||||
"""No decompose_goal in session → must decompose first."""
|
||||
session = make_session(_USER_ID)
|
||||
session.messages.append(ChatMessage(role="user", content="Build me an agent"))
|
||||
assert needs_build_plan_approval(session) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Server-side auto-approve task — uses run_copilot_turn_via_queue
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeRedisNoCancelFlag:
|
||||
"""Stub Redis that reports no cancel flag and ignores writes."""
|
||||
|
||||
async def get(self, key):
|
||||
return None
|
||||
|
||||
async def set(self, key, value, ex=None):
|
||||
pass
|
||||
|
||||
async def delete(self, key):
|
||||
pass
|
||||
|
||||
|
||||
def _stub_redis():
|
||||
"""Patch get_redis_async to return a fake Redis (no real connection)."""
|
||||
return patch(
|
||||
"backend.copilot.tools.decompose_goal.get_redis_async",
|
||||
new=AsyncMock(return_value=_FakeRedisNoCancelFlag()),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_approve_dispatches_via_queue_helper():
|
||||
"""_run_auto_approve should delegate to run_copilot_turn_via_queue."""
|
||||
fake_dispatch = AsyncMock(return_value=("completed", None))
|
||||
|
||||
with (
|
||||
_stub_redis(),
|
||||
patch(
|
||||
"backend.copilot.sdk.session_waiter.run_copilot_turn_via_queue",
|
||||
new=fake_dispatch,
|
||||
),
|
||||
patch(
|
||||
"backend.copilot.tools.decompose_goal.AUTO_APPROVE_SERVER_SECONDS",
|
||||
0,
|
||||
),
|
||||
):
|
||||
await decompose_goal_module._run_auto_approve("session-idle", _USER_ID)
|
||||
|
||||
fake_dispatch.assert_awaited_once()
|
||||
call_kwargs = fake_dispatch.await_args.kwargs
|
||||
assert call_kwargs["session_id"] == "session-idle"
|
||||
assert call_kwargs["message"] == "Approved. Please build the agent."
|
||||
assert call_kwargs["timeout"] == 0
|
||||
assert call_kwargs["tool_name"] == "decompose_goal_auto_approve"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_approve_swallows_unexpected_errors():
|
||||
"""A failure inside the task must never propagate."""
|
||||
|
||||
async def boom(*args, **kwargs):
|
||||
raise RuntimeError("kaboom")
|
||||
|
||||
with (
|
||||
_stub_redis(),
|
||||
patch(
|
||||
"backend.copilot.sdk.session_waiter.run_copilot_turn_via_queue",
|
||||
new=boom,
|
||||
),
|
||||
patch(
|
||||
"backend.copilot.tools.decompose_goal.AUTO_APPROVE_SERVER_SECONDS",
|
||||
0,
|
||||
),
|
||||
):
|
||||
await decompose_goal_module._run_auto_approve("session-error", None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_schedule_auto_approve_creates_task(monkeypatch):
|
||||
"""_schedule_auto_approve should add a task to the tracking dict."""
|
||||
monkeypatch.setattr(decompose_goal_module, "AUTO_APPROVE_SERVER_SECONDS", 0)
|
||||
fake_run = AsyncMock()
|
||||
monkeypatch.setattr(decompose_goal_module, "_run_auto_approve", fake_run)
|
||||
monkeypatch.setattr(
|
||||
decompose_goal_module,
|
||||
"get_redis_async",
|
||||
AsyncMock(return_value=_FakeRedisNoCancelFlag()),
|
||||
)
|
||||
|
||||
session = make_session(_USER_ID)
|
||||
|
||||
await _REAL_SCHEDULE_AUTO_APPROVE(
|
||||
session_id="session-schedule",
|
||||
user_id=_USER_ID,
|
||||
session=session,
|
||||
)
|
||||
|
||||
await asyncio.sleep(0)
|
||||
while decompose_goal_module._pending_auto_approvals:
|
||||
await asyncio.sleep(0)
|
||||
|
||||
fake_run.assert_awaited_once_with("session-schedule", _USER_ID)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_schedule_auto_approve_no_op_without_session_id():
|
||||
"""Empty session id should be a no-op (defensive)."""
|
||||
session = make_session(_USER_ID)
|
||||
await decompose_goal_module._schedule_auto_approve(
|
||||
session_id=None, user_id=_USER_ID, session=session
|
||||
)
|
||||
assert len(decompose_goal_module._pending_auto_approvals) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# cancel_auto_approve
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_auto_approve_sets_redis_flag_and_cancels_task(monkeypatch):
|
||||
"""cancel_auto_approve should set a Redis cancel flag AND cancel the
|
||||
in-process task. Returns True always (Redis flag is authoritative)."""
|
||||
monkeypatch.setattr(decompose_goal_module, "AUTO_APPROVE_SERVER_SECONDS", 999)
|
||||
fake_run = AsyncMock()
|
||||
monkeypatch.setattr(decompose_goal_module, "_run_auto_approve", fake_run)
|
||||
|
||||
captured_redis_calls: list[tuple] = []
|
||||
|
||||
class FakeRedis:
|
||||
async def set(self, key, value, ex=None):
|
||||
captured_redis_calls.append(("set", key, value, ex))
|
||||
|
||||
async def get(self, key):
|
||||
return None
|
||||
|
||||
async def delete(self, key):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(
|
||||
decompose_goal_module, "get_redis_async", AsyncMock(return_value=FakeRedis())
|
||||
)
|
||||
|
||||
session = make_session(_USER_ID)
|
||||
await _REAL_SCHEDULE_AUTO_APPROVE(
|
||||
session_id="session-cancel-test",
|
||||
user_id=_USER_ID,
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert "session-cancel-test" in decompose_goal_module._pending_auto_approvals
|
||||
result = await cancel_auto_approve("session-cancel-test")
|
||||
assert result is True
|
||||
assert "session-cancel-test" not in decompose_goal_module._pending_auto_approvals
|
||||
assert len(captured_redis_calls) == 1
|
||||
assert captured_redis_calls[0][0] == "set"
|
||||
assert "session-cancel-test" in captured_redis_calls[0][1]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_auto_approve_returns_true_even_without_in_process_task(
|
||||
monkeypatch,
|
||||
):
|
||||
"""Even if no in-process task exists (e.g. task is in another process),
|
||||
cancel_auto_approve should still set the Redis flag and return True."""
|
||||
|
||||
class FakeRedis:
|
||||
async def set(self, key, value, ex=None):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(
|
||||
decompose_goal_module, "get_redis_async", AsyncMock(return_value=FakeRedis())
|
||||
)
|
||||
result = await cancel_auto_approve("nonexistent-session")
|
||||
assert result is True
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Pydantic models for tool responses."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Any, Literal
|
||||
|
||||
@@ -845,24 +845,6 @@ class TaskDecompositionResponse(ToolResponseBase):
|
||||
step_count: int = Field(
|
||||
default=0, description="Number of steps (auto-derived from steps list)"
|
||||
)
|
||||
requires_approval: bool = True
|
||||
auto_approve_seconds: int = Field(
|
||||
default=60,
|
||||
description=(
|
||||
"Seconds the client should count down before auto-approving. "
|
||||
"Kept in sync with the server-side fallback timer, which runs a "
|
||||
"grace period longer to absorb network latency."
|
||||
),
|
||||
)
|
||||
created_at: datetime = Field(
|
||||
default_factory=lambda: datetime.now(UTC),
|
||||
description=(
|
||||
"UTC timestamp when the tool returned. The client uses this with "
|
||||
"auto_approve_seconds to compute the correct remaining countdown "
|
||||
"when the user reopens the session — so the timer reflects real "
|
||||
"elapsed time instead of restarting from zero."
|
||||
),
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def sync_step_count(self) -> "TaskDecompositionResponse":
|
||||
|
||||
@@ -55,8 +55,6 @@ function renderSegments(
|
||||
segments: RenderSegment[],
|
||||
messageID: string,
|
||||
onRetry?: () => void,
|
||||
isLastMessage?: boolean,
|
||||
isMessageStreaming?: boolean,
|
||||
): React.ReactNode[] {
|
||||
return segments.map((seg, segIdx) => {
|
||||
if (seg.kind === "collapsed-group") {
|
||||
@@ -69,8 +67,6 @@ function renderSegments(
|
||||
messageID={messageID}
|
||||
partIndex={seg.index}
|
||||
onRetry={onRetry}
|
||||
isLastMessage={isLastMessage}
|
||||
isMessageStreaming={isMessageStreaming}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -448,8 +444,6 @@ export function ChatMessagesContainer({
|
||||
responseSegments,
|
||||
message.id,
|
||||
isLastAssistant ? onRetry : undefined,
|
||||
isLastAssistant,
|
||||
isCurrentlyStreaming,
|
||||
)
|
||||
: message.parts.map((part, i) => (
|
||||
<MessagePartRenderer
|
||||
@@ -458,8 +452,6 @@ export function ChatMessagesContainer({
|
||||
messageID={message.id}
|
||||
partIndex={i}
|
||||
onRetry={isLastAssistant ? onRetry : undefined}
|
||||
isLastMessage={isLastAssistant}
|
||||
isMessageStreaming={isCurrentlyStreaming}
|
||||
/>
|
||||
))}
|
||||
{isLastInTurn && !isCurrentlyStreaming && (
|
||||
|
||||
@@ -56,7 +56,6 @@ describe("isInteractiveToolPart", () => {
|
||||
goal: "Build agent",
|
||||
steps: [],
|
||||
step_count: 0,
|
||||
requires_approval: true,
|
||||
});
|
||||
expect(isInteractiveToolPart(part)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -95,8 +95,6 @@ interface Props {
|
||||
messageID: string;
|
||||
partIndex: number;
|
||||
onRetry?: () => void;
|
||||
isLastMessage?: boolean;
|
||||
isMessageStreaming?: boolean;
|
||||
}
|
||||
|
||||
export function MessagePartRenderer({
|
||||
@@ -104,8 +102,6 @@ export function MessagePartRenderer({
|
||||
messageID,
|
||||
partIndex,
|
||||
onRetry,
|
||||
isLastMessage,
|
||||
isMessageStreaming,
|
||||
}: Props) {
|
||||
const key = `${messageID}-${partIndex}`;
|
||||
|
||||
@@ -186,14 +182,7 @@ export function MessagePartRenderer({
|
||||
case "tool-schedule_agent":
|
||||
return <RunAgentTool key={key} part={part as ToolUIPart} />;
|
||||
case "tool-decompose_goal":
|
||||
return (
|
||||
<DecomposeGoalTool
|
||||
key={key}
|
||||
part={part as ToolUIPart}
|
||||
isLastMessage={isLastMessage}
|
||||
isMessageStreaming={isMessageStreaming}
|
||||
/>
|
||||
);
|
||||
return <DecomposeGoalTool key={key} part={part as ToolUIPart} />;
|
||||
case "tool-create_agent":
|
||||
return <CreateAgentTool key={key} part={part as ToolUIPart} />;
|
||||
case "tool-edit_agent":
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { postV2CancelAutoApproveTask } from "@/app/api/__generated__/endpoints/chat/chat";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import {
|
||||
CheckIcon,
|
||||
PencilSimpleIcon,
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { ToolUIPart } from "ai";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCopilotChatActions } from "../../components/CopilotChatActionsProvider/useCopilotChatActions";
|
||||
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
|
||||
import {
|
||||
@@ -21,40 +12,18 @@ import { ToolErrorCard } from "../../components/ToolErrorCard/ToolErrorCard";
|
||||
import { StepItem } from "./components/StepItem";
|
||||
import {
|
||||
AccordionIcon,
|
||||
computeRemainingSeconds,
|
||||
getAnimationText,
|
||||
getDecomposeGoalOutput,
|
||||
isDecompositionOutput,
|
||||
isErrorOutput,
|
||||
FALLBACK_COUNTDOWN_SECONDS,
|
||||
ToolIcon,
|
||||
} from "./helpers";
|
||||
|
||||
const RADIUS = 15;
|
||||
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
|
||||
|
||||
interface EditableStep {
|
||||
step_id: string;
|
||||
description: string;
|
||||
action: string;
|
||||
block_name?: string | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
part: ToolUIPart;
|
||||
isLastMessage?: boolean;
|
||||
// True while the parent assistant message is still streaming. We disable
|
||||
// Approve/Modify in this window because the chat session is locked to
|
||||
// the in-flight turn — sending a new user message would fail.
|
||||
isMessageStreaming?: boolean;
|
||||
}
|
||||
|
||||
export function DecomposeGoalTool({
|
||||
part,
|
||||
isLastMessage,
|
||||
isMessageStreaming,
|
||||
}: Props) {
|
||||
export function DecomposeGoalTool({ part }: Props) {
|
||||
const text = getAnimationText(part);
|
||||
const { onSend } = useCopilotChatActions();
|
||||
|
||||
@@ -66,165 +35,6 @@ export function DecomposeGoalTool({
|
||||
part.state === "output-error" || (!!output && isErrorOutput(output));
|
||||
const isPending = !output && !isError;
|
||||
|
||||
const showActions =
|
||||
!!isLastMessage &&
|
||||
!!output &&
|
||||
isDecompositionOutput(output) &&
|
||||
output.requires_approval;
|
||||
|
||||
// The Approve/Modify buttons are visible (so the user knows what's
|
||||
// coming) but click-disabled while the assistant is still streaming
|
||||
// its summary text after the tool call. The countdown ring keeps
|
||||
// ticking so it stays in sync with the server-side timer.
|
||||
const actionsEnabled = showActions && !isMessageStreaming;
|
||||
|
||||
// Authoritative countdown comes from the backend tool response so the
|
||||
// server-side fallback timer and the client are guaranteed to agree.
|
||||
const countdownSeconds =
|
||||
(output && isDecompositionOutput(output) && output.auto_approve_seconds) ||
|
||||
FALLBACK_COUNTDOWN_SECONDS;
|
||||
|
||||
// Lazy initializer: runs once on mount and seeds remaining time from the
|
||||
// backend ``created_at`` so reopening a session resumes the countdown
|
||||
// instead of restarting it.
|
||||
const [secondsLeft, setSecondsLeft] = useState(() =>
|
||||
computeRemainingSeconds(output, FALLBACK_COUNTDOWN_SECONDS),
|
||||
);
|
||||
// timerActive becomes false when the user clicks Modify — stops countdown and auto-approve.
|
||||
const [timerActive, setTimerActive] = useState(true);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editableSteps, setEditableSteps] = useState<EditableStep[]>([]);
|
||||
|
||||
// True when secondsLeft started at 0 because the server-stamped deadline
|
||||
// had already elapsed before this mount. Prevents the auto-approve effect
|
||||
// from firing on remount with stale cache — the server handles that case.
|
||||
// Without this, re-entering after the deadline sends a duplicate "Approved".
|
||||
const wasInitiallyPastDeadlineRef = useRef(
|
||||
secondsLeft === 0 &&
|
||||
!!output &&
|
||||
isDecompositionOutput(output) &&
|
||||
!!output.created_at,
|
||||
);
|
||||
|
||||
const approvedRef = useRef(false);
|
||||
const onSendRef = useRef(onSend);
|
||||
const isEditingRef = useRef(isEditing);
|
||||
const editableStepsRef = useRef(editableSteps);
|
||||
onSendRef.current = onSend;
|
||||
isEditingRef.current = isEditing;
|
||||
editableStepsRef.current = editableSteps;
|
||||
|
||||
function buildMessage() {
|
||||
if (isEditingRef.current && editableStepsRef.current.length > 0) {
|
||||
const filledSteps = editableStepsRef.current.filter((s) =>
|
||||
s.description.trim(),
|
||||
);
|
||||
if (filledSteps.length > 0) {
|
||||
const list = filledSteps
|
||||
.map((s, i) => `${i + 1}. ${s.description}`)
|
||||
.join("; ");
|
||||
return `Approved with modifications. Please build the agent following these steps: ${list}`;
|
||||
}
|
||||
}
|
||||
return "Approved. Please build the agent.";
|
||||
}
|
||||
|
||||
function approve() {
|
||||
if (approvedRef.current) return;
|
||||
approvedRef.current = true;
|
||||
setIsEditing(false);
|
||||
onSendRef.current(buildMessage());
|
||||
}
|
||||
|
||||
function handleModify() {
|
||||
if (approvedRef.current) return;
|
||||
if (!output || !isDecompositionOutput(output)) return;
|
||||
setTimerActive(false);
|
||||
setIsEditing(true);
|
||||
setEditableSteps(
|
||||
output.steps.map((s) => ({ ...s, status: s.status ?? "pending" })),
|
||||
);
|
||||
|
||||
// Cancel the server-side auto-approve timer so it doesn't fire the
|
||||
// default "Approved" message while the user is editing steps.
|
||||
if (output.session_id) {
|
||||
postV2CancelAutoApproveTask(output.session_id).catch(() => {
|
||||
// Best-effort — if the timer already fired or the request fails,
|
||||
// the predicate check will still prevent a duplicate.
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleStepChange(index: number, description: string) {
|
||||
setEditableSteps((prev) =>
|
||||
prev.map((s, i) => (i === index ? { ...s, description } : s)),
|
||||
);
|
||||
}
|
||||
|
||||
function handleStepDelete(index: number) {
|
||||
setEditableSteps((prev) => prev.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
// Insert a blank step after the given index (-1 = prepend).
|
||||
function handleStepInsert(afterIndex: number) {
|
||||
setEditableSteps((prev) => {
|
||||
const next = [...prev];
|
||||
next.splice(afterIndex + 1, 0, {
|
||||
step_id: `step_new_${crypto.randomUUID()}`,
|
||||
description: "",
|
||||
action: "add_block",
|
||||
status: "pending",
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
// If a new message arrives while editing, exit edit mode so the user is not stuck.
|
||||
useEffect(() => {
|
||||
if (!showActions && isEditing) {
|
||||
setIsEditing(false);
|
||||
}
|
||||
}, [showActions, isEditing]);
|
||||
|
||||
// The timer only ticks once the turn is fully finished (actionsEnabled
|
||||
// includes !isMessageStreaming). This gives the user the full countdown
|
||||
// duration to review the plan after all streaming completes — not from
|
||||
// when the tool returned (which would eat streaming time into the review
|
||||
// window). For session re-entry, the lazy initializer already seeds
|
||||
// secondsLeft from created_at, so the timer resumes correctly.
|
||||
useEffect(() => {
|
||||
if (!actionsEnabled || !timerActive) return;
|
||||
const interval = setInterval(() => {
|
||||
setSecondsLeft((s) => Math.max(0, s - 1));
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [actionsEnabled, timerActive, part.toolCallId]);
|
||||
|
||||
// Auto-approve when countdown reaches 0. The client fires at 60s; the
|
||||
// server fires 5s later as a fallback for the "user closed the tab" case.
|
||||
// When the client IS present, its approve() creates the SSE subscription
|
||||
// so the user sees the build in real-time. The server's predicate then
|
||||
// sees the user's message and skips — no duplicate.
|
||||
// approve() is stable via approvedRef — safe to omit from deps.
|
||||
useEffect(() => {
|
||||
if (
|
||||
secondsLeft === 0 &&
|
||||
timerActive &&
|
||||
actionsEnabled &&
|
||||
!wasInitiallyPastDeadlineRef.current
|
||||
) {
|
||||
approve();
|
||||
}
|
||||
}, [secondsLeft, timerActive, actionsEnabled]);
|
||||
|
||||
const progress = secondsLeft / countdownSeconds;
|
||||
const dashOffset = CIRCUMFERENCE * (1 - progress);
|
||||
const stepCount = isEditing
|
||||
? editableSteps.length
|
||||
: output && isDecompositionOutput(output)
|
||||
? output.step_count
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="py-2">
|
||||
{isPending && (
|
||||
@@ -255,7 +65,7 @@ export function DecomposeGoalTool({
|
||||
{output && isDecompositionOutput(output) && (
|
||||
<ToolAccordion
|
||||
icon={<AccordionIcon />}
|
||||
title={`Build Plan — ${stepCount} steps`}
|
||||
title={`Build Plan — ${output.step_count} steps`}
|
||||
description={output.goal}
|
||||
defaultExpanded
|
||||
>
|
||||
@@ -263,164 +73,21 @@ export function DecomposeGoalTool({
|
||||
<ContentMessage>{output.message}</ContentMessage>
|
||||
|
||||
<div className="rounded-lg border border-border bg-card p-3">
|
||||
{isEditing ? (
|
||||
<div className="flex flex-col">
|
||||
{/* Insert before the first step */}
|
||||
<InsertButton onClick={() => handleStepInsert(-1)} />
|
||||
|
||||
{editableSteps.map((step, i) => (
|
||||
<div key={step.step_id} className="flex flex-col">
|
||||
<div className="flex items-start gap-2 py-1">
|
||||
<span className="w-5 shrink-0 pt-1 text-xs text-muted-foreground">
|
||||
{i + 1}.
|
||||
</span>
|
||||
<textarea
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
}
|
||||
}}
|
||||
value={step.description}
|
||||
onChange={(e) => handleStepChange(i, e.target.value)}
|
||||
rows={1}
|
||||
className="flex-1 resize-none overflow-hidden rounded border border-border px-2 py-1 text-sm focus:border-neutral-400 focus:outline-none"
|
||||
placeholder="Step description"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleStepDelete(i)}
|
||||
className="mt-1 text-muted-foreground hover:text-red-500"
|
||||
aria-label="Remove step"
|
||||
>
|
||||
<TrashIcon size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{/* Insert after each step */}
|
||||
<InsertButton onClick={() => handleStepInsert(i)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
|
||||
{showActions && (
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
{isEditing ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={approve}
|
||||
disabled={!actionsEnabled}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<CheckIcon size={14} weight="bold" />
|
||||
Approve
|
||||
</span>
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
{/* Primary CTA — encourages user to run the agent */}
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
onClick={approve}
|
||||
disabled={!actionsEnabled}
|
||||
>
|
||||
<span className="group/label inline-flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 group-hover/label:hidden">
|
||||
Starting in
|
||||
<span className="relative inline-flex h-6 w-6 items-center justify-center">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 34 34"
|
||||
className="absolute -rotate-90"
|
||||
>
|
||||
<circle
|
||||
cx="17"
|
||||
cy="17"
|
||||
r={RADIUS}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
className="text-white/30"
|
||||
/>
|
||||
<circle
|
||||
cx="17"
|
||||
cy="17"
|
||||
r={RADIUS}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={CIRCUMFERENCE}
|
||||
strokeDashoffset={dashOffset}
|
||||
className="text-white transition-[stroke-dashoffset] duration-1000 ease-linear"
|
||||
/>
|
||||
</svg>
|
||||
<span className="relative z-10 text-[11px] font-semibold tabular-nums text-white">
|
||||
{secondsLeft}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="hidden group-hover/label:inline">
|
||||
Start now
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={handleModify}
|
||||
disabled={!actionsEnabled}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<PencilSimpleIcon size={14} weight="bold" />
|
||||
Modify
|
||||
</span>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<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>
|
||||
)}
|
||||
|
||||
{output.requires_approval && !showActions && (
|
||||
<ContentMessage>
|
||||
Review the plan above and approve to start building.
|
||||
</ContentMessage>
|
||||
)}
|
||||
</div>
|
||||
</ContentGrid>
|
||||
</ToolAccordion>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InsertButton({ onClick }: { onClick: () => void }) {
|
||||
return (
|
||||
<div className="group flex items-center gap-1 py-0.5">
|
||||
<div className="h-px flex-1 bg-border group-hover:bg-neutral-300" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="flex items-center gap-0.5 rounded px-1 text-xs text-muted-foreground hover:text-foreground focus:outline-none"
|
||||
aria-label="Insert step here"
|
||||
>
|
||||
<PlusIcon size={10} weight="bold" />
|
||||
</button>
|
||||
<div className="h-px flex-1 bg-border group-hover:bg-neutral-300" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@/tests/integrations/test-utils";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { DecomposeGoalTool } from "../DecomposeGoal";
|
||||
@@ -17,10 +16,6 @@ vi.mock(
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@/app/api/__generated__/endpoints/chat/chat", () => ({
|
||||
postV2CancelAutoApproveTask: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
step_id: "step_1",
|
||||
@@ -51,9 +46,6 @@ const DECOMPOSITION: TaskDecompositionOutput = {
|
||||
goal: "Build a news summarizer",
|
||||
steps: STEPS,
|
||||
step_count: 3,
|
||||
requires_approval: true,
|
||||
auto_approve_seconds: 60,
|
||||
created_at: new Date().toISOString(),
|
||||
session_id: "test-session-1",
|
||||
};
|
||||
|
||||
@@ -84,33 +76,18 @@ describe("DecomposeGoalTool", () => {
|
||||
});
|
||||
|
||||
it("renders analyzing animation during input-streaming", () => {
|
||||
render(
|
||||
<DecomposeGoalTool
|
||||
part={makePart("input-streaming") as any}
|
||||
isLastMessage
|
||||
/>,
|
||||
);
|
||||
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}
|
||||
isLastMessage
|
||||
/>,
|
||||
);
|
||||
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}
|
||||
isLastMessage
|
||||
/>,
|
||||
);
|
||||
render(<DecomposeGoalTool part={makePart("output-error") as any} />);
|
||||
fireEvent.click(screen.getByText("Try again"));
|
||||
expect(mockOnSend).toHaveBeenCalledWith(
|
||||
"Please try decomposing the goal again.",
|
||||
@@ -126,17 +103,15 @@ describe("DecomposeGoalTool", () => {
|
||||
render(
|
||||
<DecomposeGoalTool
|
||||
part={makePart("output-available", errorOutput) as any}
|
||||
isLastMessage
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Please provide at least one step.")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders the build plan accordion with steps", () => {
|
||||
it("renders the build plan accordion with steps as a read-only list", () => {
|
||||
render(
|
||||
<DecomposeGoalTool
|
||||
part={makePart("output-available", DECOMPOSITION) as any}
|
||||
isLastMessage
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/Build Plan — 3 steps/)).toBeDefined();
|
||||
@@ -151,254 +126,37 @@ describe("DecomposeGoalTool", () => {
|
||||
render(
|
||||
<DecomposeGoalTool
|
||||
part={makePart("output-available", DECOMPOSITION) as any}
|
||||
isLastMessage
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("AI Text Generator")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows approve and modify buttons when requires_approval and isLastMessage", () => {
|
||||
it("does not render approve, modify, or edit controls", () => {
|
||||
render(
|
||||
<DecomposeGoalTool
|
||||
part={makePart("output-available", DECOMPOSITION) as any}
|
||||
isLastMessage
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Modify")).toBeDefined();
|
||||
expect(screen.getByText(/Starting in/)).toBeDefined();
|
||||
});
|
||||
|
||||
it("hides action buttons when isLastMessage is false", () => {
|
||||
render(
|
||||
<DecomposeGoalTool
|
||||
part={makePart("output-available", DECOMPOSITION) as any}
|
||||
isLastMessage={false}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText("Modify")).toBeNull();
|
||||
expect(screen.getByText(/Review the plan above and approve/)).toBeDefined();
|
||||
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("hides action buttons when requires_approval is false", () => {
|
||||
const noApproval = { ...DECOMPOSITION, requires_approval: false };
|
||||
render(
|
||||
<DecomposeGoalTool
|
||||
part={makePart("output-available", noApproval) as any}
|
||||
isLastMessage
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText("Modify")).toBeNull();
|
||||
});
|
||||
|
||||
it("disables buttons while message is still streaming", () => {
|
||||
it("does not call onSend when the plan card renders", () => {
|
||||
render(
|
||||
<DecomposeGoalTool
|
||||
part={makePart("output-available", DECOMPOSITION) as any}
|
||||
isLastMessage
|
||||
isMessageStreaming
|
||||
/>,
|
||||
);
|
||||
const modifyBtn = screen.getByText("Modify").closest("button");
|
||||
expect(modifyBtn?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("sends approval message when approve button is clicked", async () => {
|
||||
render(
|
||||
<DecomposeGoalTool
|
||||
part={makePart("output-available", DECOMPOSITION) as any}
|
||||
isLastMessage
|
||||
/>,
|
||||
);
|
||||
|
||||
const startBtn = screen.getByText(/Starting in/).closest("button");
|
||||
expect(startBtn).toBeDefined();
|
||||
fireEvent.click(startBtn!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSend).toHaveBeenCalledWith(
|
||||
"Approved. Please build the agent.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not send duplicate approval on second click", async () => {
|
||||
render(
|
||||
<DecomposeGoalTool
|
||||
part={makePart("output-available", DECOMPOSITION) as any}
|
||||
isLastMessage
|
||||
/>,
|
||||
);
|
||||
|
||||
const startBtn = screen.getByText(/Starting in/).closest("button");
|
||||
fireEvent.click(startBtn!);
|
||||
fireEvent.click(startBtn!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSend).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("enters edit mode when Modify is clicked", async () => {
|
||||
render(
|
||||
<DecomposeGoalTool
|
||||
part={makePart("output-available", DECOMPOSITION) as any}
|
||||
isLastMessage
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Modify"));
|
||||
|
||||
await waitFor(() => {
|
||||
const textareas = screen.getAllByPlaceholderText("Step description");
|
||||
expect(textareas.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
it("cancels auto-approve on the server when Modify is clicked", async () => {
|
||||
const { postV2CancelAutoApproveTask } = await import(
|
||||
"@/app/api/__generated__/endpoints/chat/chat"
|
||||
);
|
||||
|
||||
render(
|
||||
<DecomposeGoalTool
|
||||
part={makePart("output-available", DECOMPOSITION) as any}
|
||||
isLastMessage
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Modify"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(postV2CancelAutoApproveTask).toHaveBeenCalledWith(
|
||||
"test-session-1",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("allows editing step descriptions in edit mode", async () => {
|
||||
render(
|
||||
<DecomposeGoalTool
|
||||
part={makePart("output-available", DECOMPOSITION) as any}
|
||||
isLastMessage
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Modify"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByPlaceholderText("Step description").length).toBe(3);
|
||||
});
|
||||
|
||||
const textareas = screen.getAllByPlaceholderText("Step description");
|
||||
fireEvent.change(textareas[0], {
|
||||
target: { value: "Fetch RSS feed" },
|
||||
});
|
||||
|
||||
expect(
|
||||
(
|
||||
screen.getAllByPlaceholderText(
|
||||
"Step description",
|
||||
)[0] as HTMLTextAreaElement
|
||||
).value,
|
||||
).toBe("Fetch RSS feed");
|
||||
});
|
||||
|
||||
it("allows deleting steps in edit mode", async () => {
|
||||
render(
|
||||
<DecomposeGoalTool
|
||||
part={makePart("output-available", DECOMPOSITION) as any}
|
||||
isLastMessage
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Modify"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByPlaceholderText("Step description").length).toBe(3);
|
||||
});
|
||||
|
||||
const removeButtons = screen.getAllByLabelText("Remove step");
|
||||
fireEvent.click(removeButtons[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByPlaceholderText("Step description").length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("allows inserting new steps in edit mode", async () => {
|
||||
render(
|
||||
<DecomposeGoalTool
|
||||
part={makePart("output-available", DECOMPOSITION) as any}
|
||||
isLastMessage
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Modify"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByPlaceholderText("Step description").length).toBe(3);
|
||||
});
|
||||
|
||||
const insertButtons = screen.getAllByLabelText("Insert step here");
|
||||
fireEvent.click(insertButtons[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByPlaceholderText("Step description").length).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
it("sends modified steps message when approve is clicked in edit mode", async () => {
|
||||
render(
|
||||
<DecomposeGoalTool
|
||||
part={makePart("output-available", DECOMPOSITION) as any}
|
||||
isLastMessage
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Modify"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByPlaceholderText("Step description").length).toBe(3);
|
||||
});
|
||||
|
||||
const textareas = screen.getAllByPlaceholderText("Step description");
|
||||
fireEvent.change(textareas[0], {
|
||||
target: { value: "Fetch RSS feed" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("Approve"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSend).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Approved with modifications"),
|
||||
);
|
||||
expect(mockOnSend).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Fetch RSS feed"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("renders countdown timer in the approve button", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(DECOMPOSITION.created_at!));
|
||||
|
||||
render(
|
||||
<DecomposeGoalTool
|
||||
part={makePart("output-available", DECOMPOSITION) as any}
|
||||
isLastMessage
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("60")).toBeDefined();
|
||||
vi.useRealTimers();
|
||||
expect(mockOnSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders nothing pending when output is not yet available", () => {
|
||||
const { container } = render(
|
||||
<DecomposeGoalTool
|
||||
part={makePart("input-available") as any}
|
||||
isLastMessage
|
||||
/>,
|
||||
<DecomposeGoalTool part={makePart("input-available") as any} />,
|
||||
);
|
||||
expect(container.querySelector(".py-2")).toBeDefined();
|
||||
});
|
||||
|
||||
@@ -4,10 +4,8 @@
|
||||
* Covers: parseOutput / getDecomposeGoalOutput, type guards, getAnimationText
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
computeRemainingSeconds,
|
||||
FALLBACK_COUNTDOWN_SECONDS,
|
||||
getAnimationText,
|
||||
getDecomposeGoalOutput,
|
||||
isDecompositionOutput,
|
||||
@@ -49,7 +47,6 @@ const DECOMPOSITION: TaskDecompositionOutput = {
|
||||
},
|
||||
],
|
||||
step_count: 3,
|
||||
requires_approval: true,
|
||||
};
|
||||
|
||||
const ERROR_OUTPUT: DecomposeErrorOutput = {
|
||||
@@ -208,88 +205,3 @@ describe("getAnimationText", () => {
|
||||
expect(text.toLowerCase()).toContain("analyzing");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// computeRemainingSeconds
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DECOMPOSITION_BASE: TaskDecompositionOutput = {
|
||||
type: "task_decomposition",
|
||||
message: "Plan",
|
||||
goal: "Build agent",
|
||||
steps: [{ step_id: "s1", description: "Step 1", action: "add_block" }],
|
||||
step_count: 1,
|
||||
requires_approval: true,
|
||||
auto_approve_seconds: 60,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
describe("computeRemainingSeconds", () => {
|
||||
it("returns fallback when output is null", () => {
|
||||
expect(computeRemainingSeconds(null, 60)).toBe(60);
|
||||
});
|
||||
|
||||
it("returns fallback when output is an error", () => {
|
||||
const err: DecomposeErrorOutput = { type: "error", error: "oops" };
|
||||
expect(computeRemainingSeconds(err, 60)).toBe(60);
|
||||
});
|
||||
|
||||
it("returns auto_approve_seconds when created_at is missing", () => {
|
||||
const noTimestamp = { ...DECOMPOSITION_BASE, created_at: undefined };
|
||||
expect(computeRemainingSeconds(noTimestamp, 99)).toBe(60);
|
||||
});
|
||||
|
||||
it("returns auto_approve_seconds when created_at is unparseable", () => {
|
||||
const badTimestamp = { ...DECOMPOSITION_BASE, created_at: "not-a-date" };
|
||||
expect(computeRemainingSeconds(badTimestamp, 99)).toBe(60);
|
||||
});
|
||||
|
||||
it("returns correct remaining seconds for a recent timestamp", () => {
|
||||
vi.useFakeTimers();
|
||||
const now = new Date("2026-01-01T00:00:30Z");
|
||||
vi.setSystemTime(now);
|
||||
const output = {
|
||||
...DECOMPOSITION_BASE,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
// 30s elapsed → 60 - 30 = 30
|
||||
expect(computeRemainingSeconds(output, 60)).toBe(30);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("clamps to 0 when deadline has passed", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:02:00Z"));
|
||||
const output = {
|
||||
...DECOMPOSITION_BASE,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
expect(computeRemainingSeconds(output, 60)).toBe(0);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("clamps to total when client clock is ahead (future timestamp)", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||
const output = {
|
||||
...DECOMPOSITION_BASE,
|
||||
created_at: "2026-01-01T00:00:10Z",
|
||||
};
|
||||
// elapsed = -10 → total - (-10) = 70, clamped to 60
|
||||
expect(computeRemainingSeconds(output, 60)).toBe(60);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("uses fallback when auto_approve_seconds is missing", () => {
|
||||
const noAutoApprove = {
|
||||
...DECOMPOSITION_BASE,
|
||||
auto_approve_seconds: undefined,
|
||||
created_at: undefined,
|
||||
};
|
||||
expect(computeRemainingSeconds(noAutoApprove, 42)).toBe(42);
|
||||
});
|
||||
|
||||
it("exports FALLBACK_COUNTDOWN_SECONDS as 60", () => {
|
||||
expect(FALLBACK_COUNTDOWN_SECONDS).toBe(60);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,11 +15,6 @@ import { ScaleLoader } from "../../components/ScaleLoader/ScaleLoader";
|
||||
// Re-export generated step type for consumers that need it.
|
||||
export type DecompositionStep = DecompositionStepModel;
|
||||
|
||||
// Hand-rolled because the tool output is parsed from opaque JSON (not through
|
||||
// the generated API client), so the runtime shape differs from the generated
|
||||
// TaskDecompositionResponse — notably ``created_at`` is an ISO 8601 string at
|
||||
// runtime, whereas the generated type declares ``Date``. Keep fields in sync
|
||||
// with backend ``TaskDecompositionResponse`` in tools/models.py.
|
||||
export interface TaskDecompositionOutput {
|
||||
type: string;
|
||||
message: string;
|
||||
@@ -27,9 +22,6 @@ export interface TaskDecompositionOutput {
|
||||
goal: string;
|
||||
steps: DecompositionStep[];
|
||||
step_count: number;
|
||||
requires_approval: boolean;
|
||||
auto_approve_seconds?: number;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface DecomposeErrorOutput {
|
||||
@@ -42,33 +34,6 @@ export type DecomposeGoalOutput =
|
||||
| TaskDecompositionOutput
|
||||
| DecomposeErrorOutput;
|
||||
|
||||
// Fallback used only if the backend response omits auto_approve_seconds
|
||||
// (older sessions). The authoritative value comes from the tool output.
|
||||
export const FALLBACK_COUNTDOWN_SECONDS = 60;
|
||||
|
||||
/**
|
||||
* Compute remaining countdown seconds, deriving elapsed time from the
|
||||
* backend-stamped ``created_at`` so the timer reflects real elapsed time
|
||||
* when the user reopens the session — instead of restarting from full.
|
||||
*
|
||||
* Falls back to the full countdown when ``created_at`` is missing (older
|
||||
* sessions stored before this field existed) or unparseable. Clamps to
|
||||
* ``[0, total]`` to defend against client clock skew producing future
|
||||
* timestamps.
|
||||
*/
|
||||
export function computeRemainingSeconds(
|
||||
output: DecomposeGoalOutput | null,
|
||||
fallback: number,
|
||||
): number {
|
||||
if (!output || !isDecompositionOutput(output)) return fallback;
|
||||
const total = output.auto_approve_seconds ?? fallback;
|
||||
if (!output.created_at) return total;
|
||||
const createdAtMs = new Date(output.created_at).getTime();
|
||||
if (Number.isNaN(createdAtMs)) return total;
|
||||
const elapsedSec = (Date.now() - createdAtMs) / 1000;
|
||||
return Math.max(0, Math.min(total, Math.round(total - elapsedSec)));
|
||||
}
|
||||
|
||||
function parseOutput(output: unknown): DecomposeGoalOutput | null {
|
||||
if (!output) return null;
|
||||
if (typeof output === "string") {
|
||||
|
||||
@@ -2406,46 +2406,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/chat/sessions/{session_id}/cancel-auto-approve": {
|
||||
"post": {
|
||||
"tags": ["v2", "chat", "chat"],
|
||||
"summary": "Cancel Auto Approve Task",
|
||||
"description": "Cancel the pending auto-approve timer for a decompose_goal plan.\n\nCalled by the frontend when the user clicks \"Modify\" on the build-plan\nbox. Without this, the server-side timer would fire the default\n\"Approved\" message while the user is still editing.",
|
||||
"operationId": "postV2CancelAutoApproveTask",
|
||||
"security": [{ "HTTPBearerJWT": [] }],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "session_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "string", "title": "Session Id" }
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CancelSessionResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/chat/sessions/{session_id}/messages/pending": {
|
||||
"get": {
|
||||
"tags": ["v2", "chat", "chat"],
|
||||
@@ -16261,23 +16221,6 @@
|
||||
"title": "Step Count",
|
||||
"description": "Number of steps (auto-derived from steps list)",
|
||||
"default": 0
|
||||
},
|
||||
"requires_approval": {
|
||||
"type": "boolean",
|
||||
"title": "Requires Approval",
|
||||
"default": true
|
||||
},
|
||||
"auto_approve_seconds": {
|
||||
"type": "integer",
|
||||
"title": "Auto Approve Seconds",
|
||||
"description": "Seconds the client should count down before auto-approving. Kept in sync with the server-side fallback timer, which runs a grace period longer to absorb network latency.",
|
||||
"default": 60
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"title": "Created At",
|
||||
"description": "UTC timestamp when the tool returned. The client uses this with auto_approve_seconds to compute the correct remaining countdown when the user reopens the session — so the timer reflects real elapsed time instead of restarting from zero."
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
|
||||
Reference in New Issue
Block a user