PhoneWork/tests/test_sdk_migration.py
Yuyao Huang eac90941ef feat: add SDK session implementation with approval flow and audit logging
- Implement SDK session with secretary model for tool approval flow
- Add audit logging for tool usage and permission decisions
- Support Feishu card interactions for approval requests
- Add new commands for task interruption and progress checking
- Remove old test files and update documentation
2026-04-01 12:51:00 +08:00

817 lines
28 KiB
Python

"""Unit tests for the SDK migration (secretary model).
Tests cover:
- SDKSession lifecycle, message buffering, get_progress, approval
- sdk_hooks audit + deny
- SessionManager new methods (send_message, send_and_wait, get_progress, interrupt, approve)
- audit.py new functions (log_tool_use, log_permission_decision)
- bot/commands.py new commands (//stop, //progress, //perm auto)
- orchestrator/tools.py new tools (SessionProgressTool, InterruptConversationTool)
- bot/handler.py text approval fallback
"""
from __future__ import annotations
import asyncio
import json
import time
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock
import pytest
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def tmp_audit_dir(tmp_path):
"""Redirect audit logs to a temp directory."""
import agent.audit as audit_mod
original = audit_mod.AUDIT_DIR
audit_mod.AUDIT_DIR = tmp_path / "audit"
yield tmp_path / "audit"
audit_mod.AUDIT_DIR = original
@pytest.fixture
def mock_sdk_client():
"""Create a mock ClaudeSDKClient that yields controllable messages."""
client = AsyncMock()
client.connect = AsyncMock()
client.disconnect = AsyncMock()
client.query = AsyncMock()
client.interrupt = AsyncMock()
client.set_permission_mode = AsyncMock()
return client
@pytest.fixture
def mock_feishu():
"""Mock all Feishu send functions."""
captured = {"texts": [], "cards": [], "markdowns": []}
async def _send_text(rid, rtype, text):
captured["texts"].append(text)
async def _send_card(rid, rtype, card):
captured["cards"].append(card)
async def _send_markdown(rid, rtype, content):
captured["markdowns"].append(content)
with patch("bot.feishu.send_text", side_effect=_send_text), \
patch("bot.feishu.send_card", side_effect=_send_card), \
patch("bot.feishu.send_markdown", side_effect=_send_markdown), \
patch("bot.handler.send_text", side_effect=_send_text), \
patch("bot.handler.send_markdown", side_effect=_send_markdown):
yield captured
# ===========================================================================
# 1. SDKSession unit tests
# ===========================================================================
class TestSDKSessionProgress:
"""Test SDKSession.get_progress() with buffered messages."""
def test_initial_progress_is_idle(self):
from agent.sdk_session import SDKSession
s = SDKSession("c1", "/tmp", "u1")
p = s.get_progress()
assert p.busy is False
assert p.current_prompt == ""
assert p.text_messages == []
assert p.tool_calls == []
def test_progress_after_manual_state_change(self):
from agent.sdk_session import SDKSession
s = SDKSession("c1", "/tmp", "u1")
s._busy = True
s._current_prompt = "write hello.py"
s._started_at = time.time() - 5
s._text_buffer = ["I'll create the file", "Done"]
s._tool_buffer = ["Write(hello.py)", "Read(hello.py)"]
p = s.get_progress()
assert p.busy is True
assert p.current_prompt == "write hello.py"
assert p.elapsed_seconds >= 4
assert len(p.text_messages) == 2
assert len(p.tool_calls) == 2
def test_buffer_limits(self):
from agent.sdk_session import SDKSession
s = SDKSession("c1", "/tmp", "u1")
for i in range(30):
s._text_buffer.append(f"text-{i}")
if len(s._text_buffer) > s.MAX_BUFFER_TEXTS:
s._text_buffer.pop(0)
assert len(s._text_buffer) == s.MAX_BUFFER_TEXTS
assert s._text_buffer[0] == "text-10"
def test_progress_pending_approval(self):
from agent.sdk_session import SDKSession
s = SDKSession("c1", "/tmp", "u1")
s._pending_approval_desc = "Bash: `rm -rf /tmp/test`"
p = s.get_progress()
assert p.pending_approval == "Bash: `rm -rf /tmp/test`"
class TestSDKSessionApproval:
"""Test the approval mechanism."""
@pytest.mark.asyncio
async def test_approve_resolves_future(self):
from agent.sdk_session import SDKSession
s = SDKSession("c1", "/tmp", "u1")
loop = asyncio.get_running_loop()
s._pending_approval = loop.create_future()
await s.approve(True)
assert s._pending_approval.result() is True
@pytest.mark.asyncio
async def test_approve_deny(self):
from agent.sdk_session import SDKSession
s = SDKSession("c1", "/tmp", "u1")
loop = asyncio.get_running_loop()
s._pending_approval = loop.create_future()
await s.approve(False)
assert s._pending_approval.result() is False
@pytest.mark.asyncio
async def test_approve_no_pending_is_noop(self):
from agent.sdk_session import SDKSession
s = SDKSession("c1", "/tmp", "u1")
# Should not raise
await s.approve(True)
class TestSDKSessionClose:
"""Test clean shutdown."""
@pytest.mark.asyncio
async def test_close_without_start(self):
from agent.sdk_session import SDKSession
s = SDKSession("c1", "/tmp", "u1")
# Should not raise
await s.close()
assert s.client is None
@pytest.mark.asyncio
async def test_close_disconnects_client(self, mock_sdk_client):
from agent.sdk_session import SDKSession
s = SDKSession("c1", "/tmp", "u1")
s.client = mock_sdk_client
s._message_loop_task = None
await s.close()
mock_sdk_client.disconnect.assert_awaited_once()
assert s.client is None
class TestSDKSessionSend:
"""Test send() and send_and_wait() with mocked client."""
@pytest.mark.asyncio
async def test_send_returns_immediately(self):
from agent.sdk_session import SDKSession
s = SDKSession("c1", "/tmp", "u1", chat_id="chat1")
# Provide a mock client that has receive_messages yielding nothing
mock_client = AsyncMock()
mock_client.query = AsyncMock()
async def _empty_messages():
return
yield # make it an async generator
mock_client.receive_messages = _empty_messages
s.client = mock_client
result = await s.send("hello")
assert "已开始执行" in result
assert s._busy is True
assert s._current_prompt == "hello"
# Cleanup
if s._message_loop_task:
s._message_loop_task.cancel()
try:
await s._message_loop_task
except asyncio.CancelledError:
pass
class TestSDKSessionFormatSummary:
"""Test _format_tool_summary."""
def test_bash_summary(self):
from agent.sdk_session import SDKSession
s = SDKSession("c1", "/tmp", "u1")
result = s._format_tool_summary("Bash", {"command": "ls -la"})
assert "`ls -la`" in result
def test_edit_summary(self):
from agent.sdk_session import SDKSession
s = SDKSession("c1", "/tmp", "u1")
result = s._format_tool_summary("Edit", {"file_path": "/tmp/test.py"})
assert "test.py" in result
def test_other_summary_truncated(self):
from agent.sdk_session import SDKSession
s = SDKSession("c1", "/tmp", "u1")
result = s._format_tool_summary("CustomTool", {"key": "x" * 500})
assert len(result) <= 200
# ===========================================================================
# 2. sdk_hooks tests
# ===========================================================================
class TestSDKHooks:
@pytest.mark.asyncio
async def test_audit_hook_logs(self, tmp_audit_dir):
from agent.sdk_hooks import audit_hook
input_data = {
"session_id": "test-session",
"tool_name": "Bash",
"tool_input": {"command": "echo hello"},
"tool_response": "hello\n",
}
result = await audit_hook(input_data, "tu-1", {"signal": None})
assert result == {}
# Check JSONL was written
log_file = tmp_audit_dir / "test-session.jsonl"
assert log_file.exists()
entry = json.loads(log_file.read_text().strip())
assert entry["type"] == "tool_use"
assert entry["tool_name"] == "Bash"
@pytest.mark.asyncio
async def test_deny_dangerous_rm_rf(self):
from agent.sdk_hooks import deny_dangerous_hook
input_data = {
"tool_name": "Bash",
"tool_input": {"command": "rm -rf /"},
}
result = await deny_dangerous_hook(input_data, None, {"signal": None})
assert result.get("hookSpecificOutput", {}).get("permissionDecision") == "deny"
@pytest.mark.asyncio
async def test_deny_allows_safe_commands(self):
from agent.sdk_hooks import deny_dangerous_hook
input_data = {
"tool_name": "Bash",
"tool_input": {"command": "ls -la /tmp"},
}
result = await deny_dangerous_hook(input_data, None, {"signal": None})
assert result == {}
@pytest.mark.asyncio
async def test_deny_ignores_non_bash(self):
from agent.sdk_hooks import deny_dangerous_hook
input_data = {
"tool_name": "Edit",
"tool_input": {"file_path": "/etc/passwd"},
}
result = await deny_dangerous_hook(input_data, None, {"signal": None})
assert result == {}
def test_build_hooks_returns_expected_structure(self):
from agent.sdk_hooks import build_hooks
hooks = build_hooks("test-conv")
assert "PostToolUse" in hooks
assert "PreToolUse" in hooks
assert len(hooks["PostToolUse"]) == 1
assert len(hooks["PreToolUse"]) == 1
# ===========================================================================
# 3. manager tests (new methods)
# ===========================================================================
class TestSessionManagerNew:
@pytest.fixture(autouse=True)
def _reset(self):
from agent.manager import manager
manager._sessions.clear()
yield
manager._sessions.clear()
@pytest.mark.asyncio
async def test_create_session_no_cc_timeout(self):
from agent.manager import manager, Session
s = await manager.create("c1", "/tmp/test", owner_id="u1", chat_id="chat1")
assert s.conv_id == "c1"
assert s.chat_id == "chat1"
assert not hasattr(s, "cc_timeout") or "cc_timeout" not in s.to_dict()
@pytest.mark.asyncio
async def test_get_progress_no_session(self):
from agent.manager import manager
result = manager.get_progress("nonexistent")
assert result is None
@pytest.mark.asyncio
async def test_get_progress_no_sdk_session(self):
from agent.manager import manager
await manager.create("c1", "/tmp/test", owner_id="u1")
p = manager.get_progress("c1", user_id="u1")
assert p is not None
assert p.busy is False
@pytest.mark.asyncio
async def test_interrupt_no_sdk_session(self):
from agent.manager import manager
await manager.create("c1", "/tmp/test", owner_id="u1")
result = await manager.interrupt("c1", user_id="u1")
assert result is False
@pytest.mark.asyncio
async def test_approve_no_sdk_session(self):
from agent.manager import manager
await manager.create("c1", "/tmp/test", owner_id="u1")
# Should not raise
await manager.approve("c1", True)
@pytest.mark.asyncio
async def test_close_with_sdk_session(self):
from agent.manager import manager
from agent.sdk_session import SDKSession
await manager.create("c1", "/tmp/test", owner_id="u1")
mock_sdk = MagicMock(spec=SDKSession)
mock_sdk.close = AsyncMock()
manager._sessions["c1"].sdk_session = mock_sdk
result = await manager.close("c1", user_id="u1")
assert result is True
mock_sdk.close.assert_awaited_once()
@pytest.mark.asyncio
async def test_set_permission_mode_with_sdk_session(self):
from agent.manager import manager
from agent.sdk_session import SDKSession
await manager.create("c1", "/tmp/test", owner_id="u1")
mock_sdk = MagicMock(spec=SDKSession)
mock_sdk.set_permission_mode = AsyncMock()
manager._sessions["c1"].sdk_session = mock_sdk
manager.set_permission_mode("c1", "acceptEdits", user_id="u1")
assert manager._sessions["c1"].permission_mode == "acceptEdits"
def test_list_sessions_includes_busy(self):
from agent.manager import manager, Session
from agent.sdk_session import SDKSession
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
mock_sdk = MagicMock(spec=SDKSession)
mock_sdk._busy = True
session.sdk_session = mock_sdk
manager._sessions["c1"] = session
result = manager.list_sessions()
assert result[0]["busy"] is True
def test_session_from_dict_strips_old_fields(self):
from agent.manager import Session
old_data = {
"conv_id": "c1",
"cwd": "/tmp",
"owner_id": "u1",
"cc_session_id": "old-uuid",
"started": True,
"cc_timeout": 300.0,
"last_activity": 0.0,
"idle_timeout": 1800,
"permission_mode": "default",
}
s = Session.from_dict(old_data)
assert s.conv_id == "c1"
assert not hasattr(s, "cc_session_id")
def test_session_to_dict_excludes_sdk_session(self):
from agent.manager import Session
s = Session(conv_id="c1", cwd="/tmp")
s.sdk_session = MagicMock()
d = s.to_dict()
assert "sdk_session" not in d
# ===========================================================================
# 4. audit tests (new functions)
# ===========================================================================
class TestAuditNewFunctions:
def test_log_tool_use(self, tmp_audit_dir):
from agent.audit import log_tool_use
log_tool_use(
session_id="s1",
tool_name="Bash",
tool_input={"command": "echo hello"},
tool_response="hello\n",
)
log_file = tmp_audit_dir / "s1.jsonl"
assert log_file.exists()
entry = json.loads(log_file.read_text().strip())
assert entry["type"] == "tool_use"
assert entry["tool_name"] == "Bash"
def test_log_permission_decision_approved(self, tmp_audit_dir):
from agent.audit import log_permission_decision
log_permission_decision(
conv_id="c1",
tool_name="Bash",
tool_input={"command": "rm test.txt"},
approved=True,
)
log_file = tmp_audit_dir / "c1.jsonl"
assert log_file.exists()
entry = json.loads(log_file.read_text().strip())
assert entry["type"] == "permission_decision"
assert entry["approved"] is True
def test_log_permission_decision_denied(self, tmp_audit_dir):
from agent.audit import log_permission_decision
log_permission_decision(
conv_id="c1",
tool_name="Write",
tool_input={"file_path": "/etc/passwd"},
approved=False,
)
log_file = tmp_audit_dir / "c1.jsonl"
entry = json.loads(log_file.read_text().strip())
assert entry["approved"] is False
# ===========================================================================
# 5. bot/commands.py new commands
# ===========================================================================
class TestNewCommands:
@pytest.fixture(autouse=True)
def _reset(self):
from agent.manager import manager
from orchestrator.agent import agent
from orchestrator.tools import set_current_user, set_current_chat
manager._sessions.clear()
agent._active_conv.clear()
agent._passthrough.clear()
set_current_user(None)
set_current_chat(None)
yield
manager._sessions.clear()
agent._active_conv.clear()
@pytest.mark.asyncio
async def test_stop_no_active_session(self):
from bot.commands import handle_command
result = await handle_command("u1", "//stop")
assert "No active session" in result
@pytest.mark.asyncio
async def test_stop_with_session_no_sdk(self):
from bot.commands import handle_command
from agent.manager import manager, Session
from orchestrator.agent import agent
from orchestrator.tools import set_current_user
set_current_user("u1")
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
manager._sessions["c1"] = session
agent._active_conv["u1"] = "c1"
result = await handle_command("u1", "//stop")
assert "No active task" in result
@pytest.mark.asyncio
async def test_progress_no_session(self):
from bot.commands import handle_command
result = await handle_command("u1", "//progress")
assert "No active session" in result
@pytest.mark.asyncio
async def test_progress_idle_session(self):
from bot.commands import handle_command
from agent.manager import manager, Session
from orchestrator.agent import agent
from orchestrator.tools import set_current_user
set_current_user("u1")
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
manager._sessions["c1"] = session
agent._active_conv["u1"] = "c1"
result = await handle_command("u1", "//progress")
assert "空闲" in result
@pytest.mark.asyncio
async def test_progress_busy_session(self):
from bot.commands import handle_command
from agent.manager import manager, Session
from agent.sdk_session import SDKSession
from orchestrator.agent import agent
from orchestrator.tools import set_current_user
set_current_user("u1")
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
sdk = SDKSession("c1", "/tmp", "u1")
sdk._busy = True
sdk._started_at = time.time() - 10
sdk._tool_buffer = ["Bash(echo hello)", "Read(test.py)"]
session.sdk_session = sdk
manager._sessions["c1"] = session
agent._active_conv["u1"] = "c1"
result = await handle_command("u1", "//progress")
assert "执行中" in result
assert "Bash" in result
@pytest.mark.asyncio
async def test_progress_with_pending_approval(self):
from bot.commands import handle_command
from agent.manager import manager, Session
from agent.sdk_session import SDKSession
from orchestrator.agent import agent
from orchestrator.tools import set_current_user
set_current_user("u1")
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
sdk = SDKSession("c1", "/tmp", "u1")
sdk._busy = True
sdk._started_at = time.time() - 5
sdk._pending_approval_desc = "Bash: `rm test`"
session.sdk_session = sdk
manager._sessions["c1"] = session
agent._active_conv["u1"] = "c1"
result = await handle_command("u1", "//progress")
assert "审批" in result
@pytest.mark.asyncio
async def test_perm_auto_alias(self):
from bot.commands import handle_command
from agent.manager import manager, Session
from orchestrator.agent import agent
from orchestrator.tools import set_current_user
set_current_user("u1")
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
manager._sessions["c1"] = session
agent._active_conv["u1"] = "c1"
result = await handle_command("u1", "//perm auto")
assert "auto" in result
assert manager._sessions["c1"].permission_mode == "dontAsk"
# ===========================================================================
# 6. orchestrator/tools.py new tools
# ===========================================================================
class TestNewTools:
@pytest.fixture(autouse=True)
def _reset(self):
from agent.manager import manager
from orchestrator.tools import set_current_user, set_current_chat
manager._sessions.clear()
set_current_user("u1")
set_current_chat("chat1")
yield
manager._sessions.clear()
set_current_user(None)
set_current_chat(None)
@pytest.mark.asyncio
async def test_session_progress_not_found(self):
from orchestrator.tools import SessionProgressTool
tool = SessionProgressTool()
result = await tool._arun("nonexistent")
data = json.loads(result)
assert "error" in data
@pytest.mark.asyncio
async def test_session_progress_idle(self):
from orchestrator.tools import SessionProgressTool
from agent.manager import manager, Session
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
manager._sessions["c1"] = session
tool = SessionProgressTool()
result = await tool._arun("c1")
data = json.loads(result)
assert data["busy"] is False
@pytest.mark.asyncio
async def test_session_progress_busy(self):
from orchestrator.tools import SessionProgressTool
from agent.manager import manager, Session
from agent.sdk_session import SDKSession
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
sdk = SDKSession("c1", "/tmp", "u1")
sdk._busy = True
sdk._started_at = time.time() - 30
sdk._current_prompt = "fix the bug"
sdk._tool_buffer = ["Read(main.py)", "Edit(main.py)"]
session.sdk_session = sdk
manager._sessions["c1"] = session
tool = SessionProgressTool()
result = await tool._arun("c1")
data = json.loads(result)
assert data["busy"] is True
assert data["elapsed_seconds"] >= 29
assert "Edit" in str(data["recent_tools"])
@pytest.mark.asyncio
async def test_interrupt_not_found(self):
from orchestrator.tools import InterruptConversationTool
tool = InterruptConversationTool()
result = await tool._arun("nonexistent")
assert "not found" in result
@pytest.mark.asyncio
async def test_interrupt_no_active_task(self):
from orchestrator.tools import InterruptConversationTool
from agent.manager import manager, Session
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
manager._sessions["c1"] = session
tool = InterruptConversationTool()
result = await tool._arun("c1")
assert "No active task" in result
# ===========================================================================
# 7. bot/handler.py text approval fallback
# ===========================================================================
class TestTextApprovalFallback:
@pytest.fixture(autouse=True)
def _reset(self):
from agent.manager import manager
from orchestrator.agent import agent
from orchestrator.tools import set_current_chat
manager._sessions.clear()
agent._active_conv.clear()
set_current_chat(None)
yield
manager._sessions.clear()
agent._active_conv.clear()
@pytest.mark.asyncio
async def test_y_resolves_pending_approval(self, mock_feishu):
from agent.manager import manager, Session
from agent.sdk_session import SDKSession
from orchestrator.agent import agent
from orchestrator.tools import set_current_chat
set_current_chat("chat1")
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
sdk = SDKSession("c1", "/tmp", "u1", chat_id="chat1")
loop = asyncio.get_running_loop()
sdk._pending_approval = loop.create_future()
session.sdk_session = sdk
manager._sessions["c1"] = session
agent._active_conv["u1"] = "c1"
from bot.handler import _process_message
await _process_message("u1", "chat1", "y")
assert sdk._pending_approval.done()
assert sdk._pending_approval.result() is True
# handler calls send_text which is mocked separately from send_markdown
assert any("批准" in t for t in mock_feishu["texts"])
@pytest.mark.asyncio
async def test_n_denies_pending_approval(self, mock_feishu):
from agent.manager import manager, Session
from agent.sdk_session import SDKSession
from orchestrator.agent import agent
from orchestrator.tools import set_current_chat
set_current_chat("chat1")
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
sdk = SDKSession("c1", "/tmp", "u1", chat_id="chat1")
loop = asyncio.get_running_loop()
sdk._pending_approval = loop.create_future()
session.sdk_session = sdk
manager._sessions["c1"] = session
agent._active_conv["u1"] = "c1"
from bot.handler import _process_message
await _process_message("u1", "chat1", "n")
assert sdk._pending_approval.done()
assert sdk._pending_approval.result() is False
assert any("拒绝" in t for t in mock_feishu["texts"])
@pytest.mark.asyncio
async def test_y_without_pending_falls_through(self, mock_feishu):
"""If there's no pending approval, 'y' should not be consumed."""
from agent.manager import manager, Session
from orchestrator.agent import agent
from orchestrator.tools import set_current_chat
set_current_chat("chat1")
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
manager._sessions["c1"] = session
agent._active_conv["u1"] = "c1"
from bot.handler import _process_message
# Patch agent.run to avoid actual LLM call
with patch("orchestrator.agent.agent.run", new_callable=AsyncMock, return_value="ok"):
await _process_message("u1", "chat1", "y")
# Should not have consumed as approval
assert not any("批准" in t for t in mock_feishu["markdowns"])
# ===========================================================================
# 8. bot/feishu.py build_approval_card
# ===========================================================================
class TestBuildApprovalCard:
def test_card_structure(self):
from bot.feishu import build_approval_card
card = build_approval_card("c1", "Bash", "`echo hello`", timeout=60)
assert card["schema"] == "2.0"
assert "权限审批" in card["header"]["title"]["content"]
body_elements = card["body"]["elements"]
# Should have markdown, action, and note elements
tags = [e["tag"] for e in body_elements]
assert "markdown" in tags
assert "action" in tags
assert "note" in tags
# Action should have 2 buttons
action_el = next(e for e in body_elements if e["tag"] == "action")
assert len(action_el["actions"]) == 2
# First button should be approve
assert action_el["actions"][0]["value"]["action"] == "approve"
assert action_el["actions"][0]["value"]["conv_id"] == "c1"
# Second button should be deny
assert action_el["actions"][1]["value"]["action"] == "deny"
# ===========================================================================
# 9. Permission mode constants
# ===========================================================================
class TestPermissionModes:
def test_valid_modes_includes_dontask(self):
from agent.sdk_session import VALID_PERMISSION_MODES
assert "dontAsk" in VALID_PERMISSION_MODES
def test_perm_aliases_has_auto(self):
from bot.commands import _PERM_ALIASES
assert _PERM_ALIASES["auto"] == "dontAsk"
def test_perm_labels_has_dontask(self):
from bot.commands import _PERM_LABELS
assert _PERM_LABELS["dontAsk"] == "auto"
# ===========================================================================
# 10. Config
# ===========================================================================
class TestConfig:
def test_sdk_approval_timeout_exists(self):
from config import SDK_APPROVAL_TIMEOUT
assert isinstance(SDK_APPROVAL_TIMEOUT, int)
assert SDK_APPROVAL_TIMEOUT > 0