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:
@@ -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)
|
||||
@@ -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={})]))
|
||||
@@ -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)]
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,)
|
||||
Reference in New Issue
Block a user