Skip to content

Add read-only support for the Abilities API#7952

Merged
donnapep merged 51 commits into
trunkfrom
add/abilities
May 4, 2026
Merged

Add read-only support for the Abilities API#7952
donnapep merged 51 commits into
trunkfrom
add/abilities

Conversation

@donnapep

@donnapep donnapep commented Apr 22, 2026

Copy link
Copy Markdown
Member

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 the sensei category 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 required course. 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's total serves 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 when wp_register_ability isn'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

  1. WordPress 6.9+ with this branch installed and activated. (Or open the checks at the bottom of this PR, find the Plugin Build Download line item and click View details.)
  2. Install the MCP adapter from https://github.com/WordPress/mcp-adapter (clone into wp-content/plugins/mcp-adapter, run composer install --no-dev, activate). The default MCP server registers automatically at /wp-json/mcp/mcp-adapter-default-server.
  3. Application Password for an admin user (/wp-admin/profile.php → Application Passwords). Local sites need define( 'WP_ENVIRONMENT_TYPE', 'local' ); for the section to appear.
  4. Seed data: a course with at least one published lesson, a quiz with questions, and one enrolled student.

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-abilitiesget-ability-infoexecute-ability) to drive the underlying Sensei abilities. Useful prompts:

  • "List the courses on this site."
  • "How many lessons does the course have?"
  • "Who is enrolled in and what's their progress?"
  • "Show me the questions on the lesson."

Permission and scoping checks

Repeat the natural-language prompts above with the agent authenticated as different users. Confirm:

  • Subscriber: every ability is denied (the agent receives a permission error).
  • Teacher A: agent only surfaces Teacher A's courses/lessons; asking about another teacher's course/lesson is refused.
  • Admin: full access.

Pre-Merge Checklist

  • PR title and description contain sufficient detail and accurately describe the changes
  • Adheres to coding standards
  • All strings are translatable
  • Follows naming conventions
  • Hooks and functions are documented
  • New UIs are responsive and use a mobile-first approach (no UI changes)
  • Code is tested on the minimum supported PHP and WordPress versions (guarded for WP < 6.9)

@donnapep donnapep added this to the 4.26.0 milestone Apr 22, 2026
@donnapep donnapep self-assigned this Apr 22, 2026
Copilot AI review requested due to automatic review settings April 22, 2026 18:33

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_Abilities registrar from core Sensei initialization.
  • Register sensei/get-courses, sensei/get-students, and sensei/update-enrollment abilities (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.

Comment thread tests/unit-tests/abilities/test-class-sensei-abilities.php
Comment thread tests/unit-tests/abilities/test-class-sensei-abilities.php Outdated
Comment thread includes/abilities/class-sensei-abilities.php
Comment thread includes/abilities/class-sensei-abilities.php Outdated
Comment thread includes/abilities/class-sensei-abilities.php Outdated
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.
donnapep and others added 8 commits April 23, 2026 09:29
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>
donnapep and others added 7 commits April 23, 2026 11:53
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>
donnapep and others added 7 commits April 24, 2026 13:46
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread includes/abilities/class-sensei-abilities.php
Comment thread includes/abilities/class-sensei-abilities.php Outdated
Comment thread includes/abilities/class-sensei-abilities.php Outdated
Comment thread includes/abilities/class-sensei-abilities.php Outdated
Comment thread includes/abilities/class-sensei-abilities.php Outdated
donnapep and others added 8 commits April 24, 2026 15:03
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread includes/abilities/class-sensei-abilities.php Outdated
Comment thread includes/abilities/class-sensei-abilities.php Outdated
donnapep and others added 4 commits April 27, 2026 10:48
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>
@donnapep donnapep changed the title Add support for the Abilities API Add read-only support for the Abilities API Apr 27, 2026
@donnapep donnapep merged commit 11b8189 into trunk May 4, 2026
23 checks passed
@donnapep donnapep deleted the add/abilities branch May 4, 2026 12:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants