<%= styled_text_field_tag input_name, action.values, container_class: "-slim" %>
+ <% elsif action.type == :text_property %>
+
+ <%= styled_text_area_tag input_name, action.values.first, rows: 5 %>
+
<% elsif action.type == :link_property %>
<%= styled_url_field_tag input_name, action.values, container_class: "-slim" %>
diff --git a/app/views/hooks/ai_assistance/_header_button.html.erb b/app/views/hooks/ai_assistance/_header_button.html.erb
new file mode 100644
index 000000000000..2ed3d1143ad1
--- /dev/null
+++ b/app/views/hooks/ai_assistance/_header_button.html.erb
@@ -0,0 +1,32 @@
+<%#
+ -- 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, write to the Free Software
+ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+ See COPYRIGHT and LICENSE files for more details.
+
+ ++#
+%>
+
+<%= render(AI::Assistance::HeaderButtonComponent.new) %>
diff --git a/app/workers/ai/assistant_chats/reply_job.rb b/app/workers/ai/assistant_chats/reply_job.rb
new file mode 100644
index 000000000000..9ec6e9f162d3
--- /dev/null
+++ b/app/workers/ai/assistant_chats/reply_job.rb
@@ -0,0 +1,51 @@
+# 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, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+module AI
+ module AssistantChats
+ class ReplyJob < ApplicationJob
+ include GoodJob::ActiveJobExtensions::Concurrency
+
+ queue_with_priority :above_normal
+
+ # One job for each answer. A run after a crashed worker still runs.
+ good_job_control_concurrency_with(total_limit: 1, key: -> { "ai_assistant_reply_#{arguments.first}" })
+
+ def perform(message_id)
+ message = AI::AssistantChatMessage.find_by(id: message_id)
+ return unless message&.pending?
+
+ User.execute_as(message.chat.user) do
+ AI::AssistantChats::Reply.new(message).call
+ end
+ end
+ end
+ end
+end
diff --git a/config/initializers/ai_assistance.rb b/config/initializers/ai_assistance.rb
new file mode 100644
index 000000000000..20f2da15fa70
--- /dev/null
+++ b/config/initializers/ai_assistance.rb
@@ -0,0 +1,34 @@
+# 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, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+# Loads the AI Assistance view hooks, as a module engine does in its own to_prepare block.
+Rails.application.config.to_prepare do
+ OpenProject::Hooks::AIAssistanceHeaderButton
+end
diff --git a/config/initializers/feature_decisions.rb b/config/initializers/feature_decisions.rb
index 23ddf0d380ee..5f6e596e20ea 100644
--- a/config/initializers/feature_decisions.rb
+++ b/config/initializers/feature_decisions.rb
@@ -68,6 +68,10 @@
description: "Enables project settings for the unit of velocity, effort and capacity. " \
"See AGILE-198 for details."
+OpenProject::FeatureDecisions.add :ai_assistance,
+ description: "Enables the AI Assistance menu on work packages, " \
+ "with skills such as Refresh work package."
+
OpenProject::FeatureDecisions.add :llm_connection,
description: "Enables the administration page connecting OpenProject to an " \
"OpenAI-API-compatible LLM server, and the AI features built on it."
diff --git a/config/initializers/llm_features.rb b/config/initializers/llm_features.rb
index 760d8335b24f..7c3d2e5a8eab 100644
--- a/config/initializers/llm_features.rb
+++ b/config/initializers/llm_features.rb
@@ -55,3 +55,11 @@
kind: :embedding,
requires: %i[embeddings],
available: -> { OpenProject::FeatureDecisions.semantic_search_active? }
+
+# AI Assistance skills answer with structured proposals. An assistant may choose its
+# own model, which is why it is overridable.
+OpenProject::Llm::Features.register :ai_assistance,
+ kind: :chat,
+ prefers: %i[structured_output],
+ overridable: true,
+ available: -> { OpenProject::FeatureDecisions.ai_assistance_active? }
diff --git a/config/initializers/mcp.rb b/config/initializers/mcp.rb
index e2a084cbc5aa..d3c55e7f673c 100644
--- a/config/initializers/mcp.rb
+++ b/config/initializers/mcp.rb
@@ -45,7 +45,8 @@
end
Rails.application.config.to_prepare do
- McpTools.register McpTools::CreateWorkPackage,
+ McpTools.register McpTools::CountWorkPackages,
+ McpTools::CreateWorkPackage,
McpTools::CreateWorkPackageComment,
McpTools::CreateWorkPackageRelation,
McpTools::CurrentUser,
diff --git a/config/initializers/menus.rb b/config/initializers/menus.rb
index fa181a2ac9b7..6ae00a967280 100644
--- a/config/initializers/menus.rb
+++ b/config/initializers/menus.rb
@@ -357,6 +357,14 @@
{ controller: "/my/sessions", action: :index },
caption: :"users.sessions.title",
icon: "devices"
+ menu.push :ai_assistants,
+ { controller: "/my/ai_assistants", action: "index" },
+ caption: :"my_account.ai_assistants.title",
+ icon: "sparkle",
+ if: ->(*) {
+ OpenProject::FeatureDecisions.ai_assistance_active? &&
+ User.current.allowed_globally?(:manage_own_ai_assistants)
+ }
menu.push :notifications,
{ controller: "/my", action: "notifications" },
caption: I18n.t("my_account.notifications_and_email.title"),
@@ -622,6 +630,12 @@
enterprise_feature: "mcp_server",
parent: :ai
+ menu.push :ai_assistants,
+ { controller: "/admin/ai_assistants", action: :index },
+ if: ->(_) { User.current.admin? && OpenProject::FeatureDecisions.ai_assistance_active? },
+ caption: :"menus.admin.ai_assistants",
+ parent: :ai
+
menu.push :text_transform_actions,
{ controller: "/admin/text_transform_actions", action: :index },
if: ->(_) { User.current.admin? && OpenProject::FeatureDecisions.ai_text_transform_actions_active? },
diff --git a/config/initializers/permissions.rb b/config/initializers/permissions.rb
index 0edb1aafad62..8e5c2ac9744f 100644
--- a/config/initializers/permissions.rb
+++ b/config/initializers/permissions.rb
@@ -50,6 +50,15 @@
require: :loggedin,
contract_actions: { programs: %i[create] }
+ map.permission :manage_own_ai_assistants,
+ {
+ "my/ai_assistants": %i[index new create edit update destroy copy],
+ "my/ai_assistant_skills": %i[new create edit update destroy copy]
+ },
+ permissible_on: :global,
+ require: :loggedin,
+ visible: -> { OpenProject::FeatureDecisions.ai_assistance_active? }
+
map.permission :archive_project,
{
"projects/archive": %i[create dialog]
@@ -368,6 +377,14 @@
dependencies: :view_work_packages,
contract_actions: { work_packages: %i[update] }
+ wpt.permission :use_ai_assistance,
+ {
+ "ai_assistance/menus": %i[show],
+ "ai_assistance/chats": %i[show create start rerun send_message poll history apply dismiss]
+ },
+ permissible_on: %i[work_package project],
+ dependencies: :view_work_packages
+
wpt.permission :move_work_packages,
{ "work_packages/moves": %i[new create] },
permissible_on: :project,
diff --git a/config/initializers/zeitwerk.rb b/config/initializers/zeitwerk.rb
index ef7161e794d2..abceb05e88ae 100644
--- a/config/initializers/zeitwerk.rb
+++ b/config/initializers/zeitwerk.rb
@@ -88,6 +88,10 @@
OpenProject::Inflector.inflection(
"ai" => "AI",
+ "ai_assistance" => "AIAssistance",
+ "ai_assistants_controller" => "AIAssistantsController",
+ "ai_assistant_skills_controller" => "AIAssistantSkillsController",
+ "ai_assistance_header_button" => "AIAssistanceHeaderButton",
"rss" => "RSS",
"sha1" => "SHA1",
"sso" => "SSO",
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 02502393b6e6..b8f271b1730b 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -92,6 +92,24 @@ en:
activerecord:
attributes:
+ ai/assistant:
+ active: "Active"
+ description: "Description"
+ instructions: "Assistant prompt"
+ model: "Model"
+ model_id: "Model"
+ name: "Name"
+ ai/assistant_skill:
+ active: "Active"
+ answers_at_once: "Answer at once when opened"
+ description: "Description"
+ icon: "Icon"
+ label: "Label"
+ offered_on: "Offered on"
+ prompt: "Skill prompt"
+ proposal_scope: "Proposals"
+ skill_key: "Type"
+ use_tools: "Use OpenProject MCP tools"
ai/text_transform_action:
active: "Active"
injects_type_template: "Insert work package type template"
@@ -1526,6 +1544,93 @@ en:
removed_date: "date deleted %{date}"
admin:
+ ai_assistants:
+ copied: "The copy is inactive. Switch it on when it is ready."
+ copy_name: "%{name} (copy)"
+ copy_name_numbered: "%{name} (copy %{number})"
+ delete:
+ confirm: "Delete this assistant, its skills and every run of them?"
+ edit:
+ group_banner: "This assistant belongs to the group %{group}. It shows only in the AI Assistance menu of the group's members."
+ personal_banner: "This is a personal assistant of %{user}. It shows only in their AI Assistance menu."
+ form:
+ instructions_caption: "Shared by every skill of this assistant, before the skill prompt: for example who the assistant helps and what their work is about."
+ model_caption: "Empty uses the default chat model of the LLM connection."
+ model_default: "Default of the LLM connection"
+ name_caption: "Shown as the heading of its skills in the AI Assistance menu."
+ owner: "Owner"
+ owner_caption: "Global shows the assistant to everyone with AI Assistance. A group shows it only to the group's members. The owner cannot be changed later."
+ sections:
+ general: "General"
+ instructions: "Prompt"
+ inactive: "Inactive"
+ index:
+ add: "Assistant"
+ description: "Assistants group the skills of the AI Assistance menu. Each run uses the permissions of the user who starts it."
+ empty: "There are no assistants yet."
+ actions: "Actions for %{assistant}"
+ owner_kinds:
+ group: "Group"
+ user: "User"
+ owners:
+ global: "Global"
+ groups: "Groups"
+ personal: "Personal"
+ new:
+ title: "New assistant"
+ skills:
+ add: "Add skill"
+ actions: "Actions for %{skill}"
+ copied: "The copy is inactive. Switch it on when it is ready."
+ answers_at_once_caption: "Opening the skill starts a new chat, and the skill answers its instructions at once. Off opens an empty chat for the user's own questions."
+ conditions:
+ all: "All"
+ caption: "The skill is offered only on work packages that match every list. An empty list matches all."
+ count:
+ project_ids:
+ one: "1 project"
+ other: "%{count} projects"
+ role_ids:
+ one: "1 role"
+ other: "%{count} roles"
+ status_ids:
+ one: "1 status"
+ other: "%{count} statuses"
+ type_ids:
+ one: "1 type"
+ other: "%{count} types"
+ project_ids: "Projects"
+ role_ids: "Roles of the user in the project"
+ status_ids: "Statuses"
+ type_ids: "Types"
+ delete_confirm: "Delete this skill and every run of it?"
+ description_caption: "Shown under the label in the AI Assistance menu. Empty uses the built-in text, shown here as a placeholder."
+ empty: "This assistant has no skills."
+ icon_default: "Default"
+ offered_on:
+ everywhere: "Everywhere"
+ everywhere_caption: "On a work package as above. On other pages without proposals, with the project of the page as context. It cannot be limited to types or statuses."
+ work_packages: "Work packages"
+ work_packages_caption: "Only on work packages."
+ label_caption: "The name of the action in the AI Assistance menu. Empty uses the built-in name, shown here as a placeholder."
+ no_prompt_not_offered: "No skill prompt: not offered"
+ prompt_caption: "What the model does in this skill. Without it, the skill is not offered."
+ prompt_preview: "Show the full system prompt"
+ prompt_preview_caption: "The assistant prompt, this skill prompt, and the output rules that OpenProject adds for the skill type, its proposals and the MCP tools. It changes while you type, and the tool rule follows the checkbox above."
+ proposal_scopes:
+ changes: "Changes"
+ changes_caption: "Proposes changes to attributes and comments. The model also gets the names of the users who can be assigned."
+ comment_only: "Comment only"
+ comment_only_caption: "Proposes comments only."
+ read_only: "Read-only"
+ read_only_caption: "Proposes nothing."
+ sections:
+ conditions: "Where it is offered"
+ instructions: "Prompt"
+ menu: "Menu entry"
+ menu_description: "How the skill shows in the AI Assistance menu, and where."
+ title: "Skills"
+ use_tools_caption: "The AI can search OpenProject for more data than the work package, for example other work packages, counts, users or versions. It sees only what the user can see, and the tools cannot change anything. Off: the AI sees only the work package."
banners:
environment_configured_readonly: "These values are configured via environment variables and cannot be edited here."
custom_fields:
@@ -2436,6 +2541,107 @@ en:
stopped_title: "Generation stopped"
title: "AI result"
try_again: "Try again"
+ ai_assistance:
+ apply:
+ button: "Apply changes"
+ empty: "(empty)"
+ errors:
+ conflict: "The work package changed after this proposal was made. Ask again to get a current proposal."
+ invalid: "The changes could not be applied."
+ lost_content: "This proposal removes images, mentions or macros, so it cannot be applied."
+ not_allowed: "You cannot apply these changes."
+ not_open: "This proposal was already applied or dismissed."
+ label:
+ assigned_to: "Proposed assignee"
+ comment: "Proposed comment"
+ description: "Proposed description"
+ due_date: "Proposed finish date"
+ priority: "Proposed priority"
+ responsible: "Proposed accountable"
+ start_date: "Proposed start date"
+ status: "Proposed status"
+ subject: "Proposed subject"
+ lost_content: "This proposal removes images, mentions or macros from the description. Edit the description yourself."
+ success: "The changes were applied."
+ areas:
+ content: "Content"
+ pull_requests: "Pull requests"
+ relations: "Relations"
+ status: "Status"
+ tree: "Hierarchy"
+ button: "AI Assistance"
+ chat:
+ answering: "%{assistant} is writing an answer…"
+ tool_step: "%{tool} (%{arguments})"
+ copy: "Copy the answer"
+ empty_answer: "(The model gave no answer.)"
+ empty_description: "Ask about this work package. The answer can propose changes, and you decide whether to apply them."
+ empty_description_read_only: "Ask about this work package."
+ empty_description_generic: "Ask about anything you can see in OpenProject, for example work packages, projects or people."
+ empty_description_project: "Ask about the project %{project}, or about anything else you can see in OpenProject."
+ empty_title: "Chat about this work package"
+ empty_title_generic: "Ask OpenProject"
+ errors:
+ empty: "Write a question first."
+ pending: "Wait for the answer to the last question."
+ failed: "The answer failed. Open Details for more, or ask again."
+ new: "New chat"
+ not_offered: "This skill is not offered for this work package any more. You can read the chat, but not continue it."
+ placeholder: "Ask something about %{id}…"
+ placeholder_generic: "Ask something…"
+ question: "Question"
+ rerun: "Run again"
+ send: "Send"
+ start_request: "Apply your instructions to this work package, as it is now."
+ started: "%{user} started %{skill}"
+ working_on: "%{assistant} is working on %{id}…"
+ events:
+ answer: "The model answered in %{seconds} s."
+ answer_with_tools: "The model answered in %{seconds} s, after %{count} tool calls."
+ arguments: "arguments"
+ dropped: "dropped proposals"
+ error: "error message"
+ failure: "The model call failed (%{error})."
+ late: "The model answered after the time limit. The answer was not kept."
+ message: "message"
+ raw_answer: "answer"
+ result: "result"
+ request: "Asked the model %{model}: prompt %{system} characters, message %{message} characters."
+ show: "Show %{what}"
+ stored: "Stored %{stored} of %{findings} proposals; %{dropped} dropped because they had no evidence or no valid change."
+ system_prompt: "prompt"
+ thought: "The model explained its next tool calls."
+ thought_text: "text"
+ title: "Details"
+ tool_call: "Called the tool %{name}: %{seconds} s, %{size} characters."
+ tool_call_failed: "The tool %{name} failed (%{error})."
+ tool_call_refused: "Refused the tool %{name}: the limit of tool calls is reached."
+ tool_call_repeated: "Called the tool %{name} again with the same arguments. Sent the earlier result."
+ history:
+ empty: "You did not use AI Assistance on this work package yet."
+ answers:
+ one: "1 answer"
+ other: "%{count} answers"
+ title: "History"
+ proposal:
+ applied: "Applied"
+ dismiss: "Dismiss"
+ dismiss_reasons:
+ already_known: "Dismiss: already known"
+ not_relevant: "Dismiss: not relevant"
+ wrong: "Dismiss: wrong"
+ dismissed: "Dismissed"
+ severities:
+ high: "High"
+ low: "Low"
+ medium: "Medium"
+ skills:
+ chat:
+ description: "Ask questions. Only you see the chat."
+ label: "Chat"
+ preset:
+ description: "Runs its instructions on this work package. You can ask follow-up questions."
+ label: "Custom prompt"
announcements:
content: "Content"
is_active: currently displayed
@@ -3924,6 +4130,9 @@ en:
journals:
cause_descriptions:
+ assistant_chat: proposed in the chat and applied by %{user_name}
+ assistant_skill: proposed by %{assistant_name} and applied by %{user_name}
+ assistant_chat_link: "Open the chat"
budget_deleted: Budget has been deleted
import:
changed_with_diff: "%{field} changed (%{link})"
@@ -3984,6 +4193,8 @@ en:
non_working: "%{day} is now non-working"
working: "%{day} is now working"
caused_changes:
+ assistant_chat: "AI Assistance:"
+ assistant_skill: "AI Assistance:"
budget_deleted: "Budget has been deleted"
dates_changed: "Dates changed"
default_attribute_written: "Read-only attributes written"
@@ -4917,6 +5128,9 @@ en:
registry: "the figure published for this model"
server: "reported by the server"
features:
+ ai_assistance:
+ caption: "Answers the skills of AI assistants on work packages, unless an assistant sets its own model."
+ label: "AI Assistance"
description_assistant:
caption: "Rewrites and restructures work package text on request."
label: "Description assistant"
@@ -5242,6 +5456,7 @@ en:
admin:
aggregation: "Aggregation"
ai: "Artificial Intelligence (AI)"
+ ai_assistants: "Assistants"
api_and_webhooks: "API and webhooks"
llm_connection: "LLM settings"
mail_notification: "Email notifications"
@@ -5367,6 +5582,10 @@ en:
table_title: "RSS tokens"
text_hint: "RSS tokens allow users to keep up with the latest changes in this OpenProject instance via an external RSS reader."
title: "RSS"
+ ai_assistants:
+ description: "Your own assistants. They show only in your AI Assistance menu on work packages, and they use the default model of the LLM connection."
+ empty_description: "Add an assistant, then add skills with your own instructions."
+ title: "AI assistants"
email_reminders:
daily_reminders:
add_time: "Add time"
@@ -5890,6 +6109,8 @@ en:
Caution: Users with this permission can assign any project role to any member, including roles with permissions that they themselves don't have.
This includes changing their own roles, allowing users to give themselves more permissions on the project.
permission_manage_news: "Manage news"
+ permission_manage_own_ai_assistants: "Manage own AI assistants"
+ permission_manage_own_ai_assistants_explanation: "Create personal AI assistants in My account. They show only in the user's AI Assistance menu, and they use the default model of the LLM connection."
permission_manage_own_reminders: "Create own reminders"
permission_manage_own_working_times: "Manage own working times"
permission_manage_own_working_times_explanation: >
@@ -5920,6 +6141,7 @@ en:
permission_select_project_phases: "Select project phases"
permission_select_project_phases_explanation: "Activate/Deactivate the phases in a project. Enables the user to select the life cycle appropriate for the project as inactive phases will not be visible in the project overview page nor the project list."
permission_share_work_packages: "Share work packages"
+ permission_use_ai_assistance: "Use AI assistance"
permission_view_all_principals: "View all users and groups"
permission_view_all_principals_explanation: >
Allows users to see all users and groups in the system, even if they are not members of any joined projects or groups.
diff --git a/config/routes.rb b/config/routes.rb
index 9866fbff58d3..3c56c6ee1ad0 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -1177,6 +1177,13 @@
end
end
+ resources :ai_assistants, except: :show do
+ post :copy, on: :member
+ resources :skills, controller: "ai_assistant_skills", except: %i[index show] do
+ post :copy, on: :member
+ end
+ end
+
resources :text_transform_actions, except: :show do
member do
get :deletion_dialog
@@ -1253,6 +1260,19 @@
end
end
+ # AI Assistance in the app header. The place (work_package_id or project_id) is a query
+ # parameter, because the menu is the same on every page.
+ resource :ai_assistance_menu, path: "ai_assistance/menu", controller: "ai_assistance/menus", only: :show
+ resource :ai_assistance_chat, path: "ai_assistance/chat", controller: "ai_assistance/chats", only: %i[show create] do
+ post :messages, action: :send_message
+ post :start
+ post :rerun
+ get :poll
+ get :history
+ post "proposals/:proposal_id/apply", action: :apply, as: :apply_proposal
+ post "proposals/:proposal_id/dismiss", action: :dismiss, as: :dismiss_proposal
+ end
+
resources :work_packages, only: %i[index show new] do
concerns :shareable
@@ -1468,6 +1488,13 @@
end
namespace :my do
+ resources :ai_assistants, except: :show do
+ post :copy, on: :member
+ resources :skills, controller: "ai_assistant_skills", except: %i[index show] do
+ post :copy, on: :member
+ end
+ end
+
resources :access_tokens, only: %i[index] do
collection do
get :dialog
diff --git a/db/migrate/20261012120001_create_ai_assistants.rb b/db/migrate/20261012120001_create_ai_assistants.rb
new file mode 100644
index 000000000000..7aadf9cd58a8
--- /dev/null
+++ b/db/migrate/20261012120001_create_ai_assistants.rb
@@ -0,0 +1,132 @@
+# 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, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+class CreateAIAssistants < ActiveRecord::Migration[8.1]
+ def change
+ create_assistants
+ create_skills
+ create_chats
+ create_messages
+ create_proposals
+ end
+
+ private
+
+ def create_assistants
+ create_table :ai_assistants do |t|
+ # None for a global assistant, else the user or group that owns it.
+ t.references :principal, foreign_key: { to_table: :users, on_delete: :cascade }, index: false
+ t.string :name, null: false
+ t.text :description
+ t.text :instructions
+ t.string :icon
+ # Not a foreign key: syncing the models of an LLM connection replaces the llm_models rows.
+ t.string :model_id
+ t.integer :position
+ t.boolean :active, null: false, default: true
+
+ t.timestamps
+
+ t.index :name, unique: true, where: "principal_id IS NULL", name: "index_ai_assistants_on_global_name"
+ t.index %i[principal_id name], unique: true, where: "principal_id IS NOT NULL"
+ end
+ end
+
+ def create_skills
+ create_table :ai_assistant_skills do |t|
+ t.references :assistant, null: false, foreign_key: { to_table: :ai_assistants, on_delete: :cascade }, index: false
+ t.string :skill_key, null: false
+ # Shown in the menu; empty uses the text of the skill type.
+ t.string :label
+ t.text :description
+ t.text :prompt
+ # changes, comment_only or read_only: what the skill may propose.
+ t.string :proposal_scope, null: false, default: "changes"
+ t.jsonb :conditions, null: false, default: {}
+ t.jsonb :settings, null: false, default: {}
+ t.integer :position
+ t.boolean :active, null: false, default: true
+
+ t.timestamps
+
+ # Not unique: one assistant can have several variants of a skill type.
+ t.index %i[assistant_id skill_key]
+ end
+ end
+
+ def create_chats
+ create_table :ai_assistant_chats do |t|
+ t.references :skill, null: false, foreign_key: { to_table: :ai_assistant_skills, on_delete: :cascade }
+ t.references :user, null: false, foreign_key: { on_delete: :cascade }, index: false
+ t.references :work_package, null: false, foreign_key: { on_delete: :cascade }
+
+ t.timestamps
+
+ t.index %i[user_id work_package_id created_at]
+ end
+ end
+
+ def create_messages
+ create_table :ai_assistant_chat_messages do |t|
+ t.references :chat, null: false, foreign_key: { to_table: :ai_assistant_chats, on_delete: :cascade }, index: false
+ t.string :role, null: false
+ t.text :content, null: false, default: ""
+ # pending while the reply job runs; done or failed after it.
+ t.string :status, null: false, default: "done"
+ t.string :locale
+ t.integer :base_lock_version
+ t.jsonb :events, null: false, default: []
+
+ t.timestamps
+
+ t.index %i[chat_id id]
+ end
+ end
+
+ def create_proposals
+ create_table :ai_assistant_proposals do |t|
+ t.references :message, null: false, index: false,
+ foreign_key: { to_table: :ai_assistant_chat_messages, on_delete: :cascade }
+ t.integer :position, null: false, default: 0
+ t.string :severity
+ t.string :area
+ t.text :text
+ t.jsonb :evidence, null: false, default: []
+ t.jsonb :change_set
+ t.integer :base_lock_version
+ t.string :state, null: false, default: "open"
+ t.string :dismiss_reason
+
+ t.timestamps
+
+ t.index %i[message_id position]
+ end
+ end
+end
diff --git a/db/migrate/20261015100000_add_use_tools_to_ai_assistant_skills.rb b/db/migrate/20261015100000_add_use_tools_to_ai_assistant_skills.rb
new file mode 100644
index 000000000000..2c504c305043
--- /dev/null
+++ b/db/migrate/20261015100000_add_use_tools_to_ai_assistant_skills.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, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+# Skills saved before "use_tools" existed read it as nil, which means off. The default
+# of store_attribute applies only to new records.
+class AddUseToolsToAIAssistantSkills < ActiveRecord::Migration[8.1]
+ def up
+ execute <<~SQL.squish
+ UPDATE ai_assistant_skills
+ SET settings = settings || '{"use_tools": true}'::jsonb
+ WHERE NOT settings ? 'use_tools'
+ SQL
+ end
+
+ def down
+ execute "UPDATE ai_assistant_skills SET settings = settings - 'use_tools'"
+ end
+end
diff --git a/db/migrate/20261015120000_remove_icon_from_ai_assistants.rb b/db/migrate/20261015120000_remove_icon_from_ai_assistants.rb
new file mode 100644
index 000000000000..29df3c7861fd
--- /dev/null
+++ b/db/migrate/20261015120000_remove_icon_from_ai_assistants.rb
@@ -0,0 +1,36 @@
+# 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, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+# Skills have their own icon now.
+class RemoveIconFromAIAssistants < ActiveRecord::Migration[8.1]
+ def change
+ remove_column :ai_assistants, :icon, :string
+ end
+end
diff --git a/db/migrate/20261015130000_add_one_pending_answer_per_ai_chat.rb b/db/migrate/20261015130000_add_one_pending_answer_per_ai_chat.rb
new file mode 100644
index 000000000000..1aba774dec53
--- /dev/null
+++ b/db/migrate/20261015130000_add_one_pending_answer_per_ai_chat.rb
@@ -0,0 +1,47 @@
+# 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, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+# A chat has at most one answer in progress, also for two requests at the same moment.
+class AddOnePendingAnswerPerAIChat < ActiveRecord::Migration[8.1]
+ def up
+ execute <<~SQL.squish
+ UPDATE ai_assistant_chat_messages SET status = 'failed', updated_at = now()
+ WHERE status = 'pending'
+ AND id NOT IN (SELECT MAX(id) FROM ai_assistant_chat_messages WHERE status = 'pending' GROUP BY chat_id)
+ SQL
+
+ add_index :ai_assistant_chat_messages, :chat_id, unique: true, where: "status = 'pending'",
+ name: "index_ai_assistant_chat_messages_one_pending"
+ end
+
+ def down
+ remove_index :ai_assistant_chat_messages, name: "index_ai_assistant_chat_messages_one_pending"
+ end
+end
diff --git a/db/migrate/20261016100000_add_project_to_ai_assistant_chats.rb b/db/migrate/20261016100000_add_project_to_ai_assistant_chats.rb
new file mode 100644
index 000000000000..4fbc8c6cacdc
--- /dev/null
+++ b/db/migrate/20261016100000_add_project_to_ai_assistant_chats.rb
@@ -0,0 +1,47 @@
+# 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, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+# A chat can start outside a work package: in a project, or on a page without one.
+class AddProjectToAIAssistantChats < ActiveRecord::Migration[8.1]
+ def change
+ change_column_null :ai_assistant_chats, :work_package_id, true
+ add_reference :ai_assistant_chats, :project, foreign_key: { on_delete: :cascade }, index: false
+ add_index :ai_assistant_chats, %i[user_id project_id created_at]
+
+ reversible do |direction|
+ direction.up do
+ execute <<~SQL.squish
+ UPDATE ai_assistant_chats SET project_id = work_packages.project_id
+ FROM work_packages WHERE work_packages.id = ai_assistant_chats.work_package_id
+ SQL
+ end
+ end
+ end
+end
diff --git a/docs/development/concepts/ai-assistance/2026-10-14-agents.md b/docs/development/concepts/ai-assistance/2026-10-14-agents.md
new file mode 100644
index 000000000000..c7436f4bc77f
--- /dev/null
+++ b/docs/development/concepts/ai-assistance/2026-10-14-agents.md
@@ -0,0 +1,254 @@
+---
+sidebar_navigation:
+ title: Agent integration
+description: How OpenProject starts external AI agents from the AI Assistance menu, how they read and write as the user, and how they run contained
+keywords: AI, assistants, agents, opilot, MCP, OAuth, delegation, breakdown, coding agent
+---
+
+# Agent integration: OpenProject work
+
+This document describes how OpenProject starts an external AI agent from the AI Assistance menu. The agent reads the work package, can ask questions, proposes changes, and, after the user confirms, writes them through API v3 as the user. The first agent is opilot, but the contract is the same for every agent.
+
+The concept README explains which work goes to an agent and which stays in core (see "Core skills and agent skills" in the [concept](./README.md)). In short: a skill that creates or links several objects, uses tools in a loop, runs for minutes, or reaches systems outside OpenProject is an **agent skill**. A skill that changes fields of one work package with one LLM call is a **core skill**. The user sees no difference.
+
+The first agent skill is **Break down into tasks**: the agent proposes child work packages, and the user creates them with one click. The coding agent (plan, options, questions, pull request) is the second one.
+
+## Decisions
+
+- **The agent acts as the user, and the history names the agent.** OpenProject mints tokens for the user who started the session, issued to the agent connection's OAuth application. The agent can do only what the user can do. Each change shows the user and the agent: "Jane Doe, via opilot". This is the meaning of the `act` claim of RFC 8693 (OAuth Token Exchange), without the exchange grant.
+- **Reading and writing use separate tokens.** At the start, the agent gets a read token. It gets a write token only after the user confirms a proposal. The write token has a narrow scope, `agent_write`, and lives about 15 minutes.
+- **The user confirms each batch.** The agent sends its plan as a proposal of typed operations, for example "create 5 child work packages". OpenProject renders the card and checks the user's permissions. One click on "Create" confirms the whole batch.
+- **The write token is not bound to the confirmed operations.** Within the allowed endpoints and the user's permissions, the agent could do more than the user confirmed until the token expires. This is an accepted risk with mitigations (see "The agent_write scope").
+- **Core sends the context.** The start request carries the same work package context as the core skills. The agent can read more through the MCP server when it is available, but it does not need it.
+- **The agent is an optional add-on.** Core skills need only an LLM connection. Agent skills appear when an admin adds an agent connection.
+- **The agent runs contained.** In the same Docker Compose or Kubernetes stack, it has no route to the database, the cache or the secrets of OpenProject (see "Deployment and containment").
+
+### Prior art
+
+- **Linear** installs an agent with OAuth and `actor=app`. The agent is an app user with its own name and icon. Assigning an issue to it sets it as the **delegate**; the human keeps ownership. An Agent Session has the states pending, active, awaitingInput, complete, error and stale, and the agent sends activities: thought, action, elicitation, response and error. The developer hosts the agent and receives webhooks.
+- **GitHub Copilot coding agent** commits as its own bot, with the requesting person as co-author and a link to the session logs. Its token can push only to `copilot/*` branches, and a person must approve its workflows and its pull request.
+- **Atlassian Rovo** records a change that a user confirms in the chat as the user's change. Users ask for an agent identity that shows "on behalf of" the user (AUTO-2432).
+- **RFC 8693** keeps the user in `sub` and the agent in `act`, so a service can log both.
+
+We follow Linear's session and activity model, GitHub's guardrails (narrow write access, a person confirms), and RFC 8693's attribution.
+
+## Flow: Break down into tasks
+
+```mermaid
+sequenceDiagram
+ participant U as User
+ participant OP as OpenProject
+ participant A as Agent
+ U->>OP: AI Assistance → Break down into tasks
+ OP->>OP: create the session, mint the read token (mcp)
+ OP->>A: POST {base_url}/runs (signed): context, read token, callback
+ A-->>OP: callback: thought, then proposal (create 5 children)
+ OP-->>U: proposal card: 5 tasks, "Create 5 work packages"
+ U->>OP: optional: "split task 2" (message)
+ OP->>A: POST {base_url}/runs/{id}/messages (signed)
+ A-->>OP: callback: new proposal
+ U->>OP: Create
+ OP->>OP: check permissions, store the confirmed operations, mint the write token (agent_write)
+ OP->>A: POST {base_url}/runs/{id}/confirm (signed): proposal id, write token
+ A->>OP: POST /api/v3/work_packages (× 5), Authorization: Bearer write token
+ OP->>OP: journals get the cause "via agent"
+ A-->>OP: callback: response (links), finished
+ OP->>OP: revoke both tokens, compare journals with the confirmed operations
+ OP-->>U: in-app notification: result ready
+```
+
+## Work items
+
+The items are in the order in which they can be built.
+
+### 1. Agent connections (admin)
+
+An admin configures where an agent runs.
+
+- **Model** `AI::AgentConnection`, table `ai_agent_connections`:
+ - `name` (shown in the history: "via
"), `base_url`, `active`;
+ - `signing_secret`, stored encrypted;
+ - an owned Doorkeeper application, through the polymorphic `integration` association (`lib/open_project/patches/doorkeeper_application.rb`), as `ScimClient` does (`app/models/scim_client.rb`, `app/services/scim_clients/create_service.rb`);
+ - `skill_types`: the agent skill types it offers, for example `breakdown` and `coding_agent`;
+ - `timeout_minutes`, default 60.
+- **Contracts and services:** create, update and delete. The create service also creates the Doorkeeper application. The delete service revokes its tokens.
+- **Validation of `base_url`:** HTTPS is required, except for `localhost` and internal service names in development.
+- **Admin pages:** Administration → Artificial Intelligence (AI) → Agent connections.
+
+### 2. Identity and tokens
+
+OpenProject mints the tokens itself. It needs no token exchange grant: Doorkeeper (`config/initializers/doorkeeper.rb`) has `authorization_code`, `client_credentials` and refresh tokens only.
+
+- **`AI::AgentConnections::MintTokenService`** creates a `Doorkeeper::AccessToken` with the connection's application (the actor), the user who started the session as `resource_owner_id` (the subject), and the scope and lifetime of the token kind.
+- **Read token:** scope `mcp`, at most one hour.
+- **Write token:** scope `agent_write` only, never together with `api_v3`. Minted after the user confirms a proposal, valid about 15 minutes.
+- **An agent application's token is never an ordinary token.** The OAuth strategy refuses it for the `api_v3` scope unless it has `agent_write`, and then only on the allowed endpoints.
+- **The read token is read-only on MCP.** The MCP server has write tools (`create_work_package`, `update_work_package`, `create_work_package_comment` and the relation tools; `config/initializers/mcp.rb`), and an admin switches them for the whole instance, not for each client. For a token of an agent application, `lib/api/mcp.rb` offers read-only tools only.
+- **`AI::AgentConnections::RevokeTokensService`** revokes tokens when the session finishes, fails, is cancelled or times out.
+- **Plaintext tokens** are sent once and are never stored. OpenProject keeps only the token ids.
+- **The token in the request.** The OAuth strategy (`lib_static/open_project/authentication/strategies/warden/doorkeeper_oauth.rb`) stores the access token in the Rack env, so later code can find the agent application and the session.
+
+### 3. Agent skill types
+
+- **`breakdown`** (Break down into tasks) and **`coding_agent`** (Build), both conversations.
+- **Global assistants only.** The skill form in My account does not offer agent skill types, because the connection and its costs are the admin's.
+- **Skill setting:** the agent connection (`agent_connection_id` on `ai_assistant_skills`).
+- **Ready check** (`AI::AssistantSkill#ready?`): the connection is active and offers the skill type. An agent skill needs no LLM connection in OpenProject.
+- **Menu:** while a session runs, the item shows "Running".
+
+### 4. Agent sessions
+
+- **Model** `AI::AgentSession`, table `ai_agent_sessions`, one for each conversation:
+ - `chat_id`, `agent_connection_id`;
+ - `state`: `pending`, `active`, `awaiting_input`, `complete`, `error`, `stale` (as in Linear);
+ - `external_id`, `read_token_id`, `write_token_id`, `callback_secret_digest`;
+ - `last_event_id`, `request_count`, `started_at`, `finished_at`.
+- **One running session** for each user, work package and skill.
+
+### 5. Outbound requests
+
+- **`AI::AgentSessions::DispatchJob`** sends the start request, the user's messages, the confirmation and the cancellation, in the background.
+- **Signature:** an HMAC-SHA256 of the raw body with the connection's signing secret, in `X-OpenProject-Signature: sha256=`, with `X-OpenProject-Timestamp`. The agent refuses old requests.
+- **Start request** `POST {base_url}/runs`:
+
+ ```json
+ {
+ "session_id": "…",
+ "skill": { "type": "breakdown", "label": "Break down into tasks", "prompt": "…" },
+ "instance_url": "https://openproject.example.com",
+ "work_package": { "id": 1234, "display_id": "PROJ-12", "url": "…/work_packages/1234" },
+ "user": { "id": 5, "name": "Jane Doe", "locale": "en" },
+ "message": "the user's text, or empty",
+ "context": { "…": "AI::Assistance::WorkPackageContext#to_h, with change_options" },
+ "mcp": { "url": "…/mcp", "access_token": "…", "expires_at": "…" },
+ "callback": { "url": "…/ai_assistance/agent_sessions/…/events", "secret": "…" }
+ }
+ ```
+
+ `context` is the context that the core skills get (`app/services/ai/assistance/work_package_context.rb`). `mcp` is present only when the MCP server is enabled.
+- **Message** `POST {base_url}/runs/{external_id}/messages` with `{ "message": "…" }`.
+- **Confirmation** `POST {base_url}/runs/{external_id}/confirm` with `{ "proposal_id": "…", "api": { "url": "…/api/v3", "access_token": "…", "expires_at": "…" } }`.
+- **Cancel** `POST {base_url}/runs/{external_id}/cancel`.
+- **Failures:** retry a network error or a 5xx three times with backoff, then mark the session `error`. A 4xx fails at once.
+- **Log:** each request and its status become events in the "Details" timeline, without tokens or secrets.
+
+### 6. The callback endpoint
+
+- **Route:** `POST /ai_assistance/agent_sessions/:id/events`, with `Authorization: Bearer `. No user session and no CSRF token. A finished session refuses events.
+- **Event body:** `{ "event_id": "…", "kind": "…", "data": { … } }`. A repeated `event_id` is ignored.
+- **Event kinds** (aligned with Linear's activities):
+
+ | Kind | Data | Shown as |
+ |---|---|---|
+ | `thought` | `text` | The newest progress line. The agent sends one within 10 seconds of the start. |
+ | `action` | `text`, optional `result` | A line in Details |
+ | `elicitation` | `markdown` | An assistant message; the state becomes `awaiting_input` |
+ | `proposal` | `id`, `summary`, `operations` | A proposal card |
+ | `response` | `markdown`, optional `links` | An assistant message |
+ | `error` | `reason` | An error banner; the tokens are revoked |
+ | `finished` | `summary` | The state becomes `complete`; the tokens are revoked |
+
+### 7. Proposals with typed operations
+
+```json
+{
+ "id": "p-2",
+ "summary": "Five tasks for the conference",
+ "operations": [
+ { "id": "op-1", "kind": "create_work_package",
+ "attributes": { "subject": "Book the Berlin venue", "description": "…", "type": "Task", "assignee": "Olga Ops" },
+ "parent": "self" },
+ { "id": "op-2", "kind": "create_relation", "from": "$op-1", "to": 10, "type": "precedes" },
+ { "id": "op-3", "kind": "add_comment", "work_package": "self", "text": "…" }
+ ]
+}
+```
+
+- **Vocabulary:** `create_work_package`, `create_relation`, `add_comment`. An operation can refer to an earlier one (`$op-1`).
+- **Checks before "Create":** the names resolve (types of the project, assignable users); the user has the permissions of each operation (`add_work_packages`, `manage_subtasks`, `manage_work_package_relations`, `add_work_package_comments`); at most 10 operations.
+- **Confirm:** "Create" stores the confirmed operations on the session, mints the write token, and sends the confirmation.
+- **Field changes stay in core.** An agent can also propose field changes in the Automations vocabulary (`AI::Proposal`); core applies them through `ApplyProposalService`. The executor depends on the operation, not on the skill.
+
+### 8. The `agent_write` scope
+
+- **Doorkeeper:** `:agent_write` in `optional_scopes`.
+- **Strategy:** accepts an `agent_write` token under the `api_v3` warden scope, for agent applications only.
+- **Endpoint allowlist** (`lib/api/root_api.rb`): an agent token reaches only
+ - `POST /api/v3/work_packages`
+ - `POST /api/v3/work_packages/form`
+ - `POST /api/v3/work_packages/:id/relations`
+ - `POST /api/v3/work_packages/:id/activities`
+
+ Everything else answers 403.
+- **Accepted risk: no plan binding.** Within the allowlist and the user's permissions, the agent could do more than the user confirmed until the token expires. Mitigations:
+ - the token lives about 15 minutes and is revoked on `finished`;
+ - a request cap for each session;
+ - an after-the-fact check: core compares the journals with this session's cause against the confirmed operations, and flags differences;
+ - every change carries the agent cause.
+- **Considered and not chosen:**
+ - **Plan binding:** each request matches one confirmed operation exactly. A check of a few fields is not enough, because the create endpoint takes many more attributes. About six cross-cutting changes.
+ - **Core executes the confirmed operations:** no write token, about one service for each operation kind. It moves the create logic into core, which this design avoids. It stays the fallback if the after-the-fact check finds misuse.
+
+### 9. Attribution
+
+- **Cause:** `Journal::CausedByAgent`, type `agent`, with `source`, `depth`, `agent_connection_id` and `agent_name`. It is in `VALID_CAUSE_TYPES` and in the cause formatter.
+- **Request-scoped default cause:** `Journal::RequestCause`, a thread-local value like `Journal::NotificationConfiguration`, set for each request with an agent token in a block with `ensure`.
+- **In the save hook:** `with_ensured_journal_attributes` uses the request cause when `journal_cause` is blank. Services that set their own cause keep it.
+- **No merging:** `Journals::CreateService` never merges a journal with an agent cause into another journal, and never merges another journal into it, unless both have the same cause.
+- **Activity text:** "Jane Doe, via opilot".
+- **Known gaps:** background jobs that a request starts lose the thread-local cause; the parent's derived-attribute journals keep their own cause; notification mails credit the change to the user.
+
+### 10. The dialog
+
+- **Reuse** `ChatDialogComponent`, `ChatComponent` and `ChatFormComponent`, with the same poll path.
+- **While the agent works:** a spinner with the newest `thought`. The user can close the dialog; the session keeps running.
+- **Proposal card:** the operations as a list, with "Create N work packages", then the links to the results.
+- **Stop:** sends the cancel request, marks the session `error`, revokes the tokens.
+
+### 11. Notifications
+
+- A new reason `ai_result_ready`, created with `Notifications::CreateService` on the work package, as reminders do (`app/workers/reminders/schedule_reminder_job.rb`). Sent on `proposal`, `elicitation` and `finished`.
+
+### 12. Timeouts and cleanup
+
+- **`AI::AgentSessions::TimeoutJob`** (cron) marks sessions `stale` after the connection's timeout, or after 15 minutes without an event, and revokes their tokens.
+
+### 13. Permissions and gating
+
+- **Permission:** `use_ai_assistance`, as for core skills.
+- **Operation permissions:** checked before the write token is minted, and again by the API on each request.
+
+### 14. Deployment and containment
+
+The agent is an optional part of the Docker Compose files and the Helm chart, off by default. When it is on, it brings its network rules.
+
+**The threat:** the agent's harness runs commands that a model chooses and reads text that users wrote. A prompt injection must not reach the database, the cache, the attachment storage or the secrets of OpenProject.
+
+- **Networks:**
+ - the **harness** is on an internal network with the two gateways only;
+ - the **runner** reaches the OpenProject web service and the gateways, never the database, the cache or the workers;
+ - the **gateways** hold the only egress;
+ - **OpenProject** reaches the agent's inbound API, which checks the signature.
+- **Docker Compose:** separate networks, `internal: true` for the harness network.
+- **Helm:** a separate namespace; default-deny NetworkPolicies (they work only with a CNI that enforces them); `automountServiceAccountToken: false`; the "restricted" Pod Security Standard.
+- **No shared secrets or volumes** with OpenProject.
+- **Hardened containers:** non-root, read-only root file system, no capabilities, the default seccomp profile, no Docker socket, resource limits. gVisor or Kata are optional for the harness.
+- **Tokens:** the session tokens go to the runner only; the harness never sees them.
+
+### 15. Changes in opilot
+
+- A Rack app in the runner ("serve" mode) for `/runs`, `/runs/{id}/messages`, `/runs/{id}/confirm`, `/runs/{id}/cancel` and `/health`, with the signature check.
+- A client for each session with a Bearer token.
+- mcp-gw with the session's token, set by the runner.
+- `OpenProject::CreateWp` reused: the draft becomes a `proposal` event, and the creates run after `confirm` with the write token.
+- A callback sink for `OpenProject::Agent#handle_elsewhere`.
+
+## Open questions
+
+- **Hosting:** one agent for several OpenProject instances, or one for each?
+- **Enterprise:** do agent connections need an Enterprise feature?
+- **Costs:** should OpenProject limit sessions for each user or project per day?
+- **Updates:** should `agent_write` later allow updates, and for which fields?
+- **No user present:** do scheduled agent runs need a `ServiceAccount`?
+- **Plan binding:** should it come later, if the after-the-fact check finds misuse?
+- **MCP coverage:** MCP has no tools for meetings, outcomes or descendants.
diff --git a/docs/development/concepts/ai-assistance/README.md b/docs/development/concepts/ai-assistance/README.md
new file mode 100644
index 000000000000..a28b8b5239b7
--- /dev/null
+++ b/docs/development/concepts/ai-assistance/README.md
@@ -0,0 +1,246 @@
+---
+sidebar_navigation:
+ title: AI Assistance
+description: How the AI Assistance menu, its assistants and skills, and Refresh work package work, and how this change is split for review
+keywords: AI, assistants, skills, refresh work package, health check, automations, LLM
+---
+
+# Development concept: AI Assistance
+
+The "AI Assistance" menu on the work package page offers skills, for example "Refresh work package", "Write a test plan" or a chat. Every skill opens a conversation. An answer is text with proposals, and the user applies or dismisses each proposal. Every skill starts with a click, uses the permissions of the user who clicked, and writes nothing until that user applies a proposal.
+
+This document describes the design. It also shows how the first pull request is split into smaller pull requests for review.
+
+## Terms
+
+| Term | Meaning |
+|---|---|
+| **Assistant** | A record (`AI::Assistant`): a name, an icon, instructions and an optional model. An admin configures global and group assistants, and a user can create personal ones (see "Owners"). It is not a user. In the menu, its name is the label next to each of its skills, for example "OPAssistant". |
+| **Skill** | A record (`AI::AssistantSkill`) on an assistant: a label, a system prompt, a proposal scope, conditions, and the setting "Answer at once when opened". What it does is in its prompt. |
+| **Skill type** | Where a skill runs (`AI::AssistantSkills::Registry`). Core has one, `conversation`. An external agent can be a second one later (see "Core skills and agent skills"). |
+| **Chat** | One conversation of a user with a skill about one work package (`AI::AssistantChat`), with its messages (`AI::AssistantChatMessage`). Only that user sees it. |
+| **Proposal** | One proposed change in an answer (`AI::AssistantProposal`). A **finding** has a severity, an area and evidence, and an optional change set. A **plain proposal** has a short text and a change set. |
+| **Change set** | A proposed change in the Automations action vocabulary: a target (only `self` for now) and a list of actions with values, for example `{"target": "self", "actions": [{"key": "description", "values": ["…"]}]}`. Which action keys it may use depends on the skill's proposal scope (see "Proposal scope and changeable attributes"). The user applies it through the Automations services. |
+
+## Flow
+
+```mermaid
+flowchart LR
+ U([User]) -->|click| M["AI Assistance menu
(Primer ActionMenu)"]
+ M -->|answers at once: POST start| S["SendMessageService"]
+ M -->|empty chat: GET show| D
+ S --> J["ReplyJob
(as the user)"]
+ J --> LLM["LlmConnection
(structured output)"]
+ LLM --> P[("Answer + proposals")]
+ P --> D["Chat dialog
(polls while the job runs)"]
+ D -->|Apply| A["ApplyProposalService"]
+ A -->|strict, lockVersion| AU["Automations::UpdateWorkPackageService"]
+ AU --> WP["Work package
+ journal cause"]
+```
+
+1. The app header has an AI Assistance icon button (`AI::Assistance::HeaderButtonComponent`, rendered by the `view_layouts_base_top_menu` hook). Its Stimulus controller (`ai-assistance--header-button`) reads the work package id from the URL: a split view (`…/details/:id`, also on boards and calendars) first, then the full view (`…/work_packages/:id`). It loads `WorkPackages::AIAssistance::MenusController#show` for that id into a turbo frame, and hides the button on other pages. A user without the permission gets an empty frame, and so does a work package that the user cannot see.
+2. The menu frame renders one flat item for each skill that is ready (`AI::AssistantSkill#ready?`) of each active assistant, and a "History" item at the end.
+3. A skill that answers at once posts to `ChatsController#start`: a new chat that starts with a start message (role `start`). `#rerun` adds another start to the same chat. Any other skill opens `ChatsController#show`, an empty chat for the user's own question.
+4. `AI::AssistantChats::SendMessageService` stores the question or the start and a pending answer, and `ReplyJob` answers as the user. The dialog polls `#poll` until the answer is done or failed.
+5. The user applies a proposal or dismisses it, with an optional reason (`ChatsController#apply`, `#dismiss`).
+
+## Decisions
+
+- **Assistants are records, not users.** An answer always acts with the permissions of the user who asked. The job runs in `User.execute_as(chat.user)`.
+- **One runtime, one answer format.** Every skill answers in a conversation, and every answer is text with 0..n proposals (`AI::Assistance::AnswerFormat`). Whether a skill lists findings is up to its prompt. There are no built-in rules: the seeded "Refresh work package" asks the model for what rules used to check.
+- **What the AI reads.** It reads only what the user can see (`AI::Assistance::WorkPackageContext`, with `AI::Assistance::Visibility`): the project name; the work package, with its default-status flag and last activity; visible descendants, with their parent, status and update time; visible relations, with their status; linked pull requests that the user may read; comments filtered by `journals.internal_visible`; and the agendas of the newest 10 visible meetings that have the work package on the agenda or in an outcome. The prompt tells the model that this text is user data, not instructions.
+- **Read-only MCP tools.** For data outside the context, for example other work packages or counts, the model can call OpenProject's read-only MCP tools (`AI::Assistance::McpTool`). They run in-process, through RubyLLM's tool loop, as the chat user, so each tool's own visibility rules apply. No token and no HTTP. Write tools are never offered: changes stay proposals. `search_time_entries` is left out. A model whose `function_calling` verdict is `unsupported` gets no tools. Each skill can opt out with "Use OpenProject MCP tools" (`use_tools`, on by default); the full system prompt on the skill page follows that checkbox. The admin's MCP tool switches apply to external MCP clients only.
+- **Tool calls are limited.** RubyLLM has no limit, so `AI::Assistance::ToolCalls` counts them: an identical call gets the earlier result, every call after 20 calls or after three repeated calls gets "answer now", and the answer fails after 25. Each call is a `tool_call` event in "Details", with its name, arguments, time and result size, but not the result. While the answer loads, the dialog lists the calls so far.
+- **Evidence is required for findings.** A finding without evidence is dropped, and so is a plain proposal without a valid change. Text and evidence are shown as escaped text. Only `http(s)` URLs and work package ids become links.
+- **Stable answers.** Every call uses temperature 0.2, so one work package gives similar answers from run to run.
+- **Change sets are Automations actions.** `AI::Assistance::ApplyProposalService` looks up each action key in `Automations::Register`. It refuses actions that `ChangeOptions#allowed?` does not allow now: keys outside the skill's proposal scope, and values that are not valid now. It calls `Automations::UpdateWorkPackageService` with `strict: true`, so an invalid action fails the call instead of being dropped.
+- **Conflicts use a strict `lockVersion`.** Each proposal stores the work package version from which it was made (`base_lock_version`). If the work package changed after that, the apply fails and the user asks again. After a successful apply, the other open proposals of the same chat move to the new version, so a second proposal can apply.
+- **Content guard.** A description change that removes an attachment reference, a mention or a macro cannot be applied (`AI::Assistance::ContentGuard`).
+- **One answer at a time** in each chat. An answer that is still pending after 10 minutes fails.
+- **Journal cause.** An applied proposal writes `Journal::CausedByAssistantChat` with the assistant's name, so the activity tab says which assistant proposed the change. It carries `source` and `depth` for the loop protection of a later trigger model. Older journals with the cause `assistant_skill` still render.
+- **Automations stay the write layer.** Existing Automations do not change. The only change that admins can see is three new action types: Subject, Description and Comment.
+
+## Prompts
+
+The system prompt of a skill has three parts (`AI::AssistantSkill#system_prompt`):
+
+1. **The assistant prompt** (`ai_assistants.instructions`, optional): what every skill of the assistant shares, for example who it helps and the domain. Edited on the assistant page.
+2. **The skill prompt** (`prompt`): what the model does in this skill. Edited on the skill page. Without it, the skill is not offered.
+3. **The output rules**, which the code generates (`AI::Assistance::OutputRules`): the answer format and language, findings and plain proposals, the changes that the proposal scope allows, how to use the tools (`TOOLS`, only when tools are sent), and the guard against instructions in the context and the tool results. Admins and users do not edit them, so a skill cannot break the contract with the code.
+
+The work package context (JSON) follows the system prompt. The message is the user's question, with the last 20 turns before it. An earlier answer is sent with a short list of its proposals and their state, so the model knows what it proposed.
+
+The skill page shows the full system prompt under the skill prompt field. It changes while the admin types (`ai-assistance--prompt-preview`), and its tool rule follows "Use OpenProject MCP tools". The "Details" timeline of each answer shows the prompt that was sent. The basic data seeder creates the global "OPAssistant" with its instructions and two skills (`app/seeders/common.yml`): Refresh work package and Chat.
+
+## Skills
+
+A skill has one behavior setting, **"Answer at once when opened"** (on by default):
+
+- **On:** opening the skill starts a new chat, and the skill answers at once. The chat shows a start line ("Tom started Refresh work package"), not a question. The LLM gets a fixed, translated request (`ai_assistance.chat.start_request`) as the user turn. "Run again" starts the skill again in the same chat, on the current work package. The user can ask follow-up questions. Examples: Refresh work package, Write a test plan.
+- **Off:** opening the skill shows the user's latest chat of this skill, or an empty one, and the user writes the first question. Example: Chat.
+
+"New chat" starts an empty conversation. An assistant can have several skills, each with its own label, prompt, scope and conditions.
+
+## Owners
+
+An assistant has an optional owner, `ai_assistants.principal_id`:
+
+- **None:** a global assistant. Everyone with `use_ai_assistance` sees it.
+- **A group:** only the group's direct members see it. An admin chooses the group when creating the assistant (Administration → AI → Assistants).
+- **A user:** a personal assistant (see below).
+
+The development data seeds a sample for each owned kind (`DevelopmentData::AiAssistantsSeeder`, data in `app/seeders/standard.yml`). The global one is the basic data's OPAssistant.
+
+- **COMMS assistant** (the COMMS team, which builds the communication features; `DevelopmentData::TeamSeeder` seeds the team with the admin user as its member): "Write a test plan".
+- **Tom's assistant** (personal, of the admin user): "My next steps".
+
+The owner never changes (`attr_readonly`). Other principal types can be added later (`AI::Assistant::OWNER_TYPES`). The menu shows global assistants first, then owned ones (`AI::Assistant.visible_to`, `.ordered`). Owned assistants are deleted with their principal, together with their skills and chats.
+
+### Personal assistants
+
+A user can create their own assistants with their own skills at **My account → AI assistants** (`My::AIAssistantsController`, `My::AIAssistantSkillsController`).
+
+- **Permission:** the global permission `manage_own_ai_assistants`. An admin gives it through a global role. To give it to all users, add it to the Standard global role.
+- **Menu:** a personal assistant shows only in its owner's menu, after the global assistants. When the owner loses the permission, the assistant stays, but it is not offered (`AI::Assistant#usable?`).
+- **Model:** `Llm::Runtime` resolves the model of the `:ai_assistance` feature, with the assistant's model as the override. An assistant model that the connection does not offer is ignored. Without an assistant model, the feature binding applies, then the connection default. A personal assistant has no model of its own. Only an admin can set a model, also on a personal assistant.
+- **Admin:** the admin list shows all assistants with their owner, and filters by owner (Global, Groups, Personal). The admin edits, deactivates and deletes personal assistants on the same pages.
+- **Shared pages:** the admin and the My account controllers include `AIAssistance::AssistantsActions` and `AIAssistance::AssistantSkillsActions`, and render the views in `app/views/ai_assistants/` and `app/views/ai_assistant_skills/`. `AIAssistance::Pages` gives the paths and the breadcrumbs of each place.
+
+## Skill conditions
+
+A skill can be limited to work packages of some types, statuses and projects, and to users with some roles in the work package's project (`ai_assistant_skills.conditions`, a jsonb hash of id lists). An empty list matches all. The menu, and so the chats controller, offer only skills whose conditions match (`AI::AssistantSkill#conditions_fulfilled?`). The skill page edits them under "Where it is offered".
+
+The rules are the same as the conditions of Automations, but the code is separate for now: Automations store them in join tables and filter in SQL. A deleted type, status, project or role stays in the list until the skill is saved again; it then matches nothing.
+
+## Proposal scope and changeable attributes
+
+A skill's proposal scope (`ai_assistant_skills.proposal_scope`) is **Changes** (attribute changes and comments), **Comment only** or **Read-only** (nothing). A comment is the Automations action `Comment`, and it needs only the permission to comment. There is no list of attributes to maintain in the UI: with custom fields, such a list would grow without limit.
+
+| Layer | Set by | Example: Refresh work package |
+|---|---|---|
+| What a skill can change (`changeable_attributes`, `ChangeOptions::ATTRIBUTE_KEYS`) | Code | `subject`, `description`, `status`, `priority`, `assigned_to`, `responsible`, `start_date`, `due_date`, and a `comment` |
+| The proposal scope | Admin, on the skill page | Changes |
+| Which values are valid now (`AI::Assistance::ChangeOptions`) | The workflow and the user's permissions | `assignable_statuses`, `assignable_priorities`, `assignable_assignees`, `assignable_responsibles` of `WorkPackages::UpdateContract` |
+
+The model gets the valid values as `change_options`, with names, not ids. `ChangeOptions#resolve` maps each answer to an id or an ISO date, and drops anything else. `ApplyProposalService` checks every action again with `ChangeOptions#allowed?` before it applies a proposal, so an admin who narrows a skill's scope after an answer also blocks its open proposals.
+
+The dialog shows text changes as a diff, and other changes as "old → new".
+
+## Core skills and agent skills
+
+Some skills run in OpenProject (core skills), and some run in an external agent (agent skills). The user does not see the difference.
+
+- **Core skill:** it reads one work package, makes one LLM call through the LLM connection, and proposes changes to that work package in the Automations vocabulary. Refresh work package, Chat and Custom prompt are core skills.
+- **Agent skill:** it creates or links several objects, uses tools in a loop, runs for minutes, or reaches systems outside OpenProject. Break down into tasks and the coding agent are agent skills. See [Agent integration](./2026-10-14-agents.md).
+- **Neither:** a feature with one correct result, for example PDF generation, stays deterministic code.
+
+**One user experience.** Both kinds are entries in the same menu, use the same dialog, show their proposals on the same cards, and are listed in the same History. Every change in the activity tab names the user and the assistant or agent that proposed it.
+
+**Who decides where something runs:**
+
+1. **The skill type** (admin time): a core skill type runs in core; an agent skill type runs in the agent connection that the admin selects.
+2. **The operation** (answer time): a field change or a comment is applied by core through `ApplyProposalService`. Creating work packages and relations is done by the agent after the user confirms.
+3. **The apply step** (apply time): core checks the user's permissions before anything runs, also for the agent.
+
+The model and the user never decide it.
+
+**Why not everything in the agent:** every instance would need a running agent, even for a summary; the agent would depend on the MCP server, which is an Enterprise add-on; one LLM call is faster and cheaper than an agent run; and the contract between core and the agent must stay compatible across releases.
+
+## Prepared for an Automations trigger model
+
+The code does not have triggers, but it uses the shapes that a trigger model needs:
+
+- **Change set in the action vocabulary.** The same shape can serve an AI proposal, an automation step and the answer of an external service.
+- **`Automations::Actions::Base#after_success`.** It is called after a successful save. It is the extension point for actions with an effect outside the work package: start a skill, call a webhook, create a work package.
+- **Sending and answering are separate.** `SendMessageService` stores the question and the pending answer; `ReplyJob` answers it. A job can start a conversation without a request.
+- **The chat's user is the only actor.** The reply and apply never read `User.current`. A conversation that a schedule starts can act as a service account.
+- **The journal cause carries `source` and `depth`.**
+- **Not done yet:** a chat does not record its trigger. A trigger model adds `trigger` (`menu`, `automation`, `schedule`) and `automation_id` to `ai_assistant_chats`.
+
+## Gating
+
+- The feature flag `ai_assistance` (off by default in production). Skills also need the `llm_connection` flag, the `llm_features_enabled` setting and a configured connection with a model.
+- The project permission `use_ai_assistance` (view level). The seeded Member role gets it. Personal assistants also need the global permission `manage_own_ai_assistants`. Applying a proposal also needs `edit_work_packages`, or only `add_work_package_comments` for a comment.
+- There is no enterprise gate yet. See "Follow-ups".
+
+## Data model
+
+| Table | Holds |
+|---|---|
+| `ai_assistants` | name (unique for each owner), description, `instructions`, icon, `model_id` (a string: syncing models replaces `llm_models` rows), position, active, `principal_id` (the owner: a user or a group) |
+| `ai_assistant_skills` | `assistant_id`, `skill_key` (`conversation`), label, description, `prompt`, `proposal_scope`, `conditions`, `settings` (`answers_at_once`), position, active |
+| `ai_assistant_chats` | skill, user, work package |
+| `ai_assistant_chat_messages` | chat, role, content, status, locale, `base_lock_version`, `events` |
+| `ai_assistant_proposals` | message, position, source, severity, area, text, `evidence`, `change_set`, `base_lock_version`, state, `dismiss_reason` |
+
+Chats, messages and proposals are deleted with their user, their work package or their skill (foreign keys with `on_delete: :cascade`). The basic data seeder creates the "OPAssistant" with its two skills when there is no global assistant, also on an upgrade.
+
+## How to try it
+
+1. Enable `ai_assistance` at `/admin/settings/experimental`.
+2. Give "Use AI assistance" to a role, or use the seeded Member role.
+3. Configure an LLM connection with a default chat model.
+4. Open a work package, and use **AI Assistance → Refresh work package**. To see proposals, add a comment that changes the scope, or close the work package while a child is still open.
+5. Configure assistants at **Administration → Artificial Intelligence (AI) → Assistants**.
+6. For personal assistants, add "Manage own AI assistants" to a global role, then open **My account → AI assistants**.
+
+## Split for review
+
+This change comes as one pull request so that it can be tried end to end. Each commit is one review step, except the commit that adds this document. The steps can be cut into separate pull requests in this order, each one stacked on the one before it:
+
+| # | Commit | Contents | Depends on |
+|---|---|---|---|
+| 1 | Add Subject and Description automation actions and a strict mode | Three Automations action types (Subject, Description, Comment), a text area for text actions, `strict:` and the `after_success` hook in `Automations::UpdateWorkPackageService` | – |
+| 2 | Add the ai_assistance flag, permission and journal cause | Feature flag, `use_ai_assistance`, seeded role, `Journal::CausedByAssistantSkill` | – |
+| 3 | Add AI assistant, skill, run and finding models | Migration, four models, seeder, factories | 2 |
+| 4 | Add the skill registry and the health check tree rules | Registry, `HealthCheck::Context`, four tree rules | 3 |
+| 5 | Add the health check relation and staleness rules | Three rules | 4 |
+| 6 | Add the health check rules for linked pull requests | GitHub and GitLab rules, "Not checked" entries | 4 |
+| 7 | Add the run service, the run controllers and the run dialog | `RunService`, runs and findings controllers, dismiss, Primer dialog and history | 4 |
+| 8 | Add the AI Assistance menu to the work package toolbars | Angular slot, menu button and items | 7 |
+| 9 | Add the AI review to the health check | Job, review, model choice (`AI::Assistant#effective_model_id`), single-job guard, polling | 7 |
+| 10 | Apply proposed changes from AI findings | `ApplyProposalService`, content guard, diff view, toolbar refresh | 1, 9 |
+| 11 | Add admin pages for AI assistants and their skills | Admin controllers, Primer forms, menu entry | 3 |
+| 12 | Add the AI Assistance chat on work packages | Chat tables and models, reply job, chat skill type, chat dialog, `Journal::CausedByAssistantChat` | 10, 11 |
+| 13 | Add custom prompt presets, a comment-only scope and a copy button | Custom prompt skill type, `ChatsController#start`, `proposal_scope`, copy button | 12 |
+| 14 | Use "AI review" in the interface | Wording | 9 |
+| 15 | Rename the AI judgement to AI review, and move shared proposal classes to AI::Assistance | Renames only | 14 |
+| 16 | Clean up AI Assistance after review | Shared LLM call, event log and locale helpers, dead code, seeded prompts, this document | 15 |
+| 17 | Add personal AI assistants in My account | Owner column, `manage_own_ai_assistants`, My account pages, shared admin and My controller logic | 11 |
+| 18 | Let a group own an AI assistant | `principal_id` owner, group choice in admin, menu for group members | 17 |
+| 19 | Offer skills only where their conditions match | `conditions` on skills, the menu filter, the skill form in sections | 11 |
+| 20 | Generate the output rules, and share instructions across an assistant's skills | `AI::Assistance::OutputRules`, `instructions` on assistants, the prompt preview, the assistant form in sections | 11 |
+| 21 | Store the proposals of a conversation answer as their own records | `ai_assistant_proposals`, several proposals for each answer, the journal cause names the assistant | 12 |
+| 22 | Replace the AI skill types with one conversation type and settings | One `conversation` type, `settings` on skills, the skill form | 21 |
+| 23 | Answer with findings in the conversation, and with several proposals | Findings in the chat reply, the answer timeout | 22 |
+| 24 | Give AI skills a first message instead of start and answer settings | One answer format (`AnswerFormat`), simpler settings | 23 |
+| 25 | Open every AI skill in the conversation, with its proposals as cards | "Answer at once when opened", `ProposalItemComponent`, dismiss in the chat | 24 |
+| 26 | Remove AI runs and the built-in rules, and check consistency with the LLM | Runs, findings and rules removed; the context gets pull requests, the default status and activity times; the seeded prompt | 25 |
+| – | Add an open filter and a count tool to the MCP work package tools | `open` and a status list on `search_work_packages`, `count_work_packages` with `group_by` | – |
+| – | Let AI Assistance skills call the read-only MCP tools | `AI::Assistance::McpTool`, `ToolCalls`, the tool rule, the project id in the context, `tool_call` events | the MCP tool step, 26 |
+| – | Show AI tool calls while the answer loads, and stop repeated calls | The poll shows new events while pending, id normalization, the repeat limit | the step before |
+
+Steps 21–26 replace runs with conversations. For review, they are best folded into the steps they change (7, 9, 10, 12 and 13), so that runs, rules and the three skill types never appear. This table is updated when that is done.
+
+Steps 1 and 2 are useful on their own and can merge first.
+
+## Split into a plugin
+
+The code is built so that it can move into a module (`modules/ai_assistance`) without a rewrite. The header button already uses the plugin extension points: a view hook, a dynamic Stimulus controller (module controllers stay in the core `dynamic/` tree), and Angular services through the plugin context. The core files that a split must move or replace:
+
+- `config/initializers/ai_assistance.rb` (loads the hook listener): goes into the engine's `config.to_prepare`.
+- `config/initializers/feature_decisions.rb`, `llm_features.rb`, `permissions.rb`, `menus.rb`, `zeitwerk.rb`, `config/routes.rb`, `config/locales/en.yml`: the AI Assistance entries go into the engine's `register` block, initializers, routes and locale file.
+- `app/models/principal.rb`, `app/models/user.rb` (associations): engine patches.
+- `app/models/journal.rb`, `lib/open_project/journal_formatter/cause.rb` (the `assistant_chat` cause): stay in core, as the meeting cause types do.
+- The Automations actions (Subject, Description, Comment, strict mode) and the MCP tool changes: stay in core as their own pull requests.
+- The seeders (`basic_data_seeder.rb`, `development_people_seeder.rb`, `common.yml`, `standard.yml`): module seeders or patches.
+
+## Follow-ups
+
+- **Agent skills.** Start an external agent (opilot first) from the menu: Break down into tasks, then the coding agent. The OpenProject work is in [Agent integration](./2026-10-14-agents.md).
+- **Enterprise gate.** Gate the menu, the chats and the admin pages behind the existing `custom_actions` enterprise feature, as the last step.
+- **Specs.** Model, service, request and component specs cover the chat, the proposals and the admin pages. The toolbar and the chat dialog need browser specs (full view and split view).
+- **Automations can start a skill.** A "Run assistant skill" action type that uses `after_success` and starts a conversation with `trigger: "automation"`, and notifies the user. It gives skills the triggers of Automations. The result is still a proposal. The action offers only the skills of global assistants: a personal skill must not run for other users.
+- **MCP tools.** Decide the gating: the Enterprise `mcp_server` feature and the admin's per-tool switches apply to `/mcp`, but not yet to the chat. Decide about `search_time_entries`. Tool results are not kept for later turns, so a follow-up question calls the tools again. Weaker models (gpt-4o-mini) use the tools badly: they skip `group_by` and read data that the context holds. Recommend a Sonnet-class model.
+- **Edit before apply.** Open a proposed description in the description editor.
+- **Retention.** Chats are kept without a limit. A cleanup job can come when usage shows the need.
diff --git a/frontend/src/stimulus/controllers/dynamic/ai-assistance/header-button.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/ai-assistance/header-button.controller.spec.ts
new file mode 100644
index 000000000000..b1509eb4664b
--- /dev/null
+++ b/frontend/src/stimulus/controllers/dynamic/ai-assistance/header-button.controller.spec.ts
@@ -0,0 +1,69 @@
+//-- 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.
+//++
+
+import { describe, expect, it } from 'vitest';
+import { placeFrom, workPackageIdFrom } from './header-button.controller';
+
+describe('AI Assistance header button', () => {
+ describe('workPackageIdFrom', () => {
+ it('reads the full view', () => {
+ expect(workPackageIdFrom('/projects/demo/work_packages/2/activity')).toBe('2');
+ expect(workPackageIdFrom('/work_packages/42')).toBe('42');
+ });
+
+ it('prefers the split view of a list, a board or a calendar', () => {
+ expect(workPackageIdFrom('/projects/demo/work_packages/details/8')).toBe('8');
+ expect(workPackageIdFrom('/projects/demo/boards/3/details/9/overview')).toBe('9');
+ });
+
+ it('reads semantic ids', () => {
+ expect(workPackageIdFrom('/projects/demo/work_packages/PROJ-7/activity')).toBe('PROJ-7');
+ });
+
+ it('finds nothing on other pages', () => {
+ expect(workPackageIdFrom('/projects/demo/work_packages')).toBeNull();
+ expect(workPackageIdFrom('/projects/demo/work_packages/new')).toBeNull();
+ expect(workPackageIdFrom('/my/page')).toBeNull();
+ });
+ });
+
+ describe('placeFrom', () => {
+ it('takes the work package before the project', () => {
+ expect(placeFrom('/projects/demo/work_packages/2/activity')).toEqual({ work_package_id: '2' });
+ });
+
+ it('takes the project of other project pages', () => {
+ expect(placeFrom('/projects/demo/work_packages')).toEqual({ project_id: 'demo' });
+ expect(placeFrom('/projects/demo-project/meetings/3')).toEqual({ project_id: 'demo-project' });
+ });
+
+ it('has no place on pages without a project', () => {
+ expect(placeFrom('/my/page')).toEqual({});
+ expect(placeFrom('/admin/ai_assistants')).toEqual({});
+ });
+ });
+});
diff --git a/frontend/src/stimulus/controllers/dynamic/ai-assistance/header-button.controller.ts b/frontend/src/stimulus/controllers/dynamic/ai-assistance/header-button.controller.ts
new file mode 100644
index 000000000000..30d56218511b
--- /dev/null
+++ b/frontend/src/stimulus/controllers/dynamic/ai-assistance/header-button.controller.ts
@@ -0,0 +1,124 @@
+//-- 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.
+//++
+
+import { Controller } from '@hotwired/stimulus';
+import type { HalEventsService } from 'core-app/features/hal/services/hal-events.service';
+import type { ApiV3Service } from 'core-app/core/apiv3/api-v3.service';
+import { useAngularServices, type ServiceKey } from 'core-stimulus/mixins/use-angular-services';
+import { WORK_PACKAGE_UPDATED_EVENT } from 'core-stimulus/helpers/my-work-entry-menus';
+
+// The AI Assistance button in the app header. It shows the menu for the place in the URL: the
+// work package that is open (full view, or the split view of a list, a board or a calendar),
+// else the project, else no place. It hides while the menu is empty.
+// The header is not rendered again on Turbo frame visits, so it follows the URL: through the
+// Navigation API where the browser has it (it also reports pushState), else through Turbo events.
+export default class extends Controller {
+ static targets = ['frame'];
+
+ static values = { menuUrl: String };
+
+ static services:ServiceKey[] = ['apiV3Service', 'halEvents'];
+
+ declare readonly frameTarget:HTMLElement;
+ declare readonly menuUrlValue:string;
+ declare apiV3Service:ApiV3Service;
+ declare halEvents:HalEventsService;
+
+ initialize():void {
+ useAngularServices(this);
+ }
+
+ connect():void {
+ window.navigation?.addEventListener('currententrychange', this.update);
+ document.addEventListener('turbo:load', this.update);
+ document.addEventListener('turbo:frame-load', this.frameLoaded);
+ window.addEventListener('popstate', this.update);
+ document.addEventListener(WORK_PACKAGE_UPDATED_EVENT, this.refresh);
+ this.update();
+ }
+
+ disconnect():void {
+ window.navigation?.removeEventListener('currententrychange', this.update);
+ document.removeEventListener('turbo:load', this.update);
+ document.removeEventListener('turbo:frame-load', this.frameLoaded);
+ window.removeEventListener('popstate', this.update);
+ document.removeEventListener(WORK_PACKAGE_UPDATED_EVENT, this.refresh);
+ }
+
+ // A split view loads in a frame. The menu's own frame loads too, and must not trigger a reload.
+ private frameLoaded = (event:Event):void => {
+ if (event.target === this.frameTarget) {
+ this.element.hidden = !this.frameTarget.querySelector('action-menu');
+ } else {
+ this.update();
+ }
+ };
+
+ private update = ():void => {
+ // Turbo stores the src as an absolute URL.
+ const url = new URL(this.menuUrlValue, window.location.origin);
+ Object.entries(placeFrom(window.location.pathname)).forEach(([key, value]) => url.searchParams.set(key, value));
+ if (this.frameTarget.getAttribute('src') !== url.href) {
+ this.frameTarget.setAttribute('src', url.href);
+ }
+ };
+
+ // After a proposal changed the work package, reload it, so the next inline edit does not conflict.
+ private refresh = (event:Event):void => {
+ const id = (event as CustomEvent<{ work_package_id?:number|string }>).detail?.work_package_id;
+ if (!id) {
+ return;
+ }
+
+ void this.apiV3Service
+ .work_packages
+ .id(String(id))
+ .refresh()
+ .then((workPackage) => this.halEvents.push(workPackage, { eventType: 'updated' }));
+ };
+}
+
+const ID = '(\\d+|[A-Za-z][A-Za-z0-9_]*-\\d+)';
+const SPLIT_VIEW = new RegExp(`/details/${ID}(?:/|$)`);
+const FULL_VIEW = new RegExp(`/work_packages/${ID}(?:/|$)`);
+const PROJECT = /\/projects\/([^/?#]+)/;
+
+// The id of the open work package: a split view first, then the full view.
+export function workPackageIdFrom(path:string):string|null {
+ return SPLIT_VIEW.exec(path)?.[1] ?? FULL_VIEW.exec(path)?.[1] ?? null;
+}
+
+// The query parameters of the menu: the open work package, else the project of the page.
+export function placeFrom(path:string):Record {
+ const workPackageId = workPackageIdFrom(path);
+ if (workPackageId) {
+ return { work_package_id: workPackageId };
+ }
+
+ const project = PROJECT.exec(path)?.[1];
+ return project ? { project_id: decodeURIComponent(project) } : {};
+}
diff --git a/frontend/src/stimulus/controllers/dynamic/ai-assistance/icon-picker.controller.ts b/frontend/src/stimulus/controllers/dynamic/ai-assistance/icon-picker.controller.ts
new file mode 100644
index 000000000000..084b3c102adf
--- /dev/null
+++ b/frontend/src/stimulus/controllers/dynamic/ai-assistance/icon-picker.controller.ts
@@ -0,0 +1,43 @@
+//-- 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.
+//++
+
+import { Controller } from '@hotwired/stimulus';
+
+// Shows the chosen icon on the button of the skill's icon menu.
+export default class extends Controller {
+ static targets = ['button'];
+
+ declare readonly buttonTarget:HTMLElement;
+
+ pick(event:CustomEvent<{ item:HTMLElement }>):void {
+ const icon = event.detail.item.querySelector('.ActionListItem-visual--leading svg');
+ const current = this.buttonTarget.querySelector('svg');
+ if (icon && current) {
+ current.replaceWith(icon.cloneNode(true));
+ }
+ }
+}
diff --git a/frontend/src/stimulus/controllers/dynamic/ai-assistance/prompt-preview.controller.ts b/frontend/src/stimulus/controllers/dynamic/ai-assistance/prompt-preview.controller.ts
new file mode 100644
index 000000000000..1a65003c384b
--- /dev/null
+++ b/frontend/src/stimulus/controllers/dynamic/ai-assistance/prompt-preview.controller.ts
@@ -0,0 +1,45 @@
+//-- 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.
+//++
+
+import { Controller } from '@hotwired/stimulus';
+
+// Copies the skill prompt into the full system prompt preview while the admin types.
+// A separator after the prompt shows only when the prompt is not empty, as on the server.
+export default class extends Controller {
+ static targets = ['source', 'output', 'separator'];
+
+ declare readonly sourceTarget:HTMLTextAreaElement;
+ declare readonly outputTargets:HTMLElement[];
+ declare readonly separatorTargets:HTMLElement[];
+
+ update():void {
+ const text = this.sourceTarget.value;
+
+ this.outputTargets.forEach((output) => { output.textContent = text; });
+ this.separatorTargets.forEach((separator) => { separator.hidden = text.trim() === ''; });
+ }
+}
diff --git a/lib/open_project/hooks/ai_assistance_header_button.rb b/lib/open_project/hooks/ai_assistance_header_button.rb
new file mode 100644
index 000000000000..3977cc8e7746
--- /dev/null
+++ b/lib/open_project/hooks/ai_assistance_header_button.rb
@@ -0,0 +1,38 @@
+# 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, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+module OpenProject
+ module Hooks
+ # Puts the AI Assistance button into the app header.
+ class AIAssistanceHeaderButton < OpenProject::Hook::ViewListener
+ render_on :view_layouts_base_top_menu, partial: "hooks/ai_assistance/header_button"
+ end
+ end
+end
diff --git a/lib/open_project/journal_formatter/cause.rb b/lib/open_project/journal_formatter/cause.rb
index f0d58d8196da..a935174885bb 100644
--- a/lib/open_project/journal_formatter/cause.rb
+++ b/lib/open_project/journal_formatter/cause.rb
@@ -42,6 +42,7 @@ class OpenProject::JournalFormatter::Cause < JournalFormatter::Base
def render(_key, values, options = { html: true })
@cause = values.last
@html = options[:html]
+ @only_path = options.fetch(:only_path, true)
return "" if hidden_meeting_cause?
return combined_meeting_message if meeting_cause?
@@ -101,11 +102,37 @@ def cause_description
import_message
when "work_package_parent_deleted"
I18n.t("journals.cause_descriptions.work_package_parent_deleted")
+ when "assistant_skill", "assistant_chat"
+ assistant_message
else
related_work_package_changed_message
end
end
+ def assistant_message
+ key = cause["assistant_name"].present? ? "assistant_skill" : "assistant_chat"
+ message = I18n.t("journals.cause_descriptions.#{key}",
+ assistant_name: escape(cause["assistant_name"]),
+ user_name: escape(@journal.user&.name))
+ [message, assistant_chat_link].compact.join(" · ")
+ end
+
+ def escape(text)
+ html? ? ERB::Util.html_escape(text.to_s) : text.to_s
+ end
+
+ # Chats are private: only their owner gets the link.
+ def assistant_chat_link
+ return unless html? && @only_path && cause["chat_id"] && OpenProject::FeatureDecisions.ai_assistance_active?
+
+ chat = AI::AssistantChat.find_by(id: cause["chat_id"], user: User.current, work_package_id: @journal.journable_id)
+ return unless chat
+
+ link_to(I18n.t("journals.cause_descriptions.assistant_chat_link"),
+ ai_assistance_chat_path(chat_id: chat.id),
+ data: { controller: "async-dialog", test_selector: "op-journal-ai-assistance-chat-link" })
+ end
+
def system_update_message
feature = cause["feature"]
feature = "progress_calculation_adjusted" if feature == "progress_calculation_changed"
diff --git a/spec/components/ai/assistance/proposal_item_component_spec.rb b/spec/components/ai/assistance/proposal_item_component_spec.rb
new file mode 100644
index 000000000000..f54c6e10c264
--- /dev/null
+++ b/spec/components/ai/assistance/proposal_item_component_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, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+require "rails_helper"
+
+RSpec.describe AI::Assistance::ProposalItemComponent, type: :component do
+ let(:message) { create(:ai_assistant_chat_message) }
+ let(:work_package_id) { message.chat.work_package_id }
+ let(:proposal) do
+ create(:ai_assistant_proposal, :finding,
+ message:,
+ text: " is wrong",
+ evidence: [{ "text" => "#1", "work_package_id" => work_package_id },
+ { "text" => "PR", "url" => "javascript:alert(1)" }])
+ end
+
+ subject(:rendered) { render_inline(described_class.new(proposal:)) }
+
+ it "escapes the text and the evidence" do
+ expect(rendered.to_html).not_to include(" is wrong")
+ end
+
+ it "links work package evidence and does not link unsafe URLs" do
+ expect(rendered).to have_link("#1", href: "/work_packages/#{work_package_id}")
+ expect(rendered).to have_no_link("PR")
+ end
+
+ it "shows a finding's severity and offers to dismiss it" do
+ expect(rendered).to have_text("Medium")
+ expect(rendered).to have_test_selector("ai-assistance-dismiss")
+ end
+
+ context "with a plain proposal" do
+ let(:proposal) { create(:ai_assistant_proposal, message:, text: "A clearer description") }
+
+ it "shows its text without a severity or evidence" do
+ expect(rendered).to have_text("A clearer description")
+ expect(rendered).to have_no_text("Medium")
+ expect(rendered).not_to have_test_selector("ai-assistance-evidence")
+ end
+ end
+
+ context "when dismissed" do
+ before { proposal.update!(state: "dismissed") }
+
+ it "shows the state and no actions" do
+ expect(rendered).to have_text("Dismissed")
+ expect(rendered).not_to have_test_selector("ai-assistance-dismiss")
+ end
+ end
+end
diff --git a/spec/components/ai/assistance/run_events_component_spec.rb b/spec/components/ai/assistance/run_events_component_spec.rb
new file mode 100644
index 000000000000..0b88e742287f
--- /dev/null
+++ b/spec/components/ai/assistance/run_events_component_spec.rb
@@ -0,0 +1,66 @@
+# 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, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+require "rails_helper"
+
+RSpec.describe AI::Assistance::RunEventsComponent, type: :component do
+ subject(:rendered) { render_inline(described_class.new(events:)) }
+
+ let(:events) do
+ [
+ { "at" => Time.current.iso8601, "kind" => "tool_call",
+ "data" => { "name" => "count_work_packages", "arguments" => { "project_id" => 1 }, "seconds" => 0.2, "size" => 12,
+ "result" => %({"total":12}) } },
+ { "at" => Time.current.iso8601, "kind" => "thought", "data" => { "text" => "Counting once more." } },
+ { "at" => Time.current.iso8601, "kind" => "tool_call",
+ "data" => { "name" => "count_work_packages", "arguments" => { "project_id" => 1 }, "repeated" => true } },
+ { "at" => Time.current.iso8601, "kind" => "tool_call",
+ "data" => { "name" => "search_work_packages", "arguments" => {}, "refused" => true } },
+ { "at" => Time.current.iso8601, "kind" => "tool_call",
+ "data" => { "name" => "search_work_packages", "arguments" => {}, "error" => "KeyError" } },
+ { "at" => Time.current.iso8601, "kind" => "answer", "data" => { "seconds" => 3.5, "tool_calls" => 4 } }
+ ]
+ end
+
+ it "summarizes each tool call and the answer" do
+ expect(rendered).to have_text("Called the tool count_work_packages: 0.2 s, 12 characters.")
+ expect(rendered).to have_text("Called the tool count_work_packages again with the same arguments.")
+ expect(rendered).to have_text("Refused the tool search_work_packages: the limit of tool calls is reached.")
+ expect(rendered).to have_text("The tool search_work_packages failed (KeyError).")
+ expect(rendered).to have_text("The model answered in 3.5 s, after 4 tool calls.")
+ end
+
+ it "shows the arguments and the result of a tool call, and the model's text between calls" do
+ expect(rendered).to have_text(%("project_id": 1))
+ expect(rendered).to have_text(%({"total":12}))
+ expect(rendered).to have_text("The model explained its next tool calls.")
+ expect(rendered).to have_text("Counting once more.")
+ end
+end
diff --git a/spec/factories/ai_assistant_factory.rb b/spec/factories/ai_assistant_factory.rb
new file mode 100644
index 000000000000..3e247daea39f
--- /dev/null
+++ b/spec/factories/ai_assistant_factory.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, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+FactoryBot.define do
+ factory :ai_assistant, class: "AI::Assistant" do
+ sequence(:name) { |n| "Assistant #{n}" }
+ description { "Checks work packages." }
+ sequence(:position)
+ end
+
+ factory :ai_assistant_skill, class: "AI::AssistantSkill" do
+ assistant { association :ai_assistant }
+ skill_key { "conversation" }
+ answers_at_once { true }
+ prompt { "Check the work package for contradictions." }
+ sequence(:position)
+
+ trait :chat do
+ answers_at_once { false }
+ end
+
+ trait :preset do
+ answers_at_once { true }
+ end
+ end
+
+ factory :ai_assistant_chat, class: "AI::AssistantChat" do
+ skill { association :ai_assistant_skill, :chat }
+ user
+ work_package
+ end
+
+ factory :ai_assistant_chat_message, class: "AI::AssistantChatMessage" do
+ chat { association :ai_assistant_chat }
+ role { "assistant" }
+ content { "An answer." }
+ locale { "en" }
+ base_lock_version { chat.work_package&.lock_version }
+ end
+
+ factory :ai_assistant_proposal, class: "AI::AssistantProposal" do
+ message { association :ai_assistant_chat_message }
+ change_set { AI::Proposal.change_set_for({ "description" => "A better description" }) }
+ base_lock_version { message.base_lock_version }
+
+ trait :finding do
+ severity { "medium" }
+ area { "content" }
+ text { "The description misses the latest decision." }
+ evidence { [{ "text" => "A comment" }] }
+ end
+ end
+end
diff --git a/spec/lib/open_project/journal_formatter/cause_spec.rb b/spec/lib/open_project/journal_formatter/cause_spec.rb
index 7ad67c33a7e7..b605bc3033ba 100644
--- a/spec/lib/open_project/journal_formatter/cause_spec.rb
+++ b/spec/lib/open_project/journal_formatter/cause_spec.rb
@@ -303,6 +303,74 @@ def render(cause, html:)
end
end
+ context "when the change was applied from an AI assistant proposal" do
+ let(:applier) { build_stubbed(:user, firstname: "Jane", lastname: "") }
+ let(:instance) { described_class.new(build(:work_package_journal, user: applier, journable: work_package)) }
+
+ subject(:cause) do
+ {
+ "type" => "assistant_chat",
+ "chat_id" => 0,
+ "skill_key" => "conversation",
+ "assistant_name" => "QA "
+ }
+ end
+
+ it "names the assistant and the user who applied it" do
+ expect(cause).to render_html_variant(
+ "#{I18n.t('journals.caused_changes.assistant_chat')} " \
+ "#{I18n.t('journals.cause_descriptions.assistant_skill', assistant_name: 'QA <assistant>',
+ user_name: 'Jane <Doe>')}"
+ )
+ end
+
+ it do
+ expect(cause).to render_raw_variant(
+ "#{I18n.t('journals.caused_changes.assistant_chat')} " \
+ "#{I18n.t('journals.cause_descriptions.assistant_skill', assistant_name: 'QA ', user_name: 'Jane ')}"
+ )
+ end
+
+ context "when the viewer owns the chat", with_flag: { ai_assistance: true } do
+ let(:viewer) { create(:user) }
+ let(:chat) { create(:ai_assistant_chat, user: viewer, work_package:) }
+
+ before do
+ cause["chat_id"] = chat.id
+ allow(User).to receive(:current).and_return(viewer)
+ end
+
+ it "links to the chat" do
+ expect(render(cause, html: true))
+ .to include("/ai_assistance/chat?chat_id=#{chat.id}",
+ 'data-controller="async-dialog"')
+ end
+
+ it "has no link in plain text" do
+ expect(render(cause, html: false)).not_to include("chat_id")
+ end
+
+ context "with the feature flag off", with_flag: { ai_assistance: false } do
+ it "has no link" do
+ expect(render(cause, html: true)).not_to include("chat_id")
+ end
+ end
+ end
+
+ context "when another user owns the chat", with_flag: { ai_assistance: true } do
+ let(:chat) { create(:ai_assistant_chat, work_package:) }
+
+ before do
+ cause["chat_id"] = chat.id
+ allow(User).to receive(:current).and_return(create(:user))
+ end
+
+ it "has no link" do
+ expect(render(cause, html: true)).not_to include("chat_id")
+ end
+ end
+ end
+
context "when the change was caused by working day changes" do
subject(:cause) do
{
diff --git a/spec/models/ai/assistant_proposal_spec.rb b/spec/models/ai/assistant_proposal_spec.rb
new file mode 100644
index 000000000000..cf3afe0dd559
--- /dev/null
+++ b/spec/models/ai/assistant_proposal_spec.rb
@@ -0,0 +1,69 @@
+# 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, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+require "spec_helper"
+
+RSpec.describe AI::AssistantProposal do
+ shared_let(:user) { create(:admin) }
+ shared_let(:work_package) { create(:work_package, description: "Old text") }
+
+ let(:chat) { create(:ai_assistant_chat, user:, work_package:) }
+ let(:message) { create(:ai_assistant_chat_message, chat:) }
+
+ it "orders findings by severity, with plain proposals last" do
+ low = create(:ai_assistant_proposal, :finding, message:, severity: "low")
+ plain = create(:ai_assistant_proposal, message:)
+ high = create(:ai_assistant_proposal, :finding, message:, severity: "high")
+
+ expect(message.reload.proposals).to eq([high, low, plain])
+ expect(high).to be_finding
+ expect(plain).not_to be_finding
+ end
+
+ it "counts the open proposals of the whole chat as siblings" do
+ first = create(:ai_assistant_proposal, message:)
+ other = create(:ai_assistant_proposal, message: create(:ai_assistant_chat_message, chat:))
+ create(:ai_assistant_proposal, message: create(:ai_assistant_chat_message))
+
+ expect(first.proposal_siblings).to contain_exactly(first, other)
+ end
+
+ it "is applied with a journal cause that names the assistant and the proposal" do
+ proposal = create(:ai_assistant_proposal, message:)
+
+ call = User.execute_as(user) { AI::Assistance::ApplyProposalService.new(proposal:, user:).call }
+
+ expect(call).to be_success
+ expect(work_package.reload.description).to eq("A better description")
+ expect(proposal.reload).to be_applied
+ expect(work_package.journals.last.cause).to include("type" => "assistant_chat", "proposal_id" => proposal.id,
+ "assistant_name" => chat.skill.assistant.name)
+ end
+end
diff --git a/spec/models/ai/assistant_skill_spec.rb b/spec/models/ai/assistant_skill_spec.rb
new file mode 100644
index 000000000000..14ae3d77bfb3
--- /dev/null
+++ b/spec/models/ai/assistant_skill_spec.rb
@@ -0,0 +1,185 @@
+# 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, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+require "spec_helper"
+
+RSpec.describe AI::AssistantSkill do
+ describe "conditions" do
+ shared_let(:project) { create(:project) }
+ shared_let(:other_project) { create(:project) }
+ shared_let(:feature) { create(:type) }
+ shared_let(:bug) { create(:type) }
+ shared_let(:in_progress) { create(:status) }
+ shared_let(:role) { create(:project_role) }
+
+ let(:user) { create(:user, member_with_roles: { project => role }) }
+ let(:work_package) { create(:work_package, project:, type: feature, status: in_progress) }
+ let(:skill) { build(:ai_assistant_skill, conditions:) }
+ let(:conditions) { {} }
+
+ it "matches every work package without conditions" do
+ expect(skill.conditions_fulfilled?(work_package, user)).to be(true)
+ end
+
+ it "keeps only the known keys and integer ids" do
+ skill.conditions = { "type_ids" => ["", feature.id.to_s, feature.id.to_s], "status_ids" => [""], "other" => [1] }
+
+ expect(skill.conditions).to eq("type_ids" => [feature.id])
+ end
+
+ it "drops the ids of deleted records when it is saved" do
+ gone = create(:type)
+ skill = create(:ai_assistant_skill, conditions: { type_ids: [feature.id, gone.id], status_ids: [in_progress.id] })
+ gone.destroy
+ skill.update!(conditions: { type_ids: [feature.id, gone.id], status_ids: [0] })
+
+ expect(skill.reload.conditions).to eq("type_ids" => [feature.id])
+ end
+
+ context "with a type condition" do
+ let(:conditions) { { type_ids: [bug.id] } }
+
+ it "matches only work packages of those types" do
+ expect(skill.conditions_fulfilled?(work_package, user)).to be(false)
+ expect(skill.conditions_fulfilled?(create(:work_package, project:, type: bug), user)).to be(true)
+ end
+ end
+
+ context "with a status and a project condition" do
+ let(:conditions) { { status_ids: [in_progress.id], project_ids: [other_project.id] } }
+
+ it "needs every condition to match" do
+ expect(skill.conditions_fulfilled?(work_package, user)).to be(false)
+
+ skill.conditions = { status_ids: [in_progress.id], project_ids: [project.id] }
+ expect(skill.conditions_fulfilled?(work_package, user)).to be(true)
+ end
+ end
+
+ context "with a role condition" do
+ let(:conditions) { { role_ids: [role.id] } }
+
+ it "matches users with that role in the work package's project" do
+ expect(skill.conditions_fulfilled?(work_package, user)).to be(true)
+ expect(skill.conditions_fulfilled?(work_package, create(:user))).to be(false)
+ end
+
+ it "matches admins, who can see and change every skill" do
+ expect(skill.conditions_fulfilled?(work_package, create(:admin))).to be(true)
+ end
+ end
+ end
+
+ describe "#system_prompt" do
+ let(:assistant) { build(:ai_assistant, instructions: "You help the COMMS team.") }
+ let(:skill) { build(:ai_assistant_skill, :preset, assistant:, prompt: "Write a test plan.") }
+
+ it "joins the assistant's instructions, the prompt and the generated output rules" do
+ expect(skill.system_prompt(locale: "en")).to eq(
+ "You help the COMMS team.\n\nWrite a test plan.\n\n#{AI::Assistance::OutputRules.new(skill, locale: 'en')}"
+ )
+ end
+
+ it "is nil without a prompt, while the full prompt still shows the rest" do
+ skill.prompt = ""
+
+ expect(skill.system_prompt(locale: "en")).to be_nil
+ expect(skill.full_system_prompt(locale: "en")).to start_with("You help the COMMS team.\n\nWrite \"answer\"")
+ end
+
+ it "explains the tools by default, and not when the skill opts out" do
+ expect(skill.use_tools).to be(true)
+ expect(skill.system_prompt(locale: "en")).to include(AI::Assistance::OutputRules::TOOLS)
+
+ skill.use_tools = false
+ expect(skill.system_prompt(locale: "en")).not_to include(AI::Assistance::OutputRules::TOOLS)
+ end
+ end
+
+ describe "#icon" do
+ let(:skill) { build(:ai_assistant_skill) }
+
+ it "uses the icon of the variant by default, and the chosen one when set" do
+ expect(skill.icon).to eq("light-bulb")
+ expect(build(:ai_assistant_skill, :chat).icon).to eq("comment-discussion")
+
+ skill.icon = "bug"
+ expect(skill.icon).to eq("bug")
+ end
+
+ it "accepts only the listed icons" do
+ skill.icon = "not-an-icon"
+
+ expect(skill).not_to be_valid
+ expect(skill.errors).to include(:icon)
+ end
+ end
+
+ describe "#offered_at?" do
+ let(:project) { create(:project) }
+ let(:user) { create(:user, member_with_permissions: { project => %i[view_work_packages] }) }
+ let(:work_package) { create(:work_package, project:) }
+ let(:skill) { create(:ai_assistant_skill) }
+ let(:everywhere) { create(:ai_assistant_skill, :chat, offered_on: "everywhere") }
+
+ def place(**) = AI::Assistance::Place.new(**)
+
+ it "offers every skill on a work package, and only Everywhere skills elsewhere" do
+ expect(skill.offered_at?(place(work_package:), user)).to be(true)
+ expect(skill.offered_at?(place(project:), user)).to be(false)
+ expect(everywhere.offered_at?(place(work_package:), user)).to be(true)
+ expect(everywhere.offered_at?(place(project:), user)).to be(true)
+ expect(everywhere.offered_at?(place, user)).to be(true)
+ end
+
+ it "offers a skill limited to projects only in those projects" do
+ everywhere.update!(conditions: { project_ids: [project.id] })
+
+ expect(everywhere.offered_at?(place(project:), user)).to be(true)
+ expect(everywhere.offered_at?(place(project: create(:project)), user)).to be(false)
+ expect(everywhere.offered_at?(place, user)).to be(false)
+ end
+
+ it "offers a skill limited to roles only in a project where the user has one" do
+ role = user.roles_for_project(project).first
+ everywhere.update!(conditions: { role_ids: [role.id] })
+
+ expect(everywhere.offered_at?(place(project:), user)).to be(true)
+ expect(everywhere.offered_at?(place, user)).to be(false)
+ end
+
+ it "drops type and status conditions from an Everywhere skill" do
+ everywhere.update!(conditions: { type_ids: [create(:type).id], status_ids: [create(:status).id],
+ project_ids: [project.id] })
+
+ expect(everywhere.reload.conditions.keys).to eq(%w[project_ids])
+ end
+ end
+end
diff --git a/spec/models/ai/assistant_spec.rb b/spec/models/ai/assistant_spec.rb
new file mode 100644
index 000000000000..89cf93157a9a
--- /dev/null
+++ b/spec/models/ai/assistant_spec.rb
@@ -0,0 +1,165 @@
+# 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, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+require "spec_helper"
+
+RSpec.describe AI::Assistant do
+ subject(:assistant) { build(:ai_assistant) }
+
+ it "requires a unique name" do
+ create(:ai_assistant, name: "QA assistance")
+ assistant.name = "QA assistance"
+
+ expect(assistant).not_to be_valid
+ expect(assistant.errors.symbols_for(:name)).to include(:taken)
+ end
+
+ it "destroys its skills" do
+ skill = create(:ai_assistant_skill, assistant: create(:ai_assistant))
+
+ skill.assistant.destroy
+
+ expect(AI::AssistantSkill.exists?(skill.id)).to be(false)
+ end
+
+ it "allows several variants of one skill type on an assistant" do
+ skill = create(:ai_assistant_skill)
+ variant = build(:ai_assistant_skill, assistant: skill.assistant, skill_key: skill.skill_key,
+ label: "Check scope")
+
+ expect(variant).to be_valid
+ end
+
+ describe "owners" do
+ let(:owner) { create(:user) }
+ let!(:global_assistant) { create(:ai_assistant, position: 2) }
+ let!(:personal_assistant) { create(:ai_assistant, principal: owner, position: 1) }
+
+ it "orders global assistants before personal ones" do
+ expect(described_class.ordered).to eq([global_assistant, personal_assistant])
+ end
+
+ it "shows a personal assistant only to its owner" do
+ expect(described_class.visible_to(owner)).to contain_exactly(global_assistant, personal_assistant)
+ expect(described_class.visible_to(create(:user))).to contain_exactly(global_assistant)
+ end
+
+ it "requires a unique name for each owner" do
+ expect(build(:ai_assistant, name: global_assistant.name, principal: owner)).to be_valid
+ expect(build(:ai_assistant, name: personal_assistant.name, principal: owner)).not_to be_valid
+ expect(build(:ai_assistant, name: personal_assistant.name)).to be_valid
+ end
+
+ it "never changes its owner" do
+ expect { personal_assistant.update(principal: create(:user)) }.to raise_error(ActiveRecord::ReadonlyAttributeError)
+ end
+
+ it "is deleted with its owner" do
+ owner.destroy
+
+ expect(described_class.exists?(personal_assistant.id)).to be(false)
+ expect(described_class.exists?(global_assistant.id)).to be(true)
+ end
+
+ describe "a group assistant" do
+ let(:member) { create(:user) }
+ let(:group) { create(:group, members: [member]) }
+ let!(:group_assistant) { create(:ai_assistant, principal: group, position: 3) }
+
+ it "shows to the members of its group only" do
+ expect(described_class.visible_to(member)).to contain_exactly(global_assistant, group_assistant)
+ expect(described_class.visible_to(owner)).not_to include(group_assistant)
+ end
+
+ it "is listed as a group assistant, not a personal one" do
+ expect(described_class.for_groups).to contain_exactly(group_assistant)
+ expect(described_class.personal).to contain_exactly(personal_assistant)
+ expect(group_assistant).to be_group
+ expect(group_assistant).to be_usable
+ end
+
+ it "is deleted with its group" do
+ group.destroy
+
+ expect(described_class.exists?(group_assistant.id)).to be(false)
+ end
+ end
+
+ it "accepts only a user or a group as its owner" do
+ expect(build(:ai_assistant, principal: create(:placeholder_user))).not_to be_valid
+ end
+
+ it "is usable only while its owner may manage their own assistants" do
+ expect(global_assistant).to be_usable
+ expect(personal_assistant).not_to be_usable
+
+ allowed = create(:user, global_permissions: %i[manage_own_ai_assistants])
+ expect(create(:ai_assistant, principal: allowed)).to be_usable
+ end
+ end
+
+ describe "#llm_resolution", with_flag: { ai_assistance: true, llm_connection: true },
+ with_settings: { llm_features_enabled: true } do
+ subject(:resolution) { assistant.llm_resolution }
+
+ let!(:connection) { create(:llm_connection, :with_models, default_chat_model_identifier: "qwen3.6-27b") }
+
+ before { create(:llm_model, llm_connection: connection, external_id: "bound-model") }
+
+ it "uses the connection default without a binding" do
+ expect(resolution).to be_ready
+ expect(resolution.model_id).to eq("qwen3.6-27b")
+ end
+
+ it "uses the bound model over the connection default" do
+ connection.feature_bindings.create!(feature_key: "ai_assistance", model_id: "bound-model")
+
+ expect(resolution.model_id).to eq("bound-model")
+ end
+
+ it "uses the assistant's model over the binding" do
+ connection.feature_bindings.create!(feature_key: "ai_assistance", model_id: "bound-model")
+ assistant.model_id = "qwen3.6-27b"
+
+ expect(resolution.model_id).to eq("qwen3.6-27b")
+ end
+
+ it "ignores an assistant model that the connection does not offer" do
+ assistant.model_id = "gone-model"
+
+ expect(resolution).to be_ready
+ expect(resolution.model_id).to eq("qwen3.6-27b")
+ end
+
+ it "is not ready while AI Assistance is off", with_flag: { ai_assistance: false } do
+ expect(resolution.status).to eq(:feature_disabled)
+ end
+ end
+end
diff --git a/spec/models/automations/actions/description_spec.rb b/spec/models/automations/actions/description_spec.rb
new file mode 100644
index 000000000000..2c711ab7d145
--- /dev/null
+++ b/spec/models/automations/actions/description_spec.rb
@@ -0,0 +1,69 @@
+# 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, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+require "spec_helper"
+require_relative "../shared_expectations"
+
+RSpec.describe Automations::Actions::Description do
+ let(:key) { :description }
+ let(:type) { :text_property }
+ let(:value) { "First line\n\nSecond paragraph" }
+
+ it_behaves_like "base custom action" do
+ describe "#apply" do
+ let(:work_package) { build_stubbed(:work_package) }
+
+ it "sets the description to the action's value" do
+ instance.values = ["First line\n\nSecond paragraph"]
+
+ instance.apply(work_package)
+
+ expect(work_package.description)
+ .to eql "First line\n\nSecond paragraph"
+ end
+
+ it "clears the description without a value" do
+ instance.values = []
+
+ instance.apply(work_package)
+
+ expect(work_package.description)
+ .to be_nil
+ end
+ end
+
+ describe "#required?" do
+ it "is false" do
+ expect(instance)
+ .not_to be_required
+ end
+ end
+ end
+end
diff --git a/spec/models/automations/actions/subject_spec.rb b/spec/models/automations/actions/subject_spec.rb
new file mode 100644
index 000000000000..3eee63d6325f
--- /dev/null
+++ b/spec/models/automations/actions/subject_spec.rb
@@ -0,0 +1,75 @@
+# 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, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+require "spec_helper"
+require_relative "../shared_expectations"
+
+RSpec.describe Automations::Actions::Subject do
+ let(:key) { :subject }
+ let(:type) { :string_property }
+ let(:value) { "A better subject" }
+
+ it_behaves_like "base custom action" do
+ describe "#apply" do
+ let(:work_package) { build_stubbed(:work_package) }
+
+ it "sets the subject to the action's value" do
+ instance.values = ["A better subject"]
+
+ instance.apply(work_package)
+
+ expect(work_package.subject)
+ .to eql "A better subject"
+ end
+ end
+
+ describe "#required?" do
+ it "is true" do
+ expect(instance)
+ .to be_required
+ end
+ end
+
+ describe "#validate" do
+ let(:errors) do
+ build_stubbed(:automation).errors
+ end
+
+ it "is invalid without a value" do
+ instance.values = []
+
+ instance.validate(errors)
+
+ expect(errors.symbols_for(:actions))
+ .to include(:empty)
+ end
+ end
+ end
+end
diff --git a/spec/requests/admin/ai_assistants_spec.rb b/spec/requests/admin/ai_assistants_spec.rb
new file mode 100644
index 000000000000..cd2cf1ef2200
--- /dev/null
+++ b/spec/requests/admin/ai_assistants_spec.rb
@@ -0,0 +1,212 @@
+# 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, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+require "spec_helper"
+
+RSpec.describe "Admin AI assistants", :skip_csrf, type: :rails_request, with_flag: { ai_assistance: true } do
+ shared_let(:admin) { create(:admin) }
+ shared_let(:owner) { create(:user, firstname: "Ada", lastname: "Owner") }
+ shared_let(:global_assistant) { create(:ai_assistant, name: "QA assistance") }
+ shared_let(:personal_assistant) { create(:ai_assistant, name: "My helper", principal: owner) }
+
+ current_user { admin }
+
+ describe "GET /admin/ai_assistants" do
+ it "lists one section per assistant, global first, with its owner linked and its skills" do
+ skill = create(:ai_assistant_skill, assistant: global_assistant, label: "Check it", offered_on: "everywhere")
+ group = create(:group, lastname: "Release team")
+ create(:ai_assistant, name: "Team helper", principal: group)
+
+ get admin_ai_assistants_path
+
+ expect(response).to have_http_status(:ok)
+ page = response.parsed_body
+ expect(page.css("[data-test-selector='ai-assistant-section']").size).to eq(3)
+ expect(response.body.index("QA assistance")).to be < response.body.index("Team helper")
+ expect(response.body.index("Team helper")).to be < response.body.index("My helper")
+ owners = page.css("[data-test-selector='ai-assistant-owner']")
+ expect(owners.map { it.text.squish }).to eq(["Global", "Release team", "Ada Owner"])
+ expect(owners[1].at_css("a")["href"]).to eq(show_group_path(group))
+ expect(owners[2].at_css("a")["href"]).to eq(user_path(owner))
+ expect(page.at_css("a[href='#{edit_admin_ai_assistant_skill_path(global_assistant, skill)}']").text).to include("Check it")
+ end
+
+ it "deletes a skill from the list and returns to the list" do
+ skill = create(:ai_assistant_skill, assistant: global_assistant)
+
+ delete admin_ai_assistant_skill_path(global_assistant, skill), params: { back: "list" }
+
+ expect(response).to redirect_to(admin_ai_assistants_path)
+ expect(AI::AssistantSkill.exists?(skill.id)).to be(false)
+ end
+ end
+
+ describe "copying" do
+ let!(:skill) do
+ create(:ai_assistant_skill, assistant: global_assistant, label: "Check it", icon: "bug",
+ conditions: { project_ids: [create(:project).id] })
+ end
+
+ it "copies an assistant with its skills, inactive and for the same owner, and opens the copy" do
+ post copy_admin_ai_assistant_path(global_assistant)
+
+ copy = AI::Assistant.find_by!(name: "QA assistance (copy)")
+ expect(response).to redirect_to(edit_admin_ai_assistant_path(copy))
+ expect(copy).to have_attributes(active: false, principal: nil, instructions: global_assistant.instructions)
+ expect(copy.skills.map { [it.label, it.icon, it.active, it.conditions] })
+ .to eq([["Check it", "bug", true, skill.conditions]])
+ expect(global_assistant.skills.reload.size).to eq(1)
+
+ post copy_admin_ai_assistant_path(global_assistant)
+ expect(AI::Assistant.where(name: "QA assistance (copy 2)")).to exist
+ end
+
+ it "copies a skill into the same assistant, renamed and inactive, and opens the copy" do
+ post copy_admin_ai_assistant_skill_path(global_assistant, skill)
+
+ copy = global_assistant.skills.reload.last
+ expect(response).to redirect_to(edit_admin_ai_assistant_skill_path(global_assistant, copy))
+ expect(copy).to have_attributes(label: "Check it (copy)", icon: "bug", active: false, prompt: skill.prompt)
+ expect(copy.position).to be > skill.position
+ end
+
+ it "keeps the owner of a personal assistant" do
+ post copy_admin_ai_assistant_path(personal_assistant)
+
+ expect(AI::Assistant.find_by!(name: "My helper (copy)").principal).to eq(owner)
+ end
+ end
+
+ describe "a personal assistant" do
+ it "can be edited with a model, and keeps its owner" do
+ get edit_admin_ai_assistant_path(personal_assistant)
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("personal assistant of Ada Owner", "ai_assistant[model_id]")
+
+ patch admin_ai_assistant_path(personal_assistant),
+ params: { ai_assistant: { name: "Renamed", model_id: "test-model", active: "0" } }
+
+ expect(personal_assistant.reload).to have_attributes(name: "Renamed", model_id: "test-model",
+ active: false, principal: owner)
+ end
+ end
+
+ describe "skill conditions" do
+ let(:skill) { create(:ai_assistant_skill, assistant: global_assistant, conditions: { status_ids: [1] }) }
+ let(:type) { create(:type) }
+
+ it "stores the chosen ids, and a cleared input clears its condition" do
+ patch admin_ai_assistant_skill_path(global_assistant, skill),
+ params: { ai_assistant_skill: { label: "Checked" },
+ condition_type_ids: ["", type.id.to_s], condition_status_ids: [""] }
+
+ expect(skill.reload.conditions).to eq("type_ids" => [type.id])
+
+ get edit_admin_ai_assistant_path(global_assistant)
+ expect(page).to have_css("[data-test-selector='ai-skill-conditions']", text: "1 type")
+ end
+ end
+
+ describe "the icon of a skill" do
+ let(:skill) { create(:ai_assistant_skill, assistant: global_assistant) }
+
+ it "stores the chosen icon, and an empty choice uses the default again" do
+ patch admin_ai_assistant_skill_path(global_assistant, skill), params: { ai_assistant_skill: { icon: "bug" } }
+ expect(skill.reload.icon).to eq("bug")
+
+ patch admin_ai_assistant_skill_path(global_assistant, skill), params: { ai_assistant_skill: { icon: "default" } }
+ expect(skill.reload.icon).to eq("light-bulb")
+ expect(skill.chosen_icon).to be_nil
+ end
+
+ it "shows the icon menu left of the label" do
+ skill.update!(icon: "bug")
+
+ get edit_admin_ai_assistant_skill_path(global_assistant, skill)
+
+ expect(response.body).to include('data-test-selector="ai-assistance-icon-picker"', "octicon-bug",
+ 'name="ai_assistant_skill[label]"')
+ end
+ end
+
+ describe "the tools setting of a skill" do
+ let(:skill) { create(:ai_assistant_skill, assistant: global_assistant) }
+
+ it "stores the opt-out" do
+ patch admin_ai_assistant_skill_path(global_assistant, skill), params: { ai_assistant_skill: { use_tools: "0" } }
+
+ expect(skill.reload.use_tools).to be(false)
+ end
+
+ it "previews the system prompt for both states, and shows the saved one" do
+ skill.update!(prompt: "Check it.", use_tools: false)
+
+ get edit_admin_ai_assistant_skill_path(global_assistant, skill)
+
+ expect(page).to have_css("[data-test-selector='ai-skill-prompt-preview'].d-none",
+ text: AI::Assistance::OutputRules::TOOLS, visible: :all)
+ expect(page).to have_css("[data-test-selector='ai-skill-prompt-preview-without-tools']:not(.d-none)",
+ text: "Check it.", visible: :all)
+ end
+ end
+
+ describe "POST /admin/ai_assistants" do
+ it "creates a global assistant" do
+ post admin_ai_assistants_path, params: { ai_assistant: { name: "Release helper" } }
+
+ expect(AI::Assistant.find_by!(name: "Release helper").principal).to be_nil
+ end
+
+ it "creates an assistant for a group" do
+ group = create(:group, lastname: "Release team")
+
+ post admin_ai_assistants_path, params: { ai_assistant: { name: "Release helper", principal_id: group.id } }
+
+ expect(AI::Assistant.find_by!(name: "Release helper").principal).to eq(group)
+ end
+
+ it "does not make a user the owner" do
+ post admin_ai_assistants_path, params: { ai_assistant: { name: "Release helper", principal_id: owner.id } }
+
+ expect(AI::Assistant.find_by!(name: "Release helper").principal).to be_nil
+ end
+ end
+
+ describe "a group assistant" do
+ it "keeps its group on update" do
+ group = create(:group, lastname: "Release team")
+ assistant = create(:ai_assistant, name: "Team helper", principal: group)
+
+ patch admin_ai_assistant_path(assistant), params: { ai_assistant: { name: "Renamed", principal_id: "" } }
+ expect(assistant.reload).to have_attributes(name: "Renamed", principal: group)
+ end
+ end
+end
diff --git a/spec/requests/ai_assistance/chats_spec.rb b/spec/requests/ai_assistance/chats_spec.rb
new file mode 100644
index 000000000000..b92ff9a1338b
--- /dev/null
+++ b/spec/requests/ai_assistance/chats_spec.rb
@@ -0,0 +1,326 @@
+# 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, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+require "spec_helper"
+
+RSpec.describe "AI Assistance chats", :skip_csrf, type: :rails_request, with_flag: { ai_assistance: true } do
+ include_context "with an available LLM for AI Assistance"
+
+ shared_let(:project) { create(:project) }
+ shared_let(:skill) { create(:ai_assistant_skill) }
+
+ let(:permissions) { %i[view_work_packages edit_work_packages use_ai_assistance] }
+ let(:user) { create(:user, member_with_permissions: { project => permissions }) }
+ let(:work_package) { create(:work_package, project:, description: "Old text") }
+ let(:base) { "/ai_assistance/chat" }
+ let(:place) { { work_package_id: work_package.id } }
+
+ current_user { user }
+
+ describe "POST start" do
+ subject(:request) { post "#{base}/start", params: { **place, skill_id: skill.id }, as: :turbo_stream }
+
+ it "starts a conversation without a question and opens the chat dialog" do
+ expect { request }.to have_enqueued_job(AI::AssistantChats::ReplyJob)
+
+ expect(response).to have_http_status(:ok)
+ expect(response).to have_turbo_stream(action: "dialog")
+ chat = AI::AssistantChat.for(user, AI::Assistance::Place.new(work_package:)).sole
+ expect(chat.messages.map(&:role)).to eq(%w[start assistant])
+ end
+
+ context "when the last start of the skill is still answering" do
+ it "opens that chat and starts nothing new" do
+ post "#{base}/start", params: { **place, skill_id: skill.id }, as: :turbo_stream
+
+ expect { request }.not_to have_enqueued_job(AI::AssistantChats::ReplyJob)
+
+ expect(response).to have_turbo_stream(action: "dialog")
+ expect(AI::AssistantChat.for(user, AI::Assistance::Place.new(work_package:)).sole.messages.count).to eq(2)
+ end
+
+ it "starts a new chat once the answer is done" do
+ post "#{base}/start", params: { **place, skill_id: skill.id }, as: :turbo_stream
+ AI::AssistantChatMessage.pending.update_all(status: "done")
+
+ expect { request }.to have_enqueued_job(AI::AssistantChats::ReplyJob)
+ expect(AI::AssistantChat.for(user, AI::Assistance::Place.new(work_package:)).count).to eq(2)
+ end
+ end
+
+ context "without the permission" do
+ let(:permissions) { %i[view_work_packages] }
+
+ it "is forbidden and stores nothing" do
+ request
+
+ expect(response).to have_http_status(:forbidden)
+ expect(AI::AssistantChat.count).to eq(0)
+ end
+ end
+
+ context "with a personal skill of another user" do
+ let(:owner) { create(:user, global_permissions: %i[manage_own_ai_assistants]) }
+ let(:personal_skill) { create(:ai_assistant_skill, :preset, assistant: create(:ai_assistant, principal: owner)) }
+
+ it "responds with not found and stores nothing" do
+ post "#{base}/start", params: { **place, skill_id: personal_skill.id }, as: :turbo_stream
+
+ expect(response).to have_http_status(:not_found)
+ expect(AI::AssistantChat.count).to eq(0)
+ end
+ end
+
+ context "when the skill is inactive" do
+ before { skill.update!(active: false) }
+
+ after { skill.update!(active: true) }
+
+ it "responds with not found" do
+ request
+
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+
+ context "with the feature flag off", with_flag: { ai_assistance: false } do
+ it "responds with not found" do
+ request
+
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+ end
+
+ describe "POST rerun" do
+ let(:chat) { create(:ai_assistant_chat, skill:, user:, work_package:) }
+
+ before do
+ create(:ai_assistant_chat_message, chat:, role: "start", content: "")
+ create(:ai_assistant_chat_message, chat:)
+ end
+
+ it "starts the skill again in the same chat" do
+ expect { post "#{base}/rerun", params: { chat_id: chat.id }, as: :turbo_stream }
+ .to have_enqueued_job(AI::AssistantChats::ReplyJob)
+
+ expect(chat.messages.reload.map(&:role)).to eq(%w[start assistant start assistant])
+ end
+
+ context "with a chat skill" do
+ let(:chat) { create(:ai_assistant_chat, skill: create(:ai_assistant_skill, :chat), user:, work_package:) }
+
+ it "responds with not found" do
+ post "#{base}/rerun", params: { chat_id: chat.id }, as: :turbo_stream
+
+ expect(response).to have_http_status(:not_found)
+ expect(chat.messages.count).to eq(2)
+ end
+ end
+ end
+
+ describe "GET poll" do
+ let(:chat) { create(:ai_assistant_chat, skill:, user:, work_package:) }
+ let!(:answer) { create(:ai_assistant_chat_message, chat:, status: "pending", content: "") }
+
+ before do
+ answer.log_event(:tool_call, name: "count_work_packages", arguments: { project_id: project.id, open: true },
+ seconds: 0.1, size: 12)
+ end
+
+ it "shows the tool calls of a pending answer, without the form" do
+ get "#{base}/poll", params: { chat_id: chat.id, reference: "0" }, as: :turbo_stream
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("Count work packages (open: true, project_id: #{project.id})")
+ expect(response.body).to include(%(data-poll-for-changes-reference-value="1"))
+ expect(response.body).not_to include(AI::Assistance::ChatFormComponent.wrapper_key)
+ end
+
+ it "answers with no content while nothing new happened" do
+ get "#{base}/poll", params: { chat_id: chat.id, reference: "1" }, as: :turbo_stream
+
+ expect(response).to have_http_status(:no_content)
+ end
+ end
+
+ describe "GET show with a chat_id" do
+ let(:chat) { create(:ai_assistant_chat, skill:, user:, work_package:) }
+ let!(:answer) { create(:ai_assistant_chat_message, chat:) }
+
+ before { create(:ai_assistant_proposal, message: answer) }
+
+ it "opens the chat with the form and the Apply button" do
+ get base, params: { chat_id: chat.id }, as: :turbo_stream
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include('data-test-selector="ai-assistance-chat-form"', 'data-test-selector="ai-assistance-apply"')
+ end
+
+ context "when the skill is not offered any more" do
+ before { skill.update!(active: false) }
+
+ after { skill.update!(active: true) }
+
+ it "opens the chat read-only" do
+ get base, params: { chat_id: chat.id }, as: :turbo_stream
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("ai-assistance-chat-read-only", answer.content)
+ %w[ai-assistance-chat-form ai-assistance-apply ai-assistance-dismiss].each do |selector|
+ expect(response.body).not_to include(%(data-test-selector="#{selector}"))
+ end
+ end
+
+ it "does not accept a new question" do
+ post "#{base}/messages", params: { chat_id: chat.id, content: "More?" }, as: :turbo_stream
+
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+
+ context "with another user's chat" do
+ let(:chat) { create(:ai_assistant_chat, skill:, work_package:) }
+
+ it "responds with not found" do
+ get base, params: { chat_id: chat.id }, as: :turbo_stream
+
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+ end
+
+ describe "GET history" do
+ let!(:own_chat) { create(:ai_assistant_chat, skill:, user:, work_package:) }
+ let!(:other_chat) { create(:ai_assistant_chat, skill:, work_package:) }
+
+ before do
+ create(:ai_assistant_chat_message, chat: own_chat, role: "user", content: "Mine")
+ create(:ai_assistant_chat_message, chat: other_chat, role: "user", content: "Theirs")
+ end
+
+ it "lists only the user's chats" do
+ get "#{base}/history", params: place, as: :turbo_stream
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("chat_id=#{own_chat.id}")
+ expect(response.body).not_to include("chat_id=#{other_chat.id}")
+ end
+ end
+
+ describe "a proposal" do
+ let(:chat) { create(:ai_assistant_chat, skill:, user:, work_package:) }
+ let(:message) { create(:ai_assistant_chat_message, chat:) }
+ let(:proposal) { create(:ai_assistant_proposal, :finding, message:) }
+
+ it "is applied" do
+ post "#{base}/proposals/#{proposal.id}/apply", as: :turbo_stream
+
+ expect(proposal.reload).to be_applied
+ expect(work_package.reload.description).to eq("A better description")
+ end
+
+ it "is dismissed with the reason, and an unknown reason is ignored" do
+ post "#{base}/proposals/#{proposal.id}/dismiss", params: { reason: "wrong" }, as: :turbo_stream
+
+ expect(proposal.reload).to have_attributes(state: "dismissed", dismiss_reason: "wrong")
+
+ other = create(:ai_assistant_proposal, message:)
+ post "#{base}/proposals/#{other.id}/dismiss", params: { reason: "bored" }, as: :turbo_stream
+
+ expect(other.reload).to have_attributes(state: "dismissed", dismiss_reason: nil)
+ end
+
+ context "with another user's chat" do
+ let(:chat) { create(:ai_assistant_chat, skill:, work_package:) }
+
+ it "responds with not found" do
+ post "#{base}/proposals/#{proposal.id}/dismiss", as: :turbo_stream
+
+ expect(response).to have_http_status(:not_found)
+ expect(proposal.reload).to be_open
+ end
+ end
+ end
+
+ describe "a chat off a work package" do
+ let(:chat_skill) { create(:ai_assistant_skill, :chat, assistant: skill.assistant, offered_on: "everywhere") }
+
+ it "opens an empty chat on a project page and stores the question with the project" do
+ get base, params: { project_id: project.identifier, skill_id: chat_skill.id }, as: :turbo_stream
+ expect(response).to have_turbo_stream(action: "dialog")
+ expect(response.body).to include(I18n.t("ai_assistance.chat.empty_title_generic"))
+
+ params = { project_id: project.identifier, skill_id: chat_skill.id, content: "Hi" }
+ expect { post "#{base}/messages", params:, as: :turbo_stream }.to have_enqueued_job(AI::AssistantChats::ReplyJob)
+
+ chat = AI::AssistantChat.where(user:).sole
+ expect(chat).to have_attributes(project:, work_package: nil)
+ end
+
+ it "keeps a chat without a project apart from the project's chats" do
+ create(:ai_assistant_chat, skill: chat_skill, user:, work_package: nil, project:)
+
+ post "#{base}/messages", params: { skill_id: chat_skill.id, content: "Hi" }, as: :turbo_stream
+
+ expect(AI::AssistantChat.where(user:, project: nil, work_package: nil).count).to eq(1)
+ end
+
+ it "does not offer a work package skill" do
+ post "#{base}/start", params: { project_id: project.identifier, skill_id: skill.id }, as: :turbo_stream
+
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+
+ describe "History across places" do
+ let(:chat_skill) { create(:ai_assistant_skill, :chat, assistant: skill.assistant, offered_on: "everywhere") }
+ let!(:work_package_chat) { create(:ai_assistant_chat, skill:, user:, work_package:) }
+ let!(:project_chat) { create(:ai_assistant_chat, skill: chat_skill, user:, work_package: nil, project:) }
+
+ before do
+ create(:ai_assistant_chat_message, chat: work_package_chat)
+ create(:ai_assistant_chat_message, chat: project_chat, role: "user", content: "About the project")
+ end
+
+ it "lists all the user's chats with their place" do
+ get "#{base}/history", as: :turbo_stream
+
+ expect(response.body).to include(work_package.formatted_id, project.name, "About the project")
+ end
+
+ it "opens a work package chat from a page without a project" do
+ get base, params: { chat_id: work_package_chat.id }, as: :turbo_stream
+
+ expect(response).to have_turbo_stream(action: "dialog")
+ expect(response.body).to include('data-test-selector="ai-assistance-chat-form"')
+ end
+ end
+end
diff --git a/spec/requests/ai_assistance/menus_spec.rb b/spec/requests/ai_assistance/menus_spec.rb
new file mode 100644
index 000000000000..2bf90f57765d
--- /dev/null
+++ b/spec/requests/ai_assistance/menus_spec.rb
@@ -0,0 +1,237 @@
+# 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, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+require "rails_helper"
+
+RSpec.describe "AI Assistance menu", type: :rails_request, with_flag: { ai_assistance: true } do
+ include_context "with an available LLM for AI Assistance"
+
+ shared_let(:project) { create(:project) }
+ shared_let(:assistant) { create(:ai_assistant, name: "QA assistance") }
+ shared_let(:skill) { create(:ai_assistant_skill, assistant:) }
+
+ let(:permissions) { %i[view_work_packages use_ai_assistance] }
+ let(:user) { create(:user, member_with_permissions: { project => permissions }) }
+ let(:work_package) { create(:work_package, project:) }
+
+ current_user { user }
+
+ describe "GET show" do
+ it "renders the menu button in the frame" do
+ get "/ai_assistance/menu", params: { work_package_id: work_package.id }
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include('