From a88619fa02f3269db30e25c044ed7253640477cd Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:36:34 +0800 Subject: [PATCH] fix(context): restore large native reads and parse mixed stdout records Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- docs/heartbeat-automation-prompt.md | 11 +- docs/reference/required-work-context.md | 105 +++++++++--------- loopx/cli_commands/quota_capture.py | 22 ++-- loopx/cli_commands/quota_registration.py | 4 +- .../turn_driver/codex_context.py | 17 ++- .../turn_driver/decision_capture.py | 21 ++-- .../test_codex_context_lifecycle.py | 55 ++++++++- .../test_context_delivery_cli.py | 41 ++++++- .../test_saved_context_project.py | 7 +- 9 files changed, 195 insertions(+), 88 deletions(-) diff --git a/docs/heartbeat-automation-prompt.md b/docs/heartbeat-automation-prompt.md index 491315af69..0b03335271 100644 --- a/docs/heartbeat-automation-prompt.md +++ b/docs/heartbeat-automation-prompt.md @@ -482,9 +482,9 @@ The result contains `native_config.config_overrides`, `native_config.hooks` and its existing developer instructions. Apply each flat key/value in `config_overrides` to the native host configuration (or as a `-c key=value` override). This selects 64000-byte context continuations, a 20000-token history limit, and a shell-scoped -reader budget declaration. The initial guard stays small. Its instruction requests +reader budget declaration. The initial guard uses the same output budget. Its instruction requests 22000 tokens per reader result and 24000 for code-mode emission, with one directly -forwarded stdout per call. Unconfigured hosts retain the small-page default; +forwarded stdout per call. Native paging uses this default without the shell declaration; the isolated benchmark worker applies this profile automatically when opted in. Keep short context readers in the foreground (`exec_command`'s normal 10000 ms wait). Early output followed by polling can leave the native post-tool hook with @@ -519,6 +519,13 @@ these options without freezing a Turn ID. Each wake loads current instructions and binds the existing `LOOPX_TURN` before its generated guard. Selection keeps the same transport. An unset wake identity fails in the shell before admission. +Native guards and JSON recovery readers use the existing 64000-byte output +profile even when an App shell lacks the configuration environment override. +Small captures finish inline; larger captures continue with the same large-page +budget. Explicit smaller budgets remain available for truncated-range recovery. +The native hook accepts complete JSON records alongside other shell output and +still verifies each record against the trusted original capture before reuse. + Native reuse assumes a normally completed full read was read and remains available until a lifecycle invalidation. `PostToolUse` merges complete reader ranges in the existing private session journal. EOF alone, known read failures, diff --git a/docs/reference/required-work-context.md b/docs/reference/required-work-context.md index 109f81071a..8aada9fa79 100644 --- a/docs/reference/required-work-context.md +++ b/docs/reference/required-work-context.md @@ -283,11 +283,12 @@ setting. Ordinary machine validation still applies to business operations. Native `quota should-run` and the saved `context-project` entry share the same paged presentation. Each returns page zero, a digest-pinned continuation and full -recovery without another admission. The inline page contains up to 3 KiB and shrinks -to keep the complete guard stdout, including recovery and instruction fields, -within 7900 UTF-8 bytes. Without the generated native output configuration, continuations -request up to 8 KiB of content with `--max-page-output-bytes 7900`, which bounds -successful JSON stdout including escaping, commands and newline. Consume the inline +recovery without another admission. Inline and continuation pages use the same +64000-byte output budget, including recovery, instructions, escaping, commands +and newline. Content shrinks at UTF-8 boundaries to fit that complete stdout; +captures that fit finish inline. This default does not depend on a shell output +declaration. Explicit smaller reader budgets remain available to recover a +truncated range of the same capture. Consume the inline page before following next_command; when the retained-history limit is unknown, read one bounded output at a time. This is not a guarantee of model retention. Forward exec stdout directly in code mode. Repeating the packet in @@ -319,12 +320,12 @@ their separate confirmed-input contract. For native setup, `quota context-config --codex-app --goal-id GOAL --agent-id AGENT` under machine on prepares only that private transport leaf and renders the existing hooks, recovery instructions and `config_overrides`. Applying those overrides -selects 64000-byte continuation outputs and a 20000-token native history limit; -the initial guard remains within 7900 bytes. The worker applies this configuration +selects a 20000-token native history limit and declares the existing 64000-byte +output profile. Both the initial guard and continuations use that page budget, +including when the shell lacks the declaration. The worker applies this configuration automatically inside its isolated opt-in trial. The persistent instruction requests 22000 tokens for each context-read tool result and 24000 for its code-mode emission, -forwarding one stdout at a time. Unknown or unsupported output declarations retain -the small-page default. Remove the generated output overrides together with the +forwarding one stdout at a time. Remove the generated output overrides together with the context setup on rollback; saving a machine preference alone does not apply them. This output sizing does not prove retained input, lower fees or task benefit. For an SSH Goal, replace `--codex-app` @@ -529,10 +530,10 @@ Codex App SSH Goal(`--runtime-profile codex_app_ssh_goal`),以及现有 Co SSH Goal 使用 `--runtime-profile codex_app_ssh_goal` 生成 context-config 和 thin/bootstrap 提示, 沿用可见 Goal 的执行与结算身份;仅启用差分时接入捕获目录,不转换成自动心跳。 原生 `quota should-run` 与已保存捕获的 `context-project` 共用分页呈现,直接返回第零页、 -固定摘要的后续读取与完整恢复,不另行准入。内联正文最多 3 KiB,必要时缩小,使包含 -恢复字段与完整指引的 guard stdout 不超过 7900 UTF-8 字节。未应用生成的原生输出配置时,后续命令最多请求 -8 KiB 正文,并用 `--max-page-output-bytes 7900` 将成功 JSON stdout(含转义、 -后续命令与换行)限制为 7900 字节。先消费内联页,再跟随 next_command;保留历史 +固定摘要的后续读取与完整恢复,不另行准入。内联页与续页共用 64000 字节输出预算, +包含恢复字段、完整指引、转义、后续命令与换行;正文按 UTF-8 边界缩小以适配完整 stdout, +能放下的捕获一次返回。默认值不依赖 shell 输出声明;显式更小的读取预算仍可用于恢复 +同一捕获中被截断的范围。先消费内联页,再跟随 next_command;保留历史 上限未知时,每次只读一个有界输出。该上限不证明模型保留。Code Mode 直接转发 exec stdout;把整包重打进哈希工具不构成原始字节已被保留的证明。 这些短时本地读取命令应前台等到结束;`exec_command` 使用正常的 10000 ms 等待, @@ -540,10 +541,11 @@ exec stdout;把整包重打进哈希工具不构成原始字节已被保留的 `PostToolUse`;模型可见正文完整不等于 hook 观察范围完整。读取器若提前返回,仍须 消费全部输出片段;hook 覆盖缺失时,下次决定继续全量投递。不伪造读取回执或重跑 准入来制造差分;丢失上下文后继续该已捕获决定,仍沿它的精确全量恢复引用读取。 -原生配置同时返回 `config_overrides`:应用后,续读输出上限为 64000 字节,原生历史 -上限为 20000 token,首次 guard 仍保持 7900 字节。开启的 benchmark worker 在其 +原生配置同时返回 `config_overrides`:应用后,原生历史上限为 20000 token,并声明已有 +64000 字节输出配置。首次 guard 和续页均使用此分页预算,shell 缺少声明时也一样。 +开启的 benchmark worker 在其 隔离会话内自动应用;持久指引要求每次续读直接输出预算 22000 token、Code Mode -输出预算 24000 token,每次直接转发一份 stdout。未知或不支持的输出声明保持小页。 +输出预算 24000 token,每次直接转发一份 stdout。 回滚时同时移除生成的输出覆盖项;仅保存机器偏好并不会应用这些宿主配置。 该设置不证明输入保留、总费用降低或任务效果改善。 读取器校验不可变 capture 的摘要。Post-tool 适配层复用显式输入观察的字节范围校验, @@ -617,33 +619,30 @@ remains inert and reads the original capture. Failures retain that capture's exact digest and Turn receipt; they never rerun admission or spend quota. With delivery on, a successful saved projection also returns page zero using -the existing `context_capture_page_v1` fields. Its content is bounded to 3 KiB -and shrinks against the final presentation's 7900-byte stdout limit, including -recovery, activation and instruction metadata. Without the generated output profile, subsequent native commands request up to -8 KiB of content within a 7900-byte successful JSON stdout limit. The optional -JSON-only `--max-page-output-bytes` flag shrinks content at UTF-8 boundaries and -preserves the limit in `next_command`. An insufficient limit returns an error -without a zero-progress page; increase it for the same capture. Omitting the flag -preserves legacy reader output. Error messages are not subject to an impossible -page limit, and this option changes no host or retained-history budget. -When both direct-result and retained-history budgets are known to be sufficient, -the caller can increase both `--page-bytes` and `--max-page-output-bytes`, for -example to `32000`, on the same digest-pinned continuation. The body may exceed -8 KiB only with that explicit output cap, up to the existing capture size limit. -The cap includes escaping, framing and newline; `next_command` preserves it. -Codex 0.162.1 qualification uses a 10,000-token history setting and a 12,000-token -code-mode result budget with one directly forwarded stdout per call. Unknown -budgets keep the existing 7900-byte native default. A larger requested result -alone never proves history retention; missing output still needs same-capture -recovery. The reader flag changes no host setting. The shared native setup described -above supplies the larger 64000-byte profile and its matching history/execution -budgets together; only the opted-in worker applies them automatically. A legacy -consumer that has not applied that setup keeps the existing small-page boundary. +the existing `context_capture_page_v1` fields. It uses the same 64000-byte +complete-stdout budget as continuations, including recovery, activation and +instructions; captures that fit finish inline. JSON `context-read` with no page +arguments also defaults to 64000 content bytes under a 64000-byte output cap. +Markdown retains its 4096-byte content default. Explicit page/output arguments +retain their prior semantics; a page above 8192 bytes still requires a sufficient +output cap, within the existing capture limit. The JSON-only +`--max-page-output-bytes` shrinks content at UTF-8 boundaries and persists in +`next_command`, including for explicitly smaller same-capture recovery. An +insufficient cap returns an error without a zero-progress page; increase it for +the same capture. Error messages are not subject to an impossible page limit. +The reader changes no host or retained-history setting. Historical Codex 0.162.1 +qualification used a 10,000-token history setting and a 12,000-token code-mode +result budget with one directly forwarded stdout per call; that observation +does not establish another host's output capacity. The shared native setup above +supplies matching history/execution budgets; only the opted-in worker applies +them automatically. Larger defaults never prove history retention, and missing +output still requires recovery from this same capture. Consume `content`, then follow `next_command` through `eof` for full byte coverage. The reader verifies the original digest; do not retype or hash the content. The existing `command` still restarts at zero for older readers or incomplete inline output. If the optional first-page read fails, that same command remains usable. -Off responses do not add a page or change their existing guidance. No admission +Off responses do not add an inline page or activate native reuse; their recovery +references use the same JSON reader default. No admission or retention receipt is introduced; an inline page alone cannot prove complete input retention. @@ -655,22 +654,20 @@ input retention. 原始摘要和 Turn 回执,不重新准入或扣减额度。 开启时,成功的已保存投影同时用现有 `context_capture_page_v1` 字段返回第一页。 -该页内容最多 3 KiB,按最终呈现的 7900 字节 stdout 上限缩小,包含恢复、启用配置与 -完整指引元数据;未应用生成的输出配置时,后续原生命令最多请求 8 KiB -正文,成功 JSON stdout 限制为 7900 字节。可选的 JSON 专用参数 -`--max-page-output-bytes` 按 UTF-8 边界缩减正文,并在 `next_command` 中保留。 -上限不足时返回错误,不返回无进展的分页;增大上限后重读同一捕获。省略该参数 -保持旧读取输出。错误消息不受无法满足的分页上限限制,也不更改宿主或历史输出预算。 -直接输出和保留历史预算均已确认充足时,可以在同一摘要固定的续读命令上同时增大 -`--page-bytes` 和 `--max-page-output-bytes`,例如均为 `32000`。只有显式给出输出 -上限才能请求超过 8 KiB 的正文,最大仍受既有 capture 大小限制;上限包含转义、 -JSON 外层与换行,后续命令继承该值。Codex 0.162.1 定向验证采用 10,000 token 历史 -设置和 12,000 token code-mode 输出预算,每次直接转发一份 stdout。未知预算保持 -7900 字节原生默认;大输出请求不证明历史保留,缺失仍恢复同一 capture。 -读取参数本身不修改宿主设置。上述共享原生配置将 64000 字节分页与配套的历史和执行 -预算一起提供,仅开启的 worker 自动应用;尚未应用该配置的旧读取方保持小页边界。 +该页与续页共用 64000 字节完整 stdout 预算,包含恢复、启用配置与完整指引;能放下的 +捕获一次返回。JSON `context-read` 省略分页参数时,也默认在 64000 字节输出上限下 +请求 64000 字节正文;Markdown 保留 4096 字节正文默认值。显式分页/输出参数保留 +原有语义:超过 8192 字节的页仍须足够的输出上限,且不超过既有 capture 限制。 +JSON 专用参数 `--max-page-output-bytes` 按 UTF-8 边界缩减正文,并在 `next_command` +中保留,显式更小的同捕获恢复也一样。上限不足时返回错误,不返回无进展分页; +增大上限后重读同一捕获。错误消息不受无法满足的分页上限限制。 +读取器不修改宿主或保留历史设置。历史 Codex 0.162.1 定向验证采用 10,000 token 历史 +设置和 12,000 token code-mode 输出预算,每次直接转发一份 stdout;该观察不证明 +其他宿主的输出容量。上述共享原生配置提供配套历史和执行预算,仅开启的 worker +自动应用。较大默认值不证明历史保留,缺失输出仍恢复同一 capture。 先读取 `content`,再沿 `next_command` 读至 `eof`,确保完整字节覆盖。 读取器校验原始摘要;无需重抄正文或计算哈希。 原有 `command` 仍从零开始,供旧读取方或内联输出不完整时重读;可选的首屏读取 -失败时也保留该命令。关闭态不增加分页数据,也不改变既有指引;不新增准入或保留回执; +失败时也保留该命令。关闭态不增加内联分页或启用原生复用;其恢复引用同样采用 JSON +读取器默认值。不新增准入或保留回执; 单页内联内容不证明完整输入仍被保留。 diff --git a/loopx/cli_commands/quota_capture.py b/loopx/cli_commands/quota_capture.py index 6a77fce2f3..fdecb5d581 100644 --- a/loopx/cli_commands/quota_capture.py +++ b/loopx/cli_commands/quota_capture.py @@ -18,7 +18,7 @@ from ..control_plane.quota.error_codes import QuotaCommandValidationError, QuotaDecisionTransportError from ..control_plane.turn_driver.decision_capture import ( validate_handoff_socket, CONTEXT_CAPTURE_READ_GUIDANCE, CONTEXT_CAPTURE_PAGE_GUIDANCE, context_capture_read_command, - NATIVE_CONTEXT_PAGE_OUTPUT_BYTES, + NATIVE_CONTEXT_PAGE_BYTES, NATIVE_CONTEXT_PAGE_OUTPUT_BYTES, native_context_page_budget, read_context_capture_file as _read_capture, ) @@ -96,6 +96,13 @@ def read_context_capture(args: argparse.Namespace, *, presentation: dict[str, An if output_limit is not None and (type(output_limit) is not int or output_limit <= 0 or getattr(args, "format", None) != "json"): raise ValueError("max-page-output-bytes requires JSON output and a positive integer") + if size is None: + if args.format == "json": + if output_limit is None: + output_limit = NATIVE_CONTEXT_PAGE_OUTPUT_BYTES + size = max(8192, min(NATIVE_CONTEXT_PAGE_BYTES, output_limit)) + else: + size = 4096 from ..control_plane.turn_driver.codex_context import codex_context_directory context_dir = codex_context_directory(directory=getattr(args, "codex_context_dir", None), @@ -111,9 +118,8 @@ def read_context_capture(args: argparse.Namespace, *, presentation: dict[str, An raise ValueError("context-read requires the original capture file and its exact SHA-256") from ..control_plane.effect_runtime import MAX_REQUEST_BYTES - # Unknown host budgets keep the established small-page boundary. A - # caller with a qualified direct AND history budget can request one - # larger bounded output instead of many CLI/hook/model round trips. + # Default JSON reads share the native large-page profile. Explicit + # smaller budgets can recover a truncated range of this same capture. maximum = max(8192, min(output_limit or 8192, MAX_REQUEST_BYTES)) if type(offset) is not int or offset < 0 or type(size) is not int or not 256 <= size <= maximum: raise ValueError("context-read requires a nonnegative byte offset and page-bytes between 256 and 8192; " @@ -170,8 +176,8 @@ def native_context_capture_view(projected: dict[str, Any], directory: Path, *, # fields. Capping only content leaves the guard itself vulnerable to native # truncation before the consumer can discover its continuation command. page = read_context_capture(argparse.Namespace(capture_file=reference["path"], - capture_sha256=reference["sha256"], offset=0, page_bytes=3072, - format="json", max_page_output_bytes=NATIVE_CONTEXT_PAGE_OUTPUT_BYTES), + capture_sha256=reference["sha256"], offset=0, page_bytes=page_bytes, + format="json", max_page_output_bytes=output_bytes), presentation=result, next_page_bytes=page_bytes, next_page_output_bytes=output_bytes) if page["ok"]: result = page @@ -181,7 +187,7 @@ def native_context_capture_view(projected: dict[str, Any], directory: Path, *, {**result, "ancestry": projected.get("ancestry", 0)}) else: result["instruction"] = instruction + CONTEXT_CAPTURE_PAGE_GUIDANCE - if len(json.dumps(result, ensure_ascii=False, separators=(",", ":")).encode()) + 1 > NATIVE_CONTEXT_PAGE_OUTPUT_BYTES: + if len(json.dumps(result, ensure_ascii=False, separators=(",", ":")).encode()) + 1 > output_bytes: raise QuotaDecisionTransportError("Native context metadata exceeds the output limit; " "read the saved original decision and receipt without rerunning admission.") return result @@ -498,7 +504,7 @@ def handle_native_context_command(args: argparse.Namespace, *, print_payload: Ca + "Normal completed full reads establish an explicit continuity assumption; no input diagnostics or provider change is required. " "The generated output profile uses 64000-byte continuations and a 20000-token native history limit; " - "unknown budgets retain small pages. Known invalidation or incomplete reads keep full recovery. " + "the same page profile applies without the shell override. Explicit smaller reader budgets remain available for truncated-range recovery. " "Set --context-delivery off and remove the generated context configuration, including its output overrides, to restore ordinary output. " "This command installs nothing and grants no execution authority."} except (OSError, ValueError, TypeError) as exc: diff --git a/loopx/cli_commands/quota_registration.py b/loopx/cli_commands/quota_registration.py index 26f24623b0..3ee2c48513 100644 --- a/loopx/cli_commands/quota_registration.py +++ b/loopx/cli_commands/quota_registration.py @@ -226,8 +226,8 @@ def register_quota_command( "context-config renders native configuration for the exact Goal/Agent; no host configuration, trust, guard or spend are changed.") quota_parser.add_argument("--capture-sha256", help="For context-read/context-project: exact SHA-256 supplied with the capture reference.") quota_parser.add_argument("--offset", type=int, default=0, help="For context-read: byte offset returned by the previous page.") - quota_parser.add_argument("--page-bytes", type=int, default=4096, - help="For context-read: maximum UTF-8 content bytes per page, 256–8192 (default: 4096). " + quota_parser.add_argument("--page-bytes", type=int, + help="For context-read: maximum UTF-8 content bytes per page (JSON default: 64000 under a 64000-byte stdout budget; Markdown: 4096). " "Larger pages require a sufficient --max-page-output-bytes within the capture limit; " "use only with known sufficient direct and retained-history output budgets.") quota_parser.add_argument("--max-page-output-bytes", type=int, diff --git a/loopx/control_plane/turn_driver/codex_context.py b/loopx/control_plane/turn_driver/codex_context.py index 8b1b8f4fbb..cd0ab3a5f7 100644 --- a/loopx/control_plane/turn_driver/codex_context.py +++ b/loopx/control_plane/turn_driver/codex_context.py @@ -235,7 +235,6 @@ def codex_context_recovery_instructions(directory: str, *, goal_id: str, agent_i ("The generated native configuration supports 64000-byte context-read outputs and a 20000-token history limit. " "Read one continuation per tool call with max_output_tokens=22000. In code-mode, also set the exec " "pragma max_output_tokens to 24000 and forward successful stdout directly with text(result.output). " - "Keep the initial guard result small; these larger budgets apply to context-read calls only. " if page_output_bytes == NATIVE_CONTEXT_LARGE_OUTPUT_BYTES else "") + "For code-mode reads, size batches against both direct-result and retained-history output limits; " "max_output_tokens alone does not establish the latter. Read one output at a time with " @@ -342,7 +341,7 @@ def codex_context_hook(directory: str | None, event: dict[str, Any]) -> dict[str def _context_pages(value: object) -> list[dict[str, Any]]: - """Decode native shell/MCP JSON output; never classify arbitrary prose. + """Decode native shell/MCP JSON records; never classify arbitrary prose. Incomplete JSON has no successful page. The pending delivery stays pending. Native output truncation that is not reported here remains an assumption gap. @@ -351,7 +350,19 @@ def _context_pages(value: object) -> list[dict[str, Any]]: try: value = json.loads(value) except ValueError: - return [] + # CLI stdout is one complete JSON record per line. Other commands + # may precede/follow it in the same shell result; do not search for + # embedded JSON substrings or accept an incomplete record. Capture + # identity and every reported byte are still verified below. + pages = [] + for line in value.splitlines(): + try: + record = json.loads(line) + except ValueError: + continue + if isinstance(record, dict): + pages.extend(_context_pages(record)) + return pages if not isinstance(value, dict): return [] if value.get("schema_version") == "context_capture_page_v1": diff --git a/loopx/control_plane/turn_driver/decision_capture.py b/loopx/control_plane/turn_driver/decision_capture.py index 62a4285412..1f6f7184fe 100644 --- a/loopx/control_plane/turn_driver/decision_capture.py +++ b/loopx/control_plane/turn_driver/decision_capture.py @@ -133,27 +133,30 @@ def validate_handoff_socket(value: str) -> Path: ) -# Leave room below the native 2000-token exec output boundary for framing. -# This does not establish downstream retained-history capacity or retention. -NATIVE_CONTEXT_PAGE_BYTES = 8192 -NATIVE_CONTEXT_PAGE_OUTPUT_BYTES = 7900 -# Installed together by the opt-in native configuration. Unknown hosts retain -# the small-page boundary; this declaration is output capacity, not retention. +# Use the existing configured native output profile for every native caller, +# including App shells that do not inherit the generated environment override. +# Smaller explicit reader budgets remain available for same-capture recovery. +NATIVE_CONTEXT_PAGE_BYTES = 64000 +NATIVE_CONTEXT_PAGE_OUTPUT_BYTES = 64000 +# Keep the configuration declaration usable by older installed readers too. NATIVE_CONTEXT_LARGE_OUTPUT_BYTES = 64000 NATIVE_CONTEXT_HISTORY_TOKENS = 20000 NATIVE_CONTEXT_OUTPUT_ENV = "LOOPX_CONTEXT_PAGE_OUTPUT_BYTES" def native_context_page_budget() -> tuple[int, int]: - if os.environ.get(NATIVE_CONTEXT_OUTPUT_ENV) == str(NATIVE_CONTEXT_LARGE_OUTPUT_BYTES): - return NATIVE_CONTEXT_LARGE_OUTPUT_BYTES, NATIVE_CONTEXT_LARGE_OUTPUT_BYTES return NATIVE_CONTEXT_PAGE_BYTES, NATIVE_CONTEXT_PAGE_OUTPUT_BYTES -def context_capture_read_command(path: Path, digest: str, *, offset: int = 0, page_bytes: int = 4096, +def context_capture_read_command(path: Path, digest: str, *, offset: int = 0, + page_bytes: int | None = None, max_page_output_bytes: int | None = None) -> str: from ...command_invocation import loopx_cli_argv + if page_bytes is None: + page_bytes = NATIVE_CONTEXT_PAGE_BYTES + if max_page_output_bytes is None: + max_page_output_bytes = NATIVE_CONTEXT_PAGE_OUTPUT_BYTES return shlex.join([*loopx_cli_argv(), "--format", "json", "quota", "context-read", "--capture-file", str(path), "--capture-sha256", digest, "--offset", str(offset), "--page-bytes", str(page_bytes), diff --git a/tests/control_plane/test_codex_context_lifecycle.py b/tests/control_plane/test_codex_context_lifecycle.py index 920611be39..fe0ce53e01 100644 --- a/tests/control_plane/test_codex_context_lifecycle.py +++ b/tests/control_plane/test_codex_context_lifecycle.py @@ -177,6 +177,59 @@ def test_complete_reader_delivery_allows_delta_without_diagnostics_or_ack(tmp_pa assert third["mode"] == "delta" +@pytest.mark.parametrize("prefix", ["Pre-quota complete.\n", '{"ok":true}\n']) +def test_mixed_stdout_complete_records_establish_original_baseline(tmp_path, monkeypatch, prefix): + event = setup(tmp_path, monkeypatch) + payload = {"ok": True, "plan_summary": {"requirements": "Retain complete requirements. " * 1000}} + _, first = project(tmp_path, payload=payload) + pages = read_pages(tmp_path, first) + original = Path(first["recovery"]["path"]).read_bytes() + context.codex_context_hook(str(tmp_path), {**event, "hook_event_name": "PostToolUse", + "tool_response": {"exit_code": 0, "output": prefix + json.dumps(pages[0])}}) + deliver(tmp_path, event, pages[1:]) + recovered = context._state(tmp_path) + assert "pending" not in recovered and recovered["candidate"]["capture_bytes_verified"] + assert recovered["candidate"]["capture_file"] == first["recovery"]["path"] + _, second = project(tmp_path, "two", payload=payload) + assert second["mode"] == "delta" and second["base"] == first["target"] + assert Path(first["recovery"]["path"]).read_bytes() == original + + +@pytest.mark.parametrize("damage", ["embedded", "quoted", "truncated", "wrong_bytes", "failed"]) +def test_mixed_stdout_invalid_page_cannot_establish_baseline(tmp_path, monkeypatch, damage): + event = setup(tmp_path, monkeypatch) + _, first = project(tmp_path, payload={"ok": True, "requirements": "Complete original. " * 2000}) + pages = read_pages(tmp_path, first) + page = dict(pages[0]) + if damage == "wrong_bytes": + page["content"] = "x" + page["content"][1:] + text = json.dumps(page) + if damage == "embedded": + text = "Quoted example: " + text + elif damage == "quoted": + text = json.dumps(text) + elif damage == "truncated": + text = text[:-20] + context.codex_context_hook(str(tmp_path), {**event, "hook_event_name": "PostToolUse", + "tool_response": {"exit_code": 1 if damage == "failed" else 0, + "output": "Other command output.\n" + text}}) + deliver(tmp_path, event, pages[1:]) + assert "candidate" not in context._state(tmp_path) + # Same original missing range restores eligibility without another guard. + deliver(tmp_path, event, pages[:1]) + assert context._state(tmp_path)["candidate"]["capture_bytes_verified"] + + +def test_mixed_stdout_can_contain_multiple_complete_page_records(tmp_path, monkeypatch): + event = setup(tmp_path, monkeypatch) + _, first = project(tmp_path, payload={"ok": True, "requirements": "Complete original. " * 2000}) + pages = read_pages(tmp_path, first) + context.codex_context_hook(str(tmp_path), {**event, "hook_event_name": "PostToolUse", + "tool_response": {"exit_code": 0, + "output": "Pre-quota.\n" + "\n".join(json.dumps(page) for page in pages) + "\nFinished.\n"}}) + assert context._state(tmp_path)["candidate"]["capture_bytes_verified"] + + @pytest.mark.parametrize("location", ["nested", "sibling"]) def test_command_workdir_preserves_native_session_delta_and_recovery(tmp_path, monkeypatch, location): event = setup(tmp_path, monkeypatch) @@ -282,7 +335,7 @@ def test_native_page_zero_without_metadata_room_retains_original_recovery(tmp_pa setup(tmp_path, monkeypatch) _, wire = project(tmp_path, payload={"ok": True, "requirements": "完整要求🙂" * 1000}) before = context._state(tmp_path) - monkeypatch.setattr(quota_capture, "NATIVE_CONTEXT_PAGE_OUTPUT_BYTES", 1) + monkeypatch.setattr(quota_capture, "native_context_page_budget", lambda: (64000, 1)) with pytest.raises(QuotaDecisionTransportError, match="saved original decision"): quota_capture.native_context_capture_view(wire, Path(wire["recovery"]["path"]).parent, context_directory=str(tmp_path)) diff --git a/tests/control_plane/test_context_delivery_cli.py b/tests/control_plane/test_context_delivery_cli.py index e4c5421cc5..e0e48bb611 100644 --- a/tests/control_plane/test_context_delivery_cli.py +++ b/tests/control_plane/test_context_delivery_cli.py @@ -374,7 +374,8 @@ def test_native_context_cli_without_retention_stays_full_and_preserves_receipt(t assert hashlib.sha256(observed).hexdigest() == wire["sha256"] assert json.loads(observed) == json.loads(Path(wire["recovery"]["path"]).read_text()) assert json.loads(observed)["heartbeat_receipt"]["turn_instance_id"] == "native-full" - assert len(wire["content"].encode()) <= 3072 and "context" not in wire + assert wire["eof"] and wire["content"].encode() == observed and "context" not in wire + assert len(json.dumps(wire, ensure_ascii=False, separators=(",", ":")).encode()) + 1 <= 64000 assert _heartbeat_receipt_count(runtime, "native-full") == 1 assert _spend_run_count(runtime) == 0 and state.read_bytes() == original @@ -602,8 +603,8 @@ def test_larger_pages_need_explicit_output_budget_and_preserve_every_byte(tmp_pa assert not read_context_capture(args)["ok"] -@pytest.mark.parametrize("declaration,limit", [(None, 7900), ("64000", 64000), ("999999", 7900), ("unknown", 7900)]) -def test_native_configuration_budget_keeps_guard_small_and_continuations_complete(tmp_path, monkeypatch, declaration, limit): +@pytest.mark.parametrize("declaration", [None, "64000", "7900", "999999", "unknown"]) +def test_native_large_pages_do_not_depend_on_shell_environment(tmp_path, monkeypatch, declaration): from loopx.cli import build_parser from loopx.cli_commands.quota_capture import native_context_capture_view, read_context_capture @@ -614,7 +615,9 @@ def test_native_configuration_budget_keeps_guard_small_and_continuations_complet page = native_context_capture_view({"requirements": '汉🙂"\\\t\n' * 12000, "last": "Stop before publication."}, tmp_path) original = Path(page["path"]).read_bytes() - assert len(json.dumps(page, ensure_ascii=False, separators=(",", ":")).encode()) + 1 <= 7900 + limit = 64000 + assert len(page["content"].encode()) > 32000 + assert len(json.dumps(page, ensure_ascii=False, separators=(",", ":")).encode()) + 1 <= limit collected, sizes = b"", [] while True: assert page["ok"] and page["offset"] == len(collected) @@ -627,5 +630,31 @@ def test_native_configuration_budget_keeps_guard_small_and_continuations_complet page = read_context_capture(args) assert len(json.dumps(page, ensure_ascii=False, separators=(",", ":")).encode()) + 1 <= limit assert collected == original and hashlib.sha256(collected).hexdigest() == page["sha256"] - if declaration == "64000": - assert max(sizes) > 32000 and len(sizes) < len(original) / 20000 + assert max(sizes) > 32000 and len(sizes) < len(original) / 20000 + + +def test_default_json_reader_and_generated_recovery_use_large_pages(tmp_path): + from loopx.cli import build_parser + from loopx.cli_commands.quota_capture import read_context_capture + from loopx.control_plane.turn_driver.decision_capture import context_capture_read_command + + raw = json.dumps({"requirements": "完整要求🙂 " * 30000}, ensure_ascii=False).encode() + capture = tmp_path / "capture.json" + capture.write_bytes(raw) + digest = hashlib.sha256(raw).hexdigest() + parser = build_parser() + default = parser.parse_args(["--format", "json", "quota", "context-read", + "--capture-file", str(capture), "--capture-sha256", digest]) + generated = parser.parse_args(shlex.split(context_capture_read_command(capture, digest))[1:]) + for args in (default, generated): + collected, calls = b"", 0 + while True: + page = read_context_capture(args) + assert page["ok"] and page["offset"] == len(collected) + collected += page["content"].encode() + calls += 1 + if page["eof"]: + break + assert len(page["content"].encode()) > 32000 + args = parser.parse_args(shlex.split(page["next_command"])[1:]) + assert collected == raw and calls <= 10 diff --git a/tests/control_plane/test_saved_context_project.py b/tests/control_plane/test_saved_context_project.py index b5a818eeb0..ff7f7f136c 100644 --- a/tests/control_plane/test_saved_context_project.py +++ b/tests/control_plane/test_saved_context_project.py @@ -143,7 +143,7 @@ def test_saved_projection_manages_private_cache_without_authority_io(tmp_path, m assert code == 0 and json.loads(page["content"]) == original -@pytest.mark.parametrize("body", ["Exact requirement.\n" * 700, "完整要求与来源🙂\n" * 700], ids=["ascii", "utf8"]) +@pytest.mark.parametrize("body", ["Exact requirement.\n" * 7000, "完整要求与来源🙂\n" * 7000], ids=["ascii", "utf8"]) @pytest.mark.parametrize("deep_path", [False, True], ids=["short", "deep"]) def test_saved_projection_first_page_continues_exact_bytes_and_keeps_restart(tmp_path, monkeypatch, body, deep_path): from loopx.control_plane.turn_driver.codex_context import codex_context_hook @@ -170,7 +170,8 @@ def test_saved_projection_first_page_continues_exact_bytes_and_keeps_restart(tmp assert code == 0 and result["mode"] == "full", result # Include the final recovery, activation and instruction fields: an inline # content cap alone does not bound the actual native guard stdout. - assert len(json.dumps(result, ensure_ascii=False, separators=(",", ":")).encode()) + 1 <= 7900 + assert not result["eof"] and len(result["content"].encode()) > 32000 + assert len(json.dumps(result, ensure_ascii=False, separators=(",", ":")).encode()) + 1 <= 64000 delivered = Path(result["path"]).read_bytes() delivered_digest = hashlib.sha256(delivered).hexdigest() assert result["sha256"] == delivered_digest and json.loads(delivered) == json.loads(raw) @@ -182,7 +183,7 @@ def test_saved_projection_first_page_continues_exact_bytes_and_keeps_restart(tmp argv = shlex.split(page["next_command"]) code, page = _run_cli(registry, runtime, *argv[argv.index("quota"):], cwd=tmp_path) assert code == 0 and page["offset"] == len(combined) - assert len(json.dumps(page, ensure_ascii=False, separators=(",", ":")).encode()) + 1 <= 7900 + assert len(json.dumps(page, ensure_ascii=False, separators=(",", ":")).encode()) + 1 <= 64000 combined += page["content"].encode() assert combined == delivered and hashlib.sha256(combined).hexdigest() == delivered_digest argv = shlex.split(result["command"])