456f50d334
Wire the active branch's [origin_event_id, head_event_id] window into every user-facing event/memory reader so switching branches actually changes what dialogue and memories the user sees. Phase 4 T89/T94 shipped branches as metadata-only — this closes the loop. Helper: - chat/state/branches.py: add `active_branch_event_ids(conn)` returning the active branch's id range, with two defensive fall-throughs to `(0, BIG_INT)`: (a) no active branch row at all, and (b) the bootstrap "main" sentinel (name="main", origin=0, head=0). Production never bumps main's head_event_id today, so this preserves existing reader behaviour for every test that doesn't explicitly switch. Readers updated (all user-facing dialogue / retrieval surfaces): - chat/services/turn_common.py::read_recent_dialogue — chat-history prompt context + the chat-view template path (via web/turns.py + web/chat.py). - chat/services/scene_summarize.py::_read_recent_dialogue — scene-close per-POV summary input. - chat/state/memory.py::search_memories — FTS leg filters via m.event_id (T109's column); legacy NULL event_id rows are *included* unconditionally so the filter doesn't break pre-0014 retrieval. The fused (FTS + RRF + vector) path also drops vector hits whose event_id falls outside the branch window. - chat/web/meanwhile.py::_read_recent_meanwhile_dialogue — meanwhile prompt context. Projector queries (chat/state/world.py et al.) and admin/management surfaces (drawer hide-panel, cross-chat search, regenerate's row lookups by id) are intentionally NOT branch-filtered: projection must see the full log to build state correctly, and the admin surfaces operate across branches by design. Tests (10 new, 446 total): - tests/test_branches_state.py: 3 tests for `active_branch_event_ids` itself (bootstrap-main, no-active-branch, non-main literal range). - tests/test_branching.py: 7 cross-feature tests covering the spec's five required scenarios plus scene_summarize and meanwhile readers.
263 lines
7.9 KiB
Python
263 lines
7.9 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
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.branches # registers handlers
|
|
from chat.state.branches import (
|
|
_NO_HEAD_CLAMP,
|
|
active_branch,
|
|
active_branch_event_ids,
|
|
get_branch,
|
|
list_branches,
|
|
)
|
|
|
|
|
|
def test_main_branch_bootstrapped_by_migration(tmp_path):
|
|
db = tmp_path / "t.db"
|
|
apply_migrations(db)
|
|
with open_db(db) as conn:
|
|
active = active_branch(conn)
|
|
assert active is not None
|
|
assert active["name"] == "main"
|
|
assert active["is_active"] is True
|
|
assert active["origin_event_id"] == 0
|
|
assert active["head_event_id"] == 0
|
|
|
|
|
|
def test_branch_created_inserts_row(tmp_path):
|
|
db = tmp_path / "t.db"
|
|
apply_migrations(db)
|
|
with open_db(db) as conn:
|
|
append_event(
|
|
conn,
|
|
kind="branch_created",
|
|
payload={
|
|
"name": "experiment",
|
|
"origin_event_id": 42,
|
|
"chat_id": "chat_a",
|
|
},
|
|
)
|
|
project(conn)
|
|
|
|
b = get_branch(conn, "experiment")
|
|
assert b is not None
|
|
assert b["name"] == "experiment"
|
|
assert b["origin_event_id"] == 42
|
|
# head defaults to origin when not specified
|
|
assert b["head_event_id"] == 42
|
|
assert b["chat_id"] == "chat_a"
|
|
assert b["is_active"] is False
|
|
|
|
# main remains active
|
|
active = active_branch(conn)
|
|
assert active is not None
|
|
assert active["name"] == "main"
|
|
|
|
|
|
def test_branch_switched_atomic(tmp_path):
|
|
db = tmp_path / "t.db"
|
|
apply_migrations(db)
|
|
with open_db(db) as conn:
|
|
append_event(
|
|
conn,
|
|
kind="branch_created",
|
|
payload={
|
|
"name": "experiment",
|
|
"origin_event_id": 5,
|
|
"chat_id": "chat_a",
|
|
},
|
|
)
|
|
append_event(
|
|
conn,
|
|
kind="branch_switched",
|
|
payload={"name": "experiment"},
|
|
)
|
|
project(conn)
|
|
|
|
active = active_branch(conn)
|
|
assert active is not None
|
|
assert active["name"] == "experiment"
|
|
|
|
main = get_branch(conn, "main")
|
|
assert main is not None
|
|
assert main["is_active"] is False
|
|
|
|
# switch back
|
|
append_event(
|
|
conn,
|
|
kind="branch_switched",
|
|
payload={"name": "main"},
|
|
)
|
|
project(conn)
|
|
|
|
active2 = active_branch(conn)
|
|
assert active2 is not None
|
|
assert active2["name"] == "main"
|
|
|
|
experiment = get_branch(conn, "experiment")
|
|
assert experiment is not None
|
|
assert experiment["is_active"] is False
|
|
|
|
|
|
def test_branch_head_updated_changes_head(tmp_path):
|
|
db = tmp_path / "t.db"
|
|
apply_migrations(db)
|
|
with open_db(db) as conn:
|
|
append_event(
|
|
conn,
|
|
kind="branch_created",
|
|
payload={
|
|
"name": "experiment",
|
|
"origin_event_id": 10,
|
|
"head_event_id": 10,
|
|
"chat_id": "chat_a",
|
|
},
|
|
)
|
|
append_event(
|
|
conn,
|
|
kind="branch_head_updated",
|
|
payload={"name": "experiment", "head_event_id": 20},
|
|
)
|
|
project(conn)
|
|
|
|
b = get_branch(conn, "experiment")
|
|
assert b is not None
|
|
assert b["head_event_id"] == 20
|
|
|
|
|
|
def test_list_branches_returns_all(tmp_path):
|
|
db = tmp_path / "t.db"
|
|
apply_migrations(db)
|
|
with open_db(db) as conn:
|
|
append_event(
|
|
conn,
|
|
kind="branch_created",
|
|
payload={
|
|
"name": "experiment",
|
|
"origin_event_id": 1,
|
|
"chat_id": "chat_a",
|
|
},
|
|
)
|
|
project(conn)
|
|
|
|
names = [b["name"] for b in list_branches(conn)]
|
|
assert "main" in names
|
|
assert "experiment" in names
|
|
|
|
|
|
def test_branch_switched_unknown_name_warns(tmp_path, caplog):
|
|
"""Switching to a nonexistent branch logs a warning and leaves no branch active.
|
|
|
|
The previous behavior silently cleared is_active flags and applied no UPDATE
|
|
when the named branch did not exist. T103 makes that condition observable
|
|
by emitting a warning while preserving the existing (zero-active) outcome.
|
|
"""
|
|
db = tmp_path / "t.db"
|
|
apply_migrations(db)
|
|
with open_db(db) as conn:
|
|
with caplog.at_level(logging.WARNING, logger="chat.state.branches"):
|
|
append_event(
|
|
conn,
|
|
kind="branch_switched",
|
|
payload={"name": "does_not_exist"},
|
|
)
|
|
project(conn)
|
|
|
|
# A warning was emitted naming the missing branch.
|
|
warnings = [
|
|
r for r in caplog.records
|
|
if r.levelno == logging.WARNING and r.name == "chat.state.branches"
|
|
]
|
|
assert warnings, "expected a warning for unknown branch name"
|
|
assert any("does_not_exist" in r.getMessage() for r in warnings)
|
|
|
|
# Existing behavior preserved: no branch is active after the switch.
|
|
assert active_branch(conn) is None
|
|
|
|
# The unknown name was not inserted as a side effect.
|
|
assert get_branch(conn, "does_not_exist") is None
|
|
|
|
|
|
def test_active_branch_event_ids_bootstrap_main_returns_no_clamp(tmp_path):
|
|
"""Bootstrap "main" (origin=0, head=0) reads as the no-clamp sentinel.
|
|
|
|
Migration 0013 seeds main with both event-id columns at 0; production
|
|
today never emits ``branch_head_updated`` for main, so head stays at 0
|
|
even as events accumulate. The helper treats this exact bootstrap
|
|
state as "all events visible" (lower bound 0, upper bound BIG_INT) so
|
|
every existing reader stays branch-agnostic until a non-main branch
|
|
becomes active.
|
|
"""
|
|
db = tmp_path / "t.db"
|
|
apply_migrations(db)
|
|
with open_db(db) as conn:
|
|
origin, head = active_branch_event_ids(conn)
|
|
assert origin == 0
|
|
assert head == _NO_HEAD_CLAMP
|
|
|
|
|
|
def test_active_branch_event_ids_no_active_branch_falls_through(tmp_path):
|
|
"""No active branch row at all → defensive ``(0, BIG_INT)``.
|
|
|
|
A switch to an unknown branch leaves zero rows with ``is_active=1``;
|
|
``active_branch`` returns None. The helper must still hand readers a
|
|
workable range (the full log) so the read pipeline doesn't crash on
|
|
an inconsistent metadata state.
|
|
"""
|
|
db = tmp_path / "t.db"
|
|
apply_migrations(db)
|
|
with open_db(db) as conn:
|
|
# Switching to a nonexistent branch clears is_active flags
|
|
# without setting any other branch active.
|
|
append_event(
|
|
conn,
|
|
kind="branch_switched",
|
|
payload={"name": "does_not_exist"},
|
|
)
|
|
project(conn)
|
|
assert active_branch(conn) is None
|
|
|
|
origin, head = active_branch_event_ids(conn)
|
|
assert origin == 0
|
|
assert head == _NO_HEAD_CLAMP
|
|
|
|
|
|
def test_active_branch_event_ids_returns_actual_range_for_non_main(tmp_path):
|
|
"""Non-main branches return their literal ``(origin, head)`` window.
|
|
|
|
A branch created at origin=10 + bumped to head=20 must surface as
|
|
(10, 20) so readers' ``BETWEEN`` clamp scopes to that window.
|
|
"""
|
|
db = tmp_path / "t.db"
|
|
apply_migrations(db)
|
|
with open_db(db) as conn:
|
|
append_event(
|
|
conn,
|
|
kind="branch_created",
|
|
payload={
|
|
"name": "experiment",
|
|
"origin_event_id": 10,
|
|
"head_event_id": 10,
|
|
"chat_id": "c1",
|
|
},
|
|
)
|
|
append_event(
|
|
conn,
|
|
kind="branch_head_updated",
|
|
payload={"name": "experiment", "head_event_id": 20},
|
|
)
|
|
append_event(
|
|
conn,
|
|
kind="branch_switched",
|
|
payload={"name": "experiment"},
|
|
)
|
|
project(conn)
|
|
|
|
origin, head = active_branch_event_ids(conn)
|
|
assert origin == 10
|
|
assert head == 20
|