Skip to content
Merged
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
1 change: 1 addition & 0 deletions backend/application/chat/utilities/file_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -455,5 +455,6 @@ def build_files_manifest(session_context: Dict[str, Any]) -> Optional[Dict[str,
f"{file_list}\n\n"
"(You can ask to open or analyze any of these by name. "
"Large contents are not fully in this prompt unless user or tools provided excerpts.)"
"The user may refer to these files in their requests as session files or attachments."
)
}
209 changes: 209 additions & 0 deletions backend/tests/test_attach_file_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import base64
import uuid
import pytest

from application.chat.service import ChatService
from modules.file_storage.manager import FileManager
from modules.file_storage.mock_s3_client import MockS3StorageClient


class FakeLLM:
async def call_plain(self, model_name, messages, temperature=0.7):
return "ok"

async def call_with_tools(self, model_name, messages, tools_schema, tool_choice="auto", temperature=0.7):
from interfaces.llm import LLMResponse
return LLMResponse(content="ok", tool_calls=None, model_used=model_name)

async def call_with_rag(self, model_name, messages, data_sources, user_email, temperature=0.7):
return "ok"

async def call_with_rag_and_tools(self, model_name, messages, data_sources, tools_schema, user_email, tool_choice="auto", temperature=0.7):
from interfaces.llm import LLMResponse
return LLMResponse(content="ok", tool_calls=None, model_used=model_name)


@pytest.fixture
def file_manager():
# Use in-process mock S3 for deterministic tests
return FileManager(s3_client=MockS3StorageClient())


@pytest.fixture
def chat_service(file_manager):
# Minimal ChatService wiring for file/session operations
return ChatService(llm=FakeLLM(), file_manager=file_manager)


@pytest.mark.asyncio
async def test_handle_attach_file_success_creates_session_and_emits_update(chat_service, file_manager):
user_email = "user1@example.com"
session_id = uuid.uuid4()

# Seed a file into the mock storage for this user
filename = "report.txt"
content_b64 = base64.b64encode(b"hello world").decode()
upload_meta = await file_manager.s3_client.upload_file(
user_email=user_email,
filename=filename,
content_base64=content_b64,
content_type="text/plain",
tags={"source": "user"},
source_type="user",
)
s3_key = upload_meta["key"]

updates = []

async def capture_update(msg):
updates.append(msg)

# Act: attach the file to a brand new session (auto-creates session)
resp = await chat_service.handle_attach_file(
session_id=session_id,
s3_key=s3_key,
user_email=user_email,
update_callback=capture_update,
)

# Assert: success response and files_update emitted
assert resp.get("type") == "file_attach"
assert resp.get("success") is True
assert resp.get("filename") == filename

assert any(
u.get("type") == "intermediate_update" and u.get("update_type") == "files_update"
for u in updates
), "Expected a files_update intermediate update to be emitted"

# Session context should include the file by filename
session = chat_service.sessions.get(session_id)
assert session is not None
assert filename in session.context.get("files", {})
assert session.context["files"][filename]["key"] == s3_key


@pytest.mark.asyncio
async def test_handle_attach_file_not_found_returns_error(chat_service):
user_email = "user1@example.com"
session_id = uuid.uuid4()

# Non-existent S3 key for the same user
bad_key = f"users/{user_email}/uploads/does_not_exist_12345.txt"
resp = await chat_service.handle_attach_file(
session_id=session_id,
s3_key=bad_key,
user_email=user_email,
update_callback=None,
)

assert resp.get("type") == "file_attach"
assert resp.get("success") is False
assert "File not found" in resp.get("error", "")


@pytest.mark.asyncio
async def test_handle_attach_file_unauthorized_other_user_key(chat_service, file_manager):
# Upload under user1
owner_email = "owner@example.com"
other_email = "other@example.com"
session_id = uuid.uuid4()

