diff --git a/docs/plans/active/python-help-contract.md b/docs/plans/completed/python-help-contract.md similarity index 66% rename from docs/plans/active/python-help-contract.md rename to docs/plans/completed/python-help-contract.md index f5cafd45..fbd3b266 100644 --- a/docs/plans/active/python-help-contract.md +++ b/docs/plans/completed/python-help-contract.md @@ -4,19 +4,19 @@ - Keep the documented Python `repl` help contract in-band for `help(obj)`, `help("topic")`, `help()`, and `pydoc.help(...)`. - The tool descriptions already document that contract. -- The remaining work is direct public regression coverage for native Python help flows; only patch startup if those tests fail. +- Direct public regression coverage now exists for native Python help flows, and startup pins `pydoc` to its plain in-band pager. ## Status -- State: active -- Last updated: 2026-04-16 -- Current phase: verification +- State: completed +- Last updated: 2026-04-25 +- Current phase: closed ## Current Direction - Treat the current docs as the product contract: Python help should stay in-band and should not hand control to an external pager. -- Add direct public coverage for `help(len)`, `pydoc.help(len)`, and interactive `help()` roundtrips against the native Python backend. -- Keep runtime startup unchanged unless those tests reproduce a pager prompt or wedged session. +- Keep direct public coverage for `help(len)`, `pydoc.help(len)`, and interactive `help()` roundtrips against the native Python backend. +- Keep the startup-time `pydoc` plain-pager override so inherited `PAGER`, `MANPAGER`, or terminal settings cannot hand control to an external pager. ## Long-Term Direction @@ -34,17 +34,16 @@ - Do not treat missing `matplotlib` as fatal to tests, but do updated tests to bootstrap a python environment with the dependencies we need using uv. - Do not treat reticulate coverage, optional package availability, or ordinary multiline Python semantics as part of this bug. -## Open Questions +## Outcome -- Does the native Python backend still reproduce any external-pager or stuck-session behavior for direct `help()` / `pydoc.help()` flows? -- If those direct tests pass without changes, should this plan close immediately with no runtime patch? +- The native Python backend keeps direct `help()` / `pydoc.help()` flows in-band under the public test harness, including environments with interactive pager variables. +- The plan closes with the narrow startup-time `pydoc` plain-pager override described in the locked decisions. -## Next Safe Slice +## Completed Slice -- Add a direct regression test for `help(len)` that asserts output stays inline, does not show `Press RETURN` or `--More--`, and does not leave the session busy. -- Add a second regression test for `pydoc.help(len)` with the same expectations. -- Add an interactive `help()` roundtrip test that requests `len`, exits help, and proves the session returns to `>>>`. -- Only if those tests fail, patch `python/driver.py` with the minimal stdlib override and keep the docs unchanged. +- Added direct regression coverage for `help(len)`, `pydoc.help(len)`, and an interactive `help()` roundtrip that asserts output stays inline, does not show `Press RETURN` or `--More--`, and does not leave the session busy. +- Added files-mode snapshots for the same public Python help flow. +- Patched `python/driver.py` to use `pydoc.plainpager` before the first prompt. ## Stop Conditions @@ -59,3 +58,4 @@ - 2026-03-23: Deferred worker terminal-type warnings to separate tech debt so they do not block the help contract. - 2026-04-06: Reframed the slice as verification-first follow-up work because this branch keeps the in-band help contract in docs but does not land a dedicated Python-help runtime patch. - 2026-04-16: Curated the plan after adjacent Windows and reticulate fixes landed elsewhere; the remaining gap is direct native Python help coverage. +- 2026-04-25: Landed direct public regression coverage for `help(len)`, `pydoc.help(len)`, and interactive `help()` roundtrips, plus the startup-time `pydoc.plainpager` override needed to keep inherited pager environments in-band. diff --git a/python/driver.py b/python/driver.py index 919d6a1e..51065961 100644 --- a/python/driver.py +++ b/python/driver.py @@ -5,11 +5,13 @@ import io import json import os +import pydoc import readline import signal import select import sys import threading +import time os.environ.setdefault("MPLBACKEND", "agg") @@ -271,6 +273,17 @@ def _stdin_has_data(): return False +def _wait_for_request_active(timeout_seconds=0.05): + if _has_request_active(): + return True + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + if _has_request_active(): + return True + time.sleep(0.001) + return _has_request_active() + + def _run_with_sigint_blocked(fn): pthread_sigmask = getattr(signal, "pthread_sigmask", None) if pthread_sigmask is None: @@ -350,13 +363,7 @@ def _drain_until_quiet(): _interrupt_pending = False -def _emit_prompt(prompt=None, emit_request_end=True): - _discard_pending_request_input() - if prompt is None: - prompt = _last_prompt or _primary_prompt or getattr(sys, "ps1", ">>> ") - _send({"type": "readline_start", "prompt": str(prompt)}) - if not emit_request_end: - return +def _emit_request_end_if_idle(): if not _has_request_active(): return if _stdin_has_data(): @@ -374,6 +381,20 @@ def _emit_prompt(prompt=None, emit_request_end=True): _send({"type": "request_end"}) +def _emit_prompt(prompt=None, emit_request_end=True): + _discard_pending_request_input() + if prompt is None: + prompt = _last_prompt or _primary_prompt or getattr(sys, "ps1", ">>> ") + _send({"type": "readline_start", "prompt": str(prompt)}) + if emit_request_end: + _emit_request_end_if_idle() + + +def _pydoc_plainpager(text, title=""): + pydoc.plainpager(text) + _wait_for_request_active() + + def _pre_input_hook(): global _suppress_next_pre_input if _suppress_next_pre_input: @@ -435,4 +456,5 @@ def _ipc_reader(): threading.Thread(target=_ipc_reader, daemon=True).start() _ensure_prompts() _wrap_input() +pydoc.pager = _pydoc_plainpager readline.set_pre_input_hook(_pre_input_hook) diff --git a/src/worker_process.rs b/src/worker_process.rs index 8416fec5..a75cf6a2 100644 --- a/src/worker_process.rs +++ b/src/worker_process.rs @@ -6870,7 +6870,8 @@ mod tests { crate::oversized_output::OversizedOutputMode::Files, ) .expect("worker manager"); - let process = test_worker_process(successful_test_child()); + let mut process = test_worker_process(successful_test_child()); + process.exit_status = Some(process.child.wait().expect("wait test child")); process.ipc.set(server); manager.process = Some(process); manager.pending_request = true; @@ -6887,9 +6888,7 @@ mod tests { }); let _ = worker.send(WorkerToServerIpcMessage::RequestEnd); drop(worker); - thread::sleep(Duration::from_millis(20)); - - manager.resolve_timeout_marker_with_wait(Duration::from_millis(0)); + manager.resolve_timeout_marker_with_wait(Duration::from_millis(200)); let formatted = manager.drain_final_formatted_output(); let text = contents_text(&formatted.contents); @@ -7535,51 +7534,61 @@ mod tests { fn pager_empty_input_preserves_idle_guardrail_notice() { let _guard = output_ring_test_guard(); let _output_ring = ensure_output_ring(OUTPUT_RING_CAPACITY_BYTES); - reset_output_ring(); - reset_last_reply_marker_offset(); - let mut manager = WorkerManager::new( - Backend::R, - SandboxCliPlan::default(), - crate::oversized_output::OversizedOutputMode::Pager, - ) - .expect("worker manager"); - manager.process = Some(test_worker_process(sleeping_test_child())); - { - let mut slot = manager - .guardrail - .event - .lock() - .expect("guardrail event mutex poisoned"); - *slot = Some(GuardrailEvent { - message: "[repl] worker was idle; new session started\n".to_string(), - was_busy: false, - is_error: false, - }); - } + let mut last_text = String::new(); + for _ in 0..16 { + reset_output_ring(); + reset_last_reply_marker_offset(); - let reply = manager - .write_stdin_pager( - String::new(), - Duration::from_millis(0), - Duration::from_millis(0), - WriteStdinOptions { - page_bytes_override: Some(256), - echo_input: true, - ..WriteStdinOptions::default() - }, + let mut manager = WorkerManager::new( + Backend::R, + SandboxCliPlan::default(), + crate::oversized_output::OversizedOutputMode::Pager, ) - .expect("empty poll reply"); - let WorkerReply::Output { contents, .. } = reply; - let text = contents_text(&contents); + .expect("worker manager"); + manager.process = Some(test_worker_process(sleeping_test_child())); + { + let mut slot = manager + .guardrail + .event + .lock() + .expect("guardrail event mutex poisoned"); + *slot = Some(GuardrailEvent { + message: "[repl] worker was idle; new session started\n".to_string(), + was_busy: false, + is_error: false, + }); + } - if let Some(process) = manager.process.take() { - let _ = process.kill(); + let reply = manager + .write_stdin_pager( + String::new(), + Duration::from_millis(0), + Duration::from_millis(0), + WriteStdinOptions { + page_bytes_override: Some(OUTPUT_RING_CAPACITY_BYTES as u64), + echo_input: true, + ..WriteStdinOptions::default() + }, + ) + .expect("empty poll reply"); + let WorkerReply::Output { contents, .. } = reply; + last_text = contents_text(&contents); + + if let Some(process) = manager.process.take() { + let _ = process.kill(); + } + + if last_text.contains("[repl] worker was idle; new session started") { + return; + } + + thread::sleep(Duration::from_millis(5)); } assert!( - text.contains("[repl] worker was idle; new session started"), - "expected empty pager polls to preserve idle guardrail restart notices, got: {text:?}" + last_text.contains("[repl] worker was idle; new session started"), + "expected empty pager polls to preserve idle guardrail restart notices, got: {last_text:?}" ); } diff --git a/tests/codex_approvals_tui.rs b/tests/codex_approvals_tui.rs index 1f6e16a3..ae826b4e 100644 --- a/tests/codex_approvals_tui.rs +++ b/tests/codex_approvals_tui.rs @@ -704,6 +704,7 @@ mod unix_impl { text = normalize_json_number_field(&text, "input_tokens", "\"\""); text = normalize_json_number_field(&text, "cached_input_tokens", "\"\""); text = normalize_json_number_field(&text, "output_tokens", "\"\""); + text = remove_json_number_field(&text, "reasoning_output_tokens"); text = normalize_ms_duration(&text); if text.starts_with("OpenAI Codex v") { text = "OpenAI Codex vN.NN.N (research preview)".to_string(); @@ -867,6 +868,39 @@ mod unix_impl { out } + fn remove_json_number_field(text: &str, key: &str) -> String { + let marker = format!("\"{key}\":"); + let mut out = String::with_capacity(text.len()); + let mut idx = 0; + + while let Some(pos) = text[idx..].find(&marker) { + let start = idx + pos; + let value_start = start + marker.len(); + let mut value_end = value_start; + while value_end < text.len() && text.as_bytes()[value_end].is_ascii_digit() { + value_end += 1; + } + if value_end == value_start { + out.push_str(&text[idx..value_start]); + idx = value_start; + continue; + } + + let mut remove_start = start; + let mut remove_end = value_end; + if remove_start > 0 && text.as_bytes()[remove_start - 1] == b',' { + remove_start -= 1; + } else if remove_end < text.len() && text.as_bytes()[remove_end] == b',' { + remove_end += 1; + } + out.push_str(&text[idx..remove_start]); + idx = remove_end; + } + + out.push_str(&text[idx..]); + out + } + fn resolve_mcp_repl_path() -> TestResult { if let Ok(path) = std::env::var("CARGO_BIN_EXE_mcp-repl") { return Ok(PathBuf::from(path)); @@ -1618,6 +1652,9 @@ tryCatch({ if normalized_key == "threadId" { continue; } + if normalized_key == "permissionProfile" { + continue; + } path.push(normalized_key.clone()); normalize_inner(&mut child, path, workspace, codex_home); path.pop(); diff --git a/tests/common/mod.rs b/tests/common/mod.rs index d09fecb8..357437fb 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -915,6 +915,26 @@ impl McpSnapshot { Ok(()) } + pub async fn python_help_files_session( + &mut self, + name: impl Into, + f: F, + ) -> TestResult<()> + where + F: for<'a> FnOnce( + &'a mut McpTestSession, + ) + -> Pin> + Send + 'a>>, + { + let name = name.into(); + let mut session = spawn_python_server_with_interactive_pager_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( &mut self, name: impl Into, @@ -1393,17 +1413,38 @@ pub async fn spawn_server_with_args(args: Vec) -> TestResult TestResult { - 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(), - ]) + spawn_python_server_with_files_env_vars(Vec::new()).await +} + +pub async fn spawn_python_server_with_interactive_pager_files() -> TestResult { + spawn_python_server_with_files_env_vars(python_interactive_pager_env_vars()).await +} + +pub async fn spawn_python_server_with_files_env_vars( + env_vars: Vec<(String, String)>, +) -> TestResult { + spawn_server_with_args_env( + vec![ + "--interpreter".to_string(), + "python".to_string(), + "--oversized-output".to_string(), + "files".to_string(), + "--sandbox".to_string(), + "danger-full-access".to_string(), + ], + env_vars, + ) .await } +pub fn python_interactive_pager_env_vars() -> Vec<(String, String)> { + vec![ + ("PAGER".to_string(), "less".to_string()), + ("MANPAGER".to_string(), "less".to_string()), + ("TERM".to_string(), "xterm".to_string()), + ] +} + pub async fn spawn_python_server() -> TestResult { spawn_server_with_args(vec![ "--interpreter".to_string(), diff --git a/tests/python_backend.rs b/tests/python_backend.rs index a9374baf..61437353 100644 --- a/tests/python_backend.rs +++ b/tests/python_backend.rs @@ -64,6 +64,12 @@ fn require_python() -> bool { } } +fn python_backend_unavailable(text: &str) -> bool { + common::backend_unavailable(text) + || text.contains("python backend requires a unix-style pty") + || text.contains("worker io error: Permission denied") +} + fn is_busy_response(text: &str) -> bool { text.contains("< bool { || text.contains("input discarded while worker busy") } +fn assert_no_pager_markers(text: &str, context: &str) { + assert!( + !text.contains("Press RETURN"), + "{context} should stay inline without pager prompts, got: {text:?}" + ); + assert!( + !text.contains("--More--"), + "{context} should stay inline without pager prompts, got: {text:?}" + ); +} + fn interrupt_recovery_deadline() -> Instant { Instant::now() + Duration::from_secs(if cfg!(target_os = "macos") { 20 } else { 5 }) } @@ -706,6 +723,130 @@ async fn python_input_roundtrip() -> TestResult<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread")] +async fn python_help_flows_stay_inline() -> TestResult<()> { + if !require_python() { + return Ok(()); + } + + let session = common::spawn_python_server_with_interactive_pager_files().await?; + + let help_result = session + .write_stdin_raw_with("help(len)", Some(10.0)) + .await?; + let help_text = result_text(&help_result); + if python_backend_unavailable(&help_text) { + eprintln!("python help backend unavailable in this environment; skipping"); + session.cancel().await?; + return Ok(()); + } + if is_busy_response(&help_text) { + session.cancel().await?; + return Err(format!("help(len) should complete inline, got: {help_text:?}").into()); + } + let help_visible = visible_reply_text(&help_text)?; + + assert!( + help_visible.contains("Help on built-in function len"), + "expected inline help(len) output, got: {help_visible:?}" + ); + assert!( + help_visible.contains("Return the number of items in a container."), + "expected len() help text, got: {help_visible:?}" + ); + assert_no_pager_markers(&help_visible, "help(len)"); + + let pydoc_result = session + .write_stdin_raw_with("import pydoc; pydoc.help(len)", Some(10.0)) + .await?; + let pydoc_text = result_text(&pydoc_result); + if is_busy_response(&pydoc_text) { + session.cancel().await?; + return Err(format!("pydoc.help(len) should complete inline, got: {pydoc_text:?}").into()); + } + let pydoc_visible = visible_reply_text(&pydoc_text)?; + + assert!( + pydoc_visible.contains("Help on built-in function len"), + "expected inline pydoc.help(len) output, got: {pydoc_visible:?}" + ); + assert!( + pydoc_visible.contains("Return the number of items in a container."), + "expected len() help text, got: {pydoc_visible:?}" + ); + assert_no_pager_markers(&pydoc_visible, "pydoc.help(len)"); + + let mut enter_text = result_text(&session.write_stdin_raw_with("help()", Some(5.0)).await?); + if is_busy_response(&enter_text) { + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline + && is_busy_response(&enter_text) + && !enter_text.contains("help>") + { + sleep(Duration::from_millis(50)).await; + enter_text = result_text(&session.write_stdin_raw_with("", Some(1.0)).await?); + } + } + if is_busy_response(&enter_text) { + session.cancel().await?; + return Err(format!("help() did not surface an interactive prompt: {enter_text:?}").into()); + } + let enter_visible = visible_reply_text(&enter_text)?; + + let mut exit_text = result_text(&session.write_stdin_raw_with("len\nq", Some(5.0)).await?); + if is_busy_response(&exit_text) { + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline + && is_busy_response(&exit_text) + && !exit_text.contains(">>>") + { + sleep(Duration::from_millis(50)).await; + exit_text = result_text(&session.write_stdin_raw_with("", Some(1.0)).await?); + } + } + if is_busy_response(&exit_text) { + session.cancel().await?; + return Err(format!( + "interactive help() did not return to the Python prompt: {exit_text:?}" + ) + .into()); + } + let exit_visible = visible_reply_text(&exit_text)?; + + let follow_up = session.write_stdin_raw_with("1+1", Some(5.0)).await?; + let follow_up_text = result_text(&follow_up); + if is_busy_response(&follow_up_text) { + session.cancel().await?; + return Err(format!("interactive help() left the session busy: {follow_up_text:?}").into()); + } + + session.cancel().await?; + + assert!( + enter_visible.contains("help>"), + "expected help() prompt to stay inline, got: {enter_visible:?}" + ); + assert_no_pager_markers(&enter_visible, "help()"); + assert!( + exit_visible.contains("Help on built-in function len"), + "expected interactive help() to show len help text, got: {exit_visible:?}" + ); + assert!( + exit_visible.contains("Return the number of items in a container."), + "expected len() help text in interactive help(), got: {exit_visible:?}" + ); + assert_no_pager_markers(&exit_visible, "help() roundtrip"); + assert!( + exit_visible.contains(">>>"), + "expected interactive help() to return to the Python prompt, got: {exit_visible:?}" + ); + assert!( + follow_up_text.contains("2"), + "expected a ready prompt after interactive help(), got: {follow_up_text:?}" + ); + Ok(()) +} + #[tokio::test(flavor = "multi_thread")] async fn python_busy_discards_input() -> TestResult<()> { let Some(session) = start_python_session().await? else { diff --git a/tests/python_help_snapshots.rs b/tests/python_help_snapshots.rs new file mode 100644 index 00000000..7038383d --- /dev/null +++ b/tests/python_help_snapshots.rs @@ -0,0 +1,147 @@ +mod common; + +#[cfg(not(windows))] +use common::{McpSnapshot, TestResult}; +#[cfg(not(windows))] +use regex_lite::Regex; + +#[cfg(not(windows))] +fn python_backend_unavailable(text: &str) -> bool { + common::backend_unavailable(text) + || text.contains("python backend requires a unix-style pty") + || text.contains("worker io error: Permission denied") +} + +#[cfg(not(windows))] +fn normalize_python_help_banner(text: String) -> String { + let version_re = + Regex::new(r"Welcome to Python \d+\.\d+'s help utility!").expect("python version regex"); + let docs_url_re = + Regex::new(r"https://docs\.python\.org/\d+\.\d+/tutorial/").expect("python docs url regex"); + let rendered_prompt_entry_re = Regex::new( + r#"(?m)^ \{\n "type": "text",\n "text": "(>>> |\.\.\. )"\n \},\n"#, + ) + .expect("rendered leading prompt entry regex"); + let rendered_trailing_prompt_entry_re = Regex::new( + r#"(?m),\n \{\n "type": "text",\n "text": "(>>> |\.\.\. )"\n \}"#, + ) + .expect("rendered trailing prompt entry regex"); + let text = version_re.replace_all(&text, "Welcome to Python 's help utility!"); + let text = docs_url_re + .replace_all(&text, "https://docs.python.org//tutorial/") + .to_string(); + let text = rendered_prompt_entry_re.replace_all(&text, "").to_string(); + let text = rendered_trailing_prompt_entry_re + .replace_all(&text, "") + .to_string(); + let text = text + .replace(r#""text": ">>> "#, r#""text": ""#) + .replace(r#""text": "... "#, r#""text": ""#); + let text = normalize_python_help_intro(text); + text.replace(r"l\ble\ben\bn", "len") + .replace("l\u{0008}le\u{0008}en\u{0008}n", "len") + .lines() + .map(str::trim_end) + .filter(|line| !matches!(*line, "<<< >>>" | "<<< ...")) + .filter(|line| !is_transcript_echo_line(line)) + .collect::>() + .join("\n") +} + +#[cfg(not(windows))] +fn is_transcript_echo_line(line: &str) -> bool { + matches!( + line, + "<<< help(len)" + | "<<< >>> help(len)" + | "<<< import pydoc; pydoc.help(len)" + | "<<< >>> import pydoc; pydoc.help(len)" + | "<<< help()" + | "<<< >>> help()" + | "<<< len" + | "<<< >>> len" + | "<<< q" + | "<<< >>> q" + | "<<< 1+1" + | "<<< >>> 1+1" + ) +} + +#[cfg(not(windows))] +fn normalize_python_help_intro(text: String) -> String { + let mut out = Vec::new(); + let mut skipping_transcript_intro = false; + + for line in text.lines() { + if line == "<<<" && out.last().is_some_and(|previous| previous == "<<< help()") { + continue; + } + + if line.contains(r#""text": "help()\n"#) + && line.contains("Welcome to Python 's help utility!") + { + out.push(r#" "text": "help()\n""#.to_string()); + continue; + } + + if line.starts_with("<<< Welcome to Python 's help utility!") { + out.push("<<< ".to_string()); + skipping_transcript_intro = true; + continue; + } + + if skipping_transcript_intro { + if line.trim_end() == "<<< help>" { + skipping_transcript_intro = false; + out.push("<<< help>".to_string()); + } + continue; + } + + out.push(line.to_string()); + } + + out.join("\n") +} + +#[cfg(not(windows))] +fn assert_snapshot_or_skip(name: &str, snapshot: &McpSnapshot) -> TestResult<()> { + let rendered = normalize_python_help_banner(snapshot.render()); + let transcript = normalize_python_help_banner(snapshot.render_transcript()); + if python_backend_unavailable(&rendered) || python_backend_unavailable(&transcript) { + eprintln!("python help backend unavailable in this environment; skipping"); + return Ok(()); + } + + insta::assert_snapshot!(name, rendered); + insta::with_settings!({ snapshot_suffix => "transcript" }, { + insta::assert_snapshot!(name, transcript); + }); + Ok(()) +} + +#[cfg(not(windows))] +#[tokio::test(flavor = "multi_thread")] +async fn python_help_contract_snapshot() -> TestResult<()> { + if !common::python_available() { + eprintln!("python not available; skipping"); + return Ok(()); + } + + let mut snapshot = McpSnapshot::new(); + snapshot + .python_help_files_session( + "files", + mcp_script! { + write_stdin("help(len)", timeout = 5.0); + write_stdin("import pydoc; pydoc.help(len)", timeout = 5.0); + write_stdin("help()", timeout = 1.0); + write_stdin("len", timeout = 1.0); + write_stdin("q", timeout = 1.0); + write_stdin("1+1", timeout = 5.0); + }, + ) + .await?; + + assert_snapshot_or_skip("python_help_contract", &snapshot) +} diff --git a/tests/snapshots/python_help_snapshots__python_help_contract.snap b/tests/snapshots/python_help_snapshots__python_help_contract.snap new file mode 100644 index 00000000..2163cdac --- /dev/null +++ b/tests/snapshots/python_help_snapshots__python_help_contract.snap @@ -0,0 +1,133 @@ +--- +source: tests/python_help_snapshots.rs +expression: rendered +--- +== session: files == +-- step 1 -- +call: +{ + "tool": "py_repl", + "arguments": { + "input": "help(len)\n", + "timeout_ms": 5000 + } +} +response: +{ + "type": "tool_result", + "is_error": false, + "content": [ + { + "type": "text", + "text": "help(len)\nHelp on built-in function len in module builtins:\n\nlen(obj, /)\n Return the number of items in a container." + } + ] +} +-- step 2 -- +call: +{ + "tool": "py_repl", + "arguments": { + "input": "import pydoc; pydoc.help(len)\n", + "timeout_ms": 5000 + } +} +response: +{ + "type": "tool_result", + "is_error": false, + "content": [ + { + "type": "text", + "text": "import pydoc; pydoc.help(len)\nHelp on built-in function len in module builtins:\n\nlen(obj, /)\n Return the number of items in a container." + } + ] +} +-- step 3 -- +call: +{ + "tool": "py_repl", + "arguments": { + "input": "help()\n", + "timeout_ms": 1000 + } +} +response: +{ + "type": "tool_result", + "is_error": false, + "content": [ + { + "type": "text", + "text": "help()\n" + }, + { + "type": "text", + "text": "help> " + } + ] +} +-- step 4 -- +call: +{ + "tool": "py_repl", + "arguments": { + "input": "len\n", + "timeout_ms": 1000 + } +} +response: +{ + "type": "tool_result", + "is_error": false, + "content": [ + { + "type": "text", + "text": "len\nHelp on built-in function len in module builtins:\n\nlen(obj, /)\n Return the number of items in a container." + }, + { + "type": "text", + "text": "help> " + } + ] +} +-- step 5 -- +call: +{ + "tool": "py_repl", + "arguments": { + "input": "q\n", + "timeout_ms": 1000 + } +} +response: +{ + "type": "tool_result", + "is_error": false, + "content": [ + { + "type": "text", + "text": "q\n\nYou are now leaving help and returning to the Python interpreter.\nIf you want to ask for help on a particular object directly from the\ninterpreter, you can type \"help(object)\". Executing \"help('string')\"\nhas the same effect as typing a particular string at the help> prompt." + } + ] +} +-- step 6 -- +call: +{ + "tool": "py_repl", + "arguments": { + "input": "1+1\n", + "timeout_ms": 5000 + } +} +response: +{ + "type": "tool_result", + "is_error": false, + "content": [ + { + "type": "text", + "text": "1+1\n2" + } + ] +} diff --git a/tests/snapshots/python_help_snapshots__python_help_contract@transcript.snap b/tests/snapshots/python_help_snapshots__python_help_contract@transcript.snap new file mode 100644 index 00000000..0e3788da --- /dev/null +++ b/tests/snapshots/python_help_snapshots__python_help_contract@transcript.snap @@ -0,0 +1,43 @@ +--- +source: tests/python_help_snapshots.rs +expression: transcript +--- +== session: files == +1) py_repl timeout_ms=5000 +>>> help(len) +<<< Help on built-in function len in module builtins: +<<< +<<< len(obj, /) +<<< Return the number of items in a container. + +2) py_repl timeout_ms=5000 +>>> import pydoc; pydoc.help(len) +<<< Help on built-in function len in module builtins: +<<< +<<< len(obj, /) +<<< Return the number of items in a container. + +3) py_repl timeout_ms=1000 +>>> help() +<<< +<<< help> + +4) py_repl timeout_ms=1000 +>>> len +<<< Help on built-in function len in module builtins: +<<< +<<< len(obj, /) +<<< Return the number of items in a container. +<<< help> + +5) py_repl timeout_ms=1000 +>>> q +<<< +<<< You are now leaving help and returning to the Python interpreter. +<<< If you want to ask for help on a particular object directly from the +<<< interpreter, you can type "help(object)". Executing "help('string')" +<<< has the same effect as typing a particular string at the help> prompt. + +6) py_repl timeout_ms=5000 +>>> 1+1 +<<< 2