Skip to content

Commit def9b62

Browse files
author
matdev83
committed
Manual Merge PR #643
1 parent 384c802 commit def9b62

File tree

5 files changed

+322
-2
lines changed

5 files changed

+322
-2
lines changed

data/test_suite_state.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
{
2-
"test_count": 5099,
2+
"test_count": 5117,
33
"last_updated": "1762168167.0802596"
44
}

src/core/app/controllers/chat_controller.py

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,77 @@ def _try_get(
142142

143143
raise InitializationError("Translation service is not registered in DI")
144144

145+
@staticmethod
146+
def _coerce_message_content_to_text(content: Any, _depth: int = 0) -> str:
147+
"""Flatten ChatMessage content into a plain text payload for Anthropic."""
148+
# Prevent stack overflow from circular references
149+
if _depth > 20:
150+
return f"[Circular reference detected at depth {_depth}]"
151+
152+
if content is None:
153+
return ""
154+
155+
if isinstance(content, str):
156+
return content
157+
158+
if isinstance(content, bytes | bytearray):
159+
return content.decode("utf-8", errors="ignore")
160+
161+
if hasattr(content, "model_dump"):
162+
try:
163+
dumped = content.model_dump()
164+
except Exception: # pragma: no cover - defensive
165+
dumped = None
166+
if dumped is not None:
167+
return ChatController._coerce_message_content_to_text(
168+
dumped, _depth + 1
169+
)
170+
171+
if isinstance(content, dict):
172+
text_value = content.get("text")
173+
if isinstance(text_value, str):
174+
return text_value
175+
if isinstance(text_value, bytes | bytearray):
176+
return text_value.decode("utf-8", errors="ignore")
177+
178+
if content.get("type") == "image_url":
179+
image_payload = content.get("image_url")
180+
if isinstance(image_payload, dict):
181+
url_value = image_payload.get("url")
182+
if isinstance(url_value, str):
183+
return url_value
184+
185+
import json
186+
187+
try:
188+
return json.dumps(content, ensure_ascii=False)
189+
except (TypeError, ValueError) as exc:
190+
error_message = str(exc)
191+
if "Circular reference detected" in error_message:
192+
return f"[Circular reference detected at depth {_depth}]"
193+
return error_message or str(content)
194+
195+
if isinstance(content, list | tuple) and not isinstance(
196+
content, str | bytes | bytearray
197+
):
198+
parts: list[str] = []
199+
for part in content:
200+
text_part = ChatController._coerce_message_content_to_text(
201+
part, _depth + 1
202+
)
203+
if text_part:
204+
parts.append(text_part)
205+
return "\n\n".join(parts)
206+
207+
if hasattr(content, "text"):
208+
text_attr = content.text
209+
if isinstance(text_attr, str):
210+
return text_attr
211+
if isinstance(text_attr, bytes | bytearray):
212+
return text_attr.decode("utf-8", errors="ignore")
213+
214+
return str(content)
215+
145216
async def handle_chat_completion(
146217
self,
147218
request: Request,
@@ -211,7 +282,9 @@ async def handle_chat_completion(
211282
# Normalize message content to str for AnthropicMessage
212283
anth_messages = []
213284
for m in domain_request.messages:
214-
content_str = m.content if isinstance(m.content, str) else ""
285+
content_str = ChatController._coerce_message_content_to_text(
286+
m.content
287+
)
215288
anth_messages.append(
216289
AnthropicMessage(role=m.role, content=content_str)
217290
)

src/core/services/backend_service.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -749,6 +749,25 @@ def _assign_from_obj(target: dict[str, Any], obj: Any, name: str) -> None:
749749
exc_info=True,
750750
)
751751

752+
# Apply one-shot overrides from edit-precision middleware with highest precedence
753+
try:
754+
extra_body = getattr(request, "extra_body", None)
755+
if isinstance(extra_body, dict) and extra_body.get(
756+
"_edit_precision_mode"
757+
):
758+
if getattr(request, "temperature", None) is not None:
759+
session_params["temperature"] = request.temperature
760+
if getattr(request, "top_p", None) is not None:
761+
session_params["top_p"] = request.top_p
762+
if getattr(request, "top_k", None) is not None:
763+
session_params["top_k"] = request.top_k
764+
except Exception as edit_precision_error:
765+
logger.debug(
766+
"Failed to apply edit-precision overrides to session parameters: %s",
767+
edit_precision_error,
768+
exc_info=True,
769+
)
770+
752771
# Resolve parameters using ParameterResolutionService
753772
try:
754773
resolution_service = ParameterResolutionService()
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
"""
2+
Tests for ChatController message content normalization functionality.
3+
"""
4+
5+
import json
6+
from typing import Any
7+
8+
from src.core.app.controllers.chat_controller import ChatController
9+
10+
11+
class TestCoerceMessageContentToText:
12+
"""Test cases for _coerce_message_content_to_text method."""
13+
14+
def test_coerce_message_content_to_text_handles_string(self) -> None:
15+
"""String content should be returned as-is."""
16+
content = "Hello, world!"
17+
result = ChatController._coerce_message_content_to_text(content)
18+
assert result == "Hello, world!"
19+
20+
def test_coerce_message_content_to_text_handles_bytes(self) -> None:
21+
"""Bytes content should be decoded as UTF-8."""
22+
content = b"Hello, world!"
23+
result = ChatController._coerce_message_content_to_text(content)
24+
assert result == "Hello, world!"
25+
26+
def test_coerce_message_content_to_text_handles_none(self) -> None:
27+
"""None input should return empty string."""
28+
result = ChatController._coerce_message_content_to_text(None)
29+
assert result == ""
30+
31+
def test_coerce_message_content_to_text_handles_empty_sequence(self) -> None:
32+
"""Empty sequences should return empty string."""
33+
result = ChatController._coerce_message_content_to_text([])
34+
assert result == ""
35+
36+
def test_coerce_message_content_to_text_handles_dict_with_text(self) -> None:
37+
"""Dict with text field should extract text value."""
38+
content = {"text": "Hello from dict"}
39+
result = ChatController._coerce_message_content_to_text(content)
40+
assert result == "Hello from dict"
41+
42+
def test_coerce_message_content_to_text_handles_dict_with_bytes_text(self) -> None:
43+
"""Dict with bytes text field should decode bytes."""
44+
content = {"text": b"Hello from bytes"}
45+
result = ChatController._coerce_message_content_to_text(content)
46+
assert result == "Hello from bytes"
47+
48+
def test_coerce_message_content_to_text_extracts_image_url(self) -> None:
49+
"""Image URL content should extract the URL string."""
50+
content = {
51+
"type": "image_url",
52+
"image_url": {"url": "https://example.com/image.png"},
53+
}
54+
result = ChatController._coerce_message_content_to_text(content)
55+
assert result == "https://example.com/image.png"
56+
57+
def test_coerce_message_content_to_text_handles_dict_without_text(self) -> None:
58+
"""Dict without text should JSON serialize."""
59+
content = {"key": "value", "number": 42}
60+
result = ChatController._coerce_message_content_to_text(content)
61+
assert result == json.dumps(content, ensure_ascii=False)
62+
63+
def test_coerce_message_content_to_text_handles_sequence(self) -> None:
64+
"""Sequence should flatten parts with double newlines."""
65+
content = ["Part 1", "Part 2", "Part 3"]
66+
result = ChatController._coerce_message_content_to_text(content)
67+
assert result == "Part 1\n\nPart 2\n\nPart 3"
68+
69+
def test_coerce_message_content_to_text_handles_nested_sequence(self) -> None:
70+
"""Nested sequences should be flattened recursively."""
71+
content = ["Outer 1", ["Inner 1", "Inner 2"], "Outer 2"]
72+
result = ChatController._coerce_message_content_to_text(content)
73+
assert result == "Outer 1\n\nInner 1\n\nInner 2\n\nOuter 2"
74+
75+
def test_coerce_message_content_to_text_handles_mixed_sequence(self) -> None:
76+
"""Mixed sequence should handle different types."""
77+
content = ["Text part", {"text": "Dict part"}, b"Bytes part"]
78+
result = ChatController._coerce_message_content_to_text(content)
79+
assert result == "Text part\n\nDict part\n\nBytes part"
80+
81+
def test_coerce_message_content_to_text_handles_object_with_model_dump(
82+
self,
83+
) -> None:
84+
"""Objects with model_dump should use dumped content."""
85+
86+
class TestModel:
87+
def model_dump(self) -> dict[str, Any]:
88+
return {"text": "From model_dump"}
89+
90+
content = TestModel()
91+
result = ChatController._coerce_message_content_to_text(content)
92+
assert result == "From model_dump"
93+
94+
def test_coerce_message_content_to_text_handles_object_with_text_attr(self) -> None:
95+
"""Objects with text attribute should return the text value."""
96+
97+
class CustomObject:
98+
text = "custom content"
99+
100+
result = ChatController._coerce_message_content_to_text(CustomObject())
101+
assert result == "custom content"
102+
103+
def test_coerce_message_content_to_text_handles_object_with_bytes_text_attr(
104+
self,
105+
) -> None:
106+
"""Objects with bytes text attribute should decode bytes."""
107+
108+
class CustomObject:
109+
text = b"custom content"
110+
111+
result = ChatController._coerce_message_content_to_text(CustomObject())
112+
assert result == "custom content"
113+
114+
def test_coerce_message_content_to_text_fallback_to_str(self) -> None:
115+
"""Unknown objects should fallback to str()."""
116+
117+
class CustomObject:
118+
def __str__(self) -> str:
119+
return "string representation"
120+
121+
result = ChatController._coerce_message_content_to_text(CustomObject())
122+
assert result == "string representation"
123+
124+
def test_coerce_message_content_to_text_handles_model_dump_exception(self) -> None:
125+
"""Objects with failing model_dump should continue processing."""
126+
127+
class TestModel:
128+
def model_dump(self) -> dict[str, Any]:
129+
raise RuntimeError("Dump failed")
130+
131+
content = TestModel()
132+
result = ChatController._coerce_message_content_to_text(content)
133+
assert result == str(content)
134+
135+
def test_coerce_message_content_to_text_prevents_stack_overflow(self) -> None:
136+
"""Circular references should not cause stack overflow."""
137+
# Create a circular reference
138+
content: dict[str, Any] = {}
139+
content["self"] = content
140+
141+
# This should not raise RecursionError but should handle circular reference gracefully
142+
result = ChatController._coerce_message_content_to_text(content)
143+
# Should return some string representation without infinite recursion
144+
assert isinstance(result, str)
145+
assert len(result) > 0
146+
# The result should contain some indication of the circular reference
147+
assert "Circular reference detected" in result

tests/unit/core/test_request_processor.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -530,6 +530,87 @@ def set_setting_side_effect(key: str, value: Any) -> None:
530530
)
531531

532532

533+
@pytest.mark.asyncio
534+
async def test_request_processor_applies_edit_precision_temperature_override() -> None:
535+
"""Ensure URI temperature is overridden on the next request after an edit failure."""
536+
537+
command_processor = MockCommandProcessor()
538+
session_manager = AsyncMock()
539+
backend_request_manager = AsyncMock()
540+
response_manager = AsyncMock()
541+
542+
session = AsyncMock(id="test-session", agent="roo")
543+
session_manager.resolve_session_id.return_value = "test-session"
544+
session_manager.get_session.return_value = session
545+
546+
from unittest.mock import MagicMock
547+
548+
from src.core.config.app_config import AppConfig, EditPrecisionConfig
549+
from src.core.interfaces.application_state_interface import IApplicationState
550+
551+
app_config = AppConfig(
552+
edit_precision=EditPrecisionConfig(
553+
enabled=True,
554+
temperature=0.0,
555+
min_top_p=0.2,
556+
override_top_p=True,
557+
)
558+
)
559+
560+
mock_app_state = MagicMock(spec=IApplicationState)
561+
app_state_store: dict[str, Any] = {
562+
"app_config": app_config,
563+
"edit_precision_pending": {"test-session": 1},
564+
"edit_precision_hybrid_reasoning_disabled": {"test-session": True},
565+
"edit_precision_hybrid_reasoning_active": {"test-session": {"timestamp": 0.0}},
566+
}
567+
568+
def get_setting_side_effect(key: str, default: Any | None = None) -> Any:
569+
return app_state_store.get(key, default)
570+
571+
def set_setting_side_effect(key: str, value: Any) -> None:
572+
app_state_store[key] = value
573+
574+
mock_app_state.get_setting.side_effect = get_setting_side_effect
575+
mock_app_state.set_setting.side_effect = set_setting_side_effect
576+
mock_app_state.get_command_prefix.return_value = "!/"
577+
578+
processor = RequestProcessor(
579+
command_processor,
580+
session_manager,
581+
backend_request_manager,
582+
response_manager,
583+
app_state=mock_app_state,
584+
)
585+
586+
request_data = ChatRequest(
587+
model="hybrid:[minimax:MiniMax-M2,qwen-oauth:qwen3-coder-plus?temperature=0.6]",
588+
messages=[ChatMessage(role="user", content="diff_error happened")],
589+
temperature=0.7,
590+
top_p=0.9,
591+
extra_body={"hybrid_reasoning_probability": 0.6},
592+
)
593+
594+
command_processor.add_result(
595+
ProcessedResult(
596+
modified_messages=request_data.messages,
597+
command_executed=False,
598+
command_results=[],
599+
)
600+
)
601+
602+
response = TestDataBuilder.create_chat_response("OK")
603+
backend_request_manager.prepare_backend_request.return_value = request_data
604+
backend_request_manager.process_backend_request.return_value = response
605+
606+
await processor.process_request(MockRequestContext(), request_data)
607+
608+
sent_request = backend_request_manager.process_backend_request.call_args[0][0]
609+
assert sent_request.temperature == pytest.approx(0.0)
610+
assert sent_request.top_p == pytest.approx(0.2)
611+
assert app_state_store.get("edit_precision_hybrid_reasoning_disabled", {}) == {}
612+
613+
533614
@pytest.mark.asyncio
534615
async def test_request_processor_respects_exclude_agents_regex() -> None:
535616
"""Ensure exclusion regex disables precision overrides for matching agents."""

0 commit comments

Comments
 (0)