filename = "secret.pdf"
content_b64 = base64.b64encode(b"top-secret").decode()
upload_meta = await file_manager.s3_client.upload_file(
user_email=owner_email,
filename=filename,
content_base64=content_b64,
content_type="application/pdf",
tags={"source": "user"},
source_type="user",
)
s3_key = upload_meta["key"]

# Attempt to attach with a different user should fail
resp = await chat_service.handle_attach_file(
session_id=session_id,
s3_key=s3_key,
user_email=other_email,
update_callback=None,
)

assert resp.get("type") == "file_attach"
assert resp.get("success") is False
assert "Access denied" in resp.get("error", "")


@pytest.mark.asyncio
async def test_handle_reset_session_reinitializes(chat_service):
user_email = "user1@example.com"
session_id = uuid.uuid4()

# Create a session first
await chat_service.create_session(session_id, user_email)
assert chat_service.sessions.get(session_id) is not None

# Reset the session
resp = await chat_service.handle_reset_session(session_id=session_id, user_email=user_email)

assert resp.get("type") == "session_reset"
# After reset, a fresh active session should exist for the same id
new_session = chat_service.sessions.get(session_id)
assert new_session is not None
assert new_session.active is True


@pytest.mark.asyncio
async def test_handle_download_file_success_after_attach(chat_service, file_manager):
user_email = "user1@example.com"
session_id = uuid.uuid4()

# Upload and then attach to session
filename = "notes.md"
content_bytes = b"### Title\nSome content."
content_b64 = base64.b64encode(content_bytes).decode()
upload_meta = await file_manager.s3_client.upload_file(
user_email=user_email,
filename=filename,
content_base64=content_b64,
content_type="text/markdown",
tags={"source": "user"},
source_type="user",
)
s3_key = upload_meta["key"]

await chat_service.handle_attach_file(
session_id=session_id,
s3_key=s3_key,
user_email=user_email,
update_callback=None,
)

# Act: download by filename (from session context)
resp = await chat_service.handle_download_file(
session_id=session_id,
filename=filename,
user_email=user_email,
)

assert resp.get("type") is not None
# content_base64 should match uploaded content
returned_b64 = resp.get("content_base64")
assert isinstance(returned_b64, str) and len(returned_b64) > 0
assert base64.b64decode(returned_b64) == content_bytes


@pytest.mark.asyncio
async def test_handle_download_file_not_in_session_returns_error(chat_service):
user_email = "user1@example.com"
session_id = uuid.uuid4()
filename = "missing.txt"

# No attach performed; should error that file isn't in session
resp = await chat_service.handle_download_file(
session_id=session_id,
filename=filename,
user_email=user_email,
)

assert resp.get("error") == "Session or file manager not available" or resp.get("error") == "File not found in session"
33 changes: 26 additions & 7 deletions frontend/src/components/AllFilesView.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { useChat } from '../contexts/ChatContext'
import { useWS } from '../contexts/WSContext'

const AllFilesView = () => {
const { token, user: userEmail } = useChat()
const { token, user: userEmail, ensureSession, addSystemEvent, addPendingFileEvent, attachments } = useChat()
const { sendMessage } = useWS()
const [allFiles, setAllFiles] = useState([])
const [filteredFiles, setFilteredFiles] = useState([])
Expand Down Expand Up @@ -211,17 +211,36 @@ const AllFilesView = () => {
}
}

