Skip to content

Add the Global AI Workspace - #1004

Draft
whyisjake wants to merge 31 commits into
developfrom
feat/ai-workspace
Draft

Add the Global AI Workspace#1004
whyisjake wants to merge 31 commits into
developfrom
feat/ai-workspace

Conversation

@whyisjake

@whyisjake whyisjake commented Sep 4, 2026

Copy link
Copy Markdown
Member

Implements #282: a full-screen conversational admin screen where a site owner talks to an AI that can read their content — under their own capabilities, never above them.

What this adds

A new ai-workspace experiment (Tools → AI Workspace, manage_options), built on the Abilities API and core's ability-backed tool loop:

  • A turn endpoint and tool loop. Each function call runs through the resolver's single-call execute_ability() rather than the batch path, so every invocation — including denials — produces exactly one log row and a provenance envelope.
  • Two read abilities. ai/search-content returns titles and excerpts only; ai/read-content-bodies returns full bodies for at most five posts named by ID. Both filter row by row at execute time against the requesting user's own capabilities.
  • Propose-then-confirm writes. Draft creation is proposed with resolved values a person approves before anything is written. No write ability is registered.
  • Streaming, forward-ported through the existing SDK overlay pattern with an Anthropic SSE mapper, gated so a site that cannot stream falls back cleanly and says so.
  • A block editor handoff, opening the workspace seeded with the current post's identity — never its body, which would be a second, unenforced way in.
  • A retrieval trace, reporting per invocation what was searched, what came back, and what permission withheld.

Notes for review

Permission filtering is execute-time, not declaration-time. The coarse capability on a tool decides whether it is declared; which rows come back is decided per row, per user, on every call. Both matter, and the tests mutate each independently to prove neither is inert.

Withheld counts are measured, not derived — a deliberate divergence from the plan. The plan's U13 prescribed the opposite, specifying the withheld count as "the difference between the ability's total and its returned rows". Implementing it revealed that verification to be wrong, so it was not followed. total - count(results) conflates pagination with permission filtering: page one of fifty matches would announce forty posts hidden by the person's role on an ordinary search. ai/search-content counts withholding as the permission walk drops rows from the page it builds. ai/read-content-bodies deliberately reports no count at all — it answers for IDs the caller named, so counting unknown and unreadable apart would say whether those exact posts exist.

Tool surface is a hand-maintained allowlist of three abilities, which is deliberate for a first cut and is the subject of follow-up #1003 — every ability the model can call is also reachable by an instruction embedded in content someone else wrote.

Vendored SDK code (please review deliberately)

This PR adds 11 files / +2,207 lines under includes/Vendor/AiClient/. Reviewers should weigh this separately from the rest of the diff, because it is third-party code rather than plugin code.

What it is. WordPress\AiClient is the PHP AI Client, bundled inside WordPress core at wp-includes/php-ai-client/ — it is not a Composer dependency of this plugin, which has no runtime dependencies at all. includes/Vendor/AiClient/ is not a copy of that library: the files declare the SDK's own WordPress\AiClient\… namespace and are loaded by SDK_Overlay's prepended autoloader, so the plugin can answer a class request before core's autoloader and supply classes the bundled release does not have yet. Each is gated by a sentinel so the overlay stands down once core catches up.

This is an existing pattern, not a new one. develop already vendors 10 files this way for the embeddings feature. Streaming is the second consumer and follows the same shape. Nothing in core or in vendor/ is modified.

