Files
omnigent-ai--omnigent/tests/test_cursor_native_permissions.py
samarmstrong 08056475b0 fix(cursor-native): auto-accept lingering tool gates under --yolo (#2338)
* fix(cursor-native): auto-accept lingering tool gates under --yolo

cursor-agent's Run Everything mode still sometimes leaves pendingToolCall
markers long enough for Omnigent to mirror ApprovalCards and stall a
piloted parent. When the session launched with --yolo/--force/-f, accept
those tool gates in-pane instead of parking a web card; AskQuestion still
surfaces as deliberate human input.

Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: Sam Armstrong <armstrongflg@gmail.com>

* fix(cursor-native): satisfy ruff format and PIE810 on yolo args

Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: Sam Armstrong <armstrongflg@gmail.com>

* fix(cursor-native): make yolo auto-accept bounded and fail-closed

Auto-answering a tool-approval gate is a safety boundary, so the accept path
now refuses to act on anything it cannot confirm, and always has a way out.

The accept was previously a blind keystroke loop: it never checked that a
prompt was on screen, recorded a send to a dead pane as a success, and had no
attempt cap or fallback. A gate that `y` does not clear therefore degraded from
a visible stall into a literal `y` typed into cursor's composer every two
seconds for the life of the session, with no card ever surfaced.

The accept key now goes out only while `capture_cursor_pane` shows cursor's
parenthesised accept hint, at most three times, and at most once per poll pass
(cursor renders one prompt at a time). A dead pane, a send tmux rejects, or a
gate still pending after the budget all fall back to the same ApprovalCard the
non-yolo path shows, so the worst case is the visible stall we have today.
Because a call accepted this way is never seen by a human, the INFO line now
carries an argument preview: it is the only record Omnigent approved the call.

`cursor_launch_args_enable_yolo` was failing open in the same spirit —
`--yolo=false` and `--force=false` both read as enabled, because only the
presence of the `=` form was checked. Explicit off-values are now honoured, and
a bare `--` ends the flag scan so a `-f` in the prompt text that follows is
text rather than a request to bypass approvals.

Tests cover the bounded retry, the fallback to a card, an idle pane, a dead
pane, an undelivered keystroke, an explicit non-yolo session, and the
off-value / end-of-flags argv cases. The design doc gains a section on the
fail-closed contract and drops its claim that Omnigent never sends a verdict of
its own initiative; its stale `Code:` pointer at the runner wiring is refreshed
to where that wiring now lives.

Signed-off-by: SabhyaC26 <sabhyachhabria@gmail.com>

* fix(cursor-native): re-apply yolo wiring where auto-create now lives

`_auto_create_cursor_terminal` moved out of `omnigent/runner/app.py` into
`omnigent/runner/native/orchestration.py`, which left `app.py` a re-export
shell and this branch's wiring hunk applying to code that no longer runs.
Derive `auto_accept_approvals` from `launch_config.terminal_launch_args` at the
live call site instead.

This kwarg is the only thing that turns the in-pane auto-accept on, and it is
one line inside a large function, so a future move can drop it and leave the
feature inert with the whole suite green. Pin it: the auto-create harness now
captures the elicitation supervisor's kwargs, and a parametrized test asserts
the derived stance for `--yolo`, `--force`, `-f`, `--yolo=false`,
`--auto-review`, and no args. Deleting the kwarg fails all six.

Signed-off-by: SabhyaC26 <sabhyachhabria@gmail.com>

---------

Signed-off-by: Sam Armstrong <armstrongflg@gmail.com>
Signed-off-by: SabhyaC26 <sabhyachhabria@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: SabhyaC26 <sabhyachhabria@gmail.com>
2026-07-27 16:48:10 -07:00

1012 lines
38 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Unit tests for cursor-native elicitation surfacing.
Covers everything a live cursor-agent isn't needed for, with the store + tmux +
HTTP boundaries faked:
* **Transcript detection** — reading pending tool calls out of the chat
``store.db`` (incl. binary checkpoint frames), suppressing resolved/auto-run
calls, and the stable elicitation-id format.
* **Supervisor** — surfacing a settled pending call, the debounce that drops
auto-approved calls, the TUI-resolved release, and the yolo auto-accept path
that sends ``y`` without parking a web card — including every way that path
refuses to type (no gate on screen, dead pane, undelivered keystroke, retry
budget spent) and falls back to the ordinary card.
* **Verdict delivery** — ``_run_one_approval`` (park → verdict → keystroke,
incl. the reject → reason-prompt → Enter two-step) and ``_run_one_question``
(AskQuestion form → picker keystrokes).
* **Bridge helpers** — ``capture_cursor_pane`` / ``send_cursor_pane_keys`` with
the tmux primitives monkeypatched.
The *live* tmux + cursor-agent path (real detect → POST → keystroke end-to-end)
is exercised by ``tests/e2e/test_cursor_native_cli_e2e.py``.
"""
from __future__ import annotations
import asyncio
import contextlib
import json as _json
import sqlite3 as _sqlite3
from collections.abc import Callable
from pathlib import Path
import httpx
import pytest
from omnigent import cursor_native_bridge as cnb
from omnigent import cursor_native_permissions as cnp
from omnigent.cursor_native_permissions import (
CursorApprovalPrompt,
CursorPendingToolCall,
cursor_tool_call_elicitation_id,
read_cursor_pending_tool_calls,
)
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_keys"),
[
pytest.param(httpx.Response(200, json={"action": "accept"}), ["y"], id="accept->y"),
# Decline/cancel: the decline key opens cursor's "Reason for rejection"
# sub-prompt, so a follow-up Enter submits an empty reason to complete it.
pytest.param(
httpx.Response(200, json={"action": "decline"}), ["Escape", "Enter"], id="decline->esc"
),
pytest.param(
httpx.Response(200, json={"action": "cancel"}), ["Escape", "Enter"], id="cancel->esc"
),
pytest.param(httpx.Response(200), [], id="empty-200->no-key"),
pytest.param(httpx.Response(400, text="nope"), [], id="rejected->no-key"),
pytest.param(httpx.Response(200, content=b"not-json"), [], id="non-json->no-key"),
pytest.param(httpx.Response(200, json={"action": "??"}), [], id="unknown-action->no-key"),
],
)
@pytest.mark.asyncio
async def test_run_one_approval_posts_then_sends_verdict_keystroke(
response: httpx.Response,
expected_keys: list[str],
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""Park a prompt on the server, then drive the TUI with the verdict key(s).
The hook POST always carries the renderable fields; a keystroke is sent ONLY
for a concrete accept (``accept_key``) or decline/cancel (``decline_key`` +
``Enter`` to submit the empty rejection reason) verdict — an empty 2xx
(answered in the TUI / timeout), a rejection, a non-JSON body, or an unknown
action sends nothing.
"""
prompt = CursorApprovalPrompt(
operation_type="shell",
message="Cursor wants to run Shell",
preview="echo omnigent_probe > out.txt",
accept_key="y",
decline_key="Escape",
)
sent: list[tuple[Path, tuple[str, ...]]] = []
monkeypatch.setattr(cnp, "send_cursor_pane_keys", lambda d, *keys: sent.append((d, keys)))
client = _QueueClient([response])
await cnp._run_one_approval(
client, # type: ignore[arg-type]
session_id="conv_1",
bridge_dir=tmp_path,
prompt=prompt,
elicitation_id="elic_1",
)
url, body = client.posts[0]
assert url == "/v1/sessions/conv_1/hooks/cursor-permission-request"
assert body == {
"elicitation_id": "elic_1",
"operation_type": "shell",
"message": prompt.message,
"content_preview": prompt.preview,
}
# Keys are sent one per call (see _send_cursor_keys), so each is its own tuple.
assert sent == [(tmp_path, (key,)) for key in expected_keys]
@pytest.mark.asyncio
async def test_post_external_elicitation_resolved_shape() -> None:
"""The un-park POST carries the resolved-event type + elicitation id."""
client = _QueueClient([httpx.Response(200)])
await cnp._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"},
}
def test_capture_cursor_pane_returns_pane_or_none(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""Pane text when the TUI is live; ``None`` when absent or the pane is dead."""
monkeypatch.setattr(cnb, "read_tmux_info", lambda _d: {"socket_path": "s", "tmux_target": "t"})
monkeypatch.setattr(cnb, "_session_alive", lambda _s, _t: True)
monkeypatch.setattr(cnb, "_capture_pane", lambda _s, _t: "PANE-TEXT")
assert cnb.capture_cursor_pane(tmp_path) == "PANE-TEXT"
monkeypatch.setattr(cnb, "_session_alive", lambda _s, _t: False)
assert cnb.capture_cursor_pane(tmp_path) is None # dead pane
monkeypatch.setattr(cnb, "read_tmux_info", lambda _d: None)
assert cnb.capture_cursor_pane(tmp_path) is None # no tmux target advertised
def test_send_cursor_pane_keys_invokes_tmux_send_keys(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""Each key is forwarded to ``tmux send-keys -t <target>`` on the pane socket."""
calls: list[tuple[str, tuple[str, ...]]] = []
monkeypatch.setattr(
cnb, "read_tmux_info", lambda _d: {"socket_path": "sock", "tmux_target": "main"}
)
monkeypatch.setattr(cnb, "_run_tmux", lambda sp, *a: calls.append((sp, a)))
cnb.send_cursor_pane_keys(tmp_path, "y")
assert calls == [("sock", ("send-keys", "-t", "main", "y"))]
cnb.send_cursor_pane_keys(tmp_path, "Escape")
assert calls[-1] == ("sock", ("send-keys", "-t", "main", "Escape"))
def test_send_cursor_pane_keys_raises_without_target(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""A missing tmux target is a hard error (the verdict can't be delivered)."""
monkeypatch.setattr(cnb, "read_tmux_info", lambda _d: None)
with pytest.raises(RuntimeError):
cnb.send_cursor_pane_keys(tmp_path, "y")
# ── Transcript-based detector ────────────────────────────────────────────────
#
# These cover the chat-store detection path that replaced pane scraping as the
# primary signal. A pending (approval-gated) tool call is recorded as an
# assistant ``tool-call`` content part carrying
# ``providerOptions.cursor.pendingToolCallStartedAtMs``, embedded inside a
# binary protobuf checkpoint frame; it is "answered" when a ``tool-result`` with
# the same ``toolCallId`` is appended. The fixtures below mirror the real
# store-blob shapes verified against cursor-agent 2026.06.24.
def _write_store(path: Path, blobs: list[bytes]) -> None:
"""Create a minimal cursor-shaped ``store.db`` with the given raw blobs."""
con = _sqlite3.connect(str(path))
try:
con.execute("CREATE TABLE blobs (id TEXT, data BLOB)")
con.executemany(
"INSERT INTO blobs (id, data) VALUES (?, ?)",
[(f"blob{i}", data) for i, data in enumerate(blobs)],
)
con.commit()
finally:
con.close()
def _framed(obj: dict) -> bytes:
"""Wrap a JSON message in fake binary protobuf noise, as cursor checkpoints do.
The real pending tool-call blob is a protobuf frame with the JSON embedded
and arbitrary binary (including stray ``{`` / ``"`` bytes) before and after
it — the case the scanner must survive without aborting the row.
"""
prefix = b"\n \x16\xa0\x815\x13b\xc6mt2\x90{ noise \xff\x00" # incl. a stray "{"
suffix = b"*\x8e\x02\x08\xff\x01 trailing \x00\xfe"
return prefix + _json.dumps(obj).encode("utf-8") + suffix
def _pending_tool_call_obj(tool_call_id: str, tool_name: str, args: dict) -> dict:
return {
"id": "1",
"role": "assistant",
"content": [
{"type": "tool-call", "toolCallId": tool_call_id, "toolName": tool_name, "args": args}
],
"providerOptions": {"cursor": {"pendingToolCallStartedAtMs": 1782373529662}},
}
def _tool_result_obj(tool_call_id: str) -> dict:
return {
"role": "tool",
"content": [{"type": "tool-result", "toolCallId": tool_call_id, "result": "ok"}],
}
def test_iter_embedded_json_recovers_from_enclosing_garbage() -> None:
"""A stray opener whose braces balance AROUND the real object must not hide it.
Large cursor checkpoint frames contain binary that can form a ``{ … }`` span
enclosing a real message object while itself being invalid JSON. The scanner
must keep going (advance by one) and still extract the inner object — not
jump past the whole failed span (which dropped genuinely-pending tool calls,
e.g. MCP, in big frames).
"""
inner = _json.dumps(_pending_tool_call_obj("call_mcp\nfc", "omnigent-list_comments", {"x": 1}))
# Leading "{"k": … <inner> … bad}" balances at the trailing brace but fails
# to parse; the genuine object is nested inside it.
raw = b'{"k": ' + inner.encode("utf-8") + b" trailing-bad}"
objs = cnp._iter_embedded_json_objects(raw)
names = [
p.get("toolName")
for o in objs
for p in (o.get("content") or [])
if isinstance(p, dict) and p.get("type") == "tool-call"
]
assert "omnigent-list_comments" in names
def test_read_pending_detects_framed_gated_tool_call(tmp_path: Path) -> None:
"""A pending tool-call embedded in a binary frame is detected with its args.
This is the exact failure the pane parser missed: a file-deletion gate whose
accept verb ("Delete") is outside the pane regex's allowlist.
"""
store = tmp_path / "store.db"
_write_store(
store,
[
b'{"role":"user","content":"<user_query>delete it</user_query>"}',
_framed(_pending_tool_call_obj("call_abc\nfc_1", "Delete", {"path": "/x/hello.txt"})),
],
)
calls = read_cursor_pending_tool_calls(store)
assert len(calls) == 1
assert calls[0] == CursorPendingToolCall(
tool_call_id="call_abc\nfc_1", tool_name="Delete", args={"path": "/x/hello.txt"}
)
def test_read_pending_suppresses_resolved_call(tmp_path: Path) -> None:
"""A pending call whose tool-result has landed is no longer active."""
store = tmp_path / "store.db"
_write_store(
store,
[
_framed(_pending_tool_call_obj("call_done\nfc_2", "Read", {"path": "/x/a"})),
_json.dumps(_tool_result_obj("call_done\nfc_2")).encode("utf-8"),
],
)
assert read_cursor_pending_tool_calls(store) == []
def test_read_pending_excludes_committed_call(tmp_path: Path) -> None:
"""A marker'd call that ALSO appears committed (no-marker tool-call) is excluded.
This is the auto-approve case: cursor stamps the pending marker while deciding,
then finalizes the call to run — writing the same tool-call WITHOUT the marker.
The committed (no-marker) appearance means cursor is no longer blocked on the
human, so it must not surface a card even before the tool-result lands.
"""
store = tmp_path / "store.db"
committed = {
"role": "assistant",
"content": [
{
"type": "tool-call",
"toolCallId": "call_w\nfc",
"toolName": "Write",
"args": {"path": "/x"},
}
],
"providerOptions": {"cursor": {"modelProviderMessageId": "m1"}},
}
_write_store(
store,
[
_framed(_pending_tool_call_obj("call_w\nfc", "Write", {"path": "/x"})),
_json.dumps(committed).encode("utf-8"),
],
)
assert read_cursor_pending_tool_calls(store) == []
def test_read_pending_ignores_autorun_call_without_marker(tmp_path: Path) -> None:
"""A clean-JSON tool-call lacking the pending marker (auto-ran) is ignored."""
store = tmp_path / "store.db"
autorun = {
"role": "assistant",
"content": [
{"type": "tool-call", "toolCallId": "call_auto", "toolName": "Read", "args": {}}
],
"providerOptions": {"cursor": {"modelProviderMessageId": "m1"}},
}
_write_store(store, [_json.dumps(autorun).encode("utf-8")])
assert read_cursor_pending_tool_calls(store) == []
def test_read_pending_detects_multiple_distinct_gated_calls(tmp_path: Path) -> None:
"""Two distinct pending calls are both surfaced; a resolved one is dropped."""
store = tmp_path / "store.db"
_write_store(
store,
[
_framed(_pending_tool_call_obj("call_1\nfc", "Delete", {"path": "/a"})),
_framed(_pending_tool_call_obj("call_2\nfc", "Write", {"path": "/b"})),
_framed(_pending_tool_call_obj("call_3\nfc", "Read", {"path": "/c"})),
_json.dumps(_tool_result_obj("call_3\nfc")).encode("utf-8"),
],
)
names = sorted(c.tool_name for c in read_cursor_pending_tool_calls(store))
assert names == ["Delete", "Write"]
def test_tool_call_elicitation_id_is_stable_and_scoped(tmp_path: Path) -> None:
"""The id is deterministic per (session, toolCallId) and embeds the session."""
a = cursor_tool_call_elicitation_id("conv_x", "call_1\nfc")
b = cursor_tool_call_elicitation_id("conv_x", "call_1\nfc")
c = cursor_tool_call_elicitation_id("conv_y", "call_1\nfc")
assert a == b and a != c
assert a.startswith("elicit_cursor_conv_x_")
async def test_supervise_transcript_parks_new_call_then_releases_on_resolve(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""detect pending → POST hook; on resolve (call vanishes) → POST resolved."""
posts: list[tuple[str, dict]] = []
pending_now = [
CursorPendingToolCall(tool_call_id="call_z\nfc", tool_name="Delete", args={"path": "/x"})
]
monkeypatch.setattr(cnp, "_discover_store", lambda *_a, **_k: tmp_path / "store.db")
(tmp_path / "store.db").write_bytes(b"") # exists() check
monkeypatch.setattr(cnp, "read_cursor_pending_tool_calls", lambda _s: list(pending_now))
# Keystroke + park boundaries faked.
monkeypatch.setattr(cnp, "send_cursor_pane_keys", lambda *_a, **_k: None)
class _Resp:
status_code = 200
content = b""
def json(self) -> dict:
return {}
release = asyncio.Event()
class _Client:
async def post(self, url: str, json: dict | None = None, **_k):
posts.append((url, json or {}))
if "hooks/cursor-permission-request" in url:
# Simulate a parked hook (no web verdict yet): stay open until
# released, so the call is still "active" when it vanishes from
# the store — exercising the TUI-answered release path.
await release.wait()
return _Resp()
monkeypatch.setattr(cnp.httpx, "AsyncClient", lambda **_k: _FakeAsyncCM(_Client()))
task = asyncio.create_task(
cnp.supervise_cursor_transcript_elicitations(
base_url="http://x",
headers={},
session_id="conv_z",
bridge_dir=tmp_path,
workspace="/ws",
launch_epoch_ms=0,
poll_interval_s=0.01,
settle_s=0.0, # surface immediately; debounce covered separately
)
)
# Let it detect + park the pending call.
for _ in range(50):
await asyncio.sleep(0.01)
if any("hooks/cursor-permission-request" in u for u, _ in posts):
break
# Now the call disappears (answered in TUI) → expect a resolved POST.
pending_now.clear()
for _ in range(50):
await asyncio.sleep(0.01)
if any(j.get("type") == "external_elicitation_resolved" for _, j in posts):
break
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
assert any("hooks/cursor-permission-request" in u for u, _ in posts)
assert any(j.get("type") == "external_elicitation_resolved" for _, j in posts)
async def test_supervise_transcript_debounces_autoapproved_call(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""A call that resolves within the settle window surfaces NO card at all.
This is the auto-approve case: cursor stamps the pending marker while it
decides, then auto-approves and executes before a human is ever asked. With
a settle window, the detector must neither park a hook nor post a resolved
event — otherwise the web UI flashes a card that flips to "resolved
elsewhere" for a call no human saw.
"""
posts: list[tuple[str, dict]] = []
pending_now = [
CursorPendingToolCall(tool_call_id="call_auto\nfc", tool_name="Write", args={"path": "/x"})
]
monkeypatch.setattr(cnp, "_discover_store", lambda *_a, **_k: tmp_path / "store.db")
(tmp_path / "store.db").write_bytes(b"")
monkeypatch.setattr(cnp, "read_cursor_pending_tool_calls", lambda _s: list(pending_now))
monkeypatch.setattr(cnp, "send_cursor_pane_keys", lambda *_a, **_k: None)
class _Resp:
status_code = 200
content = b""
def json(self) -> dict:
return {}
class _Client:
async def post(self, url: str, json: dict | None = None, **_k):
posts.append((url, json or {}))
return _Resp()
monkeypatch.setattr(cnp.httpx, "AsyncClient", lambda **_k: _FakeAsyncCM(_Client()))
task = asyncio.create_task(
cnp.supervise_cursor_transcript_elicitations(
base_url="http://x",
headers={},
session_id="conv_a",
bridge_dir=tmp_path,
workspace="/ws",
launch_epoch_ms=0,
poll_interval_s=0.01,
settle_s=0.2, # long enough to span several polls before we resolve
)
)
# Let a few polls run while the call is pending (still inside settle window).
await asyncio.sleep(0.1)
# Auto-approved: the call resolves (vanishes) before the settle window ends.
pending_now.clear()
await asyncio.sleep(0.2)
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
assert not any("hooks/cursor-permission-request" in u for u, _ in posts), posts
assert not any(j.get("type") == "external_elicitation_resolved" for _, j in posts), posts
@pytest.mark.parametrize(
("args", "expected"),
[
(None, False),
([], False),
(["--approve-mcps"], False),
(["--auto-review"], False),
(["--yolo"], True),
(["--force"], True),
(["-f"], True),
(["--yolo", "--approve-mcps", "--model", "grok"], True),
(["--force=true"], True),
# An explicit off-value must read as off: this predicate is the safety
# gate for typing verdicts into someone's terminal, so it fails closed.
(["--yolo=false"], False),
(["--force=false"], False),
(["--yolo=0"], False),
(["--force=no"], False),
(["--yolo=OFF"], False),
(["--yolo=false", "--approve-mcps"], False),
# A bare ``--`` ends cursor-agent's flags; what follows is prompt text.
(["--", "-f"], False),
(["--", "--yolo"], False),
(["--yolo", "--", "-f"], True),
],
)
def test_cursor_launch_args_enable_yolo(args: list[str] | None, expected: bool) -> None:
"""Only the Run Everything CLI flags enable the yolo auto-accept path."""
assert cnp.cursor_launch_args_enable_yolo(args) is expected
# The block cursor renders for a tool gate. The parenthesised ``(y)`` hint on
# the accept row is what the yolo path requires on screen before it sends
# anything, so the fixtures below carry the real shape rather than a bare "y".
_ACCEPT_PANE = (
" $ docker pull example in .\n"
" Run this command?\n"
" Shell allowlist is empty\n"
" → Run (once) (y)\n"
" Run Everything (shift+tab)\n"
" Skip (esc or n)\n"
)
# A live pane with no gate on screen — the stale-marker case, where a keystroke
# would land in cursor's composer instead of answering anything.
_IDLE_PANE = " ~/ws\n Ask me anything…\n"
_SHELL_CALL = CursorPendingToolCall(
tool_call_id="call_shell\nfc",
tool_name="Shell",
args={"command": "docker pull example"},
)
def _install_supervisor_fakes(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
*,
pending: list[CursorPendingToolCall],
pane: str | None,
deliver_keys: bool = True,
) -> tuple[list[tuple[str, dict]], list[tuple[str, ...]]]:
"""Fake the store, pane and HTTP boundaries the supervisor talks to.
:param pending: Live list of pending calls; mutate it to resolve a gate.
:param pane: Pane text ``capture_cursor_pane`` returns, or ``None`` for a
dead / unadvertised pane.
:param deliver_keys: Whether the keystroke send reports success (``False``
models tmux rejecting the send after the capture succeeded).
:returns: The recorded ``(posts, keys_sent)`` lists.
"""
posts: list[tuple[str, dict]] = []
keys_sent: list[tuple[str, ...]] = []
monkeypatch.setattr(cnp, "_discover_store", lambda *_a, **_k: tmp_path / "store.db")
(tmp_path / "store.db").write_bytes(b"")
monkeypatch.setattr(cnp, "read_cursor_pending_tool_calls", lambda _s: list(pending))
monkeypatch.setattr(cnp, "capture_cursor_pane", lambda _bridge: pane)
async def _fake_send(_bridge: Path, _session: str, *keys: str) -> bool:
keys_sent.append(keys)
return deliver_keys
monkeypatch.setattr(cnp, "_send_cursor_keys", _fake_send)
class _Resp:
status_code = 200
content = b""
def json(self) -> dict:
return {}
class _Client:
async def post(self, url: str, json: dict | None = None, **_k):
posts.append((url, json or {}))
return _Resp()
monkeypatch.setattr(cnp.httpx, "AsyncClient", lambda **_k: _FakeAsyncCM(_Client()))
return posts, keys_sent
def _start_supervisor(
tmp_path: Path, *, session_id: str, auto_accept_approvals: bool
) -> asyncio.Task[None]:
"""Start the supervisor with a fast poll and no settle window."""
return asyncio.create_task(
cnp.supervise_cursor_transcript_elicitations(
base_url="http://x",
headers={},
session_id=session_id,
bridge_dir=tmp_path,
workspace="/ws",
launch_epoch_ms=0,
poll_interval_s=0.01,
settle_s=0.0,
auto_accept_approvals=auto_accept_approvals,
)
)
async def _wait_for(predicate: Callable[[], bool], *, timeout_s: float = 1.0) -> bool:
"""Poll *predicate* until it is true or *timeout_s* elapses."""
deadline = asyncio.get_running_loop().time() + timeout_s
while asyncio.get_running_loop().time() < deadline:
if predicate():
return True
await asyncio.sleep(0.01)
return predicate()
async def _stop(task: asyncio.Task[None]) -> None:
"""Cancel a supervisor task and swallow the cancellation."""
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
def _hook_posts(posts: list[tuple[str, dict]]) -> list[tuple[str, dict]]:
"""The parked approval-card POSTs among *posts*."""
return [(u, j) for u, j in posts if "hooks/cursor-permission-request" in u]
async def test_supervise_transcript_yolo_auto_accepts_without_card(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""Under yolo, a settled tool gate is accepted in-pane — no web card.
cursor-agent's Run Everything mode still sometimes leaves a pending marker
long enough for Omnigent to otherwise mirror an ApprovalCard and stall a
piloted parent. Auto-accept must send ``y`` and never POST the permission
hook.
"""
pending_now = [_SHELL_CALL]
posts, keys_sent = _install_supervisor_fakes(
monkeypatch, tmp_path, pending=pending_now, pane=_ACCEPT_PANE
)
task = _start_supervisor(tmp_path, session_id="conv_yolo", auto_accept_approvals=True)
assert await _wait_for(lambda: bool(keys_sent))
# Call resolves after the keystroke (cursor committed it).
pending_now.clear()
await asyncio.sleep(0.05)
await _stop(task)
assert keys_sent == [("y",)]
assert _hook_posts(posts) == []
assert not any(j.get("type") == "external_elicitation_resolved" for _, j in posts), posts
async def test_supervise_transcript_yolo_caps_retries_then_surfaces_card(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""A gate ``y`` never clears is retried a bounded number of times, then mirrored.
Without a cap the supervisor types ``y`` into the pane every couple of
seconds for the life of the session and no human ever sees the gate. After
the budget it must fall back to the ApprovalCard the non-yolo path shows.
"""
monkeypatch.setattr(cnp, "_YOLO_ACCEPT_RETRY_S", 0.0)
# Stays pending no matter how many times we accept it.
pending_now = [_SHELL_CALL]
posts, keys_sent = _install_supervisor_fakes(
monkeypatch, tmp_path, pending=pending_now, pane=_ACCEPT_PANE
)
task = _start_supervisor(tmp_path, session_id="conv_yolo_cap", auto_accept_approvals=True)
assert await _wait_for(lambda: bool(_hook_posts(posts)))
# Give the loop several more polls: the card is parked, so nothing more
# should be sent and the card must not be re-posted.
await asyncio.sleep(0.1)
await _stop(task)
assert keys_sent == [("y",)] * cnp._YOLO_ACCEPT_MAX_ATTEMPTS
assert len(_hook_posts(posts)) == 1, posts
async def test_supervise_transcript_yolo_never_types_when_no_prompt_on_screen(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""A pending marker with no gate rendered is mirrored, never typed at.
This is the stale-marker case the feature exists for. ``tmux send-keys y``
against an idle pane types a literal ``y`` into cursor's composer, which
then prepends itself to whatever the user types next — so the accept only
fires while cursor is actually advertising its accept key.
"""
monkeypatch.setattr(cnp, "_YOLO_ACCEPT_RETRY_S", 0.0)
posts, keys_sent = _install_supervisor_fakes(
monkeypatch, tmp_path, pending=[_SHELL_CALL], pane=_IDLE_PANE
)
task = _start_supervisor(tmp_path, session_id="conv_yolo_idle", auto_accept_approvals=True)
assert await _wait_for(lambda: bool(_hook_posts(posts)))
await _stop(task)
assert keys_sent == []
assert len(_hook_posts(posts)) == 1, posts
async def test_supervise_transcript_yolo_surfaces_card_when_pane_is_gone(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""A dead pane stops the accept immediately instead of spinning on it.
``capture_cursor_pane`` returns ``None`` when the tmux target was never
advertised or the TUI has exited. A keystroke cannot land, and recording it
as delivered would retry forever against a pane that is gone.
"""
posts, keys_sent = _install_supervisor_fakes(
monkeypatch, tmp_path, pending=[_SHELL_CALL], pane=None
)
task = _start_supervisor(tmp_path, session_id="conv_yolo_dead", auto_accept_approvals=True)
assert await _wait_for(lambda: bool(_hook_posts(posts)))
await _stop(task)
assert keys_sent == []
assert len(_hook_posts(posts)) == 1, posts
async def test_supervise_transcript_yolo_surfaces_card_when_keystroke_undelivered(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""A send tmux rejects is not counted as an accept that landed."""
monkeypatch.setattr(cnp, "_YOLO_ACCEPT_RETRY_S", 0.0)
posts, keys_sent = _install_supervisor_fakes(
monkeypatch,
tmp_path,
pending=[_SHELL_CALL],
pane=_ACCEPT_PANE,
deliver_keys=False,
)
task = _start_supervisor(
tmp_path, session_id="conv_yolo_undelivered", auto_accept_approvals=True
)
assert await _wait_for(lambda: bool(_hook_posts(posts)))
await _stop(task)
# One rejected send is enough to give up — no point retrying a broken pipe.
assert keys_sent == [("y",)]
assert len(_hook_posts(posts)) == 1, posts
async def test_supervise_transcript_without_yolo_never_auto_accepts(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""Without the yolo stance a gate is only ever mirrored, never answered.
Even with cursor's accept prompt on screen, the default launch must not
send a verdict of its own initiative — the card is the only channel.
"""
posts, keys_sent = _install_supervisor_fakes(
monkeypatch, tmp_path, pending=[_SHELL_CALL], pane=_ACCEPT_PANE
)
task = _start_supervisor(tmp_path, session_id="conv_plain", auto_accept_approvals=False)
assert await _wait_for(lambda: bool(_hook_posts(posts)))
await _stop(task)
assert keys_sent == []
async def test_supervise_transcript_yolo_still_parks_askquestion(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""AskQuestion still mirrors under yolo — that is deliberate human input."""
question_call = CursorPendingToolCall(
tool_call_id="call_q\nfc",
tool_name="AskQuestion",
args={
"title": "Pick one",
"questions": [
{
"id": "q1",
"prompt": "Continue?",
"options": [{"id": "yes", "label": "Yes"}, {"id": "no", "label": "No"}],
}
],
},
)
posts, keys_sent = _install_supervisor_fakes(
monkeypatch, tmp_path, pending=[question_call], pane=_ACCEPT_PANE
)
task = _start_supervisor(tmp_path, session_id="conv_yolo_q", auto_accept_approvals=True)
assert await _wait_for(lambda: bool(_hook_posts(posts)))
await _stop(task)
assert keys_sent == []
@pytest.mark.parametrize(
("pane", "expected"),
[
(_ACCEPT_PANE, True),
(_IDLE_PANE, False),
("", False),
# The y/n spelling of the same hint.
(" Run this command? (y/n)", True),
# A ``y`` in prose is not an advertised key.
(" yes, you may want to run this", False),
],
)
def test_pane_shows_accept_prompt(pane: str, expected: bool) -> None:
"""Only cursor's parenthesised accept hint counts as a gate on screen."""
assert cnp._pane_shows_accept_prompt(pane) is expected
async def test_send_cursor_keys_reports_undelivered_keystroke(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""A tmux send that raises reports failure rather than a silent success."""
def _boom(_bridge: Path, _key: str) -> None:
raise RuntimeError("cursor-native tmux target not advertised")
monkeypatch.setattr(cnp, "send_cursor_pane_keys", _boom)
assert await cnp._send_cursor_keys(tmp_path, "conv_dead", "y") is False
monkeypatch.setattr(cnp, "send_cursor_pane_keys", lambda *_a, **_k: None)
assert await cnp._send_cursor_keys(tmp_path, "conv_live", "y") is True
# ── AskQuestion (structured multiple-choice) ─────────────────────────────────
#
# cursor's ``AskQuestion`` tool is NOT an approval gate — it is a multi-question
# picker. It surfaces with the pending marker like any gated call, but must
# render as the web ``AskUserQuestion`` form (not approve/reject) and be answered
# by driving the TUI picker. Args shape verified against cursor-agent 2026.06.24.
_ASKQUESTION_ARGS = {
"title": "AskQuestion Demo",
"questions": [
{
"id": "demo_topic",
"prompt": "What kind of example would you like to see?",
"options": [
{"id": "coding", "label": "A coding-related question (Recommended)"},
{"id": "workflow", "label": "A workflow/planning question"},
{"id": "fun", "label": "A fun preference question"},
],
},
{
"id": "demo_depth",
"prompt": "How detailed should the follow-up be?",
"options": [
{"id": "brief", "label": "Brief (Recommended)"},
{"id": "detailed", "label": "Detailed"},
],
},
],
}
def test_is_question_call_distinguishes_askquestion() -> None:
"""``AskQuestion`` routes to the question path; other tools to approval."""
assert cnp._is_question_call(CursorPendingToolCall("t", "AskQuestion", _ASKQUESTION_ARGS))
assert not cnp._is_question_call(CursorPendingToolCall("t", "Delete", {"path": "/x"}))
assert not cnp._is_question_call(CursorPendingToolCall("t", "Shell", {"command": "ls"}))
def test_askquestion_preview_translates_to_web_form_shape() -> None:
"""cursor args → the ``AskUserQuestion(...)`` preview the web UI parses.
cursor's ``prompt`` becomes ``question``; options keep only ``label``; each
question ``id`` is preserved (the answer comes back keyed by it).
"""
preview = cnp._askquestion_preview(_ASKQUESTION_ARGS)
assert preview.startswith("AskUserQuestion(") and preview.endswith(")")
payload = _json.loads(preview[len("AskUserQuestion(") : -1])
assert [q["question"] for q in payload["questions"]] == [
"What kind of example would you like to see?",
"How detailed should the follow-up be?",
]
assert [q["id"] for q in payload["questions"]] == ["demo_topic", "demo_depth"]
assert payload["questions"][0]["options"] == [
{"label": "A coding-related question (Recommended)"},
{"label": "A workflow/planning question"},
{"label": "A fun preference question"},
]
assert all(q["multiSelect"] is False for q in payload["questions"])
def test_askquestion_keystrokes_navigate_to_chosen_options() -> None:
"""Chosen labels map to Down-navigation + Space + Enter per question."""
# First option of each question (index 0): just Space + Enter.
keys = cnp._askquestion_keystrokes(
_ASKQUESTION_ARGS,
{
"demo_topic": "A coding-related question (Recommended)",
"demo_depth": "Brief (Recommended)",
},
)
assert keys == ["Space", "Enter", "Space", "Enter"]
# Second option of each (index 1): one Down, Space, Enter — per question.
keys = cnp._askquestion_keystrokes(
_ASKQUESTION_ARGS,
{"demo_topic": "A workflow/planning question", "demo_depth": "Detailed"},
)
assert keys == ["Down", "Space", "Enter", "Down", "Space", "Enter"]
def test_askquestion_keystrokes_types_into_other_row_for_custom_answer() -> None:
"""A value matching no predefined option targets the trailing Other row."""
keys = cnp._askquestion_keystrokes(
_ASKQUESTION_ARGS,
{"demo_topic": "something custom", "demo_depth": "Detailed"},
)
# Q1 has 3 options → Other row at index 3: Down×3 then type the text.
assert keys == ["Down", "Down", "Down", "something custom", "Enter", "Down", "Space", "Enter"]
async def test_run_one_question_renders_form_then_drives_picker(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""The hook gets an AskUserQuestion preview; the verdict drives the picker."""
posts: list[tuple[str, dict]] = []
sent_keys: list[tuple[str, ...]] = []
monkeypatch.setattr(cnp, "send_cursor_pane_keys", lambda _d, *keys: sent_keys.append(keys))
class _Resp:
status_code = 200
# Web verdict: accept with the user's selected labels, keyed by question id.
content = b"x"
def json(self) -> dict:
return {
"action": "accept",
"content": {
"demo_topic": "A workflow/planning question",
"demo_depth": "Detailed",
},
}
class _Client:
async def post(self, url: str, json: dict | None = None, **_k):
posts.append((url, json or {}))
return _Resp()
await cnp._run_one_question(
_Client(),
session_id="conv_q",
bridge_dir=tmp_path,
call=CursorPendingToolCall("tc\nq", "AskQuestion", _ASKQUESTION_ARGS),
elicitation_id="elicit_cursor_conv_q_abc",
)
# 1) The hook payload carries the AskUserQuestion form preview, not raw JSON.
assert len(posts) == 1
url, body = posts[0]
assert "hooks/cursor-permission-request" in url
assert body["operation_type"] == "question"
assert body["content_preview"].startswith("AskUserQuestion(")
# Structured payload (uncapped) is the authoritative source the web renders.
assert body["ask_user_question"]["questions"][0]["id"] == "demo_topic"
assert body["ask_user_question"]["questions"][0]["question"] == (
"What kind of example would you like to see?"
)
# 2) The verdict drove the picker to the chosen options (index 1 in each).
flat = [k for group in sent_keys for k in group]
assert flat == ["Down", "Space", "Enter", "Down", "Space", "Enter"]
async def test_run_one_question_decline_skips_via_escape(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""A declined question sends Escape (skip), not an option selection."""
sent_keys: list[tuple[str, ...]] = []
monkeypatch.setattr(cnp, "send_cursor_pane_keys", lambda _d, *keys: sent_keys.append(keys))
class _Resp:
status_code = 200
content = b"x"
def json(self) -> dict:
return {"action": "decline"}
class _Client:
async def post(self, url: str, json: dict | None = None, **_k):
return _Resp()
await cnp._run_one_question(
_Client(),
session_id="conv_q",
bridge_dir=tmp_path,
call=CursorPendingToolCall("tc", "AskQuestion", _ASKQUESTION_ARGS),
elicitation_id="e",
)
assert [k for group in sent_keys for k in group] == ["Escape"]
class _FakeAsyncCM:
"""Minimal async-context-manager wrapper around a fake client."""
def __init__(self, client: object) -> None:
self._client = client
async def __aenter__(self) -> object:
return self._client
async def __aexit__(self, *_exc: object) -> bool:
return False