diff --git a/docs/reference/required-work-context.md b/docs/reference/required-work-context.md index c5fc23234c..75422c4d48 100644 --- a/docs/reference/required-work-context.md +++ b/docs/reference/required-work-context.md @@ -209,8 +209,27 @@ Initial navigation can replace fully selected children with their complete captured parent to avoid repeating addresses. An unselected child, including optional or unknown material, prevents that merge; the selected facts and obligations stay identical. Explicit selectors and index -entries keep their original addresses. The option is read-only and exclusive with -explicit selectors or discovery; unknown schemas use generic discovery. +entries keep their original addresses. The option is read-only and exclusive with explicit +selectors or discovery; unknown schemas use generic discovery. +Pinned explicit references replay their recorded address order after the same +canonical address validation. Older descriptors may contain caller-ordered +addresses but a digest of canonical output: only after a digest mismatch, the +reader tries that original canonical planner once over the same verified source +and address set. Only exact matching bytes are returned. This may add one +read-only planner call per legacy page; it adds no model-input receipt or state +write. Historical recipe-only references and raw initial cursors keep their +strict profile-change refusal and full-capture recovery. +The existing internal `context_select` request accepts the validated boolean +`preserve_selection_order` for this rendering step. Public explicit selectors +and diagnostics are unchanged; the stored descriptor shape and CLI options are +unchanged. +Initial selections retain the typed owner's priority rather than alphabetical +pointer order. Current controls, required sources and settlement obligations +precede the complete unread-material index. A coalesced parent keeps +its first selected child's position; explicit selectors retain canonical order. +This reorders the same selected facts without reducing the total body or +guaranteeing that all requirements fit one page. Earlier initial-selection +digests still reject a changed profile; recover from the original full capture. 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. @@ -246,6 +265,17 @@ reader,不重新运行首读导航;后续导航改变不能替换原选读 保留历史解码与完整捕获恢复。 首读可把已经完整选中的同级内容合并为其完整父节点,减少地址重复;任何未选中的 可选或未知内容都会阻止合并,选中事实和义务不变。显式选读和索引仍保留原地址。 +固定显式引用在既有规范地址校验后,按记录的地址顺序重放。旧描述符若保存调用方顺序 +却绑定规范排序正文的摘要,仅在摘要不匹配后,对同一已验证来源及相同地址集合重试 +一次原规范 planner;只返回摘要完全匹配的字节。此兼容路径每个旧分页最多增加一次 +只读 planner 调用,不写状态或证明模型输入覆盖。历史 recipe-only 引用和原始首读 +游标仍在 profile 变化时严格拒绝,并保留完整捕获恢复。既有内部 `context_select` +请求为重放接受严格 boolean 的 `preserve_selection_order`;公开显式选读及错误诊断 +不变,reader 描述符字段结构和 CLI 选项不变。 +首读按 TypeScript owner 的优先顺序呈现,不再按指针字母序重排;当前控制条件、必读 +来源和结算义务先于完整的未读材料索引。合并父节点保留首个已选子节点的位置, +显式选读仍按既有规范排序。这只调整相同事实的顺序,不缩减总正文,也不保证全部 +要求能放入一页。旧首读选读摘要遇到 profile 变化仍拒绝混读,可从原完整捕获恢复。 首读也给出现有验收材料地址和当前结算义务。仅明确 `required=false` 的附件延后完整 说明与模板,使用前须读原附件;未知计划保留全文。首读分页不重复正文中的指针, 选读摘要仍阻止将它记作完整输入。 diff --git a/loopx/cli_commands/quota_capture.py b/loopx/cli_commands/quota_capture.py index a3a3996c27..2c82e99843 100644 --- a/loopx/cli_commands/quota_capture.py +++ b/loopx/cli_commands/quota_capture.py @@ -162,6 +162,23 @@ def _context_selection_transport(payload: dict[str, Any]) -> dict[str, Any]: return result +def _context_selection_bytes(payload: dict[str, Any], selections: list[dict[str, Any]], + *, list_fields: bool, list_depth: int | None) -> bytes: + """Render TS-verified addresses from original, digest-checked JSON values.""" + views: list[dict[str, Any]] = [] + for item in selections: + selected: Any = payload + for key in item["path"]: + selected = selected[key] + if list_fields: + views.append({"pointer": item["pointer"], "fields": _context_index_fields(payload, item["children"])}) + else: + views.append({"pointer": item["pointer"], "value": selected}) + view_options = {"list_depth": list_depth} if list_depth is not None and list_depth != 1 else {} + return json.dumps({"schema_version": "context_capture_index_v1" if list_fields else "context_capture_selection_v1", + "selections": views, **view_options}, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8") + b"\n" + + def read_context_capture(args: argparse.Namespace, *, presentation: dict[str, Any] | None = None, next_page_bytes: int | None = None, next_page_output_bytes: int | None = None) -> dict[str, Any]: @@ -257,8 +274,10 @@ def read_context_capture(args: argparse.Namespace, *, presentation: dict[str, An depth_options = {"list_depth": list_depth} if list_depth is not None else {} selection_options = {"initial_read": True} if initial_read else {"select": selectors} planning_context = payload if initial_read or list_fields else _context_selection_transport(payload) + pinned_explicit = pinned_selection is not None and not initial_read + order_options = {"preserve_selection_order": True} if pinned_explicit else {} plan = _turn_envelope_result("context_select", context=planning_context, list_fields=list_fields, - recover_selection_errors=not initial_read, **selection_options, **depth_options) + recover_selection_errors=not initial_read, **selection_options, **depth_options, **order_options) if rejection := plan.get("rejected_selection"): # TS alone resolves the nearest container. Reuse the original # escaped tokens, without parsing diagnostic prose or traversing @@ -273,23 +292,20 @@ def read_context_capture(args: argparse.Namespace, *, presentation: dict[str, An "reason": rejection["message"], "recovery_command": command, "recovery": "Use recovery_command to --list the nearest verified container in the same capture, " "then retry the corrected batch. No values were delivered and no guard, admission or spend was performed."} - views: list[dict[str, Any]] = [] - for item in plan["selections"]: - selected: Any = payload - for key in item["path"]: - selected = selected[key] - if list_fields: - views.append({"pointer": item["pointer"], "fields": _context_index_fields(payload, item["children"])}) - else: - views.append({"pointer": item["pointer"], "value": selected}) - view_options = {"list_depth": list_depth} if list_depth is not None and list_depth != 1 else {} - raw = json.dumps({"schema_version": "context_capture_index_v1" if list_fields else "context_capture_selection_v1", "selections": views, **view_options}, - ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8") + b"\n" + raw = _context_selection_bytes(payload, plan["selections"], list_fields=list_fields, list_depth=list_depth) expected = hashlib.sha256(raw).hexdigest() + if pinned_explicit and selection_digest != expected: + # Older explicit metadata could retain caller order while its + # digest covered canonical output. Try that original owner + # once, over the same verified source and exact address set. + plan = _turn_envelope_result("context_select", context=planning_context, + list_fields=list_fields, **selection_options, **depth_options) + raw = _context_selection_bytes(payload, plan["selections"], list_fields=list_fields, list_depth=list_depth) + expected = hashlib.sha256(raw).hexdigest() if selection_digest is not None and selection_digest != expected: raise ValueError("selected view digest changed; restore the original selection and reader") selection_digest = expected - selectors = [item["pointer"] for item in views] + selectors = [item["pointer"] for item in plan["selections"]] if reference_file is not None and pinned_selection is None: try: # Pin the resolved addresses, not the initial navigation diff --git a/loopx/control_plane/quota/context_selection.ts b/loopx/control_plane/quota/context_selection.ts index aab32b6704..536e569725 100644 --- a/loopx/control_plane/quota/context_selection.ts +++ b/loopx/control_plane/quota/context_selection.ts @@ -54,6 +54,8 @@ function coalescedInitialPointers(context: JsonObject, pointers: string[]): stri export function capturedContextSelection(request: JsonObject, initialPointers: string[] = []): JsonObject { if (request.recover_selection_errors !== undefined && typeof request.recover_selection_errors !== "boolean") throw new EffectRuntimeRequestError("recover-selection-errors must be boolean"); + if (request.preserve_selection_order !== undefined && typeof request.preserve_selection_order !== "boolean") + throw new EffectRuntimeRequestError("preserve-selection-order must be boolean"); try { return resolveSelection(request, initialPointers); } catch (error) { @@ -87,7 +89,10 @@ function resolveSelection(request: JsonObject, initialPointers: string[]): JsonO (selectors as string[]).forEach((pointer, index) => { if (!argumentsByPointer.has(pointer)) argumentsByPointer.set(pointer, index + 1); }); - const pointers = [...argumentsByPointer.keys()].sort(); + // Initial navigation follows the owner's priority, including merged parents. + // Ordinary explicit selectors retain their canonical order. + const pointers = [...argumentsByPointer.keys()]; + if (request.initial_read !== true) pointers.sort(); const selections = pointers.map(pointer => { const argument = `--select argument ${argumentsByPointer.get(pointer)}`; if (pointer !== "" && !pointer.startsWith("/") || /~(?:[^01]|$)/.test(pointer)) @@ -129,6 +134,10 @@ function resolveSelection(request: JsonObject, initialPointers: string[]): JsonO } return {pointer, path}; }); + // A pinned view may record a different rendering order. Validate the same + // canonical batch first so rejected reads keep their original diagnostics. + if (request.preserve_selection_order === true) + selections.sort((left, right) => argumentsByPointer.get(left.pointer)! - argumentsByPointer.get(right.pointer)!); // Discovery follows the validated, deduplicated selection. Repeating the root // cannot drop its navigation; ordinary explicit reads need no initial plan. const navigation = request.list_fields === true && pointers.length === 1 && pointers[0] === "" diff --git a/loopx/control_plane/quota/progressive_context.ts b/loopx/control_plane/quota/progressive_context.ts index 1a8f2ffd63..3489be8c17 100644 --- a/loopx/control_plane/quota/progressive_context.ts +++ b/loopx/control_plane/quota/progressive_context.ts @@ -223,7 +223,6 @@ export function initialContextReadPointers(context: JsonObject, decisionFields: pointers.push("/" + path.map(key => String(key).replaceAll("~", "~0").replaceAll("/", "~1")).join("/")); }; for (const field of decisionFields) include(field); - include("context_details", "unread_context_index"); include("agent_lane_next_action", "next_step"); include("agent_lane_next_action", "next_action_basis"); include("interaction_contract", "user_channel"); @@ -319,5 +318,8 @@ export function initialContextReadPointers(context: JsonObject, decisionFields: } }); } else include(...planPath); // Unknown plan versions keep their complete obligations. + // Keep the complete unread index reachable after current controls, required + // sources and settlement obligations; its size must not displace them first. + include("context_details", "unread_context_index"); return pointers; } diff --git a/loopx/control_plane/testing/cli_output_budget.py b/loopx/control_plane/testing/cli_output_budget.py index e0b972394d..8b7167729d 100644 --- a/loopx/control_plane/testing/cli_output_budget.py +++ b/loopx/control_plane/testing/cli_output_budget.py @@ -155,11 +155,13 @@ class CliOutputCommandClassification: # Retain complete routes/context and bounded host-path headroom; # line, per-Todo/fixed growth and all other ceilings stay unchanged. # Inherited Stride adds its complete decision/axis/source contract: - # the same native fixture measures 35,597 -> 42,281 characters. + # the same native fixture measures 35,597 -> 42,281 characters on POSIX + # and 42,501 characters on native Windows due to drive and backslash paths. # Preserve the original 42,000 failure and the required semantics; - # calibrate this regression allowance only, with 219 chars headroom. - # No production hard limit or line/collection-growth guard changes. - "crowded": {"json": 42_500, "markdown": 10_500}, + # calibrate this regression allowance to 43,000, leaving bounded headroom + # (~499 chars on Windows, ~719 chars on POSIX) without widening collection + # or production transport limits. + "crowded": {"json": 43_000, "markdown": 10_500}, # The unchanged 18-Todo / 3-Agent fixture on main ffc90182b and # this candidate both emit 25,836 JSON characters (96 matched # surface/mode measurements, no semantic or route differences). diff --git a/tests/control_plane/test_context_first_read.py b/tests/control_plane/test_context_first_read.py index 9ef9616131..dd290f4645 100644 --- a/tests/control_plane/test_context_first_read.py +++ b/tests/control_plane/test_context_first_read.py @@ -6,6 +6,8 @@ import json from pathlib import Path import shlex +import subprocess +import sys import pytest @@ -72,6 +74,75 @@ def read_selection(command): return page, selected_context_values(page["content"]) +@pytest.mark.parametrize("output_limit", [8192, 64000]) +def test_cli_initial_pages_prioritize_required_facts_and_keep_the_complete_index(tmp_path, decision, output_limit): + goal = decision["interaction_contract"]["agent_channel"]["work_context"]["sources"][0]["content"] + goal["text"] = "Complete current Goal; retain the final stop." + decision["context_details"] = {"unread_context_index": { + "coverage_confirmed": False, "pointers": [f"/history/{i}" for i in range(6000)]}} + view = native_context_capture_view(decision, tmp_path) + raw = Path(view["recovery"]["path"]).read_bytes() + registry, runtime = tmp_path / "absent-registry.json", tmp_path / "absent-runtime" + base = [sys.executable, "-m", "loopx.entrypoint", "--registry", str(registry), + "--runtime-root", str(runtime), "--format", "json"] + + def invoke(command): + words = shlex.split(command) + result = subprocess.run([*base, *words[words.index("quota"):]], cwd=tmp_path, + capture_output=True, text=True, check=False) + return result.returncode, json.loads(result.stdout) + + words = shlex.split(view["first_read_command"]) + for option in ("--page-bytes", "--max-page-output-bytes"): + words[words.index(option) + 1] = str(output_limit) + command = shlex.join(words) + content, pages = b"", [] + while True: + code, page = invoke(command) + assert code == 0 and page["ok"] and page["offset"] == len(content), page + assert len((json.dumps(page, ensure_ascii=False, separators=(",", ":")) + "\n").encode()) <= output_limit + assert capture_page_range(page, raw=raw, digest=view["recovery"]["sha256"]) is None + pages.append(page) + content += page["content"].encode() + if page["eof"]: + break + command = page["next_command"] + assert len(pages) > 1 + first = pages[0]["content"] + for value in (decision["goal_boundary"]["stop_condition"], goal["text"], + "This new obligation must remain visible.", "This attachment is required."): + assert value in first + rows = json.loads(content)["selections"] + assert rows[-1] == {"pointer": "/context_details/unread_context_index", + "value": decision["context_details"]["unread_context_index"]} + values = selected_context_values(content) + assert values["/goal_boundary"] == decision["goal_boundary"] + assert values["/interaction_contract/agent_channel/work_context/sources/0/content/text"] == goal["text"] + # The earlier canonical ordering is a different selection, never a cursor + # that can be silently continued through a reordered view of this capture. + legacy = json.dumps({"schema_version": "context_capture_selection_v1", + "selections": sorted(rows, key=lambda row: row["pointer"])}, + ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode() + b"\n" + digest = hashlib.sha256(legacy).hexdigest() + assert digest != pages[0]["selection_sha256"] + original = view["recovery"]["command"] + code, rejected = invoke(original + " --initial-read --selection-sha256 " + digest + + " --offset " + str(pages[0]["next_offset"])) + assert code != 0 and not rejected["ok"] and "digest changed" in rejected["reason"] + assert "content" not in rejected + restored = b"" + command = original + while True: + code, page = invoke(command) + assert code == 0 and page["offset"] == len(restored), page + restored += page["content"].encode() + if page["eof"]: + break + command = page["next_command"] + assert restored == raw == Path(view["recovery"]["path"]).read_bytes() + assert not registry.exists() and not runtime.exists() + + def test_initial_read_coalesces_whole_contract_with_exact_values_and_unknown_obligations(tmp_path, decision): cli = decision["interaction_contract"]["cli_channel"] cli["settlement_plan"]["ordered_steps"] = [{ diff --git a/tests/control_plane/test_context_read_reference.py b/tests/control_plane/test_context_read_reference.py index f82ebf4b67..648dd1249f 100644 --- a/tests/control_plane/test_context_read_reference.py +++ b/tests/control_plane/test_context_read_reference.py @@ -184,6 +184,87 @@ def changed_navigation(operation, **kwargs): assert Path(machine["path"]).read_bytes() == capture_before +@pytest.mark.parametrize("layout", ["recorded", "legacy_canonical"]) +def test_pinned_explicit_replay_keeps_the_digest_bound_order_and_original_values(tmp_path, monkeypatch, layout): + from loopx.control_plane.quota import turn_envelope + + payload = {"z": {"text": "保留完整来源🙂" * 240, "numbers": [2**60 + 1, 1.0, -0.0], "tail": "Stop before effects."}, + "\ue000": None, "😀": {"unknown_constraint": False}} + machine = native_context_capture_view(payload, tmp_path) + path, digest = Path(machine["path"]), machine["sha256"] + pointers = ["/z", "/\ue000", "/z", "/😀"] + order = ["/z", "/\ue000", "/😀"] if layout == "recorded" else ["/z", "/😀", "/\ue000"] + expected = json.dumps({"schema_version": "context_capture_selection_v1", "selections": [ + {"pointer": pointer, "value": payload[pointer[1:]]} for pointer in order]}, + ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode() + b"\n" + reference = write_context_read_reference(path, digest, selection={"select": pointers, "initial_read": False, + "list_fields": False, "list_depth": None, "sha256": hashlib.sha256(expected).hexdigest()}) + before = {f: f.read_bytes() for f in tmp_path.iterdir() if f.is_file()} + invoke = turn_envelope._turn_envelope_result + requests = [] + + def observe(operation, **kwargs): + requests.append(kwargs) + return invoke(operation, **kwargs) + + monkeypatch.setattr(turn_envelope, "_turn_envelope_result", observe) + command = f"loopx --format json quota context-read --reference-file {shlex.quote(str(reference))} --page-bytes 3000 --max-page-output-bytes 3500" + content, pages = b"", 0 + while True: + page = read(command) + assert page["ok"] and page["offset"] == len(content), page + assert _context_pages(page) == [] + assert capture_page_range(page, raw=path.read_bytes(), digest=digest) is None + content += page["content"].encode() + pages += 1 + if page["eof"]: + break + command = page["next_command"] + assert pages > 1 and content == expected + assert len(requests) == pages * (2 if layout == "legacy_canonical" else 1) + assert requests[0]["preserve_selection_order"] is True + if layout == "legacy_canonical": + assert all(not request.get("preserve_selection_order", False) for request in requests[1::2]) + assert {f: f.read_bytes() for f in before} == before + + +def test_resolved_initial_addresses_replay_without_replanning_the_initial_profile(tmp_path, monkeypatch): + from loopx.control_plane.quota import turn_envelope + + _, machine, model = fixture(tmp_path) + page = read(model["first_read_command"], page_bytes=3000, max_page_output_bytes=3500) + initial = b"" + while True: + assert page["ok"] and page["offset"] == len(initial), page + initial += page["content"].encode() + if page["eof"]: + break + page = read(page["next_command"]) + addresses = [row["pointer"] for row in json.loads(initial)["selections"]] + # The existing metadata writer can pin the exact resolved selection, + # independently of which producer supplied those addresses. + pinned = write_context_read_reference(Path(machine["path"]), machine["sha256"], selection={ + "select": addresses, "initial_read": False, "list_fields": False, "list_depth": None, + "sha256": hashlib.sha256(initial).hexdigest()}) + before = {f: f.read_bytes() for f in tmp_path.iterdir() if f.is_file()} + first = read(model["command"], reference_file=str(pinned), page_bytes=3000, max_page_output_bytes=3500) + assert first["ok"] and not first["eof"] + invoke = turn_envelope._turn_envelope_result + + def changed_initial(operation, **kwargs): + assert not kwargs.get("initial_read"), "Pinned addresses must not rerun a changed initial profile" + return invoke(operation, **kwargs) + + monkeypatch.setattr(turn_envelope, "_turn_envelope_result", changed_initial) + replay, page = first["content"].encode(), first + while not page["eof"]: + page = read(page["next_command"]) + assert page["ok"] and page["offset"] == len(replay), page + replay += page["content"].encode() + assert replay == initial + assert {f: f.read_bytes() for f in before} == before + + @pytest.mark.parametrize("selection", ["fields", "index", "initial"]) def test_selected_read_survives_unwritable_reference_metadata(tmp_path, monkeypatch, selection): import errno diff --git a/tests/control_plane_ts/context_selection.test.ts b/tests/control_plane_ts/context_selection.test.ts index 8c3b382e1a..013d23c005 100644 --- a/tests/control_plane_ts/context_selection.test.ts +++ b/tests/control_plane_ts/context_selection.test.ts @@ -120,7 +120,10 @@ test("full decision discovery points to current controls and whole source obliga {selections: [{pointer: "/interaction_contract/agent_channel/work_context/sources/0/content/full_criteria", path: ["interaction_contract", "agent_channel", "work_context", "sources", 0, "content", "full_criteria"]}]}); assert.deepEqual(context, before); - assert.deepEqual(evaluateTurnEnvelope({operation: "context_select", context, initial_read: true}), select(context, pointers)); + const initial = evaluateTurnEnvelope({operation: "context_select", context, initial_read: true}); + assert.deepEqual((initial.selections as {pointer: string}[]).map(row => row.pointer), pointers); + assert.deepEqual(selectedFacts(context, initial.selections as {path: (string | number)[]}[]), + selectedFacts(context, select(context, pointers).selections as {path: (string | number)[]}[])); }); // Compare the selected facts independently of their parent/child packaging. @@ -152,7 +155,9 @@ test("initial navigation coalesces complete branches without changing selected f const before = structuredClone(context); const explicit = select(context, pointers); const initial = capturedContextSelection({context, initial_read: true}, pointers); - assert.deepEqual(initial, select(context, ["/facts", "/single/only"])); + assert.deepEqual(initial, {selections: [ + {pointer: "/single/only", path: ["single", "only"]}, {pointer: "/facts", path: ["facts"]}, + ]}); assert.deepEqual(selectedFacts(context, initial.selections as {path: (string | number)[]}[]), selectedFacts(context, explicit.selections as {path: (string | number)[]}[])); assert.deepEqual((explicit.selections as {pointer: string}[]).map(row => row.pointer), [...pointers].sort()); @@ -191,13 +196,91 @@ test("partial coverage never expands unknown or optional facts through a parent" required: {unknown_obligation: "This selected unknown obligation stays whole."}}; const pointers = ["/controls/allowed", "/controls/instruction", "/attachment/required", "/attachment/command", "/required"]; const initial = capturedContextSelection({context, initial_read: true}, pointers); - assert.deepEqual(initial, select(context, pointers)); + assert.deepEqual((initial.selections as {pointer: string}[]).map(row => row.pointer), pointers); + assert.deepEqual(selectedFacts(context, initial.selections as {path: (string | number)[]}[]), + selectedFacts(context, select(context, pointers).selections as {path: (string | number)[]}[])); const facts = selectedFacts(context, initial.selections as {path: (string | number)[]}[]); assert.ok(facts.some(value => value.includes("selected unknown obligation"))); assert.ok(!facts.some(value => /not selected|deferred recipe/.test(value))); assert.throws(() => capturedContextSelection({context, initial_read: true}, [...pointers, "/missing"]), /does not exist/); }); +test("initial reads deliver current controls and required sources before the optional unread index", () => { + const context = {ok: true, goal_id: "fixture", execution_obligation: {must_attempt_work: false}, + goal_boundary: {stop_condition: "Stop before dependent work."}, selected_todo: {todo_id: "current"}, + context_details: {unread_context_index: {pointers: Array.from({length: 4000}, (_, i) => `/history/${i}`)}}, + interaction_contract: {schema_version: "loopx_interaction_contract_v0", agent_channel: { + required_reads: ["Read the full current source."], work_context: {sources: [ + {source: "goal", content: {text: "Complete current Goal.", unknown: "Unselected detail."}}, + {source: "preferences", content: {current: {unknown_constraint: "Preserve this current preference."}}}, + ]}}, cli_channel: {spend_allowed_now: false, settlement_plan: { + schema_version: "future", unknown_stop: "Keep the complete future settlement obligation."}}}, + }; + const before = structuredClone(context); + const initial = evaluateTurnEnvelope({operation: "context_select", context, initial_read: true}); + const rows = initial.selections as {pointer: string; path: (string | number)[]}[]; + const optional = rows.findIndex(row => row.path.includes("context_details")); + assert.ok(optional > 0); + for (const path of [["execution_obligation"], ["goal_boundary"], ["selected_todo"], + ["interaction_contract", "agent_channel", "required_reads"], + ["interaction_contract", "agent_channel", "work_context", "sources", 0, "content", "text"], + ["interaction_contract", "agent_channel", "work_context", "sources", 1, "content", "current"], + ["interaction_contract", "cli_channel", "settlement_plan"]]) { + const position = rows.findIndex(row => row.path.every((key, i) => key === path[i]) && row.path.length <= path.length); + assert.ok(position >= 0 && position < optional, JSON.stringify(path)); + } + const discovery = evaluateTurnEnvelope({operation: "context_select", context, select: [""], list_fields: true}); + assert.deepEqual(rows.map(row => row.pointer), discovery.initial_read_pointers); + const explicit = select(context, discovery.initial_read_pointers); + assert.deepEqual(selectedFacts(context, rows), selectedFacts(context, explicit.selections as {path: (string | number)[]}[])); + assert.deepEqual(context, before); +}); + +test("initial pointer deduplication preserves owner priority without changing explicit sorting", () => { + const context = {last: false, first: null, unselected: []}; + const pointers = ["/last", "/last", "/first"]; + const initial = capturedContextSelection({context, initial_read: true}, pointers); + assert.deepEqual((initial.selections as {pointer: string}[]).map(row => row.pointer), ["/last", "/first"]); + assert.deepEqual((select(context, pointers).selections as {pointer: string}[]).map(row => row.pointer), ["/first", "/last"]); + assert.throws(() => capturedContextSelection({context, initial_read: true}, [...pointers, "/missing"]), /does not exist/); +}); + +test("pinned rendering preserves recorded order after canonical address validation", () => { + const context = {z: {tail: "Keep this stop."}, "\ue000": null, "😀": 3, unselected: false}; + const pointers = ["/z", "/\ue000", "/z", "/😀"]; + const canonical = select(context, pointers); + const pinned = evaluateTurnEnvelope({operation: "context_select", context, select: pointers, preserve_selection_order: true}); + assert.deepEqual((pinned.selections as {pointer: string}[]).map(row => row.pointer), ["/z", "/\ue000", "/😀"]); + assert.deepEqual((canonical.selections as {pointer: string}[]).map(row => row.pointer), ["/z", "/😀", "/\ue000"]); + assert.deepEqual(selectedFacts(context, pinned.selections as {path: (string | number)[]}[]), + selectedFacts(context, canonical.selections as {path: (string | number)[]}[])); + assert.deepEqual(evaluateTurnEnvelope({operation: "context_select", context, select: pointers, + preserve_selection_order: false}), canonical); + const index = evaluateTurnEnvelope({operation: "context_select", context, select: pointers, + list_fields: true, preserve_selection_order: true}); + assert.deepEqual((index.selections as {pointer: string}[]).map(row => row.pointer), ["/z", "/\ue000", "/😀"]); +}); + +test("recorded order does not change rejected-batch precedence or diagnostics", () => { + const context = {z: {}, a: {}}; + for (const recover_selection_errors of [false, true]) { + const request = {operation: "context_select", context, select: ["/z/missing", "/a/missing"], recover_selection_errors}; + if (recover_selection_errors) { + assert.deepEqual(evaluateTurnEnvelope({...request, preserve_selection_order: true}), evaluateTurnEnvelope(request)); + } else { + let canonical: unknown; + try { evaluateTurnEnvelope(request); } catch (error) { canonical = effectRuntimeErrorPayload(error); } + assert.throws(() => evaluateTurnEnvelope({...request, preserve_selection_order: true}), error => { + assert.deepEqual(effectRuntimeErrorPayload(error), canonical); + return true; + }); + } + } + for (const preserve_selection_order of ["true", null, 0, {}]) + assert.throws(() => evaluateTurnEnvelope({operation: "context_select", context, select: ["/z"], + preserve_selection_order}), /must be boolean/); +}); + test("unknown decision schemas and ordinary selections keep generic discovery", () => { for (const context of [{value: true}, {interaction_contract: {schema_version: "future"}}]) { const result = evaluateTurnEnvelope({operation: "context_select", context, select: [""], list_fields: true});