fix(copilot): resolve merge conflict with dev + improve frontend test coverage

- Resolve merge conflict in routes.py (keep both TaskDecompositionResponse
  and Memory*Response types) and openapi.json (re-exported from backend)
- Move computeRemainingSeconds from DecomposeGoal.tsx to helpers.tsx so
  it's testable as a pure function
- Add 9 tests for computeRemainingSeconds covering: null/error output,
  missing/unparseable created_at, correct remaining calculation, zero
  clamp when deadline passed, total clamp for future timestamps, and
  fallback when auto_approve_seconds is missing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
anvyle
2026-04-15 18:38:44 +02:00
parent b0c46ff197
commit bee1c9a3bb
3 changed files with 117 additions and 27 deletions

View File

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

View File

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

View File

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