diff --git a/backend/app/services/claude_code.py b/backend/app/services/claude_code.py index 9145013..61a4a32 100644 --- a/backend/app/services/claude_code.py +++ b/backend/app/services/claude_code.py @@ -620,10 +620,11 @@ async def _crash_backoff_and_wait( ) if publisher is not None: publisher.publish_line_nowait("error", error_message) + # 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()}), " - 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()) diff --git a/mcp_server/tests/conftest.py b/mcp_server/tests/conftest.py new file mode 100644 index 0000000..4cd6d2f --- /dev/null +++ b/mcp_server/tests/conftest.py @@ -0,0 +1,30 @@ +"""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, so local-time assertions are stable.""" + 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..34d9772 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,21 @@ 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 (UTC, naive or aware) in local time; "" when unusable.""" 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 +348,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 +494,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 ""),