Skip to content

MIO: Let response buttons write, for a choice after a conflict - #1052

Merged
epeicher merged 3 commits into
trunkfrom
docs/mio-write-response-actions-proposal
Oct 30, 2026
Merged

epeicher merged 3 commits into
trunkfrom
docs/mio-write-response-actions-proposal

Conversation

@AllTerrainDeveloper

@AllTerrainDeveloper AllTerrainDeveloper commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

Why

An app's save can fail because the user edited the document while MIO was working. For example, someone edits a form field while MIO is validating a new version of that form. Until now the turn could only end in an error, and MIO's work was lost. With this PR the app can end the turn with a question and buttons instead:

The form changed while I was working. Apply my changes over yours, or keep your edits?

[ Apply MIO's changes ] [ Keep my edits ]
Applying replaces the edits you made since MIO started.

Before this change the shell dropped any response action that wasn't read or navigate. This PR adds a narrowly scoped write action that only a person can trigger. Every change is additive and optional.

What changes

effect: 'write' on MioResponseAction (src/mio/assistant/response-actions.ts)

  • It runs only from a click whose event.isTrusted is true. A programmatic call, a synthetic event, a render pass or allowed() cannot run it.
  • A message can carry at most one write. Any later write descriptor is dropped.
  • A write is available only while its message is the newest in the conversation. Sending a new message retires it with "No longer available.", along with every other action in its choice group. Actions outside the group are unaffected.
  • The callback returns a MioOperationOutcome:
    • confirmed: the button shows "Done." and retires.
    • unknown: the button retires and says to check the result before retrying.
    • rejected: the first error is shown. When retryable is true, another click retries with the same idempotencyKey and a new callId.
  • A throw, a missing outcome or a malformed outcome counts as unknown.
  • Closing the chat aborts the signal but doesn't roll anything back. The late outcome is recorded and shown on reopen. Disposing the lease while a write is pending records it as unknown.
  • A click never starts a provider request and uses none of the turn's budget.

group: actions on one message that share a group are one choice. While one is pending the others are disabled. When one finishes (a confirmed write, or a read that resolves) the choice is final: the others disappear and the chosen one keeps its status. A rejected or unknown write, or a read that throws, leaves the group open.

description: plain-text consequence text written by the app, 1–160 characters. A message keeps only one, and any later description is dropped. It's rendered below the row while its action can still be chosen, and the button that owns it announces it as its accessible description, whatever its effect.

Operations ledger: each write is recorded as ability: 'response:<id>' with the turn ID, window ID and revision(). It fires onOperation when it starts and again when it settles, and getOperations(), inspectOperation() and operationStatus cover it. MioOperation gains source?: 'ability' | 'response-action'. The ledger always sets it. It's optional in the type so records that apps build themselves still typecheck, and a missing value means 'ability'.

Next-turn history: each settled write, and each settled choice in a group, adds one record to the next turn's outcomes, for example { source: 'response-action', label, effect, status }. There is one record per action, so a retried write reports only its latest outcome. Each record is reported once, and it goes back into the queue if that next turn fails or is stopped before the provider answers. It contains no callback, ID, URL or receipt body.

Feature detection: MioWindowLease.responseActionEffects is ['read', 'navigate', 'write']. Apps should treat a missing property as ['read', 'navigate'].

<os-button> forwards aria-description onto its native button, the same way it already forwards aria-label. This differs from the original plan. An aria-describedby on the host can't reach the shadow <button>, because an IDREF doesn't cross the shadow root (the component docs already note this). Without the forwarding, the description would not be announced.

No provider, PHP or transport changes.

Docs

  • docs/mio-window-assistant.md: new effect, group and description rows, new sections on response buttons that write, choice groups and what the model hears about a choice, and source in the ledger description.
  • docs/javascript-reference.md: the new fields, the context additions, MioOperation.source and responseActionEffects.
  • docs/api-index.md: write response actions listed as Experimental.
  • docs/examples/mio-form-editing.md: a new "Choose after a conflict" recipe next to Preview.
  • docs/components-reference.md: the aria-description forwarding.

Tests

The new file tests/vitest/mio-response-actions-write.test.ts covers each acceptance criterion:

  • A write descriptor is kept and a second one is dropped.
  • A write runs only from a trusted click.
  • A write on an older message is retired.
  • A confirmed write retires its group and records a ledger entry with the receipt.
  • A retryable rejection reuses the same idempotency key.
  • A throw, a missing outcome or a malformed outcome is recorded as unknown.
  • Closing the chat mid-write keeps the pending entry, and disposal marks it unknown.
  • A pending action disables its siblings, and a completed "Keep" retires "Apply".
  • A click makes no provider call.
  • The next turn's history gets the shell-authored record.
  • The record survives a next turn that fails or is stopped before the provider answers.
  • A new message retires the whole choice group.
  • A read or navigate action can own the description.
  • A grouped read stopped mid-way leaves the group open.
  • inspectOperation reconciles an unknown write.
  • The description is rendered and announced.

Also added: a lease test for responseActionEffects and an os-button forwarding test. One existing test listed effect: 'write' as an invalid descriptor, so it now uses an unknown effect.

npm run build, npm run lint (0 errors), npm run typecheck and npm run test:js (544 files, 6927 tests) pass locally. In headless Chromium, Enter, Space and a mouse click on a button inside a shadow root (like os-button's) all reach the host as trusted clicks, while a scripted .click() does not. So keyboard users can trigger a write and scripts can't. I haven't tried the feature by hand in a browser, or checked Safari or Firefox.

Changes after review

  • Choice records survive an early failure. If the next turn fails or is stopped before the provider answers (the prompt() hook throws, the request is over budget, or the user presses Stop), the records go back and the turn after reports them.
  • A new message retires the whole choice group, so "Keep my edits" can't answer a decision the conversation has moved past.
  • One description per message. Whichever button owns it announces it, read or navigate included, and it's cleared once that action has settled.
  • One history record per action. A write that was rejected and then confirmed reports only the confirmation.
  • MioOperation.source is optional in the type.
  • Status text matches MIO's other strings: "Could not confirm the result. Check before trying again."

The first commit on this branch added the proposal as a plan document. The second commit removes it and implements the proposal, and the third applies the review changes.

🤖 Generated with Claude Code

Open WordPress Playground Preview

AllTerrainDeveloper and others added 2 commits October 26, 2026 09:19
…onflict

Adds a plan for a narrowly scoped `effect: 'write'` response action that
only a trusted user click can run, with choice groups, app-written
consequence text, ledger entries, a next-turn history record and a
`responseActionEffects` lease property for feature detection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An app's save can fail because the user edited the document while MIO
was working. The app can now end that turn with a question and buttons
the user answers, instead of an error that loses MIO's work.

- `effect: 'write'` on MioResponseAction. It runs only from a trusted
  click, at most one per message, only on the newest assistant message,
  and returns a MioOperationOutcome. A throw or malformed outcome is
  recorded as unknown; closing the chat does not roll a write back.
- `group` makes actions on one message a single choice, and `description`
  carries app-written consequence text below the row.
- Writes go through the operations ledger as `response:<id>`, with a new
  `MioOperation.source`, so inspectOperation reconciles them.
- The next turn's history carries a shell-authored record of the choice.
- `MioWindowLease.responseActionEffects` lets apps detect support.
- `<os-button>` forwards `aria-description` onto its native button.

Replaces the proposal document added earlier on this branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@AllTerrainDeveloper AllTerrainDeveloper changed the title Docs: Propose MIO response buttons that write, for a choice after a conflict MIO: Let response buttons write, for a choice after a conflict Oct 26, 2026
@AllTerrainDeveloper

Copy link
Copy Markdown
Collaborator Author

@claude can you review this new addition to the MIO framework?

@claude

claude Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

Claude finished @AllTerrainDeveloper's task in 1m 3s —— View job


Review: MIO write response actions

I read the full src/ and CSS diff. I did not run the test suite, because the sandbox blocked the command. I'm relying on your reported results (build, lint, typecheck and test:js all passing) and haven't checked the new tests or the docs changes beyond the PR description.

The design holds together. The isTrusted gate, the single write per message, the newest-message-only rule, the stable idempotency key with a per-attempt callId, and treating a throw as unknown are all sound. Disposal handling is correct too: dispose() records unknown for in-flight calls, and write() returns early afterwards so it can't overwrite that record. I found no blocking bugs. The points below are worth deciding before merge, most important first.

Worth fixing or deciding

  1. Drained history is lost if the turn fails early. session.ts:121 calls drain() at the start of every turn. If that turn then fails before the transport call (the prompt() hook throws, the request budget is exceeded, or the user hits Stop), the record of what the user chose is gone. The next turn won't know a write was applied or left unknown, which is the case this feature exists for. Consider draining only once the request is built or sent, or putting the records back in the failure path.

  2. A retired Apply sits next to a live Keep. retireWrites() (response-actions.ts) only retires write entries. After a new user message, "Apply MIO's changes" shows "No longer available." while its group sibling "Keep my edits" (a read or navigate) stays clickable. If Keep is clicked later, it records a completed choice for a decision that has already moved on. Either retire the whole group, or make the sibling's allowed follow the latest-message rule. Docs and tests should state whichever you pick.

  3. description is shown for any effect, but only a write button announces it. response-action-row.ts renders views.find( v => v.description ) for read and navigate actions too, yet aria-description is set only when view.effect === 'write'. The doc says the description is announced for write. Either restrict description to write in validation, or announce it for all effects. Also, only the first description is rendered, so a second action's description is silently invisible. Say so in the docs, or reject a second description.

  4. MioOperation.source is a required field (types.ts). Any app that builds MioOperation fixtures, or implements a typed onOperation consumer that constructs one, will fail typecheck. Runtime is fine, since the ledger fills it in. If the intent is additive and optional, make it source?: and document the 'ability' default. If required is deliberate, mention it in the docs.

Smaller notes

  • Rejected-then-retried writes record twice. remember( entry, 'rejected' ) runs on every rejection, including retryable ones. A retry that then confirms yields two records for one choice ("each settled write" fits, "each settled choice" doesn't). Consider recording only when the entry stops being retryable.
  • A read that resolves in a group is retired even when run was aborted mid-way. The abort path in run() is unchanged, so this is probably fine, but there's no test for a read action aborted by Stop inside a group.
  • Description is not reset when the owner disappears mid-render. A confirmed Apply keeps its "replaces your edits" text under the row as past-tense noise. It's harmless, but consider clearing it once the owning action is retired.
  • aria-description support. It's ARIA 1.3 draft and works in current Chromium, Firefox and Safari, but older assistive tech may ignore it. Consider also keeping the consequence in the visible <p> (already done) and linking it with aria-describedby inside the same shadow root if os-button ever allows it.
  • __( "Couldn't confirm…" ) uses a typographic-neutral apostrophe. Match the surrounding strings, so translators see consistent source text.
  • CSS. .os-mio-chat__action-description needs flex-basis: 100% to wrap, and .os-mio-chat__actions is flex-wrap: wrap, so that works. It uses a literal fallback with a --os-mio-chat-muted token, matching the neighbouring rules, which is correct per the palette rules.

Rulebook checks

  • No raw fetch, confirm, or version annotations in the diff.
  • The os-button change forwards aria-description through observedAttributes and the template, and has a test for forwarding and removal.
  • Docs were updated in the same PR (mio-window-assistant, javascript-reference, api-index, components-reference and the example), as the guide requires.
  • AGENTS.md says to add tests only when they catch a real bug. The new test file is on-topic, and one file for the feature is the right shape.

Not tested in a browser (as your description says). I'd check two things by hand: a keyboard Enter/Space press on the Apply button, to confirm it counts as trusted, and the retired state after a new message.

- Keep the record of a user's choice when the next turn fails or is
  stopped before the provider answers, so a later turn still reports it.
- A new user message retires every action in a write's choice group,
  not only the write, so "Keep" can't answer a decision that has passed.
- One description per message, announced by whichever button owns it,
  and withdrawn once that action has settled.
- One history record per action: a retried write reports its latest
  outcome only.
- `MioOperation.source` is optional in the type, so app-built records
  stay valid; the ledger always sets it.
- Match MIO's "Could not …" wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@epeicher epeicher left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I have reviewed the changes and they LGTM!

@epeicher
epeicher merged commit 2d22586 into trunk Oct 30, 2026
7 checks passed
@epeicher
epeicher deleted the docs/mio-write-response-actions-proposal branch October 30, 2026 11:44
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