feat: complete chat sessions and production startup
This commit is contained in:
@@ -232,10 +232,12 @@ def test_sse_persists_messages_and_tool_failure(session_factory, monkeypatch):
|
||||
created = client.post("/api/conversations", json={"title": "测试对话"}).json()
|
||||
with client.stream("POST", f"/api/conversations/{created['id']}/messages", json={"content": "列出课程"}) as response:
|
||||
body = "".join(response.iter_text())
|
||||
assert body.count("event: tool") == 9 and "event: message" in body
|
||||
assert body.count("event: tool") == 18 and "event: message" in body and "event: done" in body
|
||||
restored = client.get(f"/api/conversations/{created['id']}").json()
|
||||
assert [m["role"] for m in restored["messages"]] == ["user", "assistant"]
|
||||
assert len(restored["messages"][1]["tool_calls"]) == 9
|
||||
assert len({call["id"] for call in restored["messages"][1]["tool_calls"]}) == 9
|
||||
assert all(call["status"] == "completed" and call["result"] for call in restored["messages"][1]["tool_calls"])
|
||||
assert restored["runs"][0]["status"] == "completed"
|
||||
monkeypatch.setattr(chat_api, "client_for_chat", lambda db: (_ for _ in ()).throw(ModelError("不支持工具调用")))
|
||||
with client.stream("POST", f"/api/conversations/{created['id']}/messages", json={"content": "再试"}) as response:
|
||||
@@ -273,11 +275,13 @@ def test_failed_tool_calls_count_toward_limit(monkeypatch):
|
||||
return SimpleNamespace(all=lambda: [])
|
||||
def commit(self):
|
||||
pass
|
||||
def rollback(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(chat_api, "client_for_chat", lambda db: RepeatingClient())
|
||||
monkeypatch.setattr(chat_api, "get_settings", lambda: SimpleNamespace(chat_max_tool_calls=9))
|
||||
body = "".join(chat_api._run_agent(FakeSession, 1, 1, "循环调用工具"))
|
||||
assert body.count("event: tool") == 9
|
||||
assert body.count("event: tool") == 18
|
||||
assert "本次消息工具调用超过上限 9 次" in body
|
||||
assert run.status == "failed"
|
||||
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Session lifecycle and recoverable background runs against the isolated test database."""
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from threading import Event
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from wisegrade import chat_api
|
||||
from wisegrade.db import get_db
|
||||
from wisegrade.main import app
|
||||
from wisegrade.models import AgentRun, AuditLog, ChatMessage, Conversation, CourseVersion, PendingAction, Project, ScoringBatch, Student, now
|
||||
|
||||
|
||||
def client_for(session_factory):
|
||||
def override():
|
||||
with session_factory() as db:
|
||||
yield db
|
||||
app.dependency_overrides[get_db] = override
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_first_message_is_atomic_idempotent_and_retryable(session_factory, monkeypatch):
|
||||
monkeypatch.setattr(chat_api, "_launch", lambda *_: None)
|
||||
client = client_for(session_factory)
|
||||
try:
|
||||
assert client.get('/api/conversations').json() == []
|
||||
key = str(uuid4())
|
||||
payload = {"request_id": key, "content": "列出课程"}
|
||||
assert client.post('/api/conversations/first-message', json={**payload, "content": " "}).status_code == 422
|
||||
assert client.get('/api/conversations').json() == []
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
replies = list(pool.map(lambda _: client.post('/api/conversations/first-message', json=payload), range(2)))
|
||||
assert all(reply.status_code == 200 for reply in replies)
|
||||
assert replies[0].json() == replies[1].json()
|
||||
conversation_id = replies[0].json()['conversation_id']
|
||||
with session_factory() as db:
|
||||
assert db.scalar(select(func.count(Conversation.id))) == 1
|
||||
assert db.scalar(select(func.count(ChatMessage.id))) == 1
|
||||
assert db.scalar(select(func.count(AgentRun.id))) == 1
|
||||
detail = client.get(f'/api/conversations/{conversation_id}').json()
|
||||
assert detail['scope_locked'] and detail['messages'][0]['content'] == '列出课程'
|
||||
assert client.get('/api/conversations').json()[0]['running'] is True
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_legacy_empty_scope_can_change_then_locks(session_factory, monkeypatch):
|
||||
monkeypatch.setattr(chat_api, "_launch", lambda *_: None)
|
||||
client = client_for(session_factory)
|
||||
try:
|
||||
legacy = client.post('/api/conversations', json={"title": "旧空会话"}).json()
|
||||
assert legacy['scope_locked'] is False
|
||||
with session_factory() as db:
|
||||
version = CourseVersion(course_id=1, version=2, title="第二版")
|
||||
db.add(version)
|
||||
db.flush()
|
||||
student = Student(student_no="987654321", name="测试学生")
|
||||
db.add(student)
|
||||
db.flush()
|
||||
project = Project(course_version_id=version.id, student_id=student.id, topic="测试", source_dir="test/scope")
|
||||
db.add(project)
|
||||
db.commit()
|
||||
version_id, project_id = version.id, project.id
|
||||
changed = client.put(f"/api/conversations/{legacy['id']}/scope", json={"project_id": project_id})
|
||||
assert changed.status_code == 200
|
||||
assert changed.json()['course_version_id'] == version_id
|
||||
assert changed.json()['project_id'] == project_id
|
||||
assert client.put(f"/api/conversations/{legacy['id']}/scope", json={"course_version_id": 1, "project_id": project_id}).status_code == 400
|
||||
cleared = client.put(f"/api/conversations/{legacy['id']}/scope", json={})
|
||||
assert cleared.json()['course_version_id'] is None and cleared.json()['project_id'] is None
|
||||
started = client.post(f"/api/conversations/{legacy['id']}/messages/start", json={"content": "开始"})
|
||||
assert started.status_code == 200
|
||||
assert client.put(f"/api/conversations/{legacy['id']}/scope", json={"course_version_id": version_id}).status_code == 409
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_delete_related_rows_preserves_audit_and_rejects_running(session_factory, monkeypatch):
|
||||
monkeypatch.setattr(chat_api, "_launch", lambda *_: None)
|
||||
client = client_for(session_factory)
|
||||
try:
|
||||
first = client.post('/api/conversations/first-message', json={"request_id": str(uuid4()), "content": "运行中"}).json()
|
||||
second = client.post('/api/conversations', json={"title": "可删除"}).json()
|
||||
second_id = second['id']
|
||||
with session_factory() as db:
|
||||
db.add(ChatMessage(conversation_id=second_id, role="user", content="历史"))
|
||||
db.add(AgentRun(conversation_id=second_id, status="completed"))
|
||||
db.add(PendingAction(conversation_id=second_id, kind="export", payload={}, preview={}, expected={},
|
||||
idempotency_key=uuid4().hex, status="pending", expires_at=now()))
|
||||
db.add(AuditLog(action="chat_export", entity_type="conversation", entity_id=second_id, details={"result": "kept"}))
|
||||
db.commit()
|
||||
assert client.delete(f"/api/conversations/{first['conversation_id']}").status_code == 409
|
||||
assert client.delete(f'/api/conversations/{second_id}').status_code == 200
|
||||
assert client.get(f'/api/conversations/{second_id}').status_code == 404
|
||||
assert client.post(f'/api/conversations/{second_id}/actions/1/confirm').status_code == 404
|
||||
with session_factory() as db:
|
||||
assert db.scalar(select(func.count(ChatMessage.id)).where(ChatMessage.conversation_id == second_id)) == 0
|
||||
assert db.scalar(select(func.count(AgentRun.id)).where(AgentRun.conversation_id == second_id)) == 0
|
||||
assert db.scalar(select(func.count(PendingAction.id)).where(PendingAction.conversation_id == second_id)) == 0
|
||||
assert db.scalar(select(AuditLog).where(AuditLog.entity_id == second_id)).details == {"result": "kept"}
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_background_runs_survive_no_stream_and_isolate_parallel_events(session_factory, monkeypatch):
|
||||
gates = {"甲": Event(), "乙": Event()}
|
||||
class FakeClient:
|
||||
def tool_call(self, messages, _tools):
|
||||
label = next(message['content'] for message in messages if message['role'] == 'user')
|
||||
if not any(message['role'] == 'tool' for message in messages):
|
||||
return {"role": "assistant", "content": None, "tool_calls": [{"id": f"call-{label}",
|
||||
"type": "function", "function": {"name": "list_courses", "arguments": "{}"}}]}
|
||||
return {"role": "assistant", "content": f"回答{label}", "tool_calls": []}
|
||||
def execute(_db, _conversation, _name, _arguments, user_text):
|
||||
assert gates[user_text].wait(8)
|
||||
return {"courses": [{"id": 1}]}
|
||||
monkeypatch.setattr(chat_api, "client_for_chat", lambda _db: FakeClient())
|
||||
monkeypatch.setattr(chat_api, "execute", execute)
|
||||
client = client_for(session_factory)
|
||||
try:
|
||||
ids = [client.post('/api/conversations/first-message', json={"request_id": str(uuid4()), "content": label}).json()['conversation_id'] for label in gates]
|
||||
deadline = time.monotonic() + 8
|
||||
while time.monotonic() < deadline:
|
||||
details = [client.get(f'/api/conversations/{id}').json() for id in ids]
|
||||
if all(d['runs'][0]['tool_calls'] for d in details): break
|
||||
time.sleep(.1)
|
||||
assert all(d['runs'][0]['tool_calls'][0]['id'].endswith(f'call-{label}') for d, label in zip(details, gates))
|
||||
assert all(d['runs'][0]['status'] == 'running' for d in details)
|
||||
subscription = chat_api._stream_run(session_factory, details[0]['runs'][0]['id'])
|
||||
assert 'event: run' in next(subscription)
|
||||
subscription.close() # Closing the SSE reader must not cancel the worker.
|
||||
gates['甲'].set()
|
||||
deadline = time.monotonic() + 8
|
||||
while time.monotonic() < deadline:
|
||||
first = client.get(f'/api/conversations/{ids[0]}').json()
|
||||
if first['runs'][0]['status'] == 'completed': break
|
||||
time.sleep(.1)
|
||||
other = client.get(f'/api/conversations/{ids[1]}').json()
|
||||
assert first['messages'][-1]['content'] == '回答甲'
|
||||
assert first['messages'][-1]['tool_calls'][0]['id'].endswith('call-甲')
|
||||
assert other['runs'][0]['status'] == 'running'
|
||||
assert all('回答甲' not in m['content'] for m in other['messages'])
|
||||
gates['乙'].set()
|
||||
deadline = time.monotonic() + 8
|
||||
while time.monotonic() < deadline:
|
||||
other = client.get(f'/api/conversations/{ids[1]}').json()
|
||||
if other['runs'][0]['status'] == 'completed': break
|
||||
time.sleep(.1)
|
||||
assert other['messages'][-1]['content'] == '回答乙'
|
||||
assert other['messages'][-1]['tool_calls'][0]['id'].endswith('call-乙')
|
||||
assert other['messages'][-1]['tool_calls'][0]['summary'] == '返回 1 条courses记录'
|
||||
assert other['messages'][-1]['tool_calls'][0]['result'] == {'courses': {'count': 1}}
|
||||
first_events = client.get(f"/api/conversations/{ids[0]}/runs/{first['runs'][0]['id']}/events").text
|
||||
second_events = client.get(f"/api/conversations/{ids[1]}/runs/{other['runs'][0]['id']}/events").text
|
||||
assert 'call-甲' in first_events and 'call-乙' not in first_events
|
||||
assert 'call-乙' in second_events and 'call-甲' not in second_events
|
||||
assert client.get(f"/api/conversations/{ids[0]}/runs/{other['runs'][0]['id']}/events").status_code == 404
|
||||
finally:
|
||||
for gate in gates.values(): gate.set()
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_tool_record_redacts_text_and_secrets():
|
||||
saved = chat_api._safe_arguments({"project_id": 2, "query": "学生原文", "api_key": "secret", "comment": "敏感评语"})
|
||||
assert saved["project_id"] == 2
|
||||
assert "学生原文" not in str(saved) and "secret" not in str(saved) and "敏感评语" not in str(saved)
|
||||
assert "学生原文" not in chat_api._result_summary({"found": True, "citations": [{"quote": "学生原文"}]})
|
||||
assert "学生原文" not in str(chat_api._safe_result({"citations": [{"quote": "学生原文"}]}))
|
||||
|
||||
|
||||
def test_upgrade_from_legacy_0003_preserves_history(session_factory):
|
||||
from pathlib import Path
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import text
|
||||
|
||||
with session_factory() as db:
|
||||
row = Conversation(title="旧记录")
|
||||
db.add(row)
|
||||
db.flush()
|
||||
db.add(ChatMessage(conversation_id=row.id, role="user", content="旧问题"))
|
||||
db.add(AgentRun(conversation_id=row.id, status="completed"))
|
||||
db.commit()
|
||||
conversation_id = row.id
|
||||
engine = session_factory.kw['bind']
|
||||
with engine.begin() as connection:
|
||||
connection.exec_driver_sql('ALTER TABLE conversations DROP COLUMN first_request_id')
|
||||
connection.exec_driver_sql('ALTER TABLE agent_runs DROP COLUMN tool_calls')
|
||||
connection.exec_driver_sql('ALTER TABLE agent_runs DROP COLUMN events')
|
||||
connection.exec_driver_sql("UPDATE alembic_version SET version_num = '0003'")
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
config = Config(str(root / 'alembic.ini'))
|
||||
config.set_main_option('script_location', str(root / 'migrations'))
|
||||
command.upgrade(config, 'head')
|
||||
with session_factory() as db:
|
||||
assert db.scalar(text('SELECT version_num FROM alembic_version')) == '0004'
|
||||
assert db.get(Conversation, conversation_id).title == '旧记录'
|
||||
assert db.scalar(select(ChatMessage.content).where(ChatMessage.conversation_id == conversation_id)) == '旧问题'
|
||||
run = db.scalar(select(AgentRun).where(AgentRun.conversation_id == conversation_id))
|
||||
assert run.tool_calls == [] and run.events == []
|
||||
@@ -10,7 +10,7 @@ from wisegrade.storage import classify, extract_identity, parse_student_folder,
|
||||
|
||||
def test_postgresql_migrations_and_vector_extension(db):
|
||||
assert db.bind.dialect.name == "postgresql"
|
||||
assert db.scalar(text("SELECT version_num FROM alembic_version")) == "0003"
|
||||
assert db.scalar(text("SELECT version_num FROM alembic_version")) == "0004"
|
||||
assert db.scalar(text("SELECT extname FROM pg_extension WHERE extname = 'vector'")) == "vector"
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user