Skip to content

Message Model

Represents an individual message within a conversation.

Overview

The Message model represents a single message in a conversation, including the role (user/assistant/system), content, timestamp, and parent relationship for tree navigation.

API Reference

Message

Bases: BaseModel

Immutable message structure from conversation export (per FR-223, FR-227).

This model represents a single message in an AI conversation, supporting message threading via parent_id references. Messages form a tree structure within conversations, enabling branching dialogue paths.

Immutability

This model is FROZEN - attempting to modify fields will raise ValidationError. Use .model_copy(update={...}) to create modified instances.

Example
from datetime import datetime, UTC

message = Message(
    id="msg-001",
    content="Hello, world!",
    role="user",
    timestamp=datetime.now(UTC),
    parent_id=None  # Root message
)

# Create a reply
reply = Message(
    id="msg-002",
    content="Hi! How can I help?",
    role="assistant",
    timestamp=datetime.now(UTC),
    parent_id="msg-001"  # References parent
)
Role Normalization

The role field is normalized to one of three standard values for multi-provider consistency. Provider-specific roles are mapped as follows:

OpenAI role mappings: - "user" → "user" (human input) - "assistant" → "assistant" (AI response) - "system" → "system" (system messages) - "tool" → "assistant" (tool execution is assistant action) - unknown roles → "assistant" (safe fallback)

The original provider-specific role is preserved in metadata["original_role"] for debugging and provider-specific workflows.

Attributes:

Name Type Description
id str

Unique message identifier within conversation (non-empty string)

content str

Message text content (may be empty for deleted messages)

role Literal['user', 'assistant', 'system']

Normalized message role (user, assistant, or system)

timestamp datetime

Message creation time (timezone-aware UTC)

parent_id str | None

Parent message ID for threading (None for root messages)

images list[ImageRef]

Image attachments extracted from multimodal content (empty for text-only)

metadata dict[str, Any]

Provider-specific fields (e.g., original_role, token_count)

validate_timezone_aware classmethod

validate_timezone_aware(v: datetime) -> datetime

Ensure timestamp is timezone-aware and normalized to UTC.

Parameters:

Name Type Description Default
v datetime

Timestamp value to validate

required

Returns:

Type Description
datetime

Timezone-aware datetime normalized to UTC

Raises:

Type Description
ValueError

If timestamp is timezone-naive

Requirements
  • FR-244: Timestamps must be timezone-aware
  • FR-245: Timestamps normalized to UTC
  • FR-246: Validation enforced at parse time
Source code in src/echomine/models/message.py
@field_validator("timestamp")
@classmethod
def validate_timezone_aware(cls, v: datetime) -> datetime:
    """Ensure timestamp is timezone-aware and normalized to UTC.

    Args:
        v: Timestamp value to validate

    Returns:
        Timezone-aware datetime normalized to UTC

    Raises:
        ValueError: If timestamp is timezone-naive

    Requirements:
        - FR-244: Timestamps must be timezone-aware
        - FR-245: Timestamps normalized to UTC
        - FR-246: Validation enforced at parse time
    """
    if v.tzinfo is None or v.tzinfo.utcoffset(v) is None:
        msg = f"Timestamp must be timezone-aware: {v}"
        raise ValueError(msg)
    return v.astimezone(UTC)  # Normalize to UTC

is_root

is_root() -> bool

Check if message is conversation root (no parent).

Returns:

Type Description
bool

True if parent_id is None, False otherwise

Example
root_msg = Message(id="1", content="Hello", role="user", timestamp=now, parent_id=None)
reply_msg = Message(id="2", content="Hi", role="assistant", timestamp=now, parent_id="1")

assert root_msg.is_root() is True
assert reply_msg.is_root() is False
Source code in src/echomine/models/message.py
def is_root(self) -> bool:
    """Check if message is conversation root (no parent).

    Returns:
        True if parent_id is None, False otherwise

    Example:
        ```python
        root_msg = Message(id="1", content="Hello", role="user", timestamp=now, parent_id=None)
        reply_msg = Message(id="2", content="Hi", role="assistant", timestamp=now, parent_id="1")

        assert root_msg.is_root() is True
        assert reply_msg.is_root() is False
        ```
    """
    return self.parent_id is None

Usage Examples

Basic Access

from echomine import OpenAIAdapter
from pathlib import Path

