Skip to content

Commit 86c6822

Browse files
committed
message syncing WIP
1 parent e3e97a5 commit 86c6822

6 files changed

Lines changed: 314 additions & 4 deletions

File tree

src/schema/schema.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,11 @@ class UserInput(BaseModel):
243243
default=None,
244244
examples=["847c6285-8fc9-4560-a83f-4e6285809254"],
245245
)
246+
correlation_id: str | None = Field(
247+
description="Frontend-generated correlation ID for reliable message matching.",
248+
default=None,
249+
examples=["550e8400-e29b-41d4-a716-446655440000"],
250+
)
246251
tool_call_approval: ToolCallApproval = Field(
247252
description="Whether this input is a tool call approval.",
248253
default=False,

src/service/routers/chat.py

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -335,4 +335,94 @@ async def stream_generator():
335335
):
336336
yield stream_manager.to_sse_format(stream_event)
337337

338-
return StreamingResponse(stream_generator(), media_type="text/event-stream")
338+
return StreamingResponse(stream_generator(), media_type="text/event-stream")
339+
340+
341+
@router.get("/threads/{thread_id}/messages", dependencies=[Depends(session_cookie)])
342+
async def get_thread_messages(
343+
thread_id: str,
344+
stream_manager: StreamManager = Depends(get_stream_manager)
345+
) -> dict[str, Any]:
346+
"""
347+
Get the authoritative message history for a thread.
348+
349+
This endpoint serves as the single source of truth for thread state,
350+
enabling frontend recovery/reconciliation when real-time sync fails.
351+
352+
Use cases:
353+
- Initial load/page refresh
354+
- After reconnection
355+
- When state seems inconsistent
356+
357+
Args:
358+
thread_id: The conversation thread ID
359+
stream_manager: Injected stream manager dependency
360+
361+
Returns:
362+
Dictionary containing thread_id and list of messages with their metadata
363+
"""
364+
logger.info(f"Fetching message history for thread: {thread_id}")
365+
366+
# Get the graph from the session
367+
agent = stream_manager.opey_session.graph
368+
369+
# Build config for the thread
370+
config = stream_manager.opey_session.build_config({
371+
'configurable': {
372+
'thread_id': thread_id,
373+
}
374+
})
375+
376+
try:
377+
# Get current state
378+
current_state = await agent.aget_state(config)
379+
380+
if not current_state or not current_state.values:
381+
# Thread doesn't exist yet or has no messages
382+
return {
383+
"thread_id": thread_id,
384+
"messages": [],
385+
"message_count": 0
386+
}
387+
388+
# Get messages from state
389+
messages = current_state.values.get('messages', [])
390+
391+
# Convert LangChain messages to our ChatMessage format
392+
formatted_messages = []
393+
for msg in messages:
394+
try:
395+
chat_msg = ChatMessage.from_langchain(msg)
396+
formatted_messages.append({
397+
"id": getattr(msg, 'id', None),
398+
"type": chat_msg.type,
399+
"content": chat_msg.content,
400+
"timestamp": getattr(msg, 'timestamp', None),
401+
"tool_calls": chat_msg.tool_calls if hasattr(chat_msg, 'tool_calls') else [],
402+
"tool_call_id": chat_msg.tool_call_id if hasattr(chat_msg, 'tool_call_id') else None,
403+
"tool_status": chat_msg.tool_status if hasattr(chat_msg, 'tool_status') else None,
404+
})
405+
except Exception as e:
406+
logger.warning(f"Failed to convert message to ChatMessage: {e}")
407+
# Include a minimal representation if conversion fails
408+
formatted_messages.append({
409+
"id": getattr(msg, 'id', None),
410+
"type": msg.__class__.__name__,
411+
"content": str(msg.content) if hasattr(msg, 'content') else "",
412+
"error": f"Conversion failed: {str(e)}"
413+
})
414+
415+
logger.info(f"Retrieved {len(formatted_messages)} messages for thread {thread_id}")
416+
417+
return {
418+
"thread_id": thread_id,
419+
"messages": formatted_messages,
420+
"message_count": len(formatted_messages)
421+
}
422+
423+
except Exception as e:
424+
logger.error(f"Error retrieving thread messages for {thread_id}: {e}", exc_info=True)
425+
raise HTTPException(
426+
status_code=500,
427+
detail=f"Failed to retrieve thread messages: {str(e)}"
428+
)

src/service/streaming/events.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -142,12 +142,22 @@ class UserMessageConfirmEvent(BaseStreamEvent):
142142
"""Event fired when a user message is confirmed with its backend ID"""
143143
type: Literal["user_message_confirmed"] = "user_message_confirmed"
144144
message_id: str = Field(description="Backend-assigned message ID")
145+
correlation_id: str = Field(description="Frontend-generated correlation ID for reliable matching")
145146
content: str = Field(description="The user's message content")
146147

147148
def to_sse_data(self) -> str:
148149
return f"data: {self.model_dump_json()}\n\n"
149150

150151

