Skip to content

Commit 39d20af

Browse files
committed
fix(chat): sanitize UI messages before model conversion
1 parent 6c2b08c commit 39d20af

4 files changed

Lines changed: 130 additions & 38 deletions

File tree

pages/index.vue

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,11 @@ interface KbToolOutput {
424424
latencyMs?: number
425425
}
426426
427+
/** Persisted on reload so citations render without synthetic tool parts in the API payload. */
428+
interface ChatMessageMetadata {
429+
sources?: ConverseSource[]
430+
}
431+
427432
const { t } = useI18n()
428433
const store = useDocumentsStore()
429434
const input = ref('')
@@ -543,27 +548,14 @@ async function loadConversation(id: string) {
543548
? (m.parts as Array<{ type: string }>)
544549
: [{ type: 'text', text: m.content }]
545550
546-
// Inject a synthetic tool-output part for assistant messages that have
547-
// stored sources but no tool call in their parts (e.g. older messages).
548551
const storedSources = Array.isArray(m.sources) ? (m.sources as ConverseSource[]) : []
549-
const hasTool = rawParts.some((p) => p.type.startsWith('tool-'))
550-
if (m.role === 'assistant' && storedSources.length > 0 && !hasTool) {
551-
rawParts.push({
552-
type: 'tool-searchKnowledgeBase',
553-
state: 'output-available',
554-
output: {
555-
context: '',
556-
sources: storedSources,
557-
results: storedSources,
558-
count: storedSources.length,
559-
},
560-
} as { type: string })
561-
}
552+
const partsForApi = rawParts.filter((p) => !p.type.startsWith('tool-') && p.type !== 'dynamic-tool')
562553
563554
return {
564555
id: m.id,
565556
role: m.role as 'user' | 'assistant' | 'system',
566-
parts: rawParts,
557+
parts: partsForApi,
558+
metadata: storedSources.length ? { sources: storedSources } satisfies ChatMessageMetadata : undefined,
567559
}
568560
}) as unknown as UIMessage[]
569561
expandedSet.clear()
@@ -691,7 +683,9 @@ const displayMessages = computed<DisplayMessage[]>(() => {
691683
m.role === 'assistant' && !text.trim() && toolOutput != null && toolCalled
692684
const displayText = assistantNoTextButRetrieved ? t('chat.noModelReply') : text
693685
694-
const sources = normalizeToolSources(toolOutput?.sources)
686+
const metaSources = (m.metadata as ChatMessageMetadata | undefined)?.sources
687+
const sources = normalizeToolSources(toolOutput?.sources ?? metaSources)
688+
const hadKbSearch = toolCalled || (metaSources?.length ?? 0) > 0
695689
// The assistant "cited" only if its text actually contains a [n] reference
696690
// matching one of the returned sources. Retrieving chunks alone isn't enough.
697691
const cited = m.role === 'assistant'
@@ -704,7 +698,7 @@ const displayMessages = computed<DisplayMessage[]>(() => {
704698
html: m.role === 'assistant' ? renderMarkdown(displayText) : '',
705699
sources,
706700
results: toolOutput?.results,
707-
searched: m.role === 'assistant' ? (toolCalled ? true : (text ? false : null)) : null,
701+
searched: m.role === 'assistant' ? (hadKbSearch ? true : (text ? false : null)) : null,
708702
cited,
709703
latencyMs: toolOutput?.latencyMs,
710704
}

server/utils/agent-messages.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import type { UIMessage } from 'ai'
2+
3+
const COMPLETED_TOOL_STATES = new Set([
4+
'output-available',
5+
'output-error',
6+
'output-denied',
7+
'output',
8+
])
9+
10+
function isToolPart(part: { type: string }): boolean {
11+
return part.type.startsWith('tool-') || part.type === 'dynamic-tool'
12+
}
13+
14+
function isCompletedToolPart(part: { type: string; state?: string; toolCallId?: string }): boolean {
15+
if (!isToolPart(part)) return false
16+
const state = part.state
17+
if (!state || !COMPLETED_TOOL_STATES.has(state)) return false
18+
return typeof part.toolCallId === 'string' && part.toolCallId.length > 0
19+
}
20+
21+
/**
22+
* Normalize UI messages before convertToModelMessages / streamText.
23+
*
24+
* Incomplete tool parts (mid-stream) and synthetic tool parts rehydrated from DB
25+
* (missing toolCallId) produce invalid ModelMessage[] and trigger:
26+
* "The messages do not match the ModelMessage[] schema."
27+
*/
28+
export function sanitizeUIMessagesForAgent(messages: UIMessage[]): UIMessage[] {
29+
const out: UIMessage[] = []
30+
31+
for (const message of messages) {
32+
if (message.role === 'user' || message.role === 'system') {
33+
out.push(message)
34+
continue
35+
}
36+
37+
if (message.role !== 'assistant') continue
38+
39+
const parts = (message.parts ?? []).filter((part) => {
40+
if (part.type === 'text' || part.type === 'reasoning') return true
41+
if (isToolPart(part)) return isCompletedToolPart(part as { type: string; state?: string; toolCallId?: string })
42+
return false
43+
})
44+
45+
const hasText = parts.some(
46+
(p) => p.type === 'text' && (p as { text: string }).text.trim().length > 0,
47+
)
48+
const hasCompletedTool = parts.some((p) => isToolPart(p))
49+
50+
if (!hasText && !hasCompletedTool) continue
51+
if (!parts.length) continue
52+
53+
out.push({ ...message, parts })
54+
}
55+
56+
return out
57+
}

server/utils/agent.service.ts

Lines changed: 3 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
} from './documents.service'
2525
import { truncate } from './text'
2626
import { parseMemoryCommand, getMessageText, type MemoryCommand } from './agent-commands'
27+
import { sanitizeUIMessagesForAgent } from './agent-messages'
2728

2829
async function getLlmCfg() {
2930
const config = useRuntimeConfig()
@@ -234,26 +235,8 @@ export async function agentStreamText(
234235
else if (cfg.ragResponseLang === 'en') system += '\n- Respond always in English.'
235236
}
236237

237-
const validMessages = messages.filter((m) => {
238-
if (m.role !== 'assistant') return true
239-
const textParts = (m.parts ?? []).filter(
240-
(p): p is { type: 'text'; text: string } => p.type === 'text',
241-
)
242-
const hasText = textParts.some((p) => p.text.trim().length > 0)
243-
const hasCompletedTool = (m.parts ?? []).some((p) => {
244-
if (!p.type.startsWith('tool-')) return false
245-
const state = (p as { state?: string }).state
246-
return (
247-
state === 'output-available' ||
248-
state === 'output-error' ||
249-
state === 'output-denied' ||
250-
state === 'output'
251-
)
252-
})
253-
return hasText || hasCompletedTool
254-
})
255-
256-
const modelMessages = await convertToModelMessages(validMessages)
238+
const sanitizedMessages = sanitizeUIMessagesForAgent(messages)
239+
const modelMessages = await convertToModelMessages(sanitizedMessages)
257240
const model = await getLlmModel(input.modelOverride)
258241

259242
return streamText({

tests/agent.spec.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, it, expect } from 'vitest'
22
import { parseMemoryCommand, getMessageText } from '../server/utils/agent-commands'
3+
import { sanitizeUIMessagesForAgent } from '../server/utils/agent-messages'
34
import type { UIMessage } from 'ai'
45

56
describe('parseMemoryCommand', () => {
@@ -55,3 +56,60 @@ describe('getMessageText', () => {
5556
expect(getMessageText(m)).toBe('')
5657
})
5758
})
59+
60+
describe('sanitizeUIMessagesForAgent', () => {
61+
it('drops assistant messages with only in-progress tool parts', () => {
62+
const messages = [
63+
{ id: '1', role: 'user', parts: [{ type: 'text', text: 'hi' }] },
64+
{
65+
id: '2',
66+
role: 'assistant',
67+
parts: [
68+
{
69+
type: 'tool-searchKnowledgeBase',
70+
state: 'input-streaming',
71+
toolCallId: 'call-1',
72+
},
73+
],
74+
},
75+
] as unknown as UIMessage[]
76+
expect(sanitizeUIMessagesForAgent(messages)).toEqual([messages[0]])
77+
})
78+
79+
it('drops synthetic tool parts without toolCallId', () => {
80+
const messages = [
81+
{ id: '1', role: 'user', parts: [{ type: 'text', text: 'q' }] },
82+
{
83+
id: '2',
84+
role: 'assistant',
85+
parts: [
86+
{ type: 'text', text: 'answer with [1]' },
87+
{
88+
type: 'tool-searchKnowledgeBase',
89+
state: 'output-available',
90+
output: { sources: [{ chunkId: 'c1' }] },
91+
},
92+
],
93+
},
94+
] as unknown as UIMessage[]
95+
const out = sanitizeUIMessagesForAgent(messages)
96+
expect(out).toHaveLength(2)
97+
expect(out[1].parts).toEqual([{ type: 'text', text: 'answer with [1]' }])
98+
})
99+
100+
it('keeps completed tool parts that include toolCallId', () => {
101+
const toolPart = {
102+
type: 'tool-searchKnowledgeBase',
103+
state: 'output-available',
104+
toolCallId: 'call-1',
105+
input: { query: 'test' },
106+
output: { context: 'ctx', sources: [], results: [], count: 0 },
107+
}
108+
const messages = [
109+
{ id: '1', role: 'user', parts: [{ type: 'text', text: 'q' }] },
110+
{ id: '2', role: 'assistant', parts: [toolPart] },
111+
] as unknown as UIMessage[]
112+
const out = sanitizeUIMessagesForAgent(messages)
113+
expect(out[1].parts).toContainEqual(toolPart)
114+
})
115+
})

0 commit comments

Comments
 (0)