From a5eadee351d5bad17af2c8701bf469d2ff9b9d30 Mon Sep 17 00:00:00 2001 From: Jeremy Eder Date: Tue, 21 Apr 2026 17:53:31 +0000 Subject: [PATCH 1/6] feat(runner): add Cursor CLI as a new runner provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds full support for Cursor CLI as a third runner provider alongside Claude Code and Gemini CLI. The implementation follows the established bridge/adapter pattern and is feature-gated behind `runner.cursor-cli.enabled`. ## What's included ### New packages - `ag_ui_cursor_cli/` — NDJSON parser (`types.py`, `parse_event()`), AG-UI protocol adapter (`adapter.py`, `CursorCLIAdapter`), re-exported utils - `ambient_runner/bridges/cursor_cli/` — `CursorCLIBridge` (PlatformBridge subclass), `CursorSessionWorker`/`CursorSessionManager` (async subprocess management with TTL eviction and session-ID persistence), auth helper, MCP config writer ### Infrastructure changes - `agent-registry-configmap.yaml` — Cursor CLI registry entry with feature gate - `flags.json` — `runner.cursor-cli.enabled` flag (workspace-scoped) - `models.json` — Cursor provider defaults and four cursor model entries - `Dockerfile` — Cursor CLI install block (pass-through version; pinning not supported by upstream installer) - `runner-tool-versions.yml` — Cursor CLI version tracking with explanatory comments ### Design notes - Cursor CLI `stream-json` event format is documented-but-unverified; `parse_event()` includes a NOTE comment - `CURSOR_CLI_VERSION` ARG is documentation-only; the upstream curl installer does not accept a version argument - Session IDs are persisted across pod restarts via `cursor_session_ids.json` - Env var blocklist strips platform secrets before spawning the `agent` binary Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/runner-tool-versions.yml | 29 +- .../base/core/agent-registry-configmap.yaml | 29 ++ components/manifests/base/core/flags.json | 10 + components/manifests/base/core/models.json | 35 +- components/runners/ambient-runner/Dockerfile | 14 + .../ag_ui_cursor_cli/__init__.py | 6 + .../ag_ui_cursor_cli/adapter.py | 246 ++++++++++++ .../ambient-runner/ag_ui_cursor_cli/config.py | 4 + .../ambient-runner/ag_ui_cursor_cli/types.py | 131 +++++++ .../ambient-runner/ag_ui_cursor_cli/utils.py | 8 + .../bridges/cursor_cli/__init__.py | 6 + .../ambient_runner/bridges/cursor_cli/auth.py | 37 ++ .../bridges/cursor_cli/bridge.py | 239 ++++++++++++ .../ambient_runner/bridges/cursor_cli/mcp.py | 67 ++++ .../bridges/cursor_cli/session.py | 354 ++++++++++++++++++ components/runners/ambient-runner/main.py | 1 + .../runners/ambient-runner/pyproject.toml | 2 +- 17 files changed, 1215 insertions(+), 3 deletions(-) mode change 100644 => 100755 .github/workflows/runner-tool-versions.yml mode change 100644 => 100755 components/manifests/base/core/agent-registry-configmap.yaml mode change 100644 => 100755 components/manifests/base/core/flags.json create mode 100644 components/runners/ambient-runner/ag_ui_cursor_cli/__init__.py create mode 100644 components/runners/ambient-runner/ag_ui_cursor_cli/adapter.py create mode 100644 components/runners/ambient-runner/ag_ui_cursor_cli/config.py create mode 100644 components/runners/ambient-runner/ag_ui_cursor_cli/types.py create mode 100644 components/runners/ambient-runner/ag_ui_cursor_cli/utils.py create mode 100644 components/runners/ambient-runner/ambient_runner/bridges/cursor_cli/__init__.py create mode 100644 components/runners/ambient-runner/ambient_runner/bridges/cursor_cli/auth.py create mode 100644 components/runners/ambient-runner/ambient_runner/bridges/cursor_cli/bridge.py create mode 100644 components/runners/ambient-runner/ambient_runner/bridges/cursor_cli/mcp.py create mode 100644 components/runners/ambient-runner/ambient_runner/bridges/cursor_cli/session.py mode change 100644 => 100755 components/runners/ambient-runner/main.py mode change 100644 => 100755 components/runners/ambient-runner/pyproject.toml diff --git a/.github/workflows/runner-tool-versions.yml b/.github/workflows/runner-tool-versions.yml old mode 100644 new mode 100755 index 8b2045593..01e91db4c --- a/.github/workflows/runner-tool-versions.yml +++ b/.github/workflows/runner-tool-versions.yml @@ -43,6 +43,9 @@ jobs: echo "uv=$(parse UV_VERSION)" >> "$GITHUB_OUTPUT" echo "pre_commit=$(parse PRE_COMMIT_VERSION)" >> "$GITHUB_OUTPUT" echo "gemini_cli=$(parse GEMINI_CLI_VERSION)" >> "$GITHUB_OUTPUT" + # Cursor CLI: ARG is documentation-only (installer fetches latest). + # Parsed here for visibility in the PR summary table. + echo "cursor_cli=$(parse CURSOR_CLI_VERSION)" >> "$GITHUB_OUTPUT" # ── Fetch latest upstream versions ──────────────────────────────── @@ -102,6 +105,11 @@ jobs: "https://registry.npmjs.org/@google/gemini-cli/latest" \ '.version' & pids+=($!); names+=("gemini-cli") + # Cursor CLI: no package registry — pass through the current pinned value. + # The installer (cursor.com/install) fetches "latest" automatically; automated + # version bumps are not yet possible. Update CURSOR_CLI_VERSION manually once + # Cursor publishes versioned binary releases. + echo "cursor_cli=${{ steps.current.outputs.cursor_cli }}" >> "$GITHUB_OUTPUT" failed=false for i in "${!pids[@]}"; do @@ -129,6 +137,8 @@ jobs: LAT_PC: ${{ steps.latest.outputs.pre_commit }} CUR_GEM: ${{ steps.current.outputs.gemini_cli }} LAT_GEM: ${{ steps.latest.outputs.gemini_cli }} + CUR_CURSOR: ${{ steps.current.outputs.cursor_cli }} + LAT_CURSOR: ${{ steps.latest.outputs.cursor_cli }} run: | needs_update=false updates="" @@ -162,6 +172,10 @@ jobs: check_tool uv "$CUR_UV" "$LAT_UV" check_tool pc "$CUR_PC" "$LAT_PC" check_tool gem "$CUR_GEM" "$LAT_GEM" + # Cursor CLI: latest always equals current (pass-through) — check_tool + # will always emit cursor=false. Included for future use when Cursor + # publishes a queryable version registry. + check_tool cursor "$CUR_CURSOR" "$LAT_CURSOR" echo "any=$needs_update" >> "$GITHUB_OUTPUT" echo "updates=$updates" >> "$GITHUB_OUTPUT" @@ -184,12 +198,15 @@ jobs: LAT_PC: ${{ steps.latest.outputs.pre_commit }} CUR_GEM: ${{ steps.current.outputs.gemini_cli }} LAT_GEM: ${{ steps.latest.outputs.gemini_cli }} + CUR_CURSOR: ${{ steps.current.outputs.cursor_cli }} + LAT_CURSOR: ${{ steps.latest.outputs.cursor_cli }} UPD_BASE: ${{ steps.diff.outputs.base }} UPD_GH: ${{ steps.diff.outputs.gh }} UPD_GLAB: ${{ steps.diff.outputs.glab }} UPD_UV: ${{ steps.diff.outputs.uv }} UPD_PC: ${{ steps.diff.outputs.pc }} UPD_GEM: ${{ steps.diff.outputs.gem }} + UPD_CURSOR: ${{ steps.diff.outputs.cursor }} run: | # Build sed expressions for all components that need updating SED_ARGS=() @@ -199,12 +216,17 @@ jobs: [ "$UPD_UV" = "true" ] && SED_ARGS+=(-e "s/ARG UV_VERSION=${CUR_UV}/ARG UV_VERSION=${LAT_UV}/") [ "$UPD_PC" = "true" ] && SED_ARGS+=(-e "s/ARG PRE_COMMIT_VERSION=${CUR_PC}/ARG PRE_COMMIT_VERSION=${LAT_PC}/") [ "$UPD_GEM" = "true" ] && SED_ARGS+=(-e "s/ARG GEMINI_CLI_VERSION=${CUR_GEM}/ARG GEMINI_CLI_VERSION=${LAT_GEM}/") + # Cursor CLI: UPD_CURSOR will always be false (pass-through); included for + # future use when Cursor publishes a queryable release registry. + [ "$UPD_CURSOR" = "true" ] && SED_ARGS+=(-e "s/ARG CURSOR_CLI_VERSION=${CUR_CURSOR}/ARG CURSOR_CLI_VERSION=${LAT_CURSOR}/") if [ ${#SED_ARGS[@]} -gt 0 ]; then sed -i "${SED_ARGS[@]}" "$DOCKERFILE" fi - # Sanity check: all ARGs still present and non-empty + # Sanity check: all version-controlled ARGs still present and non-empty. + # CURSOR_CLI_VERSION is intentionally excluded — it is a documentation-only + # placeholder and does not control what the installer fetches. for arg in GH_VERSION GLAB_VERSION UV_VERSION PRE_COMMIT_VERSION GEMINI_CLI_VERSION; do if ! grep -qP "ARG ${arg}=\S+" "$DOCKERFILE"; then echo "ERROR: ${arg} missing or empty after update" @@ -247,6 +269,7 @@ jobs: LAT_PC: ${{ steps.latest.outputs.pre_commit }} CUR_GEM: ${{ steps.current.outputs.gemini_cli }} LAT_GEM: ${{ steps.latest.outputs.gemini_cli }} + CUR_CURSOR: ${{ steps.current.outputs.cursor_cli }} UPD_BASE: ${{ steps.diff.outputs.base }} UPD_GH: ${{ steps.diff.outputs.gh }} UPD_GLAB: ${{ steps.diff.outputs.glab }} @@ -270,6 +293,7 @@ jobs: [ "$UPD_UV" = "true" ] && CHANGED="${CHANGED}uv " [ "$UPD_PC" = "true" ] && CHANGED="${CHANGED}pre-commit " [ "$UPD_GEM" = "true" ] && CHANGED="${CHANGED}gemini-cli " + # cursor-cli is never updated automatically (pass-through); omit from title CHANGED=$(echo "$CHANGED" | xargs | tr ' ' ', ') git commit -m "chore(runner): update ${CHANGED} @@ -293,6 +317,7 @@ jobs: | uv | $(status "$UPD_UV" "$CUR_UV" "$LAT_UV") | | pre-commit | $(status "$UPD_PC" "$CUR_PC" "$LAT_PC") | | gemini-cli | $(status "$UPD_GEM" "$CUR_GEM" "$LAT_GEM") | + | cursor-cli | \`${CUR_CURSOR}\` (installer fetches latest — no auto-bump) | ### Components not version-pinned (updated with base image) @@ -333,6 +358,7 @@ jobs: LAT_PC: ${{ steps.latest.outputs.pre_commit }} CUR_GEM: ${{ steps.current.outputs.gemini_cli }} LAT_GEM: ${{ steps.latest.outputs.gemini_cli }} + CUR_CURSOR: ${{ steps.current.outputs.cursor_cli }} ANY_UPDATE: ${{ steps.diff.outputs.any }} PR_EXISTS: ${{ steps.existing_pr.outputs.pr_exists || 'false' }} run: | @@ -349,6 +375,7 @@ jobs: echo "| uv | \`${CUR_UV}\` | \`${LAT_UV}\` | $(icon "$CUR_UV" "$LAT_UV") |" echo "| pre-commit | \`${CUR_PC}\` | \`${LAT_PC}\` | $(icon "$CUR_PC" "$LAT_PC") |" echo "| gemini-cli | \`${CUR_GEM}\` | \`${LAT_GEM}\` | $(icon "$CUR_GEM" "$LAT_GEM") |" + echo "| cursor-cli | \`${CUR_CURSOR}\` | (installer fetches latest) | ℹ️ |" echo "" if [ "$ANY_UPDATE" = "true" ]; then if [ "$PR_EXISTS" = "true" ]; then diff --git a/components/manifests/base/core/agent-registry-configmap.yaml b/components/manifests/base/core/agent-registry-configmap.yaml old mode 100644 new mode 100755 index 228ccb38f..ddf401e9a --- a/components/manifests/base/core/agent-registry-configmap.yaml +++ b/components/manifests/base/core/agent-registry-configmap.yaml @@ -66,5 +66,34 @@ data: "vertexSupported": true }, "featureGate": "runner.gemini-cli.enabled" + }, + { + "id": "cursor-cli", + "displayName": "Cursor CLI", + "description": "Cursor coding agent with multi-model support and built-in file, bash, and search tools", + "framework": "cursor-cli", + "provider": "cursor", + "container": { + "image": "quay.io/ambient_code/vteam_claude_runner:latest", + "port": 8001, + "env": { + "RUNNER_TYPE": "cursor-cli", + "RUNNER_STATE_DIR": ".cursor" + } + }, + "sandbox": { + "stateDir": ".cursor", + "stateSyncImage": "quay.io/ambient_code/vteam_state_sync:latest", + "persistence": "s3", + "workspaceSize": "10Gi", + "terminationGracePeriod": 60, + "seed": {"cloneRepos": true, "hydrateState": true} + }, + "auth": { + "requiredSecretKeys": ["CURSOR_API_KEY"], + "secretKeyLogic": "any", + "vertexSupported": false + }, + "featureGate": "runner.cursor-cli.enabled" } ] diff --git a/components/manifests/base/core/flags.json b/components/manifests/base/core/flags.json old mode 100644 new mode 100755 index 43e169fcb..367ca085f --- a/components/manifests/base/core/flags.json +++ b/components/manifests/base/core/flags.json @@ -1,5 +1,15 @@ { "flags": [ + { + "name": "runner.cursor-cli.enabled", + "description": "Enable Cursor CLI runner type for session creation", + "tags": [ + { + "type": "scope", + "value": "workspace" + } + ] + }, { "name": "runner.gemini-cli.enabled", "description": "Enable Gemini CLI runner type for session creation", diff --git a/components/manifests/base/core/models.json b/components/manifests/base/core/models.json index 31f75d572..94b1f80b5 100755 --- a/components/manifests/base/core/models.json +++ b/components/manifests/base/core/models.json @@ -3,7 +3,8 @@ "defaultModel": "claude-sonnet-4-6", "providerDefaults": { "anthropic": "claude-sonnet-4-6", - "google": "gemini-2.5-flash" + "google": "gemini-2.5-flash", + "cursor": "claude-sonnet-4-6" }, "models": [ { @@ -141,6 +142,38 @@ "provider": "google", "available": true, "featureGated": true + }, + { + "id": "cursor:claude-sonnet-4-6", + "label": "Claude Sonnet 4.6 (via Cursor)", + "vertexId": "", + "provider": "cursor", + "available": true, + "featureGated": false + }, + { + "id": "cursor:claude-opus-4-6", + "label": "Claude Opus 4.6 (via Cursor)", + "vertexId": "", + "provider": "cursor", + "available": true, + "featureGated": true + }, + { + "id": "cursor:gpt-5", + "label": "GPT-5 (via Cursor)", + "vertexId": "", + "provider": "cursor", + "available": false, + "featureGated": true + }, + { + "id": "cursor:composer", + "label": "Composer 1 (Cursor)", + "vertexId": "", + "provider": "cursor", + "available": true, + "featureGated": false } ] } diff --git a/components/runners/ambient-runner/Dockerfile b/components/runners/ambient-runner/Dockerfile index 6a2817b35..cf0bc6cf9 100755 --- a/components/runners/ambient-runner/Dockerfile +++ b/components/runners/ambient-runner/Dockerfile @@ -10,6 +10,11 @@ ARG GLAB_VERSION=1.52.0 ARG UV_VERSION=0.7.8 ARG PRE_COMMIT_VERSION=4.2.0 ARG GEMINI_CLI_VERSION=0.1.17 +# NOTE: CURSOR_CLI_VERSION is a documentation-only placeholder. The official +# installer (cursor.com/install) fetches "latest" automatically and does not +# accept a version argument. Switch to a pinned binary URL once Cursor +# publishes versioned releases. Not included in runner-tool-versions sanity check. +ARG CURSOR_CLI_VERSION=1.0.0 # Install system packages: Python 3.12, git, jq, Node.js, Go RUN dnf install -y python3 python3-pip python3-devel \ @@ -43,6 +48,15 @@ RUN pip3 install --break-system-packages --no-cache-dir '/app/ambient-runner[all RUN npm install -g @google/gemini-cli@${GEMINI_CLI_VERSION} && \ npm cache clean --force +# Install Cursor CLI via official installer. +# The binary is placed at ~/.local/bin/agent; symlink to /usr/local/bin/agent +# so it is in PATH for all users (including the non-root UID 1001 runtime user). +RUN curl -fsSL "https://cursor.com/install" -o /tmp/cursor-install.sh && \ + HOME=/root bash /tmp/cursor-install.sh && \ + rm /tmp/cursor-install.sh && \ + chmod +x /root/.local/bin/agent && \ + ln -sf /root/.local/bin/agent /usr/local/bin/agent + # Set environment variables ENV PYTHONUNBUFFERED=1 ENV PYTHONDONTWRITEBYTECODE=1 diff --git a/components/runners/ambient-runner/ag_ui_cursor_cli/__init__.py b/components/runners/ambient-runner/ag_ui_cursor_cli/__init__.py new file mode 100644 index 000000000..6ed28f122 --- /dev/null +++ b/components/runners/ambient-runner/ag_ui_cursor_cli/__init__.py @@ -0,0 +1,6 @@ +# components/runners/ambient-runner/ag_ui_cursor_cli/__init__.py +"""AG-UI adapter for Cursor CLI stream-json output.""" + +from ag_ui_cursor_cli.adapter import CursorCLIAdapter + +__all__ = ["CursorCLIAdapter"] diff --git a/components/runners/ambient-runner/ag_ui_cursor_cli/adapter.py b/components/runners/ambient-runner/ag_ui_cursor_cli/adapter.py new file mode 100644 index 000000000..b5e0f70b5 --- /dev/null +++ b/components/runners/ambient-runner/ag_ui_cursor_cli/adapter.py @@ -0,0 +1,246 @@ +# components/runners/ambient-runner/ag_ui_cursor_cli/adapter.py +"""Cursor CLI adapter for AG-UI protocol. + +Translates Cursor CLI stream-json output into AG-UI protocol events. +""" + +import logging +import uuid +from typing import AsyncIterator + +from ag_ui.core import ( + AssistantMessage as AguiAssistantMessage, + BaseEvent, + EventType, + MessagesSnapshotEvent, + RunAgentInput, + RunErrorEvent, + RunFinishedEvent, + RunStartedEvent, + TextMessageContentEvent, + TextMessageEndEvent, + TextMessageStartEvent, + ToolCallArgsEvent, + ToolCallEndEvent, + ToolCallResultEvent, + ToolCallStartEvent as AguiToolCallStartEvent, +) + +from .types import ( + InitEvent, + MessageEvent, + ResultEvent, + ToolCallCompletedEvent, + ToolCallStartEvent, + parse_event, +) + +logger = logging.getLogger(__name__) + + +class CursorCLIAdapter: + """Adapter that translates Cursor CLI stream-json to AG-UI events. + + Receives an AsyncIterator[str] of NDJSON lines from the Cursor CLI + process and yields AG-UI BaseEvent instances. + """ + + async def _flush_text_message( + self, + message_id: str, + accumulated_text: str, + run_messages: list[AguiAssistantMessage], + ) -> AsyncIterator[BaseEvent]: + """Yield TextMessageEndEvent and record the completed message.""" + yield TextMessageEndEvent( + type=EventType.TEXT_MESSAGE_END, + message_id=message_id, + ) + if accumulated_text: + run_messages.append( + AguiAssistantMessage( + id=message_id, + role="assistant", + content=accumulated_text, + ) + ) + + async def run( + self, + input_data: RunAgentInput, + *, + line_stream: AsyncIterator[str], + ) -> AsyncIterator[BaseEvent]: + thread_id = input_data.thread_id or str(uuid.uuid4()) + run_id = input_data.run_id or str(uuid.uuid4()) + + text_message_open = False + current_message_id: str | None = None + accumulated_text = "" + current_tool_call_id: str | None = None + run_messages: list[AguiAssistantMessage] = [] + + try: + yield RunStartedEvent( + type=EventType.RUN_STARTED, + thread_id=thread_id, + run_id=run_id, + ) + + async for line in line_stream: + event = parse_event(line) + if event is None: + logger.debug("Cursor CLI: unparseable line: %s", line[:200]) + continue + + # ── init ── + if isinstance(event, InitEvent): + logger.debug( + "Cursor CLI init: session_id=%s model=%s", + event.session_id, + event.model, + ) + continue + + # ── assistant message ── + if isinstance(event, MessageEvent): + if event.type == "user": + # user events are yielded by Cursor for echo purposes; skip + continue + + if not text_message_open: + current_message_id = str(uuid.uuid4()) + yield TextMessageStartEvent( + type=EventType.TEXT_MESSAGE_START, + message_id=current_message_id, + role="assistant", + ) + text_message_open = True + accumulated_text = "" + + if event.content: + accumulated_text += event.content + yield TextMessageContentEvent( + type=EventType.TEXT_MESSAGE_CONTENT, + message_id=current_message_id, + delta=event.content, + ) + + if not event.delta and text_message_open: + async for ev in self._flush_text_message( + current_message_id, accumulated_text, run_messages + ): + yield ev + text_message_open = False + current_message_id = None + accumulated_text = "" + continue + + # ── tool_call started ── + if isinstance(event, ToolCallStartEvent): + if text_message_open and current_message_id: + async for ev in self._flush_text_message( + current_message_id, accumulated_text, run_messages + ): + yield ev + text_message_open = False + current_message_id = None + accumulated_text = "" + + current_tool_call_id = event.tool_id or str(uuid.uuid4()) + yield AguiToolCallStartEvent( + type=EventType.TOOL_CALL_START, + tool_call_id=current_tool_call_id, + tool_call_name=event.tool_name, + ) + yield ToolCallArgsEvent( + type=EventType.TOOL_CALL_ARGS, + tool_call_id=current_tool_call_id, + delta=event.arguments or "{}", + ) + continue + + # ── tool_call completed ── + if isinstance(event, ToolCallCompletedEvent): + tid = event.tool_id or current_tool_call_id + if tid: + yield ToolCallEndEvent( + type=EventType.TOOL_CALL_END, + tool_call_id=tid, + ) + result_content = event.error if event.error else (event.output or "(completed)") + yield ToolCallResultEvent( + type=EventType.TOOL_CALL_RESULT, + tool_call_id=tid, + message_id=f"{tid}-result", + role="tool", + content=result_content, + ) + current_tool_call_id = None + continue + + # ── result ── + if isinstance(event, ResultEvent): + if text_message_open and current_message_id: + async for ev in self._flush_text_message( + current_message_id, accumulated_text, run_messages + ): + yield ev + text_message_open = False + current_message_id = None + accumulated_text = "" + + if event.is_error or event.subtype == "error": + yield RunErrorEvent( + type=EventType.RUN_ERROR, + thread_id=thread_id, + run_id=run_id, + message=event.result or "Cursor CLI run failed", + ) + return + + break + + all_messages = list(input_data.messages or []) + run_messages + if all_messages: + yield MessagesSnapshotEvent( + type=EventType.MESSAGES_SNAPSHOT, + messages=all_messages, + ) + + yield RunFinishedEvent( + type=EventType.RUN_FINISHED, + thread_id=thread_id, + run_id=run_id, + ) + + except Exception as exc: + logger.error("Error in Cursor CLI adapter run: %s", exc) + if text_message_open and current_message_id: + try: + yield TextMessageEndEvent( + type=EventType.TEXT_MESSAGE_END, + message_id=current_message_id, + ) + except Exception: + pass + text_message_open = False + + yield RunErrorEvent( + type=EventType.RUN_ERROR, + thread_id=thread_id, + run_id=run_id, + message=str(exc), + ) + finally: + # Handles asyncio.CancelledError (BaseException, not caught by `except + # Exception`) — ensures any open text message is always closed on + # generator cleanup. + if text_message_open and current_message_id: + try: + yield TextMessageEndEvent( + type=EventType.TEXT_MESSAGE_END, + message_id=current_message_id, + ) + except Exception: + pass diff --git a/components/runners/ambient-runner/ag_ui_cursor_cli/config.py b/components/runners/ambient-runner/ag_ui_cursor_cli/config.py new file mode 100644 index 000000000..2218ce431 --- /dev/null +++ b/components/runners/ambient-runner/ag_ui_cursor_cli/config.py @@ -0,0 +1,4 @@ +# components/runners/ambient-runner/ag_ui_cursor_cli/config.py +"""Configuration constants for Cursor CLI adapter.""" + +DEFAULT_MODEL = "claude-sonnet-4-6" diff --git a/components/runners/ambient-runner/ag_ui_cursor_cli/types.py b/components/runners/ambient-runner/ag_ui_cursor_cli/types.py new file mode 100644 index 000000000..460d40bba --- /dev/null +++ b/components/runners/ambient-runner/ag_ui_cursor_cli/types.py @@ -0,0 +1,131 @@ +# components/runners/ambient-runner/ag_ui_cursor_cli/types.py +"""Dataclasses for Cursor CLI stream-json event types.""" + +import json +import logging +from dataclasses import dataclass + +logger = logging.getLogger(__name__) + + +@dataclass +class InitEvent: + type: str # "system" + subtype: str # "init" + session_id: str = "" + model: str = "" + + +@dataclass +class MessageEvent: + type: str # "assistant" or "user" + content: str = "" + delta: bool = False + + +@dataclass +class ToolCallStartEvent: + type: str # "tool_call" + subtype: str # "started" + tool_id: str = "" + tool_name: str = "" + arguments: str = "" + + +@dataclass +class ToolCallCompletedEvent: + type: str # "tool_call" + subtype: str # "completed" + tool_id: str = "" + output: str = "" + error: str = "" + + +@dataclass +class ResultEvent: + type: str # "result" + subtype: str # "success" or "error" + result: str = "" + session_id: str = "" + duration_ms: int = 0 + is_error: bool = False + + +def parse_event( + line: str, +) -> InitEvent | MessageEvent | ToolCallStartEvent | ToolCallCompletedEvent | ResultEvent | None: + """Parse a JSON line into the appropriate event dataclass. + + Returns None when the line cannot be parsed or has an unknown type. + + NOTE: The Cursor CLI stream-json event format is based on documentation + research and may differ from actual CLI output. Validate against a real + Cursor CLI binary before relying on this in production. + """ + try: + data = json.loads(line) + except json.JSONDecodeError: + logger.warning("Failed to parse JSONL line: %s", line[:120]) + return None + + event_type = data.get("type") + subtype = data.get("subtype", "") + + if event_type == "system" and subtype == "init": + return InitEvent( + type=event_type, + subtype=subtype, + session_id=data.get("session_id", ""), + model=data.get("model", ""), + ) + + if event_type == "assistant": + msg = data.get("message", {}) + content = msg.get("content", "") if isinstance(msg, dict) else "" + return MessageEvent( + type=event_type, + content=content, + delta=data.get("delta", False), + ) + + if event_type == "user": + msg = data.get("message", {}) + content = msg.get("content", "") if isinstance(msg, dict) else "" + return MessageEvent( + type=event_type, + content=content, + delta=False, + ) + + if event_type == "tool_call" and subtype == "started": + tc = data.get("tool_call", {}) + func = tc.get("function", {}) + return ToolCallStartEvent( + type=event_type, + subtype=subtype, + tool_id=data.get("id", ""), + tool_name=func.get("name", ""), + arguments=func.get("arguments", ""), + ) + + if event_type == "tool_call" and subtype == "completed": + return ToolCallCompletedEvent( + type=event_type, + subtype=subtype, + tool_id=data.get("id", ""), + output=data.get("output", ""), + error=data.get("error", ""), + ) + + if event_type == "result": + return ResultEvent( + type=event_type, + subtype=subtype, + result=data.get("result", ""), + session_id=data.get("session_id", ""), + duration_ms=data.get("duration_ms", 0), + is_error=data.get("is_error", False), + ) + + logger.debug("Unknown Cursor CLI event: type=%s subtype=%s", event_type, subtype) + return None diff --git a/components/runners/ambient-runner/ag_ui_cursor_cli/utils.py b/components/runners/ambient-runner/ag_ui_cursor_cli/utils.py new file mode 100644 index 000000000..450c9a621 --- /dev/null +++ b/components/runners/ambient-runner/ag_ui_cursor_cli/utils.py @@ -0,0 +1,8 @@ +# components/runners/ambient-runner/ag_ui_cursor_cli/utils.py +"""Utility functions for Cursor CLI adapter.""" + +# extract_user_message is identical across adapters — re-export from the +# shared gemini utils rather than duplicating. +from ag_ui_gemini_cli.utils import extract_user_message + +__all__ = ["extract_user_message"] diff --git a/components/runners/ambient-runner/ambient_runner/bridges/cursor_cli/__init__.py b/components/runners/ambient-runner/ambient_runner/bridges/cursor_cli/__init__.py new file mode 100644 index 000000000..1c291fcd5 --- /dev/null +++ b/components/runners/ambient-runner/ambient_runner/bridges/cursor_cli/__init__.py @@ -0,0 +1,6 @@ +# components/runners/ambient-runner/ambient_runner/bridges/cursor_cli/__init__.py +"""Cursor CLI bridge for the Ambient Runner.""" + +from ambient_runner.bridges.cursor_cli.bridge import CursorCLIBridge + +__all__ = ["CursorCLIBridge"] diff --git a/components/runners/ambient-runner/ambient_runner/bridges/cursor_cli/auth.py b/components/runners/ambient-runner/ambient_runner/bridges/cursor_cli/auth.py new file mode 100644 index 000000000..789ef03c9 --- /dev/null +++ b/components/runners/ambient-runner/ambient_runner/bridges/cursor_cli/auth.py @@ -0,0 +1,37 @@ +# components/runners/ambient-runner/ambient_runner/bridges/cursor_cli/auth.py +"""Cursor CLI authentication — API key setup.""" + +import logging + +from ambient_runner.platform.context import RunnerContext + +logger = logging.getLogger(__name__) + + +async def setup_cursor_cli_auth(context: RunnerContext) -> tuple[str, str]: + """Set up Cursor CLI authentication from environment. + + Cursor CLI authenticates via CURSOR_API_KEY env var. + No Vertex AI support — Cursor routes through its own subscription. + + Returns: + (model, api_key) + """ + from ag_ui_cursor_cli.config import DEFAULT_MODEL + + model = context.get_env("LLM_MODEL", DEFAULT_MODEL).strip() + + # Strip the "cursor:" prefix if present — the registry uses + # namespaced model IDs (e.g., "cursor:claude-sonnet-4-6") but the + # CLI --model flag expects the raw name ("claude-sonnet-4-6"). + if model.startswith("cursor:"): + model = model[len("cursor:"):] + + api_key = context.get_env("CURSOR_API_KEY", "").strip() + + if api_key: + logger.info("Cursor CLI: using API key (model=%s)", model) + else: + logger.warning("Cursor CLI: no CURSOR_API_KEY set — CLI may fail") + + return model, api_key diff --git a/components/runners/ambient-runner/ambient_runner/bridges/cursor_cli/bridge.py b/components/runners/ambient-runner/ambient_runner/bridges/cursor_cli/bridge.py new file mode 100644 index 000000000..bf45d2062 --- /dev/null +++ b/components/runners/ambient-runner/ambient_runner/bridges/cursor_cli/bridge.py @@ -0,0 +1,239 @@ +# components/runners/ambient-runner/ambient_runner/bridges/cursor_cli/bridge.py +"""CursorCLIBridge -- full-lifecycle PlatformBridge for the Cursor CLI. + +Owns the Cursor CLI session lifecycle: +- Platform setup (auth, workspace, observability) +- Adapter creation +- Session worker management (one invocation per turn) +- Tracing middleware integration +- Interrupt and graceful shutdown +""" + +import asyncio +import json +import logging +import os +import time +from pathlib import Path +from typing import Any, AsyncIterator + +from ag_ui.core import BaseEvent, RunAgentInput +from ag_ui_cursor_cli import CursorCLIAdapter +from ag_ui_cursor_cli.types import InitEvent, parse_event +from ag_ui_cursor_cli.utils import extract_user_message + +from ambient_runner.bridge import ( + FrameworkCapabilities, + PlatformBridge, + _async_safe_manager_shutdown, + setup_bridge_observability, +) +from ambient_runner.bridges.cursor_cli.session import ( + SHUTDOWN_TIMEOUT_SEC, + CursorSessionManager, +) +from ambient_runner.platform.context import RunnerContext + +logger = logging.getLogger(__name__) + + +class CursorCLIBridge(PlatformBridge): + """Bridge between the Ambient platform and the Cursor CLI.""" + + def __init__(self) -> None: + super().__init__() + self._session_manager: CursorSessionManager | None = None + self._adapter: CursorCLIAdapter | None = None + self._obs: Any = None + + self._configured_model: str = "" + self._api_key: str = "" + self._cwd_path: str = "" + self._mcp_settings_path: str | None = None + self._mcp_status_cache: dict | None = None + + def capabilities(self) -> FrameworkCapabilities: + has_tracing = ( + self._obs is not None + and hasattr(self._obs, "langfuse_client") + and self._obs.langfuse_client is not None + ) + return FrameworkCapabilities( + framework="cursor-cli", + agent_features=["agentic_chat", "backend_tool_rendering"], + file_system=True, + mcp=True, + tracing="langfuse" if has_tracing else None, + ) + + async def run(self, input_data: RunAgentInput, **kwargs) -> AsyncIterator[BaseEvent]: + await self._ensure_ready() + await self._refresh_credentials_if_stale() + + user_msg = extract_user_message(input_data) + + thread_id = input_data.thread_id or self._context.session_id + worker = self._session_manager.get_or_create_worker( + thread_id, + model=self._configured_model, + api_key=self._api_key, + cwd=self._cwd_path, + ) + + session_id = self._session_manager.get_session_id(thread_id) + + async def _line_stream_with_capture(): + async for line in worker.query(user_msg, session_id=session_id): + event = parse_event(line) + if isinstance(event, InitEvent) and event.session_id: + self._session_manager.set_session_id(thread_id, event.session_id) + yield line + + async with self._session_manager.get_lock(thread_id): + from ambient_runner.middleware import ( + secret_redaction_middleware, + tracing_middleware, + ) + + wrapped_stream = tracing_middleware( + secret_redaction_middleware( + self._adapter.run(input_data, line_stream=_line_stream_with_capture()), + ), + obs=self._obs, + model=self._configured_model, + prompt=user_msg, + ) + + async for event in wrapped_stream: + yield event + + async def interrupt(self, thread_id: str | None = None) -> None: + if not self._session_manager: + raise RuntimeError("No active session manager") + + tid = thread_id or (self._context.session_id if self._context else None) + if not tid: + raise RuntimeError("No thread_id available") + + logger.info("Interrupt request for thread=%s", tid) + await self._session_manager.interrupt(tid) + + async def shutdown(self) -> None: + if self._session_manager: + try: + await asyncio.wait_for( + self._session_manager.shutdown(), + timeout=SHUTDOWN_TIMEOUT_SEC * 3, + ) + except asyncio.TimeoutError: + logger.warning( + "CursorCLIBridge: manager shutdown timed out after %ds", + SHUTDOWN_TIMEOUT_SEC * 3, + ) + if self._obs: + await self._obs.finalize() + logger.info("CursorCLIBridge: shutdown complete") + + def mark_dirty(self) -> None: + self._ready = False + self._adapter = None + self._mcp_status_cache = None + if self._session_manager: + self._session_manager.clear_session_ids() + manager = self._session_manager + self._session_manager = None + _async_safe_manager_shutdown(manager) + logger.info("CursorCLIBridge: marked dirty -- will reinitialise on next run") + + def get_error_context(self) -> str: + if not self._session_manager: + return "" + all_lines = self._session_manager.get_all_stderr(max_per_worker=10) + if all_lines: + return "Cursor CLI stderr:\n" + "\n".join(all_lines[-20:]) + return "" + + async def get_mcp_status(self) -> dict: + if self._mcp_status_cache is not None: + return self._mcp_status_cache + + empty: dict = {"servers": [], "totalCount": 0} + if not self._mcp_settings_path: + return empty + + try: + mcp_path = Path(self._mcp_settings_path) + if not mcp_path.exists(): + return empty + + with open(mcp_path) as f: + settings = json.load(f) + + mcp_servers = settings.get("mcpServers", {}) + servers_list = [] + for name, config in mcp_servers.items(): + transport = "stdio" + if config.get("httpUrl"): + transport = "http" + elif config.get("url"): + transport = "sse" + servers_list.append( + { + "name": name, + "displayName": name, + "status": "configured", + "transport": transport, + "tools": [], + } + ) + + result = {"servers": servers_list, "totalCount": len(servers_list)} + self._mcp_status_cache = result + return result + except Exception as e: + logger.error("Failed to get MCP status: %s", e, exc_info=True) + return {"servers": [], "totalCount": 0, "error": str(e)} + + @property + def context(self) -> RunnerContext | None: + return self._context + + @property + def configured_model(self) -> str: + return self._configured_model + + @property + def obs(self) -> Any: + return self._obs + + async def _setup_platform(self) -> None: + if self._session_manager is None: + state_dir = os.path.join( + os.getenv("WORKSPACE_PATH", "/workspace"), + os.getenv("RUNNER_STATE_DIR", ".cursor"), + ) + self._session_manager = CursorSessionManager(state_dir=state_dir) + + from ambient_runner.bridges.cursor_cli.auth import setup_cursor_cli_auth + from ambient_runner.bridges.cursor_cli.mcp import setup_cursor_mcp + from ambient_runner.platform.auth import populate_runtime_credentials + from ambient_runner.platform.workspace import resolve_workspace_paths + + model, api_key = await setup_cursor_cli_auth(self._context) + + cwd_path, _ = resolve_workspace_paths(self._context) + + # Run credential refresh and observability setup concurrently — independent ops. + _, self._obs = await asyncio.gather( + populate_runtime_credentials(self._context), + setup_bridge_observability(self._context, model), + ) + self._last_creds_refresh = time.monotonic() + + mcp_settings_path = setup_cursor_mcp(self._context, cwd_path) + + self._configured_model = model + self._api_key = api_key + self._cwd_path = cwd_path + self._mcp_settings_path = mcp_settings_path + self._adapter = CursorCLIAdapter() diff --git a/components/runners/ambient-runner/ambient_runner/bridges/cursor_cli/mcp.py b/components/runners/ambient-runner/ambient_runner/bridges/cursor_cli/mcp.py new file mode 100644 index 000000000..39a1de0e5 --- /dev/null +++ b/components/runners/ambient-runner/ambient_runner/bridges/cursor_cli/mcp.py @@ -0,0 +1,67 @@ +# components/runners/ambient-runner/ambient_runner/bridges/cursor_cli/mcp.py +"""MCP configuration for the Cursor CLI bridge. + +Loads the shared MCP config from the platform layer and writes it +to `.cursor/mcp.json` that the Cursor CLI reads on startup. +""" + +import json +import logging +from pathlib import Path + +from ambient_runner.platform.config import load_mcp_config +from ambient_runner.platform.context import RunnerContext + +logger = logging.getLogger(__name__) + + +def setup_cursor_mcp( + context: RunnerContext, + cwd_path: str, +) -> str | None: + """Load MCP config and write .cursor/mcp.json. + + Returns: + Path to the written mcp.json, or None if no MCP servers. + """ + mcp_servers = load_mcp_config(context, cwd_path) + if not mcp_servers: + logger.info("No MCP servers configured for Cursor CLI") + return None + + logger.info( + "Loaded %d MCP server(s) for Cursor CLI: %s", + len(mcp_servers), + list(mcp_servers.keys()), + ) + + cursor_dir = Path(cwd_path) / ".cursor" + cursor_dir.mkdir(parents=True, exist_ok=True) + mcp_path = cursor_dir / "mcp.json" + + existing: dict = {} + try: + with open(mcp_path) as f: + existing = json.load(f) + logger.debug("Loaded existing .cursor/mcp.json") + except FileNotFoundError: + pass + except (json.JSONDecodeError, OSError) as exc: + logger.warning("Could not read existing mcp.json, overwriting: %s", exc) + + merged_servers = existing.get("mcpServers", {}) + merged_servers.update(mcp_servers) + existing["mcpServers"] = merged_servers + + with open(mcp_path, "w") as f: + json.dump(existing, f, indent=2) + + mcp_path.chmod(0o600) + + abs_path = str(mcp_path.resolve()) + logger.info( + "Wrote Cursor CLI MCP config with %d server(s) to %s", + len(merged_servers), + abs_path, + ) + return abs_path diff --git a/components/runners/ambient-runner/ambient_runner/bridges/cursor_cli/session.py b/components/runners/ambient-runner/ambient_runner/bridges/cursor_cli/session.py new file mode 100644 index 000000000..1e1b56743 --- /dev/null +++ b/components/runners/ambient-runner/ambient_runner/bridges/cursor_cli/session.py @@ -0,0 +1,354 @@ +# components/runners/ambient-runner/ambient_runner/bridges/cursor_cli/session.py +"""Subprocess management for the Cursor CLI bridge. + +The Cursor CLI `agent` binary is invoked once per turn. +Each query() call spawns `agent -p --force --trust ... ""`, +reads NDJSON from stdout, and tears down the process when done. +""" + +import asyncio +import json +import logging +import os +import signal +import time +from collections import deque +from pathlib import Path +from typing import AsyncIterator + +logger = logging.getLogger(__name__) + +CURSOR_CLI_TIMEOUT_SEC = int(os.getenv("CURSOR_CLI_TIMEOUT_SEC", "300")) +SHUTDOWN_TIMEOUT_SEC = int(os.getenv("SHUTDOWN_TIMEOUT_SEC", "10")) +WORKER_TTL_SEC = int(os.getenv("WORKER_TTL_SEC", "3600")) + +_MAX_STDERR_LINES = 100 + +_CURSOR_ENV_BLOCKLIST = frozenset( + { + "ANTHROPIC_API_KEY", + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "BOT_TOKEN", + "LANGFUSE_SECRET_KEY", + "LANGFUSE_PUBLIC_KEY", + "LANGFUSE_HOST", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "S3_ENDPOINT", + "S3_BUCKET", + "GOOGLE_OAUTH_CLIENT_ID", + "GOOGLE_OAUTH_CLIENT_SECRET", + } +) + + +class CursorSessionWorker: + """Spawns the Cursor CLI for a single turn and yields NDJSON lines.""" + + def __init__( + self, + *, + model: str, + api_key: str = "", + cwd: str = "", + ) -> None: + self._model = model + self._api_key = api_key + self._cwd = cwd or os.getenv("WORKSPACE_PATH", "/workspace") + self._process: asyncio.subprocess.Process | None = None + self._stderr_lines: deque[str] = deque(maxlen=_MAX_STDERR_LINES) + self._stderr_task: asyncio.Task | None = None + + @property + def stderr_lines(self) -> list[str]: + return list(self._stderr_lines) + + async def _stream_stderr(self) -> None: + if self._process is None or self._process.stderr is None: + return + try: + async for raw_line in self._process.stderr: + line = raw_line.decode().rstrip() + if line: + self._stderr_lines.append(line) + logger.debug("[Cursor stderr] %s", line) + except asyncio.CancelledError: + raise + except Exception: + logger.debug("stderr stream ended with error", exc_info=True) + + async def query( + self, + prompt: str, + session_id: str | None = None, + ) -> AsyncIterator[str]: + """Spawn the Cursor CLI and yield NDJSON lines from stdout.""" + cmd = [ + "agent", + "-p", + "--force", + "--trust", + "--approve-mcps", + "--output-format", + "stream-json", + "--model", + self._model, + ] + if session_id: + cmd.extend(["--resume", session_id]) + cmd.extend(["--workspace", self._cwd]) + cmd.append(prompt) + + env = {k: v for k, v in os.environ.items() if k not in _CURSOR_ENV_BLOCKLIST} + if self._api_key: + env["CURSOR_API_KEY"] = self._api_key + + logger.debug("Spawning Cursor CLI: %s (cwd=%s)", cmd, self._cwd) + + self._process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=self._cwd, + env=env, + limit=10 * 1024 * 1024, + ) + + self._stderr_task = asyncio.create_task(self._stream_stderr()) + + try: + if self._process.stdout is None: + raise RuntimeError( + "Cursor CLI process has no stdout - cannot read NDJSON stream" + ) + + async def _read_lines() -> AsyncIterator[str]: + async for raw_line in self._process.stdout: + stripped = raw_line.decode().strip() + if stripped: + yield stripped + + loop = asyncio.get_running_loop() + deadline = loop.time() + CURSOR_CLI_TIMEOUT_SEC + async for line in _read_lines(): + yield line + if loop.time() > deadline: + logger.warning( + "Cursor CLI timed out after %d seconds, killing process", + CURSOR_CLI_TIMEOUT_SEC, + ) + await self._kill_process() + raise TimeoutError( + f"Cursor CLI timed out after {CURSOR_CLI_TIMEOUT_SEC}s" + ) + + await self._process.wait() + + if self._process.returncode not in (None, 0): + stderr_tail = " | ".join(list(self._stderr_lines)[-5:]) + logger.warning( + "Cursor CLI exited with code %d; recent stderr: %s", + self._process.returncode, + stderr_tail, + ) + raise RuntimeError( + f"Cursor CLI exited with code {self._process.returncode}" + + (f": {stderr_tail}" if stderr_tail else "") + ) + finally: + if self._stderr_task and not self._stderr_task.done(): + self._stderr_task.cancel() + try: + await self._stderr_task + except asyncio.CancelledError: + pass + self._stderr_task = None + self._process = None + + async def _kill_process(self) -> None: + if self._process is None or self._process.returncode is not None: + return + try: + self._process.terminate() + logger.debug("Sent SIGTERM to Cursor CLI process") + except ProcessLookupError: + return + try: + await asyncio.wait_for(self._process.wait(), timeout=SHUTDOWN_TIMEOUT_SEC) + except asyncio.TimeoutError: + logger.warning( + "Cursor CLI did not exit after %ds SIGTERM, sending SIGKILL", + SHUTDOWN_TIMEOUT_SEC, + ) + try: + self._process.kill() + await self._process.wait() + except ProcessLookupError: + pass + + async def interrupt(self) -> None: + if self._process and self._process.returncode is None: + try: + self._process.send_signal(signal.SIGINT) + logger.info("Sent SIGINT to Cursor CLI process") + except ProcessLookupError: + pass + + async def stop(self) -> None: + await self._kill_process() + if self._stderr_task and not self._stderr_task.done(): + self._stderr_task.cancel() + try: + await self._stderr_task + except asyncio.CancelledError: + pass + self._stderr_task = None + + +class CursorSessionManager: + """Manages Cursor session workers and tracks session IDs for --resume.""" + + _EVICTION_INTERVAL = 60.0 + _SESSION_IDS_FILE = "cursor_session_ids.json" + + def __init__(self, state_dir: str = "") -> None: + self._workers: dict[str, CursorSessionWorker] = {} + self._session_ids: dict[str, str] = {} + self._locks: dict[str, asyncio.Lock] = {} + self._last_access: dict[str, float] = {} + self._last_eviction: float = 0.0 + self._ids_path: Path | None = ( + Path(state_dir) / self._SESSION_IDS_FILE if state_dir else None + ) + self._restore_session_ids() + + def _evict_stale(self) -> None: + now = time.monotonic() + if now - self._last_eviction < self._EVICTION_INTERVAL: + return + self._last_eviction = now + stale = [ + tid for tid, ts in self._last_access.items() if now - ts > WORKER_TTL_SEC + ] + for tid in stale: + worker = self._workers.pop(tid, None) + self._session_ids.pop(tid, None) + self._locks.pop(tid, None) + self._last_access.pop(tid, None) + if worker: + try: + loop = asyncio.get_running_loop() + loop.create_task(worker.stop()) + except RuntimeError: + pass + logger.debug("Evicted stale worker for thread=%s", tid) + + def get_or_create_worker( + self, + thread_id: str, + *, + model: str, + api_key: str = "", + cwd: str = "", + ) -> CursorSessionWorker: + self._evict_stale() + self._last_access[thread_id] = time.monotonic() + + if thread_id not in self._workers: + self._workers[thread_id] = CursorSessionWorker( + model=model, + api_key=api_key, + cwd=cwd, + ) + logger.debug("Created CursorSessionWorker for thread=%s", thread_id) + return self._workers[thread_id] + + def get_lock(self, thread_id: str) -> asyncio.Lock: + if thread_id not in self._locks: + self._locks[thread_id] = asyncio.Lock() + return self._locks[thread_id] + + def get_session_id(self, thread_id: str) -> str | None: + return self._session_ids.get(thread_id) + + def set_session_id(self, thread_id: str, session_id: str) -> None: + self._session_ids[thread_id] = session_id + self._persist_session_ids() + logger.debug("Recorded session_id=%s for thread=%s", session_id, thread_id) + + def _persist_session_ids(self) -> None: + if not self._ids_path or not self._session_ids: + return + try: + self._ids_path.parent.mkdir(parents=True, exist_ok=True) + with open(self._ids_path, "w") as f: + json.dump(self._session_ids, f) + except OSError: + logger.debug("Could not persist session IDs to %s", self._ids_path, exc_info=True) + + def _restore_session_ids(self) -> None: + if not self._ids_path: + return + try: + with open(self._ids_path) as f: + restored = json.load(f) + if isinstance(restored, dict): + self._session_ids.update(restored) + logger.info( + "Restored %d Cursor session ID(s) from %s", len(restored), self._ids_path + ) + except FileNotFoundError: + pass + except (OSError, json.JSONDecodeError): + logger.debug("Could not restore session IDs from %s", self._ids_path, exc_info=True) + + def clear_session_ids(self) -> None: + self._session_ids.clear() + if self._ids_path: + try: + self._ids_path.unlink() + logger.info("Cleared stale Cursor session IDs from %s", self._ids_path) + except FileNotFoundError: + pass + except OSError: + logger.debug("Could not remove session IDs at %s", self._ids_path, exc_info=True) + + async def interrupt(self, thread_id: str) -> None: + worker = self._workers.get(thread_id) + if worker: + await worker.interrupt() + else: + logger.warning("No worker to interrupt for thread=%s", thread_id) + + def get_stderr_lines(self, thread_id: str) -> list[str]: + worker = self._workers.get(thread_id) + if worker: + return worker.stderr_lines + return [] + + def get_all_stderr(self, max_per_worker: int = 10) -> list[str]: + all_lines: list[str] = [] + for worker in self._workers.values(): + lines = worker.stderr_lines + if lines: + all_lines.extend(lines[-max_per_worker:]) + return all_lines + + async def shutdown(self) -> None: + async def _stop_all() -> None: + tasks = [worker.stop() for worker in self._workers.values()] + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + + try: + await asyncio.wait_for(_stop_all(), timeout=SHUTDOWN_TIMEOUT_SEC * 2) + except asyncio.TimeoutError: + logger.warning( + "CursorSessionManager: shutdown timed out after %ds", + SHUTDOWN_TIMEOUT_SEC * 2, + ) + + self._workers.clear() + self._last_access.clear() + logger.info("CursorSessionManager: all workers shut down") diff --git a/components/runners/ambient-runner/main.py b/components/runners/ambient-runner/main.py old mode 100644 new mode 100755 index 88bbdc4e8..45acf7f8f --- a/components/runners/ambient-runner/main.py +++ b/components/runners/ambient-runner/main.py @@ -20,6 +20,7 @@ "claude-agent-sdk": ("ambient_runner.bridges.claude", "ClaudeBridge"), "gemini-cli": ("ambient_runner.bridges.gemini_cli", "GeminiCLIBridge"), "langgraph": ("ambient_runner.bridges.langgraph", "LangGraphBridge"), + "cursor-cli": ("ambient_runner.bridges.cursor_cli", "CursorCLIBridge"), } diff --git a/components/runners/ambient-runner/pyproject.toml b/components/runners/ambient-runner/pyproject.toml old mode 100644 new mode 100755 index 86f1bec63..27947ddc0 --- a/components/runners/ambient-runner/pyproject.toml +++ b/components/runners/ambient-runner/pyproject.toml @@ -53,7 +53,7 @@ asyncio_mode = "auto" [tool.setuptools] py-modules = ["main"] -packages = ["ag_ui_claude_sdk", "ag_ui_gemini_cli", "ambient_runner", "ambient_runner.endpoints", "ambient_runner.middleware", "ambient_runner.platform", "ambient_runner.bridges", "ambient_runner.bridges.claude", "ambient_runner.bridges.langgraph", "ambient_runner.bridges.gemini_cli"] +packages = ["ag_ui_claude_sdk", "ag_ui_gemini_cli", "ag_ui_cursor_cli", "ambient_runner", "ambient_runner.endpoints", "ambient_runner.middleware", "ambient_runner.platform", "ambient_runner.bridges", "ambient_runner.bridges.claude", "ambient_runner.bridges.langgraph", "ambient_runner.bridges.gemini_cli", "ambient_runner.bridges.cursor_cli"] [build-system] requires = ["setuptools>=61.0"] From 7dd4c1f91d2e0033cfbb65d6d0dea87067604254 Mon Sep 17 00:00:00 2001 From: Ambient Code Bot Date: Tue, 21 Apr 2026 14:35:30 -0400 Subject: [PATCH 2/6] fix(runner): align cursor CLI types.py with real stream-json format Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ambient-runner/ag_ui_cursor_cli/types.py | 104 +++++++++++++----- .../tests/test_cursor_cli_types.py | 70 ++++++++++++ 2 files changed, 144 insertions(+), 30 deletions(-) create mode 100644 components/runners/ambient-runner/tests/test_cursor_cli_types.py diff --git a/components/runners/ambient-runner/ag_ui_cursor_cli/types.py b/components/runners/ambient-runner/ag_ui_cursor_cli/types.py index 460d40bba..faf0c43b8 100644 --- a/components/runners/ambient-runner/ag_ui_cursor_cli/types.py +++ b/components/runners/ambient-runner/ag_ui_cursor_cli/types.py @@ -1,9 +1,8 @@ -# components/runners/ambient-runner/ag_ui_cursor_cli/types.py """Dataclasses for Cursor CLI stream-json event types.""" import json import logging -from dataclasses import dataclass +from dataclasses import dataclass, field logger = logging.getLogger(__name__) @@ -14,13 +13,14 @@ class InitEvent: subtype: str # "init" session_id: str = "" model: str = "" + cwd: str = "" + permission_mode: str = "" @dataclass class MessageEvent: type: str # "assistant" or "user" content: str = "" - delta: bool = False @dataclass @@ -49,19 +49,68 @@ class ResultEvent: session_id: str = "" duration_ms: int = 0 is_error: bool = False + usage: dict = field(default_factory=dict) + + +def _extract_content_text(message: dict) -> str: + """Extract text from message.content which is [{type, text}, ...].""" + content = message.get("content", []) + if isinstance(content, str): + return content + if isinstance(content, list): + return "".join( + block.get("text", "") + for block in content + if isinstance(block, dict) and block.get("type") == "text" + ) + return "" -def parse_event( - line: str, -) -> InitEvent | MessageEvent | ToolCallStartEvent | ToolCallCompletedEvent | ResultEvent | None: - """Parse a JSON line into the appropriate event dataclass. +def _extract_tool_info(tool_call: dict) -> tuple[str, str]: + """Extract (tool_name, args_json) from tool-type-keyed tool_call dict. + + Real format: {"readToolCall": {"args": {...}}} + """ + for key, value in tool_call.items(): + if isinstance(value, dict) and "args" in value: + return key, json.dumps(value["args"]) + for key in tool_call: + return key, "{}" + return "unknown", "{}" - Returns None when the line cannot be parsed or has an unknown type. - NOTE: The Cursor CLI stream-json event format is based on documentation - research and may differ from actual CLI output. Validate against a real - Cursor CLI binary before relying on this in production. +def _extract_tool_result(tool_call: dict) -> tuple[str, str]: + """Extract (output, error) from completed tool_call. + + Real format: {"readToolCall": {"args": ..., "result": {"success": {"content": "..."}}}} """ + for key, value in tool_call.items(): + if not isinstance(value, dict): + continue + result = value.get("result", {}) + if not isinstance(result, dict): + continue + success = result.get("success", {}) + if isinstance(success, dict) and "content" in success: + return str(success["content"]), "" + error = result.get("error", {}) + if error: + return "", json.dumps(error) if isinstance(error, dict) else str(error) + return json.dumps(result), "" + return "(completed)", "" + + +def parse_event( + line: str, +) -> ( + InitEvent + | MessageEvent + | ToolCallStartEvent + | ToolCallCompletedEvent + | ResultEvent + | None +): + """Parse a JSON line from cursor-agent --output-format stream-json.""" try: data = json.loads(line) except json.JSONDecodeError: @@ -77,44 +126,38 @@ def parse_event( subtype=subtype, session_id=data.get("session_id", ""), model=data.get("model", ""), + cwd=data.get("cwd", ""), + permission_mode=data.get("permissionMode", ""), ) - if event_type == "assistant": - msg = data.get("message", {}) - content = msg.get("content", "") if isinstance(msg, dict) else "" - return MessageEvent( - type=event_type, - content=content, - delta=data.get("delta", False), - ) - - if event_type == "user": + if event_type in ("assistant", "user"): msg = data.get("message", {}) - content = msg.get("content", "") if isinstance(msg, dict) else "" + content = _extract_content_text(msg) if isinstance(msg, dict) else "" return MessageEvent( type=event_type, content=content, - delta=False, ) if event_type == "tool_call" and subtype == "started": tc = data.get("tool_call", {}) - func = tc.get("function", {}) + tool_name, arguments = _extract_tool_info(tc) return ToolCallStartEvent( type=event_type, subtype=subtype, - tool_id=data.get("id", ""), - tool_name=func.get("name", ""), - arguments=func.get("arguments", ""), + tool_id=data.get("call_id", ""), + tool_name=tool_name, + arguments=arguments, ) if event_type == "tool_call" and subtype == "completed": + tc = data.get("tool_call", {}) + output, error = _extract_tool_result(tc) return ToolCallCompletedEvent( type=event_type, subtype=subtype, - tool_id=data.get("id", ""), - output=data.get("output", ""), - error=data.get("error", ""), + tool_id=data.get("call_id", ""), + output=output, + error=error, ) if event_type == "result": @@ -125,6 +168,7 @@ def parse_event( session_id=data.get("session_id", ""), duration_ms=data.get("duration_ms", 0), is_error=data.get("is_error", False), + usage=data.get("usage", {}), ) logger.debug("Unknown Cursor CLI event: type=%s subtype=%s", event_type, subtype) diff --git a/components/runners/ambient-runner/tests/test_cursor_cli_types.py b/components/runners/ambient-runner/tests/test_cursor_cli_types.py new file mode 100644 index 000000000..85d949071 --- /dev/null +++ b/components/runners/ambient-runner/tests/test_cursor_cli_types.py @@ -0,0 +1,70 @@ +"""Tests for Cursor CLI stream-json event parsing against real binary output.""" + +from ag_ui_cursor_cli.types import ( + InitEvent, + MessageEvent, + ToolCallStartEvent, + ToolCallCompletedEvent, + ResultEvent, + parse_event, +) + +REAL_INIT = '{"type":"system","subtype":"init","apiKeySource":"login","cwd":"/tmp","session_id":"ae85-cc7a","model":"Composer 2 Fast","permissionMode":"default"}' +REAL_ASSISTANT = '{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"hello"}]},"session_id":"ae85"}' +REAL_USER = '{"type":"user","message":{"role":"user","content":[{"type":"text","text":"read test.txt"}]},"session_id":"ae85"}' +REAL_TOOL_START = '{"type":"tool_call","subtype":"started","call_id":"tool_584d","tool_call":{"readToolCall":{"args":{"path":"/tmp/test.txt"}}},"model_call_id":"7d78","session_id":"ae85","timestamp_ms":1776794504467}' +REAL_TOOL_DONE = '{"type":"tool_call","subtype":"completed","call_id":"tool_584d","tool_call":{"readToolCall":{"args":{"path":"/tmp/test.txt"},"result":{"success":{"content":"placeholder\\n"}}}},"model_call_id":"7d78","session_id":"ae85","timestamp_ms":1776794504551}' +REAL_RESULT = '{"type":"result","subtype":"success","duration_ms":6155,"duration_api_ms":6155,"is_error":false,"result":"hello world","session_id":"ae85","request_id":"db94","usage":{"inputTokens":19483,"outputTokens":105}}' + + +def test_parse_init_event(): + ev = parse_event(REAL_INIT) + assert isinstance(ev, InitEvent) + assert ev.session_id == "ae85-cc7a" + assert ev.model == "Composer 2 Fast" + assert ev.cwd == "/tmp" + assert ev.permission_mode == "default" + + +def test_parse_assistant_message(): + ev = parse_event(REAL_ASSISTANT) + assert isinstance(ev, MessageEvent) + assert ev.type == "assistant" + assert ev.content == "hello" + + +def test_parse_user_message(): + ev = parse_event(REAL_USER) + assert isinstance(ev, MessageEvent) + assert ev.type == "user" + assert ev.content == "read test.txt" + + +def test_parse_tool_call_start(): + ev = parse_event(REAL_TOOL_START) + assert isinstance(ev, ToolCallStartEvent) + assert ev.tool_id == "tool_584d" + assert ev.tool_name == "readToolCall" + assert '"path"' in ev.arguments + + +def test_parse_tool_call_completed(): + ev = parse_event(REAL_TOOL_DONE) + assert isinstance(ev, ToolCallCompletedEvent) + assert ev.tool_id == "tool_584d" + assert "placeholder" in ev.output + assert ev.error == "" + + +def test_parse_result_event(): + ev = parse_event(REAL_RESULT) + assert isinstance(ev, ResultEvent) + assert ev.result == "hello world" + assert ev.duration_ms == 6155 + assert ev.is_error is False + assert ev.usage["inputTokens"] == 19483 + + +def test_parse_garbage_returns_none(): + assert parse_event("not json") is None + assert parse_event('{"type":"unknown_thing"}') is None From 75b63d9e0c6c428b54c454b5162b63838dedd563 Mon Sep 17 00:00:00 2001 From: Ambient Code Bot Date: Tue, 21 Apr 2026 14:38:58 -0400 Subject: [PATCH 3/6] =?UTF-8?q?fix(runner):=20remove=20delta=20check=20fro?= =?UTF-8?q?m=20cursor=20adapter=20=E2=80=94=20all=20messages=20are=20delta?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ambient-runner/ag_ui_cursor_cli/adapter.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/components/runners/ambient-runner/ag_ui_cursor_cli/adapter.py b/components/runners/ambient-runner/ag_ui_cursor_cli/adapter.py index b5e0f70b5..298637e61 100644 --- a/components/runners/ambient-runner/ag_ui_cursor_cli/adapter.py +++ b/components/runners/ambient-runner/ag_ui_cursor_cli/adapter.py @@ -125,15 +125,6 @@ async def run( message_id=current_message_id, delta=event.content, ) - - if not event.delta and text_message_open: - async for ev in self._flush_text_message( - current_message_id, accumulated_text, run_messages - ): - yield ev - text_message_open = False - current_message_id = None - accumulated_text = "" continue # ── tool_call started ── @@ -168,7 +159,11 @@ async def run( type=EventType.TOOL_CALL_END, tool_call_id=tid, ) - result_content = event.error if event.error else (event.output or "(completed)") + result_content = ( + event.error + if event.error + else (event.output or "(completed)") + ) yield ToolCallResultEvent( type=EventType.TOOL_CALL_RESULT, tool_call_id=tid, From d3d622e3957433ddcb035b4d257a137ec917d30f Mon Sep 17 00:00:00 2001 From: Ambient Code Bot Date: Tue, 21 Apr 2026 14:40:34 -0400 Subject: [PATCH 4/6] fix(runner): use cursor-agent binary name and correct flags Co-Authored-By: Claude Opus 4.6 (1M context) --- .../bridges/cursor_cli/session.py | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/components/runners/ambient-runner/ambient_runner/bridges/cursor_cli/session.py b/components/runners/ambient-runner/ambient_runner/bridges/cursor_cli/session.py index 1e1b56743..bec6886a0 100644 --- a/components/runners/ambient-runner/ambient_runner/bridges/cursor_cli/session.py +++ b/components/runners/ambient-runner/ambient_runner/bridges/cursor_cli/session.py @@ -1,8 +1,8 @@ # components/runners/ambient-runner/ambient_runner/bridges/cursor_cli/session.py """Subprocess management for the Cursor CLI bridge. -The Cursor CLI `agent` binary is invoked once per turn. -Each query() call spawns `agent -p --force --trust ... ""`, +The Cursor CLI `cursor-agent` binary is invoked once per turn. +Each query() call spawns `cursor-agent --print --force ... ""`, reads NDJSON from stdout, and tears down the process when done. """ @@ -85,10 +85,9 @@ async def query( ) -> AsyncIterator[str]: """Spawn the Cursor CLI and yield NDJSON lines from stdout.""" cmd = [ - "agent", - "-p", + "cursor-agent", + "--print", "--force", - "--trust", "--approve-mcps", "--output-format", "stream-json", @@ -285,7 +284,9 @@ def _persist_session_ids(self) -> None: with open(self._ids_path, "w") as f: json.dump(self._session_ids, f) except OSError: - logger.debug("Could not persist session IDs to %s", self._ids_path, exc_info=True) + logger.debug( + "Could not persist session IDs to %s", self._ids_path, exc_info=True + ) def _restore_session_ids(self) -> None: if not self._ids_path: @@ -296,12 +297,16 @@ def _restore_session_ids(self) -> None: if isinstance(restored, dict): self._session_ids.update(restored) logger.info( - "Restored %d Cursor session ID(s) from %s", len(restored), self._ids_path + "Restored %d Cursor session ID(s) from %s", + len(restored), + self._ids_path, ) except FileNotFoundError: pass except (OSError, json.JSONDecodeError): - logger.debug("Could not restore session IDs from %s", self._ids_path, exc_info=True) + logger.debug( + "Could not restore session IDs from %s", self._ids_path, exc_info=True + ) def clear_session_ids(self) -> None: self._session_ids.clear() @@ -312,7 +317,9 @@ def clear_session_ids(self) -> None: except FileNotFoundError: pass except OSError: - logger.debug("Could not remove session IDs at %s", self._ids_path, exc_info=True) + logger.debug( + "Could not remove session IDs at %s", self._ids_path, exc_info=True + ) async def interrupt(self, thread_id: str) -> None: worker = self._workers.get(thread_id) From 757dd9b8c9b95eae15887a70e3fe45f4d10bc0fb Mon Sep 17 00:00:00 2001 From: Ambient Code Bot Date: Tue, 21 Apr 2026 14:41:26 -0400 Subject: [PATCH 5/6] fix(runner): fix cursor-agent Dockerfile install for UID 1001 Co-Authored-By: Claude Opus 4.6 (1M context) --- components/runners/ambient-runner/Dockerfile | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/components/runners/ambient-runner/Dockerfile b/components/runners/ambient-runner/Dockerfile index cf0bc6cf9..327427fe4 100755 --- a/components/runners/ambient-runner/Dockerfile +++ b/components/runners/ambient-runner/Dockerfile @@ -48,14 +48,13 @@ RUN pip3 install --break-system-packages --no-cache-dir '/app/ambient-runner[all RUN npm install -g @google/gemini-cli@${GEMINI_CLI_VERSION} && \ npm cache clean --force -# Install Cursor CLI via official installer. -# The binary is placed at ~/.local/bin/agent; symlink to /usr/local/bin/agent -# so it is in PATH for all users (including the non-root UID 1001 runtime user). -RUN curl -fsSL "https://cursor.com/install" -o /tmp/cursor-install.sh && \ - HOME=/root bash /tmp/cursor-install.sh && \ - rm /tmp/cursor-install.sh && \ - chmod +x /root/.local/bin/agent && \ - ln -sf /root/.local/bin/agent /usr/local/bin/agent +# Install Cursor Agent CLI via official installer. +# The installer places the binary at $HOME/.local/bin/cursor-agent. +# We run as root during build, then symlink to /usr/local/bin/ so it's +# in PATH for the runtime user (UID 1001). +RUN curl -fsSL "https://cursor.com/install" | bash && \ + ln -sf /root/.local/bin/cursor-agent /usr/local/bin/cursor-agent && \ + chmod +x /usr/local/bin/cursor-agent # Set environment variables ENV PYTHONUNBUFFERED=1 From ba2f8cddaeb6be95d0599ed64a37e5ea9c540f43 Mon Sep 17 00:00:00 2001 From: Ambient Code Bot Date: Tue, 21 Apr 2026 14:43:52 -0400 Subject: [PATCH 6/6] test(runner): add cursor CLI e2e and unit tests against real binary Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tests/test_cursor_cli_e2e.py | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 components/runners/ambient-runner/tests/test_cursor_cli_e2e.py diff --git a/components/runners/ambient-runner/tests/test_cursor_cli_e2e.py b/components/runners/ambient-runner/tests/test_cursor_cli_e2e.py new file mode 100644 index 000000000..c3c65ed03 --- /dev/null +++ b/components/runners/ambient-runner/tests/test_cursor_cli_e2e.py @@ -0,0 +1,136 @@ +"""E2E smoke test: runs real cursor-agent binary and validates parse_event(). + +Requires: +- cursor-agent binary in PATH and authenticated (cursor-agent login) +- Network access to Cursor API + +Skip with: pytest -m "not e2e" +""" + +import subprocess +import pytest + +from ag_ui_cursor_cli.types import ( + InitEvent, + MessageEvent, + ResultEvent, + ToolCallCompletedEvent, + ToolCallStartEvent, + parse_event, +) + +pytestmark = pytest.mark.e2e + + +def _has_cursor_agent() -> bool: + try: + result = subprocess.run( + ["cursor-agent", "--version"], + capture_output=True, + text=True, + timeout=5, + ) + return result.returncode == 0 + except (FileNotFoundError, subprocess.TimeoutExpired): + return False + + +def _run_cursor_agent(prompt: str, cwd: str = "/tmp") -> list[str]: + result = subprocess.run( + [ + "cursor-agent", + "--print", + "--force", + "--output-format", + "stream-json", + prompt, + ], + capture_output=True, + text=True, + timeout=60, + cwd=cwd, + ) + assert result.returncode == 0, f"cursor-agent failed: {result.stderr}" + return [line for line in result.stdout.strip().split("\n") if line.strip()] + + +@pytest.mark.skipif( + not _has_cursor_agent(), reason="cursor-agent not installed or not authenticated" +) +class TestCursorAgentE2E: + def test_simple_prompt_parses_all_events(self): + lines = _run_cursor_agent("respond with exactly: hello world") + + assert len(lines) >= 3, ( + f"Expected at least init+message+result, got {len(lines)}" + ) + + events = [parse_event(line) for line in lines] + none_count = sum(1 for e in events if e is None) + assert none_count == 0, ( + f"{none_count}/{len(lines)} lines returned None from parse_event. " + f"Unparsed lines: {[line for line, ev in zip(lines, events) if ev is None]}" + ) + + # Verify event type sequence + types = [(type(e).__name__, getattr(e, "type", "")) for e in events] + assert types[0] == ("InitEvent", "system"), ( + f"First event should be InitEvent, got {types[0]}" + ) + assert types[-1] == ("ResultEvent", "result"), ( + f"Last event should be ResultEvent, got {types[-1]}" + ) + + # Verify init has session_id + init = events[0] + assert isinstance(init, InitEvent) + assert init.session_id, "InitEvent.session_id should not be empty" + assert init.model, "InitEvent.model should not be empty" + + # Verify result + result = events[-1] + assert isinstance(result, ResultEvent) + assert not result.is_error + assert result.duration_ms > 0 + + # Verify at least one assistant message with content + assistant_msgs = [ + e for e in events if isinstance(e, MessageEvent) and e.type == "assistant" + ] + assert len(assistant_msgs) > 0, "Expected at least one assistant message" + full_text = "".join(m.content for m in assistant_msgs) + assert "hello" in full_text.lower(), ( + f"Expected 'hello' in response, got: {full_text}" + ) + + def test_tool_use_prompt_parses_tool_events(self, tmp_path): + test_file = tmp_path / "test.txt" + test_file.write_text("sentinel-value-12345\n") + + lines = _run_cursor_agent( + f"read the file {test_file} and tell me what it says", + cwd=str(tmp_path), + ) + + events = [parse_event(line) for line in lines] + none_count = sum(1 for e in events if e is None) + assert none_count == 0, ( + f"{none_count}/{len(lines)} lines returned None. " + f"Unparsed: {[line[:120] for line, ev in zip(lines, events) if ev is None]}" + ) + + tool_starts = [e for e in events if isinstance(e, ToolCallStartEvent)] + tool_completes = [e for e in events if isinstance(e, ToolCallCompletedEvent)] + + assert len(tool_starts) > 0, "Expected at least one ToolCallStartEvent" + assert len(tool_completes) > 0, "Expected at least one ToolCallCompletedEvent" + + # Verify tool call has an ID and name + tc = tool_starts[0] + assert tc.tool_id, "tool_id should not be empty" + assert tc.tool_name, "tool_name should not be empty" + + # Verify tool completion has output + tcd = tool_completes[0] + assert tcd.tool_id, "completed tool_id should not be empty" + assert tcd.output or tcd.error, "completed tool should have output or error"