Skip to content
Closed
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
4 changes: 3 additions & 1 deletion apps/desktop/src/store/session-pin-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,9 @@ function reconcile(): void {
if (!current.has(id)) {
mirrored.delete(id)
pending.delete(id)
void writePin(id, false, profileFor(id)).catch(() => {})
void writePin(id, false, profileFor(id)).catch((err) => {
console.warn('[pin-sync] unpin failed:', id, err)
})
}
}

Expand Down
18 changes: 16 additions & 2 deletions gateway/platforms/api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -3026,9 +3026,14 @@ def _session_response(session: Dict[str, Any]) -> Dict[str, Any]:
"output_tokens", "cache_read_tokens", "cache_write_tokens",
"reasoning_tokens", "estimated_cost_usd", "actual_cost_usd",
"api_call_count", "parent_session_id", "last_active", "preview",
"_lineage_root_id",
"_lineage_root_id", "pinned", "archived",
)
payload = {key: session.get(key) for key in safe_keys if key in session}
# SQLite stores these as 0/1; the desktop client type-checks
# `typeof row.pinned === 'boolean'` before adopting server state, so
# an int would silently defeat the whole pin-sync pull pass.
payload["pinned"] = bool(payload.get("pinned"))
payload["archived"] = bool(payload.get("archived"))
# Avoid exposing full system prompts/model_config through the client API;
# callers only need to know whether those snapshots exist.
payload["has_system_prompt"] = bool(session.get("system_prompt"))
Expand Down Expand Up @@ -3096,6 +3101,7 @@ async def _handle_list_sessions(self, request: "web.Request") -> "web.Response":
offset=offset,
include_children=include_children,
order_by_last_active=True,
include_pinned=True,
)
return web.json_response({
"object": "list",
Expand Down Expand Up @@ -3238,7 +3244,7 @@ async def _handle_patch_session(self, request: "web.Request") -> "web.Response":
body, err = await self._read_json_body(request)
if err:
return err
allowed = {"title", "end_reason"}
allowed = {"title", "end_reason", "pinned", "archived"}
unknown = sorted(set(body) - allowed)
if unknown:
return web.json_response(_openai_error(f"Unsupported session fields: {', '.join(unknown)}", code="unsupported_session_field"), status=400)
Expand All @@ -3251,6 +3257,14 @@ async def _handle_patch_session(self, request: "web.Request") -> "web.Response":
return web.json_response(_openai_error(str(exc), code="invalid_title"), status=400)
if body.get("end_reason"):
await asyncio.to_thread(db.end_session, session_id, str(body["end_reason"]))
if "pinned" in body:
if not isinstance(body["pinned"], bool):
return web.json_response(_openai_error("Field 'pinned' must be a boolean", code="invalid_field_type"), status=400)
await asyncio.to_thread(db.set_session_pinned, session_id, body["pinned"])
if "archived" in body:
if not isinstance(body["archived"], bool):
return web.json_response(_openai_error("Field 'archived' must be a boolean", code="invalid_field_type"), status=400)
await asyncio.to_thread(db.set_session_archived, session_id, body["archived"])
session = await asyncio.to_thread(db.get_session, session_id) or session
return web.json_response({"object": "hermes.session", "session": self._session_response(session)})

Expand Down
124 changes: 124 additions & 0 deletions tests/gateway/test_api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,8 @@ def _create_app(adapter: APIServerAdapter) -> web.Application:
"/api/platforms/{platform}/events",
adapter._handle_platform_event_callback,
)
app.router.add_get("/api/sessions", adapter._handle_list_sessions)
app.router.add_patch("/api/sessions/{session_id}", adapter._handle_patch_session)
return app


Expand Down Expand Up @@ -2618,3 +2620,125 @@ def __init__(self, **kwargs):
assert captured[1]["model"] == "minimax/minimax-m3"


# ---------------------------------------------------------------------------
# PATCH /api/sessions/{session_id} — pinned / archived metadata
# ---------------------------------------------------------------------------

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add this endpoint coverage to tests/gateway/test_session_api.py using its real SessionDB fixture, and exercise the changed include_pinned=True list behavior. These mocks verify handler calls but not SessionDB persistence or pinned-row back-fill.



class TestSessionPatchEndpoint:
"""Desktop pin/unpin + archive surfaces PATCH the session object
(apps/desktop/src/store/session-pin-sync.ts, use-session-actions).
The gateway must accept `pinned` and `archived` and round-trip them in
the response so the client's pull/reconcile passes see the new state."""

@pytest.mark.asyncio
async def test_patch_pinned_and_archived_persisted(self, auth_adapter):
state = {"session_id": "sess-1", "pinned": False, "archived": False}
mock_db = MagicMock()
mock_db.get_session.return_value = state
mock_db.set_session_pinned.side_effect = lambda sid, val: state.__setitem__(
"pinned", val
)
mock_db.set_session_archived.side_effect = lambda sid, val: state.__setitem__(
"archived", val
)
auth_adapter._session_db = mock_db
app = _create_app(auth_adapter)
async with TestClient(TestServer(app)) as cli:
resp = await cli.patch(
"/api/sessions/sess-1",
headers={"Authorization": "Bearer sk-secret"},
json={"pinned": True, "archived": True},
)
body = await resp.json()
assert resp.status == 200
mock_db.set_session_pinned.assert_called_once_with("sess-1", True)
mock_db.set_session_archived.assert_called_once_with("sess-1", True)
assert body["object"] == "hermes.session"
assert body["session"]["pinned"] is True
assert body["session"]["archived"] is True

@pytest.mark.asyncio
async def test_patch_rejects_unknown_fields(self, auth_adapter):
mock_db = MagicMock()
mock_db.get_session.return_value = {"session_id": "sess-1"}
auth_adapter._session_db = mock_db
app = _create_app(auth_adapter)
async with TestClient(TestServer(app)) as cli:
resp = await cli.patch(
"/api/sessions/sess-1",
headers={"Authorization": "Bearer sk-secret"},
json={"pinned": True, "bogus_field": 42},
)
body = await resp.json()
assert resp.status == 400
assert body["error"]["code"] == "unsupported_session_field"
mock_db.set_session_pinned.assert_not_called()

@pytest.mark.parametrize("bad", ["false", "true", 1, 0, 1.0])
@pytest.mark.asyncio
async def test_patch_rejects_non_boolean_pinned(self, auth_adapter, bad):
"""A string or number is a malformed client, not a truthy/falsy value:
`bool("false")` would silently persist True. Reject non-bool with 400."""
mock_db = MagicMock()
mock_db.get_session.return_value = {"session_id": "sess-1"}
auth_adapter._session_db = mock_db
app = _create_app(auth_adapter)
async with TestClient(TestServer(app)) as cli:
resp = await cli.patch(
"/api/sessions/sess-1",
headers={"Authorization": "Bearer sk-secret"},
json={"pinned": bad},
)
body = await resp.json()
assert resp.status == 400
assert body["error"]["code"] == "invalid_field_type"
mock_db.set_session_pinned.assert_not_called()

@pytest.mark.parametrize("bad", ["false", "true", 1, 0, 1.0])
@pytest.mark.asyncio
async def test_patch_rejects_non_boolean_archived(self, auth_adapter, bad):
mock_db = MagicMock()
mock_db.get_session.return_value = {"session_id": "sess-1"}
auth_adapter._session_db = mock_db
app = _create_app(auth_adapter)
async with TestClient(TestServer(app)) as cli:
resp = await cli.patch(
"/api/sessions/sess-1",
headers={"Authorization": "Bearer sk-secret"},
json={"archived": bad},
)
body = await resp.json()
assert resp.status == 400
assert body["error"]["code"] == "invalid_field_type"
mock_db.set_session_archived.assert_not_called()

@pytest.mark.asyncio
async def test_patch_unpinned_and_unarchived(self, auth_adapter):
"""Unpin/unarchive is the exact desktop toggle path — False values
must reach the DB (a truthiness check would silently drop them)."""
state = {"session_id": "sess-1", "pinned": True, "archived": True}
mock_db = MagicMock()
mock_db.get_session.return_value = state
mock_db.set_session_pinned.side_effect = lambda sid, val: state.__setitem__(
"pinned", val
)
mock_db.set_session_archived.side_effect = lambda sid, val: state.__setitem__(
"archived", val
)
auth_adapter._session_db = mock_db
app = _create_app(auth_adapter)
async with TestClient(TestServer(app)) as cli:
resp = await cli.patch(
"/api/sessions/sess-1",
headers={"Authorization": "Bearer sk-secret"},
json={"pinned": False, "archived": False},
)
body = await resp.json()
assert resp.status == 200
mock_db.set_session_pinned.assert_called_once_with("sess-1", False)
mock_db.set_session_archived.assert_called_once_with("sess-1", False)
assert body["session"]["pinned"] is False
assert body["session"]["archived"] is False


78 changes: 78 additions & 0 deletions tests/gateway/test_session_api.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Focused tests for API server session-control endpoints."""

import time
from unittest.mock import AsyncMock, patch

import pytest
Expand Down Expand Up @@ -608,3 +609,80 @@ async def test_require_model_lock_hard_fails_when_global_default_would_be_used(a
mock_run.assert_not_called()


@pytest.mark.asyncio
async def test_list_sessions_backfills_out_of_page_pinned_row(adapter, session_db):
"""A pinned conversation aging past the LIMIT page must still surface
(list_sessions_rich include_pinned back-fill), or the desktop Pinned
section silently empties as new sessions push it off the page."""
t0 = time.time()
for i in range(5):
session_db.create_session(f"page-{i}", "api_server")
session_db._conn.execute(
"UPDATE sessions SET started_at=? WHERE id=?",
(t0 - (5 - i) * 1000, f"page-{i}"),
)
session_db._conn.commit()
# Pin the oldest row (page-0), which a limit=2 page ordered by last
# active would otherwise exclude entirely.
session_db.set_session_pinned("page-0", True)

app = _create_session_app(adapter)
async with TestClient(TestServer(app)) as cli:
resp = await cli.get("/api/sessions?limit=2")
assert resp.status == 200, await resp.text()
data = await resp.json()
ids = [s["id"] for s in data["data"]]
assert len(ids) >= 3, ids
assert "page-0" in ids
pinned = next(s for s in data["data"] if s["id"] == "page-0")
assert pinned["pinned"] is True


@pytest.mark.asyncio
async def test_patch_pinned_archived_round_trips_through_real_db(adapter, session_db):
"""PATCH true then false against a real SessionDB: the SQLite row stores
0/1, so the response must coerce back to booleans or the desktop
`typeof row.pinned === 'boolean'` guard rejects the server state."""
session_db.create_session("roundtrip", "api_server")

app = _create_session_app(adapter)
async with TestClient(TestServer(app)) as cli:
resp = await cli.patch(
"/api/sessions/roundtrip",
json={"pinned": True, "archived": True},
)
assert resp.status == 200, await resp.text()
body = await resp.json()
assert body["session"]["pinned"] is True
assert body["session"]["archived"] is True

resp = await cli.patch(
"/api/sessions/roundtrip",
json={"pinned": False, "archived": False},
)
assert resp.status == 200, await resp.text()
body = await resp.json()
assert body["session"]["pinned"] is False
assert body["session"]["archived"] is False

row = session_db.get_session("roundtrip")
assert row["pinned"] == 0
assert row["archived"] == 0


@pytest.mark.asyncio
async def test_patch_rejects_non_boolean_against_real_db(adapter, session_db):
session_db.create_session("badtype", "api_server")
app = _create_session_app(adapter)
async with TestClient(TestServer(app)) as cli:
resp = await cli.patch(
"/api/sessions/badtype",
json={"pinned": "false"},
)
assert resp.status == 400, await resp.text()
body = await resp.json()
assert body["error"]["code"] == "invalid_field_type"
row = session_db.get_session("badtype")
assert row["pinned"] == 0


Loading