mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-20 20:39:10 +00:00
* fix: remove filesystem tools when no workspace is selected - Make workingDir optional on ResolvedAgentConfig - Remove resolveSessionDir() fallback that always created a session dir, masking the no-workspace state and keeping filesystem tools available - Gate buildFilesystemToolSet() on workingDir being defined - Add workspace change detection mid-conversation — rebuilds the agent session when workspace is added, removed, or switched (same pattern as existing MCP server change detection) - download_file falls back to tmpdir() when no workspace is set - Memory/soul tools are unaffected — they use ~/BrowserOS/ paths * fix: sanitize message history when session rebuilds with different tools When a session is rebuilt due to workspace or MCP changes, the carried-over message history may contain tool parts for tools that no longer exist in the new session. The AI SDK validates messages against the current toolset and rejects parts with no matching schema. - Add toolNames getter to AiSdkAgent exposing registered tool names - Add sanitizeMessagesForToolset() to strip tool parts referencing removed tools from carried-over messages - Apply sanitization in both MCP and workspace session rebuilds * fix: prepend tool-change context to user message on session rebuild When workspace or MCP integrations change mid-conversation, prepend a [Context: ...] block to the user's message explaining what changed. This prevents the LLM from hallucinating tool usage based on patterns in the carried-over conversation history. Context messages vary by change type: - Workspace removed: lists unavailable filesystem tools, suggests selecting a working directory - Workspace added: confirms filesystem tools are available with path - Workspace switched: notes the new working directory - MCP changed: notes that some integration tools may have changed Only fires on the first message after a rebuild. Invisible in the UI. * fix: make MCP change context specific about which apps were added/removed Diff the old and new MCP server keys to produce specific context like: - "The following app integrations were disconnected: Gmail, Slack." - "The following app integrations were connected: Linear." instead of a generic "some tools may no longer be available" message. * refactor: extract shared rebuildSession helper in ChatService Eliminates the duplicated 20-line dispose→create→sanitize→store flow that existed separately in both the MCP and workspace change-detection blocks. Co-authored-by: Dani Akash <DaniAkash@users.noreply.github.com> * test: add sanitizeMessagesForToolset test suite Tests for the message sanitization that runs when a session rebuilds with a different toolset (workspace or MCP change mid-conversation): - Preserves messages with no tool parts - Preserves tool parts when tool is in the toolset - Strips tool parts when tool is NOT in the toolset - Strips multiple removed tool parts from same message - Keeps browser tools while removing filesystem tools - Removes messages that become empty after stripping - Preserves non-tool parts (reasoning, step-start, file) - Returns same references when no filtering needed - Handles empty message array and empty toolset * style: fix biome formatting in chat-service.ts --------- Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
300 lines
8.8 KiB
TypeScript
300 lines
8.8 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 BrowserOS
|
|
*
|
|
* Message Validation — Test Suite
|
|
*
|
|
* Tests for sanitizeMessagesForToolset, which strips tool parts from
|
|
* carried-over messages when a session is rebuilt with a different toolset
|
|
* (e.g., workspace removed or MCP server disconnected mid-conversation).
|
|
*
|
|
* Without this sanitization, the AI SDK throws a validation error because
|
|
* it finds tool parts in the message history that have no matching schema.
|
|
*/
|
|
|
|
import { describe, expect, it } from 'bun:test'
|
|
import type { UIMessage } from 'ai'
|
|
import {
|
|
hasMessageContent,
|
|
sanitizeMessagesForToolset,
|
|
} from '../../src/agent/message-validation'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function makeUserMessage(text: string, id?: string): UIMessage {
|
|
return {
|
|
id: id ?? crypto.randomUUID(),
|
|
role: 'user',
|
|
parts: [{ type: 'text', text }],
|
|
}
|
|
}
|
|
|
|
function makeAssistantMessage(
|
|
parts: UIMessage['parts'],
|
|
id?: string,
|
|
): UIMessage {
|
|
return {
|
|
id: id ?? crypto.randomUUID(),
|
|
role: 'assistant',
|
|
parts,
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// sanitizeMessagesForToolset
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('sanitizeMessagesForToolset', () => {
|
|
const allTools = new Set([
|
|
'navigate_page',
|
|
'click',
|
|
'take_snapshot',
|
|
'filesystem_read',
|
|
'filesystem_write',
|
|
'memory_search',
|
|
])
|
|
|
|
const noFilesystemTools = new Set([
|
|
'navigate_page',
|
|
'click',
|
|
'take_snapshot',
|
|
'memory_search',
|
|
])
|
|
|
|
it('preserves messages with no tool parts', () => {
|
|
const messages: UIMessage[] = [
|
|
makeUserMessage('Hello'),
|
|
makeAssistantMessage([{ type: 'text', text: 'Hi there!' }]),
|
|
]
|
|
|
|
const result = sanitizeMessagesForToolset(messages, noFilesystemTools)
|
|
expect(result).toHaveLength(2)
|
|
expect(result[0].parts).toHaveLength(1)
|
|
expect(result[1].parts).toHaveLength(1)
|
|
})
|
|
|
|
it('preserves tool parts when tool is in the toolset', () => {
|
|
const messages: UIMessage[] = [
|
|
makeAssistantMessage([
|
|
{ type: 'text', text: 'Taking a snapshot...' },
|
|
{
|
|
type: 'tool-take_snapshot',
|
|
toolCallId: 'call-1',
|
|
toolName: 'take_snapshot',
|
|
state: 'result',
|
|
input: { page: 1 },
|
|
output: { content: 'snapshot data' },
|
|
} as unknown as UIMessage['parts'][number],
|
|
]),
|
|
]
|
|
|
|
const result = sanitizeMessagesForToolset(messages, allTools)
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].parts).toHaveLength(2)
|
|
})
|
|
|
|
it('strips tool parts when tool is NOT in the toolset', () => {
|
|
const messages: UIMessage[] = [
|
|
makeAssistantMessage([
|
|
{ type: 'text', text: 'Reading file...' },
|
|
{
|
|
type: 'tool-filesystem_read',
|
|
toolCallId: 'call-1',
|
|
toolName: 'filesystem_read',
|
|
state: 'result',
|
|
input: { path: '/tmp/test.txt' },
|
|
output: { content: 'file data' },
|
|
} as unknown as UIMessage['parts'][number],
|
|
]),
|
|
]
|
|
|
|
const result = sanitizeMessagesForToolset(messages, noFilesystemTools)
|
|
expect(result).toHaveLength(1)
|
|
// Only the text part should remain
|
|
expect(result[0].parts).toHaveLength(1)
|
|
expect(result[0].parts[0].type).toBe('text')
|
|
})
|
|
|
|
it('strips multiple removed tool parts from same message', () => {
|
|
const messages: UIMessage[] = [
|
|
makeAssistantMessage([
|
|
{ type: 'text', text: 'Working on files...' },
|
|
{
|
|
type: 'tool-filesystem_read',
|
|
toolCallId: 'call-1',
|
|
toolName: 'filesystem_read',
|
|
state: 'result',
|
|
input: { path: '/tmp/a.txt' },
|
|
output: {},
|
|
} as unknown as UIMessage['parts'][number],
|
|
{
|
|
type: 'tool-filesystem_write',
|
|
toolCallId: 'call-2',
|
|
toolName: 'filesystem_write',
|
|
state: 'result',
|
|
input: { path: '/tmp/b.txt', content: 'data' },
|
|
output: {},
|
|
} as unknown as UIMessage['parts'][number],
|
|
]),
|
|
]
|
|
|
|
const result = sanitizeMessagesForToolset(messages, noFilesystemTools)
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].parts).toHaveLength(1)
|
|
expect(result[0].parts[0].type).toBe('text')
|
|
})
|
|
|
|
it('keeps browser tool parts while removing filesystem tool parts', () => {
|
|
const messages: UIMessage[] = [
|
|
makeAssistantMessage([
|
|
{
|
|
type: 'tool-take_snapshot',
|
|
toolCallId: 'call-1',
|
|
toolName: 'take_snapshot',
|
|
state: 'result',
|
|
input: { page: 1 },
|
|
output: {},
|
|
} as unknown as UIMessage['parts'][number],
|
|
{
|
|
type: 'tool-filesystem_read',
|
|
toolCallId: 'call-2',
|
|
toolName: 'filesystem_read',
|
|
state: 'result',
|
|
input: { path: '/tmp/test.txt' },
|
|
output: {},
|
|
} as unknown as UIMessage['parts'][number],
|
|
]),
|
|
]
|
|
|
|
const result = sanitizeMessagesForToolset(messages, noFilesystemTools)
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].parts).toHaveLength(1)
|
|
expect((result[0].parts[0] as { type: string }).type).toBe(
|
|
'tool-take_snapshot',
|
|
)
|
|
})
|
|
|
|
it('removes messages that become empty after stripping', () => {
|
|
const messages: UIMessage[] = [
|
|
makeUserMessage('Read this file'),
|
|
makeAssistantMessage([
|
|
{
|
|
type: 'tool-filesystem_read',
|
|
toolCallId: 'call-1',
|
|
toolName: 'filesystem_read',
|
|
state: 'result',
|
|
input: { path: '/tmp/test.txt' },
|
|
output: {},
|
|
} as unknown as UIMessage['parts'][number],
|
|
]),
|
|
]
|
|
|
|
const result = sanitizeMessagesForToolset(messages, noFilesystemTools)
|
|
// The assistant message had only a tool part — after stripping, it's empty
|
|
// and should be filtered out by hasMessageContent
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].role).toBe('user')
|
|
})
|
|
|
|
it('preserves non-tool part types (reasoning, step-start, file)', () => {
|
|
const messages: UIMessage[] = [
|
|
makeAssistantMessage([
|
|
{ type: 'text', text: 'Let me think...' },
|
|
{
|
|
type: 'reasoning',
|
|
reasoning: 'Analyzing the request',
|
|
} as unknown as UIMessage['parts'][number],
|
|
{
|
|
type: 'step-start',
|
|
} as unknown as UIMessage['parts'][number],
|
|
]),
|
|
]
|
|
|
|
const result = sanitizeMessagesForToolset(messages, noFilesystemTools)
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].parts).toHaveLength(3)
|
|
})
|
|
|
|
it('returns same message references when no filtering needed', () => {
|
|
const messages: UIMessage[] = [
|
|
makeUserMessage('Hello'),
|
|
makeAssistantMessage([{ type: 'text', text: 'Hi!' }]),
|
|
]
|
|
|
|
const result = sanitizeMessagesForToolset(messages, noFilesystemTools)
|
|
// Messages that don't need filtering should be the same reference
|
|
expect(result[0]).toBe(messages[0])
|
|
expect(result[1]).toBe(messages[1])
|
|
})
|
|
|
|
it('handles empty message array', () => {
|
|
const result = sanitizeMessagesForToolset([], noFilesystemTools)
|
|
expect(result).toHaveLength(0)
|
|
})
|
|
|
|
it('handles empty toolset (all tools removed)', () => {
|
|
const messages: UIMessage[] = [
|
|
makeAssistantMessage([
|
|
{ type: 'text', text: 'Working...' },
|
|
{
|
|
type: 'tool-navigate_page',
|
|
toolCallId: 'call-1',
|
|
toolName: 'navigate_page',
|
|
state: 'result',
|
|
input: {},
|
|
output: {},
|
|
} as unknown as UIMessage['parts'][number],
|
|
]),
|
|
]
|
|
|
|
const result = sanitizeMessagesForToolset(messages, new Set())
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].parts).toHaveLength(1)
|
|
expect(result[0].parts[0].type).toBe('text')
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// hasMessageContent (existing function, verify edge cases)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('hasMessageContent', () => {
|
|
it('rejects messages with empty parts array', () => {
|
|
const msg: UIMessage = {
|
|
id: '1',
|
|
role: 'assistant',
|
|
parts: [],
|
|
}
|
|
expect(hasMessageContent(msg)).toBe(false)
|
|
})
|
|
|
|
it('rejects messages with only whitespace text', () => {
|
|
const msg: UIMessage = {
|
|
id: '1',
|
|
role: 'assistant',
|
|
parts: [{ type: 'text', text: ' \n ' }],
|
|
}
|
|
expect(hasMessageContent(msg)).toBe(false)
|
|
})
|
|
|
|
it('accepts messages with non-text parts', () => {
|
|
const msg: UIMessage = {
|
|
id: '1',
|
|
role: 'assistant',
|
|
parts: [
|
|
{
|
|
type: 'tool-click',
|
|
toolCallId: 'call-1',
|
|
toolName: 'click',
|
|
state: 'result',
|
|
input: {},
|
|
output: {},
|
|
} as unknown as UIMessage['parts'][number],
|
|
],
|
|
}
|
|
expect(hasMessageContent(msg)).toBe(true)
|
|
})
|
|
})
|