Files
omnigent-ai--omnigent/tests/test_kiro_native_permissions.py
Michael Gardner d80a288a6f feat(kiro-native): surface TUI approvals in Chat (#1293)
* feat(kiro-native): surface TUI approvals in Chat

Signed-off-by: Michael Gardner <gardnmi@gmail.com>

* chore: remove Kiro elicitation plan from PR

Signed-off-by: Michael Gardner <gardnmi@gmail.com>

* fix(kiro-native): harden permission mirror per review

Address review findings on the Kiro permission mirror:

- Reap finished web-delivery tasks from the pending map each poll, so a
  completed *or failed* keystroke delivery frees the single-prompt slot.
  Previously a failed delivery left the slot occupied forever, silently
  blocking every later prompt from reaching the web mirror.
- Re-validate the visible prompt's focus and title for `accept` after the
  pre-Enter settle delay (symmetric with the decline path), so a focus or
  title drift during the settle window fails closed instead of pressing
  Enter on the wrong row.
- Drop the redundant `event.request_id in pending` skip clause (subsumed by
  the `or pending` guard).
- Correct docs/kiro-native-elicitation.md: cancelling a parked task only
  reliably aborts a verdict still waiting on the web user; a mid-delivery
  keystroke worker cannot be interrupted, and the per-keypress focus/title
  re-validation is what prevents a stray verdict from landing on a later
  prompt. Also document the one-at-a-time / Terminal-only fallback.

Adds regression tests for the reaping behavior and the accept re-validation.

Co-authored-by: Isaac

* fix(test): use a benign completion token in kiro elicitation e2e

The approve-path e2e asked Kiro to echo a `kiro-approval-<hex>` token right
after a tool-approval prompt. A safety-conscious model reads "reply with this
exact token" in an approval context as an attempt to emit a spoofed
tool-approval signal and declines, so the turn-complete assertion failed even
though the card -> approve -> Kiro-continues loop succeeded. Use a neutral
`kiro-pwd-done-<hex>` token and plain framing, matching the render-parity
sibling's benign-token pattern.

Co-authored-by: Isaac

* fix(kiro-native): truncate the title in the elicitation message

content_preview was already capped at _PREVIEW_MAX but the card message
interpolated the full untruncated title, so untrusted Kiro-derived text could
reach the card unbounded. Reuse the truncated preview for both, matching the
doc's untrusted-input handling.

Co-authored-by: Isaac

* fix(test): prove kiro approval continuation structurally, not via token echo

Renaming the completion token was not enough: a safety-conscious model refuses
the whole pattern of "after the approved command, output this exact token,"
reading it as an attempt to forge an approval signal, and runs the command but
declines to emit the token. Drop the token entirely and assert continuation
structurally instead -- after web approve, the gate releases, an assistant
reply renders, and the turn finishes (no lingering working indicator). This no
longer depends on model compliance or a machine-specific command output.

Co-authored-by: Isaac

* docs(kiro-native): document the single-slot reaper in race handling

The race-handling section described the one-at-a-time slot but not the
mechanism that frees it. Note that the slot is released when the delivery
task finishes (delivered, failed checks, or timed out), not only on a
recorder response, so a stuck verdict cannot wedge the slot for the session.

Co-authored-by: Isaac

---------

Signed-off-by: Michael Gardner <gardnmi@gmail.com>
Co-authored-by: Dhruv Gupta <dhruv.gupta@databricks.com>
Co-authored-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-06-29 13:49:21 +00: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