From e1a1724e26b72b0948f907a29281e8a5994d7c3b Mon Sep 17 00:00:00 2001 From: Saturday-boyi <2174084306@qq.com> Date: Tue, 8 Sep 2026 08:59:41 +0800 Subject: [PATCH] Fix mixed timestamp timezone parsing --- README.md | 3 ++ agentrace/parse.py | 8 ++++-- tests/test_timestamps.py | 60 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 tests/test_timestamps.py diff --git a/README.md b/README.md index 54fa158..183e8f1 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,9 @@ Zero dependencies beyond `rich`. No API keys, no network: it reads local files. ## Design notes +**Timestamps without an offset are treated as UTC.** Explicit offsets are respected when sorting +runs and calculating durations. Runs with missing or invalid timestamps sort before dated runs. + **Two passes over the transcript, not one.** Results can appear before every use has been seen in unusual orderings. A 34MB file is cheap to scan twice compared to getting the pairing subtly wrong. diff --git a/agentrace/parse.py b/agentrace/parse.py index 162e1ab..79b653c 100644 --- a/agentrace/parse.py +++ b/agentrace/parse.py @@ -17,7 +17,7 @@ import json from dataclasses import dataclass, field -from datetime import datetime +from datetime import UTC, datetime from pathlib import Path from typing import Iterator @@ -75,7 +75,9 @@ def _ts(value: str | None) -> datetime | None: if not value: return None try: - return datetime.fromisoformat(value.replace("Z", "+00:00")) + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + # Treat missing offsets as UTC, independently of the reader's local timezone. + return parsed.replace(tzinfo=UTC) if parsed.tzinfo is None else parsed except ValueError: return None @@ -159,7 +161,7 @@ def parse_session(path: Path) -> Session: ) ) - runs.sort(key=lambda r: (r.started_at or datetime.min.replace(tzinfo=None), r.tool_use_id)) + runs.sort(key=lambda r: (r.started_at or datetime.min.replace(tzinfo=UTC), r.tool_use_id)) return Session(session_id=session_id, path=path, runs=runs) diff --git a/tests/test_timestamps.py b/tests/test_timestamps.py new file mode 100644 index 0000000..d81c6c0 --- /dev/null +++ b/tests/test_timestamps.py @@ -0,0 +1,60 @@ +"""Timestamp ordering and duration regressions for parsed transcripts.""" + +import json +from datetime import timedelta + +import pytest + +from agentrace.parse import parse_session + + +@pytest.mark.parametrize("earlier_timestamp", ["2026-09-08T10:00:00", "2026-09-08T11:00:00+01:00"]) +def test_mixed_timezones_sort_by_instant(tmp_path, earlier_timestamp): + transcript = tmp_path / "mixed.jsonl" + records = [ + { + "timestamp": timestamp, + "message": {"content": [{"type": "tool_use", "name": "Agent", "id": run_id}]}, + } + for run_id, timestamp in [ + ("later", "2026-09-08T10:01:00Z"), + ("earlier", earlier_timestamp), + ] + ] + records.append( + { + "timestamp": "2026-09-08T10:00:30Z", + "message": { + "content": [{"type": "tool_result", "tool_use_id": "earlier", "content": "done"}] + }, + } + ) + transcript.write_text("\n".join(json.dumps(record) for record in records)) + + runs = parse_session(transcript).runs + + assert [run.tool_use_id for run in runs] == ["earlier", "later"] + assert runs[0].duration_s == 30 + assert runs[0].started_at.utcoffset() is not None + assert runs[1].started_at.utcoffset() == timedelta(0) + + +@pytest.mark.parametrize("missing_timestamp", [None, "not-a-timestamp"]) +def test_missing_timestamp_sorts_before_aware_timestamp(tmp_path, missing_timestamp): + transcript = tmp_path / "missing.jsonl" + records = [ + { + "timestamp": timestamp, + "message": {"content": [{"type": "tool_use", "name": "Agent", "id": run_id}]}, + } + for run_id, timestamp in [ + ("known", "2026-09-08T10:00:00Z"), + ("unknown", missing_timestamp), + ] + ] + transcript.write_text("\n".join(json.dumps(record) for record in records)) + + runs = parse_session(transcript).runs + + assert [run.tool_use_id for run in runs] == ["unknown", "known"] + assert runs[0].duration_s is None