From 2f8cfb03a6213948619b233761a9d75e8513149f Mon Sep 17 00:00:00 2001 From: Lukas <103962359+L4XB@users.noreply.github.com> Date: Tue, 27 Oct 2026 05:39:40 +0100 Subject: [PATCH] fix: an empty REPL line no longer quits the chapter silently (#569) Every chapter REPL treated an empty line as a quit command and exited with status 0 without a word, while the banner only promises "Type q to quit". Under VS Code's debugger the Python extension's environment-activation line is often injected into stdin after the program started, so the first thing the REPL read was a blank line and the chapter vanished right after its banner. An empty line is now ignored and the prompt comes back; q and exit still quit, and EOF/Ctrl-C still end the loop. s17 already behaved this way. A static test keeps every chapter's quit check free of the empty string. Fixes #567 --- s01_agent_loop/code.py | 4 ++- s02_tool_use/code.py | 4 ++- s03_permission/code.py | 4 ++- s04_hooks/code.py | 4 ++- s05_todo_write/code.py | 4 ++- s06_subagent/code.py | 4 ++- s07_skill_loading/code.py | 4 ++- s08_context_compact/code.py | 4 ++- s09_memory/code.py | 4 ++- s10_task_system/code.py | 4 ++- s11_background_tasks/code.py | 4 ++- s12_cron_scheduler/code.py | 4 ++- s13_agent_teams/code.py | 6 +++- s14_mcp_plugin/code.py | 4 ++- s15_integrated_harness/code.py | 4 ++- s16_workflow_runtime/code.py | 4 ++- tests/test_repl_quit_commands.py | 57 ++++++++++++++++++++++++++++++++ 17 files changed, 107 insertions(+), 16 deletions(-) create mode 100644 tests/test_repl_quit_commands.py diff --git a/s01_agent_loop/code.py b/s01_agent_loop/code.py index a43d0f2f..2fe63d01 100644 --- a/s01_agent_loop/code.py +++ b/s01_agent_loop/code.py @@ -138,7 +138,9 @@ if __name__ == "__main__": query = input("\001\033[36m\002s01 >> \001\033[0m\002") except (EOFError, KeyboardInterrupt): break - if query.strip().lower() in ("q", "exit", ""): + if not query.strip(): + continue + if query.strip().lower() in ("q", "exit"): break history.append({"role": "user", "content": query}) agent_loop(history) diff --git a/s02_tool_use/code.py b/s02_tool_use/code.py index d2bec3f6..26889947 100644 --- a/s02_tool_use/code.py +++ b/s02_tool_use/code.py @@ -196,7 +196,9 @@ if __name__ == "__main__": query = input("\001\033[36m\002s02 >> \001\033[0m\002") except (EOFError, KeyboardInterrupt): break - if query.strip().lower() in ("q", "exit", ""): + if not query.strip(): + continue + if query.strip().lower() in ("q", "exit"): break history.append({"role": "user", "content": query}) agent_loop(history) diff --git a/s03_permission/code.py b/s03_permission/code.py index 375a2d2b..9ce69f15 100644 --- a/s03_permission/code.py +++ b/s03_permission/code.py @@ -257,7 +257,9 @@ if __name__ == "__main__": query = input("\001\033[36m\002s03 >> \001\033[0m\002") except (EOFError, KeyboardInterrupt): break - if query.strip().lower() in ("q", "exit", ""): + if not query.strip(): + continue + if query.strip().lower() in ("q", "exit"): break history.append({"role": "user", "content": query}) agent_loop(history) diff --git a/s04_hooks/code.py b/s04_hooks/code.py index bf030377..016ed5e3 100644 --- a/s04_hooks/code.py +++ b/s04_hooks/code.py @@ -269,7 +269,9 @@ if __name__ == "__main__": query = input("\001\033[36m\002s04 >> \001\033[0m\002") except (EOFError, KeyboardInterrupt): break - if query.strip().lower() in ("q", "exit", ""): + if not query.strip(): + continue + if query.strip().lower() in ("q", "exit"): break trigger_hooks("UserPromptSubmit", query) history.append({"role": "user", "content": query}) diff --git a/s05_todo_write/code.py b/s05_todo_write/code.py index d5105bd7..09410809 100644 --- a/s05_todo_write/code.py +++ b/s05_todo_write/code.py @@ -357,7 +357,9 @@ if __name__ == "__main__": query = input("\001\033[36m\002s05 >> \001\033[0m\002") except (EOFError, KeyboardInterrupt): break - if query.strip().lower() in ("q", "exit", ""): + if not query.strip(): + continue + if query.strip().lower() in ("q", "exit"): break trigger_hooks("UserPromptSubmit", query) history.append({"role": "user", "content": query}) diff --git a/s06_subagent/code.py b/s06_subagent/code.py index a5e0438a..5beeae90 100644 --- a/s06_subagent/code.py +++ b/s06_subagent/code.py @@ -372,7 +372,9 @@ if __name__ == "__main__": query = input("\001\033[36m\002s06 >> \001\033[0m\002") except (EOFError, KeyboardInterrupt): break - if query.strip().lower() in ("q", "exit", ""): + if not query.strip(): + continue + if query.strip().lower() in ("q", "exit"): break trigger_hooks("UserPromptSubmit", query) history.append({"role": "user", "content": query}) diff --git a/s07_skill_loading/code.py b/s07_skill_loading/code.py index 5f88ebb5..996383b8 100644 --- a/s07_skill_loading/code.py +++ b/s07_skill_loading/code.py @@ -388,7 +388,9 @@ if __name__ == "__main__": query = input("\001\033[36m\002s07 >> \001\033[0m\002") except (EOFError, KeyboardInterrupt): break - if query.strip().lower() in ("q", "exit", ""): + if not query.strip(): + continue + if query.strip().lower() in ("q", "exit"): break trigger_hooks("UserPromptSubmit", query) history.append({"role": "user", "content": query}) diff --git a/s08_context_compact/code.py b/s08_context_compact/code.py index e0a88291..233b84f3 100644 --- a/s08_context_compact/code.py +++ b/s08_context_compact/code.py @@ -591,7 +591,9 @@ if __name__ == "__main__": query = input("\001\033[36m\002s08 >> \001\033[0m\002") except (EOFError, KeyboardInterrupt): break - if query.strip().lower() in ("q", "exit", ""): + if not query.strip(): + continue + if query.strip().lower() in ("q", "exit"): break trigger_hooks("UserPromptSubmit", query) history.append({"role": "user", "content": query}) diff --git a/s09_memory/code.py b/s09_memory/code.py index f9baf803..40e731bc 100644 --- a/s09_memory/code.py +++ b/s09_memory/code.py @@ -773,7 +773,9 @@ if __name__ == "__main__": query = input("\001\033[36m\002s09 >> \001\033[0m\002") except (EOFError, KeyboardInterrupt): break - if query.strip().lower() in ("q", "exit", ""): + if not query.strip(): + continue + if query.strip().lower() in ("q", "exit"): break trigger_hooks("UserPromptSubmit", query) history.append({"role": "user", "content": query}) diff --git a/s10_task_system/code.py b/s10_task_system/code.py index 94328965..8998cf11 100644 --- a/s10_task_system/code.py +++ b/s10_task_system/code.py @@ -589,7 +589,9 @@ if __name__ == "__main__": query = input("\001\033[36m\002s10 >> \001\033[0m\002") except (EOFError, KeyboardInterrupt): break - if query.strip().lower() in ("q", "exit", ""): + if not query.strip(): + continue + if query.strip().lower() in ("q", "exit"): break trigger_hooks("UserPromptSubmit", query) history.append({"role": "user", "content": query}) diff --git a/s11_background_tasks/code.py b/s11_background_tasks/code.py index ee18f042..8492e85d 100644 --- a/s11_background_tasks/code.py +++ b/s11_background_tasks/code.py @@ -531,7 +531,9 @@ if __name__ == "__main__": query = input("\001\033[36m\002s11 >> \001\033[0m\002") except (EOFError, KeyboardInterrupt): break - if query.strip().lower() in ("q", "exit", ""): + if not query.strip(): + continue + if query.strip().lower() in ("q", "exit"): break trigger_hooks("UserPromptSubmit", query) history.append({"role": "user", "content": query}) diff --git a/s12_cron_scheduler/code.py b/s12_cron_scheduler/code.py index 84ca2a7f..27b0bbee 100644 --- a/s12_cron_scheduler/code.py +++ b/s12_cron_scheduler/code.py @@ -782,7 +782,9 @@ if __name__ == "__main__": query = input("\001\033[36m\002s12 >> \001\033[0m\002") except (EOFError, KeyboardInterrupt): break - if query.strip().lower() in ("q", "exit", ""): + if not query.strip(): + continue + if query.strip().lower() in ("q", "exit"): break with agent_lock: run_agent_turn_locked(query) diff --git a/s13_agent_teams/code.py b/s13_agent_teams/code.py index 25ae8432..56f6c30b 100644 --- a/s13_agent_teams/code.py +++ b/s13_agent_teams/code.py @@ -1862,7 +1862,11 @@ if __name__ == "__main__": if kind == "quit": break if kind == "user": - if payload is None or payload.strip().lower() in {"q", "exit", ""}: + if payload is None: + break + if not payload.strip(): + continue + if payload.strip().lower() in {"q", "exit"}: break trigger_hooks("UserPromptSubmit", payload) history.append({"role": "user", "content": payload}) diff --git a/s14_mcp_plugin/code.py b/s14_mcp_plugin/code.py index 3ab12a27..dee196cb 100644 --- a/s14_mcp_plugin/code.py +++ b/s14_mcp_plugin/code.py @@ -536,7 +536,9 @@ if __name__ == "__main__": query = input("s14 >> ") except (EOFError, KeyboardInterrupt): break - if query.strip().lower() in {"q", "exit", ""}: + if not query.strip(): + continue + if query.strip().lower() in {"q", "exit"}: break trigger_hooks("UserPromptSubmit", query) history.append({"role": "user", "content": query}) diff --git a/s15_integrated_harness/code.py b/s15_integrated_harness/code.py index e9549b2d..6078db29 100644 --- a/s15_integrated_harness/code.py +++ b/s15_integrated_harness/code.py @@ -3313,7 +3313,9 @@ if __name__ == "__main__": query = CONSOLE.ask() except (EOFError, KeyboardInterrupt): break - if query.strip().lower() in ("q", "exit", ""): + if not query.strip(): + continue + if query.strip().lower() in ("q", "exit"): break with agent_lock: trigger_hooks("UserPromptSubmit", query) diff --git a/s16_workflow_runtime/code.py b/s16_workflow_runtime/code.py index e3e9e141..257dccb4 100644 --- a/s16_workflow_runtime/code.py +++ b/s16_workflow_runtime/code.py @@ -868,7 +868,9 @@ def run_cli(): query = host.CONSOLE.ask() except (EOFError, KeyboardInterrupt): break - if query.strip().lower() in ("q", "exit", ""): + if not query.strip(): + continue + if query.strip().lower() in ("q", "exit"): break with host.agent_lock: host.trigger_hooks("UserPromptSubmit", query) diff --git a/tests/test_repl_quit_commands.py b/tests/test_repl_quit_commands.py new file mode 100644 index 00000000..0bf499e2 --- /dev/null +++ b/tests/test_repl_quit_commands.py @@ -0,0 +1,57 @@ +"""Every chapter REPL quits only on an explicit command (issue #567). + +An empty line used to be a silent exit with status 0, which contradicted the +"Type q to quit" banner and, combined with VS Code's environment-activation +line being injected into stdin, made a debugged chapter vanish right after its +banner. An empty line is now ignored and the prompt comes back. +""" + +import ast +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +CHAPTER_FILES = sorted(ROOT.glob("s*/code.py")) + + +def _quit_checks(source: str) -> list[ast.Compare]: + """Return every `X.strip().lower() in (...)` / `in {...}` comparison.""" + found = [] + for node in ast.walk(ast.parse(source)): + if not isinstance(node, ast.Compare) or len(node.ops) != 1 or not isinstance(node.ops[0], ast.In): + continue + left = node.left + if ( + isinstance(left, ast.Call) + and isinstance(left.func, ast.Attribute) + and left.func.attr == "lower" + and isinstance(left.func.value, ast.Call) + and isinstance(left.func.value.func, ast.Attribute) + and left.func.value.func.attr == "strip" + ): + found.append(node) + return found + + +def test_every_chapter_has_a_repl_quit_check(): + assert len(CHAPTER_FILES) >= 17 + for path in CHAPTER_FILES: + assert _quit_checks(path.read_text(encoding="utf-8")), f"{path}: no quit command check found" + + +def test_empty_line_is_never_a_quit_command(): + for path in CHAPTER_FILES: + for check in _quit_checks(path.read_text(encoding="utf-8")): + container = check.comparators[0] + assert isinstance(container, (ast.Tuple, ast.Set)), f"{path}:{check.lineno}" + values = [c.value for c in container.elts if isinstance(c, ast.Constant)] + assert "" not in values, f"{path}:{check.lineno}: empty input still quits the REPL" + assert "q" in values, f"{path}:{check.lineno}: q must still quit" + + +def test_blank_input_is_skipped_before_the_quit_check(): + pattern = re.compile(r"if not (\w+)\.strip\(\):\n\s+continue\n\s+if \1\.strip\(\)\.lower\(\) in", re.M) + for path in CHAPTER_FILES: + if path.parent.name.startswith("s17"): + continue # s17 already handled blank input before this change + assert pattern.search(path.read_text(encoding="utf-8")), f"{path}: blank input is not skipped"