Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion nerve/gateway/routes/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -351,9 +351,33 @@ async def get_messages(session_id: str, limit: int = 500, user: dict = Depends(r
return {"messages": messages, "last_usage": last_usage}


async def _would_create_cycle(db, child_id: str, new_parent_id: str) -> bool:
"""True if making ``child_id`` a child of ``new_parent_id`` forms a loop.

Walks up the ancestor chain from the proposed parent; reaching ``child_id``
means the drop would nest a session under its own descendant. The seen-set
also breaks any pre-existing cycle so the walk always terminates.
"""
seen: set[str] = set()
cursor: str | None = new_parent_id
while cursor:
if cursor == child_id:
return True
if cursor in seen:
break
seen.add(cursor)
row = await db.get_session(cursor)
cursor = row.get("parent_session_id") if row else None
return False


@router.patch("/api/sessions/{session_id}")
async def update_session(session_id: str, req: dict, user: dict = Depends(require_auth)):
"""Update session fields (title, starred, model).
"""Update session fields (title, starred, model, parent_session_id).

``parent_session_id`` is the sidebar drag-to-nest relationship (null clears
it → top-level). It is display-only: guarded against self/cycle links and
against the ``created`` fork window so a drag never alters execution.

``model`` re-points THIS session only — the composer's picker is
per-chat, not a global preference. The engine re-reads the session
Expand Down Expand Up @@ -395,6 +419,27 @@ async def update_session(session_id: str, req: dict, user: dict = Depends(requir
except BackendError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
fields["model"] = requested_model
if "parent_session_id" in req:
raw = req["parent_session_id"]
new_parent = str(raw).strip() if raw not in (None, "") else None
if new_parent is not None:
if new_parent == session_id:
raise HTTPException(status_code=400, detail="A session cannot be its own parent")
if not await deps.db.get_session(new_parent):
raise HTTPException(status_code=404, detail="Parent session not found")
# Fork-window guard: the engine reads parent_session_id to fork a
# brand-new session's first turn ONLY while status == 'created'.
# Refuse to set a parent in that window so a display drag can never
# turn a not-yet-started session into a fork. Anything already run
# (everything draggable in the sidebar) is unaffected.
if session.get("status") == "created":
raise HTTPException(
status_code=409,
detail="Send a first message before nesting this session",
)
if await _would_create_cycle(deps.db, session_id, new_parent):
raise HTTPException(status_code=400, detail="That drop would create a cycle")
fields["parent_session_id"] = new_parent
if not fields:
raise HTTPException(status_code=400, detail="No valid fields to update")
old_starred = int(session.get("starred") or 0)
Expand Down
113 changes: 113 additions & 0 deletions tests/test_session_parent_patch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
"""HTTP tests for the sidebar drag-to-nest PATCH (``parent_session_id``).

Covers the write path added for the session-hierarchy feature in
``gateway/routes/sessions.py``: setting a parent, clearing it (null →
top-level), and the guards — self-parent, unknown parent, cycle, and the
fork-window (a session still in ``created`` status can't be nested, so a
display drag never turns a not-yet-started session into a fork).

Harness mirrors TestSidebarListRoutes in test_sidebar_pagination_api.py: a
minimal FastAPI app with the real router, auth disabled via an empty jwt_secret.
"""

from __future__ import annotations

from types import SimpleNamespace

import pytest
import pytest_asyncio

from nerve.agent.sessions import SessionManager
from nerve.db import Database


@pytest.mark.asyncio
class TestSessionParentPatch:
@pytest_asyncio.fixture
async def setup(self, db: Database):
from fastapi import FastAPI
from fastapi.testclient import TestClient

import nerve.config as cfg_mod
from nerve.config import NerveConfig
from nerve.gateway.routes._deps import init_deps
from nerve.gateway.routes.sessions import router as sessions_router

cfg = NerveConfig()
cfg.auth.jwt_secret = "" # require_auth becomes a no-op
cfg_mod._config = cfg

sm = SessionManager(db)
engine = SimpleNamespace(config=cfg, sessions=sm)
init_deps(engine=engine, db=db) # type: ignore[arg-type]

app = FastAPI()
app.include_router(sessions_router)
yield SimpleNamespace(client=TestClient(app), db=db, sm=sm, cfg=cfg)

cfg_mod._config = None

async def _mk(self, setup, sid: str, *, started: bool = True) -> None:
"""Create a web session. By default move it out of 'created' so it is
nestable — mirrors any session that has run at least once (the only
kind the sidebar exposes as a stable, draggable row)."""
await setup.sm.get_or_create(sid, source="web")
if started:
await setup.db.update_session_fields(sid, {"status": "idle"})

async def test_set_then_clear_parent(self, setup):
await self._mk(setup, "parent")
await self._mk(setup, "child")

r = setup.client.patch("/api/sessions/child", json={"parent_session_id": "parent"})
assert r.status_code == 200
assert r.json()["parent_session_id"] == "parent"

# It also rides in the feed payload the sidebar reads (SELECT * → dict).
feed = setup.client.get("/api/sessions").json()["sessions"]
child = next(s for s in feed if s["id"] == "child")
assert child["parent_session_id"] == "parent"

# null clears it → back to top-level.
r = setup.client.patch("/api/sessions/child", json={"parent_session_id": None})
assert r.status_code == 200
assert r.json()["parent_session_id"] is None

async def test_self_parent_rejected(self, setup):
await self._mk(setup, "solo")
r = setup.client.patch("/api/sessions/solo", json={"parent_session_id": "solo"})
assert r.status_code == 400

async def test_unknown_parent_rejected(self, setup):
await self._mk(setup, "child")
r = setup.client.patch("/api/sessions/child", json={"parent_session_id": "ghost"})
assert r.status_code == 404

async def test_cycle_rejected(self, setup):
# b becomes a child of a; making a a child of b would close a loop.
await self._mk(setup, "a")
await self._mk(setup, "b")
assert setup.client.patch(
"/api/sessions/b", json={"parent_session_id": "a"}).status_code == 200
r = setup.client.patch("/api/sessions/a", json={"parent_session_id": "b"})
assert r.status_code == 400

async def test_created_session_cannot_be_nested(self, setup):
# A brand-new session still in 'created' is fork-eligible; refuse to
# re-parent it so a drag can never turn it into a fork.
await self._mk(setup, "parent")
await self._mk(setup, "fresh", started=False) # stays 'created'
r = setup.client.patch("/api/sessions/fresh", json={"parent_session_id": "parent"})
assert r.status_code == 409

async def test_deep_chain_allowed(self, setup):
# Arbitrary depth is fine: a → b → c.
for sid in ("a", "b", "c"):
await self._mk(setup, sid)
assert setup.client.patch(
"/api/sessions/b", json={"parent_session_id": "a"}).status_code == 200
assert setup.client.patch(
"/api/sessions/c", json={"parent_session_id": "b"}).status_code == 200
feed = {s["id"]: s for s in setup.client.get("/api/sessions").json()["sessions"]}
assert feed["b"]["parent_session_id"] == "a"
assert feed["c"]["parent_session_id"] == "b"
2 changes: 1 addition & 1 deletion web/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,7 @@ export const api = {
),
deleteSession: (id: string) =>
request<any>(`/sessions/${id}`, { method: 'DELETE' }),
updateSession: (id: string, data: { title?: string; starred?: boolean; model?: string }) =>
updateSession: (id: string, data: { title?: string; starred?: boolean; model?: string; parent_session_id?: string | null }) =>
request<any>(`/sessions/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
getMessages: (sessionId: string, limit = 100) =>
request<{ messages: any[]; last_usage?: { input_tokens: number; output_tokens: number; cache_creation_input_tokens: number; cache_read_input_tokens: number; cache_creation?: { ephemeral_5m_input_tokens?: number; ephemeral_1h_input_tokens?: number }; max_context_tokens: number; num_turns?: number } }>(`/sessions/${sessionId}/messages?limit=${limit}`),
Expand Down
Loading