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>
This commit is contained in:
239
my-deepagent/tests/integration/test_conversation_gui.py
Normal file
239
my-deepagent/tests/integration/test_conversation_gui.py
Normal file
@@ -0,0 +1,239 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user