From 288e58030d6245018199cbf5b99856326e4e585e Mon Sep 17 00:00:00 2001 From: Mateusz Konopelski Date: Tue, 28 Jul 2026 17:32:17 +0200 Subject: [PATCH 1/3] fix(tui): show log timestamps on the viewer's local clock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend timestamps are UTC ISO strings and the TUI rendered them verbatim, so a 17:24 local event showed as 15:24 (CEST). The session list was worse: it explicitly re-normalised created_at to UTC before formatting. Convert once, in one place: render.format_local() parses the ISO value, treats an offset-naive value as UTC (never shifting a clock twice), and formats in the process-local zone. Both the live message feed and the agent error/warning rows go through it, and the session list now reuses it with the date format instead of its own datetime handling. Backend-side log lines and stream text that print an absolute time keep their explicit "UTC" label — the backend has no knowledge of the viewer's timezone. --- mcp_server/tests/conftest.py | 34 ++++++++++++++++++++ mcp_server/tests/test_tui_app.py | 25 +++++++++++++++ mcp_server/tests/test_tui_render.py | 50 ++++++++++++++++++++++++----- mcp_server/tui/app.py | 13 +++----- mcp_server/tui/render.py | 25 +++++++++++---- 5 files changed, 124 insertions(+), 23 deletions(-) create mode 100644 mcp_server/tests/conftest.py diff --git a/mcp_server/tests/conftest.py b/mcp_server/tests/conftest.py new file mode 100644 index 0000000..5d46302 --- /dev/null +++ b/mcp_server/tests/conftest.py @@ -0,0 +1,34 @@ +"""Shared fixtures for the MCP server test suite.""" + +import os +import time +from contextlib import contextmanager +from typing import Iterator + +import pytest + + +@contextmanager +def _pinned_timezone(name: str) -> Iterator[None]: + """Run the block with the process timezone pinned to ``name``. + + ``datetime.astimezone()`` reads the process timezone, so local-time + assertions are only deterministic while it is pinned. + """ + previous = os.environ.get("TZ") + os.environ["TZ"] = name + time.tzset() + try: + yield + finally: + if previous is None: + os.environ.pop("TZ", None) + else: + os.environ["TZ"] = previous + time.tzset() + + +@pytest.fixture +def local_timezone(): + """Context-manager factory pinning the local timezone (see ``_pinned_timezone``).""" + return _pinned_timezone diff --git a/mcp_server/tests/test_tui_app.py b/mcp_server/tests/test_tui_app.py index f606f6f..1aae6e8 100644 --- a/mcp_server/tests/test_tui_app.py +++ b/mcp_server/tests/test_tui_app.py @@ -243,6 +243,31 @@ def test_uses_pipeline_step_defs_for_checkpoint_label(self): assert "Contract validated" in label assert "est-d67dcac6cbe5" in label + def test_created_at_shown_in_local_time(self, local_timezone): + with local_timezone("Europe/Warsaw"): # UTC+2 in July + label = tui_app._session_label( + { + "generation_id": "est-d67dcac6cbe5", + "status": "running", + "checkpoint": "contract_validated", + "created_at": "2026-07-07T14:00:15.701099+00:00", + } + ) + + assert "Jul 07 16:00" in label + + def test_unparseable_created_at_falls_back_to_raw_prefix(self): + label = tui_app._session_label( + { + "generation_id": "est-d67dcac6cbe5", + "status": "running", + "checkpoint": "contract_validated", + "created_at": "not-a-timestamp-value", + } + ) + + assert "not-a-timestamp" in label + def test_unknown_checkpoint_falls_back_to_key(self): label = tui_app._session_label( { diff --git a/mcp_server/tests/test_tui_render.py b/mcp_server/tests/test_tui_render.py index 82e1f4d..d62e21d 100644 --- a/mcp_server/tests/test_tui_render.py +++ b/mcp_server/tests/test_tui_render.py @@ -248,12 +248,45 @@ def __init__(self, **kw): self.subagent_name = kw.get("subagent_name") +class TestFormatLocal: + """Backend timestamps are UTC; the TUI must show them on the viewer's clock.""" + + def test_utc_timestamp_shown_in_local_time(self, local_timezone): + with local_timezone("Europe/Warsaw"): # UTC+2 in July + assert render.format_local("2026-07-28T15:24:05+00:00") == "17:24:05" + + def test_offset_naive_timestamp_read_as_utc(self, local_timezone): + with local_timezone("Europe/Warsaw"): + assert render.format_local("2026-07-28T15:24:05") == "17:24:05" + + def test_non_utc_offset_converted_to_local(self, local_timezone): + with local_timezone("Europe/Warsaw"): # 15:24+05:00 is 10:24 UTC is 12:24 local + assert render.format_local("2026-07-28T15:24:05+05:00") == "12:24:05" + + def test_date_format_rolls_over_with_local_day(self, local_timezone): + with local_timezone("Europe/Warsaw"): + formatted = render.format_local( + "2026-06-30T23:30:00+00:00", render.LOCAL_DATE_TIME_FORMAT + ) + assert formatted == "Jul 01 01:30" + + def test_missing_and_unparseable_are_blank(self): + assert render.format_local(None) == "" + assert render.format_local("") == "" + assert render.format_local("not-a-date") == "" + + class TestStreamRow: - def test_formats_time_kind_and_message(self): - row = render.stream_row( - _Event(timestamp="2026-06-26T14:02:31.123456+00:00", kind="assistant_text", message="hi") - ) - assert row.time == "14:02:31" + def test_formats_time_kind_and_message(self, local_timezone): + with local_timezone("Europe/Warsaw"): # UTC+2 in June + row = render.stream_row( + _Event( + timestamp="2026-06-26T14:02:31.123456+00:00", + kind="assistant_text", + message="hi", + ) + ) + assert row.time == "16:02:31" assert row.kind == "assistant_text" assert row.message == "hi" assert row.label == "" @@ -397,12 +430,13 @@ def _event(ws="ws-01-1", kind="agent_crash", message="connection lost", phase=12 class TestAgentErrorEventRows: - def test_parses_events_into_rows(self): + def test_parses_events_into_rows(self, local_timezone): payload = {"agent_error_events": [_event()]} - rows = render.agent_error_event_rows(payload) + with local_timezone("Europe/Warsaw"): # UTC+2 in July + rows = render.agent_error_event_rows(payload) assert len(rows) == 1 row = rows[0] - assert row.time == "18:28:40" + assert row.time == "20:28:40" assert row.workspace_id == "ws-01-1" assert row.phase_label == "phase 12" assert row.kind == "agent_crash" diff --git a/mcp_server/tui/app.py b/mcp_server/tui/app.py index f3f9968..715861c 100644 --- a/mcp_server/tui/app.py +++ b/mcp_server/tui/app.py @@ -30,7 +30,6 @@ import json import shutil import sys -from datetime import datetime, timezone from pathlib import Path from typing import Any @@ -942,15 +941,11 @@ def _session_label(s: dict) -> str: checkpoint_key, ) - # Date from ISO created_at ("2026-06-30T14:23:45+00:00" → "Jun 30 14:23") + # Local-time date from ISO created_at ("2026-06-30T14:23:45+00:00" → "Jun 30 16:23" in CEST) created_at_raw = s.get("created_at", "") - date_str = "" - if created_at_raw: - try: - dt = datetime.fromisoformat(created_at_raw).astimezone(timezone.utc) - date_str = dt.strftime("%b %d %H:%M") - except ValueError: - date_str = created_at_raw[:16] + date_str = render.format_local(created_at_raw, render.LOCAL_DATE_TIME_FORMAT) + if created_at_raw and not date_str: + date_str = created_at_raw[:16] # Cancellations are always user-initiated; spell that out in the list. status_word = "CANCELLED BY USER" if status_key == "cancelled" else status_key.upper() diff --git a/mcp_server/tui/render.py b/mcp_server/tui/render.py index 60fd3ce..d7f00a7 100644 --- a/mcp_server/tui/render.py +++ b/mcp_server/tui/render.py @@ -12,7 +12,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from datetime import datetime +from datetime import datetime, timezone from typing import Any from tui.constants import ( @@ -323,14 +323,27 @@ class StreamRow: message: str -def _hhmmss(timestamp: str | None) -> str: - """Format an ISO timestamp as HH:MM:SS; "" when missing/unparseable.""" +LOCAL_TIME_FORMAT = "%H:%M:%S" +LOCAL_DATE_TIME_FORMAT = "%b %d %H:%M" + + +def format_local(timestamp: str | None, fmt: str = LOCAL_TIME_FORMAT) -> str: + """Format a backend ISO timestamp in the viewer's local timezone. + + Backend timestamps are UTC; an offset-naive value is therefore read as UTC + rather than local, so a clock time is never shifted twice. Returns "" when + the value is missing or unparseable — the single place the TUI turns a + timestamp into a displayable clock time. + """ if not timestamp: return "" try: - return datetime.fromisoformat(timestamp).strftime("%H:%M:%S") + parsed = datetime.fromisoformat(timestamp) except ValueError: return "" + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone().strftime(fmt) def stream_row(event: Any) -> StreamRow: @@ -341,7 +354,7 @@ def stream_row(event: Any) -> StreamRow: """ label = getattr(event, "subagent_name", None) or getattr(event, "tool_name", None) or "" return StreamRow( - time=_hhmmss(getattr(event, "timestamp", None)), + time=format_local(getattr(event, "timestamp", None)), kind=str(getattr(event, "kind", None) or "unknown"), label=str(label), message=str(getattr(event, "message", None) or ""), @@ -487,7 +500,7 @@ def agent_error_event_rows(payload: dict[str, Any] | None) -> list[AgentErrorEve phase = event.get("phase") rows.append( AgentErrorEventRow( - time=_hhmmss(event.get("at")), + time=format_local(event.get("at")), workspace_id=str(event.get("workspace_id") or ""), phase_label=f"phase {phase}" if phase is not None else "", kind=str(event.get("kind") or ""), From c95133af0ff5004a8180ce3f37ed1c41cbec23df Mon Sep 17 00:00:00 2001 From: Mateusz Konopelski Date: Tue, 28 Jul 2026 17:37:21 +0200 Subject: [PATCH 2/3] fix(tui): drop the UTC clock from the retry stream line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The retry line published to the live feed spelled out "next attempt at 15:26 UTC" — the only absolute clock time left in the TUI after moving the timestamp columns to local time, and rendered right next to a local-clock timestamp. It cannot be converted server-side either: the backend runs in a container whose zone is UTC regardless of the host, so astimezone() there would print the same digits with the label removed. Keep the relative wait, which needs no timezone at all. The absolute instant is still on the event's next_attempt_at field if the TUI ever wants to show it in local time. --- backend/app/services/claude_code.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/backend/app/services/claude_code.py b/backend/app/services/claude_code.py index 9145013..14b6ff8 100644 --- a/backend/app/services/claude_code.py +++ b/backend/app/services/claude_code.py @@ -620,10 +620,13 @@ async def _crash_backoff_and_wait( ) if publisher is not None: publisher.publish_line_nowait("error", error_message) + # Relative wait only: this line is rendered in the TUI feed next to a + # local-clock timestamp, and the backend clock (a container, usually UTC) + # is not the viewer's. The absolute instant stays on the event's + # ``next_attempt_at`` field, which the TUI can format locally. publisher.publish_line_nowait( "system", - f"waiting {_format_wait(wait)} before retry ({resume.describe()}), " - f"next attempt at {next_attempt_at:%H:%M} UTC", + f"waiting {_format_wait(wait)} before retry ({resume.describe()})", ) await set_workspace_retry_state_safe(WorkspaceAgentState.RETRYING) await asyncio.sleep(wait.total_seconds()) From 2b0f609fdf06764937f96d65e41385dad129f4d0 Mon Sep 17 00:00:00 2001 From: Mateusz Konopelski Date: Tue, 28 Jul 2026 17:41:09 +0200 Subject: [PATCH 3/3] refactor: trim comments on the local-time formatter to one line --- backend/app/services/claude_code.py | 6 ++---- mcp_server/tests/conftest.py | 6 +----- mcp_server/tui/render.py | 8 +------- 3 files changed, 4 insertions(+), 16 deletions(-) diff --git a/backend/app/services/claude_code.py b/backend/app/services/claude_code.py index 14b6ff8..61a4a32 100644 --- a/backend/app/services/claude_code.py +++ b/backend/app/services/claude_code.py @@ -620,10 +620,8 @@ async def _crash_backoff_and_wait( ) if publisher is not None: publisher.publish_line_nowait("error", error_message) - # Relative wait only: this line is rendered in the TUI feed next to a - # local-clock timestamp, and the backend clock (a container, usually UTC) - # is not the viewer's. The absolute instant stays on the event's - # ``next_attempt_at`` field, which the TUI can format locally. + # Relative wait only — the container clock is not the TUI viewer's; the + # absolute instant stays on the event's ``next_attempt_at``. publisher.publish_line_nowait( "system", f"waiting {_format_wait(wait)} before retry ({resume.describe()})", diff --git a/mcp_server/tests/conftest.py b/mcp_server/tests/conftest.py index 5d46302..4cd6d2f 100644 --- a/mcp_server/tests/conftest.py +++ b/mcp_server/tests/conftest.py @@ -10,11 +10,7 @@ @contextmanager def _pinned_timezone(name: str) -> Iterator[None]: - """Run the block with the process timezone pinned to ``name``. - - ``datetime.astimezone()`` reads the process timezone, so local-time - assertions are only deterministic while it is pinned. - """ + """Run the block with the process timezone pinned, so local-time assertions are stable.""" previous = os.environ.get("TZ") os.environ["TZ"] = name time.tzset() diff --git a/mcp_server/tui/render.py b/mcp_server/tui/render.py index d7f00a7..34d9772 100644 --- a/mcp_server/tui/render.py +++ b/mcp_server/tui/render.py @@ -328,13 +328,7 @@ class StreamRow: def format_local(timestamp: str | None, fmt: str = LOCAL_TIME_FORMAT) -> str: - """Format a backend ISO timestamp in the viewer's local timezone. - - Backend timestamps are UTC; an offset-naive value is therefore read as UTC - rather than local, so a clock time is never shifted twice. Returns "" when - the value is missing or unparseable — the single place the TUI turns a - timestamp into a displayable clock time. - """ + """Format a backend ISO timestamp (UTC, naive or aware) in local time; "" when unusable.""" if not timestamp: return "" try: