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:
anvyle
2026-04-10 17:44:50 +02:00
parent fb86fcb67d
commit f7601d06ed
4 changed files with 64 additions and 2 deletions

View File

@@ -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
# ---------------------------------------------------------------------------

View File

@@ -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":

View File

@@ -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);

View File

@@ -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 {