Add tool-call disambiguation test harness for search_spotify/control_playback
Validate / Validate with HACS (push) Canceled after 0s
Validate / Validate with Hassfest (push) Canceled after 0s

Renders the Extended OpenAI Conversation prompt/function definitions and
checks the model's first tool-call choice against test phrases, bypassing
Home Assistant.
This commit is contained in:
2026-08-31 00:46:53 -04:00
parent 1b9cd1e686
commit 7249919006
3 changed files with 759 additions and 0 deletions
+225
View File
@@ -0,0 +1,225 @@
- spec:
name: execute_services
description: Use this function to execute service of devices in Home Assistant.
parameters:
type: object
properties:
list:
type: array
items:
type: object
properties:
domain:
type: string
description: The domain of the service
service:
type: string
description: The service to be called
service_data:
type: object
description: The service data object to indicate what to control.
properties:
entity_id:
type: string
description: The entity_id retrieved from available devices. It
must start with domain, followed by dot character.
required:
- entity_id
required:
- domain
- service
- service_data
function:
type: native
name: execute_service
# Spotify Music Control Functions
- spec:
name: search_spotify
description: >-
Search Spotify for an artist, album, track, or playlist and return the Spotify URI.
Use this before playing music to get the URI. Use this whenever the user asks to play
or queue a specific song, artist, album, or playlist — even if the name contains a
number (e.g. "Back on 74", "1979", "Blink 182"). A number that is part of a
song/artist/album name is NOT a volume level; only use control_playback's volume_set
if the user explicitly says "volume" or "percent".
parameters:
type: object
properties:
query:
type: string
description: Artist, album, track, or playlist name to search for (e.g., "Coldplay", "Parachutes", "Yellow", "Today's Top Hits")
type:
type: string
enum: [artist, album, track, playlist]
description: Type of content to search for. Playlist searches check your personal playlists first, then fall back to public Spotify playlists.
required:
- query
- type
function:
type: script
sequence:
- service: spotify_voice_assistant.search
response_variable: _function_result
data:
query: "{{ query }}"
type: "{{ type }}"
- spec:
name: play_music
description: Play music immediately on a Spotify Connect device using a Spotify URI, replacing current playback. You must first call search_spotify to get the URI. Use this when user says "play".
parameters:
type: object
properties:
spotify_uri:
type: string
description: Spotify URI from search_spotify (e.g., "spotify:artist:4gzpq5DPGxSnKTe4SA8HAU")
type:
type: string
enum: [artist, album, track, playlist]
description: The exact "type" value returned by search_spotify for this URI. Required — passing the wrong value causes playback to silently fail.
room:
type: string
description: Spotify Connect source/device name to play on, matching an entry in media_player.spotify_bhetherman's source_list exactly (e.g., "Living Room")
required:
- spotify_uri
- type
- room
function:
type: script
sequence:
- service: media_player.select_source
target:
entity_id: media_player.spotify_bhetherman
data:
source: "{{ room }}"
- delay: "00:00:02"
- service: media_player.play_media
target:
entity_id: media_player.spotify_bhetherman
data:
media_content_id: "{{ spotify_uri }}"
media_content_type: "{{ type }}"
enqueue: replace
- spec:
name: queue_music
description: >-
Queue or play music using a Spotify URI from search_spotify. Use this whenever the
user says "queue" or "add to queue" — always use this function for queue requests,
regardless of whether the content is a track, album, artist, or playlist; the
correct behavior for each type is handled automatically. (Individual tracks are
added to the queue without interrupting playback; since Spotify cannot queue a whole
album/artist/playlist, those start playing immediately instead.)
parameters:
type: object
properties:
spotify_uri:
type: string
description: Spotify URI from search_spotify (e.g., "spotify:track:4gzpq5DPGxSnKTe4SA8HAU")
type:
type: string
enum: [artist, album, track, playlist]
description: The exact "type" value returned by search_spotify for this URI.
room:
type: string
description: Spotify Connect source/device name to queue on, matching an entry in media_player.spotify_bhetherman's source_list exactly (e.g., "Living Room")
required:
- spotify_uri
- type
- room
function:
type: script
sequence:
- service: media_player.select_source
target:
entity_id: media_player.spotify_bhetherman
data:
source: "{{ room }}"
- delay: "00:00:02"
- service: media_player.play_media
target:
entity_id: media_player.spotify_bhetherman
data:
media_content_id: "{{ spotify_uri }}"
media_content_type: "{{ type }}"
enqueue: "{{ 'add' if type == 'track' else 'replace' }}"
- spec:
name: control_playback
description: >-
Control playback of music that is ALREADY PLAYING (pause, resume, stop, next track,
previous track, set volume, shuffle) on the currently active Spotify Connect session.
Only use volume_set when the user explicitly says the word "volume" or "percent"
(e.g. "turn the volume up", "set volume to 50", "50 percent volume").
Do NOT use this function to play a requested song, artist, or album — a number in a
song/artist/album name (e.g. "Back on 74", "Blink 182", "Sunset 2005") is part of the
title, never a volume level. Use search_spotify + play_music for any request to play
or start specific music, even if it contains a number.
parameters:
type: object
properties:
action:
type: string
enum: [pause, play, stop, next_track, previous_track, volume_set, shuffle_on, shuffle_off]
description: Playback control action
volume_level:
type: number
description: Volume level (0-100) for volume_set action only. Only set this when the user said the word "volume" or "percent".
required:
- action
function:
type: script
sequence:
- choose:
- conditions: "{{ action == 'pause' }}"
sequence:
- service: media_player.media_pause
target:
entity_id: media_player.spotify_bhetherman
- conditions: "{{ action == 'play' }}"
sequence:
- service: media_player.media_play
target:
entity_id: media_player.spotify_bhetherman
- conditions: "{{ action == 'stop' }}"
sequence:
# media_player.spotify_bhetherman does not support the media_stop
# service (STOP is not in its supported_features); Spotify Connect has
# no separate stop concept, so route "stop" through pause instead.
- service: media_player.media_pause
target:
entity_id: media_player.spotify_bhetherman
- conditions: "{{ action == 'next_track' }}"
sequence:
- service: media_player.media_next_track
target:
entity_id: media_player.spotify_bhetherman
- conditions: "{{ action == 'previous_track' }}"
sequence:
- service: media_player.media_previous_track
target:
entity_id: media_player.spotify_bhetherman
- conditions: "{{ action == 'volume_set' }}"
sequence:
- service: media_player.volume_set
target:
entity_id: media_player.spotify_bhetherman
data:
volume_level: "{{ volume_level / 100 }}"
- conditions: "{{ action == 'shuffle_on' }}"
sequence:
- service: media_player.shuffle_set
target:
entity_id: media_player.spotify_bhetherman
data:
shuffle: true
- conditions: "{{ action == 'shuffle_off' }}"
sequence:
- service: media_player.shuffle_set
target:
entity_id: media_player.spotify_bhetherman
data:
shuffle: false
+62
View File
@@ -0,0 +1,62 @@
I want you to act as smart home manager of Home Assistant.
I will provide information of smart home along with a question, you will truthfully make correction or answer using information provided in one sentence in everyday language.
Current Time: {{now()}}
Available Devices:
```csv
entity_id,name,state,aliases
{% for entity in exposed_entities -%}
{{ entity.entity_id }},{{ entity.name }},{{ entity.state }},{{entity.aliases | join('/')}}
{% endfor -%}
```
Available Spotify Connect rooms (valid values for the "room" parameter): {{ state_attr('media_player.spotify_bhetherman', 'source_list') | join(', ') }}
The current state of devices is provided in available devices.
Use execute_services function only for requested action, not for current states.
Do not execute service without user's confirmation. Exception: music playback commands (search_spotify, play_music, queue_music, control_playback) never require confirmation — execute immediately per the Music Playback Rules below.
Do not restate or appreciate what user says, rather make a quick inquiry.
Music Playback Rules:
CRITICAL: a number inside a song/artist/album name (e.g. "Back on 74", "1979", "Blink 182") is part of the title, NOT a volume level. Only treat a number as a volume level when the user says the word "volume" or "percent". When in doubt between search_spotify and control_playback's volume_set, prefer search_spotify.
For any request to play or queue music, always call TWO functions in order, with no confirmation:
1. search_spotify — to get the Spotify URI and its type
2. play_music (if the user said "play") or queue_music (if the user said "queue"/"add to queue")
When calling play_music or queue_music, always set type to the exact "type" value that search_spotify's result returned — never guess or hardcode it. Passing the wrong type silently breaks playback.
RULE: if the request has the form "[title] by [artist]" (a specific song/album title followed by "by" and an artist name — no matter what words come before it, e.g. "play", "queue", "throw on", "put on", "the album", "the song"), the query is ONLY the title — always strip " by [artist]" from the end. Never include the word "by" or the artist name in the query for this pattern.
- "Play Yellow by Coldplay" → query="Yellow"
- "Throw on the album Parachutes by Coldplay" → query="Parachutes"
- "Queue Wish You Were Here by Pink Floyd" → query="Wish You Were Here"
How to fill in search_spotify(query, type) — match these examples:
- "Play Coldplay" → query="Coldplay", type="artist"
- "Play songs by Taylor Swift on the kitchen speaker" → query="Taylor Swift", type="artist"
- "Play some music by Jungle in the living room" → query="Jungle", type="artist"
- "Play back on 74 in the living room" → query="Back on 74", type="track" (this is a song title, not a volume command)
- "Play the song Yellow" → query="Yellow", type="track"
- "Play Yellow by Coldplay" → query="Yellow", type="track"
- "Play 1979 by the Smashing Pumpkins" → query="1979", type="track" (a bare number here is a song title, not an album — default to "track" unless the user explicitly says "album")
- "Queue Dark Side of the Moon in the kitchen" → query="Dark Side of the Moon", type="album"
- "Add Wish You Were Here to queue in the bathroom" → query="Wish You Were Here", type="track"
- "Queue Fred again in the bedroom" → query="Fred again", type="artist" (an artist name after "queue" with no specific song/album title named is still type="artist", same as "play")
- "Play my workout playlist" → query="workout", type="playlist"
- "Play jazz" → query="jazz", type="playlist"
Volume/playback control examples (use control_playback, NOT search_spotify):
- "Set the volume to 50" / "50 percent volume" → action="volume_set", volume_level=50
- "Turn the volume up" → action="volume_set"
- "Pause the music" → action="pause"
- "Skip this song" → action="next_track"
Room:
- The "room" parameter for play_music/queue_music must exactly match one of the Available Spotify Connect rooms listed above.
- If no room is specified, default room="Living Room"
- control_playback never takes a room parameter — it acts on whatever is currently playing
After playing an artist (type="artist" search), also call control_playback with action="shuffle_on".
Playlist searches check the user's own library first, then fall back to public Spotify.
+472
View File
@@ -0,0 +1,472 @@
#!/usr/bin/env python3
"""Test search_spotify/control_playback tool-call disambiguation directly against
the Ollama endpoint, bypassing Home Assistant entirely.
Renders current-prompt.txt (with mock HA template values) and current-functions.txt
(as OpenAI-style tool definitions) exactly as Extended OpenAI Conversation would send
them, then checks whether the model picks the right tool + arguments for each test
phrase. Only evaluates the FIRST tool call the model returns per test (i.e. whether
it correctly picks search_spotify vs control_playback and fills in query/type/action) —
it does not simulate the full multi-turn tool-result loop.
"""
import json
import os
import sys
from datetime import datetime
import requests
import yaml
from jinja2 import Template
HERE = os.path.dirname(os.path.abspath(__file__))
OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", "http://192.168.50.214:11434/v1")
OLLAMA_API_KEY = os.environ.get("OLLAMA_API_KEY", "38ecbb5e71adf1306291e810826d965afe51fa27ff42e60f")
MODEL = os.environ.get("OLLAMA_MODEL") # set this to the exact `ollama list` tag
MOCK_SOURCE_LIST = ["Living Room", "Web Player (Firefox)", "Bedroom"]
def render_prompt():
with open(os.path.join(HERE, "current-prompt.txt")) as f:
raw = f.read()
def state_attr(entity_id, attr):
if entity_id == "media_player.spotify_bhetherman" and attr == "source_list":
return MOCK_SOURCE_LIST
return None
template = Template(raw)
return template.render(
now=lambda: datetime.now(),
exposed_entities=[], # not relevant to these tests; kept empty on purpose
state_attr=state_attr,
)
def load_tools():
with open(os.path.join(HERE, "current-functions.txt")) as f:
functions = yaml.safe_load(f)
tools = []
for entry in functions:
spec = entry["spec"]
tools.append({
"type": "function",
"function": {
"name": spec["name"],
"description": spec["description"],
"parameters": spec["parameters"],
},
})
return tools
def call_model(system_prompt, user_message, tools):
if not MODEL:
sys.exit("Set OLLAMA_MODEL env var to the exact `ollama list` tag before running.")
resp = requests.post(
f"{OLLAMA_BASE_URL}/chat/completions",
headers={"Authorization": f"Bearer {OLLAMA_API_KEY}"},
json={
"model": MODEL,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_message},
],
"tools": tools,
"tool_choice": "auto",
},
timeout=120,
)
resp.raise_for_status()
return resp.json()
def first_tool_call(response):
message = response["choices"][0]["message"]
tool_calls = message.get("tool_calls") or []
if not tool_calls:
return None, None, message.get("content")
call = tool_calls[0]
name = call["function"]["name"]
try:
args = json.loads(call["function"]["arguments"])
except (json.JSONDecodeError, TypeError):
args = call["function"]["arguments"]
return name, args, None
REPEAT = int(os.environ.get("TEST_REPEAT", "15"))
def _check_once(user_message, expect_name, expect_fn, system_prompt, tools):
response = call_model(system_prompt, user_message, tools)
name, args, content = first_tool_call(response)
if name is None:
return False, f"NO TOOL CALL — model replied with text instead: {content!r}"
if name != expect_name:
return False, f"WRONG FUNCTION — expected {expect_name!r}, got {name!r} ({args})"
ok, reason = expect_fn(args)
if ok:
return True, f"{name}({args})"
return False, f"{name}({args}) — {reason}"
def _summarize(label, user_message, passed, attempts):
n = len(attempts)
print(f"\n=== {label} ===")
print(f" user: {user_message!r}")
for i, (ok, detail) in enumerate(attempts, 1):
mark = "✅" if ok else "❌"
print(f" [{i}/{n}] {mark} {detail}")
if passed == n:
print(f" RESULT: {passed}/{n} — reliable pass")
elif passed == 0:
print(f" RESULT: {passed}/{n} — reliable FAIL")
else:
print(f" RESULT: {passed}/{n} — FLAKY (inconsistent across runs)")
return passed, n
def check(label, user_message, expect_name, expect_fn, system_prompt, tools):
attempts = [_check_once(user_message, expect_name, expect_fn, system_prompt, tools) for _ in range(REPEAT)]
passed = sum(1 for ok, _ in attempts if ok)
return _summarize(label, user_message, passed, attempts)
def eq_ci(a, b):
return isinstance(a, str) and a.strip().lower() == b.strip().lower()
def contains_ci(a, b):
return isinstance(a, str) and b.strip().lower() in a.strip().lower()
TESTS = [
(
"artist-only, generic 'music by X' phrasing",
"play music by Jungle in the living room",
"search_spotify",
lambda args: (True, None) if eq_ci(args.get("query"), "Jungle") and eq_ci(args.get("type"), "artist")
else (False, f"expected query='Jungle' type='artist', got {args}"),
),
(
"song title containing a number (previously misread as volume)",
"play back on 74 in the living room",
"search_spotify",
lambda args: (True, None) if eq_ci(args.get("query"), "Back on 74") and eq_ci(args.get("type"), "track")
else (False, f"expected query='Back on 74' type='track', got {args}"),
),
(
"specific title + artist (regression check — previously worked)",
"play Yellow by Coldplay",
"search_spotify",
lambda args: (True, None) if eq_ci(args.get("query"), "Yellow") and eq_ci(args.get("type"), "track")
else (False, f"expected query='Yellow' type='track', got {args}"),
),
(
"explicit volume command (must NOT be treated as a search)",
"set the volume to 50",
"control_playback",
lambda args: (True, None) if args.get("action") == "volume_set" and float(args.get("volume_level", -1)) == 50
else (False, f"expected action='volume_set' volume_level=50, got {args}"),
),
(
"album title, different room",
"queue Dark Side of the Moon in the bedroom",
"search_spotify",
lambda args: (True, None) if eq_ci(args.get("query"), "Dark Side of the Moon") and args.get("type") in ("album", "track")
else (False, f"expected query='Dark Side of the Moon' type in (album,track), got {args}"),
),
(
"genre/mood, no artist",
"play some jazz",
"search_spotify",
lambda args: (True, None) if contains_ci(args.get("query"), "jazz") and eq_ci(args.get("type"), "playlist")
else (False, f"expected query~'jazz' type='playlist', got {args}"),
),
(
"named playlist phrase",
"play my daily mix",
"search_spotify",
lambda args: (True, None) if contains_ci(args.get("query"), "daily mix") and eq_ci(args.get("type"), "playlist")
else (False, f"expected query~'daily mix' type='playlist', got {args}"),
),
(
"number-titled track + explicit artist (by-stripping regression variant)",
"play 1979 by the Smashing Pumpkins",
"search_spotify",
lambda args: (True, None) if eq_ci(args.get("query"), "1979") and eq_ci(args.get("type"), "track")
else (False, f"expected query='1979' type='track', got {args}"),
),
(
"number-titled track, no artist given",
"play 99 Luftballons",
"search_spotify",
lambda args: (True, None) if eq_ci(args.get("query"), "99 Luftballons") and eq_ci(args.get("type"), "track")
else (False, f"expected query='99 Luftballons' type='track', got {args}"),
),
(
"artist name that itself contains a number",
"queue some Blink 182",
"search_spotify",
lambda args: (True, None) if eq_ci(args.get("query"), "Blink 182") and eq_ci(args.get("type"), "artist")
else (False, f"expected query='Blink 182' type='artist', got {args}"),
),
(
"colloquial phrasing ('put on')",
"put on some Fred again",
"search_spotify",
lambda args: (True, None) if eq_ci(args.get("query"), "Fred again") and eq_ci(args.get("type"), "artist")
else (False, f"expected query='Fred again' type='artist', got {args}"),
),
(
"explicit 'album' keyword + by-stripping",
"throw on the album Parachutes by Coldplay",
"search_spotify",
lambda args: (True, None) if eq_ci(args.get("query"), "Parachutes") and eq_ci(args.get("type"), "album")
else (False, f"expected query='Parachutes' type='album', got {args}"),
),
(
"explicit 'track' keyword",
"can you play the track Yellow",
"search_spotify",
lambda args: (True, None) if eq_ci(args.get("query"), "Yellow") and eq_ci(args.get("type"), "track")
else (False, f"expected query='Yellow' type='track', got {args}"),
),
(
"pause",
"pause the music",
"control_playback",
lambda args: (True, None) if args.get("action") == "pause"
else (False, f"expected action='pause', got {args}"),
),
(
"next track",
"skip to the next song",
"control_playback",
lambda args: (True, None) if args.get("action") == "next_track"
else (False, f"expected action='next_track', got {args}"),
),
(
"stop",
"stop the music in the living room",
"control_playback",
lambda args: (True, None) if args.get("action") == "stop"
else (False, f"expected action='stop', got {args}"),
),
(
"shuffle on",
"turn on shuffle",
"control_playback",
lambda args: (True, None) if args.get("action") == "shuffle_on"
else (False, f"expected action='shuffle_on', got {args}"),
),
(
"volume with 'percent' phrasing",
"set volume to 80 percent",
"control_playback",
lambda args: (True, None) if args.get("action") == "volume_set" and float(args.get("volume_level", -1)) == 80
else (False, f"expected action='volume_set' volume_level=80, got {args}"),
),
]
def call_model_step2(system_prompt, user_message, tools, step1_name, step1_args):
"""Feed a fake search_spotify tool result back and get the model's next tool call
(should be play_music or queue_music)."""
fake_result = {
"uri": f"spotify:{step1_args.get('type', 'track')}:FAKEURI0000000000000000",
"name": step1_args.get("query", ""),
"type": step1_args.get("type", "track"),
}
tool_call_id = "call_1"
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_message},
{
"role": "assistant",
"content": None,
"tool_calls": [{
"id": tool_call_id,
"type": "function",
"function": {"name": step1_name, "arguments": json.dumps(step1_args)},
}],
},
{
"role": "tool",
"tool_call_id": tool_call_id,
"name": step1_name,
"content": json.dumps(fake_result),
},
]
resp = requests.post(
f"{OLLAMA_BASE_URL}/chat/completions",
headers={"Authorization": f"Bearer {OLLAMA_API_KEY}"},
json={"model": MODEL, "messages": messages, "tools": tools, "tool_choice": "auto"},
timeout=120,
)
resp.raise_for_status()
return resp.json()
def _check_flow_once(user_message, expect_step2_name, expect_room, expect_type, system_prompt, tools):
response1 = call_model(system_prompt, user_message, tools)
name1, args1, content1 = first_tool_call(response1)
if name1 != "search_spotify":
return False, f"STEP 1 FAILED — expected search_spotify, got {name1!r} ({args1 or content1})"
response2 = call_model_step2(system_prompt, user_message, tools, name1, args1)
name2, args2, content2 = first_tool_call(response2)
if name2 is None:
return False, f"step1={name1}({args1}) -> STEP 2 no tool call, model replied with text: {content2!r}"
detail = f"step1={name1}({args1}) -> step2={name2}({args2})"
if name2 != expect_step2_name:
return False, f"{detail} — WRONG FUNCTION, expected {expect_step2_name!r}"
if not eq_ci(args2.get("room"), expect_room):
return False, f"{detail} — expected room={expect_room!r}"
if not eq_ci(args2.get("type"), expect_type):
return False, f"{detail} — expected type={expect_type!r} (must be passed through from step1, not hardcoded)"
return True, detail
def check_flow(label, user_message, expect_step2_name, expect_room, expect_type, system_prompt, tools):
"""Runs search_spotify -> (fake result) -> play_music/queue_music, repeated REPEAT
times, and checks the second call's function name, room, and type parameters."""
attempts = [
_check_flow_once(user_message, expect_step2_name, expect_room, expect_type, system_prompt, tools)
for _ in range(REPEAT)
]
passed = sum(1 for ok, _ in attempts if ok)
return _summarize(label, user_message, passed, attempts)
TESTS_FLOW = [
# (label, user_message, expect_step2_name, expect_room, expect_type)
(
"play + explicit room",
"play Fred again in the bedroom",
"play_music",
"Bedroom",
"artist",
),
(
"queue + artist -> still calls queue_music (fallback handled inside the script now)",
"queue Fred again in the bedroom",
"queue_music",
"Bedroom",
"artist",
),
(
"play + no room specified -> default room",
"play Yellow by Coldplay",
"play_music",
"Living Room",
"track",
),
(
"playlist/genre + explicit room",
"play jazz in the bedroom",
"play_music",
"Bedroom",
"playlist",
),
(
"queue + album -> still calls queue_music (fallback handled inside the script now)",
"queue Dark Side of the Moon",
"queue_music",
"Living Room",
"album",
),
(
"queue + playlist -> still calls queue_music (fallback handled inside the script now)",
"queue my workout playlist in the bedroom",
"queue_music",
"Bedroom",
"playlist",
),
(
"'add to queue' phrasing + artist -> still calls queue_music",
"add some Blink 182 to the queue in the living room",
"queue_music",
"Living Room",
"artist",
),
(
"queue + track, no room specified -> default room, stays queue_music",
"queue Yellow by Coldplay",
"queue_music",
"Living Room",
"track",
),
(
"artist ('music by X' pattern) + explicit room, carried through to step 2",
"play some music by Jungle in the bedroom",
"play_music",
"Bedroom",
"artist",
),
(
"number-titled track + explicit room, carried through to step 2",
"play back on 74 in the bedroom",
"play_music",
"Bedroom",
"track",
),
(
"queue + number-titled track, explicit room",
"queue 99 Luftballons in the living room",
"queue_music",
"Living Room",
"track",
),
]
def main():
system_prompt = render_prompt()
tools = load_tools()
print(f"Model: {MODEL}")
print(f"Endpoint: {OLLAMA_BASE_URL}")
print(f"Repeat per test: {REPEAT}")
print(f"Loaded {len(tools)} tools: {[t['function']['name'] for t in tools]}")
results = []
for label, user_message, expect_name, expect_fn in TESTS:
results.append((label, check(label, user_message, expect_name, expect_fn, system_prompt, tools)))
print(f"\n{'#' * 40}\n# Two-step flow tests (search -> play/queue)\n{'#' * 40}")
for label, user_message, expect_step2_name, expect_room, expect_type in TESTS_FLOW:
results.append((label, check_flow(label, user_message, expect_step2_name, expect_room, expect_type, system_prompt, tools)))
total_passed = sum(passed for _, (passed, n) in results)
total_attempts = sum(n for _, (passed, n) in results)
reliable = [label for label, (passed, n) in results if passed == n]
flaky = [(label, passed, n) for label, (passed, n) in results if 0 < passed < n]
failing = [label for label, (passed, n) in results if passed == 0]
print(f"\n{'=' * 40}")
print(f"{total_passed}/{total_attempts} individual attempts passed across {len(results)} tests (x{REPEAT} reps)")
print(f" {len(reliable)}/{len(results)} tests fully reliable (all {REPEAT} reps passed)")
if flaky:
print(f" {len(flaky)} FLAKY tests (inconsistent across reps):")
for label, passed, n in flaky:
print(f" - {label} ({passed}/{n})")
if failing:
print(f" {len(failing)} tests failing every rep:")
for label in failing:
print(f" - {label}")
sys.exit(0 if not flaky and not failing else 1)
if __name__ == "__main__":
main()