From 05d00a65a663ec078da8dc302884bcc4126897f8 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 5 Jun 2026 14:05:38 +0100 Subject: [PATCH] Hide raw Assistant tool-call output --- .../v6/internal/subsystems/ai-runtime.md | 9 + .../src/components/AI/Chat/MessageItem.tsx | 60 +++-- .../src/components/AI/Chat/ThinkingBlock.tsx | 113 ++------- .../AI/Chat/__tests__/MessageItem.test.tsx | 103 +++++--- .../AI/Chat/__tests__/ThinkingBlock.test.tsx | 208 ++-------------- .../__tests__/assistantOutputHygiene.test.ts | 31 +++ .../AI/Chat/__tests__/useChat.test.ts | 59 ++++- .../AI/Chat/assistantOutputHygiene.ts | 70 ++++++ .../src/components/AI/Chat/hooks/useChat.ts | 42 ++-- internal/ai/chat/agentic.go | 32 ++- internal/ai/chat/agentic_final.go | 23 +- internal/ai/chat/agentic_sanitize.go | 226 ++++++++++++------ internal/ai/chat/agentic_sanitize_test.go | 55 ++++- internal/ai/tools/names.go | 22 +- internal/ai/tools/names_test.go | 31 +++ 15 files changed, 625 insertions(+), 459 deletions(-) create mode 100644 frontend-modern/src/components/AI/Chat/__tests__/assistantOutputHygiene.test.ts create mode 100644 frontend-modern/src/components/AI/Chat/assistantOutputHygiene.ts diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index c38075ce5..82d454dfd 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -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 diff --git a/frontend-modern/src/components/AI/Chat/MessageItem.tsx b/frontend-modern/src/components/AI/Chat/MessageItem.tsx index a2c182d38..e36564dca 100644 --- a/frontend-modern/src/components/AI/Chat/MessageItem.tsx +++ b/frontend-modern/src/components/AI/Chat/MessageItem.tsx @@ -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 = (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 = (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 = (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 = (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 = (props) => { {/* Stream events - chronological display */} - + {(evt) => ( - {/* Thinking block */} - - - - <> @@ -187,11 +194,18 @@ export const MessageItem: Component = (props) => { {/* Content/text block */} - +
@@ -220,11 +234,11 @@ export const MessageItem: Component = (props) => { {/* Fallback */} - +
diff --git a/frontend-modern/src/components/AI/Chat/ThinkingBlock.tsx b/frontend-modern/src/components/AI/Chat/ThinkingBlock.tsx index 3bd96b5c5..fa5af42d1 100644 --- a/frontend-modern/src/components/AI/Chat/ThinkingBlock.tsx +++ b/frontend-modern/src/components/AI/Chat/ThinkingBlock.tsx @@ -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 = (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 ( -
- {/* Collapsed header - always visible */} - - - {/* Expanded content */} - -
-
-            {sanitizeThinking(props.content)}
-          
-
-
-
- ); -}; +export const ThinkingBlock: Component = (props) => ( +
+
+); diff --git a/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx index b72407ab4..039a78c0f 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx @@ -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(() => ( )); - 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('

Here is the analysis

'); }); + 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(() => ( + + )); + + 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(() => ( )); - 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', () => { )); - // 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(() => ( + + )); + + 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', () => { diff --git a/frontend-modern/src/components/AI/Chat/__tests__/ThinkingBlock.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/ThinkingBlock.test.tsx index 9323f56de..7f13ac6da 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/ThinkingBlock.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/ThinkingBlock.test.tsx @@ -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(() => ); - it('renders "Thinking" label when not streaming', () => { - render(() => ); - 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(() => ); - expect(screen.getByText('Thinking...')).toBeInTheDocument(); + it('renders a neutral streaming-thinking status without reasoning content', () => { + render(() => ); + + 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(() => ); - it('shows correct line and word count', () => { - const content = 'First line\nSecond line\nThird line'; - render(() => ); - 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(() => ); - // 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(() => ); - expect(screen.getByText('1 lines · 2 words')).toBeInTheDocument(); - }); - - it('handles single-line content', () => { - render(() => ); - expect(screen.getByText('1 lines · 3 words')).toBeInTheDocument(); - }); - - // --- Preview text (collapsed state) --- - - it('shows preview text when collapsed', () => { - render(() => ); - 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(() => ); - 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(() => ); - expect(screen.getByText('Real first line')).toBeInTheDocument(); - }); - - // --- Expand/collapse behavior --- - - it('starts collapsed (content not visible)', () => { - render(() => ); - // The sanitized content inside
 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(() => );
-    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(() => );
-    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(() => );
-    // 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 
-    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(() => );
-    const pulseDiv = document.querySelector('.animate-pulse');
-    expect(pulseDiv).not.toBeNull();
-  });
-
-  it('does not apply animate-pulse when not streaming', () => {
-    render(() => );
-    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(() => );
-    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(() => );
-    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 " 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(() => );
-    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(() => );
-    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(() => );
-    // 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(() => );
-    expect(screen.getByText('0 lines · 0 words')).toBeInTheDocument();
-  });
-
-  it('handles whitespace-only content', () => {
-    const whitespace = '   \n   \n   ';
-    render(() => );
-    expect(screen.getByText('0 lines · 0 words')).toBeInTheDocument();
+    expect(container.querySelector('.animate-pulse')).toBeInTheDocument();
   });
 });
diff --git a/frontend-modern/src/components/AI/Chat/__tests__/assistantOutputHygiene.test.ts b/frontend-modern/src/components/AI/Chat/__tests__/assistantOutputHygiene.test.ts
new file mode 100644
index 000000000..077500243
--- /dev/null
+++ b/frontend-modern/src/components/AI/Chat/__tests__/assistantOutputHygiene.test.ts
@@ -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,
+    });
+  });
+});
diff --git a/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts b/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts
index 2b7e69d8b..3cd73f51d 100644
--- a/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts
+++ b/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts
@@ -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();
     });
   });