The part that genuinely differs from embeddings, and the reason to look closely: the embeddings overlay front-ports classes from upstream trunk — code that exists and will land. The streaming overlay is pinned to the head of an unmerged PR (php-ai-client#255) on a fork, not to upstream trunk. If that PR changes shape or never merges, this branch carries 2,200 lines of a future version that never arrives. That is a defensible trade for a feature-flagged experiment, but it should be an explicit decision rather than something discovered inside a large diff.

Two consequences are recorded in includes/Vendor/AiClient/README.md:

  • A re-vendor obligation once Remove duplicate error display in generate Alt text #255 merges.
  • A guard fragility. The overlay discriminates on the presence of Response::getStream() and RequestOptions::isStream(). If a later SDK release adds either method for an unrelated reason, the guards silently stop discriminating and must be re-picked.

Every symbol the trunk-era vendored classes reference was checked against the bundled 0.3.1 signatures, and the modified Response is a strict superset: the constructor's ?string $body widens to string|StreamInterface|null, and getBody()/getData()/toArray() behave identically for a string body.

On the Codecov report: those 2,207 vendored lines are ~21% of this patch and are its worst-covered files (RequestOptions 19%, Response 40%, ChunkAccumulator 69%), because they are upstream code carrying paths this plugin never calls. Coverage currently measures all of includes/ with no exclusions, so third-party code is scored as if it were ours. Project coverage still rises with this PR (75.37% → 75.99%).

Testing

  • npm run test:php1717 tests, 5076 assertions, 39 skipped, 0 failures.
  • npm run test:e2e32 AI Workspace specs passing, against a mocked Anthropic provider with sequenced tool-calling turns. No network access.
  • composer run-script lint (PHPCS, WordPress-VIP-Go + Slevomat) — clean.
  • vendor/bin/phpstan analyse --memory-limit=3G (level 8) — no errors.
  • npm run typecheck, npm run lint:js — clean.

Test suites executed by name, as the WordPress AI Guidelines require:

PHPUnit (tests/Integration/) — AI_WorkspaceTest, Turn_ControllerTest, Tool_SelectorTest, Proposal_ControllerTest, Propose_DraftsTest, Prompt_Model_ClientTest, Search_ContentTest, Read_Content_BodiesTest, SDK_OverlayTest, Streaming_Turn_DriverTest, Streaming_Http_TransporterTest, Anthropic_Stream_MapperTest, Anthropic_Streaming_Text_Generation_ModelTest, Fopen_Stream_OpenerTest, UninstallTest.

Playwright (tests/e2e/specs/experiments/) — ai-workspace.spec.js, ai-workspace-tools.spec.ts, ai-workspace-post-results.spec.ts, ai-workspace-markdown.spec.ts, ai-workspace-handoff.spec.ts.

Individual tests named in the mutation evidence below: test_execute_callback_clamps_to_five_without_schema_validation, test_private_body_of_another_author_is_withheld_from_author, test_draft_body_of_another_author_is_withheld_from_lower_roles, test_unexposed_post_types_are_never_read, test_mixed_request_returns_only_readable_posts, test_retrieval_summary_never_counts_paginated_rows_as_withheld, test_pagination_alone_reports_nothing_withheld, test_withheld_counts_the_rows_the_permission_walk_dropped, test_vendored_files_use_the_prefixed_psr_dependencies.

Security-relevant tests were verified by mutation rather than by passing alone. Forcing Read_Content_Bodies::check_read_permission() open and removing its clamp fails five tests, including one asserting an unexposed post type's body sentinel appears nowhere in the encoded result. Replacing the withheld count with subtraction fails test_retrieval_summary_never_counts_paginated_rows_as_withheld and test_pagination_alone_reports_nothing_withheld.

Still open

Opening as a draft — the presentation layer is being worked separately, and three decisions are unresolved: the confirmation modal's default selection state, the full-screen gap (is-fullscreen-mode is applied but @wordpress/interface CSS is never enqueued), and the editor handoff's placement in the Options menu.


AI assistance: Yes. Tool(s): Claude Code (Opus 5). Used for: implementation of all units in the linked plan, test authoring including the mutation testing described above, and this description.

@whyisjake — this line needs your sign-off and I have deliberately not written it for you. The WordPress AI Guidelines require the contributor to understand every line submitted and be able to explain it under review. That attestation is yours to make or reword, and it is not something an agent can satisfy on your behalf. Replace this block before marking the PR ready.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L

Open WordPress Playground Preview

whyisjake and others added 22 commits September 3, 2026 12:31
…reen

Adds a full-screen, capability-gated admin screen behind its own experiment
toggle. The screen renders an app shell only; conversation behaviour, REST
routes, and streaming land in later units.

The capability check is layered rather than single-point: the menu entry, the
asset enqueue, and the render callback each gate on manage_options, so a direct
call to the render callback cannot emit the app shell or its localized data to
a user without the capability.

Advances R1, R2, R3, R4 (U1).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
Registers ai/search-content, a bounded full-text search over the post types
exposed to the Abilities API. core/read-content is exact-match only and is kept
byte-similar to core's copy, so this lands as a sibling rather than a change to
it.

Results are filtered at execute time against the requesting user's read
permission, mirroring core/read-content's permission walk including the
inherited-parent chain, so every ability consumer inherits the same filtering
rather than relying on the query alone. The permission callback can only gate
coarsely; the row filter is the authoritative check.

Two query behaviours worth noting. per_page is capped at 20 in the schema and
clamped again in the callback, so the cap holds on transports that skip schema
validation. perm => 'readable' is passed only for a single-post-type query:
WP_Query resolves a multi-type query to the placeholder capability
read_private_multiple_post_types, which no role holds, silently hiding private
posts from users who can read them.

Registered from the workspace experiment rather than the Custom Abilities
gated collection, so the workspace always has its search tool and every other
ability consumer can still reach it.

Advances R12, R13 (U2).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
…overlay

Adds an independently-gated `streaming` feature to SDK_Overlay, alongside
`embeddings`, so environments whose bundled PHP AI Client predates streaming
get the streaming types from a vendored copy.

A spike established what the plan had wrong. WordPress core bundles the SDK
without its vendor directory, so upstream PR #255's Guzzle-based streaming
transport does not apply here; core supplies its own transporter over
wp_safe_remote_request. Only the SDK-level types are useful, and a WordPress
streaming transporter is required regardless of whether that PR merges.

Vendored files are NOT verbatim, unlike the embeddings feature. Core prefixes
its PSR and Nyholm dependencies under WordPress\AiClientDependencies, so every
such import is rewritten; the unprefixed names do not resolve under a WordPress
bootstrap. The README records the rewrite table.

PromptBuilder and AiClient are deliberately excluded. PromptBuilder is 45KB of
trunk-era code and the largest drift risk against the bundled 0.3.1, and the
method it adds only validates, resolves the model, and delegates. Two further
candidates were trimmed as unreferenced by the kept set.

The sentinel is StreamedGenerativeAiResult rather than the streaming model
interface: resolve() probes with class_exists(), which returns false for
interfaces, so an interface sentinel would activate the feature even where the
environment already ships streaming. A new test enforces that for every feature.

Streaming is not yet reachable end to end: the transport and an Anthropic model
implementing the streaming interface are still missing (U12).

Adds U11 (new; the plan has no unit for this work).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
Makes streaming reachable in PHP. A Streaming_Http_Transporter decorates the
configured transporter: non-streaming calls delegate unchanged, and a streaming
request is opened through fopen() with a stream context so the body is pulled
lazily and wrapped as a PSR-7 stream the SDK's SSE parser reads unmodified.

Because this path bypasses wp_safe_remote_request(), it also bypasses connector
approval and request logging, so both are restored explicitly. Approval mirrors
Http_Guard step for step -- same Connector_Key_Index lookup, same
Caller_Identifier, same Approvals_Store check, same pending-approval record --
and runs before the opener is touched. A test asserts zero calls to the opener,
which is the only egress point, so "before egress" is proven rather than
implied. Logging wraps the whole body in a finally, so a refused request is
logged as an error too. Two honest limits are documented on the class: a
streamed entry records time-to-headers, not time-to-last-byte, and carries no
token counts, because those arrive inside a body that has not been read yet.

The same bypass loses that function's SSRF protection, so wp_http_validate_url()
is applied before connecting and CRLF is stripped from header names and values.

Anthropic's stream is mapped in its own class. Its SSE uses named events rather
than a single delta shape, which is why upstream PR #255 does not cover it, and
tool arguments arrive as input_json_delta fragments that must be concatenated
across deltas before parsing -- parsing a fragment early yields plausible but
wrong arguments. Interleaved fragments from concurrent tool calls are covered.

The transporter is deliberately not installed globally and nothing constructs
the streaming model in production code; ordering against Logging_Http_Transporter
in the registry belongs to the turn endpoint that will drive it.

phpstan.neon.dist excludes the two classes that implement or extend SDK and
provider symbols PHPStan cannot resolve. Verified necessary: without it the run
reports 15 errors that PHPStan itself marks unignorable. This follows the
existing precedent for Logging_Http_Transporter; the mapper, opener and
interface stay fully analysed.

Adds U12 (new; the plan has no unit for this work).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
The spike in U3 contradicted three assumptions KTD3 rested on, and its outcome
added two units the plan did not contain. Brings the artifact back in line with
the code so U4 is planned against what exists.

- KTD3 records the corrected findings: upstream PR #255 streams via Guzzle,
  which core does not bundle, so a WordPress-side transport is needed either
  way; vendoring cannot be verbatim because core prefixes its PSR dependencies;
  and the guards are restorable by construction through the public transporter
  seam rather than bypassed.
- U3 is marked complete with its outcome; U11 and U12 are added.
- U2's file list pointed at the gated-abilities collection, which accepts only
  gated base classes and runs only when a different experiment is enabled.
- U4's approach corrected on three counts found by review and confirmed in the
  code: the batch resolver call offers no per-call seam for provenance or
  logging; a null-input permission call cannot filter tools because this repo's
  content callbacks are input-dependent; and cancellation cannot rely on abort
  detection on a buffered turn.
- Stop conditions and the streaming open question updated: a host that cannot
  stream now degrades to a buffered request instead of blocking the plan.

Also adds the Anthropic provider to the wp-env configs, since Claude is the
chosen provider and the environment previously loaded only Google and OpenAI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
…l loop

Runs a bounded, permission-filtered, logged tool-calling conversation behind
POST ai/v1/workspace/messages, with a companion cancel route. Both gate on
capability independently of nonce validation.

Tool selection uses a coarse, input-free capability predicate. Filtering with a
null-input permission check does not work here: this repository's content
permission callbacks are input-dependent and return false without a post id or
slug, so that filter would deny every tool to every user including
administrators. Object-level authorization stays at execute time, where
WP_Ability::execute() runs the ability's own callback.

The loop iterates the assistant message's function-call parts and invokes the
resolver's single-call execute_ability() per call. The batch form runs every
call internally and exposes no hooks, leaving no seam for the provenance
envelope or for one log row per invocation.

Tool results are wrapped as provenance-tagged data before returning to the
model, and every invocation writes exactly one log row -- allowed, denied, and
failed alike. Denials are distinguishable from failures on the indexed status
column without decoding context, and context.surface discriminates workspace
rows from MCP rows sharing the same ability name.

Cancellation is out of band: a separate route sets a marker the loop re-reads
between rounds. Client-abort detection cannot serve here because PHP observes a
disconnect only after writing output, which a buffered turn never does and the
strict no-output test setting forbids. The marker is written by a different
request, so the read drops its cache entries first -- otherwise the loop would
answer from the value cached at request start and never observe a cancel.

Conversation state lives in user-scoped transients: session-lifetime,
regenerable, and expiring, which matters because it accumulates retrieved
private and draft post bodies. Ownership is enforced twice -- the key is derived
from the user id, and the stored owner is compared again on load -- so a second
user holding the same capability cannot read another's conversation. A miss is
a 404 rather than a silently-new conversation.

Streaming is driven through U12's transport behind a seam, and every reference
to the provider-dependent model is isolated so nothing autoloads where the
Anthropic plugin is absent. A transport that cannot stream on this host falls
back to a buffered request rather than failing.

phpstan.neon.dist excludes one small driver file whose provider parent is
unresolvable; the errors are non-ignorable and the surface was deliberately
isolated so the rest of the loop stays fully analysed.

Advances R6, R7, R8, R9, R10, R13, R18, R20, R21 (U4).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
…mission

Builds the conversational surface: transcript with tool steps, prompt input
with send/stop/clear, the two context scopes, and the streaming client. Server
side, Stream_Responder consumes the filter seam the turn endpoint left and
emits SSE frames.

Model output is rendered by a restricted-subset markdown renderer rather than a
parser plus sanitizer. The renderer emits a node tree, never an HTML string, and
the components map nodes to elements, so dangerouslySetInnerHTML appears nowhere
in the unit. Dangerous constructs are impossible to emit rather than stripped
after parsing.

Links get the same treatment as images, which the plan understated. Blocking
images alone leaves the exfiltration path half open: a model steered by injected
post content can emit a link whose destination carries retrieved private content
in its query string, behind anchor text that reads as a legitimate next step. A
link is live only when it resolves to http or https on this host; everything
else renders as text with its destination visible. Images are inert including
reference-style syntax, which is not implemented at all.

Streaming asks for SSE and branches on the response content type, so a host that
cannot stream renders the identical turn arriving at once with a quiet line
saying so -- not an error state. Headers are sent lazily on the first delta, so a
turn that never streams falls through to the ordinary JSON body.

Accessibility replaces the plan's loose wording: one polite visually-hidden live
region updated at sentence and paragraph boundaries and on completion, with the
transcript itself no longer aria-live. Announcing every chunk was the failure
mode that wording invited.

Retry resends the original prompt as a new turn rather than replacing the failed
one. A turn that failed mid-stream may already have run tools, and replacing it
in place would hide that it ran twice -- exactly the fact a person needs once
writes exist. Only errors offer retry; round-cap and cancelled turns do not,
because the model ended those, not the transport.

ContextScope in the TypeScript types was 'site' | 'post-type' | 'selection',
which matched neither R6 nor the endpoint's enum. Reconciled to 'site' |
'general'.

Known gap, commented at the call site: on the first turn of a new conversation
the client does not yet know the server-minted conversation id, so Stop closes
the reader locally while that round finishes server-side. Later turns cancel
properly. Closing it needs the turn endpoint to publish the id early.

Advances R2, R6, R8, R9, R11, R19 (U5).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
…the transcript

Closes a gap the transcript could not work around: the turn route returned only
tool invocation records, never the results, so no post list could reach the
client at all. Each tool_calls entry now carries the ability's own return value
on success, and null on a denial -- a refusal is not a result.

The passthrough is deliberately narrow. Nothing is re-fetched, joined, or
enriched, because re-fetching post data client-side would bypass the
execute-time permission filtering the whole read path rests on. A test asserts
the value the client receives is identical to direct ability execution for the
same user and input, so the response cannot leak more than the tool did. The
client then treats it as unknown and rebuilds each row from the fields the
search ability declares, so a future ability with a wider payload cannot push
unexpected fields into the table.

The table is chrome-free by construction rather than by configuration: composing
DataViews with an explicit Layout child means search, filters, view config and
pagination are never mounted, since the component renders its default UI only
when given no children. Per-field flags and controlled view state back that up.

The plan's instruction to copy the DataViews stylesheet in webpack was
unnecessary -- the copy plugin already emits it once to a fixed path outside the
entry map -- so the config is untouched and the page enqueues the existing file
conditionally, mirroring the request-logs page.

R14 is only half-met by this unit: rows link to the post, not to its editor. The
search ability returns no edit URL and nothing client-side may invent one, so
the edit action is gated on a row-supplied URL and is currently never rendered.
Completing it belongs on the ability that already owns the permission walk.

Advances R14 (U6).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
Completes R14. The transcript table already had an edit action gated on a
row-supplied URL, but no ability emitted one, so the action never rendered and
rows linked only to the published post.

The URL doubles as the permission proof: it is present only when the current
user can edit the post, so a consumer does not re-derive the capability and
cannot construct an editor URL for a post it may only read. That also avoids
guessing wp-admin/post.php, which breaks on non-standard admin paths.

The explicit capability check is deliberate redundancy, and the docblock says
so: get_edit_post_link() already returns nothing for a user who cannot edit, and
a mutation run with the gate removed left the new tests green. The gate stays
because the emptiness of this field is a permission guarantee consumers rely on,
and that guarantee should not rest on the internals of a core function that is
free to change. The tests lock the contract rather than the mechanism.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
…ally work

Two bugs found by running the workspace against a real provider rather than
against tests. Both were invisible because each degraded politely instead of
failing.

OptionEnum::FUNCTION_DECLARATIONS is not a constant. That call threw an
undefined-constant Error, a catch-everything swallowed it, and the screen
therefore always reported "no compatible model available" -- even though all
eleven Anthropic models advertise function calling. The enum extends
AbstractEnum, so the member is reached with isFunctionDeclarations().

The catch is now narrower in effect: an Error is re-thrown rather than
swallowed, so a programming mistake in that method surfaces instead of
presenting as a permanent, plausible-looking capability gap. An unreachable
provider still degrades quietly, which is the case the catch is for. Throwable
is still the caught type because the project's coding standard requires it over
Exception.

The streaming driver called GenerativeAiResultChunk::toText(), which does not
exist -- the method is getDeltaText(). The Throwable guard turned that into
"this host cannot stream", so every turn silently fell back to a buffered
request and the UI truthfully reported a condition that was not true. Verified
end to end afterwards: a turn now streams, and the fallback notice is gone.

Only the visible delta text is emitted; getReasoningDeltaText() carries the
model's thinking, which is not the reply.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
…hinking signatures

Two defects found by running a two-turn conversation against the real provider.

The model was whichever candidate the registry happened to return first, which
on a live site resolved to the most capable and most expensive model available
-- selected by array position, not intent. Selection now consults an ordered
preference, filterable per site, and falls through cleanly when a preferred
model is absent.

That accidental choice also exposed the second defect. Always-on-thinking models
return thinking blocks, and Anthropic rejects a replayed thinking block whose
signature is missing, so the second turn of any conversation failed with
"messages.1.content.0.thinking.signature: Field required". The signature arrives
as its own signature_delta after the block's thinking text, so the mapper now
records which block indices opened as thinking and carries the signature onto
the part it belongs to, matched by index rather than arrival order.

The storage round trip was never at fault: the SDK's MessagePart already carries
a thought signature and Message::fromArray()/toArray() preserve it. The loss was
ours, in the mapper, on the streaming path only -- which is why no test saw it
and why it took a live second turn to surface.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
The model can propose drafts; only a person's confirmation writes them. The
proposal is persisted server-side and the confirmation renders the stored
resolved values, never the model's summary of them -- that summary is
attacker-influenceable, which is the reason the requirement exists.

There is no registered write ability. The plan called for one kept off the MCP
surface, but that is an intention without a mechanism: a registered ability is
reachable by the MCP surface, the Abilities Explorer, and any third-party
caller, none of which have a confirm gate. Instead the writer is a plain class
reachable only from the proposal controller, so the confirm gate is a structural
property of the write path rather than a validation rule someone can route
around. The model's only reach is a propose ability that writes nothing, is
hidden from REST and MCP, and refuses outside an active turn context.

Ownership is bound, not merely capability-checked. Capability is not identity:
without this, a second user holding the same capability could execute another's
proposal by id, and the values they approved on screen would not be the ones
written. The store derives its key from the owner, compares the stored owner
again on read, and compares the conversation at execution -- three checks
independent of the capability re-check at write time. Verified by mutation:
with the key no longer user-scoped, the stored-owner comparison still refuses a
peer, so both layers hold on their own.

Set approval is bounded. Proposals cap at twenty items, matching the search
tool's row bound, and items start deselected so an item appended by injected
content must be chosen deliberately rather than ridden in on a batch approval.
A status the user cannot publish to fails the proposal instead of being
downgraded silently.

Partial failure is reported per item to both the person and the model, with no
auto-retry, and re-executing a confirmed proposal creates nothing. Every write
attempt is logged in the shape the tool-call rows already use, so writes and
reads join on one conversation.

Advances R15, R16, R17, R20 (U7).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
An Options-menu action in the post editor opens the workspace with the current
post in scope. It carries identity only -- post ID, status, type, title -- and
never the body: the workspace reads content through the permission-checked tool
path, so there is one enforcement path and nothing trusts a client-supplied
body. A test asserts a marker in the post content never reaches the localized
data.

The seed is re-resolved server-side from the query argument and checked with
read_post, so the URL cannot hand someone a post they may not read; a refusal
renders an explanation rather than content.

A post title is author-controlled text, so it is flattened to single spaces and
clamped before it leaves PHP -- a title cannot smuggle a multi-line instruction
block -- and it reaches the model only inside a message the person has seen and
can edit. The composer is prefilled, never auto-sent. The residual surface is a
person who sends a prefilled prompt without reading it; that is bounded to one
clamped line with a human in the loop, and removing it entirely would mean not
naming the post at all, which the available tools make useless.

Mounted as a post-level Options-menu item rather than the block toolbar the plan
named. This is a navigation action for the whole post: a block toolbar entry
would either repeat on every block or be arbitrarily scoped to one block type
the way content resizing is. The repository already has post-level plugin
precedent.

The webpack entry is part of this change. That build declares every bundle
explicitly, and the asset loader bails silently on a missing asset file, so
without the entry the action would never appear and nothing would report why.

R5 is only half met and should not be read as complete: the handoff carries
identity, but no read-full-body tool is in the workspace allowlist, so the
assistant can find the post and see its excerpt while the body stays out of
reach. Widening the allowlist is a security-relevant scope decision and was
deliberately left alone.

Advances R5, R18 (U8).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
The workspace had no stylesheet and no visible identity: a screen-reader
heading, three unstyled regions, and a one-sentence empty state. This adds
the screen it needs to read as a place rather than a form.

- Wrap every state, including the unavailable ones, in the admin-ui Page
  shell with the plugin's AI mark, so a person who lands here with no
  credentials still sees where they are.
- Replace the bare empty state with four suggestions drawn from the
  workspace's capability classes. Choosing one fills the composer instead of
  sending it: two of the four go on to propose writes, and a prompt should be
  edited before it runs.
- Move clearing the conversation into the header as "New topic". It acts on
  the whole transcript, not on the draft message, and it was the third button
  competing with Send and Stop.
- Turn the context scope from a labelled select into a compact control in the
  composer that states the active scope on its face. Scope belongs to the
  message being written, so it stays visible while writing; each option now
  carries its own explanation rather than one help string describing both.
- Add the stylesheet. A person's message is a contained bubble aligned to the
  end of the column and the assistant's answer runs its full width, which is
  what tells them apart without a label.

The screen sits below the admin bar rather than taking the viewport, so its
height subtracts the bar; without that the composer is pushed off-screen.

Tests: npm run test:e2e for the three ai-workspace specs — 25 passed,
including two new cases covering the suggestions and the scope control.
connector-approval.spec.js fails in this environment, verified failing
identically on the parent commit and unrelated to these files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PFmvYkGE1zujS7EkqmTe4h
The mark carries no width or height of its own and relies on a stylesheet to
size it. The editor bundle ships no stylesheet, so in the post editor's Options
menu it rendered at the viewBox's 236px -- a glyph bleeding out of the panel and
a menu row tall enough to look like the item had landed in the wrong group.

AIIcon now takes an optional size, defaulting to the existing behaviour so the
content-resizing toolbar, which sizes it through its own stylesheet, is
unaffected. The handoff passes 24, matching every other icon in that menu.

Also fixes a prettier violation in the workspace e2e spec that arrived with the
design shell and was failing lint:js on the branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
Two additions, one from the design canvas and one from running the workspace
against a real provider.

Retrieval is shown rather than hidden. The design boards put a single line above
each answer naming what was searched and what was read in full, which turns the
plan's own caps into something a person can see. It is also the honest place to
report content a permission check removed: today filtering is silent, so a
contributor and an editor get different answers with nothing explaining why,
which is indistinguishable from a broken search. R7 only covered the case where
no tool is available at all.

The allowlist grows from search-only to search plus a capped, permission-filtered
body read. Search returns titles and excerpts, so the assistant can find a post
and not read it -- which is why the editor handoff half-works and why the gap
analysis the problem frame leads with underdelivers. This is the largest increase
in reachable content on this surface and is recorded as a Key Decision rather
than buried in a unit, because it also widens what an injected instruction can
reach: bodies are where a contributor's draft becomes instructions inside an
editor's session. U14 carries the body-borne injection fixture the existing test
does not cover, since that one only exercises excerpt-length content.

Adds R24-R26, U13, U14, and their Definition of Done rows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
The transcript could not be scrolled back to the start of a long answer. It sets
min-height and overflow-y correctly, but bottom-aligned with
`justify-content: flex-end`, and in a scrolling column that keyword pushes
overflow past the top edge where no scrollbar reaches it -- the opening lines
were gone for good. Bottom alignment now comes from an auto margin on the first
child, which settles a short transcript against the composer and lets a tall one
scroll from its true beginning.

Code blocks put their horizontal scroll on the wrapper, which also holds the
copy button, so a wide snippet carried its own copy control out of view --
hardest to reach on exactly the snippets that needed it. The pre scrolls now and
the block does not.

Verified in the browser: a transcript overflowing 1866px into a 578px viewport
scrolls to the top, and a 600-character line scrolls inside a pre 820px wide
while the button stays in place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
… e2e

The mock answered one complete response per call and matched on request-body
substrings, so a multi-round tool-calling turn could not be driven offline at
all. Scenarios now supply an ordered sequence of provider responses, consumed
one per matching request, with the last entry repeating so an extra round
terminates instead of falling through to the substring default. The counter
lives in an option keyed by provider and scenario, because a turn's rounds all
happen inside one request.

Anthropic fixtures are new, and the models-endpoint mock is load-bearing rather
than incidental: model ids are fetched live, so without it the provider reads as
unconfigured and no workspace spec reaches a turn.

Streaming is not mockable at this seam, and that is now proven rather than
assumed. The streaming opener calls fopen() on the provider URL directly, so it
never enters wp_safe_remote_request() and pre_http_request is never consulted --
a probe from the test container reached the real Anthropic API and came back 401.
There is no injection point to attach a chunked fixture to. Scenarios therefore
suppress the stream emitter and exercise the buffered path, while specs without a
scenario still cover the streaming decision and the WordPress-to-browser
responder, which is a separate and testable seam.

One thing the investigation confirmed: with the emitter live, the streaming round
was refused by connector approval and logged as an error before any egress, while
the buffered call succeeded -- the buffered path escapes that check only because
the mock returns from pre_http_request before the guard runs. The hand-rolled
approval check on the streaming transport is doing its job.

Advances R8, R10, R15 (U9).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
Adds the experiment page, changelog and readme entries for what shipped, and a
note in the testing docs about running both suites in one session.

The page documents the feature as built rather than as planned. The plan
specifies a retrieval trace and a read-full-body tool; neither exists, so the
tool surface is described as the two-ability allowlist it actually is, and the
gaps are listed under limitations rather than omitted. The screen is likewise
documented as not truly full-screen: the body class is applied but nothing
enqueues the stylesheet that acts on it.

The safety posture is the part the security review found missing, so it is
covered in prose rather than as a checklist: what the assistant may not do, why
confirmation shows stored resolved values rather than the model's summary of
them, that the tool surface is an allowlist rather than everything registered on
the site, that retrieved content is treated as untrusted and rendered inert,
and -- newly stated anywhere -- that site content is sent to the configured
third-party provider, limited to what the requesting user could read and to the
fields the tool returned.

The test-ordering footgun goes in docs/TESTING.md rather than the experiment
page, because it is repo-wide: any e2e run after the PHP suite hits it. The
streaming-unmockable note stays on the experiment page, because it is a property
of this feature's transport.

Advances R1, R16, R17 (U10).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
… and correct the test docs

Two defects surfaced while documenting the experiment.

The search ability's model-facing description told the assistant to "use
core/read-content to read a post body". That ability is gated behind the Custom
Abilities experiment and is not on the workspace allowlist, so the assistant was
being told about a capability it will never be offered -- which is a plausible
source of hedging about content it could not reach. The description now states
plainly that the tool returns titles and excerpts and never full bodies.

The testing docs recommended `composer test` and `vendor/bin/phpunit` directly,
with no mention of the test environment. Run inside the default wp-env container
that resolves DB_NAME to the development site's own database, and the WordPress
test bootstrap reinstalls WordPress over it: plugins deactivated, posts deleted,
options cleared including provider credentials. The suite passes while doing it,
so nothing signals the loss. That guidance cost a working dev site more than once
today, and it contradicted CONTRIBUTING.md, which already documents the scoped
command. The section now names npm run test:php as the command and explains what
the direct invocation destroys.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
`ai/search-content` deliberately returns titles and excerpts only, so the
assistant could find a post but never read it. `core/read-content` exists but
is registered behind the Custom Abilities experiment, which would make the
reading path appear and disappear with an unrelated switch.

Adds `ai/read-content-bodies`: at most five posts per call, named by ID, with
every body filtered at execute time by the requesting user's own capabilities
using the same read permission walk `ai/search-content` performs. A body the
user could not otherwise read is never returned, and an unknown ID and an
unreadable one are reported identically in `unavailable`. Password-protected
bodies are withheld from anyone who cannot edit the post, with the post still
listed and flagged `content_protected`. Bodies are returned with markup
stripped: text to reason about, not markup to reproduce.

The tests were written before the class existed and failed for its absence.
After green, the permission walk was forced open and the clamp removed; five
tests failed and were the right five, so the leakage assertions are
load-bearing rather than decorative.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
…U13)

