5 Commits

Author SHA1 Message Date
Joseph Doherty a302ed427a feat: error banners and first-run navigation flow 2026-04-26 14:33:28 -04:00
Joseph Doherty 0353d592cd feat: streaming UX with Stop, disconnect handling, send-lock 2026-04-26 14:27:39 -04:00
Joseph Doherty 330077afcf feat: transcript display formatting with markdown and OOC styling 2026-04-26 14:22:43 -04:00
Joseph Doherty 8390703b73 feat: nightly DB backups with 14-day retention 2026-04-26 14:18:57 -04:00
Joseph Doherty b9644fad31 feat: periodic snapshots with retention and cold-load fast-path 2026-04-26 14:15:17 -04:00
20 changed files with 1534 additions and 34 deletions
+18
View File
@@ -149,3 +149,21 @@ Don't jump phases. Phase 1 must work end-to-end before Phase 2 lands.
- Inference hosting (start with a cloud API, re-evaluate later)
- Character template format (during Phase 1)
- Multi-session / multi-character casts: **out of scope for v1**. Leave cheap schema hooks only.
## Phase 1 status
Phase 1 shipped end-to-end across **35 tasks** (T0T35). The single-bot core loop is functional: event log + projector, schema + migrations, settings/bot authoring, kickoff confirm, streaming turns, drawer rendering, regenerate/rewind, scene close + per-POV summaries, significance classifier, snapshots/backups, first-run navigation, and friendly 404/500 pages. **168 tests passing.**
Deferred to Phase 2: second bot, group node, scene configurations, witness filtering across multi-entity scenes, activity/containers, scene-transition compression. Phase 3: event queue + triggers, time skips, active threads. Phase 4: vector retrieval, branching, surgical delete + regenerate, impact-preview UI.
### Known v1 limitations (read before extending)
- **Drawer edits scope**: only affinity, significance, and pin can be hand-edited from the drawer. Other v1 fields (knowledge, summary text, traits) are deferred to Phase 1.5.
- **Cold-load snapshot path** is wired and unit-tested but rarely exercised in dev — long-running sessions are the only realistic trigger.
- **WAL sidecar files** (`-wal`, `-shm`) are not captured in nightly backups; the nightly snapshot is a fresh `.backup()` so this is fine for restore but worth knowing if you copy the db file by hand.
- **HTMX SSE event names** may need a version check if you bump the htmx CDN URL in `base.html` — the swap targets are name-coupled.
- **"You" activity rows** can linger after `bot_reset` (the reset purges the bot's chats and the bot's own activity row but not the "you" row that was associated with those chats). Cosmetic, fixed in Phase 1.5.
- **Projector replay is non-idempotent** for plain `INSERT` events. After appending, call `apply_event(conn, event)` for the new row only — calling `project(conn)` re-runs every handler from scratch and will trip uniqueness or duplicate inserts.
- **8-pin auto-cap eviction** is FIFO over the auto-pinned set only. Manual pins survive the eviction; this is by design (manual intent > auto-pin signal).
- **Regenerate (T29) does not broadcast `turn_html` over SSE** — the page must refresh to show the regenerated turn. Acceptable for v1 single-tab usage; Phase 1.5 should wire the SSE event.
- **First-run middleware** fires only on bare `/` and `/chats`. Sub-paths like `/chats/<id>` and `/chats/<id>/drawer` pass through (correct: HTMX partials should not page-redirect, and a deep-link to a missing chat should 404, not redirect mid-setup).
+56 -1
View File
@@ -1,13 +1,22 @@
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.exceptions import HTTPException as StarletteHTTPException
from chat.config import load_settings
from chat.db.connection import open_db
from chat.db.migrate import apply_migrations
from chat.eventlog.log import read_events
from chat.eventlog.projector import apply_event
from chat.services.background import BackgroundWorker
from chat.services.snapshot import latest_snapshot_path, restore_from_snapshot
# Trigger handler registration:
import chat.state.entities # noqa: F401
@@ -20,17 +29,40 @@ from chat.web.bots import router as bots_router
from chat.web.chat import router as chat_router
from chat.web.drawer import router as drawer_router
from chat.web.kickoff import router as kickoff_router
from chat.web.middleware import FirstRunRedirectMiddleware
from chat.web.nav import router as nav_router
from chat.web.settings import router as settings_router
from chat.web.sse import router as sse_router
from chat.web.turns import router as turns_router
log = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
settings = load_settings()
settings.db_path.parent.mkdir(parents=True, exist_ok=True)
apply_migrations(settings.db_path)
# T31 cold-load fast-path: if a periodic snapshot exists, restore
# projected tables from it and replay only events past its
# ``last_event_id``. Migrations already ran above, so any new tables
# introduced after the snapshot was taken are present and empty —
# the replay-forward step refills them from the event log.
snapshot_path = latest_snapshot_path(settings.data_dir, kind="periodic")
if snapshot_path is not None:
with open_db(settings.db_path) as conn:
last_event_id = restore_from_snapshot(conn, snapshot_path)
for event in read_events(
conn, branch_id=1, after_id=last_event_id
):
apply_event(conn, event)
log.info(
"cold-load restored from %s, replayed events past id %d",
snapshot_path,
last_event_id,
)
app.state.settings = settings
# Background worker for the async significance pass (T22). Each job
@@ -55,10 +87,33 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="chat", lifespan=lifespan)
app.add_middleware(FirstRunRedirectMiddleware)
STATIC_DIR = Path(__file__).resolve().parent / "static"
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
ERROR_TEMPLATES = Jinja2Templates(
directory=str(Path(__file__).resolve().parent / "templates")
)
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(request: Request, exc: StarletteHTTPException):
"""Render a friendly HTML page for 404/500; JSON for everything else."""
if exc.status_code in (404, 500):
return ERROR_TEMPLATES.TemplateResponse(
request,
"errors.html",
{
"status_code": exc.status_code,
"detail": exc.detail or "Something went wrong.",
"active_nav": "chats",
},
status_code=exc.status_code,
)
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
app.include_router(bots_router)
app.include_router(kickoff_router)
app.include_router(settings_router)
+8
View File
@@ -37,4 +37,12 @@ def load_settings() -> Settings:
raw = tomllib.loads(config_path.read_text())
if "CHAT_DB_PATH" in os.environ:
raw["db_path"] = Path(os.environ["CHAT_DB_PATH"])
if "CHAT_DATA_DIR" in os.environ:
raw["data_dir"] = Path(os.environ["CHAT_DATA_DIR"])
elif "data_dir" not in raw and "db_path" in raw:
# T31: when ``CHAT_DB_PATH`` is overridden (typical in tests) but
# ``data_dir`` isn't, derive ``data_dir`` from the db's parent so
# snapshot/auxiliary files stay alongside the test db rather than
# leaking into the real repo data dir.
raw["data_dir"] = Path(raw["db_path"]).parent
return Settings(**raw)
+89
View File
@@ -32,7 +32,22 @@ from chat.config import Settings
from chat.db.connection import open_db
from chat.eventlog.log import append_and_apply
from chat.llm.client import LLMClient
from chat.services.backup import (
prune_backups,
should_take_backup,
take_backup,
)
from chat.services.significance import compute_significance
from chat.services.snapshot import (
prune_periodic_snapshots,
should_take_periodic_snapshot,
take_snapshot,
)
# T32: tick-loop wake interval. 60s gives a single backup window per
# target hour with plenty of slack: should_take_backup's 23h freshness
# guard prevents back-to-back runs.
BACKUP_TICK_INTERVAL_SECONDS = 60.0
log = logging.getLogger(__name__)
@@ -70,14 +85,27 @@ class BackgroundWorker:
self._llm_client_factory = llm_client_factory
self._queue: asyncio.Queue[SignificanceJob | None] = asyncio.Queue()
self._task: asyncio.Task | None = None
# T32: nightly-backup tick loop runs alongside the job loop. The
# event is set by stop() to wake the loop early so shutdown is
# snappy even mid-tick.
self._tick_task: asyncio.Task | None = None
self._tick_stop: asyncio.Event = asyncio.Event()
self.enabled = enabled
async def start(self) -> None:
if self._task is not None:
return
self._task = asyncio.create_task(self._run())
self._tick_task = asyncio.create_task(self._tick_loop())
async def stop(self) -> None:
# Stop the tick loop first — it has no in-flight work to drain,
# so signalling early lets it exit while the job loop is still
# finishing its sentinel handoff.
self._tick_stop.set()
if self._tick_task is not None:
await self._tick_task
self._tick_task = None
if self._task is None:
return
await self._queue.put(None) # sentinel
@@ -99,6 +127,40 @@ class BackgroundWorker:
except Exception as exc: # noqa: BLE001 — worker must not die
log.exception("significance job failed: %s", exc)
async def _tick_loop(self) -> None:
"""Periodic-operations loop (T32 nightly backup).
Wakes every :data:`BACKUP_TICK_INTERVAL_SECONDS` seconds and
asks :func:`should_take_backup` whether a backup is due. The
scheduling decision lives in the backup module so we don't
duplicate the "is it 03:00?" logic here. Failures are caught
and logged so a flaky disk doesn't kill the loop — the next
tick will retry.
Wait uses :func:`asyncio.wait_for` on ``_tick_stop`` so that
:meth:`stop` can interrupt a sleeping tick instead of having to
wait the full interval.
"""
while not self._tick_stop.is_set():
try:
if should_take_backup(self._settings.data_dir):
take_backup(
db_path=self._settings.db_path,
data_dir=self._settings.data_dir,
)
prune_backups(self._settings.data_dir, keep=14)
log.info("nightly backup taken")
except Exception as exc: # noqa: BLE001 — never break the loop
log.exception("backup tick failed: %s", exc)
try:
await asyncio.wait_for(
self._tick_stop.wait(),
timeout=BACKUP_TICK_INTERVAL_SECONDS,
)
except asyncio.TimeoutError:
# Normal path: timed out waiting for stop, run another tick.
pass
async def _process(self, job: SignificanceJob) -> None:
client = self._llm_client_factory()
score = await compute_significance(
@@ -123,6 +185,33 @@ class BackgroundWorker:
memory_id=job.memory_id,
)
# T31: piggy-back the periodic snapshot check on the background
# worker so we don't need a separate timer task. The classifier
# pass already runs out-of-band, so snapshot I/O on the same
# worker is a natural fit. Each snapshot opens its own
# connection so we don't conflate the snapshot's read-only view
# with the significance-write transaction above. Failures are
# caught and logged: a flaky disk shouldn't take down the
# significance pipeline.
try:
with open_db(self._settings.db_path) as conn:
if should_take_periodic_snapshot(
conn, self._settings.data_dir
):
snapshot_path = take_snapshot(
conn,
data_dir=self._settings.data_dir,
kind="periodic",
)
prune_periodic_snapshots(
self._settings.data_dir, keep=5
)
log.info(
"periodic snapshot taken: %s", snapshot_path
)
except Exception as exc: # noqa: BLE001 — never break the worker
log.exception("periodic snapshot failed: %s", exc)
def _auto_pin_with_cap(
conn,
+106
View File
@@ -0,0 +1,106 @@
"""Nightly DB backup service (T32, Requirements §12).
A simple in-process scheduler: at 03:00 local time daily, copy
``chat.db`` to ``data/backups/chat-<utc-timestamp>.db`` and prune to the
14 most recent. The BackgroundWorker tick loop calls
:func:`should_take_backup` every 60 seconds; when it returns True the
worker calls :func:`take_backup` then :func:`prune_backups`.
The launchd plist suggested in §12 can replace this later by invoking a
small script that calls :func:`take_backup` directly. For v1 the
in-process loop is enough — the daemon already runs continuously to
serve requests, so there's no extra moving part to install.
Backups capture the live ``.db`` file via :func:`shutil.copy2`. SQLite's
WAL mode means an in-flight transaction's pages might live in the
``-wal`` sidecar rather than the main file, but our codebase commits
every write transaction synchronously, so the .db alone is sufficient
for v1. A truly safe online backup would use
``sqlite3.Connection.backup()``; deferred.
"""
from __future__ import annotations
import shutil
from datetime import datetime, timezone
from pathlib import Path
# 03:00 local time per Requirements §12. Hardcoded for v1 — making this
# configurable via Settings is straightforward but not needed yet.
DEFAULT_BACKUP_HOUR = 3
# Retention window per Requirements §12 ("Last 14 retained").
DEFAULT_KEEP = 14
# Wake interval for should_take_backup's freshness check. We wake the
# tick loop every 60s, so a backup taken in the previous tick within the
# same target hour must NOT trigger another. 23h gives us a generous
# safety margin against scheduling jitter while still allowing a single
# backup per day.
FRESHNESS_HOURS = 23
def take_backup(*, db_path: Path, data_dir: Path) -> Path:
"""Copy ``db_path`` to ``data_dir/backups/chat-<utc-timestamp>.db``.
Returns the new file path. Creates the backup directory if missing.
Uses :func:`shutil.copy2` so the destination's mtime is preserved —
:func:`should_take_backup` reads mtime to gate fresh backups.
"""
backup_dir = data_dir / "backups"
backup_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
backup_path = backup_dir / f"chat-{timestamp}.db"
shutil.copy2(db_path, backup_path)
return backup_path
def prune_backups(data_dir: Path, *, keep: int = DEFAULT_KEEP) -> int:
"""Remove all but the most recent ``keep`` backup files.
Returns the number of files removed. Safe when the directory is
missing (returns 0). Sorting is by filename, which is the UTC
timestamp embedded in the name — lexicographic order matches
chronological order.
"""
backup_dir = data_dir / "backups"
if not backup_dir.exists():
return 0
files = sorted(backup_dir.glob("chat-*.db"))
to_remove = files[:-keep] if len(files) > keep else []
for f in to_remove:
f.unlink()
return len(to_remove)
def should_take_backup(
data_dir: Path, *, target_hour: int = DEFAULT_BACKUP_HOUR
) -> bool:
"""Decide whether a nightly backup is due.
Two conditions must hold:
* The current local hour matches ``target_hour``.
* No backup file in ``data_dir/backups/`` has an mtime within the
last :data:`FRESHNESS_HOURS` (23h). The 23h window prevents a
double-backup within the same target hour while still allowing
the next day's run to fire on time.
Local time (not UTC) is used for the hour comparison per the
requirements ("03:00 local time"). The filename embeds a UTC stamp
so file ordering remains unambiguous across DST transitions.
"""
now = datetime.now()
if now.hour != target_hour:
return False
backup_dir = data_dir / "backups"
if not backup_dir.exists():
return True
files = list(backup_dir.glob("chat-*.db"))
if not files:
return True
most_recent = max(files, key=lambda f: f.stat().st_mtime)
age_hours = (
datetime.now().timestamp() - most_recent.stat().st_mtime
) / 3600
return age_hours >= FRESHNESS_HOURS
+154 -9
View File
@@ -1,26 +1,42 @@
"""Snapshot service — write a JSON dump of all projected tables to disk.
Used by the rewind flow (Requirements §10.1, T28) so the user can recover a
pre-rewind state if the rewind was a mistake. Stored under
``data/snapshots/{kind}/`` with a UTC timestamp filename.
Two snapshot kinds, both covered by this module:
The dump captures both the event log (so the original event sequence is
preserved verbatim) and every projected table (so a future restore could
either re-load tables directly or re-project from the saved event log).
* ``rewind`` (T28, Requirements §10.1): pre-rewind safety snapshot so the
user can recover if a rewind was a mistake. Retention: 14 days.
* ``periodic`` (T31, Requirements §10.4): full-state checkpoint taken
every 100 events OR every 30 minutes since the last one. Retention:
the most recent 5 are kept; older ones are pruned on write.
Both kinds live under ``data/snapshots/{kind}/`` with a UTC timestamp
filename so chronological listing matches creation order.
The dump captures the event log (so the original event sequence is
preserved verbatim), every projected table, and a top-level
``last_event_id`` recording the highest ``event_log.id`` at snapshot
time. The ``last_event_id`` is what the cold-load fast-path uses to
replay only events past the snapshot rather than the entire log.
The FTS shadow table ``memories_fts`` is intentionally skipped — it's a
virtual table maintained by the ``memories_ai/au/ad`` triggers, so it would
rebuild itself on a memories re-load. Snapshotting it would also fail
virtual table maintained by the ``memories_ai/au/ad`` triggers, so it
rebuilds itself on a memories re-load. Snapshotting it would also fail
``PRAGMA table_info`` cleanly since FTS5 reports its columns differently.
"""
from __future__ import annotations
import json
import time
from datetime import datetime, timezone
from pathlib import Path
from sqlite3 import Connection
# Periodic snapshot triggers (Requirements §10.4): "every 100 events OR
# every 30 minutes since last snapshot". Module-level so tests can read
# them and so the values stay together with the policy that uses them.
EVENT_COUNT_THRESHOLD = 100
TIME_THRESHOLD_SECONDS = 30 * 60 # 30 minutes
# Order doesn't affect correctness for snapshotting (we read, not write),
# but listing tables explicitly keeps the snapshot stable across schema
# evolution: a new table won't silently change the dump shape until it's
@@ -49,13 +65,24 @@ def take_snapshot(
directories as needed. Filename is a UTC timestamp in
``YYYYMMDDTHHMMSSZ`` form so chronological listing matches creation
order.
The dump's top-level ``last_event_id`` is the highest ``event_log.id``
at snapshot time (0 if the log is empty). This is what the cold-load
fast-path uses to know which suffix of the log to replay.
"""
snapshot_dir = data_dir / "snapshots" / kind
snapshot_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
path = snapshot_dir / f"{timestamp}.json"
dump: dict[str, list] = {}
dump: dict = {}
# Record the high-water-mark id up front so cold-load can replay
# only events past it. ``MAX(id)`` is None on an empty log; treat
# that as 0 (i.e. "replay everything").
cur = conn.execute("SELECT MAX(id) FROM event_log")
max_id_row = cur.fetchone()
dump["last_event_id"] = max_id_row[0] if max_id_row[0] is not None else 0
# Event log: pull every column we care about. ``ts`` and the
# superseded/hidden flags are needed to faithfully reconstruct the
@@ -98,3 +125,121 @@ def take_snapshot(
# all use TEXT so this is mostly defensive.
path.write_text(json.dumps(dump, default=str))
return path
def latest_snapshot_path(data_dir: Path, kind: str = "periodic") -> Path | None:
"""Return the most recent snapshot file for ``kind``, or None if none exist.
Sorting by filename works because :func:`take_snapshot` uses a UTC
timestamp in ``YYYYMMDDTHHMMSSZ`` form — lexicographic order matches
chronological order.
"""
snapshot_dir = data_dir / "snapshots" / kind
if not snapshot_dir.exists():
return None
files = sorted(snapshot_dir.glob("*.json"))
return files[-1] if files else None
def should_take_periodic_snapshot(
conn: Connection, data_dir: Path
) -> bool:
"""Decide whether a periodic snapshot is due per Requirements §10.4.
The policy:
* No prior snapshot and at least one event in the log → take one.
* Time since last snapshot ≥ ``TIME_THRESHOLD_SECONDS`` → take one.
* New events since last snapshot's ``last_event_id`` ≥
``EVENT_COUNT_THRESHOLD`` → take one.
"Time since last snapshot" is measured by the file's mtime — we
don't trust the timestamp embedded in the filename for clock drift
reasons.
"""
latest = latest_snapshot_path(data_dir, kind="periodic")
if latest is None:
# No prior snapshot; take one if there are any events to capture.
cur = conn.execute("SELECT COUNT(*) FROM event_log")
return cur.fetchone()[0] > 0
age_seconds = time.time() - latest.stat().st_mtime
if age_seconds >= TIME_THRESHOLD_SECONDS:
return True
# Count events appended since the last snapshot was written. Reading
# ``last_event_id`` from the dump is cheap (a few KB at most for the
# header) but we still avoid loading the full file by parsing once.
last_dump = json.loads(latest.read_text())
last_event_id = last_dump.get("last_event_id", 0)
cur = conn.execute(
"SELECT COUNT(*) FROM event_log WHERE id > ?", (last_event_id,)
)
new_event_count = cur.fetchone()[0]
return new_event_count >= EVENT_COUNT_THRESHOLD
def prune_periodic_snapshots(data_dir: Path, keep: int = 5) -> int:
"""Delete all but the most recent ``keep`` periodic snapshots.
Returns the number of files removed. Safe to call when the directory
doesn't exist (returns 0). Sorting is by filename, which is the UTC
timestamp — same ordering :func:`latest_snapshot_path` uses.
"""
snapshot_dir = data_dir / "snapshots" / "periodic"
if not snapshot_dir.exists():
return 0
files = sorted(snapshot_dir.glob("*.json"))
to_remove = files[:-keep] if len(files) > keep else []
for f in to_remove:
f.unlink()
return len(to_remove)
def restore_from_snapshot(conn: Connection, snapshot_path: Path) -> int:
"""Restore projected tables from ``snapshot_path``.
Returns the snapshot's ``last_event_id`` so callers (the cold-load
fast-path in :func:`chat.app.lifespan`) know what suffix of the
event log still needs replaying.
Projected tables are cleared in the same FK-respecting order as
:func:`chat.services.rewind.execute_rewind`, then re-populated from
the dump. ``memories_fts`` is skipped — it's a virtual FTS5 table
that rebuilds itself when rows hit ``memories``. The event log
itself is *not* touched: cold-load assumes the on-disk log is the
source of truth and the snapshot is just a fast-forward to skip
re-projecting old events.
"""
dump = json.loads(snapshot_path.read_text())
# Same delete order as rewind: child tables before parents so FK
# ON DELETE doesn't fire on referenced rows.
conn.execute("DELETE FROM memories")
conn.execute("DELETE FROM activity")
conn.execute("DELETE FROM scenes")
conn.execute("DELETE FROM containers")
conn.execute("DELETE FROM chat_state")
conn.execute("DELETE FROM chats")
conn.execute("DELETE FROM edges")
conn.execute("DELETE FROM bots")
conn.execute("DELETE FROM you_entity")
conn.execute("DELETE FROM classifier_failures")
for table in PROJECTED_TABLES:
if table == "memories_fts":
# Rebuilt by triggers when memories rows are inserted below.
continue
rows = dump.get(table, [])
if not rows:
continue
cols = list(rows[0].keys())
placeholders = ", ".join("?" * len(cols))
col_list = ", ".join(cols)
for row in rows:
conn.execute(
f"INSERT INTO {table} ({col_list}) VALUES ({placeholders})",
tuple(row[c] for c in cols),
)
return dump.get("last_event_id", 0)
+34
View File
@@ -75,6 +75,30 @@ code { font-family: ui-monospace, "SF Mono", Menlo, monospace; }
.drawer-toggle { padding: 4px 10px; border: 1px solid #ccc; background: #fff; color: #1c1c1c; border-radius: 3px; cursor: pointer; }
.timeline { flex: 1; overflow-y: auto; min-height: 200px; padding: 8px 0; }
.turn { margin: 12px 0; }
.turn strong { display: block; margin-bottom: 4px; }
.turn p { margin: 0 0 8px; }
.turn p:last-child { margin-bottom: 0; }
.turn-you strong { color: #1a73e8; }
.turn-bot strong { color: #1c1c1c; }
/* ``*action*`` — italic narration. */
.action { font-style: italic; color: #555; }
/* ``((ooc))`` — author-to-system aside. Dim, italic, smaller, set off
from surrounding prose so it doesn't read as in-fiction speech. */
.ooc {
font-style: italic;
font-size: 12px;
color: #999;
display: inline-block;
background: rgba(0, 0, 0, 0.04);
padding: 1px 4px;
border-radius: 3px;
}
.turn blockquote {
border-left: 3px solid #ccc;
padding-left: 12px;
margin: 8px 0;
color: #555;
}
.turn-input { display: flex; flex-direction: column; gap: 8px; padding-top: 12px; border-top: 1px solid #e5e5e5; }
.turn-input textarea { padding: 8px; font: inherit; border: 1px solid #ccc; border-radius: 3px; resize: vertical; }
.drawer { position: fixed; top: 0; right: 0; width: 360px; height: 100vh; background: #fff; border-left: 1px solid #e5e5e5; padding: 16px; overflow-y: auto; z-index: 10; }
@@ -89,3 +113,13 @@ code { font-family: ui-monospace, "SF Mono", Menlo, monospace; }
.memory-list li { padding: 4px 0; font-size: 13px; }
.sig { display: inline-block; min-width: 16px; }
.sig-3 { color: #d4af37; }
/* Streaming UX (T34): typing indicator, Stop button, disconnect banner. */
.streaming { opacity: 0.85; }
.streaming-text:after {
content: "\025AE";
margin-left: 2px;
animation: blink 1s steps(2, start) infinite;
}
@keyframes blink { to { visibility: hidden; } }
.stop-streaming { background: #c33; border-color: #a00; margin-bottom: 8px; align-self: flex-start; }
.connection-lost { margin-bottom: 8px; }
+104 -1
View File
@@ -19,7 +19,7 @@
{% for turn in turns %}
<div class="turn turn-{{ turn.role }}">
<strong>{{ turn.speaker }}</strong>
<p>{{ turn.text }}</p>
{{ turn.text|render_prose|safe }}
</div>
{% endfor %}
{% endif %}
@@ -46,4 +46,107 @@ document.querySelector('.drawer-toggle')?.addEventListener('click', (e) => {
e.target.setAttribute('aria-expanded', String(isHidden));
});
</script>
<script>
// Streaming UX (T34): typing indicator, Stop button, send-lock,
// disconnect banner. Listens to the existing HTMX SSE channel for
// `token` (per-chunk) and `turn_html` (final swap) events. The
// mid-stream disconnect path is server-side: ``request.is_disconnected()``
// in T19 commits truncated; this script just shows the banner when
// the SSE EventSource fires `error` after the connection drops.
(function () {
const shell = document.querySelector('.chat-shell');
if (!shell) return;
const chatId = shell.dataset.chatId;
const form = shell.querySelector('.turn-input');
if (!form) return;
const textarea = form.querySelector('textarea[name="prose"]');
const sendBtn = form.querySelector('button[type="submit"]');
const timeline = document.getElementById('timeline');
let isStreaming = false;
let typingEl = null;
function ensureTypingEl() {
if (typingEl) return typingEl;
typingEl = document.createElement('div');
typingEl.className = 'turn turn-bot streaming';
typingEl.innerHTML = '<strong>...</strong><p class="streaming-text"></p>';
timeline.appendChild(typingEl);
return typingEl;
}
function unlock() {
isStreaming = false;
if (sendBtn) sendBtn.disabled = false;
if (textarea) textarea.disabled = false;
const stop = shell.querySelector('.stop-streaming');
if (stop) stop.remove();
}
function showBanner(msg) {
let banner = shell.querySelector('.connection-lost');
if (banner) return;
banner = document.createElement('div');
banner.className = 'connection-lost error';
banner.textContent = msg;
form.parentElement.insertBefore(banner, form);
}
// HTMX SSE extension dispatches `htmx:sseMessage` with detail.type
// (event name) and detail.data (payload string).
shell.addEventListener('htmx:sseMessage', (e) => {
const evt = e.detail.type;
const data = e.detail.data;
if (evt === 'token' && isStreaming) {
let parsed;
try { parsed = JSON.parse(data); } catch (_) { return; }
const el = ensureTypingEl();
el.querySelector('.streaming-text').textContent += (parsed.text || '');
} else if (evt === 'turn_html') {
// The server already pushes the final HTML via sse-swap on the
// timeline element; we just remove the typing placeholder and
// unlock the input. (Don't replace innerHTML here — HTMX has
// already done the append by the time this fires.)
if (typingEl) {
typingEl.remove();
typingEl = null;
}
unlock();
}
});
// SSE connection lost — show a banner and unlock so the user can
// retry. The server commits the partial as truncated when its
// request.is_disconnected() poll trips (T19).
shell.addEventListener('htmx:sseError', () => {
if (isStreaming) {
showBanner('connection lost — partial response saved');
unlock();
}
});
form.addEventListener('submit', () => {
isStreaming = true;
if (sendBtn) sendBtn.disabled = true;
if (textarea) textarea.disabled = true;
if (!shell.querySelector('.stop-streaming')) {
const stopBtn = document.createElement('button');
stopBtn.type = 'button';
stopBtn.className = 'stop-streaming btn';
stopBtn.textContent = 'Stop';
stopBtn.addEventListener('click', async () => {
try {
await fetch('/chats/' + encodeURIComponent(chatId) + '/turns/cancel', {
method: 'POST',
});
} catch (_) {
// Network error on cancel is non-fatal — server will time out
// its own stream eventually and commit truncated.
}
});
form.parentElement.insertBefore(stopBtn, form);
}
});
})();
</script>
{% endblock %}
+9
View File
@@ -0,0 +1,9 @@
{% extends "layout.html" %}
{% block title %}Error - chat{% endblock %}
{% block content %}
<div class="error-page">
<h1>{{ status_code }}</h1>
<p>{{ detail }}</p>
<p><a href="/chats">Back to chats</a></p>
</div>
{% endblock %}
+6
View File
@@ -16,11 +16,17 @@ from fastapi.templating import Jinja2Templates
from chat.state.entities import get_bot
from chat.state.world import get_chat
from chat.web.bots import get_conn
from chat.web.render import render_prose
from chat.web.turns import _read_recent_dialogue
TEMPLATES = Jinja2Templates(
directory=str(Path(__file__).resolve().parent.parent / "templates")
)
# Register the prose renderer as a Jinja filter so the chat-detail
# template can use ``{{ turn.text|render_prose|safe }}`` (Task 33).
# The renderer escapes user content internally; ``|safe`` is required
# because the output contains intentional ``<p>``/``<em>``/etc. tags.
TEMPLATES.env.filters["render_prose"] = render_prose
router = APIRouter()
+61
View File
@@ -0,0 +1,61 @@
from __future__ import annotations
from fastapi import Request
from fastapi.responses import RedirectResponse
from starlette.middleware.base import BaseHTTPMiddleware
from chat.db.connection import open_db
from chat.state.entities import get_you, list_bots
class FirstRunRedirectMiddleware(BaseHTTPMiddleware):
"""Redirect users through the first-run flow (per requirements §16.2).
Behavior on GET requests to landing routes (``/`` and ``/chats``):
- No ``you_entity`` → ``/settings``
- ``you_entity`` exists but no bots → ``/bots/new``
- Otherwise pass through to the underlying handler.
The middleware is a no-op for:
- Non-GET requests (POST/PUT writes proceed and surface their own errors).
- Static assets, health checks, and any path under ``/settings``,
``/bots``, ``/api``, ``/health``, ``/favicon`` — so the user can
actually complete setup once redirected.
- Sub-paths of ``/chats`` (e.g. ``/chats/<id>``, ``/chats/<id>/drawer``);
only the bare landing pages get the redirect treatment. Sub-resources
either 404 cleanly or are HTMX partials that should not page-redirect.
"""
SKIP_PREFIXES = (
"/static",
"/settings",
"/bots",
"/health",
"/favicon",
"/api",
)
async def dispatch(self, request: Request, call_next):
if request.method != "GET":
return await call_next(request)
path = request.url.path
if any(path.startswith(p) for p in self.SKIP_PREFIXES):
return await call_next(request)
# Only fire on the landing routes themselves.
if path != "/" and path != "/chats":
return await call_next(request)
settings = request.app.state.settings
with open_db(settings.db_path) as conn:
you = get_you(conn)
bots = list_bots(conn)
if you is None:
return RedirectResponse(url="/settings", status_code=303)
if not bots:
return RedirectResponse(url="/bots/new", status_code=303)
return await call_next(request)
+106
View File
@@ -0,0 +1,106 @@
"""Transcript display formatting (Task 33, Requirements §16.3).
Bot and user prose is rendered with **lightweight markdown**:
* ``*action*`` → ``<em class="action">…</em>`` — italic narration.
* ``**bold**`` → ``<strong>…</strong>`` — emphasis.
* ``((ooc))`` → ``<span class="ooc">((ooc))</span>`` — author-to-system
asides; visible to the reader, dimmed/italic in CSS, and stripped from
the prompt sent to the bot (see :func:`chat.web.turns._strip_ooc_for_prompt`).
* ``> line`` → ``<blockquote>line</blockquote>``.
* Double newline → paragraph break.
* Everything else is HTML-escaped and wrapped in ``<p>…</p>``.
No headings, code blocks, links, images, or tables — out of scope per
Requirements §16.3. The renderer is the single source of truth used by
both the chat-detail GET (initial timeline render, via Jinja filter) and
the per-turn SSE fragments emitted from :mod:`chat.web.turns`.
Order of operations matters:
1. ``html.escape`` the whole input first — every replacement below assumes
user-supplied ``<``/``>``/``&`` are already neutralised, so the wrapper
tags we add can never collide with an attacker-controlled tag.
2. OOC wrap before action/bold so its inner ``*`` are not interpreted.
3. Bold (``**``) before action (``*``) — the bold pattern is stricter and
would otherwise be partially consumed by the action regex.
4. Blockquote pass over already-escaped lines (so we match ``&gt;``).
5. Paragraph split on double newline.
"""
from __future__ import annotations
import html
import re
# ``((…))`` — non-greedy, allows newlines so a multi-line OOC aside still
# wraps cleanly. The inner ``[^)]*?`` keeps it from spanning across a
# closing-paren boundary.
_OOC_PATTERN = re.compile(r"\(\([^)]*?\)\)", re.DOTALL)
# ``**bold**`` — strict: no embedded asterisks or newlines. Must run
# *before* the single-asterisk action pattern, otherwise ``**x**`` would
# be partly consumed by ``*…*``.
_BOLD_PATTERN = re.compile(r"\*\*([^*\n]+)\*\*")
# ``*action*`` — single-asterisk italics; same restriction as bold.
_ACTION_PATTERN = re.compile(r"\*([^*\n]+)\*")
# ``> line`` at start of a line — note we match the *escaped* form
# ``&gt;`` because this pass runs after ``html.escape``.
_BLOCKQUOTE_PATTERN = re.compile(r"^&gt;\s?(.+)$", re.MULTILINE)
def render_prose(text: str) -> str:
"""Render prose to safe HTML.
Returns an empty string for empty/whitespace-only input so the caller
can append the result without producing stray ``<p></p>`` tags.
"""
if not text or not text.strip():
return ""
# Normalise CRLF so paragraph splitting on ``\n\n`` works for input
# pasted from Windows clients.
text = text.replace("\r\n", "\n").replace("\r", "\n")
escaped = html.escape(text)
# OOC first — the wrapped span survives subsequent passes.
escaped = _OOC_PATTERN.sub(
lambda m: f'<span class="ooc">{m.group(0)}</span>', escaped
)
# Bold strictly before action (regex precedence — see module docstring).
escaped = _BOLD_PATTERN.sub(r"<strong>\1</strong>", escaped)
escaped = _ACTION_PATTERN.sub(r'<em class="action">\1</em>', escaped)
# Blockquote on already-escaped ``&gt;`` markers.
escaped = _BLOCKQUOTE_PATTERN.sub(r"<blockquote>\1</blockquote>", escaped)
# Paragraph splitting — drop empty fragments so a trailing ``\n\n``
# doesn't yield an empty ``<p></p>`` block.
paragraphs = [p.strip() for p in escaped.split("\n\n") if p.strip()]
return "".join(f"<p>{p}</p>" for p in paragraphs)
def render_turn_html(speaker: str, text: str, role: str = "bot") -> str:
"""Render a full transcript turn as ``<div class="turn …">…</div>``.
Used by both the SSE fragment publisher in :mod:`chat.web.turns`
(per-turn live updates) and indirectly by the chat-detail Jinja
template (initial render, via the ``render_prose`` filter).
``role`` selects the CSS class (``turn-you`` vs ``turn-bot``); the
speaker label and role name are HTML-escaped defensively even though
they currently come from trusted server-side state.
"""
speaker_html = html.escape(speaker)
role_html = html.escape(role)
body_html = render_prose(text)
return (
f'<div class="turn turn-{role_html}">'
f"<strong>{speaker_html}</strong>"
f"{body_html}"
f"</div>"
)
+57 -19
View File
@@ -53,10 +53,19 @@ from chat.state.world import active_scene, get_chat, get_container
from chat.web.bots import get_conn
from chat.web.kickoff import get_llm_client
from chat.web.pubsub import publish
from chat.web.render import render_turn_html as _render_turn_html
router = APIRouter()
# Module-level registry of in-flight streaming tasks, keyed by chat_id.
# The POST /chats/<id>/turns/cancel route looks up the task and calls
# .cancel(); the streaming coroutine in post_turn catches the resulting
# CancelledError, commits the partial as truncated, and unregisters.
# Single-process v1 only — sufficient for one user with multiple tabs.
_in_flight_tasks: dict[str, asyncio.Task] = {}
def _strip_ooc_for_prompt(parsed: ParsedTurn) -> str:
"""Concatenate non-OOC segments back to a prose string for the prompt.
@@ -69,16 +78,17 @@ def _strip_ooc_for_prompt(parsed: ParsedTurn) -> str:
def _read_recent_dialogue(conn, chat_id: str, limit: int = 200) -> list[dict]:
"""Return ``user_turn`` and ``assistant_turn`` events for ``chat_id``.
"""Return user-side and assistant_turn events for ``chat_id``.
Ordered oldest-first. Skips superseded and hidden rows so regenerated
turns (T29) drop out of the rendered timeline. Each entry is shaped
``{"speaker": <id-or-"you">, "text": <prose>}`` for the prompt
assembler and the chat-detail template.
Includes ``user_turn``, ``user_turn_edit`` (T29 edited prose), and
``assistant_turn``. Ordered oldest-first; superseded/hidden rows are
skipped so regenerated turns (T29) drop out of the rendered timeline.
Each entry is shaped ``{"speaker": <id-or-"you">, "text": <prose>}``
for the prompt assembler and the chat-detail template.
"""
cur = conn.execute(
"SELECT id, kind, payload_json FROM event_log "
"WHERE kind IN ('user_turn', 'assistant_turn') "
"WHERE kind IN ('user_turn', 'user_turn_edit', 'assistant_turn') "
" AND superseded_by IS NULL AND hidden = 0 "
"ORDER BY id DESC LIMIT ?",
(limit,),
@@ -90,7 +100,9 @@ def _read_recent_dialogue(conn, chat_id: str, limit: int = 200) -> list[dict]:
p = json.loads(payload_json)
if p.get("chat_id") != chat_id:
continue
if kind == "user_turn":
if kind in ("user_turn", "user_turn_edit"):
# Edited prose substitutes for the original user_turn (the
# original is marked superseded_by and filtered above).
out.append({"speaker": "you", "text": p.get("prose", "")})
else:
out.append(
@@ -102,16 +114,6 @@ def _read_recent_dialogue(conn, chat_id: str, limit: int = 200) -> list[dict]:
return out
def _render_turn_html(speaker_label: str, text: str, *, role: str) -> str:
"""Render a single turn as a small HTML fragment (escaped)."""
return (
f'<div class="turn turn-{role}">'
f"<strong>{html.escape(speaker_label)}</strong>"
f"<p>{html.escape(text)}</p>"
f"</div>"
)
@router.post("/chats/{chat_id}/turns")
async def post_turn(
chat_id: str,
@@ -182,11 +184,16 @@ async def post_turn(
budget_hard=settings.narrative_budget_hard,
)
# 5. Stream and accumulate tokens.
# 5. Stream and accumulate tokens. The stream runs as a Task so the
# /turns/cancel route can invoke ``Task.cancel()`` to abort it
# mid-stream. ``accumulated`` is a closure over the inner coroutine,
# so when the await on ``stream_task`` raises CancelledError below
# we still see whatever tokens were appended before cancellation.
accumulated: list[str] = []
truncated = False
cancelled = False
try:
async def _stream() -> None:
async for chunk in client.stream(
messages, model=settings.narrative_model
):
@@ -199,6 +206,11 @@ async def post_turn(
"speaker_id": host_bot["id"],
},
)
stream_task = asyncio.create_task(_stream())
_in_flight_tasks[chat_id] = stream_task
try:
await stream_task
except asyncio.CancelledError:
# Preserve the partial output before letting the cancellation
# propagate so the transcript reflects what the user actually saw.
@@ -207,6 +219,9 @@ async def post_turn(
except Exception:
# Surface as a truncated turn rather than losing the partial output.
truncated = True
finally:
# Always unregister so a subsequent turn can register a fresh task.
_in_flight_tasks.pop(chat_id, None)
full_text = "".join(accumulated)
@@ -412,6 +427,29 @@ async def post_turn(
return Response(status_code=204)
# ---------------------------------------------------------------------------
# Cancel route (Task 34).
#
# Fire-and-forget: the Stop button POSTs here, we mark the in-flight
# streaming Task as cancelled, and return 204 immediately. The cancel
# propagates into the streaming coroutine on its next await, the
# CancelledError handler in ``post_turn`` catches it, and the partial
# is committed with ``truncated=True``. No body is needed — the SSE
# channel is the conveyor of state. If no turn is in flight (or the
# task already completed), we 204 silently so the client can fire the
# Stop button without a precondition check.
# ---------------------------------------------------------------------------
@router.post("/chats/{chat_id}/turns/cancel")
async def cancel_turn(chat_id: str, request: Request):
task = _in_flight_tasks.get(chat_id)
if task is None or task.done():
return Response(status_code=204)
task.cancel()
return Response(status_code=204)
# ---------------------------------------------------------------------------
# Rewind routes (Task 28).
#
+92
View File
@@ -0,0 +1,92 @@
"""Tests for nightly DB backups (T32).
The backup service is intentionally simple: a flat ``data/backups/`` dir
containing timestamped copies of ``chat.db``, with retention of the most
recent 14. The scheduling decision (``should_take_backup``) is a pure
function of clock + filesystem state so it can be unit-tested without
spinning up the BackgroundWorker tick loop.
"""
from __future__ import annotations
from datetime import datetime
from unittest.mock import patch
from chat.services.backup import (
prune_backups,
should_take_backup,
take_backup,
)
def test_take_backup_creates_timestamped_copy(tmp_path):
db = tmp_path / "chat.db"
db.write_text("fake db contents")
backup_path = take_backup(db_path=db, data_dir=tmp_path / "data")
assert backup_path.exists()
assert backup_path.name.startswith("chat-")
assert backup_path.name.endswith(".db")
# Contents copied
assert backup_path.read_text() == "fake db contents"
# Located in data/backups/
assert backup_path.parent == tmp_path / "data" / "backups"
def test_prune_keeps_last_14(tmp_path):
backup_dir = tmp_path / "data" / "backups"
backup_dir.mkdir(parents=True)
# Create 17 dummy backup files spanning days 1..17 of Jan 2026.
# Filenames sort lexicographically by the embedded timestamp, so
# prune_backups should drop the three oldest.
for i in range(1, 18):
(backup_dir / f"chat-202601{i:02d}T000000Z.db").write_text(
f"backup {i}"
)
removed = prune_backups(tmp_path / "data", keep=14)
assert removed == 3
remaining = sorted(backup_dir.glob("chat-*.db"))
assert len(remaining) == 14
# Days 1, 2, 3 removed; day 4 is now the oldest retained backup.
assert remaining[0].name == "chat-20260104T000000Z.db"
def test_should_take_backup_when_no_prior_and_target_hour_matches(tmp_path):
from chat.services import backup as backup_mod
class FakeDateTime(datetime):
@classmethod
def now(cls, tz=None):
return datetime(2026, 4, 26, 3, 0, 0)
with patch.object(backup_mod, "datetime", FakeDateTime):
assert should_take_backup(tmp_path / "data") is True
def test_should_not_take_backup_outside_target_hour(tmp_path):
from chat.services import backup as backup_mod
class FakeDateTime(datetime):
@classmethod
def now(cls, tz=None):
return datetime(2026, 4, 26, 14, 0, 0)
with patch.object(backup_mod, "datetime", FakeDateTime):
assert should_take_backup(tmp_path / "data") is False
def test_should_not_take_backup_when_recent_backup_exists(tmp_path):
backup_dir = tmp_path / "data" / "backups"
backup_dir.mkdir(parents=True)
recent = backup_dir / "chat-recent.db"
recent.write_text("x")
# mtime defaults to "now" — within the 23h freshness window so
# should_take_backup must return False even at the target hour.
from chat.services import backup as backup_mod
class FakeDateTime(datetime):
@classmethod
def now(cls, tz=None):
return datetime(2026, 4, 26, 3, 0, 0)
with patch.object(backup_mod, "datetime", FakeDateTime):
assert should_take_backup(tmp_path / "data") is False
+35 -4
View File
@@ -20,11 +20,29 @@ def client(tmp_path, monkeypatch):
yield c
def _author_bot_and_chat(db_path: Path, bot_id: str = "bot_a") -> None:
"""Insert a bot and a chat directly via the event log (skip kickoff route)."""
def _author_you(db_path: Path) -> None:
"""Author a ``you_entity`` so the first-run middleware doesn't redirect."""
from chat.db.connection import open_db
with open_db(db_path) as conn:
append_event(
conn,
kind="you_authored",
payload={"name": "Me", "pronouns": "", "persona": ""},
)
project(conn)
def _author_bot_and_chat(db_path: Path, bot_id: str = "bot_a") -> None:
"""Insert a you_entity, bot, and chat via the event log (skip kickoff route)."""
from chat.db.connection import open_db
with open_db(db_path) as conn:
append_event(
conn,
kind="you_authored",
payload={"name": "Me", "pronouns": "", "persona": ""},
)
append_event(
conn,
kind="bot_authored",
@@ -53,13 +71,26 @@ def _author_bot_and_chat(db_path: Path, bot_id: str = "bot_a") -> None:
project(conn)
def test_root_redirects_to_chats(client):
def test_root_redirects_to_chats_when_setup_complete(client, tmp_path):
# With both you_entity and a bot present, the first-run middleware
# passes through and the nav router sends "/" → "/chats".
_author_bot_and_chat(tmp_path / "test.db", "bot_a")
response = client.get("/", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/chats"
def test_chats_list_empty_state(client):
def test_chats_list_empty_state(client, tmp_path):
# Author you + a bot but NO chats — should render the empty-state
# chats list, not redirect.
_author_bot_and_chat(tmp_path / "test.db", "bot_a")
# Drop the chat row so we hit the empty-state branch (the helper
# creates a chat — undo it via a fresh seed without chat_created).
from chat.db.connection import open_db
with open_db(tmp_path / "test.db") as conn:
conn.execute("DELETE FROM chats")
conn.commit()
response = client.get("/chats")
assert response.status_code == 200
body = response.text.lower()
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from chat.app import app
@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 _setup_minimal_state(db_path):
"""Set up enough state so the first-run middleware doesn't redirect."""
from chat.db.connection import open_db
from chat.eventlog.log import append_event
from chat.eventlog.projector import project
with open_db(db_path) as conn:
append_event(
conn,
kind="you_authored",
payload={"name": "Me", "pronouns": "", "persona": ""},
)
append_event(
conn,
kind="bot_authored",
payload={
"id": "bot_a",
"name": "BotA",
"persona": "",
"voice_samples": [],
"traits": [],
"backstory": "",
"initial_relationship_to_you": "",
"kickoff_prose": "",
},
)
project(conn)
def test_404_renders_friendly_page_for_html(client, tmp_path):
_setup_minimal_state(tmp_path / "test.db")
response = client.get("/chats/no_such_chat")
assert response.status_code == 404
body = response.text
assert "404" in body
assert "back to" in body.lower()
+146
View File
@@ -0,0 +1,146 @@
from __future__ import annotations
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_event
from chat.eventlog.projector import project
@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 test_root_redirects_to_settings_when_no_you(client):
response = client.get("/", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/settings"
def test_chats_redirects_to_settings_when_no_you(client):
response = client.get("/chats", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/settings"
def test_redirects_to_bots_new_when_you_exists_but_no_bots(client, tmp_path):
with open_db(tmp_path / "test.db") as conn:
append_event(
conn,
kind="you_authored",
payload={
"name": "Me",
"pronouns": "they/them",
"persona": "engineer",
},
)
project(conn)
response = client.get("/chats", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/bots/new"
def test_root_redirects_to_bots_new_when_you_exists_but_no_bots(client, tmp_path):
with open_db(tmp_path / "test.db") as conn:
append_event(
conn,
kind="you_authored",
payload={
"name": "Me",
"pronouns": "they/them",
"persona": "engineer",
},
)
project(conn)
response = client.get("/", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/bots/new"
def test_no_redirect_when_setup_complete(client, tmp_path):
with open_db(tmp_path / "test.db") as conn:
append_event(
conn,
kind="you_authored",
payload={
"name": "Me",
"pronouns": "they/them",
"persona": "engineer",
},
)
append_event(
conn,
kind="bot_authored",
payload={
"id": "bot_a",
"name": "BotA",
"persona": "...",
"voice_samples": [],
"traits": [],
"backstory": "",
"initial_relationship_to_you": "",
"kickoff_prose": "",
},
)
project(conn)
response = client.get("/chats", follow_redirects=False)
# /chats page renders normally (200) instead of redirecting.
assert response.status_code == 200
def test_settings_page_accessible_without_you(client):
"""Don't redirect FROM /settings — user needs to fill it out."""
response = client.get("/settings", follow_redirects=False)
assert response.status_code == 200
def test_bots_new_accessible_without_redirect(client, tmp_path):
with open_db(tmp_path / "test.db") as conn:
append_event(
conn,
kind="you_authored",
payload={"name": "Me", "pronouns": "", "persona": ""},
)
project(conn)
response = client.get("/bots/new", follow_redirects=False)
assert response.status_code == 200
def test_bots_list_accessible_without_redirect_when_empty(client, tmp_path):
"""The bot list page itself should never redirect — even when empty."""
with open_db(tmp_path / "test.db") as conn:
append_event(
conn,
kind="you_authored",
payload={"name": "Me", "pronouns": "", "persona": ""},
)
project(conn)
response = client.get("/bots", follow_redirects=False)
assert response.status_code == 200
def test_post_to_settings_not_redirected(client):
"""POST should bypass middleware — it's a write, not a landing nav."""
response = client.post(
"/settings",
data={"name": "Me", "pronouns": "", "persona": ""},
follow_redirects=False,
)
# Settings POST returns 200 with the saved page (no HTTPException raised).
assert response.status_code == 200
def test_health_endpoint_not_redirected(client):
response = client.get("/health", follow_redirects=False)
assert response.status_code == 200
+87
View File
@@ -0,0 +1,87 @@
"""Tests for the transcript renderer (Task 33).
Lightweight markdown for transcript turns:
- ``*action*`` → ``<em class="action">action</em>``
- ``**bold**`` → ``<strong>bold</strong>``
- ``((ooc))`` → ``<span class="ooc">((ooc))</span>``
- ``> line`` → ``<blockquote>line</blockquote>``
- paragraph breaks (double newline) → ``</p><p>``
- everything HTML-escaped first
No headings, no code blocks, no links — out of scope per Requirements §16.3.
"""
from __future__ import annotations
from chat.web.render import render_prose, render_turn_html
def test_render_prose_escapes_html():
"""Raw HTML in user content must be escaped — no XSS surface."""
out = render_prose("<script>alert(1)</script>")
assert "<script>" not in out
assert "&lt;script&gt;" in out
def test_render_prose_action_to_italic():
out = render_prose("*walks over*")
assert '<em class="action">walks over</em>' in out
def test_render_prose_bold_before_action():
"""Bold (``**``) must be processed before action (``*``)."""
out = render_prose("**emphasis** and *action*")
assert "<strong>emphasis</strong>" in out
assert '<em class="action">action</em>' in out
# Make sure we didn't double-wrap: no stray asterisks left behind.
assert "*" not in out
def test_render_prose_ooc_wrapped():
out = render_prose("((this is OOC))")
assert '<span class="ooc">' in out
assert "((this is OOC))" in out
def test_render_prose_paragraphs():
out = render_prose("First.\n\nSecond.")
# Two <p> opens and two closes.
assert out.count("<p>") == 2
assert out.count("</p>") == 2
assert "<p>First.</p>" in out
assert "<p>Second.</p>" in out
def test_render_prose_blockquote():
out = render_prose("> a quote")
assert "<blockquote>a quote</blockquote>" in out
def test_render_prose_empty():
"""Empty / whitespace-only inputs produce empty output, not stray tags."""
assert render_prose("") == ""
assert render_prose(" ") == ""
def test_render_turn_html_includes_role_class():
out = render_turn_html("BotA", "Hello.", role="bot")
assert 'class="turn turn-bot"' in out
assert "<strong>BotA</strong>" in out
assert "Hello." in out
def test_render_turn_html_escapes_speaker():
"""Speaker label is also HTML-escaped — names are user-controlled."""
out = render_turn_html("<bad>", "hi", role="you")
# Raw tag should not appear; escaped form should.
assert "<bad>" not in out
assert "&lt;bad&gt;" in out
def test_render_prose_mixed_full_message():
"""Realistic turn with action, dialogue, and an OOC aside."""
text = "*looks up* \"You're back late.\" ((she's tired))"
out = render_prose(text)
assert '<em class="action">looks up</em>' in out
# The apostrophe in ``she's`` is HTML-escaped to ``&#x27;``.
assert '<span class="ooc">((she&#x27;s tired))</span>' in out
+133
View File
@@ -0,0 +1,133 @@
"""Tests for Task 31 — periodic snapshots with retention and cold-load fast-path.
Per Requirements §10.4 the periodic snapshot policy is:
* Take a snapshot every 100 events OR every 30 minutes since the last one,
whichever comes first.
* Store under ``data/snapshots/periodic/`` with a UTC timestamp filename.
* Retain only the last 5 periodic snapshots; prune older ones on write.
* On cold load, restore from the most recent snapshot and replay events
past the snapshot's ``last_event_id`` to bring projected state forward.
These tests cover the functional core (snapshot timing, pruning, restore).
Worker- and lifespan-level wiring is covered by the integration tests in
``test_turn_flow`` and the existing app boot tests.
"""
from __future__ import annotations
import json
from chat.db.connection import open_db
from chat.db.migrate import apply_migrations
from chat.eventlog.log import append_event
from chat.eventlog.projector import project
from chat.services.snapshot import (
latest_snapshot_path,
prune_periodic_snapshots,
restore_from_snapshot,
should_take_periodic_snapshot,
take_snapshot,
)
# Importing the state modules registers their projector handlers as a
# side effect — restoring + replaying needs them present.
import chat.state.entities # noqa: F401
import chat.state.edges # noqa: F401
import chat.state.manual_edit # noqa: F401
import chat.state.memory # noqa: F401
import chat.state.world # noqa: F401
def _bot_payload(bot_id: str, name: str) -> dict:
return {
"id": bot_id,
"name": name,
"persona": "fancy",
"voice_samples": ["sample"],
"traits": ["shy"],
"backstory": "",
"initial_relationship_to_you": "coworker",
"kickoff_prose": "",
}
def test_take_snapshot_includes_last_event_id(tmp_path):
db = tmp_path / "t.db"
apply_migrations(db)
with open_db(db) as conn:
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_a", "BotA"))
project(conn)
path = take_snapshot(conn, data_dir=tmp_path / "data", kind="periodic")
dump = json.loads(path.read_text())
assert "last_event_id" in dump
assert dump["last_event_id"] >= 1
def test_should_take_periodic_when_no_prior_and_events_exist(tmp_path):
db = tmp_path / "t.db"
apply_migrations(db)
with open_db(db) as conn:
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_a", "BotA"))
project(conn)
assert should_take_periodic_snapshot(conn, tmp_path / "data") is True
def test_should_not_take_when_recent_and_few_events(tmp_path):
db = tmp_path / "t.db"
apply_migrations(db)
with open_db(db) as conn:
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_a", "BotA"))
project(conn)
# Take a snapshot to establish a recent baseline.
take_snapshot(conn, data_dir=tmp_path / "data", kind="periodic")
# Right after — should be False (within time threshold and < 100 new events).
assert should_take_periodic_snapshot(conn, tmp_path / "data") is False
def test_prune_keeps_last_5(tmp_path):
snapshot_dir = tmp_path / "data" / "snapshots" / "periodic"
snapshot_dir.mkdir(parents=True)
# Create 8 dummy snapshot files with sortable names.
for i in range(8):
p = snapshot_dir / f"2026010{i}T000000Z.json"
p.write_text(json.dumps({"last_event_id": i}))
removed = prune_periodic_snapshots(tmp_path / "data", keep=5)
assert removed == 3
remaining = sorted(snapshot_dir.glob("*.json"))
assert len(remaining) == 5
# The 5 most recent (highest names) should remain.
assert remaining[0].name == "20260103T000000Z.json"
assert remaining[-1].name == "20260107T000000Z.json"
def test_latest_snapshot_path_returns_none_when_missing(tmp_path):
# No directory yet.
assert latest_snapshot_path(tmp_path / "data", kind="periodic") is None
# Empty directory.
(tmp_path / "data" / "snapshots" / "periodic").mkdir(parents=True)
assert latest_snapshot_path(tmp_path / "data", kind="periodic") is None
def test_restore_from_snapshot_repopulates_tables(tmp_path):
# Source DB: seed a bot, snapshot it.
db1 = tmp_path / "t1.db"
apply_migrations(db1)
with open_db(db1) as conn:
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_a", "BotA"))
project(conn)
snapshot_path = take_snapshot(
conn, data_dir=tmp_path / "data", kind="periodic"
)
# Fresh DB — restore from the snapshot, no event-log replay needed.
db2 = tmp_path / "t2.db"
apply_migrations(db2)
with open_db(db2) as conn:
last_id = restore_from_snapshot(conn, snapshot_path)
assert last_id >= 1
bot = conn.execute(
"SELECT name FROM bots WHERE id = 'bot_a'"
).fetchone()
assert bot is not None
assert bot[0] == "BotA"
+176
View File
@@ -0,0 +1,176 @@
"""Streaming UX tests (T34): cancel route, recent-dialogue user_turn_edit
inclusion, and the chat-shell embeds the streaming JS hooks.
The cancel route is exercised at the no-op level only — the full mid-stream
cancel path is covered indirectly by T19's CancelledError handling. We
verify here that the route itself is registered and silently 204s when no
in-flight task exists, since the JS Stop button fires unconditionally.
The user_turn_edit inclusion test is the T29 follow-up fix: without it,
the original user_turn drops out of the timeline (correctly) but the
edited prose never lands (incorrectly), so the rendered chat detail is
missing the user's most recent words.
"""
from __future__ import annotations
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_event
from chat.eventlog.projector import project
@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:
# Disable the lifespan-managed background worker so it doesn't
# try to score significance through Featherless with the fake key.
worker = getattr(app.state, "background_worker", None)
if worker is not None:
worker.enabled = False
yield c
def _seed_chat(
db_path: Path,
bot_id: str = "bot_a",
chat_id: str = "chat_bot_a",
) -> None:
"""Seed a bot + chat with the activity rows the prompt assembler expects."""
with open_db(db_path) as conn:
append_event(
conn,
kind="bot_authored",
payload={
"id": bot_id,
"name": "BotA",
"persona": "...",
"voice_samples": [],
"traits": [],
"backstory": "",
"initial_relationship_to_you": "",
"kickoff_prose": "...",
},
)
append_event(
conn,
kind="chat_created",
payload={
"id": chat_id,
"host_bot_id": bot_id,
"initial_time": "2026-04-26T20:00:00+00:00",
"narrative_anchor": "Day 1",
"weather": "",
},
)
append_event(
conn,
kind="edge_update",
payload={
"source_id": bot_id,
"target_id": "you",
"chat_id": chat_id,
},
)
append_event(
conn,
kind="edge_update",
payload={
"source_id": "you",
"target_id": bot_id,
"chat_id": chat_id,
},
)
append_event(
conn,
kind="activity_change",
payload={
"entity_id": "you",
"posture": "sitting",
"action": {"verb": "talking"},
},
)
append_event(
conn,
kind="activity_change",
payload={
"entity_id": bot_id,
"posture": "sitting",
"action": {"verb": "listening"},
},
)
project(conn)
def test_cancel_route_no_op_when_no_in_flight(client, tmp_path):
"""Hitting cancel with nothing streaming returns 204 silently."""
_seed_chat(tmp_path / "test.db")
response = client.post("/chats/chat_bot_a/turns/cancel")
assert response.status_code == 204
def test_user_turn_edit_appears_in_recent_dialogue(client, tmp_path):
"""The chat-detail timeline includes a user_turn_edit's prose.
Original user_turn is superseded by the edit, so it drops out, but
the edit's prose should render in its place.
"""
db_path = tmp_path / "test.db"
_seed_chat(db_path)
with open_db(db_path) as conn:
ut_id = append_event(
conn,
kind="user_turn",
payload={
"chat_id": "chat_bot_a",
"prose": "OriginalUserText",
"segments": [],
},
)
edit_id = append_event(
conn,
kind="user_turn_edit",
payload={
"chat_id": "chat_bot_a",
"prose": "EditedUserText",
"supersedes_user_turn_id": ut_id,
},
)
conn.execute(
"UPDATE event_log SET superseded_by = ? WHERE id = ?",
(edit_id, ut_id),
)
conn.commit()
# No project() call — user_turn / user_turn_edit have no projector
# handlers (transcript-only kinds), and re-projecting would replay
# chat_created and trip its UNIQUE constraint.
response = client.get("/chats/chat_bot_a")
assert response.status_code == 200
body = response.text
assert "EditedUserText" in body
# The original (now-superseded) prose must not render.
assert "OriginalUserText" not in body
def test_chat_html_includes_stop_streaming_script(client, tmp_path):
"""The chat shell embeds the streaming-JS hooks (Stop button + send-lock)."""
_seed_chat(tmp_path / "test.db")
response = client.get("/chats/chat_bot_a")
assert response.status_code == 200
body = response.text
# Either the CSS class for the Stop button or the JS state flag must
# appear in the embedded script — both are load-bearing for T34.
assert "stop-streaming" in body or "isStreaming" in body
# Cancel route reference must be wired so the Stop button can call it.
assert "/turns/cancel" in body