From 6be0e64f4f1e7a926d37c90b2301ee42427b50f4 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Wed, 11 Nov 2026 19:38:55 +0800 Subject: [PATCH 1/2] fix(context): accept an optional target for field listing Signed-off-by: huangruiteng --- docs/guides/context-delivery.md | 8 ++- docs/guides/context-delivery.zh-CN.md | 6 +- loopx/cli_commands/quota_registration.py | 16 +++++- .../references/targeted-diagnostics.md | 6 +- .../test_context_capture_selection.py | 57 ++++++++++++++++++- 5 files changed, 82 insertions(+), 11 deletions(-) diff --git a/docs/guides/context-delivery.md b/docs/guides/context-delivery.md index 9994534972..2127d50ba5 100644 --- a/docs/guides/context-delivery.md +++ b/docs/guides/context-delivery.md @@ -169,9 +169,11 @@ response's `recovery` identifies its complete current required view; Do not substitute one file's hash for another's. - Use known JSON Pointers directly; repeat `--select` for related fields. -- For an unknown pointer, list the nearest known parent with `--list`; use - `--list-depth 2` when it avoids another discovery round. To list the root, - omit `--select` rather than using `/`, which denotes an empty-name property. +- For an unknown pointer, list the nearest known parent with + `--list /interaction_contract --list-depth 2`. This is equivalent to + `--list --select /interaction_contract --list-depth 2`; repeated `--list POINTER` + and `--select POINTER` combine into the same index. To list the root, use bare + `--list` without selectors or `--list ''`; `/` denotes an empty-name property. - Follow the returned byte cursor for an unfinished selected range. EOF applies to that selection, not to unread fields elsewhere in the capture. - Size both reader pages and the host tool's combined output budget. Valid JSON, diff --git a/docs/guides/context-delivery.zh-CN.md b/docs/guides/context-delivery.zh-CN.md index edbd782222..e395398265 100644 --- a/docs/guides/context-delivery.zh-CN.md +++ b/docs/guides/context-delivery.zh-CN.md @@ -131,8 +131,10 @@ Full 表示完整保存视图可用,不要求判断任何问题前先遍历所 `context_details` 则可指向包含延后证据的原始 capture。不能交换两份文件的 hash。 - 已知 JSON Pointer 直接选择,相关字段用重复 `--select` 批量读取。 -- 未知 pointer 先对最近的已知父节点 `--list`;必要时用 `--list-depth 2` 减少发现往返。 - 列出根节点时省略 `--select`,不要写表示空名称属性的 `/`。 +- 未知 pointer 先对最近的已知父节点执行 `--list /interaction_contract --list-depth 2`, + 等价于 `--list --select /interaction_contract --list-depth 2`。重复的 `--list POINTER` + 与 `--select POINTER` 会合并到同一个索引。列出根节点用不带 selector 的 `--list` + 或 `--list ''`;`/` 仍表示空名称属性。 - 未完成的选择沿返回的字节 cursor 继续。EOF 只覆盖该选择,不代表读了其他字段。 - 同时控制 reader 页与宿主工具的合计输出预算。合法 JSON、落盘日志和 EOF 都不能 补上被宿主截掉的字节;缩小批次,从同一 capture 补缺失范围。 diff --git a/loopx/cli_commands/quota_registration.py b/loopx/cli_commands/quota_registration.py index 216fbd49e7..9599ad0e38 100644 --- a/loopx/cli_commands/quota_registration.py +++ b/loopx/cli_commands/quota_registration.py @@ -13,6 +13,16 @@ ) +class _ListContextFields(argparse.Action): + """Normalize an optional index target into the existing reader arguments.""" + + def __call__(self, parser: argparse.ArgumentParser, namespace: argparse.Namespace, + values: object, option_string: str | None = None) -> None: + setattr(namespace, self.dest, True) + if values is not None: + namespace.select = [*(getattr(namespace, "select", None) or []), values] + + def register_quota_command( subparsers: argparse._SubParsersAction[argparse.ArgumentParser], ) -> None: @@ -256,8 +266,10 @@ def register_quota_command( quota_parser.add_argument("--select", action="append", help="For context-read or enabled native JSON context-project: repeat an exact RFC 6901 JSON pointer to read a batch from the original capture. " "Selected detail pages never acknowledge full-context retention; omit for unchanged full capture reads.") - quota_parser.add_argument("--list", dest="list_fields", action="store_true", - help="For context-read: list immediate fields at the selected pointers (root by default), " + quota_parser.add_argument("--list", dest="list_fields", action=_ListContextFields, + nargs="?", metavar="POINTER", default=False, + help="For context-read: list immediate fields at the selected pointers (root by default). " + "An optional exact JSON pointer is shorthand for --list --select POINTER; repeat to list related parents, " "with exact pointers, types and sizes. Does not read bodies or establish a delta baseline.") quota_parser.add_argument("--list-depth", type=int, help="For context-read --list: expand 1 to 8 levels in one index (default: 1). " diff --git a/skills/loopx-self-repair/references/targeted-diagnostics.md b/skills/loopx-self-repair/references/targeted-diagnostics.md index 64213c7b4a..eda0f0ce3c 100644 --- a/skills/loopx-self-repair/references/targeted-diagnostics.md +++ b/skills/loopx-self-repair/references/targeted-diagnostics.md @@ -38,8 +38,10 @@ available content; it does not prove that content was read. Avoid selecting both a parent and its children, which repeats the same evidence. For captured LoopX context, repeat `--select` on the original capture/hash. -Use `--list` only for unknown pointers and `--list-depth 2` for useful nested -discovery. A missing pointer rejects the entire selection: inspect its parent in +Use `--list --select /parent --list-depth 2` for unknown nested pointers; +current readers also accept `--list /parent --list-depth 2`. For the root, omit +the pointer or pass `''`; `/` selects an empty-name property. A missing pointer +rejects the entire selection: inspect its parent in that same capture before retrying, rather than guessing another batch or rerunning admission. For large arrays, prefer the known record selector or a focused item over recursively listing every record. diff --git a/tests/control_plane/test_context_capture_selection.py b/tests/control_plane/test_context_capture_selection.py index 220ec7e87d..c31b4e5479 100644 --- a/tests/control_plane/test_context_capture_selection.py +++ b/tests/control_plane/test_context_capture_selection.py @@ -302,11 +302,63 @@ def test_cli_index_defaults_to_root_without_registry_or_runtime(tmp_path): assert not registry.exists() and not runtime.exists() -def test_cli_nested_index_preserves_depth_and_digest_across_pages(tmp_path): +@pytest.mark.parametrize("targets", [["/rows/0"], ["/a~1b"], [""], ["/rows/0", "/a~1b"]]) +def test_cli_list_target_shorthand_matches_explicit_selection(tmp_path, targets): path, raw, original = fixture_capture(tmp_path) registry, runtime = tmp_path / "absent.json", tmp_path / "absent-runtime" command = ["quota", "context-read", "--capture-file", str(path), - "--capture-sha256", hashlib.sha256(raw).hexdigest(), "--list", "--list-depth", "3", + "--capture-sha256", hashlib.sha256(raw).hexdigest(), "--list-depth", "2"] + explicit = ["--list"] + [word for target in targets for word in ("--select", target)] + shorthand = [word for target in targets for word in ("--list", target)] + code, expected = _run_cli(registry, runtime, *command, *explicit, cwd=tmp_path) + assert code == 0 and expected["eof"], expected + code, actual = _run_cli(registry, runtime, *command, *shorthand, cwd=tmp_path) + assert code == 0 and actual == expected, actual + assert original["rows"][0]["tail"] not in actual["content"] + assert capture_page_range(actual, raw=raw, digest=actual["sha256"]) is None + assert path.read_bytes() == raw and not registry.exists() and not runtime.exists() + + +@pytest.mark.parametrize("options", [ + ["--list", "/rows/0", "--select", "/a~1b"], + ["--select", "/rows/0", "--list", "/a~1b"], +]) +def test_cli_list_target_combines_with_existing_selectors(tmp_path, options): + path, raw, _ = fixture_capture(tmp_path) + registry, runtime = tmp_path / "absent.json", tmp_path / "absent-runtime" + code, page = _run_cli(registry, runtime, "quota", "context-read", "--capture-file", str(path), + "--capture-sha256", hashlib.sha256(raw).hexdigest(), *options, cwd=tmp_path) + assert code == 0 and page["eof"], page + assert set(page["selected_pointers"]) == {"/rows/0", "/a~1b"} + assert not registry.exists() and not runtime.exists() + + +def test_cli_list_slash_does_not_silently_select_root(tmp_path): + path, raw, _ = fixture_capture(tmp_path) + registry, runtime = tmp_path / "absent.json", tmp_path / "absent-runtime" + command = ["quota", "context-read", "--capture-file", str(path), + "--capture-sha256", hashlib.sha256(raw).hexdigest()] + code, failure = _run_cli(registry, runtime, *command, "--list", "/", cwd=tmp_path) + assert code != 0 and failure["diagnostic_code"] == "context_selection_missing_pointer" + assert "content" not in failure + # RFC 6901 '/' selects the empty-name property when one actually exists. + raw = json.dumps({"": {"child": "private body"}, "other": 1}).encode() + path.write_bytes(raw) + command[-1] = hashlib.sha256(raw).hexdigest() + code, page = _run_cli(registry, runtime, *command, "--list", "/", cwd=tmp_path) + assert code == 0 and page["selected_pointers"] == ["/"], page + fields = json.loads(page["content"])["selections"][0]["fields"] + assert [field["pointer"] for field in fields] == ["//child"] + assert "private body" not in page["content"] + assert not registry.exists() and not runtime.exists() + + +@pytest.mark.parametrize("index_target", [[], [""]]) +def test_cli_nested_index_preserves_depth_and_digest_across_pages(tmp_path, index_target): + path, raw, original = fixture_capture(tmp_path) + registry, runtime = tmp_path / "absent.json", tmp_path / "absent-runtime" + command = ["quota", "context-read", "--capture-file", str(path), + "--capture-sha256", hashlib.sha256(raw).hexdigest(), "--list", *index_target, "--list-depth", "3", "--page-bytes", "256", "--max-page-output-bytes", "2000"] combined, first, offset = b"", None, 0 while True: @@ -342,6 +394,7 @@ def test_cli_nested_index_preserves_depth_and_digest_across_pages(tmp_path): ["context-read", "--list", "--list-depth", "0"], ["context-read", "--list", "--list-depth", "9"], ["should-run", "--list-depth", "2"], + ["should-run", "--list", "/rows", "--list-depth", "2"], ]) def test_cli_rejects_invalid_depth_before_authority(tmp_path, options): registry, runtime = tmp_path / "absent.json", tmp_path / "absent-runtime" From 560a07e0d5d6ede0ad6af2a8af53f46ef62c360c Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Fri, 13 Nov 2026 02:00:47 +0800 Subject: [PATCH 2/2] fix(context): preserve bare list before context-read Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/cli.py | 3 +- loopx/cli_commands/quota_registration.py | 31 +++++++++++++------ loopx/cli_runtime.py | 10 +++++- .../test_context_capture_selection.py | 17 ++++++++++ 4 files changed, 49 insertions(+), 12 deletions(-) diff --git a/loopx/cli.py b/loopx/cli.py index 265fb7705c..bc31a8c02f 100644 --- a/loopx/cli.py +++ b/loopx/cli.py @@ -210,6 +210,7 @@ dispatch_common_command, enforce_native_controller_guard, output_format, + parse_cli_args, print_payload, resolve_cli_registry, resolve_global_output_format, @@ -354,7 +355,7 @@ def _main(argv: list[str] | None = None) -> int: print(render_concise_help(sys.argv[0] if argv is None else "loopx"), end="") return 0 parser = build_parser() - args = parser.parse_args(raw_argv) + args = parse_cli_args(parser, raw_argv) from .usage_ping import select_operation select_operation(args) args.format = resolve_global_output_format(args) diff --git a/loopx/cli_commands/quota_registration.py b/loopx/cli_commands/quota_registration.py index 9599ad0e38..51350e723e 100644 --- a/loopx/cli_commands/quota_registration.py +++ b/loopx/cli_commands/quota_registration.py @@ -13,14 +13,26 @@ ) -class _ListContextFields(argparse.Action): - """Normalize an optional index target into the existing reader arguments.""" - - def __call__(self, parser: argparse.ArgumentParser, namespace: argparse.Namespace, - values: object, option_string: str | None = None) -> None: - setattr(namespace, self.dest, True) - if values is not None: - namespace.select = [*(getattr(namespace, "select", None) or []), values] +def normalize_context_list_args(argv: list[str]) -> list[str]: + """Translate only an explicit JSON pointer after the existing flag.""" + normalized = [] + index = 0 + while index < len(argv): + arg = argv[index] + if arg == "--": + normalized.extend(argv[index:]) + break + if arg.startswith("--list="): + normalized.extend(("--list", "--select=" + arg.removeprefix("--list="))) + else: + normalized.append(arg) + if arg == "--list" and index + 1 < len(argv): + pointer = argv[index + 1] + if pointer == "" or pointer.startswith("/"): + normalized.append("--select=" + pointer) + index += 1 + index += 1 + return normalized def register_quota_command( @@ -266,8 +278,7 @@ def register_quota_command( quota_parser.add_argument("--select", action="append", help="For context-read or enabled native JSON context-project: repeat an exact RFC 6901 JSON pointer to read a batch from the original capture. " "Selected detail pages never acknowledge full-context retention; omit for unchanged full capture reads.") - quota_parser.add_argument("--list", dest="list_fields", action=_ListContextFields, - nargs="?", metavar="POINTER", default=False, + quota_parser.add_argument("--list", dest="list_fields", action="store_true", help="For context-read: list immediate fields at the selected pointers (root by default). " "An optional exact JSON pointer is shorthand for --list --select POINTER; repeat to list related parents, " "with exact pointers, types and sizes. Does not read bodies or establish a delta baseline.") diff --git a/loopx/cli_runtime.py b/loopx/cli_runtime.py index 5624deb6d2..98cbfde64a 100644 --- a/loopx/cli_runtime.py +++ b/loopx/cli_runtime.py @@ -295,6 +295,14 @@ def _top_level_command(argv: list[str]) -> str | None: return None +def parse_cli_args(parser: LoopXArgumentParser, argv: list[str]) -> argparse.Namespace: + if _top_level_command(argv) == "quota": + from .cli_commands.quota_registration import normalize_context_list_args + + argv = normalize_context_list_args(argv) + return parser.parse_args(argv) + + @contextmanager def cli_invocation_route(argv: list[str]): """Read the global route only; the canonical parser still validates argv.""" @@ -958,7 +966,7 @@ def _run_command(command: str | None, raw_argv: list[str]) -> int: return _run_full_cli(raw_argv) if command in _SELECTED_COMMANDS: try: - args = _build_selected_parser(command).parse_args(raw_argv) + args = parse_cli_args(_build_selected_parser(command), raw_argv) except _SelectedParseRejected: return _run_full_cli(raw_argv) if command == "history" and args.history_action is not None and not _standalone_core(): diff --git a/tests/control_plane/test_context_capture_selection.py b/tests/control_plane/test_context_capture_selection.py index c31b4e5479..53a0f07e40 100644 --- a/tests/control_plane/test_context_capture_selection.py +++ b/tests/control_plane/test_context_capture_selection.py @@ -302,6 +302,23 @@ def test_cli_index_defaults_to_root_without_registry_or_runtime(tmp_path): assert not registry.exists() and not runtime.exists() +@pytest.mark.parametrize("listing", [ + ["--list", "context-read"], + ["--list", "/rows/0", "context-read", "--list=/a~1b"], +]) +def test_cli_list_before_context_read_preserves_action(tmp_path, listing): + path, raw, _ = fixture_capture(tmp_path) + registry, runtime = tmp_path / "absent.json", tmp_path / "absent-runtime" + capture = ["--capture-file", str(path), "--capture-sha256", hashlib.sha256(raw).hexdigest()] + expected_args = ["--select", "/rows/0", "--select", "/a~1b"] if len(listing) > 2 else [] + code, expected = _run_cli(registry, runtime, "quota", "context-read", "--list", + *expected_args, *capture, cwd=tmp_path) + assert code == 0 and expected["eof"], expected + code, actual = _run_cli(registry, runtime, "quota", *listing, *capture, cwd=tmp_path) + assert code == 0 and actual == expected, actual + assert not registry.exists() and not runtime.exists() + + @pytest.mark.parametrize("targets", [["/rows/0"], ["/a~1b"], [""], ["/rows/0", "/a~1b"]]) def test_cli_list_target_shorthand_matches_explicit_selection(tmp_path, targets): path, raw, original = fixture_capture(tmp_path)