The turn response records every tool invocation, but a consumer wanting to
show what the assistant looked up had to narrow each ability's result shape
itself. Adds a normalized `retrieval` summary per invocation: the kind of
retrieval, the term searched for, how many items were returned, and how many
were withheld because the person may not read them.

The withheld count is measured, not derived. `ai/search-content` now reports
it directly, counted as the permission walk drops rows from the page it
builds. Deriving it instead as total minus returned rows would conflate two
unrelated causes: page one of fifty matches would announce forty posts hidden
by the person's role on a perfectly ordinary search, teaching people to
distrust a control that is working. Pagination contributes zero.

`ai/read-content-bodies` deliberately reports no count. It answers for IDs the
caller named, so counting unknown and unreadable apart would say whether those
exact posts exist. Null therefore means the ability reports no count, which is
not the same as zero and must not render as "none withheld".

The searched term is model-supplied and reaches the summary from the
invocation arguments, flattened and clamped like other untrusted strings and
documented as untrusted at both ends.

Mutating the count back to subtraction fails
`test_retrieval_summary_never_counts_paginated_rows_as_withheld` and
`test_pagination_alone_reports_nothing_withheld` — written after the first
withheld test proved insensitive, its totals being ones where subtraction
happens to give the right answer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown

✅ WordPress Plugin Check Report

