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:
@@ -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 글로벌/프로젝트 레이어링 등가. 세션 시작 시 다음 두 파일을 자동
|
||||
|
||||
238
my-deepagent/src/my_deepagent/api/agent_runner.py
Normal file
238
my-deepagent/src/my_deepagent/api/agent_runner.py
Normal 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"]
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
49
my-deepagent/static/conversation.html
Normal file
49
my-deepagent/static/conversation.html
Normal 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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
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