diff --git a/tools/server/webui/src/lib/components/app/chat/index.ts b/tools/server/webui/src/lib/components/app/chat/index.ts
index 2eee7e2dfc..88690086c7 100644
--- a/tools/server/webui/src/lib/components/app/chat/index.ts
+++ b/tools/server/webui/src/lib/components/app/chat/index.ts
@@ -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
diff --git a/tools/server/webui/src/lib/constants/agentic.ts b/tools/server/webui/src/lib/constants/agentic.ts
index ac31d5126d..831fa35a93 100644
--- a/tools/server/webui/src/lib/constants/agentic.ts
+++ b/tools/server/webui/src/lib/constants/agentic.ts
@@ -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: '<<
>>',
TOOL_CALL_END: '<<>>',
TOOL_NAME_PREFIX: '<<>>'
} 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: '<<>>',
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:
/<<>>\n<<>>\n<<>>([\s\S]*?)<<>>([\s\S]*?)<<>>/g,
- // Matches pending tool call (has NAME and ARGS but no END)
- PENDING_TOOL_CALL:
- /<<>>\n<<>>\n<<>>([\s\S]*?)<<>>([\s\S]*)$/,
- // Matches partial tool call (has START and NAME, ARGS still streaming)
- PARTIAL_WITH_NAME:
- /<<>>\n<<>>\n<<>>([\s\S]*)$/,
- // Matches early tool call (just START marker)
- EARLY_MATCH: /<<>>([\s\S]*)$/,
- // Matches partial marker at end of content
- PARTIAL_MARKER: /<<<[A-Za-z_]*$/,
- // Matches reasoning content blocks (including tags)
REASONING_BLOCK: /<<>>[\s\S]*?<<>>/g,
- // Captures the reasoning text between start/end tags
REASONING_EXTRACT: /<<>>([\s\S]*?)<<>>/,
- // Matches an opening reasoning tag and any remaining content (unterminated)
REASONING_OPEN: /<<>>[\s\S]*$/,
- // Matches a complete agentic tool call display block (start to end marker)
AGENTIC_TOOL_CALL_BLOCK: /\n*<<>>[\s\S]*?<<>>/g,
- // Matches a pending/partial agentic tool call (start marker with no matching end)
AGENTIC_TOOL_CALL_OPEN: /\n*<<>>[\s\S]*$/,
- // Matches tool name inside content
- TOOL_NAME_EXTRACT: /<<]+)>>>/
+ HAS_LEGACY_MARKERS: /<<<(?:AGENTIC_TOOL_CALL_START|reasoning_content_start)>>>/
} as const;
diff --git a/tools/server/webui/src/lib/services/chat.service.ts b/tools/server/webui/src/lib/services/chat.service.ts
index 1403b7c54e..ff99342766 100644
--- a/tools/server/webui/src/lib/services/chat.service.ts
+++ b/tools/server/webui/src/lib/services/chat.service.ts
@@ -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;
}
diff --git a/tools/server/webui/src/lib/stores/agentic.svelte.ts b/tools/server/webui/src/lib/stores/agentic.svelte.ts
index f8834f9df3..d924998478 100644
--- a/tools/server/webui/src/lib/stores/agentic.svelte.ts
+++ b/tools/server/webui/src/lib/stores/agentic.svelte.ts
@@ -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 {
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[];
diff --git a/tools/server/webui/src/lib/stores/chat.svelte.ts b/tools/server/webui/src/lib/stores/chat.svelte.ts
index 5f3812ed32..229631c6a3 100644
--- a/tools/server/webui/src/lib/stores/chat.svelte.ts
+++ b/tools/server/webui/src/lib/stores/chat.svelte.ts
@@ -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(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 = {
- 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 = {
- 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 = {
+ 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 = {
+ 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()
});
}
diff --git a/tools/server/webui/src/lib/stores/conversations.svelte.ts b/tools/server/webui/src/lib/stores/conversations.svelte.ts
index 5769ee98fd..177155ea19 100644
--- a/tools/server/webui/src/lib/stores/conversations.svelte.ts
+++ b/tools/server/webui/src/lib/stores/conversations.svelte.ts
@@ -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) {
diff --git a/tools/server/webui/src/lib/types/agentic.d.ts b/tools/server/webui/src/lib/types/agentic.d.ts
index f9d256e589..ecf296fc38 100644
--- a/tools/server/webui/src/lib/types/agentic.d.ts
+++ b/tools/server/webui/src/lib/types/agentic.d.ts
@@ -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;
+ /** Create a tool result message in the DB tree */
+ createToolResultMessage?: (
+ toolCallId: string,
+ content: string,
+ extras?: DatabaseMessageExtra[]
+ ) => Promise;
+ /** Create a new assistant message for the next agentic turn */
+ createAssistantMessage?: () => Promise;
+ /** 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;
}
diff --git a/tools/server/webui/src/lib/types/chat.d.ts b/tools/server/webui/src/lib/types/chat.d.ts
index 3d2dd930cd..431cf523a6 100644
--- a/tools/server/webui/src/lib/types/chat.d.ts
+++ b/tools/server/webui/src/lib/types/chat.d.ts
@@ -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;
+ createToolResultMessage?: (
+ toolCallId: string,
+ content: string,
+ extras?: DatabaseMessageExtra[]
+ ) => Promise;
+ createAssistantMessage?: () => Promise;
+ onFlowComplete?: (timings?: ChatMessageTimings) => void;
onError?: (error: Error) => void;
onTurnComplete?: (intermediateTimings: ChatMessageTimings) => void;
}
diff --git a/tools/server/webui/src/lib/types/database.d.ts b/tools/server/webui/src/lib/types/database.d.ts
index 95ff7377c6..2f96ef3f70 100644
--- a/tools/server/webui/src/lib/types/database.d.ts
+++ b/tools/server/webui/src/lib/types/database.d.ts
@@ -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') */
diff --git a/tools/server/webui/src/lib/utils/agentic.ts b/tools/server/webui/src/lib/utils/agentic.ts
index 330b924bcd..5ec4683fa2 100644
--- a/tools/server/webui/src/lib/utils/agentic.ts
+++ b/tools/server/webui/src/lib/utils/agentic.ts
@@ -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 <<>>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., "<<<", "<< {
+ 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;
}
diff --git a/tools/server/webui/src/lib/utils/index.ts b/tools/server/webui/src/lib/utils/index.ts
index 455d4f2c3f..88c95b6212 100644
--- a/tools/server/webui/src/lib/utils/index.ts
+++ b/tools/server/webui/src/lib/utils/index.ts
@@ -149,8 +149,17 @@ export { parseHeadersToArray, serializeHeaders } from './headers';
// Favicon utilities
export { getFaviconUrl } from './favicon';
-// Agentic content parsing utilities
-export { parseAgenticContent, type AgenticSection } from './agentic';
+// Agentic content utilities (structured section derivation)
+export {
+ deriveAgenticSections,
+ parseToolResultWithImages,
+ hasAgenticContent,
+ type AgenticSection,
+ type ToolResultLine
+} from './agentic';
+
+// Legacy migration utilities
+export { runLegacyMigration, isMigrationNeeded } from './legacy-migration';
// Cache utilities
export { TTLCache, ReactiveTTLMap, type TTLCacheOptions } from './cache-ttl';
diff --git a/tools/server/webui/src/lib/utils/legacy-migration.ts b/tools/server/webui/src/lib/utils/legacy-migration.ts
new file mode 100644
index 0000000000..b526c26098
--- /dev/null
+++ b/tools/server/webui/src/lib/utils/legacy-migration.ts
@@ -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:
+ * <<>>...<<>>
+ * <<>>...<<>>
+ *
+ * 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 {
+ 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 {
+ if (!isMigrationNeeded()) return;
+
+ console.log('[Migration] Starting legacy message format migration...');
+
+ try {
+ const conversations = await DatabaseService.getAllConversations();
+ let totalMigrated = 0;
+
+ for (const conv of conversations) {
+ const count = await migrateConversation(conv.id);
+ totalMigrated += count;
+ }
+
+ if (totalMigrated > 0) {
+ console.log(
+ `[Migration] Migrated ${totalMigrated} messages across ${conversations.length} conversations`
+ );
+ } else {
+ console.log('[Migration] No legacy messages found, marking as done');
+ }
+
+ markMigrationDone();
+ } catch (error) {
+ console.error('[Migration] Failed to migrate legacy messages:', error);
+ // Still mark as done to avoid infinite retry loops
+ markMigrationDone();
+ }
+}
diff --git a/tools/server/webui/tests/unit/agentic-sections.test.ts b/tools/server/webui/tests/unit/agentic-sections.test.ts
new file mode 100644
index 0000000000..451f30c6f8
--- /dev/null
+++ b/tools/server/webui/tests/unit/agentic-sections.test.ts
@@ -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 {
+ 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 {
+ 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);
+ });
+});
diff --git a/tools/server/webui/tests/unit/agentic-strip.test.ts b/tools/server/webui/tests/unit/agentic-strip.test.ts
index 436908bdb8..86867f8a9d 100644
--- a/tools/server/webui/tests/unit/agentic-strip.test.ts
+++ b/tools/server/webui/tests/unit/agentic-strip.test.ts
@@ -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<<>>\n' +
'<<>>\n' +
@@ -30,11 +35,10 @@ const OPEN_BLOCK =
'<<>>\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 = '<<>>think hard<<>>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', () => {
'<<>>plan<<>>' +
'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<<>>more')
+ ).toBe(true);
+ expect(LEGACY_AGENTIC_REGEX.HAS_LEGACY_MARKERS.test('<<>>think')).toBe(
+ true
+ );
});
});
diff --git a/tools/server/webui/tests/unit/reasoning-context.test.ts b/tools/server/webui/tests/unit/reasoning-context.test.ts
index abbecf7e09..b448974a38 100644
--- a/tools/server/webui/tests/unit/reasoning-context.test.ts
+++ b/tools/server/webui/tests/unit/reasoning-context.test.ts
@@ -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 = {
+ 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 = {
+ 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 = {
+ 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<<>>\n' +
- '<<>>\n' +
- '<<>>\n{}\n<<>>\nout\n' +
- '<<>>\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();
});
});