From be5e82549abafcb9952ecf0a6e2aa03ee17b6244 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:20:52 +0800 Subject: [PATCH] fix(chat): preserve substantive answers after context handoff Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/capabilities/manager_context/README.md | 5 +- .../capabilities/manager_context/execution.py | 25 ++++++-- tests/test_chat_project_coordination.py | 10 ++- tests/test_manager_context_execution.py | 64 ++++++++++++++++++- 4 files changed, 93 insertions(+), 11 deletions(-) diff --git a/loopx/capabilities/manager_context/README.md b/loopx/capabilities/manager_context/README.md index ff12105588..c5d5e0bace 100644 --- a/loopx/capabilities/manager_context/README.md +++ b/loopx/capabilities/manager_context/README.md @@ -560,7 +560,10 @@ content. Updating it in a release must not reject an otherwise identical saved request. Replay retains the original entry bytes, guidance, assessment and return history; message, brief and recipient identity conflicts still fail. -The default shared Chat handoff acknowledgment is a short user-facing preview: +The shared Chat handoff acknowledgment preserves the current audience-processed +answer, including its Markdown, and appends receipt-backed delivery and execution +status. It does not repeat the full brief or historical context after a substantive +answer. A missing or blank answer uses the short user-facing preview: the exact recipient Agent, purpose, a bulleted result checklist (up to three items), execution boundaries (up to two items), and the return requirement. Each item has a visible text bound; omitted list items are counted. Selection diff --git a/loopx/capabilities/manager_context/execution.py b/loopx/capabilities/manager_context/execution.py index c5dc268bf8..42c1e4b371 100644 --- a/loopx/capabilities/manager_context/execution.py +++ b/loopx/capabilities/manager_context/execution.py @@ -6,7 +6,10 @@ from pathlib import Path from collections.abc import Callable -from typing import Any +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from ...chat_store import ChatSessionStore from ...agent_registry import load_goal_from_registry from ...collaboration_mcp import Delegations @@ -167,11 +170,21 @@ def handoff_response( response: dict[str, Any], source_authorized: Callable[[], bool], execution_allowed: Callable[[], bool], - source_store=None, + source_store: "ChatSessionStore | None" = None, ) -> dict[str, Any]: - """Present context delivery and separately admitted execution on one path.""" + """Append receipt-backed status to the existing audience-processed answer. + + This presentation adapter does not reinterpret model prose as execution + evidence or replace the answer already shown by the Chat stream. + """ from . import deliver + answer = response.get("message") + answer = answer if isinstance(answer, str) and answer.strip() else "" + + def present(notice: str) -> str: + return answer + "\n\n" + notice if answer else notice + try: if not source_authorized(): raise ValueError("manager connection authority is no longer available") @@ -182,8 +195,8 @@ def handoff_response( execution_allowed=execution_allowed) return {**response, "proposals": [], "gate": None, "context_handoff_receipt": receipt, "context_execution": execution, - "message": handoff_message(receipt, execution, - brief=response["context_handoff"].get("brief"))} + "message": present(handoff_message(receipt, execution, + brief=None if answer else response["context_handoff"].get("brief")))} except (OSError, ValueError): return {**response, "proposals": [], "gate": None, - "message": "材料尚未转交:目标绑定、来源授权或持久收件回读未通过。需要修复交接链路;没有改动任务或优先级。"} + "message": present("材料尚未转交:目标绑定、来源授权或持久收件回读未通过。需要修复交接链路;没有改动任务或优先级。")} diff --git a/tests/test_chat_project_coordination.py b/tests/test_chat_project_coordination.py index 501140137e..6ff5b90cb3 100644 --- a/tests/test_chat_project_coordination.py +++ b/tests/test_chat_project_coordination.py @@ -71,6 +71,7 @@ def test_project_chat_nested_cli_handoff_returns_once_after_restart(project, mon before = registry.read_bytes(), (repo / "ACTIVE_GOAL_STATE.md").read_bytes() store = ChatSessionStore(root) controller = ChatRuntimeController(store=store, codex_bin="codex", registry_path=registry) + answer = "**Source comparison**\n\nThe periods differ.\n\n- Keep the correction.\n- Do not infer growth." class ModelFixture: upstream_thread_id = "fixture-project-model" @@ -79,7 +80,8 @@ def close_session(self): pass def start_turn(self, message, sink): assert "OTHER_PROJECT_PRIVATE" not in message assert '"scope": "owner_goal"' in message and '"agent_id": "coordinator"' in message - return {"schema_version": "loopx_chat_agent_response_v0", "message": "Forwarding the correction", "proposals": [], "gate": None, + sink("answer.delta", {"text": answer}) + return {"schema_version": "loopx_chat_agent_response_v0", "message": answer, "proposals": [], "gate": None, "context_handoff": {"goal_id": "research", "agent_id": "coordinator"}} monkeypatch.setattr(controller, "capabilities", lambda: [ @@ -100,6 +102,12 @@ def cli(agent, action, *args): message="Ask coordinator to check the corrected source with reviewer.", work_dir=repo, objective="Research delivery") done = controller.wait_for_turn(session_id=session["session_id"], turn_id=turn["turn_id"], timeout_sec=20) assert done["status"] == "completed", done + assert done["response"]["message"].startswith(answer + "\n\n") + assert done["response"]["message"].count(answer) == 1 + streamed = [event["payload"]["text"] for event in store.events_after(session["session_id"], turn["turn_id"], None) + if event["kind"] == "answer.delta"] + assert "".join(streamed) == answer + assert ChatSessionStore(root).load_turn(session["session_id"], turn["turn_id"])["response"] == done["response"] receipt = done["response"]["context_handoff_receipt"] request_id = receipt["request_id"] assert request_id in json.dumps(cli("coordinator", "read")) diff --git a/tests/test_manager_context_execution.py b/tests/test_manager_context_execution.py index 6aadd55a00..c2030c68fb 100644 --- a/tests/test_manager_context_execution.py +++ b/tests/test_manager_context_execution.py @@ -120,7 +120,7 @@ def test_model_receives_authorized_task_choices_after_manager_context_compaction assert "execution_binding_id" not in _turn_prompt(turn["message"], context_summary=json.dumps(unavailable)) -def test_handoff_response_preserves_receipt_and_separate_execution_status(flow): +def test_handoff_response_preserves_answer_and_separate_execution_status(flow): root, registry, (_, session, turn, request, _, _), started = flow response = execution.handoff_response(root, registry, session=session, turn=turn, response={"context_handoff": request, "message": "Unverified model completion claim.", @@ -129,12 +129,70 @@ def test_handoff_response_preserves_receipt_and_separate_execution_status(flow): assert response["context_handoff_receipt"]["status"] == "delivered" assert response["context_execution"]["status"] == "prepared" assert "尚未确认完成" in response["message"] - assert request["brief"]["purpose"] in response["message"] - assert "Unverified model completion claim" not in response["message"] + assert response["message"].startswith("Unverified model completion claim.\n\n") + assert request["brief"]["purpose"] not in response["message"] + # Preserving prose does not turn a model claim into execution evidence. + assert response["context_execution"]["status"] != "accepted" assert response["proposals"] == [] and response["gate"] is None assert len(started) == 1 +@pytest.mark.parametrize("message", [None, "", " \n\t"]) +def test_empty_handoff_answer_retains_compact_brief_preview(flow, message): + root, registry, (_, session, turn, request, _, _), _ = flow + response = execution.handoff_response(root, registry, session=session, turn=turn, + response={"context_handoff": request, "message": message}, + source_authorized=lambda: True, execution_allowed=lambda: True) + assert response["message"].startswith("**已转交给 `worker`。**") + assert request["brief"]["purpose"] in response["message"] + + +@pytest.mark.parametrize("refusal", ["revoke", "delivery", "stop"]) +def test_handoff_refusal_preserves_answer_without_inventing_execution(flow, monkeypatch, refusal): + root, registry, (_, session, turn, request, _, _), started = flow + original = normalize_agent_response({"context_handoff": request, + "message": f"结论:保留现有数据。\n\n- 已核对 {root / 'private' / 'source.txt'}。\n- 恢复仍须独立验收。"}, + protected_paths=[root]) + answer = original["message"] + assert str(root) not in answer + if refusal == "delivery": + from loopx.capabilities import manager_context + def unavailable(*args, **kwargs): + raise OSError("delivery unavailable") + monkeypatch.setattr(manager_context, "deliver", unavailable) + response = execution.handoff_response(root, registry, session=session, turn=turn, + response=original, + source_authorized=lambda: refusal != "revoke", + execution_allowed=lambda: refusal != "stop") + assert response["message"].startswith(answer + "\n\n") + assert response["message"].count(answer) == 1 + assert not started + if refusal == "stop": + assert response["context_handoff_receipt"]["status"] == "delivered" + assert response["context_execution"]["submitted"] is False + assert "本次未提交受控执行" in response["message"] + else: + assert "尚未转交" in response["message"] + assert "context_handoff_receipt" not in response + + +def test_replayed_handoff_keeps_one_answer_and_one_original_operation(flow): + root, registry, (_, session, turn, request, _, _), started = flow + answer = "**检查结果**\n\n```text\nretain\n```\n\n原约束仍适用。" + original = {"context_handoff": request, "message": answer} + results = [execution.handoff_response(root, registry, session=session, turn=turn, + response=original, source_authorized=lambda: True, execution_allowed=lambda: True) + for _ in range(2)] + assert original["message"] == answer + assert len(started) == 1 + assert results[0]["context_execution"]["operation_id"] == results[1]["context_execution"]["operation_id"] + assert results[1]["context_execution"]["replayed"] + for result in results: + assert result["message"].startswith(answer + "\n\n") + assert result["message"].count(answer) == 1 + assert request["brief"]["context"] not in result["message"] + + def test_handoff_scope_revocation_stops_before_inbox_delivery(flow, monkeypatch): root, registry, (_, session, turn, request, _, _), started = flow from loopx.capabilities import manager_context