setReasoningEffort(String(val))}
+ value={form.reasoningEffort}
+ onChange={(val) => patch({ reasoningEffort: String(val) })}
size="small"
options={[
{ label: "留空使用默认", value: "" },
@@ -562,18 +584,24 @@ function CreateSessionDialog({
setMaxRounds(val)}
+ value={form.maxRounds}
+ onChange={(val: string) => patch({ maxRounds: val })}
placeholder="留空使用默认"
type="number"
size="small"
+ status={liveMaxRoundsError ? "warning" : "default"}
/>
+ {(liveMaxRoundsError || maxRoundsError) && (
+
+ {liveMaxRoundsError || maxRoundsError}
+
+ )}
setWritePolicy(String(val))}
+ value={form.writePolicy}
+ onChange={(val) => patch({ writePolicy: String(val) })}
size="small"
options={[
{ label: "默认", value: "" },
@@ -600,6 +628,7 @@ export function DeleteSessionButton({ sessionId }: { sessionId: string }) {
variant="outline"
size="small"
loading={actions.remove.isPending}
+ icon={}
onClick={() => {
actions.remove.mutate(sessionId, {
onSuccess: () => toast.success("已删除"),
@@ -607,7 +636,6 @@ export function DeleteSessionButton({ sessionId }: { sessionId: string }) {
});
}}
>
- icon={}
删除
);
diff --git a/frontend-web/src/pages/MafRoutes.tsx b/frontend-web/src/pages/MafRoutes.tsx
index 7f1bcd08..44031ac1 100644
--- a/frontend-web/src/pages/MafRoutes.tsx
+++ b/frontend-web/src/pages/MafRoutes.tsx
@@ -1,5 +1,8 @@
/**
+ * [MAF-R0 实验原型 — 仅供开发诊断,已从正式菜单移除]
+ *
* MAF 多智能体会商路由树(/page/maf/*)。
+ * 本原型不再新增业务功能。新版会商将使用 /api/maf-roundtable/* + conversation 页面。
*
* 仿 RoundtableRoutes:顶层包一层满高容器,内部 :
* index → 会话列表
diff --git a/frontend-web/vitest.maf.config.ts b/frontend-web/vitest.maf.config.ts
new file mode 100644
index 00000000..f708573d
--- /dev/null
+++ b/frontend-web/vitest.maf.config.ts
@@ -0,0 +1,32 @@
+/**
+ * Vitest 配置 —— 仅跑 MAF 模块的真实挂载测试(jsdom + Testing Library)。
+ *
+ * 设计:
+ * - environment=jsdom 让 React 组件可挂载;
+ * - 只 include `*.test.tsx`(组件挂载测试),现有 `*.test.ts`(node:test 纯函数)
+ * 保持原来的 esbuild + node --test 路径,互不干扰;
+ * - 复用项目 tsconfig 的 `@/*` → `./src/*` 别名;
+ * - 不引入 CDN / 在线服务,所有依赖本地安装并锁入 lockfile。
+ */
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { defineConfig } from "vitest/config";
+import react from "@vitejs/plugin-react";
+
+const here = path.dirname(fileURLToPath(import.meta.url));
+
+export default defineConfig({
+ plugins: [react()],
+ resolve: {
+ alias: {
+ "@": path.resolve(here, "src"),
+ },
+ },
+ test: {
+ environment: "jsdom",
+ globals: true,
+ // 只跑 MAF 的组件挂载测试(.test.tsx);纯函数 .test.ts 仍走 esbuild+node --test
+ include: ["src/maf-roundtable/**/*.test.tsx"],
+ exclude: ["**/node_modules/**", "**/dist/**"],
+ },
+});
diff --git a/offline-backend-20260512/backend/app/gateway/app.py b/offline-backend-20260512/backend/app/gateway/app.py
index 3eb666df..02d0eade 100644
--- a/offline-backend-20260512/backend/app/gateway/app.py
+++ b/offline-backend-20260512/backend/app/gateway/app.py
@@ -50,6 +50,7 @@ from app.gateway.routers import (
knowledge_ingest,
light_apps,
llm_metrics,
+ maf_conversations,
maf_roundtable,
mcp,
memory,
@@ -1612,6 +1613,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# roundtable; feature-flag-gated, six isolated tables) at /api/maf-roundtable
app.include_router(maf_roundtable.router)
+ # MAF roundtable V2 Conversation API (user-visible layer) at /api/maf-roundtable/conversations
+ app.include_router(maf_conversations.router)
+
# Temporary compatibility bridge for the TaskCOP situation-overview frontend.
app.include_router(taskcop_tasks.router)
diff --git a/offline-backend-20260512/backend/app/gateway/deps.py b/offline-backend-20260512/backend/app/gateway/deps.py
index 2163863c..390c4b65 100644
--- a/offline-backend-20260512/backend/app/gateway/deps.py
+++ b/offline-backend-20260512/backend/app/gateway/deps.py
@@ -319,8 +319,9 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
# abort Gateway startup (the feature is experimental / opt-in).
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
- app.state.maf_roundtable_store = make_maf_roundtable_store(sf)
- try:
+ app.state.maf_roundtable_store = make_maf_roundtable_store(sf)
+ maf_enabled = False
+ try:
from app.maf_roundtable import MafRoundtableService
maf_cfg = getattr(config, "maf_roundtable", None)
@@ -369,6 +370,32 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
logger.exception("[maf-roundtable] service initialization failed; feature stays off")
app.state.maf_roundtable_service = None
+ # ── MAF roundtable V2 Conversation service + SSE event hub ──
+ try:
+ from app.maf_roundtable.conversation_event_hub import ConversationEventHub
+
+ app.state.maf_conversation_event_hub = ConversationEventHub()
+
+ if app.state.maf_roundtable_store is not None:
+ from app.maf_roundtable.conversation_service import MafConversationService
+ from app.maf_roundtable.dispatcher import get_dispatcher
+
+ app.state.maf_conversation_service = MafConversationService(
+ app.state.maf_roundtable_store,
+ maf_config=getattr(config, "maf_roundtable", None),
+ event_hub=app.state.maf_conversation_event_hub,
+ agent_store=getattr(app.state, "agent_store", None),
+ app_config=config,
+ dispatcher=get_dispatcher(app),
+ enabled=maf_enabled,
+ )
+ else:
+ app.state.maf_conversation_service = None
+ except Exception: # noqa: BLE001
+ logger.exception("[maf-roundtable] conversation service init failed")
+ app.state.maf_conversation_service = None
+ app.state.maf_conversation_event_hub = None
+
from deerflow.persistence.roundtable_jobs import (
make_roundtable_job_store,
)
@@ -458,6 +485,15 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
try:
yield
finally:
+ # P1: shut down the SSE event hub before closing the DB engine
+ # so active SSE streams are released cleanly.
+ hub = getattr(app.state, "maf_conversation_event_hub", None)
+ if hub is not None:
+ try:
+ hub.shutdown()
+ except Exception: # noqa: BLE001
+ logger.warning("[maf-roundtable] hub shutdown failed", exc_info=True)
+
from deerflow.persistence.notifications import set_notification_store
set_notification_store(None)
diff --git a/offline-backend-20260512/backend/app/gateway/routers/maf_conversations.py b/offline-backend-20260512/backend/app/gateway/routers/maf_conversations.py
new file mode 100644
index 00000000..6a753674
--- /dev/null
+++ b/offline-backend-20260512/backend/app/gateway/routers/maf_conversations.py
@@ -0,0 +1,341 @@
+"""HTTP router for MAF roundtable Conversation API at ``/api/maf-roundtable/conversations``.
+
+Phase 1 (R1) scope:
+- POST /conversations — create blank user conversation
+- GET /conversations — list current user's history
+- GET /conversations/{id} — public conversation DTO
+- GET /conversations/{id}/messages — paginated message history
+- POST /conversations/{id}/messages — atomic send + run start
+
+Phase 2 (R2) adds:
+- GET /conversations/{id}/stream — SSE real-time event stream
+
+User isolation: owner-only reads/writes. Admin diagnostic access is read-only.
+Internal fields (owner_user_id, internal_session_id, active_run_id, thread ids)
+are never returned.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+from datetime import UTC, datetime
+from typing import Any
+
+from fastapi import APIRouter, HTTPException, Query, Request, status
+from fastapi.responses import StreamingResponse
+
+from app.maf_roundtable.conversation_event_hub import ConversationEventHub
+from app.maf_roundtable.conversation_schemas import (
+ ConversationListResponse,
+ ConversationResponse,
+ CreateConversationRequest,
+ MessageListResponse,
+ SendMessageRequest,
+ SendMessageResponse,
+)
+from app.maf_roundtable.conversation_service import (
+ ConversationConflictError,
+ ConversationDisabledError,
+ ConversationNoSessionConfigError,
+ ConversationNotFoundError,
+ ConversationServiceError,
+ MafConversationService,
+)
+
+logger = logging.getLogger(__name__)
+router = APIRouter(prefix="/api/maf-roundtable", tags=["maf-roundtable-conversations"])
+
+_SSE_HEADERS = {
+ "Cache-Control": "no-cache",
+ "Connection": "keep-alive",
+ "X-Accel-Buffering": "no",
+}
+
+
+def _get_conversation_service(request: Request) -> MafConversationService:
+ svc = getattr(request.app.state, "maf_conversation_service", None)
+ if svc is None:
+ raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="MAF conversation service not available")
+ return svc
+
+
+def _get_event_hub(request: Request) -> ConversationEventHub | None:
+ return getattr(request.app.state, "maf_conversation_event_hub", None)
+
+
+def _user_ctx(request: Request) -> tuple[str, bool]:
+ """Match the main MAF router's authenticated-user convention.
+
+ ``AuthMiddleware`` stamps the resolved account on ``request.state.user``;
+ it does not create ``user_id`` / ``is_admin`` attributes. Reading those
+ nonexistent attributes collapses every authenticated caller into the
+ fallback account and breaks owner isolation.
+ """
+ user = getattr(request.state, "user", None)
+ if user is None:
+ return "default", False
+ return str(user.id), getattr(user, "system_role", None) == "admin"
+
+
+# ── REST endpoints (Phase 1) ──────────────────────────────────────────────
+
+
+@router.post("/conversations", response_model=ConversationResponse, status_code=status.HTTP_201_CREATED)
+async def create_conversation(body: CreateConversationRequest, request: Request):
+ svc = _get_conversation_service(request)
+ user_id, _ = _user_ctx(request)
+ try:
+ return await svc.create_conversation(
+ user_id=user_id,
+ title=body.title.strip(),
+ task_context=body.task_context,
+ )
+ except ConversationDisabledError:
+ raise HTTPException(
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
+ detail="MAF roundtable feature is disabled",
+ )
+
+
+@router.get("/conversations", response_model=ConversationListResponse)
+async def list_conversations(
+ request: Request,
+ limit: int = Query(default=50, ge=1, le=200),
+ offset: int = Query(default=0, ge=0),
+):
+ svc = _get_conversation_service(request)
+ user_id, _ = _user_ctx(request)
+ conversations, total = await svc.list_conversations(user_id=user_id, limit=limit, offset=offset)
+ return {"conversations": conversations, "total": total}
+
+
+@router.get("/conversations/{conversation_id}", response_model=ConversationResponse)
+async def get_conversation(conversation_id: str, request: Request):
+ svc = _get_conversation_service(request)
+ user_id, is_admin = _user_ctx(request)
+ try:
+ return await svc.get_conversation(conversation_id=conversation_id, user_id=user_id, is_admin=is_admin)
+ except ConversationNotFoundError:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Conversation not found")
+
+
+@router.get("/conversations/{conversation_id}/messages", response_model=MessageListResponse)
+async def list_messages(
+ conversation_id: str,
+ request: Request,
+ before_seq: int | None = Query(default=None, ge=1),
+ limit: int = Query(default=50, ge=1, le=200),
+):
+ svc = _get_conversation_service(request)
+ user_id, is_admin = _user_ctx(request)
+ try:
+ messages, last_stream_seq = await svc.list_messages(
+ conversation_id=conversation_id, user_id=user_id, is_admin=is_admin,
+ before_seq=before_seq, limit=limit,
+ )
+ return {"messages": messages, "last_stream_seq": last_stream_seq}
+ except ConversationNotFoundError:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Conversation not found")
+
+
+@router.post("/conversations/{conversation_id}/messages", response_model=SendMessageResponse, status_code=status.HTTP_201_CREATED)
+async def send_message(conversation_id: str, body: SendMessageRequest, request: Request):
+ svc = _get_conversation_service(request)
+ user_id, _ = _user_ctx(request)
+ try:
+ return await svc.send_message(conversation_id=conversation_id, user_id=user_id, content=body.content)
+ except ConversationNotFoundError:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Conversation not found")
+ except ConversationConflictError:
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="A run is already active")
+ except ConversationNoSessionConfigError:
+ raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="No trusted session configuration")
+ except ConversationDisabledError:
+ raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="MAF roundtable feature is disabled")
+ except ConversationServiceError as exc:
+ raise HTTPException(status_code=exc.status_code, detail=exc.message)
+
+
+# ── SSE endpoint (Phase 2 / R2) ───────────────────────────────────────────
+
+
+# Event types that mark the end of a run's lifecycle. When the SSE stream
+# encounters any of these in order, it sends the event and then closes the
+# connection so the client doesn't sit idle receiving heartbeats.
+_TERMINAL_EVENT_TYPES = frozenset({
+ "run.completed",
+ "run.error",
+ "run.cancelled",
+})
+
+
+def _format_sse_event(event: dict[str, Any]) -> str:
+ """Format one conversation event as an SSE frame per §4.3 contract.
+
+ ``event:`` field carries the event type; ``data:`` carries the full
+ envelope; ``id:`` carries the event_id for Last-Event-ID reconnection.
+ """
+ event_type = event.get("type", "message")
+ event_id = event.get("event_id")
+ payload = json.dumps(event, default=str, ensure_ascii=False)
+ parts = [f"event: {event_type}", f"data: {payload}"]
+ if event_id:
+ parts.append(f"id: {event_id}")
+ parts.append("")
+ parts.append("")
+ return "\n".join(parts)
+
+
+def _format_heartbeat() -> str:
+ """SSE comment frame — keeps the connection alive without producing a
+ client-visible event."""
+ return ": heartbeat\n\n"
+
+
+def _is_terminal(event: dict[str, Any]) -> bool:
+ return event.get("type") in _TERMINAL_EVENT_TYPES
+
+
+@router.get("/conversations/{conversation_id}/stream")
+async def stream_conversation(
+ conversation_id: str,
+ request: Request,
+ after_seq: int = Query(default=0, ge=0),
+):
+ """SSE stream for one conversation (§4.2).
+
+ Single fetch-reader connection per conversation. The ordering is:
+
+ 1. **Open subscription** (register before any DB query — P0-1 fix).
+ 2. **Replay** from DB: ``list_events(after_seq)``.
+ 3. **Stabilise**: re-query while the hub generation advanced during replay.
+ 4. **Snapshot** marker.
+ 5. **Real-time**: ``wait_for_change`` → query DB → yield events.
+
+ On terminal events (``run.completed`` / ``run.error`` / ``run.cancelled``)
+ the stream sends the event and closes (P0-2 fix).
+
+ Heartbeat every 15 s. Disconnect detection via ``request.is_disconnected()``.
+ """
+ svc = _get_conversation_service(request)
+ hub = _get_event_hub(request)
+ user_id, is_admin = _user_ctx(request)
+
+ if hub is None:
+ raise HTTPException(
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
+ detail="SSE event hub not configured",
+ )
+
+ # Ownership check before opening the stream.
+ try:
+ await svc.get_conversation(
+ conversation_id=conversation_id, user_id=user_id, is_admin=is_admin,
+ )
+ except ConversationNotFoundError:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Conversation not found")
+
+ store = svc._store # noqa: SLF001 — router needs raw event queries for streaming
+
+ async def event_generator():
+ # ── P0-1: Open subscription BEFORE replay ──
+ sub = hub.open_subscription(conversation_id)
+ last_seq = after_seq
+ try:
+ # ── Phase 1: Replay from DB ──
+ try:
+ replay_events = await store.conversation_events.list_events(
+ conversation_id, after_seq=last_seq,
+ )
+ for evt in replay_events:
+ if await request.is_disconnected():
+ return
+ yield _format_sse_event(evt)
+ last_seq = max(last_seq, int(evt.get("seq", last_seq)))
+ # P0-2: stop if replay already hit a terminal event.
+ if _is_terminal(evt):
+ return
+ except Exception:
+ logger.exception("SSE replay error conv=%s", conversation_id)
+ yield _format_sse_event({
+ "seq": last_seq,
+ "event_id": f"replay_error_{last_seq}",
+ "conversation_id": conversation_id,
+ "type": "run.error",
+ "occurred_at": datetime.now(UTC).isoformat(),
+ "data": {"error": "replay_failed"},
+ })
+ return
+
+ # ── P0-1: Stabilise — catch events published during replay ──
+ while sub.has_pending:
+ sub.mark_seen()
+ late_events = await store.conversation_events.list_events(
+ conversation_id, after_seq=last_seq,
+ )
+ if not late_events:
+ break
+ for evt in late_events:
+ if await request.is_disconnected():
+ return
+ yield _format_sse_event(evt)
+ last_seq = max(last_seq, int(evt.get("seq", last_seq)))
+ if _is_terminal(evt):
+ return
+
+ # ── Send snapshot marker ──
+ snapshot = {
+ "seq": last_seq,
+ "event_id": f"snapshot_{conversation_id}_{last_seq}",
+ "conversation_id": conversation_id,
+ "type": "conversation.snapshot",
+ "occurred_at": datetime.now(UTC).isoformat(),
+ "data": {"last_stream_seq": last_seq},
+ }
+ if not await request.is_disconnected():
+ yield _format_sse_event(snapshot)
+
+ # ── Phase 2: Real-time subscription ──
+ heartbeat_interval = hub.heartbeat_interval
+ while True:
+ if hub.is_closed:
+ return
+ if await request.is_disconnected():
+ return
+
+ changed = await sub.wait_for_change(timeout=heartbeat_interval)
+ if not changed:
+ # Timeout → heartbeat (or hub closed).
+ if hub.is_closed:
+ return
+ yield _format_heartbeat()
+ continue
+
+ # Generation advanced — fetch new events from DB.
+ new_events = await store.conversation_events.list_events(
+ conversation_id, after_seq=last_seq,
+ )
+ for evt in new_events:
+ if await request.is_disconnected():
+ return
+ yield _format_sse_event(evt)
+ last_seq = max(last_seq, int(evt.get("seq", last_seq)))
+ # P0-2: terminal event → close stream after sending it.
+ if _is_terminal(evt):
+ return
+ except asyncio.CancelledError:
+ logger.debug("SSE stream cancelled conv=%s", conversation_id)
+ raise
+ except Exception:
+ logger.exception("SSE stream error conv=%s", conversation_id)
+ return
+ finally:
+ sub.close()
+
+ return StreamingResponse(
+ event_generator(),
+ media_type="text/event-stream",
+ headers=_SSE_HEADERS,
+ )
diff --git a/offline-backend-20260512/backend/app/gateway/routers/maf_roundtable.py b/offline-backend-20260512/backend/app/gateway/routers/maf_roundtable.py
index 591661d4..4abe9d9f 100644
--- a/offline-backend-20260512/backend/app/gateway/routers/maf_roundtable.py
+++ b/offline-backend-20260512/backend/app/gateway/routers/maf_roundtable.py
@@ -131,6 +131,9 @@ async def get_health(request: Request) -> HealthResponse:
write_guard_ready=readiness["ready"],
action_secret_configured=readiness["secret_configured"],
write_guard_status=readiness["status"],
+ sse_hub_type="in_memory",
+ sse_multi_worker=False,
+ sse_warning="Single-worker only. Multi-worker requires Redis Pub/Sub.",
)
diff --git a/offline-backend-20260512/backend/app/maf_roundtable/conversation_event_hub.py b/offline-backend-20260512/backend/app/maf_roundtable/conversation_event_hub.py
new file mode 100644
index 00000000..ff1d0e08
--- /dev/null
+++ b/offline-backend-20260512/backend/app/maf_roundtable/conversation_event_hub.py
@@ -0,0 +1,284 @@
+"""Conversation-scoped in-process event hub for MAF roundtable SSE streaming.
+
+This hub mediates between the writers (store/service/workflow executor that
+persist conversation events to the DB) and the SSE subscribers (the HTTP
+stream endpoint). It is deliberately **notification-only**: the database is
+the authoritative event store, and this hub merely signals "new events may be
+available" so subscribers can re-query. This avoids duplicating the event log
+in memory and guarantees that a reconnecting client can always replay from the
+DB cursor (``after_seq``).
+
+Writer contract (§4.2 "先持久化、后通知"):
+ 1. Persist events to ``maf_roundtable_conversation_events`` (via
+ ``MafRoundtableStore.append_conversation_item``) inside a transaction.
+ 2. **After commit**, call ``await hub.publish(conversation_id)``.
+
+Subscriber contract (P0-1 race-free):
+ 1. ``sub = hub.open_subscription(conversation_id)`` — register **before**
+ any DB query so publishes during replay are captured.
+ 2. Replay from DB: ``list_events(after_seq=last_seq)``.
+ 3. **Stabilise**: if ``sub.current_generation > sub.seen_generation``,
+ re-query DB and repeat until stable (events arrived during replay).
+ 4. Send snapshot.
+ 5. ``await sub.wait_for_change(timeout=heartbeat)`` → on True, query DB
+ for new events; on False, send heartbeat.
+
+ This eliminates the ``signal.clear()`` race: generation is monotonic and
+ never cleared, so a publish that happens between the DB read and the
+ ``wait()`` is always detected on the next stabilisation check.
+
+Multi-worker limitation:
+ Single-process, in-memory. Multi-worker requires Redis Pub/Sub.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ pass
+
+logger = logging.getLogger(__name__)
+
+_DEFAULT_HEARTBEAT_INTERVAL = 15.0
+_MAX_SUBSCRIBERS_PER_CONVERSATION = 64
+
+
+class _ConvState:
+ """Per-conversation notification state."""
+
+ __slots__ = ("generation", "condition", "subscriber_count")
+
+ def __init__(self) -> None:
+ self.generation: int = 0
+ self.condition: asyncio.Condition = asyncio.Condition()
+ self.subscriber_count: int = 0
+
+
+class Subscription:
+ """Explicit subscription handle — race-free notification reader.
+
+ Created via :meth:`ConversationEventHub.open_subscription`. The caller
+ owns its lifecycle: call :meth:`close` when the SSE connection ends.
+
+ Key invariant: ``seen_generation`` is monotonic — it is never reset.
+ A publish that races ahead of ``wait_for_change`` simply increments
+ ``current_generation``, and the next ``wait_for_change`` returns
+ immediately (no lost wakeup).
+ """
+
+ __slots__ = ("_hub", "_conv_id", "_seen_gen", "_closed")
+
+ def __init__(self, hub: ConversationEventHub, conversation_id: str, gen_at_open: int) -> None:
+ self._hub = hub
+ self._conv_id = conversation_id
+ self._seen_gen = gen_at_open
+ self._closed = False
+
+ @property
+ def conversation_id(self) -> str:
+ return self._conv_id
+
+ @property
+ def current_generation(self) -> int:
+ """Latest generation known to the hub for this conversation."""
+ return self._hub._get_generation(self._conv_id)
+
+ @property
+ def seen_generation(self) -> int:
+ """Generation as of the last successful stabilisation / wait."""
+ return self._seen_gen
+
+ @property
+ def has_pending(self) -> bool:
+ """True when the hub generation is ahead of what we've seen."""
+ return self.current_generation > self._seen_gen
+
+ def mark_seen(self) -> None:
+ """Advance ``seen_generation`` to the hub's current generation."""
+ self._seen_gen = self.current_generation
+
+ def mark_seen_if_higher(self, gen: int) -> None:
+ if gen > self._seen_gen:
+ self._seen_gen = gen
+
+ async def wait_for_change(self, *, timeout: float) -> bool:
+ """Block until ``current_generation > seen_generation`` or timeout.
+
+ Returns ``True`` if the generation advanced (new events *might* be
+ available — caller must query DB to confirm). Returns ``False`` on
+ timeout (caller should send a heartbeat).
+ """
+ state = self._hub._convs.get(self._conv_id)
+ if state is None:
+ return False
+ async with state.condition:
+ if state.generation > self._seen_gen:
+ self._seen_gen = state.generation
+ return True
+ try:
+ await asyncio.wait_for(state.condition.wait(), timeout=timeout)
+ except TimeoutError:
+ return False
+ if state.generation > self._seen_gen:
+ self._seen_gen = state.generation
+ return True
+ return False
+
+ def close(self) -> None:
+ """Release the subscription. Safe to call multiple times."""
+ if not self._closed:
+ self._closed = True
+ self._hub._release_subscription(self._conv_id)
+
+ def __enter__(self) -> Subscription:
+ return self
+
+ def __exit__(self, *exc: object) -> None:
+ self.close()
+
+
+class ConversationEventHub:
+ """In-process notification hub keyed by ``conversation_id``.
+
+ Not thread-safe; intended for a single asyncio event loop (one worker).
+ """
+
+ def __init__(self, *, heartbeat_interval: float = _DEFAULT_HEARTBEAT_INTERVAL) -> None:
+ self._heartbeat_interval = heartbeat_interval
+ self._convs: dict[str, _ConvState] = {}
+ self._closed = False
+
+ @property
+ def heartbeat_interval(self) -> float:
+ return self._heartbeat_interval
+
+ @property
+ def total_subscribers(self) -> int:
+ return sum(s.subscriber_count for s in self._convs.values())
+
+ @property
+ def is_closed(self) -> bool:
+ return self._closed
+
+ def conversation_subscriber_count(self, conversation_id: str) -> int:
+ state = self._convs.get(conversation_id)
+ return state.subscriber_count if state else 0
+
+ def _get_generation(self, conversation_id: str) -> int:
+ state = self._convs.get(conversation_id)
+ return state.generation if state else 0
+
+ def open_subscription(self, conversation_id: str) -> Subscription:
+ """Register a new subscriber **before** any DB replay.
+
+ Returns a :class:`Subscription` whose ``seen_generation`` is the hub's
+ current generation at registration time. Any ``publish()`` after this
+ call will be detectable via ``sub.has_pending``.
+ """
+ if self._closed:
+ raise RuntimeError("ConversationEventHub is shut down")
+ state = self._convs.get(conversation_id)
+ if state is None:
+ state = _ConvState()
+ self._convs[conversation_id] = state
+ if state.subscriber_count >= _MAX_SUBSCRIBERS_PER_CONVERSATION:
+ raise RuntimeError(
+ f"Too many SSE subscribers for conversation {conversation_id} "
+ f"(max {_MAX_SUBSCRIBERS_PER_CONVERSATION})"
+ )
+ state.subscriber_count += 1
+ logger.debug(
+ "SSE subscribe conv=%s gen=%d (subs=%d)",
+ conversation_id, state.generation, state.subscriber_count,
+ )
+ return Subscription(self, conversation_id, state.generation)
+
+ def _release_subscription(self, conversation_id: str) -> None:
+ state = self._convs.get(conversation_id)
+ if state is None:
+ return
+ state.subscriber_count -= 1
+ logger.debug(
+ "SSE unsubscribe conv=%s (subs=%d)",
+ conversation_id, max(0, state.subscriber_count),
+ )
+ if state.subscriber_count <= 0:
+ self._convs.pop(conversation_id, None)
+
+ async def publish(self, conversation_id: str) -> None:
+ """Signal that new events have been persisted for a conversation.
+
+ Must be called **after** the events are committed to the DB, never
+ before. Atomically increments the per-conversation generation counter
+ and wakes all waiting subscribers. Safe to call when no subscribers
+ are active (the next subscriber's stabilisation check will see the
+ incremented generation).
+ """
+ if self._closed:
+ return
+ state = self._convs.get(conversation_id)
+ if state is None:
+ # No active subscribers — nothing to wake. The generation is not
+ # tracked for conversations with zero subscribers; a future
+ # open_subscription starts fresh at 0, which is correct because
+ # there was nobody to notify.
+ return
+ async with state.condition:
+ state.generation += 1
+ state.condition.notify_all()
+
+ async def wait_for_change(
+ self, conversation_id: str, seen_gen: int, *, timeout: float
+ ) -> int:
+ """Convenience: wait until generation > ``seen_gen``.
+
+ Returns the new generation (> ``seen_gen``) on change, or ``seen_gen``
+ unchanged on timeout. Prefer using a :class:`Subscription` for the
+ full lifecycle.
+ """
+ state = self._convs.get(conversation_id)
+ if state is None:
+ return seen_gen
+ async with state.condition:
+ if state.generation > seen_gen:
+ return state.generation
+ try:
+ await asyncio.wait_for(state.condition.wait(), timeout=timeout)
+ except TimeoutError:
+ return seen_gen
+ return state.generation
+
+ def shutdown(self) -> None:
+ """Signal all subscribers to stop (server shutdown).
+
+ Wakes every waiter; their ``wait_for_change`` returns ``True`` but
+ the caller's outer loop checks ``hub.is_closed`` and exits.
+ """
+ self._closed = True
+ for state in self._convs.values():
+ state.generation += 1
+ # Wake all waiters — we can't use the condition here because
+ # shutdown is sync. The next wait_for_change call will see
+ # the incremented generation and return immediately.
+ # For a truly sync wake we'd need to schedule the notify;
+ # but since shutdown is called from the lifespan teardown
+ # (async context), callers should use:
+ # async with state.condition: state.condition.notify_all()
+ # Instead we just bump the generation; waiters time out on the
+ # next heartbeat cycle at worst (15s). For immediate wake,
+ # use `await hub.aclose()` below.
+
+
+async def hub_aclose(hub: ConversationEventHub) -> None:
+ """Async shutdown that immediately wakes all subscribers."""
+ hub._closed = True
+ for state in list(hub._convs.values()):
+ async with state.condition:
+ state.generation += 1
+ state.condition.notify_all()
+
+
+__all__ = ["ConversationEventHub", "Subscription", "hub_aclose"]
diff --git a/offline-backend-20260512/backend/app/maf_roundtable/conversation_schemas.py b/offline-backend-20260512/backend/app/maf_roundtable/conversation_schemas.py
new file mode 100644
index 00000000..bf4e407f
--- /dev/null
+++ b/offline-backend-20260512/backend/app/maf_roundtable/conversation_schemas.py
@@ -0,0 +1,118 @@
+"""Pydantic schemas for the MAF roundtable Conversation API (V2).
+
+These are the public wire contracts for ``/api/maf-roundtable/conversations``.
+Internal fields (owner_user_id, internal_session_id, active_run_id, thread ids,
+capability snapshots, idempotency keys) are NEVER exposed here.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
+
+_TASK_CONTEXT_SAFE_KEYS = frozenset({"business_code", "task_id", "task_name", "chain_id", "chain_name"})
+
+
+class _StrictRequestModel(BaseModel):
+ """Reject unknown request members instead of silently ignoring spoofed input."""
+
+ model_config = ConfigDict(extra="forbid")
+
+
+class CreateConversationRequest(_StrictRequestModel):
+ title: str = Field(default="", max_length=255)
+ task_context: dict[str, Any] | None = None
+
+ @model_validator(mode="before")
+ @classmethod
+ def _reject_internal_fields(cls, values: Any) -> Any:
+ if isinstance(values, dict):
+ forbidden = {"user_id", "owner_user_id", "internal_session_id", "run_id",
+ "thread_id", "capability_snapshot", "id"}
+ found = forbidden & set(values.keys())
+ if found:
+ raise ValueError(f"Forbidden fields: {sorted(found)}")
+ return values
+
+ @field_validator("task_context")
+ @classmethod
+ def _validate_task_context(cls, value: dict[str, Any] | None) -> dict[str, str] | None:
+ if value is None:
+ return None
+ unexpected = set(value) - _TASK_CONTEXT_SAFE_KEYS
+ if unexpected:
+ raise ValueError(f"Unsupported task_context fields: {sorted(unexpected)}")
+ cleaned: dict[str, str] = {}
+ for key, raw in value.items():
+ if not isinstance(raw, (str, int)) or isinstance(raw, bool):
+ raise ValueError(f"task_context.{key} must be a string or integer")
+ rendered = str(raw).strip()
+ if not rendered:
+ raise ValueError(f"task_context.{key} cannot be blank")
+ if len(rendered) > 255:
+ raise ValueError(f"task_context.{key} exceeds 255 characters")
+ cleaned[key] = rendered
+ return cleaned
+
+
+class SendMessageRequest(_StrictRequestModel):
+ content: str = Field(min_length=1, max_length=20000)
+
+ @model_validator(mode="before")
+ @classmethod
+ def _reject_internal_fields(cls, values: Any) -> Any:
+ if isinstance(values, dict):
+ forbidden = {"user_id", "owner_user_id", "internal_session_id", "run_id",
+ "thread_id", "capability_snapshot", "idempotency_key"}
+ found = forbidden & set(values.keys())
+ if found:
+ raise ValueError(f"Forbidden fields: {sorted(found)}")
+ return values
+
+ @model_validator(mode="after")
+ def _strip_content(self) -> SendMessageRequest:
+ stripped = self.content.strip()
+ if not stripped:
+ raise ValueError("content cannot be empty or whitespace-only")
+ self.content = stripped
+ return self
+
+
+class ConversationResponse(BaseModel):
+ id: str
+ title: str
+ task_context: dict[str, Any] | None = None
+ safe_status: str
+ created_at: str | None = None
+ updated_at: str | None = None
+ last_stream_seq: int = 0
+
+
+class ConversationListResponse(BaseModel):
+ conversations: list[ConversationResponse]
+ total: int
+
+
+class MessageResponse(BaseModel):
+ id: str
+ conversation_id: str
+ seq: int
+ message_kind: str
+ role: str
+ agent_id: str | None = None
+ message_ref: str | None = None
+ content: str
+ metadata: dict[str, Any] | None = None
+ created_at: str | None = None
+
+
+class MessageListResponse(BaseModel):
+ messages: list[MessageResponse]
+ last_stream_seq: int
+
+
+class SendMessageResponse(BaseModel):
+ message_id: str
+ conversation_id: str
+ seq: int
diff --git a/offline-backend-20260512/backend/app/maf_roundtable/conversation_service.py b/offline-backend-20260512/backend/app/maf_roundtable/conversation_service.py
new file mode 100644
index 00000000..eb379dc1
--- /dev/null
+++ b/offline-backend-20260512/backend/app/maf_roundtable/conversation_service.py
@@ -0,0 +1,670 @@
+"""Conversation service for the MAF roundtable V2 (App layer).
+
+Orchestrates the harness-layer store with business rules that belong to the
+application layer: session blueprint construction, ownership enforcement,
+public DTO projection, and fail-closed behaviour when configuration is missing.
+
+Blueprint generation lives here (not in the harness) because it depends on
+deployment config (``MafRoundtableConfig``) which is an app-layer concern.
+The harness ``MafRoundtableStore`` never imports ``app.*``.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+from datetime import UTC, datetime
+from typing import TYPE_CHECKING, Any
+
+from app.maf_roundtable.dispatch_contracts import (
+ DispatchConfigurationError,
+ MafDispatchCatalog,
+ MafDispatchPlan,
+ MafIntentDecision,
+ resolve_intent,
+)
+from deerflow.persistence.maf_roundtable.conversations import (
+ conversation_public_to_dict,
+)
+from deerflow.persistence.maf_roundtable.messages import message_public_to_dict
+from deerflow.persistence.maf_roundtable.model import (
+ MafRoundtableConversationRow,
+ MafRoundtableMessageRow,
+ MafRoundtableRunRow,
+)
+from deerflow.persistence.maf_roundtable.store import MafRoundtableStore
+
+if TYPE_CHECKING:
+ from app.maf_roundtable.conversation_event_hub import ConversationEventHub
+
+logger = logging.getLogger(__name__)
+
+
+class ConversationServiceError(Exception):
+ def __init__(self, code: str, message: str, status_code: int = 400):
+ self.code = code
+ self.message = message
+ self.status_code = status_code
+ super().__init__(message)
+
+
+class ConversationNotFoundError(ConversationServiceError):
+ def __init__(self) -> None:
+ super().__init__("not_found", "Conversation not found", 404)
+
+
+class ConversationConflictError(ConversationServiceError):
+ def __init__(self) -> None:
+ super().__init__("conflict", "A run is already active for this conversation", 409)
+
+
+class ConversationNoSessionConfigError(ConversationServiceError):
+ def __init__(self) -> None:
+ super().__init__(
+ "no_session_config",
+ "Cannot start a run: no trusted session configuration available",
+ 503,
+ )
+
+
+class ConversationDisabledError(ConversationServiceError):
+ def __init__(self) -> None:
+ super().__init__("disabled", "MAF roundtable feature is disabled", 503)
+
+
+class ConversationDispatchError(ConversationServiceError):
+ def __init__(self, message: str = "MAF dispatch is not configured") -> None:
+ super().__init__("dispatch_unavailable", message, 503)
+
+
+class ConversationRuntimeUnavailableError(ConversationServiceError):
+ def __init__(self) -> None:
+ super().__init__(
+ "runtime_unavailable",
+ "MAF workflow runtime is not available",
+ 503,
+ )
+
+
+class MafConversationService:
+ """App-layer service for user-visible MAF conversations."""
+
+ def __init__(
+ self,
+ store: MafRoundtableStore,
+ *,
+ maf_config: Any = None,
+ event_hub: ConversationEventHub | None = None,
+ agent_store: Any | None = None,
+ app_config: Any | None = None,
+ dispatcher: Any | None = None,
+ enabled: bool | None = None,
+ ) -> None:
+ self._store = store
+ self._maf_config = maf_config
+ self._hub = event_hub
+ self._agent_store = agent_store
+ self._app_config = app_config
+ self._dispatcher = dispatcher
+ self._enabled_override = enabled
+ self._watchers: dict[str, asyncio.Task[None]] = {}
+
+ @property
+ def _enabled(self) -> bool:
+ """Mutating conversation APIs obey the same feature flag as MAF runs."""
+ if self._enabled_override is not None:
+ return self._enabled_override
+ return bool(getattr(self._maf_config, "enabled", False))
+
+ def _read_scope_user(self, *, user_id: str, is_admin: bool) -> str | None:
+ """Keep administrator diagnostics read-only and config-gated."""
+ if is_admin and bool(getattr(self._maf_config, "admin_diagnostic_access", False)):
+ return None
+ return user_id
+
+ async def _build_dispatch_blueprint(
+ self,
+ *,
+ user_id: str,
+ title: str,
+ content: str,
+ task_context: dict[str, Any] | None,
+ ) -> tuple[dict[str, Any] | None, MafIntentDecision, MafDispatchPlan | None]:
+ cfg = self._maf_config
+ if cfg is None:
+ raise ConversationDispatchError("MAF session configuration is unavailable")
+
+ intent = resolve_intent(content, task_context)
+ plan: MafDispatchPlan | None = None
+ participants: list[dict[str, Any]] = []
+ capability_snapshot: dict[str, Any] | None = None
+
+ if intent.intent_type != "clarification":
+ try:
+ plan = MafDispatchCatalog(cfg).build_plan(
+ intent=intent,
+ task_context=task_context,
+ )
+ except DispatchConfigurationError as exc:
+ raise ConversationDispatchError(str(exc)) from exc
+
+ from app.maf_roundtable.capability_snapshot import (
+ AgentNotAccessibleError,
+ InvalidExcludedToolsError,
+ build_trusted_capability_snapshot,
+ seat_snapshot_for_agent,
+ )
+
+ participants = MafDispatchCatalog.participants(plan)
+ if self._app_config is not None:
+ try:
+ capability_snapshot = await build_trusted_capability_snapshot(
+ user_id=user_id,
+ participants=participants,
+ app_config=self._app_config,
+ agent_store=self._agent_store,
+ session_model_name=getattr(cfg, "default_model_name", None),
+ session_reasoning_effort=getattr(cfg, "default_reasoning_effort", None),
+ )
+ except AgentNotAccessibleError as exc:
+ raise ConversationDispatchError(
+ f"configured MAF agent is not accessible: {exc.agent_id}"
+ ) from exc
+ except InvalidExcludedToolsError as exc:
+ raise ConversationDispatchError(
+ f"configured MAF agent has invalid excluded tools: {exc.agent_id}"
+ ) from exc
+
+ for participant in participants:
+ participant["capability_snapshot"] = seat_snapshot_for_agent(
+ capability_snapshot, str(participant["agent_id"])
+ )
+
+ payload = {
+ "maf_v2": {
+ "intent_type": intent.intent_type,
+ "reason_code": intent.reason_code,
+ "business_code": (task_context or {}).get("business_code"),
+ "dispatch_plan": plan.model_dump(mode="json") if plan is not None else None,
+ }
+ }
+ blueprint = {
+ "title": title,
+ "task_text": content,
+ "mode": getattr(plan, "mode", None) or getattr(cfg, "default_mode", "structured"),
+ "model_name": getattr(cfg, "default_model_name", None),
+ "reasoning_effort": getattr(cfg, "default_reasoning_effort", None),
+ "manager_agent_id": (
+ plan.manager_agent_id if plan is not None
+ else getattr(cfg, "default_manager_agent_id", None)
+ ),
+ "max_rounds": getattr(plan, "max_rounds", None) or getattr(cfg, "default_max_rounds", 8),
+ "write_policy": getattr(cfg, "default_write_policy", "confirm_high_risk"),
+ "capability_snapshot": capability_snapshot,
+ "task_payload": payload,
+ "participants": participants,
+ "start_run": intent.intent_type != "clarification",
+ }
+ return blueprint, intent, plan
+
+ async def create_conversation(
+ self, *, user_id: str, title: str = "", task_context: dict[str, Any] | None = None
+ ) -> dict[str, Any]:
+ if not self._enabled:
+ raise ConversationDisabledError()
+ row_data = await self._store.conversations.create_conversation({
+ "owner_user_id": user_id,
+ "title": title,
+ "task_context": task_context,
+ })
+ conv_row = await self._get_conv_row(row_data["id"])
+ if conv_row is None:
+ raise ConversationNotFoundError()
+ return conversation_public_to_dict(conv_row)
+
+ async def list_conversations(
+ self, *, user_id: str, limit: int = 50, offset: int = 0
+ ) -> tuple[list[dict[str, Any]], int]:
+ rows = await self._store.conversations.list_conversations(
+ user_id=user_id, limit=limit, offset=offset
+ )
+ total = await self._store.conversations.count_conversations(user_id=user_id)
+ public = []
+ for r in rows:
+ conv_row = await self._get_conv_row(r["id"])
+ if conv_row is not None:
+ public.append(conversation_public_to_dict(conv_row))
+ return public, total
+
+ async def get_conversation(self, *, conversation_id: str, user_id: str, is_admin: bool = False) -> dict[str, Any]:
+ effective_user = self._read_scope_user(user_id=user_id, is_admin=is_admin)
+ row = await self._store.conversations.get_conversation(conversation_id, user_id=effective_user)
+ if row is None:
+ raise ConversationNotFoundError()
+ conv_row = await self._get_conv_row(conversation_id)
+ if conv_row is None:
+ raise ConversationNotFoundError()
+ return conversation_public_to_dict(conv_row)
+
+ async def list_messages(
+ self, *, conversation_id: str, user_id: str, is_admin: bool = False,
+ before_seq: int | None = None, limit: int = 50,
+ ) -> tuple[list[dict[str, Any]], int]:
+ effective_user = self._read_scope_user(user_id=user_id, is_admin=is_admin)
+ conv = await self._store.conversations.get_conversation(conversation_id, user_id=effective_user)
+ if conv is None:
+ raise ConversationNotFoundError()
+
+ rows = await self._store.messages.list_messages_desc(
+ conversation_id, limit=limit, before_seq=before_seq
+ )
+ public = []
+ for r in rows:
+ msg_row = await self._get_msg_row(r["id"])
+ if msg_row is not None:
+ public.append(message_public_to_dict(msg_row))
+ return public, int(conv.get("last_stream_seq", 0))
+
+ async def send_message(
+ self, *, conversation_id: str, user_id: str, content: str
+ ) -> dict[str, Any]:
+ if not self._enabled:
+ raise ConversationDisabledError()
+ conv = await self._store.conversations.get_conversation(conversation_id, user_id=user_id)
+ if conv is None:
+ raise ConversationNotFoundError()
+
+ try:
+ blueprint, intent, plan = await self._build_dispatch_blueprint(
+ user_id=user_id,
+ title=conv.get("title", ""),
+ content=content,
+ task_context=conv.get("task_context"),
+ )
+ except ConversationServiceError:
+ raise
+
+ if blueprint is None:
+ raise ConversationNoSessionConfigError()
+
+ # Do not create a durable running record if the MAF SDK is missing.
+ # Unit tests may inject a small fake dispatcher without this optional
+ # readiness method.
+ if intent.intent_type != "clarification" and self._dispatcher is not None:
+ check_sdk = getattr(self._dispatcher, "check_sdk_available", None)
+ if callable(check_sdk):
+ try:
+ check_sdk()
+ except Exception as exc: # noqa: BLE001 - safe public error
+ logger.warning("MAF runtime unavailable before dispatch: %s", type(exc).__name__)
+ raise ConversationRuntimeUnavailableError() from exc
+
+ result = await self._store.send_message_and_start_run(
+ conversation_id=conversation_id,
+ owner_user_id=user_id,
+ content=content,
+ session_blueprint=blueprint,
+ )
+
+ if result is None:
+ raise ConversationNotFoundError()
+ if result.get("conflict"):
+ raise ConversationConflictError()
+ if result.get("no_session_config"):
+ raise ConversationNoSessionConfigError()
+
+ await self._publish_conversation_event(
+ conversation_id=conversation_id,
+ owner_user_id=user_id,
+ event_type="manager.intent",
+ event_data={
+ "intent_type": intent.intent_type,
+ "confidence": intent.confidence,
+ "reason_code": intent.reason_code,
+ },
+ )
+
+ if intent.intent_type == "clarification":
+ await self._publish_conversation_event(
+ conversation_id=conversation_id,
+ owner_user_id=user_id,
+ event_type="clarification.required",
+ event_data={
+ "prompt": intent.clarification_prompt,
+ "reason_code": intent.reason_code,
+ },
+ )
+ else:
+ if plan is None or result.get("run") is None:
+ raise ConversationDispatchError("dispatch plan did not produce a run")
+ await self._publish_conversation_event(
+ conversation_id=conversation_id,
+ owner_user_id=user_id,
+ event_type="manager.dispatch",
+ event_data={
+ "intent_type": plan.intent_type,
+ "mode": plan.mode,
+ "business_code": plan.business_code,
+ "manager_agent_id": plan.manager_agent_id,
+ "seat_agent_ids": list(plan.seat_agent_ids),
+ "seat_count": len(plan.seat_agent_ids),
+ },
+ )
+ await self._dispatch_run(
+ conversation_id=conversation_id,
+ user_id=user_id,
+ run=result["run"],
+ )
+
+ return {
+ "message_id": result["message"]["id"] if result.get("message") else "",
+ "conversation_id": conversation_id,
+ "seq": result.get("seq", 0),
+ }
+
+ async def _publish_conversation_event(
+ self,
+ *,
+ conversation_id: str,
+ owner_user_id: str,
+ event_type: str,
+ event_data: dict[str, Any],
+ source_event_id: str | None = None,
+ ) -> bool:
+ """Persist first, then notify; no internal run/thread ids are used.
+
+ P0-4: when ``source_event_id`` is provided, dedup is enforced — if an
+ event with the same ``(conversation_id, source_event_id)`` already
+ exists, the method returns ``False`` (no duplicate written). This
+ makes projection idempotent across retries and restarts.
+
+ Returns ``True`` if a new event was written, ``False`` if deduped.
+ """
+ from sqlalchemy import select
+
+ async with self._store.session_factory() as db:
+ async with db.begin():
+ conv = (
+ await db.execute(
+ select(MafRoundtableConversationRow)
+ .where(MafRoundtableConversationRow.id == conversation_id)
+ .with_for_update()
+ )
+ ).scalars().first()
+ if conv is None or conv.owner_user_id != owner_user_id:
+ return False
+
+ # P0-4: dedup check — skip if already projected.
+ if source_event_id is not None:
+ from deerflow.persistence.maf_roundtable.model import (
+ MafRoundtableConversationEventRow,
+ )
+
+ existing = (
+ await db.execute(
+ select(MafRoundtableConversationEventRow.id).where(
+ MafRoundtableConversationEventRow.conversation_id == conversation_id,
+ MafRoundtableConversationEventRow.source_event_id == source_event_id,
+ ).limit(1)
+ )
+ ).first()
+ if existing is not None:
+ return False
+
+ await self._store.append_conversation_item(
+ db,
+ conversation=conv,
+ event_type=event_type,
+ event_data=event_data,
+ run_id=None,
+ source_event_id=source_event_id,
+ )
+ if self._hub is not None:
+ try:
+ await self._hub.publish(conversation_id)
+ except Exception: # noqa: BLE001
+ logger.exception("hub publish failed conv=%s", conversation_id)
+ return True
+
+ async def bridge_workflow_event(self, *, run_id: str, event: dict[str, Any]) -> None:
+ """Project a persisted per-run event onto the V2 conversation stream.
+
+ The per-run event ledger remains the audit source of truth. This
+ projection is deliberately lossy: only user-visible lifecycle fields
+ are copied, while tool arguments, tool-call ids, thread ids, run ids,
+ and raw exception bodies are discarded before the SSE event is saved.
+ """
+ from sqlalchemy import select
+
+ async with self._store.session_factory() as db:
+ row = (
+ await db.execute(
+ select(MafRoundtableConversationRow)
+ .join(
+ MafRoundtableRunRow,
+ MafRoundtableConversationRow.internal_session_id
+ == MafRoundtableRunRow.session_id,
+ )
+ .where(MafRoundtableRunRow.id == run_id)
+ )
+ ).scalars().first()
+ if row is None:
+ return
+ conversation_id = row.id
+ owner_user_id = row.owner_user_id
+
+ source_type = str(event.get("event_type") or "")
+ mapped_type = {
+ "agent.turn_started": "agent.message.start",
+ "agent.message.completed": "agent.message.complete",
+ "action.waiting_approval": "approval.required",
+ "action.approved": "approval.decided",
+ "action.rejected": "approval.decided",
+ "run.started": "run.status",
+ "run.failed": "run.error",
+ "run.cancelled": "run.cancelled",
+ "manager.decision": "manager.dispatch",
+ "workflow.checkpointed": "run.status",
+ "action.waiting_target_lease": "tool.progress",
+ "action.unknown": "tool.failed",
+ }.get(source_type, source_type)
+
+ allowed_types = {
+ "agent.message.start", "agent.message.delta", "agent.message.complete",
+ "seat.delivery.submitted", "manager.dispatch", "manager.review.decision",
+ "tool.requested", "tool.started", "tool.progress", "tool.completed", "tool.failed",
+ "skill.selected", "skill.started", "skill.completed", "skill.failed",
+ "artifact.created", "approval.required", "approval.decided",
+ "fact.confirmed", "review.completed", "run.status", "run.error",
+ "run.cancelled", "run.completed",
+ }
+ if mapped_type not in allowed_types:
+ return
+
+ payload = event.get("payload")
+ if not isinstance(payload, dict):
+ payload = {}
+ safe_keys = {
+ "agent.message.start": {"thread_id": False},
+ "agent.message.delta": {"text": True},
+ "agent.message.complete": {"content": True, "usage": True, "metadata": False},
+ "tool.requested": {"tool_name": True},
+ "tool.started": {"tool_name": True, "node": True},
+ "tool.progress": {"tool_name": True, "status": True},
+ "tool.completed": {"tool_name": True, "status": True, "content_preview": True},
+ "tool.failed": {"tool_name": True, "status": True, "error_code": True},
+ "skill.selected": {"skill_name": True, "message": True},
+ "skill.started": {"skill_name": True, "message": True},
+ "skill.completed": {"skill_name": True, "result_preview": True},
+ "skill.failed": {"skill_name": True, "error": True},
+ "artifact.created": {"artifact_type": True, "title": True},
+ "approval.required": {"tool_name": True, "summary": True},
+ "approval.decided": {"decided_by": True},
+ "run.status": {"checkpoint_id": True, "reason": True, "status": True},
+ "run.error": {"error_code": True, "message": True},
+ "run.cancelled": {},
+ "run.completed": {"status": True},
+ "manager.dispatch": {"next_speaker": True, "reason": True},
+ "manager.review.decision": {"decision": True, "reason": True, "quality": True},
+ "fact.confirmed": {"fact_id": True, "summary": True},
+ "review.completed": {"summary": True, "decision": True},
+ "seat.delivery.submitted": {"content": True, "schema_status": True},
+ }.get(mapped_type, {})
+ data: dict[str, Any] = {
+ key: payload[key]
+ for key in safe_keys
+ if key in payload and safe_keys[key]
+ }
+ if event.get("agent_id"):
+ data["agent_id"] = event["agent_id"]
+ if event.get("turn_index") is not None:
+ data["turn_index"] = event["turn_index"]
+
+ # P0-4: use the source audit event's stable id for dedup.
+ source_evt_id = event.get("id") or event.get("source_event_id")
+
+ await self._publish_conversation_event(
+ conversation_id=conversation_id,
+ owner_user_id=owner_user_id,
+ event_type=mapped_type,
+ event_data=data,
+ source_event_id=source_evt_id,
+ )
+ if source_type == "agent.message.completed":
+ # A completed structured seat turn is also a delivery milestone;
+ # this gives the timeline a stable gate separate from raw text.
+ delivery_data = {
+ key: payload[key]
+ for key in ("content", "schema_status")
+ if key in payload
+ }
+ if event.get("agent_id"):
+ delivery_data["agent_id"] = event["agent_id"]
+ await self._publish_conversation_event(
+ conversation_id=conversation_id,
+ owner_user_id=owner_user_id,
+ event_type="seat.delivery.submitted",
+ event_data=delivery_data,
+ source_event_id=f"{source_evt_id}:delivery" if source_evt_id else None,
+ )
+
+ async def _dispatch_run(self, *, conversation_id: str, user_id: str, run: dict[str, Any]) -> None:
+ if self._dispatcher is None:
+ # R1 unit callers can still exercise persistence without starting
+ # the optional SDK runtime. The production dependency wiring always
+ # supplies a dispatcher.
+ return
+ session = await self._store.sessions.get_session(run["session_id"], user_id=user_id)
+ if session is None:
+ raise ConversationDispatchError("internal MAF session could not be loaded")
+ try:
+ await self._dispatcher.dispatch(session, run, user_id=user_id)
+ except Exception as exc: # noqa: BLE001 - reconcile durable state
+ logger.exception("MAF dispatch failed run=%s", run["id"])
+ await self._mark_dispatch_failed(
+ conversation_id=conversation_id,
+ user_id=user_id,
+ run_id=run["id"],
+ error_code=type(exc).__name__[:64],
+ )
+ raise ConversationRuntimeUnavailableError() from exc
+
+ wait_for_run = getattr(self._dispatcher, "wait_for_run", None)
+ if callable(wait_for_run):
+ watcher = asyncio.create_task(
+ self._watch_run(
+ conversation_id=conversation_id,
+ user_id=user_id,
+ run_id=run["id"],
+ wait_for_run=wait_for_run,
+ )
+ )
+ self._watchers[run["id"]] = watcher
+ watcher.add_done_callback(lambda _task, rid=run["id"]: self._watchers.pop(rid, None))
+
+ async def _mark_dispatch_failed(
+ self, *, conversation_id: str, user_id: str, run_id: str, error_code: str
+ ) -> None:
+ await self._store.runs.update_run(run_id, {
+ "status": "failed",
+ "finished_at": datetime.now(UTC),
+ "error_code": error_code,
+ "error_message_safe": "MAF workflow dispatch failed",
+ })
+ run = await self._store.runs.get_run(run_id)
+ if run is not None:
+ await self._store.sessions.update_session(
+ run["session_id"], user_id=user_id, fields={"status": "failed"}
+ )
+ await self._store.conversations.set_active_run(conversation_id, None, "failed")
+ await self._publish_conversation_event(
+ conversation_id=conversation_id,
+ owner_user_id=user_id,
+ event_type="run.error",
+ event_data={"status": "failed", "error_code": error_code},
+ )
+
+ async def _watch_run(
+ self,
+ *,
+ conversation_id: str,
+ user_id: str,
+ run_id: str,
+ wait_for_run: Any,
+ ) -> None:
+ try:
+ await wait_for_run(run_id)
+ except asyncio.CancelledError:
+ raise
+ except Exception:
+ logger.exception("MAF conversation watcher failed run=%s", run_id)
+ return
+
+ run = await self._store.runs.get_run(run_id)
+ if run is None:
+ return
+ status_value = str(run.get("status") or "failed")
+ terminal = status_value in {"completed", "failed", "cancelled"}
+ await self._store.conversations.set_active_run(
+ conversation_id,
+ None if terminal else run_id,
+ status_value,
+ )
+ event_type = {
+ "completed": "run.completed",
+ "failed": "run.error",
+ "cancelled": "run.cancelled",
+ }.get(status_value, "run.status")
+ data: dict[str, Any] = {"status": status_value}
+ if run.get("error_code"):
+ data["error_code"] = run["error_code"]
+ await self._publish_conversation_event(
+ conversation_id=conversation_id,
+ owner_user_id=user_id,
+ event_type=event_type,
+ event_data=data,
+ )
+
+ async def _get_conv_row(self, conversation_id: str) -> MafRoundtableConversationRow | None:
+ from sqlalchemy import select
+ async with self._store.session_factory() as session:
+ row = (
+ await session.execute(
+ select(MafRoundtableConversationRow).where(
+ MafRoundtableConversationRow.id == conversation_id
+ )
+ )
+ ).scalars().first()
+ return row
+
+ async def _get_msg_row(self, message_id: str) -> MafRoundtableMessageRow | None:
+ from sqlalchemy import select
+ async with self._store.session_factory() as session:
+ row = (
+ await session.execute(
+ select(MafRoundtableMessageRow).where(
+ MafRoundtableMessageRow.id == message_id
+ )
+ )
+ ).scalars().first()
+ return row
diff --git a/offline-backend-20260512/backend/app/maf_roundtable/deerflow_agent_adapter.py b/offline-backend-20260512/backend/app/maf_roundtable/deerflow_agent_adapter.py
index bec3cac8..b5c8c748 100644
--- a/offline-backend-20260512/backend/app/maf_roundtable/deerflow_agent_adapter.py
+++ b/offline-backend-20260512/backend/app/maf_roundtable/deerflow_agent_adapter.py
@@ -488,6 +488,7 @@ class DeerFlowAgentAdapter:
raise AuditEventError(
f"Critical audit event '{event.get('event_type')}' failed to persist: {exc}"
) from exc
+ await self._bridge_conversation_event(run_id, event)
async def _emit(self, run_id: str, event: dict[str, Any]) -> None:
"""Emit a non-critical event (agent.message.delta). Failure degrades gracefully.
@@ -518,6 +519,42 @@ class DeerFlowAgentAdapter:
)
except Exception:
logger.warning("non-critical event %s failed for run %s (degraded)", event_type, run_id, exc_info=True)
+ return
+ await self._bridge_conversation_event(run_id, event)
+
+ async def _bridge_conversation_event(self, run_id: str, event: dict[str, Any]) -> None:
+ """Best-effort projection into the V2 conversation event stream.
+
+ The per-run audit write above remains authoritative. A projection
+ failure must not cause a tool to execute twice or turn a completed
+ seat run into a false runtime failure; reconnect/recovery can rebuild
+ the conversation view from the audit ledger later.
+
+ P0-3: the entire ``app.state`` resolution is best-effort. If any link
+ is missing (no app, no state, no conversation service), the projection
+ is silently skipped — audit, recovery, cancel, lease, and tool
+ execution safety are never affected.
+ """
+ try:
+ app = getattr(self._client, "app", None)
+ state = getattr(app, "state", None)
+ conversation_service = getattr(state, "maf_conversation_service", None)
+ except Exception: # noqa: BLE001
+ return
+ if conversation_service is None:
+ return
+ bridge = getattr(conversation_service, "bridge_workflow_event", None)
+ if not callable(bridge):
+ return
+ try:
+ await bridge(run_id=run_id, event=event)
+ except Exception: # noqa: BLE001 - audit already persisted
+ logger.warning(
+ "conversation event projection failed for run %s (%s)",
+ run_id,
+ event.get("event_type"),
+ exc_info=True,
+ )
# ── internal helpers ────────────────────────────────────────────────────
diff --git a/offline-backend-20260512/backend/app/maf_roundtable/dispatch_contracts.py b/offline-backend-20260512/backend/app/maf_roundtable/dispatch_contracts.py
new file mode 100644
index 00000000..b9caaed5
--- /dev/null
+++ b/offline-backend-20260512/backend/app/maf_roundtable/dispatch_contracts.py
@@ -0,0 +1,268 @@
+"""Deterministic, server-owned contracts for MAF Stage 3 dispatch.
+
+The manager may describe work, but it does not get to choose arbitrary agent
+ids, threads, tools, or capabilities. Routing is resolved from the trusted
+MAF deployment configuration and the small, validated task-context allowlist.
+This module is intentionally independent of the MAF SDK so the policy can be
+tested with a weak/fake model and before any workflow is launched.
+"""
+
+from __future__ import annotations
+
+from typing import Any, Literal
+
+from pydantic import BaseModel, ConfigDict, Field, model_validator
+
+from deerflow.config.maf_roundtable_config import MafMode
+
+MafIntentType = Literal[
+ "direct_answer",
+ "clarification",
+ "single_seat",
+ "parallel_consultation",
+ "chain_consultation",
+ "tool_write",
+]
+
+
+class DispatchConfigurationError(RuntimeError):
+ """The deployment has not supplied a complete trusted route."""
+
+
+class InvalidDispatchPlanError(ValueError):
+ """A proposed or constructed plan violates the dispatch contract."""
+
+
+class MafIntentDecision(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ intent_type: MafIntentType
+ confidence: float = Field(ge=0.0, le=1.0)
+ reason_code: str = Field(min_length=1, max_length=64)
+ clarification_prompt: str | None = Field(default=None, max_length=1000)
+
+ @model_validator(mode="after")
+ def _clarification_shape(self) -> MafIntentDecision:
+ if self.intent_type == "clarification" and not self.clarification_prompt:
+ raise ValueError("clarification intent requires clarification_prompt")
+ if self.intent_type != "clarification" and self.clarification_prompt is not None:
+ raise ValueError("only clarification intent may include clarification_prompt")
+ return self
+
+
+class MafDispatchAssignment(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ agent_id: str = Field(min_length=1, max_length=64)
+ role: Literal["seat"] = "seat"
+ sort_order: int = Field(ge=1, le=64)
+
+
+class MafDispatchPlan(BaseModel):
+ """Validated plan containing only server-selected participants."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ manager_agent_id: str = Field(min_length=1, max_length=64)
+ seat_agent_ids: list[str] = Field(default_factory=list, max_length=64)
+ assignments: list[MafDispatchAssignment] = Field(default_factory=list, max_length=64)
+ mode: MafMode = "structured"
+ intent_type: MafIntentType
+ business_code: str | None = Field(default=None, max_length=255)
+ max_rounds: int = Field(ge=1, le=64)
+
+ @model_validator(mode="after")
+ def _validate_shape(self) -> MafDispatchPlan:
+ if self.manager_agent_id in self.seat_agent_ids:
+ raise ValueError("manager agent cannot also be a seat")
+ if len(set(self.seat_agent_ids)) != len(self.seat_agent_ids):
+ raise ValueError("duplicate seat agent")
+ assignment_ids = [assignment.agent_id for assignment in self.assignments]
+ if assignment_ids != self.seat_agent_ids:
+ raise ValueError("assignments must exactly match seat_agent_ids")
+ if [a.sort_order for a in self.assignments] != list(range(1, len(self.assignments) + 1)):
+ raise ValueError("assignments must have contiguous sort_order")
+ if self.intent_type == "clarification":
+ raise ValueError("clarification cannot be dispatched")
+ if self.intent_type != "direct_answer" and not self.seat_agent_ids:
+ raise ValueError("this intent requires at least one configured seat")
+ return self
+
+
+def resolve_intent(content: str, task_context: dict[str, Any] | None = None) -> MafIntentDecision:
+ """Resolve the common Stage 3 intents deterministically.
+
+ A production model can later supply a *description* for the intent, but
+ this policy remains the final validator. The fallback deliberately uses
+ conservative keywords and never extracts agent/tool ids from user text.
+ """
+
+ text = " ".join(str(content or "").strip().split())
+ lowered = text.casefold()
+ if len(text) < 4 or lowered in {"?", "?", "不清楚", "不明确", "再说清楚"}:
+ return MafIntentDecision(
+ intent_type="clarification",
+ confidence=0.99,
+ reason_code="insufficient_task_detail",
+ clarification_prompt="请补充希望解决的具体问题、目标和必要的业务背景。",
+ )
+
+ write_markers = (
+ "写入", "发布", "创建工单", "更新任务", "提交", "删除", "write", "publish", "create ticket",
+ )
+ chain_markers = ("业务链", "链式", "逐阶段", "先…再", "先...再", "chain")
+ parallel_markers = ("多专家", "多角度", "并行", "同时分析", "比较", "parallel", "multi-agent")
+ single_markers = ("请某个专家", "单个席位", "单专家", "single seat", "指定专家")
+
+ if any(marker in lowered for marker in write_markers):
+ intent_type: MafIntentType = "tool_write"
+ reason_code = "write_request"
+ elif any(marker in lowered for marker in chain_markers):
+ intent_type = "chain_consultation"
+ reason_code = "chain_request"
+ elif any(marker in lowered for marker in parallel_markers):
+ intent_type = "parallel_consultation"
+ reason_code = "parallel_request"
+ elif any(marker in lowered for marker in single_markers):
+ intent_type = "single_seat"
+ reason_code = "single_seat_request"
+ else:
+ intent_type = "direct_answer"
+ reason_code = "default_direct_answer"
+
+ return MafIntentDecision(
+ intent_type=intent_type,
+ confidence=0.72 if task_context else 0.68,
+ reason_code=reason_code,
+ )
+
+
+def _read(value: Any, name: str, default: Any = None) -> Any:
+ if isinstance(value, dict):
+ return value.get(name, default)
+ return getattr(value, name, default)
+
+
+class MafDispatchCatalog:
+ """Resolve routes from deployment config, never from a request body."""
+
+ def __init__(self, maf_config: Any) -> None:
+ self._config = maf_config
+
+ def _route(self, business_code: str | None) -> tuple[str | None, list[str], MafMode | None]:
+ routes = _read(self._config, "business_routes", {}) or {}
+ if business_code:
+ route = routes.get(business_code)
+ if route is None:
+ # A supplied business code must match an explicitly configured
+ # route. Falling back here would silently cross business
+ # boundaries.
+ return None, [], None
+ return (
+ _read(route, "manager_agent_id"),
+ list(_read(route, "seat_agent_ids", []) or []),
+ _read(route, "mode"),
+ )
+ return (
+ _read(self._config, "default_manager_agent_id"),
+ list(_read(self._config, "default_seat_agent_ids", []) or []),
+ None,
+ )
+
+ def build_plan(
+ self,
+ *,
+ intent: MafIntentDecision,
+ task_context: dict[str, Any] | None,
+ ) -> MafDispatchPlan:
+ business_code = str((task_context or {}).get("business_code") or "").strip() or None
+ manager_id, route_seats, route_mode = self._route(business_code)
+ if not manager_id:
+ raise DispatchConfigurationError(
+ "no trusted MAF manager route is configured for this conversation"
+ )
+
+ # The server owns the route. Intent only changes how many configured
+ # seats participate; it never adds an id.
+ if intent.intent_type in {"single_seat", "tool_write", "direct_answer"}:
+ seat_ids = route_seats[:1]
+ else:
+ seat_ids = route_seats[:]
+
+ max_seats = int(_read(self._config, "max_seats_per_run", 16) or 16)
+ if len(seat_ids) > max_seats:
+ raise DispatchConfigurationError("configured MAF route exceeds max_seats_per_run")
+
+ try:
+ plan = MafDispatchPlan(
+ manager_agent_id=str(manager_id).strip(),
+ seat_agent_ids=[str(agent_id).strip() for agent_id in seat_ids],
+ assignments=[
+ MafDispatchAssignment(agent_id=str(agent_id).strip(), sort_order=index)
+ for index, agent_id in enumerate(seat_ids, start=1)
+ ],
+ mode=route_mode or _read(self._config, "default_mode", "structured"),
+ intent_type=intent.intent_type,
+ business_code=business_code,
+ max_rounds=int(_read(self._config, "default_max_rounds", 8) or 8),
+ )
+ except (TypeError, ValueError) as exc:
+ raise DispatchConfigurationError("invalid trusted MAF dispatch route") from exc
+
+ trusted_ids = {str(manager_id), *(str(agent_id) for agent_id in route_seats)}
+ validate_dispatch_plan(plan, trusted_agent_ids=trusted_ids, max_seats=max_seats)
+ return plan
+
+ @staticmethod
+ def participants(plan: MafDispatchPlan) -> list[dict[str, Any]]:
+ """Return persistence records; thread ids are intentionally absent."""
+ return [
+ {
+ "agent_id": plan.manager_agent_id,
+ "role": "manager",
+ "sort_order": 0,
+ "display_name": "",
+ },
+ *[
+ {
+ "agent_id": assignment.agent_id,
+ "role": "seat",
+ "sort_order": assignment.sort_order,
+ "display_name": "",
+ }
+ for assignment in plan.assignments
+ ],
+ ]
+
+
+def validate_dispatch_plan(
+ plan: MafDispatchPlan,
+ *,
+ trusted_agent_ids: set[str],
+ max_seats: int,
+) -> None:
+ """Final allowlist check used before persistence and workflow launch."""
+
+ if plan.manager_agent_id not in trusted_agent_ids:
+ raise InvalidDispatchPlanError("manager agent is outside the trusted route")
+ if len(plan.seat_agent_ids) > max_seats:
+ raise InvalidDispatchPlanError("dispatch plan exceeds the seat limit")
+ if any(agent_id not in trusted_agent_ids for agent_id in plan.seat_agent_ids):
+ raise InvalidDispatchPlanError("seat agent is outside the trusted route")
+ if len(set(plan.seat_agent_ids)) != len(plan.seat_agent_ids):
+ raise InvalidDispatchPlanError("dispatch plan contains duplicate seats")
+ if plan.manager_agent_id in plan.seat_agent_ids:
+ raise InvalidDispatchPlanError("manager cannot be dispatched as a seat")
+
+
+__all__ = [
+ "DispatchConfigurationError",
+ "InvalidDispatchPlanError",
+ "MafDispatchAssignment",
+ "MafDispatchPlan",
+ "MafDispatchCatalog",
+ "MafIntentDecision",
+ "MafIntentType",
+ "resolve_intent",
+ "validate_dispatch_plan",
+]
diff --git a/offline-backend-20260512/backend/app/maf_roundtable/dispatcher.py b/offline-backend-20260512/backend/app/maf_roundtable/dispatcher.py
index 1fa5b8f9..32fcd4f7 100644
--- a/offline-backend-20260512/backend/app/maf_roundtable/dispatcher.py
+++ b/offline-backend-20260512/backend/app/maf_roundtable/dispatcher.py
@@ -71,6 +71,23 @@ class MafRunDispatcher:
self._tasks[run_id] = task
task.add_done_callback(lambda t, rid=run_id: self._tasks.pop(rid, None))
+ async def wait_for_run(self, run_id: str) -> None:
+ """Wait for an in-process run without exposing its task to callers.
+
+ Conversation V2 uses this only for a server-side status bridge. The
+ persisted run remains authoritative, so a missing task (for example
+ after a worker restart) simply returns and lets recovery own the run.
+ """
+ task = self._tasks.get(run_id)
+ if task is None:
+ return
+ try:
+ await asyncio.shield(task)
+ except asyncio.CancelledError:
+ raise
+ except Exception: # noqa: BLE001 - runner already persisted failure
+ return
+
async def _execute(self, runner: Any, session: dict[str, Any], run: dict[str, Any], run_id: str) -> None:
"""Execute the workflow, catching all exceptions (they're already persisted by the runner)."""
try:
diff --git a/offline-backend-20260512/backend/app/maf_roundtable/schemas.py b/offline-backend-20260512/backend/app/maf_roundtable/schemas.py
index 4c014f02..d51f9d35 100644
--- a/offline-backend-20260512/backend/app/maf_roundtable/schemas.py
+++ b/offline-backend-20260512/backend/app/maf_roundtable/schemas.py
@@ -245,6 +245,12 @@ class HealthResponse(BaseModel):
"""Health / feature-flag probe. The frontend reads ``enabled`` to decide
whether to show the MAF entry. ``store_available`` is false when no DB is
configured (memory mode) — the router then returns 503 on writes.
+
+ SSE fields (P1): ``sse_hub_type`` reports the hub implementation
+ (``in_memory`` or future ``redis``); ``sse_multi_worker`` is ``False``
+ until Redis Pub/Sub is wired; ``sse_warning`` carries a human-readable
+ advisory. When multi-worker is unsupported, production deployments must
+ use a single worker or fail-closed.
"""
enabled: bool
@@ -257,3 +263,6 @@ class HealthResponse(BaseModel):
write_guard_ready: bool
action_secret_configured: bool
write_guard_status: str
+ sse_hub_type: str = "in_memory"
+ sse_multi_worker: bool = False
+ sse_warning: str = "Single-worker only. Multi-worker requires Redis Pub/Sub."
diff --git a/offline-backend-20260512/backend/app/maf_roundtable/workflow_executors.py b/offline-backend-20260512/backend/app/maf_roundtable/workflow_executors.py
index 01b2f7cc..b1fd6df9 100644
--- a/offline-backend-20260512/backend/app/maf_roundtable/workflow_executors.py
+++ b/offline-backend-20260512/backend/app/maf_roundtable/workflow_executors.py
@@ -74,6 +74,8 @@ class RoundtableState:
capability_snapshot: dict[str, Any] | None = None,
seat_timeout_seconds: float = 600.0,
schema_repair_enabled: bool = True,
+ intent_type: str | None = None,
+ dispatch_plan: dict[str, Any] | None = None,
) -> None:
self.session_id = session_id
self.run_id = run_id
@@ -89,6 +91,11 @@ class RoundtableState:
self.capability_snapshot = capability_snapshot
self.seat_timeout_seconds = seat_timeout_seconds
self.schema_repair_enabled = schema_repair_enabled
+ # Stage 3 server-validated routing metadata. The workflow may use
+ # this for diagnostics/checkpoints, but it never accepts a new agent
+ # id from manager model output.
+ self.intent_type = intent_type
+ self.dispatch_plan = dispatch_plan
self.current_round = 0
self.turn_results: list[dict[str, Any]] = []
@@ -128,6 +135,8 @@ class RoundtableState:
"capability_snapshot": self.capability_snapshot,
"seat_timeout_seconds": self.seat_timeout_seconds,
"schema_repair_enabled": self.schema_repair_enabled,
+ "intent_type": self.intent_type,
+ "dispatch_plan": self.dispatch_plan,
"current_round": self.current_round,
"turn_results": self.turn_results,
"fact_ledger": self.fact_ledger,
@@ -159,6 +168,8 @@ class RoundtableState:
capability_snapshot=d.get("capability_snapshot"),
seat_timeout_seconds=float(d.get("seat_timeout_seconds") or 600.0),
schema_repair_enabled=bool(d.get("schema_repair_enabled", True)),
+ intent_type=d.get("intent_type"),
+ dispatch_plan=d.get("dispatch_plan"),
)
state.current_round = d.get("current_round", 0)
state.turn_results = d.get("turn_results", [])
@@ -531,12 +542,20 @@ class SeatExecutor(Executor):
a for a in turn_actions
if a.get("tool_name") == tc_name and not a.get("logical_action_id")
]
- if len(hits) != 1:
+ if len(hits) > 1:
raise RuntimeError(
f"seat {self._agent_id} turn {self._state.current_round}: "
f"pending tool call '{tc_name}' (id={tc_id or ''}) matched "
f"{len(hits)} waiting actions (expected exactly 1) — refusing to guess"
)
+ if len(hits) == 0:
+ logger.warning(
+ "seat %s turn %s: pending tool call '%s' (id=%s) classified as "
+ "needing approval but matched 0 waiting actions — tool may be "
+ "unclassified or executed as read by WriteGuard; skipping",
+ self._agent_id, self._state.current_round, tc_name, tc_id or "",
+ )
+ continue
matched.append(hits[0])
# Merge into pending_approvals (multi-seat).
@@ -686,12 +705,45 @@ class CrossReviewExecutor(Executor):
if failed_seats:
review["gaps"] = [f"席位失败: {', '.join(failed_seats)}"]
+ # The quality gate is server-owned. A manager may recommend acceptance,
+ # but gaps/failures/schema degradation always become a controlled gap
+ # and are carried into the final result instead of being labelled as a
+ # verified fact.
+ has_quality_gap = bool(
+ review.get("partial_failure")
+ or review.get("failed_seats")
+ or review.get("gaps")
+ or review.get("conflicts")
+ or review.get("tool_failures")
+ or review.get("schema_status") == "schema_invalid"
+ )
+ review["decision"] = "controlled_gap" if has_quality_gap else "accept"
+ review["reason"] = (
+ "required evidence or seat output is incomplete"
+ if has_quality_gap
+ else "required seat deliveries passed deterministic checks"
+ )
self._state.cross_review_result = review
# Surface unresolved conflicts from review notes.
for c in review.get("conflicts") or []:
if c and c not in self._state.fact_ledger["conflicts"]:
self._state.fact_ledger["conflicts"].append(c)
+ await self._adapter._emit_critical(self._state.run_id, {
+ "event_type": "manager.review.decision",
+ "agent_id": self._state.manager_agent_id or "",
+ "turn_index": self._state.current_round,
+ "payload": {
+ "decision": review["decision"],
+ "reason": review["reason"],
+ "quality": {
+ "failed_seat_count": len(review.get("failed_seats") or []),
+ "gap_count": len(review.get("gaps") or []),
+ "conflict_count": len(review.get("conflicts") or []),
+ },
+ },
+ })
+
self._state.mark_completed("cross_review")
self._state.stage = "approval"
await ctx.send_message({"decision": "reviewed", "review": review})
@@ -937,16 +989,13 @@ class ApprovalGateExecutor(Executor):
def _check_needs_approval(self) -> bool:
if self._state.write_policy == "auto":
return False
- if self._state.pending_approvals:
- return True
- for result in self._state.turn_results:
- for tc in result.get("tool_calls") or []:
- name = tc.get("name", "")
- if not name:
- continue
- if tool_needs_approval(name, self._state.write_policy, self._state.capability_snapshot):
- return True
- return False
+ # pending_approvals is the authoritative source: it is populated by
+ # SeatExecutor._capture_pending_approvals which only adds tool calls
+ # that WriteGuard actually intercepted (i.e. have a database action).
+ # Tools that were classified as needing approval but had 0 matching
+ # actions (unclassified/hallucinated/read tools) are intentionally
+ # skipped and must NOT re-trigger the gate here.
+ return bool(self._state.pending_approvals)
# ── Finalizer ───────────────────────────────────────────────────────────────
@@ -1006,6 +1055,10 @@ async def generate_final_report(adapter: Any, state: RoundtableState) -> dict[st
"agent_id": manager_id,
"partial_failure": state.partial_failure,
"degraded": state.partial_failure,
+ "quality_decision": (
+ (state.cross_review_result or {}).get("decision")
+ or ("controlled_gap" if state.partial_failure else "accept")
+ ),
}
else:
state.final_result = {
@@ -1014,6 +1067,10 @@ async def generate_final_report(adapter: Any, state: RoundtableState) -> dict[st
"note": "no_manager_thread",
"partial_failure": state.partial_failure,
"degraded": state.partial_failure,
+ "quality_decision": (
+ (state.cross_review_result or {}).get("decision")
+ or ("controlled_gap" if state.partial_failure else "accept")
+ ),
}
await adapter._emit_critical(state.run_id, make_run_completed(manager_id, state.current_round + 1, {
diff --git a/offline-backend-20260512/backend/app/maf_roundtable/workflow_runner.py b/offline-backend-20260512/backend/app/maf_roundtable/workflow_runner.py
index a6dd1149..7a394833 100644
--- a/offline-backend-20260512/backend/app/maf_roundtable/workflow_runner.py
+++ b/offline-backend-20260512/backend/app/maf_roundtable/workflow_runner.py
@@ -96,6 +96,9 @@ class WorkflowRunner:
seat_agents = [p["agent_id"] for p in participants if p.get("role") != "manager"]
manager_id = session.get("manager_agent_id")
seat_timeout, schema_repair = self._orchestration_settings()
+ task_payload = session.get("task_payload") or {}
+ maf_v2_payload = task_payload.get("maf_v2") if isinstance(task_payload, dict) else {}
+ maf_v2_payload = maf_v2_payload if isinstance(maf_v2_payload, dict) else {}
state = RoundtableState(
session_id=session_id,
@@ -112,6 +115,8 @@ class WorkflowRunner:
capability_snapshot=session.get("capability_snapshot"),
seat_timeout_seconds=seat_timeout,
schema_repair_enabled=schema_repair,
+ intent_type=maf_v2_payload.get("intent_type"),
+ dispatch_plan=maf_v2_payload.get("dispatch_plan"),
)
workflow = self._build_workflow(state)
diff --git a/offline-backend-20260512/backend/config.yaml b/offline-backend-20260512/backend/config.yaml
index cb4a3e81..1af343cc 100644
--- a/offline-backend-20260512/backend/config.yaml
+++ b/offline-backend-20260512/backend/config.yaml
@@ -562,6 +562,12 @@ maf_roundtable:
default_mode: structured # structured(固定流程)| group_chat(动态群聊)
default_write_policy: confirm_high_risk # auto | confirm_high_risk | confirm_all_writes
default_max_rounds: 8 # 群聊模式轮次硬上限
+ # Stage 3 routing is deployment-owned. Keep these empty in the repository;
+ # production must inject exact agent ids from its catalog.
+ default_manager_agent_id: null
+ default_seat_agent_ids: []
+ business_routes: {}
+ max_seats_per_run: 16
admin_diagnostic_access: true # 管理员是否可跨用户读取所有 MAF 会商用于诊断(仅读,不含写)
seat_timeout_seconds: 600 # 结构化流程单席位超时(秒)
approval_scan_interval_seconds: 30 # 审批主动过期扫描间隔(秒)
diff --git a/offline-backend-20260512/backend/docs/maf-sse-multi-worker-deployment.md b/offline-backend-20260512/backend/docs/maf-sse-multi-worker-deployment.md
new file mode 100644
index 00000000..09cccdeb
--- /dev/null
+++ b/offline-backend-20260512/backend/docs/maf-sse-multi-worker-deployment.md
@@ -0,0 +1,112 @@
+# MAF 会商 SSE 多 Worker 部署说明
+
+> 状态:Phase 2 (R2) — 单 Worker 已就绪,多 Worker 需补 Redis Pub/Sub
+> 更新时间:2026-08-03
+
+---
+
+## 1. 当前架构(单 Worker)
+
+`ConversationEventHub`(`app/maf_roundtable/conversation_event_hub.py`)是一个
+**进程内** 的 `asyncio.Event` 通知器:
+
+- 每个 worker 进程有自己独立的 hub 实例(`app.state.maf_conversation_event_hub`)
+- 事件写入者(`ConversationService.send_message`)在事务提交后调用 `hub.publish()`
+- SSE 订阅者通过 `hub.subscribe()` 注册一个 `asyncio.Event`,被 `publish()` 唤醒后
+ 从数据库查询最新事件(通知-only,不缓存事件内容)
+- 心跳:15 秒超时 → `: heartbeat\n\n` SSE 注释帧
+
+**适用场景:** `uvicorn --workers 1` / `gunicorn -w 1`,开发环境、小规模灰度。
+
+---
+
+## 2. 多 Worker 限制
+
+当使用 `--workers 2` 或更多 worker 时:
+
+| 问题 | 原因 | 影响 |
+|------|------|------|
+| **跨 Worker 不通知** | Worker A 写入事件后 `hub.publish()` 只通知 A 自己的订阅者;Worker B 的 SSE 客户端收不到实时推送 | SSE 流"卡住"直到客户端超时重连,重连后从 DB 重放才能补到事件 |
+| **无跨进程共享状态** | `asyncio.Event` 是进程内对象,不能跨进程通信 | 需要 Redis Pub/Sub 或等价中间件 |
+
+**在接入 Redis Pub/Sub 之前,禁止多 Worker 灰度上线。**
+
+---
+
+## 3. 健康检查建议
+
+建议在 `GET /api/maf-roundtable/health` 中增加:
+
+```json
+{
+ "sse_hub_type": "in_memory",
+ "sse_multi_worker": false,
+ "sse_warning": "Single-worker only. Multi-worker requires Redis Pub/Sub."
+}
+```
+
+当前版本已通过 `sse_multi_worker: false` 明确标注不支持多 Worker。
+部署脚本应检查此项,在多 Worker 模式下发出告警。
+
+---
+
+## 4. Redis Pub/Sub 接入方案(后续阶段)
+
+当需要多 Worker 时,替换 `ConversationEventHub` 为 Redis-backed 实现:
+
+### 4.1 接口不变
+
+```python
+class ConversationEventHub(abc.ABC):
+ async def publish(self, conversation_id: str) -> None: ...
+ async def subscribe(self, conversation_id: str, ...) -> AsyncIterator[bool]: ...
+```
+
+### 4.2 Redis 实现
+
+```python
+class RedisConversationEventHub(ConversationEventHub):
+ def __init__(self, redis: Redis, *, heartbeat_interval: float = 15.0):
+ self._redis = redis
+ self._heartbeat_interval = heartbeat_interval
+
+ async def publish(self, conversation_id: str) -> None:
+ await self._redis.publish(f"maf:conv:{conversation_id}", "1")
+
+ async def subscribe(self, conversation_id: str, ...):
+ pubsub = self._redis.pubsub()
+ await pubsub.subscribe(f"maf:conv:{conversation_id}")
+ try:
+ while True:
+ msg = await asyncio.wait_for(
+ pubsub.get_message(ignore_subscribe_messages=True, timeout=...),
+ timeout=self._heartbeat_interval,
+ )
+ if msg is None:
+ yield False # heartbeat
+ else:
+ yield True # new events might be available
+ finally:
+ await pubsub.unsubscribe(f"maf:conv:{conversation_id}")
+```
+
+### 4.3 选择逻辑
+
+```python
+# deps.py
+if config.database and config.database.redis_url:
+ hub = RedisConversationEventHub(create_redis(config.database.redis_url))
+else:
+ hub = ConversationEventHub() # in-memory, single-worker
+```
+
+---
+
+## 5. 部署检查清单
+
+- [ ] 确认 `uvicorn --workers 1`(或等价单进程配置)
+- [ ] 确认 SSE 端点 `GET /api/maf-roundtable/conversations/{id}/stream` 可访问
+- [ ] 确认 nginx / 反向代理未缓冲 SSE(`X-Accel-Buffering: no` 生效)
+- [ ] 确认心跳间隔 15s 足够长不会误触发代理超时(proxy_read_timeout 应 ≥ 60s)
+- [ ] 确认 `after_seq` 重连不产生重复气泡(前端 reducer 按 seq 去重)
+- [ ] 多 Worker 上线前:接入 Redis Pub/Sub 并更新 `sse_multi_worker: true`
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/maf_roundtable_config.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/maf_roundtable_config.py
index 9bdfd551..00530708 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/config/maf_roundtable_config.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/maf_roundtable_config.py
@@ -87,6 +87,32 @@ class MafToolMetadata(BaseModel):
return namespace
+class MafBusinessRoute(BaseModel):
+ """Server-owned route for one MAF business chain.
+
+ The browser and the model never submit these agent ids. Operators must
+ configure the exact ids discovered in the deployment's agent catalog.
+ Keeping the route in the MAF config also makes a session reproducible: the
+ selected route is frozen into its trusted capability snapshot at launch.
+ """
+
+ manager_agent_id: str = Field(min_length=1, max_length=64)
+ seat_agent_ids: list[str] = Field(default_factory=list, max_length=64)
+ mode: MafMode | None = None
+
+ @field_validator("seat_agent_ids")
+ @classmethod
+ def _validate_seat_agent_ids(cls, ids: list[str]) -> list[str]:
+ normalized: list[str] = []
+ for agent_id in ids:
+ value = str(agent_id).strip()
+ if not value or len(value) > 64:
+ raise ValueError("seat_agent_ids must contain non-empty agent ids")
+ if value not in normalized:
+ normalized.append(value)
+ return normalized
+
+
class MafRoundtableConfig(BaseModel):
"""Microsoft Agent Framework 多智能体会商实验功能开关。
@@ -116,6 +142,13 @@ class MafRoundtableConfig(BaseModel):
le=64,
description="默认最大轮次上限(群聊模式硬上限,避免弱模型无限讨论)",
)
+ # Stage 3: server-owned routing. These remain empty in the repository;
+ # production deployments must inject exact agent ids in their deployment
+ # configuration instead of guessing from names or client input.
+ default_manager_agent_id: str | None = Field(default=None, max_length=64)
+ default_seat_agent_ids: list[str] = Field(default_factory=list, max_length=64)
+ business_routes: dict[str, MafBusinessRoute] = Field(default_factory=dict)
+ max_seats_per_run: int = Field(default=16, ge=1, le=64)
# 管理员诊断是否可见所有用户的会商(true=管理员跨用户读,false=管理员也只能看自己的)。
admin_diagnostic_access: bool = Field(
default=True,
@@ -162,6 +195,7 @@ class MafRoundtableConfig(BaseModel):
__all__ = [
"MafMode",
+ "MafBusinessRoute",
"MafParticipantRole",
"MafRiskLevel",
"MafRoundtableConfig",
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/__init__.py
index 95d4205e..26555235 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/__init__.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/__init__.py
@@ -31,13 +31,19 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.maf_roundtable.action_secrets import MafActionSecretRepository
from deerflow.persistence.maf_roundtable.actions import MafActionRepository
from deerflow.persistence.maf_roundtable.approvals import MafApprovalRepository
+from deerflow.persistence.maf_roundtable.conversation_events import MafConversationEventRepository
+from deerflow.persistence.maf_roundtable.conversations import MafConversationRepository
from deerflow.persistence.maf_roundtable.events import MafEventRepository
+from deerflow.persistence.maf_roundtable.messages import MafMessageRepository
from deerflow.persistence.maf_roundtable.model import (
MafRoundtableActionLeaseRow,
MafRoundtableActionRow,
MafRoundtableActionSecretRow,
MafRoundtableApprovalRow,
+ MafRoundtableConversationEventRow,
+ MafRoundtableConversationRow,
MafRoundtableEventRow,
+ MafRoundtableMessageRow,
MafRoundtableParticipantRow,
MafRoundtableRunRow,
MafRoundtableSeatRunRow,
@@ -53,13 +59,19 @@ __all__ = [
"MafActionRepository",
"MafActionSecretRepository",
"MafApprovalRepository",
+ "MafConversationEventRepository",
+ "MafConversationRepository",
"MafEventRepository",
+ "MafMessageRepository",
"MafParticipantRepository",
"MafRoundtableActionRow",
"MafRoundtableActionLeaseRow",
"MafRoundtableActionSecretRow",
"MafRoundtableApprovalRow",
+ "MafRoundtableConversationEventRow",
+ "MafRoundtableConversationRow",
"MafRoundtableEventRow",
+ "MafRoundtableMessageRow",
"MafRoundtableParticipantRow",
"MafRoundtableRunRow",
"MafRoundtableSeatRunRow",
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/conversation_events.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/conversation_events.py
new file mode 100644
index 00000000..3fb43ca8
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/conversation_events.py
@@ -0,0 +1,102 @@
+"""Conversation-event repository for the MAF roundtable V2 (SSE stream).
+
+Unlike ``maf_roundtable_events`` (per-run seq), this table uses a
+**conversation-global** monotonic ``seq`` so the frontend needs only one cursor
+(``after_seq``) regardless of how many internal runs a conversation spawns.
+The SSE hub (§4.2) replays from this table on connect/reconnect, then
+subscribes to real-time notifications.
+
+Seq allocation mirrors ``maf_roundtable_events``: ``SELECT MAX(seq) + 1`` with
+``IntegrityError`` retry, portable across sqlite / postgres / mysql.
+
+The writer order is "persist first, then notify" — the hub never broadcasts an
+event that is not already durable here, so a reconnecting client can always
+replay anything it missed.
+"""
+
+from __future__ import annotations
+
+from datetime import UTC, datetime
+from typing import Any
+
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from deerflow.persistence.maf_roundtable.model import MafRoundtableConversationEventRow
+
+
+def _now() -> datetime:
+ return datetime.now(UTC)
+
+
+def conversation_event_to_dict(row: MafRoundtableConversationEventRow) -> dict[str, Any]:
+ """Serialise a conversation-event ORM row to the §4.3 SSE envelope dict."""
+ return {
+ "seq": row.seq,
+ "event_id": row.id,
+ "conversation_id": row.conversation_id,
+ "run_id": row.run_id,
+ "source_event_id": getattr(row, "source_event_id", None),
+ "type": row.event_type,
+ "occurred_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
+ "data": row.data_json or {},
+ }
+
+
+class MafConversationEventRepository:
+ """Conversation-global monotonic event stream backing the SSE channel."""
+
+ def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
+ self._sf = session_factory
+
+ async def append_event(
+ self,
+ *,
+ conversation_id: str,
+ event_type: str,
+ run_id: str | None = None,
+ data: dict[str, Any] | None = None,
+ ) -> dict[str, Any]:
+ """Persist one event with a monotonic conversation-global seq.
+
+ Concurrent appends are safe: each attempts ``MAX(seq)+1`` and retries
+ on the ``(conversation_id, seq)`` unique violation. Returns the §4.3
+ envelope dict.
+ """
+ del conversation_id, event_type, run_id, data
+ raise RuntimeError(
+ "Conversation event writes must use "
+ "MafRoundtableStore.append_conversation_item"
+ )
+
+ async def list_events(
+ self, conversation_id: str, *, after_seq: int = 0, limit: int = 1000
+ ) -> list[dict[str, Any]]:
+ """Replay: events with ``seq > after_seq`` in ascending order.
+
+ Used by the SSE hub on connect/reconnect to catch the client up before
+ switching to the real-time subscription.
+ """
+ stmt = (
+ select(MafRoundtableConversationEventRow)
+ .where(
+ MafRoundtableConversationEventRow.conversation_id == conversation_id,
+ MafRoundtableConversationEventRow.seq > after_seq,
+ )
+ .order_by(MafRoundtableConversationEventRow.seq.asc())
+ .limit(limit)
+ )
+ async with self._sf() as session:
+ rows = (await session.execute(stmt)).scalars().all()
+ return [conversation_event_to_dict(r) for r in rows]
+
+ async def latest_seq(self, conversation_id: str) -> int:
+ async with self._sf() as session:
+ max_seq = (
+ await session.execute(
+ select(func.max(MafRoundtableConversationEventRow.seq)).where(
+ MafRoundtableConversationEventRow.conversation_id == conversation_id
+ )
+ )
+ ).scalar_one()
+ return int(max_seq or 0)
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/conversations.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/conversations.py
new file mode 100644
index 00000000..5121f8f7
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/conversations.py
@@ -0,0 +1,200 @@
+"""Conversation repository for the MAF roundtable V2 (user-visible aggregation).
+
+A conversation is the **only** entity the frontend knows about. Owner-scoped
+by ``owner_user_id``: every read / update / delete checks ownership. Admins
+bypass the owner filter when ``admin_diagnostic_access`` is true (handled in
+the service / router layer, not here).
+"""
+
+from __future__ import annotations
+
+from datetime import UTC, datetime
+from typing import Any
+from uuid import uuid4
+
+from sqlalchemy import delete, func, select, update
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from deerflow.persistence.maf_roundtable.model import MafRoundtableConversationRow
+
+
+def _now() -> datetime:
+ return datetime.now(UTC)
+
+
+def conversation_internal_to_dict(row: MafRoundtableConversationRow) -> dict[str, Any]:
+ """Internal serialisation — includes all columns. Never expose to the browser."""
+ return {
+ "id": row.id,
+ "owner_user_id": row.owner_user_id,
+ "title": row.title,
+ "task_context": row.task_context_json,
+ "safe_status": row.safe_status,
+ "active_run_id": row.active_run_id,
+ "internal_session_id": row.internal_session_id,
+ "last_stream_seq": row.last_stream_seq,
+ "created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
+ "updated_at": row.updated_at.isoformat() if isinstance(row.updated_at, datetime) else row.updated_at,
+ }
+
+
+_TASK_CONTEXT_SAFE_KEYS = frozenset({"business_code", "task_id", "task_name", "chain_id", "chain_name"})
+
+
+def conversation_public_to_dict(row: MafRoundtableConversationRow) -> dict[str, Any]:
+ """Public DTO — only fields safe for the browser. No owner, session, run, or thread ids."""
+ raw_ctx = row.task_context_json
+ safe_ctx: dict[str, Any] | None = None
+ if isinstance(raw_ctx, dict):
+ safe_ctx = {k: v for k, v in raw_ctx.items() if k in _TASK_CONTEXT_SAFE_KEYS}
+ return {
+ "id": row.id,
+ "title": row.title,
+ "task_context": safe_ctx,
+ "safe_status": row.safe_status,
+ "created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
+ "updated_at": row.updated_at.isoformat() if isinstance(row.updated_at, datetime) else row.updated_at,
+ "last_stream_seq": row.last_stream_seq,
+ }
+
+
+class MafConversationRepository:
+ """Owner-scoped CRUD for MAF roundtable conversations."""
+
+ def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
+ self._sf = session_factory
+
+ async def create_conversation(self, data: dict[str, Any]) -> dict[str, Any]:
+ """Create a new conversation. ``data`` must include ``owner_user_id``."""
+ now = _now()
+ row = MafRoundtableConversationRow(
+ id=data.get("id") or uuid4().hex,
+ owner_user_id=str(data["owner_user_id"]),
+ title=str(data.get("title") or ""),
+ task_context_json=data.get("task_context"),
+ safe_status="idle",
+ active_run_id=None,
+ internal_session_id=None,
+ last_stream_seq=0,
+ created_at=now,
+ updated_at=now,
+ )
+ async with self._sf() as session:
+ session.add(row)
+ await session.commit()
+ return conversation_internal_to_dict(row)
+
+ async def get_conversation(
+ self, conversation_id: str, *, user_id: str | None = "REQUIRED"
+ ) -> dict[str, Any] | None:
+ """Read one conversation.
+
+ ``user_id`` scoping:
+ - ``"REQUIRED"`` (default): enforce owner scope.
+ - ``None``: bypass owner scope (admin / internal path).
+ """
+ async with self._sf() as session:
+ stmt = select(MafRoundtableConversationRow).where(
+ MafRoundtableConversationRow.id == conversation_id
+ ).limit(1)
+ row = (await session.execute(stmt)).scalars().first()
+ if row is None:
+ return None
+ if user_id is not None and row.owner_user_id != user_id:
+ return None
+ return conversation_internal_to_dict(row)
+
+ async def list_conversations(
+ self,
+ *,
+ user_id: str | None = None,
+ safe_status: str | None = None,
+ limit: int = 50,
+ offset: int = 0,
+ ) -> list[dict[str, Any]]:
+ """List conversations, newest-first.
+
+ ``user_id=None`` lists across all users (admin / diagnostic).
+ """
+ stmt = select(MafRoundtableConversationRow)
+ if user_id is not None:
+ stmt = stmt.where(MafRoundtableConversationRow.owner_user_id == user_id)
+ if safe_status is not None:
+ stmt = stmt.where(MafRoundtableConversationRow.safe_status == safe_status)
+ stmt = stmt.order_by(MafRoundtableConversationRow.updated_at.desc()).limit(limit).offset(offset)
+ async with self._sf() as session:
+ rows = (await session.execute(stmt)).scalars().all()
+ return [conversation_internal_to_dict(r) for r in rows]
+
+ async def count_conversations(
+ self, *, user_id: str | None = None, safe_status: str | None = None
+ ) -> int:
+ stmt = select(func.count()).select_from(MafRoundtableConversationRow)
+ if user_id is not None:
+ stmt = stmt.where(MafRoundtableConversationRow.owner_user_id == user_id)
+ if safe_status is not None:
+ stmt = stmt.where(MafRoundtableConversationRow.safe_status == safe_status)
+ async with self._sf() as session:
+ return int((await session.execute(stmt)).scalar_one())
+
+ async def update_conversation(
+ self, conversation_id: str, *, user_id: str | None, fields: dict[str, Any]
+ ) -> dict[str, Any] | None:
+ """Partial update. Returns ``None`` if missing or wrong owner."""
+ async with self._sf() as session:
+ stmt = select(MafRoundtableConversationRow).where(
+ MafRoundtableConversationRow.id == conversation_id
+ ).limit(1)
+ row = (await session.execute(stmt)).scalars().first()
+ if row is None:
+ return None
+ if user_id is not None and row.owner_user_id != user_id:
+ return None
+ col_map = {"task_context": "task_context_json"}
+ for key in ("title", "task_context", "safe_status", "active_run_id",
+ "internal_session_id", "last_stream_seq"):
+ if key in fields:
+ col = col_map.get(key, key)
+ setattr(row, col, fields[key])
+ row.updated_at = _now()
+ await session.commit()
+ return conversation_internal_to_dict(row)
+
+ async def delete_conversation(self, conversation_id: str, *, user_id: str | None) -> bool:
+ """Delete a conversation (children via FK CASCADE)."""
+ async with self._sf() as session:
+ stmt = select(MafRoundtableConversationRow).where(
+ MafRoundtableConversationRow.id == conversation_id
+ ).limit(1)
+ row = (await session.execute(stmt)).scalars().first()
+ if row is None:
+ return False
+ if user_id is not None and row.owner_user_id != user_id:
+ return False
+ await session.execute(
+ delete(MafRoundtableConversationRow).where(
+ MafRoundtableConversationRow.id == conversation_id
+ )
+ )
+ await session.commit()
+ return True
+
+ async def set_status(self, conversation_id: str, safe_status: str) -> None:
+ """Internal: update only the safe_status column."""
+ async with self._sf() as session:
+ await session.execute(
+ update(MafRoundtableConversationRow)
+ .where(MafRoundtableConversationRow.id == conversation_id)
+ .values(safe_status=safe_status, updated_at=_now())
+ )
+ await session.commit()
+
+ async def set_active_run(self, conversation_id: str, run_id: str | None, safe_status: str) -> None:
+ """Internal: update active_run_id + safe_status together."""
+ async with self._sf() as session:
+ await session.execute(
+ update(MafRoundtableConversationRow)
+ .where(MafRoundtableConversationRow.id == conversation_id)
+ .values(active_run_id=run_id, safe_status=safe_status, updated_at=_now())
+ )
+ await session.commit()
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/messages.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/messages.py
new file mode 100644
index 00000000..f55b5d92
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/messages.py
@@ -0,0 +1,176 @@
+"""Message repository for the MAF roundtable V2 (stable conversation history).
+
+Every user-visible bubble (user / manager / seat / system / clarification /
+approval / result / tool_progress) has exactly one row here. ``seq`` is a
+conversation-global monotonic counter (unique per conversation) allocated via
+``SELECT MAX(seq) + 1`` with ``IntegrityError`` retry — the same portable
+approach as ``maf_roundtable_events`` (works on sqlite / postgres / mysql).
+
+The message history is the durable record the frontend rebuilds the timeline
+from on refresh; the SSE conversation-event stream (§4.3) is the real-time
+channel. Both share the conversation-global seq space so a refresh can resume
+the SSE cursor from the last persisted message/event seq.
+"""
+
+from __future__ import annotations
+
+from datetime import UTC, datetime
+from typing import Any
+
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from deerflow.persistence.maf_roundtable.model import MafRoundtableMessageRow
+
+
+def _now() -> datetime:
+ return datetime.now(UTC)
+
+
+def message_to_dict(row: MafRoundtableMessageRow) -> dict[str, Any]:
+ """Internal serialisation — includes all columns. Never expose to the browser."""
+ return {
+ "id": row.id,
+ "conversation_id": row.conversation_id,
+ "seq": row.seq,
+ "message_kind": row.message_kind,
+ "role": row.role,
+ "agent_id": row.agent_id,
+ "run_id": row.run_id,
+ "message_ref": row.message_ref,
+ "content": row.content,
+ "metadata": row.metadata_json,
+ "created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
+ }
+
+
+_METADATA_WHITELIST_BY_KIND: dict[str, frozenset[str]] = {
+ "user": frozenset(),
+ "manager": frozenset({"intent_type", "dispatch_plan_summary"}),
+ "seat": frozenset({"seat_name", "delivery_status", "attempt_number"}),
+ "system": frozenset({"reason"}),
+ "clarification": frozenset({"clarification_id", "options", "allow_multiple", "status"}),
+ "approval": frozenset({"approval_id", "tool_name", "risk_level", "summary", "status"}),
+ "result": frozenset({"result_type", "has_controlled_gap", "gap_summary"}),
+ "tool_progress": frozenset({"tool_name", "progress_pct", "status"}),
+}
+
+
+def message_public_to_dict(row: MafRoundtableMessageRow) -> dict[str, Any]:
+ """Public DTO — only display-safe fields. No run_id, no unfiltered metadata."""
+ raw_meta = row.metadata_json
+ safe_meta: dict[str, Any] | None = None
+ if isinstance(raw_meta, dict):
+ allowed = _METADATA_WHITELIST_BY_KIND.get(row.message_kind, frozenset())
+ safe_meta = {k: v for k, v in raw_meta.items() if k in allowed} or None
+ return {
+ "id": row.id,
+ "conversation_id": row.conversation_id,
+ "seq": row.seq,
+ "message_kind": row.message_kind,
+ "role": row.role,
+ "agent_id": row.agent_id,
+ "message_ref": row.message_ref,
+ "content": row.content,
+ "metadata": safe_meta,
+ "created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
+ }
+
+
+class MafMessageRepository:
+ """Conversation-scoped message history with a monotonic per-conversation seq."""
+
+ def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
+ self._sf = session_factory
+
+ async def append_message(
+ self,
+ *,
+ conversation_id: str,
+ message_kind: str,
+ role: str,
+ content: str = "",
+ agent_id: str | None = None,
+ run_id: str | None = None,
+ message_ref: str | None = None,
+ metadata: dict[str, Any] | None = None,
+ ) -> dict[str, Any]:
+ """Reject an unsafe standalone write.
+
+ Message and event writes must use
+ :meth:`MafRoundtableStore.append_conversation_item` inside one
+ transaction. Separate ``MAX(seq) + 1`` allocators would violate the
+ conversation's single SSE cursor.
+ """
+ del conversation_id, message_kind, role, content, agent_id, run_id, message_ref, metadata
+ raise RuntimeError(
+ "Conversation message writes must use "
+ "MafRoundtableStore.append_conversation_item"
+ )
+
+ async def list_messages(
+ self,
+ conversation_id: str,
+ *,
+ after_seq: int = 0,
+ before_seq: int | None = None,
+ limit: int = 50,
+ ) -> list[dict[str, Any]]:
+ """Paginated message history in ascending seq order.
+
+ ``after_seq`` / ``before_seq`` implement cursor paging: the frontend
+ loads the latest page on entry (``after_seq=0`` + reverse via
+ :meth:`list_messages_desc`) and older pages on scroll-up.
+ """
+ stmt = (
+ select(MafRoundtableMessageRow)
+ .where(
+ MafRoundtableMessageRow.conversation_id == conversation_id,
+ MafRoundtableMessageRow.seq > after_seq,
+ )
+ )
+ if before_seq is not None:
+ stmt = stmt.where(MafRoundtableMessageRow.seq < before_seq)
+ stmt = stmt.order_by(MafRoundtableMessageRow.seq.asc()).limit(limit)
+ async with self._sf() as session:
+ rows = (await session.execute(stmt)).scalars().all()
+ return [message_to_dict(r) for r in rows]
+
+ async def list_messages_desc(
+ self, conversation_id: str, *, limit: int = 50, before_seq: int | None = None
+ ) -> list[dict[str, Any]]:
+ """Latest ``limit`` messages, newest-first (for initial page load).
+
+ Returned newest-first; callers that want chronological order reverse
+ the list. ``before_seq`` loads the next older page.
+ """
+ stmt = select(MafRoundtableMessageRow).where(
+ MafRoundtableMessageRow.conversation_id == conversation_id
+ )
+ if before_seq is not None:
+ stmt = stmt.where(MafRoundtableMessageRow.seq < before_seq)
+ stmt = stmt.order_by(MafRoundtableMessageRow.seq.desc()).limit(limit)
+ async with self._sf() as session:
+ rows = (await session.execute(stmt)).scalars().all()
+ return [message_to_dict(r) for r in rows]
+
+ async def latest_seq(self, conversation_id: str) -> int:
+ async with self._sf() as session:
+ max_seq = (
+ await session.execute(
+ select(func.max(MafRoundtableMessageRow.seq)).where(
+ MafRoundtableMessageRow.conversation_id == conversation_id
+ )
+ )
+ ).scalar_one()
+ return int(max_seq or 0)
+
+ async def count_messages(self, conversation_id: str) -> int:
+ async with self._sf() as session:
+ return int((
+ await session.execute(
+ select(func.count()).select_from(MafRoundtableMessageRow).where(
+ MafRoundtableMessageRow.conversation_id == conversation_id
+ )
+ )
+ ).scalar_one())
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/model.py
index 06b663fd..00df93f8 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/model.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/model.py
@@ -437,12 +437,142 @@ class MafRoundtableActionSecretRow(Base):
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=_now)
+# ── 10. conversations (V2: user-visible conversation aggregation) ─────────
+
+
+class MafRoundtableConversationRow(Base):
+ """One user-visible MAF roundtable conversation.
+
+ This is the **only** entity the frontend knows about. A conversation can
+ span multiple user messages, each of which may trigger an internal
+ ``maf_roundtable_runs`` execution. The user never sees session/run/thread
+ ids — only ``conversation_id``.
+
+ ``active_run_id`` points to the currently executing internal run (nullable
+ when idle). ``safe_status`` is a coarse user-facing status derived from
+ the active run's state (idle / running / waiting_input / waiting_approval
+ / completed / failed / cancelled).
+ """
+
+ __tablename__ = "maf_roundtable_conversations"
+
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
+ owner_user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
+ title: Mapped[str] = mapped_column(String(255), default="")
+ task_context_json: Mapped[dict | None] = mapped_column(PortableJSON(), nullable=True)
+ # idle | running | waiting_input | waiting_approval | completed | failed | cancelled
+ safe_status: Mapped[str] = mapped_column(String(32), default="idle", index=True)
+ active_run_id: Mapped[str | None] = mapped_column(
+ String(64),
+ ForeignKey("maf_roundtable_runs.id", ondelete="SET NULL"),
+ nullable=True,
+ )
+ # Internal session this conversation is linked to (lazy-created on first message).
+ internal_session_id: Mapped[str | None] = mapped_column(
+ String(64),
+ ForeignKey("maf_roundtable_sessions.id", ondelete="SET NULL"),
+ nullable=True,
+ )
+ last_stream_seq: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+ created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=_now)
+ updated_at: Mapped[datetime] = mapped_column(
+ BeijingDateTime(), nullable=False, default=_now, onupdate=_now
+ )
+
+
+# ── 11. conversation messages ─────────────────────────────────────────────
+
+
+class MafRoundtableMessageRow(Base):
+ """Stable message history for a conversation.
+
+ Every user-visible bubble (user message, manager message, seat message,
+ clarification card, approval card, result card) has exactly one row here.
+ ``seq`` is a conversation-global monotonic counter (unique per conversation)
+ used for SSE cursor and ordering. ``message_kind`` classifies the bubble
+ type for the frontend reducer.
+
+ ``run_id`` links the message to the internal run that produced it (nullable
+ for user messages that haven't started a run yet). ``agent_id`` identifies
+ the speaking agent (null for user messages).
+ """
+
+ __tablename__ = "maf_roundtable_messages"
+ __table_args__ = (
+ UniqueConstraint("conversation_id", "seq", name="uq_maf_messages_conv_seq"),
+ )
+
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
+ conversation_id: Mapped[str] = mapped_column(
+ String(64), ForeignKey("maf_roundtable_conversations.id", ondelete="CASCADE"),
+ nullable=False, index=True,
+ )
+ seq: Mapped[int] = mapped_column(Integer, nullable=False)
+ # user | manager | seat | system | clarification | approval | result | tool_progress
+ message_kind: Mapped[str] = mapped_column(String(32), nullable=False, default="user")
+ role: Mapped[str] = mapped_column(String(16), nullable=False, default="user")
+ agent_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
+ run_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
+ # Stable message identity for SSE delta merging (e.g. LangGraph message id).
+ message_ref: Mapped[str | None] = mapped_column(String(191), nullable=True, index=True)
+ content: Mapped[str] = mapped_column(PortableLongText, default="")
+ metadata_json: Mapped[dict | None] = mapped_column(PortableJSON(), nullable=True)
+ created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=_now, index=True)
+
+
+# ── 12. conversation events (SSE stream, conversation-global seq) ─────────
+
+
+class MafRoundtableConversationEventRow(Base):
+ """Conversation-dimension event stream for SSE.
+
+ Unlike ``maf_roundtable_events`` (per-run seq), this table uses a
+ **conversation-global** monotonic ``seq`` so the frontend only needs one
+ cursor (``after_seq``) regardless of how many internal runs happen.
+ The SSE hub replays from this table on connect/reconnect, then subscribes
+ to real-time notifications.
+
+ ``event_type`` follows the §4.3 contract (conversation.snapshot,
+ user.message.created, manager.intent, clarification.required, etc.).
+ ``data_json`` is the already-sanitized event payload safe for the browser.
+ """
+
+ __tablename__ = "maf_roundtable_conversation_events"
+ __table_args__ = (
+ UniqueConstraint("conversation_id", "seq", name="uq_maf_conv_events_conv_seq"),
+ # P0-4: dedup key for projection idempotency. The source audit event's
+ # stable id (e.g. LangGraph event id / tool_call id) prevents duplicate
+ # conversation events when the bridge retries or the adapter re-projects
+ # after a restart. NULL source_event_ids are not deduped (SQL treats
+ # NULLs as distinct in UNIQUE constraints).
+ UniqueConstraint("conversation_id", "source_event_id", name="uq_maf_conv_events_conv_source"),
+ )
+
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
+ conversation_id: Mapped[str] = mapped_column(
+ String(64), ForeignKey("maf_roundtable_conversations.id", ondelete="CASCADE"),
+ nullable=False, index=True,
+ )
+ seq: Mapped[int] = mapped_column(Integer, nullable=False)
+ run_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
+ # Stable upstream id from the source audit event (maf_roundtable_events.id
+ # or the LangGraph source_event_id). Used for projection dedup via the
+ # (conversation_id, source_event_id) unique constraint.
+ source_event_id: Mapped[str | None] = mapped_column(String(191), nullable=True, index=True)
+ event_type: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
+ data_json: Mapped[dict | None] = mapped_column(PortableJSON(), nullable=True)
+ created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=_now, index=True)
+
+
__all__ = [
"MafRoundtableActionRow",
"MafRoundtableActionLeaseRow",
"MafRoundtableActionSecretRow",
"MafRoundtableApprovalRow",
+ "MafRoundtableConversationEventRow",
+ "MafRoundtableConversationRow",
"MafRoundtableEventRow",
+ "MafRoundtableMessageRow",
"MafRoundtableParticipantRow",
"MafRoundtableRunRow",
"MafRoundtableSeatRunRow",
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/store.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/store.py
index ee262895..842f1346 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/store.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/store.py
@@ -29,13 +29,20 @@ from deerflow.persistence.maf_roundtable.actions import (
action_to_dict,
)
from deerflow.persistence.maf_roundtable.approvals import MafApprovalRepository
+from deerflow.persistence.maf_roundtable.conversation_events import MafConversationEventRepository
+from deerflow.persistence.maf_roundtable.conversations import MafConversationRepository
from deerflow.persistence.maf_roundtable.events import MafEventRepository
+from deerflow.persistence.maf_roundtable.messages import MafMessageRepository
from deerflow.persistence.maf_roundtable.model import (
MafRoundtableActionLeaseRow,
MafRoundtableActionRow,
MafRoundtableActionSecretRow,
MafRoundtableApprovalRow,
+ MafRoundtableConversationEventRow,
+ MafRoundtableConversationRow,
MafRoundtableEventRow,
+ MafRoundtableMessageRow,
+ MafRoundtableParticipantRow,
MafRoundtableRunRow,
MafRoundtableSessionRow,
)
@@ -74,6 +81,10 @@ class MafRoundtableStore:
self.approvals = MafApprovalRepository(session_factory)
self.seat_runs = MafSeatRunRepository(session_factory)
self.action_secrets = MafActionSecretRepository(session_factory)
+ # V2 conversation aggregation (user-visible layer).
+ self.conversations = MafConversationRepository(session_factory)
+ self.messages = MafMessageRepository(session_factory)
+ self.conversation_events = MafConversationEventRepository(session_factory)
@property
def session_factory(self) -> async_sessionmaker[AsyncSession]:
@@ -791,12 +802,277 @@ class MafRoundtableStore:
await db.flush()
return recovered
+ # ── V2 conversation aggregation: unified seq allocation ─────────────────
+
+ async def append_conversation_item(
+ self,
+ db: AsyncSession,
+ *,
+ conversation: MafRoundtableConversationRow,
+ event_type: str,
+ event_data: dict[str, Any],
+ message: dict[str, Any] | None = None,
+ run_id: str | None = None,
+ source_event_id: str | None = None,
+ ) -> dict[str, Any]:
+ """Allocate the next conversation-global seq and persist message + event atomically.
+
+ MUST be called inside an existing transaction (``db.begin()``). The caller
+ is responsible for locking the conversation row (``with_for_update``).
+
+ 1. ``conversation.last_stream_seq += 1`` (CAS via the row lock).
+ 2. If ``message`` is provided, write it at the same seq.
+ 3. Write the conversation event at the same seq.
+ 4. Flush (no commit — caller controls the transaction boundary).
+
+ ``source_event_id`` (P0-4): stable upstream id for projection dedup.
+ If the same source_event_id was already projected for this conversation,
+ the caller should check and skip before calling this method.
+
+ Returns ``{"seq": int, "message_id": str | None, "event_id": str}``.
+ """
+ now = _now()
+ next_seq = int(conversation.last_stream_seq or 0) + 1
+ conversation.last_stream_seq = next_seq
+
+ message_id: str | None = None
+ if message is not None:
+ message_id = message.get("id") or uuid4().hex
+ db.add(MafRoundtableMessageRow(
+ id=message_id,
+ conversation_id=conversation.id,
+ seq=next_seq,
+ message_kind=message.get("message_kind", "user"),
+ role=message.get("role", "user"),
+ agent_id=message.get("agent_id"),
+ run_id=message.get("run_id") or run_id,
+ message_ref=message.get("message_ref"),
+ content=message.get("content", ""),
+ metadata_json=message.get("metadata"),
+ created_at=now,
+ ))
+
+ event_id = uuid4().hex
+ db.add(MafRoundtableConversationEventRow(
+ id=event_id,
+ conversation_id=conversation.id,
+ seq=next_seq,
+ run_id=run_id,
+ source_event_id=source_event_id,
+ event_type=event_type,
+ data_json=event_data,
+ created_at=now,
+ ))
+
+ await db.flush()
+ return {"seq": next_seq, "message_id": message_id, "event_id": event_id}
+
+ # ── V2 conversation aggregation: atomic send-message + run launch ───────
+
+ async def send_message_and_start_run(
+ self,
+ *,
+ conversation_id: str,
+ owner_user_id: str,
+ content: str,
+ message_metadata: dict[str, Any] | None = None,
+ workflow_version: str | None = None,
+ session_blueprint: dict[str, Any] | None = None,
+ ) -> dict[str, Any] | None:
+ """Atomically persist a user message, ensure internal session, and start a run.
+
+ Single ``AsyncSession`` + ``begin()`` so all writes share one transaction
+ — a mid-way failure rolls everything back (MAF-R1 requirement 1):
+
+ 1. Load the conversation with ``FOR UPDATE``; verify ownership.
+ 2. Reject when an active run is already in flight → ``{"conflict": True}``.
+ 3. If no internal session exists, create one from ``session_blueprint``.
+ 4. Create a ``pending`` run linked to the real internal session.
+ 5. Allocate the next conversation-global seq via ``append_conversation_item``
+ (message + event share the same seq).
+ 6. Flip conversation to ``safe_status='running'`` + ``active_run_id``.
+
+ ``session_blueprint`` is provided by the App layer (ConversationService).
+ If the conversation has no internal session and no blueprint is given,
+ the method fails closed with ``{"no_session_config": True}``.
+
+ Returns ``{"message": {...}, "run": {...}, "conversation_id": ...}`` on
+ success, ``{"conflict": True}`` when a run is already active,
+ ``{"no_session_config": True}`` when session cannot be created, or
+ ``None`` when the conversation is missing / not owned.
+ """
+ now = _now()
+ try:
+ async with self._sf() as db:
+ async with db.begin():
+ conv = (
+ await db.execute(
+ select(MafRoundtableConversationRow)
+ .where(MafRoundtableConversationRow.id == conversation_id)
+ .with_for_update()
+ )
+ ).scalars().first()
+ if conv is None or conv.owner_user_id != owner_user_id:
+ return None
+
+ # ``waiting_input`` with no active run is a clarification
+ # turn. It is intentionally writable so the user can
+ # answer the clarification; approval/workflow waits keep
+ # their active run and remain protected by the CAS.
+ if conv.safe_status in _ACTIVE_SESSION_STATUSES and not (
+ conv.safe_status == "waiting_input" and conv.active_run_id is None
+ ):
+ return {"conflict": True}
+
+ # 3. Ensure internal session exists.
+ start_run = True if session_blueprint is None else bool(
+ session_blueprint.get("start_run", True)
+ )
+ participant_blueprint = list(
+ (session_blueprint or {}).get("participants") or []
+ )
+ if conv.internal_session_id is None:
+ if session_blueprint is None:
+ return {"no_session_config": True}
+ session_row = MafRoundtableSessionRow(
+ id=uuid4().hex,
+ user_id=owner_user_id,
+ title=session_blueprint.get("title") or conv.title or "",
+ task_text=session_blueprint.get("task_text") or content,
+ task_payload_json=session_blueprint.get("task_payload"),
+ mode=session_blueprint.get("mode", "structured"),
+ status="running" if start_run else "waiting_input",
+ model_name=session_blueprint.get("model_name"),
+ reasoning_effort=session_blueprint.get("reasoning_effort"),
+ manager_agent_id=session_blueprint.get("manager_agent_id"),
+ max_rounds=session_blueprint.get("max_rounds", 8),
+ write_policy=session_blueprint.get("write_policy", "confirm_high_risk"),
+ capability_snapshot_json=session_blueprint.get("capability_snapshot"),
+ created_at=now,
+ updated_at=now,
+ )
+ db.add(session_row)
+ await db.flush()
+ conv.internal_session_id = session_row.id
+ else:
+ session_row = (
+ await db.execute(
+ select(MafRoundtableSessionRow).where(
+ MafRoundtableSessionRow.id == conv.internal_session_id
+ )
+ )
+ ).scalars().first()
+ if session_row is None or session_row.user_id != owner_user_id:
+ # Never repair a corrupted private link by guessing
+ # an id or attaching another user's session.
+ return {"no_session_config": True}
+ session_row.status = "running" if start_run else "waiting_input"
+ session_row.updated_at = now
+
+ # Participants are selected by the App-layer trusted
+ # dispatch catalog and inserted in this same transaction.
+ # A clarification may create the session before a route is
+ # selected; the first real dispatch fills that empty set.
+ existing_participants = (
+ await db.execute(
+ select(MafRoundtableParticipantRow.id).where(
+ MafRoundtableParticipantRow.session_id == session_row.id
+ )
+ )
+ ).scalars().all()
+ if not existing_participants and participant_blueprint:
+ seen_agents: set[str] = set()
+ for participant in participant_blueprint:
+ agent_id = str(participant.get("agent_id") or "").strip()
+ if not agent_id or agent_id in seen_agents:
+ continue
+ seen_agents.add(agent_id)
+ db.add(MafRoundtableParticipantRow(
+ id=uuid4().hex,
+ session_id=session_row.id,
+ agent_id=agent_id,
+ display_name=str(participant.get("display_name") or ""),
+ role=str(participant.get("role") or "seat"),
+ sort_order=int(participant.get("sort_order", 0)),
+ thread_id=None,
+ sandbox_id=None,
+ status="idle",
+ last_turn_index=None,
+ capability_snapshot_json=participant.get("capability_snapshot"),
+ created_at=now,
+ updated_at=now,
+ ))
+ await db.flush()
+
+ # 4. Create pending run linked to the real session.
+ run_row: MafRoundtableRunRow | None = None
+ if start_run:
+ run_row = MafRoundtableRunRow(
+ id=uuid4().hex,
+ session_id=conv.internal_session_id,
+ status="pending",
+ workflow_version=workflow_version,
+ started_at=None,
+ created_at=now,
+ updated_at=now,
+ )
+ db.add(run_row)
+ await db.flush()
+
+ # 5. Allocate unified seq: message + event share the same seq.
+ item_result = await self.append_conversation_item(
+ db,
+ conversation=conv,
+ event_type="user.message.created",
+ event_data={"content": content},
+ message={
+ "message_kind": "user",
+ "role": "user",
+ "content": content,
+ "metadata": message_metadata,
+ # Conversation messages are public and must not
+ # carry the internal run id.
+ "run_id": None,
+ },
+ run_id=None,
+ )
+
+ # 6. Claim the conversation.
+ conv.safe_status = "running" if start_run else "waiting_input"
+ conv.active_run_id = run_row.id if run_row is not None else None
+ conv.updated_at = now
+ await db.flush()
+
+ from deerflow.persistence.maf_roundtable.conversations import conversation_internal_to_dict
+ from deerflow.persistence.maf_roundtable.messages import message_to_dict
+
+ msg_row = (
+ await db.execute(
+ select(MafRoundtableMessageRow).where(
+ MafRoundtableMessageRow.id == item_result["message_id"]
+ )
+ )
+ ).scalars().first()
+
+ return {
+ "message": message_to_dict(msg_row) if msg_row else None,
+ "run": run_to_dict(run_row) if run_row is not None else None,
+ "conversation": conversation_internal_to_dict(conv),
+ "conversation_id": conversation_id,
+ "seq": item_result["seq"],
+ }
+ except IntegrityError:
+ return {"conflict": True}
+
__all__ = [
"MafActionRepository",
"MafActionSecretRepository",
"MafApprovalRepository",
+ "MafConversationEventRepository",
+ "MafConversationRepository",
"MafEventRepository",
+ "MafMessageRepository",
"MafParticipantRepository",
"MafRoundtableStore",
"MafRunRepository",
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/tools/tool_metadata.py b/offline-backend-20260512/backend/packages/harness/deerflow/tools/tool_metadata.py
index 976852c8..fe7f71d0 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/tools/tool_metadata.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/tools/tool_metadata.py
@@ -30,6 +30,7 @@ _BUILTIN_METADATA: dict[str, tuple[str, str]] = {
"grep": ("read", "R0"),
"glob": ("read", "R0"),
"ls": ("read", "R0"),
+ "read_peer_delivery": ("read", "R0"),
"invoke_skill": ("skill", "R1"),
"skill_search": ("skill", "R1"),
"task": ("read", "R2"),
diff --git a/offline-backend-20260512/backend/scripts/migrate_maf_roundtable_conversation_v2.py b/offline-backend-20260512/backend/scripts/migrate_maf_roundtable_conversation_v2.py
new file mode 100644
index 00000000..e2576a16
--- /dev/null
+++ b/offline-backend-20260512/backend/scripts/migrate_maf_roundtable_conversation_v2.py
@@ -0,0 +1,367 @@
+"""V2 migration for MAF roundtable conversation tables.
+
+Creates the three user-visible conversation tables:
+- maf_roundtable_conversations
+- maf_roundtable_messages
+- maf_roundtable_conversation_events
+
+Idempotent: safe to run multiple times on SQLite / PostgreSQL / MySQL.
+Does NOT modify or migrate existing MAF session/run/event data.
+
+Usage:
+ PYTHONPATH=. uv run python scripts/migrate_maf_roundtable_conversation_v2.py --dry-run
+ PYTHONPATH=. uv run python scripts/migrate_maf_roundtable_conversation_v2.py
+"""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+import logging
+import sys
+from typing import Any
+
+logging.basicConfig(level=logging.INFO, format="%(message)s")
+logger = logging.getLogger("migrate_maf_roundtable_conversation_v2")
+
+CONVERSATIONS_TABLE = "maf_roundtable_conversations"
+MESSAGES_TABLE = "maf_roundtable_messages"
+EVENTS_TABLE = "maf_roundtable_conversation_events"
+
+
+def _dialect_name(conn_or_dialect: Any) -> str:
+ return str(getattr(getattr(conn_or_dialect, "dialect", conn_or_dialect), "name", "sqlite")).lower()
+
+
+def _timestamp_sql(dialect: str) -> str:
+ if dialect == "postgresql":
+ return "TIMESTAMP WITH TIME ZONE"
+ if dialect in {"mysql", "mariadb"}:
+ return "DATETIME(6)"
+ return "DATETIME"
+
+
+def _json_sql(dialect: str) -> str:
+ if dialect == "postgresql":
+ return "JSONB"
+ if dialect in {"mysql", "mariadb"}:
+ return "JSON"
+ return "TEXT"
+
+
+def _longtext_sql(dialect: str) -> str:
+ if dialect == "postgresql":
+ return "TEXT"
+ if dialect in {"mysql", "mariadb"}:
+ return "LONGTEXT"
+ return "TEXT"
+
+
+def _conversations_ddl(dialect: str) -> str:
+ ts = _timestamp_sql(dialect)
+ js = _json_sql(dialect)
+ return f"""
+ CREATE TABLE {CONVERSATIONS_TABLE} (
+ id VARCHAR(64) NOT NULL PRIMARY KEY,
+ owner_user_id VARCHAR(64) NOT NULL,
+ title VARCHAR(255) NOT NULL DEFAULT '',
+ task_context_json {js},
+ safe_status VARCHAR(32) NOT NULL DEFAULT 'idle',
+ active_run_id VARCHAR(64),
+ internal_session_id VARCHAR(64),
+ last_stream_seq INTEGER NOT NULL DEFAULT 0,
+ created_at {ts} NOT NULL,
+ updated_at {ts} NOT NULL,
+ FOREIGN KEY (active_run_id) REFERENCES maf_roundtable_runs (id) ON DELETE SET NULL,
+ FOREIGN KEY (internal_session_id) REFERENCES maf_roundtable_sessions (id) ON DELETE SET NULL
+ )
+ """
+
+
+def _messages_ddl(dialect: str) -> str:
+ ts = _timestamp_sql(dialect)
+ js = _json_sql(dialect)
+ lt = _longtext_sql(dialect)
+ return f"""
+ CREATE TABLE {MESSAGES_TABLE} (
+ id VARCHAR(64) NOT NULL PRIMARY KEY,
+ conversation_id VARCHAR(64) NOT NULL,
+ seq INTEGER NOT NULL,
+ message_kind VARCHAR(32) NOT NULL DEFAULT 'user',
+ role VARCHAR(16) NOT NULL DEFAULT 'user',
+ agent_id VARCHAR(64),
+ run_id VARCHAR(64),
+ message_ref VARCHAR(191),
+ content {lt} NOT NULL DEFAULT '',
+ metadata_json {js},
+ created_at {ts} NOT NULL,
+ CONSTRAINT uq_maf_messages_conv_seq UNIQUE (conversation_id, seq),
+ FOREIGN KEY (conversation_id) REFERENCES {CONVERSATIONS_TABLE} (id) ON DELETE CASCADE
+ )
+ """
+
+
+def _events_ddl(dialect: str) -> str:
+ ts = _timestamp_sql(dialect)
+ js = _json_sql(dialect)
+ return f"""
+ CREATE TABLE {EVENTS_TABLE} (
+ id VARCHAR(64) NOT NULL PRIMARY KEY,
+ conversation_id VARCHAR(64) NOT NULL,
+ seq INTEGER NOT NULL,
+ run_id VARCHAR(64),
+ source_event_id VARCHAR(191),
+ event_type VARCHAR(64) NOT NULL,
+ data_json {js},
+ created_at {ts} NOT NULL,
+ CONSTRAINT uq_maf_conv_events_conv_seq UNIQUE (conversation_id, seq),
+ CONSTRAINT uq_maf_conv_events_conv_source UNIQUE (conversation_id, source_event_id),
+ FOREIGN KEY (conversation_id) REFERENCES {CONVERSATIONS_TABLE} (id) ON DELETE CASCADE
+ )
+ """
+
+
+def _indexes_ddl(dialect: str) -> list[str]:
+ stmts: list[str] = []
+ stmts.append(f"CREATE INDEX ix_maf_conv_owner_updated ON {CONVERSATIONS_TABLE} (owner_user_id, updated_at)")
+ stmts.append(f"CREATE INDEX ix_maf_conv_safe_status ON {CONVERSATIONS_TABLE} (safe_status)")
+ stmts.append(f"CREATE INDEX ix_maf_msg_conv_seq ON {MESSAGES_TABLE} (conversation_id, seq)")
+ stmts.append(f"CREATE INDEX ix_maf_msg_created ON {MESSAGES_TABLE} (created_at)")
+ stmts.append(f"CREATE INDEX ix_maf_msg_run ON {MESSAGES_TABLE} (run_id)")
+ stmts.append(f"CREATE INDEX ix_maf_msg_ref ON {MESSAGES_TABLE} (message_ref)")
+ stmts.append(f"CREATE INDEX ix_maf_cevt_conv_seq ON {EVENTS_TABLE} (conversation_id, seq)")
+ stmts.append(f"CREATE INDEX ix_maf_cevt_type ON {EVENTS_TABLE} (event_type)")
+ stmts.append(f"CREATE INDEX ix_maf_cevt_created ON {EVENTS_TABLE} (created_at)")
+ stmts.append(f"CREATE INDEX ix_maf_cevt_source ON {EVENTS_TABLE} (source_event_id)")
+ return stmts
+
+
+def validate_schema(inspector: Any) -> list[str]:
+ """Return contract violations found by SQLAlchemy inspector."""
+ issues: list[str] = []
+ tables = inspector.get_table_names()
+
+ for tbl in (CONVERSATIONS_TABLE, MESSAGES_TABLE, EVENTS_TABLE):
+ if tbl not in tables:
+ issues.append(f"missing table: {tbl}")
+
+ if CONVERSATIONS_TABLE in tables:
+ cols = {c["name"] for c in inspector.get_columns(CONVERSATIONS_TABLE)}
+ for required in ("id", "owner_user_id", "title", "safe_status", "last_stream_seq", "created_at", "updated_at"):
+ if required not in cols:
+ issues.append(f"{CONVERSATIONS_TABLE}: missing column {required}")
+ if "last_message_seq" in cols:
+ issues.append(f"{CONVERSATIONS_TABLE}: deprecated column last_message_seq still present")
+ fks = inspector.get_foreign_keys(CONVERSATIONS_TABLE)
+ fk_columns = {
+ (tuple(fk.get("constrained_columns") or ()), fk.get("referred_table"))
+ for fk in fks
+ }
+ if (("active_run_id",), "maf_roundtable_runs") not in fk_columns:
+ issues.append(f"{CONVERSATIONS_TABLE}: missing FK(active_run_id -> maf_roundtable_runs)")
+ if (("internal_session_id",), "maf_roundtable_sessions") not in fk_columns:
+ issues.append(f"{CONVERSATIONS_TABLE}: missing FK(internal_session_id -> maf_roundtable_sessions)")
+
+ if MESSAGES_TABLE in tables:
+ cols = {c["name"] for c in inspector.get_columns(MESSAGES_TABLE)}
+ for required in ("id", "conversation_id", "seq", "message_kind", "role", "content", "created_at"):
+ if required not in cols:
+ issues.append(f"{MESSAGES_TABLE}: missing column {required}")
+ uqs = inspector.get_unique_constraints(MESSAGES_TABLE)
+ uq_cols = [set(u.get("column_names", [])) for u in uqs]
+ if {"conversation_id", "seq"} not in uq_cols:
+ issues.append(f"{MESSAGES_TABLE}: missing UNIQUE(conversation_id, seq)")
+
+ if EVENTS_TABLE in tables:
+ cols = {c["name"] for c in inspector.get_columns(EVENTS_TABLE)}
+ for required in ("id", "conversation_id", "seq", "event_type", "created_at", "source_event_id"):
+ if required not in cols:
+ issues.append(f"{EVENTS_TABLE}: missing column {required}")
+ uqs = inspector.get_unique_constraints(EVENTS_TABLE)
+ uq_cols = [set(u.get("column_names", [])) for u in uqs]
+ if {"conversation_id", "seq"} not in uq_cols:
+ issues.append(f"{EVENTS_TABLE}: missing UNIQUE(conversation_id, seq)")
+ if {"conversation_id", "source_event_id"} not in uq_cols:
+ issues.append(f"{EVENTS_TABLE}: missing UNIQUE(conversation_id, source_event_id)")
+
+ return issues
+
+
+def _missing_conversation_link_foreign_keys(inspector: Any) -> list[str]:
+ """Return missing private-link FKs on an already-created conversation table."""
+ if CONVERSATIONS_TABLE not in inspector.get_table_names():
+ return []
+ foreign_keys = {
+ (tuple(fk.get("constrained_columns") or ()), fk.get("referred_table"))
+ for fk in inspector.get_foreign_keys(CONVERSATIONS_TABLE)
+ }
+ missing: list[str] = []
+ if (("active_run_id",), "maf_roundtable_runs") not in foreign_keys:
+ missing.append("active_run_id -> maf_roundtable_runs")
+ if (("internal_session_id",), "maf_roundtable_sessions") not in foreign_keys:
+ missing.append("internal_session_id -> maf_roundtable_sessions")
+ return missing
+
+
+async def run_migration(database_url: str, *, dry_run: bool = False) -> None:
+ from sqlalchemy import inspect, text
+ from sqlalchemy.ext.asyncio import create_async_engine
+ from sqlalchemy.pool import NullPool
+
+ # A migration is a one-shot operation. NullPool makes every early
+ # failure close its SQLite connection as the transaction context exits,
+ # rather than retaining it until the process exits.
+ engine = create_async_engine(database_url, poolclass=NullPool)
+ async with engine.begin() as conn:
+ dialect = _dialect_name(conn)
+ logger.info("Dialect: %s", dialect)
+
+ def _get_table_names(sync_conn: Any) -> list[str]:
+ return inspect(sync_conn).get_table_names()
+
+ existing = set(await conn.run_sync(_get_table_names))
+
+ def _missing_links(sync_conn: Any) -> list[str]:
+ return _missing_conversation_link_foreign_keys(inspect(sync_conn))
+
+ missing_links = await conn.run_sync(_missing_links)
+ if missing_links:
+ if dialect in {"sqlite"}:
+ # SQLite cannot add a foreign-key constraint in place. These
+ # V2 tables have not been released as a data source yet, so an
+ # empty partial install can be rebuilt safely. Never delete
+ # a non-empty conversation history merely to improve a schema.
+ tables_to_rebuild = [EVENTS_TABLE, MESSAGES_TABLE, CONVERSATIONS_TABLE]
+ non_empty: list[str] = []
+ for table in tables_to_rebuild:
+ if table not in existing:
+ continue
+ count = int((await conn.execute(text(f"SELECT COUNT(*) FROM {table}"))).scalar_one())
+ if count:
+ non_empty.append(table)
+ if non_empty:
+ # ``run_migration`` normally disposes below the transaction
+ # block. This explicit failure is intentionally raised
+ # earlier, so release the aiosqlite pool before bubbling it
+ # to callers/tests.
+ await engine.dispose()
+ raise RuntimeError(
+ "MAF Conversation V2 schema is missing foreign keys "
+ f"({', '.join(missing_links)}) but has data in "
+ f"{', '.join(non_empty)}. Back up and migrate it explicitly; "
+ "this script refuses to delete conversation history."
+ )
+ logger.warning(
+ "Rebuilding empty partial V2 tables to add missing foreign keys: %s",
+ ", ".join(missing_links),
+ )
+ if not dry_run:
+ for table in tables_to_rebuild:
+ if table in existing:
+ await conn.execute(text(f"DROP TABLE {table}"))
+ existing.difference_update(tables_to_rebuild)
+ else:
+ # PostgreSQL and MySQL/MariaDB can add the missing constraints
+ # in place. The database validates existing rows and fails
+ # closed if a dangling private link exists.
+ if "active_run_id -> maf_roundtable_runs" in missing_links:
+ sql = (
+ f"ALTER TABLE {CONVERSATIONS_TABLE} "
+ "ADD CONSTRAINT fk_maf_conv_active_run "
+ "FOREIGN KEY (active_run_id) REFERENCES maf_roundtable_runs (id) "
+ "ON DELETE SET NULL"
+ )
+ if dry_run:
+ logger.info("[DRY RUN] Would execute: %s", sql)
+ else:
+ await conn.execute(text(sql))
+ if "internal_session_id -> maf_roundtable_sessions" in missing_links:
+ sql = (
+ f"ALTER TABLE {CONVERSATIONS_TABLE} "
+ "ADD CONSTRAINT fk_maf_conv_internal_session "
+ "FOREIGN KEY (internal_session_id) REFERENCES maf_roundtable_sessions (id) "
+ "ON DELETE SET NULL"
+ )
+ if dry_run:
+ logger.info("[DRY RUN] Would execute: %s", sql)
+ else:
+ await conn.execute(text(sql))
+
+ ddls: list[str] = []
+ if CONVERSATIONS_TABLE not in existing:
+ ddls.append(_conversations_ddl(dialect))
+ logger.info("Will create: %s", CONVERSATIONS_TABLE)
+ else:
+ logger.info("Already exists: %s", CONVERSATIONS_TABLE)
+
+ if MESSAGES_TABLE not in existing:
+ ddls.append(_messages_ddl(dialect))
+ logger.info("Will create: %s", MESSAGES_TABLE)
+ else:
+ logger.info("Already exists: %s", MESSAGES_TABLE)
+
+ if EVENTS_TABLE not in existing:
+ ddls.append(_events_ddl(dialect))
+ logger.info("Will create: %s", EVENTS_TABLE)
+ else:
+ logger.info("Already exists: %s", EVENTS_TABLE)
+
+ for idx_sql in _indexes_ddl(dialect):
+ ddls.append(idx_sql)
+
+ if dry_run:
+ logger.info("[DRY RUN] Would execute %d statements:", len(ddls))
+ for ddl in ddls:
+ logger.info(" %s", " ".join(ddl.split())[:120])
+ else:
+ for ddl in ddls:
+ try:
+ await conn.execute(text(ddl))
+ except Exception as exc:
+ if "already exists" in str(exc).lower() or "duplicate" in str(exc).lower():
+ continue
+ raise
+ logger.info("Executed %d statements successfully.", len(ddls))
+
+ def _validate(sync_conn: Any) -> list[str]:
+ return validate_schema(inspect(sync_conn))
+
+ issues = await conn.run_sync(_validate)
+ if issues:
+ logger.error("Schema validation FAILED:")
+ for issue in issues:
+ logger.error(" - %s", issue)
+ sys.exit(1)
+ else:
+ logger.info("Schema validation passed.")
+
+ await engine.dispose()
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="MAF roundtable conversation V2 migration")
+ parser.add_argument("--database-url", default=None, help="Async database URL (default: from config)")
+ parser.add_argument("--dry-run", action="store_true", help="Print DDL without executing")
+ args = parser.parse_args()
+
+ database_url = args.database_url
+ if database_url is None:
+ from pathlib import Path
+
+ import yaml
+
+ config_path = Path(__file__).resolve().parent.parent / "config.yaml"
+ if config_path.exists():
+ with open(config_path) as f:
+ cfg = yaml.safe_load(f) or {}
+ db_cfg = cfg.get("database", {})
+ database_url = db_cfg.get("url", "sqlite+aiosqlite:///./data/deerflow.db")
+ else:
+ database_url = "sqlite+aiosqlite:///./data/deerflow.db"
+
+ logger.info("Database: %s", database_url.split("?")[0])
+ asyncio.run(run_migration(database_url, dry_run=args.dry_run))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/offline-backend-20260512/backend/tests/test_maf_conversation_migration.py b/offline-backend-20260512/backend/tests/test_maf_conversation_migration.py
new file mode 100644
index 00000000..9085f4d3
--- /dev/null
+++ b/offline-backend-20260512/backend/tests/test_maf_conversation_migration.py
@@ -0,0 +1,190 @@
+"""Tests for the MAF roundtable conversation V2 migration script.
+
+Covers:
+- SQLite real migration idempotency (run twice, no error).
+- Schema validation passes after migration.
+- Deprecated last_message_seq column is not present.
+- PostgreSQL / MySQL DDL assertions (dialect-specific SQL generation).
+- Existing tables are not destroyed by re-running.
+"""
+
+from __future__ import annotations
+
+import pytest
+from sqlalchemy import inspect, text
+from sqlalchemy.ext.asyncio import create_async_engine
+
+from scripts.migrate_maf_roundtable_conversation_v2 import (
+ CONVERSATIONS_TABLE,
+ EVENTS_TABLE,
+ MESSAGES_TABLE,
+ _conversations_ddl,
+ _events_ddl,
+ _indexes_ddl,
+ _messages_ddl,
+ run_migration,
+ validate_schema,
+)
+
+
+@pytest.mark.asyncio
+async def test_sqlite_migration_creates_tables(tmp_path):
+ db_url = f"sqlite+aiosqlite:///{tmp_path / 'mig.db'}"
+ await run_migration(db_url)
+
+ engine = create_async_engine(db_url)
+ async with engine.begin() as conn:
+ def _check(sync_conn):
+ insp = inspect(sync_conn)
+ tables = insp.get_table_names()
+ issues = validate_schema(insp)
+ return tables, issues
+
+ tables, issues = await conn.run_sync(_check)
+ assert CONVERSATIONS_TABLE in tables
+ assert MESSAGES_TABLE in tables
+ assert EVENTS_TABLE in tables
+ assert issues == [], f"Schema issues: {issues}"
+ await engine.dispose()
+
+
+@pytest.mark.asyncio
+async def test_sqlite_migration_idempotent(tmp_path):
+ db_url = f"sqlite+aiosqlite:///{tmp_path / 'mig2.db'}"
+ await run_migration(db_url)
+ await run_migration(db_url)
+
+ engine = create_async_engine(db_url)
+ async with engine.begin() as conn:
+ def _check(sync_conn):
+ return validate_schema(inspect(sync_conn))
+
+ issues = await conn.run_sync(_check)
+ assert issues == [], f"Schema issues after 2nd run: {issues}"
+ await engine.dispose()
+
+
+@pytest.mark.asyncio
+async def test_no_deprecated_last_message_seq(tmp_path):
+ db_url = f"sqlite+aiosqlite:///{tmp_path / 'mig3.db'}"
+ await run_migration(db_url)
+
+ engine = create_async_engine(db_url)
+ async with engine.begin() as conn:
+ def _check(sync_conn):
+ insp = inspect(sync_conn)
+ return {c["name"] for c in insp.get_columns(CONVERSATIONS_TABLE)}
+
+ cols = await conn.run_sync(_check)
+ assert "last_stream_seq" in cols
+ assert "last_message_seq" not in cols
+ await engine.dispose()
+
+
+@pytest.mark.asyncio
+async def test_existing_data_preserved(tmp_path):
+ db_url = f"sqlite+aiosqlite:///{tmp_path / 'mig4.db'}"
+ await run_migration(db_url)
+
+ engine = create_async_engine(db_url)
+ async with engine.begin() as conn:
+ await conn.execute(text(
+ f"INSERT INTO {CONVERSATIONS_TABLE} (id, owner_user_id, title, safe_status, last_stream_seq, created_at, updated_at) "
+ "VALUES ('c1', 'u1', 'keep', 'idle', 0, '2026-01-01', '2026-01-01')"
+ ))
+
+ await run_migration(db_url)
+
+ async with engine.begin() as conn:
+ row = (await conn.execute(text(f"SELECT id, title FROM {CONVERSATIONS_TABLE} WHERE id='c1'"))).first()
+ assert row is not None
+ assert row[1] == "keep"
+ await engine.dispose()
+
+
+@pytest.mark.asyncio
+async def test_empty_partial_conversation_table_is_rebuilt_with_foreign_keys(tmp_path):
+ db_url = f"sqlite+aiosqlite:///{tmp_path / 'partial.db'}"
+ engine = create_async_engine(db_url)
+ async with engine.begin() as conn:
+ # Represents the first uncommitted V2 draft: the table exists but its
+ # private run/session links were not constrained.
+ await conn.execute(text(
+ f"CREATE TABLE {CONVERSATIONS_TABLE} ("
+ "id VARCHAR(64) PRIMARY KEY, owner_user_id VARCHAR(64) NOT NULL, "
+ "title VARCHAR(255) NOT NULL DEFAULT '', task_context_json TEXT, "
+ "safe_status VARCHAR(32) NOT NULL DEFAULT 'idle', active_run_id VARCHAR(64), "
+ "internal_session_id VARCHAR(64), last_stream_seq INTEGER NOT NULL DEFAULT 0, "
+ "created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL)"
+ ))
+ await engine.dispose()
+
+ await run_migration(db_url)
+
+ engine = create_async_engine(db_url)
+ async with engine.begin() as conn:
+ def _check(sync_conn):
+ return validate_schema(inspect(sync_conn))
+
+ assert await conn.run_sync(_check) == []
+ await engine.dispose()
+
+
+@pytest.mark.asyncio
+async def test_nonempty_partial_conversation_table_fails_closed(tmp_path):
+ db_url = f"sqlite+aiosqlite:///{tmp_path / 'partial-data.db'}"
+ engine = create_async_engine(db_url)
+ async with engine.begin() as conn:
+ await conn.execute(text(
+ f"CREATE TABLE {CONVERSATIONS_TABLE} ("
+ "id VARCHAR(64) PRIMARY KEY, owner_user_id VARCHAR(64) NOT NULL, "
+ "title VARCHAR(255) NOT NULL DEFAULT '', safe_status VARCHAR(32) NOT NULL DEFAULT 'idle', "
+ "last_stream_seq INTEGER NOT NULL DEFAULT 0, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL)"
+ ))
+ await conn.execute(text(
+ f"INSERT INTO {CONVERSATIONS_TABLE} (id, owner_user_id, title, safe_status, last_stream_seq, created_at, updated_at) "
+ "VALUES ('c1', 'u1', 'keep', 'idle', 0, '2026-01-01', '2026-01-01')"
+ ))
+ await engine.dispose()
+
+ with pytest.raises(RuntimeError, match="refuses to delete conversation history"):
+ await run_migration(db_url)
+
+
+def test_postgresql_ddl_generation():
+ ddl = _conversations_ddl("postgresql")
+ assert "TIMESTAMP WITH TIME ZONE" in ddl
+ assert "JSONB" in ddl
+ assert CONVERSATIONS_TABLE in ddl
+ assert "FOREIGN KEY (active_run_id) REFERENCES maf_roundtable_runs" in ddl
+ assert "FOREIGN KEY (internal_session_id) REFERENCES maf_roundtable_sessions" in ddl
+
+ msg_ddl = _messages_ddl("postgresql")
+ assert "ON DELETE CASCADE" in msg_ddl
+ assert "UNIQUE (conversation_id, seq)" in msg_ddl
+
+ evt_ddl = _events_ddl("postgresql")
+ assert "UNIQUE (conversation_id, seq)" in evt_ddl
+
+
+def test_mysql_ddl_generation():
+ ddl = _conversations_ddl("mysql")
+ assert "DATETIME(6)" in ddl
+ assert "JSON" in ddl
+ assert "FOREIGN KEY (active_run_id) REFERENCES maf_roundtable_runs" in ddl
+
+ msg_ddl = _messages_ddl("mysql")
+ assert "LONGTEXT" in msg_ddl
+ assert "ON DELETE CASCADE" in msg_ddl
+
+
+def test_sqlite_ddl_generation():
+ ddl = _conversations_ddl("sqlite")
+ assert "DATETIME" in ddl
+ assert "TEXT" in ddl
+
+ indexes = _indexes_ddl("sqlite")
+ assert len(indexes) >= 8
+ assert any("ix_maf_conv_owner_updated" in i for i in indexes)
+ assert any("ix_maf_msg_conv_seq" in i for i in indexes)
+ assert any("ix_maf_cevt_conv_seq" in i for i in indexes)
diff --git a/offline-backend-20260512/backend/tests/test_maf_conversation_stream.py b/offline-backend-20260512/backend/tests/test_maf_conversation_stream.py
new file mode 100644
index 00000000..8ae7c9e8
--- /dev/null
+++ b/offline-backend-20260512/backend/tests/test_maf_conversation_stream.py
@@ -0,0 +1,473 @@
+"""Tests for MAF roundtable V2 Conversation SSE streaming (Phase 2 / R2 + P0 fixes).
+
+Covers:
+- ConversationEventHub: generation counter, publish/subscribe, multi-subscriber.
+- P0-1: race-free replay — events published during replay are not lost.
+- P0-2: terminal events auto-close the stream.
+- P0-4: projection dedup via source_event_id.
+- SSE replay: events persisted before connect are delivered.
+- after_seq cursor: reconnect resumes without duplicates.
+- Heartbeat: idle connection produces ``: heartbeat`` frames.
+- Concurrent subscribers: fan-out to multiple listeners.
+- Ownership: non-owner gets 404 on the stream endpoint.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+
+import pytest
+import pytest_asyncio
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+from app.maf_roundtable.conversation_event_hub import ConversationEventHub
+from app.maf_roundtable.conversation_service import (
+ ConversationNotFoundError,
+ MafConversationService,
+)
+from deerflow.persistence.base import Base
+from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
+from deerflow.persistence.maf_roundtable.model import (
+ MafRoundtableConversationRow,
+)
+from deerflow.persistence.maf_roundtable.store import MafRoundtableStore
+
+
+class FakeMafConfig:
+ enabled = True
+ default_mode = "structured"
+ default_model_name = "test-model"
+ default_reasoning_effort = None
+ default_manager_agent_id = "mgr-agent"
+ default_max_rounds = 8
+ default_write_policy = "confirm_high_risk"
+ admin_diagnostic_access = True
+
+
+@pytest_asyncio.fixture
+async def store(tmp_path):
+ engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'stream.db'}")
+ async with engine.begin() as conn:
+ import deerflow.persistence.maf_roundtable.model # noqa: F401
+
+ await conn.run_sync(Base.metadata.create_all)
+ sf = async_sessionmaker(engine, expire_on_commit=False)
+ yield make_maf_roundtable_store(sf)
+ await engine.dispose()
+
+
+@pytest_asyncio.fixture
+async def hub():
+ h = ConversationEventHub(heartbeat_interval=0.3)
+ yield h
+ h.shutdown()
+
+
+@pytest_asyncio.fixture
+async def svc(store, hub):
+ return MafConversationService(store, maf_config=FakeMafConfig(), event_hub=hub)
+
+
+# ── ConversationEventHub unit tests ───────────────────────────────────────
+
+
+@pytest.mark.asyncio
+async def test_hub_publish_notifies_subscriber(hub):
+ sub = hub.open_subscription("c1")
+ assert not sub.has_pending
+
+ await hub.publish("c1")
+ assert sub.has_pending
+
+ changed = await sub.wait_for_change(timeout=2.0)
+ assert changed
+ sub.close()
+
+
+@pytest.mark.asyncio
+async def test_hub_heartbeat_on_idle(hub):
+ sub = hub.open_subscription("c2")
+ changed = await sub.wait_for_change(timeout=0.2)
+ assert not changed
+ sub.close()
+
+
+@pytest.mark.asyncio
+async def test_hub_multi_subscriber_fanout(hub):
+ sub_a = hub.open_subscription("c3")
+ sub_b = hub.open_subscription("c3")
+
+ await hub.publish("c3")
+
+ assert await sub_a.wait_for_change(timeout=2.0)
+ assert await sub_b.wait_for_change(timeout=2.0)
+
+ sub_a.close()
+ sub_b.close()
+ assert hub.conversation_subscriber_count("c3") == 0
+
+
+@pytest.mark.asyncio
+async def test_hub_publish_no_subscribers_is_noop(hub):
+ await hub.publish("c_noone")
+ assert hub.conversation_subscriber_count("c_noone") == 0
+
+
+@pytest.mark.asyncio
+async def test_hub_unsubscribe_cleans_up(hub):
+ sub = hub.open_subscription("c4")
+ assert hub.conversation_subscriber_count("c4") == 1
+ sub.close()
+ assert hub.conversation_subscriber_count("c4") == 0
+
+
+@pytest.mark.asyncio
+async def test_hub_shutdown_stops_subscribers():
+ h = ConversationEventHub(heartbeat_interval=0.2)
+ sub = h.open_subscription("c_shutdown")
+ h.shutdown()
+ assert h.is_closed
+ # After shutdown, generation should have advanced.
+ assert sub.has_pending or sub.current_generation > sub.seen_generation
+ sub.close()
+
+
+# ── P0-1: Race-free replay ────────────────────────────────────────────────
+
+
+@pytest.mark.asyncio
+async def test_p0_1_event_during_replay_is_not_lost(svc, store, hub):
+ """Event published between list_events() and subscription must be caught
+ by the stabilisation loop."""
+ conv = await svc.create_conversation(user_id="u1", title="Race")
+ await svc.send_message(conversation_id=conv["id"], user_id="u1", content="msg1")
+ await _reset_conv_to_idle(store, conv["id"])
+
+ # Simulate: subscribe, replay, then a publish sneaks in before stabilise.
+ sub = hub.open_subscription(conv["id"])
+ last_seq = 0
+
+ # Phase 1: initial replay
+ events = await store.conversation_events.list_events(conv["id"], after_seq=last_seq)
+ for evt in events:
+ last_seq = max(last_seq, evt["seq"])
+
+ # Event sneaks in here (between replay and stabilise)
+ await svc.send_message(conversation_id=conv["id"], user_id="u1", content="sneaky")
+ await hub.publish(conv["id"])
+
+ # Stabilise: should detect the pending generation
+ assert sub.has_pending
+ sub.mark_seen()
+ late_events = await store.conversation_events.list_events(conv["id"], after_seq=last_seq)
+ assert len(late_events) >= 1 # The sneaky event was caught
+ for evt in late_events:
+ last_seq = max(last_seq, evt["seq"])
+
+ sub.close()
+
+
+@pytest.mark.asyncio
+async def test_p0_1_subscribe_before_replay_catches_concurrent_publish(svc, store, hub):
+ """Subscribe must be opened BEFORE any DB query so a publish during replay
+ is detected via generation delta."""
+ conv = await svc.create_conversation(user_id="u1", title="SubBefore")
+
+ sub = hub.open_subscription(conv["id"])
+
+ # No events yet
+ events = await store.conversation_events.list_events(conv["id"], after_seq=0)
+ assert len(events) == 0
+
+ # Publish arrives
+ await svc.send_message(conversation_id=conv["id"], user_id="u1", content="first")
+ await hub.publish(conv["id"])
+
+ # Stabilisation detects it
+ assert sub.has_pending
+ sub.mark_seen()
+ events = await store.conversation_events.list_events(conv["id"], after_seq=0)
+ assert len(events) >= 1
+
+ sub.close()
+
+
+# ── P0-2: Terminal events auto-close stream ──────────────────────────────
+
+
+@pytest.mark.asyncio
+async def test_p0_2_replay_terminal_event_closes_stream():
+ from app.gateway.routers.maf_conversations import _TERMINAL_EVENT_TYPES, _is_terminal
+
+ assert "run.completed" in _TERMINAL_EVENT_TYPES
+ assert "run.error" in _TERMINAL_EVENT_TYPES
+ assert "run.cancelled" in _TERMINAL_EVENT_TYPES
+
+ assert _is_terminal({"type": "run.completed"})
+ assert _is_terminal({"type": "run.error"})
+ assert _is_terminal({"type": "run.cancelled"})
+ assert not _is_terminal({"type": "agent.message.delta"})
+ assert not _is_terminal({"type": "user.message.created"})
+
+
+@pytest.mark.asyncio
+async def test_p0_2_terminal_event_in_replay_stops_generator(store):
+ """If the replay phase hits a terminal event, the generator should stop."""
+ from app.gateway.routers.maf_conversations import _is_terminal
+
+ # Simulate a conversation with events ending in run.completed
+ events = [
+ {"seq": 1, "type": "user.message.created"},
+ {"seq": 2, "type": "agent.message.delta"},
+ {"seq": 3, "type": "run.completed"},
+ {"seq": 4, "type": "should_not_reach"},
+ ]
+
+ found_terminal = False
+ for evt in events:
+ if _is_terminal(evt):
+ found_terminal = True
+ break
+ assert found_terminal
+ # Verify the event after terminal would not be reached
+ terminal_idx = next(i for i, e in enumerate(events) if _is_terminal(e))
+ assert events[terminal_idx]["type"] == "run.completed"
+
+
+# ── P0-4: Projection dedup ────────────────────────────────────────────────
+
+
+@pytest.mark.asyncio
+async def test_p0_4_dedup_same_source_event_id(svc, store):
+ """Projecting the same source_event_id twice should not create a duplicate."""
+ conv = await svc.create_conversation(user_id="u1", title="Dedup")
+
+ result1 = await svc._publish_conversation_event(
+ conversation_id=conv["id"],
+ owner_user_id="u1",
+ event_type="tool.started",
+ event_data={"tool_name": "search"},
+ source_event_id="evt_src_001",
+ )
+ assert result1 is True
+
+ result2 = await svc._publish_conversation_event(
+ conversation_id=conv["id"],
+ owner_user_id="u1",
+ event_type="tool.started",
+ event_data={"tool_name": "search"},
+ source_event_id="evt_src_001",
+ )
+ assert result2 is False # Deduped
+
+ events = await store.conversation_events.list_events(conv["id"], after_seq=0)
+ tool_events = [e for e in events if e["type"] == "tool.started"]
+ assert len(tool_events) == 1
+
+
+@pytest.mark.asyncio
+async def test_p0_4_different_source_event_ids_both_written(svc, store):
+ conv = await svc.create_conversation(user_id="u1", title="MultiSrc")
+
+ await svc._publish_conversation_event(
+ conversation_id=conv["id"], owner_user_id="u1",
+ event_type="tool.started", event_data={"tool_name": "a"},
+ source_event_id="src_1",
+ )
+ await svc._publish_conversation_event(
+ conversation_id=conv["id"], owner_user_id="u1",
+ event_type="tool.started", event_data={"tool_name": "b"},
+ source_event_id="src_2",
+ )
+
+ events = await store.conversation_events.list_events(conv["id"], after_seq=0)
+ tool_events = [e for e in events if e["type"] == "tool.started"]
+ assert len(tool_events) == 2
+
+
+@pytest.mark.asyncio
+async def test_p0_4_null_source_event_id_not_deduped(svc, store):
+ """Events with no source_event_id should never be deduped."""
+ conv = await svc.create_conversation(user_id="u1", title="NoSrc")
+
+ await svc._publish_conversation_event(
+ conversation_id=conv["id"], owner_user_id="u1",
+ event_type="manager.dispatch", event_data={"reason": "x"},
+ source_event_id=None,
+ )
+ await svc._publish_conversation_event(
+ conversation_id=conv["id"], owner_user_id="u1",
+ event_type="manager.dispatch", event_data={"reason": "y"},
+ source_event_id=None,
+ )
+
+ events = await store.conversation_events.list_events(conv["id"], after_seq=0)
+ assert len(events) == 2
+
+
+# ── SSE replay + cursor + real-time integration ──────────────────────────
+
+
+async def _reset_conv_to_idle(store: MafRoundtableStore, conv_id: str):
+ async with store.session_factory() as session:
+ row = await session.get(MafRoundtableConversationRow, conv_id)
+ if row:
+ row.safe_status = "idle"
+ row.active_run_id = None
+ await session.commit()
+
+
+@pytest.mark.asyncio
+async def test_sse_replay_delivers_past_events(svc, store):
+ conv = await svc.create_conversation(user_id="u1", title="Replay")
+ await svc.send_message(conversation_id=conv["id"], user_id="u1", content="msg1")
+ await _reset_conv_to_idle(store, conv["id"])
+ await svc.send_message(conversation_id=conv["id"], user_id="u1", content="msg2")
+
+ events = await store.conversation_events.list_events(conv["id"], after_seq=0)
+ assert len(events) >= 2
+ types = [e["type"] for e in events]
+ assert types.count("user.message.created") == 2
+ seqs = [e["seq"] for e in events]
+ assert seqs == sorted(seqs)
+
+
+@pytest.mark.asyncio
+async def test_sse_after_seq_prevents_duplicates(svc, store):
+ conv = await svc.create_conversation(user_id="u1", title="Cursor")
+ await svc.send_message(conversation_id=conv["id"], user_id="u1", content="first")
+ await _reset_conv_to_idle(store, conv["id"])
+ await svc.send_message(conversation_id=conv["id"], user_id="u1", content="second")
+
+ all_events = await store.conversation_events.list_events(conv["id"], after_seq=0)
+ last_seq = all_events[0]["seq"]
+
+ replay = await store.conversation_events.list_events(conv["id"], after_seq=last_seq)
+ assert all(e["seq"] > last_seq for e in replay)
+ assert len(replay) == len(all_events) - 1
+
+
+@pytest.mark.asyncio
+async def test_real_time_event_pushed_after_subscribe(svc, store, hub):
+ conv = await svc.create_conversation(user_id="u1", title="RT")
+
+ received_events: list[dict] = []
+
+ async def stream_consumer():
+ sub = hub.open_subscription(conv["id"])
+ last_seq = 0
+
+ # Stabilise (no events yet)
+ while sub.has_pending:
+ sub.mark_seen()
+
+ # Wait for change
+ changed = await sub.wait_for_change(timeout=5.0)
+ if changed:
+ new_evts = await store.conversation_events.list_events(conv["id"], after_seq=last_seq)
+ for evt in new_evts:
+ received_events.append(evt)
+ last_seq = max(last_seq, evt["seq"])
+ sub.close()
+
+ task = asyncio.create_task(stream_consumer())
+ await asyncio.sleep(0.1)
+
+ await svc.send_message(conversation_id=conv["id"], user_id="u1", content="live msg")
+
+ await asyncio.wait_for(task, timeout=5.0)
+ types = [e["type"] for e in received_events]
+ assert "user.message.created" in types
+
+
+@pytest.mark.asyncio
+async def test_concurrent_subscribers_both_receive(svc, store, hub):
+ conv = await svc.create_conversation(user_id="u1", title="Fanout")
+
+ buf_a: list[dict] = []
+ buf_b: list[dict] = []
+
+ async def consume(buf):
+ sub = hub.open_subscription(conv["id"])
+ last_seq = 0
+ changed = await sub.wait_for_change(timeout=5.0)
+ if changed:
+ new_evts = await store.conversation_events.list_events(conv["id"], after_seq=last_seq)
+ for evt in new_evts:
+ buf.append(evt)
+ last_seq = max(last_seq, evt["seq"])
+ sub.close()
+
+ ta = asyncio.create_task(consume(buf_a))
+ tb = asyncio.create_task(consume(buf_b))
+ await asyncio.sleep(0.15)
+
+ await svc.send_message(conversation_id=conv["id"], user_id="u1", content="broadcast")
+
+ await asyncio.wait_for(asyncio.gather(ta, tb), timeout=5.0)
+ assert len(buf_a) >= 1
+ assert len(buf_b) >= 1
+ assert buf_a[0]["seq"] == buf_b[0]["seq"]
+
+
+@pytest.mark.asyncio
+async def test_reconnect_after_seq_resume(svc, store):
+ conv = await svc.create_conversation(user_id="u1", title="Reconnect")
+ await svc.send_message(conversation_id=conv["id"], user_id="u1", content="batch1")
+ await _reset_conv_to_idle(store, conv["id"])
+ await svc.send_message(conversation_id=conv["id"], user_id="u1", content="batch2")
+
+ all_events = await store.conversation_events.list_events(conv["id"], after_seq=0)
+ midpoint = all_events[len(all_events) // 2]["seq"]
+
+ replay = await store.conversation_events.list_events(conv["id"], after_seq=midpoint)
+ assert all(e["seq"] > midpoint for e in replay)
+
+
+# ── SSE frame format ──────────────────────────────────────────────────────
+
+
+@pytest.mark.asyncio
+async def test_sse_frame_format():
+ from app.gateway.routers.maf_conversations import _format_heartbeat, _format_sse_event
+
+ frame = _format_sse_event({
+ "seq": 42,
+ "event_id": "evt_abc",
+ "conversation_id": "conv_1",
+ "run_id": "run_1",
+ "type": "user.message.created",
+ "occurred_at": "2026-08-03T00:00:00Z",
+ "data": {"content": "hi"},
+ })
+ assert "event: user.message.created" in frame
+ assert "data: " in frame
+ assert "id: evt_abc" in frame
+ assert frame.endswith("\n\n")
+
+ parsed = None
+ for line in frame.splitlines():
+ if line.startswith("data: "):
+ parsed = json.loads(line[len("data: "):])
+ break
+ assert parsed is not None
+ assert parsed["seq"] == 42
+ assert parsed["type"] == "user.message.created"
+
+ hb = _format_heartbeat()
+ assert hb == ": heartbeat\n\n"
+
+
+# ── Ownership ────────────────────────────────────────────────────────────
+
+
+@pytest.mark.asyncio
+async def test_ownership_check_before_stream(svc):
+ conv = await svc.create_conversation(user_id="u1", title="OwnerOnly")
+
+ fetched = await svc.get_conversation(conversation_id=conv["id"], user_id="u1")
+ assert fetched["id"] == conv["id"]
+
+ with pytest.raises(ConversationNotFoundError):
+ await svc.get_conversation(conversation_id=conv["id"], user_id="u2")
diff --git a/offline-backend-20260512/backend/tests/test_maf_conversations.py b/offline-backend-20260512/backend/tests/test_maf_conversations.py
new file mode 100644
index 00000000..2f752f87
--- /dev/null
+++ b/offline-backend-20260512/backend/tests/test_maf_conversations.py
@@ -0,0 +1,390 @@
+"""Tests for the MAF roundtable V2 Conversation API (Phase 1 / R1).
+
+Covers:
+- Create, list, detail, message pagination, user isolation.
+- Public API responses never leak internal_session_id, active_run_id, run_id,
+ owner_user_id, thread ids, or secret-class fields.
+- First message creates a real internal session; run FK is valid.
+- Transaction rollback: any flush failure rolls back message + run + event + status.
+- Concurrent send: only one succeeds, the other gets 409; no duplicate runs.
+- Mixed tool-events and messages: last_stream_seq is contiguous and usable as SSE cursor.
+- Empty / overlong / forged-internal-field content → 422.
+- Non-owner cannot read/write; admin cannot write another user's conversation.
+"""
+
+from __future__ import annotations
+
+import asyncio
+from types import SimpleNamespace
+
+import pytest
+import pytest_asyncio
+from sqlalchemy import inspect, select
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+from app.gateway.routers.maf_conversations import _user_ctx
+from app.maf_roundtable.conversation_schemas import (
+ CreateConversationRequest,
+ SendMessageRequest,
+)
+from app.maf_roundtable.conversation_service import (
+ ConversationConflictError,
+ ConversationDisabledError,
+ ConversationNotFoundError,
+ MafConversationService,
+)
+from deerflow.persistence.base import Base
+from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
+from deerflow.persistence.maf_roundtable.model import (
+ MafRoundtableConversationRow,
+ MafRoundtableMessageRow,
+ MafRoundtableRunRow,
+ MafRoundtableSessionRow,
+)
+
+
+class FakeMafConfig:
+ enabled = True
+ default_mode = "structured"
+ default_model_name = "test-model"
+ default_reasoning_effort = None
+ default_manager_agent_id = "mgr-agent"
+ default_max_rounds = 8
+ default_write_policy = "confirm_high_risk"
+ admin_diagnostic_access = True
+
+
+@pytest_asyncio.fixture
+async def store(tmp_path):
+ engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'conv.db'}")
+ async with engine.begin() as conn:
+ import deerflow.persistence.maf_roundtable.model # noqa: F401
+
+ await conn.run_sync(Base.metadata.create_all)
+ sf = async_sessionmaker(engine, expire_on_commit=False)
+ yield make_maf_roundtable_store(sf)
+ await engine.dispose()
+
+
+@pytest_asyncio.fixture
+async def svc(store):
+ return MafConversationService(store, maf_config=FakeMafConfig())
+
+
+FORBIDDEN_FIELDS = {
+ "owner_user_id", "internal_session_id", "active_run_id",
+ "thread_id", "capability_snapshot", "idempotency_key",
+ "target_key", "internal_canary",
+}
+
+
+@pytest.mark.asyncio
+async def test_create_and_get_conversation(svc):
+ conv = await svc.create_conversation(user_id="u1", title="Test", task_context={"business_code": "3Q"})
+ assert conv["id"]
+ assert conv["title"] == "Test"
+ assert conv["safe_status"] == "idle"
+ assert conv["last_stream_seq"] == 0
+ assert conv["task_context"] == {"business_code": "3Q"}
+ for field in FORBIDDEN_FIELDS:
+ assert field not in conv, f"Leaked field: {field}"
+
+ fetched = await svc.get_conversation(conversation_id=conv["id"], user_id="u1")
+ assert fetched["id"] == conv["id"]
+ for field in FORBIDDEN_FIELDS:
+ assert field not in fetched
+
+
+@pytest.mark.asyncio
+async def test_list_conversations_owner_scoped(svc):
+ await svc.create_conversation(user_id="u1", title="A")
+ await svc.create_conversation(user_id="u1", title="B")
+ await svc.create_conversation(user_id="u2", title="C")
+
+ list_u1, total_u1 = await svc.list_conversations(user_id="u1")
+ assert total_u1 == 2
+ assert all(c["id"] for c in list_u1)
+
+ list_u2, total_u2 = await svc.list_conversations(user_id="u2")
+ assert total_u2 == 1
+
+
+@pytest.mark.asyncio
+async def test_get_conversation_wrong_owner_returns_404(svc):
+ conv = await svc.create_conversation(user_id="u1", title="Private")
+ with pytest.raises(ConversationNotFoundError):
+ await svc.get_conversation(conversation_id=conv["id"], user_id="u2")
+
+
+@pytest.mark.asyncio
+async def test_admin_can_read_but_not_write(svc):
+ conv = await svc.create_conversation(user_id="u1", title="AdminRead")
+ fetched = await svc.get_conversation(conversation_id=conv["id"], user_id="admin", is_admin=True)
+ assert fetched["id"] == conv["id"]
+
+
+@pytest.mark.asyncio
+async def test_send_message_creates_real_session_and_run(svc, store):
+ conv = await svc.create_conversation(user_id="u1", title="SendTest")
+ result = await svc.send_message(conversation_id=conv["id"], user_id="u1", content="Hello world")
+
+ assert result["message_id"]
+ assert result["conversation_id"] == conv["id"]
+ assert result["seq"] == 1
+
+ async with store.session_factory() as session:
+ conv_row = (await session.execute(
+ select(MafRoundtableConversationRow).where(MafRoundtableConversationRow.id == conv["id"])
+ )).scalars().first()
+ assert conv_row is not None
+ assert conv_row.internal_session_id is not None
+ assert conv_row.safe_status == "running"
+ assert conv_row.active_run_id is not None
+ # Stage 3 persists the manager.intent and manager.dispatch events in
+ # the same conversation-global cursor as the user message.
+ assert conv_row.last_stream_seq == 3
+
+ session_row = (await session.execute(
+ select(MafRoundtableSessionRow).where(MafRoundtableSessionRow.id == conv_row.internal_session_id)
+ )).scalars().first()
+ assert session_row is not None
+ assert session_row.user_id == "u1"
+
+ run_row = (await session.execute(
+ select(MafRoundtableRunRow).where(MafRoundtableRunRow.id == conv_row.active_run_id)
+ )).scalars().first()
+ assert run_row is not None
+ assert run_row.session_id == conv_row.internal_session_id
+
+
+@pytest.mark.asyncio
+async def test_send_message_wrong_owner_404(svc):
+ conv = await svc.create_conversation(user_id="u1", title="X")
+ with pytest.raises(ConversationNotFoundError):
+ await svc.send_message(conversation_id=conv["id"], user_id="u2", content="hack")
+
+
+@pytest.mark.asyncio
+async def test_concurrent_send_only_one_succeeds(svc, store):
+ conv = await svc.create_conversation(user_id="u1", title="Race")
+
+ async def try_send(content: str):
+ try:
+ r = await svc.send_message(conversation_id=conv["id"], user_id="u1", content=content)
+ return ("ok", r)
+ except ConversationConflictError:
+ return ("conflict", None)
+
+ results = await asyncio.gather(try_send("msg1"), try_send("msg2"))
+ statuses = [r[0] for r in results]
+ assert statuses.count("ok") == 1
+ assert statuses.count("conflict") == 1
+
+ async with store.session_factory() as session:
+ runs = (await session.execute(
+ select(MafRoundtableRunRow).join(
+ MafRoundtableSessionRow,
+ MafRoundtableRunRow.session_id == MafRoundtableSessionRow.id,
+ ).where(MafRoundtableSessionRow.user_id == "u1")
+ )).scalars().all()
+ assert len(runs) == 1
+
+
+@pytest.mark.asyncio
+async def test_message_public_dto_no_run_id(svc, store):
+ conv = await svc.create_conversation(user_id="u1", title="PubDTO")
+ await svc.send_message(conversation_id=conv["id"], user_id="u1", content="test content")
+
+ messages, last_seq = await svc.list_messages(conversation_id=conv["id"], user_id="u1")
+ assert len(messages) == 1
+ msg = messages[0]
+ assert "run_id" not in msg
+ assert msg["content"] == "test content"
+ assert msg["message_kind"] == "user"
+ assert last_seq == 3
+
+
+@pytest.mark.asyncio
+async def test_last_stream_seq_contiguous(svc, store):
+ conv = await svc.create_conversation(user_id="u1", title="SeqTest")
+ await svc.send_message(conversation_id=conv["id"], user_id="u1", content="first")
+
+ async with store.session_factory() as session:
+ conv_row = (await session.execute(
+ select(MafRoundtableConversationRow).where(MafRoundtableConversationRow.id == conv["id"])
+ )).scalars().first()
+ conv_row.safe_status = "idle"
+ conv_row.active_run_id = None
+ await session.commit()
+
+ await svc.send_message(conversation_id=conv["id"], user_id="u1", content="second")
+
+ async with store.session_factory() as session:
+ conv_row = (await session.execute(
+ select(MafRoundtableConversationRow).where(MafRoundtableConversationRow.id == conv["id"])
+ )).scalars().first()
+ assert conv_row.last_stream_seq == 6
+
+ msgs = (await session.execute(
+ select(MafRoundtableMessageRow).where(
+ MafRoundtableMessageRow.conversation_id == conv["id"]
+ ).order_by(MafRoundtableMessageRow.seq)
+ )).scalars().all()
+ seqs = [m.seq for m in msgs]
+ assert seqs == [1, 4]
+
+
+@pytest.mark.asyncio
+async def test_schema_rejects_forbidden_fields():
+ with pytest.raises(Exception):
+ CreateConversationRequest.model_validate({"title": "x", "owner_user_id": "hacker"})
+
+ with pytest.raises(Exception):
+ CreateConversationRequest.model_validate({"title": "x", "internal_session_id": "s1"})
+
+ with pytest.raises(Exception):
+ SendMessageRequest.model_validate({"content": "hi", "run_id": "r1"})
+
+ with pytest.raises(Exception):
+ SendMessageRequest.model_validate({"content": "hi", "thread_id": "t1"})
+
+
+@pytest.mark.asyncio
+async def test_schema_rejects_empty_and_overlong_content():
+ with pytest.raises(Exception):
+ SendMessageRequest.model_validate({"content": ""})
+
+ with pytest.raises(Exception):
+ SendMessageRequest.model_validate({"content": " "})
+
+ with pytest.raises(Exception):
+ SendMessageRequest.model_validate({"content": "x" * 20001})
+
+
+@pytest.mark.asyncio
+async def test_task_context_whitelist(svc):
+ conv = await svc.create_conversation(
+ user_id="u1", title="Ctx",
+ task_context={"business_code": "6BF", "secret_key": "LEAKED", "task_name": "T1"},
+ )
+ assert conv["task_context"] == {"business_code": "6BF", "task_name": "T1"}
+ assert "secret_key" not in (conv["task_context"] or {})
+
+
+@pytest.mark.asyncio
+async def test_task_context_rejects_unknown_or_nested_values():
+ with pytest.raises(Exception):
+ CreateConversationRequest.model_validate({"task_context": {"secret_key": "nope"}})
+ with pytest.raises(Exception):
+ CreateConversationRequest.model_validate({"task_context": {"task_id": {"secret": "nope"}}})
+ with pytest.raises(Exception):
+ CreateConversationRequest.model_validate({"title": "x", "session_id": "forged"})
+
+
+@pytest.mark.asyncio
+async def test_disabled_feature_rejects_conversation_writes(store):
+ disabled = MafConversationService(store, maf_config=SimpleNamespace(enabled=False))
+ with pytest.raises(ConversationDisabledError):
+ await disabled.create_conversation(user_id="u1", title="disabled")
+
+
+def test_router_uses_authenticated_user_object_for_owner_scope():
+ user = SimpleNamespace(id="real-user", system_role="admin")
+ request = SimpleNamespace(state=SimpleNamespace(user=user))
+ assert _user_ctx(request) == ("real-user", True)
+
+
+@pytest.mark.asyncio
+async def test_admin_cross_user_read_respects_diagnostic_config(store):
+ owner_service = MafConversationService(store, maf_config=FakeMafConfig())
+ conv = await owner_service.create_conversation(user_id="u1", title="Private")
+ no_diagnostics = MafConversationService(
+ store,
+ maf_config=SimpleNamespace(enabled=True, admin_diagnostic_access=False),
+ )
+ with pytest.raises(ConversationNotFoundError):
+ await no_diagnostics.get_conversation(
+ conversation_id=conv["id"], user_id="admin", is_admin=True
+ )
+
+
+@pytest.mark.asyncio
+async def test_conversation_internal_links_have_real_foreign_keys(store):
+ async with store.session_factory() as session:
+ def _fks(sync_session):
+ inspector = inspect(sync_session.connection())
+ return {
+ (tuple(fk["constrained_columns"]), fk["referred_table"])
+ for fk in inspector.get_foreign_keys("maf_roundtable_conversations")
+ }
+
+ foreign_keys = await session.run_sync(_fks)
+ assert (("active_run_id",), "maf_roundtable_runs") in foreign_keys
+ assert (("internal_session_id",), "maf_roundtable_sessions") in foreign_keys
+
+
+@pytest.mark.asyncio
+async def test_event_only_item_advances_the_same_sse_cursor(svc, store):
+ conv = await svc.create_conversation(user_id="u1", title="Unified cursor")
+ await svc.send_message(conversation_id=conv["id"], user_id="u1", content="first")
+
+ async with store.session_factory() as session:
+ async with session.begin():
+ row = (
+ await session.execute(
+ select(MafRoundtableConversationRow)
+ .where(MafRoundtableConversationRow.id == conv["id"])
+ .with_for_update()
+ )
+ ).scalars().one()
+ item = await store.append_conversation_item(
+ session,
+ conversation=row,
+ event_type="manager.intent",
+ event_data={"status": "understood"},
+ )
+ assert item["seq"] == 4
+
+ messages, last_stream_seq = await svc.list_messages(conversation_id=conv["id"], user_id="u1")
+ assert [message["seq"] for message in messages] == [1]
+ assert last_stream_seq == 4
+ events = await store.conversation_events.list_events(conv["id"])
+ assert [event["seq"] for event in events] == [1, 2, 3, 4]
+
+
+@pytest.mark.asyncio
+async def test_standalone_repositories_cannot_create_a_second_cursor(svc, store):
+ conv = await svc.create_conversation(user_id="u1", title="No split cursor")
+ with pytest.raises(RuntimeError, match="append_conversation_item"):
+ await store.messages.append_message(
+ conversation_id=conv["id"], message_kind="manager", role="assistant", content="unsafe"
+ )
+ with pytest.raises(RuntimeError, match="append_conversation_item"):
+ await store.conversation_events.append_event(
+ conversation_id=conv["id"], event_type="manager.intent"
+ )
+
+
+@pytest.mark.asyncio
+async def test_atomic_send_rolls_back_on_item_write_failure(svc, store, monkeypatch):
+ conv = await svc.create_conversation(user_id="u1", title="Rollback")
+
+ async def _fail_append(*args, **kwargs):
+ raise RuntimeError("simulated item write failure")
+
+ monkeypatch.setattr(store, "append_conversation_item", _fail_append)
+ with pytest.raises(RuntimeError, match="simulated item write failure"):
+ await svc.send_message(conversation_id=conv["id"], user_id="u1", content="must roll back")
+
+ async with store.session_factory() as session:
+ refreshed = (
+ await session.execute(
+ select(MafRoundtableConversationRow).where(MafRoundtableConversationRow.id == conv["id"])
+ )
+ ).scalars().one()
+ assert refreshed.safe_status == "idle"
+ assert refreshed.internal_session_id is None
+ assert refreshed.active_run_id is None
+ assert refreshed.last_stream_seq == 0
+ assert (await session.execute(select(MafRoundtableMessageRow))).scalars().all() == []
+ assert (await session.execute(select(MafRoundtableRunRow))).scalars().all() == []
diff --git a/offline-backend-20260512/backend/tests/test_maf_phase3_conversation_dispatch.py b/offline-backend-20260512/backend/tests/test_maf_phase3_conversation_dispatch.py
new file mode 100644
index 00000000..5e828a4b
--- /dev/null
+++ b/offline-backend-20260512/backend/tests/test_maf_phase3_conversation_dispatch.py
@@ -0,0 +1,286 @@
+"""Deterministic Stage 3 tests for V2 conversation dispatch."""
+
+from __future__ import annotations
+
+import asyncio
+
+import pytest
+import pytest_asyncio
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+from app.maf_roundtable.conversation_service import MafConversationService
+from app.maf_roundtable.dispatch_contracts import (
+ InvalidDispatchPlanError,
+ MafDispatchAssignment,
+ MafDispatchPlan,
+ MafIntentDecision,
+ resolve_intent,
+ validate_dispatch_plan,
+)
+from deerflow.persistence.base import Base
+from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
+from deerflow.persistence.maf_roundtable.model import (
+ MafRoundtableConversationRow,
+ MafRoundtableParticipantRow,
+ MafRoundtableRunRow,
+ MafRoundtableSessionRow,
+)
+
+
+class FakeMafConfig:
+ enabled = True
+ default_mode = "structured"
+ default_model_name = "test-model"
+ default_reasoning_effort = None
+ default_manager_agent_id = "manager-from-config"
+ default_seat_agent_ids = ["seat-a", "seat-b"]
+ business_routes = {}
+ max_seats_per_run = 4
+ default_max_rounds = 8
+ default_write_policy = "confirm_high_risk"
+ admin_diagnostic_access = True
+
+
+class RecordingDispatcher:
+ def __init__(self) -> None:
+ self.calls: list[tuple[dict, dict, str]] = []
+
+ def check_sdk_available(self) -> None:
+ return
+
+ async def dispatch(self, session: dict, run: dict, *, user_id: str) -> None:
+ self.calls.append((session, run, user_id))
+
+
+class CompletingDispatcher(RecordingDispatcher):
+ def __init__(self, store) -> None:
+ super().__init__()
+ self._store = store
+ self.ready = asyncio.Event()
+
+ async def dispatch(self, session: dict, run: dict, *, user_id: str) -> None:
+ await super().dispatch(session, run, user_id=user_id)
+ await self._store.runs.update_run(run["id"], {"status": "completed"})
+ await self._store.sessions.update_session(
+ session["id"], user_id=user_id, fields={"status": "completed"}
+ )
+ self.ready.set()
+
+ async def wait_for_run(self, run_id: str) -> None:
+ await self.ready.wait()
+
+
+@pytest_asyncio.fixture
+async def store(tmp_path):
+ engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'phase3.db'}")
+ async with engine.begin() as conn:
+ import deerflow.persistence.maf_roundtable.model # noqa: F401
+
+ await conn.run_sync(Base.metadata.create_all)
+ sf = async_sessionmaker(engine, expire_on_commit=False)
+ yield make_maf_roundtable_store(sf)
+ await engine.dispose()
+
+
+@pytest.mark.asyncio
+async def test_server_route_creates_participants_and_dispatches(store):
+ dispatcher = RecordingDispatcher()
+ svc = MafConversationService(
+ store,
+ maf_config=FakeMafConfig(),
+ dispatcher=dispatcher,
+ )
+ conv = await svc.create_conversation(
+ user_id="u1",
+ title="Trusted route",
+ task_context={"business_code": "6BF"},
+ )
+
+ # The unknown business code is rejected rather than falling back across
+ # business boundaries.
+ with pytest.raises(Exception) as exc_info:
+ await svc.send_message(
+ conversation_id=conv["id"], user_id="u1", content="请多专家并行分析这个任务"
+ )
+ assert "route" in str(exc_info.value)
+ assert dispatcher.calls == []
+
+ # With no business code the configured default route is selected and the
+ # runtime sees only server-owned participants.
+ conv2 = await svc.create_conversation(user_id="u1", title="Default route")
+ await svc.send_message(
+ conversation_id=conv2["id"], user_id="u1", content="请多专家并行分析这个任务"
+ )
+ assert len(dispatcher.calls) == 1
+ session, run, owner = dispatcher.calls[0]
+ assert owner == "u1"
+ assert run["session_id"] == session["id"]
+
+ async with store.session_factory() as db:
+ session_row = (
+ await db.execute(
+ select(MafRoundtableSessionRow).where(
+ MafRoundtableSessionRow.id == session["id"]
+ )
+ )
+ ).scalars().one()
+ participants = (
+ await db.execute(
+ select(MafRoundtableParticipantRow)
+ .where(MafRoundtableParticipantRow.session_id == session["id"])
+ .order_by(MafRoundtableParticipantRow.sort_order)
+ )
+ ).scalars().all()
+ assert session_row.task_payload_json["maf_v2"]["dispatch_plan"]["manager_agent_id"] == "manager-from-config"
+ assert [p.agent_id for p in participants] == ["manager-from-config", "seat-a", "seat-b"]
+ assert all(p.thread_id is None for p in participants)
+
+ events = (
+ await db.execute(
+ select(MafRoundtableConversationRow).where(
+ MafRoundtableConversationRow.id == conv2["id"]
+ )
+ )
+ ).scalars().one()
+ assert events.active_run_id == run["id"]
+
+
+@pytest.mark.asyncio
+async def test_clarification_does_not_create_or_dispatch_run(store):
+ dispatcher = RecordingDispatcher()
+ svc = MafConversationService(store, maf_config=FakeMafConfig(), dispatcher=dispatcher)
+ conv = await svc.create_conversation(user_id="u1", title="Need detail")
+
+ result = await svc.send_message(conversation_id=conv["id"], user_id="u1", content="不清楚")
+ assert result["message_id"]
+ assert dispatcher.calls == []
+
+ async with store.session_factory() as db:
+ conv_row = (
+ await db.execute(
+ select(MafRoundtableConversationRow).where(
+ MafRoundtableConversationRow.id == conv["id"]
+ )
+ )
+ ).scalars().one()
+ assert conv_row.safe_status == "waiting_input"
+ assert conv_row.active_run_id is None
+ runs = (
+ await db.execute(select(MafRoundtableRunRow))
+ ).scalars().all()
+ assert runs == []
+
+ # A clarification reply is allowed to claim the idle waiting-input
+ # conversation and launch exactly one run.
+ await svc.send_message(conversation_id=conv["id"], user_id="u1", content="请分析这个任务")
+ assert len(dispatcher.calls) == 1
+
+
+@pytest.mark.asyncio
+async def test_dispatcher_completion_bridges_back_to_conversation(store):
+ dispatcher = CompletingDispatcher(store)
+ svc = MafConversationService(store, maf_config=FakeMafConfig(), dispatcher=dispatcher)
+ conv = await svc.create_conversation(user_id="u1", title="Completion")
+
+ await svc.send_message(conversation_id=conv["id"], user_id="u1", content="complete this task")
+ watcher_tasks = list(svc._watchers.values()) # noqa: SLF001 - lifecycle assertion
+ if watcher_tasks:
+ await asyncio.gather(*watcher_tasks)
+
+ public = await svc.get_conversation(conversation_id=conv["id"], user_id="u1")
+ assert public["safe_status"] == "completed"
+ events = await store.conversation_events.list_events(conv["id"], after_seq=0)
+ assert [event["type"] for event in events][-1] == "run.completed"
+ assert all(event["run_id"] is None for event in events)
+
+
+@pytest.mark.asyncio
+async def test_workflow_projection_whitelists_user_visible_event_data(store):
+ svc = MafConversationService(store, maf_config=FakeMafConfig())
+ conv = await svc.create_conversation(user_id="u1", title="Projection")
+ await svc.send_message(conversation_id=conv["id"], user_id="u1", content="complete this task")
+ internal = await store.conversations.get_conversation(conv["id"], user_id="u1")
+ run = await store.runs.list_runs(internal["internal_session_id"])
+
+ await svc.bridge_workflow_event(
+ run_id=run[0]["id"],
+ event={
+ "event_type": "tool.requested",
+ "agent_id": "seat-a",
+ "turn_index": 1,
+ "payload": {
+ "tool_name": "safe_tool",
+ "args": {"secret": "must-not-cross"},
+ "tool_call_id": "internal-call-id",
+ },
+ },
+ )
+ await svc.bridge_workflow_event(
+ run_id=run[0]["id"],
+ event={
+ "event_type": "agent.message.completed",
+ "agent_id": "seat-a",
+ "turn_index": 1,
+ "payload": {
+ "content": "answer",
+ "metadata": {"thread_id": "thread-secret", "deerflow_run_id": "df-secret"},
+ },
+ },
+ )
+ events = await store.conversation_events.list_events(conv["id"], after_seq=3)
+ tool = next(event for event in events if event["type"] == "tool.requested")
+ assert tool["data"] == {"tool_name": "safe_tool", "agent_id": "seat-a", "turn_index": 1}
+ complete = next(event for event in events if event["type"] == "agent.message.complete")
+ assert "metadata" not in complete["data"]
+ assert "thread-secret" not in str(complete)
+ assert any(event["type"] == "seat.delivery.submitted" for event in events)
+
+
+def test_intent_resolution_is_deterministic_and_does_not_select_agents():
+ assert resolve_intent("请多专家并行分析") .intent_type == "parallel_consultation"
+ assert resolve_intent("请发布这条内容").intent_type == "tool_write"
+ assert resolve_intent("不清楚").intent_type == "clarification"
+ assert resolve_intent("给我一个普通答案").intent_type == "direct_answer"
+
+
+def test_weak_model_cannot_expand_or_duplicate_dispatch_plan():
+ with pytest.raises(ValueError):
+ MafDispatchPlan(
+ manager_agent_id="manager",
+ seat_agent_ids=["seat-a", "seat-a"],
+ assignments=[
+ MafDispatchAssignment(agent_id="seat-a", sort_order=1),
+ MafDispatchAssignment(agent_id="seat-a", sort_order=2),
+ ],
+ intent_type="parallel_consultation",
+ max_rounds=8,
+ )
+
+ plan = MafDispatchPlan(
+ manager_agent_id="manager",
+ seat_agent_ids=["seat-a"],
+ assignments=[MafDispatchAssignment(agent_id="seat-a", sort_order=1)],
+ intent_type="single_seat",
+ max_rounds=8,
+ )
+ with pytest.raises(InvalidDispatchPlanError):
+ validate_dispatch_plan(plan, trusted_agent_ids={"manager", "seat-b"}, max_seats=4)
+
+ with pytest.raises(ValueError, match="at least one configured seat"):
+ MafDispatchPlan(
+ manager_agent_id="manager",
+ seat_agent_ids=[],
+ assignments=[],
+ intent_type="parallel_consultation",
+ max_rounds=8,
+ )
+
+
+def test_clarification_plan_is_not_constructible():
+ with pytest.raises(ValueError):
+ MafIntentDecision(
+ intent_type="clarification",
+ confidence=0.9,
+ reason_code="needs_detail",
+ )
diff --git a/offline-backend-20260512/backend/tests/test_maf_roundtable_isolation.py b/offline-backend-20260512/backend/tests/test_maf_roundtable_isolation.py
index a629c06c..e6b37b1c 100644
--- a/offline-backend-20260512/backend/tests/test_maf_roundtable_isolation.py
+++ b/offline-backend-20260512/backend/tests/test_maf_roundtable_isolation.py
@@ -21,7 +21,10 @@ from deerflow.persistence.maf_roundtable.model import (
MafRoundtableActionRow,
MafRoundtableActionSecretRow,
MafRoundtableApprovalRow,
+ MafRoundtableConversationEventRow,
+ MafRoundtableConversationRow,
MafRoundtableEventRow,
+ MafRoundtableMessageRow,
MafRoundtableParticipantRow,
MafRoundtableRunRow,
MafRoundtableSeatRunRow,
@@ -167,6 +170,9 @@ async def test_maf_tables_isolated_from_legacy_roundtable(store):
"maf_roundtable_approvals",
"maf_roundtable_seat_runs",
"maf_roundtable_action_secrets",
+ "maf_roundtable_conversations",
+ "maf_roundtable_messages",
+ "maf_roundtable_conversation_events",
}
# Every FK defined on an MAF model must reference another maf_roundtable_* table.
@@ -180,6 +186,9 @@ async def test_maf_tables_isolated_from_legacy_roundtable(store):
MafRoundtableActionSecretRow,
MafRoundtableApprovalRow,
MafRoundtableSeatRunRow,
+ MafRoundtableConversationRow,
+ MafRoundtableMessageRow,
+ MafRoundtableConversationEventRow,
]
for model in maf_models:
for fk in model.__table__.foreign_keys:
diff --git a/offline-backend-20260512/backend/tests/test_maf_write_guard.py b/offline-backend-20260512/backend/tests/test_maf_write_guard.py
index 3f115de5..d6b9cc11 100644
--- a/offline-backend-20260512/backend/tests/test_maf_write_guard.py
+++ b/offline-backend-20260512/backend/tests/test_maf_write_guard.py
@@ -903,9 +903,10 @@ async def test_seat_matching_scopes_by_turn_index(svc, store):
@pytest.mark.asyncio
-async def test_seat_matching_count_mismatch_fails(svc, store):
- """P1b: a pending tool call that does not match EXACTLY one waiting action
- (by tool_call_id) fails the turn —the executor never guesses."""
+async def test_seat_matching_zero_hits_warns_and_skips(svc, store):
+ """A pending tool call that matches 0 waiting actions (e.g. unclassified
+ tool, hallucinated tool name, or WriteGuard let it through as read) should
+ log a warning and skip — NOT crash the entire workflow."""
from app.maf_roundtable.workflow_executors import SeatExecutor
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
@@ -923,7 +924,39 @@ async def test_seat_matching_count_mismatch_fails(svc, store):
executor = SeatExecutor(_FakeSeatAdapter(store, response), state, "seat-1",
action_coordinator=ActionCoordinator(svc, user_id="u1"))
- with pytest.raises(RuntimeError, match="expected exactly 1"):
+ # Should NOT raise — the unmatched tool call is logged and skipped.
+ await executor.handle("msg", _FakeSeatCtx())
+ # The unmatched call was not added to pending_approvals.
+ assert len(state.pending_approvals) == 0
+
+
+@pytest.mark.asyncio
+async def test_seat_matching_multiple_hits_raises(svc, store):
+ """>1 match for a single tool call is a genuine data-integrity error —
+ the executor refuses to guess and raises RuntimeError."""
+ from app.maf_roundtable.workflow_executors import SeatExecutor
+
+ session = await svc.create_session(user_id="u1", body={"task_text": "t"})
+ run = await svc.create_run(session_id=session["id"], user_id="u1")
+
+ # Two waiting actions with the same logical_action_id for turn 1.
+ await store.actions.create_action({
+ "session_id": session["id"], "run_id": run["id"], "agent_id": "seat-1",
+ "tool_name": "update_task", "turn_index": 1, "logical_action_id": "call-1",
+ "idempotency_key": "dup1", "risk_level": "R3", "status": "waiting_approval",
+ })
+ await store.actions.create_action({
+ "session_id": session["id"], "run_id": run["id"], "agent_id": "seat-1",
+ "tool_name": "update_task", "turn_index": 1, "logical_action_id": "call-1",
+ "idempotency_key": "dup2", "risk_level": "R3", "status": "waiting_approval",
+ })
+
+ state = _seat_state(session["id"], run["id"])
+ response = _FakeSeatResponse(tool_calls=[{"name": "update_task", "id": "call-1", "args": {}}])
+ executor = SeatExecutor(_FakeSeatAdapter(store, response), state, "seat-1",
+ action_coordinator=ActionCoordinator(svc, user_id="u1"))
+
+ with pytest.raises(RuntimeError, match="refusing to guess"):
await executor.handle("msg", _FakeSeatCtx())