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 {
interface Props {
class?: string;
message: DatabaseMessage;
+ toolMessages?: DatabaseMessage[];
isLastAssistantMessage?: boolean;
siblingInfo?: ChatMessageSiblingInfo | null;
}
let {
class: className = '',
message,
+ toolMessages = [],
isLastAssistantMessage = false,
siblingInfo = null
}: Props = $props();
{deletionInfo}
{isLastAssistantMessage}
{message}
+ {toolMessages}
messageContent={message.content}
onConfirmDelete={handleConfirmDelete}
onContinue={handleContinue}
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)}
>
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';
import { modelsStore } from '$lib/stores/models.svelte';
import { ServerModelStatus } from '$lib/enums';
+ import { hasAgenticContent } from '$lib/utils';
+
interface Props {
class?: string;
deletionInfo: {
} | null;
isLastAssistantMessage?: boolean;
message: DatabaseMessage;
+ toolMessages?: DatabaseMessage[];
messageContent: string | undefined;
onCopy: () => void;
onConfirmDelete: () => void;
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}
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}
/>
/**
* **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
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:',
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;
-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
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, '')
- };
- });
- }
-
/**
*
*
*
*/
- /**
- * 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.
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;
}),
content: message.content
};
+ if (message.reasoningContent) {
+ result.reasoning_content = message.reasoningContent;
+ }
+
if (toolCalls && toolCalls.length > 0) {
result.tool_calls = toolCalls;
}
role: message.role as MessageRole,
content: contentParts
};
+ if (message.reasoningContent) {
+ result.reasoning_content = message.reasoningContent;
+ }
if (toolCalls && toolCalls.length > 0) {
result.tool_calls = toolCalls;
}
* - 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
* @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';
import { isAbortError } from '$lib/utils';
import {
DEFAULT_AGENTIC_CONFIG,
- AGENTIC_TAGS,
NEWLINE_SEPARATOR,
TURN_LIMIT_MESSAGE,
LLM_ERROR_BLOCK_START,
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 };
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 });
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,
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++) {
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 = {
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 || '';
}
} 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;
}
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)}`;
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 }
];
}
}
+ // 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(
}));
}
- 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[];
*/
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';
import {
MAX_INACTIVE_CONVERSATION_STATES,
INACTIVE_CONVERSATION_STATE_MAX_AGE_MS,
- REASONING_TAGS,
SYSTEM_MESSAGE_PLACEHOLDER
} from '$lib/constants';
import type {
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('');
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 });
},
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);
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,
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
);
}
}
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
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
});
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()
});
}
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 {
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) {
import { ToolCallType } from '$lib/enums';
import type {
ApiChatCompletionRequest,
+ ApiChatCompletionToolCall,
ApiChatMessageContentPart,
ApiChatMessageData
} from './api';
}
/**
- * 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;
}
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;
}
/**
- * 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;
}
* @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') */
-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;
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;
}
// 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';
--- /dev/null
+/**
+ * @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();
+ }
+}
--- /dev/null
+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);
+ });
+});
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' +
'<<<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.');
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');
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', () => {
'<<<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
+ );
});
});
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();
});
});