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
19 changes: 13 additions & 6 deletions backend/chainlit/data/chainlit_data_layer.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,10 +277,10 @@ async def get_element(
id=str(row["id"]),
threadId=str(row["threadId"]),
type=metadata.get("type", "file"),
url=str(row["url"]),
url=row.get("url"),
name=str(row["name"]),
mime=str(row["mime"]),
objectKey=str(row["objectKey"]),
mime=str(row["mime"]) if row.get("mime") else None,
objectKey=row.get("objectKey"),
forId=str(row["stepId"]),
chainlitKey=row.get("chainlitKey"),
display=row["display"],
Expand Down Expand Up @@ -555,9 +555,16 @@ async def get_thread(self, thread_id: str) -> Optional[ThreadDict]:
if self.storage_client is not None:
for elem in elements_results:
if not elem["url"] and elem["objectKey"]:
elem["url"] = await self.storage_client.get_read_url(
object_key=elem["objectKey"],
)
try:
elem["url"] = await self.storage_client.get_read_url(
object_key=elem["objectKey"],
)
except Exception as e:
logger.warning(
"Failed to get read URL for element '%s': %s",
elem.get("id", "unknown"),
e,
)

return ThreadDict(
id=str(thread["id"]),
Expand Down
13 changes: 10 additions & 3 deletions backend/chainlit/data/dynamodb.py
Original file line number Diff line number Diff line change
Expand Up @@ -545,9 +545,16 @@ async def get_thread(self, thread_id: str) -> "Optional[ThreadDict]":

elif item["SK"].startswith("ELEMENT"):
if self.storage_provider is not None:
item["url"] = await self.storage_provider.get_read_url(
object_key=item["objectKey"],
)
try:
item["url"] = await self.storage_provider.get_read_url(
object_key=item["objectKey"],
)
except Exception as e:
_logger.warning(
"Failed to get read URL for element '%s': %s",
item.get("id", "unknown"),
e,
)
elements.append(item)

elif item["SK"].startswith("STEP"):
Expand Down
8 changes: 7 additions & 1 deletion backend/chainlit/element.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,13 @@ def from_dict(cls, e_dict: ElementDict):
type = e_dict.get("type", "file")
path = str(e_dict.get("path")) if e_dict.get("path") else None
url = str(e_dict.get("url")) if e_dict.get("url") else None
content = str(e_dict.get("content")) if e_dict.get("content") else None
content = None
raw_content = e_dict.get("content")
if raw_content is not None:
if isinstance(raw_content, bytes):
content = raw_content
else:
content = str(raw_content)
object_key = e_dict.get("objectKey")
chainlit_key = e_dict.get("chainlitKey")
display = e_dict.get("display", "inline")
Expand Down
9 changes: 8 additions & 1 deletion backend/chainlit/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -975,7 +975,14 @@ async def get_thread(

await is_thread_author(current_user.identifier, thread_id)

res = await data_layer.get_thread(thread_id)
try:
res = await data_layer.get_thread(thread_id)
except Exception as e:
logger.error(f"Failed to get thread {thread_id}: {e!s}")
raise HTTPException(
status_code=500,
detail="Failed to load conversation history",
)
return JSONResponse(content=res)


Expand Down
29 changes: 24 additions & 5 deletions backend/chainlit/socket.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,15 @@ async def connection_successful(sid):
return

if context.session.thread_id_to_resume and config.code.on_chat_resume:
thread = await resume_thread(context.session)
try:
thread = await resume_thread(context.session)
except Exception as e:
logger.error(f"Failed to resume thread: {e!s}")
await context.emitter.send_resume_thread_error(
"Failed to load conversation history."
)
thread = None
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Exception handling for resume_thread emits an error but then falls through to the existing "Thread not found" branch, resulting in two error emissions for the same failure.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/chainlit/socket.py, line 204:

<comment>Exception handling for resume_thread emits an error but then falls through to the existing "Thread not found" branch, resulting in two error emissions for the same failure.</comment>

<file context>
@@ -194,7 +194,15 @@ async def connection_successful(sid):
+            await context.emitter.send_resume_thread_error(
+                "Failed to load conversation history."
+            )
+            thread = None
+
         if thread:
</file context>
Fix with Cubic


if thread:
context.session.has_first_interaction = True
await context.emitter.emit(
Expand All @@ -204,10 +212,21 @@ async def connection_successful(sid):
await config.code.on_chat_resume(thread)

for step in thread.get("steps", []):
if "message" in step["type"]:
chat_context.add(Message.from_dict(step))

await context.emitter.resume_thread(thread)
try:
if "message" in step["type"]:
chat_context.add(Message.from_dict(step))
except Exception as e:
logger.warning(
f"Failed to restore step {step.get('id')}: {e!s}"
)

try:
await context.emitter.resume_thread(thread)
except Exception as e:
logger.error(f"Failed to emit resume_thread: {e!s}")
await context.emitter.send_resume_thread_error(
"Failed to load conversation history."
)
return
else:
await context.emitter.send_resume_thread_error("Thread not found.")
Expand Down
142 changes: 142 additions & 0 deletions backend/tests/data/test_chainlit_data_layer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import json

import pytest

from chainlit.data.chainlit_data_layer import ChainlitDataLayer


class TestConvertElementRowToDict:
"""Test suite for ChainlitDataLayer._convert_element_row_to_dict."""

def _make_layer(self):
return ChainlitDataLayer(database_url="postgresql://fake", storage_client=None)

def _make_row(self, **overrides):
row = {
"id": "elem-1",
"threadId": "thread-1",
"stepId": "step-1",
"metadata": json.dumps({"type": "file"}),
"url": None,
"name": "test_file.txt",
"mime": "text/plain",
"objectKey": None,
"chainlitKey": None,
"display": "inline",
"size": None,
"language": None,
"page": None,
"autoPlay": None,
"playerConfig": None,
"props": "{}",
}
row.update(overrides)
return row

def test_convert_element_row_with_none_url(self):
layer = self._make_layer()
result = layer._convert_element_row_to_dict(self._make_row(url=None))
assert result["url"] is None

def test_convert_element_row_with_none_object_key(self):
layer = self._make_layer()
result = layer._convert_element_row_to_dict(self._make_row(objectKey=None))
assert result["objectKey"] is None

def test_convert_element_row_with_valid_url(self):
layer = self._make_layer()
result = layer._convert_element_row_to_dict(
self._make_row(url="https://storage.example.com/file.txt")
)
assert result["url"] == "https://storage.example.com/file.txt"

def test_convert_element_row_preserves_chainlit_key(self):
layer = self._make_layer()
result = layer._convert_element_row_to_dict(
self._make_row(chainlitKey="file-abc-123")
)
assert result["chainlitKey"] == "file-abc-123"

def test_convert_element_row_type_from_metadata(self):
layer = self._make_layer()
result = layer._convert_element_row_to_dict(
self._make_row(metadata=json.dumps({"type": "image"}))
)
assert result["type"] == "image"

def test_convert_element_row_default_type(self):
layer = self._make_layer()
result = layer._convert_element_row_to_dict(
self._make_row(metadata=json.dumps({}))
)
assert result["type"] == "file"

def test_convert_element_row_full_data(self):
layer = self._make_layer()
result = layer._convert_element_row_to_dict(
self._make_row(
url="https://storage.example.com/file.txt",
objectKey="threads/thread-1/files/elem-1",
chainlitKey="file-abc-123",
display="side",
size="large",
language="python",
page=3,
mime="application/pdf",
props=json.dumps({"custom": "value"}),
)
)
assert result["id"] == "elem-1"
assert result["url"] == "https://storage.example.com/file.txt"
assert result["objectKey"] == "threads/thread-1/files/elem-1"
assert result["chainlitKey"] == "file-abc-123"
assert result["props"] == {"custom": "value"}


class TestGetElementNoneHandling:
"""Test that get_element does not convert None values to 'None' strings."""

def _make_layer(self):
return ChainlitDataLayer(database_url="postgresql://fake", storage_client=None)

@pytest.mark.asyncio
async def test_get_element_returns_none_url_not_string(self):
from unittest.mock import AsyncMock

layer = self._make_layer()
layer.execute_query = AsyncMock(
return_value=[
{
"id": "elem-1",
"threadId": "thread-1",
"stepId": "step-1",
"metadata": json.dumps({"type": "file"}),
"url": None,
"name": "test.txt",
"mime": None,
"objectKey": None,
"chainlitKey": "ck-1",
"display": "inline",
"size": None,
"language": None,
"page": None,
"autoPlay": None,
"playerConfig": None,
"props": "{}",
}
]
)
result = await layer.get_element("thread-1", "elem-1")
assert result is not None
assert result["url"] is None
assert result["objectKey"] is None
assert result["mime"] is None

@pytest.mark.asyncio
async def test_get_element_not_found(self):
from unittest.mock import AsyncMock
Copy link
Collaborator

@dokterbob dokterbob Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This import might well be at the top! Same for other places.


layer = self._make_layer()
layer.execute_query = AsyncMock(return_value=[])
result = await layer.get_element("thread-1", "nonexistent")
assert result is None
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I notice changes in two data layers but unit tests only for one, what's the reason?

14 changes: 13 additions & 1 deletion libs/react-client/src/useChatSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,19 @@ const useChatSession = () => {
setChatSettingsValue(thread.metadata?.chat_settings);
}
setMessages(messages);
const elements = thread.elements || [];
const elements = (thread.elements || []).filter(
(e): e is IElement => e != null
);
// Resolve element URLs from chainlitKey when url is missing,
// matching the behavior of the 'element' socket event handler.
elements.forEach((element) => {
if (!element.url && element.chainlitKey) {
element.url = client.getElementUrl(
element.chainlitKey,
sessionId
);
}
});
setTasklists(
(elements as ITasklistElement[]).filter((e) => e.type === 'tasklist')
);
Expand Down
Loading