Some checks are pending
CI / ruff (push) Waiting to run
CI / mypy (push) Waiting to run
CI / pytest (push) Waiting to run
CI / package (push) Waiting to run
CI / Install smoke (${{ matrix.label }}) (linux, ubuntu-latest) (push) Blocked by required conditions
CI / Install smoke (${{ matrix.label }}) (macos-arm64, macos-14) (push) Blocked by required conditions
1281 lines
45 KiB
Python
1281 lines
45 KiB
Python
"""Extensive tests for speech_to_speech.LLM.chat.
|
|
|
|
Covers Chat class (init, add_item validation/eviction,
|
|
serialization to both Response API and transformers formats,
|
|
copy/reset, strip_images, internal helpers) and the three factory
|
|
functions (make_user_message, make_assistant_message, make_system_message).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
|
|
import pytest
|
|
from openai.types.realtime.conversation_item import (
|
|
RealtimeConversationItemAssistantMessage,
|
|
RealtimeConversationItemFunctionCall,
|
|
RealtimeConversationItemFunctionCallOutput,
|
|
RealtimeConversationItemSystemMessage,
|
|
RealtimeConversationItemUserMessage,
|
|
)
|
|
from openai.types.realtime.realtime_conversation_item_assistant_message import (
|
|
Content as AssistantContent,
|
|
)
|
|
from openai.types.realtime.realtime_conversation_item_system_message import (
|
|
Content as SystemContent,
|
|
)
|
|
from openai.types.realtime.realtime_conversation_item_user_message import (
|
|
Content as UserContent,
|
|
)
|
|
from openai.types.realtime.realtime_response_create_params import RealtimeResponseCreateParams
|
|
|
|
from speech_to_speech.LLM.chat import (
|
|
Chat,
|
|
ChatItemError,
|
|
CompactionResult,
|
|
build_active_chat,
|
|
make_assistant_message,
|
|
make_system_message,
|
|
make_user_message,
|
|
)
|
|
|
|
# ===================================================================
|
|
# Helpers
|
|
# ===================================================================
|
|
|
|
|
|
def _user(text: str) -> RealtimeConversationItemUserMessage:
|
|
return make_user_message(text)
|
|
|
|
|
|
def _assistant(text: str) -> RealtimeConversationItemAssistantMessage:
|
|
return make_assistant_message(text)
|
|
|
|
|
|
def _system(text: str) -> RealtimeConversationItemSystemMessage:
|
|
return make_system_message(text)
|
|
|
|
|
|
def _fc(call_id: str = "call_1", name: str = "my_func", arguments: str = "{}") -> RealtimeConversationItemFunctionCall:
|
|
if not call_id.startswith("call_"):
|
|
call_id = f"call_{call_id}"
|
|
return RealtimeConversationItemFunctionCall(
|
|
type="function_call",
|
|
id=f"fc_{call_id}",
|
|
call_id=call_id,
|
|
name=name,
|
|
arguments=arguments,
|
|
)
|
|
|
|
|
|
def _fco(
|
|
call_id: str = "call_1", output: str = '{"ok": true}', status=None
|
|
) -> RealtimeConversationItemFunctionCallOutput:
|
|
if not call_id.startswith("call_"):
|
|
call_id = f"call_{call_id}"
|
|
return RealtimeConversationItemFunctionCallOutput(
|
|
type="function_call_output",
|
|
call_id=call_id,
|
|
output=output,
|
|
status=status,
|
|
)
|
|
|
|
|
|
def _user_msg_with_parts(*parts) -> RealtimeConversationItemUserMessage:
|
|
"""Build a user message with arbitrary content parts.
|
|
|
|
Each *part* is a tuple like ``("text", "hello")`` or ``("image", "url")``.
|
|
"""
|
|
content = []
|
|
for kind, value in parts:
|
|
if kind == "text":
|
|
content.append(UserContent(type="input_text", text=value))
|
|
elif kind == "image":
|
|
content.append(UserContent(type="input_image", image_url=value))
|
|
elif kind == "audio":
|
|
content.append(UserContent(type="input_audio", transcript=value))
|
|
return RealtimeConversationItemUserMessage(type="message", role="user", content=content)
|
|
|
|
|
|
def _assistant_msg_with_parts(*parts) -> RealtimeConversationItemAssistantMessage:
|
|
content = []
|
|
for kind, value in parts:
|
|
if kind == "text":
|
|
content.append(AssistantContent(type="output_text", text=value))
|
|
elif kind == "audio":
|
|
content.append(AssistantContent(type="output_audio", transcript=value))
|
|
return RealtimeConversationItemAssistantMessage(type="message", role="assistant", content=content)
|
|
|
|
|
|
# ===================================================================
|
|
# 1. TestChatInit
|
|
# ===================================================================
|
|
|
|
|
|
class TestChatInit:
|
|
def test_default_state(self):
|
|
chat = Chat(size=5)
|
|
assert chat.size == 5
|
|
assert chat.buffer == []
|
|
assert chat.init_chat_message is None
|
|
assert chat._pending_tool_calls == {}
|
|
assert chat._user_turn_count == 0
|
|
|
|
def test_size_stored(self):
|
|
for s in (0, 1, 100):
|
|
assert Chat(size=s).size == s
|
|
|
|
|
|
# ===================================================================
|
|
# 2. TestFactoryHelpers
|
|
# ===================================================================
|
|
|
|
|
|
class TestFactoryHelpers:
|
|
def test_make_user_message(self):
|
|
msg = make_user_message("hello")
|
|
assert isinstance(msg, RealtimeConversationItemUserMessage)
|
|
assert msg.role == "user"
|
|
assert msg.type == "message"
|
|
assert len(msg.content) == 1
|
|
assert msg.content[0].type == "input_text"
|
|
assert msg.content[0].text == "hello"
|
|
|
|
def test_make_assistant_message(self):
|
|
msg = make_assistant_message("world")
|
|
assert isinstance(msg, RealtimeConversationItemAssistantMessage)
|
|
assert msg.role == "assistant"
|
|
assert msg.type == "message"
|
|
assert len(msg.content) == 1
|
|
assert msg.content[0].type == "output_text"
|
|
assert msg.content[0].text == "world"
|
|
|
|
def test_make_system_message(self):
|
|
msg = make_system_message("You are helpful.")
|
|
assert isinstance(msg, RealtimeConversationItemSystemMessage)
|
|
assert msg.role == "system"
|
|
assert msg.type == "message"
|
|
assert len(msg.content) == 1
|
|
assert msg.content[0].type == "input_text"
|
|
assert msg.content[0].text == "You are helpful."
|
|
|
|
|
|
# ===================================================================
|
|
# 3. TestInitChat
|
|
# ===================================================================
|
|
|
|
|
|
class TestInitChat:
|
|
def test_sets_init_chat_message(self):
|
|
chat = Chat(size=5)
|
|
sys_msg = _system("Be concise.")
|
|
chat.init_chat(sys_msg)
|
|
assert chat.init_chat_message is sys_msg
|
|
|
|
def test_overwrite_replaces_previous(self):
|
|
chat = Chat(size=5)
|
|
chat.init_chat(_system("first"))
|
|
chat.init_chat(_system("second"))
|
|
assert chat.init_chat_message.content[0].text == "second"
|
|
|
|
def test_system_message_not_in_buffer(self):
|
|
chat = Chat(size=5)
|
|
chat.init_chat(_system("system"))
|
|
assert chat.buffer == []
|
|
|
|
|
|
# ===================================================================
|
|
# 4. TestAddItemEviction
|
|
# ===================================================================
|
|
|
|
|
|
class TestAddItemEviction:
|
|
def test_add_user_increments_turn_count(self):
|
|
chat = Chat(size=5)
|
|
assert chat._user_turn_count == 0
|
|
chat.add_item(_user("hi"))
|
|
assert chat._user_turn_count == 1
|
|
chat.add_item(_user("there"))
|
|
assert chat._user_turn_count == 2
|
|
|
|
def test_add_function_call_registers_pending(self):
|
|
chat = Chat(size=5)
|
|
fc = _fc("cid_1")
|
|
chat.add_item(fc)
|
|
assert "call_cid_1" in chat._pending_tool_calls
|
|
assert chat._pending_tool_calls["call_cid_1"] is fc
|
|
|
|
def test_add_function_call_none_call_id_auto_generates(self):
|
|
chat = Chat(size=5)
|
|
fc = RealtimeConversationItemFunctionCall(
|
|
type="function_call",
|
|
call_id=None,
|
|
name="f",
|
|
arguments="{}",
|
|
)
|
|
chat.add_item(fc)
|
|
assert fc.call_id is not None
|
|
assert fc.call_id.startswith("call_")
|
|
|
|
def test_eviction_when_exceeding_size(self):
|
|
chat = Chat(size=1)
|
|
chat.add_item(_user("t1"))
|
|
chat.add_item(_assistant("r1"))
|
|
assert chat._user_turn_count == 1
|
|
|
|
chat.add_item(_user("t2"))
|
|
chat.trim_if_needed()
|
|
assert chat._user_turn_count == 1
|
|
assert chat.buffer[0].content[0].text == "t2"
|
|
|
|
def test_eviction_removes_up_to_next_user_boundary(self):
|
|
chat = Chat(size=1)
|
|
chat.add_item(_user("t1"))
|
|
chat.add_item(_assistant("a1"))
|
|
chat.add_item(_fc("c1"))
|
|
chat.add_item(_fco("c1"))
|
|
chat.add_item(_assistant("a2"))
|
|
assert len(chat.buffer) == 5
|
|
|
|
chat.add_item(_user("t2"))
|
|
chat.trim_if_needed()
|
|
assert chat._user_turn_count == 1
|
|
remaining_types = [e.type for e in chat.buffer]
|
|
assert "message" in remaining_types
|
|
assert chat.buffer[0].content[0].text == "t2"
|
|
|
|
def test_size_zero_evicts_every_user_message(self):
|
|
chat = Chat(size=0)
|
|
chat.add_item(_user("a"))
|
|
chat.trim_if_needed()
|
|
assert chat._user_turn_count == 0
|
|
assert len(chat.buffer) == 0
|
|
|
|
def test_non_user_items_do_not_trigger_eviction(self):
|
|
chat = Chat(size=1)
|
|
chat.add_item(_assistant("a"))
|
|
chat.add_item(_fc("c1"))
|
|
chat.add_item(_assistant("b"))
|
|
assert len(chat.buffer) == 2 # fc is staged in _pending_tool_calls, not buffer
|
|
|
|
def test_multiple_evictions(self):
|
|
chat = Chat(size=2)
|
|
for i in range(5):
|
|
chat.add_item(_user(f"u{i}"))
|
|
chat.add_item(_assistant(f"a{i}"))
|
|
chat.trim_if_needed()
|
|
assert chat._user_turn_count == 2
|
|
user_texts = [e.content[0].text for e in chat.buffer if isinstance(e, RealtimeConversationItemUserMessage)]
|
|
assert user_texts == ["u3", "u4"]
|
|
|
|
|
|
# ===================================================================
|
|
# 5. TestAppendToolOutput
|
|
# ===================================================================
|
|
|
|
|
|
class TestAppendToolOutput:
|
|
def test_happy_path(self):
|
|
chat = Chat(size=5)
|
|
chat.add_item(_fc("c1"))
|
|
fco = _fco("c1")
|
|
chat.append_tool_output("call_c1", fco)
|
|
|
|
assert "call_c1" not in chat._pending_tool_calls
|
|
assert chat.buffer[-1] is fco
|
|
|
|
def test_marks_function_call_completed_on_none_status(self):
|
|
chat = Chat(size=5)
|
|
fc = _fc("c1")
|
|
chat.add_item(fc)
|
|
fco = _fco("c1", status=None)
|
|
chat.append_tool_output("call_c1", fco)
|
|
|
|
assert fc.status == "completed"
|
|
|
|
def test_status_propagation_from_output(self):
|
|
chat = Chat(size=5)
|
|
fc = _fc("c1")
|
|
chat.add_item(fc)
|
|
fco = _fco("c1", status="incomplete")
|
|
chat.append_tool_output("call_c1", fco)
|
|
|
|
assert fc.status == "incomplete"
|
|
|
|
def test_reinjection_path(self):
|
|
chat = Chat(size=1)
|
|
chat.add_item(_user("u1"))
|
|
chat.add_item(_fc("cx"))
|
|
chat.add_item(_user("u2"))
|
|
chat.trim_if_needed()
|
|
assert not chat._has_call_id_in_buffer("call_cx")
|
|
assert "call_cx" in chat._pending_tool_calls
|
|
|
|
chat.append_tool_output("call_cx", _fco("cx"))
|
|
assert chat._has_call_id_in_buffer("call_cx")
|
|
assert any(isinstance(e, RealtimeConversationItemFunctionCall) and e.call_id == "call_cx" for e in chat.buffer)
|
|
assert any(
|
|
isinstance(e, RealtimeConversationItemFunctionCallOutput) and e.call_id == "call_cx" for e in chat.buffer
|
|
)
|
|
|
|
def test_reinjection_sets_status(self):
|
|
chat = Chat(size=1)
|
|
chat.add_item(_user("u1"))
|
|
fc = _fc("cx")
|
|
chat.add_item(fc)
|
|
chat.add_item(_user("u2"))
|
|
chat.trim_if_needed()
|
|
|
|
fco = _fco("cx", status="incomplete")
|
|
chat.append_tool_output("call_cx", fco)
|
|
reinjected = next(
|
|
e for e in chat.buffer if isinstance(e, RealtimeConversationItemFunctionCall) and e.call_id == "call_cx"
|
|
)
|
|
assert reinjected.status == "incomplete"
|
|
|
|
def test_unknown_call_id_raises(self):
|
|
chat = Chat(size=5)
|
|
with pytest.raises(ChatItemError, match="unknown_id"):
|
|
chat.append_tool_output("unknown_id", _fco("unknown_id"))
|
|
|
|
|
|
# ===================================================================
|
|
# 6. TestAddItem
|
|
# ===================================================================
|
|
|
|
|
|
class TestAddItem:
|
|
# -- System message --
|
|
|
|
def test_system_message_routed_to_init_chat(self):
|
|
chat = Chat(size=5)
|
|
sys_msg = _system("You are an expert.")
|
|
chat.add_item(sys_msg)
|
|
assert chat.init_chat_message is sys_msg
|
|
assert chat.buffer == []
|
|
|
|
# -- User message --
|
|
|
|
def test_user_message_text_appended(self):
|
|
chat = Chat(size=5)
|
|
chat.add_item(_user("hi"))
|
|
assert len(chat.buffer) == 1
|
|
assert chat.buffer[0].content[0].text == "hi"
|
|
|
|
def test_user_message_filters_unsupported_content(self):
|
|
chat = Chat(size=5)
|
|
msg = _user_msg_with_parts(("text", "hello"), ("audio", "transcript"))
|
|
chat.add_item(msg)
|
|
assert len(chat.buffer[0].content) == 1
|
|
assert chat.buffer[0].content[0].type == "input_text"
|
|
|
|
def test_user_message_keeps_image_content(self):
|
|
chat = Chat(size=5)
|
|
msg = _user_msg_with_parts(("text", "look"), ("image", "http://img.png"))
|
|
chat.add_item(msg)
|
|
assert len(chat.buffer[0].content) == 2
|
|
|
|
def test_user_message_empty_after_filter_raises(self):
|
|
chat = Chat(size=5)
|
|
msg = _user_msg_with_parts(("audio", "transcript only"))
|
|
with pytest.raises(ChatItemError, match="no supported content"):
|
|
chat.add_item(msg)
|
|
|
|
def test_user_message_empty_text_raises(self):
|
|
chat = Chat(size=5)
|
|
msg = RealtimeConversationItemUserMessage(
|
|
type="message",
|
|
role="user",
|
|
content=[UserContent(type="input_text", text="")],
|
|
)
|
|
with pytest.raises(ChatItemError, match="no supported content"):
|
|
chat.add_item(msg)
|
|
|
|
# -- Assistant message --
|
|
|
|
def test_assistant_message_appended(self):
|
|
chat = Chat(size=5)
|
|
chat.add_item(_user("hi"))
|
|
chat.add_item(_assistant("hello"))
|
|
assert len(chat.buffer) == 2
|
|
assert chat.buffer[1].content[0].text == "hello"
|
|
|
|
def test_assistant_message_filters_non_text(self):
|
|
chat = Chat(size=5)
|
|
msg = _assistant_msg_with_parts(("text", "ok"), ("audio", "audio_data"))
|
|
chat.add_item(msg)
|
|
assert len(chat.buffer[0].content) == 1
|
|
assert chat.buffer[0].content[0].type == "output_text"
|
|
|
|
def test_assistant_message_empty_after_filter_skipped(self):
|
|
chat = Chat(size=5)
|
|
msg = _assistant_msg_with_parts(("audio", "only audio"))
|
|
chat.add_item(msg)
|
|
assert len(chat.buffer) == 0
|
|
|
|
def test_assistant_message_empty_text_skipped(self):
|
|
chat = Chat(size=5)
|
|
msg = RealtimeConversationItemAssistantMessage(
|
|
type="message",
|
|
role="assistant",
|
|
content=[AssistantContent(type="output_text", text="")],
|
|
)
|
|
chat.add_item(msg)
|
|
assert len(chat.buffer) == 0
|
|
|
|
# -- Function call --
|
|
|
|
def test_function_call_staged_in_pending(self):
|
|
chat = Chat(size=5)
|
|
fc = _fc("c1", "do_stuff")
|
|
chat.add_item(fc)
|
|
assert len(chat.buffer) == 0
|
|
assert "call_c1" in chat._pending_tool_calls
|
|
assert chat._pending_tool_calls["call_c1"] is fc
|
|
|
|
def test_function_call_missing_call_id_auto_generates(self):
|
|
chat = Chat(size=5)
|
|
fc = RealtimeConversationItemFunctionCall(
|
|
type="function_call",
|
|
call_id=None,
|
|
name="f",
|
|
arguments="{}",
|
|
)
|
|
chat.add_item(fc)
|
|
assert fc.call_id is not None
|
|
assert fc.call_id.startswith("call_")
|
|
|
|
def test_function_call_none_call_id_auto_generates(self):
|
|
chat = Chat(size=5)
|
|
fc = RealtimeConversationItemFunctionCall(
|
|
type="function_call",
|
|
call_id=None,
|
|
name="f",
|
|
arguments="{}",
|
|
)
|
|
chat.add_item(fc)
|
|
assert fc.call_id is not None
|
|
assert fc.call_id.startswith("call_")
|
|
|
|
def test_function_call_bad_call_id_prefix_raises(self):
|
|
chat = Chat(size=5)
|
|
fc = RealtimeConversationItemFunctionCall(
|
|
type="function_call",
|
|
call_id="",
|
|
name="f",
|
|
arguments="{}",
|
|
)
|
|
with pytest.raises(ChatItemError, match="call_"):
|
|
chat.add_item(fc)
|
|
|
|
# -- Function call output --
|
|
|
|
def test_function_call_output_delegates_to_append_tool_output(self):
|
|
chat = Chat(size=5)
|
|
chat.add_item(_fc("c1"))
|
|
fco = _fco("c1")
|
|
chat.add_item(fco)
|
|
assert chat.buffer[-1] is fco
|
|
|
|
def test_function_call_output_unknown_raises(self):
|
|
chat = Chat(size=5)
|
|
with pytest.raises(ChatItemError, match="no_such_call"):
|
|
chat.add_item(_fco("no_such_call"))
|
|
|
|
|
|
# ===================================================================
|
|
# 7. TestToResponseApiChat
|
|
# ===================================================================
|
|
|
|
|
|
class TestToResponseApiChat:
|
|
def test_empty_chat(self):
|
|
chat = Chat(size=5)
|
|
assert chat.to_responses_api_chat() == []
|
|
|
|
def test_system_message_serialized(self):
|
|
chat = Chat(size=5)
|
|
chat.init_chat(_system("Be brief."))
|
|
result = chat.to_responses_api_chat()
|
|
assert len(result) == 1
|
|
assert result[0]["role"] == "system"
|
|
assert result[0]["type"] == "message"
|
|
assert result[0]["content"][0]["text"] == "Be brief."
|
|
assert result[0]["content"][0]["type"] == "input_text"
|
|
|
|
def test_system_message_empty_text_fallback(self):
|
|
chat = Chat(size=5)
|
|
sys_msg = RealtimeConversationItemSystemMessage(
|
|
type="message",
|
|
role="system",
|
|
content=[SystemContent(type="input_text", text="")],
|
|
)
|
|
chat.init_chat(sys_msg)
|
|
result = chat.to_responses_api_chat()
|
|
assert result[0]["content"][0]["text"] == "A helpful AI assistant."
|
|
|
|
def test_user_text_message(self):
|
|
chat = Chat(size=5)
|
|
chat.add_item(_user("What is 2+2?"))
|
|
result = chat.to_responses_api_chat()
|
|
assert len(result) == 1
|
|
assert result[0]["role"] == "user"
|
|
assert result[0]["content"][0]["text"] == "What is 2+2?"
|
|
|
|
def test_user_image_message(self):
|
|
chat = Chat(size=5)
|
|
msg = _user_msg_with_parts(("text", "Describe"), ("image", "http://img.png"))
|
|
chat.add_item(msg)
|
|
result = chat.to_responses_api_chat()
|
|
content = result[0]["content"]
|
|
assert len(content) == 2
|
|
assert content[0]["type"] == "input_text"
|
|
assert content[1]["type"] == "input_image"
|
|
assert content[1]["image_url"] == "http://img.png"
|
|
|
|
def test_assistant_message(self):
|
|
chat = Chat(size=5)
|
|
msg = make_assistant_message("Hello there.")
|
|
msg.status = "completed"
|
|
chat.add_item(msg)
|
|
result = chat.to_responses_api_chat()
|
|
assert len(result) == 1
|
|
assert result[0]["role"] == "assistant"
|
|
assert result[0]["id"] == msg.id
|
|
assert result[0]["status"] == "completed"
|
|
assert result[0]["content"][0]["text"] == "Hello there."
|
|
|
|
def test_assistant_message_default_status(self):
|
|
chat = Chat(size=5)
|
|
msg = make_assistant_message("hi")
|
|
chat.add_item(msg)
|
|
result = chat.to_responses_api_chat()
|
|
assert result[0]["status"] == "completed"
|
|
|
|
def test_function_call_with_id_and_status(self):
|
|
chat = Chat(size=5)
|
|
fc = _fc("c1", "search", '{"q": "test"}')
|
|
chat.add_item(fc)
|
|
fco = _fco("c1", '{"result": 1}', status="completed")
|
|
chat.add_item(fco)
|
|
result = chat.to_responses_api_chat()
|
|
entry = result[0]
|
|
assert entry["type"] == "function_call"
|
|
assert entry["call_id"] == "call_c1"
|
|
assert entry["name"] == "search"
|
|
assert entry["arguments"] == '{"q": "test"}'
|
|
assert entry["id"] == fc.id
|
|
assert entry["status"] == "completed"
|
|
|
|
def test_function_call_without_optional_fields(self):
|
|
chat = Chat(size=5)
|
|
fc = _fc("c2", "noop")
|
|
chat.add_item(fc)
|
|
fco = _fco("c2")
|
|
chat.add_item(fco)
|
|
result = chat.to_responses_api_chat()
|
|
entry = result[0]
|
|
assert entry["call_id"] == "call_c2"
|
|
assert entry["id"] == fc.id
|
|
|
|
def test_function_call_output_with_id_and_status(self):
|
|
chat = Chat(size=5)
|
|
chat.add_item(_fc("c1"))
|
|
fco = _fco("c1", '{"result": 42}')
|
|
fco.status = "completed"
|
|
chat.add_item(fco)
|
|
result = chat.to_responses_api_chat()
|
|
entry = result[-1]
|
|
assert entry["type"] == "function_call_output"
|
|
assert entry["call_id"] == "call_c1"
|
|
assert entry["output"] == '{"result": 42}'
|
|
assert entry["id"] == fco.id
|
|
assert entry["status"] == "completed"
|
|
|
|
def test_function_call_output_without_optional_fields(self):
|
|
chat = Chat(size=5)
|
|
chat.add_item(_fc("c1"))
|
|
fco = _fco("c1")
|
|
chat.add_item(fco)
|
|
result = chat.to_responses_api_chat()
|
|
entry = result[-1]
|
|
assert entry["id"] == fco.id
|
|
assert "status" not in entry
|
|
|
|
def test_full_mixed_conversation(self):
|
|
chat = Chat(size=10)
|
|
chat.init_chat(_system("You are helpful."))
|
|
chat.add_item(_user("Call my tool"))
|
|
chat.add_item(_fc("c1", "tool_a", '{"x": 1}'))
|
|
fco = _fco("c1", '{"y": 2}')
|
|
chat.add_item(fco)
|
|
chat.add_item(_assistant("Done."))
|
|
|
|
result = chat.to_responses_api_chat()
|
|
assert len(result) == 5
|
|
assert result[0]["role"] == "system"
|
|
assert result[1]["role"] == "user"
|
|
assert result[2]["type"] == "function_call"
|
|
assert result[3]["type"] == "function_call_output"
|
|
assert result[4]["role"] == "assistant"
|
|
|
|
|
|
# ===================================================================
|
|
# 8. TestToTransformersChat
|
|
# ===================================================================
|
|
|
|
|
|
class TestToTransformersChat:
|
|
def test_empty_chat(self):
|
|
chat = Chat(size=5)
|
|
assert chat.to_transformers_chat() == []
|
|
|
|
def test_system_message(self):
|
|
chat = Chat(size=5)
|
|
chat.init_chat(_system("Be concise."))
|
|
result = chat.to_transformers_chat()
|
|
assert result == [{"role": "system", "content": "Be concise."}]
|
|
|
|
def test_user_text_only_produces_string_content(self):
|
|
chat = Chat(size=5)
|
|
chat.add_item(_user("hi there"))
|
|
result = chat.to_transformers_chat()
|
|
assert len(result) == 1
|
|
assert result[0]["role"] == "user"
|
|
assert isinstance(result[0]["content"], str)
|
|
assert result[0]["content"] == "hi there"
|
|
|
|
def test_user_multi_text_parts_joined(self):
|
|
chat = Chat(size=5)
|
|
msg = _user_msg_with_parts(("text", "hello"), ("text", "world"))
|
|
chat.add_item(msg)
|
|
result = chat.to_transformers_chat()
|
|
assert result[0]["content"] == "hello world"
|
|
|
|
def test_user_with_images_produces_list_content(self):
|
|
chat = Chat(size=5)
|
|
msg = _user_msg_with_parts(("text", "look"), ("image", "http://img.png"))
|
|
chat.add_item(msg)
|
|
result = chat.to_transformers_chat()
|
|
assert result[0]["role"] == "user"
|
|
assert isinstance(result[0]["content"], list)
|
|
assert len(result[0]["content"]) == 2
|
|
|
|
def test_assistant_message_text_joined(self):
|
|
chat = Chat(size=5)
|
|
msg = RealtimeConversationItemAssistantMessage(
|
|
type="message",
|
|
role="assistant",
|
|
content=[
|
|
AssistantContent(type="output_text", text="part1"),
|
|
AssistantContent(type="output_text", text="part2"),
|
|
],
|
|
)
|
|
chat.add_item(msg)
|
|
result = chat.to_transformers_chat()
|
|
assert result[0] == {"role": "assistant", "content": "part1 part2"}
|
|
|
|
def test_function_call_valid_json_args(self):
|
|
chat = Chat(size=5)
|
|
chat.add_item(_fc("c1", "search", '{"query": "test"}'))
|
|
chat.add_item(_fco("c1", "ok"))
|
|
result = chat.to_transformers_chat()
|
|
entry = result[0]
|
|
assert entry["role"] == "assistant"
|
|
assert len(entry["tool_calls"]) == 1
|
|
tc = entry["tool_calls"][0]
|
|
assert tc["type"] == "function"
|
|
assert tc["id"] == "call_c1"
|
|
assert tc["function"]["name"] == "search"
|
|
assert tc["function"]["arguments"] == {"query": "test"}
|
|
|
|
def test_function_call_invalid_json_falls_back(self):
|
|
chat = Chat(size=5)
|
|
chat.add_item(_fc("c1", "broken", "not valid json"))
|
|
chat.add_item(_fco("c1", "ok"))
|
|
result = chat.to_transformers_chat()
|
|
assert result[0]["tool_calls"][0]["function"]["arguments"] == {}
|
|
|
|
def test_function_call_empty_string_args(self):
|
|
chat = Chat(size=5)
|
|
fc = _fc("c1", "f", "")
|
|
chat.add_item(fc)
|
|
chat.add_item(_fco("c1", "ok"))
|
|
result = chat.to_transformers_chat()
|
|
assert result[0]["tool_calls"][0]["function"]["arguments"] == {}
|
|
|
|
def test_function_call_output_resolves_name(self):
|
|
chat = Chat(size=5)
|
|
chat.add_item(_fc("c1", "lookup"))
|
|
chat.add_item(_fco("c1", "result_data"))
|
|
result = chat.to_transformers_chat()
|
|
tool_entry = result[1]
|
|
assert tool_entry["role"] == "tool"
|
|
assert tool_entry["tool_call_id"] == "call_c1"
|
|
assert tool_entry["name"] == "lookup"
|
|
assert tool_entry["content"] == "result_data"
|
|
|
|
def test_function_call_output_no_matching_call_empty_name(self):
|
|
chat = Chat(size=5)
|
|
fco = _fco("orphan_id", "data")
|
|
fco.id = "fco_orphan"
|
|
chat.buffer.append(fco)
|
|
result = chat.to_transformers_chat()
|
|
assert result[0]["name"] == ""
|
|
|
|
def test_full_mixed_conversation(self):
|
|
chat = Chat(size=10)
|
|
chat.init_chat(_system("System prompt"))
|
|
chat.add_item(_user("Do it"))
|
|
chat.add_item(_fc("c1", "action", '{"a": 1}'))
|
|
chat.add_item(_fco("c1", "done"))
|
|
chat.add_item(_assistant("All set."))
|
|
|
|
result = chat.to_transformers_chat()
|
|
assert len(result) == 5
|
|
assert result[0] == {"role": "system", "content": "System prompt"}
|
|
assert result[1] == {"role": "user", "content": "Do it"}
|
|
assert result[2]["role"] == "assistant"
|
|
assert "tool_calls" in result[2]
|
|
assert result[3]["role"] == "tool"
|
|
assert result[3]["name"] == "action"
|
|
assert result[4] == {"role": "assistant", "content": "All set."}
|
|
|
|
|
|
# ===================================================================
|
|
# 9. TestCopyAndReset
|
|
# ===================================================================
|
|
|
|
|
|
class TestCopyAndReset:
|
|
def test_copy_buffer_independent(self):
|
|
chat = Chat(size=5)
|
|
chat.add_item(_user("original"))
|
|
clone = chat.copy()
|
|
clone.add_item(_user("extra"))
|
|
assert len(chat.buffer) == 1
|
|
assert len(clone.buffer) == 2
|
|
|
|
def test_copy_preserves_init_chat_message(self):
|
|
chat = Chat(size=5)
|
|
sys_msg = _system("Keep it short.")
|
|
chat.init_chat(sys_msg)
|
|
clone = chat.copy()
|
|
assert clone.init_chat_message is sys_msg
|
|
|
|
def test_copy_preserves_pending_tool_calls_independently(self):
|
|
chat = Chat(size=5)
|
|
chat.add_item(_fc("c1"))
|
|
clone = chat.copy()
|
|
assert "call_c1" in clone._pending_tool_calls
|
|
clone._pending_tool_calls.pop("call_c1")
|
|
assert "call_c1" in chat._pending_tool_calls
|
|
|
|
def test_copy_preserves_size(self):
|
|
chat = Chat(size=7)
|
|
clone = chat.copy()
|
|
assert clone.size == 7
|
|
|
|
def test_copy_preserves_user_turn_count(self):
|
|
chat = Chat(size=5)
|
|
chat.add_item(_user("u1"))
|
|
chat.add_item(_user("u2"))
|
|
clone = chat.copy()
|
|
assert clone._user_turn_count == 2
|
|
|
|
def test_reset_clears_everything(self):
|
|
chat = Chat(size=5)
|
|
chat.init_chat(_system("sys"))
|
|
chat.add_item(_user("u"))
|
|
chat.add_item(_fc("c1"))
|
|
assert len(chat.buffer) > 0
|
|
assert chat.init_chat_message is not None
|
|
assert len(chat._pending_tool_calls) > 0
|
|
assert chat._user_turn_count > 0
|
|
|
|
chat.reset()
|
|
assert chat.buffer == []
|
|
assert chat.init_chat_message is None
|
|
assert chat._pending_tool_calls == {}
|
|
assert chat._user_turn_count == 0
|
|
|
|
def test_reset_preserves_size(self):
|
|
chat = Chat(size=3)
|
|
chat.reset()
|
|
assert chat.size == 3
|
|
|
|
|
|
# ===================================================================
|
|
# 10. TestStripImages
|
|
# ===================================================================
|
|
|
|
|
|
class TestStripImages:
|
|
def test_multiple_user_messages_images_removed(self):
|
|
chat = Chat(size=10)
|
|
chat.add_item(_user_msg_with_parts(("text", "a"), ("image", "url1")))
|
|
chat.add_item(_assistant("ok"))
|
|
chat.add_item(_user_msg_with_parts(("text", "b"), ("image", "url2")))
|
|
chat.strip_images()
|
|
|
|
for item in chat.buffer:
|
|
if isinstance(item, RealtimeConversationItemUserMessage):
|
|
assert all(p.type != "input_image" for p in item.content)
|
|
assert any(p.type == "input_text" for p in item.content)
|
|
|
|
def test_no_user_messages_noop(self):
|
|
chat = Chat(size=10)
|
|
chat.add_item(_assistant("solo"))
|
|
chat.add_item(_fc("c1"))
|
|
chat.strip_images()
|
|
assert len(chat.buffer) == 1 # fc is staged in _pending_tool_calls, not buffer
|
|
|
|
def test_text_only_messages_unchanged(self):
|
|
chat = Chat(size=10)
|
|
chat.add_item(_user("just text"))
|
|
chat.strip_images()
|
|
assert chat.buffer[0].content[0].text == "just text"
|
|
assert len(chat.buffer[0].content) == 1
|
|
|
|
def test_image_message_ids_reports_only_image_carriers(self):
|
|
chat = Chat(size=10)
|
|
chat.add_item(_user("text only"))
|
|
img_msg = chat.add_item(_user_msg_with_parts(("text", "b"), ("image", "url2")))
|
|
assert chat.image_message_ids() == {img_msg.id}
|
|
|
|
def test_strip_images_only_ids_spares_concurrent_image(self):
|
|
"""Regression: an image injected for the *next* turn (not in the set the
|
|
finished response consumed) must survive the write-back strip."""
|
|
chat = Chat(size=10)
|
|
consumed = chat.add_item(_user_msg_with_parts(("text", "a"), ("image", "old")))
|
|
# snapshot of what the just-finished response actually saw
|
|
consumed_ids = chat.image_message_ids()
|
|
# a fast client injects the next turn's image mid-generation
|
|
fresh = chat.add_item(_user_msg_with_parts(("text", "b"), ("image", "new")))
|
|
|
|
chat.strip_images(consumed_ids)
|
|
|
|
consumed_after = next(i for i in chat.buffer if i.id == consumed.id)
|
|
fresh_after = next(i for i in chat.buffer if i.id == fresh.id)
|
|
assert all(p.type != "input_image" for p in consumed_after.content) # consumed → stripped
|
|
assert any(p.type == "input_image" for p in fresh_after.content) # next turn's image → kept
|
|
|
|
|
|
# ===================================================================
|
|
# 11. TestMarkCallCompleted
|
|
# ===================================================================
|
|
|
|
|
|
class TestMarkCallCompleted:
|
|
def test_none_status_sets_completed(self):
|
|
chat = Chat(size=5)
|
|
fc = _fc("c1")
|
|
chat.buffer.append(fc)
|
|
chat._mark_call_completed("call_c1", status=None)
|
|
assert fc.status == "completed"
|
|
|
|
def test_explicit_status_used(self):
|
|
chat = Chat(size=5)
|
|
fc = _fc("c1")
|
|
chat.buffer.append(fc)
|
|
chat._mark_call_completed("call_c1", status="incomplete")
|
|
assert fc.status == "incomplete"
|
|
|
|
def test_in_progress_status(self):
|
|
chat = Chat(size=5)
|
|
fc = _fc("c1")
|
|
chat.buffer.append(fc)
|
|
chat._mark_call_completed("call_c1", status="in_progress")
|
|
assert fc.status == "in_progress"
|
|
|
|
def test_no_match_is_noop(self):
|
|
chat = Chat(size=5)
|
|
fc = _fc("c1")
|
|
chat.buffer.append(fc)
|
|
chat._mark_call_completed("nonexistent", status=None)
|
|
assert fc.status is None
|
|
|
|
def test_only_function_calls_checked(self):
|
|
chat = Chat(size=5)
|
|
chat.add_item(_user("hi"))
|
|
fc = _fc("c1")
|
|
chat.buffer.append(fc)
|
|
chat._mark_call_completed("call_c1")
|
|
fc = next(e for e in chat.buffer if isinstance(e, RealtimeConversationItemFunctionCall))
|
|
assert fc.status == "completed"
|
|
|
|
|
|
# ===================================================================
|
|
# 12. TestCompaction
|
|
# ===================================================================
|
|
|
|
|
|
def _wait_thread(chat: Chat, timeout: float = 2.0) -> None:
|
|
"""Block until the latest compaction worker (if any) finishes."""
|
|
t = chat._compact_thread
|
|
if t is not None:
|
|
t.join(timeout)
|
|
assert not t.is_alive(), "compaction thread did not finish in time"
|
|
|
|
|
|
def _make_stub_compactor(
|
|
user_text: str = "USER_SUMMARY",
|
|
assistant_text: str = "ASSISTANT_SUMMARY",
|
|
*,
|
|
gate: threading.Event | None = None,
|
|
started: threading.Event | None = None,
|
|
captured: list | None = None,
|
|
):
|
|
"""Build a stub :data:`CompactFn` that records its input and optionally blocks.
|
|
|
|
- ``gate``: if provided, the compactor waits on it before returning, so
|
|
tests can interleave concurrent operations during phase 2.
|
|
- ``started``: set immediately on entry, so tests can wait until the worker
|
|
is mid-flight before continuing.
|
|
- ``captured``: appended to with the snapshot received -- lets tests assert
|
|
on what the compactor saw.
|
|
"""
|
|
|
|
def stub(snapshot):
|
|
if started is not None:
|
|
started.set()
|
|
if captured is not None:
|
|
captured.append(snapshot)
|
|
if gate is not None:
|
|
gate.wait(timeout=2.0)
|
|
return CompactionResult(user_summary=user_text, assistant_summary=assistant_text)
|
|
|
|
return stub
|
|
|
|
|
|
class TestCompaction:
|
|
def test_compaction_replaces_old_turns(self):
|
|
chat = Chat(size=2)
|
|
compactor = _make_stub_compactor("U", "A")
|
|
for i in range(3):
|
|
chat.add_item(_user(f"u{i}"))
|
|
chat.add_item(_assistant(f"a{i}"))
|
|
chat.add_item(_user("u3"))
|
|
chat.trim_if_needed(compactor)
|
|
|
|
_wait_thread(chat)
|
|
# Buffer should be: [user_summary, assistant_summary, u3] (3 items)
|
|
assert len(chat.buffer) == 3
|
|
assert isinstance(chat.buffer[0], RealtimeConversationItemUserMessage)
|
|
assert chat.buffer[0].content[0].text == "U"
|
|
assert isinstance(chat.buffer[1], RealtimeConversationItemAssistantMessage)
|
|
assert chat.buffer[1].content[0].text == "A"
|
|
assert chat.buffer[2].content[0].text == "u3"
|
|
assert chat._user_turn_count == 2
|
|
|
|
def test_compaction_leaves_pending_fc_in_pending_map(self):
|
|
"""Pending FCs stay in _pending_tool_calls; only FCO arrival moves the pair into the buffer."""
|
|
chat = Chat(size=2)
|
|
compactor = _make_stub_compactor()
|
|
chat.add_item(_user("u0"))
|
|
chat.add_item(_assistant("a0"))
|
|
chat.add_item(_fc("c1"))
|
|
chat.add_item(_user("u1"))
|
|
chat.add_item(_assistant("a1"))
|
|
chat.add_item(_user("u2"))
|
|
chat.add_item(_assistant("a2"))
|
|
chat.add_item(_user("u3"))
|
|
chat.trim_if_needed(compactor)
|
|
|
|
_wait_thread(chat)
|
|
assert not any(isinstance(x, RealtimeConversationItemFunctionCall) for x in chat.buffer)
|
|
assert "call_c1" in chat._pending_tool_calls
|
|
|
|
chat.add_item(_fco("c1"))
|
|
buffer = chat.buffer
|
|
fc_indices = [i for i, x in enumerate(buffer) if isinstance(x, RealtimeConversationItemFunctionCall)]
|
|
fco_indices = [i for i, x in enumerate(buffer) if isinstance(x, RealtimeConversationItemFunctionCallOutput)]
|
|
assert len(fc_indices) == 1 and len(fco_indices) == 1
|
|
assert fco_indices[0] == fc_indices[0] + 1
|
|
assert "call_c1" not in chat._pending_tool_calls
|
|
|
|
def test_compaction_preserves_appends_during_compaction(self):
|
|
chat = Chat(size=2)
|
|
gate = threading.Event()
|
|
started = threading.Event()
|
|
compactor = _make_stub_compactor(gate=gate, started=started)
|
|
|
|
for i in range(3):
|
|
chat.add_item(_user(f"u{i}"))
|
|
chat.add_item(_assistant(f"a{i}"))
|
|
chat.add_item(_user("u3"))
|
|
chat.trim_if_needed(compactor)
|
|
assert started.wait(timeout=2.0), "compactor never ran"
|
|
|
|
# Append a brand-new user message during phase 2.
|
|
chat.add_item(_user("u_new"))
|
|
chat.trim_if_needed(compactor) # single-flight bypass while compaction running
|
|
|
|
gate.set()
|
|
_wait_thread(chat)
|
|
# After splice: [summary_u, summary_a, u3, u_new]
|
|
user_texts = [x.content[0].text for x in chat.buffer if isinstance(x, RealtimeConversationItemUserMessage)]
|
|
assert user_texts == ["USER_SUMMARY", "u3", "u_new"]
|
|
|
|
def test_single_flight_bypassed(self):
|
|
chat = Chat(size=2)
|
|
gate = threading.Event()
|
|
started = threading.Event()
|
|
compactor = _make_stub_compactor(gate=gate, started=started)
|
|
|
|
for i in range(3):
|
|
chat.add_item(_user(f"u{i}"))
|
|
chat.add_item(_assistant(f"a{i}"))
|
|
chat.add_item(_user("u3"))
|
|
chat.trim_if_needed(compactor) # triggers
|
|
assert started.wait(timeout=2.0)
|
|
first_thread = chat._compact_thread
|
|
|
|
# Try to trigger another compaction while the first is mid-flight.
|
|
chat.add_item(_user("u4"))
|
|
chat.trim_if_needed(compactor) # single-flight bypass
|
|
assert chat._compact_thread is first_thread
|
|
|
|
gate.set()
|
|
_wait_thread(chat)
|
|
|
|
def test_no_compaction_when_below_threshold(self):
|
|
chat = Chat(size=2)
|
|
compactor = _make_stub_compactor()
|
|
chat.add_item(_user("u0"))
|
|
chat.add_item(_assistant("a0"))
|
|
chat.add_item(_user("u1"))
|
|
chat.trim_if_needed(compactor)
|
|
# count == size, no trigger
|
|
assert chat._compact_thread is None
|
|
|
|
chat.add_item(_assistant("a1"))
|
|
chat.add_item(_user("u2"))
|
|
chat.trim_if_needed(compactor)
|
|
_wait_thread(chat)
|
|
# count > size, triggers
|
|
assert chat._compact_thread is not None
|
|
|
|
def test_compactor_none_falls_back_to_eviction(self):
|
|
chat = Chat(size=1)
|
|
chat.add_item(_user("u1"))
|
|
chat.add_item(_assistant("a1"))
|
|
chat.add_item(_user("u2"))
|
|
chat.trim_if_needed() # no compactor → eviction
|
|
assert chat._user_turn_count == 1
|
|
assert chat._compact_thread is None
|
|
assert chat.buffer[0].content[0].text == "u2"
|
|
|
|
def test_drops_paired_fc_fco_in_range(self):
|
|
chat = Chat(size=2)
|
|
compactor = _make_stub_compactor()
|
|
chat.add_item(_user("u0"))
|
|
chat.add_item(_fc("c1"))
|
|
chat.add_item(_fco("c1"))
|
|
chat.add_item(_assistant("a0"))
|
|
chat.add_item(_user("u1"))
|
|
chat.add_item(_assistant("a1"))
|
|
chat.add_item(_user("u2"))
|
|
chat.add_item(_assistant("a2"))
|
|
chat.add_item(_user("u3"))
|
|
chat.trim_if_needed(compactor)
|
|
|
|
_wait_thread(chat)
|
|
# Both fc and fco should be gone.
|
|
assert not any(isinstance(x, RealtimeConversationItemFunctionCall) for x in chat.buffer)
|
|
assert not any(isinstance(x, RealtimeConversationItemFunctionCallOutput) for x in chat.buffer)
|
|
|
|
def test_keeps_fc_when_fco_arrives_during_compaction(self):
|
|
chat = Chat(size=2)
|
|
gate = threading.Event()
|
|
started = threading.Event()
|
|
compactor = _make_stub_compactor(gate=gate, started=started)
|
|
chat.add_item(_user("u0"))
|
|
chat.add_item(_fc("c1"))
|
|
chat.add_item(_assistant("a0"))
|
|
chat.add_item(_user("u1"))
|
|
chat.add_item(_assistant("a1"))
|
|
chat.add_item(_user("u2"))
|
|
chat.add_item(_assistant("a2"))
|
|
chat.add_item(_user("u3"))
|
|
chat.trim_if_needed(compactor)
|
|
assert started.wait(timeout=2.0)
|
|
|
|
chat.add_item(_fco("c1"))
|
|
gate.set()
|
|
_wait_thread(chat)
|
|
|
|
fc_indices = [i for i, x in enumerate(chat.buffer) if isinstance(x, RealtimeConversationItemFunctionCall)]
|
|
fco_indices = [
|
|
i for i, x in enumerate(chat.buffer) if isinstance(x, RealtimeConversationItemFunctionCallOutput)
|
|
]
|
|
assert len(fc_indices) == 1 and len(fco_indices) == 1
|
|
assert fco_indices[0] == fc_indices[0] + 1
|
|
assert chat.buffer[fc_indices[0]].call_id == "call_c1"
|
|
assert chat.buffer[fco_indices[0]].call_id == "call_c1"
|
|
|
|
def test_reset_cancels_inflight_compaction(self):
|
|
chat = Chat(size=2)
|
|
gate = threading.Event()
|
|
started = threading.Event()
|
|
compactor = _make_stub_compactor(gate=gate, started=started)
|
|
for i in range(3):
|
|
chat.add_item(_user(f"u{i}"))
|
|
chat.add_item(_assistant(f"a{i}"))
|
|
chat.add_item(_user("u3"))
|
|
chat.trim_if_needed(compactor)
|
|
assert started.wait(timeout=2.0)
|
|
|
|
chat.reset()
|
|
gate.set()
|
|
_wait_thread(chat)
|
|
|
|
# Splice should have been suppressed by gen counter bump.
|
|
assert chat.buffer == []
|
|
assert chat._user_turn_count == 0
|
|
|
|
def test_close_suppresses_splice(self):
|
|
chat = Chat(size=2)
|
|
gate = threading.Event()
|
|
started = threading.Event()
|
|
compactor = _make_stub_compactor(gate=gate, started=started)
|
|
for i in range(3):
|
|
chat.add_item(_user(f"u{i}"))
|
|
chat.add_item(_assistant(f"a{i}"))
|
|
chat.add_item(_user("u3"))
|
|
chat.trim_if_needed(compactor)
|
|
assert started.wait(timeout=2.0)
|
|
|
|
before = list(chat.buffer)
|
|
chat.close()
|
|
gate.set()
|
|
_wait_thread(chat)
|
|
|
|
# Buffer was not spliced.
|
|
assert chat.buffer == before
|
|
|
|
def test_compactor_exception_leaves_buffer_unchanged(self):
|
|
chat = Chat(size=2)
|
|
|
|
def bad(snapshot):
|
|
raise RuntimeError("boom")
|
|
|
|
for i in range(3):
|
|
chat.add_item(_user(f"u{i}"))
|
|
chat.add_item(_assistant(f"a{i}"))
|
|
chat.add_item(_user("u3"))
|
|
chat.trim_if_needed(bad)
|
|
_wait_thread(chat)
|
|
|
|
# All originals still present.
|
|
user_texts = [x.content[0].text for x in chat.buffer if isinstance(x, RealtimeConversationItemUserMessage)]
|
|
assert user_texts == ["u0", "u1", "u2", "u3"]
|
|
|
|
def test_compactor_wrong_return_type_logged(self):
|
|
chat = Chat(size=2)
|
|
|
|
def wrong(snapshot):
|
|
return ("u", "a") # not a CompactionResult
|
|
|
|
for i in range(3):
|
|
chat.add_item(_user(f"u{i}"))
|
|
chat.add_item(_assistant(f"a{i}"))
|
|
chat.add_item(_user("u3"))
|
|
chat.trim_if_needed(wrong)
|
|
_wait_thread(chat)
|
|
|
|
# No splice happened.
|
|
user_texts = [x.content[0].text for x in chat.buffer if isinstance(x, RealtimeConversationItemUserMessage)]
|
|
assert user_texts == ["u0", "u1", "u2", "u3"]
|
|
|
|
def test_init_message_unchanged_after_compaction(self):
|
|
chat = Chat(size=2)
|
|
sys_msg = _system("system prompt")
|
|
chat.init_chat(sys_msg)
|
|
compactor = _make_stub_compactor()
|
|
for i in range(3):
|
|
chat.add_item(_user(f"u{i}"))
|
|
chat.add_item(_assistant(f"a{i}"))
|
|
chat.add_item(_user("u3"))
|
|
chat.trim_if_needed(compactor)
|
|
_wait_thread(chat)
|
|
|
|
assert chat.init_chat_message is sys_msg
|
|
|
|
def test_snapshot_strips_images(self):
|
|
chat = Chat(size=2)
|
|
captured: list = []
|
|
compactor = _make_stub_compactor(captured=captured)
|
|
chat.add_item(_user_msg_with_parts(("text", "look"), ("image", "http://img.png")))
|
|
chat.add_item(_assistant("a0"))
|
|
chat.add_item(_user("u1"))
|
|
chat.add_item(_assistant("a1"))
|
|
chat.add_item(_user("u2"))
|
|
chat.add_item(_assistant("a2"))
|
|
chat.add_item(_user("u3"))
|
|
chat.trim_if_needed(compactor)
|
|
_wait_thread(chat)
|
|
|
|
assert len(captured) == 1
|
|
snapshot = captured[0]
|
|
for msg in snapshot:
|
|
if isinstance(msg, dict) and msg.get("role") == "user":
|
|
for c in msg.get("content", []):
|
|
assert c.get("type") != "input_image"
|
|
|
|
|
|
# ===================================================================
|
|
# build_active_chat (out-of-band response context)
|
|
# ===================================================================
|
|
|
|
|
|
class TestBuildActiveChat:
|
|
def _default(self) -> Chat:
|
|
chat = Chat(size=4)
|
|
chat.init_chat(make_system_message("default system"))
|
|
chat.add_item(_user("default question"))
|
|
return chat
|
|
|
|
def test_input_items_seed_fresh_chat(self):
|
|
original = self._default()
|
|
resp = RealtimeResponseCreateParams(conversation="none", input=[make_user_message("fresh question")])
|
|
|
|
active = build_active_chat(original, resp)
|
|
|
|
assert active is not original
|
|
texts = [p.text for item in active.buffer for p in item.content]
|
|
assert texts == ["fresh question"]
|
|
# The default conversation's history did not leak in.
|
|
assert active.init_chat_message is None
|
|
|
|
def test_empty_input_clears_context(self):
|
|
original = self._default()
|
|
resp = RealtimeResponseCreateParams(conversation="none", input=[])
|
|
|
|
active = build_active_chat(original, resp)
|
|
|
|
assert active.buffer == []
|
|
|
|
def test_absent_input_copies_default(self):
|
|
original = self._default()
|
|
resp = RealtimeResponseCreateParams(conversation="none", input=None)
|
|
|
|
active = build_active_chat(original, resp)
|
|
|
|
assert active is not original
|
|
texts = [p.text for item in active.buffer for p in item.content]
|
|
assert texts == ["default question"]
|
|
assert active.init_chat_message is original.init_chat_message
|
|
|
|
def test_invalid_input_item_raises(self):
|
|
original = self._default()
|
|
from openai.types.realtime.conversation_item import RealtimeConversationItemFunctionCallOutput
|
|
|
|
orphan = RealtimeConversationItemFunctionCallOutput(
|
|
type="function_call_output", call_id="call_missing", output="{}"
|
|
)
|
|
resp = RealtimeResponseCreateParams(conversation="none", input=[orphan])
|
|
|
|
with pytest.raises(ChatItemError):
|
|
build_active_chat(original, resp)
|