Files
BrowserOS/packages/browseros-agent/apps/server/tests/api/services/chat-service.test.ts
Dani Akash 974e7e9b86 fix(agents): hide BrowserOS ACP envelope from chat history payloads (TKT-774) (#907)
* 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 &lt;/&gt;/&amp; 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 (&lt;USER_QUERY&gt;,
&lt;selected_text&gt;) — 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.
2026-05-01 19:42:48 +05:30

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)
})
})