2025-10-11 01:07:45 +08:00
|
|
|
"""
|
|
|
|
|
Integration tests for chat router endpoints.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-03-31 20:18:22 +08:00
|
|
|
import uuid
|
|
|
|
|
|
2025-10-11 01:07:45 +08:00
|
|
|
import pytest
|
2026-03-31 20:18:22 +08:00
|
|
|
from yuxi.agents.backends.sandbox import ensure_thread_dirs, sandbox_user_data_dir, sandbox_workspace_dir
|
2025-10-11 01:07:45 +08:00
|
|
|
|
|
|
|
|
pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_chat_endpoints_require_authentication(test_client):
|
|
|
|
|
assert (await test_client.get("/api/chat/default_agent")).status_code == 401
|
|
|
|
|
assert (await test_client.get("/api/chat/agent")).status_code == 401
|
|
|
|
|
|
|
|
|
|
|
2026-03-31 20:18:22 +08:00
|
|
|
async def _create_thread_for_user(test_client, headers: dict[str, str]) -> str:
|
|
|
|
|
agents_resp = await test_client.get("/api/chat/agent", headers=headers)
|
|
|
|
|
assert agents_resp.status_code == 200, agents_resp.text
|
|
|
|
|
agents = agents_resp.json().get("agents", [])
|
|
|
|
|
if not agents:
|
|
|
|
|
pytest.skip("No agents available for chat router integration tests.")
|
|
|
|
|
|
|
|
|
|
agent_id = agents[0].get("id")
|
|
|
|
|
if not agent_id:
|
|
|
|
|
pytest.skip("Agent payload missing id field.")
|
|
|
|
|
|
|
|
|
|
create_resp = await test_client.post(
|
|
|
|
|
"/api/chat/thread",
|
|
|
|
|
json={
|
|
|
|
|
"agent_id": agent_id,
|
|
|
|
|
"title": f"chat-router-test-{uuid.uuid4().hex[:8]}",
|
|
|
|
|
"metadata": {},
|
|
|
|
|
},
|
|
|
|
|
headers=headers,
|
|
|
|
|
)
|
|
|
|
|
assert create_resp.status_code == 200, create_resp.text
|
|
|
|
|
payload = create_resp.json()
|
|
|
|
|
thread_id = payload.get("thread_id") or payload.get("id")
|
|
|
|
|
assert thread_id, f"Create thread response missing thread identifier: {payload}"
|
|
|
|
|
return thread_id
|
|
|
|
|
|
|
|
|
|
|
2025-10-11 01:07:45 +08:00
|
|
|
async def test_admin_can_list_agents(test_client, admin_headers):
|
|
|
|
|
response = await test_client.get("/api/chat/agent", headers=admin_headers)
|
|
|
|
|
assert response.status_code == 200, response.text
|
|
|
|
|
payload = response.json()
|
|
|
|
|
assert isinstance(payload["agents"], list)
|
2026-03-25 06:32:14 +08:00
|
|
|
if payload["agents"]:
|
|
|
|
|
assert "id" in payload["agents"][0]
|
2025-10-11 01:07:45 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_admin_can_read_default_agent(test_client, admin_headers):
|
|
|
|
|
response = await test_client.get("/api/chat/default_agent", headers=admin_headers)
|
|
|
|
|
assert response.status_code == 200, response.text
|
|
|
|
|
assert "default_agent_id" in response.json()
|
|
|
|
|
|
|
|
|
|
|
2026-05-18 09:41:24 +08:00
|
|
|
async def test_standard_user_manages_own_agent_config_with_role_filtered_fields(
|
|
|
|
|
test_client,
|
|
|
|
|
admin_headers,
|
|
|
|
|
standard_user,
|
|
|
|
|
):
|
|
|
|
|
agents_response = await test_client.get("/api/chat/agent", headers=standard_user["headers"])
|
|
|
|
|
assert agents_response.status_code == 200, agents_response.text
|
|
|
|
|
agents = agents_response.json().get("agents", [])
|
|
|
|
|
if not agents:
|
|
|
|
|
pytest.skip("No agents are registered in the system.")
|
|
|
|
|
|
|
|
|
|
agent_id = agents[0]["id"]
|
|
|
|
|
|
|
|
|
|
user_agent_response = await test_client.get(f"/api/chat/agent/{agent_id}", headers=standard_user["headers"])
|
|
|
|
|
assert user_agent_response.status_code == 200, user_agent_response.text
|
|
|
|
|
user_items = user_agent_response.json().get("configurable_items", {})
|
|
|
|
|
assert "summary_threshold" not in user_items
|
|
|
|
|
|
|
|
|
|
admin_agent_response = await test_client.get(f"/api/chat/agent/{agent_id}", headers=admin_headers)
|
|
|
|
|
assert admin_agent_response.status_code == 200, admin_agent_response.text
|
|
|
|
|
admin_items = admin_agent_response.json().get("configurable_items", {})
|
|
|
|
|
assert "summary_threshold" in admin_items
|
|
|
|
|
|
|
|
|
|
config_name = f"pytest-config-{uuid.uuid4().hex[:8]}"
|
|
|
|
|
create_response = await test_client.post(
|
|
|
|
|
f"/api/chat/agent/{agent_id}/configs",
|
|
|
|
|
json={
|
|
|
|
|
"name": config_name,
|
|
|
|
|
"config_json": {"context": {"system_prompt": "user visible", "summary_threshold": 1}},
|
|
|
|
|
"set_default": True,
|
|
|
|
|
},
|
|
|
|
|
headers=standard_user["headers"],
|
|
|
|
|
)
|
|
|
|
|
assert create_response.status_code == 200, create_response.text
|
|
|
|
|
created = create_response.json()["config"]
|
|
|
|
|
config_id = created["id"]
|
|
|
|
|
assert created["uid"] == standard_user["user"]["uid"]
|
|
|
|
|
assert created["is_default"] is True
|
|
|
|
|
assert created["config_json"]["context"]["system_prompt"] == "user visible"
|
|
|
|
|
assert "summary_threshold" not in created["config_json"]["context"]
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
admin_list_response = await test_client.get(f"/api/chat/agent/{agent_id}/configs", headers=admin_headers)
|
|
|
|
|
assert admin_list_response.status_code == 200, admin_list_response.text
|
|
|
|
|
admin_config_ids = {item["id"] for item in admin_list_response.json().get("configs", [])}
|
|
|
|
|
assert config_id not in admin_config_ids
|
|
|
|
|
|
|
|
|
|
update_response = await test_client.put(
|
|
|
|
|
f"/api/chat/agent/{agent_id}/configs/{config_id}",
|
|
|
|
|
json={"config_json": {"context": {"system_prompt": "updated", "summary_threshold": 2}}},
|
|
|
|
|
headers=standard_user["headers"],
|
|
|
|
|
)
|
|
|
|
|
assert update_response.status_code == 200, update_response.text
|
|
|
|
|
updated_context = update_response.json()["config"]["config_json"]["context"]
|
|
|
|
|
assert updated_context == {"system_prompt": "updated"}
|
|
|
|
|
|
|
|
|
|
default_response = await test_client.post(
|
|
|
|
|
f"/api/chat/agent/{agent_id}/configs/{config_id}/set_default",
|
|
|
|
|
json={},
|
|
|
|
|
headers=standard_user["headers"],
|
|
|
|
|
)
|
|
|
|
|
assert default_response.status_code == 200, default_response.text
|
|
|
|
|
assert default_response.json()["config"]["is_default"] is True
|
|
|
|
|
finally:
|
|
|
|
|
delete_response = await test_client.delete(
|
|
|
|
|
f"/api/chat/agent/{agent_id}/configs/{config_id}",
|
|
|
|
|
headers=standard_user["headers"],
|
|
|
|
|
)
|
|
|
|
|
assert delete_response.status_code in (200, 404), delete_response.text
|
|
|
|
|
|
|
|
|
|
|
2025-10-11 01:07:45 +08:00
|
|
|
async def test_setting_default_agent_requires_admin(test_client, admin_headers, standard_user):
|
|
|
|
|
# Attempt as admin first to obtain a candidate agent id.
|
|
|
|
|
agents_response = await test_client.get("/api/chat/agent", headers=admin_headers)
|
|
|
|
|
assert agents_response.status_code == 200, agents_response.text
|
|
|
|
|
agents = agents_response.json().get("agents", [])
|
|
|
|
|
|
|
|
|
|
if not agents:
|
|
|
|
|
pytest.skip("No agents are registered in the system.")
|
|
|
|
|
|
|
|
|
|
candidate_agent_id = agents[0].get("id")
|
|
|
|
|
if not candidate_agent_id:
|
|
|
|
|
pytest.skip("Agent payload missing id field.")
|
|
|
|
|
|
|
|
|
|
# Normal user should not be able to update the default agent.
|
|
|
|
|
forbidden_response = await test_client.post(
|
|
|
|
|
"/api/chat/set_default_agent",
|
|
|
|
|
json={"agent_id": candidate_agent_id},
|
|
|
|
|
headers=standard_user["headers"],
|
|
|
|
|
)
|
|
|
|
|
assert forbidden_response.status_code == 403
|
|
|
|
|
|
|
|
|
|
# Admin should succeed.
|
|
|
|
|
update_response = await test_client.post(
|
|
|
|
|
"/api/chat/set_default_agent",
|
|
|
|
|
json={"agent_id": candidate_agent_id},
|
|
|
|
|
headers=admin_headers,
|
|
|
|
|
)
|
|
|
|
|
assert update_response.status_code == 200, update_response.text
|
|
|
|
|
assert update_response.json()["default_agent_id"] == candidate_agent_id
|
2026-03-31 20:18:22 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_save_thread_artifact_to_workspace_copies_output_file(test_client, standard_user):
|
|
|
|
|
headers = standard_user["headers"]
|
2026-05-17 22:44:05 +08:00
|
|
|
uid = str(standard_user["user"]["uid"])
|
2026-03-31 20:18:22 +08:00
|
|
|
thread_id = await _create_thread_for_user(test_client, headers)
|
|
|
|
|
filename = f"artifact-{uuid.uuid4().hex[:8]}.md"
|
|
|
|
|
|
2026-05-17 22:44:05 +08:00
|
|
|
ensure_thread_dirs(thread_id, uid)
|
2026-03-31 20:18:22 +08:00
|
|
|
source_path = sandbox_user_data_dir(thread_id) / "outputs" / filename
|
|
|
|
|
source_path.write_text("# artifact\n", encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
response = await test_client.post(
|
|
|
|
|
f"/api/chat/thread/{thread_id}/artifacts/save",
|
|
|
|
|
json={"path": f"/home/gem/user-data/outputs/{filename}"},
|
|
|
|
|
headers=headers,
|
|
|
|
|
)
|
|
|
|
|
assert response.status_code == 200, response.text
|
|
|
|
|
|
|
|
|
|
payload = response.json()
|
|
|
|
|
assert payload["name"] == filename
|
|
|
|
|
assert payload["source_path"] == f"/home/gem/user-data/outputs/{filename}"
|
|
|
|
|
assert payload["saved_path"] == f"/home/gem/user-data/workspace/saved_artifacts/{filename}"
|
|
|
|
|
|
2026-05-17 22:44:05 +08:00
|
|
|
saved_path = sandbox_workspace_dir(thread_id, uid) / "saved_artifacts" / filename
|
2026-03-31 20:18:22 +08:00
|
|
|
assert saved_path.exists()
|
|
|
|
|
assert saved_path.read_text(encoding="utf-8") == "# artifact\n"
|
|
|
|
|
|
|
|
|
|
download_response = await test_client.get(payload["saved_artifact_url"], headers=headers)
|
|
|
|
|
assert download_response.status_code == 200, download_response.text
|
|
|
|
|
assert download_response.text == "# artifact\n"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_save_thread_artifact_to_workspace_auto_renames_conflicts(test_client, standard_user):
|
|
|
|
|
headers = standard_user["headers"]
|
2026-05-17 22:44:05 +08:00
|
|
|
uid = str(standard_user["user"]["uid"])
|
2026-03-31 20:18:22 +08:00
|
|
|
thread_id = await _create_thread_for_user(test_client, headers)
|
|
|
|
|
filename = f"artifact-{uuid.uuid4().hex[:8]}.txt"
|
|
|
|
|
renamed_filename = filename.replace(".txt", " (1).txt")
|
|
|
|
|
|
2026-05-17 22:44:05 +08:00
|
|
|
ensure_thread_dirs(thread_id, uid)
|
2026-03-31 20:18:22 +08:00
|
|
|
source_path = sandbox_user_data_dir(thread_id) / "outputs" / filename
|
|
|
|
|
source_path.write_text("first\n", encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
first_response = await test_client.post(
|
|
|
|
|
f"/api/chat/thread/{thread_id}/artifacts/save",
|
|
|
|
|
json={"path": f"/home/gem/user-data/outputs/{filename}"},
|
|
|
|
|
headers=headers,
|
|
|
|
|
)
|
|
|
|
|
assert first_response.status_code == 200, first_response.text
|
|
|
|
|
|
|
|
|
|
source_path.write_text("second\n", encoding="utf-8")
|
|
|
|
|
second_response = await test_client.post(
|
|
|
|
|
f"/api/chat/thread/{thread_id}/artifacts/save",
|
|
|
|
|
json={"path": f"/home/gem/user-data/outputs/{filename}"},
|
|
|
|
|
headers=headers,
|
|
|
|
|
)
|
|
|
|
|
assert second_response.status_code == 200, second_response.text
|
|
|
|
|
|
|
|
|
|
first_payload = first_response.json()
|
|
|
|
|
second_payload = second_response.json()
|
|
|
|
|
assert first_payload["saved_path"] == f"/home/gem/user-data/workspace/saved_artifacts/{filename}"
|
|
|
|
|
assert second_payload["saved_path"] == f"/home/gem/user-data/workspace/saved_artifacts/{renamed_filename}"
|
|
|
|
|
|
2026-05-17 22:44:05 +08:00
|
|
|
first_saved = sandbox_workspace_dir(thread_id, uid) / "saved_artifacts" / filename
|
|
|
|
|
second_saved = sandbox_workspace_dir(thread_id, uid) / "saved_artifacts" / renamed_filename
|
2026-03-31 20:18:22 +08:00
|
|
|
assert first_saved.read_text(encoding="utf-8") == "first\n"
|
|
|
|
|
assert second_saved.read_text(encoding="utf-8") == "second\n"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_save_thread_artifact_to_workspace_rejects_invalid_paths(test_client, standard_user):
|
|
|
|
|
headers = standard_user["headers"]
|
2026-05-17 22:44:05 +08:00
|
|
|
uid = str(standard_user["user"]["uid"])
|
2026-03-31 20:18:22 +08:00
|
|
|
thread_id = await _create_thread_for_user(test_client, headers)
|
|
|
|
|
|
|
|
|
|
invalid_response = await test_client.post(
|
|
|
|
|
f"/api/chat/thread/{thread_id}/artifacts/save",
|
|
|
|
|
json={"path": "/home/gem/user-data/not-allowed/demo.txt"},
|
|
|
|
|
headers=headers,
|
|
|
|
|
)
|
|
|
|
|
assert invalid_response.status_code == 404, invalid_response.text
|
|
|
|
|
|
2026-05-17 22:44:05 +08:00
|
|
|
ensure_thread_dirs(thread_id, uid)
|
|
|
|
|
directory_path = sandbox_workspace_dir(thread_id, uid) / "nested-dir"
|
2026-03-31 20:18:22 +08:00
|
|
|
directory_path.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
directory_response = await test_client.post(
|
|
|
|
|
f"/api/chat/thread/{thread_id}/artifacts/save",
|
|
|
|
|
json={"path": "/home/gem/user-data/workspace/nested-dir"},
|
|
|
|
|
headers=headers,
|
|
|
|
|
)
|
|
|
|
|
assert directory_response.status_code == 400, directory_response.text
|