From d5e404d676b1fc93a772d59c906005bef3d714bb Mon Sep 17 00:00:00 2001 From: hanna-paasivirta Date: Wed, 7 Oct 2026 15:12:44 +0100 Subject: [PATCH 1/3] Pass Apollo's history back unchanged --- lib/lightning/ai_assistant/ai_assistant.ex | 30 +++-- lib/lightning/ai_assistant/chat_session.ex | 5 +- ...add_apollo_history_to_ai_chat_sessions.exs | 9 ++ .../ai_assistant/ai_assistant_test.exs | 107 ++++++++++++++++++ 4 files changed, 143 insertions(+), 8 deletions(-) create mode 100644 priv/repo/migrations/20261005181455_add_apollo_history_to_ai_chat_sessions.exs diff --git a/lib/lightning/ai_assistant/ai_assistant.ex b/lib/lightning/ai_assistant/ai_assistant.ex index 32a668e24b6..a8eaa53d794 100644 --- a/lib/lightning/ai_assistant/ai_assistant.ex +++ b/lib/lightning/ai_assistant/ai_assistant.ex @@ -656,6 +656,7 @@ defmodule Lightning.AiAssistant do - `:usage` - Map containing AI usage metrics (default: `%{}`) - `:meta` - Session metadata to update (default: keeps existing) - `:code` - Optional workflow code to attach to the message + - `:apollo_history` - Apollo's history to store on the session (default: keeps existing) ## Returns @@ -668,6 +669,7 @@ defmodule Lightning.AiAssistant do usage = Keyword.get(opts, :usage, %{}) meta = Keyword.get(opts, :meta) code = Keyword.get(opts, :code) + apollo_history = Keyword.get(opts, :apollo_history) message_attrs = prepare_message_attrs(message_attrs, session, code) @@ -678,7 +680,7 @@ defmodule Lightning.AiAssistant do ChatMessage.changeset(%ChatMessage{}, message_attrs) ) |> Multi.update(:session, fn %{message: _message} -> - update_session_meta(session, meta) + update_session_meta(session, meta, apollo_history) end) |> Multi.merge(&maybe_increment_ai_usage/1) |> Multi.run(:enqueue_if_user_message, &enqueue_user_message/2) @@ -734,14 +736,19 @@ defmodule Lightning.AiAssistant do end end - defp update_session_meta(session, nil), - do: ChatSession.meta_changeset(session, %{meta: session.meta}) + defp update_session_meta(session, meta, apollo_history) do + attrs = + %{meta: Map.merge(session.meta || %{}, meta || %{})} + |> put_apollo_history(apollo_history) - defp update_session_meta(session, meta) do - merged_meta = Map.merge(session.meta || %{}, meta) - ChatSession.meta_changeset(session, %{meta: merged_meta}) + ChatSession.meta_changeset(session, attrs) end + defp put_apollo_history(attrs, history) when is_list(history), + do: Map.put(attrs, :apollo_history, history) + + defp put_apollo_history(attrs, _history), do: attrs + defp enqueue_user_message(_repo, %{message: message}) do if message.role == :user && message.status == :pending do Oban.insert( @@ -1167,7 +1174,12 @@ defmodule Lightning.AiAssistant do case acc do %{complete: payload} when is_map(payload) -> {message_attrs, opts} = message_builder.(payload) - save_message(session, message_attrs, opts) + + save_message( + session, + message_attrs, + Keyword.put(opts, :apollo_history, payload["history"]) + ) _ -> save_partial_response(session, acc) @@ -1857,6 +1869,10 @@ defmodule Lightning.AiAssistant do defp extract_global_workflow_yaml(_), do: nil + defp build_history(%{apollo_history: history}) when is_list(history), + do: history + + # Sessions from before Apollo's history was stored. defp build_history(session) do messages = session.messages || [] diff --git a/lib/lightning/ai_assistant/chat_session.ex b/lib/lightning/ai_assistant/chat_session.ex index 4756cb53b78..521891f7cda 100644 --- a/lib/lightning/ai_assistant/chat_session.ex +++ b/lib/lightning/ai_assistant/chat_session.ex @@ -31,6 +31,7 @@ defmodule Lightning.AiAssistant.ChatSession do is_public: boolean(), is_deleted: boolean(), meta: map() | nil, + apollo_history: [map()] | nil, message_count: integer() | nil, messages: [ChatMessage.t()] | [] } @@ -39,6 +40,8 @@ defmodule Lightning.AiAssistant.ChatSession do field :title, :string field :session_type, :string, default: "job_code" field :meta, :map, default: %{} + # Apollo curates this; it goes back to Apollo verbatim on the next turn. + field :apollo_history, {:array, :map} field :is_public, :boolean, default: false field :is_deleted, :boolean, default: false @@ -80,7 +83,7 @@ defmodule Lightning.AiAssistant.ChatSession do @doc false def meta_changeset(chat_session, attrs) do chat_session - |> cast(attrs, [:meta]) + |> cast(attrs, [:meta, :apollo_history]) end defp validate_session_type_requirements(changeset) do diff --git a/priv/repo/migrations/20261005181455_add_apollo_history_to_ai_chat_sessions.exs b/priv/repo/migrations/20261005181455_add_apollo_history_to_ai_chat_sessions.exs new file mode 100644 index 00000000000..78733ba9007 --- /dev/null +++ b/priv/repo/migrations/20261005181455_add_apollo_history_to_ai_chat_sessions.exs @@ -0,0 +1,9 @@ +defmodule Lightning.Repo.Migrations.AddApolloHistoryToAiChatSessions do + use Ecto.Migration + + def change do + alter table(:ai_chat_sessions) do + add :apollo_history, :jsonb + end + end +end diff --git a/test/lightning/ai_assistant/ai_assistant_test.exs b/test/lightning/ai_assistant/ai_assistant_test.exs index f2d2ea89bcf..08e2e991a01 100644 --- a/test/lightning/ai_assistant/ai_assistant_test.exs +++ b/test/lightning/ai_assistant/ai_assistant_test.exs @@ -99,6 +99,113 @@ defmodule Lightning.AiAssistantTest do end end + describe "query_global_stream/3 — Apollo history" do + setup do + Mox.stub(Lightning.MockConfig, :apollo, fn key -> + case key do + :endpoint -> "http://localhost:3000" + :ai_assistant_api_key -> "api_key" + :connect_timeout -> 1_000 + :idle_timeout -> 5_000 + :request_timeout -> 5_000 + end + end) + + :ok + end + + test "sends the stored history verbatim and stores the returned one", %{ + user: user, + project: project, + workflow: workflow + } do + stored_history = [ + %{"role" => "user", "content" => "[pg:workflow] hi"}, + %{"role" => "assistant", "content" => "hello"} + ] + + session = + insert(:chat_session, + user: user, + project: project, + workflow: workflow, + session_type: "workflow_template", + apollo_history: stored_history, + messages: [ + %{role: :user, content: "hi", user: user}, + %{role: :assistant, content: "hello"}, + %{role: :user, content: "help", user: user, status: :pending} + ] + ) + + returned_history = + stored_history ++ + [ + %{"role" => "user", "content" => "[pg:workflow] help"}, + %{"role" => "assistant", "content" => "ok"} + ] + + complete_payload = + Jason.encode!(%{ + "response" => "ok", + "attachments" => [], + "usage" => %{}, + "history" => returned_history + }) + + expect(Lightning.Tesla.Mock, :call, fn %{body: body}, _opts -> + assert Jason.decode!(body)["history"] == stored_history + + {:ok, + %Tesla.Env{ + status: 200, + body: [%{event: "complete", data: complete_payload}] + }} + end) + + assert {:ok, updated_session} = + AiAssistant.query_global_stream(session, "help") + + assert updated_session.apollo_history == returned_history + end + + test "keeps the stored history when the response has none", %{ + user: user, + project: project, + workflow: workflow + } do + stored_history = [%{"role" => "user", "content" => "hi"}] + + session = + insert(:chat_session, + user: user, + project: project, + workflow: workflow, + session_type: "workflow_template", + apollo_history: stored_history, + messages: [ + %{role: :user, content: "help", user: user, status: :pending} + ] + ) + + complete_payload = + Jason.encode!(%{"response" => "ok", "attachments" => [], "usage" => %{}}) + + expect(Lightning.Tesla.Mock, :call, fn _env, _opts -> + {:ok, + %Tesla.Env{ + status: 200, + body: [%{event: "complete", data: complete_payload}] + }} + end) + + assert {:ok, updated_session} = + AiAssistant.query_global_stream(session, "help") + + assert updated_session.apollo_history == stored_history + end + end + describe "query_stream/3 — context options" do setup do Mox.stub(Lightning.MockConfig, :apollo, fn key -> From db26914e76e5193b07ccc70076e4e78c53a7395f Mon Sep 17 00:00:00 2001 From: hanna-paasivirta Date: Wed, 30 Sep 2026 17:32:55 +0100 Subject: [PATCH 2/3] Send slash-command skills to global chat --- lib/lightning/ai_assistant/ai_assistant.ex | 4 +- .../ai_assistant/message_processor.ex | 16 +++++- lib/lightning/apollo_client.ex | 4 ++ .../ai_assistant/message_processor_test.exs | 49 ++++++++++++++++++- test/lightning/apollo_client_test.exs | 17 +++++++ 5 files changed, 86 insertions(+), 4 deletions(-) diff --git a/lib/lightning/ai_assistant/ai_assistant.ex b/lib/lightning/ai_assistant/ai_assistant.ex index a8eaa53d794..beff2f37824 100644 --- a/lib/lightning/ai_assistant/ai_assistant.ex +++ b/lib/lightning/ai_assistant/ai_assistant.ex @@ -985,6 +985,7 @@ defmodule Lightning.AiAssistant do workflow_yaml = Keyword.get(opts, :workflow_yaml) page = Keyword.get(opts, :page) attachments = Keyword.get(opts, :attachments, []) + skill = Keyword.get(opts, :skill) history = build_history(session) Logger.metadata(prompt_size: byte_size(content), session_id: session.id) @@ -997,7 +998,8 @@ defmodule Lightning.AiAssistant do history: history, meta: meta, metrics_opt_in: metrics_opt_in, - attachments: attachments + attachments: attachments, + skill: skill ) do {:ok, %Tesla.Env{status: status, body: body}} when status in @success_status_range -> diff --git a/lib/lightning/ai_assistant/message_processor.ex b/lib/lightning/ai_assistant/message_processor.ex index b1add71c87a..274bf94cac6 100644 --- a/lib/lightning/ai_assistant/message_processor.ex +++ b/lib/lightning/ai_assistant/message_processor.ex @@ -167,10 +167,24 @@ defmodule Lightning.AiAssistant.MessageProcessor do AiAssistant.query_global_stream(session, message.content, workflow_yaml: workflow_yaml, page: page, - attachments: build_attachments(session) + attachments: build_attachments(session), + skill: detect_skill(message.content) ) end + # One skill per message, named by a slash command at the very start of it. + # The command stays in the content: Apollo strips it itself. + @spec detect_skill(String.t()) :: String.t() | nil + defp detect_skill("/" <> rest) do + name = rest |> String.split(~r/\s/, parts: 2) |> hd() + + if name in known_skills(), do: name + end + + defp detect_skill(_content), do: nil + + defp known_skills, do: ~w(diagnose qa) + # Run context the user ticked on the chat input; an unresolvable source is # omitted rather than sent as an empty attachment. @spec build_attachments(AiAssistant.ChatSession.t()) :: [map()] diff --git a/lib/lightning/apollo_client.ex b/lib/lightning/apollo_client.ex index b75a9644179..df353db7290 100644 --- a/lib/lightning/apollo_client.ex +++ b/lib/lightning/apollo_client.ex @@ -152,6 +152,8 @@ defmodule Lightning.ApolloClient do on the Apollo side. Omitted from the wire payload when not supplied. - `:attachments` - Run context the user chose to send, as a list of `%{"type" => type, "content" => content}` maps (default: `[]`) + - `:skill` - Name of the skill the user invoked by slash command + (optional). `content` still carries the command; Apollo strips it. """ @spec global_chat_stream(String.t(), opts()) :: Tesla.Env.result() def global_chat_stream(content, opts \\ []) do @@ -161,6 +163,7 @@ defmodule Lightning.ApolloClient do meta = Keyword.get(opts, :meta) metrics_opt_in = Keyword.get(opts, :metrics_opt_in) attachments = Keyword.get(opts, :attachments, []) + skill = Keyword.get(opts, :skill) payload = %{ @@ -172,6 +175,7 @@ defmodule Lightning.ApolloClient do "meta" => meta, "metrics_opt_in" => metrics_opt_in, "attachments" => attachments, + "skill" => skill && %{"name" => skill}, "options" => %{"stream" => true} } |> Enum.reject(fn {_, v} -> is_nil(v) end) diff --git a/test/lightning/ai_assistant/message_processor_test.exs b/test/lightning/ai_assistant/message_processor_test.exs index 99b6b488d48..2b013fc042b 100644 --- a/test/lightning/ai_assistant/message_processor_test.exs +++ b/test/lightning/ai_assistant/message_processor_test.exs @@ -1116,6 +1116,51 @@ defmodule Lightning.AiAssistant.MessageProcessorTest do end end + describe "skills via process_global_message/2" do + test "names the skill a leading slash command invoked", %{ + user: user, + project: project + } do + assert_skill(user, project, "/diagnose why did this fail?", %{ + "name" => "diagnose" + }) + end + + test "names the skill when the command is the whole message", %{ + user: user, + project: project + } do + assert_skill(user, project, "/qa", %{"name" => "qa"}) + end + + test "sends no skill for an unknown command", %{ + user: user, + project: project + } do + assert_skill(user, project, "/explain why did this fail?", nil) + end + + test "sends no skill when the command is not the first token", %{ + user: user, + project: project + } do + assert_skill(user, project, "run /qa on this", nil) + end + + defp assert_skill(user, project, content, expected) do + message = global_message(user, project, %{}, content) + + Mox.expect(Lightning.Tesla.Mock, :call, fn env, opts -> + decoded = Jason.decode!(env.body) + assert decoded["skill"] == expected + assert decoded["content"] == content + global_reply().(env, opts) + end) + + assert :ok = perform_job(MessageProcessor, %{"message_id" => message.id}) + end + end + describe "attachments via process_global_message/2" do test "sends logs and scrubbed step IO when both are ticked", %{ user: user, @@ -1343,7 +1388,7 @@ defmodule Lightning.AiAssistant.MessageProcessorTest do # A global session in `project`. `opts` is merged into message_options, # except "follow_run_id" which is lifted to session meta where the # processor reads it from. - defp global_message(user, project, opts) do + defp global_message(user, project, opts, content \\ "why did this fail?") do {run_id, message_options} = Map.pop(opts, "follow_run_id") meta = %{ @@ -1367,7 +1412,7 @@ defmodule Lightning.AiAssistant.MessageProcessorTest do session, %{ role: :user, - content: "why did this fail?", + content: content, user: user, code: "workflow:\n name: test" }, diff --git a/test/lightning/apollo_client_test.exs b/test/lightning/apollo_client_test.exs index 1f8d0ac7b69..3540182335b 100644 --- a/test/lightning/apollo_client_test.exs +++ b/test/lightning/apollo_client_test.exs @@ -239,6 +239,7 @@ defmodule Lightning.ApolloClientTest do decoded = Jason.decode!(body) refute Map.has_key?(decoded, "workflow_yaml") refute Map.has_key?(decoded, "page") + refute Map.has_key?(decoded, "skill") assert decoded["options"] == %{"stream" => true} assert decoded["content"] == "Hello" assert decoded["history"] == [] @@ -335,6 +336,22 @@ defmodule Lightning.ApolloClientTest do {:ok, _} = ApolloClient.global_chat_stream("hi") end + + test "names the skill and leaves its command in the content" do + stub_apollo_config() + + expect(Lightning.Tesla.Mock, :call, fn env, _opts -> + decoded = Jason.decode!(env.body) + assert decoded["skill"] == %{"name" => "diagnose"} + assert decoded["content"] == "/diagnose why did this fail?" + {:ok, %Tesla.Env{status: 200, body: ""}} + end) + + {:ok, _} = + ApolloClient.global_chat_stream("/diagnose why did this fail?", + skill: "diagnose" + ) + end end # Private helper function to stub Apollo configuration From d82468f3502d43a318b4eb7e0b27d88e5d7e2bfa Mon Sep 17 00:00:00 2001 From: hanna-paasivirta Date: Thu, 8 Oct 2026 19:18:12 +0100 Subject: [PATCH 3/3] Slash-command menu for global chat --- .../components/AIAssistantPanel.tsx | 5 + .../components/AIAssistantPanelWrapper.tsx | 4 + .../components/ChatInput.tsx | 194 +++++++++++++++--- .../components/MessageList.tsx | 28 ++- .../components/SkillCommand.tsx | 23 +++ .../hooks/useSessionContext.ts | 11 + .../stores/createSessionContextStore.ts | 2 + .../types/sessionContext.ts | 9 + .../utils/slashCommand.ts | 16 ++ .../ChatInput.slashCommands.test.tsx | 89 ++++++++ .../components/ChatInput.test.tsx | 2 +- .../components/MessageList.test.tsx | 22 ++ .../ai_assistant/message_processor.ex | 16 +- lib/lightning/ai_assistant/skills.ex | 40 ++++ .../channels/workflow_channel.ex | 1 + .../ai_assistant/message_processor_test.exs | 6 + .../channels/workflow_channel_test.exs | 9 + 17 files changed, 428 insertions(+), 49 deletions(-) create mode 100644 assets/js/collaborative-editor/components/SkillCommand.tsx create mode 100644 assets/js/collaborative-editor/utils/slashCommand.ts create mode 100644 assets/test/collaborative-editor/components/ChatInput.slashCommands.test.tsx create mode 100644 lib/lightning/ai_assistant/skills.ex diff --git a/assets/js/collaborative-editor/components/AIAssistantPanel.tsx b/assets/js/collaborative-editor/components/AIAssistantPanel.tsx index 383ea296848..209ee4831ec 100644 --- a/assets/js/collaborative-editor/components/AIAssistantPanel.tsx +++ b/assets/js/collaborative-editor/components/AIAssistantPanel.tsx @@ -15,6 +15,7 @@ import { useSelectedRunId } from '../hooks/useHistory'; import { useContentLocked, useIsNewWorkflow } from '../hooks/useSessionContext'; import { describeLifecycleError } from '../lib/errors'; import { notifications } from '../lib/notifications'; +import type { AISkill } from '../types/sessionContext'; import { AlertDialog } from './AlertDialog'; import { ChatInput } from './ChatInput'; @@ -61,6 +62,8 @@ interface AIAssistantPanelProps { * Switch the current workflow to draft, for the live workflow notice */ switchToDraft?: () => Promise; + /** Skills a leading slash command can invoke */ + skills?: AISkill[]; } interface MessageOptions { @@ -100,6 +103,7 @@ export function AIAssistantPanel({ connectionState = 'connected', aiLimit = null, switchToDraft = () => Promise.resolve(undefined), + skills, }: AIAssistantPanelProps) { const [view, setView] = useState<'chat' | 'sessions'>( sessionId ? 'chat' : 'sessions' @@ -477,6 +481,7 @@ export function AIAssistantPanel({ placeholder={placeholderText} disabledMessage={disabledMessage} selectedRunId={selectedRunId} + skills={skills} /> {/* About AI Assistant Modal */} diff --git a/assets/js/collaborative-editor/components/AIAssistantPanelWrapper.tsx b/assets/js/collaborative-editor/components/AIAssistantPanelWrapper.tsx index 700e2fb7670..8cf914a3d68 100644 --- a/assets/js/collaborative-editor/components/AIAssistantPanelWrapper.tsx +++ b/assets/js/collaborative-editor/components/AIAssistantPanelWrapper.tsx @@ -54,6 +54,7 @@ import { useSession, } from '../hooks/useSession'; import { + useAISkills, useIsNewWorkflow, useLimits, useProject, @@ -185,6 +186,7 @@ export function AIAssistantPanelWrapper({ const user = useUser(); const workflow = useWorkflowState(state => state.workflow); const limits = useLimits(); + const skills = useAISkills(); // AI can apply changes if: not readonly OR is a new workflow (being created) const canApplyChanges = !isReadOnly || isNewWorkflow; @@ -802,6 +804,7 @@ export function AIAssistantPanelWrapper({ connectionState={sessionId ? connectionState : 'connected'} aiLimit={limits.ai_assistant ?? null} switchToDraft={switchToDraft} + skills={skills} > MAX_MESSAGE_LENGTH; - const showCount = input.length >= COUNT_FROM; + const [skill, setSkill] = useState(null); + const [highlighted, setHighlighted] = useState(0); + const [dismissedMenuFor, setDismissedMenuFor] = useState(null); + + const content = skill + ? `/${skill.name} ${input.trim()}`.trim() + : input.trim(); + const tooLong = content.length > MAX_MESSAGE_LENGTH; + const showCount = content.length >= COUNT_FROM; + const canSend = !!content && !isLoading && !isDisabled && !tooLong; + + // A command is only one while it is the first token, so the menu closes + // at the first whitespace. + const menuMatches = + !skill && /^\/\S*$/.test(input) && dismissedMenuFor !== input + ? skills.filter(s => s.name.startsWith(input.slice(1))) + : []; + const activeIndex = Math.min(highlighted, menuMatches.length - 1); + const activeSkill: AISkill | undefined = menuMatches[activeIndex]; + const menuOpen = activeSkill !== undefined; const [attachLogs, setAttachLogs] = useState(() => { if (!storageKey) { @@ -202,9 +226,28 @@ export function ChatInput({ prevIsLoadingRef.current = isLoading; }, [isLoading, enableAutoFocus]); + const chooseSkill = (chosen: AISkill, rest = '') => { + setSkill(chosen); + setInput(rest); + setHighlighted(0); + textareaRef.current?.focus(); + }; + + const handleChange = (value: string) => { + // Typing the space after a known name, or pasting a whole command, turns + // it into a pill; a bare `/qa` stays text until then, like `/qafoo`. + const parsed = skill ? null : parseSlashCommand(value, skills); + if (parsed && /^\/\S+\s/.test(value)) { + chooseSkill(parsed.skill, parsed.rest); + return; + } + setInput(value); + setHighlighted(0); + }; + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - if (!input.trim() || isLoading || isDisabled || tooLong) return; + if (!canSend) return; const options: MessageOptions = {}; // The run rides along so what we promise to attach and what the backend @@ -215,11 +258,45 @@ export function ChatInput({ options.follow_run_id = selectedRunId; } - onSendMessage?.(input.trim(), options); + onSendMessage?.(content, options); setInput(''); + setSkill(null); }; - const handleKeyDown = (e: React.KeyboardEvent) => { + const handleKeyDown = (e: React.KeyboardEvent) => { + if (activeSkill) { + const move = { ArrowDown: 1, ArrowUp: -1 }[e.key]; + if (move) { + e.preventDefault(); + setHighlighted( + (activeIndex + move + menuMatches.length) % menuMatches.length + ); + return; + } + if ((e.key === 'Enter' && !e.shiftKey) || e.key === 'Tab') { + e.preventDefault(); + chooseSkill(activeSkill); + return; + } + if (e.key === 'Escape') { + e.preventDefault(); + setDismissedMenuFor(input); + return; + } + } + + const { selectionStart, selectionEnd } = e.currentTarget; + if ( + skill && + e.key === 'Backspace' && + selectionStart === 0 && + selectionEnd === 0 + ) { + e.preventDefault(); + setSkill(null); + return; + } + if ( e.key === 'Enter' && !e.shiftKey && @@ -241,11 +318,49 @@ export function ChatInput({ side="top" >
+ {menuOpen && ( +
+ {menuMatches.map((s, i) => ( +
{ + e.preventDefault(); + chooseSkill(s); + }} + onMouseEnter={() => setHighlighted(i)} + className={cn( + 'flex items-baseline gap-2 px-3 py-2 cursor-pointer', + i === activeIndex && 'bg-gray-100' + )} + > + + /{s.name} + + + {s.description} + +
+ ))} +
+ )}
)} -