aboutsummaryrefslogtreecommitdiffstats
path: root/test_llamachat.py
diff options
context:
space:
mode:
Diffstat (limited to 'test_llamachat.py')
-rwxr-xr-xtest_llamachat.py161
1 files changed, 156 insertions, 5 deletions
diff --git a/test_llamachat.py b/test_llamachat.py
index a766382..a06d737 100755
--- a/test_llamachat.py
+++ b/test_llamachat.py
@@ -967,11 +967,11 @@ def test_search_tool_schema():
assert out == [("content", "hi")]
assert sent == [None], sent
- # One search per turn by default: a follow-up call arrives as literal
- # <tool_call> XML rather than a structured call, and a turn that ends on
- # one shows the user an empty reply.
- assert config.DEFAULTS["max_searches"] == 1
- assert config.load(Path("/nonexistent/config.toml")).max_searches == 1
+ # Two searches per turn, which a follow-up call now survives: the final
+ # round's tool result says the tool is gone, and quantized KV cache, the
+ # other half of the empty-reply bug, is a server-side setting.
+ assert config.DEFAULTS["max_searches"] == 2
+ assert config.load(Path("/nonexistent/config.toml")).max_searches == 2
# A configured-but-urlless setup resolves to disabled at config load.
with tempfile.TemporaryDirectory() as tmp:
@@ -1472,6 +1472,152 @@ def test_search_html():
print("ok search html")
+def test_final_round_note():
+ """The last search result tells the model it has no searches left.
+
+ Withdrawing the tool schema is invisible to the model: it tries a second
+ search anyway and emits it as literal <tool_call> text, or trails off
+ inside the thinking block, either way leaving the reply empty. Saying so
+ in the tool result is what actually ends the turn with an answer. The
+ note cannot be a trailing system message: this model's chat template
+ raises "System message must be at the beginning".
+ """
+ from llamachat import search
+
+ plain = search.tool_message("call_1", [{"title": "T", "url": "u", "content": "c"}])
+ final = search.tool_message(
+ "call_1", [{"title": "T", "url": "u", "content": "c"}], last=True
+ )
+ assert plain["role"] == "tool" and final["role"] == "tool"
+ assert search.NO_MORE_SEARCHES not in plain["content"]
+ assert search.NO_MORE_SEARCHES in final["content"]
+ # The results themselves survive the note being appended.
+ for message in (plain, final):
+ assert '"title": "T"' in message["content"] or '"title":"T"' in message["content"]
+ # A failed final search still gets the note, or the model retries.
+ failed = search.tool_message("call_1", None, error="boom", last=True)
+ assert "boom" in failed["content"]
+ assert search.NO_MORE_SEARCHES in failed["content"]
+ print("ok final round note")
+
+
+def test_title_cleaning():
+ clean = backend.clean_title
+
+ assert clean("Kernel build failure") == "Kernel build failure"
+ # Quotes, trailing punctuation and a leading label all come off.
+ assert clean('"Kernel build failure"') == "Kernel build failure"
+ assert clean("Title: Kernel build failure.") == "Kernel build failure"
+ assert clean("**Kernel build failure**") == "Kernel build failure"
+ # Thinking that leaks into the body is dropped, answer kept.
+ assert clean("<think>hmm, short</think>Kernel build") == "Kernel build"
+ # An unclosed think block leaves nothing usable rather than a stray tag.
+ assert clean("<think>still reasoning") == ""
+ # Chatter before the title: the last non-empty line wins.
+ assert clean("Sure, here you go:\nKernel build failure") == "Kernel build failure"
+ # Too long is cut on a word boundary, not mid-word.
+ long = clean("word " * 40)
+ assert len(long) <= 60, long
+ assert not long.endswith("wor"), long
+ # Nothing usable yields nothing, so the caller keeps its fallback.
+ assert clean("") == ""
+ assert clean(" \n\n ") == ""
+ assert clean('""') == ""
+ print("ok title cleaning")
+
+
+def test_title_request():
+ """Client.complete posts a non-streaming request and returns the text."""
+ seen = {}
+
+ class FakeResponse:
+ status_code = 200
+ text = ""
+
+ def raise_for_status(self):
+ pass
+
+ def json(self):
+ return {"choices": [{"message": {"content": " A Title "}}]}
+
+ def fake_post(url, json=None, timeout=None):
+ seen["url"] = url
+ seen["body"] = json
+ return FakeResponse()
+
+ import httpx
+
+ original = httpx.post
+ httpx.post = fake_post
+ try:
+ client = backend.Client("http://router:8181")
+ out = client.complete("m", [{"role": "user", "content": "hi"}], max_tokens=32)
+ finally:
+ httpx.post = original
+
+ assert out == " A Title ", out
+ assert seen["url"] == "http://router:8181/v1/chat/completions"
+ assert seen["body"]["model"] == "m"
+ assert seen["body"]["stream"] is False
+ assert seen["body"]["max_tokens"] == 32
+ # No tools are offered: a title turn must not trigger a search.
+ assert "tools" not in seen["body"]
+ # Thinking off, or a reasoning model spends the budget and returns "".
+ assert seen["body"]["chat_template_kwargs"] == {"enable_thinking": False}
+
+ # A reply with no choices is empty rather than an exception.
+ class Empty(FakeResponse):
+ def json(self):
+ return {}
+
+ httpx.post = lambda url, json=None, timeout=None: Empty()
+ try:
+ assert backend.Client("http://x").complete("m", []) == ""
+ finally:
+ httpx.post = original
+ print("ok title request")
+
+
+def test_needs_title():
+ """Which finished turns are worth asking the model to name.
+
+ The trigger is a session still carrying its placeholder title, not the
+ turn number: a first reply that comes back empty (the follow-up
+ <tool_call> bug) must not forfeit titling for the whole session, and a
+ one-shot window keeps appending to the same session until New is
+ pressed.
+ """
+ needs = backend.needs_title
+ q = "how do I build a custom kernel on slackware?"
+
+ assert needs(q, q, "Fetch the sources.") is True
+ # Nothing to title from: the empty-reply turn is skipped, not consumed.
+ assert needs(q, q, "") is False
+ assert needs(q, q, " \n ") is False
+ # Already named by the model, so leave it alone.
+ assert needs("Building a custom kernel", q, "Fetch the sources.") is False
+ # A long question is stored truncated; that still counts as untouched.
+ long_q = "x" * 200
+ assert needs(long_q[:60], long_q, "an answer") is True
+ # A title the user or model set that happens to be short is not a
+ # placeholder, even though it is under the cut-off.
+ assert needs("Kernels", long_q, "an answer") is False
+ print("ok needs title")
+
+
+def test_title_prompt():
+ """The title request carries the exchange and asks for a short title."""
+ messages = backend.title_messages("how do I build a kernel?", "Run make.")
+ assert messages[-1]["role"] == "user"
+ blob = json.dumps(messages)
+ assert "how do I build a kernel?" in blob
+ assert "Run make." in blob
+ # A huge exchange is trimmed so titling never costs a full context.
+ big = backend.title_messages("x" * 10000, "y" * 10000)
+ assert len(json.dumps(big)) < 6000, len(json.dumps(big))
+ print("ok title prompt")
+
+
if __name__ == "__main__":
test_presets()
test_real_presets()
@@ -1504,4 +1650,9 @@ if __name__ == "__main__":
test_search_storage()
test_date_note()
test_search_html()
+ test_final_round_note()
+ test_title_cleaning()
+ test_title_request()
+ test_needs_title()
+ test_title_prompt()
print("\nall checks passed")