Fix Codex Responses continuation and schema parity
This commit is contained in:
parent
609b19b630
commit
ce175d7372
2 changed files with 432 additions and 14 deletions
232
run_agent.py
232
run_agent.py
|
|
@ -21,6 +21,7 @@ Usage:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import copy
|
import copy
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -483,6 +484,54 @@ class AIAgent:
|
||||||
return ""
|
return ""
|
||||||
return re.sub(r'<think>.*?</think>', '', content, flags=re.DOTALL)
|
return re.sub(r'<think>.*?</think>', '', content, flags=re.DOTALL)
|
||||||
|
|
||||||
|
def _looks_like_codex_intermediate_ack(
|
||||||
|
self,
|
||||||
|
user_message: str,
|
||||||
|
assistant_content: str,
|
||||||
|
messages: List[Dict[str, Any]],
|
||||||
|
) -> bool:
|
||||||
|
"""Detect a planning/ack message that should continue instead of ending the turn."""
|
||||||
|
if any(isinstance(msg, dict) and msg.get("role") == "tool" for msg in messages):
|
||||||
|
return False
|
||||||
|
|
||||||
|
assistant_text = self._strip_think_blocks(assistant_content or "").strip().lower()
|
||||||
|
if not assistant_text:
|
||||||
|
return False
|
||||||
|
if len(assistant_text) > 1200:
|
||||||
|
return False
|
||||||
|
|
||||||
|
has_future_ack = bool(
|
||||||
|
re.search(r"\b(i['’]ll|i will|let me|i can do that|i can help with that)\b", assistant_text)
|
||||||
|
)
|
||||||
|
if not has_future_ack:
|
||||||
|
return False
|
||||||
|
|
||||||
|
action_markers = (
|
||||||
|
"look into",
|
||||||
|
"inspect",
|
||||||
|
"scan",
|
||||||
|
"check",
|
||||||
|
"analyz",
|
||||||
|
"review",
|
||||||
|
"explore",
|
||||||
|
"read",
|
||||||
|
"open",
|
||||||
|
"run",
|
||||||
|
"test",
|
||||||
|
"fix",
|
||||||
|
"debug",
|
||||||
|
"search",
|
||||||
|
"find",
|
||||||
|
"walkthrough",
|
||||||
|
"report back",
|
||||||
|
"summarize",
|
||||||
|
)
|
||||||
|
|
||||||
|
user_text = (user_message or "").strip().lower()
|
||||||
|
user_requests_action = any(marker in user_text for marker in action_markers) or "~/" in user_text or "/" in user_text
|
||||||
|
assistant_mentions_action = any(marker in assistant_text for marker in action_markers)
|
||||||
|
return user_requests_action and assistant_mentions_action
|
||||||
|
|
||||||
|
|
||||||
def _extract_reasoning(self, assistant_message) -> Optional[str]:
|
def _extract_reasoning(self, assistant_message) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
|
|
@ -1154,10 +1203,57 @@ class AIAgent:
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"name": name,
|
"name": name,
|
||||||
"description": fn.get("description", ""),
|
"description": fn.get("description", ""),
|
||||||
|
"strict": False,
|
||||||
"parameters": fn.get("parameters", {"type": "object", "properties": {}}),
|
"parameters": fn.get("parameters", {"type": "object", "properties": {}}),
|
||||||
})
|
})
|
||||||
return converted or None
|
return converted or None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _split_responses_tool_id(raw_id: Any) -> tuple[Optional[str], Optional[str]]:
|
||||||
|
"""Split a stored tool id into (call_id, response_item_id)."""
|
||||||
|
if not isinstance(raw_id, str):
|
||||||
|
return None, None
|
||||||
|
value = raw_id.strip()
|
||||||
|
if not value:
|
||||||
|
return None, None
|
||||||
|
if "|" in value:
|
||||||
|
call_id, response_item_id = value.split("|", 1)
|
||||||
|
call_id = call_id.strip() or None
|
||||||
|
response_item_id = response_item_id.strip() or None
|
||||||
|
return call_id, response_item_id
|
||||||
|
if value.startswith("fc_"):
|
||||||
|
return None, value
|
||||||
|
return value, None
|
||||||
|
|
||||||
|
def _derive_responses_function_call_id(
|
||||||
|
self,
|
||||||
|
call_id: str,
|
||||||
|
response_item_id: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Build a valid Responses `function_call.id` (must start with `fc_`)."""
|
||||||
|
if isinstance(response_item_id, str):
|
||||||
|
candidate = response_item_id.strip()
|
||||||
|
if candidate.startswith("fc_"):
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
source = (call_id or "").strip()
|
||||||
|
if source.startswith("fc_"):
|
||||||
|
return source
|
||||||
|
if source.startswith("call_") and len(source) > len("call_"):
|
||||||
|
return f"fc_{source[len('call_'):]}"
|
||||||
|
|
||||||
|
sanitized = re.sub(r"[^A-Za-z0-9_-]", "", source)
|
||||||
|
if sanitized.startswith("fc_"):
|
||||||
|
return sanitized
|
||||||
|
if sanitized.startswith("call_") and len(sanitized) > len("call_"):
|
||||||
|
return f"fc_{sanitized[len('call_'):]}"
|
||||||
|
if sanitized:
|
||||||
|
return f"fc_{sanitized[:48]}"
|
||||||
|
|
||||||
|
seed = source or str(response_item_id or "") or uuid.uuid4().hex
|
||||||
|
digest = hashlib.sha1(seed.encode("utf-8")).hexdigest()[:24]
|
||||||
|
return f"fc_{digest}"
|
||||||
|
|
||||||
def _chat_messages_to_responses_input(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
def _chat_messages_to_responses_input(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||||
"""Convert internal chat-style messages to Responses input items."""
|
"""Convert internal chat-style messages to Responses input items."""
|
||||||
items: List[Dict[str, Any]] = []
|
items: List[Dict[str, Any]] = []
|
||||||
|
|
@ -1187,9 +1283,32 @@ class AIAgent:
|
||||||
if not isinstance(fn_name, str) or not fn_name.strip():
|
if not isinstance(fn_name, str) or not fn_name.strip():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
call_id = tc.get("id") or tc.get("call_id")
|
embedded_call_id, embedded_response_item_id = self._split_responses_tool_id(
|
||||||
|
tc.get("id")
|
||||||
|
)
|
||||||
|
call_id = tc.get("call_id")
|
||||||
if not isinstance(call_id, str) or not call_id.strip():
|
if not isinstance(call_id, str) or not call_id.strip():
|
||||||
|
call_id = embedded_call_id
|
||||||
|
if not isinstance(call_id, str) or not call_id.strip():
|
||||||
|
if (
|
||||||
|
isinstance(embedded_response_item_id, str)
|
||||||
|
and embedded_response_item_id.startswith("fc_")
|
||||||
|
and len(embedded_response_item_id) > len("fc_")
|
||||||
|
):
|
||||||
|
call_id = f"call_{embedded_response_item_id[len('fc_'):]}"
|
||||||
|
else:
|
||||||
call_id = f"call_{uuid.uuid4().hex[:12]}"
|
call_id = f"call_{uuid.uuid4().hex[:12]}"
|
||||||
|
call_id = call_id.strip()
|
||||||
|
|
||||||
|
response_item_id = tc.get("response_item_id")
|
||||||
|
if not isinstance(response_item_id, str) or not response_item_id.strip():
|
||||||
|
response_item_id = tc.get("responses_item_id")
|
||||||
|
if not isinstance(response_item_id, str) or not response_item_id.strip():
|
||||||
|
response_item_id = embedded_response_item_id
|
||||||
|
response_item_id = self._derive_responses_function_call_id(
|
||||||
|
call_id,
|
||||||
|
response_item_id if isinstance(response_item_id, str) else None,
|
||||||
|
)
|
||||||
|
|
||||||
arguments = fn.get("arguments", "{}")
|
arguments = fn.get("arguments", "{}")
|
||||||
if isinstance(arguments, dict):
|
if isinstance(arguments, dict):
|
||||||
|
|
@ -1200,7 +1319,7 @@ class AIAgent:
|
||||||
|
|
||||||
items.append({
|
items.append({
|
||||||
"type": "function_call",
|
"type": "function_call",
|
||||||
"id": call_id,
|
"id": response_item_id,
|
||||||
"call_id": call_id,
|
"call_id": call_id,
|
||||||
"name": fn_name,
|
"name": fn_name,
|
||||||
"arguments": arguments,
|
"arguments": arguments,
|
||||||
|
|
@ -1211,7 +1330,11 @@ class AIAgent:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if role == "tool":
|
if role == "tool":
|
||||||
call_id = msg.get("tool_call_id")
|
raw_tool_call_id = msg.get("tool_call_id")
|
||||||
|
call_id, _ = self._split_responses_tool_id(raw_tool_call_id)
|
||||||
|
if not isinstance(call_id, str) or not call_id.strip():
|
||||||
|
if isinstance(raw_tool_call_id, str) and raw_tool_call_id.strip():
|
||||||
|
call_id = raw_tool_call_id.strip()
|
||||||
if not isinstance(call_id, str) or not call_id.strip():
|
if not isinstance(call_id, str) or not call_id.strip():
|
||||||
continue
|
continue
|
||||||
items.append({
|
items.append({
|
||||||
|
|
@ -1278,6 +1401,8 @@ class AIAgent:
|
||||||
reasoning_parts: List[str] = []
|
reasoning_parts: List[str] = []
|
||||||
tool_calls: List[Any] = []
|
tool_calls: List[Any] = []
|
||||||
has_incomplete_items = response_status in {"queued", "in_progress", "incomplete"}
|
has_incomplete_items = response_status in {"queued", "in_progress", "incomplete"}
|
||||||
|
saw_commentary_phase = False
|
||||||
|
saw_final_answer_phase = False
|
||||||
|
|
||||||
for item in output:
|
for item in output:
|
||||||
item_type = getattr(item, "type", None)
|
item_type = getattr(item, "type", None)
|
||||||
|
|
@ -1291,6 +1416,13 @@ class AIAgent:
|
||||||
has_incomplete_items = True
|
has_incomplete_items = True
|
||||||
|
|
||||||
if item_type == "message":
|
if item_type == "message":
|
||||||
|
item_phase = getattr(item, "phase", None)
|
||||||
|
if isinstance(item_phase, str):
|
||||||
|
normalized_phase = item_phase.strip().lower()
|
||||||
|
if normalized_phase in {"commentary", "analysis"}:
|
||||||
|
saw_commentary_phase = True
|
||||||
|
elif normalized_phase in {"final_answer", "final"}:
|
||||||
|
saw_final_answer_phase = True
|
||||||
message_text = self._extract_responses_message_text(item)
|
message_text = self._extract_responses_message_text(item)
|
||||||
if message_text:
|
if message_text:
|
||||||
content_parts.append(message_text)
|
content_parts.append(message_text)
|
||||||
|
|
@ -1305,9 +1437,19 @@ class AIAgent:
|
||||||
arguments = getattr(item, "arguments", "{}")
|
arguments = getattr(item, "arguments", "{}")
|
||||||
if not isinstance(arguments, str):
|
if not isinstance(arguments, str):
|
||||||
arguments = str(arguments)
|
arguments = str(arguments)
|
||||||
call_id = getattr(item, "call_id", None) or getattr(item, "id", None) or f"call_{uuid.uuid4().hex[:12]}"
|
raw_call_id = getattr(item, "call_id", None)
|
||||||
|
raw_item_id = getattr(item, "id", None)
|
||||||
|
embedded_call_id, _ = self._split_responses_tool_id(raw_item_id)
|
||||||
|
call_id = raw_call_id if isinstance(raw_call_id, str) and raw_call_id.strip() else embedded_call_id
|
||||||
|
if not isinstance(call_id, str) or not call_id.strip():
|
||||||
|
call_id = f"call_{uuid.uuid4().hex[:12]}"
|
||||||
|
call_id = call_id.strip()
|
||||||
|
response_item_id = raw_item_id if isinstance(raw_item_id, str) else None
|
||||||
|
response_item_id = self._derive_responses_function_call_id(call_id, response_item_id)
|
||||||
tool_calls.append(SimpleNamespace(
|
tool_calls.append(SimpleNamespace(
|
||||||
id=call_id,
|
id=call_id,
|
||||||
|
call_id=call_id,
|
||||||
|
response_item_id=response_item_id,
|
||||||
type="function",
|
type="function",
|
||||||
function=SimpleNamespace(name=fn_name, arguments=arguments),
|
function=SimpleNamespace(name=fn_name, arguments=arguments),
|
||||||
))
|
))
|
||||||
|
|
@ -1316,9 +1458,19 @@ class AIAgent:
|
||||||
arguments = getattr(item, "input", "{}")
|
arguments = getattr(item, "input", "{}")
|
||||||
if not isinstance(arguments, str):
|
if not isinstance(arguments, str):
|
||||||
arguments = str(arguments)
|
arguments = str(arguments)
|
||||||
call_id = getattr(item, "call_id", None) or getattr(item, "id", None) or f"call_{uuid.uuid4().hex[:12]}"
|
raw_call_id = getattr(item, "call_id", None)
|
||||||
|
raw_item_id = getattr(item, "id", None)
|
||||||
|
embedded_call_id, _ = self._split_responses_tool_id(raw_item_id)
|
||||||
|
call_id = raw_call_id if isinstance(raw_call_id, str) and raw_call_id.strip() else embedded_call_id
|
||||||
|
if not isinstance(call_id, str) or not call_id.strip():
|
||||||
|
call_id = f"call_{uuid.uuid4().hex[:12]}"
|
||||||
|
call_id = call_id.strip()
|
||||||
|
response_item_id = raw_item_id if isinstance(raw_item_id, str) else None
|
||||||
|
response_item_id = self._derive_responses_function_call_id(call_id, response_item_id)
|
||||||
tool_calls.append(SimpleNamespace(
|
tool_calls.append(SimpleNamespace(
|
||||||
id=call_id,
|
id=call_id,
|
||||||
|
call_id=call_id,
|
||||||
|
response_item_id=response_item_id,
|
||||||
type="function",
|
type="function",
|
||||||
function=SimpleNamespace(name=fn_name, arguments=arguments),
|
function=SimpleNamespace(name=fn_name, arguments=arguments),
|
||||||
))
|
))
|
||||||
|
|
@ -1339,7 +1491,7 @@ class AIAgent:
|
||||||
|
|
||||||
if tool_calls:
|
if tool_calls:
|
||||||
finish_reason = "tool_calls"
|
finish_reason = "tool_calls"
|
||||||
elif has_incomplete_items:
|
elif has_incomplete_items or (saw_commentary_phase and not saw_final_answer_phase):
|
||||||
finish_reason = "incomplete"
|
finish_reason = "incomplete"
|
||||||
else:
|
else:
|
||||||
finish_reason = "stop"
|
finish_reason = "stop"
|
||||||
|
|
@ -1484,17 +1636,42 @@ class AIAgent:
|
||||||
]
|
]
|
||||||
|
|
||||||
if assistant_message.tool_calls:
|
if assistant_message.tool_calls:
|
||||||
msg["tool_calls"] = [
|
tool_calls = []
|
||||||
{
|
for tool_call in assistant_message.tool_calls:
|
||||||
"id": tool_call.id,
|
raw_id = getattr(tool_call, "id", None)
|
||||||
|
call_id = getattr(tool_call, "call_id", None)
|
||||||
|
if not isinstance(call_id, str) or not call_id.strip():
|
||||||
|
embedded_call_id, _ = self._split_responses_tool_id(raw_id)
|
||||||
|
call_id = embedded_call_id
|
||||||
|
if not isinstance(call_id, str) or not call_id.strip():
|
||||||
|
if isinstance(raw_id, str) and raw_id.strip():
|
||||||
|
call_id = raw_id.strip()
|
||||||
|
else:
|
||||||
|
call_id = f"call_{uuid.uuid4().hex[:12]}"
|
||||||
|
call_id = call_id.strip()
|
||||||
|
|
||||||
|
response_item_id = getattr(tool_call, "response_item_id", None)
|
||||||
|
if not isinstance(response_item_id, str) or not response_item_id.strip():
|
||||||
|
_, embedded_response_item_id = self._split_responses_tool_id(raw_id)
|
||||||
|
response_item_id = embedded_response_item_id
|
||||||
|
|
||||||
|
response_item_id = self._derive_responses_function_call_id(
|
||||||
|
call_id,
|
||||||
|
response_item_id if isinstance(response_item_id, str) else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
tool_calls.append({
|
||||||
|
"id": call_id,
|
||||||
|
"call_id": call_id,
|
||||||
|
"response_item_id": response_item_id,
|
||||||
"type": tool_call.type,
|
"type": tool_call.type,
|
||||||
"function": {
|
"function": {
|
||||||
"name": tool_call.function.name,
|
"name": tool_call.function.name,
|
||||||
"arguments": tool_call.function.arguments
|
"arguments": tool_call.function.arguments
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
for tool_call in assistant_message.tool_calls
|
msg["tool_calls"] = tool_calls
|
||||||
]
|
|
||||||
|
|
||||||
return msg
|
return msg
|
||||||
|
|
||||||
|
|
@ -2021,6 +2198,7 @@ class AIAgent:
|
||||||
api_call_count = 0
|
api_call_count = 0
|
||||||
final_response = None
|
final_response = None
|
||||||
interrupted = False
|
interrupted = False
|
||||||
|
codex_ack_continuations = 0
|
||||||
|
|
||||||
# Clear any stale interrupt state at start
|
# Clear any stale interrupt state at start
|
||||||
self.clear_interrupt()
|
self.clear_interrupt()
|
||||||
|
|
@ -2743,6 +2921,36 @@ class AIAgent:
|
||||||
if hasattr(self, '_empty_content_retries'):
|
if hasattr(self, '_empty_content_retries'):
|
||||||
self._empty_content_retries = 0
|
self._empty_content_retries = 0
|
||||||
|
|
||||||
|
if (
|
||||||
|
self.api_mode == "codex_responses"
|
||||||
|
and self.valid_tool_names
|
||||||
|
and codex_ack_continuations < 2
|
||||||
|
and self._looks_like_codex_intermediate_ack(
|
||||||
|
user_message=user_message,
|
||||||
|
assistant_content=final_response,
|
||||||
|
messages=messages,
|
||||||
|
)
|
||||||
|
):
|
||||||
|
codex_ack_continuations += 1
|
||||||
|
interim_msg = self._build_assistant_message(assistant_message, "incomplete")
|
||||||
|
messages.append(interim_msg)
|
||||||
|
self._log_msg_to_db(interim_msg)
|
||||||
|
|
||||||
|
continue_msg = {
|
||||||
|
"role": "user",
|
||||||
|
"content": (
|
||||||
|
"[System: Continue now. Execute the required tool calls and only "
|
||||||
|
"send your final answer after completing the task.]"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
messages.append(continue_msg)
|
||||||
|
self._log_msg_to_db(continue_msg)
|
||||||
|
self._session_messages = messages
|
||||||
|
self._save_session_log(messages)
|
||||||
|
continue
|
||||||
|
|
||||||
|
codex_ack_continuations = 0
|
||||||
|
|
||||||
final_msg = self._build_assistant_message(assistant_message, finish_reason)
|
final_msg = self._build_assistant_message(assistant_message, finish_reason)
|
||||||
|
|
||||||
messages.append(final_msg)
|
messages.append(final_msg)
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ def _codex_tool_call_response():
|
||||||
output=[
|
output=[
|
||||||
SimpleNamespace(
|
SimpleNamespace(
|
||||||
type="function_call",
|
type="function_call",
|
||||||
id="call_1",
|
id="fc_1",
|
||||||
call_id="call_1",
|
call_id="call_1",
|
||||||
name="terminal",
|
name="terminal",
|
||||||
arguments="{}",
|
arguments="{}",
|
||||||
|
|
@ -93,6 +93,37 @@ def _codex_incomplete_message_response(text: str):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _codex_commentary_message_response(text: str):
|
||||||
|
return SimpleNamespace(
|
||||||
|
output=[
|
||||||
|
SimpleNamespace(
|
||||||
|
type="message",
|
||||||
|
phase="commentary",
|
||||||
|
status="completed",
|
||||||
|
content=[SimpleNamespace(type="output_text", text=text)],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
usage=SimpleNamespace(input_tokens=4, output_tokens=2, total_tokens=6),
|
||||||
|
status="completed",
|
||||||
|
model="gpt-5-codex",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _codex_ack_message_response(text: str):
|
||||||
|
return SimpleNamespace(
|
||||||
|
output=[
|
||||||
|
SimpleNamespace(
|
||||||
|
type="message",
|
||||||
|
status="completed",
|
||||||
|
content=[SimpleNamespace(type="output_text", text=text)],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
usage=SimpleNamespace(input_tokens=4, output_tokens=2, total_tokens=6),
|
||||||
|
status="completed",
|
||||||
|
model="gpt-5-codex",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_api_mode_uses_explicit_provider_when_codex(monkeypatch):
|
def test_api_mode_uses_explicit_provider_when_codex(monkeypatch):
|
||||||
_patch_agent_bootstrap(monkeypatch)
|
_patch_agent_bootstrap(monkeypatch)
|
||||||
agent = run_agent.AIAgent(
|
agent = run_agent.AIAgent(
|
||||||
|
|
@ -157,6 +188,7 @@ def test_build_api_kwargs_codex(monkeypatch):
|
||||||
assert kwargs["input"][0]["role"] == "user"
|
assert kwargs["input"][0]["role"] == "user"
|
||||||
assert kwargs["tools"][0]["type"] == "function"
|
assert kwargs["tools"][0]["type"] == "function"
|
||||||
assert kwargs["tools"][0]["name"] == "terminal"
|
assert kwargs["tools"][0]["name"] == "terminal"
|
||||||
|
assert kwargs["tools"][0]["strict"] is False
|
||||||
assert "function" not in kwargs["tools"][0]
|
assert "function" not in kwargs["tools"][0]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -197,6 +229,99 @@ def test_run_conversation_codex_tool_round_trip(monkeypatch):
|
||||||
assert any(msg.get("role") == "tool" and msg.get("tool_call_id") == "call_1" for msg in result["messages"])
|
assert any(msg.get("role") == "tool" and msg.get("tool_call_id") == "call_1" for msg in result["messages"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_messages_to_responses_input_uses_fc_id_for_function_call(monkeypatch):
|
||||||
|
agent = _build_agent(monkeypatch)
|
||||||
|
items = agent._chat_messages_to_responses_input(
|
||||||
|
[
|
||||||
|
{"role": "user", "content": "Run terminal"},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "",
|
||||||
|
"tool_calls": [
|
||||||
|
{
|
||||||
|
"id": "call_abc123",
|
||||||
|
"type": "function",
|
||||||
|
"function": {"name": "terminal", "arguments": "{}"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{"role": "tool", "tool_call_id": "call_abc123", "content": '{"ok":true}'},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
function_call = next(item for item in items if item.get("type") == "function_call")
|
||||||
|
function_output = next(item for item in items if item.get("type") == "function_call_output")
|
||||||
|
|
||||||
|
assert function_call["call_id"] == "call_abc123"
|
||||||
|
assert function_call["id"] == "fc_abc123"
|
||||||
|
assert function_output["call_id"] == "call_abc123"
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_messages_to_responses_input_accepts_call_pipe_fc_ids(monkeypatch):
|
||||||
|
agent = _build_agent(monkeypatch)
|
||||||
|
items = agent._chat_messages_to_responses_input(
|
||||||
|
[
|
||||||
|
{"role": "user", "content": "Run terminal"},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "",
|
||||||
|
"tool_calls": [
|
||||||
|
{
|
||||||
|
"id": "call_pair123|fc_pair123",
|
||||||
|
"type": "function",
|
||||||
|
"function": {"name": "terminal", "arguments": "{}"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{"role": "tool", "tool_call_id": "call_pair123|fc_pair123", "content": '{"ok":true}'},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
function_call = next(item for item in items if item.get("type") == "function_call")
|
||||||
|
function_output = next(item for item in items if item.get("type") == "function_call_output")
|
||||||
|
|
||||||
|
assert function_call["call_id"] == "call_pair123"
|
||||||
|
assert function_call["id"] == "fc_pair123"
|
||||||
|
assert function_output["call_id"] == "call_pair123"
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_conversation_codex_replay_payload_keeps_call_id_and_fc_id(monkeypatch):
|
||||||
|
agent = _build_agent(monkeypatch)
|
||||||
|
responses = [_codex_tool_call_response(), _codex_message_response("done")]
|
||||||
|
requests = []
|
||||||
|
|
||||||
|
def _fake_api_call(api_kwargs):
|
||||||
|
requests.append(api_kwargs)
|
||||||
|
return responses.pop(0)
|
||||||
|
|
||||||
|
monkeypatch.setattr(agent, "_interruptible_api_call", _fake_api_call)
|
||||||
|
|
||||||
|
def _fake_execute_tool_calls(assistant_message, messages, effective_task_id):
|
||||||
|
for call in assistant_message.tool_calls:
|
||||||
|
messages.append(
|
||||||
|
{
|
||||||
|
"role": "tool",
|
||||||
|
"tool_call_id": call.id,
|
||||||
|
"content": '{"ok":true}',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(agent, "_execute_tool_calls", _fake_execute_tool_calls)
|
||||||
|
|
||||||
|
result = agent.run_conversation("run a command")
|
||||||
|
|
||||||
|
assert result["completed"] is True
|
||||||
|
assert result["final_response"] == "done"
|
||||||
|
assert len(requests) >= 2
|
||||||
|
|
||||||
|
replay_input = requests[1]["input"]
|
||||||
|
function_call = next(item for item in replay_input if item.get("type") == "function_call")
|
||||||
|
function_output = next(item for item in replay_input if item.get("type") == "function_call_output")
|
||||||
|
assert function_call["call_id"] == "call_1"
|
||||||
|
assert function_call["id"] == "fc_1"
|
||||||
|
assert function_output["call_id"] == "call_1"
|
||||||
|
|
||||||
|
|
||||||
def test_run_conversation_codex_continues_after_incomplete_interim_message(monkeypatch):
|
def test_run_conversation_codex_continues_after_incomplete_interim_message(monkeypatch):
|
||||||
agent = _build_agent(monkeypatch)
|
agent = _build_agent(monkeypatch)
|
||||||
responses = [
|
responses = [
|
||||||
|
|
@ -229,3 +354,88 @@ def test_run_conversation_codex_continues_after_incomplete_interim_message(monke
|
||||||
for msg in result["messages"]
|
for msg in result["messages"]
|
||||||
)
|
)
|
||||||
assert any(msg.get("role") == "tool" and msg.get("tool_call_id") == "call_1" for msg in result["messages"])
|
assert any(msg.get("role") == "tool" and msg.get("tool_call_id") == "call_1" for msg in result["messages"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_codex_response_marks_commentary_only_message_as_incomplete(monkeypatch):
|
||||||
|
agent = _build_agent(monkeypatch)
|
||||||
|
assistant_message, finish_reason = agent._normalize_codex_response(
|
||||||
|
_codex_commentary_message_response("I'll inspect the repository first.")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert finish_reason == "incomplete"
|
||||||
|
assert "inspect the repository" in (assistant_message.content or "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_conversation_codex_continues_after_commentary_phase_message(monkeypatch):
|
||||||
|
agent = _build_agent(monkeypatch)
|
||||||
|
responses = [
|
||||||
|
_codex_commentary_message_response("I'll inspect the repo structure first."),
|
||||||
|
_codex_tool_call_response(),
|
||||||
|
_codex_message_response("Architecture summary complete."),
|
||||||
|
]
|
||||||
|
monkeypatch.setattr(agent, "_interruptible_api_call", lambda api_kwargs: responses.pop(0))
|
||||||
|
|
||||||
|
def _fake_execute_tool_calls(assistant_message, messages, effective_task_id):
|
||||||
|
for call in assistant_message.tool_calls:
|
||||||
|
messages.append(
|
||||||
|
{
|
||||||
|
"role": "tool",
|
||||||
|
"tool_call_id": call.id,
|
||||||
|
"content": '{"ok":true}',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(agent, "_execute_tool_calls", _fake_execute_tool_calls)
|
||||||
|
|
||||||
|
result = agent.run_conversation("analyze repo")
|
||||||
|
|
||||||
|
assert result["completed"] is True
|
||||||
|
assert result["final_response"] == "Architecture summary complete."
|
||||||
|
assert any(
|
||||||
|
msg.get("role") == "assistant"
|
||||||
|
and msg.get("finish_reason") == "incomplete"
|
||||||
|
and "inspect the repo structure" in (msg.get("content") or "")
|
||||||
|
for msg in result["messages"]
|
||||||
|
)
|
||||||
|
assert any(msg.get("role") == "tool" and msg.get("tool_call_id") == "call_1" for msg in result["messages"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_conversation_codex_continues_after_ack_stop_message(monkeypatch):
|
||||||
|
agent = _build_agent(monkeypatch)
|
||||||
|
responses = [
|
||||||
|
_codex_ack_message_response(
|
||||||
|
"Absolutely — I can do that. I'll inspect ~/openclaw-studio and report back with a walkthrough."
|
||||||
|
),
|
||||||
|
_codex_tool_call_response(),
|
||||||
|
_codex_message_response("Architecture summary complete."),
|
||||||
|
]
|
||||||
|
monkeypatch.setattr(agent, "_interruptible_api_call", lambda api_kwargs: responses.pop(0))
|
||||||
|
|
||||||
|
def _fake_execute_tool_calls(assistant_message, messages, effective_task_id):
|
||||||
|
for call in assistant_message.tool_calls:
|
||||||
|
messages.append(
|
||||||
|
{
|
||||||
|
"role": "tool",
|
||||||
|
"tool_call_id": call.id,
|
||||||
|
"content": '{"ok":true}',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(agent, "_execute_tool_calls", _fake_execute_tool_calls)
|
||||||
|
|
||||||
|
result = agent.run_conversation("look into ~/openclaw-studio and tell me how it works")
|
||||||
|
|
||||||
|
assert result["completed"] is True
|
||||||
|
assert result["final_response"] == "Architecture summary complete."
|
||||||
|
assert any(
|
||||||
|
msg.get("role") == "assistant"
|
||||||
|
and msg.get("finish_reason") == "incomplete"
|
||||||
|
and "inspect ~/openclaw-studio" in (msg.get("content") or "")
|
||||||
|
for msg in result["messages"]
|
||||||
|
)
|
||||||
|
assert any(
|
||||||
|
msg.get("role") == "user"
|
||||||
|
and "Continue now. Execute the required tool calls" in (msg.get("content") or "")
|
||||||
|
for msg in result["messages"]
|
||||||
|
)
|
||||||
|
assert any(msg.get("role") == "tool" and msg.get("tool_call_id") == "call_1" for msg in result["messages"])
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue