fix(streaming): avoid false no-response after final tool answer

This commit is contained in:
Jay Stothard 2026-07-12 16:49:24 +00:00
parent 3bf21c8ffb
commit fe222d4ba2
2 changed files with 113 additions and 15 deletions

View file

@ -2565,6 +2565,15 @@ def _looks_invalid_generated_title(text: str) -> bool:
)
def _message_content_part_text(part) -> str:
"""Extract visible text from a structured content part."""
if not isinstance(part, dict):
return ''
return str(
part.get('text') or part.get('content') or part.get('input_text') or part.get('output_text') or ''
)
def _message_text(value) -> str:
"""Extract plain text from mixed message content payloads."""
if isinstance(value, list):
@ -2574,11 +2583,43 @@ def _message_text(value) -> str:
continue
ptype = str(p.get('type') or '').lower()
if ptype in ('', 'text', 'input_text', 'output_text'):
parts.append(str(p.get('text') or p.get('content') or ''))
parts.append(_message_content_part_text(p))
return _strip_thinking_markup('\n'.join(parts).strip())
return _strip_thinking_markup(str(value or '').strip())
def _assistant_content_part_is_tool_use(part) -> bool:
"""Return True when a content[] part represents a tool invocation boundary."""
if not isinstance(part, dict):
return False
part_type = str(part.get('type') or '').lower()
if part_type in {'tool_use', 'tool_call'}:
return True
if part_type:
return False
return any(part.get(key) for key in ('tool_use_id', 'tool_call_id', 'call_id')) and bool(
part.get('name') or part.get('tool_name') or part.get('input') or part.get('args')
)
def _assistant_message_has_final_visible_text(message) -> bool:
"""Return True when an assistant row carries a settled visible answer."""
if not isinstance(message, dict) or message.get('role') != 'assistant':
return False
content = message.get('content', '')
if isinstance(content, list):
last_tool_idx = -1
for idx, part in enumerate(content):
if _assistant_content_part_is_tool_use(part):
last_tool_idx = idx
tail_parts = content[last_tool_idx + 1:] if last_tool_idx >= 0 else content
return bool(_message_text(tail_parts).strip())
if message.get('tool_calls'):
return False
return bool(_message_text(content).strip())
_WORKSPACE_PREFIX_RE = re.compile(r'^\s*\[Workspace::v1:\s*(?:\\.|[^\]\\])+\]\s*')
_LEGACY_WORKSPACE_PREFIX_RE = re.compile(r'^\s*\[Workspace:[^\]]+\]\s*')
_WORKSPACE_PREFIX_ANY_RE = re.compile(r'\[Workspace::v1:\s*(?:\\.|[^\]\\])+\]\s*')
@ -4873,7 +4914,7 @@ def _raw_message_text(value) -> str:
"""
if isinstance(value, list):
return ' '.join(
str(p.get('text') or p.get('content') or '')
_message_content_part_text(p)
for p in value
if isinstance(p, dict)
)
@ -5582,7 +5623,7 @@ def _assistant_reply_added_after_current_turn(result_messages, previous_context,
isinstance(m, dict)
and m.get('role') == 'assistant'
and not m.get('_error')
and str(m.get('content') or '').strip()
and _assistant_message_has_final_visible_text(m)
for m in candidates
)
@ -5600,18 +5641,7 @@ def _session_lacks_final_assistant_answer(messages) -> bool:
if role == 'tool':
return True
if role == 'assistant':
content = msg.get('content')
if isinstance(content, list):
text = '\n'.join(
str(part.get('text') or part.get('content') or '')
for part in content
if isinstance(part, dict)
)
else:
text = str(content or '')
if msg.get('tool_calls'):
return True
if text.strip():
if _assistant_message_has_final_visible_text(msg):
return False
continue
if role == 'user':

View file

@ -0,0 +1,68 @@
from api import streaming
PRIOR_DISPLAY = [
{"role": "user", "content": "Earlier request"},
{"role": "assistant", "content": "Earlier reply"},
]
class TestIssue5686CompletedOutputText:
def test_assistant_reply_added_after_current_turn_counts_visible_output_text_after_tool_metadata(self):
previous_context = list(PRIOR_DISPLAY) + [
{"role": "user", "content": "What is the weather?"},
]
result_messages = list(previous_context) + [
{
"role": "assistant",
"content": [
{"type": "tool_use", "name": "weather.lookup", "input": {"city": "Leeds"}},
{"type": "output_text", "output_text": "It is 18C and sunny."},
],
"tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "weather.lookup", "arguments": "{}"}}],
}
]
assert streaming._assistant_reply_added_after_current_turn(
result_messages,
previous_context,
"What is the weather?",
) is True
def test_session_lacks_final_answer_false_when_visible_output_text_follows_tool_use(self):
messages = list(PRIOR_DISPLAY) + [
{"role": "user", "content": "What is the weather?"},
{
"role": "assistant",
"content": [
{"type": "tool_use", "name": "weather.lookup", "input": {"city": "Leeds"}},
{"type": "output_text", "output_text": "It is 18C and sunny."},
],
"tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "weather.lookup", "arguments": "{}"}}],
},
]
assert streaming._session_lacks_final_assistant_answer(messages) is False
def test_session_lacks_final_answer_true_for_tool_only_assistant_content(self):
messages = list(PRIOR_DISPLAY) + [
{"role": "user", "content": "What is the weather?"},
{
"role": "assistant",
"content": [
{"type": "tool_use", "name": "weather.lookup", "input": {"city": "Leeds"}},
],
"tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "weather.lookup", "arguments": "{}"}}],
},
]
assert streaming._session_lacks_final_assistant_answer(messages) is True
def test_session_lacks_final_answer_false_for_plain_output_text_parts(self):
messages = list(PRIOR_DISPLAY) + [
{"role": "user", "content": "Summarize"},
{
"role": "assistant",
"content": [
{"type": "output_text", "output_text": "Summary complete."},
],
},
]
assert streaming._session_lacks_final_assistant_answer(messages) is False