From b81a57b0c13cc8f6946b773b976c43693d546b3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Kie=C3=9Fling?= Date: Sun, 22 Feb 2026 20:06:57 +0000 Subject: [PATCH 1/3] fix: make AI editor stop action cancel immediately Stop now aborts in-flight run/poll requests and finalizes the UI as cancelled immediately, preventing late chunks from rendering after cancellation. Add integration coverage for stop during active polling and before session initialization to lock in the behavior. --- .../chat_based_content_editor_controller.ts | 135 +++++++++--- .../chat_editor_controller.test.ts | 202 +++++++++++++++++- 2 files changed, 312 insertions(+), 25 deletions(-) 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..ddf58efa 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; @@ -413,6 +442,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 +462,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 +806,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/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); + }); }); }); From 8e4f44cee1faf8ba72ad463a67b8b1dc859368e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Kie=C3=9Fling?= Date: Sun, 22 Feb 2026 20:24:47 +0000 Subject: [PATCH 2/3] fix: hard-stop backend runtime on AI session cancel Trigger best-effort Docker container termination when cancelling an edit session and treat cancellation-related runtime failures as cancelled. This makes stop requests interrupt backend work sooner and keeps session state aligned with user-triggered cancellation. --- .../Handler/RunEditSessionHandler.php | 81 +++++++++++++------ .../ChatBasedContentEditorController.php | 15 ++++ .../Execution/DockerExecutor.php | 64 +++++++++++++++ 3 files changed, 136 insertions(+), 24 deletions(-) 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..4a5849aa 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\Infrastructure\Execution\DockerExecutor; 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 DockerExecutor $dockerExecutor, ) { } @@ -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->dockerExecutor->stopAgentContainersForConversation( + $conversation->getWorkspaceId(), + $conversationId + ); + } catch (Throwable) { + // If runtime interruption fails, cooperative cancellation still applies. + } + } + return $this->json(['success' => true]); } 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. * From 05c7de8a1923340d2ce65c79dab7862b2fc92ab8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Kie=C3=9Fling?= Date: Sun, 22 Feb 2026 20:33:13 +0000 Subject: [PATCH 3/3] fix: enforce immediate stop via facade-safe backend cancellation Route stop-triggered container termination through the WorkspaceTooling facade to keep architecture boundaries intact while still hard-stopping agent runtimes. Also ignore late polling payloads after cancellation so no additional output renders once Stop is pressed. --- .../Controller/ChatBasedContentEditorController.php | 6 +++--- .../controllers/chat_based_content_editor_controller.ts | 8 ++++++++ src/WorkspaceTooling/Facade/WorkspaceToolingFacade.php | 5 +++++ .../Facade/WorkspaceToolingServiceInterface.php | 9 +++++++++ 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/ChatBasedContentEditor/Presentation/Controller/ChatBasedContentEditorController.php b/src/ChatBasedContentEditor/Presentation/Controller/ChatBasedContentEditorController.php index 4a5849aa..0609be6d 100644 --- a/src/ChatBasedContentEditor/Presentation/Controller/ChatBasedContentEditorController.php +++ b/src/ChatBasedContentEditor/Presentation/Controller/ChatBasedContentEditorController.php @@ -23,7 +23,7 @@ use App\RemoteContentAssets\Facade\RemoteContentAssetsFacadeInterface; use App\WorkspaceMgmt\Facade\Enum\WorkspaceStatus; use App\WorkspaceMgmt\Facade\WorkspaceMgmtFacadeInterface; -use App\WorkspaceTooling\Infrastructure\Execution\DockerExecutor; +use App\WorkspaceTooling\Facade\WorkspaceToolingServiceInterface; use Doctrine\ORM\EntityManagerInterface; use RuntimeException; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -61,7 +61,7 @@ public function __construct( private readonly TranslatorInterface $translator, private readonly PromptSuggestionsService $promptSuggestionsService, private readonly LlmContentEditorFacadeInterface $llmContentEditorFacade, - private readonly DockerExecutor $dockerExecutor, + private readonly WorkspaceToolingServiceInterface $workspaceToolingFacade, ) { } @@ -662,7 +662,7 @@ public function cancel( if ($conversationId !== null) { try { // Best-effort hard stop for long-running tool/runtime containers. - $this->dockerExecutor->stopAgentContainersForConversation( + $this->workspaceToolingFacade->stopAgentContainersForConversation( $conversation->getWorkspaceId(), $conversationId ); 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 ddf58efa..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 @@ -420,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(); 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; }