Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions backend/app/services/claude_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
30 changes: 30 additions & 0 deletions mcp_server/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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
25 changes: 25 additions & 0 deletions mcp_server/tests/test_tui_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand Down
50 changes: 42 additions & 8 deletions mcp_server/tests/test_tui_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 == ""
Expand Down Expand Up @@ -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"
Expand Down
13 changes: 4 additions & 9 deletions mcp_server/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@
import json
import shutil
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

Expand Down Expand Up @@ -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()
Expand Down
19 changes: 13 additions & 6 deletions mcp_server/tui/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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:
Expand All @@ -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 ""),
Expand Down Expand Up @@ -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 ""),
Expand Down