Files
liqiang b119135836
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
ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
2026-08-20 13:12:50 +00:00

336 lines
11 KiB
Python

import asyncio
from unittest.mock import AsyncMock, patch
import pytest
import demo
from browser import RecoverableFillError
from bus import MessageBus
from models import DecisionRecord, FieldSpec
from orchestration import (
ComputerAgent,
initiate_phone_call_agent,
run_parallel,
timing_evidence,
)
from voice import ScriptedPhoneChannel
class FakeBrowser:
def __init__(self):
self.values = {}
self.submit_enabled = False
async def fill(self, field, value):
await asyncio.sleep(0.05)
self.values[field.name] = value
async def submit(self):
return False
class SubmittingFakeBrowser(FakeBrowser):
def __init__(self):
super().__init__()
self.submit_enabled = True
self.submit_calls = 0
async def submit(self):
self.submit_calls += 1
return True
class FailingFillBrowser(FakeBrowser):
def __init__(self):
super().__init__()
self.submit_calls = 0
async def fill(self, field, value):
raise RecoverableFillError(f"cannot fill {field.name}")
async def submit(self):
self.submit_calls += 1
return True
@pytest.mark.asyncio
async def test_ask_one_fill_one_runs_concurrently_and_reasks_invalid_format():
fields = [
FieldSpec("email", "邮箱", "email", format_hint="name@example.com"),
FieldSpec("birth", "出生日期", "date", format_hint="YYYY-MM-DD"),
]
decision = DecisionRecord(
page_url="https://example.test/register",
page_title="Register",
known_fields=[],
discovered_fields=fields,
tool_called="initiate_phone_call_agent",
purpose="协助填写注册表单",
required_info=fields,
rationale_summary="tool call",
model="test",
monotonic_seconds=0,
)
# First email answer is invalid, forcing format feedback and a real re-ask.
channel = ScriptedPhoneChannel(["bad", "me@example.com", "2020-01-02"])
bus = MessageBus()
browser = FakeBrowser()
extracted = AsyncMock(side_effect=["bad", "me@example.com", "2020-01-02"])
agents = initiate_phone_call_agent(
decision=decision,
bus=bus,
channel=channel,
browser=browser,
known_values={},
)
with patch("orchestration._extract_value", extracted):
result = await run_parallel(agents, bus)
assert result["errors"] == []
assert browser.values == {"email": "me@example.com", "birth": "2020-01-02"}
assert any(m.type == "format_invalid" for m in bus.history)
evidence = timing_evidence(bus)
assert any(c["next_question_before_fill_completed"] for c in evidence["overlap_checks"])
def test_field_validation_handles_email_phone_date_and_page_pattern():
assert not FieldSpec("e", "email", "email").validate("bad")[0]
assert FieldSpec("e", "email", "email").validate("a@b.com")[0]
assert not FieldSpec("d", "date", "date").validate("01/02/2020")[0]
assert FieldSpec("p", "code", pattern=r"[A-Z]{2}\d{4}").validate("AB1234")[0]
@pytest.mark.asyncio
async def test_live_transport_without_consent_refuses_before_browser_or_audio_creation():
args = demo.parser().parse_args(["--headless", "--phone-transport", "local"])
with patch("demo.RegistrationBrowser") as browser_type, patch(
"demo.LiveMicrophoneChannel"
) as audio_type:
with pytest.raises(SystemExit, match="confirm-consent"):
await demo.main(args)
browser_type.assert_not_called()
audio_type.assert_not_called()
@pytest.mark.asyncio
async def test_unexpected_phone_failure_cancels_peer_and_closes_channel():
class ExplodingChannel:
def __init__(self):
self.closed = False
async def say(self, _text):
raise RuntimeError("synthetic transport failure")
async def listen(self, *, timeout=30): # pragma: no cover - say fails first
raise AssertionError(timeout)
async def close(self):
self.closed = True
fields = [FieldSpec("email", "邮箱", "email")]
decision = DecisionRecord(
page_url="https://example.test/register",
page_title="Register",
known_fields=[],
discovered_fields=fields,
tool_called="initiate_phone_call_agent",
purpose="协助填写注册表单",
required_info=fields,
rationale_summary="tool call",
model="test",
monotonic_seconds=0,
)
bus = MessageBus()
channel = ExplodingChannel()
agents = initiate_phone_call_agent(
decision=decision,
bus=bus,
channel=channel,
browser=FakeBrowser(),
known_values={},
)
with pytest.raises(RuntimeError, match="synthetic transport failure"):
await asyncio.wait_for(run_parallel(agents, bus), timeout=0.5)
assert channel.closed is True
assert not any(
task.get_name() in {"phone-agent-react-loop", "computer-agent-react-loop"}
and not task.done()
for task in asyncio.all_tasks()
)
@pytest.mark.asyncio
async def test_task_completed_triggers_submission_when_browser_is_opted_in():
fields = [FieldSpec("email", "邮箱", "email")]
decision = DecisionRecord(
page_url="https://example.test/register",
page_title="Register",
known_fields=[],
discovered_fields=fields,
tool_called="initiate_phone_call_agent",
purpose="协助填写注册表单",
required_info=fields,
rationale_summary="tool call",
model="test",
monotonic_seconds=0,
)
browser = SubmittingFakeBrowser()
bus = MessageBus()
agents = initiate_phone_call_agent(
decision=decision,
bus=bus,
channel=ScriptedPhoneChannel(["me@example.com"]),
browser=browser,
known_values={},
)
with patch("orchestration._extract_value", AsyncMock(return_value="me@example.com")):
result = await run_parallel(agents, bus)
assert result["submitted"] is True
assert browser.submit_calls == 1
@pytest.mark.asyncio
async def test_computer_fill_error_flows_back_to_phone_and_blocks_submission():
fields = [FieldSpec("email", "邮箱", "email")]
decision = DecisionRecord(
page_url="https://example.test/register",
page_title="Register",
known_fields=[],
discovered_fields=fields,
tool_called="initiate_phone_call_agent",
purpose="协助填写注册表单",
required_info=fields,
rationale_summary="tool call",
model="test",
monotonic_seconds=0,
)
browser = FailingFillBrowser()
channel = ScriptedPhoneChannel(["me@example.com"])
bus = MessageBus()
agents = initiate_phone_call_agent(
decision=decision,
bus=bus,
channel=channel,
browser=browser,
known_values={},
)
with patch("orchestration._extract_value", AsyncMock(return_value="me@example.com")):
result = await run_parallel(agents, bus)
assert result["submitted"] is False
assert browser.submit_calls == 0
assert agents.phone.browser_feedback == [
{"field": "email", "error": "cannot fill email"}
]
fill_error = next(message for message in bus.history if message.type == "fill_error")
assert getattr(fill_error, "_sensitive_keys") == ("error",)
assert "填写遇到问题" in channel.prompts[-1]
@pytest.mark.asyncio
async def test_malformed_info_collected_fails_before_fill_error_handling():
bus = MessageBus()
computer = ComputerAgent(bus, FakeBrowser(), [], {})
task = asyncio.create_task(computer.run())
await bus.send(
"phone_agent",
"computer_agent",
"info_collected",
sensitive_keys=("value",),
value="secret",
)
with pytest.raises(ValueError, match="non-empty field"):
await task
assert not any(message.type == "fill_error" for message in bus.history)
@pytest.mark.asyncio
async def test_optional_blank_is_audited_skip_and_never_written_to_browser():
fields = [
FieldSpec("email", "邮箱", "email"),
FieldSpec("birthday", "生日", "text", required=False),
FieldSpec("address", "地址", "textarea", required=False),
]
decision = DecisionRecord(
page_url="https://example.test/register",
page_title="Register",
known_fields=[],
discovered_fields=fields,
tool_called="initiate_phone_call_agent",
purpose="协助填写注册表单",
required_info=fields,
rationale_summary="tool call",
model="test",
monotonic_seconds=0,
)
browser = FakeBrowser()
bus = MessageBus()
agents = initiate_phone_call_agent(
decision=decision,
bus=bus,
channel=ScriptedPhoneChannel(["me@example.com", "", ""]),
browser=browser,
known_values={},
)
with patch("orchestration._extract_value", AsyncMock(return_value="me@example.com")) as extract:
result = await run_parallel(agents, bus)
assert result["errors"] == []
assert browser.values == {"email": "me@example.com"}
assert extract.await_count == 1
skipped = [message for message in bus.history if message.type == "info_skipped"]
assert [message.payload["field"] for message in skipped] == ["birthday", "address"]
evidence = timing_evidence(bus)
assert evidence["expected_overlap_count"] == 1
assert len(evidence["overlap_checks"]) == 1
class CountryFailBrowser(SubmittingFakeBrowser):
"""Fails only the pre-filled known field, succeeds on the phone-collected one."""
async def fill(self, field, value):
if field.name == "country":
raise RecoverableFillError(f"cannot fill {field.name}")
await asyncio.sleep(0.05)
self.values[field.name] = value
@pytest.mark.asyncio
async def test_known_value_fill_error_flows_back_to_phone_and_blocks_submission():
# A known_values field that fails to pre-fill must surface a fill_error (like
# the in-dialogue path), so the phone agent reports the failure instead of
# telling the user registration completed.
email = FieldSpec("email", "邮箱", "email")
country = FieldSpec("country", "国家", "text")
decision = DecisionRecord(
page_url="https://example.test/register",
page_title="Register",
known_fields=[country.name],
discovered_fields=[email, country],
tool_called="initiate_phone_call_agent",
purpose="协助填写注册表单",
required_info=[email],
rationale_summary="tool call",
model="test",
monotonic_seconds=0,
)
browser = CountryFailBrowser()
channel = ScriptedPhoneChannel(["me@example.com"])
bus = MessageBus()
agents = initiate_phone_call_agent(
decision=decision,
bus=bus,
channel=channel,
browser=browser,
known_values={"country": "US"},
)
with patch("orchestration._extract_value", AsyncMock(return_value="me@example.com")):
result = await run_parallel(agents, bus)
assert result["submitted"] is False
assert browser.submit_calls == 0
assert {"field": "country", "error": "cannot fill country"} in agents.phone.browser_feedback
assert any(message.type == "fill_error" for message in bus.history)
assert "填写遇到问题" in channel.prompts[-1]