Files
dev-puppeteer/my-deepagent/tests/integration/test_conversation_gui.py
chungyeong e326c07dcb feat(my-deepagent): v0.3 PR #8 — conversation-centric Web GUI (/conversation.html)
Workflow run 페이지를 archive 로 격하시키고, 사용자가 처음 보는 화면을
chat-style 대화 thread 로 전환.  Claude Code 의 Web GUI 와 동일한 UX.

핵심 동작:
- 새 페이지 `/conversation.html` 에서 세션을 picker 로 고르거나 "새 대화"
  버튼으로 만들고 메시지 입력.  Cmd/Ctrl+Enter 로 전송.
- POST /api/sessions/{id}/messages 가 user MessageRow 를 영속한 즉시 200 응답
  후 `asyncio.create_task(invoke_session_agent(...))` 로 백그라운드 invoke 발사.
- 백그라운드 task 는 lifespan 에서 1회 열어둔 LangGraph saver 를 재사용하고
  agent.ainvoke → assistant MessageRow 영속 → 자동 compaction 까지 처리.
- 기존 SSE 스트림 (`/api/sessions/{id}/stream`) 이 새 메시지를 push,
  프론트엔드의 `EventSource` 가 받아 thread 에 렌더.

신규 / 수정 파일:
- `static/conversation.html` (신규): chat UI 마크업.  data-page="conversation".
- `static/app.js`: 새 페이지 핸들러 `bootstrapConversationPage` +
  세션 picker + 메시지 thread 렌더 + SSE 구독 + Cmd/Ctrl+Enter 단축키.
  XSS 정책 동일: 모든 사용자 콘텐츠는 `textContent` 만 사용.
- `static/style.css`: `.messages-thread`, `.msg-bubble`, `.conv-topbar`,
  `.conv-input-bar` 등 chat UI 스타일.
- `api/app.py`: lifespan 에서 LangGraph saver 를 1회 열어 `app.state.saver`
  에 보관 (Postgres 일 때만).
- `api/agent_runner.py` (신규): `invoke_session_agent(...)` — REPL 의
  `InteractiveSession + _invoke_and_stream` 와 동일한 stack 을 HTTP background
  context 용으로 재구성.  실패는 로깅 + return.
- `api/routes/sessions.py`: POST /messages 가 background task 발사 + ref 를
  `app.state.pending_invocations` set 에 보관 (RUF006 / GC drop 방지).

테스트 (`tests/integration/test_conversation_gui.py`, 4 케이스):
- GET /conversation.html → 200 + 필수 마크업
- POST /messages → 200 + user row 영속 + 스텁 runner 호출 확인
- 백그라운드 task ref 가 `pending_invocations` 에 잡혀있고 완료 후 자동 discard
- 스텁 runner 가 assistant row 영속 → user + assistant 시퀀스 검증

게이트:
- ruff check / format --check / mypy: PASS
- pytest -q --ignore=tests/integration/test_e2e_workflow.py
  --ignore=tests/integration/test_openrouter_smoke.py: 675 passed (4 신규 포함)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:03:09 +09:00

240 lines
7.9 KiB
Python

