代码提交

This commit is contained in:
董宏凯 committed 2026-07-28 19:17:40 +08:00
1 parent 151ecceb85
commit 816c46d447
6 files changed
+140 -26

No files matched your search

@@ -130,10 +130,86 @@ interface ServerSession {
nodes?: ServerNode[]; nodes?: ServerNode[];
} }
const BASE = () => `${getBackendBaseURL()}/api/position-roundtable`; const PRIMARY_API_PATH = "/api/position-roundtable";
const COMPAT_API_PATH = "/api/multi-agent/position-roundtable";
const BASE = () => `${getBackendBaseURL()}${PRIMARY_API_PATH}`;
/**
* Some intranet gateways only forward the long-established `/api/multi-agent`
* namespace. The position workspace was added later under its own top-level
* prefix, so a frontend-only release can work locally but receive FastAPI's
* route-level `{"detail":"Not Found"}` after deployment.
*
* Retry only that exact "route is absent" response against the compatibility
* namespace. A domain 404 such as "Position roundtable session not found"
* remains a real 404 and is never hidden by this routing fallback.
*/
let resolvedApiPath: typeof PRIMARY_API_PATH | typeof COMPAT_API_PATH | null = null;
function replaceApiPath(url: string, apiPath: string): string {
return url.replace(PRIMARY_API_PATH, apiPath);
}
async function isMissingRoute(response: Response): Promise<boolean> {
if (response.status !== 404) return false;
const raw = await response.clone().text().catch(() => "");
try {
const parsed = JSON.parse(raw) as { detail?: unknown };
return parsed?.detail === "Not Found";
} catch {
const normalized = raw.trim();
return (
normalized === "Not Found" ||
/<title>\s*404 Not Found\s*<\/title>/i.test(normalized)
);
}
}
async function positionApiFetch(
input: string,
init?: RequestInit,
): Promise<Response> {
if (resolvedApiPath === COMPAT_API_PATH) {
const compatibilityResponse = await apiFetch(
replaceApiPath(input, COMPAT_API_PATH),
init,
);
if (!(await isMissingRoute(compatibilityResponse))) {
return compatibilityResponse;
}
// A gateway may be reloaded while the SPA remains open. Re-probe the
// canonical route instead of pinning the tab to a now-stale route choice.
resolvedApiPath = null;
}
const primaryResponse = await apiFetch(input, init);
if (!(await isMissingRoute(primaryResponse))) {
resolvedApiPath = PRIMARY_API_PATH;
return primaryResponse;
}
const compatibilityResponse = await apiFetch(
replaceApiPath(input, COMPAT_API_PATH),
init,
);
if (!(await isMissingRoute(compatibilityResponse))) {
resolvedApiPath = COMPAT_API_PATH;
return compatibilityResponse;
}
// Preserve the canonical response/error when neither route exists. This
// keeps diagnostics stable and identifies an outdated backend release.
return primaryResponse;
}
async function readJson<T>(response: Response, label: string): Promise<T> { async function readJson<T>(response: Response, label: string): Promise<T> {
if (!response.ok) { if (!response.ok) {
if (await isMissingRoute(response)) {
throw new Error(
`${label} failed (${response.status}): 岗位会商会话接口尚未部署或未被网关转发;` +
`已尝试 ${PRIMARY_API_PATH} 与 ${COMPAT_API_PATH}`,
);
}
const detail = await response.text().catch(() => ""); const detail = await response.text().catch(() => "");
throw new Error(`${label} failed (${response.status}): ${detail}`); throw new Error(`${label} failed (${response.status}): ${detail}`);
} }
@@ -219,7 +295,7 @@ function toSession(session: ServerSession): PositionRoundtableSession {
export async function listPositionSessions(externalTaskId?: string): Promise<PositionRoundtableSession[]> { export async function listPositionSessions(externalTaskId?: string): Promise<PositionRoundtableSession[]> {
const params = externalTaskId ? `?external_task_id=${encodeURIComponent(externalTaskId)}` : ""; const params = externalTaskId ? `?external_task_id=${encodeURIComponent(externalTaskId)}` : "";
const result = await readJson<ServerSession[]>( const result = await readJson<ServerSession[]>(
await apiFetch(`${BASE()}/sessions${params}`), await positionApiFetch(`${BASE()}/sessions${params}`),
"list position sessions", "list position sessions",
); );
return result.map(toSession); return result.map(toSession);
@@ -230,7 +306,7 @@ export async function createPositionSession(input: {
taskSnapshot: Record<string, unknown>; taskSnapshot: Record<string, unknown>;
}): Promise<PositionRoundtableSession> { }): Promise<PositionRoundtableSession> {
const result = await readJson<ServerSession>( const result = await readJson<ServerSession>(
await apiFetch(`${BASE()}/sessions`, { await positionApiFetch(`${BASE()}/sessions`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
@@ -244,8 +320,8 @@ export async function createPositionSession(input: {
} }
export async function getPositionSession(sessionId: string): Promise<PositionRoundtableSession> { export async function getPositionSession(sessionId: string): Promise<PositionRoundtableSession> {
const response = await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}`); const response = await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}`);
if (response.status === 404) { if (response.status === 404 && !(await isMissingRoute(response))) {
// Consume the body so the connection can be reused, then expose a typed // Consume the body so the connection can be reused, then expose a typed
// error that the page can resolve by selecting another history record. // error that the page can resolve by selecting another history record.
await response.text().catch(() => ""); await response.text().catch(() => "");
@@ -260,7 +336,7 @@ export async function updatePositionTaskSnapshot(
taskSnapshot: Record<string, unknown>, taskSnapshot: Record<string, unknown>,
): Promise<PositionRoundtableSession> { ): Promise<PositionRoundtableSession> {
const result = await readJson<ServerSession>( const result = await readJson<ServerSession>(
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/task`, { await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/task`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ task_snapshot: taskSnapshot }), body: JSON.stringify({ task_snapshot: taskSnapshot }),
@@ -279,7 +355,7 @@ export async function savePositionIntent(
}, },
): Promise<PositionRoundtableSession> { ): Promise<PositionRoundtableSession> {
const result = await readJson<ServerSession>( const result = await readJson<ServerSession>(
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/intent`, { await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/intent`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
@@ -299,7 +375,7 @@ export async function activatePositionSession(
): Promise<PositionRoundtableSession> { ): Promise<PositionRoundtableSession> {
// 启动即冻结链条快照并创建节点;后续页面不得用当前编辑中的链条替换它。 // 启动即冻结链条快照并创建节点;后续页面不得用当前编辑中的链条替换它。
const result = await readJson<ServerSession>( const result = await readJson<ServerSession>(
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/activate`, { await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/activate`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ chain_id: chainId }), body: JSON.stringify({ chain_id: chainId }),
@@ -311,7 +387,7 @@ export async function activatePositionSession(
export async function archivePositionSession(sessionId: string): Promise<PositionRoundtableSession> { export async function archivePositionSession(sessionId: string): Promise<PositionRoundtableSession> {
const result = await readJson<ServerSession>( const result = await readJson<ServerSession>(
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/archive`, { await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/archive`, {
method: "POST", method: "POST",
}), }),
"archive position session", "archive position session",
@@ -320,12 +396,12 @@ export async function archivePositionSession(sessionId: string): Promise<Positio
} }
export async function deletePositionSession(sessionId: string): Promise<void> { export async function deletePositionSession(sessionId: string): Promise<void> {
const response = await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}`, { const response = await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}`, {
method: "DELETE", method: "DELETE",
}); });
// DELETE is deliberately idempotent from the UI's perspective. A stale // DELETE is deliberately idempotent from the UI's perspective. A stale
// history row that is already gone still needs to be removed locally. // history row that is already gone still needs to be removed locally.
if (response.status === 404) { if (response.status === 404 && !(await isMissingRoute(response))) {
await response.text().catch(() => ""); await response.text().catch(() => "");
return; return;
} }
@@ -338,7 +414,7 @@ export async function bindPositionNodeThread(
threadId: string, threadId: string,
): Promise<PositionRoundtableNode> { ): Promise<PositionRoundtableNode> {
const result = await readJson<ServerNode>( const result = await readJson<ServerNode>(
await apiFetch( await positionApiFetch(
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/thread`, `${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/thread`,
{ {
method: "POST", method: "POST",
@@ -360,7 +436,7 @@ export async function savePositionNodeConversation(
}, },
): Promise<PositionRoundtableNode> { ): Promise<PositionRoundtableNode> {
const result = await readJson<ServerNode>( const result = await readJson<ServerNode>(
await apiFetch( await positionApiFetch(
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/state`, `${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/state`,
{ {
method: "PUT", method: "PUT",
@@ -386,7 +462,7 @@ export async function savePositionBuiltinConversation(
): Promise<PositionRoundtableSession> { ): Promise<PositionRoundtableSession> {
const path = kind === "summary" ? "summary/state" : "action-plan/state"; const path = kind === "summary" ? "summary/state" : "action-plan/state";
const result = await readJson<ServerSession>( const result = await readJson<ServerSession>(
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, { await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
@@ -416,7 +492,7 @@ export async function seedPositionDemoSession(
payload: PositionDemoSeedPayload, payload: PositionDemoSeedPayload,
): Promise<PositionRoundtableSession> { ): Promise<PositionRoundtableSession> {
const result = await readJson<ServerSession>( const result = await readJson<ServerSession>(
await apiFetch(`${BASE()}/demo/seed`, { await positionApiFetch(`${BASE()}/demo/seed`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload), body: JSON.stringify(payload),
@@ -437,7 +513,7 @@ export async function getPositionNodeContext(
nodeKey: string, nodeKey: string,
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
return readJson<Record<string, unknown>>( return readJson<Record<string, unknown>>(
await apiFetch( await positionApiFetch(
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/context`, `${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/context`,
{ method: "POST" }, { method: "POST" },
), ),
@@ -456,7 +532,7 @@ export async function completePositionNodeTurn(
): Promise<PositionRoundtableNode> { ): Promise<PositionRoundtableNode> {
// 后端依据 artifactManifest 判断是否有 Markdown 交付物,并在需要时解锁下游。 // 后端依据 artifactManifest 判断是否有 Markdown 交付物,并在需要时解锁下游。
const result = await readJson<ServerNode>( const result = await readJson<ServerNode>(
await apiFetch( await positionApiFetch(
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/complete-turn`, `${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/complete-turn`,
{ {
method: "POST", method: "POST",
@@ -479,7 +555,7 @@ export async function rejectPositionNode(
input: { reason?: string; title?: string }, input: { reason?: string; title?: string },
): Promise<PositionRoundtableSession> { ): Promise<PositionRoundtableSession> {
const result = await readJson<ServerSession>( const result = await readJson<ServerSession>(
await apiFetch( await positionApiFetch(
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/reject`, `${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/reject`,
{ {
method: "POST", method: "POST",
@@ -533,7 +609,7 @@ async function prepareBuiltinRun(
// prepare 阶段固定本轮的上游版本与隐藏提示词,避免总结/行动规划引用漂移数据。 // prepare 阶段固定本轮的上游版本与隐藏提示词,避免总结/行动规划引用漂移数据。
const path = kind === "summary" ? "summary/prepare" : "action-plan/prepare"; const path = kind === "summary" ? "summary/prepare" : "action-plan/prepare";
const result = await readJson<ServerBuiltinRunPreparation>( const result = await readJson<ServerBuiltinRunPreparation>(
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, { await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message }), body: JSON.stringify({ message }),
@@ -557,7 +633,7 @@ async function completeBuiltinRun(
// complete 阶段会校验 sourceRevisions;上游已变化时不能把旧结果覆盖为最新交付。 // complete 阶段会校验 sourceRevisions;上游已变化时不能把旧结果覆盖为最新交付。
const path = kind === "summary" ? "summary/complete" : "action-plan/complete"; const path = kind === "summary" ? "summary/complete" : "action-plan/complete";
const result = await readJson<ServerSession>( const result = await readJson<ServerSession>(
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, { await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
+9 -3
View File
@@ -109,8 +109,8 @@ CI runs these regression tests for every pull request via [.github/workflows/bac
### Position roundtable recovery persistence ### Position roundtable recovery persistence
`/api/position-roundtable` uses LangGraph threads as the live execution source `/api/position-roundtable` uses the shared `/api/intent/*` LangGraph flow as
and stores an additional SQL recovery copy in the live Step-1 execution source and stores an additional SQL recovery copy in
`position_roundtable_sessions`/`position_roundtable_nodes`. The recovery copy `position_roundtable_sessions`/`position_roundtable_nodes`. The recovery copy
contains complete intent/node/summary/action-plan conversations, unsent contains complete intent/node/summary/action-plan conversations, unsent
composer drafts, workflow snapshots, and bounded full text for generated composer drafts, workflow snapshots, and bounded full text for generated
@@ -118,7 +118,11 @@ Markdown/JSON artifacts. This lets history and downstream closing agents keep
working when container-local checkpoint or sandbox volumes are replaced. working when container-local checkpoint or sandbox volumes are replaced.
Revision `20260723_05` adds the conversation columns. Session deletion Revision `20260723_05` adds the conversation columns. Session deletion
explicitly removes child nodes so SQLite deployments do not depend on FK explicitly removes child nodes so SQLite deployments do not depend on FK
cascade pragmas. cascade pragmas. The router is mounted canonically below
`/api/position-roundtable` and, for intranet gateway allowlists, below the
schema-hidden compatibility prefix `/api/multi-agent/position-roundtable`.
Clients may retry the compatibility route only for a framework/proxy-level
`404 Not Found`; domain-level missing-session responses must remain real 404s.
### Harness / App Split ### Harness / App Split
@@ -411,6 +415,8 @@ integration point is the existing `/api/roundtable-chains` store: each `seats` J
may carry an optional `position_id` (validated by `ChainSeatSchema`, no schema may carry an optional `position_id` (validated by `ChainSeatSchema`, no schema
migration). The original roundtable only reads its established seat fields and migration). The original roundtable only reads its established seat fields and
therefore remains unchanged. The separate `/api/position-roundtable` router therefore remains unchanged. The separate `/api/position-roundtable` router
(also mounted at `/api/multi-agent/position-roundtable` for intranet gateway
compatibility)
persists task/intent/frozen-chain snapshots in `position_roundtable_sessions` persists task/intent/frozen-chain snapshots in `position_roundtable_sessions`
and direct-agent node indexes in `position_roundtable_nodes` (migration and direct-agent node indexes in `position_roundtable_nodes` (migration
`20260721_01`). Message history remains in the established LangGraph thread `20260721_01`). Message history remains in the established LangGraph thread
+8 -1
View File
@@ -124,7 +124,7 @@ FastAPI application providing REST endpoints for frontend integration:
| `GET/POST /api/agents` | List visible agents or create a user-owned agent | | `GET/POST /api/agents` | List visible agents or create a user-owned agent |
| `GET/PUT/DELETE /api/agents/{id}` | Read, update, or delete an id-addressed agent | | `GET/PUT/DELETE /api/agents/{id}` | Read, update, or delete an id-addressed agent |
| `GET/POST/PUT/DELETE /api/roundtable-chains` | Manage user-owned roundtable business chains; each seat may optionally carry `position_id` for the separate position-collaboration workspace (the original roundtable ignores it) | | `GET/POST/PUT/DELETE /api/roundtable-chains` | Manage user-owned roundtable business chains; each seat may optionally carry `position_id` for the separate position-collaboration workspace (the original roundtable ignores it) |
| `GET/POST/PUT /api/position-roundtable/sessions` | Standalone position-collaboration sessions: freeze task/intent/chain snapshots, index one direct-agent node per seat, build upstream handoff context (including bounded UTF-8 excerpts of text artifacts), and make archived sessions read-only without invoking the original roundtable coordinator or jobs. Built-in summary/action-plan completion mirrors visible report text into a Markdown artifact when a model omits its final `write_file` call. `POST /sessions/{id}/nodes/{nodeKey}/reject` accepts an optional reason, marks the rejected delivery for rework, and invalidates affected downstream deliveries so they are regenerated in order. | | `GET/POST/PUT /api/position-roundtable/sessions` | Standalone position-collaboration sessions: freeze task/intent/chain snapshots, index one direct-agent node per seat, build upstream handoff context (including bounded UTF-8 excerpts of text artifacts), and make archived sessions read-only without invoking the original roundtable coordinator or jobs. Built-in summary/action-plan completion mirrors visible report text into a Markdown artifact when a model omits its final `write_file` call. `POST /sessions/{id}/nodes/{nodeKey}/reject` accepts an optional reason, marks the rejected delivery for rework, and invalidates affected downstream deliveries so they are regenerated in order. The same router is also mounted at `/api/multi-agent/position-roundtable/*` for intranet gateways that only forward the established multi-agent namespace; the compatibility mount is intentionally omitted from OpenAPI. |
| `GET /api/memory` | Retrieve memory data | | `GET /api/memory` | Retrieve memory data |
| `POST /api/memory/reload` | Force memory reload | | `POST /api/memory/reload` | Force memory reload |
| `GET /api/memory/config` | Memory configuration | | `GET /api/memory/config` | Memory configuration |
@@ -475,3 +475,10 @@ unsent composer drafts, and generated text deliverables. LangGraph checkpoints
remain the execution source, but SQL history continues to render after a remain the execution source, but SQL history continues to render after a
checkpoint or sandbox-output volume is replaced. Apply Alembic revision checkpoint or sandbox-output volume is replaced. Apply Alembic revision
`20260723_05` (or simply upgrade to `head`) before deploying this version. `20260723_05` (or simply upgrade to `head`) before deploying this version.
Its live intent conversation still uses the shared `/api/intent/init` and
`/api/intent/stream` endpoints used by the original multi-agent roundtable.
Only the recovery snapshot layer is position-specific. Deployments must expose
either `/api/position-roundtable/*` or the compatibility mirror
`/api/multi-agent/position-roundtable/*`; updated clients probe both and only
retry when the first response is the framework/proxy-level `404 Not Found`.
@@ -1474,7 +1474,15 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
app.include_router(roundtable_chains.router) app.include_router(roundtable_chains.router)
# 岗位协同独立会话 / 节点状态(不使用原圆桌总控或后台作业) # 岗位协同独立会话 / 节点状态(不使用原圆桌总控或后台作业)
app.include_router(position_roundtable.router) # Canonical position-workspace persistence API.
app.include_router(position_roundtable.router, prefix="/api")
# Compatibility route for intranet gateways whose allowlist only exposes
# the established `/api/multi-agent/*` namespace.
app.include_router(
position_roundtable.router,
prefix="/api/multi-agent",
include_in_schema=False,
)
# Global business ? agent / chain mapping at /api/business-mapping # Global business ? agent / chain mapping at /api/business-mapping
app.include_router(business_mapping.router) app.include_router(business_mapping.router)
@@ -21,7 +21,9 @@ from pydantic import BaseModel, Field
from app.gateway.path_utils import aresolve_thread_virtual_path from app.gateway.path_utils import aresolve_thread_virtual_path
from deerflow.runtime.user_context import get_effective_user_id from deerflow.runtime.user_context import get_effective_user_id
router = APIRouter(prefix="/api/position-roundtable", tags=["position-roundtable"]) # The application mounts this router below both `/api` (canonical) and the
# long-established `/api/multi-agent` namespace (intranet compatibility).
router = APIRouter(prefix="/position-roundtable", tags=["position-roundtable"])
NODE_STATUSES = {"locked", "ready", "running", "done", "stale", "rejected", "error"} NODE_STATUSES = {"locked", "ready", "running", "done", "stale", "rejected", "error"}
_HANDOFF_TEXT_BUDGET = 12_000 _HANDOFF_TEXT_BUDGET = 12_000
@@ -9,6 +9,7 @@ import pytest_asyncio
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.gateway.app import create_app
from app.gateway import services as gateway_services from app.gateway import services as gateway_services
from app.gateway.routers import intent as intent_router from app.gateway.routers import intent as intent_router
from app.gateway.routers import position_roundtable as position_roundtable_router from app.gateway.routers import position_roundtable as position_roundtable_router
@@ -35,6 +36,20 @@ def test_reject_node_request_allows_an_optional_reason():
assert request.reason == "" assert request.reason == ""
def test_position_roundtable_router_has_intranet_compatibility_prefix():
"""The workspace must survive proxies that only expose /api/multi-agent."""
app = create_app()
registrations = {
(route.include_context.prefix, route.include_context.include_in_schema)
for route in app.routes
if getattr(route, "original_router", None) is position_roundtable_router.router
}
assert ("/api", True) in registrations
assert ("/api/multi-agent", False) in registrations
assert position_roundtable_router.router.prefix == "/position-roundtable"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_session_activation_freezes_chain_and_creates_nodes_once(store): async def test_session_activation_freezes_chain_and_creates_nodes_once(store):
created = await store.create_session( created = await store.create_session(