tests/test_exec_tool_rounds.py
Rama master · réplica de lectura del repositorio original.
"""Regression tests for the Exec turn that announces an action it never takes.
Observed 2026-09-02: Wai said "shadowdark hosting sept 15", Exec called
`create_card` once, and its follow-up turn then claimed a duplicate card had
"already been in the pool from earlier this turn" and said it would exile it.
`rd.json` held exactly ONE Shadowdark card and the activity log carried no
exile — both halves of that sentence were wrong.
Two independent defects:
1. The follow-up turn is offered the tools, but both send paths (the web SSE
route and the Discord bridge) kept only its TEXT — any `tool_use` it emitted
was dropped on the floor, never dispatched, never stored. So "I'll exile the
duplicate" could never become an exile. `assistant_content_blocks` is the
shared helper that now preserves those blocks so the caller can run another
round.
2. `create_card` with a due_date logs BOTH a `created` and an `updated` entry
for the one card. Rendered into the prompt's activity log by title alone,
that reads as two cards — the phantom duplicate. Entries now carry the card
id, and the actions block says outright that entries sharing an id are one
card.
Pure unit tests: no fastapi, no live app.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "api"))
from chat_actions import _actions_taken_block # noqa: E402
from chat_store import assistant_content_blocks # noqa: E402
class _Block:
def __init__(self, **kw):
self.__dict__.update(kw)
class _Msg:
def __init__(self, content):
self.content = content
def test_followup_tool_use_survives():
"""A follow-up turn's tool_use must be kept, or the announced action is lost."""
msg = _Msg([
_Block(type="text", text="Exiling the duplicate."),
_Block(type="tool_use", id="tu_1", name="exile_card", input={"card_id": "card-1"}),
])
blocks = assistant_content_blocks(msg)
assert blocks == [
{"type": "text", "text": "Exiling the duplicate."},
{"type": "tool_use", "id": "tu_1", "name": "exile_card", "input": {"card_id": "card-1"}},
]
def test_non_text_tool_blocks_dropped():
"""Thinking/other block types are not storable content."""
msg = _Msg([_Block(type="thinking", thinking="..."), _Block(type="text", text="hi")])
assert assistant_content_blocks(msg) == [{"type": "text", "text": "hi"}]
def test_actions_block_names_the_created_card():
block = _actions_taken_block([{
"name": "create_card",
"input": {"title": "Host Shadowdark session"},
"result": {"ok": True, "title": "Host Shadowdark session"},
}])
assert "created a NEW card 'Host Shadowdark session'" in block
assert "because you just made it" in block
def test_actions_block_warns_that_repeat_log_entries_are_one_card():
"""The created+updated pair from ONE create must not read as two cards."""
block = _actions_taken_block([{
"name": "create_card", "input": {"title": "X"}, "result": {"ok": True, "title": "X"},
}])
assert "[id:...]" in block
assert "same card, never two" in block
def test_failed_action_is_not_reported_as_taken():
assert _actions_taken_block([{
"name": "create_card", "input": {"title": "X"}, "result": {"error": "boom"},
}]) == ""
assert _actions_taken_block([]) == ""