diff --git a/src/ChatBasedContentEditor/Infrastructure/Handler/RunEditSessionHandler.php b/src/ChatBasedContentEditor/Infrastructure/Handler/RunEditSessionHandler.php index 59a1a139..d0b234c5 100644 --- a/src/ChatBasedContentEditor/Infrastructure/Handler/RunEditSessionHandler.php +++ b/src/ChatBasedContentEditor/Infrastructure/Handler/RunEditSessionHandler.php @@ -69,10 +69,11 @@ public function __invoke(RunEditSessionMessage $message): void $session->setStatus(EditSessionStatus::Running); $this->entityManager->flush(); + $conversation = $session->getConversation(); + try { // Load previous messages from conversation $previousMessages = $this->loadPreviousMessages($session); - $conversation = $session->getConversation(); // Set execution context for agent container execution $workspace = $this->workspaceMgmtFacade->getWorkspaceById($conversation->getWorkspaceId()); @@ -121,29 +122,8 @@ public function __invoke(RunEditSessionMessage $message): void $streamEndedWithFailure = false; foreach ($generator as $chunk) { - // Cooperative cancellation: check status directly via DBAL to avoid - // entityManager->refresh() which fails on readonly entity properties. - $currentStatus = $this->entityManager->getConnection()->fetchOne( - 'SELECT status FROM edit_sessions WHERE id = ?', - [$session->getId()] - ); - - if ($currentStatus === EditSessionStatus::Cancelling->value) { - // Persist a synthetic assistant message so the LLM on the next turn - // understands this turn was interrupted and won't try to answer it. - new ConversationMessage( - $conversation, - ConversationMessageRole::Assistant, - json_encode( - ['content' => '[Cancelled by the user — disregard this turn.]'], - JSON_THROW_ON_ERROR - ) - ); - - EditSessionChunk::createDoneChunk($session, false, 'Cancelled by user.'); - $session->setStatus(EditSessionStatus::Cancelled); - $this->entityManager->flush(); - + if ($this->isCancellationRequested($session)) { + $this->finalizeCancelledSession($session, $conversation); return; } @@ -159,6 +139,11 @@ public function __invoke(RunEditSessionMessage $message): void // Persist new conversation messages $this->persistConversationMessage($conversation, $chunk->message); } elseif ($chunk->chunkType === EditStreamChunkType::Done) { + if (($chunk->success ?? false) !== true && $this->isCancellationRequested($session)) { + $this->finalizeCancelledSession($session, $conversation); + return; + } + $streamEndedWithFailure = ($chunk->success ?? false) !== true; EditSessionChunk::createDoneChunk( $session, @@ -171,6 +156,12 @@ public function __invoke(RunEditSessionMessage $message): void } if ($streamEndedWithFailure) { + if ($this->isCancellationRequested($session)) { + $session->setStatus(EditSessionStatus::Cancelled); + $this->entityManager->flush(); + return; + } + $session->setStatus(EditSessionStatus::Failed); $this->entityManager->flush(); @@ -183,6 +174,11 @@ public function __invoke(RunEditSessionMessage $message): void // Commit and push changes after successful edit session $this->commitChangesAfterEdit($conversation, $session); } catch (Throwable $e) { + if ($this->isCancellationRequested($session)) { + $this->finalizeCancelledSession($session, $conversation); + return; + } + $this->logger->error('EditSession failed', [ 'sessionId' => $message->sessionId, 'error' => $e->getMessage(), @@ -309,4 +305,41 @@ private function truncateMessage(string $message, int $maxLength): string return mb_substr($message, 0, $maxLength - 3) . '...'; } + + private function isCancellationRequested(EditSession $session): bool + { + if ($session->getStatus() === EditSessionStatus::Cancelling) { + return true; + } + + $sessionId = $session->getId(); + if ($sessionId === null) { + return false; + } + + // Query status directly to detect cancellation from parallel requests immediately. + $currentStatus = $this->entityManager->getConnection()->fetchOne( + 'SELECT status FROM edit_sessions WHERE id = ?', + [$sessionId] + ); + + return $currentStatus === EditSessionStatus::Cancelling->value; + } + + private function finalizeCancelledSession(EditSession $session, Conversation $conversation): void + { + // Keep conversation context consistent for future turns. + new ConversationMessage( + $conversation, + ConversationMessageRole::Assistant, + json_encode( + ['content' => '[Cancelled by the user — disregard this turn.]'], + JSON_THROW_ON_ERROR + ) + ); + + EditSessionChunk::createDoneChunk($session, false, 'Cancelled by user.'); + $session->setStatus(EditSessionStatus::Cancelled); + $this->entityManager->flush(); + } } diff --git a/src/ChatBasedContentEditor/Presentation/Controller/ChatBasedContentEditorController.php b/src/ChatBasedContentEditor/Presentation/Controller/ChatBasedContentEditorController.php index a5c9ef20..0609be6d 100644 --- a/src/ChatBasedContentEditor/Presentation/Controller/ChatBasedContentEditorController.php +++ b/src/ChatBasedContentEditor/Presentation/Controller/ChatBasedContentEditorController.php @@ -23,6 +23,7 @@ use App\RemoteContentAssets\Facade\RemoteContentAssetsFacadeInterface; use App\WorkspaceMgmt\Facade\Enum\WorkspaceStatus; use App\WorkspaceMgmt\Facade\WorkspaceMgmtFacadeInterface; +use App\WorkspaceTooling\Facade\WorkspaceToolingServiceInterface; use Doctrine\ORM\EntityManagerInterface; use RuntimeException; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -60,6 +61,7 @@ public function __construct( private readonly TranslatorInterface $translator, private readonly PromptSuggestionsService $promptSuggestionsService, private readonly LlmContentEditorFacadeInterface $llmContentEditorFacade, + private readonly WorkspaceToolingServiceInterface $workspaceToolingFacade, ) { } @@ -656,6 +658,19 @@ public function cancel( $session->setStatus(EditSessionStatus::Cancelling); $this->entityManager->flush(); + $conversationId = $conversation->getId(); + if ($conversationId !== null) { + try { + // Best-effort hard stop for long-running tool/runtime containers. + $this->workspaceToolingFacade->stopAgentContainersForConversation( + $conversation->getWorkspaceId(), + $conversationId + ); + } catch (Throwable) { + // If runtime interruption fails, cooperative cancellation still applies. + } + } + return $this->json(['success' => true]); } 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 8d78dc65..27ce7cd7 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 @@ -109,6 +109,11 @@ export default class extends Controller { private submitOnEnterEnabled: boolean = true; private isContextUsagePollingActive: boolean = false; private isPollingActive: boolean = false; + private currentResponseContainer: HTMLElement | null = null; + private isRunRequestActive: boolean = false; + private isCancellationRequested: boolean = false; + private runRequestAbortController: AbortController | null = null; + private pollRequestAbortController: AbortController | null = null; // Activity indicators state (Working/Thinking badges) private activityThinkingTimerId: ReturnType | null = null; @@ -147,6 +152,7 @@ export default class extends Controller { disconnect(): void { this.stopPolling(); + this.stopRunRequest(); this.stopContextUsagePolling(); } @@ -273,6 +279,10 @@ export default class extends Controller { this.scrollToBottom(); this.setWorkingState(); + this.currentResponseContainer = inner; + this.isCancellationRequested = false; + this.isRunRequestActive = true; + this.runRequestAbortController = new AbortController(); const form = (event.target as HTMLElement).closest("form"); const csrfInput = form?.querySelector('input[name="_csrf_token"]') as HTMLInputElement | null; @@ -289,10 +299,21 @@ export default class extends Controller { method: "POST", headers: { "X-Requested-With": "XMLHttpRequest" }, body: formData, + signal: this.runRequestAbortController.signal, }); const data = (await response.json()) as RunResponse; + if (this.isCancellationRequested) { + if (data.sessionId) { + await this.sendCancelRequest(data.sessionId); + } + this.renderCancelledState(inner); + this.resetSubmitButton(); + + return; + } + if (!response.ok || !data.sessionId) { const t = this.translationsValue; const msg = data.error || t.sendError.replace("%status%", String(response.status)); @@ -304,20 +325,28 @@ export default class extends Controller { this.startPolling(data.sessionId, inner); } catch (err) { + if (this.isAbortError(err)) { + return; + } const t = this.translationsValue; const msg = err instanceof Error ? err.message : t.networkError; this.appendError(inner, msg); this.resetSubmitButton(); + } finally { + this.stopRunRequest(); } } async handleCancel(): Promise { - if (!this.currentPollingState || !this.cancelUrlTemplateValue) { + const pollingState = this.currentPollingState; + const sessionId = pollingState?.sessionId ?? null; + const container = pollingState?.container ?? this.currentResponseContainer; + if (!sessionId && !this.isRunRequestActive) { return; } - const cancelUrl = this.cancelUrlTemplateValue.replace("__SESSION_ID__", this.currentPollingState.sessionId); const t = this.translationsValue; + this.isCancellationRequested = true; // Disable immediately to prevent double-clicks if (this.hasCancelButtonTarget) { @@ -325,27 +354,23 @@ export default class extends Controller { this.cancelButtonTarget.textContent = t.stopping; } - try { - const csrfInput = document.querySelector('input[name="_csrf_token"]') as HTMLInputElement | null; + this.stopPolling(); + this.stopRunRequest(); - const formData = new FormData(); - if (csrfInput) { - formData.append("_csrf_token", csrfInput.value); - } + if (container) { + this.renderCancelledState(container); + } + this.resetSubmitButton(); - await fetch(cancelUrl, { - method: "POST", - headers: { "X-Requested-With": "XMLHttpRequest" }, - body: formData, - }); + if (!sessionId) { + return; + } - // Don't stop polling — let the polling loop detect the cancelled status - // and done chunk naturally, so all pre-cancellation output is displayed. + try { + await this.sendCancelRequest(sessionId); } catch { - // If the cancel request fails, re-enable the button so the user can retry - if (this.hasCancelButtonTarget) { - this.cancelButtonTarget.disabled = false; - this.cancelButtonTarget.textContent = t.stop; + if (container) { + this.appendError(container, t.networkError); } } } @@ -357,6 +382,7 @@ export default class extends Controller { lastId: startingLastId, pollUrl: this.pollUrlTemplateValue.replace("__SESSION_ID__", sessionId), }; + this.currentResponseContainer = container; this.isPollingActive = true; this.pollSession(); } @@ -376,9 +402,12 @@ export default class extends Controller { const { container, pollUrl } = this.currentPollingState; try { + this.pollRequestAbortController = new AbortController(); const response = await fetch(`${pollUrl}?after=${this.currentPollingState.lastId}`, { headers: { "X-Requested-With": "XMLHttpRequest" }, + signal: this.pollRequestAbortController.signal, }); + this.pollRequestAbortController = null; if (!response.ok) { const t = this.translationsValue; @@ -391,7 +420,15 @@ export default class extends Controller { const data = (await response.json()) as PollResponse; + // Ignore any late poll payload after user requested cancellation. + if (this.isCancellationRequested) { + return; + } + for (const chunk of data.chunks) { + if (this.isCancellationRequested) { + return; + } if (this.handleChunk(chunk, container)) { this.stopPolling(); this.resetSubmitButton(); @@ -413,6 +450,9 @@ export default class extends Controller { return; } } catch (err) { + if (this.isAbortError(err)) { + return; + } const t = this.translationsValue; const msg = err instanceof Error ? err.message : t.connectionRetry; this.appendError(container, msg); @@ -430,11 +470,70 @@ export default class extends Controller { private stopPolling(): void { this.isPollingActive = false; + if (this.pollRequestAbortController !== null) { + this.pollRequestAbortController.abort(); + this.pollRequestAbortController = null; + } if (this.pollingTimeoutId !== null) { clearTimeout(this.pollingTimeoutId); this.pollingTimeoutId = null; } this.currentPollingState = null; + this.currentResponseContainer = null; + } + + private stopRunRequest(): void { + if (this.runRequestAbortController !== null) { + this.runRequestAbortController.abort(); + this.runRequestAbortController = null; + } + this.isRunRequestActive = false; + } + + private isAbortError(err: unknown): boolean { + return err instanceof DOMException && err.name === "AbortError"; + } + + private async sendCancelRequest(sessionId: string): Promise { + if (!this.cancelUrlTemplateValue) { + return; + } + + const cancelUrl = this.cancelUrlTemplateValue.replace("__SESSION_ID__", sessionId); + const csrfInput = document.querySelector('input[name="_csrf_token"]') as HTMLInputElement | null; + const formData = new FormData(); + if (csrfInput) { + formData.append("_csrf_token", csrfInput.value); + } + + const response = await fetch(cancelUrl, { + method: "POST", + headers: { "X-Requested-With": "XMLHttpRequest" }, + body: formData, + }); + if (!response.ok) { + throw new Error(`Cancel request failed (${response.status})`); + } + } + + private renderCancelledState(container: HTMLElement): void { + this.appendCancelledMessage(container); + this.markTechnicalContainerComplete(container, true); + this.scrollToBottom(); + } + + private appendCancelledMessage(container: HTMLElement): void { + const existingCancelledMessage = container.querySelector('[data-cancelled-message="1"]'); + if (existingCancelledMessage) { + return; + } + + const t = this.translationsValue; + const cancelledEl = document.createElement("div"); + cancelledEl.className = "whitespace-pre-wrap text-amber-600 dark:text-amber-400 italic"; + cancelledEl.textContent = t.cancelled; + cancelledEl.dataset.cancelledMessage = "1"; + container.appendChild(cancelledEl); } private resetSubmitButton(): void { @@ -715,11 +814,7 @@ export default class extends Controller { this.appendError(container, payload.errorMessage); } if (isCancellation) { - const t = this.translationsValue; - const cancelledEl = document.createElement("div"); - cancelledEl.className = "whitespace-pre-wrap text-amber-600 dark:text-amber-400 italic"; - cancelledEl.textContent = t.cancelled; - container.appendChild(cancelledEl); + this.appendCancelledMessage(container); } this.markTechnicalContainerComplete(container, isCancellation); this.scrollToBottom(); diff --git a/src/WorkspaceTooling/Facade/WorkspaceToolingFacade.php b/src/WorkspaceTooling/Facade/WorkspaceToolingFacade.php index cb41129a..d483c93a 100644 --- a/src/WorkspaceTooling/Facade/WorkspaceToolingFacade.php +++ b/src/WorkspaceTooling/Facade/WorkspaceToolingFacade.php @@ -240,4 +240,9 @@ public function runBuildInWorkspace(string $workspacePath, string $agentImage): 'html-editor-build' ); } + + public function stopAgentContainersForConversation(string $workspaceId, string $conversationId): int + { + return $this->dockerExecutor->stopAgentContainersForConversation($workspaceId, $conversationId); + } } diff --git a/src/WorkspaceTooling/Facade/WorkspaceToolingServiceInterface.php b/src/WorkspaceTooling/Facade/WorkspaceToolingServiceInterface.php index f2da7bd4..64f852e3 100644 --- a/src/WorkspaceTooling/Facade/WorkspaceToolingServiceInterface.php +++ b/src/WorkspaceTooling/Facade/WorkspaceToolingServiceInterface.php @@ -87,4 +87,13 @@ public function getWorkspaceRules(): string; * @return string the build output */ public function runBuildInWorkspace(string $workspacePath, string $agentImage): string; + + /** + * Stop all running agent containers for a workspace conversation. + * + * Used for immediate cancellation from the chat editor stop action. + * + * @return int number of containers that were stopped + */ + public function stopAgentContainersForConversation(string $workspaceId, string $conversationId): int; } diff --git a/src/WorkspaceTooling/Infrastructure/Execution/DockerExecutor.php b/src/WorkspaceTooling/Infrastructure/Execution/DockerExecutor.php index 59fcc682..81dd7301 100644 --- a/src/WorkspaceTooling/Infrastructure/Execution/DockerExecutor.php +++ b/src/WorkspaceTooling/Infrastructure/Execution/DockerExecutor.php @@ -22,6 +22,7 @@ final class DockerExecutor { private const int DEFAULT_TIMEOUT = 300; // 5 minutes + private const string AGENT_CONTAINER_PREFIX = 'sitebuilder-ws-'; public function __construct( private readonly string $containerBasePath, @@ -147,6 +148,69 @@ public function ensureImageAvailable(string $image): bool return $pullProcess->isSuccessful(); } + /** + * Stop running agent containers for a specific workspace+conversation. + * + * This is used for immediate cancellation: when a user clicks "Stop", we + * terminate active tool/runtime containers so long-running operations are + * interrupted as quickly as possible. + */ + public function stopAgentContainersForConversation(string $workspaceId, string $conversationId): int + { + $workspaceShort = substr($workspaceId, 0, 8); + $conversationShort = substr($conversationId, 0, 8); + $nameNeedle = '-' . $workspaceShort . '-' . $conversationShort . '-'; + + $listProcess = new Process([ + 'docker', + 'ps', + '--format', + '{{.Names}}', + '--filter', + 'name=' . self::AGENT_CONTAINER_PREFIX, + ]); + $listProcess->setTimeout(10); + $listProcess->run(); + + if (!$listProcess->isSuccessful()) { + throw new DockerExecutionException( + 'Failed to list running Docker containers.', + 'docker ps --format {{.Names}} --filter name=' . self::AGENT_CONTAINER_PREFIX + ); + } + + $stoppedCount = 0; + $namesOutput = trim($listProcess->getOutput()); + if ($namesOutput === '') { + return $stoppedCount; + } + + foreach (explode("\n", $namesOutput) as $containerName) { + $containerName = trim($containerName); + if ($containerName === '') { + continue; + } + + if (!str_starts_with($containerName, self::AGENT_CONTAINER_PREFIX)) { + continue; + } + + if (!str_contains($containerName, $nameNeedle)) { + continue; + } + + $stopProcess = new Process(['docker', 'stop', '--time', '1', $containerName]); + $stopProcess->setTimeout(15); + $stopProcess->run(); + + if ($stopProcess->isSuccessful()) { + $stoppedCount++; + } + } + + return $stoppedCount; + } + /** * Build the docker run command array. * diff --git a/tests/frontend/integration/ChatBasedContentEditor/chat_editor_controller.test.ts b/tests/frontend/integration/ChatBasedContentEditor/chat_editor_controller.test.ts index 4b21a3eb..14a518d5 100644 --- a/tests/frontend/integration/ChatBasedContentEditor/chat_editor_controller.test.ts +++ b/tests/frontend/integration/ChatBasedContentEditor/chat_editor_controller.test.ts @@ -99,7 +99,7 @@ describe("ChatBasedContentEditorController", () => { - + `; @@ -277,5 +277,205 @@ describe("ChatBasedContentEditorController", () => { }), ); }); + + it("stops immediately during polling and ignores late poll output", async () => { + const abortError = (): DOMException => new DOMException("The operation was aborted.", "AbortError"); + const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + + if (url === "/api/context") { + return Promise.resolve({ + ok: true, + json: async () => ({ usedTokens: 100, maxTokens: 1000, totalCost: 0 }), + }); + } + + if (url === "/api/run") { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ sessionId: "sess-stop" }), + }); + } + + if (url === "/api/poll/sess-stop?after=0") { + return new Promise((resolve, reject) => { + const signal = init?.signal as AbortSignal | undefined; + if (signal?.aborted) { + reject(abortError()); + return; + } + const timeoutId = setTimeout(() => { + resolve({ + ok: true, + status: 200, + json: async () => ({ + chunks: [ + { + id: 1, + chunkType: "text", + payload: JSON.stringify({ content: "late output after stop" }), + }, + { id: 2, chunkType: "done", payload: JSON.stringify({ success: true }) }, + ], + lastId: 2, + status: "completed", + }), + }); + }, 75); + + signal?.addEventListener( + "abort", + () => { + clearTimeout(timeoutId); + reject(abortError()); + }, + { once: true }, + ); + }); + } + + if (url === "/api/cancel/sess-stop") { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({}), + }); + } + + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ chunks: [], lastId: 0, status: "completed" }), + }); + }); + vi.stubGlobal("fetch", fetchMock); + + createChatEditorFixture(); + await waitForController(); + + const textarea = document.querySelector( + "[data-chat-based-content-editor-target='instruction']", + ) as HTMLTextAreaElement; + textarea.value = "Please generate content"; + + const form = document.querySelector("form") as HTMLFormElement; + form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith( + "/api/poll/sess-stop?after=0", + expect.objectContaining({ + headers: { "X-Requested-With": "XMLHttpRequest" }, + }), + ); + }); + + const cancelButton = document.querySelector( + "[data-chat-based-content-editor-target='cancelButton']", + ) as HTMLButtonElement; + cancelButton.click(); + + await vi.waitFor(() => { + const messages = document.querySelector("[data-chat-based-content-editor-target='messages']"); + expect(messages?.textContent).toContain("Cancelled"); + }); + + await new Promise((resolve) => setTimeout(resolve, 120)); + + const messages = document.querySelector("[data-chat-based-content-editor-target='messages']"); + expect(messages?.textContent).not.toContain("late output after stop"); + + const submitButton = document.querySelector( + "[data-chat-based-content-editor-target='submit']", + ) as HTMLButtonElement; + expect(submitButton.disabled).toBe(false); + expect(cancelButton.classList.contains("hidden")).toBe(true); + + expect(fetchMock).toHaveBeenCalledWith( + "/api/cancel/sess-stop", + expect.objectContaining({ + method: "POST", + headers: { "X-Requested-With": "XMLHttpRequest" }, + }), + ); + }); + + it("cancels cleanly when stop is clicked before run returns session id", async () => { + const abortError = (): DOMException => new DOMException("The operation was aborted.", "AbortError"); + const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + + if (url === "/api/context") { + return Promise.resolve({ + ok: true, + json: async () => ({ usedTokens: 100, maxTokens: 1000, totalCost: 0 }), + }); + } + + if (url === "/api/run") { + return new Promise((resolve, reject) => { + const signal = init?.signal as AbortSignal | undefined; + if (signal?.aborted) { + reject(abortError()); + return; + } + + signal?.addEventListener( + "abort", + () => { + reject(abortError()); + }, + { once: true }, + ); + + setTimeout(() => { + resolve({ + ok: true, + status: 200, + json: async () => ({ sessionId: "sess-too-late" }), + }); + }, 2000); + }); + } + + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ chunks: [], lastId: 0, status: "completed" }), + }); + }); + vi.stubGlobal("fetch", fetchMock); + + createChatEditorFixture(); + await waitForController(); + + const textarea = document.querySelector( + "[data-chat-based-content-editor-target='instruction']", + ) as HTMLTextAreaElement; + textarea.value = "Start a task that I may cancel immediately"; + + const form = document.querySelector("form") as HTMLFormElement; + form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + + const cancelButton = document.querySelector( + "[data-chat-based-content-editor-target='cancelButton']", + ) as HTMLButtonElement; + cancelButton.click(); + + await vi.waitFor(() => { + const messages = document.querySelector("[data-chat-based-content-editor-target='messages']"); + expect(messages?.textContent).toContain("Cancelled"); + }); + + const submitButton = document.querySelector( + "[data-chat-based-content-editor-target='submit']", + ) as HTMLButtonElement; + expect(submitButton.disabled).toBe(false); + expect(cancelButton.classList.contains("hidden")).toBe(true); + + const cancelCalls = fetchMock.mock.calls.filter(([input]) => String(input).includes("/api/cancel/")); + expect(cancelCalls).toHaveLength(0); + }); }); });