diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 329ecaa..dd18133 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,12 @@ jobs: run: cargo clippy --all-targets --all-features - name: cargo test (skip client integrations) + if: matrix.os != 'windows-latest' run: cargo test -- --skip codex_tui_initial_sandbox_state --skip codex_tui_initial_sandbox_state_windows_stub + - name: cargo test (windows serial, skip client integrations) + if: matrix.os == 'windows-latest' + run: cargo test -j 1 -- --test-threads=1 --skip codex_tui_initial_sandbox_state --skip codex_tui_initial_sandbox_state_windows_stub + - name: cargo +nightly fmt run: cargo +nightly fmt --all -- --check diff --git a/AGENTS.md b/AGENTS.md index 5c8415e..8dac0c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,22 +1,47 @@ +# Agent Map -- If you modified code, before responding to the user, always run all tests, lints, and checks and make sure everything succeeds and returns cleanly: - - cargo check - - cargo build - - cargo clippy - - cargo test - - cargo +nightly fmt - -- Project convention: never pass `--vanilla` to `R`/`Rscript` commands unless the user explicitly asks for it. - -- Snapshot tests (insta): - - Preferred local loop: - - `cargo insta test` - - `cargo insta pending-snapshots` - - `cargo insta review` (interactive) or `cargo insta accept` / `cargo insta reject` (non-interactive) - - CI-style validation: `cargo insta test --check --unreferenced=reject` - - For intentional snapshot format/metadata migrations: `cargo insta test --force-update-snapshots --accept` - - Bulk rewrite fallback (for intentional broad refreshes): `INSTA_UPDATE=always cargo test` - - Do not manually delete `tests/snapshots/*.snap.new`; use `cargo insta reject` to clean pending snapshots canonically. - - `cargo insta ...` requires the `cargo-insta` subcommand. - -At any time, you may consult the R source code at `~/github/wch/r-source` and the Python source code at `~/github/python/cpython` to investigate and resolve questions. +Keep this file short. It is a table of contents, not the full manual. + +## Immediate Rules + +- If you modified code, run all required checks before replying: + - `cargo check` + - `cargo build` + - `cargo clippy --all-targets --all-features -- -D warnings` + - `cargo test` + - `cargo +nightly fmt` +- Treat all clippy warnings as failures. Do not leave warning cleanup for later. +- Never pass `--vanilla` to `R` or `Rscript` unless the user explicitly asks for it. + +## Start Here + +- `docs/index.md`: source-of-truth map for repository docs. +- `docs/architecture.md`: subsystem map for the binary, worker, sandbox, and eval surfaces. +- `docs/testing.md`: public verification surface and snapshot workflow. +- `docs/debugging.md`: debug logs, `--debug-repl`, and stdio tracing. +- `docs/sandbox.md`: sandbox modes and writable-root policy. +- `docs/plans/AGENTS.md`: when to create checked-in execution plans. + +## Snapshot Workflow + +- Preferred loop: + - `cargo insta test` + - `cargo insta pending-snapshots` + - `cargo insta review` or `cargo insta accept` / `cargo insta reject` +- CI-style validation: `cargo insta test --check --unreferenced=reject` +- For broad intentional snapshot migrations: `cargo insta test --force-update-snapshots --accept` +- Do not delete `tests/snapshots/*.snap.new` manually. Use `cargo insta reject`. + +## Planning Rule + +- For multi-phase refactors, redesigns, or other work that spans discovery, iteration, and implementation, keep a living plan under `docs/plans/active/` until the initiative is complete. +- Use the plan to capture design decisions, rejected options, phase boundaries, unresolved questions, and the next safe slice of work so a later agent does not need to rediscover them. +- If you pause or hand off work mid-task, update the plan before stopping. +- Do not create plan files for routine, obvious, or low-risk changes. Keep the plans area useful, not noisy. +- Move completed plans to `docs/plans/completed/`. +- Treat `docs/notes/` and `docs/futurework/` as exploratory, not normative. + +## External References + +- Consult `~/github/wch/r-source` for R behavior details. +- Consult `~/github/python/cpython` for Python behavior details. diff --git a/Cargo.lock b/Cargo.lock index c35a493..879940a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,9 +22,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.101" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "arrayref" @@ -46,7 +46,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.117", ] [[package]] @@ -69,9 +69,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[package]] name = "blake3" @@ -110,9 +110,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" -version = "1.2.55" +version = "1.2.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ "find-msvc-tools", "shlex", @@ -138,9 +138,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.43" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "js-sys", @@ -239,7 +239,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.115", + "syn 2.0.117", ] [[package]] @@ -250,7 +250,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn 2.0.115", + "syn 2.0.117", ] [[package]] @@ -271,7 +271,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.117", ] [[package]] @@ -344,7 +344,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.117", ] [[package]] @@ -360,7 +360,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -413,9 +413,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", @@ -428,9 +428,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -438,15 +438,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -455,38 +455,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.117", ] [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -496,7 +496,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -526,7 +525,7 @@ dependencies = [ [[package]] name = "harp" version = "0.1.0" -source = "git+https://github.com/t-kalinowski/ark#43441280f5bb84d9566e8f47f1c6b8614d056340" +source = "git+https://github.com/t-kalinowski/ark?rev=43441280f5bb84d9566e8f47f1c6b8614d056340#43441280f5bb84d9566e8f47f1c6b8614d056340" dependencies = [ "anyhow", "cfg-if", @@ -552,10 +551,10 @@ dependencies = [ [[package]] name = "harp-macros" version = "0.1.0" -source = "git+https://github.com/t-kalinowski/ark#43441280f5bb84d9566e8f47f1c6b8614d056340" +source = "git+https://github.com/t-kalinowski/ark?rev=43441280f5bb84d9566e8f47f1c6b8614d056340#43441280f5bb84d9566e8f47f1c6b8614d056340" dependencies = [ "quote", - "syn 2.0.115", + "syn 1.0.109", ] [[package]] @@ -814,9 +813,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.180" +version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "libloading" @@ -831,7 +830,7 @@ dependencies = [ [[package]] name = "libr" version = "0.1.0" -source = "git+https://github.com/t-kalinowski/ark#43441280f5bb84d9566e8f47f1c6b8614d056340" +source = "git+https://github.com/t-kalinowski/ark?rev=43441280f5bb84d9566e8f47f1c6b8614d056340#43441280f5bb84d9566e8f47f1c6b8614d056340" dependencies = [ "cfg-if", "libc", @@ -841,9 +840,9 @@ dependencies = [ [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" @@ -903,12 +902,12 @@ checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.117", ] [[package]] name = "mcp-repl" -version = "0.1.0" +version = "0.2.0-dev" dependencies = [ "base64", "blake3", @@ -930,7 +929,7 @@ dependencies = [ "sysinfo", "tempfile", "tokio", - "toml_edit 0.25.0+spec-1.1.0", + "toml_edit 0.25.5+spec-1.1.0", "url", "vt100", "windows-sys 0.61.2", @@ -965,7 +964,7 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "cfg-if", "cfg_aliases 0.1.1", "libc", @@ -973,11 +972,11 @@ dependencies = [ [[package]] name = "nix" -version = "0.31.1" +version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225e7cfe711e0ba79a68baeddb2982723e4235247aefce1482f2f16c27865b66" +checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "cfg-if", "cfg_aliases 0.2.1", "libc", @@ -1003,18 +1002,18 @@ dependencies = [ [[package]] name = "objc2-core-foundation" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", ] [[package]] name = "objc2-io-kit" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71c1c64d6120e51cd86033f67176b1cb66780c2efe34dec55176f77befd93c0a" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" dependencies = [ "libc", "objc2-core-foundation", @@ -1127,7 +1126,7 @@ dependencies = [ "phf_shared 0.13.1", "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.117", ] [[package]] @@ -1150,15 +1149,9 @@ dependencies = [ [[package]] name = "pin-project-lite" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - -[[package]] -name = "pin-utils" -version = "0.1.0" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "portable-pty" @@ -1203,7 +1196,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.115", + "syn 2.0.117", ] [[package]] @@ -1223,7 +1216,7 @@ checksum = "ccd9713fe2c91c3c85ac388b31b89de339365d2c995146e630b5e0da9d06526a" dependencies = [ "futures", "indexmap", - "nix 0.31.1", + "nix 0.31.2", "tokio", "tracing", "windows", @@ -1231,9 +1224,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.44" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -1265,7 +1258,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", ] [[package]] @@ -1285,7 +1278,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.117", ] [[package]] @@ -1325,9 +1318,9 @@ checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" [[package]] name = "rmcp" -version = "0.15.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bef41ebc9ebed2c1b1d90203e9d1756091e8a00bbc3107676151f39868ca0ee" +checksum = "ba6b9d2f0efe2258b23767f1f9e0054cfbcac9c2d6f81a031214143096d7864f" dependencies = [ "async-trait", "base64", @@ -1349,15 +1342,15 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "0.15.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e88ad84b8b6237a934534a62b379a5be6388915663c0cc598ceb9b3292bbbfe" +checksum = "ab9d95d7ed26ad8306352b0d5f05b593222b272790564589790d210aa15caa9e" dependencies = [ "darling", "proc-macro2", "quote", "serde_json", - "syn 2.0.115", + "syn 2.0.117", ] [[package]] @@ -1380,7 +1373,7 @@ dependencies = [ "proc-macro2", "quote", "rust-embed-utils", - "syn 2.0.115", + "syn 2.0.117", "walkdir", ] @@ -1405,15 +1398,15 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1454,7 +1447,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.115", + "syn 2.0.117", ] [[package]] @@ -1505,7 +1498,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.117", ] [[package]] @@ -1516,7 +1509,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.117", ] [[package]] @@ -1648,7 +1641,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "stdext" version = "0.1.0" -source = "git+https://github.com/t-kalinowski/ark#43441280f5bb84d9566e8f47f1c6b8614d056340" +source = "git+https://github.com/t-kalinowski/ark?rev=43441280f5bb84d9566e8f47f1c6b8614d056340#43441280f5bb84d9566e8f47f1c6b8614d056340" dependencies = [ "anyhow", "log", @@ -1698,9 +1691,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.115" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -1715,14 +1708,14 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.117", ] [[package]] name = "sysinfo" -version = "0.38.1" +version = "0.38.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5792d209c2eac902426c0c4a166c9f72147db453af548cf9bf3242644c4d4fe3" +checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" dependencies = [ "libc", "memchr", @@ -1734,15 +1727,15 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.25.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", "getrandom", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1782,7 +1775,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.117", ] [[package]] @@ -1793,7 +1786,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.117", ] [[package]] @@ -1839,7 +1832,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.117", ] [[package]] @@ -1889,9 +1882,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.0.0+spec-1.1.0" +version = "1.0.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9" dependencies = [ "serde_core", ] @@ -1907,29 +1900,29 @@ dependencies = [ "serde_spanned", "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.14", ] [[package]] name = "toml_edit" -version = "0.25.0+spec-1.1.0" +version = "0.25.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caee3f6e1c6f2025affe9191e6e6f66ade10b48f36b1a1b3cd92dfe405ffd260" +checksum = "8ca1a40644a28bce036923f6a431df0b34236949d111cc07cb6dca830c9ef2e1" dependencies = [ "indexmap", - "toml_datetime 1.0.0+spec-1.1.0", + "toml_datetime 1.0.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 1.0.0", ] [[package]] name = "toml_parser" -version = "1.0.7+spec-1.1.0" +version = "1.0.10+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "247eaa3197818b831697600aadf81514e577e0cba5eab10f7e064e78ae154df1" +checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420" dependencies = [ - "winnow", + "winnow 1.0.0", ] [[package]] @@ -1940,9 +1933,9 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "toml_writer" -version = "1.0.6+spec-1.1.0" +version = "1.0.7+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" +checksum = "f17aaa1c6e3dc22b1da4b6bba97d066e354c7945cac2f7852d4e4e7ca7a6b56d" [[package]] name = "tracing" @@ -1963,7 +1956,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.117", ] [[package]] @@ -2004,9 +1997,9 @@ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "unicode-ident" -version = "1.0.23" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-width" @@ -2157,7 +2150,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -2198,7 +2191,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "hashbrown 0.15.5", "indexmap", "semver", @@ -2238,7 +2231,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.48.0", ] [[package]] @@ -2300,7 +2293,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.117", ] [[package]] @@ -2311,7 +2304,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.117", ] [[package]] @@ -2588,6 +2581,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +dependencies = [ + "memchr", +] + [[package]] name = "winreg" version = "0.10.1" @@ -2637,7 +2639,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn 2.0.115", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -2653,7 +2655,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -2665,7 +2667,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.10.0", + "bitflags 2.11.0", "indexmap", "log", "serde", @@ -2730,7 +2732,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.117", "synstructure", ] @@ -2751,7 +2753,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.117", "synstructure", ] @@ -2785,7 +2787,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 74b95c8..90a07be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,42 +1,46 @@ [package] -name = "mcp-repl" -version = "0.1.0" -edition = "2024" -publish = false -description = "MCP server exposing a long-lived interactive REPL runtime (R or Python backend) over stdio." -repository = "https://github.com/t-kalinowski/mcp-repl" -license = "Apache-2.0" + name = "mcp-repl" + version = "0.2.0-dev" + edition = "2024" + publish = false + description = "MCP server exposing a long-lived interactive REPL runtime (R or Python backend) over stdio." + repository = "https://github.com/t-kalinowski/mcp-repl" + license = "Apache-2.0" [[bin]] -name = "mcp-repl" -path = "src/main.rs" + name = "mcp-repl" + path = "src/main.rs" [dependencies] -ctor = "0.6.3" -base64 = "0.22.1" -harp = { git = "https://github.com/t-kalinowski/ark" } -htmd = "0.5" -libc = "0.2" -libr = { git = "https://github.com/t-kalinowski/ark" } -memchr = "2.8.0" -regex-lite = "0.1" -rmcp = { version = "0.15.0", features = ["client", "transport-child-process", "transport-io"] } -schemars = "1.2" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -sysinfo = "0.38.1" -tempfile = "3.25.0" -tokio = { version = "1", features = ["macros", "rt-multi-thread"] } -toml_edit = "0.25.0" + ctor = "0.6.3" + base64 = "0.22.1" + htmd = "0.5" + libc = "0.2" + harp = { git = "https://github.com/t-kalinowski/ark", rev = "43441280f5bb84d9566e8f47f1c6b8614d056340" } + libr = { git = "https://github.com/t-kalinowski/ark", rev = "43441280f5bb84d9566e8f47f1c6b8614d056340" } + memchr = "2.8.0" + regex-lite = "0.1" + rmcp = { version = "1.2.0", features = [ + "client", + "transport-child-process", + "transport-io", + ] } + schemars = "1.2" + serde = { version = "1", features = ["derive"] } + serde_json = "1" + sysinfo = "0.38.4" + tempfile = "3.27.0" + tokio = { version = "1", features = ["macros", "rt-multi-thread"] } + toml_edit = "0.25.5" [target.'cfg(target_os = "linux")'.dependencies] -landlock = "0.4.4" -seccompiler = "0.5.0" + landlock = "0.4.4" + seccompiler = "0.5.0" [target.'cfg(target_os = "macos")'.dependencies] -url = "2.5.8" + url = "2.5.8" [target.'cfg(target_os = "windows")'.dependencies] -windows-sys = { version = "0.61.2", features = [ + windows-sys = { version = "0.61.2", features = [ "Win32_Foundation", "Win32_Globalization", "Win32_Security", @@ -48,18 +52,28 @@ windows-sys = { version = "0.61.2", features = [ "Win32_System_JobObjects", "Win32_System_Pipes", "Win32_System_Threading", -] } + ] } [dev-dependencies] -blake3 = "1.8.3" -insta = { version = "1.46.3", features = ["yaml"] } -portable-pty = "0.9.0" -tokio = { version = "1", features = ["fs", "io-std", "io-util", "macros", "net", "process", "rt-multi-thread", "sync", "time"] } -vt100 = "0.16.2" + blake3 = "1.8.3" + insta = { version = "1.46.3", features = ["yaml"] } + portable-pty = "0.9.0" + tokio = { version = "1", features = [ + "fs", + "io-std", + "io-util", + "macros", + "net", + "process", + "rt-multi-thread", + "sync", + "time", + ] } + vt100 = "0.16.2" [features] -echo-heuristics = [] + echo-heuristics = [] [profile.dev.package] -insta.opt-level = 3 -similar.opt-level = 3 + insta.opt-level = 3 + similar.opt-level = 3 diff --git a/README.md b/README.md index 0c5376b..0cee08a 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,69 @@ # mcp-repl -`mcp-repl` is an MCP server that exposes a long-lived interactive REPL runtime over stdio. +`mcp-repl` is an MCP server that provides a REPL for agents. -It is backend-agnostic in design. The default interpreter is R, with an opt-in Python interpreter (`--interpreter python`). +It gives an agent a persistent R or Python session that stays alive across tool calls, so it can work the way a person would in a REPL: load data once, inspect objects, try ideas, read help, make plots, and keep iterating in context. -Session state persists across calls, so agents can iterate in place, inspect intermediate values, debug, and read docs in-band. ## Why use it -- Stateful REPL execution in one long-lived process. -- LLM-oriented output handling: prompt/echo cleanup and built-in pager mode. -- In-band docs for common help flows (`?`, `help()`, `vignette()`, `RShowDoc()`). -- Plot images returned as MCP image content. -- OS-level sandboxing by default, plus a memory resource guardrail. +A shell tool can run `Rscript -e` or `python -c`, but that is not the same as having a session. + +Data analysis languages were designed with interactive affordances. To be able to take full advantage of what the runtime offers, it only makes sense for an LLM to also be able to access those same interactive workflows. + +If the work is exploratory, stateful, or iterative, a throwaway command runner keeps forcing the agent to rebuild context. `mcp-repl` keeps the session open instead. That makes a difference for: + +- data exploration +- interactive help and documentation lookup +- plotting and visual checks +- debugging +- any workflow where intermediate objects should stay in memory + +It is built for real agent use: sandboxing is on by default, plots and help are supported in-band, session control with interrupts or restarts is explicit, and large replies stay readable. + +## How it works + +Your MCP client sends code to `repl`. `mcp-repl` runs it inside a long-lived R or Python process and keeps that process alive for the next call. + +That means variables, loaded packages, imported modules, plots, and other session state remain available until you reset the session or exit it. + +Results come back as text and, when relevant, images. + +## What it is good at + +- Exploring data without rebuilding context on every turn. +- Reading help in-band instead of bouncing out to a browser. +- Producing plots the agent can inspect immediately. +- Iterating in a private scratch session before returning an answer. +- Multi-step analysis where keeping state saves time and tokens. ### Safe by default -Like a shell, R and Python are powerful. Without guardrails, an LLM can do real damage on the host (both accidental and prompt-induced). To reduce this risk, `mcp-repl` runs the backend process in a sandboxed environment. By default, network is disabled and writes are constrained to workspace roots and temp paths required by the worker. Sandbox policy is enforced with OS primitives at the process level, not command-specific runtime rules. On Unix backends, `mcp-repl` also enforces a memory resource guardrail on the child process tree and kills the worker if it exceeds the configured threshold. +Like a shell, R and Python are powerful. Without guardrails, an LLM can do real damage on the host (both accidental and prompt-induced). To reduce this risk, `mcp-repl` runs the backend process in a sandboxed environment. By default, network is disabled and writes are constrained to workspace roots and temp paths required by the active R or Python session. Sandbox policy is enforced with OS primitives at the process level, not command-specific runtime rules. On Unix backends, `mcp-repl` also enforces a memory resource guardrail on the child process tree and kills the worker if it exceeds the configured threshold. -### Token efficient +## Token efficient -`mcp-repl` can be substantially more token efficient for an LLM than a standard persistent shell call. It includes affordances tailored to common LLM workflow strengths and weaknesses. For example: -- There is rarely a need to repeatedly poll, since the console is embedded in the backend and normally returns as soon as evaluation is complete. -- Echoed inputs are automatically pruned or elided so output is easy to attribute. -- A rich pager, purpose-built for an LLM, prevents context floods while supporting search and controlled navigation. -- Documentation receives special handling. Built-in entry points like `?`, `help`, `vignette()`, and `RShowDoc()` are customized to present plain text or converted Markdown in-band, replacing the usual HTML browser flow. +### Keeps output readable -### Pager +REPL output can get verbose and messy quickly. `mcp-repl` curates the response to avoid wasting tokens or confusing the model: -The pager activates only when output exceeds roughly one page, and scales from small multi-page outputs to hundreds of pages (for example, navigating the R manuals). It is designed to keep context focused for the model while still allowing deterministic navigation. +- Smart echo behavior: no echo when it is safe to omit, and elided or collapsed echo for large multi-expression blocks. Input is reflected only when needed to connect output back to the code that produced it. +- Help pages render in-band instead of opening a separate browser flow. +- Very large replies stay compact in the tool response, with a preview and a path to the full saved output when needed. +- Plot images are returned directly through MCP instead of requiring a separate GUI workflow. -Internally, the pager is backed by a bounded ring buffer with an event timeline, not a naive "dump and slice" stream. That gives it predictable memory usage while still supporting strong navigation semantics: -- Output is tracked with stable offsets, so commands like `:seek` (offset/percent/line) and `:range` can jump deterministically. -- Text and image events are merged into one timeline, so pagination decisions can account for both without duplicating content. -- Already-shown ranges and images are tracked explicitly; when overlap occurs, the pager emits offset-based elision markers instead of replaying content. -- UTF-8-aware indexing keeps search and cursor movement aligned to characters while preserving exact byte offsets internally. +### Large outputs still work -These affordances are all driven by observed LLM workflows and aim to reduce token waste while improving access to reference material. +Most replies stay inline. When output gets too large, `mcp-repl` keeps the immediate response short and saves the full output as a structured bundle on disk. + +Models are good at searching and exploring files when the structure is clear. Instead of flooding the tool reply, `mcp-repl` produces a bundle the model can inspect on demand: compact previews in the immediate response, plus stable paths to the full transcript and plot files when deeper exploration is needed. + +The practical effect is simple: + +- the tool reply stays readable +- the full output is still available +- the model can explore the saved bundle incrementally +- long transcripts and plot-heavy replies do not flood model context ### Plots @@ -61,6 +88,12 @@ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh cargo install --git https://github.com/t-kalinowski/mcp-repl --locked ``` +To install a specific version, pin the tag: + +```sh +cargo install --git https://github.com/t-kalinowski/mcp-repl --tag v0.1.0 --locked +``` + This installs `mcp-repl` into Cargo’s bin directory (typically `~/.cargo/bin`). Ensure that directory is on your `PATH`. ### 2) Wire into your MCP client @@ -70,6 +103,8 @@ Point your MCP client at the binary (either via `PATH` or by using an explicit p You can auto-install into existing agent config files: ```sh +# bare mcp-repl defaults to pager unless you pass --oversized-output explicitly + # install to all available targets (does not create ~/.codex if missing) mcp-repl install @@ -83,8 +118,11 @@ mcp-repl install --client claude mcp-repl install --client codex --interpreter r ``` -`install --client codex` writes `--sandbox inherit` by default. That sentinel means `mcp-repl` should -inherit sandbox policy updates from Codex for the session. +Bare `mcp-repl` defaults to `--oversized-output pager`. + +`install --client codex` writes `--sandbox inherit --oversized-output files` by default. That +sentinel means `mcp-repl` should inherit sandbox policy updates from Codex for the session while +keeping installed Codex configs on the file-backed oversized-output path. Example `R` REPL Codex config (paths vary by OS/user): @@ -97,6 +135,7 @@ tool_timeout_sec = 1800 # If no update is sent, mcp-repl exits with an error. args = [ "--sandbox", "inherit", + "--oversized-output", "files", "--interpreter", "r", ] ``` @@ -112,12 +151,13 @@ tool_timeout_sec = 1800 # If no update is sent, mcp-repl exits with an error. args = [ "--sandbox", "inherit", + "--oversized-output", "files", "--interpreter", "python", ] ``` -For Claude, `install --client claude` writes to `~/.claude.json` with explicit sandbox mode -because Claude does not propagate sandbox state updates to MCP servers: +For Claude, `install --client claude` writes to `~/.claude.json` with explicit sandbox mode and +`--oversized-output files` because Claude does not propagate sandbox state updates to MCP servers: ```json // ~/.claude.json @@ -125,11 +165,11 @@ because Claude does not propagate sandbox state updates to MCP servers: "mcpServers": { "r": { "command": "/Users/alice/.cargo/bin/mcp-repl", - "args": ["--sandbox", "workspace-write", "--interpreter", "r"] + "args": ["--sandbox", "workspace-write", "--oversized-output", "files", "--interpreter", "r"] }, "python": { "command": "/Users/alice/.cargo/bin/mcp-repl", - "args": ["--sandbox", "workspace-write", "--interpreter", "python"] + "args": ["--sandbox", "workspace-write", "--oversized-output", "files", "--interpreter", "python"] } } } @@ -172,7 +212,7 @@ startup logs, sandbox-state tracing, and the external wire-trace proxy. - To force a specific R installation, set `R_HOME` in the environment that launches `mcp-repl`. - If `R_HOME` is not set, `mcp-repl` discovers it from `R` on `PATH` (via `R RHOME`). -- To verify which R is active, run `R.home()` in the console session. +- To verify which R is active, run `R.home()` in the REPL session. ### Python interpreter: which Python installation is used @@ -220,6 +260,8 @@ Tool guides: ## Docs +Start with `docs/index.md` if you want the engineering map for the repository. + Tool behavior and usage guidance: - `docs/tool-descriptions/repl_tool_r.md` - `docs/tool-descriptions/repl_tool_python.md` diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..fdfa166 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,45 @@ +# Architecture + +`mcp-repl` is a single Rust binary that exposes a long-lived REPL runtime over MCP stdio. +The repository is organized around a few concrete subsystems rather than deep package layering. + +## Subsystem Map + +### CLI and install path + +- `src/main.rs` parses CLI flags, chooses the backend, and dispatches to server, worker, debug REPL, or install mode. +- `src/install.rs` writes client configuration for Codex and Claude and keeps sandbox-related install defaults consistent, including pinning `--oversized-output files` in installed configs even though bare `mcp-repl` defaults to pager. + +### Server and request lifecycle + +- `src/server.rs` owns the MCP surface, request handling, timeout model, and worker lifecycle. +- `src/server/timeouts.rs` and `src/server/response.rs` keep the public `repl`/`repl_reset` behavior stable. + +### Worker and backends + +- `src/worker.rs`, `src/worker_process.rs`, and `src/worker_protocol.rs` manage the child runtime and the server-to-worker contract. +- `src/backend.rs` selects between the R and Python implementations. +- R-specific behavior lives in `src/r_session.rs`, `src/r_controls.rs`, `src/r_graphics.rs`, and `src/r_htmd.rs`. +- Python startup is driven by the worker plus the files under `python/`. + +### Sandbox and process isolation + +- `src/sandbox.rs`, `src/sandbox_cli.rs`, and `src/windows_sandbox.rs` implement OS-level sandboxing, writable-root policy, and client-driven sandbox updates. +- The sideband and sandbox contracts are documented in `docs/sandbox.md` and `docs/worker_sideband_protocol.md`. + +### Output, images, and debug surfaces + +- `src/pending_output_tape.rs` and `src/output_stream.rs` stage worker text and images until reply sealing. +- `src/server/response.rs` is the server-owned response finalizer. It separates worker-originated text from server-only notices, creates oversized-output bundle directories with lazily materialized `transcript.txt`, `events.log`, and `images/`, applies bundle retention and cleanup policy, and decides the bounded inline preview at seal time. +- `src/pager/` implements the pager-mode oversized-output path used by bare CLI defaults and explicit `--oversized-output pager` installs. +- `src/debug_logs.rs`, `src/event_log.rs`, and `src/debug_repl.rs` make the runtime legible to agents and humans during investigation. + +### Validation harnesses + +- `tests/` is the primary public validation surface. The tests exercise tool behavior, snapshots, sandboxing, and client integrations through the exposed MCP interface. + +## Design Constraints + +- The happy path is a stateful REPL session that persists across tool calls. +- Sandboxing is part of the product contract, not an optional wrapper. +- Tests should target public behavior. Internal helpers are there to support the public REPL surface, not to become separate products. diff --git a/docs/debugging.md b/docs/debugging.md index 8dd3c8d..cc93b9b 100644 --- a/docs/debugging.md +++ b/docs/debugging.md @@ -73,7 +73,7 @@ Behavior: Useful environment variables: - `MCP_REPL_IMAGES=0|1|kitty` controls inline image rendering in the debug REPL -- `MCP_REPL_PAGER_PAGE_CHARS=` overrides the pager page size if you want larger or smaller pages while debugging +- `MCP_REPL_OUTPUT_BUNDLE_MAX_COUNT`, `MCP_REPL_OUTPUT_BUNDLE_MAX_BYTES`, and `MCP_REPL_OUTPUT_BUNDLE_MAX_TOTAL_BYTES` let you lower bundle quotas when reproducing spill and pruning behavior ## External wire trace proxy diff --git a/docs/futurework/per-turn-history-bundles.md b/docs/futurework/per-turn-history-bundles.md new file mode 100644 index 0000000..9495edb --- /dev/null +++ b/docs/futurework/per-turn-history-bundles.md @@ -0,0 +1,247 @@ +# Future Work: Per-Turn History Bundles + +## Summary + +Potential follow-on design: keep a server-owned history bundle for every accepted turn so +agents can inspect recent REPL history through a stable filesystem layout instead of only +through lazily disclosed oversized-output bundles. + +Recommended direction: + +- create one turn directory for every accepted turn +- keep `last-turn` as a stable symlink to the newest turn +- prune old turns automatically, with a default count cap of 20 plus byte caps +- keep bundle creation synchronous in the first implementation + +This should be treated as a design and implementation brief for a future agent. It is not +the current repository contract. + +## Why + +Current behavior only materializes bundle files when a reply times out, spills over the hard +text threshold, or needs mixed text/image indexing. That keeps small replies cheap, but it +also means: + +- small turns leave no inspectable history bundle +- timeout handling needs hidden-vs-disclosed bundle state +- a future agent cannot rely on one stable place to look back at recent turns + +Per-turn bundles improve utility and simplify some of the current response logic: + +- every accepted turn is inspectable +- timeout follow-up polls can append to an already-existing turn bundle +- the response layer no longer needs to decide whether files exist, only how much to show inline + +## Desired Outcomes + +- A future agent can inspect recent REPL history by reading one stable session-history root. +- The newest turn is always reachable through `last-turn`. +- Polls do not create extra turn directories. +- Timeout continuation appends to the same turn directory. +- The visible inline reply behavior stays bounded and user-friendly. +- The tool descriptions stay stable; the runtime history path is disclosed by normal reply text. + +## Turn Semantics + +Turn boundaries are based on accepted top-level actions, not on every tool call and not on +internal recursion. + +Count as a new turn: + +- a non-empty top-level `repl(input=...)` +- a bare top-level `interrupt` +- a bare top-level `restart` +- `repl_reset` + +Do not count as a new turn: + +- empty-input polls +- timeout continuation polls +- idle polls that only return `<>` + +Important rule for control prefixes: + +- a non-empty top-level input that begins with `\u0003` or `\u0004` is still one turn +- `\u0003foo` is one turn that starts with interrupt handling and ends with the reply for evaluating `foo` +- `\u0004foo` is one turn that starts with restart handling and ends with the reply for evaluating `foo` + +This matters because the current worker path recursively re-enters `write_stdin()` after handling +the control prefix. The turn layer must not mirror that recursion into a second turn. + +## Session Root + +Do not store turn history under the worker session temp dir. That directory is recreated on +worker spawn/reset, so it has the wrong lifetime for server-owned history. + +Preferred root selection: + +- if a debug session dir exists, use `/turns` +- otherwise allocate a server-owned temp root for the lifetime of the MCP server + +The root should be cleaned up when the server exits. + +## Turn Layout + +Recommended per-turn layout: + +```text +turn-0001/ + events.log + transcript.txt + images/ + images/history/ +``` + +Recommended root layout: + +```text +session-history/ + last-turn -> turn-0007 + turn-0001/ + turn-0002/ + ... +``` + +File rules: + +- `events.log` always exists and is the authoritative ordered index for the full normalized visible stream +- `transcript.txt` stores worker-originated REPL text only +- `transcript.txt` should be created eagerly, even if it stays empty +- server-only notices do not go into `transcript.txt` +- `images/` and `images/history/` are only created when the turn emitted images +- top-level files under `images/` represent the latest image state that matches the collapsed inline reply +- `images/history/` preserves the full ordered image history for the turn, including same-turn plot updates that are intentionally collapsed out of standard inline REPL output + +Recommended `events.log` row types: + +- `T`: worker text range in `transcript.txt` +- `S`: server-only text +- `I`: image history path + +This keeps turn inspection uniform for text-only, mixed, timeout, reset, and interrupt flows. +It also preserves more image history than the default inline reply is expected to show. + +## Inline Reply Behavior + +Do not change the main public interaction model: + +- small replies stay inline +- oversized replies still use bounded previews +- image-heavy replies still keep the inline anchors that are useful in the current UX + +The change is only that file materialization becomes unconditional per turn. Inline compaction +still decides what the client sees directly. + +Important image rule: + +- standard inline REPL output may intentionally collapse same-turn plot updates to the final visible image state +- the turn bundle must preserve the full image history anyway +- an agent that needs the full plot-update history should be able to inspect `events.log` plus `images/history/` and recover more than the collapsed inline reply shows + +Preferred discoverability: + +- do not make the tool description dynamic +- emit one short server note on the first accepted turn that discloses the history root +- after that, the client can use `last-turn` or inspect older `turn-*` directories directly + +Dynamic tool descriptions are possible, but they add surface area for little value and make the +description less stable. + +## Retention And Pruning + +Recommended defaults: + +- keep the last 20 completed turns +- also keep the current active turn, even if that means 21 directories temporarily +- retain byte caps in addition to the count cap + +Pruning rules: + +- prune only inactive turns +- update `last-turn` atomically with a symlink swap +- keep cleanup on server shutdown + +The current oversized-output bundle defaults already use a count cap of 20. Reusing that default +for turn history is reasonable. + +## Latency Expectations + +Turn history creation will be on the response critical path unless a future implementation adds a +background writer. + +Measured local order-of-magnitude costs on this machine were small: + +- directory only: about `0.05 ms` average +- empty `transcript.txt` plus small metadata file: about `0.19 ms` average +- 4 KB transcript plus `last-turn` symlink update: about `0.26 ms` average +- 64 KB transcript plus `events.log` plus `last-turn`: about `0.34 ms` average +- 1 MB image decode plus two image writes: about `1.4 ms` average + +These numbers are optimistic because they do not include forced fsyncs and they benefit from the +local temp filesystem cache. Still, they are small enough that the first implementation should stay +synchronous. + +Do not optimize this with async bundle creation first. Async writing introduces ordering and +visibility races with: + +- timeout follow-up polls +- same-turn appends +- immediate inspection of `last-turn` + +If a future implementation wants more concurrency, it should start from a correct synchronous design +and only then move to a dedicated ordered writer. + +## Recommended Refactor Shape + +Replace the current lazy bundle-specific state with turn-specific state. + +Recommended model: + +- `ResponseState` owns a turn store rooted once per server session +- the active turn exists as soon as a new accepted turn starts +- reply finalization always appends normalized items into the active turn +- reply presentation decides only whether to show everything inline or a bounded preview + +This should remove or simplify: + +- hidden-vs-disclosed bundle state +- timeout-specific bundle setup branches +- lazy `events.log` materialization paths + +It should preserve: + +- worker-vs-server text separation +- timeout follow-up polling semantics +- current bounded preview behavior + +## Test Cases + +Public behavior to cover: + +- under-threshold text reply creates `turn-0001`, `events.log`, `transcript.txt`, and `last-turn` +- multiple timeout polls append to one turn and do not create extra turns +- idle polls do not create turns +- detached idle output remains non-blocking and is only persisted when attached to a later accepted turn +- bare `\u0003`, bare `\u0004`, and `repl_reset` each create one new turn +- `\u0003foo` creates one turn, not two +- `\u0004foo` creates one turn, not two +- mixed text/image replies always write `events.log` plus image history +- same-turn plot updates remain collapsed inline but are fully preserved under `images/history/` +- pruning removes the oldest inactive turn after the retention cap is exceeded +- cleanup removes the session-history root on server exit + +## Non-Goals + +Do not treat these as part of the first implementation: + +- dynamic tool descriptions that include the current history path +- background or async turn writers +- a new MCP read-history tool +- changing the existing inline preview contract beyond what is required to mention the history root once + +## Relevant Current Files + +- `src/server/response.rs`: current lazy output-bundle store and reply finalization +- `src/worker_process.rs`: accepted-input vs poll semantics and control-prefix handling +- `src/debug_logs.rs`: optional per-session debug directory +- `src/sandbox.rs`: worker session temp dir lifecycle, which should not be reused for turn history diff --git a/docs/futurework/repl-tool-description-extras.md b/docs/futurework/repl-tool-description-extras.md index 9cd8870..1e834f7 100644 --- a/docs/futurework/repl-tool-description-extras.md +++ b/docs/futurework/repl-tool-description-extras.md @@ -40,9 +40,9 @@ It should be treated as source material for: - Use short non-blocking/near-non-blocking calls to launch long work, then poll. - Treat timeout return as partial progress, not cancellation. -- Timeout replies surface explicit busy status markers (`<>`). +- Timeout replies surface explicit busy status markers (`<>`). - While work is active, reject/discard concurrent non-empty input and poll until completion. -- Empty-input poll while idle can return `<>`. +- Empty-input poll while idle can return `<>`. - After completion, resume normal interactive flow. ## Recovered R-specific guidance diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..c9c0e63 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,34 @@ +# Docs Index + +`docs/index.md` is the source-of-truth map for agent-facing repository knowledge. +Use it to find the current architecture, testing workflow, debugging surfaces, and +checked-in execution plans without relying on stale notes. + +## Start Here + +- `docs/architecture.md`: current subsystem map for the CLI, server, worker, sandbox, and output surfaces. +- `docs/testing.md`: public validation surface and snapshot workflow. +- `docs/debugging.md`: debug logs, `--debug-repl`, and wire tracing. +- `docs/sandbox.md`: sandbox modes, writable roots, and client-driven sandbox updates. +- `docs/worker_sideband_protocol.md`: server/worker IPC contract. +- `docs/plans/AGENTS.md`: when to write a checked-in execution plan and where it lives. + +## Normative Docs + +- `docs/tool-descriptions/repl_tool.md`: backend-neutral `repl` description. +- `docs/tool-descriptions/repl_tool_r.md`: R-specific `repl` behavior. +- `docs/tool-descriptions/repl_tool_python.md`: Python-specific `repl` behavior. +- `docs/tool-descriptions/repl_reset_tool.md`: `repl_reset` behavior. +- `README.md`: user-facing overview and installation guide. Treat it as product documentation, not the engineering source of truth. + +## Exploratory Docs + +- `docs/notes/`: ideas and sketches that may lead to later work. +- `docs/futurework/`: candidate follow-on designs that are not current repository contract. +- `docs/futurework/per-turn-history-bundles.md`: design brief for always-materialized per-turn REPL history bundles. + +## Maintenance Rules + +- Add new normative docs here in the same PR that introduces them. +- Keep `AGENTS.md` short and use it as a pointer back to this index. +- Prefer moving completed execution plans into `docs/plans/completed/` instead of leaving one-off plan files at the top of `docs/`. diff --git a/docs/plans/AGENTS.md b/docs/plans/AGENTS.md new file mode 100644 index 0000000..53642d4 --- /dev/null +++ b/docs/plans/AGENTS.md @@ -0,0 +1,87 @@ +# Execution Plans + +Use checked-in execution plans for work that is too large or too cross-cutting to keep only in prompt context. + +## When to Write a Plan + +Write a plan when the change: + +- spans multiple files or subsystems, +- changes public behavior or protocol contracts, +- changes both R and Python behavior, +- is expected to take more than one PR, or +- needs explicit decision logging so a later agent can pick it up safely. + +Small bugfixes, typo fixes, and isolated docs-only changes do not need a checked-in plan. + +For multi-phase refactors or redesigns, prefer one living plan per initiative. Keep updating that plan as +decisions change instead of creating a new file for each small pass. + +## Template + +Create a Markdown file in `docs/plans/active/` with these headings: + +```md +# + +## Summary + +- What changes. +- What stays unchanged. + +## Status + +- State: active +- Last updated: YYYY-MM-DD +- Current phase: <planning|implementation|validation|paused> + +## Current Direction + +- The design currently being pursued. +- Why it is the preferred path right now. + +## Long-Term Direction + +- The intended end-state architecture, if it differs from the current bounded phase. +- What parts of the current phase are temporary tactics rather than the long-term design. + +## Phase Status + +- Phase 0: completed / active / pending +- Phase 1: completed / active / pending + +## Locked Decisions + +- Decision that should not be re-litigated without new evidence. + +## Open Questions + +- Question that still needs a decision. + +## Next Safe Slice + +- The next bounded piece of work that is safe to implement now. + +## Stop Conditions + +- Condition that should cause the agent to stop, update the plan, and ask for a decision. + +## Decision Log + +- YYYY-MM-DD: key decision and why it was made. +``` + +Add or remove sections only when it reduces ambiguity. The goal is to preserve design history and current state +without turning the plan into a changelog. + +When the current phase deliberately takes a simpler path for iteration, say so explicitly and record the tradeoff. +Do not let a temporary implementation tactic become the apparent product definition just because it is the current slice. + +## Lifecycle + +1. Start the plan in `docs/plans/active/`. +2. Update `## Status`, `## Open Questions`, `## Next Safe Slice`, and `## Decision Log` as the work evolves. +3. Before pausing or handing off a non-trivial initiative, update the plan so the next agent does not need to + rediscover the current state. +4. Move the plan to `docs/plans/completed/` when the work lands or is intentionally abandoned. +5. Capture recurring follow-up items in `docs/plans/tech-debt.md` instead of leaving them buried in old plans. diff --git a/docs/plans/active/image-output-bundles.md b/docs/plans/active/image-output-bundles.md new file mode 100644 index 0000000..32367f8 --- /dev/null +++ b/docs/plans/active/image-output-bundles.md @@ -0,0 +1,36 @@ +# Image Output Bundles + +## Summary + +- Add a server-owned output bundle for oversized mixed text/image replies. +- Keep `transcript.txt` as worker-originated REPL text only. +- Add `events.log` as the ordered index over the retained mixed worker-text/image bundle contents. +- Keep the visible reply bounded with one truncation notice and the first/last image inline. + +## Current Decisions + +- Output bundle layout: + - `transcript.txt` + - `events.log` + - `images/001.png`, `002.png`, ... + - `images/history/001/001.png`, `001/002.png`, ... +- `events.log` covers the full retained mixed worker-text/image stream within the bundle, not just the omitted middle. +- `T` rows include both line and byte ranges into `transcript.txt`. +- `I` rows include only the relative history image path. +- Output bundle compaction uses one merged pass over normalized reply items. +- The visible output-bundle reply keeps the first and last image inline. +- Same-reply plot updates stay collapsed inline, but the bundle retains their full image history. +- Top-level files under `images/` are final aliases; `images/history/` stores ordered image history. +- The truncation notice points to `events.log`. + +## Guardrails + +- Do not write image paths into `transcript.txt`. +- Do not add timestamps. +- Do not emit multiple truncation notices for one reply. +- Server meta text is still bounded by the global response budget. + +## Next Slice + +- Tighten merged compaction behavior and timeout coverage as follow-up slices if verification exposes gaps. +- Keep the docs and tests aligned with the output-bundle contract. diff --git a/docs/plans/active/oversized-output-previews.md b/docs/plans/active/oversized-output-previews.md new file mode 100644 index 0000000..928ebde --- /dev/null +++ b/docs/plans/active/oversized-output-previews.md @@ -0,0 +1,205 @@ +# Oversized Output Previews + +## Summary + +- Replace the remaining default pager-era large-output behavior with non-modal oversized text previews plus server-owned worker transcript files. +- Keep `PendingOutputTape` as the always-on raw event collector. +- Split the design across two independent axes: + - reply-tracking state: what worker-originated text the server still owes to future `repl()` replies + - file materialization: whether that worker-originated text is also being accumulated in a server-owned file +- Keep server-only notices out of worker transcript files. +- For a timed-out request, create a hidden worker transcript file immediately on the first timeout so the server does not need to retain unbounded text in memory. +- Only disclose a file path to the client when a response actually needs truncation or quarantine. +- Scope v1 to text-only oversized replies. Replies containing any image content remain unchanged. + +## Status + +- State: active +- Last updated: 2026-03-21 +- Current phase: initial implementation shipped; follow-on cleanup still open +- Verification: `cargo check`, `cargo build`, `cargo clippy`, `cargo test`, and `cargo +nightly fmt` all pass in the implementation branch + +## Current Direction + +- Oversized-output logic happens only at reply finalization time after the tape snapshot is drained. +- Polling remains the primary interaction model. +- Detached idle output must never make `repl(input=...)` unusable. +- Worker transcript files are a fallback for omitted worker-originated text, not a mirror of the full visible reply. +- The shipped implementation creates a hidden worker transcript file on the first timeout reply, appends later worker-originated poll text to that same file, and discloses the path only if an oversized text-only reply is actually compacted. +- Non-timeout oversized text-only replies create a worker transcript file lazily at response time and disclose it immediately in the compacted reply. +- Mixed text+image replies stay unchanged in v1. +- Text-only spilling uses Unicode character count, not encoded byte length, because the threshold is meant to approximate visible reply size rather than bundle storage usage. +- Detached idle output remains non-blocking because `repl(input=...)` still accepts new input once no timed-out request is active; oversized text from that accepted reply follows the same text-only compaction path. + +## Long-Term Direction + +- The likely long-term direction is a more eager, state-machine-driven consumer of tape output rather than a pure seal-time formatter. +- The long-term design should preserve the same public behavior contract while reducing memory footprint and making output handling more incremental. +- The current seal-time phase exists to validate the public UX and worker-transcript contract first. It should not be treated as proof that the long-term implementation must remain seal-time. + +## Phase Status + +- Phase 0: completed. Default-path pager behavior was mostly removed, simplified, and isolated behind a separate legacy mode. `PendingOutputTape` became the main default collector. +- Phase 1: completed. Multiple design rounds narrowed the space: no modal pager, no default read tool, no live reader-thread formatting, and no worker-owned transcript files. +- Phase 2: completed. Lock the public text format, timeout follow-up behavior, worker-only file contents, and detached-idle behavior for text-only v1. +- Phase 3: active. Implement server-owned worker transcript storage, hidden-on-timeout file creation, and the seal-time formatter as the first bounded implementation step. +- Phase 4: pending. Revisit the implementation architecture after the public behavior is validated and move toward a more eager/state-machine-driven design. +- Phase 5: pending. Update tool descriptions and replace pager-oriented tests and snapshots for the default public surface. + +## Reply-Tracking States + +- `none` + - no worker-originated text is owed to a future reply +- `timed-out request still owes worker text` + - a prior non-empty `repl(input=...)` timed out + - future empty-input polls continue surfacing worker-originated text for that same request + - while this state is active, a new non-empty input is not accepted +- `detached idle worker text exists` + - worker-originated text arrived while no request was active + - this text may be surfaced on empty-input polls or as a bounded prefix/notice on a later accepted request + - this state never blocks a new non-empty input + +## File Materialization States + +- `no file` + - default for normal small replies +- `hidden worker transcript file` + - the server is already writing worker-originated text to a file + - the client has not yet been told the path +- `disclosed worker transcript file` + - a response has already shown the path because truncation or quarantine happened + +## Locked Decisions + +- The tape always exists and always collects output, including output that arrives between tool calls. +- For the current phase, oversized-output logic happens only at reply finalization time after the tape snapshot is drained. +- Polling is the primary interaction model. Agents should keep using bounded `repl` replies and only inspect transcript files when needed. +- Worker transcript files are owned by the server, not the worker. They must survive worker death, segfault, restart, or reset within the same logical request or idle-output episode. +- Worker transcript files contain only worker-originated REPL text. +- Worker transcript files include prompts and echoed input exactly as surfaced from the worker-side interaction. +- Worker transcript files exclude server-only notices such as timeout markers, busy/rejection notices, and reset/session notices. +- For a timed-out request, create a hidden worker transcript file immediately on the first timeout reply. +- A hidden file path is disclosed only if a later response actually truncates or quarantines worker-originated text. +- If a file is first disclosed after earlier inline replies already showed worker text, it must already contain those earlier worker-originated bytes from the start of that same request or idle-output episode. +- Detached idle output never forces endless polling and never blocks a new non-empty input. +- If detached idle text is too large, compact or quarantine only that detached-idle portion and keep the new request usable. +- The inline reply uses one middle truncation marker line only. No separate metadata block. +- Line-based preview is the default public behavior. Char-based preview is fallback only when a clean line-aligned preview cannot fit the internal budget. +- In line mode and char mode, the marker reports what is already shown, not extra helper metadata. +- No default transcript-read tool in v1. +- V1 only compacts text-only replies. Mixed text+image replies remain unchanged so current text/image ordering is preserved. + +## Rejected Options + +- Reviving or depending on modal pager behavior for the default public surface. +- Worker-owned transcript files tied to the worker tempdir lifetime. +- Adding a default output-read MCP tool before validating the simpler “show the path in reply text” workflow. +- Performing head/tail compaction in reader threads or in the tape itself. +- Treating detached idle output like a timed-out request that must be explicitly drained before new input can run. +- Treating the current seal-time implementation strategy as the long-term architecture. + +## Concrete Examples + +### Timeout with hidden file but no disclosed path + +1. `repl(input="cat('a\\n'); flush.console(); Sys.sleep(1); cat('b\\n')", timeout_ms=100)` +2. Visible reply: + +```text +> cat('a\n'); flush.console(); Sys.sleep(1); cat('b\n') +a +<<repl status: busy, write_stdin timeout reached; elapsed_ms=N>> +``` + +3. At this point: + - reply-tracking state: timed-out request still owes worker text + - file materialization state: hidden worker transcript file +4. `repl(input="", timeout_ms=500)` +5. Visible reply: + +```text +b +> +``` + +6. The request is finished. +7. No response was oversized, so the client never saw the file path. + +What the hidden file contains after step 5: + +```text +> cat('a\n'); flush.console(); Sys.sleep(1); cat('b\n') +a +b +> +``` + +It does not contain the timeout marker. + +### Four polls: small, small, spill, small + +1. `repl(input="very noisy long command", timeout_ms=100)` +2. Timeout reply is small. +3. `repl(input="", timeout_ms=200)` returns a small poll reply. +4. `repl(input="", timeout_ms=200)` returns a second small poll reply. +5. `repl(input="", timeout_ms=1000)` is the first oversized poll reply. +6. `repl(input="", timeout_ms=200)` returns a final small poll reply. + +Behavior: + +- the hidden worker transcript file was already created at step 2 +- step 5 is the first time the path is disclosed +- the step-5 inline reply shows only the new worker-originated text from step 5, compacted as needed +- after step 6, the same file contains the full worker-originated transcript from step 2 onward, including worker text that had already been shown inline in steps 2, 3, and 4 + +### Detached idle output does not block `repl(input=...)` + +1. `repl(input="normal command", timeout_ms=1000)` finishes normally. +2. Later, a forked child inherited the pipe and starts writing idle output. +3. No request is active now. +4. `repl(input="1+1", timeout_ms=1000)` + +Behavior: + +- accept `1+1` +- do not require the user to drain idle output first +- prepend at most a bounded detached-idle preview/notice +- if detached idle text is too large, compact or quarantine only that detached-idle portion and show its file path +- then show the `1+1` reply normally + +## Next Safe Slice + +- Decide whether session-end notices should stay purely inline-only all the way through the existing tape path. +- Add focused public coverage for detached idle output that later becomes oversized. +- Revisit the seal-time implementation once the public contract is stable enough to justify a more eager consumer. +- Leave quota, retention policy, transcript-read tools, and the later eager/state-machine consumer out of the current slice. + +## Stop Conditions + +- Stop if file append still happens before the final worker-derived text for a reply is known. +- Stop if the implementation requires worker protocol changes or pushes formatting logic into reader threads. +- Stop if mixed text+image replies cannot stay unchanged without hidden reordering; keep them out of v1. +- Stop if detached-idle handling starts forcing polls before new input can run. +- Stop if the implementation starts hard-coding seal-time mechanics into the public contract. Seal-time is a phase tactic, not the product definition. +- Stop if the current phase starts accumulating complexity whose only purpose is to preserve the seal-time tactic. If the tactic gets in the way, record the issue and revisit the phased rollout. + +## Decision Log + +- 2026-03-21: Ignore the pager for the new design. Treat it as legacy behavior outside the default `files` mode. +- 2026-03-21: Prefer full omitted-output retrieval over permanent middle-drop, but do it through visible file paths rather than a default read tool. +- 2026-03-21: Default retrieval path is “show the file path in normal reply text.” A dedicated read tool, if ever needed, is future work and should be opt-in. +- 2026-03-21: Polling is the primary workflow. Agents are expected to keep polling with bounded replies and use filesystem tools only when the preview is not enough. +- 2026-03-21: The tape is always-on and always collecting, including output that arrives between requests. +- 2026-03-21: For the current implementation phase, oversized-output handling should happen only at seal time after draining the tape and rendering the final reply. +- 2026-03-21: Prefer line-first preview and metadata. Fall back to char mode only when clean line-aligned preview would not fit the budget. +- 2026-03-21: Keep the reply plain: one middle marker line with shown ranges and the file path, not multiple metadata lines. +- 2026-03-21: The formatter must live on the server side because the exact visible text is finalized there after image collapsing and prompt/error normalization. +- 2026-03-21: Worker transcript files must be server-owned and tied to server lifetime, not worker lifetime. +- 2026-03-21: Worker transcript files contain only worker-originated REPL text and exclude server-only notices. +- 2026-03-21: Worker transcript files include prompts and echoed input exactly as surfaced from the worker-side interaction. +- 2026-03-21: Timed-out requests create hidden files immediately to avoid unbounded in-memory accumulation. +- 2026-03-21: Hidden file paths are disclosed only when truncation or quarantine is actually surfaced in a response. +- 2026-03-21: Detached idle output remains non-blocking; it may be previewed or quarantined, but it does not make `repl(input=...)` unusable. +- 2026-03-21: Scope v1 to text-only oversized replies and leave mixed text+image replies unchanged. +- 2026-03-21: The current seal-time phase is a bounded implementation step chosen to keep iteration simple while the public behavior is still moving. +- 2026-03-21: The likely long-term direction is a more eager/state-machine-driven consumer of tape output with a better memory profile. Do not let the current seal-time phase rename or redefine the broader initiative. diff --git a/docs/plans/active/python-help-contract.md b/docs/plans/active/python-help-contract.md new file mode 100644 index 0000000..ccf47e7 --- /dev/null +++ b/docs/plans/active/python-help-contract.md @@ -0,0 +1,78 @@ +# Python Help Contract + +## Summary + +- Repair the Python `repl` help contract so `help(obj)`, `help("topic")`, `help()`, and `pydoc.help(...)` stay in-band and never hand control to CPython's external pager. +- Keep the fix narrow: patch Python help behavior in the worker startup path and add public regression coverage. +- Leave package availability, plot support, and standard interactive-Python multiline semantics unchanged. + +## Status + +- State: active +- Last updated: 2026-03-23 +- Current phase: planning + +## Current Direction + +- Fix the bug in the Python worker startup script by forcing `pydoc` to use its plain pager before the first user prompt. +- Use the existing prompt, `input()`, timeout, and interrupt machinery unchanged so help output follows the same request lifecycle as normal REPL output. +- Add stdlib-only public tests that prove Python help no longer prints pager prompts, wedges the worker busy, or requires an interrupt/reset to recover. + +## Long-Term Direction + +- The long-term contract is simple: documentation and inspection helpers in the Python backend are ordinary inline text output, not nested terminal UIs. +- The current slice should not grow into generic support for external pagers, `less`, or arbitrary terminal applications inside the worker PTY. +- Terminal-environment cleanup is a separate concern. If it proves worth fixing, handle it as worker-env hygiene in a later slice without reopening the help design. + +## Phase Status + +- Phase 0: completed. Reproduce the bug through the public tool surface and confirm the mismatch with the documented Python `repl` contract. +- Phase 1: pending. Patch Python worker startup so `pydoc` always renders plain in-band help. +- Phase 2: pending. Add public regression coverage for direct and interactive Python help flows. +- Phase 3: pending. Reassess whether the Python worker `TERM` warning merits a separate follow-up. + +## Locked Decisions + +- Do not change the public tool schema. +- Do not replace `builtins.help` with a custom renderer. Keep stdlib help behavior and remove only the external pager path. +- In `python/driver.py`, import `pydoc` during startup and set: + - `pydoc.pager = pydoc.plainpager` + - `pydoc.getpager = lambda: pydoc.plainpager` +- Apply the `pydoc` override before the first prompt is emitted so the first help call cannot cache the wrong pager behavior. +- Use stdlib objects in tests such as `len` or `str`; do not depend on pandas or other optional packages for help regression coverage. +- Leave `docs/tool-descriptions/repl_tool_python.md` unchanged unless the implementation forces a different contract. The goal is to make runtime behavior match the current docs again. +- Do not treat missing `matplotlib`, missing `sklearn`, or the requirement for a terminating blank line in Python compound statements as part of this bug. + +## Open Questions + +- None for the help-fix slice. +- The `TERM=xterm-ghostty` warning remains open only as possible follow-up tech debt, not as a blocker for the help fix. + +## Next Safe Slice + +- Patch `python/driver.py` to force `pydoc` plain-pager behavior at startup. +- Add a direct help regression test that runs `help(len)` and asserts: + - output contains `Help on` + - output does not contain `Press RETURN` + - output does not contain `--More--` + - output does not remain busy +- Add a second regression test for `pydoc.help(len)` with the same expectations. +- Add an interactive help roundtrip test: + - call `help()` + - wait for `help>` + - request `len` + - send `q` + - run a normal Python command and assert the session is back at `>>>` + +## Stop Conditions + +- Stop if the fix requires a second output path outside the existing prompt/request-end model. +- Stop if the simplest `pydoc` pager override does not cover interactive `help()` and `pydoc.help(...)` together. +- Stop if the implementation starts depending on optional Python packages for regression coverage. +- Stop if the slice expands into generic nested pager or terminal-emulator support. + +## Decision Log + +- 2026-03-23: Scoped the active plan to Python help behavior only. Package availability and normal interactive-Python multiline semantics are out of scope. +- 2026-03-23: Chose the stdlib `pydoc` plain-pager override as the preferred implementation path instead of a custom help renderer. +- 2026-03-23: Deferred the Python worker terminal-type warning to separate tech debt so it does not block the help contract repair. diff --git a/docs/plans/active/template.md b/docs/plans/active/template.md new file mode 100644 index 0000000..d0ccf2f --- /dev/null +++ b/docs/plans/active/template.md @@ -0,0 +1,47 @@ +# Active Plan Template + +## Summary + +- Copy this file when a change needs a checked-in plan. +- Replace these bullets with the concrete scope and invariants. + +## Status + +- State: template +- Last updated: 2026-03-21 +- Current phase: planning + +## Current Direction + +- Describe the design currently being pursued. +- Explain why it is the preferred path right now. + +## Long-Term Direction + +- Describe the intended end-state architecture if it differs from the current phase. +- Call out which parts of the current implementation strategy are temporary. + +## Phase Status + +- Phase 0: completed / active / pending +- Phase 1: completed / active / pending + +## Locked Decisions + +- Record decisions that should not be revisited without new evidence. + +## Open Questions + +- Record decisions that are still unresolved. + +## Next Safe Slice + +- Describe the next bounded implementation or discovery step. + +## Stop Conditions + +- Note what should cause an agent to stop and update the plan before continuing. + +## Decision Log + +- 2026-03-21: Added a minimal template so agents have a concrete starting point for non-trivial work. diff --git a/docs/plans/completed/remove-pager-stage1.md b/docs/plans/completed/remove-pager-stage1.md new file mode 100644 index 0000000..a389c77 --- /dev/null +++ b/docs/plans/completed/remove-pager-stage1.md @@ -0,0 +1,21 @@ +# Remove Pager Stage 1 + +## Summary + +- Remove server-side paging, truncation notices, synthetic input summaries, and echo elision from `repl`. +- Keep worker behavior and the sideband wire protocol unchanged. +- Replace ring-based reply assembly with a single always-present `PendingOutputTape`. +- Preserve sideband ordering metadata so later stages can make richer echo decisions without changing visible behavior in this stage. + +## Status + +- State: completed +- Scope: response assembly and formatting changes only +- Last updated: 2026-03-21 + +## Decision Log + +- Keep the worker behavior and sideband wire protocol unchanged so the stage remains isolated to server-side rendering. +- Make `PendingOutputTape` the shared accumulator owned by `WorkerManager`. +- Preserve raw UTF-8, stderr prefixes, image update collapsing, and sideband ordering metadata for later follow-on work. +- Return echoed input verbatim in this stage and defer smarter echo suppression to later work. diff --git a/docs/plans/tech-debt.md b/docs/plans/tech-debt.md new file mode 100644 index 0000000..56b9292 --- /dev/null +++ b/docs/plans/tech-debt.md @@ -0,0 +1,5 @@ +# Tech Debt + +- Promote the new docs contract beyond existence checks if drift becomes a problem, for example by validating status tags or plan metadata more strictly. +- Decide whether the branch-specific Claude clear-hook notes still belong in mainline docs or should move fully into exploratory documentation. +- Expand the eval harness documentation once the current `inspect_ai` experiment stabilizes and the task catalog becomes part of regular development. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..f11119f --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,44 @@ +# Testing + +`mcp-repl` is validated primarily through public API tests and transcript-style snapshots. +This file is the entrypoint for deciding how to verify a change. + +## Core Test Surface + +- `tests/repl_surface.rs`: basic `repl` and `repl_reset` behavior. +- `tests/server_smoke.rs`: end-to-end MCP session smoke coverage. +- `tests/write_stdin_behavior.rs`: timeout polling, oversized text replies, and transcript-file behavior through the public `repl` API. +- `tests/sandbox.rs` and `tests/sandbox_state_updates.rs`: sandbox policy behavior and client-driven updates. +- `tests/plot_images.rs` and `tests/python_plot_images.rs`: plot/image behavior through the public tool surface. +- `tests/codex_approvals_tui.rs` and `tests/claude_integration.rs`: client integration coverage. + +## Snapshot Workflow + +- Transcript and JSON snapshots live under `tests/snapshots/`. +- Preferred loop: + - `cargo insta test` + - `cargo insta pending-snapshots` + - `cargo insta review` or `cargo insta accept` / `cargo insta reject` +- Do not delete `tests/snapshots/*.snap.new` manually. Use `cargo insta reject`. + +## Full Verification Before Replying + +If you modify code, run: + +- `cargo check` +- `cargo build` +- `cargo clippy` +- `cargo test` +- `cargo +nightly fmt` + +## Debug-Then-Validate Loop + +When behavior is unclear: + +1. Reproduce through the public tool surface or an existing integration test. +2. Inspect with `docs/debugging.md`: + - `MCP_REPL_DEBUG_DIR` + - `--debug-repl` + - the stdio trace proxy +3. Add or update a public API test. +4. Re-run the full verification set. diff --git a/docs/tool-descriptions/repl_reset_tool.md b/docs/tool-descriptions/repl_reset_tool.md index d4de421..e76b2e5 100644 --- a/docs/tool-descriptions/repl_reset_tool.md +++ b/docs/tool-descriptions/repl_reset_tool.md @@ -3,6 +3,7 @@ Behavior: - Clears in-memory session state (objects, variables, loaded runtime state tied to the process). - Starts a fresh worker session and returns the new-session status output. +- `repl_reset` does not delete server-owned output artifacts, but already-disclosed bundle paths remain valid only until quota pruning or server exit. - Prefer this when the intent is explicit lifecycle control or memory cleanup after large one-off work. Arguments: diff --git a/docs/tool-descriptions/repl_tool_python.md b/docs/tool-descriptions/repl_tool_python.md index 60a7ee4..86d70bb 100644 --- a/docs/tool-descriptions/repl_tool_python.md +++ b/docs/tool-descriptions/repl_tool_python.md @@ -8,8 +8,21 @@ Arguments: Python REPL affordances: - Session state persists across calls; treat persistence as an iteration aid, not a correctness guarantee. - While work is still running, concurrent non-empty input is discarded; use empty `input` to poll. -- Pager mode activates on large output. Empty input advances one page. Non-empty pager commands must start with `:`. Non-`:` input dismisses pager and is sent to the backend. The main search flow is `:/pattern`, `:n`, `:p`, `:matches`, and `:goto N`. +- Empty `input` polls for more output from a timed-out request or for detached background output while idle. +- If a request times out, keep polling with empty `input` until the remaining worker output is drained. New non-empty input is discarded while that timed-out request is still active. +- Large output replies may stay inline when only slightly oversized. Larger overages may be written to a server-owned output bundle directory. The inline reply stays bounded and may show a preview plus the most relevant disclosed path inside that bundle. +- Bundle files are materialized lazily. Text-only oversized replies disclose `transcript.txt`. Image bundles use `images/` for the latest image aliases and `images/history/` for ordered image history. `events.log` is created only once a bundle needs ordered mixed text+image indexing. +- `transcript.txt` contains worker-originated REPL text such as echoed input, prompts, stdout, and rendered stderr text. Server status lines stay inline and are not written into `transcript.txt` or `events.log`. +- `events.log`, when present, is the authoritative ordered index for the retained mixed worker-text/image bundle contents. `T` rows point to line and byte ranges in `transcript.txt`. `I` rows point to relative image history paths such as `images/history/001/002.png`. If bundle retention limits omit tail content, the inline reply reports that omission, and mixed bundles also record it in `events.log`. +- When an output bundle is used for images, the inline preview keeps the first and last image as anchors. Inspect top-level files under `images/` first for the latest image state. Use `events.log` plus `images/history/` only when you need ordered image history. +- Example image bundle layout: + - `images/001.png` + - `images/002.png` + - `images/history/001/001.png` + - `images/history/001/002.png` + - `images/history/002/001.png` +- Older output bundles may be pruned to keep storage bounded. A disclosed bundle path remains usable until it is pruned or the server exits. - Plot images are returned as image content (for example matplotlib output). - Help flows are in-band (`help()`, `dir()`, `pydoc.help`). -- Debugging workflows are supported (`breakpoint()`, `pdb.set_trace()`). +- Debugging works in the REPL, including interactive stops from `breakpoint()` and `pdb.set_trace()`. - Control prefixes in `input`: `\u0003` (interrupt) and `\u0004` (reset then run remaining input). diff --git a/docs/tool-descriptions/repl_tool_python_pager.md b/docs/tool-descriptions/repl_tool_python_pager.md new file mode 100644 index 0000000..b07971f --- /dev/null +++ b/docs/tool-descriptions/repl_tool_python_pager.md @@ -0,0 +1,25 @@ +`repl` runs source text in a persistent Python REPL session and returns emitted stdout/stderr and images. + +Arguments: +- `input` (string): bytes to write to backend stdin. Send empty input while the pager is active to advance one page. +- `timeout_ms` (number, optional): maximum milliseconds to wait before returning. + Timeout bounds only this response window; it does not cancel backend work. + +Python REPL affordances: +- Session state persists across calls; treat persistence as an iteration aid, not a correctness guarantee. +- While work is still running, concurrent non-empty input is discarded; use empty `input` to poll. +- Empty `input` polls for more output from a timed-out request or for detached background output while idle. While pager mode is active, empty input advances one page. +- If a request times out, keep polling with empty `input` until the remaining worker output is drained. New non-empty input is discarded while that timed-out request is still active. +- Oversized text output can enter a modal pager. While pager mode is active, backend input is blocked until you quit the pager or consume the remaining pages. +- Pager commands: + - next page: empty input or `:next` + - quit pager: `:q` + - search: `:/pattern` + - next/previous search hit: `:n`, `:p` + - list matches / hits: `:matches`, `:hits` + - help: `:help` +- Pager responses use `[pager]` status lines and may suppress the backend prompt until pager mode ends. +- Plot images are returned as image content (for example matplotlib output). +- Help flows are in-band (`help()`, `dir()`, `pydoc.help`). +- Debugging works in the REPL, including interactive stops from `breakpoint()` and `pdb.set_trace()`. +- Control prefixes in `input`: `\u0003` (interrupt) and `\u0004` (reset then run remaining input). diff --git a/docs/tool-descriptions/repl_tool_r.md b/docs/tool-descriptions/repl_tool_r.md index 73278d6..11b6bb4 100644 --- a/docs/tool-descriptions/repl_tool_r.md +++ b/docs/tool-descriptions/repl_tool_r.md @@ -9,8 +9,21 @@ Behavior: - Uses the user's R installation and library paths. - Plots (ggplot2 and base R) are captured and returned as images. Adjust sizing with `options(console.plot.width, console.plot.height, console.plot.units, console.plot.dpi)`. -- Pager mode activates on large output. Empty input advances one page. Non-empty pager commands must start with `:`. Non-`:` input dismisses pager and is sent to the backend. +- Empty `input` polls for more output from a timed-out request or for detached background output while idle. +- If a request times out, keep polling with empty `input` until the remaining worker output is drained. New non-empty input is discarded while that timed-out request is still active. +- Large output replies may stay inline when only slightly oversized. Larger overages may be written to a server-owned output bundle directory. The inline reply stays bounded and may show a preview plus the most relevant disclosed path inside that bundle. +- Bundle files are materialized lazily. Text-only oversized replies disclose `transcript.txt`. Image bundles use `images/` for the latest image aliases and `images/history/` for ordered image history. `events.log` is created only once a bundle needs ordered mixed text+image indexing. +- `transcript.txt` contains worker-originated REPL text such as echoed input, prompts, stdout, and rendered stderr text. Server status lines stay inline and are not written into `transcript.txt` or `events.log`. +- `events.log`, when present, is the authoritative ordered index for the retained mixed worker-text/image bundle contents. `T` rows point to line and byte ranges in `transcript.txt`. `I` rows point to relative image history paths such as `images/history/001/002.png`. If bundle retention limits omit tail content, the inline reply reports that omission, and mixed bundles also record it in `events.log`. +- When an output bundle is used for images, the inline preview keeps the first and last image as anchors. Inspect top-level files under `images/` first for the latest image state. Use `events.log` plus `images/history/` only when you need ordered image history. +- Example image bundle layout: + - `images/001.png` + - `images/002.png` + - `images/history/001/001.png` + - `images/history/001/002.png` + - `images/history/002/001.png` +- Older output bundles may be pruned to keep storage bounded. A disclosed bundle path remains usable until it is pruned or the server exits. - Documentation entry points work in-band. Prefer the normal R interfaces such as `?topic`, `help()`, `vignette()`, and `RShowDoc("R-exts")`; the REPL renders their text/HTML output directly instead of launching an external viewer. -- For large manuals and help pages, use the pager. `?topic`, `help()`, `vignette()`, and `RShowDoc()` can all open there. Use `:help` for commands. The main search flow is `:/pattern`, `:n`, `:p`, `:matches`, and `:goto N`. -- Debugging: `browser()`, `debug()`, `trace()`. +- `?topic`, `help()`, `vignette()`, and `RShowDoc()` render directly into the tool response instead of opening a separate web-browser flow. +- Debugging works in the REPL, including interactive stops from `browser()`, `debug()`, and `trace()`. - Control: `\u0003` in input interrupts; `\u0004` resets session then runs remaining input. diff --git a/docs/tool-descriptions/repl_tool_r_pager.md b/docs/tool-descriptions/repl_tool_r_pager.md new file mode 100644 index 0000000..83209a1 --- /dev/null +++ b/docs/tool-descriptions/repl_tool_r_pager.md @@ -0,0 +1,25 @@ +The r repl tool executes R code in a persistent session. Returns stdout, stderr, and rendered plots. + +Arguments: +- `input` (string): R code to execute. Send empty input while the pager is active to advance one page. +- `timeout_ms` (number, optional): Max milliseconds to wait (bounds this call only; doesn't cancel backend work). + +Behavior: +- Session state (variables, loaded packages) persists across calls. Errors don't crash the session. +- Uses the user's R installation and library paths. +- Plots (ggplot2 and base R) are captured and returned as images. Adjust sizing with `options(console.plot.width, console.plot.height, console.plot.units, console.plot.dpi)`. +- Empty `input` polls for more output from a timed-out request or for detached background output while idle. While pager mode is active, empty input advances one page. +- If a request times out, keep polling with empty `input` until the remaining worker output is drained. New non-empty input is discarded while that timed-out request is still active. +- Oversized text output can enter a modal pager. While pager mode is active, backend input is blocked until you quit the pager or consume the remaining pages. +- Pager commands: + - next page: empty input or `:next` + - quit pager: `:q` + - search: `:/pattern` + - next/previous search hit: `:n`, `:p` + - list matches / hits: `:matches`, `:hits` + - help: `:help` +- Pager responses use `[pager]` status lines and may suppress the backend prompt until pager mode ends. +- Documentation entry points work in-band. Prefer the normal R interfaces such as `?topic`, `help()`, `vignette()`, and `RShowDoc("R-exts")`; the REPL renders their text/HTML output directly instead of launching an external viewer. +- `?topic`, `help()`, `vignette()`, and `RShowDoc()` render directly into the tool response instead of opening a separate web-browser flow. +- Debugging works in the REPL, including interactive stops from `browser()`, `debug()`, and `trace()`. +- Control: `\u0003` in input interrupts; `\u0004` resets session then runs remaining input. diff --git a/src/debug_repl.rs b/src/debug_repl.rs index 0ea8ff8..0c7e571 100644 --- a/src/debug_repl.rs +++ b/src/debug_repl.rs @@ -4,24 +4,34 @@ use std::thread; use std::time::Duration; use std::time::Instant; +use rmcp::model::{CallToolResult, RawContent}; + use crate::backend::Backend; -use crate::pager; +use crate::oversized_output::OversizedOutputMode; use crate::sandbox_cli::SandboxCliPlan; +use crate::server::response::{ + ResponseState, TimeoutBundleReuse, text_stream_from_content, timeout_bundle_reuse_for_input, +}; use crate::worker_process::{WorkerError, WorkerManager}; -use crate::worker_protocol::{TextStream, WorkerContent, WorkerReply}; +use crate::worker_protocol::{TextStream, WorkerContent, WorkerErrorCode, WorkerReply}; const DEFAULT_WRITE_STDIN_TIMEOUT: Duration = Duration::from_secs(60); const SAFETY_MARGIN: f64 = 1.05; const MIN_SERVER_GRACE: Duration = Duration::from_secs(1); -const DEBUG_REPL_PAGE_CHARS: u64 = 300; const INITIAL_PROMPT_WAIT: Duration = Duration::from_secs(5); const INITIAL_PROMPT_POLL_INTERVAL: Duration = Duration::from_millis(50); +struct VisibleReplyContext { + pending_request_after: bool, + detached_prefix_item_count: usize, + timeout_bundle_reuse: TimeoutBundleReuse, +} + pub(crate) fn run( backend: Backend, sandbox_plan: SandboxCliPlan, + oversized_output: OversizedOutputMode, ) -> Result<(), Box<dyn std::error::Error>> { - ensure_debug_repl_page_size(); let image_support = detect_image_support(); eprintln!( "debug repl: write_stdin timeout={:.1}s | end input with END | commands: INTERRUPT, RESTART | Ctrl-D to exit | images={}", @@ -33,10 +43,26 @@ pub(crate) fn run( let mut stderr = io::stderr(); let server_timeout = apply_safety_margin(DEFAULT_WRITE_STDIN_TIMEOUT); - let mut worker = WorkerManager::new(backend, sandbox_plan)?; + let mut worker = WorkerManager::new(backend, sandbox_plan, oversized_output)?; + let mut response = if oversized_output == OversizedOutputMode::Files { + Some(ResponseState::new()?) + } else { + None + }; worker.warm_start()?; let reply = wait_for_initial_prompt(&mut worker, server_timeout)?; - render_reply(reply, &mut stdout, &mut stderr, image_support)?; + render_visible_reply( + response.as_mut(), + Ok(reply), + VisibleReplyContext { + pending_request_after: worker.pending_request(), + detached_prefix_item_count: worker.detached_prefix_item_count(), + timeout_bundle_reuse: TimeoutBundleReuse::FullReply, + }, + &mut stdout, + &mut stderr, + image_support, + )?; let stdin = io::stdin(); let mut stdin = stdin.lock(); @@ -47,13 +73,35 @@ pub(crate) fn run( }; if is_exact_command(&line, "INTERRUPT") { - let reply = worker.interrupt(DEFAULT_WRITE_STDIN_TIMEOUT)?; - render_reply(reply, &mut stdout, &mut stderr, image_support)?; + let reply = worker.interrupt(DEFAULT_WRITE_STDIN_TIMEOUT); + render_visible_reply( + response.as_mut(), + reply, + VisibleReplyContext { + pending_request_after: worker.pending_request(), + detached_prefix_item_count: 0, + timeout_bundle_reuse: TimeoutBundleReuse::None, + }, + &mut stdout, + &mut stderr, + image_support, + )?; continue; } if is_exact_command(&line, "RESTART") { - let reply = worker.restart(DEFAULT_WRITE_STDIN_TIMEOUT)?; - render_reply(reply, &mut stdout, &mut stderr, image_support)?; + let reply = worker.restart(DEFAULT_WRITE_STDIN_TIMEOUT); + render_visible_reply( + response.as_mut(), + reply, + VisibleReplyContext { + pending_request_after: worker.pending_request(), + detached_prefix_item_count: 0, + timeout_bundle_reuse: TimeoutBundleReuse::None, + }, + &mut stdout, + &mut stderr, + image_support, + )?; continue; } if is_exact_command(&line, "END") { @@ -63,8 +111,19 @@ pub(crate) fn run( server_timeout, None, false, + ); + render_visible_reply( + response.as_mut(), + reply, + VisibleReplyContext { + pending_request_after: worker.pending_request(), + detached_prefix_item_count: worker.detached_prefix_item_count(), + timeout_bundle_reuse: TimeoutBundleReuse::FullReply, + }, + &mut stdout, + &mut stderr, + image_support, )?; - render_reply(reply, &mut stdout, &mut stderr, image_support)?; continue; } @@ -83,14 +142,30 @@ pub(crate) fn run( } } + let timeout_bundle_reuse = timeout_bundle_reuse_for_input(&input); let reply = worker.write_stdin( input, DEFAULT_WRITE_STDIN_TIMEOUT, server_timeout, None, false, + ); + render_visible_reply( + response.as_mut(), + reply, + VisibleReplyContext { + pending_request_after: worker.pending_request(), + detached_prefix_item_count: worker.detached_prefix_item_count(), + timeout_bundle_reuse, + }, + &mut stdout, + &mut stderr, + image_support, )?; - render_reply(reply, &mut stdout, &mut stderr, image_support)?; + } + + if let Some(response) = response.as_mut() { + response.shutdown()?; } Ok(()) @@ -188,7 +263,7 @@ fn render_reply( } for content in contents { match content { - WorkerContent::ContentText { text, stream } => match stream { + WorkerContent::ContentText { text, stream, .. } => match stream { TextStream::Stdout => stdout.write_all(text.as_bytes())?, TextStream::Stderr => stderr.write_all(text.as_bytes())?, }, @@ -217,6 +292,102 @@ fn render_reply( Ok(()) } +fn render_visible_reply( + response: Option<&mut ResponseState>, + reply: Result<WorkerReply, WorkerError>, + context: VisibleReplyContext, + stdout: &mut impl Write, + stderr: &mut impl Write, + image_support: bool, +) -> Result<(), Box<dyn std::error::Error>> { + if let Some(response) = response { + let error_banner = reply_error_banner(&reply); + let reply = response.finalize_worker_result( + reply, + context.pending_request_after, + context.timeout_bundle_reuse, + context.detached_prefix_item_count, + ); + render_finalized_reply(reply, error_banner, stdout, stderr, image_support)?; + return Ok(()); + } + + render_reply(reply?, stdout, stderr, image_support)?; + Ok(()) +} + +fn reply_error_banner(reply: &Result<WorkerReply, WorkerError>) -> Option<Option<WorkerErrorCode>> { + match reply { + Ok(WorkerReply::Output { + is_error, + error_code, + .. + }) => { + if error_code.is_some() { + Some(*error_code) + } else if *is_error { + Some(None) + } else { + None + } + } + Err(_) => Some(None), + } +} + +fn render_finalized_reply( + reply: CallToolResult, + error_banner: Option<Option<WorkerErrorCode>>, + stdout: &mut impl Write, + stderr: &mut impl Write, + image_support: bool, +) -> io::Result<()> { + if let Some(code) = error_banner.flatten() { + writeln!(stderr, "[repl] error: {code:?}")?; + } else if error_banner.is_some() { + writeln!(stderr, "[repl] error")?; + } + + for content in reply.content { + let stream = text_stream_from_content(&content).unwrap_or(TextStream::Stdout); + match content.raw { + RawContent::Text(text) => match stream { + TextStream::Stdout => stdout.write_all(text.text.as_bytes())?, + TextStream::Stderr => stderr.write_all(text.text.as_bytes())?, + }, + RawContent::Image(image) => { + if image_support && write_kitty_image(stdout, &image.data, &image.mime_type)? { + // image rendered + } else { + writeln!( + stderr, + "[repl] image mime={} bytes={}", + image.mime_type, + image.data.len() + )?; + } + } + RawContent::Audio(audio) => { + writeln!( + stderr, + "[repl] audio mime={} bytes={}", + audio.mime_type, + audio.data.len() + )?; + } + RawContent::Resource(_) => { + writeln!(stderr, "[repl] resource content omitted")?; + } + RawContent::ResourceLink(_) => { + writeln!(stderr, "[repl] resource link omitted")?; + } + } + } + stdout.flush()?; + stderr.flush()?; + Ok(()) +} + fn detect_image_support() -> bool { if let Ok(value) = env::var("MCP_REPL_IMAGES") { return is_truthy(&value); @@ -232,18 +403,6 @@ fn detect_image_support() -> bool { matches!(term_program.as_str(), "ghostty" | "wezterm" | "iterm.app") } -fn ensure_debug_repl_page_size() { - if env::var_os(pager::PAGER_PAGE_CHARS_ENV).is_some() { - return; - } - unsafe { - env::set_var( - pager::PAGER_PAGE_CHARS_ENV, - DEBUG_REPL_PAGE_CHARS.to_string(), - ); - } -} - fn is_truthy(value: &str) -> bool { matches!( value.trim().to_lowercase().as_str(), @@ -284,6 +443,8 @@ fn write_kitty_image(stdout: &mut impl Write, data: &str, mime_type: &str) -> io #[cfg(test)] mod tests { use super::*; + use crate::server::response::{ResponseState, TimeoutBundleReuse}; + use crate::worker_protocol::{WorkerContent, WorkerReply}; #[test] fn detect_image_support_uses_mcp_repl_env() { @@ -302,4 +463,48 @@ mod tests { } assert!(enabled, "expected MCP_REPL_IMAGES=1 to enable images"); } + + #[test] + fn finalized_reply_preserves_stderr_routing() { + let mut response = ResponseState::new().expect("response state should initialize"); + let reply = response.finalize_worker_result( + Ok(WorkerReply::Output { + contents: vec![ + WorkerContent::worker_stdout("stdout line\n"), + WorkerContent::worker_stderr("stderr: boom\n"), + ], + is_error: true, + error_code: None, + prompt: None, + prompt_variants: None, + }), + false, + TimeoutBundleReuse::None, + 0, + ); + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + render_finalized_reply(reply, Some(None), &mut stdout, &mut stderr, false) + .expect("finalized reply should render"); + + let stdout = String::from_utf8(stdout).expect("stdout should be valid UTF-8"); + let stderr = String::from_utf8(stderr).expect("stderr should be valid UTF-8"); + assert!( + stdout.contains("stdout line\n"), + "expected stdout text on stdout, got: {stdout:?}" + ); + assert!( + !stdout.contains("stderr: boom\n"), + "stderr chunk leaked to stdout: {stdout:?}" + ); + assert!( + stderr.contains("[repl] error\n"), + "expected error banner on stderr, got: {stderr:?}" + ); + assert!( + stderr.contains("stderr: boom\n"), + "expected stderr chunk on stderr, got: {stderr:?}" + ); + } } diff --git a/src/install.rs b/src/install.rs index 1b738d2..5dd1448 100644 --- a/src/install.rs +++ b/src/install.rs @@ -293,6 +293,11 @@ fn has_interpreter_config_arg(args: &[String]) -> bool { }) } +fn has_oversized_output_arg(args: &[String]) -> bool { + args.iter() + .any(|arg| arg == "--oversized-output" || arg.starts_with("--oversized-output=")) +} + fn has_interpreter_value(args: &[String], target: &str) -> bool { let mut iter = args.iter(); while let Some(arg) = iter.next() { @@ -340,6 +345,10 @@ fn codex_install_args(base_args: &[String]) -> Vec<String> { args.push("--sandbox".to_string()); args.push("inherit".to_string()); } + if !has_oversized_output_arg(base_args) { + args.push("--oversized-output".to_string()); + args.push("files".to_string()); + } args } @@ -349,6 +358,10 @@ fn claude_install_args(base_args: &[String]) -> Vec<String> { args.push("--sandbox".to_string()); args.push("workspace-write".to_string()); } + if !has_oversized_output_arg(base_args) { + args.push("--oversized-output".to_string()); + args.push("files".to_string()); + } args } @@ -879,7 +892,9 @@ name="demo" "--interpreter".to_string(), "python".to_string(), "--sandbox".to_string(), - "inherit".to_string() + "inherit".to_string(), + "--oversized-output".to_string(), + "files".to_string() ] ); } @@ -893,7 +908,9 @@ name="demo" "--interpreter".to_string(), "python".to_string(), "--sandbox".to_string(), - "workspace-write".to_string() + "workspace-write".to_string(), + "--oversized-output".to_string(), + "files".to_string() ] ); } @@ -906,8 +923,16 @@ name="demo" "--interpreter".to_string(), "python".to_string(), ]; - assert_eq!(codex_install_args(&base), base); - assert_eq!(claude_install_args(&base), base); + let expected = vec![ + "--sandbox".to_string(), + "read-only".to_string(), + "--interpreter".to_string(), + "python".to_string(), + "--oversized-output".to_string(), + "files".to_string(), + ]; + assert_eq!(codex_install_args(&base), expected); + assert_eq!(claude_install_args(&base), expected); } #[test] @@ -918,8 +943,16 @@ name="demo" "--interpreter".to_string(), "python".to_string(), ]; - assert_eq!(codex_install_args(&base), base); - assert_eq!(claude_install_args(&base), base); + let expected = vec![ + "--config".to_string(), + "sandbox_mode=read-only".to_string(), + "--interpreter".to_string(), + "python".to_string(), + "--oversized-output".to_string(), + "files".to_string(), + ]; + assert_eq!(codex_install_args(&base), expected); + assert_eq!(claude_install_args(&base), expected); } #[test] @@ -929,8 +962,47 @@ name="demo" "--interpreter".to_string(), "python".to_string(), ]; - assert_eq!(codex_install_args(&base), base); - assert_eq!(claude_install_args(&base), base); + let expected = vec![ + "--config=sandbox_workspace_write.network_access=true".to_string(), + "--interpreter".to_string(), + "python".to_string(), + "--oversized-output".to_string(), + "files".to_string(), + ]; + assert_eq!(codex_install_args(&base), expected); + assert_eq!(claude_install_args(&base), expected); + } + + #[test] + fn install_args_preserve_explicit_oversized_output_mode() { + let base = vec![ + "--oversized-output".to_string(), + "pager".to_string(), + "--interpreter".to_string(), + "python".to_string(), + ]; + assert_eq!( + codex_install_args(&base), + vec![ + "--oversized-output".to_string(), + "pager".to_string(), + "--interpreter".to_string(), + "python".to_string(), + "--sandbox".to_string(), + "inherit".to_string(), + ] + ); + assert_eq!( + claude_install_args(&base), + vec![ + "--oversized-output".to_string(), + "pager".to_string(), + "--interpreter".to_string(), + "python".to_string(), + "--sandbox".to_string(), + "workspace-write".to_string(), + ] + ); } #[test] diff --git a/src/ipc.rs b/src/ipc.rs index bdf4601..3adfef0 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -95,6 +95,8 @@ pub enum WorkerToServerIpcMessage { data: String, is_new: bool, }, + /// Emitted exactly when the backend knows the logical request has consumed all queued input. + /// No later `ReadlineResult` should follow for that same request. RequestEnd, SessionEnd, } @@ -108,6 +110,8 @@ struct ServerIpcInbox { readline_result_count: u64, readline_unmatched_starts: usize, readline_unmatched_since: Option<Instant>, + request_end_seen: bool, + protocol_warnings: VecDeque<String>, session_end: bool, disconnected: bool, } @@ -135,6 +139,10 @@ pub struct IpcPlotImage { #[derive(Default, Clone)] pub struct IpcHandlers { pub on_plot_image: Option<Arc<dyn Fn(IpcPlotImage) + Send + Sync>>, + pub on_readline_start: Option<Arc<dyn Fn(String) + Send + Sync>>, + pub on_readline_result: Option<Arc<dyn Fn(IpcEchoEvent) + Send + Sync>>, + pub on_request_end: Option<Arc<dyn Fn() + Send + Sync>>, + pub on_session_end: Option<Arc<dyn Fn() + Send + Sync>>, } #[derive(Clone)] @@ -142,6 +150,7 @@ pub struct ServerIpcConnection { sender: mpsc::Sender<ServerToWorkerIpcMessage>, inbox: Arc<Mutex<ServerIpcInbox>>, cvar: Arc<Condvar>, + reader_thread: Arc<Mutex<Option<thread::JoinHandle<()>>>>, } #[derive(Clone)] @@ -177,12 +186,17 @@ impl ServerIpcConnection { let (tx, rx) = mpsc::channel(); let inbox = Arc::new(Mutex::new(ServerIpcInbox::default())); let cvar = Arc::new(Condvar::new()); + let reader_thread = Arc::new(Mutex::new(None)); let reader_inbox = inbox.clone(); let reader_cvar = cvar.clone(); let plot_handler = handlers.on_plot_image.clone(); + let readline_start_handler = handlers.on_readline_start.clone(); + let readline_result_handler = handlers.on_readline_result.clone(); + let request_end_handler = handlers.on_request_end.clone(); + let session_end_handler = handlers.on_session_end.clone(); let IpcTransport { reader, writer } = transport; - thread::spawn(move || { + let handle = thread::spawn(move || { let mut reader = BufReader::new(reader); let mut line = String::new(); loop { @@ -209,7 +223,11 @@ impl ServerIpcConnection { if let Ok(message) = serde_json::from_str::<WorkerToServerIpcMessage>(trimmed) { match message { WorkerToServerIpcMessage::ReadlineStart { prompt } => { + let prompt_for_handler = prompt.clone(); let mut guard = reader_inbox.lock().unwrap(); + if guard.request_end_seen { + reset_after_completed_request(&mut guard); + } guard.readline_unmatched_starts = guard.readline_unmatched_starts.saturating_add(1); if guard.readline_unmatched_starts == 1 { @@ -227,9 +245,23 @@ impl ServerIpcConnection { } guard.last_prompt = Some(prompt); reader_cvar.notify_all(); + drop(guard); + if let Some(handler) = readline_start_handler.as_ref() { + handler(prompt_for_handler); + } } WorkerToServerIpcMessage::ReadlineResult { prompt, line } => { + let echo_event = IpcEchoEvent { + prompt: prompt.clone(), + line: line.clone(), + }; let mut guard = reader_inbox.lock().unwrap(); + if guard.request_end_seen { + guard.protocol_warnings.push_back( + "protocol warning: worker emitted ReadlineResult after RequestEnd" + .to_string(), + ); + } guard.readline_result_count = guard.readline_result_count.saturating_add(1); if guard.readline_unmatched_starts > 0 { @@ -238,14 +270,22 @@ impl ServerIpcConnection { guard.readline_unmatched_since = None; } } - guard.echo_events.push_back(IpcEchoEvent { prompt, line }); + guard.echo_events.push_back(echo_event.clone()); reader_cvar.notify_all(); + drop(guard); + if let Some(handler) = readline_result_handler.as_ref() { + handler(echo_event); + } } WorkerToServerIpcMessage::SessionEnd => { let mut guard = reader_inbox.lock().unwrap(); guard.session_end = true; guard.queue.push_back(WorkerToServerIpcMessage::SessionEnd); reader_cvar.notify_all(); + drop(guard); + if let Some(handler) = session_end_handler.as_ref() { + handler(); + } } WorkerToServerIpcMessage::PlotImage { id, @@ -273,13 +313,23 @@ impl ServerIpcConnection { } other => { let mut guard = reader_inbox.lock().unwrap(); + let is_request_end = + matches!(other, WorkerToServerIpcMessage::RequestEnd); + if is_request_end { + guard.request_end_seen = true; + } guard.queue.push_back(other); reader_cvar.notify_all(); + drop(guard); + if is_request_end && let Some(handler) = request_end_handler.as_ref() { + handler(); + } } } } } }); + *reader_thread.lock().unwrap() = Some(handle); spawn_writer(rx, writer); @@ -287,6 +337,7 @@ impl ServerIpcConnection { sender: tx, inbox, cvar, + reader_thread, }) } @@ -297,22 +348,26 @@ impl ServerIpcConnection { self.sender.send(message) } - pub fn clear_prompt_history(&self) { - let mut guard = self.inbox.lock().unwrap(); - guard.prompt_history.clear(); + pub fn join_reader_thread(&self) -> io::Result<()> { + let handle = self.reader_thread.lock().unwrap().take(); + let Some(handle) = handle else { + return Ok(()); + }; + handle + .join() + .map_err(|_| io::Error::other("ipc reader thread panicked"))?; + Ok(()) } - pub fn clear_echo_events(&self) { + pub fn begin_request(&self) { let mut guard = self.inbox.lock().unwrap(); + guard + .queue + .retain(|msg| !matches!(msg, WorkerToServerIpcMessage::RequestEnd)); + reset_after_completed_request(&mut guard); guard.echo_events.clear(); - } - - pub fn clear_readline_tracking(&self) { - let mut guard = self.inbox.lock().unwrap(); - guard.readline_result_count = 0; - guard.readline_unmatched_starts = 0; - guard.readline_unmatched_since = None; - guard.last_prompt = None; + guard.prompt_history.clear(); + guard.protocol_warnings.clear(); } pub fn waiting_for_next_input(&self, min_wait: Duration) -> bool { @@ -326,13 +381,6 @@ impl ServerIpcConnection { since.elapsed() >= min_wait } - pub fn clear_request_end_events(&self) { - let mut guard = self.inbox.lock().unwrap(); - guard - .queue - .retain(|msg| !matches!(msg, WorkerToServerIpcMessage::RequestEnd)); - } - pub fn take_prompt_history(&self) -> Vec<String> { let mut guard = self.inbox.lock().unwrap(); guard.prompt_history.drain(..).collect() @@ -343,6 +391,16 @@ impl ServerIpcConnection { guard.echo_events.drain(..).collect() } + pub fn pending_echo_event_count(&self) -> usize { + let guard = self.inbox.lock().unwrap(); + guard.echo_events.len() + } + + pub fn take_protocol_warnings(&self) -> Vec<String> { + let mut guard = self.inbox.lock().unwrap(); + guard.protocol_warnings.drain(..).collect() + } + pub fn wait_for_request_end(&self, timeout: Duration) -> Result<(), IpcWaitError> { let deadline = Instant::now() + timeout; let mut guard = self.inbox.lock().unwrap(); @@ -1292,6 +1350,14 @@ fn take_request_end(guard: &mut ServerIpcInbox) -> bool { true } +fn reset_after_completed_request(guard: &mut ServerIpcInbox) { + guard.request_end_seen = false; + guard.readline_result_count = 0; + guard.readline_unmatched_starts = 0; + guard.readline_unmatched_since = None; + guard.last_prompt = None; +} + fn take_backend_info(guard: &mut ServerIpcInbox) -> Option<WorkerToServerIpcMessage> { let idx = guard .queue diff --git a/src/main.rs b/src/main.rs index 6d91f66..1bb86d9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,7 +9,9 @@ mod install; mod ipc; mod output_capture; mod output_stream; +mod oversized_output; mod pager; +mod pending_output_tape; mod r_controls; mod r_graphics; mod r_htmd; @@ -26,6 +28,7 @@ mod worker_protocol; use std::path::PathBuf; use crate::backend::{Backend, backend_from_env}; +use crate::oversized_output::OversizedOutputMode; use crate::sandbox_cli::{ SandboxCliOperation, SandboxCliPlan, SandboxModeArg, parse_sandbox_config_override, }; @@ -41,6 +44,7 @@ struct CliOptions { debug_repl: bool, backend: Backend, debug_dir: Option<PathBuf>, + oversized_output: OversizedOutputMode, } #[derive(Debug, Default)] @@ -92,10 +96,19 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { )?; if options.debug_repl { crate::diagnostics::startup_log("main: debug repl mode"); - return debug_repl::run(options.backend, options.sandbox_plan); + return debug_repl::run( + options.backend, + options.sandbox_plan, + options.oversized_output, + ); } crate::diagnostics::startup_log("main: server mode"); - server::run(options.backend, options.sandbox_plan).await + server::run( + options.backend, + options.sandbox_plan, + options.oversized_output, + ) + .await } CliCommand::Install(options) => install::run(options), } @@ -109,7 +122,16 @@ fn ignore_sigpipe() { } fn parse_cli_args() -> Result<CliCommand, Box<dyn std::error::Error>> { - let mut parser = ArgParser::new(); + parse_cli_args_from( + std::env::args_os() + .skip(1) + .map(|arg| arg.to_string_lossy().into_owned()) + .collect(), + ) +} + +fn parse_cli_args_from(args: Vec<String>) -> Result<CliCommand, Box<dyn std::error::Error>> { + let mut parser = ArgParser { args, index: 0 }; if parser.peek() == Some("install") { parser.next(); return Ok(CliCommand::Install(parse_install_args(&mut parser)?)); @@ -119,6 +141,8 @@ fn parse_cli_args() -> Result<CliCommand, Box<dyn std::error::Error>> { let mut debug_repl = false; let mut debug_dir = None; let mut backend = backend_from_env()?; + let mut oversized_output = OversizedOutputMode::Pager; + let mut oversized_output_seen = false; while let Some(arg) = parser.next() { match arg.as_str() { "-h" | "--help" => { @@ -227,6 +251,30 @@ fn parse_cli_args() -> Result<CliCommand, Box<dyn std::error::Error>> { } debug_dir = Some(PathBuf::from(value)); } + "--oversized-output" => { + if oversized_output_seen { + return Err("duplicate --oversized-output".into()); + } + let value = parser.next_value("--oversized-output")?; + oversized_output = + OversizedOutputMode::parse(&value).map_err(|err| err.to_string())?; + oversized_output_seen = true; + } + _ if arg.starts_with("--oversized-output=") => { + if oversized_output_seen { + return Err("duplicate --oversized-output".into()); + } + let value = arg + .split_once('=') + .map(|(_, value)| value) + .unwrap_or_default(); + if value.is_empty() { + return Err("missing value for --oversized-output".into()); + } + oversized_output = + OversizedOutputMode::parse(value).map_err(|err| err.to_string())?; + oversized_output_seen = true; + } _ => match parse_backend_arg(&arg, &mut parser)? { Some(parsed_backend) => backend = Some(parsed_backend), None => return Err(format!("unknown argument: {arg}").into()), @@ -239,6 +287,7 @@ fn parse_cli_args() -> Result<CliCommand, Box<dyn std::error::Error>> { debug_repl, backend: backend.unwrap_or(Backend::R), debug_dir, + oversized_output, })) } @@ -265,16 +314,6 @@ struct ArgParser { } impl ArgParser { - fn new() -> Self { - Self { - args: std::env::args_os() - .skip(1) - .map(|arg| arg.to_string_lossy().into_owned()) - .collect(), - index: 0, - } - } - fn next(&mut self) -> Option<String> { let value = self.args.get(self.index)?.clone(); self.index += 1; @@ -415,11 +454,12 @@ fn parse_writable_root(raw: &str) -> Result<PathBuf, Box<dyn std::error::Error>> fn print_usage() { println!( "Usage:\n\ -mcp-repl [--debug-repl] [--interpreter <r|python>] [--sandbox <inherit|read-only|workspace-write|danger-full-access>] [--add-writable-root <abs-path>] [--add-allowed-domain <domain>] [--config <key=value>]...\n\ +mcp-repl [--debug-repl] [--interpreter <r|python>] [--oversized-output <files|pager>] [--sandbox <inherit|read-only|workspace-write|danger-full-access>] [--add-writable-root <abs-path>] [--add-allowed-domain <domain>] [--config <key=value>]...\n\ mcp-repl install [--client <codex|claude>]... [--interpreter <r|python>[,r|python]...]... [--arg <value>]...\n\n\ --debug-repl: run an interactive debug REPL over stdio\n\ --debug-dir: optional base directory for per-startup debug artifacts (env: MCP_REPL_DEBUG_DIR)\n\ --interpreter: choose REPL interpreter (default: r; env MCP_REPL_INTERPRETER)\n\ +--oversized-output: choose oversized-output handling (pager: default legacy modal pager; files: spill oversized replies to files)\n\ --sandbox: base sandbox mode (inherit requires client sandbox update)\n\ --add-writable-root / --add-writeable-root: append absolute writable root in argument order\n\ --add-allowed-domain: append allowed domain pattern in argument order\n\ @@ -464,6 +504,26 @@ mod tests { assert_eq!(parsed, Some(Backend::Python)); } + #[test] + fn parse_cli_args_defaults_oversized_output_to_pager() { + let command = parse_cli_args_from(Vec::new()).expect("parse cli args"); + let CliCommand::RunServer(options) = command else { + panic!("expected server command"); + }; + assert_eq!(options.oversized_output, OversizedOutputMode::Pager); + } + + #[test] + fn parse_cli_args_accepts_explicit_oversized_output_files() { + let command = + parse_cli_args_from(vec!["--oversized-output".to_string(), "files".to_string()]) + .expect("parse cli args"); + let CliCommand::RunServer(options) = command else { + panic!("expected server command"); + }; + assert_eq!(options.oversized_output, OversizedOutputMode::Files); + } + #[test] fn parse_install_args_defaults_to_no_interpreters() { let mut parser = ArgParser { @@ -631,8 +691,10 @@ mod tests { #[test] fn empty_plan_uses_inherited_state_when_available() { let plan = SandboxCliPlan::default(); - let mut inherited = SandboxState::default(); - inherited.sandbox_policy = SandboxPolicy::DangerFullAccess; + let inherited = SandboxState { + sandbox_policy: SandboxPolicy::DangerFullAccess, + ..SandboxState::default() + }; let resolved = resolve_effective_sandbox_state(&plan, Some(&inherited)) .expect("effective sandbox state"); assert_eq!(resolved.sandbox_policy, SandboxPolicy::DangerFullAccess); diff --git a/src/oversized_output.rs b/src/oversized_output.rs new file mode 100644 index 0000000..d429b95 --- /dev/null +++ b/src/oversized_output.rs @@ -0,0 +1,16 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum OversizedOutputMode { + Files, + #[default] + Pager, +} + +impl OversizedOutputMode { + pub fn parse(value: &str) -> Result<Self, &'static str> { + match value { + "files" => Ok(Self::Files), + "pager" => Ok(Self::Pager), + _ => Err("invalid --oversized-output value (expected files|pager)"), + } + } +} diff --git a/src/pager/mod.rs b/src/pager/mod.rs index adbede2..6fa2b97 100644 --- a/src/pager/mod.rs +++ b/src/pager/mod.rs @@ -885,7 +885,8 @@ impl Pager { next }); if state.seen_images.contains(&image_id) { - *content = WorkerContent::stderr(format!("[pager] image #{num} already shown\n")); + *content = + WorkerContent::server_stderr(format!("[pager] image #{num} already shown\n")); } else { state.seen_images.insert(image_id); } @@ -1024,7 +1025,7 @@ impl Pager { pub(crate) fn handle_command(&mut self, input: &str) -> WorkerReply { if self.state.is_none() { return pager_reply( - vec![WorkerContent::stderr("[pager] no pager active")], + vec![WorkerContent::server_stderr("[pager] no pager active")], true, None, ); @@ -1033,8 +1034,10 @@ impl Pager { let Some(command) = PagerCommand::parse(input) else { let page_bytes = page_bytes(); let pages_left = self.pages_left_for_help(page_bytes); - let mut contents = vec![WorkerContent::stderr(non_command_input_message(input))]; - contents.push(WorkerContent::stderr(self.footer(pages_left))); + let mut contents = vec![WorkerContent::server_stderr(non_command_input_message( + input, + ))]; + contents.push(WorkerContent::server_stderr(self.footer(pages_left))); return pager_reply(contents, false, None); }; @@ -1043,7 +1046,7 @@ impl Pager { if let PagerCommand::Quit = command { let footer = self.footer(0); self.dismiss(); - let contents = vec![WorkerContent::stderr(footer)]; + let contents = vec![WorkerContent::server_stderr(footer)]; return pager_reply(contents, false, None); } @@ -1210,7 +1213,7 @@ impl Pager { } else { state.search_session = None; let pages_left = pages_left_for_buffer(&state.buffer, page_bytes); - let contents = vec![WorkerContent::stderr(format!( + let contents = vec![WorkerContent::server_stderr(format!( "[pager] pattern not found: {}", pattern.pattern ))]; @@ -1219,7 +1222,7 @@ impl Pager { } PagerCommand::Where { pattern } => { let pages_left = pages_left_for_buffer(&state.buffer, page_bytes); - let contents = vec![WorkerContent::stderr(where_in_buffer( + let contents = vec![WorkerContent::server_stderr(where_in_buffer( &state.buffer, &state.seen_ranges, page_bytes, @@ -1254,7 +1257,7 @@ impl Pager { .without_last_emitted_update() } else { state.search_session = None; - let contents = vec![WorkerContent::stderr(format!( + let contents = vec![WorkerContent::server_stderr(format!( "[pager] pattern not found: {}", pattern.pattern ))]; @@ -1281,7 +1284,7 @@ impl Pager { .with_view_ranges(view_ranges) .without_last_emitted_update() } else { - let contents = vec![WorkerContent::stderr( + let contents = vec![WorkerContent::server_stderr( "[pager] no active search; use `:/PATTERN` or `:matches PATTERN`" .to_string(), )]; @@ -1312,9 +1315,9 @@ impl Pager { extend_search_session_forward(&state.buffer, session, needed_hits); let moved = move_search_session(session, count, true); if moved == SearchStepOutcome::Boundary { - let contents = vec![WorkerContent::stderr(search_boundary_message( - session, true, - ))]; + let contents = vec![WorkerContent::server_stderr( + search_boundary_message(session, true), + )]; let pages_left = pages_left_for_buffer(&state.buffer, page_bytes); CommandOutcome::no_range_keep(contents, pages_left, is_error) } else { @@ -1364,9 +1367,9 @@ impl Pager { .expect("search session missing after refresh"); let moved = move_search_session(session, count, false); if moved == SearchStepOutcome::Boundary { - let contents = vec![WorkerContent::stderr(search_boundary_message( - session, false, - ))]; + let contents = vec![WorkerContent::server_stderr( + search_boundary_message(session, false), + )]; let pages_left = pages_left_for_buffer(&state.buffer, page_bytes); CommandOutcome::no_range_keep(contents, pages_left, is_error) } else { @@ -1398,7 +1401,7 @@ impl Pager { } } else { let pages_left = pages_left_for_buffer(&state.buffer, page_bytes); - let contents = vec![WorkerContent::stderr( + let contents = vec![WorkerContent::server_stderr( "[pager] no active search; use `:/PATTERN` first".to_string(), )]; CommandOutcome::no_range_keep(contents, pages_left, is_error) @@ -1440,14 +1443,14 @@ impl Pager { .without_last_emitted_update() } else { let pages_left = pages_left_for_buffer(&state.buffer, page_bytes); - let contents = vec![WorkerContent::stderr(format!( + let contents = vec![WorkerContent::server_stderr(format!( "[pager] search hit out of range: {index}" ))]; CommandOutcome::no_range_keep(contents, pages_left, is_error) } } else { let pages_left = pages_left_for_buffer(&state.buffer, page_bytes); - let contents = vec![WorkerContent::stderr( + let contents = vec![WorkerContent::server_stderr( "[pager] no active search; use `:/PATTERN` first".to_string(), )]; CommandOutcome::no_range_keep(contents, pages_left, is_error) @@ -1458,7 +1461,7 @@ impl Pager { let (mut contents, span) = take_line_range(&state.buffer, start, end, &mut state.seen_ranges); if contents.is_empty() { - contents.push(WorkerContent::stderr( + contents.push(WorkerContent::server_stderr( "[pager] no remaining output in range".to_string(), )); } @@ -1483,7 +1486,7 @@ impl Pager { }; if let Some(message) = error_message { - let contents = vec![WorkerContent::stderr(message)]; + let contents = vec![WorkerContent::server_stderr(message)]; CommandOutcome::no_range(contents, pages_left, is_error) } else { let desired_offset = desired_offset.expect("seek offset missing"); @@ -1495,7 +1498,7 @@ impl Pager { } PagerCommand::Help => { let pages_left = pages_left_for_buffer(&state.buffer, page_bytes); - let contents = vec![WorkerContent::stderr(pager_help_text())]; + let contents = vec![WorkerContent::server_stderr(pager_help_text())]; CommandOutcome::no_range(contents, pages_left, is_error) } PagerCommand::Quit => { @@ -1534,9 +1537,9 @@ impl Pager { if dismiss { let footer = self.footer(0); self.dismiss(); - contents.push(WorkerContent::stderr(footer)); + contents.push(WorkerContent::server_stderr(footer)); } else { - contents.push(WorkerContent::stderr(self.footer(pages_left))); + contents.push(WorkerContent::server_stderr(self.footer(pages_left))); } } @@ -1569,7 +1572,7 @@ pub(crate) fn maybe_activate_and_append_footer( } } pager.dedupe_images(contents); - contents.push(WorkerContent::stderr(pager.footer(pages_left))); + contents.push(WorkerContent::server_stderr(pager.footer(pages_left))); } fn contents_from_output_range(range: OutputRange) -> Vec<WorkerContent> { @@ -2118,7 +2121,7 @@ fn take_line_range( ) -> (Vec<WorkerContent>, RangeSpan) { let Some((start_offset, end_offset)) = buffer.line_range_offsets(start_line, end_line) else { return ( - vec![WorkerContent::stderr( + vec![WorkerContent::server_stderr( "[pager] line range out of bounds".to_string(), )], RangeSpan::default(), @@ -2136,7 +2139,7 @@ mod tests { OUTPUT_RING_CAPACITY_BYTES, OutputBuffer, OutputEvent, OutputTextSpan, OutputTimeline, ensure_output_ring, reset_output_ring, }; - use crate::worker_protocol::TextStream; + use crate::worker_protocol::{ContentOrigin, TextStream}; use std::sync::{Mutex, MutexGuard, OnceLock}; struct OutputPagerFixture { @@ -2255,7 +2258,7 @@ mod tests { assert_eq!(contents.len(), 3); let first = match &contents[0] { - WorkerContent::ContentText { text, stream } => { + WorkerContent::ContentText { text, stream, .. } => { assert!(matches!(stream, TextStream::Stdout)); text.as_str() } @@ -2264,7 +2267,7 @@ mod tests { assert_eq!(first, "line1\n"); let marker = match &contents[1] { - WorkerContent::ContentText { text, stream } => { + WorkerContent::ContentText { text, stream, .. } => { assert!(matches!(stream, TextStream::Stderr)); text.as_str() } @@ -2278,7 +2281,7 @@ mod tests { ); let last = match &contents[2] { - WorkerContent::ContentText { text, stream } => { + WorkerContent::ContentText { text, stream, .. } => { assert!(matches!(stream, TextStream::Stdout)); text.as_str() } @@ -2294,7 +2297,7 @@ mod tests { let marker = gap_marker_if_needed(Some((0, 5)), Some((10, 12)), &seen).expect("expected marker"); let text = match marker { - WorkerContent::ContentText { text, stream } => { + WorkerContent::ContentText { text, stream, .. } => { assert!(matches!(stream, TextStream::Stderr)); text } @@ -2393,7 +2396,7 @@ mod tests { assert!(matches!(contents[0], WorkerContent::ContentImage { .. })); let marker = match &contents[2] { - WorkerContent::ContentText { text, stream } => { + WorkerContent::ContentText { text, stream, .. } => { assert!(matches!(stream, TextStream::Stderr)); text.as_str() } @@ -2448,7 +2451,7 @@ mod tests { pager.dedupe_images(&mut contents); let marker_one = match &contents[2] { - WorkerContent::ContentText { text, stream } => { + WorkerContent::ContentText { text, stream, .. } => { assert!(matches!(stream, TextStream::Stderr)); text.as_str() } @@ -2460,7 +2463,7 @@ mod tests { ); let marker_two = match &contents[3] { - WorkerContent::ContentText { text, stream } => { + WorkerContent::ContentText { text, stream, .. } => { assert!(matches!(stream, TextStream::Stderr)); text.as_str() } @@ -3469,10 +3472,13 @@ mod tests { let body = contents .iter() .find_map(|content| match content { - WorkerContent::ContentText { text, stream } - if text.contains("warning foo details") => - { - Some((*stream, text.as_str())) + WorkerContent::ContentText { + text, + stream, + origin, + .. + } if text.contains("warning foo details") => { + Some((*stream, *origin, text.as_str())) } _ => None, }) @@ -3482,6 +3488,11 @@ mod tests { "expected compact search body to preserve stderr stream, got: {:?}", body.0 ); + assert!( + matches!(body.1, ContentOrigin::Worker), + "expected compact search body to stay worker-originated, got: {:?}", + body.1 + ); drop(guard); } @@ -3548,7 +3559,7 @@ mod tests { let stream = contents .iter() .find_map(|content| match content { - WorkerContent::ContentText { text, stream } if text.contains("alpha foo") => { + WorkerContent::ContentText { text, stream, .. } if text.contains("alpha foo") => { Some(*stream) } _ => None, @@ -3596,7 +3607,7 @@ mod tests { let stream = contents .iter() .find_map(|content| match content { - WorkerContent::ContentText { text, stream } if text.contains("alpha foo") => { + WorkerContent::ContentText { text, stream, .. } if text.contains("alpha foo") => { Some(*stream) } _ => None, @@ -3654,7 +3665,7 @@ mod tests { let stream = contents .iter() .find_map(|content| match content { - WorkerContent::ContentText { text, stream } if text.contains("alpha foo") => { + WorkerContent::ContentText { text, stream, .. } if text.contains("alpha foo") => { Some(*stream) } _ => None, @@ -3705,7 +3716,7 @@ mod tests { assert!( contents.iter().any(|content| matches!( content, - WorkerContent::ContentText { text, stream } + WorkerContent::ContentText { text, stream, .. } if matches!(stream, TextStream::Stdout) && text.contains("alpha ") )), "expected compact search card to keep the stdout prefix segment, got: {:?}", @@ -3714,7 +3725,7 @@ mod tests { assert!( contents.iter().any(|content| matches!( content, - WorkerContent::ContentText { text, stream } + WorkerContent::ContentText { text, stream, .. } if matches!(stream, TextStream::Stderr) && text.contains("foo") )), "expected compact search card to keep the stderr match segment, got: {:?}", @@ -3723,7 +3734,7 @@ mod tests { assert!( contents.iter().any(|content| matches!( content, - WorkerContent::ContentText { text, stream } + WorkerContent::ContentText { text, stream, .. } if matches!(stream, TextStream::Stdout) && text.contains(" omega") )), "expected compact search card to keep the stdout suffix segment, got: {:?}", diff --git a/src/pager/presentation.rs b/src/pager/presentation.rs index 3f8608c..037588a 100644 --- a/src/pager/presentation.rs +++ b/src/pager/presentation.rs @@ -76,7 +76,7 @@ pub(super) fn position_marker( pub(super) fn elision_marker(start: u64, end: u64) -> WorkerContent { // Always include the range: without it, multiple elisions in one page are ambiguous for the // MCP consumer to stitch back into a consistent view of the underlying output. - WorkerContent::stderr(format!( + WorkerContent::server_stderr(format!( "[pager] elided output (already shown): @{start}..{end}\n" )) } diff --git a/src/pager/search.rs b/src/pager/search.rs index 000ae26..fd0efa7 100644 --- a/src/pager/search.rs +++ b/src/pager/search.rs @@ -1,4 +1,4 @@ -use crate::worker_protocol::{TextStream, WorkerContent}; +use crate::worker_protocol::{ContentOrigin, TextStream, WorkerContent}; use super::{ MATCH_BREADCRUMB_MAX_BYTES, MATCH_LINE_MAX_BYTES, MAX_MATCH_LIMIT, MatchSpec, PagerBuffer, @@ -387,22 +387,30 @@ struct SnippetWindow { has_suffix_ellipsis: bool, } -fn same_stream(left: TextStream, right: TextStream) -> bool { - matches!( - (left, right), - (TextStream::Stdout, TextStream::Stdout) | (TextStream::Stderr, TextStream::Stderr) - ) +fn same_text_content( + left_stream: TextStream, + left_origin: ContentOrigin, + right_stream: TextStream, + right_origin: ContentOrigin, +) -> bool { + left_stream == right_stream && left_origin == right_origin } -fn append_text_content(contents: &mut Vec<WorkerContent>, text: &str, stream: TextStream) { +fn append_text_content( + contents: &mut Vec<WorkerContent>, + text: &str, + stream: TextStream, + origin: ContentOrigin, +) { if text.is_empty() { return; } if let Some(WorkerContent::ContentText { text: last_text, stream: last_stream, + origin: last_origin, }) = contents.last_mut() - && same_stream(*last_stream, stream) + && same_text_content(*last_stream, *last_origin, stream, origin) { last_text.push_str(text); return; @@ -410,18 +418,25 @@ fn append_text_content(contents: &mut Vec<WorkerContent>, text: &str, stream: Te contents.push(WorkerContent::ContentText { text: text.to_string(), stream, + origin, }); } -fn prepend_text_content(contents: &mut Vec<WorkerContent>, text: &str, stream: TextStream) { +fn prepend_text_content( + contents: &mut Vec<WorkerContent>, + text: &str, + stream: TextStream, + origin: ContentOrigin, +) { if text.is_empty() { return; } if let Some(WorkerContent::ContentText { text: first_text, stream: first_stream, + origin: first_origin, }) = contents.first_mut() - && same_stream(*first_stream, stream) + && same_text_content(*first_stream, *first_origin, stream, origin) { let mut combined = String::with_capacity(text.len() + first_text.len()); combined.push_str(text); @@ -434,20 +449,21 @@ fn prepend_text_content(contents: &mut Vec<WorkerContent>, text: &str, stream: T WorkerContent::ContentText { text: text.to_string(), stream, + origin, }, ); } -fn first_text_stream(contents: &[WorkerContent]) -> Option<TextStream> { +fn first_text_style(contents: &[WorkerContent]) -> Option<(TextStream, ContentOrigin)> { contents.iter().find_map(|content| match content { - WorkerContent::ContentText { stream, .. } => Some(*stream), + WorkerContent::ContentText { stream, origin, .. } => Some((*stream, *origin)), WorkerContent::ContentImage { .. } => None, }) } -fn last_text_stream(contents: &[WorkerContent]) -> Option<TextStream> { +fn last_text_style(contents: &[WorkerContent]) -> Option<(TextStream, ContentOrigin)> { contents.iter().rev().find_map(|content| match content { - WorkerContent::ContentText { stream, .. } => Some(*stream), + WorkerContent::ContentText { stream, origin, .. } => Some((*stream, *origin)), WorkerContent::ContentImage { .. } => None, }) } @@ -469,6 +485,7 @@ fn push_line_segment( segment_start: u64, segment_end: u64, stream: TextStream, + origin: ContentOrigin, ) { if segment_start >= segment_end { return; @@ -480,7 +497,7 @@ fn push_line_segment( if start_byte >= end_byte { return; } - append_text_content(contents, &line[start_byte..end_byte], stream); + append_text_content(contents, &line[start_byte..end_byte], stream, origin); } fn render_match_snippet_contents( @@ -493,6 +510,7 @@ fn render_match_snippet_contents( ) -> Vec<WorkerContent> { let trimmed = line.trim_end(); let snippet_stream = decoded_match_stream(buffer, match_start, line, default_stream); + let snippet_origin = ContentOrigin::Worker; let snippet_start_chars = trimmed[..window.start_byte].chars().count() as u64; let snippet_end_chars = trimmed[..window.end_byte].chars().count() as u64; let snippet_start = line_start.saturating_add(snippet_start_chars); @@ -513,6 +531,7 @@ fn render_match_snippet_contents( cursor, segment_start, snippet_stream, + snippet_origin, ); } let segment_end = span.end.min(snippet_end); @@ -527,6 +546,7 @@ fn render_match_snippet_contents( } else { TextStream::Stdout }, + snippet_origin, ); cursor = segment_end; } @@ -539,6 +559,7 @@ fn render_match_snippet_contents( cursor, snippet_end, snippet_stream, + snippet_origin, ); } @@ -547,6 +568,7 @@ fn render_match_snippet_contents( &mut contents, &trimmed[window.start_byte..window.end_byte], snippet_stream, + snippet_origin, ); } @@ -555,16 +577,18 @@ fn render_match_snippet_contents( if window.has_prefix_ellipsis { prefix.push_str("..."); } - let prefix_stream = first_text_stream(&contents).unwrap_or(snippet_stream); - prepend_text_content(&mut contents, &prefix, prefix_stream); + let (prefix_stream, prefix_origin) = + first_text_style(&contents).unwrap_or((snippet_stream, snippet_origin)); + prepend_text_content(&mut contents, &prefix, prefix_stream, prefix_origin); let suffix = if window.has_suffix_ellipsis { "...\n" } else { "\n" }; - let suffix_stream = last_text_stream(&contents).unwrap_or(snippet_stream); - append_text_content(&mut contents, suffix, suffix_stream); + let (suffix_stream, suffix_origin) = + last_text_style(&contents).unwrap_or((snippet_stream, snippet_origin)); + append_text_content(&mut contents, suffix, suffix_stream, suffix_origin); contents } @@ -755,7 +779,7 @@ pub(super) fn take_matches( ) -> (Vec<WorkerContent>, RangeSpan, Vec<(u64, u64)>) { if session.hits.is_empty() { return ( - vec![WorkerContent::stderr(pattern_not_found_message( + vec![WorkerContent::server_stderr(pattern_not_found_message( &session.pattern.pattern, buffer.current_offset(), ))], @@ -816,7 +840,7 @@ pub(super) fn take_matches( ( vec![ - WorkerContent::stderr(match_header( + WorkerContent::server_stderr(match_header( session.hits.len().min(spec.limit), spec.limit, more_available, @@ -1160,7 +1184,7 @@ pub(super) fn render_search_card( ) -> (Vec<WorkerContent>, Option<(u64, u64)>, Option<u64>) { let Some(hit) = session.hits.get(session.current_index) else { return ( - vec![WorkerContent::stderr(pattern_not_found_message( + vec![WorkerContent::server_stderr(pattern_not_found_message( &session.pattern.pattern, buffer.current_offset(), ))], @@ -1190,15 +1214,18 @@ pub(super) fn render_search_card( session.pattern.pattern, hit.match_start ) }; - let mut contents = vec![WorkerContent::stderr(header)]; + let mut contents = vec![WorkerContent::server_stderr(header)]; if let Some(message) = prior_view_message(view_history, hit.match_start) { - contents.push(WorkerContent::stderr(message)); + contents.push(WorkerContent::server_stderr(message)); } let line = read_line_text(buffer, hit.line_idx); let match_start_in_line = decoded_match_start_in_line(buffer, hit.line_start, hit.match_start); if hit.breadcrumb != "root" { - contents.push(WorkerContent::stderr(format!("{}\n", hit.breadcrumb))); + contents.push(WorkerContent::server_stderr(format!( + "{}\n", + hit.breadcrumb + ))); } let window = snippet_window_around_match(&line, match_start_in_line, &session.pattern.pattern); contents.extend(render_match_snippet_contents( @@ -1324,7 +1351,7 @@ pub(super) fn take_hits_next( let pages_left_now = pages_left_for_buffer(buffer, page_bytes); if output.is_empty() { return ( - vec![WorkerContent::stderr(all_matches_shown_message( + vec![WorkerContent::server_stderr(all_matches_shown_message( &hit_state.pattern.pattern, ))], pages_left_now, @@ -1344,7 +1371,7 @@ pub(super) fn take_hits_next( if output.is_empty() { let start_offset = buffer.current_offset(); return ( - vec![WorkerContent::stderr(pattern_not_found_message( + vec![WorkerContent::server_stderr(pattern_not_found_message( &hit_state.pattern.pattern, start_offset, ))], diff --git a/src/pending_output_tape.rs b/src/pending_output_tape.rs new file mode 100644 index 0000000..5a2fef3 --- /dev/null +++ b/src/pending_output_tape.rs @@ -0,0 +1,1142 @@ +use std::collections::VecDeque; +use std::fmt::Write as _; +use std::sync::{Arc, Mutex}; + +use crate::worker_protocol::{ContentOrigin, TextStream, WorkerContent}; + +#[derive(Clone, Default)] +pub(crate) struct PendingOutputTape { + inner: Arc<Mutex<PendingOutputTapeInner>>, +} + +#[derive(Default)] +struct PendingOutputTapeInner { + next_seq: u64, + progress_seq: u64, + events: VecDeque<PendingOutputEvent>, + stdout_tail: PendingTextTail, + stderr_tail: PendingTextTail, + pending_echo_prefix: String, + last_rendered_text: Option<RenderedTextState>, +} + +#[derive(Default)] +struct PendingTextTail { + bytes: Vec<u8>, + origin: Option<ContentOrigin>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum PendingOutputEvent { + TextFragment { + seq: u64, + stream: TextStream, + origin: ContentOrigin, + bytes: Vec<u8>, + terminated: bool, + }, + Image { + seq: u64, + data: String, + mime_type: String, + id: String, + is_new: bool, + }, + Sideband { + seq: u64, + kind: PendingSidebandKind, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum PendingSidebandKind { + ReadlineStart { prompt: String }, + ReadlineResult { prompt: String, line: String }, + RequestEnd, + SessionEnd, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct PendingOutputSnapshot { + pub events: Vec<PendingOutputEvent>, + leading_echo_prefix: Option<String>, + prior_rendered_text: Option<RenderedTextState>, +} + +#[derive(Debug, Default, PartialEq, Eq)] +pub(crate) struct FormattedPendingOutput { + pub contents: Vec<WorkerContent>, + pub saw_stderr: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct RenderedTextState { + stream: TextStream, + origin: ContentOrigin, + terminated: bool, +} + +impl PendingOutputTape { + pub(crate) fn new() -> Self { + Self::default() + } + + pub(crate) fn append_stdout_bytes(&self, bytes: &[u8]) { + self.append_bytes(bytes, TextStream::Stdout, ContentOrigin::Worker); + } + + pub(crate) fn append_stderr_bytes(&self, bytes: &[u8]) { + self.append_bytes(bytes, TextStream::Stderr, ContentOrigin::Worker); + } + + pub(crate) fn append_server_stderr_bytes(&self, bytes: &[u8]) { + self.append_bytes(bytes, TextStream::Stderr, ContentOrigin::Server); + } + + pub(crate) fn append_stdout_status_line(&self, bytes: &[u8]) { + if bytes.is_empty() { + return; + } + let mut guard = self + .inner + .lock() + .expect("pending output tape mutex poisoned"); + note_progress(&mut guard); + flush_tail(&mut guard, TextStream::Stdout, true); + flush_tail(&mut guard, TextStream::Stderr, true); + let needs_separator = last_text_fragment_bytes(&guard.events) + .is_some_and(|last| !last.ends_with(b"\n")) + && !bytes.starts_with(b"\n"); + let mut status_line = Vec::with_capacity(bytes.len() + usize::from(needs_separator)); + if needs_separator { + status_line.push(b'\n'); + } + status_line.extend_from_slice(bytes); + append_complete_bytes( + &mut guard, + TextStream::Stdout, + ContentOrigin::Server, + &status_line, + ); + } + + pub(crate) fn append_image(&self, id: String, mime_type: String, data: String, is_new: bool) { + let mut guard = self + .inner + .lock() + .expect("pending output tape mutex poisoned"); + note_progress(&mut guard); + flush_tail(&mut guard, TextStream::Stdout, true); + flush_tail(&mut guard, TextStream::Stderr, true); + let seq = next_seq(&mut guard); + guard.events.push_back(PendingOutputEvent::Image { + seq, + data, + mime_type, + id, + is_new, + }); + } + + pub(crate) fn append_sideband(&self, kind: PendingSidebandKind) { + let mut guard = self + .inner + .lock() + .expect("pending output tape mutex poisoned"); + note_progress(&mut guard); + flush_tail(&mut guard, TextStream::Stdout, false); + flush_tail(&mut guard, TextStream::Stderr, false); + let seq = next_seq(&mut guard); + guard + .events + .push_back(PendingOutputEvent::Sideband { seq, kind }); + } + + pub(crate) fn has_pending(&self) -> bool { + let guard = self + .inner + .lock() + .expect("pending output tape mutex poisoned"); + guard.events.iter().any(|event| { + matches!( + event, + PendingOutputEvent::TextFragment { .. } | PendingOutputEvent::Image { .. } + ) + }) || tail_has_flushable_bytes(&guard.stdout_tail) + || tail_has_flushable_bytes(&guard.stderr_tail) + } + + pub(crate) fn clear(&self) { + let mut guard = self + .inner + .lock() + .expect("pending output tape mutex poisoned"); + *guard = PendingOutputTapeInner::default(); + } + + pub(crate) fn current_seq(&self) -> u64 { + let guard = self + .inner + .lock() + .expect("pending output tape mutex poisoned"); + guard.progress_seq + } + + pub(crate) fn drain_snapshot(&self) -> PendingOutputSnapshot { + self.drain_snapshot_with_policy(false) + } + + pub(crate) fn drain_final_snapshot(&self) -> PendingOutputSnapshot { + self.drain_snapshot_with_policy(true) + } + + fn drain_snapshot_with_policy(&self, flush_incomplete: bool) -> PendingOutputSnapshot { + let mut guard = self + .inner + .lock() + .expect("pending output tape mutex poisoned"); + flush_tail(&mut guard, TextStream::Stdout, flush_incomplete); + flush_tail(&mut guard, TextStream::Stderr, flush_incomplete); + let prior_rendered_text = guard.last_rendered_text; + let events: Vec<_> = guard.events.drain(..).collect(); + append_readline_results_to_echo_prefix(&mut guard.pending_echo_prefix, &events); + let leading_echo_prefix = + (!guard.pending_echo_prefix.is_empty()).then(|| guard.pending_echo_prefix.clone()); + if let Some(echo_prefix) = leading_echo_prefix.as_deref() { + let (matched_bytes, keep_remaining_suffix) = + leading_echo_match_progress(&events, echo_prefix); + if keep_remaining_suffix + && !(snapshot_has_no_visible_text(&events) + && snapshot_crossed_request_boundary(&events)) + { + guard.pending_echo_prefix = echo_prefix[matched_bytes..].to_string(); + } else { + guard.pending_echo_prefix.clear(); + } + } + guard.last_rendered_text = rendered_text_state_after(events.iter(), prior_rendered_text); + PendingOutputSnapshot { + events, + leading_echo_prefix, + prior_rendered_text, + } + } + + fn append_bytes(&self, bytes: &[u8], stream: TextStream, origin: ContentOrigin) { + if bytes.is_empty() { + return; + } + let mut guard = self + .inner + .lock() + .expect("pending output tape mutex poisoned"); + note_progress(&mut guard); + flush_tail(&mut guard, other_stream(stream), false); + if tail_mut(&mut guard, stream) + .origin + .is_some_and(|tail_origin| tail_origin != origin) + { + flush_tail(&mut guard, stream, true); + } + let tail = tail_mut(&mut guard, stream); + if tail.origin.is_none() { + tail.origin = Some(origin); + } + tail.bytes.extend_from_slice(bytes); + commit_complete_lines(&mut guard, stream); + } +} + +impl PendingOutputSnapshot { + pub(crate) fn format_contents(&self) -> FormattedPendingOutput { + let mut formatted = FormattedPendingOutput::default(); + let mut last_rendered_text = self.prior_rendered_text; + for event in &self.events { + match event { + PendingOutputEvent::TextFragment { + stream, + origin, + bytes, + terminated, + .. + } => { + if bytes.is_empty() { + continue; + } + if matches!(stream, TextStream::Stderr) { + formatted.saw_stderr = true; + } + let rendered = render_bytes(bytes); + if rendered.is_empty() { + continue; + } + let text = if matches!(stream, TextStream::Stderr) { + render_stderr_text(last_rendered_text, *origin, rendered) + } else { + rendered + }; + push_text(&mut formatted.contents, *stream, *origin, text); + last_rendered_text = Some(RenderedTextState { + stream: *stream, + origin: *origin, + terminated: *terminated, + }); + } + PendingOutputEvent::Image { + data, + mime_type, + id, + is_new, + .. + } => { + formatted.contents.push(WorkerContent::ContentImage { + data: data.clone(), + mime_type: mime_type.clone(), + id: id.clone(), + is_new: *is_new, + }); + last_rendered_text = None; + } + PendingOutputEvent::Sideband { .. } => {} + } + } + maybe_trim_leading_echo_prefix( + self.leading_echo_prefix.as_deref(), + &mut formatted.contents, + ); + formatted + } +} + +fn maybe_trim_leading_echo_prefix(echo_prefix: Option<&str>, contents: &mut Vec<WorkerContent>) { + let Some(echo_prefix) = echo_prefix else { + return; + }; + trim_matching_echo_prefix_from_contents(contents, echo_prefix); +} + +fn append_readline_results_to_echo_prefix(echo_prefix: &mut String, events: &[PendingOutputEvent]) { + for event in events { + if let PendingOutputEvent::Sideband { + kind: PendingSidebandKind::ReadlineResult { prompt, line }, + .. + } = event + && is_trim_eligible_readline_prompt(prompt) + { + echo_prefix.push_str(prompt); + echo_prefix.push_str(line); + } + } +} + +fn snapshot_has_no_visible_text(events: &[PendingOutputEvent]) -> bool { + events + .iter() + .all(|event| !matches!(event, PendingOutputEvent::TextFragment { bytes, .. } if !render_bytes(bytes).is_empty())) +} + +fn snapshot_crossed_request_boundary(events: &[PendingOutputEvent]) -> bool { + events.iter().any(|event| { + matches!( + event, + PendingOutputEvent::Sideband { + kind: PendingSidebandKind::RequestEnd | PendingSidebandKind::SessionEnd, + .. + } + ) + }) +} + +fn is_trim_eligible_readline_prompt(prompt: &str) -> bool { + matches!( + prompt.trim_end_matches(|ch: char| ch.is_whitespace()), + ">" | "+" | ">>>" | "..." + ) +} + +fn leading_echo_match_progress(events: &[PendingOutputEvent], echo_prefix: &str) -> (usize, bool) { + if echo_prefix.is_empty() { + return (0, false); + } + + let mut remaining = echo_prefix; + let mut matched_bytes = 0usize; + let mut saw_visible_content = false; + + for event in events { + let PendingOutputEvent::TextFragment { + stream, + origin, + bytes, + .. + } = event + else { + if matches!(event, PendingOutputEvent::Sideband { .. }) { + continue; + } + return (matched_bytes, false); + }; + + if !matches!(stream, TextStream::Stdout) || !matches!(origin, ContentOrigin::Worker) { + return (matched_bytes, false); + } + + let rendered = render_bytes(bytes); + if rendered.is_empty() { + continue; + } + + saw_visible_content = true; + if remaining.is_empty() { + return (matched_bytes, false); + } + + let common = common_prefix_len(remaining, &rendered); + matched_bytes = matched_bytes.saturating_add(common); + remaining = &remaining[common..]; + + if common < rendered.len() { + return (matched_bytes, false); + } + } + + if !saw_visible_content { + return (matched_bytes, true); + } + + (matched_bytes, !remaining.is_empty()) +} + +fn trim_matching_echo_prefix_from_contents(contents: &mut Vec<WorkerContent>, echo_prefix: &str) { + if echo_prefix.is_empty() { + return; + } + + let mut remaining = echo_prefix; + let mut matched_bytes = 0usize; + for content in contents.iter() { + let WorkerContent::ContentText { + text, + stream, + origin, + } = content + else { + break; + }; + if !matches!(stream, TextStream::Stdout) || !matches!(origin, ContentOrigin::Worker) { + break; + } + let common = common_prefix_len(remaining, text); + matched_bytes = matched_bytes.saturating_add(common); + remaining = &remaining[common..]; + if common < text.len() || remaining.is_empty() { + break; + } + } + + if matched_bytes == 0 { + return; + } + + let mut remaining = &echo_prefix[..matched_bytes]; + let mut idx = 0usize; + while idx < contents.len() && !remaining.is_empty() { + let remove_current = match &mut contents[idx] { + WorkerContent::ContentText { text, .. } => { + if remaining.len() >= text.len() { + remaining = &remaining[text.len()..]; + text.clear(); + true + } else { + let updated = text[remaining.len()..].to_string(); + *text = updated; + remaining = ""; + false + } + } + _ => return, + }; + + if remove_current { + contents.remove(idx); + continue; + } + idx = idx.saturating_add(1); + } +} + +fn common_prefix_len(left: &str, right: &str) -> usize { + let mut matched = 0usize; + for (lch, rch) in left.chars().zip(right.chars()) { + if lch != rch { + break; + } + matched = matched.saturating_add(lch.len_utf8()); + } + matched +} + +fn push_text( + contents: &mut Vec<WorkerContent>, + stream: TextStream, + origin: ContentOrigin, + text: String, +) { + if text.is_empty() { + return; + } + if let Some(WorkerContent::ContentText { + text: existing, + stream: existing_stream, + origin: existing_origin, + }) = contents.last_mut() + && *existing_stream == stream + && *existing_origin == origin + { + existing.push_str(&text); + return; + } + contents.push(WorkerContent::ContentText { + text, + stream, + origin, + }); +} + +fn render_bytes(bytes: &[u8]) -> String { + let mut out = String::new(); + let mut remaining = bytes; + while !remaining.is_empty() { + match std::str::from_utf8(remaining) { + Ok(valid) => { + out.push_str(valid); + break; + } + Err(err) => { + let valid_up_to = err.valid_up_to(); + if valid_up_to > 0 { + out.push_str( + std::str::from_utf8(&remaining[..valid_up_to]).expect("valid utf-8 prefix"), + ); + } + let invalid_start = valid_up_to; + let invalid_end = match err.error_len() { + Some(len) => invalid_start.saturating_add(len), + None => remaining.len(), + }; + for byte in &remaining[invalid_start..invalid_end] { + let _ = write!(&mut out, "\\x{byte:02X}"); + } + remaining = &remaining[invalid_end..]; + } + } + } + out +} + +fn next_seq(inner: &mut PendingOutputTapeInner) -> u64 { + let seq = inner.next_seq; + inner.next_seq = inner.next_seq.saturating_add(1); + seq +} + +fn note_progress(inner: &mut PendingOutputTapeInner) { + inner.progress_seq = inner.progress_seq.saturating_add(1); +} + +fn render_stderr_text( + previous_text: Option<RenderedTextState>, + origin: ContentOrigin, + rendered: String, +) -> String { + if previous_text.is_some_and(|state| { + matches!(state.stream, TextStream::Stderr) && state.origin == origin && !state.terminated + }) { + return rendered; + } + let needs_separator = + previous_text.is_some_and(|state| !state.terminated) && !rendered.starts_with('\n'); + if needs_separator { + format!("\nstderr: {rendered}") + } else { + format!("stderr: {rendered}") + } +} + +fn other_stream(stream: TextStream) -> TextStream { + match stream { + TextStream::Stdout => TextStream::Stderr, + TextStream::Stderr => TextStream::Stdout, + } +} + +fn tail_mut(inner: &mut PendingOutputTapeInner, stream: TextStream) -> &mut PendingTextTail { + match stream { + TextStream::Stdout => &mut inner.stdout_tail, + TextStream::Stderr => &mut inner.stderr_tail, + } +} + +fn append_complete_bytes( + inner: &mut PendingOutputTapeInner, + stream: TextStream, + origin: ContentOrigin, + bytes: &[u8], +) { + if bytes.is_empty() { + return; + } + let seq = next_seq(inner); + inner.events.push_back(PendingOutputEvent::TextFragment { + seq, + stream, + origin, + bytes: bytes.to_vec(), + terminated: bytes.ends_with(b"\n"), + }); +} + +fn commit_complete_lines(inner: &mut PendingOutputTapeInner, stream: TextStream) { + loop { + let (origin, line, tail_empty) = { + let tail = tail_mut(inner, stream); + let Some(newline_idx) = tail.bytes.iter().position(|byte| *byte == b'\n') else { + break; + }; + let origin = tail + .origin + .expect("text tail should record origin while bytes are buffered"); + let line = tail.bytes.drain(..=newline_idx).collect::<Vec<u8>>(); + let tail_empty = tail.bytes.is_empty(); + if tail_empty { + tail.origin = None; + } + (origin, line, tail_empty) + }; + let seq = next_seq(inner); + inner.events.push_back(PendingOutputEvent::TextFragment { + seq, + stream, + origin, + bytes: line, + terminated: true, + }); + if tail_empty { + break; + } + } +} + +fn flush_tail(inner: &mut PendingOutputTapeInner, stream: TextStream, flush_incomplete: bool) { + let (origin, bytes) = { + let tail = tail_mut(inner, stream); + if tail.bytes.is_empty() { + return; + } + let mut flush_len = flushable_prefix_len(&tail.bytes); + if flush_incomplete && flush_len == 0 { + flush_len = tail.bytes.len(); + } + if flush_len == 0 { + return; + } + let origin = tail + .origin + .expect("text tail should record origin while bytes are buffered"); + let bytes = tail.bytes.drain(..flush_len).collect::<Vec<u8>>(); + if tail.bytes.is_empty() { + tail.origin = None; + } + (origin, bytes) + }; + let seq = next_seq(inner); + inner.events.push_back(PendingOutputEvent::TextFragment { + seq, + stream, + origin, + bytes, + terminated: false, + }); +} + +fn last_text_fragment_bytes(events: &VecDeque<PendingOutputEvent>) -> Option<&[u8]> { + match events.back() { + Some(PendingOutputEvent::TextFragment { bytes, .. }) => Some(bytes.as_slice()), + Some(PendingOutputEvent::Image { .. } | PendingOutputEvent::Sideband { .. }) | None => None, + } +} + +fn rendered_text_state_after<'a>( + events: impl Iterator<Item = &'a PendingOutputEvent>, + mut state: Option<RenderedTextState>, +) -> Option<RenderedTextState> { + for event in events { + match event { + PendingOutputEvent::TextFragment { + stream, + origin, + bytes, + terminated, + .. + } => { + if !bytes.is_empty() { + state = Some(RenderedTextState { + stream: *stream, + origin: *origin, + terminated: *terminated, + }); + } + } + PendingOutputEvent::Image { .. } => state = None, + PendingOutputEvent::Sideband { .. } => {} + } + } + state +} + +fn tail_has_flushable_bytes(tail: &PendingTextTail) -> bool { + flushable_prefix_len(&tail.bytes) > 0 +} + +fn flushable_prefix_len(bytes: &[u8]) -> usize { + let mut offset: usize = 0; + let mut remaining = bytes; + while !remaining.is_empty() { + match std::str::from_utf8(remaining) { + Ok(_) => return bytes.len(), + Err(err) => { + let valid_up_to = err.valid_up_to(); + if let Some(error_len) = err.error_len() { + let invalid_end = valid_up_to.saturating_add(error_len); + offset = offset.saturating_add(invalid_end); + remaining = &remaining[invalid_end..]; + } else { + return offset.saturating_add(valid_up_to); + } + } + } + } + bytes.len() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn interleaved_streams_flush_partial_fragments() { + let tape = PendingOutputTape::new(); + tape.append_stdout_bytes(b"abc"); + tape.append_stderr_bytes(b"boom\n"); + + let snapshot = tape.drain_snapshot(); + assert_eq!( + snapshot.events, + vec![ + PendingOutputEvent::TextFragment { + seq: 0, + stream: TextStream::Stdout, + origin: ContentOrigin::Worker, + bytes: b"abc".to_vec(), + terminated: false, + }, + PendingOutputEvent::TextFragment { + seq: 1, + stream: TextStream::Stderr, + origin: ContentOrigin::Worker, + bytes: b"boom\n".to_vec(), + terminated: true, + }, + ] + ); + } + + #[test] + fn sideband_events_preserve_order_with_text() { + let tape = PendingOutputTape::new(); + tape.append_stdout_bytes(b"> 1+\n"); + tape.append_sideband(PendingSidebandKind::ReadlineResult { + prompt: "> ".to_string(), + line: "1+\n".to_string(), + }); + tape.append_stdout_bytes(b"[1] 2\n"); + + let snapshot = tape.drain_snapshot(); + assert!(matches!( + snapshot.events[1], + PendingOutputEvent::Sideband { + kind: PendingSidebandKind::ReadlineResult { .. }, + .. + } + )); + } + + #[test] + fn invalid_utf8_bytes_render_as_hex_escapes() { + let tape = PendingOutputTape::new(); + tape.append_stdout_bytes(b"ok \xFF\xFE done\n"); + + let snapshot = tape.drain_snapshot(); + let formatted = snapshot.format_contents(); + assert_eq!( + formatted.contents, + vec![WorkerContent::stdout("ok \\xFF\\xFE done\n")] + ); + } + + #[test] + fn progress_seq_tracks_partial_line_appends() { + let tape = PendingOutputTape::new(); + tape.append_stdout_bytes(b"abc"); + let first = tape.current_seq(); + tape.append_stdout_bytes(b"def"); + let second = tape.current_seq(); + + assert!( + second > first, + "progress counter should advance on tail-only appends" + ); + } + + #[test] + fn stderr_after_partial_stdout_starts_on_new_line() { + let tape = PendingOutputTape::new(); + tape.append_stdout_bytes(b"x"); + tape.append_stderr_bytes(b"boom\n"); + + let snapshot = tape.drain_snapshot(); + let formatted = snapshot.format_contents(); + assert_eq!( + formatted.contents, + vec![ + WorkerContent::stdout("x"), + WorkerContent::stderr("\nstderr: boom\n") + ] + ); + } + + #[test] + fn clean_session_end_notice_starts_after_partial_stdout() { + let tape = PendingOutputTape::new(); + tape.append_stdout_bytes(b"x"); + tape.append_stdout_status_line(b"[repl] session ended\n"); + + let snapshot = tape.drain_snapshot(); + let formatted = snapshot.format_contents(); + assert_eq!( + formatted.contents, + vec![ + WorkerContent::ContentText { + text: "x".to_string(), + stream: TextStream::Stdout, + origin: ContentOrigin::Worker, + }, + WorkerContent::ContentText { + text: "\n[repl] session ended\n".to_string(), + stream: TextStream::Stdout, + origin: ContentOrigin::Server, + }, + ] + ); + } + + #[test] + fn server_stderr_notice_preserves_server_origin() { + let tape = PendingOutputTape::new(); + tape.append_server_stderr_bytes(b"[repl] guardrail\n"); + + let snapshot = tape.drain_snapshot(); + let formatted = snapshot.format_contents(); + assert_eq!( + formatted.contents, + vec![WorkerContent::ContentText { + text: "stderr: [repl] guardrail\n".to_string(), + stream: TextStream::Stderr, + origin: ContentOrigin::Server, + }] + ); + } + + #[test] + fn buffered_server_stderr_tail_preserves_server_origin_when_flushed() { + let tape = PendingOutputTape::new(); + tape.append_server_stderr_bytes(b"[repl] guardrail"); + tape.append_stdout_bytes(b"ok\n"); + + let snapshot = tape.drain_snapshot(); + assert_eq!( + snapshot.events, + vec![ + PendingOutputEvent::TextFragment { + seq: 0, + stream: TextStream::Stderr, + origin: ContentOrigin::Server, + bytes: b"[repl] guardrail".to_vec(), + terminated: false, + }, + PendingOutputEvent::TextFragment { + seq: 1, + stream: TextStream::Stdout, + origin: ContentOrigin::Worker, + bytes: b"ok\n".to_vec(), + terminated: true, + }, + ] + ); + } + + #[test] + fn split_utf8_sequence_is_preserved_across_snapshot_drains() { + let tape = PendingOutputTape::new(); + + tape.append_stdout_bytes(&[0xC3]); + let first = tape.drain_snapshot(); + assert!( + first.format_contents().contents.is_empty(), + "incomplete utf-8 prefix should stay buffered across drain boundaries" + ); + + tape.append_stdout_bytes(&[0xA9, b'\n']); + let second = tape.drain_snapshot(); + assert_eq!( + second.format_contents().contents, + vec![WorkerContent::stdout("é\n")] + ); + } + + #[test] + fn split_utf8_sequence_is_preserved_across_sideband_events() { + let tape = PendingOutputTape::new(); + + tape.append_stdout_bytes(&[0xC3]); + tape.append_sideband(PendingSidebandKind::RequestEnd); + let first = tape.drain_snapshot(); + assert!( + first.format_contents().contents.is_empty(), + "incomplete utf-8 prefix should stay buffered across invisible sideband events" + ); + + tape.append_stdout_bytes(&[0xA9, b'\n']); + let second = tape.drain_snapshot(); + assert_eq!( + second.format_contents().contents, + vec![WorkerContent::stdout("é\n")] + ); + } + + #[test] + fn readline_result_prefix_carries_across_snapshot_drains_until_echo_arrives() { + let tape = PendingOutputTape::new(); + + tape.append_sideband(PendingSidebandKind::ReadlineResult { + prompt: "> ".to_string(), + line: "1+\n".to_string(), + }); + let first = tape.drain_snapshot(); + assert!( + first.format_contents().contents.is_empty(), + "sideband-only snapshot should not render visible content" + ); + + tape.append_stdout_bytes(b"> 1"); + let second = tape.drain_snapshot(); + assert!( + second.format_contents().contents.is_empty(), + "partial echoed prefix should stay hidden until the remainder arrives" + ); + + tape.append_stdout_bytes(b"+\n[1] 2\n"); + let third = tape.drain_snapshot(); + assert_eq!( + third.format_contents().contents, + vec![WorkerContent::stdout("[1] 2\n")] + ); + } + + #[test] + fn request_end_clears_pending_echo_prefix_after_sideband_only_snapshot() { + let tape = PendingOutputTape::new(); + + tape.append_sideband(PendingSidebandKind::ReadlineResult { + prompt: "> ".to_string(), + line: "x <- 1\n".to_string(), + }); + tape.append_sideband(PendingSidebandKind::RequestEnd); + + let first = tape.drain_snapshot(); + assert!( + first.format_contents().contents.is_empty(), + "sideband-only snapshot should not render visible content" + ); + + let guard = tape + .inner + .lock() + .expect("pending output tape mutex poisoned"); + assert!( + guard.pending_echo_prefix.is_empty(), + "request boundary should clear unmatched carried echo" + ); + } + + #[test] + fn interleaved_output_drops_unmatched_echo_suffix_from_later_drains() { + let tape = PendingOutputTape::new(); + + tape.append_sideband(PendingSidebandKind::ReadlineResult { + prompt: "> ".to_string(), + line: "x <- 1\n".to_string(), + }); + tape.append_sideband(PendingSidebandKind::ReadlineResult { + prompt: "> ".to_string(), + line: "y <- 2\n".to_string(), + }); + let first = tape.drain_snapshot(); + assert!( + first.format_contents().contents.is_empty(), + "sideband-only snapshot should not render visible content" + ); + + tape.append_stdout_bytes(b"> x <- 1\nok\n"); + let second = tape.drain_snapshot(); + assert_eq!( + second.format_contents().contents, + vec![WorkerContent::stdout("ok\n")] + ); + + tape.append_stdout_bytes(b"> y <- 2\n"); + let third = tape.drain_snapshot(); + assert_eq!( + third.format_contents().contents, + vec![WorkerContent::stdout("> y <- 2\n")] + ); + } + + #[test] + fn split_utf8_prefix_flushes_before_image_event() { + let tape = PendingOutputTape::new(); + + tape.append_stdout_bytes(&[0xC3]); + tape.append_image( + "img-1".to_string(), + "image/png".to_string(), + "AA==".to_string(), + true, + ); + tape.append_stdout_bytes(&[0xA9, b'\n']); + + let snapshot = tape.drain_snapshot(); + assert!(matches!( + snapshot.events.as_slice(), + [ + PendingOutputEvent::TextFragment { bytes, .. }, + PendingOutputEvent::Image { .. }, + PendingOutputEvent::TextFragment { bytes: second, .. }, + ] if bytes == &vec![0xC3] && second == &vec![0xA9, b'\n'] + )); + } + + #[test] + fn stderr_continues_partial_line_across_snapshot_drains() { + let tape = PendingOutputTape::new(); + + tape.append_stderr_bytes(b"abc"); + let first = tape.drain_snapshot(); + assert_eq!( + first.format_contents().contents, + vec![WorkerContent::stderr("stderr: abc")] + ); + + tape.append_stderr_bytes(b"def\n"); + let second = tape.drain_snapshot(); + assert_eq!( + second.format_contents().contents, + vec![WorkerContent::stderr("def\n")] + ); + } + + #[test] + fn server_stderr_notice_reprefixes_after_partial_worker_stderr() { + let tape = PendingOutputTape::new(); + + tape.append_stderr_bytes(b"partial"); + tape.append_server_stderr_bytes(b"[repl] session ended\n"); + + let snapshot = tape.drain_snapshot(); + assert_eq!( + snapshot.format_contents().contents, + vec![ + WorkerContent::worker_stderr("stderr: partial"), + WorkerContent::server_stderr("\nstderr: [repl] session ended\n"), + ] + ); + } + + #[test] + fn final_snapshot_flushes_incomplete_utf8_as_hex_escape() { + let tape = PendingOutputTape::new(); + tape.append_stdout_bytes(&[0xC3]); + + let snapshot = tape.drain_final_snapshot(); + assert_eq!( + snapshot.format_contents().contents, + vec![WorkerContent::stdout("\\xC3")] + ); + } + + #[test] + fn status_line_flushes_incomplete_utf8_tail_before_notice() { + let tape = PendingOutputTape::new(); + tape.append_stdout_bytes(&[0xC3]); + tape.append_stdout_status_line(b"[repl] session ended\n"); + + let snapshot = tape.drain_final_snapshot(); + assert_eq!( + snapshot.events, + vec![ + PendingOutputEvent::TextFragment { + seq: 0, + stream: TextStream::Stdout, + origin: ContentOrigin::Worker, + bytes: vec![0xC3], + terminated: false, + }, + PendingOutputEvent::TextFragment { + seq: 1, + stream: TextStream::Stdout, + origin: ContentOrigin::Server, + bytes: b"\n[repl] session ended\n".to_vec(), + terminated: true, + }, + ] + ); + } + + #[test] + fn origin_change_flushes_incomplete_tail_before_appending_new_bytes() { + let tape = PendingOutputTape::new(); + tape.append_server_stderr_bytes(&[0xC3]); + tape.append_stderr_bytes(b"boom\n"); + + let snapshot = tape.drain_snapshot(); + assert_eq!( + snapshot.events, + vec![ + PendingOutputEvent::TextFragment { + seq: 0, + stream: TextStream::Stderr, + origin: ContentOrigin::Server, + bytes: vec![0xC3], + terminated: false, + }, + PendingOutputEvent::TextFragment { + seq: 1, + stream: TextStream::Stderr, + origin: ContentOrigin::Worker, + bytes: b"boom\n".to_vec(), + terminated: true, + }, + ] + ); + } +} diff --git a/src/r_controls.rs b/src/r_controls.rs index 0144ae3..a53fe28 100644 --- a/src/r_controls.rs +++ b/src/r_controls.rs @@ -1,6 +1,7 @@ use harp::Result; use libr::SEXP; +#[cfg_attr(windows, allow(clippy::result_large_err))] #[harp::register] pub extern "C-unwind" fn mcp_repl_clear_pending_input() -> Result<SEXP> { let _ = crate::r_session::clear_pending_input(); diff --git a/src/r_graphics.rs b/src/r_graphics.rs index 29a4c60..02bb3ad 100644 --- a/src/r_graphics.rs +++ b/src/r_graphics.rs @@ -1,6 +1,7 @@ use harp::object::RObject; use libr::SEXP; +#[cfg_attr(windows, allow(clippy::result_large_err))] #[harp::register] pub extern "C-unwind" fn mcp_repl_plot_emit( id: SEXP, diff --git a/src/r_htmd.rs b/src/r_htmd.rs index 60fc112..7fb7099 100644 --- a/src/r_htmd.rs +++ b/src/r_htmd.rs @@ -2,6 +2,7 @@ use harp::object::RObject; use harp::protect::RProtect; use libr::SEXP; +#[cfg_attr(windows, allow(clippy::result_large_err))] #[harp::register] pub extern "C-unwind" fn mcp_repl_htmd_file_to_markdown(path: SEXP) -> harp::Result<SEXP> { let path = String::try_from(RObject::view(path))?; @@ -26,6 +27,7 @@ pub extern "C-unwind" fn mcp_repl_htmd_file_to_markdown(path: SEXP) -> harp::Res } } +#[cfg_attr(windows, allow(clippy::result_large_err))] #[harp::register] pub extern "C-unwind" fn mcp_repl_htmd_html_to_markdown(html: SEXP) -> harp::Result<SEXP> { let html = String::try_from(RObject::view(html))?; diff --git a/src/r_session.rs b/src/r_session.rs index bb41314..e174370 100644 --- a/src/r_session.rs +++ b/src/r_session.rs @@ -36,7 +36,7 @@ use windows_sys::Win32::Globalization::{GetACP, MultiByteToWideChar}; const MCP_REPL_R_SCRIPT: &str = include_str!("../r/mcp_repl.R"); #[derive(Debug)] -pub struct SessionReply; +pub struct RequestCompleted; pub struct RSession { sender: mpsc::Sender<RRequest>, @@ -68,7 +68,7 @@ impl RSession { self.init.wait_ready() } - pub fn send_request(&self, input: String) -> Result<mpsc::Receiver<SessionReply>, String> { + pub fn send_request(&self, input: String) -> Result<mpsc::Receiver<RequestCompleted>, String> { self.wait_until_ready()?; let (reply_tx, reply_rx) = mpsc::channel(); let request = RRequest { @@ -84,7 +84,7 @@ impl RSession { struct RRequest { input: String, - reply: mpsc::Sender<SessionReply>, + reply: mpsc::Sender<RequestCompleted>, } #[derive(Debug)] @@ -243,7 +243,7 @@ struct SessionStateInner { } struct ActiveRequest { - reply: mpsc::Sender<SessionReply>, + reply: mpsc::Sender<RequestCompleted>, plot_hashes: HashMap<String, u64>, } @@ -809,7 +809,10 @@ fn complete_active_request( emit_session_end: bool, ) { if let Some(active) = active { - let _ = active.reply.send(SessionReply); + // Keep the request boundary coupled to the same R-thread decision that + // drained the final queued input line. + ipc::emit_request_end(); + let _ = active.reply.send(RequestCompleted); state.cvar.notify_all(); } if emit_session_end { diff --git a/src/sandbox.rs b/src/sandbox.rs index b1fa73c..9eed70b 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -2295,8 +2295,10 @@ mod tests { #[test] fn prepare_worker_command_sets_allow_local_binding_one_when_enabled() { - let mut state = SandboxState::default(); - state.sandbox_policy = SandboxPolicy::DangerFullAccess; + let mut state = SandboxState { + sandbox_policy: SandboxPolicy::DangerFullAccess, + ..SandboxState::default() + }; state.managed_network_policy.allow_local_binding = true; let prepared = @@ -2314,8 +2316,10 @@ mod tests { #[test] fn prepare_worker_command_sets_allow_local_binding_zero_when_explicitly_disabled() { - let mut state = SandboxState::default(); - state.sandbox_policy = SandboxPolicy::DangerFullAccess; + let mut state = SandboxState { + sandbox_policy: SandboxPolicy::DangerFullAccess, + ..SandboxState::default() + }; state.managed_network_policy.allow_local_binding = false; let prepared = @@ -2333,8 +2337,10 @@ mod tests { #[test] fn prepare_worker_command_clears_managed_domain_env_when_lists_are_empty() { - let mut state = SandboxState::default(); - state.sandbox_policy = SandboxPolicy::DangerFullAccess; + let mut state = SandboxState { + sandbox_policy: SandboxPolicy::DangerFullAccess, + ..SandboxState::default() + }; state.managed_network_policy.allowed_domains = Vec::new(); state.managed_network_policy.denied_domains = Vec::new(); diff --git a/src/server.rs b/src/server.rs index fb92f92..74650d5 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,7 +1,7 @@ use rmcp::handler::server::tool::ToolRouter; use rmcp::handler::server::wrapper::Parameters; use rmcp::model::{ - CallToolResult, Content, CustomNotification, CustomRequest, CustomResult, ErrorCode, + CallToolResult, CustomNotification, CustomRequest, CustomResult, ErrorCode, ErrorData as McpError, JsonObject, ProtocolVersion, ServerCapabilities, ServerInfo, }; use rmcp::{RoleServer, ServerHandler, tool, tool_handler, tool_router}; @@ -13,59 +13,91 @@ use std::sync::{Arc, Mutex}; use std::thread; use std::time::Duration; -mod response; +pub(crate) mod response; #[cfg(test)] mod tests; mod timeouts; -use self::response::{finalize_batch, worker_reply_to_contents}; +use self::response::{ + ResponseState, TimeoutBundleReuse, strip_text_stream_meta, timeout_bundle_reuse_for_input, +}; use self::timeouts::{ SANDBOX_UPDATE_TIMEOUT, apply_safety_margin, apply_tool_call_margin, parse_timeout, }; use crate::backend::Backend; +use crate::oversized_output::OversizedOutputMode; use crate::sandbox::{SANDBOX_STATE_CAPABILITY, SANDBOX_STATE_METHOD, SandboxStateUpdate}; use crate::sandbox_cli::SandboxCliPlan; use crate::worker_process::{WorkerError, WorkerManager}; #[cfg(test)] -fn repl_tool_description_for_backend(backend: Backend) -> &'static str { - match backend { - Backend::R => include_str!("../docs/tool-descriptions/repl_tool_r.md"), - Backend::Python => include_str!("../docs/tool-descriptions/repl_tool_python.md"), +fn repl_tool_description_for_backend( + backend: Backend, + oversized_output: OversizedOutputMode, +) -> &'static str { + match (backend, oversized_output) { + (Backend::R, OversizedOutputMode::Files) => { + include_str!("../docs/tool-descriptions/repl_tool_r.md") + } + (Backend::R, OversizedOutputMode::Pager) => { + include_str!("../docs/tool-descriptions/repl_tool_r_pager.md") + } + (Backend::Python, OversizedOutputMode::Files) => { + include_str!("../docs/tool-descriptions/repl_tool_python.md") + } + (Backend::Python, OversizedOutputMode::Pager) => { + include_str!("../docs/tool-descriptions/repl_tool_python_pager.md") + } } } #[derive(Clone)] struct SharedServer { - worker: Arc<Mutex<WorkerManager>>, + state: Arc<Mutex<ServerState>>, +} + +struct ServerState { + worker: WorkerManager, + response: ResponseState, } impl SharedServer { - fn new(backend: Backend, sandbox_plan: SandboxCliPlan) -> Result<Self, WorkerError> { + fn new( + backend: Backend, + sandbox_plan: SandboxCliPlan, + oversized_output: OversizedOutputMode, + ) -> Result<Self, WorkerError> { Ok(Self { - worker: Arc::new(Mutex::new(WorkerManager::new(backend, sandbox_plan)?)), + state: Arc::new(Mutex::new(ServerState { + worker: WorkerManager::new(backend, sandbox_plan, oversized_output)?, + response: ResponseState::new()?, + })), }) } - fn worker(&self) -> Arc<Mutex<WorkerManager>> { - Arc::clone(&self.worker) + fn state(&self) -> Arc<Mutex<ServerState>> { + Arc::clone(&self.state) } - async fn run_worker<T, F>(&self, f: F) -> Result<T, McpError> + /// Runs a closure with exclusive access to the combined worker/response state. + /// This keeps reply finalization in the same critical section as the worker call it seals. + async fn run_state<T, F>(&self, f: F) -> Result<T, McpError> where - F: FnOnce(&mut WorkerManager) -> T + Send + 'static, + F: FnOnce(&mut ServerState) -> T + Send + 'static, T: Send + 'static, { - let worker = self.worker.clone(); + let state = self.state.clone(); tokio::task::spawn_blocking(move || { - let mut worker = worker.lock().unwrap(); - f(&mut worker) + let mut state = state.lock().unwrap(); + f(&mut state) }) .await .map_err(|err| McpError::internal_error(err.to_string(), None)) } + /// Executes one `repl` call and immediately finalizes the visible reply on the server side. + /// The response layer needs `pending_request` after the worker call to decide transcript reuse. async fn run_write_input( &self, input: String, @@ -73,12 +105,23 @@ impl SharedServer { ) -> Result<CallToolResult, McpError> { let worker_timeout = apply_tool_call_margin(timeout); let server_timeout = apply_safety_margin(timeout); - let result = self - .run_worker(move |worker| { - worker.write_stdin(input, worker_timeout, server_timeout, None, false) - }) - .await?; - worker_result_to_call_tool_result(result) + self.run_state(move |state| { + let timeout_bundle_reuse = timeout_bundle_reuse_for_input(&input); + let result = + state + .worker + .write_stdin(input, worker_timeout, server_timeout, None, false); + let detached_prefix_item_count = state.worker.detached_prefix_item_count(); + let mut result = state.response.finalize_worker_result( + result, + state.worker.pending_request(), + timeout_bundle_reuse, + detached_prefix_item_count, + ); + strip_text_stream_meta(&mut result); + result + }) + .await } async fn on_custom_request(&self, request: CustomRequest) -> Result<CustomResult, McpError> { @@ -113,7 +156,17 @@ impl SharedServer { .unwrap_or_else(|err| json!({"serialize_error": err.to_string()})); let outcome = self - .run_worker(move |worker| worker.update_sandbox_state(update, SANDBOX_UPDATE_TIMEOUT)) + .run_state(move |state| { + let outcome = state + .worker + .update_sandbox_state(update, SANDBOX_UPDATE_TIMEOUT); + if matches!(outcome, Ok(true)) + && let Err(err) = state.response.clear_active_timeout_bundle() + { + return Err(err); + } + outcome + }) .await?; match outcome { Ok(changed) => { @@ -180,7 +233,17 @@ impl SharedServer { .unwrap_or_else(|err| json!({"serialize_error": err.to_string()})); match self - .run_worker(move |worker| worker.update_sandbox_state(update, SANDBOX_UPDATE_TIMEOUT)) + .run_state(move |state| { + let outcome = state + .worker + .update_sandbox_state(update, SANDBOX_UPDATE_TIMEOUT); + if matches!(outcome, Ok(true)) + && let Err(err) = state.response.clear_active_timeout_bundle() + { + return Err(err); + } + outcome + }) .await { Ok(Ok(changed)) => { @@ -217,14 +280,13 @@ impl SharedServer { } fn server_info() -> ServerInfo { - ServerInfo { - protocol_version: ProtocolVersion::V_2025_06_18, - capabilities: ServerCapabilities::builder() + ServerInfo::new( + ServerCapabilities::builder() .enable_tools() .enable_experimental_with(sandbox_capabilities()) .build(), - ..ServerInfo::default() - } + ) + .with_protocol_version(ProtocolVersion::V_2025_06_18) } #[derive(Clone, Copy)] @@ -297,9 +359,13 @@ macro_rules! define_backend_tool_server { #[tool_router] impl $server_ty { - fn new(backend: Backend, sandbox_plan: SandboxCliPlan) -> Result<Self, WorkerError> { + fn new( + backend: Backend, + sandbox_plan: SandboxCliPlan, + oversized_output: OversizedOutputMode, + ) -> Result<Self, WorkerError> { Ok(Self { - shared: SharedServer::new(backend, sandbox_plan)?, + shared: SharedServer::new(backend, sandbox_plan, oversized_output)?, tool_router: Self::tool_router(), }) } @@ -330,9 +396,19 @@ macro_rules! define_backend_tool_server { let worker_timeout = apply_tool_call_margin(timeout); let result = self .shared - .run_worker(move |worker| worker.restart(worker_timeout)) + .run_state(move |state| { + let result = state.worker.restart(worker_timeout); + let mut result = state.response.finalize_worker_result( + result, + state.worker.pending_request(), + TimeoutBundleReuse::None, + 0, + ); + strip_text_stream_meta(&mut result); + result + }) .await?; - worker_result_to_call_tool_result(result) + Ok(result) } } @@ -361,11 +437,19 @@ macro_rules! define_backend_tool_server { }; } -define_backend_tool_server!(RToolServer, "../docs/tool-descriptions/repl_tool_r.md"); +define_backend_tool_server!(RFilesToolServer, "../docs/tool-descriptions/repl_tool_r.md"); +define_backend_tool_server!( + RPagerToolServer, + "../docs/tool-descriptions/repl_tool_r_pager.md" +); define_backend_tool_server!( - PythonToolServer, + PythonFilesToolServer, "../docs/tool-descriptions/repl_tool_python.md" ); +define_backend_tool_server!( + PythonPagerToolServer, + "../docs/tool-descriptions/repl_tool_python_pager.md" +); #[derive(Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] @@ -388,27 +472,6 @@ fn resolve_timeout_ms( parse_timeout(timeout_secs, tool_name, allow_zero) } -fn worker_result_to_call_tool_result( - result: Result<crate::worker_protocol::WorkerReply, WorkerError>, -) -> Result<CallToolResult, McpError> { - let mut contents = Vec::new(); - let mut is_error = false; - match result { - Ok(reply) => { - let (mut reply_contents, reply_error) = worker_reply_to_contents(reply); - is_error |= reply_error; - contents.append(&mut reply_contents); - Ok(finalize_batch(contents, is_error)) - } - Err(err) => { - eprintln!("worker write stdin error: {err}"); - contents.push(Content::text(format!("worker error: {err}"))); - is_error = true; - Ok(finalize_batch(contents, is_error)) - } - } -} - fn sandbox_capabilities() -> BTreeMap<String, JsonObject> { let mut capability = JsonObject::new(); capability.insert("version".to_string(), json!("1.0.0")); @@ -419,16 +482,16 @@ fn sandbox_capabilities() -> BTreeMap<String, JsonObject> { async fn run_backend_server<S>( service: S, - shutdown_worker: Arc<Mutex<WorkerManager>>, + shutdown_state: Arc<Mutex<ServerState>>, ) -> Result<(), Box<dyn std::error::Error>> where S: ServerHandler + Send + Sync + Clone + 'static, { - let warm_worker = shutdown_worker.clone(); + let warm_state = shutdown_state.clone(); thread::spawn(move || { crate::event_log::log("worker_warm_start_begin", json!({})); - let mut worker = warm_worker.lock().unwrap(); - if let Err(err) = worker.warm_start() { + let mut state = warm_state.lock().unwrap(); + if let Err(err) = state.worker.warm_start() { eprintln!("worker warm start error: {err}"); crate::event_log::log( "worker_warm_start_error", @@ -453,8 +516,17 @@ where .await; { - let mut worker = shutdown_worker.lock().unwrap(); - worker.shutdown(); + let mut state = shutdown_state.lock().unwrap(); + state.worker.shutdown(); + if let Err(err) = state.response.shutdown() { + eprintln!("output bundle cleanup error: {err}"); + crate::event_log::log( + "output_bundle_cleanup_error", + json!({ + "error": err.to_string(), + }), + ); + } } match &result { Ok(()) => crate::event_log::log("server_listen_end", json!({"status": "ok"})), @@ -472,6 +544,7 @@ where pub async fn run( backend: Backend, sandbox_plan: SandboxCliPlan, + oversized_output: OversizedOutputMode, ) -> Result<(), Box<dyn std::error::Error>> { eprintln!("starting mcp-repl server"); crate::event_log::log( @@ -481,13 +554,25 @@ pub async fn run( }), ); match backend { - Backend::R => { - let service = RToolServer::new(backend, sandbox_plan)?; - run_backend_server(service.clone(), service.shared.worker()).await - } - Backend::Python => { - let service = PythonToolServer::new(backend, sandbox_plan)?; - run_backend_server(service.clone(), service.shared.worker()).await - } + Backend::R => match oversized_output { + OversizedOutputMode::Files => { + let service = RFilesToolServer::new(backend, sandbox_plan, oversized_output)?; + run_backend_server(service.clone(), service.shared.state()).await + } + OversizedOutputMode::Pager => { + let service = RPagerToolServer::new(backend, sandbox_plan, oversized_output)?; + run_backend_server(service.clone(), service.shared.state()).await + } + }, + Backend::Python => match oversized_output { + OversizedOutputMode::Files => { + let service = PythonFilesToolServer::new(backend, sandbox_plan, oversized_output)?; + run_backend_server(service.clone(), service.shared.state()).await + } + OversizedOutputMode::Pager => { + let service = PythonPagerToolServer::new(backend, sandbox_plan, oversized_output)?; + run_backend_server(service.clone(), service.shared.state()).await + } + }, } } diff --git a/src/server/response.rs b/src/server/response.rs index 2a02506..3e66c2f 100644 --- a/src/server/response.rs +++ b/src/server/response.rs @@ -1,43 +1,2453 @@ -use rmcp::model::{AnnotateAble, CallToolResult, Content, Meta, RawContent, RawImageContent}; -use serde_json::json; +use std::collections::VecDeque; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; -use crate::worker_protocol::{WorkerContent, WorkerReply}; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; +use rmcp::model::{ + AnnotateAble, CallToolResult, Content, Meta, RawContent, RawImageContent, RawTextContent, +}; +use serde_json::Value; +use tempfile::Builder; -pub(crate) fn worker_reply_to_contents(reply: WorkerReply) -> (Vec<Content>, bool) { - let (contents, is_error) = match reply { +use crate::worker_process::WorkerError; +use crate::worker_protocol::{ + ContentOrigin, TextStream, WorkerContent, WorkerErrorCode, WorkerReply, +}; + +const INLINE_TEXT_BUDGET: usize = 3500; +const INLINE_TEXT_HARD_SPILL_THRESHOLD_NUMERATOR: usize = 5; +const INLINE_TEXT_HARD_SPILL_THRESHOLD_DENOMINATOR: usize = 4; +const INLINE_TEXT_HARD_SPILL_THRESHOLD: usize = INLINE_TEXT_BUDGET + * INLINE_TEXT_HARD_SPILL_THRESHOLD_NUMERATOR + / INLINE_TEXT_HARD_SPILL_THRESHOLD_DENOMINATOR; +const IMAGE_OUTPUT_BUNDLE_THRESHOLD: usize = 5; +const HEAD_TEXT_BUDGET: usize = INLINE_TEXT_BUDGET / 3; +const PRE_LAST_TEXT_BUDGET: usize = INLINE_TEXT_BUDGET / 5; +const POST_LAST_TEXT_BUDGET: usize = INLINE_TEXT_BUDGET / 8; +const TEXT_ROW_OVERHEAD_BYTES: usize = 160; +const DEFAULT_OUTPUT_BUNDLE_MAX_COUNT: usize = 20; +const DEFAULT_OUTPUT_BUNDLE_MAX_BYTES: u64 = 1 << 30; +const DEFAULT_OUTPUT_BUNDLE_MAX_TOTAL_BYTES: u64 = 2 << 30; +const OUTPUT_BUNDLE_MAX_COUNT_ENV: &str = "MCP_REPL_OUTPUT_BUNDLE_MAX_COUNT"; +const OUTPUT_BUNDLE_MAX_BYTES_ENV: &str = "MCP_REPL_OUTPUT_BUNDLE_MAX_BYTES"; +const OUTPUT_BUNDLE_MAX_TOTAL_BYTES_ENV: &str = "MCP_REPL_OUTPUT_BUNDLE_MAX_TOTAL_BYTES"; +const OUTPUT_BUNDLE_HEADER: &[u8] = b"v1\ntext transcript.txt\nimages images/\n"; +const OUTPUT_BUNDLE_OMITTED_NOTICE: &str = "output bundle quota reached; later content omitted"; +const TEXT_STREAM_META_KEY: &str = "mcpReplTextStream"; + +pub(crate) struct ResponseState { + output_store: OutputStore, + active_timeout_bundle: Option<ActiveOutputBundle>, + staged_timeout_output: Option<StagedTimeoutOutput>, +} + +type OutputStoreRootFactory = fn() -> std::io::Result<tempfile::TempDir>; + +struct OutputStore { + root: Option<tempfile::TempDir>, + create_root: OutputStoreRootFactory, + next_id: u64, + total_bytes: u64, + limits: OutputStoreLimits, + bundles: VecDeque<StoredBundle>, +} + +struct ActiveOutputBundle { + id: u64, + paths: OutputBundlePaths, + next_image_number: usize, + current_image_history_number: usize, + history_image_count: usize, + transcript_bytes: usize, + transcript_lines: usize, + transcript_has_partial_line: bool, + omitted_tail: bool, + omission_recorded: bool, + pre_index_image_paths: Vec<String>, + disclosed: bool, +} + +struct BundleAppendResult { + retained_items: Vec<ReplyItem>, + omitted_this_reply: bool, +} + +#[derive(Clone)] +struct OutputBundlePaths { + dir: PathBuf, + transcript: PathBuf, + events_log: PathBuf, + images_dir: PathBuf, + images_history_dir: PathBuf, +} + +struct StoredBundle { + id: u64, + dir: PathBuf, + bytes_on_disk: u64, +} + +struct OutputStoreLimits { + max_bundle_count: usize, + max_bundle_bytes: u64, + max_total_bytes: u64, +} + +#[derive(Clone)] +enum ReplyItem { + WorkerText { text: String, stream: TextStream }, + ServerText { text: String, stream: TextStream }, + Image(ReplyImage), +} + +impl ReplyItem { + fn worker_text(text: impl Into<String>, stream: TextStream) -> Self { + Self::WorkerText { + text: text.into(), + stream, + } + } + + fn server_text(text: impl Into<String>, stream: TextStream) -> Self { + Self::ServerText { + text: text.into(), + stream, + } + } +} + +#[derive(Clone)] +struct ReplyImage { + data: String, + mime_type: String, + is_new: bool, +} + +#[derive(Clone)] +struct StagedTimeoutOutput { + items: Vec<ReplyItem>, +} + +struct ReplyMaterial { + inline_items: Vec<ReplyItem>, + bundle_items: Vec<ReplyItem>, + worker_text: String, + detached_prefix_items: Vec<ReplyItem>, + detached_prefix_inline_items: Vec<ReplyItem>, + detached_prefix_worker_text: String, + reply_inline_items: Vec<ReplyItem>, + reply_bundle_items: Vec<ReplyItem>, + reply_worker_text: String, + is_error: bool, + error_code: Option<WorkerErrorCode>, +} + +struct FollowUpDetachedPrefix { + contents: Vec<Content>, + protected_bundle_id: Option<u64>, + retained_active_timeout_bundle: Option<ActiveOutputBundle>, + retained_staged_timeout_output: Option<StagedTimeoutOutput>, +} + +struct TimeoutReplySegment { + contents: Vec<Content>, + retained_active_timeout_bundle: Option<ActiveOutputBundle>, + retained_staged_timeout_output: Option<StagedTimeoutOutput>, +} + +struct TimeoutReplyView<'a> { + bundle_items: &'a [ReplyItem], + inline_items: &'a [ReplyItem], + worker_text: &'a str, + error_code: Option<WorkerErrorCode>, + protected_bundle_id: Option<u64>, +} + +#[derive(Clone, Copy)] +pub(crate) enum TimeoutBundleReuse { + None, + FullReply, + FollowUpInput, +} + +pub(crate) fn timeout_bundle_reuse_for_input(input: &str) -> TimeoutBundleReuse { + if input.is_empty() { + return TimeoutBundleReuse::FullReply; + } + + let Some(first) = input.chars().next() else { + return TimeoutBundleReuse::FullReply; + }; + let tail = &input[first.len_utf8()..]; + let tail = if let Some(rest) = tail.strip_prefix("\r\n") { + rest + } else if let Some(rest) = tail.strip_prefix('\n') { + rest + } else if let Some(rest) = tail.strip_prefix('\r') { + rest + } else { + tail + }; + + match first { + '\u{3}' if tail.is_empty() => TimeoutBundleReuse::FullReply, + '\u{3}' => TimeoutBundleReuse::FollowUpInput, + '\u{4}' => TimeoutBundleReuse::None, + _ => TimeoutBundleReuse::FollowUpInput, + } +} + +impl ResponseState { + pub(crate) fn new() -> Result<Self, WorkerError> { + Ok(Self { + output_store: OutputStore::new()?, + active_timeout_bundle: None, + staged_timeout_output: None, + }) + } + + pub(crate) fn clear_active_timeout_bundle(&mut self) -> Result<(), WorkerError> { + if let Some(active) = self.active_timeout_bundle.take() { + self.finish_bundle(active)?; + } + self.staged_timeout_output = None; + Ok(()) + } + + pub(crate) fn shutdown(&mut self) -> Result<(), WorkerError> { + self.active_timeout_bundle = None; + self.staged_timeout_output = None; + self.output_store.cleanup_now() + } + + #[cfg(test)] + pub(crate) fn has_active_timeout_bundle(&self) -> bool { + self.active_timeout_bundle.is_some() || self.staged_timeout_output.is_some() + } + + fn materialize_staged_timeout_output( + &mut self, + staged: &StagedTimeoutOutput, + protected_bundle_id: Option<u64>, + ) -> Result<ActiveOutputBundle, WorkerError> { + let mut bundle = self + .output_store + .new_bundle_preserving(protected_bundle_id)?; + if !staged.items.is_empty() + && let Err(err) = bundle.append_items(&mut self.output_store, &staged.items) + { + if let Err(cleanup_err) = self.finish_bundle(bundle) { + eprintln!( + "dropping closed timeout bundle after output-bundle error: {cleanup_err}" + ); + } + return Err(err); + } + Ok(bundle) + } + + /// Converts a worker result into the final MCP reply, including transcript updates and + /// oversized reply compaction. + pub(crate) fn finalize_worker_result( + &mut self, + result: Result<WorkerReply, WorkerError>, + pending_request_after: bool, + timeout_bundle_reuse: TimeoutBundleReuse, + detached_prefix_item_count: usize, + ) -> CallToolResult { + match result { + Ok(reply) => self.finalize_reply( + reply, + pending_request_after, + timeout_bundle_reuse, + detached_prefix_item_count, + ), + Err(err) => { + eprintln!("worker write stdin error: {err}"); + if let Err(cleanup_err) = self.clear_active_timeout_bundle() { + eprintln!( + "dropping closed timeout bundle after output-bundle error: {cleanup_err}" + ); + } + finalize_batch(vec![Content::text(format!("worker error: {err}"))], true) + } + } + } + + /// Splits worker-originated text from server-only notices, keeps timeout polls on one + /// transcript path, and only discloses that path once text actually needs compaction. + fn finalize_reply( + &mut self, + reply: WorkerReply, + pending_request_after: bool, + timeout_bundle_reuse: TimeoutBundleReuse, + detached_prefix_item_count: usize, + ) -> CallToolResult { + let material = prepare_reply_material(reply, detached_prefix_item_count); + let mut active_timeout_bundle = self.active_timeout_bundle.take(); + let mut staged_timeout_output = self.staged_timeout_output.take(); + if matches!(timeout_bundle_reuse, TimeoutBundleReuse::FollowUpInput) { + let contents = self.finalize_follow_up_reply( + &material, + pending_request_after, + active_timeout_bundle, + staged_timeout_output, + ); + return finalize_batch(contents, material.is_error); + } + + let reuse_active_timeout_bundle = + matches!(timeout_bundle_reuse, TimeoutBundleReuse::FullReply); + if !reuse_active_timeout_bundle { + staged_timeout_output = None; + if let Some(active) = active_timeout_bundle.take() + && let Err(err) = self.finish_bundle(active) + { + eprintln!("dropping closed timeout bundle after output-bundle error: {err}"); + } + } + + let contents = if active_timeout_bundle.is_none() + && staged_timeout_output.is_none() + && should_spill_detached_prefix_only(&material) + { + self.finalize_reply_with_spilled_detached_prefix(&material, pending_request_after) + } else { + let TimeoutReplySegment { + contents, + retained_active_timeout_bundle, + retained_staged_timeout_output, + } = self.render_timeout_reply_segment( + TimeoutReplyView { + bundle_items: &material.bundle_items, + inline_items: &material.inline_items, + worker_text: &material.worker_text, + error_code: material.error_code, + protected_bundle_id: None, + }, + pending_request_after, + active_timeout_bundle, + staged_timeout_output, + ); + self.active_timeout_bundle = retained_active_timeout_bundle; + self.staged_timeout_output = retained_staged_timeout_output; + contents + }; + + finalize_batch(contents, material.is_error) + } + + fn finalize_reply_with_spilled_detached_prefix( + &mut self, + material: &ReplyMaterial, + pending_request_after: bool, + ) -> Vec<Content> { + let FollowUpDetachedPrefix { + mut contents, + protected_bundle_id, + retained_active_timeout_bundle, + retained_staged_timeout_output, + } = self.render_follow_up_detached_prefix(material, None, None); + let TimeoutReplySegment { + contents: reply_contents, + retained_active_timeout_bundle: retained_reply_timeout_bundle, + retained_staged_timeout_output: retained_reply_staged_timeout_output, + } = self.render_timeout_reply_segment( + TimeoutReplyView { + bundle_items: &material.reply_bundle_items, + inline_items: &material.reply_inline_items, + worker_text: &material.reply_worker_text, + error_code: material.error_code, + protected_bundle_id, + }, + pending_request_after, + None, + None, + ); + contents.extend(reply_contents); + self.active_timeout_bundle = retained_reply_timeout_bundle; + self.staged_timeout_output = retained_reply_staged_timeout_output; + + debug_assert!(retained_active_timeout_bundle.is_none()); + debug_assert!(retained_staged_timeout_output.is_none()); + + contents + } + + fn finalize_follow_up_reply( + &mut self, + material: &ReplyMaterial, + pending_request_after: bool, + active_timeout_bundle: Option<ActiveOutputBundle>, + staged_timeout_output: Option<StagedTimeoutOutput>, + ) -> Vec<Content> { + let FollowUpDetachedPrefix { + mut contents, + protected_bundle_id, + mut retained_active_timeout_bundle, + mut retained_staged_timeout_output, + } = self.render_follow_up_detached_prefix( + material, + active_timeout_bundle, + staged_timeout_output, + ); + let reply_is_server_only_follow_up = material.reply_worker_text.is_empty() + && count_images(&material.reply_bundle_items) == 0; + + let TimeoutReplySegment { + contents: reply_contents, + retained_active_timeout_bundle: mut retained_reply_timeout_bundle, + retained_staged_timeout_output: mut retained_reply_staged_timeout_output, + } = self.render_timeout_reply_segment( + TimeoutReplyView { + bundle_items: &material.reply_bundle_items, + inline_items: &material.reply_inline_items, + worker_text: &material.reply_worker_text, + error_code: material.error_code, + protected_bundle_id, + }, + pending_request_after, + None, + None, + ); + contents.extend(reply_contents); + if pending_request_after && reply_is_server_only_follow_up { + if let Some(active) = retained_reply_timeout_bundle.take() + && let Err(err) = self.finish_bundle(active) + { + eprintln!("dropping closed timeout bundle after output-bundle error: {err}"); + } + retained_reply_staged_timeout_output = None; + } + self.active_timeout_bundle = retained_reply_timeout_bundle; + self.staged_timeout_output = retained_reply_staged_timeout_output; + + if pending_request_after + && (material.error_code != Some(WorkerErrorCode::Timeout) + || reply_is_server_only_follow_up) + && self.active_timeout_bundle.is_none() + && self.staged_timeout_output.is_none() + { + self.active_timeout_bundle = retained_active_timeout_bundle.take(); + self.staged_timeout_output = retained_staged_timeout_output.take(); + } + if let Some(active) = retained_active_timeout_bundle.take() + && let Err(err) = self.finish_bundle(active) + { + eprintln!("dropping closed timeout bundle after output-bundle error: {err}"); + } + + contents + } + + fn render_follow_up_detached_prefix( + &mut self, + material: &ReplyMaterial, + active_timeout_bundle: Option<ActiveOutputBundle>, + staged_timeout_output: Option<StagedTimeoutOutput>, + ) -> FollowUpDetachedPrefix { + if let Some(active) = active_timeout_bundle { + return self.render_follow_up_detached_prefix_with_active_bundle(material, active); + } + + let detached_prefix_image_count = count_images(&material.detached_prefix_items); + let combined_image_count = + staged_timeout_output + .as_ref() + .map_or(detached_prefix_image_count, |staged| { + staged + .image_count() + .saturating_add(detached_prefix_image_count) + }); + let use_output_bundle = combined_image_count > 0 + && should_use_output_bundle( + combined_image_count, + material.detached_prefix_worker_text.chars().count(), + ); + + if let Some(mut staged) = staged_timeout_output { + if use_output_bundle + || text_should_spill(material.detached_prefix_worker_text.chars().count()) + { + match self.materialize_staged_timeout_output(&staged, None) { + Ok(active) => { + return self + .render_follow_up_detached_prefix_with_active_bundle(material, active); + } + Err(err) => { + eprintln!("dropping output-bundle setup after output-bundle error: {err}"); + return FollowUpDetachedPrefix { + contents: compact_detached_prefix_without_output_bundle(material), + protected_bundle_id: None, + retained_active_timeout_bundle: None, + retained_staged_timeout_output: None, + }; + } + } + } + staged.extend(&material.detached_prefix_items); + return FollowUpDetachedPrefix { + contents: materialize_items(material.detached_prefix_inline_items.clone()), + protected_bundle_id: None, + retained_active_timeout_bundle: None, + retained_staged_timeout_output: staged + .has_retained_worker_output() + .then_some(staged), + }; + } + + if use_output_bundle + || text_should_spill(material.detached_prefix_worker_text.chars().count()) + { + match self.output_store.new_bundle() { + Ok(mut bundle) => { + match bundle + .append_items(&mut self.output_store, &material.detached_prefix_items) + { + Ok(append) => { + bundle.disclosed = true; + let contents = if use_output_bundle { + compact_output_bundle_items(&append.retained_items, &bundle) + } else { + let retained_worker_text = + worker_text_from_items(&append.retained_items); + compact_text_bundle_items( + append.retained_items, + &retained_worker_text, + &bundle, + ) + }; + return FollowUpDetachedPrefix { + contents, + protected_bundle_id: Some(bundle.id), + retained_active_timeout_bundle: None, + retained_staged_timeout_output: None, + }; + } + Err(err) => { + eprintln!( + "dropping detached idle bundle after output-bundle error: {err}" + ); + if let Err(cleanup_err) = self.finish_bundle(bundle) { + eprintln!( + "dropping closed output bundle after output-bundle error: {cleanup_err}" + ); + } + } + } + } + Err(err) => { + eprintln!("dropping output-bundle setup after output-bundle error: {err}"); + } + } + return FollowUpDetachedPrefix { + contents: compact_detached_prefix_without_output_bundle(material), + protected_bundle_id: None, + retained_active_timeout_bundle: None, + retained_staged_timeout_output: None, + }; + } + FollowUpDetachedPrefix { + contents: materialize_items(material.detached_prefix_inline_items.clone()), + protected_bundle_id: None, + retained_active_timeout_bundle: None, + retained_staged_timeout_output: None, + } + } + + fn render_follow_up_detached_prefix_with_active_bundle( + &mut self, + material: &ReplyMaterial, + mut active: ActiveOutputBundle, + ) -> FollowUpDetachedPrefix { + let contents = if material.detached_prefix_items.is_empty() { + Vec::new() + } else { + match render_active_bundle_contents( + &mut self.output_store, + &mut active, + &material.detached_prefix_items, + &material.detached_prefix_inline_items, + material.detached_prefix_worker_text.chars().count(), + ) { + Ok(contents) => contents, + Err(err) => { + eprintln!("dropping timeout bundle content after output-bundle error: {err}"); + let protected_bundle_id = active.was_disclosed().then_some(active.id); + if let Err(err) = self.finish_bundle(active) { + eprintln!( + "dropping closed timeout bundle after output-bundle error: {err}" + ); + } + return FollowUpDetachedPrefix { + contents: compact_detached_prefix_without_output_bundle(material), + protected_bundle_id, + retained_active_timeout_bundle: None, + retained_staged_timeout_output: None, + }; + } + } + }; + let protected_bundle_id = active.was_disclosed().then_some(active.id); + FollowUpDetachedPrefix { + contents, + protected_bundle_id, + retained_active_timeout_bundle: Some(active), + retained_staged_timeout_output: None, + } + } + + fn render_timeout_reply_segment( + &mut self, + view: TimeoutReplyView<'_>, + pending_request_after: bool, + active_timeout_bundle: Option<ActiveOutputBundle>, + staged_timeout_output: Option<StagedTimeoutOutput>, + ) -> TimeoutReplySegment { + let TimeoutReplyView { + bundle_items, + inline_items, + worker_text, + error_code, + protected_bundle_id, + } = view; + if let Some(mut active) = active_timeout_bundle { + match render_active_bundle_contents( + &mut self.output_store, + &mut active, + bundle_items, + inline_items, + worker_text.chars().count(), + ) { + Ok(contents) => { + if pending_request_after { + return TimeoutReplySegment { + contents, + retained_active_timeout_bundle: Some(active), + retained_staged_timeout_output: None, + }; + } + if let Err(err) = self.finish_bundle(active) { + eprintln!( + "dropping closed timeout bundle after output-bundle error: {err}" + ); + } + return TimeoutReplySegment { + contents, + retained_active_timeout_bundle: None, + retained_staged_timeout_output: None, + }; + } + Err(err) => { + eprintln!("dropping timeout bundle content after output-bundle error: {err}"); + if let Err(err) = self.finish_bundle(active) { + eprintln!( + "dropping closed timeout bundle after output-bundle error: {err}" + ); + } + return TimeoutReplySegment { + contents: compact_items_without_output_bundle( + bundle_items, + inline_items, + worker_text, + ), + retained_active_timeout_bundle: None, + retained_staged_timeout_output: None, + }; + } + } + } + + let current_image_count = count_images(bundle_items); + let staged_worker_text_chars = staged_timeout_output + .as_ref() + .map_or(0, StagedTimeoutOutput::worker_text_chars); + let combined_worker_text_chars = + staged_worker_text_chars.saturating_add(worker_text.chars().count()); + let combined_image_count = staged_timeout_output + .as_ref() + .map_or(current_image_count, |staged| { + staged.image_count().saturating_add(current_image_count) + }); + let use_output_bundle = combined_image_count > 0 + && should_use_output_bundle(combined_image_count, combined_worker_text_chars); + let text_spills = text_should_spill(combined_worker_text_chars); + + if let Some(mut staged) = staged_timeout_output { + if use_output_bundle || text_spills { + match self.materialize_staged_timeout_output(&staged, protected_bundle_id) { + Ok(mut active) => { + match render_active_bundle_contents( + &mut self.output_store, + &mut active, + bundle_items, + inline_items, + combined_worker_text_chars, + ) { + Ok(contents) => { + if pending_request_after { + return TimeoutReplySegment { + contents, + retained_active_timeout_bundle: Some(active), + retained_staged_timeout_output: None, + }; + } + if let Err(err) = self.finish_bundle(active) { + eprintln!( + "dropping closed timeout bundle after output-bundle error: {err}" + ); + } + return TimeoutReplySegment { + contents, + retained_active_timeout_bundle: None, + retained_staged_timeout_output: None, + }; + } + Err(err) => { + eprintln!( + "dropping timeout bundle content after output-bundle error: {err}" + ); + if let Err(err) = self.finish_bundle(active) { + eprintln!( + "dropping closed timeout bundle after output-bundle error: {err}" + ); + } + } + } + } + Err(err) => { + eprintln!("dropping timeout bundle setup after output-bundle error: {err}"); + } + } + return TimeoutReplySegment { + contents: compact_items_without_output_bundle( + bundle_items, + inline_items, + worker_text, + ), + retained_active_timeout_bundle: None, + retained_staged_timeout_output: None, + }; + } + let contents = materialize_items(inline_items.to_vec()); + if pending_request_after { + staged.extend(bundle_items); + return TimeoutReplySegment { + contents, + retained_active_timeout_bundle: None, + retained_staged_timeout_output: Some(staged), + }; + } + return TimeoutReplySegment { + contents, + retained_active_timeout_bundle: None, + retained_staged_timeout_output: None, + }; + } + + if error_code == Some(WorkerErrorCode::Timeout) { + if use_output_bundle || text_spills { + match self.output_store.new_bundle_preserving(protected_bundle_id) { + Ok(mut bundle) => { + match render_active_bundle_contents( + &mut self.output_store, + &mut bundle, + bundle_items, + inline_items, + worker_text.chars().count(), + ) { + Ok(contents) => { + if pending_request_after { + return TimeoutReplySegment { + contents, + retained_active_timeout_bundle: Some(bundle), + retained_staged_timeout_output: None, + }; + } + if let Err(err) = self.finish_bundle(bundle) { + eprintln!( + "dropping closed timeout bundle after output-bundle error: {err}" + ); + } + return TimeoutReplySegment { + contents, + retained_active_timeout_bundle: None, + retained_staged_timeout_output: None, + }; + } + Err(err) => { + eprintln!( + "dropping timeout bundle content after output-bundle error: {err}" + ); + if let Err(err) = self.finish_bundle(bundle) { + eprintln!( + "dropping closed timeout bundle after output-bundle error: {err}" + ); + } + } + } + } + Err(err) => { + eprintln!("dropping timeout bundle setup after output-bundle error: {err}"); + } + } + return TimeoutReplySegment { + contents: compact_items_without_output_bundle( + bundle_items, + inline_items, + worker_text, + ), + retained_active_timeout_bundle: None, + retained_staged_timeout_output: None, + }; + } + return TimeoutReplySegment { + contents: materialize_items(inline_items.to_vec()), + retained_active_timeout_bundle: None, + retained_staged_timeout_output: pending_request_after + .then(|| StagedTimeoutOutput::from_items(bundle_items)) + .flatten(), + }; + } + + TimeoutReplySegment { + contents: render_reply_items( + &mut self.output_store, + bundle_items, + inline_items, + worker_text, + protected_bundle_id, + ), + retained_active_timeout_bundle: None, + retained_staged_timeout_output: None, + } + } +} + +impl OutputStore { + fn new() -> Result<Self, WorkerError> { + let limits = OutputStoreLimits::from_env()?; + Ok(Self { + root: None, + create_root: create_output_store_root, + next_id: 0, + total_bytes: 0, + limits, + bundles: VecDeque::new(), + }) + } + + fn cleanup_now(&mut self) -> Result<(), WorkerError> { + if let Some(root) = self.root.take() { + root.close().map_err(WorkerError::Io)?; + } + self.bundles.clear(); + self.total_bytes = 0; + Ok(()) + } + + fn ensure_root_path(&mut self) -> Result<&Path, WorkerError> { + if self.root.is_none() { + self.root = Some((self.create_root)().map_err(WorkerError::Io)?); + } + Ok(self + .root + .as_ref() + .expect("output store root should exist") + .path()) + } + + fn new_bundle(&mut self) -> Result<ActiveOutputBundle, WorkerError> { + self.new_bundle_preserving(None) + } + + fn new_bundle_preserving( + &mut self, + protected_bundle_id: Option<u64>, + ) -> Result<ActiveOutputBundle, WorkerError> { + self.prune_for_new_bundle(0, protected_bundle_id)?; + self.next_id = self.next_id.saturating_add(1); + let bundle_id = self.next_id; + let root_path = self.ensure_root_path()?.to_path_buf(); + let dir = root_path.join(format!("output-{bundle_id:04}")); + fs::create_dir_all(&dir).map_err(WorkerError::Io)?; + let images_dir = dir.join("images"); + let images_history_dir = images_dir.join("history"); + let transcript = dir.join("transcript.txt"); + let events_log = dir.join("events.log"); + self.bundles.push_back(StoredBundle { + id: bundle_id, + dir: dir.clone(), + bytes_on_disk: 0, + }); + Ok(ActiveOutputBundle { + id: bundle_id, + paths: OutputBundlePaths { + dir, + transcript, + events_log, + images_dir, + images_history_dir, + }, + next_image_number: 0, + current_image_history_number: 0, + history_image_count: 0, + transcript_bytes: 0, + transcript_lines: 0, + transcript_has_partial_line: false, + omitted_tail: false, + omission_recorded: false, + pre_index_image_paths: Vec::new(), + disclosed: false, + }) + } + + fn remove_bundle(&mut self, bundle_id: u64) -> Result<(), WorkerError> { + let Some(index) = self + .bundles + .iter() + .position(|bundle| bundle.id == bundle_id) + else { + return Ok(()); + }; + self.remove_bundle_at(index) + } + + fn append_bundle_bytes( + &mut self, + bundle_id: u64, + path: &Path, + bytes: &[u8], + ) -> Result<(), WorkerError> { + if bytes.is_empty() { + return Ok(()); + } + let mut file = OpenOptions::new() + .append(true) + .create(true) + .open(path) + .map_err(WorkerError::Io)?; + file.write_all(bytes).map_err(WorkerError::Io)?; + self.record_append(bundle_id, bytes.len() as u64); + Ok(()) + } + + fn prepare_append_capacity( + &mut self, + bundle_id: u64, + requested_bytes: u64, + ) -> Result<u64, WorkerError> { + let bundle_bytes = self + .bundle_bytes(bundle_id) + .expect("bundle metadata should exist for append"); + let bundle_remaining = self.limits.max_bundle_bytes.saturating_sub(bundle_bytes); + let target = requested_bytes.min(bundle_remaining); + self.prune_until_total_capacity(bundle_id, target)?; + let total_remaining = self.limits.max_total_bytes.saturating_sub(self.total_bytes); + Ok(target.min(total_remaining)) + } + + fn bundle_bytes(&self, bundle_id: u64) -> Option<u64> { + self.bundles + .iter() + .find(|bundle| bundle.id == bundle_id) + .map(|bundle| bundle.bytes_on_disk) + } + + fn record_append(&mut self, bundle_id: u64, bytes: u64) { + if bytes == 0 { + return; + } + let bundle = self + .bundles + .iter_mut() + .find(|bundle| bundle.id == bundle_id) + .expect("bundle metadata should exist for append"); + bundle.bytes_on_disk = bundle.bytes_on_disk.saturating_add(bytes); + self.total_bytes = self.total_bytes.saturating_add(bytes); + } + + fn record_file_replace(&mut self, bundle_id: u64, old_bytes: u64, new_bytes: u64) { + if old_bytes == new_bytes { + return; + } + let bundle = self + .bundles + .iter_mut() + .find(|bundle| bundle.id == bundle_id) + .expect("bundle metadata should exist for file replacement"); + if new_bytes > old_bytes { + let delta = new_bytes - old_bytes; + bundle.bytes_on_disk = bundle.bytes_on_disk.saturating_add(delta); + self.total_bytes = self.total_bytes.saturating_add(delta); + } else { + let delta = old_bytes - new_bytes; + bundle.bytes_on_disk = bundle.bytes_on_disk.saturating_sub(delta); + self.total_bytes = self.total_bytes.saturating_sub(delta); + } + } + + fn record_file_removal(&mut self, bundle_id: u64, bytes: u64) { + if bytes == 0 { + return; + } + let bundle = self + .bundles + .iter_mut() + .find(|bundle| bundle.id == bundle_id) + .expect("bundle metadata should exist for file removal"); + bundle.bytes_on_disk = bundle.bytes_on_disk.saturating_sub(bytes); + self.total_bytes = self.total_bytes.saturating_sub(bytes); + } + + fn prune_for_new_bundle( + &mut self, + initial_bytes: u64, + protected_bundle_id: Option<u64>, + ) -> Result<(), WorkerError> { + while self.bundles.len() >= self.limits.max_bundle_count { + if !self.prune_oldest_inactive_bundle(protected_bundle_id)? { + return Err(WorkerError::Protocol( + "output bundle count quota left no room for a new bundle".to_string(), + )); + } + } + self.prune_until_total_capacity(protected_bundle_id.unwrap_or(0), initial_bytes)?; + if self.total_bytes.saturating_add(initial_bytes) > self.limits.max_total_bytes { + return Err(WorkerError::Protocol( + "output bundle total quota is too small for a new bundle".to_string(), + )); + } + Ok(()) + } + + fn prune_until_total_capacity( + &mut self, + active_bundle_id: u64, + needed_bytes: u64, + ) -> Result<(), WorkerError> { + while self.total_bytes.saturating_add(needed_bytes) > self.limits.max_total_bytes { + if !self.prune_oldest_inactive_bundle(Some(active_bundle_id))? { + break; + } + } + Ok(()) + } + + fn prune_oldest_inactive_bundle( + &mut self, + active_bundle_id: Option<u64>, + ) -> Result<bool, WorkerError> { + let Some(index) = self + .bundles + .iter() + .position(|bundle| Some(bundle.id) != active_bundle_id) + else { + return Ok(false); + }; + self.remove_bundle_at(index)?; + Ok(true) + } + + fn remove_bundle_at(&mut self, index: usize) -> Result<(), WorkerError> { + let bundle = self.bundles.get(index).expect("bundle index should exist"); + match fs::remove_dir_all(&bundle.dir) { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(WorkerError::Io(err)), + } + let bundle = self + .bundles + .remove(index) + .expect("bundle index should still exist"); + self.total_bytes = self.total_bytes.saturating_sub(bundle.bytes_on_disk); + Ok(()) + } +} + +fn create_output_store_root() -> std::io::Result<tempfile::TempDir> { + match Builder::new().prefix("mcp-repl-output-").tempdir() { + Ok(root) => Ok(root), + Err(err) + if err.kind() == std::io::ErrorKind::NotFound + && output_store_temp_env_has_non_unicode_value() => + { + Builder::new() + .prefix("mcp-repl-output-") + .tempdir_in(fallback_output_store_root_dir()) + } + Err(err) => Err(err), + } +} + +fn output_store_temp_env_has_non_unicode_value() -> bool { + ["TMPDIR", "TMP", "TEMP"] + .into_iter() + .any(|name| std::env::var_os(name).is_some() && std::env::var(name).is_err()) +} + +fn fallback_output_store_root_dir() -> PathBuf { + let candidate = std::env::temp_dir(); + if candidate.exists() { + return candidate; + } + + #[cfg(target_family = "unix")] + { + PathBuf::from("/tmp") + } + + #[cfg(not(target_family = "unix"))] + { + std::env::current_dir().unwrap_or(candidate) + } +} + +impl OutputStoreLimits { + fn from_env() -> Result<Self, WorkerError> { + let max_bundle_count = + parse_limit_env::<usize>(OUTPUT_BUNDLE_MAX_COUNT_ENV, DEFAULT_OUTPUT_BUNDLE_MAX_COUNT)?; + let max_bundle_bytes = + parse_limit_env::<u64>(OUTPUT_BUNDLE_MAX_BYTES_ENV, DEFAULT_OUTPUT_BUNDLE_MAX_BYTES)?; + let max_total_bytes = parse_limit_env::<u64>( + OUTPUT_BUNDLE_MAX_TOTAL_BYTES_ENV, + DEFAULT_OUTPUT_BUNDLE_MAX_TOTAL_BYTES, + )?; + if max_bundle_count == 0 { + return Err(WorkerError::Protocol( + "output bundle count quota must be greater than zero".to_string(), + )); + } + Ok(Self { + max_bundle_count, + max_bundle_bytes, + max_total_bytes, + }) + } +} + +impl ActiveOutputBundle { + fn was_disclosed(&self) -> bool { + self.disclosed + } + + fn append_items( + &mut self, + store: &mut OutputStore, + items: &[ReplyItem], + ) -> Result<BundleAppendResult, WorkerError> { + let mut retained_items = Vec::with_capacity(items.len()); + let mut omitted_this_reply = false; + + for item in items { + if self.omitted_tail { + if let ReplyItem::ServerText { text, stream } = item { + retained_items.push(ReplyItem::server_text(text.clone(), *stream)); + } + continue; + } + + match item { + ReplyItem::WorkerText { text, stream } => { + let append = self.append_worker_text(store, text, *stream)?; + if let Some(retained_item) = append { + let partial_worker_text = matches!( + &retained_item, + ReplyItem::WorkerText { text: retained, .. } if retained.len() < text.len() + ); + retained_items.push(retained_item); + if partial_worker_text { + omitted_this_reply = true; + self.apply_omission(store)?; + } + } else { + omitted_this_reply = true; + self.apply_omission(store)?; + } + } + ReplyItem::ServerText { text, stream } => { + match self.append_server_text(store, text, *stream)? { + Some(retained_item) => retained_items.push(retained_item), + None => { + omitted_this_reply = true; + self.apply_omission(store)?; + retained_items.push(ReplyItem::server_text(text.clone(), *stream)); + } + } + } + ReplyItem::Image(image) => { + if let Some(retained_item) = self.append_image(store, image)? { + retained_items.push(retained_item); + } else { + omitted_this_reply = true; + self.apply_omission(store)?; + } + } + } + } + + Ok(BundleAppendResult { + retained_items, + omitted_this_reply, + }) + } + + fn append_worker_text( + &mut self, + store: &mut OutputStore, + text: &str, + stream: TextStream, + ) -> Result<Option<ReplyItem>, WorkerError> { + if text.is_empty() { + return Ok(None); + } + if self.has_images() && !self.has_events_log() { + self.materialize_events_log(store)?; + } + self.ensure_transcript(store)?; + let start_byte = self.transcript_bytes; + let omission_reserve = if self.omission_recorded { + 0 + } else { + usize::from(self.has_events_log()) * omission_event_line_len() + }; + let granted = store.prepare_append_capacity( + self.id, + (text.len() + TEXT_ROW_OVERHEAD_BYTES + omission_reserve) as u64, + )? as usize; + if granted == 0 { + return Ok(None); + } + let initial_retained = truncate_utf8_prefix(text, granted); + if initial_retained.is_empty() { + return Ok(None); + } + let mut retained = initial_retained; + loop { + let (start_line, end_line, next_line_count, next_has_partial_line) = + append_text_line_span( + retained, + self.transcript_lines, + self.transcript_has_partial_line, + ); + let end_byte = start_byte.saturating_add(retained.len()); + let row = format!("T lines={start_line}-{end_line} bytes={start_byte}-{end_byte}\n"); + let reserve = if retained.len() < text.len() { + omission_reserve + } else { + 0 + }; + if retained + .len() + .saturating_add(row.len()) + .saturating_add(reserve) + <= granted + { + store.append_bundle_bytes(self.id, &self.paths.transcript, retained.as_bytes())?; + if self.has_events_log() { + store.append_bundle_bytes(self.id, &self.paths.events_log, row.as_bytes())?; + } + self.transcript_bytes = self.transcript_bytes.saturating_add(retained.len()); + self.transcript_lines = next_line_count; + self.transcript_has_partial_line = next_has_partial_line; + return Ok(Some(ReplyItem::worker_text(retained.to_string(), stream))); + } + let allowed_text_bytes = granted.saturating_sub(row.len().saturating_add(reserve)); + let next = truncate_utf8_prefix(retained, allowed_text_bytes); + if next.is_empty() || next.len() == retained.len() { + return Ok(None); + } + retained = next; + } + } + + fn append_server_text( + &mut self, + store: &mut OutputStore, + text: &str, + stream: TextStream, + ) -> Result<Option<ReplyItem>, WorkerError> { + let _ = store; + Ok(Some(ReplyItem::server_text(text.to_string(), stream))) + } + + fn append_events_log_text<'a>( + &mut self, + store: &mut OutputStore, + text: &'a str, + ) -> Result<Option<&'a str>, WorkerError> { + if text.is_empty() { + return Ok(None); + } + let line = build_events_log_server_line(text); + let granted = store.prepare_append_capacity(self.id, line.len() as u64)?; + if granted < line.len() as u64 { + return Ok(None); + } + store.append_bundle_bytes(self.id, &self.paths.events_log, line.as_bytes())?; + Ok(Some(text)) + } + + fn append_image( + &mut self, + store: &mut OutputStore, + image: &ReplyImage, + ) -> Result<Option<ReplyItem>, WorkerError> { + self.ensure_images_dir()?; + if self.has_text() && !self.has_events_log() { + self.materialize_events_log(store)?; + } + let extension = image_extension(&image.mime_type); + let starts_new_image = image.is_new || self.next_image_number == 0; + let image_number = if starts_new_image { + self.next_image_number.saturating_add(1) + } else { + self.next_image_number + }; + let history_number = if starts_new_image { + 1 + } else { + self.current_image_history_number.saturating_add(1) + }; + let history_rel_path = + format!("images/history/{image_number:03}/{history_number:03}.{extension}"); + let history_path = self + .paths + .images_history_dir + .join(format!("{image_number:03}/{history_number:03}.{extension}")); + let alias_path = self + .paths + .images_dir + .join(format!("{image_number:03}.{extension}")); + let bytes = STANDARD + .decode(image.data.as_bytes()) + .map_err(|err| WorkerError::Protocol(format!("invalid image data: {err}")))?; + let alias_old_path = self.existing_image_alias_path(image_number); + let alias_old_len = alias_old_path + .as_ref() + .and_then(|path| fs::metadata(path).ok()) + .map_or(0, |metadata| metadata.len()); + let alias_growth = (bytes.len() as u64).saturating_sub(alias_old_len); + let row = format!("I {history_rel_path}\n"); + let required = bytes.len() as u64 + row.len() as u64 + alias_growth; + let granted = store.prepare_append_capacity(self.id, required)?; + if granted < required { + return Ok(None); + } + let history_parent = history_path + .parent() + .expect("history file should have a parent directory"); + fs::create_dir_all(history_parent).map_err(WorkerError::Io)?; + fs::write(&history_path, &bytes).map_err(WorkerError::Io)?; + store.record_append(self.id, bytes.len() as u64); + if let Some(old_path) = alias_old_path.as_ref() + && old_path != &alias_path + { + fs::remove_file(old_path).map_err(WorkerError::Io)?; + store.record_file_removal(self.id, alias_old_len); + } + let replace_old_len = if alias_old_path.as_ref() == Some(&alias_path) { + alias_old_len + } else { + 0 + }; + fs::write(&alias_path, &bytes).map_err(WorkerError::Io)?; + store.record_file_replace(self.id, replace_old_len, bytes.len() as u64); + if self.has_events_log() { + store.append_bundle_bytes(self.id, &self.paths.events_log, row.as_bytes())?; + } else { + self.pre_index_image_paths.push(history_rel_path); + } + self.next_image_number = image_number; + self.current_image_history_number = history_number; + self.history_image_count = self.history_image_count.saturating_add(1); + Ok(Some(ReplyItem::Image(image.clone()))) + } + + fn apply_omission(&mut self, store: &mut OutputStore) -> Result<(), WorkerError> { + self.omitted_tail = true; + if self.omission_recorded || !self.has_events_log() { + return Ok(()); + } + if self + .append_events_log_text(store, OUTPUT_BUNDLE_OMITTED_NOTICE)? + .is_some() + { + self.omission_recorded = true; + } + Ok(()) + } + + fn ensure_transcript(&self, _store: &mut OutputStore) -> Result<(), WorkerError> { + if self.paths.transcript.exists() { + return Ok(()); + } + std::fs::File::create(&self.paths.transcript).map_err(WorkerError::Io)?; + Ok(()) + } + + fn ensure_images_dir(&self) -> Result<(), WorkerError> { + if self.paths.images_dir.exists() { + return Ok(()); + } + fs::create_dir_all(&self.paths.images_dir).map_err(WorkerError::Io) + } + + fn materialize_events_log(&mut self, store: &mut OutputStore) -> Result<(), WorkerError> { + if self.has_events_log() { + return Ok(()); + } + let mut bytes = Vec::new(); + bytes.extend_from_slice(OUTPUT_BUNDLE_HEADER); + if self.has_text() { + bytes.extend_from_slice(self.backfill_text_row().as_bytes()); + } else { + for image_path in &self.pre_index_image_paths { + bytes.extend_from_slice(format!("I {image_path}\n").as_bytes()); + } + } + if self.omitted_tail { + bytes.extend_from_slice( + build_events_log_server_line(OUTPUT_BUNDLE_OMITTED_NOTICE).as_bytes(), + ); + self.omission_recorded = true; + } + let granted = store.prepare_append_capacity(self.id, bytes.len() as u64)?; + if granted < bytes.len() as u64 { + return Err(WorkerError::Protocol( + "output bundle could not materialize events.log within quota".to_string(), + )); + } + std::fs::File::create(&self.paths.events_log).map_err(WorkerError::Io)?; + store.append_bundle_bytes(self.id, &self.paths.events_log, &bytes)?; + self.pre_index_image_paths.clear(); + Ok(()) + } + + fn has_text(&self) -> bool { + self.transcript_bytes > 0 + } + + fn has_images(&self) -> bool { + self.next_image_number > 0 + } + + fn has_events_log(&self) -> bool { + self.paths.events_log.exists() + } + + fn backfill_text_row(&self) -> String { + format!( + "T lines=1-{} bytes=0-{}\n", + self.transcript_lines.max(1), + self.transcript_bytes + ) + } + + fn disclosure_path(&self) -> &Path { + if self.has_events_log() { + &self.paths.events_log + } else if self.has_text() { + &self.paths.transcript + } else if self.has_images() { + &self.paths.images_dir + } else { + &self.paths.dir + } + } + + fn image_path(&self, index: usize) -> PathBuf { + let stem = format!("{index:03}"); + for extension in ["png", "jpg", "jpeg", "gif", "webp", "svg"] { + let path = self.paths.images_dir.join(format!("{stem}.{extension}")); + if path.exists() { + return path; + } + } + self.paths.images_dir.join(format!("{stem}.png")) + } + + fn existing_image_alias_path(&self, index: usize) -> Option<PathBuf> { + let stem = format!("{index:03}"); + for extension in ["png", "jpg", "jpeg", "gif", "webp", "svg"] { + let path = self.paths.images_dir.join(format!("{stem}.{extension}")); + if path.exists() { + return Some(path); + } + } + None + } +} + +impl ResponseState { + fn finish_bundle(&mut self, active: ActiveOutputBundle) -> Result<(), WorkerError> { + if active.was_disclosed() { + return Ok(()); + } + self.output_store.remove_bundle(active.id) + } +} + +impl StagedTimeoutOutput { + fn from_items(items: &[ReplyItem]) -> Option<Self> { + let items = Self::retained_items(items); + (!items.is_empty()).then_some(Self { items }) + } + + fn extend(&mut self, items: &[ReplyItem]) { + self.items.extend(Self::retained_items(items)); + } + + fn image_count(&self) -> usize { + count_images(&self.items) + } + + fn worker_text_chars(&self) -> usize { + self.items + .iter() + .map(|item| match item { + ReplyItem::WorkerText { text, .. } => text.chars().count(), + _ => 0, + }) + .sum() + } + + fn has_retained_worker_output(&self) -> bool { + self.items + .iter() + .any(|item| matches!(item, ReplyItem::WorkerText { .. } | ReplyItem::Image(_))) + } + + fn retained_items(items: &[ReplyItem]) -> Vec<ReplyItem> { + items + .iter() + .filter(|item| matches!(item, ReplyItem::WorkerText { .. } | ReplyItem::Image(_))) + .cloned() + .collect() + } +} + +fn parse_limit_env<T>(name: &str, default: T) -> Result<T, WorkerError> +where + T: std::str::FromStr, + T::Err: std::fmt::Display, +{ + let Some(value) = std::env::var_os(name) else { + return Ok(default); + }; + let value = value.to_string_lossy(); + value + .parse::<T>() + .map_err(|err| WorkerError::Protocol(format!("invalid {name}: {err}"))) +} + +fn truncate_utf8_prefix(text: &str, limit_bytes: usize) -> &str { + let mut end = limit_bytes.min(text.len()); + while end > 0 && !text.is_char_boundary(end) { + end -= 1; + } + &text[..end] +} + +fn build_events_log_server_line(text: &str) -> String { + let escaped = serde_json::to_string(text).unwrap_or_else(|_| "\"<server_text>\"".to_string()); + format!("S {escaped}\n") +} + +fn omission_event_line_len() -> usize { + build_events_log_server_line(OUTPUT_BUNDLE_OMITTED_NOTICE).len() +} + +/// Normalizes one worker reply into renderable items while preserving the split between +/// worker-originated transcript text and inline-only server notices. +fn prepare_reply_material(reply: WorkerReply, detached_prefix_item_count: usize) -> ReplyMaterial { + let (contents, is_error, error_code) = match reply { WorkerReply::Output { contents, is_error, - error_code: _, + error_code, prompt: _, prompt_variants: _, - } => (contents, is_error), + } => (contents, is_error, error_code), }; - let contents = collapse_image_updates(contents); - let contents = contents - .into_iter() - .map(|content| match content { - WorkerContent::ContentText { text, .. } => { - Content::text(normalize_error_prompt(text, is_error)) + + let mut bundle_items = Vec::with_capacity(contents.len()); + let mut worker_text = String::new(); + let mut detached_prefix_items = Vec::new(); + let mut detached_prefix_worker_text = String::new(); + let mut reply_bundle_items = Vec::new(); + let mut reply_worker_text = String::new(); + + for (index, content) in contents.into_iter().enumerate() { + let is_detached_prefix = index < detached_prefix_item_count; + match content { + WorkerContent::ContentText { + text, + origin, + stream, + } => { + let text = if matches!(origin, ContentOrigin::Worker) { + normalize_error_prompt(text, is_error) + } else { + text + }; + if text.is_empty() { + continue; + } + let item = match origin { + ContentOrigin::Worker => { + worker_text.push_str(&text); + if is_detached_prefix { + detached_prefix_worker_text.push_str(&text); + } else { + reply_worker_text.push_str(&text); + } + ReplyItem::worker_text(text, stream) + } + ContentOrigin::Server => ReplyItem::server_text(text, stream), + }; + if is_detached_prefix { + detached_prefix_items.push(item.clone()); + } else { + reply_bundle_items.push(item.clone()); + } + bundle_items.push(item); } WorkerContent::ContentImage { data, mime_type, - id, + id: _, is_new, - } => content_image_with_meta(data, mime_type, id, is_new), + } => { + let item = ReplyItem::Image(ReplyImage { + data, + mime_type, + is_new, + }); + if is_detached_prefix { + detached_prefix_items.push(item.clone()); + } else { + reply_bundle_items.push(item.clone()); + } + bundle_items.push(item); + } + } + } + + let inline_items = collapse_image_updates(bundle_items.clone()); + let detached_prefix_inline_items = collapse_image_updates(detached_prefix_items.clone()); + let reply_inline_items = collapse_image_updates(reply_bundle_items.clone()); + + ReplyMaterial { + inline_items, + bundle_items, + worker_text, + detached_prefix_items, + detached_prefix_inline_items, + detached_prefix_worker_text, + reply_inline_items, + reply_bundle_items, + reply_worker_text, + is_error, + error_code, + } +} + +pub(crate) fn finalize_batch(mut contents: Vec<Content>, is_error: bool) -> CallToolResult { + ensure_nonempty_contents(&mut contents); + let _ = is_error; + CallToolResult::success(contents) +} + +pub(crate) fn strip_text_stream_meta(result: &mut CallToolResult) { + for item in &mut result.content { + let RawContent::Text(text) = &mut item.raw else { + continue; + }; + let Some(meta) = &mut text.meta else { + continue; + }; + meta.remove(TEXT_STREAM_META_KEY); + if meta.is_empty() { + text.meta = None; + } + } +} + +fn materialize_items(items: Vec<ReplyItem>) -> Vec<Content> { + items + .into_iter() + .map(|item| match item { + ReplyItem::WorkerText { text, stream } | ReplyItem::ServerText { text, stream } => { + content_text(text, stream) + } + ReplyItem::Image(image) => image_to_content(&image), }) - .collect(); + .collect() +} + +fn image_to_content(image: &ReplyImage) -> Content { + content_image(image.data.clone(), image.mime_type.clone()) +} + +pub(crate) fn text_stream_from_content(content: &Content) -> Option<TextStream> { + let RawContent::Text(text) = &content.raw else { + return None; + }; + text.meta.as_ref().and_then(text_stream_from_meta) +} + +fn content_text(text: String, stream: TextStream) -> Content { + RawContent::Text(RawTextContent { + text, + meta: text_stream_meta(stream), + }) + .no_annotation() +} + +fn text_stream_meta(stream: TextStream) -> Option<Meta> { + if !matches!(stream, TextStream::Stderr) { + return None; + } + let mut meta = Meta::new(); + meta.insert( + TEXT_STREAM_META_KEY.to_string(), + Value::String("stderr".to_string()), + ); + Some(meta) +} + +fn text_stream_from_meta(meta: &Meta) -> Option<TextStream> { + match meta.get(TEXT_STREAM_META_KEY).and_then(Value::as_str) { + Some("stderr") => Some(TextStream::Stderr), + Some("stdout") => Some(TextStream::Stdout), + _ => None, + } +} + +fn count_images(items: &[ReplyItem]) -> usize { + items + .iter() + .filter(|item| matches!(item, ReplyItem::Image(_))) + .count() +} + +fn worker_text_from_items(items: &[ReplyItem]) -> String { + let mut out = String::new(); + for item in items { + if let ReplyItem::WorkerText { text, .. } = item { + out.push_str(text); + } + } + out +} + +fn compact_text_bundle_items( + items: Vec<ReplyItem>, + worker_text: &str, + bundle: &ActiveOutputBundle, +) -> Vec<Content> { + let preview = build_preview( + worker_text, + Some(bundle.disclosure_path()), + bundle.omitted_tail, + ); + let mut out = Vec::new(); + let mut worker_inserted = false; + for item in items { + match item { + ReplyItem::WorkerText { .. } => { + if !worker_inserted { + out.push(Content::text(preview.clone())); + worker_inserted = true; + } + } + ReplyItem::ServerText { text, stream } => out.push(content_text(text, stream)), + ReplyItem::Image(image) => out.push(image_to_content(&image)), + } + } + if !worker_inserted { + out.insert(0, Content::text(preview)); + } + out +} + +fn compact_text_without_bundle_items(items: Vec<ReplyItem>, worker_text: &str) -> Vec<Content> { + let preview = build_preview(worker_text, None, false); + let mut out = Vec::new(); + let mut worker_inserted = false; + for item in items { + match item { + ReplyItem::WorkerText { .. } => { + if !worker_inserted { + out.push(Content::text(preview.clone())); + worker_inserted = true; + } + } + ReplyItem::ServerText { text, stream } => out.push(content_text(text, stream)), + ReplyItem::Image(image) => out.push(image_to_content(&image)), + } + } + out +} + +fn compact_output_bundle_items(items: &[ReplyItem], bundle: &ActiveOutputBundle) -> Vec<Content> { + let first_image_idx = items + .iter() + .position(|item| matches!(item, ReplyItem::Image(_))); + let last_image_idx = items + .iter() + .rposition(|item| matches!(item, ReplyItem::Image(_))); + let mut out = Vec::new(); + let (first_anchor, last_anchor) = match bundle.next_image_number { + 0 => (None, None), + 1 => (load_output_bundle_image_content(bundle, 1), None), + _ => ( + load_output_bundle_history_image_content(bundle, 1, 1), + load_output_bundle_image_content(bundle, bundle.next_image_number), + ), + }; + let displayed_anchor_count = + usize::from(first_anchor.is_some()) + usize::from(last_anchor.is_some()); + + let head_text = collect_prefix_text( + items, + first_image_idx.unwrap_or(items.len()), + HEAD_TEXT_BUDGET, + ); + if !head_text.is_empty() { + out.push(Content::text(head_text.clone())); + } + if let Some(image) = first_anchor { + out.push(image); + } + out.push(Content::text(build_output_bundle_notice( + bundle, + displayed_anchor_count, + ))); + let pre_last_text = if last_image_idx == first_image_idx { + collect_non_overlapping_suffix_text_before( + items, + last_image_idx, + &head_text, + PRE_LAST_TEXT_BUDGET, + ) + } else { + collect_suffix_text_before(items, last_image_idx, PRE_LAST_TEXT_BUDGET) + }; + if !pre_last_text.is_empty() { + out.push(Content::text(pre_last_text)); + } + if let Some(image) = last_anchor { + out.push(image); + } + let post_last_text = collect_prefix_text_after(items, last_image_idx, POST_LAST_TEXT_BUDGET); + if !post_last_text.is_empty() { + out.push(Content::text(post_last_text)); + } + out +} + +fn materialize_items_with_output_bundle_notice( + items: Vec<ReplyItem>, + bundle: &ActiveOutputBundle, + displayed_anchor_count: usize, +) -> Vec<Content> { + let mut out = materialize_items(items); + out.push(Content::text(build_output_bundle_notice( + bundle, + displayed_anchor_count, + ))); + out +} + +fn compact_output_without_bundle_items(items: &[ReplyItem]) -> Vec<Content> { + let first_image_idx = items + .iter() + .position(|item| matches!(item, ReplyItem::Image(_))); + let last_image_idx = items + .iter() + .rposition(|item| matches!(item, ReplyItem::Image(_))); + let mut out = Vec::new(); + + let head_text = collect_prefix_text( + items, + first_image_idx.unwrap_or(items.len()), + HEAD_TEXT_BUDGET, + ); + if !head_text.is_empty() { + out.push(Content::text(head_text.clone())); + } + if let Some(index) = first_image_idx + && let ReplyItem::Image(image) = &items[index] + { + out.push(image_to_content(image)); + } + out.push(Content::text(build_output_bundle_unavailable_notice( + count_images(items), + ))); + let pre_last_text = if last_image_idx == first_image_idx { + collect_non_overlapping_suffix_text_before( + items, + last_image_idx, + &head_text, + PRE_LAST_TEXT_BUDGET, + ) + } else { + collect_suffix_text_before(items, last_image_idx, PRE_LAST_TEXT_BUDGET) + }; + if !pre_last_text.is_empty() { + out.push(Content::text(pre_last_text)); + } + if let Some(index) = last_image_idx + && Some(index) != first_image_idx + && let ReplyItem::Image(image) = &items[index] + { + out.push(image_to_content(image)); + } + let post_last_text = collect_prefix_text_after(items, last_image_idx, POST_LAST_TEXT_BUDGET); + if !post_last_text.is_empty() { + out.push(Content::text(post_last_text)); + } + out +} + +fn render_active_bundle_contents( + output_store: &mut OutputStore, + active: &mut ActiveOutputBundle, + bundle_items: &[ReplyItem], + inline_items: &[ReplyItem], + spill_worker_text_chars: usize, +) -> Result<Vec<Content>, WorkerError> { + let append = active.append_items(output_store, bundle_items)?; + let retained_image_count = count_images(&append.retained_items); + let retained_worker_text = worker_text_from_items(&append.retained_items); + let has_incremental_content = !append.retained_items.is_empty(); + let image_bundle_still_needed = active.next_image_number > 0 + && should_use_output_bundle(active.history_image_count, spill_worker_text_chars); + + if append.omitted_this_reply { + active.disclosed = true; + if active.next_image_number > 0 { + Ok(compact_output_bundle_items(&append.retained_items, active)) + } else { + Ok(compact_text_bundle_items( + append.retained_items.clone(), + &retained_worker_text, + active, + )) + } + } else if retained_image_count > 0 && image_bundle_still_needed { + active.disclosed = true; + Ok(compact_output_bundle_items(&append.retained_items, active)) + } else if text_should_spill(spill_worker_text_chars) { + active.disclosed = true; + Ok(compact_text_bundle_items( + append.retained_items.clone(), + &retained_worker_text, + active, + )) + } else if active.was_disclosed() && image_bundle_still_needed && has_incremental_content { + active.disclosed = true; + Ok(materialize_items_with_output_bundle_notice( + inline_items.to_vec(), + active, + 0, + )) + } else { + Ok(materialize_items(inline_items.to_vec())) + } +} + +fn compact_items_without_output_bundle( + bundle_items: &[ReplyItem], + inline_items: &[ReplyItem], + worker_text: &str, +) -> Vec<Content> { + let image_count = count_images(bundle_items); + if image_count > 0 && should_use_output_bundle(image_count, worker_text.chars().count()) { + return compact_output_without_bundle_items(bundle_items); + } + if text_should_spill(worker_text.chars().count()) { + return compact_text_without_bundle_items(inline_items.to_vec(), worker_text); + } + materialize_items(inline_items.to_vec()) +} + +fn compact_detached_prefix_without_output_bundle(material: &ReplyMaterial) -> Vec<Content> { + compact_items_without_output_bundle( + &material.detached_prefix_items, + &material.detached_prefix_inline_items, + &material.detached_prefix_worker_text, + ) +} + +fn render_reply_items( + output_store: &mut OutputStore, + reply_bundle_items: &[ReplyItem], + reply_inline_items: &[ReplyItem], + reply_worker_text: &str, + protected_bundle_id: Option<u64>, +) -> Vec<Content> { + let reply_image_count = count_images(reply_bundle_items); + if reply_image_count > 0 + && should_use_output_bundle(reply_image_count, reply_worker_text.chars().count()) + { + return compact_reply_items_with_new_bundle( + output_store, + reply_bundle_items, + reply_inline_items, + reply_worker_text, + false, + protected_bundle_id, + ); + } + if text_should_spill(reply_worker_text.chars().count()) { + return compact_reply_items_with_new_bundle( + output_store, + reply_bundle_items, + reply_inline_items, + reply_worker_text, + true, + protected_bundle_id, + ); + } + materialize_items(reply_inline_items.to_vec()) +} + +fn compact_reply_items_with_new_bundle( + output_store: &mut OutputStore, + reply_bundle_items: &[ReplyItem], + reply_inline_items: &[ReplyItem], + reply_worker_text: &str, + text_only: bool, + protected_bundle_id: Option<u64>, +) -> Vec<Content> { + match output_store.new_bundle_preserving(protected_bundle_id) { + Ok(mut bundle) => match bundle.append_items(output_store, reply_bundle_items) { + Ok(append) => { + if text_only { + let retained_worker_text = worker_text_from_items(&append.retained_items); + compact_text_bundle_items(append.retained_items, &retained_worker_text, &bundle) + } else { + compact_output_bundle_items(&append.retained_items, &bundle) + } + } + Err(err) => { + eprintln!("dropping output-bundled content after output-bundle error: {err}"); + if let Err(cleanup_err) = output_store.remove_bundle(bundle.id) { + eprintln!( + "dropping closed output bundle after output-bundle error: {cleanup_err}" + ); + } + if text_only { + compact_text_without_bundle_items( + reply_inline_items.to_vec(), + reply_worker_text, + ) + } else { + compact_output_without_bundle_items(reply_inline_items) + } + } + }, + Err(err) => { + eprintln!("dropping output-bundle setup after output-bundle error: {err}"); + if text_only { + compact_text_without_bundle_items(reply_inline_items.to_vec(), reply_worker_text) + } else { + compact_output_without_bundle_items(reply_inline_items) + } + } + } +} + +fn should_spill_detached_prefix_only(material: &ReplyMaterial) -> bool { + should_spill_detached_prefix(material) + && !should_use_output_bundle( + count_images(&material.reply_bundle_items), + material.reply_worker_text.chars().count(), + ) +} + +fn should_spill_detached_prefix(material: &ReplyMaterial) -> bool { + !material.detached_prefix_items.is_empty() + && count_images(&material.detached_prefix_items) == 0 + && text_should_spill(material.detached_prefix_worker_text.chars().count()) +} + +fn should_use_output_bundle(image_count: usize, worker_text_chars: usize) -> bool { + image_count >= IMAGE_OUTPUT_BUNDLE_THRESHOLD || text_should_spill(worker_text_chars) +} + +fn text_should_spill(worker_text_chars: usize) -> bool { + worker_text_chars > INLINE_TEXT_HARD_SPILL_THRESHOLD +} + +fn build_output_bundle_notice( + bundle: &ActiveOutputBundle, + displayed_anchor_count: usize, +) -> String { + let omitted = if bundle.omitted_tail { + "; later content omitted" + } else { + "" + }; + let path = bundle.disclosure_path(); + let label = if bundle.has_events_log() { + "ordered output bundle index" + } else if bundle.has_images() && !bundle.has_text() { + "output bundle images" + } else { + "full output" + }; + match displayed_anchor_count { + 0 => format!( + "...[middle truncated; {label}: {}{}]...", + path.display(), + omitted + ), + 1 if bundle.next_image_number <= 1 && bundle.history_image_count <= 1 => format!( + "...[middle truncated; first image shown inline; {label}: {}{}]...", + path.display(), + omitted + ), + 1 => format!( + "...[middle truncated; one image shown inline; {label}: {}{}]...", + path.display(), + omitted + ), + _ => format!( + "...[middle truncated; first and last images shown inline; {label}: {}{}]...", + path.display(), + omitted + ), + } +} + +fn build_output_bundle_unavailable_notice(image_count: usize) -> String { + match image_count { + 0 => "...[middle truncated; output bundle unavailable]...".to_string(), + 1 => "...[middle truncated; first image shown inline; output bundle unavailable]..." + .to_string(), + _ => "...[middle truncated; first and last images shown inline; output bundle unavailable]..." + .to_string(), + } +} + +fn collect_prefix_text(items: &[ReplyItem], end_exclusive: usize, budget: usize) -> String { + let mut out = String::new(); + for item in items.iter().take(end_exclusive) { + let Some(text) = item_text(item) else { + continue; + }; + push_prefix_text(&mut out, text, budget); + if out.chars().count() >= budget { + break; + } + } + out +} + +fn collect_suffix_text_before(items: &[ReplyItem], index: Option<usize>, budget: usize) -> String { + let Some(index) = index else { + return String::new(); + }; + let mut parts = Vec::new(); + let mut remaining = budget; + for item in items[..index].iter().rev() { + let Some(text) = item_text(item) else { + continue; + }; + let suffix = take_suffix_chars(text, remaining); + if suffix.is_empty() { + continue; + } + remaining = remaining.saturating_sub(suffix.chars().count()); + parts.push(suffix); + if remaining == 0 { + break; + } + } + parts.reverse(); + parts.concat() +} + +fn collect_non_overlapping_suffix_text_before( + items: &[ReplyItem], + index: Option<usize>, + head_text: &str, + budget: usize, +) -> String { + let tail_text = collect_suffix_text_before(items, index, budget); + let Some(index) = index else { + return tail_text; + }; + let total_chars = items[..index] + .iter() + .filter_map(item_text) + .map(|text| text.chars().count()) + .sum::<usize>(); + let overlap = head_text + .chars() + .count() + .saturating_add(tail_text.chars().count()) + .saturating_sub(total_chars); + drop_prefix_chars(&tail_text, overlap) +} + +fn collect_prefix_text_after(items: &[ReplyItem], index: Option<usize>, budget: usize) -> String { + let Some(index) = index else { + return String::new(); + }; + let start = index.saturating_add(1); + collect_prefix_text(&items[start..], items[start..].len(), budget) +} + +fn item_text(item: &ReplyItem) -> Option<&str> { + match item { + ReplyItem::WorkerText { text, .. } | ReplyItem::ServerText { text, .. } => Some(text), + ReplyItem::Image(_) => None, + } +} + +fn drop_prefix_chars(text: &str, count: usize) -> String { + if count == 0 { + return text.to_string(); + } + text.chars().skip(count).collect() +} + +fn push_prefix_text(out: &mut String, text: &str, budget: usize) { + if budget == 0 { + return; + } + let used = out.chars().count(); + let remaining = budget.saturating_sub(used); + if remaining == 0 { + return; + } + let prefix = take_prefix_chars(text, remaining); + out.push_str(&prefix); +} + +fn take_prefix_chars(text: &str, limit: usize) -> String { + text.chars().take(limit).collect() +} + +fn take_suffix_chars(text: &str, limit: usize) -> String { + let chars: Vec<char> = text.chars().collect(); + let start = chars.len().saturating_sub(limit); + chars[start..].iter().collect() +} + +fn append_text_line_span( + text: &str, + transcript_lines: usize, + transcript_has_partial_line: bool, +) -> (usize, usize, usize, bool) { + assert!(!text.is_empty(), "text line spans require non-empty text"); + let newline_count = text.bytes().filter(|byte| *byte == b'\n').count(); + let start_line = if transcript_lines == 0 { + 1 + } else if transcript_has_partial_line { + transcript_lines + } else { + transcript_lines.saturating_add(1) + }; + let next_line_count = if transcript_has_partial_line { + transcript_lines + .saturating_add(newline_count) + .saturating_add(usize::from(!text.ends_with('\n'))) + .saturating_sub(1) + } else { + transcript_lines + .saturating_add(newline_count) + .saturating_add(usize::from(!text.ends_with('\n'))) + } + .max(start_line); + ( + start_line, + next_line_count, + next_line_count, + !text.ends_with('\n'), + ) +} + +fn image_extension(mime_type: &str) -> &str { + match mime_type.trim().to_ascii_lowercase().as_str() { + "image/png" => "png", + "image/jpeg" | "image/jpg" => "jpg", + "image/gif" => "gif", + "image/webp" => "webp", + "image/svg+xml" => "svg", + _ => "png", + } +} + +fn mime_type_from_path(path: &Path) -> String { + match path + .extension() + .and_then(|ext| ext.to_str()) + .unwrap_or_default() + .to_ascii_lowercase() + .as_str() + { + "png" => "image/png".to_string(), + "jpg" | "jpeg" => "image/jpeg".to_string(), + "gif" => "image/gif".to_string(), + "webp" => "image/webp".to_string(), + "svg" => "image/svg+xml".to_string(), + _ => "image/png".to_string(), + } +} + +fn load_output_bundle_image_content(bundle: &ActiveOutputBundle, index: usize) -> Option<Content> { + let path = bundle.image_path(index); + load_output_bundle_image_content_at_path(&path) +} + +fn load_output_bundle_history_image_content( + bundle: &ActiveOutputBundle, + image_index: usize, + history_index: usize, +) -> Option<Content> { + let stem = format!("images/history/{image_index:03}/{history_index:03}"); + for extension in ["png", "jpg", "jpeg", "gif", "webp", "svg"] { + let path = bundle.paths.dir.join(format!("{stem}.{extension}")); + if path.exists() { + return load_output_bundle_image_content_at_path(&path); + } + } + load_output_bundle_image_content(bundle, image_index) +} + +fn load_output_bundle_image_content_at_path(path: &Path) -> Option<Content> { + let bytes = match fs::read(path) { + Ok(bytes) => bytes, + Err(err) => { + eprintln!( + "skipping unreadable output bundle image {}: {err}", + path.display() + ); + return None; + } + }; + let mime_type = mime_type_from_path(path); + let data = STANDARD.encode(bytes); + Some(content_image(data, mime_type)) +} + +fn build_preview(text: &str, path: Option<&Path>, omitted_tail: bool) -> String { + if omitted_tail && text.chars().count() <= INLINE_TEXT_BUDGET { + return build_short_preview(text, path); + } + if let Some(preview) = build_line_preview(text, path, omitted_tail) { + return preview; + } + build_char_preview(text, path, omitted_tail) +} + +fn build_line_preview(text: &str, path: Option<&Path>, omitted_tail: bool) -> Option<String> { + if !text.contains('\n') { + return None; + } + let lines: Vec<&str> = text.split_inclusive('\n').collect(); + if lines.len() < 3 { + return None; + } + + let head_budget = INLINE_TEXT_BUDGET * 2 / 3; + let tail_budget = INLINE_TEXT_BUDGET / 3; + + let mut head_count = 0usize; + let mut head_len = 0usize; + while head_count < lines.len() { + let next = head_len + lines[head_count].chars().count(); + if next > head_budget && head_count > 0 { + break; + } + head_len = next; + head_count += 1; + } + + let mut tail_count = 0usize; + let mut tail_len = 0usize; + while tail_count < lines.len().saturating_sub(head_count) { + let line = lines[lines.len() - 1 - tail_count]; + let next = tail_len + line.chars().count(); + if next > tail_budget && tail_count > 0 { + break; + } + tail_len = next; + tail_count += 1; + } + + if head_count + tail_count >= lines.len() || head_count == 0 || tail_count == 0 { + return None; + } + + let head = lines[..head_count].concat(); + let tail = lines[lines.len() - tail_count..].concat(); + let omitted = if omitted_tail { + "; later content omitted" + } else { + "" + }; + let storage = preview_storage_clause(path); + let marker = format!( + "...[middle truncated; shown lines 1-{head_count} and {}-{} of {} total; {storage}{omitted}]...", + lines.len() - tail_count + 1, + lines.len(), + lines.len(), + ); - (contents, is_error) + Some(format!("{head}{marker}\n{tail}")) } -pub(crate) fn finalize_batch(mut contents: Vec<Content>, is_error: bool) -> CallToolResult { - ensure_nonempty_contents(&mut contents); - // Preserve backend error detection (for prompt normalization, paging decisions, etc.) but - // do not map it to MCP tool errors. - let _ = is_error; - CallToolResult::success(contents) +fn build_char_preview(text: &str, path: Option<&Path>, omitted_tail: bool) -> String { + let chars: Vec<char> = text.chars().collect(); + let total = chars.len(); + let head_chars = INLINE_TEXT_BUDGET * 2 / 3; + let tail_chars = INLINE_TEXT_BUDGET / 3; + let head_end = head_chars.min(total); + let tail_start = total.saturating_sub(tail_chars); + let head: String = chars[..head_end].iter().collect(); + let tail: String = chars[tail_start..].iter().collect(); + let omitted = if omitted_tail { + "; later content omitted" + } else { + "" + }; + let storage = preview_storage_clause(path); + let marker = format!( + "...[middle truncated; shown chars 1-{head_end} and {}-{} of {} total; {storage}{omitted}]...", + tail_start.saturating_add(1), + total, + total, + ); + format!("{head}\n{marker}\n{tail}") +} + +fn build_short_preview(text: &str, path: Option<&Path>) -> String { + let mut out = String::new(); + out.push_str(text); + if !text.is_empty() && !text.ends_with('\n') { + out.push('\n'); + } + out.push_str(&format!( + "...[{}; later content omitted]...", + preview_storage_clause(path) + )); + out +} + +fn preview_storage_clause(path: Option<&Path>) -> String { + match path { + Some(path) => format!("full output: {}", path.display()), + None => "output bundle unavailable".to_string(), + } } fn ensure_nonempty_contents(contents: &mut Vec<Content>) { @@ -46,14 +2456,14 @@ fn ensure_nonempty_contents(contents: &mut Vec<Content>) { } } -fn collapse_image_updates(contents: Vec<WorkerContent>) -> Vec<WorkerContent> { - let mut group_for_index: Vec<Option<usize>> = vec![None; contents.len()]; +fn collapse_image_updates(items: Vec<ReplyItem>) -> Vec<ReplyItem> { + let mut group_for_index: Vec<Option<usize>> = vec![None; items.len()]; let mut last_in_group: Vec<usize> = Vec::new(); let mut current_group: Option<usize> = None; - for (idx, content) in contents.iter().enumerate() { - if let WorkerContent::ContentImage { is_new, .. } = content { - if *is_new || current_group.is_none() { + for (idx, item) in items.iter().enumerate() { + if let ReplyItem::Image(image) = item { + if image.is_new || current_group.is_none() { current_group = Some(last_in_group.len()); last_in_group.push(idx); } @@ -63,15 +2473,15 @@ fn collapse_image_updates(contents: Vec<WorkerContent>) -> Vec<WorkerContent> { } } - contents + items .into_iter() .enumerate() - .filter_map(|(idx, content)| match &content { - WorkerContent::ContentImage { .. } => match group_for_index[idx] { - Some(group) if last_in_group.get(group).copied() == Some(idx) => Some(content), + .filter_map(|(idx, item)| match &item { + ReplyItem::Image(_) => match group_for_index[idx] { + Some(group) if last_in_group.get(group).copied() == Some(idx) => Some(item), _ => None, }, - _ => Some(content), + _ => Some(item), }) .collect() } @@ -103,47 +2513,1937 @@ fn normalize_error_prompt(text: String, is_error: bool) -> String { if normalized_any { normalized } else { text } } -fn content_image_with_meta(data: String, mime_type: String, id: String, is_new: bool) -> Content { - let mut meta = Meta::new(); - let image_id = normalize_plot_id(&id); - meta.0.insert( - "mcpConsole".to_string(), - json!({ - "imageId": image_id, - "isNewPage": is_new, - }), - ); +fn content_image(data: String, mime_type: String) -> Content { RawContent::Image(RawImageContent { data, mime_type, - meta: Some(meta), + meta: None, }) .no_annotation() } -fn normalize_plot_id(raw: &str) -> String { - let Some(rest) = raw.strip_prefix("plot-") else { - return raw.to_string(); - }; - let mut parts = rest.splitn(2, '-'); - let _pid = parts.next(); - let Some(counter) = parts.next() else { - return raw.to_string(); +#[cfg(test)] +mod tests { + use base64::Engine as _; + use std::fs; + use std::io; + use std::path::PathBuf; + + use rmcp::model::RawContent; + use tempfile::Builder; + + use super::{ + OutputStore, ReplyImage, ReplyItem, ResponseState, TimeoutBundleReuse, + compact_output_bundle_items, normalize_error_prompt, }; - if counter.chars().all(|ch| ch.is_ascii_digit()) { - format!("plot-{counter}") - } else { - raw.to_string() + use crate::worker_process::WorkerError; + use crate::worker_protocol::{TextStream, WorkerContent, WorkerErrorCode, WorkerReply}; + + fn result_text(result: &rmcp::model::CallToolResult) -> String { + result + .content + .iter() + .filter_map(|item| match &item.raw { + RawContent::Text(text) => Some(text.text.as_str()), + _ => None, + }) + .collect() } -} -#[cfg(test)] -mod tests { - use super::normalize_error_prompt; + fn result_images(result: &rmcp::model::CallToolResult) -> Vec<Vec<u8>> { + result + .content + .iter() + .filter_map(|item| match &item.raw { + RawContent::Image(image) => base64::engine::general_purpose::STANDARD + .decode(image.data.as_bytes()) + .ok(), + _ => None, + }) + .collect() + } + + fn disclosed_path(text: &str, suffix: &str) -> Option<PathBuf> { + let end = text.find(suffix)?.saturating_add(suffix.len()); + let start = text[..end] + .rfind(|ch: char| ch.is_whitespace() || matches!(ch, '"' | '\'' | '[' | '(')) + .map_or(0, |idx| idx.saturating_add(1)); + Some(PathBuf::from(&text[start..end])) + } + + fn disclosed_paths(text: &str, suffix: &str) -> Vec<PathBuf> { + let mut paths = Vec::new(); + let mut offset = 0; + while let Some(relative_end) = text[offset..].find(suffix) { + let end = offset + .saturating_add(relative_end) + .saturating_add(suffix.len()); + let start = text[..end] + .rfind(|ch: char| ch.is_whitespace() || matches!(ch, '"' | '\'' | '[' | '(')) + .map_or(0, |idx| idx.saturating_add(1)); + paths.push(PathBuf::from(&text[start..end])); + offset = end; + } + paths + } + + fn fail_output_store_root_creation() -> io::Result<tempfile::TempDir> { + Err(io::Error::other("simulated tempdir failure")) + } + + fn output_store_root_with_text_conflict() -> io::Result<tempfile::TempDir> { + let root = Builder::new().prefix("mcp-repl-output-test-").tempdir()?; + fs::create_dir_all(root.path().join("output-0001/transcript.txt"))?; + Ok(root) + } + + fn worker_reply( + contents: Vec<WorkerContent>, + error_code: Option<WorkerErrorCode>, + ) -> WorkerReply { + WorkerReply::Output { + contents, + is_error: false, + error_code, + prompt: None, + prompt_variants: None, + } + } #[test] fn compact_search_cards_do_not_trigger_error_prompt_normalization() { let text = "[pager] search for `Error` @10\n[match] Error: boom\n".to_string(); assert_eq!(normalize_error_prompt(text.clone(), true), text); } + + #[test] + fn events_log_text_rows_preserve_partial_line_state_across_images() { + let mut store = OutputStore::new().expect("output store should initialize"); + let mut bundle = store.new_bundle().expect("bundle should initialize"); + + let first = bundle + .append_worker_text(&mut store, "a", TextStream::Stdout) + .expect("first worker text should append"); + assert!(matches!(first, Some(ReplyItem::WorkerText { text, .. }) if text == "a")); + + let image = ReplyImage { + data: base64::engine::general_purpose::STANDARD.encode([0_u8]), + mime_type: "image/png".to_string(), + is_new: true, + }; + let retained_image = bundle + .append_image(&mut store, &image) + .expect("image should append"); + assert!(matches!(retained_image, Some(ReplyItem::Image(_)))); + + let second = bundle + .append_worker_text(&mut store, "b\n", TextStream::Stdout) + .expect("second worker text should append"); + assert!(matches!(second, Some(ReplyItem::WorkerText { text, .. }) if text == "b\n")); + + let transcript = std::fs::read_to_string(&bundle.paths.transcript) + .expect("transcript should be readable"); + let events = std::fs::read_to_string(&bundle.paths.events_log) + .expect("events log should be readable"); + + assert_eq!(transcript, "ab\n"); + assert!( + events.contains("T lines=1-1 bytes=0-1\n"), + "expected first text row to cover the initial partial line, got: {events:?}" + ); + assert!( + events.contains("T lines=1-1 bytes=1-3\n"), + "expected text after the image to continue the same line, got: {events:?}" + ); + } + + #[test] + fn detached_prefix_spills_without_swallowing_follow_up_reply() { + let mut state = ResponseState::new().expect("response state should initialize"); + let detached_prefix = format!( + "IDLE_START\n{}\nIDLE_END\n", + "x".repeat(super::INLINE_TEXT_HARD_SPILL_THRESHOLD + 200) + ); + let follow_up = "FOLLOWUP_OK\n".to_string(); + let result = state.finalize_worker_result( + Ok(worker_reply( + vec![ + WorkerContent::worker_stdout(detached_prefix), + WorkerContent::worker_stdout(follow_up.clone()), + ], + None, + )), + false, + TimeoutBundleReuse::None, + 1, + ); + + let text = result_text(&result); + let transcript_path = disclosed_path(&text, "transcript.txt") + .unwrap_or_else(|| panic!("expected detached prefix transcript path, got: {text:?}")); + let transcript = fs::read_to_string(&transcript_path) + .unwrap_or_else(|err| panic!("expected transcript to be readable: {err}")); + + assert!( + text.contains(&follow_up), + "expected follow-up reply inline, got: {text:?}" + ); + assert!( + transcript.contains("IDLE_START") && transcript.contains("IDLE_END"), + "expected detached prefix transcript content, got: {transcript:?}" + ); + assert!( + !transcript.contains("FOLLOWUP_OK"), + "did not expect follow-up reply to be appended to detached prefix bundle: {transcript:?}" + ); + } + + #[test] + fn detached_prefix_timeout_poll_preserves_later_timeout_bundle_state() { + let mut state = ResponseState::new().expect("response state should initialize"); + let detached_prefix = format!( + "IDLE_START\n{}\nIDLE_END\n", + "x".repeat(super::INLINE_TEXT_HARD_SPILL_THRESHOLD + 200) + ); + let first_timeout_chunk = "FIRST_TIMEOUT\n".to_string(); + let first = state.finalize_worker_result( + Ok(worker_reply( + vec![ + WorkerContent::worker_stdout(detached_prefix), + WorkerContent::worker_stdout(first_timeout_chunk.clone()), + ], + Some(WorkerErrorCode::Timeout), + )), + true, + TimeoutBundleReuse::FullReply, + 1, + ); + + let first_text = result_text(&first); + let detached_transcript_path = disclosed_path(&first_text, "transcript.txt") + .unwrap_or_else(|| { + panic!("expected detached prefix transcript path, got: {first_text:?}") + }); + let detached_transcript = + fs::read_to_string(&detached_transcript_path).unwrap_or_else(|err| { + panic!("expected detached prefix transcript to be readable: {err}") + }); + + assert!( + first_text.contains(&first_timeout_chunk), + "expected the first timed-out chunk to stay inline, got: {first_text:?}" + ); + assert!( + state.has_active_timeout_bundle(), + "expected the timed-out poll to retain timeout state after detached-prefix compaction" + ); + assert!( + detached_transcript.contains("IDLE_START") && detached_transcript.contains("IDLE_END"), + "expected detached-prefix transcript content, got: {detached_transcript:?}" + ); + assert!( + !detached_transcript.contains(&first_timeout_chunk), + "did not expect the timed-out chunk in the detached-prefix transcript: {detached_transcript:?}" + ); + + let later_timeout_chunk = format!( + "SECOND_START\n{}\nSECOND_END\n", + "y".repeat(super::INLINE_TEXT_HARD_SPILL_THRESHOLD + 200) + ); + let second = state.finalize_worker_result( + Ok(worker_reply( + vec![WorkerContent::worker_stdout(later_timeout_chunk.clone())], + Some(WorkerErrorCode::Timeout), + )), + true, + TimeoutBundleReuse::FullReply, + 0, + ); + + let second_text = result_text(&second); + let timeout_transcript_path = disclosed_path(&second_text, "transcript.txt") + .unwrap_or_else(|| panic!("expected timeout transcript path, got: {second_text:?}")); + let timeout_transcript = fs::read_to_string(&timeout_transcript_path) + .unwrap_or_else(|err| panic!("expected timeout transcript to be readable: {err}")); + + assert_ne!( + timeout_transcript_path, detached_transcript_path, + "expected the later timeout spill to use a separate transcript path" + ); + assert!( + timeout_transcript.contains(&first_timeout_chunk), + "expected the later timeout transcript to backfill the first timed-out chunk, got: {timeout_transcript:?}" + ); + assert!( + timeout_transcript.contains("SECOND_START") + && timeout_transcript.contains("SECOND_END"), + "expected the later timeout transcript to include the new timed-out chunk, got: {timeout_transcript:?}" + ); + assert!( + !timeout_transcript.contains("IDLE_START"), + "did not expect detached-prefix output in the later timeout transcript: {timeout_transcript:?}" + ); + } + + #[test] + fn active_timeout_bundle_keeps_detached_prefix_on_same_path() { + let mut state = ResponseState::new().expect("response state should initialize"); + let mut bundle = state + .output_store + .new_bundle() + .expect("timeout bundle should initialize"); + let detached_prefix = format!( + "TAIL_START\n{}\nTAIL_END\n", + "x".repeat(super::INLINE_TEXT_HARD_SPILL_THRESHOLD + 200) + ); + bundle + .append_worker_text(&mut state.output_store, "HEAD\n", TextStream::Stdout) + .expect("existing timeout text should append"); + let transcript_path = bundle.paths.transcript.clone(); + state.active_timeout_bundle = Some(bundle); + + let result = state.finalize_worker_result( + Ok(worker_reply( + vec![ + WorkerContent::worker_stdout(detached_prefix), + WorkerContent::worker_stdout("NEW_TURN\n"), + ], + None, + )), + false, + TimeoutBundleReuse::FollowUpInput, + 1, + ); + + let text = result_text(&result); + let disclosed_path = disclosed_path(&text, "transcript.txt").unwrap_or_else(|| { + panic!("expected timeout bundle path in follow-up reply, got: {text:?}") + }); + let transcript = fs::read_to_string(&transcript_path) + .unwrap_or_else(|err| panic!("expected transcript to be readable: {err}")); + + assert!( + text.contains("NEW_TURN"), + "expected new request output inline, got: {text:?}" + ); + assert_eq!( + disclosed_path, transcript_path, + "expected follow-up disclosure to reuse the existing timeout bundle path" + ); + assert!( + transcript.contains("HEAD\nTAIL_START\n") && transcript.contains("TAIL_END\n"), + "expected detached prefix to stay on the existing timeout bundle path, got: {transcript:?}" + ); + assert!( + !transcript.contains("NEW_TURN"), + "did not expect new request output to append to the timeout bundle: {transcript:?}" + ); + } + + #[test] + fn large_follow_up_reply_still_compacts_after_detached_timeout_tail() { + let mut state = ResponseState::new().expect("response state should initialize"); + let mut bundle = state + .output_store + .new_bundle() + .expect("timeout bundle should initialize"); + bundle + .append_worker_text(&mut state.output_store, "HEAD\n", TextStream::Stdout) + .expect("existing timeout text should append"); + bundle.disclosed = true; + let timeout_transcript_path = bundle.paths.transcript.clone(); + state.active_timeout_bundle = Some(bundle); + + let large_follow_up = format!( + "FOLLOW_UP_START\n{}\nFOLLOW_UP_END\n", + "y".repeat(super::INLINE_TEXT_HARD_SPILL_THRESHOLD + 200) + ); + let result = state.finalize_worker_result( + Ok(worker_reply( + vec![ + WorkerContent::worker_stdout("TAIL\n"), + WorkerContent::worker_stdout(large_follow_up.clone()), + ], + None, + )), + false, + TimeoutBundleReuse::FollowUpInput, + 1, + ); + + let text = result_text(&result); + let follow_up_transcript_path = disclosed_path(&text, "transcript.txt") + .unwrap_or_else(|| panic!("expected oversized follow-up bundle path, got: {text:?}")); + let timeout_transcript = fs::read_to_string(&timeout_transcript_path) + .unwrap_or_else(|err| panic!("expected timeout transcript to be readable: {err}")); + let follow_up_transcript = fs::read_to_string(&follow_up_transcript_path) + .unwrap_or_else(|err| panic!("expected follow-up transcript to be readable: {err}")); + + assert_ne!( + follow_up_transcript_path, timeout_transcript_path, + "expected the large follow-up reply to use its own bundle path" + ); + assert!( + timeout_transcript.contains("HEAD\nTAIL\n"), + "expected detached timeout tail to stay on the timeout bundle path, got: {timeout_transcript:?}" + ); + assert!( + !timeout_transcript.contains("FOLLOW_UP_START"), + "did not expect fresh follow-up output on the timeout bundle path: {timeout_transcript:?}" + ); + assert!( + follow_up_transcript.contains("FOLLOW_UP_START") + && follow_up_transcript.contains("FOLLOW_UP_END"), + "expected follow-up transcript to contain the large fresh reply, got: {follow_up_transcript:?}" + ); + assert!( + !follow_up_transcript.contains("TAIL\n"), + "did not expect the detached timeout tail in the fresh follow-up bundle: {follow_up_transcript:?}" + ); + } + + #[test] + fn detached_prefix_and_large_follow_up_each_compact_on_follow_up_input() { + let mut state = ResponseState::new().expect("response state should initialize"); + let detached_prefix = format!( + "DETACHED_START\n{}\nDETACHED_END\n", + "x".repeat(super::INLINE_TEXT_HARD_SPILL_THRESHOLD + 200) + ); + let large_follow_up = format!( + "FOLLOW_UP_START\n{}\nFOLLOW_UP_END\n", + "y".repeat(super::INLINE_TEXT_HARD_SPILL_THRESHOLD + 200) + ); + let result = state.finalize_worker_result( + Ok(worker_reply( + vec![ + WorkerContent::worker_stdout(detached_prefix), + WorkerContent::worker_stdout(large_follow_up), + ], + None, + )), + false, + TimeoutBundleReuse::FollowUpInput, + 1, + ); + + let text = result_text(&result); + let transcript_paths = disclosed_paths(&text, "transcript.txt"); + assert_eq!( + transcript_paths.len(), + 2, + "expected detached prefix and large follow-up to disclose separate bundle paths, got: {text:?}" + ); + assert_ne!( + transcript_paths[0], transcript_paths[1], + "expected detached prefix and large follow-up to use separate bundle paths" + ); + + let detached_transcript = fs::read_to_string(&transcript_paths[0]).unwrap_or_else(|err| { + panic!("expected detached-prefix transcript to be readable: {err}") + }); + let follow_up_transcript = fs::read_to_string(&transcript_paths[1]) + .unwrap_or_else(|err| panic!("expected follow-up transcript to be readable: {err}")); + + assert!( + detached_transcript.contains("DETACHED_START") + && detached_transcript.contains("DETACHED_END"), + "expected detached-prefix transcript content, got: {detached_transcript:?}" + ); + assert!( + !detached_transcript.contains("FOLLOW_UP_START"), + "did not expect large follow-up output in detached-prefix transcript: {detached_transcript:?}" + ); + assert!( + follow_up_transcript.contains("FOLLOW_UP_START") + && follow_up_transcript.contains("FOLLOW_UP_END"), + "expected large follow-up transcript content, got: {follow_up_transcript:?}" + ); + assert!( + !follow_up_transcript.contains("DETACHED_START"), + "did not expect detached-prefix output in follow-up transcript: {follow_up_transcript:?}" + ); + } + + #[test] + fn detached_prefix_image_updates_collapse_on_follow_up_input() { + let mut state = ResponseState::new().expect("response state should initialize"); + let result = state.finalize_worker_result( + Ok(worker_reply( + vec![ + WorkerContent::ContentImage { + data: base64::engine::general_purpose::STANDARD.encode([0_u8]), + mime_type: "image/png".to_string(), + id: "plot-1".to_string(), + is_new: true, + }, + WorkerContent::ContentImage { + data: base64::engine::general_purpose::STANDARD.encode([1_u8]), + mime_type: "image/png".to_string(), + id: "plot-1".to_string(), + is_new: false, + }, + WorkerContent::ContentImage { + data: base64::engine::general_purpose::STANDARD.encode([2_u8]), + mime_type: "image/png".to_string(), + id: "plot-1".to_string(), + is_new: false, + }, + WorkerContent::worker_stdout("FOLLOW_UP_OK\n"), + ], + None, + )), + false, + TimeoutBundleReuse::FollowUpInput, + 3, + ); + + let text = result_text(&result); + let images = result_images(&result); + + assert!( + text.contains("FOLLOW_UP_OK"), + "expected follow-up reply inline, got: {text:?}" + ); + assert_eq!( + images.len(), + 1, + "expected collapsed detached-prefix image updates to keep one inline image" + ); + assert_eq!( + images[0], + vec![2_u8], + "expected the final detached-prefix image update to remain inline" + ); + assert!( + !text.contains("output bundle images"), + "did not expect a collapsed detached-prefix image update sequence to disclose a bundle, got: {text:?}" + ); + } + + #[test] + fn image_heavy_detached_prefix_spills_on_follow_up_input() { + let mut state = ResponseState::new().expect("response state should initialize"); + let mut contents: Vec<_> = (0..super::IMAGE_OUTPUT_BUNDLE_THRESHOLD) + .map(|index| WorkerContent::ContentImage { + data: base64::engine::general_purpose::STANDARD.encode([index as u8]), + mime_type: "image/png".to_string(), + id: format!("plot-{index}"), + is_new: true, + }) + .collect(); + contents.push(WorkerContent::worker_stdout("FOLLOW_UP_OK\n")); + + let result = state.finalize_worker_result( + Ok(worker_reply(contents, None)), + false, + TimeoutBundleReuse::FollowUpInput, + super::IMAGE_OUTPUT_BUNDLE_THRESHOLD, + ); + + let text = result_text(&result); + let images = result_images(&result); + + assert!( + text.contains("FOLLOW_UP_OK"), + "expected follow-up reply inline, got: {text:?}" + ); + assert!( + text.contains("output bundle images"), + "expected detached-prefix image burst to disclose an image bundle, got: {text:?}" + ); + assert_eq!( + images.len(), + 2, + "expected bundled detached-prefix image burst to keep only anchor images inline" + ); + assert_eq!( + images[0], + vec![0_u8], + "expected the first detached-prefix image to remain as the first inline anchor" + ); + assert_eq!( + images[1], + vec![(super::IMAGE_OUTPUT_BUNDLE_THRESHOLD - 1) as u8], + "expected the last detached-prefix image to remain as the last inline anchor" + ); + } + + #[test] + fn repeated_image_updates_spill_to_bundle_to_preserve_history() { + let mut state = ResponseState::new().expect("response state should initialize"); + let update_count = super::IMAGE_OUTPUT_BUNDLE_THRESHOLD; + let contents = (0..update_count) + .map(|index| WorkerContent::ContentImage { + data: base64::engine::general_purpose::STANDARD.encode([index as u8]), + mime_type: "image/png".to_string(), + id: "plot-1".to_string(), + is_new: index == 0, + }) + .collect(); + + let result = state.finalize_worker_result( + Ok(worker_reply(contents, None)), + false, + TimeoutBundleReuse::None, + 0, + ); + + let text = result_text(&result); + let images = result_images(&result); + assert!( + text.contains("output bundle images"), + "expected repeated image updates to disclose an image bundle, got: {text:?}" + ); + assert_eq!( + images.len(), + 1, + "expected repeated updates to one image to keep a single inline anchor" + ); + assert_eq!( + images[0], + vec![(update_count - 1) as u8], + "expected the inline anchor to use the final image state" + ); + + let bundle_dir = state + .output_store + .bundles + .back() + .map(|bundle| bundle.dir.clone()) + .expect("expected disclosed output bundle metadata"); + let history_dir = bundle_dir.join("images/history/001"); + let mut history_files: Vec<_> = fs::read_dir(&history_dir) + .unwrap_or_else(|err| panic!("expected history dir to be readable: {err}")) + .map(|entry| { + entry + .unwrap_or_else(|err| panic!("expected history dir entry: {err}")) + .file_name() + .to_string_lossy() + .into_owned() + }) + .collect(); + history_files.sort(); + + assert_eq!( + history_files.len(), + update_count, + "expected every repeated image update to be preserved in bundle history" + ); + assert_eq!( + history_files.first().map(String::as_str), + Some("001.png"), + "expected the first history frame to be preserved" + ); + assert_eq!( + history_files.last().map(String::as_str), + Some(format!("{update_count:03}.png").as_str()), + "expected the final history frame to be preserved" + ); + + let final_alias = fs::read(bundle_dir.join("images/001.png")) + .unwrap_or_else(|err| panic!("expected final image alias to be readable: {err}")); + let final_history = fs::read(history_dir.join(format!("{update_count:03}.png"))) + .unwrap_or_else(|err| panic!("expected final history frame to be readable: {err}")); + assert_eq!( + final_alias, final_history, + "expected the final alias to match the final history frame" + ); + } + + #[test] + fn timed_out_follow_up_reply_gets_its_own_active_bundle() { + let mut state = ResponseState::new().expect("response state should initialize"); + let mut bundle = state + .output_store + .new_bundle() + .expect("timeout bundle should initialize"); + bundle + .append_worker_text(&mut state.output_store, "HEAD\n", TextStream::Stdout) + .expect("existing timeout text should append"); + bundle.disclosed = true; + let timeout_transcript_path = bundle.paths.transcript.clone(); + state.active_timeout_bundle = Some(bundle); + + let large_follow_up = format!( + "FOLLOW_UP_START\n{}\nFOLLOW_UP_END\n", + "q".repeat(super::INLINE_TEXT_HARD_SPILL_THRESHOLD + 200) + ); + let result = state.finalize_worker_result( + Ok(worker_reply( + vec![ + WorkerContent::worker_stdout("TAIL\n"), + WorkerContent::worker_stdout(large_follow_up.clone()), + ], + Some(WorkerErrorCode::Timeout), + )), + true, + TimeoutBundleReuse::FollowUpInput, + 1, + ); + + let text = result_text(&result); + let follow_up_transcript_path = disclosed_path(&text, "transcript.txt") + .unwrap_or_else(|| panic!("expected timed-out follow-up bundle path, got: {text:?}")); + let timeout_transcript = fs::read_to_string(&timeout_transcript_path) + .unwrap_or_else(|err| panic!("expected timeout transcript to be readable: {err}")); + let follow_up_transcript = fs::read_to_string(&follow_up_transcript_path) + .unwrap_or_else(|err| panic!("expected follow-up transcript to be readable: {err}")); + let active_transcript_path = state + .active_timeout_bundle + .as_ref() + .map(|active| active.paths.transcript.clone()) + .expect("expected timed-out follow-up to install a new active timeout bundle"); + + assert_ne!( + follow_up_transcript_path, timeout_transcript_path, + "expected the timed-out follow-up reply to use a new bundle path" + ); + assert_eq!( + active_transcript_path, follow_up_transcript_path, + "expected the fresh follow-up turn to own the active timeout bundle" + ); + assert!( + timeout_transcript.contains("HEAD\nTAIL\n"), + "expected detached timeout tail to stay on the previous timeout bundle path, got: {timeout_transcript:?}" + ); + assert!( + !timeout_transcript.contains("FOLLOW_UP_START"), + "did not expect fresh follow-up output on the previous timeout bundle path: {timeout_transcript:?}" + ); + assert!( + follow_up_transcript.contains("FOLLOW_UP_START") + && follow_up_transcript.contains("FOLLOW_UP_END"), + "expected fresh follow-up output in the new timeout bundle, got: {follow_up_transcript:?}" + ); + assert!( + !follow_up_transcript.contains("TAIL\n"), + "did not expect detached timeout tail in the fresh follow-up bundle: {follow_up_transcript:?}" + ); + } + + #[test] + fn disclosed_timeout_image_bundle_keeps_later_small_polls_incremental() { + let mut state = ResponseState::new().expect("response state should initialize"); + let mut bundle = state + .output_store + .new_bundle() + .expect("timeout bundle should initialize"); + for index in 0..super::IMAGE_OUTPUT_BUNDLE_THRESHOLD { + let image = ReplyImage { + data: base64::engine::general_purpose::STANDARD.encode([index as u8]), + mime_type: "image/png".to_string(), + is_new: true, + }; + let retained = bundle + .append_image(&mut state.output_store, &image) + .expect("timeout image should append"); + assert!(matches!(retained, Some(ReplyItem::Image(_)))); + } + bundle.disclosed = true; + let transcript_path = bundle.paths.transcript.clone(); + state.active_timeout_bundle = Some(bundle); + + let result = state.finalize_worker_result( + Ok(worker_reply( + vec![WorkerContent::worker_stdout("TAIL\n".to_string())], + None, + )), + false, + TimeoutBundleReuse::FullReply, + 0, + ); + + let text = result_text(&result); + let images = result_images(&result); + let transcript = fs::read_to_string(&transcript_path) + .unwrap_or_else(|err| panic!("expected timeout transcript to be readable: {err}")); + + assert!( + disclosed_path(&text, "events.log").is_some() + || disclosed_path(&text, "transcript.txt").is_some(), + "expected later small poll to keep disclosing the existing bundle path, got: {text:?}" + ); + assert!( + images.is_empty(), + "did not expect anchor images on later small poll" + ); + assert!( + text.contains("TAIL\n"), + "expected later small poll to keep the new text visible, got: {text:?}" + ); + assert!( + transcript.contains("TAIL\n"), + "expected later small poll output to append to the existing timeout bundle, got: {transcript:?}" + ); + } + + #[test] + fn disclosed_timeout_image_bundle_spills_later_oversized_text_to_existing_bundle() { + let mut state = ResponseState::new().expect("response state should initialize"); + let mut bundle = state + .output_store + .new_bundle() + .expect("timeout bundle should initialize"); + for index in 0..super::IMAGE_OUTPUT_BUNDLE_THRESHOLD { + let image = ReplyImage { + data: base64::engine::general_purpose::STANDARD.encode([index as u8]), + mime_type: "image/png".to_string(), + is_new: true, + }; + let retained = bundle + .append_image(&mut state.output_store, &image) + .expect("timeout image should append"); + assert!(matches!(retained, Some(ReplyItem::Image(_)))); + } + bundle.disclosed = true; + let transcript_path = bundle.paths.transcript.clone(); + state.active_timeout_bundle = Some(bundle); + + let oversized = (0..1200) + .map(|index| format!("line{index:04}\n")) + .collect::<String>(); + assert!( + super::text_should_spill(oversized.chars().count()), + "expected later timeout text to cross the hard spill threshold" + ); + + let result = state.finalize_worker_result( + Ok(worker_reply( + vec![WorkerContent::worker_stdout(oversized.clone())], + None, + )), + false, + TimeoutBundleReuse::FullReply, + 0, + ); + + let text = result_text(&result); + let transcript = fs::read_to_string(&transcript_path) + .unwrap_or_else(|err| panic!("expected timeout transcript to be readable: {err}")); + + assert!( + disclosed_path(&text, "events.log").is_some(), + "expected later oversized poll to keep disclosing the existing bundle path, got: {text:?}" + ); + assert!( + !text.contains("line0600\n"), + "did not expect a middle line from the oversized text to stay inline, got: {text:?}" + ); + assert!( + transcript.contains("line0600\n"), + "expected the existing bundle transcript to receive the oversized text, got: {transcript:?}" + ); + } + + #[test] + fn disclosed_detached_prefix_bundle_survives_timeout_follow_up_quota_pressure() { + let mut state = ResponseState::new().expect("response state should initialize"); + state.output_store.limits.max_bundle_count = 1; + let detached_prefix = format!( + "IDLE_START\n{}\nIDLE_END\n", + "d".repeat(super::INLINE_TEXT_HARD_SPILL_THRESHOLD + 200) + ); + let result = state.finalize_worker_result( + Ok(worker_reply( + vec![ + WorkerContent::worker_stdout(detached_prefix), + WorkerContent::worker_stdout("FOLLOW_UP_TIMEOUT\n"), + ], + Some(WorkerErrorCode::Timeout), + )), + true, + TimeoutBundleReuse::FollowUpInput, + 1, + ); + + let text = result_text(&result); + let transcript_path = disclosed_path(&text, "transcript.txt") + .unwrap_or_else(|| panic!("expected detached-prefix transcript path, got: {text:?}")); + let transcript = fs::read_to_string(&transcript_path).unwrap_or_else(|err| { + panic!("expected disclosed detached-prefix path to stay readable: {err}") + }); + + assert!( + transcript.contains("IDLE_START") && transcript.contains("IDLE_END"), + "expected detached-prefix transcript to survive same-reply timeout allocation, got: {transcript:?}" + ); + assert!( + !transcript.contains("FOLLOW_UP_TIMEOUT"), + "did not expect fresh follow-up output in detached-prefix transcript: {transcript:?}" + ); + } + + #[test] + fn hidden_timeout_bundle_survives_server_only_follow_up_until_later_spill() { + let mut state = ResponseState::new().expect("response state should initialize"); + let first = state.finalize_worker_result( + Ok(worker_reply( + vec![WorkerContent::worker_stdout("HEAD\n")], + Some(WorkerErrorCode::Timeout), + )), + true, + TimeoutBundleReuse::None, + 0, + ); + assert!( + disclosed_path(&result_text(&first), "transcript.txt").is_none(), + "did not expect the initial timed-out reply to disclose a bundle path" + ); + assert!( + state.has_active_timeout_bundle(), + "expected the timed-out reply to keep an active timeout bundle" + ); + + let second = state.finalize_worker_result( + Ok(worker_reply( + vec![ + WorkerContent::server_stdout("<<repl status: busy>>\n"), + WorkerContent::worker_stdout("FOLLOW_UP_INLINE\n"), + ], + None, + )), + true, + TimeoutBundleReuse::FollowUpInput, + 1, + ); + let second_text = result_text(&second); + assert!( + second_text.contains("FOLLOW_UP_INLINE"), + "expected the follow-up reply to stay inline, got: {second_text:?}" + ); + assert!( + state.has_active_timeout_bundle(), + "expected a server-only detached prefix to preserve the hidden timeout bundle" + ); + + let detached_prefix = format!( + "TAIL_START\n{}\nTAIL_END\n", + "z".repeat(super::INLINE_TEXT_HARD_SPILL_THRESHOLD + 200) + ); + let third = state.finalize_worker_result( + Ok(worker_reply( + vec![ + WorkerContent::worker_stdout(detached_prefix), + WorkerContent::worker_stdout("DONE\n"), + ], + None, + )), + false, + TimeoutBundleReuse::FollowUpInput, + 1, + ); + + let third_text = result_text(&third); + let transcript_path = disclosed_path(&third_text, "transcript.txt").unwrap_or_else(|| { + panic!("expected the later follow-up spill to disclose a transcript path, got: {third_text:?}") + }); + let transcript = fs::read_to_string(&transcript_path) + .unwrap_or_else(|err| panic!("expected transcript to be readable: {err}")); + + assert!( + transcript.contains("HEAD\nTAIL_START\n") && transcript.contains("TAIL_END\n"), + "expected the preserved timeout bundle to keep the earlier timed-out bytes, got: {transcript:?}" + ); + assert!( + !transcript.contains("DONE\n"), + "did not expect fresh follow-up output in the detached-prefix transcript: {transcript:?}" + ); + } + + #[test] + fn hidden_timeout_bundle_survives_worker_follow_up_until_later_spill() { + let mut state = ResponseState::new().expect("response state should initialize"); + let first = state.finalize_worker_result( + Ok(worker_reply( + vec![WorkerContent::worker_stdout("HEAD\n")], + Some(WorkerErrorCode::Timeout), + )), + true, + TimeoutBundleReuse::None, + 0, + ); + assert!( + disclosed_path(&result_text(&first), "transcript.txt").is_none(), + "did not expect the initial timed-out reply to disclose a bundle path" + ); + assert!( + state.has_active_timeout_bundle(), + "expected the timed-out reply to keep hidden timeout state" + ); + + let second = state.finalize_worker_result( + Ok(worker_reply( + vec![ + WorkerContent::worker_stdout("MID\n"), + WorkerContent::server_stdout("<<repl status: busy>>\n"), + ], + None, + )), + true, + TimeoutBundleReuse::FollowUpInput, + 1, + ); + let second_text = result_text(&second); + assert!( + second_text.contains("MID\n"), + "expected the small busy follow-up to keep worker output inline, got: {second_text:?}" + ); + assert!( + disclosed_path(&second_text, "transcript.txt").is_none(), + "did not expect the small busy follow-up to spill yet, got: {second_text:?}" + ); + assert!( + state.has_active_timeout_bundle(), + "expected hidden timeout state to survive the small worker detached prefix" + ); + + let detached_prefix = format!( + "TAIL_START\n{}\nTAIL_END\n", + "z".repeat(super::INLINE_TEXT_HARD_SPILL_THRESHOLD + 200) + ); + let third = state.finalize_worker_result( + Ok(worker_reply( + vec![ + WorkerContent::worker_stdout(detached_prefix), + WorkerContent::worker_stdout("DONE\n"), + ], + None, + )), + false, + TimeoutBundleReuse::FollowUpInput, + 1, + ); + + let third_text = result_text(&third); + let transcript_path = disclosed_path(&third_text, "transcript.txt").unwrap_or_else(|| { + panic!("expected the later spill to disclose a transcript path, got: {third_text:?}") + }); + let transcript = fs::read_to_string(&transcript_path) + .unwrap_or_else(|err| panic!("expected transcript to be readable: {err}")); + + assert!( + transcript.contains("HEAD\nMID\nTAIL_START\n") && transcript.contains("TAIL_END\n"), + "expected the later spill to backfill both earlier timeout chunks, got: {transcript:?}" + ); + assert!( + !transcript.contains("DONE\n"), + "did not expect fresh follow-up output in the detached-prefix transcript: {transcript:?}" + ); + } + + #[test] + fn timeout_bundle_spills_once_cumulative_staged_text_crosses_threshold() { + let mut state = ResponseState::new().expect("response state should initialize"); + let chunk_body = "x".repeat(super::INLINE_TEXT_HARD_SPILL_THRESHOLD / 2); + let first_chunk = format!("FIRST_START\n{chunk_body}\nFIRST_END\n"); + let second_chunk = format!("SECOND_START\n{chunk_body}\nSECOND_END\n"); + + assert!( + !super::text_should_spill(first_chunk.chars().count()), + "expected each staged timeout chunk to stay under the spill threshold" + ); + assert!( + super::text_should_spill( + first_chunk + .chars() + .count() + .saturating_add(second_chunk.chars().count()) + ), + "expected staged timeout chunks to exceed the spill threshold cumulatively" + ); + + let first = state.finalize_worker_result( + Ok(worker_reply( + vec![WorkerContent::worker_stdout(first_chunk.clone())], + Some(WorkerErrorCode::Timeout), + )), + true, + TimeoutBundleReuse::FullReply, + 0, + ); + let first_text = result_text(&first); + assert!( + disclosed_path(&first_text, "transcript.txt").is_none(), + "did not expect the first under-threshold timeout poll to disclose a bundle path" + ); + assert!( + state.has_active_timeout_bundle(), + "expected the first under-threshold timeout poll to retain hidden timeout state" + ); + + let second = state.finalize_worker_result( + Ok(worker_reply( + vec![WorkerContent::worker_stdout(second_chunk.clone())], + Some(WorkerErrorCode::Timeout), + )), + true, + TimeoutBundleReuse::FullReply, + 0, + ); + let second_text = result_text(&second); + let transcript_path = disclosed_path(&second_text, "transcript.txt").unwrap_or_else(|| { + panic!("expected the cumulative timeout poll to disclose a transcript path, got: {second_text:?}") + }); + let transcript = fs::read_to_string(&transcript_path) + .unwrap_or_else(|err| panic!("expected transcript to be readable: {err}")); + + assert!( + transcript.contains("FIRST_START") && transcript.contains("FIRST_END"), + "expected the disclosed timeout bundle to backfill the first staged chunk, got: {transcript:?}" + ); + assert!( + transcript.contains("SECOND_START") && transcript.contains("SECOND_END"), + "expected the disclosed timeout bundle to include the later poll chunk, got: {transcript:?}" + ); + } + + #[test] + fn server_only_timeout_poll_does_not_accumulate_in_staged_timeout_state() { + let mut state = ResponseState::new().expect("response state should initialize"); + + let first = state.finalize_worker_result( + Ok(worker_reply( + vec![WorkerContent::worker_stdout("HEAD\n")], + Some(WorkerErrorCode::Timeout), + )), + true, + TimeoutBundleReuse::FullReply, + 0, + ); + let first_text = result_text(&first); + assert!( + first_text.contains("HEAD\n"), + "expected the initial timed-out reply to stay inline, got: {first_text:?}" + ); + let staged = state + .staged_timeout_output + .as_ref() + .expect("expected the initial timed-out reply to retain staged timeout state"); + assert_eq!(staged.items.len(), 1); + assert!(matches!( + staged.items.first(), + Some(ReplyItem::WorkerText { text, .. }) if text == "HEAD\n" + )); + + let second = state.finalize_worker_result( + Ok(worker_reply( + vec![WorkerContent::server_stdout("<<repl status: busy>>\n")], + Some(WorkerErrorCode::Timeout), + )), + true, + TimeoutBundleReuse::FullReply, + 0, + ); + let second_text = result_text(&second); + assert!( + second_text.contains("<<repl status: busy>>"), + "expected the server-only timeout poll to stay inline, got: {second_text:?}" + ); + + let staged = state + .staged_timeout_output + .as_ref() + .expect("expected staged timeout state to survive the server-only poll"); + assert_eq!( + staged.items.len(), + 1, + "did not expect server-only timeout status text to accumulate in staged timeout state" + ); + assert!(matches!( + staged.items.first(), + Some(ReplyItem::WorkerText { text, .. }) if text == "HEAD\n" + )); + } + + #[test] + fn disclosed_timeout_bundle_survives_busy_follow_up_until_later_poll() { + let mut state = ResponseState::new().expect("response state should initialize"); + let mut bundle = state + .output_store + .new_bundle() + .expect("timeout bundle should initialize"); + bundle + .append_worker_text(&mut state.output_store, "HEAD\n", TextStream::Stdout) + .expect("existing timeout text should append"); + bundle.disclosed = true; + let transcript_path = bundle.paths.transcript.clone(); + state.active_timeout_bundle = Some(bundle); + + let second = state.finalize_worker_result( + Ok(worker_reply( + vec![WorkerContent::server_stdout("<<repl status: busy>>\n")], + None, + )), + true, + TimeoutBundleReuse::FollowUpInput, + 0, + ); + let second_text = result_text(&second); + assert!( + second_text.contains("<<repl status: busy>>"), + "expected a busy follow-up marker, got: {second_text:?}" + ); + assert!( + state.has_active_timeout_bundle(), + "expected the disclosed timeout bundle to stay active through the busy follow-up" + ); + + let third = state.finalize_worker_result( + Ok(worker_reply( + vec![WorkerContent::worker_stdout("TAIL\n")], + None, + )), + false, + TimeoutBundleReuse::FullReply, + 0, + ); + let third_text = result_text(&third); + let transcript = fs::read_to_string(&transcript_path) + .unwrap_or_else(|err| panic!("expected timeout transcript to be readable: {err}")); + + assert!( + third_text.contains("TAIL\n"), + "expected the later poll to keep its small reply inline, got: {third_text:?}" + ); + assert!( + transcript.contains("HEAD\nTAIL\n"), + "expected the disclosed timeout bundle to keep appending after the busy follow-up, got: {transcript:?}" + ); + } + + #[test] + fn disclosed_timeout_bundle_survives_timeout_coded_busy_follow_up_until_later_poll() { + let mut state = ResponseState::new().expect("response state should initialize"); + let mut bundle = state + .output_store + .new_bundle() + .expect("timeout bundle should initialize"); + bundle + .append_worker_text(&mut state.output_store, "HEAD\n", TextStream::Stdout) + .expect("existing timeout text should append"); + bundle.disclosed = true; + let transcript_path = bundle.paths.transcript.clone(); + state.active_timeout_bundle = Some(bundle); + + let second = state.finalize_worker_result( + Ok(worker_reply( + vec![WorkerContent::server_stdout("<<repl status: busy>>\n")], + Some(WorkerErrorCode::Timeout), + )), + true, + TimeoutBundleReuse::FollowUpInput, + 0, + ); + let second_text = result_text(&second); + assert!( + second_text.contains("<<repl status: busy>>"), + "expected a busy follow-up marker, got: {second_text:?}" + ); + assert!( + state.has_active_timeout_bundle(), + "expected timeout-coded busy follow-up replies to keep the disclosed timeout bundle active" + ); + + let third = state.finalize_worker_result( + Ok(worker_reply( + vec![WorkerContent::worker_stdout("TAIL\n")], + None, + )), + false, + TimeoutBundleReuse::FullReply, + 0, + ); + let third_text = result_text(&third); + let transcript = fs::read_to_string(&transcript_path) + .unwrap_or_else(|err| panic!("expected timeout transcript to be readable: {err}")); + + assert!( + third_text.contains("TAIL\n"), + "expected the later poll to keep its small reply inline, got: {third_text:?}" + ); + assert!( + transcript.contains("HEAD\nTAIL\n"), + "expected timeout-coded busy follow-up replies to keep appending to the existing timeout bundle, got: {transcript:?}" + ); + } + + #[test] + fn follow_up_bundle_does_not_prune_disclosed_timeout_bundle() { + let mut state = ResponseState::new().expect("response state should initialize"); + let mut bundle = state + .output_store + .new_bundle() + .expect("timeout bundle should initialize"); + bundle + .append_worker_text(&mut state.output_store, "HEAD\n", TextStream::Stdout) + .expect("existing timeout text should append"); + bundle.disclosed = true; + let timeout_transcript_path = bundle.paths.transcript.clone(); + state.output_store.limits.max_bundle_count = 1; + state.active_timeout_bundle = Some(bundle); + + let large_follow_up = format!( + "FOLLOW_UP_START\n{}\nFOLLOW_UP_END\n", + "z".repeat(super::INLINE_TEXT_HARD_SPILL_THRESHOLD + 200) + ); + let result = state.finalize_worker_result( + Ok(worker_reply( + vec![ + WorkerContent::worker_stdout("TAIL\n"), + WorkerContent::worker_stdout(large_follow_up), + ], + None, + )), + false, + TimeoutBundleReuse::FollowUpInput, + 1, + ); + + let text = result_text(&result); + let timeout_transcript = + fs::read_to_string(&timeout_transcript_path).unwrap_or_else(|err| { + panic!("expected timeout transcript to survive quota fallback: {err}") + }); + + assert!( + timeout_transcript.contains("HEAD\nTAIL\n"), + "expected timeout transcript to remain readable after follow-up compaction fallback, got: {timeout_transcript:?}" + ); + assert!( + !timeout_transcript.contains("FOLLOW_UP_START"), + "did not expect fresh follow-up output in the preserved timeout transcript: {timeout_transcript:?}" + ); + assert!( + disclosed_path(&text, "transcript.txt").is_none(), + "did not expect a fresh follow-up bundle path when the active timeout bundle is the only quota slot: {text:?}" + ); + } + + #[test] + fn small_timeout_does_not_prune_disclosed_bundle_before_any_new_disclosure() { + let mut state = ResponseState::new().expect("response state should initialize"); + let mut bundle = state + .output_store + .new_bundle() + .expect("disclosed bundle should initialize"); + bundle + .append_worker_text(&mut state.output_store, "PERSIST\n", TextStream::Stdout) + .expect("disclosed bundle text should append"); + bundle.disclosed = true; + let transcript_path = bundle.paths.transcript.clone(); + state.output_store.limits.max_bundle_count = 1; + + let timed_out = state.finalize_worker_result( + Ok(worker_reply( + vec![WorkerContent::worker_stdout("HEAD\n")], + Some(WorkerErrorCode::Timeout), + )), + true, + TimeoutBundleReuse::None, + 0, + ); + let timed_out_text = result_text(&timed_out); + + assert!( + timed_out_text.contains("HEAD\n"), + "expected the timed-out reply to stay inline, got: {timed_out_text:?}" + ); + assert!( + disclosed_path(&timed_out_text, "transcript.txt").is_none(), + "did not expect the timed-out reply to disclose a new bundle path, got: {timed_out_text:?}" + ); + let persisted_after_timeout = fs::read_to_string(&transcript_path).unwrap_or_else(|err| { + panic!("expected disclosed bundle path to survive timeout: {err}") + }); + + let completed = state.finalize_worker_result( + Ok(worker_reply( + vec![WorkerContent::worker_stdout("DONE\n")], + None, + )), + false, + TimeoutBundleReuse::FullReply, + 0, + ); + let completed_text = result_text(&completed); + let persisted_after_completion = + fs::read_to_string(&transcript_path).unwrap_or_else(|err| { + panic!("expected disclosed bundle path to survive inline completion: {err}") + }); + + assert!( + completed_text.contains("DONE\n"), + "expected the completion poll to stay inline, got: {completed_text:?}" + ); + assert!( + disclosed_path(&completed_text, "transcript.txt").is_none(), + "did not expect the completion poll to disclose a new bundle path, got: {completed_text:?}" + ); + assert!( + persisted_after_timeout.contains("PERSIST\n"), + "expected the original disclosed bundle content to remain readable after timeout, got: {persisted_after_timeout:?}" + ); + assert_eq!( + persisted_after_completion, persisted_after_timeout, + "did not expect the original disclosed bundle to change when the hidden timeout state never spilled" + ); + } + + #[test] + fn output_bundle_setup_failure_returns_pathless_truncated_reply() { + let mut state = ResponseState::new().expect("response state should initialize"); + state.output_store.create_root = fail_output_store_root_creation; + + let oversized_text = format!( + "START{}END", + "a".repeat(super::INLINE_TEXT_HARD_SPILL_THRESHOLD + 200) + ); + let result = state.finalize_worker_result( + Ok(crate::worker_protocol::WorkerReply::Output { + contents: vec![WorkerContent::worker_stdout(oversized_text.clone())], + is_error: false, + error_code: None, + prompt: None, + prompt_variants: None, + }), + false, + TimeoutBundleReuse::None, + 0, + ); + + let text = result_text(&result); + assert!( + text.contains("output bundle unavailable"), + "expected truncated fallback notice when bundle setup fails, got: {text:?}" + ); + assert!( + !text.contains("/transcript.txt") && !text.contains("/events.log"), + "did not expect bundle path in fallback reply, got: {text:?}" + ); + assert!( + text.contains("START") && text.contains("END"), + "expected truncated fallback reply to preserve worker output preview, got: {text:?}" + ); + } + + #[test] + fn timeout_busy_marker_survives_bundle_quota_truncation() { + let mut state = ResponseState::new().expect("response state should initialize"); + state.output_store.limits.max_bundle_bytes = 2048; + let oversized_text = format!( + "START\n{}\nEND\n", + "q".repeat(super::INLINE_TEXT_HARD_SPILL_THRESHOLD + 200) + ); + let busy_marker = "<<repl status: busy, write_stdin timeout reached; elapsed_ms=50>>"; + let result = state.finalize_worker_result( + Ok(worker_reply( + vec![ + WorkerContent::worker_stdout(oversized_text), + WorkerContent::server_stdout(busy_marker), + ], + Some(WorkerErrorCode::Timeout), + )), + true, + TimeoutBundleReuse::None, + 0, + ); + + let text = result_text(&result); + let transcript_path = disclosed_path(&text, "transcript.txt").unwrap_or_else(|| { + panic!("expected transcript path in timed-out reply, got: {text:?}") + }); + let transcript = fs::read_to_string(&transcript_path) + .unwrap_or_else(|err| panic!("expected timeout transcript to be readable: {err}")); + + assert!( + text.contains("later content omitted"), + "expected omission notice after bundle cap, got: {text:?}" + ); + assert!( + text.contains(busy_marker), + "expected timeout busy marker to remain inline after bundle truncation, got: {text:?}" + ); + assert!( + !transcript.contains(busy_marker), + "did not expect timeout busy marker in transcript bundle, got: {transcript:?}" + ); + } + + #[test] + fn mixed_timeout_bundle_events_log_excludes_server_status_lines() { + let mut state = ResponseState::new().expect("response state should initialize"); + let busy_marker = "<<repl status: busy>>\n"; + let mut contents = vec![WorkerContent::worker_stdout("HEAD\n")]; + contents.extend((0..super::IMAGE_OUTPUT_BUNDLE_THRESHOLD).map(|index| { + WorkerContent::ContentImage { + data: base64::engine::general_purpose::STANDARD.encode([index as u8]), + mime_type: "image/png".to_string(), + id: format!("plot-{index}"), + is_new: true, + } + })); + contents.push(WorkerContent::server_stdout(busy_marker)); + + let result = state.finalize_worker_result( + Ok(worker_reply(contents, Some(WorkerErrorCode::Timeout))), + true, + TimeoutBundleReuse::None, + 0, + ); + + let text = result_text(&result); + let events_path = disclosed_path(&text, "events.log").unwrap_or_else(|| { + panic!("expected events log path in mixed timeout reply, got: {text:?}") + }); + let events = fs::read_to_string(&events_path) + .unwrap_or_else(|err| panic!("expected events log to be readable: {err}")); + + assert!( + text.contains(busy_marker), + "expected busy marker to remain inline, got: {text:?}" + ); + assert!( + !events.contains(busy_marker), + "did not expect server-only busy marker in events.log, got: {events:?}" + ); + } + + #[test] + fn quota_truncated_active_image_bundle_keeps_inline_preview_compact() { + let initial_image_count = super::IMAGE_OUTPUT_BUNDLE_THRESHOLD; + let later_image_count = 5usize; + let mut sizing_state = ResponseState::new().expect("response state should initialize"); + let mut sizing_bundle = sizing_state + .output_store + .new_bundle() + .expect("bundle should initialize"); + for index in 0..initial_image_count { + let retained = sizing_bundle + .append_image( + &mut sizing_state.output_store, + &ReplyImage { + data: base64::engine::general_purpose::STANDARD.encode([index as u8]), + mime_type: "image/png".to_string(), + is_new: true, + }, + ) + .expect("initial image should append"); + assert!(matches!(retained, Some(ReplyItem::Image(_)))); + } + let current_bytes = sizing_state + .output_store + .bundle_bytes(sizing_bundle.id) + .expect("bundle metadata should exist"); + for offset in 0..3 { + let retained = sizing_bundle + .append_image( + &mut sizing_state.output_store, + &ReplyImage { + data: base64::engine::general_purpose::STANDARD + .encode([(initial_image_count + offset) as u8]), + mime_type: "image/png".to_string(), + is_new: true, + }, + ) + .expect("sizing image should append"); + assert!(matches!(retained, Some(ReplyItem::Image(_)))); + } + let quota_cap = sizing_state + .output_store + .bundle_bytes(sizing_bundle.id) + .expect("bundle metadata should exist after sizing"); + assert!( + quota_cap > current_bytes, + "expected sizing pass to consume additional bundle bytes" + ); + + let mut state = ResponseState::new().expect("response state should initialize"); + let mut bundle = state + .output_store + .new_bundle() + .expect("bundle should initialize"); + for index in 0..initial_image_count { + let retained = bundle + .append_image( + &mut state.output_store, + &ReplyImage { + data: base64::engine::general_purpose::STANDARD.encode([index as u8]), + mime_type: "image/png".to_string(), + is_new: true, + }, + ) + .expect("initial image should append"); + assert!(matches!(retained, Some(ReplyItem::Image(_)))); + } + bundle.disclosed = true; + state.output_store.limits.max_bundle_bytes = quota_cap; + state.active_timeout_bundle = Some(bundle); + + let result = state.finalize_worker_result( + Ok(worker_reply( + (0..later_image_count) + .map(|offset| WorkerContent::ContentImage { + data: base64::engine::general_purpose::STANDARD + .encode([(initial_image_count + offset) as u8]), + mime_type: "image/png".to_string(), + id: format!("later-{offset}"), + is_new: true, + }) + .collect(), + Some(WorkerErrorCode::Timeout), + )), + true, + TimeoutBundleReuse::FullReply, + 0, + ); + + let text = result_text(&result); + let images = result_images(&result); + + assert!( + text.contains("later content omitted"), + "expected quota-truncated image bundle to report omitted content, got: {text:?}" + ); + assert!( + !images.is_empty() && images.len() <= 2, + "expected quota-truncated image bundle to keep only compact anchor previews, got {} images", + images.len() + ); + } + + #[test] + fn worker_error_clears_active_timeout_bundle() { + let mut state = ResponseState::new().expect("response state should initialize"); + let bundle = state + .output_store + .new_bundle() + .expect("timeout bundle should initialize"); + let bundle_dir = bundle.paths.dir.clone(); + state.active_timeout_bundle = Some(bundle); + assert!( + state.has_active_timeout_bundle(), + "expected test setup to install an active timeout bundle" + ); + + let result = state.finalize_worker_result( + Err(WorkerError::Protocol( + "simulated worker failure".to_string(), + )), + false, + TimeoutBundleReuse::FullReply, + 0, + ); + + let text = result_text(&result); + assert!( + text.contains("simulated worker failure"), + "expected worker error text, got: {text:?}" + ); + assert!( + state.active_timeout_bundle.is_none(), + "expected worker error to clear the active timeout bundle" + ); + assert!( + state.output_store.bundles.is_empty(), + "expected worker error to drop the hidden timeout bundle" + ); + assert!( + !bundle_dir.exists(), + "expected dropped timeout bundle directory to be removed: {bundle_dir:?}" + ); + } + + #[test] + fn text_spill_append_failure_cleans_up_undisclosed_bundle() { + let mut state = ResponseState::new().expect("response state should initialize"); + state.output_store.create_root = output_store_root_with_text_conflict; + + let oversized_text = format!( + "START{}END", + "a".repeat(super::INLINE_TEXT_HARD_SPILL_THRESHOLD + 200) + ); + let result = state.finalize_worker_result( + Ok(worker_reply( + vec![WorkerContent::worker_stdout(oversized_text)], + None, + )), + false, + TimeoutBundleReuse::None, + 0, + ); + + let text = result_text(&result); + assert!( + text.contains("output bundle unavailable"), + "expected inline fallback after append failure, got: {text:?}" + ); + assert!( + state.output_store.bundles.is_empty(), + "expected failed text bundle append to remove the undisclosed bundle" + ); + } + + #[test] + fn detached_prefix_text_spill_append_failure_returns_pathless_truncated_reply() { + let mut state = ResponseState::new().expect("response state should initialize"); + state.output_store.create_root = output_store_root_with_text_conflict; + + let detached_prefix = format!( + "HEAD\n{}\nMIDDLE\n{}\nTAIL\n", + "a".repeat(super::INLINE_TEXT_HARD_SPILL_THRESHOLD + 200), + "b".repeat(super::INLINE_TEXT_HARD_SPILL_THRESHOLD + 200) + ); + let result = state.finalize_worker_result( + Ok(worker_reply( + vec![ + WorkerContent::worker_stdout(detached_prefix), + WorkerContent::worker_stdout("FOLLOW_UP_OK\n"), + ], + None, + )), + false, + TimeoutBundleReuse::FollowUpInput, + 1, + ); + + let text = result_text(&result); + assert!( + text.contains("output bundle unavailable"), + "expected truncated detached-prefix fallback when bundle append fails, got: {text:?}" + ); + assert!( + !text.contains("/transcript.txt") && !text.contains("/events.log"), + "did not expect bundle path in detached-prefix fallback reply, got: {text:?}" + ); + assert!( + text.contains("HEAD\n") && text.contains("TAIL\n"), + "expected truncated detached-prefix preview in fallback reply, got: {text:?}" + ); + assert!( + !text.contains("MIDDLE"), + "did not expect the full detached-prefix transcript inline after append failure, got: {text:?}" + ); + assert!( + text.contains("FOLLOW_UP_OK"), + "expected follow-up reply inline after detached-prefix append failure, got: {text:?}" + ); + } + + #[test] + fn image_bundle_append_failure_cleans_up_undisclosed_bundle() { + let mut state = ResponseState::new().expect("response state should initialize"); + let contents = (0..super::IMAGE_OUTPUT_BUNDLE_THRESHOLD) + .map(|index| WorkerContent::ContentImage { + data: "!not-base64!".to_string(), + mime_type: "image/png".to_string(), + id: format!("image-{index}"), + is_new: true, + }) + .collect(); + + let result = state.finalize_worker_result( + Ok(worker_reply(contents, None)), + false, + TimeoutBundleReuse::None, + 0, + ); + + let text = result_text(&result); + assert!( + text.contains("output bundle unavailable"), + "expected inline fallback after image bundle append failure, got: {text:?}" + ); + assert!( + state.output_store.bundles.is_empty(), + "expected failed image bundle append to remove the undisclosed bundle" + ); + } + + #[test] + fn removing_missing_bundle_dir_still_releases_quota() { + let mut store = OutputStore::new().expect("output store should initialize"); + let mut bundle = store.new_bundle().expect("bundle should initialize"); + bundle + .append_worker_text(&mut store, "quota", TextStream::Stdout) + .expect("worker text should append"); + let bundle_id = bundle.id; + let bundle_dir = bundle.paths.dir.clone(); + let bytes_before = store + .bundle_bytes(bundle_id) + .expect("bundle metadata should exist before removal"); + assert!(bytes_before > 0, "expected test bundle to consume quota"); + + fs::remove_dir_all(&bundle_dir).expect("bundle dir should be removable"); + store + .remove_bundle(bundle_id) + .expect("missing bundle dir should still clean up metadata"); + + assert_eq!( + store.total_bytes, 0, + "expected quota accounting to be released" + ); + assert!( + store.bundle_bytes(bundle_id).is_none(), + "expected bundle metadata to be removed after cleanup" + ); + } + + #[test] + fn single_image_update_bundle_uses_final_image_as_inline_anchor() { + let mut store = OutputStore::new().expect("output store should initialize"); + let mut bundle = store.new_bundle().expect("bundle should initialize"); + let first_bytes = vec![0_u8]; + let last_bytes = vec![1_u8]; + let first = ReplyImage { + data: base64::engine::general_purpose::STANDARD.encode(&first_bytes), + mime_type: "image/png".to_string(), + is_new: true, + }; + let last = ReplyImage { + data: base64::engine::general_purpose::STANDARD.encode(&last_bytes), + mime_type: "image/png".to_string(), + is_new: false, + }; + let retained_first = bundle + .append_image(&mut store, &first) + .expect("first image should append") + .expect("first image should be retained"); + let retained_last = bundle + .append_image(&mut store, &last) + .expect("updated image should append") + .expect("updated image should be retained"); + + let result = super::finalize_batch( + compact_output_bundle_items(&[retained_first, retained_last], &bundle), + false, + ); + let images = result_images(&result); + + assert_eq!(images.len(), 1, "expected exactly one inline anchor image"); + assert_eq!( + images[0], last_bytes, + "expected inline anchor to use the final image state" + ); + } + + #[test] + fn omission_recorded_stays_false_when_notice_cannot_be_written() { + let mut store = OutputStore::new().expect("output store should initialize"); + let mut bundle = store.new_bundle().expect("bundle should initialize"); + + let worker_text = bundle + .append_worker_text(&mut store, "a", TextStream::Stdout) + .expect("worker text should append"); + assert!(matches!(worker_text, Some(ReplyItem::WorkerText { text, .. }) if text == "a")); + + let image = ReplyImage { + data: base64::engine::general_purpose::STANDARD.encode([0_u8]), + mime_type: "image/png".to_string(), + is_new: true, + }; + let retained_image = bundle + .append_image(&mut store, &image) + .expect("image should append"); + assert!(matches!(retained_image, Some(ReplyItem::Image(_)))); + assert!( + bundle.has_events_log(), + "expected mixed bundle to materialize events.log" + ); + + let events_before = + fs::read_to_string(&bundle.paths.events_log).expect("events log should exist"); + store.limits.max_bundle_bytes = store + .bundle_bytes(bundle.id) + .expect("bundle metadata should exist"); + + bundle + .apply_omission(&mut store) + .expect("omission should degrade to inline state"); + + let events_after = + fs::read_to_string(&bundle.paths.events_log).expect("events log should still exist"); + assert!(bundle.omitted_tail, "expected omission state to be set"); + assert!( + !bundle.omission_recorded, + "did not expect omission row to be marked recorded when quota blocked the write" + ); + assert_eq!( + events_after, events_before, + "did not expect events.log to change when omission row could not be appended" + ); + } + + #[test] + fn timeout_omission_without_worker_text_still_discloses_bundle() { + let mut state = ResponseState::new().expect("response state should initialize"); + let mut bundle = state + .output_store + .new_bundle() + .expect("bundle should initialize"); + let image = ReplyImage { + data: base64::engine::general_purpose::STANDARD.encode([0_u8]), + mime_type: "image/png".to_string(), + is_new: true, + }; + let retained_image = bundle + .append_image(&mut state.output_store, &image) + .expect("first image should append"); + assert!(matches!(retained_image, Some(ReplyItem::Image(_)))); + state.output_store.limits.max_bundle_bytes = state + .output_store + .bundle_bytes(bundle.id) + .expect("bundle metadata should exist"); + let images_dir = bundle.paths.images_dir.clone(); + state.active_timeout_bundle = Some(bundle); + + let result = state.finalize_worker_result( + Ok(worker_reply( + vec![WorkerContent::ContentImage { + data: base64::engine::general_purpose::STANDARD.encode([1_u8]), + mime_type: "image/png".to_string(), + id: "image-2".to_string(), + is_new: true, + }], + Some(WorkerErrorCode::Timeout), + )), + false, + TimeoutBundleReuse::FullReply, + 0, + ); + + let text = result_text(&result); + assert!( + text.contains("later content omitted"), + "expected omission notice even without worker text, got: {text:?}" + ); + assert!( + text.contains(images_dir.to_string_lossy().as_ref()), + "expected omission notice to disclose bundle path, got: {text:?}" + ); + } + + #[test] + fn strip_text_stream_meta_removes_internal_marker() { + let mut result = super::finalize_batch( + vec![super::content_text( + "stderr: boom\n".to_string(), + TextStream::Stderr, + )], + false, + ); + + assert_eq!( + super::text_stream_from_content(&result.content[0]), + Some(TextStream::Stderr) + ); + + super::strip_text_stream_meta(&mut result); + + assert_eq!(super::text_stream_from_content(&result.content[0]), None); + let value = serde_json::to_value(&result).expect("result should serialize"); + assert!( + !value.to_string().contains(super::TEXT_STREAM_META_KEY), + "did not expect stripped result to expose internal stream metadata: {value}" + ); + } } diff --git a/src/server/tests.rs b/src/server/tests.rs index 5cf32fb..e063b04 100644 --- a/src/server/tests.rs +++ b/src/server/tests.rs @@ -28,7 +28,7 @@ fn cleanup_echo_only_sequences( for (idx, content) in contents.iter().enumerate() { match content { - WorkerContent::ContentText { text, stream } => { + WorkerContent::ContentText { text, stream, .. } => { let lines = split_lines(text); let mut keep = vec![true; lines.len()]; for (line_idx, line) in lines.iter().enumerate() { @@ -86,7 +86,11 @@ fn cleanup_echo_only_sequences( let mut cleaned = Vec::with_capacity(contents.len()); for (idx, content) in contents.into_iter().enumerate() { match content { - WorkerContent::ContentText { text: _, stream } => { + WorkerContent::ContentText { + text: _, + stream, + origin, + } => { let Some(lines) = lines_per_content[idx].take() else { continue; }; @@ -101,6 +105,7 @@ fn cleanup_echo_only_sequences( cleaned.push(WorkerContent::ContentText { text: new_text, stream, + origin, }); } } @@ -113,8 +118,14 @@ fn cleanup_echo_only_sequences( #[test] fn repl_tool_descriptions_are_backend_specific() { - let r = super::repl_tool_description_for_backend(crate::backend::Backend::R); - let python = super::repl_tool_description_for_backend(crate::backend::Backend::Python); + let r = super::repl_tool_description_for_backend( + crate::backend::Backend::R, + crate::oversized_output::OversizedOutputMode::Files, + ); + let python = super::repl_tool_description_for_backend( + crate::backend::Backend::Python, + crate::oversized_output::OversizedOutputMode::Files, + ); assert_ne!(r, python, "expected backend-specific repl descriptions"); assert!(r.contains("R code")); @@ -123,12 +134,19 @@ fn repl_tool_descriptions_are_backend_specific() { #[test] fn repl_tool_descriptions_include_language_specific_affordances() { - let r = super::repl_tool_description_for_backend(crate::backend::Backend::R); - let python = super::repl_tool_description_for_backend(crate::backend::Backend::Python); + let r = super::repl_tool_description_for_backend( + crate::backend::Backend::R, + crate::oversized_output::OversizedOutputMode::Files, + ); + let python = super::repl_tool_description_for_backend( + crate::backend::Backend::Python, + crate::oversized_output::OversizedOutputMode::Files, + ); for description in [r, python] { let lower = description.to_lowercase(); - assert!(lower.contains("pager")); + assert!(lower.contains("poll")); + assert!(lower.contains("large output")); assert!(lower.contains("images")); assert!(lower.contains("debug")); } @@ -136,6 +154,39 @@ fn repl_tool_descriptions_include_language_specific_affordances() { assert!(python.contains("help()")); } +#[test] +fn repl_tool_descriptions_are_mode_specific() { + let files = super::repl_tool_description_for_backend( + crate::backend::Backend::R, + crate::oversized_output::OversizedOutputMode::Files, + ); + let pager = super::repl_tool_description_for_backend( + crate::backend::Backend::R, + crate::oversized_output::OversizedOutputMode::Pager, + ); + + assert_ne!(files, pager, "expected mode-specific repl descriptions"); + assert!(files.contains("output bundle")); + assert!(pager.contains("modal pager")); + assert!(pager.contains(":q")); +} + +#[test] +fn timeout_bundle_reuse_treats_blank_lines_as_fresh_input() { + assert!(matches!( + super::response::timeout_bundle_reuse_for_input(""), + super::response::TimeoutBundleReuse::FullReply + )); + assert!(matches!( + super::response::timeout_bundle_reuse_for_input("\n"), + super::response::TimeoutBundleReuse::FollowUpInput + )); + assert!(matches!( + super::response::timeout_bundle_reuse_for_input("\r\n"), + super::response::TimeoutBundleReuse::FollowUpInput + )); +} + fn split_lines(text: &str) -> Vec<String> { if text.is_empty() { return Vec::new(); diff --git a/src/worker.rs b/src/worker.rs index 5a95b45..05af10b 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -176,8 +176,8 @@ fn request_loop(rx: mpsc::Receiver<QueuedRequest>, state: Arc<WorkerState>) { let result = write_stdin_request(request.text); if let Err(err) = result { emit_stderr_message(&err.message); + emit_request_end(); } - emit_request_end(); state.mark_idle(); } } diff --git a/src/worker_process.rs b/src/worker_process.rs index d46cd1f..3e4e92c 100644 --- a/src/worker_process.rs +++ b/src/worker_process.rs @@ -28,8 +28,11 @@ use crate::ipc::{IpcHandlers, IpcPlotImage}; use crate::output_capture::{ OUTPUT_RING_CAPACITY_BYTES, OutputBuffer, OutputEventKind, OutputRange, OutputTextSpan, OutputTimeline, ensure_output_ring, reset_last_reply_marker_offset, reset_output_ring, + set_last_reply_marker_offset, update_last_reply_marker_offset_max, }; +use crate::oversized_output::OversizedOutputMode; use crate::pager::{self, Pager}; +use crate::pending_output_tape::{FormattedPendingOutput, PendingOutputTape, PendingSidebandKind}; use crate::sandbox::{ R_SESSION_TMPDIR_ENV, SandboxState, SandboxStateUpdate, prepare_worker_command, }; @@ -39,7 +42,7 @@ use crate::sandbox_cli::{ sandbox_plan_requests_inherited_state, }; use crate::worker_protocol::{ - TextStream, WORKER_MODE_ARG, WorkerContent, WorkerErrorCode, WorkerReply, + ContentOrigin, TextStream, WORKER_MODE_ARG, WorkerContent, WorkerErrorCode, WorkerReply, }; #[cfg(target_family = "unix")] @@ -61,6 +64,61 @@ struct GuardrailShared { busy: Arc<AtomicBool>, } +#[derive(Clone)] +struct LiveOutputCapture { + pending_output_tape: Option<PendingOutputTape>, + output_timeline: OutputTimeline, +} + +impl LiveOutputCapture { + fn new( + oversized_output: OversizedOutputMode, + pending_output_tape: PendingOutputTape, + output_timeline: OutputTimeline, + ) -> Self { + Self { + pending_output_tape: matches!(oversized_output, OversizedOutputMode::Files) + .then_some(pending_output_tape), + output_timeline, + } + } + + fn append_text(&self, bytes: &[u8], stream: TextStream) { + match stream { + TextStream::Stdout => { + self.output_timeline.append_text(bytes, false); + if let Some(tape) = &self.pending_output_tape { + tape.append_stdout_bytes(bytes); + } + } + TextStream::Stderr => { + self.output_timeline.append_text(bytes, true); + if let Some(tape) = &self.pending_output_tape { + tape.append_stderr_bytes(bytes); + } + } + } + } + + fn append_image(&self, image: IpcPlotImage) { + self.output_timeline.append_image( + image.id.clone(), + image.mime_type.clone(), + image.data.clone(), + image.is_new, + ); + if let Some(tape) = &self.pending_output_tape { + tape.append_image(image.id, image.mime_type, image.data, image.is_new); + } + } + + fn append_sideband(&self, kind: PendingSidebandKind) { + if let Some(tape) = &self.pending_output_tape { + tape.append_sideband(kind); + } + } +} + #[cfg(target_family = "unix")] const WORKER_MEM_GUARDRAIL_RATIO: f64 = 0.75; #[cfg(target_family = "unix")] @@ -102,10 +160,7 @@ impl RBackendDriver { } fn driver_on_input_start(_text: &str, ipc: &ServerIpcConnection) { - ipc.clear_request_end_events(); - ipc.clear_readline_tracking(); - ipc.clear_prompt_history(); - ipc.clear_echo_events(); + ipc.begin_request(); } const REQUEST_END_FALLBACK_WAIT: Duration = Duration::from_millis(20); @@ -128,11 +183,12 @@ fn driver_wait_for_completion( let slice = remaining.min(Duration::from_millis(50)); match ipc.wait_for_request_end(slice) { Ok(()) => { - let (prompt, prompt_variants, echo_events) = collect_completion_metadata(&ipc); + let (prompt, prompt_variants) = collect_completion_metadata(&ipc); return Ok(CompletionInfo { prompt, prompt_variants: Some(prompt_variants), - echo_events, + echo_events: ipc.take_echo_events(), + protocol_warnings: ipc.take_protocol_warnings(), session_end_seen: false, }); } @@ -140,11 +196,12 @@ fn driver_wait_for_completion( if ipc.waiting_for_next_input(REQUEST_END_FALLBACK_WAIT) && ipc.try_take_request_end() { - let (prompt, prompt_variants, echo_events) = collect_completion_metadata(&ipc); + let (prompt, prompt_variants) = collect_completion_metadata(&ipc); return Ok(CompletionInfo { prompt, prompt_variants: Some(prompt_variants), - echo_events, + echo_events: ipc.take_echo_events(), + protocol_warnings: ipc.take_protocol_warnings(), session_end_seen: false, }); } @@ -154,7 +211,8 @@ fn driver_wait_for_completion( return Ok(CompletionInfo { prompt: None, prompt_variants: None, - echo_events: Vec::new(), + echo_events: ipc.take_echo_events(), + protocol_warnings: ipc.take_protocol_warnings(), session_end_seen: true, }); } @@ -314,12 +372,11 @@ const COMPLETION_METADATA_SETTLE_MAX: Duration = Duration::from_millis(30); const COMPLETION_METADATA_SETTLE_POLL: Duration = Duration::from_millis(5); const COMPLETION_METADATA_STABLE: Duration = Duration::from_millis(10); -fn collect_completion_metadata( - ipc: &ServerIpcConnection, -) -> (Option<String>, Vec<String>, Vec<IpcEchoEvent>) { +fn collect_completion_metadata(ipc: &ServerIpcConnection) -> (Option<String>, Vec<String>) { let mut prompt = ipc.try_take_prompt().filter(|value| !value.is_empty()); let mut prompt_variants = ipc.take_prompt_history(); - let mut echo_events = ipc.take_echo_events(); + let mut echo_event_count = ipc.pending_echo_event_count(); + let mut saw_late_echo_event = false; let start = std::time::Instant::now(); let mut stable_for = Duration::from_millis(0); @@ -327,22 +384,25 @@ fn collect_completion_metadata( thread::sleep(COMPLETION_METADATA_SETTLE_POLL); let next_prompt = ipc.try_take_prompt().filter(|value| !value.is_empty()); let mut next_prompt_variants = ipc.take_prompt_history(); - let mut next_echo_events = ipc.take_echo_events(); + let next_echo_event_count = ipc.pending_echo_event_count(); + if next_echo_event_count > echo_event_count { + saw_late_echo_event = true; + } let changed = next_prompt.is_some() || !next_prompt_variants.is_empty() - || !next_echo_events.is_empty(); + || next_echo_event_count != echo_event_count; if let Some(value) = next_prompt { prompt = Some(value); } prompt_variants.append(&mut next_prompt_variants); - echo_events.append(&mut next_echo_events); + echo_event_count = next_echo_event_count; if changed { stable_for = Duration::from_millis(0); } else { stable_for = stable_for.saturating_add(COMPLETION_METADATA_SETTLE_POLL); - if stable_for >= COMPLETION_METADATA_STABLE { + if !saw_late_echo_event && stable_for >= COMPLETION_METADATA_STABLE { break; } } @@ -356,7 +416,7 @@ fn collect_completion_metadata( .cloned(); } - (prompt, prompt_variants, echo_events) + (prompt, prompt_variants) } impl From<std::io::Error> for WorkerError { @@ -366,11 +426,12 @@ impl From<std::io::Error> for WorkerError { } struct InputContext { - start_offset: u64, prefix_contents: Vec<WorkerContent>, - prefix_bytes: u64, prefix_is_error: bool, + start_offset: u64, + prefix_bytes: u64, input_echo: Option<String>, + input_transcript: Option<String>, } struct ReplyWithOffset { @@ -399,6 +460,7 @@ struct CompletionInfo { prompt: Option<String>, prompt_variants: Option<Vec<String>>, echo_events: Vec<IpcEchoEvent>, + protocol_warnings: Vec<String>, session_end_seen: bool, } @@ -432,11 +494,10 @@ fn split_write_stdin_control_prefix(input: &str) -> Option<(WriteStdinControlAct fn worker_context_event_payload( backend: Backend, sandbox_state: &SandboxState, - preserve_pager: Option<bool>, ) -> serde_json::Value { let sandbox_policy = serde_json::to_value(&sandbox_state.sandbox_policy) .unwrap_or_else(|err| serde_json::json!({ "serialize_error": err.to_string() })); - let mut payload = serde_json::json!({ + serde_json::json!({ "backend": format!("{backend:?}"), "sandbox_policy": sandbox_policy, "sandbox_cwd": sandbox_state.sandbox_cwd.to_string_lossy().to_string(), @@ -451,16 +512,7 @@ fn worker_context_event_payload( "denied_domains": sandbox_state.managed_network_policy.denied_domains.clone(), "allow_local_binding": sandbox_state.managed_network_policy.allow_local_binding, }, - }); - if let Some(preserve_pager) = preserve_pager - && let Some(object) = payload.as_object_mut() - { - object.insert( - "preserve_pager".to_string(), - serde_json::Value::Bool(preserve_pager), - ); - } - payload + }) } pub struct WorkerManager { @@ -472,6 +524,8 @@ pub struct WorkerManager { inherited_sandbox_state: Option<SandboxState>, sandbox_defaults: SandboxState, sandbox_state: SandboxState, + oversized_output: OversizedOutputMode, + pending_output_tape: PendingOutputTape, output: OutputBuffer, pager: Pager, output_timeline: OutputTimeline, @@ -479,9 +533,7 @@ pub struct WorkerManager { pending_request: bool, pending_request_started_at: Option<std::time::Instant>, session_end_seen: bool, - // Prompt captured when pager is activated. We suppress REPL prompts while paging, but once - // paging is dismissed we still want to surface the prompt that was actually emitted by the - // backend for that turn (without inventing a prompt). + last_detached_prefix_item_count: usize, pager_prompt: Option<String>, last_prompt: Option<String>, last_spawn: Option<std::time::Instant>, @@ -490,7 +542,11 @@ pub struct WorkerManager { } impl WorkerManager { - pub fn new(backend: Backend, sandbox_plan: SandboxCliPlan) -> Result<Self, WorkerError> { + pub fn new( + backend: Backend, + sandbox_plan: SandboxCliPlan, + oversized_output: OversizedOutputMode, + ) -> Result<Self, WorkerError> { let exe_path = std::env::current_exe()?; let sandbox_defaults = crate::sandbox::sandbox_state_defaults_with_environment(); let mut inherited_state = sandbox_defaults.clone(); @@ -522,12 +578,14 @@ impl WorkerManager { Err(err) => return Err(WorkerError::Sandbox(err)), }; crate::event_log::log_lazy("worker_manager_created", || { - worker_context_event_payload(backend, &sandbox_state, None) + worker_context_event_payload(backend, &sandbox_state) }); - let output_ring = ensure_output_ring(OUTPUT_RING_CAPACITY_BYTES); - reset_output_ring(); - reset_last_reply_marker_offset(); - let output_timeline = OutputTimeline::new(output_ring); + let output_timeline = { + let output_ring = ensure_output_ring(OUTPUT_RING_CAPACITY_BYTES); + reset_output_ring(); + reset_last_reply_marker_offset(); + OutputTimeline::new(output_ring) + }; Ok(Self { exe_path, backend, @@ -537,6 +595,8 @@ impl WorkerManager { inherited_sandbox_state: inherited_update_received.then_some(inherited_state), sandbox_defaults, sandbox_state, + oversized_output, + pending_output_tape: PendingOutputTape::new(), output: OutputBuffer::default(), pager: Pager::default(), output_timeline, @@ -547,6 +607,7 @@ impl WorkerManager { pending_request: false, pending_request_started_at: None, session_end_seen: false, + last_detached_prefix_item_count: 0, pager_prompt: None, last_prompt: None, last_spawn: None, @@ -565,6 +626,32 @@ impl WorkerManager { self.ensure_process() } + /// Exposes whether a timed-out logical request still owns future empty-input polls. + pub fn pending_request(&self) -> bool { + self.pending_request + } + + pub fn detached_prefix_item_count(&self) -> usize { + self.last_detached_prefix_item_count + } + + fn reset_preserving_detached_prefix_item_count(&mut self) -> Result<(), WorkerError> { + let detached_prefix_item_count = self.last_detached_prefix_item_count; + let result = self.reset(); + self.last_detached_prefix_item_count = detached_prefix_item_count; + result + } + + fn reset_with_pager_preserving_detached_prefix_item_count( + &mut self, + preserve_pager: bool, + ) -> Result<(), WorkerError> { + let detached_prefix_item_count = self.last_detached_prefix_item_count; + let result = self.reset_with_pager(preserve_pager); + self.last_detached_prefix_item_count = detached_prefix_item_count; + result + } + pub fn write_stdin( &mut self, text: String, @@ -573,6 +660,131 @@ impl WorkerManager { page_bytes_override: Option<u64>, echo_input: bool, ) -> Result<WorkerReply, WorkerError> { + match self.oversized_output { + OversizedOutputMode::Files => { + self.write_stdin_files(text, worker_timeout, server_timeout) + } + OversizedOutputMode::Pager => self.write_stdin_pager( + text, + worker_timeout, + server_timeout, + page_bytes_override, + echo_input, + ), + } + } + + /// Entry point for the public `repl` tool in default files mode. + fn write_stdin_files( + &mut self, + text: String, + worker_timeout: Duration, + server_timeout: Duration, + ) -> Result<WorkerReply, WorkerError> { + self.last_detached_prefix_item_count = 0; + if let Some((control, remaining)) = split_write_stdin_control_prefix(&text) { + self.clear_guardrail_busy_event(); + let control_reply = match control { + WriteStdinControlAction::Interrupt => self.interrupt(worker_timeout), + WriteStdinControlAction::Restart => self.restart(worker_timeout), + }?; + if remaining.is_empty() { + return Ok(control_reply); + } + return self.write_stdin_files(remaining.to_string(), worker_timeout, server_timeout); + } + + if self.guardrail_busy_event_pending() { + // Don't execute new input; the previous request was aborted. + let event = self + .guardrail + .event + .lock() + .expect("guardrail event mutex poisoned") + .take() + .expect("guardrail event should be present"); + self.guardrail.busy.store(false, Ordering::Relaxed); + let input_context = self.prepare_input_context_files(); + let err = WorkerError::Guardrail(event.message); + let reply = self.build_reply_from_worker_error_files(&err, input_context); + let _ = self.reset_preserving_detached_prefix_item_count(); + let reply = self.finalize_reply(reply); + self.maybe_reset_after_session_end(); + return Ok(reply); + } + + if let Err(err) = self.ensure_process() { + let input_context = self.prepare_input_context_files(); + let reply = self.build_reply_from_worker_error_files(&err, input_context); + let reply = self.finalize_reply(reply); + self.maybe_reset_after_session_end(); + return Ok(reply); + } + self.maybe_emit_guardrail_notice(); + self.resolve_timeout_marker(); + if text.is_empty() { + if self.pending_request || self.pending_output_tape.has_pending() { + let reply = self.poll_pending_output_files(worker_timeout)?; + let reply = self.finalize_reply(reply); + self.maybe_reset_after_session_end(); + return Ok(reply); + } + let reply = self.build_idle_poll_reply_files(); + let reply = self.finalize_reply(reply); + self.maybe_reset_after_session_end(); + return Ok(reply); + } + if !text.is_empty() && self.pending_request { + self.resolve_timeout_marker_with_wait(Duration::from_millis(25)); + } + if !text.is_empty() && self.pending_request { + let mut reply = self.poll_pending_output_files(worker_timeout)?; + let detached_prefix_item_count = match &reply.reply { + WorkerReply::Output { contents, .. } => contents.len(), + }; + self.last_detached_prefix_item_count = detached_prefix_item_count; + let WorkerReply::Output { + contents, + is_error, + error_code, + .. + } = &mut reply.reply; + contents.push(WorkerContent::server_stderr( + "[repl] input discarded while worker busy", + )); + *is_error = true; + *error_code = Some(WorkerErrorCode::Busy); + let reply = self.finalize_reply(reply); + self.maybe_reset_after_session_end(); + return Ok(reply); + } + + let input_context = self.prepare_input_context_files(); + + let request = match self.send_worker_request(text, worker_timeout, server_timeout) { + Ok(result) => result, + Err(err) => { + self.guardrail.busy.store(false, Ordering::Relaxed); + let reply = self.build_reply_from_worker_error_files(&err, input_context); + let _ = self.reset_preserving_detached_prefix_item_count(); + return Ok(self.finalize_reply(reply)); + } + }; + let reply = self.build_reply_from_request_files(request, input_context)?; + let reply = self.finalize_reply(reply); + self.maybe_reset_after_session_end(); + Ok(reply) + } + + fn write_stdin_pager( + &mut self, + text: String, + worker_timeout: Duration, + server_timeout: Duration, + page_bytes_override: Option<u64>, + echo_input: bool, + ) -> Result<WorkerReply, WorkerError> { + self.last_detached_prefix_item_count = 0; if let Some((control, remaining)) = split_write_stdin_control_prefix(&text) { self.clear_guardrail_busy_event(); let control_reply = match control { @@ -582,7 +794,7 @@ impl WorkerManager { if remaining.is_empty() { return Ok(control_reply); } - return self.write_stdin( + return self.write_stdin_pager( remaining.to_string(), worker_timeout, server_timeout, @@ -592,7 +804,6 @@ impl WorkerManager { } if self.guardrail_busy_event_pending() { - // Don't execute new input; the previous request was aborted. let event = self .guardrail .event @@ -602,11 +813,11 @@ impl WorkerManager { .expect("guardrail event should be present"); self.guardrail.busy.store(false, Ordering::Relaxed); let page_bytes = pager::resolve_page_bytes(page_bytes_override); - let input_context = self.prepare_input_context(&text, echo_input); + let input_context = self.prepare_input_context_pager(&text, echo_input); let err = WorkerError::Guardrail(event.message); - let reply = self.build_reply_from_worker_error(&err, input_context, page_bytes); + let reply = self.build_reply_from_worker_error_pager(&err, input_context, page_bytes); let preserve_pager = self.pager.is_active(); - let _ = self.reset_with_pager(preserve_pager); + let _ = self.reset_with_pager_preserving_detached_prefix_item_count(preserve_pager); let reply = self.finalize_reply(reply); self.maybe_reset_after_session_end(); return Ok(reply); @@ -614,9 +825,6 @@ impl WorkerManager { if self.pager.is_active() { let trimmed = text.trim(); - // While pager is active: - // - empty input and `:`-prefixed input are pager commands - // - other input auto-dismisses pager and is sent to the backend if trimmed.is_empty() || trimmed.starts_with(':') { if let Some(reply) = self.handle_pager_command(&text) { let reply = self.finalize_reply(reply); @@ -628,10 +836,11 @@ impl WorkerManager { self.pager_prompt = None; } } + let page_bytes = pager::resolve_page_bytes(page_bytes_override); if let Err(err) = self.ensure_process() { - let input_context = self.prepare_input_context(&text, echo_input); - let reply = self.build_reply_from_worker_error(&err, input_context, page_bytes); + let input_context = self.prepare_input_context_pager(&text, echo_input); + let reply = self.build_reply_from_worker_error_pager(&err, input_context, page_bytes); let reply = self.finalize_reply(reply); self.maybe_reset_after_session_end(); return Ok(reply); @@ -641,52 +850,51 @@ impl WorkerManager { self.resolve_timeout_marker(); if text.is_empty() { if self.pending_request || self.output.has_pending_output() { - let reply = self.poll_pending_output(worker_timeout, page_bytes)?; + let reply = self.poll_pending_output_pager(worker_timeout, page_bytes)?; let reply = self.finalize_reply(reply); self.maybe_reset_after_session_end(); return Ok(reply); } - let reply = self.build_idle_poll_reply(); + let reply = self.build_idle_poll_reply_pager(); let reply = self.finalize_reply(reply); self.maybe_reset_after_session_end(); return Ok(reply); } - if !text.is_empty() && self.pending_request { + if self.pending_request { self.resolve_timeout_marker_with_wait(Duration::from_millis(25)); } - if !text.is_empty() && self.pending_request { - let mut reply = self.poll_pending_output(worker_timeout, page_bytes)?; + if self.pending_request { + let mut reply = self.poll_pending_output_pager(worker_timeout, page_bytes)?; let WorkerReply::Output { contents, is_error, error_code, .. } = &mut reply.reply; - contents.push(WorkerContent::stderr( + contents.push(WorkerContent::server_stderr( "[repl] input discarded while worker busy", )); *is_error = true; - if error_code.is_none() { - *error_code = Some(WorkerErrorCode::Busy); - } + *error_code = Some(WorkerErrorCode::Busy); let reply = self.finalize_reply(reply); self.maybe_reset_after_session_end(); return Ok(reply); } - let input_context = self.prepare_input_context(&text, echo_input); + let input_context = self.prepare_input_context_pager(&text, echo_input); let request = match self.send_worker_request(text, worker_timeout, server_timeout) { Ok(result) => result, Err(err) => { self.guardrail.busy.store(false, Ordering::Relaxed); - let reply = self.build_reply_from_worker_error(&err, input_context, page_bytes); + let reply = + self.build_reply_from_worker_error_pager(&err, input_context, page_bytes); let preserve_pager = self.pager.is_active(); - let _ = self.reset_with_pager(preserve_pager); + let _ = self.reset_with_pager_preserving_detached_prefix_item_count(preserve_pager); return Ok(self.finalize_reply(reply)); } }; - let reply = self.build_reply_from_request(request, input_context, page_bytes)?; + let reply = self.build_reply_from_request_pager(request, input_context, page_bytes)?; let reply = self.finalize_reply(reply); self.maybe_reset_after_session_end(); Ok(reply) @@ -698,8 +906,6 @@ impl WorkerManager { } self.pager.refresh_from_output(&self.output); let mut reply = self.pager.handle_command(text); - // `handle_command()` may dismiss the pager (e.g. `q`, `tail`, reaching end). Only emit - // the backend prompt once the pager is no longer active. let pager_active = self.pager.is_active(); let WorkerReply::Output { contents, prompt, .. @@ -714,7 +920,7 @@ impl WorkerManager { } else { self.remember_prompt(resolved_prompt.clone()); if resolved_prompt.is_none() { - contents.push(WorkerContent::stderr( + contents.push(WorkerContent::server_stderr( "[repl] protocol error: missing prompt after pager dismiss", )); } @@ -725,7 +931,105 @@ impl WorkerManager { Some(ReplyWithOffset { reply, end_offset }) } - fn poll_pending_output( + /// Serves empty-input polls and busy follow-up replies for a timed-out request. + /// Each poll only returns newly available output, but the server may keep appending it to one transcript file. + fn poll_pending_output_files( + &mut self, + timeout: Duration, + ) -> Result<ReplyWithOffset, WorkerError> { + let poll_start = std::time::Instant::now(); + let mut timed_out = false; + let mut completion = CompletionInfo { + prompt: None, + prompt_variants: None, + echo_events: Vec::new(), + protocol_warnings: Vec::new(), + session_end_seen: false, + }; + + if self.pending_request { + match self.wait_for_request_completion(timeout) { + Ok(info) => { + self.clear_pending_request_state(); + if info.session_end_seen { + self.note_session_end(true); + } + completion = info; + } + Err(WorkerError::Timeout(_)) => { + let worker_exited = match self.process.as_mut() { + Some(process) => !process.is_running()?, + None => true, + }; + if worker_exited { + self.note_session_end(true); + self.clear_pending_request_state(); + completion.session_end_seen = true; + } else { + timed_out = true; + } + } + Err(err) => return Err(err), + } + } + + let FormattedPendingOutput { + mut contents, + saw_stderr, + } = if timed_out { + self.drain_formatted_output() + } else { + self.drain_final_formatted_output() + }; + let is_error = saw_stderr; + + if timed_out { + let elapsed = self + .pending_request_started_at + .map(|start| start.elapsed()) + .unwrap_or_else(|| poll_start.elapsed()); + contents.push(timeout_status_content(elapsed)); + } + + let session_end = completion.session_end_seen; + let resolved_prompt = normalize_prompt(completion.prompt.clone()); + let resolved_prompt = if session_end || timed_out { + None + } else { + resolved_prompt + }; + self.remember_prompt(resolved_prompt.clone()); + let trim_enabled = !timed_out && should_trim_echo_prefix(&completion.echo_events); + let echo_transcript = (!timed_out) + .then(|| echo_transcript_from_events(&completion.echo_events)) + .flatten(); + trim_echo_then_append_protocol_warnings( + &mut contents, + echo_transcript.as_deref(), + trim_enabled, + should_drop_echo_only_contents(&completion.echo_events), + &completion.protocol_warnings, + ); + if !timed_out && !session_end { + if let Some(prompt_text) = resolved_prompt.as_deref() { + strip_prompt_from_contents(&mut contents, prompt_text); + } + append_prompt_if_missing(&mut contents, resolved_prompt.clone()); + } + + Ok(ReplyWithOffset { + reply: WorkerReply::Output { + contents, + is_error, + error_code: timed_out.then_some(WorkerErrorCode::Timeout), + prompt: (!session_end).then_some(()).and(resolved_prompt), + prompt_variants: completion.prompt_variants.clone(), + }, + end_offset: 0, + }) + } + + fn poll_pending_output_pager( &mut self, timeout: Duration, page_bytes: u64, @@ -739,6 +1043,7 @@ impl WorkerManager { prompt: None, prompt_variants: None, echo_events: Vec::new(), + protocol_warnings: Vec::new(), session_end_seen: false, }; @@ -808,7 +1113,17 @@ impl WorkerManager { .unwrap_or_else(|| poll_start.elapsed()); contents.push(timeout_status_content(elapsed)); } - + let trim_enabled = !timed_out && should_trim_echo_prefix(&completion.echo_events); + let echo_transcript = (!timed_out) + .then(|| echo_transcript_from_events(&completion.echo_events)) + .flatten(); + trim_echo_then_append_protocol_warnings( + &mut contents, + echo_transcript.as_deref(), + trim_enabled, + should_drop_echo_only_contents(&completion.echo_events), + &completion.protocol_warnings, + ); pager::maybe_activate_and_append_footer( &mut self.pager, &mut contents, @@ -852,18 +1167,35 @@ impl WorkerManager { }) } - fn prepare_input_context(&mut self, text: &str, echo_input: bool) -> InputContext { + /// Drains detached output that arrived before the next accepted request so it can be prefixed + /// into that request's visible reply. + fn prepare_input_context_files(&mut self) -> InputContext { + let FormattedPendingOutput { + contents, + saw_stderr, + } = self.drain_final_formatted_output(); + InputContext { + prefix_contents: contents, + prefix_is_error: saw_stderr, + start_offset: 0, + prefix_bytes: 0, + input_echo: None, + input_transcript: None, + } + } + + fn prepare_input_context_pager(&mut self, text: &str, echo_input: bool) -> InputContext { self.output.start_capture(); - // We treat any output that arrives between tool calls as "prefix" output for the next - // request, and we include an explicit input marker so the LLM can attribute subsequent - // output without relying on prompt-like echoes. let had_pending_output = self.output.has_pending_output(); let saw_background_output = self.output.pending_output_since_last_reply(); + let prompt_hint = self.current_prompt_hint(); + self.remember_prompt(prompt_hint.clone()); let mut input_echo = echo_input .then(|| text.to_string()) .and_then(|value| pager::build_input_echo(&value)); + let input_transcript = build_input_transcript(prompt_hint.as_deref(), text); let mut prefix_contents = Vec::new(); let mut prefix_bytes: u64 = 0; @@ -887,11 +1219,12 @@ impl WorkerManager { } InputContext { - start_offset, prefix_contents, - prefix_bytes, prefix_is_error, + start_offset, + prefix_bytes, input_echo, + input_transcript, } } @@ -924,12 +1257,35 @@ impl WorkerManager { }) } - fn build_reply_from_worker_error( + fn build_reply_from_worker_error_files( + &mut self, + err: &WorkerError, + context: InputContext, + ) -> ReplyWithOffset { + self.last_detached_prefix_item_count = context.prefix_contents.len(); + let mut contents = context.prefix_contents; + let formatted = self.drain_final_formatted_output(); + contents.extend(formatted.contents); + contents.push(WorkerContent::server_stderr(format!("worker error: {err}"))); + ReplyWithOffset { + reply: WorkerReply::Output { + contents, + is_error: true, + error_code: worker_error_code(err), + prompt: None, + prompt_variants: None, + }, + end_offset: 0, + } + } + + fn build_reply_from_worker_error_pager( &mut self, err: &WorkerError, context: InputContext, page_bytes: u64, ) -> ReplyWithOffset { + self.last_detached_prefix_item_count = context.prefix_contents.len(); let end_offset = self.output.end_offset().unwrap_or(context.start_offset); let first_page_budget = page_bytes.saturating_sub(context.prefix_bytes); let mut contents = context.prefix_contents; @@ -951,7 +1307,7 @@ impl WorkerManager { buffer, last_range, ); - contents.push(WorkerContent::stderr(format!("worker error: {err}"))); + contents.push(WorkerContent::server_stderr(format!("worker error: {err}"))); ReplyWithOffset { reply: WorkerReply::Output { contents, @@ -964,14 +1320,115 @@ impl WorkerManager { } } - fn build_reply_from_request( + fn build_reply_from_request_files( + &mut self, + request: RequestState, + context: InputContext, + ) -> Result<ReplyWithOffset, WorkerError> { + self.last_detached_prefix_item_count = context.prefix_contents.len(); + match self.wait_for_request_completion(request.timeout) { + Ok(completion) => { + let mut session_end = completion.session_end_seen; + if !session_end + && let Some(process) = self.process.as_mut() + && !process.is_running()? + { + session_end = true; + } + if session_end { + self.note_session_end(true); + } + let mut contents = context.prefix_contents; + let formatted = self.drain_final_formatted_output(); + let is_error = context.prefix_is_error || formatted.saw_stderr; + contents.extend(formatted.contents); + let resolved_prompt = if session_end { + None + } else { + normalize_prompt(completion.prompt.clone()) + }; + self.remember_prompt(resolved_prompt.clone()); + let trim_enabled = should_trim_echo_prefix(&completion.echo_events); + let echo_transcript = echo_transcript_from_events(&completion.echo_events); + trim_echo_then_append_protocol_warnings( + &mut contents, + echo_transcript.as_deref(), + trim_enabled, + should_drop_echo_only_contents(&completion.echo_events), + &completion.protocol_warnings, + ); + if !session_end { + if let Some(prompt_text) = resolved_prompt.as_deref() { + strip_prompt_from_contents(&mut contents, prompt_text); + } + append_prompt_if_missing(&mut contents, resolved_prompt.clone()); + } + self.guardrail.busy.store(false, Ordering::Relaxed); + Ok(ReplyWithOffset { + reply: WorkerReply::Output { + contents, + is_error, + error_code: None, + prompt: (!session_end).then_some(()).and(resolved_prompt), + prompt_variants: completion.prompt_variants.clone(), + }, + end_offset: 0, + }) + } + Err(WorkerError::Timeout(_)) => { + if let Some(process) = self.process.as_mut() { + match process.is_running() { + Ok(true) => {} + Ok(false) => { + return Err(WorkerError::Protocol( + "worker connection closed unexpectedly".to_string(), + )); + } + Err(err) => { + return Err(err); + } + } + } + + self.pending_request = true; + self.pending_request_started_at = Some(request.started_at); + let mut contents = context.prefix_contents; + let formatted = self.drain_formatted_output(); + contents.extend(formatted.contents); + + contents.push(timeout_status_content(request.started_at.elapsed())); + + let is_error = context.prefix_is_error || formatted.saw_stderr; + + Ok(ReplyWithOffset { + reply: WorkerReply::Output { + contents, + is_error, + error_code: Some(WorkerErrorCode::Timeout), + prompt: None, + prompt_variants: None, + }, + end_offset: 0, + }) + } + Err(err) => { + let reply = self.build_reply_from_worker_error_files(&err, context); + let _ = self.reset_preserving_detached_prefix_item_count(); + Ok(reply) + } + } + } + + fn build_reply_from_request_pager( &mut self, request: RequestState, context: InputContext, page_bytes: u64, ) -> Result<ReplyWithOffset, WorkerError> { + self.last_detached_prefix_item_count = context.prefix_contents.len(); match self.wait_for_request_completion(request.timeout) { Ok(completion) => { + let fallback_input_transcript = context.input_transcript.clone(); let mut session_end = completion.session_end_seen; if !session_end && let Some(process) = self.process.as_mut() @@ -1022,6 +1479,25 @@ impl WorkerManager { if self.pager.is_active() && !session_end { self.pager_prompt = resolved_prompt.clone(); } + let has_fallback_input_transcript = fallback_input_transcript.is_some(); + let trim_enabled = if completion.echo_events.is_empty() { + has_fallback_input_transcript + } else { + should_trim_echo_prefix(&completion.echo_events) + }; + let echo_transcript = echo_transcript_from_events(&completion.echo_events) + .or(fallback_input_transcript); + trim_echo_then_append_protocol_warnings( + &mut contents, + echo_transcript.as_deref(), + trim_enabled, + if completion.echo_events.is_empty() { + has_fallback_input_transcript + } else { + should_drop_echo_only_contents(&completion.echo_events) + }, + &completion.protocol_warnings, + ); if !session_end { if let Some(prompt_text) = resolved_prompt.as_deref() { strip_prompt_from_contents(&mut contents, prompt_text); @@ -1045,6 +1521,7 @@ impl WorkerManager { }) } Err(WorkerError::Timeout(_)) => { + let fallback_input_transcript = context.input_transcript.clone(); if let Some(process) = self.process.as_mut() { match process.is_running() { Ok(true) => {} @@ -1074,6 +1551,10 @@ impl WorkerManager { last_range, } = snapshot_page_with_images(&self.output, end_offset, first_page_budget); contents.append(&mut page_contents); + maybe_trim_echo_prefix(&mut contents, fallback_input_transcript.as_deref(), true); + if let Some(echo) = fallback_input_transcript.as_deref() { + let _ = drop_echo_only_contents(&mut contents, echo); + } contents.push(timeout_status_content(request.started_at.elapsed())); @@ -1103,9 +1584,9 @@ impl WorkerManager { }) } Err(err) => { - let reply = self.build_reply_from_worker_error(&err, context, page_bytes); + let reply = self.build_reply_from_worker_error_pager(&err, context, page_bytes); let preserve_pager = self.pager.is_active(); - let _ = self.reset_with_pager(preserve_pager); + let _ = self.reset_with_pager_preserving_detached_prefix_item_count(preserve_pager); Ok(reply) } } @@ -1125,7 +1606,7 @@ impl WorkerManager { .get() .ok_or_else(|| WorkerError::Protocol("worker ipc unavailable".to_string()))?; let start = std::time::Instant::now(); - let mut result = self.driver.wait_for_completion(timeout, ipc); + let mut result = self.driver.wait_for_completion(timeout, ipc.clone()); if matches!( &result, Err(WorkerError::Protocol(message)) @@ -1148,6 +1629,7 @@ impl WorkerManager { prompt: None, prompt_variants: None, echo_events: Vec::new(), + protocol_warnings: ipc.take_protocol_warnings(), session_end_seen: true, }); } @@ -1179,11 +1661,17 @@ impl WorkerManager { let poll = Duration::from_millis(5); let start = std::time::Instant::now(); - let mut last = self.output.end_offset().unwrap_or(0); + let mut last = match self.oversized_output { + OversizedOutputMode::Files => self.pending_output_tape.current_seq(), + OversizedOutputMode::Pager => self.output.end_offset().unwrap_or(0), + }; let mut stable_for = Duration::from_millis(0); while start.elapsed() < total { thread::sleep(poll); - let now = self.output.end_offset().unwrap_or(0); + let now = match self.oversized_output { + OversizedOutputMode::Files => self.pending_output_tape.current_seq(), + OversizedOutputMode::Pager => self.output.end_offset().unwrap_or(0), + }; if now == last { stable_for = stable_for.saturating_add(poll); if stable_for >= stable_needed { @@ -1237,12 +1725,20 @@ impl WorkerManager { let Some(event) = slot.take() else { return; }; - self.output_timeline - .append_text(event.message.as_bytes(), true); + match self.oversized_output { + OversizedOutputMode::Files => self + .pending_output_tape + .append_server_stderr_bytes(event.message.as_bytes()), + OversizedOutputMode::Pager => self + .output_timeline + .append_text(event.message.as_bytes(), true), + } } fn finalize_reply(&self, reply: ReplyWithOffset) -> WorkerReply { - crate::output_capture::set_last_reply_marker_offset(reply.end_offset); + if matches!(self.oversized_output, OversizedOutputMode::Pager) { + set_last_reply_marker_offset(reply.end_offset); + } reply.reply } @@ -1256,10 +1752,24 @@ impl WorkerManager { if !message.ends_with('\n') { message.push('\n'); } - self.output_timeline.append_text(message.as_bytes(), true); + match self.oversized_output { + OversizedOutputMode::Files => self + .pending_output_tape + .append_server_stderr_bytes(message.as_bytes()), + OversizedOutputMode::Pager => { + self.output_timeline.append_text(message.as_bytes(), true); + } + } } else { let message = "[repl] session ended\n".to_string(); - self.output_timeline.append_text(message.as_bytes(), false); + match self.oversized_output { + OversizedOutputMode::Files => self + .pending_output_tape + .append_stdout_status_line(message.as_bytes()), + OversizedOutputMode::Pager => { + self.output_timeline.append_text(message.as_bytes(), false); + } + } } } } @@ -1267,13 +1777,162 @@ impl WorkerManager { fn maybe_reset_after_session_end(&mut self) { if self.session_end_seen { - let preserve_pager = self.pager.is_active(); - let _ = self.reset_with_pager(preserve_pager); + let _ = match self.oversized_output { + OversizedOutputMode::Files => self.reset_preserving_detached_prefix_item_count(), + OversizedOutputMode::Pager => self + .reset_with_pager_preserving_detached_prefix_item_count(self.pager.is_active()), + }; self.session_end_seen = false; } } pub fn interrupt(&mut self, timeout: Duration) -> Result<WorkerReply, WorkerError> { + match self.oversized_output { + OversizedOutputMode::Files => self.interrupt_files(timeout), + OversizedOutputMode::Pager => self.interrupt_pager(timeout), + } + } + + fn interrupt_files(&mut self, timeout: Duration) -> Result<WorkerReply, WorkerError> { + crate::event_log::log( + "worker_interrupt_begin", + serde_json::json!({ + "timeout_ms": timeout.as_millis(), + }), + ); + self.ensure_process()?; + if let Err(err) = self.driver.interrupt( + self.process + .as_mut() + .expect("worker process should be available"), + ) { + self.reset()?; + crate::event_log::log( + "worker_interrupt_error", + serde_json::json!({ + "error": err.to_string(), + }), + ); + return Err(err); + } + + if self.pending_request { + let mut reply = self.poll_pending_output_files(timeout)?; + let prompt = match &reply.reply { + WorkerReply::Output { prompt, .. } => prompt.clone(), + }; + let WorkerReply::Output { contents, .. } = &mut reply.reply; + if let Some(prompt) = prompt.as_deref() { + strip_trailing_prompt(contents, prompt); + } + if let Some(prompt) = prompt { + append_prompt_if_missing(contents, Some(prompt)); + } + let reply = self.finalize_reply(reply); + self.maybe_reset_after_session_end(); + return Ok(reply); + } + + let mut timed_out = false; + let mut prompt: Option<String> = None; + if let Some(process) = self.process.as_ref() + && let Some(ipc) = process.ipc.get() + { + let result = ipc.wait_for_prompt(timeout); + match result { + Ok(value) => { + prompt = Some(value); + } + Err(IpcWaitError::Timeout) => { + timed_out = true; + } + Err(IpcWaitError::SessionEnd) => { + self.note_session_end(true); + } + Err(IpcWaitError::Disconnected) => { + // IPC is optional for the R backend; fall back to prompt-as-output. + } + } + } + + let FormattedPendingOutput { + mut contents, + saw_stderr, + } = self.drain_formatted_output(); + let is_error = saw_stderr; + + if timed_out { + contents.push(timeout_status_content(timeout)); + } + + let session_end = self.session_end_seen; + let resolved_prompt = normalize_prompt(prompt.clone()); + let resolved_prompt = if session_end || timed_out { + None + } else { + resolved_prompt + }; + self.remember_prompt(resolved_prompt.clone()); + if !session_end { + if let Some(prompt_text) = resolved_prompt.as_deref() { + strip_trailing_prompt(&mut contents, prompt_text); + } + if !timed_out { + append_prompt_if_missing(&mut contents, resolved_prompt.clone()); + } + } + + let reply = WorkerReply::Output { + contents, + is_error, + error_code: timed_out.then_some(WorkerErrorCode::Timeout), + prompt: (!session_end).then_some(()).and(resolved_prompt), + prompt_variants: None, + }; + crate::event_log::log( + "worker_interrupt_end", + serde_json::json!({ + "timed_out": timed_out, + "session_end": session_end, + }), + ); + Ok(self.finalize_reply(ReplyWithOffset { + reply, + end_offset: 0, + })) + } + + pub fn restart(&mut self, timeout: Duration) -> Result<WorkerReply, WorkerError> { + match self.oversized_output { + OversizedOutputMode::Files => self.restart_files(timeout), + OversizedOutputMode::Pager => self.restart_pager(timeout), + } + } + + fn restart_files(&mut self, timeout: Duration) -> Result<WorkerReply, WorkerError> { + crate::event_log::log( + "worker_restart_begin", + serde_json::json!({ + "timeout_ms": timeout.as_millis(), + }), + ); + if self.awaiting_initial_sandbox_state_update { + return Err(WorkerError::Sandbox( + MISSING_INHERITED_SANDBOX_STATE_MESSAGE.to_string(), + )); + } + if let Some(process) = self.process.take() { + let _ = process.shutdown_graceful(timeout); + } + self.guardrail.busy.store(false, Ordering::Relaxed); + + let reply = self.build_session_reset_reply_files("new session started"); + self.reset_output_state_files(true); + crate::event_log::log("worker_restart_end", serde_json::json!({"status": "ok"})); + Ok(self.finalize_reply(reply)) + } + + fn interrupt_pager(&mut self, timeout: Duration) -> Result<WorkerReply, WorkerError> { crate::event_log::log( "worker_interrupt_begin", serde_json::json!({ @@ -1298,7 +1957,7 @@ impl WorkerManager { let page_bytes = pager::resolve_page_bytes(None); if self.pending_request { - let mut reply = self.poll_pending_output(timeout, page_bytes)?; + let mut reply = self.poll_pending_output_pager(timeout, page_bytes)?; let pager_active = self.pager.is_active(); let prompt = match &reply.reply { WorkerReply::Output { prompt, .. } => prompt.clone(), @@ -1333,9 +1992,7 @@ impl WorkerManager { Err(IpcWaitError::SessionEnd) => { self.note_session_end(true); } - Err(IpcWaitError::Disconnected) => { - // IPC is optional for the R backend; fall back to prompt-as-output. - } + Err(IpcWaitError::Disconnected) => {} } } @@ -1348,8 +2005,6 @@ impl WorkerManager { let is_error = self .output .saw_stderr_in_range(start_offset.min(end_offset), end_offset); - let page_is_error = is_error; - let SnapshotWithImages { mut contents, pages_left, @@ -1365,7 +2020,7 @@ impl WorkerManager { &mut self.pager, &mut contents, pages_left, - page_is_error, + is_error, buffer, last_range, ); @@ -1409,7 +2064,7 @@ impl WorkerManager { Ok(self.finalize_reply(ReplyWithOffset { reply, end_offset })) } - pub fn restart(&mut self, timeout: Duration) -> Result<WorkerReply, WorkerError> { + fn restart_pager(&mut self, timeout: Duration) -> Result<WorkerReply, WorkerError> { crate::event_log::log( "worker_restart_begin", serde_json::json!({ @@ -1427,8 +2082,8 @@ impl WorkerManager { self.guardrail.busy.store(false, Ordering::Relaxed); let page_bytes = pager::resolve_page_bytes(None); - let reply = self.build_session_reset_reply(page_bytes, "new session started"); - self.reset_output_state(false); + let reply = self.build_session_reset_reply_pager(page_bytes, "new session started"); + self.reset_output_state_pager(true, false); crate::event_log::log("worker_restart_end", serde_json::json!({"status": "ok"})); Ok(self.finalize_reply(reply)) } @@ -1454,9 +2109,16 @@ impl WorkerManager { if needs_spawn { if let Some(process) = self.process.take() { - process.cleanup_session_tmpdir(); + process.finish_exited()?; } - self.process = Some(self.spawn_process()?); + match self.oversized_output { + OversizedOutputMode::Files => self.reset_output_state_files(false), + OversizedOutputMode::Pager => self.reset_output_state_pager(true, false), + } + self.process = Some(match self.oversized_output { + OversizedOutputMode::Files => self.spawn_process_files()?, + OversizedOutputMode::Pager => self.spawn_process_with_pager(false)?, + }); } Ok(()) @@ -1467,13 +2129,19 @@ impl WorkerManager { if let Some(process) = self.process.take() { let _ = process.kill(); } - self.guardrail.busy.store(false, Ordering::Relaxed); if self.awaiting_initial_sandbox_state_update { return Err(WorkerError::Sandbox( MISSING_INHERITED_SANDBOX_STATE_MESSAGE.to_string(), )); } - self.process = Some(self.spawn_process()?); + match self.oversized_output { + OversizedOutputMode::Files => self.reset_output_state_files(true), + OversizedOutputMode::Pager => self.reset_output_state_pager(true, false), + } + self.process = Some(match self.oversized_output { + OversizedOutputMode::Files => self.spawn_process_files()?, + OversizedOutputMode::Pager => self.spawn_process_with_pager(false)?, + }); crate::event_log::log("worker_reset_end", serde_json::json!({"status": "ok"})); Ok(()) } @@ -1488,12 +2156,12 @@ impl WorkerManager { if let Some(process) = self.process.take() { let _ = process.kill(); } - self.guardrail.busy.store(false, Ordering::Relaxed); if self.awaiting_initial_sandbox_state_update { return Err(WorkerError::Sandbox( MISSING_INHERITED_SANDBOX_STATE_MESSAGE.to_string(), )); } + self.reset_output_state_pager(true, preserve_pager); self.process = Some(self.spawn_process_with_pager(preserve_pager)?); crate::event_log::log( "worker_reset_with_pager_end", @@ -1539,8 +2207,14 @@ impl WorkerManager { ); if !changed { if awaiting_before && self.process.is_none() { - self.guardrail.busy.store(false, Ordering::Relaxed); - self.process = Some(self.spawn_process()?); + match self.oversized_output { + OversizedOutputMode::Files => self.reset_output_state_files(true), + OversizedOutputMode::Pager => self.reset_output_state_pager(true, false), + } + self.process = Some(match self.oversized_output { + OversizedOutputMode::Files => self.spawn_process_files()?, + OversizedOutputMode::Pager => self.spawn_process_with_pager(false)?, + }); return Ok(true); } return Ok(false); @@ -1549,12 +2223,33 @@ impl WorkerManager { if let Some(process) = self.process.take() { let _ = process.shutdown_graceful(timeout); } - self.guardrail.busy.store(false, Ordering::Relaxed); - self.process = Some(self.spawn_process()?); + match self.oversized_output { + OversizedOutputMode::Files => self.reset_output_state_files(true), + OversizedOutputMode::Pager => self.reset_output_state_pager(true, false), + } + self.process = Some(match self.oversized_output { + OversizedOutputMode::Files => self.spawn_process_files()?, + OversizedOutputMode::Pager => self.spawn_process_with_pager(false)?, + }); Ok(true) } - fn reset_output_state(&mut self, preserve_pager: bool) { + fn reset_output_state_files(&mut self, clear_pending_output: bool) { + if clear_pending_output { + self.pending_output_tape.clear(); + } + self.pending_request = false; + self.pending_request_started_at = None; + self.session_end_seen = false; + self.last_detached_prefix_item_count = 0; + self.last_prompt = None; + self.guardrail.busy.store(false, Ordering::Relaxed); + } + + fn reset_output_state_pager(&mut self, clear_pending_output: bool, preserve_pager: bool) { + if clear_pending_output { + self.pending_output_tape.clear(); + } reset_output_ring(); reset_last_reply_marker_offset(); self.output = OutputBuffer::default(); @@ -1564,6 +2259,7 @@ impl WorkerManager { self.pending_request = false; self.pending_request_started_at = None; self.session_end_seen = false; + self.last_detached_prefix_item_count = 0; self.pager_prompt = None; self.last_prompt = None; self.guardrail.busy.store(false, Ordering::Relaxed); @@ -1585,7 +2281,34 @@ impl WorkerManager { prompt.or_else(|| self.last_prompt.clone()) } - fn build_idle_poll_reply(&mut self) -> ReplyWithOffset { + fn drain_formatted_output(&self) -> FormattedPendingOutput { + self.pending_output_tape.drain_snapshot().format_contents() + } + + fn drain_final_formatted_output(&self) -> FormattedPendingOutput { + self.pending_output_tape + .drain_final_snapshot() + .format_contents() + } + + fn build_idle_poll_reply_files(&mut self) -> ReplyWithOffset { + let prompt = self.current_prompt_hint(); + self.remember_prompt(prompt.clone()); + let mut contents = vec![idle_status_content()]; + append_prompt_if_missing(&mut contents, prompt.clone()); + ReplyWithOffset { + reply: WorkerReply::Output { + contents, + is_error: false, + error_code: None, + prompt, + prompt_variants: None, + }, + end_offset: 0, + } + } + + fn build_idle_poll_reply_pager(&mut self) -> ReplyWithOffset { let prompt = self.current_prompt_hint(); self.remember_prompt(prompt.clone()); let mut contents = vec![idle_status_content()]; @@ -1602,15 +2325,16 @@ impl WorkerManager { } } - fn spawn_process(&mut self) -> Result<WorkerProcess, WorkerError> { - self.reset_output_state(false); + fn spawn_process_files(&mut self) -> Result<WorkerProcess, WorkerError> { crate::event_log::log_lazy("worker_spawn_begin", || { - worker_context_event_payload(self.backend, &self.sandbox_state, Some(false)) + worker_context_event_payload(self.backend, &self.sandbox_state) }); let process = WorkerProcess::spawn( self.backend, &self.exe_path, &self.sandbox_state, + self.oversized_output, + self.pending_output_tape.clone(), self.output_timeline.clone(), self.guardrail.clone(), )?; @@ -1645,14 +2369,15 @@ impl WorkerManager { &mut self, preserve_pager: bool, ) -> Result<WorkerProcess, WorkerError> { - self.reset_output_state(preserve_pager); crate::event_log::log_lazy("worker_spawn_begin", || { - worker_context_event_payload(self.backend, &self.sandbox_state, Some(preserve_pager)) + worker_context_event_payload(self.backend, &self.sandbox_state) }); let process = WorkerProcess::spawn( self.backend, &self.exe_path, &self.sandbox_state, + self.oversized_output, + self.pending_output_tape.clone(), self.output_timeline.clone(), self.guardrail.clone(), )?; @@ -1734,8 +2459,9 @@ impl WorkerManager { match status { Ok(()) => { self.settle_output_after_request_end(Duration::from_millis(120)); - let offset = self.output.end_offset().unwrap_or(0); - crate::output_capture::update_last_reply_marker_offset_max(offset); + if matches!(self.oversized_output, OversizedOutputMode::Pager) { + update_last_reply_marker_offset_max(self.output.end_offset().unwrap_or(0)); + } self.clear_pending_request_state(); } Err(IpcWaitError::SessionEnd) => { @@ -1762,7 +2488,33 @@ impl WorkerManager { self.guardrail.busy.store(false, Ordering::Relaxed); } - fn build_session_reset_reply(&mut self, page_bytes: u64, meta: &str) -> ReplyWithOffset { + fn build_session_reset_reply_files(&mut self, meta: &str) -> ReplyWithOffset { + let FormattedPendingOutput { + mut contents, + saw_stderr, + } = self.drain_final_formatted_output(); + contents.retain(|content| match content { + WorkerContent::ContentText { text, .. } => !text.trim().is_empty(), + _ => true, + }); + let is_error = saw_stderr; + if !meta.is_empty() { + contents.push(WorkerContent::server_stderr(format!("[repl] {meta}"))); + } + + ReplyWithOffset { + reply: WorkerReply::Output { + contents, + is_error, + error_code: None, + prompt: None, + prompt_variants: None, + }, + end_offset: 0, + } + } + + fn build_session_reset_reply_pager(&mut self, page_bytes: u64, meta: &str) -> ReplyWithOffset { let end_offset = self.output.end_offset().unwrap_or(0); let mut is_error = false; @@ -1786,7 +2538,7 @@ impl WorkerManager { } if !meta.is_empty() { - contents.push(WorkerContent::stderr(format!("[repl] {meta}"))); + contents.push(WorkerContent::server_stderr(format!("[repl] {meta}"))); } pager::maybe_activate_and_append_footer( @@ -1832,8 +2584,6 @@ fn snapshot_page_with_images( .all(|content| !matches!(content, WorkerContent::ContentImage { .. })) && !image_groups.is_empty() { - // The pager snapshot may exclude image events when the text page is tiny (e.g. just a - // prompt). Ensure we still surface the final images for this capture range. let max = pager::MAX_IMAGES_PER_PAGE.min(image_groups.len()); for (_, image) in image_groups.into_iter().take(max) { contents.push(image); @@ -1897,9 +2647,6 @@ fn snapshot_after_completion( ) -> CompletionSnapshot { let trim_enabled = should_trim_echo_prefix(&completion.echo_events); if !trim_enabled { - // Multi-expression inputs can produce huge echoed transcripts even when most lines are - // silent. Collapse echoed input aggressively (while preserving attribution to the - // relevant expression) so we don't page/hang on pure echo. let saw_stderr = output.saw_stderr_in_range(start_offset.min(end_offset), end_offset); let range = output.read_range(start_offset, end_offset); output.advance_offset_to(end_offset); @@ -1921,9 +2668,6 @@ fn snapshot_after_completion( let echo_transcript = echo_transcript_from_events(&completion.echo_events); if let Some(echo) = echo_transcript.as_deref() { - // Large multi-line inputs can be echoed back line-by-line by the backend, which can trip - // the pager and waste tokens even when the input is silent. If the turn's captured output - // is exactly the echoed bytes, drop it entirely. let _ = drop_echo_only_output(output, start_offset, end_offset, echo); } @@ -2014,7 +2758,7 @@ fn append_image_groups_after_page( break; } if offset > last_offset { - contents.push(WorkerContent::stderr(format!( + contents.push(WorkerContent::server_stderr(format!( "[pager] elided output: @{last_offset}..{offset}\n" ))); } @@ -2036,14 +2780,32 @@ fn echo_transcript_from_events(events: &[IpcEchoEvent]) -> Option<String> { Some(transcript) } -fn line_matches_echo_event(line: &[u8], event: &IpcEchoEvent) -> bool { +fn echo_event_prefix_len(line: &[u8], event: &IpcEchoEvent) -> Option<usize> { let prompt = event.prompt.as_bytes(); let consumed = event.line.as_bytes(); - if line.len() != prompt.len().saturating_add(consumed.len()) { - return false; + if line.len() == prompt.len().saturating_add(consumed.len()) { + let (prefix, suffix) = line.split_at(prompt.len()); + if prefix == prompt && suffix == consumed { + return Some(line.len()); + } + } + + let consumed = if let Some(consumed) = consumed.strip_suffix(b"\r\n") { + consumed + } else if let Some(consumed) = consumed.strip_suffix(b"\n") { + consumed + } else { + return None; + }; + let prefix_len = prompt.len().saturating_add(consumed.len()); + if line.len() <= prefix_len { + return None; } let (prefix, suffix) = line.split_at(prompt.len()); - prefix == prompt && suffix == consumed + if prefix != prompt || !suffix.starts_with(consumed) { + return None; + } + Some(prefix_len) } #[derive(Default)] @@ -2146,6 +2908,8 @@ fn collapse_echo_with_attribution( echo_events: &[IpcEchoEvent], prompt_variants: &[String], ) -> (Vec<u8>, Vec<(u64, OutputEventKind)>, Vec<OutputTextSpan>) { + use std::cell::Cell; + const ECHO_MARKER_MIN_BYTES: usize = 512; let mut out_bytes: Vec<u8> = Vec::new(); @@ -2155,6 +2919,7 @@ fn collapse_echo_with_attribution( let prompt_variants = prompt_variants_bytes(prompt_variants); let mut pending = PendingEchoRun::default(); let mut echo_idx = 0usize; + let saw_substantive_output = Cell::new(false); let base_offset = range.start_offset; let end_offset = range.end_offset; @@ -2182,6 +2947,9 @@ fn collapse_echo_with_attribution( return; } let pending = pending.take(); + if !saw_substantive_output.get() { + return; + } let head = pending.head.as_deref().unwrap_or_default(); let tail = pending.tail.as_deref().unwrap_or_default(); if pending.lines >= 2 || pending.bytes >= ECHO_MARKER_MIN_BYTES { @@ -2214,6 +2982,7 @@ fn collapse_echo_with_attribution( &mut echo_idx, &prompt_variants, &mut pending, + &saw_substantive_output, &mut flush_pending, &mut out_bytes, &mut out_text_spans, @@ -2238,6 +3007,7 @@ fn collapse_echo_with_attribution( &mut echo_idx, &prompt_variants, &mut pending, + &saw_substantive_output, &mut flush_pending, &mut out_bytes, &mut out_text_spans, @@ -2283,6 +3053,7 @@ fn consume_text_segment_with_spans( echo_idx: &mut usize, prompt_variants: &[Vec<u8>], pending: &mut PendingEchoRun, + saw_substantive_output: &std::cell::Cell<bool>, flush_pending: &mut impl FnMut(&mut Vec<u8>, &mut Vec<OutputTextSpan>, &mut PendingEchoRun), out_bytes: &mut Vec<u8>, out_text_spans: &mut Vec<OutputTextSpan>, @@ -2306,6 +3077,7 @@ fn consume_text_segment_with_spans( echo_idx, prompt_variants, pending, + saw_substantive_output, flush_pending, out_bytes, out_text_spans, @@ -2318,6 +3090,7 @@ fn consume_text_segment_with_spans( echo_idx, prompt_variants, pending, + saw_substantive_output, flush_pending, out_bytes, out_text_spans, @@ -2332,6 +3105,7 @@ fn consume_text_segment_with_spans( echo_idx, prompt_variants, pending, + saw_substantive_output, flush_pending, out_bytes, out_text_spans, @@ -2347,6 +3121,7 @@ fn consume_text_segment( echo_idx: &mut usize, prompt_variants: &[Vec<u8>], pending: &mut PendingEchoRun, + saw_substantive_output: &std::cell::Cell<bool>, flush_pending: &mut impl FnMut(&mut Vec<u8>, &mut Vec<OutputTextSpan>, &mut PendingEchoRun), out_bytes: &mut Vec<u8>, out_text_spans: &mut Vec<OutputTextSpan>, @@ -2363,36 +3138,69 @@ fn consume_text_segment( let line = &segment[start..end]; start = end; - let is_echo = - *echo_idx < echo_events.len() && line_matches_echo_event(line, &echo_events[*echo_idx]); - if is_echo { - pending.push(line); + let echo_prefix = if *echo_idx < echo_events.len() { + echo_event_prefix_len(line, &echo_events[*echo_idx]) + } else { + None + }; + if let Some(prefix_len) = echo_prefix { + pending.push(&line[..prefix_len]); *echo_idx = echo_idx.saturating_add(1); - continue; + if prefix_len == line.len() { + continue; + } } + let line = if let Some(prefix_len) = echo_prefix { + &line[prefix_len..] + } else { + line + }; + let substantive = !is_ascii_whitespace_only(line) && !is_prompt_only_fragment(line, prompt_variants); if substantive { flush_pending(out_bytes, out_text_spans, pending); } append_text_with_span(out_bytes, out_text_spans, line, is_stderr); + if substantive { + saw_substantive_output.set(true); + } } } fn should_trim_echo_prefix(events: &[IpcEchoEvent]) -> bool { - if events.is_empty() { + let Some((first, rest)) = events.split_first() else { + return false; + }; + if !is_primary_repl_prompt(&first.prompt) { return false; } - if events.len() == 1 { + if rest.is_empty() { return true; } - events - .iter() - .skip(1) + rest.iter() .all(|event| is_continuation_prompt(&event.prompt)) } +fn should_drop_echo_only_contents(events: &[IpcEchoEvent]) -> bool { + let Some((first, rest)) = events.split_first() else { + return false; + }; + if !is_primary_repl_prompt(&first.prompt) { + return false; + } + rest.iter() + .all(|event| is_primary_repl_prompt(&event.prompt) || is_continuation_prompt(&event.prompt)) +} + +fn is_primary_repl_prompt(prompt: &str) -> bool { + matches!( + prompt.trim_end_matches(|ch: char| ch.is_whitespace()), + ">" | ">>>" + ) +} + fn is_continuation_prompt(prompt: &str) -> bool { let trimmed = prompt.trim_end_matches(|ch: char| ch.is_whitespace()); if trimmed.is_empty() { @@ -2424,7 +3232,7 @@ fn maybe_trim_echo_prefix( if remaining.is_empty() { break; } - let WorkerContent::ContentText { text, stream } = content else { + let WorkerContent::ContentText { text, stream, .. } = content else { return; }; if !matches!(stream, TextStream::Stdout) { @@ -2530,6 +3338,56 @@ fn drop_echo_only_output( true } +fn drop_echo_only_contents(contents: &mut Vec<WorkerContent>, echo: &str) -> bool { + if echo.is_empty() { + return false; + } + + let mut remaining = echo; + for content in contents.iter() { + let WorkerContent::ContentText { + text, + stream, + origin, + } = content + else { + return false; + }; + if !matches!(stream, TextStream::Stdout) || !matches!(origin, ContentOrigin::Worker) { + return false; + } + if remaining.len() >= text.len() { + if !remaining.starts_with(text.as_str()) { + return false; + } + remaining = &remaining[text.len()..]; + } else { + return false; + } + } + + if !remaining.is_empty() { + return false; + } + + contents.clear(); + true +} + +fn trim_echo_then_append_protocol_warnings( + contents: &mut Vec<WorkerContent>, + echo: Option<&str>, + trim_enabled: bool, + drop_echo_only_enabled: bool, + warnings: &[String], +) { + maybe_trim_echo_prefix(contents, echo, trim_enabled); + if drop_echo_only_enabled && let Some(echo) = echo { + let _ = drop_echo_only_contents(contents, echo); + } + append_protocol_warnings(contents, warnings); +} + fn normalize_prompt(prompt: Option<String>) -> Option<String> { prompt.filter(|value| !value.is_empty()) } @@ -2538,16 +3396,32 @@ fn normalize_input_newlines(text: &str) -> String { text.replace("\r\n", "\n").replace('\r', "\n") } +fn build_input_transcript(prompt: Option<&str>, input: &str) -> Option<String> { + let prompt = prompt?; + let normalized = normalize_input_newlines(input); + let trimmed = normalized.trim_end_matches('\n').trim_end(); + if trimmed.is_empty() || trimmed.contains('\n') { + return None; + } + Some(format!("{prompt}{trimmed}\n")) +} + fn timeout_status_content(timeout: Duration) -> WorkerContent { let elapsed_ms = duration_to_millis(timeout); let elapsed_ms = (elapsed_ms / TIMEOUT_STATUS_GRANULARITY_MS) * TIMEOUT_STATUS_GRANULARITY_MS; - WorkerContent::stdout(format!( - "<<console status: busy, write_stdin timeout reached; elapsed_ms={elapsed_ms}>>" + WorkerContent::server_stdout(format!( + "<<repl status: busy, write_stdin timeout reached; elapsed_ms={elapsed_ms}>>" )) } fn idle_status_content() -> WorkerContent { - WorkerContent::stdout("<<console status: idle>>") + WorkerContent::server_stdout("<<repl status: idle>>") +} + +fn append_protocol_warnings(contents: &mut Vec<WorkerContent>, warnings: &[String]) { + for warning in warnings { + contents.push(WorkerContent::server_stderr(format!("[repl] {warning}"))); + } } const TIMEOUT_STATUS_GRANULARITY_MS: u64 = 100; @@ -2567,7 +3441,7 @@ fn append_prompt_if_missing(contents: &mut Vec<WorkerContent>, prompt: Option<St { return; } - contents.push(WorkerContent::stdout(prompt)); + contents.push(WorkerContent::worker_stdout(prompt)); } fn strip_trailing_prompt(contents: &mut Vec<WorkerContent>, prompt: &str) { @@ -2580,7 +3454,7 @@ fn strip_trailing_prompt(contents: &mut Vec<WorkerContent>, prompt: &str) { let Some(idx) = idx else { return; }; - let WorkerContent::ContentText { text, stream } = &contents[idx] else { + let WorkerContent::ContentText { text, stream, .. } = &contents[idx] else { return; }; let Some(prefix) = text.strip_suffix(prompt) else { @@ -2592,6 +3466,7 @@ fn strip_trailing_prompt(contents: &mut Vec<WorkerContent>, prompt: &str) { contents[idx] = WorkerContent::ContentText { text: prefix.to_string(), stream: *stream, + origin: crate::worker_protocol::ContentOrigin::Worker, }; } } @@ -2603,7 +3478,7 @@ fn strip_prompt_from_contents(contents: &mut Vec<WorkerContent>, prompt: &str) { let mut idx = 0usize; while idx < contents.len() { let remove = match &contents[idx] { - WorkerContent::ContentText { text, stream } => { + WorkerContent::ContentText { text, stream, .. } => { if !matches!(stream, crate::worker_protocol::TextStream::Stdout) { false } else if text == prompt { @@ -2615,6 +3490,7 @@ fn strip_prompt_from_contents(contents: &mut Vec<WorkerContent>, prompt: &str) { contents[idx] = WorkerContent::ContentText { text: prefix.to_string(), stream: *stream, + origin: crate::worker_protocol::ContentOrigin::Worker, }; false } @@ -2637,6 +3513,8 @@ struct WorkerProcess { stdin_tx: mpsc::Sender<StdinCommand>, session_tmpdir: Option<PathBuf>, ipc: IpcHandle, + stdout_reader: Option<std::thread::JoinHandle<()>>, + stderr_reader: Option<std::thread::JoinHandle<()>>, expected_exit: bool, exit_status: Option<std::process::ExitStatus>, #[cfg(target_family = "unix")] @@ -2663,6 +3541,8 @@ struct SpawnedWorker { child: Child, stdin_tx: mpsc::Sender<StdinCommand>, session_tmpdir: Option<PathBuf>, + stdout_reader: Option<std::thread::JoinHandle<()>>, + stderr_reader: Option<std::thread::JoinHandle<()>>, #[cfg(target_os = "macos")] denial_logger: Option<crate::sandbox::DenialLogger>, } @@ -2755,6 +3635,8 @@ impl WorkerProcess { backend: Backend, exe_path: &Path, sandbox_state: &SandboxState, + oversized_output: OversizedOutputMode, + pending_output_tape: PendingOutputTape, output_timeline: OutputTimeline, guardrail: GuardrailShared, ) -> Result<Self, WorkerError> { @@ -2762,21 +3644,28 @@ impl WorkerProcess { let _ = &guardrail; let mut ipc_server = IpcServer::bind().map_err(WorkerError::Io)?; + let live_output = LiveOutputCapture::new( + oversized_output, + pending_output_tape.clone(), + output_timeline.clone(), + ); let SpawnedWorker { child, stdin_tx, session_tmpdir, + stdout_reader, + stderr_reader, #[cfg(target_os = "macos")] denial_logger, } = match backend { Backend::R => Self::spawn_r_worker( exe_path, sandbox_state, - output_timeline.clone(), + live_output.clone(), &mut ipc_server, )?, Backend::Python => { - Self::spawn_python_worker(sandbox_state, output_timeline.clone(), &mut ipc_server)? + Self::spawn_python_worker(sandbox_state, live_output.clone(), &mut ipc_server)? } }; #[allow(unused_mut)] @@ -2785,16 +3674,36 @@ impl WorkerProcess { let ipc = IpcHandle::new(); #[cfg(any(target_family = "unix", target_family = "windows"))] { - let image_timeline = output_timeline.clone(); + let image_capture = live_output.clone(); + let sideband_capture = live_output.clone(); let handlers = IpcHandlers { on_plot_image: Some(Arc::new(move |image: IpcPlotImage| { - image_timeline.append_image( - image.id, - image.mime_type, - image.data, - image.is_new, - ); + image_capture.append_image(image); })), + on_readline_start: Some(Arc::new(move |prompt: String| { + sideband_capture.append_sideband(PendingSidebandKind::ReadlineStart { prompt }); + })), + on_readline_result: { + let sideband_capture = live_output.clone(); + Some(Arc::new(move |event: IpcEchoEvent| { + sideband_capture.append_sideband(PendingSidebandKind::ReadlineResult { + prompt: event.prompt, + line: event.line, + }); + })) + }, + on_request_end: { + let sideband_capture = live_output.clone(); + Some(Arc::new(move || { + sideband_capture.append_sideband(PendingSidebandKind::RequestEnd); + })) + }, + on_session_end: { + let sideband_capture = live_output.clone(); + Some(Arc::new(move || { + sideband_capture.append_sideband(PendingSidebandKind::SessionEnd); + })) + }, }; #[cfg(target_family = "unix")] ipc_server @@ -2821,6 +3730,8 @@ impl WorkerProcess { stdin_tx, session_tmpdir, ipc, + stdout_reader, + stderr_reader, expected_exit: false, exit_status: None, #[cfg(target_family = "unix")] @@ -2837,7 +3748,7 @@ impl WorkerProcess { fn spawn_r_worker( exe_path: &Path, sandbox_state: &SandboxState, - output_timeline: OutputTimeline, + live_output: LiveOutputCapture, ipc_server: &mut IpcServer, ) -> Result<SpawnedWorker, WorkerError> { let prepared = @@ -2905,8 +3816,10 @@ impl WorkerProcess { .take() .ok_or_else(|| WorkerError::Protocol("worker stdin unavailable".to_string()))?; let stdin_tx = spawn_stdin_writer(stdin); - spawn_output_reader(child.stdout.take(), false, output_timeline.clone()); - spawn_output_reader(child.stderr.take(), true, output_timeline.clone()); + let stdout_reader = + spawn_output_reader(child.stdout.take(), TextStream::Stdout, live_output.clone()); + let stderr_reader = + spawn_output_reader(child.stderr.take(), TextStream::Stderr, live_output.clone()); #[cfg(target_os = "macos")] let mut denial_logger = prepared.denial_logger; @@ -2919,6 +3832,8 @@ impl WorkerProcess { child, stdin_tx, session_tmpdir, + stdout_reader, + stderr_reader, #[cfg(target_os = "macos")] denial_logger, }) @@ -2937,13 +3852,13 @@ impl WorkerProcess { fn spawn_python_worker( sandbox_state: &SandboxState, - output_timeline: OutputTimeline, + live_output: LiveOutputCapture, ipc_server: &mut IpcServer, ) -> Result<SpawnedWorker, WorkerError> { #[cfg(not(target_family = "unix"))] { let _ = sandbox_state; - let _ = output_timeline; + let _ = live_output; let _ = ipc_server; Err(WorkerError::Protocol( "python backend requires a unix-style pty".to_string(), @@ -3014,7 +3929,8 @@ impl WorkerProcess { let master_reader = master.try_clone()?; let stdin_tx = spawn_stdin_writer(master); // Python runs under a PTY so stdout/stderr are merged. - spawn_output_reader(Some(master_reader), false, output_timeline.clone()); + let stdout_reader = + spawn_output_reader(Some(master_reader), TextStream::Stdout, live_output.clone()); #[cfg(target_os = "macos")] let mut denial_logger = prepared.denial_logger; @@ -3027,6 +3943,8 @@ impl WorkerProcess { child, stdin_tx, session_tmpdir, + stdout_reader, + stderr_reader: None, #[cfg(target_os = "macos")] denial_logger, }) @@ -3246,6 +4164,7 @@ impl WorkerProcess { } } + self.quiesce_output_producers()?; self.cleanup_session_tmpdir(); self.report_denials(); Ok(()) @@ -3258,11 +4177,39 @@ impl WorkerProcess { self.kill_process_tree_scan(libc::SIGKILL); } let _ = self.child.wait(); + self.quiesce_output_producers()?; self.cleanup_session_tmpdir(); self.report_denials(); Ok(()) } + fn finish_exited(mut self) -> Result<(), WorkerError> { + if self.exit_status.is_none() { + self.exit_status = Some(self.child.wait()?); + } + self.quiesce_output_producers()?; + self.cleanup_session_tmpdir(); + self.report_denials(); + Ok(()) + } + + fn quiesce_output_producers(&mut self) -> Result<(), WorkerError> { + if let Some(handle) = self.stdout_reader.take() { + handle.join().map_err(|_| { + WorkerError::Protocol("worker stdout reader thread panicked".to_string()) + })?; + } + if let Some(handle) = self.stderr_reader.take() { + handle.join().map_err(|_| { + WorkerError::Protocol("worker stderr reader thread panicked".to_string()) + })?; + } + if let Some(ipc) = self.ipc.get() { + ipc.join_reader_thread().map_err(WorkerError::Io)?; + } + Ok(()) + } + fn cleanup_session_tmpdir(&self) { let Some(path) = self.session_tmpdir.as_ref() else { return; @@ -3534,28 +4481,28 @@ fn open_pty_pair() -> Result<(File, File), WorkerError> { Ok((master, slave)) } -fn spawn_output_reader<R>(stream: Option<R>, is_stderr: bool, timeline: OutputTimeline) +fn spawn_output_reader<R>( + stream: Option<R>, + output_stream: TextStream, + live_output: LiveOutputCapture, +) -> Option<std::thread::JoinHandle<()>> where R: Read + Send + 'static, { - let Some(mut stream) = stream else { - return; - }; - thread::spawn(move || { + let mut stream = stream?; + Some(thread::spawn(move || { let mut buffer = [0u8; 8192]; loop { match stream.read(&mut buffer) { Ok(0) => { break; } - Ok(n) => { - timeline.append_text(&buffer[..n], is_stderr); - } + Ok(n) => live_output.append_text(&buffer[..n], output_stream), Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue, Err(_) => break, } } - }); + })) } fn spawn_stdin_writer<W>(stdin: W) -> mpsc::Sender<StdinCommand> @@ -3677,6 +4624,9 @@ fn worker_error_code(err: &WorkerError) -> Option<WorkerErrorCode> { #[cfg(test)] mod tests { use super::*; + use crate::output_capture::{ + OUTPUT_RING_CAPACITY_BYTES, OutputBuffer, ensure_output_ring, reset_output_ring, + }; use crate::sandbox::SandboxPolicy; use std::sync::{Mutex, OnceLock}; @@ -3736,6 +4686,48 @@ mod tests { assert_eq!(text, "stderr: boom\n"); } + #[test] + fn trim_echo_then_append_protocol_warnings_drops_echo_only_multiline_input() { + let warning = "ReadlineResult after RequestEnd".to_string(); + let echo = "> x <- 1\n> y <- 2\n"; + let mut contents = vec![WorkerContent::stdout(echo)]; + + trim_echo_then_append_protocol_warnings( + &mut contents, + Some(echo), + false, + true, + std::slice::from_ref(&warning), + ); + + assert_eq!( + contents, + vec![WorkerContent::server_stderr(format!("[repl] {warning}"))] + ); + } + + #[test] + fn trim_echo_then_append_protocol_warnings_keeps_output_before_warning() { + let warning = "ReadlineResult after RequestEnd".to_string(); + let mut contents = vec![WorkerContent::stdout("> x <- 1\n[1] 1\n")]; + + trim_echo_then_append_protocol_warnings( + &mut contents, + Some("> x <- 1\n"), + true, + true, + std::slice::from_ref(&warning), + ); + + assert_eq!( + contents, + vec![ + WorkerContent::stdout("[1] 1\n"), + WorkerContent::server_stderr(format!("[repl] {warning}")), + ] + ); + } + #[test] fn trim_decision_respects_continuation_prompts() { let single = vec![echo_event("> ", "1+1\n")]; @@ -3746,6 +4738,76 @@ mod tests { let multi = vec![echo_event("> ", "1+1\n"), echo_event("> ", "2+2\n")]; assert!(!should_trim_echo_prefix(&multi)); + + let browser = vec![echo_event("Browse[1]> ", "n\n")]; + assert!(!should_trim_echo_prefix(&browser)); + + let readline = vec![echo_event("FIRST> ", "alpha\n")]; + assert!(!should_trim_echo_prefix(&readline)); + } + + #[test] + fn collapse_echo_with_attribution_drops_leading_multi_expression_echo_prefix() { + let range = OutputRange { + start_offset: 0, + end_offset: 27, + bytes: b"> x <- 1\n> y <- 2\n[1] 2\n> ".to_vec(), + events: Vec::new(), + text_spans: vec![OutputTextSpan { + start_byte: 0, + end_byte: 27, + is_stderr: false, + }], + }; + + let (bytes, events, text_spans) = collapse_echo_with_attribution( + range, + &[echo_event("> ", "x <- 1\n"), echo_event("> ", "y <- 2\n")], + &["> ".to_string()], + ); + + assert_eq!(String::from_utf8(bytes).expect("utf8"), "[1] 2\n> "); + assert!(events.is_empty(), "did not expect sideband events"); + assert_eq!( + text_spans.len(), + 1, + "expected collapsed output to stay in one stdout span" + ); + assert_eq!(text_spans[0].start_byte, 0); + assert_eq!(text_spans[0].end_byte, 8); + assert!(!text_spans[0].is_stderr); + } + + #[test] + fn collapse_echo_with_attribution_drops_leading_echo_prefix_without_separator_newline() { + let range = OutputRange { + start_offset: 0, + end_offset: 42, + bytes: b"> xstderr: Error: object 'x' not found\n> ".to_vec(), + events: Vec::new(), + text_spans: vec![OutputTextSpan { + start_byte: 0, + end_byte: 42, + is_stderr: false, + }], + }; + + let (bytes, events, text_spans) = + collapse_echo_with_attribution(range, &[echo_event("> ", "x\n")], &["> ".to_string()]); + + assert_eq!( + String::from_utf8(bytes).expect("utf8"), + "stderr: Error: object 'x' not found\n> " + ); + assert!(events.is_empty(), "did not expect sideband events"); + assert_eq!( + text_spans.len(), + 1, + "expected collapsed output to stay in one stdout span" + ); + assert_eq!(text_spans[0].start_byte, 0); + assert_eq!(text_spans[0].end_byte, 38); + assert!(!text_spans[0].is_stderr); } #[test] @@ -3792,6 +4854,245 @@ mod tests { assert_eq!(completion.prompt.as_deref(), Some("> ")); } + #[test] + fn completion_settle_waits_for_late_echo_events() { + let (server, worker) = crate::ipc::test_connection_pair().expect("ipc pair"); + driver_on_input_start("1+\n1", &server); + let prompt = "> ".to_string(); + let delayed_worker = worker.clone(); + + let _ = worker.send(WorkerToServerIpcMessage::ReadlineStart { + prompt: prompt.clone(), + }); + + let late_sender = thread::spawn(move || { + thread::sleep(Duration::from_millis(1)); + let _ = delayed_worker.send(WorkerToServerIpcMessage::ReadlineResult { + prompt: "> ".to_string(), + line: "1+\n".to_string(), + }); + thread::sleep(Duration::from_millis(21)); + let _ = delayed_worker.send(WorkerToServerIpcMessage::ReadlineResult { + prompt: "+ ".to_string(), + line: "1\n".to_string(), + }); + let _ = delayed_worker.send(WorkerToServerIpcMessage::RequestEnd); + }); + + let completion = driver_wait_for_completion(Duration::from_millis(200), server) + .expect("expected completion after request-end"); + late_sender.join().expect("late sender should join"); + + assert_eq!(completion.prompt.as_deref(), Some("> ")); + assert_eq!(completion.echo_events.len(), 2); + assert!(completion.protocol_warnings.is_empty()); + assert_eq!(completion.echo_events[0].prompt, "> "); + assert_eq!(completion.echo_events[0].line, "1+\n"); + assert_eq!(completion.echo_events[1].prompt, "+ "); + assert_eq!(completion.echo_events[1].line, "1\n"); + } + + #[test] + fn completion_warns_when_readline_result_arrives_after_request_end() { + let (server, worker) = crate::ipc::test_connection_pair().expect("ipc pair"); + driver_on_input_start("1+1", &server); + + let _ = worker.send(WorkerToServerIpcMessage::ReadlineStart { + prompt: "> ".to_string(), + }); + let _ = worker.send(WorkerToServerIpcMessage::RequestEnd); + + let delayed_worker = worker.clone(); + let late_sender = thread::spawn(move || { + thread::sleep(Duration::from_millis(1)); + let _ = delayed_worker.send(WorkerToServerIpcMessage::ReadlineResult { + prompt: "> ".to_string(), + line: "1+1\n".to_string(), + }); + }); + + let completion = driver_wait_for_completion(Duration::from_millis(200), server) + .expect("expected completion after request-end"); + late_sender.join().expect("late sender should join"); + + assert!( + completion + .protocol_warnings + .iter() + .any(|warning| warning.contains("ReadlineResult after RequestEnd")), + "expected protocol warning, got: {:?}", + completion.protocol_warnings + ); + } + + #[test] + fn next_request_result_is_retained_when_prompt_is_already_active() { + let (server, worker) = crate::ipc::test_connection_pair().expect("ipc pair"); + + driver_on_input_start("first()", &server); + let _ = worker.send(WorkerToServerIpcMessage::RequestEnd); + let _ = worker.send(WorkerToServerIpcMessage::ReadlineStart { + prompt: "> ".to_string(), + }); + let first = driver_wait_for_completion(Duration::from_millis(200), server.clone()) + .expect("expected first completion"); + assert_eq!(first.prompt.as_deref(), Some("> ")); + + driver_on_input_start("second()", &server); + let _ = worker.send(WorkerToServerIpcMessage::ReadlineResult { + prompt: "> ".to_string(), + line: "second()\n".to_string(), + }); + let _ = worker.send(WorkerToServerIpcMessage::RequestEnd); + + let second = driver_wait_for_completion(Duration::from_millis(200), server) + .expect("expected second completion"); + + assert!(second.protocol_warnings.is_empty()); + assert_eq!(second.echo_events.len(), 1); + assert_eq!(second.echo_events[0].prompt, "> "); + assert_eq!(second.echo_events[0].line, "second()\n"); + } + + #[test] + fn completion_preserves_echo_events_when_next_prompt_arrives_immediately() { + let (server, worker) = crate::ipc::test_connection_pair().expect("ipc pair"); + + driver_on_input_start("first()", &server); + let _ = worker.send(WorkerToServerIpcMessage::ReadlineStart { + prompt: "> ".to_string(), + }); + let _ = worker.send(WorkerToServerIpcMessage::ReadlineResult { + prompt: "> ".to_string(), + line: "first()\n".to_string(), + }); + let _ = worker.send(WorkerToServerIpcMessage::RequestEnd); + let _ = worker.send(WorkerToServerIpcMessage::ReadlineStart { + prompt: "> ".to_string(), + }); + + let completion = driver_wait_for_completion(Duration::from_millis(200), server) + .expect("expected completion after request-end"); + + assert_eq!(completion.prompt.as_deref(), Some("> ")); + assert!(completion.protocol_warnings.is_empty()); + assert_eq!(completion.echo_events.len(), 1); + assert_eq!(completion.echo_events[0].prompt, "> "); + assert_eq!(completion.echo_events[0].line, "first()\n"); + } + + #[test] + fn completion_retains_echo_events_when_session_ends_before_request_end() { + let (server, worker) = crate::ipc::test_connection_pair().expect("ipc pair"); + driver_on_input_start("quit()", &server); + + let _ = worker.send(WorkerToServerIpcMessage::ReadlineStart { + prompt: "> ".to_string(), + }); + let _ = worker.send(WorkerToServerIpcMessage::ReadlineResult { + prompt: "> ".to_string(), + line: "quit()\n".to_string(), + }); + let _ = worker.send(WorkerToServerIpcMessage::SessionEnd); + + let completion = driver_wait_for_completion(Duration::from_millis(200), server) + .expect("expected completion after session end"); + + assert!(completion.session_end_seen); + assert_eq!(completion.echo_events.len(), 1); + assert_eq!(completion.echo_events[0].prompt, "> "); + assert_eq!(completion.echo_events[0].line, "quit()\n"); + } + + #[test] + fn send_worker_request_error_preserves_detached_prefix_count() { + let mut manager = WorkerManager::new( + Backend::R, + SandboxCliPlan::default(), + crate::oversized_output::OversizedOutputMode::Files, + ) + .expect("worker manager"); + manager + .pending_output_tape + .append_stdout_bytes(b"detached output\n"); + + let reply = manager + .write_stdin( + "1+1".to_string(), + Duration::from_millis(50), + Duration::ZERO, + None, + false, + ) + .expect("reply"); + + if let Some(process) = manager.process.take() { + let _ = process.kill(); + } + + assert!( + manager.detached_prefix_item_count() >= 1, + "detached-prefix metadata must survive reset until server-side finalization" + ); + let WorkerReply::Output { .. } = reply; + } + + #[test] + fn session_end_reset_preserves_detached_prefix_count() { + let mut manager = WorkerManager::new( + Backend::R, + SandboxCliPlan::default(), + crate::oversized_output::OversizedOutputMode::Files, + ) + .expect("worker manager"); + manager.last_detached_prefix_item_count = 2; + manager.session_end_seen = true; + + manager.maybe_reset_after_session_end(); + + if let Some(process) = manager.process.take() { + let _ = process.kill(); + } + + assert_eq!( + manager.detached_prefix_item_count(), + 2, + "session-end cleanup must preserve detached-prefix metadata until server finalization" + ); + } + + #[test] + fn pager_output_capture_skips_pending_output_tape() { + let output_ring = ensure_output_ring(OUTPUT_RING_CAPACITY_BYTES); + reset_output_ring(); + let output = OutputBuffer::default(); + output.start_capture(); + + let tape = PendingOutputTape::new(); + let capture = LiveOutputCapture::new( + OversizedOutputMode::Pager, + tape.clone(), + OutputTimeline::new(output_ring), + ); + capture.append_text(b"pager output\n", TextStream::Stdout); + capture.append_image(IpcPlotImage { + id: "img-1".to_string(), + data: "AA==".to_string(), + mime_type: "image/png".to_string(), + is_new: true, + }); + capture.append_sideband(PendingSidebandKind::RequestEnd); + + assert!( + tape.drain_final_snapshot().events.is_empty(), + "pager mode should not mirror text, images, or sideband events into the pending tape" + ); + assert!( + output.end_offset().unwrap_or(0) > 0, + "pager mode should still append text to the output timeline" + ); + } + #[test] fn python_driver_uses_small_ipc_request_start_signal() { let (server, worker) = crate::ipc::test_connection_pair().expect("ipc pair"); @@ -3829,7 +5130,11 @@ mod tests { std::env::set_var("TMPDIR", &non_utf8_tmpdir); } let result = std::panic::catch_unwind(|| { - WorkerManager::new(Backend::Python, SandboxCliPlan::default()) + WorkerManager::new( + Backend::Python, + SandboxCliPlan::default(), + crate::oversized_output::OversizedOutputMode::Files, + ) }); match original_tmpdir { @@ -3873,7 +5178,12 @@ mod tests { ), ], }; - let mut manager = WorkerManager::new(Backend::Python, plan).expect("worker manager"); + let mut manager = WorkerManager::new( + Backend::Python, + plan, + crate::oversized_output::OversizedOutputMode::Files, + ) + .expect("worker manager"); let inherited_before = manager .inherited_sandbox_state .clone() diff --git a/src/worker_protocol.rs b/src/worker_protocol.rs index 3337696..4f08978 100644 --- a/src/worker_protocol.rs +++ b/src/worker_protocol.rs @@ -14,12 +14,24 @@ pub enum WorkerErrorCode { Interrupted, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum ContentOrigin { + /// Text that came from the worker REPL and is eligible for transcript files. + #[default] + Worker, + /// Text synthesized by the server, such as timeout or busy-status notices. + Server, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(tag = "type", rename_all = "snake_case")] pub enum WorkerContent { ContentText { text: String, stream: TextStream, + #[serde(default)] + origin: ContentOrigin, }, ContentImage { data: String, @@ -29,7 +41,7 @@ pub enum WorkerContent { }, } -#[derive(Debug, Serialize, Deserialize, Clone, Copy)] +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum TextStream { Stdout, @@ -57,17 +69,46 @@ pub enum WorkerReply { } impl WorkerContent { + #[allow(dead_code)] pub fn stdout(text: impl Into<String>) -> Self { + Self::worker_stdout(text) + } + + #[allow(dead_code)] + pub fn stderr(text: impl Into<String>) -> Self { + Self::worker_stderr(text) + } + + pub fn worker_stdout(text: impl Into<String>) -> Self { WorkerContent::ContentText { text: text.into(), stream: TextStream::Stdout, + origin: ContentOrigin::Worker, } } - pub fn stderr(text: impl Into<String>) -> Self { + #[allow(dead_code)] + pub fn worker_stderr(text: impl Into<String>) -> Self { + WorkerContent::ContentText { + text: text.into(), + stream: TextStream::Stderr, + origin: ContentOrigin::Worker, + } + } + + pub fn server_stdout(text: impl Into<String>) -> Self { + WorkerContent::ContentText { + text: text.into(), + stream: TextStream::Stdout, + origin: ContentOrigin::Server, + } + } + + pub fn server_stderr(text: impl Into<String>) -> Self { WorkerContent::ContentText { text: text.into(), stream: TextStream::Stderr, + origin: ContentOrigin::Server, } } } diff --git a/tests/claude_integration.rs b/tests/claude_integration.rs index 05f1035..7beec6c 100644 --- a/tests/claude_integration.rs +++ b/tests/claude_integration.rs @@ -169,7 +169,8 @@ fn resolve_mcp_repl_path() -> TestResult<PathBuf> { let mut path = env::current_exe()?; path.pop(); path.pop(); - for candidate in ["mcp-repl"] { + { + let candidate = "mcp-repl"; let mut candidate_path = path.clone(); candidate_path.push(candidate); if cfg!(windows) { diff --git a/tests/codex_approvals_tui.rs b/tests/codex_approvals_tui.rs index a9143f9..cbeebee 100644 --- a/tests/codex_approvals_tui.rs +++ b/tests/codex_approvals_tui.rs @@ -503,7 +503,7 @@ mod unix_impl { end += 1; } if end > abs + marker.len() && text[end..].starts_with("ms") { - out.push_str("N"); + out.push('N'); idx = end; } else { idx = abs + marker.len(); @@ -600,7 +600,8 @@ mod unix_impl { let mut path = std::env::current_exe()?; path.pop(); path.pop(); - for candidate in ["mcp-repl"] { + { + let candidate = "mcp-repl"; let mut candidate_path = path.clone(); candidate_path.push(candidate); if cfg!(windows) { diff --git a/tests/common/mod.rs b/tests/common/mod.rs index dfe397b..ece0dfd 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -6,6 +6,7 @@ use std::pin::Pin; #[cfg(target_os = "macos")] use std::sync::OnceLock; +use regex_lite::Regex; use rmcp::ServiceExt; use rmcp::handler::client::ClientHandler; use rmcp::model::{ @@ -21,7 +22,6 @@ use tokio::process::Command; pub type TestResult<T> = Result<T, Box<dyn Error + Send + Sync>>; const TEST_PAGER_PAGE_CHARS: u64 = 300; -const PAGER_PAGE_CHARS_ENV: &str = "MCP_REPL_PAGER_PAGE_CHARS"; #[cfg(windows)] const WINDOWS_TEST_TIMEOUT_CAP_SECS: f64 = 60.0; @@ -211,7 +211,7 @@ fn strip_trailing_prompt(text: &str) -> String { } fn normalize_text_snapshot(text: &str) -> String { - let normalized = normalize_newlines(text.to_string()); + let normalized = normalize_output_bundle_paths(&normalize_newlines(text.to_string())); let mut stripped = strip_trailing_prompt(&normalized); while stripped.ends_with('\n') { stripped.pop(); @@ -222,6 +222,31 @@ fn normalize_text_snapshot(text: &str) -> String { stripped } +fn normalize_output_bundle_paths(text: &str) -> String { + static OUTPUT_BUNDLE_PATH_RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new(); + let re = OUTPUT_BUNDLE_PATH_RE.get_or_init(|| { + Regex::new( + r#"(?x) + (?:[A-Za-z]:)?(?:[/\\][^\s\]"')]+)*[/\\] + mcp-repl-output(?:-[A-Za-z0-9]+)?[/\\] + output-\d{4}[/\\] + (?:transcript\.txt|events\.log) + "#, + ) + .expect("output bundle path regex") + }); + re.replace_all(text, |captures: ®ex_lite::Captures<'_>| { + let matched = captures.get(0).expect("full match").as_str(); + let leaf = if matched.ends_with("events.log") { + "events.log" + } else { + "transcript.txt" + }; + format!("<mcp-repl-output>/output-0001/{leaf}") + }) + .into_owned() +} + fn pretty_json(value: &Value) -> String { normalize_newlines(serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string())) } @@ -365,15 +390,12 @@ impl McpTestSession { } }; - let result = self - .service - .call_tool(CallToolRequestParams { - meta: None, - name: request_tool.into(), - arguments: arguments_for_mcp, - task: None, - }) - .await; + let request = match arguments_for_mcp { + Some(arguments) => CallToolRequestParams::new(request_tool).with_arguments(arguments), + None => CallToolRequestParams::new(request_tool), + }; + + let result = self.service.call_tool(request).await; let response = match result { Ok(result) => SnapshotResponse::ToolResult(tool_result_snapshot(&result)), @@ -406,14 +428,12 @@ impl McpTestSession { ))); } }; - self.service - .call_tool(CallToolRequestParams { - meta: None, - name: request_tool.into(), - arguments, - task: None, - }) - .await + let request = match arguments { + Some(arguments) => CallToolRequestParams::new(request_tool).with_arguments(arguments), + None => CallToolRequestParams::new(request_tool), + }; + + self.service.call_tool(request).await } pub async fn write_stdin_raw_with( @@ -422,7 +442,7 @@ impl McpTestSession { timeout: Option<f64>, ) -> Result<rmcp::model::CallToolResult, ServiceError> { let mut input = input.into(); - if !input.ends_with('\n') { + if !input.is_empty() && !input.ends_with('\n') { input.push('\n'); } let timeout = normalized_test_timeout(timeout); @@ -514,6 +534,43 @@ impl McpSnapshot { Ok(()) } + pub async fn files_session<F>(&mut self, name: impl Into<String>, f: F) -> TestResult<()> + where + F: for<'a> FnOnce( + &'a mut McpTestSession, + ) + -> Pin<Box<dyn std::future::Future<Output = TestResult<()>> + Send + 'a>>, + { + let name = name.into(); + let mut session = spawn_server_with_files().await?; + f(&mut session).await?; + let steps = session.steps.clone(); + session.cancel().await?; + self.sessions.push((name, steps)); + Ok(()) + } + + pub async fn pager_session<F>( + &mut self, + name: impl Into<String>, + page_chars: u64, + f: F, + ) -> TestResult<()> + where + F: for<'a> FnOnce( + &'a mut McpTestSession, + ) + -> Pin<Box<dyn std::future::Future<Output = TestResult<()>> + Send + 'a>>, + { + let name = name.into(); + let mut session = spawn_server_with_pager_page_chars(page_chars).await?; + f(&mut session).await?; + let steps = session.steps.clone(); + session.cancel().await?; + self.sessions.push((name, steps)); + Ok(()) + } + pub fn render(&self) -> String { self.render_json() } @@ -631,7 +688,7 @@ fn normalize_snapshot_text(text: &str) -> String { if text.starts_with("\n[repl] session ended") { return text.trim_start_matches('\n').to_string(); } - let text = normalize_busy_timeout_elapsed_ms(&normalize_pager_elision(text)); + let text = normalize_busy_timeout_elapsed_ms(text); if !text.contains("stderr:") { return text; } @@ -699,34 +756,6 @@ fn normalize_busy_timeout_elapsed_ms(text: &str) -> String { out } -fn normalize_pager_elision(text: &str) -> String { - let marker = "[pager] elided output: @"; - let mut out = String::with_capacity(text.len()); - let mut idx = 0; - while let Some(pos) = text[idx..].find(marker) { - let abs = idx + pos; - out.push_str(&text[idx..abs]); - out.push_str(marker); - let mut end = abs + marker.len(); - let bytes = text.as_bytes(); - while end < bytes.len() && bytes[end].is_ascii_digit() { - end += 1; - } - if end + 1 < bytes.len() && bytes[end] == b'.' && bytes[end + 1] == b'.' { - end += 2; - while end < bytes.len() && bytes[end].is_ascii_digit() { - end += 1; - } - out.push_str("N..N"); - } else { - out.push('N'); - } - idx = end; - } - out.push_str(&text[idx..]); - out -} - fn snapshot_response_is_error(response: &SnapshotResponse) -> bool { match response { SnapshotResponse::ToolResult(result) => matches!(result.is_error, Some(true)), @@ -901,7 +930,11 @@ fn strip_prompt_prefix(line: &str) -> Option<&str> { } pub async fn spawn_server() -> TestResult<McpTestSession> { - spawn_server_with_pager_page_chars(TEST_PAGER_PAGE_CHARS).await + spawn_server_with_args_env(Vec::new(), Vec::new()).await +} + +pub async fn spawn_server_with_files() -> TestResult<McpTestSession> { + spawn_server_with_args(vec!["--oversized-output".to_string(), "files".to_string()]).await } pub async fn spawn_server_with_pager_page_chars(page_bytes: u64) -> TestResult<McpTestSession> { @@ -911,12 +944,33 @@ pub async fn spawn_server_with_pager_page_chars(page_bytes: u64) -> TestResult<M pub async fn spawn_server_with_env_vars( env_vars: Vec<(String, String)>, ) -> TestResult<McpTestSession> { - spawn_server_with_args_env_and_pager_page_chars(Vec::new(), env_vars, TEST_PAGER_PAGE_CHARS) - .await + spawn_server_with_args_env(Vec::new(), env_vars).await +} + +pub async fn spawn_server_with_files_env_vars( + env_vars: Vec<(String, String)>, +) -> TestResult<McpTestSession> { + spawn_server_with_args_env( + vec!["--oversized-output".to_string(), "files".to_string()], + env_vars, + ) + .await } pub async fn spawn_server_with_args(args: Vec<String>) -> TestResult<McpTestSession> { - spawn_server_with_args_env_and_pager_page_chars(args, Vec::new(), TEST_PAGER_PAGE_CHARS).await + spawn_server_with_args_env(args, Vec::new()).await +} + +pub async fn spawn_python_server_with_files() -> TestResult<McpTestSession> { + spawn_server_with_args(vec![ + "--interpreter".to_string(), + "python".to_string(), + "--oversized-output".to_string(), + "files".to_string(), + "--sandbox".to_string(), + "danger-full-access".to_string(), + ]) + .await } pub async fn spawn_python_server() -> TestResult<McpTestSession> { @@ -962,6 +1016,21 @@ pub async fn spawn_server_with_args_env_and_pager_page_chars( args: Vec<String>, env_vars: Vec<(String, String)>, page_bytes: u64, +) -> TestResult<McpTestSession> { + let mut args = args; + args.push("--oversized-output".to_string()); + args.push("pager".to_string()); + let mut env_vars = env_vars; + env_vars.push(( + "MCP_REPL_PAGER_PAGE_CHARS".to_string(), + page_bytes.to_string(), + )); + spawn_server_with_args_env(args, env_vars).await +} + +pub async fn spawn_server_with_args_env( + args: Vec<String>, + env_vars: Vec<(String, String)>, ) -> TestResult<McpTestSession> { let exe = resolve_server_path()?; let env_vars = env_vars.clone(); @@ -981,7 +1050,6 @@ pub async fn spawn_server_with_args_env_and_pager_page_chars( cmd.env_remove("R_ENVIRON"); cmd.env_remove("R_ENVIRON_USER"); cmd.env_remove("MCP_REPL_UPDATE_PLOT_IMAGES"); - cmd.env(PAGER_PAGE_CHARS_ENV, page_bytes.to_string()); cmd.args(&args); for (key, value) in &env_vars { cmd.env(key, value); @@ -1025,7 +1093,8 @@ fn resolve_server_path() -> TestResult<PathBuf> { let mut path = std::env::current_exe()?; path.pop(); path.pop(); - for candidate in ["mcp-repl"] { + { + let candidate = "mcp-repl"; let mut candidate_path = path.clone(); candidate_path.push(candidate); if cfg!(windows) { diff --git a/tests/debug_events_env.rs b/tests/debug_events_env.rs index 42724c9..7f7ce31 100644 --- a/tests/debug_events_env.rs +++ b/tests/debug_events_env.rs @@ -21,7 +21,8 @@ mod unix { let mut path = std::env::current_exe()?; path.pop(); path.pop(); - for candidate in ["mcp-repl"] { + { + let candidate = "mcp-repl"; let mut candidate_path = path.clone(); candidate_path.push(candidate); if candidate_path.exists() { diff --git a/tests/debug_repl_prompt.rs b/tests/debug_repl_prompt.rs index 0859fc9..a338ba6 100644 --- a/tests/debug_repl_prompt.rs +++ b/tests/debug_repl_prompt.rs @@ -1,8 +1,11 @@ -use std::io::Read; +use std::fs; +use std::io::{Read, Write}; use std::path::PathBuf; use std::process::{Command, Stdio}; use std::sync::mpsc; +use std::sync::{Mutex, OnceLock}; use std::time::{Duration, Instant}; +use tempfile::tempdir; type TestResult<T> = Result<T, Box<dyn std::error::Error + Send + Sync>>; @@ -30,7 +33,8 @@ fn resolve_mcp_repl_path() -> TestResult<PathBuf> { let mut path = std::env::current_exe()?; path.pop(); path.pop(); - for candidate in ["mcp-repl"] { + { + let candidate = "mcp-repl"; let mut candidate_path = path.clone(); candidate_path.push(candidate); if cfg!(windows) { @@ -43,8 +47,67 @@ fn resolve_mcp_repl_path() -> TestResult<PathBuf> { Err("unable to locate mcp-repl test binary".into()) } +fn bundle_transcript_path(text: &str) -> Option<PathBuf> { + let end = text + .find("transcript.txt")? + .saturating_add("transcript.txt".len()); + let start = text[..end] + .rfind(|ch: char| ch.is_whitespace() || matches!(ch, '"' | '\'' | '[' | '(')) + .map_or(0, |idx| idx.saturating_add(1)); + Some(PathBuf::from(&text[start..end])) +} + +fn backend_unavailable(stdout: &str, stderr: &str) -> bool { + stdout.is_empty() + || stderr.contains("Fatal error: cannot create 'R_TempDir'") + || stderr.contains("failed to start R session") + || stderr.contains("worker protocol error: ipc disconnected while waiting for backend info") + || stderr.contains("worker exited with status") + || stderr.contains("[repl] error") +} + +fn debug_repl_test_mutex() -> &'static Mutex<()> { + static TEST_MUTEX: OnceLock<Mutex<()>> = OnceLock::new(); + TEST_MUTEX.get_or_init(|| Mutex::new(())) +} + +fn wait_for_prompt_or_idle( + rx: &mpsc::Receiver<Vec<u8>>, + seen: &mut Vec<u8>, + deadline: Instant, +) -> (bool, bool) { + let mut saw_prompt = false; + let mut saw_idle = false; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + break; + } + match rx.recv_timeout(remaining.min(Duration::from_millis(250))) { + Ok(chunk) => { + seen.extend_from_slice(&chunk); + let output = String::from_utf8_lossy(seen); + if output.contains("> ") { + saw_prompt = true; + break; + } + if output.contains("<<repl status: idle>>") { + saw_idle = true; + break; + } + } + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) => break, + } + } + (saw_prompt, saw_idle) +} + #[test] fn debug_repl_prints_initial_prompt() -> TestResult<()> { + let _guard = debug_repl_test_mutex() + .lock() + .expect("debug repl prompt test mutex poisoned"); let exe = resolve_mcp_repl_path()?; let mut cmd = Command::new(exe); cmd.arg("--debug-repl"); @@ -94,32 +157,7 @@ fn debug_repl_prints_initial_prompt() -> TestResult<()> { let deadline = Instant::now() + Duration::from_secs(20); let mut seen = Vec::new(); - let mut saw_prompt = false; - let mut saw_idle = false; - loop { - let remaining = deadline.saturating_duration_since(Instant::now()); - if remaining.is_zero() { - break; - } - match rx.recv_timeout(remaining.min(Duration::from_millis(250))) { - Ok(chunk) => { - seen.extend_from_slice(&chunk); - let output = String::from_utf8_lossy(&seen); - if output.contains("> ") { - saw_prompt = true; - break; - } - if output.contains("<<console status: idle>>") { - saw_idle = true; - break; - } - } - Err(mpsc::RecvTimeoutError::Timeout) => { - // keep waiting - } - Err(mpsc::RecvTimeoutError::Disconnected) => break, - } - } + let (saw_prompt, saw_idle) = wait_for_prompt_or_idle(&rx, &mut seen, deadline); drop(child.stdin.take()); let _ = child.kill(); @@ -131,15 +169,9 @@ fn debug_repl_prints_initial_prompt() -> TestResult<()> { err_seen.extend_from_slice(&chunk); } let err_output = String::from_utf8_lossy(&err_seen); - let backend_unavailable = output.is_empty() - || err_output.contains("Fatal error: cannot create 'R_TempDir'") - || err_output.contains("failed to start R session") - || err_output - .contains("worker protocol error: ipc disconnected while waiting for backend info") - || err_output.contains("worker exited with status") - || err_output.contains("[repl] error"); + let backend_unavailable = backend_unavailable(&output, &err_output); if !((saw_prompt && output.contains("> ")) - || (saw_idle && output.contains("<<console status: idle>>"))) + || (saw_idle && output.contains("<<repl status: idle>>"))) && backend_unavailable { eprintln!("debug_repl backend unavailable in this environment; skipping"); @@ -147,8 +179,154 @@ fn debug_repl_prints_initial_prompt() -> TestResult<()> { } assert!( (saw_prompt && output.contains("> ")) - || (saw_idle && output.contains("<<console status: idle>>")), + || (saw_idle && output.contains("<<repl status: idle>>")), "expected prompt or idle status in stdout, got: {output:?}, stderr: {err_output:?}" ); Ok(()) } + +#[test] +fn debug_repl_files_mode_uses_output_bundle_dir_for_large_output() -> TestResult<()> { + let _guard = debug_repl_test_mutex() + .lock() + .expect("debug repl prompt test mutex poisoned"); + let exe = resolve_mcp_repl_path()?; + let temp = tempdir()?; + let mut cmd = Command::new(exe); + cmd.arg("--debug-repl") + .arg("--oversized-output") + .arg("files"); + #[cfg(target_os = "macos")] + if !sandbox_exec_available() { + cmd.arg("--sandbox").arg("danger-full-access"); + } + let mut child = cmd + .env("MCP_REPL_IMAGES", "0") + .env("MCP_REPL_PAGER_PAGE_CHARS", "1000000") + .env("TMPDIR", temp.path()) + .env("TMP", temp.path()) + .env("TEMP", temp.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + let mut stdout = child.stdout.take().ok_or("missing stdout")?; + let mut stderr = child.stderr.take().ok_or("missing stderr")?; + let (tx, rx) = mpsc::channel::<Vec<u8>>(); + let (err_tx, err_rx) = mpsc::channel::<Vec<u8>>(); + std::thread::spawn(move || { + let mut buf = [0u8; 1024]; + loop { + match stdout.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + if tx.send(buf[..n].to_vec()).is_err() { + break; + } + } + Err(_) => break, + } + } + }); + std::thread::spawn(move || { + let mut buf = [0u8; 1024]; + loop { + match stderr.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + if err_tx.send(buf[..n].to_vec()).is_err() { + break; + } + } + Err(_) => break, + } + } + }); + + let startup_deadline = Instant::now() + Duration::from_secs(20); + let mut startup_seen = Vec::new(); + let (saw_prompt, saw_idle) = wait_for_prompt_or_idle(&rx, &mut startup_seen, startup_deadline); + + let mut startup_err_seen = Vec::new(); + while let Ok(chunk) = err_rx.try_recv() { + startup_err_seen.extend_from_slice(&chunk); + } + let startup_stdout = String::from_utf8_lossy(&startup_seen); + let startup_stderr = String::from_utf8_lossy(&startup_err_seen); + if !((saw_prompt && startup_stdout.contains("> ")) + || (saw_idle && startup_stdout.contains("<<repl status: idle>>"))) + && backend_unavailable(&startup_stdout, &startup_stderr) + { + drop(child.stdin.take()); + let _ = child.kill(); + let _ = child.wait(); + eprintln!("debug_repl backend unavailable in this environment; skipping"); + return Ok(()); + } + assert!( + (saw_prompt && startup_stdout.contains("> ")) + || (saw_idle && startup_stdout.contains("<<repl status: idle>>")), + "expected prompt or idle status before sending debug repl input, got stdout: {startup_stdout:?}, stderr: {startup_stderr:?}" + ); + + { + let stdin = child.stdin.as_mut().ok_or("missing stdin")?; + write!( + stdin, + "big <- paste(rep('x', 5000), collapse = ''); cat('BUNDLE_START\\n'); cat(big); cat('\\nBUNDLE_END\\n')\nEND\n" + )?; + stdin.flush()?; + } + + let deadline = Instant::now() + Duration::from_secs(20); + let mut seen = Vec::new(); + let transcript_path = loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + break None; + } + match rx.recv_timeout(remaining.min(Duration::from_millis(250))) { + Ok(chunk) => { + seen.extend_from_slice(&chunk); + let stdout = String::from_utf8_lossy(&seen); + if let Some(path) = bundle_transcript_path(&stdout) { + break Some(path); + } + } + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) => break None, + } + }; + + let stdout = String::from_utf8_lossy(&seen); + let mut err_seen = Vec::new(); + while let Ok(chunk) = err_rx.try_recv() { + err_seen.extend_from_slice(&chunk); + } + let stderr = String::from_utf8_lossy(&err_seen); + if backend_unavailable(&stdout, &stderr) { + drop(child.stdin.take()); + let _ = child.kill(); + let _ = child.wait(); + eprintln!("debug_repl backend unavailable in this environment; skipping"); + return Ok(()); + } + + let transcript_path = transcript_path.unwrap_or_else(|| { + drop(child.stdin.take()); + let _ = child.kill(); + let _ = child.wait(); + panic!("expected transcript path in debug repl files mode output, got stdout: {stdout:?}, stderr: {stderr:?}") + }); + let transcript = fs::read_to_string(&transcript_path)?; + drop(child.stdin.take()); + let _ = child.kill(); + let _ = child.wait(); + + assert!( + transcript.contains("BUNDLE_START") && transcript.contains("BUNDLE_END"), + "expected transcript bundle to capture the large debug repl output, got: {transcript:?}" + ); + + Ok(()) +} diff --git a/tests/docs_contracts.rs b/tests/docs_contracts.rs new file mode 100644 index 0000000..b948257 --- /dev/null +++ b/tests/docs_contracts.rs @@ -0,0 +1,97 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +fn read(path: &Path) -> String { + fs::read_to_string(path) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display())) +} + +fn assert_exists(path: &Path) { + assert!(path.exists(), "expected {} to exist", path.display()); +} + +#[test] +fn agents_is_short_and_points_to_main_docs() { + let agents = read(&repo_root().join("AGENTS.md")); + assert!( + agents.lines().count() <= 120, + "AGENTS.md should stay at 120 lines or less" + ); + + for required in [ + "docs/index.md", + "docs/architecture.md", + "docs/testing.md", + "docs/debugging.md", + "docs/sandbox.md", + "docs/plans/AGENTS.md", + ] { + assert!(agents.contains(required), "missing {required} in AGENTS.md"); + } +} + +#[test] +fn docs_index_lists_main_docs() { + let root = repo_root(); + let index = read(&root.join("docs/index.md")); + + for required in [ + "docs/architecture.md", + "docs/testing.md", + "docs/debugging.md", + "docs/sandbox.md", + "docs/worker_sideband_protocol.md", + "docs/plans/AGENTS.md", + ] { + assert_exists(&root.join(required)); + assert!( + index.contains(required), + "missing {required} in docs/index.md" + ); + } +} + +#[test] +fn plans_layout_exists() { + let root = repo_root(); + for required in [ + "docs/plans/AGENTS.md", + "docs/plans/active", + "docs/plans/completed", + "docs/plans/tech-debt.md", + ] { + assert_exists(&root.join(required)); + } +} + +#[test] +fn plot_image_snapshots_do_not_expose_mcp_console_meta() { + let snapshots_dir = repo_root().join("tests/snapshots"); + for entry in fs::read_dir(&snapshots_dir) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", snapshots_dir.display())) + { + let entry = entry.unwrap_or_else(|err| panic!("failed to read snapshot entry: {err}")); + let path = entry.path(); + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if !name.starts_with("plot_images__") || !name.ends_with(".snap") { + continue; + } + let contents = read(&path); + assert!( + !contents.contains("\"_meta\""), + "plot snapshot should not expose _meta: {}", + path.display() + ); + assert!( + !contents.contains("mcpConsole"), + "plot snapshot should not expose mcpConsole: {}", + path.display() + ); + } +} diff --git a/tests/install_dual_backend.rs b/tests/install_dual_backend.rs index 4055367..989717e 100644 --- a/tests/install_dual_backend.rs +++ b/tests/install_dual_backend.rs @@ -15,7 +15,8 @@ fn resolve_exe() -> TestResult<PathBuf> { let mut path = std::env::current_exe()?; path.pop(); path.pop(); - for candidate in ["mcp-repl"] { + { + let candidate = "mcp-repl"; let mut candidate_path = path.clone(); candidate_path.push(candidate); if cfg!(windows) { @@ -75,6 +76,14 @@ fn install_codex_target_defaults_to_r_and_python_servers() -> TestResult<()> { has_sandbox_inherit, "expected r args to include `--sandbox inherit`" ); + let r_has_files_mode = r_args + .iter() + .zip(r_args.iter().skip(1)) + .any(|(a, b)| a.as_str() == Some("--oversized-output") && b.as_str() == Some("files")); + assert!( + r_has_files_mode, + "expected r args to include `--oversized-output files`" + ); let py_args = doc["mcp_servers"]["python"]["args"] .as_array() @@ -95,6 +104,14 @@ fn install_codex_target_defaults_to_r_and_python_servers() -> TestResult<()> { py_has_sandbox_inherit, "expected python args to include `--sandbox inherit`" ); + let py_has_files_mode = py_args + .iter() + .zip(py_args.iter().skip(1)) + .any(|(a, b)| a.as_str() == Some("--oversized-output") && b.as_str() == Some("files")); + assert!( + py_has_files_mode, + "expected python args to include `--oversized-output files`" + ); Ok(()) } @@ -141,6 +158,14 @@ fn install_claude_target_defaults_to_r_and_python_servers() -> TestResult<()> { r_has_workspace_write, "expected r args to include `--sandbox workspace-write`" ); + let r_has_files_mode = r_args + .iter() + .zip(r_args.iter().skip(1)) + .any(|(a, b)| a.as_str() == Some("--oversized-output") && b.as_str() == Some("files")); + assert!( + r_has_files_mode, + "expected r args to include `--oversized-output files`" + ); let py_args = root["mcpServers"]["python"]["args"] .as_array() @@ -153,6 +178,14 @@ fn install_claude_target_defaults_to_r_and_python_servers() -> TestResult<()> { py_has_workspace_write, "expected python args to include `--sandbox workspace-write`" ); + let py_has_files_mode = py_args + .iter() + .zip(py_args.iter().skip(1)) + .any(|(a, b)| a.as_str() == Some("--oversized-output") && b.as_str() == Some("files")); + assert!( + py_has_files_mode, + "expected python args to include `--oversized-output files`" + ); let py_has_interpreter_python = py_args.iter().zip(py_args.iter().skip(1)).any(|(a, b)| { (a.as_str() == Some("--interpreter") || a.as_str() == Some("--interpreter")) && b.as_str() == Some("python") diff --git a/tests/interrupt.rs b/tests/interrupt.rs index be1301b..251208d 100644 --- a/tests/interrupt.rs +++ b/tests/interrupt.rs @@ -18,7 +18,7 @@ fn result_text(result: &CallToolResult) -> String { } fn is_busy_response(text: &str) -> bool { - text.contains("<<console status: busy") + text.contains("<<repl status: busy") || text.contains("worker is busy") || text.contains("request already running") || text.contains("input discarded while worker busy") @@ -26,7 +26,6 @@ fn is_busy_response(text: &str) -> bool { fn is_restart_transient_output(text: &str) -> bool { is_busy_response(text) - || text.contains("--More--") || text.contains("new session started") || text.contains("worker exited with status") } @@ -39,12 +38,27 @@ async fn spawn_interrupt_session() -> TestResult<common::McpTestSession> { .await } -#[cfg(unix)] +#[cfg(windows)] +fn backend_unavailable(text: &str) -> bool { + text.contains("Fatal error: cannot create 'R_TempDir'") + || text.contains("failed to start R session") + || text.contains("worker exited with status") + || text.contains("unable to initialize the JIT") + || text.contains( + "worker protocol error: ipc disconnected while waiting for request completion", + ) +} + +#[cfg(not(windows))] fn backend_unavailable(text: &str) -> bool { - text.contains("failed to start R session") + text.contains("Fatal error: cannot create 'R_TempDir'") + || text.contains("failed to start R session") || text.contains("worker exited with status") || text.contains("worker exited with signal") || text.contains("unable to initialize the JIT") + || text.contains( + "worker protocol error: ipc disconnected while waiting for request completion", + ) || text.contains("options(\"defaultPackages\") was not found") || text.contains("worker io error: Broken pipe") } @@ -64,7 +78,7 @@ async fn interrupt_unblocks_long_running_request() -> TestResult<()> { return Ok(()); } assert!( - timeout_text.contains("<<console status: busy"), + timeout_text.contains("<<repl status: busy"), "expected sleep call to time out, got: {timeout_text:?}" ); @@ -77,7 +91,7 @@ async fn interrupt_unblocks_long_running_request() -> TestResult<()> { } assert!( interrupt_text.contains("> ") - || interrupt_text.contains("<<console status: busy") + || interrupt_text.contains("<<repl status: busy") || interrupt_text.contains("worker is busy") || interrupt_text.contains("request already running") || interrupt_text.contains("input discarded while worker busy"), @@ -97,7 +111,7 @@ async fn interrupt_unblocks_long_running_request() -> TestResult<()> { if text.contains("worker is busy") || text.contains("request already running") || text.contains("input discarded while worker busy") - || text.contains("<<console status: busy") + || text.contains("<<repl status: busy") { sleep(Duration::from_millis(50)).await; continue; @@ -128,13 +142,13 @@ async fn write_stdin_ctrl_c_prefix_interrupts_then_runs_remaining_input() -> Tes return Ok(()); } assert!( - timeout_text.contains("<<console status: busy"), + timeout_text.contains("<<repl status: busy"), "expected sleep call to time out, got: {timeout_text:?}" ); let result = session.write_stdin_raw_with("\u{3}1+1", Some(5.0)).await?; let text = result_text(&result); - if text.contains("<<console status: busy") + if text.contains("<<repl status: busy") || text.contains("worker is busy") || text.contains("request already running") || text.contains("input discarded while worker busy") @@ -162,6 +176,11 @@ async fn write_stdin_ctrl_d_prefix_restarts_then_runs_remaining_input() -> TestR .write_stdin_raw_with("\u{4}print(exists(\"x\"))", Some(10.0)) .await?; let mut text = result_text(&first); + if backend_unavailable(&text) { + eprintln!("interrupt test backend unavailable in this environment; skipping"); + session.cancel().await?; + return Ok(()); + } let deadline = Instant::now() + Duration::from_secs(30); loop { if text.contains("FALSE") { @@ -175,27 +194,16 @@ async fn write_stdin_ctrl_d_prefix_restarts_then_runs_remaining_input() -> TestR session.cancel().await?; panic!("expected fresh session after restart prefix, got: {text:?}"); } - if text.contains("--More--") { - let pager_quit = session.write_stdin_raw_with(":q", Some(5.0)).await?; - text = result_text(&pager_quit); - if text.contains("FALSE") { - break; - } - assert!( - !text.contains("TRUE"), - "expected restarted session to clear x, got: {text:?}" - ); - if Instant::now() >= deadline { - session.cancel().await?; - panic!("expected fresh session after restart prefix, got: {text:?}"); - } - } - sleep(Duration::from_millis(100)).await; let result = session .write_stdin_raw_with("print(exists(\"x\"))", Some(5.0)) .await?; text = result_text(&result); + if backend_unavailable(&text) { + eprintln!("interrupt test backend unavailable in this environment; skipping"); + session.cancel().await?; + return Ok(()); + } if is_restart_transient_output(&text) { continue; } diff --git a/tests/manage_session_behavior.rs b/tests/manage_session_behavior.rs index e0bf03a..7360d82 100644 --- a/tests/manage_session_behavior.rs +++ b/tests/manage_session_behavior.rs @@ -61,7 +61,7 @@ async fn interrupt_without_active_request_returns_prompt() -> TestResult<()> { return Ok(()); } assert!( - text.contains(">") || text.contains("<<console status: busy"), + text.contains(">") || text.contains("<<repl status: busy"), "expected prompt or timeout status in output, got: {text:?}" ); assert!( @@ -86,7 +86,7 @@ async fn interrupt_without_active_request_returns_prompt() -> TestResult<()> { if text.contains("worker is busy") || text.contains("request already running") || text.contains("input discarded while worker busy") - || text.contains("<<console status: busy") + || text.contains("<<repl status: busy") { sleep(Duration::from_millis(50)).await; continue; diff --git a/tests/mcp_transcripts.rs b/tests/mcp_transcripts.rs index a405b55..5400810 100644 --- a/tests/mcp_transcripts.rs +++ b/tests/mcp_transcripts.rs @@ -42,7 +42,6 @@ fn backend_unavailable(text: &str) -> bool { ) || text.contains("options(\"defaultPackages\") was not found") || text.contains("worker io error: Broken pipe") - || text.contains("[pager] input blocked while pager is active") } #[cfg(not(windows))] @@ -210,7 +209,7 @@ async fn transcripts_windows_smoke() -> TestResult<()> { if last_text.contains(expected) { return Ok(true); } - if last_text.contains("<<console status: busy") + if last_text.contains("<<repl status: busy") || last_text.contains("worker is busy") || last_text.contains("request already running") || last_text.contains("input discarded while worker busy") diff --git a/tests/oversized_output_cli.rs b/tests/oversized_output_cli.rs new file mode 100644 index 0000000..33c4f2a --- /dev/null +++ b/tests/oversized_output_cli.rs @@ -0,0 +1,73 @@ +use std::path::PathBuf; +use std::process::Command; + +type TestResult<T> = Result<T, Box<dyn std::error::Error + Send + Sync>>; + +fn resolve_mcp_repl_path() -> TestResult<PathBuf> { + if let Ok(path) = std::env::var("CARGO_BIN_EXE_mcp-repl") { + return Ok(PathBuf::from(path)); + } + + let mut path = std::env::current_exe()?; + path.pop(); + path.pop(); + let mut candidate_path = path; + candidate_path.push("mcp-repl"); + if cfg!(windows) { + candidate_path.set_extension("exe"); + } + if candidate_path.exists() { + return Ok(candidate_path); + } + + Err("unable to locate mcp-repl test binary".into()) +} + +#[test] +fn help_mentions_oversized_output_flag() -> TestResult<()> { + let exe = resolve_mcp_repl_path()?; + let output = Command::new(exe).arg("--help").output()?; + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(output.status.success(), "expected --help to succeed"); + assert!( + stdout.contains("--oversized-output"), + "expected --help to mention --oversized-output, got: {stdout:?}" + ); + Ok(()) +} + +#[test] +fn invalid_oversized_output_value_fails_fast() -> TestResult<()> { + let exe = resolve_mcp_repl_path()?; + let output = Command::new(exe) + .args(["--oversized-output", "bogus"]) + .output()?; + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !output.status.success(), + "expected invalid oversized-output value to fail" + ); + assert!( + stderr.contains("oversized-output") || stderr.contains("unknown argument"), + "expected oversized-output parse error, got: {stderr:?}" + ); + Ok(()) +} + +#[test] +fn repeated_oversized_output_flag_fails_fast() -> TestResult<()> { + let exe = resolve_mcp_repl_path()?; + let output = Command::new(exe) + .args(["--oversized-output", "files", "--oversized-output", "pager"]) + .output()?; + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !output.status.success(), + "expected repeated oversized-output flag to fail" + ); + assert!( + stderr.contains("oversized-output"), + "expected oversized-output duplication error, got: {stderr:?}" + ); + Ok(()) +} diff --git a/tests/pager.rs b/tests/pager.rs index 707afa0..cad7acc 100644 --- a/tests/pager.rs +++ b/tests/pager.rs @@ -3,10 +3,12 @@ mod common; #[cfg(not(windows))] use common::McpSnapshot; use common::TestResult; -#[cfg(windows)] use rmcp::model::RawContent; +#[cfg(not(windows))] +use std::fs; +#[cfg(not(windows))] +use std::path::PathBuf; -#[cfg(windows)] fn result_text(result: &rmcp::model::CallToolResult) -> String { result .content @@ -19,6 +21,17 @@ fn result_text(result: &rmcp::model::CallToolResult) -> String { .join("") } +#[cfg(not(windows))] +fn bundle_transcript_path(text: &str) -> Option<PathBuf> { + let end = text + .find("transcript.txt")? + .saturating_add("transcript.txt".len()); + let start = text[..end] + .rfind(|ch: char| ch.is_whitespace() || matches!(ch, '"' | '\'' | '[' | '(')) + .map_or(0, |idx| idx.saturating_add(1)); + Some(PathBuf::from(&text[start..end])) +} + #[cfg(windows)] fn backend_unavailable(text: &str) -> bool { text.contains("Fatal error: cannot create 'R_TempDir'") @@ -60,12 +73,122 @@ fn assert_snapshot_or_skip(name: &str, snapshot: &McpSnapshot) -> TestResult<()> Ok(()) } +#[cfg(not(windows))] +#[tokio::test(flavor = "multi_thread")] +async fn pager_commands_are_handled_server_side() -> TestResult<()> { + let mut session = common::spawn_server_with_pager_page_chars(120).await?; + + let initial = session + .write_stdin_raw_with( + "line <- paste(rep(\"x\", 200), collapse = \"\"); for (i in 1:200) cat(sprintf(\"line%04d %s\\n\", i, line))", + Some(30.0), + ) + .await?; + let initial_text = result_text(&initial); + if backend_unavailable(&initial_text) { + eprintln!("pager backend unavailable in this environment; skipping"); + session.cancel().await?; + return Ok(()); + } + assert!( + initial_text.contains("--More--"), + "expected pager to activate, got: {initial_text:?}" + ); + + let next = session.write_stdin_raw_with(":next", Some(30.0)).await?; + let next_text = result_text(&next); + assert!( + !next_text.contains("unexpected ':'"), + "expected :next to be handled by pager, got: {next_text:?}" + ); + assert!( + next_text.contains("--More--") || next_text.contains("(END"), + "expected pager output for :next, got: {next_text:?}" + ); + + let hits = session + .write_stdin_raw_with(":hits line0150", Some(30.0)) + .await?; + let hits_text = result_text(&hits); + assert!( + !hits_text.contains("unexpected ':'"), + "expected :hits to be handled by pager, got: {hits_text:?}" + ); + assert!( + hits_text.contains("[pager]") || hits_text.contains("#1 @"), + "expected pager response for :hits, got: {hits_text:?}" + ); + + let quit = session.write_stdin_raw_with(":q", Some(30.0)).await?; + let quit_text = result_text(&quit); + assert!( + !quit_text.contains("unexpected ':'"), + "expected :q to be handled by pager, got: {quit_text:?}" + ); + assert!( + quit_text.contains(">"), + "expected prompt after :q, got: {quit_text:?}" + ); + + session.cancel().await?; + Ok(()) +} + +#[cfg(not(windows))] +#[tokio::test(flavor = "multi_thread")] +async fn pager_matches_bundle_excludes_server_metadata_from_transcript() -> TestResult<()> { + let mut session = common::spawn_server_with_pager_page_chars(120).await?; + + let initial = session + .write_stdin_raw_with( + "line <- paste(rep(\"foo\", 80), collapse = \" \"); for (i in 1:300) cat(sprintf(\"line%04d %s\\n\", i, line))", + Some(30.0), + ) + .await?; + let initial_text = result_text(&initial); + if backend_unavailable(&initial_text) { + eprintln!("pager backend unavailable in this environment; skipping"); + session.cancel().await?; + return Ok(()); + } + assert!( + initial_text.contains("--More--"), + "expected pager to activate, got: {initial_text:?}" + ); + + let matches = session + .write_stdin_raw_with(":matches foo", Some(30.0)) + .await?; + let matches_text = result_text(&matches); + let transcript_path = bundle_transcript_path(&matches_text).unwrap_or_else(|| { + panic!("expected transcript path for oversized :matches output, got: {matches_text:?}") + }); + let transcript = fs::read_to_string(&transcript_path)?; + + session.cancel().await?; + + assert!( + matches_text.contains("[pager]"), + "expected pager metadata inline, got: {matches_text:?}" + ); + assert!( + transcript.contains("line0001") || transcript.contains("line0002"), + "expected worker match content in transcript, got: {transcript:?}" + ); + assert!( + !transcript.contains("[pager]"), + "did not expect pager metadata in transcript, got: {transcript:?}" + ); + + Ok(()) +} + #[cfg(not(windows))] #[tokio::test(flavor = "multi_thread")] async fn paginates_large_output() -> TestResult<()> { let mut snapshot = McpSnapshot::new(); snapshot - .session("default", mcp_script! { + .pager_session("default", 300, mcp_script! { write_stdin("line <- paste(rep(\"x\", 200), collapse = \"\"); for (i in 1:200) cat(sprintf(\"line%04d %s\\n\", i, line))", timeout = 30.0); write_stdin("1+1", timeout = 10.0); write_stdin("line <- paste(rep(\"x\", 200), collapse = \"\"); for (i in 1:200) cat(sprintf(\"line%04d %s\\n\", i, line))", timeout = 30.0); @@ -83,7 +206,7 @@ async fn paginates_large_output() -> TestResult<()> { async fn pager_search_and_counts() -> TestResult<()> { let mut snapshot = McpSnapshot::new(); snapshot - .session("default", mcp_script! { + .pager_session("default", 300, mcp_script! { write_stdin("line <- paste(rep(\"x\", 200), collapse = \"\"); for (i in 1:200) cat(sprintf(\"line%04d %s\\n\", i, line))", timeout = 30.0); write_stdin(":/line01", timeout = 30.0); write_stdin(":n", timeout = 30.0); @@ -101,7 +224,7 @@ async fn pager_search_and_counts() -> TestResult<()> { async fn pager_search_preserves_whitespace() -> TestResult<()> { let mut snapshot = McpSnapshot::new(); snapshot - .session("default", mcp_script! { + .pager_session("default", 300, mcp_script! { write_stdin("line <- paste(rep(\"x\", 200), collapse = \"\"); for (i in 1:200) { suffix <- if (i == 25) \" r\" else if (i == 75) \"r \" else \"\"; cat(sprintf(\"line%04d %s%s\\n\", i, line, suffix)) }", timeout = 30.0); write_stdin(":where r", timeout = 30.0); write_stdin(":/ r", timeout = 30.0); @@ -119,7 +242,7 @@ async fn pager_search_preserves_whitespace() -> TestResult<()> { async fn pager_search_case_insensitive_prefix_parsing() -> TestResult<()> { let mut snapshot = McpSnapshot::new(); snapshot - .session("default", mcp_script! { + .pager_session("default", 300, mcp_script! { write_stdin("line <- paste(rep(\"x\", 200), collapse = \"\"); for (i in 1:200) cat(sprintf(\"line%04d %s\\n\", i, line))", timeout = 30.0); write_stdin(":where -i LINE01", timeout = 30.0); write_stdin(":/i LINE01", timeout = 30.0); @@ -136,7 +259,7 @@ async fn pager_search_case_insensitive_prefix_parsing() -> TestResult<()> { async fn pager_matches_with_headings() -> TestResult<()> { let mut snapshot = McpSnapshot::new(); snapshot - .session("default", mcp_script! { + .pager_session("default", 300, mcp_script! { write_stdin("cat('# Title\\n\\n## Alpha\\n'); for (i in 1:20) cat(sprintf('alpha line %02d foo\\n', i)); cat('\\n## Beta\\n'); for (i in 1:20) cat(sprintf('beta line %02d foo\\n', i))", timeout = 30.0); write_stdin(":matches foo", timeout = 30.0); write_stdin(":matches -C 1 foo", timeout = 30.0); @@ -152,7 +275,7 @@ async fn pager_matches_with_headings() -> TestResult<()> { async fn pager_hits_mode() -> TestResult<()> { let mut snapshot = McpSnapshot::new(); snapshot - .session("default", mcp_script! { + .pager_session("default", 300, mcp_script! { write_stdin("cat('# Title\\n'); for (i in 1:40) cat(sprintf('filler %02d xxxxxxxxxxxxxxxxxxxxxxxxxxxxx\\n', i)); cat('## Alpha\\n'); for (i in 1:3) cat(sprintf('alpha configure %02d\\n', i)); cat('## Beta\\n'); for (i in 1:3) cat(sprintf('beta configure %02d\\n', i))", timeout = 30.0); write_stdin(":hits configure", timeout = 30.0); write_stdin(":n", timeout = 30.0); @@ -168,7 +291,7 @@ async fn pager_hits_mode() -> TestResult<()> { async fn pager_whitespace_only_input_advances_page() -> TestResult<()> { let mut snapshot = McpSnapshot::new(); snapshot - .session("default", mcp_script! { + .pager_session("default", 300, mcp_script! { write_stdin("line <- paste(rep(\"x\", 200), collapse = \"\"); for (i in 1:200) cat(sprintf(\"line%04d %s\\n\", i, line))", timeout = 30.0); write_stdin(" ", timeout = 30.0); write_stdin(":q", timeout = 30.0); @@ -183,7 +306,7 @@ async fn pager_whitespace_only_input_advances_page() -> TestResult<()> { async fn pager_dedup_on_seek() -> TestResult<()> { let mut snapshot = McpSnapshot::new(); snapshot - .session("default", mcp_script! { + .pager_session("default", 300, mcp_script! { write_stdin("line <- paste(rep(\"x\", 120), collapse = \"\"); for (i in 1:40) cat(sprintf(\"line%02d %s\\n\", i, line))", timeout = 30.0); write_stdin(":next", timeout = 30.0); write_stdin(":seek 0", timeout = 30.0); diff --git a/tests/pager_page_size.rs b/tests/pager_page_size.rs index 96331b3..1a0e103 100644 --- a/tests/pager_page_size.rs +++ b/tests/pager_page_size.rs @@ -40,7 +40,7 @@ fn backend_unavailable(text: &str) -> bool { } fn busy_response(text: &str) -> bool { - text.contains("<<console status: busy") + text.contains("<<repl status: busy") || text.contains("worker is busy") || text.contains("request already running") || text.contains("input discarded while worker busy") diff --git a/tests/plot_images.rs b/tests/plot_images.rs index 291afa7..196d03c 100644 --- a/tests/plot_images.rs +++ b/tests/plot_images.rs @@ -3,10 +3,15 @@ mod common; use base64::Engine as _; -use common::{TestResult, spawn_server, spawn_server_with_pager_page_chars}; +use common::{TestResult, spawn_server_with_files, spawn_server_with_files_env_vars}; +use regex_lite::Regex; use rmcp::model::{CallToolResult, RawContent}; use serde::Serialize; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; use tempfile::tempdir; +use tokio::time::{Duration, sleep}; #[derive(Debug)] struct ImageData { @@ -26,6 +31,14 @@ struct PlotTranscriptSnapshot { steps: Vec<PlotStepSnapshot>, } +#[derive(Debug)] +struct TextEventRow { + start_line: usize, + end_line: usize, + start_byte: usize, + end_byte: usize, +} + fn result_text(result: &CallToolResult) -> String { result .content @@ -38,6 +51,49 @@ fn result_text(result: &CallToolResult) -> String { .join("") } +fn events_log_path(text: &str) -> Option<PathBuf> { + static RE: OnceLock<Regex> = OnceLock::new(); + let re = RE.get_or_init(|| { + Regex::new(r"(/[^]\s]+/events\.log)").expect("events-log regex should compile") + }); + re.captures(text) + .and_then(|caps| caps.get(1)) + .map(|path| PathBuf::from(path.as_str())) +} + +fn top_level_entry_names(dir: &Path) -> TestResult<Vec<String>> { + let mut names = fs::read_dir(dir)? + .map(|entry| entry.map(|entry| entry.file_name().to_string_lossy().into_owned())) + .collect::<Result<Vec<_>, _>>()?; + names.sort(); + Ok(names) +} + +fn relative_file_paths(root: &Path) -> TestResult<Vec<String>> { + let mut paths = Vec::new(); + collect_relative_file_paths(root, root, &mut paths)?; + paths.sort(); + Ok(paths) +} + +fn collect_relative_file_paths(root: &Path, dir: &Path, out: &mut Vec<String>) -> TestResult<()> { + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if entry.file_type()?.is_dir() { + collect_relative_file_paths(root, &path, out)?; + continue; + } + let relative = path + .strip_prefix(root) + .expect("file should be under root") + .to_string_lossy() + .replace('\\', "/"); + out.push(relative); + } + Ok(()) +} + fn backend_unavailable(text: &str) -> bool { text.contains("Fatal error: cannot create 'R_TempDir'") || text.contains("failed to start R session") @@ -84,6 +140,26 @@ fn response_snapshot(result: &CallToolResult) -> serde_json::Value { value } +fn assert_images_expose_no_meta(result: &CallToolResult, context: &str) { + let snapshot = response_snapshot(result); + let content = snapshot + .get("content") + .and_then(|value| value.as_array()) + .expect("tool result content should be an array"); + for item in content { + let is_image = item + .get("type") + .and_then(|value| value.as_str()) + .is_some_and(|value| value == "image"); + if is_image { + assert!( + item.get("_meta").is_none(), + "expected image results to omit _meta for {context}: {item}" + ); + } + } +} + fn step_snapshot(input: &str, result: &CallToolResult) -> PlotStepSnapshot { PlotStepSnapshot { tool: "r_repl".to_string(), @@ -111,6 +187,61 @@ fn extract_images(result: &CallToolResult) -> Vec<ImageData> { .collect() } +fn text_occurrences(text: &str, needle: &str) -> usize { + text.match_indices(needle).count() +} + +fn parse_text_event_rows(events: &str) -> Vec<TextEventRow> { + events + .lines() + .filter_map(|line| { + let rest = line.strip_prefix("T ")?; + let mut parts = rest.split_whitespace(); + let lines = parts.next()?.strip_prefix("lines=")?; + let bytes = parts.next()?.strip_prefix("bytes=")?; + let mut line_parts = lines.split('-'); + let mut byte_parts = bytes.split('-'); + Some(TextEventRow { + start_line: line_parts.next()?.parse().ok()?, + end_line: line_parts.next()?.parse().ok()?, + start_byte: byte_parts.next()?.parse().ok()?, + end_byte: byte_parts.next()?.parse().ok()?, + }) + }) + .collect() +} + +fn advance_visible_lines( + text: &str, + visible_lines: usize, + has_partial_line: bool, +) -> (usize, usize, bool) { + assert!(!text.is_empty(), "text event rows should not be empty"); + let newline_count = text.bytes().filter(|byte| *byte == b'\n').count(); + let start_line = if visible_lines == 0 { + 1 + } else if has_partial_line { + visible_lines + } else { + visible_lines.saturating_add(1) + }; + let next_visible_lines = if has_partial_line { + visible_lines + .saturating_add(newline_count) + .saturating_add(usize::from(!text.ends_with('\n'))) + .saturating_sub(1) + } else { + visible_lines + .saturating_add(newline_count) + .saturating_add(usize::from(!text.ends_with('\n'))) + }; + ( + start_line, + next_visible_lines.max(start_line), + !text.ends_with('\n'), + ) +} + fn png_dimensions(bytes: &[u8]) -> Option<(u32, u32)> { const PNG_SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n"; if bytes.len() < 24 { @@ -177,6 +308,11 @@ fn assert_no_images(result: &CallToolResult, context: &str) { ); } +const INLINE_TEXT_BUDGET_CHARS: usize = 3500; +const INLINE_TEXT_HARD_SPILL_THRESHOLD_CHARS: usize = INLINE_TEXT_BUDGET_CHARS * 5 / 4; +const UNDER_HARD_SPILL_TEXT_LEN: usize = INLINE_TEXT_BUDGET_CHARS + 200; +const OVER_HARD_SPILL_TEXT_LEN: usize = INLINE_TEXT_HARD_SPILL_THRESHOLD_CHARS + 200; + fn assert_plot_snapshot(name: &str, snapshot: &PlotTranscriptSnapshot) -> TestResult<()> { let serialized = serde_json::to_string_pretty(snapshot)?; if cfg!(target_os = "macos") { @@ -311,7 +447,7 @@ fn is_prompt_line(line: &str) -> bool { #[tokio::test(flavor = "multi_thread")] async fn plots_emit_images_and_updates() -> TestResult<()> { - let mut session = spawn_server().await?; + let mut session = spawn_server_with_files().await?; let mut steps = Vec::new(); let plot_input = "plot(1:10)"; @@ -356,6 +492,8 @@ async fn plots_emit_images_and_updates() -> TestResult<()> { let plot_images = extract_images(&plot_result); let update_images = extract_images(&update_result); + assert_images_expose_no_meta(&plot_result, "plot(1:10)"); + assert_images_expose_no_meta(&update_result, "lines(4:8, 4:8)"); assert!( !plot_images.is_empty(), "expected plot(1:10) to emit image content" @@ -382,7 +520,7 @@ async fn plots_emit_images_and_updates() -> TestResult<()> { #[tokio::test(flavor = "multi_thread")] async fn plots_emit_stable_images_for_repeats() -> TestResult<()> { - let mut session = spawn_server().await?; + let mut session = spawn_server_with_files().await?; let mut steps = Vec::new(); let plot_input = "plot(1:10)"; @@ -448,7 +586,7 @@ async fn plots_emit_stable_images_for_repeats() -> TestResult<()> { #[tokio::test(flavor = "multi_thread")] async fn multi_panel_plots_emit_single_image() -> TestResult<()> { - let mut session = spawn_server().await?; + let mut session = spawn_server_with_files().await?; let mut steps = Vec::new(); let plot_input = "par(mfrow = c(2, 1)); plot(1:10); plot(10:1)"; @@ -479,6 +617,7 @@ async fn multi_panel_plots_emit_single_image() -> TestResult<()> { ); let plot_images = extract_images(&plot_result); + assert_images_expose_no_meta(&plot_result, "multi-panel plot"); assert_eq!( plot_images.len(), 1, @@ -493,7 +632,7 @@ async fn multi_panel_plots_emit_single_image() -> TestResult<()> { #[tokio::test(flavor = "multi_thread")] async fn plots_emit_images_when_paged_output() -> TestResult<()> { - let mut session = spawn_server_with_pager_page_chars(200).await?; + let mut session = spawn_server_with_files().await?; let input = "line <- paste(rep(\"x\", 200), collapse = \"\"); for (i in 1:50) cat(line, \"\\n\"); plot(1:10)"; let result = session.write_stdin_raw_with(input, Some(30.0)).await?; @@ -502,7 +641,6 @@ async fn plots_emit_images_when_paged_output() -> TestResult<()> { session.cancel().await?; return Ok(()); } - session.cancel().await?; assert_ne!( result.is_error, @@ -514,11 +652,16 @@ async fn plots_emit_images_when_paged_output() -> TestResult<()> { let images = extract_images(&result); assert!( !images.is_empty(), - "expected paged output to still include plot image content" + "expected large output to still include plot image content" ); assert!( - result_text(&result).contains("--More--"), - "expected pager footer in response" + !result_text(&result).contains("--More--"), + "did not expect pager footer in response" + ); + assert!( + !result_text(&result).contains("full output:"), + "did not expect oversized-output path marker in mixed text+image reply: {}", + result_text(&result) ); Ok(()) @@ -526,7 +669,7 @@ async fn plots_emit_images_when_paged_output() -> TestResult<()> { #[tokio::test(flavor = "multi_thread")] async fn plots_respect_numeric_size_options() -> TestResult<()> { - let mut session = spawn_server().await?; + let mut session = spawn_server_with_files().await?; let input = "options(console.plot.width = 4, console.plot.height = 3, console.plot.dpi = 100); plot(1:10)"; let result = session.write_stdin_raw_with(input, Some(30.0)).await?; @@ -560,7 +703,7 @@ async fn plots_respect_numeric_size_options() -> TestResult<()> { #[tokio::test(flavor = "multi_thread")] async fn grid_plots_emit_images_and_updates() -> TestResult<()> { - let mut session = spawn_server().await?; + let mut session = spawn_server_with_files().await?; let mut steps = Vec::new(); let plot_input = "grid::grid.newpage(); grid::grid.lines(x = c(0.1, 0.9), y = c(0.1, 0.9))"; @@ -605,6 +748,8 @@ async fn grid_plots_emit_images_and_updates() -> TestResult<()> { let plot_images = extract_images(&plot_result); let update_images = extract_images(&update_result); + assert_images_expose_no_meta(&plot_result, "grid base plot"); + assert_images_expose_no_meta(&update_result, "grid plot update"); assert!( !plot_images.is_empty(), "expected grid plot to emit image content" @@ -631,7 +776,7 @@ async fn grid_plots_emit_images_and_updates() -> TestResult<()> { #[tokio::test(flavor = "multi_thread")] async fn grid_plots_emit_stable_images_for_repeats() -> TestResult<()> { - let mut session = spawn_server().await?; + let mut session = spawn_server_with_files().await?; let mut steps = Vec::new(); let plot_input = "grid::grid.newpage(); grid::grid.lines(x = c(0.1, 0.9), y = c(0.1, 0.9))"; @@ -697,7 +842,7 @@ async fn grid_plots_emit_stable_images_for_repeats() -> TestResult<()> { #[tokio::test(flavor = "multi_thread")] async fn plot_updates_in_single_request_collapse() -> TestResult<()> { - let mut session = spawn_server().await?; + let mut session = spawn_server_with_files().await?; let input = "plot(1:10); lines(2:9, 2:9); lines(2:9, 2:9)"; let result = session.write_stdin_raw_with(input, Some(30.0)).await?; @@ -706,7 +851,6 @@ async fn plot_updates_in_single_request_collapse() -> TestResult<()> { session.cancel().await?; return Ok(()); } - session.cancel().await?; assert_ne!( result.is_error, @@ -726,8 +870,8 @@ async fn plot_updates_in_single_request_collapse() -> TestResult<()> { } #[tokio::test(flavor = "multi_thread")] -async fn plot_emitted_after_truncation() -> TestResult<()> { - let mut session = spawn_server_with_pager_page_chars(5_000_000).await?; +async fn plot_emitted_after_large_output() -> TestResult<()> { + let mut session = spawn_server_with_files().await?; let input = r#" cat(paste(rep("x", 3000000), collapse = "")) @@ -740,7 +884,6 @@ plot(1:10) session.cancel().await?; return Ok(()); } - session.cancel().await?; assert_ne!( result.is_error, @@ -751,8 +894,12 @@ plot(1:10) let text = result_text(&result); assert!( - text.contains("output truncated"), - "expected truncation notice, got: {text:?}" + text.contains("END"), + "expected the tail of the large output, got: {text:?}" + ); + assert!( + !text.contains("output truncated"), + "did not expect truncation notice, got: {text:?}" ); let images = extract_images(&result); @@ -763,3 +910,781 @@ plot(1:10) Ok(()) } + +#[tokio::test(flavor = "multi_thread")] +async fn mixed_plot_reply_with_four_images_and_under_grace_text_stays_inline() -> TestResult<()> { + let mut session = spawn_server_with_files().await?; + + let input = format!( + r#" +big <- paste(rep("u", {UNDER_HARD_SPILL_TEXT_LEN}), collapse = "") +cat("UNDER_START\n") +cat(big) +cat("\nUNDER_END\n") +for (i in 1:4) {{ + plot(1:10, main = sprintf("plot%03d", i)) +}} +"# + ); + let result = session.write_stdin_raw_with(&input, Some(60.0)).await?; + if any_backend_unavailable(&[&result]) { + eprintln!("plot_images backend unavailable in this environment; skipping"); + session.cancel().await?; + return Ok(()); + } + + assert_ne!( + result.is_error, + Some(true), + "under-grace mixed plot reply reported an error: {}", + result_text(&result) + ); + + let text = result_text(&result); + let images = extract_images(&result); + + assert!( + text.contains("UNDER_START") && text.contains("UNDER_END"), + "expected under-grace mixed reply text inline, got: {text:?}" + ); + assert!( + events_log_path(&text).is_none(), + "did not expect mixed output bundle for under-grace text, got: {text:?}" + ); + assert_eq!( + images.len(), + 4, + "expected four inline images when text stays under the hard spill threshold" + ); + + session.cancel().await?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn mixed_plot_reply_with_two_images_and_over_grace_text_uses_output_bundle() -> TestResult<()> +{ + let mut session = spawn_server_with_files().await?; + + let input = format!( + r#" +big <- paste(rep("v", {OVER_HARD_SPILL_TEXT_LEN}), collapse = "") +cat("OVER_START\n") +cat(big) +cat("\nOVER_END\n") +for (i in 1:2) {{ + plot(1:10, main = sprintf("plot%03d", i)) +}} +"# + ); + let result = session.write_stdin_raw_with(&input, Some(60.0)).await?; + if any_backend_unavailable(&[&result]) { + eprintln!("plot_images backend unavailable in this environment; skipping"); + session.cancel().await?; + return Ok(()); + } + + assert_ne!( + result.is_error, + Some(true), + "over-grace mixed plot reply reported an error: {}", + result_text(&result) + ); + + let text = result_text(&result); + let events_log = events_log_path(&text).unwrap_or_else(|| { + panic!("expected output bundle events.log path in over-grace mixed reply, got: {text:?}") + }); + let bundle_dir = events_log + .parent() + .unwrap_or_else(|| panic!("events.log missing parent: {events_log:?}")); + let transcript = fs::read_to_string(bundle_dir.join("transcript.txt"))?; + let images = extract_images(&result); + + assert_eq!( + images.len(), + 2, + "expected output-bundle mixed reply to keep both endpoint images inline" + ); + assert!( + transcript.contains("OVER_START") && transcript.contains("OVER_END"), + "expected transcript.txt to contain the over-grace worker text, got: {transcript:?}" + ); + + session.cancel().await?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn single_image_over_grace_text_does_not_duplicate_pre_image_preview() -> TestResult<()> { + let mut session = spawn_server_with_files().await?; + + let input = format!( + r#" +cat("PRE_UNIQUE_START\n") +cat(paste(rep("p", 240), collapse = "")) +cat("\nPRE_UNIQUE_END\n") +plot(1:10, main = "single-plot") +big <- paste(rep("v", {OVER_HARD_SPILL_TEXT_LEN}), collapse = "") +cat("POST_START\n") +cat(big) +cat("\nPOST_END\n") +"# + ); + let result = session.write_stdin_raw_with(&input, Some(60.0)).await?; + if any_backend_unavailable(&[&result]) { + eprintln!("plot_images backend unavailable in this environment; skipping"); + session.cancel().await?; + return Ok(()); + } + + assert_ne!( + result.is_error, + Some(true), + "single-image output bundle reported an error: {}", + result_text(&result) + ); + + let text = result_text(&result); + let images = extract_images(&result); + let events_log = events_log_path(&text).unwrap_or_else(|| { + panic!("expected output bundle events.log path in single-image reply, got: {text:?}") + }); + let bundle_dir = events_log + .parent() + .unwrap_or_else(|| panic!("events.log missing parent: {events_log:?}")); + let transcript = fs::read_to_string(bundle_dir.join("transcript.txt"))?; + + assert_eq!( + images.len(), + 1, + "expected single-image reply to keep exactly one inline image" + ); + assert_eq!( + text_occurrences(&text, "PRE_UNIQUE_START\n"), + 1, + "did not expect duplicated pre-image preview text, got: {text:?}" + ); + assert_eq!( + text_occurrences(&text, "PRE_UNIQUE_END\n"), + 1, + "did not expect duplicated pre-image preview tail, got: {text:?}" + ); + assert!( + transcript.contains("POST_START") && transcript.contains("POST_END"), + "expected transcript.txt to contain the over-grace worker text, got: {transcript:?}" + ); + + session.cancel().await?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn mixed_plot_replies_output_bundle_and_keep_first_and_last_images() -> TestResult<()> { + let mut session = spawn_server_with_files().await?; + + let input = r#" +for (i in 1:6) { + cat(sprintf("warn%03d\n", i)) + plot(1:10, main = sprintf("plot%03d", i)) +} +"#; + let result = session.write_stdin_raw_with(input, Some(60.0)).await?; + if any_backend_unavailable(&[&result]) { + eprintln!("plot_images backend unavailable in this environment; skipping"); + session.cancel().await?; + return Ok(()); + } + assert_ne!( + result.is_error, + Some(true), + "mixed plot output bundle reported an error: {}", + result_text(&result) + ); + + let text = result_text(&result); + let events_log = events_log_path(&text).unwrap_or_else(|| { + panic!("expected output bundle events.log path in response, got: {text:?}") + }); + let bundle_dir = events_log + .parent() + .unwrap_or_else(|| panic!("events.log missing parent: {events_log:?}")); + let transcript = fs::read_to_string(bundle_dir.join("transcript.txt"))?; + let events = fs::read_to_string(&events_log)?; + let top_level_images = top_level_entry_names(&bundle_dir.join("images"))?; + let history_files = relative_file_paths(&bundle_dir.join("images/history"))?; + let images = extract_images(&result); + + assert_eq!( + images.len(), + 2, + "expected output-bundle reply to keep exactly two inline images" + ); + assert_eq!( + top_level_images, + vec![ + "001.png".to_string(), + "002.png".to_string(), + "003.png".to_string(), + "004.png".to_string(), + "005.png".to_string(), + "006.png".to_string(), + "history".to_string(), + ], + "expected top-level final image aliases in output bundle" + ); + assert_eq!( + history_files, + vec![ + "001/001.png".to_string(), + "002/001.png".to_string(), + "003/001.png".to_string(), + "004/001.png".to_string(), + "005/001.png".to_string(), + "006/001.png".to_string(), + ], + "expected image history files grouped under images/history" + ); + assert_eq!( + images[0].bytes, + fs::read(bundle_dir.join("images/001.png"))?, + "expected first inline image to match first top-level final alias" + ); + assert_eq!( + images[1].bytes, + fs::read(bundle_dir.join("images/006.png"))?, + "expected second inline image to match last top-level final alias" + ); + assert!( + text.contains("events.log"), + "expected response to teach client about events.log, got: {text:?}" + ); + assert!( + events.starts_with("v1\ntext transcript.txt\nimages images/\n"), + "expected events.log header, got: {events:?}" + ); + assert!( + events.contains("T lines="), + "expected text range entries in events.log, got: {events:?}" + ); + assert!( + events.contains("I images/history/001/001.png") + && events.contains("I images/history/006/001.png"), + "expected first/last image entries in events.log, got: {events:?}" + ); + assert!( + transcript.contains("warn001") && transcript.contains("warn006"), + "expected transcript.txt to contain worker text, got: {transcript:?}" + ); + assert!( + !transcript.contains("images/history/001/001.png"), + "did not expect transcript.txt to contain image paths, got: {transcript:?}" + ); + + session.cancel().await?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn mixed_output_bundle_events_log_keeps_partial_line_ranges_stable() -> TestResult<()> { + let mut session = spawn_server_with_files().await?; + + let input = r#" +cat("a") +flush.console() +Sys.sleep(0.05) +for (i in 1:5) { + plot(1:10, main = sprintf("plot%03d", i)) + cat(sprintf("b%03d\n", i)) + flush.console() +} +"#; + let result = session.write_stdin_raw_with(input, Some(60.0)).await?; + if any_backend_unavailable(&[&result]) { + eprintln!("plot_images backend unavailable in this environment; skipping"); + session.cancel().await?; + return Ok(()); + } + assert_ne!( + result.is_error, + Some(true), + "mixed plot output bundle with partial lines reported an error: {}", + result_text(&result) + ); + + let text = result_text(&result); + let events_log = events_log_path(&text).unwrap_or_else(|| { + panic!("expected output bundle events.log path in response, got: {text:?}") + }); + let bundle_dir = events_log + .parent() + .unwrap_or_else(|| panic!("events.log missing parent: {events_log:?}")); + let transcript = fs::read_to_string(bundle_dir.join("transcript.txt"))?; + let events = fs::read_to_string(&events_log)?; + let rows = parse_text_event_rows(&events); + + assert!( + transcript.contains("b001\nb002\nb003\nb004\nb005\n"), + "expected transcript.txt to preserve the mixed plot text, got: {transcript:?}" + ); + assert!( + rows.len() >= 2, + "expected multiple text rows in events.log for the mixed output bundle, got: {events:?}" + ); + let mut visible_lines = 0; + let mut has_partial_line = false; + for row in rows { + let text = transcript + .get(row.start_byte..row.end_byte) + .unwrap_or_else(|| panic!("expected valid UTF-8 row slice for {row:?}")); + let (expected_start, expected_end, next_partial) = + advance_visible_lines(text, visible_lines, has_partial_line); + assert_eq!( + (row.start_line, row.end_line), + (expected_start, expected_end), + "expected events.log line span to match transcript slice {text:?}, got: {events:?}" + ); + visible_lines = expected_end; + has_partial_line = next_partial; + } + + session.cancel().await?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn timeout_image_output_bundle_backfills_earlier_worker_text() -> TestResult<()> { + let mut session = spawn_server_with_files().await?; + + let input = r#" +cat("warn000\n") +flush.console() +Sys.sleep(0.25) +for (i in 1:6) { + cat(sprintf("warn%03d\n", i)) + plot(1:10, main = sprintf("plot%03d", i)) +} +"#; + let first = session.write_stdin_raw_with(input, Some(0.05)).await?; + let first_text = result_text(&first); + if any_backend_unavailable(&[&first]) { + eprintln!("plot_images backend unavailable in this environment; skipping"); + session.cancel().await?; + return Ok(()); + } + assert!( + events_log_path(&first_text).is_none(), + "did not expect output bundle on first small timeout reply, got: {first_text:?}" + ); + + let result = session.write_stdin_raw_with("", Some(60.0)).await?; + let text = result_text(&result); + if text.contains("<<repl status: busy") { + eprintln!("plot_images timeout output-bundle poll remained busy; skipping"); + session.cancel().await?; + return Ok(()); + } + assert_ne!( + result.is_error, + Some(true), + "timeout image output bundle reported an error: {}", + text + ); + + let events_log = events_log_path(&text).unwrap_or_else(|| { + panic!("expected output bundle events.log path in timeout poll, got: {text:?}") + }); + let bundle_dir = events_log + .parent() + .unwrap_or_else(|| panic!("events.log missing parent: {events_log:?}")); + let transcript = fs::read_to_string(bundle_dir.join("transcript.txt"))?; + let events = fs::read_to_string(&events_log)?; + + assert!( + transcript.contains("warn000"), + "expected transcript.txt to backfill early timeout text, got: {transcript:?}" + ); + assert!( + transcript.contains("warn006"), + "expected transcript.txt to include later text, got: {transcript:?}" + ); + assert!( + !transcript.contains("<<repl status: busy"), + "did not expect timeout marker in transcript.txt, got: {transcript:?}" + ); + assert!( + events.contains("I images/history/001/001.png") + && events.contains("I images/history/006/001.png"), + "expected events.log to cover the full image set, got: {events:?}" + ); + assert!( + !events.contains("<<repl status: busy"), + "did not expect timeout marker in events.log, got: {events:?}" + ); + + session.cancel().await?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn timeout_output_bundle_text_only_poll_does_not_duplicate_prefix_text() -> TestResult<()> { + let mut session = spawn_server_with_files().await?; + + let input = r#" +cat("HEAD_ONLY\n") +flush.console() +Sys.sleep(0.25) +for (i in 1:6) { + cat(sprintf("plot%03d\n", i)) + plot(1:10, main = sprintf("plot%03d", i)) +} +flush.console() +Sys.sleep(1) +cat("TAIL_ONLY\n") +"#; + let first = session.write_stdin_raw_with(input, Some(0.05)).await?; + if any_backend_unavailable(&[&first]) { + eprintln!("plot_images backend unavailable in this environment; skipping"); + session.cancel().await?; + return Ok(()); + } + + sleep(Duration::from_millis(600)).await; + let bundled = session.write_stdin_raw_with("", Some(0.05)).await?; + let bundled_text = result_text(&bundled); + if bundled_text.contains("<<repl status: busy") && events_log_path(&bundled_text).is_none() { + eprintln!( + "plot_images timeout output-bundle poll did not flush image history yet; skipping" + ); + session.cancel().await?; + return Ok(()); + } + let final_result = session.write_stdin_raw_with("", Some(5.0)).await?; + let final_text = result_text(&final_result); + + assert_ne!( + final_result.is_error, + Some(true), + "timeout text-only follow-up poll reported an error: {}", + final_text + ); + assert!( + !final_text.contains("<<repl status: busy"), + "expected timeout text-only follow-up poll to finish, got: {final_text:?}" + ); + assert!( + events_log_path(&final_text).is_some(), + "expected output bundle disclosure in final timeout poll, got: {final_text:?}" + ); + assert_eq!( + final_text.matches("TAIL_ONLY\n").count(), + 1, + "expected trailing timeout text segment to appear once, got: {final_text:?}" + ); + assert!( + !final_text.contains("> cat(\"TAIL_ONLY\\n\")"), + "did not expect the trailing command echo to survive the final timeout poll: {final_text:?}" + ); + assert!( + !final_text.contains("[repl] input discarded while worker busy"), + "did not expect empty-poll completion to inject a busy-discard notice: {final_text:?}" + ); + + session.cancel().await?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn timeout_output_bundle_image_only_omission_still_discloses_bundle_path() -> TestResult<()> { + let temp = tempdir()?; + let mut session = spawn_server_with_files_env_vars(vec![ + ("TMPDIR".to_string(), temp.path().display().to_string()), + ( + "MCP_REPL_OUTPUT_BUNDLE_MAX_BYTES".to_string(), + "12000".to_string(), + ), + ]) + .await?; + + let input = r#" +Sys.sleep(0.25) +for (i in 1:6) { + plot(1:10, main = sprintf("plot%03d", i)) +} +flush.console() +Sys.sleep(1) +"#; + let first = session.write_stdin_raw_with(input, Some(0.05)).await?; + if any_backend_unavailable(&[&first]) { + eprintln!("plot_images backend unavailable in this environment; skipping"); + session.cancel().await?; + return Ok(()); + } + + sleep(Duration::from_millis(600)).await; + let bundled = session.write_stdin_raw_with("", Some(0.05)).await?; + let bundled_text = result_text(&bundled); + if bundled_text.contains("<<repl status: busy") && events_log_path(&bundled_text).is_none() { + eprintln!("plot_images timeout omission poll did not flush bundle state yet; skipping"); + session.cancel().await?; + return Ok(()); + } + + assert_ne!( + bundled.is_error, + Some(true), + "image-only timeout omission poll reported an error: {}", + bundled_text + ); + assert!( + bundled_text.contains("later content omitted"), + "expected omission notice in image-only timeout poll, got: {bundled_text:?}" + ); + assert!( + bundled_text.contains("output-0001"), + "expected omission reply to disclose a bundle path, got: {bundled_text:?}" + ); + + session.cancel().await?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn timeout_output_bundle_survives_missing_anchor_image() -> TestResult<()> { + let temp = tempdir()?; + let mut session = spawn_server_with_files_env_vars(vec![( + "TMPDIR".to_string(), + temp.path().display().to_string(), + )]) + .await?; + + let input = r#" +Sys.sleep(0.25) +for (i in 1:6) { + plot(1:10, main = sprintf("plot%03d", i)) +} +flush.console() +Sys.sleep(1) +"#; + let first = session.write_stdin_raw_with(input, Some(0.05)).await?; + if any_backend_unavailable(&[&first]) { + eprintln!("plot_images backend unavailable in this environment; skipping"); + session.cancel().await?; + return Ok(()); + } + + sleep(Duration::from_millis(600)).await; + let bundled = session.write_stdin_raw_with("", Some(0.05)).await?; + let bundled_text = result_text(&bundled); + if bundled_text.contains("<<repl status: busy") && events_log_path(&bundled_text).is_none() { + eprintln!("plot_images timeout bundle poll did not flush image history yet; skipping"); + session.cancel().await?; + return Ok(()); + } + + let events_log = events_log_path(&bundled_text).unwrap_or_else(|| { + panic!("expected output bundle events.log path in timeout poll, got: {bundled_text:?}") + }); + let bundle_dir = events_log + .parent() + .unwrap_or_else(|| panic!("events.log missing parent: {events_log:?}")); + fs::remove_file(bundle_dir.join("images/001.png"))?; + + let damaged = session.write_stdin_raw_with("", Some(0.05)).await?; + let damaged_text = result_text(&damaged); + assert_ne!( + damaged.is_error, + Some(true), + "missing anchor image poll reported an error: {}", + damaged_text + ); + assert!( + damaged_text.contains("events.log"), + "expected damaged anchor poll to keep disclosing the output bundle, got: {damaged_text:?}" + ); + + let mut settled_text = damaged_text; + while settled_text.contains("<<repl status: busy") { + sleep(Duration::from_millis(100)).await; + let next = session.write_stdin_raw_with("", Some(0.5)).await?; + settled_text = result_text(&next); + } + + let follow_up = session.write_stdin_raw_with("1+1", Some(5.0)).await?; + let follow_up_text = result_text(&follow_up); + + session.cancel().await?; + + assert!( + follow_up_text.contains("[1] 2"), + "expected session to stay alive after anchor image deletion, got: {follow_up_text:?}" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn same_reply_plot_updates_bundle_preserves_image_history() -> TestResult<()> { + let mut session = spawn_server_with_files().await?; + + let input = format!( + r#" +big <- paste(rep("h", {OVER_HARD_SPILL_TEXT_LEN}), collapse = "") +cat("HISTORY_START\n") +cat(big) +cat("\nHISTORY_END\n") +plot(1:10) +lines(2:9, 2:9) +lines(3:8, 3:8) +"# + ); + let result = session.write_stdin_raw_with(&input, Some(60.0)).await?; + if any_backend_unavailable(&[&result]) { + eprintln!("plot_images backend unavailable in this environment; skipping"); + session.cancel().await?; + return Ok(()); + } + + assert_ne!( + result.is_error, + Some(true), + "same-reply plot history bundle reported an error: {}", + result_text(&result) + ); + + let text = result_text(&result); + let events_log = events_log_path(&text).unwrap_or_else(|| { + panic!("expected output bundle events.log path in response, got: {text:?}") + }); + let bundle_dir = events_log + .parent() + .unwrap_or_else(|| panic!("events.log missing parent: {events_log:?}")); + let transcript = fs::read_to_string(bundle_dir.join("transcript.txt"))?; + let events = fs::read_to_string(&events_log)?; + let top_level_images = top_level_entry_names(&bundle_dir.join("images"))?; + let history_files = relative_file_paths(&bundle_dir.join("images/history"))?; + let images = extract_images(&result); + + assert_eq!( + images.len(), + 1, + "expected same-reply updates to stay collapsed inline" + ); + assert_eq!( + top_level_images, + vec!["001.png".to_string(), "history".to_string()], + "expected one top-level final alias plus history" + ); + assert_eq!( + history_files, + vec![ + "001/001.png".to_string(), + "001/002.png".to_string(), + "001/003.png".to_string(), + ], + "expected every same-reply image update in bundle history" + ); + assert_eq!( + images[0].bytes, + fs::read(bundle_dir.join("images/001.png"))?, + "expected inline image to match the final bundled image" + ); + assert_eq!( + fs::read(bundle_dir.join("images/001.png"))?, + fs::read(bundle_dir.join("images/history/001/003.png"))?, + "expected final alias to match the last history entry" + ); + assert!( + transcript.contains("HISTORY_START") && transcript.contains("HISTORY_END"), + "expected transcript.txt to contain worker text, got: {transcript:?}" + ); + assert!( + events.contains("I images/history/001/001.png") + && events.contains("I images/history/001/003.png"), + "expected events.log to cover the full same-reply history, got: {events:?}" + ); + + session.cancel().await?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn same_reply_plot_updates_stay_inline_and_show_final_state() -> TestResult<()> { + let mut batch_session = spawn_server_with_files().await?; + let mut control_session = spawn_server_with_files().await?; + + let steps = [ + "plot(1:10)", + "lines(2:9, 2:9)", + "lines(3:8, 3:8)", + "lines(c(1, 10), c(10, 1))", + ]; + let batch_input = steps.join("\n"); + let batch = batch_session + .write_stdin_raw_with(&batch_input, Some(60.0)) + .await?; + if any_backend_unavailable(&[&batch]) { + eprintln!("plot_images backend unavailable in this environment; skipping"); + batch_session.cancel().await?; + control_session.cancel().await?; + return Ok(()); + } + + let batch_text = result_text(&batch); + let batch_images = extract_images(&batch); + assert_eq!( + batch_images.len(), + 1, + "expected one inline image for same-reply updates, got: {batch_text:?}" + ); + assert!( + events_log_path(&batch_text).is_none(), + "did not expect output bundle for collapsed same-reply updates, got: {batch_text:?}" + ); + + let mut control = None; + for step in steps { + let result = control_session + .write_stdin_raw_with(step, Some(60.0)) + .await?; + let text = result_text(&result); + if backend_unavailable(&text) { + eprintln!("plot_images backend unavailable in this environment; skipping"); + batch_session.cancel().await?; + control_session.cancel().await?; + return Ok(()); + } + control = Some(result); + } + + let control = control.expect("control sequence should produce a final image"); + let control_images = extract_images(&control); + + batch_session.cancel().await?; + control_session.cancel().await?; + + assert_eq!( + control_images.len(), + 1, + "expected control sequence to end with one inline image" + ); + assert_eq!( + batch_images[0].mime_type, control_images[0].mime_type, + "expected same mime type for batch and control plot replies" + ); + assert_eq!( + batch_images[0].bytes, control_images[0].bytes, + "expected same-reply updates to expose the final plot state inline" + ); + + Ok(()) +} diff --git a/tests/python_backend.rs b/tests/python_backend.rs index 93864fb..3ae844d 100644 --- a/tests/python_backend.rs +++ b/tests/python_backend.rs @@ -2,6 +2,8 @@ mod common; use common::TestResult; use rmcp::model::RawContent; +use std::fs; +use std::path::PathBuf; use tokio::time::{Duration, Instant, sleep}; fn result_text(result: &rmcp::model::CallToolResult) -> String { @@ -16,6 +18,23 @@ fn result_text(result: &rmcp::model::CallToolResult) -> String { .join("") } +fn bundle_transcript_path(text: &str) -> Option<PathBuf> { + let end = text + .find("transcript.txt")? + .saturating_add("transcript.txt".len()); + let start = text[..end] + .rfind(|ch: char| ch.is_whitespace() || matches!(ch, '"' | '\'' | '[' | '(')) + .map_or(0, |idx| idx.saturating_add(1)); + Some(PathBuf::from(&text[start..end])) +} + +fn visible_reply_text(text: &str) -> TestResult<String> { + if let Some(path) = bundle_transcript_path(text) { + return Ok(fs::read_to_string(path)?); + } + Ok(text.to_string()) +} + fn require_python() -> bool { if common::python_available() { true @@ -26,18 +45,22 @@ fn require_python() -> bool { } fn is_busy_response(text: &str) -> bool { - text.contains("<<console status: busy") + text.contains("<<repl status: busy") || text.contains("worker is busy") || text.contains("request already running") || text.contains("input discarded while worker busy") } +fn interrupt_recovery_deadline() -> Instant { + Instant::now() + Duration::from_secs(if cfg!(target_os = "macos") { 20 } else { 5 }) +} + async fn start_python_session() -> TestResult<Option<common::McpTestSession>> { if !require_python() { return Ok(None); } - let mut session = common::spawn_python_server().await?; + let mut session = common::spawn_python_server_with_files().await?; let probe = session.write_stdin_raw_with("", Some(2.0)).await?; let probe_text = result_text(&probe); if probe_text.contains("worker io error: Permission denied") @@ -197,7 +220,7 @@ async fn python_interrupt_unblocks_long_running_request() -> TestResult<()> { .await?; let timeout_text = result_text(&timeout_result); assert!( - timeout_text.contains("<<console status: busy"), + timeout_text.contains("<<repl status: busy"), "expected sleep call to time out, got: {timeout_text:?}" ); @@ -208,7 +231,7 @@ async fn python_interrupt_unblocks_long_running_request() -> TestResult<()> { "expected prompt after interrupt, got: {interrupt_text:?}" ); - let deadline = Instant::now() + Duration::from_secs(5); + let deadline = interrupt_recovery_deadline(); loop { if Instant::now() >= deadline { session.cancel().await?; @@ -232,6 +255,276 @@ async fn python_interrupt_unblocks_long_running_request() -> TestResult<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread")] +async fn python_detached_idle_output_does_not_bundle_follow_up_reply() -> TestResult<()> { + let Some(mut session) = start_python_session().await? else { + return Ok(()); + }; + + let setup = session + .write_stdin_raw_with( + r#"import subprocess, sys +script = """import sys, time +time.sleep(0.3) +for i in range(160): + sys.stdout.write("IDLE_%03d " % i + ("x" * 80) + "\\n") +sys.stdout.flush() +""" +subprocess.Popen( + [sys.executable, "-c", script], + stdin=subprocess.DEVNULL, + close_fds=False, +) +print("parent ready") +"#, + Some(5.0), + ) + .await?; + let setup_text = result_text(&setup); + if is_busy_response(&setup_text) { + eprintln!("python detached-idle setup remained busy; skipping"); + session.cancel().await?; + return Ok(()); + } + assert!( + setup_text.contains("parent ready"), + "expected detached-idle setup reply, got: {setup_text:?}" + ); + + sleep(Duration::from_millis(1500)).await; + let follow_up = session + .write_stdin_raw_with("print('FOLLOWUP_OK')", Some(5.0)) + .await?; + let follow_up_text = result_text(&follow_up); + if is_busy_response(&follow_up_text) { + eprintln!("python detached-idle follow-up remained busy; skipping"); + session.cancel().await?; + return Ok(()); + } + + let transcript_path = bundle_transcript_path(&follow_up_text).unwrap_or_else(|| { + panic!("expected detached idle output to disclose transcript path, got: {follow_up_text:?}") + }); + let transcript = std::fs::read_to_string(&transcript_path)?; + + session.cancel().await?; + + assert!( + follow_up_text.contains("FOLLOWUP_OK"), + "expected follow-up output inline, got: {follow_up_text:?}" + ); + assert!( + transcript.contains("IDLE_000"), + "expected detached idle output in transcript bundle, got: {transcript:?}" + ); + assert!( + !transcript.contains("FOLLOWUP_OK"), + "did not expect follow-up output to be bundled with detached idle output: {transcript:?}" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn python_idle_exit_preserves_detached_tail_before_respawn() -> TestResult<()> { + let Some(mut session) = start_python_session().await? else { + return Ok(()); + }; + + let arm = session + .write_stdin_raw_with( + "import os, sys, threading, time; print('armed'); threading.Thread(target=lambda: (time.sleep(0.2), sys.stdout.write('IDLE_TAIL\\n'), sys.stdout.flush(), os._exit(0)), daemon=True).start()", + Some(5.0), + ) + .await?; + let arm_text = result_text(&arm); + if is_busy_response(&arm_text) { + eprintln!( + "python_idle_exit_preserves_detached_tail_before_respawn remained busy; skipping" + ); + session.cancel().await?; + return Ok(()); + } + assert!( + arm_text.contains("armed"), + "expected arming output, got: {arm_text:?}" + ); + + sleep(Duration::from_millis(500)).await; + let reply = session + .write_stdin_raw_with("print('AFTER_RESPAWN')", Some(5.0)) + .await?; + let text = result_text(&reply); + if is_busy_response(&text) { + eprintln!( + "python_idle_exit_preserves_detached_tail_before_respawn remained busy after respawn; skipping" + ); + session.cancel().await?; + return Ok(()); + } + let visible = visible_reply_text(&text)?; + + session.cancel().await?; + + assert!( + visible.contains("IDLE_TAIL"), + "expected detached idle output to survive auto-respawn, got: {visible:?}" + ); + assert!( + visible.contains("AFTER_RESPAWN"), + "expected fresh respawned output, got: {visible:?}" + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn python_restart_does_not_leak_old_generation_output() -> TestResult<()> { + let Some(mut session) = start_python_session().await? else { + return Ok(()); + }; + + let timeout_result = session + .write_stdin_raw_with( + "import sys, time; big = 'OLD_BLOCK\\n' * 200000; sys.stdout.write(big); sys.stdout.flush(); time.sleep(30)", + Some(0.05), + ) + .await?; + let timeout_text = result_text(&timeout_result); + if !is_busy_response(&timeout_text) { + eprintln!( + "python_restart_does_not_leak_old_generation_output did not time out as expected; skipping" + ); + session.cancel().await?; + return Ok(()); + } + + let restart = session.write_stdin_raw_with("\u{4}", Some(10.0)).await?; + let restart_text = result_text(&restart); + if is_busy_response(&restart_text) { + eprintln!( + "python_restart_does_not_leak_old_generation_output restart remained busy; skipping" + ); + session.cancel().await?; + return Ok(()); + } + assert!( + restart_text.contains("new session started"), + "expected restart notice, got: {restart_text:?}" + ); + + let next = session + .write_stdin_raw_with("print('NEW_GENERATION_OK')", Some(5.0)) + .await?; + let next_text = result_text(&next); + if is_busy_response(&next_text) { + eprintln!( + "python_restart_does_not_leak_old_generation_output next turn remained busy; skipping" + ); + session.cancel().await?; + return Ok(()); + } + let visible = visible_reply_text(&next_text)?; + + session.cancel().await?; + + assert!( + visible.contains("NEW_GENERATION_OK"), + "expected fresh-generation reply, got: {visible:?}" + ); + assert!( + !visible.contains("OLD_BLOCK"), + "did not expect old-generation output after restart, got: {visible:?}" + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn python_detached_incomplete_utf8_tail_does_not_merge_into_next_request() -> TestResult<()> { + let Some(mut session) = start_python_session().await? else { + return Ok(()); + }; + + let setup = session + .write_stdin_raw_with( + r#"import subprocess, sys +script = """import os, sys, time +time.sleep(0.3) +for i in range(160): + os.write(sys.stdout.fileno(), ("IDLE_%03d " % i + ("x" * 80) + "\\n").encode()) +os.write(sys.stdout.fileno(), bytes([0xC3])) +""" +subprocess.Popen( + [sys.executable, "-c", script], + stdin=subprocess.DEVNULL, + close_fds=False, +) +print("parent ready") +"#, + Some(5.0), + ) + .await?; + let setup_text = result_text(&setup); + if is_busy_response(&setup_text) { + eprintln!("python detached-incomplete setup remained busy; skipping"); + session.cancel().await?; + return Ok(()); + } + assert!( + setup_text.contains("parent ready"), + "expected detached-incomplete setup reply, got: {setup_text:?}" + ); + + sleep(Duration::from_millis(700)).await; + let follow_up = session + .write_stdin_raw_with( + "import os, sys\nos.write(sys.stdout.fileno(), bytes([0xA9, 0x0A]))\nprint('FOLLOWUP_OK')", + Some(5.0), + ) + .await?; + let follow_up_text = result_text(&follow_up); + if is_busy_response(&follow_up_text) { + eprintln!("python detached-incomplete follow-up remained busy; skipping"); + session.cancel().await?; + return Ok(()); + } + + let transcript_path = bundle_transcript_path(&follow_up_text).unwrap_or_else(|| { + panic!( + "expected detached output transcript path in follow-up reply, got: {follow_up_text:?}" + ) + }); + let transcript = std::fs::read_to_string(&transcript_path)?; + + session.cancel().await?; + + assert!( + follow_up_text.contains("\\xA9"), + "expected new request continuation byte to stay split, got: {follow_up_text:?}" + ); + assert!( + !follow_up_text.contains("é"), + "did not expect cross-request UTF-8 merge, got: {follow_up_text:?}" + ); + assert!( + follow_up_text.contains("FOLLOWUP_OK"), + "expected follow-up output, got: {follow_up_text:?}" + ); + assert!( + transcript.contains("IDLE_000"), + "expected detached idle output in transcript, got: {transcript:?}" + ); + assert!( + transcript.contains("\\xC3"), + "expected detached lead byte to stay with detached transcript, got: {transcript:?}" + ); + assert!( + !transcript.contains("FOLLOWUP_OK"), + "did not expect follow-up output in detached transcript: {transcript:?}" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread")] async fn python_interrupt_discards_buffered_tail_after_timeout() -> TestResult<()> { let Some(mut session) = start_python_session().await? else { @@ -243,7 +536,7 @@ async fn python_interrupt_discards_buffered_tail_after_timeout() -> TestResult<( .await?; let timeout_text = result_text(&timeout_result); assert!( - timeout_text.contains("<<console status: busy"), + timeout_text.contains("<<repl status: busy"), "expected sleep call to time out, got: {timeout_text:?}" ); @@ -257,7 +550,7 @@ async fn python_interrupt_discards_buffered_tail_after_timeout() -> TestResult<( let poll_result = session.write_stdin_raw_with("", Some(0.5)).await?; let _poll_text = result_text(&poll_result); - let deadline = Instant::now() + Duration::from_secs(5); + let deadline = interrupt_recovery_deadline(); loop { if Instant::now() >= deadline { session.cancel().await?; @@ -277,7 +570,7 @@ async fn python_interrupt_discards_buffered_tail_after_timeout() -> TestResult<( break; } - let deadline = Instant::now() + Duration::from_secs(5); + let deadline = interrupt_recovery_deadline(); loop { if Instant::now() >= deadline { session.cancel().await?; diff --git a/tests/python_plot_images.rs b/tests/python_plot_images.rs index 6cf7401..62298d9 100644 --- a/tests/python_plot_images.rs +++ b/tests/python_plot_images.rs @@ -4,8 +4,12 @@ mod common; use base64::Engine as _; use common::TestResult; +use regex_lite::Regex; use rmcp::model::{CallToolResult, RawContent}; use serde::Serialize; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; use tempfile::tempdir; #[derive(Debug)] @@ -38,6 +42,49 @@ fn result_text(result: &CallToolResult) -> String { .join("") } +fn events_log_path(text: &str) -> Option<PathBuf> { + static RE: OnceLock<Regex> = OnceLock::new(); + let re = RE.get_or_init(|| { + Regex::new(r"(/[^]\s]+/events\.log)").expect("events-log regex should compile") + }); + re.captures(text) + .and_then(|caps| caps.get(1)) + .map(|path| PathBuf::from(path.as_str())) +} + +fn top_level_entry_names(dir: &Path) -> TestResult<Vec<String>> { + let mut names = fs::read_dir(dir)? + .map(|entry| entry.map(|entry| entry.file_name().to_string_lossy().into_owned())) + .collect::<Result<Vec<_>, _>>()?; + names.sort(); + Ok(names) +} + +fn relative_file_paths(root: &Path) -> TestResult<Vec<String>> { + let mut paths = Vec::new(); + collect_relative_file_paths(root, root, &mut paths)?; + paths.sort(); + Ok(paths) +} + +fn collect_relative_file_paths(root: &Path, dir: &Path, out: &mut Vec<String>) -> TestResult<()> { + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if entry.file_type()?.is_dir() { + collect_relative_file_paths(root, &path, out)?; + continue; + } + let relative = path + .strip_prefix(root) + .expect("file should be under root") + .to_string_lossy() + .replace('\\', "/"); + out.push(relative); + } + Ok(()) +} + fn response_snapshot(result: &CallToolResult) -> serde_json::Value { let mut value = serde_json::to_value(result) .unwrap_or_else(|_| serde_json::json!({"error": "failed to serialize response"})); @@ -65,6 +112,26 @@ fn response_snapshot(result: &CallToolResult) -> serde_json::Value { value } +fn assert_images_expose_no_meta(result: &CallToolResult, context: &str) { + let snapshot = response_snapshot(result); + let content = snapshot + .get("content") + .and_then(|value| value.as_array()) + .expect("tool result content should be an array"); + for item in content { + let is_image = item + .get("type") + .and_then(|value| value.as_str()) + .is_some_and(|value| value == "image"); + if is_image { + assert!( + item.get("_meta").is_none(), + "expected image results to omit _meta for {context}: {item}" + ); + } + } +} + fn step_snapshot(input: &str, result: &CallToolResult) -> PlotStepSnapshot { PlotStepSnapshot { tool: "py_repl".to_string(), @@ -333,7 +400,7 @@ async fn python_plots_emit_images_and_updates() -> TestResult<()> { if !python_plot_tests_enabled() { return Ok(()); } - let mut session = common::spawn_python_server().await?; + let mut session = common::spawn_python_server_with_files().await?; let mut steps = Vec::new(); let plot_input = format!( @@ -346,7 +413,7 @@ async fn python_plots_emit_images_and_updates() -> TestResult<()> { steps.push(step_snapshot(&plot_input, &plot_result)); session.cancel().await?; - let mut session = common::spawn_python_server().await?; + let mut session = common::spawn_python_server_with_files().await?; let update_input = format!( "{}; plt.figure(1); plt.plot(list(range(4, 9)), list(range(4, 9))); plt.show()", python_plot_preamble() @@ -357,7 +424,7 @@ async fn python_plots_emit_images_and_updates() -> TestResult<()> { steps.push(step_snapshot(&update_input, &update_result)); session.cancel().await?; - let mut session = common::spawn_python_server().await?; + let mut session = common::spawn_python_server_with_files().await?; let noop_input = "1+1"; let noop_result = session.write_stdin_raw_with(noop_input, Some(30.0)).await?; steps.push(step_snapshot(noop_input, &noop_result)); @@ -385,6 +452,8 @@ async fn python_plots_emit_images_and_updates() -> TestResult<()> { let plot_images = extract_images(&plot_result); let update_images = extract_images(&update_result); + assert_images_expose_no_meta(&plot_result, "python base plot"); + assert_images_expose_no_meta(&update_result, "python plot update"); assert!( !plot_images.is_empty(), "expected base plot to emit image content" @@ -414,7 +483,7 @@ async fn python_plots_emit_stable_images_for_repeats() -> TestResult<()> { if !python_plot_tests_enabled() { return Ok(()); } - let mut session = common::spawn_python_server().await?; + let mut session = common::spawn_python_server_with_files().await?; let mut steps = Vec::new(); let plot_input = format!( @@ -427,14 +496,14 @@ async fn python_plots_emit_stable_images_for_repeats() -> TestResult<()> { steps.push(step_snapshot(&plot_input, &first_result)); session.cancel().await?; - let mut session = common::spawn_python_server().await?; + let mut session = common::spawn_python_server_with_files().await?; let second_result = session .write_stdin_raw_with(&plot_input, Some(30.0)) .await?; steps.push(step_snapshot(&plot_input, &second_result)); session.cancel().await?; - let mut session = common::spawn_python_server().await?; + let mut session = common::spawn_python_server_with_files().await?; let noop_input = "1+1"; let noop_result = session.write_stdin_raw_with(noop_input, Some(30.0)).await?; steps.push(step_snapshot(noop_input, &noop_result)); @@ -490,7 +559,7 @@ async fn python_multi_panel_plots_emit_single_image() -> TestResult<()> { if !python_plot_tests_enabled() { return Ok(()); } - let mut session = common::spawn_python_server().await?; + let mut session = common::spawn_python_server_with_files().await?; let mut steps = Vec::new(); let plot_input = format!( @@ -521,6 +590,7 @@ async fn python_multi_panel_plots_emit_single_image() -> TestResult<()> { ); let plot_images = extract_images(&plot_result); + assert_images_expose_no_meta(&plot_result, "python multi-panel plot"); assert_eq!( plot_images.len(), 1, @@ -557,11 +627,16 @@ async fn python_plots_emit_images_when_paged_output() -> TestResult<()> { let images = extract_images(&result); assert!( !images.is_empty(), - "expected paged output to still include plot image content" + "expected large output to still include plot image content" + ); + assert!( + !result_text(&result).contains("--More--"), + "did not expect pager footer in response" ); assert!( - result_text(&result).contains("--More--"), - "expected pager footer in response" + !result_text(&result).contains("full output:"), + "did not expect oversized-output path marker in mixed text+image reply: {}", + result_text(&result) ); Ok(()) @@ -572,7 +647,7 @@ async fn python_grid_plots_emit_images_and_updates() -> TestResult<()> { if !python_plot_tests_enabled() { return Ok(()); } - let mut session = common::spawn_python_server().await?; + let mut session = common::spawn_python_server_with_files().await?; let mut steps = Vec::new(); let plot_input = format!( @@ -585,7 +660,7 @@ async fn python_grid_plots_emit_images_and_updates() -> TestResult<()> { steps.push(step_snapshot(&plot_input, &plot_result)); session.cancel().await?; - let mut session = common::spawn_python_server().await?; + let mut session = common::spawn_python_server_with_files().await?; let update_input = format!( "{}; plt.figure(2); plt.plot([0.1, 0.9], [0.9, 0.1]); plt.show()", python_plot_preamble() @@ -596,7 +671,7 @@ async fn python_grid_plots_emit_images_and_updates() -> TestResult<()> { steps.push(step_snapshot(&update_input, &update_result)); session.cancel().await?; - let mut session = common::spawn_python_server().await?; + let mut session = common::spawn_python_server_with_files().await?; let noop_input = "1+1"; let noop_result = session.write_stdin_raw_with(noop_input, Some(30.0)).await?; steps.push(step_snapshot(noop_input, &noop_result)); @@ -624,6 +699,8 @@ async fn python_grid_plots_emit_images_and_updates() -> TestResult<()> { let plot_images = extract_images(&plot_result); let update_images = extract_images(&update_result); + assert_images_expose_no_meta(&plot_result, "python grid base plot"); + assert_images_expose_no_meta(&update_result, "python grid plot update"); assert!( !plot_images.is_empty(), "expected grid plot to emit image content" @@ -653,7 +730,7 @@ async fn python_grid_plots_emit_stable_images_for_repeats() -> TestResult<()> { if !python_plot_tests_enabled() { return Ok(()); } - let mut session = common::spawn_python_server().await?; + let mut session = common::spawn_python_server_with_files().await?; let mut steps = Vec::new(); let plot_input = format!( @@ -666,14 +743,14 @@ async fn python_grid_plots_emit_stable_images_for_repeats() -> TestResult<()> { steps.push(step_snapshot(&plot_input, &first_result)); session.cancel().await?; - let mut session = common::spawn_python_server().await?; + let mut session = common::spawn_python_server_with_files().await?; let second_result = session .write_stdin_raw_with(&plot_input, Some(30.0)) .await?; steps.push(step_snapshot(&plot_input, &second_result)); session.cancel().await?; - let mut session = common::spawn_python_server().await?; + let mut session = common::spawn_python_server_with_files().await?; let noop_input = "1+1"; let noop_result = session.write_stdin_raw_with(noop_input, Some(30.0)).await?; steps.push(step_snapshot(noop_input, &noop_result)); @@ -732,7 +809,7 @@ async fn python_plot_updates_in_single_request_collapse() -> TestResult<()> { if !python_plot_tests_enabled() { return Ok(()); } - let mut session = common::spawn_python_server().await?; + let mut session = common::spawn_python_server_with_files().await?; let input = format!( "{}; plt.figure(1); plt.clf(); plt.plot(list(range(1, 11))); plt.plot(list(range(2, 10)), list(range(2, 10))); plt.plot(list(range(2, 10)), list(range(2, 10))); plt.show()", @@ -759,7 +836,7 @@ async fn python_plot_updates_in_single_request_collapse() -> TestResult<()> { } #[tokio::test(flavor = "multi_thread")] -async fn python_plot_emitted_after_truncation() -> TestResult<()> { +async fn python_plot_emitted_after_large_output() -> TestResult<()> { if !python_plot_tests_enabled() { return Ok(()); } @@ -781,8 +858,12 @@ async fn python_plot_emitted_after_truncation() -> TestResult<()> { let text = result_text(&result); assert!( - text.contains("output truncated"), - "expected truncation notice, got: {text:?}" + text.contains("END"), + "expected the tail of the large output, got: {text:?}" + ); + assert!( + !text.contains("output truncated"), + "did not expect truncation notice, got: {text:?}" ); let images = extract_images(&result); @@ -793,3 +874,185 @@ async fn python_plot_emitted_after_truncation() -> TestResult<()> { Ok(()) } + +#[tokio::test(flavor = "multi_thread")] +async fn python_mixed_plot_replies_output_bundle_and_keep_first_and_last_images() -> TestResult<()> +{ + if !python_plot_tests_enabled() { + return Ok(()); + } + let mut session = common::spawn_python_server_with_files().await?; + + let input = format!( + "{}; exec(\"for i in range(1, 7):\\n print(f'warn{{i:03d}}')\\n plt.figure(i)\\n plt.clf()\\n plt.plot(list(range(1, 11)))\\n plt.title(f'plot{{i:03d}}')\\n plt.show()\")", + python_plot_preamble() + ); + let result = session.write_stdin_raw_with(&input, Some(60.0)).await?; + + assert_ne!( + result.is_error, + Some(true), + "mixed plot output bundle reported an error: {}", + result_text(&result) + ); + + let text = result_text(&result); + let events_log = events_log_path(&text).unwrap_or_else(|| { + panic!("expected output bundle events.log path in response, got: {text:?}") + }); + let bundle_dir = events_log + .parent() + .unwrap_or_else(|| panic!("events.log missing parent: {events_log:?}")); + let transcript = fs::read_to_string(bundle_dir.join("transcript.txt"))?; + let events = fs::read_to_string(&events_log)?; + let top_level_images = top_level_entry_names(&bundle_dir.join("images"))?; + let history_files = relative_file_paths(&bundle_dir.join("images/history"))?; + let images = extract_images(&result); + + assert_eq!( + images.len(), + 2, + "expected output-bundle reply to keep exactly two inline images" + ); + assert_eq!( + top_level_images, + vec![ + "001.png".to_string(), + "002.png".to_string(), + "003.png".to_string(), + "004.png".to_string(), + "005.png".to_string(), + "006.png".to_string(), + "history".to_string(), + ], + "expected top-level final image aliases in output bundle" + ); + assert_eq!( + history_files, + vec![ + "001/001.png".to_string(), + "002/001.png".to_string(), + "003/001.png".to_string(), + "004/001.png".to_string(), + "005/001.png".to_string(), + "006/001.png".to_string(), + ], + "expected image history files grouped under images/history" + ); + assert_eq!( + images[0].bytes, + fs::read(bundle_dir.join("images/001.png"))?, + "expected first inline image to match first top-level final alias" + ); + assert_eq!( + images[1].bytes, + fs::read(bundle_dir.join("images/006.png"))?, + "expected second inline image to match last top-level final alias" + ); + assert!( + text.contains("events.log"), + "expected response to teach client about events.log, got: {text:?}" + ); + assert!( + events.starts_with("v1\ntext transcript.txt\nimages images/\n"), + "expected events.log header, got: {events:?}" + ); + assert!( + events.contains("T lines="), + "expected text range entries in events.log, got: {events:?}" + ); + assert!( + events.contains("I images/history/001/001.png") + && events.contains("I images/history/006/001.png"), + "expected first/last image entries in events.log, got: {events:?}" + ); + assert!( + transcript.contains("warn001") && transcript.contains("warn006"), + "expected transcript.txt to contain worker text, got: {transcript:?}" + ); + assert!( + !transcript.contains("images/history/001/001.png"), + "did not expect transcript.txt to contain image paths, got: {transcript:?}" + ); + + session.cancel().await?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn python_same_reply_plot_updates_bundle_preserves_image_history() -> TestResult<()> { + if !python_plot_tests_enabled() { + return Ok(()); + } + let mut session = common::spawn_python_server_with_files().await?; + + let input = format!( + "{}; exec(\"big = 'h' * {}\\nprint('HISTORY_START')\\nprint(big)\\nprint('HISTORY_END')\\nplt.figure(1)\\nplt.clf()\\nplt.plot(list(range(1, 11)))\\nplt.plot(list(range(2, 10)), list(range(2, 10)))\\nplt.plot(list(range(3, 9)), list(range(3, 9)))\\nplt.show()\")", + python_plot_preamble(), + 5000, + ); + let result = session.write_stdin_raw_with(&input, Some(60.0)).await?; + session.cancel().await?; + + assert_ne!( + result.is_error, + Some(true), + "same-reply plot history bundle reported an error: {}", + result_text(&result) + ); + + let text = result_text(&result); + let events_log = events_log_path(&text).unwrap_or_else(|| { + panic!("expected output bundle events.log path in response, got: {text:?}") + }); + let bundle_dir = events_log + .parent() + .unwrap_or_else(|| panic!("events.log missing parent: {events_log:?}")); + let transcript = fs::read_to_string(bundle_dir.join("transcript.txt"))?; + let events = fs::read_to_string(&events_log)?; + let top_level_images = top_level_entry_names(&bundle_dir.join("images"))?; + let history_files = relative_file_paths(&bundle_dir.join("images/history"))?; + let images = extract_images(&result); + + assert_eq!( + images.len(), + 1, + "expected same-reply updates to stay collapsed inline" + ); + assert_eq!( + top_level_images, + vec!["001.png".to_string(), "history".to_string()], + "expected one top-level final alias plus history" + ); + assert_eq!( + history_files, + vec![ + "001/001.png".to_string(), + "001/002.png".to_string(), + "001/003.png".to_string(), + ], + "expected every same-reply image update in bundle history" + ); + assert_eq!( + images[0].bytes, + fs::read(bundle_dir.join("images/001.png"))?, + "expected inline image to match final top-level alias" + ); + assert_eq!( + fs::read(bundle_dir.join("images/001.png"))?, + fs::read(bundle_dir.join("images/history/001/003.png"))?, + "expected final alias to match the last history entry" + ); + assert!( + transcript.contains("HISTORY_START") && transcript.contains("HISTORY_END"), + "expected transcript.txt to contain worker text, got: {transcript:?}" + ); + assert!( + events.contains("I images/history/001/001.png") + && events.contains("I images/history/001/003.png"), + "expected events.log to cover the full same-reply history, got: {events:?}" + ); + + Ok(()) +} diff --git a/tests/r_console_encoding.rs b/tests/r_console_encoding.rs index 4702461..4347614 100644 --- a/tests/r_console_encoding.rs +++ b/tests/r_console_encoding.rs @@ -73,7 +73,7 @@ async fn write_stdin_windows_output_has_no_utf8_marker_artifacts() -> TestResult .write_stdin_raw_with("options(useFancyQuotes=TRUE); ?mean", Some(30.0)) .await?; let help_text = result_text(&help); - if help_text.contains("<<console status: busy") { + if help_text.contains("<<repl status: busy") { eprintln!("r_console_encoding help output still busy; skipping"); session.cancel().await?; return Ok(()); diff --git a/tests/r_file_show.rs b/tests/r_file_show.rs index f954dbf..7cdf716 100644 --- a/tests/r_file_show.rs +++ b/tests/r_file_show.rs @@ -29,13 +29,14 @@ fn backend_unavailable(text: &str) -> bool { } #[tokio::test(flavor = "multi_thread")] -async fn file_show_uses_mcp_repl_pager() -> TestResult<()> { - let mut session = common::spawn_server_with_pager_page_chars(4_000).await?; +async fn file_show_returns_full_output_without_pager() -> TestResult<()> { + let mut session = common::spawn_server_with_files().await?; + let timeout_secs = if cfg!(windows) { 60.0 } else { 30.0 }; let result = session .write_stdin_raw_with( "line <- paste(rep(\"x\", 200), collapse = \"\"); tf <- tempfile(\"mcp-repl-file-show-\"); writeLines(sprintf(\"file_show_line%04d %s\", 1:200, line), tf); file.show(tf, delete.file = TRUE); invisible(NULL)", - Some(30.0), + Some(timeout_secs), ) .await?; let text = result_text(&result); @@ -49,25 +50,13 @@ async fn file_show_uses_mcp_repl_pager() -> TestResult<()> { "expected file.show() content in output, got: {text:?}" ); assert!( - text.contains("--More--"), - "expected mcp-repl pager footer, got: {text:?}" + text.contains("file_show_line0200"), + "expected full file.show() output in one reply, got: {text:?}" ); - - let result = session.write_stdin_raw_with(":next", Some(30.0)).await?; session.cancel().await?; - - let text = result_text(&result); - if backend_unavailable(&text) { - eprintln!("r_file_show backend unavailable in this environment; skipping"); - return Ok(()); - } - assert!( - text.contains("file_show_line") && !text.contains("file_show_line0001"), - "expected a later page of file.show() output, got: {text:?}" - ); assert!( - text.contains("--More--") || text.contains("(END"), - "expected pager footer on subsequent page, got: {text:?}" + !text.contains("--More--"), + "did not expect pager footer, got: {text:?}" ); Ok(()) } diff --git a/tests/r_help.rs b/tests/r_help.rs index 2e97135..c9d47ea 100644 --- a/tests/r_help.rs +++ b/tests/r_help.rs @@ -30,7 +30,7 @@ fn backend_unavailable(text: &str) -> bool { #[tokio::test(flavor = "multi_thread")] async fn text_help_is_llm_friendly() -> TestResult<()> { - let mut session = common::spawn_server_with_pager_page_chars(20_000).await?; + let mut session = common::spawn_server_with_files().await?; let result = session.write_stdin_raw_with("?mean", Some(30.0)).await?; session.cancel().await?; diff --git a/tests/r_manuals.rs b/tests/r_manuals.rs index 086367c..c4c967c 100644 --- a/tests/r_manuals.rs +++ b/tests/r_manuals.rs @@ -3,6 +3,7 @@ use common::TestResult; use rmcp::model::RawContent; use std::sync::{Mutex, OnceLock}; +use tokio::time::{Duration, Instant, sleep}; mod common; @@ -33,12 +34,38 @@ fn backend_unavailable(text: &str) -> bool { ) } +async fn wait_until_not_busy( + session: &mut common::McpTestSession, + initial: rmcp::model::CallToolResult, +) -> TestResult<rmcp::model::CallToolResult> { + let mut result = initial; + let mut text = result_text(&result); + if !text.contains("<<repl status: busy") { + return Ok(result); + } + + let deadline = Instant::now() + Duration::from_secs(30); + while Instant::now() < deadline { + sleep(Duration::from_millis(250)).await; + let next = session + .write_stdin_raw_unterminated_with("", Some(2.0)) + .await?; + text = result_text(&next); + result = next; + if !text.contains("<<repl status: busy") { + return Ok(result); + } + } + + Err(format!("worker remained busy after polling: {text:?}").into()) +} + #[tokio::test(flavor = "multi_thread")] async fn r_show_doc_prints_manual_html_in_console() -> TestResult<()> { let _guard = test_mutex() .lock() .map_err(|_| "r_manuals test mutex poisoned")?; - let mut session = common::spawn_server_with_pager_page_chars(12_000).await?; + let mut session = common::spawn_server_with_files().await?; let result = session .write_stdin_raw_with( @@ -46,6 +73,7 @@ async fn r_show_doc_prints_manual_html_in_console() -> TestResult<()> { Some(60.0), ) .await?; + let result = wait_until_not_busy(&mut session, result).await?; let text = result_text(&result); if backend_unavailable(&text) { eprintln!("r_manuals backend unavailable in this environment; skipping"); @@ -73,7 +101,7 @@ async fn r_show_doc_accepts_text_type_alias() -> TestResult<()> { let _guard = test_mutex() .lock() .map_err(|_| "r_manuals test mutex poisoned")?; - let mut session = common::spawn_server_with_pager_page_chars(12_000).await?; + let mut session = common::spawn_server_with_files().await?; let result = session .write_stdin_raw_with( @@ -81,6 +109,7 @@ async fn r_show_doc_accepts_text_type_alias() -> TestResult<()> { Some(60.0), ) .await?; + let result = wait_until_not_busy(&mut session, result).await?; let text = result_text(&result); if backend_unavailable(&text) { eprintln!("r_manuals backend unavailable in this environment; skipping"); @@ -104,7 +133,7 @@ async fn browseurl_supports_html_fragments_for_r_manuals() -> TestResult<()> { let _guard = test_mutex() .lock() .map_err(|_| "r_manuals test mutex poisoned")?; - let mut session = common::spawn_server_with_pager_page_chars(20_000).await?; + let mut session = common::spawn_server_with_files().await?; let result = session .write_stdin_raw_with( @@ -112,6 +141,7 @@ async fn browseurl_supports_html_fragments_for_r_manuals() -> TestResult<()> { Some(60.0), ) .await?; + let result = wait_until_not_busy(&mut session, result).await?; let text = result_text(&result); if backend_unavailable(&text) { eprintln!("r_manuals backend unavailable in this environment; skipping"); @@ -139,11 +169,12 @@ async fn r_show_doc_does_not_open_pdfs() -> TestResult<()> { let _guard = test_mutex() .lock() .map_err(|_| "r_manuals test mutex poisoned")?; - let mut session = common::spawn_server_with_pager_page_chars(50_000).await?; + let mut session = common::spawn_server_with_files().await?; let result = session .write_stdin_raw_with("RShowDoc(\"R-exts\"); invisible(NULL)", Some(60.0)) .await?; + let result = wait_until_not_busy(&mut session, result).await?; let text = result_text(&result); if backend_unavailable(&text) { eprintln!("r_manuals backend unavailable in this environment; skipping"); @@ -153,7 +184,7 @@ async fn r_show_doc_does_not_open_pdfs() -> TestResult<()> { session.cancel().await?; assert!( text.contains("[repl] browseURL file:") && text.contains("R-exts.html"), - "expected RShowDoc() to render HTML in console, got: {text:?}" + "expected RShowDoc() to render HTML in the REPL, got: {text:?}" ); assert!( text.contains("Writing R Extensions"), @@ -172,6 +203,7 @@ async fn r_show_doc_search_returns_compact_card() -> TestResult<()> { let setup = session .write_stdin_raw_with("RShowDoc(\"R-exts\"); invisible(NULL)", Some(60.0)) .await?; + let setup = wait_until_not_busy(&mut session, setup).await?; let setup_text = result_text(&setup); if backend_unavailable(&setup_text) { eprintln!("r_manuals backend unavailable in this environment; skipping"); diff --git a/tests/r_startup.rs b/tests/r_startup.rs index 85446fd..9b4d8a8 100644 --- a/tests/r_startup.rs +++ b/tests/r_startup.rs @@ -30,7 +30,7 @@ fn backend_unavailable(text: &str) -> bool { } fn is_busy_response(text: &str) -> bool { - text.contains("<<console status: busy") + text.contains("<<repl status: busy") || text.contains("worker is busy") || text.contains("request already running") || text.contains("input discarded while worker busy") diff --git a/tests/r_vignettes.rs b/tests/r_vignettes.rs index 87a7aa2..1cbe7dd 100644 --- a/tests/r_vignettes.rs +++ b/tests/r_vignettes.rs @@ -38,7 +38,7 @@ async fn vignette_prints_contents_in_console() -> TestResult<()> { let _guard = test_mutex() .lock() .map_err(|_| "r_vignettes test mutex poisoned")?; - let mut session = common::spawn_server_with_pager_page_chars(4_000).await?; + let mut session = common::spawn_server_with_files().await?; let result = session .write_stdin_raw_with( @@ -55,7 +55,7 @@ async fn vignette_prints_contents_in_console() -> TestResult<()> { session.cancel().await?; assert!( text.contains("[repl] vignette: grid (package: grid)"), - "expected vignette info in console, got: {text:?}" + "expected vignette info in the REPL, got: {text:?}" ); assert!( text.contains("Source:") && text.contains("grid.Rnw"), @@ -77,7 +77,7 @@ async fn browse_vignettes_prints_text_listing() -> TestResult<()> { let _guard = test_mutex() .lock() .map_err(|_| "r_vignettes test mutex poisoned")?; - let mut session = common::spawn_server_with_pager_page_chars(20_000).await?; + let mut session = common::spawn_server_with_files().await?; let result = session .write_stdin_raw_with( diff --git a/tests/refactor_coverage.rs b/tests/refactor_coverage.rs index 6381f27..2d45644 100644 --- a/tests/refactor_coverage.rs +++ b/tests/refactor_coverage.rs @@ -93,8 +93,9 @@ cat("\nEND\n") "#; snapshot - .session( + .pager_session( "truncation_tail", + 300, mcp_script! { write_stdin(big_output, timeout = 20.0); write_stdin(":tail 8k", timeout = 10.0); @@ -123,8 +124,9 @@ for (i in 1:60) cat("gamma line ", i, "\n", sep = "") "##; snapshot - .session( + .pager_session( "pager_hits_images", + 300, mcp_script! { write_stdin(output, timeout = 10.0); write_stdin(":hits alpha", timeout = 10.0); @@ -153,6 +155,13 @@ fn backend_unavailable(text: &str) -> bool { || text.contains("worker io error: Broken pipe") } +#[cfg(windows)] +fn initial_plot_command_completed(text: &str) -> bool { + text.contains("plots_done") + || text.contains("<<repl status: busy") + || text.contains("--More-- (") +} + #[cfg(not(windows))] fn normalize_pager_footer_page_counts(text: &str) -> String { let marker = "--More-- ("; @@ -227,7 +236,7 @@ async fn windows_restart_interrupt_plot_smoke() -> TestResult<()> { if text.contains(expected) { return Ok(true); } - if text.contains("<<console status: busy") + if text.contains("<<repl status: busy") || text.contains("worker is busy") || text.contains("request already running") || text.contains("input discarded while worker busy") @@ -254,7 +263,7 @@ async fn windows_restart_interrupt_plot_smoke() -> TestResult<()> { session.cancel().await?; return Ok(()); } - if !text.contains("plots_done") && !text.contains("<<console status: busy") { + if !initial_plot_command_completed(&text) { return Err(format!("expected plot command output marker, got: {text:?}").into()); } diff --git a/tests/repl_surface.rs b/tests/repl_surface.rs index ad2efe7..c90dc2d 100644 --- a/tests/repl_surface.rs +++ b/tests/repl_surface.rs @@ -27,7 +27,7 @@ fn backend_unavailable(text: &str) -> bool { } fn busy_response(text: &str) -> bool { - text.contains("<<console status: busy") + text.contains("<<repl status: busy") || text.contains("worker is busy") || text.contains("request already running") || text.contains("input discarded while worker busy") diff --git a/tests/reticulate_py_help.rs b/tests/reticulate_py_help.rs index db3f88a..32c0d86 100644 --- a/tests/reticulate_py_help.rs +++ b/tests/reticulate_py_help.rs @@ -16,8 +16,8 @@ fn result_text(result: &rmcp::model::CallToolResult) -> String { } #[tokio::test(flavor = "multi_thread")] -async fn reticulate_py_help_is_paged_or_skipped() -> TestResult<()> { - let mut session = common::spawn_server_with_pager_page_chars(2_000).await?; +async fn reticulate_py_help_is_rendered_or_skipped() -> TestResult<()> { + let mut session = common::spawn_server_with_files().await?; let result = session .write_stdin_raw_with( @@ -52,24 +52,19 @@ async fn reticulate_py_help_is_paged_or_skipped() -> TestResult<()> { return Ok(()); } if text.trim() == ">" { - eprintln!("reticulate::py_help() produced no console output in this environment; skipping"); + eprintln!("reticulate::py_help() produced no REPL output in this environment; skipping"); session.cancel().await?; return Ok(()); } assert!( - text.contains("--More--") || text.to_ascii_lowercase().contains("help"), + text.to_ascii_lowercase().contains("help"), "expected reticulate::py_help() output, got: {text:?}" ); - - if text.contains("--More--") { - let result = session.write_stdin_raw_with("Next", Some(30.0)).await?; - let next_text = result_text(&result); - assert!( - next_text.contains("--More--") || next_text.contains("(END") || !next_text.is_empty(), - "expected subsequent pager output, got: {next_text:?}" - ); - } + assert!( + !text.contains("--More--"), + "did not expect pager footer, got: {text:?}" + ); session.cancel().await?; Ok(()) diff --git a/tests/sandbox_state_updates.rs b/tests/sandbox_state_updates.rs index 15f80de..0e8bdd3 100644 --- a/tests/sandbox_state_updates.rs +++ b/tests/sandbox_state_updates.rs @@ -5,8 +5,12 @@ mod common; use common::{McpTestSession, TestResult}; use rmcp::model::{CallToolResult, RawContent}; use serde_json::json; +use std::fs; +use std::path::PathBuf; use std::sync::{Mutex, OnceLock}; use std::time::{Duration, Instant}; +use tempfile::tempdir; +use tokio::time::sleep; const SANDBOX_STATE_METHOD: &str = "codex/sandbox-state/update"; @@ -34,6 +38,30 @@ fn collect_text(result: &CallToolResult) -> String { .join("\n") } +fn result_text(result: &CallToolResult) -> String { + result + .content + .iter() + .filter_map(|content| match &content.raw { + RawContent::Text(text) => Some(text.text.as_str()), + _ => None, + }) + .collect::<Vec<_>>() + .join("") +} + +fn disclosed_path(text: &str, suffix: &str) -> Option<PathBuf> { + let end = text.find(suffix)?.saturating_add(suffix.len()); + let start = text[..end] + .rfind(|ch: char| ch.is_whitespace() || matches!(ch, '"' | '\'' | '[' | '(')) + .map_or(0, |idx| idx.saturating_add(1)); + Some(PathBuf::from(&text[start..end])) +} + +fn bundle_transcript_path(text: &str) -> Option<PathBuf> { + disclosed_path(text, "transcript.txt") +} + fn sandbox_update_params(network_access: bool) -> serde_json::Value { json!({ "sandboxPolicy": { @@ -61,16 +89,22 @@ fn backend_unavailable(text: &str) -> bool { } fn busy_response(text: &str) -> bool { - text.contains("<<console status: busy") + text.contains("<<repl status: busy") || text.contains("worker is busy") || text.contains("request already running") || text.contains("input discarded while worker busy") } async fn spawn_server_retry() -> TestResult<common::McpTestSession> { + spawn_server_retry_with_env_vars(Vec::new()).await +} + +async fn spawn_server_retry_with_env_vars( + env_vars: Vec<(String, String)>, +) -> TestResult<common::McpTestSession> { let mut last_error: Option<Box<dyn std::error::Error + Send + Sync>> = None; for _ in 0..3 { - match common::spawn_server().await { + match common::spawn_server_with_env_vars(env_vars.clone()).await { Ok(session) => return Ok(session), Err(err) => { let message = err.to_string(); @@ -92,6 +126,115 @@ async fn spawn_server_retry() -> TestResult<common::McpTestSession> { })) } +enum SandboxUpdateKind { + Request, + Notification, +} + +async fn wait_for_timeout_bundle_transcript( + session: &mut McpTestSession, + input: &str, +) -> TestResult<Option<PathBuf>> { + let first = session.write_stdin_raw_with(input, Some(0.05)).await?; + let first_text = result_text(&first); + if backend_unavailable(&first_text) { + return Ok(None); + } + + sleep(Duration::from_millis(260)).await; + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + let spilled = session + .write_stdin_raw_unterminated_with("", Some(0.1)) + .await?; + let spilled_text = result_text(&spilled); + if let Some(path) = bundle_transcript_path(&spilled_text) { + return Ok(Some(path)); + } + if !busy_response(&spilled_text) { + return Err(format!( + "expected timeout bundle disclosure in spill poll, got: {spilled_text:?}" + ) + .into()); + } + sleep(Duration::from_millis(100)).await; + } + + Err("timed out waiting for timeout bundle transcript".into()) +} + +async fn poll_until_not_busy(session: &mut McpTestSession) -> TestResult<CallToolResult> { + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + let result = session + .write_stdin_raw_unterminated_with("", Some(1.0)) + .await?; + let text = result_text(&result); + if !busy_response(&text) { + return Ok(result); + } + sleep(Duration::from_millis(100)).await; + } + Err("timed out waiting for non-busy empty poll".into()) +} + +async fn assert_sandbox_update_clears_stale_timeout_bundle( + kind: SandboxUpdateKind, +) -> TestResult<()> { + let _guard = test_mutex() + .lock() + .map_err(|_| "sandbox_state_updates test mutex poisoned")?; + if !common::sandbox_exec_available() { + eprintln!("sandbox-exec unavailable; skipping"); + return Ok(()); + } + + let temp = tempdir()?; + let mut session = spawn_server_retry_with_env_vars(vec![( + "TMPDIR".to_string(), + temp.path().display().to_string(), + )]) + .await?; + let input = "big <- paste(rep('q', 120), collapse = ''); cat('start\\n'); flush.console(); Sys.sleep(0.2); for (i in 1:80) cat(sprintf('mid%03d %s\\n', i, big)); flush.console(); Sys.sleep(30); cat('tail\\n')"; + let Some(transcript_path) = wait_for_timeout_bundle_transcript(&mut session, input).await? + else { + eprintln!("sandbox_state_updates backend unavailable; skipping"); + session.cancel().await?; + return Ok(()); + }; + let transcript_before = fs::read_to_string(&transcript_path)?; + + match kind { + SandboxUpdateKind::Request => { + session + .send_custom_request(SANDBOX_STATE_METHOD, sandbox_update_params(true)) + .await?; + } + SandboxUpdateKind::Notification => { + session + .send_custom_notification(SANDBOX_STATE_METHOD, sandbox_update_params(true)) + .await?; + sleep(Duration::from_millis(200)).await; + } + } + + let poll = poll_until_not_busy(&mut session).await?; + let poll_text = result_text(&poll); + let transcript_after = fs::read_to_string(&transcript_path)?; + + session.cancel().await?; + + assert!( + bundle_transcript_path(&poll_text).is_none(), + "did not expect empty poll after sandbox restart to reuse prior timeout bundle: {poll_text:?}" + ); + assert_eq!( + transcript_after, transcript_before, + "did not expect sandbox-triggered restart output to append to prior timeout bundle" + ); + Ok(()) +} + #[cfg(any(target_os = "macos", target_os = "linux"))] fn sandbox_full_access_params() -> serde_json::Value { json!({ @@ -176,6 +319,11 @@ async fn sandbox_state_update_request_restarts_worker() -> TestResult<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread")] +async fn sandbox_state_update_request_clears_hidden_timeout_bundle() -> TestResult<()> { + assert_sandbox_update_clears_stale_timeout_bundle(SandboxUpdateKind::Request).await +} + #[tokio::test(flavor = "multi_thread")] async fn sandbox_state_update_notification_restarts_worker() -> TestResult<()> { let _guard = test_mutex() @@ -203,6 +351,11 @@ async fn sandbox_state_update_notification_restarts_worker() -> TestResult<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread")] +async fn sandbox_state_update_notification_clears_hidden_timeout_bundle() -> TestResult<()> { + assert_sandbox_update_clears_stale_timeout_bundle(SandboxUpdateKind::Notification).await +} + #[cfg(any(target_os = "macos", target_os = "linux"))] #[tokio::test(flavor = "multi_thread")] async fn sandbox_state_update_applies_full_access_policy() -> TestResult<()> { diff --git a/tests/server_smoke.rs b/tests/server_smoke.rs index ac87e7d..7159f27 100644 --- a/tests/server_smoke.rs +++ b/tests/server_smoke.rs @@ -23,7 +23,7 @@ fn result_text(result: &rmcp::model::CallToolResult) -> String { #[cfg(windows)] fn is_busy_response(text: &str) -> bool { - text.contains("<<console status: busy") + text.contains("<<repl status: busy") || text.contains("worker is busy") || text.contains("request already running") || text.contains("input discarded while worker busy") diff --git a/tests/session_endings.rs b/tests/session_endings.rs index 47a6151..1369e4a 100644 --- a/tests/session_endings.rs +++ b/tests/session_endings.rs @@ -164,7 +164,7 @@ async fn session_endings_windows_smoke() -> TestResult<()> { if last_text.contains(expected) { return Ok(true); } - if last_text.contains("<<console status: busy") + if last_text.contains("<<repl status: busy") || last_text.contains("worker is busy") || last_text.contains("request already running") || last_text.contains("input discarded while worker busy") diff --git a/tests/snapshots/codex_approvals_tui__macos__codex_exec_initial_sandbox_state.snap b/tests/snapshots/codex_approvals_tui__macos__codex_exec_initial_sandbox_state.snap index 921b956..fcdfef2 100644 --- a/tests/snapshots/codex_approvals_tui__macos__codex_exec_initial_sandbox_state.snap +++ b/tests/snapshots/codex_approvals_tui__macos__codex_exec_initial_sandbox_state.snap @@ -6,6 +6,6 @@ $ codex exec --json --sandbox workspace-write --skip-git-repo-check --cd <WORKSP {"type":"thread.started","thread_id":"<THREAD_ID>"} {"type":"turn.started"} {"type":"item.started","item":{"id":"item_0","type":"mcp_tool_call","server":"r","tool":"repl","arguments":{"input":"target <- tempfile(\"mcp-repl-codex\")\ntryCatch({\n writeLines(\"ok\", target)\n cat(\"WRITE_OK\\n\")\n unlink(target)\n}, error = function(e) {\n message(\"WRITE_ERROR:\", conditionMessage(e))\n})\n"},"result":null,"error":null,"status":"in_progress"}} -{"type":"item.completed","item":{"id":"item_0","type":"mcp_tool_call","server":"r","tool":"repl","arguments":{"input":"target <- tempfile(\"mcp-repl-codex\")\ntryCatch({\n writeLines(\"ok\", target)\n cat(\"WRITE_OK\\n\")\n unlink(target)\n}, error = function(e) {\n message(\"WRITE_ERROR:\", conditionMessage(e))\n})\n"},"result":{"content":[{"type":"text","text":"[repl] echoed input elided: 8 lines (203 bytes); head: > target <- tempfile(\"mcp-repl-codex\"); tail: + })\nWRITE_OK\n"},{"type":"text","text":"> "}],"structured_content":null},"error":null,"status":"completed"}} +{"type":"item.completed","item":{"id":"item_0","type":"mcp_tool_call","server":"r","tool":"repl","arguments":{"input":"target <- tempfile(\"mcp-repl-codex\")\ntryCatch({\n writeLines(\"ok\", target)\n cat(\"WRITE_OK\\n\")\n unlink(target)\n}, error = function(e) {\n message(\"WRITE_ERROR:\", conditionMessage(e))\n})\n"},"result":{"content":[{"type":"text","text":"WRITE_OK\n"},{"type":"text","text":"> "}],"structured_content":null},"error":null,"status":"completed"}} {"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"Tool call 1 completed"}} {"type":"turn.completed","usage":{"input_tokens":"<N>","cached_input_tokens":"<N>","output_tokens":"<N>"}} diff --git a/tests/snapshots/codex_approvals_tui__macos__codex_exec_initial_sandbox_state_plain.snap b/tests/snapshots/codex_approvals_tui__macos__codex_exec_initial_sandbox_state_plain.snap index 44c1a77..558e464 100644 --- a/tests/snapshots/codex_approvals_tui__macos__codex_exec_initial_sandbox_state_plain.snap +++ b/tests/snapshots/codex_approvals_tui__macos__codex_exec_initial_sandbox_state_plain.snap @@ -25,7 +25,7 @@ r.repl({"input":"target <- tempfile(\"mcp-repl-codex\")\ntryCatch({\n writeLine "content": [ { "type": "text", - "text": "[repl] echoed input elided: 8 lines (203 bytes); head: > target <- tempfile(\"mcp-repl-codex\"); tail: + })\nWRITE_OK\n" + "text": "WRITE_OK\n" }, { "type": "text", diff --git a/tests/snapshots/mcp_transcripts__snapshots_interrupt_handler_output.snap b/tests/snapshots/mcp_transcripts__snapshots_interrupt_handler_output.snap index 7832110..dc2b46e 100644 --- a/tests/snapshots/mcp_transcripts__snapshots_interrupt_handler_output.snap +++ b/tests/snapshots/mcp_transcripts__snapshots_interrupt_handler_output.snap @@ -1,6 +1,6 @@ --- source: tests/mcp_transcripts.rs -expression: snapshot.render() +expression: rendered --- == session: interrupt_handler == -- step 1 -- @@ -19,11 +19,7 @@ response: "content": [ { "type": "text", - "text": "> tryCatch({ Sys.sleep(10000000) }, interrupt = function(e) cat(\"interrupt received\\n\"))" - }, - { - "type": "text", - "text": "<<console status: busy, write_stdin timeout reached; elapsed_ms=N>>" + "text": "<<repl status: busy, write_stdin timeout reached; elapsed_ms=N>>" } ] } diff --git a/tests/snapshots/mcp_transcripts__snapshots_interrupt_handler_output@transcript.snap b/tests/snapshots/mcp_transcripts__snapshots_interrupt_handler_output@transcript.snap index afb3e3d..d62d35d 100644 --- a/tests/snapshots/mcp_transcripts__snapshots_interrupt_handler_output@transcript.snap +++ b/tests/snapshots/mcp_transcripts__snapshots_interrupt_handler_output@transcript.snap @@ -5,7 +5,7 @@ expression: snapshot.render_transcript() == session: interrupt_handler == 1) r_repl timeout_ms=200 >>> tryCatch({ Sys.sleep(10000000) }, interrupt = function(e) cat("interrupt received\n")) -<<< <<console status: busy, write_stdin timeout reached; elapsed_ms=N>> +<<< <<repl status: busy, write_stdin timeout reached; elapsed_ms=N>> 2) r_repl timeout_ms=5000 >>>  diff --git a/tests/snapshots/mcp_transcripts__snapshots_support_multiple_calls_and_sessions.snap b/tests/snapshots/mcp_transcripts__snapshots_support_multiple_calls_and_sessions.snap index 64254f1..327d553 100644 --- a/tests/snapshots/mcp_transcripts__snapshots_support_multiple_calls_and_sessions.snap +++ b/tests/snapshots/mcp_transcripts__snapshots_support_multiple_calls_and_sessions.snap @@ -1,6 +1,6 @@ --- source: tests/mcp_transcripts.rs -expression: snapshot.render() +expression: rendered --- == session: session_1 == -- step 1 -- diff --git a/tests/snapshots/mcp_transcripts__snapshots_tempdir_session_restart.snap b/tests/snapshots/mcp_transcripts__snapshots_tempdir_session_restart.snap index 7742362..8e23d94 100644 --- a/tests/snapshots/mcp_transcripts__snapshots_tempdir_session_restart.snap +++ b/tests/snapshots/mcp_transcripts__snapshots_tempdir_session_restart.snap @@ -19,11 +19,11 @@ response: "content": [ { "type": "text", - "text": "[repl] echoed input elided: 2 lines (70 bytes); head: > ; tail: > cat(\"TMPDIR_SET=\", nzchar(Sys.getenv(\"TMPDIR\")), \"\\n\", sep = \"\")\nTMPDIR_SET=TRUE\n> cat(\"TMPDIR_MATCH=\", Sys.getenv(\"TMPDIR\") == Sys.getenv(\"MCP_REPL_R_SESSION_TMPDIR\"), \"\\n\", sep = \"\")\nTMPDIR_MATCH=TRUE" + "text": "TMPDIR_SET=TRUE\n> cat(\"TMPDIR_MATCH=\", Sys.getenv(\"TMPDIR\") == Sys.getenv(\"MCP_REPL_R_SESSION_TMPDIR\"), \"\\n\", sep = \"\")\nTMPDIR_MATCH=TRUE\n> cat(\"TEMPDIR_UNDER_TMPDIR=\", startsWith(tempdir(), Sys.getenv(\"TMPDIR\")), \"\\n\", sep = \"\")\nTEMPDIR_UNDER_TMPDIR=TRUE\n[repl] echoed input elided: 7 lines (222 bytes); head: > marker <- file.path(tempdir(), \"mcp-repl-snapshot.txt\"); tail: + })\nTEMPDIR_MARKER_OK\n[repl] echoed input elided: 7 lines (167 bytes); head: > tf <- tempfile(); tail: + })\nTEMPFILE_OK\n[repl] echoed input elided: 2 lines (98 bytes); head: > unlink(tf); tail: > cat(\"TEMPDIR_LIST=\", paste(list.files(tempdir()), collapse = \",\"), \"\\n\", sep = \"\")\nTEMPDIR_LIST=mcp-repl-snapshot.txt\n[repl] echoed input elided: 7 lines (243 bytes); head: > root_marker <- file.path(Sys.getenv(\"TMPDIR\"), \"mcp-repl-snapshot-root.txt\"); tail: + })\nROOT_MARKER_OK\n> cat(\"ROOT_MARKER_EXISTS=\", file.exists(root_marker), \"\\n\", sep = \"\")\nROOT_MARKER_EXISTS=TRUE" }, { "type": "text", - "text": "--More-- (3p, 24.9%, @0..269/1078)" + "text": "> " } ] } @@ -62,7 +62,7 @@ response: "content": [ { "type": "text", - "text": "[repl] echoed input elided: 3 lines (153 bytes); head: > ; tail: > cat(\"ROOT_MARKER_EXISTS=\", file.exists(root_marker), \"\\n\", sep = \"\")\nROOT_MARKER_EXISTS=FALSE\n> cat(\"TEMPDIR_LIST=\", paste(list.files(tempdir()), collapse = \",\"), \"\\n\", sep = \"\")\nTEMPDIR_LIST=" + "text": "ROOT_MARKER_EXISTS=FALSE\n> cat(\"TEMPDIR_LIST=\", paste(list.files(tempdir()), collapse = \",\"), \"\\n\", sep = \"\")\nTEMPDIR_LIST=\n> cat(\"TEMPDIR_UNDER_TMPDIR=\", startsWith(tempdir(), Sys.getenv(\"TMPDIR\")), \"\\n\", sep = \"\")\nTEMPDIR_UNDER_TMPDIR=TRUE" }, { "type": "text", @@ -105,7 +105,7 @@ response: "content": [ { "type": "text", - "text": "[repl] echoed input elided: 3 lines (153 bytes); head: > ; tail: > cat(\"ROOT_MARKER_EXISTS=\", file.exists(root_marker), \"\\n\", sep = \"\")\nROOT_MARKER_EXISTS=FALSE\n> cat(\"TEMPDIR_LIST=\", paste(list.files(tempdir()), collapse = \",\"), \"\\n\", sep = \"\")\nTEMPDIR_LIST=" + "text": "ROOT_MARKER_EXISTS=FALSE\n> cat(\"TEMPDIR_LIST=\", paste(list.files(tempdir()), collapse = \",\"), \"\\n\", sep = \"\")\nTEMPDIR_LIST=\n> cat(\"TEMPDIR_UNDER_TMPDIR=\", startsWith(tempdir(), Sys.getenv(\"TMPDIR\")), \"\\n\", sep = \"\")\nTEMPDIR_UNDER_TMPDIR=TRUE" }, { "type": "text", diff --git a/tests/snapshots/mcp_transcripts__snapshots_tempdir_session_restart@transcript.snap b/tests/snapshots/mcp_transcripts__snapshots_tempdir_session_restart@transcript.snap index 4a68540..53b82ab 100644 --- a/tests/snapshots/mcp_transcripts__snapshots_tempdir_session_restart@transcript.snap +++ b/tests/snapshots/mcp_transcripts__snapshots_tempdir_session_restart@transcript.snap @@ -32,10 +32,18 @@ expression: transcript >>> message("ROOT_MARKER_ERROR:", conditionMessage(e)) >>> }) >>> cat("ROOT_MARKER_EXISTS=", file.exists(root_marker), "\n", sep = "") -<<< [repl] echoed input elided: 2 lines (70 bytes); head: > ; tail: > cat("TMPDIR_SET=", nzchar(Sys.getenv("TMPDIR")), "\n", sep = "") <<< TMPDIR_SET=TRUE <<< TMPDIR_MATCH=TRUE -<<< --More-- (3p, 24.9%, @0..269/1078) +<<< TEMPDIR_UNDER_TMPDIR=TRUE +<<< [repl] echoed input elided: 7 lines (222 bytes); head: > marker <- file.path(tempdir(), "mcp-repl-snapshot.txt"); tail: + }) +<<< TEMPDIR_MARKER_OK +<<< [repl] echoed input elided: 7 lines (167 bytes); head: > tf <- tempfile(); tail: + }) +<<< TEMPFILE_OK +<<< [repl] echoed input elided: 2 lines (98 bytes); head: > unlink(tf); tail: > cat("TEMPDIR_LIST=", paste(list.files(tempdir()), collapse = ","), "\n", sep = "") +<<< TEMPDIR_LIST=mcp-repl-snapshot.txt +<<< [repl] echoed input elided: 7 lines (243 bytes); head: > root_marker <- file.path(Sys.getenv("TMPDIR"), "mcp-repl-snapshot-root.txt"); tail: + }) +<<< ROOT_MARKER_OK +<<< ROOT_MARKER_EXISTS=TRUE 2) r_repl >>>  @@ -47,9 +55,9 @@ expression: transcript >>> cat("ROOT_MARKER_EXISTS=", file.exists(root_marker), "\n", sep = "") >>> cat("TEMPDIR_LIST=", paste(list.files(tempdir()), collapse = ","), "\n", sep = "") >>> cat("TEMPDIR_UNDER_TMPDIR=", startsWith(tempdir(), Sys.getenv("TMPDIR")), "\n", sep = "") -<<< [repl] echoed input elided: 3 lines (153 bytes); head: > ; tail: > cat("ROOT_MARKER_EXISTS=", file.exists(root_marker), "\n", sep = "") <<< ROOT_MARKER_EXISTS=FALSE <<< TEMPDIR_LIST= +<<< TEMPDIR_UNDER_TMPDIR=TRUE 4) r_repl >>>  @@ -61,6 +69,6 @@ expression: transcript >>> cat("ROOT_MARKER_EXISTS=", file.exists(root_marker), "\n", sep = "") >>> cat("TEMPDIR_LIST=", paste(list.files(tempdir()), collapse = ","), "\n", sep = "") >>> cat("TEMPDIR_UNDER_TMPDIR=", startsWith(tempdir(), Sys.getenv("TMPDIR")), "\n", sep = "") -<<< [repl] echoed input elided: 3 lines (153 bytes); head: > ; tail: > cat("ROOT_MARKER_EXISTS=", file.exists(root_marker), "\n", sep = "") <<< ROOT_MARKER_EXISTS=FALSE <<< TEMPDIR_LIST= +<<< TEMPDIR_UNDER_TMPDIR=TRUE diff --git a/tests/snapshots/plot_images__grid_plots_emit_images_and_updates.snap b/tests/snapshots/plot_images__grid_plots_emit_images_and_updates.snap index f62782a..4e684dd 100644 --- a/tests/snapshots/plot_images__grid_plots_emit_images_and_updates.snap +++ b/tests/snapshots/plot_images__grid_plots_emit_images_and_updates.snap @@ -12,13 +12,7 @@ expression: serialized { "type": "image", "data": "blake3:a1906ff9561813c36a9a3662e7740159b59c81481d6142183859678cba817c41", - "mimeType": "image/png", - "_meta": { - "mcpConsole": { - "imageId": "plot-2", - "isNewPage": true - } - } + "mimeType": "image/png" }, { "type": "text", @@ -36,13 +30,7 @@ expression: serialized { "type": "image", "data": "blake3:eea45b50384ba1ebfb7e74f1ab9826400b252526a72227900d8096b214bfec08", - "mimeType": "image/png", - "_meta": { - "mcpConsole": { - "imageId": "plot-2", - "isNewPage": false - } - } + "mimeType": "image/png" }, { "type": "text", diff --git a/tests/snapshots/plot_images__grid_plots_emit_images_and_updates@macos.snap b/tests/snapshots/plot_images__grid_plots_emit_images_and_updates@macos.snap index ee50b43..e795d32 100644 --- a/tests/snapshots/plot_images__grid_plots_emit_images_and_updates@macos.snap +++ b/tests/snapshots/plot_images__grid_plots_emit_images_and_updates@macos.snap @@ -12,13 +12,7 @@ expression: serialized { "type": "image", "data": "blake3:47525e6a088b368bfb44d178ec912c488125bafe7cf47165a37fa0377f2d116f", - "mimeType": "image/png", - "_meta": { - "mcpConsole": { - "imageId": "plot-2", - "isNewPage": true - } - } + "mimeType": "image/png" }, { "type": "text", @@ -36,13 +30,7 @@ expression: serialized { "type": "image", "data": "blake3:13144fb0a14c9e60fe21d1220af6bbf64f335854dafd4e803fe0bcb1c7fbe2ca", - "mimeType": "image/png", - "_meta": { - "mcpConsole": { - "imageId": "plot-2", - "isNewPage": false - } - } + "mimeType": "image/png" }, { "type": "text", diff --git a/tests/snapshots/plot_images__grid_plots_emit_stable_images_for_repeats.snap b/tests/snapshots/plot_images__grid_plots_emit_stable_images_for_repeats.snap index ea482b2..30ab35d 100644 --- a/tests/snapshots/plot_images__grid_plots_emit_stable_images_for_repeats.snap +++ b/tests/snapshots/plot_images__grid_plots_emit_stable_images_for_repeats.snap @@ -12,13 +12,7 @@ expression: serialized { "type": "image", "data": "blake3:a1906ff9561813c36a9a3662e7740159b59c81481d6142183859678cba817c41", - "mimeType": "image/png", - "_meta": { - "mcpConsole": { - "imageId": "plot-2", - "isNewPage": true - } - } + "mimeType": "image/png" }, { "type": "text", @@ -36,13 +30,7 @@ expression: serialized { "type": "image", "data": "blake3:a1906ff9561813c36a9a3662e7740159b59c81481d6142183859678cba817c41", - "mimeType": "image/png", - "_meta": { - "mcpConsole": { - "imageId": "plot-3", - "isNewPage": true - } - } + "mimeType": "image/png" }, { "type": "text", diff --git a/tests/snapshots/plot_images__grid_plots_emit_stable_images_for_repeats@macos.snap b/tests/snapshots/plot_images__grid_plots_emit_stable_images_for_repeats@macos.snap index 5597882..e194c6c 100644 --- a/tests/snapshots/plot_images__grid_plots_emit_stable_images_for_repeats@macos.snap +++ b/tests/snapshots/plot_images__grid_plots_emit_stable_images_for_repeats@macos.snap @@ -12,13 +12,7 @@ expression: serialized { "type": "image", "data": "blake3:47525e6a088b368bfb44d178ec912c488125bafe7cf47165a37fa0377f2d116f", - "mimeType": "image/png", - "_meta": { - "mcpConsole": { - "imageId": "plot-2", - "isNewPage": true - } - } + "mimeType": "image/png" }, { "type": "text", @@ -36,13 +30,7 @@ expression: serialized { "type": "image", "data": "blake3:47525e6a088b368bfb44d178ec912c488125bafe7cf47165a37fa0377f2d116f", - "mimeType": "image/png", - "_meta": { - "mcpConsole": { - "imageId": "plot-3", - "isNewPage": true - } - } + "mimeType": "image/png" }, { "type": "text", diff --git a/tests/snapshots/plot_images__multi_panel_plots_emit_single_image.snap b/tests/snapshots/plot_images__multi_panel_plots_emit_single_image.snap index 3d092a9..e2b4d44 100644 --- a/tests/snapshots/plot_images__multi_panel_plots_emit_single_image.snap +++ b/tests/snapshots/plot_images__multi_panel_plots_emit_single_image.snap @@ -12,13 +12,7 @@ expression: serialized { "type": "image", "data": "blake3:709934cb4985dce96f6d6cf437d06f422027aa525901182b6b5c3e76167d6128", - "mimeType": "image/png", - "_meta": { - "mcpConsole": { - "imageId": "plot-2", - "isNewPage": false - } - } + "mimeType": "image/png" }, { "type": "text", diff --git a/tests/snapshots/plot_images__multi_panel_plots_emit_single_image@macos.snap b/tests/snapshots/plot_images__multi_panel_plots_emit_single_image@macos.snap index b74714f..10b0ab2 100644 --- a/tests/snapshots/plot_images__multi_panel_plots_emit_single_image@macos.snap +++ b/tests/snapshots/plot_images__multi_panel_plots_emit_single_image@macos.snap @@ -12,13 +12,7 @@ expression: serialized { "type": "image", "data": "blake3:f767760a51c634506c4f219b749ff8aeb7f26e4fbe2ce6aa7b37f4564a76c23b", - "mimeType": "image/png", - "_meta": { - "mcpConsole": { - "imageId": "plot-2", - "isNewPage": false - } - } + "mimeType": "image/png" }, { "type": "text", diff --git a/tests/snapshots/plot_images__plots_emit_images_and_updates.snap b/tests/snapshots/plot_images__plots_emit_images_and_updates.snap index 0ea2dc7..97d90a4 100644 --- a/tests/snapshots/plot_images__plots_emit_images_and_updates.snap +++ b/tests/snapshots/plot_images__plots_emit_images_and_updates.snap @@ -12,13 +12,7 @@ expression: serialized { "type": "image", "data": "blake3:0694aafd9f8fc0744e7029b187c1ab182d061a4f180319772a19794be32ebf98", - "mimeType": "image/png", - "_meta": { - "mcpConsole": { - "imageId": "plot-2", - "isNewPage": true - } - } + "mimeType": "image/png" }, { "type": "text", @@ -36,13 +30,7 @@ expression: serialized { "type": "image", "data": "blake3:360038e3039ff204ea7387e588961dc5e0c2cac15c840f78a7c1c8f3e8c9a0c0", - "mimeType": "image/png", - "_meta": { - "mcpConsole": { - "imageId": "plot-2", - "isNewPage": false - } - } + "mimeType": "image/png" }, { "type": "text", diff --git a/tests/snapshots/plot_images__plots_emit_images_and_updates@macos.snap b/tests/snapshots/plot_images__plots_emit_images_and_updates@macos.snap index 3594c3f..269fa34 100644 --- a/tests/snapshots/plot_images__plots_emit_images_and_updates@macos.snap +++ b/tests/snapshots/plot_images__plots_emit_images_and_updates@macos.snap @@ -12,13 +12,7 @@ expression: serialized { "type": "image", "data": "blake3:ae6672605feb09b2f26604d8439f4ca60aac78b7db4f1f6a210b44194d84af6c", - "mimeType": "image/png", - "_meta": { - "mcpConsole": { - "imageId": "plot-2", - "isNewPage": true - } - } + "mimeType": "image/png" }, { "type": "text", @@ -36,13 +30,7 @@ expression: serialized { "type": "image", "data": "blake3:397871e52656cc5b0883b57c200ded44fb60ca1d264683ffb13e6bfd4e96ad85", - "mimeType": "image/png", - "_meta": { - "mcpConsole": { - "imageId": "plot-2", - "isNewPage": false - } - } + "mimeType": "image/png" }, { "type": "text", diff --git a/tests/snapshots/plot_images__plots_emit_stable_images_for_repeats.snap b/tests/snapshots/plot_images__plots_emit_stable_images_for_repeats.snap index d7d2094..1a18199 100644 --- a/tests/snapshots/plot_images__plots_emit_stable_images_for_repeats.snap +++ b/tests/snapshots/plot_images__plots_emit_stable_images_for_repeats.snap @@ -12,13 +12,7 @@ expression: serialized { "type": "image", "data": "blake3:0694aafd9f8fc0744e7029b187c1ab182d061a4f180319772a19794be32ebf98", - "mimeType": "image/png", - "_meta": { - "mcpConsole": { - "imageId": "plot-2", - "isNewPage": true - } - } + "mimeType": "image/png" }, { "type": "text", @@ -36,13 +30,7 @@ expression: serialized { "type": "image", "data": "blake3:0694aafd9f8fc0744e7029b187c1ab182d061a4f180319772a19794be32ebf98", - "mimeType": "image/png", - "_meta": { - "mcpConsole": { - "imageId": "plot-3", - "isNewPage": true - } - } + "mimeType": "image/png" }, { "type": "text", diff --git a/tests/snapshots/plot_images__plots_emit_stable_images_for_repeats@macos.snap b/tests/snapshots/plot_images__plots_emit_stable_images_for_repeats@macos.snap index 9dbea1b..937f788 100644 --- a/tests/snapshots/plot_images__plots_emit_stable_images_for_repeats@macos.snap +++ b/tests/snapshots/plot_images__plots_emit_stable_images_for_repeats@macos.snap @@ -12,13 +12,7 @@ expression: serialized { "type": "image", "data": "blake3:ae6672605feb09b2f26604d8439f4ca60aac78b7db4f1f6a210b44194d84af6c", - "mimeType": "image/png", - "_meta": { - "mcpConsole": { - "imageId": "plot-2", - "isNewPage": true - } - } + "mimeType": "image/png" }, { "type": "text", @@ -36,13 +30,7 @@ expression: serialized { "type": "image", "data": "blake3:ae6672605feb09b2f26604d8439f4ca60aac78b7db4f1f6a210b44194d84af6c", - "mimeType": "image/png", - "_meta": { - "mcpConsole": { - "imageId": "plot-3", - "isNewPage": true - } - } + "mimeType": "image/png" }, { "type": "text", diff --git a/tests/snapshots/refactor_coverage__snapshots_browser_prompt_and_continue.snap b/tests/snapshots/refactor_coverage__snapshots_browser_prompt_and_continue.snap index dc2babc..e21e037 100644 --- a/tests/snapshots/refactor_coverage__snapshots_browser_prompt_and_continue.snap +++ b/tests/snapshots/refactor_coverage__snapshots_browser_prompt_and_continue.snap @@ -1,6 +1,6 @@ --- source: tests/refactor_coverage.rs -expression: snapshot.render() +expression: rendered --- == session: browser_prompt == -- step 1 -- diff --git a/tests/snapshots/refactor_coverage__snapshots_pager_hits_with_images.snap b/tests/snapshots/refactor_coverage__snapshots_pager_hits_with_images.snap index 6489521..8eef856 100644 --- a/tests/snapshots/refactor_coverage__snapshots_pager_hits_with_images.snap +++ b/tests/snapshots/refactor_coverage__snapshots_pager_hits_with_images.snap @@ -19,11 +19,11 @@ response: "content": [ { "type": "text", - "text": "[repl] echoed input elided: 3 lines (36 bytes); head: > ; tail: > cat(\"# Title\\n\")\n# Title\n> for (i in 1:60) cat(\"alpha line \", i, \"\\n\", sep = \"\")\nalpha line 1\nalpha line 2\nalpha line 3\nalpha line 4\nalpha line 5\nalpha line 6\nalpha line 7\nalpha line 8\nalpha line 9\nalpha line 10\nalpha line 11" + "text": "# Title\n> for (i in 1:60) cat(\"alpha line \", i, \"\\n\", sep = \"\")\nalpha line 1\nalpha line 2\nalpha line 3\nalpha line 4\nalpha line 5\nalpha line 6\nalpha line 7\nalpha line 8\nalpha line 9\nalpha line 10\nalpha line 11\nalpha line 12\nalpha line 13\nalpha line 14\nalpha line 15\nalpha line 16\nalpha line 17" }, { "type": "text", - "text": "--More-- (Np, 10.2%, @0..292/2861)" + "text": "--More-- (Np, 10.5%, @0..293/2778)" } ] } @@ -43,11 +43,11 @@ response: "content": [ { "type": "text", - "text": "#1 @292 Title\n > alpha line 12" + "text": "#1 @293 Title\n > alpha line 18" }, { "type": "text", - "text": "--More-- (Np, 10.6%, @292..306/2861)" + "text": "--More-- (Np, 11.0%, @293..307/2778)" } ] } @@ -67,15 +67,15 @@ response: "content": [ { "type": "text", - "text": "[pager] elided output (already shown): @0..306" + "text": "[pager] elided output (already shown): @0..307" }, { "type": "text", - "text": "alpha line 13\nalpha line 14\nalpha line 15\nalpha line 16\nalpha line 17\nalpha line 18\nalpha line 19\nalpha line 20\nalpha line 21\nalpha line 22\nalpha line 23\nalpha line 24\nalpha line 25\nalpha line 26\nalpha line 27\nalpha line 28\nalpha line 29\nalpha line 30\nalpha line 31\nalpha line 32\nalpha line 33" + "text": "alpha line 19\nalpha line 20\nalpha line 21\nalpha line 22\nalpha line 23\nalpha line 24\nalpha line 25\nalpha line 26\nalpha line 27\nalpha line 28\nalpha line 29\nalpha line 30\nalpha line 31\nalpha line 32\nalpha line 33\nalpha line 34\nalpha line 35\nalpha line 36\nalpha line 37\nalpha line 38\nalpha line 39" }, { "type": "text", - "text": "--More-- (Np, 20.9%, @306..600/2861)" + "text": "--More-- (Np, 21.6%, @307..601/2778)" } ] } @@ -95,11 +95,11 @@ response: "content": [ { "type": "text", - "text": "#1 @978 Title\n > [repl] echoed input elided: 2 lines (79 bytes); head: > plot(1:5, type = \"l\"); tail: > for (i in 1:60) cat(\"beta line \", i, \"\\n\", sep = \"\")" + "text": "#1 @895 Title\n > [repl] echoed input elided: 2 lines (79 bytes); head: > plot(1:5, type = \"l\"); tail: > for (i in 1:60) cat(\"beta line \", i, \"\\n\", sep = \"\")" }, { "type": "text", - "text": "--More-- (Np, 39.0%, @978..1118/2861)" + "text": "--More-- (Np, 37.2%, @895..1035/2778)" } ] } diff --git a/tests/snapshots/refactor_coverage__snapshots_pager_hits_with_images@transcript.snap b/tests/snapshots/refactor_coverage__snapshots_pager_hits_with_images@transcript.snap index cce8215..22561a0 100644 --- a/tests/snapshots/refactor_coverage__snapshots_pager_hits_with_images@transcript.snap +++ b/tests/snapshots/refactor_coverage__snapshots_pager_hits_with_images@transcript.snap @@ -12,7 +12,6 @@ expression: transcript >>> for (i in 1:60) cat("beta line ", i, "\n", sep = "") >>> plot(5:1, type = "l") >>> for (i in 1:60) cat("gamma line ", i, "\n", sep = "") -<<< [repl] echoed input elided: 3 lines (36 bytes); head: > ; tail: > cat("# Title\n") <<< # Title <<< alpha line 1 <<< alpha line 2 @@ -25,23 +24,23 @@ expression: transcript <<< alpha line 9 <<< alpha line 10 <<< alpha line 11 -<<< --More-- (Np, 10.2%, @0..292/2861) +<<< alpha line 12 +<<< alpha line 13 +<<< alpha line 14 +<<< alpha line 15 +<<< alpha line 16 +<<< alpha line 17 +<<< --More-- (Np, 10.5%, @0..293/2778) 2) r_repl timeout_ms=10000 >>> :hits alpha -<<< #1 @292 Title -<<< > alpha line 12 -<<< --More-- (Np, 10.6%, @292..306/2861) +<<< #1 @293 Title +<<< > alpha line 18 +<<< --More-- (Np, 11.0%, @293..307/2778) 3) r_repl timeout_ms=10000 >>> :seek 0 -<<< [pager] elided output (already shown): @0..306 -<<< alpha line 13 -<<< alpha line 14 -<<< alpha line 15 -<<< alpha line 16 -<<< alpha line 17 -<<< alpha line 18 +<<< [pager] elided output (already shown): @0..307 <<< alpha line 19 <<< alpha line 20 <<< alpha line 21 @@ -57,10 +56,16 @@ expression: transcript <<< alpha line 31 <<< alpha line 32 <<< alpha line 33 -<<< --More-- (Np, 20.9%, @306..600/2861) +<<< alpha line 34 +<<< alpha line 35 +<<< alpha line 36 +<<< alpha line 37 +<<< alpha line 38 +<<< alpha line 39 +<<< --More-- (Np, 21.6%, @307..601/2778) 4) r_repl timeout_ms=10000 >>> :hits beta -<<< #1 @978 Title +<<< #1 @895 Title <<< > [repl] echoed input elided: 2 lines (79 bytes); head: > plot(1:5, type = "l"); tail: > for (i in 1:60) cat("beta line ", i, "\n", sep = "") -<<< --More-- (Np, 39.0%, @978..1118/2861) +<<< --More-- (Np, 37.2%, @895..1035/2778) diff --git a/tests/snapshots/refactor_coverage__snapshots_restart_and_interrupt_with_plots.snap b/tests/snapshots/refactor_coverage__snapshots_restart_and_interrupt_with_plots.snap index a66c0f2..ca39f0a 100644 --- a/tests/snapshots/refactor_coverage__snapshots_restart_and_interrupt_with_plots.snap +++ b/tests/snapshots/refactor_coverage__snapshots_restart_and_interrupt_with_plots.snap @@ -1,6 +1,6 @@ --- source: tests/refactor_coverage.rs -expression: snapshot.render() +expression: rendered --- == session: restart_interrupt == -- step 1 -- @@ -44,7 +44,7 @@ response: "content": [ { "type": "text", - "text": "[repl] echoed input elided: 3 lines (49 bytes); head: > ; tail: > cat(\"plots_done\\n\")\nplots_done" + "text": "plots_done" }, { "type": "image", @@ -116,11 +116,7 @@ response: "content": [ { "type": "text", - "text": "> Sys.sleep(5)" - }, - { - "type": "text", - "text": "<<console status: busy, write_stdin timeout reached; elapsed_ms=N>>" + "text": "<<repl status: busy, write_stdin timeout reached; elapsed_ms=N>>" } ] } diff --git a/tests/snapshots/refactor_coverage__snapshots_restart_and_interrupt_with_plots@transcript.snap b/tests/snapshots/refactor_coverage__snapshots_restart_and_interrupt_with_plots@transcript.snap index 6053a95..02247ba 100644 --- a/tests/snapshots/refactor_coverage__snapshots_restart_and_interrupt_with_plots@transcript.snap +++ b/tests/snapshots/refactor_coverage__snapshots_restart_and_interrupt_with_plots@transcript.snap @@ -1,6 +1,6 @@ --- source: tests/refactor_coverage.rs -expression: snapshot.render_transcript() +expression: transcript --- == session: restart_interrupt == 1) r_repl timeout_ms=10000 @@ -13,7 +13,6 @@ expression: snapshot.render_transcript() >>> >>> plot(5:1, type = "l") >>> cat("plots_done\n") -<<< [repl] echoed input elided: 3 lines (49 bytes); head: > ; tail: > cat("plots_done\n") <<< plots_done <<< [image/png len=0] @@ -27,7 +26,7 @@ expression: snapshot.render_transcript() 5) r_repl timeout_ms=200 >>> Sys.sleep(5) -<<< <<console status: busy, write_stdin timeout reached; elapsed_ms=N>> +<<< <<repl status: busy, write_stdin timeout reached; elapsed_ms=N>> 6) r_repl timeout_ms=5000 >>>  diff --git a/tests/snapshots/refactor_coverage__snapshots_truncation_notice_tail.snap b/tests/snapshots/refactor_coverage__snapshots_truncation_notice_tail.snap index 76fabb9..0ebf083 100644 --- a/tests/snapshots/refactor_coverage__snapshots_truncation_notice_tail.snap +++ b/tests/snapshots/refactor_coverage__snapshots_truncation_notice_tail.snap @@ -43,23 +43,11 @@ response: "content": [ { "type": "text", - "text": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" - }, - { - "type": "text", - "text": "[repl] output truncated (older output dropped)" - }, - { - "type": "text", - "text": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx> cat(\"\\nEND\\n\")\n\nEND" + "text": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx[repl] output truncated (older output dropped)\n...[middle truncated; shown lines 1-1 and 3-5 of 5 total; full output: <mcp-repl-output>/output-0001/transcript.txt]...\n\nEND" }, { "type": "text", "text": "(END, 100.0%, @2085334..2093526/2093526)" - }, - { - "type": "text", - "text": "> " } ] } diff --git a/tests/snapshots/refactor_coverage__snapshots_truncation_notice_tail@transcript.snap b/tests/snapshots/refactor_coverage__snapshots_truncation_notice_tail@transcript.snap index c5e049a..a9686e7 100644 --- a/tests/snapshots/refactor_coverage__snapshots_truncation_notice_tail@transcript.snap +++ b/tests/snapshots/refactor_coverage__snapshots_truncation_notice_tail@transcript.snap @@ -12,9 +12,7 @@ expression: transcript 2) r_repl timeout_ms=10000 >>> :tail 8k -<<< xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx -<<< [repl] output truncated (older output dropped) -<<< xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx> cat("\nEND\n") +<<< xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx[repl] output truncated (older output dropped) <<< <<< END <<< (END, 100.0%, @2085334..2093526/2093526) diff --git a/tests/snapshots/server_smoke__sends_input_to_r_console.snap b/tests/snapshots/server_smoke__sends_input_to_r_console.snap index a11cba6..9722a31 100644 --- a/tests/snapshots/server_smoke__sends_input_to_r_console.snap +++ b/tests/snapshots/server_smoke__sends_input_to_r_console.snap @@ -1,6 +1,6 @@ --- source: tests/server_smoke.rs -expression: snapshot.render() +expression: rendered --- == session: default == -- step 1 -- diff --git a/tests/snapshots/session_endings__snapshots_session_endings.snap b/tests/snapshots/session_endings__snapshots_session_endings.snap index 06cf289..52bb0ac 100644 --- a/tests/snapshots/session_endings__snapshots_session_endings.snap +++ b/tests/snapshots/session_endings__snapshots_session_endings.snap @@ -1,6 +1,6 @@ --- source: tests/session_endings.rs -expression: snapshot.render() +expression: rendered --- == session: restart_timeout_zero == -- step 1 -- @@ -243,7 +243,7 @@ response: "content": [ { "type": "text", - "text": "> quit(\"no\")\n[repl] session ended" + "text": "[repl] session ended" } ] } @@ -309,7 +309,7 @@ response: "content": [ { "type": "text", - "text": "> quit()\n[repl] session ended" + "text": "[repl] session ended" } ] } @@ -375,7 +375,7 @@ response: "content": [ { "type": "text", - "text": "> quit(\"yes\")\n[repl] session ended" + "text": "[repl] session ended" } ] } diff --git a/tests/snapshots/write_stdin_batch__write_stdin_accepts_multiple_calls.snap b/tests/snapshots/write_stdin_batch__write_stdin_accepts_multiple_calls.snap index b17b2cf..3e44c50 100644 --- a/tests/snapshots/write_stdin_batch__write_stdin_accepts_multiple_calls.snap +++ b/tests/snapshots/write_stdin_batch__write_stdin_accepts_multiple_calls.snap @@ -1,6 +1,6 @@ --- source: tests/write_stdin_batch.rs -expression: snapshot.render() +expression: rendered --- == session: list_inputs == -- step 1 -- diff --git a/tests/snapshots/write_stdin_batch__write_stdin_drives_browser.snap b/tests/snapshots/write_stdin_batch__write_stdin_drives_browser.snap index e89fc3c..623eb50 100644 --- a/tests/snapshots/write_stdin_batch__write_stdin_drives_browser.snap +++ b/tests/snapshots/write_stdin_batch__write_stdin_drives_browser.snap @@ -1,6 +1,6 @@ --- source: tests/write_stdin_batch.rs -expression: snapshot.render() +expression: rendered --- == session: browser_queue == -- step 1 -- diff --git a/tests/snapshots/write_stdin_batch__write_stdin_timeout_polling_returns_pending_output.snap b/tests/snapshots/write_stdin_batch__write_stdin_timeout_polling_returns_pending_output.snap deleted file mode 100644 index d63ae85..0000000 --- a/tests/snapshots/write_stdin_batch__write_stdin_timeout_polling_returns_pending_output.snap +++ /dev/null @@ -1,57 +0,0 @@ ---- -source: tests/write_stdin_batch.rs -expression: snapshot.render() ---- -== session: timeout_poll == --- step 1 -- -call: -{ - "tool": "r_repl", - "arguments": { - "input": "cat(\"start\\n\"); flush.console(); Sys.sleep(1); cat(\"end\\n\")\n", - "timeout_ms": 500 - } -} -response: -{ - "type": "tool_result", - "is_error": false, - "content": [ - { - "type": "text", - "text": "> cat(\"start\\n\"); flush.console(); Sys.sleep(1); cat(\"end\\n\")\nstart" - }, - { - "type": "text", - "text": "<<console status: busy, write_stdin timeout reached; elapsed_ms=N>>" - } - ] -} --- step 2 -- -call: -{ - "tool": "r_repl", - "arguments": { - "input": "\n", - "timeout_ms": 2000 - } -} -response: -{ - "type": "tool_result", - "is_error": false, - "content": [ - { - "type": "text", - "text": "end" - }, - { - "type": "text", - "text": "> " - }, - { - "type": "text", - "text": "[repl] input discarded while worker busy" - } - ] -} diff --git a/tests/snapshots/write_stdin_batch__write_stdin_timeout_polling_returns_pending_output@transcript.snap b/tests/snapshots/write_stdin_batch__write_stdin_timeout_polling_returns_pending_output@transcript.snap deleted file mode 100644 index e8528d5..0000000 --- a/tests/snapshots/write_stdin_batch__write_stdin_timeout_polling_returns_pending_output@transcript.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests/write_stdin_batch.rs -expression: snapshot.render_transcript() ---- -== session: timeout_poll == -1) r_repl timeout_ms=500 ->>> cat("start\n"); flush.console(); Sys.sleep(1); cat("end\n") -<<< start -<<< <<console status: busy, write_stdin timeout reached; elapsed_ms=N>> - -2) r_repl timeout_ms=2000 ->>> -<<< end -<<< [repl] input discarded while worker busy diff --git a/tests/snapshots/write_stdin_batch__write_stdin_timeout_then_busy_then_recovers.snap b/tests/snapshots/write_stdin_batch__write_stdin_timeout_then_busy_then_recovers.snap index 47e37f5..e334a90 100644 --- a/tests/snapshots/write_stdin_batch__write_stdin_timeout_then_busy_then_recovers.snap +++ b/tests/snapshots/write_stdin_batch__write_stdin_timeout_then_busy_then_recovers.snap @@ -1,6 +1,6 @@ --- source: tests/write_stdin_batch.rs -expression: snapshot.render() +expression: rendered --- == session: timeout_list == -- step 1 -- @@ -19,11 +19,7 @@ response: "content": [ { "type": "text", - "text": "> Sys.sleep(5)" - }, - { - "type": "text", - "text": "<<console status: busy, write_stdin timeout reached; elapsed_ms=N>>" + "text": "<<repl status: busy, write_stdin timeout reached; elapsed_ms=N>>" } ] } @@ -43,7 +39,7 @@ response: "content": [ { "type": "text", - "text": "<<console status: busy, write_stdin timeout reached; elapsed_ms=N>>" + "text": "<<repl status: busy, write_stdin timeout reached; elapsed_ms=N>>" }, { "type": "text", diff --git a/tests/snapshots/write_stdin_batch__write_stdin_timeout_then_busy_then_recovers@transcript.snap b/tests/snapshots/write_stdin_batch__write_stdin_timeout_then_busy_then_recovers@transcript.snap index 16adf78..dd3d8c2 100644 --- a/tests/snapshots/write_stdin_batch__write_stdin_timeout_then_busy_then_recovers@transcript.snap +++ b/tests/snapshots/write_stdin_batch__write_stdin_timeout_then_busy_then_recovers@transcript.snap @@ -5,11 +5,11 @@ expression: snapshot.render_transcript() == session: timeout_list == 1) r_repl timeout_ms=2000 >>> Sys.sleep(5) -<<< <<console status: busy, write_stdin timeout reached; elapsed_ms=N>> +<<< <<repl status: busy, write_stdin timeout reached; elapsed_ms=N>> 2) r_repl timeout_ms=1000 >>> 1+1 -<<< <<console status: busy, write_stdin timeout reached; elapsed_ms=N>> +<<< <<repl status: busy, write_stdin timeout reached; elapsed_ms=N>> <<< [repl] input discarded while worker busy 3) r_repl timeout_ms=10000 diff --git a/tests/worker_ipc_disconnect.rs b/tests/worker_ipc_disconnect.rs index 96441ff..fd64658 100644 --- a/tests/worker_ipc_disconnect.rs +++ b/tests/worker_ipc_disconnect.rs @@ -47,7 +47,8 @@ mod unix { let mut path = std::env::current_exe()?; path.pop(); path.pop(); - for candidate in ["mcp-repl"] { + { + let candidate = "mcp-repl"; let mut candidate_path = path.clone(); candidate_path.push(candidate); if candidate_path.exists() { diff --git a/tests/write_stdin_batch.rs b/tests/write_stdin_batch.rs index 0c9bb4a..9892563 100644 --- a/tests/write_stdin_batch.rs +++ b/tests/write_stdin_batch.rs @@ -3,6 +3,8 @@ mod common; #[cfg(not(windows))] use common::McpSnapshot; use common::TestResult; +use std::fs; +use std::path::PathBuf; #[cfg(not(windows))] use tokio::time::{Duration, sleep}; @@ -32,6 +34,29 @@ fn backend_unavailable(text: &str) -> bool { ) } +fn bundle_transcript_path(text: &str) -> Option<PathBuf> { + disclosed_path(text, "transcript.txt") +} + +fn disclosed_path(text: &str, suffix: &str) -> Option<PathBuf> { + let end = text.find(suffix)?.saturating_add(suffix.len()); + let start = text[..end] + .rfind(|ch: char| ch.is_whitespace() || matches!(ch, '"' | '\'' | '[' | '(')) + .map_or(0, |idx| idx.saturating_add(1)); + Some(PathBuf::from(&text[start..end])) +} + +#[test] +fn disclosed_path_parses_windows_paths() { + let text = "...[full output: C:\\Users\\runner\\AppData\\Local\\Temp\\mcp-repl-output\\output-0001\\transcript.txt]..."; + assert_eq!( + bundle_transcript_path(text), + Some(PathBuf::from( + r"C:\Users\runner\AppData\Local\Temp\mcp-repl-output\output-0001\transcript.txt" + )) + ); +} + #[cfg(not(windows))] fn assert_snapshot_or_skip(name: &str, snapshot: &McpSnapshot) -> TestResult<()> { let rendered = snapshot.render(); @@ -72,7 +97,7 @@ async fn write_stdin_timeout_then_busy_then_recovers() -> TestResult<()> { let mut snapshot = McpSnapshot::new(); snapshot - .session( + .files_session( "timeout_list", mcp_session!(|session| { session.write_stdin_with("Sys.sleep(5)", Some(2.0)).await; @@ -90,19 +115,42 @@ async fn write_stdin_timeout_then_busy_then_recovers() -> TestResult<()> { #[cfg(not(windows))] #[tokio::test(flavor = "multi_thread")] async fn write_stdin_timeout_polling_returns_pending_output() -> TestResult<()> { - let mut snapshot = McpSnapshot::new(); + let mut session = common::spawn_server().await?; - snapshot - .session("timeout_poll", mcp_script! { - write_stdin("cat(\"start\\n\"); flush.console(); Sys.sleep(1); cat(\"end\\n\")", timeout = 0.5); - write_stdin("", timeout = 2.0); - }) + let first = session + .write_stdin_raw_with( + "cat(\"start\\n\"); flush.console(); Sys.sleep(1); cat(\"end\\n\")", + Some(0.5), + ) .await?; + let first_text = collect_text(&first); + if backend_unavailable(&first_text) { + eprintln!("write_stdin_batch backend unavailable in this environment; skipping"); + session.cancel().await?; + return Ok(()); + } + assert!( + first_text.contains("start"), + "expected timeout reply to include early output, got: {first_text:?}" + ); + assert!( + first_text.contains("<<repl status: busy"), + "expected timeout status marker, got: {first_text:?}" + ); + + let second = session.write_stdin_raw_with("", Some(2.0)).await?; + let second_text = collect_text(&second); + session.cancel().await?; - assert_snapshot_or_skip( - "write_stdin_timeout_polling_returns_pending_output", - &snapshot, - ) + assert!( + !second_text.contains("<<repl status: busy"), + "expected empty poll to finish request, got: {second_text:?}" + ); + assert!( + second_text.contains("end"), + "expected empty poll to return trailing output, got: {second_text:?}" + ); + Ok(()) } #[cfg(not(windows))] @@ -132,7 +180,7 @@ async fn write_stdin_pager_search() -> TestResult<()> { let mut snapshot = McpSnapshot::new(); snapshot - .session("pager_search_queue", mcp_script! { + .pager_session("pager_search_queue", 300, mcp_script! { write_stdin("line <- paste(rep(\"x\", 200), collapse = \"\"); for (i in 1:200) cat(sprintf(\"line%04d %s\\n\", i, line))", timeout = 30.0); write_stdin(":/line0050", timeout = 30.0); write_stdin(":n", timeout = 30.0); @@ -149,7 +197,7 @@ async fn write_stdin_pager_hits() -> TestResult<()> { let mut snapshot = McpSnapshot::new(); snapshot - .session("pager_hits_queue", mcp_script! { + .pager_session("pager_hits_queue", 300, mcp_script! { write_stdin("line <- paste(rep(\"x\", 200), collapse = \"\"); for (i in 1:200) cat(sprintf(\"line%04d %s\\n\", i, line))", timeout = 30.0); write_stdin(":hits line0150", timeout = 30.0); write_stdin(":n", timeout = 30.0); @@ -175,7 +223,7 @@ async fn write_stdin_recovers_after_error() -> TestResult<()> { session.cancel().await?; return Ok(()); } - if text.contains("<<console status: busy") { + if text.contains("<<repl status: busy") { eprintln!("write_stdin_batch huge echo attribution still busy; skipping"); session.cancel().await?; return Ok(()); @@ -192,8 +240,6 @@ async fn write_stdin_recovers_after_error() -> TestResult<()> { async fn write_stdin_drops_huge_echo_only_inputs() -> TestResult<()> { let mut session = common::spawn_server().await?; - // Large silent inputs should not be returned as echoed transcripts (which can trip pager mode - // and waste tokens). The backend prompt is still returned. let input = (1..=2_000) .map(|idx| format!("x{idx} <- {idx}\n")) .collect::<String>(); @@ -204,7 +250,7 @@ async fn write_stdin_drops_huge_echo_only_inputs() -> TestResult<()> { session.cancel().await?; return Ok(()); } - if text.contains("<<console status: busy") { + if text.contains("<<repl status: busy") { eprintln!("write_stdin_batch huge echo-only input still busy; skipping"); session.cancel().await?; return Ok(()); @@ -212,23 +258,19 @@ async fn write_stdin_drops_huge_echo_only_inputs() -> TestResult<()> { session.cancel().await?; assert!( !text.contains("--More--"), - "expected no pager activation for echo-only input, got: {text:?}" - ); - assert!( - text.trim_end().ends_with('>'), - "expected backend prompt, got: {text:?}" + "did not expect pager activation for echo-only input, got: {text:?}" ); assert!( - text.len() < 1_000, - "expected trimmed output; got {} bytes", - text.len() + !text.contains("echoed input elided"), + "did not expect echo elision marker, got: {text:?}" ); + assert_eq!(text, "> ", "expected prompt-only reply, got: {text:?}"); Ok(()) } #[tokio::test(flavor = "multi_thread")] -async fn write_stdin_collapses_huge_echo_with_output_attribution() -> TestResult<()> { - let mut session = common::spawn_server().await?; +async fn write_stdin_trims_huge_leading_echo_prefix_and_preserves_later_echo() -> TestResult<()> { + let mut session = common::spawn_server_with_files().await?; let mut input = String::new(); for idx in 1..=1_000 { @@ -247,32 +289,59 @@ async fn write_stdin_collapses_huge_echo_with_output_attribution() -> TestResult session.cancel().await?; return Ok(()); } - if text.contains("<<console status: busy") { + if text.contains("<<repl status: busy") { eprintln!("write_stdin_batch huge echo attribution still busy; skipping"); session.cancel().await?; return Ok(()); } + let transcript_path = bundle_transcript_path(&text); + let spill_text = transcript_path + .as_ref() + .map(fs::read_to_string) + .transpose()?; session.cancel().await?; assert!( - text.contains("ok") && text.contains("done"), - "expected output from both cat() calls, got: {text:?}" - ); - assert!( - text.contains("echoed input elided"), - "expected echo elision marker, got: {text:?}" + text.contains("transcript.txt") || (text.contains("ok") && text.contains("y500 <- 500")), + "expected either an inline transcript or a spill path, got: {text:?}" ); + if let Some(spill_text) = spill_text { + assert!( + !spill_text.contains("x500 <- 500"), + "did not expect the pure leading echo prefix in spill file, got: {spill_text:?}" + ); + assert!( + spill_text.contains("y500 <- 500"), + "expected later echoed input to remain after output interleaving, got: {spill_text:?}" + ); + assert!( + spill_text.contains("ok") && spill_text.contains("done"), + "expected output from both cat() calls in spill file, got: {spill_text:?}" + ); + assert!( + text.contains("done"), + "expected the inline tail to keep the final output, got: {text:?}" + ); + } else { + assert!( + text.contains("ok") && text.contains("done"), + "expected output from both cat() calls inline, got: {text:?}" + ); + assert!( + !text.contains("x500 <- 500"), + "did not expect the pure leading echo prefix inline, got: {text:?}" + ); + assert!( + text.contains("y500 <- 500"), + "expected later echoed input to remain after output interleaving, got: {text:?}" + ); + } assert!( - !text.contains("x500 <- 500"), - "expected large echoed transcript to be collapsed, got: {text:?}" + !text.contains("echoed input elided"), + "did not expect echo elision marker, got: {text:?}" ); assert!( !text.contains("--More--"), - "expected no pager activation for huge echo with small output, got: {text:?}" - ); - assert!( - text.len() < 8_000, - "expected bounded output; got {} bytes", - text.len() + "did not expect pager activation for huge echo with small output, got: {text:?}" ); Ok(()) } diff --git a/tests/write_stdin_behavior.rs b/tests/write_stdin_behavior.rs index 171d943..84f26ee 100644 --- a/tests/write_stdin_behavior.rs +++ b/tests/write_stdin_behavior.rs @@ -4,7 +4,10 @@ mod common; use common::TestResult; use rmcp::model::RawContent; +use std::fs; +use std::path::PathBuf; use std::sync::{Mutex, MutexGuard, OnceLock}; +use tempfile::tempdir; use tokio::time::{Duration, Instant, sleep}; fn test_mutex() -> &'static Mutex<()> { @@ -45,13 +48,60 @@ fn backend_unavailable(text: &str) -> bool { ) } +fn bundle_events_log_path(text: &str) -> Option<PathBuf> { + disclosed_path(text, "events.log") +} + +fn bundle_transcript_path(text: &str) -> Option<PathBuf> { + disclosed_path(text, "transcript.txt") +} + +fn disclosed_path(text: &str, suffix: &str) -> Option<PathBuf> { + let end = text.find(suffix)?.saturating_add(suffix.len()); + let start = text[..end] + .rfind(|ch: char| ch.is_whitespace() || matches!(ch, '"' | '\'' | '[' | '(')) + .map_or(0, |idx| idx.saturating_add(1)); + Some(PathBuf::from(&text[start..end])) +} + +fn bundle_root(path: &std::path::Path) -> PathBuf { + path.parent() + .expect("bundle artifact should have a parent bundle dir") + .to_path_buf() +} + +fn has_timeout_bundle_dir(temp_root: &std::path::Path) -> TestResult<bool> { + for entry in fs::read_dir(temp_root)? { + let entry = entry?; + let file_name = entry.file_name(); + if !file_name.to_string_lossy().starts_with("mcp-repl-output-") { + continue; + } + if entry.path().join("output-0001").exists() { + return Ok(true); + } + } + Ok(false) +} + +async fn wait_for_path_to_disappear(path: &std::path::Path) -> TestResult<()> { + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + if !path.exists() { + return Ok(()); + } + sleep(Duration::from_millis(50)).await; + } + Err(format!("path still exists after shutdown: {}", path.display()).into()) +} + async fn wait_until_not_busy( session: &mut common::McpTestSession, initial: rmcp::model::CallToolResult, ) -> TestResult<rmcp::model::CallToolResult> { let mut result = initial; let mut text = result_text(&result); - if !text.contains("<<console status: busy") { + if !text.contains("<<repl status: busy") { return Ok(result); } @@ -63,7 +113,7 @@ async fn wait_until_not_busy( .await?; text = result_text(&next); result = next; - if !text.contains("<<console status: busy") { + if !text.contains("<<repl status: busy") { return Ok(result); } } @@ -71,21 +121,86 @@ async fn wait_until_not_busy( Err(format!("worker remained busy after polling: {text:?}").into()) } -async fn spawn_behavior_session() -> TestResult<common::McpTestSession> { - #[cfg(target_os = "windows")] - { - common::spawn_server_with_args(vec![ - "--sandbox".to_string(), - "danger-full-access".to_string(), - ]) - .await +async fn wait_until_file_contains_via_polls( + session: &mut common::McpTestSession, + path: &std::path::Path, + needle: &str, +) -> TestResult<String> { + let deadline = Instant::now() + Duration::from_secs(5); + let mut last_text = String::new(); + while Instant::now() < deadline { + match fs::read_to_string(path) { + Ok(text) => { + if text.contains(needle) { + return Ok(text); + } + last_text = text; + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(err.into()), + } + + let next = session + .write_stdin_raw_unterminated_with("", Some(0.5)) + .await?; + let next_text = result_text(&next); + if let Some(disclosed_path) = bundle_transcript_path(&next_text) { + assert_eq!( + disclosed_path, path, + "did not expect later empty polls to switch transcript paths, got: {next_text:?}" + ); + } + sleep(Duration::from_millis(50)).await; } - #[cfg(not(target_os = "windows"))] - { - common::spawn_server().await + + Err(format!( + "file did not contain {needle:?} before timeout: {} last contents: {last_text:?}", + path.display() + ) + .into()) +} + +const INLINE_TEXT_BUDGET_CHARS: usize = 3500; +const INLINE_TEXT_HARD_SPILL_THRESHOLD_CHARS: usize = INLINE_TEXT_BUDGET_CHARS * 5 / 4; +const UNDER_HARD_SPILL_TEXT_LEN: usize = INLINE_TEXT_BUDGET_CHARS + 200; +const OVER_HARD_SPILL_TEXT_LEN: usize = INLINE_TEXT_HARD_SPILL_THRESHOLD_CHARS + 200; + +fn test_timeout_secs(default_secs: f64, windows_secs: f64) -> f64 { + if cfg!(windows) { + windows_secs + } else { + default_secs } } +fn test_delay_ms(default_ms: u64, windows_ms: u64) -> Duration { + Duration::from_millis(if cfg!(windows) { + windows_ms + } else { + default_ms + }) +} + +fn output_bundle_temp_env_vars(path: &std::path::Path) -> Vec<(String, String)> { + let value = path.display().to_string(); + vec![ + ("TMPDIR".to_string(), value.clone()), + ("TMP".to_string(), value.clone()), + ("TEMP".to_string(), value), + ] +} + +async fn spawn_behavior_session() -> TestResult<common::McpTestSession> { + spawn_behavior_session_with_env_vars(Vec::new()).await +} + +async fn spawn_behavior_session_with_env_vars( + env_vars: Vec<(String, String)>, +) -> TestResult<common::McpTestSession> { + // These assertions exercise the files-mode public API on every platform. + common::spawn_server_with_files_env_vars(env_vars).await +} + #[tokio::test(flavor = "multi_thread")] async fn write_stdin_discards_when_busy() -> TestResult<()> { let _guard = lock_test_mutex(); @@ -104,8 +219,7 @@ async fn write_stdin_discards_when_busy() -> TestResult<()> { return Ok(()); } assert!( - text.contains("input discarded while worker busy") - || text.contains("<<console status: busy"), + text.contains("input discarded while worker busy") || text.contains("<<repl status: busy"), "expected busy discard/timeout message, got: {text:?}" ); assert_ne!(result.is_error, Some(true)); @@ -115,7 +229,7 @@ async fn write_stdin_discards_when_busy() -> TestResult<()> { } #[tokio::test(flavor = "multi_thread")] -async fn write_stdin_trims_continuation_echo() -> TestResult<()> { +async fn write_stdin_trims_continuation_echo_prefix() -> TestResult<()> { let _guard = lock_test_mutex(); let mut session = spawn_behavior_session().await?; @@ -126,20 +240,195 @@ async fn write_stdin_trims_continuation_echo() -> TestResult<()> { session.cancel().await?; return Ok(()); } - if text.contains("<<console status: busy") { + if text.contains("<<repl status: busy") { eprintln!("write_stdin_behavior continuation output still busy; skipping"); session.cancel().await?; return Ok(()); } session.cancel().await?; - assert!(text.contains("2"), "expected result, got: {text:?}"); + assert!(text.contains("[1] 2"), "expected result, got: {text:?}"); assert!( !text.contains("> 1+"), - "did not expect echoed input prompt line, got: {text:?}" + "did not expect echoed first line in trimmed reply, got: {text:?}" ); assert!( !text.contains("\n+ 1"), - "did not expect echoed continuation prompt line, got: {text:?}" + "did not expect echoed continuation line in trimmed reply, got: {text:?}" + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn write_stdin_trims_full_noninterleaved_multiexpression_echo_prefix() -> TestResult<()> { + let _guard = lock_test_mutex(); + let mut session = spawn_behavior_session().await?; + + let result = session + .write_stdin_raw_with("x <- 1\nx + 1", Some(30.0)) + .await?; + let text = result_text(&result); + if backend_unavailable(&text) { + eprintln!("write_stdin_behavior backend unavailable in this environment; skipping"); + session.cancel().await?; + return Ok(()); + } + if text.contains("<<repl status: busy") { + eprintln!("write_stdin_behavior multi-expression output still busy; skipping"); + session.cancel().await?; + return Ok(()); + } + + session.cancel().await?; + assert!(text.contains("[1] 2"), "expected result, got: {text:?}"); + assert!( + !text.contains("> x <- 1"), + "did not expect leading assignment echo in trimmed reply, got: {text:?}" + ); + assert!( + !text.contains("> x + 1"), + "did not expect trailing expression echo when the whole prefix is safe to trim, got: {text:?}" + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn write_stdin_drops_echo_only_multiexpression_reply() -> TestResult<()> { + let _guard = lock_test_mutex(); + let mut session = spawn_behavior_session().await?; + + let result = session + .write_stdin_raw_with("x <- 1\ny <- 2", Some(30.0)) + .await?; + let text = result_text(&result); + if backend_unavailable(&text) { + eprintln!("write_stdin_behavior backend unavailable in this environment; skipping"); + session.cancel().await?; + return Ok(()); + } + if text.contains("<<repl status: busy") { + eprintln!("write_stdin_behavior echo-only multi-expression output still busy; skipping"); + session.cancel().await?; + return Ok(()); + } + + session.cancel().await?; + assert_eq!(text, "> ", "expected prompt-only reply, got: {text:?}"); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn write_stdin_preserves_later_echo_when_output_is_interleaved() -> TestResult<()> { + let _guard = lock_test_mutex(); + let mut session = spawn_behavior_session().await?; + + let result = session + .write_stdin_raw_with("cat('A\\n')\n1+1", Some(30.0)) + .await?; + let text = result_text(&result); + if backend_unavailable(&text) { + eprintln!("write_stdin_behavior backend unavailable in this environment; skipping"); + session.cancel().await?; + return Ok(()); + } + if text.contains("<<repl status: busy") { + eprintln!("write_stdin_behavior interleaved output still busy; skipping"); + session.cancel().await?; + return Ok(()); + } + + session.cancel().await?; + assert!( + text.contains("A\n"), + "expected first expression output, got: {text:?}" + ); + assert!( + text.contains("[1] 2"), + "expected second expression result, got: {text:?}" + ); + assert!( + !text.contains("> cat('A\\n')"), + "did not expect the leading echoed prefix to remain, got: {text:?}" + ); + assert!( + text.contains("> 1+1"), + "expected later echoed expression to remain for attribution after output interleaving, got: {text:?}" + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn write_stdin_preserves_non_repl_readline_transcripts() -> TestResult<()> { + let _guard = lock_test_mutex(); + let mut session = spawn_behavior_session().await?; + + let input = format!( + "first <- readline('FIRST> '); second <- readline('SECOND> '); big <- paste(rep('z', {OVER_HARD_SPILL_TEXT_LEN}), collapse = ''); cat('DONE_START\\n'); cat(big); cat('\\nDONE_END\\n')" + ); + let first = session.write_stdin_raw_with(&input, Some(10.0)).await?; + let first_text = result_text(&first); + if backend_unavailable(&first_text) { + eprintln!("write_stdin_behavior backend unavailable in this environment; skipping"); + session.cancel().await?; + return Ok(()); + } + + assert!( + first_text.contains("FIRST> "), + "expected first readline prompt, got: {first_text:?}" + ); + + let second = session.write_stdin_raw_with("alpha", Some(10.0)).await?; + let second_text = result_text(&second); + assert!( + second_text.contains("FIRST> alpha"), + "expected first readline transcript in follow-up reply, got: {second_text:?}" + ); + assert!( + second_text.contains("SECOND> "), + "expected second readline prompt after the first answer, got: {second_text:?}" + ); + + let third = session.write_stdin_raw_with("beta", Some(30.0)).await?; + let third = wait_until_not_busy(&mut session, third).await?; + let third_text = result_text(&third); + let transcript_path = bundle_transcript_path(&third_text).unwrap_or_else(|| { + panic!("expected transcript path in spilled readline reply, got: {third_text:?}") + }); + let transcript = fs::read_to_string(&transcript_path)?; + + session.cancel().await?; + + assert!( + transcript.contains("SECOND> beta"), + "expected second readline transcript in transcript.txt, got: {transcript:?}" + ); + assert!( + transcript.contains("DONE_START") && transcript.contains("DONE_END"), + "expected spilled worker output in transcript.txt, got: {transcript:?}" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn write_stdin_does_not_treat_colon_input_as_pager_command_by_default() -> TestResult<()> { + let _guard = lock_test_mutex(); + let mut session = spawn_behavior_session().await?; + + let result = session.write_stdin_raw_with(":q", Some(10.0)).await?; + let text = result_text(&result); + if backend_unavailable(&text) { + eprintln!("write_stdin_behavior backend unavailable in this environment; skipping"); + session.cancel().await?; + return Ok(()); + } + + session.cancel().await?; + assert!( + !text.contains("[pager]") + && !text.contains("--More--") + && !text.contains("no pager active"), + "did not expect pager handling in default files mode, got: {text:?}" ); Ok(()) } @@ -186,18 +475,18 @@ async fn write_stdin_normalizes_error_prompt() -> TestResult<()> { session.cancel().await?; return Ok(()); } - if text.contains("<<console status: busy") { + if text.contains("<<repl status: busy") { eprintln!("write_stdin_behavior error prompt output still busy; skipping"); session.cancel().await?; return Ok(()); } session.cancel().await?; assert!( - text.contains("Error: boom"), + text.contains("Error: boom\n"), "missing error text, got: {text:?}" ); assert!( - !text.contains("> Error: boom"), + !text.contains("> Error: boom\n"), "expected leading prompt to be normalized, got: {text:?}" ); assert_ne!(result.is_error, Some(true)); @@ -205,64 +494,912 @@ async fn write_stdin_normalizes_error_prompt() -> TestResult<()> { } #[tokio::test(flavor = "multi_thread")] -async fn write_stdin_auto_dismisses_pager_for_backend_input() -> TestResult<()> { +async fn write_stdin_large_output_is_not_paged() -> TestResult<()> { let _guard = lock_test_mutex(); - let mut session = common::spawn_server_with_pager_page_chars(80).await?; + let mut session = spawn_behavior_session().await?; - let activate = session + let result = session .write_stdin_raw_with( "line <- paste(rep('x', 200), collapse = ''); for (i in 1:120) cat(sprintf('line%04d %s\\n', i, line))", Some(30.0), ) .await?; - let activate = wait_until_not_busy(&mut session, activate).await?; - let activate_text = result_text(&activate); - if backend_unavailable(&activate_text) { + let result = wait_until_not_busy(&mut session, result).await?; + let text = result_text(&result); + if backend_unavailable(&text) { eprintln!("write_stdin_behavior backend unavailable in this environment; skipping"); session.cancel().await?; return Ok(()); } + session.cancel().await?; + + assert!( + !text.contains("--More--"), + "did not expect pager footer, got: {text:?}" + ); assert!( - activate_text.contains("--More--"), - "expected pager activation, got: {activate_text:?}" + text.contains("line0120"), + "expected the full output in one reply, got: {text:?}" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn write_stdin_text_slightly_over_inline_budget_stays_inline() -> TestResult<()> { + let _guard = lock_test_mutex(); + let mut session = spawn_behavior_session().await?; + + let input = format!( + "big <- paste(rep('u', {UNDER_HARD_SPILL_TEXT_LEN}), collapse = ''); cat('UNDER_START\\n'); cat(big); cat('\\nUNDER_END\\n')" ); + let result = session.write_stdin_raw_with(&input, Some(30.0)).await?; + let result = wait_until_not_busy(&mut session, result).await?; + let text = result_text(&result); + if backend_unavailable(&text) { + eprintln!("write_stdin_behavior backend unavailable in this environment; skipping"); + session.cancel().await?; + return Ok(()); + } + + session.cancel().await?; - let run_backend = session.write_stdin_raw_with("1+1", Some(10.0)).await?; - let run_backend_text = result_text(&run_backend); assert!( - run_backend_text.contains("[1] 2"), - "expected backend command to run after auto-dismiss, got: {run_backend_text:?}" + text.contains("UNDER_START") && text.contains("UNDER_END"), + "expected full under-threshold text inline, got: {text:?}" + ); + assert!( + bundle_transcript_path(&text).is_none(), + "did not expect transcript path for under-threshold text, got: {text:?}" ); assert!( - !run_backend_text.contains("input blocked while pager is active"), - "did not expect pager block message, got: {run_backend_text:?}" + !text.contains("full output:"), + "did not expect truncation marker for under-threshold text, got: {text:?}" ); - let reactivate = session - .write_stdin_raw_with( - "line <- paste(rep('x', 200), collapse = ''); for (i in 1:120) cat(sprintf('line%04d %s\\n', i, line))", - Some(30.0), - ) - .await?; - let reactivate = wait_until_not_busy(&mut session, reactivate).await?; - let reactivate_text = result_text(&reactivate); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn write_stdin_text_above_hard_spill_threshold_uses_output_bundle_dir() -> TestResult<()> { + let _guard = lock_test_mutex(); + let mut session = spawn_behavior_session().await?; + + let input = format!( + "big <- paste(rep('v', {OVER_HARD_SPILL_TEXT_LEN}), collapse = ''); cat('OVER_START\\n'); cat(big); cat('\\nOVER_END\\n')" + ); + let result = session.write_stdin_raw_with(&input, Some(30.0)).await?; + let result = wait_until_not_busy(&mut session, result).await?; + let text = result_text(&result); + if backend_unavailable(&text) { + eprintln!("write_stdin_behavior backend unavailable in this environment; skipping"); + session.cancel().await?; + return Ok(()); + } + + let transcript_path = bundle_transcript_path(&text).unwrap_or_else(|| { + panic!("expected transcript path in over-threshold reply, got: {text:?}") + }); + let transcript = fs::read_to_string(&transcript_path)?; + + session.cancel().await?; + assert!( - reactivate_text.contains("--More--"), - "expected pager re-activation, got: {reactivate_text:?}" + transcript.contains("OVER_START") && transcript.contains("OVER_END"), + "expected transcript bundle to contain the full over-threshold worker text, got: {transcript:?}" ); - let invalid_pager = session.write_stdin_raw_with(":wat", Some(10.0)).await?; - let invalid_pager_text = result_text(&invalid_pager); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn text_only_oversized_reply_uses_output_bundle_dir() -> TestResult<()> { + let _guard = lock_test_mutex(); + let mut session = spawn_behavior_session().await?; + + let input = "big <- paste(rep('x', 120), collapse = ''); for (i in 1:80) cat(sprintf('mid%03d %s\\n', i, big))"; + let result = session.write_stdin_raw_with(input, Some(30.0)).await?; + let result = wait_until_not_busy(&mut session, result).await?; + let text = result_text(&result); + if backend_unavailable(&text) { + eprintln!("write_stdin_behavior backend unavailable in this environment; skipping"); + session.cancel().await?; + return Ok(()); + } + + let transcript_path = bundle_transcript_path(&text) + .unwrap_or_else(|| panic!("expected transcript path in oversized reply, got: {text:?}")); + let transcript = fs::read_to_string(&transcript_path)?; + let bundle_dir = bundle_root(&transcript_path); + let events_log = bundle_dir.join("events.log"); + let images_dir = bundle_dir.join("images"); + + session.cancel().await?; + assert!( - invalid_pager_text.contains("[pager] unrecognized command: :wat"), - "expected unrecognized pager command message, got: {invalid_pager_text:?}" + transcript.contains("mid080"), + "expected transcript bundle to contain the full worker text, got: {transcript:?}" ); assert!( - invalid_pager_text.contains("--More--"), - "expected pager to remain active after invalid pager command, got: {invalid_pager_text:?}" + !events_log.exists(), + "did not expect events.log for text-only bundle" ); + assert!( + !images_dir.exists(), + "did not expect images dir for text-only bundle" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn timeout_output_bundle_backfills_earlier_worker_text_and_excludes_timeout_marker() +-> TestResult<()> { + let _guard = lock_test_mutex(); + let mut session = spawn_behavior_session().await?; + + let input = "big <- paste(rep('x', 120), collapse = ''); cat('start\\n'); flush.console(); Sys.sleep(0.2); for (i in 1:80) cat(sprintf('mid%03d %s\\n', i, big)); flush.console(); Sys.sleep(0.1); cat('end\\n')"; + let first = session.write_stdin_raw_with(input, Some(0.05)).await?; + let first_text = result_text(&first); + if backend_unavailable(&first_text) { + eprintln!("write_stdin_behavior backend unavailable in this environment; skipping"); + session.cancel().await?; + return Ok(()); + } + assert!(bundle_events_log_path(&first_text).is_none()); + + sleep(Duration::from_millis(260)).await; + let spilled = session.write_stdin_raw_with("", Some(2.0)).await?; + let spilled_text = result_text(&spilled); + if spilled_text.contains("<<repl status: busy") { + eprintln!("write_stdin_behavior spill poll remained busy; skipping"); + session.cancel().await?; + return Ok(()); + } + let transcript_path = bundle_transcript_path(&spilled_text).unwrap_or_else(|| { + panic!("expected transcript path in oversized poll reply, got: {spilled_text:?}") + }); + let file_text = fs::read_to_string(&transcript_path)?; session.cancel().await?; + + assert!( + !file_text.contains("> big <- paste"), + "did not expect echoed input in spill file after pruning, got: {file_text:?}" + ); + assert!( + file_text.contains("start"), + "expected early worker text from timeout reply in spill file, got: {file_text:?}" + ); + assert!( + file_text.contains("mid080"), + "expected oversized poll output in spill file, got: {file_text:?}" + ); + assert!( + file_text.contains("end"), + "expected later worker text in spill file, got: {file_text:?}" + ); + assert!( + !file_text.contains("<<repl status: busy"), + "did not expect timeout marker in spill file, got: {file_text:?}" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn timeout_output_bundle_is_disclosed_only_after_poll_crosses_hard_spill_threshold() +-> TestResult<()> { + let _guard = lock_test_mutex(); + let mut session = spawn_behavior_session().await?; + + // Keep the oversized output comfortably behind the initial 50 ms timeout. + // The worker timeout path polls in 50 ms slices, so a narrower gap can make + // this boundary test flap and disclose the bundle on the first reply. + let input = format!( + "small <- paste(rep('s', {UNDER_HARD_SPILL_TEXT_LEN}), collapse = ''); big <- paste(rep('t', {OVER_HARD_SPILL_TEXT_LEN}), collapse = ''); cat('SMALL_START\\n'); cat(small); cat('\\nSMALL_END\\n'); flush.console(); Sys.sleep(0.5); cat('BIG_START\\n'); cat(big); cat('\\nBIG_END\\n')" + ); + let first = session + .write_stdin_raw_with(&input, Some(test_timeout_secs(0.05, 0.2))) + .await?; + let first_text = result_text(&first); + if backend_unavailable(&first_text) { + eprintln!("write_stdin_behavior backend unavailable in this environment; skipping"); + session.cancel().await?; + return Ok(()); + } + assert!( + bundle_transcript_path(&first_text).is_none(), + "did not expect transcript path before a poll crosses the hard spill threshold, got: {first_text:?}" + ); + sleep(test_delay_ms(600, 900)).await; + let spilled = session.write_stdin_raw_with("", Some(2.0)).await?; + let spilled_text = result_text(&spilled); + if spilled_text.contains("<<repl status: busy") { + eprintln!("write_stdin_behavior spill poll remained busy; skipping"); + session.cancel().await?; + return Ok(()); + } + let transcript_path = bundle_transcript_path(&spilled_text).unwrap_or_else(|| { + panic!("expected transcript path once the poll crossed the hard spill threshold, got: {spilled_text:?}") + }); + let file_text = fs::read_to_string(&transcript_path)?; + + session.cancel().await?; + + assert!( + file_text.contains("SMALL_START") && file_text.contains("SMALL_END"), + "expected transcript file to backfill earlier under-threshold timeout text, got: {file_text:?}" + ); + assert!( + file_text.contains("BIG_START") && file_text.contains("BIG_END"), + "expected transcript file to include the over-threshold poll text, got: {file_text:?}" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn busy_follow_up_reuses_hidden_timeout_bundle_when_it_first_spills() -> TestResult<()> { + let _guard = lock_test_mutex(); + let mut session = spawn_behavior_session().await?; + + let input = format!( + "small <- paste(rep('s', {UNDER_HARD_SPILL_TEXT_LEN}), collapse = ''); big <- paste(rep('t', {OVER_HARD_SPILL_TEXT_LEN}), collapse = ''); cat('SMALL_START\\n'); cat(small); cat('\\nSMALL_END\\n'); flush.console(); Sys.sleep(0.2); cat('BIG_START\\n'); cat(big); cat('\\nBIG_END\\n'); flush.console(); Sys.sleep(1.0); cat('TAIL\\n')" + ); + let first = session.write_stdin_raw_with(&input, Some(0.05)).await?; + let first_text = result_text(&first); + if backend_unavailable(&first_text) { + eprintln!("write_stdin_behavior backend unavailable in this environment; skipping"); + session.cancel().await?; + return Ok(()); + } + assert!( + bundle_transcript_path(&first_text).is_none(), + "did not expect timeout bundle disclosure before the busy follow-up, got: {first_text:?}" + ); + + sleep(test_delay_ms(260, 700)).await; + let busy_follow_up = session.write_stdin_raw_with("1+1", Some(0.1)).await?; + let busy_text = result_text(&busy_follow_up); + if !busy_text.contains("input discarded while worker busy") + && !busy_text.contains("<<repl status: busy") + { + eprintln!("write_stdin_behavior busy follow-up completed without a busy marker; skipping"); + session.cancel().await?; + return Ok(()); + } + let transcript_path = bundle_transcript_path(&busy_text).unwrap_or_else(|| { + panic!("expected busy follow-up spill to disclose a transcript path, got: {busy_text:?}") + }); + let spilled_text = fs::read_to_string(&transcript_path)?; + + assert!( + spilled_text.contains("SMALL_START") && spilled_text.contains("SMALL_END"), + "expected spilled transcript to backfill the earlier timeout text, got: {spilled_text:?}" + ); + assert!( + spilled_text.contains("BIG_START") && spilled_text.contains("BIG_END"), + "expected busy follow-up spill to include the later oversized worker text, got: {spilled_text:?}" + ); + assert!( + !spilled_text.contains("input discarded while worker busy") + && !spilled_text.contains("<<repl status: busy"), + "did not expect busy marker text inside the worker transcript, got: {spilled_text:?}" + ); + + let final_poll = session.write_stdin_raw_with("", Some(0.1)).await?; + let final_poll = wait_until_not_busy(&mut session, final_poll).await?; + let final_text = result_text(&final_poll); + if final_text.contains("<<repl status: busy") { + eprintln!("write_stdin_behavior final poll remained busy; skipping"); + session.cancel().await?; + return Ok(()); + } + let final_transcript = + wait_until_file_contains_via_polls(&mut session, &transcript_path, "TAIL").await?; + + session.cancel().await?; + + assert!( + final_transcript.contains("TAIL"), + "expected the original timeout bundle to receive the final tail text, got: {final_transcript:?}" + ); + assert!( + bundle_transcript_path(&final_text).is_none(), + "did not expect the settled poll to switch to a different transcript path, got: {final_text:?}" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn timeout_spill_file_path_stays_stable_across_later_small_poll() -> TestResult<()> { + let _guard = lock_test_mutex(); + let mut session = spawn_behavior_session().await?; + + let input = "big <- paste(rep('y', 120), collapse = ''); cat('start\\n'); flush.console(); Sys.sleep(0.2); for (i in 1:80) cat(sprintf('mid%03d %s\\n', i, big)); flush.console(); Sys.sleep(0.35); cat('tail\\n')"; + let first = session.write_stdin_raw_with(input, Some(0.05)).await?; + let first_text = result_text(&first); + if backend_unavailable(&first_text) { + eprintln!("write_stdin_behavior backend unavailable in this environment; skipping"); + session.cancel().await?; + return Ok(()); + } + + sleep(Duration::from_millis(260)).await; + let spilled = session.write_stdin_raw_with("", Some(0.1)).await?; + let spilled_text = result_text(&spilled); + let transcript_path = match bundle_transcript_path(&spilled_text) { + Some(path) => path, + None if spilled_text.contains("<<repl status: busy") => { + eprintln!("write_stdin_behavior spill poll remained busy; skipping"); + session.cancel().await?; + return Ok(()); + } + None => { + panic!("expected transcript path in first oversized poll reply, got: {spilled_text:?}") + } + }; + + sleep(Duration::from_millis(450)).await; + let final_poll = session.write_stdin_raw_with("", Some(2.0)).await?; + let final_text = result_text(&final_poll); + if final_text.contains("<<repl status: busy") { + eprintln!("write_stdin_behavior final poll remained busy; skipping"); + session.cancel().await?; + return Ok(()); + } + let file_text = fs::read_to_string(&transcript_path)?; + + session.cancel().await?; + + assert!( + bundle_events_log_path(&final_text).is_none(), + "did not expect bundle path to be repeated on later small poll, got: {final_text:?}" + ); + assert!( + file_text.contains("tail"), + "expected later small poll output to append to existing spill file, got: {file_text:?}" + ); + assert!( + final_text.contains("tail") || final_text.contains("<<repl status: idle>>"), + "expected later small poll to either return inline tail text or settle idle after appending to the existing spill file, got: {final_text:?}" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn timeout_bundle_file_creation_failure_preserves_inline_content() -> TestResult<()> { + let _guard = lock_test_mutex(); + let temp = tempdir()?; + let mut session = + spawn_behavior_session_with_env_vars(output_bundle_temp_env_vars(temp.path())).await?; + + let input = "big <- paste(rep('z', 120), collapse = ''); cat('start\\n'); flush.console(); Sys.sleep(0.2); for (i in 1:80) cat(sprintf('mid%03d %s\\n', i, big)); flush.console(); Sys.sleep(0.1); cat('end\\n')"; + let first = session.write_stdin_raw_with(input, Some(0.05)).await?; + let first_text = result_text(&first); + if backend_unavailable(&first_text) { + eprintln!("write_stdin_behavior backend unavailable in this environment; skipping"); + session.cancel().await?; + return Ok(()); + } + + fs::remove_dir_all(temp.path())?; + + sleep(test_delay_ms(260, 600)).await; + let spilled = session.write_stdin_raw_with("", Some(2.0)).await?; + let spilled_text = result_text(&spilled); + + let follow_up = session.write_stdin_raw_with("1+1", Some(2.0)).await?; + let follow_up_text = result_text(&follow_up); + + session.cancel().await?; + + assert!( + !spilled_text.contains("worker error:"), + "did not expect bundle write failure to surface as a worker error: {spilled_text:?}" + ); + assert!( + bundle_transcript_path(&spilled_text).is_none(), + "did not expect a transcript path after bundle file creation failed: {spilled_text:?}" + ); + assert!( + spilled_text.contains("mid080") && spilled_text.contains("end"), + "expected bundle write failure to fall back to inline worker text, got: {spilled_text:?}" + ); + assert!( + follow_up_text.contains("[1] 2"), + "expected session to stay alive after bundle file creation failed: {follow_up_text:?}" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn hidden_timeout_bundle_is_removed_after_request_finishes_inline() -> TestResult<()> { + let _guard = lock_test_mutex(); + let temp = tempdir()?; + let mut session = + spawn_behavior_session_with_env_vars(output_bundle_temp_env_vars(temp.path())).await?; + + let first = session + .write_stdin_raw_with( + "cat('start\\n'); flush.console(); Sys.sleep(0.2); cat('end\\n')", + Some(0.05), + ) + .await?; + let first_text = result_text(&first); + if backend_unavailable(&first_text) { + eprintln!("write_stdin_behavior backend unavailable in this environment; skipping"); + session.cancel().await?; + return Ok(()); + } + assert!( + bundle_transcript_path(&first_text).is_none(), + "did not expect timeout bundle disclosure on first small timeout reply, got: {first_text:?}" + ); + + assert!( + !has_timeout_bundle_dir(temp.path())?, + "did not expect a hidden timeout bundle directory before disclosure" + ); + + sleep(test_delay_ms(260, 600)).await; + let final_poll = session.write_stdin_raw_with("", Some(2.0)).await?; + let final_text = result_text(&final_poll); + if final_text.contains("<<repl status: busy") { + eprintln!("write_stdin_behavior final inline poll remained busy; skipping"); + session.cancel().await?; + return Ok(()); + } + + session.cancel().await?; + + assert!( + final_text.contains("end"), + "expected final worker output inline, got: {final_text:?}" + ); + assert!( + bundle_transcript_path(&final_text).is_none(), + "did not expect hidden timeout bundle disclosure on final inline poll, got: {final_text:?}" + ); + assert!( + !has_timeout_bundle_dir(temp.path())?, + "did not expect a timeout bundle directory when the request finished inline" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn timeout_bundle_stops_before_ctrl_d_restart_output() -> TestResult<()> { + let _guard = lock_test_mutex(); + let mut session = spawn_behavior_session().await?; + + let input = "big <- paste(rep('q', 120), collapse = ''); cat('start\\n'); flush.console(); Sys.sleep(0.2); for (i in 1:80) cat(sprintf('mid%03d %s\\n', i, big)); flush.console(); Sys.sleep(30); cat('tail\\n')"; + let first = session.write_stdin_raw_with(input, Some(0.05)).await?; + let first_text = result_text(&first); + if backend_unavailable(&first_text) { + eprintln!("write_stdin_behavior backend unavailable in this environment; skipping"); + session.cancel().await?; + return Ok(()); + } + + sleep(Duration::from_millis(260)).await; + let spilled = session.write_stdin_raw_with("", Some(0.1)).await?; + let spilled_text = result_text(&spilled); + let transcript_path = match bundle_transcript_path(&spilled_text) { + Some(path) => path, + None if spilled_text.contains("<<repl status: busy") => { + eprintln!("write_stdin_behavior spill poll remained busy; skipping"); + session.cancel().await?; + return Ok(()); + } + None => { + panic!("expected transcript path in oversized timeout poll, got: {spilled_text:?}") + } + }; + let transcript_before = fs::read_to_string(&transcript_path)?; + + let restart = session + .write_stdin_raw_with("\u{4}print('after reset')", Some(10.0)) + .await?; + let restart_text = result_text(&restart); + if restart_text.contains("<<repl status: busy") { + eprintln!("write_stdin_behavior ctrl-d restart did not complete in time; skipping"); + session.cancel().await?; + return Ok(()); + } + + sleep(Duration::from_millis(100)).await; + let transcript_after = fs::read_to_string(&transcript_path)?; + + session.cancel().await?; + + assert!( + restart_text.contains("after reset"), + "expected restarted session output, got: {restart_text:?}" + ); + assert_eq!( + transcript_after, transcript_before, + "did not expect ctrl-d restart output to append to prior timeout bundle" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn disclosed_timeout_bundle_keeps_appending_after_busy_follow_up() -> TestResult<()> { + let _guard = lock_test_mutex(); + let mut session = spawn_behavior_session().await?; + + let input = format!( + "big <- paste(rep('d', {OVER_HARD_SPILL_TEXT_LEN}), collapse = ''); cat('BIG_START\\n'); cat(big); cat('\\nBIG_END\\n'); flush.console(); Sys.sleep(1.0); cat('TAIL\\n')" + ); + let first = session.write_stdin_raw_with(&input, Some(0.05)).await?; + let first_text = result_text(&first); + if backend_unavailable(&first_text) { + eprintln!("write_stdin_behavior backend unavailable in this environment; skipping"); + session.cancel().await?; + return Ok(()); + } + + sleep(test_delay_ms(260, 700)).await; + let spilled = session.write_stdin_raw_with("", Some(0.1)).await?; + let spilled_text = result_text(&spilled); + let transcript_path = match bundle_transcript_path(&spilled_text) { + Some(path) => path, + None if spilled_text.contains("<<repl status: busy") => { + eprintln!("write_stdin_behavior spill poll remained busy; skipping"); + session.cancel().await?; + return Ok(()); + } + None => { + panic!("expected transcript path in oversized timeout poll, got: {spilled_text:?}") + } + }; + + let busy_follow_up = session.write_stdin_raw_with("1+1", Some(0.1)).await?; + let busy_text = result_text(&busy_follow_up); + if !busy_text.contains("input discarded while worker busy") + && !busy_text.contains("<<repl status: busy") + { + eprintln!("write_stdin_behavior busy follow-up completed without a busy marker; skipping"); + session.cancel().await?; + return Ok(()); + } + + sleep(test_delay_ms(900, 1800)).await; + let final_poll = session.write_stdin_raw_with("", Some(2.0)).await?; + let final_text = result_text(&final_poll); + if final_text.contains("<<repl status: busy") { + eprintln!("write_stdin_behavior final poll remained busy; skipping"); + session.cancel().await?; + return Ok(()); + } + let transcript = fs::read_to_string(&transcript_path)?; + + session.cancel().await?; + + assert!( + transcript.contains("TAIL"), + "expected the disclosed timeout bundle to keep receiving later worker output after a busy follow-up, got: {transcript:?}" + ); + assert!( + bundle_transcript_path(&final_text).is_none(), + "did not expect the settled poll to switch to a different transcript path, got: {final_text:?}" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn disclosed_timeout_bundle_keeps_appending_after_idle_busy_follow_up() -> TestResult<()> { + let _guard = lock_test_mutex(); + let mut session = spawn_behavior_session().await?; + + let input = format!( + "big <- paste(rep('i', {OVER_HARD_SPILL_TEXT_LEN}), collapse = ''); cat('BIG_START\\n'); cat(big); cat('\\nBIG_END\\n'); flush.console(); Sys.sleep(1.5); cat('TAIL\\n')" + ); + let first = session + .write_stdin_raw_with(&input, Some(test_timeout_secs(0.05, 0.2))) + .await?; + let first_text = result_text(&first); + if backend_unavailable(&first_text) { + eprintln!("write_stdin_behavior backend unavailable in this environment; skipping"); + session.cancel().await?; + return Ok(()); + } + + sleep(test_delay_ms(260, 700)).await; + let spilled = session + .write_stdin_raw_with("", Some(test_timeout_secs(0.1, 0.3))) + .await?; + let spilled_text = result_text(&spilled); + let transcript_path = match bundle_transcript_path(&spilled_text) { + Some(path) => path, + None if spilled_text.contains("<<repl status: busy") => { + eprintln!("write_stdin_behavior spill poll remained busy; skipping"); + session.cancel().await?; + return Ok(()); + } + None => { + panic!("expected transcript path in oversized timeout poll, got: {spilled_text:?}") + } + }; + + sleep(test_delay_ms(250, 600)).await; + let busy_follow_up = session + .write_stdin_raw_with("1+1", Some(test_timeout_secs(0.05, 0.2))) + .await?; + let busy_text = result_text(&busy_follow_up); + if !busy_text.contains("input discarded while worker busy") + && !busy_text.contains("<<repl status: busy") + { + eprintln!("write_stdin_behavior busy follow-up completed without a busy marker; skipping"); + session.cancel().await?; + return Ok(()); + } + + sleep(test_delay_ms(1300, 2500)).await; + let final_poll = session.write_stdin_raw_with("", Some(2.0)).await?; + let final_text = result_text(&final_poll); + if final_text.contains("<<repl status: busy") { + eprintln!("write_stdin_behavior final poll remained busy; skipping"); + session.cancel().await?; + return Ok(()); + } + let transcript = fs::read_to_string(&transcript_path)?; + + session.cancel().await?; + + assert!( + transcript.contains("TAIL"), + "expected the disclosed timeout bundle to keep receiving later worker output after a silent busy follow-up, got: {transcript:?}" + ); + assert!( + bundle_transcript_path(&final_text).is_none(), + "did not expect the settled poll to switch to a different transcript path, got: {final_text:?}" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn timeout_bundle_stops_before_fresh_follow_up_output() -> TestResult<()> { + let _guard = lock_test_mutex(); + let mut session = spawn_behavior_session().await?; + + let input = "big <- paste(rep('n', 120), collapse = ''); cat('start\\n'); flush.console(); Sys.sleep(0.2); for (i in 1:80) cat(sprintf('mid%03d %s\\n', i, big)); flush.console(); Sys.sleep(0.2); cat('tail\\n')"; + let first = session.write_stdin_raw_with(input, Some(0.05)).await?; + let first_text = result_text(&first); + if backend_unavailable(&first_text) { + eprintln!("write_stdin_behavior backend unavailable in this environment; skipping"); + session.cancel().await?; + return Ok(()); + } + + sleep(Duration::from_millis(260)).await; + let spilled = session + .write_stdin_raw_unterminated_with("", Some(0.1)) + .await?; + let spilled_text = result_text(&spilled); + let transcript_path = match bundle_transcript_path(&spilled_text) { + Some(path) => path, + None if spilled_text.contains("<<repl status: busy") => { + eprintln!("write_stdin_behavior spill poll remained busy; skipping"); + session.cancel().await?; + return Ok(()); + } + None => { + panic!("expected transcript path in oversized timeout poll, got: {spilled_text:?}") + } + }; + let transcript_before = fs::read_to_string(&transcript_path)?; + sleep(Duration::from_millis(260)).await; + let follow_up = session + .write_stdin_raw_with("cat('NEW_TURN\\n')", Some(2.0)) + .await?; + let follow_up_text = result_text(&follow_up); + if follow_up_text.contains("<<repl status: busy") { + eprintln!("write_stdin_behavior fresh follow-up remained busy; skipping"); + session.cancel().await?; + return Ok(()); + } + let transcript_after = fs::read_to_string(&transcript_path)?; + + session.cancel().await?; + + assert!( + follow_up_text.contains("NEW_TURN"), + "expected fresh follow-up output inline, got: {follow_up_text:?}" + ); + assert!( + !transcript_after.contains("NEW_TURN"), + "did not expect fresh follow-up output to append to prior timeout bundle: {transcript_after:?}" + ); + assert!( + transcript_after.contains(&transcript_before), + "expected original timeout bundle contents to remain intact" + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn output_bundle_prunes_oldest_inactive_bundle_when_count_limit_exceeded() -> TestResult<()> { + let _guard = lock_test_mutex(); + let mut session = spawn_behavior_session_with_env_vars(vec![ + ( + "MCP_REPL_OUTPUT_BUNDLE_MAX_COUNT".to_string(), + "2".to_string(), + ), + ( + "MCP_REPL_OUTPUT_BUNDLE_MAX_BYTES".to_string(), + "1048576".to_string(), + ), + ( + "MCP_REPL_OUTPUT_BUNDLE_MAX_TOTAL_BYTES".to_string(), + "2097152".to_string(), + ), + ]) + .await?; + let mut bundles = Vec::new(); + + for label in ["a", "b", "c"] { + let input = format!( + "big <- paste(rep('{label}', 120), collapse = ''); for (i in 1:80) cat(sprintf('{label}%03d %s\\n', i, big))" + ); + let result = session.write_stdin_raw_with(input, Some(30.0)).await?; + let result = wait_until_not_busy(&mut session, result).await?; + let text = result_text(&result); + if backend_unavailable(&text) { + eprintln!("write_stdin_behavior backend unavailable in this environment; skipping"); + session.cancel().await?; + return Ok(()); + } + let transcript_path = bundle_transcript_path(&text).unwrap_or_else(|| { + panic!("expected transcript path in oversized reply, got: {text:?}") + }); + bundles.push(transcript_path); + } + + assert!( + !bundles[0].exists(), + "expected oldest bundle to be pruned after count cap, still exists: {:?}", + bundles[0] + ); + assert!(bundles[1].exists(), "expected second bundle to remain"); + assert!(bundles[2].exists(), "expected newest bundle to remain"); + + session.cancel().await?; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn output_bundle_reports_omitted_tail_when_bundle_size_cap_is_hit() -> TestResult<()> { + let _guard = lock_test_mutex(); + let mut session = spawn_behavior_session_with_env_vars(vec![ + ( + "MCP_REPL_OUTPUT_BUNDLE_MAX_COUNT".to_string(), + "20".to_string(), + ), + ( + "MCP_REPL_OUTPUT_BUNDLE_MAX_BYTES".to_string(), + "2048".to_string(), + ), + ( + "MCP_REPL_OUTPUT_BUNDLE_MAX_TOTAL_BYTES".to_string(), + "1048576".to_string(), + ), + ]) + .await?; + + let input = "big <- paste(rep('z', 120), collapse = ''); for (i in 1:120) cat(sprintf('z%03d %s\\n', i, big))"; + let result = session.write_stdin_raw_with(input, Some(30.0)).await?; + let result = wait_until_not_busy(&mut session, result).await?; + let text = result_text(&result); + if backend_unavailable(&text) { + eprintln!("write_stdin_behavior backend unavailable in this environment; skipping"); + session.cancel().await?; + return Ok(()); + } + + let transcript_path = bundle_transcript_path(&text).unwrap_or_else(|| { + panic!("expected transcript path in capped oversized reply, got: {text:?}") + }); + let transcript = fs::read_to_string(&transcript_path)?; + let events_log = bundle_root(&transcript_path).join("events.log"); + + session.cancel().await?; + + assert!( + text.contains("later content omitted"), + "expected inline omission notice after bundle cap, got: {text:?}" + ); + assert!( + !events_log.exists(), + "did not expect events.log for text-only capped bundle" + ); + assert!( + !transcript.contains("z120"), + "did not expect capped transcript to contain the omitted tail, got: {transcript:?}" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn output_bundle_prunes_oldest_inactive_bundle_when_total_size_limit_is_hit() -> TestResult<()> +{ + let _guard = lock_test_mutex(); + let mut session = spawn_behavior_session_with_env_vars(vec![ + ( + "MCP_REPL_OUTPUT_BUNDLE_MAX_COUNT".to_string(), + "20".to_string(), + ), + ( + "MCP_REPL_OUTPUT_BUNDLE_MAX_BYTES".to_string(), + "1048576".to_string(), + ), + ( + "MCP_REPL_OUTPUT_BUNDLE_MAX_TOTAL_BYTES".to_string(), + "7000".to_string(), + ), + ]) + .await?; + + let mut bundles = Vec::new(); + for label in ["m", "n"] { + let input = format!( + "big <- paste(rep('{label}', 120), collapse = ''); for (i in 1:45) cat(sprintf('{label}%03d %s\\n', i, big))" + ); + let result = session.write_stdin_raw_with(input, Some(30.0)).await?; + let result = wait_until_not_busy(&mut session, result).await?; + let text = result_text(&result); + if backend_unavailable(&text) { + eprintln!("write_stdin_behavior backend unavailable in this environment; skipping"); + session.cancel().await?; + return Ok(()); + } + let transcript_path = bundle_transcript_path(&text).unwrap_or_else(|| { + panic!("expected transcript path in oversized reply, got: {text:?}") + }); + bundles.push(transcript_path); + } + + assert!( + !bundles[0].exists(), + "expected oldest bundle to be pruned after total-size cap, still exists: {:?}", + bundles[0] + ); + assert!(bundles[1].exists(), "expected newest bundle to remain"); + + session.cancel().await?; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn output_bundle_is_cleaned_up_when_server_exits() -> TestResult<()> { + let _guard = lock_test_mutex(); + let mut session = spawn_behavior_session().await?; + + let input = "big <- paste(rep('q', 120), collapse = ''); for (i in 1:80) cat(sprintf('q%03d %s\\n', i, big))"; + let result = session.write_stdin_raw_with(input, Some(30.0)).await?; + let result = wait_until_not_busy(&mut session, result).await?; + let text = result_text(&result); + if backend_unavailable(&text) { + eprintln!("write_stdin_behavior backend unavailable in this environment; skipping"); + session.cancel().await?; + return Ok(()); + } + let transcript_path = bundle_transcript_path(&text) + .unwrap_or_else(|| panic!("expected transcript path in oversized reply, got: {text:?}")); + + session.cancel().await?; + wait_for_path_to_disappear(&transcript_path).await?; + Ok(()) } diff --git a/tests/write_stdin_edge_cases.rs b/tests/write_stdin_edge_cases.rs index 477904c..f56aaf8 100644 --- a/tests/write_stdin_edge_cases.rs +++ b/tests/write_stdin_edge_cases.rs @@ -76,7 +76,7 @@ async fn write_stdin_timeout_zero_is_non_blocking() -> TestResult<()> { session.cancel().await?; return Ok(()); } - if timeout_text.contains("<<console status: busy") { + if timeout_text.contains("<<repl status: busy") { let completed = session .write_stdin_raw_unterminated_with("", Some(5.0)) .await?; @@ -120,9 +120,9 @@ async fn write_stdin_accepts_crlf_input() -> TestResult<()> { session.cancel().await?; return Ok(()); } - if text.contains("<<console status: busy") { + if text.contains("<<repl status: busy") { let deadline = Instant::now() + Duration::from_secs(30); - while text.contains("<<console status: busy") && Instant::now() < deadline { + while text.contains("<<repl status: busy") && Instant::now() < deadline { let polled = session .write_stdin_raw_unterminated_with("", Some(5.0)) .await?; @@ -187,9 +187,54 @@ async fn write_stdin_empty_returns_prompt() -> TestResult<()> { session.cancel().await?; assert_ne!(result.is_error, Some(true), "empty input should not error"); + assert!( + text.contains("<<repl status: idle>>"), + "expected idle status on empty poll, got: {text:?}" + ); assert!( text.contains(">"), - "expected prompt in output, got: {text:?}" + "expected prompt on empty poll, got: {text:?}" + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn empty_poll_after_completed_request_returns_idle_status_and_prompt() -> TestResult<()> { + let _guard = lock_test_mutex(); + let mut session = common::spawn_server().await?; + + let result = session.write_stdin_raw_with("1+1", Some(10.0)).await?; + let text = result_text(&result); + if backend_unavailable(&text) { + eprintln!("write_stdin_edge_cases backend unavailable in this environment; skipping"); + session.cancel().await?; + return Ok(()); + } + assert!( + text.contains("2"), + "expected evaluation result before idle poll, got: {text:?}" + ); + + let idle = session + .write_stdin_raw_unterminated_with("", Some(1.0)) + .await?; + let idle_text = result_text(&idle); + if backend_unavailable(&idle_text) { + eprintln!("write_stdin_edge_cases backend unavailable in this environment; skipping"); + session.cancel().await?; + return Ok(()); + } + + session.cancel().await?; + + assert_ne!(idle.is_error, Some(true), "empty input should not error"); + assert!( + idle_text.contains("<<repl status: idle>>"), + "expected idle status after completed request, got: {idle_text:?}" + ); + assert!( + idle_text.contains(">"), + "expected prompt after completed request, got: {idle_text:?}" ); Ok(()) }