f2a57005e5
Phase 2 T44 deferred interjection regenerate — when the original turn group included a follow-on interjection beat we left it untouched. Now regenerate redoes BOTH halves: - Detect a sibling interjection by looking up assistant_turn events pinned to the same user_turn_id with `interjection_of` set. - After streaming the new primary, run `detect_interjection` against the new primary text. - If True: stream a new interjection from the silent witness, append with `interjection_of=<new primary speaker_id>`, supersede the original interjection, and re-run memory + state-update for the new beat. - If False: supersede the original interjection without a replacement (back-pointer goes to the new primary so the row stays consistently hidden). Also broadcast a `turn_html_replace` event for the new interjection so the front-end can swap the prior interjection node in place (mirrors T73.1's primary swap). Tests: - `test_regenerate_with_interjection_redoes_both_turns`: classifier returns True; assert two new assistant_turns land for the same user_turn, second carries `interjection_of`, originals superseded. - `test_regenerate_drops_interjection_when_classifier_returns_false`: classifier returns False; assert one new assistant_turn (primary only) and the original interjection is superseded with no replacement. `interjection_of` carries the primary's *speaker_id* (matching the existing convention in chat/web/turns.py) rather than the event_id.
665 lines
23 KiB
Python
665 lines
23 KiB
Python
"""Regenerate flow (T29).
|
|
|
|
POST ``/chats/<chat_id>/turns/<event_id>/regenerate`` re-streams the
|
|
assistant turn, supersedes the prior ``assistant_turn`` event, and — when
|
|
prose is supplied — captures a ``user_turn_edit`` event that supersedes
|
|
the original ``user_turn``.
|
|
|
|
These tests cover the functional core required by the plan:
|
|
|
|
- Without edit: a new ``assistant_turn`` is appended; the original is
|
|
marked ``superseded_by`` the new one.
|
|
- With edit: a ``user_turn_edit`` event is appended; the original
|
|
``user_turn`` is also marked ``superseded_by``.
|
|
- Missing event id returns 404.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
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
|
|
from chat.llm.mock import MockLLMClient
|
|
|
|
|
|
@pytest.fixture
|
|
def client(tmp_path, monkeypatch):
|
|
cfg = tmp_path / "config.toml"
|
|
cfg.write_text('featherless_api_key = "test"\n')
|
|
monkeypatch.setenv("CHAT_CONFIG_PATH", str(cfg))
|
|
db = tmp_path / "test.db"
|
|
monkeypatch.setenv("CHAT_DB_PATH", str(db))
|
|
with TestClient(app) as c:
|
|
# Disable lifespan-managed background worker (would otherwise try
|
|
# to score significance through Featherless with the test key).
|
|
if hasattr(app.state, "background_worker"):
|
|
app.state.background_worker.enabled = False
|
|
yield c
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
def _seed_with_one_turn(db_path):
|
|
"""Seed bot, chat, edges/activity, and ONE round of user_turn + assistant_turn.
|
|
|
|
Returns ``(user_turn_event_id, assistant_turn_event_id)``.
|
|
"""
|
|
with open_db(db_path) as conn:
|
|
append_event(
|
|
conn,
|
|
kind="bot_authored",
|
|
payload={
|
|
"id": "bot_a",
|
|
"name": "BotA",
|
|
"persona": "thoughtful",
|
|
"voice_samples": [],
|
|
"traits": [],
|
|
"backstory": "",
|
|
"initial_relationship_to_you": "",
|
|
"kickoff_prose": "",
|
|
},
|
|
)
|
|
append_event(
|
|
conn,
|
|
kind="chat_created",
|
|
payload={
|
|
"id": "chat_bot_a",
|
|
"host_bot_id": "bot_a",
|
|
"initial_time": "2026-04-26T20:00:00+00:00",
|
|
"narrative_anchor": "Day 1",
|
|
"weather": "",
|
|
},
|
|
)
|
|
append_event(
|
|
conn,
|
|
kind="edge_update",
|
|
payload={
|
|
"source_id": "bot_a",
|
|
"target_id": "you",
|
|
"chat_id": "chat_bot_a",
|
|
},
|
|
)
|
|
append_event(
|
|
conn,
|
|
kind="edge_update",
|
|
payload={
|
|
"source_id": "you",
|
|
"target_id": "bot_a",
|
|
"chat_id": "chat_bot_a",
|
|
},
|
|
)
|
|
append_event(
|
|
conn,
|
|
kind="activity_change",
|
|
payload={
|
|
"entity_id": "you",
|
|
"posture": "sitting",
|
|
"action": {"verb": "talking"},
|
|
"attention": "",
|
|
"holding": [],
|
|
"status": {},
|
|
},
|
|
)
|
|
append_event(
|
|
conn,
|
|
kind="activity_change",
|
|
payload={
|
|
"entity_id": "bot_a",
|
|
"posture": "sitting",
|
|
"action": {"verb": "listening"},
|
|
"attention": "",
|
|
"holding": [],
|
|
"status": {},
|
|
},
|
|
)
|
|
# First round: user_turn + assistant_turn.
|
|
ut_id = append_event(
|
|
conn,
|
|
kind="user_turn",
|
|
payload={
|
|
"chat_id": "chat_bot_a",
|
|
"prose": "hello",
|
|
"segments": [],
|
|
},
|
|
)
|
|
at_id = append_event(
|
|
conn,
|
|
kind="assistant_turn",
|
|
payload={
|
|
"chat_id": "chat_bot_a",
|
|
"speaker_id": "bot_a",
|
|
"text": "Original response.",
|
|
"truncated": False,
|
|
"user_turn_id": ut_id,
|
|
},
|
|
)
|
|
project(conn)
|
|
return ut_id, at_id
|
|
|
|
|
|
def test_regenerate_without_edit_creates_new_assistant_turn(client, tmp_path):
|
|
"""Reissuing the regenerate POST with no prose should:
|
|
|
|
- Stream a new ``assistant_turn`` carrying ``regenerated_from`` and
|
|
the canned narrative text.
|
|
- Mark the original ``assistant_turn`` row as ``superseded_by`` the
|
|
new one.
|
|
"""
|
|
ut_id, at_id = _seed_with_one_turn(tmp_path / "test.db")
|
|
|
|
narrative_canned = "New response."
|
|
state_canned = json.dumps(
|
|
{"affinity_delta": 0, "trust_delta": 0, "knowledge_facts": []}
|
|
)
|
|
canned = [narrative_canned, state_canned, state_canned]
|
|
|
|
from chat.web.kickoff import get_llm_client
|
|
|
|
app.dependency_overrides[get_llm_client] = lambda: MockLLMClient(
|
|
canned=list(canned)
|
|
)
|
|
try:
|
|
response = client.post(
|
|
f"/chats/chat_bot_a/turns/{at_id}/regenerate", data={}
|
|
)
|
|
assert response.status_code == 204
|
|
finally:
|
|
app.dependency_overrides.clear()
|
|
|
|
with open_db(tmp_path / "test.db") as conn:
|
|
# Original assistant_turn is now superseded.
|
|
row = conn.execute(
|
|
"SELECT superseded_by FROM event_log WHERE id = ?", (at_id,)
|
|
).fetchone()
|
|
assert row[0] is not None
|
|
|
|
# A new assistant_turn exists, links back to the original, and
|
|
# carries the canned narrative text.
|
|
cur = conn.execute(
|
|
"SELECT id, payload_json FROM event_log "
|
|
"WHERE kind = 'assistant_turn' AND id != ? "
|
|
"AND superseded_by IS NULL",
|
|
(at_id,),
|
|
).fetchall()
|
|
assert len(cur) == 1
|
|
new_id, new_payload_json = cur[0]
|
|
new_payload = json.loads(new_payload_json)
|
|
assert new_payload["text"] == "New response."
|
|
assert new_payload["regenerated_from"] == at_id
|
|
# The original assistant_turn's superseded_by points at the new one.
|
|
assert row[0] == new_id
|
|
|
|
# The original user_turn is NOT touched when no prose was supplied.
|
|
ut_row = conn.execute(
|
|
"SELECT superseded_by FROM event_log WHERE id = ?", (ut_id,)
|
|
).fetchone()
|
|
assert ut_row[0] is None
|
|
|
|
|
|
def test_regenerate_with_edit_appends_user_turn_edit(client, tmp_path):
|
|
"""Supplying ``prose`` should:
|
|
|
|
- Append a ``user_turn_edit`` event whose payload references the
|
|
original user_turn id and carries the edited prose.
|
|
- Mark the original ``user_turn`` as ``superseded_by`` the edit.
|
|
"""
|
|
ut_id, at_id = _seed_with_one_turn(tmp_path / "test.db")
|
|
|
|
narrative_canned = "Reply to edited."
|
|
state_canned = json.dumps(
|
|
{"affinity_delta": 0, "trust_delta": 0, "knowledge_facts": []}
|
|
)
|
|
canned = [narrative_canned, state_canned, state_canned]
|
|
|
|
from chat.web.kickoff import get_llm_client
|
|
|
|
app.dependency_overrides[get_llm_client] = lambda: MockLLMClient(
|
|
canned=list(canned)
|
|
)
|
|
try:
|
|
response = client.post(
|
|
f"/chats/chat_bot_a/turns/{at_id}/regenerate",
|
|
data={"prose": "edited prose"},
|
|
)
|
|
assert response.status_code == 204
|
|
finally:
|
|
app.dependency_overrides.clear()
|
|
|
|
with open_db(tmp_path / "test.db") as conn:
|
|
# A user_turn_edit event was appended with the edited prose and
|
|
# a back-pointer to the original user_turn.
|
|
cur = conn.execute(
|
|
"SELECT payload_json FROM event_log WHERE kind = 'user_turn_edit'"
|
|
).fetchall()
|
|
assert len(cur) == 1
|
|
edit_payload = json.loads(cur[0][0])
|
|
assert edit_payload["prose"] == "edited prose"
|
|
assert edit_payload["supersedes_user_turn_id"] == ut_id
|
|
assert edit_payload["chat_id"] == "chat_bot_a"
|
|
|
|
# Original user_turn is now superseded.
|
|
ut_row = conn.execute(
|
|
"SELECT superseded_by FROM event_log WHERE id = ?", (ut_id,)
|
|
).fetchone()
|
|
assert ut_row[0] is not None
|
|
|
|
# Original assistant_turn is also superseded by the new one.
|
|
at_row = conn.execute(
|
|
"SELECT superseded_by FROM event_log WHERE id = ?", (at_id,)
|
|
).fetchone()
|
|
assert at_row[0] is not None
|
|
|
|
|
|
def test_regenerate_404_when_assistant_turn_missing(client, tmp_path):
|
|
"""An unknown ``event_id`` returns 404."""
|
|
_seed_with_one_turn(tmp_path / "test.db")
|
|
|
|
from chat.web.kickoff import get_llm_client
|
|
|
|
app.dependency_overrides[get_llm_client] = lambda: MockLLMClient(
|
|
canned=["x", "y", "z"]
|
|
)
|
|
try:
|
|
response = client.post(
|
|
"/chats/chat_bot_a/turns/99999/regenerate", data={}
|
|
)
|
|
assert response.status_code == 404
|
|
finally:
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
def _seed_with_interjection_group(db_path):
|
|
"""Seed a multi-entity scene with a (primary + interjection) group.
|
|
|
|
Returns ``(user_turn_id, primary_at_id, interjection_at_id)``.
|
|
|
|
The primary speaker is the host (bot_a); the silent witness who
|
|
interjected is the guest (bot_b). Mirrors the convention in
|
|
chat/web/turns.py — both assistant_turns share the same
|
|
``user_turn_id`` and the interjection's payload carries
|
|
``interjection_of=<primary speaker_id>``.
|
|
"""
|
|
with open_db(db_path) as conn:
|
|
for bot_id, name, persona in (
|
|
("bot_a", "BotA", "thoughtful"),
|
|
("bot_b", "BotB", "loud"),
|
|
):
|
|
append_event(
|
|
conn,
|
|
kind="bot_authored",
|
|
payload={
|
|
"id": bot_id,
|
|
"name": name,
|
|
"persona": persona,
|
|
"voice_samples": [],
|
|
"traits": [],
|
|
"backstory": "",
|
|
"initial_relationship_to_you": "",
|
|
"kickoff_prose": "",
|
|
},
|
|
)
|
|
append_event(
|
|
conn,
|
|
kind="chat_created",
|
|
payload={
|
|
"id": "chat_multi",
|
|
"host_bot_id": "bot_a",
|
|
"guest_bot_id": "bot_b",
|
|
"initial_time": "2026-04-26T20:00:00+00:00",
|
|
"narrative_anchor": "Day 1",
|
|
"weather": "",
|
|
},
|
|
)
|
|
for src, tgt in (
|
|
("bot_a", "you"),
|
|
("you", "bot_a"),
|
|
("bot_b", "you"),
|
|
("you", "bot_b"),
|
|
("bot_a", "bot_b"),
|
|
("bot_b", "bot_a"),
|
|
):
|
|
append_event(
|
|
conn,
|
|
kind="edge_update",
|
|
payload={
|
|
"source_id": src,
|
|
"target_id": tgt,
|
|
"chat_id": "chat_multi",
|
|
},
|
|
)
|
|
for entity_id in ("you", "bot_a", "bot_b"):
|
|
append_event(
|
|
conn,
|
|
kind="activity_change",
|
|
payload={
|
|
"entity_id": entity_id,
|
|
"posture": "sitting",
|
|
"action": {"verb": "talking"},
|
|
"attention": "",
|
|
"holding": [],
|
|
"status": {},
|
|
},
|
|
)
|
|
ut_id = append_event(
|
|
conn,
|
|
kind="user_turn",
|
|
payload={
|
|
"chat_id": "chat_multi",
|
|
"prose": "hello",
|
|
"segments": [],
|
|
},
|
|
)
|
|
primary_id = append_event(
|
|
conn,
|
|
kind="assistant_turn",
|
|
payload={
|
|
"chat_id": "chat_multi",
|
|
"speaker_id": "bot_a",
|
|
"text": "Original primary.",
|
|
"truncated": False,
|
|
"user_turn_id": ut_id,
|
|
},
|
|
)
|
|
interjection_id = append_event(
|
|
conn,
|
|
kind="assistant_turn",
|
|
payload={
|
|
"chat_id": "chat_multi",
|
|
"speaker_id": "bot_b",
|
|
"text": "Original interjection!",
|
|
"truncated": False,
|
|
"user_turn_id": ut_id,
|
|
"interjection_of": "bot_a",
|
|
},
|
|
)
|
|
project(conn)
|
|
return ut_id, primary_id, interjection_id
|
|
|
|
|
|
def test_regenerate_broadcasts_turn_html_over_sse(
|
|
tmp_path, monkeypatch
|
|
):
|
|
"""T73.1: regenerate publishes a ``turn_html_replace`` SSE event so
|
|
connected tabs swap the prior turn's DOM node in place.
|
|
|
|
The event carries:
|
|
- ``data``: rendered HTML for the new turn
|
|
- ``turn_id``: event_id of the new assistant_turn
|
|
- ``supersedes_id``: event_id of the original assistant_turn
|
|
"""
|
|
import asyncio
|
|
|
|
from chat.config import Settings
|
|
from chat.db.migrate import apply_migrations
|
|
from chat.services import regenerate as regenerate_module
|
|
from chat.services.regenerate import regenerate_assistant_turn
|
|
|
|
db_path = tmp_path / "test.db"
|
|
cfg = tmp_path / "config.toml"
|
|
cfg.write_text('featherless_api_key = "test"\n')
|
|
monkeypatch.setenv("CHAT_CONFIG_PATH", str(cfg))
|
|
monkeypatch.setenv("CHAT_DB_PATH", str(db_path))
|
|
apply_migrations(db_path)
|
|
|
|
ut_id, at_id = _seed_with_one_turn(db_path)
|
|
|
|
published: list[tuple[str, dict]] = []
|
|
|
|
async def _capture(chat_id, event):
|
|
published.append((chat_id, event))
|
|
|
|
# Patch the imported reference inside the regenerate module so the
|
|
# service's call site goes through our spy.
|
|
monkeypatch.setattr(regenerate_module, "publish", _capture)
|
|
|
|
narrative_canned = "Refreshed reply."
|
|
state_canned = json.dumps(
|
|
{"affinity_delta": 0, "trust_delta": 0, "knowledge_facts": []}
|
|
)
|
|
canned = [narrative_canned, state_canned, state_canned]
|
|
mock_client = MockLLMClient(canned=list(canned))
|
|
|
|
settings = Settings(featherless_api_key="test")
|
|
|
|
with open_db(db_path) as conn:
|
|
new_text = asyncio.run(
|
|
regenerate_assistant_turn(
|
|
conn,
|
|
mock_client,
|
|
settings=settings,
|
|
chat_id="chat_bot_a",
|
|
original_assistant_event_id=at_id,
|
|
)
|
|
)
|
|
assert new_text == narrative_canned
|
|
|
|
# Find the new assistant_turn event_id for cross-checking.
|
|
cur = conn.execute(
|
|
"SELECT id FROM event_log "
|
|
"WHERE kind = 'assistant_turn' AND id != ? "
|
|
"AND superseded_by IS NULL",
|
|
(at_id,),
|
|
).fetchone()
|
|
new_at_id = cur[0]
|
|
|
|
# Filter out per-token publishes; we want the replace broadcast.
|
|
replace_calls = [
|
|
ev for (_cid, ev) in published if ev.get("event") == "turn_html_replace"
|
|
]
|
|
assert len(replace_calls) == 1
|
|
payload = replace_calls[0]
|
|
assert payload["supersedes_id"] == at_id
|
|
assert payload["turn_id"] == new_at_id
|
|
# The HTML carries the new narrative text and the speaker name.
|
|
assert "Refreshed reply." in payload["data"]
|
|
assert "BotA" in payload["data"]
|
|
# Sanity: every publish targeted this chat.
|
|
for cid, _ev in published:
|
|
assert cid == "chat_bot_a"
|
|
|
|
|
|
def test_regenerate_with_interjection_redoes_both_turns(tmp_path, monkeypatch):
|
|
"""T73.2: when the original turn group included an interjection, both
|
|
the primary and the interjection are regenerated.
|
|
|
|
Setup: 3-entity scene (host BotA + guest BotB + you) with a prior
|
|
(primary by BotA + interjection by BotB) group. Mock the
|
|
interjection classifier to return ``should_interject=True`` so the
|
|
follow-on regenerates too.
|
|
|
|
Assert: 2 new assistant_turns exist for the same user_turn_id, the
|
|
second carrying ``interjection_of`` pointing at the new primary's
|
|
speaker_id. Both originals are superseded.
|
|
"""
|
|
import asyncio
|
|
|
|
from chat.config import Settings
|
|
from chat.db.migrate import apply_migrations
|
|
from chat.services import regenerate as regenerate_module
|
|
from chat.services.interjection import InterjectionDecision
|
|
from chat.services.regenerate import regenerate_assistant_turn
|
|
|
|
db_path = tmp_path / "test.db"
|
|
cfg = tmp_path / "config.toml"
|
|
cfg.write_text('featherless_api_key = "test"\n')
|
|
monkeypatch.setenv("CHAT_CONFIG_PATH", str(cfg))
|
|
monkeypatch.setenv("CHAT_DB_PATH", str(db_path))
|
|
apply_migrations(db_path)
|
|
|
|
ut_id, primary_id, interjection_id = _seed_with_interjection_group(db_path)
|
|
|
|
# Stub detect_interjection so the classifier "fires" with new prose.
|
|
async def _stub_should_interject(*_args, **_kwargs):
|
|
return InterjectionDecision(should_interject=True, reason="fired")
|
|
|
|
monkeypatch.setattr(
|
|
regenerate_module, "detect_interjection", _stub_should_interject
|
|
)
|
|
|
|
# Canned queue:
|
|
# 1. New primary narrative stream.
|
|
# 2-7. Six state-update classifier calls (one per directed pair
|
|
# across host/you/guest = 6 pairs) for the primary pass.
|
|
# 8. New interjection narrative stream.
|
|
# 9-14. Six state-update classifier calls for the post-interjection
|
|
# pass.
|
|
state_canned = json.dumps(
|
|
{"affinity_delta": 0, "trust_delta": 0, "knowledge_facts": []}
|
|
)
|
|
canned: list[str] = []
|
|
canned.append("New primary text.")
|
|
canned.extend([state_canned] * 6)
|
|
canned.append("New interjection text!")
|
|
canned.extend([state_canned] * 6)
|
|
mock_client = MockLLMClient(canned=list(canned))
|
|
|
|
settings = Settings(featherless_api_key="test")
|
|
|
|
with open_db(db_path) as conn:
|
|
new_text = asyncio.run(
|
|
regenerate_assistant_turn(
|
|
conn,
|
|
mock_client,
|
|
settings=settings,
|
|
chat_id="chat_multi",
|
|
original_assistant_event_id=primary_id,
|
|
)
|
|
)
|
|
assert new_text == "New primary text."
|
|
|
|
# Both originals are superseded.
|
|
primary_super = conn.execute(
|
|
"SELECT superseded_by FROM event_log WHERE id = ?", (primary_id,)
|
|
).fetchone()[0]
|
|
interjection_super = conn.execute(
|
|
"SELECT superseded_by FROM event_log WHERE id = ?",
|
|
(interjection_id,),
|
|
).fetchone()[0]
|
|
assert primary_super is not None
|
|
assert interjection_super is not None
|
|
|
|
# Two NEW assistant_turn events exist (the regenerated primary
|
|
# and the regenerated interjection), both pinned to the same
|
|
# user_turn_id as the originals.
|
|
cur = conn.execute(
|
|
"SELECT id, payload_json FROM event_log "
|
|
"WHERE kind = 'assistant_turn' AND id NOT IN (?, ?) "
|
|
"ORDER BY id",
|
|
(primary_id, interjection_id),
|
|
).fetchall()
|
|
assert len(cur) == 2
|
|
new_primary_id, new_primary_payload_json = cur[0]
|
|
new_interjection_id, new_interjection_payload_json = cur[1]
|
|
new_primary_payload = json.loads(new_primary_payload_json)
|
|
new_interjection_payload = json.loads(new_interjection_payload_json)
|
|
|
|
assert new_primary_payload["text"] == "New primary text."
|
|
assert new_primary_payload["speaker_id"] == "bot_a"
|
|
assert new_primary_payload["user_turn_id"] == ut_id
|
|
assert new_primary_payload["regenerated_from"] == primary_id
|
|
assert "interjection_of" not in new_primary_payload
|
|
|
|
assert new_interjection_payload["text"] == "New interjection text!"
|
|
assert new_interjection_payload["speaker_id"] == "bot_b"
|
|
assert new_interjection_payload["user_turn_id"] == ut_id
|
|
assert new_interjection_payload["regenerated_from"] == interjection_id
|
|
# interjection_of links to the new primary's speaker (matches
|
|
# the existing convention in chat/web/turns.py).
|
|
assert new_interjection_payload["interjection_of"] == "bot_a"
|
|
|
|
# The originals' supersede pointers reach the new ones.
|
|
assert primary_super == new_primary_id
|
|
assert interjection_super == new_interjection_id
|
|
|
|
|
|
def test_regenerate_drops_interjection_when_classifier_returns_false(
|
|
tmp_path, monkeypatch
|
|
):
|
|
"""T73.2: when the original group included an interjection but the
|
|
classifier returns False this time, the new group is primary-only.
|
|
|
|
The original interjection is still superseded (we don't leave it
|
|
visible in the timeline alongside a regenerated primary it no longer
|
|
follows from), but no replacement assistant_turn is appended.
|
|
"""
|
|
import asyncio
|
|
|
|
from chat.config import Settings
|
|
from chat.db.migrate import apply_migrations
|
|
from chat.services import regenerate as regenerate_module
|
|
from chat.services.interjection import InterjectionDecision
|
|
from chat.services.regenerate import regenerate_assistant_turn
|
|
|
|
db_path = tmp_path / "test.db"
|
|
cfg = tmp_path / "config.toml"
|
|
cfg.write_text('featherless_api_key = "test"\n')
|
|
monkeypatch.setenv("CHAT_CONFIG_PATH", str(cfg))
|
|
monkeypatch.setenv("CHAT_DB_PATH", str(db_path))
|
|
apply_migrations(db_path)
|
|
|
|
ut_id, primary_id, interjection_id = _seed_with_interjection_group(db_path)
|
|
|
|
async def _stub_no_interject(*_args, **_kwargs):
|
|
return InterjectionDecision(
|
|
should_interject=False, reason="quiet"
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
regenerate_module, "detect_interjection", _stub_no_interject
|
|
)
|
|
|
|
# Canned queue: primary narrative + 6 state-update calls. No
|
|
# interjection stream because the classifier short-circuits.
|
|
state_canned = json.dumps(
|
|
{"affinity_delta": 0, "trust_delta": 0, "knowledge_facts": []}
|
|
)
|
|
canned: list[str] = ["New primary text."] + [state_canned] * 6
|
|
mock_client = MockLLMClient(canned=list(canned))
|
|
|
|
settings = Settings(featherless_api_key="test")
|
|
|
|
with open_db(db_path) as conn:
|
|
new_text = asyncio.run(
|
|
regenerate_assistant_turn(
|
|
conn,
|
|
mock_client,
|
|
settings=settings,
|
|
chat_id="chat_multi",
|
|
original_assistant_event_id=primary_id,
|
|
)
|
|
)
|
|
assert new_text == "New primary text."
|
|
|
|
# Original primary superseded by the new primary.
|
|
primary_super = conn.execute(
|
|
"SELECT superseded_by FROM event_log WHERE id = ?", (primary_id,)
|
|
).fetchone()[0]
|
|
# Original interjection ALSO superseded — we don't leave a
|
|
# dangling beat attached to a regenerated primary that no longer
|
|
# warrants a follow-on. Back-pointer goes to the new primary.
|
|
interjection_super = conn.execute(
|
|
"SELECT superseded_by FROM event_log WHERE id = ?",
|
|
(interjection_id,),
|
|
).fetchone()[0]
|
|
assert primary_super is not None
|
|
assert interjection_super is not None
|
|
assert interjection_super == primary_super # both point at new primary
|
|
|
|
# Exactly ONE new assistant_turn — the primary; no replacement
|
|
# interjection.
|
|
cur = conn.execute(
|
|
"SELECT payload_json FROM event_log "
|
|
"WHERE kind = 'assistant_turn' AND id NOT IN (?, ?) "
|
|
"AND superseded_by IS NULL",
|
|
(primary_id, interjection_id),
|
|
).fetchall()
|
|
assert len(cur) == 1
|
|
new_primary_payload = json.loads(cur[0][0])
|
|
assert new_primary_payload["text"] == "New primary text."
|
|
assert "interjection_of" not in new_primary_payload
|