adapter = OpenAIAdapter()
conversation = adapter.get_conversation_by_id(Path("export.json"), "conv-abc123")

# Access messages
for message in conversation.messages:
    print(f"[{message.timestamp}] {message.role}: {message.content[:50]}...")

Role Types

Messages have normalized roles:

from typing import Literal

# Message.role is Literal["user", "assistant", "system"]
for message in conversation.messages:
    if message.role == "user":
        print(f"User: {message.content}")
    elif message.role == "assistant":
        print(f"AI: {message.content}")
    elif message.role == "system":
        print(f"System: {message.content}")
    # No other values possible - type safety guaranteed!

Timestamp Handling

All timestamps are timezone-aware UTC datetimes:

from datetime import timezone

for message in conversation.messages:
    # Guaranteed to be UTC and timezone-aware
    assert message.timestamp.tzinfo == timezone.utc

    # Safe to compare and serialize
    print(f"{message.timestamp.isoformat()}: {message.content}")

# Convert to local timezone for display
import datetime
local_tz = datetime.datetime.now().astimezone().tzinfo
for msg in conversation.messages:
    local_time = msg.timestamp.astimezone(local_tz)
    print(f"[{local_time}] {msg.role}: {msg.content[:30]}...")

Parent-Child Relationships

Messages are organized in a tree structure:

# Check if message is root (conversation starter)
is_root = message.parent_id is None

# Find message's parent
parent_id = message.parent_id
if parent_id:
    parent = next((m for m in conversation.messages if m.id == parent_id), None)
    if parent:
        print(f"Parent: {parent.content[:50]}...")

# Find message's children
children = [m for m in conversation.messages if m.parent_id == message.id]
print(f"Message has {len(children)} children")

Immutability

Messages are frozen (immutable):

from pydantic import ValidationError

# ❌ This raises ValidationError
try:
    message.content = "New content"
except ValidationError:
    print("Error: Cannot modify frozen model")

# ✅ Create modified copy instead
updated = message.model_copy(update={"content": "New content"})

Validation

All fields are strictly validated:

from echomine.models import Message
from datetime import datetime, timezone
from pydantic import ValidationError

# ❌ Invalid: naive timestamp (no timezone)
try:
    invalid = Message(
        id="msg-123",
        content="Hello",
        role="user",
        timestamp=datetime.now(),  # ❌ Not timezone-aware!
        parent_id=None
    )
except ValidationError as e:
    print(f"Error: {e}")

# ✅ Valid: timezone-aware UTC timestamp
valid = Message(
    id="msg-123",
    content="Hello",
    role="user",
    timestamp=datetime.now(timezone.utc),
    parent_id=None
)

Model Fields

Required Fields

  • id (str): Unique message identifier (non-empty)
  • content (str): Message text content
  • role (Literal["user", "assistant", "system"]): Message role (normalized)
  • timestamp (datetime): Message timestamp (UTC, timezone-aware)

Optional Fields

  • parent_id (str | None): ID of parent message, None for root messages
  • metadata (dict[str, Any]): Message-specific metadata (default: empty dict)

Role Normalization

All provider-specific roles are normalized to three standard values:

Provider Source Role Normalized Role
OpenAI "user" "user"
OpenAI "assistant" "assistant"
OpenAI "system" "system"
Anthropic (future) "human" "user"
Anthropic (future) "assistant" "assistant"
Google (future) "user" "user"
Google (future) "model" "assistant"

Timestamp Format

All timestamps follow these rules:

  1. Timezone-aware: Must have tzinfo set
  2. UTC: Normalized to UTC timezone
  3. ISO 8601: Serialized as ISO 8601 strings

Example:

from datetime import datetime, timezone

# Create message with current UTC time
message = Message(
    id="msg-123",
    content="Hello",
    role="user",
    timestamp=datetime.now(timezone.utc),
    parent_id=None
)

# Serialize to ISO 8601
iso_timestamp = message.timestamp.isoformat()
# Output: "2024-01-15T10:30:00+00:00"

Metadata

Message-specific metadata (e.g., image attachments, code blocks):

# Access metadata
image_urls = message.metadata.get("image_urls", [])
code_blocks = message.metadata.get("code_blocks", [])

# Check if metadata exists
if "image_urls" in message.metadata:
    print(f"Message has {len(message.metadata['image_urls'])} images")
  • Conversation: Container for messages
  • Image: Image attachment model (if present in metadata)

See Also