✅ Status: Passed

📊 Report

All checks passed! No errors or warnings found.


🤖 Generated by WordPress Plugin Check Action • Learn more about Plugin Check

@codecov

codecov Bot commented Sep 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 77.84744% with 636 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.99%. Comparing base (5246098) to head (4906e0f).

Files with missing lines Patch % Lines
...AiClient/src/Providers/Http/DTO/RequestOptions.php 19.48% 62 Missing ⚠️
...Experiments/AI_Workspace/REST/Stream_Responder.php 4.91% 58 Missing ⚠️
...s/AI_Workspace/Streaming/Streaming_Turn_Driver.php 14.92% 57 Missing ⚠️
...endor/AiClient/src/Providers/Http/DTO/Response.php 40.00% 54 Missing ⚠️
...cludes/Experiments/AI_Workspace/Proposal_Store.php 70.16% 37 Missing ⚠️
...nts/AI_Workspace/Streaming/Fopen_Stream_Opener.php 56.00% 33 Missing ⚠️
...s/Vendor/AiClient/src/Results/ChunkAccumulator.php 68.57% 33 Missing ⚠️
includes/Experiments/AI_Workspace/Turn_Runner.php 87.80% 30 Missing ⚠️
includes/Abilities/Content/Read_Content_Bodies.php 88.52% 28 Missing ⚠️
includes/Abilities/Content/Search_Content.php 91.46% 28 Missing ⚠️
... and 24 more
Additional details and impacted files
@@              Coverage Diff              @@
##             develop    #1004      +/-   ##
=============================================
+ Coverage      75.55%   75.99%   +0.44%     
- Complexity      3381     4310     +929     
=============================================
  Files            138      172      +34     
  Lines          13081    15950    +2869     
