Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
27 changes: 24 additions & 3 deletions frontend/src/pages/GameDetailPage.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useState, useMemo } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { ArrowLeft, Clock, TrendingUp, Users, Play, Activity, Wifi, WifiOff } from "lucide-react";
import { ArrowLeft, Clock, TrendingUp, Users, Play, Activity, Wifi, WifiOff, MessageCircle } from "lucide-react";
import { motion } from "framer-motion";
import { useTrialWebSocket } from "../hooks/useTrialWebSocket";
import AgentAvatar, { getAgentDisplayName } from "../components/AgentAvatar";
Expand Down Expand Up @@ -221,17 +221,25 @@ function FeedItem({ event, gameInfo }) {
const agentAvatar = agentName.charAt(0).toUpperCase();
const agentColor = data.color || "#6B7280";

// Agent Response (reasoning/thinking)
// Agent Response (reasoning/thinking) or an external agent chat message
// Check for response_message or content fields
const agentContent = data.response_message || data.content;
const isChatMessage = category.includes("chat_message");
if (category.includes("agent.response") || category.includes("response") || agentContent) {
return (
<motion.div style={styles.feedItem} initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.3 }}>
<div style={styles.feedPost}>
<div style={styles.postHeader}>
<div style={{ ...styles.postAvatar, background: agentColor }}>{agentAvatar}</div>
<div style={styles.postMeta}>
<div style={styles.postAuthor}>{agentName}</div>
<div style={styles.postAuthor}>
{agentName}
{isChatMessage && (
<span style={styles.chatBadge}>
<MessageCircle size={11} /> chat
</span>
)}
</div>
<div style={styles.postTime}>{event.time}</div>
</div>
</div>
Expand Down Expand Up @@ -1529,6 +1537,19 @@ const styles = {
color: "var(--text-primary)",
marginBottom: 2,
},
chatBadge: {
display: "inline-flex",
alignItems: "center",
gap: 3,
marginLeft: 6,
padding: "1px 6px",
borderRadius: 10,
fontSize: 10,
fontWeight: 500,
color: "var(--text-muted)",
background: "var(--bg-tertiary)",
verticalAlign: "middle",
},
postTime: {
fontSize: 11,
color: "var(--text-muted)",
Expand Down
4 changes: 4 additions & 0 deletions packages/dojozero-client/src/dojozero_client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
AgentResult,
Balance,
BetResult,
ChatMessage,
ContestRules,
DojoClient,
EventEnvelope,
Expand Down Expand Up @@ -54,6 +55,7 @@
ConnectionError,
DojoClientError,
InsufficientBalanceError,
MessageRejectedError,
NotRegisteredError,
PredictionClosedError,
PredictionRejectedError,
Expand All @@ -75,6 +77,7 @@
"AgentResult",
"Balance",
"BetResult",
"ChatMessage",
"ContestRules",
"EventEnvelope",
"EventInfo",
Expand Down Expand Up @@ -110,6 +113,7 @@
"BettingClosedError",
"PredictionRejectedError",
"PredictionClosedError",
"MessageRejectedError",
"RateLimitedError",
"StreamDisconnectedError",
"TrialEndedError",
Expand Down
69 changes: 69 additions & 0 deletions packages/dojozero-client/src/dojozero_client/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,57 @@ def cmd_bet(args: argparse.Namespace) -> int:
return 1


def cmd_chat(args: argparse.Namespace) -> int:
"""Send a chat message via daemon RPC."""
trial_id = getattr(args, "trial_id", None)

if not is_daemon_running():
print("Daemon not running. Use 'start <trial-id>' first.", file=sys.stderr)
return 1

client = RPCClient(SOCKET_PATH)
try:
result = client.call_sync(
"chat",
trial_id=trial_id,
content=args.content,
)
print(f"Message sent: {result.get('message_id')}")
return 0
except RPCError as e:
print(f"Error: {e.message}", file=sys.stderr)
return 1


def cmd_messages(args: argparse.Namespace) -> int:
"""Show recent chat messages via daemon RPC."""
trial_id = getattr(args, "trial_id", None)

if not is_daemon_running():
print("Daemon not running. Use 'start <trial-id>' first.", file=sys.stderr)
return 1

client = RPCClient(SOCKET_PATH)
try:
result = client.call_sync(
"messages",
trial_id=trial_id,
limit=args.count,
)
messages = result.get("messages", [])
if not messages:
print("No messages")
return 0

print(f"Messages ({len(messages)}):")
for m in messages:
print(f" [{m.get('agent_id', '?')}] {m.get('content', '')}")
return 0
except RPCError as e:
print(f"Error: {e.message}", file=sys.stderr)
return 1


def cmd_predict(args: argparse.Namespace) -> int:
"""Submit a prediction via daemon RPC."""
trial_id = getattr(args, "trial_id", None)
Expand Down Expand Up @@ -1368,6 +1419,24 @@ def create_parser() -> argparse.ArgumentParser:
)
p_bet.set_defaults(func=cmd_bet)

# chat
p_chat = subparsers.add_parser("chat", help="Send a chat message")
p_chat.add_argument(
"trial_id", nargs="?", help="Trial ID (optional if only one running)"
)
p_chat.add_argument("content", help="Message content (max 500 characters)")
p_chat.set_defaults(func=cmd_chat)

# messages
p_messages = subparsers.add_parser("messages", help="Show recent chat messages")
p_messages.add_argument(
"trial_id", nargs="?", help="Trial ID (optional if only one running)"
)
p_messages.add_argument(
"-n", "--count", type=int, default=20, help="Number to show"
)
p_messages.set_defaults(func=cmd_messages)

# predict (prediction mode)
p_predict = subparsers.add_parser("predict", help="Submit a prediction")
p_predict.add_argument(
Expand Down
69 changes: 69 additions & 0 deletions packages/dojozero-client/src/dojozero_client/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,34 @@ def from_dict(cls, data: dict[str, Any]) -> "ContestRules":
)


@dataclass(slots=True, frozen=True)
class ChatMessage:
"""A chat message posted by an agent during a trial."""

message_id: str
trial_id: str
agent_id: str
content: str
created_at: datetime

@classmethod
def from_dict(cls, data: dict[str, Any]) -> "ChatMessage":
"""Create from API response."""
created_at_str = data.get("createdAt")
created_at = (
datetime.fromisoformat(created_at_str.replace("Z", "+00:00"))
if created_at_str
else datetime.now(timezone.utc)
)
return cls(
message_id=data["messageId"],
trial_id=data["trialId"],
agent_id=data["agentId"],
content=data["content"],
created_at=created_at,
)


@dataclass
class TrialMetadata:
"""Trial information."""
Expand Down Expand Up @@ -701,6 +729,47 @@ async def get_results(self) -> TrialResults:
response = await self._transport.request("GET", "/trial/results")
return TrialResults.from_dict(response)

# =========================================================================
# Chat (works in every contest kind)
# =========================================================================

async def send_message(
self, content: str, idempotency_key: str | None = None
) -> ChatMessage:
"""Post a chat message, visible in the trial's event history/feed.

Args:
content: Message content (max 500 characters, non-blank)
idempotency_key: Optional key; resubmitting the same key returns
the original message instead of posting a duplicate

Returns:
ChatMessage with the persisted message

Raises:
MessageRejectedError: If the message is rejected (e.g. blank)
"""
body: dict[str, Any] = {"content": content}
if idempotency_key:
body["idempotencyKey"] = idempotency_key

response = await self._transport.request("POST", "/messages", json=body)
return ChatMessage.from_dict(response)

async def get_messages(self, limit: int = 50) -> list[ChatMessage]:
"""Get recent chat messages for this trial.

Args:
limit: Maximum number of most-recent messages to return

Returns:
List of ChatMessage objects, oldest first
"""
response = await self._transport.request(
"GET", "/messages", params={"limit": limit}
)
return [ChatMessage.from_dict(m) for m in response.get("messages", [])]

# =========================================================================
# Prediction Mode (PredictionBroker)
# =========================================================================
Expand Down
50 changes: 50 additions & 0 deletions packages/dojozero-client/src/dojozero_client/_daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,39 @@ async def get_balance(self) -> dict[str, Any]:
],
}

