From d57d872ecebe2c6b8e8466b10cbf54ccd4df3134 Mon Sep 17 00:00:00 2001 From: nasser1941 <9349840+nasser1941@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:05:03 +0200 Subject: [PATCH 1/7] Look at a window before calling it done: screenshot actions, unseen-window check, steady repeats From a real session that 'fixed' a double-click feature in a PyQt6 camera wall four times, checking only imports and unrelated tests, while the user saw a blank window each time. The cause was printed by Qt (setLayout on a widget that already had a layout), and only showed after a double-click. - screenshot actions (Python Qt programs): click / double_click / right_click a point or a widget by its text, key (with modifiers), type, wait, run by the Qt hook before the picture; the result says what each step hit. - A request that changed a Python file importing a GUI toolkit, with no screenshot or view_image, is sent back once before the answer counts. - The repeat check ignores timings and clock times, so the same test run four times ('75 passed in 0.14s', '0.12s') counts as a repeat. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 20 ++++ docs/how-it-works.md | 3 +- docs/usage.md | 20 ++++ src/lcode/agent.py | 17 +++ src/lcode/checks.py | 49 +++++++++ src/lcode/repeats.py | 13 ++- src/lcode/screenshots.py | 223 +++++++++++++++++++++++++++++++++++++-- src/lcode/tools.py | 16 ++- tests/test_look.py | 214 +++++++++++++++++++++++++++++++++++++ 9 files changed, 561 insertions(+), 14 deletions(-) create mode 100644 tests/test_look.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 421df41..a554dcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,26 @@ All notable changes to lcode are documented here. The format follows ## [Unreleased] +### Added + +- The screenshot tool can click first. For a Python Qt program, `actions` run before the picture, + the way a user would: `double_click Camera 1` (a widget by its text) or `double_click 300,200`, + `click`, `right_click`, `key Escape`, `key ctrl+s`, `type …`, `wait 2`. The result says what each + step hit. Bugs that only show after a click were out of reach before. + +### Fixed + +From a real session that "fixed" a double-click feature four times without once looking at the +window, while the user saw a blank screen each time: + +- **Windows it never looked at** are sent back once before the answer counts. When a request + changed a Python file that draws a window (PyQt, PySide, tkinter, Kivy, wx, GTK) and the model + never took a screenshot or looked at an image, it's told to look (with clicks first, if that's + where the change shows) or to say plainly that it's unchecked. The real cause was in what Qt + printed: `setLayout: … which already has a layout`. +- **A rerun that only took a different time** counts as a repeat: the model ran the same tests four + times in a row (`75 passed in 0.14s`, then `0.12s`…) without the repeat check noticing. + ## [0.37.0] - 2026-10-20 ### Added diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 5471a09..b1e8c12 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -56,7 +56,8 @@ same tool, the same arguments and the same result. `loop`. A result that changes isn't a repeat, so a test that's run again after a fix, or a background job -that's checked while it runs, never adds up. A call that may have changed something, like an edit or +that's checked while it runs, never adds up. Timings don't count as a change: `75 passed in 0.12s` +and then `in 0.14s` is the same result. A call that may have changed something, like an edit or a shell command that isn't read-only, starts the count again for the other calls: checking the build after each edit is fine. Change the threshold with `lcode config set repeat_limit 5`, or turn it off with `0` (that also turns off the checks for unreachable sites and for thinking in circles, below). diff --git a/docs/usage.md b/docs/usage.md index 206ab03..1fb2834 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -183,6 +183,13 @@ command = "uv run pytest -q && uv run ruff check ." Or set it for yourself with `lcode config set checks "make check"`; `lcode config set checks off` turns checks off. +**Windows it never looked at.** A model that changes a window checks it with an import and the +project's tests, which don't show what's on the screen, and calls it done: you find a blank +window. When a request changed a Python file that draws one (it imports PyQt, PySide, tkinter, +Kivy, wx or GTK) and the model never took a [screenshot](#screenshots) or looked at an image, +lcode sends it back once: look at it, with clicks first if that's where the change shows, or say +plainly that it's unchecked. `checks = "off"` turns this off. + **Scripts it never ran.** A model that writes a build or install script tends to call it done without running it once, and the first thing you see is its first error. When a request wrote a shell script (a `.sh` file, or a file with a shell shebang) and no command in it ran the script, @@ -427,6 +434,19 @@ a screen and sees what you would, measured: - For a web app, the model gives the command that starts the server and the page's URL: lcode starts the server, waits for the page to answer, takes the shot and stops the server. +- **Clicks and keys first.** For something that only shows after a click, a Python Qt program can + take `actions` before the picture, the way you would: `double_click 300,200` (a point in the + window), `double_click Camera 1` (the widget showing that text), `click Save`, `right_click …`, + `key Escape`, `key ctrl+s`, `type some text`, `wait 2`. The result says what each step hit, and + what the program printed, where Qt names layout mistakes: + + ```text + ● screenshot(python camera_wall.py) actions: double_click Camera 1 + Steps, before the picture: + - double_click Camera 1: on QLabel "Camera 1 — Connected" at (207, 317) + What the program printed: + QWidget::setLayout: Attempting to set QLayout "" on FocusedCamera "", which already has a layout + ``` - A model that can see describes the picture with the model's question in mind (see [Which model looks](#images)). Without one, the measurements still come back. - Shots are saved in `.lcode/screenshots/` in the project, which keeps itself out of git, so you diff --git a/src/lcode/agent.py b/src/lcode/agent.py index 0191e3b..59f1358 100644 --- a/src/lcode/agent.py +++ b/src/lcode/agent.py @@ -961,6 +961,12 @@ def unrun_scripts(self, commands: list[str]) -> list[str]: return [] return checks.unrun_scripts(project_root_or_cwd(self.cwd), list(self.tools.before), commands) + def unseen_windows(self, tools_used: set[str]) -> dict[str, str]: + """Window code the model changed in this request and never looked at (only where the checks would run).""" + if self.allowed_tools is not None or self.planning() or self.no_changes or self.settings.checks == "off": + return {} + return checks.unseen_windows(project_root_or_cwd(self.cwd), list(self.tools.before), tools_used) + def project_check(self) -> checks.Check | None: """The project's check command, for the main agent outside plan mode and reviews (None otherwise).""" if self.allowed_tools is not None or self.planning() or self.no_changes or self.settings.checks == "off": @@ -1164,6 +1170,8 @@ def _run_turn(self, user_text: str) -> None: stalls = 0 # replies that went round in circles or ended without a word commands: list[str] = [] # shell commands run in this request scripts_noted = False # the model was told about scripts it wrote and never ran + tools_used: set[str] = set() # tools called in this request + windows_noted = False # the model was told about windows it changed and never looked at for step in range(self.max_steps): if self.cancel is not None and self.cancel.is_set(): raise KeyboardInterrupt @@ -1253,6 +1261,14 @@ def _run_turn(self, user_text: str) -> None: self.console.print(Text(f" ⎿ never ran {', '.join(unrun)}: asking the model to", style="yellow")) self.messages.append({"role": "user", "content": checks.unrun_note(unrun)}) continue + unseen = {} if windows_noted else self.unseen_windows(tools_used) + if unseen: + windows_noted = True + self.console.print( + Text(f" ⎿ never looked at {', '.join(unseen)}: asking the model to", style="yellow") + ) + self.messages.append({"role": "user", "content": checks.unseen_note(unseen)}) + continue check = self.project_check() if changed and not verified else None if check is not None and check_rounds < checks.MAX_ROUNDS: check_rounds += 1 @@ -1345,6 +1361,7 @@ def _run_turn(self, user_text: str) -> None: ) if out_of_reach.stop: blocked = out_of_reach + tools_used.add(name) if name == "bash": commands.append(str(args.get("command", ""))) if name in ("edit_file", "write_file", "apply_patch") and not result.startswith(REFUSED): diff --git a/src/lcode/checks.py b/src/lcode/checks.py index 54a5207..708ec7b 100644 --- a/src/lcode/checks.py +++ b/src/lcode/checks.py @@ -229,3 +229,52 @@ def unrun_note(scripts: list[str]) -> str: f"deploy, delete data) or needs something you don't have, don't: tell the user plainly that {it} is untested " "and why." ) + + +# ----------------------------------------------------------------------------- windows it never looked at + +GUI_IMPORT = re.compile(r"^\s*(?:from|import)\s+(PyQt[56]|PySide[26]|tkinter|kivy|wx|gi\.repository)\b", re.M) +LOOKED = frozenset({"screenshot", "view_image"}) + + +def unseen_windows(root: Path, changed: list[str], tools_used: set[str]) -> dict[str, str]: + """Python files that draw a window (they import a GUI toolkit), changed in this request when the model never + looked at one: file -> toolkit. + + A small model checks a window change by importing the module and running unrelated tests, then calls it done; + the user finds a blank window. Tests and imports don't show what's on the screen; a screenshot does. + """ + if tools_used & LOOKED: + return {} + found = {} + for rel in changed: + path = root / rel + if path.suffix != ".py" or not path.is_file(): + continue + try: + text = path.read_text(errors="replace")[:200_000] + except OSError: + continue + match = GUI_IMPORT.search(text) + if match: + found[rel] = match.group(1) + return found + + +def unseen_note(found: dict[str, str]) -> str: + names = ", ".join(found) + kits = sorted(set(found.values())) + qt = any(kit.startswith(("PyQt", "PySide")) for kit in kits) + how = ( + "take a screenshot with the screenshot tool: it runs the program without a screen and shows what it draws, " + "and what the program printed (Qt names layout mistakes there). For something that only shows after a click " + 'or a key, give actions, e.g. actions=["double_click Camera 1"]' + if qt + else "take a screenshot with the screenshot tool: it runs the program on a virtual display and shows what it " + "draws" + ) + return ( + f"[lcode] You changed {names}, which draws a window ({', '.join(kits)}), but never looked at it: tests and " + f"imports don't show what's on the screen. Before you say it works, {how}. Check the picture against what the " + "user asked for. If the program can't run here, tell the user plainly that you haven't seen it working." + ) diff --git a/src/lcode/repeats.py b/src/lcode/repeats.py index fb95a17..e5ae0d8 100644 --- a/src/lcode/repeats.py +++ b/src/lcode/repeats.py @@ -6,7 +6,8 @@ failed: one sooner) the result gets a note that tells the model so, and if it still goes on, lcode ends the request. -Results that change aren't repeats: a test run again after a fix, a background job read while it runs. And +Results that change aren't repeats: a test run again after a fix, a background job read while it runs. Timings +don't count as a change, though: `75 passed in 0.12s` and then `in 0.14s` is the same result. And a call that may have changed something (a file edit, a shell command that isn't read-only) starts the count again for every other call, so checking a build after each edit never adds up. Its own repeats still count: running the same failing command over and over is a loop. @@ -23,6 +24,9 @@ from lcode.permissions import is_read_only STOP_AFTER_NOTE = 2 # repeats after the note before the request ends +TIMING = re.compile( + r"\b\d+(?:\.\d+)?\s?(?:ms|µs|us|s|secs?|seconds?|mins?|minutes?)\b|\b\d{1,2}:\d{2}:\d{2}(?:[.,]\d+)?\b", re.I +) FAILED = ("Error:", "A deny rule", "The user denied", "This needs the user's permission") UNCHANGING = frozenset( { @@ -64,7 +68,7 @@ def check(self, name: str, args: dict, result: str) -> Verdict: """Count this call and its result.""" if self.limit <= 0: return Verdict(1) - key = hashlib.sha256(json.dumps([name, args, result], sort_keys=True, default=str).encode()).hexdigest() + key = hashlib.sha256(json.dumps([name, args, steady(result)], sort_keys=True, default=str).encode()).hexdigest() count = self.seen.get(key, 0) + 1 if changes_things(name, args, result): self.seen.clear() @@ -95,6 +99,11 @@ def check(self, name: str, args: dict, result: str) -> Verdict: ) +def steady(result: str) -> str: + """The result without its timings and clock times, which change on every run of the same command.""" + return TIMING.sub("#", result) + + def changes_things(name: str, args: dict, result: str) -> bool: """Whether the call may have changed files (or anything else a later call could see).""" if name in UNCHANGING: diff --git a/src/lcode/screenshots.py b/src/lcode/screenshots.py index 6a4e8f9..520ae17 100644 --- a/src/lcode/screenshots.py +++ b/src/lcode/screenshots.py @@ -6,7 +6,9 @@ - A Python Qt program (PyQt5/6, PySide2/6) runs on Qt's "offscreen" platform, with a screen of the asked size. A small startup hook (a sitecustomize module on PYTHONPATH) waits for the program's event loop, then grabs its - windows, measures them and their widgets, and quits it. + windows, measures them and their widgets, and quits it. First it can take `actions`, the way a user would: click + or double-click a point or a widget by its text, press a key, type, wait. Bugs that only show after a click need + that, and Qt's warnings (in what the program printed) often name the cause. - Other desktop programs run on a virtual X display (Xvfb, Linux), captured with ImageMagick's `import` or `xwd`. The Qt hook still measures a Python Qt program there. - A web page, or a local server started with `command`, opens in headless Chrome (or Chromium, Edge), driven @@ -43,6 +45,14 @@ DEFAULT_SCREEN = (1920, 1080) DEFAULT_WAIT = 3 # seconds for a program or page to start and draw MAX_WAIT = 60 +MAX_ACTIONS = 20 +ACTION_PAUSE = 0.4 # seconds after each action, for the program to react and redraw +MOUSE_ACTIONS = ("click", "double_click", "right_click") +KEY_NAMES = {"enter": "Return", "return": "Return", "esc": "Escape", "escape": "Escape", "space": "Space", + "tab": "Tab", "backspace": "Backspace", "delete": "Delete", "del": "Delete", "home": "Home", "end": "End", + "pageup": "PageUp", "pagedown": "PageDown", "up": "Up", "down": "Down", "left": "Left", + "right": "Right"} # fmt: skip +MODIFIERS = ("ctrl", "shift", "alt", "meta") SERVER_TIMEOUT = 60 # seconds for a server started with `command` to answer at `url` GRACE = 25 # seconds beyond `wait` before a program that never showed a window is stopped QT_BINDINGS = ("PyQt6", "PyQt5", "PySide6", "PySide2") @@ -169,15 +179,19 @@ def _lcode_shot(): out = _os.environ.get("LCODE_SHOT_OUT") wait_ms = int(float(_os.environ.get("LCODE_SHOT_WAIT", "3")) * 1000) quit_after = _os.environ.get("LCODE_SHOT_QUIT", "1") == "1" + actions = json.loads(_os.environ.get("LCODE_SHOT_ACTIONS") or "[]") + pause_ms = int(float(_os.environ.get("LCODE_SHOT_PAUSE", "0.4")) * 1000) bindings = ("PyQt6", "PyQt5", "PySide6", "PySide2") state = {"scheduled": False} - def capture(binding): + def capture(binding, steps=None): QtCore = _sys.modules[binding + ".QtCore"] QtGui = _sys.modules[binding + ".QtGui"] QtWidgets = _sys.modules.get(binding + ".QtWidgets") app = QtGui.QGuiApplication.instance() result = {"binding": binding, "windows": [], "screen": None, "platform": app.platformName()} + if steps is not None: + result["actions"] = steps screen = app.primaryScreen() if screen is not None: g, a = screen.geometry(), screen.availableGeometry() @@ -257,6 +271,100 @@ def capture(binding): app.quit() QtCore.QTimer.singleShot(3000, lambda: _os._exit(0)) + def text_of(widget): + for getter in ("text", "windowTitle", "title", "placeholderText"): + fn = getattr(widget, getter, None) + if callable(fn): + try: + text = str(fn() or "") + except Exception: + text = "" + if text: + return text + return "" + + def act(binding, then): + """Take the steps one by one, pausing after each so the program reacts, then call then(log).""" + QtCore = _sys.modules[binding + ".QtCore"] + QtWidgets = _sys.modules[binding + ".QtWidgets"] + Qt = QtCore.Qt + try: + QTest = importlib.import_module(binding + ".QtTest").QTest + except Exception as e: + then([{"step": s["raw"], "error": "Qt's QtTest module isn't available (%s)" % e} for s in actions]) + return + app = QtWidgets.QApplication.instance() + + def enum(group, name): # scoped enums (PyQt6, PySide6), or flat ones (PyQt5, PySide2) + scope = getattr(Qt, group, None) + if scope is not None and hasattr(scope, name): + return getattr(scope, name) + return getattr(Qt, name) + + def window(): + tops = [w for w in app.topLevelWidgets() if w.isVisible() and w.width() > 1 and w.height() > 1] + active = app.activeWindow() + return active if active in tops else (tops[0] if tops else None) + + def describe(widget): + text = text_of(widget)[:40] + return type(widget).__name__ + (' "%s"' % text if text else "") + + def by_text(top, wanted): + wanted = wanted.casefold() + found = [c for c in top.findChildren(QtWidgets.QWidget) + if c.isVisible() and wanted in text_of(c).casefold()] # fmt: skip + return min(found, key=lambda c: c.width() * c.height()) if found else None + + log = [] + + def step(i): + if i >= len(actions): + then(log) + return + s, note, pause = actions[i], {"step": actions[i]["raw"]}, pause_ms + try: + top = window() + if s["do"] == "wait": + pause = int(s["seconds"] * 1000) + elif top is None: + note["error"] = "no window to act on" + elif s["do"] in ("click", "double_click", "right_click"): + if "text" in s: + target = by_text(top, s["text"]) + pos = target.rect().center() if target is not None else None + else: + point = QtCore.QPoint(s["x"], s["y"]) + target = top.childAt(point) or top + pos = target.mapFrom(top, point) + if target is None: + note["error"] = 'no visible widget shows "%s"' % s["text"] + else: + button = enum("MouseButton", "RightButton" if s["do"] == "right_click" else "LeftButton") + none = enum("KeyboardModifier", "NoModifier") + at = target.mapTo(top, pos) # before the click moves things around + note["target"] = "%s at (%d, %d)" % (describe(target), at.x(), at.y()) + click = QTest.mouseDClick if s["do"] == "double_click" else QTest.mouseClick + click(target, button, none, pos) + else: + target = app.focusWidget() or top + if s["do"] == "key": + mods = enum("KeyboardModifier", "NoModifier") + names = {"ctrl": "ControlModifier", "shift": "ShiftModifier", "alt": "AltModifier", + "meta": "MetaModifier"} # fmt: skip + for m in s["mods"]: + mods = mods | enum("KeyboardModifier", names[m]) + QTest.keyClick(target, enum("Key", "Key_" + s["key"]), mods) + else: + QTest.keyClicks(target, s["text"]) + note["target"] = describe(target) + except Exception as e: # one step failing doesn't stop the picture + note["error"] = "%s: %s" % (type(e).__name__, e) + log.append(note) + QtCore.QTimer.singleShot(pause, lambda: step(i + 1)) + + step(0) + def patch(binding): """Wrap exec() of the application classes loaded so far (QtWidgets may come after QtGui).""" QtCore = _sys.modules.get(binding + ".QtCore") @@ -275,14 +383,23 @@ def schedule(): state["scheduled"] = True open(_os.path.join(out, "started"), "w").close() # the event loop runs: a capture is coming - def run(): + def run(steps=None): try: - capture(binding) + capture(binding, steps) except Exception as e: # the program goes on; lcode reports what went wrong with open(_os.path.join(out, "qt.json"), "w") as f: json.dump({"error": "%s: %s" % (type(e).__name__, e)}, f) - QtCore.QTimer.singleShot(wait_ms, run) + def start(): + if not actions: + run() + return + try: + act(binding, run) + except Exception as e: + run([{"step": "actions", "error": "%s: %s" % (type(e).__name__, e)}]) + + QtCore.QTimer.singleShot(wait_ms, start) class Wrapped: """In place of exec(): schedules the capture, then runs the original, static or not.""" @@ -372,6 +489,69 @@ def hook_dir(tmp: Path) -> Path: # --------------------------------------------------------------------------------------------- running a program +def parse_actions(actions) -> list[dict]: + """The steps to take before the picture, from the tool's `actions` (a list, or one string split on ; and lines). + + `click X,Y`, `double_click X,Y`, `right_click X,Y` (a point in the window), or the same with a widget's text + (`double_click "Camera 1"`); `key Escape` or `key ctrl+s`; `type some text`; `wait 2` (seconds). + """ + if not actions: + return [] + if isinstance(actions, str): + actions = [part for line in actions.splitlines() for part in line.split(";")] + steps = [] + for raw in actions: + raw = str(raw).strip() + if not raw: + continue + verb, _, rest = raw.partition(" ") + verb, rest = verb.lower().replace("-", "_"), rest.strip() + if verb == "doubleclick": + verb = "double_click" + step: dict = {"do": verb, "raw": raw} + if verb in MOUSE_ACTIONS: + point = re.fullmatch(r"\(?\s*(-?\d+)\s*[, ]\s*(-?\d+)\s*\)?", rest) + if point: + step["x"], step["y"] = int(point.group(1)), int(point.group(2)) + elif rest.strip("\"'"): + step["text"] = rest.strip("\"'") + else: + raise ShotError(f"{raw!r}: say where, as X,Y in the window or the widget's text, e.g. {verb} 200,150") + elif verb == "key": + parts = [p for p in re.split(r"[+\s]+", rest.lower()) if p] + if not parts: + raise ShotError(f"{raw!r}: name the key, e.g. key Escape or key ctrl+s") + *mods, key = parts + if any(m not in MODIFIERS for m in mods): + raise ShotError(f"{raw!r}: modifiers are {', '.join(MODIFIERS)}") + if key in KEY_NAMES: + key = KEY_NAMES[key] + elif re.fullmatch(r"f([1-9]|1[0-2])", key) or (len(key) == 1 and key.isalnum()): + key = key.upper() + else: + raise ShotError(f"{raw!r}: unknown key {key!r} (Escape, Return, Tab, Space, arrows, F1-F12, a letter)") + step["key"], step["mods"] = key, mods + elif verb == "type": + if not rest: + raise ShotError(f"{raw!r}: give the text to type") + step["text"] = rest + elif verb == "wait": + try: + step["seconds"] = max(0.0, min(float(rest or 1), 10.0)) + except ValueError as e: + raise ShotError(f"{raw!r}: wait takes seconds, e.g. wait 2") from e + else: + raise ShotError(f"{raw!r}: actions are click, double_click, right_click, key, type and wait") + steps.append(step) + if len(steps) > MAX_ACTIONS: + raise ShotError(f"at most {MAX_ACTIONS} actions in one screenshot") + return steps + + +def actions_time(steps: list[dict]) -> float: + return sum(s.get("seconds", 0) for s in steps) + ACTION_PAUSE * len(steps) + + def run_program(command: str, cwd: Path, env: dict[str, str], log: Path) -> subprocess.Popen: out = log.open("wb") try: @@ -423,7 +603,9 @@ def base_env(hook: Path) -> dict[str, str]: return env -def qt_offscreen(command: str, cwd: Path, screen: tuple[int, int], wait: int, tmp: Path, cancel=None) -> Shot: +def qt_offscreen( + command: str, cwd: Path, screen: tuple[int, int], wait: int, tmp: Path, cancel=None, actions: list | None = None +) -> Shot: """Run a Python Qt program on Qt's offscreen platform and capture its windows.""" config = tmp / "screens.json" w, h = screen @@ -453,12 +635,14 @@ def qt_offscreen(command: str, cwd: Path, screen: tuple[int, int], wait: int, tm LCODE_SHOT_OUT=str(out), LCODE_SHOT_WAIT=str(wait), LCODE_SHOT_QUIT="1", + LCODE_SHOT_ACTIONS=json.dumps(actions or []), + LCODE_SHOT_PAUSE=str(ACTION_PAUSE), ) env.pop("DISPLAY", None) log = tmp / "output.txt" proc = run_program(command, cwd, env, log) try: - deadline = time.time() + wait + GRACE + deadline = time.time() + wait + actions_time(actions or []) + GRACE while time.time() < deadline and proc.poll() is None and not (out / "qt.json").exists(): if cancel is not None and cancel(): raise KeyboardInterrupt @@ -509,8 +693,17 @@ def qt_report(data: dict) -> list[str]: screen = data.get("screen") or [0, 0] sw, sh = screen windows = data.get("windows") or [] + if data.get("actions"): + lines.append("Steps, before the picture:") + for note in data["actions"]: + done = ( + f"couldn't: {note['error']}" + if note.get("error") + else (f"on {note['target']}" if note.get("target") else "done") + ) + lines.append(f"- {note['step']}: {done}") if not windows: - return ["No visible Qt window."] + return [*lines, "No visible Qt window."] for win in windows: title = f'"{win["title"]}" ' if win.get("title") else "" x, y, w, h = win["x"], win["y"], win["width"], win["height"] @@ -1059,12 +1252,15 @@ def take( full_page: bool = False, mode: str = "auto", cancel=None, + actions: list | None = None, ) -> Shot: - """Take the screenshot. `cancel` is checked while waiting (return True to stop).""" + """Take the screenshot. `cancel` is checked while waiting (return True to stop); `actions` come first.""" wait = max(0, min(int(wait), MAX_WAIT)) path = new_path(folder, label_for(command, url)) with tempfile.TemporaryDirectory(prefix="lcode-shot-") as name: tmp = Path(name) + if actions and url: + raise ShotError("actions work for Python Qt programs (PyQt, PySide), not web pages yet") if url: url = normalize_url(url, cwd) server = None @@ -1086,7 +1282,14 @@ def take( raise ShotError("give a command that starts the program, or a url") if mode == "auto": mode = "qt" if looks_like_qt(command, cwd) or not xvfb_available() else "xvfb" - shot = (qt_offscreen if mode == "qt" else virtual_display)(command, cwd, screen, wait, tmp, cancel) + if actions and mode != "qt": + raise ShotError( + "actions work for Python Qt programs (PyQt, PySide) only: take this one's screenshot without them" + ) + if mode == "qt": + shot = qt_offscreen(command, cwd, screen, wait, tmp, cancel, actions) + else: + shot = virtual_display(command, cwd, screen, wait, tmp, cancel) shutil.copyfile(shot.image, path) kept = [] for i, window in enumerate(shot.windows): diff --git a/src/lcode/tools.py b/src/lcode/tools.py index 575c7a6..f8efde9 100644 --- a/src/lcode/tools.py +++ b/src/lcode/tools.py @@ -192,7 +192,8 @@ def _fn(name: str, description: str, properties: dict, required: list[str]) -> d "widgets (Qt) or of the page, and the browser's console errors. Use it to check GUI and layout work instead " "of guessing. Python Qt programs run offscreen; other desktop programs on a virtual display (Linux); web pages " "in a headless browser. For a web app, give the command that starts its server and the url (lcode starts and " - "stops it); if a server is already running, or for an .html file, give only the url.", + "stops it); if a server is already running, or for an .html file, give only the url. For something that " + "only shows after a click or a key (a Python Qt program), give `actions`: they run first, then the picture.", { "command": {"type": "string", "description": "Command that starts the program, or the web server"}, "url": {"type": "string", "description": "Page to open: a URL, localhost:PORT/path or an .html file"}, @@ -203,6 +204,14 @@ def _fn(name: str, description: str, properties: dict, required: list[str]) -> d "screen": {"type": "string", "description": "Screen (or browser window) size, WxH (default 1920x1080)"}, "wait": {"type": "integer", "description": "Seconds to let it start and draw first (default 3)"}, "full_page": {"type": "boolean", "description": "Web pages: the whole page, not just the window"}, + "actions": { + "type": "array", + "items": {"type": "string"}, + "description": "Python Qt programs: steps to take before the picture, as a user would: " + '"click X,Y", "double_click X,Y", "right_click X,Y" (a point in the window, as the sizes show), ' + 'or with the widget\'s text: "double_click Camera 1"; "key Escape", "key ctrl+s"; "type some text"; ' + '"wait 2". The result says what each step hit.', + }, "mode": { "type": "string", "enum": ["auto", "qt", "xvfb"], @@ -698,6 +707,7 @@ def t_screenshot( wait: int = screenshots.DEFAULT_WAIT, full_page: bool = False, mode: str = "auto", + actions: list | str | None = None, ) -> str: command, url = (command or "").strip(), (url or "").strip() if not command and not url: @@ -708,6 +718,7 @@ def t_screenshot( size = screenshots.parse_screen(screen) if url: url = screenshots.normalize_url(url, self.agent.cwd) + steps = screenshots.parse_actions(actions) except screenshots.ShotError as e: raise ToolError(str(e)) from e if command: @@ -748,6 +759,7 @@ def cancelled() -> bool: full_page=full_page, mode=mode, cancel=cancelled, + actions=steps, ) except screenshots.ShotError as e: raise ToolError(f"no screenshot: {e}") from e @@ -762,6 +774,8 @@ def cancelled() -> bool: lines.append(f"What the {'server' if url else 'program'} printed:\n{shot.output}") if self.agent.vision_model(): what = f"the page {url}" if url else f"the program `{command}` on a {w}x{h} screen" + if steps: + what += ", after these steps: " + "; ".join(s["raw"] for s in steps) ask = f"This is a screenshot of {what}." + (f" {question.strip()}" if question.strip() else "") try: seen = self.agent.look(shot.image, ask) diff --git a/tests/test_look.py b/tests/test_look.py new file mode 100644 index 0000000..95107ab --- /dev/null +++ b/tests/test_look.py @@ -0,0 +1,214 @@ +"""Looking at a window before calling it done: the screenshot tool's actions (click, double-click, key, type, wait +before the picture), the check that sends back window code the model never looked at, and repeats that differ only +in their timings. From a real session: a double-click feature 'fixed' four times without one look at the window.""" + +import os +import shutil +import subprocess +import textwrap + +import pytest + +from conftest import call, output, reply +from lcode import checks +from lcode import screenshots as sh +from lcode.repeats import Repeats + +QT_PYTHON = os.environ.get("LCODE_TEST_QT_PYTHON") # a Python with PyQt6, for the real run below + + +# ----------------------------------------------------------------------------- the screenshot tool's actions + + +def test_actions_are_parsed_the_way_a_user_would_say_them(): + steps = sh.parse_actions( + ["double_click 300,200", 'click "Save"', "double-click Camera 1", "key ctrl+s", "key esc", "type hello", "wait"] + ) + assert steps[0] == {"do": "double_click", "raw": "double_click 300,200", "x": 300, "y": 200} + assert steps[1]["text"] == "Save" and steps[2]["do"] == "double_click" and steps[2]["text"] == "Camera 1" + assert (steps[3]["key"], steps[3]["mods"]) == ("S", ["ctrl"]) and steps[4]["key"] == "Escape" + assert steps[5]["text"] == "hello" and steps[6]["seconds"] == 1 + assert [s["raw"] for s in sh.parse_actions("click 10,20; wait 2\nkey Return")] == [ + "click 10,20", + "wait 2", + "key Return", + ] + assert sh.parse_actions(None) == [] and sh.actions_time(sh.parse_actions(["wait 2", "click 1,1"])) == 2.8 + + +@pytest.mark.parametrize( + ("action", "says"), + [ + ("hover 10,10", "actions are click"), + ("click", "say where"), + ("key", "name the key"), + ("key hyper+x", "modifiers are"), + ("key PrintScreen", "unknown key"), + ("type", "give the text"), + ("wait soon", "wait takes seconds"), + ], +) +def test_a_wrong_action_says_how_to_write_it(action, says): + with pytest.raises(sh.ShotError, match=says): + sh.parse_actions([action]) + + +def test_actions_only_for_qt_programs(tmp_path): + with pytest.raises(sh.ShotError, match="not web pages"): + sh.take(tmp_path, sh.shots_dir(tmp_path), url="https://example.com", actions=sh.parse_actions(["click 1,1"])) + with pytest.raises(sh.ShotError, match="Python Qt programs"): + sh.take(tmp_path, sh.shots_dir(tmp_path), command="xterm", mode="xvfb", actions=sh.parse_actions(["wait 1"])) + + +def test_the_report_says_what_each_step_hit(): + data = { + "screen": [1366, 768], + "windows": [{"title": "Wall", "class": "Wall", "x": 0, "y": 0, "width": 800, "height": 600}], + "actions": [ + {"step": "double_click Camera 1", "target": 'QLabel "Camera 1 — Connected" at (207, 317)'}, + {"step": 'click "Gone"', "error": 'no visible widget shows "Gone"'}, + {"step": "wait 1"}, + ], + } + lines = sh.qt_report(data) + assert lines[:4] == [ + "Steps, before the picture:", + '- double_click Camera 1: on QLabel "Camera 1 — Connected" at (207, 317)', + '- click "Gone": couldn\'t: no visible widget shows "Gone"', + "- wait 1: done", + ] + + +def test_the_tool_passes_the_actions_on(make_agent, fake_take): + agent = make_agent( + [ + reply(tool_calls=[call("screenshot", command="python wall.py", actions=["double_click Camera 1"])]), + reply(tool_calls=[call("screenshot", command="python wall.py", actions=["hover 1,1"])]), + reply("Seen."), + ] + ) + agent.run_turn("Does double-clicking a camera fill the window?") + assert [s["raw"] for s in fake_take[0]["actions"]] == ["double_click Camera 1"] + results = [m["content"] for m in agent.messages if m["role"] == "tool"] + assert results[1].startswith("Error:") and "actions are click" in results[1] and len(fake_take) == 1 + + +@pytest.fixture +def fake_take(monkeypatch): + calls = [] + + def take(cwd, folder, **kwargs): + calls.append(kwargs) + image = folder / "20261021-120000-wall.png" + image.write_bytes(b"\x89PNG\r\n\x1a\n") + return sh.Shot(image=image, how="Qt offscreen (PyQt6)", screen=(1366, 768), report=[], output="") + + monkeypatch.setattr(sh, "take", take) + return calls + + +@pytest.mark.skipif(not QT_PYTHON, reason="set LCODE_TEST_QT_PYTHON to a Python with PyQt6") +def test_a_real_qt_program_is_clicked_before_the_picture(tmp_path): + (tmp_path / "app.py").write_text( + textwrap.dedent( + """ + from PyQt6.QtWidgets import QApplication, QLabel, QLineEdit, QPushButton, QVBoxLayout, QWidget + + class Big(QLabel): + def mouseDoubleClickEvent(self, event): + self.setText("zoomed in") + + app = QApplication([]) + window = QWidget() + window.setWindowTitle("Demo") + layout = QVBoxLayout(window) + label = Big("Camera 1") + count = QLabel("clicks 0") + button = QPushButton("Add") + button.clicked.connect(lambda: count.setText("clicks %d" % (int(count.text().split()[1]) + 1))) + field = QLineEdit() + for widget in (label, count, button, field): + layout.addWidget(widget) + window.resize(400, 300) + window.show() + field.setFocus() + app.exec() + """ + ) + ) + # typing goes where the keyboard focus is (the field, until a click moves it to the button) + steps = sh.parse_actions(["double_click Camera 1", "type hello", "click Add", "click Add", 'click "Nowhere"']) + shot = sh.take(tmp_path, sh.shots_dir(tmp_path), command=f"{QT_PYTHON} app.py", wait=1, mode="qt", actions=steps) + report = "\n".join(shot.report) + assert '- double_click Camera 1: on Big "Camera 1" at' in report + assert '- click Add: on QPushButton "Add"' in report + assert "- type hello: on QLineEdit" in report + assert '- click "Nowhere": couldn\'t: no visible widget shows "Nowhere"' in report + assert '"zoomed in"' in report and '"clicks 2"' in report and '"hello"' in report + + +# ----------------------------------------------------------------------------- windows it never looked at + +WINDOW = "from PyQt6.QtWidgets import QApplication, QLabel\n\napp = QApplication([])\nQLabel('hi').show()\napp.exec()\n" + + +def test_window_code_is_recognized_and_a_look_counts(tmp_path): + (tmp_path / "wall.py").write_text(WINDOW) + (tmp_path / "tk.py").write_text("import tkinter as tk\n") + (tmp_path / "logic.py").write_text("def add(a, b):\n return a + b\n") + changed = ["wall.py", "tk.py", "logic.py", "gone.py"] + assert checks.unseen_windows(tmp_path, changed, {"bash"}) == {"wall.py": "PyQt6", "tk.py": "tkinter"} + assert checks.unseen_windows(tmp_path, changed, {"screenshot"}) == {} + assert checks.unseen_windows(tmp_path, changed, {"view_image"}) == {} + note = checks.unseen_note({"wall.py": "PyQt6"}) + assert "never looked at it" in note and 'actions=["double_click' in note + + +def test_a_window_change_without_a_look_is_sent_back_once(make_agent, repo, fake_take): + write = call("write_file", path="wall.py", content=WINDOW) + agent = make_agent( + [ + reply(tool_calls=[write, call("bash", command="python -c 'import ast'")]), + reply("Done: double-click works."), + reply(tool_calls=[call("screenshot", command="python wall.py", actions=["double_click hi"])]), + reply("It shows the label."), + ], + checks="auto", + ) + agent.run_turn("Make the label zoom on double-click") + notes = [m["content"] for m in agent.messages if m["role"] == "user" and "never looked at it" in m["content"]] + assert len(notes) == 1 and "wall.py" in notes[0] and "(PyQt6)" in notes[0] + assert agent.messages[-1]["content"] == "It shows the label." and not agent.ollama.scripts + assert "never looked at wall.py" in output(agent) + + +def test_no_check_when_checks_are_off_or_nothing_draws(make_agent, repo): + agent = make_agent([reply(tool_calls=[call("write_file", path="wall.py", content=WINDOW)]), reply("Done.")]) + agent.run_turn("Change the window") # checks off (the test default) + assert agent.messages[-1]["content"] == "Done." + logic = call("write_file", path="logic.py", content="X = 1\n") + agent = make_agent([reply(tool_calls=[logic]), reply("Done.")], checks="auto") + agent.run_turn("Change the logic") + assert agent.messages[-1]["content"] == "Done." + + +# ----------------------------------------------------------------------------- repeats that differ only in timing + + +def test_a_rerun_that_only_took_a_different_time_is_a_repeat(): + repeats = Repeats() + command = {"command": ".venv/bin/python -m pytest -q"} + verdicts = [repeats.check("bash", command, f"75 passed in 0.{n}s\n[exit code: 0]") for n in (14, 12, 15)] + assert [bool(v.note) for v in verdicts] == [False, False, True] + changed = Repeats() + runs = [changed.check("bash", command, f"{n} failed in 0.1s\n[exit code: 1]") for n in (3, 2, 1)] + assert not any(v.note for v in runs) # the results changed: not a repeat + + +def test_the_real_qt_test_can_find_a_python(tmp_path): + """Documents how to run the real Qt test: any Python with PyQt6 will do.""" + if not QT_PYTHON: + pytest.skip("set LCODE_TEST_QT_PYTHON to a Python with PyQt6") + done = subprocess.run([QT_PYTHON, "-c", "import PyQt6.QtTest"], capture_output=True, text=True) + assert done.returncode == 0, done.stderr + assert shutil.which(QT_PYTHON) or os.path.exists(QT_PYTHON) From 742cb45242cfd65d7dac53f9dff2c6b27e8f4b76 Mon Sep 17 00:00:00 2001 From: nasser1941 <9349840+nasser1941@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:17:37 +0200 Subject: [PATCH 2/7] Keep a reply lcode drops for going round in circles, and say where A real run on the camera wall ended after two dropped replies, and nobody could see what the model had been thinking: the session doesn't keep them. Now the dropped reply (thinking and text) is saved with the other long outputs and the message gives the path. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 ++ docs/how-it-works.md | 3 ++- src/lcode/agent.py | 15 ++++++++++++--- tests/test_stuck.py | 15 +++++++++++++++ 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a554dcc..d817410 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ window, while the user saw a blank screen each time: printed: `setLayout: … which already has a layout`. - **A rerun that only took a different time** counts as a repeat: the model ran the same tests four times in a row (`75 passed in 0.14s`, then `0.12s`…) without the repeat check noticing. +- **A reply lcode drops** for going round in circles is saved, and the message says where, so you + can read what the model was thinking instead of taking the stop on trust. ## [0.37.0] - 2026-10-20 diff --git a/docs/how-it-works.md b/docs/how-it-works.md index b1e8c12..4f3a8cf 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -86,7 +86,8 @@ aren't tracked. Small models sometimes think in circles: a few paragraphs, over and over, until the context is full. That takes minutes behind the spinner and ends without an answer. lcode watches the thinking as it streams, and when its sentences keep repeating, it stops the model, drops that reply and asks for a -short one: the next tool call, or what's blocking it. An empty reply, or one that filled the context +short one: the next tool call, or what's blocking it. The dropped reply is saved, and the message +says where, so you can read what went round in circles. An empty reply, or one that filled the context before it said anything, gets the same second chance. The second time in a request, lcode stops it (status `loop`), offering the [stronger model](usage.md#a-stronger-model-when-stuck) first when one is set. An answer the context cut off midway is kept, with a warning to `/compact` or use a larger diff --git a/src/lcode/agent.py b/src/lcode/agent.py index 59f1358..75ccde9 100644 --- a/src/lcode/agent.py +++ b/src/lcode/agent.py @@ -1223,10 +1223,11 @@ def _run_turn(self, user_text: str) -> None: continue if not calls and (self.reply_trouble == "circles" or not (reply.get("content") or "").strip()): stalls += 1 - self.messages.pop() # nothing to keep: no answer, and circles would only fill the context + dropped = self.messages.pop() # no answer, and circles would only fill the context + kept = self.keep_dropped(dropped) if stalls < MAX_STALLS: self.console.print( - Text(f" ⎿ {STALLED[self.reply_trouble]}: asking for a short answer", style="yellow") + Text(f" ⎿ {STALLED[self.reply_trouble]}: asking for a short answer{kept}", style="yellow") ) self.messages.append({"role": "user", "content": stall_note(self.reply_trouble)}) continue @@ -1242,7 +1243,7 @@ def _run_turn(self, user_text: str) -> None: self.console.print( Text( "Stopped: the model's replies went round in circles or ended without an answer, twice in " - "this request. Try again with a smaller step, or tell it what to do differently.", + f"this request{kept}. Try again with a smaller step, or tell it what to do differently.", style="yellow", ) ) @@ -1429,6 +1430,14 @@ def _run_turn(self, user_text: str) -> None: self.turn_status = "success" self.print_stats() + def keep_dropped(self, reply: dict) -> str: + """Save a reply lcode dropped (its thinking too), so the user can see what went round in circles.""" + text = "\n\n".join(part for part in (reply.get("thinking"), reply.get("content")) if part) + if not text.strip(): + return "" + path = context_tools.save_output(text, "dropped reply") + return f" (the dropped reply is in {path})" if path else "" + def can_escalate(self) -> bool: """Whether a stronger model may be asked here: one is set, and this isn't a subagent or plan mode.""" model = (self.settings.escalate_model or "").strip().lower() diff --git a/tests/test_stuck.py b/tests/test_stuck.py index 54a1bb5..870f8a7 100644 --- a/tests/test_stuck.py +++ b/tests/test_stuck.py @@ -248,3 +248,18 @@ def test_a_script_written_and_never_run_is_sent_back_once(make_agent, repo): agent = make_agent([reply(tool_calls=[write]), reply("Done.")], checks="off") agent.run_turn("Write a build script") assert agent.messages[-1]["content"] == "Done." + + +def test_a_dropped_reply_is_kept_where_the_user_can_read_it(make_agent): + import re + + looping = [{"message": {"thinking": LOOP}, "done": False} for _ in range(200)] + agent = make_agent([None, reply("Done.")]) + agent.ollama.scripts[0] = streamed(looping, []) + agent.run_turn("Build the APK") + text = " ".join(output(agent).split()) + match = re.search(r"the dropped reply is in (\S+?\.txt)", text) + assert match, text + from pathlib import Path + + assert "Let me try to check the actual download page" in Path(match.group(1)).read_text() From 3d7198832c19a3674c5e7b69e437618c69f4535a Mon Sep 17 00:00:00 2001 From: nasser1941 <9349840+nasser1941@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:30:09 +0200 Subject: [PATCH 3/7] After a reply that went nowhere, point at running the thing instead of reasoning about it In a real run the model's dropped thinking said 'let me create a test config and run the app' ten times without doing it. The note after such a reply now says: if you were about to check something by running it, run it now; for a window, take a screenshot (with actions for clicks or keys). Co-Authored-By: Claude Opus 5.5 --- src/lcode/agent.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lcode/agent.py b/src/lcode/agent.py index 75ccde9..1754cdc 100644 --- a/src/lcode/agent.py +++ b/src/lcode/agent.py @@ -99,7 +99,9 @@ def stall_note(trouble: str) -> str: }[trouble] return ( f"[lcode] {first} Don't deliberate at length now. Either make your next tool call, or, if you're stuck, " - "tell the user in a few lines what you did, what fails, and what they could do." + "tell the user in a few lines what you did, what fails, and what they could do. If you were about to check " + "something by running it, run it now instead of reasoning about it: for a window, take a screenshot (with " + "actions for clicks or keys)." ) From 0be492c12b5ab65675856989a6f8bb631b5cf189 Mon Sep 17 00:00:00 2001 From: nasser1941 <9349840+nasser1941@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:47:15 +0200 Subject: [PATCH 4/7] Put Qt's warnings at the top of a screenshot's report In a real run the model double-clicked the camera with the new actions and the cause, 'QWidget::setLayout: ... which already has a layout', was in what the program printed, among 30 lines of camera logs. The model noticed it and then went back to its own theories. Qt's warnings are now taken from everything the program printed (not only the shown end), without repeats or offscreen noise, and listed first: 'Qt printed warnings (they often name the cause)'. Co-Authored-By: Claude Opus 5.5 --- src/lcode/screenshots.py | 24 ++++++++++++++++++++++++ tests/test_look.py | 13 +++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/lcode/screenshots.py b/src/lcode/screenshots.py index 520ae17..a27b946 100644 --- a/src/lcode/screenshots.py +++ b/src/lcode/screenshots.py @@ -585,6 +585,27 @@ def stop(proc: subprocess.Popen | None) -> None: proc.wait(timeout=3) +QT_WARNING = re.compile(r"^(?:Q[A-Za-z0-9]+::\w+|qt\.[\w.]+:|QML |Qt[: ])") +NOT_A_PROBLEM = re.compile( + r"propagateSizeHints|QFont::|This plugin does not support|qt\.qpa\.fonts" +) # offscreen platform noise +MAX_WARNINGS = 6 + + +def qt_warnings(log: Path) -> list[str]: + """Qt's own warnings in everything the program printed (not just the end that's shown), without repeats.""" + try: + text = log.read_bytes().decode(errors="replace") + except OSError: + return [] + found: list[str] = [] + for line in text.splitlines(): + line = line.strip() + if QT_WARNING.match(line) and not NOT_A_PROBLEM.search(line) and line[:300] not in found: + found.append(line[:300]) + return found[:MAX_WARNINGS] + + def printed(log: Path) -> str: try: text = log.read_bytes().decode(errors="replace") @@ -666,6 +687,9 @@ def qt_offscreen( if image is None and not windows: raise ShotError("the program started Qt but showed no window") report = qt_report(data) + warnings = qt_warnings(log) + if warnings: + report[:0] = ["Qt printed warnings (they often name the cause):", *[f"- {line}" for line in warnings]] if shot_screen != screen: # Qt 5's offscreen platform has a fixed screen report.append( f"(Qt used a {shot_screen[0]}x{shot_screen[1]} screen offscreen, not the {screen[0]}x{screen[1]} asked " diff --git a/tests/test_look.py b/tests/test_look.py index 95107ab..0d8a6bc 100644 --- a/tests/test_look.py +++ b/tests/test_look.py @@ -212,3 +212,16 @@ def test_the_real_qt_test_can_find_a_python(tmp_path): done = subprocess.run([QT_PYTHON, "-c", "import PyQt6.QtTest"], capture_output=True, text=True) assert done.returncode == 0, done.stderr assert shutil.which(QT_PYTHON) or os.path.exists(QT_PYTHON) + + +def test_qt_warnings_are_pulled_out_of_what_the_program_printed(tmp_path): + log = tmp_path / "output.txt" + lines = ["2026-10-21 [INFO] Camera 1: connecting"] * 40 + lines[3] = 'QWidget::setLayout: Attempting to set QLayout "" on FocusedCamera "", which already has a layout' + lines[5] = lines[3] # said twice, listed once + lines[7] = "This plugin does not support propagateSizeHints()" # offscreen noise + lines[9] = "qt.qpa.fonts: Populating font family aliases took 50 ms" + lines[39] = "QPainter::begin: Paint device returned engine == 0, type: 2" + log.write_text("\n".join(lines)) + assert sh.qt_warnings(log) == [lines[3], lines[39]] + assert sh.qt_warnings(tmp_path / "missing.txt") == [] From c157e12283662fd600d3d6c2f4a59be75d528cde Mon Sep 17 00:00:00 2001 From: nasser1941 <9349840+nasser1941@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:47:43 +0200 Subject: [PATCH 5/7] Docs and changelog: Qt warnings at the top of a screenshot's report Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +++ docs/usage.md | 9 +++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d817410..88f149d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ All notable changes to lcode are documented here. The format follows the way a user would: `double_click Camera 1` (a widget by its text) or `double_click 300,200`, `click`, `right_click`, `key Escape`, `key ctrl+s`, `type …`, `wait 2`. The result says what each step hit. Bugs that only show after a click were out of reach before. +- Qt's warnings are listed at the top of a screenshot's report, taken from everything the program + printed. In a real run the cause (`setLayout: … which already has a layout`) was there, among 30 + lines of the program's logs, and the model looked past it. ### Fixed diff --git a/docs/usage.md b/docs/usage.md index 1fb2834..f8576ff 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -437,15 +437,16 @@ a screen and sees what you would, measured: - **Clicks and keys first.** For something that only shows after a click, a Python Qt program can take `actions` before the picture, the way you would: `double_click 300,200` (a point in the window), `double_click Camera 1` (the widget showing that text), `click Save`, `right_click …`, - `key Escape`, `key ctrl+s`, `type some text`, `wait 2`. The result says what each step hit, and - what the program printed, where Qt names layout mistakes: + `key Escape`, `key ctrl+s`, `type some text`, `wait 2`. The result says what each step hit. +- **Qt's warnings come first.** Qt names many layout mistakes in what the program prints, often + among the program's own logs. lcode lists them at the top of the report: ```text ● screenshot(python camera_wall.py) actions: double_click Camera 1 + Qt printed warnings (they often name the cause): + - QWidget::setLayout: Attempting to set QLayout "" on FocusedCamera "", which already has a layout Steps, before the picture: - double_click Camera 1: on QLabel "Camera 1 — Connected" at (207, 317) - What the program printed: - QWidget::setLayout: Attempting to set QLayout "" on FocusedCamera "", which already has a layout ``` - A model that can see describes the picture with the model's question in mind (see [Which model looks](#images)). Without one, the measurements still come back. From 3c8b5768f1014138b6362e2f00014238810ebb31 Mon Sep 17 00:00:00 2001 From: nasser1941 <9349840+nasser1941@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:16:31 +0200 Subject: [PATCH 6/7] Checks: the project's own Python, and pytest for tests written for it A real run on the camera wall ended with lcode's checks failing: they ran 'python -m unittest discover' with the Python on PATH, which has neither the project's dependencies (PyQt6, PyAV) nor a way to run its pytest-style tests. The model then 'fixed' that instead of the user's problem. - Python checks run with the project's .venv/bin/python (or venv, env) when there is one. - Tests that import pytest, or a requirements file listing it, get pytest without a pytest configuration. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 ++++ docs/usage.md | 4 ++- src/lcode/checks.py | 63 ++++++++++++++++++++++++++++++++------------ tests/test_checks.py | 30 +++++++++++++++++++++ 4 files changed, 84 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88f149d..d7d5394 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,11 @@ window, while the user saw a blank screen each time: times in a row (`75 passed in 0.14s`, then `0.12s`…) without the repeat check noticing. - **A reply lcode drops** for going round in circles is saved, and the message says where, so you can read what the model was thinking instead of taking the stop on trust. +- **The checks use the project's own Python.** They ran `python -m …` from PATH, which doesn't have + the project's dependencies, so a project with a `.venv` "failed" its checks on imports. Now + `.venv/bin/python` (or `venv`, `env`) runs them. +- **Tests written for pytest get pytest** when the project has no pytest configuration: the tests + import it, or a requirements file lists it. They used to get `unittest`, which can't run them. ## [0.37.0] - 2026-10-20 diff --git a/docs/usage.md b/docs/usage.md index f8576ff..2b70bc1 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -170,7 +170,9 @@ runs the project's checks itself before the answer is final: - **Programs** using `lcode -p --output json` see the runs in the result's `checks`. **Which checks.** lcode looks at the project: pytest when the configuration mentions it (`uv run -pytest -q` with a `uv.lock`), otherwise `python -m unittest` for `test_*.py` files. Then +pytest -q` with a `uv.lock`), when the tests import it, or when a requirements file lists it; +otherwise `python -m unittest` for `test_*.py` files. Python checks run with the project's own +environment when it has one (`.venv/bin/python`, `venv`, `env`), where its dependencies are. Then `npm test` (or pnpm, yarn, bun) when `package.json` has a test script, `cargo test`, `go test ./...` and `make test`. To choose the command yourself, set it for the repository in `.lcode/settings.toml`. That file is used once you trust the repository, like its hooks: diff --git a/src/lcode/checks.py b/src/lcode/checks.py index 708ec7b..f62ada2 100644 --- a/src/lcode/checks.py +++ b/src/lcode/checks.py @@ -50,10 +50,28 @@ def find(root: Path, setting: str, trusted: bool) -> Check | None: return detect(root) -def python() -> str: +VENVS = (".venv", "venv", "env") +PYTEST_USE = re.compile(r"^\s*(?:import pytest\b|from pytest\b)|^\s*@pytest\.", re.M) + + +def python(root: Path | None = None) -> str: + """The project's own Python (its .venv, venv or env), where its dependencies are; else the one on PATH.""" + if root is not None: + for env in VENVS: + for exe in ("bin/python", "Scripts/python.exe"): + if (root / env / exe).is_file(): + return f"{env}/{exe}" # the checks run in the project's root return "python" if shutil.which("python") else "python3" +def uses_pytest(root: Path) -> bool: + """Whether the tests are written for pytest (they import it), or the requirements list it, without a config.""" + for name in ("requirements.txt", "requirements-dev.txt", "requirements_dev.txt", "dev-requirements.txt"): + if re.search(r"^\s*pytest\b", text(root / name), re.M | re.I): + return True + return any(PYTEST_USE.search(text(path)[:20000]) for path in test_files(root)[:50]) + + def text(path: Path) -> str: try: return path.read_text(errors="replace") if path.is_file() else "" @@ -61,38 +79,49 @@ def text(path: Path) -> str: return "" -def python_tests(root: Path) -> bool: - """Whether the project has Python test files (a few levels deep, outside environments and caches).""" +def test_files(root: Path, limit: int = 200) -> list[Path]: + """The project's Python test files, a few levels deep, outside environments and caches.""" from lcode.tools import IGNORE_DIRS - def walk(folder: Path, depth: int) -> bool: + found: list[Path] = [] + + def walk(folder: Path, depth: int) -> None: try: - entries = list(folder.iterdir()) + entries = sorted(folder.iterdir()) except OSError: - return False - if any(e.is_file() and re.fullmatch(r"test_.*\.py|.*_test\.py", e.name) for e in entries): - return True - return depth > 0 and any( - walk(e, depth - 1) - for e in entries - if e.is_dir() and e.name not in IGNORE_DIRS and not e.name.startswith(".") - ) + return + found.extend(e for e in entries if e.is_file() and re.fullmatch(r"test_.*\.py|.*_test\.py", e.name)) + if depth > 0: + for e in entries: + if len(found) >= limit: + return + if e.is_dir() and e.name not in IGNORE_DIRS and e.name not in VENVS and not e.name.startswith("."): + walk(e, depth - 1) + + walk(root, 3) + return found[:limit] - return walk(root, 3) + +def python_tests(root: Path) -> bool: + """Whether the project has Python test files (a few levels deep, outside environments and caches).""" + return bool(test_files(root, limit=1)) def detect(root: Path) -> Check | None: """A check command from what the project looks like. Python checks need test files: pytest configured in a project without tests "fails" with nothing to run.""" if python_tests(root): + py = python(root) configs = text(root / "pyproject.toml") + text(root / "setup.cfg") + text(root / "tox.ini") if "pytest" in configs or any((root / name).is_file() for name in ("pytest.ini", "conftest.py")): - runner = "uv run pytest -q" if (root / "uv.lock").is_file() else f"{python()} -m pytest -q" + runner = "uv run pytest -q" if (root / "uv.lock").is_file() else f"{py} -m pytest -q" return Check(runner, "pytest in the project's configuration") + if uses_pytest(root): + return Check(f"{py} -m pytest -q", "tests written for pytest") if list(root.glob("test_*.py")) or list(root.glob("*_test.py")): - return Check(f"{python()} -m unittest", "test files") + return Check(f"{py} -m unittest", "test files") if (root / "tests").is_dir(): - return Check(f"{python()} -m unittest discover -s tests -t .", "tests/") + return Check(f"{py} -m unittest discover -s tests -t .", "tests/") package = text(root / "package.json") if package: try: diff --git a/tests/test_checks.py b/tests/test_checks.py index e78e2c3..33cef21 100644 --- a/tests/test_checks.py +++ b/tests/test_checks.py @@ -190,3 +190,33 @@ def test_a_failure_the_model_doesnt_touch_isnt_checked_again(make_agent): ) agent.run_turn("write notes") assert len(agent.check_results) == 1 and agent.messages[-1]["content"].startswith("That failure") + + +@pytest.mark.parametrize( + ("files", "expected"), + [ + ({"tests/test_wall.py": "import pytest\n\n@pytest.mark.parametrize('x', [1])\ndef test_x(x): ...\n"}, + "-m pytest -q"), + ({"requirements.txt": "PyQt6\npytest>=8\n", "tests/test_wall.py": "def test_x(): ...\n"}, "-m pytest -q"), + ({"tests/test_wall.py": "import unittest\n"}, "-m unittest discover -s tests -t ."), + ], +) # fmt: skip +def test_tests_written_for_pytest_get_pytest_without_a_config(tmp_path, files, expected): + for name, content in files.items(): + (tmp_path / name).parent.mkdir(parents=True, exist_ok=True) + (tmp_path / name).write_text(content) + check = checks.detect(tmp_path) + assert check.command.endswith(expected) + + +def test_the_projects_own_python_runs_the_checks(tmp_path): + """Its dependencies are in its environment: the Python on PATH fails on their imports.""" + (tmp_path / "tests").mkdir() + (tmp_path / "tests" / "test_wall.py").write_text("import pytest\n") + assert not checks.detect(tmp_path).command.startswith(".venv") + (tmp_path / ".venv" / "bin").mkdir(parents=True) + (tmp_path / ".venv" / "bin" / "python").write_text("") + (tmp_path / ".venv" / "lib" / "test_inside_the_venv.py").parent.mkdir(parents=True) + (tmp_path / ".venv" / "lib" / "test_inside_the_venv.py").write_text("import pytest\n") + assert checks.detect(tmp_path).command == ".venv/bin/python -m pytest -q" + assert checks.test_files(tmp_path) == [tmp_path / "tests" / "test_wall.py"] # not the environment's own From 15eeea742324aa48883eb5c90adae34f5abb050f Mon Sep 17 00:00:00 2001 From: nasser1941 <9349840+nasser1941@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:20:05 +0200 Subject: [PATCH 7/7] Test: find the saved dropped reply on disk, not in a wrapped console line Co-Authored-By: Claude Opus 5.5 --- tests/test_stuck.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/tests/test_stuck.py b/tests/test_stuck.py index 870f8a7..e84d8e9 100644 --- a/tests/test_stuck.py +++ b/tests/test_stuck.py @@ -251,15 +251,13 @@ def test_a_script_written_and_never_run_is_sent_back_once(make_agent, repo): def test_a_dropped_reply_is_kept_where_the_user_can_read_it(make_agent): - import re + from lcode import context looping = [{"message": {"thinking": LOOP}, "done": False} for _ in range(200)] agent = make_agent([None, reply("Done.")]) agent.ollama.scripts[0] = streamed(looping, []) agent.run_turn("Build the APK") - text = " ".join(output(agent).split()) - match = re.search(r"the dropped reply is in (\S+?\.txt)", text) - assert match, text - from pathlib import Path - - assert "Let me try to check the actual download page" in Path(match.group(1)).read_text() + [saved] = context.outputs_dir().glob("*dropped-reply.txt") + assert "Let me try to check the actual download page" in saved.read_text() + shown = "".join(output(agent).split()) # a long path may be wrapped on screen + assert "thedroppedreplyisin" in shown and saved.name in shown