mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-21 21:05:09 +00:00
* fix(agents): hide BrowserOS ACP envelope from chat history payloads (TKT-774) The user-message text persisted on the wire carried two nested envelopes — the outer `<role>You are BrowserOS…</role>` + `<user_request>…</user_request>` block from buildBrowserosAcpPrompt and the inner `## Browser Context` + `<selected_text>` + `<USER_QUERY>` block from formatUserMessage. PR #856 had unwrapped only the outer envelope on history reads, so the user bubble in the agent rail still rendered the inner envelope, and the LLM chat-service path leaked the wrapper all the way back to the sidepanel client through AI SDK's stream sync. Two surgical fixes, both server-only: 1) ACP path (acpx-runtime.ts) — replace unwrapBrowserosAcpPrompt with a comprehensive unwrapBrowserosAcpUserMessage that strips both layers and decodes the </>/& escapes the server applied via escapePromptTagText. Each step is independently defensive (anchors that don't match are skipped) so the helper is idempotent and tolerates partial / older / future-shape envelopes. Applied in userContentToText (history mapper) and inherited by extractLastUserMessage (listing's lastUserMessage). 2) LLM chat path (chat-service.ts) — split the persisted user message from the prompt-time copy. session.agent.appendUserMessage now stores the raw user text; a transient promptUiMessages array is built with the wrapped (formatUserMessage + context-change prefix) form and passed to createAgentUIStreamResponse for the model. onFinish restores the raw form before persisting, so the user-visible message and any future history reads see only the user's typed text. Tests: - acpx-runtime.test.ts: new dedicated unwrapBrowserosAcpUserMessage suite covering fully-wrapped messages, only-outer / only-inner inputs, selected_text blocks with attribute strings, idempotency, literal user-typed angle-bracket round-trip, and an integration test that round-trips the real formatUserMessage output through the unwrap to pin the writer/reader contract. - chat-service.test.ts: existing 'rebuilds a managed-app session' test updated for the new behaviour — asserts the persisted user message is the raw text and the prompt copy passed to the agent carries the Klavis context-change notice. * fix(agents): decode entity escapes before stripping inner envelope (TKT-774) The unwrap was running its inner-envelope strips against the literal-tag form (<USER_QUERY>, <selected_text>) but the persisted payload has those tags entity-escaped (<USER_QUERY>, <selected_text>) — buildBrowserosAcpPrompt runs escapePromptTagText over the entire formatUserMessage payload before adding the outer <role>+<user_request> envelope, so the inner anchors never matched against the on-disk text and the user was still seeing <USER_QUERY> in /agents/:id/sessions/main/history responses. Reorder unwrapBrowserosAcpUserMessage to: outer-strip → decode entities → inner-strips. Test fixtures updated to reflect the actual on-wire form (escaped inner tags); the round-trip test duplicates the escape rule inline so the contract between buildBrowserosAcpPrompt and the unwrap is pinned end-to-end.
429 lines
12 KiB
TypeScript
429 lines
12 KiB
TypeScript
import { describe, expect, it, mock } from 'bun:test'
|
|
|
|
interface MockMessage {
|
|
id: string
|
|
role: 'user' | 'assistant'
|
|
parts: Array<{ type: 'text'; text: string }>
|
|
}
|
|
|
|
interface MockAgent {
|
|
toolLoopAgent: object
|
|
toolNames: Set<string>
|
|
messages: MockMessage[]
|
|
appendUserMessage(text: string): void
|
|
updateAclRules(rules: unknown): void
|
|
dispose(): Promise<void>
|
|
}
|
|
|
|
interface StoredSession {
|
|
agent: MockAgent
|
|
hiddenPageId?: number
|
|
}
|
|
|
|
interface StreamResponseOptions {
|
|
uiMessages?: MockMessage[]
|
|
onFinish(args: { messages: MockMessage[] }): Promise<void>
|
|
}
|
|
|
|
let agentToReturn: MockAgent | undefined
|
|
let streamResponseHandler:
|
|
| ((options: StreamResponseOptions) => Promise<Response>)
|
|
| undefined
|
|
|
|
const createAgentSpy = mock(async (config: unknown) => {
|
|
if (!agentToReturn) {
|
|
throw new Error(`No mock agent configured for ${JSON.stringify(config)}`)
|
|
}
|
|
return agentToReturn
|
|
})
|
|
|
|
const createAgentUIStreamResponseSpy = mock(
|
|
async (options: StreamResponseOptions) => {
|
|
if (!streamResponseHandler) {
|
|
throw new Error('No stream response handler configured')
|
|
}
|
|
return await streamResponseHandler(options)
|
|
},
|
|
)
|
|
|
|
const resolveLLMConfigSpy = mock(async () => ({
|
|
provider: 'openai',
|
|
model: 'gpt-5',
|
|
apiKey: 'test-key',
|
|
}))
|
|
|
|
mock.module('ai', () => ({
|
|
createAgentUIStreamResponse: createAgentUIStreamResponseSpy,
|
|
}))
|
|
|
|
mock.module('../../../src/agent/ai-sdk-agent', () => ({
|
|
AiSdkAgent: {
|
|
create: createAgentSpy,
|
|
},
|
|
}))
|
|
|
|
mock.module('../../../src/lib/clients/llm/config', () => ({
|
|
resolveLLMConfig: resolveLLMConfigSpy,
|
|
}))
|
|
|
|
mock.module('../../../src/lib/logger', () => ({
|
|
logger: {
|
|
info: mock(() => {}),
|
|
warn: mock(() => {}),
|
|
debug: mock(() => {}),
|
|
},
|
|
}))
|
|
|
|
const { ChatService } = await import('../../../src/api/services/chat-service')
|
|
|
|
function createSessionStore() {
|
|
const sessions = new Map<string, StoredSession>()
|
|
return {
|
|
get(conversationId: string) {
|
|
return sessions.get(conversationId)
|
|
},
|
|
set(conversationId: string, session: StoredSession) {
|
|
sessions.set(conversationId, session)
|
|
},
|
|
remove(conversationId: string) {
|
|
return sessions.delete(conversationId)
|
|
},
|
|
async delete(conversationId: string) {
|
|
const session = sessions.get(conversationId)
|
|
if (!session) return false
|
|
await session.agent.dispose()
|
|
sessions.delete(conversationId)
|
|
return true
|
|
},
|
|
count() {
|
|
return sessions.size
|
|
},
|
|
}
|
|
}
|
|
|
|
function createFakeAgent() {
|
|
const messages: MockMessage[] = []
|
|
return {
|
|
toolLoopAgent: {},
|
|
toolNames: new Set<string>(),
|
|
messages,
|
|
appendUserMessage(text: string) {
|
|
this.messages.push({
|
|
id: 'user-1',
|
|
role: 'user',
|
|
parts: [{ type: 'text', text }],
|
|
})
|
|
},
|
|
updateAclRules: mock(() => {}),
|
|
dispose: mock(async () => {}),
|
|
}
|
|
}
|
|
|
|
describe('ChatService scheduled task hidden page lifecycle', () => {
|
|
it('creates and cleans up a hidden page without creating a hidden window', async () => {
|
|
const fakeAgent = createFakeAgent()
|
|
agentToReturn = fakeAgent
|
|
streamResponseHandler = async ({ onFinish, uiMessages }) => {
|
|
await onFinish({ messages: uiMessages ?? fakeAgent.messages })
|
|
return new Response('ok')
|
|
}
|
|
|
|
const browser = {
|
|
newPage: mock(async () => 77),
|
|
listPages: mock(async () => [
|
|
{
|
|
pageId: 77,
|
|
windowId: 11,
|
|
},
|
|
]),
|
|
closePage: mock(async () => {}),
|
|
createWindow: mock(async () => ({ windowId: 11 })),
|
|
closeWindow: mock(async () => {}),
|
|
resolveTabIds: mock(async () => new Map<number, number>()),
|
|
}
|
|
const sessionStore = createSessionStore()
|
|
const service = new ChatService({
|
|
sessionStore: sessionStore as never,
|
|
klavisRef: { handle: null },
|
|
browser: browser as never,
|
|
registry: {} as never,
|
|
})
|
|
|
|
await service.processMessage(
|
|
{
|
|
conversationId: crypto.randomUUID(),
|
|
message: 'Run the scheduled task',
|
|
isScheduledTask: true,
|
|
mode: 'agent',
|
|
origin: 'sidepanel',
|
|
browserContext: {
|
|
windowId: 9,
|
|
activeTab: {
|
|
id: 3,
|
|
url: 'https://example.com',
|
|
title: 'Example',
|
|
},
|
|
selectedTabs: [{ id: 4 }],
|
|
enabledMcpServers: ['slack'],
|
|
},
|
|
} as never,
|
|
new AbortController().signal,
|
|
)
|
|
|
|
expect(browser.newPage).toHaveBeenCalledWith('about:blank', {
|
|
hidden: true,
|
|
background: true,
|
|
})
|
|
expect(browser.createWindow).not.toHaveBeenCalled()
|
|
expect(browser.closePage).toHaveBeenCalledWith(77)
|
|
expect(browser.closeWindow).not.toHaveBeenCalled()
|
|
|
|
const createArgs = createAgentSpy.mock.calls.at(-1)?.[0] as {
|
|
browserContext?: {
|
|
windowId?: number
|
|
selectedTabs?: unknown[]
|
|
activeTab?: {
|
|
id: number
|
|
pageId: number
|
|
url: string
|
|
title: string
|
|
}
|
|
enabledMcpServers?: string[]
|
|
}
|
|
}
|
|
expect(createArgs.browserContext?.windowId).toBe(11)
|
|
expect(createArgs.browserContext?.selectedTabs).toBeUndefined()
|
|
expect(createArgs.browserContext?.activeTab).toEqual({
|
|
id: 77,
|
|
pageId: 77,
|
|
url: 'about:blank',
|
|
title: 'Scheduled Task',
|
|
})
|
|
expect(createArgs.browserContext?.enabledMcpServers).toEqual(['slack'])
|
|
})
|
|
|
|
it('deleteSession closes the tracked hidden page', async () => {
|
|
const fakeAgent = createFakeAgent()
|
|
const sessionStore = createSessionStore()
|
|
const browser = {
|
|
closePage: mock(async () => {}),
|
|
}
|
|
const conversationId = crypto.randomUUID()
|
|
|
|
sessionStore.set(conversationId, {
|
|
agent: fakeAgent,
|
|
hiddenPageId: 33,
|
|
})
|
|
|
|
const service = new ChatService({
|
|
sessionStore: sessionStore as never,
|
|
klavisRef: { handle: null },
|
|
browser: browser as never,
|
|
registry: {} as never,
|
|
})
|
|
|
|
const result = await service.deleteSession(conversationId)
|
|
|
|
expect(result).toEqual({ deleted: true, sessionCount: 0 })
|
|
expect(browser.closePage).toHaveBeenCalledWith(33)
|
|
expect(fakeAgent.dispose).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('keeps the scheduled hidden page context when metadata lookup fails', async () => {
|
|
const fakeAgent = createFakeAgent()
|
|
agentToReturn = fakeAgent
|
|
streamResponseHandler = async ({ onFinish, uiMessages }) => {
|
|
await onFinish({ messages: uiMessages ?? fakeAgent.messages })
|
|
return new Response('ok')
|
|
}
|
|
|
|
const browser = {
|
|
newPage: mock(async () => 88),
|
|
listPages: mock(async () => {
|
|
throw new Error('CDP lookup failed')
|
|
}),
|
|
closePage: mock(async () => {}),
|
|
resolveTabIds: mock(async () => new Map<number, number>()),
|
|
}
|
|
const sessionStore = createSessionStore()
|
|
const service = new ChatService({
|
|
sessionStore: sessionStore as never,
|
|
klavisRef: { handle: null },
|
|
browser: browser as never,
|
|
registry: {} as never,
|
|
})
|
|
|
|
await service.processMessage(
|
|
{
|
|
conversationId: crypto.randomUUID(),
|
|
message: 'Run the scheduled task',
|
|
isScheduledTask: true,
|
|
mode: 'agent',
|
|
origin: 'sidepanel',
|
|
browserContext: {
|
|
activeTab: {
|
|
id: 3,
|
|
url: 'https://example.com',
|
|
title: 'Example',
|
|
},
|
|
},
|
|
} as never,
|
|
new AbortController().signal,
|
|
)
|
|
|
|
const createArgs = createAgentSpy.mock.calls.at(-1)?.[0] as {
|
|
browserContext?: {
|
|
windowId?: number
|
|
activeTab?: {
|
|
id: number
|
|
pageId: number
|
|
url: string
|
|
title: string
|
|
}
|
|
}
|
|
}
|
|
expect(createArgs.browserContext?.windowId).toBeUndefined()
|
|
expect(createArgs.browserContext?.activeTab).toEqual({
|
|
id: 88,
|
|
pageId: 88,
|
|
url: 'about:blank',
|
|
title: 'Scheduled Task',
|
|
})
|
|
expect(browser.closePage).toHaveBeenCalledWith(88)
|
|
})
|
|
})
|
|
|
|
describe('ChatService Klavis session rebuilds', () => {
|
|
it('rebuilds a managed-app session when the shared Klavis handle appears', async () => {
|
|
const firstAgent = createFakeAgent()
|
|
const secondAgent = createFakeAgent()
|
|
agentToReturn = firstAgent
|
|
let lastPromptUiMessages: MockMessage[] | undefined
|
|
streamResponseHandler = async ({ onFinish, uiMessages }) => {
|
|
lastPromptUiMessages = uiMessages
|
|
await onFinish({ messages: uiMessages ?? [] })
|
|
return new Response('ok')
|
|
}
|
|
|
|
const klavisRef = { handle: null as object | null }
|
|
const browser = {
|
|
resolveTabIds: mock(
|
|
async (tabIds: number[]) =>
|
|
new Map(tabIds.map((tabId) => [tabId, tabId + 100])),
|
|
),
|
|
closePage: mock(async () => {}),
|
|
}
|
|
const sessionStore = createSessionStore()
|
|
const service = new ChatService({
|
|
sessionStore: sessionStore as never,
|
|
klavisRef: klavisRef as never,
|
|
browser: browser as never,
|
|
registry: {} as never,
|
|
})
|
|
const createCallsBefore = createAgentSpy.mock.calls.length
|
|
const conversationId = crypto.randomUUID()
|
|
const request = {
|
|
conversationId,
|
|
message: 'check integrations',
|
|
isScheduledTask: false,
|
|
mode: 'agent',
|
|
origin: 'sidepanel',
|
|
browserContext: {
|
|
activeTab: {
|
|
id: 3,
|
|
url: 'https://example.com',
|
|
title: 'Example',
|
|
},
|
|
enabledMcpServers: ['slack'],
|
|
},
|
|
} as never
|
|
|
|
await service.processMessage(request, new AbortController().signal)
|
|
|
|
agentToReturn = secondAgent
|
|
klavisRef.handle = {}
|
|
|
|
await service.processMessage(
|
|
{ ...request, message: 'check integrations again' },
|
|
new AbortController().signal,
|
|
)
|
|
|
|
expect(createAgentSpy.mock.calls.length - createCallsBefore).toBe(2)
|
|
expect(firstAgent.dispose).toHaveBeenCalledTimes(1)
|
|
|
|
// Persisted form stays the raw user text — TKT-774. The Klavis
|
|
// context-change notice and the formatted user envelope go only
|
|
// into the transient prompt copy fed to the LLM.
|
|
expect(secondAgent.messages).toHaveLength(2)
|
|
const persistedRebuiltMessage =
|
|
secondAgent.messages[1]?.parts[0]?.text ?? ''
|
|
expect(persistedRebuiltMessage).toBe('check integrations again')
|
|
|
|
// Prompt copy (what the agent loop actually saw) carries the
|
|
// context-change prefix so the model knows about the new tools.
|
|
const promptRebuiltMessage =
|
|
lastPromptUiMessages?.at(-1)?.parts[0]?.text ?? ''
|
|
expect(promptRebuiltMessage).toContain(
|
|
'Klavis app integration tools are now available for the following connected apps: slack.',
|
|
)
|
|
expect(promptRebuiltMessage).not.toContain('klavis:pending')
|
|
expect(promptRebuiltMessage).not.toContain('klavis:connected')
|
|
})
|
|
|
|
it('does not rebuild a session with no enabled managed apps when Klavis connects', async () => {
|
|
const firstAgent = createFakeAgent()
|
|
const secondAgent = createFakeAgent()
|
|
agentToReturn = firstAgent
|
|
streamResponseHandler = async ({ onFinish, uiMessages }) => {
|
|
await onFinish({ messages: uiMessages ?? [] })
|
|
return new Response('ok')
|
|
}
|
|
|
|
const klavisRef = { handle: null as object | null }
|
|
const browser = {
|
|
resolveTabIds: mock(
|
|
async (tabIds: number[]) =>
|
|
new Map(tabIds.map((tabId) => [tabId, tabId + 200])),
|
|
),
|
|
closePage: mock(async () => {}),
|
|
}
|
|
const sessionStore = createSessionStore()
|
|
const service = new ChatService({
|
|
sessionStore: sessionStore as never,
|
|
klavisRef: klavisRef as never,
|
|
browser: browser as never,
|
|
registry: {} as never,
|
|
})
|
|
const createCallsBefore = createAgentSpy.mock.calls.length
|
|
const conversationId = crypto.randomUUID()
|
|
const request = {
|
|
conversationId,
|
|
message: 'check browser only',
|
|
isScheduledTask: false,
|
|
mode: 'agent',
|
|
origin: 'sidepanel',
|
|
browserContext: {
|
|
activeTab: {
|
|
id: 5,
|
|
url: 'https://example.com',
|
|
title: 'Example',
|
|
},
|
|
},
|
|
} as never
|
|
|
|
await service.processMessage(request, new AbortController().signal)
|
|
|
|
agentToReturn = secondAgent
|
|
klavisRef.handle = {}
|
|
|
|
await service.processMessage(
|
|
{ ...request, message: 'check browser only again' },
|
|
new AbortController().signal,
|
|
)
|
|
|
|
expect(createAgentSpy.mock.calls.length - createCallsBefore).toBe(1)
|
|
expect(firstAgent.dispose).not.toHaveBeenCalled()
|
|
expect(firstAgent.messages).toHaveLength(2)
|
|
})
|
|
})
|