Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
222 lines
8.9 KiB
Python
222 lines
8.9 KiB
Python
import json
|
|
import sys
|
|
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
import demo
|
|
from werewolf import agent as agent_module
|
|
from werewolf.game import Judge, create_players
|
|
from werewolf.roles import Role
|
|
from werewolf.agent import PlayerAgent
|
|
from werewolf.simulator import SimulatedUserPlayerAgent, SimulatedVoiceSession
|
|
|
|
|
|
class FakeVoice:
|
|
def __init__(self, transcripts=None):
|
|
self.events = []
|
|
self.transcripts = iter(transcripts or [])
|
|
self.spoken = []
|
|
|
|
def say(self, speaker, text, round_no, allow_barge_in=False):
|
|
self.spoken.append((speaker, text, round_no))
|
|
|
|
def record_llm_decision(self, **data):
|
|
self.events.append({"type": "simulator_llm_tool", **data})
|
|
|
|
def roundtrip_user(self, speaker, text, round_no):
|
|
self.spoken.append((speaker, text, round_no))
|
|
return next(self.transcripts)
|
|
|
|
def _event(self, type_, **data):
|
|
self.events.append({"type": type_, **data})
|
|
|
|
|
|
def tool_response(name, arguments):
|
|
call = SimpleNamespace(
|
|
function=SimpleNamespace(name=name, arguments=json.dumps(arguments, ensure_ascii=False))
|
|
)
|
|
message = SimpleNamespace(tool_calls=[call])
|
|
return SimpleNamespace(
|
|
id="response-real-shape",
|
|
model="provider-model",
|
|
usage=SimpleNamespace(model_dump=lambda: {"prompt_tokens": 10, "completion_tokens": 3}),
|
|
choices=[SimpleNamespace(message=message)],
|
|
)
|
|
|
|
|
|
def test_simulated_user_is_one_randomized_protected_user_seat():
|
|
voice = FakeVoice()
|
|
players = create_players(
|
|
seed=42,
|
|
players=7,
|
|
wolves=2,
|
|
simulated_user_seat=1,
|
|
voice=voice,
|
|
)
|
|
assert sum(isinstance(player, SimulatedUserPlayerAgent) for player in players) == 1
|
|
assert sum(getattr(player, "is_user", False) for player in players) == 1
|
|
assert players[0].role in set(Role)
|
|
|
|
judge = Judge(players, seed=42)
|
|
judge.assign_identities()
|
|
assert any("您的身份" in text for _, text, _ in voice.spoken)
|
|
|
|
|
|
def test_simulator_uses_private_context_tool_and_only_asr_speech(monkeypatch):
|
|
voice = FakeVoice(transcripts=["ASR 后的公开发言"])
|
|
player = SimulatedUserPlayerAgent("P1", Role.VILLAGER, voice, model="test/model")
|
|
player.observe("仅 P1 可见的记忆")
|
|
captured = {}
|
|
response = tool_response("speak_publicly", {"utterance": "LLM 原始公开发言"})
|
|
monkeypatch.setattr(agent_module, "get_client", lambda: object())
|
|
|
|
def fake_create(_client, **kwargs):
|
|
captured.update(kwargs)
|
|
return response
|
|
|
|
monkeypatch.setattr(agent_module, "_safe_create", fake_create)
|
|
|
|
speech = player.speak(["P1", "P2"])
|
|
|
|
assert speech == "ASR 后的公开发言"
|
|
assert "仅 P1 可见的记忆" in captured["messages"][1]["content"]
|
|
assert captured["tool_choice"]["function"]["name"] == "speak_publicly"
|
|
assert captured["tools"][0]["function"]["parameters"]["additionalProperties"] is False
|
|
assert voice.spoken[-1][1] == "LLM 原始公开发言"
|
|
assert voice.events[0]["tool"] == "speak_publicly"
|
|
|
|
|
|
def test_simulator_fails_closed_when_asr_changes_selected_action(monkeypatch):
|
|
voice = FakeVoice(transcripts=["I choose player three"])
|
|
player = SimulatedUserPlayerAgent("P1", Role.VILLAGER, voice)
|
|
response = tool_response("choose_player", {"target": "P2", "reason": "公开证据"})
|
|
monkeypatch.setattr(agent_module, "get_client", lambda: object())
|
|
monkeypatch.setattr(agent_module, "_safe_create", lambda _client, **kwargs: response)
|
|
|
|
with pytest.raises(RuntimeError, match="speech boundary changed"):
|
|
player.vote(["P2", "P3"], ["P1", "P2", "P3"])
|
|
|
|
mismatch = voice.events[-1]
|
|
assert mismatch["type"] == "simulator_action_mismatch"
|
|
assert mismatch["tool_target"] == "P2"
|
|
assert mismatch["parsed_target"] == "P3"
|
|
|
|
|
|
def test_simulator_fails_closed_when_abstention_is_not_explicit_in_asr(monkeypatch):
|
|
voice = FakeVoice(transcripts=["P1 is not"])
|
|
player = SimulatedUserPlayerAgent("P1", Role.VILLAGER, voice)
|
|
response = tool_response("choose_player", {"target": "none", "reason": "not enough evidence"})
|
|
monkeypatch.setattr(agent_module, "get_client", lambda: object())
|
|
monkeypatch.setattr(agent_module, "_safe_create", lambda _client, **kwargs: response)
|
|
|
|
with pytest.raises(RuntimeError, match="speech boundary changed"):
|
|
player.vote(["P2", "P3"], ["P1", "P2", "P3"])
|
|
|
|
assert voice.spoken[-1][1] == "I choose to abstain."
|
|
assert voice.events[-1]["type"] == "simulator_action_mismatch"
|
|
|
|
|
|
def test_judge_retains_llm_decision_reason_in_action_evidence(monkeypatch):
|
|
voice = FakeVoice(transcripts=["I choose player two"])
|
|
simulator = SimulatedUserPlayerAgent("P1", Role.VILLAGER, voice)
|
|
response = tool_response(
|
|
"choose_player", {"target": "P2", "reason": "P2 contradicted the public vote record"}
|
|
)
|
|
monkeypatch.setattr(agent_module, "get_client", lambda: object())
|
|
monkeypatch.setattr(agent_module, "_safe_create", lambda _client, **kwargs: response)
|
|
target = simulator.vote(["P2"], ["P1", "P2"])
|
|
record = Judge._decision_record(
|
|
simulator, round=1, phase="vote", actor="P1", role="村民",
|
|
action="vote", target=target,
|
|
)
|
|
assert record["reason"] == "P2 contradicted the public vote record"
|
|
assert simulator.last_decision_reason is None
|
|
|
|
|
|
def test_good_faction_vote_prompt_prioritizes_uncontested_seer_evidence(monkeypatch):
|
|
player = PlayerAgent("P7", Role.VILLAGER)
|
|
captured = {}
|
|
|
|
def fake_chat(instruction, players, max_tokens, json_mode=False):
|
|
captured["instruction"] = instruction
|
|
return '{"target": "P5", "reason": "P4 is uncontested and checked P5 as a wolf"}'
|
|
|
|
monkeypatch.setattr(player, "_chat", fake_chat)
|
|
assert player.vote(["P4", "P5"], ["P4", "P5", "P7"]) == "P5"
|
|
assert "不得投该声明者" in captured["instruction"]
|
|
assert "被查杀者仅仅否认并不构成" in captured["instruction"]
|
|
|
|
|
|
def test_reasoning_model_empty_content_retries_with_larger_bounded_budget(monkeypatch):
|
|
player = PlayerAgent("P7", Role.VILLAGER)
|
|
calls = []
|
|
responses = iter([
|
|
SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content=""))]),
|
|
SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content="visible speech"))]),
|
|
])
|
|
monkeypatch.setattr(agent_module, "get_client", lambda: object())
|
|
|
|
def fake_create(_client, **kwargs):
|
|
calls.append(kwargs)
|
|
return next(responses)
|
|
|
|
monkeypatch.setattr(agent_module, "_safe_create", fake_create)
|
|
assert player._chat("speak", ["P7"], max_tokens=180) == "visible speech"
|
|
assert calls[0]["max_tokens"] == 512
|
|
assert calls[1]["max_tokens"] == 2048
|
|
|
|
|
|
def test_simulator_vote_prompt_uses_the_same_evidence_priority(monkeypatch):
|
|
voice = FakeVoice(transcripts=["I choose player five"])
|
|
player = SimulatedUserPlayerAgent("P1", Role.VILLAGER, voice)
|
|
captured = {}
|
|
response = tool_response(
|
|
"choose_player", {"target": "P5", "reason": "P4 is the only Seer and checked P5"}
|
|
)
|
|
monkeypatch.setattr(agent_module, "get_client", lambda: object())
|
|
|
|
def fake_create(_client, **kwargs):
|
|
captured.update(kwargs)
|
|
return response
|
|
|
|
monkeypatch.setattr(agent_module, "_safe_create", fake_create)
|
|
assert player.vote(["P4", "P5"], ["P1", "P4", "P5"]) == "P5"
|
|
instruction = captured["messages"][1]["content"]
|
|
assert "不得投该声明者" in instruction
|
|
assert "被查杀者仅仅否认不构成" in instruction
|
|
|
|
|
|
def test_system_speech_falls_back_to_macos_say(monkeypatch, tmp_path):
|
|
from werewolf import simulator as simulator_module
|
|
|
|
paths = {"espeak-ng": None, "espeak": None, "say": "/usr/bin/say", "ffmpeg": "/opt/bin/ffmpeg"}
|
|
monkeypatch.setattr(simulator_module.shutil, "which", lambda name: paths.get(name))
|
|
monkeypatch.setenv("OPENROUTER_API_KEY", "configured-for-test")
|
|
|
|
def fake_run(command, **kwargs):
|
|
if command[0] == "/usr/bin/say":
|
|
output = command[command.index("-o") + 1]
|
|
else:
|
|
output = command[-1]
|
|
from pathlib import Path
|
|
Path(output).write_bytes(b"real-audio-shaped-bytes")
|
|
return SimpleNamespace(returncode=0)
|
|
|
|
monkeypatch.setattr(simulator_module.subprocess, "run", fake_run)
|
|
session = SimulatedVoiceSession(str(tmp_path), provider="openrouter-system")
|
|
path = session._synthesize("P1", "I choose player four.", 1)
|
|
assert path.read_bytes() == b"real-audio-shaped-bytes"
|
|
assert session.events[0]["model"] == "macos-say-Samantha"
|
|
|
|
|
|
def test_simulated_user_cli_needs_no_human_consent(monkeypatch):
|
|
monkeypatch.setenv("OPENROUTER_API_KEY", "configured-for-test")
|
|
monkeypatch.setenv("GEMINI_API_KEY", "configured-for-test")
|
|
monkeypatch.setattr(sys, "argv", ["demo.py", "--simulate-user"])
|
|
with patch("demo.run_game", return_value=True) as run_game:
|
|
demo.main()
|
|
assert run_game.call_args.args[0].simulate_user is True
|
|
assert run_game.call_args.args[0].confirm_human_consent is False
|