152+
class ThreadSyncEvent(BaseStreamEvent):
153+
"""Event fired to sync thread_id with the frontend"""
154+
type: Literal["thread_sync"] = "thread_sync"
155+
thread_id: str = Field(description="Thread ID assigned/confirmed by backend")
156+
157+
def to_sse_data(self) -> str:
158+
return f"data: {self.model_dump_json()}\n\n"
159+
160+
151161
class StreamEndEvent(BaseStreamEvent):
152162
"""Event fired when the stream ends"""
153163
type: Literal["stream_end"] = "stream_end"
@@ -169,6 +179,7 @@ def to_sse_data(self) -> str:
169179
ApprovalRequestEvent,
170180
BatchApprovalRequestEvent,
171181
UserMessageConfirmEvent,
182+
ThreadSyncEvent,
172183
StreamEndEvent
173184
]
174185

@@ -409,16 +420,34 @@ def batch_approval_request(
409420
return event
410421

411422
@staticmethod
412-
def user_message_confirmed(message_id: str, content: str) -> UserMessageConfirmEvent:
423+
def user_message_confirmed(message_id: str, correlation_id: str, content: str) -> UserMessageConfirmEvent:
413424
"""
414425
Create a user message confirmation event.
415426
This is sent after the backend accepts a user message and assigns it an ID.
416427
"""
417-
event = UserMessageConfirmEvent(message_id=message_id, content=content)
428+
event = UserMessageConfirmEvent(
429+
message_id=message_id,
430+
correlation_id=correlation_id,
431+
content=content
432+
)
418433
StreamEventFactory._log_event(
419434
event,
420435
"USER_MESSAGE_CONFIRMED",
421-
{"message_id": message_id, "content_length": len(content)}
436+
{"message_id": message_id, "correlation_id": correlation_id[:8], "content_length": len(content)}
437+
)
438+
return event
439+
440+
@staticmethod
441+
def thread_sync(thread_id: str) -> ThreadSyncEvent:
442+
"""
443+
Create a thread sync event.
444+
This is sent at the start of a stream to sync the thread_id with the frontend.
445+
"""
446+
event = ThreadSyncEvent(thread_id=thread_id)
447+
StreamEventFactory._log_event(
448+
event,
449+
"THREAD_SYNC",
450+
{"thread_id": thread_id}
422451
)
423452
return event
424453

src/service/streaming/processors.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,13 @@ async def process(self, event: LangGraphStreamEvent) -> AsyncGenerator[StreamEve
6767
# (Compare content to ensure we're syncing the right message)
6868
if message_id and content and content == self.stream_input.message:
6969
self.user_message_confirmed = True
70+
71+
# Get correlation_id from stream_input (frontend provides this)
72+
correlation_id = getattr(self.stream_input, 'correlation_id', None) or message_id
73+
7074
yield StreamEventFactory.user_message_confirmed(
7175
message_id=message_id,
76+
correlation_id=correlation_id,
7277
content=content
7378
)
7479
return

src/service/streaming/stream_manager.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ async def stream_response(
4646
"message_length": len(stream_input.message) if stream_input.message else 0
4747
})
4848

49+
# Emit thread_sync event first to ensure frontend has the correct thread_id
50+
# This is critical when the backend generates the thread_id (when not provided)
51+
yield StreamEventFactory.thread_sync(thread_id)
52+
4953
orchestrator = orchestrator_repository.get_or_create(thread_id, stream_input)
5054

5155
# Track if generator is being closed to avoid yielding in finally block
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
"""
2+
Test suite for correlation_id and thread_sync implementation.
3+
4+
Tests verify:
5+
1. Frontend generates a correlation_id when sending a message
6+
2. Backend echoes it back in user_message_confirmed event
7+
3. Backend sends thread_sync event at stream start
8+
4. Frontend can retrieve authoritative message history via GET endpoint
9+
"""
10+
11+
import pytest
12+
import uuid
13+
import json
14+
15+
from schema.schema import StreamInput
16+
from service.streaming.events import StreamEventFactory
17+
18+
19+
def generate_correlation_id() -> str:
20+
"""Simulate frontend generating a correlation ID"""
21+
return str(uuid.uuid4())
22+
23+
24+
def test_stream_input_accepts_correlation_id():
25+
"""Test that StreamInput accepts and stores correlation_id"""
26+
correlation_id = generate_correlation_id()
27+
28+
stream_input = StreamInput(
29+
message="Test message",
30+
thread_id="test-thread-123",
31+
correlation_id=correlation_id,
32+
stream_tokens=True
33+
)
34+
35+
assert stream_input.message == "Test message"
36+
assert stream_input.thread_id == "test-thread-123"
37+
assert stream_input.correlation_id == correlation_id
38+
assert stream_input.stream_tokens is True
39+
40+
41+
def test_user_message_confirmed_event_includes_correlation_id():
42+
"""Test that UserMessageConfirmEvent includes correlation_id"""
43+
correlation_id = generate_correlation_id()
44+
backend_id = str(uuid.uuid4())
45+
46+
event = StreamEventFactory.user_message_confirmed(
47+
message_id=backend_id,
48+
correlation_id=correlation_id,
49+
content="Test message content"
50+
)
51+
52+
assert event.message_id == backend_id
53+
assert event.correlation_id == correlation_id
54+
assert event.content == "Test message content"
55+
assert event.type == "user_message_confirmed"
56+
57+
58+
def test_thread_sync_event_creation():
59+
"""Test that ThreadSyncEvent is created correctly"""
60+
thread_id = "test-thread-456"
61+
62+
sync_event = StreamEventFactory.thread_sync(thread_id=thread_id)
63+
64+
assert sync_event.thread_id == thread_id
65+
assert sync_event.type == "thread_sync"
66+
67+
68+
def test_user_message_confirmed_sse_serialization():
69+
"""Test SSE serialization of user_message_confirmed event"""
70+
correlation_id = generate_correlation_id()
71+
backend_id = str(uuid.uuid4())
72+
73+
event = StreamEventFactory.user_message_confirmed(
74+
message_id=backend_id,
75+
correlation_id=correlation_id,
76+
content="Test content"
77+
)
78+
79+
sse_data = event.to_sse_data()
80+
81+
assert sse_data.startswith("data: ")
82+
assert sse_data.endswith("\n\n")
83+
84+
# Parse the JSON data
85+
json_str = sse_data.replace("data: ", "").strip()
86+
parsed = json.loads(json_str)
87+
88+
assert parsed["type"] == "user_message_confirmed"
89+
assert parsed["message_id"] == backend_id
90+
assert parsed["correlation_id"] == correlation_id
91+
assert parsed["content"] == "Test content"
92+
93+
94+
def test_thread_sync_sse_serialization():
95+
"""Test SSE serialization of thread_sync event"""
96+
thread_id = "test-thread-789"
97+
98+
sync_event = StreamEventFactory.thread_sync(thread_id=thread_id)
99+
sse_data = sync_event.to_sse_data()
100+
101+
assert sse_data.startswith("data: ")
102+
assert sse_data.endswith("\n\n")
103+
104+
# Parse the JSON data
105+
json_str = sse_data.replace("data: ", "").strip()
106+
parsed = json.loads(json_str)
107+
108+
assert parsed["type"] == "thread_sync"
109+
assert parsed["thread_id"] == thread_id
110+
111+
112+
def test_correlation_id_matching_flow():
113+
"""
114+
Test the complete correlation ID flow for reliable message matching
115+
"""
116+
# 1. Frontend generates correlation_id
117+
frontend_correlation_id = generate_correlation_id()
118+
119+
# 2. Frontend sends message with correlation_id
120+
stream_input = StreamInput(
121+
message="Hello, how can I help?",
122+
correlation_id=frontend_correlation_id,
123+
stream_tokens=True
124+
)
125+
126+
assert stream_input.correlation_id == frontend_correlation_id
127+
128+
# 3. Backend assigns backend message_id
129+
backend_message_id = str(uuid.uuid4())
130+
131+
# 4. Backend emits user_message_confirmed with both IDs
132+
confirm_event = StreamEventFactory.user_message_confirmed(
133+
message_id=backend_message_id,
134+
correlation_id=frontend_correlation_id,
135+
content="Hello, how can I help?"
136+
)
137+
138+
# 5. Verify frontend can match by correlation_id
139+
assert confirm_event.correlation_id == frontend_correlation_id
140+
assert confirm_event.message_id == backend_message_id
141+
142+
# Frontend would find message with correlation_id and update to backend_id
143+
# This eliminates the fragile content-based matching
144+
145+
146+
def test_thread_sync_with_provided_thread_id():
147+
"""Test thread sync when frontend provides thread_id"""
148+
frontend_thread_id = "existing-thread-789"
149+
150+
# Backend uses provided thread_id
151+
sync_event = StreamEventFactory.thread_sync(thread_id=frontend_thread_id)
152+
153+
assert sync_event.thread_id == frontend_thread_id
154+
155+
156+
def test_thread_sync_with_generated_thread_id():
157+
"""Test thread sync when backend generates new thread_id"""
158+
# Frontend doesn't provide thread_id (new conversation)
159+
# Backend generates one
160+
backend_thread_id = str(uuid.uuid4())
161+
162+
sync_event = StreamEventFactory.thread_sync(thread_id=backend_thread_id)
163+
164+
assert sync_event.thread_id == backend_thread_id
165+
# Frontend would store this as the authoritative thread_id
166+
167+
168+
def test_correlation_id_optional_for_backward_compatibility():
169+
"""Test that correlation_id is optional for backward compatibility"""
170+
# Should work without correlation_id
171+
stream_input = StreamInput(
172+
message="Test message",
173+
stream_tokens=True
174+
)
175+
176+
assert stream_input.message == "Test message"
177+
assert stream_input.correlation_id is None # Optional field

0 commit comments

Comments
 (0)