From f99e78272bb68f7bb1cc55b25707372196f2a6f5 Mon Sep 17 00:00:00 2001 From: evalstate <1936278+evalstate@users.noreply.github.com> Date: Wed, 22 Apr 2026 23:12:15 +0100 Subject: [PATCH] allow new/old session dates, tool call export errors --- src/fast_agent/session/snapshot.py | 17 ++- src/fast_agent/session/trace_export_codex.py | 5 + .../session/test_session_manager.py | 32 +++++ .../fast_agent/session/test_trace_exporter.py | 113 +++++++++++++++++- 4 files changed, 162 insertions(+), 5 deletions(-) diff --git a/src/fast_agent/session/snapshot.py b/src/fast_agent/session/snapshot.py index 596216063..5273315a4 100644 --- a/src/fast_agent/session/snapshot.py +++ b/src/fast_agent/session/snapshot.py @@ -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 @@ -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: @@ -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, @@ -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, diff --git a/src/fast_agent/session/trace_export_codex.py b/src/fast_agent/session/trace_export_codex.py index 24554b8d3..f2dc4efba 100644 --- a/src/fast_agent/session/trace_export_codex.py +++ b/src/fast_agent/session/trace_export_codex.py @@ -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 @@ -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 diff --git a/tests/unit/fast_agent/session/test_session_manager.py b/tests/unit/fast_agent/session/test_session_manager.py index 4970fdb29..3ba113f38 100644 --- a/tests/unit/fast_agent/session/test_session_manager.py +++ b/tests/unit/fast_agent/session/test_session_manager.py @@ -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, diff --git a/tests/unit/fast_agent/session/test_trace_exporter.py b/tests/unit/fast_agent/session/test_trace_exporter.py index 8ceb7f0b4..77980d1f4 100644 --- a/tests/unit/fast_agent/session/test_trace_exporter.py +++ b/tests/unit/fast_agent/session/test_trace_exporter.py @@ -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( @@ -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" @@ -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: @@ -736,6 +844,7 @@ def test_session_trace_exporter_preserves_non_text_tool_outputs(tmp_path: Path) "file_data": "d2F2", }, ], + "status": "success", } @@ -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", } @@ -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", }