Resolve "Enable Drupal GitLab CI pipeline and add Unit/Kernel/Functional test coverage"

What this MR does

Enables the standard Drupal GitLab CI pipeline for the project and adds the first PHPUnit test coverage (Unit, Kernel, Functional) for both agui and the agui_mercure sub-module. Everything the enabled pipeline jobs check was pre-cleaned, so the first run should be green.

CI pipeline (.gitlab-ci.yml)

  • Standard gitlab_templates include: composer validation, PHP parallel-lint, PHPCS, PHPStan, CSpell, and PHPUnit run on every MR.
  • PHPCS and CSpell are blocking (allow_failure: false) — the code base is clean for both, so there's no reason to let them drift. PHPCS uses a new phpcs.xml (Drupal + DrupalPractice, same extension set as the ai module; compiled components/*/public/types and examples/ excluded). CSpell uses a new .cspell-project-words.txt; built artifacts, source maps, and package-lock.json are ignored via _CSPELL_IGNORE_PATHS.
  • PHPStan is advisory for now (template default) — level 1 passes clean locally; suggest flipping it to blocking in a follow-up once a few pipelines have run.
  • ESLint/Stylelint are skipped: the chat component is TypeScript with its own esbuild/tsc toolchain, and the committed CSS is compiled from SCSS — neither follows core's lint configs.
  • OPT_IN_TEST_NEXT_MINOR is on. Previous-major (D10/PHP 8.1) is deliberately not opted in: the code uses PHP 8.3 syntax (typed class constants, e.g. AguiTokenController::DEFAULT_EXPIRY), so the effective PHP floor is 8.3. Worth deciding separately whether to drop the typed constants or adjust core_version_requirement.

Test coverage (56 tests / 204 assertions)

Unit

  • ToolResultBuffer: add/pop/popAll/clear semantics, per-id isolation, overwrite behavior.
  • AguiErrorCode: data provider over all nine Drupal\ai\Exception\* mappings, Guzzle ConnectExceptiontimeout, exception-chain walking (outermost match wins), unknown fallback.

Kernel

  • AguiTokenController: token parsed and signature-verified with lcobucci; anonymous vs authenticated claims (sub, drupal_uid, drupal_roles); anonymous_id passthrough; mercure.subscribe claim iff threadId; agui_token_expiry setting; missing-secret 500; the agui_token_payload alter hook (via a new hidden agui_test fixture module).
  • AguiChatController: 400/404 validation paths, query-string agentId fallback, demo-agent response metadata (status, text/event-stream, X-Accel-Buffering). SSE body assertions intentionally live in the Functional suite — the stream callbacks disable all output buffering, which is incompatible with in-process PHPUnit capture.
  • AgentToolResultSubscriber: dispatches a real AgentToolFinishedExecutionEvent with mocked tools; buffering, empty-tool-id skip.

Functional (real HTTP, real streaming)

  • Chat endpoint: permission matrix (anonymous / no-permission / use agui chat), full demo-agent SSE sequence (RUN_STARTED → TEXT_MESSAGE_* → RUN_FINISHED with reassembled deltas and consistent run/thread ids), an echoai-backed assistant flow end-to-end, and the RUN_ERROR terminal contract (code: response_error, no RUN_FINISHED after) using the echo provider's test_exception model.
  • Token endpoint: 403 / 500-without-secret / verified JWT after writeSettings.
  • Demo page: access control + form elements render.

agui_mercure

  • New hidden agui_mercure_test fixture module registering a SpyHub under the exact mercure.hub.agui service id, so tests observe published Updates without a real hub.
  • AguiAgentStatusSubscriber (unit, 8 tests): exact topics (agui/{threadId}/runs/{runId}), private updates, RUN_STARTED only on loop 0, message triplet with stable messageId, tool-call events suppressing TEXT_MESSAGE_END, TOOL_CALL_END/RESULT, RUN_FINISHED, and the null-hub / null-threadId no-op guards.
  • MercureChatController (kernel): all missing-parameter 400s, unknown agent 400, and rejection of class-based agent plugins.

Bug fix surfaced by the tests

AiAssistantApiRunner::process() returns a plain array whenever the LLM response decodes as JSON (its PromptJsonDecoder extracts any {...}/[...] from the reply). AguiChatController called ->getNormalized() on it and crashed the stream with RUN_ERROR: Call to a member function getNormalized() on array. Any provider replying with JSON-looking content would hit this. The controller now normalizes an array result into an assistant ChatMessage (re-encoded JSON) so the stream completes normally. Covered by the echoai functional test.

Also included

  • agui.info.yml now declares ai:ai_assistant_api — the chat controller hard-requires ai_assistant_api.runner and the demo form needs the ai_assistant entity type, so the module was broken without it.
  • composer.json: require-dev on drupal/ai_agents (^1.3@rc) and drupal/mercure (^1.0) so CI can install and run the agui_mercure tests (and PHPStan can resolve those namespaces), plus the license field that composer validate --strict requires.
  • README section documenting how to run the suites locally.

Running locally

vendor/bin/phpunit -c web/core web/modules/contrib/agui
cd web/modules/contrib/agui && ../../../../vendor/bin/phpcs -p .

(With DDEV, prefix with ddev exec; SIMPLETEST_DB/SIMPLETEST_BASE_URL are provided.)

Closes #3590044 (closed)

Merge request reports

Loading