diff --git a/README.md b/README.md index 5b69574..7da3e6e 100644 --- a/README.md +++ b/README.md @@ -210,12 +210,13 @@ iex (Invoke-WebRequest -Uri 'https://raw.githubusercontent.com/HKUDS/OpenHarness pip install openharness-ai ``` -**Note**: Windows support is now native! The PowerShell installer handles virtual environment setup and PATH configuration automatically. +**Note**: Windows support is now native. In PowerShell, use `openh` instead of `oh` because `oh` can resolve to the built-in `Out-Host` alias. ### 2. Configure ```bash oh setup # interactive wizard — pick a provider, authenticate, done +# On Windows PowerShell, use: openh setup ``` Supports **Claude / OpenAI / Copilot / Codex / Moonshot(Kimi) / GLM / MiniMax** and any compatible endpoint. @@ -224,6 +225,7 @@ Supports **Claude / OpenAI / Copilot / Codex / Moonshot(Kimi) / GLM / MiniMax** ```bash oh +# On Windows PowerShell, use: openh ```
diff --git a/ohmo/session_storage.py b/ohmo/session_storage.py index 9352b16..e5a0648 100644 --- a/ohmo/session_storage.py +++ b/ohmo/session_storage.py @@ -10,9 +10,12 @@ from typing import Any from uuid import uuid4 from openharness.api.usage import UsageSnapshot -from openharness.engine.messages import ConversationMessage +from openharness.engine.messages import ConversationMessage, sanitize_conversation_messages from openharness.services.session_backend import SessionBackend -from openharness.services.session_storage import _persistable_tool_metadata +from openharness.services.session_storage import ( + _persistable_tool_metadata, + _sanitize_snapshot_payload, +) from openharness.utils.fs import atomic_write_text from ohmo.workspace import get_sessions_dir @@ -51,6 +54,7 @@ def save_session_snapshot( session_dir = get_session_dir(workspace) sid = session_id or uuid4().hex[:12] now = time.time() + messages = sanitize_conversation_messages(messages) summary = "" for msg in messages: if msg.role == "user" and msg.text.strip(): @@ -85,13 +89,13 @@ def load_latest(workspace: str | Path | None = None) -> dict[str, Any] | None: path = get_session_dir(workspace) / "latest.json" if not path.exists(): return None - return json.loads(path.read_text(encoding="utf-8")) + return _sanitize_snapshot_payload(json.loads(path.read_text(encoding="utf-8"))) def load_latest_for_session_key(workspace: str | Path | None, session_key: str) -> dict[str, Any] | None: path = _session_key_latest_path(workspace, session_key) if path.exists(): - return json.loads(path.read_text(encoding="utf-8")) + return _sanitize_snapshot_payload(json.loads(path.read_text(encoding="utf-8"))) return None @@ -120,7 +124,7 @@ def list_snapshots(workspace: str | Path | None = None, limit: int = 20) -> list def load_by_id(workspace: str | Path | None, session_id: str) -> dict[str, Any] | None: path = get_session_dir(workspace) / f"session-{session_id}.json" if path.exists(): - return json.loads(path.read_text(encoding="utf-8")) + return _sanitize_snapshot_payload(json.loads(path.read_text(encoding="utf-8"))) latest = load_latest(workspace) if latest and (latest.get("session_id") == session_id or session_id == "latest"): return latest diff --git a/pyproject.toml b/pyproject.toml index be17e5d..be4b2e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,7 @@ dev = [ [project.scripts] openharness = "openharness.cli:app" oh = "openharness.cli:app" +openh = "openharness.cli:app" ohmo = "ohmo.cli:app" [tool.hatch.build.targets.wheel] diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 3619360..869e964 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -259,22 +259,26 @@ if ($CurrentPath -like "*$VenvBinDir*") { Write-Step "Verifying installation" $OhPath = "$VenvBinDir\oh.exe" +$OpenhPath = "$VenvBinDir\openh.exe" $OhmoPath = "$VenvBinDir\ohmo.exe" -if ((Test-Path $OhPath) -and (Test-Path $OhmoPath)) { - $OhVersion = & $OhPath --version 2>&1 +if ((Test-Path $OpenhPath) -and (Test-Path $OhmoPath)) { + $OhVersion = & $OpenhPath --version 2>&1 Write-Success "Installation successful!" Write-Host "" - Write-Host " oh is ready: $OhVersion" -ForegroundColor Green + Write-Host " openh is ready: $OhVersion" -ForegroundColor Green + if (Test-Path $OhPath) { + Write-Host " oh is also installed, but PowerShell may resolve 'oh' to Out-Host first." -ForegroundColor Yellow + } Write-Host " ohmo is ready" -ForegroundColor Green } else { # Try module execution $ModuleVersion = python -m openharness --version 2>&1 if ($ModuleVersion) { - Write-Warn "'oh'/'ohmo' commands not yet available. Run via: python -m openharness" + Write-Warn "'openh'/'ohmo' commands not yet available. Run via: python -m openharness" Write-Host " Version: $ModuleVersion" } else { - Write-Warn "Could not verify 'oh'/'ohmo' commands. The package may need a PATH update." + Write-Warn "Could not verify 'openh'/'ohmo' commands. The package may need a PATH update." Write-Host " Try: python -m openharness --version" } } @@ -289,7 +293,8 @@ Write-Host " Next steps:" Write-Host " 1. Restart terminal, or run: refreshenv (if using Chocolatey)" Write-Host " Or manually refresh: `$env:PATH = [System.Environment]::GetEnvironmentVariable('PATH','User')" Write-Host " 2. Set your API key: `$env:ANTHROPIC_API_KEY = 'your_key'" -Write-Host " 3. Launch: oh" +Write-Host " 3. Launch (PowerShell): openh" Write-Host " 4. Launch ohmo: ohmo" +Write-Host " Note: 'oh' may collide with the built-in Out-Host alias in PowerShell." Write-Host " 5. Docs: https://github.com/HKUDS/OpenHarness" -Write-Host "" \ No newline at end of file +Write-Host "" diff --git a/src/openharness/commands/registry.py b/src/openharness/commands/registry.py index 2323f7f..505587a 100644 --- a/src/openharness/commands/registry.py +++ b/src/openharness/commands/registry.py @@ -28,7 +28,7 @@ from openharness.bridge.types import WorkSecret from openharness.bridge.work_secret import build_sdk_url, decode_work_secret, encode_work_secret from openharness.api.provider import auth_status, detect_provider from openharness.config.settings import Settings, display_model_setting, load_settings, save_settings -from openharness.engine.messages import ConversationMessage +from openharness.engine.messages import ConversationMessage, sanitize_conversation_messages from openharness.engine.query_engine import QueryEngine from openharness.memory import ( add_memory_entry, @@ -410,10 +410,9 @@ def create_default_command_registry( snapshot = context.session_backend.load_by_id(context.cwd, sid) if snapshot is None: return CommandResult(message=f"Session not found: {sid}") - messages = [ - ConversationMessage.model_validate(item) - for item in snapshot.get("messages", []) - ] + messages = sanitize_conversation_messages( + [ConversationMessage.model_validate(item) for item in snapshot.get("messages", [])] + ) context.engine.load_messages(messages) summary = snapshot.get("summary", "")[:60] return CommandResult( @@ -429,10 +428,9 @@ def create_default_command_registry( snapshot = context.session_backend.load_latest(context.cwd) if snapshot is None: return CommandResult(message="No saved sessions found for this project.") - messages = [ - ConversationMessage.model_validate(item) - for item in snapshot.get("messages", []) - ] + messages = sanitize_conversation_messages( + [ConversationMessage.model_validate(item) for item in snapshot.get("messages", [])] + ) context.engine.load_messages(messages) return CommandResult( message=f"Restored {len(messages)} messages from the latest session.", diff --git a/src/openharness/engine/messages.py b/src/openharness/engine/messages.py index af25823..b8daf50 100644 --- a/src/openharness/engine/messages.py +++ b/src/openharness/engine/messages.py @@ -8,7 +8,7 @@ from pathlib import Path from typing import Any, Annotated, Literal from uuid import uuid4 -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator class TextBlock(BaseModel): @@ -67,6 +67,14 @@ class ConversationMessage(BaseModel): role: Literal["user", "assistant"] content: list[ContentBlock] = Field(default_factory=list) + @field_validator("content", mode="before") + @classmethod + def _normalize_content(cls, value: Any) -> list[Any]: + """Normalize legacy/null payloads before block validation.""" + if value is None: + return [] + return value + @classmethod def from_user_text(cls, text: str) -> "ConversationMessage": """Construct a user message from raw text.""" @@ -96,6 +104,26 @@ class ConversationMessage(BaseModel): "content": [serialize_content_block(block) for block in self.content], } + def is_effectively_empty(self) -> bool: + """Return True when the message carries no useful content.""" + if self.content: + for block in self.content: + if isinstance(block, TextBlock) and block.text.strip(): + return False + if isinstance(block, (ImageBlock, ToolUseBlock, ToolResultBlock)): + return False + return True + + +def sanitize_conversation_messages(messages: list[ConversationMessage]) -> list[ConversationMessage]: + """Drop legacy empty assistant messages while preserving other content.""" + sanitized: list[ConversationMessage] = [] + for message in messages: + if message.role == "assistant" and message.is_effectively_empty(): + continue + sanitized.append(message) + return sanitized + def serialize_content_block(block: ContentBlock) -> dict[str, Any]: """Convert a local content block into the provider wire format.""" diff --git a/src/openharness/engine/query.py b/src/openharness/engine/query.py index 6112132..9c7bb50 100644 --- a/src/openharness/engine/query.py +++ b/src/openharness/engine/query.py @@ -515,6 +515,16 @@ async def run_query( if messages and messages[-1].role == "user" and messages[-1].text.startswith("# Coordinator User Context"): coordinator_context_message = messages.pop() + if final_message.role == "assistant" and final_message.is_effectively_empty(): + log.warning("dropping empty assistant message from provider response") + yield ErrorEvent( + message=( + "Model returned an empty assistant message. " + "The turn was ignored to keep the session healthy." + ) + ), usage + return + messages.append(final_message) yield AssistantTurnComplete(message=final_message, usage=usage), usage diff --git a/src/openharness/services/session_storage.py b/src/openharness/services/session_storage.py index a2f36be..5d4c215 100644 --- a/src/openharness/services/session_storage.py +++ b/src/openharness/services/session_storage.py @@ -11,7 +11,7 @@ from uuid import uuid4 from openharness.api.usage import UsageSnapshot from openharness.config.paths import get_sessions_dir -from openharness.engine.messages import ConversationMessage +from openharness.engine.messages import ConversationMessage, sanitize_conversation_messages from openharness.utils.fs import atomic_write_text @@ -73,6 +73,7 @@ def save_session_snapshot( session_dir = get_project_session_dir(cwd) sid = session_id or uuid4().hex[:12] now = time.time() + messages = sanitize_conversation_messages(messages) # Extract a summary from the first user message summary = "" for msg in messages: @@ -105,12 +106,25 @@ def save_session_snapshot( return latest_path +def _sanitize_snapshot_payload(payload: dict[str, Any]) -> dict[str, Any]: + """Normalize persisted messages for forward compatibility.""" + raw_messages = payload.get("messages", []) + if isinstance(raw_messages, list): + messages = sanitize_conversation_messages( + [ConversationMessage.model_validate(item) for item in raw_messages] + ) + payload = dict(payload) + payload["messages"] = [message.model_dump(mode="json") for message in messages] + payload["message_count"] = len(messages) + return payload + + def load_session_snapshot(cwd: str | Path) -> dict[str, Any] | None: """Load the most recent session snapshot for the project.""" path = get_project_session_dir(cwd) / "latest.json" if not path.exists(): return None - return json.loads(path.read_text(encoding="utf-8")) + return _sanitize_snapshot_payload(json.loads(path.read_text(encoding="utf-8"))) def list_session_snapshots(cwd: str | Path, limit: int = 20) -> list[dict[str, Any]]: @@ -182,11 +196,11 @@ def load_session_by_id(cwd: str | Path, session_id: str) -> dict[str, Any] | Non # Try named session first path = session_dir / f"session-{session_id}.json" if path.exists(): - return json.loads(path.read_text(encoding="utf-8")) + return _sanitize_snapshot_payload(json.loads(path.read_text(encoding="utf-8"))) # Fallback to latest.json if session_id matches latest = session_dir / "latest.json" if latest.exists(): - data = json.loads(latest.read_text(encoding="utf-8")) + data = _sanitize_snapshot_payload(json.loads(latest.read_text(encoding="utf-8"))) if data.get("session_id") == session_id or session_id == "latest": return data return None diff --git a/src/openharness/ui/runtime.py b/src/openharness/ui/runtime.py index b7855c3..df6bbbd 100644 --- a/src/openharness/ui/runtime.py +++ b/src/openharness/ui/runtime.py @@ -17,7 +17,12 @@ from openharness.bridge import get_bridge_manager from openharness.commands import CommandContext, CommandResult, create_default_command_registry from openharness.config import get_config_file_path, load_settings from openharness.engine import QueryEngine -from openharness.engine.messages import ConversationMessage, ToolResultBlock, ToolUseBlock +from openharness.engine.messages import ( + ConversationMessage, + ToolResultBlock, + ToolUseBlock, + sanitize_conversation_messages, +) from openharness.engine.query import MaxTurnsExceeded from openharness.engine.stream_events import StreamEvent from openharness.hooks import HookEvent, HookExecutionContext, HookExecutor, load_hook_registry @@ -301,9 +306,9 @@ async def build_runtime( ) # Restore messages from a saved session if provided if restore_messages: - restored = [ - ConversationMessage.model_validate(m) for m in restore_messages - ] + restored = sanitize_conversation_messages( + [ConversationMessage.model_validate(m) for m in restore_messages] + ) engine.load_messages(restored) # Start Docker sandbox if configured diff --git a/tests/test_engine/test_query_engine.py b/tests/test_engine/test_query_engine.py index e064066..d79c1d0 100644 --- a/tests/test_engine/test_query_engine.py +++ b/tests/test_engine/test_query_engine.py @@ -18,6 +18,7 @@ from openharness.engine.stream_events import ( AssistantTextDelta, AssistantTurnComplete, CompactProgressEvent, + ErrorEvent, StatusEvent, ToolExecutionCompleted, ToolExecutionStarted, @@ -108,6 +109,16 @@ class PromptTooLongThenSuccessApiClient: ) +class EmptyAssistantApiClient: + async def stream_message(self, request): + del request + yield ApiMessageCompleteEvent( + message=ConversationMessage(role="assistant", content=[]), + usage=UsageSnapshot(input_tokens=1, output_tokens=1), + stop_reason=None, + ) + + class CoordinatorLoopApiClient: def __init__(self) -> None: self.requests = [] @@ -918,3 +929,22 @@ async def test_query_engine_synthesizes_tool_result_when_parallel_tool_raises(tm assert isinstance(events[-1], AssistantTurnComplete) assert events[-1].message.text == "Recovered from the failure." + + +@pytest.mark.asyncio +async def test_query_engine_drops_empty_assistant_messages(tmp_path: Path): + engine = QueryEngine( + api_client=EmptyAssistantApiClient(), + tool_registry=ToolRegistry(), + permission_checker=PermissionChecker(PermissionSettings(mode=PermissionMode.FULL_AUTO)), + cwd=tmp_path, + model="claude-test", + system_prompt="system", + ) + + events = [event async for event in engine.submit_message("hello")] + + assert any(isinstance(event, ErrorEvent) for event in events) + assert not any(isinstance(event, AssistantTurnComplete) for event in events) + assert len(engine.messages) == 1 + assert engine.messages[0].role == "user" diff --git a/tests/test_install/test_windows_alias.py b/tests/test_install/test_windows_alias.py new file mode 100644 index 0000000..621cc5d --- /dev/null +++ b/tests/test_install/test_windows_alias.py @@ -0,0 +1,24 @@ +"""Installer regressions for Windows command aliases.""" + +from __future__ import annotations + +from pathlib import Path + +try: + import tomllib +except ModuleNotFoundError: # pragma: no cover - Python < 3.11 + import tomli as tomllib + + +def test_pyproject_exposes_openh_console_script(): + data = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8")) + scripts = data["project"]["scripts"] + assert scripts["openh"] == "openharness.cli:app" + assert scripts["oh"] == "openharness.cli:app" + + +def test_powershell_installer_recommends_openh_for_windows(): + script = Path("scripts/install.ps1").read_text(encoding="utf-8") + assert "openh.exe" in script + assert "Launch (PowerShell): openh" in script + assert "Out-Host" in script diff --git a/tests/test_ohmo/test_ohmo_session_storage.py b/tests/test_ohmo/test_ohmo_session_storage.py index f5fe919..b4e3405 100644 --- a/tests/test_ohmo/test_ohmo_session_storage.py +++ b/tests/test_ohmo/test_ohmo_session_storage.py @@ -1,3 +1,4 @@ +import json from pathlib import Path from openharness.api.usage import UsageSnapshot @@ -51,3 +52,38 @@ def test_ohmo_session_backend_loads_latest_for_session_key(tmp_path: Path): assert loaded["session_id"] == "abc123" assert loaded["session_key"] == "feishu:chat-1" assert loaded["tool_metadata"]["task_focus_state"]["goal"] == "Continue the same Feishu task" + + +def test_ohmo_session_backend_sanitizes_legacy_empty_assistant_messages(tmp_path: Path): + workspace = tmp_path / ".ohmo-home" + initialize_workspace(workspace) + backend = OhmoSessionBackend(workspace) + session_dir = get_session_dir(workspace) + (session_dir / "latest.json").write_text( + json.dumps( + { + "app": "ohmo", + "session_id": "abc123", + "session_key": "feishu:chat-1", + "cwd": str(tmp_path), + "model": "gpt-5.4", + "system_prompt": "system", + "messages": [ + {"role": "user", "content": [{"type": "text", "text": "hello"}]}, + {"role": "assistant", "content": None}, + {"role": "assistant", "content": []}, + ], + "usage": {"input_tokens": 0, "output_tokens": 0}, + "tool_metadata": {}, + "created_at": 1.0, + "summary": "hello", + "message_count": 3, + } + ), + encoding="utf-8", + ) + + loaded = backend.load_latest(tmp_path) + assert loaded is not None + assert loaded["message_count"] == 1 + assert loaded["messages"][0]["role"] == "user" diff --git a/tests/test_services/test_session_storage.py b/tests/test_services/test_session_storage.py index 65e527a..fc2db46 100644 --- a/tests/test_services/test_session_storage.py +++ b/tests/test_services/test_session_storage.py @@ -2,12 +2,14 @@ from __future__ import annotations +import json from pathlib import Path from openharness.api.usage import UsageSnapshot from openharness.engine.messages import ConversationMessage, TextBlock from openharness.services.session_storage import ( export_session_markdown, + get_project_session_dir, load_session_snapshot, save_session_snapshot, ) @@ -57,3 +59,35 @@ def test_export_session_markdown(tmp_path: Path, monkeypatch): assert "OpenHarness Session Transcript" in content assert "hello" in content assert "world" in content + + +def test_load_session_snapshot_sanitizes_legacy_empty_assistant_messages(tmp_path: Path, monkeypatch): + monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data")) + project = tmp_path / "repo" + project.mkdir() + + target_dir = get_project_session_dir(project) + payload = { + "session_id": "legacy123", + "cwd": str(project), + "model": "claude-test", + "system_prompt": "system", + "messages": [ + {"role": "user", "content": [{"type": "text", "text": "hello"}]}, + {"role": "assistant", "content": None}, + {"role": "assistant", "content": []}, + {"role": "assistant", "content": [{"type": "text", "text": "world"}]}, + ], + "usage": {"input_tokens": 1, "output_tokens": 1}, + "tool_metadata": {}, + "created_at": 1.0, + "summary": "hello", + "message_count": 4, + } + (target_dir / "latest.json").write_text(json.dumps(payload), encoding="utf-8") + + snapshot = load_session_snapshot(project) + assert snapshot is not None + assert snapshot["message_count"] == 2 + assert [message["role"] for message in snapshot["messages"]] == ["user", "assistant"] + assert snapshot["messages"][1]["content"][0]["text"] == "world"