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>
This commit is contained in:
samarmstrong
2026-07-27 19:48:10 -04:00
committed by GitHub
parent 2f39f04e1f
commit 08056475b0
5 changed files with 673 additions and 20 deletions
+37 -5
View File
@@ -3,16 +3,21 @@
**Status:** implemented
**Supersedes:** [`cursor-native-tui-mirror-plan.md`](./cursor-native-tui-mirror-plan.md) (pane-scrape design)
**Code:** `omnigent/cursor_native_permissions.py`, the `cursor-permission-request` hook in
`omnigent/server/routes/sessions.py`, runner wiring in `omnigent/runner/app.py`,
`web/.../ApprovalCard.tsx`.
`omnigent/server/routes/sessions.py`, runner wiring in
`omnigent/runner/native/orchestration.py`, `web/.../ApprovalCard.tsx`.
## Goal / behavior
Surface an Omnigent **elicitation card whenever the `cursor-agent` TUI gates a tool call or
asks a question**, answerable from the web **or** the embedded TUI. Cursor's own native gate
stays the source of truth — **no `--force`, no JS-bundle modification**. The failure mode is
benign: if detection ever breaks, the embedded TUI prompt still works and the user answers
there.
stays the source of truth — Omnigent never modifies cursor's JS bundle and never suppresses
the TUI prompt. The failure mode is benign: if detection ever breaks, the embedded TUI prompt
still works and the user answers there.
One exception: a session the *caller* launched with `--yolo` / `--force` / `-f` has already
declared it wants no approvals, and a card mirrored to a piloted parent is a stall nobody can
click. Those sessions answer lingering gates in the pane instead — see
[Yolo sessions](#yolo-sessions-run-everything) below.
Two interaction kinds are surfaced (both ride cursor's per-call "pending" mechanism):
@@ -89,6 +94,28 @@ The pane is still used to *deliver* the verdict. Two gotchas, both handled in
doesn't park at the reason input. (The `AskQuestion` picker's "Esc to skip" dismisses
cleanly, so the question decline is a single key.)
### Yolo sessions (Run Everything)
A session launched with `--yolo` / `--force` / `-f` (`cursor_launch_args_enable_yolo`) still
occasionally leaves a pending marker behind. Mirroring that as a card stalls a piloted parent
that has no human to click it, so the supervisor answers it in the pane instead — with the
opposite default of the rest of this design, so the accept is deliberately fail-closed:
- **Only while cursor is asking.** `capture_cursor_pane` must show cursor's parenthesised
accept hint (`→ Run (once) (y)`). A stale marker with no gate rendered gets no keystroke —
`tmux send-keys y` would type a literal `y` into the composer, which then prepends itself to
whatever the user types next in the embedded terminal.
- **Bounded.** `_YOLO_ACCEPT_MAX_ATTEMPTS` tries, paced by `_YOLO_ACCEPT_RETRY_S`, at most one
keystroke per poll (cursor renders one prompt at a time).
- **Falls back to the card.** A dead pane, a send tmux rejects, or a gate still pending after
the budget all surface the ordinary ApprovalCard. The worst case is therefore today's visible
stall, never a keystroke loop.
- **`AskQuestion` is excluded** — a question is human input, not a gate `y` can answer.
- Because a gate answered this way is never seen by a human, the accept logs the tool name and
an argument preview at INFO: that line is the only record Omnigent approved the call.
The attempt counters are in-memory, so a runner restart re-tries a call that is still pending.
### AskQuestion specifics
- Rendered via the existing web form: the runner stamps the full questions as the **structured
@@ -143,6 +170,11 @@ in-memory. Byte-scanning the frames for embedded JSON reveals the pending tool c
double-surface. Low likelihood; not yet addressed.
- **Store schema is private and version-sensitive.** Confirmed against cursor-agent 2026.06.24
(and the marker present back to 2026.06.18). Failure stays benign (TUI gate authoritative).
- **Why a `--yolo` session gates at all is unconfirmed.** cursor documents `--force` as "force
allow commands unless explicitly denied", so a surviving gate may be one cursor deliberately
held back (a user deny rule, or a server-side classifier). The auto-accept above answers it
anyway, which is what the flag asks for; the pane check is what keeps that from becoming a
blind keystroke.
- **Keystroke delivery assumes the pane still shows the prompt** and the picker's key bindings
(`Down`/`Space`/`Enter`, highlight resets per question). Verified live; re-check on cursor
upgrades.
+210 -5
View File
@@ -23,7 +23,14 @@ pane scraping — which silently missed any prompt whose wording fell outside a
regex. The pane is still used only to *deliver* the keystroke verdict. This does
NOT modify cursor's JS bundle and does NOT suppress cursor's native gate; the
TUI prompt remains the source of truth and the benign fallback if store
detection ever fails. See ``docs/cursor-native-elicitation.md`` (and the
detection ever fails. Sessions launched with ``--yolo`` / ``--force`` / ``-f``
auto-accept lingering tool gates in-pane (no web card) because cursor's Run
Everything mode still sometimes leaves a pending marker long enough to stall a
piloted parent. That accept is deliberately fail-closed: it only fires while
the pane is live and actually showing cursor's accept hint, it is capped at a
few attempts, and anything it does not clear falls back to the ordinary web
card rather than typing ``y`` into the composer forever. See
``docs/cursor-native-elicitation.md`` (and the
superseded ``docs/cursor-native-tui-mirror-plan.md`` for the original pane-scrape
design and why the transcript channel replaced it).
"""
@@ -31,15 +38,17 @@ design and why the transcript channel replaced it).
from __future__ import annotations
import asyncio
import enum
import hashlib
import json
import logging
import re
from dataclasses import dataclass
from pathlib import Path
import httpx
from omnigent.cursor_native_bridge import send_cursor_pane_keys
from omnigent.cursor_native_bridge import capture_cursor_pane, send_cursor_pane_keys
# Reuse the forwarder's store discovery and WAL-aware blob reader so the
# transcript-based detector binds to the SAME cursor chat the forwarder mirrors
@@ -115,7 +124,7 @@ async def _park_cursor_elicitation(
return result if isinstance(result, dict) else None
async def _send_cursor_keys(bridge_dir: Path, session_id: str, *keys: str) -> None:
async def _send_cursor_keys(bridge_dir: Path, session_id: str, *keys: str) -> bool:
"""Send tmux keys to the cursor pane ONE AT A TIME, with settle pauses.
A multi-key sequence (the ``AskQuestion`` picker: ``Down``/``Space``/
@@ -125,9 +134,13 @@ async def _send_cursor_keys(bridge_dir: Path, session_id: str, *keys: str) -> No
own call, pause between them, and pause a little longer before ``Enter``.
A single-key approval (``y`` / ``Escape``) just sends once. Delivery failure
is logged and aborts the rest of the sequence.
:returns: Whether every key was handed to tmux. Callers that retry (the
yolo auto-accept) must not record an undelivered keystroke as an
attempt that landed.
"""
if not keys:
return
return True
for key in keys:
if key == "Enter":
await asyncio.sleep(_KEY_ENTER_SETTLE_S)
@@ -137,9 +150,10 @@ async def _send_cursor_keys(bridge_dir: Path, session_id: str, *keys: str) -> No
_logger.exception(
"failed to send cursor keystroke %r (of %r); session=%s", key, keys, session_id
)
return
return False
await asyncio.sleep(_KEY_INTERVAL_S)
_logger.debug("cursor keystrokes sent: %r; session=%s", keys, session_id)
return True
async def _run_one_approval(
@@ -310,6 +324,61 @@ _QUESTION_TOOL_NAMES = frozenset({"askquestion"})
# Auto-review retry) still surfaces a card rather than being suppressed.
_ELICITATION_SETTLE_S = 0.5
# When a session launched with ``--yolo`` / ``--force`` / ``-f``, cursor still
# sometimes leaves a pending marker long enough for Omnigent to mirror a card.
# Auto-accept sends ``y`` into the pane instead of parking a web elicitation.
# Retry if the call stays pending (keystroke dropped by a TUI re-render), but
# only a few times: a gate still pending after that is not one ``y`` answers
# (a stale marker with no prompt on screen, or a prompt needing a different
# key), so fall back to the ordinary card instead of typing into the composer
# for the life of the session.
_YOLO_ACCEPT_RETRY_S = 2.0
_YOLO_ACCEPT_MAX_ATTEMPTS = 3
# Cursor's approval block advertises its accept key as a parenthesised hint on
# the chosen option line, e.g. ``→ Run (once) (y)``. Auto-accept requires that
# hint to be on screen before it sends anything, so a stale pending marker with
# no prompt rendered never types a literal ``y`` into cursor's composer. A
# wording change therefore degrades to the visible card, not to a stray
# keystroke.
_ACCEPT_KEY_HINT_RE = re.compile(
rf"\(\s*{re.escape(_TRANSCRIPT_ACCEPT_KEY)}\s*(?:/[^)\n]*)?\)", re.IGNORECASE
)
_YOLO_FLAGS = frozenset({"--yolo", "--force", "-f"})
# Values that turn an explicit ``--yolo=<value>`` back off. Anything else in the
# value slot counts as on, so an unrecognised truthy spelling does not silently
# disable the gate the caller asked for.
_YOLO_FALSEY_VALUES = frozenset({"false", "0", "no", "off"})
def cursor_launch_args_enable_yolo(args: list[str] | None) -> bool:
"""Return whether *args* request cursor-agent's full tool-approval bypass.
Matches the CLI flags cursor documents as Run Everything: ``--yolo``,
``--force``, and the short ``-f`` form. Used by the runner to decide whether
the transcript elicitation supervisor should auto-accept lingering gates
instead of mirroring ApprovalCards to a parent that cannot click them.
This is a safety predicate, so it fails closed: an explicit ``--yolo=false``
is off, and a bare ``--`` ends cursor-agent's flags, so a ``-f`` in the
prompt text that follows is text, not a request to bypass approvals.
"""
for arg in args or []:
if arg == "--":
return False
if arg in _YOLO_FLAGS:
return True
name, sep, value = arg.partition("=")
if sep and name in _YOLO_FLAGS and value.strip().lower() not in _YOLO_FALSEY_VALUES:
return True
return False
def _pane_shows_accept_prompt(pane: str) -> bool:
"""Return whether *pane* is showing a prompt that ``y`` can answer."""
return bool(_ACCEPT_KEY_HINT_RE.search(pane))
@dataclass(frozen=True)
class CursorPendingToolCall:
@@ -621,6 +690,106 @@ def _askquestion_keystrokes(args: dict[str, object], content: dict) -> list[str]
return keys
class _YoloAccept(enum.Enum):
"""What one auto-accept pass over a pending call decided to do."""
SENT = "sent"
"""The accept key was delivered to a pane that was showing the prompt."""
SKIP = "skip"
"""Nothing to do this pass — pacing, no prompt rendered yet, or another
call was already answered (cursor shows one prompt at a time)."""
SURFACE_CARD = "surface_card"
"""Auto-accept is not clearing this gate; fall back to the web card."""
async def _yolo_auto_accept(
call: CursorPendingToolCall,
*,
bridge_dir: Path,
session_id: str,
now: float,
attempts_by_call: dict[str, tuple[float, int]],
allow_send: bool,
) -> _YoloAccept:
"""Try to answer one gated call in-pane, or hand it back to the card path.
Fail-closed on every uncertainty: the accept key goes out only while the
pane is live *and* rendering cursor's accept hint, at most
:data:`_YOLO_ACCEPT_MAX_ATTEMPTS` times. A gate that survives that budget —
a stale marker with no prompt on screen, a prompt ``y`` cannot answer, or a
pane that has gone away — returns :attr:`_YoloAccept.SURFACE_CARD` so it
ends up visible in the parent instead of drawing a keystroke every couple of
seconds for the life of the session.
:param attempts_by_call: tool_call_id → (loop-time of last attempt, count).
Mutated in place; in-memory only, so a runner restart re-tries a call
that is still pending.
:param allow_send: False once another call has been answered this pass.
"""
last_attempt_at, attempts = attempts_by_call.get(call.tool_call_id, (None, 0))
if attempts >= _YOLO_ACCEPT_MAX_ATTEMPTS:
_logger.warning(
"cursor elicitation: yolo auto-accept did not clear %s after %d attempts; "
"surfacing a card; session=%s tool_call_id=%s",
call.tool_name,
attempts,
session_id,
call.tool_call_id.splitlines()[0],
)
return _YoloAccept.SURFACE_CARD
if last_attempt_at is not None and (now - last_attempt_at) < _YOLO_ACCEPT_RETRY_S:
return _YoloAccept.SKIP
if not allow_send:
return _YoloAccept.SKIP
pane = await asyncio.to_thread(capture_cursor_pane, bridge_dir)
if pane is None:
# No advertised tmux target, or the pane exited: a keystroke cannot
# land, and recording it as delivered would spin here forever.
_logger.warning(
"cursor elicitation: no live cursor pane to auto-accept %s; surfacing a card; "
"session=%s tool_call_id=%s",
call.tool_name,
session_id,
call.tool_call_id.splitlines()[0],
)
attempts_by_call[call.tool_call_id] = (now, _YOLO_ACCEPT_MAX_ATTEMPTS)
return _YoloAccept.SURFACE_CARD
if not _pane_shows_accept_prompt(pane):
# The store says pending but nothing on screen takes the accept key —
# a prompt still painting, or a stale marker. Sending now would type a
# literal ``y`` into cursor's composer.
attempts_by_call[call.tool_call_id] = (now, attempts + 1)
_logger.debug(
"cursor elicitation: no accept prompt on screen for %s (attempt %d/%d); "
"session=%s tool_call_id=%s",
call.tool_name,
attempts + 1,
_YOLO_ACCEPT_MAX_ATTEMPTS,
session_id,
call.tool_call_id.splitlines()[0],
)
return _YoloAccept.SKIP
# A call nobody ever sees needs its arguments in the record, or an operator
# has no way to tell an auto-approved ``ls`` from an auto-approved ``rm``.
_logger.info(
"cursor elicitation: auto-accepting %s under yolo (attempt %d/%d); "
"session=%s tool_call_id=%s preview=%s",
call.tool_name,
attempts + 1,
_YOLO_ACCEPT_MAX_ATTEMPTS,
session_id,
call.tool_call_id.splitlines()[0],
_preview_for_args(call.args),
)
if not await _send_cursor_keys(bridge_dir, session_id, _TRANSCRIPT_ACCEPT_KEY):
attempts_by_call[call.tool_call_id] = (now, _YOLO_ACCEPT_MAX_ATTEMPTS)
return _YoloAccept.SURFACE_CARD
attempts_by_call[call.tool_call_id] = (now, attempts + 1)
return _YoloAccept.SENT
async def supervise_cursor_transcript_elicitations(
*,
base_url: str,
@@ -632,6 +801,7 @@ async def supervise_cursor_transcript_elicitations(
auth: httpx.Auth | None = None,
poll_interval_s: float = _POLL_INTERVAL_S,
settle_s: float = _ELICITATION_SETTLE_S,
auto_accept_approvals: bool = False,
) -> None:
"""Mirror cursor's gated tool calls to web elicitations via the chat store.
@@ -648,6 +818,15 @@ async def supervise_cursor_transcript_elicitations(
the TUI, or executed after approval) it releases the card via
``external_elicitation_resolved``.
When ``auto_accept_approvals`` is set (sessions launched with ``--yolo`` /
``--force`` / ``-f``), tool-approval gates are accepted in-pane without a
web card. Cursor's Run Everything mode still sometimes leaves a pending
marker long enough to otherwise stall a piloted parent on mirrored
ApprovalCards. ``AskQuestion`` still surfaces — that is intentional human
input, not a tool gate. The accept is bounded and fail-closed (see
:func:`_yolo_auto_accept`): a gate it does not clear falls back to the same
card the non-yolo path would have shown.
Store discovery reuses the forwarder's logic, so this binds to the same chat
the forwarder mirrors. Detection is keyed by ``toolCallId`` (stable across
polls and restarts), capturing every gated tool kind without a
@@ -662,12 +841,16 @@ async def supervise_cursor_transcript_elicitations(
:param auth: Optional httpx auth for the runner's requests.
:param poll_interval_s: Store poll cadence in seconds.
:param settle_s: How long a call must stay pending before it is surfaced.
:param auto_accept_approvals: When True, accept tool gates in-pane instead
of mirroring ApprovalCards (yolo / force launch stance).
"""
# tool_call_id → {"elicitation_id": str, "task": asyncio.Task} for SURFACED
# (parked) calls; tool_call_id → loop-time first seen pending, for calls
# still inside the settle window (not yet surfaced).
active: dict[str, dict[str, object]] = {}
first_seen: dict[str, float] = {}
# tool_call_id → (loop-time of last auto-accept attempt, attempts) — yolo only.
auto_accept_attempts: dict[str, tuple[float, int]] = {}
store_path: Path | None = None
loop = asyncio.get_running_loop()
timeout = httpx.Timeout(_POST_TIMEOUT_S, connect=10.0)
@@ -707,6 +890,13 @@ async def supervise_cursor_transcript_elicitations(
session_id,
tool_call_id.splitlines()[0],
)
for tool_call_id in [
tcid for tcid in auto_accept_attempts if tcid not in seen_ids
]:
auto_accept_attempts.pop(tool_call_id, None)
# Cursor renders one approval prompt at a time, so at most one
# accept key goes out per pass however many calls are pending.
auto_accepted_this_pass = False
# Surface calls that have now stayed pending past the settle window.
for call in pending_calls:
if call.tool_call_id in active:
@@ -724,6 +914,21 @@ async def supervise_cursor_transcript_elicitations(
)
if now - first < settle_s:
continue
# Yolo / force: try to accept tool gates in-pane rather than
# mirroring a card a piloted parent cannot click. AskQuestion
# still parks — that is deliberate human input.
if auto_accept_approvals and not _is_question_call(call):
outcome = await _yolo_auto_accept(
call,
bridge_dir=bridge_dir,
session_id=session_id,
now=now,
attempts_by_call=auto_accept_attempts,
allow_send=not auto_accepted_this_pass,
)
if outcome is not _YoloAccept.SURFACE_CARD:
auto_accepted_this_pass |= outcome is _YoloAccept.SENT
continue
elicitation_id = cursor_tool_call_elicitation_id(session_id, call.tool_call_id)
_logger.debug(
"cursor elicitation: surfacing %s; session=%s tool_call_id=%s",
+10 -1
View File
@@ -2175,7 +2175,10 @@ async def _auto_create_cursor_terminal(
_runner_auth = _RunnerDatabricksAuth(_make_auth_token_factory())
from omnigent.cursor_native_forwarder import supervise_cursor_forwarder
from omnigent.cursor_native_permissions import supervise_cursor_transcript_elicitations
from omnigent.cursor_native_permissions import (
cursor_launch_args_enable_yolo,
supervise_cursor_transcript_elicitations,
)
from omnigent.cursor_native_usage import supervise_cursor_usage_forwarder
if server_client is not None and ensure_comment_relay is not None:
@@ -2220,6 +2223,12 @@ async def _auto_create_cursor_terminal(
workspace=workspace,
launch_epoch_ms=launch_epoch_ms,
auth=_runner_auth,
# Yolo / force sessions still sometimes leave pending markers;
# answer those in-pane instead of mirroring a card to a parent
# that cannot click it.
auto_accept_approvals=cursor_launch_args_enable_yolo(
launch_config.terminal_launch_args
),
),
supervise_cursor_usage_forwarder(
base_url=server_url,
@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
from dataclasses import dataclass
@@ -1059,8 +1060,8 @@ async def _run_auto_create_cursor_terminal(
monkeypatch: pytest.MonkeyPatch,
agent_spec: AgentSpec | None,
terminal_launch_args: list[str] | None,
) -> Any:
"""Drive ``_auto_create_cursor_terminal`` and return the captured launch spec.
) -> dict[str, Any]:
"""Drive ``_auto_create_cursor_terminal`` and return what it wired up.
Stubs the cursor-agent binary lookup, the transcript forwarder, and the
runner auth factory so the model-injection branch runs without a real
@@ -1068,6 +1069,10 @@ async def _run_auto_create_cursor_terminal(
(workspace + ``terminal_launch_args``) is served from an in-memory
``httpx.MockTransport``, and a fake registry records the ``spec`` passed to
``launch_required_terminal`` so the test can assert on ``spec.args``.
:returns: ``{"spec": <launch spec>, "elicitation_kwargs": <kwargs>}``, the
latter captured from the elicitation supervisor the forwarder task
gathers (empty if that task never got to start it).
"""
from omnigent.runner import _entry as _runner_entry
@@ -1084,11 +1089,25 @@ async def _run_auto_create_cursor_terminal(
"omnigent.cursor_native_forwarder.supervise_cursor_forwarder",
_no_op_forwarder,
)
monkeypatch.setattr(
"omnigent.cursor_native_usage.supervise_cursor_usage_forwarder",
_no_op_forwarder,
)
# The forwarder is stubbed, so the auth it would carry is never used — keep
# the factory from reaching for ambient Databricks credentials in tests.
monkeypatch.setattr(_runner_entry, "_make_auth_token_factory", lambda *a, **k: None)
captured: dict[str, Any] = {}
captured: dict[str, Any] = {"elicitation_kwargs": {}}
started = asyncio.Event()
async def _capture_elicitation_supervisor(**kwargs: Any) -> None:
captured["elicitation_kwargs"] = kwargs
started.set()
monkeypatch.setattr(
"omnigent.cursor_native_permissions.supervise_cursor_transcript_elicitations",
_capture_elicitation_supervisor,
)
class _FakeResourceRegistry:
"""Captures the launched terminal spec; no real terminal registry."""
@@ -1130,9 +1149,13 @@ async def _run_auto_create_cursor_terminal(
ensure_comment_relay=None,
agent_spec=agent_spec,
)
# The bridge supervisors are gathered in a background task, so give it
# a beat to record the kwargs the wiring derived.
with contextlib.suppress(TimeoutError):
await asyncio.wait_for(started.wait(), timeout=2.0)
finally:
await fake_client.aclose()
return captured["spec"]
return captured
@pytest.mark.asyncio
@@ -1146,7 +1169,7 @@ async def test_auto_create_cursor_terminal_injects_spec_model(
session's ``executor.model`` (from ``--model`` or config.yaml ``model:``)
must reach the TUI as ``--model <id>`` — the regression #933 fixes.
"""
spec = await _run_auto_create_cursor_terminal(
captured = await _run_auto_create_cursor_terminal(
tmp_path=tmp_path,
monkeypatch=monkeypatch,
agent_spec=AgentSpec(
@@ -1154,6 +1177,7 @@ async def test_auto_create_cursor_terminal_injects_spec_model(
),
terminal_launch_args=None,
)
spec = captured["spec"]
assert spec.command == "cursor-agent"
assert "--model" in spec.args
assert spec.args[spec.args.index("--model") + 1] == "sonnet-4-thinking"
@@ -1178,7 +1202,7 @@ async def test_auto_create_cursor_terminal_user_model_wins(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A user-pinned passthrough model wins; the spec model is not injected."""
spec = await _run_auto_create_cursor_terminal(
captured = await _run_auto_create_cursor_terminal(
tmp_path=tmp_path,
monkeypatch=monkeypatch,
agent_spec=AgentSpec(
@@ -1186,6 +1210,7 @@ async def test_auto_create_cursor_terminal_user_model_wins(
),
terminal_launch_args=passthrough,
)
spec = captured["spec"]
# Exactly the user's args survive — no second ``--model`` / spec model added.
assert spec.args.count("--model") == passthrough.count("--model")
assert "sonnet-4-thinking" not in spec.args
@@ -1207,7 +1232,7 @@ async def test_auto_create_cursor_terminal_omits_model_when_unusable(
Gateway-routed ``databricks-*`` ids are not cursor-agent model ids, so they
are dropped rather than passed through (which would error on launch).
"""
spec = await _run_auto_create_cursor_terminal(
captured = await _run_auto_create_cursor_terminal(
tmp_path=tmp_path,
monkeypatch=monkeypatch,
agent_spec=AgentSpec(
@@ -1215,7 +1240,42 @@ async def test_auto_create_cursor_terminal_omits_model_when_unusable(
),
terminal_launch_args=None,
)
assert "--model" not in spec.args
assert "--model" not in captured["spec"].args
@pytest.mark.parametrize(
("terminal_launch_args", "expected"),
[
(None, False),
(["--auto-review"], False),
(["--yolo=false"], False),
(["--yolo"], True),
(["--force"], True),
(["-f"], True),
],
ids=["none", "auto-review", "yolo-off", "yolo", "force", "short-force"],
)
@pytest.mark.asyncio
async def test_auto_create_cursor_terminal_wires_yolo_auto_accept(
terminal_launch_args: list[str] | None,
expected: bool,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The elicitation supervisor's yolo stance is derived from the launch args.
This wiring is the only thing that turns the in-pane auto-accept on. It is
a single kwarg in a large auto-create function, so without this assertion a
rebase can drop it and leave the feature inert with the rest of the suite
green.
"""
captured = await _run_auto_create_cursor_terminal(
tmp_path=tmp_path,
monkeypatch=monkeypatch,
agent_spec=None,
terminal_launch_args=terminal_launch_args,
)
assert captured["elicitation_kwargs"]["auto_accept_approvals"] is expected
@pytest.mark.parametrize(
+348 -1
View File
@@ -7,7 +7,10 @@ HTTP boundaries faked:
``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, and the TUI-resolved release.
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).
@@ -24,6 +27,7 @@ import asyncio
import contextlib
import json as _json
import sqlite3 as _sqlite3
from collections.abc import Callable
from pathlib import Path
import httpx
@@ -481,6 +485,349 @@ async def test_supervise_transcript_debounces_autoapproved_call(
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