diff --git a/autogpt_platform/backend/backend/copilot/tools/decompose_goal_test.py b/autogpt_platform/backend/backend/copilot/tools/decompose_goal_test.py index 50984bc58f..e72b10ceb6 100644 --- a/autogpt_platform/backend/backend/copilot/tools/decompose_goal_test.py +++ b/autogpt_platform/backend/backend/copilot/tools/decompose_goal_test.py @@ -1,6 +1,7 @@ """Unit tests for DecomposeGoalTool.""" import asyncio +from datetime import UTC, datetime from unittest.mock import AsyncMock, patch import pytest @@ -301,6 +302,25 @@ async def test_response_includes_auto_approve_seconds(tool: DecomposeGoalTool, s 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 + + # --------------------------------------------------------------------------- # Predicate: _no_user_action_since # --------------------------------------------------------------------------- diff --git a/autogpt_platform/backend/backend/copilot/tools/models.py b/autogpt_platform/backend/backend/copilot/tools/models.py index e9eb7ca4ed..dcad0819ad 100644 --- a/autogpt_platform/backend/backend/copilot/tools/models.py +++ b/autogpt_platform/backend/backend/copilot/tools/models.py @@ -1,6 +1,6 @@ """Pydantic models for tool responses.""" -from datetime import datetime +from datetime import UTC, datetime from enum import Enum from typing import Any, Literal @@ -737,6 +737,15 @@ class TaskDecompositionResponse(ToolResponseBase): "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": diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/tools/DecomposeGoal/DecomposeGoal.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/tools/DecomposeGoal/DecomposeGoal.tsx index f257a16023..53992d9e13 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/tools/DecomposeGoal/DecomposeGoal.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/tools/DecomposeGoal/DecomposeGoal.tsx @@ -25,6 +25,7 @@ import { isDecompositionOutput, isErrorOutput, ToolIcon, + type DecomposeGoalOutput, } from "./helpers"; // Fallback used only if the backend response omits auto_approve_seconds @@ -33,6 +34,29 @@ const FALLBACK_COUNTDOWN_SECONDS = 60; const RADIUS = 15; const CIRCUMFERENCE = 2 * Math.PI * RADIUS; +/** + * 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. + */ +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))); +} + interface EditableStep { step_id: string; description: string; @@ -70,7 +94,12 @@ export function DecomposeGoalTool({ part, isLastMessage }: Props) { (output && isDecompositionOutput(output) && output.auto_approve_seconds) || FALLBACK_COUNTDOWN_SECONDS; - const [secondsLeft, setSecondsLeft] = useState(countdownSeconds); + // 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); diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/tools/DecomposeGoal/helpers.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/tools/DecomposeGoal/helpers.tsx index 43c3118c78..8df68fe755 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/tools/DecomposeGoal/helpers.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/tools/DecomposeGoal/helpers.tsx @@ -27,6 +27,10 @@ export interface TaskDecompositionOutput { step_count: number; requires_approval: boolean; auto_approve_seconds?: number; + // ISO 8601 UTC timestamp stamped by the backend when the tool returned. + // Used to compute the actual remaining countdown when the user reopens + // the session, so the timer doesn't restart from full each time. + created_at?: string; } export interface DecomposeErrorOutput {