diff --git a/frontend-modern/src/components/AI/Chat/assistantOutputHygiene.ts b/frontend-modern/src/components/AI/Chat/assistantOutputHygiene.ts
new file mode 100644
index 000000000..cbc6d696c
--- /dev/null
+++ b/frontend-modern/src/components/AI/Chat/assistantOutputHygiene.ts
@@ -0,0 +1,70 @@
+const RAW_TOOL_MARKERS = [
+  '<|DSML|',
+  '',
+  '<|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;
+}
diff --git a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts
index 4ba784470..b7c417795 100644
--- a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts
+++ b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts
@@ -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 | null = null;
+  const suppressedRawContentMessageIds = new Set();
 
   const abortBackendSession = (targetSessionId: string): Promise | 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 };
 
               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();
diff --git a/internal/ai/chat/agentic.go b/internal/ai/chat/agentic.go
index db37e9bcd..34b6fa5ae 100644
--- a/internal/ai/chat/agentic.go
+++ b/internal/ai/chat/agentic.go
@@ -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).
diff --git a/internal/ai/chat/agentic_final.go b/internal/ai/chat/agentic_final.go
index d727c5909..bd13d82e6 100644
--- a/internal/ai/chat/agentic_final.go
+++ b/internal/ai/chat/agentic_final.go
@@ -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(),
diff --git a/internal/ai/chat/agentic_sanitize.go b/internal/ai/chat/agentic_sanitize.go
index 10a98c39f..c80e17002 100644
--- a/internal/ai/chat/agentic_sanitize.go
+++ b/internal/ai/chat/agentic_sanitize.go
@@ -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)
+		"..., ...,
 	// ..., ...
 	xmlToolCallRe = regexp.MustCompile(`(?s)`)
@@ -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)
-		"= 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
+}
diff --git a/internal/ai/chat/agentic_sanitize_test.go b/internal/ai/chat/agentic_sanitize_test.go
index 48538140a..1cc28d9d8 100644
--- a/internal/ai/chat/agentic_sanitize_test.go
+++ b/internal/ai/chat/agentic_sanitize_test.go
@@ -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)
+	}
+}
diff --git a/internal/ai/tools/names.go b/internal/ai/tools/names.go
index cddb6e182..b59b010e1 100644
--- a/internal/ai/tools/names.go
+++ b/internal/ai/tools/names.go
@@ -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
diff --git a/internal/ai/tools/names_test.go b/internal/ai/tools/names_test.go
index bd21ed231..d394381ec 100644
--- a/internal/ai/tools/names_test.go
+++ b/internal/ai/tools/names_test.go
@@ -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)
+		}
+	}
+}