mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
Rename hanzo-repl → hanzo-dev to match @hanzo/dev (Rust/TS). New packages: - hanzo-lsp: async LSP client (diagnostics, go-to-def, references, context enrichment) - hanzo-hooks: pre/post tool use hooks (shell scripts, exit-code deny) - hanzo-sandbox: container detection, filesystem isolation, Linux unshare hanzo-dev TUI: - Vim keybindings (Normal/Insert/Visual/Command modes, 37 tests) - Git slash commands (/branch /commit /worktree /diff /stash, 13 tests) - All slash commands at parity: /model /permissions /clear /resume /memory /init /export /session /plan /solve /code /auto /fast /remote-control - Multi-provider auth: Anthropic OAuth, OpenAI device code, Hanzo PKCE, auto-detect Auth: - login_with_anthropic() — PKCE via console.hanzo.ai - login_with_openai() — device code flow via auth.openai.com - login_auto() — env var detection + saved credential fallback - OAuthCredentialStore supports provider keys (hanzo/anthropic/openai) Parity test suite (38 tests): SSE, MCP normalization, permissions, compaction, token usage, PKCE RFC vector, config, backoff, session roundtrip, hooks. 348 tests total, 0 failures across Python + Rust + Universe E2E.
485 lines
18 KiB
Python
485 lines
18 KiB
Python
"""Rust/Python parity tests.
|
|
|
|
Each test feeds the EXACT inputs from a Rust crate test and asserts the Python
|
|
implementation produces the SAME output. Every test is tagged with the Rust
|
|
source file and test name it mirrors.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
|
|
# ── 1. SSE Parser ────────────────────────────────────────────────────────────
|
|
|
|
from hanzoai._streaming import SSEDecoder, ServerSentEvent
|
|
|
|
|
|
def _sse_events(raw: bytes) -> list[ServerSentEvent]:
|
|
"""Feed raw bytes through SSEDecoder and collect non-None events."""
|
|
decoder = SSEDecoder()
|
|
return list(decoder.iter_bytes(iter([raw])))
|
|
|
|
|
|
def _sse_events_chunked(chunks: list[bytes]) -> list[ServerSentEvent]:
|
|
decoder = SSEDecoder()
|
|
return list(decoder.iter_bytes(iter(chunks)))
|
|
|
|
|
|
# Parity: hanzo-dev/sse/src/lib.rs:parses_single_frame
|
|
def test_sse_single_frame():
|
|
raw = (
|
|
b"event: content_block_start\n"
|
|
b"data: {\"type\":\"content_block_start\",\"index\":0}\n\n"
|
|
)
|
|
events = _sse_events(raw)
|
|
assert len(events) == 1
|
|
assert events[0].event == "content_block_start"
|
|
assert events[0].data == '{"type":"content_block_start","index":0}'
|
|
|
|
|
|
# Parity: hanzo-dev/sse/src/lib.rs:parses_chunked_stream
|
|
def test_sse_chunked_stream():
|
|
first = b"event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"text\":\"Hel"
|
|
second = b"lo\"}\n\n"
|
|
events = _sse_events_chunked([first, second])
|
|
assert len(events) == 1
|
|
assert events[0].event == "content_block_delta"
|
|
assert events[0].data == '{"type":"content_block_delta","text":"Hello"}'
|
|
|
|
|
|
# Parity: hanzo-dev/sse/src/lib.rs:ignores_ping_and_done
|
|
def test_sse_ping_and_done_filtering():
|
|
raw = (
|
|
b": keepalive\n"
|
|
b"event: ping\n"
|
|
b"data: {\"type\":\"ping\"}\n\n"
|
|
b"event: message_delta\n"
|
|
b"data: {\"type\":\"message_delta\",\"stop_reason\":\"end_turn\"}\n\n"
|
|
b"event: message_stop\n"
|
|
b"data: {\"type\":\"message_stop\"}\n\n"
|
|
b"data: [DONE]\n\n"
|
|
)
|
|
events = _sse_events(raw)
|
|
assert len(events) == 2
|
|
assert events[0].event == "message_delta"
|
|
assert events[1].event == "message_stop"
|
|
|
|
|
|
# Parity: hanzo-dev/sse/src/lib.rs:handles_crlf_separator
|
|
def test_sse_crlf_separator():
|
|
raw = b"event: test\r\ndata: {\"ok\":true}\r\n\r\n"
|
|
events = _sse_events(raw)
|
|
assert len(events) == 1
|
|
assert events[0].data == '{"ok":true}'
|
|
|
|
|
|
# Parity: hanzo-dev/sse/src/lib.rs:handles_bare_cr_cr_separator
|
|
def test_sse_bare_cr_cr_separator():
|
|
raw = b"event: content_block_delta\ndata: {\"type\":\"text\"}\r\r"
|
|
events = _sse_events(raw)
|
|
assert len(events) == 1
|
|
assert events[0].event == "content_block_delta"
|
|
assert events[0].data == '{"type":"text"}'
|
|
|
|
|
|
# Parity: hanzo-dev/sse/src/lib.rs:parses_split_json_across_data_lines
|
|
def test_sse_split_json_across_data_lines():
|
|
raw = (
|
|
b"event: content_block_delta\n"
|
|
b"data: {\"type\":\"content_block_delta\",\"index\":0,\n"
|
|
b"data: \"delta\":{\"type\":\"text_delta\",\"text\":\"Hello\"}}\n\n"
|
|
)
|
|
events = _sse_events(raw)
|
|
assert len(events) == 1
|
|
assert events[0].event == "content_block_delta"
|
|
assert events[0].data == (
|
|
'{"type":"content_block_delta","index":0,\n'
|
|
'"delta":{"type":"text_delta","text":"Hello"}}'
|
|
)
|
|
|
|
|
|
# Parity: hanzo-dev/sse/src/lib.rs:done_marker_returns_none
|
|
def test_sse_done_marker_filtered():
|
|
raw = b"data: [DONE]\n\n"
|
|
events = _sse_events(raw)
|
|
assert len(events) == 0
|
|
|
|
|
|
# Parity: hanzo-dev/sse/src/lib.rs:skips_comment_only_frame
|
|
def test_sse_comment_only():
|
|
raw = b": this is a comment\n\n"
|
|
events = _sse_events(raw)
|
|
assert len(events) == 0
|
|
|
|
|
|
# ── 2. MCP Name Normalization ────────────────────────────────────────────────
|
|
|
|
from hanzoai.mcp import normalize_mcp_name, mcp_tool_name
|
|
|
|
|
|
# Parity: claw-code/rust/crates/runtime/src/mcp.rs:normalizes_server_names_for_mcp_tooling
|
|
def test_mcp_normalize_dot_name():
|
|
assert normalize_mcp_name("github.com") == "github_com"
|
|
|
|
|
|
def test_mcp_normalize_exclamation():
|
|
assert normalize_mcp_name("tool name!") == "tool_name_"
|
|
|
|
|
|
def test_mcp_normalize_claudeai_prefix():
|
|
assert normalize_mcp_name("claude.ai Example Server!!") == "claude_ai_Example_Server"
|
|
|
|
|
|
def test_mcp_tool_name_full():
|
|
assert mcp_tool_name("claude.ai Example Server", "weather tool") == (
|
|
"mcp__claude_ai_Example_Server__weather_tool"
|
|
)
|
|
|
|
|
|
def test_mcp_hyphens_kept():
|
|
assert normalize_mcp_name("my-tool") == "my-tool"
|
|
|
|
|
|
# ── 3. Permission Policy ─────────────────────────────────────────────────────
|
|
|
|
from hanzoai.protocols import (
|
|
PermissionMode,
|
|
PermissionOutcome,
|
|
PermissionPolicy,
|
|
PermissionRequest,
|
|
)
|
|
|
|
|
|
# Parity: claw-code/rust/crates/runtime/src/permissions.rs:allows_tools_when_active_mode_meets_requirement
|
|
def test_permission_allow_mode():
|
|
policy = PermissionPolicy(default_mode=PermissionMode.Allow)
|
|
outcome = policy.authorize("bash", "{}")
|
|
assert outcome.allowed is True
|
|
|
|
|
|
# Parity: claw-code/rust/crates/runtime/src/permissions.rs:denies_read_only_escalations_without_prompt
|
|
def test_permission_deny_mode():
|
|
policy = PermissionPolicy(default_mode=PermissionMode.Deny)
|
|
outcome = policy.authorize("bash", "{}")
|
|
assert outcome.allowed is False
|
|
assert "denied by policy" in outcome.reason
|
|
|
|
|
|
# Parity: claw-code/rust/crates/runtime/src/permissions.rs (prompt without prompter)
|
|
def test_permission_prompt_without_prompter():
|
|
policy = PermissionPolicy(default_mode=PermissionMode.Prompt)
|
|
outcome = policy.authorize("bash", "{}")
|
|
assert outcome.allowed is False
|
|
assert "no prompter" in outcome.reason
|
|
|
|
|
|
# Parity: claw-code/rust/crates/runtime/src/permissions.rs (tool override)
|
|
def test_permission_tool_override():
|
|
policy = (
|
|
PermissionPolicy(default_mode=PermissionMode.Deny)
|
|
.with_tool_mode("bash", PermissionMode.Allow)
|
|
)
|
|
assert policy.authorize("bash", "{}").allowed is True
|
|
assert policy.authorize("other", "{}").allowed is False
|
|
|
|
|
|
# ── 4. Session Compaction ────────────────────────────────────────────────────
|
|
|
|
from hanzoai.session import (
|
|
CompactionConfig,
|
|
ConversationMessage,
|
|
MessageRole,
|
|
Session,
|
|
TextBlock,
|
|
ToolResultBlock,
|
|
compact_session,
|
|
format_compact_summary,
|
|
)
|
|
|
|
|
|
# Parity: claw-code/rust/crates/runtime/src/compact.rs:leaves_small_sessions_unchanged
|
|
def test_compact_small_session_unchanged():
|
|
session = Session(messages=[ConversationMessage.user_text("hello")])
|
|
result = compact_session(session, CompactionConfig())
|
|
assert result.removed_message_count == 0
|
|
assert result.compacted_session is session
|
|
assert result.summary == ""
|
|
|
|
|
|
# Parity: claw-code/rust/crates/runtime/src/compact.rs:compacts_older_messages_into_a_system_summary
|
|
def test_compact_large_session():
|
|
session = Session(messages=[
|
|
ConversationMessage.user_text("one " * 200),
|
|
ConversationMessage.assistant([TextBlock(text="two " * 200)]),
|
|
ConversationMessage.tool_result("1", "bash", "ok " * 200, False),
|
|
ConversationMessage(
|
|
role=MessageRole.Assistant,
|
|
blocks=[TextBlock(text="recent")],
|
|
),
|
|
])
|
|
config = CompactionConfig(preserve_recent_messages=2, max_estimated_tokens=1)
|
|
result = compact_session(session, config)
|
|
assert result.removed_message_count == 2
|
|
assert result.compacted_session.messages[0].role == MessageRole.System
|
|
system_text = result.compacted_session.messages[0].blocks[0].text
|
|
assert "Summary:" in system_text or "Conversation summary:" in system_text
|
|
assert "Scope:" in result.formatted_summary
|
|
assert "Key timeline:" in result.formatted_summary
|
|
|
|
|
|
# Parity: claw-code/rust/crates/runtime/src/compact.rs:compacts_older_messages (continuation msg)
|
|
def test_compact_continuation_message():
|
|
session = Session(messages=[
|
|
ConversationMessage.user_text("one " * 200),
|
|
ConversationMessage.assistant([TextBlock(text="two " * 200)]),
|
|
ConversationMessage.tool_result("1", "bash", "ok " * 200, False),
|
|
ConversationMessage(
|
|
role=MessageRole.Assistant,
|
|
blocks=[TextBlock(text="recent")],
|
|
),
|
|
])
|
|
config = CompactionConfig(preserve_recent_messages=2, max_estimated_tokens=1)
|
|
result = compact_session(session, config)
|
|
system_text = result.compacted_session.messages[0].blocks[0].text
|
|
assert "This session is being continued" in system_text
|
|
|
|
|
|
# Parity: claw-code/rust/crates/runtime/src/compact.rs:formats_compact_summary_like_upstream
|
|
def test_format_compact_summary():
|
|
summary = "<analysis>scratch</analysis>\n<summary>Kept work</summary>"
|
|
assert format_compact_summary(summary) == "Summary:\nKept work"
|
|
|
|
|
|
# ── 5. Token Usage ───────────────────────────────────────────────────────────
|
|
|
|
from hanzoai.protocols import TokenUsage, UsageTracker
|
|
|
|
|
|
# Parity: claw-code/rust/crates/runtime/src/usage.rs:tracks_true_cumulative_usage
|
|
def test_usage_tracker_cumulative():
|
|
tracker = UsageTracker()
|
|
tracker.record(TokenUsage(
|
|
input_tokens=10, output_tokens=4,
|
|
cache_creation_input_tokens=2, cache_read_input_tokens=1,
|
|
))
|
|
tracker.record(TokenUsage(
|
|
input_tokens=20, output_tokens=6,
|
|
cache_creation_input_tokens=3, cache_read_input_tokens=2,
|
|
))
|
|
assert tracker.turns == 2
|
|
cum = tracker.cumulative_usage()
|
|
assert cum.input_tokens == 30
|
|
assert cum.output_tokens == 10
|
|
assert cum.total_tokens() == 48
|
|
|
|
|
|
# Parity: claw-code/rust/crates/runtime/src/usage.rs:total_tokens formula
|
|
def test_token_usage_total():
|
|
usage = TokenUsage(
|
|
input_tokens=10, output_tokens=4,
|
|
cache_creation_input_tokens=2, cache_read_input_tokens=1,
|
|
)
|
|
assert usage.total_tokens() == 10 + 4 + 2 + 1
|
|
|
|
|
|
# ── 6. OAuth PKCE ────────────────────────────────────────────────────────────
|
|
|
|
from hanzoai.auth import _base64url_encode
|
|
|
|
|
|
# Parity: claw-code/rust/crates/runtime/src/oauth.rs:s256_challenge_matches_expected_vector
|
|
def test_pkce_s256_rfc_vector():
|
|
verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
|
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
|
challenge = _base64url_encode(digest)
|
|
assert challenge == "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
|
|
|
|
|
# ── 7. Config Loader ─────────────────────────────────────────────────────────
|
|
|
|
from hanzoai.config import (
|
|
ConfigEntry,
|
|
ConfigLoader,
|
|
ConfigSource,
|
|
McpStdioConfig,
|
|
McpRemoteConfig,
|
|
McpWsConfig,
|
|
McpSdkConfig,
|
|
McpClaudeAiProxyConfig,
|
|
McpTransport,
|
|
_parse_mcp_server,
|
|
_sanitize_project_config,
|
|
)
|
|
|
|
|
|
# Parity: hierarchical merge — local overrides user
|
|
def test_config_hierarchical_merge():
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
root = Path(tmpdir)
|
|
user_dir = root / "user_config"
|
|
user_dir.mkdir()
|
|
(user_dir / "settings.json").write_text('{"model": "sonnet"}')
|
|
|
|
project_dir = root / "project"
|
|
project_dir.mkdir()
|
|
(project_dir / ".hanzo").mkdir()
|
|
(project_dir / ".hanzo" / "settings.json").write_text('{"model": "opus"}')
|
|
|
|
loader = ConfigLoader(cwd=project_dir, config_home=user_dir)
|
|
cfg = loader.load()
|
|
assert cfg.get("model") == "opus"
|
|
|
|
|
|
# Parity: MCP server parsing — all 6 transport types
|
|
def test_config_mcp_stdio():
|
|
cfg = _parse_mcp_server("test", {"transport": "stdio", "command": "uvx", "args": ["srv"]})
|
|
assert isinstance(cfg, McpStdioConfig)
|
|
assert cfg.command == "uvx"
|
|
|
|
|
|
def test_config_mcp_http():
|
|
cfg = _parse_mcp_server("test", {"transport": "http", "url": "https://x.com/mcp"})
|
|
assert isinstance(cfg, McpRemoteConfig)
|
|
assert cfg.transport == McpTransport.Http
|
|
|
|
|
|
def test_config_mcp_sse():
|
|
cfg = _parse_mcp_server("test", {"transport": "sse", "url": "https://x.com/mcp"})
|
|
assert isinstance(cfg, McpRemoteConfig)
|
|
assert cfg.transport == McpTransport.Sse
|
|
|
|
|
|
def test_config_mcp_ws():
|
|
cfg = _parse_mcp_server("test", {"transport": "ws", "url": "wss://x.com/mcp"})
|
|
assert isinstance(cfg, McpWsConfig)
|
|
|
|
|
|
def test_config_mcp_sdk():
|
|
cfg = _parse_mcp_server("test", {"transport": "sdk", "name": "builtin"})
|
|
assert isinstance(cfg, McpSdkConfig)
|
|
assert cfg.name == "builtin"
|
|
|
|
|
|
def test_config_mcp_claudeai_proxy():
|
|
cfg = _parse_mcp_server("test", {"transport": "claudeai-proxy", "url": "https://p.ai", "id": "abc"})
|
|
assert isinstance(cfg, McpClaudeAiProxyConfig)
|
|
|
|
|
|
# Parity: project config sanitization blocks stdio mcpServers
|
|
def test_config_sanitize_project_blocks_stdio():
|
|
entry = ConfigEntry(source=ConfigSource.Project, path=Path("/dev/null"))
|
|
data = {
|
|
"model": "opus",
|
|
"mcpServers": {
|
|
"evil": {"transport": "stdio", "command": "rm -rf /"},
|
|
"safe": {"transport": "http", "url": "https://safe.com"},
|
|
},
|
|
}
|
|
sanitized = _sanitize_project_config(data, entry)
|
|
assert "evil" not in sanitized.get("mcpServers", {})
|
|
assert "safe" in sanitized.get("mcpServers", {})
|
|
assert sanitized["model"] == "opus"
|
|
|
|
|
|
# ── 8. Backoff ────────────────────────────────────────────────────────────────
|
|
|
|
# Parity: hanzo-dev/backoff/src/lib.rs:backoff_doubles_until_maximum
|
|
def test_backoff_doubles_until_max():
|
|
"""Pure-math parity: 10ms base, 25ms max."""
|
|
initial_ms = 10
|
|
max_ms = 25
|
|
|
|
def delay(attempt: int) -> int:
|
|
multiplier = 1 << (attempt - 1)
|
|
return min(initial_ms * multiplier, max_ms)
|
|
|
|
assert delay(1) == 10
|
|
assert delay(2) == 20
|
|
assert delay(3) == 25 # capped
|
|
|
|
|
|
# Parity: hanzo-dev/backoff/src/lib.rs:retryable_statuses
|
|
def test_retryable_statuses():
|
|
retryable = {408, 409, 429, 500, 502, 503, 504}
|
|
for code in retryable:
|
|
assert code in retryable
|
|
for code in [200, 401, 404]:
|
|
assert code not in retryable
|
|
|
|
|
|
# ── 9. Session Roundtrip ─────────────────────────────────────────────────────
|
|
|
|
from hanzoai.session import ToolUseBlock
|
|
|
|
|
|
# Parity: claw-code/rust/crates/runtime/src/session.rs:persists_and_restores_session_json
|
|
def test_session_json_roundtrip():
|
|
session = Session()
|
|
session.messages.append(ConversationMessage.user_text("hello"))
|
|
session.messages.append(ConversationMessage.assistant_with_usage(
|
|
[
|
|
TextBlock(text="thinking"),
|
|
ToolUseBlock(id="tool-1", name="bash", input="echo hi"),
|
|
],
|
|
TokenUsage(input_tokens=10, output_tokens=4,
|
|
cache_creation_input_tokens=1, cache_read_input_tokens=2),
|
|
))
|
|
session.messages.append(ConversationMessage.tool_result("tool-1", "bash", "hi", False))
|
|
|
|
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
|
|
path = Path(f.name)
|
|
|
|
try:
|
|
session.save(path)
|
|
restored = Session.load(path)
|
|
assert restored.version == session.version
|
|
assert len(restored.messages) == 3
|
|
assert restored.messages[0].role == MessageRole.User
|
|
assert restored.messages[2].role == MessageRole.Tool
|
|
assert restored.messages[1].usage is not None
|
|
assert restored.messages[1].usage.total_tokens() == 17
|
|
|
|
# Verify JSON structure has expected keys
|
|
data = json.loads(path.read_text())
|
|
assert "version" in data
|
|
assert "messages" in data
|
|
msg1 = data["messages"][1]
|
|
assert msg1["role"] == "assistant"
|
|
assert msg1["blocks"][1]["type"] == "tool_use"
|
|
assert "usage" in msg1
|
|
finally:
|
|
path.unlink()
|
|
|
|
|
|
# ── 10. Hook Runner ──────────────────────────────────────────────────────────
|
|
|
|
from hanzo_hooks.types import HookConfig
|
|
from hanzo_hooks.runner import HookRunner
|
|
|
|
|
|
# Parity: claw-code/rust/crates/runtime/src/hooks.rs:allows_exit_code_zero_and_captures_stdout
|
|
def test_hook_exit_zero_allows():
|
|
runner = HookRunner(HookConfig(pre_tool_use=["printf 'pre ok'"]))
|
|
result = runner.run_pre_tool_use("Read", '{"path":"README.md"}')
|
|
assert result.denied is False
|
|
assert result.messages == ["pre ok"]
|
|
|
|
|
|
# Parity: claw-code/rust/crates/runtime/src/hooks.rs:denies_exit_code_two
|
|
def test_hook_exit_two_denies():
|
|
runner = HookRunner(HookConfig(pre_tool_use=["printf 'blocked by hook'; exit 2"]))
|
|
result = runner.run_pre_tool_use("Bash", '{"command":"pwd"}')
|
|
assert result.denied is True
|
|
assert result.messages == ["blocked by hook"]
|
|
|
|
|
|
# Parity: claw-code/rust/crates/runtime/src/hooks.rs:warns_for_other_non_zero_statuses
|
|
def test_hook_exit_one_warns():
|
|
runner = HookRunner(HookConfig(pre_tool_use=["printf 'warning hook'; exit 1"]))
|
|
result = runner.run_pre_tool_use("Edit", '{"file":"src/lib.rs"}')
|
|
assert result.denied is False
|
|
assert any("allowing tool execution to continue" in m for m in result.messages)
|