Files
omnigent-ai--omnigent/tests/test_kiro_native_permissions.py
2026-07-13 13:12:00 +08:00

491 lines
17 KiB
Python

"""Tests for the Kiro-native ACP permission mirror."""
from __future__ import annotations
import asyncio
import contextlib
import json
from pathlib import Path
import httpx
import pytest
from omnigent import kiro_native_permissions as knp
from omnigent.kiro_native_bridge import acp_record_path
from omnigent.kiro_native_permissions import (
kiro_permission_elicitation_id,
parse_permission_request,
)
def _permission_msg(
request_id: str = "req-1",
*,
allow_option_id: str = "allow_once",
reject_option_id: str = "reject_once",
) -> dict:
return {
"jsonrpc": "2.0",
"id": request_id,
"method": "session/request_permission",
"params": {
"sessionId": "kiro-session",
"toolCall": {"toolCallId": f"tool-{request_id}", "title": "Running: pwd"},
"options": [
{"optionId": allow_option_id, "name": "Yes", "kind": "allow_once"},
{"optionId": "allow_always", "name": "Always", "kind": "allow_always"},
{"optionId": reject_option_id, "name": "No", "kind": "reject_once"},
],
"_meta": {"trustOptions": True},
},
}
def _permission_result_msg(request_id: str = "req-1", option_id: str = "allow_once") -> dict:
return {
"jsonrpc": "2.0",
"id": request_id,
"result": {"outcome": {"outcome": "selected", "optionId": option_id}},
}
def _record(message: dict, *, direction: str = "out") -> dict:
return {"ts": "2026-06-25T00:00:00Z", "dir": direction, "msg": json.dumps(message)}
def _record_bytes(message: dict, *, direction: str = "out") -> bytes:
return (json.dumps(_record(message, direction=direction)) + "\n").encode("utf-8")
def test_parse_permission_request_extracts_one_time_options() -> None:
req = parse_permission_request(_permission_msg())
assert req is not None
assert req.request_id == "req-1"
assert req.tool_call_id == "tool-req-1"
assert req.title == "Running: pwd"
assert req.accept_option_id == "allow_once"
assert req.decline_option_id == "reject_once"
assert req.preview == "Running: pwd"
def test_parse_permission_request_preserves_option_ids_by_kind() -> None:
req = parse_permission_request(
_permission_msg("req-1", allow_option_id="yes-1", reject_option_id="no-1")
)
assert req is not None
assert req.accept_option_id == "yes-1"
assert req.decline_option_id == "no-1"
@pytest.mark.parametrize(
"message",
[
pytest.param({"method": "session/prompt"}, id="not-permission"),
pytest.param({**_permission_msg(), "id": ""}, id="missing-id"),
pytest.param(
{
**_permission_msg(),
"params": {**_permission_msg()["params"], "toolCall": {"title": "Running: pwd"}},
},
id="missing-tool-call-id",
),
pytest.param(
{
**_permission_msg(),
"params": {
**_permission_msg()["params"],
"toolCall": {"toolCallId": "tool-1"},
},
},
id="missing-title",
),
pytest.param(
{**_permission_msg(), "params": {**_permission_msg()["params"], "options": []}},
id="missing-one-time-options",
),
],
)
def test_parse_permission_request_returns_none_for_unsupported_shapes(message: dict) -> None:
assert parse_permission_request(message) is None
def test_permission_result_request_id_extraction() -> None:
assert knp._permission_result_request_id(_permission_result_msg("req-9")) == "req-9"
assert knp._permission_result_request_id({"id": "req-9", "result": {}}) is None
assert knp._permission_result_request_id({"id": 1, "result": {"outcome": {}}}) is None
def test_elicitation_id_is_deterministic_and_session_scoped() -> None:
eid = kiro_permission_elicitation_id("conv_abc", "req-1")
assert eid == kiro_permission_elicitation_id("conv_abc", "req-1")
assert eid != kiro_permission_elicitation_id("conv_other", "req-1")
assert eid.startswith("elicit_kiro_conv_abc_")
def test_read_new_permission_events_incremental_and_partial_line(tmp_path: Path) -> None:
record_file = tmp_path / "kiro_acp_record.jsonl"
record_file.write_bytes(_record_bytes(_permission_msg("req-1")))
events, offset = knp._read_new_permission_events(record_file, 0)
assert [(event.kind, event.request_id) for event in events] == [("request", "req-1")]
with record_file.open("ab") as handle:
handle.write(_record_bytes(_permission_result_msg("req-1"), direction="in"))
handle.write(b'{"dir":"out","msg":"')
events2, offset2 = knp._read_new_permission_events(record_file, offset)
assert [(event.kind, event.request_id) for event in events2] == [("response", "req-1")]
assert offset2 == offset + len(_record_bytes(_permission_result_msg("req-1"), direction="in"))
def test_read_new_permission_events_ignores_malformed_and_non_permission(tmp_path: Path) -> None:
record_file = tmp_path / "kiro_acp_record.jsonl"
record_file.write_bytes(
b"not-json\n"
+ _record_bytes({"jsonrpc": "2.0", "method": "session/prompt"})
+ _record_bytes(_permission_msg("req-1"))
)
events, _offset = knp._read_new_permission_events(record_file, 0)
assert [(event.kind, event.request_id) for event in events] == [("request", "req-1")]
class _QueueClient:
"""Async httpx-client stub: records POSTs, returns queued responses."""
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_action"),
[
pytest.param(httpx.Response(200, json={"action": "accept"}), "accept", id="accept"),
pytest.param(httpx.Response(200, json={"action": "decline"}), "decline", id="decline"),
pytest.param(httpx.Response(200, json={"action": "cancel"}), "cancel", id="cancel"),
pytest.param(httpx.Response(200), None, id="empty-200"),
pytest.param(httpx.Response(400, text="nope"), None, id="rejected"),
pytest.param(httpx.Response(200, content=b"not-json"), None, id="non-json"),
],
)
@pytest.mark.asyncio
async def test_run_one_permission_posts_then_delivers_verdict(
response: httpx.Response,
expected_action: str | None,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
delivered: list[tuple[Path, str]] = []
def _fake_send(bridge_dir: Path, *, action: str, expected_title: str | None = None) -> None:
assert expected_title == "Running: pwd"
delivered.append((bridge_dir, action))
monkeypatch.setattr(knp, "send_kiro_permission_verdict", _fake_send)
req = parse_permission_request(_permission_msg("req-1"))
assert req is not None
client = _QueueClient([response])
await knp._run_one_permission(
client, # type: ignore[arg-type]
session_id="conv_1",
bridge_dir=tmp_path,
permission=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": "Kiro",
"policy_name": "kiro_native_permission",
"operation_type": "tool",
"message": "Kiro wants approval for Running: pwd",
"content_preview": "Running: pwd",
}
if expected_action is None:
assert delivered == []
else:
assert delivered == [(tmp_path, expected_action)]
@pytest.mark.asyncio
async def test_post_external_elicitation_resolved_shape() -> None:
client = _QueueClient([httpx.Response(200)])
await knp._post_external_elicitation_resolved(client, "conv_2", "elic_9") # type: ignore[arg-type]
assert client.posts == [
(
"/v1/sessions/conv_2/events",
{"type": "external_elicitation_resolved", "data": {"elicitation_id": "elic_9"}},
)
]
@pytest.mark.asyncio
async def test_supervise_mirror_parks_then_releases_on_permission_response(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
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, *_args: 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(knp.httpx, "AsyncClient", _FakeAsyncClient)
started = asyncio.Event()
release = asyncio.Event()
cancelled = asyncio.Event()
async def _fake_run_one(_client: object, **_kw: object) -> None:
started.set()
try:
await release.wait()
except asyncio.CancelledError:
cancelled.set()
raise
monkeypatch.setattr(knp, "_run_one_permission", _fake_run_one)
record_file = acp_record_path(tmp_path)
record_file.write_bytes(b"")
task = asyncio.create_task(
knp.supervise_kiro_permission_mirror(
base_url="http://t",
headers={},
session_id="conv_3",
bridge_dir=tmp_path,
poll_interval_s=0.001,
)
)
try:
await asyncio.sleep(0.05)
with record_file.open("ab") as handle:
handle.write(_record_bytes(_permission_msg("req-1")))
await asyncio.wait_for(started.wait(), 2.0)
with record_file.open("ab") as handle:
handle.write(_record_bytes(_permission_result_msg("req-1"), direction="in"))
for _ in range(400):
if created and getattr(created[0], "posts", None):
break
await asyncio.sleep(0.005)
assert created
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"] == kiro_permission_elicitation_id("conv_3", "req-1")
await asyncio.wait_for(cancelled.wait(), 2.0)
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:
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, *_args: 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(knp.httpx, "AsyncClient", _FakeAsyncClient)
run_one_calls: list[str] = []
async def _fake_run_one(_client: object, *, permission: object, **_kw: object) -> None:
run_one_calls.append(permission.request_id) # type: ignore[attr-defined]
monkeypatch.setattr(knp, "_run_one_permission", _fake_run_one)
record_file = acp_record_path(tmp_path)
record_file.write_bytes(b"")
task = asyncio.create_task(
knp.supervise_kiro_permission_mirror(
base_url="http://t",
headers={},
session_id="conv_4",
bridge_dir=tmp_path,
poll_interval_s=0.02,
)
)
try:
await asyncio.sleep(0.08)
with record_file.open("ab") as handle:
handle.write(
_record_bytes(_permission_msg("req-1"))
+ _record_bytes(_permission_result_msg("req-1"), direction="in")
)
await asyncio.sleep(0.2)
assert run_one_calls == []
assert created
assert created[0].posts == [] # type: ignore[attr-defined]
finally:
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
@pytest.mark.asyncio
async def test_supervise_mirror_skips_additional_request_while_one_is_pending(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
class _FakeAsyncClient:
def __init__(self, **_kw: object) -> None:
pass
async def __aenter__(self) -> _FakeAsyncClient:
return self
async def __aexit__(self, *_args: object) -> bool:
return False
async def post(self, url: str, *, json: dict, **_kw: object) -> httpx.Response:
return httpx.Response(200, request=httpx.Request("POST", url))
monkeypatch.setattr(knp.httpx, "AsyncClient", _FakeAsyncClient)
started = asyncio.Event()
release = asyncio.Event()
run_one_calls: list[str] = []
async def _fake_run_one(_client: object, *, permission: object, **_kw: object) -> None:
run_one_calls.append(permission.request_id) # type: ignore[attr-defined]
started.set()
await release.wait()
monkeypatch.setattr(knp, "_run_one_permission", _fake_run_one)
record_file = acp_record_path(tmp_path)
record_file.write_bytes(b"")
task = asyncio.create_task(
knp.supervise_kiro_permission_mirror(
base_url="http://t",
headers={},
session_id="conv_5",
bridge_dir=tmp_path,
poll_interval_s=0.001,
)
)
try:
await asyncio.sleep(0.05)
with record_file.open("ab") as handle:
handle.write(_record_bytes(_permission_msg("req-1")))
await asyncio.wait_for(started.wait(), 2.0)
with record_file.open("ab") as handle:
handle.write(_record_bytes(_permission_msg("req-2")))
await asyncio.sleep(0.05)
assert run_one_calls == ["req-1"]
finally:
release.set()
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
@pytest.mark.asyncio
async def test_supervise_mirror_reaps_finished_task_and_mirrors_next_request(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""A finished delivery task frees the slot so a later prompt is still mirrored.
Without reaping done tasks, a completed (or failed) web-delivery would keep
the single-prompt slot occupied forever and silently block every later
prompt from the web mirror.
"""
class _FakeAsyncClient:
def __init__(self, **_kw: object) -> None:
pass
async def __aenter__(self) -> _FakeAsyncClient:
return self
async def __aexit__(self, *_args: object) -> bool:
return False
async def post(self, url: str, *, json: dict, **_kw: object) -> httpx.Response:
return httpx.Response(200, request=httpx.Request("POST", url))
monkeypatch.setattr(knp.httpx, "AsyncClient", _FakeAsyncClient)
run_one_calls: list[str] = []
async def _fake_run_one(_client: object, *, permission: object, **_kw: object) -> None:
# Returns immediately, so the parked task finishes without a recorder
# response event ever arriving (mimics a delivered/failed verdict).
run_one_calls.append(permission.request_id) # type: ignore[attr-defined]
monkeypatch.setattr(knp, "_run_one_permission", _fake_run_one)
record_file = acp_record_path(tmp_path)
record_file.write_bytes(b"")
task = asyncio.create_task(
knp.supervise_kiro_permission_mirror(
base_url="http://t",
headers={},
session_id="conv_6",
bridge_dir=tmp_path,
poll_interval_s=0.001,
)
)
try:
await asyncio.sleep(0.05)
with record_file.open("ab") as handle:
handle.write(_record_bytes(_permission_msg("req-1")))
for _ in range(400):
if run_one_calls == ["req-1"]:
break
await asyncio.sleep(0.005)
assert run_one_calls == ["req-1"]
# No response event for req-1 ever arrives; the reaper must still free
# the slot once its task is done so req-2 gets mirrored.
with record_file.open("ab") as handle:
handle.write(_record_bytes(_permission_msg("req-2")))
for _ in range(400):
if run_one_calls == ["req-1", "req-2"]:
break
await asyncio.sleep(0.005)
assert run_one_calls == ["req-1", "req-2"]
finally:
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task