@@ -320,7 +346,7 @@ export function ChatInput({
tooLong ? 'text-red-600' : 'text-gray-400'
)}
>
- {input.length.toLocaleString()} /{' '}
+ {content.length.toLocaleString()} /{' '}
{MAX_MESSAGE_LENGTH.toLocaleString()}
{tooLong ? ' — too long to send' : null}
@@ -331,15 +357,13 @@ export function ChatInput({
diff --git a/assets/js/collaborative-editor/components/SkillCommand.tsx b/assets/js/collaborative-editor/components/SkillCommand.tsx
new file mode 100644
index 0000000000..b135533291
--- /dev/null
+++ b/assets/js/collaborative-editor/components/SkillCommand.tsx
@@ -0,0 +1,25 @@
+import { Tooltip } from '../../components/Tooltip';
+import type { AISkill } from '../types/sessionContext';
+
+export function SkillCommand({
+ skill,
+ withTooltip = true,
+}: {
+ skill: AISkill;
+ /** Off in an input, where the menu has just shown the description */
+ withTooltip?: boolean;
+}) {
+ const command = (
+
+ /{skill.name}
+
+ );
+
+ return withTooltip ? (
+
+ {command}
+
+ ) : (
+ command
+ );
+}
diff --git a/assets/js/collaborative-editor/components/SkillMenu.tsx b/assets/js/collaborative-editor/components/SkillMenu.tsx
new file mode 100644
index 0000000000..2878f3e0a8
--- /dev/null
+++ b/assets/js/collaborative-editor/components/SkillMenu.tsx
@@ -0,0 +1,58 @@
+import { cn } from '#/utils/cn';
+
+import type { AISkill } from '../types/sessionContext';
+
+interface SkillMenuProps {
+ id: string;
+ matches: AISkill[];
+ activeIndex: number;
+ onChoose: (skill: AISkill) => void;
+ onHighlight: (index: number) => void;
+}
+
+export function SkillMenu({
+ id,
+ matches,
+ activeIndex,
+ onChoose,
+ onHighlight,
+}: SkillMenuProps) {
+ if (matches.length === 0) return null;
+
+ return (
+
+ {matches.map((s, i) => (
+
{
+ e.preventDefault();
+ onChoose(s);
+ }}
+ onMouseEnter={() => onHighlight(i)}
+ className={cn(
+ 'flex items-baseline gap-2 px-3 py-2 cursor-pointer',
+ i === activeIndex && 'bg-gray-100'
+ )}
+ >
+ /{s.name}
+
+ {s.description}
+
+
+ ))}
+
+ );
+}
diff --git a/assets/js/collaborative-editor/hooks/useSessionContext.ts b/assets/js/collaborative-editor/hooks/useSessionContext.ts
index 092ab573b6..529ffae860 100644
--- a/assets/js/collaborative-editor/hooks/useSessionContext.ts
+++ b/assets/js/collaborative-editor/hooks/useSessionContext.ts
@@ -32,11 +32,12 @@
* ```
*/
-import { useContext, useSyncExternalStore } from 'react';
+import { useContext, useMemo, useSyncExternalStore } from 'react';
import { StoreContext } from '../contexts/StoreProvider';
import type { SessionContextStoreInstance } from '../stores/createSessionContextStore';
import type {
+ AISkill,
AppConfig,
Limits,
Permissions,
@@ -166,6 +167,27 @@ export const usePermissions = (): Permissions | null => {
return useSyncExternalStore(sessionContextStore.subscribe, selectPermissions);
};
+/** Skills for the current workflow; a new one leaves out those for existing workflows. */
+export const useAISkills = (): AISkill[] => {
+ const sessionContextStore = useSessionContextStore();
+
+ const selectSkills = sessionContextStore.withSelector(
+ state => state.aiSkills
+ );
+
+ const skills = useSyncExternalStore(
+ sessionContextStore.subscribe,
+ selectSkills
+ );
+ const isNewWorkflow = useIsNewWorkflow();
+
+ return useMemo(
+ () =>
+ isNewWorkflow ? skills.filter(s => s.workflow !== 'existing') : skills,
+ [skills, isNewWorkflow]
+ );
+};
+
export const useExperimentalFeatures = (): boolean => {
const sessionContextStore = useSessionContextStore();
diff --git a/assets/js/collaborative-editor/hooks/useSlashCommand.ts b/assets/js/collaborative-editor/hooks/useSlashCommand.ts
new file mode 100644
index 0000000000..55aaaf51f4
--- /dev/null
+++ b/assets/js/collaborative-editor/hooks/useSlashCommand.ts
@@ -0,0 +1,135 @@
+import { useId, useLayoutEffect, useRef, useState } from 'react';
+import type { KeyboardEvent, RefObject, UIEvent } from 'react';
+
+import type { AISkill } from '../types/sessionContext';
+import { parseSlashCommand } from '../utils/slashCommand';
+
+/**
+ * Slash-command state for a message textarea: the `/` menu, and the skill
+ * pill a chosen command becomes. `content` is the message to send.
+ */
+export function useSlashCommand(
+ skills: AISkill[],
+ textareaRef: RefObject
+) {
+ const [input, setInput] = useState('');
+ const [skill, setSkill] = useState(null);
+ const [highlighted, setHighlighted] = useState(0);
+ const [dismissedMenuFor, setDismissedMenuFor] = useState(null);
+ const commandRef = useRef(null);
+ const [commandWidth, setCommandWidth] = useState(0);
+ const menuId = useId();
+
+ useLayoutEffect(() => {
+ setCommandWidth(skill ? (commandRef.current?.offsetWidth ?? 0) : 0);
+ }, [skill]);
+
+ const content = skill
+ ? `/${skill.name} ${input.trim()}`.trim()
+ : input.trim();
+
+ // A command is only one while it is the first token, so the menu closes
+ // at the first whitespace.
+ const matches =
+ !skill && /^\/\S*$/.test(input) && dismissedMenuFor !== input
+ ? skills.filter(s => s.name.startsWith(input.slice(1)))
+ : [];
+ const activeIndex = Math.min(highlighted, matches.length - 1);
+ const activeSkill: AISkill | undefined = matches[activeIndex];
+ const menuOpen = activeSkill !== undefined;
+
+ 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);
+ setDismissedMenuFor(null);
+ };
+
+ /** Handles the menu and pill keys; true when the key was used. */
+ const handleKeyDown = (e: KeyboardEvent): boolean => {
+ if (activeSkill) {
+ const move = { ArrowDown: 1, ArrowUp: -1 }[e.key];
+ if (move) {
+ e.preventDefault();
+ setHighlighted((activeIndex + move + matches.length) % matches.length);
+ return true;
+ }
+ if ((e.key === 'Enter' && !e.shiftKey) || e.key === 'Tab') {
+ e.preventDefault();
+ chooseSkill(activeSkill);
+ return true;
+ }
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ // The editor's Escape shortcuts listen on window
+ e.stopPropagation();
+ setDismissedMenuFor(input);
+ return true;
+ }
+ }
+
+ const { selectionStart, selectionEnd } = e.currentTarget;
+ if (
+ skill &&
+ e.key === 'Backspace' &&
+ selectionStart === 0 &&
+ selectionEnd === 0
+ ) {
+ e.preventDefault();
+ setSkill(null);
+ return true;
+ }
+
+ return false;
+ };
+
+ const reset = () => {
+ setInput('');
+ setSkill(null);
+ setDismissedMenuFor(null);
+ };
+
+ return {
+ input,
+ skill,
+ content,
+ // The command is laid over the textarea's first line, so only that line
+ // makes room for it and wrapped lines start at the left edge.
+ commandRef,
+ textIndent: commandWidth ? `calc(${commandWidth}px + 0.25em)` : undefined,
+ onScroll: (e: UIEvent) => {
+ if (commandRef.current) {
+ commandRef.current.style.transform = `translateY(-${e.currentTarget.scrollTop}px)`;
+ }
+ },
+ handleChange,
+ handleKeyDown,
+ reset,
+ comboboxProps: {
+ role: 'combobox',
+ 'aria-expanded': menuOpen,
+ 'aria-controls': menuOpen ? menuId : undefined,
+ 'aria-activedescendant': activeSkill && `${menuId}-${activeSkill.name}`,
+ },
+ menuProps: {
+ id: menuId,
+ matches,
+ activeIndex,
+ onChoose: (s: AISkill) => chooseSkill(s),
+ onHighlight: setHighlighted,
+ },
+ };
+}
diff --git a/assets/js/collaborative-editor/stores/createSessionContextStore.ts b/assets/js/collaborative-editor/stores/createSessionContextStore.ts
index 33bdf1e435..08143e0ad6 100644
--- a/assets/js/collaborative-editor/stores/createSessionContextStore.ts
+++ b/assets/js/collaborative-editor/stores/createSessionContextStore.ts
@@ -137,6 +137,7 @@ export const createSessionContextStore = (
workflow_template: null,
suppressEnableTriggerWarning: false,
limits: {},
+ aiSkills: [],
isNewWorkflow,
isLoading: false,
error: null,
@@ -209,6 +210,7 @@ export const createSessionContextStore = (
draft.suppressEnableTriggerWarning =
sessionContext.suppress_enable_trigger_warning;
draft.limits = sessionContext.limits;
+ draft.aiSkills = sessionContext.ai_skills;
draft.isLoading = false;
draft.error = null;
draft.lastUpdated = Date.now();
diff --git a/assets/js/collaborative-editor/types/sessionContext.ts b/assets/js/collaborative-editor/types/sessionContext.ts
index 034e1ce0d9..6827938ed7 100644
--- a/assets/js/collaborative-editor/types/sessionContext.ts
+++ b/assets/js/collaborative-editor/types/sessionContext.ts
@@ -111,6 +111,15 @@ export const LimitsSchema = z.object({
export type Limits = z.infer;
+export const AISkillSchema = z.object({
+ name: z.string(),
+ description: z.string(),
+ /** Whether the skill fits any workflow, or only a saved one */
+ workflow: z.enum(['any', 'existing']),
+});
+
+export type AISkill = z.infer;
+
export const SessionContextResponseSchema = z.object({
user: UserContextSchema.nullable(),
project: ProjectContextSchema.nullable(),
@@ -125,6 +134,7 @@ export const SessionContextResponseSchema = z.object({
suppress_enable_trigger_warning: z.boolean().optional().default(false),
experimental_features_enabled: z.boolean().optional().default(false),
limits: LimitsSchema.optional(),
+ ai_skills: z.array(AISkillSchema).optional().default([]),
workflow: BaseWorkflowSchema.optional(),
});
@@ -156,6 +166,7 @@ export interface SessionContextState {
workflow_template: WorkflowTemplate | null;
suppressEnableTriggerWarning: boolean;
limits: Limits;
+ aiSkills: AISkill[];
isNewWorkflow: boolean;
isLoading: boolean;
error: string | null;
diff --git a/assets/js/collaborative-editor/utils/slashCommand.ts b/assets/js/collaborative-editor/utils/slashCommand.ts
new file mode 100644
index 0000000000..106fb4e575
--- /dev/null
+++ b/assets/js/collaborative-editor/utils/slashCommand.ts
@@ -0,0 +1,16 @@
+import type { AISkill } from '../types/sessionContext';
+
+/**
+ * Splits a message into the skill its leading `/name` invokes and the rest.
+ * Mirrors Lightning.AiAssistant.Skills.detect/1, which decides what is sent.
+ */
+export function parseSlashCommand(
+ content: string,
+ skills: AISkill[]
+): { skill: AISkill; rest: string } | null {
+ const match = /^\/(\S+)(?:\s([\s\S]*))?$/.exec(content);
+ if (!match) return null;
+
+ const skill = skills.find(s => s.name === match[1]);
+ return skill ? { skill, rest: match[2] ?? '' } : null;
+}
diff --git a/assets/test/collaborative-editor/components/ChatInput.slashCommands.test.tsx b/assets/test/collaborative-editor/components/ChatInput.slashCommands.test.tsx
new file mode 100644
index 0000000000..d0a6df9837
--- /dev/null
+++ b/assets/test/collaborative-editor/components/ChatInput.slashCommands.test.tsx
@@ -0,0 +1,115 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+
+import { ChatInput } from '../../../js/collaborative-editor/components/ChatInput';
+
+const skills = [
+ {
+ name: 'diagnose',
+ description: 'Find out why a run failed',
+ workflow: 'existing' as const,
+ },
+ {
+ name: 'qa',
+ description: 'Review this workflow',
+ workflow: 'existing' as const,
+ },
+];
+
+describe('ChatInput slash commands', () => {
+ let onSendMessage: ReturnType;
+
+ beforeEach(() => {
+ onSendMessage = vi.fn();
+ render();
+ });
+
+ const input = () => screen.getByTestId('chat-input');
+
+ it('offers every skill for a leading slash, filtered as you type', async () => {
+ await userEvent.type(input(), '/');
+ expect(screen.getAllByRole('option').map(o => o.textContent)).toEqual([
+ expect.stringContaining('/diagnose'),
+ expect.stringContaining('/qa'),
+ ]);
+
+ await userEvent.type(input(), 'q');
+ expect(screen.getAllByRole('option')).toHaveLength(1);
+ });
+
+ it('offers nothing for a slash after the first token', async () => {
+ await userEvent.type(input(), 'run /');
+ expect(screen.queryByTestId('skill-menu')).not.toBeInTheDocument();
+ });
+
+ it('highlights the chosen skill and sends it as a command', async () => {
+ await userEvent.type(input(), '/');
+ await userEvent.click(screen.getByRole('option', { name: /\/qa/ }));
+
+ expect(screen.getByTestId('skill-command')).toHaveTextContent('/qa');
+ expect(input()).toHaveValue('');
+
+ await userEvent.type(input(), 'the new step{Enter}');
+ expect(onSendMessage).toHaveBeenCalledWith('/qa the new step', {});
+ expect(screen.queryByTestId('skill-command')).not.toBeInTheDocument();
+ });
+
+ it('picks the highlighted skill with the keyboard', async () => {
+ await userEvent.type(input(), '/{ArrowDown}{Enter}');
+
+ expect(screen.getByTestId('skill-command')).toHaveTextContent('/qa');
+ expect(onSendMessage).not.toHaveBeenCalled();
+ });
+
+ it('highlights a typed command at the space after its name', async () => {
+ await userEvent.type(input(), '/diagnose why');
+
+ expect(screen.getByTestId('skill-command')).toHaveTextContent('/diagnose');
+ expect(input()).toHaveValue('why');
+ });
+
+ it('sends a command with no details', async () => {
+ await userEvent.type(input(), '/qa {Enter}');
+ expect(onSendMessage).toHaveBeenCalledWith('/qa', {});
+ });
+
+ it('leaves an unknown command as text', async () => {
+ await userEvent.type(input(), '/explain this');
+
+ expect(screen.queryByTestId('skill-command')).not.toBeInTheDocument();
+ expect(input()).toHaveValue('/explain this');
+ });
+
+ it('removes the command on backspace at the start, keeping the details', async () => {
+ await userEvent.type(input(), '/qa x{ArrowLeft}{Backspace}');
+
+ expect(screen.queryByTestId('skill-command')).not.toBeInTheDocument();
+ expect(input()).toHaveValue('x');
+ });
+
+ it('closes the menu on Escape so Enter sends the text', async () => {
+ await userEvent.type(input(), '/q{Escape}{Enter}');
+
+ expect(screen.queryByTestId('skill-menu')).not.toBeInTheDocument();
+ expect(onSendMessage).toHaveBeenCalledWith('/q', {});
+ });
+
+ it('opens the menu again once the input changes after Escape', async () => {
+ await userEvent.type(input(), '/{Escape}{Backspace}/');
+
+ expect(screen.getByTestId('skill-menu')).toBeInTheDocument();
+ });
+
+ it('keeps the Escape that closes the menu from reaching the editor', async () => {
+ const onWindowKeyDown = vi.fn();
+ window.addEventListener('keydown', onWindowKeyDown);
+
+ await userEvent.type(input(), '/{Escape}');
+
+ window.removeEventListener('keydown', onWindowKeyDown);
+ expect(onWindowKeyDown).not.toHaveBeenCalledWith(
+ expect.objectContaining({ key: 'Escape' })
+ );
+ });
+});
diff --git a/assets/test/collaborative-editor/components/ChatInput.test.tsx b/assets/test/collaborative-editor/components/ChatInput.test.tsx
index 68f1b57194..9367b60961 100644
--- a/assets/test/collaborative-editor/components/ChatInput.test.tsx
+++ b/assets/test/collaborative-editor/components/ChatInput.test.tsx
@@ -650,7 +650,7 @@ describe('ChatInput', () => {
render();
const textarea = screen.getByPlaceholderText('Ask me anything...');
- const container = textarea.parentElement!;
+ const container = textarea.closest('.rounded-xl')!;
// Initially no content
expect(container).toHaveClass('border-gray-200');
diff --git a/assets/test/collaborative-editor/components/LandingScreen.test.tsx b/assets/test/collaborative-editor/components/LandingScreen.test.tsx
index 5169f661ff..101d331377 100644
--- a/assets/test/collaborative-editor/components/LandingScreen.test.tsx
+++ b/assets/test/collaborative-editor/components/LandingScreen.test.tsx
@@ -11,6 +11,7 @@ import userEvent from '@testing-library/user-event';
import { describe, expect, test, vi } from 'vitest';
import { LandingScreen } from '../../../js/collaborative-editor/components/LandingScreen';
+import type { AISkill } from '../../../js/collaborative-editor/types/sessionContext';
// =============================================================================
// HELPERS
@@ -25,6 +26,7 @@ function renderLandingScreen(props: {
isBuildingFromScratch?: boolean;
onBrowseTemplates?: () => void;
onImportYAML?: () => void;
+ skills?: AISkill[];
}) {
const defaults = {
aiAssistantEnabled: true,
@@ -247,3 +249,24 @@ describe('LandingScreen - AI limit reached', () => {
expect(screen.getByTestId('build-with-ai-input')).not.toBeDisabled();
});
});
+
+describe('LandingScreen - Slash commands', () => {
+ test('sends a skill chosen from the slash menu as a command', async () => {
+ const onBuildWithAI = vi.fn();
+ renderLandingScreen({
+ onBuildWithAI,
+ skills: [
+ { name: 'design', description: 'Plan a workflow', workflow: 'any' },
+ ],
+ });
+
+ await userEvent.type(screen.getByTestId('build-with-ai-input'), '/');
+ await userEvent.click(screen.getByRole('option', { name: /\/design/ }));
+ await userEvent.type(
+ screen.getByTestId('build-with-ai-input'),
+ 'a Kobo sync{Enter}'
+ );
+
+ expect(onBuildWithAI).toHaveBeenCalledWith('/design a Kobo sync');
+ });
+});
diff --git a/assets/test/collaborative-editor/components/MessageList.test.tsx b/assets/test/collaborative-editor/components/MessageList.test.tsx
index c7df47f28c..41772bca37 100644
--- a/assets/test/collaborative-editor/components/MessageList.test.tsx
+++ b/assets/test/collaborative-editor/components/MessageList.test.tsx
@@ -71,6 +71,34 @@ describe('MessageList', () => {
expect(screen.getByText('Hello AI')).toBeInTheDocument();
});
+ it('highlights a leading known skill command', () => {
+ const skills = [
+ {
+ name: 'qa',
+ description: 'Review this workflow',
+ workflow: 'existing' as const,
+ },
+ ];
+ render(
+
+ );
+
+ const [invoked, mentioned] = screen.getAllByTestId('user-message');
+ expect(within(invoked!).getByTestId('skill-command')).toHaveTextContent(
+ '/qa'
+ );
+ expect(invoked).toHaveTextContent(/\/qa\s*now/);
+ expect(
+ within(mentioned!).queryByTestId('skill-command')
+ ).not.toBeInTheDocument();
+ });
+
it('should render assistant messages', () => {
const messages = [
createMockAIMessage({
diff --git a/assets/test/collaborative-editor/hooks/useSessionContext.test.tsx b/assets/test/collaborative-editor/hooks/useSessionContext.test.tsx
index 05ef28b172..6f27eea3fc 100644
--- a/assets/test/collaborative-editor/hooks/useSessionContext.test.tsx
+++ b/assets/test/collaborative-editor/hooks/useSessionContext.test.tsx
@@ -12,6 +12,7 @@ import { beforeEach, describe, expect, test } from 'vitest';
import type { StoreContextValue } from '../../../js/collaborative-editor/contexts/StoreProvider';
import { StoreContext } from '../../../js/collaborative-editor/contexts/StoreProvider';
import {
+ useAISkills,
useAppConfig,
useProject,
useSessionContextError,
@@ -353,3 +354,39 @@ describe('Hook Integration', () => {
expect(errorResult.current).toBe('Something went wrong');
});
});
+
+describe('useAISkills()', () => {
+ const skills = [
+ { name: 'design', description: 'Plan a workflow', workflow: 'any' },
+ { name: 'qa', description: 'Review this workflow', workflow: 'existing' },
+ ];
+
+ test.each([
+ [true, ['design']],
+ [false, ['design', 'qa']],
+ ])('on a new workflow (%s) offers %j', async (isNewWorkflow, expected) => {
+ const store = createSessionContextStore(isNewWorkflow);
+ const { mockChannel } = setupHookTest(store);
+ const { result } = renderHook(() => useAISkills(), {
+ wrapper: createWrapper(store),
+ });
+
+ act(() => {
+ (mockChannel as any)._test.emit('session_context', {
+ user: null,
+ project: null,
+ config: createMockAppConfig(),
+ permissions: mockPermissions,
+ latest_snapshot_lock_version: 1,
+ project_repo_connection: null,
+ webhook_auth_methods: [],
+ workflow_template: null,
+ ai_skills: skills,
+ });
+ });
+
+ await waitFor(() => {
+ expect(result.current.map(s => s.name)).toEqual(expected);
+ });
+ });
+});
diff --git a/lib/lightning/ai_assistant/ai_assistant.ex b/lib/lightning/ai_assistant/ai_assistant.ex
index 32a668e24b..beff2f3782 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(
@@ -978,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)
@@ -990,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 ->
@@ -1167,7 +1176,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 +1871,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 4756cb53b7..521891f7cd 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/lib/lightning/ai_assistant/message_processor.ex b/lib/lightning/ai_assistant/message_processor.ex
index b1add71c87..fdb70a150f 100644
--- a/lib/lightning/ai_assistant/message_processor.ex
+++ b/lib/lightning/ai_assistant/message_processor.ex
@@ -167,7 +167,13 @@ 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),
+ # The command stays in the content: Apollo strips it itself.
+ skill:
+ AiAssistant.Skills.detect(
+ message.content,
+ Map.has_key?(session.meta || %{}, "unsaved_workflow")
+ )
)
end
diff --git a/lib/lightning/ai_assistant/skills.ex b/lib/lightning/ai_assistant/skills.ex
new file mode 100644
index 0000000000..51ab6d3dfe
--- /dev/null
+++ b/lib/lightning/ai_assistant/skills.ex
@@ -0,0 +1,56 @@
+defmodule Lightning.AiAssistant.Skills do
+ @moduledoc """
+ Skills the user can invoke in the global assistant with a slash command.
+
+ These are Apollo's standard skills, so each name must match a
+ `skills//SKILL.md` there with no `metadata.agent`; Apollo rejects
+ any other name.
+
+ `workflow` says which workflows a skill makes sense for: `"any"`, or only
+ `"existing"` ones, saved with steps and runs to look at.
+ """
+
+ @type skill :: %{
+ name: String.t(),
+ description: String.t(),
+ workflow: String.t()
+ }
+
+ @skills [
+ %{
+ name: "design",
+ description: "Work out what a workflow should do before building it",
+ workflow: "any"
+ },
+ %{
+ name: "diagnose",
+ description: "Find out why a run failed, from its logs and data",
+ workflow: "existing"
+ },
+ %{
+ name: "qa",
+ description: "Review this workflow and list what should change",
+ workflow: "existing"
+ }
+ ]
+
+ @spec list() :: [skill()]
+ def list, do: @skills
+
+ @doc """
+ The skill a message invokes: a known `/name` as its first token, or nil.
+ A skill for existing workflows does not count on a new one.
+ """
+ @spec detect(String.t(), boolean()) :: String.t() | nil
+ def detect("/" <> rest, new_workflow?) do
+ name = rest |> String.split(~r/\s/, parts: 2) |> hd()
+
+ if Enum.any?(@skills, &(&1.name == name and fits?(&1, new_workflow?))),
+ do: name
+ end
+
+ def detect(_content, _new_workflow?), do: nil
+
+ defp fits?(%{workflow: "existing"}, true = _new_workflow?), do: false
+ defp fits?(_skill, _new_workflow?), do: true
+end
diff --git a/lib/lightning/apollo_client.ex b/lib/lightning/apollo_client.ex
index b75a964417..df353db729 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/lib/lightning_web/channels/workflow_channel.ex b/lib/lightning_web/channels/workflow_channel.ex
index 8012290441..e9d4fc0198 100644
--- a/lib/lightning_web/channels/workflow_channel.ex
+++ b/lib/lightning_web/channels/workflow_channel.ex
@@ -1379,6 +1379,7 @@ defmodule LightningWeb.WorkflowChannel do
experimental_features_enabled:
Lightning.Accounts.experimental_features_enabled?(user),
limits: render_limits(project.id),
+ ai_skills: Lightning.AiAssistant.Skills.list(),
workflow: fresh_workflow
}
end
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 0000000000..78733ba900
--- /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 f2d2ea89bc..08e2e991a0 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 ->
diff --git a/test/lightning/ai_assistant/message_processor_test.exs b/test/lightning/ai_assistant/message_processor_test.exs
index 99b6b488d4..62ab8ba3bc 100644
--- a/test/lightning/ai_assistant/message_processor_test.exs
+++ b/test/lightning/ai_assistant/message_processor_test.exs
@@ -1116,6 +1116,81 @@ 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 "names the design skill on new and existing workflows", %{
+ user: user,
+ project: project
+ } do
+ design = "/design a patient intake flow"
+
+ assert_skill(user, project, design, %{"name" => "design"})
+
+ assert_skill(user, project, design, %{"name" => "design"},
+ new_workflow: true
+ )
+ end
+
+ test "sends no skill for existing workflows on a new one", %{
+ user: user,
+ project: project
+ } do
+ assert_skill(user, project, "/qa", nil, new_workflow: true)
+ 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, opts \\ []) do
+ message = global_message(user, project, %{}, content)
+
+ if opts[:new_workflow] do
+ session = Repo.get!(AiAssistant.ChatSession, message.chat_session_id)
+
+ session
+ |> Ecto.Changeset.change(
+ meta: Map.put(session.meta, "unsaved_workflow", %{"is_new" => true})
+ )
+ |> Repo.update!()
+ end
+
+ 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 +1418,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 +1442,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 1f8d0ac7b6..3540182335 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
diff --git a/test/lightning_web/channels/workflow_channel_test.exs b/test/lightning_web/channels/workflow_channel_test.exs
index 5e69ccb55c..1445b2dc92 100644
--- a/test/lightning_web/channels/workflow_channel_test.exs
+++ b/test/lightning_web/channels/workflow_channel_test.exs
@@ -6430,6 +6430,15 @@ defmodule LightningWeb.WorkflowChannelTest do
} = response
end
+ test "lists the skills the assistant can be asked to use", %{
+ socket: socket
+ } do
+ ref = push(socket, "get_context", %{})
+
+ assert_reply ref, :ok, %{ai_skills: skills}
+ assert Enum.map(skills, & &1.name) == ["design", "diagnose", "qa"]
+ end
+
test "reports the plan's sandbox upsell when sandboxes are not available", %{
socket: socket,
project: %{id: project_id}