diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 03854e340..192e2214f 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -174,7 +174,10 @@ runtime cost control, and shared AI transport surfaces. 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. Completed tool rows in the drawer may show compact tool + frontend rendering. Compacted no-whitespace internal prelude text attached + to a leaked tool invocation is part of that same artifact and must be + suppressed or retracted from the current stream segment instead of rendered + as assistant prose. Completed tool rows in the drawer may show compact tool name, action summary, status, and an explicit details affordance, but raw tool input/output JSON must not render in the default transcript. Token accounting and other provider metadata remain runtime/accounting data, not 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 f9f88cba5..586ff4514 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/assistantOutputHygiene.test.ts +++ b/frontend-modern/src/components/AI/Chat/__tests__/assistantOutputHygiene.test.ts @@ -36,6 +36,15 @@ describe('stripAssistantOutputArtifacts', () => { expect(result.stripped).toBe(true); }); + it('suppresses compacted internal prose before raw function-call leaks', () => { + const result = stripAssistantOutputArtifacts( + 'I\'llcheckthedevicenodesinsidethecontainertoanswerthat.Letmecounttheentriesin/devandlisttheblockdevices.pulse_read(target_host="current_resource", command="lsblk")', + ); + + expect(result.text).toBe(''); + 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.', @@ -62,6 +71,29 @@ describe('stripAssistantOutputArtifacts', () => { expect(flushPendingAssistantOutputText(state)).toBe(''); }); + it('asks callers to replace already emitted compacted prose when a split leak completes', () => { + const state = createAssistantOutputArtifactStreamState(); + const first = + "I'llcheckthedevicenodesinsidethecontainertoanswerthat.Letmecounttheentriesin/devandlisttheblockdevices."; + + expect(appendVisibleTextBeforeAssistantOutputArtifacts(state, first)).toEqual({ + text: first, + stripped: false, + }); + expect( + appendVisibleTextBeforeAssistantOutputArtifacts( + state, + 'pulse_read(target_host="current_resource", command="lsblk")', + ), + ).toEqual({ + text: '', + stripped: true, + previousVisibleText: first, + replacementText: '', + }); + expect(flushPendingAssistantOutputText(state)).toBe(''); + }); + it('releases a held prefix when the next delta proves it is normal prose', () => { const state = createAssistantOutputArtifactStreamState(); 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 f79317533..216ab8132 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts +++ b/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts @@ -842,6 +842,32 @@ describe('useChat', () => { dispose(); }); + it('retracts compacted internal prose when a split tool-call leak completes', async () => { + const { getFireEvent } = setupWithEventCapture(); + const { value: chat, dispose } = withRoot(() => useChat({ sessionId: 's' })); + + await chat.sendMessage('how many devices in this'); + const fire = getFireEvent(); + const compacted = + "I'llcheckthedevicenodesinsidethecontainertoanswerthat.Letmecounttheentriesin/devandlisttheblockdevices."; + + fire({ type: 'content', data: compacted }); + expect(chat.messages().find((m) => m.role === 'assistant')?.content).toBe(compacted); + + fire({ + type: 'content', + data: 'pulse_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(''); + expect(assistant.content).not.toContain('pulse_read'); + expect(assistant.content).not.toContain('raw arguments'); + expect(assistant.streamEvents?.filter((e) => e.type === 'content')).toEqual([]); + 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' })); diff --git a/frontend-modern/src/components/AI/Chat/assistantOutputHygiene.ts b/frontend-modern/src/components/AI/Chat/assistantOutputHygiene.ts index f9d6cc863..7b2479f1f 100644 --- a/frontend-modern/src/components/AI/Chat/assistantOutputHygiene.ts +++ b/frontend-modern/src/components/AI/Chat/assistantOutputHygiene.ts @@ -49,7 +49,11 @@ export function stripAssistantOutputArtifacts(content: string): { if (idx < 0) { return { text: content, stripped: false }; } - return { text: content.slice(0, idx).trimEnd(), stripped: true }; + const visiblePrefix = content.slice(0, idx).trim(); + return { + text: isCompactedToolPrelude(visiblePrefix) ? '' : visiblePrefix, + stripped: true, + }; } export function appendVisibleTextBeforeAssistantOutputArtifacts( @@ -58,6 +62,8 @@ export function appendVisibleTextBeforeAssistantOutputArtifacts( ): { text: string; stripped: boolean; + previousVisibleText?: string; + replacementText?: string; } { if (!content && !state.pendingText) { return { text: '', stripped: false }; @@ -77,6 +83,17 @@ export function appendVisibleTextBeforeAssistantOutputArtifacts( } const safeText = candidate.slice(0, idx).trimEnd(); + if (isCompactedToolPrelude(safeText)) { + const previousVisibleText = state.visibleText; + state.visibleText = ''; + state.pendingText = ''; + return { + text: '', + stripped: true, + previousVisibleText, + replacementText: '', + }; + } const visibleDelta = safeText.length > existing.length ? safeText.slice(existing.length) : ''; state.visibleText = safeText; state.pendingText = ''; @@ -180,3 +197,23 @@ function isKnownAssistantToolNamePrefix(prefix: string): boolean { /^patrol_[a-zA-Z0-9_]*$/.test(prefix) ); } + +function isCompactedToolPrelude(content: string): boolean { + const trimmed = content.trim(); + if (!trimmed) return false; + + let letters = 0; + let whitespace = 0; + for (const char of trimmed) { + if (/\p{L}/u.test(char)) { + letters += 1; + } + if (/\s/.test(char)) { + whitespace += 1; + } + } + + if (letters < 16) return false; + if (whitespace === 0) return true; + return letters >= 48 && whitespace <= 1; +} diff --git a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts index e79fd25c4..3e7c26c55 100644 --- a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts +++ b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts @@ -251,6 +251,53 @@ export function useChat(options: UseChatOptions = {}) { return `${existing} ${content}`; }; + const appendTextAfterBoundary = (existing: string, content: string): string => { + if (!existing || !content) { + return existing + content; + } + if (/\s$/.test(existing) || /^\s|^[,.;:!?)]/.test(content)) { + return existing + content; + } + return `${existing} ${content}`; + }; + + const replaceCurrentAssistantOutputSegment = ( + msg: ChatMessage, + previousVisibleText: string, + replacementText: string, + ): ChatMessage => { + const events = msg.streamEvents || []; + let boundaryIndex = -1; + for (let i = events.length - 1; i >= 0; i -= 1) { + if (events[i].type !== 'content' && events[i].type !== 'thinking') { + boundaryIndex = i; + break; + } + } + + const retainedEvents = [ + ...events.slice(0, boundaryIndex + 1), + ...events.slice(boundaryIndex + 1).filter((evt) => evt.type !== 'content'), + ]; + const streamEvents = replacementText + ? [...retainedEvents, { type: 'content' as const, content: replacementText }] + : retainedEvents; + + let content = msg.content || ''; + if (previousVisibleText && content.endsWith(previousVisibleText)) { + content = content.slice(0, -previousVisibleText.length); + } + content = replacementText + ? appendTextAfterBoundary(content, replacementText) + : content.trimEnd(); + + return { + ...msg, + content, + streamEvents, + }; + }; + // Process stream events const extractText = (value: unknown): string => { if (typeof value === 'string') return value; @@ -444,12 +491,20 @@ export function useChat(options: UseChatOptions = {}) { if (visible.stripped) { suppressedRawContentMessageIds.add(assistantId); } - if (!visible.text) return msg; + const baseMsg = + visible.replacementText !== undefined + ? replaceCurrentAssistantOutputSegment( + msg, + visible.previousVisibleText || '', + visible.replacementText, + ) + : msg; + if (!visible.text) return baseMsg; // Add to streamEvents for chronological display - const updated = addStreamEvent(msg, { type: 'content', content: visible.text }); + const updated = addStreamEvent(baseMsg, { type: 'content', content: visible.text }); return { ...updated, - content: appendMessageContent(msg, visible.text), + content: appendMessageContent(baseMsg, visible.text), }; } diff --git a/internal/ai/chat/agentic_sanitize.go b/internal/ai/chat/agentic_sanitize.go index c80e17002..a972a9bb6 100644 --- a/internal/ai/chat/agentic_sanitize.go +++ b/internal/ai/chat/agentic_sanitize.go @@ -3,6 +3,7 @@ package chat import ( "regexp" "strings" + "unicode" "github.com/rcourtman/pulse-go-rewrite/internal/ai/tools" ) @@ -77,7 +78,11 @@ func cleanToolCallArtifacts(content string) string { } if idx := toolCallArtifactIndex(content); idx >= 0 { - content = strings.TrimSpace(content[:idx]) + prefix := strings.TrimSpace(content[:idx]) + if isCompactedToolPrelude(prefix) { + return "" + } + content = prefix } return content @@ -161,8 +166,20 @@ func appendVisibleContentBeforeToolLeak( } if idx > len(existing) { + if isCompactedToolPrelude(candidate[:idx]) { + builder.Reset() + if pending != nil { + *pending = "" + } + return "", true + } visibleDelta, _ = splitTrailingPotentialToolNamePrefix(candidate[len(existing):idx]) builder.WriteString(visibleDelta) + } else if isCompactedToolPrelude(candidate[:idx]) { + builder.Reset() + if pending != nil { + *pending = "" + } } return visibleDelta, true } @@ -203,6 +220,32 @@ func splitTrailingPotentialToolNamePrefix(content string) (visible string, held return content, "" } +func isCompactedToolPrelude(content string) bool { + trimmed := strings.TrimSpace(content) + if trimmed == "" { + return false + } + + letters := 0 + whitespace := 0 + for _, ch := range trimmed { + if unicode.IsLetter(ch) { + letters++ + } + if unicode.IsSpace(ch) { + whitespace++ + } + } + + if letters < 16 { + return false + } + if whitespace == 0 { + return true + } + return letters >= 48 && whitespace <= 1 +} + // findJSONToolCallLeak returns the byte offset to strip from when a plain-JSON // tool-call leak is found, or -1 if none. A match must (a) sit at start of // content or after a newline, (b) be a JSON object whose first key is "name", diff --git a/internal/ai/chat/agentic_sanitize_test.go b/internal/ai/chat/agentic_sanitize_test.go index 1cc28d9d8..6d965441a 100644 --- a/internal/ai/chat/agentic_sanitize_test.go +++ b/internal/ai/chat/agentic_sanitize_test.go @@ -71,6 +71,11 @@ func TestCleanToolCallArtifacts(t *testing.T) { "I will check that now. pulse_read(target_host=\"current_resource\", command=\"lsblk\")", "I will check that now.", }, + { + "plain function leak with compacted internal prelude", + "I'llcheckthedevicenodesinsidethecontainertoanswerthat.Letmecounttheentriesin/devandlisttheblockdevices.pulse_read(target_host=\"current_resource\", command=\"lsblk\")", + "", + }, { "plain function leak only no prose", "pulse_read(target_host=\"current_resource\", command=\"lsblk\")", @@ -189,3 +194,46 @@ func TestAppendVisibleContentBeforeToolLeak(t *testing.T) { t.Fatalf("builder should not append leaked call suffix, got builder=%q pending=%q", builder.String(), pending) } } + +func TestAppendVisibleContentBeforeToolLeak_DropsCompactedPrelude(t *testing.T) { + var builder strings.Builder + var pending string + + delta, leakFound := appendVisibleContentBeforeToolLeak( + &builder, + &pending, + "I'llcheckthedevicenodesinsidethecontainertoanswerthat.Letmecounttheentriesin/devandlisttheblockdevices.pulse_read(target_host=\"current_resource\", command=\"lsblk\")", + ) + if !leakFound { + t.Fatal("expected compacted raw function call to be detected") + } + if delta != "" || builder.String() != "" || pending != "" { + t.Fatalf("compacted prelude should be suppressed, got delta=%q builder=%q pending=%q", delta, builder.String(), pending) + } +} + +func TestAppendVisibleContentBeforeToolLeak_ClearsCompactedPreludeWhenSplit(t *testing.T) { + var builder strings.Builder + var pending string + + first := "I'llcheckthedevicenodesinsidethecontainertoanswerthat.Letmecounttheentriesin/devandlisttheblockdevices." + delta, leakFound := appendVisibleContentBeforeToolLeak(&builder, &pending, first) + if leakFound { + t.Fatal("first compacted chunk should not be classified as a tool leak by itself") + } + if delta != first || builder.String() != first { + t.Fatalf("unexpected first compacted delta=%q builder=%q", delta, builder.String()) + } + + delta, leakFound = appendVisibleContentBeforeToolLeak( + &builder, + &pending, + "pulse_read(target_host=\"current_resource\", command=\"lsblk\")", + ) + if !leakFound { + t.Fatal("expected split compacted raw function call to be detected") + } + if delta != "" || builder.String() != "" || pending != "" { + t.Fatalf("compacted prelude should be cleared after split leak, got delta=%q builder=%q pending=%q", delta, builder.String(), pending) + } +}