From e326c07dcb5c5d5143e5fedce905b02a7703d684 Mon Sep 17 00:00:00 2001 From: chungyeong Date: Sun, 17 May 2026 21:03:09 +0900 Subject: [PATCH] =?UTF-8?q?feat(my-deepagent):=20v0.3=20PR=20#8=20?= =?UTF-8?q?=E2=80=94=20conversation-centric=20Web=20GUI=20(/conversation.h?= =?UTF-8?q?tml)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- my-deepagent/CHANGELOG.md | 40 +++ .../src/my_deepagent/api/agent_runner.py | 238 ++++++++++++++++ my-deepagent/src/my_deepagent/api/app.py | 24 +- .../src/my_deepagent/api/routes/sessions.py | 32 +++ my-deepagent/static/app.js | 262 ++++++++++++++++++ my-deepagent/static/conversation.html | 49 ++++ my-deepagent/static/style.css | 184 ++++++++++++ .../integration/test_conversation_gui.py | 239 ++++++++++++++++ 8 files changed, 1067 insertions(+), 1 deletion(-) create mode 100644 my-deepagent/src/my_deepagent/api/agent_runner.py create mode 100644 my-deepagent/static/conversation.html create mode 100644 my-deepagent/tests/integration/test_conversation_gui.py diff --git a/my-deepagent/CHANGELOG.md b/my-deepagent/CHANGELOG.md index 4d5eb4f..f39927a 100644 --- a/my-deepagent/CHANGELOG.md +++ b/my-deepagent/CHANGELOG.md @@ -2,6 +2,46 @@ ## [Unreleased] +### Added +- **v0.3 PR #8 — Conversation-centric Web GUI (`/conversation.html`)**. + Workflow run 페이지는 archive 로 격하; 사용자가 처음 보는 화면은 chat-style + 대화 thread. Claude Code 의 Web GUI 와 동일한 사용성. + - `static/conversation.html` (신규): session picker + 메시지 thread + + 입력 박스. data-page="conversation". + - `static/app.js`: + - 새 페이지 핸들러 `bootstrapConversationPage` 추가. + - `loadSessionList()` → GET /api/sessions, picker 채움. + - `loadAndAttachSession(sid)` → GET /api/sessions/{id}, 메시지 thread 렌더, + SSE 구독 시작. + - `attachEventSource` → 기존 SSE message/done 이벤트 처리. 새 user 메시지 + 전송 시 `pending` 풍선 표시, assistant 메시지 도착 시 교체. + - `createNewSession` → default-interactive persona 로 POST /api/sessions. + - XSS 정책 동일: 모든 사용자 콘텐츠는 `textContent` 만 사용. + - `static/style.css`: `.messages-thread`, `.msg-bubble`, `.conv-topbar`, + `.conv-input-bar` 등 chat UI 스타일 추가. + - `api/app.py`: + - lifespan 에서 LangGraph saver 를 `from_conn_string` 으로 1회 열고 + `app.state.saver` 에 보관 (Postgres 일 때만, SQLite 테스트는 None). + 백그라운드 invoke 가 재사용. 종료 시 `__aexit__` 호출. + - `api/agent_runner.py` (신규): + - `invoke_session_agent(db, config, personas, session_id, user_message, saver=...)` — + 세션 로우 로드 → persona 해상 → 디렉터리 부트스트랩 (memory / skills / + MYDEEPAGENT.md) → middleware 스택 (plan-mode + cost + audit) 생성 → + `build_agent` → `ainvoke` → assistant MessageRow 영속 → 자동 compaction. + - 모든 실패는 로깅 + return (raise 안 함) — HTTP 응답은 이미 200 이고 + SSE 가 진행 상태를 보여줌. + - `api/routes/sessions.py`: + - `POST /api/sessions/{id}/messages` 가 user row 영속 후 + `asyncio.create_task(invoke_session_agent(...))` 로 백그라운드 invoke 발사. + task ref 를 `app.state.pending_invocations` set 에 보관 (RUF006 + GC + drop 방지), 완료 시 `discard`. + - `tests/integration/test_conversation_gui.py` (신규, 4 케이스): + - GET /conversation.html → 200 + 필수 마크업 + - POST /messages → 200 + user row 영속 + 백그라운드 invoke 호출 + - 백그라운드 task ref 가 `app.state.pending_invocations` 에 잡혀있고 완료 + 후 자동 discard + - 스텁 runner 가 assistant row 영속 → user + assistant 시퀀스 검증 + ### Added - **v0.3 PR #7 — MYDEEPAGENT.md instruction-file hierarchy**. Claude Code 의 CLAUDE.md 글로벌/프로젝트 레이어링 등가. 세션 시작 시 다음 두 파일을 자동 diff --git a/my-deepagent/src/my_deepagent/api/agent_runner.py b/my-deepagent/src/my_deepagent/api/agent_runner.py new file mode 100644 index 0000000..0859128 --- /dev/null +++ b/my-deepagent/src/my_deepagent/api/agent_runner.py @@ -0,0 +1,238 @@ +"""Background agent invocation for the Web GUI (v0.3 PR #8). + +The Web GUI POSTs user messages to ``/api/sessions/{id}/messages`` and expects +an assistant response to appear via the SSE stream shortly after. The route +handler persists the user message and kicks off this runner as a fire-and- +forget asyncio task — same fundamentals as :mod:`cli.interactive` but without +the prompt-toolkit REPL loop. + +This runner is **single-uvicorn-worker** by design (see ``api/app.py``'s +docstring): the saver is held on ``app.state.saver`` and shared across all +background invocations. Multi-worker support would require Postgres +``LISTEN/NOTIFY`` fanout — deferred per plan. +""" + +from __future__ import annotations + +import logging +from typing import Any +from uuid import UUID, uuid4 + +from sqlalchemy import desc, select + +from ..audit import make_audit_recorder +from ..budget import make_budget_tracker_from_config +from ..compaction import compact_session, should_compact +from ..config import Config +from ..hash import sha256 +from ..instructions import ( + ensure_global_instructions_initialized, + resolve_instruction_paths, +) +from ..memory import ( + ensure_memory_initialized, + list_memory_paths, + project_memory_dir, +) +from ..middleware.audit import AuditToolMiddleware +from ..middleware.cost import CostMiddleware +from ..middleware.plan_mode import PlanModeMiddleware +from ..monitoring.pricing import ModelPrice, PricingCache +from ..monitoring.token_budget import count_tokens +from ..persistence.db import Database +from ..persistence.models import InteractiveSessionRow, MessageRow +from ..persona import Persona +from ..session import build_agent +from ..skills import ensure_skills_initialized, resolve_skill_sources, user_skills_dir + +_LOG = logging.getLogger(__name__) + + +def _static_pricing_seed() -> PricingCache: + """Minimal seed identical to the REPL's _static_pricing_seed.""" + cache = PricingCache() + cache.set( + [ + ModelPrice("anthropic/claude-sonnet-4-6", 0.003, 0.015, 200_000), + ModelPrice("anthropic/claude-haiku-4-5", 0.001, 0.005, 200_000), + ModelPrice("anthropic/claude-opus-4-1", 0.015, 0.075, 200_000), + ModelPrice("deepseek/deepseek-chat", 0.00028, 0.00112, 64_000), + ] + ) + return cache + + +def _flatten_assistant_content(message: Any) -> str: + """Convert a langchain assistant message's content into a plain string. + + LangChain may return a list of content blocks (text + tool_use); we + concatenate the text-bearing pieces. Falls back to ``str(content)`` if + the shape is unexpected. + """ + content = getattr(message, "content", "") or "" + if isinstance(content, list): + parts: list[str] = [] + for block in content: + if isinstance(block, dict): + parts.append(block.get("text", "") or "") + else: + parts.append(str(block)) + return "\n".join(p for p in parts if p) + return str(content) + + +async def _bootstrap_session_dirs(config: Config, project_key: str) -> None: + """Ensure memory + skills + global instruction dirs exist for the session. + + Mirrors :class:`cli.interactive.InteractiveSession.__init__`. Idempotent + so repeated background invocations are cheap. + """ + ensure_memory_initialized(project_memory_dir(config, project_key)) + ensure_skills_initialized(user_skills_dir(config)) + ensure_global_instructions_initialized(config) + + +async def invoke_session_agent( + db: Database, + config: Config, + personas: list[Persona], + session_id: UUID, + user_message: str, + *, + saver: Any | None = None, +) -> None: + """Run one ainvoke + persist the assistant reply for the given session. + + The user message is assumed to be ALREADY persisted by the HTTP handler + (POST /api/sessions/{id}/messages). This runner only adds the assistant + response and runs the post-turn auto-compaction check. + + Failures are logged but never raised — the route handler returned 200 as + soon as the user message was persisted, and the SSE stream is how the + client observes success or absence of progress. + """ + async with db.session() as s: + row = await s.get(InteractiveSessionRow, str(session_id)) + if row is None: + _LOG.warning("invoke_session_agent: session %s not found", session_id) + return + + persona = _resolve_persona(personas, row.persona_hash) + if persona is None: + _LOG.warning( + "invoke_session_agent: persona hash %s not in loaded personas", row.persona_hash + ) + return + + project_key = row.project_key or sha256(str(config.workspace_root.resolve()))[:16] + await _bootstrap_session_dirs(config, project_key) + + # Build agent. No model override at the API layer — `/model` slash is REPL-only. + pricing = _static_pricing_seed() + budget = make_budget_tracker_from_config(db, config) + cost_mw = CostMiddleware( + pricing=pricing, + model_name=row.model or persona.model, + interactive_session_id=session_id, + persona_name=persona.name, + budget_tracker=budget, + ) + audit_mw = AuditToolMiddleware( + interactive_session_id=session_id, + file_recorder=make_audit_recorder(config.state_dir), + ) + # Plan-mode is read from the session row — API users can toggle via a + # future API endpoint; REPL toggles via /plan slash. + is_plan = bool(row.plan_mode) + plan_mw = PlanModeMiddleware(is_active=lambda: is_plan) + + memory_dir = project_memory_dir(config, project_key) + instruction_paths = resolve_instruction_paths(config, config.workspace_root) + memory_paths = list_memory_paths(memory_dir) + skill_sources = resolve_skill_sources(config) + + agent = build_agent( + persona, + config, + root_dir=config.workspace_root, + middleware=[plan_mw, cost_mw, audit_mw], + checkpointer=saver, + memory_paths_override=[*instruction_paths, *memory_paths], + skills_sources_override=skill_sources, + ) + + thread_id = f"{session_id}:0" + try: + result = await agent.ainvoke( + {"messages": [{"role": "user", "content": user_message}]}, + config={"configurable": {"thread_id": thread_id}}, + ) + except Exception: + _LOG.exception("agent.ainvoke failed for session %s", session_id) + return + + messages = result.get("messages", []) if isinstance(result, dict) else [] + if not messages: + return + assistant_text = _flatten_assistant_content(messages[-1]) + if not assistant_text: + return + + await _persist_assistant_message(db, session_id, assistant_text, row.model or persona.model) + + # Post-turn auto-compaction (mirrors REPL behaviour). + async with db.session() as s: + refreshed = await s.get(InteractiveSessionRow, str(session_id)) + if refreshed is not None and should_compact(refreshed): + await compact_session(db, config, str(session_id)) + + +def _resolve_persona(personas: list[Persona], persona_hash: str) -> Persona | None: + for p in personas: + if p.compute_hash() == persona_hash: + return p + return None + + +async def _persist_assistant_message( + db: Database, + session_id: UUID, + content: str, + model: str, +) -> None: + token_count = count_tokens(content, model) + from datetime import UTC, datetime + + now = datetime.now(UTC).isoformat(timespec="seconds") + async with db.session() as s: + last_seq = ( + await s.execute( + select(MessageRow.seq) + .where(MessageRow.session_id == str(session_id)) + .order_by(desc(MessageRow.seq)) + .limit(1) + ) + ).scalar_one_or_none() or 0 + s.add( + MessageRow( + session_id=str(session_id), + seq=last_seq + 1, + role="assistant", + content=content, + tool_calls=None, + token_count=token_count, + is_summary=False, + archived=False, + ts=now, + ) + ) + row = await s.get(InteractiveSessionRow, str(session_id)) + if row is not None: + row.last_message_at = now + row.total_output_tokens += token_count + await s.commit() + + +# Re-exported for tests that want to construct a fresh persona+session row +# without going through the HTTP layer. +__all__ = ["invoke_session_agent", "uuid4"] diff --git a/my-deepagent/src/my_deepagent/api/app.py b/my-deepagent/src/my_deepagent/api/app.py index 2140cec..5aa660a 100644 --- a/my-deepagent/src/my_deepagent/api/app.py +++ b/my-deepagent/src/my_deepagent/api/app.py @@ -17,6 +17,7 @@ from fastapi.staticfiles import StaticFiles from starlette.responses import FileResponse from ..config import Config, load_config +from ..persistence.checkpointer import get_checkpointer_ctx from ..persistence.db import Database from ..persona import load_personas_from_dir from ..workflow import WorkflowTemplate, load_workflow_yaml @@ -54,7 +55,14 @@ def _load_seed_workflows() -> list[tuple[Path, WorkflowTemplate]]: @asynccontextmanager async def _lifespan(app: FastAPI) -> AsyncIterator[None]: - """Initialize the shared Database, personas, workflows on startup; dispose on shutdown.""" + """Initialize the shared Database, personas, workflows, LangGraph saver on + startup; dispose on shutdown. + + The saver is opened once per app lifetime and reused by background agent + invocations from POST /api/sessions/{id}/messages (v0.3 PR #8). Opening + per-request would be too expensive (each open establishes a Postgres + connection + verifies the checkpoint schema). + """ config: Config = app.state.config or load_config() db = Database(config.database_url) # init_schema is a no-op against an already-migrated DB; cheap to call. @@ -63,9 +71,23 @@ async def _lifespan(app: FastAPI) -> AsyncIterator[None]: app.state.db = db app.state.personas = load_personas_from_dir(_DOCS_SCHEMAS / "personas") app.state.workflows = _load_seed_workflows() + saver_ctx = get_checkpointer_ctx(config.database_url) try: + # AsyncPostgresSaver.from_conn_string only works for postgres; for sqlite + # tests we silently fall back to None and let background ainvoke run + # without checkpointing (acceptable: tests stub agents anyway). + if config.database_url.startswith("postgresql"): + saver = await saver_ctx.__aenter__() + app.state.saver = saver + else: + app.state.saver = None yield finally: + if app.state.saver is not None: + try: + await saver_ctx.__aexit__(None, None, None) + except Exception: + _LOG.exception("saver context exit failed during shutdown") await db.dispose() diff --git a/my-deepagent/src/my_deepagent/api/routes/sessions.py b/my-deepagent/src/my_deepagent/api/routes/sessions.py index 10fce83..0018438 100644 --- a/my-deepagent/src/my_deepagent/api/routes/sessions.py +++ b/my-deepagent/src/my_deepagent/api/routes/sessions.py @@ -30,6 +30,7 @@ from ...persistence.models import ( MessageRow, ) from ...persona import Persona +from ..agent_runner import invoke_session_agent from ..deps import get_config, get_db, get_personas from ..models import ( CreateSessionRequest, @@ -218,8 +219,18 @@ async def create_session( async def post_message( session_id: str, body: PostMessageRequest, + request: Request, db: DbDep, + config: ConfigDep, + personas: PersonasDep, ) -> SessionAck: + """Persist a user message + fire the agent invocation in the background. + + v0.3 PR #8: returns immediately after the user message is durably + persisted. The background task fetches the saver from ``app.state`` (set + up by the lifespan) and emits the assistant reply via the same SSE stream + that the client is already subscribed to. + """ async with db.session() as s: row = await s.get(InteractiveSessionRow, session_id) if row is None: @@ -257,6 +268,27 @@ async def post_message( row.title = body.content[:50] await s.commit() + # Fire-and-forget background invocation. We do NOT await it — the route + # returns 200 immediately and the SSE stream picks up the assistant reply. + # Hold a reference on app.state so RUF006 + GC don't kill the task mid-flight. + saver = getattr(request.app.state, "saver", None) + from uuid import UUID + + task = asyncio.create_task( + invoke_session_agent( + db, + config, + personas, + UUID(session_id), + body.content, + saver=saver, + ) + ) + pending: set[asyncio.Task[Any]] = getattr(request.app.state, "pending_invocations", set()) + pending.add(task) + request.app.state.pending_invocations = pending + task.add_done_callback(pending.discard) + return SessionAck(session_id=session_id, state="active", message="queued") diff --git a/my-deepagent/static/app.js b/my-deepagent/static/app.js index a25f4f7..211f26c 100644 --- a/my-deepagent/static/app.js +++ b/my-deepagent/static/app.js @@ -417,6 +417,266 @@ async function resumeRun() { } } +// =============== conversation page (v0.3 PR #8) =============== + +const CONV_STATE = { + sessionId: null, + eventSource: null, + lastSeq: 0, + awaitingReply: false, +}; + +function $conv(sel) { return document.querySelector(sel); } + +function setSendDisabled(disabled) { + $conv("#message-input").disabled = disabled; + $conv("#send-btn").disabled = disabled; +} + +function clearMessages() { + const list = $conv("#messages"); + list.replaceChildren(); +} + +function showConversationEmpty(show, text) { + let el = $conv("#conv-empty"); + if (!el && show) { + el = document.createElement("div"); + el.id = "conv-empty"; + el.className = "conv-empty"; + $conv("#messages").appendChild(el); + } + if (el) { + if (show) { + el.textContent = text || "대화를 시작하세요."; + el.style.display = ""; + } else { + el.remove(); + } + } +} + +function appendMessageBubble(role, content, ts) { + showConversationEmpty(false); + const list = $conv("#messages"); + const bubble = document.createElement("div"); + bubble.className = `msg-bubble role-${role}`; + const meta = document.createElement("div"); + meta.className = "msg-meta"; + const roleSpan = document.createElement("span"); + roleSpan.className = "msg-role"; + roleSpan.textContent = role; + const tsSpan = document.createElement("span"); + tsSpan.className = "msg-ts"; + tsSpan.textContent = (ts || "").slice(11, 19); + meta.appendChild(roleSpan); + if (ts) meta.appendChild(tsSpan); + const body = document.createElement("div"); + body.className = "msg-body"; + body.textContent = content; + bubble.appendChild(meta); + bubble.appendChild(body); + list.appendChild(bubble); + list.scrollTop = list.scrollHeight; +} + +function appendPendingPlaceholder() { + const list = $conv("#messages"); + const placeholder = document.createElement("div"); + placeholder.id = "pending-placeholder"; + placeholder.className = "msg-bubble role-assistant pending"; + placeholder.textContent = "…"; + list.appendChild(placeholder); + list.scrollTop = list.scrollHeight; +} + +function removePendingPlaceholder() { + const p = $conv("#pending-placeholder"); + if (p) p.remove(); +} + +function updateSessionStatePill(state) { + const pill = $conv("#session-state-pill"); + if (!pill) return; + if (!state) { + pill.textContent = ""; + pill.className = "conv-session-state"; + return; + } + pill.textContent = state; + pill.className = `conv-session-state state-${state}`; +} + +async function loadSessionList() { + try { + const list = await jsonFetch("/sessions?limit=50"); + const picker = $conv("#session-picker"); + picker.replaceChildren(); + const placeholderOpt = document.createElement("option"); + placeholderOpt.value = ""; + placeholderOpt.textContent = "(세션 선택…)"; + picker.appendChild(placeholderOpt); + for (const s of list) { + const opt = document.createElement("option"); + opt.value = s.id; + const titleStr = s.title || "(제목 없음)"; + opt.textContent = `${s.id.slice(0, 8)}… · ${titleStr}`; + picker.appendChild(opt); + } + } catch (e) { + setError(`세션 목록 로드 실패: ${e.message}`); + } +} + +async function loadAndAttachSession(sessionId) { + if (CONV_STATE.eventSource) { + CONV_STATE.eventSource.close(); + CONV_STATE.eventSource = null; + } + CONV_STATE.sessionId = sessionId; + CONV_STATE.lastSeq = 0; + CONV_STATE.awaitingReply = false; + clearMessages(); + + let detail; + try { + detail = await jsonFetch(`/sessions/${sessionId}`); + } catch (e) { + setError(`세션 로드 실패: ${e.message}`); + setSendDisabled(true); + return; + } + updateSessionStatePill(detail.session.state); + + const messages = detail.messages || []; + for (const m of messages) { + if (m.role === "system" && !m.is_summary) continue; + appendMessageBubble(m.role, m.content, m.ts); + if (m.seq > CONV_STATE.lastSeq) CONV_STATE.lastSeq = m.seq; + } + if (messages.length === 0) { + showConversationEmpty(true, "이 세션에 메시지가 아직 없습니다. 첫 메시지를 보내보세요."); + } + + const ended = detail.session.state === "ended"; + setSendDisabled(ended); + if (!ended) attachEventSource(sessionId); +} + +function attachEventSource(sessionId) { + if (CONV_STATE.eventSource) { + CONV_STATE.eventSource.close(); + } + const url = `${API}/sessions/${sessionId}/stream?last_seq=${CONV_STATE.lastSeq}`; + const src = new EventSource(url); + CONV_STATE.eventSource = src; + + src.addEventListener("message", (ev) => { + try { + const data = JSON.parse(ev.data); + if (data.seq <= CONV_STATE.lastSeq) return; + if (data.role === "assistant" && CONV_STATE.awaitingReply) { + removePendingPlaceholder(); + CONV_STATE.awaitingReply = false; + } + // Skip system messages except summaries. + if (data.role === "system" && !data.is_summary) { + CONV_STATE.lastSeq = data.seq; + return; + } + appendMessageBubble(data.role, data.content, data.ts); + CONV_STATE.lastSeq = data.seq; + } catch (_) { /* ignore parse errors */ } + }); + + src.addEventListener("done", () => { + src.close(); + if (CONV_STATE.eventSource === src) CONV_STATE.eventSource = null; + updateSessionStatePill("ended"); + setSendDisabled(true); + }); + + src.onerror = () => { + // Sessions are long-lived — let the browser reconnect on EventSource's + // default backoff. We don't surface this as a hard error unless it + // persists. + }; +} + +async function sendMessage(text) { + if (!CONV_STATE.sessionId) { + setError("세션을 먼저 선택하거나 새로 만드세요."); + return; + } + if (!text.trim()) return; + setSendDisabled(true); + CONV_STATE.awaitingReply = true; + appendPendingPlaceholder(); + try { + await jsonFetch(`/sessions/${CONV_STATE.sessionId}/messages`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content: text }), + }); + $conv("#message-input").value = ""; + setError(""); + } catch (e) { + removePendingPlaceholder(); + CONV_STATE.awaitingReply = false; + setError(`전송 실패: ${e.message}`); + } finally { + setSendDisabled(false); + $conv("#message-input").focus(); + } +} + +async function createNewSession() { + let personas; + try { + personas = await jsonFetch("/personas"); + } catch (e) { + setError(`persona 목록 로드 실패: ${e.message}`); + return; + } + const defaultPersona = personas.find((p) => p.name === "default-interactive") || personas[0]; + if (!defaultPersona) { + setError("등록된 persona 가 없습니다. CLI 에서 `mydeepagent` 한 번 실행한 후 재시도하세요."); + return; + } + try { + const ack = await jsonFetch("/sessions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ persona_name: defaultPersona.name, repo_path: "" }), + }); + await loadSessionList(); + $conv("#session-picker").value = ack.session_id; + await loadAndAttachSession(ack.session_id); + } catch (e) { + setError(`세션 생성 실패: ${e.message}`); + } +} + +function bootstrapConversationPage() { + loadSessionList(); + $conv("#new-session-btn").addEventListener("click", createNewSession); + $conv("#session-picker").addEventListener("change", (ev) => { + const sid = ev.target.value; + if (sid) loadAndAttachSession(sid); + }); + $conv("#message-form").addEventListener("submit", (ev) => { + ev.preventDefault(); + const input = $conv("#message-input"); + sendMessage(input.value); + }); + $conv("#message-input").addEventListener("keydown", (ev) => { + if ((ev.metaKey || ev.ctrlKey) && ev.key === "Enter") { + ev.preventDefault(); + sendMessage(ev.target.value); + } + }); +} + // =============== bootstrap =============== document.addEventListener("DOMContentLoaded", () => { @@ -430,5 +690,7 @@ document.addEventListener("DOMContentLoaded", () => { renderRunDetail(); $("#abort-btn").addEventListener("click", abortRun); $("#resume-btn").addEventListener("click", resumeRun); + } else if (page === "conversation") { + bootstrapConversationPage(); } }); diff --git a/my-deepagent/static/conversation.html b/my-deepagent/static/conversation.html new file mode 100644 index 0000000..895918b --- /dev/null +++ b/my-deepagent/static/conversation.html @@ -0,0 +1,49 @@ + + + + + + my-deepagent · 대화 + + + +
+

