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
17 changes: 13 additions & 4 deletions src/fast_agent/session/snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import json
import warnings
from collections.abc import Mapping
from datetime import datetime
from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING, Literal, Protocol, runtime_checkable

Expand Down Expand Up @@ -207,7 +207,10 @@ def load_session_snapshot(payload: object) -> SessionSnapshot:
return synthesize_legacy_session_snapshot(payload_mapping)
if raw_schema_version != SESSION_SNAPSHOT_SCHEMA_VERSION:
raise ValueError(f"Unsupported session snapshot schema version: {raw_schema_version!r}")
return SessionSnapshot.model_validate(payload_mapping)
snapshot = SessionSnapshot.model_validate(payload_mapping)
snapshot.created_at = _normalize_session_timestamp(snapshot.created_at)
snapshot.last_activity = _normalize_session_timestamp(snapshot.last_activity)
return snapshot


def synthesize_legacy_session_snapshot(payload: Mapping[str, object]) -> SessionSnapshot:
Expand Down Expand Up @@ -411,10 +414,10 @@ def _legacy_timestamp(
fallback: datetime,
) -> datetime:
if isinstance(value, datetime):
return value
return _normalize_session_timestamp(value)
if isinstance(value, str):
try:
return datetime.fromisoformat(value)
return _normalize_session_timestamp(datetime.fromisoformat(value))
except ValueError:
_warn_legacy_issue(
session_id,
Expand All @@ -429,6 +432,12 @@ def _legacy_timestamp(
return fallback


def _normalize_session_timestamp(value: datetime) -> datetime:
if value.tzinfo is None or value.utcoffset() is None:
return value
return value.astimezone(timezone.utc).replace(tzinfo=None)


def _legacy_metadata_extras(
metadata: Mapping[str, object] | None,
session_id: str,
Expand Down
5 changes: 5 additions & 0 deletions src/fast_agent/session/trace_export_codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,10 @@ def flush_text_parts() -> None:
return ""


def _tool_result_status(result: CallToolResult) -> str:
return "error" if result.isError else "success"


def _object_mapping(value: object) -> dict[str, object] | None:
if not isinstance(value, dict):
return None
Expand Down Expand Up @@ -508,6 +512,7 @@ def _function_call_output_items(message: PromptMessageExtended) -> list[dict[str
"type": "function_call_output",
"call_id": call_id,
"output": _tool_result_output(result),
"status": _tool_result_status(result),
}
)
return items
Expand Down
32 changes: 32 additions & 0 deletions tests/unit/fast_agent/session/test_session_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,38 @@ def test_load_session_marks_loaded_session_as_latest(tmp_path) -> None:
reset_session_manager()


def test_list_sessions_normalizes_timezone_aware_timestamps(tmp_path) -> None:
manager = SessionManager(
cwd=tmp_path,
environment_override=tmp_path / ".fast-agent",
respect_env_override=False,
)
older = manager.create_session()
newer = manager.create_session()

older_payload = json.loads((older.directory / "session.json").read_text(encoding="utf-8"))
older_payload["created_at"] = "2026-04-22T21:00:41.016804"
older_payload["last_activity"] = "2026-04-22T21:00:41.016804"
(older.directory / "session.json").write_text(
json.dumps(older_payload, indent=2),
encoding="utf-8",
)

newer_payload = json.loads((newer.directory / "session.json").read_text(encoding="utf-8"))
newer_payload["created_at"] = "2026-04-22T21:01:41.016804Z"
newer_payload["last_activity"] = "2026-04-22T21:01:41.016804Z"
(newer.directory / "session.json").write_text(
json.dumps(newer_payload, indent=2),
encoding="utf-8",
)

sessions = manager.list_sessions()

assert [session.name for session in sessions] == [newer.info.name, older.info.name]
assert sessions[0].created_at.tzinfo is None
assert sessions[0].last_activity.tzinfo is None


@pytest.mark.asyncio
async def test_resume_session_agents_uses_hydrator_active_agent_and_prompt_restore(
tmp_path,
Expand Down
113 changes: 112 additions & 1 deletion tests/unit/fast_agent/session/test_trace_exporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
SessionExportWriteError,
)
from fast_agent.session.trace_export_models import DatasetUploadResult, ExportRequest
from fast_agent.types import LlmStopReason
from fast_agent.types import FINAL_ANSWER_PHASE, LlmStopReason


def _write_session_snapshot(
Expand Down Expand Up @@ -185,6 +185,7 @@ def test_session_trace_exporter_writes_codex_trace(tmp_path: Path) -> None:
assert records[7]["payload"]["role"] == "assistant"
assert records[7]["payload"]["content"] == [{"type": "output_text", "text": "done"}]
assert records[7]["payload"]["end_turn"] is True
assert "phase" not in records[7]["payload"]
assert records[8]["type"] == "event_msg"
assert "timestamp" not in records[8]
assert records[8]["payload"]["type"] == "turn_complete"
Expand Down Expand Up @@ -408,12 +409,119 @@ def test_session_trace_exporter_writes_native_codex_tool_items(tmp_path: Path) -
"type": "function_call_output",
"call_id": "call_1",
"output": "process exit code was 0",
"status": "success",
}
assert records[6]["type"] == "event_msg"
assert records[6]["payload"]["type"] == "turn_complete"
assert records[6]["payload"]["last_agent_message"] == "Using tools"


def test_session_trace_exporter_marks_tool_errors_in_codex_output(tmp_path: Path) -> None:
manager = _build_manager(tmp_path)
session_id = "2604201303-x5MNlH"
session_dir = manager.base_dir / session_id
session_dir.mkdir(parents=True)
messages = [
PromptMessageExtended(
role="assistant",
content=[TextContent(type="text", text="Using tools")],
tool_calls={
"call_1": CallToolRequest(
method="tools/call",
params=CallToolRequestParams(
name="execute",
arguments={"command": "false"},
),
)
},
stop_reason=LlmStopReason.TOOL_USE,
),
PromptMessageExtended(
role="user",
content=[],
tool_results={
"call_1": CallToolResult(
content=[TextContent(type="text", text="process exit code was 1")],
isError=True,
)
},
),
]
save_json(messages, str(session_dir / "history_dev.json"))
_write_session_snapshot(
session_dir,
session_id=session_id,
active_agent="dev",
agents={"dev": SessionAgentSnapshot(history_file="history_dev.json")},
)

exporter = SessionTraceExporter(session_manager=manager)
exporter.export(
ExportRequest(
target=session_dir,
agent_name="dev",
output_path=tmp_path / "trace.jsonl",
)
)

records = [
json.loads(line)
for line in (tmp_path / "trace.jsonl").read_text(encoding="utf-8").splitlines()
]

assert records[5]["type"] == "response_item"
assert records[5]["payload"] == {
"type": "function_call_output",
"call_id": "call_1",
"output": "process exit code was 1",
"status": "error",
}


def test_session_trace_exporter_preserves_explicit_assistant_phase(tmp_path: Path) -> None:
manager = _build_manager(tmp_path)
session_id = "2604201303-x5MNlH"
session_dir = manager.base_dir / session_id
session_dir.mkdir(parents=True)
messages = [
PromptMessageExtended(
role="user",
content=[TextContent(type="text", text="hello")],
),
PromptMessageExtended(
role="assistant",
content=[TextContent(type="text", text="done")],
phase=FINAL_ANSWER_PHASE,
stop_reason=LlmStopReason.END_TURN,
),
]
save_json(messages, str(session_dir / "history_dev.json"))
_write_session_snapshot(
session_dir,
session_id=session_id,
active_agent="dev",
agents={"dev": SessionAgentSnapshot(history_file="history_dev.json")},
)

exporter = SessionTraceExporter(session_manager=manager)
exporter.export(
ExportRequest(
target=session_dir,
agent_name="dev",
output_path=tmp_path / "trace.jsonl",
)
)

records = [
json.loads(line)
for line in (tmp_path / "trace.jsonl").read_text(encoding="utf-8").splitlines()
]

assert records[5]["payload"]["type"] == "message"
assert records[5]["payload"]["role"] == "assistant"
assert records[5]["payload"]["phase"] == FINAL_ANSWER_PHASE


def test_session_trace_exporter_serializes_zero_argument_tool_calls_as_empty_object(
tmp_path: Path,
) -> None:
Expand Down Expand Up @@ -736,6 +844,7 @@ def test_session_trace_exporter_preserves_non_text_tool_outputs(tmp_path: Path)
"file_data": "d2F2",
},
],
"status": "success",
}


Expand Down Expand Up @@ -826,6 +935,7 @@ def test_session_trace_exporter_preserves_tool_output_item_order(tmp_path: Path)
"file_url": "https://example.com/audio.mp3",
},
],
"status": "success",
}


Expand Down Expand Up @@ -893,6 +1003,7 @@ def test_session_trace_exporter_preserves_user_content_alongside_tool_outputs(tm
"type": "function_call_output",
"call_id": "call_1",
"output": "process exit code was 0",
"status": "success",
}


Expand Down
Loading