const handleLoadToSession = async (file) => {
const handleAddToSession = async (file) => {
try {
// Check if file is already attached
if (attachments.has(file.key)) {
addSystemEvent('file-attached', `'${file.filename}' is already in this session.`)
return
}

// Ensure session exists
await ensureSession()

// Add "attaching" system event and track it as pending
const eventId = addSystemEvent('file-attaching', `Adding '${file.filename}' to this session...`, {
fileId: file.key,
fileName: file.filename,
source: 'library'
})

// Track this as a pending file event
addPendingFileEvent(file.key, eventId)

// Send attach_file message (WebSocket handler will resolve the pending event)
sendMessage({
type: 'attach_file',
s3_key: file.key,
user: userEmail
})
showNotification(`File "${file.filename}" loaded to current session`, 'success')
} catch (error) {
console.error('Error loading file to session:', error)
showNotification('Failed to load file to session', 'error')
console.error('Error adding file to session:', error)
addSystemEvent('file-attach-error', `Failed to add '${file.filename}' to session: ${error.message}`)
}
}

Expand Down Expand Up @@ -380,9 +399,9 @@ const AllFilesView = () => {
{/* Action Buttons */}
<div className="flex items-center gap-2 flex-shrink-0">
<button
onClick={() => handleLoadToSession(file)}
onClick={() => handleAddToSession(file)}
className="p-2 rounded-lg bg-purple-600 hover:bg-purple-700 text-white transition-colors"
title="Load to current session"
title="Add to session"
>
<ArrowUpToLine className="w-4 h-4" />
</button>
Expand Down
63 changes: 44 additions & 19 deletions frontend/src/components/Message.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ hljs.registerLanguage('sh', bash)
const processFileReferences = (content) => {
return content.replace(
/@file\s+([^\s]+)/g,
'<span class="inline-flex items-center px-2 py-1 rounded-md bg-green-900/30 border border-green-500/30 text-green-400 text-sm font-medium">📎 @file $1</span>'
'<span class="inline-flex items-center px-2 py-1 rounded-md bg-green-900/30 border border-green-500/30 text-green-400 text-sm font-medium">@file $1</span>'
)
}

Expand Down Expand Up @@ -629,24 +629,24 @@ const renderContent = () => {
</div>
)}

{/* Result Section */}
{message.result && (
<div className="mb-2">
<div className={`border-l-4 pl-4 ${
message.status === 'failed' ? 'border-red-500' : 'border-green-500'
}`}>
<button
onClick={() => setToolOutputCollapsed(!toolOutputCollapsed)}
className={`w-full text-left text-sm font-semibold mb-2 flex items-center gap-2 transition-colors ${
message.status === 'failed'
? 'text-red-400 hover:text-red-300'
: 'text-green-400 hover:text-green-300'
}`}
>
<span className={`transform transition-transform duration-200 ${toolOutputCollapsed ? 'rotate-0' : 'rotate-90'}`}>
</span>
{message.status === 'failed' ? 'Error Details' : 'Output Result'} {toolOutputCollapsed ? '(click to expand)' : ''}
{/* Result Section */}
{message.result && (
<div className="mb-2">
<div className={`border-l-4 pl-4 ${
message.status === 'failed' ? 'border-red-500' : 'border-green-500'
}`}>
<button
onClick={() => setToolOutputCollapsed(!toolOutputCollapsed)}
className={`w-full text-left text-sm font-semibold mb-2 flex items-center gap-2 transition-colors ${
message.status === 'failed'
? 'text-red-400 hover:text-red-300'
: 'text-green-400 hover:text-green-300'
}`}
>
<span className={`transform transition-transform duration-200 ${toolOutputCollapsed ? 'rotate-0' : 'rotate-90'}`}>
</span>
{message.status === 'failed' ? 'Error Details' : 'Output Result'} {toolOutputCollapsed ? '(click to expand)' : ''}
</button>

{!toolOutputCollapsed && (
Expand Down Expand Up @@ -768,6 +768,31 @@ const renderContent = () => {
}

if (isUser || isSystem) {
// Handle file attachment system events
if (message.type === 'system' && message.subtype) {
switch (message.subtype) {
case 'file-attaching':
return (
<div className="text-blue-300 italic">
{message.text}
</div>
)
case 'file-attached':
return (
<div className="text-green-300">
{message.text}
</div>
)
case 'file-attach-error':
return (
<div className="text-red-300">
{message.text}
</div>
)
default:
return <div className="text-gray-200">{message.content}</div>
}
}
return <div className="text-gray-200">{message.content}</div>
}

Expand Down
Loading
Loading