From db61b6935820669f232f77100453a9783c9d6bce Mon Sep 17 00:00:00 2001 From: Yuchao Wang Date: Wed, 28 Oct 2026 07:10:28 +0000 Subject: [PATCH] fix(chat): preserve original operator material in native turns Signed-off-by: Yuchao Wang --- loopx/chat_agent.py | 4 ++-- tests/test_chat_agent.py | 32 ++++++++++++++++++++++++++ tests/test_chat_queue_preparation.py | 34 ++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/loopx/chat_agent.py b/loopx/chat_agent.py index 89fb2d2b52..3e8b5d5870 100644 --- a/loopx/chat_agent.py +++ b/loopx/chat_agent.py @@ -658,7 +658,7 @@ def _turn_prompt( if context_summary.strip() else "" ) - + f"Operator user message:\n{user_message.strip()}" + + f"Operator user message:\n{user_message}" ) @@ -1696,7 +1696,7 @@ def send( on_native_item: Callable[[dict[str, Any]], None] | None = None, turn_start_guard: Callable[[], AbstractContextManager[None]] | None = None, ) -> dict[str, Any]: - text = str(user_message or "") if project_collaboration else " ".join(str(user_message or "").split()) + text = str(user_message or "") if not text.strip(): raise ValueError("user message is required") if self.read_only_review: diff --git a/tests/test_chat_agent.py b/tests/test_chat_agent.py index e91767036c..145926a69a 100644 --- a/tests/test_chat_agent.py +++ b/tests/test_chat_agent.py @@ -21,6 +21,38 @@ import pytest +@pytest.mark.parametrize("mode", ["chat", "execution", "structured", "review"]) +def test_native_turn_preserves_operator_material(monkeypatch, tmp_path, mode): + material = " 修复下面的代码,保留字面值:\r\n```python\r\nif ready:\r\n\tprint('a b')\r\n```\n\nname:\u00a0value \n" + session = chat_agent.CodexChatAgentSession( + process=SimpleNamespace(poll=lambda: None), messages=queue.Queue(), + thread_id="material-thread", work_dir=tmp_path, + execution_mode=mode in {"execution", "structured"}, read_only_review=mode == "review", + ) + sent = [] + + def write(packet): + assert packet["method"] == "turn/start" + sent.append(packet["params"]) + session.messages.put({"id": packet["id"], "result": {"turn": {"id": "material-turn"}}}) + session.messages.put({"method": "item/completed", "params": {"item": { + "type": "agentMessage", "phase": "final_answer", "text": '{"answer":"received"}'}}}) + session.messages.put({"method": "turn/completed", "params": {"turn": {"status": "completed"}}}) + + monkeypatch.setattr(session, "_write", write) + session.send(material, output_schema={"type": "object"} if mode == "structured" else None) + prompt = sent[0]["input"][0]["text"] + assert prompt.endswith(material) + if mode == "structured": + assert prompt == material + if mode == "review": + assert "do not re-execute" in prompt + for blank in ["", " \r\n\t\u00a0"]: + with pytest.raises(ValueError, match="user message is required"): + session.send(blank) + assert len(sent) == 1 + + @pytest.mark.parametrize("fault", [None, "active", "completed", "final", "missing-phase", "correction", "unknown", "foreign", "duplicate", "live-tool", "late-items", "late-turn"]) def test_interrupted_result_review_requires_stable_full_inventory_without_final_or_live_effects(tmp_path, monkeypatch, fault): diff --git a/tests/test_chat_queue_preparation.py b/tests/test_chat_queue_preparation.py index 71f9415638..827ce82b20 100644 --- a/tests/test_chat_queue_preparation.py +++ b/tests/test_chat_queue_preparation.py @@ -38,6 +38,40 @@ def session_runtime(tmp_path, *, channel="goal.public-research"): ) +def test_restarted_queue_delivers_original_material_to_native_turn(tmp_path, monkeypatch): + import queue + from types import SimpleNamespace + from loopx.chat_agent import CodexChatAgentSession + + store, sid, _ = session_runtime(tmp_path) + material = "Explain this exact source:\n```python\nif ready:\n print('a b')\n```\n\n原文:a\u00a0b" + original, _ = store.create_queued_turn( + sid, client_turn_id="material-request", message=material, origin="web") + store = ChatSessionStore(tmp_path) + runtime = ChatRuntimeController(store=store, codex_bin="unused-test-codex") + native = CodexChatAgentSession(process=SimpleNamespace(poll=lambda: None), + messages=queue.Queue(), thread_id="public-thread", work_dir=tmp_path) + sent = [] + + def write(packet): + assert packet["method"] == "turn/start" + sent.append(packet["params"]) + native.messages.put({"id": packet["id"], "result": {"turn": {"id": "native-material"}}}) + native.messages.put({"method": "item/completed", "params": {"item": { + "type": "agentMessage", "phase": "final_answer", "text": "Received the source."}}}) + native.messages.put({"method": "turn/completed", "params": {"turn": {"status": "completed"}}}) + + monkeypatch.setattr(native, "_write", write) + monkeypatch.setattr(runtime, "_ensure_adapter", lambda *a, **kw: CodexAppServerAdapter(native)) + runtime.resume_session_queue(session_id=sid, work_dir=tmp_path, objective="Explain source") + result = runtime.wait_for_turn(session_id=sid, turn_id=original["turn_id"], timeout_sec=10) + assert result["status"] == "completed" + assert len(sent) == 1 and sent[0]["threadId"] == "public-thread" + assert sent[0]["input"][0]["text"].endswith(material) + assert store.turn_for_client(sid, "material-request")["message"] == material + assert store.messages(sid)[0]["text"] == material + + @pytest.mark.parametrize("failure", ["before_message", "after_message", "before_event"]) @pytest.mark.parametrize("display", ["Execution request", "Original visible request 原文"]) def test_queue_replay_leaves_prepared_managed_input_with_its_owner(tmp_path, monkeypatch, failure, display):