mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-25 17:02:10 +00:00
Hide raw Assistant tool-call output
This commit is contained in:
parent
df203b0ce6
commit
05d00a65a6
15 changed files with 625 additions and 459 deletions
|
|
@ -88,6 +88,15 @@ runtime cost control, and shared AI transport surfaces.
|
|||
and render a neutral transcript marker rather than persisting synthetic
|
||||
assistant answer text or surfacing the interruption as a retryable provider
|
||||
failure.
|
||||
Assistant output hygiene is part of the same boundary: provider reasoning
|
||||
and raw serialized tool-call artifacts must never render as assistant
|
||||
transcript prose. Reasoning/thinking deltas may update neutral progress
|
||||
state, but visible answer blocks and copyable message text must exclude the
|
||||
reasoning body. Pulse tool invocations must surface only through governed
|
||||
`tool_start` / `tool_end`, approval, or question blocks; if a provider emits
|
||||
`pulse_*` / `patrol_*` calls, DSML, XML/function-call envelopes, or JSON
|
||||
tool-call shapes as text content, the chat runtime must strip them before
|
||||
streaming, persistence, and frontend rendering.
|
||||
4. Add or change Patrol, alert-analysis, or remediation transport through `internal/api/ai_handlers.go`, `internal/api/ai_intelligence_handlers.go`, and `frontend-modern/src/api/patrol.ts`
|
||||
Provider preflight diagnostics returned from `internal/api/ai_handlers.go`
|
||||
must reuse the Patrol runtime failure classifier in `internal/ai/` and
|
||||
|
|
|
|||
|
|
@ -5,12 +5,12 @@ import CopyIcon from 'lucide-solid/icons/copy';
|
|||
import RotateCcwIcon from 'lucide-solid/icons/rotate-ccw';
|
||||
import SparklesIcon from 'lucide-solid/icons/sparkles';
|
||||
import { renderMarkdown } from '../aiChatUtils';
|
||||
import { ThinkingBlock } from './ThinkingBlock';
|
||||
import { ToolExecutionBlock } from './ToolExecutionBlock';
|
||||
import { ApprovalCard } from './ApprovalCard';
|
||||
import { QuestionCard } from './QuestionCard';
|
||||
import { stripAssistantOutputArtifacts } from './assistantOutputHygiene';
|
||||
import { groupStreamEventsForDisplay } from './streamEventGrouping';
|
||||
import type { ChatMessage, PendingApproval, PendingQuestion } from './types';
|
||||
import type { ChatMessage, PendingApproval, PendingQuestion, StreamDisplayEvent } from './types';
|
||||
import {
|
||||
AI_CHAT_ASSISTANT_MESSAGE_LABEL,
|
||||
AI_CHAT_CONTEXT_USED_LABEL,
|
||||
|
|
@ -41,15 +41,28 @@ const markdownClass =
|
|||
export const MessageItem: Component<MessageItemProps> = (props) => {
|
||||
const isUser = () => props.message.role === 'user';
|
||||
|
||||
const hasStreamEvents = () => props.message.streamEvents && props.message.streamEvents.length > 0;
|
||||
|
||||
// Group stream events into display blocks. Content and reasoning each collapse
|
||||
// into a single block even when a reasoning model interleaves them, so the
|
||||
// answer stays a coherent markdown document instead of fragmenting into
|
||||
// Group stream events into display blocks. Content collapses into a single
|
||||
// block even when a reasoning model interleaves hidden thinking deltas, so
|
||||
// the answer stays a coherent markdown document instead of fragmenting into
|
||||
// whitespace-trimmed pieces. See groupStreamEventsForDisplay for the rationale.
|
||||
const groupedEvents = createMemo(() =>
|
||||
groupStreamEventsForDisplay(props.message.streamEvents || []),
|
||||
);
|
||||
const isRenderableStreamEvent = (evt: StreamDisplayEvent) => {
|
||||
switch (evt.type) {
|
||||
case 'content':
|
||||
return !!stripAssistantOutputArtifacts(evt.content || '').text;
|
||||
case 'tool':
|
||||
return !!evt.tool;
|
||||
case 'approval':
|
||||
return !!evt.approval;
|
||||
case 'question':
|
||||
return !!evt.question;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const hasRenderableStreamEvents = () => groupedEvents().some(isRenderableStreamEvent);
|
||||
|
||||
const contextTools = createMemo(() => {
|
||||
const events = props.message.streamEvents || [];
|
||||
|
|
@ -63,6 +76,8 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
|
|||
|
||||
return Array.from(names);
|
||||
});
|
||||
const visibleMessageContent = () =>
|
||||
stripAssistantOutputArtifacts(props.message.content || '').text;
|
||||
|
||||
// Check if currently streaming content (no tools pending, still streaming)
|
||||
const isStreamingText = () =>
|
||||
|
|
@ -71,7 +86,7 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
|
|||
const isWaitingForFirstToken = () =>
|
||||
isStreamingText() &&
|
||||
!props.message.content.trim() &&
|
||||
!hasStreamEvents() &&
|
||||
!hasRenderableStreamEvents() &&
|
||||
!props.message.error;
|
||||
const interruptionLabel = createMemo(() => {
|
||||
switch (props.message.interruption) {
|
||||
|
|
@ -86,9 +101,9 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
|
|||
|
||||
// Copy-to-clipboard for a completed assistant answer.
|
||||
const [copied, setCopied] = createSignal(false);
|
||||
const canCopy = () => !props.message.isStreaming && !!props.message.content?.trim();
|
||||
const canCopy = () => !props.message.isStreaming && !!visibleMessageContent().trim();
|
||||
const copyMessage = async () => {
|
||||
const text = props.message.content || '';
|
||||
const text = visibleMessageContent();
|
||||
try {
|
||||
await navigator.clipboard?.writeText(text);
|
||||
setCopied(true);
|
||||
|
|
@ -159,18 +174,10 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
|
|||
</Show>
|
||||
|
||||
{/* Stream events - chronological display */}
|
||||
<Show when={hasStreamEvents()}>
|
||||
<Show when={hasRenderableStreamEvents()}>
|
||||
<For each={groupedEvents()}>
|
||||
{(evt) => (
|
||||
<Switch>
|
||||
{/* Thinking block */}
|
||||
<Match when={evt.type === 'thinking' && evt.thinking}>
|
||||
<ThinkingBlock
|
||||
content={evt.thinking || ''}
|
||||
isStreaming={props.message.isStreaming}
|
||||
/>
|
||||
</Match>
|
||||
|
||||
<Match when={evt.type === 'pending_tool' && evt.pendingTool}>
|
||||
<></>
|
||||
</Match>
|
||||
|
|
@ -187,11 +194,18 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
|
|||
</Match>
|
||||
|
||||
{/* Content/text block */}
|
||||
<Match when={evt.type === 'content' && evt.content}>
|
||||
<Match
|
||||
when={
|
||||
evt.type === 'content' &&
|
||||
stripAssistantOutputArtifacts(evt.content || '').text
|
||||
}
|
||||
>
|
||||
<div
|
||||
class={markdownClass}
|
||||
// eslint-disable-next-line solid/no-innerhtml
|
||||
innerHTML={renderMarkdown(evt.content || '')}
|
||||
innerHTML={renderMarkdown(
|
||||
stripAssistantOutputArtifacts(evt.content || '').text,
|
||||
)}
|
||||
/>
|
||||
</Match>
|
||||
|
||||
|
|
@ -220,11 +234,11 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
|
|||
</Show>
|
||||
|
||||
{/* Fallback */}
|
||||
<Show when={props.message.content && !hasStreamEvents()}>
|
||||
<Show when={visibleMessageContent() && !hasRenderableStreamEvents()}>
|
||||
<div
|
||||
class={markdownClass}
|
||||
// eslint-disable-next-line solid/no-innerhtml
|
||||
innerHTML={renderMarkdown(props.message.content)}
|
||||
innerHTML={renderMarkdown(visibleMessageContent())}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,103 +1,20 @@
|
|||
import { Component, createSignal, Show, createMemo } from 'solid-js';
|
||||
import { sanitizeThinking } from '../aiChatUtils';
|
||||
import { Component } from 'solid-js';
|
||||
import BrainIcon from 'lucide-solid/icons/brain';
|
||||
|
||||
interface ThinkingBlockProps {
|
||||
content: string;
|
||||
content?: string;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* ThinkingBlock - Displays AI's reasoning/thinking in a collapsed-by-default block.
|
||||
*
|
||||
* Inspired by Pulse AI's terminal TUI which shows thinking as a subtle,
|
||||
* collapsible section that doesn't distract from the main response.
|
||||
*/
|
||||
export const ThinkingBlock: Component<ThinkingBlockProps> = (props) => {
|
||||
const [expanded, setExpanded] = createSignal(false);
|
||||
|
||||
// Count lines and words for preview
|
||||
const stats = createMemo(() => {
|
||||
const lines = props.content.split('\n').filter((l) => l.trim()).length;
|
||||
const words = props.content.split(/\s+/).filter((w) => w).length;
|
||||
return { lines, words };
|
||||
});
|
||||
|
||||
// Get a short preview (first line, truncated)
|
||||
const preview = createMemo(() => {
|
||||
const firstLine = props.content.split('\n').find((l) => l.trim()) || '';
|
||||
const maxLen = 60;
|
||||
if (firstLine.length > maxLen) {
|
||||
return firstLine.substring(0, maxLen).trim() + '...';
|
||||
}
|
||||
return firstLine.trim();
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="my-2 font-mono text-xs">
|
||||
{/* Collapsed header - always visible */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(!expanded())}
|
||||
class="w-full flex items-center gap-2 px-3 py-1.5 rounded-md bg-surface-alt hover:bg-surface-hover transition-colors text-left group"
|
||||
>
|
||||
{/* Thinking icon */}
|
||||
<div
|
||||
class={`flex items-center justify-center w-4 h-4 ${props.isStreaming ? 'animate-pulse' : ''}`}
|
||||
>
|
||||
<svg
|
||||
class="w-3.5 h-3.5 text-blue-500 dark:text-blue-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<span class="text-blue-600 dark:text-blue-400 font-medium uppercase text-[10px] tracking-wider">
|
||||
{props.isStreaming ? 'Thinking...' : 'Thinking'}
|
||||
</span>
|
||||
|
||||
{/* Preview (when collapsed) */}
|
||||
<Show when={!expanded() && preview()}>
|
||||
<span class="text-muted truncate flex-1">{preview()}</span>
|
||||
</Show>
|
||||
|
||||
{/* Stats */}
|
||||
<span class="text-muted text-[10px] ml-auto">
|
||||
{stats().lines} lines · {stats().words} words
|
||||
</span>
|
||||
|
||||
{/* Expand/collapse chevron */}
|
||||
<svg
|
||||
class={`w-3.5 h-3.5 transition-transform ${expanded() ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Expanded content */}
|
||||
<Show when={expanded()}>
|
||||
<div class="mt-1 ml-4 pl-3 border-l-2 border-blue-200 dark:border-blue-800">
|
||||
<pre class="text-[11px] text-muted whitespace-pre-wrap leading-relaxed max-h-64 overflow-y-auto">
|
||||
{sanitizeThinking(props.content)}
|
||||
</pre>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export const ThinkingBlock: Component<ThinkingBlockProps> = (props) => (
|
||||
<div
|
||||
class="my-2 inline-flex items-center gap-2 rounded-md border border-border-subtle bg-surface-alt px-2.5 py-1.5 text-xs text-muted"
|
||||
role="status"
|
||||
>
|
||||
<BrainIcon
|
||||
class={`h-3.5 w-3.5 text-blue-500 ${props.isStreaming ? 'animate-pulse' : ''}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{props.isStreaming ? 'Thinking...' : 'Thinking complete'}</span>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -474,9 +474,9 @@ describe('MessageItem', () => {
|
|||
});
|
||||
|
||||
describe('stream events rendering', () => {
|
||||
it('renders thinking blocks from stream events', () => {
|
||||
it('does not render raw thinking from stream events', () => {
|
||||
const events: StreamDisplayEvent[] = [
|
||||
{ type: 'thinking', thinking: 'Let me analyze this...' },
|
||||
{ type: 'thinking', thinking: 'We need to inspect the user prompt before answering.' },
|
||||
];
|
||||
|
||||
render(() => (
|
||||
|
|
@ -486,22 +486,30 @@ describe('MessageItem', () => {
|
|||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByTestId('thinking-block')).toBeInTheDocument();
|
||||
expect(screen.getByText('Let me analyze this...')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('thinking-block')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/inspect the user prompt/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes isStreaming to ThinkingBlock', () => {
|
||||
const events: StreamDisplayEvent[] = [{ type: 'thinking', thinking: 'Thinking...' }];
|
||||
it('keeps the neutral first-token indicator when only thinking has streamed', () => {
|
||||
const events: StreamDisplayEvent[] = [
|
||||
{ type: 'thinking', thinking: 'Hidden reasoning should not be visible.' },
|
||||
];
|
||||
|
||||
render(() => (
|
||||
<MessageItem
|
||||
message={makeMessage({ role: 'assistant', streamEvents: events, isStreaming: true })}
|
||||
message={makeMessage({
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
streamEvents: events,
|
||||
isStreaming: true,
|
||||
pendingTools: [],
|
||||
})}
|
||||
{...makeHandlers()}
|
||||
/>
|
||||
));
|
||||
|
||||
const block = screen.getByTestId('thinking-block');
|
||||
expect(block.getAttribute('data-streaming')).toBe('true');
|
||||
expect(screen.getByText('Thinking...')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Hidden reasoning/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders tool execution blocks', () => {
|
||||
|
|
@ -575,6 +583,29 @@ describe('MessageItem', () => {
|
|||
expect(prose!.innerHTML).toContain('<p>Here is the analysis</p>');
|
||||
});
|
||||
|
||||
it('strips serialized tool-call text from content blocks', () => {
|
||||
const events: StreamDisplayEvent[] = [
|
||||
{
|
||||
type: 'content',
|
||||
content:
|
||||
'I will inspect the device nodes.\npulse_read(target_host="current_resource", command="lsblk")',
|
||||
},
|
||||
];
|
||||
|
||||
const { container } = render(() => (
|
||||
<MessageItem
|
||||
message={makeMessage({ role: 'assistant', streamEvents: events })}
|
||||
{...makeHandlers()}
|
||||
/>
|
||||
));
|
||||
|
||||
const prose = container.querySelector('.prose');
|
||||
expect(prose).toBeInTheDocument();
|
||||
expect(prose!.innerHTML).toContain('I will inspect the device nodes.');
|
||||
expect(prose!.innerHTML).not.toContain('pulse_read');
|
||||
expect(screen.queryByText(/target_host/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders pending_tool events as empty fragments (no visible output)', () => {
|
||||
const events: StreamDisplayEvent[] = [
|
||||
{
|
||||
|
|
@ -644,30 +675,26 @@ describe('MessageItem', () => {
|
|||
{ type: 'content', content: 'Step 2' },
|
||||
];
|
||||
|
||||
render(() => (
|
||||
const { container } = render(() => (
|
||||
<MessageItem
|
||||
message={makeMessage({ role: 'assistant', streamEvents: events })}
|
||||
{...makeHandlers()}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByTestId('thinking-block')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('thinking-block')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('tool-execution-block')).toBeInTheDocument();
|
||||
|
||||
// Verify DOM order: thinking → content(Step 1) → tool → content(Step 2)
|
||||
const contentArea = screen.getByTestId('thinking-block').parentElement!;
|
||||
// Verify DOM order: content(Step 1) → tool → content(Step 2). Thinking is hidden.
|
||||
const allBlocks = Array.from(
|
||||
contentArea.querySelectorAll(
|
||||
'[data-testid="thinking-block"], .prose, [data-testid="tool-execution-block"]',
|
||||
),
|
||||
container.querySelectorAll('.prose, [data-testid="tool-execution-block"]'),
|
||||
);
|
||||
expect(allBlocks.length).toBe(4); // thinking + 2 prose + 1 tool
|
||||
expect(allBlocks[0].getAttribute('data-testid')).toBe('thinking-block');
|
||||
expect(allBlocks[1].classList.contains('prose')).toBe(true);
|
||||
expect(allBlocks[1].innerHTML).toContain('Step 1');
|
||||
expect(allBlocks[2].getAttribute('data-testid')).toBe('tool-execution-block');
|
||||
expect(allBlocks[3].classList.contains('prose')).toBe(true);
|
||||
expect(allBlocks[3].innerHTML).toContain('Step 2');
|
||||
expect(allBlocks.length).toBe(3);
|
||||
expect(allBlocks[0].classList.contains('prose')).toBe(true);
|
||||
expect(allBlocks[0].innerHTML).toContain('Step 1');
|
||||
expect(allBlocks[1].getAttribute('data-testid')).toBe('tool-execution-block');
|
||||
expect(allBlocks[2].classList.contains('prose')).toBe(true);
|
||||
expect(allBlocks[2].innerHTML).toContain('Step 2');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -752,10 +779,8 @@ describe('MessageItem', () => {
|
|||
expect(prose!.innerHTML).toContain('Fallback text');
|
||||
});
|
||||
|
||||
it('suppresses fallback content when streamEvents is non-empty but all events are non-renderable', () => {
|
||||
it('uses fallback content when streamEvents are present but all events are non-renderable', () => {
|
||||
// All content events have empty content (falsy), so nothing renders from them.
|
||||
// But hasStreamEvents() is true because the array is non-empty,
|
||||
// so the fallback content path is also suppressed.
|
||||
const events: StreamDisplayEvent[] = [
|
||||
{ type: 'content', content: '' },
|
||||
{ type: 'content', content: '' },
|
||||
|
|
@ -765,18 +790,16 @@ describe('MessageItem', () => {
|
|||
<MessageItem
|
||||
message={makeMessage({
|
||||
role: 'assistant',
|
||||
content: 'Fallback that should NOT appear',
|
||||
content: 'Fallback answer text',
|
||||
streamEvents: events,
|
||||
})}
|
||||
{...makeHandlers()}
|
||||
/>
|
||||
));
|
||||
|
||||
// No prose blocks rendered (empty content events are skipped by groupedEvents)
|
||||
const proseBlocks = container.querySelectorAll('.prose');
|
||||
expect(proseBlocks.length).toBe(0);
|
||||
// Fallback is also suppressed because hasStreamEvents() is true
|
||||
expect(screen.queryByText('Fallback that should NOT appear')).not.toBeInTheDocument();
|
||||
expect(proseBlocks.length).toBe(1);
|
||||
expect(proseBlocks[0].innerHTML).toContain('Fallback answer text');
|
||||
});
|
||||
|
||||
it('handles undefined stream events (uses fallback content)', () => {
|
||||
|
|
@ -794,6 +817,24 @@ describe('MessageItem', () => {
|
|||
const prose = container.querySelector('.prose');
|
||||
expect(prose!.innerHTML).toContain('Regular content');
|
||||
});
|
||||
|
||||
it('strips serialized tool-call text from fallback content', () => {
|
||||
const { container } = render(() => (
|
||||
<MessageItem
|
||||
message={makeMessage({
|
||||
streamEvents: undefined,
|
||||
content:
|
||||
'I will inspect the device nodes.\npulse_read(target_host="current_resource", command="lsblk")',
|
||||
})}
|
||||
{...makeHandlers()}
|
||||
/>
|
||||
));
|
||||
|
||||
const prose = container.querySelector('.prose');
|
||||
expect(prose).toBeInTheDocument();
|
||||
expect(prose!.innerHTML).toContain('I will inspect the device nodes.');
|
||||
expect(prose!.innerHTML).not.toContain('pulse_read');
|
||||
});
|
||||
});
|
||||
|
||||
describe('context tools display', () => {
|
||||
|
|
|
|||
|
|
@ -1,207 +1,27 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { cleanup, render, screen, fireEvent } from '@solidjs/testing-library';
|
||||
import { afterEach } from 'vitest';
|
||||
import { describe, expect, it, afterEach } from 'vitest';
|
||||
import { cleanup, render, screen } from '@solidjs/testing-library';
|
||||
import { ThinkingBlock } from '../ThinkingBlock';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('ThinkingBlock', () => {
|
||||
// --- Header / label rendering ---
|
||||
it('renders a neutral completed-thinking status without reasoning content', () => {
|
||||
render(() => <ThinkingBlock content="We need to inspect the prompt before answering." />);
|
||||
|
||||
it('renders "Thinking" label when not streaming', () => {
|
||||
render(() => <ThinkingBlock content="Some reasoning" />);
|
||||
expect(screen.getByText('Thinking')).toBeInTheDocument();
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Thinking complete');
|
||||
expect(screen.queryByText(/inspect the prompt/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Thinking..." label when streaming', () => {
|
||||
render(() => <ThinkingBlock content="Some reasoning" isStreaming={true} />);
|
||||
expect(screen.getByText('Thinking...')).toBeInTheDocument();
|
||||
it('renders a neutral streaming-thinking status without reasoning content', () => {
|
||||
render(() => <ThinkingBlock content="Hidden provider reasoning" isStreaming={true} />);
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Thinking...');
|
||||
expect(screen.queryByText(/Hidden provider reasoning/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// --- Stats display ---
|
||||
it('marks the icon as active while streaming', () => {
|
||||
const { container } = render(() => <ThinkingBlock content="hidden" isStreaming={true} />);
|
||||
|
||||
it('shows correct line and word count', () => {
|
||||
const content = 'First line\nSecond line\nThird line';
|
||||
render(() => <ThinkingBlock content={content} />);
|
||||
expect(screen.getByText('3 lines · 6 words')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('counts only non-empty lines', () => {
|
||||
const content = 'Line one\n\n\nLine two\n\n';
|
||||
render(() => <ThinkingBlock content={content} />);
|
||||
// Only 2 non-blank lines, 4 words
|
||||
expect(screen.getByText('2 lines · 4 words')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('counts only non-empty words', () => {
|
||||
const content = ' hello world ';
|
||||
render(() => <ThinkingBlock content={content} />);
|
||||
expect(screen.getByText('1 lines · 2 words')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles single-line content', () => {
|
||||
render(() => <ThinkingBlock content="just one line" />);
|
||||
expect(screen.getByText('1 lines · 3 words')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// --- Preview text (collapsed state) ---
|
||||
|
||||
it('shows preview text when collapsed', () => {
|
||||
render(() => <ThinkingBlock content="This is the preview" />);
|
||||
expect(screen.getByText('This is the preview')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('truncates preview to 60 chars with ellipsis', () => {
|
||||
const longLine = 'A'.repeat(70) + ' and some more text';
|
||||
render(() => <ThinkingBlock content={longLine} />);
|
||||
const expectedPreview = 'A'.repeat(60) + '...';
|
||||
expect(screen.getByText(expectedPreview)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses first non-empty line as preview', () => {
|
||||
const content = '\n\n Real first line\nSecond line';
|
||||
render(() => <ThinkingBlock content={content} />);
|
||||
expect(screen.getByText('Real first line')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// --- Expand/collapse behavior ---
|
||||
|
||||
it('starts collapsed (content not visible)', () => {
|
||||
render(() => <ThinkingBlock content="Hidden reasoning content" />);
|
||||
// The sanitized content inside <pre> should not be in the DOM when collapsed
|
||||
const preElements = document.querySelectorAll('pre');
|
||||
expect(preElements.length).toBe(0);
|
||||
});
|
||||
|
||||
it('expands on click to show content', async () => {
|
||||
render(() => <ThinkingBlock content="Expanded reasoning" />);
|
||||
const button = screen.getByRole('button');
|
||||
await fireEvent.click(button);
|
||||
const preElement = document.querySelector('pre');
|
||||
expect(preElement).not.toBeNull();
|
||||
expect(preElement!.textContent).toContain('Expanded reasoning');
|
||||
});
|
||||
|
||||
it('collapses again on second click', async () => {
|
||||
render(() => <ThinkingBlock content="Toggle content" />);
|
||||
const button = screen.getByRole('button');
|
||||
|
||||
// Expand
|
||||
await fireEvent.click(button);
|
||||
expect(document.querySelector('pre')).not.toBeNull();
|
||||
|
||||
// Collapse
|
||||
await fireEvent.click(button);
|
||||
expect(document.querySelector('pre')).toBeNull();
|
||||
});
|
||||
|
||||
it('hides preview text when expanded', async () => {
|
||||
render(() => <ThinkingBlock content="Preview goes away" />);
|
||||
// Preview visible when collapsed
|
||||
expect(screen.getByText('Preview goes away')).toBeInTheDocument();
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
await fireEvent.click(button);
|
||||
|
||||
// After expanding, preview span should be gone; content appears only inside <pre>
|
||||
const spans = document.querySelectorAll('span');
|
||||
const previewSpans = Array.from(spans).filter((s) => s.textContent === 'Preview goes away');
|
||||
expect(previewSpans.length).toBe(0);
|
||||
});
|
||||
|
||||
// --- Streaming indicator ---
|
||||
|
||||
it('applies animate-pulse class when streaming', () => {
|
||||
render(() => <ThinkingBlock content="streaming" isStreaming={true} />);
|
||||
const pulseDiv = document.querySelector('.animate-pulse');
|
||||
expect(pulseDiv).not.toBeNull();
|
||||
});
|
||||
|
||||
it('does not apply animate-pulse when not streaming', () => {
|
||||
render(() => <ThinkingBlock content="not streaming" isStreaming={false} />);
|
||||
const pulseDiv = document.querySelector('.animate-pulse');
|
||||
expect(pulseDiv).toBeNull();
|
||||
});
|
||||
|
||||
// --- Content sanitization ---
|
||||
|
||||
it('sanitizes TCP connection details in expanded content', async () => {
|
||||
const rawContent = 'Error: write tcp 192.0.2.10:7655->198.51.100.20:58004: i/o timeout';
|
||||
render(() => <ThinkingBlock content={rawContent} />);
|
||||
const button = screen.getByRole('button');
|
||||
await fireEvent.click(button);
|
||||
|
||||
const preElement = document.querySelector('pre');
|
||||
expect(preElement).not.toBeNull();
|
||||
// Should NOT show raw IP addresses
|
||||
expect(preElement!.textContent).not.toContain('192.0.2.10');
|
||||
expect(preElement!.textContent).toContain('connection timed out');
|
||||
});
|
||||
|
||||
it('sanitizes "failed to send command" patterns', async () => {
|
||||
const rawContent = 'failed to send command: write tcp 10.0.0.1:7655->10.0.0.2:9999: broken';
|
||||
render(() => <ThinkingBlock content={rawContent} />);
|
||||
const button = screen.getByRole('button');
|
||||
await fireEvent.click(button);
|
||||
|
||||
const preElement = document.querySelector('pre');
|
||||
expect(preElement).not.toBeNull();
|
||||
// The sanitizer replaces the "failed to send command: write tcp <addr>" prefix
|
||||
expect(preElement!.textContent).toContain('failed to send command: connection error');
|
||||
// Source IP is removed by the regex
|
||||
expect(preElement!.textContent).not.toContain('10.0.0.1');
|
||||
// NOTE: The destination IP (10.0.0.2) may still leak because the regex
|
||||
// [\d.:->\s]+ in sanitizeThinking doesn't fully consume "->dest:port".
|
||||
// This is a known limitation in the sanitizer source, not a test gap.
|
||||
});
|
||||
|
||||
it('sanitizes "dial tcp" connection refused patterns', async () => {
|
||||
const rawContent = 'Error: dial tcp 10.0.0.5:8006: connection refused';
|
||||
render(() => <ThinkingBlock content={rawContent} />);
|
||||
const button = screen.getByRole('button');
|
||||
await fireEvent.click(button);
|
||||
|
||||
const preElement = document.querySelector('pre');
|
||||
expect(preElement).not.toBeNull();
|
||||
expect(preElement!.textContent).toContain('connection refused');
|
||||
expect(preElement!.textContent).not.toContain('10.0.0.5');
|
||||
});
|
||||
|
||||
it('sanitizes "read tcp" timeout patterns', async () => {
|
||||
const rawContent = 'read tcp 172.16.0.1:7655: i/o timeout';
|
||||
render(() => <ThinkingBlock content={rawContent} />);
|
||||
const button = screen.getByRole('button');
|
||||
await fireEvent.click(button);
|
||||
|
||||
const preElement = document.querySelector('pre');
|
||||
expect(preElement).not.toBeNull();
|
||||
expect(preElement!.textContent).toContain('connection timed out');
|
||||
expect(preElement!.textContent).not.toContain('172.16.0.1');
|
||||
});
|
||||
|
||||
// --- Preview does not sanitize (documents current behavior) ---
|
||||
|
||||
it('preview shows raw content (sanitization only applies to expanded body)', () => {
|
||||
const rawContent = 'write tcp 192.168.1.1:7655->192.168.1.2:58004: i/o timeout';
|
||||
render(() => <ThinkingBlock content={rawContent} />);
|
||||
// The collapsed preview uses raw props.content, not sanitizeThinking()
|
||||
// This documents the current behavior — preview truncates but does not sanitize
|
||||
const previewSpan = document.querySelector('span.text-muted.truncate');
|
||||
expect(previewSpan).not.toBeNull();
|
||||
// The preview shows the raw (truncated) text
|
||||
expect(previewSpan!.textContent).toContain('write tcp');
|
||||
});
|
||||
|
||||
// --- Edge cases ---
|
||||
|
||||
it('handles empty content gracefully', () => {
|
||||
render(() => <ThinkingBlock content="" />);
|
||||
expect(screen.getByText('0 lines · 0 words')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles whitespace-only content', () => {
|
||||
const whitespace = ' \n \n ';
|
||||
render(() => <ThinkingBlock content={whitespace} />);
|
||||
expect(screen.getByText('0 lines · 0 words')).toBeInTheDocument();
|
||||
expect(container.querySelector('.animate-pulse')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { stripAssistantOutputArtifacts } from '../assistantOutputHygiene';
|
||||
|
||||
describe('stripAssistantOutputArtifacts', () => {
|
||||
it('strips plain Pulse function-call leaks while preserving prose before them', () => {
|
||||
const result = stripAssistantOutputArtifacts(
|
||||
'I will inspect the device nodes.\npulse_read(target_host="current_resource", command="ls /dev | wc -l")',
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
text: 'I will inspect the device nodes.',
|
||||
stripped: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('strips JSON tool-call leaks', () => {
|
||||
const result = stripAssistantOutputArtifacts(
|
||||
'Looking it up now.\n{"name":"pulse_query","parameters":{"action":"list"}}',
|
||||
);
|
||||
|
||||
expect(result.text).toBe('Looking it up now.');
|
||||
expect(result.stripped).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves ordinary prose and unrelated function calls alone', () => {
|
||||
expect(stripAssistantOutputArtifacts('Call helper(target="x") in the example.')).toEqual({
|
||||
text: 'Call helper(target="x") in the example.',
|
||||
stripped: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -588,8 +588,7 @@ describe('useChat', () => {
|
|||
expect(assistant.thinking).toBe('Let me think...');
|
||||
|
||||
const thinkingEvents = assistant.streamEvents?.filter((e) => e.type === 'thinking') ?? [];
|
||||
expect(thinkingEvents).toHaveLength(1);
|
||||
expect(thinkingEvents[0].thinking).toBe('Let me think...');
|
||||
expect(thinkingEvents).toHaveLength(0);
|
||||
dispose();
|
||||
});
|
||||
|
||||
|
|
@ -636,6 +635,58 @@ describe('useChat', () => {
|
|||
dispose();
|
||||
});
|
||||
|
||||
it('strips serialized Pulse tool calls from streamed content', async () => {
|
||||
const { getFireEvent } = setupWithEventCapture();
|
||||
const { value: chat, dispose } = withRoot(() => useChat({ sessionId: 's' }));
|
||||
|
||||
await chat.sendMessage('how many devices in this');
|
||||
const fire = getFireEvent();
|
||||
|
||||
fire({
|
||||
type: 'content',
|
||||
data: 'I will inspect the device nodes.\npulse_read(target_host="current_resource", command="ls /dev | wc -l")',
|
||||
});
|
||||
fire({ type: 'content', data: 'raw arguments that should stay hidden' });
|
||||
|
||||
const assistant = chat.messages().find((m) => m.role === 'assistant')!;
|
||||
expect(assistant.content).toBe('I will inspect the device nodes.');
|
||||
expect(assistant.content).not.toContain('pulse_read');
|
||||
expect(assistant.content).not.toContain('raw arguments');
|
||||
expect(assistant.streamEvents?.filter((e) => e.type === 'content')).toEqual([
|
||||
{ type: 'content', content: 'I will inspect the device nodes.' },
|
||||
]);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('resumes visible content after a governed tool boundary clears a raw leak', async () => {
|
||||
const { getFireEvent } = setupWithEventCapture();
|
||||
const { value: chat, dispose } = withRoot(() => useChat({ sessionId: 's' }));
|
||||
|
||||
await chat.sendMessage('how many devices in this');
|
||||
const fire = getFireEvent();
|
||||
|
||||
fire({
|
||||
type: 'content',
|
||||
data: 'I will inspect the device nodes.\npulse_read(target_host="current_resource", command="ls /dev | wc -l")',
|
||||
});
|
||||
fire({ type: 'content', data: 'raw arguments that should stay hidden' });
|
||||
fire({ type: 'tool_start', data: { id: 'tool-1', name: 'pulse_read', input: 'exec' } });
|
||||
fire({
|
||||
type: 'tool_end',
|
||||
data: { id: 'tool-1', name: 'pulse_read', input: 'exec', output: '42', success: true },
|
||||
});
|
||||
fire({ type: 'content', data: 'There are 42 device entries.' });
|
||||
|
||||
const assistant = chat.messages().find((m) => m.role === 'assistant')!;
|
||||
expect(assistant.content).toBe(
|
||||
'I will inspect the device nodes.There are 42 device entries.',
|
||||
);
|
||||
expect(assistant.content).not.toContain('pulse_read');
|
||||
expect(assistant.content).not.toContain('raw arguments');
|
||||
expect(assistant.streamEvents?.map((e) => e.type)).toEqual(['content', 'tool', 'content']);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('ignores product telemetry events that are not the model response', async () => {
|
||||
const { getFireEvent } = setupWithEventCapture();
|
||||
const { value: chat, dispose } = withRoot(() => useChat({ sessionId: 's' }));
|
||||
|
|
@ -1088,8 +1139,8 @@ describe('useChat', () => {
|
|||
|
||||
const assistant = chat.messages().find((m) => m.role === 'assistant')!;
|
||||
const types = assistant.streamEvents!.map((e) => e.type);
|
||||
// Thinking, content, pending_tool, content (new content after non-content breaks merging)
|
||||
expect(types).toEqual(['thinking', 'content', 'pending_tool', 'content']);
|
||||
// Content, pending_tool, content (thinking is retained internally, not rendered)
|
||||
expect(types).toEqual(['content', 'pending_tool', 'content']);
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
const RAW_TOOL_MARKERS = [
|
||||
'<|DSML|',
|
||||
'</|DSML|',
|
||||
'<||DSML||',
|
||||
'</||DSML||',
|
||||
'<|DSML|',
|
||||
'</|DSML|',
|
||||
'<||DSML||',
|
||||
'</||DSML||',
|
||||
'<tool_call',
|
||||
'</tool_call',
|
||||
'<tool_calls',
|
||||
'</tool_calls',
|
||||
'<function_call',
|
||||
'</function_call',
|
||||
'<function_calls',
|
||||
'</function_calls',
|
||||
'<|plugin|>',
|
||||
'<|interpreter|>',
|
||||
'<|tool_call|>',
|
||||
];
|
||||
|
||||
const jsonToolCallLeakRe =
|
||||
/(?:^|\n)[ \t]*(?:```[ \t]*(?:json|JSON)?[ \t]*\n?[ \t]*)?\{[ \t\n]*"name"[ \t]*:[ \t]*"((?:pulse|patrol)_[a-zA-Z0-9_]*)"/;
|
||||
const functionToolCallLeakRe = /(?:^|[^a-zA-Z0-9_])((?:pulse|patrol)_[a-zA-Z0-9_]*)[ \t\r\n]*\(/;
|
||||
const minimaxToolCallLeakRe = /^minimax:tool_call\b/m;
|
||||
|
||||
export function stripAssistantOutputArtifacts(content: string): {
|
||||
text: string;
|
||||
stripped: boolean;
|
||||
} {
|
||||
const idx = assistantOutputArtifactIndex(content);
|
||||
if (idx < 0) {
|
||||
return { text: content, stripped: false };
|
||||
}
|
||||
return { text: content.slice(0, idx).trimEnd(), stripped: true };
|
||||
}
|
||||
|
||||
function assistantOutputArtifactIndex(content: string): number {
|
||||
if (!content) return -1;
|
||||
|
||||
let first = -1;
|
||||
const record = (idx: number) => {
|
||||
if (idx < 0) return;
|
||||
if (first < 0 || idx < first) {
|
||||
first = idx;
|
||||
}
|
||||
};
|
||||
|
||||
for (const marker of RAW_TOOL_MARKERS) {
|
||||
record(content.indexOf(marker));
|
||||
}
|
||||
|
||||
const jsonMatch = jsonToolCallLeakRe.exec(content);
|
||||
if (jsonMatch) {
|
||||
record(jsonMatch.index);
|
||||
}
|
||||
|
||||
const functionMatch = functionToolCallLeakRe.exec(content);
|
||||
if (functionMatch?.[1]) {
|
||||
record(functionMatch.index + functionMatch[0].lastIndexOf(functionMatch[1]));
|
||||
}
|
||||
|
||||
const minimaxMatch = minimaxToolCallLeakRe.exec(content);
|
||||
if (minimaxMatch) {
|
||||
record(minimaxMatch.index);
|
||||
}
|
||||
|
||||
return first;
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import {
|
|||
import { notificationStore } from '@/stores/notifications';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { normalizeChatToolName } from '@/utils/chatIdentifiers';
|
||||
import { stripAssistantOutputArtifacts } from '../assistantOutputHygiene';
|
||||
import type {
|
||||
ChatMessage,
|
||||
ToolExecution,
|
||||
|
|
@ -57,6 +58,7 @@ export function useChat(options: UseChatOptions = {}) {
|
|||
let abortControllerRef: AbortController | null = null;
|
||||
let activeRequestId = 0;
|
||||
let pendingBackendAbort: Promise<void> | null = null;
|
||||
const suppressedRawContentMessageIds = new Set<string>();
|
||||
|
||||
const abortBackendSession = (targetSessionId: string): Promise<void> | null => {
|
||||
const normalizedSessionId = targetSessionId.trim();
|
||||
|
|
@ -240,24 +242,30 @@ export function useChat(options: UseChatOptions = {}) {
|
|||
try {
|
||||
switch (event.type) {
|
||||
case 'content': {
|
||||
if (suppressedRawContentMessageIds.has(assistantId)) {
|
||||
return msg;
|
||||
}
|
||||
const content = extractText(event.data);
|
||||
if (!content) return msg;
|
||||
const visible = stripAssistantOutputArtifacts(content);
|
||||
if (visible.stripped) {
|
||||
suppressedRawContentMessageIds.add(assistantId);
|
||||
}
|
||||
if (!visible.text) return msg;
|
||||
const existing = msg.content || '';
|
||||
// Add to streamEvents for chronological display
|
||||
const updated = addStreamEvent(msg, { type: 'content', content });
|
||||
const updated = addStreamEvent(msg, { type: 'content', content: visible.text });
|
||||
return {
|
||||
...updated,
|
||||
content: existing + content,
|
||||
content: existing + visible.text,
|
||||
};
|
||||
}
|
||||
|
||||
case 'thinking': {
|
||||
const thinking = extractText(event.data);
|
||||
if (!thinking) return msg;
|
||||
// Add thinking to streamEvents
|
||||
const updated = addStreamEvent(msg, { type: 'thinking', thinking });
|
||||
return {
|
||||
...updated,
|
||||
...msg,
|
||||
thinking: (msg.thinking || '') + thinking,
|
||||
};
|
||||
}
|
||||
|
|
@ -268,6 +276,7 @@ export function useChat(options: UseChatOptions = {}) {
|
|||
}
|
||||
|
||||
case 'tool_start': {
|
||||
suppressedRawContentMessageIds.delete(assistantId);
|
||||
const data = (event.data || {}) as {
|
||||
id?: string;
|
||||
name?: string;
|
||||
|
|
@ -304,6 +313,7 @@ export function useChat(options: UseChatOptions = {}) {
|
|||
}
|
||||
|
||||
case 'tool_end': {
|
||||
suppressedRawContentMessageIds.delete(assistantId);
|
||||
const data = event.data as {
|
||||
id?: string;
|
||||
name: string;
|
||||
|
|
@ -406,6 +416,7 @@ export function useChat(options: UseChatOptions = {}) {
|
|||
}
|
||||
|
||||
case 'approval_needed': {
|
||||
suppressedRawContentMessageIds.delete(assistantId);
|
||||
const data = event.data as {
|
||||
command: string;
|
||||
tool_id: string;
|
||||
|
|
@ -486,6 +497,7 @@ export function useChat(options: UseChatOptions = {}) {
|
|||
}
|
||||
|
||||
case 'question': {
|
||||
suppressedRawContentMessageIds.delete(assistantId);
|
||||
const data = event.data as { question_id: string; questions: Array<any> };
|
||||
|
||||
const pendingQuestion: PendingQuestion = {
|
||||
|
|
@ -520,14 +532,22 @@ export function useChat(options: UseChatOptions = {}) {
|
|||
}
|
||||
|
||||
case 'done': {
|
||||
suppressedRawContentMessageIds.delete(assistantId);
|
||||
const tokens = extractTokens(event.data);
|
||||
if (tokens && (tokens.input > 0 || tokens.output > 0)) {
|
||||
return { ...msg, isStreaming: false, pendingTools: [], tokens, workflowStatus: undefined };
|
||||
return {
|
||||
...msg,
|
||||
isStreaming: false,
|
||||
pendingTools: [],
|
||||
tokens,
|
||||
workflowStatus: undefined,
|
||||
};
|
||||
}
|
||||
return { ...msg, isStreaming: false, pendingTools: [], workflowStatus: undefined };
|
||||
}
|
||||
|
||||
case 'error': {
|
||||
suppressedRawContentMessageIds.delete(assistantId);
|
||||
const errorMsg = extractErrorMessage(event.data);
|
||||
// Keep any content streamed before the failure; surface the error
|
||||
// as a distinct, recoverable block rather than overwriting the answer.
|
||||
|
|
@ -603,11 +623,7 @@ export function useChat(options: UseChatOptions = {}) {
|
|||
// event, avoiding a separate preflight request before first token.
|
||||
let currentSessionId = sessionId();
|
||||
|
||||
if (
|
||||
backendAbortBeforeNextSend &&
|
||||
abortedSessionId &&
|
||||
currentSessionId === abortedSessionId
|
||||
) {
|
||||
if (backendAbortBeforeNextSend && abortedSessionId && currentSessionId === abortedSessionId) {
|
||||
await backendAbortBeforeNextSend;
|
||||
if (requestId !== activeRequestId) {
|
||||
return false;
|
||||
|
|
@ -654,9 +670,7 @@ export function useChat(options: UseChatOptions = {}) {
|
|||
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === assistantId
|
||||
? { ...msg, isStreaming: false, error: errorMessage }
|
||||
: msg,
|
||||
msg.id === assistantId ? { ...msg, isStreaming: false, error: errorMessage } : msg,
|
||||
),
|
||||
);
|
||||
await notifyConversationChanged();
|
||||
|
|
|
|||
|
|
@ -521,6 +521,8 @@ func (a *AgenticLoop) executeWithTools(ctx context.Context, sessionID string, me
|
|||
var contentBuilder strings.Builder
|
||||
var thinkingBuilder strings.Builder
|
||||
var toolCalls []providers.ToolCall
|
||||
var suppressLeakedToolContent bool
|
||||
var pendingVisibleContent string
|
||||
|
||||
log.Debug().
|
||||
Str("session_id", sessionID).
|
||||
|
|
@ -542,22 +544,21 @@ func (a *AgenticLoop) executeWithTools(ctx context.Context, sessionID string, me
|
|||
case "content":
|
||||
if data, ok := event.Data.(providers.ContentEvent); ok {
|
||||
attemptEmittedVisibleEvents = true
|
||||
// Check for tool call marker leakage - if detected, stop streaming this chunk.
|
||||
// These markers indicate the model is outputting internal tool call
|
||||
// formatting instead of using the proper tool calling API.
|
||||
if containsToolCallMarker(data.Text) {
|
||||
// Don't append or stream this content
|
||||
if suppressLeakedToolContent {
|
||||
return
|
||||
}
|
||||
// Also check if the accumulated content already has the marker
|
||||
// (in case it arrived in a previous chunk)
|
||||
if containsToolCallMarker(contentBuilder.String()) {
|
||||
visibleText, leakFound := appendVisibleContentBeforeToolLeak(&contentBuilder, &pendingVisibleContent, data.Text)
|
||||
if visibleText != "" {
|
||||
jsonData, _ := json.Marshal(ContentData{Text: visibleText})
|
||||
callback(StreamEvent{Type: "content", Data: jsonData})
|
||||
}
|
||||
if leakFound {
|
||||
// The model started serializing an internal tool call as
|
||||
// assistant prose instead of using the structured tool_calls
|
||||
// channel. Stop forwarding the rest of this provider turn.
|
||||
suppressLeakedToolContent = true
|
||||
return
|
||||
}
|
||||
contentBuilder.WriteString(data.Text)
|
||||
// Forward to callback - send ContentData struct
|
||||
jsonData, _ := json.Marshal(ContentData{Text: data.Text})
|
||||
callback(StreamEvent{Type: "content", Data: jsonData})
|
||||
}
|
||||
|
||||
case "thinking":
|
||||
|
|
@ -665,6 +666,13 @@ func (a *AgenticLoop) executeWithTools(ctx context.Context, sessionID string, me
|
|||
break
|
||||
}
|
||||
|
||||
if err == nil && !suppressLeakedToolContent {
|
||||
if visibleText := flushPendingVisibleContent(&contentBuilder, &pendingVisibleContent); visibleText != "" {
|
||||
jsonData, _ := json.Marshal(ContentData{Text: visibleText})
|
||||
callback(StreamEvent{Type: "content", Data: jsonData})
|
||||
}
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Str("session_id", sessionID).
|
||||
Err(err).
|
||||
|
|
|
|||
|
|
@ -74,6 +74,8 @@ func (a *AgenticLoop) ensureFinalTextResponse(
|
|||
}
|
||||
|
||||
var summaryBuilder strings.Builder
|
||||
var suppressLeakedToolContent bool
|
||||
var pendingVisibleContent string
|
||||
|
||||
// Keep this bounded so a stuck provider stream doesn't drag the whole request.
|
||||
summaryCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
|
|
@ -83,9 +85,17 @@ func (a *AgenticLoop) ensureFinalTextResponse(
|
|||
switch event.Type {
|
||||
case "content":
|
||||
if data, ok := event.Data.(providers.ContentEvent); ok {
|
||||
summaryBuilder.WriteString(data.Text)
|
||||
jsonData, _ := json.Marshal(ContentData{Text: data.Text})
|
||||
callback(StreamEvent{Type: "content", Data: jsonData})
|
||||
if suppressLeakedToolContent {
|
||||
return
|
||||
}
|
||||
visibleText, leakFound := appendVisibleContentBeforeToolLeak(&summaryBuilder, &pendingVisibleContent, data.Text)
|
||||
if visibleText != "" {
|
||||
jsonData, _ := json.Marshal(ContentData{Text: visibleText})
|
||||
callback(StreamEvent{Type: "content", Data: jsonData})
|
||||
}
|
||||
if leakFound {
|
||||
suppressLeakedToolContent = true
|
||||
}
|
||||
}
|
||||
case "done":
|
||||
if data, ok := event.Data.(providers.DoneEvent); ok {
|
||||
|
|
@ -95,6 +105,13 @@ func (a *AgenticLoop) ensureFinalTextResponse(
|
|||
}
|
||||
})
|
||||
|
||||
if summaryErr == nil && !suppressLeakedToolContent {
|
||||
if visibleText := flushPendingVisibleContent(&summaryBuilder, &pendingVisibleContent); visibleText != "" {
|
||||
jsonData, _ := json.Marshal(ContentData{Text: visibleText})
|
||||
callback(StreamEvent{Type: "content", Data: jsonData})
|
||||
}
|
||||
}
|
||||
|
||||
if summaryErr == nil && summaryBuilder.Len() > 0 {
|
||||
summaryMsg := Message{
|
||||
ID: uuid.New().String(),
|
||||
|
|
|
|||
|
|
@ -11,6 +11,21 @@ import (
|
|||
// These patterns catch tool call markup that leaks into content when models are
|
||||
// told not to use tools but still see tool definitions.
|
||||
var (
|
||||
dsmlMarkers = []string{
|
||||
"<|DSML|", // Unicode single pipe (opening)
|
||||
"</|DSML|", // Unicode single pipe (closing)
|
||||
"<||DSML||", // Unicode double pipe (opening) — deepseek-v4-flash
|
||||
"</||DSML||", // Unicode double pipe (closing)
|
||||
"<||DSML||", // ASCII double pipe (opening)
|
||||
"</||DSML||", // ASCII double pipe (closing)
|
||||
"<|DSML|", // ASCII single pipe (opening)
|
||||
"</|DSML|", // ASCII single pipe (closing)
|
||||
"<|/DSML|", // Alternative Unicode closing
|
||||
"<||/DSML||", // Alternative Unicode double-pipe closing
|
||||
"<|/DSML|", // Alternative ASCII closing
|
||||
"<||/DSML||", // Alternative ASCII double-pipe closing
|
||||
}
|
||||
|
||||
// XML-style tool call envelopes: <tool_call>...</tool_call>, <tool_calls>...</tool_calls>,
|
||||
// <function_call>...</function_call>, <function_calls>...</function_calls>
|
||||
xmlToolCallRe = regexp.MustCompile(`(?s)</?(?:tool_calls?|function_calls?)>`)
|
||||
|
|
@ -44,6 +59,12 @@ var (
|
|||
jsonToolCallRe = regexp.MustCompile(
|
||||
`(?:^|\n)[ \t]*(?:` + "```" + `[ \t]*(?:json|JSON)?[ \t]*\n?[ \t]*)?\{[ \t\n]*"name"[ \t]*:[ \t]*"([a-zA-Z_][a-zA-Z0-9_]*)"`,
|
||||
)
|
||||
|
||||
// Plain function-style tool-call leak: some models emit
|
||||
// pulse_read(target_host="...", command="...") as assistant content
|
||||
// instead of a structured tool call. Gate on canonical tool names so a
|
||||
// random prose function call is not stripped.
|
||||
plainFunctionToolCallRe = regexp.MustCompile(`(?:^|[^a-zA-Z0-9_])([a-zA-Z_][a-zA-Z0-9_]*)[ \t\r\n]*\(`)
|
||||
)
|
||||
|
||||
// cleanToolCallArtifacts removes LLM-internal tool call format leakage from content.
|
||||
|
|
@ -55,56 +76,7 @@ func cleanToolCallArtifacts(content string) string {
|
|||
return content
|
||||
}
|
||||
|
||||
// Fast-path: DeepSeek DSML markers (literal string checks, no regex needed).
|
||||
// Includes both single- and double-pipe variants — deepseek-v4-flash
|
||||
// emits the double-pipe form ("<||DSML||tool_calls>"). The single-pipe
|
||||
// list alone left the double-pipe form unsanitised, so users saw the
|
||||
// raw DSML in chat as the assistant's "final response."
|
||||
dsmlMarkers := []string{
|
||||
"<|DSML|", // Unicode single pipe (opening)
|
||||
"</|DSML|", // Unicode single pipe (closing)
|
||||
"<||DSML||", // Unicode double pipe (opening) — deepseek-v4-flash
|
||||
"</||DSML||", // Unicode double pipe (closing)
|
||||
"<|DSML|", // ASCII single pipe (opening)
|
||||
"</|DSML|", // ASCII single pipe (closing)
|
||||
"<||DSML||", // ASCII double pipe (opening)
|
||||
"</||DSML||", // ASCII double pipe (closing)
|
||||
"<|/DSML|", // Alternative Unicode closing
|
||||
"<||/DSML||", // Alternative Unicode double-pipe closing
|
||||
"<|/DSML|", // Alternative ASCII closing
|
||||
"<||/DSML||", // Alternative ASCII double-pipe closing
|
||||
}
|
||||
|
||||
for _, marker := range dsmlMarkers {
|
||||
if idx := strings.Index(content, marker); idx >= 0 {
|
||||
content = strings.TrimSpace(content[:idx])
|
||||
}
|
||||
}
|
||||
|
||||
// Backstop regex catches arbitrary pipe-count variants the fast-path
|
||||
// list above might miss as new model behaviours surface.
|
||||
if loc := dsmlRe.FindStringIndex(content); loc != nil {
|
||||
content = strings.TrimSpace(content[:loc[0]])
|
||||
}
|
||||
|
||||
// Structural patterns: XML-style envelopes
|
||||
if loc := xmlToolCallRe.FindStringIndex(content); loc != nil {
|
||||
content = strings.TrimSpace(content[:loc[0]])
|
||||
}
|
||||
|
||||
// Pipe-delimited markers
|
||||
if loc := pipeMarkerRe.FindStringIndex(content); loc != nil {
|
||||
content = strings.TrimSpace(content[:loc[0]])
|
||||
}
|
||||
|
||||
// MiniMax-style markers
|
||||
if loc := minimaxMarkerRe.FindStringIndex(content); loc != nil {
|
||||
content = strings.TrimSpace(content[:loc[0]])
|
||||
}
|
||||
|
||||
// Plain-JSON tool-call leak from weak local models (qwen2.5 small).
|
||||
// Allowlist-gated to avoid stripping legitimate user JSON.
|
||||
if idx := findJSONToolCallLeak(content); idx >= 0 {
|
||||
if idx := toolCallArtifactIndex(content); idx >= 0 {
|
||||
content = strings.TrimSpace(content[:idx])
|
||||
}
|
||||
|
||||
|
|
@ -114,40 +86,121 @@ func cleanToolCallArtifacts(content string) string {
|
|||
// containsToolCallMarker checks if content contains any known LLM-internal tool call markers.
|
||||
// Used during streaming to detect when to stop forwarding content chunks.
|
||||
func containsToolCallMarker(content string) bool {
|
||||
// Fast-path: DeepSeek DSML literal checks. Covers single- and
|
||||
// double-pipe variants. deepseek-v4-flash uses double-pipe; older
|
||||
// deepseek variants use single. Both leak into content as text
|
||||
// rather than going through the tool-call channel.
|
||||
dsmlMarkers := []string{
|
||||
"<|DSML|", // Unicode single pipe
|
||||
"<||DSML||", // Unicode double pipe (deepseek-v4-flash)
|
||||
"<|DSML|", // ASCII single pipe
|
||||
"<||DSML||", // ASCII double pipe
|
||||
return toolCallArtifactIndex(content) >= 0
|
||||
}
|
||||
|
||||
// toolCallArtifactIndex returns the byte offset of the first known tool-call
|
||||
// artifact in content, or -1 if no artifact is present.
|
||||
func toolCallArtifactIndex(content string) int {
|
||||
if content == "" {
|
||||
return -1
|
||||
}
|
||||
for _, marker := range dsmlMarkers {
|
||||
if strings.Contains(content, marker) {
|
||||
return true
|
||||
|
||||
first := -1
|
||||
record := func(idx int) {
|
||||
if idx < 0 {
|
||||
return
|
||||
}
|
||||
if first < 0 || idx < first {
|
||||
first = idx
|
||||
}
|
||||
}
|
||||
|
||||
// Structural patterns
|
||||
if dsmlRe.MatchString(content) {
|
||||
return true
|
||||
}
|
||||
if xmlToolCallRe.MatchString(content) {
|
||||
return true
|
||||
}
|
||||
if pipeMarkerRe.MatchString(content) {
|
||||
return true
|
||||
}
|
||||
if minimaxMarkerRe.MatchString(content) {
|
||||
return true
|
||||
}
|
||||
if findJSONToolCallLeak(content) >= 0 {
|
||||
return true
|
||||
for _, marker := range dsmlMarkers {
|
||||
if idx := strings.Index(content, marker); idx >= 0 {
|
||||
record(idx)
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
if loc := dsmlRe.FindStringIndex(content); loc != nil {
|
||||
record(loc[0])
|
||||
}
|
||||
if loc := xmlToolCallRe.FindStringIndex(content); loc != nil {
|
||||
record(loc[0])
|
||||
}
|
||||
if loc := pipeMarkerRe.FindStringIndex(content); loc != nil {
|
||||
record(loc[0])
|
||||
}
|
||||
if loc := minimaxMarkerRe.FindStringIndex(content); loc != nil {
|
||||
record(loc[0])
|
||||
}
|
||||
record(findJSONToolCallLeak(content))
|
||||
record(findPlainFunctionToolCallLeak(content))
|
||||
|
||||
return first
|
||||
}
|
||||
|
||||
func appendVisibleContentBeforeToolLeak(
|
||||
builder *strings.Builder,
|
||||
pending *string,
|
||||
text string,
|
||||
) (visibleDelta string, leakFound bool) {
|
||||
if text == "" && (pending == nil || *pending == "") {
|
||||
return "", false
|
||||
}
|
||||
|
||||
pendingText := ""
|
||||
if pending != nil {
|
||||
pendingText = *pending
|
||||
*pending = ""
|
||||
}
|
||||
text = pendingText + text
|
||||
|
||||
existing := builder.String()
|
||||
candidate := existing + text
|
||||
idx := toolCallArtifactIndex(candidate)
|
||||
if idx < 0 {
|
||||
visible, held := splitTrailingPotentialToolNamePrefix(text)
|
||||
if visible != "" {
|
||||
builder.WriteString(visible)
|
||||
}
|
||||
if pending != nil {
|
||||
*pending = held
|
||||
}
|
||||
return visible, false
|
||||
}
|
||||
|
||||
if idx > len(existing) {
|
||||
visibleDelta, _ = splitTrailingPotentialToolNamePrefix(candidate[len(existing):idx])
|
||||
builder.WriteString(visibleDelta)
|
||||
}
|
||||
return visibleDelta, true
|
||||
}
|
||||
|
||||
func flushPendingVisibleContent(builder *strings.Builder, pending *string) string {
|
||||
if pending == nil || *pending == "" {
|
||||
return ""
|
||||
}
|
||||
visible := *pending
|
||||
*pending = ""
|
||||
builder.WriteString(visible)
|
||||
return visible
|
||||
}
|
||||
|
||||
func splitTrailingPotentialToolNamePrefix(content string) (visible string, held string) {
|
||||
if content == "" {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
start := len(content)
|
||||
for start > 0 {
|
||||
ch := content[start-1]
|
||||
if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' {
|
||||
start--
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if start == len(content) {
|
||||
return content, ""
|
||||
}
|
||||
|
||||
token := content[start:]
|
||||
if tools.IsKnownToolNamePrefix(token) {
|
||||
return content[:start], token
|
||||
}
|
||||
return content, ""
|
||||
}
|
||||
|
||||
// findJSONToolCallLeak returns the byte offset to strip from when a plain-JSON
|
||||
|
|
@ -172,3 +225,20 @@ func findJSONToolCallLeak(content string) int {
|
|||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func findPlainFunctionToolCallLeak(content string) int {
|
||||
if content == "" {
|
||||
return -1
|
||||
}
|
||||
matches := plainFunctionToolCallRe.FindAllStringSubmatchIndex(content, -1)
|
||||
for _, m := range matches {
|
||||
if len(m) < 4 || m[2] < 0 || m[3] < 0 {
|
||||
continue
|
||||
}
|
||||
name := content[m[2]:m[3]]
|
||||
if tools.IsKnownToolName(name) {
|
||||
return m[2]
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
package chat
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCleanToolCallArtifacts(t *testing.T) {
|
||||
tests := []struct {
|
||||
|
|
@ -58,6 +61,21 @@ func TestCleanToolCallArtifacts(t *testing.T) {
|
|||
"{\"name\": \"pulse_query\", \"parameters\": {\"action\":\"get\"}}",
|
||||
"",
|
||||
},
|
||||
{
|
||||
"plain function leak: pulse_read",
|
||||
"Let me inspect the device nodes.\npulse_read(target_host=\"current_resource\", command=\"ls /dev | wc -l\")",
|
||||
"Let me inspect the device nodes.",
|
||||
},
|
||||
{
|
||||
"plain function leak inline after prose",
|
||||
"I will check that now. pulse_read(target_host=\"current_resource\", command=\"lsblk\")",
|
||||
"I will check that now.",
|
||||
},
|
||||
{
|
||||
"plain function leak only no prose",
|
||||
"pulse_read(target_host=\"current_resource\", command=\"lsblk\")",
|
||||
"",
|
||||
},
|
||||
// Negative: unrelated JSON the user might share — no "name" field
|
||||
// matching an allowlisted tool, so the sanitiser leaves it alone.
|
||||
{
|
||||
|
|
@ -82,6 +100,11 @@ func TestCleanToolCallArtifacts(t *testing.T) {
|
|||
"You can call the pulse_query tool to look up resources.",
|
||||
"You can call the pulse_query tool to look up resources.",
|
||||
},
|
||||
{
|
||||
"negative: unknown function-style call",
|
||||
"Call helper(target_host=\"current_resource\") in the example.",
|
||||
"Call helper(target_host=\"current_resource\") in the example.",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
@ -122,9 +145,12 @@ func TestContainsToolCallMarker(t *testing.T) {
|
|||
{"json leak pulse_query", "{\"name\": \"pulse_query\", \"parameters\": {}}", true},
|
||||
{"json leak pulse_discovery", "prose\n{\"name\": \"pulse_discovery\", \"parameters\": {}}", true},
|
||||
{"json leak with code-fence", "answer\n```json\n{\"name\": \"pulse_query\"}", true},
|
||||
{"plain function leak pulse_read", "pulse_read(target_host=\"current_resource\", command=\"lsblk\")", true},
|
||||
{"plain function leak after prose", "text. pulse_read(target_host=\"current_resource\")", true},
|
||||
{"json non-tool name", "{\"name\": \"frobnicate\", \"parameters\": {}}", false},
|
||||
{"json unrelated object", "{\"foo\": \"bar\"}", false},
|
||||
{"tool name as prose substring", "the pulse_query tool is useful", false},
|
||||
{"unknown function-style call", "helper(target_host=\"current_resource\")", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
@ -136,3 +162,30 @@ func TestContainsToolCallMarker(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendVisibleContentBeforeToolLeak(t *testing.T) {
|
||||
var builder strings.Builder
|
||||
var pending string
|
||||
delta, leakFound := appendVisibleContentBeforeToolLeak(&builder, &pending, "Let me check. pu")
|
||||
if leakFound {
|
||||
t.Fatal("partial tool name should not be treated as a leak yet")
|
||||
}
|
||||
if delta != "Let me check. " || builder.String() != "Let me check. " || pending != "pu" {
|
||||
t.Fatalf("unexpected first delta=%q builder=%q pending=%q", delta, builder.String(), pending)
|
||||
}
|
||||
|
||||
delta, leakFound = appendVisibleContentBeforeToolLeak(
|
||||
&builder,
|
||||
&pending,
|
||||
"lse_read(target_host=\"current_resource\", command=\"lsblk\")",
|
||||
)
|
||||
if !leakFound {
|
||||
t.Fatal("expected split plain function call to be detected")
|
||||
}
|
||||
if delta != "" {
|
||||
t.Fatalf("expected no visible delta once split call completed, got %q", delta)
|
||||
}
|
||||
if builder.String() != "Let me check. " || pending != "" {
|
||||
t.Fatalf("builder should not append leaked call suffix, got builder=%q pending=%q", builder.String(), pending)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
package tools
|
||||
|
||||
import "sync"
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// IsKnownToolName reports whether name is one of the canonical Pulse tool
|
||||
// names. Used by chat-content sanitisers to gate stripping on a closed
|
||||
|
|
@ -15,6 +18,23 @@ func IsKnownToolName(name string) bool {
|
|||
return ok
|
||||
}
|
||||
|
||||
// IsKnownToolNamePrefix reports whether prefix can still become a canonical
|
||||
// Pulse tool name. Streaming chat sanitizers use this to hold a tiny trailing
|
||||
// token fragment until the next chunk proves whether it is prose or a leaked
|
||||
// tool call.
|
||||
func IsKnownToolNamePrefix(prefix string) bool {
|
||||
if prefix == "" {
|
||||
return false
|
||||
}
|
||||
initKnownToolNames()
|
||||
for _, name := range knownToolNamesList {
|
||||
if strings.HasPrefix(name, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var (
|
||||
knownToolNamesOnce sync.Once
|
||||
knownToolNamesList []string
|
||||
|
|
|
|||
|
|
@ -45,3 +45,34 @@ func TestIsKnownToolNameRejectsUnknown(t *testing.T) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsKnownToolNamePrefix(t *testing.T) {
|
||||
cases := []string{
|
||||
"p",
|
||||
"pulse_",
|
||||
"pulse_re",
|
||||
"pulse_read",
|
||||
"patrol_",
|
||||
"patrol_report",
|
||||
}
|
||||
for _, prefix := range cases {
|
||||
if !IsKnownToolNamePrefix(prefix) {
|
||||
t.Errorf("IsKnownToolNamePrefix(%q) = false, want true", prefix)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsKnownToolNamePrefixRejectsUnknown(t *testing.T) {
|
||||
cases := []string{
|
||||
"",
|
||||
"x",
|
||||
"Pulse_",
|
||||
"helper",
|
||||
"pulse_unknown_tool",
|
||||
}
|
||||
for _, prefix := range cases {
|
||||
if IsKnownToolNamePrefix(prefix) {
|
||||
t.Errorf("IsKnownToolNamePrefix(%q) = true, want false", prefix)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue