Files
Dumitru Picalau 5056722e1a fix(ai): Claude walkthrough fixes, chat polish and a search results card (#82)
Pack builder: Fix all and updates say why nothing went out while the web search card waits; page follow-up lines are not request words; Only rows narrow only the tag groups they rule on; answers are not request gaps; code-wired story beats skip the lookup lint; left-out rules read plainly and get Fix with AI; a rule overwriting a pick becomes an append; model labels read "For Illustrious"; roomier plan head.

Chat: same-name edits refused with the shown item named; search by category for organizing; empty edit answers carry a note; prompt syntax coloured inside cards; every chat format named in the prompt; pointer captions avoid each other; one shared working line.

Search: what an allowed search found shows as a card in the Pack builder and the Draft with AI panels; Use what fits sends all results for the model to keep what fits, ticking sends only those, None of these sends nothing.
2026-10-21 22:05:41 +02:00

252 lines
13 KiB
Python

"""The chat's `ask` tool: questions that pause the turn until the user
answers them, settled answers, and editing the last message."""
from __future__ import annotations
import json
from engine.ai import config as ai_config
from tests.wp_api.test_ai_api import _chat, _two_wildcards
def _on(fake_llm, **extra):
ai_config.update({"enabled": True, "provider": "lmstudio",
"base_url": str(fake_llm.make_url("/v1")), "model": "m1", **extra})
async def _json(resp, status=200):
assert resp.status == status, await resp.text()
return await resp.json()
async def _resume(wp_client, chat_id, results, status=200):
return await _json(await wp_client.post("/wp/api/ai/chat", json={
"chat_id": chat_id, "results": results}), status)
QUESTIONS = {"questions": [
{"header": "Style", "question": "Which art style should the pack use?",
"choices": ["Anime", "Realistic: photo-like", "Painterly (oil and brush)"]},
{"q": "Add negatives to options?", "options": ["Yes", "No"]},
{"header": "Extras", "question": "Which extras?", "multi": True,
"choices": [{"label": "Hats"}, {"label": "Glasses", "hint": "round or square"}, "Scarves"]},
]}
def _ask_step(args=None, message=""):
return {"message": message, "calls": [
{"tool": "ask", "args": json.dumps(args or QUESTIONS)}]}
def _last_user(fake_llm) -> str:
return fake_llm.seen[-1]["body"]["messages"][-1]["content"]
async def test_ask_pauses_and_the_answers_carry_the_turn_on(wp_client, fake_llm):
_on(fake_llm)
fake_llm.chat_steps.extend([_ask_step(), {"message": "Building it.", "calls": []}])
first = await _chat(wp_client, "make me a fantasy character pack")
assert [c["tool"] for c in first["pending"]] == ["ask"]
call = first["pending"][0]
assert call["label"] == "Asked 3 questions"
style, neg, extras = call["args"]["questions"]
assert style == {"header": "Style", "question": "Which art style should the pack use?",
"multi": False, "choices": [
{"label": "Anime"}, {"label": "Realistic", "hint": "photo-like"},
{"label": "Painterly", "hint": "oil and brush"}]}
# Aliases (q, options) are read, and a missing header comes from the question.
assert neg["header"] == "Add negatives to" and neg["question"].startswith("Add")
assert extras["multi"] is True and extras["choices"][1] == {
"label": "Glasses", "hint": "round or square"}
chat = await _json(await wp_client.get(f"/wp/api/ai/chats/{first['chat_id']}"))
assert chat["waiting"] is True
# Only numbers come back; labels are the server's own.
done = await _resume(wp_client, first["chat_id"], [{"id": call["id"], "answers": [
{"q": 0, "picked": [0]}, {"q": 1, "decide": True},
{"q": 2, "picked": [0, 1, 9]}, {"q": 7, "picked": [0]}]}])
assert done["message"] == "Building it." and done["pending"] == []
back = _last_user(fake_llm)
assert ("ask: The user answered: Style → Anime; Add negatives to → you decide "
"(choose, and say what you chose); Extras → Hats, Glasses") in back
# A reopened chat shows the answers under the questions.
chat = await _json(await wp_client.get(f"/wp/api/ai/chats/{first['chat_id']}"))
assert chat["waiting"] is False
asked = chat["turns"][1]["view"]
assert asked["pending"][0]["tool"] == "ask"
assert asked["answered"] == {call["id"]: {"answers": [
{"header": "Style", "answer": "Anime"},
{"header": "Add negatives to", "answer": "you decide"},
{"header": "Extras", "answer": "Hats, Glasses"}]}}
# Settled: asking Style again is answered at once, without a pause.
fake_llm.chat_steps.extend([
_ask_step({"questions": [{"header": "style", "question": "Anime or realistic?",
"choices": ["Anime", "Realistic"]}]}),
{"message": "Anime it is.", "calls": []}])
again = await _chat(wp_client, "now the outfits", chat_id=first["chat_id"])
assert again["pending"] == [] and again["message"] == "Anime it is."
assert "ask: already answered: Style → Anime" in _last_user(fake_llm)
# Partly settled: the rest is asked, and the model hears what was settled.
fake_llm.chat_steps.extend([
_ask_step({"questions": [
{"header": "Style", "question": "Style?", "choices": ["A", "B"]},
{"header": "Palette", "question": "Which palette?", "choices": ["Warm", "Cool"]}]}),
{"message": "ok", "calls": []}])
part = await _chat(wp_client, "colours too", chat_id=first["chat_id"])
pcall = part["pending"][0]
assert [q["header"] for q in pcall["args"]["questions"]] == ["Palette"]
await _resume(wp_client, part["chat_id"], [{"id": pcall["id"], "answers": [
{"q": 0, "other": "pastel only"}]}])
assert ('Palette → "pastel only" (their own words). Already answered before: '
"Style → Anime") in _last_user(fake_llm)
async def test_bad_asks_are_refused_with_a_reason(wp_client, fake_llm):
_src, tgt = await _two_wildcards(wp_client)
_on(fake_llm)
one_choice = {"questions": [{"header": "X", "question": "Which?", "choices": ["Only"]}]}
generic = {"questions": [{"header": "More", "question": "Anything else you'd like?",
"choices": ["No", "Yes"]}]}
permission = {"questions": [{"header": "Go", "question": "Do you want me to add them?",
"choices": ["Yes", "No"]}]}
too_many = {"questions": [{"question": f"Q{i}?", "choices": ["a", "b"]} for i in range(5)]}
fake_llm.chat_steps.extend([_ask_step(a) for a in (too_many, one_choice, generic, permission)]
+ [{"message": "fine", "calls": []}])
out = await _chat(wp_client, "make a pack")
assert out["pending"] == []
notes = [s["note"] for s in out["steps"] if not s["ok"]]
assert "at most 4 questions" in notes[0]
assert "give 2-5" in notes[1]
assert "too general" in notes[2]
assert "asks permission" in notes[3]
# Never with change tools, and last in its step.
fake_llm.chat_steps.extend([
{"message": "", "calls": [
{"tool": "ask", "args": json.dumps(QUESTIONS)},
{"tool": "update_info", "args": json.dumps({"id": tgt, "tags_add": ["gear"]})}]},
{"message": "", "calls": [
{"tool": "ask", "args": json.dumps(QUESTIONS)},
{"tool": "read", "args": json.dumps({"id": tgt})}]},
{"message": "done", "calls": []},
])
out = await _chat(wp_client, "tag my Weapon", chat_id=out["chat_id"])
assert out["pending"] == []
notes = [s["note"] for s in out["steps"] if not s["ok"]]
assert "ask first, then propose" in notes[0]
assert "put ask last" in notes[1]
async def test_a_new_message_answers_the_ask_in_its_own_words(wp_client, fake_llm):
_on(fake_llm)
fake_llm.chat_steps.extend([_ask_step(), {"message": "Got it.", "calls": []}])
first = await _chat(wp_client, "make a pack")
call_id = first["pending"][0]["id"]
await _chat(wp_client, "anime, no negatives", chat_id=first["chat_id"])
last = _last_user(fake_llm)
assert last.index("ask: the user answered in their own words in the next message") < \
last.index("anime, no negatives")
assert "not run" not in last
chat = await _json(await wp_client.get(f"/wp/api/ai/chats/{first['chat_id']}"))
assert chat["turns"][1]["view"]["answered"] == {call_id: {"own": True}}
async def test_at_most_two_asks_per_message(wp_client, fake_llm):
_on(fake_llm)
def q(head):
return _ask_step({"questions": [{"header": head, "question": f"{head}?",
"choices": ["a", "b"]}]})
fake_llm.chat_steps.extend([q("One"), q("Two"), q("Three"), {"message": "ok", "calls": []}])
first = await _chat(wp_client, "pack please")
second = await _resume(wp_client, first["chat_id"], [
{"id": first["pending"][0]["id"], "answers": [{"q": 0, "picked": [1]}]}])
assert second["pending"][0]["args"]["questions"][0]["header"] == "Two"
third = await _resume(wp_client, first["chat_id"], [
{"id": second["pending"][0]["id"], "answers": []}])
assert third["pending"] == [] and third["message"] == "ok"
assert "decide yourself and say what you assumed" in _last_user(fake_llm)
# A new message may ask again.
fake_llm.chat_steps.extend([q("Four"), {"message": "ok", "calls": []}])
fourth = await _chat(wp_client, "next", chat_id=first["chat_id"])
assert fourth["pending"][0]["tool"] == "ask"
async def test_compaction_keeps_the_settled_answers(wp_client, fake_llm):
_on(fake_llm)
fake_llm.overrides["_ctx"] = 4096
fake_llm.overrides["summary"] = {"summary": "What the user wants:\n- a pack"}
fake_llm.chat_steps.extend([_ask_step(), {"message": "ok", "calls": []}])
first = await _chat(wp_client, "pack")
chat_id = first["chat_id"]
await _resume(wp_client, chat_id, [{"id": first["pending"][0]["id"], "answers": [
{"q": 0, "picked": [1]}]}])
fake_llm.chat_steps.extend({"message": f"Answer {i}. " + "words " * 200, "calls": []}
for i in range(4))
for word in ("two", "three", "four", "five"):
await _chat(wp_client, word, chat_id=chat_id)
meta = await _json(await wp_client.get(f"/wp/api/ai/chats/{chat_id}"))
assert "Style → Realistic" in meta["summary"]
assert "never ask them again" in meta["summary"]
async def test_editing_the_last_message_runs_the_new_text(wp_client, fake_llm):
_src, tgt = await _two_wildcards(wp_client)
_on(fake_llm)
fake_llm.chat_steps.extend([{"message": "One.", "calls": []},
{"message": "Red.", "calls": []},
{"message": "Blue.", "calls": []}])
first = await _chat(wp_client, "first")
await _chat(wp_client, "make it red", chat_id=first["chat_id"])
edited = await _chat(wp_client, "make it blue", chat_id=first["chat_id"], retry=True)
assert edited["message"] == "Blue."
users = [m["content"] for m in fake_llm.seen[-1]["body"]["messages"] if m["role"] == "user"]
assert any("make it blue" in u for u in users)
assert not any("make it red" in u for u in users)
chat = await _json(await wp_client.get(f"/wp/api/ai/chats/{first['chat_id']}"))
assert [t.get("text") or t["view"]["message"] for t in chat["turns"]] == [
"first", "One.", "make it blue", "Blue."]
# Refused like a retry once a card from that answer was applied.
fake_llm.chat_steps.append({"message": "Here.", "calls": [
{"tool": "update_info", "args": json.dumps({"id": tgt, "tags_add": ["gear"]})}]})
out = await _chat(wp_client, "tag Weapon", chat_id=first["chat_id"])
await _json(await wp_client.patch(f"/wp/api/ai/chats/{first['chat_id']}", json={
"events": [{"pid": out["proposals"][0]["pid"], "status": "applied"}]}))
refused = await _json(await wp_client.post("/wp/api/ai/chat", json={
"message": "tag Class", "chat_id": first["chat_id"], "retry": True}), 409)
assert "undo" in refused["error"]
async def test_the_item_an_answer_picks_may_be_changed(wp_client, fake_llm):
"""Haiku hair eval (2026-10-21): "make a hair colour wildcard" found the
user's own, asked whether to trim it, and the user picked "trim it";
the edit was then refused as an item they never named, because an
answer by button sends no text. The item the picked answer names
counts as named."""
_src, tgt = await _two_wildcards(wp_client)
_on(fake_llm, tool_format="json")
ask = {"questions": [{"header": "Weapons", "question": "You already have a weapon list.",
"choices": [f"Trim [[{tgt}#Weapon]]", "Make a new one"]}]}
edit = {"message": "Trimmed.", "calls": [
{"tool": "edit_options", "args": json.dumps({"id": tgt, "remove": [1]})}]}
fake_llm.chat_steps.extend([_ask_step(ask), edit])
first = await _chat(wp_client, "make a weapon wildcard", page={"route": "wildcards"})
[call] = first["pending"]
done = await _resume(wp_client, first["chat_id"], [{"id": call["id"], "answers": [
{"q": 0, "picked": [0]}]}])
[card] = done["proposals"]
assert card["op"]["id"] == tgt
assert [o["value"] for o in card["op"]["body"]["payload"]["options"]] == ["longsword"]
# Leaving it to the assistant names nothing: the edit is still refused.
fake_llm.chat_steps.extend([_ask_step(ask), edit, {"message": "Asked instead.", "calls": []}])
again = await _chat(wp_client, "make another weapon wildcard", page={"route": "wildcards"})
[call2] = again["pending"]
left = await _resume(wp_client, again["chat_id"], [{"id": call2["id"], "answers": [
{"q": 0, "decide": True}]}])
assert left["proposals"] == []