]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
server/webui: cleanup dual representation approach, simplify to openai-compat (#21090)
authorPiotr Wilkin (ilintar) <redacted>
Tue, 31 Mar 2026 08:42:06 +0000 (10:42 +0200)
committerGitHub <redacted>
Tue, 31 Mar 2026 08:42:06 +0000 (10:42 +0200)
* server/webui: cleanup dual representation approach, simplify to openai-compat

* feat: Fix regression for Agentic Loop UI

* chore: update webui build output

* refactor: Post-review code improvements

* chore: update webui build output

* refactor: Cleanup

* chore: update webui build output

---------

Co-authored-by: Aleksander Grygier <redacted>
20 files changed:
tools/server/public/index.html.gz
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte
tools/server/webui/src/lib/components/app/chat/index.ts
tools/server/webui/src/lib/constants/agentic.ts
tools/server/webui/src/lib/services/chat.service.ts
tools/server/webui/src/lib/stores/agentic.svelte.ts
tools/server/webui/src/lib/stores/chat.svelte.ts
tools/server/webui/src/lib/stores/conversations.svelte.ts
tools/server/webui/src/lib/types/agentic.d.ts
tools/server/webui/src/lib/types/chat.d.ts
tools/server/webui/src/lib/types/database.d.ts
tools/server/webui/src/lib/utils/agentic.ts
tools/server/webui/src/lib/utils/index.ts
tools/server/webui/src/lib/utils/legacy-migration.ts [new file with mode: 0644]
tools/server/webui/tests/unit/agentic-sections.test.ts [new file with mode: 0644]
tools/server/webui/tests/unit/agentic-strip.test.ts
tools/server/webui/tests/unit/reasoning-context.test.ts

index 1a69dde3296eb9089224d93b3917e2c1700087da..831ff3925263ae5b05fff5311f01f41ba3a4d853 100644 (file)
Binary files a/tools/server/public/index.html.gz and b/tools/server/public/index.html.gz differ
index b48c71aec1675e293ff9fb967ce858467b3740c7..deb393a4b30b784de3aad09391d32ba484ee386e 100644 (file)
@@ -4,7 +4,7 @@
        import { getChatActionsContext, setMessageEditContext } from '$lib/contexts';
        import { chatStore, pendingEditMessageId } from '$lib/stores/chat.svelte';
        import { conversationsStore } from '$lib/stores/conversations.svelte';
-       import { DatabaseService } from '$lib/services';
+       import { DatabaseService } from '$lib/services/database.service';
        import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants';
        import { MessageRole, AttachmentType } from '$lib/enums';
        import {
@@ -19,6 +19,7 @@
        interface Props {
                class?: string;
                message: DatabaseMessage;
+               toolMessages?: DatabaseMessage[];
                isLastAssistantMessage?: boolean;
                siblingInfo?: ChatMessageSiblingInfo | null;
        }
@@ -26,6 +27,7 @@
        let {
                class: className = '',
                message,
+               toolMessages = [],
                isLastAssistantMessage = false,
                siblingInfo = null
        }: Props = $props();
                {deletionInfo}
                {isLastAssistantMessage}
                {message}
+               {toolMessages}
                messageContent={message.content}
                onConfirmDelete={handleConfirmDelete}
                onContinue={handleContinue}
index 5977f1c8f1e82f148eaef9b508362c8fa13a3439..17346e02702cb576a5ec644774f7215b3ddff125 100644 (file)
@@ -6,42 +6,42 @@
                SyntaxHighlightedCode
        } from '$lib/components/app';
        import { config } from '$lib/stores/settings.svelte';
-       import { Wrench, Loader2, AlertTriangle, Brain } from '@lucide/svelte';
-       import { AgenticSectionType, AttachmentType, FileTypeText } from '$lib/enums';
+       import { Wrench, Loader2, Brain } from '@lucide/svelte';
+       import { AgenticSectionType, FileTypeText } from '$lib/enums';
        import { formatJsonPretty } from '$lib/utils';
-       import { ATTACHMENT_SAVED_REGEX, NEWLINE_SEPARATOR } from '$lib/constants';
-       import { parseAgenticContent, type AgenticSection } from '$lib/utils';
-       import type { DatabaseMessage, DatabaseMessageExtraImageFile } from '$lib/types/database';
+       import {
+               deriveAgenticSections,
+               parseToolResultWithImages,
+               type AgenticSection,
+               type ToolResultLine
+       } from '$lib/utils';
+       import type { DatabaseMessage } from '$lib/types/database';
        import type { ChatMessageAgenticTimings, ChatMessageAgenticTurnStats } from '$lib/types/chat';
        import { ChatMessageStatsView } from '$lib/enums';
 
        interface Props {
-               message?: DatabaseMessage;
-               content: string;
+               message: DatabaseMessage;
+               toolMessages?: DatabaseMessage[];
                isStreaming?: boolean;
                highlightTurns?: boolean;
        }
 
-       type ToolResultLine = {
-               text: string;
-               image?: DatabaseMessageExtraImageFile;
-       };
-
-       let { content, message, isStreaming = false, highlightTurns = false }: Props = $props();
+       let { message, toolMessages = [], isStreaming = false, highlightTurns = false }: Props = $props();
 
        let expandedStates: Record<number, boolean> = $state({});
 
-       const sections = $derived(parseAgenticContent(content));
        const showToolCallInProgress = $derived(config().showToolCallInProgress as boolean);
        const showThoughtInProgress = $derived(config().showThoughtInProgress as boolean);
 
-       // Parse toolResults with images only when sections or message.extra change
+       const sections = $derived(deriveAgenticSections(message, toolMessages, []));
+
+       // Parse tool results with images
        const sectionsParsed = $derived(
                sections.map((section) => ({
                        ...section,
                        parsedLines: section.toolResult
-                               ? parseToolResultWithImages(section.toolResult, message?.extra)
-                               : []
+                               ? parseToolResultWithImages(section.toolResult, section.toolResultExtras || message?.extra)
+                               : ([] as ToolResultLine[])
                }))
        );
 
                expandedStates[index] = !currentState;
        }
 
-       function parseToolResultWithImages(
-               toolResult: string,
-               extras?: DatabaseMessage['extra']
-       ): ToolResultLine[] {
-               const lines = toolResult.split(NEWLINE_SEPARATOR);
-
-               return lines.map((line) => {
-                       const match = line.match(ATTACHMENT_SAVED_REGEX);
-                       if (!match || !extras) return { text: line };
-
-                       const attachmentName = match[1];
-                       const image = extras.find(
-                               (e): e is DatabaseMessageExtraImageFile =>
-                                       e.type === AttachmentType.IMAGE && e.name === attachmentName
-                       );
-
-                       return { text: line, image };
-               });
-       }
-
        function buildTurnAgenticTimings(stats: ChatMessageAgenticTurnStats): ChatMessageAgenticTimings {
                return {
                        turns: 1,
                        <MarkdownContent content={section.content} attachments={message?.extra} />
                </div>
        {:else if section.type === AgenticSectionType.TOOL_CALL_STREAMING}
-               {@const streamingIcon = isStreaming ? Loader2 : AlertTriangle}
-               {@const streamingIconClass = isStreaming ? 'h-4 w-4 animate-spin' : 'h-4 w-4 text-yellow-500'}
-               {@const streamingSubtitle = isStreaming ? '' : 'incomplete'}
+               {@const streamingIcon = isStreaming ? Loader2 : Loader2}
+               {@const streamingIconClass = isStreaming ? 'h-4 w-4 animate-spin' : 'h-4 w-4'}
 
                <CollapsibleContentBlock
                        open={isExpanded(index, section)}
                        icon={streamingIcon}
                        iconClass={streamingIconClass}
                        title={section.toolName || 'Tool call'}
-                       subtitle={streamingSubtitle}
+                       subtitle={isStreaming ? '' : 'incomplete'}
                        {isStreaming}
                        onToggle={() => toggleExpanded(index, section)}
                >
index 97aee0300812034a316dd74b5bec02380bafd121..153070a9f4540775a5296218126b7816017f2932 100644 (file)
@@ -15,7 +15,7 @@
        import { Check, X } from '@lucide/svelte';
        import { Button } from '$lib/components/ui/button';
        import { Checkbox } from '$lib/components/ui/checkbox';
-       import { AGENTIC_TAGS, INPUT_CLASSES, REASONING_TAGS } from '$lib/constants';
+       import { INPUT_CLASSES } from '$lib/constants';
        import { MessageRole, KeyboardKey, ChatMessageStatsView } from '$lib/enums';
        import Label from '$lib/components/ui/label/label.svelte';
        import { config } from '$lib/stores/settings.svelte';
@@ -23,6 +23,8 @@
        import { modelsStore } from '$lib/stores/models.svelte';
        import { ServerModelStatus } from '$lib/enums';
 
+       import { hasAgenticContent } from '$lib/utils';
+
        interface Props {
                class?: string;
                deletionInfo: {
@@ -33,6 +35,7 @@
                } | null;
                isLastAssistantMessage?: boolean;
                message: DatabaseMessage;
+               toolMessages?: DatabaseMessage[];
                messageContent: string | undefined;
                onCopy: () => void;
                onConfirmDelete: () => void;
@@ -53,6 +56,7 @@
                deletionInfo,
                isLastAssistantMessage = false,
                message,
+               toolMessages = [],
                messageContent,
                onConfirmDelete,
                onContinue,
                }
        }
 
-       const hasAgenticMarkers = $derived(
-               messageContent?.includes(AGENTIC_TAGS.TOOL_CALL_START) ?? false
-       );
-       const hasReasoningMarkers = $derived(messageContent?.includes(REASONING_TAGS.START) ?? false);
+       const isAgentic = $derived(hasAgenticContent(message, toolMessages));
+       const hasReasoning = $derived(!!message.reasoningContent);
        const processingState = useProcessingState();
 
        let currentConfig = $derived(config());
        }
 
        let highlightAgenticTurns = $derived(
-               hasAgenticMarkers &&
+               isAgentic &&
                        (currentConfig.alwaysShowAgenticTurns || activeStatsView === ChatMessageStatsView.SUMMARY)
        );
 
                message?.role === MessageRole.ASSISTANT &&
                        isActivelyProcessing &&
                        hasNoContent &&
+                       !isAgentic &&
                        isLastAssistantMessage
        );
 
        let showProcessingInfoBottom = $derived(
                message?.role === MessageRole.ASSISTANT &&
                        isActivelyProcessing &&
-                       !hasNoContent &&
+                       (!hasNoContent || isAgentic) &&
                        isLastAssistantMessage
        );
 
                        <pre class="raw-output">{messageContent || ''}</pre>
                {:else}
                        <ChatMessageAgenticContent
-                               content={messageContent || ''}
+                               {message}
+                               {toolMessages}
                                isStreaming={isChatStreaming()}
                                highlightTurns={highlightAgenticTurns}
-                               {message}
                        />
                {/if}
        {:else}
                        {onCopy}
                        {onEdit}
                        {onRegenerate}
-                       onContinue={currentConfig.enableContinueGeneration && !hasReasoningMarkers
-                               ? onContinue
-                               : undefined}
+                       onContinue={currentConfig.enableContinueGeneration && !hasReasoning ? onContinue : undefined}
                        {onForkConversation}
                        {onDelete}
                        {onConfirmDelete}
index 5ff53644cac5746847ee4b326db827e230ff9c89..6d16b4698599422f456de1017883e453d7d28a03 100644 (file)
@@ -6,7 +6,12 @@
        import { chatStore } from '$lib/stores/chat.svelte';
        import { conversationsStore, activeConversation } from '$lib/stores/conversations.svelte';
        import { config } from '$lib/stores/settings.svelte';
-       import { copyToClipboard, formatMessageForClipboard, getMessageSiblings } from '$lib/utils';
+       import {
+               copyToClipboard,
+               formatMessageForClipboard,
+               getMessageSiblings,
+               hasAgenticContent
+       } from '$lib/utils';
 
        interface Props {
                class?: string;
                        ? messages
                        : messages.filter((msg) => msg.type !== MessageRole.SYSTEM);
 
-               let lastAssistantIndex = -1;
+               // Build display entries, grouping agentic sessions into single entries.
+               // An agentic session = assistant(with tool_calls) â†’ tool â†’ assistant â†’ tool â†’ ... â†’ assistant(final)
+               const result: Array<{
+                       message: DatabaseMessage;
+                       toolMessages: DatabaseMessage[];
+                       isLastAssistantMessage: boolean;
+                       siblingInfo: ChatMessageSiblingInfo;
+               }> = [];
 
-               for (let i = filteredMessages.length - 1; i >= 0; i--) {
-                       if (filteredMessages[i].role === MessageRole.ASSISTANT) {
-                               lastAssistantIndex = i;
+               for (let i = 0; i < filteredMessages.length; i++) {
+                       const msg = filteredMessages[i];
 
-                               break;
+                       // Skip tool messages - they're grouped with preceding assistant
+                       if (msg.role === MessageRole.TOOL) continue;
+
+                       const toolMessages: DatabaseMessage[] = [];
+                       if (msg.role === MessageRole.ASSISTANT && hasAgenticContent(msg)) {
+                               let j = i + 1;
+
+                               while (j < filteredMessages.length) {
+                                       const next = filteredMessages[j];
+
+                                       if (next.role === MessageRole.TOOL) {
+                                               toolMessages.push(next);
+
+                                               j++;
+                                       } else if (next.role === MessageRole.ASSISTANT) {
+                                               toolMessages.push(next);
+
+                                               j++;
+                                       } else {
+                                               break;
+                                       }
+                               }
+
+                               i = j - 1;
+                       } else if (msg.role === MessageRole.ASSISTANT) {
+                               let j = i + 1;
+
+                               while (j < filteredMessages.length && filteredMessages[j].role === MessageRole.TOOL) {
+                                       toolMessages.push(filteredMessages[j]);
+                                       j++;
+                               }
                        }
-               }
 
-               return filteredMessages.map((message, index) => {
-                       const siblingInfo = getMessageSiblings(allConversationMessages, message.id);
-                       const isLastAssistantMessage =
-                               message.role === MessageRole.ASSISTANT && index === lastAssistantIndex;
+                       const siblingInfo = getMessageSiblings(allConversationMessages, msg.id);
 
-                       return {
-                               message,
-                               isLastAssistantMessage,
+                       result.push({
+                               message: msg,
+                               toolMessages,
+                               isLastAssistantMessage: false,
                                siblingInfo: siblingInfo || {
-                                       message,
-                                       siblingIds: [message.id],
+                                       message: msg,
+                                       siblingIds: [msg.id],
                                        currentIndex: 0,
                                        totalSiblings: 1
                                }
-                       };
-               });
+                       });
+               }
+
+               // Mark the last assistant message
+               for (let i = result.length - 1; i >= 0; i--) {
+                       if (result[i].message.role === MessageRole.ASSISTANT) {
+                               result[i].isLastAssistantMessage = true;
+                               break;
+                       }
+               }
+
+               return result;
        });
 </script>
 
        class="flex h-full flex-col space-y-10 pt-24 {className}"
        style="height: auto; min-height: calc(100dvh - 14rem);"
 >
