mirror of
https://github.com/Jeuners/astra-vision.git
synced 2026-09-09 15:02:35 +02:00
Pipecat stores a tool call's assistant message OpenAI-style, with
function.arguments as a JSON string. Ollama's native /api/chat wants an
object there and rejects the follow-up request with 400 ("Value looks
like object, but can't find closing '}' symbol") — reproduced verbatim
against real Ollama, then fixed with astra/core.py::normalize_tool_calls,
applied in build_request. Verified end-to-end with a real browser test
(synthesized speech as the fake mic): "erzeuge ein Bild von einer Katze
im Weltraum" now completes the full round trip — tool call, ComfyUI
image, and Astra's follow-up reply — with no error.
Also: an assistant turn interrupted before producing any text showed
nothing at all in the transcript. astra/server.py's assistant_turn
handler now notifies on interruption even with empty content, so an
early interruption (e.g. mic picking up noise during the slightly
longer vision-augmented responses) is visible instead of silent.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVgSHNHdRx3UNTBodFmhRA
128 lines
5.1 KiB
Python
128 lines
5.1 KiB
Python
import unittest
|
|
|
|
from astra.core import (
|
|
Settings,
|
|
build_request,
|
|
local_origin_allowed,
|
|
normalize_tool_calls,
|
|
trim_messages,
|
|
)
|
|
|
|
|
|
class CoreTests(unittest.TestCase):
|
|
def test_thinking_is_disabled_in_every_native_request(self):
|
|
request = build_request(Settings(), [{"role": "user", "content": "Hallo"}])
|
|
self.assertIs(request["think"], False)
|
|
self.assertIs(request["stream"], True)
|
|
self.assertEqual(request["model"], "qwen3.5:latest")
|
|
self.assertNotIn("think", request["options"])
|
|
self.assertEqual(request["options"]["num_ctx"], 4096)
|
|
|
|
def test_history_stays_bounded_and_preserves_system_and_latest_user(self):
|
|
messages = [{"role": "system", "content": "Deutsch"}]
|
|
for i in range(40):
|
|
messages.extend(
|
|
[
|
|
{"role": "user", "content": f"Frage {i}"},
|
|
{"role": "assistant", "content": "Antwort " * 100},
|
|
]
|
|
)
|
|
messages.append({"role": "user", "content": "Neueste Frage"})
|
|
trimmed = trim_messages(messages, max_chars=5000)
|
|
self.assertEqual(trimmed[0], messages[0])
|
|
self.assertEqual(trimmed[1]["role"], "user")
|
|
self.assertEqual(trimmed[-1], messages[-1])
|
|
self.assertLessEqual(sum(len(m["content"]) for m in trimmed), 5000)
|
|
self.assertEqual(len(messages), 82)
|
|
|
|
def test_rejects_foreign_web_origins(self):
|
|
self.assertTrue(local_origin_allowed("http://localhost:7860"))
|
|
self.assertTrue(local_origin_allowed("http://127.0.0.1:7860"))
|
|
self.assertFalse(local_origin_allowed("https://evil.example"))
|
|
self.assertFalse(local_origin_allowed("http://localhost.evil.example:7860"))
|
|
self.assertFalse(local_origin_allowed("null"))
|
|
|
|
def test_huge_last_message_is_bounded(self):
|
|
trimmed = trim_messages(
|
|
[
|
|
{"role": "system", "content": "Deutsch"},
|
|
{"role": "user", "content": "x" * 20000},
|
|
],
|
|
max_chars=5000,
|
|
)
|
|
self.assertLessEqual(sum(len(m["content"]) for m in trimmed), 5000)
|
|
|
|
def test_tool_round_trip_survives_trimming(self):
|
|
messages = [
|
|
{"role": "system", "content": "Deutsch"},
|
|
{"role": "user", "content": "Zeig mir ein Bild von einer Katze."},
|
|
{
|
|
"role": "assistant",
|
|
"content": "",
|
|
"tool_calls": [{"function": {"name": "generate_image", "arguments": {}}}],
|
|
},
|
|
{"role": "tool", "content": '{"status": "ok"}', "tool_call_id": "call_1"},
|
|
{"role": "assistant", "content": "Fertig, schau mal!"},
|
|
]
|
|
trimmed = trim_messages(messages, max_chars=5000)
|
|
roles = [m["role"] for m in trimmed]
|
|
self.assertEqual(roles, ["system", "user", "assistant", "tool", "assistant"])
|
|
self.assertEqual(trimmed[2]["tool_calls"][0]["function"]["name"], "generate_image")
|
|
self.assertEqual(trimmed[3]["tool_call_id"], "call_1")
|
|
|
|
def test_tool_call_string_arguments_are_normalized_to_an_object(self):
|
|
messages = [
|
|
{
|
|
"role": "assistant",
|
|
"content": "",
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_1",
|
|
"type": "function",
|
|
"function": {"name": "generate_image", "arguments": '{"prompt": "a cat"}'},
|
|
}
|
|
],
|
|
}
|
|
]
|
|
normalized = normalize_tool_calls(messages)
|
|
self.assertEqual(
|
|
normalized[0]["tool_calls"][0]["function"]["arguments"], {"prompt": "a cat"}
|
|
)
|
|
# The original message is untouched (immutable transform).
|
|
self.assertIsInstance(messages[0]["tool_calls"][0]["function"]["arguments"], str)
|
|
|
|
def test_build_request_normalizes_tool_call_arguments_end_to_end(self):
|
|
messages = [
|
|
{"role": "user", "content": "Erzeuge ein Bild."},
|
|
{
|
|
"role": "assistant",
|
|
"content": "",
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_1",
|
|
"type": "function",
|
|
"function": {"name": "generate_image", "arguments": '{"prompt": "a cat"}'},
|
|
}
|
|
],
|
|
},
|
|
{"role": "tool", "tool_call_id": "call_1", "content": '{"status": "ok"}'},
|
|
]
|
|
request = build_request(Settings(), messages)
|
|
tool_call_message = next(m for m in request["messages"] if m.get("tool_calls"))
|
|
self.assertIsInstance(tool_call_message["tool_calls"][0]["function"]["arguments"], dict)
|
|
|
|
def test_image_attachment_survives_trimming(self):
|
|
messages = [
|
|
{"role": "system", "content": "Deutsch"},
|
|
{
|
|
"role": "user",
|
|
"content": '[Hochgeladenes Bild "katze.png"]',
|
|
"images": ["base64data"],
|
|
},
|
|
]
|
|
trimmed = trim_messages(messages, max_chars=5000)
|
|
self.assertEqual(trimmed[1]["images"], ["base64data"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|