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 e36e35af6a..b00430391a 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 @@ -21,43 +21,19 @@ import { ToolErrorCard } from "../../components/ToolErrorCard/ToolErrorCard"; import { StepItem } from "./components/StepItem"; import { AccordionIcon, + computeRemainingSeconds, getAnimationText, getDecomposeGoalOutput, isDecompositionOutput, isErrorOutput, + FALLBACK_COUNTDOWN_SECONDS, ToolIcon, type DecomposeGoalOutput, } from "./helpers"; -// Fallback used only if the backend response omits auto_approve_seconds -// (older sessions). The authoritative value comes from the tool output. -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; diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/tools/DecomposeGoal/__tests__/helpers.test.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/tools/DecomposeGoal/__tests__/helpers.test.ts index 80c9f835ad..fc26c90161 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/tools/DecomposeGoal/__tests__/helpers.test.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/tools/DecomposeGoal/__tests__/helpers.test.ts @@ -4,8 +4,10 @@ * Covers: parseOutput / getDecomposeGoalOutput, type guards, getAnimationText */ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { + computeRemainingSeconds, + FALLBACK_COUNTDOWN_SECONDS, getAnimationText, getDecomposeGoalOutput, isDecompositionOutput, @@ -206,3 +208,88 @@ 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); + }); +}); 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 4da6e0457e..f8b3cd2320 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 @@ -42,6 +42,33 @@ 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") {