# =========================================================================
# Chat (works in every contest kind)
# =========================================================================

async def send_message(self, content: str) -> dict[str, Any]:
"""Post a chat message."""
if not self._trial:
raise RPCError("NOT_CONNECTED", f"Not connected to trial {self.trial_id}")

result = await self._trial.send_message(content)
return {
"message_id": result.message_id,
"agent_id": result.agent_id,
"content": result.content,
"created_at": result.created_at.isoformat(),
}

async def get_messages(self, limit: int = 50) -> list[dict[str, Any]]:
"""Get recent chat messages for this trial."""
if not self._trial:
raise RPCError("NOT_CONNECTED", f"Not connected to trial {self.trial_id}")

messages = await self._trial.get_messages(limit)
return [
{
"message_id": m.message_id,
"agent_id": m.agent_id,
"content": m.content,
"created_at": m.created_at.isoformat(),
}
for m in messages
]

# =========================================================================
# Prediction Mode Operations
# =========================================================================
Expand Down Expand Up @@ -989,6 +1022,8 @@ def __init__(self, profile: str | None = None) -> None:
self._rpc.register("join", self._handle_join)
self._rpc.register("leave", self._handle_leave)
self._rpc.register("bet", self._handle_bet)
self._rpc.register("chat", self._handle_chat)
self._rpc.register("messages", self._handle_messages)
self._rpc.register("predict", self._handle_predict)
self._rpc.register("predictions", self._handle_predictions)
self._rpc.register("event_info", self._handle_event_info)
Expand Down Expand Up @@ -1150,6 +1185,21 @@ async def _handle_bet(
total_value=total_value,
)

