Files
guyuming bf5931e7c1 fix(runtime): close API clients on shutdown
Close API clients when the runtime shuts down to release HTTP connection pool resources, and surface the active session ID in /session output.

Cherry-picked from PR #273. Excluded the embedded-wrapper session resolver and memory parser API changes because they need a narrower public API design.
2026-05-27 10:19:21 +00:00

1421 lines
57 KiB
Python

"""Tests for slash command handlers."""
from __future__ import annotations
import subprocess
from pathlib import Path
import pytest
import openharness.commands.registry as registry_module
from openharness.commands.registry import (
CommandContext,
MemoryCommandBackend,
create_default_command_registry,
lookup_skill_slash_command,
)
from openharness.autopilot import RepoVerificationStep
from openharness.config.paths import get_feedback_log_path, get_project_issue_file, get_project_pr_comments_file
from openharness.config.settings import load_settings, save_settings, Settings
from openharness.engine.messages import ConversationMessage, TextBlock
from openharness.engine.query_engine import QueryEngine
from openharness.memory.paths import get_project_memory_dir
from openharness.mcp.types import McpHttpServerConfig, McpStdioServerConfig
from openharness.permissions import PermissionChecker
from openharness.plugins.types import PluginCommandDefinition
from openharness.state import AppState, AppStateStore
from openharness.tasks import get_task_manager
from openharness.tools import create_default_tool_registry
class FakeApiClient:
async def stream_message(self, request):
del request
raise AssertionError("stream_message should not be called in command tests")
def _make_engine(tmp_path: Path) -> QueryEngine:
return QueryEngine(
api_client=FakeApiClient(),
tool_registry=create_default_tool_registry(),
permission_checker=PermissionChecker(load_settings().permission),
cwd=tmp_path,
model="claude-test",
system_prompt="system",
)
def _make_context(tmp_path: Path) -> CommandContext:
tool_registry = create_default_tool_registry()
return CommandContext(
engine=QueryEngine(
api_client=FakeApiClient(),
tool_registry=tool_registry,
permission_checker=PermissionChecker(load_settings().permission),
cwd=tmp_path,
model="claude-test",
system_prompt="system",
),
cwd=str(tmp_path),
tool_registry=tool_registry,
app_state=AppStateStore(
AppState(
model="claude-test",
permission_mode="default",
theme="default",
keybindings={},
)
),
)
@pytest.mark.asyncio
async def test_permissions_command_persists(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
registry = create_default_command_registry()
command, args = registry.lookup("/permissions set full_auto")
assert command is not None
result = await command.handler(args, CommandContext(engine=_make_engine(tmp_path), cwd=str(tmp_path)))
assert "Auto" in result.message
assert load_settings().permission.mode == "full_auto"
@pytest.mark.asyncio
async def test_permissions_command_is_marked_local_only(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
registry = create_default_command_registry()
command, _ = registry.lookup("/permissions set full_auto")
assert command is not None
assert command.remote_invocable is False
@pytest.mark.asyncio
async def test_permissions_command_supports_explicit_remote_admin_opt_in(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
registry = create_default_command_registry()
command, _ = registry.lookup("/permissions set full_auto")
assert command is not None
assert getattr(command, "remote_admin_opt_in", False) is True
@pytest.mark.asyncio
async def test_stop_command_explains_interrupt_paths(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
registry = create_default_command_registry()
command, args = registry.lookup("/stop")
assert command is not None
result = await command.handler(args, CommandContext(engine=_make_engine(tmp_path), cwd=str(tmp_path)))
assert "/stop" in result.message
assert "Esc/Ctrl+C" in result.message
@pytest.mark.asyncio
async def test_plugin_command_is_marked_local_only(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
registry = create_default_command_registry()
command, _ = registry.lookup("/plugin list")
assert command is not None
assert command.remote_invocable is False
@pytest.mark.asyncio
async def test_plugin_command_supports_explicit_remote_admin_opt_in(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
registry = create_default_command_registry()
command, _ = registry.lookup("/plugin list")
assert command is not None
assert getattr(command, "remote_admin_opt_in", False) is True
@pytest.mark.asyncio
async def test_reload_plugins_command_is_marked_local_only(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
registry = create_default_command_registry()
command, _ = registry.lookup("/reload-plugins")
assert command is not None
assert command.remote_invocable is False
@pytest.mark.asyncio
async def test_reload_plugins_command_supports_explicit_remote_admin_opt_in(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
registry = create_default_command_registry()
command, _ = registry.lookup("/reload-plugins")
assert command is not None
assert getattr(command, "remote_admin_opt_in", False) is True
@pytest.mark.asyncio
async def test_bridge_command_is_marked_local_only(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
registry = create_default_command_registry()
command, _ = registry.lookup("/bridge spawn id")
assert command is not None
assert command.remote_invocable is False
@pytest.mark.asyncio
async def test_bridge_command_supports_explicit_remote_admin_opt_in(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
registry = create_default_command_registry()
command, _ = registry.lookup("/bridge spawn id")
assert command is not None
assert getattr(command, "remote_admin_opt_in", False) is True
@pytest.mark.asyncio
async def test_autopilot_command_is_marked_local_only(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
registry = create_default_command_registry()
command, _ = registry.lookup("/autopilot run-next")
assert command is not None
assert command.remote_invocable is False
@pytest.mark.asyncio
async def test_autopilot_command_supports_explicit_remote_admin_opt_in(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
registry = create_default_command_registry()
command, _ = registry.lookup("/autopilot run-next")
assert command is not None
assert getattr(command, "remote_admin_opt_in", False) is True
@pytest.mark.asyncio
async def test_diff_command_is_marked_local_only(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
registry = create_default_command_registry()
command, _ = registry.lookup("/diff full")
assert command is not None
assert command.remote_invocable is False
@pytest.mark.asyncio
async def test_diff_command_supports_explicit_remote_admin_opt_in(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
registry = create_default_command_registry()
command, _ = registry.lookup("/diff full")
assert command is not None
assert getattr(command, "remote_admin_opt_in", False) is True
@pytest.mark.asyncio
async def test_project_context_commands_are_marked_local_only(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
registry = create_default_command_registry()
for payload in (
"/issue set Remote supplied issue :: marker",
"/pr_comments add src/app.py:1 :: marker",
):
command, _ = registry.lookup(payload)
assert command is not None
assert command.remote_invocable is False, payload
@pytest.mark.asyncio
async def test_project_context_commands_support_explicit_remote_admin_opt_in(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
registry = create_default_command_registry()
for payload in (
"/issue set Remote supplied issue :: marker",
"/pr_comments add src/app.py:1 :: marker",
):
command, _ = registry.lookup(payload)
assert command is not None
assert getattr(command, "remote_admin_opt_in", False) is True, payload
@pytest.mark.asyncio
async def test_tasks_command_is_marked_local_only(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
registry = create_default_command_registry()
command, _ = registry.lookup("/tasks run id")
assert command is not None
assert command.remote_invocable is False
@pytest.mark.asyncio
async def test_tasks_command_supports_explicit_remote_admin_opt_in(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
registry = create_default_command_registry()
command, _ = registry.lookup("/tasks run id")
assert command is not None
assert getattr(command, "remote_admin_opt_in", False) is True
@pytest.mark.asyncio
async def test_sensitive_control_plane_commands_are_local_only(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
registry = create_default_command_registry()
for payload in (
"/config show",
"/login TEST_KEY",
"/logout",
"/mcp",
"/provider",
"/model show",
"/commit remote requested commit",
"/ship",
"/resume",
"/resume session-from-another-sender",
"/summary 10",
):
command, _ = registry.lookup(payload)
assert command is not None
assert command.remote_invocable is False, payload
assert command.remote_admin_opt_in is True, payload
@pytest.mark.asyncio
async def test_config_show_redacts_nested_mcp_and_vision_secrets(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
settings = Settings(
api_key="TOP_LEVEL_FAKE_SECRET",
mcp_servers={
"internal-http": McpHttpServerConfig(
url="https://mcp.internal",
headers={
"Authorization": "Bearer MCP_FAKE_SECRET",
"X-Token": "RAW_FAKE_TOKEN",
"X-Public": "non-secret-value",
},
),
"local-stdio": McpStdioServerConfig(
command="server",
env={
"MCP_AUTH_TOKEN": "STDIO_FAKE_SECRET",
"SAFE_SETTING": "visible",
},
),
},
vision={
"model": "vision-test",
"api_key": "VISION_FAKE_SECRET",
"base_url": "https://vision.example",
},
)
monkeypatch.setattr(registry_module, "load_settings", lambda: settings)
registry = create_default_command_registry()
command, args = registry.lookup("/config show")
assert command is not None
result = await command.handler(args, CommandContext(engine=_make_engine(tmp_path), cwd=str(tmp_path)))
for secret in (
"TOP_LEVEL_FAKE_SECRET",
"MCP_FAKE_SECRET",
"RAW_FAKE_TOKEN",
"STDIO_FAKE_SECRET",
"VISION_FAKE_SECRET",
):
assert secret not in result.message
assert "[REDACTED]" in result.message
assert "non-secret-value" in result.message
assert "visible" in result.message
@pytest.mark.asyncio
async def test_memory_show_rejects_path_traversal(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
registry = create_default_command_registry()
command, args = registry.lookup("/memory show ../../../../../../etc/hosts")
assert command is not None
result = await command.handler(args, CommandContext(engine=_make_engine(tmp_path), cwd=str(tmp_path)))
assert result.message == "Memory entry path must stay within the configured memory directory."
@pytest.mark.asyncio
async def test_memory_show_reads_normal_entries_with_md_fallback(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
registry = create_default_command_registry()
add_command, add_args = registry.lookup("/memory add Notes :: hello world")
assert add_command is not None
await add_command.handler(add_args, CommandContext(engine=_make_engine(tmp_path), cwd=str(tmp_path)))
show_command, show_args = registry.lookup("/memory show Notes")
result = await show_command.handler(show_args, CommandContext(engine=_make_engine(tmp_path), cwd=str(tmp_path)))
assert "hello world" in result.message
@pytest.mark.asyncio
async def test_model_command_persists(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
registry = create_default_command_registry()
command, args = registry.lookup("/model opus")
assert command is not None
result = await command.handler(args, CommandContext(engine=_make_engine(tmp_path), cwd=str(tmp_path)))
assert "opus" in result.message
assert load_settings().resolve_profile()[1].last_model == "opus"
assert load_settings().model == "claude-opus-4-6"
@pytest.mark.asyncio
async def test_model_command_accepts_direct_value(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
registry = create_default_command_registry()
command, args = registry.lookup("/model gpt-5.4")
assert command is not None
result = await command.handler(args, CommandContext(engine=_make_engine(tmp_path), cwd=str(tmp_path)))
assert "gpt-5.4" in result.message
assert load_settings().model == "gpt-5.4"
@pytest.mark.asyncio
async def test_model_command_lists_profile_model_allowlist(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
save_settings(
Settings().model_copy(
update={
"active_profile": "local-llm",
"provider": "openai",
"api_format": "openai",
"base_url": "http://localhost:8000/v1",
"model": "deepseek-chat",
"profiles": {
"local-llm": {
"label": "Local LLM",
"provider": "openai",
"api_format": "openai",
"auth_source": "openai_api_key",
"default_model": "deepseek-chat",
"last_model": "deepseek-chat",
"base_url": "http://localhost:8000/v1",
"allowed_models": ["deepseek-chat", "qwen-vl"],
}
},
}
)
)
registry = create_default_command_registry()
command, args = registry.lookup("/model list")
assert command is not None
result = await command.handler(args, CommandContext(engine=_make_engine(tmp_path), cwd=str(tmp_path)))
assert "Switchable models for profile 'local-llm'" in result.message
assert "- deepseek-chat" in result.message
assert "- qwen-vl" in result.message
@pytest.mark.asyncio
async def test_model_command_adds_model_to_profile_allowlist(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
save_settings(
Settings().model_copy(
update={
"active_profile": "local-llm",
"provider": "openai",
"api_format": "openai",
"base_url": "http://localhost:8000/v1",
"model": "deepseek-chat",
"profiles": {
"local-llm": {
"label": "Local LLM",
"provider": "openai",
"api_format": "openai",
"auth_source": "openai_api_key",
"default_model": "deepseek-chat",
"last_model": "deepseek-chat",
"base_url": "http://localhost:8000/v1",
"allowed_models": ["deepseek-chat"],
}
},
}
)
)
registry = create_default_command_registry()
command, args = registry.lookup("/model add qwen-vl")
assert command is not None
result = await command.handler(args, CommandContext(engine=_make_engine(tmp_path), cwd=str(tmp_path)))
assert result.refresh_runtime is True
assert load_settings().resolve_profile()[1].allowed_models == ["deepseek-chat", "qwen-vl"]
@pytest.mark.asyncio
async def test_model_command_remove_current_model_resets_to_default(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
save_settings(
Settings().model_copy(
update={
"active_profile": "local-llm",
"provider": "openai",
"api_format": "openai",
"base_url": "http://localhost:8000/v1",
"model": "qwen-vl",
"profiles": {
"local-llm": {
"label": "Local LLM",
"provider": "openai",
"api_format": "openai",
"auth_source": "openai_api_key",
"default_model": "deepseek-chat",
"last_model": "qwen-vl",
"base_url": "http://localhost:8000/v1",
"allowed_models": ["deepseek-chat", "qwen-vl"],
}
},
}
)
)
registry = create_default_command_registry()
context = _make_context(tmp_path)
command, args = registry.lookup("/model remove qwen-vl")
assert command is not None
result = await command.handler(args, context)
profile = load_settings().resolve_profile()[1]
assert result.refresh_runtime is True
assert profile.allowed_models == ["deepseek-chat"]
assert profile.last_model == ""
assert context.engine.model == "deepseek-chat"
@pytest.mark.asyncio
async def test_model_command_clear_removes_profile_allowlist(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
save_settings(
Settings().model_copy(
update={
"active_profile": "local-llm",
"provider": "openai",
"api_format": "openai",
"base_url": "http://localhost:8000/v1",
"model": "deepseek-chat",
"profiles": {
"local-llm": {
"label": "Local LLM",
"provider": "openai",
"api_format": "openai",
"auth_source": "openai_api_key",
"default_model": "deepseek-chat",
"last_model": "deepseek-chat",
"base_url": "http://localhost:8000/v1",
"allowed_models": ["deepseek-chat", "qwen-vl"],
}
},
}
)
)
registry = create_default_command_registry()
command, args = registry.lookup("/model clear")
assert command is not None
result = await command.handler(args, CommandContext(engine=_make_engine(tmp_path), cwd=str(tmp_path)))
assert result.refresh_runtime is True
assert load_settings().resolve_profile()[1].allowed_models == []
@pytest.mark.asyncio
async def test_model_command_default_clears_profile_override(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
save_settings(
Settings().model_copy(
update={
"active_profile": "claude-api",
"profiles": {
"claude-api": {
"label": "Claude API",
"provider": "anthropic",
"api_format": "anthropic",
"auth_source": "anthropic_api_key",
"default_model": "sonnet",
"last_model": "opus",
}
},
}
)
)
registry = create_default_command_registry()
command, args = registry.lookup("/model default")
assert command is not None
result = await command.handler(args, CommandContext(engine=_make_engine(tmp_path), cwd=str(tmp_path)))
assert "reset to default" in result.message
assert load_settings().resolve_profile()[1].last_model == ""
assert load_settings().model == "claude-sonnet-4-6"
@pytest.mark.asyncio
async def test_turns_show_reports_unlimited_engine_when_session_is_unbounded(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
registry = create_default_command_registry()
context = _make_context(tmp_path)
context.engine.set_max_turns(None)
command, args = registry.lookup("/turns show")
assert command is not None
result = await command.handler(args, context)
assert "Max turns (engine): unlimited" in result.message
@pytest.mark.asyncio
async def test_turns_command_accepts_unlimited(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
registry = create_default_command_registry()
context = _make_context(tmp_path)
command, args = registry.lookup("/turns unlimited")
assert command is not None
result = await command.handler(args, context)
assert "unlimited for this session" in result.message
assert context.engine.max_turns is None
@pytest.mark.asyncio
async def test_provider_command_switches_profile_and_requests_runtime_refresh(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
save_settings(
Settings().model_copy(
update={
"profiles": {
"kimi-anthropic": {
"label": "Kimi Anthropic",
"provider": "anthropic",
"api_format": "anthropic",
"auth_source": "anthropic_api_key",
"default_model": "kimi-k2.5",
"last_model": "kimi-k2.5",
"base_url": "https://api.moonshot.cn/anthropic",
"allowed_models": ["kimi-k2.5"],
}
}
}
)
)
registry = create_default_command_registry()
context = _make_context(tmp_path)
command, args = registry.lookup("/provider kimi-anthropic")
assert command is not None
result = await command.handler(args, context)
loaded = load_settings()
assert result.refresh_runtime is True
assert loaded.active_profile == "kimi-anthropic"
assert loaded.base_url == "https://api.moonshot.cn/anthropic"
assert loaded.model == "kimi-k2.5"
@pytest.mark.asyncio
async def test_autopilot_command_add_list_and_complete(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
registry = create_default_command_registry()
context = _make_context(tmp_path)
add_command, add_args = registry.lookup("/autopilot add idea Build unified queue :: intake from issues and prs")
assert add_command is not None
add_result = await add_command.handler(add_args, context)
assert "Queued autopilot card" in add_result.message
list_command, list_args = registry.lookup("/autopilot list")
assert list_command is not None
list_result = await list_command.handler(list_args, context)
assert "Build unified queue" in list_result.message
next_command, next_args = registry.lookup("/autopilot next")
next_result = await next_command.handler(next_args, context)
assert "Build unified queue" in next_result.message
card_id = next_result.message.split()[0]
complete_command, complete_args = registry.lookup(f"/autopilot complete {card_id} implemented")
complete_result = await complete_command.handler(complete_args, context)
assert "-> completed" in complete_result.message
status_command, status_args = registry.lookup("/autopilot status")
status_result = await status_command.handler(status_args, context)
assert "- completed: 1" in status_result.message
@pytest.mark.asyncio
async def test_autopilot_command_export_dashboard(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
registry = create_default_command_registry()
context = _make_context(tmp_path)
command, args = registry.lookup("/autopilot export-dashboard")
assert command is not None
result = await command.handler(args, context)
assert "Exported autopilot dashboard:" in result.message
output_dir = Path(result.message.split(": ", 1)[1])
assert (output_dir / "index.html").exists()
assert (output_dir / "snapshot.json").exists()
@pytest.mark.asyncio
async def test_ship_command_queues_and_executes_card(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
registry = create_default_command_registry()
context = _make_context(tmp_path)
async def fake_run_agent_prompt(self, prompt: str, *, model, max_turns, permission_mode, cwd=None):
return "Implemented the requested feature."
def fake_run_verification_steps(self, policies, *, cwd=None):
return [RepoVerificationStep(command="uv run pytest -q", returncode=0, status="success")]
monkeypatch.setattr(
"openharness.autopilot.service.RepoAutopilotStore._run_agent_prompt",
fake_run_agent_prompt,
)
monkeypatch.setattr(
"openharness.autopilot.service.RepoAutopilotStore._run_verification_steps",
fake_run_verification_steps,
)
command, args = registry.lookup("/ship Build autopilot tick :: end-to-end automation")
assert command is not None
result = await command.handler(args, context)
assert "-> completed" in result.message
assert "run report:" in result.message
assert "verification report:" in result.message
@pytest.mark.asyncio
async def test_plugin_command_registers_and_submits_prompt(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
registry = create_default_command_registry(
plugin_commands=[
PluginCommandDefinition(
name="fixture:ops:restart",
description="Restart services safely",
content="Base workflow\n\n$ARGUMENTS",
user_invocable=True,
)
]
)
command, args = registry.lookup("/fixture:ops:restart api")
assert command is not None
result = await command.handler(args, _make_context(tmp_path))
assert result.submit_prompt == "Base workflow\n\napi"
@pytest.mark.asyncio
async def test_bundled_user_invocable_skill_registers_as_slash_command(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
registry = create_default_command_registry()
command, args = registry.lookup("/skill-creator create a deployment skill")
assert command is not None
assert command.name == "skill-creator"
result = await command.handler(args, _make_context(tmp_path))
assert result.submit_prompt is not None
assert "Base directory for this skill:" in result.submit_prompt
assert "# skill-creator" in result.submit_prompt
assert "Arguments: create a deployment skill" in result.submit_prompt
@pytest.mark.asyncio
async def test_context_skill_slash_command_uses_folder_name(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
extra_root = tmp_path / "ohmo-skills"
skill_dir = extra_root / "pikastream-video-meeting"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
"---\n"
"name: PikaStream Video Meeting\n"
"description: Join and summarize video meetings.\n"
"---\n\n"
"# PikaStream Video Meeting\n\n"
"Use the meeting workflow.\n",
encoding="utf-8",
)
context = _make_context(tmp_path)
context.extra_skill_dirs = (extra_root,)
parsed = lookup_skill_slash_command("/pikastream-video-meeting room 123", context)
assert parsed is not None
command, args = parsed
result = await command.handler(args, context)
assert command.name == "pikastream-video-meeting"
assert result.submit_prompt is not None
assert f"Base directory for this skill: {skill_dir.resolve()}" in result.submit_prompt
assert "PikaStream Video Meeting" in result.submit_prompt
assert "Arguments: room 123" in result.submit_prompt
@pytest.mark.asyncio
async def test_user_invocable_false_skill_is_not_slash_resolved(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
extra_root = tmp_path / "skills"
skill_dir = extra_root / "hidden"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
"---\n"
"description: Model-only helper.\n"
"user-invocable: false\n"
"---\n\n"
"# Hidden\n",
encoding="utf-8",
)
context = _make_context(tmp_path)
context.extra_skill_dirs = (extra_root,)
assert lookup_skill_slash_command("/hidden", context) is None
@pytest.mark.asyncio
async def test_project_skill_registers_as_context_slash_command(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
repo = tmp_path / "repo"
repo.mkdir()
skill_dir = repo / ".claude" / "skills" / "shipit"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
"---\n"
"description: Project ship workflow.\n"
"---\n\n"
"# Shipit\n\nShip this repo.\n",
encoding="utf-8",
)
context = _make_context(repo)
parsed = lookup_skill_slash_command("/shipit now", context)
assert parsed is not None
command, args = parsed
result = await command.handler(args, context)
assert command.name == "shipit"
assert result.submit_prompt is not None
assert f"Base directory for this skill: {skill_dir.resolve()}" in result.submit_prompt
assert "Arguments: now" in result.submit_prompt
@pytest.mark.asyncio
async def test_skills_command_lists_project_skill_path(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
repo = tmp_path / "repo"
repo.mkdir()
skill_dir = repo / ".agents" / "skills" / "triage"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text("# Triage\nTriage workflow.\n", encoding="utf-8")
registry = create_default_command_registry()
command, args = registry.lookup("/skills")
assert command is not None
result = await command.handler(args, _make_context(repo))
assert "triage (Triage) [project]" in result.message
assert str((skill_dir / "SKILL.md").resolve()) in result.message
@pytest.mark.asyncio
async def test_disable_model_invocation_skill_still_allows_user_slash(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
extra_root = tmp_path / "skills"
skill_dir = extra_root / "deploy"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
"---\n"
"description: User-triggered deploy workflow.\n"
"disable-model-invocation: true\n"
"model: gpt-5.4\n"
"---\n\n"
"# Deploy\n\n$ARGUMENTS\n",
encoding="utf-8",
)
context = _make_context(tmp_path)
context.extra_skill_dirs = (extra_root,)
parsed = lookup_skill_slash_command("/deploy staging", context)
assert parsed is not None
command, args = parsed
result = await command.handler(args, context)
assert result.submit_prompt is not None
assert result.submit_model == "gpt-5.4"
assert "staging" in result.submit_prompt
@pytest.mark.asyncio
async def test_model_command_rejects_values_outside_profile_allowlist(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
save_settings(
Settings().model_copy(
update={
"active_profile": "kimi-anthropic",
"profiles": {
"kimi-anthropic": {
"label": "Kimi Anthropic",
"provider": "anthropic",
"api_format": "anthropic",
"auth_source": "anthropic_api_key",
"default_model": "kimi-k2.5",
"last_model": "kimi-k2.5",
"base_url": "https://api.moonshot.cn/anthropic",
"allowed_models": ["kimi-k2.5"],
}
},
}
)
)
registry = create_default_command_registry()
command, args = registry.lookup("/model claude-opus-4-6")
assert command is not None
result = await command.handler(args, CommandContext(engine=_make_engine(tmp_path), cwd=str(tmp_path)))
assert "is not allowed for profile 'kimi-anthropic'" in result.message
@pytest.mark.asyncio
async def test_doctor_command_reports_context(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
registry = create_default_command_registry()
command, args = registry.lookup("/doctor")
assert command is not None
result = await command.handler(
args,
CommandContext(
engine=_make_engine(tmp_path),
cwd=str(tmp_path),
plugin_summary="Plugins:\n- demo [enabled] Example",
mcp_summary="No MCP servers configured.",
),
)
assert "Doctor summary:" in result.message
assert str(tmp_path) in result.message
@pytest.mark.asyncio
async def test_memory_command_manages_entries(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
registry = create_default_command_registry()
context = _make_context(tmp_path)
add_command, add_args = registry.lookup("/memory add Pytest Tips :: use fixtures")
add_result = await add_command.handler(add_args, context)
assert "Added memory entry" in add_result.message
list_command, list_args = registry.lookup("/memory list")
list_result = await list_command.handler(list_args, context)
assert "pytest_tips.md" in list_result.message
show_command, show_args = registry.lookup("/memory show pytest_tips")
show_result = await show_command.handler(show_args, context)
assert "use fixtures" in show_result.message
remove_command, remove_args = registry.lookup("/memory remove pytest_tips")
remove_result = await remove_command.handler(remove_args, context)
assert "Removed memory entry" in remove_result.message
list_after_remove = await list_command.handler(list_args, context)
assert "pytest_tips.md" not in list_after_remove.message
show_after_remove = await show_command.handler(show_args, context)
assert show_after_remove.message == "Memory entry not found: pytest_tips"
@pytest.mark.asyncio
async def test_memory_command_migrates_entries(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
memory_dir = get_project_memory_dir(tmp_path)
legacy = memory_dir / "legacy.md"
legacy.write_text("legacy command note\n", encoding="utf-8")
registry = create_default_command_registry()
context = _make_context(tmp_path)
dry_command, dry_args = registry.lookup("/memory migrate --dry-run")
dry_result = await dry_command.handler(dry_args, context)
assert "Memory migration dry run." in dry_result.message
assert "Changed: 1" in dry_result.message
assert "schema_version" not in legacy.read_text(encoding="utf-8")
apply_command, apply_args = registry.lookup("/memory migrate --apply")
apply_result = await apply_command.handler(apply_args, context)
assert "Memory migration applied." in apply_result.message
assert "Backup:" in apply_result.message
assert "schema_version: 1" in legacy.read_text(encoding="utf-8")
@pytest.mark.asyncio
async def test_memory_migrate_uses_backend_defaults_not_label(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
memory_dir = tmp_path / "custom-memory"
memory_dir.mkdir()
legacy = memory_dir / "legacy.md"
legacy.write_text("personal preference note\n", encoding="utf-8")
registry = create_default_command_registry()
context = _make_context(tmp_path)
context.memory_backend = MemoryCommandBackend(
label="renamed user notes",
default_type="personal",
default_category="preference",
get_memory_dir=lambda: memory_dir,
get_entrypoint=lambda: memory_dir / "MEMORY.md",
list_files=lambda: [],
add_entry=lambda title, content: memory_dir / f"{title}.md",
remove_entry=lambda name: False,
)
apply_command, apply_args = registry.lookup("/memory migrate --apply")
result = await apply_command.handler(apply_args, context)
migrated = legacy.read_text(encoding="utf-8")
assert "Memory migration applied." in result.message
assert 'type: "personal"' in migrated
assert 'category: "preference"' in migrated
@pytest.mark.asyncio
async def test_compact_summary_and_usage_commands(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
registry = create_default_command_registry()
context = _make_context(tmp_path)
context.engine.load_messages(
[
ConversationMessage(role="user", content=[TextBlock(text="alpha request")]),
ConversationMessage(role="assistant", content=[TextBlock(text="alpha reply")]),
ConversationMessage(role="user", content=[TextBlock(text="beta request")]),
ConversationMessage(role="assistant", content=[TextBlock(text="beta reply")]),
]
)
summary_command, summary_args = registry.lookup("/summary 3")
summary_result = await summary_command.handler(summary_args, context)
assert "assistant: alpha reply" in summary_result.message or "user: beta request" in summary_result.message
compact_command, compact_args = registry.lookup("/compact 2")
compact_result = await compact_command.handler(compact_args, context)
assert "Compacted conversation" in compact_result.message
assert len(context.engine.messages) == 3
usage_command, usage_args = registry.lookup("/usage")
usage_result = await usage_command.handler(usage_args, context)
assert "Estimated conversation tokens" in usage_result.message
stats_command, stats_args = registry.lookup("/stats")
stats_result = await stats_command.handler(stats_args, context)
assert "Session stats:" in stats_result.message
@pytest.mark.asyncio
async def test_ui_mode_commands_persist_and_update_state(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
registry = create_default_command_registry()
context = _make_context(tmp_path)
config_command, config_args = registry.lookup("/config set verbose true")
config_result = await config_command.handler(config_args, context)
assert "Updated verbose" in config_result.message
assert load_settings().verbose is True
output_command, output_args = registry.lookup("/output-style set minimal")
output_result = await output_command.handler(output_args, context)
assert "minimal" in output_result.message
assert context.app_state.get().output_style == "minimal"
keybindings_command, keybindings_args = registry.lookup("/keybindings")
keybindings_result = await keybindings_command.handler(keybindings_args, context)
assert "ctrl+l" in keybindings_result.message
vim_command, vim_args = registry.lookup("/vim toggle")
vim_result = await vim_command.handler(vim_args, context)
assert "enabled" in vim_result.message
assert context.app_state.get().vim_enabled is True
voice_command, voice_args = registry.lookup("/voice keyterms Shipping pytest fixtures")
voice_result = await voice_command.handler(voice_args, context)
assert "pytest" in voice_result.message
plan_command, plan_args = registry.lookup("/plan on")
plan_result = await plan_command.handler(plan_args, context)
assert "enabled" in plan_result.message
assert load_settings().permission.mode == "plan"
fast_command, fast_args = registry.lookup("/fast on")
fast_result = await fast_command.handler(fast_args, context)
assert "enabled" in fast_result.message
assert load_settings().fast_mode is True
assert context.app_state.get().fast_mode is True
effort_command, effort_args = registry.lookup("/effort high")
effort_result = await effort_command.handler(effort_args, context)
assert "high" in effort_result.message
assert load_settings().effort == "high"
assert context.app_state.get().effort == "high"
effort_command, effort_args = registry.lookup("/effort xhigh")
effort_result = await effort_command.handler(effort_args, context)
assert "xhigh" in effort_result.message
assert load_settings().effort == "xhigh"
assert context.app_state.get().effort == "xhigh"
passes_command, passes_args = registry.lookup("/passes 3")
passes_result = await passes_command.handler(passes_args, context)
assert "3" in passes_result.message
assert load_settings().passes == 3
assert context.app_state.get().passes == 3
@pytest.mark.asyncio
async def test_version_context_and_share_commands(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
registry = create_default_command_registry()
context = _make_context(tmp_path)
version_command, version_args = registry.lookup("/version")
version_result = await version_command.handler(version_args, context)
assert "OpenHarness" in version_result.message
context_command, context_args = registry.lookup("/context")
context_result = await context_command.handler(context_args, context)
assert "OpenHarness" in context_result.message or "interactive agent" in context_result.message
share_command, share_args = registry.lookup("/share")
share_result = await share_command.handler(share_args, context)
assert "shareable transcript snapshot" in share_result.message
@pytest.mark.asyncio
async def test_auth_feedback_and_project_context_commands(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
# Prevent env var leakage from overriding the configured api_key
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
registry = create_default_command_registry()
context = _make_context(tmp_path)
login_command, login_args = registry.lookup("/login sk-test-123456")
login_result = await login_command.handler(login_args, context)
assert "Stored API key" in login_result.message
assert load_settings().api_key == "sk-test-123456"
issue_command, issue_args = registry.lookup("/issue set Fix CI :: The CI flakes on task retry")
issue_result = await issue_command.handler(issue_args, context)
assert "Saved issue context" in issue_result.message
assert "Fix CI" in get_project_issue_file(tmp_path).read_text(encoding="utf-8")
pr_command, pr_args = registry.lookup("/pr_comments add src/app.py:12 :: simplify this branch")
pr_result = await pr_command.handler(pr_args, context)
assert "Added PR comment" in pr_result.message
assert "simplify this branch" in get_project_pr_comments_file(tmp_path).read_text(encoding="utf-8")
feedback_command, feedback_args = registry.lookup("/feedback this workflow feels good")
feedback_result = await feedback_command.handler(feedback_args, context)
assert "Saved feedback" in feedback_result.message
assert "this workflow feels good" in get_feedback_log_path().read_text(encoding="utf-8")
logout_command, logout_args = registry.lookup("/logout")
logout_result = await logout_command.handler(logout_args, context)
assert "Cleared stored API key" in logout_result.message
assert load_settings().api_key == ""
@pytest.mark.asyncio
async def test_agents_session_files_and_reload_plugins_commands(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
registry = create_default_command_registry()
context = _make_context(tmp_path)
(tmp_path / "src").mkdir()
(tmp_path / "src" / "app.py").write_text("print('hi')\n", encoding="utf-8")
session_command, session_args = registry.lookup("/session")
context.session_id = "session-smoke"
session_result = await session_command.handler(session_args, context)
assert "Session ID: session-smoke" in session_result.message
assert "Session directory:" in session_result.message
session_path_command, session_path_args = registry.lookup("/session path")
session_path_result = await session_path_command.handler(session_path_args, context)
assert "sessions" in session_path_result.message
session_tag_command, session_tag_args = registry.lookup("/session tag smoke")
session_tag_result = await session_tag_command.handler(session_tag_args, context)
assert "smoke.json" in session_tag_result.message
assert "smoke.md" in session_tag_result.message
tag_command, tag_args = registry.lookup("/tag alias-smoke")
tag_result = await tag_command.handler(tag_args, context)
assert "alias-smoke.json" in tag_result.message
assert "alias-smoke.md" in tag_result.message
files_command, files_args = registry.lookup("/files app.py")
files_result = await files_command.handler(files_args, context)
assert "src/app.py" in files_result.message.replace("\\", "/")
files_dirs_command, files_dirs_args = registry.lookup("/files dirs")
files_dirs_result = await files_dirs_command.handler(files_dirs_args, context)
assert "src" in files_dirs_result.message
plugin_root = tmp_path / "config" / "plugins" / "fixture-plugin"
(plugin_root / "skills").mkdir(parents=True)
(plugin_root / "plugin.json").write_text(
'{"name":"fixture-plugin","version":"1.0.0","description":"Fixture plugin"}',
encoding="utf-8",
)
reload_command, reload_args = registry.lookup("/reload-plugins")
reload_result = await reload_command.handler(reload_args, context)
assert "fixture-plugin" in reload_result.message
manager = get_task_manager()
task = await manager.create_agent_task(
prompt="ready",
description="test agent",
cwd=tmp_path,
command="python -u -c \"import sys; print(sys.stdin.readline().strip())\"",
)
agents_command, agents_args = registry.lookup("/agents")
agents_result = await agents_command.handler(agents_args, context)
assert task.id in agents_result.message
agent_show_command, agent_show_args = registry.lookup(f"/agents show {task.id}")
agent_show_result = await agent_show_command.handler(agent_show_args, context)
assert "test agent" in agent_show_result.message
@pytest.mark.asyncio
async def test_agents_help_and_subagents_alias(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
registry = create_default_command_registry()
context = _make_context(tmp_path)
agents_help_command, agents_help_args = registry.lookup("/agents help")
assert agents_help_command is not None
agents_help_result = await agents_help_command.handler(agents_help_args, context)
assert "Subagent guide:" in agents_help_result.message
assert 'subagent_type="worker"' in agents_help_result.message
subagents_command, subagents_args = registry.lookup("/subagents")
assert subagents_command is not None
subagents_result = await subagents_command.handler(subagents_args, context)
assert "Subagent guide:" in subagents_result.message
agents_command, agents_args = registry.lookup("/agents")
assert agents_command is not None
agents_result = await agents_command.handler(agents_args, context)
assert "Subagent guide:" in agents_result.message
@pytest.mark.asyncio
async def test_init_and_bridge_commands(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
registry = create_default_command_registry()
context = _make_context(tmp_path)
init_command, init_args = registry.lookup("/init")
init_result = await init_command.handler(init_args, context)
assert "Initialized project files" in init_result.message or "already initialized" in init_result.message
assert (tmp_path / "CLAUDE.md").exists()
assert (tmp_path / ".openharness" / "memory" / "MEMORY.md").exists()
bridge_show_command, bridge_show_args = registry.lookup("/bridge show")
bridge_show_result = await bridge_show_command.handler(bridge_show_args, context)
assert "Bridge summary:" in bridge_show_result.message
bridge_encode_command, bridge_encode_args = registry.lookup("/bridge encode https://api.example.com token123")
bridge_encode_result = await bridge_encode_command.handler(bridge_encode_args, context)
assert bridge_encode_result.message
bridge_decode_command, bridge_decode_args = registry.lookup(f"/bridge decode {bridge_encode_result.message}")
bridge_decode_result = await bridge_decode_command.handler(bridge_decode_args, context)
assert "api.example.com" in bridge_decode_result.message
bridge_sdk_command, bridge_sdk_args = registry.lookup("/bridge sdk https://api.example.com session123")
bridge_sdk_result = await bridge_sdk_command.handler(bridge_sdk_args, context)
assert "session123" in bridge_sdk_result.message
bridge_spawn_command, bridge_spawn_args = registry.lookup("/bridge spawn printf bridge-ok")
bridge_spawn_result = await bridge_spawn_command.handler(bridge_spawn_args, context)
assert "Spawned bridge session" in bridge_spawn_result.message
session_id = bridge_spawn_result.message.split()[3]
bridge_list_command, bridge_list_args = registry.lookup("/bridge list")
bridge_list_result = await bridge_list_command.handler(bridge_list_args, context)
assert session_id in bridge_list_result.message
bridge_output_command, bridge_output_args = registry.lookup(f"/bridge output {session_id}")
bridge_output_result = await bridge_output_command.handler(bridge_output_args, context)
assert "bridge-ok" in bridge_output_result.message or bridge_output_result.message == "(no output)"
bridge_stop_command, bridge_stop_args = registry.lookup(f"/bridge stop {session_id}")
bridge_stop_result = await bridge_stop_command.handler(bridge_stop_args, context)
assert f"Stopped bridge session {session_id}" in bridge_stop_result.message
@pytest.mark.asyncio
async def test_copy_rewind_and_meta_commands(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
registry = create_default_command_registry()
context = _make_context(tmp_path)
context.engine.load_messages(
[
ConversationMessage.from_user_text("first prompt"),
ConversationMessage(role="assistant", content=[TextBlock(text="first answer")]),
ConversationMessage.from_user_text("second prompt"),
ConversationMessage(role="assistant", content=[TextBlock(text="second answer")]),
]
)
copied: list[str] = []
def _fake_copy(text: str) -> None:
copied.append(text)
monkeypatch.setattr(registry_module.pyperclip, "copy", _fake_copy)
copy_command, copy_args = registry.lookup("/copy")
copy_result = await copy_command.handler(copy_args, context)
assert "Copied" in copy_result.message
assert copied == ["second answer"]
rewind_command, rewind_args = registry.lookup("/rewind 1")
rewind_result = await rewind_command.handler(rewind_args, context)
assert "removed 2 message(s)" in rewind_result.message
assert len(context.engine.messages) == 2
privacy_command, privacy_args = registry.lookup("/privacy-settings")
privacy_result = await privacy_command.handler(privacy_args, context)
assert "user_config_dir" in privacy_result.message
rate_command, rate_args = registry.lookup("/rate-limit-options")
rate_result = await rate_command.handler(rate_args, context)
assert "Rate limit options:" in rate_result.message
release_command, release_args = registry.lookup("/release-notes")
release_result = await release_command.handler(release_args, context)
assert "Release Notes" in release_result.message
upgrade_command, upgrade_args = registry.lookup("/upgrade")
upgrade_result = await upgrade_command.handler(upgrade_args, context)
assert "Upgrade instructions:" in upgrade_result.message
@pytest.mark.asyncio
async def test_mcp_and_voice_commands_report_richer_state(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
settings = Settings(
mcp_servers={
"http-demo": McpHttpServerConfig(url="https://example.com/mcp"),
"stdio-demo": McpStdioServerConfig(command="python", args=["-m", "demo"]),
}
)
save_settings(settings)
registry = create_default_command_registry()
context = _make_context(tmp_path)
mcp_http_command, mcp_http_args = registry.lookup("/mcp auth http-demo secret-token")
mcp_http_result = await mcp_http_command.handler(mcp_http_args, context)
assert "Saved MCP auth for http-demo" in mcp_http_result.message
assert load_settings().mcp_servers["http-demo"].headers["Authorization"] == "Bearer secret-token"
mcp_stdio_command, mcp_stdio_args = registry.lookup("/mcp auth stdio-demo env DEMO_TOKEN")
mcp_stdio_result = await mcp_stdio_command.handler(mcp_stdio_args, context)
assert "Saved MCP auth for stdio-demo" in mcp_stdio_result.message
assert load_settings().mcp_servers["stdio-demo"].env["MCP_AUTH_TOKEN"] == "DEMO_TOKEN"
voice_command, voice_args = registry.lookup("/voice show")
voice_result = await voice_command.handler(voice_args, context)
assert "Voice mode:" in voice_result.message
assert "Available:" in voice_result.message
assert "Reason:" in voice_result.message
@pytest.mark.asyncio
async def test_git_commands_report_repository_state(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
subprocess.run(["git", "init"], cwd=tmp_path, check=True, capture_output=True, text=True)
subprocess.run(
["git", "config", "user.email", "openharness@example.com"],
cwd=tmp_path,
check=True,
capture_output=True,
text=True,
)
subprocess.run(
["git", "config", "user.name", "OpenHarness Tests"],
cwd=tmp_path,
check=True,
capture_output=True,
text=True,
)
(tmp_path / "demo.txt").write_text("hello\n", encoding="utf-8")
registry = create_default_command_registry()
context = _make_context(tmp_path)
branch_command, branch_args = registry.lookup("/branch show")
branch_result = await branch_command.handler(branch_args, context)
assert "Current branch" in branch_result.message
diff_command, diff_args = registry.lookup("/diff")
diff_result = await diff_command.handler(diff_args, context)
assert "demo.txt" in diff_result.message or "(no diff)" in diff_result.message
commit_command, commit_args = registry.lookup("/commit initial commit")
commit_result = await commit_command.handler(commit_args, context)
assert "commit" in commit_result.message.lower()
def test_quit_is_alias_for_exit():
"""Regression for #183: /quit should resolve to the same handler as /exit."""
reg = create_default_command_registry()
exit_cmd, _ = reg.lookup("/exit")
quit_cmd, _ = reg.lookup("/quit")
assert exit_cmd is quit_cmd
assert quit_cmd.name == "exit"
def test_help_and_list_do_not_duplicate_aliases():
"""Aliases share a SlashCommand object; help/listing must not repeat it."""
reg = create_default_command_registry()
names = [cmd.name for cmd in reg.list_commands()]
assert names.count("exit") == 1
assert "quit" not in names # alias is resolvable via lookup, not listed
help_text = reg.help_text()
assert help_text.count("/exit ") == 1
assert "/quit" not in help_text