diff --git a/tests/tools/test_process_heartbeat.py b/tests/tools/test_process_heartbeat.py index 7c5f9626f7219..fed9b06de8587 100644 --- a/tests/tools/test_process_heartbeat.py +++ b/tests/tools/test_process_heartbeat.py @@ -63,7 +63,7 @@ def test_heartbeat_carries_only_new_output_and_stops_at_exit(tmp_path, monkeypat timeout=2.5) -def test_terminal_dispatch_heartbeat_implies_notify_and_refuses_foreground(monkeypatch): +def test_terminal_dispatch_normalizes_materialized_background_fields_for_foreground(monkeypatch): from tools import terminal_tool as tt captured = {} @@ -74,8 +74,17 @@ def fake_terminal_tool(**kwargs): monkeypatch.setattr(tt, "terminal_tool", fake_terminal_tool) dispatch = tt._handle_terminal - fg = json.loads(dispatch({"command": "sleep 1", "heartbeat": 120})) - assert fg.get("error") and "background" in fg["error"] + fg = json.loads(dispatch({ + "command": "sleep 1", + "background": False, + "notify": False, + "heartbeat": 60, + })) + assert "error" not in fg or not fg["error"] + assert captured["heartbeat"] == 0 and captured["notify_on_complete"] is False + + foreground_notify = json.loads(dispatch({"command": "sleep 1", "notify": True})) + assert foreground_notify.get("error") and "background" in foreground_notify["error"] bg = json.loads(dispatch({"command": "sleep 1", "background": True, "heartbeat": 120})) assert "error" not in bg or not bg["error"] diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 80cfaf9fd0469..2e9bae49db3d7 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -1411,8 +1411,9 @@ def _handle_terminal(args, **kw): ) # `notify` is the advertised interface (true → notify_on_complete, # [...] → watch_patterns); the legacy args stay accepted, explicit - # `notify` wins. Background-only modifiers on a foreground call fail - # with the corrected call instead of being silently ignored. + # `notify` wins. Tool schemas mechanically materialize false background + # defaults and a heartbeat for ordinary foreground calls, so discard those + # non-operative fields instead of making the model retry the same command. notify = args.get("notify") notify_on_complete = args.get("notify_on_complete", False) watch_patterns = args.get("watch_patterns") @@ -1420,12 +1421,13 @@ def _handle_terminal(args, **kw): if not isinstance(heartbeat, int) or isinstance(heartbeat, bool) or heartbeat < 0: return tool_error("heartbeat must be a whole number of seconds (min 60).") if not args.get("background", False): - if notify or watch_patterns or notify_on_complete or heartbeat: + if notify or watch_patterns or notify_on_complete: return tool_error( "notify/heartbeat only apply to background commands (foreground " "results return directly). Either drop them, or run as " "terminal(command=..., background=true, notify=...)." ) + heartbeat = 0 if args.get("pty", False): return tool_error( "pty requires background=true (a PTY session is interacted "