diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c10c24a5f..ae10439384 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,11 @@ and this project adheres to ### Changed +- The global chat now starts streaming Apollo's response earlier, so users wait + less before seeing output. Lightning handles the several streaming event types + Apollo sends, including status updates. + [#4969](https://github.com/OpenFn/lightning/pull/4969) + ### Fixed ## [2.18.0-pre] - 2026-07-31 @@ -323,7 +328,7 @@ Migrations in this release, all in `priv/repo/migrations/`: code...") that Apollo streams _after_ the text answer while it generates code, displayed below the answer in the same style as the initial "Thinking..." indicator. Statuses are surfaced in whatever order Apollo sends them. - [#PR](https://github.com/OpenFn/lightning/pull/PR) + [#4833](https://github.com/OpenFn/lightning/pull/4833) ### Changed diff --git a/assets/js/collaborative-editor/components/AIAssistantPanelWrapper.tsx b/assets/js/collaborative-editor/components/AIAssistantPanelWrapper.tsx index dfe4386074..01caf16499 100644 --- a/assets/js/collaborative-editor/components/AIAssistantPanelWrapper.tsx +++ b/assets/js/collaborative-editor/components/AIAssistantPanelWrapper.tsx @@ -31,6 +31,7 @@ import { useAIStreamingApply, useAIStreamingChanges, useAIStreamingContent, + useAIStreamingSegments, useAIStreamingStatus, useAIWorkflowTemplateContext, } from '../hooks/useAIAssistant'; @@ -67,6 +68,7 @@ import { } from '../hooks/useWorkflow'; import { useKeyboardShortcut } from '../keyboard'; import type { JobCodeContext, Message } from '../types/ai-assistant'; +import { STREAMING_MESSAGE_ID } from '../types/ai-assistant'; import { Z_INDEX } from '../utils/constants'; import { prepareWorkflowForSerialization, @@ -145,6 +147,7 @@ export function AIAssistantPanelWrapper({ const isLoading = useAIIsLoading(); const streamingContent = useAIStreamingContent(); const streamingStatus = useAIStreamingStatus(); + const streamingSegments = useAIStreamingSegments(); const streamingChanges = useAIStreamingChanges(); const sessionId = useAISessionId(); const sessionType = useAISessionType(); @@ -598,6 +601,7 @@ export function AIAssistantPanelWrapper({ : null, currentUserId: user?.id, aiMode, + isGlobalSession: isGlobalAssistantActive, isNewWorkflow, isSessionConnected, isSessionConnecting, @@ -657,27 +661,53 @@ export function AIAssistantPanelWrapper({ ); useEffect(() => { if (!streamingChanges || !canApplyChanges) return; - // Avoid re-applying the same streaming changes object + // Avoid re-applying the same streaming changes object. The ref is only + // set once a handler is invoked for the change (whatever its outcome — + // a failed apply is recovered by the final new_message auto-apply), so + // a change that never reached a handler stays eligible if the page + // switches mid-stream. if (appliedStreamingChangesRef.current === streamingChanges) return; - appliedStreamingChangesRef.current = streamingChanges; - if (aiMode?.page === 'workflow_template' && 'yaml' in streamingChanges) { + // Every collaborator's browser receives streaming_changes, but only the + // author's client auto-applies: the Y.Doc is shared, so concurrent + // applies from multiple viewers of the same session would race. Other + // collaborators still see the result through the shared doc. When the + // author can't be determined (no user info on the message) we fall back + // to applying, preserving single-user behavior. + const triggeringUserId = messages.findLast(m => m.role === 'user')?.user + ?.id; + if (triggeringUserId && user?.id && triggeringUserId !== user.id) return; + + // Workflow YAML applies to the shared Y.Doc, so global streams are + // page-independent: global chat streams it from the job code view too, + // and the diagram must be up to date whenever the user navigates there. + // Non-global workflow chat keeps its workflow_template-only gate (a + // stream can outlive a mid-stream switch to a job page). + if ('yaml' in streamingChanges) { const yaml = streamingChanges['yaml'] as string; - if (yaml) { + const yamlCanApply = + isGlobalAssistantActive || aiMode?.page === 'workflow_template'; + if (yaml && yamlCanApply) { + appliedStreamingChangesRef.current = streamingChanges; // handleApplyWorkflow records the streaming apply in the store // (after a successful import) so the final new_message can skip it - void handleApplyWorkflow(yaml, '__streaming__'); + void handleApplyWorkflow(yaml, STREAMING_MESSAGE_ID); } } else if (aiMode?.page === 'job_code' && 'code' in streamingChanges) { + // Job code previews open job-editor UI, so they stay page-gated. const code = streamingChanges['code'] as string; if (code) { - handlePreviewJobCode(code, '__streaming__'); + appliedStreamingChangesRef.current = streamingChanges; + handlePreviewJobCode(code, STREAMING_MESSAGE_ID); } } }, [ streamingChanges, aiMode?.page, canApplyChanges, + isGlobalAssistantActive, + messages, + user?.id, handleApplyWorkflow, handlePreviewJobCode, ]); @@ -783,6 +813,8 @@ export function AIAssistantPanelWrapper({ isWriteDisabled={isWriteDisabled} streamingContent={streamingContent} streamingStatus={streamingStatus} + streamingSegments={streamingSegments} + isGlobalAssistantActive={isGlobalAssistantActive} /> diff --git a/assets/js/collaborative-editor/components/MessageList.tsx b/assets/js/collaborative-editor/components/MessageList.tsx index 012c9ebcaa..edef1406ba 100644 --- a/assets/js/collaborative-editor/components/MessageList.tsx +++ b/assets/js/collaborative-editor/components/MessageList.tsx @@ -5,12 +5,11 @@ import remarkGfm from 'remark-gfm'; import { useCopyToClipboard } from '#/collaborative-editor/hooks/useCopyToClipboard'; import { cn } from '#/utils/cn'; -import type { Message } from '../types/ai-assistant'; +import type { Message, ResponseSegment } from '../types/ai-assistant'; +import { STREAMING_MESSAGE_ID } from '../types/ai-assistant'; import { Tooltip } from '../../components/Tooltip'; -const STREAMING_MESSAGE_ID = '__streaming__' as const; - const PROSE_CLASSES = 'text-sm text-gray-700 leading-relaxed prose prose-sm max-w-none prose-headings:font-medium prose-h1:text-lg prose-h1:text-gray-900 prose-h1:mb-3 prose-h2:text-base prose-h2:text-gray-900 prose-h2:mb-2 prose-h2:mt-5 prose-h3:text-sm prose-h3:text-gray-900 prose-h3:mb-2 prose-h3:font-semibold prose-p:mb-3 prose-p:last:mb-0 prose-p:text-gray-700 prose-ul:list-disc prose-ul:pl-5 prose-ul:mb-3 prose-ul:space-y-1 prose-ol:list-decimal prose-ol:pl-5 prose-ol:mb-3 prose-ol:space-y-1 prose-li:text-gray-700 prose-strong:font-medium prose-strong:text-gray-900 prose-em:italic prose-a:text-primary-600 prose-a:hover:text-primary-700 prose-a:underline prose-a:font-normal prose-code:px-1.5 prose-code:py-0.5 prose-code:bg-gray-100 prose-code:text-gray-800 prose-code:rounded prose-code:text-xs prose-code:font-mono prose-code:font-normal prose-code:before:content-none prose-code:after:content-none prose-pre:rounded-md prose-pre:bg-slate-100 prose-pre:border-2 prose-pre:border-slate-200 prose-pre:text-slate-800 prose-pre:p-4 prose-pre:overflow-x-auto prose-pre:text-xs prose-pre:font-mono prose-pre:mb-4'; @@ -26,6 +25,17 @@ const BouncingDots = () => ( ); +/** A settled status row in the woven timeline ("Edited workflow structure" + tick) */ +const StatusSegmentRow = ({ content }: { content: string }) => ( +
+
+); + /** * Custom code block component for react-markdown * Renders code with COPY/ADD action buttons @@ -159,6 +169,55 @@ const MarkdownContent = ({ ); }; +/** + * Woven timeline of text and status segments (global assistant replies). + * Text segments render as markdown blocks; status segments as italic rows. + * Status segments are completed actions and always render settled (tick). + * The transient thinking indicator (dots) renders separately, from the + * scalar streamingStatus, below the timeline. + */ +const SegmentTimeline = ({ + segments, + streaming = false, + showAddButtons = false, + isWriteDisabled = false, +}: { + segments: ResponseSegment[]; + streaming?: boolean; + showAddButtons?: boolean; + isWriteDisabled?: boolean; +}) => ( + <> + {segments.map((segment, index) => { + const isLast = index === segments.length - 1; + + if (segment.type === 'status') { + return ( + + ); + } + + return ( + + ); + })} + +); + /** * Copy text to clipboard using modern Clipboard API */ @@ -408,6 +467,14 @@ interface MessageListProps { isWriteDisabled?: boolean; streamingContent?: string | null; streamingStatus?: string | null; + /** Woven text/status timeline built while a reply streams in */ + streamingSegments?: ResponseSegment[] | null; + /** + * Whether the global assistant is active. Gates the woven streaming + * timeline — non-global streams keep the flat content + single scalar + * status row behavior. + */ + isGlobalAssistantActive?: boolean; } export function MessageList({ @@ -426,6 +493,8 @@ export function MessageList({ isWriteDisabled = false, streamingContent, streamingStatus, + streamingSegments, + isGlobalAssistantActive = false, }: MessageListProps) { const loadingRef = useRef(null); const messagesEndRef = useRef(null); @@ -491,22 +560,45 @@ export function MessageList({ // NOTE: This useMemo must be BEFORE the early return to maintain consistent // hook count across renders (React rules of hooks). const displayMessages = useMemo(() => { - if (streamingContent && messages.length > 0) { + // A global reply can open with a status segment before any text has + // streamed (Apollo edits the workflow first, then writes prose), so the + // placeholder must exist as soon as either arrives. + const hasStream = + !!streamingContent || + (isGlobalAssistantActive && !!streamingSegments?.length); + if (hasStream && messages.length > 0) { return [ ...messages, { id: STREAMING_MESSAGE_ID, role: 'assistant' as const, - content: streamingContent, + content: streamingContent ?? '', status: 'streaming' as const, } as Message & { status: 'streaming' }, ]; } return messages; - }, [messages, streamingContent]); + }, [messages, streamingContent, streamingSegments, isGlobalAssistantActive]); const isStreaming = (message: Message) => message.id === STREAMING_MESSAGE_ID; + // Woven text/status timeline to render instead of flat content, or null. + // - Completed messages: persisted `response_segments` (global replies). + // - Streaming placeholder: live `streamingSegments`. Gated on the global + // assistant being active as a deliberate blast-radius hold: job and + // workflow chat are live services, and keeping their streaming render + // on the flat `streamingContent` path means this PR cannot change what + // they display. Only Apollo's global endpoint emits status segments + // today; lift the gate when that changes. + const timelineSegments = (message: Message): ResponseSegment[] | null => { + if (isStreaming(message)) { + return isGlobalAssistantActive && streamingSegments?.length + ? streamingSegments + : null; + } + return message.response_segments?.length ? message.response_segments : null; + }; + if (messages.length === 0) { return (
- {displayMessages.map(message => ( -
-
- {message.role === 'assistant' ? ( -
-
- {message.status === 'error' && - !isStreaming(message) && - message.content.trim() ? ( -
-
- -

- {message.content} -

-
-
- ) : ( - - )} - - {/* Status (e.g. "Generating code...") Apollo may stream - after the text answer, while we wait for code. Same - visual as the pre-text loading indicator. */} - {isStreaming(message) && streamingStatus && ( -
-
- + {displayMessages.map(message => { + const segments = timelineSegments(message); + const showMessageAddButtons = + !isStreaming(message) && showAddButtons && !message.code; + + return ( +
+
+ {message.role === 'assistant' ? ( +
+
+ {message.status === 'error' && + !isStreaming(message) && + message.content.trim() ? ( +
+
+ +

+ {message.content} +

+
- - {streamingStatus} - -
- )} + ) : segments ? ( + + ) : ( + + )} - {!isStreaming(message) && message.code && ( -
+ {/* Transient thinking status — the registry clears it + when text or a persistent status segment arrives. */} + {isStreaming(message) && streamingStatus && (
-
+ )} + + {!isStreaming(message) && message.code && ( +
+
+ + { + if (message.job_id) { + onApplyJobCode?.(message.code!, message.id); } else { - next.add(message.id); + onApplyWorkflow?.(message.code!, message.id); } - return next; - }); - }} - className="flex items-center gap-2 hover:opacity-75 transition-opacity" - > - { + if (message.from_global) { + // Per-step diff from the full workflow YAML + onPreviewGlobalStep?.( + message.code!, + message.id + ); + } else { + onPreviewJobCode?.(message.code!, message.id); + } + }} + isApplying={!!applyingMessageId} + isPreviewActive={previewingMessageId === message.id} + isWriteDisabled={isWriteDisabled} + /> +
+ {expandedYaml.has(message.id) && ( +
-                            
-                          
-                          
-                            {message.job_id
-                              ? 'Generated Job Code'
-                              : 'Generated Workflow'}
+                            {message.code}
+                          
+ )} +
+ )} + + {!isStreaming(message) && + message.status === 'error' && + !message.content.trim() && ( +
+ + + Failed to send message. Please try again. - - { - if (message.job_id) { - onApplyJobCode?.(message.code!, message.id); - } else { - onApplyWorkflow?.(message.code!, message.id); - } - }} - onPreview={() => { - if (message.from_global) { - // Per-step diff from the full workflow YAML - onPreviewGlobalStep?.(message.code!, message.id); - } else { - onPreviewJobCode?.(message.code!, message.id); + {onRetryMessage && ( + + )} +
+ )} + + {!isStreaming(message) && + message.status === 'processing' && ( +
+
+ +
+
+ )} + +
+ {formatTimestamp(message.inserted_at)} + + +
+
+
+ ) : ( +
+
+
+
+ {message.content}
- {expandedYaml.has(message.id) && ( -
-                          {message.code}
-                        
- )}
- )} - {!isStreaming(message) && - message.status === 'error' && - !message.content.trim() && ( + {message.status === 'error' && (
- - - Failed to send message. Please try again. + + + Failed to send {onRetryMessage && ( )}
)} - {!isStreaming(message) && message.status === 'processing' && ( -
-
- -
-
- )} - -
- {formatTimestamp(message.inserted_at)} - - +
-
- ) : ( -
-
-
-
- {message.content} -
-
- - {message.status === 'error' && ( -
- - - Failed to send - - {onRetryMessage && ( - - )} -
- )} - - - {formatUserName(message.user) ? ( - <> - Sent by {formatUserName(message.user)} •{' '} - {formatTimestamp(message.inserted_at)} - - ) : ( - formatTimestamp(message.inserted_at) - )} - -
-
- )} + )} +
-
- ))} + ); + })} {isLoading && !streamingContent && (
{ ); }; +/** + * Get the woven text/status streaming timeline. Populated for every stream + * (text chunks are mirrored in); status segments only occur on global + * assistant streams today. + */ +export const useAIStreamingSegments = () => { + const store = useAIStore(); + return useSyncExternalStore( + store.subscribe, + store.withSelector(state => state.streamingSegments) + ); +}; + /** * Get streaming changes (code edits or workflow YAML sent before text streams) */ diff --git a/assets/js/collaborative-editor/hooks/useAIWorkflowApplications.ts b/assets/js/collaborative-editor/hooks/useAIWorkflowApplications.ts index a2a5c77dfc..01244a6c90 100644 --- a/assets/js/collaborative-editor/hooks/useAIWorkflowApplications.ts +++ b/assets/js/collaborative-editor/hooks/useAIWorkflowApplications.ts @@ -20,6 +20,7 @@ import type { StreamingApplyState, WorkflowTemplateContext, } from '../types/ai-assistant'; +import { STREAMING_MESSAGE_ID } from '../types/ai-assistant'; import type { AIModeResult } from './useAIMode'; import { NOT_CONNECTED_ALERT, STILL_CONNECTING_ALERT } from './useWorkflow'; @@ -109,6 +110,7 @@ export function useAIWorkflowApplications({ currentSession, currentUserId, aiMode, + isGlobalSession, isNewWorkflow, isSessionConnected, isSessionConnecting, @@ -133,6 +135,12 @@ export function useAIWorkflowApplications({ } | null; currentUserId: string | undefined; aiMode: AIModeResult | null; + /** + * Whether the active session is a global assistant session. Mid-stream + * applies (STREAMING_MESSAGE_ID) have no session message to look up, so this + * flag decides whether they get the global page-independent treatment. + */ + isGlobalSession: boolean; isNewWorkflow: boolean; /** * Workflow-session socket connectivity (distinct from `connectionState`, @@ -260,11 +268,16 @@ export function useAIWorkflowApplications({ ): Promise<'applied' | 'gated' | 'failed'> => { if (!aiMode) return 'failed'; // Global messages carry a full workflow YAML and may be applied even - // while a job is open (job_code mode). Non-global workflow chat keeps - // the workflow_template-only guard so its Apply stays a no-op when a - // job is open. - const isGlobal = !!currentSession?.messages.find(m => m.id === messageId) - ?.from_global; + // while a job is open (job_code mode). Mid-stream applies + // (STREAMING_MESSAGE_ID) have no session message to look up, so the caller + // tells us via isGlobalSession whether the active session is global. + // A non-global workflow chat stream (e.g. the user navigated to a job + // mid-stream) keeps the workflow_template-only guard so its Apply + // stays a no-op when a job is open. + const isGlobal = + (messageId === STREAMING_MESSAGE_ID && isGlobalSession) || + !!currentSession?.messages.find(m => m.id === messageId)?.from_global; + if (aiMode.page !== 'workflow_template' && !isGlobal) { console.error( '[AI Assistant] Cannot apply workflow - not in workflow mode', @@ -300,7 +313,7 @@ export function useAIWorkflowApplications({ // Any non-streaming apply supersedes a pending streaming apply — the // canvas will no longer hold the streamed YAML after this import. - if (messageId !== '__streaming__') { + if (messageId !== STREAMING_MESSAGE_ID) { streamingApplyActions.clear(); } @@ -327,7 +340,7 @@ export function useAIWorkflowApplications({ await importWorkflow(workflowStateWithCreds); applySucceeded = true; - if (messageId === '__streaming__') { + if (messageId === STREAMING_MESSAGE_ID) { // Record the applied YAML so the auto-apply effect can skip the // duplicate import when the final new_message carries the same YAML. // Set only after a successful import, so failed applies never @@ -377,6 +390,7 @@ export function useAIWorkflowApplications({ [ aiMode, currentSession, + isGlobalSession, importWorkflow, startApplyingWorkflow, doneApplyingWorkflow, @@ -414,7 +428,7 @@ export function useAIWorkflowApplications({ * re-apply loop); the user can re-apply via the still-enabled manual button * and save failures recover via the shared Retry toast. * - * The '__streaming__' pseudo-message is deliberately NOT routed through here + * The STREAMING_MESSAGE_ID pseudo-message is deliberately NOT routed through here * (it calls handleApplyWorkflow directly) so it never lands in * appliedMessageIdsRef and can be superseded by the final new_message. */ @@ -472,7 +486,7 @@ export function useAIWorkflowApplications({ // If we're previewing from streaming and the real message arrives, // just update the message ID without re-rendering the diff - if (previewingMessageId === '__streaming__') { + if (previewingMessageId === STREAMING_MESSAGE_ID) { setPreviewingMessageId(messageId); return; } @@ -522,7 +536,7 @@ export function useAIWorkflowApplications({ // Same dedup guards as handlePreviewJobCode if (previewingMessageId === messageId) return; - if (previewingMessageId === '__streaming__') { + if (previewingMessageId === STREAMING_MESSAGE_ID) { setPreviewingMessageId(messageId); return; } @@ -678,7 +692,16 @@ export function useAIWorkflowApplications({ if (!currentSession) return; const messages = currentSession.messages; - if (page !== 'workflow_template' || !messages.length) return; + // Global sessions apply page-independently (their streaming applies can + // finish while a job is open), so they must reconcile here too — + // otherwise a streamingApply record would stay stuck and a failed save + // would never retry until the user navigated back to the canvas. + if ( + (page !== 'workflow_template' && !isGlobalSession) || + !messages.length + ) { + return; + } if (connectionState !== 'connected') return; // Don't auto-apply when readonly (except for new workflow creation) if (!canApplyChanges) return; @@ -754,6 +777,7 @@ export function useAIWorkflowApplications({ }, [ currentSession, page, + isGlobalSession, sessionId, connectionState, launchApply, diff --git a/assets/js/collaborative-editor/lib/AIChannelRegistry.ts b/assets/js/collaborative-editor/lib/AIChannelRegistry.ts index 3c99e44850..e7c96c990a 100644 --- a/assets/js/collaborative-editor/lib/AIChannelRegistry.ts +++ b/assets/js/collaborative-editor/lib/AIChannelRegistry.ts @@ -114,6 +114,7 @@ interface ChannelEntry { messageStatusChanged: ChannelCallback; streamingChunk: ChannelCallback; streamingStatus: ChannelCallback; + streamingSegment: ChannelCallback; streamingChanges: ChannelCallback; streamingError: ChannelCallback; }; @@ -137,6 +138,11 @@ export class AIChannelRegistry { private streamingBuffer = ''; private streamingDrainPos = 0; private streamingDrainTimer: ReturnType | null = null; + // Status markers pinned to buffer positions. A status arriving over the + // wire is emitted into the store's streamingSegments timeline only once + // every character buffered before it has drained, preserving wire order + // (same guarantee drainThenRun provides for new_message). + private pendingStatusMarkers: Array<{ pos: number; text: string }> = []; // Delay in ms between each letter. 15ms ≈ 65 chars/sec. private static readonly LETTER_INTERVAL_MS = 15; // Callback to run after the buffer finishes draining (e.g., finalize message) @@ -163,10 +169,41 @@ export class AIChannelRegistry { this.startDraining(); } + /** + * Enqueue a status marker at the current end of the streaming buffer so it + * enters the store's streamingSegments timeline only after the text that + * preceded it on the wire has drained. + */ + private bufferStreamingStatusSegment(text: string): void { + this.pendingStatusMarkers.push({ + pos: this.streamingBuffer.length, + text, + }); + this.startDraining(); + } + + /** + * Emit any status markers whose buffer position has been reached by the + * char drain (i.e. all text before them has already been appended). + */ + private flushDueStatusMarkers(): void { + let next = this.pendingStatusMarkers.at(0); + while (next && next.pos <= this.streamingDrainPos) { + this.pendingStatusMarkers.shift(); + this.store._appendStreamingSegment({ + type: 'status', + content: next.text, + }); + next = this.pendingStatusMarkers.at(0); + } + } + private startDraining(): void { if (this.streamingDrainTimer !== null) return; this.streamingDrainTimer = setInterval(() => { + this.flushDueStatusMarkers(); + if (this.streamingDrainPos >= this.streamingBuffer.length) { // Buffer fully drained — if a callback is waiting, run it now if (this.streamingDrainCallback) { @@ -191,6 +228,7 @@ export class AIChannelRegistry { } this.streamingBuffer = ''; this.streamingDrainPos = 0; + this.pendingStatusMarkers = []; } /** @@ -199,7 +237,9 @@ export class AIChannelRegistry { */ private drainThenRun(callback: () => void): void { if (this.streamingDrainPos >= this.streamingBuffer.length) { - // Nothing left to drain + // Nothing left to drain — emit any statuses already due before the + // stream finalizes, so the timeline matches wire order to the end. + this.flushDueStatusMarkers(); this.stopDraining(); callback(); } else { @@ -608,6 +648,7 @@ export class AIChannelRegistry { ); entry.channel.off('streaming_chunk', entry.handlers.streamingChunk); entry.channel.off('streaming_status', entry.handlers.streamingStatus); + entry.channel.off('streaming_segment', entry.handlers.streamingSegment); entry.channel.off('streaming_changes', entry.handlers.streamingChanges); entry.channel.off('streaming_error', entry.handlers.streamingError); entry.channel.leave(); @@ -674,11 +715,33 @@ export class AIChannelRegistry { this.bufferStreamingChunk(typedPayload.content); }; + // Transient "thinking" updates: scalar only. They replace each other and + // are cleared by any subsequent event (text chunk or status segment). + // They never enter the persistent segments timeline. const streamingStatusHandler: ChannelCallback = (payload: unknown) => { const typedPayload = payload as { text: string }; this.store.setStreamingStatus(typedPayload.text); }; + // Persistent completed-action statuses (same shape as a + // response_segments entry): supersede any active thinking status at + // network arrival, and enter the woven timeline through the char drain + // so they land after the text that preceded them on the wire. + const streamingSegmentHandler: ChannelCallback = (payload: unknown) => { + const typedPayload = payload as { + segment?: { type?: string; content?: string }; + }; + // Only status segments exist on the wire today; anything else is a + // contract change and is ignored until the client learns about it. + if (typedPayload.segment?.type !== 'status') return; + if (typeof typedPayload.segment.content !== 'string') return; + + if (this.store.getSnapshot().streamingStatus) { + this.store.setStreamingStatus(null); + } + this.bufferStreamingStatusSegment(typedPayload.segment.content); + }; + const streamingChangesHandler: ChannelCallback = (payload: unknown) => { const typedPayload = payload as { changes: Record; @@ -698,6 +761,7 @@ export class AIChannelRegistry { channel.on('message_status_changed', messageStatusChangedHandler); channel.on('streaming_chunk', streamingChunkHandler); channel.on('streaming_status', streamingStatusHandler); + channel.on('streaming_segment', streamingSegmentHandler); channel.on('streaming_changes', streamingChangesHandler); channel.on('streaming_error', streamingErrorHandler); @@ -709,6 +773,7 @@ export class AIChannelRegistry { messageStatusChanged: messageStatusChangedHandler, streamingChunk: streamingChunkHandler, streamingStatus: streamingStatusHandler, + streamingSegment: streamingSegmentHandler, streamingChanges: streamingChangesHandler, streamingError: streamingErrorHandler, }; @@ -820,6 +885,7 @@ export class AIChannelRegistry { ); entry.channel.off('streaming_chunk', entry.handlers.streamingChunk); entry.channel.off('streaming_status', entry.handlers.streamingStatus); + entry.channel.off('streaming_segment', entry.handlers.streamingSegment); entry.channel.off('streaming_changes', entry.handlers.streamingChanges); entry.channel.leave(); diff --git a/assets/js/collaborative-editor/stores/createAIAssistantStore.ts b/assets/js/collaborative-editor/stores/createAIAssistantStore.ts index b642e16e4b..709df1ab77 100644 --- a/assets/js/collaborative-editor/stores/createAIAssistantStore.ts +++ b/assets/js/collaborative-editor/stores/createAIAssistantStore.ts @@ -61,6 +61,7 @@ import type { JobCodeContext, Message, MessageStatus, + ResponseSegment, Session, SessionListResponse, SessionSummary, @@ -89,6 +90,7 @@ export const createAIAssistantStore = (): AIAssistantStore => { streamingContent: null, streamingStatus: null, streamingChanges: null, + streamingSegments: [], streamingApply: null, sessionList: [], sessionListLoading: false, @@ -162,6 +164,7 @@ export const createAIAssistantStore = (): AIAssistantStore => { draft.streamingContent = null; draft.streamingStatus = null; draft.streamingChanges = null; + draft.streamingSegments = []; }); notify('disconnect'); @@ -209,6 +212,7 @@ export const createAIAssistantStore = (): AIAssistantStore => { draft.streamingContent = null; draft.streamingStatus = null; draft.streamingChanges = null; + draft.streamingSegments = []; draft.streamingApply = null; }); @@ -418,6 +422,7 @@ export const createAIAssistantStore = (): AIAssistantStore => { draft.streamingContent = null; draft.streamingStatus = null; draft.streamingChanges = null; + draft.streamingSegments = []; } else if (message.status === 'processing') { draft.isLoading = true; } @@ -447,6 +452,7 @@ export const createAIAssistantStore = (): AIAssistantStore => { if (status === 'error') { draft.streamingContent = null; draft.streamingStatus = null; + draft.streamingSegments = []; } if (status === 'processing') { draft.isLoading = true; @@ -568,10 +574,31 @@ export const createAIAssistantStore = (): AIAssistantStore => { const _appendStreamingChunk = (content: string) => { state = produce(state, draft => { draft.streamingContent = (draft.streamingContent || '') + content; + + // streamingContent stays the flat source of truth; the timeline is a + // parallel view of the same text, split by status segments. + const lastSegment = draft.streamingSegments.at(-1); + if (lastSegment && lastSegment.type === 'text') { + lastSegment.content += content; + } else { + draft.streamingSegments.push({ type: 'text', content }); + } }); notify('_appendStreamingChunk'); }; + /** + * Append a status segment to the streaming timeline. Only the channel + * registry's char drain may call this — that is what keeps wire order. + * @internal + */ + const _appendStreamingSegment = (segment: ResponseSegment) => { + state = produce(state, draft => { + draft.streamingSegments.push(segment); + }); + notify('_appendStreamingSegment'); + }; + const setStreamingStatus = (text: string | null) => { state = produce(state, draft => { draft.streamingStatus = text; @@ -593,6 +620,7 @@ export const createAIAssistantStore = (): AIAssistantStore => { draft.streamingContent = null; draft.streamingStatus = null; draft.streamingChanges = null; + draft.streamingSegments = []; }); notify('_clearStreaming'); }; @@ -720,6 +748,7 @@ export const createAIAssistantStore = (): AIAssistantStore => { _initializeContext, _setProcessingState, _appendStreamingChunk, + _appendStreamingSegment, setStreamingStatus, _setStreamingChanges, _clearStreaming, diff --git a/assets/js/collaborative-editor/types/ai-assistant.ts b/assets/js/collaborative-editor/types/ai-assistant.ts index 47bf189170..1a1da00294 100644 --- a/assets/js/collaborative-editor/types/ai-assistant.ts +++ b/assets/js/collaborative-editor/types/ai-assistant.ts @@ -41,6 +41,23 @@ export interface MessageUser { last_name: string | null; } +/** + * Message id of the in-flight streaming placeholder. Applies triggered + * mid-stream carry this id instead of a persisted message id. + */ +export const STREAMING_MESSAGE_ID = '__streaming__' as const; + +/** + * A single entry in an assistant message's display timeline: either a chunk + * of answer text or a status update ("Adding step...") woven between texts. + * Mirrors the backend `response_segments` contract + * (`{"type": "text" | "status", "content": string}`). + */ +export interface ResponseSegment { + type: 'text' | 'status'; + content: string; +} + /** * Message represents a single chat message in the AI assistant */ @@ -59,6 +76,11 @@ export interface Message { * messages carry a full workflow YAML in `code` and never a `job_id`. */ from_global?: boolean; + /** + * Interleaved text/status timeline for global assistant replies. + * `null`/absent for legacy and non-global messages (render flat `content`). + */ + response_segments?: ResponseSegment[] | null; } /** @@ -167,6 +189,12 @@ export interface AIAssistantState { streamingContent: string | null; streamingStatus: string | null; streamingChanges: Record | null; + /** + * Woven text/status timeline built up while a reply streams in. + * Append-only during a stream; fed exclusively by the char drain so wire + * order is preserved. Reset alongside the other streaming fields. + */ + streamingSegments: ResponseSegment[]; streamingApply: StreamingApplyState | null; sessionList: SessionSummary[]; @@ -224,6 +252,7 @@ export interface AIAssistantStore { ) => void; _setProcessingState: (isProcessing: boolean) => void; _appendStreamingChunk: (content: string) => void; + _appendStreamingSegment: (segment: ResponseSegment) => void; setStreamingStatus: (text: string | null) => void; _setStreamingChanges: (changes: Record) => void; _clearStreaming: () => void; diff --git a/assets/test/collaborative-editor/components/MessageList.test.tsx b/assets/test/collaborative-editor/components/MessageList.test.tsx index 1aba7f478a..a99fa96bab 100644 --- a/assets/test/collaborative-editor/components/MessageList.test.tsx +++ b/assets/test/collaborative-editor/components/MessageList.test.tsx @@ -950,6 +950,182 @@ describe('MessageList', () => { }); }); + describe('Response Segments Timeline', () => { + it('renders text blocks and settled status rows in segment order', () => { + const messages = [ + createMockAIMessage({ + role: 'assistant', + content: 'Final answer', + response_segments: [ + { type: 'text', content: 'Adding a step first.' }, + { type: 'status', content: 'Adding step send-to-gmail...' }, + { type: 'text', content: 'Final answer' }, + { type: 'status', content: 'Validating workflow...' }, + ], + }), + ]; + + render(); + + const assistantMessage = screen.getByTestId('assistant-message'); + + // Statuses render settled: italic gray rows, no bouncing dots + const statusRows = screen.getAllByTestId('settled-status'); + expect(statusRows).toHaveLength(2); + expect(statusRows[0]).toHaveTextContent('Adding step send-to-gmail...'); + expect(statusRows[1]).toHaveTextContent('Validating workflow...'); + statusRows.forEach(row => { + expect(row.querySelector('.animate-bounce')).not.toBeInTheDocument(); + expect(row.querySelector('.italic')).toBeInTheDocument(); + }); + + // Both text segments render (not the joined flat content once) + expect(screen.getByText('Adding a step first.')).toBeInTheDocument(); + expect(screen.getByText('Final answer')).toBeInTheDocument(); + + // DOM order matches segment order: text, status, text, status + const textContent = assistantMessage.textContent ?? ''; + expect(textContent.indexOf('Adding a step first.')).toBeLessThan( + textContent.indexOf('Adding step send-to-gmail...') + ); + expect(textContent.indexOf('Adding step send-to-gmail...')).toBeLessThan( + textContent.indexOf('Final answer') + ); + expect(textContent.indexOf('Final answer')).toBeLessThan( + textContent.indexOf('Validating workflow...') + ); + }); + + it('renders flat content when response_segments is absent or empty', () => { + const messages = [ + createMockAIMessage({ + id: '1', + role: 'assistant', + content: 'Legacy flat message', + }), + createMockAIMessage({ + id: '2', + role: 'assistant', + content: 'Empty segments message', + response_segments: [], + }), + ]; + + render(); + + expect(screen.getByText('Legacy flat message')).toBeInTheDocument(); + expect(screen.getByText('Empty segments message')).toBeInTheDocument(); + expect(screen.queryByTestId('settled-status')).not.toBeInTheDocument(); + }); + + it('settles timeline statuses with ticks and shows the thinking scalar with dots (global active)', () => { + const messages = [ + createMockAIMessage({ role: 'user', content: 'Question' }), + ]; + + const { rerender } = render( + + ); + + // Timeline statuses are completed actions: settled + tick, even the + // trailing one, even mid-stream. + const settled = screen.getAllByTestId('settled-status'); + expect(settled).toHaveLength(2); + for (const row of settled) { + expect(row.querySelector('.animate-bounce')).not.toBeInTheDocument(); + expect(row.querySelector('.hero-check-micro')).toBeInTheDocument(); + } + + // The transient thinking status renders below with dots. + const thinking = screen.getByTestId('streaming-status'); + expect(thinking).toHaveTextContent('Writing the next step...'); + expect(thinking.querySelectorAll('.animate-bounce')).toHaveLength(3); + + // No thinking scalar → no dots row at all. + rerender( + + ); + expect(screen.queryByTestId('streaming-status')).not.toBeInTheDocument(); + expect(screen.getByTestId('settled-status')).toHaveTextContent( + 'Edited workflow structure' + ); + }); + + it('shows a leading status segment before any text has streamed', () => { + // Apollo can complete an action (and emit its status) before the + // first text chunk arrives; the streaming placeholder must render + // from segments alone. + const messages = [ + createMockAIMessage({ role: 'user', content: 'Question' }), + ]; + + render( + + ); + + expect(screen.getByTestId('streaming-message')).toBeInTheDocument(); + expect(screen.getByTestId('settled-status')).toHaveTextContent( + 'Edited workflow structure' + ); + }); + + it('keeps flat streaming rendering when the global assistant is not active', () => { + const messages = [ + createMockAIMessage({ role: 'user', content: 'Question' }), + ]; + + render( + + ); + + // Flat content + single scalar status row, no woven timeline + expect(screen.getByText('Flat answer')).toBeInTheDocument(); + expect(screen.queryByTestId('settled-status')).not.toBeInTheDocument(); + const status = screen.getByTestId('streaming-status'); + expect(status).toHaveTextContent('Generating code...'); + expect(status.querySelectorAll('.animate-bounce')).toHaveLength(3); + }); + }); + describe('Props Handling', () => { it('should handle undefined messages prop', () => { render(); diff --git a/assets/test/collaborative-editor/hooks/useAIWorkflowApplications.workflowStreamingApplyGlobal.test.ts b/assets/test/collaborative-editor/hooks/useAIWorkflowApplications.workflowStreamingApplyGlobal.test.ts new file mode 100644 index 0000000000..a7f4311690 --- /dev/null +++ b/assets/test/collaborative-editor/hooks/useAIWorkflowApplications.workflowStreamingApplyGlobal.test.ts @@ -0,0 +1,143 @@ +/** + * useAIWorkflowApplications - Streaming Apply Reconciliation for Global Sessions + * + * Global sessions apply streamed YAML page-independently, so their + * reconciliation (clearing the streamingApply record, retrying an owed + * save) must also run when the reply finishes while the user is on a + * job page — not only on the workflow_template page. + */ + +import { renderHook, waitFor } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +import { useAIWorkflowApplications } from '../../../js/collaborative-editor/hooks/useAIWorkflowApplications'; +import type { ConnectionState } from '../../../js/collaborative-editor/types/ai-assistant'; + +import { createAIWorkflowApplicationsMocks } from './__helpers__/aiWorkflowApplicationsTestSetup'; + +vi.mock('../../../js/yaml/util', async () => { + const { aiWorkflowApplicationsYamlUtilMock } = await import( + './__helpers__/aiWorkflowApplicationsTestSetup' + ); + return aiWorkflowApplicationsYamlUtilMock(); +}); + +vi.mock('../../../js/collaborative-editor/lib/notifications', async () => { + const { aiWorkflowApplicationsNotificationsMock } = await import( + './__helpers__/aiWorkflowApplicationsTestSetup' + ); + return aiWorkflowApplicationsNotificationsMock(); +}); + +describe('useAIWorkflowApplications - global session reconciliation off the canvas page', () => { + const { + mockImportWorkflow, + mockSetPreviewingMessageId, + mockSetApplyingMessageId, + mockStreamingApplyActions, + mockWorkflowActions, + createMockMonacoRef, + createMockAIMode, + } = createAIWorkflowApplicationsMocks(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + const userMessage = { + id: 'user-msg-1', + role: 'user' as const, + content: 'Build me a workflow', + status: 'success' as const, + inserted_at: '2024-01-01T00:00:00Z', + user_id: 'user-123', + }; + + const assistantMessage = { + id: 'msg-1', + role: 'assistant' as const, + content: 'Here is your workflow', + code: 'name: Test', + status: 'success' as const, + inserted_at: '2024-01-01T00:00:01Z', + }; + + type Props = { + currentSession: { messages: (typeof userMessage)[] } | null; + streamingApply: { yaml: string; saveFailed: boolean } | null; + }; + + const renderOnJobPage = ( + saveWorkflow?: () => Promise, + appliedMessageIdsRef = { current: new Set() } + ) => + renderHook( + ({ currentSession, streamingApply }: Props) => + useAIWorkflowApplications({ + sessionId: 'session-1', + page: 'job_code', + currentSession, + currentUserId: 'user-123', + aiMode: createMockAIMode('job_code'), + isGlobalSession: true, + workflowActions: saveWorkflow + ? { ...mockWorkflowActions, saveWorkflow } + : mockWorkflowActions, + monacoRef: createMockMonacoRef(), + jobs: [], + canApplyChanges: true, + connectionState: 'connected' as ConnectionState, + setPreviewingMessageId: mockSetPreviewingMessageId, + previewingMessageId: null, + setApplyingMessageId: mockSetApplyingMessageId, + isNewWorkflow: true, + isSessionConnected: true, + isSessionConnecting: false, + appliedMessageIdsRef, + streamingApply, + streamingApplyActions: mockStreamingApplyActions, + }), + { + initialProps: { + currentSession: { messages: [userMessage] }, + streamingApply: null, + } as Props, + } + ); + + it('clears a matching streaming apply record while on the job page', async () => { + const appliedMessageIdsRef = { current: new Set() }; + const { rerender } = renderOnJobPage(undefined, appliedMessageIdsRef); + + rerender({ + currentSession: { + messages: [userMessage, assistantMessage] as (typeof userMessage)[], + }, + streamingApply: { yaml: 'name: Test', saveFailed: false }, + }); + + await waitFor(() => { + expect(appliedMessageIdsRef.current.has('msg-1')).toBe(true); + expect(mockStreamingApplyActions.clear).toHaveBeenCalled(); + }); + expect(mockImportWorkflow).not.toHaveBeenCalled(); + }); + + it('retries an owed save while on the job page', async () => { + const successfulSaveWorkflow = vi.fn(() => Promise.resolve(true)); + const { rerender } = renderOnJobPage(successfulSaveWorkflow); + + rerender({ + currentSession: { + messages: [userMessage, assistantMessage] as (typeof userMessage)[], + }, + streamingApply: { yaml: 'name: Test', saveFailed: true }, + }); + + await waitFor(() => { + expect(successfulSaveWorkflow).toHaveBeenCalled(); + expect(mockStreamingApplyActions.clear).toHaveBeenCalled(); + }); + expect(mockImportWorkflow).not.toHaveBeenCalled(); + }); +}); diff --git a/assets/test/collaborative-editor/lib/AIChannelRegistry.test.ts b/assets/test/collaborative-editor/lib/AIChannelRegistry.test.ts index d88ab0f983..6aa117c700 100644 --- a/assets/test/collaborative-editor/lib/AIChannelRegistry.test.ts +++ b/assets/test/collaborative-editor/lib/AIChannelRegistry.test.ts @@ -71,4 +71,106 @@ describe('AIChannelRegistry streaming', () => { expect(store.getSnapshot().streamingStatus).toBe('Writing code...'); expect(store.getSnapshot().streamingContent).toBe('Answer'); }); + + it('preserves wire order of text and status segments in the streaming timeline', () => { + channel._test.emit('streaming_chunk', { content: 'First' }); + channel._test.emit('streaming_segment', { + segment: { type: 'status', content: 'Added step' }, + }); + channel._test.emit('streaming_chunk', { content: 'Second' }); + + // The segment must not enter the timeline before the text preceding it + // on the wire has drained ("First" = 5 chars at 15ms each). + vi.advanceTimersByTime(4 * 15); + expect(store.getSnapshot().streamingSegments).toEqual([ + { type: 'text', content: 'Firs' }, + ]); + + // Drain everything. + vi.advanceTimersByTime(1000); + expect(store.getSnapshot().streamingSegments).toEqual([ + { type: 'text', content: 'First' }, + { type: 'status', content: 'Added step' }, + { type: 'text', content: 'Second' }, + ]); + expect(store.getSnapshot().streamingContent).toBe('FirstSecond'); + }); + + it('keeps thinking statuses out of the timeline and supersedes them with status segments', () => { + // Thinking events only touch the scalar, never the timeline. + channel._test.emit('streaming_status', { text: 'Reviewing workflow...' }); + expect(store.getSnapshot().streamingStatus).toBe('Reviewing workflow...'); + expect(store.getSnapshot().streamingSegments).toEqual([]); + + // A persistent status segment clears the thinking scalar at arrival... + channel._test.emit('streaming_segment', { + segment: { type: 'status', content: 'Reviewed workflow' }, + }); + expect(store.getSnapshot().streamingStatus).toBeNull(); + + // ...and lands in the timeline via the drain. + vi.advanceTimersByTime(200); + expect(store.getSnapshot().streamingSegments).toEqual([ + { type: 'status', content: 'Reviewed workflow' }, + ]); + }); + + it('flushes a trailing status into the timeline before the final message lands', () => { + // Track timeline appends so we can assert the status entered the + // timeline before finalization cleared the streaming state. + const appended: unknown[] = []; + const originalAppend = store._appendStreamingSegment.bind(store); + store._appendStreamingSegment = segment => { + appended.push(segment); + originalAppend(segment); + }; + + channel._test.emit('streaming_chunk', { content: 'Hi' }); + channel._test.emit('streaming_segment', { + segment: { type: 'status', content: 'Validated workflow' }, + }); + channel._test.emit('new_message', { + message: { + id: 'final-1', + role: 'assistant', + content: 'Hi', + status: 'success', + }, + }); + + // Drain everything; drainThenRun must flush the due status marker + // before running the finalize callback. + vi.advanceTimersByTime(1000); + + expect(appended).toContainEqual({ + type: 'status', + content: 'Validated workflow', + }); + expect( + store.getSnapshot().messages.find(m => m.id === 'final-1') + ).toBeTruthy(); + }); + + it('does not leak pending status markers from an errored stream into the next one', () => { + // A status is pinned behind text that will never finish draining. + channel._test.emit('streaming_chunk', { content: 'Long answer text' }); + channel._test.emit('streaming_segment', { + segment: { type: 'status', content: 'Stale status from dead stream' }, + }); + + // The stream errors before the drain reaches the status marker. + vi.advanceTimersByTime(2 * 15); + channel._test.emit('streaming_error', { error: 'boom' }); + expect(store.getSnapshot().streamingSegments).toEqual([]); + + // A fresh stream starts and fully drains. + channel._test.emit('streaming_chunk', { content: 'New' }); + vi.advanceTimersByTime(500); + + // Only the new stream's text is in the timeline — the dead stream's + // status marker must not resurface. + expect(store.getSnapshot().streamingSegments).toEqual([ + { type: 'text', content: 'New' }, + ]); + }); }); diff --git a/assets/test/collaborative-editor/stores/createAIAssistantStore.test.ts b/assets/test/collaborative-editor/stores/createAIAssistantStore.test.ts index 038dcf2278..e3829e0be8 100644 --- a/assets/test/collaborative-editor/stores/createAIAssistantStore.test.ts +++ b/assets/test/collaborative-editor/stores/createAIAssistantStore.test.ts @@ -364,6 +364,92 @@ describe('createAIAssistantStore', () => { }); }); + describe('Streaming Segments', () => { + it('stacks status segments and opens a new text segment after a status', () => { + store._appendStreamingChunk('First '); + store._appendStreamingChunk('answer'); + store._appendStreamingSegment({ + type: 'status', + content: 'Edited workflow structure', + }); + store._appendStreamingSegment({ + type: 'status', + content: 'Added step send-to-gmail', + }); + store._appendStreamingChunk('Done'); + + // Every status segment is a completed action from Apollo's dedicated + // status event — they all persist, in wire order, no collapsing. + expect(store.getSnapshot().streamingSegments).toEqual([ + { type: 'text', content: 'First answer' }, + { type: 'status', content: 'Edited workflow structure' }, + { type: 'status', content: 'Added step send-to-gmail' }, + { type: 'text', content: 'Done' }, + ]); + }); + + it('keeps a leading status segment when text starts', () => { + store._appendStreamingSegment({ + type: 'status', + content: 'Edited workflow structure', + }); + store._appendStreamingChunk('Answer'); + + expect(store.getSnapshot().streamingSegments).toEqual([ + { type: 'status', content: 'Edited workflow structure' }, + { type: 'text', content: 'Answer' }, + ]); + }); + + it('survives scalar-status clearing (setStreamingStatus and streaming changes)', () => { + store._appendStreamingChunk('Answer'); + store._appendStreamingSegment({ type: 'status', content: 'Working...' }); + + store.setStreamingStatus(null); + store._setStreamingChanges({ code: 'fn(s => s)' }); + + expect(store.getSnapshot().streamingSegments).toEqual([ + { type: 'text', content: 'Answer' }, + { type: 'status', content: 'Working...' }, + ]); + }); + + it('resets when the final assistant message lands', () => { + store._appendStreamingChunk('Answer'); + store._appendStreamingSegment({ type: 'status', content: 'Working...' }); + + store._addMessage( + createMockAIMessage({ role: 'assistant', status: 'success' }) + ); + + expect(store.getSnapshot().streamingSegments).toEqual([]); + }); + + it('resets on message error', () => { + const message = createMockAIMessage({ + role: 'assistant', + status: 'processing', + }); + store._addMessage(message); + store._appendStreamingChunk('Answer'); + store._appendStreamingSegment({ type: 'status', content: 'Working...' }); + + store._updateMessageStatus(message.id, 'error'); + + expect(store.getSnapshot().streamingSegments).toEqual([]); + }); + + it('resets on clearSession and disconnect', () => { + store._appendStreamingChunk('Answer'); + store.clearSession(); + expect(store.getSnapshot().streamingSegments).toEqual([]); + + store._appendStreamingChunk('Answer again'); + store.disconnect(); + expect(store.getSnapshot().streamingSegments).toEqual([]); + }); + }); + describe('Streaming Apply', () => { it('records, flags, and clears the streaming apply lifecycle', () => { store._setStreamingApply('name: Test'); diff --git a/lib/lightning/ai_assistant/ai_assistant.ex b/lib/lightning/ai_assistant/ai_assistant.ex index 6bb7ac7eaa..7d09c56c04 100644 --- a/lib/lightning/ai_assistant/ai_assistant.ex +++ b/lib/lightning/ai_assistant/ai_assistant.ex @@ -1206,6 +1206,31 @@ defmodule Lightning.AiAssistant do acc end + # Bridge event: status — a persistent, completed-action status, the same + # shape as a persisted `response_segments` entry, so the client renders + # live and reloaded status segments identically. Transient "thinking" + # updates arrive separately as Anthropic thinking events (see + # handle_stream_event/2). + defp handle_sse_event(session_id, %{event: "status", data: data}, acc) do + case Jason.decode(data) do + {:ok, %{"type" => "status", "content" => content} = segment} + when is_binary(content) -> + # Take only the contract fields so stray keys from Apollo never + # reach the client. + broadcast_streaming_segment( + session_id, + Map.take(segment, ["type", "content"]) + ) + + _ -> + Logger.warning( + "Dropping malformed status event for session #{session_id}" + ) + end + + acc + end + # Bridge event: log — skip Python stdout defp handle_sse_event(_session_id, %{event: "log"}, acc), do: acc @@ -1274,6 +1299,14 @@ defmodule Lightning.AiAssistant do ) end + defp broadcast_streaming_segment(session_id, segment) do + Lightning.broadcast( + "ai_session:#{session_id}", + {:ai_assistant, :streaming_segment, + %{segment: segment, session_id: session_id}} + ) + end + defp broadcast_streaming_error(session_id, error) do Lightning.broadcast( "ai_session:#{session_id}", @@ -1297,7 +1330,9 @@ defmodule Lightning.AiAssistant do case error_response do {:ok, %Tesla.Env{status: status, body: body}} when status not in @success_status_range -> - error_message = body["message"] + error_message = + error_message_from_body(body) || + "AI server returned an error (HTTP #{status})." Logger.error( "AI query failed for session #{session.id}: #{error_message}" @@ -1322,6 +1357,13 @@ defmodule Lightning.AiAssistant do end end + # Streaming requests carry a lazy Stream (a fun or %Stream{} struct) as the + # body, so error responses can't be indexed like decoded JSON maps. + defp error_message_from_body(body) when is_map(body) and not is_struct(body), + do: body["message"] + + defp error_message_from_body(_body), do: nil + defp build_job_message(body) do message = body["history"] |> Enum.reverse() |> hd() message_attrs = Map.take(message, ["role", "content"]) @@ -1353,16 +1395,59 @@ defmodule Lightning.AiAssistant do defp build_global_message(body) do code = extract_global_workflow_yaml(body["attachments"]) - message_attrs = %{ - role: :assistant, - content: body["response"], - meta: %{"from_global" => true} - } + message_attrs = + %{ + role: :assistant, + content: body["response"], + meta: %{"from_global" => true} + } + |> put_response_segments( + normalize_response_segments(body["response_segments"]) + ) opts = [usage: body["usage"] || %{}, meta: body["meta"], code: code] {message_attrs, opts} end + # Flat replies omit the key entirely: casting nil into embeds_many is an + # error, and an absent key leaves the column NULL for legacy parity. + defp put_response_segments(attrs, nil), do: attrs + + defp put_response_segments(attrs, segments), + do: Map.put(attrs, :response_segments, segments) + + # `response_segments` is the display timeline of the streamed reply (text and status + # segments in stream order); `response` stays the flat answer that history + # is rebuilt from. Apollo is an external boundary, so invalid or oversized + # segments are dropped (and counted in the logs) rather than failing the + # save: absent or all-invalid segments mean a flat legacy message. The + # segment contract itself lives in `ChatMessage.Segment`. + defp normalize_response_segments(segments) when is_list(segments) do + {valid, dropped} = + Enum.split_with(segments, fn segment -> + is_map(segment) and + ChatMessage.Segment.changeset(%ChatMessage.Segment{}, segment).valid? + end) + + max_segments = ChatMessage.max_response_segments() + {kept, truncated} = Enum.split(valid, max_segments) + + if dropped != [] or truncated != [] do + Logger.warning( + "Discarding response segments from Apollo payload: " <> + "#{length(dropped)} invalid, #{length(truncated)} over the " <> + "#{max_segments}-segment cap" + ) + end + + case kept do + [] -> nil + kept -> Enum.map(kept, &Map.take(&1, ["type", "content"])) + end + end + + defp normalize_response_segments(_segments), do: nil + # Global chat always returns a full workflow YAML (job bodies embedded). # The frontend handles per-step diffing and full-workflow apply. defp extract_global_workflow_yaml(attachments) when is_list(attachments) do diff --git a/lib/lightning/ai_assistant/chat_message.ex b/lib/lightning/ai_assistant/chat_message.ex index 2c3753b684..2552c7ab8d 100644 --- a/lib/lightning/ai_assistant/chat_message.ex +++ b/lib/lightning/ai_assistant/chat_message.ex @@ -10,6 +10,9 @@ defmodule Lightning.AiAssistant.ChatMessage do * `content` - The text content of the message (required, 1-10,000 characters) * `code` - Optional code associated with the message (e.g., generated workflows) + * `response_segments` - Optional display timeline of text and status segments + for assistant messages (global chat); `[]` for flat messages (the column + is NULL, which `embeds_many` loads as an empty list) * `role` - Who sent the message: `:user` or `:assistant` * `status` - Processing status: `:pending`, `:success`, `:error`, or `:cancelled` * `is_deleted` - Soft deletion flag (defaults to false) @@ -25,6 +28,45 @@ defmodule Lightning.AiAssistant.ChatMessage do alias Lightning.Workflows.Job + defmodule Segment do + @moduledoc """ + One entry in an assistant reply's display timeline: a chunk of answer + text, or a completed-action status ("Added step send-to-gmail...") woven + between texts in stream order. Display-only — the flat `content` field + stays the canonical answer. + """ + + use Ecto.Schema + import Ecto.Changeset + + @max_content_length 10_000 + + @type t() :: %__MODULE__{type: :text | :status, content: String.t()} + + @derive {Jason.Encoder, only: [:type, :content]} + @primary_key false + embedded_schema do + field :type, Ecto.Enum, values: [:text, :status] + field :content, :string + end + + @doc false + def changeset(segment, attrs) do + segment + |> cast(attrs, [:type, :content]) + |> validate_required([:type, :content]) + |> validate_length(:content, max: @max_content_length) + end + + @doc "Maximum length of a single segment's content (matches `content`'s cap)." + def max_content_length, do: @max_content_length + end + + # A reply's segment count is naturally bounded by the model's output size; + # this cap only guards against a runaway or buggy Apollo response bloating + # rows that get re-serialized on every channel join. + @max_response_segments 200 + @type role() :: :user | :assistant @type status() :: :pending | :processing | :success | :error | :cancelled @@ -32,6 +74,7 @@ defmodule Lightning.AiAssistant.ChatMessage do id: Ecto.UUID.t(), content: String.t() | nil, code: String.t() | nil, + response_segments: [Segment.t()], role: role(), status: status(), job_id: Ecto.UUID.t() | nil, @@ -51,6 +94,8 @@ defmodule Lightning.AiAssistant.ChatMessage do field :code, :string field :role, Ecto.Enum, values: [:user, :assistant] + embeds_many :response_segments, Segment, on_replace: :delete + field :status, Ecto.Enum, values: [:pending, :processing, :success, :error, :cancelled] @@ -79,6 +124,9 @@ defmodule Lightning.AiAssistant.ChatMessage do * `content` and `role` are required * `content` must be between 1 and 10,000 characters + * `response_segments`, when present, must be `#{@max_response_segments}` or + fewer segments, each with a `type` of `text` or `status` and a `content` + string of at most `#{Segment.max_content_length()}` characters * User messages (role: `:user`) require an associated user * Status defaults based on role: `:pending` for users, `:success` for assistant * If status is explicitly provided, it takes precedence over role-based defaults @@ -97,6 +145,8 @@ defmodule Lightning.AiAssistant.ChatMessage do :processing_started_at, :processing_completed_at ]) + |> cast_embed(:response_segments) + |> validate_length(:response_segments, max: @max_response_segments) |> validate_required([:content, :role]) |> validate_length(:content, min: 1, max: 10_000) |> maybe_put_user_assoc(attrs[:user] || attrs["user"]) @@ -105,6 +155,9 @@ defmodule Lightning.AiAssistant.ChatMessage do |> set_default_status_by_role() end + @doc "Maximum number of segments accepted on a message." + def max_response_segments, do: @max_response_segments + @doc """ Creates a changeset for updating message status. diff --git a/lib/lightning/ai_assistant/message_processor.ex b/lib/lightning/ai_assistant/message_processor.ex index 6747022db1..3e7fbcfb20 100644 --- a/lib/lightning/ai_assistant/message_processor.ex +++ b/lib/lightning/ai_assistant/message_processor.ex @@ -129,7 +129,9 @@ defmodule Lightning.AiAssistant.MessageProcessor do @spec dispatch_message_processing( AiAssistant.ChatSession.t(), ChatMessage.t() - ) :: {:ok, AiAssistant.ChatSession.t()} | {:error, String.t()} + ) :: + {:ok, AiAssistant.ChatSession.t()} + | {:error, String.t() | Ecto.Changeset.t()} defp dispatch_message_processing(session, message) do if global_chat?(session) do process_global_message(session, message) @@ -148,7 +150,8 @@ defmodule Lightning.AiAssistant.MessageProcessor do end @spec process_global_message(AiAssistant.ChatSession.t(), ChatMessage.t()) :: - {:ok, AiAssistant.ChatSession.t()} | {:error, String.t()} + {:ok, AiAssistant.ChatSession.t()} + | {:error, String.t() | Ecto.Changeset.t()} defp process_global_message(session, message) do workflow_yaml = message.code page = get_in(session.meta, ["message_options", "page"]) @@ -179,7 +182,8 @@ defmodule Lightning.AiAssistant.MessageProcessor do @spec handle_processing_result( ChatMessage.t(), - {:ok, AiAssistant.ChatSession.t()} | {:error, String.t()} + {:ok, AiAssistant.ChatSession.t()} + | {:error, String.t() | Ecto.Changeset.t()} ) :: {:ok, AiAssistant.ChatSession.t()} | {:error, String.t()} defp handle_processing_result(message, result) do case result do @@ -189,6 +193,17 @@ defmodule Lightning.AiAssistant.MessageProcessor do {:ok, updated_session} + {:error, %Ecto.Changeset{} = changeset} -> + {:ok, _updated_session, _updated_message} = + update_message_status(message, :error) + + Logger.error( + "[MessageProcessor] Failed to save assistant response for message " <> + "#{message.id}: invalid changeset: #{inspect(changeset.errors)}" + ) + + {:error, "Failed to save assistant response"} + {:error, error_message} -> {:ok, _updated_session, _updated_message} = update_message_status(message, :error) @@ -199,7 +214,8 @@ defmodule Lightning.AiAssistant.MessageProcessor do @doc false @spec process_job_message(AiAssistant.ChatSession.t(), ChatMessage.t()) :: - {:ok, AiAssistant.ChatSession.t()} | {:error, String.t()} + {:ok, AiAssistant.ChatSession.t()} + | {:error, String.t() | Ecto.Changeset.t()} defp process_job_message(session, message) do enriched_session = AiAssistant.enrich_session_with_job_context(session) @@ -256,7 +272,8 @@ defmodule Lightning.AiAssistant.MessageProcessor do @doc false @spec process_workflow_message(AiAssistant.ChatSession.t(), ChatMessage.t()) :: - {:ok, AiAssistant.ChatSession.t()} | {:error, String.t()} + {:ok, AiAssistant.ChatSession.t()} + | {:error, String.t() | Ecto.Changeset.t()} defp process_workflow_message(session, message) do code = message.code || workflow_code_from_session(session) diff --git a/lib/lightning_web/channels/ai_assistant_channel.ex b/lib/lightning_web/channels/ai_assistant_channel.ex index e098ad37e9..d6e667a2cc 100644 --- a/lib/lightning_web/channels/ai_assistant_channel.ex +++ b/lib/lightning_web/channels/ai_assistant_channel.ex @@ -351,6 +351,18 @@ defmodule LightningWeb.AiAssistantChannel do {:noreply, socket} end + # Streaming: a persistent completed-action status segment (same shape as a + # response_segments entry) + @impl true + def handle_info( + {:ai_assistant, :streaming_segment, + %{segment: segment, session_id: session_id}}, + %{assigns: %{session_id: session_id}} = socket + ) do + broadcast(socket, "streaming_segment", %{segment: segment}) + {:noreply, socket} + end + # Streaming: error during stream @impl true def handle_info( @@ -853,6 +865,7 @@ defmodule LightningWeb.AiAssistantChannel do id: message.id, content: message.content, code: message.code, + response_segments: message.response_segments, role: to_string(message.role), status: to_string(message.status), inserted_at: message.inserted_at, diff --git a/priv/repo/migrations/20260802120000_add_response_segments_to_ai_chat_messages.exs b/priv/repo/migrations/20260802120000_add_response_segments_to_ai_chat_messages.exs new file mode 100644 index 0000000000..a4944e3863 --- /dev/null +++ b/priv/repo/migrations/20260802120000_add_response_segments_to_ai_chat_messages.exs @@ -0,0 +1,11 @@ +defmodule Lightning.Repo.Migrations.AddResponseSegmentsToAiChatMessages do + use Ecto.Migration + + def change do + alter table(:ai_chat_messages) do + # jsonb holding an array of {type, content} segment objects + # (embeds_many on the schema); nil for flat legacy messages + add :response_segments, :map, null: true + end + end +end diff --git a/test/lightning/ai_assistant/chat_message_test.exs b/test/lightning/ai_assistant/chat_message_test.exs index a705cc0709..a6326fd777 100644 --- a/test/lightning/ai_assistant/chat_message_test.exs +++ b/test/lightning/ai_assistant/chat_message_test.exs @@ -152,6 +152,97 @@ defmodule Lightning.AiAssistant.ChatMessageTest do assert changeset.valid? end + test "accepts a valid segments timeline and casts to Segment embeds" do + segments = [ + %{"type" => "text", "content" => "Adding a step..."}, + %{"type" => "status", "content" => "Validating workflow..."}, + %{"type" => "text", "content" => "Done!"} + ] + + changeset = + ChatMessage.changeset(%ChatMessage{}, %{ + content: "Adding a step...\n\nDone!", + role: :assistant, + response_segments: segments + }) + + assert changeset.valid? + + assert [ + %ChatMessage.Segment{type: :text, content: "Adding a step..."}, + %ChatMessage.Segment{ + type: :status, + content: "Validating workflow..." + }, + %ChatMessage.Segment{type: :text, content: "Done!"} + ] = Ecto.Changeset.apply_changes(changeset).response_segments + end + + test "leaves segments empty when the key is absent (flat message)" do + changeset = + ChatMessage.changeset(%ChatMessage{}, %{ + content: "Flat response", + role: :assistant + }) + + assert changeset.valid? + + assert Ecto.Changeset.apply_changes(changeset).response_segments == [] + end + + test "rejects segments with unknown types or non-binary content" do + invalid_segments = [ + [%{"type" => "thinking", "content" => "hmm"}], + [%{"type" => "text", "content" => 123}], + [%{"content" => "no type"}], + [%{"type" => "text", "content" => "ok"}, %{"type" => "status"}] + ] + + for segments <- invalid_segments do + changeset = + ChatMessage.changeset(%ChatMessage{}, %{ + content: "Test message", + role: :assistant, + response_segments: segments + }) + + refute changeset.valid?, "expected #{inspect(segments)} to be invalid" + end + end + + test "rejects segments over the count cap and content over the length cap" do + too_many = + for i <- 1..(ChatMessage.max_response_segments() + 1) do + %{"type" => "text", "content" => "segment #{i}"} + end + + changeset = + ChatMessage.changeset(%ChatMessage{}, %{ + content: "Test message", + role: :assistant, + response_segments: too_many + }) + + refute changeset.valid? + + oversized = [ + %{ + "type" => "text", + "content" => + String.duplicate("x", ChatMessage.Segment.max_content_length() + 1) + } + ] + + changeset = + ChatMessage.changeset(%ChatMessage{}, %{ + content: "Test message", + role: :assistant, + response_segments: oversized + }) + + refute changeset.valid? + end + test "sets pending status by default for user messages" do user = insert(:user) diff --git a/test/lightning/ai_assistant/message_processor_test.exs b/test/lightning/ai_assistant/message_processor_test.exs index e208f16412..546aad72ec 100644 --- a/test/lightning/ai_assistant/message_processor_test.exs +++ b/test/lightning/ai_assistant/message_processor_test.exs @@ -7,6 +7,7 @@ defmodule Lightning.AiAssistant.MessageProcessorTest do import Lightning.Factories alias Lightning.AiAssistant + alias Lightning.AiAssistant.ChatMessage alias Lightning.AiAssistant.MessageProcessor # Note: Integration tests for I/O data scrubbing are tested at lower levels: @@ -329,6 +330,90 @@ defmodule Lightning.AiAssistant.MessageProcessorTest do assistant_msg = Enum.find(reloaded.messages, &(&1.role == :assistant)) assert assistant_msg != nil assert assistant_msg.content == "Global response" + # Flat-string responses have no timeline + assert assistant_msg.response_segments == [] + end + + test "persists the segments timeline alongside the flat response", + %{user: user, project: project} do + workflow = insert(:workflow, project: project) + + global_meta = %{ + "message_options" => %{ + "use_global_assistant" => true, + "page" => "/projects/p1/workflows/w1" + } + } + + session = + insert(:chat_session, + user: user, + session_type: "workflow_template", + project: project, + workflow: workflow, + job_id: nil, + meta: global_meta + ) + + {:ok, updated_session} = + AiAssistant.save_message( + session, + %{ + role: :user, + content: "add a step to my workflow", + user: user, + code: "workflow:\n name: test" + }, + meta: global_meta + ) + + user_message = Enum.find(updated_session.messages, &(&1.role == :user)) + + Mox.stub( + Lightning.Tesla.Mock, + :call, + Lightning.AiAssistantHelpers.streaming_or_sync_response(%{ + "response" => "Here's the final result.", + "response_segments" => [ + %{"type" => "text", "content" => "I'm going to add a step."}, + %{"type" => "status", "content" => "Adding step send-to-gmail..."}, + %{"type" => "text", "content" => "Here's the final result."}, + %{"type" => "unknown", "content" => "dropped during normalization"} + ], + "attachments" => [ + %{"type" => "workflow_yaml", "content" => "workflow:\n name: new"} + ], + "usage" => %{} + }) + ) + + assert :ok = + perform_job(MessageProcessor, %{"message_id" => user_message.id}) + + reloaded = AiAssistant.get_session!(session.id) + assistant_msg = Enum.find(reloaded.messages, &(&1.role == :assistant)) + + # Content is the flat response verbatim; segments keep the full timeline + # (minus unrecognised entries); code comes from attachments. + assert %{ + content: "Here's the final result.", + response_segments: [ + %ChatMessage.Segment{ + type: :text, + content: "I'm going to add a step." + }, + %ChatMessage.Segment{ + type: :status, + content: "Adding step send-to-gmail..." + }, + %ChatMessage.Segment{ + type: :text, + content: "Here's the final result." + } + ], + code: "workflow:\n name: new", + meta: %{"from_global" => true} + } = assistant_msg end test "dispatches to workflow chat when use_global_assistant is not set", %{ @@ -385,6 +470,292 @@ defmodule Lightning.AiAssistant.MessageProcessorTest do assert assistant_msg != nil assert assistant_msg.content == "Workflow response" end + + test "clamps segments over the cap instead of failing the save", + %{user: user, project: project} do + workflow = insert(:workflow, project: project) + + global_meta = %{ + "message_options" => %{ + "use_global_assistant" => true, + "page" => "/projects/p1/workflows/w1" + } + } + + session = + insert(:chat_session, + user: user, + session_type: "workflow_template", + project: project, + workflow: workflow, + job_id: nil, + meta: global_meta + ) + + {:ok, updated_session} = + AiAssistant.save_message( + session, + %{role: :user, content: "add a step", user: user}, + meta: global_meta + ) + + user_message = Enum.find(updated_session.messages, &(&1.role == :user)) + + max = ChatMessage.max_response_segments() + + segments = + for i <- 1..(max + 1) do + %{"type" => "text", "content" => "segment #{i}"} + end + + Mox.stub( + Lightning.Tesla.Mock, + :call, + Lightning.AiAssistantHelpers.streaming_or_sync_response(%{ + "response" => "Done!", + "response_segments" => segments, + "usage" => %{} + }) + ) + + log = + ExUnit.CaptureLog.capture_log(fn -> + assert :ok = + perform_job(MessageProcessor, %{ + "message_id" => user_message.id + }) + end) + + reloaded = AiAssistant.get_session!(session.id) + assistant_msg = Enum.find(reloaded.messages, &(&1.role == :assistant)) + + assert length(assistant_msg.response_segments) == max + assert log =~ "over the #{max}-segment cap" + end + + test "persists a flat message when every segment is invalid", + %{user: user, project: project} do + workflow = insert(:workflow, project: project) + + global_meta = %{ + "message_options" => %{ + "use_global_assistant" => true, + "page" => "/projects/p1/workflows/w1" + } + } + + session = + insert(:chat_session, + user: user, + session_type: "workflow_template", + project: project, + workflow: workflow, + job_id: nil, + meta: global_meta + ) + + {:ok, updated_session} = + AiAssistant.save_message( + session, + %{role: :user, content: "add a step", user: user}, + meta: global_meta + ) + + user_message = Enum.find(updated_session.messages, &(&1.role == :user)) + + Mox.stub( + Lightning.Tesla.Mock, + :call, + Lightning.AiAssistantHelpers.streaming_or_sync_response(%{ + "response" => "Done!", + "response_segments" => [ + %{"type" => "thinking", "content" => "unknown type"}, + %{"content" => "no type"} + ], + "usage" => %{} + }) + ) + + log = + ExUnit.CaptureLog.capture_log(fn -> + assert :ok = + perform_job(MessageProcessor, %{ + "message_id" => user_message.id + }) + end) + + reloaded = AiAssistant.get_session!(session.id) + assistant_msg = Enum.find(reloaded.messages, &(&1.role == :assistant)) + + # All-invalid segments degrade to a flat message (the key is omitted, + # so the column stays NULL and loads as []), never a save failure. + assert assistant_msg.content == "Done!" + assert assistant_msg.response_segments == [] + assert log =~ "2 invalid" + end + + test "broadcasts valid status events as streaming segments and drops malformed ones", + %{user: user, project: project} do + workflow = insert(:workflow, project: project) + + global_meta = %{ + "message_options" => %{ + "use_global_assistant" => true, + "page" => "/projects/p1/workflows/w1" + } + } + + session = + insert(:chat_session, + user: user, + session_type: "workflow_template", + project: project, + workflow: workflow, + job_id: nil, + meta: global_meta + ) + + {:ok, updated_session} = + AiAssistant.save_message( + session, + %{role: :user, content: "add a step", user: user}, + meta: global_meta + ) + + user_message = Enum.find(updated_session.messages, &(&1.role == :user)) + + segment = %{ + "type" => "status", + "content" => "Adding step send-to-gmail..." + } + + sse_body = + "event: status\ndata: #{Jason.encode!(segment)}\n\n" <> + "event: status\ndata: {not json\n\n" <> + "event: status\ndata: #{Jason.encode!(%{"type" => "status", "content" => 123})}\n\n" <> + "event: complete\ndata: #{Jason.encode!(%{"response" => "Done!", "usage" => %{}})}\n\n" + + Mox.stub(Lightning.Tesla.Mock, :call, fn %{url: url}, _opts -> + assert url =~ "/stream" + + {:ok, + %Tesla.Env{ + status: 200, + headers: [{"content-type", "text/event-stream"}], + body: sse_body + }} + end) + + Lightning.subscribe("ai_session:#{session.id}") + session_id = session.id + + log = + ExUnit.CaptureLog.capture_log(fn -> + assert :ok = + perform_job(MessageProcessor, %{ + "message_id" => user_message.id + }) + end) + + # The well-formed status event reaches the session topic verbatim... + assert_receive {:ai_assistant, :streaming_segment, + %{segment: ^segment, session_id: ^session_id}} + + # ...while the two malformed ones are dropped with a warning, never + # broadcast. + refute_receive {:ai_assistant, :streaming_segment, _}, 100 + assert log =~ "Dropping malformed status event" + end + + test "marks the message as error when a streaming request returns non-2xx", + %{user: user, project: project} do + workflow = insert(:workflow, project: project) + + session = + insert(:chat_session, + user: user, + session_type: "workflow_template", + project: project, + workflow: workflow, + job_id: nil, + meta: %{} + ) + + {:ok, updated_session} = + AiAssistant.save_message( + session, + %{role: :user, content: "generate a workflow", user: user}, + [] + ) + + user_message = Enum.find(updated_session.messages, &(&1.role == :user)) + + # Streaming requests carry a lazy Stream body even on error responses, + # so the error handler must not index it like a decoded JSON map. + Mox.expect(Lightning.Tesla.Mock, :call, fn %{url: url}, _opts -> + assert url =~ "/services/workflow_chat/stream" + + {:ok, + %Tesla.Env{ + status: 500, + body: Stream.map(["upstream exploded"], & &1) + }} + end) + + assert :ok = + perform_job(MessageProcessor, %{ + "message_id" => user_message.id + }) + + reloaded = AiAssistant.get_session!(session.id) + assert Enum.find(reloaded.messages, &(&1.role == :user)).status == :error + refute Enum.find(reloaded.messages, &(&1.role == :assistant)) + end + + test "marks the message as error when the assistant reply fails to save", + %{user: user, project: project} do + workflow = insert(:workflow, project: project) + + session = + insert(:chat_session, + user: user, + session_type: "workflow_template", + project: project, + workflow: workflow, + job_id: nil, + meta: %{} + ) + + {:ok, updated_session} = + AiAssistant.save_message( + session, + %{role: :user, content: "generate a workflow", user: user}, + [] + ) + + user_message = Enum.find(updated_session.messages, &(&1.role == :user)) + + # A response over the 10k content cap fails the ChatMessage changeset, + # exercising the {:error, %Ecto.Changeset{}} branch in + # handle_processing_result/2. + Mox.stub( + Lightning.Tesla.Mock, + :call, + Lightning.AiAssistantHelpers.streaming_or_sync_response(%{ + "response" => String.duplicate("x", 10_001), + "usage" => %{} + }) + ) + + assert :ok = + perform_job(MessageProcessor, %{ + "message_id" => user_message.id + }) + + reloaded = AiAssistant.get_session!(session.id) + assert Enum.find(reloaded.messages, &(&1.role == :user)).status == :error + refute Enum.find(reloaded.messages, &(&1.role == :assistant)) + end end describe "fetch_and_scrub_io_data/1 via process_job_message/2" do diff --git a/test/lightning_web/channels/ai_assistant_channel_test.exs b/test/lightning_web/channels/ai_assistant_channel_test.exs index 246c132fe5..6e787bbfc9 100644 --- a/test/lightning_web/channels/ai_assistant_channel_test.exs +++ b/test/lightning_web/channels/ai_assistant_channel_test.exs @@ -253,6 +253,65 @@ defmodule LightningWeb.AiAssistantChannelTest do %{from_global: false} ] = messages end + + test "serializes segments timeline when present", %{ + socket: socket, + job: job, + user: user + } do + segments = [ + %Lightning.AiAssistant.ChatMessage.Segment{ + type: :text, + content: "Adding a step..." + }, + %Lightning.AiAssistant.ChatMessage.Segment{ + type: :status, + content: "Validating workflow..." + }, + %Lightning.AiAssistant.ChatMessage.Segment{ + type: :text, + content: "Done!" + } + ] + + session = + insert(:chat_session, + job: job, + user: user, + session_type: "job_code", + messages: [ + %{ + role: :assistant, + content: "Adding a step...\n\nDone!", + status: :success, + meta: %{"from_global" => true}, + response_segments: segments + }, + %{ + role: :assistant, + content: "Flat response", + status: :success, + inserted_at: DateTime.utc_now() |> DateTime.add(1) + } + ] + ) + + assert {:ok, %{messages: messages}, _socket} = + subscribe_and_join( + socket, + AiAssistantChannel, + "ai_assistant:job_code:#{session.id}", + %{} + ) + + assert [ + %{ + response_segments: ^segments, + content: "Adding a step...\n\nDone!" + }, + %{response_segments: [], content: "Flat response"} + ] = messages + end end describe "workflow_template sessions" do @@ -3604,6 +3663,21 @@ defmodule LightningWeb.AiAssistantChannelTest do assert_broadcast "streaming_changes", %{changes: ^changes} end + test "forwards streaming_segment to channel", %{ + socket: socket, + session_id: session_id + } do + segment = %{"type" => "status", "content" => "Edited workflow structure"} + + send( + socket.channel_pid, + {:ai_assistant, :streaming_segment, + %{segment: segment, session_id: session_id}} + ) + + assert_broadcast "streaming_segment", %{segment: ^segment} + end + test "forwards streaming_error to channel", %{ socket: socket, session_id: session_id diff --git a/test/support/ai_assistant_helpers.ex b/test/support/ai_assistant_helpers.ex index 202e1a76eb..fa2183221b 100644 --- a/test/support/ai_assistant_helpers.ex +++ b/test/support/ai_assistant_helpers.ex @@ -29,10 +29,23 @@ defmodule Lightning.AiAssistantHelpers do endpoints. For streaming URLs (containing "/stream"), returns an SSE `event: complete` response. For other URLs, returns a regular JSON body. + The body is encoded verbatim, so it can include the global chat planner + fields (`"response_segments"` timeline alongside the flat `"response"`). + ## Examples stub_ai_response(%{"history" => [%{"role" => "assistant", "content" => "Hi"}]}) + streaming_or_sync_response(%{ + "response" => "Done!", + "response_segments" => [ + %{"type" => "text", "content" => "Adding a step..."}, + %{"type" => "status", "content" => "Validating workflow..."}, + %{"type" => "text", "content" => "Done!"} + ], + "attachments" => [%{"type" => "workflow_yaml", "content" => "..."}] + }) + """ def streaming_or_sync_response(body) do fn