diff --git a/.env b/.env index adefa71..34de473 100644 --- a/.env +++ b/.env @@ -69,6 +69,8 @@ LLM_CONTENT_EDITOR_OPENAI_API_KEY=your-key-here LLM_WIRE_LOG_ENABLED=0 ###< sitebuilder/llm-wire-log ### +CURSOR_AGENT_API_KEY=your-key-here + ###> sitebuilder/docker-execution ### # Host path for Docker-in-Docker volume mounts. # IMPORTANT: This must be set to the absolute path of the project on the host. diff --git a/config/services.yaml b/config/services.yaml index a00a5bf..30305aa 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -133,6 +133,18 @@ services: App\ChatBasedContentEditor\Facade\ChatBasedContentEditorFacadeInterface: class: App\ChatBasedContentEditor\Facade\ChatBasedContentEditorFacade + App\LlmContentEditor\Infrastructure\LlmContentEditorAdapter: ~ + + App\CursorAgentContentEditor\Infrastructure\CursorAgentContentEditorAdapter: ~ + + App\AgenticContentEditor\Facade\AgenticContentEditorFacadeInterface: + class: App\AgenticContentEditor\Facade\AgenticContentEditorFacade + arguments: + - [ + '@App\LlmContentEditor\Infrastructure\LlmContentEditorAdapter', + '@App\CursorAgentContentEditor\Infrastructure\CursorAgentContentEditorAdapter', + ] + # Domain service bindings App\WorkspaceMgmt\Domain\Service\WorkspaceStatusGuardInterface: class: App\WorkspaceMgmt\Domain\Service\WorkspaceStatusGuard diff --git a/docker-compose.yml b/docker-compose.yml index 58050a5..65882ee 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,6 @@ services: app: + image: etfs_${ETFS_PROJECT_NAME}_app build: context: . dockerfile: docker/app/Dockerfile @@ -7,6 +8,7 @@ services: volumes: - .:/var/www - mise_data:/opt/mise + - /var/run/docker.sock:/var/run/docker.sock environment: # Redirect cache and config directories to /tmp to avoid polluting the mounted project directory HOME: /tmp/container-home @@ -19,6 +21,8 @@ services: MISE_DATA_DIR: /opt/mise/data MISE_CACHE_DIR: /opt/mise/cache MISE_STATE_DIR: /opt/mise/state + # Use the app image (includes Cursor Agent CLI) for agent runs + CURSOR_AGENT_IMAGE: etfs_${ETFS_PROJECT_NAME}_app networks: - default depends_on: @@ -26,6 +30,7 @@ services: restart: unless-stopped messenger: + image: etfs_${ETFS_PROJECT_NAME}_app build: context: . dockerfile: docker/app/Dockerfile @@ -49,6 +54,8 @@ services: # The messenger runs Docker commands via the host socket, so paths must be host paths # Uses PWD as default, which is set by docker-compose to the project directory HOST_PROJECT_PATH: ${HOST_PROJECT_PATH:-${PWD}} + # Use the app image (includes Cursor Agent CLI) for agent runs + CURSOR_AGENT_IMAGE: etfs_${ETFS_PROJECT_NAME}_app networks: - default depends_on: diff --git a/docker/app/Dockerfile b/docker/app/Dockerfile index 09c6cd7..d716ba6 100644 --- a/docker/app/Dockerfile +++ b/docker/app/Dockerfile @@ -32,6 +32,9 @@ RUN install -m 0755 -d /etc/apt/keyrings && \ apt-get update -y && \ apt-get install -y docker-ce-cli +# Install Cursor CLI for agent execution +RUN curl -fsS https://cursor.com/install | bash + # Clean up to reduce image size RUN apt-get clean && rm -rf /var/lib/apt/lists/* diff --git a/docs/vertical-wiring.md b/docs/vertical-wiring.md index 7d864d5..fe3c379 100644 --- a/docs/vertical-wiring.md +++ b/docs/vertical-wiring.md @@ -1,13 +1,15 @@ # Vertical Facade Wiring -This diagram shows **which verticals call which other verticals’ facade interface methods** — the wiring between verticals only. Internal calls within a vertical are omitted. See [archbook.md](archbook.md) for the overall facade/vertical architecture. +This diagram shows **which verticals call which other verticals' facade interface methods** — the wiring between verticals only. Internal calls within a vertical are omitted. See [archbook.md](archbook.md) for the overall facade/vertical architecture. ```mermaid flowchart LR subgraph callers["Callers"] direction TB CBCE["ChatBasedContentEditor"] + ACE["AgenticContentEditor"] LLM["LlmContentEditor"] + CAC["CursorAgentContentEditor"] WSM["WorkspaceMgmt"] WST["WorkspaceTooling"] ORG["Organization"] @@ -21,6 +23,7 @@ flowchart LR ACC[(Account)] PRJF[(ProjectMgmt)] WSMF[(WorkspaceMgmt)] + ACEF[(AgenticContentEditor)] LLMF[(LlmContentEditor)] WSTF[(WorkspaceTooling)] RCAF[(RemoteContentAssets)] @@ -32,11 +35,16 @@ flowchart LR CBCE -->|account lookup| ACC CBCE -->|getProjectInfo| PRJF CBCE -->|workspace lifecycle, commit, PR| WSMF - CBCE -->|streamEdit, context dump| LLMF + CBCE -->|streamEdit, context dump, model info| ACEF + + ACE -.->|dispatches to adapters in| LLM + ACE -.->|dispatches to adapters in| CAC LLM -->|tools: build, preview, assets, rules| WSTF LLM -->|getAgentConfigTemplate| PRJF + CAC -->|tools: build, rules| WSTF + WSM -->|getProjectInfo| PRJF WSM -->|getLatestConversationId| CBCEF @@ -65,8 +73,10 @@ Method details are in the summary table below. | Caller vertical | Calls into (facade) | Main methods | |---------------------------|----------------------------|--------------| -| **ChatBasedContentEditor** | Account, ProjectMgmt, WorkspaceMgmt, LlmContentEditor | Workspace lifecycle, commitAndPush, streamEditWithHistory, buildAgentContextDump, account resolution | +| **ChatBasedContentEditor** | Account, ProjectMgmt, WorkspaceMgmt, AgenticContentEditor | Workspace lifecycle, commitAndPush, streamEditWithHistory, buildAgentContextDump, getBackendModelInfo, account resolution | +| **AgenticContentEditor** | *(dispatches to adapters)* | Facade dispatches to `LlmContentEditorAdapter` and `CursorAgentContentEditorAdapter` via SPI | | **LlmContentEditor** | WorkspaceTooling, ProjectMgmt | runQualityChecks, runTests, runBuild, suggestCommitMessage, getPreviewUrl, list/search remote assets, getWorkspaceRules; getAgentConfigTemplate (EditContentCommand) | +| **CursorAgentContentEditor** | WorkspaceTooling | runBuildInWorkspace, runShellCommandAsync, getWorkspaceRules | | **WorkspaceMgmt** | ProjectMgmt, ChatBasedContentEditor | getProjectInfo (setup, git, review); getLatestConversationId (reviewer UI) | | **WorkspaceTooling** | RemoteContentAssets | fetchAndMergeAssetUrls, getRemoteAssetInfo | | **Organization** | Prefab, ProjectMgmt, WorkspaceMgmt, Account | loadPrefabs, createProjectFromPrefab, dispatchSetupIfNeeded; account resolution and registration | @@ -74,10 +84,26 @@ Method details are in the summary table below. | **RemoteContentAssets** (UI) | ProjectMgmt | getProjectInfo (for manifest URLs) | | **Common** (voter) | Account, Organization | getAccountCoreIdByEmail; userCanReviewWorkspaces | +## Architecture: Agentic Content Editor + +The **AgenticContentEditor** vertical implements a hexagonal port/adapter pattern: + +- **Port** (`AgenticContentEditorFacadeInterface`): what consumers call (e.g. `ChatBasedContentEditor`). +- **SPI** (`AgenticContentEditorAdapterInterface`): what backend adapters implement. +- **Facade** (`AgenticContentEditorFacade`): dispatcher that resolves the correct adapter by backend type. + +Adapters live in their respective backend verticals: +- `LlmContentEditor/Infrastructure/LlmContentEditorAdapter` — delegates to `LlmContentEditorFacade` +- `CursorAgentContentEditor/Infrastructure/CursorAgentContentEditorAdapter` — runs the Cursor CLI agent directly + +Canonical DTOs and enums (`EditStreamChunkDto`, `AgentConfigDto`, `AgenticContentEditorBackend`, etc.) live in `AgenticContentEditor/Facade/` and are shared by all participants. + ## Notes -- **ChatBasedContentEditor** is the main consumer of **WorkspaceMgmt** and **LlmContentEditor** (conversation flow, edit sessions, commit/push). +- **ChatBasedContentEditor** calls **AgenticContentEditor** for all edit operations. It never imports from LlmContentEditor or CursorAgentContentEditor directly. - **LlmContentEditor** (ContentEditorAgent) uses **WorkspaceTooling** for all tool implementations (quality checks, build, preview, remote assets, rules). +- **CursorAgentContentEditor** uses **WorkspaceTooling** for build and shell execution. - **WorkspaceTooling** delegates remote asset listing/info to **RemoteContentAssets**. - **Organization** onboarding (AccountCoreCreatedSymfonyEventSubscriber) wires **Prefab → ProjectMgmt → WorkspaceMgmt** to create projects and dispatch setup. - **ProjectMgmt** presentation layer coordinates **ChatBasedContentEditor**, **WorkspaceMgmt**, **LlmContentEditor**, and **RemoteContentAssets** for project/workspace/conversation and validation flows. +- **ProjectMgmt** still calls **LlmContentEditor** directly for `verifyApiKey()` — this is intentional as API key verification is LLM-specific and part of project configuration. diff --git a/migrations/Version20260127145023.php b/migrations/Version20260127145023.php new file mode 100644 index 0000000..95a657f --- /dev/null +++ b/migrations/Version20260127145023.php @@ -0,0 +1,33 @@ +addSql('ALTER TABLE conversations ADD content_editor_backend VARCHAR(32) DEFAULT \'llm\' NOT NULL, ADD cursor_agent_session_id VARCHAR(64) DEFAULT NULL'); + $this->addSql('ALTER TABLE projects ADD content_editor_backend VARCHAR(32) DEFAULT \'llm\' NOT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE conversations DROP content_editor_backend, DROP cursor_agent_session_id'); + $this->addSql('ALTER TABLE projects DROP content_editor_backend'); + } +} diff --git a/migrations/Version20260211120000.php b/migrations/Version20260211120000.php new file mode 100644 index 0000000..2002dc1 --- /dev/null +++ b/migrations/Version20260211120000.php @@ -0,0 +1,30 @@ +addSql('ALTER TABLE conversations RENAME COLUMN cursor_agent_session_id TO backend_session_state'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE conversations RENAME COLUMN backend_session_state TO cursor_agent_session_id'); + } +} diff --git a/src/AgenticContentEditor/Facade/AgenticContentEditorAdapterInterface.php b/src/AgenticContentEditor/Facade/AgenticContentEditorAdapterInterface.php new file mode 100644 index 0000000..e3cfe00 --- /dev/null +++ b/src/AgenticContentEditor/Facade/AgenticContentEditorAdapterInterface.php @@ -0,0 +1,54 @@ + $previousMessages + * + * @return Generator + */ + public function streamEdit( + string $workspacePath, + string $instruction, + array $previousMessages, + string $apiKey, + AgentConfigDto $agentConfig, + ?string $backendSessionState = null, + string $locale = 'en', + ): Generator; + + /** + * Build a human-readable dump of the full agent context for debugging. + * Each backend formats this according to how it actually sends context to its agent. + * + * @param list $previousMessages + */ + public function buildAgentContextDump( + string $instruction, + array $previousMessages, + AgentConfigDto $agentConfig + ): string; + + /** + * Return model information for this backend (name, context limit, cost rates). + * Used for context usage bars and cost estimation in the UI. + */ + public function getBackendModelInfo(): BackendModelInfoDto; +} diff --git a/src/AgenticContentEditor/Facade/AgenticContentEditorFacade.php b/src/AgenticContentEditor/Facade/AgenticContentEditorFacade.php new file mode 100644 index 0000000..97187fd --- /dev/null +++ b/src/AgenticContentEditor/Facade/AgenticContentEditorFacade.php @@ -0,0 +1,87 @@ + $adapters + */ + public function __construct( + private readonly array $adapters + ) { + } + + /** + * @param list $previousMessages + * + * @return Generator + */ + public function streamEditWithHistory( + AgenticContentEditorBackend $backend, + string $workspacePath, + string $instruction, + array $previousMessages, + string $apiKey, + AgentConfigDto $agentConfig, + ?string $backendSessionState = null, + string $locale = 'en', + ): Generator { + $adapter = $this->resolveAdapter($backend); + + return $adapter->streamEdit( + $workspacePath, + $instruction, + $previousMessages, + $apiKey, + $agentConfig, + $backendSessionState, + $locale + ); + } + + /** + * @param list $previousMessages + */ + public function buildAgentContextDump( + AgenticContentEditorBackend $backend, + string $instruction, + array $previousMessages, + AgentConfigDto $agentConfig + ): string { + return $this->resolveAdapter($backend)->buildAgentContextDump( + $instruction, + $previousMessages, + $agentConfig + ); + } + + public function getBackendModelInfo(AgenticContentEditorBackend $backend): BackendModelInfoDto + { + return $this->resolveAdapter($backend)->getBackendModelInfo(); + } + + private function resolveAdapter(AgenticContentEditorBackend $backend): AgenticContentEditorAdapterInterface + { + foreach ($this->adapters as $adapter) { + if ($adapter->supports($backend)) { + return $adapter; + } + } + + throw new RuntimeException('No content editor adapter registered for backend: ' . $backend->value); + } +} diff --git a/src/AgenticContentEditor/Facade/AgenticContentEditorFacadeInterface.php b/src/AgenticContentEditor/Facade/AgenticContentEditorFacadeInterface.php new file mode 100644 index 0000000..a0fdb0d --- /dev/null +++ b/src/AgenticContentEditor/Facade/AgenticContentEditorFacadeInterface.php @@ -0,0 +1,54 @@ + $previousMessages + * + * @return Generator + */ + public function streamEditWithHistory( + AgenticContentEditorBackend $backend, + string $workspacePath, + string $instruction, + array $previousMessages, + string $apiKey, + AgentConfigDto $agentConfig, + ?string $backendSessionState = null, + string $locale = 'en', + ): Generator; + + /** + * Build a human-readable dump of the full agent context for debugging. + * Dispatches to the adapter matching the given backend. + * + * @param list $previousMessages + */ + public function buildAgentContextDump( + AgenticContentEditorBackend $backend, + string $instruction, + array $previousMessages, + AgentConfigDto $agentConfig + ): string; + + /** + * Return model information for the given backend (name, context limit, cost rates). + * Used for context usage bars and cost estimation in the UI. + */ + public function getBackendModelInfo(AgenticContentEditorBackend $backend): BackendModelInfoDto; +} diff --git a/src/LlmContentEditor/Facade/Dto/AgentConfigDto.php b/src/AgenticContentEditor/Facade/Dto/AgentConfigDto.php similarity index 84% rename from src/LlmContentEditor/Facade/Dto/AgentConfigDto.php rename to src/AgenticContentEditor/Facade/Dto/AgentConfigDto.php index 2cf65c6..63bb945 100644 --- a/src/LlmContentEditor/Facade/Dto/AgentConfigDto.php +++ b/src/AgenticContentEditor/Facade/Dto/AgentConfigDto.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace App\LlmContentEditor\Facade\Dto; +namespace App\AgenticContentEditor\Facade\Dto; /** - * DTO for passing agent configuration to the LLM content editor. + * DTO for passing agent configuration to the content editor. * Contains the three instruction sets that define agent behavior. * * When workingFolderPath is set (e.g. "/workspace"), it is appended to the system diff --git a/src/AgenticContentEditor/Facade/Dto/AgentEventDto.php b/src/AgenticContentEditor/Facade/Dto/AgentEventDto.php new file mode 100644 index 0000000..00984dc --- /dev/null +++ b/src/AgenticContentEditor/Facade/Dto/AgentEventDto.php @@ -0,0 +1,31 @@ +|null $toolInputs + * @param int|null $inputBytes Actual byte length of tool inputs (for context-usage tracking) + * @param int|null $resultBytes Actual byte length of tool result (for context-usage tracking) + */ +readonly class AgentEventDto +{ + /** + * @param list|null $toolInputs + */ + public function __construct( + public string $kind, + public ?string $toolName = null, + public ?array $toolInputs = null, + public ?string $toolResult = null, + public ?string $errorMessage = null, + public ?int $inputBytes = null, + public ?int $resultBytes = null, + ) { + } +} diff --git a/src/AgenticContentEditor/Facade/Dto/BackendModelInfoDto.php b/src/AgenticContentEditor/Facade/Dto/BackendModelInfoDto.php new file mode 100644 index 0000000..614bd83 --- /dev/null +++ b/src/AgenticContentEditor/Facade/Dto/BackendModelInfoDto.php @@ -0,0 +1,27 @@ +workspaceId = $workspaceId; - $this->userId = $userId; - $this->workspacePath = $workspacePath; - $this->status = ConversationStatus::ONGOING; - $this->createdAt = DateAndTimeService::getDateTimeImmutable(); - $this->editSessions = new ArrayCollection(); - $this->messages = new ArrayCollection(); + $this->workspaceId = $workspaceId; + $this->userId = $userId; + $this->workspacePath = $workspacePath; + $this->status = ConversationStatus::ONGOING; + $this->contentEditorBackend = $contentEditorBackend; + $this->createdAt = DateAndTimeService::getDateTimeImmutable(); + $this->editSessions = new ArrayCollection(); + $this->messages = new ArrayCollection(); } #[ORM\Id] @@ -104,6 +107,25 @@ public function getWorkspacePath(): string return $this->workspacePath; } + #[ORM\Column( + type: Types::STRING, + length: 32, + nullable: false, + enumType: AgenticContentEditorBackend::class, + options: ['default' => AgenticContentEditorBackend::Llm->value] + )] + private AgenticContentEditorBackend $contentEditorBackend = AgenticContentEditorBackend::Llm; + + public function getContentEditorBackend(): AgenticContentEditorBackend + { + return $this->contentEditorBackend; + } + + public function setContentEditorBackend(AgenticContentEditorBackend $contentEditorBackend): void + { + $this->contentEditorBackend = $contentEditorBackend; + } + #[ORM\Column( type: Types::DATETIME_IMMUTABLE, nullable: false @@ -136,6 +158,23 @@ public function updateLastActivity(): void $this->lastActivityAt = DateAndTimeService::getDateTimeImmutable(); } + #[ORM\Column( + type: Types::STRING, + length: 64, + nullable: true + )] + private ?string $backendSessionState = null; + + public function getBackendSessionState(): ?string + { + return $this->backendSessionState; + } + + public function setBackendSessionState(?string $backendSessionState): void + { + $this->backendSessionState = $backendSessionState; + } + /** * @var Collection */ diff --git a/src/ChatBasedContentEditor/Domain/Service/ConversationService.php b/src/ChatBasedContentEditor/Domain/Service/ConversationService.php index aee02a9..0008162 100644 --- a/src/ChatBasedContentEditor/Domain/Service/ConversationService.php +++ b/src/ChatBasedContentEditor/Domain/Service/ConversationService.php @@ -9,6 +9,7 @@ use App\ChatBasedContentEditor\Domain\Entity\Conversation; use App\ChatBasedContentEditor\Domain\Enum\ConversationStatus; use App\ChatBasedContentEditor\Infrastructure\Service\ConversationUrlServiceInterface; +use App\ProjectMgmt\Facade\ProjectMgmtFacadeInterface; use App\WorkspaceMgmt\Facade\WorkspaceMgmtFacadeInterface; use Doctrine\ORM\EntityManagerInterface; use RuntimeException; @@ -24,6 +25,7 @@ public function __construct( private readonly WorkspaceMgmtFacadeInterface $workspaceMgmtFacade, private readonly AccountFacadeInterface $accountFacade, private readonly ConversationUrlServiceInterface $conversationUrlService, + private readonly ProjectMgmtFacadeInterface $projectMgmtFacade, ) { } @@ -48,6 +50,9 @@ public function startOrResumeConversation(string $projectId, string $userId): Co return $this->toDto($existingConversation); } + $projectInfo = $this->projectMgmtFacade->getProjectInfo($projectId); + $contentEditorBackend = $projectInfo->contentEditorBackend; + // Transition workspace to IN_CONVERSATION $this->workspaceMgmtFacade->transitionToInConversation($workspaceInfo->id); @@ -55,7 +60,8 @@ public function startOrResumeConversation(string $projectId, string $userId): Co $conversation = new Conversation( $workspaceInfo->id, $userId, - $workspaceInfo->workspacePath + $workspaceInfo->workspacePath, + $contentEditorBackend ); $this->entityManager->persist($conversation); $this->entityManager->flush(); diff --git a/src/ChatBasedContentEditor/Infrastructure/Handler/RunEditSessionHandler.php b/src/ChatBasedContentEditor/Infrastructure/Handler/RunEditSessionHandler.php index 2136f70..ddabb79 100644 --- a/src/ChatBasedContentEditor/Infrastructure/Handler/RunEditSessionHandler.php +++ b/src/ChatBasedContentEditor/Infrastructure/Handler/RunEditSessionHandler.php @@ -5,6 +5,12 @@ namespace App\ChatBasedContentEditor\Infrastructure\Handler; use App\Account\Facade\AccountFacadeInterface; +use App\AgenticContentEditor\Facade\AgenticContentEditorFacadeInterface; +use App\AgenticContentEditor\Facade\Dto\AgentConfigDto; +use App\AgenticContentEditor\Facade\Dto\AgentEventDto; +use App\AgenticContentEditor\Facade\Dto\ConversationMessageDto; +use App\AgenticContentEditor\Facade\Dto\ToolInputEntryDto; +use App\AgenticContentEditor\Facade\Enum\EditStreamChunkType; use App\ChatBasedContentEditor\Domain\Entity\Conversation; use App\ChatBasedContentEditor\Domain\Entity\ConversationMessage; use App\ChatBasedContentEditor\Domain\Entity\EditSession; @@ -13,12 +19,6 @@ use App\ChatBasedContentEditor\Domain\Enum\EditSessionStatus; use App\ChatBasedContentEditor\Infrastructure\Message\RunEditSessionMessage; use App\ChatBasedContentEditor\Infrastructure\Service\ConversationUrlServiceInterface; -use App\LlmContentEditor\Facade\Dto\AgentConfigDto; -use App\LlmContentEditor\Facade\Dto\AgentEventDto; -use App\LlmContentEditor\Facade\Dto\ConversationMessageDto; -use App\LlmContentEditor\Facade\Dto\ToolInputEntryDto; -use App\LlmContentEditor\Facade\Enum\EditStreamChunkType; -use App\LlmContentEditor\Facade\LlmContentEditorFacadeInterface; use App\ProjectMgmt\Facade\ProjectMgmtFacadeInterface; use App\WorkspaceMgmt\Facade\WorkspaceMgmtFacadeInterface; use App\WorkspaceTooling\Facade\AgentExecutionContextInterface; @@ -36,14 +36,14 @@ final readonly class RunEditSessionHandler { public function __construct( - private EntityManagerInterface $entityManager, - private LlmContentEditorFacadeInterface $facade, - private LoggerInterface $logger, - private WorkspaceMgmtFacadeInterface $workspaceMgmtFacade, - private ProjectMgmtFacadeInterface $projectMgmtFacade, - private AccountFacadeInterface $accountFacade, - private ConversationUrlServiceInterface $conversationUrlService, - private AgentExecutionContextInterface $executionContext, + private EntityManagerInterface $entityManager, + private AgenticContentEditorFacadeInterface $facade, + private LoggerInterface $logger, + private WorkspaceMgmtFacadeInterface $workspaceMgmtFacade, + private ProjectMgmtFacadeInterface $projectMgmtFacade, + private AccountFacadeInterface $accountFacade, + private ConversationUrlServiceInterface $conversationUrlService, + private AgentExecutionContextInterface $executionContext, ) { } @@ -80,12 +80,12 @@ public function __invoke(RunEditSessionMessage $message): void // Ensure we have a valid LLM API key from the project if ($project === null || $project->llmApiKey === '') { - $this->logger->error('EditSession failed: no LLM API key configured for project', [ + $this->logger->error('EditSession failed: no API key configured for project', [ 'sessionId' => $message->sessionId, 'workspaceId' => $conversation->getWorkspaceId(), ]); - EditSessionChunk::createDoneChunk($session, false, 'No LLM API key configured for this project.'); + EditSessionChunk::createDoneChunk($session, false, 'No API key configured for this project.'); $session->setStatus(EditSessionStatus::Failed); $this->entityManager->flush(); @@ -110,11 +110,13 @@ public function __invoke(RunEditSessionMessage $message): void ); $generator = $this->facade->streamEditWithHistory( + $conversation->getContentEditorBackend(), $session->getWorkspacePath(), $session->getInstruction(), $previousMessages, $project->llmApiKey, $agentConfig, + $conversation->getBackendSessionState(), $message->locale, ); @@ -157,6 +159,9 @@ public function __invoke(RunEditSessionMessage $message): void // Persist new conversation messages $this->persistConversationMessage($conversation, $chunk->message); } elseif ($chunk->chunkType === EditStreamChunkType::Done) { + if ($chunk->backendSessionState !== null) { + $conversation->setBackendSessionState($chunk->backendSessionState); + } EditSessionChunk::createDoneChunk( $session, $chunk->success ?? false, @@ -227,9 +232,11 @@ private function serializeEvent(AgentEventDto $event): string } if ($event->toolInputs !== null) { + /** @var list $toolInputs */ + $toolInputs = $event->toolInputs; $data['toolInputs'] = array_map( static fn (ToolInputEntryDto $t) => ['key' => $t->key, 'value' => $t->value], - $event->toolInputs + $toolInputs ); } diff --git a/src/ChatBasedContentEditor/Presentation/Controller/ChatBasedContentEditorController.php b/src/ChatBasedContentEditor/Presentation/Controller/ChatBasedContentEditorController.php index 47a6438..8523d2c 100644 --- a/src/ChatBasedContentEditor/Presentation/Controller/ChatBasedContentEditorController.php +++ b/src/ChatBasedContentEditor/Presentation/Controller/ChatBasedContentEditorController.php @@ -6,6 +6,9 @@ use App\Account\Facade\AccountFacadeInterface; use App\Account\Facade\Dto\AccountInfoDto; +use App\AgenticContentEditor\Facade\AgenticContentEditorFacadeInterface; +use App\AgenticContentEditor\Facade\Dto\AgentConfigDto; +use App\AgenticContentEditor\Facade\Dto\ConversationMessageDto; use App\ChatBasedContentEditor\Domain\Entity\Conversation; use App\ChatBasedContentEditor\Domain\Entity\EditSession; use App\ChatBasedContentEditor\Domain\Entity\EditSessionChunk; @@ -16,9 +19,6 @@ use App\ChatBasedContentEditor\Infrastructure\Message\RunEditSessionMessage; use App\ChatBasedContentEditor\Presentation\Service\ConversationContextUsageService; use App\ChatBasedContentEditor\Presentation\Service\PromptSuggestionsService; -use App\LlmContentEditor\Facade\Dto\AgentConfigDto; -use App\LlmContentEditor\Facade\Dto\ConversationMessageDto; -use App\LlmContentEditor\Facade\LlmContentEditorFacadeInterface; use App\ProjectMgmt\Facade\ProjectMgmtFacadeInterface; use App\RemoteContentAssets\Facade\RemoteContentAssetsFacadeInterface; use App\WorkspaceMgmt\Facade\Enum\WorkspaceStatus; @@ -49,17 +49,17 @@ final class ChatBasedContentEditorController extends AbstractController { public function __construct( - private readonly ConversationService $conversationService, - private readonly WorkspaceMgmtFacadeInterface $workspaceMgmtFacade, - private readonly ProjectMgmtFacadeInterface $projectMgmtFacade, - private readonly AccountFacadeInterface $accountFacade, - private readonly EntityManagerInterface $entityManager, - private readonly MessageBusInterface $messageBus, - private readonly DistFileScannerInterface $distFileScanner, - private readonly ConversationContextUsageService $contextUsageService, - private readonly TranslatorInterface $translator, - private readonly PromptSuggestionsService $promptSuggestionsService, - private readonly LlmContentEditorFacadeInterface $llmContentEditorFacade, + private readonly ConversationService $conversationService, + private readonly WorkspaceMgmtFacadeInterface $workspaceMgmtFacade, + private readonly ProjectMgmtFacadeInterface $projectMgmtFacade, + private readonly AccountFacadeInterface $accountFacade, + private readonly EntityManagerInterface $entityManager, + private readonly MessageBusInterface $messageBus, + private readonly DistFileScannerInterface $distFileScanner, + private readonly ConversationContextUsageService $contextUsageService, + private readonly TranslatorInterface $translator, + private readonly PromptSuggestionsService $promptSuggestionsService, + private readonly AgenticContentEditorFacadeInterface $agenticContentEditorFacade, ) { } @@ -398,7 +398,7 @@ public function contextUsage(string $conversationId, Request $request): Response /** * Dump the full agent context (system prompt + conversation history + last instruction) - * as it would be sent to the LLM API. Returns plain text for troubleshooting. + * as it would be sent to the backend agent. Returns plain text for troubleshooting. */ #[Route( path: '/conversation/{conversationId}/dump-agent-context', @@ -458,7 +458,8 @@ public function dumpAgentContext( $lastInstruction = $lastSession->getInstruction(); } - $dump = $this->llmContentEditorFacade->buildAgentContextDump( + $dump = $this->agenticContentEditorFacade->buildAgentContextDump( + $conversation->getContentEditorBackend(), $lastInstruction, $previousMessages, $agentConfig diff --git a/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/chat_based_content_editor_controller.ts b/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/chat_based_content_editor_controller.ts index 4c5dcd3..7724b94 100644 --- a/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/chat_based_content_editor_controller.ts +++ b/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/chat_based_content_editor_controller.ts @@ -1050,6 +1050,10 @@ export default class extends Controller { wrap.textContent = `✖ ${e.errorMessage ?? tr.unknownError}`; wrap.classList.add("text-red-600/70", "dark:text-red-400/70"); break; + case "tool_error": + wrap.textContent = `✖ ${e.toolName ?? "Tool"} failed: ${e.errorMessage ?? tr.unknownError}`; + wrap.classList.add("text-red-600/70", "dark:text-red-400/70"); + break; default: wrap.textContent = `[${e.kind}]`; wrap.classList.add("text-dark-400", "dark:text-dark-500"); @@ -1135,10 +1139,13 @@ export default class extends Controller { } private updateActivityIndicators(container: HTMLElement, event: AgentEvent): void { - // Only react to tool_calling events - Working badge tracks tool calls + // Working badge tracks tool calls (including run_build) if (event.kind === "tool_calling") { this.onToolCall(container); } + if (event.kind === "tool_called" || event.kind === "tool_error") { + this.completeActivityIndicators(container); + } } private completeActivityIndicators(container: HTMLElement): void { diff --git a/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/chat_editor_helpers.ts b/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/chat_editor_helpers.ts index d17eeec..bdf2445 100644 --- a/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/chat_editor_helpers.ts +++ b/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/chat_editor_helpers.ts @@ -214,7 +214,7 @@ export interface ProgressAnimationState { */ export function getProgressAnimationState(eventKind: string): ProgressAnimationState { const intensifyEvents = ["tool_calling", "inference_start"]; - const normalizeEvents = ["tool_called", "inference_stop"]; + const normalizeEvents = ["tool_called", "inference_stop", "tool_error"]; return { intensify: intensifyEvents.includes(eventKind), @@ -227,6 +227,6 @@ export function getProgressAnimationState(eventKind: string): ProgressAnimationS * Note: agent_error events are intentionally not surfaced to avoid alarming users. */ export function shouldShowEventFeedback(eventKind: string): boolean { - const feedbackEvents = ["tool_calling", "inference_start", "tool_called", "inference_stop"]; + const feedbackEvents = ["tool_calling", "inference_start", "tool_called", "inference_stop", "tool_error"]; return feedbackEvents.includes(eventKind); } diff --git a/src/ChatBasedContentEditor/Presentation/Service/ConversationContextUsageService.php b/src/ChatBasedContentEditor/Presentation/Service/ConversationContextUsageService.php index da2e86f..e838f19 100644 --- a/src/ChatBasedContentEditor/Presentation/Service/ConversationContextUsageService.php +++ b/src/ChatBasedContentEditor/Presentation/Service/ConversationContextUsageService.php @@ -4,22 +4,23 @@ namespace App\ChatBasedContentEditor\Presentation\Service; +use App\AgenticContentEditor\Facade\AgenticContentEditorFacadeInterface; use App\ChatBasedContentEditor\Domain\Entity\Conversation; use App\ChatBasedContentEditor\Domain\Entity\EditSession; use App\ChatBasedContentEditor\Domain\Enum\EditSessionStatus; use App\ChatBasedContentEditor\Presentation\Dto\ContextUsageDto; -use App\LlmContentEditor\Domain\Enum\LlmModelName; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\DependencyInjection\Attribute\Autowire; final readonly class ConversationContextUsageService { public function __construct( - private EntityManagerInterface $entityManager, + private EntityManagerInterface $entityManager, + private AgenticContentEditorFacadeInterface $agenticContentEditorFacade, #[Autowire(param: 'chat_based_content_editor.bytes_per_token_estimate')] - private int $bytesPerTokenEstimate, + private int $bytesPerTokenEstimate, #[Autowire(param: 'chat_based_content_editor.system_prompt_bytes_estimate')] - private int $systemPromptBytesEstimate, + private int $systemPromptBytesEstimate, ) { } @@ -69,19 +70,20 @@ public function getContextUsage(Conversation $conversation, ?string $activeSessi $inputTokensCumulative = (int) round($inputBytesCumulative / $this->bytesPerTokenEstimate); $outputTokensCumulative = (int) round($outputBytesCumulative / $this->bytesPerTokenEstimate); - $model = LlmModelName::defaultForContentEditor(); - $maxTokens = $model->maxContextTokens(); + $modelInfo = $this->agenticContentEditorFacade->getBackendModelInfo( + $conversation->getContentEditorBackend() + ); - $inputCostPer1M = $model->inputCostPer1M(); - $outputCostPer1M = $model->outputCostPer1M(); + $inputCostPer1M = $modelInfo->inputCostPer1M ?? 0.0; + $outputCostPer1M = $modelInfo->outputCostPer1M ?? 0.0; $inputCost = ($inputTokensCumulative / 1_000_000) * $inputCostPer1M; $outputCost = ($outputTokensCumulative / 1_000_000) * $outputCostPer1M; $totalCost = $inputCost + $outputCost; return new ContextUsageDto( $usedTokens, - $maxTokens, - $model->value, + $modelInfo->maxContextTokens, + $modelInfo->modelName, $inputTokensCumulative, $outputTokensCumulative, $inputCost, diff --git a/src/CursorAgentContentEditor/Domain/Agent/ContentEditorAgent.php b/src/CursorAgentContentEditor/Domain/Agent/ContentEditorAgent.php new file mode 100644 index 0000000..9797c6c --- /dev/null +++ b/src/CursorAgentContentEditor/Domain/Agent/ContentEditorAgent.php @@ -0,0 +1,69 @@ +buildCommand($prompt, $apiKey, $resumeSessionId); + + return $this->workspaceTooling->runShellCommand($workingDirectory, $command); + } + + /** + * Start the agent asynchronously for streaming output. + * + * Returns a StreamingProcessInterface that can be polled for completion. + * Output is streamed to any configured callback as it arrives. + */ + public function startAsync( + string $workingDirectory, + string $prompt, + string $apiKey, + ?string $resumeSessionId = null + ): StreamingProcessInterface { + $command = $this->buildCommand($prompt, $apiKey, $resumeSessionId); + + return $this->workspaceTooling->runShellCommandAsync($workingDirectory, $command); + } + + private function buildCommand(string $prompt, string $apiKey, ?string $resumeSessionId): string + { + $sessionArg = ''; + if ($resumeSessionId !== null && $resumeSessionId !== '') { + $sessionArg = '--resume ' . escapeshellarg($resumeSessionId); + } + + // Create a script that sets up PATH with mise node installation, then set BASH_ENV to source it. + // The Cursor CLI spawns fresh bash processes for shellToolCall that don't inherit environment + // variables from the parent. BASH_ENV tells non-interactive bash to source this file first. + return sprintf( + 'echo \'export PATH="/opt/mise/data/installs/node/24.13.0/bin:$PATH"\' > /etc/profile.d/mise-path.sh && ' . + 'export BASH_ENV=/etc/profile.d/mise-path.sh && ' . + 'AGENT_BIN=%s; ' . + 'if [ ! -x "$AGENT_BIN" ]; then echo "agent not found" >&2; exit 127; fi; ' . + '"$AGENT_BIN" --output-format stream-json --stream-partial-output --force %s --api-key %s -p %s', + escapeshellarg('/root/.local/bin/agent'), + $sessionArg, + escapeshellarg($apiKey), + escapeshellarg($prompt) + ); + } +} diff --git a/src/CursorAgentContentEditor/Domain/Command/EditContentCommand.php b/src/CursorAgentContentEditor/Domain/Command/EditContentCommand.php new file mode 100644 index 0000000..470eb3d --- /dev/null +++ b/src/CursorAgentContentEditor/Domain/Command/EditContentCommand.php @@ -0,0 +1,130 @@ +addArgument( + 'folder', + InputArgument::REQUIRED, + 'The path to the folder containing files to edit.' + ) + ->addArgument( + 'instruction', + InputArgument::REQUIRED, + 'Natural language instruction describing what to edit.' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + /** @var string $folder */ + $folder = $input->getArgument('folder'); + /** @var string $instruction */ + $instruction = $input->getArgument('instruction'); + + if (!is_dir($folder)) { + throw new RuntimeException("Directory does not exist: {$folder}"); + } + + $resolvedFolder = realpath($folder); + if ($resolvedFolder === false) { + throw new RuntimeException('Could not resolve folder path.'); + } + + $output->writeln("Working folder: {$resolvedFolder}"); + $output->writeln("Instruction: {$instruction}"); + $output->writeln(''); + + $workspaceId = hash('sha256', $resolvedFolder); + $projectName = basename($resolvedFolder); + $agentImage = $_ENV['CURSOR_AGENT_IMAGE'] ?? null; + if (!is_string($agentImage) || $agentImage === '') { + $agentImage = 'node:22-slim'; + } + $workingDir = '/workspace'; + + $this->executionContext->setContext( + $workspaceId, + $resolvedFolder, + null, + $projectName, + $agentImage + ); + + $observer = new ConsoleObserver($output); + $shouldStream = !$output->isQuiet(); + if ($shouldStream) { + $this->executionContext->setOutputCallback($observer); + } + + try { + $apiKey = $_ENV['CURSOR_AGENT_API_KEY'] ?? ''; + if (!is_string($apiKey) || $apiKey === '') { + throw new RuntimeException('CURSOR_AGENT_API_KEY is not configured.'); + } + + $agent = new ContentEditorAgent($this->fileEditingFacade); + $output->writeln('Running cursor agent CLI...'); + $output->writeln(''); + $output->writeln('Agent response:'); + + $result = $agent->run($workingDir, $instruction, $apiKey); + } finally { + $this->executionContext->clearContext(); + } + + if (!$shouldStream) { + $output->writeln('Agent response:'); + $output->writeln($result); + } else { + $output->writeln(''); + } + + return self::SUCCESS; + } +} diff --git a/src/CursorAgentContentEditor/Infrastructure/CursorAgentContentEditorAdapter.php b/src/CursorAgentContentEditor/Infrastructure/CursorAgentContentEditorAdapter.php new file mode 100644 index 0000000..81744db --- /dev/null +++ b/src/CursorAgentContentEditor/Infrastructure/CursorAgentContentEditorAdapter.php @@ -0,0 +1,265 @@ + $previousMessages + * + * @return Generator + */ + public function streamEdit( + string $workspacePath, + string $instruction, + array $previousMessages, + string $apiKey, + AgentConfigDto $agentConfig, + ?string $backendSessionState = null, + string $locale = 'en', + ): Generator { + $collector = new CursorAgentStreamCollector(); + $this->executionContext->setOutputCallback($collector); + + try { + $prompt = $this->buildPrompt( + $instruction, + $previousMessages, + $backendSessionState === null, + $agentConfig + ); + + yield new EditStreamChunkDto(EditStreamChunkType::Event, null, new AgentEventDto('inference_start')); + + $agent = new ContentEditorAgent($this->workspaceTooling); + $process = $agent->startAsync('/workspace', $prompt, $apiKey, $backendSessionState); + + // Poll for chunks while the process is running + while ($process->isRunning()) { + foreach ($collector->drain() as $chunk) { + yield $chunk; + } + + usleep(self::POLL_INTERVAL_US); + } + + // Check for Docker-level errors + $process->checkResult(); + + // Drain any remaining chunks after process completes + foreach ($collector->drain() as $chunk) { + yield $chunk; + } + + $lastSessionId = $collector->getLastSessionId(); + + // Always run the build after the agent completes. The Cursor CLI cannot run shell + // commands in headless mode, so we run the build ourselves regardless of agent success. + yield new EditStreamChunkDto(EditStreamChunkType::Event, null, new AgentEventDto('tool_calling', 'run_build')); + $agentImage = $this->executionContext->getAgentImage() ?? 'node:22-slim'; + + try { + $buildOutput = $this->workspaceTooling->runBuildInWorkspace($workspacePath, $agentImage); + yield new EditStreamChunkDto(EditStreamChunkType::Event, null, new AgentEventDto('tool_called', 'run_build', null, $buildOutput)); + } catch (RuntimeException $e) { + yield new EditStreamChunkDto(EditStreamChunkType::Event, null, new AgentEventDto('tool_error', 'run_build', null, null, $e->getMessage())); + } + + yield new EditStreamChunkDto(EditStreamChunkType::Event, null, new AgentEventDto('inference_stop')); + + yield new EditStreamChunkDto( + EditStreamChunkType::Done, + null, + null, + $collector->isSuccess(), + $collector->getErrorMessage(), + null, + $lastSessionId + ); + } catch (Throwable $e) { + yield new EditStreamChunkDto(EditStreamChunkType::Event, null, new AgentEventDto('inference_stop')); + yield new EditStreamChunkDto(EditStreamChunkType::Done, null, null, false, $e->getMessage()); + } finally { + $this->executionContext->setOutputCallback(null); + } + } + + public function getBackendModelInfo(): BackendModelInfoDto + { + // The Cursor agent manages its own model and context internally. + // We report the effective prompt limit for context-bar purposes. + // Cost rates are null because Cursor pricing is opaque (not per-token BYOK). + return new BackendModelInfoDto( + 'cursor-agent', + 200_000, + ); + } + + /** + * @param list $previousMessages + */ + public function buildAgentContextDump( + string $instruction, + array $previousMessages, + AgentConfigDto $agentConfig + ): string { + $isFirstMessage = $previousMessages === []; + + $lines = []; + $lines[] = '=== CURSOR AGENT CONTEXT ==='; + $lines[] = ''; + + if ($isFirstMessage) { + $lines[] = '--- SYSTEM CONTEXT ---'; + $lines[] = $this->buildSystemContext($agentConfig); + $lines[] = ''; + } + + if ($previousMessages !== []) { + $lines[] = '--- CONVERSATION HISTORY ---'; + $lines[] = $this->formatHistory($previousMessages); + $lines[] = ''; + } + + $lines[] = '--- CURRENT PROMPT ---'; + $lines[] = $this->buildPrompt($instruction, $previousMessages, $isFirstMessage, $agentConfig); + + return implode("\n", $lines); + } + + /** + * @param list $previousMessages + */ + private function buildPrompt( + string $instruction, + array $previousMessages, + bool $isFirstMessage, + AgentConfigDto $agentConfig + ): string { + $parts = []; + + if ($isFirstMessage) { + $systemContext = $this->buildSystemContext($agentConfig); + if ($systemContext !== '') { + $parts[] = $systemContext; + } + } + + if ($previousMessages !== []) { + $parts[] = $this->formatHistory($previousMessages); + $parts[] = 'User: ' . $instruction; + } else { + $parts[] = $this->wrapInstruction($instruction, $isFirstMessage); + } + + return implode("\n\n", $parts); + } + + private function buildSystemContext(AgentConfigDto $agentConfig): string + { + $sections = []; + + $sections[] = 'The working folder is: /workspace'; + + if (trim($agentConfig->backgroundInstructions) !== '') { + $sections[] = "## Background Instructions\n" . $agentConfig->backgroundInstructions; + } + + if (trim($agentConfig->stepInstructions) !== '') { + $sections[] = "## Step-by-Step Instructions\n" . $agentConfig->stepInstructions; + } + + if (trim($agentConfig->outputInstructions) !== '') { + $sections[] = "## Output Instructions\n" . $agentConfig->outputInstructions; + } + + $workspaceRules = $this->getWorkspaceRulesForPrompt(); + if ($workspaceRules !== '') { + $sections[] = "## Workspace Rules\n" . $workspaceRules; + } + + $sections[] = "## Important: Keep Source and Dist in Sync\n" . + "After making changes to source files in /workspace/src/, you MUST run 'npm run build' " . + 'to compile the changes to the /workspace/dist/ folder. The dist folder is what gets ' . + 'served to users, so always keep it in sync with your source changes.'; + + return implode("\n\n", $sections); + } + + private function getWorkspaceRulesForPrompt(): string + { + $rulesJson = $this->workspaceTooling->getWorkspaceRules(); + $rules = json_decode($rulesJson, true); + + if (!is_array($rules) || $rules === []) { + return ''; + } + + $formatted = []; + foreach ($rules as $ruleName => $ruleContent) { + if (!is_string($ruleName) || !is_string($ruleContent)) { + continue; + } + $formatted[] = "### {$ruleName}\n{$ruleContent}"; + } + + return implode("\n\n", $formatted); + } + + private function wrapInstruction(string $instruction, bool $includeWorkspaceContext): string + { + if ($includeWorkspaceContext) { + return 'Please perform the following task: ' . $instruction; + } + + return $instruction; + } + + /** + * @param list $previousMessages + */ + private function formatHistory(array $previousMessages): string + { + $lines = ['Conversation so far:']; + + foreach ($previousMessages as $message) { + $lines[] = sprintf('%s: %s', $message->role, $message->contentJson); + } + + return implode("\n", $lines); + } +} diff --git a/src/CursorAgentContentEditor/Infrastructure/Observer/ConsoleObserver.php b/src/CursorAgentContentEditor/Infrastructure/Observer/ConsoleObserver.php new file mode 100644 index 0000000..98a08ae --- /dev/null +++ b/src/CursorAgentContentEditor/Infrastructure/Observer/ConsoleObserver.php @@ -0,0 +1,377 @@ +}> + */ + private array $toolCalls = []; + + public function __invoke(string $buffer, bool $isError): void + { + if ($this->output->isQuiet()) { + return; + } + + $this->buffer .= $buffer; + + while (true) { + $newlinePosition = strpos($this->buffer, "\n"); + if ($newlinePosition === false) { + return; + } + + $line = trim(substr($this->buffer, 0, $newlinePosition)); + $this->buffer = substr($this->buffer, $newlinePosition + 1); + + if ($line === '') { + continue; + } + + try { + $decoded = json_decode($line, true, 512, JSON_THROW_ON_ERROR); + $normalized = $this->normalizeEvent($decoded); + if ($normalized === null) { + $this->output->writeln($line); + continue; + } + + $this->handleEvent($normalized); + } catch (JsonException) { + $this->output->writeln($line); + } + } + } + + public function update(SplSubject $subject): void + { + if (!$this->output->isVerbose()) { + return; + } + + $this->output->writeln(sprintf( + 'Agent event received from %s', + $subject::class + )); + } + + /** + * @param array $event + */ + private function handleEvent(array $event): void + { + $type = $event['type'] ?? null; + $subtype = $event['subtype'] ?? null; + + if (!is_string($type)) { + return; + } + + if (!is_string($subtype)) { + $subtype = null; + } + + if ($type !== 'thinking') { + $this->flushThinking(); + } + + if ($type === 'thinking') { + $this->handleThinking($event, $subtype); + + return; + } + + if ($type === 'tool_call') { + $this->handleToolCall($event, $subtype); + + return; + } + + if ($type === 'assistant') { + $this->handleAssistant($event); + + return; + } + + if ($type === 'result') { + $this->handleResult($event, $subtype); + } + } + + /** + * @param array $event + */ + private function handleThinking(array $event, ?string $subtype): void + { + if ($subtype === 'delta') { + $text = $event['text'] ?? ''; + if (is_string($text)) { + $this->thinkingBuffer .= $text; + } + + return; + } + + if ($subtype === 'completed') { + $this->flushThinking(); + } + } + + /** + * @param array $event + */ + private function handleToolCall(array $event, ?string $subtype): void + { + $callId = $event['call_id'] ?? null; + $toolCall = $event['tool_call'] ?? null; + if (!is_array($toolCall)) { + return; + } + + $toolName = array_key_first($toolCall); + if (!is_string($toolName)) { + return; + } + + $toolPayload = $toolCall[$toolName] ?? []; + if (!is_array($toolPayload)) { + $toolPayload = []; + } + + if ($subtype === 'started' && is_string($callId)) { + $args = $this->normalizeArgs($toolPayload['args'] ?? null); + $this->toolCalls[$callId] = [ + 'name' => $toolName, + 'args' => $args, + ]; + + $this->output->writeln(sprintf('▶ Calling tool: %s', $toolName)); + + foreach ($this->toolCalls[$callId]['args'] as $key => $value) { + $this->output->writeln(sprintf(' %s: %s', (string) $key, $this->formatValue($value))); + } + + return; + } + + if ($subtype === 'completed') { + $args = $this->normalizeArgs($toolPayload['args'] ?? null); + if (is_string($callId) && !array_key_exists($callId, $this->toolCalls)) { + $this->toolCalls[$callId] = [ + 'name' => $toolName, + 'args' => $args, + ]; + } + + $result = $toolPayload['result'] ?? null; + $summary = $this->formatToolResult($result); + $this->output->writeln(sprintf('◀ Tool result: %s', $summary)); + + return; + } + } + + /** + * @param array $event + */ + private function handleAssistant(array $event): void + { + $message = $event['message'] ?? null; + if (!is_array($message)) { + return; + } + + $content = $message['content'] ?? null; + if (!is_array($content)) { + return; + } + + foreach ($content as $item) { + if (!is_array($item)) { + continue; + } + + if (($item['type'] ?? null) === 'text' && is_string($item['text'] ?? null)) { + $this->assistantBuffer .= $item['text']; + } + } + } + + /** + * @param array $event + */ + private function handleResult(array $event, ?string $subtype): void + { + if ($subtype !== 'success') { + return; + } + + $result = $event['result'] ?? null; + if (!is_string($result) || $result === '') { + $result = $this->assistantBuffer; + } + + if ($result !== '') { + $this->output->writeln($result); + } + + $this->assistantBuffer = ''; + } + + private function flushThinking(): void + { + $text = trim($this->thinkingBuffer); + if ($text === '') { + $this->thinkingBuffer = ''; + + return; + } + + $this->output->writeln(sprintf('▶ Thinking: %s', $text)); + $this->thinkingBuffer = ''; + } + + private function formatToolResult(mixed $result): string + { + if (!is_array($result)) { + return $this->formatValue($result); + } + + $success = $result['success'] ?? null; + if (is_array($success)) { + $root = $success['directoryTreeRoot'] ?? null; + if (is_array($root)) { + $files = $this->collectNames($root['childrenFiles'] ?? null); + $dirs = $this->collectNames($root['childrenDirs'] ?? null); + + if (count($dirs) === 0 && count($files) === 1) { + return $files[0]; + } + + $parts = []; + if (count($dirs) > 0) { + $parts[] = 'dirs: ' . implode(', ', $dirs); + } + if (count($files) > 0) { + $parts[] = 'files: ' . implode(', ', $files); + } + + if (count($parts) > 0) { + return implode('; ', $parts); + } + } + } + + return $this->formatValue($result); + } + + /** + * @param array|mixed $items + * + * @return list + */ + private function collectNames(mixed $items): array + { + if (!is_array($items)) { + return []; + } + + $names = []; + + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + + $name = $item['name'] ?? null; + if (is_string($name) && $name !== '') { + $names[] = $name; + } + } + + return $names; + } + + private function formatValue(mixed $value): string + { + if (is_string($value)) { + return $this->truncate($value); + } + + if (is_scalar($value)) { + return $this->truncate((string) $value); + } + + $encoded = json_encode($value, JSON_UNESCAPED_SLASHES); + if ($encoded === false) { + return '[unserializable]'; + } + + return $this->truncate($encoded); + } + + private function truncate(string $value, int $maxLength = 200): string + { + if (mb_strlen($value) <= $maxLength) { + return $value; + } + + return mb_substr($value, 0, $maxLength) . '...'; + } + + /** + * @return array|null + */ + private function normalizeEvent(mixed $decoded): ?array + { + if (!is_array($decoded)) { + return null; + } + + $normalized = []; + foreach ($decoded as $key => $value) { + if (!is_string($key)) { + continue; + } + + $normalized[$key] = $value; + } + + return $normalized; + } + + /** + * @return array + */ + private function normalizeArgs(mixed $args): array + { + if (!is_array($args)) { + return []; + } + + $normalized = []; + foreach ($args as $key => $value) { + $normalized[(string) $key] = $value; + } + + return $normalized; + } +} diff --git a/src/CursorAgentContentEditor/Infrastructure/Streaming/CursorAgentStreamCollector.php b/src/CursorAgentContentEditor/Infrastructure/Streaming/CursorAgentStreamCollector.php new file mode 100644 index 0000000..5e41f7e --- /dev/null +++ b/src/CursorAgentContentEditor/Infrastructure/Streaming/CursorAgentStreamCollector.php @@ -0,0 +1,445 @@ + + */ + private SplQueue $chunks; + + private bool $thinkingStarted = false; + + private bool $resultSuccess = true; + + private ?string $resultErrorMessage = null; + + private ?string $lastSessionId = null; + + /** + * Accumulates streaming parts with spaces between them. + */ + private string $accumulatedParts = ''; + + /** + * Whether we've emitted any complete messages yet. + */ + private bool $hasEmittedText = false; + + public function __construct() + { + /** @var SplQueue $queue */ + $queue = new SplQueue(); + $this->chunks = $queue; + } + + public function __invoke(string $buffer, bool $isError): void + { + $this->buffer .= $buffer; + + while (true) { + $newlinePosition = strpos($this->buffer, "\n"); + if ($newlinePosition === false) { + return; + } + + $line = trim(substr($this->buffer, 0, $newlinePosition)); + $this->buffer = substr($this->buffer, $newlinePosition + 1); + + if ($line === '') { + continue; + } + + try { + $decoded = json_decode($line, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException) { + continue; + } + + $normalized = $this->normalizeEvent($decoded); + if ($normalized === null) { + continue; + } + + $this->captureSessionId($normalized); + $this->handleEvent($normalized); + } + } + + /** + * @return list + */ + public function drain(): array + { + $drained = []; + + while (!$this->chunks->isEmpty()) { + $drained[] = $this->chunks->dequeue(); + } + + return $drained; + } + + public function getLastSessionId(): ?string + { + return $this->lastSessionId; + } + + public function isSuccess(): bool + { + return $this->resultSuccess; + } + + public function getErrorMessage(): ?string + { + return $this->resultErrorMessage; + } + + /** + * @param array $event + */ + private function handleEvent(array $event): void + { + $type = $event['type'] ?? null; + $subtype = $event['subtype'] ?? null; + + if (!is_string($type)) { + return; + } + + if (!is_string($subtype)) { + $subtype = null; + } + + if ($type === 'thinking') { + $this->handleThinking($subtype); + + return; + } + + if ($this->thinkingStarted) { + $this->flushThinking(); + } + + if ($type === 'tool_call') { + $this->handleToolCall($event, $subtype); + + return; + } + + if ($type === 'assistant') { + $this->handleAssistant($event); + + return; + } + + if ($type === 'result') { + $this->handleResult($event, $subtype); + } + } + + private function handleThinking(?string $subtype): void + { + if ($subtype === 'delta') { + if (!$this->thinkingStarted) { + $this->thinkingStarted = true; + } + + return; + } + + if ($subtype === 'completed') { + $this->flushThinking(); + } + } + + private function flushThinking(): void + { + if (!$this->thinkingStarted) { + return; + } + + $this->thinkingStarted = false; + } + + /** + * @param array $event + */ + private function handleToolCall(array $event, ?string $subtype): void + { + $toolCall = $event['tool_call'] ?? null; + if (!is_array($toolCall)) { + return; + } + + $toolName = array_key_first($toolCall); + if (!is_string($toolName)) { + return; + } + + $toolPayload = $toolCall[$toolName] ?? []; + if (!is_array($toolPayload)) { + $toolPayload = []; + } + + $toolInputs = $this->buildToolInputs($toolPayload['args'] ?? null); + + if ($subtype === 'started') { + $this->enqueueEvent(new AgentEventDto('tool_calling', $toolName, $toolInputs)); + + return; + } + + if ($subtype === 'completed') { + $toolResult = $this->formatValue($toolPayload['result'] ?? null); + $this->enqueueEvent(new AgentEventDto('tool_called', $toolName, $toolInputs, $toolResult)); + } + } + + /** + * Handle assistant text messages. + * + * The Cursor agent streams messages as follows: + * 1. Individual word/phrase parts arrive WITH their natural spacing (leading/trailing spaces) + * 2. At the end, the COMPLETE message arrives with the full properly-formatted text + * + * Strategy: + * - Accumulate incoming parts by direct concatenation (preserving their natural spacing) + * - When a new part matches the accumulated string, it's the complete message + * - Emit the complete message and reset for the next paragraph + * + * @param array $event + */ + private function handleAssistant(array $event): void + { + $message = $event['message'] ?? null; + if (!is_array($message)) { + return; + } + + $content = $message['content'] ?? null; + if (!is_array($content)) { + return; + } + + foreach ($content as $item) { + if (!is_array($item)) { + continue; + } + + if (($item['type'] ?? null) === 'text' && is_string($item['text'] ?? null)) { + // Preserve original spacing - don't trim! + $incomingText = $item['text']; + $trimmedIncoming = trim($incomingText); + $trimmedAccumulated = trim($this->accumulatedParts); + + if ($trimmedIncoming === '') { + continue; + } + + // Check if this incoming text matches our accumulated parts (complete message arrived) + if ($trimmedAccumulated !== '' && $this->isCompleteMessage($trimmedIncoming, $trimmedAccumulated)) { + // Add paragraph break if we already have content + if ($this->hasEmittedText) { + $this->chunks->enqueue(new EditStreamChunkDto(EditStreamChunkType::Text, "\n\n")); + } + + // Emit the complete message (use the trimmed incoming text as it has proper spacing) + $this->chunks->enqueue(new EditStreamChunkDto(EditStreamChunkType::Text, $trimmedIncoming)); + $this->hasEmittedText = true; + $this->accumulatedParts = ''; + + continue; + } + + // This is a streaming part - accumulate by direct concatenation (preserves natural spacing) + $this->accumulatedParts .= $incomingText; + } + } + } + + /** + * Check if the incoming text represents the complete version of accumulated parts. + * + * The complete message has proper spacing between words, while our accumulated + * parts are joined with single spaces. We compare them with normalized whitespace. + */ + private function isCompleteMessage(string $incoming, string $accumulated): bool + { + // Normalize whitespace for comparison + $normalizedIncoming = preg_replace('/\s+/', ' ', $incoming) ?? $incoming; + $normalizedAccumulated = preg_replace('/\s+/', ' ', $accumulated) ?? $accumulated; + + // Exact match after normalization + if ($normalizedIncoming === $normalizedAccumulated) { + return true; + } + + // Check similarity (allow for minor differences like punctuation spacing) + $lenIncoming = mb_strlen($normalizedIncoming); + $lenAccumulated = mb_strlen($normalizedAccumulated); + + // Must be similar length (within 10%) + if ($lenIncoming === 0 || $lenAccumulated === 0) { + return false; + } + + $lengthRatio = min($lenIncoming, $lenAccumulated) / max($lenIncoming, $lenAccumulated); + if ($lengthRatio < 0.9) { + return false; + } + + // For reasonably sized strings, use levenshtein + if ($lenIncoming <= 255 && $lenAccumulated <= 255) { + $distance = levenshtein($normalizedIncoming, $normalizedAccumulated); + $similarity = 1 - ($distance / max($lenIncoming, $lenAccumulated)); + + return $similarity > 0.9; + } + + // For longer strings, check prefix similarity + $minLen = min($lenIncoming, $lenAccumulated); + $commonLen = 0; + for ($i = 0; $i < $minLen; ++$i) { + if ($normalizedIncoming[$i] !== $normalizedAccumulated[$i]) { + break; + } + ++$commonLen; + } + + return $commonLen / $minLen > 0.9; + } + + /** + * @param array $event + */ + private function handleResult(array $event, ?string $subtype): void + { + if ($subtype === 'success') { + $this->resultSuccess = true; + $this->resultErrorMessage = null; + + // Only emit result text if NO text was streamed during the session. + // The result is a complete summary that duplicates the streamed content. + $result = $event['result'] ?? null; + if (!$this->hasEmittedText && is_string($result) && $result !== '') { + $this->chunks->enqueue(new EditStreamChunkDto(EditStreamChunkType::Text, $result)); + } + + return; + } + + $this->resultSuccess = false; + $errorMessage = $event['error'] ?? $event['message'] ?? null; + if (!is_string($errorMessage) || $errorMessage === '') { + $errorMessage = 'Cursor agent failed.'; + } + + $this->resultErrorMessage = $errorMessage; + $this->enqueueEvent(new AgentEventDto('agent_error', null, null, null, $errorMessage)); + } + + /** + * @param array $event + */ + private function captureSessionId(array $event): void + { + $sessionId = $event['session_id'] ?? $event['sessionId'] ?? null; + if (is_string($sessionId) && $sessionId !== '') { + $this->lastSessionId = $sessionId; + } + + $session = $event['session'] ?? null; + if (is_array($session)) { + $nestedSessionId = $session['id'] ?? $session['session_id'] ?? null; + if (is_string($nestedSessionId) && $nestedSessionId !== '') { + $this->lastSessionId = $nestedSessionId; + } + } + } + + private function enqueueEvent(AgentEventDto $event): void + { + $this->chunks->enqueue(new EditStreamChunkDto(EditStreamChunkType::Event, null, $event)); + } + + /** + * @return list|null + */ + private function buildToolInputs(mixed $args): ?array + { + if (!is_array($args)) { + return null; + } + + $inputs = []; + foreach ($args as $key => $value) { + $inputs[] = new ToolInputEntryDto((string) $key, $this->formatValue($value)); + } + + return $inputs === [] ? null : $inputs; + } + + private function formatValue(mixed $value): string + { + if (is_string($value)) { + return $this->truncate($value); + } + + if (is_scalar($value)) { + return $this->truncate((string) $value); + } + + $encoded = json_encode($value, JSON_UNESCAPED_SLASHES); + if ($encoded === false) { + return '[unserializable]'; + } + + return $this->truncate($encoded); + } + + private function truncate(string $value, int $maxLength = 500): string + { + if (mb_strlen($value) <= $maxLength) { + return $value; + } + + return mb_substr($value, 0, $maxLength) . '...'; + } + + /** + * @return array|null + */ + private function normalizeEvent(mixed $decoded): ?array + { + if (!is_array($decoded)) { + return null; + } + + $normalized = []; + foreach ($decoded as $key => $value) { + if (!is_string($key)) { + continue; + } + + $normalized[$key] = $value; + } + + return $normalized; + } +} diff --git a/src/LlmContentEditor/Domain/Agent/ContentEditorAgent.php b/src/LlmContentEditor/Domain/Agent/ContentEditorAgent.php index 97b59f5..04622fc 100644 --- a/src/LlmContentEditor/Domain/Agent/ContentEditorAgent.php +++ b/src/LlmContentEditor/Domain/Agent/ContentEditorAgent.php @@ -4,9 +4,9 @@ namespace App\LlmContentEditor\Domain\Agent; +use App\AgenticContentEditor\Facade\Dto\AgentConfigDto; use App\LlmContentEditor\Domain\Enum\LlmModelName; use App\LlmContentEditor\Domain\TurnActivityProviderInterface; -use App\LlmContentEditor\Facade\Dto\AgentConfigDto; use App\LlmContentEditor\Infrastructure\WireLog\LlmWireLogMiddleware; use App\WorkspaceTooling\Facade\WorkspaceToolingServiceInterface; use EtfsCodingAgent\Agent\BaseCodingAgent; diff --git a/src/LlmContentEditor/Domain/Command/EditContentCommand.php b/src/LlmContentEditor/Domain/Command/EditContentCommand.php index 6ac521f..4e1954c 100644 --- a/src/LlmContentEditor/Domain/Command/EditContentCommand.php +++ b/src/LlmContentEditor/Domain/Command/EditContentCommand.php @@ -4,9 +4,9 @@ namespace App\LlmContentEditor\Domain\Command; +use App\AgenticContentEditor\Facade\Dto\AgentConfigDto; use App\LlmContentEditor\Domain\Agent\ContentEditorAgent; use App\LlmContentEditor\Domain\Enum\LlmModelName; -use App\LlmContentEditor\Facade\Dto\AgentConfigDto; use App\LlmContentEditor\Infrastructure\Observer\ConsoleObserver; use App\ProjectMgmt\Facade\Enum\ProjectType; use App\ProjectMgmt\Facade\ProjectMgmtFacadeInterface; diff --git a/src/LlmContentEditor/Facade/Dto/AgentEventDto.php b/src/LlmContentEditor/Facade/Dto/AgentEventDto.php deleted file mode 100644 index e41936b..0000000 --- a/src/LlmContentEditor/Facade/Dto/AgentEventDto.php +++ /dev/null @@ -1,25 +0,0 @@ -|null $toolInputs - * @param int|null $inputBytes Actual byte length of tool inputs (for context-usage tracking) - * @param int|null $resultBytes Actual byte length of tool result (for context-usage tracking) - */ - public function __construct( - public string $kind, - public ?string $toolName = null, - public ?array $toolInputs = null, - public ?string $toolResult = null, - public ?string $errorMessage = null, - public ?int $inputBytes = null, - public ?int $resultBytes = null, - ) { - } -} diff --git a/src/LlmContentEditor/Facade/LlmContentEditorFacade.php b/src/LlmContentEditor/Facade/LlmContentEditorFacade.php index 014e443..7084010 100644 --- a/src/LlmContentEditor/Facade/LlmContentEditorFacade.php +++ b/src/LlmContentEditor/Facade/LlmContentEditorFacade.php @@ -4,12 +4,12 @@ namespace App\LlmContentEditor\Facade; +use App\AgenticContentEditor\Facade\Dto\AgentConfigDto; +use App\AgenticContentEditor\Facade\Dto\ConversationMessageDto; +use App\AgenticContentEditor\Facade\Dto\EditStreamChunkDto; +use App\AgenticContentEditor\Facade\Enum\EditStreamChunkType; use App\LlmContentEditor\Domain\Agent\ContentEditorAgent; use App\LlmContentEditor\Domain\Enum\LlmModelName; -use App\LlmContentEditor\Facade\Dto\AgentConfigDto; -use App\LlmContentEditor\Facade\Dto\ConversationMessageDto; -use App\LlmContentEditor\Facade\Dto\EditStreamChunkDto; -use App\LlmContentEditor\Facade\Enum\EditStreamChunkType; use App\LlmContentEditor\Facade\Enum\LlmModelProvider; use App\LlmContentEditor\Infrastructure\AgentEventQueue; use App\LlmContentEditor\Infrastructure\ChatHistory\CallbackChatHistory; @@ -54,18 +54,6 @@ public function __construct( $this->messageSerializer = new MessageSerializer(); } - /** - * @deprecated Use streamEditWithHistory() to support multi-turn conversations - * - * @return Generator - */ - public function streamEdit(string $workspacePath, string $instruction): Generator - { - // This method is deprecated and should not be used. - // Yield an error chunk since we can't proceed without an API key. - yield new EditStreamChunkDto(EditStreamChunkType::Done, null, null, false, 'streamEdit is deprecated. Use streamEditWithHistory with an API key.'); - } - /** * @param list $previousMessages * diff --git a/src/LlmContentEditor/Facade/LlmContentEditorFacadeInterface.php b/src/LlmContentEditor/Facade/LlmContentEditorFacadeInterface.php index f27f581..47b860e 100644 --- a/src/LlmContentEditor/Facade/LlmContentEditorFacadeInterface.php +++ b/src/LlmContentEditor/Facade/LlmContentEditorFacadeInterface.php @@ -4,25 +4,14 @@ namespace App\LlmContentEditor\Facade; -use App\LlmContentEditor\Facade\Dto\AgentConfigDto; -use App\LlmContentEditor\Facade\Dto\ConversationMessageDto; -use App\LlmContentEditor\Facade\Dto\EditStreamChunkDto; +use App\AgenticContentEditor\Facade\Dto\AgentConfigDto; +use App\AgenticContentEditor\Facade\Dto\ConversationMessageDto; +use App\AgenticContentEditor\Facade\Dto\EditStreamChunkDto; use App\LlmContentEditor\Facade\Enum\LlmModelProvider; use Generator; interface LlmContentEditorFacadeInterface { - /** - * Runs the content editor agent on the given workspace with the given instruction. - * Yields streaming chunks: event (structured agent feedback), text (LLM output), and done. - * The caller is responsible for resolving and validating workspacePath (e.g. under an allowed root). - * - * @deprecated Use streamEditWithHistory() to support multi-turn conversations - * - * @return Generator - */ - public function streamEdit(string $workspacePath, string $instruction): Generator; - /** * Runs the content editor agent with conversation history support. * Yields streaming chunks: event, text, message (new messages to persist), progress, and done. diff --git a/src/LlmContentEditor/Infrastructure/AgentEventQueue.php b/src/LlmContentEditor/Infrastructure/AgentEventQueue.php index 5412142..17f9398 100644 --- a/src/LlmContentEditor/Infrastructure/AgentEventQueue.php +++ b/src/LlmContentEditor/Infrastructure/AgentEventQueue.php @@ -4,7 +4,7 @@ namespace App\LlmContentEditor\Infrastructure; -use App\LlmContentEditor\Facade\Dto\AgentEventDto; +use App\AgenticContentEditor\Facade\Dto\AgentEventDto; /** * Typed queue for AgentEventDto used to collect events from the observer. diff --git a/src/LlmContentEditor/Infrastructure/ChatHistory/MessageSerializer.php b/src/LlmContentEditor/Infrastructure/ChatHistory/MessageSerializer.php index 5d109bf..ae36031 100644 --- a/src/LlmContentEditor/Infrastructure/ChatHistory/MessageSerializer.php +++ b/src/LlmContentEditor/Infrastructure/ChatHistory/MessageSerializer.php @@ -4,7 +4,7 @@ namespace App\LlmContentEditor\Infrastructure\ChatHistory; -use App\LlmContentEditor\Facade\Dto\ConversationMessageDto; +use App\AgenticContentEditor\Facade\Dto\ConversationMessageDto; use JsonException; use NeuronAI\Chat\Enums\MessageRole; use NeuronAI\Chat\Messages\AssistantMessage; diff --git a/src/LlmContentEditor/Infrastructure/LlmContentEditorAdapter.php b/src/LlmContentEditor/Infrastructure/LlmContentEditorAdapter.php new file mode 100644 index 0000000..56f4550 --- /dev/null +++ b/src/LlmContentEditor/Infrastructure/LlmContentEditorAdapter.php @@ -0,0 +1,79 @@ + $previousMessages + * + * @return Generator + */ + public function streamEdit( + string $workspacePath, + string $instruction, + array $previousMessages, + string $apiKey, + AgentConfigDto $agentConfig, + ?string $backendSessionState = null, + string $locale = 'en', + ): Generator { + return $this->llmContentEditorFacade->streamEditWithHistory( + $workspacePath, + $instruction, + $previousMessages, + $apiKey, + $agentConfig, + $locale + ); + } + + /** + * @param list $previousMessages + */ + public function buildAgentContextDump( + string $instruction, + array $previousMessages, + AgentConfigDto $agentConfig + ): string { + return $this->llmContentEditorFacade->buildAgentContextDump( + $instruction, + $previousMessages, + $agentConfig + ); + } + + public function getBackendModelInfo(): BackendModelInfoDto + { + $model = LlmModelName::defaultForContentEditor(); + + return new BackendModelInfoDto( + $model->value, + $model->maxContextTokens(), + $model->inputCostPer1M(), + $model->outputCostPer1M() + ); + } +} diff --git a/src/LlmContentEditor/Infrastructure/Observer/AgentEventCollectingObserver.php b/src/LlmContentEditor/Infrastructure/Observer/AgentEventCollectingObserver.php index a9cbcce..62ef369 100644 --- a/src/LlmContentEditor/Infrastructure/Observer/AgentEventCollectingObserver.php +++ b/src/LlmContentEditor/Infrastructure/Observer/AgentEventCollectingObserver.php @@ -4,8 +4,8 @@ namespace App\LlmContentEditor\Infrastructure\Observer; -use App\LlmContentEditor\Facade\Dto\AgentEventDto; -use App\LlmContentEditor\Facade\Dto\ToolInputEntryDto; +use App\AgenticContentEditor\Facade\Dto\AgentEventDto; +use App\AgenticContentEditor\Facade\Dto\ToolInputEntryDto; use App\LlmContentEditor\Infrastructure\AgentEventQueue; use NeuronAI\Observability\Events\AgentError; use NeuronAI\Observability\Events\InferenceStart; diff --git a/src/LlmContentEditor/Infrastructure/ProgressMessageResolver.php b/src/LlmContentEditor/Infrastructure/ProgressMessageResolver.php index 4840259..0584d08 100644 --- a/src/LlmContentEditor/Infrastructure/ProgressMessageResolver.php +++ b/src/LlmContentEditor/Infrastructure/ProgressMessageResolver.php @@ -4,8 +4,8 @@ namespace App\LlmContentEditor\Infrastructure; -use App\LlmContentEditor\Facade\Dto\AgentEventDto; -use App\LlmContentEditor\Facade\Dto\ToolInputEntryDto; +use App\AgenticContentEditor\Facade\Dto\AgentEventDto; +use App\AgenticContentEditor\Facade\Dto\ToolInputEntryDto; use Symfony\Contracts\Translation\TranslatorInterface; /** diff --git a/src/ProjectMgmt/Domain/Entity/Project.php b/src/ProjectMgmt/Domain/Entity/Project.php index 264f2c6..3cd1044 100644 --- a/src/ProjectMgmt/Domain/Entity/Project.php +++ b/src/ProjectMgmt/Domain/Entity/Project.php @@ -4,6 +4,7 @@ namespace App\ProjectMgmt\Domain\Entity; +use App\AgenticContentEditor\Facade\Enum\AgenticContentEditorBackend; use App\LlmContentEditor\Facade\Enum\LlmModelProvider; use App\ProjectMgmt\Domain\ValueObject\AgentConfigTemplate; use App\ProjectMgmt\Facade\Enum\ProjectType; @@ -27,18 +28,19 @@ class Project * @param list|null $remoteContentAssetsManifestUrls */ public function __construct( - string $organizationId, - string $name, - string $gitUrl, - string $githubToken, - LlmModelProvider $llmModelProvider, - string $llmApiKey, - ProjectType $projectType = ProjectType::DEFAULT, - string $agentImage = self::DEFAULT_AGENT_IMAGE, - ?string $agentBackgroundInstructions = null, - ?string $agentStepInstructions = null, - ?string $agentOutputInstructions = null, - ?array $remoteContentAssetsManifestUrls = null + string $organizationId, + string $name, + string $gitUrl, + string $githubToken, + LlmModelProvider $llmModelProvider, + string $llmApiKey, + ProjectType $projectType = ProjectType::DEFAULT, + AgenticContentEditorBackend $contentEditorBackend = AgenticContentEditorBackend::Llm, + string $agentImage = self::DEFAULT_AGENT_IMAGE, + ?string $agentBackgroundInstructions = null, + ?string $agentStepInstructions = null, + ?string $agentOutputInstructions = null, + ?array $remoteContentAssetsManifestUrls = null ) { $this->organizationId = $organizationId; $this->name = $name; @@ -47,6 +49,7 @@ public function __construct( $this->llmModelProvider = $llmModelProvider; $this->llmApiKey = $llmApiKey; $this->projectType = $projectType; + $this->contentEditorBackend = $contentEditorBackend; $this->agentImage = $agentImage; $this->createdAt = DateAndTimeService::getDateTimeImmutable(); $this->remoteContentAssetsManifestUrls = $remoteContentAssetsManifestUrls !== null && $remoteContentAssetsManifestUrls !== [] ? $remoteContentAssetsManifestUrls : null; @@ -152,6 +155,25 @@ public function setProjectType(ProjectType $projectType): void $this->projectType = $projectType; } + #[ORM\Column( + type: Types::STRING, + length: 32, + nullable: false, + enumType: AgenticContentEditorBackend::class, + options: ['default' => AgenticContentEditorBackend::Llm->value] + )] + private AgenticContentEditorBackend $contentEditorBackend = AgenticContentEditorBackend::Llm; + + public function getContentEditorBackend(): AgenticContentEditorBackend + { + return $this->contentEditorBackend; + } + + public function setContentEditorBackend(AgenticContentEditorBackend $contentEditorBackend): void + { + $this->contentEditorBackend = $contentEditorBackend; + } + #[ORM\Column( type: Types::STRING, length: 255, diff --git a/src/ProjectMgmt/Domain/Service/ProjectService.php b/src/ProjectMgmt/Domain/Service/ProjectService.php index 9ea1b20..334adb6 100644 --- a/src/ProjectMgmt/Domain/Service/ProjectService.php +++ b/src/ProjectMgmt/Domain/Service/ProjectService.php @@ -4,6 +4,7 @@ namespace App\ProjectMgmt\Domain\Service; +use App\AgenticContentEditor\Facade\Enum\AgenticContentEditorBackend; use App\LlmContentEditor\Facade\Enum\LlmModelProvider; use App\ProjectMgmt\Domain\Entity\Project; use App\ProjectMgmt\Facade\Enum\ProjectType; @@ -24,25 +25,26 @@ public function __construct( * @param list|null $remoteContentAssetsManifestUrls */ public function create( - string $organizationId, - string $name, - string $gitUrl, - string $githubToken, - LlmModelProvider $llmModelProvider, - string $llmApiKey, - ProjectType $projectType = ProjectType::DEFAULT, - string $agentImage = Project::DEFAULT_AGENT_IMAGE, - ?string $agentBackgroundInstructions = null, - ?string $agentStepInstructions = null, - ?string $agentOutputInstructions = null, - ?array $remoteContentAssetsManifestUrls = null, - ?string $s3BucketName = null, - ?string $s3Region = null, - ?string $s3AccessKeyId = null, - ?string $s3SecretAccessKey = null, - ?string $s3IamRoleArn = null, - ?string $s3KeyPrefix = null, - bool $keysVisible = true + string $organizationId, + string $name, + string $gitUrl, + string $githubToken, + LlmModelProvider $llmModelProvider, + string $llmApiKey, + ProjectType $projectType = ProjectType::DEFAULT, + AgenticContentEditorBackend $contentEditorBackend = AgenticContentEditorBackend::Llm, + string $agentImage = Project::DEFAULT_AGENT_IMAGE, + ?string $agentBackgroundInstructions = null, + ?string $agentStepInstructions = null, + ?string $agentOutputInstructions = null, + ?array $remoteContentAssetsManifestUrls = null, + ?string $s3BucketName = null, + ?string $s3Region = null, + ?string $s3AccessKeyId = null, + ?string $s3SecretAccessKey = null, + ?string $s3IamRoleArn = null, + ?string $s3KeyPrefix = null, + bool $keysVisible = true ): Project { $project = new Project( $organizationId, @@ -52,6 +54,7 @@ public function create( $llmModelProvider, $llmApiKey, $projectType, + $contentEditorBackend, $agentImage, $agentBackgroundInstructions, $agentStepInstructions, @@ -79,24 +82,25 @@ public function create( * @param list|null $remoteContentAssetsManifestUrls */ public function update( - Project $project, - string $name, - string $gitUrl, - string $githubToken, - LlmModelProvider $llmModelProvider, - string $llmApiKey, - ProjectType $projectType = ProjectType::DEFAULT, - string $agentImage = Project::DEFAULT_AGENT_IMAGE, - ?string $agentBackgroundInstructions = null, - ?string $agentStepInstructions = null, - ?string $agentOutputInstructions = null, - ?array $remoteContentAssetsManifestUrls = null, - ?string $s3BucketName = null, - ?string $s3Region = null, - ?string $s3AccessKeyId = null, - ?string $s3SecretAccessKey = null, - ?string $s3IamRoleArn = null, - ?string $s3KeyPrefix = null + Project $project, + string $name, + string $gitUrl, + string $githubToken, + LlmModelProvider $llmModelProvider, + string $llmApiKey, + ProjectType $projectType = ProjectType::DEFAULT, + AgenticContentEditorBackend $contentEditorBackend = AgenticContentEditorBackend::Llm, + string $agentImage = Project::DEFAULT_AGENT_IMAGE, + ?string $agentBackgroundInstructions = null, + ?string $agentStepInstructions = null, + ?string $agentOutputInstructions = null, + ?array $remoteContentAssetsManifestUrls = null, + ?string $s3BucketName = null, + ?string $s3Region = null, + ?string $s3AccessKeyId = null, + ?string $s3SecretAccessKey = null, + ?string $s3IamRoleArn = null, + ?string $s3KeyPrefix = null ): void { $project->setName($name); $project->setGitUrl($gitUrl); @@ -104,6 +108,7 @@ public function update( $project->setLlmModelProvider($llmModelProvider); $project->setLlmApiKey($llmApiKey); $project->setProjectType($projectType); + $project->setContentEditorBackend($contentEditorBackend); $project->setAgentImage($agentImage); if ($agentBackgroundInstructions !== null) { diff --git a/src/ProjectMgmt/Facade/Dto/ProjectInfoDto.php b/src/ProjectMgmt/Facade/Dto/ProjectInfoDto.php index c5e2283..7c71d9a 100644 --- a/src/ProjectMgmt/Facade/Dto/ProjectInfoDto.php +++ b/src/ProjectMgmt/Facade/Dto/ProjectInfoDto.php @@ -4,6 +4,7 @@ namespace App\ProjectMgmt\Facade\Dto; +use App\AgenticContentEditor\Facade\Enum\AgenticContentEditorBackend; use App\LlmContentEditor\Facade\Enum\LlmModelProvider; use App\ProjectMgmt\Facade\Enum\ProjectType; @@ -13,26 +14,27 @@ * @param list $remoteContentAssetsManifestUrls */ public function __construct( - public string $id, - public string $name, - public string $gitUrl, - public string $githubToken, - public ProjectType $projectType, - public string $githubUrl, - public string $agentImage, - public LlmModelProvider $llmModelProvider, - public string $llmApiKey, - public string $agentBackgroundInstructions, - public string $agentStepInstructions, - public string $agentOutputInstructions, - public array $remoteContentAssetsManifestUrls = [], + public string $id, + public string $name, + public string $gitUrl, + public string $githubToken, + public ProjectType $projectType, + public AgenticContentEditorBackend $contentEditorBackend, + public string $githubUrl, + public string $agentImage, + public LlmModelProvider $llmModelProvider, + public string $llmApiKey, + public string $agentBackgroundInstructions, + public string $agentStepInstructions, + public string $agentOutputInstructions, + public array $remoteContentAssetsManifestUrls = [], // S3 Upload Configuration (all optional) - public ?string $s3BucketName = null, - public ?string $s3Region = null, - public ?string $s3AccessKeyId = null, - public ?string $s3SecretAccessKey = null, - public ?string $s3IamRoleArn = null, - public ?string $s3KeyPrefix = null, + public ?string $s3BucketName = null, + public ?string $s3Region = null, + public ?string $s3AccessKeyId = null, + public ?string $s3SecretAccessKey = null, + public ?string $s3IamRoleArn = null, + public ?string $s3KeyPrefix = null, ) { } diff --git a/src/ProjectMgmt/Facade/ProjectMgmtFacade.php b/src/ProjectMgmt/Facade/ProjectMgmtFacade.php index 908d1ca..c56ec89 100644 --- a/src/ProjectMgmt/Facade/ProjectMgmtFacade.php +++ b/src/ProjectMgmt/Facade/ProjectMgmtFacade.php @@ -4,6 +4,7 @@ namespace App\ProjectMgmt\Facade; +use App\AgenticContentEditor\Facade\Enum\AgenticContentEditorBackend; use App\LlmContentEditor\Facade\Enum\LlmModelProvider; use App\Prefab\Facade\Dto\PrefabDto; use App\ProjectMgmt\Domain\Entity\Project; @@ -48,6 +49,7 @@ public function createProjectFromPrefab(string $organizationId, PrefabDto $prefa $llmModelProvider, $prefab->llmApiKey, ProjectType::DEFAULT, + AgenticContentEditorBackend::Llm, Project::DEFAULT_AGENT_IMAGE, null, null, @@ -167,6 +169,7 @@ private function toDto(Project $project): ProjectInfoDto $project->getGitUrl(), $project->getGithubToken(), $project->getProjectType(), + $project->getContentEditorBackend(), $githubUrl, $project->getAgentImage(), $project->getLlmModelProvider(), diff --git a/src/ProjectMgmt/Presentation/Controller/ProjectController.php b/src/ProjectMgmt/Presentation/Controller/ProjectController.php index ed27a7d..39ef0bd 100644 --- a/src/ProjectMgmt/Presentation/Controller/ProjectController.php +++ b/src/ProjectMgmt/Presentation/Controller/ProjectController.php @@ -5,6 +5,7 @@ namespace App\ProjectMgmt\Presentation\Controller; use App\Account\Facade\AccountFacadeInterface; +use App\AgenticContentEditor\Facade\Enum\AgenticContentEditorBackend; use App\ChatBasedContentEditor\Facade\ChatBasedContentEditorFacadeInterface; use App\LlmContentEditor\Facade\Enum\LlmModelProvider; use App\LlmContentEditor\Facade\LlmContentEditorFacadeInterface; @@ -135,10 +136,11 @@ public function new(): Response $defaultTemplate = $this->projectMgmtFacade->getAgentConfigTemplate(ProjectType::DEFAULT); return $this->render('@project_mgmt.presentation/project_form.twig', [ - 'project' => null, - 'llmProviders' => LlmModelProvider::cases(), - 'existingLlmKeys' => $this->projectMgmtFacade->getExistingLlmApiKeys($organizationId), - 'agentConfigTemplate' => $defaultTemplate, + 'project' => null, + 'llmProviders' => LlmModelProvider::cases(), + 'contentEditorBackends' => AgenticContentEditorBackend::cases(), + 'existingLlmKeys' => $this->projectMgmtFacade->getExistingLlmApiKeys($organizationId), + 'agentConfigTemplate' => $defaultTemplate, ]); } @@ -155,12 +157,13 @@ public function create(Request $request): Response return $this->redirectToRoute('project_mgmt.presentation.new'); } - $name = $request->request->getString('name'); - $gitUrl = $request->request->getString('git_url'); - $githubToken = $request->request->getString('github_token'); - $llmModelProvider = LlmModelProvider::tryFrom($request->request->getString('llm_model_provider')); - $llmApiKey = $request->request->getString('llm_api_key'); - $agentImage = $this->resolveAgentImage($request); + $name = $request->request->getString('name'); + $gitUrl = $request->request->getString('git_url'); + $githubToken = $request->request->getString('github_token'); + $llmModelProvider = LlmModelProvider::tryFrom($request->request->getString('llm_model_provider')); + $contentEditorBackend = AgenticContentEditorBackend::tryFrom($request->request->getString('content_editor_backend')); + $llmApiKey = $request->request->getString('llm_api_key'); + $agentImage = $this->resolveAgentImage($request); // Agent configuration (optional - uses template defaults if empty) $agentBackgroundInstructions = $this->nullIfEmpty($request->request->getString('agent_background_instructions')); @@ -180,6 +183,12 @@ public function create(Request $request): Response return $this->redirectToRoute('project_mgmt.presentation.new'); } + if ($contentEditorBackend === null) { + $this->addFlash('error', $this->translator->trans('flash.error.select_content_editor_backend')); + + return $this->redirectToRoute('project_mgmt.presentation.new'); + } + if ($agentImage === '' || !$this->isValidDockerImageName($agentImage)) { $this->addFlash('error', $this->translator->trans('flash.error.invalid_docker_image')); @@ -210,6 +219,7 @@ public function create(Request $request): Response $llmModelProvider, $llmApiKey, ProjectType::DEFAULT, + $contentEditorBackend, $agentImage, $agentBackgroundInstructions, $agentStepInstructions, @@ -266,13 +276,14 @@ public function edit(string $id): Response $agentConfigTemplate = $this->projectMgmtFacade->getAgentConfigTemplate($project->getProjectType()); return $this->render('@project_mgmt.presentation/project_form.twig', [ - 'project' => $project, - 'llmProviders' => LlmModelProvider::cases(), - 'existingLlmKeys' => $existingLlmKeys, - 'agentConfigTemplate' => $agentConfigTemplate, - 'keysVisible' => $keysVisible, - 'displayGithubToken' => $displayGithubToken, - 'displayLlmApiKey' => $displayLlmApiKey, + 'project' => $project, + 'llmProviders' => LlmModelProvider::cases(), + 'contentEditorBackends' => AgenticContentEditorBackend::cases(), + 'existingLlmKeys' => $existingLlmKeys, + 'agentConfigTemplate' => $agentConfigTemplate, + 'keysVisible' => $keysVisible, + 'displayGithubToken' => $displayGithubToken, + 'displayLlmApiKey' => $displayLlmApiKey, ]); } @@ -296,13 +307,14 @@ public function update(string $id, Request $request): Response return $this->redirectToRoute('project_mgmt.presentation.edit', ['id' => $id]); } - $name = $request->request->getString('name'); - $gitUrl = $request->request->getString('git_url'); - $keysVisible = $project->isKeysVisible(); - $githubToken = $keysVisible ? $request->request->getString('github_token') : $project->getGithubToken(); - $llmModelProvider = LlmModelProvider::tryFrom($request->request->getString('llm_model_provider')); - $llmApiKey = $keysVisible ? $request->request->getString('llm_api_key') : $project->getLlmApiKey(); - $agentImage = $this->resolveAgentImage($request); + $name = $request->request->getString('name'); + $gitUrl = $request->request->getString('git_url'); + $keysVisible = $project->isKeysVisible(); + $githubToken = $keysVisible ? $request->request->getString('github_token') : $project->getGithubToken(); + $llmModelProvider = LlmModelProvider::tryFrom($request->request->getString('llm_model_provider')); + $contentEditorBackend = AgenticContentEditorBackend::tryFrom($request->request->getString('content_editor_backend')); + $llmApiKey = $keysVisible ? $request->request->getString('llm_api_key') : $project->getLlmApiKey(); + $agentImage = $this->resolveAgentImage($request); // Agent configuration (null means keep existing values) $agentBackgroundInstructions = $this->nullIfEmpty($request->request->getString('agent_background_instructions')); @@ -323,6 +335,12 @@ public function update(string $id, Request $request): Response return $this->redirectToRoute('project_mgmt.presentation.edit', ['id' => $id]); } + if ($contentEditorBackend === null) { + $this->addFlash('error', $this->translator->trans('flash.error.select_content_editor_backend')); + + return $this->redirectToRoute('project_mgmt.presentation.edit', ['id' => $id]); + } + if ($agentImage === '' || !$this->isValidDockerImageName($agentImage)) { $this->addFlash('error', $this->translator->trans('flash.error.invalid_docker_image')); @@ -345,6 +363,7 @@ public function update(string $id, Request $request): Response $llmModelProvider, $llmApiKey, ProjectType::DEFAULT, + $contentEditorBackend, $agentImage, $agentBackgroundInstructions, $agentStepInstructions, diff --git a/src/ProjectMgmt/Presentation/Resources/templates/project_form.twig b/src/ProjectMgmt/Presentation/Resources/templates/project_form.twig index 6c0a093..548588d 100644 --- a/src/ProjectMgmt/Presentation/Resources/templates/project_form.twig +++ b/src/ProjectMgmt/Presentation/Resources/templates/project_form.twig @@ -64,26 +64,62 @@

