From b9aab0e1a7085bacf89d85b1b4549a0ef4ba61c3 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Tue, 3 Nov 2026 02:59:36 +0800 Subject: [PATCH] fix(context): pin initial reader selections to resolved addresses Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- docs/reference/required-work-context.md | 21 ++++++-- loopx/cli_commands/quota_capture.py | 5 +- .../test_context_read_reference.py | 52 +++++++++++++++++++ 3 files changed, 72 insertions(+), 6 deletions(-) diff --git a/docs/reference/required-work-context.md b/docs/reference/required-work-context.md index f39809d47a..ce421ea267 100644 --- a/docs/reference/required-work-context.md +++ b/docs/reference/required-work-context.md @@ -191,10 +191,17 @@ marked `required=false` defer their guidance/templates; their routes stay visibl and the complete captured attachment must be read before use. Unknown plan versions remain whole. Initial pages do not repeat pointers already present in their content; the selection digest still prevents a full-input receipt. -Its `context-read --initial-read` uses the same exact-address selector; paged -continuations retain the short option and pin the selection digest. A changed -profile rejects rather than mixing views. The option is read-only and exclusive with explicit -selectors or discovery; unknown schemas use generic discovery. +Its `context-read --initial-read` uses the same exact-address selector. New private +selection references pin the resolved addresses and complete selection digest; +continuations use the existing explicit reader instead of rerunning initial +navigation. A later navigation change cannot replace the saved selection. +Historical recipe-only references and explicit-file initial cursors still reject +a changed selection digest; recover through the same original full-capture +reference, without another admission or rewriting the saved descriptor. These +legacy readers still require reconciliation of their unfinished original Turns +before retirement; this slice retains their decoder and full-capture recovery. +The option is read-only and exclusive with +explicit selectors or discovery; unknown schemas use generic discovery. The concrete first read replaces the broad scalar preview and root index when it fits; unknown schemas or a tight reference budget keep generic discovery. Warm deltas keep their existing reader, and disabled native delivery is unchanged. @@ -219,7 +226,11 @@ activation defaults, saved files, Delta and full-read receipts are unchanged. 同一捕获中按需展开。它是起始选读,不代表已读全部必要来源;依赖具体验收、证据或 执行/结算契约前仍须完整读取。未知 schema、过小引用预算和 warm delta 保留既有路线, 关闭原生传输的输出不变;完整恢复和原 SHA 校验均保留。只读的 `--initial-read` 不与 -显式选读或索引混用;续页保留短选项和固定的选读摘要,profile 改变则拒绝混读。 +显式选读或索引混用。新私有选读引用固定已解析路径和完整选读摘要,续页复用显式 +reader,不重新运行首读导航;后续导航改变不能替换原选读。历史仅保存导航配方的引用 +及显式文件首读游标在选读摘要变化时仍拒绝,沿同一完整原 capture 引用恢复,不重新 +准入或改写旧描述文件。退役这些 legacy reader 前仍须对账原未完成 Turn;本切片 +保留历史解码与完整捕获恢复。 首读也给出现有验收材料地址和当前结算义务。仅明确 `required=false` 的附件延后完整 说明与模板,使用前须读原附件;未知计划保留全文。首读分页不重复正文中的指针, 选读摘要仍阻止将它记作完整输入。 diff --git a/loopx/cli_commands/quota_capture.py b/loopx/cli_commands/quota_capture.py index 97d09ac7ed..a2a50cebc3 100644 --- a/loopx/cli_commands/quota_capture.py +++ b/loopx/cli_commands/quota_capture.py @@ -251,8 +251,11 @@ def read_context_capture(args: argparse.Namespace, *, presentation: dict[str, An selectors = [item["pointer"] for item in views] if reference_file is not None and pinned_selection is None: try: + # Pin the resolved addresses, not the initial navigation + # recipe. Existing explicit readers can replay these exact + # bytes after a navigation change, with both digests checked. reference_file = write_context_read_reference(path, digest, selection={ - "select": None if initial_read else selectors, "initial_read": initial_read, + "select": selectors, "initial_read": False, "list_fields": list_fields, "list_depth": list_depth, "sha256": expected}) except OSError: # A verified read must not require a writable capture root. diff --git a/tests/control_plane/test_context_read_reference.py b/tests/control_plane/test_context_read_reference.py index ecf21717fa..43edb0ee6a 100644 --- a/tests/control_plane/test_context_read_reference.py +++ b/tests/control_plane/test_context_read_reference.py @@ -132,6 +132,58 @@ def changed(operation, **kwargs): assert "content" not in rejected +@pytest.mark.parametrize("legacy", [False, True], ids=["pinned-addresses", "historical-profile"]) +def test_initial_reference_navigation_change_preserves_original_read_boundary(tmp_path, monkeypatch, legacy): + payload, machine, model = fixture(tmp_path) + expected = read(model["first_read_command"]) + assert expected["ok"] and expected["eof"] + first = read(model["first_read_command"], max_page_output_bytes=3000, page_bytes=3000) + assert first["ok"] and not first["eof"] + reference = Path(first["reference_file"]) + if legacy: + # Historical documents stored the navigation recipe, not its addresses. + _, descriptor = read_context_read_reference(str(reference)) + reference = write_context_read_reference(Path(machine["path"]), machine["sha256"], selection={ + **descriptor["selection"], "select": None, "initial_read": True}) + reference_before, capture_before = reference.read_bytes(), Path(machine["path"]).read_bytes() + from loopx.control_plane.quota import turn_envelope + + original = turn_envelope._turn_envelope_result + + def changed_navigation(operation, **kwargs): + if kwargs.get("initial_read"): + kwargs = {**kwargs, "initial_read": False, "select": ["/criterion"]} + return original(operation, **kwargs) + + monkeypatch.setattr(turn_envelope, "_turn_envelope_result", changed_navigation) + # A fresh initial read now has different navigation over the same source. + current = read(model["first_read_command"]) + assert json.loads(current["content"])["selections"] == [{"pointer": "/criterion", "value": payload["criterion"]}] + following = read(first["next_command"], reference_file=str(reference)) + if legacy: + assert following["ok"] is False and "digest changed" in following["reason"] + assert "content" not in following + recovered = read(model["recovery"]["command"], select=["/goal_boundary", "/selected_todo"]) + assert recovered["ok"] and recovered["eof"] + values = {row["pointer"]: row["value"] for row in json.loads(recovered["content"])["selections"]} + else: + parts = [first["content"]] + while True: + assert following["ok"] and following["schema_version"] == "context_capture_selection_page_v1" + assert _context_pages(following) == [] + assert following["offset"] == len("".join(parts).encode()) + parts.append(following["content"]) + if following["eof"]: + break + following = read(following["next_command"]) + assert "".join(parts) == expected["content"] + values = {row["pointer"]: row["value"] for row in json.loads("".join(parts))["selections"]} + assert values["/goal_boundary"] == payload["goal_boundary"] + assert values["/selected_todo"] == payload["selected_todo"] + assert reference.read_bytes() == reference_before + assert Path(machine["path"]).read_bytes() == capture_before + + @pytest.mark.parametrize("selection", ["fields", "index", "initial"]) def test_selected_read_survives_unwritable_reference_metadata(tmp_path, monkeypatch, selection): import errno