Hide raw Assistant tool-call output

This commit is contained in:
rcourtman 2026-06-05 14:05:38 +01:00
parent df203b0ce6
commit 05d00a65a6
15 changed files with 625 additions and 459 deletions

View file

@ -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

View file

@ -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>

View file

@ -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>
);

View file

@ -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', () => {

View file

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

View file

@ -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,
});
});
});

View file

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

View file

@ -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;
}

View file

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

View file

@ -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).

View file

@ -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(),

View file

@ -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 ("<DSMLtool_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
}

View file

@ -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)
}
}

View file

@ -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

View file

@ -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)
}
}
}