1807 lines
67 KiB
Python
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
|