Files
turnstone/tests/test_bash_background_tool.py
Patrick Buckley f311555026 fix(bash): report exit code for killed shells; single import style in registry tests
Copilot: bash_output's schema promises the exit code once the shell has
exited, but the formatting attached it only to 'completed' — a killed
shell has one too (the negated signal number). Attach it to any exited
state.

Code-quality: the registry test file mixed a top-level from-import with
function-local 'import ... as bg_mod' for monkeypatching module
attributes; one from-style module alias at the top now serves all of
them.

(cherry picked from commit dc647f4d63)
2026-07-11 19:05:48 -07:00

802 lines
33 KiB
Python

"""Session-level tests for the background-shell tool surface (#817).
Covers the wiring around :class:`BackgroundShellRegistry`:
* ``bash`` gains ``run_in_background: true`` (alias ``is_background``) —
same approval gate, returns immediately with a ``bash_N`` handle.
* ``bash_output`` — auto-approved delta reader (status + exit code + only
new output since the last call, optional ``filter`` regex).
* ``kill_shell`` — auto-approved kill of a registered shell's whole group.
* Exit notices ride the NudgeQueue on channel ``"any"`` (the watch rail) so
they drain at the next seam and can wake an idle workstream.
* Lifecycle: ``close()`` reaps everything; generation-``cancel()`` does NOT
(a deliberately-detached server survives a stopped turn); shells spawned
inside a task_agent are owner-scoped and reaped when the agent finishes.
"""
import time
import pytest
from tests._proc_helpers import pid_alive as _pid_alive
from tests._proc_helpers import poll_until as _wait_until
from tests._session_helpers import make_session
@pytest.fixture
def session():
s = make_session()
yield s
s.close()
def _start_background(session, command, call_id="bg1", **extra_args):
"""Prepare + execute a backgrounded bash call; return the result text."""
args = {"command": command, "run_in_background": True, **extra_args}
prepared = session._prepare_bash(call_id, args)
assert "error" not in prepared, prepared.get("error")
_cid, output = prepared["execute"](prepared)
return output
def _only_shell(session):
shells = session._background_shells.shells()
assert len(shells) == 1
return shells[0]
# ---------------------------------------------------------------------------
# bash: run_in_background routing
# ---------------------------------------------------------------------------
def test_prepare_bash_background_keeps_approval_gate(session):
prepared = session._prepare_bash("c1", {"command": "sleep 30", "run_in_background": True})
assert prepared["needs_approval"] is True
assert prepared["approval_label"] == "bash"
def test_prepare_bash_background_header_says_background(session):
prepared = session._prepare_bash("c1", {"command": "sleep 30", "run_in_background": True})
assert "background" in prepared["header"]
def test_background_bash_returns_immediately_with_handle(session):
start = time.monotonic()
output = _start_background(session, "sleep 30")
elapsed = time.monotonic() - start
assert elapsed < 5, f"backgrounded call blocked for {elapsed:.1f}s"
assert "bash_1" in output
shell = _only_shell(session)
assert shell.status == "running"
assert _pid_alive(shell.pid)
def test_background_start_mentions_reader_and_killer(session):
"""The immediate result must teach the follow-up tools — weak-prior
models (GPT-5.6) only reach for the poll pattern if the result names it."""
output = _start_background(session, "sleep 30")
assert "bash_output" in output
assert "kill_shell" in output
def test_is_background_alias_accepted(session):
output = _start_background(session, "sleep 30", is_background=True)
assert "bash_1" in output
assert _only_shell(session).status == "running"
def test_foreground_bash_routing_unchanged(session):
prepared = session._prepare_bash("c1", {"command": "echo hi"})
assert prepared["execute"] == session._exec_bash
prepared_false = session._prepare_bash("c2", {"command": "echo hi", "run_in_background": False})
assert prepared_false["execute"] == session._exec_bash
def test_background_respects_command_blocklist(session):
prepared = session._prepare_bash("c1", {"command": "shutdown now", "run_in_background": True})
assert "error" in prepared
assert session._background_shells.shells() == []
def test_background_ignores_timeout(session):
"""No bounded wait exists to time out — a 1s timeout must not kill the
detached shell."""
_start_background(session, "sleep 30", timeout=1)
shell = _only_shell(session)
time.sleep(1.5)
assert shell.status == "running"
assert _pid_alive(shell.pid)
def test_background_spawn_failure_reports_error(session, monkeypatch):
from turnstone.core import background_shells as bg_mod
def _boom(*args, **kwargs):
raise OSError("cannot fork")
monkeypatch.setattr(bg_mod.subprocess, "Popen", _boom)
prepared = session._prepare_bash("c1", {"command": "echo hi", "run_in_background": True})
_cid, output = prepared["execute"](prepared)
assert "cannot fork" in output
def test_too_many_background_shells_reports_error(session, monkeypatch):
monkeypatch.setattr(session._background_shells, "_max_shells", 1)
_start_background(session, "sleep 30", call_id="bg1")
output = _start_background(session, "sleep 30", call_id="bg2")
assert "bash_1" in output # the live shell is named so the model can kill it
assert len(session._background_shells.shells()) == 1
# ---------------------------------------------------------------------------
# bash_output
# ---------------------------------------------------------------------------
def test_bash_output_is_auto_approved(session):
prepared = session._prepare_bash_output("c1", {"id": "bash_1"})
assert prepared["needs_approval"] is False
def test_bash_output_missing_id_errors(session):
prepared = session._prepare_bash_output("c1", {})
assert "error" in prepared
def test_bash_output_returns_delta_then_no_new_output(session):
_start_background(session, "echo hello; sleep 30")
shell = _only_shell(session)
assert _wait_until(lambda: shell.status == "running")
def _read():
prepared = session._prepare_bash_output("r", {"id": shell.shell_id})
assert "error" not in prepared
return prepared["execute"](prepared)[1]
assert _wait_until(lambda: "hello" in _read())
again = _read()
assert "hello" not in again
assert "no new output" in again.lower()
assert "running" in again.lower()
def test_bash_output_reports_exit_code_when_completed(session):
_start_background(session, "exit 3")
shell = _only_shell(session)
assert _wait_until(lambda: shell.status == "completed")
prepared = session._prepare_bash_output("r", {"id": shell.shell_id})
_cid, output = prepared["execute"](prepared)
assert "completed" in output.lower()
assert "3" in output
def test_bash_output_filter_applies(session):
_start_background(session, "echo match-a; echo skip-b")
shell = _only_shell(session)
assert _wait_until(lambda: shell.status == "completed")
prepared = session._prepare_bash_output("r", {"id": shell.shell_id, "filter": "^match"})
_cid, output = prepared["execute"](prepared)
assert "match-a" in output
assert "skip-b" not in output
def test_bash_output_invalid_filter_reports_error(session):
_start_background(session, "sleep 30")
shell = _only_shell(session)
prepared = session._prepare_bash_output("r", {"id": shell.shell_id, "filter": "[bad"})
_cid, output = prepared["execute"](prepared)
assert "regex" in output.lower() or "filter" in output.lower()
def test_bash_output_unknown_id_lists_live_shells(session):
_start_background(session, "sleep 30")
prepared = session._prepare_bash_output("r", {"id": "bash_42"})
_cid, output = prepared["execute"](prepared)
assert "bash_42" in output
assert "bash_1" in output
# ---------------------------------------------------------------------------
# kill_shell
# ---------------------------------------------------------------------------
def test_kill_shell_is_auto_approved(session):
prepared = session._prepare_kill_shell("c1", {"id": "bash_1"})
assert prepared["needs_approval"] is False
def test_kill_shell_missing_id_errors(session):
prepared = session._prepare_kill_shell("c1", {})
assert "error" in prepared
def test_kill_shell_kills_and_reports(session):
_start_background(session, "sleep 60")
shell = _only_shell(session)
prepared = session._prepare_kill_shell("k", {"id": shell.shell_id})
_cid, output = prepared["execute"](prepared)
assert "killed" in output.lower()
assert _wait_until(lambda: not _pid_alive(shell.pid))
# The schema promises the exit code for ANY exited state, killed included.
read_prepared = session._prepare_bash_output("r", {"id": shell.shell_id})
_cid, read_output = read_prepared["execute"](read_prepared)
assert "exit code" in read_output
def test_kill_shell_unknown_id_reports_error(session):
prepared = session._prepare_kill_shell("k", {"id": "bash_9"})
_cid, output = prepared["execute"](prepared)
assert "bash_9" in output
# ---------------------------------------------------------------------------
# Exit notices (NudgeQueue, channel "any", wake)
# ---------------------------------------------------------------------------
def test_natural_exit_enqueues_any_channel_notice(session):
_start_background(session, "echo done")
assert _wait_until(
lambda: any(t == "background_shell_exit" for t, _ in session._nudge_queue.pending())
)
entries = session._nudge_queue.pending(channel="any")
texts = [text for t, text in entries if t == "background_shell_exit"]
assert texts, "notice must ride channel 'any' so it can wake an idle workstream"
assert "bash_1" in texts[0]
assert "bash_output" in texts[0]
def test_exit_notice_carries_metadata(session):
_start_background(session, "exit 5")
assert _wait_until(
lambda: any(t == "background_shell_exit" for t, _ in session._nudge_queue.pending())
)
metadata = [
meta
for t, _text, meta in session._nudge_queue.pending_with_metadata()
if t == "background_shell_exit"
][0]
assert metadata["shell_id"] == "bash_1"
assert metadata["exit_code"] == 5
def test_exit_notice_triggers_wake_fn(session):
wakes = []
session._watch_wake_fn = lambda: wakes.append(1)
_start_background(session, "echo done")
assert _wait_until(lambda: wakes), "natural exit must wake an idle workstream"
def test_kill_shell_suppresses_exit_notice(session):
_start_background(session, "sleep 60")
shell = _only_shell(session)
prepared = session._prepare_kill_shell("k", {"id": shell.shell_id})
prepared["execute"](prepared)
assert _wait_until(lambda: not _pid_alive(shell.pid))
time.sleep(0.3) # a buggy late notice would land within this window
assert not any(t == "background_shell_exit" for t, _ in session._nudge_queue.pending())
def test_close_drops_pending_exit_notice_via_valid_until(session):
"""A notice for a shell that no longer exists (registry closed) must not
deliver — the valid_until predicate drops it at drain time."""
_start_background(session, "echo done")
assert _wait_until(
lambda: any(t == "background_shell_exit" for t, _ in session._nudge_queue.pending())
)
session.close()
from turnstone.core.nudge_queue import USER_DRAIN
drained = session._nudge_queue.drain(USER_DRAIN)
assert not any(t == "background_shell_exit" for t, _text, _m in drained)
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
def test_close_reaps_background_shells(session):
_start_background(session, "sleep 60")
shell = _only_shell(session)
session.close()
assert not _pid_alive(shell.pid)
def test_generation_cancel_does_not_reap_background_shells(session):
"""cancel() fires on mere stop-generation — a deliberately-detached
server must survive it. Only close()/kill_shell end it."""
_start_background(session, "sleep 60")
shell = _only_shell(session)
session.cancel()
time.sleep(0.3)
assert _pid_alive(shell.pid), "generation cancel must not kill detached shells"
# ---------------------------------------------------------------------------
# Review-hardening regressions (#817 code review)
# ---------------------------------------------------------------------------
def test_string_typed_background_flag_is_honored(session):
"""Providers intermittently send booleans as strings; 'true' must not
silently fall through to the foreground executor (where the group kill
would reap the server the model believed it detached)."""
for call_id, args in (
("s1", {"command": "sleep 30", "run_in_background": "true"}),
("s2", {"command": "sleep 30", "is_background": "True"}),
):
prepared = session._prepare_bash(call_id, args)
assert prepared["execute"] == session._exec_bash_background, args
def test_kill_shell_on_completed_shell_reports_already_exited(session):
_start_background(session, "true")
shell = _only_shell(session)
assert _wait_until(lambda: shell.status == "completed")
prepared = session._prepare_kill_shell("k", {"id": shell.shell_id})
_cid, output = prepared["execute"](prepared)
assert "already exited" in output.lower()
def test_exit_notice_survives_generation_abandon_without_waking(session):
"""cancel/interrupt/exception clear generation-scoped advisories, but an
external event (a background shell exited) still happened — its notice
must survive to the next seam or the model keeps talking to a dead
server. It survives DEMOTED to 'quiet': still deliverable, but no
longer wake-eligible, so the workstream the user just stopped cannot
resume itself over it."""
from turnstone.core.nudge_queue import USER_DRAIN, WAKE_PENDING
_start_background(session, "echo done")
assert _wait_until(
lambda: any(t == "background_shell_exit" for t, _ in session._nudge_queue.pending())
)
session._queue_tool_advisory("tool_error", "3 consecutive tool errors")
session._drain_pending_advisories()
kinds = [t for t, _ in session._nudge_queue.pending()]
assert "background_shell_exit" in kinds
assert "tool_error" not in kinds
# Post-cancel quiescence: nothing is wake-eligible...
assert not session._nudge_queue.has_pending(WAKE_PENDING)
# ...yet the notice still delivers at the next legitimate seam.
drained = session._nudge_queue.drain(USER_DRAIN)
assert any(t == "background_shell_exit" for t, _x, _m in drained)
def test_int_typed_background_flag_is_honored(session):
prepared = session._prepare_bash("i1", {"command": "sleep 30", "run_in_background": 1})
assert prepared["execute"] == session._exec_bash_background
prepared_zero = session._prepare_bash("i2", {"command": "echo hi", "run_in_background": 0})
assert prepared_zero["execute"] == session._exec_bash
def test_bash_output_non_string_filter_errors_without_consuming(session):
_start_background(session, "echo hello; sleep 30")
shell = _only_shell(session)
prepared = session._prepare_bash_output("r", {"id": shell.shell_id, "filter": 123})
assert "error" in prepared
assert "filter" in prepared["error"].lower()
# Nothing was consumed by the refused call.
assert _wait_until(lambda: shell.unread_lines > 0)
def test_filter_timeout_reports_error_without_consuming(session, monkeypatch):
from turnstone.core.background_shells import FilterTimeoutError
_start_background(session, "sleep 30")
shell = _only_shell(session)
def _boom(*a, **kw):
raise FilterTimeoutError("filter regex took longer than 2s to run")
monkeypatch.setattr(session._background_shells, "read", _boom)
prepared = session._prepare_bash_output("r", {"id": shell.shell_id, "filter": "(a+)+$"})
_cid, output = prepared["execute"](prepared)
assert "filter" in output.lower()
assert "error" in output.lower()
def test_registries_are_isolated_per_session():
"""Workstream isolation: a handle from one session must be unresolvable
from another — buffers, ids, and kills never cross ChatSessions."""
session_a = make_session()
session_b = make_session()
try:
_start_background(session_a, "sleep 30")
shell_a = _only_shell(session_a)
read_b = session_b._prepare_bash_output("r", {"id": shell_a.shell_id})
_cid, output = read_b["execute"](read_b)
assert "no background shell" in output.lower()
kill_b = session_b._prepare_kill_shell("k", {"id": shell_a.shell_id})
_cid, kill_output = kill_b["execute"](kill_b)
assert "no background shell" in kill_output.lower()
assert _pid_alive(shell_a.pid), "another session must not be able to kill the shell"
finally:
session_a.close()
session_b.close()
def test_bash_output_polling_is_repeat_exempt(session):
"""Repeated identical bash_output calls ARE the documented monitoring
pattern — the repeat detector must not brand them 'identical repeat'
(the delta result differs by construction) nor queue a repeat nudge."""
import json as _json
_start_background(session, "sleep 30")
shell = _only_shell(session)
args = _json.dumps({"id": shell.shell_id})
for i in range(5):
tool_calls = [{"id": f"t{i}", "function": {"name": "bash_output", "arguments": args}}]
results = [(f"t{i}", "bash_1 (running)\nNo new output since the last read.")]
session._apply_post_execute_advisories(tool_calls, results)
assert "identical repeat" not in results[0][1]
assert not any(t == "repeat" for t, _ in session._nudge_queue.pending())
def test_repeat_exempt_calls_still_break_other_streaks(session):
"""The exemption suppresses the WARNING, not the recording: a
bash_output poll interleaved between identical bash calls must reset
the bash streak — otherwise the documented monitor-and-probe loop
(poll, curl health, poll, curl health…) draws a false 'identical
repeat' on the probe."""
import json as _json
_start_background(session, "sleep 30")
shell = _only_shell(session)
poll_args = _json.dumps({"id": shell.shell_id})
probe_args = _json.dumps({"command": "curl -s localhost:8080/health"})
for i in range(6):
probe = [{"id": f"p{i}", "function": {"name": "bash", "arguments": probe_args}}]
probe_results = [(f"p{i}", "ok")]
session._apply_post_execute_advisories(probe, probe_results)
assert "identical repeat" not in probe_results[0][1], (
"interleaved probes are not a stuck loop"
)
poll = [{"id": f"q{i}", "function": {"name": "bash_output", "arguments": poll_args}}]
session._apply_post_execute_advisories(poll, [(f"q{i}", "no new output")])
def test_bash_repeats_still_warn(session):
"""The exemption is bash_output-specific: a genuinely stuck identical
bash loop still gets the warning."""
import json as _json
args = _json.dumps({"command": "echo test"})
warned = False
for i in range(5):
tool_calls = [{"id": f"b{i}", "function": {"name": "bash", "arguments": args}}]
results = [(f"b{i}", "test")]
session._apply_post_execute_advisories(tool_calls, results)
warned = warned or "identical repeat" in results[0][1]
assert warned
def test_quiet_only_entries_do_not_trigger_wake_delivery(session, monkeypatch):
"""A dispatched wake whose wake-eligible entries all evaporated must be
a no-op: quiet entries alone never resume a stopped workstream, and
they stay queued for the next legitimate seam."""
calls = []
monkeypatch.setattr(session, "send", lambda *a, **k: calls.append(1))
session._nudge_queue.enqueue("background_shell_exit", "old news", "quiet")
session.deliver_wake_nudge_from_queue()
assert calls == []
assert session._nudge_queue.pending(channel="quiet") == [("background_shell_exit", "old news")]
def test_wake_delivers_quiet_alongside_eligible_in_insertion_order(session, monkeypatch):
"""Quiet entries ride the wake AND cross-channel chronology holds: an
older demoted notice renders before the newer fire that earned the
wake (a poll counter must never run backwards)."""
seen = {}
def _fake_send(*a, **k):
seen["reminders"] = list(session._wake_drained_reminders or [])
session._wake_drained_reminders = None # emulate emission consuming
monkeypatch.setattr(session, "send", _fake_send)
session._nudge_queue.enqueue("background_shell_exit", "old", "quiet")
session._nudge_queue.enqueue("watch_triggered", "new", "any")
session.deliver_wake_nudge_from_queue()
types = [e["type"] for e in seen["reminders"]]
assert types == ["background_shell_exit", "watch_triggered"], (
"older quiet entry must precede the newer wake-eligible one"
)
assert session._nudge_queue.pending() == []
def test_failed_wake_reenqueue_preserves_valid_until(session, monkeypatch):
"""The re-enqueued notice keeps its staleness predicate — a stale
notice re-queued by a failed wake must still be droppable at its next
drain, not delivered against a gone shell."""
from turnstone.core.nudge_queue import USER_DRAIN
alive = {"value": True}
def _fail(*a, **k):
raise RuntimeError("storage down")
monkeypatch.setattr(session, "send", _fail)
session._nudge_queue.enqueue(
"background_shell_exit",
"server died",
"any",
valid_until=lambda: alive["value"],
)
with pytest.raises(RuntimeError):
session.deliver_wake_nudge_from_queue()
assert session._nudge_queue.pending(channel="quiet"), "notice must be re-queued"
alive["value"] = False # the shell record is gone now
drained = session._nudge_queue.drain(USER_DRAIN)
assert drained == [], "stale re-queued notice must drop via its predicate"
def test_mid_emit_failure_restashes_unemitted_tail(session, monkeypatch):
"""A failure while emitting reminder k of n must leave k..n recoverable
— the wake caller's finally re-enqueues them instead of losing the
suffix."""
calls = {"n": 0}
def _append(source, text, **meta):
calls["n"] += 1
if calls["n"] == 2:
raise RuntimeError("storage down")
monkeypatch.setattr(session, "_append_system_turn", _append)
session._wake_drained_reminders = [
{"type": "a", "text": "1"},
{"type": "b", "text": "2"},
{"type": "c", "text": "3"},
]
with pytest.raises(RuntimeError):
session._emit_pending_user_nudges()
assert session._wake_drained_reminders == [
{"type": "b", "text": "2"},
{"type": "c", "text": "3"},
]
def test_failed_wake_reenqueues_undelivered_as_quiet(session, monkeypatch):
"""A wake send that dies before emitting its drained reminders must not
eat them — a shell's exit notice fires exactly once."""
def _fail(*a, **k):
raise RuntimeError("storage down")
monkeypatch.setattr(session, "send", _fail)
session._nudge_queue.enqueue(
"background_shell_exit", "server died", "any", metadata={"shell_id": "bash_1"}
)
with pytest.raises(RuntimeError):
session.deliver_wake_nudge_from_queue()
pending = session._nudge_queue.pending_with_metadata(channel="quiet")
assert [(t, x) for t, x, _m in pending] == [("background_shell_exit", "server died")]
assert pending[0][2] == {"shell_id": "bash_1"}
def test_failed_wake_preserves_chronology_and_stays_wake_quiescent(session, monkeypatch):
"""Failed-wake recovery invariants: (a) the re-queued external notice
keeps its seq, so the retry renders it BEFORE a newer event that
arrived during the failure; (b) NOTHING wake-eligible remains after
the failure — external notices demote to quiet and user-channel
advisories are dropped outright, because a re-armed WAKE_PENDING gate
plus the zero-backoff worker-exit retry would respawn wake workers in
an unbounded hot loop against a persistent failure."""
from turnstone.core.nudge_queue import WAKE_PENDING
calls = {"n": 0}
seen = {}
def _send(*a, **k):
calls["n"] += 1
if calls["n"] == 1:
raise RuntimeError("transient storage failure")
seen["reminders"] = list(session._wake_drained_reminders or [])
session._wake_drained_reminders = None
monkeypatch.setattr(session, "send", _send)
session._nudge_queue.enqueue("watch_triggered", "poll-4", "any")
session._nudge_queue.enqueue("correction", "user advisory", "user")
with pytest.raises(RuntimeError):
session.deliver_wake_nudge_from_queue()
# (b) bounded: nothing left that could re-trigger the wake gate.
assert not session._nudge_queue.has_pending(WAKE_PENDING), (
"a failed wake must not leave wake-eligible entries (respawn hot loop)"
)
assert [t for t, _x in session._nudge_queue.pending(channel="quiet")] == ["watch_triggered"]
# A NEWER event lands after the failure...
session._nudge_queue.enqueue("watch_triggered", "poll-5", "any")
session.deliver_wake_nudge_from_queue()
texts = [e["text"] for e in seen["reminders"]]
# (a) ...and the retry renders old-before-new despite the round trip.
assert texts.index("poll-4") < texts.index("poll-5")
def test_exit_notice_emits_end_to_end_as_system_turn(session):
"""THE test whose absence hid an undeliverable notice for six review
rounds: drive the notice through REAL emission (make_system_turn +
_append_system_turn), not just queue assertions — an unregistered
``_source`` raises ValueError only at this layer."""
_start_background(session, "echo done")
assert _wait_until(
lambda: any(t == "background_shell_exit" for t, _ in session._nudge_queue.pending())
)
from turnstone.core.trajectory import Role
before = len(session.messages)
session._emit_pending_user_nudges() # must not raise
new_turns = session.messages[before:]
assert any(
turn.role is Role.SYSTEM and turn.source == "background_shell_exit" for turn in new_turns
), f"exit notice must land as a first-class system turn, got {new_turns!r}"
def test_cli_exit_closes_every_loaded_session():
"""CLI exit must reap background shells in EVERY workstream, not just
the active one — a server started before /new must not outlive /exit."""
from unittest.mock import MagicMock
from turnstone.cli import _close_all_sessions
ws_a, ws_b, ws_never_loaded = MagicMock(), MagicMock(), MagicMock()
ws_never_loaded.session = None
ws_a.session.close.side_effect = RuntimeError("bad teardown")
manager = MagicMock()
manager.list_all.return_value = [ws_a, ws_b, ws_never_loaded]
_close_all_sessions(manager) # must not raise
ws_a.session.close.assert_called_once()
ws_b.session.close.assert_called_once(), "one bad teardown must not stop the rest"
# Signal phase ran for every loaded session, before any close.
ws_a.session._background_shells.signal_all.assert_called_once()
ws_b.session._background_shells.signal_all.assert_called_once()
def test_cli_exit_ctrl_c_does_not_abort_the_reap():
"""Ctrl-C during the close phase must not escape the helper: the kill
signals already landed on every session in phase 1, and an escaping
KeyboardInterrupt would also skip MCP/registry shutdown in main()."""
from unittest.mock import MagicMock
from turnstone.cli import _close_all_sessions
ws_a, ws_b = MagicMock(), MagicMock()
ws_a.session.close.side_effect = KeyboardInterrupt
manager = MagicMock()
manager.list_all.return_value = [ws_a, ws_b]
_close_all_sessions(manager) # must not raise
ws_a.session._background_shells.signal_all.assert_called_once()
(
ws_b.session._background_shells.signal_all.assert_called_once(),
("signals must land on every session before the interruptible close phase"),
)
def test_non_string_reminder_text_drops_silently(session):
"""A dict reminder with non-str text must drop at the rail, not
TypeError out of the dispatch closure (WatchRunner would re-fire the
row every tick)."""
runner = type(
"R",
(),
{
"set_dispatch_fn": lambda self, ws, fn: None,
"remove_dispatch_fn": lambda self, ws, owner=None: None,
},
)()
session.set_watch_runner(runner)
session._watch_dispatch_fn({"text": 123, "watch_name": "w"}, "watch-1") # must not raise
assert session._nudge_queue.pending() == []
def test_string_typed_stop_on_error_is_honored(session):
"""One coercion dialect for every bash boolean: a string-typed
stop_on_error must add set -e in both branches, not silently drop it."""
fg = session._prepare_bash("f1", {"command": "echo hi", "stop_on_error": "true"})
assert fg["stop_on_error"] is True
bg = session._prepare_bash(
"b1", {"command": "echo hi", "run_in_background": True, "stop_on_error": "true"}
)
assert bg["stop_on_error"] is True
def test_non_dict_watch_reminder_drops_silently(session):
"""The rebuilt dispatch closure must drop a non-dict reminder like the
old code did — a TypeError would make WatchRunner hold and re-fire the
row every tick."""
runner = type(
"R",
(),
{
"set_dispatch_fn": lambda self, ws, fn: None,
"remove_dispatch_fn": lambda self, ws, owner=None: None,
},
)()
session.set_watch_runner(runner)
dispatch = session._watch_dispatch_fn
dispatch("not a dict", "watch-1") # must not raise
assert session._nudge_queue.pending() == []
def test_truthy_flag_dialect_is_unified():
"""One coercion dialect file-wide — 'on' and nonzero numbers count, so a
provider quirk honored on coordinator tools is honored on bash too."""
from turnstone.core.session import _is_truthy_flag
assert _is_truthy_flag(True)
assert _is_truthy_flag("on")
assert _is_truthy_flag(2)
assert not _is_truthy_flag("off")
assert not _is_truthy_flag(0)
assert not _is_truthy_flag(None)
assert not _is_truthy_flag(False)
def test_bash_output_notes_clipped_lines_under_filter(session):
_start_background(session, "printf 'x%.0s' $(seq 1 5000); echo tail")
shell = _only_shell(session)
assert _wait_until(lambda: shell.status == "completed")
prepared = session._prepare_bash_output("r", {"id": shell.shell_id, "filter": "zzz"})
_cid, output = prepared["execute"](prepared)
assert "partially visible" in output
# ---------------------------------------------------------------------------
# task_agent scoping
# ---------------------------------------------------------------------------
def test_task_agent_shells_are_owner_scoped_and_reaped(session, monkeypatch):
seen = {}
def fake_run_agent(agent_turns, label="task", **kwargs):
out = _start_background(session, "sleep 60", call_id="sub-bash")
seen["start_output"] = out
agent_shells = session._background_shells.shells(owner="task-1")
seen["agent_shells"] = list(agent_shells)
seen["pid"] = agent_shells[0].pid if agent_shells else None
# The sub-agent's shell is invisible to the main scope.
seen["visible_to_parent"] = [s.shell_id for s in session._background_shells.shells()]
return "agent done"
monkeypatch.setattr(session, "_run_agent", fake_run_agent)
call_id, result = session._exec_task({"call_id": "task-1", "prompt": "start a server"})
assert "agent done" in result
assert seen["agent_shells"], "shell spawned inside the agent must carry its owner"
# Scope honesty in the start message: the sub-agent must not promise its
# caller a server that dies the moment it returns.
assert "terminated when the agent finishes" in seen["start_output"]
assert seen["visible_to_parent"] == []
assert seen["pid"] is not None
assert _wait_until(lambda: not _pid_alive(seen["pid"])), (
"sub-agent shells must be reaped when the agent finishes"
)
def test_task_agent_cannot_touch_parent_shells(session, monkeypatch):
_start_background(session, "sleep 60", call_id="parent-bash")
parent_shell = _only_shell(session)
seen = {}
def fake_run_agent(agent_turns, label="task", **kwargs):
prepared = session._prepare_bash_output("r", {"id": parent_shell.shell_id})
seen["read_output"] = prepared["execute"](prepared)[1]
prepared_kill = session._prepare_kill_shell("k", {"id": parent_shell.shell_id})
seen["kill_output"] = prepared_kill["execute"](prepared_kill)[1]
return "done"
monkeypatch.setattr(session, "_run_agent", fake_run_agent)
session._exec_task({"call_id": "task-1", "prompt": "snoop"})
assert "no background shell" in seen["read_output"].lower()
assert "no background shell" in seen["kill_output"].lower()
assert _pid_alive(parent_shell.pid), "agent must not be able to kill a parent shell"
def test_parent_scope_restored_after_task_agent(session, monkeypatch):
monkeypatch.setattr(session, "_run_agent", lambda *a, **k: "done")
session._exec_task({"call_id": "task-1", "prompt": "noop"})
output = _start_background(session, "sleep 30", call_id="after-task")
assert "bash_1" in output
assert _only_shell(session).owner is None