diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 82ca9687b..60b619067 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -120,7 +120,9 @@ runtime cost control, and shared AI transport surfaces. `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. + streaming, persistence, and frontend rendering. Token accounting and other + provider metadata remain runtime/accounting data, not normal transcript + prose. 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 a141bc0f5..1b186d270 100644 --- a/frontend-modern/src/components/AI/Chat/MessageItem.tsx +++ b/frontend-modern/src/components/AI/Chat/MessageItem.tsx @@ -310,14 +310,6 @@ export const MessageItem: Component = (props) => { - - -
- - {props.message.tokens!.input} in · {props.message.tokens!.output} out - -
-
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 ba6bbf2ae..18d8702cd 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx @@ -374,7 +374,7 @@ describe('MessageItem', () => { }); describe('token display', () => { - it('shows token counts when tokens are provided and not streaming', () => { + it('keeps token counts out of the visible transcript', () => { render(() => ( { /> )); - expect(screen.getByText('500 in · 200 out')).toBeInTheDocument(); - }); - - it('does not show token counts when streaming', () => { - render(() => ( - - )); - expect(screen.queryByText('500 in · 200 out')).not.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 index 077500243..f9f88cba5 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/assistantOutputHygiene.test.ts +++ b/frontend-modern/src/components/AI/Chat/__tests__/assistantOutputHygiene.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { stripAssistantOutputArtifacts } from '../assistantOutputHygiene'; +import { + appendVisibleTextBeforeAssistantOutputArtifacts, + createAssistantOutputArtifactStreamState, + flushPendingAssistantOutputText, + stripAssistantOutputArtifacts, +} from '../assistantOutputHygiene'; describe('stripAssistantOutputArtifacts', () => { it('strips plain Pulse function-call leaks while preserving prose before them', () => { @@ -22,10 +27,52 @@ describe('stripAssistantOutputArtifacts', () => { expect(result.stripped).toBe(true); }); + it('strips pulse-like tool calls even when the frontend has not learned a new tool name yet', () => { + const result = stripAssistantOutputArtifacts( + 'Checking it now.\npulse_future_tool(target_host="current_resource")', + ); + + expect(result.text).toBe('Checking it 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, }); }); + + it('holds split tool-name prefixes until the next stream delta proves the shape', () => { + const state = createAssistantOutputArtifactStreamState(); + + expect(appendVisibleTextBeforeAssistantOutputArtifacts(state, 'I will check pu')).toEqual({ + text: 'I will check ', + stripped: false, + }); + expect( + appendVisibleTextBeforeAssistantOutputArtifacts( + state, + 'lse_read(target_host="current_resource", command="lsblk")', + ), + ).toEqual({ + text: '', + stripped: true, + }); + expect(flushPendingAssistantOutputText(state)).toBe(''); + }); + + it('releases a held prefix when the next delta proves it is normal prose', () => { + const state = createAssistantOutputArtifactStreamState(); + + expect(appendVisibleTextBeforeAssistantOutputArtifacts(state, 'The p')).toEqual({ + text: 'The ', + stripped: false, + }); + expect(appendVisibleTextBeforeAssistantOutputArtifacts(state, 'latform is healthy.')).toEqual({ + text: 'platform is healthy.', + stripped: false, + }); + expect(flushPendingAssistantOutputText(state)).toBe(''); + }); }); 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 7559dcb2d..722fee0ab 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts +++ b/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts @@ -800,6 +800,31 @@ describe('useChat', () => { dispose(); }); + it('strips serialized Pulse tool calls split across streamed content deltas', 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 check pu' }); + fire({ + type: 'content', + data: 'lse_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 check '); + expect(assistant.content).not.toContain('pulse_read'); + expect(assistant.content).not.toContain('target_host'); + expect(assistant.content).not.toContain('raw arguments'); + expect(assistant.streamEvents?.filter((e) => e.type === 'content')).toEqual([ + { type: 'content', content: 'I will check ' }, + ]); + 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' })); @@ -821,7 +846,7 @@ describe('useChat', () => { const assistant = chat.messages().find((m) => m.role === 'assistant')!; expect(assistant.content).toBe( - 'I will inspect the device nodes.There are 42 device entries.', + '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'); diff --git a/frontend-modern/src/components/AI/Chat/assistantOutputHygiene.ts b/frontend-modern/src/components/AI/Chat/assistantOutputHygiene.ts index cbc6d696c..f9d6cc863 100644 --- a/frontend-modern/src/components/AI/Chat/assistantOutputHygiene.ts +++ b/frontend-modern/src/components/AI/Chat/assistantOutputHygiene.ts @@ -3,10 +3,14 @@ const RAW_TOOL_MARKERS = [ ' existing.length ? safeText.slice(existing.length) : ''; + state.visibleText = safeText; + state.pendingText = ''; + return { text: visibleDelta, stripped: true }; +} + +export function flushPendingAssistantOutputText(state: AssistantOutputArtifactStreamState): string { + if (!state.pendingText) { + return ''; + } + const text = state.pendingText; + state.pendingText = ''; + state.visibleText += text; + return text; +} + function assistantOutputArtifactIndex(content: string): number { if (!content) return -1; @@ -51,15 +108,8 @@ function assistantOutputArtifactIndex(content: string): number { 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])); - } + record(findJSONToolCallLeak(content)); + record(findFunctionToolCallLeak(content)); const minimaxMatch = minimaxToolCallLeakRe.exec(content); if (minimaxMatch) { @@ -68,3 +118,65 @@ function assistantOutputArtifactIndex(content: string): number { return first; } + +function findJSONToolCallLeak(content: string): number { + for (const match of content.matchAll(jsonToolCallLeakRe)) { + const name = match[1] || ''; + if (isAssistantToolLikeName(name)) { + return match.index ?? -1; + } + } + return -1; +} + +function findFunctionToolCallLeak(content: string): number { + for (const match of content.matchAll(functionToolCallLeakRe)) { + const name = match[1] || ''; + if (isAssistantToolLikeName(name)) { + return (match.index ?? 0) + match[0].lastIndexOf(name); + } + } + return -1; +} + +function splitTrailingPotentialToolNamePrefix(content: string): { + visible: string; + held: string; +} { + if (!content) { + return { visible: '', held: '' }; + } + + let start = content.length; + while (start > 0 && isToolNameCharacter(content[start - 1])) { + start -= 1; + } + + if (start === content.length) { + return { visible: content, held: '' }; + } + + const token = content.slice(start); + if (isKnownAssistantToolNamePrefix(token)) { + return { visible: content.slice(0, start), held: token }; + } + return { visible: content, held: '' }; +} + +function isToolNameCharacter(char: string): boolean { + return /[a-zA-Z0-9_]/.test(char); +} + +function isAssistantToolLikeName(name: string): boolean { + return /^(?:pulse|patrol)_[a-zA-Z0-9_]+$/.test(name); +} + +function isKnownAssistantToolNamePrefix(prefix: string): boolean { + if (!prefix) return false; + return ( + 'pulse_'.startsWith(prefix) || + 'patrol_'.startsWith(prefix) || + /^pulse_[a-zA-Z0-9_]*$/.test(prefix) || + /^patrol_[a-zA-Z0-9_]*$/.test(prefix) + ); +} diff --git a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts index 8076bacfe..45db40c33 100644 --- a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts +++ b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts @@ -10,7 +10,13 @@ import { import { notificationStore } from '@/stores/notifications'; import { logger } from '@/utils/logger'; import { normalizeChatToolName } from '@/utils/chatIdentifiers'; -import { stripAssistantOutputArtifacts } from '../assistantOutputHygiene'; +import { + appendVisibleTextBeforeAssistantOutputArtifacts, + createAssistantOutputArtifactStreamState, + flushPendingAssistantOutputText, + stripAssistantOutputArtifacts, + type AssistantOutputArtifactStreamState, +} from '../assistantOutputHygiene'; import type { ChatMessage, ToolExecution, @@ -72,6 +78,25 @@ export function useChat(options: UseChatOptions = {}) { let pendingBackendAbort: Promise | null = null; let isDrainingQueuedFollowUps = false; const suppressedRawContentMessageIds = new Set(); + const outputArtifactStreamStates = new Map(); + + const outputArtifactStateFor = (assistantId: string) => { + let state = outputArtifactStreamStates.get(assistantId); + if (!state) { + state = createAssistantOutputArtifactStreamState(); + outputArtifactStreamStates.set(assistantId, state); + } + return state; + }; + + const clearOutputArtifactState = (assistantId: string) => { + outputArtifactStreamStates.delete(assistantId); + }; + + const clearSuppressedOutputBoundary = (assistantId: string) => { + suppressedRawContentMessageIds.delete(assistantId); + clearOutputArtifactState(assistantId); + }; const abortBackendSession = (targetSessionId: string): Promise | null => { const normalizedSessionId = targetSessionId.trim(); @@ -201,6 +226,24 @@ export function useChat(options: UseChatOptions = {}) { }; }; + const appendMessageContent = (msg: ChatMessage, content: string): string => { + const existing = msg.content || ''; + if (!existing || !content) { + return existing + content; + } + + const events = msg.streamEvents || []; + const lastEvent = events[events.length - 1]; + if (!lastEvent || lastEvent.type === 'content') { + return existing + content; + } + + if (/\s$/.test(existing) || /^\s|^[,.;:!?)]/.test(content)) { + return existing + content; + } + return `${existing} ${content}`; + }; + // Process stream events const extractText = (value: unknown): string => { if (typeof value === 'string') return value; @@ -387,17 +430,19 @@ export function useChat(options: UseChatOptions = {}) { } const content = extractText(event.data); if (!content) return msg; - const visible = stripAssistantOutputArtifacts(content); + const visible = appendVisibleTextBeforeAssistantOutputArtifacts( + outputArtifactStateFor(assistantId), + 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: visible.text }); return { ...updated, - content: existing + visible.text, + content: appendMessageContent(msg, visible.text), }; } @@ -416,7 +461,7 @@ export function useChat(options: UseChatOptions = {}) { } case 'tool_start': { - suppressedRawContentMessageIds.delete(assistantId); + clearSuppressedOutputBoundary(assistantId); const data = (event.data || {}) as { id?: string; name?: string; @@ -453,7 +498,7 @@ export function useChat(options: UseChatOptions = {}) { } case 'tool_end': { - suppressedRawContentMessageIds.delete(assistantId); + clearSuppressedOutputBoundary(assistantId); const data = event.data as { id?: string; name: string; @@ -556,7 +601,7 @@ export function useChat(options: UseChatOptions = {}) { } case 'approval_needed': { - suppressedRawContentMessageIds.delete(assistantId); + clearSuppressedOutputBoundary(assistantId); const data = event.data as { command: string; tool_id: string; @@ -637,7 +682,7 @@ export function useChat(options: UseChatOptions = {}) { } case 'question': { - suppressedRawContentMessageIds.delete(assistantId); + clearSuppressedOutputBoundary(assistantId); const data = event.data as { question_id: string; questions: Array }; const pendingQuestion: PendingQuestion = { @@ -672,22 +717,37 @@ export function useChat(options: UseChatOptions = {}) { } case 'done': { + const pendingText = suppressedRawContentMessageIds.has(assistantId) + ? '' + : flushPendingAssistantOutputText(outputArtifactStateFor(assistantId)); suppressedRawContentMessageIds.delete(assistantId); + clearOutputArtifactState(assistantId); + const flushedMsg = pendingText + ? { + ...addStreamEvent(msg, { type: 'content', content: pendingText }), + content: appendMessageContent(msg, pendingText), + } + : msg; const tokens = extractTokens(event.data); if (tokens && (tokens.input > 0 || tokens.output > 0)) { return { - ...msg, + ...flushedMsg, isStreaming: false, pendingTools: [], tokens, workflowStatus: undefined, }; } - return { ...msg, isStreaming: false, pendingTools: [], workflowStatus: undefined }; + return { + ...flushedMsg, + isStreaming: false, + pendingTools: [], + workflowStatus: undefined, + }; } case 'error': { - suppressedRawContentMessageIds.delete(assistantId); + clearSuppressedOutputBoundary(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. @@ -922,12 +982,14 @@ export function useChat(options: UseChatOptions = {}) { setMessages( msgs.map((m) => { const toolCalls = m.tool_calls || []; + const content = + m.role === 'assistant' ? stripAssistantOutputArtifacts(m.content).text : m.content; const streamEvents = - m.role === 'assistant' ? buildPersistedStreamEvents(m.content, toolCalls) : undefined; + m.role === 'assistant' ? buildPersistedStreamEvents(content, toolCalls) : undefined; return { id: m.id, role: m.role, - content: m.content, + content, timestamp: new Date(m.timestamp), model: m.model, toolCalls,