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:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user