570 lines
18 KiB
Python
570 lines
18 KiB
Python
import asyncio
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from anthropic.types import (
|
|
RawContentBlockDeltaEvent,
|
|
RawContentBlockStartEvent,
|
|
RawContentBlockStopEvent,
|
|
RawMessageDeltaEvent,
|
|
RawMessageStartEvent,
|
|
RawMessageStopEvent,
|
|
)
|
|
from claude_agent_sdk import (
|
|
AssistantMessage,
|
|
InMemorySessionStore,
|
|
ResultMessage,
|
|
StreamEvent,
|
|
TextBlock,
|
|
ToolResultBlock,
|
|
ToolUseBlock,
|
|
UserMessage,
|
|
project_key_for_directory,
|
|
)
|
|
|
|
from beaver_gateway.agents.base import ExposedMcp
|
|
from beaver_gateway.agents.claude import ClaudeAgent, ClaudeOptions, Prompts, SkillSets
|
|
from beaver_gateway.backends.claude_sdk import (
|
|
ClaudeSdkBackend,
|
|
RunnerConfig,
|
|
UsageEvent,
|
|
fingerprint,
|
|
)
|
|
from beaver_gateway.backends.transcript import messages_from_entries
|
|
from beaver_gateway.backends.capture import TurnCapture
|
|
|
|
|
|
def _stream(index: int, text: str) -> list[StreamEvent]:
|
|
def ev(event: dict[str, Any]) -> StreamEvent:
|
|
return StreamEvent(uuid="u", session_id="s", event=event)
|
|
|
|
return [
|
|
ev(
|
|
{
|
|
"type": "content_block_start",
|
|
"index": index,
|
|
"content_block": {"type": "text", "text": ""},
|
|
}
|
|
),
|
|
ev(
|
|
{
|
|
"type": "content_block_delta",
|
|
"index": index,
|
|
"delta": {"type": "text_delta", "text": text},
|
|
}
|
|
),
|
|
ev({"type": "content_block_stop", "index": index}),
|
|
]
|
|
|
|
|
|
def _result(session_id: str) -> ResultMessage:
|
|
return ResultMessage(
|
|
subtype="success",
|
|
duration_ms=10,
|
|
duration_api_ms=5,
|
|
is_error=False,
|
|
num_turns=1,
|
|
session_id=session_id,
|
|
stop_reason="end_turn",
|
|
total_cost_usd=0.01,
|
|
usage={
|
|
"input_tokens": 3,
|
|
"output_tokens": 7,
|
|
"cache_read_input_tokens": 100,
|
|
"cache_creation_input_tokens": 20,
|
|
},
|
|
)
|
|
|
|
|
|
class FakeClient:
|
|
instances: list["FakeClient"] = []
|
|
|
|
def __init__(self, options: Any) -> None:
|
|
self.options = options
|
|
self.prompts: list[str] = []
|
|
self.connected = False
|
|
self.session_id = options.resume or "fresh-session"
|
|
FakeClient.instances.append(self)
|
|
|
|
async def connect(self) -> None:
|
|
self.connected = True
|
|
|
|
async def query(self, prompt: str) -> None:
|
|
self.prompts.append(prompt)
|
|
|
|
async def interrupt(self) -> None:
|
|
self.interrupted = True
|
|
|
|
async def receive_response(self):
|
|
start = {
|
|
"type": "message_start",
|
|
"message": {
|
|
"usage": {"input_tokens": 5, "cache_read_input_tokens": 20_000}
|
|
},
|
|
}
|
|
yield StreamEvent(uuid="u", session_id="s", event=start)
|
|
for e in _stream(0, "calling "):
|
|
yield e
|
|
tool = ToolUseBlock(id="toolu_1", name="Read", input={"path": "x"})
|
|
yield StreamEvent(
|
|
uuid="u",
|
|
session_id="s",
|
|
event={
|
|
"type": "content_block_start",
|
|
"index": 1,
|
|
"content_block": {"type": "tool_use", "id": "toolu_1", "name": "Read"},
|
|
},
|
|
)
|
|
yield StreamEvent(
|
|
uuid="u", session_id="s", event={"type": "content_block_stop", "index": 1}
|
|
)
|
|
yield AssistantMessage(content=[TextBlock(text="calling "), tool], model="m")
|
|
yield UserMessage(
|
|
content=[ToolResultBlock(tool_use_id="toolu_1", content="42")]
|
|
)
|
|
second = {
|
|
"type": "message_start",
|
|
"message": {
|
|
"usage": {
|
|
"input_tokens": 7,
|
|
"cache_read_input_tokens": 20_000,
|
|
"cache_creation_input_tokens": 3_000,
|
|
}
|
|
},
|
|
}
|
|
yield StreamEvent(uuid="u", session_id="s", event=second)
|
|
for e in _stream(0, "done"):
|
|
yield e
|
|
yield AssistantMessage(content=[TextBlock(text="done")], model="m")
|
|
yield StreamEvent(
|
|
uuid="sub",
|
|
session_id="s",
|
|
event={"type": "content_block_stop", "index": 5},
|
|
parent_tool_use_id="toolu_9",
|
|
)
|
|
yield _result(self.session_id)
|
|
|
|
async def disconnect(self) -> None:
|
|
self.connected = False
|
|
|
|
|
|
@pytest.fixture
|
|
def cwd() -> Path:
|
|
return Path(tempfile.mkdtemp(prefix="beaver-sdk-"))
|
|
|
|
|
|
def _backend(
|
|
cwd: Path, store: InMemorySessionStore, sink=None, **agent_kwargs
|
|
) -> ClaudeSdkBackend:
|
|
FakeClient.instances.clear()
|
|
agent = ClaudeAgent(
|
|
name="a",
|
|
model="claude-x",
|
|
system_prompt="hi",
|
|
cwd=cwd,
|
|
options=ClaudeOptions(effort="low"),
|
|
**agent_kwargs,
|
|
)
|
|
return ClaudeSdkBackend(
|
|
agent=agent,
|
|
mcp_internal_urls={"firefly": "http://127.0.0.1:1/mcp/firefly/"},
|
|
session_store=store,
|
|
mcp_tool_names={"firefly": ["list_account", "delete_account", "store_account"]},
|
|
usage_sink=sink,
|
|
client_factory=FakeClient,
|
|
work_dir=cwd / "work",
|
|
)
|
|
|
|
|
|
async def _drain(events) -> list[Any]:
|
|
return [e async for e in events]
|
|
|
|
|
|
async def test_stream_envelope_and_index_rebase(cwd: Path) -> None:
|
|
backend = _backend(
|
|
cwd, InMemorySessionStore(), expose_mcps=(ExposedMcp(name="firefly"),)
|
|
)
|
|
capture = TurnCapture()
|
|
events = await _drain(
|
|
backend.complete(
|
|
agent=backend.agent,
|
|
messages=[{"role": "user", "content": "hi"}],
|
|
conversation_id="conv-1",
|
|
capture=capture,
|
|
)
|
|
)
|
|
assert isinstance(events[0], RawMessageStartEvent)
|
|
assert isinstance(events[-1], RawMessageStopEvent)
|
|
assert isinstance(events[-2], RawMessageDeltaEvent)
|
|
assert events[-2].usage.output_tokens == 7
|
|
assert events[-2].usage.cache_read_input_tokens == 100
|
|
starts = [e.index for e in events if isinstance(e, RawContentBlockStartEvent)]
|
|
assert starts == [0, 1, 2]
|
|
stops = [e.index for e in events if isinstance(e, RawContentBlockStopEvent)]
|
|
assert stops == [0, 1, 2]
|
|
deltas = [e.delta.text for e in events if isinstance(e, RawContentBlockDeltaEvent)]
|
|
assert deltas == ["calling ", "done"]
|
|
|
|
assert capture.session_id == "fresh-session"
|
|
assert capture.usage is not None and capture.usage.cost_usd == 0.01
|
|
assert capture.usage.context_tokens == 23_007
|
|
assert capture.synthesized_messages == [
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{"type": "text", "text": "calling "},
|
|
{
|
|
"type": "tool_use",
|
|
"id": "toolu_1",
|
|
"name": "Read",
|
|
"input": {"path": "x"},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "tool_result", "tool_use_id": "toolu_1", "content": "42"}
|
|
],
|
|
},
|
|
{"role": "assistant", "content": [{"type": "text", "text": "done"}]},
|
|
]
|
|
client = FakeClient.instances[0]
|
|
assert client.prompts == ["hi"]
|
|
opts = client.options
|
|
assert opts.setting_sources == []
|
|
assert opts.strict_mcp_config is True
|
|
assert opts.permission_mode == "bypassPermissions"
|
|
assert opts.mcp_servers == {
|
|
"firefly": {"type": "http", "url": "http://127.0.0.1:1/mcp/firefly/"}
|
|
}
|
|
assert opts.session_store is not None
|
|
assert opts.resume is None
|
|
assert Path(opts.cli_path).exists()
|
|
wrapper = Path(opts.cli_path).read_text()
|
|
assert "CLAUDE_" in wrapper and "DATABASE_URL" not in wrapper
|
|
|
|
|
|
async def test_conversation_reuses_live_session(cwd: Path) -> None:
|
|
backend = _backend(cwd, InMemorySessionStore())
|
|
for i in range(2):
|
|
await _drain(
|
|
backend.complete(
|
|
agent=backend.agent,
|
|
messages=[{"role": "user", "content": f"turn {i}"}],
|
|
conversation_id="conv-1",
|
|
)
|
|
)
|
|
assert len(FakeClient.instances) == 1
|
|
assert FakeClient.instances[0].prompts == ["turn 0", "turn 1"]
|
|
assert backend.sessions["conv-1"]["turns"] == 2
|
|
|
|
|
|
async def test_history_is_seeded_into_store_and_resumed(cwd: Path) -> None:
|
|
store = InMemorySessionStore()
|
|
backend = _backend(cwd, store)
|
|
history = [
|
|
{"role": "user", "content": "first"},
|
|
{"role": "assistant", "content": [{"type": "text", "text": "reply"}]},
|
|
{"role": "user", "content": "second"},
|
|
]
|
|
await _drain(
|
|
backend.complete(
|
|
agent=backend.agent, messages=history, conversation_id="conv-2"
|
|
)
|
|
)
|
|
client = FakeClient.instances[0]
|
|
assert client.options.resume is not None
|
|
key = {
|
|
"project_key": project_key_for_directory(str(cwd)),
|
|
"session_id": client.options.resume,
|
|
}
|
|
entries = store.get_entries(key)
|
|
assert messages_from_entries(entries) == history[:2]
|
|
assert client.prompts == ["second"]
|
|
|
|
|
|
async def test_known_session_id_is_resumed_without_seeding(cwd: Path) -> None:
|
|
store = InMemorySessionStore()
|
|
backend = _backend(cwd, store)
|
|
history = [
|
|
{"role": "user", "content": "first"},
|
|
{"role": "assistant", "content": "reply"},
|
|
{"role": "user", "content": "second"},
|
|
]
|
|
await _drain(
|
|
backend.complete(
|
|
agent=backend.agent,
|
|
messages=history,
|
|
conversation_id="conv-3",
|
|
session_id="known-sid",
|
|
)
|
|
)
|
|
assert FakeClient.instances[0].options.resume == "known-sid"
|
|
assert store.size == 0
|
|
|
|
|
|
async def test_stateless_caller_hits_same_session_next_turn(cwd: Path) -> None:
|
|
backend = _backend(cwd, InMemorySessionStore())
|
|
first = [{"role": "user", "content": "hi"}]
|
|
await _drain(backend.complete(agent=backend.agent, messages=first))
|
|
follow_up = [
|
|
*first,
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{"type": "text", "text": "calling "},
|
|
{"type": "text", "text": "done"},
|
|
],
|
|
},
|
|
{"role": "user", "content": "more"},
|
|
]
|
|
assert list(backend.sessions) == [fingerprint(follow_up[:-1])]
|
|
await _drain(backend.complete(agent=backend.agent, messages=follow_up))
|
|
assert len(FakeClient.instances) == 1
|
|
assert FakeClient.instances[0].prompts == ["hi", "more"]
|
|
|
|
|
|
async def test_mcp_deny_and_usage_sink(cwd: Path) -> None:
|
|
seen: list[UsageEvent] = []
|
|
|
|
async def sink(event: UsageEvent) -> None:
|
|
seen.append(event)
|
|
|
|
backend = _backend(
|
|
cwd,
|
|
InMemorySessionStore(),
|
|
sink,
|
|
expose_mcps=(ExposedMcp(name="firefly", deny=("delete_*",)),),
|
|
)
|
|
await _drain(
|
|
backend.complete(
|
|
agent=backend.agent,
|
|
messages=[{"role": "user", "content": "x"}],
|
|
conversation_id="c",
|
|
)
|
|
)
|
|
assert FakeClient.instances[0].options.disallowed_tools == [
|
|
"mcp__firefly__delete_account"
|
|
]
|
|
assert len(seen) == 1
|
|
assert seen[0].usage.input_tokens == 3
|
|
assert seen[0].conversation_id == "c"
|
|
assert seen[0].session_id == "fresh-session"
|
|
|
|
|
|
def _skill_sets(cwd: Path, *names: str) -> tuple[Path, ...]:
|
|
sets = cwd / "skills"
|
|
for name in names:
|
|
(sets / name / "demo").mkdir(parents=True)
|
|
(sets / name / "demo" / "SKILL.md").write_text("---\nname: demo\n---\n")
|
|
return tuple(sets / name for name in names)
|
|
|
|
|
|
async def _plugins_for(backend: ClaudeSdkBackend, kind: str) -> list[Path]:
|
|
await _drain(
|
|
backend.complete(
|
|
agent=backend.agent,
|
|
messages=[{"role": "user", "content": "x"}],
|
|
conversation_id=f"c-{kind}",
|
|
kind=kind,
|
|
)
|
|
)
|
|
plugins = FakeClient.instances[-1].options.plugins
|
|
assert all(p["type"] == "local" for p in plugins)
|
|
paths = [Path(p["path"]) for p in plugins]
|
|
for path in paths:
|
|
assert path.parent.name == kind
|
|
assert path.parent.parent.name == backend.agent.name
|
|
assert (path / ".claude-plugin" / "plugin.json").exists()
|
|
assert (path / "skills" / "demo" / "SKILL.md").exists()
|
|
return paths
|
|
|
|
|
|
async def test_skill_sets_become_sorted_plugins(cwd: Path) -> None:
|
|
zeta, common = _skill_sets(cwd, "zeta", "общие")
|
|
backend = _backend(cwd, InMemorySessionStore(), skill_sets=(zeta, common))
|
|
paths = await _plugins_for(backend, "deep")
|
|
assert [p.name for p in paths] == ["zeta", "общие"]
|
|
assert FakeClient.instances[0].options.skills == "all"
|
|
assert [p.name for p in await _plugins_for(backend, "job")] == ["zeta", "общие"]
|
|
|
|
|
|
async def test_skill_sets_per_kind(cwd: Path) -> None:
|
|
a, b = _skill_sets(cwd, "a", "b")
|
|
backend = _backend(
|
|
cwd,
|
|
InMemorySessionStore(),
|
|
kinds=("master", "branch", "fork"),
|
|
skill_sets=SkillSets(master=(a,), branch=(a, b)),
|
|
)
|
|
master = await _plugins_for(backend, "master")
|
|
branch = await _plugins_for(backend, "branch")
|
|
assert [p.name for p in master] == ["a"]
|
|
assert [p.name for p in branch] == ["a", "b"]
|
|
assert master[0] != branch[0]
|
|
assert await _plugins_for(backend, "fork") == []
|
|
assert FakeClient.instances[-1].options.skills is None
|
|
|
|
|
|
async def test_prompt_sources_are_assembled(cwd: Path) -> None:
|
|
(cwd / "a.md").write_text("alpha\n")
|
|
(cwd / "b.md").write_text("\nbeta\n\n")
|
|
backend = _backend(
|
|
cwd,
|
|
InMemorySessionStore(),
|
|
prompts=Prompts(deep=(("role", cwd / "a.md"), cwd / "b.md")),
|
|
)
|
|
await _drain(
|
|
backend.complete(
|
|
agent=backend.agent,
|
|
messages=[{"role": "user", "content": "x"}],
|
|
conversation_id="c",
|
|
)
|
|
)
|
|
assert (
|
|
FakeClient.instances[0].options.system_prompt
|
|
== "<role>\nalpha\n</role>\n\nbeta\n"
|
|
)
|
|
|
|
|
|
async def test_runner_user_lands_in_wrapper(cwd: Path) -> None:
|
|
import os
|
|
import pwd
|
|
|
|
me = pwd.getpwuid(os.getuid())
|
|
backend = _backend(cwd, InMemorySessionStore())
|
|
backend._runner = RunnerConfig(user=me.pw_name, home=cwd)
|
|
backend._uid, backend._gid = me.pw_uid, me.pw_gid
|
|
await _drain(
|
|
backend.complete(
|
|
agent=backend.agent,
|
|
messages=[{"role": "user", "content": "x"}],
|
|
conversation_id="c",
|
|
)
|
|
)
|
|
opts = FakeClient.instances[0].options
|
|
assert opts.env["HOME"] == str(cwd)
|
|
assert opts.env["CLAUDE_CONFIG_DIR"] == str(cwd / ".claude")
|
|
wrapper = Path(opts.cli_path).read_text()
|
|
assert f"UID = {me.pw_uid}" in wrapper
|
|
assert "os.setuid(UID)" in wrapper
|
|
|
|
|
|
async def test_close_disconnects(cwd: Path) -> None:
|
|
backend = _backend(cwd, InMemorySessionStore())
|
|
async with backend:
|
|
await _drain(
|
|
backend.complete(
|
|
agent=backend.agent,
|
|
messages=[{"role": "user", "content": "x"}],
|
|
conversation_id="c",
|
|
)
|
|
)
|
|
assert FakeClient.instances[0].connected is False
|
|
assert backend.sessions == {}
|
|
|
|
|
|
class GatedClient(FakeClient):
|
|
"""Streams one delta, then waits for ``gate`` before finishing the turn."""
|
|
|
|
gate: asyncio.Event
|
|
|
|
async def receive_response(self):
|
|
yield StreamEvent(
|
|
uuid="u", session_id="s", event={"type": "message_start", "message": {}}
|
|
)
|
|
for e in _stream(0, "first"):
|
|
yield e
|
|
await GatedClient.gate.wait()
|
|
yield AssistantMessage(content=[TextBlock(text="first")], model="m")
|
|
yield _result(self.session_id)
|
|
|
|
|
|
async def test_deltas_reach_the_caller_before_the_turn_ends(cwd: Path) -> None:
|
|
"""The turn is not buffered: a delta is observable while the CLI still runs."""
|
|
GatedClient.gate = asyncio.Event()
|
|
backend = _backend(cwd, InMemorySessionStore())
|
|
backend._factory = GatedClient
|
|
events = backend.complete(
|
|
agent=backend.agent,
|
|
messages=[{"role": "user", "content": "hi"}],
|
|
conversation_id="conv-live",
|
|
)
|
|
seen: list[Any] = []
|
|
async for ev in events:
|
|
seen.append(ev)
|
|
if isinstance(ev, RawContentBlockDeltaEvent):
|
|
break
|
|
assert seen[-1].delta.text == "first"
|
|
assert not GatedClient.gate.is_set()
|
|
GatedClient.gate.set()
|
|
rest = [e async for e in events]
|
|
assert isinstance(rest[-1], RawMessageStopEvent)
|
|
|
|
|
|
async def test_policy_hook_denies_and_audits(cwd: Path) -> None:
|
|
from beaver_gateway.agents.policy import Deny, ToolCall
|
|
|
|
def no_days(c: ToolCall):
|
|
p = c.path()
|
|
if c.tool == "Write" and p is not None and "📅 дни" in p.parts:
|
|
return Deny(reason="дневник только читать")
|
|
c.state["seen"] = c.state.get("seen", 0) + 1
|
|
return None
|
|
|
|
audits = []
|
|
|
|
async def sink(a):
|
|
audits.append(a)
|
|
|
|
backend = _backend(cwd, InMemorySessionStore(), policy=(no_days,))
|
|
backend._audit_sink = sink
|
|
await _drain(
|
|
backend.complete(
|
|
agent=backend.agent,
|
|
messages=[{"role": "user", "content": "hi"}],
|
|
conversation_id="conv-1",
|
|
)
|
|
)
|
|
hook = FakeClient.instances[0].options.hooks["PreToolUse"][0].hooks[0]
|
|
denied = await hook(
|
|
{"tool_name": "Write", "tool_input": {"file_path": "/vault/📅 дни/x.md"}},
|
|
"t1",
|
|
{},
|
|
)
|
|
assert denied["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
assert (
|
|
denied["hookSpecificOutput"]["permissionDecisionReason"]
|
|
== "дневник только читать"
|
|
)
|
|
allowed = await hook(
|
|
{"tool_name": "Write", "tool_input": {"file_path": "/vault/мета/бобер/x.md"}},
|
|
"t2",
|
|
{},
|
|
)
|
|
assert allowed == {}
|
|
await hook(
|
|
{"tool_name": "Read", "tool_input": {"file_path": "/vault/a.md"}}, "t3", {}
|
|
)
|
|
assert backend.live("conv-1").state["seen"] == 2
|
|
assert [(a.tool, a.decision) for a in audits] == [
|
|
("Write", "deny"),
|
|
("Write", "allow"),
|
|
("Read", "allow"),
|
|
]
|
|
assert audits[0].brief == "/vault/📅 дни/x.md"
|
|
assert audits[0].kind == "deep" and audits[0].conversation == "conv-1"
|
|
|
|
|
|
async def test_no_policy_no_hooks(cwd: Path) -> None:
|
|
backend = _backend(cwd, InMemorySessionStore())
|
|
await _drain(
|
|
backend.complete(
|
|
agent=backend.agent, messages=[{"role": "user", "content": "hi"}]
|
|
)
|
|
)
|
|
assert FakeClient.instances[0].options.hooks is None
|