From b29acd51e9339263e1dde3c5636ab13d3ca4e87b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Kie=C3=9Fling?= Date: Tue, 27 Jan 2026 11:02:17 +0100 Subject: [PATCH 1/7] Adding cursor agent as alternative --- .env | 2 + .mise/tasks/setup.sh | 2 +- config/services.yaml | 15 + docker/app/Dockerfile | 3 + .../Domain/Entity/Conversation.php | 40 +- .../Domain/Enum/ContentEditorBackend.php | 11 + .../Domain/Service/ConversationService.php | 9 +- .../ContentEditorAdapterInterface.php | 32 ++ .../ContentEditor/ContentEditorFacade.php | 75 ++++ .../ContentEditorFacadeInterface.php | 31 ++ .../CursorAgentContentEditorAdapter.php | 53 +++ .../ContentEditor/LlmContentEditorAdapter.php | 52 +++ .../Handler/RunEditSessionHandler.php | 21 +- .../Domain/Agent/ContentEditorAgent.php | 45 +++ .../Domain/Command/EditContentCommand.php | 130 ++++++ .../Facade/CursorAgentContentEditorFacade.php | 116 ++++++ ...ursorAgentContentEditorFacadeInterface.php | 31 ++ .../Observer/ConsoleObserver.php | 377 ++++++++++++++++++ .../Streaming/CursorAgentStreamCollector.php | 329 +++++++++++++++ src/ProjectMgmt/Domain/Entity/Project.php | 21 + .../Domain/Service/ProjectService.php | 5 + src/ProjectMgmt/Facade/Dto/ProjectInfoDto.php | 2 + .../Facade/Enum/ContentEditorBackend.php | 11 + src/ProjectMgmt/Facade/ProjectMgmtFacade.php | 1 + .../Controller/ProjectController.php | 19 + .../Resources/templates/project_form.twig | 16 + .../Facade/AgentExecutionContextInterface.php | 12 + .../Execution/AgentExecutionContext.php | 19 + .../Execution/DockerExecutor.php | 26 +- .../Execution/IsolatedShellExecutor.php | 5 +- translations/messages.de.yaml | 5 + translations/messages.en.yaml | 5 + 32 files changed, 1502 insertions(+), 19 deletions(-) create mode 100644 src/ChatBasedContentEditor/Domain/Enum/ContentEditorBackend.php create mode 100644 src/ChatBasedContentEditor/Infrastructure/ContentEditor/ContentEditorAdapterInterface.php create mode 100644 src/ChatBasedContentEditor/Infrastructure/ContentEditor/ContentEditorFacade.php create mode 100644 src/ChatBasedContentEditor/Infrastructure/ContentEditor/ContentEditorFacadeInterface.php create mode 100644 src/ChatBasedContentEditor/Infrastructure/ContentEditor/CursorAgentContentEditorAdapter.php create mode 100644 src/ChatBasedContentEditor/Infrastructure/ContentEditor/LlmContentEditorAdapter.php create mode 100644 src/CursorAgentContentEditor/Domain/Agent/ContentEditorAgent.php create mode 100644 src/CursorAgentContentEditor/Domain/Command/EditContentCommand.php create mode 100644 src/CursorAgentContentEditor/Facade/CursorAgentContentEditorFacade.php create mode 100644 src/CursorAgentContentEditor/Facade/CursorAgentContentEditorFacadeInterface.php create mode 100644 src/CursorAgentContentEditor/Infrastructure/Observer/ConsoleObserver.php create mode 100644 src/CursorAgentContentEditor/Infrastructure/Streaming/CursorAgentStreamCollector.php create mode 100644 src/ProjectMgmt/Facade/Enum/ContentEditorBackend.php diff --git a/.env b/.env index 01e83f1..5909b9c 100644 --- a/.env +++ b/.env @@ -60,6 +60,8 @@ LOCK_DSN="${DATABASE_URL}" LLM_CONTENT_EDITOR_OPENAI_API_KEY=your-key-here +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/.mise/tasks/setup.sh b/.mise/tasks/setup.sh index 7c2dc52..d5a575b 100755 --- a/.mise/tasks/setup.sh +++ b/.mise/tasks/setup.sh @@ -4,7 +4,7 @@ set -e if [ ! -f .env.local ]; then - echo "HOST_PROJECT_PATH=$(pwd)" > .env.local + echo "HOST_PROJECT_PATH=\"$(pwd)\"" > .env.local fi HOST_PROJECT_PATH="${HOST_PROJECT_PATH:-$(pwd)}" /usr/bin/env docker compose up --build -d diff --git a/config/services.yaml b/config/services.yaml index a7fb5b7..b958908 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -104,6 +104,21 @@ services: App\ChatBasedContentEditor\Facade\ChatBasedContentEditorFacadeInterface: class: App\ChatBasedContentEditor\Facade\ChatBasedContentEditorFacade + App\CursorAgentContentEditor\Facade\CursorAgentContentEditorFacadeInterface: + class: App\CursorAgentContentEditor\Facade\CursorAgentContentEditorFacade + + App\ChatBasedContentEditor\Infrastructure\ContentEditor\LlmContentEditorAdapter: ~ + + App\ChatBasedContentEditor\Infrastructure\ContentEditor\CursorAgentContentEditorAdapter: ~ + + App\ChatBasedContentEditor\Infrastructure\ContentEditor\ContentEditorFacadeInterface: + class: App\ChatBasedContentEditor\Infrastructure\ContentEditor\ContentEditorFacade + arguments: + - [ + '@App\ChatBasedContentEditor\Infrastructure\ContentEditor\LlmContentEditorAdapter', + '@App\ChatBasedContentEditor\Infrastructure\ContentEditor\CursorAgentContentEditorAdapter' + ] + # Domain service bindings App\WorkspaceMgmt\Domain\Service\WorkspaceStatusGuardInterface: class: App\WorkspaceMgmt\Domain\Service\WorkspaceStatusGuard 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/src/ChatBasedContentEditor/Domain/Entity/Conversation.php b/src/ChatBasedContentEditor/Domain/Entity/Conversation.php index 8977926..ea80c31 100644 --- a/src/ChatBasedContentEditor/Domain/Entity/Conversation.php +++ b/src/ChatBasedContentEditor/Domain/Entity/Conversation.php @@ -4,6 +4,7 @@ namespace App\ChatBasedContentEditor\Domain\Entity; +use App\ChatBasedContentEditor\Domain\Enum\ContentEditorBackend; use App\ChatBasedContentEditor\Domain\Enum\ConversationStatus; use DateTimeImmutable; use Doctrine\Common\Collections\ArrayCollection; @@ -27,12 +28,14 @@ class Conversation public function __construct( string $workspaceId, string $userId, - string $workspacePath + string $workspacePath, + ContentEditorBackend $contentEditorBackend = ContentEditorBackend::Llm ) { $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(); @@ -104,6 +107,24 @@ public function getWorkspacePath(): string return $this->workspacePath; } + #[ORM\Column( + type: Types::STRING, + length: 32, + nullable: false, + enumType: ContentEditorBackend::class + )] + private ContentEditorBackend $contentEditorBackend = ContentEditorBackend::Llm; + + public function getContentEditorBackend(): ContentEditorBackend + { + return $this->contentEditorBackend; + } + + public function setContentEditorBackend(ContentEditorBackend $contentEditorBackend): void + { + $this->contentEditorBackend = $contentEditorBackend; + } + #[ORM\Column( type: Types::DATETIME_IMMUTABLE, nullable: false @@ -136,6 +157,23 @@ public function updateLastActivity(): void $this->lastActivityAt = DateAndTimeService::getDateTimeImmutable(); } + #[ORM\Column( + type: Types::STRING, + length: 64, + nullable: true + )] + private ?string $cursorAgentSessionId = null; + + public function getCursorAgentSessionId(): ?string + { + return $this->cursorAgentSessionId; + } + + public function setCursorAgentSessionId(?string $cursorAgentSessionId): void + { + $this->cursorAgentSessionId = $cursorAgentSessionId; + } + /** * @var Collection */ diff --git a/src/ChatBasedContentEditor/Domain/Enum/ContentEditorBackend.php b/src/ChatBasedContentEditor/Domain/Enum/ContentEditorBackend.php new file mode 100644 index 0000000..5ccc713 --- /dev/null +++ b/src/ChatBasedContentEditor/Domain/Enum/ContentEditorBackend.php @@ -0,0 +1,11 @@ +toDto($existingConversation); } + $projectInfo = $this->projectMgmtFacade->getProjectInfo($projectId); + $contentEditorBackend = ContentEditorBackend::from($projectInfo->contentEditorBackend->value); + // Transition workspace to IN_CONVERSATION $this->workspaceMgmtFacade->transitionToInConversation($workspaceInfo->id); @@ -55,7 +61,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/ContentEditor/ContentEditorAdapterInterface.php b/src/ChatBasedContentEditor/Infrastructure/ContentEditor/ContentEditorAdapterInterface.php new file mode 100644 index 0000000..9705b61 --- /dev/null +++ b/src/ChatBasedContentEditor/Infrastructure/ContentEditor/ContentEditorAdapterInterface.php @@ -0,0 +1,32 @@ + $previousMessages + * + * @return Generator + */ + public function streamEditWithHistory( + string $workspacePath, + string $instruction, + array $previousMessages, + string $apiKey, + ?AgentConfigDto $agentConfig = null, + ?string $cursorAgentSessionId = null + ): Generator; + + public function getLastCursorAgentSessionId(): ?string; +} diff --git a/src/ChatBasedContentEditor/Infrastructure/ContentEditor/ContentEditorFacade.php b/src/ChatBasedContentEditor/Infrastructure/ContentEditor/ContentEditorFacade.php new file mode 100644 index 0000000..e77a783 --- /dev/null +++ b/src/ChatBasedContentEditor/Infrastructure/ContentEditor/ContentEditorFacade.php @@ -0,0 +1,75 @@ + $adapters + */ + public function __construct( + private readonly array $adapters + ) { + } + + /** + * @param list $previousMessages + * + * @return Generator + */ + public function streamEditWithHistory( + ContentEditorBackend $backend, + string $workspacePath, + string $instruction, + array $previousMessages, + string $apiKey, + ?AgentConfigDto $agentConfig = null, + ?string $cursorAgentSessionId = null + ): Generator { + $adapter = $this->resolveAdapter($backend); + + $generator = $adapter->streamEditWithHistory( + $workspacePath, + $instruction, + $previousMessages, + $apiKey, + $agentConfig, + $cursorAgentSessionId + ); + + try { + foreach ($generator as $chunk) { + yield $chunk; + } + } finally { + $this->lastCursorAgentSessionId = $adapter->getLastCursorAgentSessionId(); + } + } + + public function getLastCursorAgentSessionId(): ?string + { + return $this->lastCursorAgentSessionId; + } + + private function resolveAdapter(ContentEditorBackend $backend): ContentEditorAdapterInterface + { + 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/ChatBasedContentEditor/Infrastructure/ContentEditor/ContentEditorFacadeInterface.php b/src/ChatBasedContentEditor/Infrastructure/ContentEditor/ContentEditorFacadeInterface.php new file mode 100644 index 0000000..9ccebbb --- /dev/null +++ b/src/ChatBasedContentEditor/Infrastructure/ContentEditor/ContentEditorFacadeInterface.php @@ -0,0 +1,31 @@ + $previousMessages + * + * @return Generator + */ + public function streamEditWithHistory( + ContentEditorBackend $backend, + string $workspacePath, + string $instruction, + array $previousMessages, + string $apiKey, + ?AgentConfigDto $agentConfig = null, + ?string $cursorAgentSessionId = null + ): Generator; + + public function getLastCursorAgentSessionId(): ?string; +} diff --git a/src/ChatBasedContentEditor/Infrastructure/ContentEditor/CursorAgentContentEditorAdapter.php b/src/ChatBasedContentEditor/Infrastructure/ContentEditor/CursorAgentContentEditorAdapter.php new file mode 100644 index 0000000..5c0782c --- /dev/null +++ b/src/ChatBasedContentEditor/Infrastructure/ContentEditor/CursorAgentContentEditorAdapter.php @@ -0,0 +1,53 @@ + $previousMessages + * + * @return Generator + */ + public function streamEditWithHistory( + string $workspacePath, + string $instruction, + array $previousMessages, + string $apiKey, + ?AgentConfigDto $agentConfig = null, + ?string $cursorAgentSessionId = null + ): Generator { + return $this->cursorAgentContentEditorFacade->streamEditWithHistory( + $workspacePath, + $instruction, + $previousMessages, + $apiKey, + $agentConfig, + $cursorAgentSessionId + ); + } + + public function getLastCursorAgentSessionId(): ?string + { + return $this->cursorAgentContentEditorFacade->getLastSessionId(); + } +} diff --git a/src/ChatBasedContentEditor/Infrastructure/ContentEditor/LlmContentEditorAdapter.php b/src/ChatBasedContentEditor/Infrastructure/ContentEditor/LlmContentEditorAdapter.php new file mode 100644 index 0000000..bff5701 --- /dev/null +++ b/src/ChatBasedContentEditor/Infrastructure/ContentEditor/LlmContentEditorAdapter.php @@ -0,0 +1,52 @@ + $previousMessages + * + * @return Generator + */ + public function streamEditWithHistory( + string $workspacePath, + string $instruction, + array $previousMessages, + string $apiKey, + ?AgentConfigDto $agentConfig = null, + ?string $cursorAgentSessionId = null + ): Generator { + return $this->llmContentEditorFacade->streamEditWithHistory( + $workspacePath, + $instruction, + $previousMessages, + $apiKey, + $agentConfig + ); + } + + public function getLastCursorAgentSessionId(): ?string + { + return null; + } +} diff --git a/src/ChatBasedContentEditor/Infrastructure/Handler/RunEditSessionHandler.php b/src/ChatBasedContentEditor/Infrastructure/Handler/RunEditSessionHandler.php index 4ea78cc..4b25f97 100644 --- a/src/ChatBasedContentEditor/Infrastructure/Handler/RunEditSessionHandler.php +++ b/src/ChatBasedContentEditor/Infrastructure/Handler/RunEditSessionHandler.php @@ -9,15 +9,16 @@ use App\ChatBasedContentEditor\Domain\Entity\ConversationMessage; use App\ChatBasedContentEditor\Domain\Entity\EditSession; use App\ChatBasedContentEditor\Domain\Entity\EditSessionChunk; +use App\ChatBasedContentEditor\Domain\Enum\ContentEditorBackend; use App\ChatBasedContentEditor\Domain\Enum\ConversationMessageRole; use App\ChatBasedContentEditor\Domain\Enum\EditSessionStatus; +use App\ChatBasedContentEditor\Infrastructure\ContentEditor\ContentEditorFacadeInterface; 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\LlmContentEditorFacadeInterface; use App\ProjectMgmt\Facade\ProjectMgmtFacadeInterface; use App\WorkspaceMgmt\Facade\WorkspaceMgmtFacadeInterface; use App\WorkspaceTooling\Facade\AgentExecutionContextInterface; @@ -36,7 +37,7 @@ { public function __construct( private EntityManagerInterface $entityManager, - private LlmContentEditorFacadeInterface $facade, + private ContentEditorFacadeInterface $facade, private LoggerInterface $logger, private WorkspaceMgmtFacadeInterface $workspaceMgmtFacade, private ProjectMgmtFacadeInterface $projectMgmtFacade, @@ -70,12 +71,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(); @@ -98,11 +99,13 @@ public function __invoke(RunEditSessionMessage $message): void ); $generator = $this->facade->streamEditWithHistory( + $conversation->getContentEditorBackend(), $session->getWorkspacePath(), $session->getInstruction(), $previousMessages, $project->llmApiKey, - $agentConfig + $agentConfig, + $conversation->getCursorAgentSessionId() ); foreach ($generator as $chunk) { @@ -128,6 +131,14 @@ public function __invoke(RunEditSessionMessage $message): void $session->setStatus(EditSessionStatus::Completed); $this->entityManager->flush(); + if ($conversation->getContentEditorBackend() === ContentEditorBackend::CursorAgent) { + $sessionId = $this->facade->getLastCursorAgentSessionId(); + if ($sessionId !== null && $sessionId !== $conversation->getCursorAgentSessionId()) { + $conversation->setCursorAgentSessionId($sessionId); + $this->entityManager->flush(); + } + } + // Commit and push changes after successful edit session $this->commitChangesAfterEdit($conversation, $session); } catch (Throwable $e) { diff --git a/src/CursorAgentContentEditor/Domain/Agent/ContentEditorAgent.php b/src/CursorAgentContentEditor/Domain/Agent/ContentEditorAgent.php new file mode 100644 index 0000000..de1183d --- /dev/null +++ b/src/CursorAgentContentEditor/Domain/Agent/ContentEditorAgent.php @@ -0,0 +1,45 @@ +workspaceToolingFacade->runShellCommand($workingDirectory, $command); + } +} 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/Facade/CursorAgentContentEditorFacade.php b/src/CursorAgentContentEditor/Facade/CursorAgentContentEditorFacade.php new file mode 100644 index 0000000..392e039 --- /dev/null +++ b/src/CursorAgentContentEditor/Facade/CursorAgentContentEditorFacade.php @@ -0,0 +1,116 @@ + $previousMessages + * + * @return Generator + */ + public function streamEditWithHistory( + string $workspacePath, + string $instruction, + array $previousMessages, + string $apiKey, + ?AgentConfigDto $agentConfig = null, + ?string $cursorAgentSessionId = null + ): Generator { + $this->lastSessionId = null; + $collector = new CursorAgentStreamCollector(); + + $this->executionContext->setOutputCallback($collector); + + try { + $prompt = $this->buildPrompt($instruction, $previousMessages, $cursorAgentSessionId === null); + + $agent = new ContentEditorAgent($this->workspaceTooling); + $agent->run('/workspace', $prompt, $apiKey, $cursorAgentSessionId); + + $this->lastSessionId = $collector->getLastSessionId(); + + foreach ($collector->drain() as $chunk) { + yield $chunk; + } + + yield new EditStreamChunkDto( + 'done', + null, + null, + $collector->isSuccess(), + $collector->getErrorMessage() + ); + } catch (Throwable $e) { + yield new EditStreamChunkDto('done', null, null, false, $e->getMessage()); + } finally { + $this->executionContext->setOutputCallback(null); + } + } + + public function getLastSessionId(): ?string + { + return $this->lastSessionId; + } + + /** + * @param list $previousMessages + */ + private function buildPrompt(string $instruction, array $previousMessages, bool $includeWorkspaceContext): string + { + if ($previousMessages === []) { + return $this->wrapInstruction($instruction, $includeWorkspaceContext); + } + + $history = $this->formatHistory($previousMessages); + + return $this->wrapInstruction($history . "\n\n" . 'User: ' . $instruction, $includeWorkspaceContext); + } + + private function wrapInstruction(string $instruction, bool $includeWorkspaceContext): string + { + if ($includeWorkspaceContext) { + return sprintf( + 'The working folder is: %s' . "\n\n" . 'Please perform the following task: %s', + '/workspace', + $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/Facade/CursorAgentContentEditorFacadeInterface.php b/src/CursorAgentContentEditor/Facade/CursorAgentContentEditorFacadeInterface.php new file mode 100644 index 0000000..e458003 --- /dev/null +++ b/src/CursorAgentContentEditor/Facade/CursorAgentContentEditorFacadeInterface.php @@ -0,0 +1,31 @@ + $previousMessages + * + * @return Generator + */ + public function streamEditWithHistory( + string $workspacePath, + string $instruction, + array $previousMessages, + string $apiKey, + ?AgentConfigDto $agentConfig = null, + ?string $cursorAgentSessionId = null + ): Generator; + + public function getLastSessionId(): ?string; +} 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..d9794c2 --- /dev/null +++ b/src/CursorAgentContentEditor/Infrastructure/Streaming/CursorAgentStreamCollector.php @@ -0,0 +1,329 @@ + + */ + private SplQueue $chunks; + + private bool $thinkingStarted = false; + + private bool $resultSuccess = true; + + private ?string $resultErrorMessage = null; + + private ?string $lastSessionId = null; + + private string $assistantBuffer = ''; + + 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 { + /** @var mixed $decoded */ + $decoded = json_decode($line, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException) { + continue; + } + + if (!is_array($decoded)) { + continue; + } + + $this->captureSessionId($decoded); + $this->handleEvent($decoded); + } + } + + /** + * @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; + $this->enqueueEvent(new AgentEventDto('inference_start')); + } + + return; + } + + if ($subtype === 'completed') { + $this->flushThinking(); + } + } + + private function flushThinking(): void + { + if (!$this->thinkingStarted) { + return; + } + + $this->thinkingStarted = false; + $this->enqueueEvent(new AgentEventDto('inference_stop')); + } + + /** + * @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)); + } + } + + /** + * @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)) { + $text = $item['text']; + $this->assistantBuffer .= $text; + $this->chunks->enqueue(new EditStreamChunkDto('text', $text)); + } + } + } + + /** + * @param array $event + */ + private function handleResult(array $event, ?string $subtype): void + { + if ($subtype === 'success') { + $this->resultSuccess = true; + $this->resultErrorMessage = null; + + $result = $event['result'] ?? null; + if (is_string($result) && $result !== '' && $this->assistantBuffer === '') { + $this->chunks->enqueue(new EditStreamChunkDto('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('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) . '...'; + } +} diff --git a/src/ProjectMgmt/Domain/Entity/Project.php b/src/ProjectMgmt/Domain/Entity/Project.php index 77aeb9b..342b8ea 100644 --- a/src/ProjectMgmt/Domain/Entity/Project.php +++ b/src/ProjectMgmt/Domain/Entity/Project.php @@ -6,6 +6,7 @@ use App\LlmContentEditor\Facade\Enum\LlmModelProvider; use App\ProjectMgmt\Domain\ValueObject\AgentConfigTemplate; +use App\ProjectMgmt\Facade\Enum\ContentEditorBackend; use App\ProjectMgmt\Facade\Enum\ProjectType; use DateTimeImmutable; use Doctrine\DBAL\Types\Types; @@ -30,6 +31,7 @@ public function __construct( LlmModelProvider $llmModelProvider, string $llmApiKey, ProjectType $projectType = ProjectType::DEFAULT, + ContentEditorBackend $contentEditorBackend = ContentEditorBackend::Llm, string $agentImage = self::DEFAULT_AGENT_IMAGE, ?string $agentBackgroundInstructions = null, ?string $agentStepInstructions = null, @@ -41,6 +43,7 @@ public function __construct( $this->llmModelProvider = $llmModelProvider; $this->llmApiKey = $llmApiKey; $this->projectType = $projectType; + $this->contentEditorBackend = $contentEditorBackend; $this->agentImage = $agentImage; $this->createdAt = DateAndTimeService::getDateTimeImmutable(); @@ -134,6 +137,24 @@ public function setProjectType(ProjectType $projectType): void $this->projectType = $projectType; } + #[ORM\Column( + type: Types::STRING, + length: 32, + nullable: false, + enumType: ContentEditorBackend::class + )] + private ContentEditorBackend $contentEditorBackend = ContentEditorBackend::Llm; + + public function getContentEditorBackend(): ContentEditorBackend + { + return $this->contentEditorBackend; + } + + public function setContentEditorBackend(ContentEditorBackend $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 c6bb1a6..9216977 100644 --- a/src/ProjectMgmt/Domain/Service/ProjectService.php +++ b/src/ProjectMgmt/Domain/Service/ProjectService.php @@ -6,6 +6,7 @@ use App\LlmContentEditor\Facade\Enum\LlmModelProvider; use App\ProjectMgmt\Domain\Entity\Project; +use App\ProjectMgmt\Facade\Enum\ContentEditorBackend; use App\ProjectMgmt\Facade\Enum\ProjectType; use Doctrine\ORM\EntityManagerInterface; @@ -27,6 +28,7 @@ public function create( LlmModelProvider $llmModelProvider, string $llmApiKey, ProjectType $projectType = ProjectType::DEFAULT, + ContentEditorBackend $contentEditorBackend = ContentEditorBackend::Llm, string $agentImage = Project::DEFAULT_AGENT_IMAGE, ?string $agentBackgroundInstructions = null, ?string $agentStepInstructions = null, @@ -39,6 +41,7 @@ public function create( $llmModelProvider, $llmApiKey, $projectType, + $contentEditorBackend, $agentImage, $agentBackgroundInstructions, $agentStepInstructions, @@ -58,6 +61,7 @@ public function update( LlmModelProvider $llmModelProvider, string $llmApiKey, ProjectType $projectType = ProjectType::DEFAULT, + ContentEditorBackend $contentEditorBackend = ContentEditorBackend::Llm, string $agentImage = Project::DEFAULT_AGENT_IMAGE, ?string $agentBackgroundInstructions = null, ?string $agentStepInstructions = null, @@ -69,6 +73,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 f6a430d..07549b3 100644 --- a/src/ProjectMgmt/Facade/Dto/ProjectInfoDto.php +++ b/src/ProjectMgmt/Facade/Dto/ProjectInfoDto.php @@ -5,6 +5,7 @@ namespace App\ProjectMgmt\Facade\Dto; use App\LlmContentEditor\Facade\Enum\LlmModelProvider; +use App\ProjectMgmt\Facade\Enum\ContentEditorBackend; use App\ProjectMgmt\Facade\Enum\ProjectType; final readonly class ProjectInfoDto @@ -15,6 +16,7 @@ public function __construct( public string $gitUrl, public string $githubToken, public ProjectType $projectType, + public ContentEditorBackend $contentEditorBackend, public string $githubUrl, public string $agentImage, public LlmModelProvider $llmModelProvider, diff --git a/src/ProjectMgmt/Facade/Enum/ContentEditorBackend.php b/src/ProjectMgmt/Facade/Enum/ContentEditorBackend.php new file mode 100644 index 0000000..0b83427 --- /dev/null +++ b/src/ProjectMgmt/Facade/Enum/ContentEditorBackend.php @@ -0,0 +1,11 @@ +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 40ab9ec..eb51609 100644 --- a/src/ProjectMgmt/Presentation/Controller/ProjectController.php +++ b/src/ProjectMgmt/Presentation/Controller/ProjectController.php @@ -10,6 +10,7 @@ use App\LlmContentEditor\Facade\LlmContentEditorFacadeInterface; use App\ProjectMgmt\Domain\Service\ProjectService; use App\ProjectMgmt\Facade\Dto\ExistingLlmApiKeyDto; +use App\ProjectMgmt\Facade\Enum\ContentEditorBackend; use App\ProjectMgmt\Facade\Enum\ProjectType; use App\ProjectMgmt\Facade\ProjectMgmtFacadeInterface; use App\WorkspaceMgmt\Facade\Enum\WorkspaceStatus; @@ -111,6 +112,7 @@ public function new(): Response return $this->render('@project_mgmt.presentation/project_form.twig', [ 'project' => null, 'llmProviders' => LlmModelProvider::cases(), + 'contentEditorBackends' => ContentEditorBackend::cases(), 'existingLlmKeys' => $this->projectMgmtFacade->getExistingLlmApiKeys(), 'agentConfigTemplate' => $defaultTemplate, ]); @@ -133,6 +135,7 @@ public function create(Request $request): Response $gitUrl = $request->request->getString('git_url'); $githubToken = $request->request->getString('github_token'); $llmModelProvider = LlmModelProvider::tryFrom($request->request->getString('llm_model_provider')); + $contentEditorBackend = ContentEditorBackend::tryFrom($request->request->getString('content_editor_backend')); $llmApiKey = $request->request->getString('llm_api_key'); $agentImage = $this->resolveAgentImage($request); @@ -153,6 +156,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')); @@ -166,6 +175,7 @@ public function create(Request $request): Response $llmModelProvider, $llmApiKey, ProjectType::DEFAULT, + $contentEditorBackend, $agentImage, $agentBackgroundInstructions, $agentStepInstructions, @@ -203,6 +213,7 @@ public function edit(string $id): Response return $this->render('@project_mgmt.presentation/project_form.twig', [ 'project' => $project, 'llmProviders' => LlmModelProvider::cases(), + 'contentEditorBackends' => ContentEditorBackend::cases(), 'existingLlmKeys' => $existingLlmKeys, 'agentConfigTemplate' => $agentConfigTemplate, ]); @@ -232,6 +243,7 @@ public function update(string $id, Request $request): Response $gitUrl = $request->request->getString('git_url'); $githubToken = $request->request->getString('github_token'); $llmModelProvider = LlmModelProvider::tryFrom($request->request->getString('llm_model_provider')); + $contentEditorBackend = ContentEditorBackend::tryFrom($request->request->getString('content_editor_backend')); $llmApiKey = $request->request->getString('llm_api_key'); $agentImage = $this->resolveAgentImage($request); @@ -252,6 +264,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')); @@ -266,6 +284,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 239185f..ba783e0 100644 --- a/src/ProjectMgmt/Presentation/Resources/templates/project_form.twig +++ b/src/ProjectMgmt/Presentation/Resources/templates/project_form.twig @@ -73,6 +73,22 @@ + {% set currentBackend = project ? project.contentEditorBackend.value : 'llm' %} +
+ + +

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

