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
27 changes: 22 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."
)
return

if thread:
context.session.has_first_interaction = True
await context.emitter.emit(
Expand All @@ -204,10 +212,19 @@ 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
194 changes: 194 additions & 0 deletions backend/tests/data/test_chainlit_data_layer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import json
from unittest.mock import AsyncMock

import pytest

from chainlit.data.chainlit_data_layer import ChainlitDataLayer
from chainlit.data.dynamodb import DynamoDBDataLayer


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):
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):
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?



class _FakeDynamoClient:
def __init__(self, items):
self.items = items

def query(self, **_kwargs):
return {"Items": self.items}


class _FailingStorageProvider:
async def get_read_url(self, object_key: str):
raise RuntimeError(f"failed for {object_key}")


class TestDynamoThreadElementUrlFailure:
@pytest.mark.asyncio
async def test_get_thread_tolerates_storage_read_url_error(self):
bootstrap = DynamoDBDataLayer(
table_name="test-table", client=_FakeDynamoClient([])
)

thread_item = bootstrap._serialize_item(
{
"PK": "THREAD#thread-1",
"SK": "THREAD",
"id": "thread-1",
"createdAt": "2026-01-01T00:00:00Z",
"name": "name",
}
)
element_item = bootstrap._serialize_item(
{
"PK": "THREAD#thread-1",
"SK": "ELEMENT#elem-1",
"id": "elem-1",
"objectKey": "threads/thread-1/files/elem-1",
"type": "file",
"name": "file.txt",
}
)

layer = DynamoDBDataLayer(
table_name="test-table",
client=_FakeDynamoClient([thread_item, element_item]),
storage_provider=_FailingStorageProvider(),
)

thread = await layer.get_thread("thread-1")

assert thread is not None
assert thread["id"] == "thread-1"
assert len(thread["elements"]) == 1
assert thread["elements"][0]["id"] == "elem-1"
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