merge: T59 drawer events / threads / skip controls
This commit is contained in:
@@ -41,6 +41,121 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<details class="skip-controls">
|
||||
<summary>Elision skip</summary>
|
||||
<form class="inline-edit"
|
||||
hx-post="/chats/{{ chat.id }}/drawer/skip/elision"
|
||||
hx-target="#drawer" hx-swap="innerHTML">
|
||||
<label>
|
||||
Landing state hint:
|
||||
<input type="text" name="landing_state_hint"
|
||||
placeholder="e.g. arriving at the office">
|
||||
</label>
|
||||
<label>
|
||||
New time (ISO 8601):
|
||||
<input type="text" name="new_time" required
|
||||
placeholder="2026-04-26T20:30:00+00:00">
|
||||
</label>
|
||||
<button type="submit">Skip ahead</button>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<details class="skip-controls">
|
||||
<summary>Jump skip</summary>
|
||||
<form class="inline-edit"
|
||||
hx-post="/chats/{{ chat.id }}/drawer/skip/jump"
|
||||
hx-target="#drawer" hx-swap="innerHTML">
|
||||
<label>
|
||||
New time (ISO 8601):
|
||||
<input type="text" name="new_time" required
|
||||
placeholder="2026-04-27T08:00:00+00:00">
|
||||
</label>
|
||||
<label>
|
||||
Anything notable happen? (optional)
|
||||
<textarea name="notable_prose" rows="3"
|
||||
placeholder="leave blank to jump without synthesizing memories"></textarea>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" name="reset_activity" value="1">
|
||||
Reset activity at landing
|
||||
</label>
|
||||
<button type="submit">Jump ahead</button>
|
||||
</form>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section class="drawer-section">
|
||||
<h3>Events</h3>
|
||||
{% if active_events %}
|
||||
<ul class="event-list">
|
||||
{% for ev in active_events %}
|
||||
<li class="event-row">
|
||||
<strong>{{ ev.kind }}</strong>
|
||||
<span class="muted"> ({{ ev.status }})</span>
|
||||
{% if ev.planned_for %}
|
||||
<p class="muted">planned for: {{ ev.planned_for }}</p>
|
||||
{% endif %}
|
||||
{% if ev.props %}
|
||||
<p class="muted">{{ ev.props|tojson }}</p>
|
||||
{% endif %}
|
||||
<form class="inline-edit"
|
||||
hx-post="/chats/{{ chat.id }}/drawer/event/cancel/{{ ev.event_id }}"
|
||||
hx-target="#drawer" hx-swap="innerHTML">
|
||||
<button type="submit">Cancel</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="muted">No active events.</p>
|
||||
{% endif %}
|
||||
<details>
|
||||
<summary>Plan event</summary>
|
||||
<form class="inline-edit"
|
||||
hx-post="/chats/{{ chat.id }}/drawer/event/plan"
|
||||
hx-target="#drawer" hx-swap="innerHTML">
|
||||
<label>
|
||||
Kind:
|
||||
<input type="text" name="kind" required
|
||||
placeholder="e.g. dinner_reservation">
|
||||
</label>
|
||||
<label>
|
||||
Planned for (ISO 8601):
|
||||
<input type="text" name="planned_for" required
|
||||
placeholder="2026-04-26T19:00:00+00:00">
|
||||
</label>
|
||||
<label>
|
||||
Props (JSON):
|
||||
<textarea name="props_json" rows="3"
|
||||
placeholder='{"location": "Bistro X"}'>{}</textarea>
|
||||
</label>
|
||||
<button type="submit">Plan event</button>
|
||||
</form>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section class="drawer-section">
|
||||
<h3>Threads</h3>
|
||||
{% if open_threads %}
|
||||
<ul class="thread-list">
|
||||
{% for th in open_threads %}
|
||||
<li class="thread-row">
|
||||
<strong>{{ th.title }}</strong>
|
||||
{% if th.summary %}
|
||||
<p>{{ th.summary }}</p>
|
||||
{% endif %}
|
||||
<form class="inline-edit"
|
||||
hx-post="/chats/{{ chat.id }}/drawer/thread/close/{{ th.thread_id }}"
|
||||
hx-target="#drawer" hx-swap="innerHTML">
|
||||
<button type="submit">Close</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="muted">No open threads.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% if guest_bot %}
|
||||
|
||||
+395
-1
@@ -27,19 +27,27 @@ one so a later inverse edit can restore state (§6.4 final paragraph).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from chat.eventlog.log import append_and_apply
|
||||
from chat.eventlog.log import append_and_apply, append_event
|
||||
from chat.services.memory_write import record_turn_memory_for_present
|
||||
from chat.services.relationship_seed import seed_inter_bot_edges
|
||||
from chat.services.scene_summarize import apply_scene_close_summary
|
||||
from chat.services.skip_narration import narrate_skip
|
||||
from chat.services.synthesized_memories import synthesize_memories
|
||||
from chat.state.edges import get_edge
|
||||
from chat.state.entities import get_bot, get_you, list_bots
|
||||
from chat.state.events import list_active_events
|
||||
from chat.state.group_node import get_group_node
|
||||
from chat.state.memory import get_pinned
|
||||
from chat.state.threads import list_open_threads
|
||||
from chat.state.world import active_scene, get_activity, get_chat, get_container
|
||||
from chat.web.bots import get_conn
|
||||
from chat.web.kickoff import get_llm_client
|
||||
@@ -155,6 +163,10 @@ async def drawer(chat_id: str, request: Request, conn=Depends(get_conn)):
|
||||
|
||||
pinned = get_pinned(conn, chat["host_bot_id"])
|
||||
|
||||
# T59: active events + open threads for the new drawer sections.
|
||||
active_events = list_active_events(conn, chat_id)
|
||||
open_threads = list_open_threads(conn, chat_id)
|
||||
|
||||
return TEMPLATES.TemplateResponse(
|
||||
request,
|
||||
"_drawer.html",
|
||||
@@ -180,6 +192,8 @@ async def drawer(chat_id: str, request: Request, conn=Depends(get_conn)):
|
||||
"recent_memories": recent_memories,
|
||||
"pinned": pinned,
|
||||
"pin_cap": PIN_CAP,
|
||||
"active_events": active_events,
|
||||
"open_threads": open_threads,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -839,3 +853,383 @@ async def remove_guest(
|
||||
)
|
||||
|
||||
return await drawer(chat_id, request, conn)
|
||||
|
||||
|
||||
# --- T59 events / threads / skip controls --------------------------------
|
||||
#
|
||||
# Five drawer-driven endpoints that emit Phase 3 event-log entries:
|
||||
#
|
||||
# * ``event_planned`` / ``event_cancelled`` for the events panel — props
|
||||
# arrive as a JSON-encoded form field so the user can author arbitrary
|
||||
# structured side-info without a custom HTMX widget per kind.
|
||||
# * ``time_skip_elision`` / ``time_skip_jump`` for the skip panel —
|
||||
# each emits the projector event AND an ``assistant_turn`` carrying the
|
||||
# narration prose from :mod:`chat.services.skip_narration`. Jump skips
|
||||
# ALSO write per-bot synthesized memories from any user-supplied
|
||||
# ``notable_prose`` via :func:`synthesize_memories` +
|
||||
# :func:`record_turn_memory_for_present`.
|
||||
# * ``thread_closed`` for the threads panel.
|
||||
#
|
||||
# Skip narration is appended via plain ``append_event`` (assistant_turn
|
||||
# has no projector handler — it's a transcript-only kind, see
|
||||
# :func:`chat.web.turns._read_recent_dialogue`). The user will see the
|
||||
# new turn on the next chat-detail page load; we do NOT broadcast via
|
||||
# ``publish`` here because the SSE channel is scoped to the chat-detail
|
||||
# page and the drawer partial is the response body — adding cross-cutting
|
||||
# SSE here would require dragging the publish import + chat-channel state
|
||||
# into the drawer module without a meaningful UX gain (the drawer only
|
||||
# rerenders itself on these submissions).
|
||||
|
||||
|
||||
def _parse_iso_time(value: str) -> datetime | None:
|
||||
"""Permissive ISO 8601 parser for skip route validation.
|
||||
|
||||
``datetime.fromisoformat`` doesn't accept a trailing ``Z`` until 3.11,
|
||||
so we normalize it to ``+00:00`` first. Returns ``None`` on parse
|
||||
failure so the caller can return ``400`` with a stable error shape.
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
v = value.strip()
|
||||
if v.endswith("Z"):
|
||||
v = v[:-1] + "+00:00"
|
||||
return datetime.fromisoformat(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
"""UTC ISO timestamp used as a fallback when the chat clock is unset."""
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/chats/{chat_id}/drawer/event/plan",
|
||||
response_class=HTMLResponse,
|
||||
)
|
||||
async def plan_event(
|
||||
chat_id: str,
|
||||
request: Request,
|
||||
kind: str = Form(...),
|
||||
planned_for: str = Form(...),
|
||||
props_json: str = Form("{}"),
|
||||
conn=Depends(get_conn),
|
||||
):
|
||||
"""Append an ``event_planned`` row from the drawer's "Plan event" form.
|
||||
|
||||
``props_json`` is parsed into a dict before being attached to the
|
||||
payload so the projector can treat it as structured data. Bad JSON
|
||||
yields ``400`` — the form template renders an inline error in that
|
||||
case so the user can fix-and-resubmit without losing their input.
|
||||
"""
|
||||
chat = get_chat(conn, chat_id)
|
||||
if chat is None:
|
||||
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
|
||||
|
||||
try:
|
||||
props = json.loads(props_json) if props_json.strip() else {}
|
||||
except json.JSONDecodeError as exc:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"props_json must be valid JSON: {exc}"
|
||||
)
|
||||
if not isinstance(props, dict):
|
||||
raise HTTPException(
|
||||
status_code=400, detail="props_json must encode a JSON object"
|
||||
)
|
||||
|
||||
event_id = f"evt_{uuid.uuid4().hex[:12]}"
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="event_planned",
|
||||
payload={
|
||||
"event_id": event_id,
|
||||
"chat_id": chat_id,
|
||||
"kind": kind,
|
||||
"props": props,
|
||||
"planned_for": planned_for,
|
||||
},
|
||||
)
|
||||
return await drawer(chat_id, request, conn)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/chats/{chat_id}/drawer/event/cancel/{event_id}",
|
||||
response_class=HTMLResponse,
|
||||
)
|
||||
async def cancel_event(
|
||||
chat_id: str,
|
||||
event_id: str,
|
||||
request: Request,
|
||||
conn=Depends(get_conn),
|
||||
):
|
||||
"""Append an ``event_cancelled`` row for ``event_id``.
|
||||
|
||||
``completed_at`` is sourced from the chat clock (so cancellations
|
||||
timeline-align with the rest of the fiction) with a UTC-now fallback
|
||||
when the clock isn't set. The projector is idempotent on terminal
|
||||
statuses so a stale double-submit is harmless.
|
||||
"""
|
||||
chat = get_chat(conn, chat_id)
|
||||
if chat is None:
|
||||
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
|
||||
|
||||
completed_at = chat.get("time") or _now_iso()
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="event_cancelled",
|
||||
payload={
|
||||
"event_id": event_id,
|
||||
"completed_at": completed_at,
|
||||
},
|
||||
)
|
||||
return await drawer(chat_id, request, conn)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/chats/{chat_id}/drawer/skip/elision",
|
||||
response_class=HTMLResponse,
|
||||
)
|
||||
async def skip_elision(
|
||||
chat_id: str,
|
||||
request: Request,
|
||||
landing_state_hint: str = Form(""),
|
||||
new_time: str = Form(...),
|
||||
conn=Depends(get_conn),
|
||||
client=Depends(get_llm_client),
|
||||
):
|
||||
"""Elision skip: collapse in-progress activity into its end-state.
|
||||
|
||||
Validates ``new_time`` is ISO 8601 AND non-decreasing relative to the
|
||||
chat clock (a backwards skip would corrupt downstream causality).
|
||||
Emits ``time_skip_elision`` first (chat clock advances) then an
|
||||
``assistant_turn`` carrying the narrated transition from
|
||||
:func:`chat.services.skip_narration.narrate_skip`. The narration call
|
||||
has its own LLM-failure fallback so this route never blocks on a
|
||||
flaky model.
|
||||
"""
|
||||
chat = get_chat(conn, chat_id)
|
||||
if chat is None:
|
||||
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
|
||||
|
||||
new_dt = _parse_iso_time(new_time)
|
||||
if new_dt is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"new_time must be ISO 8601, got {new_time!r}",
|
||||
)
|
||||
cur_dt = _parse_iso_time(chat.get("time") or "")
|
||||
if cur_dt is not None and new_dt < cur_dt:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="new_time must not be earlier than the current chat clock",
|
||||
)
|
||||
|
||||
host_bot = get_bot(conn, chat["host_bot_id"]) or {
|
||||
"name": "host",
|
||||
"persona": "",
|
||||
}
|
||||
you_entity = get_you(conn) or {"name": "you"}
|
||||
bot_activity = get_activity(conn, chat["host_bot_id"]) or {}
|
||||
current_activity = (
|
||||
(bot_activity.get("action") or {}).get("verb") or ""
|
||||
)
|
||||
|
||||
settings = request.app.state.settings
|
||||
narration = await narrate_skip(
|
||||
client,
|
||||
narrative_model=settings.narrative_model,
|
||||
skip_kind="elision",
|
||||
speaker_bot=host_bot,
|
||||
you_name=you_entity.get("name") or "you",
|
||||
current_time=chat.get("time") or "",
|
||||
new_time=new_time,
|
||||
current_activity=current_activity,
|
||||
landing_state_hint=landing_state_hint,
|
||||
timeout_s=settings.classifier_timeout_s,
|
||||
)
|
||||
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="time_skip_elision",
|
||||
payload={"chat_id": chat_id, "new_time": new_time},
|
||||
)
|
||||
append_event(
|
||||
conn,
|
||||
kind="assistant_turn",
|
||||
payload={
|
||||
"chat_id": chat_id,
|
||||
"speaker_id": host_bot["id"] if "id" in host_bot else chat["host_bot_id"],
|
||||
"text": narration,
|
||||
"truncated": False,
|
||||
},
|
||||
)
|
||||
return await drawer(chat_id, request, conn)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/chats/{chat_id}/drawer/skip/jump",
|
||||
response_class=HTMLResponse,
|
||||
)
|
||||
async def skip_jump(
|
||||
chat_id: str,
|
||||
request: Request,
|
||||
new_time: str = Form(...),
|
||||
notable_prose: str = Form(""),
|
||||
reset_activity: str = Form(""),
|
||||
conn=Depends(get_conn),
|
||||
client=Depends(get_llm_client),
|
||||
):
|
||||
"""Jump skip: bridge a longer fiction-time delta.
|
||||
|
||||
Same ISO + non-decreasing validations as the elision route. When
|
||||
``notable_prose`` is non-empty, runs :func:`synthesize_memories`
|
||||
once per present bot witness (host always; guest when present),
|
||||
then writes one ``memory_written`` per synthesized memory via
|
||||
:func:`record_turn_memory_for_present` (which fans out to host +
|
||||
guest internally). Each call writes ``source="synthesized"`` so the
|
||||
retrieval ranker can treat them as lower-reliability than direct
|
||||
turn memories. Finally emits ``time_skip_jump`` and the narration
|
||||
``assistant_turn``.
|
||||
|
||||
``reset_activity`` is parsed permissively ("1" / "true" / "on" /
|
||||
"yes" — same shape as the add-guest reseed flag) since HTML
|
||||
checkboxes typically post the literal "1" or omit the field
|
||||
entirely.
|
||||
"""
|
||||
chat = get_chat(conn, chat_id)
|
||||
if chat is None:
|
||||
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
|
||||
|
||||
new_dt = _parse_iso_time(new_time)
|
||||
if new_dt is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"new_time must be ISO 8601, got {new_time!r}",
|
||||
)
|
||||
cur_dt = _parse_iso_time(chat.get("time") or "")
|
||||
if cur_dt is not None and new_dt < cur_dt:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="new_time must not be earlier than the current chat clock",
|
||||
)
|
||||
|
||||
reset_flag = reset_activity.lower() in ("1", "true", "on", "yes")
|
||||
|
||||
host_bot = get_bot(conn, chat["host_bot_id"]) or {
|
||||
"id": chat["host_bot_id"],
|
||||
"name": "host",
|
||||
"persona": "",
|
||||
}
|
||||
you_entity = get_you(conn) or {"name": "you"}
|
||||
you_name = you_entity.get("name") or "you"
|
||||
guest_bot_id = chat.get("guest_bot_id")
|
||||
guest_bot = get_bot(conn, guest_bot_id) if guest_bot_id else None
|
||||
|
||||
settings = request.app.state.settings
|
||||
|
||||
# Emit time_skip_jump up front so the chat clock is at the new time
|
||||
# before any memory writes — they should record at the post-jump
|
||||
# clock, mirroring how a regular turn's memory carries the chat clock.
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="time_skip_jump",
|
||||
payload={
|
||||
"chat_id": chat_id,
|
||||
"new_time": new_time,
|
||||
"reset_activity": reset_flag,
|
||||
},
|
||||
)
|
||||
|
||||
# Synthesize memories per present bot witness when prose is non-empty.
|
||||
# ``synthesize_memories`` short-circuits on whitespace prose so this
|
||||
# is safe to call unconditionally, but we gate the loop to avoid
|
||||
# iterating a fixed empty list.
|
||||
if notable_prose.strip():
|
||||
present_bots: list[dict] = [host_bot]
|
||||
if guest_bot is not None:
|
||||
present_bots.append(guest_bot)
|
||||
for bot in present_bots:
|
||||
digest = await synthesize_memories(
|
||||
client,
|
||||
classifier_model=settings.classifier_model,
|
||||
prose=notable_prose,
|
||||
bot_name=bot.get("name") or "",
|
||||
bot_persona=bot.get("persona") or "",
|
||||
you_name=you_name,
|
||||
timeout_s=settings.classifier_timeout_s,
|
||||
)
|
||||
for mem in digest.memories:
|
||||
# ``record_turn_memory_for_present`` writes one
|
||||
# ``memory_written`` per present bot per call. Calling it
|
||||
# once per synthesized memory means N memories x M bots
|
||||
# = N*M events; the loop above already iterates by bot
|
||||
# so we pass guest_bot_id=None here to avoid double-
|
||||
# writing the guest's row when bot==guest.
|
||||
record_turn_memory_for_present(
|
||||
conn,
|
||||
chat_id=chat_id,
|
||||
host_bot_id=bot["id"],
|
||||
guest_bot_id=None,
|
||||
narrative_text=mem.text,
|
||||
chat_clock_at=new_time,
|
||||
source="synthesized",
|
||||
significance=mem.significance,
|
||||
)
|
||||
|
||||
narration = await narrate_skip(
|
||||
client,
|
||||
narrative_model=settings.narrative_model,
|
||||
skip_kind="jump",
|
||||
speaker_bot=host_bot,
|
||||
you_name=you_name,
|
||||
current_time=chat.get("time") or "",
|
||||
new_time=new_time,
|
||||
current_activity="",
|
||||
landing_state_hint=notable_prose,
|
||||
timeout_s=settings.classifier_timeout_s,
|
||||
)
|
||||
append_event(
|
||||
conn,
|
||||
kind="assistant_turn",
|
||||
payload={
|
||||
"chat_id": chat_id,
|
||||
"speaker_id": host_bot.get("id") or chat["host_bot_id"],
|
||||
"text": narration,
|
||||
"truncated": False,
|
||||
},
|
||||
)
|
||||
return await drawer(chat_id, request, conn)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/chats/{chat_id}/drawer/thread/close/{thread_id}",
|
||||
response_class=HTMLResponse,
|
||||
)
|
||||
async def close_thread(
|
||||
chat_id: str,
|
||||
thread_id: str,
|
||||
request: Request,
|
||||
conn=Depends(get_conn),
|
||||
):
|
||||
"""Append a ``thread_closed`` row for ``thread_id``.
|
||||
|
||||
Mirrors :func:`cancel_event` — chat-clock-or-now timestamp, projector
|
||||
handles idempotency. The drawer's open-threads list is sourced from
|
||||
``list_open_threads`` which filters by ``status='open'`` so a stale
|
||||
double-submit is a no-op visually.
|
||||
"""
|
||||
chat = get_chat(conn, chat_id)
|
||||
if chat is None:
|
||||
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
|
||||
|
||||
closed_at = chat.get("time") or _now_iso()
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="thread_closed",
|
||||
payload={
|
||||
"thread_id": thread_id,
|
||||
"closed_at": closed_at,
|
||||
},
|
||||
)
|
||||
return await drawer(chat_id, request, conn)
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
"""T59: drawer events / threads / skip controls.
|
||||
|
||||
Extends the chat drawer with three new sections (Events, Threads, Skip)
|
||||
and five new POST endpoints:
|
||||
|
||||
* ``POST /chats/{chat_id}/drawer/event/plan`` — emits ``event_planned``.
|
||||
* ``POST /chats/{chat_id}/drawer/event/cancel/{event_id}`` — emits
|
||||
``event_cancelled``.
|
||||
* ``POST /chats/{chat_id}/drawer/skip/elision`` — validates new_time,
|
||||
emits ``time_skip_elision`` plus an ``assistant_turn`` carrying the
|
||||
narrated transition prose from :mod:`chat.services.skip_narration`.
|
||||
* ``POST /chats/{chat_id}/drawer/skip/jump`` — validates new_time, emits
|
||||
``time_skip_jump`` plus per-bot synthesized ``memory_written`` events
|
||||
derived from the user-supplied "anything notable" prose, and an
|
||||
``assistant_turn`` carrying the narration.
|
||||
* ``POST /chats/{chat_id}/drawer/thread/close/{thread_id}`` — emits
|
||||
``thread_closed``.
|
||||
|
||||
Each route returns the refreshed drawer partial (HTMX swap target) so
|
||||
the tests assert both the persisted event_log effect AND the rendered
|
||||
section content. Wire-up follows the T42 ``MockLLMClient`` pattern.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from chat.app import app
|
||||
from chat.db.connection import open_db
|
||||
from chat.eventlog.log import append_and_apply, append_event
|
||||
from chat.eventlog.projector import project
|
||||
from chat.llm.mock import MockLLMClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path, monkeypatch):
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text('featherless_api_key = "test"\n')
|
||||
monkeypatch.setenv("CHAT_CONFIG_PATH", str(cfg))
|
||||
db = tmp_path / "test.db"
|
||||
monkeypatch.setenv("CHAT_DB_PATH", str(db))
|
||||
with TestClient(app) as c:
|
||||
if hasattr(app.state, "background_worker"):
|
||||
app.state.background_worker.enabled = False
|
||||
yield c
|
||||
|
||||
|
||||
def _bot_payload(bot_id: str, name: str) -> dict:
|
||||
return {
|
||||
"id": bot_id,
|
||||
"name": name,
|
||||
"persona": "...",
|
||||
"voice_samples": [],
|
||||
"traits": [],
|
||||
"backstory": "",
|
||||
"initial_relationship_to_you": "",
|
||||
"kickoff_prose": "",
|
||||
}
|
||||
|
||||
|
||||
def _seed_chat(db: Path, *, with_scene: bool = True) -> None:
|
||||
"""Seed a chat hosted by ``bot_a`` (with ``bot_b`` authored as a
|
||||
candidate guest) so the skip-jump path can write per-bot synthesized
|
||||
memories when a guest is present.
|
||||
"""
|
||||
with open_db(db) as conn:
|
||||
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_a", "BotA"))
|
||||
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_b", "BotB"))
|
||||
append_event(
|
||||
conn,
|
||||
kind="you_authored",
|
||||
payload={"name": "Me", "pronouns": "they/them", "persona": ""},
|
||||
)
|
||||
append_event(
|
||||
conn,
|
||||
kind="chat_created",
|
||||
payload={
|
||||
"id": "chat_bot_a",
|
||||
"host_bot_id": "bot_a",
|
||||
"initial_time": "2026-04-26T20:00:00+00:00",
|
||||
"narrative_anchor": "Day 1",
|
||||
"weather": "",
|
||||
},
|
||||
)
|
||||
if with_scene:
|
||||
append_event(
|
||||
conn,
|
||||
kind="scene_opened",
|
||||
payload={
|
||||
"chat_id": "chat_bot_a",
|
||||
"container_id": None,
|
||||
"started_at": "2026-04-26T20:00:00+00:00",
|
||||
"participants": ["you", "bot_a"],
|
||||
},
|
||||
)
|
||||
project(conn)
|
||||
|
||||
|
||||
def _override_llm(canned: list[str]):
|
||||
"""Wire a ``MockLLMClient`` into the drawer's LLM dependency."""
|
||||
from chat.web.kickoff import get_llm_client
|
||||
|
||||
app.dependency_overrides[get_llm_client] = lambda: MockLLMClient(
|
||||
canned=list(canned)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Empty drawer state — Events + Threads sections render but show
|
||||
# empty-state copy (no row markup) when no events / threads exist.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_drawer_with_no_events_or_threads_omits_sections(client, tmp_path):
|
||||
_seed_chat(tmp_path / "test.db")
|
||||
|
||||
response = client.get("/chats/chat_bot_a/drawer")
|
||||
assert response.status_code == 200
|
||||
body = response.text
|
||||
|
||||
# Sections render with empty-state copy — deterministic markers.
|
||||
assert "<h3>Events</h3>" in body
|
||||
assert "<h3>Threads</h3>" in body
|
||||
assert "No active events" in body
|
||||
assert "No open threads" in body
|
||||
# Skip controls always render under Activity (gated by chat clock).
|
||||
assert "Elision skip" in body
|
||||
assert "Jump skip" in body
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. POST event/plan — event_planned lands and the drawer lists it.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_post_event_plan_appends_event_planned_and_renders(client, tmp_path):
|
||||
_seed_chat(tmp_path / "test.db")
|
||||
|
||||
response = client.post(
|
||||
"/chats/chat_bot_a/drawer/event/plan",
|
||||
data={
|
||||
"kind": "dinner_reservation",
|
||||
"planned_for": "2026-04-26T19:00:00+00:00",
|
||||
"props_json": json.dumps({"restaurant": "Bistro X"}),
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
with open_db(tmp_path / "test.db") as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT payload_json FROM event_log WHERE kind = 'event_planned'"
|
||||
).fetchall()
|
||||
assert len(rows) == 1
|
||||
payload = json.loads(rows[0][0])
|
||||
assert payload["kind"] == "dinner_reservation"
|
||||
assert payload["chat_id"] == "chat_bot_a"
|
||||
assert payload["planned_for"] == "2026-04-26T19:00:00+00:00"
|
||||
assert payload["props"] == {"restaurant": "Bistro X"}
|
||||
assert payload["event_id"].startswith("evt_")
|
||||
|
||||
# Refreshed partial lists the new event by kind.
|
||||
body = response.text
|
||||
assert "dinner_reservation" in body
|
||||
|
||||
|
||||
def test_post_event_plan_invalid_props_json_returns_400(client, tmp_path):
|
||||
_seed_chat(tmp_path / "test.db")
|
||||
|
||||
response = client.post(
|
||||
"/chats/chat_bot_a/drawer/event/plan",
|
||||
data={
|
||||
"kind": "dinner_reservation",
|
||||
"planned_for": "2026-04-26T19:00:00+00:00",
|
||||
"props_json": "not valid json {",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. POST event/cancel — event_cancelled lands and the active list drops it.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_post_event_cancel_appends_event_cancelled(client, tmp_path):
|
||||
_seed_chat(tmp_path / "test.db")
|
||||
|
||||
# Plan first via the route so the test exercises both sides.
|
||||
plan_resp = client.post(
|
||||
"/chats/chat_bot_a/drawer/event/plan",
|
||||
data={
|
||||
"kind": "doctor_visit",
|
||||
"planned_for": "2026-04-27T09:00:00+00:00",
|
||||
"props_json": "{}",
|
||||
},
|
||||
)
|
||||
assert plan_resp.status_code == 200
|
||||
|
||||
with open_db(tmp_path / "test.db") as conn:
|
||||
row = conn.execute(
|
||||
"SELECT payload_json FROM event_log WHERE kind = 'event_planned' "
|
||||
"ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
event_id = json.loads(row[0])["event_id"]
|
||||
|
||||
cancel_resp = client.post(
|
||||
f"/chats/chat_bot_a/drawer/event/cancel/{event_id}"
|
||||
)
|
||||
assert cancel_resp.status_code == 200
|
||||
|
||||
with open_db(tmp_path / "test.db") as conn:
|
||||
cancelled = conn.execute(
|
||||
"SELECT payload_json FROM event_log WHERE kind = 'event_cancelled'"
|
||||
).fetchall()
|
||||
assert len(cancelled) == 1
|
||||
cp = json.loads(cancelled[0][0])
|
||||
assert cp["event_id"] == event_id
|
||||
|
||||
# Active-events query should no longer surface this event.
|
||||
from chat.state.events import list_active_events
|
||||
|
||||
assert list_active_events(conn, "chat_bot_a") == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. POST skip/elision — emits time_skip_elision + assistant_turn narration.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_post_skip_elision_advances_clock_and_emits_narration(client, tmp_path):
|
||||
_seed_chat(tmp_path / "test.db")
|
||||
|
||||
canned_narration = "We pull up to the curb just before sunset."
|
||||
_override_llm([canned_narration])
|
||||
try:
|
||||
response = client.post(
|
||||
"/chats/chat_bot_a/drawer/skip/elision",
|
||||
data={
|
||||
"landing_state_hint": "arriving at the venue",
|
||||
"new_time": "2026-04-26T20:30:00+00:00",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
with open_db(tmp_path / "test.db") as conn:
|
||||
from chat.state.world import get_chat
|
||||
|
||||
chat = get_chat(conn, "chat_bot_a")
|
||||
assert chat["time"] == "2026-04-26T20:30:00+00:00"
|
||||
|
||||
skip_rows = conn.execute(
|
||||
"SELECT payload_json FROM event_log WHERE kind = 'time_skip_elision'"
|
||||
).fetchall()
|
||||
assert len(skip_rows) == 1
|
||||
sp = json.loads(skip_rows[0][0])
|
||||
assert sp["chat_id"] == "chat_bot_a"
|
||||
assert sp["new_time"] == "2026-04-26T20:30:00+00:00"
|
||||
|
||||
# An assistant_turn event landed with the narration text.
|
||||
turn_rows = conn.execute(
|
||||
"SELECT payload_json FROM event_log WHERE kind = 'assistant_turn'"
|
||||
).fetchall()
|
||||
assert len(turn_rows) == 1
|
||||
tp = json.loads(turn_rows[0][0])
|
||||
assert tp["chat_id"] == "chat_bot_a"
|
||||
assert tp["text"].strip() # non-empty narration
|
||||
assert tp["speaker_id"] == "bot_a"
|
||||
|
||||
|
||||
def test_post_skip_elision_invalid_time_returns_400(client, tmp_path):
|
||||
_seed_chat(tmp_path / "test.db")
|
||||
_override_llm([])
|
||||
try:
|
||||
# Garbled ISO timestamp.
|
||||
bad_resp = client.post(
|
||||
"/chats/chat_bot_a/drawer/skip/elision",
|
||||
data={"landing_state_hint": "x", "new_time": "not-a-time"},
|
||||
)
|
||||
assert bad_resp.status_code == 400
|
||||
|
||||
# Backwards-in-time skip: chat seeded at 20:00, asking 19:00.
|
||||
backwards_resp = client.post(
|
||||
"/chats/chat_bot_a/drawer/skip/elision",
|
||||
data={
|
||||
"landing_state_hint": "x",
|
||||
"new_time": "2026-04-26T19:00:00+00:00",
|
||||
},
|
||||
)
|
||||
assert backwards_resp.status_code == 400
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. POST skip/jump — synthesized memories per present bot + narration.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_post_skip_jump_with_notable_prose_writes_synthesized_memories(
|
||||
client, tmp_path
|
||||
):
|
||||
_seed_chat(tmp_path / "test.db")
|
||||
|
||||
# Single host present (no guest) — exactly one synthesize call,
|
||||
# one narration call. The synthesize digest carries two memories so
|
||||
# we can assert N writes lands the right shape.
|
||||
digest_json = json.dumps(
|
||||
{
|
||||
"memories": [
|
||||
{
|
||||
"text": "We bumped into an old friend at the cafe.",
|
||||
"significance": 1,
|
||||
"affinity_delta": 0,
|
||||
"trust_delta": 0,
|
||||
},
|
||||
{
|
||||
"text": "It started raining on the walk home.",
|
||||
"significance": 1,
|
||||
"affinity_delta": 0,
|
||||
"trust_delta": 0,
|
||||
},
|
||||
]
|
||||
}
|
||||
)
|
||||
narration = "The afternoon slipped by quickly."
|
||||
_override_llm([digest_json, narration])
|
||||
try:
|
||||
response = client.post(
|
||||
"/chats/chat_bot_a/drawer/skip/jump",
|
||||
data={
|
||||
"new_time": "2026-04-27T08:00:00+00:00",
|
||||
"notable_prose": (
|
||||
"We ran into an old friend, and it rained on the way back."
|
||||
),
|
||||
"reset_activity": "1",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
with open_db(tmp_path / "test.db") as conn:
|
||||
from chat.state.world import get_chat
|
||||
|
||||
chat = get_chat(conn, "chat_bot_a")
|
||||
assert chat["time"] == "2026-04-27T08:00:00+00:00"
|
||||
|
||||
jump_rows = conn.execute(
|
||||
"SELECT payload_json FROM event_log WHERE kind = 'time_skip_jump'"
|
||||
).fetchall()
|
||||
assert len(jump_rows) == 1
|
||||
jp = json.loads(jump_rows[0][0])
|
||||
assert jp["chat_id"] == "chat_bot_a"
|
||||
assert jp["new_time"] == "2026-04-27T08:00:00+00:00"
|
||||
assert jp["reset_activity"] is True
|
||||
|
||||
# Two synthesized memories land for the lone host bot
|
||||
# (record_turn_memory_for_present writes one row per present bot
|
||||
# per call — host only here, so 2 memories x 1 bot = 2 events).
|
||||
mem_rows = conn.execute(
|
||||
"SELECT payload_json FROM event_log WHERE kind = 'memory_written'"
|
||||
).fetchall()
|
||||
synth_payloads = [
|
||||
json.loads(r[0])
|
||||
for r in mem_rows
|
||||
if json.loads(r[0]).get("source") == "synthesized"
|
||||
]
|
||||
assert len(synth_payloads) == 2
|
||||
for p in synth_payloads:
|
||||
assert p["owner_id"] == "bot_a"
|
||||
assert p["chat_id"] == "chat_bot_a"
|
||||
|
||||
# And the assistant_turn narration landed.
|
||||
turn_rows = conn.execute(
|
||||
"SELECT payload_json FROM event_log WHERE kind = 'assistant_turn'"
|
||||
).fetchall()
|
||||
assert len(turn_rows) == 1
|
||||
tp = json.loads(turn_rows[0][0])
|
||||
assert tp["text"].strip()
|
||||
assert tp["speaker_id"] == "bot_a"
|
||||
|
||||
|
||||
def test_post_skip_jump_with_empty_prose_skips_memory_writes(client, tmp_path):
|
||||
_seed_chat(tmp_path / "test.db")
|
||||
|
||||
# Empty prose short-circuits in synthesize_memories before any LLM call,
|
||||
# so the canned queue only needs the narration.
|
||||
narration = "(next morning: still in the kitchen.)"
|
||||
_override_llm([narration])
|
||||
try:
|
||||
response = client.post(
|
||||
"/chats/chat_bot_a/drawer/skip/jump",
|
||||
data={
|
||||
"new_time": "2026-04-27T08:00:00+00:00",
|
||||
"notable_prose": " ",
|
||||
"reset_activity": "",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
with open_db(tmp_path / "test.db") as conn:
|
||||
synth = conn.execute(
|
||||
"SELECT COUNT(*) FROM event_log WHERE kind = 'memory_written' "
|
||||
"AND payload_json LIKE '%synthesized%'"
|
||||
).fetchone()[0]
|
||||
assert synth == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. POST thread/close — thread_closed lands and the open list drops it.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_post_thread_close_appends_thread_closed(client, tmp_path):
|
||||
_seed_chat(tmp_path / "test.db")
|
||||
|
||||
# Open a thread directly via append_and_apply so the test focuses on
|
||||
# the close route's effect.
|
||||
with open_db(tmp_path / "test.db") as conn:
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="thread_opened",
|
||||
payload={
|
||||
"thread_id": "thr_alpha",
|
||||
"chat_id": "chat_bot_a",
|
||||
"title": "the missing key",
|
||||
"summary": "Couldn't find the key.",
|
||||
},
|
||||
)
|
||||
|
||||
response = client.post("/chats/chat_bot_a/drawer/thread/close/thr_alpha")
|
||||
assert response.status_code == 200
|
||||
|
||||
with open_db(tmp_path / "test.db") as conn:
|
||||
closed = conn.execute(
|
||||
"SELECT payload_json FROM event_log WHERE kind = 'thread_closed'"
|
||||
).fetchall()
|
||||
assert len(closed) == 1
|
||||
cp = json.loads(closed[0][0])
|
||||
assert cp["thread_id"] == "thr_alpha"
|
||||
|
||||
from chat.state.threads import list_open_threads
|
||||
|
||||
assert list_open_threads(conn, "chat_bot_a") == []
|
||||
Reference in New Issue
Block a user