+
+ {# LLM API Key with verification #}
projectName = null; $this->agentImage = null; $this->suggestedCommitMessage = null; + $this->outputCallback = null; + } + + 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 diff --git a/src/WorkspaceTooling/Infrastructure/Execution/DockerExecutor.php b/src/WorkspaceTooling/Infrastructure/Execution/DockerExecutor.php index 59fcc68..c37cf2f 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(); diff --git a/src/WorkspaceTooling/Infrastructure/Execution/IsolatedShellExecutor.php b/src/WorkspaceTooling/Infrastructure/Execution/IsolatedShellExecutor.php index 7c8d51e..358ce13 100644 --- a/src/WorkspaceTooling/Infrastructure/Execution/IsolatedShellExecutor.php +++ b/src/WorkspaceTooling/Infrastructure/Execution/IsolatedShellExecutor.php @@ -66,6 +66,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 +75,8 @@ public function runCommand(string $workingDirectory, string $command): string $workingDirectory, // Working directory inside container 300, true, - $containerName + $containerName, + $outputCallback ); } } diff --git a/translations/messages.de.yaml b/translations/messages.de.yaml index 06e52d1..b9df049 100644 --- a/translations/messages.de.yaml +++ b/translations/messages.de.yaml @@ -91,6 +91,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" @@ -296,6 +300,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 111c83f..31e1b86 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -91,6 +91,10 @@ project: llm_api_key: "LLM API Key" placeholder_llm_api_key: "sk-..." llm_api_key_help: "Your API key for the selected LLM provider." + content_editor_backend: "Content editor backend" + 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" @@ -296,6 +300,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%" From be5eabc92307858e01d585fa6e1e8c6115c3368e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20K=C3=B6lbl?= Date: Tue, 27 Jan 2026 17:03:06 +0100 Subject: [PATCH 2/7] Adding cursor agent as alternative WIP - errors fixed and agent fixed in docker image --- config/services.yaml | 6 +- docker-compose.yml | 7 ++ migrations/Version20260127145023.php | 33 +++++++ .../Domain/Entity/Conversation.php | 23 ++--- .../Domain/Service/ConversationService.php | 2 +- .../Handler/RunEditSessionHandler.php | 10 ++- .../Domain/Agent/ContentEditorAgent.php | 14 ++- .../Facade/CursorAgentContentEditorFacade.php | 8 +- .../Streaming/CursorAgentStreamCollector.php | 67 +++++++++++--- src/ProjectMgmt/Domain/Entity/Project.php | 27 +++--- .../Domain/Service/ProjectService.php | 46 +++++----- src/ProjectMgmt/Facade/Dto/ProjectInfoDto.php | 26 +++--- .../Controller/ProjectController.php | 40 ++++----- .../Execution/AgentExecutionContext.php | 2 +- .../ConversationServiceTest.php | 88 +++++++++++-------- tests/Unit/ProjectMgmt/ProjectServiceTest.php | 5 +- 16 files changed, 259 insertions(+), 145 deletions(-) create mode 100644 migrations/Version20260127145023.php diff --git a/config/services.yaml b/config/services.yaml index 6e00448..3180e62 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -136,9 +136,9 @@ services: class: App\ChatBasedContentEditor\Infrastructure\ContentEditor\ContentEditorFacade arguments: - [ - '@App\ChatBasedContentEditor\Infrastructure\ContentEditor\LlmContentEditorAdapter', - '@App\ChatBasedContentEditor\Infrastructure\ContentEditor\CursorAgentContentEditorAdapter' - ] + '@App\ChatBasedContentEditor\Infrastructure\ContentEditor\LlmContentEditorAdapter', + '@App\ChatBasedContentEditor\Infrastructure\ContentEditor\CursorAgentContentEditorAdapter', + ] # Domain service bindings App\WorkspaceMgmt\Domain\Service\WorkspaceStatusGuardInterface: 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/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/src/ChatBasedContentEditor/Domain/Entity/Conversation.php b/src/ChatBasedContentEditor/Domain/Entity/Conversation.php index ea80c31..2dddc99 100644 --- a/src/ChatBasedContentEditor/Domain/Entity/Conversation.php +++ b/src/ChatBasedContentEditor/Domain/Entity/Conversation.php @@ -26,19 +26,19 @@ class Conversation * @throws Exception */ public function __construct( - string $workspaceId, - string $userId, - string $workspacePath, + string $workspaceId, + string $userId, + string $workspacePath, ContentEditorBackend $contentEditorBackend = ContentEditorBackend::Llm ) { - $this->workspaceId = $workspaceId; - $this->userId = $userId; - $this->workspacePath = $workspacePath; - $this->status = ConversationStatus::ONGOING; + $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(); + $this->createdAt = DateAndTimeService::getDateTimeImmutable(); + $this->editSessions = new ArrayCollection(); + $this->messages = new ArrayCollection(); } #[ORM\Id] @@ -111,7 +111,8 @@ public function getWorkspacePath(): string type: Types::STRING, length: 32, nullable: false, - enumType: ContentEditorBackend::class + enumType: ContentEditorBackend::class, + options: ['default' => ContentEditorBackend::Llm->value] )] private ContentEditorBackend $contentEditorBackend = ContentEditorBackend::Llm; diff --git a/src/ChatBasedContentEditor/Domain/Service/ConversationService.php b/src/ChatBasedContentEditor/Domain/Service/ConversationService.php index dc7acd0..1b4e14f 100644 --- a/src/ChatBasedContentEditor/Domain/Service/ConversationService.php +++ b/src/ChatBasedContentEditor/Domain/Service/ConversationService.php @@ -51,7 +51,7 @@ public function startOrResumeConversation(string $projectId, string $userId): Co return $this->toDto($existingConversation); } - $projectInfo = $this->projectMgmtFacade->getProjectInfo($projectId); + $projectInfo = $this->projectMgmtFacade->getProjectInfo($projectId); $contentEditorBackend = ContentEditorBackend::from($projectInfo->contentEditorBackend->value); // Transition workspace to IN_CONVERSATION diff --git a/src/ChatBasedContentEditor/Infrastructure/Handler/RunEditSessionHandler.php b/src/ChatBasedContentEditor/Infrastructure/Handler/RunEditSessionHandler.php index a79158f..2e0d331 100644 --- a/src/ChatBasedContentEditor/Infrastructure/Handler/RunEditSessionHandler.php +++ b/src/ChatBasedContentEditor/Infrastructure/Handler/RunEditSessionHandler.php @@ -83,12 +83,20 @@ public function __invoke(RunEditSessionMessage $message): void return; } + $agentImage = $project->agentImage; + if ($conversation->getContentEditorBackend() === ContentEditorBackend::CursorAgent) { + $cursorAgentImage = $_ENV['CURSOR_AGENT_IMAGE'] ?? null; + if (is_string($cursorAgentImage) && $cursorAgentImage !== '') { + $agentImage = $cursorAgentImage; + } + } + $this->executionContext->setContext( $conversation->getWorkspaceId(), $session->getWorkspacePath(), $conversation->getId(), $workspace->projectName, - $project->agentImage, + $agentImage, $project->remoteContentAssetsManifestUrls ); diff --git a/src/CursorAgentContentEditor/Domain/Agent/ContentEditorAgent.php b/src/CursorAgentContentEditor/Domain/Agent/ContentEditorAgent.php index de1183d..71056bd 100644 --- a/src/CursorAgentContentEditor/Domain/Agent/ContentEditorAgent.php +++ b/src/CursorAgentContentEditor/Domain/Agent/ContentEditorAgent.php @@ -20,21 +20,17 @@ public function run( string $prompt, string $apiKey, ?string $resumeSessionId = null - ): string - { - $agentBinary = $_ENV['CURSOR_AGENT_BINARY'] ?? 'agent'; - if (!is_string($agentBinary) || $agentBinary === '') { - $agentBinary = 'agent'; - } - + ): string { $sessionArg = ''; if ($resumeSessionId !== null && $resumeSessionId !== '') { $sessionArg = '--resume ' . escapeshellarg($resumeSessionId); } $command = sprintf( - '%s --output-format stream-json --stream-partial-output %s --api-key %s -p %s', - escapeshellcmd($agentBinary), + '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 %s --api-key %s -p %s', + escapeshellarg('/root/.local/bin/agent'), $sessionArg, escapeshellarg($apiKey), escapeshellarg($prompt) diff --git a/src/CursorAgentContentEditor/Facade/CursorAgentContentEditorFacade.php b/src/CursorAgentContentEditor/Facade/CursorAgentContentEditorFacade.php index 392e039..901b494 100644 --- a/src/CursorAgentContentEditor/Facade/CursorAgentContentEditorFacade.php +++ b/src/CursorAgentContentEditor/Facade/CursorAgentContentEditorFacade.php @@ -7,6 +7,7 @@ use App\CursorAgentContentEditor\Domain\Agent\ContentEditorAgent; use App\CursorAgentContentEditor\Infrastructure\Streaming\CursorAgentStreamCollector; use App\LlmContentEditor\Facade\Dto\AgentConfigDto; +use App\LlmContentEditor\Facade\Dto\AgentEventDto; use App\LlmContentEditor\Facade\Dto\ConversationMessageDto; use App\LlmContentEditor\Facade\Dto\EditStreamChunkDto; use App\WorkspaceTooling\Facade\AgentExecutionContextInterface; @@ -20,7 +21,7 @@ final class CursorAgentContentEditorFacade implements CursorAgentContentEditorFa public function __construct( private readonly WorkspaceToolingServiceInterface $workspaceTooling, - private readonly AgentExecutionContextInterface $executionContext, + private readonly AgentExecutionContextInterface $executionContext, ) { } @@ -45,6 +46,8 @@ public function streamEditWithHistory( try { $prompt = $this->buildPrompt($instruction, $previousMessages, $cursorAgentSessionId === null); + yield new EditStreamChunkDto('event', null, new AgentEventDto('inference_start')); + $agent = new ContentEditorAgent($this->workspaceTooling); $agent->run('/workspace', $prompt, $apiKey, $cursorAgentSessionId); @@ -54,6 +57,8 @@ public function streamEditWithHistory( yield $chunk; } + yield new EditStreamChunkDto('event', null, new AgentEventDto('inference_stop')); + yield new EditStreamChunkDto( 'done', null, @@ -62,6 +67,7 @@ public function streamEditWithHistory( $collector->getErrorMessage() ); } catch (Throwable $e) { + yield new EditStreamChunkDto('event', null, new AgentEventDto('inference_stop')); yield new EditStreamChunkDto('done', null, null, false, $e->getMessage()); } finally { $this->executionContext->setOutputCallback(null); diff --git a/src/CursorAgentContentEditor/Infrastructure/Streaming/CursorAgentStreamCollector.php b/src/CursorAgentContentEditor/Infrastructure/Streaming/CursorAgentStreamCollector.php index d9794c2..4493cb9 100644 --- a/src/CursorAgentContentEditor/Infrastructure/Streaming/CursorAgentStreamCollector.php +++ b/src/CursorAgentContentEditor/Infrastructure/Streaming/CursorAgentStreamCollector.php @@ -31,6 +31,7 @@ final class CursorAgentStreamCollector private ?string $lastSessionId = null; private string $assistantBuffer = ''; + private bool $hasEmittedText = false; public function __construct() { @@ -57,18 +58,18 @@ public function __invoke(string $buffer, bool $isError): void } try { - /** @var mixed $decoded */ $decoded = json_decode($line, true, 512, JSON_THROW_ON_ERROR); } catch (JsonException) { continue; } - if (!is_array($decoded)) { + $normalized = $this->normalizeEvent($decoded); + if ($normalized === null) { continue; } - $this->captureSessionId($decoded); - $this->handleEvent($decoded); + $this->captureSessionId($normalized); + $this->handleEvent($normalized); } } @@ -106,7 +107,7 @@ public function getErrorMessage(): ?string */ private function handleEvent(array $event): void { - $type = $event['type'] ?? null; + $type = $event['type'] ?? null; $subtype = $event['subtype'] ?? null; if (!is_string($type)) { @@ -149,7 +150,6 @@ private function handleThinking(?string $subtype): void if ($subtype === 'delta') { if (!$this->thinkingStarted) { $this->thinkingStarted = true; - $this->enqueueEvent(new AgentEventDto('inference_start')); } return; @@ -167,7 +167,6 @@ private function flushThinking(): void } $this->thinkingStarted = false; - $this->enqueueEvent(new AgentEventDto('inference_stop')); } /** @@ -225,9 +224,14 @@ private function handleAssistant(array $event): void } if (($item['type'] ?? null) === 'text' && is_string($item['text'] ?? null)) { - $text = $item['text']; - $this->assistantBuffer .= $text; - $this->chunks->enqueue(new EditStreamChunkDto('text', $text)); + $text = $item['text']; + $delta = $this->resolveAssistantDelta($text); + if ($delta === '') { + continue; + } + + $this->hasEmittedText = true; + $this->chunks->enqueue(new EditStreamChunkDto('text', $delta)); } } } @@ -238,11 +242,11 @@ private function handleAssistant(array $event): void private function handleResult(array $event, ?string $subtype): void { if ($subtype === 'success') { - $this->resultSuccess = true; + $this->resultSuccess = true; $this->resultErrorMessage = null; $result = $event['result'] ?? null; - if (is_string($result) && $result !== '' && $this->assistantBuffer === '') { + if (!$this->hasEmittedText && is_string($result) && $result !== '') { $this->chunks->enqueue(new EditStreamChunkDto('text', $result)); } @@ -326,4 +330,43 @@ private function truncate(string $value, int $maxLength = 500): string return mb_substr($value, 0, $maxLength) . '...'; } + + private function resolveAssistantDelta(string $incoming): string + { + if ($incoming === '') { + return ''; + } + + if ($this->assistantBuffer !== '' && str_starts_with($incoming, $this->assistantBuffer)) { + $delta = substr($incoming, strlen($this->assistantBuffer)); + $this->assistantBuffer = $incoming; + + return $delta; + } + + $this->assistantBuffer .= $incoming; + + return $incoming; + } + + /** + * @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/ProjectMgmt/Domain/Entity/Project.php b/src/ProjectMgmt/Domain/Entity/Project.php index abf835d..9bcbccf 100644 --- a/src/ProjectMgmt/Domain/Entity/Project.php +++ b/src/ProjectMgmt/Domain/Entity/Project.php @@ -28,18 +28,18 @@ class Project * @param list|null $remoteContentAssetsManifestUrls */ public function __construct( - string $name, - string $gitUrl, - string $githubToken, - LlmModelProvider $llmModelProvider, - string $llmApiKey, - ProjectType $projectType = ProjectType::DEFAULT, + string $name, + string $gitUrl, + string $githubToken, + LlmModelProvider $llmModelProvider, + string $llmApiKey, + ProjectType $projectType = ProjectType::DEFAULT, ContentEditorBackend $contentEditorBackend = ContentEditorBackend::Llm, - string $agentImage = self::DEFAULT_AGENT_IMAGE, - ?string $agentBackgroundInstructions = null, - ?string $agentStepInstructions = null, - ?string $agentOutputInstructions = null, - ?array $remoteContentAssetsManifestUrls = null + string $agentImage = self::DEFAULT_AGENT_IMAGE, + ?string $agentBackgroundInstructions = null, + ?string $agentStepInstructions = null, + ?string $agentOutputInstructions = null, + ?array $remoteContentAssetsManifestUrls = null ) { $this->name = $name; $this->gitUrl = $gitUrl; @@ -47,7 +47,7 @@ public function __construct( $this->llmModelProvider = $llmModelProvider; $this->llmApiKey = $llmApiKey; $this->projectType = $projectType; - $this->contentEditorBackend = $contentEditorBackend; + $this->contentEditorBackend = $contentEditorBackend; $this->agentImage = $agentImage; $this->createdAt = DateAndTimeService::getDateTimeImmutable(); $this->remoteContentAssetsManifestUrls = $remoteContentAssetsManifestUrls !== null && $remoteContentAssetsManifestUrls !== [] ? $remoteContentAssetsManifestUrls : null; @@ -146,7 +146,8 @@ public function setProjectType(ProjectType $projectType): void type: Types::STRING, length: 32, nullable: false, - enumType: ContentEditorBackend::class + enumType: ContentEditorBackend::class, + options: ['default' => ContentEditorBackend::Llm->value] )] private ContentEditorBackend $contentEditorBackend = ContentEditorBackend::Llm; diff --git a/src/ProjectMgmt/Domain/Service/ProjectService.php b/src/ProjectMgmt/Domain/Service/ProjectService.php index fa7e019..4b4fd4d 100644 --- a/src/ProjectMgmt/Domain/Service/ProjectService.php +++ b/src/ProjectMgmt/Domain/Service/ProjectService.php @@ -25,18 +25,18 @@ public function __construct( * @param list|null $remoteContentAssetsManifestUrls */ public function create( - string $name, - string $gitUrl, - string $githubToken, - LlmModelProvider $llmModelProvider, - string $llmApiKey, - ProjectType $projectType = ProjectType::DEFAULT, + string $name, + string $gitUrl, + string $githubToken, + LlmModelProvider $llmModelProvider, + string $llmApiKey, + ProjectType $projectType = ProjectType::DEFAULT, ContentEditorBackend $contentEditorBackend = ContentEditorBackend::Llm, - string $agentImage = Project::DEFAULT_AGENT_IMAGE, - ?string $agentBackgroundInstructions = null, - ?string $agentStepInstructions = null, - ?string $agentOutputInstructions = null, - ?array $remoteContentAssetsManifestUrls = null + string $agentImage = Project::DEFAULT_AGENT_IMAGE, + ?string $agentBackgroundInstructions = null, + ?string $agentStepInstructions = null, + ?string $agentOutputInstructions = null, + ?array $remoteContentAssetsManifestUrls = null ): Project { $project = new Project( $name, @@ -62,19 +62,19 @@ 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, + Project $project, + string $name, + string $gitUrl, + string $githubToken, + LlmModelProvider $llmModelProvider, + string $llmApiKey, + ProjectType $projectType = ProjectType::DEFAULT, ContentEditorBackend $contentEditorBackend = ContentEditorBackend::Llm, - string $agentImage = Project::DEFAULT_AGENT_IMAGE, - ?string $agentBackgroundInstructions = null, - ?string $agentStepInstructions = null, - ?string $agentOutputInstructions = null, - ?array $remoteContentAssetsManifestUrls = null + string $agentImage = Project::DEFAULT_AGENT_IMAGE, + ?string $agentBackgroundInstructions = null, + ?string $agentStepInstructions = null, + ?string $agentOutputInstructions = null, + ?array $remoteContentAssetsManifestUrls = null ): void { $project->setName($name); $project->setGitUrl($gitUrl); diff --git a/src/ProjectMgmt/Facade/Dto/ProjectInfoDto.php b/src/ProjectMgmt/Facade/Dto/ProjectInfoDto.php index 08d0848..f8fa27d 100644 --- a/src/ProjectMgmt/Facade/Dto/ProjectInfoDto.php +++ b/src/ProjectMgmt/Facade/Dto/ProjectInfoDto.php @@ -14,20 +14,20 @@ * @param list $remoteContentAssetsManifestUrls */ public function __construct( - public string $id, - public string $name, - public string $gitUrl, - public string $githubToken, - public ProjectType $projectType, + public string $id, + public string $name, + public string $gitUrl, + public string $githubToken, + public ProjectType $projectType, public ContentEditorBackend $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 = [], + public string $githubUrl, + public string $agentImage, + public LlmModelProvider $llmModelProvider, + public string $llmApiKey, + public string $agentBackgroundInstructions, + public string $agentStepInstructions, + public string $agentOutputInstructions, + public array $remoteContentAssetsManifestUrls = [], ) { } } diff --git a/src/ProjectMgmt/Presentation/Controller/ProjectController.php b/src/ProjectMgmt/Presentation/Controller/ProjectController.php index 7aec56d..4c1db7d 100644 --- a/src/ProjectMgmt/Presentation/Controller/ProjectController.php +++ b/src/ProjectMgmt/Presentation/Controller/ProjectController.php @@ -112,11 +112,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(), + 'project' => null, + 'llmProviders' => LlmModelProvider::cases(), 'contentEditorBackends' => ContentEditorBackend::cases(), - 'existingLlmKeys' => $this->projectMgmtFacade->getExistingLlmApiKeys(), - 'agentConfigTemplate' => $defaultTemplate, + 'existingLlmKeys' => $this->projectMgmtFacade->getExistingLlmApiKeys(), + 'agentConfigTemplate' => $defaultTemplate, ]); } @@ -133,13 +133,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')); + $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 = ContentEditorBackend::tryFrom($request->request->getString('content_editor_backend')); - $llmApiKey = $request->request->getString('llm_api_key'); - $agentImage = $this->resolveAgentImage($request); + $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')); @@ -215,11 +215,11 @@ 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(), + 'project' => $project, + 'llmProviders' => LlmModelProvider::cases(), 'contentEditorBackends' => ContentEditorBackend::cases(), - 'existingLlmKeys' => $existingLlmKeys, - 'agentConfigTemplate' => $agentConfigTemplate, + 'existingLlmKeys' => $existingLlmKeys, + 'agentConfigTemplate' => $agentConfigTemplate, ]); } @@ -243,13 +243,13 @@ 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'); - $githubToken = $request->request->getString('github_token'); - $llmModelProvider = LlmModelProvider::tryFrom($request->request->getString('llm_model_provider')); + $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 = ContentEditorBackend::tryFrom($request->request->getString('content_editor_backend')); - $llmApiKey = $request->request->getString('llm_api_key'); - $agentImage = $this->resolveAgentImage($request); + $llmApiKey = $request->request->getString('llm_api_key'); + $agentImage = $this->resolveAgentImage($request); // Agent configuration (null means keep existing values) $agentBackgroundInstructions = $this->nullIfEmpty($request->request->getString('agent_background_instructions')); diff --git a/src/WorkspaceTooling/Infrastructure/Execution/AgentExecutionContext.php b/src/WorkspaceTooling/Infrastructure/Execution/AgentExecutionContext.php index 87c8cf1..3cbf4f5 100644 --- a/src/WorkspaceTooling/Infrastructure/Execution/AgentExecutionContext.php +++ b/src/WorkspaceTooling/Infrastructure/Execution/AgentExecutionContext.php @@ -74,7 +74,7 @@ public function clearContext(): void $this->agentImage = null; $this->suggestedCommitMessage = null; $this->remoteContentAssetsManifestUrls = null; - $this->outputCallback = null; + $this->outputCallback = null; } /** 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/ProjectMgmt/ProjectServiceTest.php b/tests/Unit/ProjectMgmt/ProjectServiceTest.php index 38744dc..6aaeda4 100644 --- a/tests/Unit/ProjectMgmt/ProjectServiceTest.php +++ b/tests/Unit/ProjectMgmt/ProjectServiceTest.php @@ -7,6 +7,8 @@ use App\LlmContentEditor\Facade\Enum\LlmModelProvider; use App\ProjectMgmt\Domain\Entity\Project; use App\ProjectMgmt\Domain\Service\ProjectService; +use App\ProjectMgmt\Facade\Enum\ContentEditorBackend; +use App\ProjectMgmt\Facade\Enum\ProjectType; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\TestCase; use ReflectionClass; @@ -49,7 +51,8 @@ public function testCreateStoresRemoteContentAssetsManifestUrlsWhenProvided(): v 'token', LlmModelProvider::OpenAI, 'sk-key', - \App\ProjectMgmt\Facade\Enum\ProjectType::DEFAULT, + ProjectType::DEFAULT, + ContentEditorBackend::Llm, 'node:22-slim', null, null, From 38669e92a29c15a3bf2e89ff5c6fbc4213f1dbe3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20K=C3=B6lbl?= Date: Fri, 6 Feb 2026 12:14:59 +0100 Subject: [PATCH 3/7] Adding cursor agent as alternative WIP --- .../chat_based_content_editor_controller.ts | 21 +- .../assets/controllers/chat_editor_helpers.ts | 14 +- .../Domain/Agent/ContentEditorAgent.php | 40 +++- .../Facade/CursorAgentContentEditorFacade.php | 140 +++++++++++-- .../Streaming/CursorAgentStreamCollector.php | 191 +++++++++++++++++- .../Facade/Dto/AgentEventDto.php | 4 +- .../Facade/AgentExecutionContextInterface.php | 7 + .../Facade/StreamingProcessInterface.php | 49 +++++ .../Facade/WorkspaceToolingFacade.php | 10 + .../WorkspaceToolingServiceInterface.php | 13 ++ .../Execution/DockerExecutor.php | 60 ++++++ .../Execution/IsolatedShellExecutor.php | 54 +++++ .../Execution/StreamingDockerProcess.php | 94 +++++++++ 13 files changed, 665 insertions(+), 32 deletions(-) create mode 100644 src/WorkspaceTooling/Facade/StreamingProcessInterface.php create mode 100644 src/WorkspaceTooling/Infrastructure/Execution/StreamingDockerProcess.php 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 dfe789f..8c83245 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 @@ -914,6 +914,20 @@ 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 "build_start": + wrap.textContent = "▶ Building workspace…"; + wrap.classList.add("text-blue-600/70", "dark:text-blue-400/70"); + break; + case "build_complete": { + const result = (e.toolResult ?? "").slice(0, 200); + wrap.innerHTML = `◀ Build completed. ${escapeHtml(result)}${(e.toolResult?.length ?? 0) > 200 ? "…" : ""}`; + wrap.classList.add("text-green-600/70", "dark:text-green-400/70"); + break; + } + case "build_error": + wrap.textContent = `✖ Build 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"); @@ -999,10 +1013,13 @@ export default class extends Controller { } private updateActivityIndicators(container: HTMLElement, event: AgentEvent): void { - // Only react to tool_calling events - Working badge tracks tool calls - if (event.kind === "tool_calling") { + // Working badge tracks tool calls and post-agent build + if (event.kind === "tool_calling" || event.kind === "build_start") { this.onToolCall(container); } + if (event.kind === "build_complete" || event.kind === "build_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 5fca902..dcaa9e6 100644 --- a/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/chat_editor_helpers.ts +++ b/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/chat_editor_helpers.ts @@ -194,8 +194,8 @@ export interface ProgressAnimationState { * Returns whether to intensify animations, return to normal, or do nothing. */ export function getProgressAnimationState(eventKind: string): ProgressAnimationState { - const intensifyEvents = ["tool_calling", "inference_start"]; - const normalizeEvents = ["tool_called", "inference_stop"]; + const intensifyEvents = ["tool_calling", "inference_start", "build_start"]; + const normalizeEvents = ["tool_called", "inference_stop", "build_complete", "build_error"]; return { intensify: intensifyEvents.includes(eventKind), @@ -208,6 +208,14 @@ 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", + "build_start", + "build_complete", + "build_error", + ]; return feedbackEvents.includes(eventKind); } diff --git a/src/CursorAgentContentEditor/Domain/Agent/ContentEditorAgent.php b/src/CursorAgentContentEditor/Domain/Agent/ContentEditorAgent.php index 71056bd..9797c6c 100644 --- a/src/CursorAgentContentEditor/Domain/Agent/ContentEditorAgent.php +++ b/src/CursorAgentContentEditor/Domain/Agent/ContentEditorAgent.php @@ -4,15 +4,16 @@ namespace App\CursorAgentContentEditor\Domain\Agent; +use App\WorkspaceTooling\Facade\StreamingProcessInterface; use App\WorkspaceTooling\Facade\WorkspaceToolingServiceInterface; use EtfsCodingAgent\Agent\BaseCodingAgent; class ContentEditorAgent extends BaseCodingAgent { public function __construct( - WorkspaceToolingServiceInterface $workspaceToolingFacade + private readonly WorkspaceToolingServiceInterface $workspaceTooling ) { - parent::__construct($workspaceToolingFacade); + parent::__construct($workspaceTooling); } public function run( @@ -21,21 +22,48 @@ public function run( string $apiKey, ?string $resumeSessionId = null ): string { + $command = $this->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); } - $command = sprintf( + // 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 %s --api-key %s -p %s', + '"$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) ); - - return $this->workspaceToolingFacade->runShellCommand($workingDirectory, $command); } } diff --git a/src/CursorAgentContentEditor/Facade/CursorAgentContentEditorFacade.php b/src/CursorAgentContentEditor/Facade/CursorAgentContentEditorFacade.php index 901b494..bf0bf2b 100644 --- a/src/CursorAgentContentEditor/Facade/CursorAgentContentEditorFacade.php +++ b/src/CursorAgentContentEditor/Facade/CursorAgentContentEditorFacade.php @@ -13,10 +13,16 @@ use App\WorkspaceTooling\Facade\AgentExecutionContextInterface; use App\WorkspaceTooling\Facade\WorkspaceToolingServiceInterface; use Generator; +use RuntimeException; use Throwable; final class CursorAgentContentEditorFacade implements CursorAgentContentEditorFacadeInterface { + /** + * Polling interval in microseconds (50ms). + */ + private const int POLL_INTERVAL_US = 50_000; + private ?string $lastSessionId = null; public function __construct( @@ -44,19 +50,50 @@ public function streamEditWithHistory( $this->executionContext->setOutputCallback($collector); try { - $prompt = $this->buildPrompt($instruction, $previousMessages, $cursorAgentSessionId === null); + $prompt = $this->buildPrompt( + $instruction, + $previousMessages, + $cursorAgentSessionId === null, + $agentConfig + ); yield new EditStreamChunkDto('event', null, new AgentEventDto('inference_start')); - $agent = new ContentEditorAgent($this->workspaceTooling); - $agent->run('/workspace', $prompt, $apiKey, $cursorAgentSessionId); + $agent = new ContentEditorAgent($this->workspaceTooling); + $process = $agent->startAsync('/workspace', $prompt, $apiKey, $cursorAgentSessionId); - $this->lastSessionId = $collector->getLastSessionId(); + // Poll for chunks while the process is running + while ($process->isRunning()) { + // Drain any chunks that have arrived + foreach ($collector->drain() as $chunk) { + yield $chunk; + } + // Brief sleep to avoid busy-waiting + 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; } + $this->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('event', null, new AgentEventDto('build_start')); + $agentImage = $this->executionContext->getAgentImage() ?? 'node:22-slim'; + try { + $buildOutput = $this->workspaceTooling->runBuildInWorkspace($workspacePath, $agentImage); + yield new EditStreamChunkDto('event', null, new AgentEventDto('build_complete', null, null, $buildOutput)); + } catch (RuntimeException $e) { + yield new EditStreamChunkDto('event', null, new AgentEventDto('build_error', null, null, null, $e->getMessage())); + } + yield new EditStreamChunkDto('event', null, new AgentEventDto('inference_stop')); yield new EditStreamChunkDto( @@ -82,25 +119,100 @@ public function getLastSessionId(): ?string /** * @param list $previousMessages */ - private function buildPrompt(string $instruction, array $previousMessages, bool $includeWorkspaceContext): string + private function buildPrompt( + string $instruction, + array $previousMessages, + bool $isFirstMessage, + ?AgentConfigDto $agentConfig + ): string { + $parts = []; + + // Include system instructions and workspace rules only on first message of a session + if ($isFirstMessage) { + $systemContext = $this->buildSystemContext($agentConfig); + if ($systemContext !== '') { + $parts[] = $systemContext; + } + } + + // Add conversation history if any + if ($previousMessages !== []) { + $parts[] = $this->formatHistory($previousMessages); + $parts[] = 'User: ' . $instruction; + } else { + $parts[] = $this->wrapInstruction($instruction, $isFirstMessage); + } + + return implode("\n\n", $parts); + } + + /** + * Build system context including agent instructions and workspace rules. + */ + private function buildSystemContext(?AgentConfigDto $agentConfig): string { - if ($previousMessages === []) { - return $this->wrapInstruction($instruction, $includeWorkspaceContext); + $sections = []; + + // Add working folder info + $sections[] = 'The working folder is: /workspace'; + + // Add agent background instructions + if ($agentConfig !== null && trim($agentConfig->backgroundInstructions) !== '') { + $sections[] = "## Background Instructions\n" . $agentConfig->backgroundInstructions; } - $history = $this->formatHistory($previousMessages); + // Add agent step instructions + if ($agentConfig !== null && trim($agentConfig->stepInstructions) !== '') { + $sections[] = "## Step-by-Step Instructions\n" . $agentConfig->stepInstructions; + } + + // Add agent output instructions + if ($agentConfig !== null && trim($agentConfig->outputInstructions) !== '') { + $sections[] = "## Output Instructions\n" . $agentConfig->outputInstructions; + } - return $this->wrapInstruction($history . "\n\n" . 'User: ' . $instruction, $includeWorkspaceContext); + // Add workspace rules + $workspaceRules = $this->getWorkspaceRulesForPrompt(); + if ($workspaceRules !== '') { + $sections[] = "## Workspace Rules\n" . $workspaceRules; + } + + // Add critical instruction about running build + $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); + } + + /** + * Get workspace rules formatted for the prompt. + */ + 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 sprintf( - 'The working folder is: %s' . "\n\n" . 'Please perform the following task: %s', - '/workspace', - $instruction - ); + return 'Please perform the following task: ' . $instruction; } return $instruction; diff --git a/src/CursorAgentContentEditor/Infrastructure/Streaming/CursorAgentStreamCollector.php b/src/CursorAgentContentEditor/Infrastructure/Streaming/CursorAgentStreamCollector.php index 4493cb9..ad10f29 100644 --- a/src/CursorAgentContentEditor/Infrastructure/Streaming/CursorAgentStreamCollector.php +++ b/src/CursorAgentContentEditor/Infrastructure/Streaming/CursorAgentStreamCollector.php @@ -33,6 +33,18 @@ final class CursorAgentStreamCollector private string $assistantBuffer = ''; private bool $hasEmittedText = false; + /** + * Tracks the last full incoming message (before delta extraction) for dedup. + */ + private string $lastFullMessage = ''; + + /** + * Tracks all full messages we've seen to detect repeats. + * + * @var list + */ + private array $seenMessages = []; + public function __construct() { /** @var SplQueue $queue */ @@ -224,18 +236,142 @@ private function handleAssistant(array $event): void } if (($item['type'] ?? null) === 'text' && is_string($item['text'] ?? null)) { - $text = $item['text']; - $delta = $this->resolveAssistantDelta($text); - if ($delta === '') { + $fullText = trim($item['text']); + if ($fullText === '') { continue; } - $this->hasEmittedText = true; + // Check if this full message (or very similar) was already seen + if ($this->isMessageAlreadySeen($fullText)) { + // Update buffer for delta tracking but don't emit + $this->assistantBuffer = $fullText; + + continue; + } + + // Extract delta from cumulative text + $delta = $this->resolveAssistantDelta($fullText); + if (trim($delta) === '') { + continue; + } + + // Add paragraph break if we already have content and this is a new sentence + $trimmedDelta = trim($delta); + if ($this->hasEmittedText && $this->shouldAddParagraphBreak($trimmedDelta)) { + $this->chunks->enqueue(new EditStreamChunkDto('text', "\n\n")); + } + + $this->hasEmittedText = true; + $this->lastFullMessage = $fullText; + $this->recordSeenMessage($fullText); $this->chunks->enqueue(new EditStreamChunkDto('text', $delta)); } } } + /** + * Check if a message (or very similar) was already seen. + */ + private function isMessageAlreadySeen(string $message): bool + { + // Check against last full message first (most common case) + if ($this->isSimilarToMessage($message, $this->lastFullMessage)) { + return true; + } + + // Check against all seen messages for exact or near duplicates + foreach ($this->seenMessages as $seen) { + if ($this->isSimilarToMessage($message, $seen)) { + return true; + } + } + + return false; + } + + /** + * Check if two messages are similar enough to be considered duplicates. + */ + private function isSimilarToMessage(string $a, string $b): bool + { + if ($a === '' || $b === '') { + return false; + } + + // Exact match + if ($a === $b) { + return true; + } + + // One is a prefix of the other (cumulative update - the longer one is the "real" message) + if (str_starts_with($a, $b) || str_starts_with($b, $a)) { + return true; + } + + // For messages of similar length, check similarity + $lenA = mb_strlen($a); + $lenB = mb_strlen($b); + + // Only check similarity for messages of similar length (within 20%) + if (min($lenA, $lenB) / max($lenA, $lenB) < 0.8) { + return false; + } + + // Use levenshtein for short texts + if ($lenA <= 255 && $lenB <= 255) { + $distance = levenshtein($a, $b); + $similarity = 1 - ($distance / max($lenA, $lenB)); + + return $similarity > 0.85; + } + + // For longer texts, check if they share a long common prefix + $commonLen = 0; + $minLen = min($lenA, $lenB); + for ($i = 0; $i < $minLen; ++$i) { + if ($a[$i] !== $b[$i]) { + break; + } + ++$commonLen; + } + + return $commonLen / $minLen > 0.85; + } + + /** + * Record a message as seen (keep only recent messages to limit memory). + */ + private function recordSeenMessage(string $message): void + { + // Only record substantial messages + if (mb_strlen($message) < 20) { + return; + } + + $this->seenMessages[] = $message; + + // Keep only the last 20 messages to limit memory + if (count($this->seenMessages) > 20) { + array_shift($this->seenMessages); + } + } + + /** + * Determine if we should add a paragraph break before this text. + */ + private function shouldAddParagraphBreak(string $text): bool + { + $text = trim($text); + if ($text === '') { + return false; + } + + // Add paragraph if the new text starts with a capital letter (new sentence) + $firstChar = mb_substr($text, 0, 1); + + return preg_match('/[A-Z]/', $firstChar) === 1; + } + /** * @param array $event */ @@ -245,6 +381,8 @@ private function handleResult(array $event, ?string $subtype): void $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('text', $result)); @@ -331,12 +469,20 @@ private function truncate(string $value, int $maxLength = 500): string return mb_substr($value, 0, $maxLength) . '...'; } + /** + * Resolve the delta from cumulative assistant text. + * + * The Cursor agent streams assistant messages cumulatively - each message + * contains all text so far, not just the new part. We need to extract + * only the new text (delta) to avoid duplicates. + */ private function resolveAssistantDelta(string $incoming): string { if ($incoming === '') { return ''; } + // If incoming starts with the buffer, extract just the new part if ($this->assistantBuffer !== '' && str_starts_with($incoming, $this->assistantBuffer)) { $delta = substr($incoming, strlen($this->assistantBuffer)); $this->assistantBuffer = $incoming; @@ -344,11 +490,46 @@ private function resolveAssistantDelta(string $incoming): string return $delta; } - $this->assistantBuffer .= $incoming; + // Check if the buffer ends with the beginning of incoming (overlap case) + if ($this->assistantBuffer !== '') { + $overlap = $this->findOverlap($this->assistantBuffer, $incoming); + if ($overlap > 0) { + $delta = substr($incoming, $overlap); + $this->assistantBuffer = $this->assistantBuffer . $delta; + + return $delta; + } + } + + // Check if this exact text was already in the buffer (complete duplicate) + if (str_contains($this->assistantBuffer, $incoming)) { + return ''; + } + + // New independent text segment - update buffer and return + $this->assistantBuffer = $incoming; return $incoming; } + /** + * Find how many characters of $suffix's beginning overlap with $prefix's end. + */ + private function findOverlap(string $prefix, string $suffix): int + { + $maxCheck = min(strlen($prefix), strlen($suffix)); + + for ($len = $maxCheck; $len > 0; --$len) { + $prefixEnd = substr($prefix, -$len); + $suffixStart = substr($suffix, 0, $len); + if ($prefixEnd === $suffixStart) { + return $len; + } + } + + return 0; + } + /** * @return array|null */ diff --git a/src/LlmContentEditor/Facade/Dto/AgentEventDto.php b/src/LlmContentEditor/Facade/Dto/AgentEventDto.php index 4c897b3..189f379 100644 --- a/src/LlmContentEditor/Facade/Dto/AgentEventDto.php +++ b/src/LlmContentEditor/Facade/Dto/AgentEventDto.php @@ -7,8 +7,8 @@ readonly class AgentEventDto { /** - * @param 'inference_start'|'inference_stop'|'tool_calling'|'tool_called'|'agent_error' $kind - * @param list|null $toolInputs + * @param 'inference_start'|'inference_stop'|'tool_calling'|'tool_called'|'agent_error'|'build_start'|'build_complete'|'build_error' $kind + * @param list|null $toolInputs */ public function __construct( public string $kind, diff --git a/src/WorkspaceTooling/Facade/AgentExecutionContextInterface.php b/src/WorkspaceTooling/Facade/AgentExecutionContextInterface.php index 895e80f..985678d 100644 --- a/src/WorkspaceTooling/Facade/AgentExecutionContextInterface.php +++ b/src/WorkspaceTooling/Facade/AgentExecutionContextInterface.php @@ -69,4 +69,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/DockerExecutor.php b/src/WorkspaceTooling/Infrastructure/Execution/DockerExecutor.php index c37cf2f..810f86d 100644 --- a/src/WorkspaceTooling/Infrastructure/Execution/DockerExecutor.php +++ b/src/WorkspaceTooling/Infrastructure/Execution/DockerExecutor.php @@ -115,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. */ @@ -197,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 358ce13..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. * @@ -79,4 +81,56 @@ public function runCommand(string $workingDirectory, string $command): string $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(); + } +} From 7b29db7f0cfb8679711485296a83991d28fccbe8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20K=C3=B6lbl?= Date: Fri, 6 Feb 2026 14:52:46 +0100 Subject: [PATCH 4/7] Adding cursor agent as alternative WIP --- .../ContentEditor/LlmContentEditorAdapter.php | 2 +- .../Streaming/CursorAgentStreamCollector.php | 229 +++++------------- src/ProjectMgmt/Facade/ProjectMgmtFacade.php | 2 + 3 files changed, 63 insertions(+), 170 deletions(-) diff --git a/src/ChatBasedContentEditor/Infrastructure/ContentEditor/LlmContentEditorAdapter.php b/src/ChatBasedContentEditor/Infrastructure/ContentEditor/LlmContentEditorAdapter.php index bff5701..6fe1064 100644 --- a/src/ChatBasedContentEditor/Infrastructure/ContentEditor/LlmContentEditorAdapter.php +++ b/src/ChatBasedContentEditor/Infrastructure/ContentEditor/LlmContentEditorAdapter.php @@ -41,7 +41,7 @@ public function streamEditWithHistory( $instruction, $previousMessages, $apiKey, - $agentConfig + $agentConfig ?? new AgentConfigDto('', '', '') ); } diff --git a/src/CursorAgentContentEditor/Infrastructure/Streaming/CursorAgentStreamCollector.php b/src/CursorAgentContentEditor/Infrastructure/Streaming/CursorAgentStreamCollector.php index ad10f29..b17e2b5 100644 --- a/src/CursorAgentContentEditor/Infrastructure/Streaming/CursorAgentStreamCollector.php +++ b/src/CursorAgentContentEditor/Infrastructure/Streaming/CursorAgentStreamCollector.php @@ -30,20 +30,15 @@ final class CursorAgentStreamCollector private ?string $lastSessionId = null; - private string $assistantBuffer = ''; - private bool $hasEmittedText = false; - /** - * Tracks the last full incoming message (before delta extraction) for dedup. + * Accumulates streaming parts with spaces between them. */ - private string $lastFullMessage = ''; + private string $accumulatedParts = ''; /** - * Tracks all full messages we've seen to detect repeats. - * - * @var list + * Whether we've emitted any complete messages yet. */ - private array $seenMessages = []; + private bool $hasEmittedText = false; public function __construct() { @@ -216,6 +211,17 @@ private function handleToolCall(array $event, ?string $subtype): void } /** + * 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 @@ -236,140 +242,86 @@ private function handleAssistant(array $event): void } if (($item['type'] ?? null) === 'text' && is_string($item['text'] ?? null)) { - $fullText = trim($item['text']); - if ($fullText === '') { + // Preserve original spacing - don't trim! + $incomingText = $item['text']; + $trimmedIncoming = trim($incomingText); + $trimmedAccumulated = trim($this->accumulatedParts); + + if ($trimmedIncoming === '') { continue; } - // Check if this full message (or very similar) was already seen - if ($this->isMessageAlreadySeen($fullText)) { - // Update buffer for delta tracking but don't emit - $this->assistantBuffer = $fullText; + // 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('text', "\n\n")); + } - continue; - } + // Emit the complete message (use the trimmed incoming text as it has proper spacing) + $this->chunks->enqueue(new EditStreamChunkDto('text', $trimmedIncoming)); + $this->hasEmittedText = true; + $this->accumulatedParts = ''; - // Extract delta from cumulative text - $delta = $this->resolveAssistantDelta($fullText); - if (trim($delta) === '') { continue; } - // Add paragraph break if we already have content and this is a new sentence - $trimmedDelta = trim($delta); - if ($this->hasEmittedText && $this->shouldAddParagraphBreak($trimmedDelta)) { - $this->chunks->enqueue(new EditStreamChunkDto('text', "\n\n")); - } - - $this->hasEmittedText = true; - $this->lastFullMessage = $fullText; - $this->recordSeenMessage($fullText); - $this->chunks->enqueue(new EditStreamChunkDto('text', $delta)); + // This is a streaming part - accumulate by direct concatenation (preserves natural spacing) + $this->accumulatedParts .= $incomingText; } } } /** - * Check if a message (or very similar) was already seen. + * 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 isMessageAlreadySeen(string $message): bool + private function isCompleteMessage(string $incoming, string $accumulated): bool { - // Check against last full message first (most common case) - if ($this->isSimilarToMessage($message, $this->lastFullMessage)) { - return true; - } + // Normalize whitespace for comparison + $normalizedIncoming = preg_replace('/\s+/', ' ', $incoming) ?? $incoming; + $normalizedAccumulated = preg_replace('/\s+/', ' ', $accumulated) ?? $accumulated; - // Check against all seen messages for exact or near duplicates - foreach ($this->seenMessages as $seen) { - if ($this->isSimilarToMessage($message, $seen)) { - return true; - } + // Exact match after normalization + if ($normalizedIncoming === $normalizedAccumulated) { + return true; } - return false; - } + // Check similarity (allow for minor differences like punctuation spacing) + $lenIncoming = mb_strlen($normalizedIncoming); + $lenAccumulated = mb_strlen($normalizedAccumulated); - /** - * Check if two messages are similar enough to be considered duplicates. - */ - private function isSimilarToMessage(string $a, string $b): bool - { - if ($a === '' || $b === '') { + // Must be similar length (within 10%) + if ($lenIncoming === 0 || $lenAccumulated === 0) { return false; } - // Exact match - if ($a === $b) { - return true; - } - - // One is a prefix of the other (cumulative update - the longer one is the "real" message) - if (str_starts_with($a, $b) || str_starts_with($b, $a)) { - return true; - } - - // For messages of similar length, check similarity - $lenA = mb_strlen($a); - $lenB = mb_strlen($b); - - // Only check similarity for messages of similar length (within 20%) - if (min($lenA, $lenB) / max($lenA, $lenB) < 0.8) { + $lengthRatio = min($lenIncoming, $lenAccumulated) / max($lenIncoming, $lenAccumulated); + if ($lengthRatio < 0.9) { return false; } - // Use levenshtein for short texts - if ($lenA <= 255 && $lenB <= 255) { - $distance = levenshtein($a, $b); - $similarity = 1 - ($distance / max($lenA, $lenB)); + // For reasonably sized strings, use levenshtein + if ($lenIncoming <= 255 && $lenAccumulated <= 255) { + $distance = levenshtein($normalizedIncoming, $normalizedAccumulated); + $similarity = 1 - ($distance / max($lenIncoming, $lenAccumulated)); - return $similarity > 0.85; + return $similarity > 0.9; } - // For longer texts, check if they share a long common prefix + // For longer strings, check prefix similarity + $minLen = min($lenIncoming, $lenAccumulated); $commonLen = 0; - $minLen = min($lenA, $lenB); for ($i = 0; $i < $minLen; ++$i) { - if ($a[$i] !== $b[$i]) { + if ($normalizedIncoming[$i] !== $normalizedAccumulated[$i]) { break; } ++$commonLen; } - return $commonLen / $minLen > 0.85; - } - - /** - * Record a message as seen (keep only recent messages to limit memory). - */ - private function recordSeenMessage(string $message): void - { - // Only record substantial messages - if (mb_strlen($message) < 20) { - return; - } - - $this->seenMessages[] = $message; - - // Keep only the last 20 messages to limit memory - if (count($this->seenMessages) > 20) { - array_shift($this->seenMessages); - } - } - - /** - * Determine if we should add a paragraph break before this text. - */ - private function shouldAddParagraphBreak(string $text): bool - { - $text = trim($text); - if ($text === '') { - return false; - } - - // Add paragraph if the new text starts with a capital letter (new sentence) - $firstChar = mb_substr($text, 0, 1); - - return preg_match('/[A-Z]/', $firstChar) === 1; + return $commonLen / $minLen > 0.9; } /** @@ -469,67 +421,6 @@ private function truncate(string $value, int $maxLength = 500): string return mb_substr($value, 0, $maxLength) . '...'; } - /** - * Resolve the delta from cumulative assistant text. - * - * The Cursor agent streams assistant messages cumulatively - each message - * contains all text so far, not just the new part. We need to extract - * only the new text (delta) to avoid duplicates. - */ - private function resolveAssistantDelta(string $incoming): string - { - if ($incoming === '') { - return ''; - } - - // If incoming starts with the buffer, extract just the new part - if ($this->assistantBuffer !== '' && str_starts_with($incoming, $this->assistantBuffer)) { - $delta = substr($incoming, strlen($this->assistantBuffer)); - $this->assistantBuffer = $incoming; - - return $delta; - } - - // Check if the buffer ends with the beginning of incoming (overlap case) - if ($this->assistantBuffer !== '') { - $overlap = $this->findOverlap($this->assistantBuffer, $incoming); - if ($overlap > 0) { - $delta = substr($incoming, $overlap); - $this->assistantBuffer = $this->assistantBuffer . $delta; - - return $delta; - } - } - - // Check if this exact text was already in the buffer (complete duplicate) - if (str_contains($this->assistantBuffer, $incoming)) { - return ''; - } - - // New independent text segment - update buffer and return - $this->assistantBuffer = $incoming; - - return $incoming; - } - - /** - * Find how many characters of $suffix's beginning overlap with $prefix's end. - */ - private function findOverlap(string $prefix, string $suffix): int - { - $maxCheck = min(strlen($prefix), strlen($suffix)); - - for ($len = $maxCheck; $len > 0; --$len) { - $prefixEnd = substr($prefix, -$len); - $suffixStart = substr($suffix, 0, $len); - if ($prefixEnd === $suffixStart) { - return $len; - } - } - - return 0; - } - /** * @return array|null */ diff --git a/src/ProjectMgmt/Facade/ProjectMgmtFacade.php b/src/ProjectMgmt/Facade/ProjectMgmtFacade.php index 233a67c..d79f30e 100644 --- a/src/ProjectMgmt/Facade/ProjectMgmtFacade.php +++ b/src/ProjectMgmt/Facade/ProjectMgmtFacade.php @@ -12,6 +12,7 @@ use App\ProjectMgmt\Facade\Dto\AgentConfigTemplateDto; use App\ProjectMgmt\Facade\Dto\ExistingLlmApiKeyDto; use App\ProjectMgmt\Facade\Dto\ProjectInfoDto; +use App\ProjectMgmt\Facade\Enum\ContentEditorBackend; use App\ProjectMgmt\Facade\Enum\ProjectType; use App\WorkspaceMgmt\Infrastructure\Service\GitHubUrlServiceInterface; use Doctrine\ORM\EntityManagerInterface; @@ -48,6 +49,7 @@ public function createProjectFromPrefab(string $organizationId, PrefabDto $prefa $llmModelProvider, $prefab->llmApiKey, ProjectType::DEFAULT, + ContentEditorBackend::Llm, Project::DEFAULT_AGENT_IMAGE, null, null, From cfe0d253a12aeca61a82efb4c24419ffbd597bf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20K=C3=B6lbl?= Date: Fri, 6 Feb 2026 17:43:51 +0100 Subject: [PATCH 5/7] Adding cursor agent as alternative WIP --- .../Resources/templates/project_form.twig | 115 ++++++++++++++---- translations/messages.en.yaml | 7 +- 2 files changed, 100 insertions(+), 22 deletions(-) diff --git a/src/ProjectMgmt/Presentation/Resources/templates/project_form.twig b/src/ProjectMgmt/Presentation/Resources/templates/project_form.twig index 964ce63..8031eba 100644 --- a/src/ProjectMgmt/Presentation/Resources/templates/project_form.twig +++ b/src/ProjectMgmt/Presentation/Resources/templates/project_form.twig @@ -64,24 +64,6 @@

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

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

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

