From 015b1f24f9243bbca7fa3bf6395809b6a6c3ebe4 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:53:21 +0530 Subject: [PATCH] fix(chat): preserve answers with invalid legacy handoff metadata Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/chat.py | 7 ++++-- tests/test_chat_ordinary_project.py | 35 +++++++++++++++++++++++++++++ tests/test_chat_response_parsing.py | 35 +++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/loopx/chat.py b/loopx/chat.py index 43dd95fd00..8cbf7c38ad 100644 --- a/loopx/chat.py +++ b/loopx/chat.py @@ -837,10 +837,13 @@ def parse_agent_response( # A bad action envelope must not discard an already generated # answer or turn a formatting failure into a business retry. # Preserve prose, but admit none of the unvalidated actions. - if not isinstance(payload.get("message"), str) or not payload["message"].strip(): + answer = payload.get("message") + if not isinstance(answer, str) or not answer.strip(): + answer = raw_text[:start].split(CHAT_REVIEW_OPEN_TAG, 1)[0].strip() + if not answer: raise summary = "答复正文已保留,但操作或委派元数据未通过校验;相关操作和委派尚未提交。" - return normalize_agent_response({"message": payload["message"] + "\n\n待处理:" + summary, + return normalize_agent_response({"message": answer + "\n\n待处理:" + summary, "gate": {"kind": "response_metadata_invalid", "summary": summary}}, protected_paths=protected) key = re.search(r'"message"\s*:\s*', body) diff --git a/tests/test_chat_ordinary_project.py b/tests/test_chat_ordinary_project.py index 316102f0e8..176875142a 100644 --- a/tests/test_chat_ordinary_project.py +++ b/tests/test_chat_ordinary_project.py @@ -65,6 +65,41 @@ def request(path, body=None): thread.join(timeout=2) +def test_http_keeps_native_answer_when_legacy_handoff_metadata_is_invalid(ordinary): + store, runtime, contexts, request, capture, fake, workspace = ordinary + # Use the real protocol process and HTTP queue, including its completed + # upstream Turn; only the provider's final response is a fixture. + source = fake.read_text() + marker = ' "message": "Runtime response.",' + assert marker in source + fake.write_text(source.replace(marker, + ' "context_handoff": [{"goal_id": "existing-work", ' + '"agent_id": "existing-worker", "brief": "invalid legacy brief"}],', 1)) + project = request("/api/chat/projects")[1]["projects"][0] + status, opened = request("/api/chat/sessions", { + "context_kind": "project", "project_ref": project["project_ref"]}) + assert status == 201 + sid = opened["session_id"] + try: + status, accepted = request(f"/api/chat/sessions/{sid}/turns", { + "message": "Recover the original answer", "client_turn_id": "legacy-answer"}) + assert status == 202 + ended = runtime.wait_for_turn(session_id=sid, turn_id=accepted["turn_id"], timeout_sec=10) + assert ended["status"] == "completed", ended + assert ended["response"]["gate"]["kind"] == "response_metadata_invalid" + assert not ended["response"].get("context_handoff") + snapshot = request(f"/api/chat/sessions/{sid}")[1] + answer = next(m["text"] for m in snapshot["messages"] if m["role"] == "agent") + assert answer.startswith("Visible runtime response.\n\n待处理:") + assert "相关操作和委派尚未提交" in answer + assert "invalid legacy brief" not in answer + calls = [json.loads(line) for line in capture.read_text().splitlines()] + assert sum(c.get("method") == "turn/start" for c in calls) == 1 + assert store.load_session(sid).get("active_turn_id") is None + finally: + runtime.close() + + def test_http_ordinary_project_continues_native_session_without_goal_or_portfolio(ordinary): store, runtime, contexts, request, capture, fake, workspace = ordinary status, projects = request("/api/chat/projects") diff --git a/tests/test_chat_response_parsing.py b/tests/test_chat_response_parsing.py index 1a18b72f30..ae671c32ec 100644 --- a/tests/test_chat_response_parsing.py +++ b/tests/test_chat_response_parsing.py @@ -24,6 +24,41 @@ def test_invalid_action_metadata_preserves_answer_without_admitting_actions(meta assert response["proposals"] == [] and response["protected_action"] is None +@pytest.mark.parametrize("message_field", [{}, {"message": None}, {"message": " "}]) +def test_invalid_legacy_handoff_keeps_visible_answer_when_envelope_has_no_answer(message_field): + visible = "已核对 [报告](/custom-volume/project/report.md)。结论已保留。" + payload = {**message_field, "GlobalSummary": "hidden metadata", "context_handoff": [{ + "goal_id": "existing-work", "agent_id": "existing-worker", "brief": "invalid brief", + }], "proposals": [{"kind": "todo", "text": "must not run"}]} + response = parse_agent_response(visible + "\n" + CHAT_REVIEW_OPEN_TAG + + json.dumps(payload) + CHAT_REVIEW_CLOSE_TAG, protected_paths=["/custom-volume/project"]) + + assert response["message"].startswith("已核对 报告。结论已保留。\n\n待处理:") + assert response["gate"]["kind"] == "response_metadata_invalid" + assert not response.get("context_handoff") and not response.get("goal_draft") + assert response["proposals"] == [] and response["protected_action"] is None + assert "hidden metadata" not in response["message"] + assert "invalid brief" not in response["message"] + + +def test_invalid_handoff_without_any_answer_still_fails_closed(): + raw = CHAT_REVIEW_OPEN_TAG + json.dumps({"context_handoff": [{ + "goal_id": "existing-work", "agent_id": "existing-worker", "brief": "invalid brief", + }]}) + CHAT_REVIEW_CLOSE_TAG + with pytest.raises(ValueError): + parse_agent_response(raw) + + +def test_invalid_handoff_never_recovers_an_earlier_hidden_envelope_as_prose(): + raw = 'Answer.\n{"message":"hidden"}\n' + raw += CHAT_REVIEW_OPEN_TAG + json.dumps({"context_handoff": [{ + "goal_id": "existing-work", "agent_id": "existing-worker", "brief": "invalid brief", + }]}) + CHAT_REVIEW_CLOSE_TAG + response = parse_agent_response(raw) + assert response["message"].startswith("Answer.\n\n待处理:") + assert "hidden" not in response["message"] and "loopx-review-json" not in response["message"] + + @pytest.mark.parametrize("change", [None, {"effects_settled": False}, {"effects_settled": "true"}, {"remaining": ""}, {"remaining": "x" * 1001}, {"dependency": 4}, {"grant": "workspace_write"}]) def test_unfinished_work_is_explicit_bounded_data_never_inferred_from_prose(change):