P1: host-agnostic quercus-core with agent loop, providers, and tool schemas

- Core types: Rational time, EntityId, ActionBatch, PngBytes (no Pillow dep)
- Config: ~/.quercus/config.toml with 0600 perms, env var override for CI
- LLMProvider: Claude (anthropic SDK) and OpenAI-compatible endpoint
  (covers custom gateways and llama.cpp server mode); graceful degradation
  without API keys
- Tool schemas: 26 curated tools with JSON Schema and read_only flags
- AgentLoop: sync dialogue loop with confirmation gate (read-only batches
  bypass, rejected mutations are reported back to the LLM), PNG frame
  feedback for multimodal providers
- Session log: JSONL recording with frame sha256 hashes, replayable
- HostAdapter ABC (signatures only, aligned with OPP/1 method families)
- Mock LLM provider (scripted/replay/record) for network-free CI

Tests: 69 passed via uv run pytest (no network access)
This commit is contained in:
2026-08-25 02:06:10 +08:00
parent c9870b4faf
commit e0db5ab2cf
33 changed files with 3957 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
"""pytest 全局配置:把 tests/ 加入 sys.path(供 ``import mock_llm``)与共享 fixtures。"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
_TESTS_DIR = Path(__file__).resolve().parent
if str(_TESTS_DIR) not in sys.path:
sys.path.insert(0, str(_TESTS_DIR))
@pytest.fixture
def tmp_home(tmp_path, monkeypatch):
"""把 ~/.quercus 定向到临时目录(经 QUERCUS_HOME 覆盖)。"""
home = tmp_path / "home"
monkeypatch.setenv("QUERCUS_HOME", str(home))
return home
@pytest.fixture
def fake_png():
"""最小 PNG 头 + 填充字节;core 只流转字节,不解码。"""
return b"\x89PNG\r\n\x1a\n" + bytes(range(64))
+83
View File
@@ -0,0 +1,83 @@
"""配置:0600 权限、环境变量覆盖优先级、密钥不落日志。"""
import stat
import pytest
from quercus_core.config import (
ENV_ANTHROPIC_API_KEY,
ENV_OPENAI_API_KEY,
ENV_OPENAI_BASE_URL,
Config,
config_home,
config_summary,
default_config_path,
default_sessions_dir,
load_config,
save_config,
)
class TestSaveLoad:
def test_save_creates_0600(self, tmp_path):
path = tmp_path / "cfg" / "config.toml"
save_config(Config(anthropic_api_key="sk-ant-secret"), path)
assert path.exists()
assert stat.S_IMODE(path.stat().st_mode) == 0o600
def test_roundtrip(self, tmp_path):
path = tmp_path / "config.toml"
save_config(
Config(
anthropic_api_key="sk-a",
openai_api_key="sk-o",
openai_base_url="https://gw.example/v1",
),
path,
)
cfg = load_config(path)
assert cfg.anthropic_api_key == "sk-a"
assert cfg.openai_api_key == "sk-o"
assert cfg.openai_base_url == "https://gw.example/v1"
assert cfg.has_anthropic_key and cfg.has_openai_key
def test_env_overrides_file(self, tmp_path, monkeypatch):
path = tmp_path / "config.toml"
save_config(Config(anthropic_api_key="file-key", openai_api_key="file-o"), path)
monkeypatch.setenv(ENV_ANTHROPIC_API_KEY, "env-key")
cfg = load_config(path)
assert cfg.anthropic_api_key == "env-key" # 环境变量 > 配置文件
assert cfg.openai_api_key == "file-o" # 未覆盖的保持文件值
def test_env_without_file(self, tmp_path, monkeypatch):
monkeypatch.setenv(ENV_OPENAI_API_KEY, "env-o")
monkeypatch.setenv(ENV_OPENAI_BASE_URL, "http://localhost:8080/v1")
monkeypatch.delenv(ENV_ANTHROPIC_API_KEY, raising=False)
cfg = load_config(tmp_path / "missing.toml")
assert cfg.openai_api_key == "env-o"
assert cfg.openai_base_url == "http://localhost:8080/v1"
assert cfg.anthropic_api_key is None
def test_no_config_no_env(self, tmp_path, monkeypatch):
monkeypatch.delenv(ENV_ANTHROPIC_API_KEY, raising=False)
monkeypatch.delenv(ENV_OPENAI_API_KEY, raising=False)
monkeypatch.delenv(ENV_OPENAI_BASE_URL, raising=False)
cfg = load_config(tmp_path / "missing.toml")
assert cfg.anthropic_api_key is None
assert cfg.openai_api_key is None
assert cfg.openai_base_url is None
assert not cfg.has_anthropic_key and not cfg.has_openai_key
class TestHome:
def test_home_override(self, tmp_home):
assert config_home() == tmp_home / ".quercus"
assert default_config_path() == tmp_home / ".quercus" / "config.toml"
assert default_sessions_dir() == tmp_home / ".quercus" / "sessions"
def test_config_summary_hides_secrets():
cfg = Config(anthropic_api_key="top-secret-value", openai_api_key="second-secret")
summary = config_summary(cfg)
assert summary["anthropic_configured"] is True
assert summary["openai_configured"] is True
assert "secret" not in str(summary)
+72
View File
@@ -0,0 +1,72 @@
"""HostAdapter ABC:只定义接口形状,不可实例化,方法签名齐全。"""
import pytest
from quercus_core.host.adapter import HostAdapter
from quercus_core.types import Action, ActionBatch, Limits, Rational
def test_abc_cannot_instantiate():
with pytest.raises(TypeError):
HostAdapter() # type: ignore[abstract]
def test_abstract_methods_declared():
assert {"capabilities", "limits", "get_frame", "get_thumbnails", "execute", "undo_last"} <= set(
HostAdapter.__abstractmethods__
)
def test_missing_abstract_method_raises():
class Partial(HostAdapter):
def capabilities(self):
return set()
with pytest.raises(TypeError):
Partial() # type: ignore[abstract]
def test_concrete_subclass_works():
class Impl(HostAdapter):
def capabilities(self):
return set()
def limits(self):
return Limits()
def open_project(self, path): ...
def save_project(self, path=None): ...
def get_project_overview(self): ...
def probe_media(self, path): ...
def import_footage(self, paths): ...
def get_timeline_structure(self, seq): ...
def get_frame(self, target, time, max_size): ...
def get_thumbnails(self, target, range, count): ...
def get_audio_levels(self, seq, range, resolution): ...
def play(self, speed=1.0): ...
def pause(self): ...
def seek(self, time): ...
def get_state(self): ...
def export(self, seq, output, preset): ...
def execute(self, batch): ...
def undo_last(self): ...
impl = Impl()
assert impl.capabilities() == set()
assert impl.limits().max_frame_rate == 8.0
# execute 接受 ActionBatch 语义
impl.execute(ActionBatch(label="t", actions=[Action(tool="play", params={})]))
+195
View File
@@ -0,0 +1,195 @@
"""Agent loop:两轮对话、确认门、只读直通、视觉闭环回喂、会话日志。"""
from quercus_core.agent.loop import AgentLoop
from quercus_core.providers.base import AssistantTurn, ToolCall
from quercus_core.session.log import SessionLog, replay
from quercus_core.types import PngBytes, ToolResult
from mock_llm import MockProvider
PNG = PngBytes(b"\x89PNG\r\n\x1a\n" + bytes(range(64)))
def _sha256(data: bytes) -> str:
import hashlib
return hashlib.sha256(data).hexdigest()
class StubExecutor:
"""内存 stub 工具执行器(P1 无真实宿主实现)。"""
def __init__(self):
self.calls = []
def execute(self, action):
self.calls.append(action)
if action.tool == "get_frame":
return ToolResult(tool="get_frame", ok=True, summary="帧:00:00:02", images=(PNG,))
if action.tool == "place_clip":
return ToolResult(tool="place_clip", ok=True, summary="已放置片段 c1 到轨道 1")
if action.tool == "ripple_delete":
return ToolResult(tool="ripple_delete", ok=True, summary="已波纹删除 1 个片段")
return ToolResult(tool=action.tool, ok=True, summary=f"{action.tool} ok")
def _frame_call(i: int) -> ToolCall:
return ToolCall(
id=f"call_frame_{i}",
name="get_frame",
arguments={"time": {"num": i, "den": 1}, "max_size": {"width": 32, "height": 18}},
)
def test_two_round_loop_confirm_auto_approve():
"""第一轮含变更工具(触发确认门) + get_frame(PNG 回喂);第二轮纯文本结束。"""
mock = MockProvider(
script=[
AssistantTurn(
text="",
tool_calls=(
ToolCall(
id="call_place",
name="place_clip",
arguments={"clip_id": "c1", "track_index": 1, "time": {"num": 0, "den": 1}},
),
_frame_call(2),
),
),
AssistantTurn(text="剪辑完成", tool_calls=()),
]
)
stub = StubExecutor()
confirmed = []
def confirm(batch):
confirmed.append(batch)
return True
loop = AgentLoop(provider=mock, executor=stub, confirm=confirm)
result = loop.run("铺一个片段并看一眼画面")
assert result.final_text == "剪辑完成"
assert result.turns == 2
assert result.tool_calls_executed == 2
assert [a.tool for a in stub.calls] == ["place_clip", "get_frame"]
assert len(confirmed) == 1
assert len(confirmed[0].actions) == 2
# 视觉闭环:get_frame 的 PNG 作为 tool 消息回喂 provider
last_messages = mock.observed[-1]
tool_msgs = [m for m in last_messages if m.role == "tool"]
assert len(tool_msgs) == 2
frame_msg = next(m for m in tool_msgs if m.name == "get_frame")
assert PNG in frame_msg.images
def test_rejected_batch_mutation_not_executed():
mock = MockProvider(
script=[
AssistantTurn(
text="",
tool_calls=(
ToolCall(id="c1", name="ripple_delete", arguments={"clip_ids": ["x1"]}),
),
),
AssistantTurn(text="已取消删除", tool_calls=()),
]
)
stub = StubExecutor()
loop = AgentLoop(provider=mock, executor=stub, confirm=lambda batch: False)
result = loop.run("删掉 x1")
assert stub.calls == [] # 变更工具未执行
assert result.final_text == "已取消删除"
assert loop.batch_decisions[0][1] is False
# 拒绝结果回喂 LLM
last_messages = mock.observed[-1]
tool_msgs = [m for m in last_messages if m.role == "tool"]
assert len(tool_msgs) == 1
assert "未执行" in tool_msgs[0].text
def test_readonly_batch_bypasses_confirm():
mock = MockProvider(
script=[
AssistantTurn(text="", tool_calls=(_frame_call(1),)),
AssistantTurn(text="帧已取到", tool_calls=()),
]
)
stub = StubExecutor()
confirm_calls = []
def confirm(batch):
confirm_calls.append(batch)
return True
loop = AgentLoop(provider=mock, executor=stub, confirm=confirm)
result = loop.run("取一帧")
assert confirm_calls == [] # 纯只读批次不触发确认门
assert [a.tool for a in stub.calls] == ["get_frame"]
assert result.final_text == "帧已取到"
def test_default_confirm_is_auto_approve():
"""未注入 confirm 回调时默认全开(计划文档 P1.3)。"""
mock = MockProvider(
script=[
AssistantTurn(
text="",
tool_calls=(ToolCall(id="c1", name="add_marker", arguments={"time": {"num": 1, "den": 1}}),),
),
AssistantTurn(text="已打点", tool_calls=()),
]
)
stub = StubExecutor()
loop = AgentLoop(provider=mock, executor=stub)
result = loop.run("打点")
assert [a.tool for a in stub.calls] == ["add_marker"]
assert result.final_text == "已打点"
def test_max_turns_bound():
mock = MockProvider(
script=[AssistantTurn(text="", tool_calls=(_frame_call(i),)) for i in range(3)]
)
stub = StubExecutor()
loop = AgentLoop(provider=mock, executor=stub, max_turns=2)
result = loop.run("一直取帧")
assert result.turns == 2
assert len(stub.calls) == 2
def test_unknown_tool_reported_back():
mock = MockProvider(
script=[
AssistantTurn(text="", tool_calls=(ToolCall(id="c1", name="no_such_tool", arguments={}),)),
AssistantTurn(text="done", tool_calls=()),
]
)
stub = StubExecutor()
loop = AgentLoop(provider=mock, executor=stub)
loop.run("调用未知工具")
assert stub.calls == []
last = mock.observed[-1][-1]
assert "未知工具" in last.text
def test_loop_writes_session_log(tmp_path):
mock = MockProvider(
script=[
AssistantTurn(text="", tool_calls=(_frame_call(3),)),
AssistantTurn(text="结束", tool_calls=()),
]
)
stub = StubExecutor()
log = SessionLog(tmp_path / "sessions" / "s1.jsonl", session_id="sess-1")
loop = AgentLoop(provider=mock, executor=stub, session=log)
loop.run("取帧并结束")
events = replay(log.path)
kinds = [e.kind for e in events]
assert kinds == ["user_message", "assistant_turn", "batch_decision", "tool_result", "assistant_turn"]
# 帧哈希落盘(不落图片本体)
result_event = next(e for e in events if e.kind == "tool_result")
assert result_event.payload["image_hashes"] == [_sha256(PNG)]
+55
View File
@@ -0,0 +1,55 @@
"""mock_llm:脚本化回复、录制/回放往返、脚本文件序列化。"""
from quercus_core.providers.base import AssistantTurn, Message, Role, ToolCall
from mock_llm import MockProvider, RecordingProvider
def test_mock_provider_scripted():
mock = MockProvider(
script=[
AssistantTurn(
text="", tool_calls=(ToolCall(id="c1", name="get_frame", arguments={"time": {"num": 1, "den": 1}}),)
),
AssistantTurn(text="done", tool_calls=()),
]
)
turn1 = mock.generate([Message(role=Role.USER, text="hi")])
assert turn1.tool_calls[0].name == "get_frame"
assert mock.observed[0][0].text == "hi"
turn2 = mock.generate([Message(role=Role.USER, text="hi")])
assert turn2.text == "done" and not turn2.tool_calls
assert mock.exhausted
def test_mock_provider_empty_script_graceful():
mock = MockProvider()
turn = mock.generate([Message(role=Role.USER, text="hi")])
assert turn.text and not turn.tool_calls # 空脚本不抛异常,结束本轮
def test_script_file_roundtrip(tmp_path):
turns = [
AssistantTurn(
text="", tool_calls=(ToolCall(id="c1", name="place_clip", arguments={"x": 1}),)
),
AssistantTurn(text="ok", tool_calls=()),
]
path = tmp_path / "script.json"
MockProvider.dump_script(turns, path)
loaded = MockProvider.load_script(path)
assert loaded == turns
def test_recording_provider_records_and_replays(tmp_path):
inner = MockProvider(
script=[AssistantTurn(text="a", tool_calls=()), AssistantTurn(text="b", tool_calls=())]
)
rec = RecordingProvider(inner)
rec.generate([Message(role=Role.USER, text="1")])
rec.generate([Message(role=Role.USER, text="2")])
assert [t.text for t in rec.recorded] == ["a", "b"]
path = tmp_path / "recorded.json"
MockProvider.dump_script(rec.recorded, path)
replayed = MockProvider.load_script(path)
assert replayed == rec.recorded
+321
View File
@@ -0,0 +1,321 @@
"""Provider 层测试:httpx MockTransport,零网络。
覆盖:OpenAI 兼容请求/响应解析、图片回喂、错误处理;Claude(anthropic SDK)
的消息/图片/tool_result 转换与 tool_use 解析;注册表可用性与无 key 降级。
"""
import base64
import json
import httpx
import pytest
from quercus_core.config import Config
from quercus_core.providers import create_provider, provider_status
from quercus_core.providers.base import (
LLMProvider,
Message,
ProviderError,
ProviderUnavailable,
Role,
ToolCall,
ToolSchema,
)
from quercus_core.providers.claude import ClaudeProvider
from quercus_core.providers.openai_compat import OpenAICompatProvider
from quercus_core.types import PngBytes
PNG = PngBytes(b"\x89PNG\r\n\x1a\n" + bytes(range(64)))
GET_FRAME_SCHEMA = ToolSchema(
name="get_frame",
description="取帧",
parameters={"type": "object", "properties": {"time": {"type": "object"}}, "required": ["time"]},
read_only=True,
)
CLAUDE_MESSAGE = {
"id": "msg_01",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-5",
"content": [],
"stop_reason": "end_turn",
"stop_sequence": None,
"usage": {"input_tokens": 10, "output_tokens": 5},
}
# ---- OpenAI 兼容 ----
def test_openai_compat_request_and_parse():
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["url"] = str(request.url)
captured["authorization"] = request.headers.get("authorization")
captured["body"] = json.loads(request.content)
return httpx.Response(
200,
json={
"id": "chatcmpl-1",
"object": "chat.completion",
"created": 1,
"model": "gpt-4o",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "我来调用工具",
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {
"name": "get_frame",
"arguments": json.dumps({"time": {"num": 1, "den": 1}}),
},
}
],
},
"finish_reason": "tool_calls",
}
],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
},
)
client = httpx.Client(transport=httpx.MockTransport(handler))
provider = OpenAICompatProvider(
api_key="sk-test",
base_url="https://gw.example.com/v1",
model="local-vl",
client=client,
)
turn = provider.generate(
[Message(role=Role.USER, text="hi")], tools=[GET_FRAME_SCHEMA]
)
assert captured["url"] == "https://gw.example.com/v1/chat/completions"
assert captured["authorization"] == "Bearer sk-test"
assert captured["body"]["model"] == "local-vl"
assert captured["body"]["tools"][0]["function"]["name"] == "get_frame"
assert turn.text == "我来调用工具"
assert turn.tool_calls[0].name == "get_frame"
assert turn.tool_calls[0].arguments == {"time": {"num": 1, "den": 1}}
def test_openai_compat_image_message():
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["body"] = json.loads(request.content)
return httpx.Response(200, json={"choices": [{"message": {"content": "ok"}}]})
client = httpx.Client(transport=httpx.MockTransport(handler))
provider = OpenAICompatProvider(api_key="k", base_url="https://x/v1", client=client)
provider.generate([Message(role=Role.USER, text="看图", images=(PNG,))])
content = captured["body"]["messages"][0]["content"]
assert content[0] == {"type": "text", "text": "看图"}
url = content[1]["image_url"]["url"]
assert url.startswith("data:image/png;base64,")
assert base64.b64decode(url.split(",", 1)[1]) == PNG
def test_openai_compat_tool_result_image_follow_up_user_message():
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["body"] = json.loads(request.content)
return httpx.Response(200, json={"choices": [{"message": {"content": "ok"}}]})
client = httpx.Client(transport=httpx.MockTransport(handler))
provider = OpenAICompatProvider(api_key="k", base_url="https://x/v1", client=client)
provider.generate(
[
Message(role=Role.TOOL, text="帧", images=(PNG,), tool_call_id="c1", name="get_frame"),
]
)
msgs = captured["body"]["messages"]
assert msgs[0]["role"] == "tool"
assert msgs[0]["tool_call_id"] == "c1"
assert msgs[1]["role"] == "user" # 帧图以随后的 user 消息回喂
assert msgs[1]["content"][0]["type"] == "text"
assert msgs[1]["content"][1]["image_url"]["url"].startswith("data:image/png;base64,")
def test_openai_compat_http_error():
client = httpx.Client(
transport=httpx.MockTransport(lambda req: httpx.Response(500, text="boom"))
)
provider = OpenAICompatProvider(api_key="k", base_url="https://x/v1", client=client)
with pytest.raises(ProviderError):
provider.generate([Message(role=Role.USER, text="hi")])
# ---- Claude ----
def test_claude_tool_use_parse():
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["body"] = json.loads(request.content)
return httpx.Response(
200,
headers={"content-type": "application/json"},
json={
"id": "msg_01",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-5",
"content": [
{"type": "text", "text": "我来取帧"},
{
"type": "tool_use",
"id": "toolu_01",
"name": "get_frame",
"input": {"time": {"num": 1, "den": 1}, "max_size": {"width": 320, "height": 180}},
},
],
"stop_reason": "tool_use",
"stop_sequence": None,
"usage": {"input_tokens": 10, "output_tokens": 5},
},
)
client = httpx.Client(transport=httpx.MockTransport(handler))
provider = ClaudeProvider(api_key="sk-test", http_client=client)
turn = provider.generate([Message(role=Role.USER, text="hi")])
assert turn.text == "我来取帧"
assert len(turn.tool_calls) == 1
assert turn.tool_calls[0].id == "toolu_01"
assert turn.tool_calls[0].name == "get_frame"
assert turn.tool_calls[0].arguments["max_size"]["width"] == 320
def test_claude_image_message_becomes_image_block():
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["body"] = json.loads(request.content)
return httpx.Response(
200, headers={"content-type": "application/json"}, json=CLAUDE_MESSAGE
)
client = httpx.Client(transport=httpx.MockTransport(handler))
provider = ClaudeProvider(api_key="sk-test", http_client=client)
provider.generate([Message(role=Role.USER, text="看图", images=(PNG,))])
content = captured["body"]["messages"][0]["content"]
assert content[0]["type"] == "text" # 文本块在前
img_block = content[1]
assert img_block["type"] == "image"
assert img_block["source"]["media_type"] == "image/png"
assert base64.b64decode(img_block["source"]["data"]) == PNG
def test_claude_tool_result_with_image():
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["body"] = json.loads(request.content)
return httpx.Response(
200, headers={"content-type": "application/json"}, json=CLAUDE_MESSAGE
)
client = httpx.Client(transport=httpx.MockTransport(handler))
provider = ClaudeProvider(api_key="sk-test", http_client=client)
provider.generate(
[
Message(
role=Role.ASSISTANT,
tool_calls=(ToolCall(id="toolu_01", name="get_frame", arguments={}),),
),
Message(
role=Role.TOOL,
text="帧",
images=(PNG,),
tool_call_id="toolu_01",
name="get_frame",
),
]
)
msgs = captured["body"]["messages"]
assert msgs[0]["role"] == "assistant"
assert msgs[0]["content"][0]["type"] == "tool_use"
assert msgs[1]["role"] == "user" # 连续 tool 消息合并为一条带 tool_result 的 user
tool_result = msgs[1]["content"][0]
assert tool_result["type"] == "tool_result"
assert tool_result["tool_use_id"] == "toolu_01"
assert tool_result["content"][0]["type"] == "text"
assert tool_result["content"][1]["type"] == "image"
def test_claude_multiple_tool_results_merged_into_one_user_message():
"""agent loop 的典型回喂形态:多条 tool 消息合并成一条 user(tool_result...)。"""
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["body"] = json.loads(request.content)
return httpx.Response(
200, headers={"content-type": "application/json"}, json=CLAUDE_MESSAGE
)
client = httpx.Client(transport=httpx.MockTransport(handler))
provider = ClaudeProvider(api_key="sk-test", http_client=client)
provider.generate(
[
Message(
role=Role.ASSISTANT,
tool_calls=(
ToolCall(id="t1", name="place_clip", arguments={"x": 1}),
ToolCall(id="t2", name="get_frame", arguments={"t": 1}),
),
),
Message(role=Role.TOOL, text="placed", tool_call_id="t1", name="place_clip"),
Message(role=Role.TOOL, text="frame", images=(PNG,), tool_call_id="t2", name="get_frame"),
]
)
msgs = captured["body"]["messages"]
assert len(msgs) == 2
assert msgs[0]["role"] == "assistant"
assert [b["type"] for b in msgs[0]["content"]] == ["tool_use", "tool_use"]
# 两条 tool_result 合并进同一条 user 消息(保持 assistant 之后紧跟 user)
user_content = msgs[1]["content"]
assert [b["type"] for b in user_content] == ["tool_result", "tool_result"]
assert [b["tool_use_id"] for b in user_content] == ["t1", "t2"]
# ---- 注册表 / 无 key 降级 ----
def test_registry_unavailable_without_keys(tmp_path, monkeypatch):
monkeypatch.delenv("QUERCUS_ANTHROPIC_API_KEY", raising=False)
monkeypatch.delenv("QUERCUS_OPENAI_API_KEY", raising=False)
cfg = Config()
status = provider_status(cfg)
assert status["claude"].available is False
assert status["openai_compat"].available is False
assert status["claude"].reason
with pytest.raises(ProviderUnavailable):
create_provider("claude", cfg)
with pytest.raises(ProviderUnavailable):
create_provider("openai_compat", cfg)
def test_registry_create_with_keys():
cfg = Config(anthropic_api_key="sk-ant", openai_api_key="sk-o")
status = provider_status(cfg)
assert status["claude"].available is True
assert status["claude"].model
assert status["openai_compat"].available is True
mock_client = httpx.Client(
transport=httpx.MockTransport(lambda req: httpx.Response(200, json={"choices": [{"message": {"content": "ok"}}]}))
)
claude = create_provider("claude", cfg, http_client=mock_client)
assert isinstance(claude, ClaudeProvider)
openai = create_provider("openai_compat", cfg, http_client=mock_client)
assert isinstance(openai, OpenAICompatProvider)
assert isinstance(claude, LLMProvider)
def test_registry_unknown_provider():
with pytest.raises(ValueError):
create_provider("llamacpp", Config())
+112
View File
@@ -0,0 +1,112 @@
"""工具 schema 注册表:合法性、read_only 标注、有理秒参数形状。"""
import json
import pytest
from quercus_core.providers.base import ToolSchema
from quercus_core.tools.schemas import (
RATIONAL_SCHEMA,
TIME_RANGE_SCHEMA,
TOOL_LIST,
TOOLS,
get_tool,
tool_read_only,
)
# 期望的 read_only 标注(独立于代码本身,防止误改)。
EXPECTED_READ_ONLY = {
"open_project": False,
"save_project": False,
"get_project_overview": True,
"probe_media": True,
"import_footage": False,
"list_footage": True,
"add_track": False,
"place_clip": False,
"split_clip": False,
"trim_clip": False,
"move_clip": False,
"ripple_delete": False,
"add_transition": False,
"add_marker": False,
"add_effect": False,
"set_param": False,
"set_keyframe": False,
"list_effects": True,
"get_frame": True,
"scan_timeline": True,
"get_audio_levels": True,
"play": True,
"pause": True,
"seek": True,
"export_render": False,
"undo_last_action": False,
}
def test_tool_count():
assert len(TOOLS) == 26
assert len(TOOL_LIST) == 26
assert len({t.name for t in TOOL_LIST}) == 26
def test_schema_shape():
for name, tool in TOOLS.items():
assert isinstance(tool, ToolSchema)
assert tool.name == name
assert isinstance(tool.description, str) and tool.description
p = tool.parameters
assert p["type"] == "object"
assert isinstance(p["properties"], dict)
required = p.get("required", [])
assert isinstance(required, list)
for req in required:
assert req in p["properties"], f"{name}: required {req} 不在 properties"
json.dumps(p) # 可 JSON 序列化 => 合法 JSON Schema 对象
def test_read_only_flags():
assert set(TOOLS) == set(EXPECTED_READ_ONLY)
for name, expected in EXPECTED_READ_ONLY.items():
assert TOOLS[name].read_only is expected, f"{name} read_only 应为 {expected}"
assert tool_read_only(name) is expected, f"{name} tool_read_only 不一致"
def test_unknown_tool_treated_as_mutation():
assert tool_read_only("no_such_tool") is False
@pytest.mark.parametrize(
"tool_name,param_name",
[
("place_clip", "time"),
("split_clip", "time"),
("trim_clip", "in_point"),
("move_clip", "time"),
("add_marker", "time"),
("set_keyframe", "time"),
("get_frame", "time"),
("seek", "time"),
],
)
def test_rational_params_are_num_den(tool_name, param_name):
props = TOOLS[tool_name].parameters["properties"]
assert props[param_name] == RATIONAL_SCHEMA, f"{tool_name}.{param_name}"
def test_time_range_param_shape():
props = TOOLS["scan_timeline"].parameters["properties"]
assert props["range"] == TIME_RANGE_SCHEMA
assert props["count"]["maximum"] == 64
def test_get_frame_max_size():
props = TOOLS["get_frame"].parameters["properties"]
max_size = props["max_size"]
assert max_size["type"] == "object"
assert max_size["required"] == ["width", "height"]
def test_get_tool_lookup():
assert get_tool("play") is TOOLS["play"]
assert get_tool("nope") is None
+74
View File
@@ -0,0 +1,74 @@
"""会话日志:JSONL 写读往返、帧哈希记录(不落图片本体)。"""
import hashlib
import json
from quercus_core.providers.base import AssistantTurn, ToolCall
from quercus_core.session.log import SessionLog, frame_sha256, replay
from quercus_core.types import Action, ActionBatch, PngBytes, ToolResult
PNG = PngBytes(b"\x89PNG\r\n\x1a\n" + bytes(range(64)))
def test_write_read_roundtrip(tmp_path):
path = tmp_path / "s1.jsonl"
log = SessionLog(path, session_id="sess-1")
log.log_user("你好")
log.log_assistant(
AssistantTurn(
text="",
tool_calls=(ToolCall(id="c1", name="get_frame", arguments={"time": {"num": 1, "den": 1}}),),
)
)
log.log_tool_result(ToolResult(tool="get_frame", ok=True, summary="帧", images=(PNG,)))
log.log_batch_decision(
ActionBatch(
label="AI 动作:get_frame",
actions=[Action(tool="get_frame", params={"time": {"num": 1, "den": 1}}, call_id="c1")],
id="b1",
),
approved=True,
)
events = replay(path)
assert len(events) == 4
assert events[0].kind == "user_message"
assert events[0].payload["text"] == "你好"
assert events[0].session_id == "sess-1"
assert events[1].kind == "assistant_turn"
assert events[1].payload["tool_calls"][0]["name"] == "get_frame"
assert events[2].kind == "tool_result"
assert events[3].kind == "batch_decision"
assert events[3].payload["approved"] is True
def test_frame_hash_recorded_not_bytes(tmp_path):
path = tmp_path / "s2.jsonl"
log = SessionLog(path, session_id="s")
log.log_tool_result(ToolResult(tool="get_frame", ok=True, summary="f", images=(PNG,)))
events = replay(path)
ev = events[0]
assert ev.kind == "tool_result"
assert ev.payload["image_hashes"] == [hashlib.sha256(PNG).hexdigest()]
# 日志文件里绝不出现图片本体字节
assert PNG not in path.read_bytes()
def test_jsonl_each_line_is_valid_json(tmp_path):
path = tmp_path / "s3.jsonl"
log = SessionLog(path)
log.log_user("x")
log.log_tool_result(ToolResult(tool="seek", ok=True, summary="ok"))
for line in path.read_text(encoding="utf-8").splitlines():
json.loads(line)
def test_frame_sha256_helper(fake_png):
assert frame_sha256(fake_png) == hashlib.sha256(fake_png).hexdigest()
def test_create_default_uses_home(tmp_home):
log = SessionLog.create_default()
assert log.path.parent == tmp_home / ".quercus" / "sessions"
assert log.path.suffix == ".jsonl"
assert log.path.name # <id>.jsonl
+122
View File
@@ -0,0 +1,122 @@
"""核心类型:Rational 运算与序列化、TimeRange、Size、ActionBatch 等。"""
import pytest
from quercus_core.types import (
Action,
ActionBatch,
Capabilities,
Limits,
PngBytes,
Rational,
Size,
TimeRange,
ToolResult,
)
class TestRational:
def test_normalization(self):
assert Rational(2, 4) == Rational(1, 2)
assert Rational(0, 7) == Rational(0, 1)
assert Rational(1, -2) == Rational(-1, 2)
assert Rational(4, 2).num == 2
assert Rational(4, 2).den == 1
def test_zero_denominator_raises(self):
with pytest.raises(ValueError):
Rational(1, 0)
def test_arithmetic(self):
assert Rational(1, 3) + Rational(1, 6) == Rational(1, 2)
assert Rational(1, 2) - Rational(1, 4) == Rational(1, 4)
assert Rational(1, 3) * Rational(3, 4) == Rational(1, 4)
assert Rational(1, 2) / Rational(1, 4) == Rational(2)
with pytest.raises(ZeroDivisionError):
Rational(1, 2) / Rational(0, 1)
def test_float_conversion(self):
assert Rational.from_float(0.5) == Rational(1, 2)
assert Rational(1, 3).to_float() == pytest.approx(1 / 3)
def test_ordering_and_hashing(self):
assert Rational(1, 3) < Rational(1, 2)
assert sorted([Rational(1, 2), Rational(1, 3)]) == [Rational(1, 3), Rational(1, 2)]
assert len({Rational(1, 2), Rational(2, 4)}) == 1 # 约分后同一对象
def test_json_roundtrip(self):
assert Rational(2, 4).to_json() == {"num": 1, "den": 2}
assert Rational.from_json({"num": 2, "den": 4}) == Rational(1, 2)
assert Rational.from_json(Rational(3, 4)) == Rational(3, 4)
with pytest.raises(ValueError):
Rational.from_json({"num": 1})
with pytest.raises(ValueError):
Rational.from_json("nope")
class TestTimeRange:
def test_basic(self):
tr = TimeRange(Rational(0), Rational(2))
assert tr.duration == Rational(2)
assert tr.contains(Rational(1))
assert not tr.contains(Rational(3))
def test_invalid_order(self):
with pytest.raises(ValueError):
TimeRange(Rational(2), Rational(1))
def test_json_roundtrip(self):
tr = TimeRange(Rational(1, 2), Rational(3))
assert TimeRange.from_json(tr.to_json()) == tr
assert tr.to_json() == {
"start": {"num": 1, "den": 2},
"end": {"num": 3, "den": 1},
}
class TestSize:
def test_basic(self):
s = Size(1920, 1080)
assert s.max_side == 1920
assert Size.from_json({"width": 320, "height": 180}) == Size(320, 180)
def test_invalid(self):
with pytest.raises(ValueError):
Size(0, 100)
class TestMisc:
def test_png_bytes_is_bytes(self):
png = PngBytes(b"\x89PNG")
assert isinstance(png, bytes)
def test_capability_constants(self):
assert Capabilities.TIMELINE_EDIT == "timeline.edit"
assert Capabilities.RENDER_FRAME == "render.frame"
def test_limits_defaults(self):
limits = Limits()
assert limits.max_frame_rate == 8.0
assert limits.max_scan_frames == 64
assert limits.to_json()["max_frame_width"] == 1920
def test_action_batch_json(self):
batch = ActionBatch(
label="AI 动作:place_clip",
actions=[
Action(
tool="place_clip",
params={"time": {"num": 0, "den": 1}},
call_id="call_1",
)
],
id="b1",
)
data = batch.to_json()
assert data["label"] == "AI 动作:place_clip"
assert data["actions"][0]["tool"] == "place_clip"
assert data["actions"][0]["call_id"] == "call_1"
def test_tool_result_carries_png(self):
png = PngBytes(b"\x89PNG")
result = ToolResult(tool="get_frame", ok=True, summary="帧", images=(png,))
assert result.images == (png,)
+4
View File
@@ -0,0 +1,4 @@
"""mock_llm:进程内 MockProvider / RecordingProvider(测试辅助包)。"""
from .provider import MockProvider, RecordingProvider
__all__ = ["MockProvider", "RecordingProvider"]
+104
View File
@@ -0,0 +1,104 @@
"""进程内 Mock LLM:脚本化回复 + 录制模式(CI 无 key 无网络跑通 agent loop 的底座)。
脚本文件格式(JSON 列表,每项一个 AssistantTurn)::
[
{"text": "", "tool_calls": [{"id": "call_1", "name": "get_frame",
"arguments": {"time": {"num": 1, "den": 1}}}]},
{"text": "完成", "tool_calls": []}
]
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from quercus_core.providers.base import AssistantTurn, LLMProvider, Message, ToolCall, ToolSchema
def _turn_to_dict(turn: AssistantTurn) -> dict[str, Any]:
return {
"text": turn.text,
"tool_calls": [
{"id": tc.id, "name": tc.name, "arguments": tc.arguments}
for tc in turn.tool_calls
],
}
def _turn_from_dict(data: dict[str, Any]) -> AssistantTurn:
calls = tuple(
ToolCall(id=item["id"], name=item["name"], arguments=item.get("arguments") or {})
for item in data.get("tool_calls") or []
)
return AssistantTurn(text=data.get("text", ""), tool_calls=calls)
class MockProvider(LLMProvider):
"""按预置脚本依次返回 AssistantTurn;记录每次收到的消息供断言。"""
name = "mock"
def __init__(
self,
script: list[AssistantTurn] | None = None,
script_file: Path | str | None = None,
) -> None:
self._script = list(script) if script is not None else []
if script_file is not None:
self._script = self.load_script(script_file)
self._index = 0
self.observed: list[list[Message]] = [] # 每次 generate 收到的消息历史
def generate(
self,
messages: list[Message],
tools: list[ToolSchema] | None = None,
) -> AssistantTurn:
self.observed.append(list(messages))
if self._index >= len(self._script):
return AssistantTurn(text="(mock: 脚本已耗尽,结束本轮)")
turn = self._script[self._index]
self._index += 1
return turn
@property
def exhausted(self) -> bool:
return self._index >= len(self._script)
# ---- 脚本文件 ----
@staticmethod
def dump_script(turns: list[AssistantTurn], path: Path | str) -> Path:
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps([_turn_to_dict(t) for t in turns], ensure_ascii=False, indent=2),
encoding="utf-8",
)
return path
@staticmethod
def load_script(path: Path | str) -> list[AssistantTurn]:
data = json.loads(Path(path).read_text(encoding="utf-8"))
return [_turn_from_dict(item) for item in data]
class RecordingProvider(LLMProvider):
"""录制模式:包装真实(或 mock)provider,记录其响应序列。"""
name = "recording"
def __init__(self, inner: LLMProvider) -> None:
self._inner = inner
self.recorded: list[AssistantTurn] = []
def generate(
self,
messages: list[Message],
tools: list[ToolSchema] | None = None,
) -> AssistantTurn:
turn = self._inner.generate(messages, tools=tools)
self.recorded.append(turn)
return turn