mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-30 03:00:41 -04:00
fix(copilot): resume decompose_goal countdown from server timestamp
Reopening a session was restarting the client countdown from a fresh 60s, even though the server had been counting the whole time. Now the timer reflects real elapsed time so the user sees the actual remaining seconds (or 0, which auto-approves immediately). - backend: stamp UTC created_at on TaskDecompositionResponse via a default factory. The timestamp is set when the tool returns and persisted in the message content JSON, so it survives DB round-trips. - frontend: lazy-init secondsLeft from (auto_approve_seconds - (Date.now() - created_at)), clamped to [0, total]. Older messages without created_at fall back to a fresh full countdown (existing behaviour). - Test: assert created_at is stamped within the duration of _execute(). Note: openapi.json regen is skipped in this commit because the existing REST server is in use; the frontend reads tool output as opaque JSON via custom helpers, so the regen is not required for the feature to work. Regen later for completeness. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user