server/webui: cleanup dual representation approach, simplify to openai-compat (#21090)

* server/webui: cleanup dual representation approach, simplify to openai-compat

* feat: Fix regression for Agentic Loop UI

* chore: update webui build output

* refactor: Post-review code improvements

* chore: update webui build output

* refactor: Cleanup

* chore: update webui build output

---------

Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com>
This commit is contained in:
Piotr Wilkin (ilintar) 2026-03-31 10:42:06 +02:00 committed by GitHub
parent 26dac845cc
commit 4453e77561
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 1308 additions and 909 deletions

Binary file not shown.

View File

@ -4,7 +4,7 @@
import { getChatActionsContext, setMessageEditContext } from '$lib/contexts';
import { chatStore, pendingEditMessageId } from '$lib/stores/chat.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { DatabaseService } from '$lib/services';
import { DatabaseService } from '$lib/services/database.service';
import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants';
import { MessageRole, AttachmentType } from '$lib/enums';
import {
@ -19,6 +19,7 @@
interface Props {
class?: string;
message: DatabaseMessage;
toolMessages?: DatabaseMessage[];
isLastAssistantMessage?: boolean;
siblingInfo?: ChatMessageSiblingInfo | null;
}
@ -26,6 +27,7 @@
let {
class: className = '',
message,
toolMessages = [],
isLastAssistantMessage = false,
siblingInfo = null
}: Props = $props();
@ -302,6 +304,7 @@
{deletionInfo}
{isLastAssistantMessage}
{message}
{toolMessages}
messageContent={message.content}
onConfirmDelete={handleConfirmDelete}
onContinue={handleContinue}

View File

@ -6,42 +6,42 @@
SyntaxHighlightedCode
} from '$lib/components/app';
import { config } from '$lib/stores/settings.svelte';
import { Wrench, Loader2, AlertTriangle, Brain } from '@lucide/svelte';
import { AgenticSectionType, AttachmentType, FileTypeText } from '$lib/enums';
import { Wrench, Loader2, Brain } from '@lucide/svelte';
import { AgenticSectionType, FileTypeText } from '$lib/enums';
import { formatJsonPretty } from '$lib/utils';
import { ATTACHMENT_SAVED_REGEX, NEWLINE_SEPARATOR } from '$lib/constants';
import { parseAgenticContent, type AgenticSection } from '$lib/utils';
import type { DatabaseMessage, DatabaseMessageExtraImageFile } from '$lib/types/database';
import {
deriveAgenticSections,
parseToolResultWithImages,
type AgenticSection,
type ToolResultLine
} from '$lib/utils';
import type { DatabaseMessage } from '$lib/types/database';
import type { ChatMessageAgenticTimings, ChatMessageAgenticTurnStats } from '$lib/types/chat';
import { ChatMessageStatsView } from '$lib/enums';
interface Props {
message?: DatabaseMessage;
content: string;
message: DatabaseMessage;
toolMessages?: DatabaseMessage[];
isStreaming?: boolean;
highlightTurns?: boolean;
}
type ToolResultLine = {
text: string;
image?: DatabaseMessageExtraImageFile;
};
let { content, message, isStreaming = false, highlightTurns = false }: Props = $props();
let { message, toolMessages = [], isStreaming = false, highlightTurns = false }: Props = $props();
let expandedStates: Record<number, boolean> = $state({});
const sections = $derived(parseAgenticContent(content));
const showToolCallInProgress = $derived(config().showToolCallInProgress as boolean);
const showThoughtInProgress = $derived(config().showThoughtInProgress as boolean);
// Parse toolResults with images only when sections or message.extra change
const sections = $derived(deriveAgenticSections(message, toolMessages, []));
// Parse tool results with images
const sectionsParsed = $derived(
sections.map((section) => ({
...section,
parsedLines: section.toolResult
? parseToolResultWithImages(section.toolResult, message?.extra)
: []
? parseToolResultWithImages(section.toolResult, section.toolResultExtras || message?.extra)
: ([] as ToolResultLine[])
}))
);
@ -107,26 +107,6 @@
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,
@ -144,9 +124,8 @@
<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)}
@ -154,7 +133,7 @@
icon={streamingIcon}
iconClass={streamingIconClass}
title={section.toolName || 'Tool call'}
subtitle={streamingSubtitle}
subtitle={isStreaming ? '' : 'incomplete'}
{isStreaming}
onToggle={() => toggleExpanded(index, section)}
>

View File

@ -15,7 +15,7 @@
import { Check, X } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox';
import { AGENTIC_TAGS, INPUT_CLASSES, REASONING_TAGS } from '$lib/constants';
import { INPUT_CLASSES } from '$lib/constants';
import { MessageRole, KeyboardKey, ChatMessageStatsView } from '$lib/enums';
import Label from '$lib/components/ui/label/label.svelte';
import { config } from '$lib/stores/settings.svelte';
@ -23,6 +23,8 @@
import { modelsStore } from '$lib/stores/models.svelte';
import { ServerModelStatus } from '$lib/enums';
import { hasAgenticContent } from '$lib/utils';
interface Props {
class?: string;
deletionInfo: {
@ -33,6 +35,7 @@
} | null;
isLastAssistantMessage?: boolean;
message: DatabaseMessage;
toolMessages?: DatabaseMessage[];
messageContent: string | undefined;
onCopy: () => void;
onConfirmDelete: () => void;
@ -53,6 +56,7 @@
deletionInfo,
isLastAssistantMessage = false,
message,
toolMessages = [],
messageContent,
onConfirmDelete,
onContinue,
@ -84,10 +88,8 @@
}
}
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());
@ -145,7 +147,7 @@
}
let highlightAgenticTurns = $derived(
hasAgenticMarkers &&
isAgentic &&
(currentConfig.alwaysShowAgenticTurns || activeStatsView === ChatMessageStatsView.SUMMARY)
);
@ -160,13 +162,14 @@
message?.role === MessageRole.ASSISTANT &&
isActivelyProcessing &&
hasNoContent &&
!isAgentic &&
isLastAssistantMessage
);
let showProcessingInfoBottom = $derived(
message?.role === MessageRole.ASSISTANT &&
isActivelyProcessing &&
!hasNoContent &&
(!hasNoContent || isAgentic) &&
isLastAssistantMessage
);
@ -252,10 +255,10 @@
<pre class="raw-output">{messageContent || ''}</pre>
{:else}
<ChatMessageAgenticContent
content={messageContent || ''}
{message}
{toolMessages}
isStreaming={isChatStreaming()}
highlightTurns={highlightAgenticTurns}
{message}
/>
{/if}
{:else}
@ -344,9 +347,7 @@
{onCopy}
{onEdit}
{onRegenerate}
onContinue={currentConfig.enableContinueGeneration && !hasReasoningMarkers
? onContinue
: undefined}
onContinue={currentConfig.enableContinueGeneration && !hasReasoning ? onContinue : undefined}
{onForkConversation}
{onDelete}
{onConfirmDelete}

View File

@ -6,7 +6,12 @@
import { chatStore } from '$lib/stores/chat.svelte';
import { conversationsStore, activeConversation } from '$lib/stores/conversations.svelte';
import { config } from '$lib/stores/settings.svelte';
import { copyToClipboard, formatMessageForClipboard, getMessageSiblings } from '$lib/utils';
import {
copyToClipboard,
formatMessageForClipboard,
getMessageSiblings,
hasAgenticContent
} from '$lib/utils';
interface Props {
class?: string;
@ -119,32 +124,75 @@
? 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];
// 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++;
}
}
const siblingInfo = getMessageSiblings(allConversationMessages, msg.id);
result.push({
message: msg,
toolMessages,
isLastAssistantMessage: false,
siblingInfo: siblingInfo || {
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 filteredMessages.map((message, index) => {
const siblingInfo = getMessageSiblings(allConversationMessages, message.id);
const isLastAssistantMessage =
message.role === MessageRole.ASSISTANT && index === lastAssistantIndex;
return {
message,
isLastAssistantMessage,
siblingInfo: siblingInfo || {
message,
siblingIds: [message.id],
currentIndex: 0,
totalSiblings: 1
}
};
});
return result;
});
</script>
@ -152,11 +200,12 @@
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}
/>

View File

