From c48757f26ae36b3b85a8eb232cf5f8130195c7cd Mon Sep 17 00:00:00 2001 From: rdwj Date: Tue, 28 Apr 2026 06:07:53 -0500 Subject: [PATCH] feat(routes): GET /v1/sessions/{id}/cost_data read companion Closes the cumulative-cost gap that the 0.14.1 cluster smoke spotlighted. Without a read endpoint, agent-side _persist_cost_data could only write deltas (HttpSessionStore.get_cost_data raised NotImplementedError, so the accumulator fell back to existing={} every turn). With the GET route in place, the agent reads existing totals before computing the merge and HTTP-backed deployments converge on the same cumulative semantics that SQLite/Postgres provide natively. Wire shape mirrors the existing /v1/sessions/{id} handler: {"session_id": ..., "cost_data": {...}}. 404 when the session is missing; 200 with empty cost_data when the session exists but has no PATCH writes yet. Existence is decided via store.exists() since SessionStore.get_cost_data() returns {} for both missing-session and empty-cost_data and the route needs to distinguish the two. Three new pytest cases against the live ASGI app + SqliteSessionStore (after-PATCHes merge, empty-no-writes, missing-session 404). Suite total 43 -> 46. Closes #4 Assisted-by: Claude Code (Opus 4.7) --- CHANGELOG.md | 12 ++++++++ pyproject.toml | 2 +- src/fipsagents_platform/routes/sessions.py | 22 ++++++++++++++ src/fipsagents_platform/version.py | 2 +- tests/test_sessions.py | 35 ++++++++++++++++++++++ 5 files changed, 71 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e6c7e2..f9e5f39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to `fipsagents-platform` will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/). +## [0.2.1] - 2026-04-28 + +Cumulative-cost read companion for `PATCH /v1/sessions/{id}`. Closes the HTTP-backend cumulative gap that the 0.14.1 cluster smoke spotlighted: without a read endpoint, agent-side `_persist_cost_data` could only write deltas (last-write-wins per top-level key). Tracked as [fipsagents-platform#4](https://github.com/fips-agents/fipsagents-platform/issues/4). + +### Added + +- **`GET /v1/sessions/{session_id}/cost_data`.** Returns `{"session_id": ..., "cost_data": {...}}` for existing sessions; 404 when the session is missing. Uses `store.exists()` to distinguish a missing session from one with empty accumulator state. Auth identical to the existing GET. 3 new tests; suite total 46. + +### Notes + +- Pairs with `fipsagents>=0.14.2` on the agent side, where `HttpSessionStore.get_cost_data` consumes this endpoint. Older agents (<=0.14.1) are unaffected — they never call it. Older platforms (<=0.2.0) paired with newer agents 404 cleanly and degrade to last-write-wins. + ## [0.2.0] - 2026-04-27 Sessions and traces proof points. Closes [#1](https://github.com/fips-agents/fipsagents-platform/issues/1)'s remaining checkboxes — the platform service is feature-complete instead of "feedback only," and the wire shape is wide enough to fully back an agent-side `HttpSessionStore` / `HttpTraceStore` (agent-template#114). diff --git a/pyproject.toml b/pyproject.toml index b85181e..0ca249d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "fipsagents-platform" -version = "0.2.0" +version = "0.2.1" description = "Cross-agent platform service for fips-agents deployments" readme = "README.md" license = {file = "LICENSE"} diff --git a/src/fipsagents_platform/routes/sessions.py b/src/fipsagents_platform/routes/sessions.py index 88b76a7..c6faf57 100644 --- a/src/fipsagents_platform/routes/sessions.py +++ b/src/fipsagents_platform/routes/sessions.py @@ -59,6 +59,28 @@ async def get_session( return JSONResponse({"session_id": session_id, "messages": messages}) +@router.get("/{session_id}/cost_data") +async def get_session_cost_data( + session_id: str, + request: Request, + _user: str = Depends(require_user), +) -> JSONResponse: + """Return the accumulated ``cost_data`` for a session. + + Symmetric companion to :func:`update_session`. The agent-side per-turn + cost accumulator reads the current cumulative totals via this endpoint + before computing the next merge, so HTTP-backed deployments converge on + the same cumulative semantics that SQLite/Postgres backends provide + natively. 404 when the session does not exist; 200 with an empty + ``cost_data`` dict when the session exists but has no writes yet. + """ + store = request.app.state.session_store + if not await store.exists(session_id): + raise HTTPException(status_code=404, detail=f"Session {session_id} not found") + cost_data = await store.get_cost_data(session_id) + return JSONResponse({"session_id": session_id, "cost_data": cost_data}) + + @router.put("/{session_id}") async def save_session( session_id: str, diff --git a/src/fipsagents_platform/version.py b/src/fipsagents_platform/version.py index 79372ea..e61ef0c 100644 --- a/src/fipsagents_platform/version.py +++ b/src/fipsagents_platform/version.py @@ -3,4 +3,4 @@ Updated by ``scripts/release.sh``; ``pyproject.toml`` mirrors this value. """ -__version__ = "0.2.0" +__version__ = "0.2.1" diff --git a/tests/test_sessions.py b/tests/test_sessions.py index 5c7dc2f..1000dcd 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -231,3 +231,38 @@ async def test_patch_session_returns_messages(client) -> None: ) assert resp.status_code == 200 assert resp.json() == {"session_id": sid, "messages": messages} + + +# --------------------------------------------------------------------------- +# GET /v1/sessions/{session_id}/cost_data — read companion to PATCH. +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_get_cost_data_after_patches(client) -> None: + """GET returns the cumulative shallow-merged cost_data.""" + sid = "cost-read" + await client.post("/v1/sessions", json={"session_id": sid}) + await client.patch(f"/v1/sessions/{sid}", json={"cost_data": {"a": 1}}) + await client.patch(f"/v1/sessions/{sid}", json={"cost_data": {"b": 2, "a": 5}}) + + resp = await client.get(f"/v1/sessions/{sid}/cost_data") + assert resp.status_code == 200 + assert resp.json() == {"session_id": sid, "cost_data": {"a": 5, "b": 2}} + + +@pytest.mark.asyncio +async def test_get_cost_data_empty_when_no_writes(client) -> None: + """Existing session with no PATCH writes returns 200 + empty dict.""" + sid = "cost-empty" + await client.post("/v1/sessions", json={"session_id": sid}) + + resp = await client.get(f"/v1/sessions/{sid}/cost_data") + assert resp.status_code == 200 + assert resp.json() == {"session_id": sid, "cost_data": {}} + + +@pytest.mark.asyncio +async def test_get_cost_data_404_when_session_missing(client) -> None: + resp = await client.get("/v1/sessions/never-existed/cost_data") + assert resp.status_code == 404