From 1ed8f50d7fa41e0e95c70c7c7c875e6efeb82538 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:39:01 +0800 Subject: [PATCH] fix(collaboration): accept original result revision on legacy requests Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/capabilities/manager_context/README.md | 7 +++ .../collaboration/result_publication.ts | 7 ++- .../collaboration_result_publication.test.ts | 30 ++++++++++++ tests/test_manager_context_roundtrip.py | 47 +++++++++++++++++++ 4 files changed, 90 insertions(+), 1 deletion(-) diff --git a/loopx/capabilities/manager_context/README.md b/loopx/capabilities/manager_context/README.md index 20050cd0f8..b073b7112d 100644 --- a/loopx/capabilities/manager_context/README.md +++ b/loopx/capabilities/manager_context/README.md @@ -605,6 +605,13 @@ evidence records; requested detail and material gaps remain in the answer. This is authoring guidance, not a summary service or a delivery-time rewrite, and does not certify the quality of a receiver's result. +For a legacy v1 request, `manager-inbox report --request-revision 1` is equivalent +to omitting the revision. It publishes the receiver's original result without +creating request-control state or granting correction authority; other explicit +revisions are rejected. An `--update-id` still requires an initial conclusion. +This compatibility does not replay an earlier failed command or relax original +delivery proof, audience, immutable-result or unknown-effect checks. + For longer work, `--phase decision` optionally returns a meaningful intermediate update. A ready conclusion supersedes an unsent intermediate update. Do not send one notification per poll, quote private deliberation, or claim an implementation diff --git a/loopx/control_plane/collaboration/result_publication.ts b/loopx/control_plane/collaboration/result_publication.ts index ce42acea48..491e374a8e 100644 --- a/loopx/control_plane/collaboration/result_publication.ts +++ b/loopx/control_plane/collaboration/result_publication.ts @@ -112,7 +112,12 @@ export function collaborationResultObservations(request: JsonObject, observation export function planCollaborationResult(params: JsonObject): JsonObject { const request = requireJsonObject(params.request, "result request"); const phase = requireStringLiteral(params.phase, ["decision", "conclusion"], "result phase"); - const controlled = request.request_control !== undefined || params.request_revision != null; + const legacyOriginal = request.schema_version === "loopx_manager_context_entry_v1" + && request.request_control === undefined; + if (legacyOriginal && params.request_revision != null && params.request_revision !== 1) { + throw new EffectRuntimeRequestError("legacy requests only support original result revision 1"); + } + const controlled = !legacyOriginal && (request.request_control !== undefined || params.request_revision != null); // Old commands always name original revision 1. They never inherit the new // head merely because a correction arrived while their result was in flight. const context = controlled ? requestRevisionReceiptContext(request, params.request_revision, params.legacy_assessment ?? null) : null; diff --git a/tests/control_plane_ts/collaboration_result_publication.test.ts b/tests/control_plane_ts/collaboration_result_publication.test.ts index 772c9fe339..2bef755a84 100644 --- a/tests/control_plane_ts/collaboration_result_publication.test.ts +++ b/tests/control_plane_ts/collaboration_result_publication.test.ts @@ -12,6 +12,36 @@ function observation(key: string, receipt: JsonObject): JsonObject { } const input = { request, phase: "conclusion", text: "Review finished.", decision: "adopt", update_id: "review-finished", results: [observation("conclusion", initial)] }; +test("legacy original revision is an optional result selector, not request control", () => { + const legacy = {...request, schema_version: "loopx_manager_context_entry_v1"}; + for (const phase of ["decision", "conclusion"]) { + const source = {...input, request: legacy, phase, update_id: null, results: []}; + const expected = planCollaborationResult(source); + const explicit = planCollaborationResult({...source, request_revision: 1}); + assert.deepEqual(explicit, expected); + const value = explicit.value as JsonObject; + assert.equal(Object.hasOwn(value, "request_revision"), false); + assert.equal(Object.hasOwn(value, "request_revision_digest"), false); + assert.equal(planCollaborationResult({...source, request_revision: 1, + results: [observation(phase, value)]}).replay, true); + assert.equal(planCollaborationResult({...source, + results: [observation(phase, value)]}).replay, true); + } +}); + +test("legacy revision selection cannot create corrections or bypass immutable results", () => { + const source = {...input, request: {...request, schema_version: "loopx_manager_context_entry_v1"}, + request_revision: 1}; + for (const request_revision of [0, 2, -1, 1.5, "1", true, {}]) { + assert.throws(() => planCollaborationResult({...source, request_revision}), /original result revision 1/); + } + assert.equal(planCollaborationResult(source).result_key, "conclusion-00000001"); + assert.throws(() => planCollaborationResult({...source, update_id: null}), /conflicting/); + assert.throws(() => planCollaborationResult({...source, results: []}), /initial conclusion/); + assert.throws(() => planCollaborationResult({...source, + request: {...source.request, request_control: {}}}), /exact-instance/); +}); + test("a later result appends without replacing the initial conclusion", () => { const plan = planCollaborationResult(input); assert.equal(plan.result_key, "conclusion-00000001"); diff --git a/tests/test_manager_context_roundtrip.py b/tests/test_manager_context_roundtrip.py index ee2eef8d2c..179ab4187d 100644 --- a/tests/test_manager_context_roundtrip.py +++ b/tests/test_manager_context_roundtrip.py @@ -160,6 +160,53 @@ def test_owner_return_does_not_create_source_policy(flow, project): assert not list(_root(root).glob("policy*")) +def test_cli_legacy_explicit_original_revision_returns_once(flow, capsys): + from loopx.cli import main + + root, registry, store, create = flow + session, turn, receipt = create() + rid = receipt["request_id"] + acknowledge(root, "research", "worker", rid, "adopt", "Checked the original request") + text = "Checked the original request and completed its existing work." + argv = ["--registry", str(registry), "--runtime-root", str(root), "--format", "json", + "manager-inbox", "report", "--goal-id", "research", "--agent-id", "worker", + "--request-id", rid, "--reply-text", text] + assert main([*argv, "--request-revision", "1"]) == 0 + assert json.loads(capsys.readouterr().out)["status"] == "queued_for_original_conversation" + assert main(argv) == 0 + assert json.loads(capsys.readouterr().out)["ok"] is True + assert drain(root, registry, store, None) == 1 + assert drain(root, registry, ChatSessionStore(root), None) == 0 + returned = [row for row in store.messages(session["session_id"]) + if row.get("origin") == "manager_followup"] + assert len(returned) == 1 and returned[0]["turn_id"] == turn["turn_id"] + assert returned[0]["text"].split("\n\n", 1)[1] == text + assert reply_status(root, receipt)[0]["status"] == "delivered" + assert not list(_root(root).glob("policy*")) + entry = next((_root(root) / "entries").glob("*/*.json")) + assert "request_control" not in json.loads(entry.read_text()) + + +def test_legacy_original_revision_does_not_repair_mismatched_initial_delivery(flow): + root, registry, store, create = flow + session, turn, receipt = create(True) + rid = receipt["request_id"] + store.update_turn(session["session_id"], turn["turn_id"], response={ + "context_handoff_receipt": {**receipt, "agent_id": "another-worker"}, + }) + acknowledge(root, "research", "worker", rid, "adopt", "Checked") + report(root, "research", "worker", rid, "conclusion", "Checked the original request.", + registry=registry, request_revision=1) + calls = [] + drain(root, registry, store, lambda *args: calls.append(args)) + assert calls == [] + assert not any(row.get("origin") == "manager_followup" + for row in store.messages(session["session_id"])) + assert reply_status(root, receipt)[0]["status"] != "delivered" + assert store.load_turn(session["session_id"], turn["turn_id"])["response"][ + "context_handoff_receipt"]["agent_id"] == "another-worker" + + @pytest.mark.parametrize("audience", ["local_owner", "bound_owner"]) @pytest.mark.parametrize("drift", ["channel", "goal", "context", "context_missing", "context_null"]) def test_recovered_project_identity_cannot_publish_as_a_manager(flow, audience, drift):