fix: suppress invalid task timestamps (#3434)

This commit is contained in:
Carlos Florêncio
2026-09-06 15:14:49 +01:00
committed by GitHub
parent 039142db80
commit 28eb911a2a
10 changed files with 309 additions and 63 deletions
@@ -98,6 +98,21 @@ describe("MessageActions timestamp tooltip", () => {
expect(timeEl?.getAttribute("title")).toBe(new Date(MESSAGE_TIMESTAMP).toLocaleString());
});
it.each(["", "not-a-date", "0", "2026-02-30T10:00:00Z"])(
"omits the timestamp affordance for invalid created_at value %j",
(createdAt) => {
const { container } = render(
<StateProvider>
<MessageActions message={assistantMessage({ created_at: createdAt })} />
</StateProvider>,
);
expect(container.querySelector("time")).toBeNull();
expect(container.textContent).not.toContain("Invalid Date");
expect(screen.getByRole("button", { name: /copy message to clipboard/i })).toBeTruthy();
},
);
it("omits the timestamp element entirely when showTimestamp is false", () => {
const { container } = render(
<StateProvider>
@@ -15,6 +15,7 @@ import {
} from "@tabler/icons-react";
import { cn } from "@/lib/utils";
import { formatRelativeTime } from "@/lib/utils";
import { parseStrictRfc3339Timestamp } from "@/lib/utils/strict-timestamp";
import { formatPromptDuration, messageTurnDurationSeconds } from "@/lib/prompt-history";
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard";
import { useAppStore } from "@/components/state-provider";
@@ -247,7 +248,9 @@ function MessageTimestamp({ createdAt }: { createdAt: string }) {
const { t } = useTranslation();
const usesTouchDrawer = useTouchDrawer();
const [open, setOpen] = useState(false);
const absoluteTime = new Date(createdAt).toLocaleString();
if (parseStrictRfc3339Timestamp(createdAt) === null) return null;
const date = new Date(createdAt);
const absoluteTime = date.toLocaleString();
const timeEl = (
<time
dateTime={createdAt}
@@ -2,6 +2,7 @@ import type { StateCreator } from "zustand";
import type { Draft } from "immer";
import type { SessionSlice } from "./types";
import type { Turn, TaskSession } from "@/lib/types/http";
import { parseStrictRfc3339Timestamp } from "@/lib/utils/strict-timestamp";
type ImmerSet = Parameters<
StateCreator<SessionSlice, [["zustand/immer", never]], [], SessionSlice>
@@ -9,43 +10,6 @@ type ImmerSet = Parameters<
type TurnReconciliationDraft = Pick<Draft<SessionSlice>, "turns" | "taskSessions">;
// Strict wire format: full RFC3339 with explicit offset/UTC marker. JS
// Date.parse is permissive (e.g. "0" parses as 2000-01-01, partial dates as
// midnight, timezone-less values in the local zone), so shape validation must
// come first or malformed rows would win freshness comparisons.
const RFC3339_PATTERN =
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(Z|[+-](\d{2}):(\d{2}))$/;
const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
/** Whether the year is a leap year (proleptic Gregorian calendar). */
function isLeapYear(year: number): boolean {
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
}
/** Whether the day exists in the month (leap-year aware). */
function validDayForMonth(year: number, month: number, day: number): boolean {
if (day < 1) return false;
const maxDay = month === 2 && isLeapYear(year) ? 29 : DAYS_IN_MONTH[month - 1];
return day <= maxDay;
}
/**
* Returns the UTC-offset delta in SECONDS for a matched zone, or null when
* the offset components are out of range.
*/
function parseOffsetSeconds(
zone: string,
offsetHourText: string | undefined,
offsetMinuteText: string | undefined,
): number | null {
if (zone === "Z") return 0;
const hour = Number(offsetHourText);
const minute = Number(offsetMinuteText);
if (hour > 23 || minute > 59) return null;
return (zone.startsWith("-") ? -1 : 1) * (hour * 60 + minute) * 60;
}
/**
* Whether `incoming` is not newer than `existing`. A malformed incoming
* timestamp counts as stale (never newer).
@@ -224,30 +188,7 @@ export function reconcileActiveTurnAfterHydrationDraft(
* would collapse legitimate sub-100ns differences and misorder rows.
*/
export function parseTurnTimestamp(value: string | undefined): bigint | null {
if (!value) return null;
const match = RFC3339_PATTERN.exec(value);
if (!match) return null;
const year = Number(match[1]);
const month = Number(match[2]);
if (month < 1 || month > 12) return null;
if (!validDayForMonth(year, month, Number(match[3]))) return null;
if (Number(match[4]) > 23 || Number(match[5]) > 59 || Number(match[6]) > 59) {
return null;
}
const offsetSeconds = parseOffsetSeconds(match[8], match[9], match[10]);
if (offsetSeconds === null) return null;
// RFC3339Nano caps the fraction at 9 digits; longer fractions would
// overflow into the seconds component (padEnd does not cap).
if (match[7] !== undefined && match[7].length > 9) return null;
// setUTCFullYear handles years 0-99 correctly (Date.UTC maps them to
// 1900+year); the components are validated above, so no normalization can
// occur here.
const utc = new Date(0);
utc.setUTCFullYear(year, month - 1, Number(match[3]));
utc.setUTCHours(Number(match[4]), Number(match[5]), Number(match[6]), 0);
const wholeSeconds = BigInt(Math.floor(utc.getTime() / 1000)) - BigInt(offsetSeconds);
const fractionNs = BigInt((match[7] ?? "").padEnd(9, "0"));
return wholeSeconds * BigInt(1_000_000_000) + fractionNs;
return parseStrictRfc3339Timestamp(value);
}
/**
+7
View File
@@ -184,6 +184,13 @@ describe("formatRelativeTime", () => {
expect(formatRelativeTime(ago(9 * SECOND))).toBe("just now");
});
it.each(["", "not-a-date", "0", "2026-02-30T10:00:00Z"])(
"returns an empty label for invalid input %j",
(value) => {
expect(formatRelativeTime(value)).toBe("");
},
);
it("localizes EVERY branch, so a timestamp does not change language as it ages", async () => {
// The regression: "just now" was routed through the catalog while the rungs
// below it stayed hardcoded, so a non-English locale showed a translated
+2
View File
@@ -1,6 +1,7 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import { t } from "@/lib/i18n";
import { parseStrictRfc3339Timestamp } from "@/lib/utils/strict-timestamp";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
@@ -205,6 +206,7 @@ export const DEFAULT_LOCAL_EXECUTOR_TYPE = "worktree";
* @returns Formatted relative time string
*/
export function formatRelativeTime(dateString: string): string {
if (parseStrictRfc3339Timestamp(dateString) === null) return "";
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
+66
View File
@@ -0,0 +1,66 @@
// Strict wire format: full RFC3339 with an explicit offset or UTC marker.
// Date.parse is permissive, so shape validation must happen before any date
// formatting or malformed values can be normalized into a different instant.
const RFC3339_PATTERN =
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(Z|[+-](\d{2}):(\d{2}))$/;
const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
/** Whether the year is a leap year in the proleptic Gregorian calendar. */
function isLeapYear(year: number): boolean {
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
}
/** Whether the day exists in the month, including leap-day validation. */
function validDayForMonth(year: number, month: number, day: number): boolean {
if (day < 1) return false;
const maxDay = month === 2 && isLeapYear(year) ? 29 : DAYS_IN_MONTH[month - 1];
return day <= maxDay;
}
/** Returns the UTC offset in seconds for a matched RFC3339 zone. */
function parseOffsetSeconds(
zone: string,
offsetHourText: string | undefined,
offsetMinuteText: string | undefined,
): number | null {
if (zone === "Z") return 0;
const hour = Number(offsetHourText);
const minute = Number(offsetMinuteText);
if (hour > 23 || minute > 59) return null;
return (zone.startsWith("-") ? -1 : 1) * (hour * 60 + minute) * 60;
}
/**
* Parses an RFC3339/RFC3339Nano timestamp into epoch nanoseconds.
*
* Missing, empty, malformed, and semantically invalid values return `null`.
* Calendar and time components are validated explicitly because Date.parse
* normalizes values such as February 30th instead of rejecting them.
*/
export function parseStrictRfc3339Timestamp(value: string | undefined): bigint | null {
if (!value) return null;
const match = RFC3339_PATTERN.exec(value);
if (!match) return null;
const year = Number(match[1]);
const month = Number(match[2]);
if (month < 1 || month > 12) return null;
if (!validDayForMonth(year, month, Number(match[3]))) return null;
if (Number(match[4]) > 23 || Number(match[5]) > 59 || Number(match[6]) > 59) {
return null;
}
const offsetSeconds = parseOffsetSeconds(match[8], match[9], match[10]);
if (offsetSeconds === null) return null;
// RFC3339Nano caps the fraction at 9 digits; longer fractions would
// overflow into the seconds component when padded.
if (match[7] !== undefined && match[7].length > 9) return null;
// setUTCFullYear handles years 0-99 correctly; Date.UTC maps them to
// 1900-1999 and would therefore normalize an otherwise valid timestamp.
const utc = new Date(0);
utc.setUTCFullYear(year, month - 1, Number(match[3]));
utc.setUTCHours(Number(match[4]), Number(match[5]), Number(match[6]), 0);
const wholeSeconds = BigInt(Math.floor(utc.getTime() / 1000)) - BigInt(offsetSeconds);
const fractionNs = BigInt((match[7] ?? "").padEnd(9, "0"));
return wholeSeconds * BigInt(1_000_000_000) + fractionNs;
}
@@ -0,0 +1,109 @@
---
created: 2026-09-05
status: done
requirements:
- REQ-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001
system_design:
- ../../specs/ui/system-design/task-prompt-transcript-visibility.md
legacy_specs: []
---
# Implementation Plan: Suppress invalid task timestamps
## Overview
Prevent the task transcript from displaying the browser's `Invalid Date` text
when it renders the frontend-only task-description fallback. The fallback has
content but no persisted message timestamp, so the timestamp action is omitted;
the shared legacy relative formatter also returns an empty label for malformed
input. The implementation is one sequential frontend slice with focused unit
regressions.
## Root cause
`useProcessedMessages` creates `TASK_DESCRIPTION_SYNTHETIC_ID` with
`created_at: ""` when history is exhausted without a stored user prompt.
`MessageActions` always renders `MessageTimestamp`, and the legacy
`formatRelativeTime` helper treats the invalid `Date` as though its numeric
comparisons were valid before falling through to `date.toLocaleDateString()`.
The browser therefore supplies the visible string `Invalid Date`.
## Scope
### In scope
- Validate a message timestamp before rendering the timestamp action and its
absolute-time disclosure.
- Make the shared legacy relative formatter return an empty string for empty or
malformed input.
- Add unit coverage for the observed synthetic fallback path and the shared
formatter contract.
### Out of scope
- Changing task-description fallback eligibility, message persistence, or API
timestamp fields.
- Adding a new mobile composition or changing action-row sizing, touch targets,
navigation, or scrolling.
- Reworking the separate locale-aware formatter in `lib/i18n/formats.ts`.
## Technical approach
- `apps/web/lib/utils.ts`: return `""` immediately when the parsed date is
invalid in `formatRelativeTime`, using the shared strict parser.
- `apps/web/lib/utils/strict-timestamp.ts`: centralize strict RFC3339 calendar,
clock, offset, and fractional-second validation for timestamp consumers.
- `apps/web/components/task/chat/messages/message-actions.tsx`: validate
- `createdAt` in `MessageTimestamp` with the shared parser before deriving the
absolute label or mounting the `<time>`/Drawer surface. Invalid timestamps
keep content actions visible but omit only the timestamp affordance.
- `apps/web/lib/state/slices/session/turn-actions.ts`: delegate the existing
nanosecond parser API to the shared implementation so session reconciliation
and UI formatting enforce the same wire contract.
- Keep the existing `formatRelativeTime` import and action-row presentation so
valid message timestamps and desktop/mobile interaction behavior are
unchanged.
## Tests
- `apps/web/lib/utils.test.ts`: add an invalid and empty input regression for
`formatRelativeTime`, including values that `Date.parse` would normalize.
- `apps/web/components/task/chat/messages/message-actions.test.tsx`: add a
synthetic-fallback timestamp case asserting no `<time>` element or
`Invalid Date` text/disclosure is rendered for empty, malformed, and
normalized-malformed values. This covers
`AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.15`.
## E2E tests
No new Playwright scenario is required. This repair changes only timestamp
normalization and conditionally removes an affordance from an existing inline
message row; it does not change mobile composition, touch behavior, navigation,
scrolling, or viewport geometry. The focused message-action test covers the
shared desktop/mobile rendering path, and existing task-chat mobile flows remain
the rendered transcript smoke coverage.
## Work orders
- [x] [Task 01: Suppress invalid message timestamps](task-01-suppress-invalid-message-timestamps.md)
## Verification results
Passed:
- `pnpm --filter @kandev/web test -- --run lib/utils.test.ts components/task/chat/messages/message-actions.test.tsx`: 51 tests passed.
- `pnpm --filter @kandev/web test -- --run lib/state/slices/session/turn-actions.test.ts`: 54 tests passed.
- ESLint passed for the four changed frontend source and test files.
- `pnpm run typecheck` passed from `apps/web`.
- `python3 scripts/lint-spec-files.py --all` passed.
- `git diff --check` passed.
- Managed Chromium and Pixel 5 capture specs passed with synthetic task data; both screenshots contain no `Invalid Date` text. Temporary capture specs were removed after capture.
## Risks
- A malformed timestamp supplied by a persisted message will lose its timestamp
affordance instead of showing a misleading value; message content and other
actions remain available.
- The focused test command may regenerate frontend release-note/changelog
artifacts as part of the package pretest hook; unrelated generated changes
must not be included.
@@ -0,0 +1,95 @@
---
id: "01-suppress-invalid-message-timestamps"
title: "Suppress invalid message timestamps"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001
acceptance_criteria:
- AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.4
- AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.15
system_design:
- ../../specs/ui/system-design/task-prompt-transcript-visibility.md
---
# Task 01: Suppress invalid message timestamps
## Summary
Make empty and malformed message timestamps render no timestamp affordance and
never expose the browser's `Invalid Date` string. Preserve the existing message
action row and valid timestamp behavior.
## In scope
- Add a failing formatter regression for empty and invalid values.
- Add a failing message-action regression for the synthetic task-description
message shape (`created_at: ""`).
- Add the minimum guards in the shared formatter and `MessageTimestamp`.
## Out of scope
- Task/session persistence or backend message contracts.
- Changes to transcript pagination, fallback eligibility, responsive layout,
touch targets, or action semantics for valid timestamps.
## Acceptance
- `formatRelativeTime` returns an empty string for empty, malformed, and
normalized-malformed values such as `"0"` and February 30th.
- `MessageActions` renders no timestamp element or invalid-date disclosure for
an empty or malformed `created_at` while copy/raw/favorite actions remain
available.
- Valid timestamp tests continue to pass unchanged.
## Verification
```bash
(cd apps && pnpm --filter @kandev/web test -- --run lib/utils.test.ts components/task/chat/messages/message-actions.test.tsx)
(cd apps/web && pnpm exec eslint lib/utils.ts lib/utils.test.ts lib/utils/strict-timestamp.ts lib/state/slices/session/turn-actions.ts components/task/chat/messages/message-actions.tsx components/task/chat/messages/message-actions.test.tsx)
python3 scripts/lint-spec-files.py --all
git diff --check
```
## Files likely touched
- `apps/web/lib/utils.ts`
- `apps/web/lib/utils/strict-timestamp.ts`
- `apps/web/lib/utils.test.ts`
- `apps/web/lib/state/slices/session/turn-actions.ts`
- `apps/web/components/task/chat/messages/message-actions.tsx`
- `apps/web/components/task/chat/messages/message-actions.test.tsx`
- `docs/specs/ui/requirements/task-prompt-transcript-visibility.md`
- `docs/specs/ui/system-design/task-prompt-transcript-visibility.md`
## Dependencies
None.
## Risks
- Invalid persisted timestamps will be intentionally hidden rather than
displayed as raw values, matching the fallback's lack of a trustworthy date.
## Parallelism
`sequential`
## Inputs
- `REQ-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.4` and `.15`.
- The fallback timestamp boundary in the transcript system design.
- Existing `MessageActions` and `formatRelativeTime` tests.
## Results
Implemented and verified:
- `parseStrictRfc3339Timestamp` is shared by session reconciliation and UI date formatting.
- `formatRelativeTime` returns an empty label for empty, malformed, and normalized-malformed dates.
- `MessageTimestamp` omits the timestamp element and coarse-pointer disclosure for invalid dates while preserving other message actions.
- Focused frontend tests pass with 51 tests; turn timestamp tests pass with 54 tests.
- ESLint, TypeScript typecheck, specification lint, and `git diff --check` pass.
- Managed Chromium and Pixel 5 capture specs pass against the production build; the captured fallback shows no `Invalid Date` text. Temporary capture specs were removed.
@@ -51,6 +51,7 @@ start.
- **AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.12:** When a previously opened session is revisited after more than one message page was persisted while it was inactive, the transcript shall reconcile to a contiguous newest window and upward pagination shall reach every persisted user prompt without gaps or duplicate rows.
- **AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.13:** When an upward lazy-load operation sizes its batch, it shall target 20 newly loaded text parts. Tool calls and other activity rows shall not advance that target; the operation can stop earlier when prompt `#1` is loaded, persisted history is exhausted, a request makes no progress, or its bounded safety limit is reached.
- **AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.14:** When a previously hidden transcript becomes visible with its restored viewport at the oldest loaded edge and older history remains, the transcript shall resume older-history loading without requiring the user to move away from and return to that edge.
- **AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.15:** When the task-description fallback has no valid persisted message timestamp, the transcript shall omit that row's timestamp affordance and shall not render an invalid-date placeholder.
## Exclusions
@@ -85,6 +85,12 @@ are true:
A tool-only newest window with `has_more: true` does not satisfy this contract.
It renders only the stored rows in that window until the user navigates upward.
The fallback row is frontend-synthesized content, not a persisted message, so
its timestamp is empty. The message action renderer must validate timestamps
before creating the timestamp element. An empty or malformed timestamp omits
the timestamp affordance, including its absolute-time disclosure, instead of
passing an invalid `Date` to browser formatting APIs.
## Opening boundary
The session message fetch requests only the newest bounded window when a task
@@ -229,7 +235,8 @@ anchor behavior as desktop.
- Processed-message tests cover an uninitialized window, a tool-only window
with older history, exhausted legacy history, an empty description, and a
loaded stored prompt.
loaded stored prompt. Message-action tests cover the no-timestamp fallback
path without an invalid-date placeholder.
- Session-state tests cover initialization through boot hydration, newest
fetch, purge, and refetch without inferring exhaustion from defaults.
- Sentinel tests cover committed in-region continuation, out-of-region stop,