=============================================
+ Hits            9883    12121    +2238     
- Misses          3198     3829     +631     
Flag Coverage Δ
unit 75.99% <77.84%> (+0.44%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

whyisjake and others added 3 commits September 4, 2026 07:55
`develop` tracks no planning or Compound Engineering artifacts, so committing
the workspace plan would have introduced 626 lines of a personal workflow
document as a new project convention. It is excluded locally instead, which
keeps it available to work from without asking every contributor to adopt it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
…rror codes

Plugin Check runs `WordPress.Security.EscapeOutput` and reported eleven errors
across the streaming classes, each phrased as an unescaped output found in
`Streaming_Exception`. The messages were never the problem: the sniff inspects
every argument of an exception constructor, and what it rejects is the integer
error code passed as the second one. It names `Streaming_Exception` only
because that is the first token of `Streaming_Exception::CODE_MALFORMED`.

The same throw without a code argument is clean, and `RuntimeException` with
one is clean, which is why the plugin's existing throws never tripped it —
they pass no code. An `int` error code cannot be output, so escaping it would
be meaningless and would misdescribe the code for the next reader. Each site
takes a narrowly scoped ignore with its own justification instead.

Two sites are not error codes and say so: the mapper's `provider_error()` call
is a factory that escapes the provider message itself, and the model's
`getStatusCode()` is an HTTP status feeding an already-escaped `%1$d`.

Comments only; no message, error code or behavior changed. The repository's own
`lint` never covered this sniff, so it could only surface in CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
The guard asserting that vendored files import core's prefixed dependencies
matched `Nyholm\` and `Psr\Http\` only. Core scopes more than PSR-7 —
`Psr\EventDispatcher\` and `Psr\SimpleCache\` are prefixed the same way — so
the pattern was narrower than the hazard it exists to prevent, and an
unprefixed `Psr\EventDispatcher\EventDispatcherInterface` import had been
sitting in `EmbeddingBuilder` since the embeddings feature was vendored.

It stayed latent because the symbol appears only as a nullable typed property
and constructor parameter, and PHP does not resolve such a type while the
value is null. Passing a real dispatcher would have raised a TypeError: the
prefixed interface does not satisfy the unprefixed name. Widening the pattern
turns the suite red on that file, so the import is corrected here.

The README's "Modifications applied" section recorded `embeddings` as having
none, which is no longer true, and it now also records the one place first-party
code is exempt from PHPStan — the three streaming bridge classes, whose
unresolvable SDK and provider-plugin symbols produce non-ignorable errors — so
the closing claim that first-party code is fully analysed is no longer read as
unqualified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
whyisjake and others added 2 commits September 4, 2026 11:40
…real (U13)

The turn response has carried a per-invocation retrieval summary since the
server half of U13, but nothing rendered it. The transcript's tool disclosure
opened with "Looked up site content (3 steps)", which counted invocations
rather than saying what was retrieved.

The trace now *is* that summary line — "Searched 20 posts · read 5 in full" —
so what the assistant retrieved is always visible while the per-invocation
detail stays one interaction away, rather than rendering the same facts twice.
Counts come from the server's summary, never from arithmetic over a result
set. A withheld count is reported only when an ability counted one: null reads
as silence, not as "none withheld", because only a counting ability can make
that claim. An ability reporting no summary falls back to naming the steps, so
an unrecognised tool still discloses that it ran.

Two defects surfaced while screenshotting the result.

`is-fullscreen-mode` was on the body with nothing to act on it: the CSS lives
inside editor bundles and `wp-interface` is not a registered handle, so the
admin menu never collapsed. Enqueuing an editor bundle to collect three rules
would import the editor's whole selector surface, so the rules are restated
against core's contract — menu hidden, content gutter reclaimed, admin bar
deliberately kept, which is what lets the app subtract only the bar's height.

The results table was also being crushed to the width of the summary text,
truncating every post title, because the disclosure sized itself to its
own summary as a flex-start child. It now spans the message column while the
summary keeps its chip shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
whyisjake and others added 4 commits September 4, 2026 13:26
…rect the API key

Two findings from the branch's code review, both in the streaming path.

The availability guard called a static method on
`Anthropic_Streaming_Text_Generation_Model`, which extends a class belonging to
the separate Anthropic provider plugin. Calling it autoloaded the subclass and
forced PHP to resolve that parent, so on a site without that plugin the guard
raised a fatal instead of returning false — and it sat above the try, outside
`stream()`'s try, with no catch further up. A site running streaming with only
OpenAI or Google configured died on the turn. The guard now probes the parent
class and the interface by name, `create_model()` runs entirely inside the
existing `catch ( Throwable )`, and every bail-out reports through
`fall_back()` so the reason is observable rather than a bare null.

The stream opener validated only the initial URL and then set
`follow_location`, so PHP replayed the provider credential to as many as five
unvalidated targets — the file's own docblock claimed parity with
`wp_safe_remote_request()`, which held for the first hop alone. Redirects are
now refused outright and surfaced as a transport error: the messages endpoint
does not redirect in normal operation, so a 3xx is a misconfiguration or an
interception attempt, and the caller already degrades to the buffered path.

Both guards are proven, not assumed. Restoring the old one-line availability
check makes the new driver test fail with the real fatal
(`Class "…AnthropicTextGenerationModel" not found`, exit 255); it runs in a
subprocess because sibling tests load the provider autoloader, which would make
any in-process check pass vacuously. Restoring `follow_location` makes the
redirect test fail by recording the credential arriving at the redirect target,
and a control test asserts the recorder works so the check cannot pass by
never running.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
…tored

A proposed draft's content and excerpt reached `wp_insert_post()` unfiltered.
Because the workspace is gated on `manage_options`, the approver normally holds
`unfiltered_html`, so WordPress's own `content_save_pre` pass never ran — and
the text is model-generated from site content a lower-privileged author can
write, then approved from behind a collapsed disclosure the person may never
expand.

Filtering happens in `Proposal_Store::normalize_item()`, not at write time.
That placement is the point: the confirmation screen renders the stored values,
so filtering at store time preserves the property that what a person approves
is what gets written. Filtering in the writer would have broken it, showing one
thing and storing another. `wp_kses_post()` is the right ruleset rather than a
stricter one — it keeps block delimiter comments intact while removing scripts
and event handlers, and it is what core would have applied to an author without
`unfiltered_html`. The excerpt gets the same treatment for the same reason.

Also covers the write-time publish recheck, which had no test: `publish_posts`
revoked between proposing and confirming a publish-status item. Deleting that
branch does not merely change a message — a published post is created by a user
who can no longer publish — so the test now holds the guard down.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
…nnot support

The trace exists so a person can tell when their own permissions kept content
back. Two paths made it claim things that were not true.

A post type dropped by the status gate never reached the per-row counting loop,
so its rows were excluded for permission reasons while `withheld` still
reported the integer 0 — which this code reserves for the positive claim that
nothing was kept back. The search ability now reports `withheld` as null in
that case. Null already means "no claim" end to end, and inventing a count for
rows that were never queried would be false precision.

Separately, "Searched N posts" rendered `returned`, the count after row-level
filtering, so it silently excluded the very rows the withheld figure was
reporting and the two could not be reconciled. The ability now reports
`examined` — the rows the page was built from before the permission walk — and
the trace renders that, so `examined - withheld == returned` holds. `examined`
is read from the ability's own payload and never derived as
`returned + withheld`, which would be wrong in exactly the case where withheld
is null.

Each guard is mutation-proven: reverting either one fails a test that names the
specific wrong number it produces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
…est end

Pressing Stop on the first turn showed "cancelled" and then flipped to a
generic error the person had not caused. `stop()` set the entry itself but
never told the in-flight request, so when the aborted promise settled the
send path rewrote the same entry to `error`. The outcome now carries an
explicit `aborted` flag rather than the code checking for a `cancelled`
status: the status check would have covered only the first-turn path, leaving
the other abort branch — where the entry is still streaming — spinning
forever. The screen-reader announcement follows the same signal, so a stop is
announced as a stop.

The proposal dialog's fetch was wrapped only around `response.json()`, so a
network rejection escaped unhandled and pinned the dialog at "loading" or
"working" with its controls disabled until reload. The request helper is now
total and distinguishes a request the server refused from one that never
reached it, because those support different claims: a failed discard leaves
the proposal stored and still approvable, which the old copy denied by always
reporting it discarded.

All three new specs were shown to fail against the unfixed client.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
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.

1 participant