Files
2026-07-13 13:12:00 +08:00

1807 lines
67 KiB
Python

"""Unit tests for QwenExecutor (ACP / JSON-RPC 2.0 mode).
Tests cover:
- Executor construction and attribute defaults
- ACP protocol helpers (_rpc, _notify, _send)
- Session lifecycle (_ensure_initialized, _ensure_session)
- run_turn event translation (agent_message_chunk → TextChunk, TurnComplete)
- run_turn error paths (ACP error response, session-not-found retry reset)
- Process cleanup (close())
- Harness registry and alias wiring
- FastAPI app shape
"""
from __future__ import annotations
import asyncio
import json
import os
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest
from omnigent.inner.executor import ExecutorError, TextChunk, TurnComplete
from omnigent.inner.qwen_executor import QwenExecutor
# ---------------------------------------------------------------------------
# Construction / attribute defaults
# ---------------------------------------------------------------------------
def test_executor_default_attributes() -> None:
"""Constructor stores arguments and initialises state correctly."""
executor = QwenExecutor(qwen_path="qwen")
assert executor._qwen_path == "qwen"
assert executor._model is None
assert executor._proc is None
assert executor._session_id is None
assert executor._initialized is False
assert executor._rpc_id == 0
def test_executor_with_custom_model() -> None:
"""Custom model is stored on the instance."""
executor = QwenExecutor(model="qwen/qwen-plus", qwen_path="qwen")
assert executor._model == "qwen/qwen-plus"
def test_executor_cwd_defaults_to_cwd() -> None:
"""When no cwd is supplied the executor uses the process cwd."""
executor = QwenExecutor()
assert executor._cwd == os.getcwd()
def test_executor_explicit_cwd() -> None:
"""An explicit cwd is stored as-is."""
executor = QwenExecutor(cwd="/tmp")
assert executor._cwd == "/tmp"
# ---------------------------------------------------------------------------
# close() with no process
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_close_with_no_process_is_a_noop() -> None:
"""close() is safe to call when no subprocess was started."""
executor = QwenExecutor()
await executor.close() # must not raise
# ---------------------------------------------------------------------------
# close() with a live process
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_close_terminates_process() -> None:
"""close() terminates the subprocess and clears _proc."""
executor = QwenExecutor()
# asyncio.Process.terminate() is synchronous; stdin.close() is sync too.
mock_proc = MagicMock()
mock_proc.stdin = MagicMock()
mock_proc.returncode = None
# wait() must be a coroutine.
async def fake_wait() -> int:
return 0
mock_proc.wait = fake_wait
executor._proc = mock_proc
await executor.close()
mock_proc.terminate.assert_called_once()
assert executor._proc is None
@pytest.mark.asyncio
async def test_close_kills_when_terminate_raises() -> None:
"""close() falls back to kill() if terminate() raises."""
executor = QwenExecutor()
mock_proc = MagicMock()
mock_proc.stdin = MagicMock()
mock_proc.terminate.side_effect = OSError("gone")
mock_proc.returncode = None
executor._proc = mock_proc
await executor.close() # must not propagate the OSError
# ---------------------------------------------------------------------------
# _rpc_id increments
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_rpc_id_increments_monotonically() -> None:
"""Each _rpc call uses a unique, incrementing id."""
executor = QwenExecutor()
sent: list[dict] = []
async def fake_send(msg: dict) -> None:
sent.append(msg)
# Immediately resolve the future so _rpc returns.
fut = executor._pending.get(msg["id"])
if fut and not fut.done():
fut.set_result({"jsonrpc": "2.0", "id": msg["id"], "result": {}})
executor._send = fake_send # type: ignore[method-assign]
await executor._rpc("initialize", {"protocolVersion": 1})
await executor._rpc("session/new", {"sessionId": "x", "cwd": "/", "mcpServers": []})
assert sent[0]["id"] == 1
assert sent[1]["id"] == 2
# ---------------------------------------------------------------------------
# _read_stdout — dispatches responses vs notifications
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_read_stdout_resolves_pending_future() -> None:
"""_read_stdout resolves the matching _pending future on a response."""
executor = QwenExecutor()
loop = asyncio.get_event_loop()
fut: asyncio.Future = loop.create_future()
executor._pending[42] = fut
response_line = json.dumps({"jsonrpc": "2.0", "id": 42, "result": {"ok": True}}) + "\n"
# Fake stdout that yields one line then EOF (b"" on the second readline).
mock_stdout = AsyncMock()
calls = [response_line.encode(), b""]
mock_stdout.readline = AsyncMock(side_effect=calls)
mock_proc = MagicMock()
mock_proc.stdout = mock_stdout
executor._proc = mock_proc
# Run reader until it sees EOF (second readline returns b"").
await executor._read_stdout()
assert fut.done()
assert fut.result()["result"]["ok"] is True
@pytest.mark.asyncio
async def test_read_stdout_puts_notifications_on_queue() -> None:
"""_read_stdout enqueues notifications (no id) onto the queue."""
executor = QwenExecutor()
notification = {
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": "sess-1",
"update": {
"sessionUpdate": "agent_message_chunk",
"content": {"type": "text", "text": "hello"},
},
},
}
notification_line = json.dumps(notification) + "\n"
mock_stdout = AsyncMock()
mock_stdout.readline = AsyncMock(side_effect=[notification_line.encode(), b""])
mock_proc = MagicMock()
mock_proc.stdout = mock_stdout
executor._proc = mock_proc
await executor._read_stdout()
assert not executor._queue.empty()
msg = executor._queue.get_nowait()
assert msg["method"] == "session/update"
@pytest.mark.asyncio
async def test_read_stdout_does_not_resolve_future_for_colliding_request() -> None:
"""A server request whose id collides with a pending one is queued, not resolved.
qwen mints its own request ids; one can equal an outstanding _rpc_id. Such a
message has a "method", so it must route to the queue, not our future.
"""
executor = QwenExecutor()
loop = asyncio.get_event_loop()
fut: asyncio.Future = loop.create_future()
executor._pending[2] = fut # our pending session/prompt, id=2
# qwen sends a permission *request* that happens to reuse id=2.
request = {
"jsonrpc": "2.0",
"id": 2,
"method": "session/request_permission",
"params": {"toolCall": {"_meta": {"toolName": "run_shell_command"}}},
}
mock_stdout = AsyncMock()
mock_stdout.readline = AsyncMock(side_effect=[(json.dumps(request) + "\n").encode(), b""])
mock_proc = MagicMock()
mock_proc.stdout = mock_stdout
executor._proc = mock_proc
await executor._read_stdout()
# The colliding request must NOT resolve our future with a result; it
# routes to the queue instead. (The trailing EOF wakes the still-pending
# future with EOFError so callers fail fast — see the EOF path — so the
# future may be done, but never with a result.)
assert fut.exception() is not None and not fut.cancelled()
assert isinstance(fut.exception(), EOFError)
assert 2 in executor._pending
queued = executor._queue.get_nowait()
assert queued["method"] == "session/request_permission"
@pytest.mark.asyncio
async def test_read_stdout_wakes_pending_futures_on_eof() -> None:
"""A clean EOF (subprocess crash) wakes in-flight futures with EOFError.
Without this, a qwen process that dies mid-turn closes stdout (an EOF, not
an exception), the reader exits normally, and the pending session/prompt
future never resolves — run_turn blocks until the idle timeout instead of
failing fast.
"""
executor = QwenExecutor()
loop = asyncio.get_event_loop()
fut: asyncio.Future = loop.create_future()
executor._pending[7] = fut # in-flight session/prompt
# stdout closes immediately (process died) — first readline is EOF.
mock_stdout = AsyncMock()
mock_stdout.readline = AsyncMock(side_effect=[b""])
mock_proc = MagicMock()
mock_proc.stdout = mock_stdout
executor._proc = mock_proc
await executor._read_stdout()
assert fut.done()
assert isinstance(fut.exception(), EOFError)
# ---------------------------------------------------------------------------
# _sandbox_launch_path — confine the qwen process tree per os_env.sandbox
# ---------------------------------------------------------------------------
def test_sandbox_launch_path_bare_when_no_sandbox() -> None:
"""No os_env, or sandbox type 'none', spawns the bare qwen binary."""
from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec
# os_env not provided at all.
bare = QwenExecutor(qwen_path="/usr/bin/qwen")
assert bare._sandbox_launch_path(["PATH"]) == "/usr/bin/qwen"
# os_env present but sandbox explicitly disabled.
executor = QwenExecutor(
qwen_path="/usr/bin/qwen",
os_env=OSEnvSpec(sandbox=OSEnvSandboxSpec(type="none")),
)
assert executor._sandbox_launch_path(["PATH"]) == "/usr/bin/qwen"
def test_sandbox_launch_path_wraps_active_policy(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
"""An active sandbox wraps qwen in a launcher with its roots + env baked in.
Mirrors pi: the whole qwen process tree is confined, so even an allowed
tool call can't escape the spec's read/write roots. Asserts the launcher is
returned and the policy carries qwen's own paths and our spawn env names.
"""
from omnigent.inner import sandbox as sandbox_mod
from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec
from omnigent.inner.sandbox import SandboxPolicy
captured: dict = {}
def _fake_resolve(_os_env, cwd: Path) -> SandboxPolicy:
return SandboxPolicy(
backend_type="linux_bwrap",
active=True,
read_roots=[cwd.resolve(strict=False)],
write_roots=[cwd.resolve(strict=False)],
write_files=[],
allow_network=True,
)
def _fake_launcher(target: str, sandbox: SandboxPolicy) -> str:
captured["target"] = target
captured["policy"] = sandbox
return "/fake/launcher"
monkeypatch.setattr(sandbox_mod, "resolve_sandbox", _fake_resolve)
monkeypatch.setattr(sandbox_mod, "create_exec_launcher", _fake_launcher)
executor = QwenExecutor(
cwd=str(tmp_path),
qwen_path="/usr/bin/qwen",
os_env=OSEnvSpec(sandbox=OSEnvSandboxSpec(type="linux_bwrap")),
)
path = executor._sandbox_launch_path(("PATH", "OPENAI_BASE_URL"))
assert path == "/fake/launcher"
assert captured["target"] == "/usr/bin/qwen"
policy = captured["policy"]
# qwen's config dir is a write root so it can start inside the jail.
assert any(str(p).endswith(".qwen") for p in policy.write_roots)
# Our deliberate spawn env names are pruneproof in the launcher.
assert policy.spawn_env_allowlist is not None
assert "PATH" in policy.spawn_env_allowlist
assert "OPENAI_BASE_URL" in policy.spawn_env_allowlist
def test_sandbox_launch_path_falls_back_when_backend_unavailable(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
"""A backend failure degrades to the bare binary, never blocks startup."""
from omnigent.inner import sandbox as sandbox_mod
from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec
def _boom(_os_env, _cwd) -> None:
raise NotImplementedError("no bwrap on this platform")
monkeypatch.setattr(sandbox_mod, "resolve_sandbox", _boom)
executor = QwenExecutor(
cwd=str(tmp_path),
qwen_path="/usr/bin/qwen",
os_env=OSEnvSpec(sandbox=OSEnvSandboxSpec(type="linux_bwrap")),
)
assert executor._sandbox_launch_path(["PATH"]) == "/usr/bin/qwen"
@pytest.mark.asyncio
async def test_start_process_resets_handshake_state(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A (re)start clears the one-way init latch so the fresh process re-handshakes.
After a crash the error paths reset session state but ``_initialized`` is a
one-way latch — without resetting it on restart, ``_ensure_initialized``
would skip ``initialize`` against the new subprocess and qwen would reject
the subsequent ``session/new``. ``_image_supported`` (derived from the init
response) is stale for the same reason.
"""
executor = QwenExecutor(qwen_path="/usr/bin/qwen")
# Simulate the post-crash state: handshake flags left latched from the
# previous (now-dead) subprocess.
executor._initialized = True
executor._image_supported = True
async def _fake_subprocess_exec(*_args, **_kwargs):
proc = MagicMock()
proc.stdout = AsyncMock()
proc.stdout.readline = AsyncMock(return_value=b"")
proc.stderr = AsyncMock()
proc.stderr.readline = AsyncMock(return_value=b"")
return proc
monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_subprocess_exec)
await executor._start_process()
assert executor._initialized is False
assert executor._image_supported is False
# ---------------------------------------------------------------------------
# _ensure_session resets on "Session not found"
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_ensure_session_uses_server_assigned_id() -> None:
"""_ensure_session stores the sessionId from the server response, not ours."""
executor = QwenExecutor()
executor._initialized = True # skip initialize
server_session_id = "server-assigned-uuid"
async def fake_rpc(method: str, params: dict, timeout: float = 30.0) -> dict:
if method == "session/new":
return {"jsonrpc": "2.0", "id": 1, "result": {"sessionId": server_session_id}}
return {"jsonrpc": "2.0", "id": 1, "result": {}}
executor._rpc = fake_rpc # type: ignore[method-assign]
sid = await executor._ensure_session()
assert sid == server_session_id
assert executor._session_id == server_session_id
@pytest.mark.asyncio
async def test_ensure_session_cached_after_first_call() -> None:
"""_ensure_session does not make a second RPC call once session is set."""
executor = QwenExecutor()
executor._initialized = True
executor._session_id = "cached-sid"
rpc_calls: list[str] = []
async def fake_rpc(method: str, params: dict, timeout: float = 30.0) -> dict:
rpc_calls.append(method)
return {"result": {}}
executor._rpc = fake_rpc # type: ignore[method-assign]
sid = await executor._ensure_session()
assert sid == "cached-sid"
assert rpc_calls == [] # no RPC was made
# ---------------------------------------------------------------------------
# run_turn — success path
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_run_turn_yields_text_chunks_and_turn_complete() -> None:
"""run_turn yields TextChunk events for agent_message_chunk notifications
and a TurnComplete when the session/prompt response arrives.
The fake_send callback:
1. Enqueues the streaming notification immediately (so the event loop
processes it before checking fut.done()).
2. Schedules the future resolution on the *next* event-loop iteration
via ``loop.call_soon`` so the notification is always consumed first.
"""
executor = QwenExecutor()
executor._initialized = True
executor._session_id = "sess-abc"
executor._proc = MagicMock()
executor._proc.returncode = None
session_id = executor._session_id
loop = asyncio.get_event_loop()
async def fake_send(msg: dict) -> None:
if msg.get("method") == "session/prompt":
req_id = msg["id"]
# 1. Put the streaming notification on the queue first.
notification = {
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": session_id,
"update": {
"sessionUpdate": "agent_message_chunk",
"content": {"type": "text", "text": "Hello!"},
},
},
}
await executor._queue.put(notification)
# 2. Resolve the future on the next loop iteration so the
# notification is consumed before fut.done() is True.
def _resolve() -> None:
fut = executor._pending.get(req_id)
if fut and not fut.done():
fut.set_result(
{
"jsonrpc": "2.0",
"id": req_id,
"result": {"stopReason": "end_turn"},
}
)
loop.call_soon(_resolve)
executor._send = fake_send # type: ignore[method-assign]
messages = [{"role": "user", "content": "say hi"}]
events = []
async for event in executor.run_turn(messages, [], "Be helpful"):
events.append(event)
text_chunks = [e for e in events if isinstance(e, TextChunk)]
turn_completes = [e for e in events if isinstance(e, TurnComplete)]
assert len(text_chunks) == 1
assert text_chunks[0].text == "Hello!"
assert len(turn_completes) == 1
assert turn_completes[0].response == "Hello!"
# ---------------------------------------------------------------------------
# Token usage — parsed from qwen's _meta.usage and emitted on TurnComplete
# ---------------------------------------------------------------------------
def test_accumulate_usage_maps_and_splits_cached() -> None:
"""_meta.usage maps to wire keys; cached tokens split out of input_tokens.
qwen's inputTokens (Gemini promptTokenCount) is inclusive of cached tokens,
but compute_llm_cost wants the non-cached portion in input_tokens.
"""
acc: dict[str, int] = {}
QwenExecutor._accumulate_usage(
acc,
{
"sessionUpdate": "agent_message_chunk",
"content": {"type": "text", "text": ""},
"_meta": {
"usage": {
"inputTokens": 1000,
"outputTokens": 200,
"totalTokens": 1200,
"cachedReadTokens": 300,
"thoughtTokens": 50,
}
},
},
)
assert acc == {
"input_tokens": 700, # 1000 - 300 cached
"output_tokens": 200,
"total_tokens": 1200,
"cache_read_input_tokens": 300,
}
def test_accumulate_usage_sums_across_calls_and_ignores_non_usage() -> None:
"""Multiple emissions sum; updates without _meta.usage are ignored."""
acc: dict[str, int] = {}
# A plain text chunk carries no usage — must not perturb the accumulator.
QwenExecutor._accumulate_usage(acc, {"content": {"type": "text", "text": "hi"}})
assert acc == {}
for _ in range(2):
QwenExecutor._accumulate_usage(
acc,
{"_meta": {"usage": {"inputTokens": 100, "outputTokens": 10, "totalTokens": 110}}},
)
# Summed across the two calls; no cached key when cachedReadTokens absent.
assert acc == {"input_tokens": 200, "output_tokens": 20, "total_tokens": 220}
def test_accumulate_usage_clamps_negative_input() -> None:
"""A malformed cached > input never drives input_tokens negative."""
acc: dict[str, int] = {}
QwenExecutor._accumulate_usage(
acc,
{"_meta": {"usage": {"inputTokens": 100, "cachedReadTokens": 500, "totalTokens": 100}}},
)
assert acc["input_tokens"] == 0
assert acc["cache_read_input_tokens"] == 500
@pytest.mark.asyncio
async def test_run_turn_emits_usage_on_turn_complete(monkeypatch: pytest.MonkeyPatch) -> None:
"""A usage-bearing chunk surfaces as TurnComplete.usage and notifies cost."""
notified: list[dict] = []
monkeypatch.setattr(
"omnigent.inner.qwen_executor._notify_usage_from_dict",
lambda model, usage: notified.append({"model": model, "usage": usage}),
)
executor = QwenExecutor(model="qwen/qwen3-coder")
executor._initialized = True
executor._session_id = "sess-usage"
executor._proc = MagicMock()
executor._proc.returncode = None
session_id = executor._session_id
async def fake_send(msg: dict) -> None:
if msg.get("method") == "session/prompt":
base = {
"jsonrpc": "2.0",
"method": "session/update",
"params": {"sessionId": session_id},
}
# 1. Real text chunk.
await executor._queue.put(
{
**base,
"params": {
"sessionId": session_id,
"update": {
"sessionUpdate": "agent_message_chunk",
"content": {"type": "text", "text": "done"},
},
},
}
)
# 2. Usage-bearing chunk (empty text + _meta.usage).
await executor._queue.put(
{
**base,
"params": {
"sessionId": session_id,
"update": {
"sessionUpdate": "agent_message_chunk",
"content": {"type": "text", "text": ""},
"_meta": {
"usage": {
"inputTokens": 500,
"outputTokens": 80,
"totalTokens": 580,
"cachedReadTokens": 100,
}
},
},
},
}
)
fut = executor._pending.get(msg["id"])
if fut and not fut.done():
fut.set_result(
{"jsonrpc": "2.0", "id": msg["id"], "result": {"stopReason": "end_turn"}}
)
executor._send = fake_send # type: ignore[method-assign]
events = [e async for e in executor.run_turn([{"role": "user", "content": "hi"}], [], "")]
completes = [e for e in events if isinstance(e, TurnComplete)]
assert len(completes) == 1
assert completes[0].response == "done"
assert completes[0].usage == {
"input_tokens": 400, # 500 - 100 cached
"output_tokens": 80,
"total_tokens": 580,
"cache_read_input_tokens": 100,
}
# Cost observer was notified with the same usage + the configured model.
assert notified == [{"model": "qwen/qwen3-coder", "usage": completes[0].usage}]
@pytest.mark.asyncio
async def test_run_turn_usage_none_when_unreported(monkeypatch: pytest.MonkeyPatch) -> None:
"""No usage chunk → TurnComplete.usage is None and the observer isn't called."""
notified: list[dict] = []
monkeypatch.setattr(
"omnigent.inner.qwen_executor._notify_usage_from_dict",
lambda model, usage: notified.append({"model": model, "usage": usage}),
)
executor = QwenExecutor()
executor._initialized = True
executor._session_id = "sess-no-usage"
executor._proc = MagicMock()
executor._proc.returncode = None
session_id = executor._session_id
async def fake_send(msg: dict) -> None:
if msg.get("method") == "session/prompt":
await executor._queue.put(
{
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": session_id,
"update": {
"sessionUpdate": "agent_message_chunk",
"content": {"type": "text", "text": "hi"},
},
},
}
)
fut = executor._pending.get(msg["id"])
if fut and not fut.done():
fut.set_result(
{"jsonrpc": "2.0", "id": msg["id"], "result": {"stopReason": "end_turn"}}
)
executor._send = fake_send # type: ignore[method-assign]
events = [e async for e in executor.run_turn([{"role": "user", "content": "hi"}], [], "")]
completes = [e for e in events if isinstance(e, TurnComplete)]
assert len(completes) == 1
assert completes[0].usage is None
assert notified == []
@pytest.mark.asyncio
async def test_run_turn_drains_all_chunks_before_completing() -> None:
"""All buffered chunks are yielded even if the future resolves first.
The reader can enqueue several chunks AND resolve the prompt future before
run_turn drains the queue. Completion is gated on an empty queue, so no
chunk is lost to a premature return.
"""
executor = QwenExecutor()
executor._initialized = True
executor._session_id = "sess-drain"
executor._proc = MagicMock()
executor._proc.returncode = None
session_id = executor._session_id
async def fake_send(msg: dict) -> None:
if msg.get("method") == "session/prompt":
for piece in ("a", "b", "c"):
await executor._queue.put(
{
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": session_id,
"update": {
"sessionUpdate": "agent_message_chunk",
"content": {"type": "text", "text": piece},
},
},
}
)
# Resolve immediately — chunks are still buffered in the queue.
fut = executor._pending.get(msg["id"])
if fut and not fut.done():
fut.set_result(
{"jsonrpc": "2.0", "id": msg["id"], "result": {"stopReason": "end_turn"}}
)
executor._send = fake_send # type: ignore[method-assign]
events = [e async for e in executor.run_turn([{"role": "user", "content": "hi"}], [], "")]
text = "".join(e.text for e in events if isinstance(e, TextChunk))
completes = [e for e in events if isinstance(e, TurnComplete)]
assert text == "abc"
assert len(completes) == 1
assert completes[0].response == "abc"
@pytest.mark.asyncio
async def test_run_turn_approval_does_not_count_against_idle_timeout(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A slow human approval must not trip the response timeout.
With a tiny timeout and an elicitation handler slower than it, the turn must
still complete: the idle deadline resets after the approval round-trip.
"""
monkeypatch.setattr("omnigent.inner.qwen_executor._PROMPT_TIMEOUT_SECONDS", 0.05)
executor = QwenExecutor()
executor._initialized = True
executor._session_id = "sess-slow"
executor._proc = MagicMock()
executor._proc.returncode = None
async def slow_handler(tool_name: str, tool_input: dict) -> bool:
await asyncio.sleep(0.25) # 5x the timeout
return True
executor._elicitation_handler = slow_handler # type: ignore[assignment]
async def fake_send(msg: dict) -> None:
if msg.get("method") == "session/prompt":
# qwen asks permission before finishing the turn.
await executor._queue.put(
{
"jsonrpc": "2.0",
"id": 999,
"method": "session/request_permission",
"params": {
"toolCall": {"_meta": {"toolName": "run_shell_command"}},
"options": [{"kind": "allow_once", "optionId": "ok"}],
},
}
)
elif "result" in msg and msg.get("id") == 999:
# Our approval reply went out — now qwen completes the prompt.
rid = executor._rpc_id
fut = executor._pending.get(rid)
if fut and not fut.done():
fut.set_result({"jsonrpc": "2.0", "id": rid, "result": {"stopReason": "end_turn"}})
executor._send = fake_send # type: ignore[method-assign]
events = [e async for e in executor.run_turn([{"role": "user", "content": "rm it"}], [], "")]
assert any(isinstance(e, TurnComplete) for e in events)
assert not any(isinstance(e, ExecutorError) for e in events)
# ---------------------------------------------------------------------------
# run_turn — ACP error response
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_run_turn_yields_executor_error_on_acp_error() -> None:
"""run_turn yields ExecutorError when session/prompt returns an error."""
executor = QwenExecutor()
executor._initialized = True
executor._session_id = "sess-err"
executor._proc = MagicMock()
executor._proc.returncode = None
async def fake_send(msg: dict) -> None:
if msg.get("method") == "session/prompt":
fut = executor._pending.get(msg["id"])
if fut and not fut.done():
fut.set_result(
{
"jsonrpc": "2.0",
"id": msg["id"],
"error": {"code": -32603, "message": "Something went wrong"},
}
)
executor._send = fake_send # type: ignore[method-assign]
messages = [{"role": "user", "content": "hi"}]
events = []
async for event in executor.run_turn(messages, [], ""):
events.append(event)
assert len(events) == 1
assert isinstance(events[0], ExecutorError)
assert "Something went wrong" in events[0].message
@pytest.mark.asyncio
async def test_run_turn_resets_session_on_not_found_error() -> None:
"""run_turn clears _session_id when ACP reports Session not found."""
executor = QwenExecutor()
executor._initialized = True
executor._session_id = "stale-sess"
executor._proc = MagicMock()
executor._proc.returncode = None
async def fake_send(msg: dict) -> None:
if msg.get("method") == "session/prompt":
fut = executor._pending.get(msg["id"])
if fut and not fut.done():
fut.set_result(
{
"jsonrpc": "2.0",
"id": msg["id"],
"error": {
"code": -32603,
"message": "Session not found: stale-sess",
},
}
)
executor._send = fake_send # type: ignore[method-assign]
async for _ in executor.run_turn([{"role": "user", "content": "hi"}], [], ""):
pass
# Session id should be reset so next turn creates a fresh session.
assert executor._session_id is None
# ---------------------------------------------------------------------------
# Harness registry / alias wiring
# ---------------------------------------------------------------------------
def test_qwen_in_harness_registry() -> None:
"""'qwen' must be in the _HARNESS_MODULES dispatch table."""
from omnigent.runtime.harnesses import _HARNESS_MODULES
assert "qwen" in _HARNESS_MODULES
def test_qwen_in_harness_allowlist() -> None:
"""'qwen' must be in OMNIGENT_HARNESSES."""
from omnigent.spec._omnigent_compat import OMNIGENT_HARNESSES
assert "qwen" in OMNIGENT_HARNESSES
def test_qwen_code_alias_resolves_to_qwen() -> None:
"""'qwen-code' alias maps to the canonical 'qwen' harness id."""
from omnigent.harness_aliases import canonicalize_harness
assert canonicalize_harness("qwen-code") == "qwen"
def test_qwen_code_in_harness_aliases() -> None:
"""'qwen-code' must be in OMNIGENT_HARNESS_ALIASES."""
from omnigent.spec._omnigent_compat import OMNIGENT_HARNESS_ALIASES
assert "qwen-code" in OMNIGENT_HARNESS_ALIASES
# ---------------------------------------------------------------------------
# FastAPI app shape
# ---------------------------------------------------------------------------
def test_qwen_harness_creates_fastapi_app() -> None:
"""create_app() returns a FastAPI app with at least a /health route."""
from omnigent.inner.qwen_harness import create_app
app = create_app()
assert app is not None
assert hasattr(app, "routes")
health_routes = [r for r in app.routes if hasattr(r, "path") and "/health" in r.path]
assert len(health_routes) > 0
def test_qwen_harness_module_importable() -> None:
"""qwen_harness can be imported and exposes create_app."""
from omnigent.inner import qwen_harness
assert hasattr(qwen_harness, "create_app")
def test_wrap_passes_gateway_env_to_executor(monkeypatch: pytest.MonkeyPatch) -> None:
"""_build_qwen_executor threads HARNESS_QWEN_GATEWAY_* into the executor."""
from omnigent.inner import qwen_harness
monkeypatch.setenv("HARNESS_QWEN_MODEL", "qwen/qwen3-coder")
monkeypatch.setenv("HARNESS_QWEN_GATEWAY_BASE_URL", "https://gw.example/v1")
monkeypatch.setenv("HARNESS_QWEN_GATEWAY_AUTH_COMMAND", "printf '%s' sk-x")
executor = qwen_harness._build_qwen_executor()
assert isinstance(executor, QwenExecutor)
assert executor._gateway_base_url == "https://gw.example/v1"
assert executor._gateway_auth_command == "printf '%s' sk-x"
def test_wrap_gateway_env_absent_leaves_executor_ungated(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Without the gateway env vars, the executor has no gateway config."""
from omnigent.inner import qwen_harness
monkeypatch.delenv("HARNESS_QWEN_GATEWAY_BASE_URL", raising=False)
monkeypatch.delenv("HARNESS_QWEN_GATEWAY_AUTH_COMMAND", raising=False)
executor = qwen_harness._build_qwen_executor()
assert isinstance(executor, QwenExecutor)
assert executor._gateway_base_url is None
assert executor._gateway_auth_command is None
# ---------------------------------------------------------------------------
# close_session is a no-op (sessions are per-process)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_close_session_is_noop() -> None:
"""close_session() does nothing and does not raise."""
executor = QwenExecutor()
await executor.close_session("some-key") # must not raise
# ---------------------------------------------------------------------------
# system_prompt folded into the first turn (ACP has no system field)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_run_turn_prepends_system_prompt_on_first_turn() -> None:
"""The system prompt is prepended to the first user turn's text.
ACP has no dedicated system-prompt field, so the spec ``prompt:`` would
otherwise never reach qwen. The second turn must NOT repeat it (the
session retains context).
"""
executor = QwenExecutor()
executor._initialized = True
executor._session_id = "sess-sp"
executor._proc = MagicMock()
executor._proc.returncode = None
loop = asyncio.get_event_loop()
sent_prompts: list[str] = []
async def fake_send(msg: dict) -> None:
if msg.get("method") == "session/prompt":
sent_prompts.append(msg["params"]["prompt"][0]["text"])
req_id = msg["id"]
def _resolve() -> None:
fut = executor._pending.get(req_id)
if fut and not fut.done():
fut.set_result(
{"jsonrpc": "2.0", "id": req_id, "result": {"stopReason": "end_turn"}}
)
loop.call_soon(_resolve)
executor._send = fake_send # type: ignore[method-assign]
messages = [{"role": "user", "content": "first"}]
async for _ in executor.run_turn(messages, [], "SYSTEM RULES"):
pass
async for _ in executor.run_turn([{"role": "user", "content": "second"}], [], "SYSTEM RULES"):
pass
assert sent_prompts[0] == "SYSTEM RULES\n\nfirst"
assert sent_prompts[1] == "second" # not repeated
assert executor._system_prompt_sent is True
@pytest.mark.asyncio
async def test_run_turn_resends_system_prompt_after_session_reset() -> None:
"""After a 'Session not found' reset, the next turn re-folds the system prompt.
Losing the session clears ``_system_prompt_sent`` so the fresh session
receives the spec prompt again (qwen no longer holds the earlier context).
"""
executor = QwenExecutor()
executor._initialized = True
executor._session_id = "sess-1"
executor._proc = MagicMock()
executor._proc.returncode = None
sent_prompts: list[str] = []
fail_next = {"flag": True} # turn 1's prompt is rejected with "Session not found"
async def fake_send(msg: dict) -> None:
method = msg.get("method")
fut = executor._pending.get(msg["id"])
if method == "session/new":
# Fresh session created after the reset.
if fut and not fut.done():
fut.set_result(
{"jsonrpc": "2.0", "id": msg["id"], "result": {"sessionId": "sess-2"}}
)
elif method == "session/prompt":
sent_prompts.append(msg["params"]["prompt"][0]["text"])
if fut and not fut.done():
if fail_next["flag"]:
fail_next["flag"] = False
fut.set_result(
{
"jsonrpc": "2.0",
"id": msg["id"],
"error": {"code": -32603, "message": "Session not found: sess-1"},
}
)
else:
fut.set_result(
{"jsonrpc": "2.0", "id": msg["id"], "result": {"stopReason": "end_turn"}}
)
executor._send = fake_send # type: ignore[method-assign]
# Turn 1: folds the prompt, then errors with "Session not found" → reset.
async for _ in executor.run_turn([{"role": "user", "content": "first"}], [], "SYSTEM"):
pass
# Turn 2: fresh session — the system prompt must be re-folded.
async for _ in executor.run_turn([{"role": "user", "content": "second"}], [], "SYSTEM"):
pass
assert sent_prompts[0] == "SYSTEM\n\nfirst" # turn 1 folded
assert sent_prompts[1] == "SYSTEM\n\nsecond" # re-folded into the new session
assert executor._session_id == "sess-2"
# ---------------------------------------------------------------------------
# History replay on a fresh session (model switch / reset doesn't drop context)
# ---------------------------------------------------------------------------
def test_history_prefix_serializes_prior_turns() -> None:
"""_history_prefix renders prior turns as labeled role: content lines."""
prior = [
{"role": "user", "content": "what is 2+2"},
{"role": "assistant", "content": "4"},
{"role": "user", "content": [{"type": "input_text", "text": "and 3+3"}]},
{"role": "assistant", "content": [{"type": "output_text", "text": "6"}]},
]
out = QwenExecutor._history_prefix(prior)
assert out.startswith("Conversation so far:")
assert "user: what is 2+2" in out
assert "assistant: 4" in out
assert "user: and 3+3" in out # list content folded via _text_from_blocks
assert "assistant: 6" in out
assert out.rstrip().endswith("using the conversation above as context.")
@pytest.mark.asyncio
async def test_run_turn_replays_history_on_fresh_session() -> None:
"""A fresh session folds prior turns into the prompt (e.g. after /model respawn).
qwen normally only sees the latest user turn; on a brand-new subprocess
(a ``/model`` switch respawns it) that would drop everything before the
switch. The first turn must replay the transcript so context survives.
"""
executor = QwenExecutor()
executor._initialized = True
executor._session_id = "sess-fresh" # session exists, but no prior turns sent
executor._proc = MagicMock()
executor._proc.returncode = None
loop = asyncio.get_event_loop()
sent_prompts: list[str] = []
async def fake_send(msg: dict) -> None:
if msg.get("method") == "session/prompt":
sent_prompts.append(msg["params"]["prompt"][0]["text"])
req_id = msg["id"]
def _resolve() -> None:
fut = executor._pending.get(req_id)
if fut and not fut.done():
fut.set_result(
{"jsonrpc": "2.0", "id": req_id, "result": {"stopReason": "end_turn"}}
)
loop.call_soon(_resolve)
executor._send = fake_send # type: ignore[method-assign]
messages = [
{"role": "user", "content": "remember the number 42"},
{"role": "assistant", "content": "Got it, 42."},
{"role": "user", "content": "what number did I say?"},
]
async for _ in executor.run_turn(messages, [], "SYS"):
pass
prompt = sent_prompts[0]
# System prompt first, then replayed history, then the latest turn.
assert prompt.startswith("SYS\n\n")
assert "Conversation so far:" in prompt
assert "user: remember the number 42" in prompt
assert "assistant: Got it, 42." in prompt
assert prompt.rstrip().endswith("user: what number did I say?")
@pytest.mark.asyncio
async def test_run_turn_no_replay_on_continuing_session() -> None:
"""A continuing session sends only the latest turn (qwen retains context)."""
executor = QwenExecutor()
executor._initialized = True
executor._session_id = "sess-cont"
executor._system_prompt_sent = True # not a fresh session
executor._proc = MagicMock()
executor._proc.returncode = None
loop = asyncio.get_event_loop()
sent_prompts: list[str] = []
async def fake_send(msg: dict) -> None:
if msg.get("method") == "session/prompt":
sent_prompts.append(msg["params"]["prompt"][0]["text"])
req_id = msg["id"]
def _resolve() -> None:
fut = executor._pending.get(req_id)
if fut and not fut.done():
fut.set_result(
{"jsonrpc": "2.0", "id": req_id, "result": {"stopReason": "end_turn"}}
)
loop.call_soon(_resolve)
executor._send = fake_send # type: ignore[method-assign]
messages = [
{"role": "user", "content": "earlier turn"},
{"role": "assistant", "content": "ok"},
{"role": "user", "content": "latest turn"},
]
async for _ in executor.run_turn(messages, [], "SYS"):
pass
# No history prefix, no system-prompt re-fold — just the latest message.
assert sent_prompts[0] == "latest turn"
@pytest.mark.asyncio
async def test_run_turn_no_replay_on_genuine_first_turn() -> None:
"""A brand-new conversation (single user turn) has nothing to replay."""
executor = QwenExecutor()
executor._initialized = True
executor._session_id = "sess-first"
executor._proc = MagicMock()
executor._proc.returncode = None
loop = asyncio.get_event_loop()
sent_prompts: list[str] = []
async def fake_send(msg: dict) -> None:
if msg.get("method") == "session/prompt":
sent_prompts.append(msg["params"]["prompt"][0]["text"])
req_id = msg["id"]
def _resolve() -> None:
fut = executor._pending.get(req_id)
if fut and not fut.done():
fut.set_result(
{"jsonrpc": "2.0", "id": req_id, "result": {"stopReason": "end_turn"}}
)
loop.call_soon(_resolve)
executor._send = fake_send # type: ignore[method-assign]
async for _ in executor.run_turn([{"role": "user", "content": "hello"}], [], "SYS"):
pass
assert sent_prompts[0] == "SYS\n\nhello" # system prompt only, no replay
assert "Conversation so far:" not in sent_prompts[0]
# ---------------------------------------------------------------------------
# Server-initiated requests: permission, unsupported methods (incl. fs/*)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_respond_to_fs_read_text_file_unsupported_without_os_env() -> None:
"""fs/* is method-not-found when fs delegation isn't advertised.
With no os_env configured, ``_fs_delegation`` is False and we never
advertise ``clientCapabilities.fs``, so qwen uses its own file tools. A
stray ``fs/read_text_file`` must get a JSON-RPC method-not-found error
rather than a fabricated (and dangerous) empty/real-file response.
"""
executor = QwenExecutor() # no os_env → delegation off
assert executor._fs_delegation is False
sent: list[dict] = []
executor._send = AsyncMock(side_effect=lambda m: sent.append(m)) # type: ignore[method-assign]
await executor._respond_to_agent_request(
{"jsonrpc": "2.0", "id": 7, "method": "fs/read_text_file", "params": {"path": "/x"}}
)
assert sent[0]["id"] == 7
assert sent[0]["error"]["code"] == -32601
assert "result" not in sent[0]
# ---------------------------------------------------------------------------
# Filesystem delegation (fs/read_text_file, fs/write_text_file)
# ---------------------------------------------------------------------------
class _FakeOSEnv:
"""Minimal OSEnvironment stand-in capturing read/write calls."""
def __init__(self, read_result: dict | None = None, write_result: dict | None = None) -> None:
self._read_result = read_result if read_result is not None else {}
self._write_result = write_result if write_result is not None else {}
self.read_calls: list[tuple] = []
self.write_calls: list[tuple] = []
self.closed = False
async def read(self, path: str, offset: int = 1, limit: int | None = None) -> dict:
self.read_calls.append((path, offset, limit))
return self._read_result
async def write(self, path: str, content: str) -> dict:
self.write_calls.append((path, content))
return self._write_result
def close(self) -> None:
self.closed = True
def test_fs_delegation_flag_tracks_os_env() -> None:
"""Delegation is on with an os_env, off without one or for a fork env."""
from omnigent.inner.datamodel import OSEnvSpec
assert QwenExecutor()._fs_delegation is False # no os_env
assert QwenExecutor(os_env=OSEnvSpec(type="caller_process"))._fs_delegation is True
# A fork env operates on a copied tree → path would diverge from the qwen
# subprocess cwd, so delegation must stay off.
assert QwenExecutor(os_env=OSEnvSpec(type="caller_process", fork=True))._fs_delegation is False
@pytest.mark.asyncio
async def test_initialize_advertises_fs_capability_per_delegation() -> None:
"""initialize advertises clientCapabilities.fs matching the delegation flag."""
from omnigent.inner.datamodel import OSEnvSpec
init_result = {"result": {"agentCapabilities": {"promptCapabilities": {}}}}
# Delegation ON (os_env configured).
on = QwenExecutor(os_env=OSEnvSpec(type="caller_process"))
on._rpc = AsyncMock(return_value=init_result) # type: ignore[method-assign]
await on._ensure_initialized()
caps = on._rpc.call_args.args[1]["clientCapabilities"]["fs"]
assert caps == {"readTextFile": True, "writeTextFile": True}
# Delegation OFF (no os_env).
off = QwenExecutor()
off._rpc = AsyncMock(return_value=init_result) # type: ignore[method-assign]
await off._ensure_initialized()
caps = off._rpc.call_args.args[1]["clientCapabilities"]["fs"]
assert caps == {"readTextFile": False, "writeTextFile": False}
@pytest.mark.asyncio
async def test_fs_read_returns_content_and_maps_window() -> None:
"""fs/read_text_file reads through the OSEnvironment; line/limit → offset/limit."""
from omnigent.inner.datamodel import OSEnvSpec
executor = QwenExecutor(os_env=OSEnvSpec(type="caller_process"))
fake = _FakeOSEnv(read_result={"content": "hello\nworld\n", "encoding": "utf-8"})
executor._os_environment = fake # type: ignore[assignment]
sent: list[dict] = []
executor._send = AsyncMock(side_effect=lambda m: sent.append(m)) # type: ignore[method-assign]
await executor._respond_to_agent_request(
{
"jsonrpc": "2.0",
"id": 3,
"method": "fs/read_text_file",
"params": {"path": "a.txt", "line": 2, "limit": 5},
}
)
assert sent[0]["result"] == {"content": "hello\nworld\n"}
assert fake.read_calls == [("a.txt", 2, 5)] # line→offset, limit→limit
@pytest.mark.asyncio
async def test_fs_read_whole_file_when_no_window() -> None:
"""Absent line/limit reads the whole file (offset=1, limit=None)."""
from omnigent.inner.datamodel import OSEnvSpec
executor = QwenExecutor(os_env=OSEnvSpec(type="caller_process"))
fake = _FakeOSEnv(read_result={"content": "x", "encoding": "utf-8"})
executor._os_environment = fake # type: ignore[assignment]
executor._send = AsyncMock() # type: ignore[method-assign]
await executor._respond_to_agent_request(
{"jsonrpc": "2.0", "id": 1, "method": "fs/read_text_file", "params": {"path": "a.txt"}}
)
assert fake.read_calls == [("a.txt", 1, None)]
@pytest.mark.asyncio
async def test_fs_read_missing_file_maps_to_enoent() -> None:
"""A 'no such file' read error maps to qwen's ENOENT code (-32002)."""
from omnigent.inner.datamodel import OSEnvSpec
executor = QwenExecutor(os_env=OSEnvSpec(type="caller_process"))
executor._os_environment = _FakeOSEnv( # type: ignore[assignment]
read_result={"error": "[Errno 2] No such file or directory: 'gone.txt'"}
)
sent: list[dict] = []
executor._send = AsyncMock(side_effect=lambda m: sent.append(m)) # type: ignore[method-assign]
await executor._respond_to_agent_request(
{"jsonrpc": "2.0", "id": 5, "method": "fs/read_text_file", "params": {"path": "gone.txt"}}
)
assert sent[0]["error"]["code"] == -32002
assert "result" not in sent[0]
@pytest.mark.asyncio
async def test_fs_read_binary_file_is_rejected() -> None:
"""A non-utf-8 (binary) file is refused rather than returned as bytes."""
from omnigent.inner.datamodel import OSEnvSpec
executor = QwenExecutor(os_env=OSEnvSpec(type="caller_process"))
executor._os_environment = _FakeOSEnv( # type: ignore[assignment]
read_result={"content": "AAAA", "encoding": "base64", "total_bytes": 3}
)
sent: list[dict] = []
executor._send = AsyncMock(side_effect=lambda m: sent.append(m)) # type: ignore[method-assign]
await executor._respond_to_agent_request(
{"jsonrpc": "2.0", "id": 6, "method": "fs/read_text_file", "params": {"path": "img.png"}}
)
assert sent[0]["error"]["code"] == -32603
assert "text file" in sent[0]["error"]["message"]
@pytest.mark.asyncio
async def test_fs_write_writes_through_os_env() -> None:
"""fs/write_text_file writes via the OSEnvironment and returns an empty result."""
from omnigent.inner.datamodel import OSEnvSpec
executor = QwenExecutor(os_env=OSEnvSpec(type="caller_process"))
fake = _FakeOSEnv(write_result={"path": "out.txt", "bytes": 3})
executor._os_environment = fake # type: ignore[assignment]
sent: list[dict] = []
executor._send = AsyncMock(side_effect=lambda m: sent.append(m)) # type: ignore[method-assign]
await executor._respond_to_agent_request(
{
"jsonrpc": "2.0",
"id": 8,
"method": "fs/write_text_file",
"params": {"path": "out.txt", "content": "abc"},
}
)
assert sent[0]["result"] == {}
assert fake.write_calls == [("out.txt", "abc")]
@pytest.mark.asyncio
async def test_fs_write_error_surfaces_as_internal_error() -> None:
"""A write failure surfaces as a JSON-RPC internal error (-32603)."""
from omnigent.inner.datamodel import OSEnvSpec
executor = QwenExecutor(os_env=OSEnvSpec(type="caller_process"))
executor._os_environment = _FakeOSEnv( # type: ignore[assignment]
write_result={"error": "Permission denied"}
)
sent: list[dict] = []
executor._send = AsyncMock(side_effect=lambda m: sent.append(m)) # type: ignore[method-assign]
await executor._respond_to_agent_request(
{
"jsonrpc": "2.0",
"id": 9,
"method": "fs/write_text_file",
"params": {"path": "out.txt", "content": "abc"},
}
)
assert sent[0]["error"]["code"] == -32603
assert "Permission denied" in sent[0]["error"]["message"]
@pytest.mark.asyncio
async def test_fs_write_rejects_missing_args() -> None:
"""Missing path / non-string content is an invalid-params error (-32602)."""
from omnigent.inner.datamodel import OSEnvSpec
executor = QwenExecutor(os_env=OSEnvSpec(type="caller_process"))
executor._os_environment = _FakeOSEnv() # type: ignore[assignment]
sent: list[dict] = []
executor._send = AsyncMock(side_effect=lambda m: sent.append(m)) # type: ignore[method-assign]
await executor._respond_to_agent_request(
{"jsonrpc": "2.0", "id": 10, "method": "fs/write_text_file", "params": {"path": "out.txt"}}
)
assert sent[0]["error"]["code"] == -32602
@pytest.mark.asyncio
async def test_close_releases_fs_os_environment() -> None:
"""close() tears down a lazily-created fs-delegation OSEnvironment."""
from omnigent.inner.datamodel import OSEnvSpec
executor = QwenExecutor(os_env=OSEnvSpec(type="caller_process"))
fake = _FakeOSEnv()
executor._os_environment = fake # type: ignore[assignment]
await executor.close()
assert fake.closed is True
assert executor._os_environment is None
# Realistic qwen session/request_permission payload (from the ACP probe).
def _perm_request(req_id: int = 9) -> dict:
return {
"jsonrpc": "2.0",
"id": req_id,
"method": "session/request_permission",
"params": {
"sessionId": "s",
"options": [
{"optionId": "proceed_always_project", "kind": "allow_always"},
{"optionId": "proceed_once", "kind": "allow_once"},
{"optionId": "cancel", "kind": "reject_once"},
],
"toolCall": {
"kind": "execute",
"rawInput": {"command": "rm -f victim.txt"},
"_meta": {"toolName": "run_shell_command"},
},
},
}
@pytest.mark.asyncio
async def test_respond_to_permission_allows_when_no_gates_wired() -> None:
"""With no policy/elicitation bridge wired, permission falls back to allow.
Prefers the once-scoped grant (``allow_once``), never ``allow_always``.
"""
executor = QwenExecutor() # no _policy_evaluator / _elicitation_handler
sent: list[dict] = []
executor._send = AsyncMock(side_effect=lambda m: sent.append(m)) # type: ignore[method-assign]
await executor._respond_to_agent_request(_perm_request())
assert sent[0]["result"]["outcome"] == {"outcome": "selected", "optionId": "proceed_once"}
@pytest.mark.asyncio
async def test_respond_to_permission_denied_by_policy() -> None:
"""A POLICY_ACTION_DENY verdict selects a reject option — no elicitation."""
executor = QwenExecutor()
executor._policy_evaluator = AsyncMock( # type: ignore[attr-defined]
return_value=MagicMock(action="POLICY_ACTION_DENY")
)
executor._elicitation_handler = AsyncMock(return_value=True) # type: ignore[attr-defined]
sent: list[dict] = []
executor._send = AsyncMock(side_effect=lambda m: sent.append(m)) # type: ignore[method-assign]
await executor._respond_to_agent_request(_perm_request())
assert sent[0]["result"]["outcome"] == {"outcome": "selected", "optionId": "cancel"}
executor._elicitation_handler.assert_not_called() # policy DENY short-circuits
# Policy saw the real tool name + args extracted from the payload.
phase, data = executor._policy_evaluator.call_args.args
assert phase == "PHASE_TOOL_CALL"
assert data == {"name": "run_shell_command", "arguments": {"command": "rm -f victim.txt"}}
@pytest.mark.asyncio
async def test_respond_to_permission_elicitation_allow_and_deny() -> None:
"""With only elicitation wired, the user's accept/deny maps to allow/reject."""
# Accept → allow_once.
allow_exec = QwenExecutor()
allow_exec._elicitation_handler = AsyncMock(return_value=True) # type: ignore[attr-defined]
sent_a: list[dict] = []
allow_exec._send = AsyncMock(side_effect=lambda m: sent_a.append(m)) # type: ignore[method-assign]
await allow_exec._respond_to_agent_request(_perm_request())
assert sent_a[0]["result"]["outcome"] == {"outcome": "selected", "optionId": "proceed_once"}
allow_exec._elicitation_handler.assert_awaited_once_with(
"run_shell_command", {"command": "rm -f victim.txt"}
)
# Deny → reject_once.
deny_exec = QwenExecutor()
deny_exec._elicitation_handler = AsyncMock(return_value=False) # type: ignore[attr-defined]
sent_d: list[dict] = []
deny_exec._send = AsyncMock(side_effect=lambda m: sent_d.append(m)) # type: ignore[method-assign]
await deny_exec._respond_to_agent_request(_perm_request())
assert sent_d[0]["result"]["outcome"] == {"outcome": "selected", "optionId": "cancel"}
@pytest.mark.asyncio
async def test_respond_to_unknown_method_returns_jsonrpc_error() -> None:
"""An unsupported server request yields a method-not-found error, not {}."""
executor = QwenExecutor()
sent: list[dict] = []
executor._send = AsyncMock(side_effect=lambda m: sent.append(m)) # type: ignore[method-assign]
await executor._respond_to_agent_request(
{"jsonrpc": "2.0", "id": 11, "method": "terminal/create", "params": {}}
)
assert sent[0]["error"]["code"] == -32601
assert "result" not in sent[0]
# ---------------------------------------------------------------------------
# stderr is drained so a chatty CLI can't wedge the pipe
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_read_stderr_drains_until_eof() -> None:
"""_read_stderr consumes lines and exits cleanly on EOF."""
executor = QwenExecutor()
mock_stderr = AsyncMock()
mock_stderr.readline = AsyncMock(side_effect=[b"warn: something\n", b""])
mock_proc = MagicMock()
mock_proc.stderr = mock_stderr
executor._proc = mock_proc
await executor._read_stderr() # must terminate at EOF, not hang
assert mock_stderr.readline.await_count == 2
# ---------------------------------------------------------------------------
# Missing-binary path surfaces a clear error on first turn
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_run_turn_missing_binary_yields_retryable_error() -> None:
"""A non-existent qwen binary surfaces as an ExecutorError, not a crash."""
executor = QwenExecutor(qwen_path="/nonexistent/qwen-binary-xyz")
events = []
async for event in executor.run_turn([{"role": "user", "content": "hi"}], [], "be helpful"):
events.append(event)
assert len(events) == 1
assert isinstance(events[0], ExecutorError)
# ---------------------------------------------------------------------------
# Content-block extraction (file attachments must not drop the message)
# ---------------------------------------------------------------------------
def test_text_from_blocks_recovers_input_text_and_marks_file() -> None:
"""A message with input_text + input_file keeps the text and notes the file.
Regression: the old ``type == "text"`` filter matched neither ``input_text``
nor ``input_file``, so attaching a file dropped the ENTIRE message.
"""
blocks = [
{"type": "input_text", "text": "review this"},
{"type": "input_file", "file_id": "f_1", "filename": "foo.py"},
]
out = QwenExecutor._text_from_blocks(blocks)
assert "review this" in out
assert "[attached file: foo.py]" in out
def test_text_from_blocks_inlines_text_file_data() -> None:
"""A text input_file with a base64 data URI is inlined into the prompt."""
import base64
payload = base64.b64encode(b"print('hi')").decode()
blocks = [
{"type": "input_text", "text": "summarize"},
{
"type": "input_file",
"file_data": f"data:text/x-python;base64,{payload}",
"filename": "a.py",
},
]
out = QwenExecutor._text_from_blocks(blocks)
assert "summarize" in out
assert "print('hi')" in out
# Content is fenced with a labeled header/footer so weaker models read it as
# an attachment, not instructions (regression: bare-appended file content
# derailed qwen3-coder:free into narrating the tool call as prose).
assert "--- attached file: a.py ---" in out
assert "--- end of a.py ---" in out
def test_text_from_blocks_skips_image_and_marks_binary_file() -> None:
"""Images are skipped (deferred); binary files fall back to a name marker."""
import base64
payload = base64.b64encode(b"%PDF-1.4").decode()
blocks = [
{"type": "input_image", "file_id": "img"},
{
"type": "input_file",
"file_data": f"data:application/pdf;base64,{payload}",
"filename": "d.pdf",
},
]
out = QwenExecutor._text_from_blocks(blocks)
assert out == "[attached file: d.pdf]"
def test_image_blocks_from_content_builds_acp_image_block() -> None:
"""An input_image with a resolved data URI becomes an ACP image block."""
import base64
payload = base64.b64encode(b"\x89PNG...").decode()
content = [
{"type": "input_text", "text": "what is this"},
{"type": "input_image", "image_url": f"data:image/png;base64,{payload}"},
]
blocks = QwenExecutor._image_blocks_from_content(content)
assert blocks == [{"type": "image", "mimeType": "image/png", "data": payload}]
def test_image_blocks_from_content_skips_non_image_and_external_urls() -> None:
"""Only inline image data URIs are forwarded; URLs/non-images are skipped."""
content = [
{"type": "input_image", "image_url": "https://example.com/cat.png"},
{"type": "input_file", "file_data": "data:text/plain;base64,aGk="},
{"type": "input_image", "image_url": "data:application/pdf;base64,JVBE"},
]
assert QwenExecutor._image_blocks_from_content(content) == []
def test_image_blocks_from_content_uses_file_data_fallback() -> None:
"""An input_image carrying its data URI in file_data (not image_url) works."""
content = [
{"type": "input_image", "file_data": "data:image/jpeg;base64,/9j/4AAQ"},
]
assert QwenExecutor._image_blocks_from_content(content) == [
{"type": "image", "mimeType": "image/jpeg", "data": "/9j/4AAQ"}
]
def test_parse_image_data_uri_edge_cases() -> None:
"""Malformed / non-image data URIs return None rather than raising."""
from omnigent.inner.qwen_executor import _parse_image_data_uri
assert _parse_image_data_uri("data:image/png;base64") is None # no comma
assert _parse_image_data_uri("data:image/png;base64,") is None # empty payload
assert _parse_image_data_uri("https://example.com/x.png") is None # not a data URI
assert _parse_image_data_uri(None) is None
assert _parse_image_data_uri("data:image/webp;base64,UklGR") == ("image/webp", "UklGR")
def test_text_from_blocks_marks_image_only_when_requested() -> None:
"""Image markers appear only with emit_image_marker (capability-off path)."""
content = [
{"type": "input_text", "text": "what is this"},
{"type": "input_image", "image_url": "data:image/png;base64,iVBOR", "filename": "p.png"},
]
# Default: image handled as a real block elsewhere → no marker here.
assert QwenExecutor._text_from_blocks(content) == "what is this"
# Capability off: leave a marker so the image isn't silently dropped.
marked = QwenExecutor._text_from_blocks(content, emit_image_marker=True)
assert "what is this" in marked
assert "[attached image: p.png]" in marked
@pytest.mark.asyncio
async def test_resolve_gateway_env_runs_auth_command() -> None:
"""A wired gateway → OPENAI_* env with the token from the auth command."""
executor = QwenExecutor(
model="qwen/qwen3-coder",
gateway_base_url="https://gw.example/v1",
gateway_auth_command="printf '%s' sk-tok-123",
)
env = await executor._resolve_gateway_env()
assert env == {
"OPENAI_BASE_URL": "https://gw.example/v1",
"OPENAI_API_KEY": "sk-tok-123",
"OPENAI_MODEL": "qwen/qwen3-coder",
}
@pytest.mark.asyncio
async def test_resolve_gateway_env_empty_without_config() -> None:
"""No gateway configured → no OPENAI_* overrides (ambient auth path)."""
assert await QwenExecutor(model="m")._resolve_gateway_env() == {}
# base URL without an auth command is also inert.
only_url = QwenExecutor(gateway_base_url="https://gw/v1")
assert await only_url._resolve_gateway_env() == {}
@pytest.mark.asyncio
async def test_resolve_gateway_env_raises_on_command_failure() -> None:
"""A failing auth command surfaces a clear error rather than an empty key."""
executor = QwenExecutor(
gateway_base_url="https://gw/v1",
gateway_auth_command="exit 3",
)
with pytest.raises(RuntimeError, match="auth command failed"):
await executor._resolve_gateway_env()
@pytest.mark.asyncio
async def test_resolve_gateway_env_raises_on_empty_token() -> None:
"""An auth command that prints nothing is treated as a failure."""
executor = QwenExecutor(
gateway_base_url="https://gw/v1",
gateway_auth_command="true", # exits 0, no stdout
)
with pytest.raises(RuntimeError, match="empty token"):
await executor._resolve_gateway_env()
@pytest.mark.asyncio
async def test_resolve_gateway_env_omits_model_when_unset() -> None:
"""Without a model, only base URL + key are exported (no OPENAI_MODEL)."""
executor = QwenExecutor(
gateway_base_url="https://gw/v1",
gateway_auth_command="printf '%s' k",
)
env = await executor._resolve_gateway_env()
assert env == {"OPENAI_BASE_URL": "https://gw/v1", "OPENAI_API_KEY": "k"}
@pytest.mark.asyncio
async def test_ensure_initialized_captures_image_capability() -> None:
"""initialize handshake records promptCapabilities.image on the executor."""
executor = QwenExecutor(model="m")
executor._rpc = AsyncMock( # type: ignore[method-assign]
return_value={"result": {"agentCapabilities": {"promptCapabilities": {"image": True}}}}
)
await executor._ensure_initialized()
assert executor._initialized is True
assert executor._image_supported is True
@pytest.mark.asyncio
async def test_ensure_initialized_image_capability_defaults_false() -> None:
"""Absent promptCapabilities leaves image support off (degrade to marker)."""
executor = QwenExecutor(model="m")
executor._rpc = AsyncMock(return_value={"result": {}}) # type: ignore[method-assign]
await executor._ensure_initialized()
assert executor._initialized is True
assert executor._image_supported is False