Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions s16_team_protocols/code.py
Original file line number Diff line number Diff line change
Expand Up @@ -547,22 +547,34 @@ def run():
inbox = BUS.read_inbox(name)
if not inbox:
continue
should_resume = False
idle_messages = []
for msg in inbox:
if msg.get("type") in ("shutdown_request", "plan_approval_response"):
msg_type = msg.get("type")
if msg_type in ("shutdown_request", "plan_approval_response"):
should_stop = handle_inbox_message(name, msg, messages)
if should_stop:
shutdown_requested = True
break
if msg_type == "plan_approval_response":
should_resume = True
else:
non_protocol.append(msg)
idle_messages.append(msg)
if shutdown_requested:
break
if non_protocol:
inbox_json = json.dumps(non_protocol)
if idle_messages:
inbox_json = json.dumps(idle_messages)
messages.append({"role": "user",
"content": "<inbox>" + inbox_json + "</inbox>"})
should_resume = True
if should_resume:
break # back to LLM turn with new messages

# A non-tool response has no tool calls to execute. Start a new
# LLM turn after inbox activity instead of appending an empty
# tool_result user message for the previous response.
continue

# Execute tool calls
results = []
for block in response.content:
Expand Down
138 changes: 138 additions & 0 deletions tests/test_s16_team_protocols.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
from __future__ import annotations

import importlib.util
import os
import sys
import tempfile
import threading
import time
import types
from pathlib import Path


REPO_ROOT = Path(__file__).resolve().parents[1]
S16_PATH = REPO_ROOT / "s16_team_protocols" / "code.py"


def load_s16(temp_cwd: Path):
fake_anthropic = types.ModuleType("anthropic")

class FakeAnthropic:
def __init__(self, *args, **kwargs):
self.messages = types.SimpleNamespace(create=None)

fake_dotenv = types.ModuleType("dotenv")
fake_anthropic.Anthropic = FakeAnthropic
fake_dotenv.load_dotenv = lambda override=True: None

previous_anthropic = sys.modules.get("anthropic")
previous_dotenv = sys.modules.get("dotenv")
previous_cwd = Path.cwd()
previous_model = os.environ.get("MODEL_ID")

spec = importlib.util.spec_from_file_location("s16_under_test", S16_PATH)
if spec is None or spec.loader is None:
raise RuntimeError(f"Unable to load {S16_PATH}")
module = importlib.util.module_from_spec(spec)

sys.modules["anthropic"] = fake_anthropic
sys.modules["dotenv"] = fake_dotenv
sys.modules[spec.name] = module
os.environ["MODEL_ID"] = "test-model"
try:
os.chdir(temp_cwd)
spec.loader.exec_module(module)
return module
finally:
os.chdir(previous_cwd)
if previous_anthropic is None:
sys.modules.pop("anthropic", None)
else:
sys.modules["anthropic"] = previous_anthropic
if previous_dotenv is None:
sys.modules.pop("dotenv", None)
else:
sys.modules["dotenv"] = previous_dotenv
if previous_model is None:
os.environ.pop("MODEL_ID", None)
else:
os.environ["MODEL_ID"] = previous_model


def text_response(text: str):
return types.SimpleNamespace(
content=[types.SimpleNamespace(type="text", text=text)],
stop_reason="end_turn",
)


def wait_until(predicate, timeout: float = 2.0) -> bool:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return True
time.sleep(0.01)
return predicate()


def test_plan_approval_wakes_idle_teammate_without_empty_user_message():
with tempfile.TemporaryDirectory() as tmp:
module = load_s16(Path(tmp))
module.time = types.SimpleNamespace(
sleep=lambda _seconds: time.sleep(0.01),
time=time.time,
)

first_call = threading.Event()
second_call = threading.Event()
captured_messages = []

def create(**kwargs):
captured_messages.append(list(kwargs["messages"]))
if len(captured_messages) == 1:
first_call.set()
return text_response("Waiting for plan review.")
if len(captured_messages) == 2:
second_call.set()
return text_response("Approval received.")
raise AssertionError("unexpected extra LLM call")

module.client.messages.create = create
module.spawn_teammate_thread("bob", "developer", "Submit a plan.")

assert first_call.wait(1)
module.BUS.send(
"lead",
"bob",
"Approved",
"plan_approval_response",
{"request_id": "req_test", "approve": True},
)

assert second_call.wait(2), "plan approval should resume the idle teammate"
resumed_messages = captured_messages[1]
assert any(
message.get("role") == "user"
and message.get("content") == "[Plan approved] Proceed with the task."
for message in resumed_messages
)
assert not any(
message.get("role") == "user" and message.get("content") == []
for message in resumed_messages
)

module.BUS.send(
"lead",
"bob",
"Shut down",
"shutdown_request",
{"request_id": "req_shutdown"},
)
assert wait_until(lambda: "bob" not in module.active_teammates)

lead_messages = module.BUS.read_inbox("lead")
assert any(
message.get("type") == "shutdown_response"
and message.get("metadata", {}).get("request_id") == "req_shutdown"
for message in lead_messages
)