Skip to content
Open
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
10 changes: 9 additions & 1 deletion pyrit/backend/mappers/attack_mappers.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,9 +199,17 @@ def attack_result_to_summary(
last_preview = stats.last_message_preview
labels = dict(stats.labels) if stats.labels else {}

# Resolution order for created_at: explicit metadata override, then the
# persisted AttackResult.timestamp, and finally datetime.now() as a
# last-resort fallback for never-persisted results.
created_str = ar.metadata.get("created_at")
updated_str = ar.metadata.get("updated_at")
created_at = datetime.fromisoformat(created_str) if created_str else datetime.now(timezone.utc)
if created_str:
created_at = datetime.fromisoformat(created_str)
elif ar.timestamp is not None:
created_at = ar.timestamp
else:
created_at = datetime.now(timezone.utc)
updated_at = datetime.fromisoformat(updated_str) if updated_str else created_at

aid = ar.get_attack_strategy_identifier()
Expand Down
1 change: 1 addition & 0 deletions pyrit/memory/memory_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -894,6 +894,7 @@ def get_attack_result(self) -> AttackResult:
outcome_reason=self.outcome_reason,
related_conversations=related_conversations,
metadata=self.attack_metadata or {},
timestamp=_ensure_utc(self.timestamp),
)


Expand Down
8 changes: 8 additions & 0 deletions pyrit/models/attack_result.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
from pyrit.models.strategy_result import StrategyResult

if TYPE_CHECKING:
from datetime import datetime

from pyrit.identifiers.component_identifier import ComponentIdentifier
from pyrit.models.conversation_reference import ConversationReference
from pyrit.models.message_piece import MessagePiece
Expand Down Expand Up @@ -81,6 +83,12 @@ class AttackResult(StrategyResult):
# Optional reason for the outcome, providing additional context
outcome_reason: Optional[str] = None

# Wall-clock time the result was persisted. Hydrated from
# AttackResultEntries.timestamp when loaded from memory; None when the
# AttackResult has never been persisted. Downstream consumers (e.g. the
# backend mapper) use this to populate user-facing creation times.
timestamp: Optional[datetime] = None

# Flexible conversation refs (nothing unused)
related_conversations: set[ConversationReference] = field(default_factory=set)

Expand Down
42 changes: 42 additions & 0 deletions tests/unit/backend/test_mappers.py
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,48 @@ def test_message_count_from_stats(self) -> None:

assert summary.message_count == 5

def test_created_at_prefers_ar_timestamp_when_metadata_absent(self) -> None:
"""When metadata['created_at'] is absent but ar.timestamp is set, use ar.timestamp."""
persisted_ts = datetime(2026, 4, 17, 12, 0, 0, tzinfo=timezone.utc)
ar = AttackResult(
conversation_id="attack-1",
objective="test",
outcome=AttackOutcome.SUCCESS,
timestamp=persisted_ts,
)
summary = attack_result_to_summary(ar, stats=ConversationStats(message_count=0))

assert summary.created_at == persisted_ts
assert summary.updated_at == persisted_ts

def test_created_at_metadata_still_wins_over_ar_timestamp(self) -> None:
"""When both metadata['created_at'] and ar.timestamp are set, metadata wins (backward compat)."""
metadata_ts = datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
ar_ts = datetime(2026, 4, 17, 12, 0, 0, tzinfo=timezone.utc)
ar = AttackResult(
conversation_id="attack-1",
objective="test",
outcome=AttackOutcome.SUCCESS,
timestamp=ar_ts,
metadata={"created_at": metadata_ts.isoformat()},
)
summary = attack_result_to_summary(ar, stats=ConversationStats(message_count=0))

assert summary.created_at == metadata_ts

def test_created_at_falls_back_to_now_when_both_absent(self) -> None:
"""When neither metadata nor ar.timestamp is set, fall back to datetime.now()."""
ar = AttackResult(
conversation_id="attack-1",
objective="test",
outcome=AttackOutcome.SUCCESS,
)
before = datetime.now(timezone.utc)
summary = attack_result_to_summary(ar, stats=ConversationStats(message_count=0))
after = datetime.now(timezone.utc)

assert before <= summary.created_at <= after

"""Tests for pyrit_scores_to_dto function."""

def test_maps_scores(self) -> None:
Expand Down
48 changes: 47 additions & 1 deletion tests/unit/models/test_attack_result.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
# Licensed under the MIT license.

import warnings
from datetime import datetime, timezone

from pyrit.identifiers import ComponentIdentifier
from pyrit.identifiers.atomic_attack_identifier import build_atomic_attack_identifier
from pyrit.models.attack_result import AttackResult
from pyrit.memory.memory_models import AttackResultEntry
from pyrit.models.attack_result import AttackOutcome, AttackResult


class TestAttackResultDeprecation:
Expand Down Expand Up @@ -133,3 +135,47 @@ def test_constructor_with_no_identifier_at_all(self) -> None:
result = AttackResult(conversation_id="c1", objective="test")
assert result.atomic_attack_identifier is None
assert result.get_attack_strategy_identifier() is None


class TestAttackResultTimestamp:
"""Tests for the AttackResult.timestamp field and its round-trip through AttackResultEntry."""

def test_timestamp_defaults_to_none_when_not_set(self) -> None:
"""AttackResult constructed without a timestamp exposes the field as None."""
result = AttackResult(conversation_id="c1", objective="test")
assert result.timestamp is None

def test_timestamp_accepts_and_preserves_aware_datetime(self) -> None:
"""A tz-aware datetime passed to the constructor is stored as-is."""
ts = datetime(2026, 4, 17, 12, 0, 0, tzinfo=timezone.utc)
result = AttackResult(conversation_id="c1", objective="test", timestamp=ts)
assert result.timestamp == ts

def test_timestamp_roundtrips_through_attack_result_entry(self) -> None:
"""AttackResultEntry.timestamp is surfaced on the hydrated AttackResult."""
original = AttackResult(
conversation_id="c1",
objective="test",
outcome=AttackOutcome.SUCCESS,
)
entry = AttackResultEntry(entry=original)
persisted_ts = datetime(2026, 4, 17, 12, 0, 0, tzinfo=timezone.utc)
entry.timestamp = persisted_ts

hydrated = entry.get_attack_result()

assert hydrated.timestamp == persisted_ts

def test_naive_entry_timestamp_is_normalized_to_utc_on_hydration(self) -> None:
"""SQLite returns naive datetimes; hydration must attach UTC tzinfo."""
original = AttackResult(conversation_id="c1", objective="test")
entry = AttackResultEntry(entry=original)
# Simulate a SQLite-backed row: naive datetime, no tzinfo. The whole
# point of this test is to exercise that path, so DTZ001 is suppressed.
entry.timestamp = datetime(2026, 4, 17, 12, 0, 0) # noqa: DTZ001

hydrated = entry.get_attack_result()

assert hydrated.timestamp is not None
assert hydrated.timestamp.tzinfo is timezone.utc
assert hydrated.timestamp.replace(tzinfo=None) == datetime(2026, 4, 17, 12, 0, 0) # noqa: DTZ001