diff --git a/.github/workflows/pull-request-checks.yml b/.github/workflows/pull-request-checks.yml index c73f8e292..cb079e949 100644 --- a/.github/workflows/pull-request-checks.yml +++ b/.github/workflows/pull-request-checks.yml @@ -497,48 +497,6 @@ jobs: project-version: 0.0.0-SNAPSHOT artifact-name: sbom-container-images-ssh - build-agi-container-images: - name: Build AGI Container Images - runs-on: ubuntu-latest - needs: build - permissions: - contents: read - actions: read - packages: read - security-events: write - steps: - - name: Checkout code - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - - name: Set nx shas - uses: nrwl/nx-set-shas@v5 - - - name: Download build artifacts - uses: actions/download-artifact@v8 - with: - name: build-artifacts - path: . - - - name: Setup environment - uses: ./.github/actions/setup-env - - - name: Build AGI container images - run: | - npx nx affected --target=agi-container-image --configuration=test --parallel=3 - - - name: Scan AGI container images with Trivy - uses: ./.github/actions/trivy-scan-local-images - with: - sarif-category: trivy-images-agi - - - name: Generate container image SBOMs - uses: ./.github/actions/generate-container-image-sboms - with: - project-version: 0.0.0-SNAPSHOT - artifact-name: sbom-container-images-agi - build-server-container-images: name: Build Server Container Images runs-on: ubuntu-latest @@ -591,7 +549,6 @@ jobs: - build-worker-container-images - build-vnc-container-images - build-ssh-container-images - - build-agi-container-images - build-server-container-images if: always() && needs.detect-affected.outputs.has_affected == 'true' steps: @@ -635,10 +592,9 @@ jobs: WORKER: ${{ needs.build-worker-container-images.result }} VNC: ${{ needs.build-vnc-container-images.result }} SSH: ${{ needs.build-ssh-container-images.result }} - AGI: ${{ needs.build-agi-container-images.result }} SERVER: ${{ needs.build-server-container-images.result }} run: | - for result in "$BUILD" "$API" "$WORKER" "$VNC" "$SSH" "$AGI" "$SERVER"; do + for result in "$BUILD" "$API" "$WORKER" "$VNC" "$SSH" "$SERVER"; do if [ "$result" = "failure" ]; then echo "::error::An upstream SBOM build job failed" exit 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8322f1e80..619693ca4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -270,53 +270,6 @@ jobs: project-version: ${{ needs.publish.outputs.new_release_version }} artifact-name: sbom-container-images-ssh - build-agi-container-images: - name: Build AGI Container Images - runs-on: ubuntu-latest - needs: publish - if: needs.publish.outputs.new_release_published == 'true' - permissions: - contents: read - actions: read - packages: write - steps: - - name: Checkout code - uses: actions/checkout@v5 - with: - persist-credentials: false - fetch-depth: 0 - - - name: Set nx shas - uses: nrwl/nx-set-shas@v5 - - - name: Download build artifacts - uses: actions/download-artifact@v8 - with: - name: build-artifacts - path: . - - - name: Setup environment - uses: ./.github/actions/setup-env - - - name: 'Login to container registry' - uses: docker/login-action@v4 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Publish AGI container images - env: - VERSION: ${{ needs.publish.outputs.new_release_version }} - run: | - npx nx run-many --target=agi-container-image --configuration=release --parallel=3 - - - name: Generate container image SBOMs - uses: ./.github/actions/generate-container-image-sboms - with: - project-version: ${{ needs.publish.outputs.new_release_version }} - artifact-name: sbom-container-images-agi - build-server-container-images: name: Build Server Container Images runs-on: ubuntu-latest @@ -673,7 +626,6 @@ jobs: - build-worker-container-images - build-vnc-container-images - build-ssh-container-images - - build-agi-container-images - build-server-container-images if: needs.publish.outputs.new_release_published == 'true' environment: production @@ -962,7 +914,6 @@ jobs: - build-worker-container-images - build-vnc-container-images - build-ssh-container-images - - build-agi-container-images - build-server-container-images if: needs.publish.outputs.new_release_published == 'true' env: diff --git a/apps/agenstra/backend-agent-manager/Dockerfile.agi b/apps/agenstra/backend-agent-manager/Dockerfile.agi deleted file mode 100644 index 038c7a6e0..000000000 --- a/apps/agenstra/backend-agent-manager/Dockerfile.agi +++ /dev/null @@ -1,62 +0,0 @@ -# ----------------------------------------------------------------------------- -# Image: agenstra-manager-agi -# Project: backend-agent-manager — Nx target `agi-container-image` -# Registry: ghcr.io/forepath/agenstra-manager-agi -# Base: debian:trixie-slim -# User: agenstra (APP_UID / APP_GID, default 10001); sudo: chown only (workspace bind mount) -# Exposes: 18789 (OpenClaw gateway) -# Build: npx nx run backend-agent-manager:agi-container-image -# Run: — -# Notes: OpenClaw gateway; provider API keys via runtime env (see deployment docs) -# ----------------------------------------------------------------------------- -# -FROM docker.io/debian:trixie-slim - -ARG APP_UID=10001 -ARG APP_GID=10001 -ARG VERSION=0.0.0 -ENV VERSION=${VERSION} - -ENV PORT=18789 -ENV OPENCLAW_HOME=/openclaw -ENV HOME=/home/agenstra - -WORKDIR /openclaw - -EXPOSE ${PORT} - -RUN apt-get update && \ - apt-get install -y --no-install-recommends \ - curl \ - bash \ - ca-certificates \ - gnupg \ - lsb-release \ - sudo && \ - groupadd --gid "${APP_GID}" agenstra && \ - useradd --uid "${APP_UID}" --gid "${APP_GID}" --create-home --home-dir /home/agenstra --shell /bin/bash agenstra && \ - echo 'agenstra ALL=(ALL) NOPASSWD: /usr/bin/chown' > /etc/sudoers.d/agenstra && \ - chmod 0440 /etc/sudoers.d/agenstra && \ - mkdir -p /openclaw && \ - chown agenstra:agenstra /openclaw /home/agenstra && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* - -RUN HOME=/home/agenstra OPENCLAW_HOME=/openclaw \ - curl -fsSL https://openclaw.ai/install.sh | bash -s -- --no-onboard && \ - npm install -g npm@11.18.0 && \ - npm cache clean --force && \ - chown -R agenstra:agenstra /openclaw /home/agenstra - -RUN printf '%s\n' \ - '#!/bin/bash' \ - 'set -euo pipefail' \ - 'sudo chown -R agenstra:agenstra /openclaw 2>/dev/null || true' \ - 'exec openclaw gateway --allow-unconfigured "$@"' \ - > /usr/local/bin/docker-entrypoint.sh && \ - chmod 755 /usr/local/bin/docker-entrypoint.sh && \ - chown agenstra:agenstra /usr/local/bin/docker-entrypoint.sh - -USER ${APP_UID}:${APP_GID} - -ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] diff --git a/apps/agenstra/backend-agent-manager/Dockerfile.ssh b/apps/agenstra/backend-agent-manager/Dockerfile.ssh index 7b8d662e6..db746354e 100644 --- a/apps/agenstra/backend-agent-manager/Dockerfile.ssh +++ b/apps/agenstra/backend-agent-manager/Dockerfile.ssh @@ -42,7 +42,6 @@ RUN printf '%s\n' \ '#!/bin/bash' \ 'set -euo pipefail' \ 'sudo chown -R agenstra:agenstra /app 2>/dev/null || true' \ - 'sudo chown -R agenstra:agenstra /openclaw 2>/dev/null || true' \ 'if [ -z "${SSH_PASSWORD:-}" ]; then' \ ' echo "SSH_PASSWORD is required" >&2' \ ' exit 1' \ diff --git a/apps/agenstra/backend-agent-manager/docker-compose.yaml b/apps/agenstra/backend-agent-manager/docker-compose.yaml index 7f8234874..01c89fad5 100644 --- a/apps/agenstra/backend-agent-manager/docker-compose.yaml +++ b/apps/agenstra/backend-agent-manager/docker-compose.yaml @@ -74,9 +74,6 @@ services: CURSOR_AGENT_DOCKER_IMAGE: ${CURSOR_AGENT_DOCKER_IMAGE:-} CURSOR_AGENT_VIRTUAL_WORKSPACE_DOCKER_IMAGE: ${CURSOR_AGENT_VIRTUAL_WORKSPACE_DOCKER_IMAGE:-} CURSOR_AGENT_SSH_CONNECTION_DOCKER_IMAGE: ${CURSOR_AGENT_SSH_CONNECTION_DOCKER_IMAGE:-} - OPENCLAW_AGENT_DOCKER_IMAGE: ${OPENCLAW_AGENT_DOCKER_IMAGE:-} - OPENCLAW_AGENT_VIRTUAL_WORKSPACE_DOCKER_IMAGE: ${OPENCLAW_AGENT_VIRTUAL_WORKSPACE_DOCKER_IMAGE:-} - OPENCLAW_AGENT_SSH_CONNECTION_DOCKER_IMAGE: ${OPENCLAW_AGENT_SSH_CONNECTION_DOCKER_IMAGE:-} OPENCODE_AGENT_DOCKER_IMAGE: ${OPENCODE_AGENT_DOCKER_IMAGE:-} OPENCODE_AGENT_VIRTUAL_WORKSPACE_DOCKER_IMAGE: ${OPENCODE_AGENT_VIRTUAL_WORKSPACE_DOCKER_IMAGE:-} OPENCODE_AGENT_SSH_CONNECTION_DOCKER_IMAGE: ${OPENCODE_AGENT_SSH_CONNECTION_DOCKER_IMAGE:-} diff --git a/apps/agenstra/backend-agent-manager/package.json b/apps/agenstra/backend-agent-manager/package.json index c39d26bce..5b2cbe409 100644 --- a/apps/agenstra/backend-agent-manager/package.json +++ b/apps/agenstra/backend-agent-manager/package.json @@ -4,6 +4,7 @@ "license": "AGPL-3.0-or-later", "private": true, "dependencies": { + "@agentclientprotocol/sdk": "0.23.0", "@nestjs/common": "11.1.6", "@nestjs/core": "11.1.6", "@nestjs/jwt": "11.0.2", @@ -35,6 +36,7 @@ "ssh2": "1.17.0", "sshpk": "1.18.0", "uuid": "11.1.1", + "zod": "4.3.6", "@opentelemetry/api": "1.9.1", "@opentelemetry/auto-instrumentations-node": "0.79.0", "@opentelemetry/exporter-logs-otlp-http": "0.221.0", diff --git a/apps/agenstra/backend-agent-manager/project.json b/apps/agenstra/backend-agent-manager/project.json index 3bf904a94..1039e9510 100644 --- a/apps/agenstra/backend-agent-manager/project.json +++ b/apps/agenstra/backend-agent-manager/project.json @@ -216,40 +216,12 @@ }, "defaultConfiguration": "test" }, - "agi-container-image": { - "dependsOn": ["prune"], - "cache": false, - "executor": "@nx-tools/nx-container:build", - "options": { - "engine": "docker", - "file": "apps/agenstra/backend-agent-manager/Dockerfile.agi", - "context": "dist/apps/agenstra/backend-agent-manager", - "build-args": ["VERSION"] - }, - "configurations": { - "test": { - "load": true, - "tags": [ - "registry.forenet.internal/forepath/agenstra-manager-agi:test" - ] - }, - "release": { - "push": true, - "tags": [ - "ghcr.io/forepath/agenstra-manager-agi:latest", - "ghcr.io/forepath/agenstra-manager-agi:$VERSION" - ] - } - }, - "defaultConfiguration": "test" - }, "start-containers": { "dependsOn": [ "api-container-image", "worker-container-image", "vnc-container-image", - "ssh-container-image", - "agi-container-image" + "ssh-container-image" ], "cache": false, "executor": "nx:run-commands", diff --git a/apps/agenstra/backend-agent-manager/src/migrations/1781000000000_AddAcpSessionColumnsToAgentsTable.ts b/apps/agenstra/backend-agent-manager/src/migrations/1781000000000_AddAcpSessionColumnsToAgentsTable.ts new file mode 100644 index 000000000..1af10b7d6 --- /dev/null +++ b/apps/agenstra/backend-agent-manager/src/migrations/1781000000000_AddAcpSessionColumnsToAgentsTable.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +/** + * Persist agent-issued ACP session ids so chat can resume after API restarts. + */ +export class AddAcpSessionColumnsToAgentsTable1781000000000 implements MigrationInterface { + name = 'AddAcpSessionColumnsToAgentsTable1781000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumn( + 'agents', + new TableColumn({ + name: 'acp_session_id', + type: 'varchar', + length: '512', + isNullable: true, + }), + ); + await queryRunner.addColumn( + 'agents', + new TableColumn({ + name: 'acp_session_container_id', + type: 'varchar', + length: '255', + isNullable: true, + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropColumn('agents', 'acp_session_container_id'); + await queryRunner.dropColumn('agents', 'acp_session_id'); + } +} diff --git a/apps/agenstra/backend-agent-manager/src/migrations/1781100000000_MigrateAcpSessionsToJsonbBySuffix.ts b/apps/agenstra/backend-agent-manager/src/migrations/1781100000000_MigrateAcpSessionsToJsonbBySuffix.ts new file mode 100644 index 000000000..c346c30f3 --- /dev/null +++ b/apps/agenstra/backend-agent-manager/src/migrations/1781100000000_MigrateAcpSessionsToJsonbBySuffix.ts @@ -0,0 +1,48 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +/** + * Store ACP session ids per resumeSessionSuffix (primary + automation / helper sessions). + */ +export class MigrateAcpSessionsToJsonbBySuffix1781100000000 implements MigrationInterface { + name = 'MigrateAcpSessionsToJsonbBySuffix1781100000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumn( + 'agents', + new TableColumn({ + name: 'acp_sessions', + type: 'jsonb', + isNullable: true, + }), + ); + + await queryRunner.query(` + UPDATE "agents" + SET "acp_sessions" = jsonb_build_object('', "acp_session_id") + WHERE "acp_session_id" IS NOT NULL + `); + + await queryRunner.dropColumn('agents', 'acp_session_id'); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.addColumn( + 'agents', + new TableColumn({ + name: 'acp_session_id', + type: 'varchar', + length: '512', + isNullable: true, + }), + ); + + await queryRunner.query(` + UPDATE "agents" + SET "acp_session_id" = "acp_sessions"->>'' + WHERE "acp_sessions" IS NOT NULL + AND ("acp_sessions"->>'') IS NOT NULL + `); + + await queryRunner.dropColumn('agents', 'acp_sessions'); + } +} diff --git a/apps/agenstra/frontend-agent-console/src/i18n/messages.de.xlf b/apps/agenstra/frontend-agent-console/src/i18n/messages.de.xlf index b8baa8144..23fb32c11 100644 --- a/apps/agenstra/frontend-agent-console/src/i18n/messages.de.xlf +++ b/apps/agenstra/frontend-agent-console/src/i18n/messages.de.xlf @@ -1001,56 +1001,7 @@ Connect to socket to send messages Mit Socket verbinden, um Nachrichten zu senden - - - Gateway - Gateway - - - Step 1: Create the OpenClaw configuration - Schritt 1: OpenClaw-Konfiguration anlegen - - - Create the default OpenClaw configuration file in the container. This writes .openclaw/openclaw.json so the gateway can load its settings. - Erstellen Sie die standardmäßige OpenClaw-Konfigurationsdatei im Container. Dadurch wird .openclaw/openclaw.json geschrieben, damit das Gateway seine Einstellungen laden kann. - - - Create OpenClaw configuration - OpenClaw-Konfiguration erstellen - - - Step 2: Adapt the configuration - Schritt 2: Konfiguration anpassen - - - Edit the configuration in the editor to customize the gateway settings, adding and configuring channels, AI models, and more. - Bearbeiten Sie die Konfiguration im Editor, um Gateway-Einstellungen anzupassen und Kanäle, KI-Modelle und mehr hinzuzufügen und zu konfigurieren. - - - Open Editor - Editor öffnen - - - Step 3: Restart the agent - Schritt 3: Agent neu starten - - - Restart the agent so it picks up the new configuration. - Starten Sie den Agenten neu, damit die neue Konfiguration übernommen wird. - - - Restart the agent - Agent neu starten - - - Step 4: Start chatting - Schritt 4: Chat starten - - - You are all set. Say hello to your new bot in the chat, try a question, or start a task. Have fun! - Sie sind startklar. Begrüßen Sie Ihren neuen Bot im Chat, stellen Sie eine Frage oder starten Sie eine Aufgabe. Viel Erfolg! - - + Select context Kontext auswählen diff --git a/apps/agenstra/frontend-agent-console/src/i18n/messages.xlf b/apps/agenstra/frontend-agent-console/src/i18n/messages.xlf index 800be98e5..705bbe615 100644 --- a/apps/agenstra/frontend-agent-console/src/i18n/messages.xlf +++ b/apps/agenstra/frontend-agent-console/src/i18n/messages.xlf @@ -757,44 +757,7 @@ Connect to socket to send messages - - - Gateway - - - Step 1: Create the OpenClaw configuration - - - Create the default OpenClaw configuration file in the container. This writes .openclaw/openclaw.json so the gateway can load its settings. - - - Create OpenClaw configuration - - - Step 2: Adapt the configuration - - - Edit the configuration in the editor to customize the gateway settings, adding and configuring channels, AI models, and more. - - - Open Editor - - - Step 3: Restart the agent - - - Restart the agent so it picks up the new configuration. - - - Restart the agent - - - Step 4: Start chatting - - - You are all set. Say hello to your new bot in the chat, try a question, or start a task. Have fun! - - + Select context diff --git a/apps/agenstra/frontend-landingpage/public/assets/images/logos/openclaw.png b/apps/agenstra/frontend-landingpage/public/assets/images/logos/openclaw.png deleted file mode 100644 index b14e4233b..000000000 Binary files a/apps/agenstra/frontend-landingpage/public/assets/images/logos/openclaw.png and /dev/null differ diff --git a/apps/agenstra/frontend-landingpage/src/i18n/messages.de.xlf b/apps/agenstra/frontend-landingpage/src/i18n/messages.de.xlf index c3243c8f7..21e568bc9 100644 --- a/apps/agenstra/frontend-landingpage/src/i18n/messages.de.xlf +++ b/apps/agenstra/frontend-landingpage/src/i18n/messages.de.xlf @@ -2191,8 +2191,8 @@ Ihre Agenten - Use Cursor, OpenCode, OpenClaw, and more on infrastructure you choose - Nutzen Sie Cursor, OpenCode, OpenClaw und mehr auf Infrastruktur Ihrer Wahl + Use Cursor and OpenCode on infrastructure you choose + Nutzen Sie Cursor und OpenCode auf Infrastruktur Ihrer Wahl Your deployment diff --git a/apps/agenstra/frontend-landingpage/src/i18n/messages.xlf b/apps/agenstra/frontend-landingpage/src/i18n/messages.xlf index db930424c..19a7efa05 100644 --- a/apps/agenstra/frontend-landingpage/src/i18n/messages.xlf +++ b/apps/agenstra/frontend-landingpage/src/i18n/messages.xlf @@ -1644,7 +1644,7 @@ Your agents - Use Cursor, OpenCode, OpenClaw, and more on infrastructure you choose + Use Cursor and OpenCode on infrastructure you choose Your deployment diff --git a/docs/agenstra/ai-agents/README.md b/docs/agenstra/ai-agents/README.md index 19df5e10b..8d58d0ecf 100644 --- a/docs/agenstra/ai-agents/README.md +++ b/docs/agenstra/ai-agents/README.md @@ -2,6 +2,8 @@ Guides for AI coding assistants working on **Agenstra** in this monorepo. Covers the workspace `.agenstra/` agent context used to configure Cursor, OpenCode, and GitHub Copilot from a single, tool-agnostic source. +For **runtime** chat between Agenstra and coding agents in worker containers, see [Agent Client Protocol (ACP)](./agent-client-protocol.md). + ## Overview The `.agenstra/` context is a **single source of truth** for agent rules, commands, skills, agents, and tools. The `@forepath/ai` transformer reads this directory and emits tool-specific configs so you can maintain one set of files and generate Cursor, OpenCode, and GitHub Copilot output as needed. diff --git a/docs/agenstra/ai-agents/agent-client-protocol.md b/docs/agenstra/ai-agents/agent-client-protocol.md new file mode 100644 index 000000000..a7f893e10 --- /dev/null +++ b/docs/agenstra/ai-agents/agent-client-protocol.md @@ -0,0 +1,85 @@ +# Agent Client Protocol (ACP) in Agenstra + +Agenstra’s agent manager uses the [Agent Client Protocol](https://agentclientprotocol.com) as the **internal transport** between the platform (ACP client) and coding agents running in worker containers (ACP agents). + +## Glossary + +| Term | Meaning | +| ---------------- | ---------------------------------------------------------------------- | +| **ACP (client)** | Agent Client Protocol — JSON-RPC over stdio (this document) | +| **MCP** | Model Context Protocol — tools and resources for LLM hosts | +| **BeeAI ACP** | IBM Agent Communication Protocol — REST agent-to-agent (not used here) | + +## Architecture + +```mermaid +flowchart LR + Console[Agent Console] + Manager[Agent Manager] + ACP[AcpSessionService] + Worker[Worker container] + Agent[cursor-agent acp / opencode acp] + + Console -->|WebSocket AgentEventEnvelope| Manager + Manager --> ACP + ACP -->|stdio JSON-RPC| Agent + Agent --> Worker +``` + +Outward APIs (OpenAPI / AsyncAPI chat events) are unchanged. ACP replaces vendor-specific CLI NDJSON parsing inside providers. + +Built-in providers: + +- **`cursor`** — launches `cursor-agent acp` in the worker container +- **`opencode`** — launches `opencode acp` in the worker container + +## Protocol details + +- **Version:** ACP protocol version 1 (stable) +- **Transport:** newline-delimited JSON-RPC 2.0 over stdio +- **Session flow:** `initialize` → `session/new` (or `session/load` with a persisted agent-issued id) → `session/prompt` → `session/update` notifications +- **Session resume:** ACP session ids are stored per agent on `acp_sessions` (jsonb), keyed by `resumeSessionSuffix` (empty key = primary chat). After an API restart, the manager opens a new stdio transport and calls `session/load` when the container id still matches. That covers main chat and background automation sessions (`-ticket-auto-loop`, `-ticket-auto-pre`, etc.) the same way in-memory reuse already did within a process. +- **Permissions:** `session/request_permission` is auto-approved when `ACP_AUTO_APPROVE` is not `false` (default for headless agents) + +## Configuration + +| Variable | Values | Default | +| ------------------ | ---------------- | ------------------------ | +| `ACP_AUTO_APPROVE` | `true` / `false` | `true` (headless agents) | + +## Profile / config surface + +Provider capabilities (including `transport: 'acp'`) are returned on: + +- Manager `GET /api/config` → `agentTypes[].capabilities` +- Agent response DTOs → `capabilities` +- Controller client profile → `config.agentTypes` (embedded manager config) + +## Notifications + +Operator-facing notification events (controller notification bus / webhooks): + +| Event | When | +| ----------------------------- | ------------------------------------------------- | +| `agent.acp.session_failed` | ACP initialize / session / transport failure | +| `agent.acp.permission_denied` | Permission request denied or no options available | +| `agent.chat.failed` | Generic chat turn failure | + +Streaming token deltas are not notified. + +## Worker image requirements + +The [worker image](../../../apps/agenstra/backend-agent-manager/Dockerfile.worker) installs Cursor CLI and OpenCode so the manager can exec: + +- `cursor-agent acp` +- `opencode acp` + +## Troubleshooting + +- **Session fails at initialize** — Confirm the agent binary supports `acp` inside the container (`docker exec … cursor-agent acp` / `opencode acp`). +- **Permission prompts** — Set `ACP_AUTO_APPROVE=false` only if the console will answer `session/request_permission`. +- **Auth errors** — Check Cursor/OpenCode credentials inside the worker container; stderr is logged as ACP exec stderr. + +## Migration note + +Legacy OpenClaw (`openclaw` agent type / AGI image) has been removed. Recreate affected agents as `cursor` or `opencode`. diff --git a/docs/agenstra/applications/backend-agent-manager.md b/docs/agenstra/applications/backend-agent-manager.md index 722016a58..fb2ca4328 100644 --- a/docs/agenstra/applications/backend-agent-manager.md +++ b/docs/agenstra/applications/backend-agent-manager.md @@ -19,7 +19,7 @@ This application provides: - **Auto Migrations** Automatic database schema migrations on startup - **Rate Limiting** Configurable rate limiting on all API endpoints - **CORS Configuration** Production-safe CORS defaults -- **Plugin-based Agent Providers** Support for multiple agent implementations (cursor-agent, etc.) +- **Plugin-based Agent Providers** Support for multiple agent implementations (Cursor and OpenCode via ACP, etc.) ## Architecture @@ -96,7 +96,7 @@ Regex rules scoped to this manager instance (distinct from controller global rul ### Configuration -- `GET /api/config` - Get configuration parameters including Git repository URL and available agent types +- `GET /api/config` - Get configuration parameters including Git repository URL and available agent types (with capabilities / ACP transport) ### File Operations @@ -293,13 +293,12 @@ Treat socket access as **high privilege** on the host. The API image runs as **` | -------------------------------- | ------------------ | ------------------------------------------ | ------------------------------------------------------------------------------------- | | **API** (`Dockerfile.api`) | `agenstra` (10001) | `ghcr.io/forepath/agenstra-manager-api` | HTTP + WebSocket; Docker CLI + socket mount; restricted `sudo` for GID sync | | **Worker** (`Dockerfile.worker`) | `agenstra` | `ghcr.io/forepath/agenstra-manager-worker` | Cursor/OpenCode workloads; workspace at `/app`; credentials in `/home/agenstra` | -| **agi** (`Dockerfile.agi`) | `agenstra` | `ghcr.io/forepath/agenstra-manager-agi` | OpenClaw gateway; workspace at `/openclaw` | | **VNC** (`Dockerfile.vnc`) | `agenstra` | `ghcr.io/forepath/agenstra-manager-vnc` | Desktop browser; shared repo at `/home/agenstra/environment`; `VNC_PASSWORD` required | | **SSH** (`Dockerfile.ssh`) | `agenstra` | `ghcr.io/forepath/agenstra-manager-ssh` | Optional shell; `SSH_PASSWORD` required; workspace at provider `basePath` | Per-agent bind mounts (host `/opt/agents/{uuid}`) and read-only `/opt/agents` → `/opt/workspace` are documented in **[Container image security](../security/container-images.md)**. -Configuration secrets belong in the environment at deploy time, not in image defaults. Override images per provider via `CURSOR_AGENT_*`, `OPENCODE_AGENT_*`, and `OPENCLAW_AGENT_*` variables (see [Environment configuration](../deployment/environment-configuration.md)). When upgrading, deploy API, worker, VNC, SSH, and **agi** tags from the **same release**. +Configuration secrets belong in the environment at deploy time, not in image defaults. Override images per provider via `CURSOR_AGENT_*` and `OPENCODE_AGENT_*` variables (see [Environment configuration](../deployment/environment-configuration.md)). When upgrading, deploy API, worker, VNC, and SSH tags from the **same release**. ## Production Deployment Checklist @@ -312,7 +311,7 @@ Before deploying to production, ensure: - `STATIC_API_KEY` or Keycloak credentials are configured - Database credentials are secure - Docker socket is properly mounted for container management -- Manager API, worker, VNC, SSH, and agi images are on matching release tags +- Manager API, worker, VNC, and SSH images are on matching release tags - Host `/opt/agents` permissions are suitable for UID **10001** (see [Container image security](../security/container-images.md)) - Host `docker` group GID matches image `DOCKER_GID` (or image rebuilt with correct `--build-arg`) diff --git a/docs/agenstra/deployment/README.md b/docs/agenstra/deployment/README.md index a683d0177..d8f0b55b6 100644 --- a/docs/agenstra/deployment/README.md +++ b/docs/agenstra/deployment/README.md @@ -37,7 +37,7 @@ Containerized deployment using Docker: CPU, memory, and disk guidance by deployment role: - Controller API, worker, and scheduler sizing -- Manager host and per-agent workload containers (worker, VNC, SSH, AGI) +- Manager host and per-agent workload containers (worker, VNC, SSH) - PostgreSQL (pgvector) and Redis baselines - Frontend hosts and mixed local-development hosts diff --git a/docs/agenstra/deployment/docker-deployment.md b/docs/agenstra/deployment/docker-deployment.md index 377fc7a79..06c84e539 100644 --- a/docs/agenstra/deployment/docker-deployment.md +++ b/docs/agenstra/deployment/docker-deployment.md @@ -222,12 +222,12 @@ This allows the container to communicate with the host Docker daemon. Restrict w First-party images follow a common hardening baseline: -- **Non-root**: API, worker, VNC, SSH, and OpenClaw (**agi**) images run as `agenstra` (UID/GID **10001** by default), not root. Frontend server images run as `node` (**1000**). Billing API uses the same `agenstra` pattern. +- **Non-root**: API, worker, VNC, and SSH images run as `agenstra` (UID/GID **10001** by default), not root. Frontend server images run as `node` (**1000**). Billing API uses the same `agenstra` pattern. - **Secrets at runtime**: Do not rely on default `ENV` values in images for databases, Keycloak, or VNC/SSH passwords; set variables in Compose or your orchestrator. - **Restricted `sudo`**: `agenstra` is not in the Debian `sudo` group. Only explicit binaries in `/etc/sudoers.d/agenstra` may run via passwordless `sudo` (workspace `chown`, SSH `sshd`/`chpasswd`, API socket GID sync). See **[Container image security](../security/container-images.md#restricted-sudo)**. - **Agent volumes**: Per-agent data under host `/opt/agents/{uuid}`; shared read-only context at `/opt/agents` → `/opt/workspace`. Provision `/opt/agents` with ownership compatible with UID **10001** where possible. - **Docker socket GID**: Manager/controller API images declare `ARG DOCKER_GID=995` and align the in-container `docker` group at startup with the mounted socket’s GID. If your host `docker` group differs, rebuild with `--build-arg DOCKER_GID=$(stat -c '%g' /var/run/docker.sock)` or ensure the default matches your host. -- **Coordinated upgrades**: Upgrade manager API, worker, VNC, SSH, and **agi** images together on the same release tag when user or mount paths change. +- **Coordinated upgrades**: Upgrade manager API, worker, VNC, and SSH images together on the same release tag when user or mount paths change. See **[Container image security](../security/container-images.md)** and **[Operational hardening (Container images)](../security/operational-hardening.md#container-images-docker)**. diff --git a/docs/agenstra/deployment/environment-configuration.md b/docs/agenstra/deployment/environment-configuration.md index 597067aca..350cc65a5 100644 --- a/docs/agenstra/deployment/environment-configuration.md +++ b/docs/agenstra/deployment/environment-configuration.md @@ -175,11 +175,9 @@ Optional runtime extensions for provisioning and context import. See [Dynamic pr - `OPENCODE_AGENT_VIRTUAL_WORKSPACE_DOCKER_IMAGE` - VNC image (default: `ghcr.io/forepath/agenstra-manager-vnc:latest`) - `OPENCODE_AGENT_SSH_CONNECTION_DOCKER_IMAGE` - SSH sidecar image (default: `ghcr.io/forepath/agenstra-manager-ssh:latest`) -### OpenClaw Agent Configuration +### Agent Client Protocol (ACP) -- `OPENCLAW_AGENT_DOCKER_IMAGE` - Primary gateway image (default: `ghcr.io/forepath/agenstra-manager-agi:latest`) -- `OPENCLAW_AGENT_VIRTUAL_WORKSPACE_DOCKER_IMAGE` - VNC image (default: `ghcr.io/forepath/agenstra-manager-vnc:latest`) -- `OPENCLAW_AGENT_SSH_CONNECTION_DOCKER_IMAGE` - SSH sidecar image (default: `ghcr.io/forepath/agenstra-manager-ssh:latest`) +- `ACP_AUTO_APPROVE` - When not `false`, auto-select the first option on `session/request_permission` (default: enabled for headless agents). See [Agent Client Protocol](../ai-agents/agent-client-protocol.md). Sidecar containers require runtime passwords where applicable: **`VNC_PASSWORD`** and **`SSH_PASSWORD`** (set by the manager when creating agents; not image defaults). See **[Container image security](../security/container-images.md)**. diff --git a/docs/agenstra/deployment/operator-runbook.md b/docs/agenstra/deployment/operator-runbook.md index bfdf9c1fa..0ba92e685 100644 --- a/docs/agenstra/deployment/operator-runbook.md +++ b/docs/agenstra/deployment/operator-runbook.md @@ -36,7 +36,7 @@ Map your intended profile to **[System Requirements](./system-requirements.md)** - [ ] Per concurrent agent (worker + VNC): plan ~4 vCPU and 4-8 GiB plus disk under `/opt/agents/{uuid}` - [ ] Host totals match expected concurrent agents (see manager host totals table in system requirements) - [ ] Image **`DOCKER_GID`** matches host `docker` group GID at build time -- [ ] Manager **API, worker, VNC, SSH, and AGI** images planned on the **same release tag** +- [ ] Manager **API, worker, VNC, and SSH** images planned on the **same release tag** ### Frontend and network diff --git a/docs/agenstra/deployment/production-checklist.md b/docs/agenstra/deployment/production-checklist.md index cf2a69140..67e15493e 100644 --- a/docs/agenstra/deployment/production-checklist.md +++ b/docs/agenstra/deployment/production-checklist.md @@ -27,7 +27,7 @@ Comprehensive checklist for deploying Agenstra to production. - Docker socket permissions are restricted (if applicable); API images run as non-root `agenstra` with socket GID sync at startup - `agenstra` has **no** full `sudo` access (only allowlisted commands in `/etc/sudoers.d/agenstra`; see [Container image security](../security/container-images.md#restricted-sudo)) - Host `/opt/agents` exists and is writable by container UID **10001** (or relies on entrypoint `chown` after bind mount) -- Manager **API, worker, VNC, SSH, and agi (OpenClaw)** images are upgraded together on the same release tag +- Manager **API, worker, VNC, and SSH** images are upgraded together on the same release tag - Image `DOCKER_GID` matches host `docker` group GID when building manager/controller API images (see [Docker deployment](./docker-deployment.md#container-security-images)) ### Database diff --git a/docs/agenstra/deployment/system-requirements.md b/docs/agenstra/deployment/system-requirements.md index f7dc0392d..dd63e881f 100644 --- a/docs/agenstra/deployment/system-requirements.md +++ b/docs/agenstra/deployment/system-requirements.md @@ -7,7 +7,7 @@ Hardware and software requirements for running Agenstra components in developmen Agenstra has two backend stacks and the agent console frontend: 1. **Agent controller** control plane with BullMQ roles (`api`, `worker`, `scheduler`), PostgreSQL with **pgvector**, and Redis. -2. **Agent manager** per-workspace runtime on a **Docker host** that spawns agent workload containers (worker, VNC, SSH, AGI). +2. **Agent manager** per-workspace runtime on a **Docker host** that spawns agent workload containers (worker, VNC, SSH). The console talks only to the controller. The controller proxies agent operations to one or more manager instances. @@ -144,7 +144,6 @@ Spawned dynamically per agent. Typical cursor agents use a **worker** container; | `agenstra-manager-worker` | 2 | 2-4 GiB | 10-50 GiB workspace under `/opt/agents/{uuid}` | Includes cursor-agent, OpenCode, Nx, Git; builds and `npm install` spike usage | | `agenstra-manager-vnc` | 2 | 2-4 GiB | 5 GiB | XFCE4 + Chromium at default **1920×1080** | | `agenstra-manager-ssh` | 0.25 | 256-512 MiB | - | SSH sidecar | -| `agenstra-manager-agi` | 1-2 | 1-2 GiB | 2 GiB | OpenClaw gateway on **18789** | Idle agent workers still consume baseline memory; stop agents when not in use. diff --git a/docs/agenstra/features/agent-management.md b/docs/agenstra/features/agent-management.md index f66e9b257..71728a890 100644 --- a/docs/agenstra/features/agent-management.md +++ b/docs/agenstra/features/agent-management.md @@ -97,7 +97,10 @@ Agenstra uses a plugin-based agent provider system. Each agent has an `agentType ### Available Types -- **`cursor`** (default) - Cursor-agent binary running in Docker containers +- **`cursor`** (default) — Cursor agent via [Agent Client Protocol (ACP)](../ai-agents/agent-client-protocol.md) (`cursor-agent acp`) +- **`opencode`** — OpenCode via ACP (`opencode acp`) + +Both providers advertise capabilities (including `transport: acp`) on manager config and agent profile responses. ### Adding New Agent Types @@ -140,7 +143,7 @@ See the application and feature docs linked below for details. ### Chat -Send messages to agents via the chat interface. Messages are sent to the container's stdin and responses are captured from stdout. +Send messages to agents via the chat interface. The console uses WebSocket chat events unchanged. Internally, the agent-manager speaks [ACP](../ai-agents/agent-client-protocol.md) (JSON-RPC over stdio) to `cursor-agent acp` / `opencode acp` inside the worker container and maps session updates to the existing chat event model. ### File Operations diff --git a/docs/agenstra/features/client-management.md b/docs/agenstra/features/client-management.md index 03de81009..46a053700 100644 --- a/docs/agenstra/features/client-management.md +++ b/docs/agenstra/features/client-management.md @@ -42,7 +42,7 @@ See the [Server Provisioning](./server-provisioning.md) documentation for detail Each client includes a `config` field that is automatically fetched from the remote agent-manager: - **`gitRepositoryUrl`** The Git repository URL configured on the agent-manager instance -- **`agentTypes`** Array of available agent provider types (e.g., `['cursor', 'opencode', 'openclaw']`) +- **`agentTypes`** Array of available agent provider types (e.g., `['cursor', 'opencode']`) This configuration allows you to discover which agent types are available on each remote agent-manager instance. diff --git a/docs/agenstra/features/dynamic-provider-plugins.md b/docs/agenstra/features/dynamic-provider-plugins.md index f409e274b..ed905ad10 100644 --- a/docs/agenstra/features/dynamic-provider-plugins.md +++ b/docs/agenstra/features/dynamic-provider-plugins.md @@ -157,7 +157,7 @@ Or inspect startup logs for `DynamicProviderRegistry` / loader errors. ### Agent manager -- **Agents** [Agent Management](./agent-management.md); `DYNAMIC_AGENT_PROVIDERS` extends agent types beyond built-in cursor/openclaw/opencode providers. +- **Agents** [Agent Management](./agent-management.md); `DYNAMIC_AGENT_PROVIDERS` extends agent types beyond built-in cursor/opencode providers. - **Pipelines** [Deployment](./deployment.md); `DYNAMIC_PIPELINE_PROVIDERS` adds CI/CD backends. - **Chat filters** [Message Filter Rules](./message-filter-rules.md); `DYNAMIC_CHAT_FILTERS` adds filter implementations on the manager. diff --git a/docs/agenstra/security/container-images.md b/docs/agenstra/security/container-images.md index 0eef28662..ecec193f3 100644 --- a/docs/agenstra/security/container-images.md +++ b/docs/agenstra/security/container-images.md @@ -6,10 +6,10 @@ For image build targets and registry names, see **[Backend Agent Manager](../app ## Runtime users -| Image family | User | Default UID/GID | Notes | -| ---------------------------------------------------------------------------- | ---------- | --------------- | --------------------------------------- | -| Manager/controller **API**, **worker**, **VNC**, **SSH**, **agi** (OpenClaw) | `agenstra` | **10001** | `ARG APP_UID` / `APP_GID` at build time | -| Frontend **server** images (agent console, portal, docs) | `node` | **1000** | Alpine-based SSR images | +| Image family | User | Default UID/GID | Notes | +| -------------------------------------------------------- | ---------- | --------------- | --------------------------------------- | +| Manager/controller **API**, **worker**, **VNC**, **SSH** | `agenstra` | **10001** | `ARG APP_UID` / `APP_GID` at build time | +| Frontend **server** images (agent console, portal, docs) | `node` | **1000** | Alpine-based SSR images | Processes do **not** run as root after container start. The optional SSH image still starts **`sshd`** via a single allowed `sudo` invocation in the entrypoint. @@ -25,10 +25,9 @@ When the agent manager creates an agent, it bind-mounts host paths into child co **Provider `basePath`:** -| Agent type | Primary image | `basePath` | Git clone target | -| -------------------- | ------------------------- | ----------- | --------------------- | -| `cursor`, `opencode` | `agenstra-manager-worker` | `/app` | `/app` | -| `openclaw` | `agenstra-manager-agi` | `/openclaw` | `/openclaw/workspace` | +| Agent type | Primary image | `basePath` | Git clone target | +| -------------------- | ------------------------- | ---------- | ---------------- | +| `cursor`, `opencode` | `agenstra-manager-worker` | `/app` | `/app` | The same host directory is shared across the worker, SSH, and VNC containers for one agent; only the **in-container mount point** differs (for example worker `/app` vs VNC `/home/agenstra/environment`). @@ -48,7 +47,6 @@ Entrypoint scripts live under **`/usr/local/bin/docker-entrypoint.sh`**, not und | ----------------------------------- | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | | **worker** | `/usr/bin/chown` | Fix ownership on `/app` bind mount at startup | | **VNC** | `/usr/bin/chown` | Fix ownership on `/home/agenstra/environment` bind mount | -| **agi** (OpenClaw) | `/usr/bin/chown` | Fix ownership on `/openclaw` bind mount | | **SSH** | `/usr/bin/chown`, `/usr/sbin/chpasswd`, `/usr/sbin/sshd` | Workspace ownership, set login password from `SSH_PASSWORD`, start SSH daemon | | **Manager API**, **controller API** | `/usr/sbin/groupmod`, `/usr/sbin/groupadd`, `/usr/sbin/usermod` | Align in-container `docker` group GID with mounted `/var/run/docker.sock` before starting Node | @@ -58,7 +56,7 @@ Any other `sudo` attempt (for example `sudo bash`, `sudo apt`) should **fail** w ```bash docker exec -u agenstra sudo id # expect: not allowed -docker exec -u agenstra sudo /usr/bin/chown --version # expect: success (worker/vnc/agi/ssh) +docker exec -u agenstra sudo /usr/bin/chown --version # expect: success (worker/vnc/ssh) ``` ## Manager and controller API images @@ -82,14 +80,9 @@ docker exec -u agenstra sudo /usr/bin/chown --version # expect: su - Shared agent repo is mounted at **`/home/agenstra/environment`**, not `/app`. - TigerVNC / XFCE / websockify run as `agenstra` without `sudo` after startup `chown`. -## OpenClaw (agi) image - -- Registry image: `ghcr.io/forepath/agenstra-manager-agi:latest` (override with `OPENCLAW_AGENT_DOCKER_IMAGE`). -- Gateway listens on port **18789**; `OPENCLAW_HOME=/openclaw`. - ## Coordinated upgrades -Deploy **manager API, worker, VNC, SSH, and agi** images from the **same release tag** when user IDs, home paths, or mount layouts change. Mismatched tags can break shared volumes or console SSH/VNC URLs. +Deploy **manager API, worker, VNC, and SSH** images from the **same release tag** when user IDs, home paths, or mount layouts change. Mismatched tags can break shared volumes or console SSH/VNC URLs. ## Related documentation diff --git a/docs/agenstra/security/operational-hardening.md b/docs/agenstra/security/operational-hardening.md index b66336e2a..381fec2bb 100644 --- a/docs/agenstra/security/operational-hardening.md +++ b/docs/agenstra/security/operational-hardening.md @@ -8,16 +8,16 @@ First-party images are hardened for production use. Full detail (bind mounts, en | Practice | Detail | | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Non-root runtime** | Debian-based backends and worker/VNC/SSH/agi images run as **`agenstra`** (UID/GID **10001** by default); Alpine frontend servers run as **`node`** (**1000**). | +| **Non-root runtime** | Debian-based backends and worker/VNC/SSH images run as **`agenstra`** (UID/GID **10001** by default); Alpine frontend servers run as **`node`** (**1000**). | | **No baked-in secrets** | Database, Keycloak, VNC/SSH passwords, and API keys are **not** defaulted in image `ENV`; operators supply them at deploy time. | | **Restricted `sudo`** | `agenstra` is **not** in the `sudo` group. Only commands listed in `/etc/sudoers.d/agenstra` run passwordless (typically `chown` on workspace mounts; API images add `groupmod` / `groupadd` / `usermod` for Docker socket GID sync). No other `sudo` is permitted, with or without a password. | -| **Workspace mount ownership** | Worker, VNC, SSH, and agi entrypoints `chown` bind-mounted agent data to `agenstra` when the host directory is root-owned. | +| **Workspace mount ownership** | Worker, VNC, and SSH entrypoints `chown` bind-mounted agent data to `agenstra` when the host directory is root-owned. | | **Least privilege on socket** | Manager and controller API images mount the host Docker socket only when required. The entrypoint syncs the in-container `docker` group GID to the socket’s GID (`DOCKER_GID` build arg, default **995**), then starts Node with **`sg docker`**. | | **Worker credential paths** | When cloning private Git repos, the manager writes `.netrc` and SSH keys under the worker’s **`$HOME`** (`/home/agenstra`), not `/root`. | | **SSH access image** | Optional SSH sidecar: runtime **`SSH_PASSWORD`** required; login as **`agenstra`**; `sshd` started only via allowed `sudo` in the entrypoint. | | **Image scanning** | Repository `trivy.yaml` configures filesystem/config/image scans; CI fails on CRITICAL findings (HIGH+ visible in SARIF). | -Deploy **manager API, worker, VNC, SSH, and agi images from the same release** when paths or users change. See **[Docker deployment](../deployment/docker-deployment.md)** and **[Backend Agent Manager](../applications/backend-agent-manager.md)**. +Deploy **manager API, worker, VNC, and SSH images from the same release** when paths or users change. See **[Docker deployment](../deployment/docker-deployment.md)** and **[Backend Agent Manager](../applications/backend-agent-manager.md)**. ## Authentication mode (backends) diff --git a/docs/agenstra/troubleshooting/common-issues.md b/docs/agenstra/troubleshooting/common-issues.md index 8809bff7e..bedc33681 100644 --- a/docs/agenstra/troubleshooting/common-issues.md +++ b/docs/agenstra/troubleshooting/common-issues.md @@ -77,12 +77,12 @@ Common problems and their solutions in the Agenstra system. ### Agent workspace permission errors -**Symptoms**: `git clone` fails in the worker container; permission denied writing under `/app`, `/openclaw`, or `/home/agenstra/environment`; entrypoint `chown` failures +**Symptoms**: `git clone` fails in the worker container; permission denied writing under `/app` or `/home/agenstra/environment`; entrypoint `chown` failures **Solutions**: -- Ensure host `/opt/agents` exists and is writable by UID **10001**, or allow the image entrypoint to `chown` the bind mount (rebuild worker/VNC/SSH/agi images from a current release) -- Confirm manager API, worker, VNC, SSH, and agi images are on the **same release tag** Inspect ownership on the host: `ls -la /opt/agents/` +- Ensure host `/opt/agents` exists and is writable by UID **10001**, or allow the image entrypoint to `chown` the bind mount (rebuild worker/VNC/SSH images from a current release) +- Confirm manager API, worker, VNC, and SSH images are on the **same release tag** Inspect ownership on the host: `ls -la /opt/agents/` - See **[Container image security](../security/container-images.md#host-directory-ownership)** ## Database Issues diff --git a/graph/graph.json b/graph/graph.json index 53da0bdc3..5fd56ca1c 100644 --- a/graph/graph.json +++ b/graph/graph.json @@ -1,6 +1,6 @@ { "version": 1, - "generatedAt": "2026-08-04T14:49:14.978Z", + "generatedAt": "2026-08-04T17:27:47.835Z", "nodes": [ { "id": "project:@forepath/test/mounted-plugin-fixture", @@ -1268,7 +1268,6 @@ "worker-container-image", "vnc-container-image", "ssh-container-image", - "agi-container-image", "start-containers", "openapi-client-js", "sbom" @@ -1712,6 +1711,14 @@ "targets": [] } }, + { + "id": "package:@agentclientprotocol/sdk", + "type": "package", + "attrs": { + "name": "@agentclientprotocol/sdk", + "version": "0.23.0" + } + }, { "id": "package:@bull-board/api", "type": "package", @@ -2088,6 +2095,14 @@ "version": "11.1.1" } }, + { + "id": "package:zod", + "type": "package", + "attrs": { + "name": "zod", + "version": "4.3.6" + } + }, { "id": "package:@angular/animations", "type": "package", @@ -2384,14 +2399,6 @@ "version": "1.18.2" } }, - { - "id": "package:zod", - "type": "package", - "attrs": { - "name": "zod", - "version": "4.3.6" - } - }, { "id": "file:libs/domains/forepath/frontend/data-access-project-estimator/README.md", "type": "readme", @@ -8849,19 +8856,37 @@ } }, { - "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agents/cursor-agent.provider.ts", - "type": "provider", + "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/acp-agent-messaging.service.ts", + "type": "service", "attrs": { - "path": "libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agents/cursor-agent.provider.ts", + "path": "libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/acp-agent-messaging.service.ts", "languageOrKind": "ts", "projectName": "agenstra-backend-feature-agent-manager" } }, { - "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agents/openclaw-agent.provider.ts", + "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/acp-session.service.ts", + "type": "service", + "attrs": { + "path": "libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/acp-session.service.ts", + "languageOrKind": "ts", + "projectName": "agenstra-backend-feature-agent-manager" + } + }, + { + "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/fixtures/acp-spike.md", + "type": "readme", + "attrs": { + "path": "libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/fixtures/acp-spike.md", + "languageOrKind": "md", + "projectName": "agenstra-backend-feature-agent-manager" + } + }, + { + "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agents/cursor-agent.provider.ts", "type": "provider", "attrs": { - "path": "libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agents/openclaw-agent.provider.ts", + "path": "libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agents/cursor-agent.provider.ts", "languageOrKind": "ts", "projectName": "agenstra-backend-feature-agent-manager" } @@ -10964,6 +10989,14 @@ "languageOrKind": "md" } }, + { + "id": "file:docs/agenstra/ai-agents/agent-client-protocol.md", + "type": "doc", + "attrs": { + "path": "docs/agenstra/ai-agents/agent-client-protocol.md", + "languageOrKind": "md" + } + }, { "id": "file:docs/agenstra/ai-agents/agentctx.md", "type": "doc", @@ -14147,6 +14180,33 @@ "specKind": "openapi" } }, + { + "id": "webhook-event:agenstra-backend-feature-agent-controller:agent.acp.permission_denied", + "type": "webhook-event", + "attrs": { + "eventName": "agent.acp.permission_denied", + "projectName": "agenstra-backend-feature-agent-controller", + "catalogPath": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.ts" + } + }, + { + "id": "webhook-event:agenstra-backend-feature-agent-controller:agent.acp.session_failed", + "type": "webhook-event", + "attrs": { + "eventName": "agent.acp.session_failed", + "projectName": "agenstra-backend-feature-agent-controller", + "catalogPath": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.ts" + } + }, + { + "id": "webhook-event:agenstra-backend-feature-agent-controller:agent.chat.failed", + "type": "webhook-event", + "attrs": { + "eventName": "agent.chat.failed", + "projectName": "agenstra-backend-feature-agent-controller", + "catalogPath": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.ts" + } + }, { "id": "webhook-event:agenstra-backend-feature-agent-controller:application.dependency_health_changed", "type": "webhook-event", @@ -17951,6 +18011,106 @@ "domain": "agenstra" } }, + { + "id": "concept:agenstra-agent-client-protocol-acp-in-agenstra", + "type": "concept", + "attrs": { + "title": "Agent Client Protocol (ACP) in Agenstra", + "docPath": "docs/agenstra/ai-agents/agent-client-protocol.md", + "sectionAnchor": "agent-client-protocol-acp-in-agenstra", + "domain": "agenstra" + } + }, + { + "id": "concept:agenstra-glossary", + "type": "concept", + "attrs": { + "title": "Glossary", + "docPath": "docs/agenstra/ai-agents/agent-client-protocol.md", + "sectionAnchor": "glossary", + "domain": "agenstra" + } + }, + { + "id": "concept:agenstra-architecture", + "type": "concept", + "attrs": { + "title": "Architecture", + "docPath": "docs/agenstra/ai-agents/agent-client-protocol.md", + "sectionAnchor": "architecture", + "domain": "agenstra" + } + }, + { + "id": "concept:agenstra-protocol-details", + "type": "concept", + "attrs": { + "title": "Protocol details", + "docPath": "docs/agenstra/ai-agents/agent-client-protocol.md", + "sectionAnchor": "protocol-details", + "domain": "agenstra" + } + }, + { + "id": "concept:agenstra-configuration", + "type": "concept", + "attrs": { + "title": "Configuration", + "docPath": "docs/agenstra/ai-agents/agent-client-protocol.md", + "sectionAnchor": "configuration", + "domain": "agenstra" + } + }, + { + "id": "concept:agenstra-profile-config-surface", + "type": "concept", + "attrs": { + "title": "Profile / config surface", + "docPath": "docs/agenstra/ai-agents/agent-client-protocol.md", + "sectionAnchor": "profile-config-surface", + "domain": "agenstra" + } + }, + { + "id": "concept:agenstra-notifications", + "type": "concept", + "attrs": { + "title": "Notifications", + "docPath": "docs/agenstra/ai-agents/agent-client-protocol.md", + "sectionAnchor": "notifications", + "domain": "agenstra" + } + }, + { + "id": "concept:agenstra-worker-image-requirements", + "type": "concept", + "attrs": { + "title": "Worker image requirements", + "docPath": "docs/agenstra/ai-agents/agent-client-protocol.md", + "sectionAnchor": "worker-image-requirements", + "domain": "agenstra" + } + }, + { + "id": "concept:agenstra-troubleshooting", + "type": "concept", + "attrs": { + "title": "Troubleshooting", + "docPath": "docs/agenstra/ai-agents/agent-client-protocol.md", + "sectionAnchor": "troubleshooting", + "domain": "agenstra" + } + }, + { + "id": "concept:agenstra-migration-note", + "type": "concept", + "attrs": { + "title": "Migration note", + "docPath": "docs/agenstra/ai-agents/agent-client-protocol.md", + "sectionAnchor": "migration-note", + "domain": "agenstra" + } + }, { "id": "concept:agenstra-agentctx", "type": "concept", @@ -18411,16 +18571,6 @@ "domain": "agenstra" } }, - { - "id": "concept:agenstra-architecture", - "type": "concept", - "attrs": { - "title": "Architecture", - "docPath": "docs/agenstra/applications/backend-agent-controller.md", - "sectionAnchor": "architecture", - "domain": "agenstra" - } - }, { "id": "concept:agenstra-api-endpoints", "type": "concept", @@ -19311,16 +19461,6 @@ "domain": "agenstra" } }, - { - "id": "concept:agenstra-configuration", - "type": "concept", - "attrs": { - "title": "Configuration", - "docPath": "docs/agenstra/deployment/local-development.md", - "sectionAnchor": "configuration", - "domain": "agenstra" - } - }, { "id": "concept:agenstra-database", "type": "concept", @@ -19621,16 +19761,6 @@ "domain": "agenstra" } }, - { - "id": "concept:agenstra-troubleshooting", - "type": "concept", - "attrs": { - "title": "Troubleshooting", - "docPath": "docs/agenstra/deployment/local-development.md", - "sectionAnchor": "troubleshooting", - "domain": "agenstra" - } - }, { "id": "concept:agenstra-operator-runbook", "type": "concept", @@ -21491,16 +21621,6 @@ "domain": "agenstra" } }, - { - "id": "concept:agenstra-openclaw-agi-image", - "type": "concept", - "attrs": { - "title": "OpenClaw (agi) image", - "docPath": "docs/agenstra/security/container-images.md", - "sectionAnchor": "openclaw-agi-image", - "domain": "agenstra" - } - }, { "id": "concept:agenstra-coordinated-upgrades", "type": "concept", @@ -28238,6 +28358,11 @@ "to": "project:shared-mcp-devkit", "type": "depends_on" }, + { + "from": "project:agenstra-backend-agent-controller", + "to": "package:@agentclientprotocol/sdk", + "type": "depends_on" + }, { "from": "project:agenstra-backend-agent-controller", "to": "package:@bull-board/api", @@ -28473,6 +28598,11 @@ "to": "package:uuid", "type": "depends_on" }, + { + "from": "project:agenstra-backend-agent-controller", + "to": "package:zod", + "type": "depends_on" + }, { "from": "project:agenstra-frontend-billing-console", "to": "package:@angular/animations", @@ -29248,6 +29378,11 @@ "to": "package:zone.js", "type": "depends_on" }, + { + "from": "project:agenstra-backend-agent-manager", + "to": "package:@agentclientprotocol/sdk", + "type": "depends_on" + }, { "from": "project:agenstra-backend-agent-manager", "to": "package:@bull-board/api", @@ -29468,6 +29603,11 @@ "to": "package:uuid", "type": "depends_on" }, + { + "from": "project:agenstra-backend-agent-manager", + "to": "package:zod", + "type": "depends_on" + }, { "from": "project:forepath-backend-communication", "to": "package:@nestjs/common", @@ -33645,12 +33785,22 @@ }, { "from": "project:agenstra-backend-feature-agent-manager", - "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agents/cursor-agent.provider.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/acp-agent-messaging.service.ts", "type": "contains" }, { "from": "project:agenstra-backend-feature-agent-manager", - "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agents/openclaw-agent.provider.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/acp-session.service.ts", + "type": "contains" + }, + { + "from": "project:agenstra-backend-feature-agent-manager", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/fixtures/acp-spike.md", + "type": "contains" + }, + { + "from": "project:agenstra-backend-feature-agent-manager", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agents/cursor-agent.provider.ts", "type": "contains" }, { @@ -35733,6 +35883,21 @@ "to": "api:HTTP:GET:/otel/metrics", "type": "contains" }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "webhook-event:agenstra-backend-feature-agent-controller:agent.acp.permission_denied", + "type": "contains" + }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "webhook-event:agenstra-backend-feature-agent-controller:agent.acp.session_failed", + "type": "contains" + }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "webhook-event:agenstra-backend-feature-agent-controller:agent.chat.failed", + "type": "contains" + }, { "from": "project:agenstra-backend-feature-agent-controller", "to": "webhook-event:agenstra-backend-feature-agent-controller:application.dependency_health_changed", @@ -37703,6 +37868,56 @@ "to": "concept:agenstra-related-documentation", "type": "contains" }, + { + "from": "file:docs/agenstra/ai-agents/agent-client-protocol.md", + "to": "concept:agenstra-agent-client-protocol-acp-in-agenstra", + "type": "contains" + }, + { + "from": "file:docs/agenstra/ai-agents/agent-client-protocol.md", + "to": "concept:agenstra-glossary", + "type": "contains" + }, + { + "from": "file:docs/agenstra/ai-agents/agent-client-protocol.md", + "to": "concept:agenstra-architecture", + "type": "contains" + }, + { + "from": "file:docs/agenstra/ai-agents/agent-client-protocol.md", + "to": "concept:agenstra-protocol-details", + "type": "contains" + }, + { + "from": "file:docs/agenstra/ai-agents/agent-client-protocol.md", + "to": "concept:agenstra-configuration", + "type": "contains" + }, + { + "from": "file:docs/agenstra/ai-agents/agent-client-protocol.md", + "to": "concept:agenstra-profile-config-surface", + "type": "contains" + }, + { + "from": "file:docs/agenstra/ai-agents/agent-client-protocol.md", + "to": "concept:agenstra-notifications", + "type": "contains" + }, + { + "from": "file:docs/agenstra/ai-agents/agent-client-protocol.md", + "to": "concept:agenstra-worker-image-requirements", + "type": "contains" + }, + { + "from": "file:docs/agenstra/ai-agents/agent-client-protocol.md", + "to": "concept:agenstra-troubleshooting", + "type": "contains" + }, + { + "from": "file:docs/agenstra/ai-agents/agent-client-protocol.md", + "to": "concept:agenstra-migration-note", + "type": "contains" + }, { "from": "file:docs/agenstra/ai-agents/agentctx.md", "to": "concept:agenstra-agentctx", @@ -40378,11 +40593,6 @@ "to": "concept:agenstra-vnc-image", "type": "contains" }, - { - "from": "file:docs/agenstra/security/container-images.md", - "to": "concept:agenstra-openclaw-agi-image", - "type": "contains" - }, { "from": "file:docs/agenstra/security/container-images.md", "to": "concept:agenstra-coordinated-upgrades", @@ -52603,14 +52813,24 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-git-state-broadcast.service.ts", "type": "injects" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/acp-agent-messaging.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/acp-session.service.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/acp-session.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agents.repository.ts", + "type": "injects" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agents/cursor-agent.provider.ts", - "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/docker.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/acp-agent-messaging.service.ts", "type": "injects" }, { "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agents/opencode-agent.provider.ts", - "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/docker.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/acp-agent-messaging.service.ts", "type": "injects" }, { @@ -54590,17 +54810,22 @@ }, { "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.ts", - "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agents/cursor-agent.provider.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/acp-session.service.ts", "type": "provides" }, { "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.ts", - "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agents/opencode-agent.provider.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/acp-agent-messaging.service.ts", "type": "provides" }, { "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.ts", - "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agents/openclaw-agent.provider.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agents/cursor-agent.provider.ts", + "type": "provides" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agents/opencode-agent.provider.ts", "type": "provides" }, { @@ -56173,6 +56398,21 @@ "to": "api:HTTP:GET:/config", "type": "documents" }, + { + "from": "concept:agenstra-architecture", + "to": "api:HTTP:GET:/agents", + "type": "documents" + }, + { + "from": "concept:agenstra-architecture", + "to": "api:HTTP:POST:/agents", + "type": "documents" + }, + { + "from": "concept:agenstra-profile-config-surface", + "to": "api:HTTP:GET:/config", + "type": "documents" + }, { "from": "concept:agenstra-agents-and-subagents", "to": "api:HTTP:GET:/agents", @@ -56548,16 +56788,6 @@ "to": "project:agenstra-backend-agent-controller", "type": "documents" }, - { - "from": "concept:agenstra-architecture", - "to": "api:HTTP:GET:/agents", - "type": "documents" - }, - { - "from": "concept:agenstra-architecture", - "to": "api:HTTP:POST:/agents", - "type": "documents" - }, { "from": "concept:agenstra-api-endpoints", "to": "api:HTTP:GET:/clients", @@ -62853,6 +63083,11 @@ "to": "domain:agenstra", "type": "belongs_to" }, + { + "from": "file:docs/agenstra/ai-agents/agent-client-protocol.md", + "to": "domain:agenstra", + "type": "belongs_to" + }, { "from": "file:docs/agenstra/ai-agents/agentctx.md", "to": "domain:agenstra", @@ -63543,6 +63778,56 @@ "to": "domain:agenstra", "type": "belongs_to" }, + { + "from": "concept:agenstra-agent-client-protocol-acp-in-agenstra", + "to": "domain:agenstra", + "type": "belongs_to" + }, + { + "from": "concept:agenstra-glossary", + "to": "domain:agenstra", + "type": "belongs_to" + }, + { + "from": "concept:agenstra-architecture", + "to": "domain:agenstra", + "type": "belongs_to" + }, + { + "from": "concept:agenstra-protocol-details", + "to": "domain:agenstra", + "type": "belongs_to" + }, + { + "from": "concept:agenstra-configuration", + "to": "domain:agenstra", + "type": "belongs_to" + }, + { + "from": "concept:agenstra-profile-config-surface", + "to": "domain:agenstra", + "type": "belongs_to" + }, + { + "from": "concept:agenstra-notifications", + "to": "domain:agenstra", + "type": "belongs_to" + }, + { + "from": "concept:agenstra-worker-image-requirements", + "to": "domain:agenstra", + "type": "belongs_to" + }, + { + "from": "concept:agenstra-troubleshooting", + "to": "domain:agenstra", + "type": "belongs_to" + }, + { + "from": "concept:agenstra-migration-note", + "to": "domain:agenstra", + "type": "belongs_to" + }, { "from": "concept:agenstra-agentctx", "to": "domain:agenstra", @@ -63773,11 +64058,6 @@ "to": "domain:agenstra", "type": "belongs_to" }, - { - "from": "concept:agenstra-architecture", - "to": "domain:agenstra", - "type": "belongs_to" - }, { "from": "concept:agenstra-api-endpoints", "to": "domain:agenstra", @@ -64223,11 +64503,6 @@ "to": "domain:agenstra", "type": "belongs_to" }, - { - "from": "concept:agenstra-configuration", - "to": "domain:agenstra", - "type": "belongs_to" - }, { "from": "concept:agenstra-database", "to": "domain:agenstra", @@ -64378,11 +64653,6 @@ "to": "domain:agenstra", "type": "belongs_to" }, - { - "from": "concept:agenstra-troubleshooting", - "to": "domain:agenstra", - "type": "belongs_to" - }, { "from": "concept:agenstra-operator-runbook", "to": "domain:agenstra", @@ -65313,11 +65583,6 @@ "to": "domain:agenstra", "type": "belongs_to" }, - { - "from": "concept:agenstra-openclaw-agi-image", - "to": "domain:agenstra", - "type": "belongs_to" - }, { "from": "concept:agenstra-coordinated-upgrades", "to": "domain:agenstra", diff --git a/libs/domains/agenstra/backend/feature-agent-controller/jest.config.ts b/libs/domains/agenstra/backend/feature-agent-controller/jest.config.ts index 276d4a196..9a67255de 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/jest.config.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/jest.config.ts @@ -5,6 +5,9 @@ export default { transform: { '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], }, + moduleNameMapper: { + '^@agentclientprotocol/sdk$': '/../feature-agent-manager/src/test-utils/acp-sdk.mock.ts', + }, moduleFileExtensions: ['ts', 'js', 'html'], coverageDirectory: '../../../../../coverage/libs/domains/agenstra/backend/feature-agent-controller', }; diff --git a/libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml b/libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml index 6c1d94bfc..0072a99bf 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml +++ b/libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml @@ -5000,6 +5000,11 @@ components: type: string description: type: string + agentType: + type: string + description: Agent provider type identifier + capabilities: + $ref: '#/components/schemas/AgentTypeCapabilities' git: type: [object, 'null'] properties: @@ -5027,14 +5032,33 @@ components: type: string AgentTypeInfo: type: object - required: [type, displayName] + required: [type, displayName, capabilities] properties: type: type: string - description: The unique type identifier (e.g., 'cursor', 'openai', 'anthropic') + description: The unique type identifier (e.g., 'cursor', 'opencode') displayName: type: string - description: Human-readable display name (e.g., 'Cursor', 'OpenAI', 'Anthropic Claude') + description: Human-readable display name (e.g., 'Cursor', 'OpenCode') + capabilities: + $ref: '#/components/schemas/AgentTypeCapabilities' + AgentTypeCapabilities: + type: object + required: + [supportsChat, supportsStreaming, supportsToolEvents, supportsQuestions] + properties: + transport: + type: string + enum: [acp] + description: Wire transport for agent messaging (Agent Client Protocol over stdio) + supportsChat: + type: boolean + supportsStreaming: + type: boolean + supportsToolEvents: + type: boolean + supportsQuestions: + type: boolean ConfigResponseDto: type: object required: [agentTypes] diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients.controller.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients.controller.spec.ts index 90e27ad0c..9347d66b8 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients.controller.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients.controller.spec.ts @@ -63,7 +63,19 @@ describe('ClientsController', () => { canManageWorkspaceConfiguration: true, config: { gitRepositoryUrl: 'https://github.com/user/repo.git', - agentTypes: [{ type: 'cursor', displayName: 'Cursor' }], + agentTypes: [ + { + type: 'cursor', + displayName: 'Cursor', + capabilities: { + transport: 'acp', + supportsChat: true, + supportsStreaming: true, + supportsToolEvents: true, + supportsQuestions: true, + }, + }, + ], }, createdAt: new Date('2024-01-01'), updatedAt: new Date('2024-01-01'), diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/gateways/clients.gateway.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/gateways/clients.gateway.ts index 24004cf84..5a64df5f1 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/gateways/clients.gateway.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/gateways/clients.gateway.ts @@ -337,7 +337,37 @@ export class ClientsGateway implements OnGatewayInit, OnGatewayConnection, OnGat const userInfo = (socket as Socket & { data?: { userInfo?: { userId?: string } } }).data?.userInfo; const userId = userInfo?.userId; - if (event === 'chatEnhanceResult' && currentClientId && lastAgentId && args.length > 0) { + if (event === 'error' && currentClientId && lastAgentId && args.length > 0) { + const errorPayload = args[0] as { + code?: string; + message?: string; + error?: { code?: string; message?: string }; + }; + const code = errorPayload?.code ?? errorPayload?.error?.code ?? null; + const message = errorPayload?.message ?? errorPayload?.error?.message ?? null; + + if ( + code === 'ACP_PERMISSION_DENIED' || + (typeof message === 'string' && message.includes('permission denied')) + ) { + this.notificationPublisher.publishAgentAcpPermissionDenied(currentClientId, { + agentId: lastAgentId, + message, + }); + } else if (code === 'ACP_SESSION_FAILED') { + this.notificationPublisher.publishAgentAcpSessionFailed(currentClientId, { + agentId: lastAgentId, + message, + code, + }); + } else if (code === 'CHAT_ERROR') { + this.notificationPublisher.publishAgentChatFailed(currentClientId, { + agentId: lastAgentId, + message, + code, + }); + } + } else if (event === 'chatEnhanceResult' && currentClientId && lastAgentId && args.length > 0) { const data = args[0] as { success?: boolean; data?: Record }; const payload: Record | undefined = data?.success ? data.data : data; diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.spec.ts index 3591b1a86..12bf77b08 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.spec.ts @@ -14,6 +14,9 @@ describe('AGENSTRA_NOTIFICATION_EVENTS', () => { 'client_user.created', 'client_user.deleted', 'chat_message.created', + 'agent.acp.session_failed', + 'agent.acp.permission_denied', + 'agent.chat.failed', 'filter_rule.triggered', 'environment.created', 'environment.updated', diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.ts index 6aff20e1d..25e2a4776 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.ts @@ -10,6 +10,9 @@ export const AGENSTRA_NOTIFICATION_EVENTS = [ 'ticket.deleted', 'ticket.comment.created', 'chat_message.created', + 'agent.acp.session_failed', + 'agent.acp.permission_denied', + 'agent.chat.failed', 'filter_rule.created', 'filter_rule.updated', 'filter_rule.deleted', diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.publisher.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.publisher.spec.ts index d71399975..bfffd431d 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.publisher.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.publisher.spec.ts @@ -163,6 +163,73 @@ describe('AgenstraNotificationPublisher', () => { }); }); + it('publishes ACP session failure events with workspace client id', () => { + const dispatcher = { + publishFireAndForget: jest.fn(), + } as unknown as NotificationDispatcherService; + const publisher = new AgenstraNotificationPublisher(dispatcher); + + publisher.publishAgentAcpSessionFailed('client-1', { + agentId: 'agent-1', + message: 'ACP session initialize failed', + code: 'ACP_SESSION_FAILED', + }); + + expect(dispatcher.publishFireAndForget).toHaveBeenCalledWith({ + type: 'agent.acp.session_failed', + scopeKey: INSTANCE_SCOPE_KEY, + clientId: 'client-1', + data: expect.objectContaining({ + agentId: 'agent-1', + code: 'ACP_SESSION_FAILED', + }), + }); + }); + + it('publishes ACP permission denied events with workspace client id', () => { + const dispatcher = { + publishFireAndForget: jest.fn(), + } as unknown as NotificationDispatcherService; + const publisher = new AgenstraNotificationPublisher(dispatcher); + + publisher.publishAgentAcpPermissionDenied('client-1', { + agentId: 'agent-1', + message: 'ACP session permission denied', + }); + + expect(dispatcher.publishFireAndForget).toHaveBeenCalledWith({ + type: 'agent.acp.permission_denied', + scopeKey: INSTANCE_SCOPE_KEY, + clientId: 'client-1', + data: expect.objectContaining({ + agentId: 'agent-1', + }), + }); + }); + + it('publishes agent chat failed events with workspace client id', () => { + const dispatcher = { + publishFireAndForget: jest.fn(), + } as unknown as NotificationDispatcherService; + const publisher = new AgenstraNotificationPublisher(dispatcher); + + publisher.publishAgentChatFailed('client-1', { + agentId: 'agent-1', + message: 'Error processing chat message', + code: 'CHAT_ERROR', + }); + + expect(dispatcher.publishFireAndForget).toHaveBeenCalledWith({ + type: 'agent.chat.failed', + scopeKey: INSTANCE_SCOPE_KEY, + clientId: 'client-1', + data: expect.objectContaining({ + agentId: 'agent-1', + code: 'CHAT_ERROR', + }), + }); + }); + it('publishes filter rule triggered events with workspace client id', () => { const dispatcher = { publishFireAndForget: jest.fn(), diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.publisher.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.publisher.ts index 5340d23c8..d756c2b34 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.publisher.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.publisher.ts @@ -104,6 +104,24 @@ export class AgenstraNotificationPublisher implements IIdentityNotificationPubli this.publish('chat_message.created', payload, clientId); } + publishAgentAcpSessionFailed( + clientId: string, + payload: { agentId: string; message?: string | null; code?: string | null }, + ): void { + this.publish('agent.acp.session_failed', payload, clientId); + } + + publishAgentAcpPermissionDenied(clientId: string, payload: { agentId: string; message?: string | null }): void { + this.publish('agent.acp.permission_denied', payload, clientId); + } + + publishAgentChatFailed( + clientId: string, + payload: { agentId: string; message?: string | null; code?: string | null }, + ): void { + this.publish('agent.chat.failed', payload, clientId); + } + publishFilterRuleTriggered(clientId: string, payload: FilterRuleTriggeredNotificationPayload): void { this.publish('filter_rule.triggered', payload, clientId); } diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/clients.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/clients.service.spec.ts index b68a878a4..bf564f699 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/clients.service.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/clients.service.spec.ts @@ -327,7 +327,19 @@ describe('ClientsService', () => { const clients = [mockClient]; const mockConfig: ConfigResponseDto = { gitRepositoryUrl: 'https://github.com/user/repo.git', - agentTypes: [{ type: 'cursor', displayName: 'Cursor' }], + agentTypes: [ + { + type: 'cursor', + displayName: 'Cursor', + capabilities: { + transport: 'acp', + supportsChat: true, + supportsStreaming: true, + supportsToolEvents: true, + supportsQuestions: true, + }, + }, + ], }; const mockProvisioningReference: ProvisioningReferenceEntity = { id: 'ref-uuid', @@ -423,7 +435,19 @@ describe('ClientsService', () => { it('should return client by id with config and isAutoProvisioned set correctly', async () => { const mockConfig: ConfigResponseDto = { gitRepositoryUrl: 'https://github.com/user/repo.git', - agentTypes: [{ type: 'cursor', displayName: 'Cursor' }], + agentTypes: [ + { + type: 'cursor', + displayName: 'Cursor', + capabilities: { + transport: 'acp', + supportsChat: true, + supportsStreaming: true, + supportsToolEvents: true, + supportsQuestions: true, + }, + }, + ], }; const mockProvisioningReference: ProvisioningReferenceEntity = { id: 'ref-uuid', @@ -473,7 +497,19 @@ describe('ClientsService', () => { const updatedClient = { ...mockClient, ...updateDto }; const mockConfig: ConfigResponseDto = { gitRepositoryUrl: 'https://github.com/user/repo.git', - agentTypes: [{ type: 'cursor', displayName: 'Cursor' }], + agentTypes: [ + { + type: 'cursor', + displayName: 'Cursor', + capabilities: { + transport: 'acp', + supportsChat: true, + supportsStreaming: true, + supportsToolEvents: true, + supportsQuestions: true, + }, + }, + ], }; mockRepository.findByIdOrThrow.mockResolvedValue(mockClient); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/README.md b/libs/domains/agenstra/backend/feature-agent-manager/README.md index d168d10cd..4b5398ba5 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/README.md +++ b/libs/domains/agenstra/backend/feature-agent-manager/README.md @@ -23,7 +23,7 @@ Agents are entities that can be created, authenticated, and interacted with thro - ✅ Chat message broadcasting - ✅ Container command forwarding - ✅ Support for UUID or name-based agent identification -- ✅ **Plugin-based agent provider system** - Support for multiple agent implementations (cursor-agent, OpenAI, Anthropic, etc.) through a unified interface +- ✅ **Plugin-based agent provider system** - Cursor and OpenCode via [Agent Client Protocol (ACP)](../../../../docs/agenstra/ai-agents/agent-client-protocol.md) over stdio; additional types through the unified `AgentProvider` interface - ✅ **Plugin-based chat filter system** - Support for multiple message filtering implementations (profanity, PII, content policy, etc.) through a unified interface - ✅ **Extensible architecture** - Easy to add new agent providers and chat filters by implementing the respective interfaces diff --git a/libs/domains/agenstra/backend/feature-agent-manager/jest.config.ts b/libs/domains/agenstra/backend/feature-agent-manager/jest.config.ts index ee33754b9..fb82a5dbd 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/jest.config.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/jest.config.ts @@ -5,6 +5,9 @@ export default { transform: { '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], }, + moduleNameMapper: { + '^@agentclientprotocol/sdk$': '/src/test-utils/acp-sdk.mock.ts', + }, moduleFileExtensions: ['ts', 'js', 'html'], coverageDirectory: '../../../../../coverage/libs/domains/agenstra/backend/feature-agent-manager', }; diff --git a/libs/domains/agenstra/backend/feature-agent-manager/spec/openapi.yaml b/libs/domains/agenstra/backend/feature-agent-manager/spec/openapi.yaml index d7b3f5aba..75675f5ac 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/spec/openapi.yaml +++ b/libs/domains/agenstra/backend/feature-agent-manager/spec/openapi.yaml @@ -1526,7 +1526,7 @@ components: type: [string, 'null'] agentType: type: string - enum: [cursor, opencode, openclaw] + enum: [cursor, opencode] description: Agent provider type (defaults to 'cursor' if not provided) gitRepositorySetupMode: type: string @@ -1577,7 +1577,7 @@ components: type: [string, 'null'] agentType: type: string - enum: [cursor, opencode, openclaw] + enum: [cursor, opencode] description: Agent provider type deploymentConfiguration: type: object @@ -1621,6 +1621,8 @@ components: agentType: type: string description: Agent provider type identifier + capabilities: + $ref: '#/components/schemas/AgentTypeCapabilities' git: type: [object, 'null'] properties: @@ -1658,14 +1660,33 @@ components: type: string AgentTypeInfo: type: object - required: [type, displayName] + required: [type, displayName, capabilities] properties: type: type: string - description: The unique type identifier (e.g., 'cursor', 'openai', 'anthropic') + description: The unique type identifier (e.g., 'cursor', 'opencode') displayName: type: string - description: Human-readable display name (e.g., 'Cursor', 'OpenAI', 'Anthropic Claude') + description: Human-readable display name (e.g., 'Cursor', 'OpenCode') + capabilities: + $ref: '#/components/schemas/AgentTypeCapabilities' + AgentTypeCapabilities: + type: object + required: + [supportsChat, supportsStreaming, supportsToolEvents, supportsQuestions] + properties: + transport: + type: string + enum: [acp] + description: Wire transport for agent messaging (Agent Client Protocol over stdio) + supportsChat: + type: boolean + supportsStreaming: + type: boolean + supportsToolEvents: + type: boolean + supportsQuestions: + type: boolean DependencyHealthStatus: type: string enum: [healthy, degraded, unknown, not_applicable] diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/config.controller.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/config.controller.spec.ts index ab3dfcc00..5d33f64f9 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/config.controller.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/config.controller.spec.ts @@ -1,10 +1,19 @@ import { Test, TestingModule } from '@nestjs/testing'; import { GitRepositorySetupMode } from '../constants/git-repository-setup-mode'; +import type { AgentTypeInfo } from '../dto/config-response.dto'; import { ConfigService } from '../services/config.service'; import { ConfigController } from './config.controller'; +const cursorCapabilities = { + transport: 'acp' as const, + supportsChat: true, + supportsStreaming: true, + supportsToolEvents: true, + supportsQuestions: true, +}; + describe('ConfigController', () => { let controller: ConfigController; let service: jest.Mocked; @@ -36,7 +45,7 @@ describe('ConfigController', () => { describe('getConfig', () => { it('should return configuration with git repository URL and agent types when set', async () => { const gitRepositoryUrl = 'https://github.com/user/repo.git'; - const agentTypes = [{ type: 'cursor', displayName: 'Cursor' }]; + const agentTypes: AgentTypeInfo[] = [{ type: 'cursor', displayName: 'Cursor', capabilities: cursorCapabilities }]; service.getGitRepositoryUrl.mockReturnValue(gitRepositoryUrl); service.getGitRepositorySetupMode.mockReturnValue(GitRepositorySetupMode.CLONE); @@ -54,7 +63,7 @@ describe('ConfigController', () => { }); it('should return configuration with undefined git repository URL when not set', async () => { - const agentTypes = [{ type: 'cursor', displayName: 'Cursor' }]; + const agentTypes: AgentTypeInfo[] = [{ type: 'cursor', displayName: 'Cursor', capabilities: cursorCapabilities }]; service.getGitRepositoryUrl.mockReturnValue(undefined); service.getGitRepositorySetupMode.mockReturnValue(GitRepositorySetupMode.CLONE); @@ -72,10 +81,18 @@ describe('ConfigController', () => { }); it('should return all registered agent types', async () => { - const agentTypes = [ - { type: 'cursor', displayName: 'Cursor' }, - { type: 'openai', displayName: 'OpenAI' }, - { type: 'anthropic', displayName: 'Anthropic Claude' }, + const agentTypes: AgentTypeInfo[] = [ + { type: 'cursor', displayName: 'Cursor', capabilities: cursorCapabilities }, + { + type: 'openai', + displayName: 'OpenAI', + capabilities: { ...cursorCapabilities, transport: undefined }, + }, + { + type: 'anthropic', + displayName: 'Anthropic Claude', + capabilities: { ...cursorCapabilities, transport: undefined }, + }, ]; service.getGitRepositoryUrl.mockReturnValue(undefined); @@ -86,9 +103,13 @@ describe('ConfigController', () => { expect(result.agentTypes).toEqual(agentTypes); expect(result.agentTypes).toHaveLength(3); - expect(result.agentTypes[0]).toEqual({ type: 'cursor', displayName: 'Cursor' }); - expect(result.agentTypes[1]).toEqual({ type: 'openai', displayName: 'OpenAI' }); - expect(result.agentTypes[2]).toEqual({ type: 'anthropic', displayName: 'Anthropic Claude' }); + expect(result.agentTypes[0]).toEqual({ + type: 'cursor', + displayName: 'Cursor', + capabilities: cursorCapabilities, + }); + expect(result.agentTypes[1].type).toBe('openai'); + expect(result.agentTypes[2].type).toBe('anthropic'); }); it('should return empty array when no agent types are registered', async () => { diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/agent-response.dto.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/agent-response.dto.ts index bc511b6cb..9f4eeb9cd 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/agent-response.dto.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/agent-response.dto.ts @@ -1,6 +1,8 @@ import { GitRepositorySetupMode } from '../constants/git-repository-setup-mode'; import { ContainerType } from '../entities/agent.entity'; +import type { AgentTypeCapabilities } from './config-response.dto'; + /** * DTO for agent API responses. * Excludes sensitive information like password hash. @@ -11,6 +13,10 @@ export class AgentResponseDto { description?: string; agentType!: string; containerType!: ContainerType; + /** + * Capabilities of the agent's provider (mirrors config agentTypes capabilities). + */ + capabilities?: AgentTypeCapabilities; vnc?: { port: number; password: string; diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/config-response.dto.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/config-response.dto.ts index ae1498210..41c54b6fa 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/config-response.dto.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/config-response.dto.ts @@ -1,19 +1,39 @@ +import { GitRepositorySetupMode } from '../constants/git-repository-setup-mode'; + +/** + * Capabilities advertised for an agent provider type. + */ +export class AgentTypeCapabilities { + /** + * Wire transport used for agent messaging (`acp` = Agent Client Protocol over stdio). + */ + transport?: 'acp'; + + supportsChat!: boolean; + supportsStreaming!: boolean; + supportsToolEvents!: boolean; + supportsQuestions!: boolean; +} + /** - * Agent type information with identifier and display name. + * Agent type information with identifier, display name, and capabilities. */ export class AgentTypeInfo { /** - * The unique type identifier (e.g., 'cursor', 'openai', 'anthropic') + * The unique type identifier (e.g., 'cursor', 'opencode') */ type!: string; /** - * Human-readable display name (e.g., 'Cursor', 'OpenAI', 'Anthropic Claude') + * Human-readable display name (e.g., 'Cursor', 'OpenCode') */ displayName!: string; -} -import { GitRepositorySetupMode } from '../constants/git-repository-setup-mode'; + /** + * Feature flags for the provider (chat, streaming, ACP transport, etc.). + */ + capabilities!: AgentTypeCapabilities; +} /** * DTO for configuration API responses. diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/create-agent.dto.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/create-agent.dto.ts index b7f5b1dc6..68e0a8479 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/create-agent.dto.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/create-agent.dto.ts @@ -18,7 +18,7 @@ export class CreateAgentDto { @IsOptional() @IsString({ message: 'Agent type must be a string' }) - @IsIn(['cursor', 'opencode', 'openclaw'], { message: 'Agent type must be one of: cursor, opencode, openclaw' }) + @IsIn(['cursor', 'opencode'], { message: 'Agent type must be one of: cursor, opencode' }) agentType?: string; @IsOptional() diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/update-agent.dto.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/update-agent.dto.ts index 9d8be6f7e..ba58b926d 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/update-agent.dto.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/update-agent.dto.ts @@ -18,7 +18,7 @@ export class UpdateAgentDto { @IsOptional() @IsString({ message: 'Agent type must be a string' }) - @IsIn(['cursor', 'opencode', 'openclaw'], { message: 'Agent type must be one of: cursor, opencode, openclaw' }) + @IsIn(['cursor', 'opencode'], { message: 'Agent type must be one of: cursor, opencode' }) agentType?: string; @IsOptional() diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/agent.entity.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/agent.entity.ts index 04bd01872..d0b41f717 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/agent.entity.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/agent.entity.ts @@ -84,6 +84,19 @@ export class AgentEntity { @Column({ type: 'varchar', length: 16, nullable: true, name: 'git_repository_setup_mode' }) gitRepositorySetupMode?: GitRepositorySetupMode; + /** + * Agent-issued ACP session ids keyed by resumeSessionSuffix (empty string = primary chat). + * Used to call `loadSession` after API restarts for main chat and background automation sessions. + */ + @Column({ type: 'jsonb', nullable: true, name: 'acp_sessions' }) + acpSessions?: Record | null; + + /** + * Container id that owned {@link acpSessions}. Ignored when the agent container was replaced. + */ + @Column({ type: 'varchar', length: 255, nullable: true, name: 'acp_session_container_id' }) + acpSessionContainerId?: string | null; + @CreateDateColumn({ name: 'created_at' }) createdAt!: Date; diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts index 50dc5ac26..68b69d7d0 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts @@ -1342,7 +1342,7 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, wantsStream && responseMode !== 'sync' && provider.getCapabilities().supportsStreaming && - provider.sendMessageStream; + Boolean(provider.streamChatEvents || provider.sendMessageStream); const agentResponseTimestamp = new Date().toISOString(); if (supportsStreaming) { @@ -1350,88 +1350,99 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, let aggregatedText = ''; const streamedUnified: AgentResponseObject[] = []; let streamingTurnPersisted = false; - const consumeStreamingRawLine = async (rawLine: string): Promise => { - const parseables = provider.toParseableStrings(rawLine); + const consumeParsedResponse = async (parsed: AgentResponseObject | string): Promise => { + if (!parsed || (typeof parsed === 'object' && Object.keys(parsed).length === 0)) { + return; + } - for (const toParse of parseables) { - try { - const parsed = provider.toUnifiedResponse(toParse); + if (typeof parsed === 'object') { + streamedUnified.push(parsed); + } - if (!parsed) continue; + const events = this.agentResponseToChatEvents(agentUuid, correlationId, sequence++, parsed); - streamedUnified.push(parsed); - const events = this.agentResponseToChatEvents(agentUuid, correlationId, sequence++, parsed); + for (const ev of events) { + if (ev.kind === 'assistantDelta') { + aggregatedText += ev.payload.delta; + } else if (ev.kind === 'assistantMessage') { + const text = ev.payload.text; - for (const ev of events) { - if (ev.kind === 'assistantDelta') { - aggregatedText += ev.payload.delta; - } else if (ev.kind === 'assistantMessage') { - // Full replacement: deltas already built the prose; final `result` NDJSON repeats it. - // Multiple `result` lines must not be concatenated or persisted text becomes 2–3× duplicate. - const t = ev.payload.text; - - if (typeof t === 'string' && t.length > 0) { - aggregatedText = t; - } - } - - this.emitOrPersistChatEvent(agentUuid, ephemeral, socket, ev); + if (typeof text === 'string' && text.length > 0) { + aggregatedText = text; } + } - if (!ephemeral && !streamingTurnPersisted && this.isStreamingTerminalUnifiedResponse(parsed)) { - const built = this.mergeTranscriptPartsIntoFinalResponse( - this.buildFinalStreamingResponse(streamedUnified, aggregatedText), - enrichmentTranscriptParts, - ); + this.emitOrPersistChatEvent(agentUuid, ephemeral, socket, ev); + } - if (built) { - await this.persistFilteredAgentChatResponse(agentUuid, agentResponseTimestamp, built); - streamingTurnPersisted = true; - } - } - } catch (parseError) { - const parseErr = parseError as { message?: string }; + if ( + !ephemeral && + !streamingTurnPersisted && + typeof parsed === 'object' && + this.isStreamingTerminalUnifiedResponse(parsed) + ) { + const built = this.mergeTranscriptPartsIntoFinalResponse( + this.buildFinalStreamingResponse(streamedUnified, aggregatedText), + enrichmentTranscriptParts, + ); - this.logger.warn(`Failed to parse streaming agent line: ${parseErr.message}`); - const events = this.agentResponseToChatEvents(agentUuid, correlationId, sequence++, toParse); + if (built) { + await this.persistFilteredAgentChatResponse(agentUuid, agentResponseTimestamp, built); + streamingTurnPersisted = true; + } + } + }; + const useStructuredStream = Boolean(provider.streamChatEvents); + + if (useStructuredStream) { + for await (const parsed of provider.streamChatEvents!(agent.id, containerId, messageToUse, { + model: data.model, + continue: data.continue, + resumeSessionSuffix: data.resumeSessionSuffix, + })) { + await consumeParsedResponse(parsed); + } + } else if (provider.sendMessageStream) { + const consumeStreamingRawLine = async (rawLine: string): Promise => { + const parseables = provider.toParseableStrings(rawLine); - for (const ev of events) { - if (ev.kind === 'assistantDelta') { - aggregatedText += ev.payload.delta; - } else if (ev.kind === 'assistantMessage') { - const t = ev.payload.text; + for (const toParse of parseables) { + try { + const parsed = provider.toUnifiedResponse(toParse); - if (typeof t === 'string' && t.length > 0) { - aggregatedText = t; - } + if (!parsed) { + continue; } - this.emitOrPersistChatEvent(agentUuid, ephemeral, socket, ev); + await consumeParsedResponse(parsed); + } catch (parseError) { + const parseErr = parseError as { message?: string }; + + this.logger.warn(`Failed to parse streaming agent line: ${parseErr.message}`); + await consumeParsedResponse(toParse); } } - } - }; + }; - for await (const chunk of provider.sendMessageStream(agent.id, containerId, messageToUse, { - model: data.model, - continue: data.continue, - resumeSessionSuffix: data.resumeSessionSuffix, - })) { - buffered += chunk; - const parts = buffered.split('\n'); + for await (const chunk of provider.sendMessageStream(agent.id, containerId, messageToUse, { + model: data.model, + continue: data.continue, + resumeSessionSuffix: data.resumeSessionSuffix, + })) { + buffered += chunk; + const parts = buffered.split('\n'); - buffered = parts.pop() ?? ''; + buffered = parts.pop() ?? ''; - for (const rawLine of parts) { - await consumeStreamingRawLine(rawLine); + for (const rawLine of parts) { + await consumeStreamingRawLine(rawLine); + } } - } - // NDJSON producers often omit a trailing newline on the last frame; without this flush, - // the final line never runs through consumeStreamingRawLine and nothing is persisted. - if (buffered.trim().length > 0) { - await consumeStreamingRawLine(buffered); - buffered = ''; + if (buffered.trim().length > 0) { + await consumeStreamingRawLine(buffered); + buffered = ''; + } } if (!ephemeral && !streamingTurnPersisted) { @@ -1690,8 +1701,14 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, } } } catch (error) { - socket.emit('error', createErrorResponse('Error processing chat message', 'CHAT_ERROR')); const err = error as { message?: string; stack?: string }; + const errorCode = err.message?.includes('permission denied') + ? 'ACP_PERMISSION_DENIED' + : err.message?.includes('ACP') + ? 'ACP_SESSION_FAILED' + : 'CHAT_ERROR'; + + socket.emit('error', createErrorResponse('Error processing chat message', errorCode)); this.logger.error(`Chat error for agent ${agentUuid}: ${err.message}`, err.stack); } diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.spec.ts index 15c6e8c1c..6f5eb2cda 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.spec.ts @@ -17,7 +17,6 @@ import { WorkspaceConfigurationOverrideEntity } from '../entities/workspace-conf import { AgentsGateway } from '../gateways/agents.gateway'; import { AgentProviderFactory } from '../providers/agent-provider.factory'; import { CursorAgentProvider } from '../providers/agents/cursor-agent.provider'; -import { OpenClawAgentProvider } from '../providers/agents/openclaw-agent.provider'; import { OpenCodeAgentProvider } from '../providers/agents/opencode-agent.provider'; import { ChatFilterFactory } from '../providers/chat-filter.factory'; import { BidirectionalChatFilter } from '../providers/filters/bidirectional-chat-filter'; @@ -199,13 +198,6 @@ describe('AgentsModule', () => { expect(provider).toBeInstanceOf(OpenCodeAgentProvider); }); - it('should provide OpenClawAgentProvider', () => { - const provider = module.get(OpenClawAgentProvider); - - expect(provider).toBeDefined(); - expect(provider).toBeInstanceOf(OpenClawAgentProvider); - }); - it('should register CursorAgentProvider and OpenCodeAgentProvider via AGENT_PROVIDER_INIT factory', () => { const factory = module.get(AgentProviderFactory); const cursorProvider = module.get(CursorAgentProvider); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.ts index 40609c9f0..2bb9dc734 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.ts @@ -28,8 +28,12 @@ import { WorkspaceConfigurationOverrideEntity } from '../entities/workspace-conf import { AgentsGateway } from '../gateways/agents.gateway'; import { AgentProviderFactory } from '../providers/agent-provider.factory'; import type { AgentProvider } from '../providers/agent-provider.interface'; +import { AcpAgentMessagingService } from '../providers/acp/acp-agent-messaging.service'; +import { AcpClientHostFactory } from '../providers/acp/acp-client-host'; +import { AcpNotificationMapper } from '../providers/acp/acp-notification-mapper'; +import { AcpSessionService } from '../providers/acp/acp-session.service'; +import { DockerAcpTransportFactory } from '../providers/acp/docker-acp-transport'; import { CursorAgentProvider } from '../providers/agents/cursor-agent.provider'; -import { OpenClawAgentProvider } from '../providers/agents/openclaw-agent.provider'; import { OpenCodeAgentProvider } from '../providers/agents/opencode-agent.provider'; import { ChatFilterFactory } from '../providers/chat-filter.factory'; import { BidirectionalChatFilter } from '../providers/filters/bidirectional-chat-filter'; @@ -125,10 +129,14 @@ import { WorkspaceConfigurationOverridesService } from '../services/workspace-co DeploymentConfigurationsRepository, DeploymentRunsRepository, DockerService, + AcpNotificationMapper, + AcpClientHostFactory, + DockerAcpTransportFactory, + AcpSessionService, + AcpAgentMessagingService, AgentProviderFactory, CursorAgentProvider, OpenCodeAgentProvider, - OpenClawAgentProvider, PipelineProviderFactory, GitHubProvider, GitLabProvider, @@ -152,12 +160,10 @@ import { WorkspaceConfigurationOverridesService } from '../services/workspace-co factory: AgentProviderFactory, cursorProvider: CursorAgentProvider, opencodeProvider: OpenCodeAgentProvider, - openclawProvider: OpenClawAgentProvider, dynamicLoader: DynamicProviderLoaderService, ) => { factory.registerProvider(cursorProvider); factory.registerProvider(opencodeProvider); - factory.registerProvider(openclawProvider); await registerDynamicProviders({ envKey: 'DYNAMIC_AGENT_PROVIDERS', @@ -169,13 +175,7 @@ import { WorkspaceConfigurationOverridesService } from '../services/workspace-co return true; }, - inject: [ - AgentProviderFactory, - CursorAgentProvider, - OpenCodeAgentProvider, - OpenClawAgentProvider, - DynamicProviderLoaderService, - ], + inject: [AgentProviderFactory, CursorAgentProvider, OpenCodeAgentProvider, DynamicProviderLoaderService], }, { provide: 'PIPELINE_PROVIDER_INIT', diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/acp-agent-messaging.service.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/acp-agent-messaging.service.ts new file mode 100644 index 000000000..095d16f53 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/acp-agent-messaging.service.ts @@ -0,0 +1,49 @@ +import { Injectable } from '@nestjs/common'; + +import type { AgentProviderOptions, AgentResponseObject } from '../agent-provider.interface'; + +import type { AcpLaunchSpec, AcpSessionKey } from './acp-launch-spec.types'; +import { AcpSessionService } from './acp-session.service'; + +@Injectable() +export class AcpAgentMessagingService { + constructor(private readonly acpSessionService: AcpSessionService) {} + + async sendMessage( + key: AcpSessionKey, + launchSpec: AcpLaunchSpec, + message: string, + options?: AgentProviderOptions, + ): Promise { + return this.acpSessionService.prompt(key, launchSpec, message, options); + } + + async *sendMessageStream( + key: AcpSessionKey, + launchSpec: AcpLaunchSpec, + message: string, + options?: AgentProviderOptions, + ): AsyncIterable { + for await (const obj of this.acpSessionService.promptStream(key, launchSpec, message, options)) { + yield JSON.stringify(obj); + } + } + + async sendInitialization( + key: AcpSessionKey, + launchSpec: AcpLaunchSpec, + instructions: string, + options?: AgentProviderOptions, + ): Promise { + await this.acpSessionService.prompt(key, launchSpec, instructions, options); + } + + async *streamChatEvents( + key: AcpSessionKey, + launchSpec: AcpLaunchSpec, + message: string, + options?: AgentProviderOptions, + ): AsyncIterable { + yield* this.acpSessionService.promptStream(key, launchSpec, message, options); + } +} diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/acp-client-host.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/acp-client-host.spec.ts new file mode 100644 index 000000000..a4b477a96 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/acp-client-host.spec.ts @@ -0,0 +1,30 @@ +import type { PermissionOption } from '@agentclientprotocol/sdk'; + +import { selectAutoApprovePermissionOptionId } from './acp-client-host'; + +describe('selectAutoApprovePermissionOptionId', () => { + it('prefers allow_always over allow_once and reject options', () => { + const options: PermissionOption[] = [ + { optionId: 'reject', name: 'Reject', kind: 'reject_once' }, + { optionId: 'once', name: 'Allow once', kind: 'allow_once' }, + { optionId: 'always', name: 'Allow always', kind: 'allow_always' }, + ]; + + expect(selectAutoApprovePermissionOptionId(options)).toBe('always'); + }); + + it('prefers allow_once when allow_always is absent', () => { + const options: PermissionOption[] = [ + { optionId: 'reject', name: 'Reject', kind: 'reject_once' }, + { optionId: 'once', name: 'Allow once', kind: 'allow_once' }, + ]; + + expect(selectAutoApprovePermissionOptionId(options)).toBe('once'); + }); + + it('falls back to the first option when no allow_* kinds exist', () => { + const options: PermissionOption[] = [{ optionId: 'only', name: 'Only', kind: 'reject_once' }]; + + expect(selectAutoApprovePermissionOptionId(options)).toBe('only'); + }); +}); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/acp-client-host.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/acp-client-host.ts new file mode 100644 index 000000000..02be73979 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/acp-client-host.ts @@ -0,0 +1,123 @@ +import type { + Client, + PermissionOption, + ReadTextFileRequest, + ReadTextFileResponse, + RequestPermissionRequest, + RequestPermissionResponse, + SessionNotification, + WriteTextFileRequest, + WriteTextFileResponse, +} from '@agentclientprotocol/sdk'; +import { Injectable, Logger } from '@nestjs/common'; + +import { AgentFileSystemService } from '../../services/agent-file-system.service'; +import type { AgentResponseObject } from '../agent-provider.interface'; + +import { AcpNotificationMapper, createAcpToolCallState, type AcpToolCallState } from './acp-notification-mapper'; + +export interface AcpClientHostContext { + agentId: string; + containerId: string; +} + +export interface AcpPromptEventSink { + onResponses(objects: AgentResponseObject[]): void; +} + +/** + * Mutable callbacks/state for a long-lived ACP client connection. + * Updated at the start of each prompt so session reuse keeps streaming to the active turn. + */ +export interface AcpClientHostBindings { + sink: AcpPromptEventSink; + toolCallState: AcpToolCallState; +} + +@Injectable() +export class AcpClientHostFactory { + private readonly logger = new Logger(AcpClientHostFactory.name); + + constructor( + private readonly agentFileSystemService: AgentFileSystemService, + private readonly mapper: AcpNotificationMapper, + ) {} + + create(context: AcpClientHostContext, bindings: AcpClientHostBindings): Client { + const autoApprove = process.env.ACP_AUTO_APPROVE !== 'false'; + + return { + sessionUpdate: async (params: SessionNotification): Promise => { + const mapped = this.mapper.mapSessionUpdate(params, bindings.toolCallState); + + if (mapped.length > 0) { + bindings.sink.onResponses(mapped); + } + }, + requestPermission: async (params: RequestPermissionRequest): Promise => { + if (params.options.length === 0) { + const errorMessage = `ACP session permission denied for agent ${context.agentId}: no permission options available`; + + this.logger.warn(errorMessage); + throw new Error(errorMessage); + } + + if (!autoApprove) { + this.logger.warn(`ACP permission request cancelled for agent ${context.agentId}: auto-approve is disabled`); + + return { + outcome: { + outcome: 'cancelled', + }, + }; + } + + const optionId = selectAutoApprovePermissionOptionId(params.options); + + return { + outcome: { + outcome: 'selected', + optionId, + }, + }; + }, + readTextFile: async (params: ReadTextFileRequest): Promise => { + const dto = await this.agentFileSystemService.readFile(context.agentId, params.path, 'app'); + const text = dto.encoding === 'utf-8' ? Buffer.from(dto.content, 'base64').toString('utf-8') : dto.content; + + return { content: text }; + }, + writeTextFile: async (params: WriteTextFileRequest): Promise => { + const base64Content = Buffer.from(params.content, 'utf-8').toString('base64'); + + await this.agentFileSystemService.writeFile(context.agentId, params.path, base64Content, 'utf-8', 'app'); + + return {}; + }, + }; + } +} + +export function createAcpClientHostBindings(sink: AcpPromptEventSink): AcpClientHostBindings { + return { + sink, + toolCallState: createAcpToolCallState(), + }; +} + +/** Prefer allow_once / allow_always over reject_* when auto-approving. */ +export function selectAutoApprovePermissionOptionId(options: PermissionOption[]): string { + const allowAlways = options.find((option) => option.kind === 'allow_always'); + + if (allowAlways) { + return allowAlways.optionId; + } + + const allowOnce = options.find((option) => option.kind === 'allow_once'); + + if (allowOnce) { + return allowOnce.optionId; + } + + return options[0].optionId; +} diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/acp-launch-spec.types.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/acp-launch-spec.types.ts new file mode 100644 index 000000000..ad1b50e4a --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/acp-launch-spec.types.ts @@ -0,0 +1,15 @@ +/** + * How to spawn an ACP-speaking agent process inside a worker container. + */ +export interface AcpLaunchSpec { + executable: string; + args: string[]; + cwd: string; + supportsLoadSession: boolean; +} + +export interface AcpSessionKey { + agentId: string; + containerId: string; + resumeSessionSuffix?: string; +} diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/acp-notification-mapper.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/acp-notification-mapper.spec.ts new file mode 100644 index 000000000..7663fa673 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/acp-notification-mapper.spec.ts @@ -0,0 +1,191 @@ +import { AcpNotificationMapper, createAcpToolCallState } from './acp-notification-mapper'; + +describe('AcpNotificationMapper', () => { + const mapper = new AcpNotificationMapper(); + + it('maps agent_message_chunk to delta', () => { + const results = mapper.mapSessionUpdate({ + sessionId: 'sess-1', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Hello' }, + }, + } as never); + + expect(results).toEqual([{ type: 'delta', delta: 'Hello' }]); + }); + + it('maps tool_call to tool_call unified object', () => { + const results = mapper.mapSessionUpdate({ + sessionId: 'sess-1', + update: { + sessionUpdate: 'tool_call', + toolCallId: 'tc-1', + title: 'bash', + status: 'pending', + }, + } as never); + + expect(results).toEqual([ + { + type: 'tool_call', + toolCallId: 'tc-1', + name: 'bash', + status: 'started', + }, + ]); + }); + + it('prefers compact title over kind for find/grep style tools', () => { + const results = mapper.mapSessionUpdate({ + sessionId: 'sess-1', + update: { + sessionUpdate: 'tool_call', + toolCallId: 'tc-find', + title: 'Find', + kind: 'search', + status: 'pending', + }, + } as never); + + expect(results[0]).toMatchObject({ name: 'Find' }); + }); + + it('uses kind label when title is a long shell command', () => { + const longCommand = '`ls -la /app; echo ---; du -sh /opt/workspace; uname -a; which node`'; + const results = mapper.mapSessionUpdate({ + sessionId: 'sess-1', + update: { + sessionUpdate: 'tool_call', + toolCallId: 'tc-shell', + title: longCommand, + kind: 'execute', + status: 'pending', + rawInput: { command: longCommand }, + }, + } as never); + + expect(results[0]).toMatchObject({ + type: 'tool_call', + name: 'shell', + args: { command: longCommand }, + }); + }); + + it('remembers tool name across updates that omit title', () => { + const state = createAcpToolCallState(); + + mapper.mapSessionUpdate( + { + sessionId: 'sess-1', + update: { + sessionUpdate: 'tool_call', + toolCallId: 'tc-1', + title: 'Grep', + status: 'pending', + }, + } as never, + state, + ); + + const inProgress = mapper.mapSessionUpdate( + { + sessionId: 'sess-1', + update: { + sessionUpdate: 'tool_call_update', + toolCallId: 'tc-1', + status: 'in_progress', + }, + } as never, + state, + ); + + const completed = mapper.mapSessionUpdate( + { + sessionId: 'sess-1', + update: { + sessionUpdate: 'tool_call_update', + toolCallId: 'tc-1', + status: 'completed', + content: [{ type: 'content', content: { type: 'text', text: 'match in file.ts' } }], + }, + } as never, + state, + ); + + expect(inProgress).toEqual([]); + expect(completed).toEqual([ + { + type: 'tool_result', + toolCallId: 'tc-1', + name: 'Grep', + result: 'match in file.ts', + isError: false, + }, + ]); + }); + + it('does not emit a second tool_call for status-only progress updates', () => { + const state = createAcpToolCallState(); + + const first = mapper.mapSessionUpdate( + { + sessionId: 'sess-1', + update: { + sessionUpdate: 'tool_call', + toolCallId: 'tc-shell', + title: 'shell', + kind: 'execute', + status: 'pending', + }, + } as never, + state, + ); + const second = mapper.mapSessionUpdate( + { + sessionId: 'sess-1', + update: { + sessionUpdate: 'tool_call_update', + toolCallId: 'tc-shell', + status: 'in_progress', + }, + } as never, + state, + ); + + expect(first).toHaveLength(1); + expect(second).toEqual([]); + }); + + it('maps completed tool updates to tool results with rawOutput', () => { + const results = mapper.mapSessionUpdate({ + sessionId: 'sess-1', + update: { + sessionUpdate: 'tool_call_update', + toolCallId: 'tc-1', + title: 'bash', + status: 'completed', + rawOutput: { stdout: 'ok', stderr: '', signal: '' }, + }, + } as never); + + expect(results).toEqual([ + { + type: 'tool_result', + toolCallId: 'tc-1', + name: 'bash', + result: { stdout: 'ok', stderr: '', signal: '' }, + isError: false, + }, + ]); + }); + + it('buildFinalResult produces result object', () => { + expect(mapper.buildFinalResult('done', 'sess-1')).toEqual({ + type: 'result', + subtype: 'success', + result: 'done', + session_id: 'sess-1', + }); + }); +}); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/acp-notification-mapper.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/acp-notification-mapper.ts new file mode 100644 index 000000000..0a2d4fc97 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/acp-notification-mapper.ts @@ -0,0 +1,327 @@ +import type { SessionNotification, ToolCallContent } from '@agentclientprotocol/sdk'; +import { Injectable } from '@nestjs/common'; + +import type { AgentResponseObject } from '../agent-provider.interface'; + +/** Per-prompt ACP tool-call bookkeeping (names + which calls were already emitted). */ +export interface AcpToolCallState { + names: Map; + emittedCalls: Set; +} + +export function createAcpToolCallState(): AcpToolCallState { + return { + names: new Map(), + emittedCalls: new Set(), + }; +} + +/** @deprecated Prefer {@link AcpToolCallState}; kept for older call sites/tests. */ +export type AcpToolNameCache = Map; + +const TOOL_KIND_LABELS: Record = { + execute: 'shell', + search: 'search', + read: 'read', + edit: 'edit', + delete: 'delete', + move: 'move', + think: 'think', + fetch: 'fetch', + switch_mode: 'switch_mode', + other: 'tool', +}; + +const MAX_TITLE_NAME_LENGTH = 80; + +@Injectable() +export class AcpNotificationMapper { + mapSessionUpdate( + notification: SessionNotification, + toolState: AcpToolCallState | AcpToolNameCache = createAcpToolCallState(), + ): AgentResponseObject[] { + const state = normalizeToolCallState(toolState); + const update = notification.update; + const results: AgentResponseObject[] = []; + + switch (update.sessionUpdate) { + case 'agent_message_chunk': + if (update.content.type === 'text' && update.content.text) { + results.push({ type: 'delta', delta: update.content.text }); + } + break; + case 'agent_thought_chunk': + results.push({ type: 'thinking', phase: 'running' }); + break; + case 'tool_call': { + const name = resolveToolName(update, state.names); + const toolCall: AgentResponseObject = { + type: 'tool_call', + toolCallId: update.toolCallId, + name, + status: mapAcpToolStatus(update.status), + }; + + if (update.rawInput !== undefined) { + toolCall.args = update.rawInput; + } + + state.emittedCalls.add(update.toolCallId); + results.push(toolCall); + break; + } + case 'tool_call_update': { + const name = resolveToolName(update, state.names); + + if (update.status === 'completed' || update.status === 'failed') { + results.push({ + type: 'tool_result', + toolCallId: update.toolCallId, + name, + result: extractToolResultPayload(update), + isError: update.status === 'failed', + }); + } else if (!state.emittedCalls.has(update.toolCallId)) { + // First sighting of this tool (update without a prior tool_call). + const toolCall: AgentResponseObject = { + type: 'tool_call', + toolCallId: update.toolCallId, + name, + status: mapAcpToolStatus(update.status ?? 'in_progress'), + }; + + if (update.rawInput !== undefined) { + toolCall.args = update.rawInput; + } + + state.emittedCalls.add(update.toolCallId); + results.push(toolCall); + } + // Else: status-only progress (started → in_progress) — do not emit another row. + break; + } + case 'plan': + case 'plan_update': + results.push({ type: 'thinking', phase: 'plan' }); + break; + default: + break; + } + + return results; + } + + buildFinalResult(aggregatedText: string, sessionId?: string): AgentResponseObject { + return { + type: 'result', + subtype: 'success', + result: aggregatedText, + ...(sessionId ? { session_id: sessionId } : {}), + }; + } +} + +function normalizeToolCallState(toolState: AcpToolCallState | AcpToolNameCache): AcpToolCallState { + if (toolState instanceof Map) { + return { + names: toolState, + emittedCalls: new Set(toolState.keys()), + }; + } + + return toolState; +} + +function mapAcpToolStatus(status: string | null | undefined): 'started' | 'inProgress' | 'succeeded' | 'failed' { + if (status === 'completed') { + return 'succeeded'; + } + + if (status === 'failed') { + return 'failed'; + } + + if (status === 'pending') { + return 'started'; + } + + return 'inProgress'; +} + +function resolveToolName( + update: { + toolCallId: string; + title?: string | null; + kind?: string | null; + rawInput?: unknown; + }, + nameCache: AcpToolNameCache, +): string { + const fromTitle = normalizeTitle(update.title); + const fromKind = kindToLabel(update.kind); + const fromRawInput = extractNameFromRawInput(update.rawInput); + + let candidate = ''; + + if (fromTitle && isCompactTitle(fromTitle)) { + candidate = fromTitle; + } else if (fromKind) { + candidate = fromKind; + } else if (fromRawInput) { + candidate = fromRawInput; + } else if (fromTitle) { + candidate = abbreviateCommandTitle(fromTitle); + } + + if (candidate) { + nameCache.set(update.toolCallId, candidate); + + return candidate; + } + + return nameCache.get(update.toolCallId) ?? 'tool'; +} + +function normalizeTitle(title: string | null | undefined): string { + if (typeof title !== 'string') { + return ''; + } + + const trimmed = title.trim(); + + if ( + (trimmed.startsWith('`') && trimmed.endsWith('`')) || + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return trimmed.slice(1, -1).trim(); + } + + return trimmed; +} + +function isCompactTitle(title: string): boolean { + if (title.length > MAX_TITLE_NAME_LENGTH || title.includes('\n')) { + return false; + } + + // Shell agents often put the full command in title; treat those as non-names. + if (/[;|]|&&|\|\|/.test(title) || /^\s*(ls|cd|cat|echo|for|while|if|sudo)\b/.test(title)) { + return false; + } + + return true; +} + +function abbreviateCommandTitle(title: string): string { + const firstLine = title.split('\n')[0]?.trim() ?? title; + const firstToken = firstLine.split(/\s+/)[0] ?? firstLine; + + if (firstToken.length > 0 && firstToken.length <= MAX_TITLE_NAME_LENGTH) { + return firstToken.replace(/^#+/, '') || 'shell'; + } + + return 'shell'; +} + +function kindToLabel(kind: string | null | undefined): string { + if (typeof kind !== 'string' || !kind.trim()) { + return ''; + } + + const normalized = kind.trim().toLowerCase(); + + return TOOL_KIND_LABELS[normalized] ?? normalized; +} + +function extractNameFromRawInput(rawInput: unknown): string { + if (!rawInput || typeof rawInput !== 'object') { + return ''; + } + + const record = rawInput as Record; + + for (const key of ['name', 'toolName', 'tool', 'command', 'cmd']) { + const value = record[key]; + + if (typeof value === 'string' && value.trim()) { + const trimmed = value.trim(); + + if (key === 'command' || key === 'cmd') { + return abbreviateCommandTitle(trimmed); + } + + if (isCompactTitle(trimmed)) { + return trimmed; + } + } + } + + return ''; +} + +function extractToolResultPayload(update: { + status?: string | null; + rawOutput?: unknown; + content?: Array | null; +}): unknown { + if (update.rawOutput !== undefined && update.rawOutput !== null) { + return update.rawOutput; + } + + if (Array.isArray(update.content) && update.content.length > 0) { + return serializeToolContent(update.content); + } + + return update.status ?? 'completed'; +} + +function serializeToolContent(content: Array): unknown { + const texts: string[] = []; + const parts: unknown[] = []; + + for (const item of content) { + if (item.type === 'content') { + const block = item.content; + + if ( + block && + typeof block === 'object' && + 'type' in block && + block.type === 'text' && + typeof block.text === 'string' + ) { + texts.push(block.text); + } else { + parts.push(item); + } + } else if (item.type === 'diff') { + parts.push({ + type: 'diff', + path: item.path, + oldText: item.oldText, + newText: item.newText, + }); + } else if (item.type === 'terminal') { + parts.push({ + type: 'terminal', + terminalId: item.terminalId, + }); + } else { + parts.push(item); + } + } + + if (parts.length === 0) { + return texts.join('\n'); + } + + if (texts.length === 0 && parts.length === 1) { + return parts[0]; + } + + return { + ...(texts.length > 0 ? { text: texts.join('\n') } : {}), + parts, + }; +} diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/acp-provider.config.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/acp-provider.config.ts new file mode 100644 index 000000000..90fcef32f --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/acp-provider.config.ts @@ -0,0 +1,43 @@ +import type { AcpLaunchSpec } from './acp-launch-spec.types'; + +export const ACP_INITIALIZATION_INSTRUCTIONS = `You are operating in a codebase with a structured command and rules system. Follow these guidelines: + +COMMAND SYSTEM: +- Executable commands **CAN** be found in the project folder at .cursor/commands +- Each command **IS** a Markdown (.md) file +- The command invocation format **IS** /{filenamewithoutextension} (where filenamewithoutextension is the filename without the .md extension) +- Example: A file named "ship.md" in .cursor/commands **IS** invoked as /ship +- Commands **MUST** be at the start of a message to be recognized and executed +- When you need to execute a command, you **MUST** look for it in .cursor/commands and invoke it using the /{filenamewithoutextension} format at the beginning of your message + +RULES SYSTEM: +- Basic context files **CAN** be found in .cursor/rules +- Rules files **MAY** contain an "alwaysApply" property (this is optional in the system) +- If a rules file has "alwaysApply: true", you **MUST** always read and apply that file regardless of context +- If a rules file has "alwaysApply: false", you **SHALL** only apply that file to files matching the respective "globs:" entries +- The "globs:" property **CONTAINS** comma-separated glob patterns that specify which files the rules apply to +- When processing a file, you **MUST** check all rules files with "alwaysApply: true" and all rules files with "alwaysApply: false" whose globs match the current file path + +MESSAGE HANDLING: +- This is a one-time initialization message to establish system context +- All subsequent messages you receive **WILL** be from users +- You **MUST** treat all messages after this initialization as user requests, tasks, or questions +- You **SHALL** respond to user messages as you would in a normal conversation, applying the command and rules system guidelines above`; + +export const CURSOR_ACP_LAUNCH_SPEC: AcpLaunchSpec = { + executable: 'cursor-agent', + args: ['acp'], + cwd: '/app', + supportsLoadSession: true, +}; + +export const OPENCODE_ACP_LAUNCH_SPEC: AcpLaunchSpec = { + executable: 'opencode', + args: ['acp'], + cwd: '/app', + supportsLoadSession: true, +}; + +export function buildResumeSessionId(agentId: string, containerId: string, resumeSessionSuffix?: string): string { + return `${agentId}-${containerId}${resumeSessionSuffix ?? ''}`; +} diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/acp-session.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/acp-session.service.spec.ts new file mode 100644 index 000000000..f3a66b17b --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/acp-session.service.spec.ts @@ -0,0 +1,79 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { AgentsRepository } from '../../repositories/agents.repository'; + +import { AcpClientHostFactory } from './acp-client-host'; +import { AcpNotificationMapper } from './acp-notification-mapper'; +import { AcpSessionService } from './acp-session.service'; +import { DockerAcpTransportFactory } from './docker-acp-transport'; + +type CreateOrLoad = ( + connection: { + loadSession: jest.Mock; + newSession: jest.Mock; + }, + launchSpec: { cwd: string; supportsLoadSession: boolean }, + knownSessionId?: string, +) => Promise; + +describe('AcpSessionService', () => { + let service: AcpSessionService; + const loadSession = jest.fn(); + const newSession = jest.fn(); + + beforeEach(async () => { + jest.clearAllMocks(); + newSession.mockResolvedValue({ sessionId: 'sess-new' }); + loadSession.mockResolvedValue({}); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AcpSessionService, + { provide: DockerAcpTransportFactory, useValue: { connect: jest.fn() } }, + { provide: AcpClientHostFactory, useValue: { create: jest.fn() } }, + { provide: AcpNotificationMapper, useValue: { mapSessionUpdate: jest.fn(), buildFinalResult: jest.fn() } }, + { + provide: AgentsRepository, + useValue: { + findPersistedAcpSessionId: jest.fn(), + saveAcpSession: jest.fn(), + clearAcpSession: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(AcpSessionService); + }); + + const createOrLoad = (): CreateOrLoad => + (service as unknown as { createOrLoadSession: CreateOrLoad }).createOrLoadSession.bind(service); + + it('createOrLoadSession loads a known agent-issued session id', async () => { + const launchSpec = { cwd: '/app', supportsLoadSession: true }; + + await expect(createOrLoad()({ loadSession, newSession }, launchSpec, 'sess-old')).resolves.toBe('sess-old'); + expect(loadSession).toHaveBeenCalledWith({ + sessionId: 'sess-old', + cwd: '/app', + mcpServers: [], + }); + expect(newSession).not.toHaveBeenCalled(); + }); + + it('createOrLoadSession falls back to newSession when loadSession fails', async () => { + loadSession.mockRejectedValueOnce(new Error('gone')); + const launchSpec = { cwd: '/app', supportsLoadSession: true }; + + await expect(createOrLoad()({ loadSession, newSession }, launchSpec, 'sess-old')).resolves.toBe('sess-new'); + expect(newSession).toHaveBeenCalled(); + }); + + it('createOrLoadSession skips load when no known id', async () => { + const launchSpec = { cwd: '/app', supportsLoadSession: true }; + + await expect(createOrLoad()({ loadSession, newSession }, launchSpec)).resolves.toBe('sess-new'); + expect(loadSession).not.toHaveBeenCalled(); + expect(newSession).toHaveBeenCalled(); + }); +}); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/acp-session.service.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/acp-session.service.ts new file mode 100644 index 000000000..b38ee0ac0 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/acp-session.service.ts @@ -0,0 +1,297 @@ +import { ClientSideConnection, PROTOCOL_VERSION } from '@agentclientprotocol/sdk'; +import { Injectable, Logger } from '@nestjs/common'; + +import { AgentsRepository } from '../../repositories/agents.repository'; +import type { AgentProviderOptions, AgentResponseObject } from '../agent-provider.interface'; + +import { + AcpClientHostFactory, + createAcpClientHostBindings, + type AcpClientHostBindings, + type AcpPromptEventSink, +} from './acp-client-host'; +import type { AcpLaunchSpec, AcpSessionKey } from './acp-launch-spec.types'; +import { AcpNotificationMapper, createAcpToolCallState } from './acp-notification-mapper'; +import type { AcpTransport } from './acp-transport.interface'; +import { DockerAcpTransportFactory } from './docker-acp-transport'; + +interface ManagedAcpSession { + connection: ClientSideConnection; + transport: AcpTransport; + acpSessionId: string; + bindings: AcpClientHostBindings; +} + +@Injectable() +export class AcpSessionService { + private readonly logger = new Logger(AcpSessionService.name); + private readonly sessions = new Map(); + + constructor( + private readonly transportFactory: DockerAcpTransportFactory, + private readonly clientHostFactory: AcpClientHostFactory, + private readonly mapper: AcpNotificationMapper, + private readonly agentsRepository: AgentsRepository, + ) {} + + private sessionMapKey(key: AcpSessionKey): string { + return `${key.agentId}:${key.containerId}:${key.resumeSessionSuffix ?? ''}`; + } + + async closeSession(key: AcpSessionKey): Promise { + const mapKey = this.sessionMapKey(key); + const existing = this.sessions.get(mapKey); + + if (!existing) { + return; + } + + await existing.transport.close(); + this.sessions.delete(mapKey); + } + + async closeSessionsForAgent(agentId: string): Promise { + const prefix = `${agentId}:`; + + for (const [mapKey, session] of this.sessions.entries()) { + if (!mapKey.startsWith(prefix)) { + continue; + } + + await session.transport.close(); + this.sessions.delete(mapKey); + } + } + + async *promptStream( + key: AcpSessionKey, + launchSpec: AcpLaunchSpec, + message: string, + options?: AgentProviderOptions, + ): AsyncIterable { + const queue: AgentResponseObject[] = []; + let aggregatedText = ''; + let acpSessionId: string | undefined; + let done = false; + let promptError: unknown | null = null; + + const notify = (() => { + let resolve: (() => void) | null = null; + const wait = () => + new Promise((r) => { + resolve = r; + }); + const signal = () => { + resolve?.(); + resolve = null; + }; + + return { wait, signal }; + })(); + + const sink: AcpPromptEventSink = { + onResponses: (objects: AgentResponseObject[]) => { + for (const obj of objects) { + if (obj.type === 'delta' && typeof obj.delta === 'string') { + aggregatedText += obj.delta; + } else if (obj.type === 'result' && typeof obj.result === 'string') { + aggregatedText = obj.result; + } + + queue.push(obj); + } + + notify.signal(); + }, + }; + + void this.runPrompt(key, launchSpec, message, options, sink) + .then((result) => { + acpSessionId = result.acpSessionId; + }) + .catch((error: unknown) => { + promptError = error; + }) + .finally(() => { + done = true; + notify.signal(); + }); + + while (!done || queue.length > 0) { + const item = queue.shift(); + + if (item) { + yield item; + continue; + } + + if (done) { + break; + } + + await notify.wait(); + } + + if (promptError) { + throw promptError; + } + + if (aggregatedText.trim()) { + yield this.mapper.buildFinalResult(aggregatedText, acpSessionId); + } + } + + async prompt( + key: AcpSessionKey, + launchSpec: AcpLaunchSpec, + message: string, + options?: AgentProviderOptions, + ): Promise { + const parts: string[] = []; + + for await (const obj of this.promptStream(key, launchSpec, message, options)) { + parts.push(JSON.stringify(obj)); + } + + return parts.join('\n'); + } + + private async runPrompt( + key: AcpSessionKey, + launchSpec: AcpLaunchSpec, + message: string, + options: AgentProviderOptions | undefined, + sink: AcpPromptEventSink, + ): Promise<{ acpSessionId: string }> { + const managed = await this.getOrCreateSession(key, launchSpec, sink); + + try { + await managed.connection.prompt({ + sessionId: managed.acpSessionId, + prompt: [{ type: 'text', text: message }], + }); + } catch (error) { + const err = error as { message?: string }; + + // Drop broken sessions so the next turn reconnects cleanly. + await this.closeSession(key); + await this.agentsRepository.clearAcpSession(key.agentId, key.resumeSessionSuffix); + + throw new Error(`ACP session prompt failed: ${err.message ?? 'Unknown ACP prompt error'}`); + } + + return { acpSessionId: managed.acpSessionId }; + } + + private async getOrCreateSession( + key: AcpSessionKey, + launchSpec: AcpLaunchSpec, + sink: AcpPromptEventSink, + ): Promise { + const mapKey = this.sessionMapKey(key); + const existing = this.sessions.get(mapKey); + + if (existing) { + // Retarget the long-lived ACP client at this prompt's sink + fresh tool-call bookkeeping. + existing.bindings.sink = sink; + existing.bindings.toolCallState = createAcpToolCallState(); + + return existing; + } + + const bindings = createAcpClientHostBindings(sink); + const transport = await this.transportFactory.connect(key.containerId, launchSpec); + const client = this.clientHostFactory.create({ agentId: key.agentId, containerId: key.containerId }, bindings); + const connection = new ClientSideConnection(() => client, transport.stream); + + try { + const initResult = await connection.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: { + fs: { + readTextFile: true, + writeTextFile: true, + }, + }, + }); + + this.logger.debug(`ACP initialized for agent ${key.agentId} (protocol v${initResult.protocolVersion})`); + } catch (error) { + await transport.close(); + const err = error as { message?: string }; + + throw new Error(`ACP session initialization failed: ${err.message ?? 'Unknown ACP initialization error'}`); + } + + try { + const knownSessionId = await this.agentsRepository.findPersistedAcpSessionId( + key.agentId, + key.containerId, + key.resumeSessionSuffix, + ); + const sessionId = await this.createOrLoadSession(connection, launchSpec, knownSessionId ?? undefined); + + if (sessionId !== knownSessionId) { + await this.agentsRepository.saveAcpSession(key.agentId, key.containerId, sessionId, key.resumeSessionSuffix); + } else if (knownSessionId) { + this.logger.debug( + `Resumed persisted ACP session for agent ${key.agentId}` + + (key.resumeSessionSuffix ? ` (suffix ${key.resumeSessionSuffix})` : ''), + ); + } + + const managed: ManagedAcpSession = { + connection, + transport, + acpSessionId: sessionId, + bindings, + }; + + this.sessions.set(mapKey, managed); + + return managed; + } catch (error) { + await transport.close(); + throw error; + } + } + + /** + * Prefer `newSession` for a fresh transport. + * Call `loadSession` only with a real agent-issued id previously returned by the agent. + */ + private async createOrLoadSession( + connection: ClientSideConnection, + launchSpec: AcpLaunchSpec, + knownSessionId?: string, + ): Promise { + if (launchSpec.supportsLoadSession && knownSessionId) { + try { + await connection.loadSession({ + sessionId: knownSessionId, + cwd: launchSpec.cwd, + mcpServers: [], + }); + + return knownSessionId; + } catch (error) { + const err = error as { message?: string }; + + this.logger.debug(`ACP loadSession failed for known id, creating new session: ${err.message}`); + } + } + + try { + const created = await connection.newSession({ + cwd: launchSpec.cwd, + mcpServers: [], + }); + + return created.sessionId; + } catch (error) { + const err = error as { message?: string }; + + throw new Error(`ACP session creation failed: ${err.message ?? 'Unknown ACP session error'}`); + } + } +} diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/acp-transport.interface.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/acp-transport.interface.ts new file mode 100644 index 000000000..06be0df39 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/acp-transport.interface.ts @@ -0,0 +1,13 @@ +import type { Stream } from '@agentclientprotocol/sdk'; + +/** + * Bidirectional newline-delimited JSON-RPC transport to an agent subprocess. + */ +export interface AcpTransport { + readonly stream: Stream; + close(): Promise; +} + +export interface AcpTransportFactory { + connect(containerId: string, launchSpec: { executable: string; args: string[] }): Promise; +} diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/docker-acp-transport.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/docker-acp-transport.ts new file mode 100644 index 000000000..9bb48d691 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/docker-acp-transport.ts @@ -0,0 +1,72 @@ +import { ndJsonStream, type Stream } from '@agentclientprotocol/sdk'; +import { Injectable, Logger } from '@nestjs/common'; + +import { DockerService } from '../../services/docker.service'; + +import type { AcpTransport } from './acp-transport.interface'; + +@Injectable() +export class DockerAcpTransport implements AcpTransport { + private readonly logger = new Logger(DockerAcpTransport.name); + readonly stream: Stream; + private readonly closeSession: () => Promise; + + private constructor(stream: Stream, closeSession: () => Promise) { + this.stream = stream; + this.closeSession = closeSession; + } + + static async connect( + dockerService: DockerService, + containerId: string, + executable: string, + args: string[], + ): Promise { + const command = [executable, ...args].join(' '); + const session = await dockerService.createExecSession(containerId, command); + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + const output = new WritableStream({ + write(chunk) { + session.writeLine(decoder.decode(chunk)); + }, + }); + const input = new ReadableStream({ + async start(controller) { + try { + for await (const line of session.stdoutLines()) { + controller.enqueue(encoder.encode(`${line}\n`)); + } + + controller.close(); + } catch (error) { + controller.error(error); + } + }, + }); + const stream = ndJsonStream(output, input); + + return new DockerAcpTransport(stream, async () => { + await session.close(); + }); + } + + async close(): Promise { + try { + await this.closeSession(); + } catch (error) { + const err = error as { message?: string; stack?: string }; + + this.logger.warn(`Failed to close ACP transport cleanly: ${err.message}`, err.stack); + } + } +} + +@Injectable() +export class DockerAcpTransportFactory { + constructor(private readonly dockerService: DockerService) {} + + async connect(containerId: string, launchSpec: { executable: string; args: string[] }): Promise { + return DockerAcpTransport.connect(this.dockerService, containerId, launchSpec.executable, launchSpec.args); + } +} diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/fixtures/acp-spike.md b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/fixtures/acp-spike.md new file mode 100644 index 000000000..69ff16317 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/fixtures/acp-spike.md @@ -0,0 +1,15 @@ +# ACP protocol fixtures + +Sample newline-delimited JSON-RPC messages for ACP v1 over stdio (illustrative): + +```json +{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{"fs":{"readTextFile":true,"writeTextFile":true}},"clientInfo":{"name":"agenstra","version":"0.0.0"}}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{}}} +{"jsonrpc":"2.0","id":2,"method":"session/new","params":{"cwd":"/app","mcpServers":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"sess-1"}} +{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"sess-1","prompt":[{"type":"text","text":"Hello"}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"sess-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Hi"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} +``` + +See [Agent Client Protocol docs](../../../../../../../docs/agenstra/ai-agents/agent-client-protocol.md). diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/line-buffer.util.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/line-buffer.util.spec.ts new file mode 100644 index 000000000..57cfe95c8 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/line-buffer.util.spec.ts @@ -0,0 +1,39 @@ +import { drainLineBuffer } from './line-buffer.util'; + +describe('drainLineBuffer', () => { + it('yields complete lines and returns the trailing remainder', () => { + const iterator = drainLineBuffer('line1\nline2', '\nline3'); + const lines: string[] = []; + let step = iterator.next(); + + while (!step.done) { + lines.push(step.value); + step = iterator.next(); + } + + expect(lines).toEqual(['line1', 'line2']); + expect(step.value).toBe('line3'); + }); + + it('ignores empty lines', () => { + const iterator = drainLineBuffer('', '\nfirst\n\nsecond\n'); + const lines: string[] = []; + let step = iterator.next(); + + while (!step.done) { + lines.push(step.value); + step = iterator.next(); + } + + expect(lines).toEqual(['first', 'second']); + expect(step.value).toBe(''); + }); + + it('returns combined remainder when no newline is present', () => { + const iterator = drainLineBuffer('partial', '-chunk'); + const step = iterator.next(); + + expect(step.done).toBe(true); + expect(step.value).toBe('partial-chunk'); + }); +}); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/line-buffer.util.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/line-buffer.util.ts new file mode 100644 index 000000000..55b533219 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/line-buffer.util.ts @@ -0,0 +1,17 @@ +/** + * Accumulates stream chunks and yields complete lines without trailing newlines. + */ +export function* drainLineBuffer(buffer: string, chunk: string): Generator { + let remaining = buffer + chunk; + const parts = remaining.split('\n'); + + remaining = parts.pop() ?? ''; + + for (const line of parts) { + if (line.length > 0) { + yield line; + } + } + + return remaining; +} diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agent-provider.factory.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agent-provider.factory.spec.ts index f22018b6f..2611980e9 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agent-provider.factory.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agent-provider.factory.spec.ts @@ -1,3 +1,4 @@ +import { BadRequestException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { AgentProviderFactory } from './agent-provider.factory'; @@ -116,16 +117,18 @@ describe('AgentProviderFactory', () => { expect(provider).toBe(mockProvider1); }); - it('should throw error if provider not found', () => { + it('should throw BadRequestException if provider not found', () => { + expect(() => factory.getProvider('nonexistent')).toThrow(BadRequestException); expect(() => factory.getProvider('nonexistent')).toThrow( "Agent provider with type 'nonexistent' not found. Available types: none", ); }); - it('should throw error with available types when provider not found', () => { + it('should throw BadRequestException with available types when provider not found', () => { factory.registerProvider(mockProvider1); factory.registerProvider(mockProvider2); + expect(() => factory.getProvider('nonexistent')).toThrow(BadRequestException); expect(() => factory.getProvider('nonexistent')).toThrow( "Agent provider with type 'nonexistent' not found. Available types: provider1, provider2", ); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agent-provider.factory.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agent-provider.factory.ts index f06c9171d..859f557a4 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agent-provider.factory.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agent-provider.factory.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { AgentProvider } from './agent-provider.interface'; @@ -30,7 +30,7 @@ export class AgentProviderFactory { * Get an agent provider by type. * @param type - The agent type identifier * @returns The agent provider instance - * @throws Error if provider is not found + * @throws BadRequestException if provider is not found */ getProvider(type: string): AgentProvider { const provider = this.providers.get(type); @@ -38,7 +38,9 @@ export class AgentProviderFactory { if (!provider) { const availableTypes = Array.from(this.providers.keys()).join(', '); - throw new Error(`Agent provider with type '${type}' not found. Available types: ${availableTypes || 'none'}`); + throw new BadRequestException( + `Agent provider with type '${type}' not found. Available types: ${availableTypes || 'none'}`, + ); } return provider; diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agent-provider.interface.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agent-provider.interface.ts index 7d2f56b8d..ecd9bddc3 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agent-provider.interface.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agent-provider.interface.ts @@ -12,9 +12,13 @@ export interface AgentResponseObject { } export interface AgentProviderCapabilities { + /** + * Wire transport used for agent messaging. + */ + transport?: 'acp'; + /** * Provider supports the chat flow (`chat` websocket event). - * Providers like `openclaw` intentionally do not support chat and should keep it disabled. */ supportsChat: boolean; @@ -177,6 +181,16 @@ export interface AgentProvider { * @returns The unified response object */ toUnifiedResponse(response: string): AgentResponseObject | undefined; + + /** + * Optional structured streaming variant for ACP-native providers. + */ + streamChatEvents?( + agentId: string, + containerId: string, + message: string, + options?: AgentProviderOptions, + ): AsyncIterable; } /** diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agents/cursor-agent.provider.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agents/cursor-agent.provider.spec.ts index 233eff442..f80e261dc 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agents/cursor-agent.provider.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agents/cursor-agent.provider.spec.ts @@ -1,15 +1,17 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { DockerService } from '../../services/docker.service'; +import type { AgentResponseObject } from '../agent-provider.interface'; +import { AcpAgentMessagingService } from '../acp/acp-agent-messaging.service'; import { CursorAgentProvider } from './cursor-agent.provider'; describe('CursorAgentProvider', () => { let provider: CursorAgentProvider; - let dockerService: jest.Mocked; - const mockDockerService = { - sendCommandToContainer: jest.fn(), - execCommandStream: jest.fn(), + const mockAcpMessaging = { + sendMessage: jest.fn(), + sendMessageStream: jest.fn(), + sendInitialization: jest.fn(), + streamChatEvents: jest.fn(), }; beforeEach(async () => { @@ -17,14 +19,13 @@ describe('CursorAgentProvider', () => { providers: [ CursorAgentProvider, { - provide: DockerService, - useValue: mockDockerService, + provide: AcpAgentMessagingService, + useValue: mockAcpMessaging, }, ], }).compile(); provider = module.get(CursorAgentProvider); - dockerService = module.get(DockerService); }); afterEach(() => { @@ -32,594 +33,105 @@ describe('CursorAgentProvider', () => { delete process.env.CURSOR_AGENT_DOCKER_IMAGE; }); - describe('getType', () => { - it('should return "cursor"', () => { - expect(provider.getType()).toBe('cursor'); + it('reports ACP chat capabilities', () => { + expect(provider.getCapabilities()).toEqual({ + transport: 'acp', + supportsChat: true, + supportsStreaming: true, + supportsToolEvents: true, + supportsQuestions: true, }); }); - describe('getDisplayName', () => { - it('should return "Cursor"', () => { - expect(provider.getDisplayName()).toBe('Cursor'); - }); - }); - - describe('getCapabilities', () => { - it('should report chat and streaming capabilities', () => { - expect(provider.getCapabilities()).toEqual({ - supportsChat: true, - supportsStreaming: true, - supportsToolEvents: true, - supportsQuestions: true, - }); - }); - }); - - describe('getBasePath', () => { - it('should return "/app"', () => { - expect(provider.getBasePath()).toBe('/app'); - }); - }); - - describe('getConfigBasePath', () => { - it('should return "~/.cursor"', () => { - expect(provider.getConfigBasePath()).toBe('~/.cursor'); - }); - }); - - describe('getDockerImage', () => { - it('should return default image when CURSOR_AGENT_DOCKER_IMAGE is not set', () => { - delete process.env.CURSOR_AGENT_DOCKER_IMAGE; - - const image = provider.getDockerImage(); - - expect(image).toBe('ghcr.io/forepath/agenstra-manager-worker:latest'); - }); - - it('should return custom image from CURSOR_AGENT_DOCKER_IMAGE environment variable', () => { - process.env.CURSOR_AGENT_DOCKER_IMAGE = 'custom-registry/custom-image:v1.0.0'; - - const image = provider.getDockerImage(); - - expect(image).toBe('custom-registry/custom-image:v1.0.0'); - }); - }); - - describe('getModelsListCommand', () => { - it('should return "cursor-agent --list-models"', () => { - expect(provider.getModelsListCommand()).toBe('cursor-agent --list-models'); - }); + it('keeps base paths and image helpers', () => { + expect(provider.getType()).toBe('cursor'); + expect(provider.getDisplayName()).toBe('Cursor'); + expect(provider.getBasePath()).toBe('/app'); + expect(provider.getConfigBasePath()).toBe('~/.cursor'); + expect(provider.getDockerImage()).toBe('ghcr.io/forepath/agenstra-manager-worker:latest'); + expect(provider.getModelsListCommand()).toBe('cursor-agent --list-models'); }); - describe('toModelsList', () => { - it('should parse model lines with ANSI noise and id - name pairs', () => { - const raw = `\u001b[2K\u001b[GLoading models… -\u001b[2K\u001b[1A\u001b[2K\u001b[GAvailable models - + it('parses Cursor model output', () => { + const raw = `\u001b[2K\u001b[GLoading models auto - Auto -composer-2-fast - Composer 2 Fast (current, default) -composer-2 - Composer 2 -composer-1.5 - Composer 1.5 -`; - - expect(provider.toModelsList(raw)).toEqual({ - auto: 'Auto', - 'composer-2-fast': 'Composer 2 Fast (current, default)', - 'composer-2': 'Composer 2', - 'composer-1.5': 'Composer 1.5', - }); - }); +composer-2-fast - Composer 2 Fast`; - it('should split only on first " - " so names may contain hyphens', () => { - expect(provider.toModelsList('my-id - Display - with - extra')).toEqual({ - 'my-id': 'Display - with - extra', - }); - }); - - it('should skip lines without " - " and return empty object when nothing matches', () => { - expect( - provider.toModelsList(`Available models -no separator here -`), - ).toEqual({}); - }); - - it('should return empty object for empty or whitespace-only input', () => { - expect(provider.toModelsList('')).toEqual({}); - expect(provider.toModelsList(' \n \t ')).toEqual({}); - }); - - it('should ignore lines with empty id after split', () => { - expect(provider.toModelsList(' - only name')).toEqual({}); + expect(provider.toModelsList(raw)).toEqual({ + auto: 'Auto', + 'composer-2-fast': 'Composer 2 Fast', }); }); - describe('sendMessage', () => { - const agentId = 'test-agent-id'; - const containerId = 'test-container-id'; - const message = 'Hello, agent!'; - - it('should send message to container without model option', async () => { - const expectedResponse = '{"type":"result","result":"Hello from agent!"}'; - - dockerService.sendCommandToContainer.mockResolvedValue(expectedResponse); - - const response = await provider.sendMessage(agentId, containerId, message); - - expect(response).toBe(expectedResponse); - expect(dockerService.sendCommandToContainer).toHaveBeenCalledWith( - containerId, - `cursor-agent --print --approve-mcps --force --output-format json --resume ${agentId}-${containerId}`, - message, - ); - }); - - it('should send message to container with model option', async () => { - const expectedResponse = '{"type":"result","result":"Hello from agent!"}'; - const model = 'gpt-4'; - - dockerService.sendCommandToContainer.mockResolvedValue(expectedResponse); - - const response = await provider.sendMessage(agentId, containerId, message, { model }); - - expect(response).toBe(expectedResponse); - expect(dockerService.sendCommandToContainer).toHaveBeenCalledWith( - containerId, - `cursor-agent --print --approve-mcps --force --output-format json --resume ${agentId}-${containerId} --model ${model}`, - message, - ); - }); - - it('should append resumeSessionSuffix to resume id when provided', async () => { - dockerService.sendCommandToContainer.mockResolvedValue('{}'); - - await provider.sendMessage(agentId, containerId, message, { resumeSessionSuffix: '-prompt-enhance' }); + it('delegates sendMessage to ACP messaging', async () => { + mockAcpMessaging.sendMessage.mockResolvedValue('{"type":"result","result":"hi"}'); - expect(dockerService.sendCommandToContainer).toHaveBeenCalledWith( - containerId, - `cursor-agent --print --approve-mcps --force --output-format json --resume ${agentId}-${containerId}-prompt-enhance`, - message, - ); + const result = await provider.sendMessage('agent-1', 'container-1', 'hello', { + model: 'gpt-5', + resumeSessionSuffix: '-x', }); - it('should handle errors from docker service', async () => { - const error = new Error('Container not found'); - - dockerService.sendCommandToContainer.mockRejectedValue(error); - - await expect(provider.sendMessage(agentId, containerId, message)).rejects.toThrow('Container not found'); - }); + expect(result).toBe('{"type":"result","result":"hi"}'); + expect(mockAcpMessaging.sendMessage).toHaveBeenCalledWith( + { agentId: 'agent-1', containerId: 'container-1', resumeSessionSuffix: '-x' }, + expect.objectContaining({ executable: 'cursor-agent', args: ['acp'] }), + 'hello', + { model: 'gpt-5', resumeSessionSuffix: '-x' }, + ); }); - describe('sendMessageStream', () => { - const agentId = 'test-agent-id'; - const containerId = 'test-container-id'; - const message = 'Hello, agent!'; - - it('should use stream-json and stream-partial-output with execCommandStream', async () => { - async function* mockStream(): AsyncGenerator<{ stream: 'stdout' | 'stderr'; chunk: string }> { - yield { stream: 'stdout', chunk: '{"type":"assistant"' }; - yield { stream: 'stdout', chunk: '}\n' }; - } - - dockerService.execCommandStream.mockImplementation(mockStream); - - const chunks: string[] = []; - - for await (const chunk of provider.sendMessageStream(agentId, containerId, message)) { - chunks.push(chunk); - } - - expect(chunks.join('')).toBe('{"type":"assistant"}\n'); - expect(dockerService.execCommandStream).toHaveBeenCalledWith( - containerId, - `cursor-agent --print --approve-mcps --force --output-format stream-json --stream-partial-output --resume ${agentId}-${containerId}`, - message, - ); + it('delegates sendMessageStream to ACP messaging', async () => { + mockAcpMessaging.sendMessageStream.mockImplementation(async function* () { + yield '{"type":"delta","delta":"hi"}'; }); - it('should forward model flag when provided', async () => { - async function* emptyStream(): AsyncGenerator<{ stream: 'stdout' | 'stderr'; chunk: string }> { - // empty - } - - dockerService.execCommandStream.mockImplementation(emptyStream); - const model = 'gpt-4'; + const chunks: string[] = []; - for await (const _ of provider.sendMessageStream(agentId, containerId, message, { model })) { - // consume - } + for await (const chunk of provider.sendMessageStream('agent-1', 'container-1', 'hello')) { + chunks.push(chunk); + } - expect(dockerService.execCommandStream).toHaveBeenCalledWith( - containerId, - `cursor-agent --print --approve-mcps --force --output-format stream-json --stream-partial-output --resume ${agentId}-${containerId} --model ${model}`, - message, - ); - }); + expect(chunks).toEqual(['{"type":"delta","delta":"hi"}']); }); - describe('sendInitialization', () => { - const agentId = 'test-agent-id'; - const containerId = 'test-container-id'; - - it('should send initialization message without model option', async () => { - const loggerDebugSpy = jest.spyOn(provider['logger'], 'debug').mockImplementation(); - - dockerService.sendCommandToContainer.mockResolvedValue(''); - - await provider.sendInitialization(agentId, containerId); - - expect(dockerService.sendCommandToContainer).toHaveBeenCalledWith( - containerId, - `cursor-agent --print --approve-mcps --force --output-format json --resume ${agentId}-${containerId}`, - expect.stringContaining('You are operating in a codebase with a structured command and rules system'), - ); - expect(loggerDebugSpy).toHaveBeenCalledWith(`Sent initialization message to agent ${agentId}`); - - loggerDebugSpy.mockRestore(); - }); - - it('should send initialization message with model option', async () => { - const loggerDebugSpy = jest.spyOn(provider['logger'], 'debug').mockImplementation(); - const model = 'gpt-4'; - - dockerService.sendCommandToContainer.mockResolvedValue(''); - - await provider.sendInitialization(agentId, containerId, { model }); - - expect(dockerService.sendCommandToContainer).toHaveBeenCalledWith( - containerId, - `cursor-agent --print --approve-mcps --force --output-format json --resume ${agentId}-${containerId} --model ${model}`, - expect.stringContaining('You are operating in a codebase with a structured command and rules system'), - ); - expect(loggerDebugSpy).toHaveBeenCalledWith(`Sent initialization message to agent ${agentId}`); - - loggerDebugSpy.mockRestore(); - }); - - it('should include command system instructions in initialization message', async () => { - dockerService.sendCommandToContainer.mockResolvedValue(''); - - await provider.sendInitialization(agentId, containerId); - - const callArgs = dockerService.sendCommandToContainer.mock.calls[0]; - const instructions = callArgs[2] as string; - - expect(instructions).toContain('COMMAND SYSTEM'); - expect(instructions).toContain('.cursor/commands'); - expect(instructions).toContain('/{filenamewithoutextension}'); - }); - - it('should include rules system instructions in initialization message', async () => { - dockerService.sendCommandToContainer.mockResolvedValue(''); - - await provider.sendInitialization(agentId, containerId); - - const callArgs = dockerService.sendCommandToContainer.mock.calls[0]; - const instructions = callArgs[2] as string; - - expect(instructions).toContain('RULES SYSTEM'); - expect(instructions).toContain('.cursor/rules'); - expect(instructions).toContain('alwaysApply'); - }); - - it('should include message handling instructions in initialization message', async () => { - dockerService.sendCommandToContainer.mockResolvedValue(''); - - await provider.sendInitialization(agentId, containerId); - - const callArgs = dockerService.sendCommandToContainer.mock.calls[0]; - const instructions = callArgs[2] as string; - - expect(instructions).toContain('MESSAGE HANDLING'); - expect(instructions).toContain('one-time initialization message'); - }); - - it('should log warning and re-throw error on failure', async () => { - const loggerWarnSpy = jest.spyOn(provider['logger'], 'warn').mockImplementation(); - const error = new Error('Container error'); - - dockerService.sendCommandToContainer.mockRejectedValue(error); - - await expect(provider.sendInitialization(agentId, containerId)).rejects.toThrow('Container error'); - - expect(loggerWarnSpy).toHaveBeenCalledWith( - `Failed to send initialization message to agent ${agentId}: Container error`, - expect.any(String), // Error stack trace - ); - - loggerWarnSpy.mockRestore(); + it('delegates streamChatEvents to ACP messaging', async () => { + mockAcpMessaging.streamChatEvents.mockImplementation(async function* () { + yield { type: 'delta', delta: 'hello' }; }); - it('should log warning with stack trace when error has stack', async () => { - const loggerWarnSpy = jest.spyOn(provider['logger'], 'warn').mockImplementation(); - const error = new Error('Container error'); + const events: AgentResponseObject[] = []; - error.stack = 'Error stack trace'; - dockerService.sendCommandToContainer.mockRejectedValue(error); + for await (const event of provider.streamChatEvents!('agent-1', 'container-1', 'hello')) { + events.push(event); + } - await expect(provider.sendInitialization(agentId, containerId)).rejects.toThrow('Container error'); - - expect(loggerWarnSpy).toHaveBeenCalledWith( - `Failed to send initialization message to agent ${agentId}: Container error`, - 'Error stack trace', - ); - - loggerWarnSpy.mockRestore(); - }); + expect(events).toEqual([{ type: 'delta', delta: 'hello' }]); }); - describe('toParseableStrings', () => { - it('should extract JSON from each line of response', () => { - const response = 'Some text before {"type":"result","result":"Hello"} and text after'; - const result = provider.toParseableStrings(response); - - // The implementation extracts JSON by removing text before { and after } - expect(result).toEqual(['{"type":"result","result":"Hello"}']); - }); - - it('should return array with clean JSON when response is already clean', () => { - const response = '{"type":"result","result":"Hello"}'; - const result = provider.toParseableStrings(response); - - expect(result).toEqual(['{"type":"result","result":"Hello"}']); - }); - - it('should handle response with only opening brace', () => { - const response = 'Some text {'; - const result = provider.toParseableStrings(response); - - expect(result).toEqual(['{']); - }); - - it('should handle response with only closing brace', () => { - const response = '} some text'; - const result = provider.toParseableStrings(response); - - expect(result).toEqual(['}']); - }); - - it('should handle response with no braces', () => { - const response = 'Some text without braces'; - const result = provider.toParseableStrings(response); - - expect(result).toEqual(['Some text without braces']); - }); + it('delegates initialization to ACP messaging', async () => { + mockAcpMessaging.sendInitialization.mockResolvedValue(undefined); - it('should extract JSON from multiple lines', () => { - const response = '{"type":"first","result":"First"}\n{"type":"second","result":"Second"}'; - const result = provider.toParseableStrings(response); - - expect(result).toEqual(['{"type":"first","result":"First"}', '{"type":"second","result":"Second"}']); - }); - - it('should handle nested JSON objects', () => { - const response = 'Prefix {"type":"result","data":{"nested":"value"}} suffix'; - const result = provider.toParseableStrings(response); - - // The implementation extracts JSON by removing text before { and after } - expect(result).toEqual(['{"type":"result","data":{"nested":"value"}}']); - }); + await provider.sendInitialization('agent-1', 'container-1', { resumeSessionSuffix: '-init' }); - it('should trim whitespace from each line', () => { - const response = ' {"type":"result","result":"Hello"} '; - const result = provider.toParseableStrings(response); - - expect(result).toEqual(['{"type":"result","result":"Hello"}']); - }); - - it('should return empty array for empty string', () => { - const response = ''; - const result = provider.toParseableStrings(response); - - // Empty string splits to [''] which maps to [''] - expect(result).toEqual(['']); - }); - - it('should handle response with only whitespace', () => { - const response = ' \n\t '; - const result = provider.toParseableStrings(response); - - // Splits to [' ', '\t '] which both trim to [''] - expect(result).toEqual(['', '']); - }); - - it('should handle complex JSON with arrays and nested objects', () => { - const response = 'Log: {"type":"result","items":[{"id":1},{"id":2}]} done'; - const result = provider.toParseableStrings(response); - - // The implementation extracts JSON by removing text before { and after } - expect(result).toEqual(['{"type":"result","items":[{"id":1},{"id":2}]}']); - }); - - it('should process each line independently', () => { - const response = 'Line 1 {"type":"result","result":"First"}\nLine 2 {"type":"result","result":"Second"}'; - const result = provider.toParseableStrings(response); - - // The implementation extracts JSON from each line by removing text before { and after } - expect(result).toEqual(['{"type":"result","result":"First"}', '{"type":"result","result":"Second"}']); - }); - - it('should handle lines with text before and after JSON', () => { - const response = - 'Prefix {"type":"result","result":"Hello"} Suffix\nAnother {"type":"result","result":"World"} End'; - const result = provider.toParseableStrings(response); - - // The implementation extracts JSON from each line by removing text before { and after } - expect(result).toEqual(['{"type":"result","result":"Hello"}', '{"type":"result","result":"World"}']); - }); + expect(mockAcpMessaging.sendInitialization).toHaveBeenCalledWith( + { agentId: 'agent-1', containerId: 'container-1', resumeSessionSuffix: '-init' }, + expect.objectContaining({ executable: 'cursor-agent', args: ['acp'] }), + expect.stringContaining('COMMAND SYSTEM'), + { resumeSessionSuffix: '-init' }, + ); }); - describe('toUnifiedResponse', () => { - it('should parse valid JSON response with required fields', () => { - const response = '{"type":"result","result":"Hello from agent"}'; - const result = provider.toUnifiedResponse(response); - - expect(result).toEqual({ - type: 'result', - result: 'Hello from agent', - }); - }); - - it('should parse valid JSON response with all optional fields', () => { - const response = - '{"type":"result","subtype":"success","is_error":false,"duration_ms":100,"duration_api_ms":50,"result":"Success","session_id":"session-123","request_id":"req-456"}'; - const result = provider.toUnifiedResponse(response); - - expect(result).toEqual({ - type: 'result', - subtype: 'success', - is_error: false, - duration_ms: 100, - duration_api_ms: 50, - result: 'Success', - session_id: 'session-123', - request_id: 'req-456', - }); - }); - - it('should parse JSON response with additional properties', () => { - const response = '{"type":"result","result":"Hello","custom_field":"custom_value","another_field":123}'; - const result = provider.toUnifiedResponse(response); - - expect(result).toEqual({ - type: 'result', - result: 'Hello', - custom_field: 'custom_value', - another_field: 123, - }); - }); - - it('should parse error response', () => { - const response = '{"type":"error","is_error":true,"result":"Something went wrong"}'; - const result = provider.toUnifiedResponse(response); - - expect(result).toEqual({ - type: 'error', - is_error: true, - result: 'Something went wrong', - }); - }); - - it('should throw error for invalid JSON', () => { - const response = '{"type":"result","result":"Hello"'; // Missing closing brace - - expect(() => provider.toUnifiedResponse(response)).toThrow(); - }); - - it('should throw error for empty string', () => { - const response = ''; - - expect(() => provider.toUnifiedResponse(response)).toThrow(); - }); - - it('should throw error for non-JSON string', () => { - const response = 'This is not JSON'; - - expect(() => provider.toUnifiedResponse(response)).toThrow(); - }); - - it('should throw error for malformed JSON with trailing comma', () => { - const response = '{"type":"result","result":"Hello",}'; // Trailing comma - - expect(() => provider.toUnifiedResponse(response)).toThrow(); - }); - - it('should parse JSON with null values', () => { - const response = '{"type":"result","result":null,"subtype":null}'; - const result = provider.toUnifiedResponse(response); - - expect(result).toEqual({ - type: 'result', - result: null, - subtype: null, - }); - }); - - it('should parse JSON with boolean values', () => { - const response = '{"type":"result","is_error":true,"success":false}'; - const result = provider.toUnifiedResponse(response); - - expect(result).toEqual({ - type: 'result', - is_error: true, - success: false, - }); - }); - - it('should parse JSON with numeric values', () => { - const response = '{"type":"result","duration_ms":1234,"count":42,"rate":3.14}'; - const result = provider.toUnifiedResponse(response); - - expect(result).toEqual({ - type: 'result', - duration_ms: 1234, - count: 42, - rate: 3.14, - }); - }); - - it('should map stream-json assistant line to delta', () => { - const response = JSON.stringify({ - type: 'assistant', - message: { - role: 'assistant', - content: [{ type: 'text', text: 'Hello' }], - }, - session_id: 's1', - }); - const result = provider.toUnifiedResponse(response); - - expect(result).toEqual({ type: 'delta', delta: 'Hello' }); - }); - - it('should return undefined for stream-json user line', () => { - const response = JSON.stringify({ - type: 'user', - message: { role: 'user', content: [{ type: 'text', text: 'Hi' }] }, - }); - const result = provider.toUnifiedResponse(response); - - expect(result).toBeUndefined(); - }); - - it('should map tool_call started to tool_call', () => { - const response = JSON.stringify({ - type: 'tool_call', - subtype: 'started', - tool_call: { - readToolCall: { - args: { path: '/src/a.ts' }, - }, - }, - }); - const result = provider.toUnifiedResponse(response); - - expect(result?.type).toBe('tool_call'); - expect(result?.toolCallId).toMatch(/^cursor-read-/); - expect(result?.name).toBe('read'); - expect(result?.status).toBe('started'); - }); - - it('should map tool_call completed to tool_result', () => { - const response = JSON.stringify({ - type: 'tool_call', - subtype: 'completed', - tool_call: { - readToolCall: { - args: { path: '/src/a.ts' }, - result: { - success: { totalLines: 10, contentSize: 100 }, - }, - }, - }, - }); - const result = provider.toUnifiedResponse(response); + it('splits ACP JSON lines into parseable strings', () => { + expect(provider.toParseableStrings(' {"type":"delta"} \n\n {"type":"result"} ')).toEqual([ + '{"type":"delta"}', + '{"type":"result"}', + ]); + }); - expect(result?.type).toBe('tool_result'); - expect(result?.toolCallId).toMatch(/^cursor-read-/); - expect(result?.name).toBe('read'); - expect(result?.isError).toBe(false); + it('parses ACP JSON responses directly', () => { + expect(provider.toUnifiedResponse('{"type":"result","result":"done"}')).toEqual({ + type: 'result', + result: 'done', }); }); }); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agents/cursor-agent.provider.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agents/cursor-agent.provider.ts index c7d9edf54..eb74c4e91 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agents/cursor-agent.provider.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agents/cursor-agent.provider.ts @@ -1,6 +1,5 @@ import { Injectable, Logger } from '@nestjs/common'; -import { DockerService } from '../../services/docker.service'; import { AgentProvider, AgentProviderCapabilities, @@ -8,6 +7,8 @@ import { AgentProviderOptions, AgentResponseObject, } from '../agent-provider.interface'; +import { AcpAgentMessagingService } from '../acp/acp-agent-messaging.service'; +import { ACP_INITIALIZATION_INSTRUCTIONS, CURSOR_ACP_LAUNCH_SPEC } from '../acp/acp-provider.config'; /** * Cursor-agent provider implementation. @@ -19,7 +20,7 @@ export class CursorAgentProvider implements AgentProvider { private static readonly TYPE = 'cursor'; private static readonly LIST_MODELS_COMMAND = 'cursor-agent --list-models'; - constructor(private readonly dockerService: DockerService) {} + constructor(private readonly acpMessaging: AcpAgentMessagingService) {} /** * Get the unique type identifier for this provider. @@ -39,6 +40,7 @@ export class CursorAgentProvider implements AgentProvider { getCapabilities(): AgentProviderCapabilities { return { + transport: 'acp', supportsChat: true, supportsStreaming: true, supportsToolEvents: true, @@ -161,18 +163,12 @@ export class CursorAgentProvider implements AgentProvider { message: string, options?: AgentProviderOptions, ): Promise { - const resumeId = `${agentId}-${containerId}${options?.resumeSessionSuffix ?? ''}`; - // Build command: cursor-agent with prompt mode and JSON output - let command = `cursor-agent --print --approve-mcps --force --output-format json --resume ${resumeId}`; - - if (options?.model) { - command += ` --model ${options.model}`; - } - - // Send the message to STDIN of the command and get the response - const response = await this.dockerService.sendCommandToContainer(containerId, command, message); - - return response; + return this.acpMessaging.sendMessage( + { agentId, containerId, resumeSessionSuffix: options?.resumeSessionSuffix }, + CURSOR_ACP_LAUNCH_SPEC, + message, + options, + ); } async *sendMessageStream( @@ -181,18 +177,26 @@ export class CursorAgentProvider implements AgentProvider { message: string, options?: AgentProviderOptions, ): AsyncIterable { - const resumeId = `${agentId}-${containerId}${options?.resumeSessionSuffix ?? ''}`; - let command = `cursor-agent --print --approve-mcps --force --output-format stream-json --stream-partial-output --resume ${resumeId}`; - - if (options?.model) { - command += ` --model ${options.model}`; - } + yield* this.acpMessaging.sendMessageStream( + { agentId, containerId, resumeSessionSuffix: options?.resumeSessionSuffix }, + CURSOR_ACP_LAUNCH_SPEC, + message, + options, + ); + } - for await (const { stream, chunk } of this.dockerService.execCommandStream(containerId, command, message)) { - if (stream === 'stdout') { - yield chunk; - } - } + async *streamChatEvents( + agentId: string, + containerId: string, + message: string, + options?: AgentProviderOptions, + ): AsyncIterable { + yield* this.acpMessaging.streamChatEvents( + { agentId, containerId, resumeSessionSuffix: options?.resumeSessionSuffix }, + CURSOR_ACP_LAUNCH_SPEC, + message, + options, + ); } /** @@ -203,47 +207,18 @@ export class CursorAgentProvider implements AgentProvider { * @param options - Optional configuration (e.g., model name) */ async sendInitialization(agentId: string, containerId: string, options?: AgentProviderOptions): Promise { - const resumeId = `${agentId}-${containerId}${options?.resumeSessionSuffix ?? ''}`; - // Build command: cursor-agent with prompt mode and JSON output - let command = `cursor-agent --print --approve-mcps --force --output-format json --resume ${resumeId}`; - - if (options?.model) { - command += ` --model ${options.model}`; - } - - // Send dummy message to container stdin (not persisted or broadcast) - const instructions = `You are operating in a codebase with a structured command and rules system. Follow these guidelines: - -COMMAND SYSTEM: -- Executable commands **CAN** be found in the project folder at .cursor/commands -- Each command **IS** a Markdown (.md) file -- The command invocation format **IS** /{filenamewithoutextension} (where filenamewithoutextension is the filename without the .md extension) -- Example: A file named "ship.md" in .cursor/commands **IS** invoked as /ship -- Commands **MUST** be at the start of a message to be recognized and executed -- When you need to execute a command, you **MUST** look for it in .cursor/commands and invoke it using the /{filenamewithoutextension} format at the beginning of your message - -RULES SYSTEM: -- Basic context files **CAN** be found in .cursor/rules -- Rules files **MAY** contain an "alwaysApply" property (this is optional in the system) -- If a rules file has "alwaysApply: true", you **MUST** always read and apply that file regardless of context -- If a rules file has "alwaysApply: false", you **SHALL** only apply that file to files matching the respective "globs:" entries -- The "globs:" property **CONTAINS** comma-separated glob patterns that specify which files the rules apply to -- When processing a file, you **MUST** check all rules files with "alwaysApply: true" and all rules files with "alwaysApply: false" whose globs match the current file path - -MESSAGE HANDLING: -- This is a one-time initialization message to establish system context -- All subsequent messages you receive **WILL** be from users -- You **MUST** treat all messages after this initialization as user requests, tasks, or questions -- You **SHALL** respond to user messages as you would in a normal conversation, applying the command and rules system guidelines above`; - try { - await this.dockerService.sendCommandToContainer(containerId, command, instructions); - this.logger.debug(`Sent initialization message to agent ${agentId}`); - } catch (error) { + await this.acpMessaging.sendInitialization( + { agentId, containerId, resumeSessionSuffix: options?.resumeSessionSuffix }, + CURSOR_ACP_LAUNCH_SPEC, + ACP_INITIALIZATION_INSTRUCTIONS, + options, + ); + this.logger.debug(`Sent ACP initialization message to agent ${agentId}`); + } catch (error: unknown) { const err = error as { message?: string; stack?: string }; - this.logger.warn(`Failed to send initialization message to agent ${agentId}: ${err.message}`, err.stack); - // Re-throw to allow caller to handle the error + this.logger.warn(`Failed to send ACP initialization message to agent ${agentId}: ${err.message}`, err.stack); throw error; } } @@ -255,32 +230,10 @@ MESSAGE HANDLING: * @returns The parseable strings */ toParseableStrings(response: string): string[] { - // Extract the response object from the response - const lines = response.split('\n'); - - if (lines.length === 0) { - return []; - } - - return lines.map((line) => { - // Clean the response: remove everything before first { and after last } - let toParse = line.trim(); - // Remove everything before the first { in the string - const firstBrace = toParse.indexOf('{'); - - if (firstBrace !== -1) { - toParse = toParse.slice(firstBrace); - } - - // Remove everything after the last } in the string - const lastBrace = toParse.lastIndexOf('}'); - - if (lastBrace !== -1) { - toParse = toParse.slice(0, lastBrace + 1); - } - - return toParse; - }); + return response + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0); } /** @@ -289,213 +242,6 @@ MESSAGE HANDLING: * @returns The unified response object */ toUnifiedResponse(response: string): AgentResponseObject | undefined { - const parsed = JSON.parse(response) as Record; - const topLevelType = typeof parsed.type === 'string' ? parsed.type : undefined; - const normalized = this.normalizeCursorCliOutput(parsed); - - if (normalized !== undefined) { - return normalized; - } - - if ( - topLevelType === 'user' || - topLevelType === 'system' || - topLevelType === 'assistant' || - topLevelType === 'tool_call' - ) { - return undefined; - } - - return parsed as AgentResponseObject; - } - - /** - * Maps Cursor CLI stream-json (NDJSON) lines to unified agent events. - * Legacy `--output-format json` result objects pass through unchanged. - */ - private normalizeCursorCliOutput(parsed: Record): AgentResponseObject | undefined { - const type = parsed.type; - - if (typeof type !== 'string') { - return undefined; - } - - if (type === 'user' || type === 'system') { - return undefined; - } - - if (type === 'assistant') { - const delta = this.extractCursorStreamAssistantDelta(parsed); - - if (!delta) { - return undefined; - } - - return { type: 'delta', delta }; - } - - if (type === 'tool_call') { - return this.mapCursorStreamToolCall(parsed); - } - - return undefined; - } - - private extractCursorStreamAssistantDelta(parsed: Record): string { - const message = parsed.message; - - if (!message || typeof message !== 'object') { - return ''; - } - - const content = (message as { content?: unknown }).content; - - if (!Array.isArray(content)) { - return ''; - } - - return content - .map((part) => { - if (!part || typeof part !== 'object') { - return ''; - } - - const p = part as { type?: unknown; text?: unknown }; - - return p.type === 'text' && typeof p.text === 'string' ? p.text : ''; - }) - .join(''); - } - - private mapCursorStreamToolCall(parsed: Record): AgentResponseObject | undefined { - const subtype = parsed.subtype; - const toolCallRoot = parsed.tool_call; - - if (!toolCallRoot || typeof toolCallRoot !== 'object') { - return undefined; - } - - const info = this.getCursorStreamToolCallIdentity(toolCallRoot as Record); - - if (!info) { - return undefined; - } - - if (subtype === 'started') { - return { - type: 'tool_call', - toolCallId: info.toolCallId, - name: info.name, - args: info.args, - status: 'started', - }; - } - - if (subtype === 'completed') { - const { result, isError } = this.summarizeCursorStreamToolCompletion(toolCallRoot as Record); - - return { - type: 'tool_result', - toolCallId: info.toolCallId, - name: info.name, - result, - isError, - }; - } - - return undefined; - } - - private getCursorStreamToolCallIdentity(toolCallBlock: Record): { - toolCallId: string; - name: string; - args: unknown; - } | null { - for (const [key, value] of Object.entries(toolCallBlock)) { - if (!key.endsWith('ToolCall') || !value || typeof value !== 'object') { - continue; - } - - const entry = value as Record; - const name = key.replace(/ToolCall$/, ''); - const args = entry.args; - let fingerprint = name; - - if (args && typeof args === 'object') { - const a = args as Record; - - if (typeof a.pattern === 'string' && typeof a.path === 'string') { - fingerprint = `${name}:${a.pattern}@${a.path}`; - } else if (typeof a.path === 'string') { - fingerprint = `${name}:${a.path}`; - } else if (typeof a.command === 'string') { - fingerprint = `${name}:${a.command}`; - } else if (typeof a.globPattern === 'string' && typeof a.targetDirectory === 'string') { - fingerprint = `${name}:${a.globPattern}@${a.targetDirectory}`; - } else { - try { - fingerprint = `${name}:${JSON.stringify(a)}`; - } catch { - fingerprint = name; - } - } - } - - return { - toolCallId: `cursor-${name}-${this.fingerprintHash(fingerprint)}`, - name, - args, - }; - } - - return null; - } - - private fingerprintHash(value: string): string { - let hash = 0; - - for (let index = 0; index < value.length; index++) { - hash = (Math.imul(31, hash) + value.charCodeAt(index)) | 0; - } - - return Math.abs(hash).toString(36); - } - - private summarizeCursorStreamToolCompletion(toolCallBlock: Record): { - result: unknown; - isError: boolean; - } { - for (const [key, value] of Object.entries(toolCallBlock)) { - if (!key.endsWith('ToolCall') || !value || typeof value !== 'object') { - continue; - } - - const entry = value as Record; - const toolResult = entry.result; - - if (!toolResult || typeof toolResult !== 'object') { - return { result: entry, isError: false }; - } - - const outcome = toolResult as Record; - - if (outcome.success !== undefined) { - return { result: outcome.success, isError: false }; - } - - if (outcome.rejected !== undefined) { - return { result: outcome.rejected, isError: true }; - } - - const exitCode = (outcome as { exitCode?: unknown }).exitCode; - - if (typeof exitCode === 'number') { - return { result: outcome, isError: exitCode !== 0 }; - } - - return { result: outcome, isError: false }; - } - - return { result: toolCallBlock, isError: false }; + return JSON.parse(response) as AgentResponseObject; } } diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agents/openclaw-agent.provider.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agents/openclaw-agent.provider.spec.ts deleted file mode 100644 index 13a22e70d..000000000 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agents/openclaw-agent.provider.spec.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; - -import { OpenClawAgentProvider } from './openclaw-agent.provider'; - -describe('OpenClawAgentProvider', () => { - let provider: OpenClawAgentProvider; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [OpenClawAgentProvider], - }).compile(); - - provider = module.get(OpenClawAgentProvider); - }); - - afterEach(() => { - jest.clearAllMocks(); - delete process.env.OPENCLAW_AGENT_DOCKER_IMAGE; - delete process.env.OPENCODE_AGENT_VIRTUAL_WORKSPACE_DOCKER_IMAGE; - delete process.env.OPENCODE_AGENT_SSH_CONNECTION_DOCKER_IMAGE; - }); - - describe('getType', () => { - it('should return "openclaw"', () => { - expect(provider.getType()).toBe('openclaw'); - }); - }); - - describe('getDisplayName', () => { - it('should return "OpenClaw"', () => { - expect(provider.getDisplayName()).toBe('OpenClaw'); - }); - }); - - describe('getCapabilities', () => { - it('should report no chat capabilities', () => { - expect(provider.getCapabilities()).toEqual({ - supportsChat: false, - supportsStreaming: false, - supportsToolEvents: false, - supportsQuestions: false, - }); - }); - }); - - describe('getBasePath', () => { - it('should return "/openclaw"', () => { - expect(provider.getBasePath()).toBe('/openclaw'); - }); - }); - - describe('getRepositoryPath', () => { - it('should return "/workspace"', () => { - expect(provider.getRepositoryPath()).toBe('/workspace'); - }); - }); - - describe('getEnvironmentVariables', () => { - it('should return object with OPENCLAW_GATEWAY_TOKEN', () => { - const envVars = provider.getEnvironmentVariables(); - - expect(envVars).toHaveProperty('OPENCLAW_GATEWAY_TOKEN'); - expect(typeof envVars.OPENCLAW_GATEWAY_TOKEN).toBe('string'); - expect(envVars.OPENCLAW_GATEWAY_TOKEN.length).toBe(32); - }); - - it('should generate different tokens on subsequent calls', () => { - const first = provider.getEnvironmentVariables().OPENCLAW_GATEWAY_TOKEN; - const second = provider.getEnvironmentVariables().OPENCLAW_GATEWAY_TOKEN; - - expect(first).not.toBe(second); - }); - }); - - describe('getDockerImage', () => { - it('should return default image when OPENCLAW_AGENT_DOCKER_IMAGE is not set', () => { - delete process.env.OPENCLAW_AGENT_DOCKER_IMAGE; - - const image = provider.getDockerImage(); - - expect(image).toBe('ghcr.io/forepath/agenstra-manager-agi:latest'); - }); - - it('should return custom image from OPENCLAW_AGENT_DOCKER_IMAGE environment variable', () => { - process.env.OPENCLAW_AGENT_DOCKER_IMAGE = 'custom-registry/openclaw-agent:v1.0.0'; - - const image = provider.getDockerImage(); - - expect(image).toBe('custom-registry/openclaw-agent:v1.0.0'); - }); - }); - - describe('getVirtualWorkspaceDockerImage', () => { - it('should return default image when OPENCODE_AGENT_VIRTUAL_WORKSPACE_DOCKER_IMAGE is not set', () => { - delete process.env.OPENCODE_AGENT_VIRTUAL_WORKSPACE_DOCKER_IMAGE; - - const image = provider.getVirtualWorkspaceDockerImage(); - - expect(image).toBe('ghcr.io/forepath/agenstra-manager-vnc:latest'); - }); - - it('should return custom image from OPENCODE_AGENT_VIRTUAL_WORKSPACE_DOCKER_IMAGE environment variable', () => { - process.env.OPENCODE_AGENT_VIRTUAL_WORKSPACE_DOCKER_IMAGE = 'custom-registry/custom-vnc:v1.0.0'; - - const image = provider.getVirtualWorkspaceDockerImage(); - - expect(image).toBe('custom-registry/custom-vnc:v1.0.0'); - }); - }); - - describe('getSshConnectionDockerImage', () => { - it('should return default image when OPENCODE_AGENT_SSH_CONNECTION_DOCKER_IMAGE is not set', () => { - delete process.env.OPENCODE_AGENT_SSH_CONNECTION_DOCKER_IMAGE; - - const image = provider.getSshConnectionDockerImage(); - - expect(image).toBe('ghcr.io/forepath/agenstra-manager-ssh:latest'); - }); - - it('should return custom image from OPENCODE_AGENT_SSH_CONNECTION_DOCKER_IMAGE environment variable', () => { - process.env.OPENCODE_AGENT_SSH_CONNECTION_DOCKER_IMAGE = 'custom-registry/custom-ssh:v1.0.0'; - - const image = provider.getSshConnectionDockerImage(); - - expect(image).toBe('custom-registry/custom-ssh:v1.0.0'); - }); - }); - - describe('getModelsListCommand', () => { - it('should throw not implemented error', () => { - expect(() => provider.getModelsListCommand()).toThrow('Not implemented'); - }); - }); - - describe('sendMessage', () => { - it('should throw not implemented error', async () => { - await expect(provider.sendMessage('agent-id', 'container-id', 'message')).rejects.toThrow('Not implemented'); - }); - }); - - describe('sendInitialization', () => { - it('should throw not implemented error', async () => { - await expect(provider.sendInitialization('agent-id', 'container-id')).rejects.toThrow('Not implemented'); - }); - }); - - describe('toParseableStrings', () => { - it('should throw not implemented error', () => { - expect(() => provider.toParseableStrings('response')).toThrow('Not implemented'); - }); - }); - - describe('toUnifiedResponse', () => { - it('should throw not implemented error', () => { - expect(() => provider.toUnifiedResponse('response')).toThrow('Not implemented'); - }); - }); -}); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agents/openclaw-agent.provider.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agents/openclaw-agent.provider.ts deleted file mode 100644 index a6b52b1e2..000000000 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agents/openclaw-agent.provider.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { - AgentProvider, - AgentProviderCapabilities, - AgentProviderModels, - AgentProviderOptions, - AgentResponseObject, -} from '../agent-provider.interface'; - -/** - * OpenClaw agent provider implementation. - * Handles communication with the openclaw agent binary running in Docker containers. - */ -@Injectable() -export class OpenClawAgentProvider implements AgentProvider { - private static readonly TYPE = 'openclaw'; - - /** - * Get the unique type identifier for this provider. - * @returns 'openclaw' - */ - getType(): string { - return OpenClawAgentProvider.TYPE; - } - - /** - * Get the human-readable display name for this provider. - * @returns 'OpenClaw' - */ - getDisplayName(): string { - return 'OpenClaw'; - } - - getCapabilities(): AgentProviderCapabilities { - return { - supportsChat: false, - supportsStreaming: false, - supportsToolEvents: false, - supportsQuestions: false, - }; - } - - /** - * Get the base path for the provider. - * This is used to construct the API base URL. - * @returns The base path string (e.g., '/openclaw') - */ - getBasePath(): string { - return '/openclaw'; - } - - /** - * Get the path to the repository relative to the base path for the provider. - * This is used to construct the repository path within agent containers. - * @returns The repository path string (e.g., '/workspace'). Defaults to '' if not implemented. - */ - getRepositoryPath(): string { - return '/workspace'; - } - - getEnvironmentVariables(): Record { - function randomString(length = 32): string { - let result = ''; - const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - const charactersLength = characters.length; - - for (let i = 0; i < length; i++) { - result += characters.charAt(Math.floor(Math.random() * charactersLength)); - } - - return result; - } - - return { - OPENCLAW_GATEWAY_TOKEN: randomString(), - }; - } - - /** - * Get the Docker image (including tag) to use for openclaw agent containers. - * @returns The Docker image string - */ - getDockerImage(): string { - return process.env.OPENCLAW_AGENT_DOCKER_IMAGE || 'ghcr.io/forepath/agenstra-manager-agi:latest'; - } - - /** - * Get the Docker image (including tag) to use for virtual workspace containers created for this provider. - * @returns The Docker image string - */ - getVirtualWorkspaceDockerImage(): string { - return process.env.OPENCODE_AGENT_VIRTUAL_WORKSPACE_DOCKER_IMAGE || 'ghcr.io/forepath/agenstra-manager-vnc:latest'; - } - - /** - * Get the Docker image (including tag) to use for SSH connection containers created for this provider. - * @returns The Docker image string - */ - getSshConnectionDockerImage(): string { - return process.env.OPENCODE_AGENT_SSH_CONNECTION_DOCKER_IMAGE || 'ghcr.io/forepath/agenstra-manager-ssh:latest'; - } - - /** - * Get the command to list models. - * @returns The command to list models - */ - getModelsListCommand(): string { - throw new Error('Not implemented'); - } - - /** - * Parse the result of the models list command. - * @param result - The result of the models list command - * @returns The list of models - */ - toModelsList(_result: string): AgentProviderModels { - throw new Error('Not implemented'); - } - - /** - * Send a message to the openclaw-agent and get a response. - * @param agentId - The UUID of the agent - * @param containerId - The Docker container ID where the agent is running - * @param message - The message to send to the agent - * @param options - Optional configuration (e.g., model name) - * @returns The agent's response as a string - */ - async sendMessage( - _agentId: string, - _containerId: string, - _message: string, - _options?: AgentProviderOptions, - ): Promise { - throw new Error('Not implemented'); - } - - /** - * Send an initialization message to the openclaw-agent. - * This establishes system context for the agent. - * @param _agentId - The UUID of the agent (unused for openclaw) - * @param _containerId - The Docker container ID where the agent is running (unused for openclaw) - * @param _options - Optional configuration (e.g., model name) (unused for openclaw) - */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async sendInitialization(_agentId: string, _containerId: string, _options?: AgentProviderOptions): Promise { - throw new Error('Not implemented'); - } - - /** - * Convert the response from the agent to parseable strings. - * Removes all characters that are not UTF-8 supported. - * @param response - The response from the agent - * @returns Array of parseable strings with only valid UTF-8 characters - */ - toParseableStrings(_response: string): string[] { - throw new Error('Not implemented'); - } - - /** - * Convert the response from the agent to a unified response object. - * @param response - The response from the agent - * @returns The unified response object - */ - toUnifiedResponse(_response: string): AgentResponseObject { - throw new Error('Not implemented'); - } -} diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agents/opencode-agent.provider.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agents/opencode-agent.provider.spec.ts index 3fcda39ee..34b2a2378 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agents/opencode-agent.provider.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agents/opencode-agent.provider.spec.ts @@ -1,15 +1,17 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { DockerService } from '../../services/docker.service'; +import type { AgentResponseObject } from '../agent-provider.interface'; +import { AcpAgentMessagingService } from '../acp/acp-agent-messaging.service'; import { OpenCodeAgentProvider } from './opencode-agent.provider'; describe('OpenCodeAgentProvider', () => { let provider: OpenCodeAgentProvider; - let dockerService: jest.Mocked; - const mockDockerService = { - sendCommandToContainer: jest.fn(), - execCommandStream: jest.fn(), + const mockAcpMessaging = { + sendMessage: jest.fn(), + sendMessageStream: jest.fn(), + sendInitialization: jest.fn(), + streamChatEvents: jest.fn(), }; beforeEach(async () => { @@ -17,14 +19,13 @@ describe('OpenCodeAgentProvider', () => { providers: [ OpenCodeAgentProvider, { - provide: DockerService, - useValue: mockDockerService, + provide: AcpAgentMessagingService, + useValue: mockAcpMessaging, }, ], }).compile(); provider = module.get(OpenCodeAgentProvider); - dockerService = module.get(DockerService); }); afterEach(() => { @@ -34,794 +35,104 @@ describe('OpenCodeAgentProvider', () => { delete process.env.OPENCODE_AGENT_SSH_CONNECTION_DOCKER_IMAGE; }); - describe('getType', () => { - it('should return "opencode"', () => { - expect(provider.getType()).toBe('opencode'); + it('reports ACP chat capabilities', () => { + expect(provider.getCapabilities()).toEqual({ + transport: 'acp', + supportsChat: true, + supportsStreaming: true, + supportsToolEvents: true, + supportsQuestions: true, }); }); - describe('getDisplayName', () => { - it('should return "OpenCode"', () => { - expect(provider.getDisplayName()).toBe('OpenCode'); - }); - }); - - describe('getCapabilities', () => { - it('should report chat and streaming capabilities', () => { - expect(provider.getCapabilities()).toEqual({ - supportsChat: true, - supportsStreaming: true, - supportsToolEvents: true, - supportsQuestions: true, - }); - }); - }); - - describe('getBasePath', () => { - it('should return "/app"', () => { - expect(provider.getBasePath()).toBe('/app'); - }); - }); - - describe('getConfigBasePath', () => { - it('should return "~/.config/opencode"', () => { - expect(provider.getConfigBasePath()).toBe('~/.config/opencode'); - }); - }); - - describe('getDockerImage', () => { - it('should return default image when OPENCODE_AGENT_DOCKER_IMAGE is not set', () => { - delete process.env.OPENCODE_AGENT_DOCKER_IMAGE; - - const image = provider.getDockerImage(); - - expect(image).toBe('ghcr.io/forepath/agenstra-manager-worker:latest'); - }); - - it('should return custom image from OPENCODE_AGENT_DOCKER_IMAGE environment variable', () => { - process.env.OPENCODE_AGENT_DOCKER_IMAGE = 'custom-registry/custom-image:v1.0.0'; - - const image = provider.getDockerImage(); - - expect(image).toBe('custom-registry/custom-image:v1.0.0'); - }); + it('keeps base path, config path, image, and model helpers', () => { + expect(provider.getType()).toBe('opencode'); + expect(provider.getDisplayName()).toBe('OpenCode'); + expect(provider.getBasePath()).toBe('/app'); + expect(provider.getConfigBasePath()).toBe('~/.config/opencode'); + expect(provider.getDockerImage()).toBe('ghcr.io/forepath/agenstra-manager-worker:latest'); + expect(provider.getVirtualWorkspaceDockerImage()).toBe('ghcr.io/forepath/agenstra-manager-vnc:latest'); + expect(provider.getSshConnectionDockerImage()).toBe('ghcr.io/forepath/agenstra-manager-ssh:latest'); + expect(provider.getModelsListCommand()).toBe('opencode models'); + expect(provider.buildModelsCommand()).toBe('opencode models'); }); - describe('getModelsListCommand', () => { - it('should return "opencode models"', () => { - expect(provider.getModelsListCommand()).toBe('opencode models'); + it('parses OpenCode model output', () => { + expect(provider.toModelsList('model-a\nmodel-b')).toEqual({ + 'model-a': 'model-a', + 'model-b': 'model-b', }); }); - describe('toModelsList', () => { - it('should map each non-empty line to id and name equal to that line', () => { - const raw = `openrouter/z-ai/glm-5 -openrouter/z-ai/glm-5-turbo -openrouter/z-ai/glm-5.1`; + it('delegates sendMessage to ACP messaging', async () => { + mockAcpMessaging.sendMessage.mockResolvedValue('{"type":"result","result":"hi"}'); - expect(provider.toModelsList(raw)).toEqual({ - 'openrouter/z-ai/glm-5': 'openrouter/z-ai/glm-5', - 'openrouter/z-ai/glm-5-turbo': 'openrouter/z-ai/glm-5-turbo', - 'openrouter/z-ai/glm-5.1': 'openrouter/z-ai/glm-5.1', - }); + const result = await provider.sendMessage('agent-1', 'container-1', 'hello', { + model: 'gpt-5', + resumeSessionSuffix: '-x', }); - it('should drop empty lines and trim whitespace', () => { - const raw = ` model-a - -model-b -`; - - expect(provider.toModelsList(raw)).toEqual({ - 'model-a': 'model-a', - 'model-b': 'model-b', - }); - }); - - it('should handle CRLF line endings', () => { - expect(provider.toModelsList('one\r\ntwo')).toEqual({ - one: 'one', - two: 'two', - }); - }); - - it('should return empty object for empty or whitespace-only input', () => { - expect(provider.toModelsList('')).toEqual({}); - expect(provider.toModelsList(' \n \t ')).toEqual({}); - }); - }); - - describe('getVirtualWorkspaceDockerImage', () => { - it('should return default image when OPENCODE_AGENT_VIRTUAL_WORKSPACE_DOCKER_IMAGE is not set', () => { - delete process.env.OPENCODE_AGENT_VIRTUAL_WORKSPACE_DOCKER_IMAGE; - - const image = provider.getVirtualWorkspaceDockerImage(); - - expect(image).toBe('ghcr.io/forepath/agenstra-manager-vnc:latest'); - }); - - it('should return custom image from OPENCODE_AGENT_VIRTUAL_WORKSPACE_DOCKER_IMAGE environment variable', () => { - process.env.OPENCODE_AGENT_VIRTUAL_WORKSPACE_DOCKER_IMAGE = 'custom-registry/custom-vnc:v1.0.0'; - - const image = provider.getVirtualWorkspaceDockerImage(); - - expect(image).toBe('custom-registry/custom-vnc:v1.0.0'); - }); - }); - - describe('getSshConnectionDockerImage', () => { - it('should return default image when OPENCODE_AGENT_SSH_CONNECTION_DOCKER_IMAGE is not set', () => { - delete process.env.OPENCODE_AGENT_SSH_CONNECTION_DOCKER_IMAGE; - - const image = provider.getSshConnectionDockerImage(); - - expect(image).toBe('ghcr.io/forepath/agenstra-manager-ssh:latest'); - }); - - it('should return custom image from OPENCODE_AGENT_SSH_CONNECTION_DOCKER_IMAGE environment variable', () => { - process.env.OPENCODE_AGENT_SSH_CONNECTION_DOCKER_IMAGE = 'custom-registry/custom-ssh:v1.0.0'; - - const image = provider.getSshConnectionDockerImage(); - - expect(image).toBe('custom-registry/custom-ssh:v1.0.0'); - }); + expect(result).toBe('{"type":"result","result":"hi"}'); + expect(mockAcpMessaging.sendMessage).toHaveBeenCalledWith( + { agentId: 'agent-1', containerId: 'container-1', resumeSessionSuffix: '-x' }, + expect.objectContaining({ executable: 'opencode', args: ['acp'] }), + 'hello', + { model: 'gpt-5', resumeSessionSuffix: '-x' }, + ); }); - describe('sendMessage', () => { - const agentId = 'test-agent-id'; - const containerId = 'test-container-id'; - const message = 'Hello, agent!'; - - it('should send message to container without model option', async () => { - const expectedResponse = 'Hello from agent!'; - - dockerService.sendCommandToContainer.mockResolvedValue(expectedResponse); - - const response = await provider.sendMessage(agentId, containerId, message); - - expect(response).toBe(expectedResponse); - expect(dockerService.sendCommandToContainer).toHaveBeenCalledWith( - containerId, - 'opencode run --format json --continue', - message, - ); - }); - - it('should send message to container with model option', async () => { - const expectedResponse = 'Hello from agent!'; - const model = 'gpt-4'; - - dockerService.sendCommandToContainer.mockResolvedValue(expectedResponse); - - const response = await provider.sendMessage(agentId, containerId, message, { model }); - - expect(response).toBe(expectedResponse); - expect(dockerService.sendCommandToContainer).toHaveBeenCalledWith( - containerId, - `opencode run --format json --continue --model ${model}`, - message, - ); - }); - - it('should send message without continue flag when continue is false', async () => { - const expectedResponse = 'Hello from agent!'; - - dockerService.sendCommandToContainer.mockResolvedValue(expectedResponse); - - const response = await provider.sendMessage(agentId, containerId, message, { continue: false }); - - expect(response).toBe(expectedResponse); - expect(dockerService.sendCommandToContainer).toHaveBeenCalledWith( - containerId, - 'opencode run --format json', - message, - ); + it('delegates sendMessageStream to ACP messaging', async () => { + mockAcpMessaging.sendMessageStream.mockImplementation(async function* () { + yield '{"type":"delta","delta":"hi"}'; }); - it('should retry without continue flag when Session not found error occurs', async () => { - const sessionNotFoundResponse = 'Session not found'; - const expectedResponse = 'Hello from agent!'; - - dockerService.sendCommandToContainer - .mockResolvedValueOnce(sessionNotFoundResponse) - .mockResolvedValueOnce(expectedResponse); + const chunks: string[] = []; - const response = await provider.sendMessage(agentId, containerId, message); + for await (const chunk of provider.sendMessageStream('agent-1', 'container-1', 'hello')) { + chunks.push(chunk); + } - expect(response).toBe(expectedResponse); - expect(dockerService.sendCommandToContainer).toHaveBeenCalledTimes(2); - expect(dockerService.sendCommandToContainer).toHaveBeenNthCalledWith( - 1, - containerId, - 'opencode run --format json --continue', - message, - ); - expect(dockerService.sendCommandToContainer).toHaveBeenNthCalledWith( - 2, - containerId, - 'opencode run --format json', - message, - ); - }); - - it('should retry without continue flag and preserve model option when Session not found error occurs', async () => { - const sessionNotFoundResponse = 'Session not found'; - const expectedResponse = 'Hello from agent!'; - const model = 'gpt-4'; - - dockerService.sendCommandToContainer - .mockResolvedValueOnce(sessionNotFoundResponse) - .mockResolvedValueOnce(expectedResponse); - - const response = await provider.sendMessage(agentId, containerId, message, { model }); - - expect(response).toBe(expectedResponse); - expect(dockerService.sendCommandToContainer).toHaveBeenCalledTimes(2); - expect(dockerService.sendCommandToContainer).toHaveBeenNthCalledWith( - 1, - containerId, - `opencode run --format json --continue --model ${model}`, - message, - ); - expect(dockerService.sendCommandToContainer).toHaveBeenNthCalledWith( - 2, - containerId, - `opencode run --format json --model ${model}`, - message, - ); - }); - - it('should handle errors from docker service', async () => { - const error = new Error('Container not found'); - - dockerService.sendCommandToContainer.mockRejectedValue(error); - - await expect(provider.sendMessage(agentId, containerId, message)).rejects.toThrow('Container not found'); - }); + expect(chunks).toEqual(['{"type":"delta","delta":"hi"}']); }); - describe('sendMessageStream', () => { - const agentId = 'test-agent-id'; - const containerId = 'test-container-id'; - const message = 'Hello, agent!'; - - it('should yield stdout chunks from execCommandStream', async () => { - async function* mockStream(): AsyncGenerator<{ stream: 'stdout' | 'stderr'; chunk: string }> { - yield { stream: 'stdout', chunk: '{"type":"text",' }; - yield { stream: 'stdout', chunk: '"part":{"type":"text","text":"Hi"}}\n' }; - } - - dockerService.execCommandStream.mockImplementation(mockStream); - - const chunks: string[] = []; - - for await (const chunk of provider.sendMessageStream(agentId, containerId, message)) { - chunks.push(chunk); - } - - expect(chunks.join('')).toBe('{"type":"text","part":{"type":"text","text":"Hi"}}\n'); - expect(dockerService.execCommandStream).toHaveBeenCalledWith( - containerId, - 'opencode run --format json --continue', - message, - ); + it('delegates streamChatEvents to ACP messaging', async () => { + mockAcpMessaging.streamChatEvents.mockImplementation(async function* () { + yield { type: 'delta', delta: 'hello' }; }); - it('should retry without continue when output contains Session not found', async () => { - let call = 0; - - async function* firstSessionMissing(): AsyncGenerator<{ stream: 'stdout' | 'stderr'; chunk: string }> { - yield { stream: 'stdout', chunk: 'Session not found\n' }; - } - - async function* secondOk(): AsyncGenerator<{ stream: 'stdout' | 'stderr'; chunk: string }> { - yield { stream: 'stdout', chunk: '{"ok":true}\n' }; - } + const events: AgentResponseObject[] = []; - dockerService.execCommandStream.mockImplementation(async function* () { - call += 1; + for await (const event of provider.streamChatEvents!('agent-1', 'container-1', 'hello')) { + events.push(event); + } - if (call === 1) { - yield* firstSessionMissing(); - } else { - yield* secondOk(); - } - }); - - const chunks: string[] = []; - - for await (const chunk of provider.sendMessageStream(agentId, containerId, message)) { - chunks.push(chunk); - } - - expect(chunks).toEqual(['Session not found\n', '{"ok":true}\n']); - expect(dockerService.execCommandStream).toHaveBeenNthCalledWith( - 1, - containerId, - 'opencode run --format json --continue', - message, - ); - expect(dockerService.execCommandStream).toHaveBeenNthCalledWith( - 2, - containerId, - 'opencode run --format json', - message, - ); - }); + expect(events).toEqual([{ type: 'delta', delta: 'hello' }]); }); - describe('sendInitialization', () => { - const agentId = 'test-agent-id'; - const containerId = 'test-container-id'; - - it('should return immediately without sending any command', async () => { - await provider.sendInitialization(agentId, containerId); + it('delegates initialization to ACP messaging', async () => { + mockAcpMessaging.sendInitialization.mockResolvedValue(undefined); - expect(dockerService.sendCommandToContainer).not.toHaveBeenCalled(); - }); - - it('should return immediately even with model option', async () => { - const model = 'gpt-4'; - - await provider.sendInitialization(agentId, containerId, { model }); - - expect(dockerService.sendCommandToContainer).not.toHaveBeenCalled(); - }); + await provider.sendInitialization('agent-1', 'container-1', { resumeSessionSuffix: '-init' }); - it('should not throw errors', async () => { - await expect(provider.sendInitialization(agentId, containerId)).resolves.toBeUndefined(); - }); + expect(mockAcpMessaging.sendInitialization).toHaveBeenCalledWith( + { agentId: 'agent-1', containerId: 'container-1', resumeSessionSuffix: '-init' }, + expect.objectContaining({ executable: 'opencode', args: ['acp'] }), + expect.stringContaining('COMMAND SYSTEM'), + { resumeSessionSuffix: '-init' }, + ); }); - describe('toParseableStrings', () => { - it('should extract JSON object with type text from response', () => { - const json = JSON.stringify({ - type: 'text', - part: { type: 'text', text: 'Hello' }, - }); - const response = `Some text before ${json} and text after`; - const result = provider.toParseableStrings(response); - - expect(result).toEqual([json]); - }); - - it('should return empty array when no type text object found', () => { - const response = 'Some text without type text'; - const result = provider.toParseableStrings(response); - - expect(result).toEqual([]); - }); - - it('should return empty array when type text object has empty text', () => { - const json = JSON.stringify({ - type: 'text', - part: { type: 'text', text: '' }, - }); - const response = `Some text ${json} more text`; - const result = provider.toParseableStrings(response); - - expect(result).toEqual([]); - }); - - it('should extract JSON object and clean braces', () => { - const json = JSON.stringify({ - type: 'text', - part: { type: 'text', text: 'Hello' }, - }); - const response = `Prefix ${json} suffix`; - const result = provider.toParseableStrings(response); - - expect(result).toEqual([json]); - }); - - it('should handle response with text before and after JSON', () => { - const json = JSON.stringify({ - type: 'text', - part: { type: 'text', text: 'Message' }, - }); - const response = `Log: ${json} done`; - const result = provider.toParseableStrings(response); - - expect(result).toEqual([json]); - }); - - it('should handle multiline response with type text', () => { - const json = JSON.stringify({ - type: 'text', - part: { type: 'text', text: 'Hello' }, - }); - const response = `Line 1\n${json}\nLine 3`; - const result = provider.toParseableStrings(response); - - expect(result).toEqual([json]); - }); - - it('should expand tool_use lines into synthetic tool_call plus original tool_use for tool_result', () => { - const json = JSON.stringify({ - type: 'tool_use', - part: { type: 'tool', callID: 'c1', tool: 'bash', state: { status: 'completed' } }, - }); - const response = `log ${json}`; - const result = provider.toParseableStrings(response); - - expect(result).toHaveLength(2); - const call = JSON.parse(result[0] ?? '{}') as { - type?: string; - toolCallId?: string; - name?: string; - status?: string; - }; - - expect(call.type).toBe('tool_call'); - expect(call.toolCallId).toBe('c1'); - expect(call.name).toBe('bash'); - expect(call.status).toBe('succeeded'); - expect(result[1]).toBe(json); - }); - - it('should trim whitespace from extracted JSON', () => { - const json = JSON.stringify({ - type: 'text', - part: { type: 'text', text: 'Hello' }, - }); - const response = ` ${json} `; - const result = provider.toParseableStrings(response); - - expect(result).toEqual([json]); - }); - - it('should handle empty string', () => { - const response = ''; - const result = provider.toParseableStrings(response); - - expect(result).toEqual([]); - }); - - it('should handle response with only whitespace', () => { - const response = ' \n\t '; - const result = provider.toParseableStrings(response); - - expect(result).toEqual([]); - }); - - it('should extract multiple JSONL lines', () => { - const first = JSON.stringify({ - type: 'text', - part: { type: 'text', text: 'First' }, - }); - const second = JSON.stringify({ - type: 'text', - part: { type: 'text', text: 'Second' }, - }); - const response = `${first}\n${second}`; - const result = provider.toParseableStrings(response); - - expect(result).toEqual([first, second]); - }); + it('splits ACP JSON lines into parseable strings', () => { + expect(provider.toParseableStrings(' {"type":"delta"} \n\n {"type":"result"} ')).toEqual([ + '{"type":"delta"}', + '{"type":"result"}', + ]); }); - describe('toUnifiedResponse', () => { - it('should parse valid opencode response object', () => { - const response = JSON.stringify({ - type: 'text', - timestamp: 1234567890, - sessionID: 'session-123', - part: { - id: 'part-1', - sessionID: 'session-123', - messageID: 'msg-1', - type: 'text', - text: 'Hello from agent!', - time: { - start: 1234567890, - end: 1234567900, - }, - }, - }); - const result = provider.toUnifiedResponse(response); - - expect(result).toEqual({ - type: 'result', - subtype: 'success', - result: 'Hello from agent!', - }); - }); - - it('should extract text from part object', () => { - const response = JSON.stringify({ - type: 'text', - timestamp: 1234567890, - sessionID: 'session-123', - part: { - id: 'part-1', - sessionID: 'session-123', - messageID: 'msg-1', - type: 'text', - text: 'Response text', - time: { - start: 1234567890, - end: 1234567900, - }, - }, - }); - const result = provider.toUnifiedResponse(response); - - expect(result).toEqual({ - type: 'result', - subtype: 'success', - result: 'Response text', - }); - }); - - it('should handle empty text in part object', () => { - const response = JSON.stringify({ - type: 'text', - timestamp: 1234567890, - sessionID: 'session-123', - part: { - id: 'part-1', - sessionID: 'session-123', - messageID: 'msg-1', - type: 'text', - text: '', - time: { - start: 1234567890, - end: 1234567900, - }, - }, - }); - const result = provider.toUnifiedResponse(response); - - expect(result).toEqual({ - type: 'result', - subtype: 'success', - result: '', - }); - }); - - it('should handle multiline text in part object', () => { - const response = JSON.stringify({ - type: 'text', - timestamp: 1234567890, - sessionID: 'session-123', - part: { - id: 'part-1', - sessionID: 'session-123', - messageID: 'msg-1', - type: 'text', - text: 'Line 1\nLine 2\nLine 3', - time: { - start: 1234567890, - end: 1234567900, - }, - }, - }); - const result = provider.toUnifiedResponse(response); - - expect(result).toEqual({ - type: 'result', - subtype: 'success', - result: 'Line 1\nLine 2\nLine 3', - }); - }); - - it('should handle text with special characters', () => { - const response = JSON.stringify({ - type: 'text', - timestamp: 1234567890, - sessionID: 'session-123', - part: { - id: 'part-1', - sessionID: 'session-123', - messageID: 'msg-1', - type: 'text', - text: 'Hello! @#$%^&*()_+-=[]{}|;:,.<>?', - time: { - start: 1234567890, - end: 1234567900, - }, - }, - }); - const result = provider.toUnifiedResponse(response); - - expect(result).toEqual({ - type: 'result', - subtype: 'success', - result: 'Hello! @#$%^&*()_+-=[]{}|;:,.<>?', - }); - }); - - it('should handle text with unicode characters', () => { - const response = JSON.stringify({ - type: 'text', - timestamp: 1234567890, - sessionID: 'session-123', - part: { - id: 'part-1', - sessionID: 'session-123', - messageID: 'msg-1', - type: 'text', - text: 'Hello 世界 🌍', - time: { - start: 1234567890, - end: 1234567900, - }, - }, - }); - const result = provider.toUnifiedResponse(response); - - expect(result).toEqual({ - type: 'result', - subtype: 'success', - result: 'Hello 世界 🌍', - }); - }); - - it('should always return type "result"', () => { - const response = JSON.stringify({ - type: 'text', - timestamp: 1234567890, - sessionID: 'session-123', - part: { - id: 'part-1', - sessionID: 'session-123', - messageID: 'msg-1', - type: 'text', - text: 'Any message', - time: { - start: 1234567890, - end: 1234567900, - }, - }, - }); - const result = provider.toUnifiedResponse(response); - - expect(result.type).toBe('result'); - }); - - it('should always return subtype "success"', () => { - const response = JSON.stringify({ - type: 'text', - timestamp: 1234567890, - sessionID: 'session-123', - part: { - id: 'part-1', - sessionID: 'session-123', - messageID: 'msg-1', - type: 'text', - text: 'Any message', - time: { - start: 1234567890, - end: 1234567900, - }, - }, - }); - const result = provider.toUnifiedResponse(response); - - expect(result.subtype).toBe('success'); - }); - - it('should handle very long text', () => { - const longText = 'A'.repeat(10000); - const response = JSON.stringify({ - type: 'text', - timestamp: 1234567890, - sessionID: 'session-123', - part: { - id: 'part-1', - sessionID: 'session-123', - messageID: 'msg-1', - type: 'text', - text: longText, - time: { - start: 1234567890, - end: 1234567900, - }, - }, - }); - const result = provider.toUnifiedResponse(response); - - expect(result).toEqual({ - type: 'result', - subtype: 'success', - result: longText, - }); - expect(typeof result?.result === 'string' ? result.result.length : 0).toBe(10000); - }); - - it('should throw error for invalid JSON', () => { - const response = '{"type":"text","part":{"text":"Hello"'; // Missing closing brace - - expect(() => provider.toUnifiedResponse(response)).toThrow(); - }); - - it('should map synthetic tool_call JSON to unified tool_call', () => { - const response = JSON.stringify({ - type: 'tool_call', - toolCallId: 'call-1', - name: 'bash', - args: { command: 'echo hi' }, - status: 'succeeded', - }); - const result = provider.toUnifiedResponse(response); - - expect(result).toEqual({ - type: 'tool_call', - toolCallId: 'call-1', - name: 'bash', - args: { command: 'echo hi' }, - status: 'succeeded', - }); - }); - - it('should map tool_use line to tool_result', () => { - const response = JSON.stringify({ - type: 'tool_use', - timestamp: 1, - sessionID: 'ses_x', - part: { - id: 'p1', - type: 'tool', - callID: 'call-1', - tool: 'bash', - state: { - status: 'completed', - input: { command: 'echo hi' }, - output: 'hi\n', - title: 'Run', - metadata: { exit: 0 }, - time: { start: 1, end: 2 }, - }, - }, - }); - const result = provider.toUnifiedResponse(response); - - expect(result).toEqual({ - type: 'tool_result', - toolCallId: 'call-1', - name: 'bash', - result: { - output: 'hi\n', - input: { command: 'echo hi' }, - title: 'Run', - metadata: { exit: 0 }, - }, - isError: false, - }); - }); - - it('should set isError when bash exit is non-zero', () => { - const response = JSON.stringify({ - type: 'tool_use', - part: { - type: 'tool', - callID: 'call-1', - tool: 'bash', - state: { - status: 'completed', - metadata: { exit: 1 }, - }, - }, - }); - const result = provider.toUnifiedResponse(response); - - expect(result?.isError).toBe(true); - }); - - it('should map error event', () => { - const response = JSON.stringify({ - type: 'error', - timestamp: 1, - sessionID: 'ses_x', - error: { name: 'APIError', data: { message: 'Rate limited' } }, - }); - const result = provider.toUnifiedResponse(response); - - expect(result).toEqual({ - type: 'error', - is_error: true, - result: 'Rate limited', - }); + it('parses ACP JSON responses directly', () => { + expect(provider.toUnifiedResponse('{"type":"result","result":"done"}')).toEqual({ + type: 'result', + result: 'done', }); }); }); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agents/opencode-agent.provider.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agents/opencode-agent.provider.ts index e1aecc5ed..d4f8a67d7 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agents/opencode-agent.provider.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agents/opencode-agent.provider.ts @@ -1,6 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { DockerService } from '../../services/docker.service'; import { AgentProvider, AgentProviderCapabilities, @@ -8,6 +7,8 @@ import { AgentProviderOptions, AgentResponseObject, } from '../agent-provider.interface'; +import { AcpAgentMessagingService } from '../acp/acp-agent-messaging.service'; +import { ACP_INITIALIZATION_INSTRUCTIONS, OPENCODE_ACP_LAUNCH_SPEC } from '../acp/acp-provider.config'; /** * OpenCode agent provider implementation. @@ -18,7 +19,7 @@ export class OpenCodeAgentProvider implements AgentProvider { private static readonly TYPE = 'opencode'; private static readonly LIST_MODELS_COMMAND = 'opencode models'; - constructor(private readonly dockerService: DockerService) {} + constructor(private readonly acpMessaging: AcpAgentMessagingService) {} /** * Get the unique type identifier for this provider. @@ -38,6 +39,7 @@ export class OpenCodeAgentProvider implements AgentProvider { getCapabilities(): AgentProviderCapabilities { return { + transport: 'acp', supportsChat: true, supportsStreaming: true, supportsToolEvents: true, @@ -45,6 +47,20 @@ export class OpenCodeAgentProvider implements AgentProvider { }; } + async *streamChatEvents( + agentId: string, + containerId: string, + message: string, + options?: AgentProviderOptions, + ): AsyncIterable { + yield* this.acpMessaging.streamChatEvents( + { agentId, containerId, resumeSessionSuffix: options?.resumeSessionSuffix }, + OPENCODE_ACP_LAUNCH_SPEC, + message, + options, + ); + } + /** * Get the base path for the provider. * This is used to construct the API base URL. @@ -119,77 +135,32 @@ export class OpenCodeAgentProvider implements AgentProvider { return models; } - /** - * Send a message to the opencode-agent and get a response. - * @param agentId - The UUID of the agent - * @param containerId - The Docker container ID where the agent is running - * @param message - The message to send to the agent - * @param options - Optional configuration (e.g., model name) - * @returns The agent's response as a string - */ - private buildRunCommand(options?: AgentProviderOptions): string { - let command = `opencode run --format json`; - - if (options?.continue === undefined || options?.continue === true) { - command += ` --continue`; - } - - if (options?.model && options.model !== 'auto') { - command += ` --model ${options.model}`; - } - - return command; - } - - private wantsSessionContinue(options?: AgentProviderOptions): boolean { - return options?.continue === undefined || options?.continue === true; - } - async sendMessage( agentId: string, containerId: string, message: string, options?: AgentProviderOptions, ): Promise { - const command = this.buildRunCommand(options); - const response = await this.dockerService.sendCommandToContainer(containerId, command, message); - - if (response.includes('Session not found') && this.wantsSessionContinue(options)) { - return this.sendMessage(agentId, containerId, message, { - ...options, - continue: false, - }); - } - - return response; + return this.acpMessaging.sendMessage( + { agentId, containerId, resumeSessionSuffix: options?.resumeSessionSuffix }, + OPENCODE_ACP_LAUNCH_SPEC, + message, + options, + ); } async *sendMessageStream( - _agentId: string, + agentId: string, containerId: string, message: string, options?: AgentProviderOptions, ): AsyncIterable { - const streamOnce = (opts?: AgentProviderOptions): AsyncIterable<{ stream: 'stdout' | 'stderr'; chunk: string }> => - this.dockerService.execCommandStream(containerId, this.buildRunCommand(opts), message); - const chunks: string[] = []; - - for await (const { stream, chunk } of streamOnce(options)) { - if (stream === 'stdout') { - chunks.push(chunk); - yield chunk; - } - } - - const combined = chunks.join(''); - - if (combined.includes('Session not found') && this.wantsSessionContinue(options)) { - for await (const { stream, chunk } of streamOnce({ ...options, continue: false })) { - if (stream === 'stdout') { - yield chunk; - } - } - } + yield* this.acpMessaging.sendMessageStream( + { agentId, containerId, resumeSessionSuffix: options?.resumeSessionSuffix }, + OPENCODE_ACP_LAUNCH_SPEC, + message, + options, + ); } /** @@ -199,9 +170,13 @@ export class OpenCodeAgentProvider implements AgentProvider { * @param _containerId - The Docker container ID where the agent is running (unused for opencode) * @param _options - Optional configuration (e.g., model name) (unused for opencode) */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async sendInitialization(_agentId: string, _containerId: string, _options?: AgentProviderOptions): Promise { - return; + async sendInitialization(agentId: string, containerId: string, options?: AgentProviderOptions): Promise { + await this.acpMessaging.sendInitialization( + { agentId, containerId, resumeSessionSuffix: options?.resumeSessionSuffix }, + OPENCODE_ACP_LAUNCH_SPEC, + ACP_INITIALIZATION_INSTRUCTIONS, + options, + ); } /** @@ -211,101 +186,10 @@ export class OpenCodeAgentProvider implements AgentProvider { * @returns Array of parseable strings with only valid UTF-8 characters */ toParseableStrings(response: string): string[] { - const lines = response.split('\n'); - - if (lines.length === 0) { - return []; - } - - const result: string[] = []; - - for (const line of lines) { - let toParse = line.trim(); - const firstBrace = toParse.indexOf('{'); - - if (firstBrace !== -1) { - toParse = toParse.slice(firstBrace); - } - - const lastBrace = toParse.lastIndexOf('}'); - - if (lastBrace !== -1) { - toParse = toParse.slice(0, lastBrace + 1); - } - - if (!toParse.includes('{')) { - continue; - } - - try { - const parsed = JSON.parse(toParse) as { - type?: string; - part?: { - type?: string; - text?: string; - callID?: string; - tool?: string; - state?: { status?: string; input?: unknown }; - }; - }; - - if (!parsed.type) { - continue; - } - - if (parsed.type === 'text') { - if (parsed.part?.type === 'text' && typeof parsed.part.text === 'string' && parsed.part.text !== '') { - result.push(toParse); - } - } else if (parsed.type === 'tool_use') { - const part = parsed.part; - - if ( - part?.type === 'tool' && - typeof part.callID === 'string' && - typeof part.tool === 'string' && - part.callID.length > 0 && - part.tool.length > 0 - ) { - const toolCallFrame = { - type: 'tool_call', - toolCallId: part.callID, - name: part.tool, - args: part.state?.input, - status: OpenCodeAgentProvider.mapOpenCodeToolLifecycleStatus(part.state?.status), - }; - - result.push(JSON.stringify(toolCallFrame)); - } - - result.push(toParse); - } else if (parsed.type === 'error') { - result.push(toParse); - } - } catch { - continue; - } - } - - return result; - } - - private static mapOpenCodeToolLifecycleStatus(status: unknown): 'started' | 'inProgress' | 'succeeded' | 'failed' { - const s = typeof status === 'string' ? status.toLowerCase() : ''; - - if (s === 'completed' || s === 'success' || s === 'succeeded') { - return 'succeeded'; - } - - if (s === 'failed' || s === 'error') { - return 'failed'; - } - - if (s === 'started' || s === 'running' || s === 'pending') { - return 'inProgress'; - } - - return 'inProgress'; + return response + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0); } /** @@ -314,109 +198,7 @@ export class OpenCodeAgentProvider implements AgentProvider { * @returns The unified response object */ toUnifiedResponse(response: string): AgentResponseObject | undefined { - const responseObject = JSON.parse(response) as { - type: string; - timestamp?: number; - sessionID?: string; - part?: { - id?: string; - sessionID?: string; - messageID?: string; - type?: string; - text?: string; - callID?: string; - tool?: string; - state?: { - status?: string; - input?: unknown; - output?: unknown; - title?: string; - metadata?: Record; - }; - time?: { - start: number; - end: number; - }; - }; - error?: { - name?: string; - data?: { message?: string }; - }; - }; - - if (responseObject.type === 'text' && responseObject.part?.type === 'text') { - return { - type: 'result', - subtype: 'success', - result: responseObject.part.text ?? '', - }; - } - - if (responseObject.type === 'tool_call') { - const o = responseObject as unknown as { - toolCallId?: unknown; - name?: unknown; - args?: unknown; - status?: unknown; - }; - - if (typeof o.toolCallId !== 'string' || typeof o.name !== 'string') { - return undefined; - } - - const st = o.status; - const status = - st === 'started' || st === 'inProgress' || st === 'succeeded' || st === 'failed' ? st : 'inProgress'; - - return { - type: 'tool_call', - toolCallId: o.toolCallId, - name: o.name, - ...(o.args !== undefined ? { args: o.args } : {}), - status, - }; - } - - if (responseObject.type === 'tool_use' && responseObject.part?.type === 'tool') { - const part = responseObject.part; - - if (typeof part.callID !== 'string' || typeof part.tool !== 'string') { - return undefined; - } - - const exit = part.state?.metadata?.exit; - const isError = typeof exit === 'number' && exit !== 0; - - return { - type: 'tool_result', - toolCallId: part.callID, - name: part.tool, - result: { - output: part.state?.output, - input: part.state?.input, - title: part.state?.title, - metadata: part.state?.metadata, - }, - isError, - }; - } - - if (responseObject.type === 'error') { - const message = - typeof responseObject.error?.data?.message === 'string' - ? responseObject.error.data.message - : typeof responseObject.error?.name === 'string' - ? responseObject.error.name - : 'OpenCode error'; - - return { - type: 'error', - is_error: true, - result: message, - }; - } - - return undefined; + return JSON.parse(response) as AgentResponseObject; } buildModelsCommand(): string { diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agents.repository.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agents.repository.spec.ts index 3fd229ff4..decc41708 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agents.repository.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agents.repository.spec.ts @@ -27,6 +27,7 @@ describe('AgentsRepository', () => { create: jest.fn(), save: jest.fn(), remove: jest.fn(), + update: jest.fn(), }; beforeEach(async () => { @@ -186,4 +187,102 @@ describe('AgentsRepository', () => { expect(mockTypeOrmRepository.remove).toHaveBeenCalledWith(mockAgent); }); }); + + describe('ACP session persistence', () => { + it('findPersistedAcpSessionId returns primary id when container matches', async () => { + mockTypeOrmRepository.findOne.mockResolvedValue({ + ...mockAgent, + acpSessions: { '': 'sess-abc' }, + acpSessionContainerId: 'container-id-123', + }); + + await expect(repository.findPersistedAcpSessionId('test-uuid', 'container-id-123')).resolves.toBe('sess-abc'); + }); + + it('findPersistedAcpSessionId returns suffix id', async () => { + mockTypeOrmRepository.findOne.mockResolvedValue({ + ...mockAgent, + acpSessions: { '': 'sess-main', '-ticket-auto-loop': 'sess-loop' }, + acpSessionContainerId: 'container-id-123', + }); + + await expect( + repository.findPersistedAcpSessionId('test-uuid', 'container-id-123', '-ticket-auto-loop'), + ).resolves.toBe('sess-loop'); + }); + + it('findPersistedAcpSessionId returns null when container changed', async () => { + mockTypeOrmRepository.findOne.mockResolvedValue({ + ...mockAgent, + acpSessions: { '': 'sess-abc' }, + acpSessionContainerId: 'old-container', + }); + + await expect(repository.findPersistedAcpSessionId('test-uuid', 'container-id-123')).resolves.toBeNull(); + }); + + it('saveAcpSession merges a suffix without dropping others', async () => { + mockTypeOrmRepository.findOne.mockResolvedValue({ + ...mockAgent, + acpSessions: { '': 'sess-main' }, + acpSessionContainerId: 'container-id-123', + }); + mockTypeOrmRepository.update.mockResolvedValue({ affected: 1 }); + + await repository.saveAcpSession('test-uuid', 'container-id-123', 'sess-loop', '-ticket-auto-loop'); + + expect(mockTypeOrmRepository.update).toHaveBeenCalledWith('test-uuid', { + acpSessions: { '': 'sess-main', '-ticket-auto-loop': 'sess-loop' }, + acpSessionContainerId: 'container-id-123', + }); + }); + + it('saveAcpSession resets map when container changed', async () => { + mockTypeOrmRepository.findOne.mockResolvedValue({ + ...mockAgent, + acpSessions: { '': 'sess-old' }, + acpSessionContainerId: 'old-container', + }); + mockTypeOrmRepository.update.mockResolvedValue({ affected: 1 }); + + await repository.saveAcpSession('test-uuid', 'container-id-123', 'sess-new', '-ticket-auto-loop'); + + expect(mockTypeOrmRepository.update).toHaveBeenCalledWith('test-uuid', { + acpSessions: { '-ticket-auto-loop': 'sess-new' }, + acpSessionContainerId: 'container-id-123', + }); + }); + + it('clearAcpSession removes one suffix and keeps others', async () => { + mockTypeOrmRepository.findOne.mockResolvedValue({ + ...mockAgent, + acpSessions: { '': 'sess-main', '-ticket-auto-loop': 'sess-loop' }, + acpSessionContainerId: 'container-id-123', + }); + mockTypeOrmRepository.update.mockResolvedValue({ affected: 1 }); + + await repository.clearAcpSession('test-uuid', '-ticket-auto-loop'); + + expect(mockTypeOrmRepository.update).toHaveBeenCalledWith('test-uuid', { + acpSessions: { '': 'sess-main' }, + acpSessionContainerId: 'container-id-123', + }); + }); + + it('clearAcpSession nulls map and container when last entry removed', async () => { + mockTypeOrmRepository.findOne.mockResolvedValue({ + ...mockAgent, + acpSessions: { '': 'sess-main' }, + acpSessionContainerId: 'container-id-123', + }); + mockTypeOrmRepository.update.mockResolvedValue({ affected: 1 }); + + await repository.clearAcpSession('test-uuid'); + + expect(mockTypeOrmRepository.update).toHaveBeenCalledWith('test-uuid', { + acpSessions: null, + acpSessionContainerId: null, + }); + }); + }); }); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agents.repository.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agents.repository.ts index e4de0e9ca..c2952d499 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agents.repository.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agents.repository.ts @@ -129,4 +129,74 @@ export class AgentsRepository { await this.repository.remove(agent); } + + private acpSessionMapKey(resumeSessionSuffix?: string): string { + return resumeSessionSuffix ?? ''; + } + + /** + * Return a persisted ACP session id for the given suffix when the container still matches. + * Empty / omitted suffix is the primary chat session. + */ + async findPersistedAcpSessionId( + agentId: string, + containerId: string, + resumeSessionSuffix?: string, + ): Promise { + const agent = await this.findById(agentId); + + if (!agent?.acpSessionContainerId || agent.acpSessionContainerId !== containerId) { + return null; + } + + const sessionId = agent.acpSessions?.[this.acpSessionMapKey(resumeSessionSuffix)]; + + return typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : null; + } + + /** + * Persist an ACP session id for a suffix (overwrites that key only). + * When the container id changes, previous suffix entries are discarded. + */ + async saveAcpSession( + agentId: string, + containerId: string, + acpSessionId: string, + resumeSessionSuffix?: string, + ): Promise { + const agent = await this.findById(agentId); + const mapKey = this.acpSessionMapKey(resumeSessionSuffix); + const sessions = agent?.acpSessionContainerId === containerId && agent.acpSessions ? { ...agent.acpSessions } : {}; + + sessions[mapKey] = acpSessionId; + + await this.repository.update(agentId, { + acpSessions: sessions, + acpSessionContainerId: containerId, + }); + } + + /** + * Clear a persisted ACP session for one suffix (e.g. after prompt/transport failure). + * When the map becomes empty, also clear the container binding. + */ + async clearAcpSession(agentId: string, resumeSessionSuffix?: string): Promise { + const agent = await this.findById(agentId); + + if (!agent) { + return; + } + + const mapKey = this.acpSessionMapKey(resumeSessionSuffix); + const sessions = { ...(agent.acpSessions ?? {}) }; + + delete sessions[mapKey]; + + const remainingKeys = Object.keys(sessions); + + await this.repository.update(agentId, { + acpSessions: remainingKeys.length > 0 ? sessions : null, + acpSessionContainerId: remainingKeys.length > 0 ? agent.acpSessionContainerId : null, + }); + } } diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agenstra-manager-metrics-collector.service.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agenstra-manager-metrics-collector.service.ts index a1829867f..f03482041 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agenstra-manager-metrics-collector.service.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agenstra-manager-metrics-collector.service.ts @@ -73,7 +73,7 @@ export class AgenstraManagerMetricsCollectorService implements OnModuleInit, OnM .getRawMany<{ agentType: string; containerType: string; count: string }>(); const byKey = new Map(agentRows.map((row) => [`${row.agentType}:${row.containerType}`, parseInt(row.count, 10)])); - const agentTypes = new Set([...agentRows.map((row) => row.agentType), 'cursor', 'opencode', 'openclaw']); + const agentTypes = new Set([...agentRows.map((row) => row.agentType), 'cursor', 'opencode']); for (const agentType of agentTypes) { for (const containerType of Object.values(ContainerType)) { diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents.service.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents.service.ts index 57ab74d4a..be6eba5d2 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents.service.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents.service.ts @@ -959,12 +959,30 @@ export class AgentsService implements OnApplicationBootstrap { * @returns The agent response DTO */ private mapToResponseDto(agent: AgentEntity): AgentResponseDto { + let capabilities: AgentResponseDto['capabilities']; + + try { + const provider = this.agentProviderFactory.getProvider(agent.agentType || 'cursor'); + const caps = provider.getCapabilities(); + + capabilities = { + transport: caps.transport, + supportsChat: caps.supportsChat, + supportsStreaming: caps.supportsStreaming, + supportsToolEvents: caps.supportsToolEvents, + supportsQuestions: caps.supportsQuestions, + }; + } catch { + capabilities = undefined; + } + return { id: agent.id, name: agent.name, description: agent.description, agentType: agent.agentType, containerType: agent.containerType, + capabilities, vnc: agent.vncHostPort ? { port: agent.vncHostPort, diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/config.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/config.service.spec.ts index d39292da3..518f51934 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/config.service.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/config.service.spec.ts @@ -77,11 +77,23 @@ describe('ConfigService', () => { const result = service.getAvailableAgentTypes(); - expect(result).toEqual([{ type: 'cursor', displayName: 'Cursor' }]); + expect(result).toEqual([ + { + type: 'cursor', + displayName: 'Cursor', + capabilities: { + supportsChat: true, + supportsStreaming: false, + supportsToolEvents: false, + supportsQuestions: false, + }, + }, + ]); expect(agentProviderFactory.getRegisteredTypes).toHaveBeenCalled(); expect(agentProviderFactory.getProvider).toHaveBeenCalledWith('cursor'); expect(mockProvider.getType).toHaveBeenCalled(); expect(mockProvider.getDisplayName).toHaveBeenCalled(); + expect(mockProvider.getCapabilities).toHaveBeenCalled(); }); it('should return multiple agent types when multiple providers are registered', () => { @@ -134,9 +146,36 @@ describe('ConfigService', () => { const result = service.getAvailableAgentTypes(); expect(result).toEqual([ - { type: 'cursor', displayName: 'Cursor' }, - { type: 'openai', displayName: 'OpenAI' }, - { type: 'anthropic', displayName: 'Anthropic Claude' }, + { + type: 'cursor', + displayName: 'Cursor', + capabilities: { + supportsChat: true, + supportsStreaming: false, + supportsToolEvents: false, + supportsQuestions: false, + }, + }, + { + type: 'openai', + displayName: 'OpenAI', + capabilities: { + supportsChat: true, + supportsStreaming: false, + supportsToolEvents: false, + supportsQuestions: false, + }, + }, + { + type: 'anthropic', + displayName: 'Anthropic Claude', + capabilities: { + supportsChat: true, + supportsStreaming: false, + supportsToolEvents: false, + supportsQuestions: false, + }, + }, ]); expect(result).toHaveLength(3); }); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/config.service.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/config.service.ts index b06981d11..bd8220145 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/config.service.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/config.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { GitRepositorySetupMode, resolveGitRepositorySetupMode } from '../constants/git-repository-setup-mode'; +import type { AgentTypeInfo } from '../dto/config-response.dto'; import { AgentProviderFactory } from '../providers/agent-provider.factory'; /** @@ -28,16 +29,24 @@ export class ConfigService { } /** - * Get the list of available agent provider types with display names. + * Get the list of available agent provider types with display names and capabilities. * @returns Array of agent type information objects */ - getAvailableAgentTypes(): Array<{ type: string; displayName: string }> { + getAvailableAgentTypes(): AgentTypeInfo[] { return this.agentProviderFactory.getRegisteredTypes().map((type) => { const provider = this.agentProviderFactory.getProvider(type); + const capabilities = provider.getCapabilities(); return { type: provider.getType(), displayName: provider.getDisplayName(), + capabilities: { + transport: capabilities.transport, + supportsChat: capabilities.supportsChat, + supportsStreaming: capabilities.supportsStreaming, + supportsToolEvents: capabilities.supportsToolEvents, + supportsQuestions: capabilities.supportsQuestions, + }, }; }); } diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/docker.service.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/docker.service.ts index b4f7a6735..f5da75c91 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/docker.service.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/docker.service.ts @@ -11,6 +11,23 @@ import { v4 as uuidv4 } from 'uuid'; const execAsync = promisify(exec); +function drainExecStdoutLines(buffer: string, chunk: string, queue: string[]): string { + let remaining = buffer + chunk; + const parts = remaining.split('\n'); + + remaining = parts.pop() ?? ''; + + for (const line of parts) { + const trimmed = line.trim(); + + if (trimmed) { + queue.push(trimmed); + } + } + + return remaining; +} + interface TerminalSession { exec: Docker.Exec; stream: NodeJS.ReadWriteStream; @@ -18,6 +35,13 @@ interface TerminalSession { sessionId: string; } +export interface DockerExecSession { + writeLine(line: string): void; + closeStdin(): void; + stdoutLines(): AsyncIterable; + close(): Promise; +} + @Injectable() export class DockerService { private readonly logger = new Logger(DockerService.name); @@ -1460,6 +1484,142 @@ export class DockerService { } } + /** + * Start a long-lived exec in a container with stdin left open for ACP stdio transport. + */ + async createExecSession(containerId: string, command: string): Promise { + const container = this.docker.getContainer(containerId); + + try { + await container.inspect(); + } catch (error: unknown) { + const dockerError = error as { statusCode?: number }; + + if (dockerError.statusCode === 404) { + throw new NotFoundException(`Container with ID '${containerId}' not found`); + } + + throw error; + } + + const commandParts = this.parseShellCommand(command.trim()); + const executable = commandParts[0]; + const args = commandParts.slice(1); + const execInstance = await container.exec({ + Cmd: [executable, ...args], + AttachStdin: true, + AttachStdout: true, + AttachStderr: true, + Tty: false, + }); + const stream = (await execInstance.start({ + hijack: true, + stdin: true, + })) as NodeJS.ReadWriteStream; + const stdoutStream = new PassThrough(); + const stderrStream = new PassThrough(); + + container.modem.demuxStream(stream, stdoutStream, stderrStream); + + let closed = false; + let lineBuffer = ''; + const queue: string[] = []; + let done = false; + let streamError: unknown | null = null; + + const notify = (() => { + let resolve: (() => void) | null = null; + const wait = () => + new Promise((r) => { + resolve = r; + }); + const signal = () => { + resolve?.(); + resolve = null; + }; + + return { wait, signal }; + })(); + + const pushLines = (chunk: string) => { + lineBuffer = drainExecStdoutLines(lineBuffer, chunk, queue); + notify.signal(); + }; + + stdoutStream.on('data', (chunk: Buffer) => pushLines(chunk.toString('utf-8'))); + stderrStream.on('data', (chunk: Buffer) => { + const text = chunk.toString('utf-8').trim(); + + if (text) { + this.logger.debug(`ACP exec stderr (${containerId}): ${text}`); + } + }); + stdoutStream.on('end', () => { + if (lineBuffer.trim()) { + queue.push(lineBuffer.trim()); + lineBuffer = ''; + } + + done = true; + notify.signal(); + }); + stream.on('error', (err) => { + streamError = err; + done = true; + notify.signal(); + }); + + return { + writeLine: (line: string) => { + if (closed) { + return; + } + + const payload = line.endsWith('\n') ? line : `${line}\n`; + + stream.write(payload); + }, + closeStdin: () => { + if (!closed) { + stream.end(); + } + }, + stdoutLines: async function* stdoutLines() { + while (!done || queue.length > 0) { + if (streamError) { + throw streamError; + } + + const item = queue.shift(); + + if (item) { + yield item; + continue; + } + + if (done) { + break; + } + + await notify.wait(); + } + }, + close: async () => { + if (closed) { + return; + } + + closed = true; + + try { + stream.end(); + } catch { + return; + } + }, + }; + } + /** * Create a new terminal session (TTY) for a container. * Creates a persistent TTY exec instance that can be used for interactive terminal sessions. diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/test-utils/acp-sdk.mock.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/test-utils/acp-sdk.mock.ts new file mode 100644 index 000000000..8d09b158d --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/test-utils/acp-sdk.mock.ts @@ -0,0 +1,36 @@ +export const PROTOCOL_VERSION = 1; + +export type Stream = { + writable: WritableStream; + readable: ReadableStream; +}; + +export function ndJsonStream(): Stream { + return { + writable: new WritableStream(), + readable: new ReadableStream(), + }; +} + +export class ClientSideConnection { + constructor( + private readonly _toClient: () => unknown, + private readonly _stream: Stream, + ) {} + + async initialize(): Promise<{ protocolVersion: number }> { + return { protocolVersion: PROTOCOL_VERSION }; + } + + async newSession(): Promise<{ sessionId: string }> { + return { sessionId: 'mock-session-id' }; + } + + async loadSession(): Promise<{ sessionId: string }> { + return { sessionId: 'mock-session-id' }; + } + + async prompt(): Promise<{ stopReason: string }> { + return { stopReason: 'end_turn' }; + } +} diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/clients.service.spec.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/clients.service.spec.ts index 1d74e734b..ee5299a3f 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/clients.service.spec.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/clients.service.spec.ts @@ -25,7 +25,19 @@ describe('ClientsService', () => { canManageWorkspaceConfiguration: true, config: { gitRepositoryUrl: 'https://github.com/user/repo.git', - agentTypes: [{ type: 'cursor', displayName: 'Cursor' }], + agentTypes: [ + { + type: 'cursor', + displayName: 'Cursor', + capabilities: { + transport: 'acp', + supportsChat: true, + supportsStreaming: true, + supportsToolEvents: true, + supportsQuestions: true, + }, + }, + ], }, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/agents/agents.facade.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/agents/agents.facade.ts index 38c5287bf..3e1b58992 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/agents/agents.facade.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/agents/agents.facade.ts @@ -299,7 +299,7 @@ export class AgentsFacade { * Get commands for a specific client and agent. * @param clientId - The client ID * @param agentId - The agent ID - * @param agentType - The agent type (e.g., 'cursor', 'opencode', 'openclaw') + * @param agentType - The agent type (e.g., 'cursor', 'opencode') * @returns Observable of commands array */ getClientAgentCommands$(clientId: string, agentId: string, agentType: string): Observable { diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/agents/agents.types.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/agents/agents.types.ts index 8523e8ee0..b15d449e3 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/agents/agents.types.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/agents/agents.types.ts @@ -1,10 +1,19 @@ // Types based on OpenAPI spec +export interface AgentTypeCapabilities { + transport?: 'acp'; + supportsChat: boolean; + supportsStreaming: boolean; + supportsToolEvents: boolean; + supportsQuestions: boolean; +} + export interface AgentResponseDto { id: string; name: string; description?: string; agentType: string; containerType: ContainerType; + capabilities?: AgentTypeCapabilities; vnc?: { port: number; password: string; diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/clients/clients.effects.spec.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/clients/clients.effects.spec.ts index 46cd5554a..cbc84bdf4 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/clients/clients.effects.spec.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/clients/clients.effects.spec.ts @@ -78,7 +78,19 @@ describe('ClientsEffects', () => { canManageWorkspaceConfiguration: true, config: { gitRepositoryUrl: 'https://github.com/user/repo.git', - agentTypes: [{ type: 'cursor', displayName: 'Cursor' }], + agentTypes: [ + { + type: 'cursor', + displayName: 'Cursor', + capabilities: { + transport: 'acp', + supportsChat: true, + supportsStreaming: true, + supportsToolEvents: true, + supportsQuestions: true, + }, + }, + ], }, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/clients/clients.facade.spec.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/clients/clients.facade.spec.ts index 9ed84f05b..80d495f2f 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/clients/clients.facade.spec.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/clients/clients.facade.spec.ts @@ -30,7 +30,19 @@ describe('ClientsFacade', () => { canManageWorkspaceConfiguration: true, config: { gitRepositoryUrl: 'https://github.com/user/repo.git', - agentTypes: [{ type: 'cursor', displayName: 'Cursor' }], + agentTypes: [ + { + type: 'cursor', + displayName: 'Cursor', + capabilities: { + transport: 'acp', + supportsChat: true, + supportsStreaming: true, + supportsToolEvents: true, + supportsQuestions: true, + }, + }, + ], }, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', @@ -44,7 +56,19 @@ describe('ClientsFacade', () => { canManageWorkspaceConfiguration: true, config: { gitRepositoryUrl: 'https://github.com/user2/repo2.git', - agentTypes: [{ type: 'cursor', displayName: 'Cursor' }], + agentTypes: [ + { + type: 'cursor', + displayName: 'Cursor', + capabilities: { + transport: 'acp', + supportsChat: true, + supportsStreaming: true, + supportsToolEvents: true, + supportsQuestions: true, + }, + }, + ], }, createdAt: '2024-01-02T00:00:00Z', updatedAt: '2024-01-02T00:00:00Z', diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/clients/clients.reducer.spec.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/clients/clients.reducer.spec.ts index 1bd679ade..e6c5c4b7a 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/clients/clients.reducer.spec.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/clients/clients.reducer.spec.ts @@ -44,7 +44,19 @@ describe('clientsReducer', () => { canManageWorkspaceConfiguration: true, config: { gitRepositoryUrl: 'https://github.com/user/repo.git', - agentTypes: [{ type: 'cursor', displayName: 'Cursor' }], + agentTypes: [ + { + type: 'cursor', + displayName: 'Cursor', + capabilities: { + transport: 'acp', + supportsChat: true, + supportsStreaming: true, + supportsToolEvents: true, + supportsQuestions: true, + }, + }, + ], }, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', @@ -58,7 +70,19 @@ describe('clientsReducer', () => { canManageWorkspaceConfiguration: true, config: { gitRepositoryUrl: 'https://github.com/user2/repo2.git', - agentTypes: [{ type: 'cursor', displayName: 'Cursor' }], + agentTypes: [ + { + type: 'cursor', + displayName: 'Cursor', + capabilities: { + transport: 'acp', + supportsChat: true, + supportsStreaming: true, + supportsToolEvents: true, + supportsQuestions: true, + }, + }, + ], }, createdAt: '2024-01-02T00:00:00Z', updatedAt: '2024-01-02T00:00:00Z', diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/clients/clients.selectors.spec.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/clients/clients.selectors.spec.ts index 58fe02445..53f09aef1 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/clients/clients.selectors.spec.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/clients/clients.selectors.spec.ts @@ -33,7 +33,19 @@ describe('Clients Selectors', () => { canManageWorkspaceConfiguration: true, config: { gitRepositoryUrl: 'https://github.com/user/repo.git', - agentTypes: [{ type: 'cursor', displayName: 'Cursor' }], + agentTypes: [ + { + type: 'cursor', + displayName: 'Cursor', + capabilities: { + transport: 'acp', + supportsChat: true, + supportsStreaming: true, + supportsToolEvents: true, + supportsQuestions: true, + }, + }, + ], }, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', @@ -47,7 +59,19 @@ describe('Clients Selectors', () => { canManageWorkspaceConfiguration: true, config: { gitRepositoryUrl: 'https://github.com/user2/repo2.git', - agentTypes: [{ type: 'cursor', displayName: 'Cursor' }], + agentTypes: [ + { + type: 'cursor', + displayName: 'Cursor', + capabilities: { + transport: 'acp', + supportsChat: true, + supportsStreaming: true, + supportsToolEvents: true, + supportsQuestions: true, + }, + }, + ], }, createdAt: '2024-01-02T00:00:00Z', updatedAt: '2024-01-02T00:00:00Z', diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/clients/clients.types.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/clients/clients.types.ts index edea3f2f0..08447138c 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/clients/clients.types.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/clients/clients.types.ts @@ -1,9 +1,12 @@ +import type { AgentTypeCapabilities } from '../agents/agents.types'; + // Types based on OpenAPI spec export type ClientAuthenticationType = 'api_key' | 'keycloak'; export interface AgentTypeInfo { type: string; displayName: string; + capabilities: AgentTypeCapabilities; } export interface ConfigResponseDto { diff --git a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/agent-chat-event-display.spec.ts b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/agent-chat-event-display.spec.ts index b6f9b85b2..dc077d413 100644 --- a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/agent-chat-event-display.spec.ts +++ b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/agent-chat-event-display.spec.ts @@ -261,6 +261,53 @@ describe('agent-chat-event-display', () => { expect(rows[0]?.toolPair?.resultDetailJson).toBeDefined(); }); + it('coalesces started+inProgress toolCalls before pairing so awaiting-result is not left behind', () => { + const rows = mapForwardedChatEventsToDisplayRows([ + { + payload: successEnvelope({ + eventId: 'tc1', + agentId: 'a', + correlationId: 'c', + sequence: 0, + timestamp: '2026-04-08T12:00:00.000Z', + kind: 'toolCall', + payload: { toolCallId: 't1', name: 'shell', status: 'started', args: {} }, + }), + timestamp: 10, + }, + { + payload: successEnvelope({ + eventId: 'tc2', + agentId: 'a', + correlationId: 'c', + sequence: 1, + timestamp: '2026-04-08T12:00:01.000Z', + kind: 'toolCall', + payload: { toolCallId: 't1', name: 'shell', status: 'inProgress', args: {} }, + }), + timestamp: 11, + }, + { + payload: successEnvelope({ + eventId: 'tr', + agentId: 'a', + correlationId: 'c', + sequence: 2, + timestamp: '2026-04-08T12:00:02.000Z', + kind: 'toolResult', + payload: { toolCallId: 't1', name: 'shell', isError: false, result: { stdout: 'ok' } }, + }), + timestamp: 12, + }, + ]); + + expect(rows).toHaveLength(1); + expect(rows[0]?.kind).toBe('toolCall'); + expect(rows[0]?.toolPair?.outcome).toBe('success'); + expect(rows[0]?.toolPair?.resultDetailJson).toBeDefined(); + expect(rows.filter((r) => r.toolPair?.outcome === 'pending')).toHaveLength(0); + }); + it('merges toolCall and toolResult with same id when not adjacent in event list', () => { const rows = mapForwardedChatEventsToDisplayRows([ { diff --git a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/agent-chat-event-display.ts b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/agent-chat-event-display.ts index 12c122c62..8a2870871 100644 --- a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/agent-chat-event-display.ts +++ b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/agent-chat-event-display.ts @@ -559,6 +559,72 @@ export function consolidateConsecutiveInteractionQueryTimelineRows( return out; } +/** + * Collapses multiple `toolCall` rows that share a concrete `toolCallId` into the earliest row + * (ACP emits started then in_progress as separate events). Keeps the latest summary/status. + */ +export function coalesceDuplicateToolCallDisplayRows(rows: AgentChatEventDisplayRow[]): AgentChatEventDisplayRow[] { + if (rows.length === 0) { + return rows; + } + + const firstIndexById = new Map(); + const skip = new Set(); + const replaced = new Map(); + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + + if (row === undefined || row.kind !== 'toolCall' || !isConcreteToolCallId(row.toolCallId)) { + continue; + } + + const id = row.toolCallId; + const firstIndex = firstIndexById.get(id); + + if (firstIndex === undefined) { + firstIndexById.set(id, i); + continue; + } + + const base = replaced.get(firstIndex) ?? rows[firstIndex]; + + if (base === undefined) { + continue; + } + + replaced.set(firstIndex, { + ...base, + summaryTitle: row.summaryTitle || base.summaryTitle, + summaryBody: row.summaryBody || base.summaryBody, + badgeClass: row.badgeClass || base.badgeClass, + detailJson: row.detailJson || base.detailJson, + toolPair: { + outcome: row.toolPair?.outcome ?? base.toolPair?.outcome ?? 'pending', + callDetailJson: row.toolPair?.callDetailJson ?? base.toolPair?.callDetailJson ?? row.detailJson, + resultDetailJson: row.toolPair?.resultDetailJson ?? base.toolPair?.resultDetailJson, + }, + }); + skip.add(i); + } + + const out: AgentChatEventDisplayRow[] = []; + + for (let i = 0; i < rows.length; i++) { + if (skip.has(i)) { + continue; + } + + const row = replaced.get(i) ?? rows[i]; + + if (row !== undefined) { + out.push(row); + } + } + + return out; +} + /** Merges tool call + result rows in a flat timeline (websocket event list) by matching `toolCallId`. */ export function consolidateConsecutiveToolPairTimelineRows( rows: AgentChatEventDisplayRow[], @@ -567,10 +633,11 @@ export function consolidateConsecutiveToolPairTimelineRows( return rows; } + const coalesced = coalesceDuplicateToolCallDisplayRows(rows); const { skip, mergedAt } = computeToolPairMergePlanFromIndices({ - length: rows.length, + length: coalesced.length, describe: (i) => { - const r = rows[i]; + const r = coalesced[i]; if (r === undefined) { return null; @@ -593,8 +660,8 @@ export function consolidateConsecutiveToolPairTimelineRows( return null; }, mergeAt: (callIndex, resultIndex) => { - const callRow = rows[callIndex]; - const resultRow = rows[resultIndex]; + const callRow = coalesced[callIndex]; + const resultRow = coalesced[resultIndex]; if (callRow === undefined || resultRow === undefined) { throw new Error('tool pair merge: missing row'); @@ -605,7 +672,7 @@ export function consolidateConsecutiveToolPairTimelineRows( }); const out: AgentChatEventDisplayRow[] = []; - for (let i = 0; i < rows.length; i++) { + for (let i = 0; i < coalesced.length; i++) { if (skip.has(i)) { const row = mergedAt.get(i); @@ -616,7 +683,7 @@ export function consolidateConsecutiveToolPairTimelineRows( continue; } - const row = rows[i]; + const row = coalesced[i]; if (row !== undefined) { out.push(row); diff --git a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat-thread-display.ts b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat-thread-display.ts index 0a237a622..b7689a042 100644 --- a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat-thread-display.ts +++ b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat-thread-display.ts @@ -117,10 +117,11 @@ export function consolidateInteractionQueriesInSegments(segments: AgentTurnSegme } export function consolidateToolPairsInSegments(segments: AgentTurnSegment[]): AgentTurnSegment[] { + const coalescedSegments = coalesceDuplicateToolCallSegments(segments); const { skip, mergedAt } = computeToolPairMergePlanFromIndices({ - length: segments.length, + length: coalescedSegments.length, describe: (i) => { - const s = segments[i]; + const s = coalescedSegments[i]; if (s === undefined) { return null; @@ -149,8 +150,8 @@ export function consolidateToolPairsInSegments(segments: AgentTurnSegment[]): Ag return null; }, mergeAt: (callIndex, resultIndex) => { - const callSeg = segments[callIndex]; - const resultSeg = segments[resultIndex]; + const callSeg = coalescedSegments[callIndex]; + const resultSeg = coalescedSegments[resultIndex]; if (callSeg === undefined || resultSeg === undefined) { throw new Error('tool pair merge: missing segment'); @@ -165,7 +166,7 @@ export function consolidateToolPairsInSegments(segments: AgentTurnSegment[]): Ag }); const out: AgentTurnSegment[] = []; - for (let i = 0; i < segments.length; i++) { + for (let i = 0; i < coalescedSegments.length; i++) { if (skip.has(i)) { const row = mergedAt.get(i); @@ -176,6 +177,87 @@ export function consolidateToolPairsInSegments(segments: AgentTurnSegment[]): Ag continue; } + const seg = coalescedSegments[i]; + + if (seg !== undefined) { + out.push(seg); + } + } + + return out; +} + +/** Collapse duplicate toolCall segments that share a toolCallId (status updates). */ +function coalesceDuplicateToolCallSegments(segments: AgentTurnSegment[]): AgentTurnSegment[] { + if (segments.length === 0) { + return segments; + } + + const firstIndexById = new Map(); + const skip = new Set(); + const replaced = new Map(); + + for (let i = 0; i < segments.length; i++) { + const seg = segments[i]; + + if (seg === undefined || seg.kind !== 'row' || seg.row.kind !== 'toolCall') { + continue; + } + + if (!isConcreteToolCallId(seg.row.toolCallId)) { + continue; + } + + const id = seg.row.toolCallId; + const firstIndex = firstIndexById.get(id); + + if (firstIndex === undefined) { + firstIndexById.set(id, i); + continue; + } + + const baseSeg = segments[firstIndex]; + const base = replaced.get(firstIndex) ?? (baseSeg?.kind === 'row' ? baseSeg.row : undefined); + + if (base === undefined) { + continue; + } + + const row = seg.row; + + replaced.set(firstIndex, { + ...base, + summaryTitle: row.summaryTitle || base.summaryTitle, + summaryBody: row.summaryBody || base.summaryBody, + badgeClass: row.badgeClass || base.badgeClass, + detailJson: row.detailJson || base.detailJson, + toolPair: { + outcome: row.toolPair?.outcome ?? base.toolPair?.outcome ?? 'pending', + callDetailJson: row.toolPair?.callDetailJson ?? base.toolPair?.callDetailJson ?? row.detailJson, + resultDetailJson: row.toolPair?.resultDetailJson ?? base.toolPair?.resultDetailJson, + }, + }); + skip.add(i); + } + + if (skip.size === 0) { + return segments; + } + + const out: AgentTurnSegment[] = []; + + for (let i = 0; i < segments.length; i++) { + if (skip.has(i)) { + continue; + } + + const replacedRow = replaced.get(i); + + if (replacedRow !== undefined) { + out.push({ kind: 'row', row: replacedRow }); + continue; + } + const seg = segments[i]; if (seg !== undefined) { diff --git a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat.component.html b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat.component.html index 8dc1eee84..b9b38831c 100644 --- a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat.component.html +++ b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat.component.html @@ -49,23 +49,21 @@ aria-hidden="true" > - @if ((selectedAgent$ | async)?.agentType !== 'openclaw') { - - } + - @if (activeClient$ | async; as activeClient) { - @if (selectedAgent$ | async; as selectedAgent) { - @if ({ running: getAgentContainerRunning$(activeClient.id, selectedAgent.id) | async }; as status) { - - } - } - } -
Gateway
- -
- @if (activeClient$ | async; as activeClient) { - @if (selectedAgent$ | async; as selectedAgent) { - @if ({ running: getAgentContainerRunning$(activeClient.id, selectedAgent.id) | async }; as status) { -
- @if (status.running !== true) { - - } - @if (status.running === true) { - - } - @if (status.running !== null && status.running !== undefined) { - - } -
- } - } - } -
- @if (socketConnected$ | async) { - - } @else { - @if (socketReconnecting$ | async; as isReconnecting) { - - } @else { - - } - } -
- @if (!editorOpen()) { -
- - @if ((activeClient$ | async)?.canManageWorkspaceConfiguration) { - - } -
- } - @if (activeClient$ | async; as activeClient) { - @if (selectedAgent$ | async; as selectedAgent) { - @if (selectedAgent.vnc?.port && (activeClient$ | async)) { - - } - @if (selectedAgent.ssh?.port) { - - } - - } - } -
- - @if (showSSHCommand()) { - @if (selectedAgent$ | async; as selectedAgent) { - @if (activeClient$ | async; as activeClient) { - @if (activeClient?.endpoint && selectedAgent.ssh?.port) { -
-
- -
- {{ - buildSSHCommand( - activeClient?.endpoint || '', - selectedAgent.ssh?.port || 22, - undefined, - selectedAgent.ssh?.password - ) - }} -
-
-
- - If you want to connect to this workspace with an editor like Cursor or VS Code via SSH, add the - following lines to your - ~/.ssh/config - file: -
- Host remote-workspace
-     HostName {{ getHostname(activeClient?.endpoint || '', false) }}
-     User agenstra
-     Port {{ selectedAgent.ssh?.port }}
-     StrictHostKeyChecking no -
-
- - The password for the - agenstra - user is - {{ - selectedAgent.ssh?.password - }}. The repository path is - /app. -
-
-
- } - } - } - } - - - -
- @if (activeClientId$ | async; as clientId) { - @if (selectedAgent$ | async; as selectedAgent) { -
-
-
-
-

- Step 1: Create the OpenClaw configuration -

-
-
-

- Create the default OpenClaw configuration file in the container. This writes - .openclaw/openclaw.json - so the gateway can load its settings. -

- -
-
-
-
-
-
-

- Step 2: Adapt the configuration -

-
-
-

- Edit the configuration in the editor to customize the gateway settings, adding and configuring - channels, AI models, and more. -

- -
-
-
-
-
-
-

- Step 3: Restart the agent -

-
-
-

- Restart the agent so it picks up the new configuration. -

- -
-
-
-
-
-
-

- Step 4: Start chatting -

-
-
-

- You are all set. Say hello to your new bot in the chat, try a question, or start a task. Have - fun! -

-
-
-
-
- } - } -
- @if (!editorOpen() && (activeClientId$ | async) && (selectedAgent$ | async)) { ('app'); deploymentManagerOpen = signal(false); chatVisible = signal(false); - gatewayVisible = signal(false); private previousAgentId: string | null = null; readonly fileOnlyMode = signal(false); readonly standaloneMode = signal(false); @@ -738,31 +735,13 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe toObservable(this.chatVisible), ]).pipe( map(([selectedAgent, editorOpen, deploymentManagerOpen, chatVisible]) => { - if (!selectedAgent || selectedAgent.agentType === 'openclaw') { - return false; - } - - const sidePanelOpen = deploymentManagerOpen; - - return (!editorOpen && !sidePanelOpen) || chatVisible; - }), - ); - - // Computed observable to determine if chat should be visible - readonly shouldShowGateway$ = combineLatest([ - this.selectedAgent$, - toObservable(this.editorOpen), - toObservable(this.deploymentManagerOpen), - toObservable(this.gatewayVisible), - ]).pipe( - map(([selectedAgent, editorOpen, deploymentManagerOpen, gatewayVisible]) => { if (!selectedAgent) { return false; } const sidePanelOpen = deploymentManagerOpen; - return ((!editorOpen && !sidePanelOpen) || gatewayVisible) && selectedAgent.agentType === 'openclaw'; + return (!editorOpen && !sidePanelOpen) || chatVisible; }), ); @@ -781,54 +760,6 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe }), ); - /** Path for OpenClaw config file in the container (relative to agent base path). */ - private static readonly OPENCLAW_CONFIG_PATH = '.openclaw/openclaw.json'; - - /** Path for OpenClaw config directory. */ - private static readonly OPENCLAW_CONFIG_DIR = '.openclaw'; - - /** - * True when .openclaw/openclaw.json exists in the selected openclaw agent's container. - * Triggers a directory list when the gateway is shown for an openclaw agent. - */ - readonly openclawConfigExists$: Observable = combineLatest([this.activeClientId$, this.selectedAgent$]).pipe( - filter(([clientId, agent]) => !!clientId && !!agent && agent.agentType === 'openclaw'), - tap(([clientId, agent]) => { - if (!clientId || !agent) { - return; - } - - this.filesFacade.listDirectory(clientId, agent.id, { path: AgentConsoleChatComponent.OPENCLAW_CONFIG_DIR }); - }), - switchMap(([clientId, agent]) => { - if (!clientId || !agent) { - return of([]); - } - - return this.filesFacade.getDirectoryListing$(clientId, agent.id, AgentConsoleChatComponent.OPENCLAW_CONFIG_DIR); - }), - map((nodes) => nodes?.some((n) => n.name === 'openclaw.json' && n.type === 'file') ?? false), - startWith(false), - shareReplay(1), - ); - - /** True while the OpenClaw config file is being written. */ - readonly isWritingOpenClawConfig$: Observable = combineLatest([ - this.activeClientId$, - this.selectedAgent$, - ]).pipe( - filter(([clientId, agent]) => !!clientId && !!agent && agent.agentType === 'openclaw'), - switchMap(([clientId, agent]) => { - if (!clientId || !agent) { - return of(false); - } - - return this.filesFacade.isWritingFile$(clientId, agent.id, AgentConsoleChatComponent.OPENCLAW_CONFIG_PATH); - }), - startWith(false), - shareReplay(1), - ); - /** * Set on send until the echoed user `chatMessage` exists in the store; drives waiting/streaming UI. */ @@ -4022,84 +3953,6 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe this.agentsFacade.restartClientAgent(clientId, agentId); } - /** - * Creates the .openclaw directory and .openclaw/openclaw.json with default configuration, - * then refreshes the directory listing so the quickstart step 1 button can disable. - */ - createOpenClawConfig(clientId: string, agentId: string): void { - this.filesFacade.createFileOrDirectory(clientId, agentId, AgentConsoleChatComponent.OPENCLAW_CONFIG_DIR, { - type: 'directory', - } as CreateFileDto); - - of(null) - .pipe(delay(400), takeUntilDestroyed(this.destroyRef)) - .subscribe(() => { - const config = this.getDefaultOpenClawConfig(); - const content = JSON.stringify(config, null, 2); - const base64Content = btoa(unescape(encodeURIComponent(content))); - const writeDto: WriteFileDto = { content: base64Content, encoding: 'utf-8' }; - - this.filesFacade.writeFile(clientId, agentId, AgentConsoleChatComponent.OPENCLAW_CONFIG_PATH, writeDto); - - this.filesFacade - .isWritingFile$(clientId, agentId, AgentConsoleChatComponent.OPENCLAW_CONFIG_PATH) - .pipe( - filter((writing) => !writing), - take(1), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe(() => { - this.filesFacade.listDirectory(clientId, agentId, { - path: AgentConsoleChatComponent.OPENCLAW_CONFIG_DIR, - }); - }); - }); - } - - /** Returns the default OpenClaw config object written to .openclaw/openclaw.json. */ - private getDefaultOpenClawConfig(): object { - return { - commands: { - native: 'auto', - nativeSkills: 'auto', - }, - channels: { - telegram: { - enabled: true, - dmPolicy: 'pairing', - botToken: '1234567890', - groupPolicy: 'allowlist', - streamMode: 'partial', - }, - }, - agents: { - defaults: { - model: { - primary: 'openai/gpt-5.1-codex', - }, - maxConcurrent: 4, - subagents: { - maxConcurrent: 8, - }, - }, - }, - messages: { - ackReactionScope: 'group-mentions', - }, - plugins: { - entries: { - telegram: { - enabled: true, - }, - }, - }, - meta: { - lastTouchedVersion: '2026.2.9', - lastTouchedAt: '2026-02-10T19:07:41.424Z', - }, - }; - } - onEditingClientAuthTypeChange(): void { // Clear authentication-specific fields when type changes const current = this.editingClient(); diff --git a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/tickets/tickets-board.component.ts b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/tickets/tickets-board.component.ts index 9f6ab0398..52bd31add 100644 --- a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/tickets/tickets-board.component.ts +++ b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/tickets/tickets-board.component.ts @@ -369,9 +369,7 @@ export class TicketsBoardComponent implements OnInit, AfterViewInit { return this.automationAgentChoices().filter((a) => enabled.has(a.id)); }); - readonly chatCapableAgents$: Observable = this.agents$.pipe( - map((agents) => agents.filter((a) => a.agentType !== 'openclaw')), - ); + readonly chatCapableAgents$: Observable = this.agents$; /** Mirrors `chatCapableAgents$` for constructor effects (WebSocket detail updates, socket agent changes). */ private readonly chatCapableAgentsSignal = toSignal(this.chatCapableAgents$, { diff --git a/libs/domains/agenstra/frontend/feature-landingpage/src/lib/home/home.component.html b/libs/domains/agenstra/frontend/feature-landingpage/src/lib/home/home.component.html index c14291dc3..e4486bbdd 100644 --- a/libs/domains/agenstra/frontend/feature-landingpage/src/lib/home/home.component.html +++ b/libs/domains/agenstra/frontend/feature-landingpage/src/lib/home/home.component.html @@ -124,7 +124,6 @@

Cursor OpenCode - OpenClaw @@ -1069,7 +1068,7 @@