Ctrl K
ComunidadGuías…

tests/test_chat_passes.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""The two-pass Exec turn (api/chat_passes.py): ACT with tools, then REPLY without.

Observed 2026-09-24: "hang out with Nick" / "hang out with Jesse" got "Added ...
to the ideas pool" with no tool call and an invented card id. These pin the
properties the split exists for, against a scripted fake client — no network,
no live app:

- act-pass text never reaches Wai; only the reply pass is streamed
- a reply that opens with [redo: ...] is never shown, and re-runs the act pass
  with the instruction
- the server's redo note is not saved as something Wai said
- the redo loop is bounded, and the last reply pass is told it cannot redo
"""
import asyncio
import copy
import sys
from pathlib import Path

import pytest

sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "api"))

import chat_passes  # noqa: E402
import exec_tools  # noqa: E402


class _B:
    def __init__(self, **kw):
        self.__dict__.update(kw)


class _Resp:
    def __init__(self, content):
        self.content = content


class _Stream:
    def __init__(self, chunks):
        self._chunks = chunks

    async def __aenter__(self):
        return self

    async def __aexit__(self, *a):
        return False

    @property
    def text_stream(self):
        async def gen():
            for c in self._chunks:
                yield c
        return gen()


class _Client:
    """Scripted: `acts` answers messages.create (act pass), `replies` answers
    messages.stream (reply pass), each in order. Records every call."""

    def __init__(self, acts, replies):
        self.acts, self.replies = list(acts), list(replies)
        self.calls = []
        self.messages = self

    async def create(self, **kw):
        self.calls.append(("act", copy.deepcopy(kw)))
        return self.acts.pop(0)

    def stream(self, **kw):
        self.calls.append(("reply", copy.deepcopy(kw)))
        return _Stream(self.replies.pop(0))


@pytest.fixture(autouse=True)
def _stub(monkeypatch):
    async def system(stage, actions, rules):
        return [{"type": "text", "text": "S"}, {"type": "text", "text": "V" + rules}]

    async def push(_):
        return None

    monkeypatch.setattr(chat_passes, "_system", system)
    monkeypatch.setattr(chat_passes, "push_to_monitor", push)
    # Tool dispatch lives in exec_tools.run_tool (shared with the sidecar route).
    monkeypatch.setattr(exec_tools, "_handle_tool",
                        lambda name, inp: {"ok": True, "id": "card-1", "title": inp.get("title")})


def _run(client, text="hang out with nick"):
    async def go():
        return [ev async for ev in chat_passes.run_turn(client, [{"role": "user", "content": text}])]
    return asyncio.run(go())


def _tool(title):
    return _B(type="tool_use", id="tu_" + title, name="create_card", input={"title": title})


def _shown(events):
    return "".join(e["delta"] for e in events if e["type"] == "text")


def test_act_text_is_discarded_and_reply_is_streamed():
    client = _Client(
        acts=[_Resp([_B(type="text", text="ACT TALK"), _tool("Nick")]), _Resp([])],
        replies=[["Added ", "Nick."]],
    )
    ev = _run(client)
    assert _shown(ev) == "Added Nick."
    assert [e["name"] for e in ev if e["type"] == "tool_call"] == ["create_card"]
    final = ev[-1]
    assert final["type"] == "final"
    saved = str(final["messages"])
    assert "ACT TALK" not in saved
    # reply pass ran with tools CLOSED
    reply_kw = [kw for kind, kw in client.calls if kind == "reply"][0]
    assert reply_kw["tool_choice"] == {"type": "none"}


def test_redo_is_hidden_and_reruns_the_act_pass():
    client = _Client(
        acts=[_Resp([]), _Resp([_tool("Jesse")]), _Resp([])],
        replies=[["[re", "do: create card Hang out with Jesse]"], ["Added Jesse."]],
    )
    ev = _run(client, "hang out with jesse")
    assert _shown(ev) == "Added Jesse."
    assert "redo" not in _shown(ev)
    # the second act pass saw the instruction
    second_act = [kw for kind, kw in client.calls if kind == "act"][1]
    assert "create card Hang out with Jesse" in str(second_act["messages"][-1])
    # ...but it is not saved as Wai's words
    assert chat_passes._NOTE not in str(ev[-1]["messages"])
    assert ev[-1]["messages"][0]["role"] == "user"


def test_redo_loop_is_bounded_and_last_pass_cannot_redo():
    n = chat_passes._MAX_REDOS
    client = _Client(
        acts=[_Resp([])] * (n + 1),
        replies=[["[redo: x]"]] * n + [["It was not done."]],
    )
    ev = _run(client)
    assert _shown(ev) == "It was not done."
    replies = [kw for kind, kw in client.calls if kind == "reply"]
    assert len(replies) == n + 1
    assert chat_passes._REDO not in replies[-1]["system"][1]["text"]
    assert chat_passes._REDO in replies[0]["system"][1]["text"]
    assert "Never claim it." in replies[-1]["system"][1]["text"]


def test_short_reply_that_is_not_a_redo_is_still_shown():
    client = _Client(acts=[_Resp([])], replies=[["[ok]"]])
    assert _shown(_run(client)) == "[ok]"