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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
8 changes: 5 additions & 3 deletions agentrace/parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)


Expand Down
60 changes: 60 additions & 0 deletions tests/test_timestamps.py
Original file line number Diff line number Diff line change
@@ -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
Loading