@ -425,21 +425,16 @@ export { default as ChatMessage } from './ChatMessages/ChatMessage.svelte';
/**
* **ChatMessageAgenticContent** - Agentic workflow output display
*
* Specialized renderer for assistant messages containing agentic workflow markers.
* Parses structured content and displays tool calls and reasoning blocks as
* interactive collapsible sections with real-time streaming support.
* Specialized renderer for assistant messages with tool calls and reasoning.
* Derives display sections from structured message data (toolCalls, reasoningContent,
* and child tool result messages) and renders them as interactive collapsible sections.
*
* **Architecture:**
* - Uses `parseAgenticContent()` from `$lib/utils` to parse markers
* - Uses `deriveAgenticSections()` from `$lib/utils` to build sections from structured data
* - Renders sections as CollapsibleContentBlock components
* - Handles streaming state for progressive content display
* - Falls back to MarkdownContent for plain text sections
*
* **Marker Format:**
* - Tool calls: in constants/agentic.ts (AGENTIC_TAGS)
* - Reasoning: in constants/agentic.ts (REASONING_TAGS)
* - Partial markers handled gracefully during streaming
*
* **Execution States:**
* - **Streaming**: Animated spinner, block expanded, auto-scroll enabled
* - **Pending**: Waiting indicator for queued tool calls

View File

@ -15,8 +15,11 @@ export const DEFAULT_AGENTIC_CONFIG: AgenticConfig = {
maxToolPreviewLines: 25
} as const;
// Agentic tool call tag markers
export const AGENTIC_TAGS = {
/**
* @deprecated Legacy marker tags - only used for migration of old stored messages.
* New messages use structured fields (reasoningContent, toolCalls, toolCallId).
*/
export const LEGACY_AGENTIC_TAGS = {
TOOL_CALL_START: '<<<AGENTIC_TOOL_CALL_START>>>',
TOOL_CALL_END: '<<<AGENTIC_TOOL_CALL_END>>>',
TOOL_NAME_PREFIX: '<<<TOOL_NAME:',
@ -25,39 +28,25 @@ export const AGENTIC_TAGS = {
TAG_SUFFIX: '>>>'
} as const;
export const REASONING_TAGS = {
/**
* @deprecated Legacy reasoning tags - only used for migration of old stored messages.
* New messages use the dedicated reasoningContent field.
*/
export const LEGACY_REASONING_TAGS = {
START: '<<<reasoning_content_start>>>',
END: '<<<reasoning_content_end>>>'
} as const;
// Regex for trimming leading/trailing newlines
export const TRIM_NEWLINES_REGEX = /^\n+|\n+$/g;
// Regex patterns for parsing agentic content
export const AGENTIC_REGEX = {
// Matches completed tool calls (with END marker)
/**
* @deprecated Legacy regex patterns - only used for migration of old stored messages.
*/
export const LEGACY_AGENTIC_REGEX = {
COMPLETED_TOOL_CALL:
/<<<AGENTIC_TOOL_CALL_START>>>\n<<<TOOL_NAME:(.+?)>>>\n<<<TOOL_ARGS_START>>>([\s\S]*?)<<<TOOL_ARGS_END>>>([\s\S]*?)<<<AGENTIC_TOOL_CALL_END>>>/g,
// Matches pending tool call (has NAME and ARGS but no END)
PENDING_TOOL_CALL:
/<<<AGENTIC_TOOL_CALL_START>>>\n<<<TOOL_NAME:(.+?)>>>\n<<<TOOL_ARGS_START>>>([\s\S]*?)<<<TOOL_ARGS_END>>>([\s\S]*)$/,
// Matches partial tool call (has START and NAME, ARGS still streaming)
PARTIAL_WITH_NAME:
/<<<AGENTIC_TOOL_CALL_START>>>\n<<<TOOL_NAME:(.+?)>>>\n<<<TOOL_ARGS_START>>>([\s\S]*)$/,
// Matches early tool call (just START marker)
EARLY_MATCH: /<<<AGENTIC_TOOL_CALL_START>>>([\s\S]*)$/,
// Matches partial marker at end of content
PARTIAL_MARKER: /<<<[A-Za-z_]*$/,
// Matches reasoning content blocks (including tags)
REASONING_BLOCK: /<<<reasoning_content_start>>>[\s\S]*?<<<reasoning_content_end>>>/g,
// Captures the reasoning text between start/end tags
REASONING_EXTRACT: /<<<reasoning_content_start>>>([\s\S]*?)<<<reasoning_content_end>>>/,
// Matches an opening reasoning tag and any remaining content (unterminated)
REASONING_OPEN: /<<<reasoning_content_start>>>[\s\S]*$/,
// Matches a complete agentic tool call display block (start to end marker)
AGENTIC_TOOL_CALL_BLOCK: /\n*<<<AGENTIC_TOOL_CALL_START>>>[\s\S]*?<<<AGENTIC_TOOL_CALL_END>>>/g,
// Matches a pending/partial agentic tool call (start marker with no matching end)
AGENTIC_TOOL_CALL_OPEN: /\n*<<<AGENTIC_TOOL_CALL_START>>>[\s\S]*$/,
// Matches tool name inside content
TOOL_NAME_EXTRACT: /<<<TOOL_NAME:([^>]+)>>>/
HAS_LEGACY_MARKERS: /<<<(?:AGENTIC_TOOL_CALL_START|reasoning_content_start)>>>/
} as const;

View File

@ -1,6 +1,7 @@
import { getJsonHeaders, formatAttachmentText, isAbortError } from '$lib/utils';
import { getJsonHeaders } from '$lib/utils/api-headers';
import { formatAttachmentText } from '$lib/utils/formatters';
import { isAbortError } from '$lib/utils/abort';
import {
AGENTIC_REGEX,
ATTACHMENT_LABEL_PDF_FILE,
ATTACHMENT_LABEL_MCP_PROMPT,
ATTACHMENT_LABEL_MCP_RESOURCE
@ -17,38 +18,6 @@ import type { DatabaseMessageExtraMcpPrompt, DatabaseMessageExtraMcpResource } f
import { modelsStore } from '$lib/stores/models.svelte';
export class ChatService {
private static stripReasoningContent(
content: ApiChatMessageData['content'] | null | undefined
): ApiChatMessageData['content'] | null | undefined {
if (!content) {
return content;
}
if (typeof content === 'string') {
return content
.replace(AGENTIC_REGEX.REASONING_BLOCK, '')
.replace(AGENTIC_REGEX.REASONING_OPEN, '')
.replace(AGENTIC_REGEX.AGENTIC_TOOL_CALL_BLOCK, '')
.replace(AGENTIC_REGEX.AGENTIC_TOOL_CALL_OPEN, '');
}
if (!Array.isArray(content)) {
return content;
}
return content.map((part: ApiChatMessageContentPart) => {
if (part.type !== ContentPartType.TEXT || !part.text) return part;
return {
...part,
text: part.text
.replace(AGENTIC_REGEX.REASONING_BLOCK, '')
.replace(AGENTIC_REGEX.REASONING_OPEN, '')
.replace(AGENTIC_REGEX.AGENTIC_TOOL_CALL_BLOCK, '')
.replace(AGENTIC_REGEX.AGENTIC_TOOL_CALL_OPEN, '')
};
});
}
/**
*
*
@ -57,46 +26,6 @@ export class ChatService {
*
*/
/**
* Extracts reasoning text from content that contains internal reasoning tags.
* Returns the concatenated reasoning content or undefined if none found.
*/
private static extractReasoningFromContent(
content: ApiChatMessageData['content'] | null | undefined
): string | undefined {
if (!content) return undefined;
const extractFromString = (text: string): string => {
const parts: string[] = [];
// Use a fresh regex instance to avoid shared lastIndex state
const re = new RegExp(AGENTIC_REGEX.REASONING_EXTRACT.source);
let match = re.exec(text);
while (match) {
parts.push(match[1]);
// advance past the matched portion and retry
text = text.slice(match.index + match[0].length);
match = re.exec(text);
}
return parts.join('');
};
if (typeof content === 'string') {
const result = extractFromString(content);
return result || undefined;
}
if (!Array.isArray(content)) return undefined;
const parts: string[] = [];
for (const part of content) {
if (part.type === ContentPartType.TEXT && part.text) {
const result = extractFromString(part.text);
if (result) parts.push(result);
}
}
return parts.length > 0 ? parts.join('') : undefined;
}
/**
* Sends a chat completion request to the llama.cpp server.
* Supports both streaming and non-streaming responses with comprehensive parameter configuration.
@ -201,20 +130,15 @@ export class ChatService {
const requestBody: ApiChatCompletionRequest = {
messages: normalizedMessages.map((msg: ApiChatMessageData) => {
// Always strip internal reasoning/agentic tags from content
const cleanedContent = ChatService.stripReasoningContent(msg.content);
const mapped: ApiChatCompletionRequest['messages'][0] = {
role: msg.role,
content: cleanedContent,
content: msg.content,
tool_calls: msg.tool_calls,
tool_call_id: msg.tool_call_id
};
// When preserving reasoning, extract it from raw content and send as separate field
if (!excludeReasoningFromContext) {
const reasoning = ChatService.extractReasoningFromContent(msg.content);
if (reasoning) {
mapped.reasoning_content = reasoning;
}
// Include reasoning_content from the dedicated field
if (!excludeReasoningFromContext && msg.reasoning_content) {
mapped.reasoning_content = msg.reasoning_content;
}
return mapped;
}),
@ -726,6 +650,10 @@ export class ChatService {
content: message.content
};
if (message.reasoningContent) {
result.reasoning_content = message.reasoningContent;
}
if (toolCalls && toolCalls.length > 0) {
result.tool_calls = toolCalls;
}
@ -854,6 +782,9 @@ export class ChatService {
role: message.role as MessageRole,
content: contentParts
};
if (message.reasoningContent) {
result.reasoning_content = message.reasoningContent;
}
if (toolCalls && toolCalls.length > 0) {
result.tool_calls = toolCalls;
}

View File

@ -7,6 +7,10 @@
* - Session state management
* - Turn limit enforcement
*
* Each agentic turn produces separate DB messages:
* - One assistant message per LLM turn (with tool_calls if any)
* - One tool result message per tool call execution
*
* **Architecture & Relationships:**
* - **ChatService**: Stateless API layer (sendMessage, streaming)
* - **mcpStore**: MCP connection management and tool execution
@ -16,7 +20,6 @@
* @see mcpStore in stores/mcp.svelte.ts for MCP operations
*/
import { SvelteMap } from 'svelte/reactivity';
import { ChatService } from '$lib/services';
import { config } from '$lib/stores/settings.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
@ -24,7 +27,6 @@ import { modelsStore } from '$lib/stores/models.svelte';
import { isAbortError } from '$lib/utils';
import {
DEFAULT_AGENTIC_CONFIG,
AGENTIC_TAGS,
NEWLINE_SEPARATOR,
TURN_LIMIT_MESSAGE,
LLM_ERROR_BLOCK_START,
@ -193,17 +195,6 @@ class AgenticStore {
async runAgenticFlow(params: AgenticFlowParams): Promise<AgenticFlowResult> {
const { conversationId, messages, options = {}, callbacks, signal, perChatOverrides } = params;
const {
onChunk,
onReasoningChunk,
onToolCallChunk,
onAttachments,
onModel,
onComplete,
onError,
onTimings,
onTurnComplete
} = callbacks;
const agenticConfig = this.getConfig(config(), perChatOverrides);
if (!agenticConfig.enabled) return { handled: false };
@ -253,24 +244,14 @@ class AgenticStore {
options,
tools,
agenticConfig,
callbacks: {
onChunk,
onReasoningChunk,
onToolCallChunk,
onAttachments,
onModel,
onComplete,
onError,
onTimings,
onTurnComplete
},
callbacks,
signal
});
return { handled: true };
} catch (error) {
const normalizedError = error instanceof Error ? error : new Error(String(error));
this.updateSession(conversationId, { lastError: normalizedError });
onError?.(normalizedError);
callbacks.onError?.(normalizedError);
return { handled: true, error: normalizedError };
} finally {
this.updateSession(conversationId, { isRunning: false });
@ -295,17 +276,20 @@ class AgenticStore {
const {
onChunk,
onReasoningChunk,
onToolCallChunk,
onToolCallsStreaming,
onAttachments,
onModel,
onComplete,
onAssistantTurnComplete,
createToolResultMessage,
createAssistantMessage,
onFlowComplete,
onTimings,
onTurnComplete
} = callbacks;
const sessionMessages: AgenticMessage[] = toAgenticMessages(messages);
const allToolCalls: ApiChatCompletionToolCall[] = [];
let capturedTimings: ChatMessageTimings | undefined;
let totalToolCallCount = 0;
const agenticTimings: ChatMessageAgenticTimings = {
turns: 0,
@ -316,12 +300,7 @@ class AgenticStore {
llm: { predicted_n: 0, predicted_ms: 0, prompt_n: 0, prompt_ms: 0 }
};
const maxTurns = agenticConfig.maxTurns;
const maxToolPreviewLines = agenticConfig.maxToolPreviewLines;
// Resolve effective model for vision capability checks.
// In ROUTER mode, options.model is always set by the caller.
// In MODEL mode, options.model is undefined; use the single loaded model
// which carries modalities bridged from /props.
const effectiveModel = options.model || modelsStore.models[0]?.model || '';
for (let turn = 0; turn < maxTurns; turn++) {
@ -329,23 +308,20 @@ class AgenticStore {
agenticTimings.turns = turn + 1;
if (signal?.aborted) {
onComplete?.(
'',
undefined,
this.buildFinalTimings(capturedTimings, agenticTimings),
undefined
);
onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
return;
}
// For turns > 0, create a new assistant message via callback
if (turn > 0 && createAssistantMessage) {
await createAssistantMessage();
}
let turnContent = '';
let turnReasoningContent = '';
let turnToolCalls: ApiChatCompletionToolCall[] = [];
let lastStreamingToolCallName = '';
let lastStreamingToolCallArgsLength = 0;
const emittedToolCallStates = new SvelteMap<
number,
{ emittedOnce: boolean; lastArgs: string }
>();
let turnTimings: ChatMessageTimings | undefined;
const turnStats: ChatMessageAgenticTurnStats = {
@ -366,30 +342,15 @@ class AgenticStore {
turnContent += chunk;
onChunk?.(chunk);
},
onReasoningChunk,
onReasoningChunk: (chunk: string) => {
turnReasoningContent += chunk;
onReasoningChunk?.(chunk);
},
onToolCallChunk: (serialized: string) => {
try {
turnToolCalls = JSON.parse(serialized) as ApiChatCompletionToolCall[];
for (let i = 0; i < turnToolCalls.length; i++) {
const toolCall = turnToolCalls[i];
const toolName = toolCall.function?.name ?? '';
const toolArgs = toolCall.function?.arguments ?? '';
const state = emittedToolCallStates.get(i) || {
emittedOnce: false,
lastArgs: ''
};
if (!state.emittedOnce) {
const output = `\n\n${AGENTIC_TAGS.TOOL_CALL_START}\n${AGENTIC_TAGS.TOOL_NAME_PREFIX}${toolName}${AGENTIC_TAGS.TAG_SUFFIX}\n${AGENTIC_TAGS.TOOL_ARGS_START}\n${toolArgs}`;
onChunk?.(output);
state.emittedOnce = true;
state.lastArgs = toolArgs;
emittedToolCallStates.set(i, state);
} else if (toolArgs.length > state.lastArgs.length) {
onChunk?.(toolArgs.slice(state.lastArgs.length));
state.lastArgs = toolArgs;
emittedToolCallStates.set(i, state);
}
}
onToolCallsStreaming?.(turnToolCalls);
if (turnToolCalls.length > 0 && turnToolCalls[0]?.function) {
const name = turnToolCalls[0].function.name || '';
const args = turnToolCalls[0].function.arguments || '';
@ -442,77 +403,84 @@ class AgenticStore {
}
} catch (error) {
if (signal?.aborted) {
onComplete?.(
'',
undefined,
// Save whatever we have for this turn before exiting
await onAssistantTurnComplete?.(
turnContent,
turnReasoningContent || undefined,
this.buildFinalTimings(capturedTimings, agenticTimings),
undefined
);
onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
return;
}
const normalizedError = error instanceof Error ? error : new Error('LLM stream error');
// Save error as content in the current turn
onChunk?.(`${LLM_ERROR_BLOCK_START}${normalizedError.message}${LLM_ERROR_BLOCK_END}`);
onComplete?.(
'',
undefined,
await onAssistantTurnComplete?.(
turnContent + `${LLM_ERROR_BLOCK_START}${normalizedError.message}${LLM_ERROR_BLOCK_END}`,
turnReasoningContent || undefined,
this.buildFinalTimings(capturedTimings, agenticTimings),
undefined
);
onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
throw normalizedError;
}
// No tool calls = final turn, save and complete
if (turnToolCalls.length === 0) {
agenticTimings.perTurn!.push(turnStats);
onComplete?.(
'',
undefined,
this.buildFinalTimings(capturedTimings, agenticTimings),
const finalTimings = this.buildFinalTimings(capturedTimings, agenticTimings);
await onAssistantTurnComplete?.(
turnContent,
turnReasoningContent || undefined,
finalTimings,
undefined
);
if (finalTimings) onTurnComplete?.(finalTimings);
onFlowComplete?.(finalTimings);
return;
}
// Normalize and save assistant turn with tool calls
const normalizedCalls = this.normalizeToolCalls(turnToolCalls);
if (normalizedCalls.length === 0) {
onComplete?.(
'',
undefined,
await onAssistantTurnComplete?.(
turnContent,
turnReasoningContent || undefined,
this.buildFinalTimings(capturedTimings, agenticTimings),
undefined
);
onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
return;
}
for (const call of normalizedCalls) {
allToolCalls.push({
id: call.id,
type: call.type,
function: call.function ? { ...call.function } : undefined
});
}
totalToolCallCount += normalizedCalls.length;
this.updateSession(conversationId, { totalToolCalls: totalToolCallCount });
this.updateSession(conversationId, { totalToolCalls: allToolCalls.length });
onToolCallChunk?.(JSON.stringify(allToolCalls));
// Save the assistant message with its tool calls
await onAssistantTurnComplete?.(
turnContent,
turnReasoningContent || undefined,
turnTimings,
normalizedCalls
);
// Add assistant message to session history
sessionMessages.push({
role: MessageRole.ASSISTANT,
content: turnContent || undefined,
tool_calls: normalizedCalls
});
// Execute each tool call and create result messages
for (const toolCall of normalizedCalls) {
if (signal?.aborted) {
onComplete?.(
'',
undefined,
this.buildFinalTimings(capturedTimings, agenticTimings),
undefined
);
onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
return;
}
@ -530,13 +498,7 @@ class AgenticStore {
result = executionResult.content;
} catch (error) {
if (isAbortError(error)) {
onComplete?.(
'',
undefined,
this.buildFinalTimings(capturedTimings, agenticTimings),
undefined
);
onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
return;
}
result = `Error: ${error instanceof Error ? error.message : String(error)}`;
@ -557,21 +519,27 @@ class AgenticStore {
turnStats.toolsMs += Math.round(toolDurationMs);
if (signal?.aborted) {
onComplete?.(
'',
undefined,
this.buildFinalTimings(capturedTimings, agenticTimings),
undefined
);
onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
return;
}
const { cleanedResult, attachments } = this.extractBase64Attachments(result);
if (attachments.length > 0) onAttachments?.(attachments);
this.emitToolCallResult(cleanedResult, maxToolPreviewLines, onChunk);
// Create the tool result message in the DB
let toolResultMessage: DatabaseMessage | undefined;
if (createToolResultMessage) {
toolResultMessage = await createToolResultMessage(
toolCall.id,
cleanedResult,
attachments.length > 0 ? attachments : undefined
);
}
if (attachments.length > 0 && toolResultMessage) {
onAttachments?.(toolResultMessage.id, attachments);
}
// Build content parts for session history (including images for vision models)
const contentParts: ApiChatMessageContentPart[] = [
{ type: ContentPartType.TEXT, text: cleanedResult }
];
@ -605,8 +573,15 @@ class AgenticStore {
}
}
// Turn limit reached
onChunk?.(TURN_LIMIT_MESSAGE);
onComplete?.('', undefined, this.buildFinalTimings(capturedTimings, agenticTimings), undefined);
await onAssistantTurnComplete?.(
TURN_LIMIT_MESSAGE,
undefined,
this.buildFinalTimings(capturedTimings, agenticTimings),
undefined
);
onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
}
private buildFinalTimings(
@ -633,23 +608,6 @@ class AgenticStore {
}));
}
private emitToolCallResult(
result: string,
maxLines: number,
emit?: (chunk: string) => void
): void {
if (!emit) {
return;
}
let output = `${NEWLINE_SEPARATOR}${AGENTIC_TAGS.TOOL_ARGS_END}`;
const lines = result.split(NEWLINE_SEPARATOR);
const trimmedLines = lines.length > maxLines ? lines.slice(-maxLines) : lines;
output += `${NEWLINE_SEPARATOR}${trimmedLines.join(NEWLINE_SEPARATOR)}${NEWLINE_SEPARATOR}${AGENTIC_TAGS.TOOL_CALL_END}${NEWLINE_SEPARATOR}`;
emit(output);
}
private extractBase64Attachments(result: string): {
cleanedResult: string;
attachments: DatabaseMessageExtra[];

View File

@ -12,7 +12,8 @@
*/
import { SvelteMap } from 'svelte/reactivity';
import { DatabaseService, ChatService } from '$lib/services';
import { DatabaseService } from '$lib/services/database.service';
import { ChatService } from '$lib/services/chat.service';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { config } from '$lib/stores/settings.svelte';
import { agenticStore } from '$lib/stores/agentic.svelte';
@ -34,7 +35,6 @@ import {
import {
MAX_INACTIVE_CONVERSATION_STATES,
INACTIVE_CONVERSATION_STATE_MAX_AGE_MS,
REASONING_TAGS,
SYSTEM_MESSAGE_PLACEHOLDER
} from '$lib/constants';
import type {
@ -50,15 +50,6 @@ interface ConversationStateEntry {
lastAccessed: number;
}
const countOccurrences = (source: string, token: string): number =>
source ? source.split(token).length - 1 : 0;
const hasUnclosedReasoningTag = (content: string): boolean =>
countOccurrences(content, REASONING_TAGS.START) > countOccurrences(content, REASONING_TAGS.END);
const wrapReasoningContent = (content: string, reasoningContent?: string): string => {
if (!reasoningContent) return content;
return `${REASONING_TAGS.START}${reasoningContent}${REASONING_TAGS.END}${content}`;
};
class ChatStore {
activeProcessingState = $state<ApiProcessingState | null>(null);
currentResponse = $state('');
@ -557,83 +548,76 @@ class ChatStore {
await modelsStore.fetchModelProps(effectiveModel);
}
let streamedContent = '',
streamedToolCallContent = '',
isReasoningOpen = false,
hasStreamedChunks = false,
resolvedModel: string | null = null,
modelPersisted = false;
let streamedExtras: DatabaseMessageExtra[] = assistantMessage.extra
? JSON.parse(JSON.stringify(assistantMessage.extra))
: [];
// Mutable state for the current message being streamed
let currentMessageId = assistantMessage.id;
let streamedContent = '';
let streamedReasoningContent = '';
let resolvedModel: string | null = null;
let modelPersisted = false;
const convId = assistantMessage.convId;
const recordModel = (modelName: string | null | undefined, persistImmediately = true): void => {
if (!modelName) return;
const n = normalizeModelName(modelName);
if (!n || n === resolvedModel) return;
resolvedModel = n;
const idx = conversationsStore.findMessageIndex(assistantMessage.id);
const idx = conversationsStore.findMessageIndex(currentMessageId);
conversationsStore.updateMessageAtIndex(idx, { model: n });
if (persistImmediately && !modelPersisted) {
modelPersisted = true;
DatabaseService.updateMessage(assistantMessage.id, { model: n }).catch(() => {
DatabaseService.updateMessage(currentMessageId, { model: n }).catch(() => {
modelPersisted = false;
resolvedModel = null;
});
}
};
const updateStreamingContent = () => {
this.setChatStreaming(assistantMessage.convId, streamedContent, assistantMessage.id);
const idx = conversationsStore.findMessageIndex(assistantMessage.id);
const updateStreamingUI = () => {
this.setChatStreaming(convId, streamedContent, currentMessageId);
const idx = conversationsStore.findMessageIndex(currentMessageId);
conversationsStore.updateMessageAtIndex(idx, { content: streamedContent });
};
const appendContentChunk = (chunk: string) => {
if (isReasoningOpen) {
streamedContent += REASONING_TAGS.END;
isReasoningOpen = false;
}
streamedContent += chunk;
hasStreamedChunks = true;
updateStreamingContent();
};
const appendReasoningChunk = (chunk: string) => {
if (!isReasoningOpen) {
streamedContent += REASONING_TAGS.START;
isReasoningOpen = true;
}
streamedContent += chunk;
hasStreamedChunks = true;
updateStreamingContent();
};
const finalizeReasoning = () => {
if (isReasoningOpen) {
streamedContent += REASONING_TAGS.END;
isReasoningOpen = false;
}
const cleanupStreamingState = () => {
this.setStreamingActive(false);
this.setChatLoading(convId, false);
this.clearChatStreaming(convId);
this.setProcessingState(convId, null);
};
this.setStreamingActive(true);
this.setActiveProcessingConversation(assistantMessage.convId);
const abortController = this.getOrCreateAbortController(assistantMessage.convId);
this.setActiveProcessingConversation(convId);
const abortController = this.getOrCreateAbortController(convId);
const streamCallbacks: ChatStreamCallbacks = {
onChunk: (chunk: string) => appendContentChunk(chunk),
onReasoningChunk: (chunk: string) => appendReasoningChunk(chunk),
onToolCallChunk: (chunk: string) => {
const c = chunk.trim();
if (!c) return;
streamedToolCallContent = c;
const idx = conversationsStore.findMessageIndex(assistantMessage.id);
conversationsStore.updateMessageAtIndex(idx, { toolCalls: streamedToolCallContent });
onChunk: (chunk: string) => {
streamedContent += chunk;
updateStreamingUI();
},
onAttachments: (extras: DatabaseMessageExtra[]) => {
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: (messageId: string, extras: DatabaseMessageExtra[]) => {
if (!extras.length) return;
streamedExtras = [...streamedExtras, ...extras];
const idx = conversationsStore.findMessageIndex(assistantMessage.id);
conversationsStore.updateMessageAtIndex(idx, { extra: streamedExtras });
DatabaseService.updateMessage(assistantMessage.id, { extra: streamedExtras }).catch(
console.error
);
const idx = conversationsStore.findMessageIndex(messageId);
if (idx === -1) return;
const msg = conversationsStore.activeMessages[idx];
const updatedExtras = [...(msg.extra || []), ...extras];
conversationsStore.updateMessageAtIndex(idx, { extra: updatedExtras });
DatabaseService.updateMessage(messageId, { extra: updatedExtras }).catch(console.error);
},
onModel: (modelName: string) => recordModel(modelName),
onTurnComplete: (intermediateTimings: ChatMessageTimings) => {
// Update the first assistant message with cumulative agentic timings
const idx = conversationsStore.findMessageIndex(assistantMessage.id);
conversationsStore.updateMessageAtIndex(idx, { timings: intermediateTimings });
},
@ -651,56 +635,104 @@ class ChatStore {
cache_n: timings?.cache_n || 0,
prompt_progress: promptProgress
},
assistantMessage.convId
convId
);
},
onComplete: async (
finalContent?: string,
reasoningContent?: string,
timings?: ChatMessageTimings,
toolCallContent?: string
onAssistantTurnComplete: async (
content: string,
reasoningContent: string | undefined,
timings: ChatMessageTimings | undefined,
toolCalls: import('$lib/types/api').ApiChatCompletionToolCall[] | undefined
) => {
this.setStreamingActive(false);
finalizeReasoning();
const combinedContent = hasStreamedChunks
? streamedContent
: wrapReasoningContent(finalContent || '', reasoningContent);
const updateData: Record<string, unknown> = {
content: combinedContent,
toolCalls: toolCallContent || streamedToolCallContent,
content,
reasoningContent: reasoningContent || undefined,
toolCalls: toolCalls ? JSON.stringify(toolCalls) : '',
timings
};
if (streamedExtras.length > 0) updateData.extra = streamedExtras;
if (resolvedModel && !modelPersisted) updateData.model = resolvedModel;
await DatabaseService.updateMessage(assistantMessage.id, updateData);
const idx = conversationsStore.findMessageIndex(assistantMessage.id);
await DatabaseService.updateMessage(currentMessageId, updateData);
const idx = conversationsStore.findMessageIndex(currentMessageId);
const uiUpdate: Partial<DatabaseMessage> = {
content: combinedContent,
toolCalls: updateData.toolCalls as string
content,
reasoningContent: reasoningContent || undefined,
toolCalls: toolCalls ? JSON.stringify(toolCalls) : ''
};
if (streamedExtras.length > 0) uiUpdate.extra = streamedExtras;
if (timings) uiUpdate.timings = timings;
if (resolvedModel) uiUpdate.model = resolvedModel;
conversationsStore.updateMessageAtIndex(idx, uiUpdate);
await conversationsStore.updateCurrentNode(assistantMessage.id);
if (onComplete) await onComplete(combinedContent);
this.setChatLoading(assistantMessage.convId, false);
this.clearChatStreaming(assistantMessage.convId);
this.setProcessingState(assistantMessage.convId, null);
await conversationsStore.updateCurrentNode(currentMessageId);
},
createToolResultMessage: async (
toolCallId: string,
content: string,
extras?: DatabaseMessageExtra[]
) => {
const msg = await DatabaseService.createMessageBranch(
{
convId,
type: MessageType.TEXT,
role: MessageRole.TOOL,
content,
toolCallId,
timestamp: Date.now(),
toolCalls: '',
children: [],
extra: extras
},
currentMessageId
);
conversationsStore.addMessageToActive(msg);
await conversationsStore.updateCurrentNode(msg.id);
return msg;
},
createAssistantMessage: async () => {
// Reset streaming state for new message
streamedContent = '';
streamedReasoningContent = '';
const lastMsg =
conversationsStore.activeMessages[conversationsStore.activeMessages.length - 1];
const msg = await DatabaseService.createMessageBranch(
{
convId,
type: MessageType.TEXT,
role: MessageRole.ASSISTANT,
content: '',
timestamp: Date.now(),
toolCalls: '',
children: [],
model: resolvedModel
},
lastMsg.id
);
conversationsStore.addMessageToActive(msg);
currentMessageId = msg.id;
return msg;
},
onFlowComplete: (finalTimings?: ChatMessageTimings) => {
if (finalTimings) {
const idx = conversationsStore.findMessageIndex(assistantMessage.id);
conversationsStore.updateMessageAtIndex(idx, { timings: finalTimings });
DatabaseService.updateMessage(assistantMessage.id, { timings: finalTimings }).catch(
console.error
);
}
cleanupStreamingState();
if (onComplete) onComplete(streamedContent);
if (isRouterMode()) modelsStore.fetchRouterModels().catch(console.error);
},
onError: (error: Error) => {
this.setStreamingActive(false);
if (isAbortError(error)) {
this.setChatLoading(assistantMessage.convId, false);
this.clearChatStreaming(assistantMessage.convId);
this.setProcessingState(assistantMessage.convId, null);
cleanupStreamingState();
return;
}
console.error('Streaming error:', error);
this.setChatLoading(assistantMessage.convId, false);
this.clearChatStreaming(assistantMessage.convId);
this.setProcessingState(assistantMessage.convId, null);
cleanupStreamingState();
const idx = conversationsStore.findMessageIndex(assistantMessage.id);
if (idx !== -1) {
const failedMessage = conversationsStore.removeMessageAtIndex(idx);
@ -717,12 +749,13 @@ class ChatStore {
if (onError) onError(error);
}
};
const perChatOverrides = conversationsStore.activeConversation?.mcpServerOverrides;
const agenticConfig = agenticStore.getConfig(config(), perChatOverrides);
if (agenticConfig.enabled) {
const agenticResult = await agenticStore.runAgenticFlow({
conversationId: assistantMessage.convId,
conversationId: convId,
messages: allMessages,
options: { ...this.getApiOptions(), ...(effectiveModel ? { model: effectiveModel } : {}) },
callbacks: streamCallbacks,
@ -732,16 +765,50 @@ class ChatStore {
if (agenticResult.handled) return;
}
const completionOptions = {
...this.getApiOptions(),
...(effectiveModel ? { model: effectiveModel } : {}),
...streamCallbacks
};
// Non-agentic path: direct streaming into the single assistant message
await ChatService.sendMessage(
allMessages,
completionOptions,
assistantMessage.convId,
{
...this.getApiOptions(),
...(effectiveModel ? { model: effectiveModel } : {}),
stream: true,
onChunk: streamCallbacks.onChunk,
onReasoningChunk: streamCallbacks.onReasoningChunk,
onModel: streamCallbacks.onModel,
onTimings: streamCallbacks.onTimings,
onComplete: async (
finalContent?: string,
reasoningContent?: string,
timings?: ChatMessageTimings,
toolCalls?: string
) => {
const content = streamedContent || finalContent || '';
const reasoning = streamedReasoningContent || reasoningContent;
const updateData: Record<string, unknown> = {
content,
reasoningContent: reasoning || undefined,
toolCalls: toolCalls || '',
timings
};
if (resolvedModel && !modelPersisted) updateData.model = resolvedModel;
await DatabaseService.updateMessage(currentMessageId, updateData);
const idx = conversationsStore.findMessageIndex(currentMessageId);
const uiUpdate: Partial<DatabaseMessage> = {
content,
reasoningContent: reasoning || undefined,
toolCalls: toolCalls || ''
};
if (timings) uiUpdate.timings = timings;
if (resolvedModel) uiUpdate.model = resolvedModel;
conversationsStore.updateMessageAtIndex(idx, uiUpdate);
await conversationsStore.updateCurrentNode(currentMessageId);
cleanupStreamingState();
if (onComplete) await onComplete(content);
if (isRouterMode()) modelsStore.fetchRouterModels().catch(console.error);
},
onError: streamCallbacks.onError
},
convId,
abortController.signal
);
}
@ -1033,56 +1100,40 @@ class ChatStore {
}
const originalContent = dbMessage.content;
const originalReasoning = dbMessage.reasoningContent || '';
const conversationContext = conversationsStore.activeMessages.slice(0, idx);
const contextWithContinue = [
...conversationContext,
{ role: MessageRole.ASSISTANT as const, content: originalContent }
];
let appendedContent = '',
hasReceivedContent = false,
isReasoningOpen = hasUnclosedReasoningTag(originalContent);
let appendedContent = '';
let appendedReasoning = '';
let hasReceivedContent = false;
const updateStreamingContent = (fullContent: string) => {
this.setChatStreaming(msg.convId, fullContent, msg.id);
conversationsStore.updateMessageAtIndex(idx, { content: fullContent });
};
const appendContentChunk = (chunk: string) => {
if (isReasoningOpen) {
appendedContent += REASONING_TAGS.END;
isReasoningOpen = false;
}
appendedContent += chunk;
hasReceivedContent = true;
updateStreamingContent(originalContent + appendedContent);
};
const appendReasoningChunk = (chunk: string) => {
if (!isReasoningOpen) {
appendedContent += REASONING_TAGS.START;
isReasoningOpen = true;
}
appendedContent += chunk;
hasReceivedContent = true;
updateStreamingContent(originalContent + appendedContent);
};
const finalizeReasoning = () => {
if (isReasoningOpen) {
appendedContent += REASONING_TAGS.END;
isReasoningOpen = false;
}
};
const abortController = this.getOrCreateAbortController(msg.convId);
await ChatService.sendMessage(
contextWithContinue,
{
...this.getApiOptions(),
onChunk: (chunk: string) => appendContentChunk(chunk),
onReasoningChunk: (chunk: string) => appendReasoningChunk(chunk),
onChunk: (chunk: string) => {
appendedContent += chunk;
hasReceivedContent = true;
updateStreamingContent(originalContent + appendedContent);
},
onReasoningChunk: (chunk: string) => {
appendedReasoning += chunk;
hasReceivedContent = true;
conversationsStore.updateMessageAtIndex(idx, {
reasoningContent: originalReasoning + appendedReasoning
});
},
onTimings: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => {
const tokensPerSecond =
timings?.predicted_ms && timings?.predicted_n
@ -1105,21 +1156,23 @@ class ChatStore {
reasoningContent?: string,
timings?: ChatMessageTimings
) => {
finalizeReasoning();
const appendedFromCompletion = hasReceivedContent
? appendedContent
: wrapReasoningContent(finalContent || '', reasoningContent);
const fullContent = originalContent + appendedFromCompletion;
const finalAppendedContent = hasReceivedContent ? appendedContent : finalContent || '';
const finalAppendedReasoning = hasReceivedContent
? appendedReasoning
: reasoningContent || '';
const fullContent = originalContent + finalAppendedContent;
const fullReasoning = originalReasoning + finalAppendedReasoning || undefined;
await DatabaseService.updateMessage(msg.id, {
content: fullContent,
reasoningContent: fullReasoning,
timestamp: Date.now(),
timings
});
conversationsStore.updateMessageAtIndex(idx, {
content: fullContent,
reasoningContent: fullReasoning,
timestamp: Date.now(),
timings
});
@ -1135,11 +1188,13 @@ class ChatStore {
if (hasReceivedContent && appendedContent) {
await DatabaseService.updateMessage(msg.id, {
content: originalContent + appendedContent,
reasoningContent: originalReasoning + appendedReasoning || undefined,
timestamp: Date.now()
});
conversationsStore.updateMessageAtIndex(idx, {
content: originalContent + appendedContent,
reasoningContent: originalReasoning + appendedReasoning || undefined,
timestamp: Date.now()
});
}

View File

@ -23,7 +23,7 @@ import { browser } from '$app/environment';
import { toast } from 'svelte-sonner';
import { DatabaseService } from '$lib/services/database.service';
import { config } from '$lib/stores/settings.svelte';
import { filterByLeafNodeId, findLeafNode } from '$lib/utils';
import { filterByLeafNodeId, findLeafNode, runLegacyMigration } from '$lib/utils';
import type { McpServerOverride } from '$lib/types/database';
import { MessageRole } from '$lib/enums';
import {
@ -128,6 +128,10 @@ class ConversationsStore {
if (this.isInitialized) return;
try {
// @deprecated Legacy migration for old marker-based messages.
// Remove once all users have migrated to the structured format.
await runLegacyMigration();
await this.loadConversations();
this.isInitialized = true;
} catch (error) {

View File

@ -2,6 +2,7 @@ import type { MessageRole } from '$lib/enums';
import { ToolCallType } from '$lib/enums';
import type {
ApiChatCompletionRequest,
ApiChatCompletionToolCall,
ApiChatMessageContentPart,
ApiChatMessageData
} from './api';
@ -70,22 +71,48 @@ export interface AgenticSession {
}
/**
* Callbacks for agentic flow execution
* Callbacks for agentic flow execution.
*
* The agentic loop creates separate DB messages for each turn:
* - assistant messages (one per LLM turn, with tool_calls if any)
* - tool result messages (one per tool call execution)
*
* The first assistant message is created by the caller before starting the flow.
* Subsequent messages are created via createToolResultMessage / createAssistantMessage.
*/
export interface AgenticFlowCallbacks {
/** Content chunk for the current assistant message */
onChunk?: (chunk: string) => void;
/** Reasoning content chunk for the current assistant message */
onReasoningChunk?: (chunk: string) => void;
onToolCallChunk?: (serializedToolCalls: string) => void;
onAttachments?: (extras: DatabaseMessageExtra[]) => void;
/** Tool calls being streamed (partial, accumulating) for the current turn */
onToolCallsStreaming?: (toolCalls: ApiChatCompletionToolCall[]) => void;
/** Attachments extracted from tool results */
onAttachments?: (messageId: string, extras: DatabaseMessageExtra[]) => void;
/** Model name detected from response */
onModel?: (model: string) => void;
onComplete?: (
/** Current assistant turn's streaming is complete - save to DB */
onAssistantTurnComplete?: (
content: string,
reasoningContent?: string,
timings?: ChatMessageTimings,
toolCalls?: string
) => void;
reasoningContent: string | undefined,
timings: ChatMessageTimings | undefined,
toolCalls: ApiChatCompletionToolCall[] | undefined
) => Promise<void>;
/** Create a tool result message in the DB tree */
createToolResultMessage?: (
toolCallId: string,
content: string,
extras?: DatabaseMessageExtra[]
) => Promise<DatabaseMessage>;
/** Create a new assistant message for the next agentic turn */
createAssistantMessage?: () => Promise<DatabaseMessage>;
/** Entire agentic flow is complete */
onFlowComplete?: (timings?: ChatMessageTimings) => void;
/** Error during flow */
onError?: (error: Error) => void;
/** Timing updates during streaming */
onTimings?: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void;
/** An agentic turn (LLM + tool execution) completed - intermediate timing update */
onTurnComplete?: (intermediateTimings: ChatMessageTimings) => void;
}

View File

@ -1,5 +1,6 @@
import type { ErrorDialogType } from '$lib/enums';
import type { DatabaseMessageExtra } from './database';
import type { ApiChatCompletionToolCall } from './api';
import type { DatabaseMessage, DatabaseMessageExtra } from './database';
export interface ChatUploadedFile {
id: string;
@ -99,21 +100,28 @@ export interface ChatMessageToolCallTiming {
}
/**
* Callbacks for streaming chat responses
* Callbacks for streaming chat responses (used by both agentic and non-agentic paths)
*/
export interface ChatStreamCallbacks {
onChunk?: (chunk: string) => void;
onReasoningChunk?: (chunk: string) => void;
onToolCallChunk?: (chunk: string) => void;
onAttachments?: (extras: DatabaseMessageExtra[]) => void;
onToolCallsStreaming?: (toolCalls: ApiChatCompletionToolCall[]) => void;
onAttachments?: (messageId: string, extras: DatabaseMessageExtra[]) => void;
onModel?: (model: string) => void;
onTimings?: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void;
onComplete?: (
content?: string,
reasoningContent?: string,
timings?: ChatMessageTimings,
toolCallContent?: string
) => void;
onAssistantTurnComplete?: (
content: string,
reasoningContent: string | undefined,
timings: ChatMessageTimings | undefined,
toolCalls: ApiChatCompletionToolCall[] | undefined
) => Promise<void>;
createToolResultMessage?: (
toolCallId: string,
content: string,
extras?: DatabaseMessageExtra[]
) => Promise<DatabaseMessage>;
createAssistantMessage?: () => Promise<DatabaseMessage>;
onFlowComplete?: (timings?: ChatMessageTimings) => void;
onError?: (error: Error) => void;
onTurnComplete?: (intermediateTimings: ChatMessageTimings) => void;
}

View File

@ -92,6 +92,8 @@ export interface DatabaseMessage {
* @deprecated - left for backward compatibility
*/
thinking?: string;
/** Reasoning content produced by the model (separate from visible content) */
reasoningContent?: string;
/** Serialized JSON array of tool calls made by assistant messages */
toolCalls?: string;
/** Tool call ID for tool result messages (role: 'tool') */

View File

@ -1,8 +1,15 @@
import { AgenticSectionType } from '$lib/enums';
import { AGENTIC_TAGS, AGENTIC_REGEX, REASONING_TAGS, TRIM_NEWLINES_REGEX } from '$lib/constants';
import { AgenticSectionType, MessageRole } from '$lib/enums';
import { ATTACHMENT_SAVED_REGEX, NEWLINE_SEPARATOR } from '$lib/constants';
import type { ApiChatCompletionToolCall } from '$lib/types/api';
import type {
DatabaseMessage,
DatabaseMessageExtra,
DatabaseMessageExtraImageFile
} from '$lib/types/database';
import { AttachmentType } from '$lib/enums';
/**
* Represents a parsed section of agentic content
* Represents a parsed section of agentic content for display
*/
export interface AgenticSection {
type: AgenticSectionType;
@ -10,63 +17,70 @@ export interface AgenticSection {
toolName?: string;
toolArgs?: string;
toolResult?: string;
toolResultExtras?: DatabaseMessageExtra[];
}
/**
* Represents a segment of content that may contain reasoning blocks
* Represents a tool result line that may reference an image attachment
*/
type ReasoningSegment = {
type:
| AgenticSectionType.TEXT
| AgenticSectionType.REASONING
| AgenticSectionType.REASONING_PENDING;
content: string;
export type ToolResultLine = {
text: string;
image?: DatabaseMessageExtraImageFile;
};
/**
* Parses agentic content into structured sections
* Derives display sections from a single assistant message and its direct tool results.
*
* 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.
*
* @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
});
}
// 2. Text content
if (message.content?.trim()) {
sections.push({
type: AgenticSectionType.TEXT,
content: message.content
});
}
// 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: 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
});
}
// 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: tc.function?.name,
toolArgs: tc.function?.arguments
});
}
@ -74,211 +88,123 @@ export function parseAgenticContent(rawContent: string): AgenticSection[] {
}
/**
* Parses content containing tool call markers
* Derives display sections from structured message data.
*
* 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)
* Handles both single-turn (one assistant + its tool results) and multi-turn
* agentic sessions (multiple assistant + tool messages grouped together).
*
* @param rawContent - The raw content string to parse
* @returns Array of agentic sections representing tool calls and text
* 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 parseToolCallContent(rawContent: string): AgenticSection[] {
if (!rawContent) return [];
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);
}
const sections: AgenticSection[] = [];
const completedToolCallRegex = new RegExp(AGENTIC_REGEX.COMPLETED_TOOL_CALL.source, 'g');
const firstTurnToolMsgs = collectToolMessages(toolMessages, 0);
sections.push(...deriveSingleTurnSections(message, firstTurnToolMsgs));
let lastIndex = 0;
let match;
let i = firstTurnToolMsgs.length;
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 });
}
while (i < toolMessages.length) {
const msg = toolMessages[i];
if (msg.role === MessageRole.ASSISTANT) {
const turnToolMsgs = collectToolMessages(toolMessages, i + 1);
const isLastTurn = i + 1 + turnToolMsgs.length >= toolMessages.length;
sections.push(
...deriveSingleTurnSections(msg, turnToolMsgs, isLastTurn ? streamingToolCalls : [])
);
i += 1 + turnToolMsgs.length;
} else {
i++;
}
const toolName = match[1];
const toolArgs = match[2];
const toolResult = match[3].replace(TRIM_NEWLINES_REGEX, '');
sections.push({
type: AgenticSectionType.TOOL_CALL,
content: toolResult,
toolName,
toolArgs,
toolResult
});
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] || '';
sections.push({
type: AgenticSectionType.TOOL_CALL_STREAMING,
content: '',
toolName: partialWithNameMatch[1],
toolArgs: partialArgs || undefined,
toolResult: undefined
});
} 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);
sections.push({
type: AgenticSectionType.TOOL_CALL_STREAMING,
content: '',
toolName: nameMatch?.[1],
toolArgs: undefined,
toolResult: undefined
});
} 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
*
* Removes incomplete agentic markers (e.g., "<<<", "<<<AGENTIC") that may appear
* at the end of streaming content.
*
* @param text - The text content to process
* @returns Text with partial markers removed
* Collect consecutive tool messages starting at `startIndex`.
*/
function stripPartialMarker(text: string): string {
const partialMarkerMatch = text.match(AGENTIC_REGEX.PARTIAL_MARKER);
function collectToolMessages(messages: DatabaseMessage[], startIndex: number): DatabaseMessage[] {
const result: DatabaseMessage[] = [];
if (partialMarkerMatch) {
return text.slice(0, partialMarkerMatch.index).trim();
for (let i = startIndex; i < messages.length; i++) {
if (messages[i].role === MessageRole.TOOL) {
result.push(messages[i]);
} else {
break;
}
}
return text;
return result;
}
/**
* 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
* Parse tool result text into lines, matching image attachments by name.
*/
function splitReasoningSegments(rawContent: string): ReasoningSegment[] {
if (!rawContent) return [];
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 segments: ReasoningSegment[] = [];
let cursor = 0;
const attachmentName = match[1];
const image = extras.find(
(e): e is DatabaseMessageExtraImageFile =>
e.type === AttachmentType.IMAGE && e.name === attachmentName
);
while (cursor < rawContent.length) {
const startIndex = rawContent.indexOf(REASONING_TAGS.START, cursor);
return { text: line, image };
});
}
if (startIndex === -1) {
const remainingText = rawContent.slice(cursor);
/**
* Safely parse the toolCalls JSON string from a DatabaseMessage.
*/
function parseToolCalls(toolCallsJson?: string): ApiChatCompletionToolCall[] {
if (!toolCallsJson) return [];
if (remainingText) {
segments.push({ type: AgenticSectionType.TEXT, content: remainingText });
}
try {
const parsed = JSON.parse(toolCallsJson);
break;
}
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
if (startIndex > cursor) {
const textBefore = rawContent.slice(cursor, startIndex);
/**
* 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 (textBefore) {
segments.push({ type: AgenticSectionType.TEXT, content: textBefore });
}
}
const contentStart = startIndex + REASONING_TAGS.START.length;
const endIndex = rawContent.indexOf(REASONING_TAGS.END, contentStart);
if (endIndex === -1) {
const pendingContent = rawContent.slice(contentStart);
segments.push({
type: AgenticSectionType.REASONING_PENDING,
content: stripPartialMarker(pendingContent)
});
break;
}
const reasoningContent = rawContent.slice(contentStart, endIndex);
segments.push({ type: AgenticSectionType.REASONING, content: reasoningContent });
cursor = endIndex + REASONING_TAGS.END.length;
if (tc.length > 0) return true;
}
return segments;
return toolMessages.length > 0;
}

View File

@ -149,8 +149,17 @@ export { parseHeadersToArray, serializeHeaders } from './headers';
// Favicon utilities
export { getFaviconUrl } from './favicon';
// Agentic content parsing utilities
export { parseAgenticContent, type AgenticSection } from './agentic';
// Agentic content utilities (structured section derivation)
export {
deriveAgenticSections,
parseToolResultWithImages,
hasAgenticContent,
type AgenticSection,
type ToolResultLine
} from './agentic';
// Legacy migration utilities
export { runLegacyMigration, isMigrationNeeded } from './legacy-migration';
// Cache utilities
export { TTLCache, ReactiveTTLMap, type TTLCacheOptions } from './cache-ttl';

View File

@ -0,0 +1,345 @@
/**
* @deprecated Legacy migration utility remove at some point in the future once all users have migrated to the new structured agentic message format.
*
* Converts old marker-based agentic messages to the new structured format
* with separate messages per turn.
*
* Old format: Single assistant message with markers in content:
* <<<reasoning_content_start>>>...<<<reasoning_content_end>>>
* <<<AGENTIC_TOOL_CALL_START>>>...<<<AGENTIC_TOOL_CALL_END>>>
*
* New format: Separate messages per turn:
* - assistant (content + reasoningContent + toolCalls)
* - tool (toolCallId + content)
* - assistant (next turn)
* - ...
*/
import { LEGACY_AGENTIC_REGEX, LEGACY_REASONING_TAGS } from '$lib/constants';
import { DatabaseService } from '$lib/services/database.service';
import { MessageRole, MessageType } from '$lib/enums';
import type { DatabaseMessage } from '$lib/types/database';
const MIGRATION_DONE_KEY = 'llama-webui-migration-v2-done';
/**
* @deprecated Part of legacy migration remove with the migration module.
* Check if migration has been performed.
*/
export function isMigrationNeeded(): boolean {
try {
return !localStorage.getItem(MIGRATION_DONE_KEY);
} catch {
return false;
}
}
/**
* Mark migration as done.
*/
function markMigrationDone(): void {
try {
localStorage.setItem(MIGRATION_DONE_KEY, String(Date.now()));
} catch {
// Ignore localStorage errors
}
}
/**
* Check if a message has legacy markers in its content.
*/
function hasLegacyMarkers(message: DatabaseMessage): boolean {
if (!message.content) return false;
return LEGACY_AGENTIC_REGEX.HAS_LEGACY_MARKERS.test(message.content);
}
/**
* Extract reasoning content from legacy marker format.
*/
function extractLegacyReasoning(content: string): { reasoning: string; cleanContent: string } {
let reasoning = '';
let cleanContent = content;
// Extract all reasoning blocks
const re = new RegExp(LEGACY_AGENTIC_REGEX.REASONING_EXTRACT.source, 'g');
let match;
while ((match = re.exec(content)) !== null) {
reasoning += match[1];
}
// Remove reasoning tags from content
cleanContent = cleanContent
.replace(new RegExp(LEGACY_AGENTIC_REGEX.REASONING_BLOCK.source, 'g'), '')
.replace(LEGACY_AGENTIC_REGEX.REASONING_OPEN, '');
return { reasoning, cleanContent };
}
/**
* Parse legacy content with tool call markers into structured turns.
*/
interface ParsedTurn {
textBefore: string;
toolCalls: Array<{
name: string;
args: string;
result: string;
}>;
}
function parseLegacyToolCalls(content: string): ParsedTurn[] {
const turns: ParsedTurn[] = [];
const regex = new RegExp(LEGACY_AGENTIC_REGEX.COMPLETED_TOOL_CALL.source, 'g');
let lastIndex = 0;
let currentTurn: ParsedTurn = { textBefore: '', toolCalls: [] };
let match;
while ((match = regex.exec(content)) !== null) {
const textBefore = content.slice(lastIndex, match.index).trim();
// If there's text between tool calls and we already have tool calls,
// that means a new turn started (text after tool results = new LLM turn)
if (textBefore && currentTurn.toolCalls.length > 0) {
turns.push(currentTurn);
currentTurn = { textBefore, toolCalls: [] };
} else if (textBefore && currentTurn.toolCalls.length === 0) {
currentTurn.textBefore = textBefore;
}
currentTurn.toolCalls.push({
name: match[1],
args: match[2],
result: match[3].replace(/^\n+|\n+$/g, '')
});
lastIndex = match.index + match[0].length;
}
// Any remaining text after the last tool call
const remainingText = content.slice(lastIndex).trim();
if (currentTurn.toolCalls.length > 0) {
turns.push(currentTurn);
}
// If there's text after all tool calls, it's the final assistant response
if (remainingText) {
// Remove any partial/open markers
const cleanRemaining = remainingText
.replace(LEGACY_AGENTIC_REGEX.AGENTIC_TOOL_CALL_OPEN, '')
.trim();
if (cleanRemaining) {
turns.push({ textBefore: cleanRemaining, toolCalls: [] });
}
}
// If no tool calls found at all, return the original content as a single turn
if (turns.length === 0) {
turns.push({ textBefore: content.trim(), toolCalls: [] });
}
return turns;
}
/**
* Migrate a single conversation's messages from legacy format to new format.
*/
async function migrateConversation(convId: string): Promise<number> {
const allMessages = await DatabaseService.getConversationMessages(convId);
let migratedCount = 0;
for (const message of allMessages) {
if (message.role !== MessageRole.ASSISTANT) continue;
if (!hasLegacyMarkers(message)) {
// Still check for reasoning-only markers (no tool calls)
if (message.content?.includes(LEGACY_REASONING_TAGS.START)) {
const { reasoning, cleanContent } = extractLegacyReasoning(message.content);
await DatabaseService.updateMessage(message.id, {
content: cleanContent.trim(),
reasoningContent: reasoning || undefined
});
migratedCount++;
}
continue;
}
// Has agentic markers - full migration needed
const { reasoning, cleanContent } = extractLegacyReasoning(message.content);
const turns = parseLegacyToolCalls(cleanContent);
// Parse existing toolCalls JSON to try to match IDs
let existingToolCalls: Array<{ id: string; function?: { name: string; arguments: string } }> =
[];
if (message.toolCalls) {
try {
existingToolCalls = JSON.parse(message.toolCalls);
} catch {
// Ignore
}
}
// First turn uses the existing message
const firstTurn = turns[0];
if (!firstTurn) continue;
// Match tool calls from the first turn to existing IDs
const firstTurnToolCalls = firstTurn.toolCalls.map((tc, i) => {
const existing =
existingToolCalls.find((e) => e.function?.name === tc.name) || existingToolCalls[i];
return {
id: existing?.id || `legacy_tool_${i}`,
type: 'function' as const,
function: { name: tc.name, arguments: tc.args }
};
});
// Update the existing message for the first turn
await DatabaseService.updateMessage(message.id, {
content: firstTurn.textBefore,
reasoningContent: reasoning || undefined,
toolCalls: firstTurnToolCalls.length > 0 ? JSON.stringify(firstTurnToolCalls) : ''
});
let currentParentId = message.id;
let toolCallIdCounter = existingToolCalls.length;
// Create tool result messages for the first turn
for (let i = 0; i < firstTurn.toolCalls.length; i++) {
const tc = firstTurn.toolCalls[i];
const toolCallId = firstTurnToolCalls[i]?.id || `legacy_tool_${i}`;
const toolMsg = await DatabaseService.createMessageBranch(
{
convId,
type: MessageType.TEXT,
role: MessageRole.TOOL,
content: tc.result,
toolCallId,
timestamp: message.timestamp + i + 1,
toolCalls: '',
children: []
},
currentParentId
);
currentParentId = toolMsg.id;
}
// Create messages for subsequent turns
for (let turnIdx = 1; turnIdx < turns.length; turnIdx++) {
const turn = turns[turnIdx];
const turnToolCalls = turn.toolCalls.map((tc, i) => {
const idx = toolCallIdCounter + i;
const existing = existingToolCalls[idx];
return {
id: existing?.id || `legacy_tool_${idx}`,
type: 'function' as const,
function: { name: tc.name, arguments: tc.args }
};
});
toolCallIdCounter += turn.toolCalls.length;
// Create assistant message for this turn
const assistantMsg = await DatabaseService.createMessageBranch(
{
convId,
type: MessageType.TEXT,
role: MessageRole.ASSISTANT,
content: turn.textBefore,
timestamp: message.timestamp + turnIdx * 100,
toolCalls: turnToolCalls.length > 0 ? JSON.stringify(turnToolCalls) : '',
children: [],
model: message.model
},
currentParentId
);
currentParentId = assistantMsg.id;
// Create tool result messages for this turn
for (let i = 0; i < turn.toolCalls.length; i++) {
const tc = turn.toolCalls[i];
const toolCallId = turnToolCalls[i]?.id || `legacy_tool_${toolCallIdCounter + i}`;
const toolMsg = await DatabaseService.createMessageBranch(
{
convId,
type: MessageType.TEXT,
role: MessageRole.TOOL,
content: tc.result,
toolCallId,
timestamp: message.timestamp + turnIdx * 100 + i + 1,
toolCalls: '',
children: []
},
currentParentId
);
currentParentId = toolMsg.id;
}
}
// Re-parent any children of the original message to the last created message
// (the original message's children list was the next user message or similar)
if (message.children.length > 0 && currentParentId !== message.id) {
for (const childId of message.children) {
// Skip children we just created (they were already properly parented)
const child = allMessages.find((m) => m.id === childId);
if (!child) continue;
// Only re-parent non-tool messages that were original children
if (child.role !== MessageRole.TOOL) {
await DatabaseService.updateMessage(childId, { parent: currentParentId });
// Add to new parent's children
const newParent = await DatabaseService.getConversationMessages(convId).then((msgs) =>
msgs.find((m) => m.id === currentParentId)
);
if (newParent && !newParent.children.includes(childId)) {
await DatabaseService.updateMessage(currentParentId, {
children: [...newParent.children, childId]
});
}
}
}
// Clear re-parented children from the original message
await DatabaseService.updateMessage(message.id, { children: [] });
}
migratedCount++;
}
return migratedCount;
}
/**
* @deprecated Part of legacy migration remove with the migration module.
* Run the full migration across all conversations.
* This should be called once at app startup if migration is needed.
*/
export async function runLegacyMigration(): Promise<void> {
if (!isMigrationNeeded()) return;
console.log('[Migration] Starting legacy message format migration...');
try {
const conversations = await DatabaseService.getAllConversations();
let totalMigrated = 0;
for (const conv of conversations) {
const count = await migrateConversation(conv.id);
totalMigrated += count;
}
if (totalMigrated > 0) {
console.log(
`[Migration] Migrated ${totalMigrated} messages across ${conversations.length} conversations`
);
} else {
console.log('[Migration] No legacy messages found, marking as done');
}
markMigrationDone();
} catch (error) {
console.error('[Migration] Failed to migrate legacy messages:', error);
// Still mark as done to avoid infinite retry loops
markMigrationDone();
}
}

View File

@ -0,0 +1,211 @@
import { describe, it, expect } from 'vitest';
import { deriveAgenticSections, hasAgenticContent } from '$lib/utils/agentic';
import { AgenticSectionType, MessageRole } from '$lib/enums';
import type { DatabaseMessage } from '$lib/types/database';
import type { ApiChatCompletionToolCall } from '$lib/types/api';
function makeAssistant(overrides: Partial<DatabaseMessage> = {}): DatabaseMessage {
return {
id: overrides.id ?? 'ast-1',
convId: 'conv-1',
type: 'text',
timestamp: Date.now(),
role: MessageRole.ASSISTANT,
content: overrides.content ?? '',
parent: null,
children: [],
...overrides
} as DatabaseMessage;
}
function makeToolMsg(overrides: Partial<DatabaseMessage> = {}): DatabaseMessage {
return {
id: overrides.id ?? 'tool-1',
convId: 'conv-1',
type: 'text',
timestamp: Date.now(),
role: MessageRole.TOOL,
content: overrides.content ?? 'tool result',
parent: null,
children: [],
toolCallId: overrides.toolCallId ?? 'call_1',
...overrides
} as DatabaseMessage;
}
describe('deriveAgenticSections', () => {
it('returns empty array for assistant with no content', () => {
const msg = makeAssistant({ content: '' });
const sections = deriveAgenticSections(msg);
expect(sections).toEqual([]);
});
it('returns text section for simple assistant message', () => {
const msg = makeAssistant({ content: 'Hello world' });
const sections = deriveAgenticSections(msg);
expect(sections).toHaveLength(1);
expect(sections[0].type).toBe(AgenticSectionType.TEXT);
expect(sections[0].content).toBe('Hello world');
});
it('returns reasoning + text for message with reasoning', () => {
const msg = makeAssistant({
content: 'Answer is 4.',
reasoningContent: 'Let me think...'
});
const sections = deriveAgenticSections(msg);
expect(sections).toHaveLength(2);
expect(sections[0].type).toBe(AgenticSectionType.REASONING);
expect(sections[0].content).toBe('Let me think...');
expect(sections[1].type).toBe(AgenticSectionType.TEXT);
});
it('single turn: assistant with tool calls and results', () => {
const msg = makeAssistant({
content: 'Let me check.',
toolCalls: JSON.stringify([
{ id: 'call_1', type: 'function', function: { name: 'search', arguments: '{"q":"test"}' } }
])
});
const toolResult = makeToolMsg({
toolCallId: 'call_1',
content: 'Found 3 results'
});
const sections = deriveAgenticSections(msg, [toolResult]);
expect(sections).toHaveLength(2);
expect(sections[0].type).toBe(AgenticSectionType.TEXT);
expect(sections[1].type).toBe(AgenticSectionType.TOOL_CALL);
expect(sections[1].toolName).toBe('search');
expect(sections[1].toolResult).toBe('Found 3 results');
});
it('single turn: pending tool call without result', () => {
const msg = makeAssistant({
toolCalls: JSON.stringify([
{ id: 'call_1', type: 'function', function: { name: 'bash', arguments: '{}' } }
])
});
const sections = deriveAgenticSections(msg, []);
expect(sections).toHaveLength(1);
expect(sections[0].type).toBe(AgenticSectionType.TOOL_CALL_PENDING);
expect(sections[0].toolName).toBe('bash');
});
it('multi-turn: two assistant turns grouped as one session', () => {
const assistant1 = makeAssistant({
id: 'ast-1',
content: 'Turn 1 text',
toolCalls: JSON.stringify([
{ id: 'call_1', type: 'function', function: { name: 'search', arguments: '{"q":"foo"}' } }
])
});
const tool1 = makeToolMsg({ id: 'tool-1', toolCallId: 'call_1', content: 'result 1' });
const assistant2 = makeAssistant({
id: 'ast-2',
content: 'Final answer based on results.'
});
// toolMessages contains both tool result and continuation assistant
const sections = deriveAgenticSections(assistant1, [tool1, assistant2]);
expect(sections).toHaveLength(3);
// Turn 1
expect(sections[0].type).toBe(AgenticSectionType.TEXT);
expect(sections[0].content).toBe('Turn 1 text');
expect(sections[1].type).toBe(AgenticSectionType.TOOL_CALL);
expect(sections[1].toolName).toBe('search');
expect(sections[1].toolResult).toBe('result 1');
// Turn 2 (final)
expect(sections[2].type).toBe(AgenticSectionType.TEXT);
expect(sections[2].content).toBe('Final answer based on results.');
});
it('multi-turn: three turns with tool calls', () => {
const assistant1 = makeAssistant({
id: 'ast-1',
content: '',
toolCalls: JSON.stringify([
{ id: 'call_1', type: 'function', function: { name: 'list_files', arguments: '{}' } }
])
});
const tool1 = makeToolMsg({ id: 'tool-1', toolCallId: 'call_1', content: 'file1 file2' });
const assistant2 = makeAssistant({
id: 'ast-2',
content: 'Reading file1...',
toolCalls: JSON.stringify([
{
id: 'call_2',
type: 'function',
function: { name: 'read_file', arguments: '{"path":"file1"}' }
}
])
});
const tool2 = makeToolMsg({ id: 'tool-2', toolCallId: 'call_2', content: 'contents of file1' });
const assistant3 = makeAssistant({
id: 'ast-3',
content: 'Here is the analysis.',
reasoningContent: 'The file contains...'
});
const sections = deriveAgenticSections(assistant1, [tool1, assistant2, tool2, assistant3]);
// Turn 1: tool_call (no text since content is empty)
// Turn 2: text + tool_call
// Turn 3: reasoning + text
expect(sections).toHaveLength(5);
expect(sections[0].type).toBe(AgenticSectionType.TOOL_CALL);
expect(sections[0].toolName).toBe('list_files');
expect(sections[1].type).toBe(AgenticSectionType.TEXT);
expect(sections[1].content).toBe('Reading file1...');
expect(sections[2].type).toBe(AgenticSectionType.TOOL_CALL);
expect(sections[2].toolName).toBe('read_file');
expect(sections[3].type).toBe(AgenticSectionType.REASONING);
expect(sections[4].type).toBe(AgenticSectionType.TEXT);
expect(sections[4].content).toBe('Here is the analysis.');
});
it('multi-turn: streaming tool calls on last turn', () => {
const assistant1 = makeAssistant({
toolCalls: JSON.stringify([
{ id: 'call_1', type: 'function', function: { name: 'search', arguments: '{}' } }
])
});
const tool1 = makeToolMsg({ toolCallId: 'call_1', content: 'result' });
const assistant2 = makeAssistant({ id: 'ast-2', content: '' });
const streamingToolCalls: ApiChatCompletionToolCall[] = [
{ id: 'call_2', type: 'function', function: { name: 'write_file', arguments: '{"pa' } }
];
const sections = deriveAgenticSections(assistant1, [tool1, assistant2], streamingToolCalls);
// Turn 1: tool_call
// Turn 2 (streaming): streaming tool call
expect(sections.some((s) => s.type === AgenticSectionType.TOOL_CALL)).toBe(true);
expect(sections.some((s) => s.type === AgenticSectionType.TOOL_CALL_STREAMING)).toBe(true);
});
});
describe('hasAgenticContent', () => {
it('returns false for plain assistant', () => {
const msg = makeAssistant({ content: 'Just text' });
expect(hasAgenticContent(msg)).toBe(false);
});
it('returns true when message has toolCalls', () => {
const msg = makeAssistant({
toolCalls: JSON.stringify([
{ id: 'call_1', type: 'function', function: { name: 'test', arguments: '{}' } }
])
});
expect(hasAgenticContent(msg)).toBe(true);
});
it('returns true when toolMessages are provided', () => {
const msg = makeAssistant();
const tool = makeToolMsg();
expect(hasAgenticContent(msg, [tool])).toBe(true);
});
it('returns false for empty toolCalls JSON', () => {
const msg = makeAssistant({ toolCalls: '[]' });
expect(hasAgenticContent(msg)).toBe(false);
});
});

View File

@ -1,17 +1,22 @@
import { describe, it, expect } from 'vitest';
import { AGENTIC_REGEX } from '$lib/constants/agentic';
import { LEGACY_AGENTIC_REGEX } from '$lib/constants/agentic';
// Mirror the logic in ChatService.stripReasoningContent so we can test it in isolation.
// The real function is private static, so we replicate the strip pipeline here.
function stripContextMarkers(content: string): string {
/**
* Tests for legacy marker stripping (used in migration).
* The new system does not embed markers in content - these tests verify
* the legacy regex patterns still work for the migration code.
*/
// Mirror the legacy stripping logic used during migration
function stripLegacyContextMarkers(content: string): string {
return content
.replace(AGENTIC_REGEX.REASONING_BLOCK, '')
.replace(AGENTIC_REGEX.REASONING_OPEN, '')
.replace(AGENTIC_REGEX.AGENTIC_TOOL_CALL_BLOCK, '')
.replace(AGENTIC_REGEX.AGENTIC_TOOL_CALL_OPEN, '');
.replace(new RegExp(LEGACY_AGENTIC_REGEX.REASONING_BLOCK.source, 'g'), '')
.replace(LEGACY_AGENTIC_REGEX.REASONING_OPEN, '')
.replace(new RegExp(LEGACY_AGENTIC_REGEX.AGENTIC_TOOL_CALL_BLOCK.source, 'g'), '')
.replace(LEGACY_AGENTIC_REGEX.AGENTIC_TOOL_CALL_OPEN, '');
}
// A realistic complete tool call block as stored in message.content after a turn.
// A realistic complete tool call block as stored in old message.content
const COMPLETE_BLOCK =
'\n\n<<<AGENTIC_TOOL_CALL_START>>>\n' +
'<<<TOOL_NAME:bash_tool>>>\n' +
@ -30,11 +35,10 @@ const OPEN_BLOCK =
'<<<TOOL_ARGS_END>>>\n' +
'partial output...';
describe('agentic marker stripping for context', () => {
describe('legacy agentic marker stripping (for migration)', () => {
it('strips a complete tool call block, leaving surrounding text', () => {
const input = 'Before.' + COMPLETE_BLOCK + 'After.';
const result = stripContextMarkers(input);
// markers gone; residual newlines between fragments are fine
const result = stripLegacyContextMarkers(input);
expect(result).not.toContain('<<<');
expect(result).toContain('Before.');
expect(result).toContain('After.');
@ -42,7 +46,7 @@ describe('agentic marker stripping for context', () => {
it('strips multiple complete tool call blocks', () => {
const input = 'A' + COMPLETE_BLOCK + 'B' + COMPLETE_BLOCK + 'C';
const result = stripContextMarkers(input);
const result = stripLegacyContextMarkers(input);
expect(result).not.toContain('<<<');
expect(result).toContain('A');
expect(result).toContain('B');
@ -51,19 +55,19 @@ describe('agentic marker stripping for context', () => {
it('strips an open/partial tool call block (no END marker)', () => {
const input = 'Lead text.' + OPEN_BLOCK;
const result = stripContextMarkers(input);
const result = stripLegacyContextMarkers(input);
expect(result).toBe('Lead text.');
expect(result).not.toContain('<<<');
});
it('does not alter content with no markers', () => {
const input = 'Just a normal assistant response.';
expect(stripContextMarkers(input)).toBe(input);
expect(stripLegacyContextMarkers(input)).toBe(input);
});
it('strips reasoning block independently', () => {
const input = '<<<reasoning_content_start>>>think hard<<<reasoning_content_end>>>Answer.';
expect(stripContextMarkers(input)).toBe('Answer.');
expect(stripLegacyContextMarkers(input)).toBe('Answer.');
});
it('strips both reasoning and agentic blocks together', () => {
@ -71,11 +75,21 @@ describe('agentic marker stripping for context', () => {
'<<<reasoning_content_start>>>plan<<<reasoning_content_end>>>' +
'Some text.' +
COMPLETE_BLOCK;
expect(stripContextMarkers(input)).not.toContain('<<<');
expect(stripContextMarkers(input)).toContain('Some text.');
expect(stripLegacyContextMarkers(input)).not.toContain('<<<');
expect(stripLegacyContextMarkers(input)).toContain('Some text.');
});
it('empty string survives', () => {
expect(stripContextMarkers('')).toBe('');
expect(stripLegacyContextMarkers('')).toBe('');
});
it('detects legacy markers', () => {
expect(LEGACY_AGENTIC_REGEX.HAS_LEGACY_MARKERS.test('normal text')).toBe(false);
expect(
LEGACY_AGENTIC_REGEX.HAS_LEGACY_MARKERS.test('text<<<AGENTIC_TOOL_CALL_START>>>more')
).toBe(true);
expect(LEGACY_AGENTIC_REGEX.HAS_LEGACY_MARKERS.test('<<<reasoning_content_start>>>think')).toBe(
true
);
});
});

View File

@ -1,196 +1,89 @@
import { describe, it, expect } from 'vitest';
import { AGENTIC_REGEX, REASONING_TAGS } from '$lib/constants/agentic';
import { ContentPartType } from '$lib/enums';
import { MessageRole } 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;
/**
* 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.
*/
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, '')
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;
// Content should be clean
expect(message.content).not.toContain('<<<');
expect(message.content).toBe('The answer is 4.');
// Reasoning in dedicated field
expect(message.reasoningContent).toBe('Let me think: 2+2=4, basic arithmetic.');
});
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.'
};
const apiMessage: Record<string, unknown> = {
role: dbMessage.role,
content: dbMessage.content
};
if (dbMessage.reasoningContent) {
apiMessage.reasoning_content = dbMessage.reasoningContent;
}
}
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');
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('<<<');
});
it('returns undefined when no reasoning tags present', () => {
expect(extractReasoningFromContent('Just a normal response.')).toBeUndefined();
it('API message excludes reasoning when excludeReasoningFromContext is true', () => {
const dbMessage = {
role: MessageRole.ASSISTANT,
content: 'The answer is 4.',
reasoningContent: 'internal thinking'
};
const excludeReasoningFromContext = true;
const apiMessage: Record<string, unknown> = {
role: dbMessage.role,
content: dbMessage.content
};
if (!excludeReasoningFromContext && dbMessage.reasoningContent) {
apiMessage.reasoning_content = dbMessage.reasoningContent;
}
expect(apiMessage.content).toBe('The answer is 4.');
expect(apiMessage.reasoning_content).toBeUndefined();
});
it('returns undefined for null/empty input', () => {
expect(extractReasoningFromContent(null)).toBeUndefined();
expect(extractReasoningFromContent(undefined)).toBeUndefined();
expect(extractReasoningFromContent('')).toBeUndefined();
});
it('handles messages with no reasoning', () => {
const dbMessage = {
role: MessageRole.ASSISTANT,
content: 'No reasoning here.',
reasoningContent: undefined
};
it('extracts reasoning from content part arrays', () => {
const input = [
{
type: ContentPartType.TEXT,
text: wrapReasoning('thinking hard', 'result')
}
];
expect(extractReasoningFromContent(input)).toBe('thinking hard');
});
const apiMessage: Record<string, unknown> = {
role: dbMessage.role,
content: dbMessage.content
};
if (dbMessage.reasoningContent) {
apiMessage.reasoning_content = dbMessage.reasoningContent;
}
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');
});
it('ignores non-text content parts', () => {
const input = [{ type: 'image_url', text: wrapReasoning('hidden', 'img') }];
expect(extractReasoningFromContent(input)).toBeUndefined();
});
});
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('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');
});
});
describe('API message building with reasoning preservation', () => {
const storedContent = wrapReasoning('Let me think: 2+2=4, basic arithmetic.', 'The answer is 4.');
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('<<<');
});
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 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();
});
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();
});
});