- {# LLM API Key with verification #} -
+
+ {{ 'project.form.llm_provider'|trans }} +
+ {% for provider in llmProviders %} + + {% endfor %} +
+
+
+
+
+ {{ 'project.form.cursor_provider'|trans }} +
+ +
+
+
+ + {# API Key (depends on backend) #} +
@@ -108,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') }} @@ -160,6 +182,57 @@
{% endif %} +
+ + +

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

+
+ + {# GitHub Access Key Helper - shown after base required fields #}
diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 680d479..a21a940 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -101,10 +101,15 @@ 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." - content_editor_backend: "Content editor backend" + 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" From 6326ca33c3055981b33daf60ae18260e953d932a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20K=C3=B6lbl?= Date: Tue, 10 Feb 2026 09:13:06 +0100 Subject: [PATCH 6/7] Adding cursor agent as alternative WIP fixes after main merge --- .../ContentEditor/ContentEditorFacade.php | 3 ++- .../ContentEditorFacadeInterface.php | 3 ++- .../Handler/RunEditSessionHandler.php | 1 - .../Facade/CursorAgentContentEditorFacade.php | 20 ++++++++++--------- ...ursorAgentContentEditorFacadeInterface.php | 3 ++- .../Streaming/CursorAgentStreamCollector.php | 9 +++++---- 6 files changed, 22 insertions(+), 17 deletions(-) diff --git a/src/ChatBasedContentEditor/Infrastructure/ContentEditor/ContentEditorFacade.php b/src/ChatBasedContentEditor/Infrastructure/ContentEditor/ContentEditorFacade.php index e77a783..7989d3e 100644 --- a/src/ChatBasedContentEditor/Infrastructure/ContentEditor/ContentEditorFacade.php +++ b/src/ChatBasedContentEditor/Infrastructure/ContentEditor/ContentEditorFacade.php @@ -35,7 +35,8 @@ public function streamEditWithHistory( array $previousMessages, string $apiKey, ?AgentConfigDto $agentConfig = null, - ?string $cursorAgentSessionId = null + ?string $cursorAgentSessionId = null, + string $locale = 'en', ): Generator { $adapter = $this->resolveAdapter($backend); diff --git a/src/ChatBasedContentEditor/Infrastructure/ContentEditor/ContentEditorFacadeInterface.php b/src/ChatBasedContentEditor/Infrastructure/ContentEditor/ContentEditorFacadeInterface.php index 9ccebbb..0302113 100644 --- a/src/ChatBasedContentEditor/Infrastructure/ContentEditor/ContentEditorFacadeInterface.php +++ b/src/ChatBasedContentEditor/Infrastructure/ContentEditor/ContentEditorFacadeInterface.php @@ -24,7 +24,8 @@ public function streamEditWithHistory( array $previousMessages, string $apiKey, ?AgentConfigDto $agentConfig = null, - ?string $cursorAgentSessionId = null + ?string $cursorAgentSessionId = null, + string $locale = 'en', ): Generator; public function getLastCursorAgentSessionId(): ?string; diff --git a/src/ChatBasedContentEditor/Infrastructure/Handler/RunEditSessionHandler.php b/src/ChatBasedContentEditor/Infrastructure/Handler/RunEditSessionHandler.php index 1d330ac..28fa402 100644 --- a/src/ChatBasedContentEditor/Infrastructure/Handler/RunEditSessionHandler.php +++ b/src/ChatBasedContentEditor/Infrastructure/Handler/RunEditSessionHandler.php @@ -20,7 +20,6 @@ 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; diff --git a/src/CursorAgentContentEditor/Facade/CursorAgentContentEditorFacade.php b/src/CursorAgentContentEditor/Facade/CursorAgentContentEditorFacade.php index bf0bf2b..668ba9e 100644 --- a/src/CursorAgentContentEditor/Facade/CursorAgentContentEditorFacade.php +++ b/src/CursorAgentContentEditor/Facade/CursorAgentContentEditorFacade.php @@ -10,6 +10,7 @@ use App\LlmContentEditor\Facade\Dto\AgentEventDto; use App\LlmContentEditor\Facade\Dto\ConversationMessageDto; use App\LlmContentEditor\Facade\Dto\EditStreamChunkDto; +use App\LlmContentEditor\Facade\Enum\EditStreamChunkType; use App\WorkspaceTooling\Facade\AgentExecutionContextInterface; use App\WorkspaceTooling\Facade\WorkspaceToolingServiceInterface; use Generator; @@ -42,7 +43,8 @@ public function streamEditWithHistory( array $previousMessages, string $apiKey, ?AgentConfigDto $agentConfig = null, - ?string $cursorAgentSessionId = null + ?string $cursorAgentSessionId = null, + string $locale = 'en', ): Generator { $this->lastSessionId = null; $collector = new CursorAgentStreamCollector(); @@ -57,7 +59,7 @@ public function streamEditWithHistory( $agentConfig ); - yield new EditStreamChunkDto('event', null, new AgentEventDto('inference_start')); + yield new EditStreamChunkDto(EditStreamChunkType::Event, null, new AgentEventDto('inference_start')); $agent = new ContentEditorAgent($this->workspaceTooling); $process = $agent->startAsync('/workspace', $prompt, $apiKey, $cursorAgentSessionId); @@ -85,27 +87,27 @@ public function streamEditWithHistory( // 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('event', null, new AgentEventDto('build_start')); + yield new EditStreamChunkDto(EditStreamChunkType::Event, null, new AgentEventDto('build_start')); $agentImage = $this->executionContext->getAgentImage() ?? 'node:22-slim'; try { $buildOutput = $this->workspaceTooling->runBuildInWorkspace($workspacePath, $agentImage); - yield new EditStreamChunkDto('event', null, new AgentEventDto('build_complete', null, null, $buildOutput)); + yield new EditStreamChunkDto(EditStreamChunkType::Event, null, new AgentEventDto('build_complete', null, null, $buildOutput)); } catch (RuntimeException $e) { - yield new EditStreamChunkDto('event', null, new AgentEventDto('build_error', null, null, null, $e->getMessage())); + yield new EditStreamChunkDto(EditStreamChunkType::Event, null, new AgentEventDto('build_error', null, null, null, $e->getMessage())); } - yield new EditStreamChunkDto('event', null, new AgentEventDto('inference_stop')); + yield new EditStreamChunkDto(EditStreamChunkType::Event, null, new AgentEventDto('inference_stop')); yield new EditStreamChunkDto( - 'done', + EditStreamChunkType::Done, null, null, $collector->isSuccess(), $collector->getErrorMessage() ); } catch (Throwable $e) { - yield new EditStreamChunkDto('event', null, new AgentEventDto('inference_stop')); - yield new EditStreamChunkDto('done', null, null, false, $e->getMessage()); + 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); } diff --git a/src/CursorAgentContentEditor/Facade/CursorAgentContentEditorFacadeInterface.php b/src/CursorAgentContentEditor/Facade/CursorAgentContentEditorFacadeInterface.php index e458003..16e81b9 100644 --- a/src/CursorAgentContentEditor/Facade/CursorAgentContentEditorFacadeInterface.php +++ b/src/CursorAgentContentEditor/Facade/CursorAgentContentEditorFacadeInterface.php @@ -24,7 +24,8 @@ public function streamEditWithHistory( array $previousMessages, string $apiKey, ?AgentConfigDto $agentConfig = null, - ?string $cursorAgentSessionId = null + ?string $cursorAgentSessionId = null, + string $locale = 'en', ): Generator; public function getLastSessionId(): ?string; diff --git a/src/CursorAgentContentEditor/Infrastructure/Streaming/CursorAgentStreamCollector.php b/src/CursorAgentContentEditor/Infrastructure/Streaming/CursorAgentStreamCollector.php index b17e2b5..5674ee4 100644 --- a/src/CursorAgentContentEditor/Infrastructure/Streaming/CursorAgentStreamCollector.php +++ b/src/CursorAgentContentEditor/Infrastructure/Streaming/CursorAgentStreamCollector.php @@ -7,6 +7,7 @@ use App\LlmContentEditor\Facade\Dto\AgentEventDto; use App\LlmContentEditor\Facade\Dto\EditStreamChunkDto; use App\LlmContentEditor\Facade\Dto\ToolInputEntryDto; +use App\LlmContentEditor\Facade\Enum\EditStreamChunkType; use JsonException; use SplQueue; @@ -255,11 +256,11 @@ private function handleAssistant(array $event): void if ($trimmedAccumulated !== '' && $this->isCompleteMessage($trimmedIncoming, $trimmedAccumulated)) { // Add paragraph break if we already have content if ($this->hasEmittedText) { - $this->chunks->enqueue(new EditStreamChunkDto('text', "\n\n")); + $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('text', $trimmedIncoming)); + $this->chunks->enqueue(new EditStreamChunkDto(EditStreamChunkType::Text, $trimmedIncoming)); $this->hasEmittedText = true; $this->accumulatedParts = ''; @@ -337,7 +338,7 @@ private function handleResult(array $event, ?string $subtype): void // 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('text', $result)); + $this->chunks->enqueue(new EditStreamChunkDto(EditStreamChunkType::Text, $result)); } return; @@ -374,7 +375,7 @@ private function captureSessionId(array $event): void private function enqueueEvent(AgentEventDto $event): void { - $this->chunks->enqueue(new EditStreamChunkDto('event', null, $event)); + $this->chunks->enqueue(new EditStreamChunkDto(EditStreamChunkType::Event, null, $event)); } /** From 75cb7e27d31b165840f7c762deef0aaf481a1f07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20K=C3=B6lbl?= Date: Wed, 11 Feb 2026 16:25:25 +0100 Subject: [PATCH 7/7] Adding cursor agent as alternative WIP abstracted agent usage behind AgenticContentEditor vertical --- config/services.yaml | 15 +- docs/vertical-wiring.md | 34 ++++- migrations/Version20260211120000.php | 30 ++++ .../AgenticContentEditorAdapterInterface.php | 54 +++++++ .../Facade/AgenticContentEditorFacade.php | 87 +++++++++++ .../AgenticContentEditorFacadeInterface.php | 54 +++++++ .../Facade/Dto/AgentConfigDto.php | 4 +- .../Facade/Dto/AgentEventDto.php | 31 ++++ .../Facade/Dto/BackendModelInfoDto.php | 27 ++++ .../Facade/Dto/ConversationMessageDto.php | 7 +- .../Facade/Dto/EditStreamChunkDto.php | 6 +- .../Facade/Dto/ToolInputEntryDto.php | 2 +- .../Enum/AgenticContentEditorBackend.php} | 4 +- .../Facade/Enum/EditStreamChunkType.php | 4 +- .../Domain/Entity/Conversation.php | 30 ++-- .../Domain/Service/ConversationService.php | 3 +- .../ContentEditorAdapterInterface.php | 32 ---- .../ContentEditor/ContentEditorFacade.php | 76 ---------- .../ContentEditorFacadeInterface.php | 32 ---- .../CursorAgentContentEditorAdapter.php | 53 ------- .../ContentEditor/LlmContentEditorAdapter.php | 52 ------- .../Handler/RunEditSessionHandler.php | 56 +++---- .../ChatBasedContentEditorController.php | 33 +++-- .../chat_based_content_editor_controller.ts | 20 +-- .../assets/controllers/chat_editor_helpers.ts | 14 +- .../ConversationContextUsageService.php | 22 +-- ...ursorAgentContentEditorFacadeInterface.php | 32 ---- .../CursorAgentContentEditorAdapter.php} | 137 +++++++++++------- .../Streaming/CursorAgentStreamCollector.php | 8 +- .../Domain/Agent/ContentEditorAgent.php | 2 +- .../Domain/Command/EditContentCommand.php | 2 +- .../Facade/Dto/AgentEventDto.php | 25 ---- .../Facade/LlmContentEditorFacade.php | 20 +-- .../LlmContentEditorFacadeInterface.php | 17 +-- .../Infrastructure/AgentEventQueue.php | 2 +- .../ChatHistory/MessageSerializer.php | 2 +- .../LlmContentEditorAdapter.php | 79 ++++++++++ .../Observer/AgentEventCollectingObserver.php | 4 +- .../ProgressMessageResolver.php | 4 +- src/ProjectMgmt/Domain/Entity/Project.php | 38 ++--- .../Domain/Service/ProjectService.php | 80 +++++----- src/ProjectMgmt/Facade/Dto/ProjectInfoDto.php | 42 +++--- .../Facade/Enum/ContentEditorBackend.php | 11 -- src/ProjectMgmt/Facade/ProjectMgmtFacade.php | 4 +- .../Controller/ProjectController.php | 10 +- .../AgenticContentEditorFacadeTest.php | 137 ++++++++++++++++++ .../CursorAgentContentEditorAdapterTest.php | 123 ++++++++++++++++ .../ChatHistory/MessageSerializerTest.php | 2 +- .../ContentEditorAgentTest.php | 2 +- .../ProgressMessageResolverTest.php | 4 +- tests/Unit/ProjectMgmt/ProjectServiceTest.php | 4 +- 51 files changed, 942 insertions(+), 631 deletions(-) create mode 100644 migrations/Version20260211120000.php create mode 100644 src/AgenticContentEditor/Facade/AgenticContentEditorAdapterInterface.php create mode 100644 src/AgenticContentEditor/Facade/AgenticContentEditorFacade.php create mode 100644 src/AgenticContentEditor/Facade/AgenticContentEditorFacadeInterface.php rename src/{LlmContentEditor => AgenticContentEditor}/Facade/Dto/AgentConfigDto.php (84%) create mode 100644 src/AgenticContentEditor/Facade/Dto/AgentEventDto.php create mode 100644 src/AgenticContentEditor/Facade/Dto/BackendModelInfoDto.php rename src/{LlmContentEditor => AgenticContentEditor}/Facade/Dto/ConversationMessageDto.php (77%) rename src/{LlmContentEditor => AgenticContentEditor}/Facade/Dto/EditStreamChunkDto.php (60%) rename src/{LlmContentEditor => AgenticContentEditor}/Facade/Dto/ToolInputEntryDto.php (78%) rename src/{ChatBasedContentEditor/Domain/Enum/ContentEditorBackend.php => AgenticContentEditor/Facade/Enum/AgenticContentEditorBackend.php} (54%) rename src/{LlmContentEditor => AgenticContentEditor}/Facade/Enum/EditStreamChunkType.php (67%) delete mode 100644 src/ChatBasedContentEditor/Infrastructure/ContentEditor/ContentEditorAdapterInterface.php delete mode 100644 src/ChatBasedContentEditor/Infrastructure/ContentEditor/ContentEditorFacade.php delete mode 100644 src/ChatBasedContentEditor/Infrastructure/ContentEditor/ContentEditorFacadeInterface.php delete mode 100644 src/ChatBasedContentEditor/Infrastructure/ContentEditor/CursorAgentContentEditorAdapter.php delete mode 100644 src/ChatBasedContentEditor/Infrastructure/ContentEditor/LlmContentEditorAdapter.php delete mode 100644 src/CursorAgentContentEditor/Facade/CursorAgentContentEditorFacadeInterface.php rename src/CursorAgentContentEditor/{Facade/CursorAgentContentEditorFacade.php => Infrastructure/CursorAgentContentEditorAdapter.php} (63%) delete mode 100644 src/LlmContentEditor/Facade/Dto/AgentEventDto.php create mode 100644 src/LlmContentEditor/Infrastructure/LlmContentEditorAdapter.php delete mode 100644 src/ProjectMgmt/Facade/Enum/ContentEditorBackend.php create mode 100644 tests/Unit/AgenticContentEditor/AgenticContentEditorFacadeTest.php create mode 100644 tests/Unit/CursorAgentContentEditor/CursorAgentContentEditorAdapterTest.php diff --git a/config/services.yaml b/config/services.yaml index a42b729..30305aa 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -133,19 +133,16 @@ services: App\ChatBasedContentEditor\Facade\ChatBasedContentEditorFacadeInterface: class: App\ChatBasedContentEditor\Facade\ChatBasedContentEditorFacade - App\CursorAgentContentEditor\Facade\CursorAgentContentEditorFacadeInterface: - class: App\CursorAgentContentEditor\Facade\CursorAgentContentEditorFacade + App\LlmContentEditor\Infrastructure\LlmContentEditorAdapter: ~ - App\ChatBasedContentEditor\Infrastructure\ContentEditor\LlmContentEditorAdapter: ~ + App\CursorAgentContentEditor\Infrastructure\CursorAgentContentEditorAdapter: ~ - App\ChatBasedContentEditor\Infrastructure\ContentEditor\CursorAgentContentEditorAdapter: ~ - - App\ChatBasedContentEditor\Infrastructure\ContentEditor\ContentEditorFacadeInterface: - class: App\ChatBasedContentEditor\Infrastructure\ContentEditor\ContentEditorFacade + App\AgenticContentEditor\Facade\AgenticContentEditorFacadeInterface: + class: App\AgenticContentEditor\Facade\AgenticContentEditorFacade arguments: - [ - '@App\ChatBasedContentEditor\Infrastructure\ContentEditor\LlmContentEditorAdapter', - '@App\ChatBasedContentEditor\Infrastructure\ContentEditor\CursorAgentContentEditorAdapter', + '@App\LlmContentEditor\Infrastructure\LlmContentEditorAdapter', + '@App\CursorAgentContentEditor\Infrastructure\CursorAgentContentEditorAdapter', ] # Domain service bindings 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/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; @@ -111,17 +111,17 @@ public function getWorkspacePath(): string type: Types::STRING, length: 32, nullable: false, - enumType: ContentEditorBackend::class, - options: ['default' => ContentEditorBackend::Llm->value] + enumType: AgenticContentEditorBackend::class, + options: ['default' => AgenticContentEditorBackend::Llm->value] )] - private ContentEditorBackend $contentEditorBackend = ContentEditorBackend::Llm; + private AgenticContentEditorBackend $contentEditorBackend = AgenticContentEditorBackend::Llm; - public function getContentEditorBackend(): ContentEditorBackend + public function getContentEditorBackend(): AgenticContentEditorBackend { return $this->contentEditorBackend; } - public function setContentEditorBackend(ContentEditorBackend $contentEditorBackend): void + public function setContentEditorBackend(AgenticContentEditorBackend $contentEditorBackend): void { $this->contentEditorBackend = $contentEditorBackend; } @@ -163,16 +163,16 @@ public function updateLastActivity(): void length: 64, nullable: true )] - private ?string $cursorAgentSessionId = null; + private ?string $backendSessionState = null; - public function getCursorAgentSessionId(): ?string + public function getBackendSessionState(): ?string { - return $this->cursorAgentSessionId; + return $this->backendSessionState; } - public function setCursorAgentSessionId(?string $cursorAgentSessionId): void + public function setBackendSessionState(?string $backendSessionState): void { - $this->cursorAgentSessionId = $cursorAgentSessionId; + $this->backendSessionState = $backendSessionState; } /** diff --git a/src/ChatBasedContentEditor/Domain/Service/ConversationService.php b/src/ChatBasedContentEditor/Domain/Service/ConversationService.php index 1b4e14f..0008162 100644 --- a/src/ChatBasedContentEditor/Domain/Service/ConversationService.php +++ b/src/ChatBasedContentEditor/Domain/Service/ConversationService.php @@ -7,7 +7,6 @@ use App\Account\Facade\AccountFacadeInterface; use App\ChatBasedContentEditor\Domain\Dto\ConversationInfoDto; use App\ChatBasedContentEditor\Domain\Entity\Conversation; -use App\ChatBasedContentEditor\Domain\Enum\ContentEditorBackend; use App\ChatBasedContentEditor\Domain\Enum\ConversationStatus; use App\ChatBasedContentEditor\Infrastructure\Service\ConversationUrlServiceInterface; use App\ProjectMgmt\Facade\ProjectMgmtFacadeInterface; @@ -52,7 +51,7 @@ public function startOrResumeConversation(string $projectId, string $userId): Co } $projectInfo = $this->projectMgmtFacade->getProjectInfo($projectId); - $contentEditorBackend = ContentEditorBackend::from($projectInfo->contentEditorBackend->value); + $contentEditorBackend = $projectInfo->contentEditorBackend; // Transition workspace to IN_CONVERSATION $this->workspaceMgmtFacade->transitionToInConversation($workspaceInfo->id); diff --git a/src/ChatBasedContentEditor/Infrastructure/ContentEditor/ContentEditorAdapterInterface.php b/src/ChatBasedContentEditor/Infrastructure/ContentEditor/ContentEditorAdapterInterface.php deleted file mode 100644 index 9705b61..0000000 --- a/src/ChatBasedContentEditor/Infrastructure/ContentEditor/ContentEditorAdapterInterface.php +++ /dev/null @@ -1,32 +0,0 @@ - $previousMessages - * - * @return Generator - */ - public function streamEditWithHistory( - string $workspacePath, - string $instruction, - array $previousMessages, - string $apiKey, - ?AgentConfigDto $agentConfig = null, - ?string $cursorAgentSessionId = null - ): Generator; - - public function getLastCursorAgentSessionId(): ?string; -} diff --git a/src/ChatBasedContentEditor/Infrastructure/ContentEditor/ContentEditorFacade.php b/src/ChatBasedContentEditor/Infrastructure/ContentEditor/ContentEditorFacade.php deleted file mode 100644 index 7989d3e..0000000 --- a/src/ChatBasedContentEditor/Infrastructure/ContentEditor/ContentEditorFacade.php +++ /dev/null @@ -1,76 +0,0 @@ - $adapters - */ - public function __construct( - private readonly array $adapters - ) { - } - - /** - * @param list $previousMessages - * - * @return Generator - */ - public function streamEditWithHistory( - ContentEditorBackend $backend, - string $workspacePath, - string $instruction, - array $previousMessages, - string $apiKey, - ?AgentConfigDto $agentConfig = null, - ?string $cursorAgentSessionId = null, - string $locale = 'en', - ): Generator { - $adapter = $this->resolveAdapter($backend); - - $generator = $adapter->streamEditWithHistory( - $workspacePath, - $instruction, - $previousMessages, - $apiKey, - $agentConfig, - $cursorAgentSessionId - ); - - try { - foreach ($generator as $chunk) { - yield $chunk; - } - } finally { - $this->lastCursorAgentSessionId = $adapter->getLastCursorAgentSessionId(); - } - } - - public function getLastCursorAgentSessionId(): ?string - { - return $this->lastCursorAgentSessionId; - } - - private function resolveAdapter(ContentEditorBackend $backend): ContentEditorAdapterInterface - { - 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/ChatBasedContentEditor/Infrastructure/ContentEditor/ContentEditorFacadeInterface.php b/src/ChatBasedContentEditor/Infrastructure/ContentEditor/ContentEditorFacadeInterface.php deleted file mode 100644 index 0302113..0000000 --- a/src/ChatBasedContentEditor/Infrastructure/ContentEditor/ContentEditorFacadeInterface.php +++ /dev/null @@ -1,32 +0,0 @@ - $previousMessages - * - * @return Generator - */ - public function streamEditWithHistory( - ContentEditorBackend $backend, - string $workspacePath, - string $instruction, - array $previousMessages, - string $apiKey, - ?AgentConfigDto $agentConfig = null, - ?string $cursorAgentSessionId = null, - string $locale = 'en', - ): Generator; - - public function getLastCursorAgentSessionId(): ?string; -} diff --git a/src/ChatBasedContentEditor/Infrastructure/ContentEditor/CursorAgentContentEditorAdapter.php b/src/ChatBasedContentEditor/Infrastructure/ContentEditor/CursorAgentContentEditorAdapter.php deleted file mode 100644 index 5c0782c..0000000 --- a/src/ChatBasedContentEditor/Infrastructure/ContentEditor/CursorAgentContentEditorAdapter.php +++ /dev/null @@ -1,53 +0,0 @@ - $previousMessages - * - * @return Generator - */ - public function streamEditWithHistory( - string $workspacePath, - string $instruction, - array $previousMessages, - string $apiKey, - ?AgentConfigDto $agentConfig = null, - ?string $cursorAgentSessionId = null - ): Generator { - return $this->cursorAgentContentEditorFacade->streamEditWithHistory( - $workspacePath, - $instruction, - $previousMessages, - $apiKey, - $agentConfig, - $cursorAgentSessionId - ); - } - - public function getLastCursorAgentSessionId(): ?string - { - return $this->cursorAgentContentEditorFacade->getLastSessionId(); - } -} diff --git a/src/ChatBasedContentEditor/Infrastructure/ContentEditor/LlmContentEditorAdapter.php b/src/ChatBasedContentEditor/Infrastructure/ContentEditor/LlmContentEditorAdapter.php deleted file mode 100644 index 6fe1064..0000000 --- a/src/ChatBasedContentEditor/Infrastructure/ContentEditor/LlmContentEditorAdapter.php +++ /dev/null @@ -1,52 +0,0 @@ - $previousMessages - * - * @return Generator - */ - public function streamEditWithHistory( - string $workspacePath, - string $instruction, - array $previousMessages, - string $apiKey, - ?AgentConfigDto $agentConfig = null, - ?string $cursorAgentSessionId = null - ): Generator { - return $this->llmContentEditorFacade->streamEditWithHistory( - $workspacePath, - $instruction, - $previousMessages, - $apiKey, - $agentConfig ?? new AgentConfigDto('', '', '') - ); - } - - public function getLastCursorAgentSessionId(): ?string - { - return null; - } -} diff --git a/src/ChatBasedContentEditor/Infrastructure/Handler/RunEditSessionHandler.php b/src/ChatBasedContentEditor/Infrastructure/Handler/RunEditSessionHandler.php index 28fa402..ddabb79 100644 --- a/src/ChatBasedContentEditor/Infrastructure/Handler/RunEditSessionHandler.php +++ b/src/ChatBasedContentEditor/Infrastructure/Handler/RunEditSessionHandler.php @@ -5,21 +5,20 @@ 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; use App\ChatBasedContentEditor\Domain\Entity\EditSessionChunk; -use App\ChatBasedContentEditor\Domain\Enum\ContentEditorBackend; use App\ChatBasedContentEditor\Domain\Enum\ConversationMessageRole; use App\ChatBasedContentEditor\Domain\Enum\EditSessionStatus; -use App\ChatBasedContentEditor\Infrastructure\ContentEditor\ContentEditorFacadeInterface; 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\ProjectMgmt\Facade\ProjectMgmtFacadeInterface; use App\WorkspaceMgmt\Facade\WorkspaceMgmtFacadeInterface; use App\WorkspaceTooling\Facade\AgentExecutionContextInterface; @@ -37,14 +36,14 @@ final readonly class RunEditSessionHandler { public function __construct( - private EntityManagerInterface $entityManager, - private ContentEditorFacadeInterface $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, ) { } @@ -93,20 +92,12 @@ public function __invoke(RunEditSessionMessage $message): void return; } - $agentImage = $project->agentImage; - if ($conversation->getContentEditorBackend() === ContentEditorBackend::CursorAgent) { - $cursorAgentImage = $_ENV['CURSOR_AGENT_IMAGE'] ?? null; - if (is_string($cursorAgentImage) && $cursorAgentImage !== '') { - $agentImage = $cursorAgentImage; - } - } - $this->executionContext->setContext( $conversation->getWorkspaceId(), $session->getWorkspacePath(), $conversation->getId(), $workspace->projectName, - $agentImage, + $project->agentImage, $project->remoteContentAssetsManifestUrls ); @@ -125,7 +116,7 @@ public function __invoke(RunEditSessionMessage $message): void $previousMessages, $project->llmApiKey, $agentConfig, - $conversation->getCursorAgentSessionId(), + $conversation->getBackendSessionState(), $message->locale, ); @@ -168,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, @@ -181,14 +175,6 @@ public function __invoke(RunEditSessionMessage $message): void $session->setStatus(EditSessionStatus::Completed); $this->entityManager->flush(); - if ($conversation->getContentEditorBackend() === ContentEditorBackend::CursorAgent) { - $sessionId = $this->facade->getLastCursorAgentSessionId(); - if ($sessionId !== null && $sessionId !== $conversation->getCursorAgentSessionId()) { - $conversation->setCursorAgentSessionId($sessionId); - $this->entityManager->flush(); - } - } - // Commit and push changes after successful edit session $this->commitChangesAfterEdit($conversation, $session); } catch (Throwable $e) { @@ -246,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 c0b47c5..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,18 +1050,8 @@ 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 "build_start": - wrap.textContent = "▶ Building workspace…"; - wrap.classList.add("text-blue-600/70", "dark:text-blue-400/70"); - break; - case "build_complete": { - const result = (e.toolResult ?? "").slice(0, 200); - wrap.innerHTML = `◀ Build completed. ${escapeHtml(result)}${(e.toolResult?.length ?? 0) > 200 ? "…" : ""}`; - wrap.classList.add("text-green-600/70", "dark:text-green-400/70"); - break; - } - case "build_error": - wrap.textContent = `✖ Build failed: ${e.errorMessage ?? tr.unknownError}`; + 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: @@ -1149,11 +1139,11 @@ export default class extends Controller { } private updateActivityIndicators(container: HTMLElement, event: AgentEvent): void { - // Working badge tracks tool calls and post-agent build - if (event.kind === "tool_calling" || event.kind === "build_start") { + // Working badge tracks tool calls (including run_build) + if (event.kind === "tool_calling") { this.onToolCall(container); } - if (event.kind === "build_complete" || event.kind === "build_error") { + if (event.kind === "tool_called" || event.kind === "tool_error") { this.completeActivityIndicators(container); } } 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 dfce856..bdf2445 100644 --- a/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/chat_editor_helpers.ts +++ b/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/chat_editor_helpers.ts @@ -213,8 +213,8 @@ export interface ProgressAnimationState { * Returns whether to intensify animations, return to normal, or do nothing. */ export function getProgressAnimationState(eventKind: string): ProgressAnimationState { - const intensifyEvents = ["tool_calling", "inference_start", "build_start"]; - const normalizeEvents = ["tool_called", "inference_stop", "build_complete", "build_error"]; + const intensifyEvents = ["tool_calling", "inference_start"]; + const normalizeEvents = ["tool_called", "inference_stop", "tool_error"]; return { intensify: intensifyEvents.includes(eventKind), @@ -227,14 +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", - "build_start", - "build_complete", - "build_error", - ]; + 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/Facade/CursorAgentContentEditorFacadeInterface.php b/src/CursorAgentContentEditor/Facade/CursorAgentContentEditorFacadeInterface.php deleted file mode 100644 index 16e81b9..0000000 --- a/src/CursorAgentContentEditor/Facade/CursorAgentContentEditorFacadeInterface.php +++ /dev/null @@ -1,32 +0,0 @@ - $previousMessages - * - * @return Generator - */ - public function streamEditWithHistory( - string $workspacePath, - string $instruction, - array $previousMessages, - string $apiKey, - ?AgentConfigDto $agentConfig = null, - ?string $cursorAgentSessionId = null, - string $locale = 'en', - ): Generator; - - public function getLastSessionId(): ?string; -} diff --git a/src/CursorAgentContentEditor/Facade/CursorAgentContentEditorFacade.php b/src/CursorAgentContentEditor/Infrastructure/CursorAgentContentEditorAdapter.php similarity index 63% rename from src/CursorAgentContentEditor/Facade/CursorAgentContentEditorFacade.php rename to src/CursorAgentContentEditor/Infrastructure/CursorAgentContentEditorAdapter.php index 668ba9e..81744db 100644 --- a/src/CursorAgentContentEditor/Facade/CursorAgentContentEditorFacade.php +++ b/src/CursorAgentContentEditor/Infrastructure/CursorAgentContentEditorAdapter.php @@ -2,76 +2,78 @@ declare(strict_types=1); -namespace App\CursorAgentContentEditor\Facade; - +namespace App\CursorAgentContentEditor\Infrastructure; + +use App\AgenticContentEditor\Facade\AgenticContentEditorAdapterInterface; +use App\AgenticContentEditor\Facade\Dto\AgentConfigDto; +use App\AgenticContentEditor\Facade\Dto\AgentEventDto; +use App\AgenticContentEditor\Facade\Dto\BackendModelInfoDto; +use App\AgenticContentEditor\Facade\Dto\ConversationMessageDto; +use App\AgenticContentEditor\Facade\Dto\EditStreamChunkDto; +use App\AgenticContentEditor\Facade\Enum\AgenticContentEditorBackend; +use App\AgenticContentEditor\Facade\Enum\EditStreamChunkType; use App\CursorAgentContentEditor\Domain\Agent\ContentEditorAgent; use App\CursorAgentContentEditor\Infrastructure\Streaming\CursorAgentStreamCollector; -use App\LlmContentEditor\Facade\Dto\AgentConfigDto; -use App\LlmContentEditor\Facade\Dto\AgentEventDto; -use App\LlmContentEditor\Facade\Dto\ConversationMessageDto; -use App\LlmContentEditor\Facade\Dto\EditStreamChunkDto; -use App\LlmContentEditor\Facade\Enum\EditStreamChunkType; use App\WorkspaceTooling\Facade\AgentExecutionContextInterface; use App\WorkspaceTooling\Facade\WorkspaceToolingServiceInterface; use Generator; use RuntimeException; use Throwable; -final class CursorAgentContentEditorFacade implements CursorAgentContentEditorFacadeInterface +final class CursorAgentContentEditorAdapter implements AgenticContentEditorAdapterInterface { /** * Polling interval in microseconds (50ms). */ private const int POLL_INTERVAL_US = 50_000; - private ?string $lastSessionId = null; - public function __construct( private readonly WorkspaceToolingServiceInterface $workspaceTooling, private readonly AgentExecutionContextInterface $executionContext, ) { } + public function supports(AgenticContentEditorBackend $backend): bool + { + return $backend === AgenticContentEditorBackend::CursorAgent; + } + /** * @param list $previousMessages * * @return Generator */ - public function streamEditWithHistory( - string $workspacePath, - string $instruction, - array $previousMessages, - string $apiKey, - ?AgentConfigDto $agentConfig = null, - ?string $cursorAgentSessionId = null, - string $locale = 'en', + public function streamEdit( + string $workspacePath, + string $instruction, + array $previousMessages, + string $apiKey, + AgentConfigDto $agentConfig, + ?string $backendSessionState = null, + string $locale = 'en', ): Generator { - $this->lastSessionId = null; - $collector = new CursorAgentStreamCollector(); - + $collector = new CursorAgentStreamCollector(); $this->executionContext->setOutputCallback($collector); try { $prompt = $this->buildPrompt( $instruction, $previousMessages, - $cursorAgentSessionId === null, + $backendSessionState === null, $agentConfig ); yield new EditStreamChunkDto(EditStreamChunkType::Event, null, new AgentEventDto('inference_start')); $agent = new ContentEditorAgent($this->workspaceTooling); - $process = $agent->startAsync('/workspace', $prompt, $apiKey, $cursorAgentSessionId); + $process = $agent->startAsync('/workspace', $prompt, $apiKey, $backendSessionState); // Poll for chunks while the process is running while ($process->isRunning()) { - // Drain any chunks that have arrived foreach ($collector->drain() as $chunk) { yield $chunk; } - // Brief sleep to avoid busy-waiting usleep(self::POLL_INTERVAL_US); } @@ -83,17 +85,18 @@ public function streamEditWithHistory( yield $chunk; } - $this->lastSessionId = $collector->getLastSessionId(); + $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('build_start')); + 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('build_complete', null, null, $buildOutput)); + 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('build_error', null, null, null, $e->getMessage())); + 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')); @@ -103,7 +106,9 @@ public function streamEditWithHistory( null, null, $collector->isSuccess(), - $collector->getErrorMessage() + $collector->getErrorMessage(), + null, + $lastSessionId ); } catch (Throwable $e) { yield new EditStreamChunkDto(EditStreamChunkType::Event, null, new AgentEventDto('inference_stop')); @@ -113,23 +118,60 @@ public function streamEditWithHistory( } } - public function getLastSessionId(): ?string + public function getBackendModelInfo(): BackendModelInfoDto { - return $this->lastSessionId; + // 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 $instruction, + array $previousMessages, + bool $isFirstMessage, + AgentConfigDto $agentConfig ): string { $parts = []; - // Include system instructions and workspace rules only on first message of a session if ($isFirstMessage) { $systemContext = $this->buildSystemContext($agentConfig); if ($systemContext !== '') { @@ -137,7 +179,6 @@ private function buildPrompt( } } - // Add conversation history if any if ($previousMessages !== []) { $parts[] = $this->formatHistory($previousMessages); $parts[] = 'User: ' . $instruction; @@ -148,38 +189,29 @@ private function buildPrompt( return implode("\n\n", $parts); } - /** - * Build system context including agent instructions and workspace rules. - */ - private function buildSystemContext(?AgentConfigDto $agentConfig): string + private function buildSystemContext(AgentConfigDto $agentConfig): string { $sections = []; - // Add working folder info $sections[] = 'The working folder is: /workspace'; - // Add agent background instructions - if ($agentConfig !== null && trim($agentConfig->backgroundInstructions) !== '') { + if (trim($agentConfig->backgroundInstructions) !== '') { $sections[] = "## Background Instructions\n" . $agentConfig->backgroundInstructions; } - // Add agent step instructions - if ($agentConfig !== null && trim($agentConfig->stepInstructions) !== '') { + if (trim($agentConfig->stepInstructions) !== '') { $sections[] = "## Step-by-Step Instructions\n" . $agentConfig->stepInstructions; } - // Add agent output instructions - if ($agentConfig !== null && trim($agentConfig->outputInstructions) !== '') { + if (trim($agentConfig->outputInstructions) !== '') { $sections[] = "## Output Instructions\n" . $agentConfig->outputInstructions; } - // Add workspace rules $workspaceRules = $this->getWorkspaceRulesForPrompt(); if ($workspaceRules !== '') { $sections[] = "## Workspace Rules\n" . $workspaceRules; } - // Add critical instruction about running build $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 ' . @@ -188,9 +220,6 @@ private function buildSystemContext(?AgentConfigDto $agentConfig): string return implode("\n\n", $sections); } - /** - * Get workspace rules formatted for the prompt. - */ private function getWorkspaceRulesForPrompt(): string { $rulesJson = $this->workspaceTooling->getWorkspaceRules(); diff --git a/src/CursorAgentContentEditor/Infrastructure/Streaming/CursorAgentStreamCollector.php b/src/CursorAgentContentEditor/Infrastructure/Streaming/CursorAgentStreamCollector.php index 5674ee4..5e41f7e 100644 --- a/src/CursorAgentContentEditor/Infrastructure/Streaming/CursorAgentStreamCollector.php +++ b/src/CursorAgentContentEditor/Infrastructure/Streaming/CursorAgentStreamCollector.php @@ -4,10 +4,10 @@ namespace App\CursorAgentContentEditor\Infrastructure\Streaming; -use App\LlmContentEditor\Facade\Dto\AgentEventDto; -use App\LlmContentEditor\Facade\Dto\EditStreamChunkDto; -use App\LlmContentEditor\Facade\Dto\ToolInputEntryDto; -use App\LlmContentEditor\Facade\Enum\EditStreamChunkType; +use App\AgenticContentEditor\Facade\Dto\AgentEventDto; +use App\AgenticContentEditor\Facade\Dto\EditStreamChunkDto; +use App\AgenticContentEditor\Facade\Dto\ToolInputEntryDto; +use App\AgenticContentEditor\Facade\Enum\EditStreamChunkType; use JsonException; use SplQueue; 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 bb657ba..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 d153c93..3cd1044 100644 --- a/src/ProjectMgmt/Domain/Entity/Project.php +++ b/src/ProjectMgmt/Domain/Entity/Project.php @@ -4,9 +4,9 @@ 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\ContentEditorBackend; use App\ProjectMgmt\Facade\Enum\ProjectType; use DateTimeImmutable; use Doctrine\DBAL\Types\Types; @@ -28,19 +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, - ContentEditorBackend $contentEditorBackend = ContentEditorBackend::Llm, - 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; @@ -159,17 +159,17 @@ public function setProjectType(ProjectType $projectType): void type: Types::STRING, length: 32, nullable: false, - enumType: ContentEditorBackend::class, - options: ['default' => ContentEditorBackend::Llm->value] + enumType: AgenticContentEditorBackend::class, + options: ['default' => AgenticContentEditorBackend::Llm->value] )] - private ContentEditorBackend $contentEditorBackend = ContentEditorBackend::Llm; + private AgenticContentEditorBackend $contentEditorBackend = AgenticContentEditorBackend::Llm; - public function getContentEditorBackend(): ContentEditorBackend + public function getContentEditorBackend(): AgenticContentEditorBackend { return $this->contentEditorBackend; } - public function setContentEditorBackend(ContentEditorBackend $contentEditorBackend): void + public function setContentEditorBackend(AgenticContentEditorBackend $contentEditorBackend): void { $this->contentEditorBackend = $contentEditorBackend; } diff --git a/src/ProjectMgmt/Domain/Service/ProjectService.php b/src/ProjectMgmt/Domain/Service/ProjectService.php index 6a8bab8..334adb6 100644 --- a/src/ProjectMgmt/Domain/Service/ProjectService.php +++ b/src/ProjectMgmt/Domain/Service/ProjectService.php @@ -4,9 +4,9 @@ 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\ContentEditorBackend; use App\ProjectMgmt\Facade\Enum\ProjectType; use Doctrine\ORM\EntityManagerInterface; @@ -25,26 +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, - ContentEditorBackend $contentEditorBackend = ContentEditorBackend::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 + 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, @@ -82,25 +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, - ContentEditorBackend $contentEditorBackend = ContentEditorBackend::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 + 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); diff --git a/src/ProjectMgmt/Facade/Dto/ProjectInfoDto.php b/src/ProjectMgmt/Facade/Dto/ProjectInfoDto.php index cabba96..7c71d9a 100644 --- a/src/ProjectMgmt/Facade/Dto/ProjectInfoDto.php +++ b/src/ProjectMgmt/Facade/Dto/ProjectInfoDto.php @@ -4,8 +4,8 @@ namespace App\ProjectMgmt\Facade\Dto; +use App\AgenticContentEditor\Facade\Enum\AgenticContentEditorBackend; use App\LlmContentEditor\Facade\Enum\LlmModelProvider; -use App\ProjectMgmt\Facade\Enum\ContentEditorBackend; use App\ProjectMgmt\Facade\Enum\ProjectType; final readonly class ProjectInfoDto @@ -14,27 +14,27 @@ * @param list $remoteContentAssetsManifestUrls */ public function __construct( - public string $id, - public string $name, - public string $gitUrl, - public string $githubToken, - public ProjectType $projectType, - public ContentEditorBackend $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 = [], + 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/Enum/ContentEditorBackend.php b/src/ProjectMgmt/Facade/Enum/ContentEditorBackend.php deleted file mode 100644 index 0b83427..0000000 --- a/src/ProjectMgmt/Facade/Enum/ContentEditorBackend.php +++ /dev/null @@ -1,11 +0,0 @@ -llmApiKey, ProjectType::DEFAULT, - ContentEditorBackend::Llm, + AgenticContentEditorBackend::Llm, Project::DEFAULT_AGENT_IMAGE, null, null, diff --git a/src/ProjectMgmt/Presentation/Controller/ProjectController.php b/src/ProjectMgmt/Presentation/Controller/ProjectController.php index 51d8263..39ef0bd 100644 --- a/src/ProjectMgmt/Presentation/Controller/ProjectController.php +++ b/src/ProjectMgmt/Presentation/Controller/ProjectController.php @@ -5,12 +5,12 @@ 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; use App\ProjectMgmt\Domain\Service\ProjectService; use App\ProjectMgmt\Facade\Dto\ExistingLlmApiKeyDto; -use App\ProjectMgmt\Facade\Enum\ContentEditorBackend; use App\ProjectMgmt\Facade\Enum\ProjectType; use App\ProjectMgmt\Facade\ProjectMgmtFacadeInterface; use App\RemoteContentAssets\Facade\RemoteContentAssetsFacadeInterface; @@ -138,7 +138,7 @@ public function new(): Response return $this->render('@project_mgmt.presentation/project_form.twig', [ 'project' => null, 'llmProviders' => LlmModelProvider::cases(), - 'contentEditorBackends' => ContentEditorBackend::cases(), + 'contentEditorBackends' => AgenticContentEditorBackend::cases(), 'existingLlmKeys' => $this->projectMgmtFacade->getExistingLlmApiKeys($organizationId), 'agentConfigTemplate' => $defaultTemplate, ]); @@ -161,7 +161,7 @@ public function create(Request $request): Response $gitUrl = $request->request->getString('git_url'); $githubToken = $request->request->getString('github_token'); $llmModelProvider = LlmModelProvider::tryFrom($request->request->getString('llm_model_provider')); - $contentEditorBackend = ContentEditorBackend::tryFrom($request->request->getString('content_editor_backend')); + $contentEditorBackend = AgenticContentEditorBackend::tryFrom($request->request->getString('content_editor_backend')); $llmApiKey = $request->request->getString('llm_api_key'); $agentImage = $this->resolveAgentImage($request); @@ -278,7 +278,7 @@ public function edit(string $id): Response return $this->render('@project_mgmt.presentation/project_form.twig', [ 'project' => $project, 'llmProviders' => LlmModelProvider::cases(), - 'contentEditorBackends' => ContentEditorBackend::cases(), + 'contentEditorBackends' => AgenticContentEditorBackend::cases(), 'existingLlmKeys' => $existingLlmKeys, 'agentConfigTemplate' => $agentConfigTemplate, 'keysVisible' => $keysVisible, @@ -312,7 +312,7 @@ public function update(string $id, Request $request): Response $keysVisible = $project->isKeysVisible(); $githubToken = $keysVisible ? $request->request->getString('github_token') : $project->getGithubToken(); $llmModelProvider = LlmModelProvider::tryFrom($request->request->getString('llm_model_provider')); - $contentEditorBackend = ContentEditorBackend::tryFrom($request->request->getString('content_editor_backend')); + $contentEditorBackend = AgenticContentEditorBackend::tryFrom($request->request->getString('content_editor_backend')); $llmApiKey = $keysVisible ? $request->request->getString('llm_api_key') : $project->getLlmApiKey(); $agentImage = $this->resolveAgentImage($request); 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/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 86213e6..75f1da2 100644 --- a/tests/Unit/ProjectMgmt/ProjectServiceTest.php +++ b/tests/Unit/ProjectMgmt/ProjectServiceTest.php @@ -4,10 +4,10 @@ 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\ContentEditorBackend; use App\ProjectMgmt\Facade\Enum\ProjectType; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\TestCase; @@ -55,7 +55,7 @@ public function testCreateStoresRemoteContentAssetsManifestUrlsWhenProvided(): v LlmModelProvider::OpenAI, 'sk-key', ProjectType::DEFAULT, - ContentEditorBackend::Llm, + AgenticContentEditorBackend::Llm, 'node:22-slim', null, null,