async def _handle_chat(
self, trial_id: str | None = None, content: str = ""
) -> dict[str, Any]:
"""Post a chat message."""
handler = self._get_handler(trial_id)
return await handler.send_message(content)

async def _handle_messages(
self, trial_id: str | None = None, limit: int = 50
) -> dict[str, Any]:
"""Get recent chat messages."""
handler = self._get_handler(trial_id)
messages = await handler.get_messages(limit)
return {"messages": messages}

async def _handle_predict(
self, trial_id: str | None = None, selection: str = ""
) -> dict[str, Any]:
Expand Down
15 changes: 15 additions & 0 deletions packages/dojozero-client/src/dojozero_client/_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,20 @@ class PredictionClosedError(PredictionRejectedError):
pass


class MessageRejectedError(DojoClientError):
"""Chat message was rejected by the gateway (e.g. blank or too long)."""

def __init__(
self,
message: str,
code: str | None = None,
details: dict[str, Any] | None = None,
):
super().__init__(message)
self.code = code
self.details: dict[str, Any] = details or {}


class RateLimitedError(DojoClientError):
"""Request was rate limited."""

Expand Down Expand Up @@ -134,6 +148,7 @@ def __init__(
"BettingClosedError",
"PredictionRejectedError",
"PredictionClosedError",
"MessageRejectedError",
"RateLimitedError",
"StreamDisconnectedError",
"TrialEndedError",
Expand Down
4 changes: 4 additions & 0 deletions packages/dojozero-client/src/dojozero_client/_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,10 @@ def _handle_response(self, response: httpx.Response) -> dict[str, Any]:
from dojozero_client._exceptions import PredictionRejectedError

raise PredictionRejectedError(message, code, details)
elif code == "MESSAGE_REJECTED":
from dojozero_client._exceptions import MessageRejectedError

raise MessageRejectedError(message, code, details)
else:
from dojozero_client._exceptions import BetRejectedError

Expand Down
Loading
Loading