my-deepagent

+ +
+
+ + + +
+ + + + +
+ + +
+
대화를 시작하려면 위에서 세션을 선택하거나 "새 대화"를 누르세요.
+
+ + +
+ + +
+
+ + + diff --git a/my-deepagent/static/style.css b/my-deepagent/static/style.css index 11cdab6..474941b 100644 --- a/my-deepagent/static/style.css +++ b/my-deepagent/static/style.css @@ -778,3 +778,187 @@ select { .event-line { grid-template-columns: 1fr; gap: 2px; } .chips { grid-template-columns: 1fr; gap: 6px; } } + +/* ================================================================= + v0.3 PR #8 — Conversation page + ================================================================= */ + +.conversation-main { + display: flex; + flex-direction: column; + min-height: calc(100vh - 80px); + padding-bottom: 0; +} + +.conv-topbar { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + margin-bottom: 12px; + flex-wrap: wrap; +} + +.conv-label { + font-size: 13px; + color: var(--text-muted); + font-weight: 600; +} + +.conv-picker { + flex: 1; + min-width: 240px; + padding: 6px 10px; + font-family: var(--font-mono); + font-size: 13px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg); +} + +.conv-action-btn { + padding: 6px 14px; + font-size: 13px; + background: var(--accent); + color: white; + border: none; + border-radius: 6px; + cursor: pointer; +} + +.conv-action-btn:hover { filter: brightness(1.08); } + +.conv-session-state { + font-size: 11px; + padding: 2px 8px; + border-radius: 999px; + text-transform: lowercase; + letter-spacing: 0.04em; +} + +.conv-session-state.state-active { + background: rgba(34,197,94,0.12); + color: rgb(22,163,74); +} + +.conv-session-state.state-ended { + background: rgba(100,116,139,0.12); + color: rgb(71,85,105); +} + +.messages-thread { + flex: 1; + overflow-y: auto; + padding: 16px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg); + margin-bottom: 12px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.conv-empty { + color: var(--text-muted); + text-align: center; + padding: 40px 16px; + font-size: 13px; +} + +.msg-bubble { + max-width: 80%; + padding: 10px 14px; + border-radius: 12px; + font-size: 14px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; +} + +.msg-bubble.role-user { + align-self: flex-end; + background: var(--accent); + color: white; +} + +.msg-bubble.role-assistant { + align-self: flex-start; + background: var(--bg-card); + border: 1px solid var(--border); +} + +.msg-bubble.role-system { + align-self: center; + max-width: 90%; + font-style: italic; + font-size: 12.5px; + background: rgba(245,158,11,0.08); + border: 1px dashed rgba(245,158,11,0.4); + color: rgb(120,53,15); +} + +.msg-bubble.pending { + opacity: 0.6; + font-size: 20px; + padding: 6px 14px; +} + +.msg-meta { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; + opacity: 0.6; + margin-bottom: 4px; +} + +.msg-role { + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.conv-input-bar { + display: flex; + gap: 8px; + padding: 12px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; +} + +.conv-input-bar textarea { + flex: 1; + font-family: var(--font-body); + font-size: 14px; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: 6px; + resize: vertical; + min-height: 44px; +} + +.conv-input-bar textarea:disabled { + background: var(--bg); + opacity: 0.5; +} + +.conv-input-bar button { + padding: 0 18px; + font-size: 13px; + background: var(--accent); + color: white; + border: none; + border-radius: 6px; + cursor: pointer; +} + +.conv-input-bar button:disabled { + opacity: 0.4; + cursor: not-allowed; +} + diff --git a/my-deepagent/tests/integration/test_conversation_gui.py b/my-deepagent/tests/integration/test_conversation_gui.py new file mode 100644 index 0000000..4132dbf --- /dev/null +++ b/my-deepagent/tests/integration/test_conversation_gui.py @@ -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