Add read-only support for the Abilities API#7952
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds initial integration with the WordPress Abilities API by registering a Sensei abilities category and three abilities to expose course listing, learner listing, and bulk enrollment updates.
Changes:
- Load and initialize a new
Sensei_Abilitiesregistrar from core Sensei initialization. - Register
sensei/get-courses,sensei/get-students, andsensei/update-enrollmentabilities (with schemas + permission callbacks). - Add unit tests for the new abilities behavior.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
includes/class-sensei.php |
Loads the Abilities registrar and calls Sensei_Abilities::init() during global initialization. |
includes/abilities/class-sensei-abilities.php |
Implements abilities category + ability registration and execute/permission callbacks. |
tests/unit-tests/abilities/test-class-sensei-abilities.php |
Adds unit tests intended to validate registration, execution, and permissions. |
config/psalm/psalm-baseline.xml |
Updates baseline for the new require_once include path. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Registers the 'sensei' ability category with the WordPress Abilities API (WP 6.9+). On earlier WordPress versions Sensei_Abilities::init() is a no-op — the class loads but registers nothing. Wire the class into Sensei_Main's initialization after the internal REST API, and add the wp_register_ability[_category] functions to Psalm's UndefinedFunction suppressions (they are provided by core 6.9+).
Paginated course listing with filters for IDs, teachers, course categories, status, search, and date range, plus ordering. Non-admin callers (e.g. teachers without edit_others_courses) are scoped to their own courses regardless of the teachers filter — the ability mirrors the course scoping Sensei's admin screen already applies. Each course item returns the authoring teacher as an object (id, display_name, user_login) so agents can answer name-based queries, plus course-category terms and created/modified timestamps. No lesson or enrollment counts — those trigger per-row queries that scale poorly on busy sites and aren't required for the common listing prompts. Add edit_others_courses to the phpcs WordPress.WP.Capabilities custom list.
Paginated learner listing with filters for ID, course enrollment, progress state, pending-grading status, and text search. Each item returns id, display_name, user_login, user_email, and (when scoped to a course) a progress_status enum. This matches the output shape WooCommerce's orders-list ability uses for customer data and Jetpack Forms' form-responses ability uses for submitter data — callers who pass the permission check receive the personal fields directly, with no gating or redaction. Site owners enabling the WordPress Abilities API for AI agents are responsible for ensuring the consuming agent is contractually permitted to process that data.
Bulk enroll or remove learners across one or more courses in a single call. Wraps Sensei_REST_API_Course_Students_Controller's existing batch_create_items / batch_remove_items, which go through Sensei's provider-layered enrollment model — the remove action withdraws a manual grant and force-removes any residual enrollment from other providers. Permission check rejects the caller if they can't edit_post any one of the listed courses (fail fast rather than returning partial results).
Apply per-question grades and optional feedback for a learner's quiz attempt, then compute and store the final grade via Sensei_Utils::sensei_grade_quiz. Callers must hold either manage_sensei_grades or edit_lessons on the quiz's parent lesson. Note that quiz_id and question_id aren't discoverable through the other Sensei abilities in this release — agents need those values supplied out of band (e.g. by the admin from the Grading screen). This ability still gives programmatic callers a single entry point for grading.
Removes the sensei/grade-quiz ability and trims input filters on get-courses and get-students down to ones agents can actually populate. Dropped: ids/teachers/categories on get-courses, ids on get-students, grade-quiz entirely. All dropped inputs required IDs with no discovery path (no get-teachers, get-course-categories, or get-quiz-submission ability to resolve names to IDs). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Removes order/orderby and before/after. Order controls are cosmetic — agents can sort a page of results locally. Date filters aren't a common Sensei prompt (courses are static content, unlike orders) and the agent can filter a page locally if needed. Remaining filters: status, search, page, per_page. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
It's speculative surface — no ability accepts user_login as input, so it can't chain. Keep id (machine key) and display_name (human-readable output), which are the only two fields natural-language flows use. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
user_login (teacher) and slug (category) are secondary system identifiers. Neither chains into any ability, and display prompts use display_name / name. Keep id (machine key) and display_name / name (human-readable output) only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Rename "learner" to "student" across descriptions and phpdoc. - Drop the has_pending_submissions filter and its helper. It was designed to feed grade-quiz, which isn't in this PR. - Drop user_login from output. Not a chaining primitive (no ability consumes logins) and user_email is the better disambiguator for search matches. - Use Course_Progress_Interface::STATUS_IN_PROGRESS / STATUS_COMPLETE constants instead of hardcoded strings. This fixes a bug where we emitted "completed" while Sensei's canonical value is "complete". - Drop the synthetic "not-started" status. Sensei's model has no such status; it's the absence of a progress record. Omit progress_status from output when no record exists, matching the admin UI. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WP_User_Query already sanitizes the search string via \$wpdb->esc_like and parameter binding in get_search_sql. esc_attr is for HTML attribute context — it breaks searches containing &, <, >, \", or ' by HTML-escaping them before they reach the LIKE query. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment the empty-enrolled short-circuit (WP_User_Query treats an empty include as no restriction and returns every user) and the post-query progress_status filter (per-user-per-course state can't be joined in the query). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
update-enrollment moves to its own follow-up PR so this one focuses on read-only discovery abilities. The write ability remains available on the add/abilities-update-enrollment branch and will come in separately. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
These methods are invoked by the WP Abilities API and action system; they aren't meant to be called directly by plugin code. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Sensei Students admin screen is gated by manage_sensei_grades (see Sensei_Learner_Management::learners_admin_menu), so mirror that capability on the ability instead of edit_courses. Also regroup the class so the register_* methods sit together and the ability callbacks (execute_*, can_*) move to the bottom. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each assertion in tests with more than one assert now carries a message describing what that specific assertion proves, so a failure report identifies the failing one without needing to read line numbers against the source. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Without a course filter the ability was effectively a generic WP user lookup by name/email, returning any matching user whether or not they had a Sensei relationship. Requiring course keeps the ability honestly scoped to Sensei students and avoids overlap with potential future WP core user-search abilities. Agents that want to find a student across courses can still do it by chaining get-courses and looping get-students per course. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lets agents list Sensei lessons with optional filtering by course,
status, and search. Mirrors get-courses structurally: same discovery-
friendly input set, same teacher auto-scoping via edit_others_lessons,
same pagination shape.
Each item includes the parent course as a nested { id, title } so
agents can answer "which course is this lesson in?" without a second
call, and the lesson's module (singular, since a lesson belongs to
at most one module) when assigned. Teacher is intentionally omitted —
a lesson's author is synced with its course's author, so agents that
need teacher info can chain through get-courses with course.id.
The ability is gated by edit_lessons, mirroring the Lessons admin
screen. New methods are placed immediately after their get-courses
counterparts at each tier of the class for readability. Also adds
edit_others_lessons to the phpcs custom_capabilities whitelist.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The data model is moving toward allowing a lesson to belong to multiple courses and multiple modules. Shipping the fields as arrays now — even though they currently hold at most one entry each — avoids a breaking schema change when that lands, and avoids the need for a deprecation window alongside plural variants. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
created_at was speculative surface — most freshness and "recent" prompts answer just as well from modified_at, and we don't have a concrete prompt that needs the original creation date specifically. Keeping one date field (modified_at) covers the stale-content and "when was this updated?" cases. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Flag the O(enrolled) cost of the progress_status narrowing loop so future debugging of slow get-students requests has a starting point, and record the deferred remediation (direct HPPS progress-store query) so it isn't rediscovered from scratch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four lines: the correctness reason plus a one-liner noting the O(enrolled) cost is acceptable at typical course sizes. Drop the deferred-HPPS note. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The "at most one of each; moving toward multi-assignment" note was half wrong and half rot bait: lessons map to one module in practice and there's no firm commitment to change that. Keep the WHY (wrapping as arrays lets the shape survive future multi-assignment without a schema break) and drop the roadmap claim. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- auto_grade's schema description implied a runtime assertion that all questions support auto-grading; it's really just the stored mode. File-upload questions still fall through to manual review even when auto_grade is true. Rewrite to match. - Add a positive teacher-scope test so an accidental cap-check inversion would fail loudly instead of silently locking teachers out of their own quizzes. - Add a wrong-post-type test: passing a non-lesson id to check_permissions must return false, pinning the defense-in-depth the permission callback already provides. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- prepare_question_item silently produced "3 questions from " when the referenced question-category term had been deleted. Fall back to a visible "(deleted category)" marker so the broken state is evident to agents rather than rendered as garbled prose. - The type-slug description hard-coded the six core slugs, which would lie the moment an extension registered another (via the filterable sensei_question_types). Rewrite so the description points at the taxonomy source without enumerating. - Add three tests covering previously uncovered behaviour: pagination returns the right slice on page 2, a teacher can read their own quiz's questions (symmetric with the existing deny test), and a multiple_question pool post is surfaced with the synthetic category-question type and a title that names both the pool size and the category. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- execute_get_students echoed an empty-title course envelope when the
input id didn't resolve to a course post, which in direct-callback
use silently produced a response indistinguishable from a real
empty course. Return WP_Error('sensei_course_not_found') like
execute_get_quiz does for missing lessons. Through the ability
pipeline the permission callback already rejects bogus ids — the
check is defense in depth for direct callers.
- Add a permission test for a nonexistent course id (observable
behaviour via the ability pipeline).
- Add a direct-execute test covering the new WP_Error branch for a
wrong-post-type input.
- Add an empty-enrolment test so a future regression that removed
the empty-include short-circuit (and thus leaked the full user
list) would fail loudly.
- Add a pagination + progress_status page-boundary test so the
recent "narrow enrolled set before paginating" fix is pinned to a
regression scenario larger than its original single-user case.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Pagination test now asserts the exact ids returned on page 2 match array_slice of the ability's own ordered question set. A off-by-one regression in the array_slice math would flip this assertion, where the earlier count-only check silently accepted any 2-item slice. - Category-question test asserts grade and description are absent from the placeholder item so a refactor that accidentally gave pool placeholders the regular-question shape (a documented contract break) fails loudly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
The four execute_* callbacks capped per_page with min(100, ...) but had no lower clamp, so a direct caller passing per_page=-1 would reach WP_Query/WP_User_Query as -1 and return every row — schema validation covers the ability pipeline, but tests and any future direct callers bypass that. Make the existing max(1, ...) guard on `page` symmetric by wrapping `per_page` in max(1, ...) too. The can_manage_grades docblock's mention of manage_options fallback wasn't obviously tied to the second (per-course edit_post) check — reads as if the first manage_sensei_grades check should also fall back to manage_options. Rewrite to make which check the fallback applies to explicit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Question post_content is block-editor-serialized markup that's near- empty for most questions and carries correct/incorrect feedback blocks — the latter being authoring-tier hint prose we explicitly excluded from the ability contract. In Sensei's block model the question text lives in the post title, so callers already have the semantic content; shipping post_content added token noise, placeholder copy, and a feedback-leak path without meaningful upside. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The three other abilities that take a parent id as input (get-quiz, get-questions, get-students) refuse callers who can't edit the parent. get-lessons did not: a teacher asking about another teacher's course would pass can_edit_lessons (they have the global edit_lessons cap) and then get a silently empty list because author__in filtered out the other teacher's lessons. Callers couldn't tell "no lessons in this course" from "you can't see this course." Make can_edit_lessons input-aware: when course is provided, require edit_post on that course (with the usual manage_options fallback). When course isn't provided, behaviour is unchanged — the teacher scope still narrows the returned set via author__in in execute. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The quiz id is an internal identifier agents never naturally hold — every get-questions call was preceded by a get-quiz call purely to resolve it. Accept lesson directly and resolve the quiz internally, matching how agents already think about the hierarchy (lessons own quizzes, quizzes are invisible plumbing). Envelope's quiz-wrapper is flattened to a direct lesson echo since the echoed quiz id was always an internal value the caller didn't supply. Permission callback collapses into can_edit_quiz_lesson — identical check against the lesson input. If Sensei ever decouples quizzes from lessons this schema will need to change; accepting that cost for the immediate round-trip saving. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The other read abilities expose entity identity, relationships, and content (courses, lessons, students, questions). get-quiz was the only one returning configuration scalars (pass_required, quiz_passmark, auto_grade), and with get-questions now keyed on lesson it is no longer needed as a chaining step. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Psalm flags these as RedundantCondition under PHP 8.2 because $per_page is typed int<1, 100> after min/max clamping. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Use Sensei()->question->get_question_type() so missing question-type terms fall back to multiple-choice (matches Sensei core), and resolve progress through course_progress_repository->get() in a single read instead of two separate Sensei_Utils calls. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Aligns with WooCommerce's convention so resource operations group together when more are added (e.g. courses-create alongside courses-list). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Proposed Changes
Registers Sensei with the WordPress Abilities API (core in WP 6.9+) so AI agents can discover courses, lessons, questions, and students through a standard interface.
New class:
Sensei_Abilities(includes/abilities/class-sensei-abilities.php) registers thesenseicategory and four abilities.sensei/courses-list— paginated list. Teachers see only their own.sensei/lessons-list— paginated list. Teachers see only their own.sensei/questions-list— paginated list keyed by lesson id.sensei/students-list— paginated list scoped to a requiredcourse. Teachers only read students on courses they author.Design principle. Bounded identity refs (
{id, title}) are embedded; unbounded rosters are exposed through separate listing abilities keyed by parent id. Inline content counts are omitted — each listing envelope'stotalserves the same need without forcing every call to pay for full id lists. Quiz configuration scalars (pass mark, auto-grade) are not exposed: every other ability returns identity/relationships/content, and a single ability for three settings was inconsistent with the rest of the surface.WP 6.7/6.8 safety.
Sensei_Abilities::init()short-circuits whenwp_register_abilityisn't defined; nothing registers on older WordPress versions.Testing Instructions
The proper end-to-end test is to drive the abilities through the MCP adapter the way an AI agent would. WP 6.9+ ships the Abilities API in core; the MCP adapter is a separate plugin.
Set up an MCP-enabled local site
wp-content/plugins/mcp-adapter, runcomposer install --no-dev, activate). The default MCP server registers automatically at/wp-json/mcp/mcp-adapter-default-server./wp-admin/profile.php → Application Passwords). Local sites needdefine( 'WP_ENVIRONMENT_TYPE', 'local' );for the section to appear.Drive it from an MCP client
Add a
.mcp.json(project-level) or equivalent client config pointing at the default server, with the application password as HTTP Basic auth:{ "mcpServers": { "sensei-local": { "type": "http", "url": "http://your-site.test/wp-json/mcp/mcp-adapter-default-server", "headers": { "Authorization": "Basic <base64(admin:app-password)>" } } } }Then ask the agent natural-language questions and confirm it chains the three default meta-tools (
discover-abilities→get-ability-info→execute-ability) to drive the underlying Sensei abilities. Useful prompts:Permission and scoping checks
Repeat the natural-language prompts above with the agent authenticated as different users. Confirm:
Pre-Merge Checklist