From 4546bc0d9c820ee3088ecfad538fc56638626c81 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 27 Apr 2026 03:35:54 -0400 Subject: [PATCH] feat: drawer remaining v1 field edits (T98.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit of chat/state/manual_edit.py target_kind dispatch found two §6.4 fields without drawer affordances despite being already-projected text columns: chat_state.narrative_anchor and chat_state.weather. Both land via new manual_edit branches (target_kind chat_narrative_anchor and chat_weather) plus paired drawer routes and Scene-section text inputs. The container properties_json blob is intentionally deferred — bounded JSON edits aren't wired through manual_edit and the drawer never surfaces multiple containers at once, so v1 leaves it out. --- chat/state/manual_edit.py | 19 ++++++++ chat/templates/_drawer.html | 20 +++++++++ chat/web/drawer.py | 87 +++++++++++++++++++++++++++++++++++++ tests/test_drawer_phase4.py | 65 +++++++++++++++++++++++++++ 4 files changed, 191 insertions(+) diff --git a/chat/state/manual_edit.py b/chat/state/manual_edit.py index fdcc723..049b4ca 100644 --- a/chat/state/manual_edit.py +++ b/chat/state/manual_edit.py @@ -38,6 +38,12 @@ T98.3 adds a hide-from-view toggle: ``event_log.id`` of the turn; ``new_value`` is ``{"hidden": 0|1}`` and ``prior_value`` mirrors the shape so an inverse edit restores it. +T98.5 finishes the v1 drawer surface with two chat-scope text edits: +- ``chat_narrative_anchor`` and ``chat_weather`` — string overwrites of + the matching ``chat_state`` columns. ``target_id`` is the chat id + (``chats.id``); ``new_value`` is the new string and ``prior_value`` + carries the previous content for §6.4 reversibility. + Pin toggles intentionally use the existing ``memory_pin_changed`` event (registered in :mod:`chat.state.memory`) rather than ``manual_edit`` so the projection writes both ``pinned`` and ``auto_pinned`` atomically. @@ -157,5 +163,18 @@ def _apply_manual_edit(conn: Connection, e: Event) -> None: "UPDATE event_log SET hidden = ? WHERE id = ?", (hidden_int, int(target_id)), ) + elif kind == "chat_narrative_anchor": + # T98.5: string overwrite of ``chat_state.narrative_anchor`` for + # the chat keyed by ``target_id``. + conn.execute( + "UPDATE chat_state SET narrative_anchor = ? WHERE chat_id = ?", + (str(new_value), str(target_id)), + ) + elif kind == "chat_weather": + # T98.5: string overwrite of ``chat_state.weather``. + conn.execute( + "UPDATE chat_state SET weather = ? WHERE chat_id = ?", + (str(new_value), str(target_id)), + ) # Unknown target_kind: silently no-op for v1. Future kinds (activity # fields, etc.) extend the dispatch above. diff --git a/chat/templates/_drawer.html b/chat/templates/_drawer.html index 8614a80..8cfdd5f 100644 --- a/chat/templates/_drawer.html +++ b/chat/templates/_drawer.html @@ -16,6 +16,26 @@

No active container.

{% endif %}

Time: {{ chat.time }}

+
+ + +
+
+ + +
{% if scene %}
CHAT_NARRATIVE_ANCHOR_MAX: + raise HTTPException( + status_code=400, + detail=( + f"narrative_anchor exceeds {CHAT_NARRATIVE_ANCHOR_MAX} chars " + f"(got {len(new_value)})" + ), + ) + + prior = chat.get("narrative_anchor") or "" + append_and_apply( + conn, + kind="manual_edit", + payload={ + "target_kind": "chat_narrative_anchor", + "target_id": chat_id, + "prior_value": prior, + "new_value": new_value, + }, + ) + return await drawer(chat_id, request, conn) + + +@router.post( + "/chats/{chat_id}/drawer/chat/weather", + response_class=HTMLResponse, +) +async def edit_chat_weather( + chat_id: str, + request: Request, + new_value: str = Form(...), + conn=Depends(get_conn), +): + chat = get_chat(conn, chat_id) + if chat is None: + raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}") + if len(new_value) > CHAT_WEATHER_MAX: + raise HTTPException( + status_code=400, + detail=( + f"weather exceeds {CHAT_WEATHER_MAX} chars " + f"(got {len(new_value)})" + ), + ) + + prior = chat.get("weather") or "" + append_and_apply( + conn, + kind="manual_edit", + payload={ + "target_kind": "chat_weather", + "target_id": chat_id, + "prior_value": prior, + "new_value": new_value, + }, + ) + return await drawer(chat_id, request, conn) + + @router.post( "/chats/{chat_id}/drawer/branch/from-turn/{event_id}", response_class=HTMLResponse, diff --git a/tests/test_drawer_phase4.py b/tests/test_drawer_phase4.py index 9ec4a66..f94f266 100644 --- a/tests/test_drawer_phase4.py +++ b/tests/test_drawer_phase4.py @@ -456,3 +456,68 @@ def test_t98_4_delete_invokes_rewind_and_drops_cascade(client, tmp_path): "SELECT 1 FROM event_log WHERE id = ?", (ev_id,) ).fetchone() assert row is None, f"event {ev_id} should have been deleted" + + +# --------------------------------------------------------------------------- +# T98.5 — remaining v1 edits (chat narrative anchor + weather). +# --------------------------------------------------------------------------- + + +def test_t98_5_edit_chat_narrative_anchor_emits_manual_edit(client, tmp_path): + db = tmp_path / "test.db" + _seed_chat(db) + + response = client.post( + "/chats/chat_bot_a/drawer/chat/narrative-anchor", + data={"new_value": "Late evening, after dinner"}, + ) + assert response.status_code == 200 + + with open_db(db) as conn: + row = conn.execute( + "SELECT narrative_anchor FROM chat_state WHERE chat_id = ?", + ("chat_bot_a",), + ).fetchone() + assert row[0] == "Late evening, after dinner" + + import json as _json + + log = conn.execute( + "SELECT payload_json FROM event_log " + "WHERE kind = 'manual_edit' ORDER BY id DESC LIMIT 1" + ).fetchone() + payload = _json.loads(log[0]) + assert payload["target_kind"] == "chat_narrative_anchor" + assert payload["target_id"] == "chat_bot_a" + assert payload["prior_value"] == "Day 1" + assert payload["new_value"] == "Late evening, after dinner" + + +def test_t98_5_edit_chat_weather_emits_manual_edit(client, tmp_path): + db = tmp_path / "test.db" + _seed_chat(db) + + response = client.post( + "/chats/chat_bot_a/drawer/chat/weather", + data={"new_value": "thunderstorm rolling in"}, + ) + assert response.status_code == 200 + + with open_db(db) as conn: + row = conn.execute( + "SELECT weather FROM chat_state WHERE chat_id = ?", + ("chat_bot_a",), + ).fetchone() + assert row[0] == "thunderstorm rolling in" + + import json as _json + + log = conn.execute( + "SELECT payload_json FROM event_log " + "WHERE kind = 'manual_edit' ORDER BY id DESC LIMIT 1" + ).fetchone() + payload = _json.loads(log[0]) + assert payload["target_kind"] == "chat_weather" + assert payload["target_id"] == "chat_bot_a" + assert payload["prior_value"] == "" + assert payload["new_value"] == "thunderstorm rolling in"