What isn't the dev agent's moves to nisse-core, unchanged in behaviour, with its tests: canonical, render, render_anthropic, llm, catalog, probe, context, views, state, reconcile, worker, cancel, dump, transport, matrix, execution, testing, vault, memory, oplog, envfile and gitea (whole). - nisse_core.state.StateStore holds the tables every agent has; an agent's subclass adds its tables (version, migrations), roles and views. nisse_dev.state.StateStore adds the build VM blocks (v5). - views.project takes what each role is shown, rather than a fixed table. - Vault takes the agent's name (agent=), for provenance. - A test that nisse_core never imports an agent. - The loop, config, git, delegation, expert, tools and evals stay in nisse-dev until nisse-ops has a loop to share. Docs: the Development Plan marks M7.1 done and notes that Phase 6 waits on the agent host and Terraform; Architecture Part L names the package. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
197 lines
6.4 KiB
Python
197 lines
6.4 KiB
Python
"""The Anthropic Messages shape differs structurally from the OpenAI one;
|
|
these tests pin the differences that a normalising middlebox would blur."""
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from nisse_core.canonical import (
|
|
AssistantText,
|
|
Compaction,
|
|
StoredEvent,
|
|
ToolCall,
|
|
ToolResult,
|
|
UserMessage,
|
|
)
|
|
from nisse_core.llm import AnthropicPassthroughClient, LLMError
|
|
from nisse_core.render_anthropic import (
|
|
AnthropicMessagesRenderer,
|
|
to_anthropic_tools,
|
|
)
|
|
|
|
TOOLS = [
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "run_command",
|
|
"description": "Run a shell command.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {"command": {"type": "string"}},
|
|
"required": ["command"],
|
|
},
|
|
},
|
|
}
|
|
]
|
|
|
|
|
|
def stored(events: list) -> list[StoredEvent]:
|
|
return [StoredEvent(id=i + 1, ts="t", event=e) for i, e in enumerate(events)]
|
|
|
|
|
|
def render(events: list, **kw) -> dict:
|
|
return AnthropicMessagesRenderer().render(
|
|
stored(events), kw.pop("tools", None), "claude-haiku-4-5", **kw
|
|
)
|
|
|
|
|
|
def test_system_is_a_top_level_field_not_a_message() -> None:
|
|
body = render([UserMessage(text="hi", source="m")], system="you are nisse")
|
|
assert body["system"] == "you are nisse"
|
|
assert all(m["role"] != "system" for m in body["messages"])
|
|
|
|
|
|
def test_max_tokens_is_always_present() -> None:
|
|
# The Messages API rejects a request without it.
|
|
assert render([UserMessage(text="hi", source="m")])["max_tokens"] > 0
|
|
|
|
|
|
def test_tool_call_is_a_content_block_not_an_array() -> None:
|
|
body = render(
|
|
[ToolCall(call_id="call-1", tool="run_command", arguments={"command": "ls"})]
|
|
)
|
|
block = body["messages"][0]["content"][0]
|
|
assert block["type"] == "tool_use"
|
|
assert block["id"] == "call-1"
|
|
assert block["input"] == {"command": "ls"}
|
|
assert "tool_calls" not in body["messages"][0]
|
|
|
|
|
|
def test_tool_result_is_a_user_block_directly_after_the_call() -> None:
|
|
body = render(
|
|
[
|
|
ToolCall(call_id="call-1", tool="run_command", arguments={}),
|
|
ToolResult(call_id="call-1", result="exit code: 0", success=True),
|
|
]
|
|
)
|
|
assistant, user = body["messages"]
|
|
assert assistant["role"] == "assistant"
|
|
assert assistant["content"][0]["type"] == "tool_use"
|
|
assert user["role"] == "user" # not a "tool" role, as OpenAI would have
|
|
assert user["content"][0] == {
|
|
"type": "tool_result",
|
|
"tool_use_id": "call-1",
|
|
"content": "exit code: 0",
|
|
}
|
|
|
|
|
|
def test_adjacent_same_role_events_are_merged() -> None:
|
|
body = render(
|
|
[
|
|
UserMessage(text="one", source="m"),
|
|
UserMessage(text="two", source="m"),
|
|
AssistantText(text="reply", model="m"),
|
|
]
|
|
)
|
|
assert [m["role"] for m in body["messages"]] == ["user", "assistant"]
|
|
assert [b["text"] for b in body["messages"][0]["content"]] == ["one", "two"]
|
|
|
|
|
|
def test_dangling_tool_call_gets_a_placeholder_result() -> None:
|
|
body = render([ToolCall(call_id="call-x", tool="submit_plan", arguments={})])
|
|
assert [m["role"] for m in body["messages"]] == ["assistant", "user"]
|
|
assert body["messages"][1]["content"][0]["tool_use_id"] == "call-x"
|
|
|
|
|
|
def test_tools_are_converted_to_input_schema() -> None:
|
|
[tool] = to_anthropic_tools(TOOLS)
|
|
assert tool["name"] == "run_command"
|
|
assert tool["input_schema"]["properties"] == {"command": {"type": "string"}}
|
|
assert "parameters" not in tool
|
|
assert "function" not in tool
|
|
|
|
|
|
def test_compaction_still_replaces_its_range() -> None:
|
|
events = stored(
|
|
[
|
|
UserMessage(text="old", source="m"),
|
|
UserMessage(text="kept", source="m"),
|
|
]
|
|
)
|
|
events.append(
|
|
StoredEvent(
|
|
id=3,
|
|
ts="t",
|
|
event=Compaction(summary="summary", model="m", replaces=[1, 1]),
|
|
)
|
|
)
|
|
body = AnthropicMessagesRenderer().render(events, None, "claude-haiku-4-5")
|
|
texts = [b["text"] for m in body["messages"] for b in m["content"]]
|
|
assert "old" not in texts
|
|
assert any("summary" in t for t in texts)
|
|
|
|
|
|
# --- passthrough client --------------------------------------------------
|
|
|
|
|
|
def client(handler) -> AnthropicPassthroughClient:
|
|
return AnthropicPassthroughClient(
|
|
"https://litellm.test", "tok", transport=httpx.MockTransport(handler)
|
|
)
|
|
|
|
|
|
def test_client_posts_to_the_messages_route_with_version_header() -> None:
|
|
seen: dict = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
seen["url"] = str(request.url)
|
|
seen["version"] = request.headers.get("anthropic-version")
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"model": "claude-haiku-4-5",
|
|
"content": [{"type": "text", "text": "hi"}],
|
|
},
|
|
)
|
|
|
|
events = stored([UserMessage(text="hello", source="m")])
|
|
[event] = client(handler).complete(events, None, "claude-haiku-4-5")
|
|
assert seen["url"] == "https://litellm.test/v1/messages"
|
|
assert seen["version"] == "2023-06-01"
|
|
assert event == AssistantText(text="hi", model="claude-haiku-4-5")
|
|
|
|
|
|
def test_client_maps_tool_use_to_local_call_ids() -> None:
|
|
response = {
|
|
"model": "claude-haiku-4-5",
|
|
"content": [
|
|
{"type": "text", "text": "running it"},
|
|
{
|
|
"type": "tool_use",
|
|
"id": "toolu_vendor_xyz",
|
|
"name": "run_command",
|
|
"input": {"command": "pytest"},
|
|
},
|
|
],
|
|
}
|
|
events = stored([UserMessage(text="go", source="m")])
|
|
text, call = client(lambda r: httpx.Response(200, json=response)).complete(
|
|
events, TOOLS, "claude-haiku-4-5"
|
|
)
|
|
assert isinstance(text, AssistantText)
|
|
assert isinstance(call, ToolCall)
|
|
assert call.arguments == {"command": "pytest"}
|
|
assert call.call_id != "toolu_vendor_xyz" # provider id never a join key
|
|
assert call.call_id.startswith("call-")
|
|
|
|
|
|
def test_client_errors_are_llm_errors() -> None:
|
|
events = stored([UserMessage(text="go", source="m")])
|
|
with pytest.raises(LLMError, match="400"):
|
|
client(lambda r: httpx.Response(400, text="bad")).complete(
|
|
events, None, "claude-haiku-4-5"
|
|
)
|
|
with pytest.raises(LLMError, match="malformed"):
|
|
client(lambda r: httpx.Response(200, json={"oops": 1})).complete(
|
|
events, None, "claude-haiku-4-5"
|
|
)
|