406 lines
16 KiB
Python
406 lines
16 KiB
Python
"""Unit tests for the qwen-native tool-approval mirror.
|
|
|
|
Covers everything a live ``qwen`` TUI isn't needed for, with the file + HTTP
|
|
boundaries faked:
|
|
|
|
* **Parser** — pulling a ``can_use_tool`` control request out of a ``--json-file``
|
|
event (tool name / shell-command preview / request id), ignoring non-approval
|
|
events, extracting a ``control_response`` request id, and the elicitation-id
|
|
format.
|
|
* **Reader** — ``_read_new_control_events`` incremental byte-offset tailing,
|
|
partial-line handling, and truncation rewind (the analog of the forwarder's
|
|
``_read_new_events``, but for the control plane).
|
|
* **Mirror supervisor** — ``_run_one_approval`` (park → verdict →
|
|
``confirmation_response``), ``_post_external_elicitation_resolved`` (un-park on
|
|
a TUI-side answer), and ``supervise_qwen_approval_mirror`` (park a new request,
|
|
then release it when its ``control_response`` arrives unanswered).
|
|
|
|
The event shapes are pinned to qwen's dual-output protocol (see
|
|
``docs/QWEN_NATIVE_DESIGN.md`` and qwen's ``dual-output.md``).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from omnigent import qwen_native_permissions as qnp
|
|
from omnigent.qwen_native_bridge import events_file_path, input_file_path
|
|
from omnigent.qwen_native_permissions import (
|
|
parse_can_use_tool,
|
|
qwen_permission_elicitation_id,
|
|
)
|
|
|
|
|
|
def _can_use_tool_ev(
|
|
request_id: str, *, tool_name: str = "run_shell_command", command: str = "ls"
|
|
) -> dict:
|
|
return {
|
|
"type": "control_request",
|
|
"request_id": request_id,
|
|
"request": {
|
|
"subtype": "can_use_tool",
|
|
"tool_name": tool_name,
|
|
"tool_use_id": f"tu_{request_id}",
|
|
"input": {"command": command},
|
|
},
|
|
}
|
|
|
|
|
|
def _control_response_ev(request_id: str, *, allowed: bool = True) -> dict:
|
|
return {
|
|
"type": "control_response",
|
|
"response": {
|
|
"subtype": "success",
|
|
"request_id": request_id,
|
|
"response": {"allowed": allowed},
|
|
},
|
|
}
|
|
|
|
|
|
def _ev_bytes(obj: dict) -> bytes:
|
|
return (json.dumps(obj) + "\n").encode("utf-8")
|
|
|
|
|
|
# ── Parser ───────────────────────────────────────────────────────
|
|
|
|
|
|
def test_parse_can_use_tool_extracts_request_id_name_and_command_preview() -> None:
|
|
req = parse_can_use_tool(_can_use_tool_ev("r1", command="rm -rf build"))
|
|
assert req is not None
|
|
assert req.request_id == "r1"
|
|
assert req.tool_name == "run_shell_command"
|
|
assert req.preview == "rm -rf build"
|
|
assert req.message
|
|
|
|
|
|
def test_parse_can_use_tool_non_shell_input_falls_back_to_json_preview() -> None:
|
|
ev = {
|
|
"type": "control_request",
|
|
"request_id": "r2",
|
|
"request": {"subtype": "can_use_tool", "tool_name": "write_file", "input": {"path": "/x"}},
|
|
}
|
|
req = parse_can_use_tool(ev)
|
|
assert req is not None
|
|
assert req.tool_name == "write_file"
|
|
assert json.loads(req.preview) == {"path": "/x"}
|
|
|
|
|
|
def test_parse_can_use_tool_missing_name_degrades_to_tool() -> None:
|
|
ev = {"type": "control_request", "request_id": "r3", "request": {"subtype": "can_use_tool"}}
|
|
req = parse_can_use_tool(ev)
|
|
assert req is not None
|
|
assert req.tool_name == "tool"
|
|
assert req.preview == "tool"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"ev",
|
|
[
|
|
pytest.param({"type": "assistant", "uuid": "a1"}, id="assistant"),
|
|
pytest.param({"type": "system", "subtype": "session_start"}, id="system"),
|
|
pytest.param(
|
|
{"type": "control_request", "request": {"subtype": "other"}, "request_id": "x"},
|
|
id="other-control-subtype",
|
|
),
|
|
pytest.param(
|
|
{"type": "control_request", "request": {"subtype": "can_use_tool"}},
|
|
id="missing-request-id",
|
|
),
|
|
],
|
|
)
|
|
def test_parse_can_use_tool_returns_none_for_non_approval_events(ev: dict) -> None:
|
|
assert parse_can_use_tool(ev) is None
|
|
|
|
|
|
def test_control_response_request_id_extraction() -> None:
|
|
assert qnp._control_response_request_id(_control_response_ev("r9")) == "r9"
|
|
assert qnp._control_response_request_id({"type": "control_request"}) is None
|
|
assert qnp._control_response_request_id({"type": "control_response", "response": {}}) is None
|
|
|
|
|
|
def test_elicitation_id_is_deterministic_and_session_scoped() -> None:
|
|
eid = qwen_permission_elicitation_id("conv_abc", "r1")
|
|
assert eid == qwen_permission_elicitation_id("conv_abc", "r1")
|
|
assert eid != qwen_permission_elicitation_id("conv_xyz", "r1")
|
|
assert "conv_abc" in eid
|
|
assert eid.startswith("elicit_qwen_")
|
|
|
|
|
|
# ── Reader ───────────────────────────────────────────────────────
|
|
|
|
|
|
def test_read_new_control_events_incremental_and_partial_line(tmp_path: Path) -> None:
|
|
f = tmp_path / "out.ndjson"
|
|
f.write_bytes(_ev_bytes(_can_use_tool_ev("r1")))
|
|
events, off = qnp._read_new_control_events(f, 0)
|
|
assert [e.kind for e in events] == ["request"]
|
|
assert events[0].request_id == "r1"
|
|
|
|
# Append a complete response line plus a trailing partial line; only the
|
|
# complete one is consumed, offset stops before the partial.
|
|
with open(f, "ab") as fh:
|
|
fh.write(_ev_bytes(_control_response_ev("r1")))
|
|
fh.write(b'{"type":"control_request","request_id":"r2"') # no newline
|
|
events2, off2 = qnp._read_new_control_events(f, off)
|
|
assert [(e.kind, e.request_id) for e in events2] == [("response", "r1")]
|
|
assert off2 == off + len(_ev_bytes(_control_response_ev("r1")))
|
|
|
|
|
|
def test_read_new_control_events_rewinds_on_truncation(tmp_path: Path) -> None:
|
|
f = tmp_path / "out.ndjson"
|
|
f.write_bytes(_ev_bytes(_can_use_tool_ev("r1")) * 3)
|
|
_events, off = qnp._read_new_control_events(f, 0)
|
|
assert off > 0
|
|
# Terminal relaunch truncates the file; a stale offset past EOF rewinds to 0.
|
|
f.write_bytes(_ev_bytes(_can_use_tool_ev("r2")))
|
|
events, new_off = qnp._read_new_control_events(f, off)
|
|
assert [e.request_id for e in events] == ["r2"]
|
|
assert new_off == len(_ev_bytes(_can_use_tool_ev("r2")))
|
|
|
|
|
|
def test_read_new_control_events_ignores_transcript_and_malformed(tmp_path: Path) -> None:
|
|
f = tmp_path / "out.ndjson"
|
|
f.write_bytes(
|
|
_ev_bytes({"type": "assistant", "uuid": "a1", "message": {"content": []}})
|
|
+ b"not-json\n"
|
|
+ _ev_bytes(_can_use_tool_ev("r1"))
|
|
)
|
|
events, _off = qnp._read_new_control_events(f, 0)
|
|
assert [(e.kind, e.request_id) for e in events] == [("request", "r1")]
|
|
|
|
|
|
# ── Mirror supervisor ────────────────────────────────────────────
|
|
|
|
|
|
class _QueueClient:
|
|
"""Async httpx-client stub: records POSTs, returns queued responses in order."""
|
|
|
|
def __init__(self, responses: list[httpx.Response]) -> None:
|
|
self.posts: list[tuple[str, dict]] = []
|
|
self._responses = list(responses)
|
|
|
|
async def post(self, url: str, *, json: dict, **_kw: object) -> httpx.Response:
|
|
self.posts.append((url, json))
|
|
return self._responses.pop(0)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("response", "expected_allowed"),
|
|
[
|
|
pytest.param(httpx.Response(200, json={"action": "accept"}), True, id="accept->allow"),
|
|
pytest.param(httpx.Response(200, json={"action": "decline"}), False, id="decline->deny"),
|
|
pytest.param(httpx.Response(200, json={"action": "cancel"}), False, id="cancel->deny"),
|
|
pytest.param(httpx.Response(200), None, id="empty-200->no-write"),
|
|
pytest.param(httpx.Response(400, text="nope"), None, id="rejected->no-write"),
|
|
pytest.param(httpx.Response(200, content=b"not-json"), None, id="non-json->no-write"),
|
|
pytest.param(
|
|
httpx.Response(200, json={"action": "??"}), None, id="unknown-action->no-write"
|
|
),
|
|
],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_run_one_approval_posts_then_writes_confirmation(
|
|
response: httpx.Response,
|
|
expected_allowed: bool | None,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Park a request on the server, then answer qwen with a confirmation line.
|
|
|
|
A ``confirmation_response`` is written ONLY for a concrete accept/decline/
|
|
cancel verdict; an empty 2xx (TUI answered / timeout), a rejection, a
|
|
non-JSON body, or an unknown action writes nothing to the input file.
|
|
"""
|
|
req = parse_can_use_tool(_can_use_tool_ev("r1", command="ls -la"))
|
|
assert req is not None
|
|
client = _QueueClient([response])
|
|
|
|
await qnp._run_one_approval(
|
|
client, # type: ignore[arg-type]
|
|
session_id="conv_1",
|
|
bridge_dir=tmp_path,
|
|
approval=req,
|
|
elicitation_id="elic_1",
|
|
)
|
|
|
|
url, body = client.posts[0]
|
|
assert url == "/v1/sessions/conv_1/hooks/native-permission-request"
|
|
assert body == {
|
|
"elicitation_id": "elic_1",
|
|
"agent": "qwen",
|
|
"policy_name": "qwen_native_permission",
|
|
"operation_type": "run_shell_command",
|
|
"message": req.message,
|
|
"content_preview": "ls -la",
|
|
}
|
|
|
|
in_path = input_file_path(tmp_path)
|
|
if expected_allowed is None:
|
|
assert not in_path.exists() or in_path.read_text() == ""
|
|
else:
|
|
lines = [json.loads(ln) for ln in in_path.read_text().splitlines() if ln.strip()]
|
|
assert lines == [
|
|
{"type": "confirmation_response", "request_id": "r1", "allowed": expected_allowed}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_external_elicitation_resolved_shape() -> None:
|
|
client = _QueueClient([httpx.Response(200)])
|
|
await qnp._post_external_elicitation_resolved(client, "conv_2", "elic_9") # type: ignore[arg-type]
|
|
url, body = client.posts[0]
|
|
assert url == "/v1/sessions/conv_2/events"
|
|
assert body == {"type": "external_elicitation_resolved", "data": {"elicitation_id": "elic_9"}}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_supervise_mirror_parks_then_releases_on_control_response(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Park a new ``can_use_tool``, then release it when its ``control_response``
|
|
arrives while the web card is still parked (the user answered in the TUI).
|
|
"""
|
|
created: list[object] = []
|
|
|
|
class _FakeAsyncClient:
|
|
def __init__(self, **_kw: object) -> None:
|
|
self.posts: list[tuple[str, dict]] = []
|
|
created.append(self)
|
|
|
|
async def __aenter__(self) -> _FakeAsyncClient:
|
|
return self
|
|
|
|
async def __aexit__(self, *_a: object) -> bool:
|
|
return False
|
|
|
|
async def post(self, url: str, *, json: dict, **_kw: object) -> httpx.Response:
|
|
self.posts.append((url, json))
|
|
return httpx.Response(200, request=httpx.Request("POST", url))
|
|
|
|
monkeypatch.setattr(qnp.httpx, "AsyncClient", _FakeAsyncClient)
|
|
|
|
# Hold the park task pending so the release branch is taken (a completed task
|
|
# means the web verdict already landed → no release needed).
|
|
started = asyncio.Event()
|
|
release = asyncio.Event()
|
|
|
|
async def _fake_run_one(_client: object, **_kw: object) -> None:
|
|
started.set()
|
|
await release.wait()
|
|
|
|
monkeypatch.setattr(qnp, "_run_one_approval", _fake_run_one)
|
|
|
|
events_file = events_file_path(tmp_path)
|
|
events_file.write_bytes(b"") # mirror seeds offset at EOF (==0 here)
|
|
|
|
task = asyncio.create_task(
|
|
qnp.supervise_qwen_approval_mirror(
|
|
base_url="http://t",
|
|
headers={},
|
|
session_id="conv_3",
|
|
bridge_dir=tmp_path,
|
|
poll_interval_s=0.001,
|
|
)
|
|
)
|
|
try:
|
|
# Let the mirror seed its read offset at EOF (empty file) first, so the
|
|
# request below is seen as NEW — the real fresh-terminal flow.
|
|
await asyncio.sleep(0.05)
|
|
# First a control_request appears → park task spawns.
|
|
with open(events_file, "ab") as fh:
|
|
fh.write(_ev_bytes(_can_use_tool_ev("r1")))
|
|
await asyncio.wait_for(started.wait(), 2.0)
|
|
# Then its control_response (answered in the TUI) → release the card.
|
|
with open(events_file, "ab") as fh:
|
|
fh.write(_ev_bytes(_control_response_ev("r1")))
|
|
for _ in range(400):
|
|
if created and getattr(created[0], "posts", None):
|
|
break
|
|
await asyncio.sleep(0.005)
|
|
assert created, "supervisor never opened a client"
|
|
url, body = created[0].posts[0] # type: ignore[attr-defined]
|
|
assert url == "/v1/sessions/conv_3/events"
|
|
assert body["type"] == "external_elicitation_resolved"
|
|
assert body["data"]["elicitation_id"] == qwen_permission_elicitation_id("conv_3", "r1")
|
|
finally:
|
|
release.set()
|
|
task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await task
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_supervise_mirror_skips_request_resolved_in_same_poll_batch(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""A request + its control_response in ONE poll batch never parks a card.
|
|
|
|
The decision was made (TUI/auto) within a single poll window, before the
|
|
mirror could park it. Parking now would race its own response — the response
|
|
branch would run against a freshly-created task that hasn't POSTed yet and so
|
|
couldn't release the card, leaving it stuck until the server-side timeout. So
|
|
the mirror must spawn no approval task and post nothing for that request.
|
|
"""
|
|
created: list[object] = []
|
|
|
|
class _FakeAsyncClient:
|
|
def __init__(self, **_kw: object) -> None:
|
|
self.posts: list[tuple[str, dict]] = []
|
|
created.append(self)
|
|
|
|
async def __aenter__(self) -> _FakeAsyncClient:
|
|
return self
|
|
|
|
async def __aexit__(self, *_a: object) -> bool:
|
|
return False
|
|
|
|
async def post(self, url: str, *, json: dict, **_kw: object) -> httpx.Response:
|
|
self.posts.append((url, json))
|
|
return httpx.Response(200, request=httpx.Request("POST", url))
|
|
|
|
monkeypatch.setattr(qnp.httpx, "AsyncClient", _FakeAsyncClient)
|
|
|
|
run_one_calls: list[str] = []
|
|
|
|
async def _fake_run_one(_client: object, *, approval: object, **_kw: object) -> None:
|
|
run_one_calls.append(approval.request_id) # type: ignore[attr-defined]
|
|
|
|
monkeypatch.setattr(qnp, "_run_one_approval", _fake_run_one)
|
|
|
|
events_file = events_file_path(tmp_path)
|
|
events_file.write_bytes(b"") # mirror seeds offset at EOF (==0 here)
|
|
|
|
task = asyncio.create_task(
|
|
qnp.supervise_qwen_approval_mirror(
|
|
base_url="http://t",
|
|
headers={},
|
|
session_id="conv_4",
|
|
bridge_dir=tmp_path,
|
|
poll_interval_s=0.02,
|
|
)
|
|
)
|
|
try:
|
|
# Let the mirror seed its offset at EOF and enter its sleep, then write
|
|
# BOTH events in a single atomic write so the next poll reads them as one
|
|
# batch (the file is empty until this one syscall completes, so a poll
|
|
# can only see neither line or both — never just the request).
|
|
await asyncio.sleep(0.08)
|
|
with open(events_file, "ab") as fh:
|
|
fh.write(_ev_bytes(_can_use_tool_ev("r1")) + _ev_bytes(_control_response_ev("r1")))
|
|
# Give the supervisor several poll cycles to consume the batch.
|
|
await asyncio.sleep(0.2)
|
|
assert run_one_calls == [], "should not park a request already resolved in-batch"
|
|
assert created, "supervisor never opened a client"
|
|
assert created[0].posts == [], "should not post external_elicitation_resolved" # type: ignore[attr-defined]
|
|
finally:
|
|
task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await task
|