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

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();
});
});