784 lines
32 KiB
Python
784 lines
32 KiB
Python
"""End-to-end tests: ``omnigent cursor`` drives the native Cursor TUI.
|
|
|
|
The cursor-native sibling of ``test_codex_native_cli_cwd_e2e`` /
|
|
``test_codex_native_cli_resume_e2e``. ``cursor-native`` is a *terminal-first*
|
|
harness: ``omnigent cursor`` launches the official ``cursor-agent`` TUI in a
|
|
runner-owned tmux pane, and each web-UI turn is injected into that pane
|
|
(bracketed paste + Enter) by
|
|
:class:`omnigent.inner.cursor_native_executor.CursorNativeExecutor`. The TUI's
|
|
own conversation store is tailed by
|
|
:mod:`omnigent.cursor_native_forwarder`, which mirrors ``cursor-agent``'s
|
|
replies back onto the Omnigent conversation as assistant items.
|
|
|
|
These tests drive the full stack the way a user does — spawn ``omnigent
|
|
cursor``, then talk to the session **through the server** (``POST
|
|
/v1/sessions/{id}/events``, the web-UI path) — and assert on the persisted
|
|
assistant items:
|
|
|
|
* **smoke** — inject a prompt that makes ``cursor-agent`` emit a unique marker
|
|
word, and confirm the marker comes back as an assistant item. This exercises
|
|
CLI parse -> daemon runner spawn -> cursor terminal launch -> tmux injection
|
|
-> ``cursor-agent`` turn -> forwarder mirror -> conversation store.
|
|
* **cwd** — drop a marker file in the launch cwd and ask ``cursor-agent`` to
|
|
read it. The file exists only in the launch directory (never in the runner's
|
|
spec-bundle dir), so a correct answer proves both that the TUI launched in
|
|
the launch cwd *and* that its built-in Read tool ran.
|
|
|
|
Unlike the SDK ``cursor`` harness (``test_per_harness_cursor``), cursor-native
|
|
authenticates from the **ambient ``cursor-agent login``** under ``$HOME/.cursor``
|
|
— there is no ``CURSOR_API_KEY``. The TUI is launched with ``-f`` (Cursor's
|
|
force/trust flag) so it neither blocks on the per-directory "Workspace Trust"
|
|
prompt nor on per-tool approval prompts — either of which would hang the pane.
|
|
|
|
Environment requirements (why this is opt-in, not pure-CI)
|
|
----------------------------------------------------------
|
|
* **Opt-in only**: set ``OMNIGENT_E2E_CURSOR_NATIVE=1`` to run. Like the other
|
|
native-TUI e2e tests, cursor-native needs an interactive ``cursor-agent
|
|
login`` anchored to the real ``$HOME`` and a ``tmux`` binary; the
|
|
``cursor-agent`` binary may be present on CI but unauthenticated, which would
|
|
hang the TUI. The env-var gate keeps it out of CI; a developer with a
|
|
logged-in Cursor opts in. ``tmux`` and ``cursor-agent`` on ``PATH`` are also
|
|
required (checked below).
|
|
* Run it like the codex-native CLI tests::
|
|
|
|
OMNIGENT_E2E_CURSOR_NATIVE=1 \
|
|
.venv/bin/python -m pytest tests/e2e/test_cursor_native_cli_e2e.py \
|
|
--profile oss \
|
|
--llm-api-key "$(databricks auth token -p oss \
|
|
| python -c 'import sys,json;print(json.load(sys.stdin)["access_token"])')" \
|
|
-v
|
|
|
|
The ``--profile`` / ``--llm-api-key`` only satisfy the test server's startup
|
|
(``resume_test_server``); the ``cursor-agent`` turn itself authenticates via
|
|
the ambient Cursor login, not the Databricks gateway.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import queue
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from omnigent.cursor_native_bridge import bridge_dir_for_session_id, kill_session
|
|
from tests.e2e._native_resume_helpers import (
|
|
PtyHandle,
|
|
cli_env,
|
|
inject_user_message,
|
|
omnigent_console_script,
|
|
poll_for_assistant_marker,
|
|
poll_for_pending_elicitation,
|
|
resolve_elicitation,
|
|
spawn_cli_background,
|
|
wait_for_conversation_id,
|
|
wait_for_terminal_ready,
|
|
)
|
|
|
|
# ``resume_test_server`` is provided by tests/e2e/conftest.py (the allow-list-
|
|
# free server the CLI wrapper's self-spawned host daemon can register against).
|
|
|
|
# Opt-in only — see module docstring. Binary presence is not a sufficient gate
|
|
# (present-but-unauthenticated hangs the TUI), so require the explicit env var,
|
|
# plus the two binaries the terminal-first harness needs on PATH.
|
|
pytestmark = pytest.mark.skipif(
|
|
os.environ.get("OMNIGENT_E2E_CURSOR_NATIVE") != "1"
|
|
or shutil.which("cursor-agent") is None
|
|
or shutil.which("tmux") is None,
|
|
reason=(
|
|
"cursor-native CLI e2e needs an interactive `cursor-agent login` and a "
|
|
"`tmux` binary; set OMNIGENT_E2E_CURSOR_NATIVE=1 (and have `cursor-agent` "
|
|
"installed + logged in and `tmux` on PATH) to run"
|
|
),
|
|
)
|
|
|
|
# Cursor's force/trust flag, passed as a raw cursor-agent arg. Clears the
|
|
# per-directory "Workspace Trust" gate and per-tool approval prompts so the TUI
|
|
# never blocks the tmux pane waiting on a y/n the test can't answer.
|
|
_FORCE_FLAG = "-f"
|
|
|
|
_CWD_MARKER_FILE = "CWD_MARKER.txt"
|
|
|
|
# cursor-agent cold-starts the TUI and round-trips to Cursor's backend; mirror
|
|
# the headroom the codex-native CLI tests allow on a contended host.
|
|
_CONV_ID_TIMEOUT = 120.0
|
|
_TERMINAL_READY_TIMEOUT = 90.0
|
|
_REPLY_TIMEOUT = 180.0
|
|
_COLD_RESUME_HINT = (
|
|
"Terminal not running - starting a fresh Cursor session (prior chat not restored)."
|
|
)
|
|
|
|
# A per-tool approval prompt surfaces only after cursor cold-starts, runs a
|
|
# turn, and reaches the tool call — give it the same headroom as a reply.
|
|
_ELICITATION_TIMEOUT = 150.0
|
|
|
|
|
|
def _write_json_line(stdin, payload: dict[str, object]) -> None:
|
|
stdin.write(json.dumps(payload) + "\n")
|
|
stdin.flush()
|
|
|
|
|
|
def _read_json_line(stdout, *, timeout_s: float) -> dict[str, object]:
|
|
lines: queue.Queue[str] = queue.Queue(maxsize=1)
|
|
|
|
def reader() -> None:
|
|
lines.put(stdout.readline())
|
|
|
|
threading.Thread(target=reader, daemon=True).start()
|
|
try:
|
|
line = lines.get(timeout=timeout_s)
|
|
except queue.Empty as exc:
|
|
raise AssertionError(f"timed out waiting for MCP response after {timeout_s}s") from exc
|
|
assert line, "MCP process closed stdout"
|
|
return json.loads(line)
|
|
|
|
|
|
def _read_jsonrpc_response(stdout, *, response_id: int, timeout_s: float) -> dict[str, object]:
|
|
deadline = time.time() + timeout_s
|
|
while time.time() < deadline:
|
|
payload = _read_json_line(stdout, timeout_s=max(0.1, deadline - time.time()))
|
|
if payload.get("id") == response_id:
|
|
return payload
|
|
raise AssertionError(f"timed out waiting for MCP response id={response_id}")
|
|
|
|
|
|
def _wait_for_output_contains(handle: PtyHandle, needle: str, *, timeout: float) -> str:
|
|
"""Poll a PTY handle until *needle* appears in its decoded output."""
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
output = handle.output()
|
|
if needle in output:
|
|
return output
|
|
time.sleep(0.2)
|
|
output = handle.output()
|
|
raise AssertionError(
|
|
f"did not see {needle!r} within {timeout}s; output tail:\n{output[-2000:]}"
|
|
)
|
|
|
|
|
|
def test_cursor_native_cli_smoke(
|
|
resume_test_server: str,
|
|
tmp_path: Path,
|
|
request: pytest.FixtureRequest,
|
|
) -> None:
|
|
"""A cursor-native turn driven through the server returns the model's reply.
|
|
|
|
Spawns a backgrounded ``omnigent cursor`` session, waits for its terminal
|
|
to register, injects (via ``/events`` — the web-UI path) a prompt asking
|
|
``cursor-agent`` to emit a unique marker word, and asserts the marker comes
|
|
back as an assistant item. The marker is a fresh per-run nonce so a match
|
|
cannot be coincidental and a parallel run cannot leak it.
|
|
|
|
This is the end-to-end smoke gate for the cursor-native harness: it covers
|
|
the whole path from CLI parse through tmux injection to the forwarder
|
|
mirroring ``cursor-agent``'s reply onto the conversation store.
|
|
|
|
:param resume_test_server: Base URL of the allow-list-free test server.
|
|
:param tmp_path: Per-test temp dir; its ``pwd`` subdir is the launch cwd.
|
|
:param request: Pytest request — reads ``--profile`` for the test server.
|
|
"""
|
|
profile = request.config.getoption("--profile")
|
|
assert profile, "this test requires --profile (e.g. --profile oss) for the test server"
|
|
|
|
pwd_dir = tmp_path / "pwd"
|
|
pwd_dir.mkdir()
|
|
marker = f"CURSOR_{uuid.uuid4().hex[:8].upper()}"
|
|
|
|
omni = str(omnigent_console_script())
|
|
handle = spawn_cli_background(
|
|
[omni, "cursor", "--server", resume_test_server, _FORCE_FLAG],
|
|
env=cli_env(profile=profile),
|
|
cwd=str(pwd_dir),
|
|
)
|
|
try:
|
|
conversation_id = wait_for_conversation_id(handle, timeout=_CONV_ID_TIMEOUT)
|
|
with httpx.Client(base_url=resume_test_server, timeout=30) as client:
|
|
wait_for_terminal_ready(
|
|
client,
|
|
conversation_id=conversation_id,
|
|
harness="cursor",
|
|
timeout=_TERMINAL_READY_TIMEOUT,
|
|
)
|
|
inject_user_message(
|
|
client,
|
|
conversation_id=conversation_id,
|
|
text=f"Reply with ONLY this exact word and nothing else: {marker}",
|
|
)
|
|
try:
|
|
poll_for_assistant_marker(
|
|
client,
|
|
conversation_id=conversation_id,
|
|
marker=marker,
|
|
timeout=_REPLY_TIMEOUT,
|
|
)
|
|
except AssertionError as exc:
|
|
raise AssertionError(
|
|
f"`omnigent cursor` did not return marker {marker!r}. The "
|
|
"cursor-native path regressed somewhere between tmux injection, "
|
|
"the cursor-agent turn, and the forwarder mirroring the reply "
|
|
f"onto the conversation.\n\nCLI output tail:\n{handle.output()[-2000:]}"
|
|
) from exc
|
|
finally:
|
|
handle.terminate()
|
|
|
|
|
|
def test_cursor_native_cli_runs_in_launch_cwd(
|
|
resume_test_server: str,
|
|
tmp_path: Path,
|
|
request: pytest.FixtureRequest,
|
|
) -> None:
|
|
"""``omnigent cursor`` launches ``cursor-agent`` in the directory it was run from.
|
|
|
|
Spawns a backgrounded ``omnigent cursor`` whose process cwd is a temp
|
|
directory containing a marker file, then injects (via the server, the
|
|
web-UI path) a request to read that file. The marker exists only in the
|
|
launch cwd (never in the runner's spec-bundle dir), so it can come back
|
|
only if the wrapper launched the TUI in the launch directory *and*
|
|
``cursor-agent``'s built-in Read tool ran (the ``-f`` flag auto-approves
|
|
it). The cursor-native sibling of ``test_codex_native_cli_runs_in_launch_cwd``.
|
|
|
|
:param resume_test_server: Base URL of the allow-list-free test server.
|
|
:param tmp_path: Per-test temp dir; its ``pwd`` subdir is the launch cwd.
|
|
:param request: Pytest request — reads ``--profile`` for the test server.
|
|
"""
|
|
profile = request.config.getoption("--profile")
|
|
assert profile, "this test requires --profile (e.g. --profile oss) for the test server"
|
|
|
|
pwd_dir = tmp_path / "pwd"
|
|
pwd_dir.mkdir()
|
|
marker = f"PWD_{uuid.uuid4().hex[:6].upper()}"
|
|
(pwd_dir / _CWD_MARKER_FILE).write_text(marker + "\n")
|
|
|
|
omni = str(omnigent_console_script())
|
|
handle = spawn_cli_background(
|
|
[omni, "cursor", "--server", resume_test_server, _FORCE_FLAG],
|
|
env=cli_env(profile=profile),
|
|
cwd=str(pwd_dir),
|
|
)
|
|
try:
|
|
conversation_id = wait_for_conversation_id(handle, timeout=_CONV_ID_TIMEOUT)
|
|
with httpx.Client(base_url=resume_test_server, timeout=30) as client:
|
|
wait_for_terminal_ready(
|
|
client,
|
|
conversation_id=conversation_id,
|
|
harness="cursor",
|
|
timeout=_TERMINAL_READY_TIMEOUT,
|
|
)
|
|
inject_user_message(
|
|
client,
|
|
conversation_id=conversation_id,
|
|
text=(
|
|
f"Read the file {_CWD_MARKER_FILE} in your current directory "
|
|
"and reply with its exact contents and nothing else."
|
|
),
|
|
)
|
|
try:
|
|
poll_for_assistant_marker(
|
|
client,
|
|
conversation_id=conversation_id,
|
|
marker=marker,
|
|
timeout=_REPLY_TIMEOUT,
|
|
)
|
|
except AssertionError as exc:
|
|
raise AssertionError(
|
|
f"`omnigent cursor` did not return marker {marker!r} from "
|
|
f"{_CWD_MARKER_FILE} — it did not run cursor-agent in its launch "
|
|
"cwd (the wrapper-path cwd resolution regressed, likely the "
|
|
f"spec-bundle dir).\n\nCLI output tail:\n{handle.output()[-2000:]}"
|
|
) from exc
|
|
finally:
|
|
handle.terminate()
|
|
|
|
|
|
def test_cursor_native_cli_resume_warns_when_terminal_was_killed(
|
|
resume_test_server: str,
|
|
tmp_path: Path,
|
|
request: pytest.FixtureRequest,
|
|
) -> None:
|
|
"""Live reattach stays quiet, but cold resume tells the truth.
|
|
|
|
This is the e2e guard for the UX bug in ``CURSOR_NATIVE_AUDIT_FIXES.md``
|
|
item #2. A second ``omnigent cursor --resume <conv>`` while the original
|
|
tmux pane is still alive should attach to the live terminal and must not
|
|
print the cold-resume warning. After killing that tmux session, the same
|
|
resume command necessarily starts a fresh ``cursor-agent`` TUI with no
|
|
prior Cursor chat, so the CLI must print the honest stderr hint before it
|
|
attaches.
|
|
|
|
:param resume_test_server: Base URL of the allow-list-free test server.
|
|
:param tmp_path: Per-test temp dir; its ``pwd`` subdir is the launch cwd.
|
|
:param request: Pytest request - reads ``--profile`` for the test server.
|
|
"""
|
|
profile = request.config.getoption("--profile")
|
|
assert profile, "this test requires --profile (e.g. --profile oss) for the test server"
|
|
|
|
pwd_dir = tmp_path / "pwd"
|
|
pwd_dir.mkdir()
|
|
|
|
omni = str(omnigent_console_script())
|
|
base = [omni, "cursor", "--server", resume_test_server]
|
|
primary = spawn_cli_background(
|
|
[*base, _FORCE_FLAG],
|
|
env=cli_env(profile=profile),
|
|
cwd=str(pwd_dir),
|
|
)
|
|
try:
|
|
conversation_id = wait_for_conversation_id(primary, timeout=_CONV_ID_TIMEOUT)
|
|
with httpx.Client(base_url=resume_test_server, timeout=30) as client:
|
|
wait_for_terminal_ready(
|
|
client,
|
|
conversation_id=conversation_id,
|
|
harness="cursor",
|
|
timeout=_TERMINAL_READY_TIMEOUT,
|
|
)
|
|
|
|
live_resume = spawn_cli_background(
|
|
[*base, "--resume", conversation_id, _FORCE_FLAG],
|
|
env=cli_env(profile=profile),
|
|
cwd=str(pwd_dir),
|
|
)
|
|
try:
|
|
_wait_for_output_contains(
|
|
live_resume,
|
|
f"/c/{conversation_id}",
|
|
timeout=_CONV_ID_TIMEOUT,
|
|
)
|
|
# The cold-resume hint is printed immediately after the Web UI line
|
|
# and before the blocking tmux attach. Wait briefly past that point
|
|
# before asserting absence, so this catches a misplaced hint on live
|
|
# reattach rather than racing ahead of it.
|
|
time.sleep(2.0)
|
|
live_output = live_resume.output()
|
|
assert _COLD_RESUME_HINT not in live_output, (
|
|
"live cursor-native resume incorrectly printed the cold-resume hint; "
|
|
"it should be a true reattach while the terminal is running."
|
|
)
|
|
finally:
|
|
live_resume.terminate()
|
|
|
|
kill_session(bridge_dir_for_session_id(conversation_id), timeout_s=30.0)
|
|
primary.terminate()
|
|
|
|
cold_resume = spawn_cli_background(
|
|
[*base, "--resume", conversation_id, _FORCE_FLAG],
|
|
env=cli_env(profile=profile),
|
|
cwd=str(pwd_dir),
|
|
)
|
|
try:
|
|
_wait_for_output_contains(
|
|
cold_resume,
|
|
_COLD_RESUME_HINT,
|
|
timeout=_CONV_ID_TIMEOUT,
|
|
)
|
|
with httpx.Client(base_url=resume_test_server, timeout=30) as client:
|
|
wait_for_terminal_ready(
|
|
client,
|
|
conversation_id=conversation_id,
|
|
harness="cursor",
|
|
timeout=_TERMINAL_READY_TIMEOUT,
|
|
)
|
|
finally:
|
|
cold_resume.terminate()
|
|
finally:
|
|
primary.terminate()
|
|
|
|
|
|
def test_cursor_native_cli_exposes_omnigent_mcp_tools(
|
|
resume_test_server: str,
|
|
tmp_path: Path,
|
|
request: pytest.FixtureRequest,
|
|
) -> None:
|
|
"""``omnigent cursor`` wires Omnigent tools into Cursor's native MCP client.
|
|
|
|
Spawns a real cursor-native session, waits for the runner-owned Cursor TUI
|
|
to start, then asks ``cursor-agent``'s own MCP subcommand to discover the
|
|
workspace-scoped ``omnigent`` server. This catches the regressions that made
|
|
Cursor-native unable to call ``sys_*`` tools: missing ``bridge.json``, a
|
|
disabled workspace MCP server, lost ``TMPDIR`` under ``python -I``, missing
|
|
auto-approval config, and stdio framing incompatibility.
|
|
|
|
:param resume_test_server: Base URL of the allow-list-free test server.
|
|
:param tmp_path: Per-test temp dir; its ``pwd`` subdir is the launch cwd.
|
|
:param request: Pytest request — reads ``--profile`` for the test server.
|
|
"""
|
|
profile = request.config.getoption("--profile")
|
|
if not profile:
|
|
pytest.skip("requires --profile (e.g. --profile oss) for the test server")
|
|
|
|
pwd_dir = tmp_path / "pwd"
|
|
pwd_dir.mkdir()
|
|
|
|
omni = str(omnigent_console_script())
|
|
env = cli_env(profile=profile)
|
|
handle = spawn_cli_background(
|
|
[omni, "cursor", "--server", resume_test_server, _FORCE_FLAG],
|
|
env=env,
|
|
cwd=str(pwd_dir),
|
|
)
|
|
try:
|
|
conversation_id = wait_for_conversation_id(handle, timeout=_CONV_ID_TIMEOUT)
|
|
with httpx.Client(base_url=resume_test_server, timeout=30) as client:
|
|
wait_for_terminal_ready(
|
|
client,
|
|
conversation_id=conversation_id,
|
|
harness="cursor",
|
|
timeout=_TERMINAL_READY_TIMEOUT,
|
|
)
|
|
|
|
bridge_dir = bridge_dir_for_session_id(conversation_id)
|
|
mcp_config_path = pwd_dir / ".cursor" / "mcp.json"
|
|
assert mcp_config_path.is_file(), "cursor-native did not write .cursor/mcp.json"
|
|
assert (bridge_dir / "bridge.json").is_file(), "serve-mcp token bridge was not written"
|
|
assert (bridge_dir / "tool_relay.json").is_file(), "Omnigent tool relay was not started"
|
|
|
|
payload = json.loads(mcp_config_path.read_text(encoding="utf-8"))
|
|
server = payload["mcpServers"]["omnigent"]
|
|
assert server["env"]["TMPDIR"]
|
|
assert "--bridge-dir" in server["args"]
|
|
assert str(bridge_dir) in server["args"]
|
|
assert "sys_session_list" in server["autoApprove"]
|
|
assert "sys_os_read" in server["autoApprove"]
|
|
|
|
cli_config_path = Path.home() / ".cursor" / "cli-config.json"
|
|
if cli_config_path.is_file():
|
|
cli_config = json.loads(cli_config_path.read_text(encoding="utf-8"))
|
|
allow = cli_config.get("permissions", {}).get("allow", [])
|
|
assert "Mcp(omnigent:sys_session_list)" in allow
|
|
assert "Mcp(omnigent:sys_os_read)" in allow
|
|
|
|
listed = subprocess.run(
|
|
["cursor-agent", "mcp", "list"],
|
|
cwd=pwd_dir,
|
|
env=env,
|
|
text=True,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
timeout=45,
|
|
check=False,
|
|
)
|
|
assert listed.returncode == 0, listed.stdout
|
|
assert "omnigent" in listed.stdout
|
|
assert "ready" in listed.stdout.lower()
|
|
|
|
tools = subprocess.run(
|
|
["cursor-agent", "mcp", "list-tools", "omnigent"],
|
|
cwd=pwd_dir,
|
|
env=env,
|
|
text=True,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
timeout=45,
|
|
check=False,
|
|
)
|
|
assert tools.returncode == 0, tools.stdout
|
|
assert "sys_session_list" in tools.stdout
|
|
assert "sys_session_send" in tools.stdout
|
|
assert "sys_os_read" in tools.stdout
|
|
finally:
|
|
handle.terminate()
|
|
|
|
|
|
def test_cursor_native_cli_mcp_can_call_sys_tool(
|
|
resume_test_server: str,
|
|
tmp_path: Path,
|
|
request: pytest.FixtureRequest,
|
|
) -> None:
|
|
"""Cursor-native's generated Omnigent MCP server can call ``sys_*`` tools.
|
|
|
|
Launches a real Cursor TUI, then calls the same generated ``.cursor/mcp.json``
|
|
Omnigent relay that Cursor uses. The tool call is direct JSON-RPC over the
|
|
generated stdio server rather than model-steered prose, so it deterministically
|
|
proves the Cursor-native MCP wiring can execute relayed ``sys_*`` tools.
|
|
"""
|
|
profile = request.config.getoption("--profile")
|
|
if not profile:
|
|
pytest.skip("requires --profile (e.g. --profile oss) for the test server")
|
|
|
|
pwd_dir = tmp_path / "pwd"
|
|
pwd_dir.mkdir()
|
|
|
|
omni = str(omnigent_console_script())
|
|
env = cli_env(profile=profile)
|
|
handle = spawn_cli_background(
|
|
[omni, "cursor", "--server", resume_test_server, _FORCE_FLAG],
|
|
env=env,
|
|
cwd=str(pwd_dir),
|
|
)
|
|
try:
|
|
conversation_id = wait_for_conversation_id(handle, timeout=_CONV_ID_TIMEOUT)
|
|
with httpx.Client(base_url=resume_test_server, timeout=30) as client:
|
|
wait_for_terminal_ready(
|
|
client,
|
|
conversation_id=conversation_id,
|
|
harness="cursor",
|
|
timeout=_TERMINAL_READY_TIMEOUT,
|
|
)
|
|
|
|
bridge_dir = bridge_dir_for_session_id(conversation_id)
|
|
mcp_config_path = pwd_dir / ".cursor" / "mcp.json"
|
|
payload = json.loads(mcp_config_path.read_text(encoding="utf-8"))
|
|
server = payload["mcpServers"]["omnigent"]
|
|
proc_env = {**os.environ, **env, **server.get("env", {})}
|
|
proc = subprocess.Popen(
|
|
[server.get("command") or sys.executable, *server["args"]],
|
|
cwd=pwd_dir,
|
|
env=proc_env,
|
|
stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
)
|
|
with httpx.Client(base_url=resume_test_server, timeout=30) as client:
|
|
try:
|
|
assert proc.stdin is not None
|
|
assert proc.stdout is not None
|
|
assert (bridge_dir / "tool_relay.json").is_file()
|
|
_write_json_line(
|
|
proc.stdin,
|
|
{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}},
|
|
)
|
|
initialize = _read_jsonrpc_response(proc.stdout, response_id=1, timeout_s=10.0)
|
|
assert initialize["id"] == 1
|
|
|
|
_write_json_line(
|
|
proc.stdin,
|
|
{
|
|
"jsonrpc": "2.0",
|
|
"id": 2,
|
|
"method": "tools/call",
|
|
"params": {
|
|
"name": "sys_session_list",
|
|
"arguments": {},
|
|
},
|
|
},
|
|
)
|
|
tool_response = _read_jsonrpc_response(
|
|
proc.stdout,
|
|
response_id=2,
|
|
timeout_s=30.0,
|
|
)
|
|
assert "error" not in tool_response, tool_response
|
|
text = tool_response["result"]["content"][0]["text"]
|
|
decoded = json.loads(text)
|
|
assert "error" not in decoded
|
|
assert "sessions" in decoded or "child_sessions" in decoded or "items" in decoded
|
|
finally:
|
|
proc.terminate()
|
|
try:
|
|
proc.wait(timeout=5.0)
|
|
except subprocess.TimeoutExpired:
|
|
proc.kill()
|
|
proc.wait(timeout=5.0)
|
|
finally:
|
|
handle.terminate()
|
|
|
|
|
|
def _poll_for_file_marker(path: Path, *, marker: str, timeout: float) -> None:
|
|
"""Poll until *path* contains *marker* (proves the approved command ran)."""
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
if marker in path.read_text(encoding="utf-8"):
|
|
return
|
|
except OSError:
|
|
pass
|
|
time.sleep(0.5)
|
|
raise AssertionError(
|
|
f"approved command did not produce {marker!r} in {path} within {timeout}s — "
|
|
"accepting the web elicitation did not drive the cursor TUI to run the command."
|
|
)
|
|
|
|
|
|
def test_cursor_native_cli_tool_approval_surfaced_as_elicitation(
|
|
resume_test_server: str,
|
|
tmp_path: Path,
|
|
request: pytest.FixtureRequest,
|
|
) -> None:
|
|
"""A cursor-native tool-approval prompt surfaces as a web elicitation card.
|
|
|
|
Unlike the other tests in this module, this launches ``omnigent cursor``
|
|
**without** the ``-f`` force/trust flag, so ``cursor-agent`` shows its own
|
|
per-tool approval prompt (the Workspace Trust gate is still auto-dismissed
|
|
by the inject path's ``_settle_pane``). It then injects (via the server,
|
|
the web-UI path) a request to run a shell command cursor cannot auto-run —
|
|
a redirect, which the Cursor backend never auto-approves — and asserts the
|
|
runner-side TUI-mirror:
|
|
|
|
1. the native prompt is surfaced to the web UI as a
|
|
``response.elicitation_request`` (it appears in
|
|
``SessionResponse.pending_elicitations``), and
|
|
2. accepting it from the web (``type == "approval"``) drives the cursor TUI
|
|
to actually run the command — the marker file appears in the launch cwd.
|
|
|
|
Without the runner-side TUI-mirror this fails at step 1: cursor blocks on
|
|
its in-terminal prompt and nothing is ever published. This is the
|
|
cursor-native analog of the codex MCP-elicitation web mirror.
|
|
|
|
:param resume_test_server: Base URL of the allow-list-free test server.
|
|
:param tmp_path: Per-test temp dir; its ``pwd`` subdir is the launch cwd.
|
|
:param request: Pytest request — reads ``--profile`` for the test server.
|
|
"""
|
|
profile = request.config.getoption("--profile")
|
|
assert profile, "this test requires --profile (e.g. --profile oss) for the test server"
|
|
|
|
pwd_dir = tmp_path / "pwd"
|
|
pwd_dir.mkdir()
|
|
marker = f"CURSOR_APPROVE_{uuid.uuid4().hex[:8].upper()}"
|
|
result_file = pwd_dir / "result.txt"
|
|
|
|
omni = str(omnigent_console_script())
|
|
# No _FORCE_FLAG: we WANT cursor's native per-tool approval prompt so the
|
|
# runner-side TUI-mirror can surface it as a web elicitation.
|
|
handle = spawn_cli_background(
|
|
[omni, "cursor", "--server", resume_test_server],
|
|
env=cli_env(profile=profile),
|
|
cwd=str(pwd_dir),
|
|
)
|
|
try:
|
|
conversation_id = wait_for_conversation_id(handle, timeout=_CONV_ID_TIMEOUT)
|
|
with httpx.Client(base_url=resume_test_server, timeout=30) as client:
|
|
wait_for_terminal_ready(
|
|
client,
|
|
conversation_id=conversation_id,
|
|
harness="cursor",
|
|
timeout=_TERMINAL_READY_TIMEOUT,
|
|
)
|
|
inject_user_message(
|
|
client,
|
|
conversation_id=conversation_id,
|
|
text=(
|
|
"Run exactly this shell command and nothing else, do not "
|
|
f"explain: echo {marker} > result.txt"
|
|
),
|
|
)
|
|
elicitation = poll_for_pending_elicitation(
|
|
client,
|
|
conversation_id=conversation_id,
|
|
timeout=_ELICITATION_TIMEOUT,
|
|
)
|
|
params = elicitation.get("params") or {}
|
|
assert isinstance(params, dict) and params.get("message"), (
|
|
f"surfaced elicitation has no message to render: {elicitation!r}"
|
|
)
|
|
elicitation_id = elicitation.get("elicitation_id")
|
|
assert isinstance(elicitation_id, str) and elicitation_id, (
|
|
f"surfaced elicitation is missing an elicitation_id: {elicitation!r}"
|
|
)
|
|
resolve_elicitation(
|
|
client,
|
|
conversation_id=conversation_id,
|
|
elicitation_id=elicitation_id,
|
|
action="accept",
|
|
)
|
|
try:
|
|
_poll_for_file_marker(result_file, marker=marker, timeout=_REPLY_TIMEOUT)
|
|
except AssertionError as exc:
|
|
raise AssertionError(
|
|
f"{exc}\n\nCLI output tail:\n{handle.output()[-2000:]}"
|
|
) from exc
|
|
finally:
|
|
handle.terminate()
|
|
|
|
|
|
def _items_text(client: httpx.Client, conversation_id: str) -> str:
|
|
"""Return the concatenated text of every item in a conversation (``""`` on error)."""
|
|
resp = client.get(
|
|
f"/v1/sessions/{conversation_id}/items", params={"limit": 100, "order": "asc"}
|
|
)
|
|
if resp.status_code != 200:
|
|
return ""
|
|
parts: list[str] = []
|
|
for item in resp.json().get("data", []):
|
|
content = item.get("content")
|
|
if isinstance(content, list):
|
|
parts.extend(b.get("text", "") for b in content if isinstance(b, dict))
|
|
elif isinstance(content, str):
|
|
parts.append(content)
|
|
return " ".join(parts)
|
|
|
|
|
|
def test_cursor_native_cli_same_cwd_launch_does_not_duplicate(
|
|
resume_test_server: str,
|
|
tmp_path: Path,
|
|
request: pytest.FixtureRequest,
|
|
) -> None:
|
|
"""A second cursor-native launch in the same cwd does not duplicate the chat.
|
|
|
|
cursor keeps one chat per working directory, so two ``omnigent cursor``
|
|
launches from the same dir discover the SAME chat store. The forwarder's
|
|
per-chat claim must let only ONE session (the earlier-launched one) mirror
|
|
it; the later session yields. A marker injected into the FIRST session must
|
|
therefore surface in its transcript but NEVER in the second's — without the
|
|
claim, both forwarders mirror the one shared chat and the second session is
|
|
a duplicate of the first. The e2e guard for the duplicate-session bug.
|
|
|
|
:param resume_test_server: Base URL of the allow-list-free test server.
|
|
:param tmp_path: Per-test temp dir; its ``pwd`` subdir is the shared cwd.
|
|
:param request: Pytest request — reads ``--profile`` for the test server.
|
|
"""
|
|
profile = request.config.getoption("--profile")
|
|
assert profile, "this test requires --profile (e.g. --profile oss) for the test server"
|
|
|
|
pwd_dir = tmp_path / "pwd"
|
|
pwd_dir.mkdir()
|
|
marker = f"DEDUP_{uuid.uuid4().hex[:8].upper()}"
|
|
|
|
omni = str(omnigent_console_script())
|
|
base = [omni, "cursor", "--server", resume_test_server, _FORCE_FLAG]
|
|
first = spawn_cli_background(base, env=cli_env(profile=profile), cwd=str(pwd_dir))
|
|
second: PtyHandle | None = None
|
|
try:
|
|
conv1 = wait_for_conversation_id(first, timeout=_CONV_ID_TIMEOUT)
|
|
with httpx.Client(base_url=resume_test_server, timeout=30) as client:
|
|
wait_for_terminal_ready(
|
|
client, conversation_id=conv1, harness="cursor", timeout=_TERMINAL_READY_TIMEOUT
|
|
)
|
|
# Second launch in the SAME cwd → a distinct conversation, same chat.
|
|
second = spawn_cli_background(base, env=cli_env(profile=profile), cwd=str(pwd_dir))
|
|
conv2 = wait_for_conversation_id(second, timeout=_CONV_ID_TIMEOUT)
|
|
assert conv2 != conv1, "second launch reused the first conversation id"
|
|
wait_for_terminal_ready(
|
|
client, conversation_id=conv2, harness="cursor", timeout=_TERMINAL_READY_TIMEOUT
|
|
)
|
|
|
|
inject_user_message(
|
|
client,
|
|
conversation_id=conv1,
|
|
text=f"Reply with ONLY this exact word and nothing else: {marker}",
|
|
)
|
|
|
|
# The marker must reach the FIRST session's transcript (mirroring works)…
|
|
deadline = time.monotonic() + _REPLY_TIMEOUT
|
|
while time.monotonic() < deadline:
|
|
if marker in _items_text(client, conv1):
|
|
break
|
|
time.sleep(2.0)
|
|
else:
|
|
raise AssertionError(
|
|
f"marker {marker!r} never reached the first session {conv1}; the "
|
|
f"forwarder did not mirror it.\n\nCLI tail:\n{first.output()[-1500:]}"
|
|
)
|
|
|
|
# …and must NEVER appear in the SECOND same-cwd session. Poll for a
|
|
# while so a wrongly-mirroring second forwarder has every chance to
|
|
# surface it before we conclude it correctly yielded.
|
|
for _ in range(10):
|
|
assert marker not in _items_text(client, conv2), (
|
|
f"marker {marker!r} from session {conv1} also appeared in the "
|
|
f"second same-cwd session {conv2} — both forwarders mirrored the "
|
|
"one cursor chat (the duplicate-session bug)."
|
|
)
|
|
time.sleep(2.0)
|
|
finally:
|
|
if second is not None:
|
|
second.terminate()
|
|
first.terminate()
|