merge: T59 drawer events / threads / skip controls
This commit is contained in:
@@ -41,6 +41,121 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% 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>
|
</section>
|
||||||
|
|
||||||
{% if guest_bot %}
|
{% 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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
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.relationship_seed import seed_inter_bot_edges
|
||||||
from chat.services.scene_summarize import apply_scene_close_summary
|
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.edges import get_edge
|
||||||
from chat.state.entities import get_bot, get_you, list_bots
|
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.group_node import get_group_node
|
||||||
from chat.state.memory import get_pinned
|
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.state.world import active_scene, get_activity, get_chat, get_container
|
||||||
from chat.web.bots import get_conn
|
from chat.web.bots import get_conn
|
||||||
from chat.web.kickoff import get_llm_client
|
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"])
|
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(
|
return TEMPLATES.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"_drawer.html",
|
"_drawer.html",
|
||||||
@@ -180,6 +192,8 @@ async def drawer(chat_id: str, request: Request, conn=Depends(get_conn)):
|
|||||||
"recent_memories": recent_memories,
|
"recent_memories": recent_memories,
|
||||||
"pinned": pinned,
|
"pinned": pinned,
|
||||||
"pin_cap": PIN_CAP,
|
"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)
|
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