Keep Assistant tool-call leaks out of chat output

This commit is contained in:
rcourtman
2026-06-05 20:29:24 +01:00
parent 7067a51680
commit 3d8a926acc
7 changed files with 250 additions and 6 deletions
@@ -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
@@ -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();
@@ -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' }));
@@ -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;
}
@@ -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),
};
}
+44 -1
View File
@@ -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",
+48
View File
@@ -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)
}
}