"""v0.3 PR #8 — Conversation Web GUI tests.
Covers:
1. GET /conversation.html serves the static file (200).
2. POST /api/sessions/{id}/messages still returns 200 + queues a background
task (the agent_runner is stubbed so we never hit OpenRouter).
3. The background task persists an assistant MessageRow that the SSE stream
then surfaces.
4. The background task is awaited correctly (asyncio.Task ref held on
app.state so RUF006 doesn't drop it mid-flight).
"""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator
from pathlib import Path
from typing import Any
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from my_deepagent.api.app import create_app
from my_deepagent.config import load_config
from my_deepagent.persistence.db import Database
from my_deepagent.persistence.models import InteractiveSessionRow, MessageRow
@pytest.fixture
async def app_client(tmp_path: Path) -> AsyncIterator[tuple[AsyncClient, Database]]:
db_url = f"sqlite+aiosqlite:///{tmp_path / 'conv.sqlite3'}"
cfg = load_config(
workspace_root=tmp_path,
data_dir=tmp_path / "data",
database_url=db_url,
)
db = Database(db_url)
await db.init_schema()
await db.dispose()
app = create_app(cfg)
transport = ASGITransport(app=app)
async with app.router.lifespan_context(app):
# Tests get their own Database instance for direct row inspection.
external_db = Database(db_url)
async with AsyncClient(transport=transport, base_url="http://test", timeout=10.0) as client:
yield (client, external_db)
await external_db.dispose()
# ---------------------------------------------------------------------------
# Static file serving
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_conversation_page_served(
app_client: tuple[AsyncClient, Database],
) -> None:
client, _ = app_client
r = await client.get("/conversation.html")
assert r.status_code == 200
assert 'data-page="conversation"' in r.text
assert "message-input" in r.text
# ---------------------------------------------------------------------------
# POST /messages still 200 + background task fires
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_post_message_returns_ack_and_persists_user_row(
app_client: tuple[AsyncClient, Database], monkeypatch: pytest.MonkeyPatch
) -> None:
client, db = app_client
invocations: list[tuple[str, str]] = []
async def fake_invoke(
_db: Any,
_config: Any,
_personas: Any,
session_id: Any,
user_message: str,
*,
saver: Any = None,
) -> None:
invocations.append((str(session_id), user_message))
monkeypatch.setattr("my_deepagent.api.routes.sessions.invoke_session_agent", fake_invoke)
# Create a session.
r = await client.post(
"/api/sessions",
json={"persona_name": "default-interactive", "repo_path": str(Path.cwd())},
)
assert r.status_code == 200
sid = r.json()["session_id"]
# POST a message.
r2 = await client.post(f"/api/sessions/{sid}/messages", json={"content": "hello agent"})
assert r2.status_code == 200
assert r2.json()["state"] == "active"
# User row persisted synchronously.
async with db.session() as s:
rows = (
(
await s.execute(
select(MessageRow).where(MessageRow.session_id == sid).order_by(MessageRow.seq)
)
)
.scalars()
.all()
)
assert len(rows) == 1
assert rows[0].role == "user"
assert rows[0].content == "hello agent"
# Give the event loop one cycle so the background task can fire.
await asyncio.sleep(0.05)
assert invocations == [(sid, "hello agent")]
@pytest.mark.asyncio
async def test_post_message_holds_task_ref_on_app_state(
app_client: tuple[AsyncClient, Database], monkeypatch: pytest.MonkeyPatch
) -> None:
"""Background task must be held on app.state.pending_invocations so the
GC + RUF006 don't drop it before completion."""
client, _ = app_client
started = asyncio.Event()
can_finish = asyncio.Event()
async def slow_invoke(*_a: Any, **_k: Any) -> None:
started.set()
await can_finish.wait()
monkeypatch.setattr("my_deepagent.api.routes.sessions.invoke_session_agent", slow_invoke)
r = await client.post(
"/api/sessions",
json={"persona_name": "default-interactive", "repo_path": str(Path.cwd())},
)
sid = r.json()["session_id"]
await client.post(f"/api/sessions/{sid}/messages", json={"content": "x"})
# Wait for the task to start.
await asyncio.wait_for(started.wait(), timeout=2.0)
# The pending_invocations set on the app should hold a reference.
pending = client._transport.app.state.pending_invocations
assert len(pending) == 1
# Release the task and let the discard callback fire.
can_finish.set()
await asyncio.sleep(0.05)
assert len(client._transport.app.state.pending_invocations) == 0
# ---------------------------------------------------------------------------
# End-to-end: assistant message materializes for SSE
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_background_invocation_persists_assistant_row(
app_client: tuple[AsyncClient, Database], monkeypatch: pytest.MonkeyPatch
) -> None:
"""When the runner finishes, an assistant MessageRow should be visible."""
client, db = app_client
async def fake_invoke(
passed_db: Any,
_config: Any,
_personas: Any,
session_id: Any,
_user_message: str,
*,
saver: Any = None,
) -> None:
# Simulate what the real runner does: write an assistant MessageRow.
from datetime import UTC, datetime
from sqlalchemy import desc
from my_deepagent.persistence.models import MessageRow as MR
async with passed_db.session() as s:
last = (
await s.execute(
select(MR.seq)
.where(MR.session_id == str(session_id))
.order_by(desc(MR.seq))
.limit(1)
)
).scalar_one_or_none() or 0
s.add(
MR(
session_id=str(session_id),
seq=last + 1,
role="assistant",
content="(stubbed assistant reply)",
tool_calls=None,
token_count=5,
is_summary=False,
archived=False,
ts=datetime.now(UTC).isoformat(timespec="seconds"),
)
)
await s.commit()
monkeypatch.setattr("my_deepagent.api.routes.sessions.invoke_session_agent", fake_invoke)
r = await client.post(
"/api/sessions",
json={"persona_name": "default-interactive", "repo_path": str(Path.cwd())},
)
sid = r.json()["session_id"]
await client.post(f"/api/sessions/{sid}/messages", json={"content": "ping"})
# Let the background task complete.
await asyncio.sleep(0.1)
# Verify the conversation now has both user + assistant rows.
async with db.session() as s:
rows = (
(
await s.execute(
select(MessageRow).where(MessageRow.session_id == sid).order_by(MessageRow.seq)
)
)
.scalars()
.all()
)
sess_row = await s.get(InteractiveSessionRow, sid)
assert [r.role for r in rows] == ["user", "assistant"]
assert rows[1].content == "(stubbed assistant reply)"
assert sess_row is not None
assert sess_row.title is not None # set from first user message