-       {#each displayMessages as { message, isLastAssistantMessage, siblingInfo } (message.id)}
+       {#each displayMessages as { message, toolMessages, isLastAssistantMessage, siblingInfo } (message.id)}
                <div use:fadeInView>
                        <ChatMessage
                                class="mx-auto w-full max-w-[48rem]"
                                {message}
+                               {toolMessages}
                                {isLastAssistantMessage}
                                {siblingInfo}
                        />
index 2eee7e2dfc0b4f584d937d758ae52516b97e19cd..88690086c7e1877c6abd46c696cf70dc64ff9686 100644 (file)
@@ -425,21 +425,16 @@ export { default as ChatMessage } from './ChatMessages/ChatMessage.svelte';
 /**
  * **ChatMessageAgenticContent** - Agentic workflow output display
  *
- * Specialized renderer for assistant messages containing agentic workflow markers.
- * Parses structured content and displays tool calls and reasoning blocks as
- * interactive collapsible sections with real-time streaming support.
+ * Specialized renderer for assistant messages with tool calls and reasoning.
+ * Derives display sections from structured message data (toolCalls, reasoningContent,
+ * and child tool result messages) and renders them as interactive collapsible sections.
  *
  * **Architecture:**
- * - Uses `parseAgenticContent()` from `$lib/utils` to parse markers
+ * - Uses `deriveAgenticSections()` from `$lib/utils` to build sections from structured data
  * - Renders sections as CollapsibleContentBlock components
  * - Handles streaming state for progressive content display
  * - Falls back to MarkdownContent for plain text sections
  *
- * **Marker Format:**
- * - Tool calls: in constants/agentic.ts (AGENTIC_TAGS)
- * - Reasoning: in constants/agentic.ts (REASONING_TAGS)
- * - Partial markers handled gracefully during streaming
- *
  * **Execution States:**
  * - **Streaming**: Animated spinner, block expanded, auto-scroll enabled
  * - **Pending**: Waiting indicator for queued tool calls
index ac31d5126de15d059bbaee29dbb42bbf9cc27eb4..831fa35a937b1158d11e9033b7cce41253cc2681 100644 (file)
@@ -15,8 +15,11 @@ export const DEFAULT_AGENTIC_CONFIG: AgenticConfig = {
        maxToolPreviewLines: 25
 } as const;
 
-// Agentic tool call tag markers
-export const AGENTIC_TAGS = {
+/**
+ * @deprecated Legacy marker tags - only used for migration of old stored messages.
+ * New messages use structured fields (reasoningContent, toolCalls, toolCallId).
+ */
+export const LEGACY_AGENTIC_TAGS = {
        TOOL_CALL_START: '<<<AGENTIC_TOOL_CALL_START>>>',
        TOOL_CALL_END: '<<<AGENTIC_TOOL_CALL_END>>>',
        TOOL_NAME_PREFIX: '<<<TOOL_NAME:',
@@ -25,39 +28,25 @@ export const AGENTIC_TAGS = {
        TAG_SUFFIX: '>>>'
 } as const;
 
-export const REASONING_TAGS = {
+/**
+ * @deprecated Legacy reasoning tags - only used for migration of old stored messages.
+ * New messages use the dedicated reasoningContent field.
+ */
+export const LEGACY_REASONING_TAGS = {
        START: '<<<reasoning_content_start>>>',
        END: '<<<reasoning_content_end>>>'
 } as const;
 
-// Regex for trimming leading/trailing newlines
-export const TRIM_NEWLINES_REGEX = /^\n+|\n+$/g;
-
-// Regex patterns for parsing agentic content
-export const AGENTIC_REGEX = {
-       // Matches completed tool calls (with END marker)
+/**
+ * @deprecated Legacy regex patterns - only used for migration of old stored messages.
+ */
+export const LEGACY_AGENTIC_REGEX = {
        COMPLETED_TOOL_CALL:
                /<<<AGENTIC_TOOL_CALL_START>>>\n<<<TOOL_NAME:(.+?)>>>\n<<<TOOL_ARGS_START>>>([\s\S]*?)<<<TOOL_ARGS_END>>>([\s\S]*?)<<<AGENTIC_TOOL_CALL_END>>>/g,
-       // Matches pending tool call (has NAME and ARGS but no END)
-       PENDING_TOOL_CALL:
-               /<<<AGENTIC_TOOL_CALL_START>>>\n<<<TOOL_NAME:(.+?)>>>\n<<<TOOL_ARGS_START>>>([\s\S]*?)<<<TOOL_ARGS_END>>>([\s\S]*)$/,
-       // Matches partial tool call (has START and NAME, ARGS still streaming)
-       PARTIAL_WITH_NAME:
-               /<<<AGENTIC_TOOL_CALL_START>>>\n<<<TOOL_NAME:(.+?)>>>\n<<<TOOL_ARGS_START>>>([\s\S]*)$/,
-       // Matches early tool call (just START marker)
-       EARLY_MATCH: /<<<AGENTIC_TOOL_CALL_START>>>([\s\S]*)$/,
-       // Matches partial marker at end of content
-       PARTIAL_MARKER: /<<<[A-Za-z_]*$/,
-       // Matches reasoning content blocks (including tags)
        REASONING_BLOCK: /<<<reasoning_content_start>>>[\s\S]*?<<<reasoning_content_end>>>/g,
-       // Captures the reasoning text between start/end tags
        REASONING_EXTRACT: /<<<reasoning_content_start>>>([\s\S]*?)<<<reasoning_content_end>>>/,
-       // Matches an opening reasoning tag and any remaining content (unterminated)
        REASONING_OPEN: /<<<reasoning_content_start>>>[\s\S]*$/,
-       // Matches a complete agentic tool call display block (start to end marker)
        AGENTIC_TOOL_CALL_BLOCK: /\n*<<<AGENTIC_TOOL_CALL_START>>>[\s\S]*?<<<AGENTIC_TOOL_CALL_END>>>/g,
-       // Matches a pending/partial agentic tool call (start marker with no matching end)
        AGENTIC_TOOL_CALL_OPEN: /\n*<<<AGENTIC_TOOL_CALL_START>>>[\s\S]*$/,
-       // Matches tool name inside content
-       TOOL_NAME_EXTRACT: /<<<TOOL_NAME:([^>]+)>>>/
+       HAS_LEGACY_MARKERS: /<<<(?:AGENTIC_TOOL_CALL_START|reasoning_content_start)>>>/
 } as const;
index 1403b7c54eb4acb135aab4c623eeb66530aa7020..ff99342766f2cb9ef94928b23393832bb48440ae 100644 (file)
@@ -1,6 +1,7 @@
-import { getJsonHeaders, formatAttachmentText, isAbortError } from '$lib/utils';
+import { getJsonHeaders } from '$lib/utils/api-headers';
+import { formatAttachmentText } from '$lib/utils/formatters';
+import { isAbortError } from '$lib/utils/abort';
 import {
-       AGENTIC_REGEX,
        ATTACHMENT_LABEL_PDF_FILE,
        ATTACHMENT_LABEL_MCP_PROMPT,
        ATTACHMENT_LABEL_MCP_RESOURCE
@@ -17,38 +18,6 @@ import type { DatabaseMessageExtraMcpPrompt, DatabaseMessageExtraMcpResource } f
 import { modelsStore } from '$lib/stores/models.svelte';
 
 export class ChatService {
-       private static stripReasoningContent(
-               content: ApiChatMessageData['content'] | null | undefined
-       ): ApiChatMessageData['content'] | null | undefined {
-               if (!content) {
-                       return content;
-               }
-
-               if (typeof content === 'string') {
-                       return content
-                               .replace(AGENTIC_REGEX.REASONING_BLOCK, '')
-                               .replace(AGENTIC_REGEX.REASONING_OPEN, '')
-                               .replace(AGENTIC_REGEX.AGENTIC_TOOL_CALL_BLOCK, '')
-                               .replace(AGENTIC_REGEX.AGENTIC_TOOL_CALL_OPEN, '');
-               }
-
-               if (!Array.isArray(content)) {
-                       return content;
-               }
-
-               return content.map((part: ApiChatMessageContentPart) => {
-                       if (part.type !== ContentPartType.TEXT || !part.text) return part;
-                       return {
-                               ...part,
-                               text: part.text
-                                       .replace(AGENTIC_REGEX.REASONING_BLOCK, '')
-                                       .replace(AGENTIC_REGEX.REASONING_OPEN, '')
-                                       .replace(AGENTIC_REGEX.AGENTIC_TOOL_CALL_BLOCK, '')
-                                       .replace(AGENTIC_REGEX.AGENTIC_TOOL_CALL_OPEN, '')
-                       };
-               });
-       }
-
        /**
         *
         *
@@ -57,46 +26,6 @@ export class ChatService {
         *
         */
 
-       /**
-        * Extracts reasoning text from content that contains internal reasoning tags.
-        * Returns the concatenated reasoning content or undefined if none found.
-        */
-       private static extractReasoningFromContent(
-               content: ApiChatMessageData['content'] | null | undefined
-       ): string | undefined {
-               if (!content) return undefined;
-
-               const extractFromString = (text: string): string => {
-                       const parts: string[] = [];
-                       // Use a fresh regex instance to avoid shared lastIndex state
-                       const re = new RegExp(AGENTIC_REGEX.REASONING_EXTRACT.source);
-                       let match = re.exec(text);
-                       while (match) {
-                               parts.push(match[1]);
-                               // advance past the matched portion and retry
-                               text = text.slice(match.index + match[0].length);
-                               match = re.exec(text);
-                       }
-                       return parts.join('');
-               };
-
-               if (typeof content === 'string') {
-                       const result = extractFromString(content);
-                       return result || undefined;
-               }
-
-               if (!Array.isArray(content)) return undefined;
-
-               const parts: string[] = [];
-               for (const part of content) {
-                       if (part.type === ContentPartType.TEXT && part.text) {
-                               const result = extractFromString(part.text);
-                               if (result) parts.push(result);
-                       }
-               }
-               return parts.length > 0 ? parts.join('') : undefined;
-       }
-
        /**
         * Sends a chat completion request to the llama.cpp server.
         * Supports both streaming and non-streaming responses with comprehensive parameter configuration.
@@ -201,20 +130,15 @@ export class ChatService {
 
                const requestBody: ApiChatCompletionRequest = {
                        messages: normalizedMessages.map((msg: ApiChatMessageData) => {
-                               // Always strip internal reasoning/agentic tags from content
-                               const cleanedContent = ChatService.stripReasoningContent(msg.content);
                                const mapped: ApiChatCompletionRequest['messages'][0] = {
                                        role: msg.role,
-                                       content: cleanedContent,
+                                       content: msg.content,
                                        tool_calls: msg.tool_calls,
                                        tool_call_id: msg.tool_call_id
                                };
-                               // When preserving reasoning, extract it from raw content and send as separate field
-                               if (!excludeReasoningFromContext) {
-                                       const reasoning = ChatService.extractReasoningFromContent(msg.content);
-                                       if (reasoning) {
-                                               mapped.reasoning_content = reasoning;
-                                       }
+                               // Include reasoning_content from the dedicated field
+                               if (!excludeReasoningFromContext && msg.reasoning_content) {
+                                       mapped.reasoning_content = msg.reasoning_content;
                                }
                                return mapped;
                        }),
@@ -726,6 +650,10 @@ export class ChatService {
                                content: message.content
                        };
 
+                       if (message.reasoningContent) {
+                               result.reasoning_content = message.reasoningContent;
+                       }
+
                        if (toolCalls && toolCalls.length > 0) {
                                result.tool_calls = toolCalls;
                        }
@@ -854,6 +782,9 @@ export class ChatService {
                        role: message.role as MessageRole,
                        content: contentParts
                };
+               if (message.reasoningContent) {
+                       result.reasoning_content = message.reasoningContent;
+               }
                if (toolCalls && toolCalls.length > 0) {
                        result.tool_calls = toolCalls;
                }
index f8834f9df3f9229ee17b0915f8bab9b12469e16b..d92499847807e712e3be9cccf512c615fd3b84ad 100644 (file)
@@ -7,6 +7,10 @@
  * - Session state management
  * - Turn limit enforcement
  *
+ * Each agentic turn produces separate DB messages:
+ * - One assistant message per LLM turn (with tool_calls if any)
+ * - One tool result message per tool call execution
+ *
  * **Architecture & Relationships:**
  * - **ChatService**: Stateless API layer (sendMessage, streaming)
  * - **mcpStore**: MCP connection management and tool execution
@@ -16,7 +20,6 @@
  * @see mcpStore in stores/mcp.svelte.ts for MCP operations
  */
 
-import { SvelteMap } from 'svelte/reactivity';
 import { ChatService } from '$lib/services';
 import { config } from '$lib/stores/settings.svelte';
 import { mcpStore } from '$lib/stores/mcp.svelte';
@@ -24,7 +27,6 @@ import { modelsStore } from '$lib/stores/models.svelte';
 import { isAbortError } from '$lib/utils';
 import {
        DEFAULT_AGENTIC_CONFIG,
-       AGENTIC_TAGS,
        NEWLINE_SEPARATOR,
        TURN_LIMIT_MESSAGE,
        LLM_ERROR_BLOCK_START,
@@ -193,17 +195,6 @@ class AgenticStore {
 
        async runAgenticFlow(params: AgenticFlowParams): Promise<AgenticFlowResult> {
                const { conversationId, messages, options = {}, callbacks, signal, perChatOverrides } = params;
-               const {
-                       onChunk,
-                       onReasoningChunk,
-                       onToolCallChunk,
-                       onAttachments,
-                       onModel,
-                       onComplete,
-                       onError,
-                       onTimings,
-                       onTurnComplete
-               } = callbacks;
 
                const agenticConfig = this.getConfig(config(), perChatOverrides);
                if (!agenticConfig.enabled) return { handled: false };
@@ -253,24 +244,14 @@ class AgenticStore {
                                options,
                                tools,
                                agenticConfig,
-                               callbacks: {
-                                       onChunk,
-                                       onReasoningChunk,
-                                       onToolCallChunk,
-                                       onAttachments,
-                                       onModel,
-                                       onComplete,
-                                       onError,
-                                       onTimings,
-                                       onTurnComplete
-                               },
+                               callbacks,
                                signal
                        });
                        return { handled: true };
                } catch (error) {
                        const normalizedError = error instanceof Error ? error : new Error(String(error));
                        this.updateSession(conversationId, { lastError: normalizedError });
-                       onError?.(normalizedError);
+                       callbacks.onError?.(normalizedError);
                        return { handled: true, error: normalizedError };
                } finally {
                        this.updateSession(conversationId, { isRunning: false });
@@ -295,17 +276,20 @@ class AgenticStore {
                const {
                        onChunk,
                        onReasoningChunk,
-                       onToolCallChunk,
+                       onToolCallsStreaming,
                        onAttachments,
                        onModel,
-                       onComplete,
+                       onAssistantTurnComplete,
+                       createToolResultMessage,
+                       createAssistantMessage,
+                       onFlowComplete,
                        onTimings,
                        onTurnComplete
                } = callbacks;
 
                const sessionMessages: AgenticMessage[] = toAgenticMessages(messages);
-               const allToolCalls: ApiChatCompletionToolCall[] = [];
                let capturedTimings: ChatMessageTimings | undefined;
+               let totalToolCallCount = 0;
 
                const agenticTimings: ChatMessageAgenticTimings = {
                        turns: 0,
@@ -316,12 +300,7 @@ class AgenticStore {
                        llm: { predicted_n: 0, predicted_ms: 0, prompt_n: 0, prompt_ms: 0 }
                };
                const maxTurns = agenticConfig.maxTurns;
-               const maxToolPreviewLines = agenticConfig.maxToolPreviewLines;
 
-               // Resolve effective model for vision capability checks.
-               // In ROUTER mode, options.model is always set by the caller.
-               // In MODEL mode, options.model is undefined; use the single loaded model
-               // which carries modalities bridged from /props.
                const effectiveModel = options.model || modelsStore.models[0]?.model || '';
 
                for (let turn = 0; turn < maxTurns; turn++) {
@@ -329,23 +308,20 @@ class AgenticStore {
                        agenticTimings.turns = turn + 1;
 
                        if (signal?.aborted) {
-                               onComplete?.(
-                                       '',
-                                       undefined,
-                                       this.buildFinalTimings(capturedTimings, agenticTimings),
-                                       undefined
-                               );
+                               onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
                                return;
                        }
 
+                       // For turns > 0, create a new assistant message via callback
+                       if (turn > 0 && createAssistantMessage) {
+                               await createAssistantMessage();
+                       }
+
                        let turnContent = '';
+                       let turnReasoningContent = '';
                        let turnToolCalls: ApiChatCompletionToolCall[] = [];
                        let lastStreamingToolCallName = '';
                        let lastStreamingToolCallArgsLength = 0;
-                       const emittedToolCallStates = new SvelteMap<
-                               number,
-                               { emittedOnce: boolean; lastArgs: string }
-                       >();
                        let turnTimings: ChatMessageTimings | undefined;
 
                        const turnStats: ChatMessageAgenticTurnStats = {
@@ -366,30 +342,15 @@ class AgenticStore {
                                                        turnContent += chunk;
                                                        onChunk?.(chunk);
                                                },
-                                               onReasoningChunk,
+                                               onReasoningChunk: (chunk: string) => {
+                                                       turnReasoningContent += chunk;
+                                                       onReasoningChunk?.(chunk);
+                                               },
                                                onToolCallChunk: (serialized: string) => {
                                                        try {
                                                                turnToolCalls = JSON.parse(serialized) as ApiChatCompletionToolCall[];
-                                                               for (let i = 0; i < turnToolCalls.length; i++) {
-                                                                       const toolCall = turnToolCalls[i];
-                                                                       const toolName = toolCall.function?.name ?? '';
-                                                                       const toolArgs = toolCall.function?.arguments ?? '';
-                                                                       const state = emittedToolCallStates.get(i) || {
-                                                                               emittedOnce: false,
-                                                                               lastArgs: ''
-                                                                       };
-                                                                       if (!state.emittedOnce) {
-                                                                               const output = `\n\n${AGENTIC_TAGS.TOOL_CALL_START}\n${AGENTIC_TAGS.TOOL_NAME_PREFIX}${toolName}${AGENTIC_TAGS.TAG_SUFFIX}\n${AGENTIC_TAGS.TOOL_ARGS_START}\n${toolArgs}`;
-                                                                               onChunk?.(output);
-                                                                               state.emittedOnce = true;
-                                                                               state.lastArgs = toolArgs;
-                                                                               emittedToolCallStates.set(i, state);
-                                                                       } else if (toolArgs.length > state.lastArgs.length) {
-                                                                               onChunk?.(toolArgs.slice(state.lastArgs.length));
-                                                                               state.lastArgs = toolArgs;
-                                                                               emittedToolCallStates.set(i, state);
-                                                                       }
-                                                               }
+                                                               onToolCallsStreaming?.(turnToolCalls);
+
                                                                if (turnToolCalls.length > 0 && turnToolCalls[0]?.function) {
                                                                        const name = turnToolCalls[0].function.name || '';
                                                                        const args = turnToolCalls[0].function.arguments || '';
@@ -442,77 +403,84 @@ class AgenticStore {
                                }
                        } catch (error) {
                                if (signal?.aborted) {
-                                       onComplete?.(
-                                               '',
-                                               undefined,
+                                       // Save whatever we have for this turn before exiting
+                                       await onAssistantTurnComplete?.(
+                                               turnContent,
+                                               turnReasoningContent || undefined,
                                                this.buildFinalTimings(capturedTimings, agenticTimings),
                                                undefined
                                        );
-
+                                       onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
                                        return;
                                }
                                const normalizedError = error instanceof Error ? error : new Error('LLM stream error');
+                               // Save error as content in the current turn
                                onChunk?.(`${LLM_ERROR_BLOCK_START}${normalizedError.message}${LLM_ERROR_BLOCK_END}`);
-                               onComplete?.(
-                                       '',
-                                       undefined,
+                               await onAssistantTurnComplete?.(
+                                       turnContent + `${LLM_ERROR_BLOCK_START}${normalizedError.message}${LLM_ERROR_BLOCK_END}`,
+                                       turnReasoningContent || undefined,
                                        this.buildFinalTimings(capturedTimings, agenticTimings),
                                        undefined
                                );
-
+                               onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
                                throw normalizedError;
                        }
 
+                       // No tool calls = final turn, save and complete
                        if (turnToolCalls.length === 0) {
                                agenticTimings.perTurn!.push(turnStats);
 
-                               onComplete?.(
-                                       '',
-                                       undefined,
-                                       this.buildFinalTimings(capturedTimings, agenticTimings),
+                               const finalTimings = this.buildFinalTimings(capturedTimings, agenticTimings);
+
+                               await onAssistantTurnComplete?.(
+                                       turnContent,
+                                       turnReasoningContent || undefined,
+                                       finalTimings,
                                        undefined
                                );
 
+                               if (finalTimings) onTurnComplete?.(finalTimings);
+
+                               onFlowComplete?.(finalTimings);
+
                                return;
                        }
 
+                       // Normalize and save assistant turn with tool calls
                        const normalizedCalls = this.normalizeToolCalls(turnToolCalls);
                        if (normalizedCalls.length === 0) {
-                               onComplete?.(
-                                       '',
-                                       undefined,
+                               await onAssistantTurnComplete?.(
+                                       turnContent,
+                                       turnReasoningContent || undefined,
                                        this.buildFinalTimings(capturedTimings, agenticTimings),
                                        undefined
                                );
+                               onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
                                return;
                        }
 
-                       for (const call of normalizedCalls) {
-                               allToolCalls.push({
-                                       id: call.id,
-                                       type: call.type,
-                                       function: call.function ? { ...call.function } : undefined
-                               });
-                       }
+                       totalToolCallCount += normalizedCalls.length;
+                       this.updateSession(conversationId, { totalToolCalls: totalToolCallCount });
 
-                       this.updateSession(conversationId, { totalToolCalls: allToolCalls.length });
-                       onToolCallChunk?.(JSON.stringify(allToolCalls));
+                       // Save the assistant message with its tool calls
+                       await onAssistantTurnComplete?.(
+                               turnContent,
+                               turnReasoningContent || undefined,
+                               turnTimings,
+                               normalizedCalls
+                       );
 
+                       // Add assistant message to session history
                        sessionMessages.push({
                                role: MessageRole.ASSISTANT,
                                content: turnContent || undefined,
                                tool_calls: normalizedCalls
                        });
 
+                       // Execute each tool call and create result messages
                        for (const toolCall of normalizedCalls) {
                                if (signal?.aborted) {
-                                       onComplete?.(
-                                               '',
-                                               undefined,
-                                               this.buildFinalTimings(capturedTimings, agenticTimings),
-                                               undefined
-                                       );
-
+                                       onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
                                        return;
                                }
 
@@ -530,13 +498,7 @@ class AgenticStore {
                                        result = executionResult.content;
                                } catch (error) {
                                        if (isAbortError(error)) {
-                                               onComplete?.(
-                                                       '',
-                                                       undefined,
-                                                       this.buildFinalTimings(capturedTimings, agenticTimings),
-                                                       undefined
-                                               );
-
+                                               onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
                                                return;
                                        }
                                        result = `Error: ${error instanceof Error ? error.message : String(error)}`;
@@ -557,21 +519,27 @@ class AgenticStore {
                                turnStats.toolsMs += Math.round(toolDurationMs);
 
                                if (signal?.aborted) {
-                                       onComplete?.(
-                                               '',
-                                               undefined,
-                                               this.buildFinalTimings(capturedTimings, agenticTimings),
-                                               undefined
-                                       );
-
+                                       onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
                                        return;
                                }
 
                                const { cleanedResult, attachments } = this.extractBase64Attachments(result);
-                               if (attachments.length > 0) onAttachments?.(attachments);
 
-                               this.emitToolCallResult(cleanedResult, maxToolPreviewLines, onChunk);
+                               // Create the tool result message in the DB
+                               let toolResultMessage: DatabaseMessage | undefined;
+                               if (createToolResultMessage) {
+                                       toolResultMessage = await createToolResultMessage(
+                                               toolCall.id,
+                                               cleanedResult,
+                                               attachments.length > 0 ? attachments : undefined
+                                       );
+                               }
+
+                               if (attachments.length > 0 && toolResultMessage) {
+                                       onAttachments?.(toolResultMessage.id, attachments);
+                               }
 
+                               // Build content parts for session history (including images for vision models)
                                const contentParts: ApiChatMessageContentPart[] = [
                                        { type: ContentPartType.TEXT, text: cleanedResult }
                                ];
@@ -605,8 +573,15 @@ class AgenticStore {
                        }
                }
 
+               // Turn limit reached
                onChunk?.(TURN_LIMIT_MESSAGE);
-               onComplete?.('', undefined, this.buildFinalTimings(capturedTimings, agenticTimings), undefined);
+               await onAssistantTurnComplete?.(
+                       TURN_LIMIT_MESSAGE,
+                       undefined,
+                       this.buildFinalTimings(capturedTimings, agenticTimings),
+                       undefined
+               );
+               onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
        }
 
        private buildFinalTimings(
@@ -633,23 +608,6 @@ class AgenticStore {
                }));
        }
 
-       private emitToolCallResult(
-               result: string,
-               maxLines: number,
-               emit?: (chunk: string) => void
-       ): void {
-               if (!emit) {
-                       return;
-               }
-
-               let output = `${NEWLINE_SEPARATOR}${AGENTIC_TAGS.TOOL_ARGS_END}`;
-               const lines = result.split(NEWLINE_SEPARATOR);
-               const trimmedLines = lines.length > maxLines ? lines.slice(-maxLines) : lines;
-
-               output += `${NEWLINE_SEPARATOR}${trimmedLines.join(NEWLINE_SEPARATOR)}${NEWLINE_SEPARATOR}${AGENTIC_TAGS.TOOL_CALL_END}${NEWLINE_SEPARATOR}`;
-               emit(output);
-       }
-
        private extractBase64Attachments(result: string): {
                cleanedResult: string;
                attachments: DatabaseMessageExtra[];
index 5f3812ed32c983c98782bcdd6950a98e869baddd..229631c6a37f2e225316ad520b01c74297e3c66f 100644 (file)
@@ -12,7 +12,8 @@
  */
 
 import { SvelteMap } from 'svelte/reactivity';
-import { DatabaseService, ChatService } from '$lib/services';
+import { DatabaseService } from '$lib/services/database.service';
+import { ChatService } from '$lib/services/chat.service';
 import { conversationsStore } from '$lib/stores/conversations.svelte';
 import { config } from '$lib/stores/settings.svelte';
 import { agenticStore } from '$lib/stores/agentic.svelte';
@@ -34,7 +35,6 @@ import {
 import {
        MAX_INACTIVE_CONVERSATION_STATES,
        INACTIVE_CONVERSATION_STATE_MAX_AGE_MS,
-       REASONING_TAGS,
        SYSTEM_MESSAGE_PLACEHOLDER
 } from '$lib/constants';
 import type {
@@ -50,15 +50,6 @@ interface ConversationStateEntry {
        lastAccessed: number;
 }
 
-const countOccurrences = (source: string, token: string): number =>
-       source ? source.split(token).length - 1 : 0;
-const hasUnclosedReasoningTag = (content: string): boolean =>
-       countOccurrences(content, REASONING_TAGS.START) > countOccurrences(content, REASONING_TAGS.END);
-const wrapReasoningContent = (content: string, reasoningContent?: string): string => {
-       if (!reasoningContent) return content;
-       return `${REASONING_TAGS.START}${reasoningContent}${REASONING_TAGS.END}${content}`;
-};
-
 class ChatStore {
        activeProcessingState = $state<ApiProcessingState | null>(null);
        currentResponse = $state('');
@@ -557,83 +548,76 @@ class ChatStore {
                                await modelsStore.fetchModelProps(effectiveModel);
                }
 
-               let streamedContent = '',
-                       streamedToolCallContent = '',
-                       isReasoningOpen = false,
-                       hasStreamedChunks = false,
-                       resolvedModel: string | null = null,
-                       modelPersisted = false;
-               let streamedExtras: DatabaseMessageExtra[] = assistantMessage.extra
-                       ? JSON.parse(JSON.stringify(assistantMessage.extra))
-                       : [];
+               // Mutable state for the current message being streamed
+               let currentMessageId = assistantMessage.id;
+               let streamedContent = '';
+               let streamedReasoningContent = '';
+               let resolvedModel: string | null = null;
+               let modelPersisted = false;
+               const convId = assistantMessage.convId;
+
                const recordModel = (modelName: string | null | undefined, persistImmediately = true): void => {
                        if (!modelName) return;
                        const n = normalizeModelName(modelName);
                        if (!n || n === resolvedModel) return;
                        resolvedModel = n;
-                       const idx = conversationsStore.findMessageIndex(assistantMessage.id);
+                       const idx = conversationsStore.findMessageIndex(currentMessageId);
                        conversationsStore.updateMessageAtIndex(idx, { model: n });
                        if (persistImmediately && !modelPersisted) {
                                modelPersisted = true;
-                               DatabaseService.updateMessage(assistantMessage.id, { model: n }).catch(() => {
+                               DatabaseService.updateMessage(currentMessageId, { model: n }).catch(() => {
                                        modelPersisted = false;
                                        resolvedModel = null;
                                });
                        }
                };
-               const updateStreamingContent = () => {
-                       this.setChatStreaming(assistantMessage.convId, streamedContent, assistantMessage.id);
-                       const idx = conversationsStore.findMessageIndex(assistantMessage.id);
+
+               const updateStreamingUI = () => {
+                       this.setChatStreaming(convId, streamedContent, currentMessageId);
+                       const idx = conversationsStore.findMessageIndex(currentMessageId);
                        conversationsStore.updateMessageAtIndex(idx, { content: streamedContent });
                };
-               const appendContentChunk = (chunk: string) => {
-                       if (isReasoningOpen) {
-                               streamedContent += REASONING_TAGS.END;
-                               isReasoningOpen = false;
-                       }
-                       streamedContent += chunk;
-                       hasStreamedChunks = true;
-                       updateStreamingContent();
-               };
-               const appendReasoningChunk = (chunk: string) => {
-                       if (!isReasoningOpen) {
-                               streamedContent += REASONING_TAGS.START;
-                               isReasoningOpen = true;
-                       }
-                       streamedContent += chunk;
-                       hasStreamedChunks = true;
-                       updateStreamingContent();
-               };
-               const finalizeReasoning = () => {
-                       if (isReasoningOpen) {
-                               streamedContent += REASONING_TAGS.END;
-                               isReasoningOpen = false;
-                       }
+
+               const cleanupStreamingState = () => {
+                       this.setStreamingActive(false);
+                       this.setChatLoading(convId, false);
+                       this.clearChatStreaming(convId);
+                       this.setProcessingState(convId, null);
                };
+
                this.setStreamingActive(true);
-               this.setActiveProcessingConversation(assistantMessage.convId);
-               const abortController = this.getOrCreateAbortController(assistantMessage.convId);
+               this.setActiveProcessingConversation(convId);
+               const abortController = this.getOrCreateAbortController(convId);
+
                const streamCallbacks: ChatStreamCallbacks = {
-                       onChunk: (chunk: string) => appendContentChunk(chunk),
-                       onReasoningChunk: (chunk: string) => appendReasoningChunk(chunk),
-                       onToolCallChunk: (chunk: string) => {
-                               const c = chunk.trim();
-                               if (!c) return;
-                               streamedToolCallContent = c;
-                               const idx = conversationsStore.findMessageIndex(assistantMessage.id);
-                               conversationsStore.updateMessageAtIndex(idx, { toolCalls: streamedToolCallContent });
+                       onChunk: (chunk: string) => {
+                               streamedContent += chunk;
+                               updateStreamingUI();
+                       },
+                       onReasoningChunk: (chunk: string) => {
+                               streamedReasoningContent += chunk;
+                               // Update UI to show reasoning is being received
+                               const idx = conversationsStore.findMessageIndex(currentMessageId);
+                               conversationsStore.updateMessageAtIndex(idx, {
+                                       reasoningContent: streamedReasoningContent
+                               });
+                       },
+                       onToolCallsStreaming: (toolCalls) => {
+                               const idx = conversationsStore.findMessageIndex(currentMessageId);
+                               conversationsStore.updateMessageAtIndex(idx, { toolCalls: JSON.stringify(toolCalls) });
                        },
-                       onAttachments: (extras: DatabaseMessageExtra[]) => {
+                       onAttachments: (messageId: string, extras: DatabaseMessageExtra[]) => {
                                if (!extras.length) return;
-                               streamedExtras = [...streamedExtras, ...extras];
-                               const idx = conversationsStore.findMessageIndex(assistantMessage.id);
-                               conversationsStore.updateMessageAtIndex(idx, { extra: streamedExtras });
-                               DatabaseService.updateMessage(assistantMessage.id, { extra: streamedExtras }).catch(
-                                       console.error
-                               );
+                               const idx = conversationsStore.findMessageIndex(messageId);
+                               if (idx === -1) return;
+                               const msg = conversationsStore.activeMessages[idx];
+                               const updatedExtras = [...(msg.extra || []), ...extras];
+                               conversationsStore.updateMessageAtIndex(idx, { extra: updatedExtras });
+                               DatabaseService.updateMessage(messageId, { extra: updatedExtras }).catch(console.error);
                        },
                        onModel: (modelName: string) => recordModel(modelName),
                        onTurnComplete: (intermediateTimings: ChatMessageTimings) => {
+                               // Update the first assistant message with cumulative agentic timings
                                const idx = conversationsStore.findMessageIndex(assistantMessage.id);
                                conversationsStore.updateMessageAtIndex(idx, { timings: intermediateTimings });
                        },
@@ -651,56 +635,104 @@ class ChatStore {
                                                cache_n: timings?.cache_n || 0,
                                                prompt_progress: promptProgress
                                        },
-                                       assistantMessage.convId
+                                       convId
                                );
                        },
-                       onComplete: async (
-                               finalContent?: string,
-                               reasoningContent?: string,
-                               timings?: ChatMessageTimings,
-                               toolCallContent?: string
+                       onAssistantTurnComplete: async (
+                               content: string,
+                               reasoningContent: string | undefined,
+                               timings: ChatMessageTimings | undefined,
+                               toolCalls: import('$lib/types/api').ApiChatCompletionToolCall[] | undefined
                        ) => {
-                               this.setStreamingActive(false);
-                               finalizeReasoning();
-                               const combinedContent = hasStreamedChunks
-                                       ? streamedContent
-                                       : wrapReasoningContent(finalContent || '', reasoningContent);
                                const updateData: Record<string, unknown> = {
-                                       content: combinedContent,
-                                       toolCalls: toolCallContent || streamedToolCallContent,
+                                       content,
+                                       reasoningContent: reasoningContent || undefined,
+                                       toolCalls: toolCalls ? JSON.stringify(toolCalls) : '',
                                        timings
                                };
-                               if (streamedExtras.length > 0) updateData.extra = streamedExtras;
                                if (resolvedModel && !modelPersisted) updateData.model = resolvedModel;
-                               await DatabaseService.updateMessage(assistantMessage.id, updateData);
-                               const idx = conversationsStore.findMessageIndex(assistantMessage.id);
+                               await DatabaseService.updateMessage(currentMessageId, updateData);
+                               const idx = conversationsStore.findMessageIndex(currentMessageId);
                                const uiUpdate: Partial<DatabaseMessage> = {
-                                       content: combinedContent,
-                                       toolCalls: updateData.toolCalls as string
+                                       content,
+                                       reasoningContent: reasoningContent || undefined,
+                                       toolCalls: toolCalls ? JSON.stringify(toolCalls) : ''
                                };
-                               if (streamedExtras.length > 0) uiUpdate.extra = streamedExtras;
                                if (timings) uiUpdate.timings = timings;
                                if (resolvedModel) uiUpdate.model = resolvedModel;
                                conversationsStore.updateMessageAtIndex(idx, uiUpdate);
-                               await conversationsStore.updateCurrentNode(assistantMessage.id);
-                               if (onComplete) await onComplete(combinedContent);
-                               this.setChatLoading(assistantMessage.convId, false);
-                               this.clearChatStreaming(assistantMessage.convId);
-                               this.setProcessingState(assistantMessage.convId, null);
+                               await conversationsStore.updateCurrentNode(currentMessageId);
+                       },
+                       createToolResultMessage: async (
+                               toolCallId: string,
+                               content: string,
+                               extras?: DatabaseMessageExtra[]
+                       ) => {
+                               const msg = await DatabaseService.createMessageBranch(
+                                       {
+                                               convId,
+                                               type: MessageType.TEXT,
+                                               role: MessageRole.TOOL,
+                                               content,
+                                               toolCallId,
+                                               timestamp: Date.now(),
+                                               toolCalls: '',
+                                               children: [],
+                                               extra: extras
+                                       },
+                                       currentMessageId
+                               );
+                               conversationsStore.addMessageToActive(msg);
+                               await conversationsStore.updateCurrentNode(msg.id);
+                               return msg;
+                       },
+                       createAssistantMessage: async () => {
+                               // Reset streaming state for new message
+                               streamedContent = '';
+                               streamedReasoningContent = '';
+
+                               const lastMsg =
+                                       conversationsStore.activeMessages[conversationsStore.activeMessages.length - 1];
+                               const msg = await DatabaseService.createMessageBranch(
+                                       {
+                                               convId,
+                                               type: MessageType.TEXT,
+                                               role: MessageRole.ASSISTANT,
+                                               content: '',
+                                               timestamp: Date.now(),
+                                               toolCalls: '',
+                                               children: [],
+                                               model: resolvedModel
+                                       },
+                                       lastMsg.id
+                               );
+                               conversationsStore.addMessageToActive(msg);
+                               currentMessageId = msg.id;
+                               return msg;
+                       },
+                       onFlowComplete: (finalTimings?: ChatMessageTimings) => {
+                               if (finalTimings) {
+                                       const idx = conversationsStore.findMessageIndex(assistantMessage.id);
+
+                                       conversationsStore.updateMessageAtIndex(idx, { timings: finalTimings });
+                                       DatabaseService.updateMessage(assistantMessage.id, { timings: finalTimings }).catch(
+                                               console.error
+                                       );
+                               }
+
+                               cleanupStreamingState();
+
+                               if (onComplete) onComplete(streamedContent);
                                if (isRouterMode()) modelsStore.fetchRouterModels().catch(console.error);
                        },
                        onError: (error: Error) => {
                                this.setStreamingActive(false);
                                if (isAbortError(error)) {
-                                       this.setChatLoading(assistantMessage.convId, false);
-                                       this.clearChatStreaming(assistantMessage.convId);
-                                       this.setProcessingState(assistantMessage.convId, null);
+                                       cleanupStreamingState();
                                        return;
                                }
                                console.error('Streaming error:', error);
-                               this.setChatLoading(assistantMessage.convId, false);
-                               this.clearChatStreaming(assistantMessage.convId);
-                               this.setProcessingState(assistantMessage.convId, null);
+                               cleanupStreamingState();
                                const idx = conversationsStore.findMessageIndex(assistantMessage.id);
                                if (idx !== -1) {
                                        const failedMessage = conversationsStore.removeMessageAtIndex(idx);
@@ -717,12 +749,13 @@ class ChatStore {
                                if (onError) onError(error);
                        }
                };
+
                const perChatOverrides = conversationsStore.activeConversation?.mcpServerOverrides;
 
                const agenticConfig = agenticStore.getConfig(config(), perChatOverrides);
                if (agenticConfig.enabled) {
                        const agenticResult = await agenticStore.runAgenticFlow({
-                               conversationId: assistantMessage.convId,
+                               conversationId: convId,
                                messages: allMessages,
                                options: { ...this.getApiOptions(), ...(effectiveModel ? { model: effectiveModel } : {}) },
                                callbacks: streamCallbacks,
@@ -732,16 +765,50 @@ class ChatStore {
                        if (agenticResult.handled) return;
                }
 
-               const completionOptions = {
-                       ...this.getApiOptions(),
-                       ...(effectiveModel ? { model: effectiveModel } : {}),
-                       ...streamCallbacks
-               };
-
+               // Non-agentic path: direct streaming into the single assistant message
                await ChatService.sendMessage(
                        allMessages,
-                       completionOptions,
-                       assistantMessage.convId,
+                       {
+                               ...this.getApiOptions(),
+                               ...(effectiveModel ? { model: effectiveModel } : {}),
+                               stream: true,
+                               onChunk: streamCallbacks.onChunk,
+                               onReasoningChunk: streamCallbacks.onReasoningChunk,
+                               onModel: streamCallbacks.onModel,
+                               onTimings: streamCallbacks.onTimings,
+                               onComplete: async (
+                                       finalContent?: string,
+                                       reasoningContent?: string,
+                                       timings?: ChatMessageTimings,
+                                       toolCalls?: string
+                               ) => {
+                                       const content = streamedContent || finalContent || '';
+                                       const reasoning = streamedReasoningContent || reasoningContent;
+                                       const updateData: Record<string, unknown> = {
+                                               content,
+                                               reasoningContent: reasoning || undefined,
+                                               toolCalls: toolCalls || '',
+                                               timings
+                                       };
+                                       if (resolvedModel && !modelPersisted) updateData.model = resolvedModel;
+                                       await DatabaseService.updateMessage(currentMessageId, updateData);
+                                       const idx = conversationsStore.findMessageIndex(currentMessageId);
+                                       const uiUpdate: Partial<DatabaseMessage> = {
+                                               content,
+                                               reasoningContent: reasoning || undefined,
+                                               toolCalls: toolCalls || ''
+                                       };
+                                       if (timings) uiUpdate.timings = timings;
+                                       if (resolvedModel) uiUpdate.model = resolvedModel;
+                                       conversationsStore.updateMessageAtIndex(idx, uiUpdate);
+                                       await conversationsStore.updateCurrentNode(currentMessageId);
+                                       cleanupStreamingState();
+                                       if (onComplete) await onComplete(content);
+                                       if (isRouterMode()) modelsStore.fetchRouterModels().catch(console.error);
+                               },
+                               onError: streamCallbacks.onError
+                       },
+                       convId,
                        abortController.signal
                );
        }
@@ -1033,56 +1100,40 @@ class ChatStore {
                        }
 
                        const originalContent = dbMessage.content;
+                       const originalReasoning = dbMessage.reasoningContent || '';
                        const conversationContext = conversationsStore.activeMessages.slice(0, idx);
                        const contextWithContinue = [
                                ...conversationContext,
                                { role: MessageRole.ASSISTANT as const, content: originalContent }
                        ];
 
-                       let appendedContent = '',
-                               hasReceivedContent = false,
-                               isReasoningOpen = hasUnclosedReasoningTag(originalContent);
+                       let appendedContent = '';
+                       let appendedReasoning = '';
+                       let hasReceivedContent = false;
 
                        const updateStreamingContent = (fullContent: string) => {
                                this.setChatStreaming(msg.convId, fullContent, msg.id);
                                conversationsStore.updateMessageAtIndex(idx, { content: fullContent });
                        };
 
-                       const appendContentChunk = (chunk: string) => {
-                               if (isReasoningOpen) {
-                                       appendedContent += REASONING_TAGS.END;
-                                       isReasoningOpen = false;
-                               }
-                               appendedContent += chunk;
-                               hasReceivedContent = true;
-                               updateStreamingContent(originalContent + appendedContent);
-                       };
-
-                       const appendReasoningChunk = (chunk: string) => {
-                               if (!isReasoningOpen) {
-                                       appendedContent += REASONING_TAGS.START;
-                                       isReasoningOpen = true;
-                               }
-                               appendedContent += chunk;
-                               hasReceivedContent = true;
-                               updateStreamingContent(originalContent + appendedContent);
-                       };
-
-                       const finalizeReasoning = () => {
-                               if (isReasoningOpen) {
-                                       appendedContent += REASONING_TAGS.END;
-                                       isReasoningOpen = false;
-                               }
-                       };
-
                        const abortController = this.getOrCreateAbortController(msg.convId);
 
                        await ChatService.sendMessage(
                                contextWithContinue,
                                {
                                        ...this.getApiOptions(),
-                                       onChunk: (chunk: string) => appendContentChunk(chunk),
-                                       onReasoningChunk: (chunk: string) => appendReasoningChunk(chunk),
+                                       onChunk: (chunk: string) => {
+                                               appendedContent += chunk;
+                                               hasReceivedContent = true;
+                                               updateStreamingContent(originalContent + appendedContent);
+                                       },
+                                       onReasoningChunk: (chunk: string) => {
+                                               appendedReasoning += chunk;
+                                               hasReceivedContent = true;
+                                               conversationsStore.updateMessageAtIndex(idx, {
+                                                       reasoningContent: originalReasoning + appendedReasoning
+                                               });
+                                       },
                                        onTimings: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => {
                                                const tokensPerSecond =
                                                        timings?.predicted_ms && timings?.predicted_n
@@ -1105,21 +1156,23 @@ class ChatStore {
                                                reasoningContent?: string,
                                                timings?: ChatMessageTimings
                                        ) => {
-                                               finalizeReasoning();
-
-                                               const appendedFromCompletion = hasReceivedContent
-                                                       ? appendedContent
-                                                       : wrapReasoningContent(finalContent || '', reasoningContent);
-                                               const fullContent = originalContent + appendedFromCompletion;
+                                               const finalAppendedContent = hasReceivedContent ? appendedContent : finalContent || '';
+                                               const finalAppendedReasoning = hasReceivedContent
+                                                       ? appendedReasoning
+                                                       : reasoningContent || '';
+                                               const fullContent = originalContent + finalAppendedContent;
+                                               const fullReasoning = originalReasoning + finalAppendedReasoning || undefined;
 
                                                await DatabaseService.updateMessage(msg.id, {
                                                        content: fullContent,
+                                                       reasoningContent: fullReasoning,
                                                        timestamp: Date.now(),
                                                        timings
                                                });
 
                                                conversationsStore.updateMessageAtIndex(idx, {
                                                        content: fullContent,
+                                                       reasoningContent: fullReasoning,
                                                        timestamp: Date.now(),
                                                        timings
                                                });
@@ -1135,11 +1188,13 @@ class ChatStore {
                                                        if (hasReceivedContent && appendedContent) {
                                                                await DatabaseService.updateMessage(msg.id, {
                                                                        content: originalContent + appendedContent,
+                                                                       reasoningContent: originalReasoning + appendedReasoning || undefined,
                                                                        timestamp: Date.now()
                                                                });
 
                                                                conversationsStore.updateMessageAtIndex(idx, {
                                                                        content: originalContent + appendedContent,
+                                                                       reasoningContent: originalReasoning + appendedReasoning || undefined,
                                                                        timestamp: Date.now()
                                                                });
                                                        }
index 5769ee98fdee54751019bc5a5d4a76d2f8beaa24..177155ea19539ab3c7d32415284d369c5c555494 100644 (file)
@@ -23,7 +23,7 @@ import { browser } from '$app/environment';
 import { toast } from 'svelte-sonner';
 import { DatabaseService } from '$lib/services/database.service';
 import { config } from '$lib/stores/settings.svelte';
-import { filterByLeafNodeId, findLeafNode } from '$lib/utils';
+import { filterByLeafNodeId, findLeafNode, runLegacyMigration } from '$lib/utils';
 import type { McpServerOverride } from '$lib/types/database';
 import { MessageRole } from '$lib/enums';
 import {
@@ -128,6 +128,10 @@ class ConversationsStore {
                if (this.isInitialized) return;
 
                try {
+                       // @deprecated Legacy migration for old marker-based messages.
+                       // Remove once all users have migrated to the structured format.
+                       await runLegacyMigration();
+
                        await this.loadConversations();
                        this.isInitialized = true;
                } catch (error) {
index f9d256e589a62641470d0cc5bf9c33045dcab5f5..ecf296fc381061954ae7d3122ca74d302bffe11b 100644 (file)
@@ -2,6 +2,7 @@ import type { MessageRole } from '$lib/enums';
 import { ToolCallType } from '$lib/enums';
 import type {
        ApiChatCompletionRequest,
+       ApiChatCompletionToolCall,
        ApiChatMessageContentPart,
        ApiChatMessageData
 } from './api';
@@ -70,22 +71,48 @@ export interface AgenticSession {
 }
 
 /**
- * Callbacks for agentic flow execution
+ * Callbacks for agentic flow execution.
+ *
+ * The agentic loop creates separate DB messages for each turn:
+ * - assistant messages (one per LLM turn, with tool_calls if any)
+ * - tool result messages (one per tool call execution)
+ *
+ * The first assistant message is created by the caller before starting the flow.
+ * Subsequent messages are created via createToolResultMessage / createAssistantMessage.
  */
 export interface AgenticFlowCallbacks {
+       /** Content chunk for the current assistant message */
        onChunk?: (chunk: string) => void;
+       /** Reasoning content chunk for the current assistant message */
        onReasoningChunk?: (chunk: string) => void;
-       onToolCallChunk?: (serializedToolCalls: string) => void;
-       onAttachments?: (extras: DatabaseMessageExtra[]) => void;
+       /** Tool calls being streamed (partial, accumulating) for the current turn */
+       onToolCallsStreaming?: (toolCalls: ApiChatCompletionToolCall[]) => void;
+       /** Attachments extracted from tool results */
+       onAttachments?: (messageId: string, extras: DatabaseMessageExtra[]) => void;
+       /** Model name detected from response */
        onModel?: (model: string) => void;
-       onComplete?: (
+       /** Current assistant turn's streaming is complete - save to DB */
+       onAssistantTurnComplete?: (
                content: string,
-               reasoningContent?: string,
-               timings?: ChatMessageTimings,
-               toolCalls?: string
-       ) => void;
+               reasoningContent: string | undefined,
+               timings: ChatMessageTimings | undefined,
+               toolCalls: ApiChatCompletionToolCall[] | undefined
+       ) => Promise<void>;
+       /** Create a tool result message in the DB tree */
+       createToolResultMessage?: (
+               toolCallId: string,
+               content: string,
+               extras?: DatabaseMessageExtra[]
+       ) => Promise<DatabaseMessage>;
+       /** Create a new assistant message for the next agentic turn */
+       createAssistantMessage?: () => Promise<DatabaseMessage>;
+       /** Entire agentic flow is complete */
+       onFlowComplete?: (timings?: ChatMessageTimings) => void;
+       /** Error during flow */
        onError?: (error: Error) => void;
+       /** Timing updates during streaming */
        onTimings?: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void;
+       /** An agentic turn (LLM + tool execution) completed - intermediate timing update */
        onTurnComplete?: (intermediateTimings: ChatMessageTimings) => void;
 }
 
index 3d2dd930cd69c0028c67b183b77f8a475d306293..431cf523a65fd0d806fcabf8d1fc099eb9f3f6c1 100644 (file)
@@ -1,5 +1,6 @@
 import type { ErrorDialogType } from '$lib/enums';
-import type { DatabaseMessageExtra } from './database';
+import type { ApiChatCompletionToolCall } from './api';
+import type { DatabaseMessage, DatabaseMessageExtra } from './database';
 
 export interface ChatUploadedFile {
        id: string;
@@ -99,21 +100,28 @@ export interface ChatMessageToolCallTiming {
 }
 
 /**
- * Callbacks for streaming chat responses
+ * Callbacks for streaming chat responses (used by both agentic and non-agentic paths)
  */
 export interface ChatStreamCallbacks {
        onChunk?: (chunk: string) => void;
        onReasoningChunk?: (chunk: string) => void;
-       onToolCallChunk?: (chunk: string) => void;
-       onAttachments?: (extras: DatabaseMessageExtra[]) => void;
+       onToolCallsStreaming?: (toolCalls: ApiChatCompletionToolCall[]) => void;
+       onAttachments?: (messageId: string, extras: DatabaseMessageExtra[]) => void;
        onModel?: (model: string) => void;
        onTimings?: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void;
-       onComplete?: (
-               content?: string,
-               reasoningContent?: string,
-               timings?: ChatMessageTimings,
-               toolCallContent?: string
-       ) => void;
+       onAssistantTurnComplete?: (
+               content: string,
+               reasoningContent: string | undefined,
+               timings: ChatMessageTimings | undefined,
+               toolCalls: ApiChatCompletionToolCall[] | undefined
+       ) => Promise<void>;
+       createToolResultMessage?: (
+               toolCallId: string,
+               content: string,
+               extras?: DatabaseMessageExtra[]
+       ) => Promise<DatabaseMessage>;
+       createAssistantMessage?: () => Promise<DatabaseMessage>;
+       onFlowComplete?: (timings?: ChatMessageTimings) => void;
        onError?: (error: Error) => void;
        onTurnComplete?: (intermediateTimings: ChatMessageTimings) => void;
 }
index 95ff7377c6e6a1b18d8db6438131ab2f18985457..2f96ef3f70a6cb411a20624c8e511a274d657958 100644 (file)
@@ -92,6 +92,8 @@ export interface DatabaseMessage {
         * @deprecated - left for backward compatibility
         */
        thinking?: string;
+       /** Reasoning content produced by the model (separate from visible content) */
+       reasoningContent?: string;
        /** Serialized JSON array of tool calls made by assistant messages */
        toolCalls?: string;
        /** Tool call ID for tool result messages (role: 'tool') */
index 330b924bcd8b3c6bec86fd24736d5c39e2e4a75c..5ec4683fa229fb4b7811a4a4259b2c6e159b4b90 100644 (file)
@@ -1,8 +1,15 @@
-import { AgenticSectionType } from '$lib/enums';
-import { AGENTIC_TAGS, AGENTIC_REGEX, REASONING_TAGS, TRIM_NEWLINES_REGEX } from '$lib/constants';
+import { AgenticSectionType, MessageRole } from '$lib/enums';
+import { ATTACHMENT_SAVED_REGEX, NEWLINE_SEPARATOR } from '$lib/constants';
+import type { ApiChatCompletionToolCall } from '$lib/types/api';
+import type {
+       DatabaseMessage,
+       DatabaseMessageExtra,
+       DatabaseMessageExtraImageFile
+} from '$lib/types/database';
+import { AttachmentType } from '$lib/enums';
 
 /**
- * Represents a parsed section of agentic content
+ * Represents a parsed section of agentic content for display
  */
 export interface AgenticSection {
        type: AgenticSectionType;
@@ -10,275 +17,194 @@ export interface AgenticSection {
        toolName?: string;
        toolArgs?: string;
        toolResult?: string;
+       toolResultExtras?: DatabaseMessageExtra[];
 }
 
 /**
- * Represents a segment of content that may contain reasoning blocks
+ * Represents a tool result line that may reference an image attachment
  */
-type ReasoningSegment = {
-       type:
-               | AgenticSectionType.TEXT
-               | AgenticSectionType.REASONING
-               | AgenticSectionType.REASONING_PENDING;
-       content: string;
+export type ToolResultLine = {
+       text: string;
+       image?: DatabaseMessageExtraImageFile;
 };
 
 /**
- * Parses agentic content into structured sections
- *
- * Main parsing function that processes content containing:
- * - Tool calls (completed, pending, or streaming)
- * - Reasoning blocks (completed or streaming)
- * - Regular text content
- *
- * The parser handles chronological display of agentic flow output, maintaining
- * the order of operations and properly identifying different states of tool calls
- * and reasoning blocks during streaming.
+ * Derives display sections from a single assistant message and its direct tool results.
  *
- * @param rawContent - The raw content string to parse
- * @returns Array of structured agentic sections ready for display
- *
- * @example
- * ```typescript
- * const content = "Some text <<<AGENTIC_TOOL_CALL>>>tool_name...";
- * const sections = parseAgenticContent(content);
- * // Returns: [{ type: 'text', content: 'Some text' }, { type: 'tool_call_streaming', ... }]
- * ```
+ * @param message - The assistant message
+ * @param toolMessages - Tool result messages for this assistant's tool_calls
+ * @param streamingToolCalls - Partial tool calls during streaming (not yet persisted)
  */
-export function parseAgenticContent(rawContent: string): AgenticSection[] {
-       if (!rawContent) return [];
-
-       const segments = splitReasoningSegments(rawContent);
+function deriveSingleTurnSections(
+       message: DatabaseMessage,
+       toolMessages: DatabaseMessage[] = [],
+       streamingToolCalls: ApiChatCompletionToolCall[] = []
+): AgenticSection[] {
        const sections: AgenticSection[] = [];
 
-       for (const segment of segments) {
-               if (segment.type === AgenticSectionType.TEXT) {
-                       sections.push(...parseToolCallContent(segment.content));
-                       continue;
-               }
-
-               if (segment.type === AgenticSectionType.REASONING) {
-                       if (segment.content.trim()) {
-                               sections.push({ type: AgenticSectionType.REASONING, content: segment.content });
-                       }
-                       continue;
-               }
-
+       // 1. Reasoning content (from dedicated field)
+       if (message.reasoningContent) {
                sections.push({
-                       type: AgenticSectionType.REASONING_PENDING,
-                       content: segment.content
+                       type: AgenticSectionType.REASONING,
+                       content: message.reasoningContent
                });
        }
 
-       return sections;
-}
-
-/**
- * Parses content containing tool call markers
- *
- * Identifies and extracts tool calls from content, handling:
- * - Completed tool calls with name, arguments, and results
- * - Pending tool calls (execution in progress)
- * - Streaming tool calls (arguments being received)
- * - Early-stage tool calls (just started)
- *
- * @param rawContent - The raw content string to parse
- * @returns Array of agentic sections representing tool calls and text
- */
-function parseToolCallContent(rawContent: string): AgenticSection[] {
-       if (!rawContent) return [];
-
-       const sections: AgenticSection[] = [];
-
-       const completedToolCallRegex = new RegExp(AGENTIC_REGEX.COMPLETED_TOOL_CALL.source, 'g');
-
-       let lastIndex = 0;
-       let match;
-
-       while ((match = completedToolCallRegex.exec(rawContent)) !== null) {
-               if (match.index > lastIndex) {
-                       const textBefore = rawContent.slice(lastIndex, match.index).trim();
-                       if (textBefore) {
-                               sections.push({ type: AgenticSectionType.TEXT, content: textBefore });
-                       }
-               }
-
-               const toolName = match[1];
-               const toolArgs = match[2];
-               const toolResult = match[3].replace(TRIM_NEWLINES_REGEX, '');
-
+       // 2. Text content
+       if (message.content?.trim()) {
                sections.push({
-                       type: AgenticSectionType.TOOL_CALL,
-                       content: toolResult,
-                       toolName,
-                       toolArgs,
-                       toolResult
+                       type: AgenticSectionType.TEXT,
+                       content: message.content
                });
-
-               lastIndex = match.index + match[0].length;
        }
 
-       const remainingContent = rawContent.slice(lastIndex);
-
-       const pendingMatch = remainingContent.match(AGENTIC_REGEX.PENDING_TOOL_CALL);
-       const partialWithNameMatch = remainingContent.match(AGENTIC_REGEX.PARTIAL_WITH_NAME);
-       const earlyMatch = remainingContent.match(AGENTIC_REGEX.EARLY_MATCH);
-
-       if (pendingMatch) {
-               const pendingIndex = remainingContent.indexOf(AGENTIC_TAGS.TOOL_CALL_START);
-
-               if (pendingIndex > 0) {
-                       const textBefore = remainingContent.slice(0, pendingIndex).trim();
-
-                       if (textBefore) {
-                               sections.push({ type: AgenticSectionType.TEXT, content: textBefore });
-                       }
-               }
-
-               const toolName = pendingMatch[1];
-               const toolArgs = pendingMatch[2];
-               const streamingResult = (pendingMatch[3] || '').replace(TRIM_NEWLINES_REGEX, '');
-
-               sections.push({
-                       type: AgenticSectionType.TOOL_CALL_PENDING,
-                       content: streamingResult,
-                       toolName,
-                       toolArgs,
-                       toolResult: streamingResult || undefined
-               });
-       } else if (partialWithNameMatch) {
-               const pendingIndex = remainingContent.indexOf(AGENTIC_TAGS.TOOL_CALL_START);
-
-               if (pendingIndex > 0) {
-                       const textBefore = remainingContent.slice(0, pendingIndex).trim();
-                       if (textBefore) {
-                               sections.push({ type: AgenticSectionType.TEXT, content: textBefore });
-                       }
-               }
-
-               const partialArgs = partialWithNameMatch[2] || '';
-
+       // 3. Persisted tool calls (from message.toolCalls field)
+       const toolCalls = parseToolCalls(message.toolCalls);
+       for (const tc of toolCalls) {
+               const resultMsg = toolMessages.find((m) => m.toolCallId === tc.id);
                sections.push({
-                       type: AgenticSectionType.TOOL_CALL_STREAMING,
-                       content: '',
-                       toolName: partialWithNameMatch[1],
-                       toolArgs: partialArgs || undefined,
-                       toolResult: undefined
+                       type: resultMsg ? AgenticSectionType.TOOL_CALL : AgenticSectionType.TOOL_CALL_PENDING,
+                       content: resultMsg?.content || '',
+                       toolName: tc.function?.name,
+                       toolArgs: tc.function?.arguments,
+                       toolResult: resultMsg?.content,
+                       toolResultExtras: resultMsg?.extra
                });
-       } else if (earlyMatch) {
-               const pendingIndex = remainingContent.indexOf(AGENTIC_TAGS.TOOL_CALL_START);
-
-               if (pendingIndex > 0) {
-                       const textBefore = remainingContent.slice(0, pendingIndex).trim();
-                       if (textBefore) {
-                               sections.push({ type: AgenticSectionType.TEXT, content: textBefore });
-                       }
-               }
-
-               const nameMatch = earlyMatch[1]?.match(AGENTIC_REGEX.TOOL_NAME_EXTRACT);
+       }
 
+       // 4. Streaming tool calls (not yet persisted - currently being received)
+       for (const tc of streamingToolCalls) {
+               // Skip if already in persisted tool calls
+               if (tc.id && toolCalls.find((t) => t.id === tc.id)) continue;
                sections.push({
                        type: AgenticSectionType.TOOL_CALL_STREAMING,
                        content: '',
-                       toolName: nameMatch?.[1],
-                       toolArgs: undefined,
-                       toolResult: undefined
+                       toolName: tc.function?.name,
+                       toolArgs: tc.function?.arguments
                });
-       } else if (lastIndex < rawContent.length) {
-               let remainingText = rawContent.slice(lastIndex).trim();
-
-               const partialMarkerMatch = remainingText.match(AGENTIC_REGEX.PARTIAL_MARKER);
-
-               if (partialMarkerMatch) {
-                       remainingText = remainingText.slice(0, partialMarkerMatch.index).trim();
-               }
-
-               if (remainingText) {
-                       sections.push({ type: AgenticSectionType.TEXT, content: remainingText });
-               }
-       }
-
-       if (sections.length === 0 && rawContent.trim()) {
-               sections.push({ type: AgenticSectionType.TEXT, content: rawContent });
        }
 
        return sections;
 }
 
 /**
- * Strips partial marker from text content
+ * Derives display sections from structured message data.
  *
- * Removes incomplete agentic markers (e.g., "<<<", "<<<AGENTIC") that may appear
- * at the end of streaming content.
+ * Handles both single-turn (one assistant + its tool results) and multi-turn
+ * agentic sessions (multiple assistant + tool messages grouped together).
  *
- * @param text - The text content to process
- * @returns Text with partial markers removed
+ * When `toolMessages` contains continuation assistant messages (from multi-turn
+ * agentic flows), they are processed in order to produce sections across all turns.
+ *
+ * @param message - The first/anchor assistant message
+ * @param toolMessages - Tool result messages and continuation assistant messages
+ * @param streamingToolCalls - Partial tool calls during streaming (not yet persisted)
+ * @param isStreaming - Whether the message is currently being streamed
  */
-function stripPartialMarker(text: string): string {
-       const partialMarkerMatch = text.match(AGENTIC_REGEX.PARTIAL_MARKER);
-
-       if (partialMarkerMatch) {
-               return text.slice(0, partialMarkerMatch.index).trim();
+export function deriveAgenticSections(
+       message: DatabaseMessage,
+       toolMessages: DatabaseMessage[] = [],
+       streamingToolCalls: ApiChatCompletionToolCall[] = []
+): AgenticSection[] {
+       const hasAssistantContinuations = toolMessages.some((m) => m.role === MessageRole.ASSISTANT);
+
+       if (!hasAssistantContinuations) {
+               return deriveSingleTurnSections(message, toolMessages, streamingToolCalls);
        }
 
-       return text;
-}
+       const sections: AgenticSection[] = [];
 
-/**
- * Splits raw content into segments based on reasoning blocks
- *
- * Identifies and extracts reasoning content wrapped in REASONING_TAGS.START/END markers,
- * separating it from regular text content. Handles both complete and incomplete
- * (streaming) reasoning blocks.
- *
- * @param rawContent - The raw content string to parse
- * @returns Array of reasoning segments with their types and content
- */
-function splitReasoningSegments(rawContent: string): ReasoningSegment[] {
-       if (!rawContent) return [];
+       const firstTurnToolMsgs = collectToolMessages(toolMessages, 0);
+       sections.push(...deriveSingleTurnSections(message, firstTurnToolMsgs));
 
-       const segments: ReasoningSegment[] = [];
-       let cursor = 0;
+       let i = firstTurnToolMsgs.length;
 
-       while (cursor < rawContent.length) {
-               const startIndex = rawContent.indexOf(REASONING_TAGS.START, cursor);
+       while (i < toolMessages.length) {
+               const msg = toolMessages[i];
 
-               if (startIndex === -1) {
-                       const remainingText = rawContent.slice(cursor);
+               if (msg.role === MessageRole.ASSISTANT) {
+                       const turnToolMsgs = collectToolMessages(toolMessages, i + 1);
+                       const isLastTurn = i + 1 + turnToolMsgs.length >= toolMessages.length;
 
-                       if (remainingText) {
-                               segments.push({ type: AgenticSectionType.TEXT, content: remainingText });
-                       }
+                       sections.push(
+                               ...deriveSingleTurnSections(msg, turnToolMsgs, isLastTurn ? streamingToolCalls : [])
+                       );
 
-                       break;
+                       i += 1 + turnToolMsgs.length;
+               } else {
+                       i++;
                }
+       }
 
-               if (startIndex > cursor) {
-                       const textBefore = rawContent.slice(cursor, startIndex);
+       return sections;
+}
+
+/**
+ * Collect consecutive tool messages starting at `startIndex`.
+ */
+function collectToolMessages(messages: DatabaseMessage[], startIndex: number): DatabaseMessage[] {
+       const result: DatabaseMessage[] = [];
 
-                       if (textBefore) {
-                               segments.push({ type: AgenticSectionType.TEXT, content: textBefore });
-                       }
+       for (let i = startIndex; i < messages.length; i++) {
+               if (messages[i].role === MessageRole.TOOL) {
+                       result.push(messages[i]);
+               } else {
+                       break;
                }
+       }
 
-               const contentStart = startIndex + REASONING_TAGS.START.length;
-               const endIndex = rawContent.indexOf(REASONING_TAGS.END, contentStart);
+       return result;
+}
+
+/**
+ * Parse tool result text into lines, matching image attachments by name.
+ */
+export function parseToolResultWithImages(
+       toolResult: string,
+       extras?: DatabaseMessageExtra[]
+): ToolResultLine[] {
+       const lines = toolResult.split(NEWLINE_SEPARATOR);
+       return lines.map((line) => {
+               const match = line.match(ATTACHMENT_SAVED_REGEX);
+               if (!match || !extras) return { text: line };
+
+               const attachmentName = match[1];
+               const image = extras.find(
+                       (e): e is DatabaseMessageExtraImageFile =>
+                               e.type === AttachmentType.IMAGE && e.name === attachmentName
+               );
+
+               return { text: line, image };
+       });
+}
 
-               if (endIndex === -1) {
-                       const pendingContent = rawContent.slice(contentStart);
+/**
+ * Safely parse the toolCalls JSON string from a DatabaseMessage.
+ */
+function parseToolCalls(toolCallsJson?: string): ApiChatCompletionToolCall[] {
+       if (!toolCallsJson) return [];
 
-                       segments.push({
-                               type: AgenticSectionType.REASONING_PENDING,
-                               content: stripPartialMarker(pendingContent)
-                       });
+       try {
+               const parsed = JSON.parse(toolCallsJson);
 
-                       break;
-               }
+               return Array.isArray(parsed) ? parsed : [];
+       } catch {
+               return [];
+       }
+}
 
-               const reasoningContent = rawContent.slice(contentStart, endIndex);
-               segments.push({ type: AgenticSectionType.REASONING, content: reasoningContent });
-               cursor = endIndex + REASONING_TAGS.END.length;
+/**
+ * Check if a message has agentic content (tool calls or is part of an agentic flow).
+ */
+export function hasAgenticContent(
+       message: DatabaseMessage,
+       toolMessages: DatabaseMessage[] = []
+): boolean {
+       if (message.toolCalls) {
+               const tc = parseToolCalls(message.toolCalls);
+
+               if (tc.length > 0) return true;
        }
 
-       return segments;
+       return toolMessages.length > 0;
 }
index 455d4f2c3fc63e6f1fead348f4449042f7e04d32..88c95b62122917f28032ebe50f5f81611c5cd3cb 100644 (file)
@@ -149,8 +149,17 @@ export { parseHeadersToArray, serializeHeaders } from './headers';
 // Favicon utilities
 export { getFaviconUrl } from './favicon';
 
-// Agentic content parsing utilities
-export { parseAgenticContent, type AgenticSection } from './agentic';
+// Agentic content utilities (structured section derivation)
+export {
+       deriveAgenticSections,
+       parseToolResultWithImages,
+       hasAgenticContent,
+       type AgenticSection,
+       type ToolResultLine
+} from './agentic';
+
+// Legacy migration utilities
+export { runLegacyMigration, isMigrationNeeded } from './legacy-migration';
 
 // Cache utilities
 export { TTLCache, ReactiveTTLMap, type TTLCacheOptions } from './cache-ttl';
diff --git a/tools/server/webui/src/lib/utils/legacy-migration.ts b/tools/server/webui/src/lib/utils/legacy-migration.ts
new file mode 100644 (file)
index 0000000..b526c26
--- /dev/null
@@ -0,0 +1,345 @@
+/**
+ * @deprecated Legacy migration utility â€” remove at some point in the future once all users have migrated to the new structured agentic message format.
+ *
+ * Converts old marker-based agentic messages to the new structured format
+ * with separate messages per turn.
+ *
+ * Old format: Single assistant message with markers in content:
+ *   <<<reasoning_content_start>>>...<<<reasoning_content_end>>>
+ *   <<<AGENTIC_TOOL_CALL_START>>>...<<<AGENTIC_TOOL_CALL_END>>>
+ *
+ * New format: Separate messages per turn:
+ *   - assistant (content + reasoningContent + toolCalls)
+ *   - tool (toolCallId + content)
+ *   - assistant (next turn)
+ *   - ...
+ */
+
+import { LEGACY_AGENTIC_REGEX, LEGACY_REASONING_TAGS } from '$lib/constants';
+import { DatabaseService } from '$lib/services/database.service';
+import { MessageRole, MessageType } from '$lib/enums';
+import type { DatabaseMessage } from '$lib/types/database';
+
+const MIGRATION_DONE_KEY = 'llama-webui-migration-v2-done';
+
+/**
+ * @deprecated Part of legacy migration â€” remove with the migration module.
+ * Check if migration has been performed.
+ */
+export function isMigrationNeeded(): boolean {
+       try {
+               return !localStorage.getItem(MIGRATION_DONE_KEY);
+       } catch {
+               return false;
+       }
+}
+
+/**
+ * Mark migration as done.
+ */
+function markMigrationDone(): void {
+       try {
+               localStorage.setItem(MIGRATION_DONE_KEY, String(Date.now()));
+       } catch {
+               // Ignore localStorage errors
+       }
+}
+
+/**
+ * Check if a message has legacy markers in its content.
+ */
+function hasLegacyMarkers(message: DatabaseMessage): boolean {
+       if (!message.content) return false;
+       return LEGACY_AGENTIC_REGEX.HAS_LEGACY_MARKERS.test(message.content);
+}
+
+/**
+ * Extract reasoning content from legacy marker format.
+ */
+function extractLegacyReasoning(content: string): { reasoning: string; cleanContent: string } {
+       let reasoning = '';
+       let cleanContent = content;
+
+       // Extract all reasoning blocks
+       const re = new RegExp(LEGACY_AGENTIC_REGEX.REASONING_EXTRACT.source, 'g');
+       let match;
+       while ((match = re.exec(content)) !== null) {
+               reasoning += match[1];
+       }
+
+       // Remove reasoning tags from content
+       cleanContent = cleanContent
+               .replace(new RegExp(LEGACY_AGENTIC_REGEX.REASONING_BLOCK.source, 'g'), '')
+               .replace(LEGACY_AGENTIC_REGEX.REASONING_OPEN, '');
+
+       return { reasoning, cleanContent };
+}
+
+/**
+ * Parse legacy content with tool call markers into structured turns.
+ */
+interface ParsedTurn {
+       textBefore: string;
+       toolCalls: Array<{
+               name: string;
+               args: string;
+               result: string;
+       }>;
+}
+
+function parseLegacyToolCalls(content: string): ParsedTurn[] {
+       const turns: ParsedTurn[] = [];
+       const regex = new RegExp(LEGACY_AGENTIC_REGEX.COMPLETED_TOOL_CALL.source, 'g');
+
+       let lastIndex = 0;
+       let currentTurn: ParsedTurn = { textBefore: '', toolCalls: [] };
+       let match;
+
+       while ((match = regex.exec(content)) !== null) {
+               const textBefore = content.slice(lastIndex, match.index).trim();
+
+               // If there's text between tool calls and we already have tool calls,
+               // that means a new turn started (text after tool results = new LLM turn)
+               if (textBefore && currentTurn.toolCalls.length > 0) {
+                       turns.push(currentTurn);
+                       currentTurn = { textBefore, toolCalls: [] };
+               } else if (textBefore && currentTurn.toolCalls.length === 0) {
+                       currentTurn.textBefore = textBefore;
+               }
+
+               currentTurn.toolCalls.push({
+                       name: match[1],
+                       args: match[2],
+                       result: match[3].replace(/^\n+|\n+$/g, '')
+               });
+
+               lastIndex = match.index + match[0].length;
+       }
+
+       // Any remaining text after the last tool call
+       const remainingText = content.slice(lastIndex).trim();
+
+       if (currentTurn.toolCalls.length > 0) {
+               turns.push(currentTurn);
+       }
+
+       // If there's text after all tool calls, it's the final assistant response
+       if (remainingText) {
+               // Remove any partial/open markers
+               const cleanRemaining = remainingText
+                       .replace(LEGACY_AGENTIC_REGEX.AGENTIC_TOOL_CALL_OPEN, '')
+                       .trim();
+               if (cleanRemaining) {
+                       turns.push({ textBefore: cleanRemaining, toolCalls: [] });
+               }
+       }
+
+       // If no tool calls found at all, return the original content as a single turn
+       if (turns.length === 0) {
+               turns.push({ textBefore: content.trim(), toolCalls: [] });
+       }
+
+       return turns;
+}
+
+/**
+ * Migrate a single conversation's messages from legacy format to new format.
+ */
+async function migrateConversation(convId: string): Promise<number> {
+       const allMessages = await DatabaseService.getConversationMessages(convId);
+       let migratedCount = 0;
+
+       for (const message of allMessages) {
+               if (message.role !== MessageRole.ASSISTANT) continue;
+               if (!hasLegacyMarkers(message)) {
+                       // Still check for reasoning-only markers (no tool calls)
+                       if (message.content?.includes(LEGACY_REASONING_TAGS.START)) {
+                               const { reasoning, cleanContent } = extractLegacyReasoning(message.content);
+                               await DatabaseService.updateMessage(message.id, {
+                                       content: cleanContent.trim(),
+                                       reasoningContent: reasoning || undefined
+                               });
+                               migratedCount++;
+                       }
+                       continue;
+               }
+
+               // Has agentic markers - full migration needed
+               const { reasoning, cleanContent } = extractLegacyReasoning(message.content);
+               const turns = parseLegacyToolCalls(cleanContent);
+
+               // Parse existing toolCalls JSON to try to match IDs
+               let existingToolCalls: Array<{ id: string; function?: { name: string; arguments: string } }> =
+                       [];
+               if (message.toolCalls) {
+                       try {
+                               existingToolCalls = JSON.parse(message.toolCalls);
+                       } catch {
+                               // Ignore
+                       }
+               }
+
+               // First turn uses the existing message
+               const firstTurn = turns[0];
+               if (!firstTurn) continue;
+
+               // Match tool calls from the first turn to existing IDs
+               const firstTurnToolCalls = firstTurn.toolCalls.map((tc, i) => {
+                       const existing =
+                               existingToolCalls.find((e) => e.function?.name === tc.name) || existingToolCalls[i];
+                       return {
+                               id: existing?.id || `legacy_tool_${i}`,
+                               type: 'function' as const,
+                               function: { name: tc.name, arguments: tc.args }
+                       };
+               });
+
+               // Update the existing message for the first turn
+               await DatabaseService.updateMessage(message.id, {
+                       content: firstTurn.textBefore,
+                       reasoningContent: reasoning || undefined,
+                       toolCalls: firstTurnToolCalls.length > 0 ? JSON.stringify(firstTurnToolCalls) : ''
+               });
+
+               let currentParentId = message.id;
+               let toolCallIdCounter = existingToolCalls.length;
+
+               // Create tool result messages for the first turn
+               for (let i = 0; i < firstTurn.toolCalls.length; i++) {
+                       const tc = firstTurn.toolCalls[i];
+                       const toolCallId = firstTurnToolCalls[i]?.id || `legacy_tool_${i}`;
+
+                       const toolMsg = await DatabaseService.createMessageBranch(
+                               {
+                                       convId,
+                                       type: MessageType.TEXT,
+                                       role: MessageRole.TOOL,
+                                       content: tc.result,
+                                       toolCallId,
+                                       timestamp: message.timestamp + i + 1,
+                                       toolCalls: '',
+                                       children: []
+                               },
+                               currentParentId
+                       );
+                       currentParentId = toolMsg.id;
+               }
+
+               // Create messages for subsequent turns
+               for (let turnIdx = 1; turnIdx < turns.length; turnIdx++) {
+                       const turn = turns[turnIdx];
+
+                       const turnToolCalls = turn.toolCalls.map((tc, i) => {
+                               const idx = toolCallIdCounter + i;
+                               const existing = existingToolCalls[idx];
+                               return {
+                                       id: existing?.id || `legacy_tool_${idx}`,
+                                       type: 'function' as const,
+                                       function: { name: tc.name, arguments: tc.args }
+                               };
+                       });
+                       toolCallIdCounter += turn.toolCalls.length;
+
+                       // Create assistant message for this turn
+                       const assistantMsg = await DatabaseService.createMessageBranch(
+                               {
+                                       convId,
+                                       type: MessageType.TEXT,
+                                       role: MessageRole.ASSISTANT,
+                                       content: turn.textBefore,
+                                       timestamp: message.timestamp + turnIdx * 100,
+                                       toolCalls: turnToolCalls.length > 0 ? JSON.stringify(turnToolCalls) : '',
+                                       children: [],
+                                       model: message.model
+                               },
+                               currentParentId
+                       );
+                       currentParentId = assistantMsg.id;
+
+                       // Create tool result messages for this turn
+                       for (let i = 0; i < turn.toolCalls.length; i++) {
+                               const tc = turn.toolCalls[i];
+                               const toolCallId = turnToolCalls[i]?.id || `legacy_tool_${toolCallIdCounter + i}`;
+
+                               const toolMsg = await DatabaseService.createMessageBranch(
+                                       {
+                                               convId,
+                                               type: MessageType.TEXT,
+                                               role: MessageRole.TOOL,
+                                               content: tc.result,
+                                               toolCallId,
+                                               timestamp: message.timestamp + turnIdx * 100 + i + 1,
+                                               toolCalls: '',
+                                               children: []
+                                       },
+                                       currentParentId
+                               );
+                               currentParentId = toolMsg.id;
+                       }
+               }
+
+               // Re-parent any children of the original message to the last created message
+               // (the original message's children list was the next user message or similar)
+               if (message.children.length > 0 && currentParentId !== message.id) {
+                       for (const childId of message.children) {
+                               // Skip children we just created (they were already properly parented)
+                               const child = allMessages.find((m) => m.id === childId);
+                               if (!child) continue;
+                               // Only re-parent non-tool messages that were original children
+                               if (child.role !== MessageRole.TOOL) {
+                                       await DatabaseService.updateMessage(childId, { parent: currentParentId });
+                                       // Add to new parent's children
+                                       const newParent = await DatabaseService.getConversationMessages(convId).then((msgs) =>
+                                               msgs.find((m) => m.id === currentParentId)
+                                       );
+                                       if (newParent && !newParent.children.includes(childId)) {
+                                               await DatabaseService.updateMessage(currentParentId, {
+                                                       children: [...newParent.children, childId]
+                                               });
+                                       }
+                               }
+                       }
+                       // Clear re-parented children from the original message
+                       await DatabaseService.updateMessage(message.id, { children: [] });
+               }
+
+               migratedCount++;
+       }
+
+       return migratedCount;
+}
+
+/**
+ * @deprecated Part of legacy migration â€” remove with the migration module.
+ * Run the full migration across all conversations.
+ * This should be called once at app startup if migration is needed.
+ */
+export async function runLegacyMigration(): Promise<void> {
+       if (!isMigrationNeeded()) return;
+
+       console.log('[Migration] Starting legacy message format migration...');
+
+       try {
+               const conversations = await DatabaseService.getAllConversations();
+               let totalMigrated = 0;
+
+               for (const conv of conversations) {
+                       const count = await migrateConversation(conv.id);
+                       totalMigrated += count;
+               }
+
+               if (totalMigrated > 0) {
+                       console.log(
+                               `[Migration] Migrated ${totalMigrated} messages across ${conversations.length} conversations`
+                       );
+               } else {
+                       console.log('[Migration] No legacy messages found, marking as done');
+               }
+
+               markMigrationDone();
+       } catch (error) {
+               console.error('[Migration] Failed to migrate legacy messages:', error);
+               // Still mark as done to avoid infinite retry loops
+               markMigrationDone();
+       }
+}
diff --git a/tools/server/webui/tests/unit/agentic-sections.test.ts b/tools/server/webui/tests/unit/agentic-sections.test.ts
new file mode 100644 (file)
index 0000000..451f30c
--- /dev/null
@@ -0,0 +1,211 @@
+import { describe, it, expect } from 'vitest';
+import { deriveAgenticSections, hasAgenticContent } from '$lib/utils/agentic';
+import { AgenticSectionType, MessageRole } from '$lib/enums';
+import type { DatabaseMessage } from '$lib/types/database';
+import type { ApiChatCompletionToolCall } from '$lib/types/api';
+
+function makeAssistant(overrides: Partial<DatabaseMessage> = {}): DatabaseMessage {
+       return {
+               id: overrides.id ?? 'ast-1',
+               convId: 'conv-1',
+               type: 'text',
+               timestamp: Date.now(),
+               role: MessageRole.ASSISTANT,
+               content: overrides.content ?? '',
+               parent: null,
+               children: [],
+               ...overrides
+       } as DatabaseMessage;
+}
+
+function makeToolMsg(overrides: Partial<DatabaseMessage> = {}): DatabaseMessage {
+       return {
+               id: overrides.id ?? 'tool-1',
+               convId: 'conv-1',
+               type: 'text',
+               timestamp: Date.now(),
+               role: MessageRole.TOOL,
+               content: overrides.content ?? 'tool result',
+               parent: null,
+               children: [],
+               toolCallId: overrides.toolCallId ?? 'call_1',
+               ...overrides
+       } as DatabaseMessage;
+}
+
+describe('deriveAgenticSections', () => {
+       it('returns empty array for assistant with no content', () => {
+               const msg = makeAssistant({ content: '' });
+               const sections = deriveAgenticSections(msg);
+               expect(sections).toEqual([]);
+       });
+
+       it('returns text section for simple assistant message', () => {
+               const msg = makeAssistant({ content: 'Hello world' });
+               const sections = deriveAgenticSections(msg);
+               expect(sections).toHaveLength(1);
+               expect(sections[0].type).toBe(AgenticSectionType.TEXT);
+               expect(sections[0].content).toBe('Hello world');
+       });
+
+       it('returns reasoning + text for message with reasoning', () => {
+               const msg = makeAssistant({
+                       content: 'Answer is 4.',
+                       reasoningContent: 'Let me think...'
+               });
+               const sections = deriveAgenticSections(msg);
+               expect(sections).toHaveLength(2);
+               expect(sections[0].type).toBe(AgenticSectionType.REASONING);
+               expect(sections[0].content).toBe('Let me think...');
+               expect(sections[1].type).toBe(AgenticSectionType.TEXT);
+       });
+
+       it('single turn: assistant with tool calls and results', () => {
+               const msg = makeAssistant({
+                       content: 'Let me check.',
+                       toolCalls: JSON.stringify([
+                               { id: 'call_1', type: 'function', function: { name: 'search', arguments: '{"q":"test"}' } }
+                       ])
+               });
+               const toolResult = makeToolMsg({
+                       toolCallId: 'call_1',
+                       content: 'Found 3 results'
+               });
+               const sections = deriveAgenticSections(msg, [toolResult]);
+               expect(sections).toHaveLength(2);
+               expect(sections[0].type).toBe(AgenticSectionType.TEXT);
+               expect(sections[1].type).toBe(AgenticSectionType.TOOL_CALL);
+               expect(sections[1].toolName).toBe('search');
+               expect(sections[1].toolResult).toBe('Found 3 results');
+       });
+
+       it('single turn: pending tool call without result', () => {
+               const msg = makeAssistant({
+                       toolCalls: JSON.stringify([
+                               { id: 'call_1', type: 'function', function: { name: 'bash', arguments: '{}' } }
+                       ])
+               });
+               const sections = deriveAgenticSections(msg, []);
+               expect(sections).toHaveLength(1);
+               expect(sections[0].type).toBe(AgenticSectionType.TOOL_CALL_PENDING);
+               expect(sections[0].toolName).toBe('bash');
+       });
+
+       it('multi-turn: two assistant turns grouped as one session', () => {
+               const assistant1 = makeAssistant({
+                       id: 'ast-1',
+                       content: 'Turn 1 text',
+                       toolCalls: JSON.stringify([
+                               { id: 'call_1', type: 'function', function: { name: 'search', arguments: '{"q":"foo"}' } }
+                       ])
+               });
+               const tool1 = makeToolMsg({ id: 'tool-1', toolCallId: 'call_1', content: 'result 1' });
+               const assistant2 = makeAssistant({
+                       id: 'ast-2',
+                       content: 'Final answer based on results.'
+               });
+
+               // toolMessages contains both tool result and continuation assistant
+               const sections = deriveAgenticSections(assistant1, [tool1, assistant2]);
+               expect(sections).toHaveLength(3);
+               // Turn 1
+               expect(sections[0].type).toBe(AgenticSectionType.TEXT);
+               expect(sections[0].content).toBe('Turn 1 text');
+               expect(sections[1].type).toBe(AgenticSectionType.TOOL_CALL);
+               expect(sections[1].toolName).toBe('search');
+               expect(sections[1].toolResult).toBe('result 1');
+               // Turn 2 (final)
+               expect(sections[2].type).toBe(AgenticSectionType.TEXT);
+               expect(sections[2].content).toBe('Final answer based on results.');
+       });
+
+       it('multi-turn: three turns with tool calls', () => {
+               const assistant1 = makeAssistant({
+                       id: 'ast-1',
+                       content: '',
+                       toolCalls: JSON.stringify([
+                               { id: 'call_1', type: 'function', function: { name: 'list_files', arguments: '{}' } }
+                       ])
+               });
+               const tool1 = makeToolMsg({ id: 'tool-1', toolCallId: 'call_1', content: 'file1 file2' });
+               const assistant2 = makeAssistant({
+                       id: 'ast-2',
+                       content: 'Reading file1...',
+                       toolCalls: JSON.stringify([
+                               {
+                                       id: 'call_2',
+                                       type: 'function',
+                                       function: { name: 'read_file', arguments: '{"path":"file1"}' }
+                               }
+                       ])
+               });
+               const tool2 = makeToolMsg({ id: 'tool-2', toolCallId: 'call_2', content: 'contents of file1' });
+               const assistant3 = makeAssistant({
+                       id: 'ast-3',
+                       content: 'Here is the analysis.',
+                       reasoningContent: 'The file contains...'
+               });
+
+               const sections = deriveAgenticSections(assistant1, [tool1, assistant2, tool2, assistant3]);
+               // Turn 1: tool_call (no text since content is empty)
+               // Turn 2: text + tool_call
+               // Turn 3: reasoning + text
+               expect(sections).toHaveLength(5);
+               expect(sections[0].type).toBe(AgenticSectionType.TOOL_CALL);
+               expect(sections[0].toolName).toBe('list_files');
+               expect(sections[1].type).toBe(AgenticSectionType.TEXT);
+               expect(sections[1].content).toBe('Reading file1...');
+               expect(sections[2].type).toBe(AgenticSectionType.TOOL_CALL);
+               expect(sections[2].toolName).toBe('read_file');
+               expect(sections[3].type).toBe(AgenticSectionType.REASONING);
+               expect(sections[4].type).toBe(AgenticSectionType.TEXT);
+               expect(sections[4].content).toBe('Here is the analysis.');
+       });
+
+       it('multi-turn: streaming tool calls on last turn', () => {
+               const assistant1 = makeAssistant({
+                       toolCalls: JSON.stringify([
+                               { id: 'call_1', type: 'function', function: { name: 'search', arguments: '{}' } }
+                       ])
+               });
+               const tool1 = makeToolMsg({ toolCallId: 'call_1', content: 'result' });
+               const assistant2 = makeAssistant({ id: 'ast-2', content: '' });
+
+               const streamingToolCalls: ApiChatCompletionToolCall[] = [
+                       { id: 'call_2', type: 'function', function: { name: 'write_file', arguments: '{"pa' } }
+               ];
+
+               const sections = deriveAgenticSections(assistant1, [tool1, assistant2], streamingToolCalls);
+               // Turn 1: tool_call
+               // Turn 2 (streaming): streaming tool call
+               expect(sections.some((s) => s.type === AgenticSectionType.TOOL_CALL)).toBe(true);
+               expect(sections.some((s) => s.type === AgenticSectionType.TOOL_CALL_STREAMING)).toBe(true);
+       });
+});
+
+describe('hasAgenticContent', () => {
+       it('returns false for plain assistant', () => {
+               const msg = makeAssistant({ content: 'Just text' });
+               expect(hasAgenticContent(msg)).toBe(false);
+       });
+
+       it('returns true when message has toolCalls', () => {
+               const msg = makeAssistant({
+                       toolCalls: JSON.stringify([
+                               { id: 'call_1', type: 'function', function: { name: 'test', arguments: '{}' } }
+                       ])
+               });
+               expect(hasAgenticContent(msg)).toBe(true);
+       });
+
+       it('returns true when toolMessages are provided', () => {
+               const msg = makeAssistant();
+               const tool = makeToolMsg();
+               expect(hasAgenticContent(msg, [tool])).toBe(true);
+       });
+
+       it('returns false for empty toolCalls JSON', () => {
+               const msg = makeAssistant({ toolCalls: '[]' });
+               expect(hasAgenticContent(msg)).toBe(false);
+       });
+});
index 436908bdb8be1c186744999205f3289a253d605b..86867f8a9ddcd7e6234a643243ac3702e8669cfd 100644 (file)
@@ -1,17 +1,22 @@
 import { describe, it, expect } from 'vitest';
-import { AGENTIC_REGEX } from '$lib/constants/agentic';
+import { LEGACY_AGENTIC_REGEX } from '$lib/constants/agentic';
 
-// Mirror the logic in ChatService.stripReasoningContent so we can test it in isolation.
-// The real function is private static, so we replicate the strip pipeline here.
-function stripContextMarkers(content: string): string {
+/**
+ * Tests for legacy marker stripping (used in migration).
+ * The new system does not embed markers in content - these tests verify
+ * the legacy regex patterns still work for the migration code.
+ */
+
+// Mirror the legacy stripping logic used during migration
+function stripLegacyContextMarkers(content: string): string {
        return content
-               .replace(AGENTIC_REGEX.REASONING_BLOCK, '')
-               .replace(AGENTIC_REGEX.REASONING_OPEN, '')
-               .replace(AGENTIC_REGEX.AGENTIC_TOOL_CALL_BLOCK, '')
-               .replace(AGENTIC_REGEX.AGENTIC_TOOL_CALL_OPEN, '');
+               .replace(new RegExp(LEGACY_AGENTIC_REGEX.REASONING_BLOCK.source, 'g'), '')
+               .replace(LEGACY_AGENTIC_REGEX.REASONING_OPEN, '')
+               .replace(new RegExp(LEGACY_AGENTIC_REGEX.AGENTIC_TOOL_CALL_BLOCK.source, 'g'), '')
+               .replace(LEGACY_AGENTIC_REGEX.AGENTIC_TOOL_CALL_OPEN, '');
 }
 
-// A realistic complete tool call block as stored in message.content after a turn.
+// A realistic complete tool call block as stored in old message.content
 const COMPLETE_BLOCK =
        '\n\n<<<AGENTIC_TOOL_CALL_START>>>\n' +
        '<<<TOOL_NAME:bash_tool>>>\n' +
@@ -30,11 +35,10 @@ const OPEN_BLOCK =
        '<<<TOOL_ARGS_END>>>\n' +
        'partial output...';
 
-describe('agentic marker stripping for context', () => {
+describe('legacy agentic marker stripping (for migration)', () => {
        it('strips a complete tool call block, leaving surrounding text', () => {
                const input = 'Before.' + COMPLETE_BLOCK + 'After.';
-               const result = stripContextMarkers(input);
-               // markers gone; residual newlines between fragments are fine
+               const result = stripLegacyContextMarkers(input);
                expect(result).not.toContain('<<<');
                expect(result).toContain('Before.');
                expect(result).toContain('After.');
@@ -42,7 +46,7 @@ describe('agentic marker stripping for context', () => {
 
        it('strips multiple complete tool call blocks', () => {
                const input = 'A' + COMPLETE_BLOCK + 'B' + COMPLETE_BLOCK + 'C';
-               const result = stripContextMarkers(input);
+               const result = stripLegacyContextMarkers(input);
                expect(result).not.toContain('<<<');
                expect(result).toContain('A');
                expect(result).toContain('B');
@@ -51,19 +55,19 @@ describe('agentic marker stripping for context', () => {
 
        it('strips an open/partial tool call block (no END marker)', () => {
                const input = 'Lead text.' + OPEN_BLOCK;
-               const result = stripContextMarkers(input);
+               const result = stripLegacyContextMarkers(input);
                expect(result).toBe('Lead text.');
                expect(result).not.toContain('<<<');
        });
 
        it('does not alter content with no markers', () => {
                const input = 'Just a normal assistant response.';
-               expect(stripContextMarkers(input)).toBe(input);
+               expect(stripLegacyContextMarkers(input)).toBe(input);
        });
 
        it('strips reasoning block independently', () => {
                const input = '<<<reasoning_content_start>>>think hard<<<reasoning_content_end>>>Answer.';
-               expect(stripContextMarkers(input)).toBe('Answer.');
+               expect(stripLegacyContextMarkers(input)).toBe('Answer.');
        });
 
        it('strips both reasoning and agentic blocks together', () => {
@@ -71,11 +75,21 @@ describe('agentic marker stripping for context', () => {
                        '<<<reasoning_content_start>>>plan<<<reasoning_content_end>>>' +
                        'Some text.' +
                        COMPLETE_BLOCK;
-               expect(stripContextMarkers(input)).not.toContain('<<<');
-               expect(stripContextMarkers(input)).toContain('Some text.');
+               expect(stripLegacyContextMarkers(input)).not.toContain('<<<');
+               expect(stripLegacyContextMarkers(input)).toContain('Some text.');
        });
 
        it('empty string survives', () => {
-               expect(stripContextMarkers('')).toBe('');
+               expect(stripLegacyContextMarkers('')).toBe('');
+       });
+
+       it('detects legacy markers', () => {
+               expect(LEGACY_AGENTIC_REGEX.HAS_LEGACY_MARKERS.test('normal text')).toBe(false);
+               expect(
+                       LEGACY_AGENTIC_REGEX.HAS_LEGACY_MARKERS.test('text<<<AGENTIC_TOOL_CALL_START>>>more')
+               ).toBe(true);
+               expect(LEGACY_AGENTIC_REGEX.HAS_LEGACY_MARKERS.test('<<<reasoning_content_start>>>think')).toBe(
+                       true
+               );
        });
 });
index abbecf7e098780d11c1756039d5ccc2cb865c3ec..b448974a38da76b2831052f02e3b34d5b476d037 100644 (file)
 import { describe, it, expect } from 'vitest';
-import { AGENTIC_REGEX, REASONING_TAGS } from '$lib/constants/agentic';
-import { ContentPartType } from '$lib/enums';
-
-// Replicate ChatService.extractReasoningFromContent (private static)
-function extractReasoningFromContent(
-       content: string | Array<{ type: string; text?: string }> | null | undefined
-): string | undefined {
-       if (!content) return undefined;
-
-       const extractFromString = (text: string): string => {
-               const parts: string[] = [];
-               const re = new RegExp(AGENTIC_REGEX.REASONING_EXTRACT.source);
-               let match = re.exec(text);
-               while (match) {
-                       parts.push(match[1]);
-                       text = text.slice(match.index + match[0].length);
-                       match = re.exec(text);
-               }
-               return parts.join('');
-       };
-
-       if (typeof content === 'string') {
-               const result = extractFromString(content);
-               return result || undefined;
-       }
-
-       if (!Array.isArray(content)) return undefined;
-
-       const parts: string[] = [];
-       for (const part of content) {
-               if (part.type === ContentPartType.TEXT && part.text) {
-                       const result = extractFromString(part.text);
-                       if (result) parts.push(result);
-               }
-       }
-       return parts.length > 0 ? parts.join('') : undefined;
-}
-
-// Replicate ChatService.stripReasoningContent (private static)
-function stripReasoningContent(
-       content: string | Array<{ type: string; text?: string }> | null | undefined
-): typeof content {
-       if (!content) return content;
-
-       if (typeof content === 'string') {
-               return content
-                       .replace(AGENTIC_REGEX.REASONING_BLOCK, '')
-                       .replace(AGENTIC_REGEX.REASONING_OPEN, '')
-                       .replace(AGENTIC_REGEX.AGENTIC_TOOL_CALL_BLOCK, '')
-                       .replace(AGENTIC_REGEX.AGENTIC_TOOL_CALL_OPEN, '');
-       }
-
-       if (!Array.isArray(content)) return content;
-
-       return content.map((part) => {
-               if (part.type !== ContentPartType.TEXT || !part.text) return part;
-               return {
-                       ...part,
-                       text: part.text
-                               .replace(AGENTIC_REGEX.REASONING_BLOCK, '')
-                               .replace(AGENTIC_REGEX.REASONING_OPEN, '')
-                               .replace(AGENTIC_REGEX.AGENTIC_TOOL_CALL_BLOCK, '')
-                               .replace(AGENTIC_REGEX.AGENTIC_TOOL_CALL_OPEN, '')
+import { MessageRole } from '$lib/enums';
+
+/**
+ * Tests for the new reasoning content handling.
+ * In the new architecture, reasoning content is stored in a dedicated
+ * `reasoningContent` field on DatabaseMessage, not embedded in content with tags.
+ * The API sends it as `reasoning_content` on ApiChatMessageData.
+ */
+
+describe('reasoning content in new structured format', () => {
+       it('reasoning is stored as separate field, not in content', () => {
+               // Simulate what the new chat store does
+               const message = {
+                       content: 'The answer is 4.',
+                       reasoningContent: 'Let me think: 2+2=4, basic arithmetic.'
                };
-       });
-}
-
-// Simulate the message mapping logic from ChatService.sendMessage
-function buildApiMessage(
-       content: string,
-       excludeReasoningFromContext: boolean
-): { role: string; content: string; reasoning_content?: string } {
-       const cleaned = stripReasoningContent(content) as string;
-       const mapped: { role: string; content: string; reasoning_content?: string } = {
-               role: 'assistant',
-               content: cleaned
-       };
-       if (!excludeReasoningFromContext) {
-               const reasoning = extractReasoningFromContent(content);
-               if (reasoning) {
-                       mapped.reasoning_content = reasoning;
-               }
-       }
-       return mapped;
-}
 
-// Helper: wrap reasoning the same way the chat store does during streaming
-function wrapReasoning(reasoning: string, content: string): string {
-       return `${REASONING_TAGS.START}${reasoning}${REASONING_TAGS.END}${content}`;
-}
-
-describe('reasoning content extraction', () => {
-       it('extracts reasoning from tagged string content', () => {
-               const input = wrapReasoning('step 1, step 2', 'The answer is 42.');
-               const result = extractReasoningFromContent(input);
-               expect(result).toBe('step 1, step 2');
-       });
-
-       it('returns undefined when no reasoning tags present', () => {
-               expect(extractReasoningFromContent('Just a normal response.')).toBeUndefined();
-       });
+               // Content should be clean
+               expect(message.content).not.toContain('<<<');
+               expect(message.content).toBe('The answer is 4.');
 
-       it('returns undefined for null/empty input', () => {
-               expect(extractReasoningFromContent(null)).toBeUndefined();
-               expect(extractReasoningFromContent(undefined)).toBeUndefined();
-               expect(extractReasoningFromContent('')).toBeUndefined();
+               // Reasoning in dedicated field
+               expect(message.reasoningContent).toBe('Let me think: 2+2=4, basic arithmetic.');
        });
 
-       it('extracts reasoning from content part arrays', () => {
-               const input = [
-                       {
-                               type: ContentPartType.TEXT,
-                               text: wrapReasoning('thinking hard', 'result')
-                       }
-               ];
-               expect(extractReasoningFromContent(input)).toBe('thinking hard');
-       });
+       it('convertDbMessageToApiChatMessageData includes reasoning_content', () => {
+               // Simulate the conversion logic
+               const dbMessage = {
+                       role: MessageRole.ASSISTANT,
+                       content: 'The answer is 4.',
+                       reasoningContent: 'Let me think: 2+2=4, basic arithmetic.'
+               };
 
-       it('handles multiple reasoning blocks', () => {
-               const input =
-                       REASONING_TAGS.START +
-                       'block1' +
-                       REASONING_TAGS.END +
-                       'middle' +
-                       REASONING_TAGS.START +
-                       'block2' +
-                       REASONING_TAGS.END +
-                       'end';
-               expect(extractReasoningFromContent(input)).toBe('block1block2');
-       });
+               const apiMessage: Record<string, unknown> = {
+                       role: dbMessage.role,
+                       content: dbMessage.content
+               };
+               if (dbMessage.reasoningContent) {
+                       apiMessage.reasoning_content = dbMessage.reasoningContent;
+               }
 
-       it('ignores non-text content parts', () => {
-               const input = [{ type: 'image_url', text: wrapReasoning('hidden', 'img') }];
-               expect(extractReasoningFromContent(input)).toBeUndefined();
+               expect(apiMessage.content).toBe('The answer is 4.');
+               expect(apiMessage.reasoning_content).toBe('Let me think: 2+2=4, basic arithmetic.');
+               // No internal tags leak into either field
+               expect(apiMessage.content).not.toContain('<<<');
+               expect(apiMessage.reasoning_content).not.toContain('<<<');
        });
-});
 
-describe('strip reasoning content', () => {
-       it('removes reasoning tags from string content', () => {
-               const input = wrapReasoning('internal thoughts', 'visible answer');
-               expect(stripReasoningContent(input)).toBe('visible answer');
-       });
+       it('API message excludes reasoning when excludeReasoningFromContext is true', () => {
+               const dbMessage = {
+                       role: MessageRole.ASSISTANT,
+                       content: 'The answer is 4.',
+                       reasoningContent: 'internal thinking'
+               };
 
-       it('removes reasoning from content part arrays', () => {
-               const input = [
-                       {
-                               type: ContentPartType.TEXT,
-                               text: wrapReasoning('thoughts', 'answer')
-                       }
-               ];
-               const result = stripReasoningContent(input) as Array<{ type: string; text?: string }>;
-               expect(result[0].text).toBe('answer');
-       });
-});
+               const excludeReasoningFromContext = true;
 
-describe('API message building with reasoning preservation', () => {
-       const storedContent = wrapReasoning('Let me think: 2+2=4, basic arithmetic.', 'The answer is 4.');
+               const apiMessage: Record<string, unknown> = {
+                       role: dbMessage.role,
+                       content: dbMessage.content
+               };
+               if (!excludeReasoningFromContext && dbMessage.reasoningContent) {
+                       apiMessage.reasoning_content = dbMessage.reasoningContent;
+               }
 
-       it('preserves reasoning_content when excludeReasoningFromContext is false', () => {
-               const msg = buildApiMessage(storedContent, false);
-               expect(msg.content).toBe('The answer is 4.');
-               expect(msg.reasoning_content).toBe('Let me think: 2+2=4, basic arithmetic.');
-               // no internal tags leak into either field
-               expect(msg.content).not.toContain('<<<');
-               expect(msg.reasoning_content).not.toContain('<<<');
+               expect(apiMessage.content).toBe('The answer is 4.');
+               expect(apiMessage.reasoning_content).toBeUndefined();
        });
 
-       it('strips reasoning_content when excludeReasoningFromContext is true', () => {
-               const msg = buildApiMessage(storedContent, true);
-               expect(msg.content).toBe('The answer is 4.');
-               expect(msg.reasoning_content).toBeUndefined();
-       });
+       it('handles messages with no reasoning', () => {
+               const dbMessage = {
+                       role: MessageRole.ASSISTANT,
+                       content: 'No reasoning here.',
+                       reasoningContent: undefined
+               };
 
-       it('handles content with no reasoning in both modes', () => {
-               const plain = 'No reasoning here.';
-               const msgPreserve = buildApiMessage(plain, false);
-               const msgExclude = buildApiMessage(plain, true);
-               expect(msgPreserve.content).toBe(plain);
-               expect(msgPreserve.reasoning_content).toBeUndefined();
-               expect(msgExclude.content).toBe(plain);
-               expect(msgExclude.reasoning_content).toBeUndefined();
-       });
+               const apiMessage: Record<string, unknown> = {
+                       role: dbMessage.role,
+                       content: dbMessage.content
+               };
+               if (dbMessage.reasoningContent) {
+                       apiMessage.reasoning_content = dbMessage.reasoningContent;
+               }
 
-       it('cleans agentic tool call blocks from content even when preserving reasoning', () => {
-               const input =
-                       wrapReasoning('plan', 'text') +
-                       '\n\n<<<AGENTIC_TOOL_CALL_START>>>\n' +
-                       '<<<TOOL_NAME:bash>>>\n' +
-                       '<<<TOOL_ARGS_START>>>\n{}\n<<<TOOL_ARGS_END>>>\nout\n' +
-                       '<<<AGENTIC_TOOL_CALL_END>>>\n';
-               const msg = buildApiMessage(input, false);
-               expect(msg.content).not.toContain('<<<');
-               expect(msg.reasoning_content).toBe('plan');
+               expect(apiMessage.content).toBe('No reasoning here.');
+               expect(apiMessage.reasoning_content).toBeUndefined();
        });
 });