fix(session): sanitize empty assistant messages and add windows alias
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 ""
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user