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:
chungyeong
2026-05-17 21:03:09 +09:00
parent 61b34af0e4
commit e326c07dcb
8 changed files with 1067 additions and 1 deletions

View File

@@ -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 글로벌/프로젝트 레이어링 등가. 세션 시작 시 다음 두 파일을 자동

View File

@@ -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"]

View File

@@ -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()

View File

@@ -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")

View File

@@ -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();
}
});

View File

@@ -0,0 +1,49 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>my-deepagent · 대화</title>
<link rel="stylesheet" href="/static/style.css" />
</head>
<body data-page="conversation">
<header>
<h1><a href="/">my-deepagent</a></h1>
<nav>
<a href="/conversation.html" class="active">대화</a>
<a href="/">Runs (아카이브)</a>
</nav>
</header>
<main class="conversation-main">
<div id="error" class="error-banner" style="display:none"></div>
<!-- Top bar: session picker + new conversation button -->
<div class="conv-topbar">
<label for="session-picker" class="conv-label">세션</label>
<select id="session-picker" class="conv-picker">
<option value="">(세션 선택…)</option>
</select>
<button id="new-session-btn" type="button" class="conv-action-btn">새 대화</button>
<span class="conv-session-state" id="session-state-pill"></span>
</div>
<!-- Message thread -->
<div id="messages" class="messages-thread">
<div class="conv-empty" id="conv-empty">대화를 시작하려면 위에서 세션을 선택하거나 "새 대화"를 누르세요.</div>
</div>
<!-- Input bar -->
<form id="message-form" class="conv-input-bar">
<textarea
id="message-input"
rows="2"
placeholder="메시지를 입력하세요… (Cmd/Ctrl+Enter 로 전송)"
autocomplete="off"
disabled
></textarea>
<button id="send-btn" type="submit" disabled>전송</button>
</form>
</main>
<script src="/static/app.js"></script>
</body>
</html>

View File

@@ -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;
}

View 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