{{ 'project.form.github_access_key_help'|trans }}

- {# LLM Model Provider #} -
- {{ 'project.form.llm_provider'|trans }} -
- {% for provider in llmProviders %} + {% set currentBackend = project ? project.contentEditorBackend.value : 'llm' %} +
+ + +

{{ 'project.form.content_editor_backend_help'|trans }}

+
+ + {# Content editor model provider (depends on backend) #} +
+
+ {{ 'project.form.llm_provider'|trans }} +
+ {% for provider in llmProviders %} + + {% endfor %} +
+
+
+
+
+ {{ 'project.form.cursor_provider'|trans }} +
- {% endfor %} -
-
+
+
+ - {# LLM API Key with verification #} -
@@ -92,7 +128,9 @@ name="llm_api_key" id="llm_api_key" value="{{ project ? (displayLlmApiKey is defined ? displayLlmApiKey : project.llmApiKey) : '' }}" - {{ (keysVisible is not defined or keysVisible) ? 'required' : 'disabled' }} + data-required="{{ (keysVisible is not defined or keysVisible) ? 'true' : 'false' }}" + data-force-disabled="{{ (keysVisible is defined and not keysVisible) ? 'true' : 'false' }}" + {{ (keysVisible is not defined or keysVisible) and currentBackend == 'llm' ? 'required' : 'disabled' }} placeholder="{{ (keysVisible is defined and not keysVisible) ? 'project.form.keys_managed_by_deployment'|trans : 'project.form.placeholder_llm_api_key'|trans }}" class="etfswui-form-input" {{ stimulus_target('llm-key-verification', 'input') }} @@ -144,6 +182,57 @@
{% endif %} +
+ + +

{{ 'project.form.cursor_api_key_help'|trans }}

+
+ + {# GitHub Access Key Helper - shown after base required fields #}
diff --git a/src/WorkspaceTooling/Facade/AgentExecutionContextInterface.php b/src/WorkspaceTooling/Facade/AgentExecutionContextInterface.php index 8451f9e..fbb55ee 100644 --- a/src/WorkspaceTooling/Facade/AgentExecutionContextInterface.php +++ b/src/WorkspaceTooling/Facade/AgentExecutionContextInterface.php @@ -53,6 +53,18 @@ public function getConversationId(): ?string; */ public function getRemoteContentAssetsManifestUrls(): array; + /** + * Set a callback that receives streaming command output. + * + * The callback signature is: fn(string $buffer, bool $isError): void + */ + public function setOutputCallback(?callable $callback): void; + + /** + * Get the output callback for streaming command output. + */ + public function getOutputCallback(): ?callable; + /** * Set a suggested commit message from the agent. * @@ -67,4 +79,11 @@ public function setSuggestedCommitMessage(string $message): void; * @return string|null The suggested message, or null if none was set */ public function getSuggestedCommitMessage(): ?string; + + /** + * Get the Docker agent image to use for agent containers. + * + * @return string|null The agent image, or null if not set + */ + public function getAgentImage(): ?string; } diff --git a/src/WorkspaceTooling/Facade/StreamingProcessInterface.php b/src/WorkspaceTooling/Facade/StreamingProcessInterface.php new file mode 100644 index 0000000..0fff8e9 --- /dev/null +++ b/src/WorkspaceTooling/Facade/StreamingProcessInterface.php @@ -0,0 +1,49 @@ +shellOperationsService instanceof IsolatedShellExecutor); + + return $this->shellOperationsService->runCommandAsync($workingDirectory, $command); + } } diff --git a/src/WorkspaceTooling/Facade/WorkspaceToolingServiceInterface.php b/src/WorkspaceTooling/Facade/WorkspaceToolingServiceInterface.php index f2da7bd..fe5234b 100644 --- a/src/WorkspaceTooling/Facade/WorkspaceToolingServiceInterface.php +++ b/src/WorkspaceTooling/Facade/WorkspaceToolingServiceInterface.php @@ -8,6 +8,19 @@ interface WorkspaceToolingServiceInterface extends BaseWorkspaceToolingFacadeInterface { + /** + * Start a shell command asynchronously. + * + * Returns a StreamingProcessInterface that can be polled for completion. + * Output is streamed to any configured callback as it arrives. + * + * @param string $workingDirectory The working directory (e.g., /workspace) + * @param string $command The command to execute + * + * @return StreamingProcessInterface The running process wrapper + */ + public function runShellCommandAsync(string $workingDirectory, string $command): StreamingProcessInterface; + public function runQualityChecks(string $pathToFolder): string; public function runTests(string $pathToFolder): string; diff --git a/src/WorkspaceTooling/Infrastructure/Execution/AgentExecutionContext.php b/src/WorkspaceTooling/Infrastructure/Execution/AgentExecutionContext.php index 559de1b..3cbf4f5 100644 --- a/src/WorkspaceTooling/Infrastructure/Execution/AgentExecutionContext.php +++ b/src/WorkspaceTooling/Infrastructure/Execution/AgentExecutionContext.php @@ -5,6 +5,7 @@ namespace App\WorkspaceTooling\Infrastructure\Execution; use App\WorkspaceTooling\Facade\AgentExecutionContextInterface; +use Closure; /** * Holds context information for the current agent execution. @@ -33,6 +34,7 @@ final class AgentExecutionContext implements AgentExecutionContextInterface private ?string $projectName = null; private ?string $agentImage = null; private ?string $suggestedCommitMessage = null; + private ?Closure $outputCallback = null; /** * @var list|null @@ -72,6 +74,7 @@ public function clearContext(): void $this->agentImage = null; $this->suggestedCommitMessage = null; $this->remoteContentAssetsManifestUrls = null; + $this->outputCallback = null; } /** @@ -82,6 +85,22 @@ public function getRemoteContentAssetsManifestUrls(): array return $this->remoteContentAssetsManifestUrls ?? []; } + public function setOutputCallback(?callable $callback): void + { + if ($callback === null) { + $this->outputCallback = null; + + return; + } + + $this->outputCallback = Closure::fromCallable($callback); + } + + public function getOutputCallback(): ?callable + { + return $this->outputCallback; + } + public function getWorkspaceId(): ?string { return $this->workspaceId; diff --git a/src/WorkspaceTooling/Infrastructure/Execution/DockerExecutor.php b/src/WorkspaceTooling/Infrastructure/Execution/DockerExecutor.php index 59fcc68..810f86d 100644 --- a/src/WorkspaceTooling/Infrastructure/Execution/DockerExecutor.php +++ b/src/WorkspaceTooling/Infrastructure/Execution/DockerExecutor.php @@ -45,13 +45,14 @@ public function __construct( * @throws DockerExecutionException if command execution fails */ public function run( - string $image, - string $command, - string $mountPath, - string $workingDirectory = '/workspace', - int $timeout = self::DEFAULT_TIMEOUT, - bool $allowNetwork = true, - ?string $containerName = null + string $image, + string $command, + string $mountPath, + string $workingDirectory = '/workspace', + int $timeout = self::DEFAULT_TIMEOUT, + bool $allowNetwork = true, + ?string $containerName = null, + ?callable $outputCallback = null ): string { $dockerCommand = $this->buildDockerCommand( $image, @@ -66,7 +67,14 @@ public function run( $process->setTimeout($timeout); try { - $process->run(); + $output = ''; + $process->run(function (string $type, string $buffer) use (&$output, $outputCallback): void { + $output .= $buffer; + + if ($outputCallback !== null) { + $outputCallback($buffer, $type === Process::ERR); + } + }); } catch (ProcessTimedOutException $e) { throw new DockerExecutionException( sprintf('Command timed out after %d seconds', $timeout), @@ -75,8 +83,6 @@ public function run( ); } - $output = $process->getOutput() . $process->getErrorOutput(); - if (!$process->isSuccessful()) { // Check for common Docker errors $exitCode = $process->getExitCode(); @@ -109,6 +115,59 @@ public function run( return $output; } + /** + * Start a command asynchronously in an isolated Docker container. + * + * Returns a StreamingDockerProcess that can be polled for completion. + * Output is streamed to the callback as it arrives. + * + * @param string $image Docker image to use (e.g., node:22-slim) + * @param string $command Command to execute inside the container + * @param string $mountPath Path to mount as /workspace (container path, will be translated) + * @param string $workingDirectory Working directory inside the container (e.g., /workspace) + * @param int $timeout Timeout in seconds + * @param bool $allowNetwork Whether to allow network access + * @param string|null $containerName Optional container name for identification + * @param callable|null $outputCallback Callback for streaming output: fn(string $buffer, bool $isError): void + * + * @return StreamingDockerProcess The running process wrapper + */ + public function startAsync( + string $image, + string $command, + string $mountPath, + string $workingDirectory = '/workspace', + int $timeout = self::DEFAULT_TIMEOUT, + bool $allowNetwork = true, + ?string $containerName = null, + ?callable $outputCallback = null + ): StreamingDockerProcess { + $dockerCommand = $this->buildDockerCommand( + $image, + $command, + $mountPath, + $workingDirectory, + $allowNetwork, + $containerName + ); + + $process = new Process($dockerCommand); + $process->setTimeout($timeout); + + $streamingProcess = new StreamingDockerProcess($process, $command, $image); + + // Start with output callback + $process->start(function (string $type, string $buffer) use ($streamingProcess, $outputCallback): void { + $streamingProcess->appendOutput($buffer); + + if ($outputCallback !== null) { + $outputCallback($buffer, $type === Process::ERR); + } + }); + + return $streamingProcess; + } + /** * Check if Docker is available and accessible. */ @@ -191,6 +250,13 @@ private function buildDockerCommand( $dockerCmd[] = '-e'; $dockerCmd[] = 'NODE_OPTIONS=--max-old-space-size=1536'; + // Set BASH_ENV to source a script that sets up PATH with mise node installation. + // The Cursor CLI spawns fresh bash processes for shellToolCall that don't inherit + // environment variables from the parent. BASH_ENV tells non-interactive bash to + // source a file before running commands. + $dockerCmd[] = '-e'; + $dockerCmd[] = 'BASH_ENV=/etc/profile.d/mise-path.sh'; + // Add the image $dockerCmd[] = $image; diff --git a/src/WorkspaceTooling/Infrastructure/Execution/IsolatedShellExecutor.php b/src/WorkspaceTooling/Infrastructure/Execution/IsolatedShellExecutor.php index 7c8d51e..b04715f 100644 --- a/src/WorkspaceTooling/Infrastructure/Execution/IsolatedShellExecutor.php +++ b/src/WorkspaceTooling/Infrastructure/Execution/IsolatedShellExecutor.php @@ -7,6 +7,8 @@ use EtfsCodingAgent\Service\ShellOperationsServiceInterface; use RuntimeException; +use function str_starts_with; + /** * Shell operations service that executes commands in isolated Docker containers. * @@ -66,6 +68,8 @@ public function runCommand(string $workingDirectory, string $command): string // Execute command in isolated container // The actual workspace path is mounted to /workspace + $outputCallback = $this->executionContext->getOutputCallback(); + return $this->dockerExecutor->run( $agentImage, $command, @@ -73,7 +77,60 @@ public function runCommand(string $workingDirectory, string $command): string $workingDirectory, // Working directory inside container 300, true, - $containerName + $containerName, + $outputCallback + ); + } + + /** + * Start a command asynchronously in an isolated Docker container. + * + * Returns a StreamingDockerProcess that can be polled for completion. + * Output is streamed to the callback (from execution context) as it arrives. + * + * @param string $workingDirectory The working directory (relative to /workspace) + * @param string $command The command to execute + * + * @return StreamingDockerProcess The running process wrapper + * + * @throws RuntimeException if execution context is not set + */ + public function runCommandAsync(string $workingDirectory, string $command): StreamingDockerProcess + { + // Get workspace path and image from execution context (set by handler) + $workspacePath = $this->executionContext->getWorkspacePath(); + $agentImage = $this->executionContext->getAgentImage(); + + if ($workspacePath === null || $agentImage === null) { + throw new RuntimeException( + 'Execution context not set. Ensure setContext() is called before running commands.' + ); + } + + // Validate the working directory is within /workspace + if (!str_starts_with($workingDirectory, self::WORKSPACE_MOUNT_POINT)) { + throw new RuntimeException(sprintf( + 'Working directory must be within %s, got: %s', + self::WORKSPACE_MOUNT_POINT, + $workingDirectory + )); + } + + // Get container name from execution context + $containerName = $this->executionContext->buildContainerName(); + + // Execute command in isolated container asynchronously + $outputCallback = $this->executionContext->getOutputCallback(); + + return $this->dockerExecutor->startAsync( + $agentImage, + $command, + $workspacePath, // Actual path to mount + $workingDirectory, // Working directory inside container + 300, + true, + $containerName, + $outputCallback ); } } diff --git a/src/WorkspaceTooling/Infrastructure/Execution/StreamingDockerProcess.php b/src/WorkspaceTooling/Infrastructure/Execution/StreamingDockerProcess.php new file mode 100644 index 0000000..8cb1192 --- /dev/null +++ b/src/WorkspaceTooling/Infrastructure/Execution/StreamingDockerProcess.php @@ -0,0 +1,94 @@ +process->isRunning(); + } + + public function wait(): void + { + $this->process->wait(); + } + + /** + * Check the process result and throw exceptions for Docker-level failures. + * + * @throws DockerExecutionException if Docker execution failed + */ + public function checkResult(): void + { + if ($this->process->isRunning()) { + return; + } + + if (!$this->process->isSuccessful()) { + $exitCode = $this->process->getExitCode(); + $errorOutput = $this->process->getErrorOutput(); + + if (str_contains($errorOutput, 'Unable to find image')) { + throw new DockerExecutionException( + sprintf('Docker image not found: %s', $this->image), + $this->command + ); + } + + if (str_contains($errorOutput, 'permission denied')) { + throw new DockerExecutionException( + 'Docker permission denied. Ensure the Docker socket is accessible.', + $this->command + ); + } + + // Only throw for Docker-level failures (exit code 125-127 are Docker errors) + if ($exitCode !== null && $exitCode >= 125) { + throw new DockerExecutionException( + sprintf('Docker execution failed with exit code %d: %s', $exitCode, $errorOutput), + $this->command + ); + } + } + } + + public function appendOutput(string $buffer): void + { + $this->output .= $buffer; + } + + public function getOutput(): string + { + return $this->output; + } + + public function getExitCode(): ?int + { + return $this->process->getExitCode(); + } + + public function isSuccessful(): bool + { + return $this->process->isSuccessful(); + } +} diff --git a/tests/Unit/AgenticContentEditor/AgenticContentEditorFacadeTest.php b/tests/Unit/AgenticContentEditor/AgenticContentEditorFacadeTest.php new file mode 100644 index 0000000..889ee7c --- /dev/null +++ b/tests/Unit/AgenticContentEditor/AgenticContentEditorFacadeTest.php @@ -0,0 +1,137 @@ +createMock(AgenticContentEditorAdapterInterface::class); + $llmAdapter->method('supports')->willReturnCallback( + static fn (AgenticContentEditorBackend $b): bool => $b === AgenticContentEditorBackend::Llm + ); + $llmAdapter->method('streamEdit')->willReturnCallback( + static function (): Generator { + yield new EditStreamChunkDto(EditStreamChunkType::Done, null, null, true); + } + ); + + $cursorAdapter = $this->createMock(AgenticContentEditorAdapterInterface::class); + $cursorAdapter->method('supports')->willReturnCallback( + static fn (AgenticContentEditorBackend $b): bool => $b === AgenticContentEditorBackend::CursorAgent + ); + $cursorAdapter->expects(self::never())->method('streamEdit'); + + $facade = new AgenticContentEditorFacade([$llmAdapter, $cursorAdapter]); + + $chunks = iterator_to_array($facade->streamEditWithHistory( + AgenticContentEditorBackend::Llm, + '/workspace', + 'Edit title', + [], + 'key-123', + $config + )); + + self::assertCount(1, $chunks); + self::assertSame(EditStreamChunkType::Done, $chunks[0]->chunkType); + } + + public function testBuildAgentContextDumpDispatchesToCorrectAdapter(): void + { + $config = new AgentConfigDto('bg', 'step', 'out'); + + $llmAdapter = $this->createMock(AgenticContentEditorAdapterInterface::class); + $llmAdapter->method('supports')->willReturnCallback( + static fn (AgenticContentEditorBackend $b): bool => $b === AgenticContentEditorBackend::Llm + ); + $llmAdapter->method('buildAgentContextDump')->willReturn('LLM context dump'); + + $cursorAdapter = $this->createMock(AgenticContentEditorAdapterInterface::class); + $cursorAdapter->method('supports')->willReturnCallback( + static fn (AgenticContentEditorBackend $b): bool => $b === AgenticContentEditorBackend::CursorAgent + ); + $cursorAdapter->method('buildAgentContextDump')->willReturn('Cursor context dump'); + + $facade = new AgenticContentEditorFacade([$llmAdapter, $cursorAdapter]); + + self::assertSame('LLM context dump', $facade->buildAgentContextDump( + AgenticContentEditorBackend::Llm, + 'Edit title', + [], + $config + )); + + self::assertSame('Cursor context dump', $facade->buildAgentContextDump( + AgenticContentEditorBackend::CursorAgent, + 'Edit title', + [], + $config + )); + } + + public function testGetBackendModelInfoDispatchesToCorrectAdapter(): void + { + $llmInfo = new BackendModelInfoDto('gpt-5.2', 128_000, 1.75, 14.0); + $cursorInfo = new BackendModelInfoDto('cursor-agent', 200_000); + + $llmAdapter = $this->createMock(AgenticContentEditorAdapterInterface::class); + $llmAdapter->method('supports')->willReturnCallback( + static fn (AgenticContentEditorBackend $b): bool => $b === AgenticContentEditorBackend::Llm + ); + $llmAdapter->method('getBackendModelInfo')->willReturn($llmInfo); + + $cursorAdapter = $this->createMock(AgenticContentEditorAdapterInterface::class); + $cursorAdapter->method('supports')->willReturnCallback( + static fn (AgenticContentEditorBackend $b): bool => $b === AgenticContentEditorBackend::CursorAgent + ); + $cursorAdapter->method('getBackendModelInfo')->willReturn($cursorInfo); + + $facade = new AgenticContentEditorFacade([$llmAdapter, $cursorAdapter]); + + $result = $facade->getBackendModelInfo(AgenticContentEditorBackend::Llm); + self::assertSame('gpt-5.2', $result->modelName); + self::assertSame(128_000, $result->maxContextTokens); + self::assertSame(1.75, $result->inputCostPer1M); + self::assertSame(14.0, $result->outputCostPer1M); + + $result = $facade->getBackendModelInfo(AgenticContentEditorBackend::CursorAgent); + self::assertSame('cursor-agent', $result->modelName); + self::assertSame(200_000, $result->maxContextTokens); + self::assertNull($result->inputCostPer1M); + self::assertNull($result->outputCostPer1M); + } + + public function testThrowsWhenNoAdapterSupportsBackend(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('No content editor adapter registered for backend'); + + $facade = new AgenticContentEditorFacade([]); + + // Force generator to execute by iterating + iterator_to_array($facade->streamEditWithHistory( + AgenticContentEditorBackend::CursorAgent, + '/workspace', + 'Edit', + [], + 'key', + new AgentConfigDto('', '', '') + )); + } +} diff --git a/tests/Unit/ChatBasedContentEditor/ConversationServiceTest.php b/tests/Unit/ChatBasedContentEditor/ConversationServiceTest.php index e0b5f95..34f1f85 100644 --- a/tests/Unit/ChatBasedContentEditor/ConversationServiceTest.php +++ b/tests/Unit/ChatBasedContentEditor/ConversationServiceTest.php @@ -10,6 +10,7 @@ use App\ChatBasedContentEditor\Domain\Enum\ConversationStatus; use App\ChatBasedContentEditor\Domain\Service\ConversationService; use App\ChatBasedContentEditor\Infrastructure\Service\ConversationUrlServiceInterface; +use App\ProjectMgmt\Facade\ProjectMgmtFacadeInterface; use App\WorkspaceMgmt\Facade\WorkspaceMgmtFacadeInterface; use Doctrine\ORM\EntityManagerInterface; use EnterpriseToolingForSymfony\SharedBundle\DateAndTime\Service\DateAndTimeService; @@ -38,16 +39,22 @@ private function createConversationUrlService(): ConversationUrlServiceInterface return $urlService; } + private function createProjectMgmtFacade(): ProjectMgmtFacadeInterface + { + return $this->createMock(ProjectMgmtFacadeInterface::class); + } + public function testFinishConversationThrowsWhenConversationNotFound(): void { $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager->method('find')->willReturn(null); - $workspaceFacade = $this->createMock(WorkspaceMgmtFacadeInterface::class); - $accountFacade = $this->createMock(AccountFacadeInterface::class); - $urlService = $this->createConversationUrlService(); + $workspaceFacade = $this->createMock(WorkspaceMgmtFacadeInterface::class); + $accountFacade = $this->createMock(AccountFacadeInterface::class); + $urlService = $this->createConversationUrlService(); + $projectMgmtFacade = $this->createProjectMgmtFacade(); - $service = new ConversationService($entityManager, $workspaceFacade, $accountFacade, $urlService); + $service = new ConversationService($entityManager, $workspaceFacade, $accountFacade, $urlService, $projectMgmtFacade); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Conversation not found'); @@ -62,11 +69,12 @@ public function testFinishConversationThrowsWhenUserIsNotOwner(): void $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager->method('find')->willReturn($conversation); - $workspaceFacade = $this->createMock(WorkspaceMgmtFacadeInterface::class); - $accountFacade = $this->createMock(AccountFacadeInterface::class); - $urlService = $this->createConversationUrlService(); + $workspaceFacade = $this->createMock(WorkspaceMgmtFacadeInterface::class); + $accountFacade = $this->createMock(AccountFacadeInterface::class); + $urlService = $this->createConversationUrlService(); + $projectMgmtFacade = $this->createProjectMgmtFacade(); - $service = new ConversationService($entityManager, $workspaceFacade, $accountFacade, $urlService); + $service = new ConversationService($entityManager, $workspaceFacade, $accountFacade, $urlService, $projectMgmtFacade); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Only the conversation owner can perform this action'); @@ -81,11 +89,12 @@ public function testFinishConversationThrowsWhenConversationNotOngoing(): void $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager->method('find')->willReturn($conversation); - $workspaceFacade = $this->createMock(WorkspaceMgmtFacadeInterface::class); - $accountFacade = $this->createMock(AccountFacadeInterface::class); - $urlService = $this->createConversationUrlService(); + $workspaceFacade = $this->createMock(WorkspaceMgmtFacadeInterface::class); + $accountFacade = $this->createMock(AccountFacadeInterface::class); + $urlService = $this->createConversationUrlService(); + $projectMgmtFacade = $this->createProjectMgmtFacade(); - $service = new ConversationService($entityManager, $workspaceFacade, $accountFacade, $urlService); + $service = new ConversationService($entityManager, $workspaceFacade, $accountFacade, $urlService, $projectMgmtFacade); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Conversation is not ongoing'); @@ -109,10 +118,11 @@ public function testFinishConversationCommitsAndTransitionsWorkspace(): void ->method('transitionToAvailableForConversation') ->with('workspace-1'); - $accountFacade = $this->createAccountFacade('user-123', 'user@example.com'); - $urlService = $this->createConversationUrlService(); + $accountFacade = $this->createAccountFacade('user-123', 'user@example.com'); + $urlService = $this->createConversationUrlService(); + $projectMgmtFacade = $this->createProjectMgmtFacade(); - $service = new ConversationService($entityManager, $workspaceFacade, $accountFacade, $urlService); + $service = new ConversationService($entityManager, $workspaceFacade, $accountFacade, $urlService, $projectMgmtFacade); $service->finishConversation('conv-1', 'user-123'); self::assertSame(ConversationStatus::FINISHED, $conversation->getStatus()); @@ -123,11 +133,12 @@ public function testSendToReviewThrowsWhenConversationNotFound(): void $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager->method('find')->willReturn(null); - $workspaceFacade = $this->createMock(WorkspaceMgmtFacadeInterface::class); - $accountFacade = $this->createMock(AccountFacadeInterface::class); - $urlService = $this->createConversationUrlService(); + $workspaceFacade = $this->createMock(WorkspaceMgmtFacadeInterface::class); + $accountFacade = $this->createMock(AccountFacadeInterface::class); + $urlService = $this->createConversationUrlService(); + $projectMgmtFacade = $this->createProjectMgmtFacade(); - $service = new ConversationService($entityManager, $workspaceFacade, $accountFacade, $urlService); + $service = new ConversationService($entityManager, $workspaceFacade, $accountFacade, $urlService, $projectMgmtFacade); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Conversation not found'); @@ -142,11 +153,12 @@ public function testSendToReviewThrowsWhenUserIsNotOwner(): void $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager->method('find')->willReturn($conversation); - $workspaceFacade = $this->createMock(WorkspaceMgmtFacadeInterface::class); - $accountFacade = $this->createMock(AccountFacadeInterface::class); - $urlService = $this->createConversationUrlService(); + $workspaceFacade = $this->createMock(WorkspaceMgmtFacadeInterface::class); + $accountFacade = $this->createMock(AccountFacadeInterface::class); + $urlService = $this->createConversationUrlService(); + $projectMgmtFacade = $this->createProjectMgmtFacade(); - $service = new ConversationService($entityManager, $workspaceFacade, $accountFacade, $urlService); + $service = new ConversationService($entityManager, $workspaceFacade, $accountFacade, $urlService, $projectMgmtFacade); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Only the conversation owner can perform this action'); @@ -161,11 +173,12 @@ public function testSendToReviewThrowsWhenConversationNotOngoing(): void $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager->method('find')->willReturn($conversation); - $workspaceFacade = $this->createMock(WorkspaceMgmtFacadeInterface::class); - $accountFacade = $this->createMock(AccountFacadeInterface::class); - $urlService = $this->createConversationUrlService(); + $workspaceFacade = $this->createMock(WorkspaceMgmtFacadeInterface::class); + $accountFacade = $this->createMock(AccountFacadeInterface::class); + $urlService = $this->createConversationUrlService(); + $projectMgmtFacade = $this->createProjectMgmtFacade(); - $service = new ConversationService($entityManager, $workspaceFacade, $accountFacade, $urlService); + $service = new ConversationService($entityManager, $workspaceFacade, $accountFacade, $urlService, $projectMgmtFacade); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Conversation is not ongoing'); @@ -193,10 +206,11 @@ public function testSendToReviewCommitsTransitionsAndReturnsPrUrl(): void ->with('workspace-1', 'conv-1', 'https://sitebuilder.example.com/conversation/conv-1', 'user@example.com') ->willReturn('https://github.com/org/repo/pull/123'); - $accountFacade = $this->createAccountFacade('user-123', 'user@example.com'); - $urlService = $this->createConversationUrlService(); + $accountFacade = $this->createAccountFacade('user-123', 'user@example.com'); + $urlService = $this->createConversationUrlService(); + $projectMgmtFacade = $this->createProjectMgmtFacade(); - $service = new ConversationService($entityManager, $workspaceFacade, $accountFacade, $urlService); + $service = new ConversationService($entityManager, $workspaceFacade, $accountFacade, $urlService, $projectMgmtFacade); $prUrl = $service->sendToReview('conv-1', 'user-123'); self::assertSame(ConversationStatus::FINISHED, $conversation->getStatus()); @@ -226,10 +240,11 @@ public function testSendToReviewFinishesConversationWhenBranchHasNoDifferences() ->method('transitionToAvailableForConversation') ->with('workspace-1'); - $accountFacade = $this->createAccountFacade('user-123', 'user@example.com'); - $urlService = $this->createConversationUrlService(); + $accountFacade = $this->createAccountFacade('user-123', 'user@example.com'); + $urlService = $this->createConversationUrlService(); + $projectMgmtFacade = $this->createProjectMgmtFacade(); - $service = new ConversationService($entityManager, $workspaceFacade, $accountFacade, $urlService); + $service = new ConversationService($entityManager, $workspaceFacade, $accountFacade, $urlService, $projectMgmtFacade); $prUrl = $service->sendToReview('conv-1', 'user-123'); self::assertSame(ConversationStatus::FINISHED, $conversation->getStatus()); @@ -288,10 +303,11 @@ public function testStartOrResumeConversationReturnsExistingConversationWhenWork // because we found an existing conversation for this user $workspaceFacade->expects($this->never())->method('transitionToInConversation'); - $accountFacade = $this->createMock(AccountFacadeInterface::class); - $urlService = $this->createConversationUrlService(); + $accountFacade = $this->createMock(AccountFacadeInterface::class); + $urlService = $this->createConversationUrlService(); + $projectMgmtFacade = $this->createProjectMgmtFacade(); - $service = new ConversationService($entityManager, $workspaceFacade, $accountFacade, $urlService); + $service = new ConversationService($entityManager, $workspaceFacade, $accountFacade, $urlService, $projectMgmtFacade); $result = $service->startOrResumeConversation('project-1', 'user-123'); self::assertSame('existing-conv-id', $result->id); diff --git a/tests/Unit/CursorAgentContentEditor/CursorAgentContentEditorAdapterTest.php b/tests/Unit/CursorAgentContentEditor/CursorAgentContentEditorAdapterTest.php new file mode 100644 index 0000000..b5a9170 --- /dev/null +++ b/tests/Unit/CursorAgentContentEditor/CursorAgentContentEditorAdapterTest.php @@ -0,0 +1,123 @@ +createMock(WorkspaceToolingServiceInterface::class); + $workspaceTooling->method('getWorkspaceRules')->willReturn('{}'); + + $executionContext = $this->createMock(AgentExecutionContextInterface::class); + + $this->adapter = new CursorAgentContentEditorAdapter($workspaceTooling, $executionContext); + } + + public function testBuildAgentContextDumpIncludesSystemContextOnFirstMessage(): void + { + $config = new AgentConfigDto('Background info', 'Step info', 'Output info'); + + $dump = $this->adapter->buildAgentContextDump('Edit the hero section', [], $config); + + self::assertStringContainsString('CURSOR AGENT CONTEXT', $dump); + self::assertStringContainsString('SYSTEM CONTEXT', $dump); + self::assertStringContainsString('Background info', $dump); + self::assertStringContainsString('Step info', $dump); + self::assertStringContainsString('Output info', $dump); + self::assertStringContainsString('Edit the hero section', $dump); + } + + public function testBuildAgentContextDumpOmitsSystemContextOnFollowUp(): void + { + $config = new AgentConfigDto('Background info', 'Step info', 'Output info'); + $messages = [ + new ConversationMessageDto('user', '{"content":"First instruction"}'), + new ConversationMessageDto('assistant', '{"content":"Done."}'), + ]; + + $dump = $this->adapter->buildAgentContextDump('Follow-up instruction', $messages, $config); + + self::assertStringNotContainsString('SYSTEM CONTEXT', $dump); + self::assertStringContainsString('CONVERSATION HISTORY', $dump); + self::assertStringContainsString('Follow-up instruction', $dump); + } + + public function testBuildAgentContextDumpIncludesConversationHistory(): void + { + $config = new AgentConfigDto('', '', ''); + $messages = [ + new ConversationMessageDto('user', '{"content":"Make the title bigger"}'), + new ConversationMessageDto('assistant', '{"content":"I updated the title."}'), + ]; + + $dump = $this->adapter->buildAgentContextDump('Now change the color', $messages, $config); + + self::assertStringContainsString('Conversation so far:', $dump); + self::assertStringContainsString('Make the title bigger', $dump); + self::assertStringContainsString('I updated the title.', $dump); + } + + public function testBuildAgentContextDumpIncludesWorkspaceFolderInSystemContext(): void + { + $config = new AgentConfigDto('', '', ''); + + $dump = $this->adapter->buildAgentContextDump('Some task', [], $config); + + self::assertStringContainsString('The working folder is: /workspace', $dump); + } + + public function testBuildAgentContextDumpIncludesBuildSyncInstruction(): void + { + $config = new AgentConfigDto('', '', ''); + + $dump = $this->adapter->buildAgentContextDump('Some task', [], $config); + + self::assertStringContainsString('npm run build', $dump); + self::assertStringContainsString('Keep Source and Dist in Sync', $dump); + } + + public function testBuildAgentContextDumpSkipsEmptyInstructions(): void + { + $config = new AgentConfigDto('', '', ''); + + $dump = $this->adapter->buildAgentContextDump('Some task', [], $config); + + self::assertStringNotContainsString('Background Instructions', $dump); + self::assertStringNotContainsString('Step-by-Step Instructions', $dump); + self::assertStringNotContainsString('Output Instructions', $dump); + } + + public function testGetBackendModelInfoReturnsCursorDefaults(): void + { + $info = $this->adapter->getBackendModelInfo(); + + self::assertSame('cursor-agent', $info->modelName); + self::assertSame(200_000, $info->maxContextTokens); + self::assertNull($info->inputCostPer1M); + self::assertNull($info->outputCostPer1M); + } + + public function testBuildAgentContextDumpOnlyIncludesNonEmptyInstructionSections(): void + { + $config = new AgentConfigDto('', 'Do step one then step two', ''); + + $dump = $this->adapter->buildAgentContextDump('Some task', [], $config); + + self::assertStringNotContainsString('Background Instructions', $dump); + self::assertStringContainsString('Step-by-Step Instructions', $dump); + self::assertStringContainsString('Do step one then step two', $dump); + self::assertStringNotContainsString('Output Instructions', $dump); + } +} diff --git a/tests/Unit/LlmContentEditor/ChatHistory/MessageSerializerTest.php b/tests/Unit/LlmContentEditor/ChatHistory/MessageSerializerTest.php index f6558ec..57b4cfe 100644 --- a/tests/Unit/LlmContentEditor/ChatHistory/MessageSerializerTest.php +++ b/tests/Unit/LlmContentEditor/ChatHistory/MessageSerializerTest.php @@ -4,7 +4,7 @@ namespace App\Tests\Unit\LlmContentEditor\ChatHistory; -use App\LlmContentEditor\Facade\Dto\ConversationMessageDto; +use App\AgenticContentEditor\Facade\Dto\ConversationMessageDto; use App\LlmContentEditor\Infrastructure\ChatHistory\MessageSerializer; use NeuronAI\Chat\Messages\AssistantMessage; use PHPUnit\Framework\TestCase; diff --git a/tests/Unit/LlmContentEditor/ContentEditorAgentTest.php b/tests/Unit/LlmContentEditor/ContentEditorAgentTest.php index 1240882..9ca79cd 100644 --- a/tests/Unit/LlmContentEditor/ContentEditorAgentTest.php +++ b/tests/Unit/LlmContentEditor/ContentEditorAgentTest.php @@ -4,9 +4,9 @@ namespace App\Tests\Unit\LlmContentEditor; +use App\AgenticContentEditor\Facade\Dto\AgentConfigDto; use App\LlmContentEditor\Domain\Agent\ContentEditorAgent; use App\LlmContentEditor\Domain\Enum\LlmModelName; -use App\LlmContentEditor\Facade\Dto\AgentConfigDto; use App\LlmContentEditor\Infrastructure\ChatHistory\CallbackChatHistory; use App\ProjectMgmt\Domain\ValueObject\AgentConfigTemplate; use App\ProjectMgmt\Facade\Enum\ProjectType; diff --git a/tests/Unit/LlmContentEditor/ProgressMessageResolverTest.php b/tests/Unit/LlmContentEditor/ProgressMessageResolverTest.php index b3a27c2..633235b 100644 --- a/tests/Unit/LlmContentEditor/ProgressMessageResolverTest.php +++ b/tests/Unit/LlmContentEditor/ProgressMessageResolverTest.php @@ -4,8 +4,8 @@ namespace App\Tests\Unit\LlmContentEditor; -use App\LlmContentEditor\Facade\Dto\AgentEventDto; -use App\LlmContentEditor\Facade\Dto\ToolInputEntryDto; +use App\AgenticContentEditor\Facade\Dto\AgentEventDto; +use App\AgenticContentEditor\Facade\Dto\ToolInputEntryDto; use App\LlmContentEditor\Infrastructure\ProgressMessageResolver; use PHPUnit\Framework\TestCase; use Symfony\Contracts\Translation\TranslatorInterface; diff --git a/tests/Unit/ProjectMgmt/ProjectServiceTest.php b/tests/Unit/ProjectMgmt/ProjectServiceTest.php index c74ba00..75f1da2 100644 --- a/tests/Unit/ProjectMgmt/ProjectServiceTest.php +++ b/tests/Unit/ProjectMgmt/ProjectServiceTest.php @@ -4,9 +4,11 @@ namespace Tests\Unit\ProjectMgmt; +use App\AgenticContentEditor\Facade\Enum\AgenticContentEditorBackend; use App\LlmContentEditor\Facade\Enum\LlmModelProvider; use App\ProjectMgmt\Domain\Entity\Project; use App\ProjectMgmt\Domain\Service\ProjectService; +use App\ProjectMgmt\Facade\Enum\ProjectType; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\TestCase; use ReflectionClass; @@ -52,7 +54,8 @@ public function testCreateStoresRemoteContentAssetsManifestUrlsWhenProvided(): v 'token', LlmModelProvider::OpenAI, 'sk-key', - \App\ProjectMgmt\Facade\Enum\ProjectType::DEFAULT, + ProjectType::DEFAULT, + AgenticContentEditorBackend::Llm, 'node:22-slim', null, null, diff --git a/translations/messages.de.yaml b/translations/messages.de.yaml index 0068e40..a7385b7 100644 --- a/translations/messages.de.yaml +++ b/translations/messages.de.yaml @@ -104,6 +104,10 @@ project: llm_api_key: "LLM-API-Schlüssel" placeholder_llm_api_key: "sk-..." llm_api_key_help: "Ihr API-Schlüssel für den ausgewählten LLM-Anbieter." + content_editor_backend: "Inhaltseditor-Backend" + content_editor_backend_help: "Wählen Sie, welcher Agent die Änderungen ausführt." + content_editor_backend_llm: "LLM (NeuronAI)" + content_editor_backend_cursor_agent: "Cursor Agent" verifying_key: "Schlüssel wird überprüft..." key_verified: "Schlüssel erfolgreich überprüft" key_verification_failed: "Schlüsselüberprüfung fehlgeschlagen" @@ -353,6 +357,7 @@ flash: invalid_csrf: "Ungültiges CSRF-Token." all_fields_required: "Alle Felder sind erforderlich." select_llm_provider: "Bitte wählen Sie einen LLM-Modellanbieter." + select_content_editor_backend: "Bitte wählen Sie ein Inhaltseditor-Backend." invalid_docker_image: "Ungültiges Docker-Image-Format. Erwartetes Format: name:tag" no_workspace: "Für dieses Projekt existiert kein Arbeitsbereich." workspace_reset_failed: "Arbeitsbereich konnte nicht zurückgesetzt werden: %error%" diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 17447c7..89945ac 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -101,9 +101,18 @@ project: placeholder_github_token: "ghp_xxxxxxxxxxxx" github_access_key_help: "A personal access token so we can open the project and save changes." llm_provider: "LLM Model Provider" + cursor_provider: "Cursor Model Provider" + cursor_provider_auto: "Auto" llm_api_key: "LLM API Key" + cursor_api_key: "Cursor API Key" placeholder_llm_api_key: "sk-..." + placeholder_cursor_api_key: "key-..." llm_api_key_help: "Your API key for the selected LLM provider." + cursor_api_key_help: "Your API key for the Cursor agent." + content_editor_backend: "Content editor agent" + content_editor_backend_help: "Choose which agent runs edits for this project." + content_editor_backend_llm: "LLM (NeuronAI)" + content_editor_backend_cursor_agent: "Cursor Agent" verifying_key: "Verifying key..." key_verified: "Key successfully verified" key_verification_failed: "Key verification failed" @@ -353,6 +362,7 @@ flash: invalid_csrf: "Invalid CSRF token." all_fields_required: "All fields are required." select_llm_provider: "Please select an LLM model provider." + select_content_editor_backend: "Please select a content editor backend." invalid_docker_image: "Invalid Docker image format. Expected format: name:tag" no_workspace: "No workspace exists for this project." workspace_reset_failed: "Failed to reset workspace: %error%"