Add event hook to ai_ckeditor to allow context injection
>>> [!note] Migrated issue
<!-- Drupal.org comment -->
<!-- Migrated from issue #3581952. -->
Reported by: [jessehs](https://www.drupal.org/user/620440)
Related to !1469 !1425
>>>
<h3 id="summary-problem-motivation">Problem/Motivation</h3>
<p>The <code>ai_ckeditor</code> module provides AI-powered CKEditor 5 actions (Tone, Translate, Summarize, etc.) but has <strong>zero entity awareness</strong> — it doesn't know what content type, bundle, or entity ID the editor is attached to. The <code>ai_context</code> module provides a sophisticated scope-based context selection system that can inject bundle-specific writing guidelines into AI prompts.</p>
<p>These two modules cannot communicate because:</p>
<ol>
<li><code>ai_ckeditor</code> does not pass entity information (type, bundle, ID) through its request chain — from the CKEditor 5 JS dialog, through the Drupal AJAX form, to the streaming controller.</li>
<li><code>ai_ckeditor</code> does not dispatch any events before calling the AI provider, so there is no extension point for other modules to modify the prompt or system prompt.</li>
<li><code>ai_context</code>'s existing event subscriber only hooks into the <code>ai_agents</code> pipeline (<code>BuildSystemPromptEvent</code>), which <code>ai_ckeditor</code> bypasses entirely by calling the AI provider directly in its own controller.</li>
</ol>
<p>This means site builders cannot use <code>ai_context</code> items (brand voice guidelines, content-type-specific writing rules, editorial tone instructions, etc.) to influence AI CKEditor actions — even though both modules are installed and configured.</p>
<h4 id="summary-steps-reproduce">Steps to reproduce (required for bugs, but not feature requests)</h4>
<p>This is a feature request / architectural gap, not a bug.</p>
<h3 id="summary-proposed-resolution">Proposed resolution</h3>
<p>This solution is delivered as <strong>three patches applied in sequence</strong>. Patch 1 is a prerequisite. Patches 2 and 3 are the new work in this issue.</p>
<h4>Patch 1 (prerequisite): <code>3549657-ai_ckeditor-use-prompt-entities.patch</code></h4>
<p><strong>Applied to:</strong> <code>drupal/ai</code> module</p>
<p>Prerequisite patch from <a href="https://www.drupal.org/project/ai/issues/3549657">#3549657</a>. Converts all inline text prompts in <code>ai_ckeditor</code> to AI Prompt config entities and changes form elements from <code>textarea</code> to <code>ai_prompt</code> type. All plugin <code>ajaxGenerate()</code> methods now use <code>strtr()</code> for variable replacement and load prompts from config entities via <code>$this->getConfigFactory()->get('ai.ai_prompt.' . $promptId)</code>.</p>
<h4>Patch 2: <code>ai_ckeditor-entity-context-event.patch</code></h4>
<p><strong>Applied to:</strong> <code>drupal/ai</code> module (on top of Patch 1)</p>
<p>This is the core of the issue. It threads entity context through the entire <code>ai_ckeditor</code> request chain and dispatches a new event before the AI provider call. Here is what each layer does:</p>
<ul>
<li><strong>New file — <code>src/Event/AiCKEditorRequestEvent.php</code>:</strong> A Symfony event class with <code>EVENT_NAME = 'ai_ckeditor.pre_request'</code>. It carries two mutable properties — <code>prompt</code> (get/set) and <code>systemPrompt</code> (get/set) — that subscribers can modify before the AI call. It also carries read-only properties: <code>editorId</code> (the text format ID), <code>pluginId</code> (e.g., <code>ai_ckeditor_tone</code>), <code>entityType</code> (e.g., <code>node</code>), <code>entityBundle</code> (e.g., <code>blog</code>), <code>entityId</code> (e.g., <code>123</code>, empty on add forms), and the <code>EditorInterface</code> config entity.</li>
<li><strong>JS layer — <code>AiDrupalDialog.js</code>:</strong> A new <code>getEntityContext(editor)</code> helper function extracts entity info from the current page. It parses <code>drupalSettings.path.currentPath</code> for patterns like <code>node/123/edit</code> (returning <code>entityType: 'node'</code>, <code>entityId: '123'</code>) and <code>node/add/blog</code> (returning <code>entityType: 'node'</code>, <code>entityBundle: 'blog'</code>, <code>entityId: ''</code>). As a fallback, it parses the closest form element's <code>data-drupal-selector</code> attribute (e.g., <code>node-blog-edit-form</code>). The resulting <code>entity_type</code>, <code>entity_bundle</code>, and <code>entity_id</code> are included in the dialog payload alongside the existing <code>selected_text</code>, <code>editor_id</code>, and <code>plugin_id</code> fields. The webpack build output (<code>js/build/aickeditor.js</code>) is rebuilt.</li>
<li><strong>Form layer — <code>AiCKEditorDialogForm.php</code>:</strong> The <code>buildForm()</code> method now extracts <code>entity_type</code>, <code>entity_bundle</code>, and <code>entity_id</code> from the AJAX payload. These are passed into <code>buildCkEditorModalForm()</code> via the <code>$settings</code> array and rendered as hidden form fields alongside the existing <code>editor_id</code>, <code>plugin_id</code>, and <code>selected_text</code> hidden fields. This ensures entity context survives the AJAX round-trip from the dialog form to the <code>ajaxGenerate()</code> callback.</li>
<li><strong>AJAX command — <code>AiRequestCommand.php</code>:</strong> The constructor gains three new parameters: <code>$entity_type = ''</code>, <code>$entity_bundle = ''</code>, <code>$entity_id = ''</code> — all with empty-string defaults for full backward compatibility. The <code>render()</code> method includes these fields in the JSON command output, which <code>AiWriter.js</code> sends to the streaming controller via <code>JSON.stringify(request_parameters)</code>.</li>
<li><strong>Plugin base — <code>AiCKEditorPluginBase.php</code>:</strong> A new protected helper method <code>createAiRequestCommand(string $prompt, FormStateInterface $form_state): AiRequestCommand</code> centralizes command construction. It reads <code>editor_id</code>, <code>entity_type</code>, <code>entity_bundle</code>, and <code>entity_id</code> from <code>$form_state->getValues()</code> and uses <code>$this->pluginDefinition['id']</code> for the plugin ID.</li>
<li><strong>All 7 plugins updated:</strong> <code>Tone.php</code>, <code>Completion.php</code>, <code>Summarize.php</code>, <code>Translate.php</code>, <code>SpellFix.php</code>, <code>ReformatHtml.php</code>, and <code>ModifyPrompt.php</code> — each <code>ajaxGenerate()</code> method is updated from multi-line manual <code>AiRequestCommand</code> construction to a single-line <code>$this->createAiRequestCommand($promptText, $form_state)</code> call. Unused <code>use Drupal\ai_ckeditor\Command\AiRequestCommand;</code> imports are removed from each plugin.</li>
<li><strong>Controller — <code>AiRequest.php</code>:</strong> The constructor now injects <code>EventDispatcherInterface</code> (added to both <code>__construct()</code> and <code>create()</code>). In <code>doRequest()</code>, entity fields are extracted from the decoded JSON body. Request attributes <code>ai_context_entity_type</code> and <code>ai_context_entity_id</code> are set on the request object for downstream services. After building the HTML-formatting prompt prefix and the system prompt, the controller dispatches <code>AiCKEditorRequestEvent</code>. The <code>ChatInput</code> is then built using <code>$event->getPrompt()</code> and <code>$event->getSystemPrompt()</code> instead of the original local variables, so any modifications made by subscribers take effect.</li>
</ul>
<h4>Patch 3: <code>ai_context-implement-event-subscriber-to-inject-context-to-ckeditor.patch</code></h4>
<p><strong>Applied to:</strong> <code>drupal/ai_context</code> module</p>
<p>This patch makes <code>ai_context</code> subscribe to the new event from Patch 2, inject scope-matched context items into the CKEditor AI system prompt, and fixes two bugs in the Site Section scope plugin that prevented path-pattern-based context items from matching on entity edit forms.</p>
<ul>
<li><strong>New file — <code>src/EventSubscriber/AiContextCKEditorSubscriber.php</code>:</strong> Implements <code>EventSubscriberInterface</code>. Uses a <code>class_exists('Drupal\ai_ckeditor\Event\AiCKEditorRequestEvent')</code> guard in <code>getSubscribedEvents()</code> so <code>ai_context</code> does not hard-depend on <code>ai_ckeditor</code>. Listens to <code>ai_ckeditor.pre_request</code>. The handler extracts <code>entityType</code> and <code>entityId</code> from the event, builds a context request using <code>AiContextRequestFactory::fromParameters()</code> with <code>SELECTION_MODE_MATCH_ALL</code>, runs <code>AiContextSelector::select()</code> to find matching context items, and appends the rendered context text to the system prompt via <code>$event->setSystemPrompt()</code>. The append uses the same prefix/separator format as the existing <code>AiContextSystemPromptSubscriber</code>. Finally, it records usage via <code>AiContextUsageTracker::recordUsage()</code>.</li>
<li><strong>Modified file — <code>ai_context.services.yml</code>:</strong> Registers the new subscriber as <code>ai_context.event_subscriber.ckeditor</code> with dependencies on <code>@ai_context.selector</code>, <code>@ai_context.request_factory</code>, <code>@ai_context.usage_tracker</code>, and <code>@config.factory</code>. Tagged with <code>event_subscriber</code>.</li>
<li><strong>Modified file — <code>src/Plugin/AiContextScope/AiContextScopeSiteSection.php</code>:</strong> Fixes two bugs that prevented Site Section scope items with custom path patterns from matching on entity edit forms:
<ol>
<li><strong>URL alias resolution for edit forms:</strong> When a user edits a node at <code>/node/123/edit</code>, the HTTP request path is <code>/node/123/edit</code> — not the front-end URL alias (e.g., <code>/blogs/my-blog-post-title</code>). A context item scoped with the custom path pattern <code>/blogs/*</code> would never match because the request path never starts with <code>/blogs/</code>. This patch adds a <code>getCurrentPaths()</code> method that builds an array of candidate paths by: (1) using the current HTTP request path, (2) resolving the entity's canonical internal path from the <code>ai_context_entity_type</code> and <code>ai_context_entity_id</code> request attributes (set by the <code>AiRequest</code> controller in Patch 2), and (3) resolving the URL alias for that canonical path via <code>AliasManagerInterface::getAliasByPath()</code>. For example, when editing node 123, the candidate paths would be <code>['/node/123/edit', '/node/123', '/blogs/my-blog-post-title']</code>, and the pattern <code>/blogs/*</code> successfully matches the alias.</li>
<li><strong>Custom pattern matching in <code>matchesCurrentContext()</code>:</strong> The base class <code>matchesCurrentContext()</code> calls <code>getCurrentValue()</code>, which only returns pre-defined section IDs (e.g., <code>marketing</code>) — it cannot evaluate custom patterns (stored as <code>custom:/blogs/*</code>). This patch overrides <code>matchesCurrentContext()</code> to use the existing <code>matchesCurrentPath()</code> method, which correctly handles both custom wildcard patterns and pre-defined section ID lookups against all candidate paths.</li>
</ol>
<p> New dependencies injected: <code>AliasManagerInterface</code> (optional, guarded by <code>$container->has('path_alias.manager')</code>) and <code>EntityTypeManagerInterface</code>.
</p></li>
</ul>
<p><strong>Selection mode note:</strong> The CKEditor subscriber uses <code>AiContextRequestFactory::fromParameters()</code> with <code>SELECTION_MODE_MATCH_ALL</code> rather than <code>fromAgent()</code>. This is intentional: <code>fromAgent()</code> hardcodes <code>SELECTION_MODE_MINIMAL</code>, which requires explicit scope subscriptions in the agent's <code>ai_context.agents</code> config entry to select non-global items. Since CKEditor is not an agent and typically has no such config entry, <code>MINIMAL</code> mode would silently drop all non-global scoped items (like a blog context item scoped to <code>/blogs/*</code>) even after they pass the hard context filter. <code>MATCH_ALL</code> mode includes all items that pass hard context filtering, which is the correct behavior for a non-agent consumer like CKEditor. Site admins can still optionally add an <code>ai_ckeditor</code> consumer entry in <code>ai_context.agents</code> config to further narrow selection with specific scope subscriptions.</p>
<h3 id="summary-remaining-tasks">Remaining tasks</h3>
<ol>
<li>Review and feedback on the approach.</li>
<li>Confirm backward compatibility — all new <code>AiRequestCommand</code> params default to empty strings, existing custom plugins continue to work without changes.</li>
<li>Consider adding kernel/functional tests for the event dispatch, entity context extraction, and Site Section URL alias resolution.</li>
<li>Coordinate with <a href="https://www.drupal.org/project/ai/issues/3549657">#3549657</a> (prompt entities patch) since Patch 2 is applied on top of Patch 1.</li>
<li>Review whether the Site Section scope plugin's <code>matchesCurrentContext()</code> override and <code>getCurrentPaths()</code> URL alias resolution should be upstreamed as a standalone fix to <code>ai_context</code> (it fixes a general limitation where edit forms can never match front-end path patterns, independent of the CKEditor integration).</li>
</ol>
<h3>Testing instructions</h3>
<h4>Prerequisites</h4>
<ul>
<li>Drupal 10.3+ or 11.x</li>
<li>Modules enabled: <code>ai</code>, <code>ai_ckeditor</code>, <code>ai_context</code>, <code>taxonomy</code></li>
<li>A working AI chat provider configured (e.g., OpenAI, Anthropic) — confirm via <em>Admin > Configuration > AI > Settings</em> that a default Chat model is set</li>
</ul>
<h4>Step 1: Apply all three patches in order</h4>
<ol>
<li>Apply <code>patches/3549657-ai_ckeditor-use-prompt-entities.patch</code> to the <code>drupal/ai</code> module</li>
<li>Apply <code>patches/ai_ckeditor-entity-context-event.patch</code> to the <code>drupal/ai</code> module</li>
<li>Apply <code>patches/ai_context-implement-event-subscriber-to-inject-context-to-ckeditor.patch</code> to the <code>drupal/ai_context</code> module</li>
<li>Run <code>drush cr</code> to rebuild caches</li>
</ol>
<h4>Step 2: Create a Tone taxonomy vocabulary and terms</h4>
<ol>
<li>Go to <em>Admin > Structure > Taxonomy</em> and click <strong>"Add vocabulary"</strong></li>
<li>Name it <strong>"Tone"</strong> (machine name: <code>tone</code>) and save</li>
<li>Click <strong>"Add term"</strong> and create a term called <strong>"Formal"</strong></li>
<li>Optionally add additional terms like <strong>"Casual"</strong> or <strong>"Professional"</strong></li>
</ol>
<h4>Step 3: Configure the CKEditor text format with AI plugins</h4>
<ol>
<li>Go to <em>Admin > Configuration > Content authoring > Text formats and editors</em></li>
<li>Edit a text format that uses CKEditor 5 (e.g., "Full HTML")</li>
<li>In the CKEditor 5 toolbar configuration, drag the <strong>"AI Tools"</strong> button into the active toolbar</li>
<li>Scroll down to the <strong>"AI CKEditor"</strong> plugin settings section</li>
<li>Enable the <strong>Tone</strong> plugin:
<ul>
<li>Check the <strong>"Enabled"</strong> checkbox next to Tone</li>
<li>Select the <strong>"Tone"</strong> vocabulary from the dropdown</li>
<li>Select an AI provider/model, or leave as <strong>"Default from AI module (chat)"</strong></li>
<li>Configure the tone prompt (a default prompt entity should be available)</li>
</ul>
</li>
<li>Click <strong>"Save configuration"</strong></li>
</ol>
<h4>Step 4: Create an ai_context item with an easily verifiable instruction</h4>
<ol>
<li>Go to <em>Admin > Configuration > AI > AI Context</em></li>
<li>Click <strong>"Add context item"</strong></li>
<li>Set the <strong>Label</strong> to: <code>Company Brand Voice</code></li>
<li>Set the <strong>Scope</strong> to: <strong>Global</strong> (so it applies regardless of content type)</li>
<li>In the <strong>Content</strong> field, enter the following text exactly:<br>
<br><br><br>
<code>IMPORTANT: You must insert the following sentence on its own line at the very beginning of your response: "Modified by the Company Brand Voice context control center item." Then continue with the rest of your response as normal.</code><br>
<br><br><br>
<em>Note: This deliberately obvious instruction makes it trivially easy to verify that ai_context content is being injected. In production you would use real brand voice guidelines, writing style rules, etc.</em>
</li>
<li>Set the status to <strong>Published</strong></li>
<li>Click <strong>"Save"</strong></li>
</ol>
<h4>Step 5: Trigger an AI CKEditor action</h4>
<ol>
<li>Create or edit a <strong>node</strong> (e.g., a Basic Page) that uses the text format configured in Step 3</li>
<li>In the CKEditor body field, type some sample text, e.g.: <code>Our company provides excellent services to all of our valued customers around the world.</code></li>
<li><strong>Select the text</strong> you just typed</li>
<li>Click the <strong>AI Tools</strong> button in the CKEditor toolbar</li>
<li>Choose <strong>"Tone"</strong> from the dropdown</li>
<li>In the modal dialog that opens, select <strong>"Formal"</strong> from the tone dropdown</li>
<li>Click <strong>"Change the tone"</strong></li>
<li>Wait for the AI response to stream into the response textarea</li>
</ol>
<h4>Step 6: Verify the results</h4>
<p><strong>Primary verification — context injection works:</strong></p>
<p>The AI response in the modal textarea should include the sentence: <code>"Modified by the Company Brand Voice context control center item."</code> This confirms that <code>ai_context</code> successfully injected its content into the system prompt via the new <code>ai_ckeditor.pre_request</code> event, and that the AI provider received and acted on it.</p>
<p><strong>Secondary verification — entity context is being sent:</strong></p>
<ol>
<li>Open browser DevTools > <strong>Network</strong> tab</li>
<li>Repeat Step 5 (trigger a tone change)</li>
<li>Find the POST request to <code>/api/ai-ckeditor/request/{editor}/{plugin}</code></li>
<li>Inspect the <strong>Request Body</strong> (JSON). You should see:
<ul>
<li><code>"entity_type": "node"</code></li>
<li><code>"entity_bundle": "page"</code> (or whatever content type you used)</li>
<li><code>"entity_id": "123"</code> (the actual node ID, or empty string on <code>node/add/*</code> forms)</li>
</ul>
</li>
</ol>
<p><strong>Tertiary verification — usage tracking:</strong></p>
<ol>
<li>Go to <em>Admin > Configuration > AI > AI Context > Usage</em></li>
<li>Verify that entries appear for the <code>ai_ckeditor</code> consumer, confirming the usage tracker recorded the context injection</li>
</ol>
<p><strong>Optional verification — Site Section path-pattern scoping:</strong></p>
<p>This test verifies that <code>ai_context</code> items scoped to a URL path pattern (e.g., <code>/blogs/*</code>) are correctly selected when editing a node whose URL alias matches that pattern, even though the edit form's request path is <code>/node/123/edit</code>.</p>
<ol>
<li>Ensure the <strong>path_alias</strong> module is enabled and that your "Blog" (or similar) content type has URL alias patterns configured (e.g., <code>/blogs/[node:title]</code>)</li>
<li>Create a Blog node — for example, titled "My Test Post" — and confirm it has a URL alias like <code>/blogs/my-test-post</code></li>
<li>Go to <em>Admin > Configuration > AI > AI Context > Settings > Scope: Site Sections</em> and optionally add a pre-defined section. (This step is optional — the custom pattern field on the context item works without any pre-defined sections.)</li>
<li>Create a second <code>ai_context</code> item:
<ul>
<li>Set the <strong>Label</strong> to: <code>Blog Writing Guidelines</code></li>
<li>In the <strong>Site Sections</strong> scope, enter the custom path pattern: <code>/blogs/*</code></li>
<li>In the <strong>Content</strong> field, enter: <code>IMPORTANT: Insert the sentence "This is blog-specific context from the /blogs/* path pattern." at the beginning of your response.</code></li>
<li>Set the status to <strong>Published</strong> and save</li>
</ul>
</li>
<li>Edit the Blog node you created in step 2 (navigate to <code>/node/123/edit</code>) and trigger a tone change via AI Tools</li>
<li>Verify the AI response contains <strong>both</strong> the global brand voice sentence <strong>and</strong> the blog-specific sentence — this confirms that the Site Section scope resolved the node's URL alias (<code>/blogs/my-test-post</code>) from the edit form path (<code>/node/123/edit</code>) and matched it against the <code>/blogs/*</code> pattern</li>
<li>Edit a <strong>Basic Page</strong> node (whose alias does <strong>not</strong> match <code>/blogs/*</code>) and trigger the same tone change — the response should contain <strong>only</strong> the global brand voice sentence (the blog-specific one should be absent)</li>
</ol>
<p><strong>Regression check — other plugins still work:</strong></p>
<ol>
<li>Test the other AI CKEditor plugins (Summarize, Translate, SpellFix, Completion, Reformat HTML, Modify with a prompt) — all should continue functioning normally</li>
<li>Each should also receive the injected context from <code>ai_context</code></li>
</ol>
<h3>API changes</h3>
<h4>Patch 2 (ai_ckeditor):</h4>
<ul>
<li><strong>New event:</strong> <code>Drupal\ai_ckeditor\Event\AiCKEditorRequestEvent</code> dispatched as <code>ai_ckeditor.pre_request</code> before every AI CKEditor streaming request. Subscribers can call <code>$event->setPrompt()</code> and <code>$event->setSystemPrompt()</code> to modify the AI input. Read-only getters: <code>getEditorId()</code>, <code>getPluginId()</code>, <code>getEntityType()</code>, <code>getEntityBundle()</code>, <code>getEntityId()</code>, <code>getEditor()</code>.</li>
<li><strong><code>AiRequestCommand</code> constructor:</strong> Three new optional string params — <code>$entity_type = ''</code>, <code>$entity_bundle = ''</code>, <code>$entity_id = ''</code>. Fully backward compatible; existing code that only passes 4 args continues to work.</li>
<li><strong><code>AiCKEditorPluginBase</code>:</strong> New protected method <code>createAiRequestCommand(string $prompt, FormStateInterface $form_state): AiRequestCommand</code>. Custom plugin subclasses are not required to use it.</li>
<li><strong>JS dialog payload:</strong> Now includes <code>entity_type</code>, <code>entity_bundle</code>, and <code>entity_id</code> fields extracted from the current page context.</li>
</ul>
<h4>Patch 3 (ai_context):</h4>
<ul>
<li><strong><code>AiContextScopeSiteSection</code>:</strong> New protected method <code>getCurrentPaths(): string[]</code> resolves the current entity's canonical path and URL alias from request attributes. Overridden <code>matchesCurrentContext()</code> now checks all candidate paths (request path, canonical path, URL alias) against both custom patterns and pre-defined section patterns. New dependencies: <code>AliasManagerInterface</code> (optional), <code>EntityTypeManagerInterface</code>.</li>
<li><strong><code>AiContextCKEditorSubscriber</code>:</strong> New event subscriber using <code>SELECTION_MODE_MATCH_ALL</code> via <code>fromParameters()</code> for correct non-agent consumer behavior.</li>
</ul>
<h3 id="summary-ai-usage">AI usage (if applicable)</h3>
<p>[x] AI Generated Code<br>
<br>This code was mainly generated by an AI with human guidance, and reviewed, tested, and refined by a human.</p>
> Related issue: [Issue #3549657](https://www.drupal.org/node/3549657)
issue