Compare commits
10 Commits
phase-2.5
...
da1f67fb6a
| Author | SHA1 | Date | |
|---|---|---|---|
| da1f67fb6a | |||
| 03ba34272b | |||
| e26885b011 | |||
| 5b7a195cf5 | |||
| 25bcbac055 | |||
| ab2b494c21 | |||
| b6888ff36a | |||
| e4fd888b53 | |||
| 079774dce5 | |||
| 3be7920f41 |
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE events (
|
||||
id INTEGER PRIMARY KEY,
|
||||
event_id TEXT NOT NULL UNIQUE,
|
||||
chat_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'planned',
|
||||
props_json TEXT NOT NULL DEFAULT '{}',
|
||||
planned_for TEXT,
|
||||
started_at TEXT,
|
||||
completed_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX events_chat_idx ON events(chat_id, status);
|
||||
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE threads (
|
||||
id INTEGER PRIMARY KEY,
|
||||
thread_id TEXT NOT NULL UNIQUE,
|
||||
chat_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
opened_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
closed_at TEXT,
|
||||
last_referenced_scene_id INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX threads_chat_status_idx ON threads(chat_id, status);
|
||||
@@ -0,0 +1,127 @@
|
||||
from __future__ import annotations
|
||||
import json
|
||||
from sqlite3 import Connection
|
||||
|
||||
from chat.eventlog.projector import on
|
||||
from chat.eventlog.log import Event
|
||||
|
||||
|
||||
_TERMINAL_STATUSES = {"completed", "cancelled", "expired"}
|
||||
|
||||
|
||||
@on("event_planned")
|
||||
def _apply_event_planned(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO events "
|
||||
"(event_id, chat_id, kind, status, props_json, planned_for) "
|
||||
"VALUES (?, ?, ?, 'planned', ?, ?)",
|
||||
(
|
||||
p["event_id"],
|
||||
p["chat_id"],
|
||||
p["kind"],
|
||||
json.dumps(p.get("props", {})),
|
||||
p.get("planned_for"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@on("event_started")
|
||||
def _apply_event_started(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
# Idempotent: only transition from non-terminal status.
|
||||
conn.execute(
|
||||
"UPDATE events SET status = 'active', started_at = ?, updated_at = datetime('now') "
|
||||
"WHERE event_id = ? AND status NOT IN ('completed','cancelled','expired')",
|
||||
(p.get("started_at"), p["event_id"]),
|
||||
)
|
||||
|
||||
|
||||
@on("event_completed")
|
||||
def _apply_event_completed(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
conn.execute(
|
||||
"UPDATE events SET status = 'completed', completed_at = ?, updated_at = datetime('now') "
|
||||
"WHERE event_id = ? AND status NOT IN ('completed','cancelled','expired')",
|
||||
(p.get("completed_at"), p["event_id"]),
|
||||
)
|
||||
|
||||
|
||||
@on("event_cancelled")
|
||||
def _apply_event_cancelled(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
conn.execute(
|
||||
"UPDATE events SET status = 'cancelled', completed_at = ?, updated_at = datetime('now') "
|
||||
"WHERE event_id = ? AND status NOT IN ('completed','cancelled','expired')",
|
||||
(p.get("completed_at"), p["event_id"]),
|
||||
)
|
||||
|
||||
|
||||
@on("event_expired")
|
||||
def _apply_event_expired(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
conn.execute(
|
||||
"UPDATE events SET status = 'expired', completed_at = ?, updated_at = datetime('now') "
|
||||
"WHERE event_id = ? AND status NOT IN ('completed','cancelled','expired')",
|
||||
(p.get("completed_at"), p["event_id"]),
|
||||
)
|
||||
|
||||
|
||||
def get_event(conn: Connection, event_id: str) -> dict | None:
|
||||
row = conn.execute(
|
||||
"SELECT event_id, chat_id, kind, status, props_json, planned_for, "
|
||||
"started_at, completed_at, created_at, updated_at "
|
||||
"FROM events WHERE event_id = ?",
|
||||
(event_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"event_id": row[0],
|
||||
"chat_id": row[1],
|
||||
"kind": row[2],
|
||||
"status": row[3],
|
||||
"props": json.loads(row[4]),
|
||||
"planned_for": row[5],
|
||||
"started_at": row[6],
|
||||
"completed_at": row[7],
|
||||
"created_at": row[8],
|
||||
"updated_at": row[9],
|
||||
}
|
||||
|
||||
|
||||
def list_active_events(conn: Connection, chat_id: str) -> list[dict]:
|
||||
rows = conn.execute(
|
||||
"SELECT event_id, chat_id, kind, status, props_json, planned_for, "
|
||||
"started_at, completed_at, created_at, updated_at "
|
||||
"FROM events WHERE chat_id = ? AND status IN ('planned','active') "
|
||||
"ORDER BY id ASC",
|
||||
(chat_id,),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"event_id": r[0], "chat_id": r[1], "kind": r[2], "status": r[3],
|
||||
"props": json.loads(r[4]),
|
||||
"planned_for": r[5], "started_at": r[6], "completed_at": r[7],
|
||||
"created_at": r[8], "updated_at": r[9],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def list_events_in_status(conn: Connection, chat_id: str, status: str) -> list[dict]:
|
||||
rows = conn.execute(
|
||||
"SELECT event_id, chat_id, kind, status, props_json, planned_for, "
|
||||
"started_at, completed_at, created_at, updated_at "
|
||||
"FROM events WHERE chat_id = ? AND status = ? ORDER BY id ASC",
|
||||
(chat_id, status),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"event_id": r[0], "chat_id": r[1], "kind": r[2], "status": r[3],
|
||||
"props": json.loads(r[4]),
|
||||
"planned_for": r[5], "started_at": r[6], "completed_at": r[7],
|
||||
"created_at": r[8], "updated_at": r[9],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
@@ -0,0 +1,123 @@
|
||||
from __future__ import annotations
|
||||
from sqlite3 import Connection
|
||||
|
||||
from chat.eventlog.projector import on
|
||||
from chat.eventlog.log import Event
|
||||
|
||||
|
||||
@on("thread_opened")
|
||||
def _apply_thread_opened(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO threads "
|
||||
"(thread_id, chat_id, title, summary, status) "
|
||||
"VALUES (?, ?, ?, ?, 'open')",
|
||||
(
|
||||
p["thread_id"],
|
||||
p["chat_id"],
|
||||
p["title"],
|
||||
p.get("summary", ""),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@on("thread_updated")
|
||||
def _apply_thread_updated(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
# Idempotent: closed threads ignore subsequent updates.
|
||||
conn.execute(
|
||||
"UPDATE threads SET summary = ?, last_referenced_scene_id = ?, "
|
||||
"updated_at = datetime('now') "
|
||||
"WHERE thread_id = ? AND status = 'open'",
|
||||
(
|
||||
p.get("summary", ""),
|
||||
p.get("last_referenced_scene_id"),
|
||||
p["thread_id"],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@on("thread_closed")
|
||||
def _apply_thread_closed(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
conn.execute(
|
||||
"UPDATE threads SET status = 'closed', closed_at = ?, "
|
||||
"updated_at = datetime('now') "
|
||||
"WHERE thread_id = ? AND status = 'open'",
|
||||
(p.get("closed_at"), p["thread_id"]),
|
||||
)
|
||||
|
||||
|
||||
def get_thread(conn: Connection, thread_id: str) -> dict | None:
|
||||
row = conn.execute(
|
||||
"SELECT thread_id, chat_id, title, summary, status, "
|
||||
"opened_at, closed_at, last_referenced_scene_id, "
|
||||
"created_at, updated_at "
|
||||
"FROM threads WHERE thread_id = ?",
|
||||
(thread_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"thread_id": row[0],
|
||||
"chat_id": row[1],
|
||||
"title": row[2],
|
||||
"summary": row[3],
|
||||
"status": row[4],
|
||||
"opened_at": row[5],
|
||||
"closed_at": row[6],
|
||||
"last_referenced_scene_id": row[7],
|
||||
"created_at": row[8],
|
||||
"updated_at": row[9],
|
||||
}
|
||||
|
||||
|
||||
def list_open_threads(conn: Connection, chat_id: str) -> list[dict]:
|
||||
rows = conn.execute(
|
||||
"SELECT thread_id, chat_id, title, summary, status, "
|
||||
"opened_at, closed_at, last_referenced_scene_id, "
|
||||
"created_at, updated_at "
|
||||
"FROM threads WHERE chat_id = ? AND status = 'open' "
|
||||
"ORDER BY id ASC",
|
||||
(chat_id,),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"thread_id": r[0], "chat_id": r[1], "title": r[2],
|
||||
"summary": r[3], "status": r[4],
|
||||
"opened_at": r[5], "closed_at": r[6],
|
||||
"last_referenced_scene_id": r[7],
|
||||
"created_at": r[8], "updated_at": r[9],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def list_threads(conn: Connection, chat_id: str, status: str | None = None) -> list[dict]:
|
||||
if status is None:
|
||||
rows = conn.execute(
|
||||
"SELECT thread_id, chat_id, title, summary, status, "
|
||||
"opened_at, closed_at, last_referenced_scene_id, "
|
||||
"created_at, updated_at "
|
||||
"FROM threads WHERE chat_id = ? ORDER BY id ASC",
|
||||
(chat_id,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT thread_id, chat_id, title, summary, status, "
|
||||
"opened_at, closed_at, last_referenced_scene_id, "
|
||||
"created_at, updated_at "
|
||||
"FROM threads WHERE chat_id = ? AND status = ? "
|
||||
"ORDER BY id ASC",
|
||||
(chat_id, status),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"thread_id": r[0], "chat_id": r[1], "title": r[2],
|
||||
"summary": r[3], "status": r[4],
|
||||
"opened_at": r[5], "closed_at": r[6],
|
||||
"last_referenced_scene_id": r[7],
|
||||
"created_at": r[8], "updated_at": r[9],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
@@ -29,6 +29,34 @@ def _apply_chat_created(conn: Connection, e: Event) -> None:
|
||||
)
|
||||
|
||||
|
||||
@on("time_skip_elision")
|
||||
def _apply_time_skip_elision(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
conn.execute(
|
||||
"UPDATE chat_state SET time = ? WHERE chat_id = ?",
|
||||
(p["new_time"], p["chat_id"]),
|
||||
)
|
||||
|
||||
|
||||
@on("time_skip_jump")
|
||||
def _apply_time_skip_jump(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
chat_id = p["chat_id"]
|
||||
conn.execute(
|
||||
"UPDATE chat_state SET time = ? WHERE chat_id = ?",
|
||||
(p["new_time"], chat_id),
|
||||
)
|
||||
if p.get("reset_activity", False):
|
||||
# Activity rows are keyed by entity_id with a container_id FK.
|
||||
# Each chat owns its containers, so deleting activity rows whose
|
||||
# container_id belongs to this chat clears every present entity.
|
||||
conn.execute(
|
||||
"DELETE FROM activity "
|
||||
"WHERE container_id IN (SELECT id FROM containers WHERE chat_id = ?)",
|
||||
(chat_id,),
|
||||
)
|
||||
|
||||
|
||||
@on("guest_added")
|
||||
def _apply_guest_added(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from chat.db.connection import open_db
|
||||
from chat.db.migrate import apply_migrations
|
||||
from chat.eventlog.log import append_and_apply, append_event
|
||||
from chat.eventlog.projector import project
|
||||
import chat.state.entities # registers handlers
|
||||
import chat.state.world # registers handlers
|
||||
import chat.state.group_node # registers handlers
|
||||
import chat.state.events # registers handlers
|
||||
from chat.state.events import (
|
||||
get_event,
|
||||
list_active_events,
|
||||
list_events_in_status,
|
||||
)
|
||||
|
||||
|
||||
def _bot_payload(bot_id: str, name: str) -> dict:
|
||||
return {
|
||||
"id": bot_id,
|
||||
"name": name,
|
||||
"persona": "thoughtful, observant",
|
||||
"voice_samples": [],
|
||||
"traits": [],
|
||||
"backstory": "",
|
||||
"initial_relationship_to_you": "coworker",
|
||||
"kickoff_prose": "",
|
||||
}
|
||||
|
||||
|
||||
def _chat_payload(chat_id: str = "chat_bot_a") -> dict:
|
||||
return {
|
||||
"id": chat_id,
|
||||
"host_bot_id": "bot_a",
|
||||
"guest_bot_id": "bot_b",
|
||||
"initial_time": "2026-04-26T20:00:00+00:00",
|
||||
"narrative_anchor": "Day 1 evening",
|
||||
"weather": "clear",
|
||||
}
|
||||
|
||||
|
||||
def _seed_chat(conn) -> None:
|
||||
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="chat_created", payload=_chat_payload())
|
||||
|
||||
|
||||
def test_event_planned_creates_row(tmp_path):
|
||||
db = tmp_path / "t.db"
|
||||
apply_migrations(db)
|
||||
with open_db(db) as conn:
|
||||
_seed_chat(conn)
|
||||
append_event(
|
||||
conn,
|
||||
kind="event_planned",
|
||||
payload={
|
||||
"event_id": "evt_abc",
|
||||
"chat_id": "chat_bot_a",
|
||||
"kind": "date_at_park",
|
||||
"props": {"location": "park"},
|
||||
"planned_for": "2026-04-30T18:00:00+00:00",
|
||||
},
|
||||
)
|
||||
project(conn)
|
||||
|
||||
ev = get_event(conn, "evt_abc")
|
||||
assert ev is not None
|
||||
assert ev["event_id"] == "evt_abc"
|
||||
assert ev["chat_id"] == "chat_bot_a"
|
||||
assert ev["kind"] == "date_at_park"
|
||||
assert ev["status"] == "planned"
|
||||
assert ev["props"]["location"] == "park"
|
||||
assert ev["planned_for"] == "2026-04-30T18:00:00+00:00"
|
||||
assert ev["started_at"] is None
|
||||
assert ev["completed_at"] is None
|
||||
|
||||
|
||||
def test_event_started_then_completed_updates_status(tmp_path):
|
||||
db = tmp_path / "t.db"
|
||||
apply_migrations(db)
|
||||
with open_db(db) as conn:
|
||||
_seed_chat(conn)
|
||||
append_event(
|
||||
conn,
|
||||
kind="event_planned",
|
||||
payload={
|
||||
"event_id": "evt_abc",
|
||||
"chat_id": "chat_bot_a",
|
||||
"kind": "date_at_park",
|
||||
"props": {},
|
||||
"planned_for": "2026-04-30T18:00:00+00:00",
|
||||
},
|
||||
)
|
||||
append_event(
|
||||
conn,
|
||||
kind="event_started",
|
||||
payload={
|
||||
"event_id": "evt_abc",
|
||||
"started_at": "2026-04-30T18:01:00+00:00",
|
||||
},
|
||||
)
|
||||
append_event(
|
||||
conn,
|
||||
kind="event_completed",
|
||||
payload={
|
||||
"event_id": "evt_abc",
|
||||
"completed_at": "2026-04-30T20:00:00+00:00",
|
||||
},
|
||||
)
|
||||
project(conn)
|
||||
|
||||
ev = get_event(conn, "evt_abc")
|
||||
assert ev is not None
|
||||
assert ev["status"] == "completed"
|
||||
assert ev["started_at"] == "2026-04-30T18:01:00+00:00"
|
||||
assert ev["completed_at"] == "2026-04-30T20:00:00+00:00"
|
||||
|
||||
|
||||
def test_event_cancelled_terminal_subsequent_transitions_ignored(tmp_path):
|
||||
db = tmp_path / "t.db"
|
||||
apply_migrations(db)
|
||||
with open_db(db) as conn:
|
||||
_seed_chat(conn)
|
||||
append_event(
|
||||
conn,
|
||||
kind="event_planned",
|
||||
payload={
|
||||
"event_id": "evt_abc",
|
||||
"chat_id": "chat_bot_a",
|
||||
"kind": "date_at_park",
|
||||
"props": {},
|
||||
"planned_for": "2026-04-30T18:00:00+00:00",
|
||||
},
|
||||
)
|
||||
append_event(
|
||||
conn,
|
||||
kind="event_cancelled",
|
||||
payload={
|
||||
"event_id": "evt_abc",
|
||||
"completed_at": "2026-04-30T17:00:00+00:00",
|
||||
},
|
||||
)
|
||||
project(conn)
|
||||
|
||||
ev = get_event(conn, "evt_abc")
|
||||
assert ev is not None
|
||||
assert ev["status"] == "cancelled"
|
||||
assert ev["completed_at"] == "2026-04-30T17:00:00+00:00"
|
||||
|
||||
# Subsequent event_started must be no-oped because status is terminal.
|
||||
# Use append_and_apply so we apply ONLY this new event without
|
||||
# replaying earlier non-idempotent handlers (e.g. chat_created).
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="event_started",
|
||||
payload={
|
||||
"event_id": "evt_abc",
|
||||
"started_at": "2026-04-30T18:01:00+00:00",
|
||||
},
|
||||
)
|
||||
|
||||
ev2 = get_event(conn, "evt_abc")
|
||||
assert ev2 is not None
|
||||
assert ev2["status"] == "cancelled"
|
||||
assert ev2["started_at"] is None
|
||||
# completed_at unchanged from the cancelled transition
|
||||
assert ev2["completed_at"] == "2026-04-30T17:00:00+00:00"
|
||||
|
||||
|
||||
def test_list_active_events_filters_to_planned_and_active(tmp_path):
|
||||
db = tmp_path / "t.db"
|
||||
apply_migrations(db)
|
||||
with open_db(db) as conn:
|
||||
_seed_chat(conn)
|
||||
# Four events: one planned, one active, one completed, one cancelled.
|
||||
for ev_id, kind in [
|
||||
("evt_planned", "date_at_park"),
|
||||
("evt_active", "movie_night"),
|
||||
("evt_done", "dinner"),
|
||||
("evt_canx", "trip"),
|
||||
]:
|
||||
append_event(
|
||||
conn,
|
||||
kind="event_planned",
|
||||
payload={
|
||||
"event_id": ev_id,
|
||||
"chat_id": "chat_bot_a",
|
||||
"kind": kind,
|
||||
"props": {},
|
||||
"planned_for": "2026-04-30T18:00:00+00:00",
|
||||
},
|
||||
)
|
||||
append_event(
|
||||
conn,
|
||||
kind="event_started",
|
||||
payload={
|
||||
"event_id": "evt_active",
|
||||
"started_at": "2026-04-30T18:01:00+00:00",
|
||||
},
|
||||
)
|
||||
append_event(
|
||||
conn,
|
||||
kind="event_started",
|
||||
payload={
|
||||
"event_id": "evt_done",
|
||||
"started_at": "2026-04-30T18:01:00+00:00",
|
||||
},
|
||||
)
|
||||
append_event(
|
||||
conn,
|
||||
kind="event_completed",
|
||||
payload={
|
||||
"event_id": "evt_done",
|
||||
"completed_at": "2026-04-30T20:00:00+00:00",
|
||||
},
|
||||
)
|
||||
append_event(
|
||||
conn,
|
||||
kind="event_cancelled",
|
||||
payload={
|
||||
"event_id": "evt_canx",
|
||||
"completed_at": "2026-04-30T17:00:00+00:00",
|
||||
},
|
||||
)
|
||||
project(conn)
|
||||
|
||||
active = list_active_events(conn, "chat_bot_a")
|
||||
active_ids = {e["event_id"] for e in active}
|
||||
assert active_ids == {"evt_planned", "evt_active"}
|
||||
|
||||
completed = list_events_in_status(conn, "chat_bot_a", "completed")
|
||||
assert [e["event_id"] for e in completed] == ["evt_done"]
|
||||
|
||||
cancelled = list_events_in_status(conn, "chat_bot_a", "cancelled")
|
||||
assert [e["event_id"] for e in cancelled] == ["evt_canx"]
|
||||
@@ -0,0 +1,181 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from chat.db.connection import open_db
|
||||
from chat.db.migrate import apply_migrations
|
||||
from chat.eventlog.log import append_and_apply, append_event
|
||||
from chat.eventlog.projector import project
|
||||
import chat.state.entities # registers handlers
|
||||
import chat.state.world # registers handlers
|
||||
import chat.state.threads # registers handlers
|
||||
from chat.state.threads import get_thread, list_open_threads
|
||||
|
||||
|
||||
def _bot_payload(bot_id: str, name: str) -> dict:
|
||||
return {
|
||||
"id": bot_id,
|
||||
"name": name,
|
||||
"persona": "thoughtful, observant",
|
||||
"voice_samples": [],
|
||||
"traits": [],
|
||||
"backstory": "",
|
||||
"initial_relationship_to_you": "coworker",
|
||||
"kickoff_prose": "",
|
||||
}
|
||||
|
||||
|
||||
def _chat_payload(chat_id: str = "chat_bot_a") -> dict:
|
||||
return {
|
||||
"id": chat_id,
|
||||
"host_bot_id": "bot_a",
|
||||
"guest_bot_id": "bot_b",
|
||||
"initial_time": "2026-04-26T20:00:00+00:00",
|
||||
"narrative_anchor": "Day 1 evening",
|
||||
"weather": "clear",
|
||||
}
|
||||
|
||||
|
||||
def test_thread_opened_creates_row(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"))
|
||||
append_event(conn, kind="chat_created", payload=_chat_payload())
|
||||
append_event(
|
||||
conn,
|
||||
kind="thread_opened",
|
||||
payload={
|
||||
"thread_id": "thr_abc",
|
||||
"chat_id": "chat_bot_a",
|
||||
"title": "Maya's job hunt",
|
||||
"summary": "Maya is looking for a new job",
|
||||
},
|
||||
)
|
||||
project(conn)
|
||||
|
||||
t = get_thread(conn, "thr_abc")
|
||||
assert t is not None
|
||||
assert t["thread_id"] == "thr_abc"
|
||||
assert t["chat_id"] == "chat_bot_a"
|
||||
assert t["title"] == "Maya's job hunt"
|
||||
assert t["summary"] == "Maya is looking for a new job"
|
||||
assert t["status"] == "open"
|
||||
assert t["closed_at"] is None
|
||||
assert t["last_referenced_scene_id"] is None
|
||||
|
||||
|
||||
def test_thread_updated_changes_summary_and_last_referenced(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"))
|
||||
append_event(conn, kind="chat_created", payload=_chat_payload())
|
||||
append_event(
|
||||
conn,
|
||||
kind="thread_opened",
|
||||
payload={
|
||||
"thread_id": "thr_abc",
|
||||
"chat_id": "chat_bot_a",
|
||||
"title": "Maya's job hunt",
|
||||
"summary": "Maya is looking for a new job",
|
||||
},
|
||||
)
|
||||
append_event(
|
||||
conn,
|
||||
kind="thread_updated",
|
||||
payload={
|
||||
"thread_id": "thr_abc",
|
||||
"summary": "Maya landed an interview at a startup",
|
||||
"last_referenced_scene_id": 42,
|
||||
},
|
||||
)
|
||||
project(conn)
|
||||
|
||||
t = get_thread(conn, "thr_abc")
|
||||
assert t is not None
|
||||
assert t["summary"] == "Maya landed an interview at a startup"
|
||||
assert t["last_referenced_scene_id"] == 42
|
||||
assert t["status"] == "open"
|
||||
|
||||
|
||||
def test_thread_closed_terminal(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"))
|
||||
append_event(conn, kind="chat_created", payload=_chat_payload())
|
||||
append_event(
|
||||
conn,
|
||||
kind="thread_opened",
|
||||
payload={
|
||||
"thread_id": "thr_abc",
|
||||
"chat_id": "chat_bot_a",
|
||||
"title": "Maya's job hunt",
|
||||
"summary": "Maya is looking for a new job",
|
||||
},
|
||||
)
|
||||
append_event(
|
||||
conn,
|
||||
kind="thread_closed",
|
||||
payload={
|
||||
"thread_id": "thr_abc",
|
||||
"closed_at": "2026-04-26T21:00:00+00:00",
|
||||
},
|
||||
)
|
||||
project(conn)
|
||||
|
||||
t = get_thread(conn, "thr_abc")
|
||||
assert t is not None
|
||||
assert t["status"] == "closed"
|
||||
assert t["closed_at"] == "2026-04-26T21:00:00+00:00"
|
||||
|
||||
# Subsequent updates to a closed thread are no-ops.
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="thread_updated",
|
||||
payload={
|
||||
"thread_id": "thr_abc",
|
||||
"summary": "should not be applied",
|
||||
},
|
||||
)
|
||||
|
||||
t2 = get_thread(conn, "thr_abc")
|
||||
assert t2 is not None
|
||||
assert t2["summary"] == "Maya is looking for a new job"
|
||||
assert t2["status"] == "closed"
|
||||
|
||||
|
||||
def test_list_open_threads_filters_to_open_only(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"))
|
||||
append_event(conn, kind="chat_created", payload=_chat_payload())
|
||||
for tid, title in [
|
||||
("thr_1", "Arc 1"),
|
||||
("thr_2", "Arc 2"),
|
||||
("thr_3", "Arc 3"),
|
||||
]:
|
||||
append_event(
|
||||
conn,
|
||||
kind="thread_opened",
|
||||
payload={
|
||||
"thread_id": tid,
|
||||
"chat_id": "chat_bot_a",
|
||||
"title": title,
|
||||
"summary": "",
|
||||
},
|
||||
)
|
||||
append_event(
|
||||
conn,
|
||||
kind="thread_closed",
|
||||
payload={
|
||||
"thread_id": "thr_3",
|
||||
"closed_at": "2026-04-26T21:00:00+00:00",
|
||||
},
|
||||
)
|
||||
project(conn)
|
||||
|
||||
open_threads = list_open_threads(conn, "chat_bot_a")
|
||||
assert len(open_threads) == 2
|
||||
ids = {t["thread_id"] for t in open_threads}
|
||||
assert ids == {"thr_1", "thr_2"}
|
||||
@@ -0,0 +1,132 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
import chat.state.world # registers handlers
|
||||
from chat.state.world import get_activity, get_chat
|
||||
|
||||
|
||||
def _chat_payload(**overrides):
|
||||
payload = {
|
||||
"id": "chat_bot_a",
|
||||
"host_bot_id": "bot_a",
|
||||
"guest_bot_id": None,
|
||||
"initial_time": "2026-04-26T20:00:00+00:00",
|
||||
"narrative_anchor": "Day 1 evening",
|
||||
"weather": "clear",
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
def _container_payload(**overrides):
|
||||
payload = {
|
||||
"chat_id": "chat_bot_a",
|
||||
"name": "office",
|
||||
"type": "workplace",
|
||||
"properties": {
|
||||
"public": True,
|
||||
"moving": False,
|
||||
"audible_range": "normal",
|
||||
"slots": [],
|
||||
},
|
||||
"parent_id": None,
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
def _activity_payload(**overrides):
|
||||
payload = {
|
||||
"entity_id": "bot_a",
|
||||
"container_id": 1,
|
||||
"slot": "desk_chair",
|
||||
"posture": "sitting",
|
||||
"action": {"verb": "writing email"},
|
||||
"attention": "the screen",
|
||||
"holding": ["pen"],
|
||||
"status": {"hungry": False},
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
def _seed_events(conn):
|
||||
"""Append seed events but do NOT project — caller appends more then projects once."""
|
||||
append_event(conn, kind="chat_created", payload=_chat_payload())
|
||||
append_event(conn, kind="container_created", payload=_container_payload())
|
||||
append_event(conn, kind="activity_change", payload=_activity_payload())
|
||||
|
||||
|
||||
def test_elision_advances_chat_clock_only(tmp_path):
|
||||
db = tmp_path / "t.db"
|
||||
apply_migrations(db)
|
||||
with open_db(db) as conn:
|
||||
_seed_events(conn)
|
||||
append_event(conn, kind="time_skip_elision", payload={
|
||||
"chat_id": "chat_bot_a",
|
||||
"new_time": "2026-04-26T20:30:00+00:00",
|
||||
})
|
||||
project(conn)
|
||||
|
||||
chat = get_chat(conn, "chat_bot_a")
|
||||
assert chat["time"] == "2026-04-26T20:30:00+00:00"
|
||||
|
||||
# Activity row preserved with the same fields it was seeded with.
|
||||
a = get_activity(conn, "bot_a")
|
||||
assert a is not None
|
||||
assert a["entity_id"] == "bot_a"
|
||||
assert a["container_id"] == 1
|
||||
assert a["slot"] == "desk_chair"
|
||||
assert a["posture"] == "sitting"
|
||||
assert a["action"] == {"verb": "writing email"}
|
||||
assert a["attention"] == "the screen"
|
||||
assert a["holding"] == ["pen"]
|
||||
assert a["status"] == {"hungry": False}
|
||||
|
||||
|
||||
def test_jump_with_reset_clears_activity(tmp_path):
|
||||
db = tmp_path / "t.db"
|
||||
apply_migrations(db)
|
||||
with open_db(db) as conn:
|
||||
_seed_events(conn)
|
||||
append_event(conn, kind="time_skip_jump", payload={
|
||||
"chat_id": "chat_bot_a",
|
||||
"new_time": "2026-04-27T08:00:00+00:00",
|
||||
"reset_activity": True,
|
||||
})
|
||||
project(conn)
|
||||
|
||||
chat = get_chat(conn, "chat_bot_a")
|
||||
assert chat["time"] == "2026-04-27T08:00:00+00:00"
|
||||
|
||||
count = conn.execute(
|
||||
"SELECT COUNT(*) FROM activity "
|
||||
"WHERE container_id IN (SELECT id FROM containers WHERE chat_id = ?)",
|
||||
("chat_bot_a",),
|
||||
).fetchone()[0]
|
||||
assert count == 0
|
||||
assert get_activity(conn, "bot_a") is None
|
||||
|
||||
|
||||
def test_jump_without_reset_preserves_activity(tmp_path):
|
||||
db = tmp_path / "t.db"
|
||||
apply_migrations(db)
|
||||
with open_db(db) as conn:
|
||||
_seed_events(conn)
|
||||
append_event(conn, kind="time_skip_jump", payload={
|
||||
"chat_id": "chat_bot_a",
|
||||
"new_time": "2026-04-27T08:00:00+00:00",
|
||||
"reset_activity": False,
|
||||
})
|
||||
project(conn)
|
||||
|
||||
chat = get_chat(conn, "chat_bot_a")
|
||||
assert chat["time"] == "2026-04-27T08:00:00+00:00"
|
||||
|
||||
a = get_activity(conn, "bot_a")
|
||||
assert a is not None
|
||||
assert a["posture"] == "sitting"
|
||||
assert a["action"]["verb"] == "writing email"
|
||||
+2
-2
@@ -324,11 +324,11 @@ def test_get_scene_returns_none_for_missing(tmp_path):
|
||||
assert active_scene(conn, "chat_missing") is None
|
||||
|
||||
|
||||
def test_schema_version_after_migration_is_8(tmp_path):
|
||||
def test_schema_version_after_migration_is_10(tmp_path):
|
||||
db = tmp_path / "t.db"
|
||||
apply_migrations(db)
|
||||
with open_db(db) as conn:
|
||||
row = conn.execute(
|
||||
"SELECT value FROM meta WHERE key = 'schema_version'"
|
||||
).fetchone()
|
||||
assert int(row[0]) == 8
|
||||
assert int(row[0]) == 10
|
||||
|
||||
Reference in New Issue
Block a user