fix(session): sanitize empty assistant messages and add windows alias

This commit is contained in:
tjb-tech
2026-04-14 08:51:33 +00:00
parent a2dea036e0
commit ce84a6a1a2
13 changed files with 222 additions and 31 deletions
+3 -1
View File
@@ -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
```
<p align="center">
+9 -5
View File
@@ -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
+1
View File
@@ -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]
+12 -7
View File
@@ -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 ""
Write-Host ""
+7 -9
View File
@@ -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.",
+29 -1
View File
@@ -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."""
+10
View File
@@ -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
+18 -4
View File
@@ -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
+9 -4
View File
@@ -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
+30
View File
@@ -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"
+24
View File
@@ -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
@@ -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"
@@ -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"