fix: suppress invalid task timestamps (#3434)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user