ME-1 (Story-2095) shaped the agents.chat_session identity + lifecycle
columns and stopped short of any HTTP surface. ME-2 is that surface: the
functions-side of the session dropdown (RFD
docs/notes/rfd-memory-engine.md §4.3 verb table + §8 route sketch).
Nine routes drive the eight ACP verbs — list, create, load-turns,
rename, compact, clear, archive, restore, delete — over
agents.chat_session / agents.chat_turn, each mutating
verb writing a typed chat_session.* audit row in the same
transaction (§9.3). No new table, no new migration; ME-1's shape is consumed
as-is.
| Verb | Route | Server semantics | Audit action |
|---|---|---|---|
| list | GET /app/matters/{matter_id}/agent-sessions?agentKey=&availability= | The dropdown query — firm × matter × counsel × agent, newest-active first. availability=archived serves the archive-management list. | — |
| create | POST /app/matters/{matter_id}/agent-sessions | INSERT a named_agent_chat row, fresh session_id, empty turns, title optional. | chat_session.created |
| load | GET /app/agent-sessions/{session_id}/turns | List turns oldest-first (paged); stamps last_active_at. | — |
| rename | PATCH /app/agent-sessions/{session_id} | Update the title (inline rename). | chat_session.renamed |
| compact | POST /app/agent-sessions/{session_id}/compact | Persist an in-session fold: summary + summary_until_turn_index watermark. Raw turns retained. | chat_session.updated |
| clear | POST /app/agent-sessions/{session_id}/clear | Soft-reset: null the fold, set cleared_until_turn_index = turn_count. Raw turns retained. | chat_session.updated |
| archive | POST /app/agent-sessions/{session_id}/archive | availability='archived' — out of the default dropdown, restorable. | chat_session.archived |
| restore | POST /app/agent-sessions/{session_id}/restore | availability='live'. | chat_session.restored |
| delete | DELETE /app/agent-sessions/{session_id} | Soft: availability='deleted' + deleted_at. NEVER a hard row delete. | chat_session.deleted |
| Surface | Change |
|---|---|
src/app_api/agent_session_dtos.py (new) | Request/response contracts. *Request inputs (ApiRequestModel, camelCase wire, extra="forbid"); distinct *View/*Response outputs — no class is both a request body and a response_model=. |
src/app_api/agent_session_service.py (new) | The lifecycle service. Caller-scoped (every read/write filtered to the counsel's own user_id); firm/user resolved once from the RLS context cache (DR-3); each mutating verb emits its typed audit row via audit.emit in-transaction. |
src/app_api/router_agent_sessions.py (new) | The nine routes. Full contract hygiene: router tags=, response_model=, canonical ErrorResponse responses=, clean camelCase operationIds. matters:read gates every verb. |
src/shared/audit/registry.py | New subject chat_session (area matter) — §9.3. NOT the firm-area agent_session subject (tool-call activity), which stays un-overloaded. |
src/shared/audit/coverage.py | The seven mutating routes registered in EMIT_ROUTES with their chat_session.* actions (the route-coverage gate). |
src/shared/agents_db_models.py | The Python ChatSessionKind Literal widened to admit named_agent_chat — ME-1 left it at two values on purpose ("extends with the session-creation verb in a later ME story"); this is that story. The DB CHECK was already ahead (0150). |
src/app_api/app.py | Router mounted. |
openapi/app-api.json | Regenerated (make openapi) — the app consumes these via src/gen. |
agent_run FK intact (Lifecycle danger-zone).
Delete is availability='deleted' + deleted_at — a row skeleton
survives so agent_run.chat_session_id is never orphaned; the retention job
purges turn content later (§9.2). No session.delete() anywhere.list_sessions takes an
availability filter, so an archived session can be listed and restored —
an archive with no list query is broken-by-construction.turn_count rides the row, so there is no per-session child query. The
dropdown index ix_chat_session_dropdown serves it directly.agent.key/matter_id)
via the shared helper — no hand-rolled manual span (a plumbing span on an HTTP route
is a PR reject). The durable per-verb record is the in-txn audit row. This mirrors the
sibling router_agent_session route exactly.make test-db # Postgres 16 @ :55432, migrate to head (0150)
bash scripts/run-with-test-db-env.sh uv run pytest tests/integration/test_app_agent_sessions.py -q # 3 passed
# every verb over HTTP against a real DB, incl. empty first-run
# test_session_lifecycle_all_verbs_over_http
# delete leaves agent_run FK intact
# test_delete_leaves_agent_run_fk_intact
# db_query_count O(1) on the list (1 session vs 5 sessions -> identical count)
# test_list_db_query_count_is_o1
make openapi && git diff --exit-code -- openapi/app-api.json # producer snapshot committed
# guards unaffected by the registry/coverage/kind edits
uv run pytest tests/unit/test_audit_route_coverage.py tests/unit/test_audit_registry_emit.py \
tests/integration/test_app_registers.py -q # green
The lifecycle test starts from the empty first-run state — a brand-new
matter's dropdown returns [] before any session exists — then walks every
verb through the full ASGI pipeline (middleware → RLS dep → service →
audit → real Postgres), proving the archived-list returns the archived-not-deleted
row and that a deleted session vanishes from both the live and archived lists while its
agent_run reference survives.