mcp.spec.ts 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576
  1. import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import { fileURLToPath, pathToFileURL } from 'node:url'
  5. import { afterEach, describe, expect, it, vi } from 'vitest'
  6. import { Context } from '@deepseek-ai/cordis'
  7. import Loader from '@deepseek-ai/cordis-plugin-loader'
  8. import Include from '@deepseek-ai/cordis-plugin-include'
  9. import BrowserUse from '@deepseek-ai/dsh-browser-use'
  10. import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
  11. import Tools from '@deepseek-ai/dsh-tools'
  12. import McpResources from '@deepseek-ai/dsh-mcp-resources'
  13. import { PtcRuntime } from '@deepseek-ai/dsh-ptc-runtime'
  14. import Llm, { LlmAdapter, ToolCallId, createUserMessage } from '@deepseek-ai/dsh-llm'
  15. import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
  16. import Sessions, { SessionId } from '@deepseek-ai/dsh-session'
  17. import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
  18. import Agents from '@deepseek-ai/dsh-agent'
  19. import type { Agent } from '@deepseek-ai/dsh-agent'
  20. import AgentLoop from '@deepseek-ai/dsh-agent-loop'
  21. import Projections from '@deepseek-ai/dsh-session-projection'
  22. import { bindScopeParent } from '@deepseek-ai/dsh-scope'
  23. import { BrowserMcpConfig, mountSessionMcp, validateBrowserMcpConfig } from '../src/mcp.ts'
  24. const fixture = fileURLToPath(new URL('./mcp-fixture.mjs', import.meta.url))
  25. const roots: string[] = []
  26. const contexts: Context[] = []
  27. const TOOL = 'mcp__browser-fixture__visit'
  28. afterEach(async () => {
  29. await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
  30. await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
  31. })
  32. class FixtureModel extends LlmAdapter {
  33. requests: GenerateOptions[] = []
  34. override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
  35. return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text'] })
  36. }
  37. async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
  38. this.requests.push(options)
  39. if (options.tools?.some(tool => tool.name === TOOL) && options.messages.at(-1)?.content[0]?.type === 'text') {
  40. const call = { type: 'tool-call' as const, id: ToolCallId('visit'), name: TOOL, arguments: '{"label":"fixture"}' }
  41. yield { type: 'block-start', index: 0, blockType: 'tool-call' }
  42. yield { type: 'tool-call-delta', index: 0, id: call.id, name: call.name, argumentsDelta: call.arguments }
  43. yield { type: 'block-end', index: 0, block: call }
  44. yield { type: 'finish', reason: { kind: 'tool-calls' } }
  45. } else {
  46. yield { type: 'block-start', index: 0, blockType: 'text' }
  47. yield { type: 'text-delta', index: 0, text: 'Visited.' }
  48. yield { type: 'block-end', index: 0, block: { type: 'text', text: 'Visited.' } }
  49. yield { type: 'finish', reason: { kind: 'stop' } }
  50. }
  51. }
  52. }
  53. class PresentationRuntime extends PtcRuntime {
  54. readonly language = 'typescript'
  55. readonly isolation = 'fixture'
  56. resolve(): never { throw new Error('Unexpected PTC execution in a presentation test') }
  57. run(): Promise<never> { return Promise.reject(new Error('Unexpected PTC execution in a presentation test')) }
  58. }
  59. async function load(exclusive = false, mode?: string, toolCallTimeoutMs?: number, toolOrder?: string[]) {
  60. const root = await mkdtemp(join(tmpdir(), 'dsh-browser-mcp-'))
  61. roots.push(root)
  62. const model = new FixtureModel()
  63. const modules = new Map<string, unknown>([
  64. ['browserUse', BrowserUse], ['prompt', SystemPrompt], ['tools', Tools], ['llm', Llm],
  65. ['sessions', Sessions], ['agents', Agents], ['loop', AgentLoop], ['projections', Projections],
  66. ['model', { inject: ['llm'], apply(ctx: Context) { ctx.effect(() => ctx.llm.registerAdapter(['fixture'], model)) } }],
  67. ['browser', { inject: ['browserUse', 'agents', 'tools', 'systemPrompt'], apply(ctx: Context) {
  68. mountSessionMcp(ctx, { name: 'browser-fixture', exclusive, command: process.execPath, args: [fixture, root, ...mode === undefined ? [] : [mode]], ...toolCallTimeoutMs === undefined ? {} : { toolCallTimeoutMs, env: {} } })
  69. } }],
  70. ])
  71. const configPath = join(root, 'cordis.yml')
  72. await writeFile(configPath, JSON.stringify([...modules.keys()].map(name => ({ id: name, name, config: name === 'loop' ? { agents: [] } : name === 'prompt' && toolOrder !== undefined ? { toolOrder } : {} }))))
  73. const ctx = new Context()
  74. contexts.push(ctx)
  75. ctx.baseUrl = pathToFileURL(root).href + '/'
  76. await ctx.plugin(Loader)
  77. ctx.loader.builtins.include = Include
  78. ctx.loader.internal = {
  79. version: 'v2',
  80. async import(specifier: string) {
  81. if (!modules.has(specifier)) throw new Error(`Unexpected fixture module ${specifier}`)
  82. return modules.get(specifier)
  83. },
  84. } as unknown as NonNullable<typeof ctx.loader.internal>
  85. await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } })
  86. await ctx.loader.await()
  87. const browser = [...ctx.loader.entries()].find(entry => entry.options.id === 'browser')!.fiber!
  88. return { ctx, root, model, browser }
  89. }
  90. async function warm(ctx: Context, agent: Agent, signal = new AbortController().signal) {
  91. await agent.whenIdle()
  92. return ctx.systemPrompt.assemble({ agent, scope: agent, signal })
  93. }
  94. function execute(ctx: Context, agent: Agent, name = TOOL) {
  95. return ctx.tools.execute({ agent, name, arguments: name === TOOL ? { label: 'direct' } : {}, callId: ToolCallId('direct'), signal: new AbortController().signal })
  96. }
  97. function resource(ctx: Context, agent: Agent | undefined, name = 'read_mcp_resource', server = 'browser-fixture', callId = name) {
  98. return ctx.tools.execute({
  99. ...agent === undefined ? {} : { agent }, name,
  100. arguments: { server, ...name === 'read_mcp_resource' ? { uri: 'browser-fixture://state' } : {} },
  101. callId: ToolCallId(callId), signal: new AbortController().signal,
  102. })
  103. }
  104. function browserState(result: Awaited<ReturnType<typeof resource>>): { counter: number; pid: number } {
  105. expect(result.isError).toBe(false)
  106. const value = result.value as { contents: { text: string }[] }
  107. return JSON.parse(value.contents[0]!.text) as { counter: number; pid: number }
  108. }
  109. function registerIndependentTool(ctx: Context) {
  110. ctx.tools.register({
  111. name: 'unrelated', description: 'An independent capability.', parameters: { type: 'object' },
  112. output: { schema: { type: 'boolean' }, render: () => [{ type: 'text', text: 'Independent.' }] },
  113. execute: async () => true,
  114. })
  115. }
  116. async function events(root: string) {
  117. return (await readFile(join(root, 'events.ndjson'), 'utf8')).trim().split('\n').map(line => JSON.parse(line) as { event: string; pid: number; name?: string })
  118. }
  119. it('requires a browser mode and validates launch and attachment settings', () => {
  120. expect(BrowserMcpConfig({ mode: 'launch' })).toEqual({ mode: 'launch', headless: true })
  121. expect(BrowserMcpConfig({ mode: 'launch', headless: false, executablePath: '/chromium', toolCallTimeoutMs: 12 })).toMatchObject({ headless: false, toolCallTimeoutMs: 12 })
  122. expect(BrowserMcpConfig({ mode: 'attach', endpoint: 'wss://browser.example/devtools/browser/id' })).toMatchObject({ mode: 'attach' })
  123. for (const invalid of [{}, { mode: 'attach' }, { mode: 'attach', endpoint: 'file:///tmp/browser' }, { mode: 'launch', toolCallTimeoutMs: 0 }, { mode: 'launch', executablePath: '' }]) {
  124. expect(() => BrowserMcpConfig(invalid as never)).toThrow()
  125. }
  126. for (const endpoint of ['http://localhost:bad/path', 'http://localhost trailing-junk', 'file:///tmp/browser', 'http://localhost/ ']) {
  127. expect(() => { validateBrowserMcpConfig({ mode: 'attach', endpoint }) }).toThrow('browser endpoint')
  128. }
  129. })
  130. it('waits for SystemPrompt and ToolRuntime before reserving the provider', async () => {
  131. const ctx = new Context()
  132. contexts.push(ctx)
  133. await ctx.plugin(BrowserUse)
  134. await ctx.plugin(Agents)
  135. const tools = ctx.plugin(Tools)
  136. const browser = ctx.plugin({
  137. inject: ['browserUse', 'agents', 'tools', 'systemPrompt'],
  138. apply(provider: Context) {
  139. mountSessionMcp(provider, { name: 'browser-fixture', exclusive: true, command: process.execPath, args: [fixture] })
  140. },
  141. })
  142. await Promise.all([tools, browser])
  143. expect(ctx.get('tools')).toBeUndefined()
  144. expect(ctx.browserUse.providerName).toBeUndefined()
  145. await ctx.plugin(SystemPrompt)
  146. await Promise.all([tools, browser])
  147. expect(ctx.browserUse.providerName).toBe('browser-fixture')
  148. })
  149. describe('Session MCP Loader composition', () => {
  150. it('discovers schemas before the first model request and retains distinct state across turns', async () => {
  151. const { ctx, root, model, browser } = await load(false, undefined, 5000, [TOOL, '<unlisted-tools>'])
  152. const first = await ctx.agents.create({ sessionId: SessionId('first'), meta: { cwd: root }, agentOptions: { provider: 'fixture', model: 'fixture' } })
  153. const second = await ctx.agents.create({ sessionId: SessionId('second') })
  154. expect(ctx.tools.schemas()).toEqual([])
  155. first.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Visit the fixture.' }], source: { kind: 'user' } }))
  156. await first.agent.whenIdle()
  157. expect(model.requests).toHaveLength(2)
  158. expect(model.requests[0]?.tools?.find(tool => tool.name === TOOL)).toMatchObject({ description: 'Visit the fixture page.', parameters: { required: ['label'], additionalProperties: false } })
  159. expect(JSON.stringify(first.agent.session.snapshotEvents())).toContain('Visit 1: fixture')
  160. expect((await execute(ctx, first.agent)).content).toEqual([{ type: 'text', text: 'Visit 2: direct' }])
  161. first.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Visit once more.' }], source: { kind: 'user' } }))
  162. await first.agent.whenIdle()
  163. expect(model.requests).toHaveLength(4)
  164. expect(JSON.stringify(first.agent.session.snapshotEvents())).toContain('Visit 3: fixture')
  165. await warm(ctx, second.agent)
  166. expect((await execute(ctx, second.agent)).content).toEqual([{ type: 'text', text: 'Visit 1: direct' }])
  167. expect(ctx.tools.schemas()).toEqual([])
  168. expect(ctx.tools.schemas(first.agent)).toHaveLength(2)
  169. const initial = await events(root)
  170. expect(initial.filter(event => event.event === 'start')).toHaveLength(4)
  171. expect(initial.filter(event => event.event === 'probe')).toHaveLength(2)
  172. expect(initial.filter(event => event.event === 'initialize')).toHaveLength(2)
  173. await first.dispose()
  174. expect(ctx.tools.schemas(first.agent)).toEqual([])
  175. const resumed = await ctx.agents.create({ sessionId: SessionId('first') })
  176. await warm(ctx, resumed.agent)
  177. expect((await execute(ctx, resumed.agent)).content).toEqual([{ type: 'text', text: 'Visit 1: direct' }])
  178. await browser.dispose()
  179. expect(ctx.browserUse.providerName).toBeUndefined()
  180. const closed = await events(root)
  181. for (const { pid } of closed.filter(event => event.event === 'start')) {
  182. expect(() => process.kill(pid, 0)).toThrow(expect.objectContaining({ code: 'ESRCH' }))
  183. }
  184. })
  185. it('keeps unrelated and child Sessions running without a busy attachment and admits a later owner', async () => {
  186. const { ctx, root, model } = await load(true)
  187. registerIndependentTool(ctx)
  188. const first = await ctx.agents.create({ sessionId: SessionId('first') })
  189. const second = await ctx.agents.create({ sessionId: SessionId('second'), agentOptions: { provider: 'fixture', model: 'fixture' } })
  190. const child = await ctx.agents.create({
  191. sessionId: SessionId('child'), parentAgent: first.agent, agentOptions: { provider: 'fixture', model: 'fixture' },
  192. setup: (_inner, agent) => { bindScopeParent(agent, first.agent) },
  193. })
  194. await warm(ctx, first.agent)
  195. expect(ctx.tools.schemas(child.agent).some(tool => tool.name === TOOL)).toBe(false)
  196. for (const { agent } of [second, child]) {
  197. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Answer without using the browser.' }], source: { kind: 'user' } }))
  198. }
  199. await Promise.all([second.agent.whenIdle(), child.agent.whenIdle()])
  200. expect(model.requests).toHaveLength(2)
  201. expect(model.requests.map(request => request.tools?.map(tool => tool.name))).toEqual([['unrelated'], ['unrelated']])
  202. for (const { agent } of [second, child]) {
  203. expect(ctx.tools.schemas(agent).map(tool => tool.name)).toEqual(['unrelated'])
  204. expect((await execute(ctx, agent)).isError).toBe(true)
  205. expect((await execute(ctx, agent, 'unrelated')).isError).toBe(false)
  206. expect((await warm(ctx, agent)).tools.map(tool => tool.name)).toEqual(['unrelated'])
  207. }
  208. first.agent.ctx.tools.register({
  209. name: 'mcp__browser-fixture__late', description: 'A newly discovered browser operation.', parameters: { type: 'object' },
  210. output: { schema: { type: 'boolean' }, render: () => [{ type: 'text', text: 'Late browser result.' }] },
  211. execute: async () => true,
  212. })
  213. expect(ctx.tools.schemas(child.agent).filter(tool => tool.name.startsWith('mcp__browser-fixture__'))).toEqual([])
  214. expect((await warm(ctx, child.agent)).tools.map(tool => tool.name)).toEqual(['unrelated'])
  215. expect((await execute(ctx, child.agent, 'mcp__browser-fixture__late')).isError).toBe(true)
  216. expect((await events(root)).filter(event => event.event === 'initialize')).toHaveLength(1)
  217. expect((await events(root)).filter(event => event.event === 'call')).toEqual([])
  218. expect((await execute(ctx, first.agent)).isError).toBe(false)
  219. await first.dispose()
  220. expect((await warm(ctx, child.agent)).tools.some(tool => tool.name === TOOL)).toBe(false)
  221. expect((await execute(ctx, child.agent)).isError).toBe(true)
  222. expect((await warm(ctx, second.agent)).tools.map(tool => tool.name)).toEqual(['unrelated'])
  223. const successor = await ctx.agents.create({ sessionId: SessionId('successor') })
  224. expect((await warm(ctx, successor.agent)).tools.some(tool => tool.name === TOOL)).toBe(true)
  225. expect((await execute(ctx, successor.agent)).isError).toBe(false)
  226. await successor.dispose()
  227. await second.dispose()
  228. const resumed = await ctx.agents.create({ sessionId: SessionId('second') })
  229. expect((await warm(ctx, resumed.agent)).tools.some(tool => tool.name === TOOL)).toBe(true)
  230. expect((await execute(ctx, resumed.agent)).isError).toBe(false)
  231. })
  232. it('allows another Session to answer while attached-browser discovery is pending', async () => {
  233. const { ctx, root, model, browser } = await load(true, 'hold')
  234. const first = ctx.agents.create({ sessionId: SessionId('first') })
  235. const rejected = expect(first).rejects.toThrow()
  236. await vi.waitFor(async () => { expect((await events(root)).some(event => event.event === 'probe')).toBe(true) })
  237. const second = await ctx.agents.create({ sessionId: SessionId('second'), agentOptions: { provider: 'fixture', model: 'fixture' } })
  238. second.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Answer without using the browser.' }], source: { kind: 'user' } }))
  239. await second.agent.whenIdle()
  240. expect(model.requests).toHaveLength(1)
  241. expect(model.requests[0]?.tools ?? []).toEqual([])
  242. expect((await events(root)).filter(event => event.event === 'start')).toHaveLength(1)
  243. await browser.dispose()
  244. await rejected
  245. expect(ctx.agents.get(SessionId('first'))).toBeUndefined()
  246. expect(ctx.sessions.get(SessionId('first'))).toBeUndefined()
  247. })
  248. it('rolls back failed discovery and stops a child when unload interrupts discovery', async () => {
  249. const failed = await load(false, 'fail', undefined, [TOOL, '<unlisted-tools>'])
  250. let failedAgent!: Agent
  251. const laterListener = vi.fn()
  252. await expect(failed.ctx.agents.create({
  253. sessionId: SessionId('failure'), agentOptions: { provider: 'fixture', model: 'fixture' },
  254. setup: (inner, agent) => {
  255. failedAgent = agent
  256. inner.on('agent/created', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Visit the fixture.' }], source: { kind: 'user' } })) }, { prepend: true })
  257. inner.on('agent/created', laterListener)
  258. },
  259. })).rejects.toThrow('initial connection')
  260. expect(failed.model.requests).toEqual([])
  261. expect(laterListener).not.toHaveBeenCalled()
  262. expect(failed.ctx.agents.get(failedAgent.id)).toBeUndefined()
  263. expect(failed.ctx.sessions.get(failedAgent.id)).toBeUndefined()
  264. expect(failed.ctx.tools.schemas(failedAgent)).toEqual([])
  265. const failedEvents = await events(failed.root)
  266. expect(failedEvents.filter(event => event.event === 'initialize')).toHaveLength(1)
  267. for (const { pid } of failedEvents.filter(event => event.event === 'start')) {
  268. expect(() => process.kill(pid, 0)).toThrow(expect.objectContaining({ code: 'ESRCH' }))
  269. }
  270. const held = await load(false, 'hold')
  271. const pendingOwner = held.ctx.agents.create({ sessionId: SessionId('pending') })
  272. const interrupted = expect(pendingOwner).rejects.toThrow()
  273. await vi.waitFor(async () => { expect((await events(held.root)).some(event => event.event === 'probe')).toBe(true) })
  274. await held.browser.dispose()
  275. await interrupted
  276. expect(held.ctx.agents.get(SessionId('pending'))).toBeUndefined()
  277. for (const { pid } of (await events(held.root)).filter(event => event.event === 'start')) {
  278. expect(() => process.kill(pid, 0)).toThrow(expect.objectContaining({ code: 'ESRCH' }))
  279. }
  280. })
  281. it('does not reconnect and silently replace browser state after a process exits', async () => {
  282. const { ctx, root } = await load()
  283. const owner = await ctx.agents.create({ sessionId: SessionId('disconnect') })
  284. await warm(ctx, owner.agent)
  285. const servingPid = (await events(root)).find(event => event.event === 'initialize')!.pid
  286. await execute(ctx, owner.agent, 'mcp__browser-fixture__disconnect')
  287. await vi.waitFor(() => { expect(() => process.kill(servingPid, 0)).toThrow(expect.objectContaining({ code: 'ESRCH' })) })
  288. await warm(ctx, owner.agent)
  289. expect((await events(root)).filter(event => event.event === 'initialize')).toHaveLength(1)
  290. expect((await execute(ctx, owner.agent)).isError).toBe(true)
  291. })
  292. it('keeps browser ownership exact when scopes inherit tools and leaves unrelated tools usable', async () => {
  293. const { ctx } = await load()
  294. await ctx.systemPrompt.assemble()
  295. const owner = await ctx.agents.create({ sessionId: SessionId('parent') })
  296. const child = await ctx.agents.create({
  297. sessionId: SessionId('child'), parentAgent: owner.agent,
  298. setup: (_inner, agent) => { bindScopeParent(agent, owner.agent) },
  299. })
  300. await warm(ctx, owner.agent)
  301. registerIndependentTool(ctx)
  302. expect((await execute(ctx, owner.agent, 'unrelated')).content).toEqual([{ type: 'text', text: 'Independent.' }])
  303. await warm(ctx, child.agent)
  304. expect((await execute(ctx, child.agent)).content).toEqual([{ type: 'text', text: 'Visit 1: direct' }])
  305. })
  306. it('denies inherited browser resources and instructions while keeping unrelated MCP servers usable', async () => {
  307. const { ctx, root } = await load(true)
  308. await ctx.plugin(McpResources)
  309. ctx.mcpResources.register('docs', { request: async () => ({ contents: [{ uri: 'docs://memo', text: 'Independent document.' }] }) })
  310. const parent = await ctx.agents.create({ sessionId: SessionId('resource-parent') })
  311. const child = await ctx.agents.create({
  312. sessionId: SessionId('resource-child'), parentAgent: parent.agent,
  313. setup: (_inner, agent) => { bindScopeParent(agent, parent.agent) },
  314. })
  315. expect(renderPrompt(await warm(ctx, parent.agent))).toContain('BROWSER_FIXTURE_INSTRUCTION')
  316. const blocked = await warm(ctx, child.agent)
  317. expect(blocked.tools.some(tool => tool.name === TOOL)).toBe(false)
  318. expect(renderPrompt(blocked)).not.toContain('BROWSER_FIXTURE_INSTRUCTION')
  319. expect(renderPrompt(blocked)).toContain('browser-fixture')
  320. for (const name of ['list_mcp_resources', 'list_mcp_resource_templates', 'read_mcp_resource']) {
  321. expect((await resource(ctx, child.agent, name)).isError).toBe(true)
  322. }
  323. expect((await resource(ctx, undefined)).isError).toBe(true)
  324. expect((await events(root)).filter(event => event.event === 'resource')).toEqual([])
  325. expect((await resource(ctx, child.agent, 'read_mcp_resource', 'docs')).value)
  326. .toEqual({ contents: [{ uri: 'docs://memo', text: 'Independent document.' }] })
  327. for (const args of [null, 'invalid arguments']) {
  328. expect((await ctx.tools.execute({ agent: child.agent, name: 'read_mcp_resource', arguments: args, callId: ToolCallId('invalid-resource'), signal: new AbortController().signal })).isError).toBe(true)
  329. }
  330. expect(browserState(await resource(ctx, parent.agent)).counter).toBe(0)
  331. await parent.dispose()
  332. expect(renderPrompt(await warm(ctx, child.agent))).not.toContain('BROWSER_FIXTURE_INSTRUCTION')
  333. expect((await resource(ctx, child.agent)).isError).toBe(true)
  334. const successor = await ctx.agents.create({ sessionId: SessionId('resource-successor') })
  335. expect(renderPrompt(await warm(ctx, successor.agent))).toContain('BROWSER_FIXTURE_INSTRUCTION')
  336. expect(browserState(await resource(ctx, successor.agent)).counter).toBe(0)
  337. })
  338. it('uses the child connection for resources and serializes them with browser tools exactly once', async () => {
  339. const { ctx } = await load()
  340. await ctx.plugin(McpResources)
  341. const parent = await ctx.agents.create({ sessionId: SessionId('parent') })
  342. const child = await ctx.agents.create({
  343. sessionId: SessionId('child'), parentAgent: parent.agent,
  344. setup: (_inner, agent) => { bindScopeParent(agent, parent.agent) },
  345. })
  346. await warm(ctx, parent.agent)
  347. await execute(ctx, parent.agent)
  348. await warm(ctx, child.agent)
  349. const parentOnly = vi.fn(async () => true)
  350. parent.agent.ctx.tools.register({
  351. name: 'mcp__browser-fixture__parent_only', description: 'A browser operation available only in the parent.', parameters: { type: 'object' },
  352. output: { schema: { type: 'boolean' }, render: () => [] }, execute: parentOnly,
  353. })
  354. expect((await execute(ctx, child.agent, 'mcp__browser-fixture__parent_only')).isError).toBe(true)
  355. expect(parentOnly).not.toHaveBeenCalled()
  356. const parentState = browserState(await resource(ctx, parent.agent))
  357. const childState = browserState(await resource(ctx, child.agent))
  358. expect(parentState.counter).toBe(1)
  359. expect(childState.counter).toBe(0)
  360. expect(childState.pid).not.toBe(parentState.pid)
  361. const entered = Promise.withResolvers<undefined>()
  362. const release = Promise.withResolvers<undefined>()
  363. const started: string[] = []
  364. const stop = ctx.on('tools/execute', async (exec, next) => {
  365. if (exec.agent === child.agent) {
  366. started.push(exec.name)
  367. if (exec.callId === ToolCallId('held-resource')) {
  368. entered.resolve(undefined)
  369. await release.promise
  370. }
  371. }
  372. return next()
  373. })
  374. try {
  375. const reading = resource(ctx, child.agent, 'read_mcp_resource', 'browser-fixture', 'held-resource')
  376. await entered.promise
  377. const visiting = execute(ctx, child.agent)
  378. expect((await execute(ctx, parent.agent)).isError).toBe(false)
  379. expect(started).toEqual(['read_mcp_resource'])
  380. release.resolve(undefined)
  381. expect(browserState(await reading).counter).toBe(0)
  382. expect((await visiting).isError).toBe(false)
  383. expect(started).toEqual(['read_mcp_resource', TOOL])
  384. expect(browserState(await resource(ctx, child.agent)).counter).toBe(1)
  385. } finally {
  386. release.resolve(undefined)
  387. stop()
  388. }
  389. })
  390. it('holds an immediate PTC turn until the first SDK includes browser tools', async () => {
  391. const { ctx, root, model } = await load(false, 'gate', undefined, ['run_code', '<unlisted-tools>'])
  392. await ctx.plugin(PresentationRuntime)
  393. let created = false
  394. let initializing!: Agent
  395. const creation = ctx.agents.create({
  396. sessionId: SessionId('ptc-first-request'), agentOptions: { provider: 'fixture', model: 'fixture' },
  397. setup: (inner, agent) => {
  398. initializing = agent
  399. inner.tools.presentAs('ptc')
  400. inner.on('agent/created', () => {
  401. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Describe the available browser tools.' }], source: { kind: 'user' } }))
  402. }, { prepend: true })
  403. },
  404. }).then((handle) => {
  405. created = true
  406. return handle
  407. })
  408. await vi.waitFor(async () => { expect((await events(root)).some(event => event.event === 'probe')).toBe(true) })
  409. expect(created).toBe(false)
  410. expect(model.requests).toEqual([])
  411. expect((await execute(ctx, initializing)).isError).toBe(true)
  412. await writeFile(join(root, 'release'), '')
  413. const owner = await creation
  414. await owner.agent.whenIdle()
  415. expect(model.requests).toHaveLength(1)
  416. expect(model.requests[0]?.tools?.map(tool => tool.name)).toEqual(['run_code'])
  417. expect(JSON.stringify(model.requests[0]?.messages)).toContain(TOOL)
  418. })
  419. it('cancels creation during discovery, closes its process, and releases the attachment', async () => {
  420. const { ctx, root, model } = await load(true, 'gate')
  421. const controller = new AbortController()
  422. let initializing!: Agent
  423. const creation = ctx.agents.create({
  424. sessionId: SessionId('canceled-startup'), signal: controller.signal,
  425. agentOptions: { provider: 'fixture', model: 'fixture' },
  426. setup: (inner, agent) => {
  427. initializing = agent
  428. inner.on('agent/created', () => {
  429. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Visit the fixture.' }], source: { kind: 'user' } }))
  430. }, { prepend: true })
  431. },
  432. })
  433. const rejected = expect(creation).rejects.toThrow('cancel browser startup')
  434. await vi.waitFor(async () => { expect((await events(root)).some(event => event.event === 'probe')).toBe(true) })
  435. controller.abort(new Error('cancel browser startup'))
  436. await rejected
  437. expect(model.requests).toEqual([])
  438. expect(ctx.agents.get(initializing.id)).toBeUndefined()
  439. expect(ctx.sessions.get(initializing.id)).toBeUndefined()
  440. expect(ctx.tools.schemas(initializing)).toEqual([])
  441. expect((await execute(ctx, initializing)).isError).toBe(true)
  442. for (const { pid } of (await events(root)).filter(event => event.event === 'start')) {
  443. expect(() => process.kill(pid, 0)).toThrow(expect.objectContaining({ code: 'ESRCH' }))
  444. }
  445. await writeFile(join(root, 'release'), '')
  446. const successor = await ctx.agents.create({ sessionId: initializing.id })
  447. expect((await execute(ctx, successor.agent)).isError).toBe(false)
  448. })
  449. it('closes the discovered client when a later creation listener rejects', async () => {
  450. const { ctx, root, model } = await load(false, undefined, undefined, [TOOL, '<unlisted-tools>'])
  451. let initializing!: Agent
  452. await expect(ctx.agents.create({
  453. sessionId: SessionId('later-listener-failure'), agentOptions: { provider: 'fixture', model: 'fixture' },
  454. setup: (inner, agent) => {
  455. initializing = agent
  456. inner.on('agent/created', () => {
  457. expect(ctx.tools.schemas(agent).some(tool => tool.name === TOOL)).toBe(true)
  458. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Visit the fixture.' }], source: { kind: 'user' } }))
  459. throw new Error('later initialization failed')
  460. })
  461. },
  462. })).rejects.toThrow('later initialization failed')
  463. expect(model.requests).toEqual([])
  464. expect(ctx.agents.get(initializing.id)).toBeUndefined()
  465. expect(ctx.sessions.get(initializing.id)).toBeUndefined()
  466. expect(ctx.tools.schemas(initializing)).toEqual([])
  467. expect((await execute(ctx, initializing)).isError).toBe(true)
  468. for (const { pid } of (await events(root)).filter(event => event.event === 'start')) {
  469. expect(() => process.kill(pid, 0)).toThrow(expect.objectContaining({ code: 'ESRCH' }))
  470. }
  471. })
  472. it('cancels before browser discovery without starting a process', async () => {
  473. const { ctx, root, model } = await load(false, 'gate')
  474. const controller = new AbortController()
  475. await expect(ctx.agents.create({
  476. sessionId: SessionId('canceled-at-creation'), signal: controller.signal,
  477. agentOptions: { provider: 'fixture', model: 'fixture' },
  478. setup: (inner) => {
  479. inner.on('agent/created', () => { controller.abort(new Error('cancel before discovery')) }, { prepend: true })
  480. },
  481. })).rejects.toThrow('cancel before discovery')
  482. expect(model.requests).toEqual([])
  483. expect(ctx.agents.get(SessionId('canceled-at-creation'))).toBeUndefined()
  484. await expect(readFile(join(root, 'events.ndjson'))).rejects.toMatchObject({ code: 'ENOENT' })
  485. })
  486. it('awaits discovery on persisted resume and releases a canceled resume before retry', async () => {
  487. const { ctx, root, model } = await load(true, 'gate')
  488. await ctx.plugin(JsonlSessionPersistence, { root: join(root, 'sessions') })
  489. await writeFile(join(root, 'release'), '')
  490. const sessionId = SessionId('persisted-browser')
  491. const first = await ctx.agents.create({ sessionId, agentOptions: { provider: 'fixture', model: 'fixture' } })
  492. first.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Visit the fixture.' }], source: { kind: 'user' } }))
  493. await first.agent.whenIdle()
  494. expect(model.requests).toHaveLength(2)
  495. await first.dispose()
  496. await rm(join(root, 'release'))
  497. const controller = new AbortController()
  498. const canceledResume = ctx.agents.resume({
  499. resumeSessionId: sessionId, signal: controller.signal,
  500. agentOptions: { provider: 'fixture', model: 'fixture' },
  501. setup: (inner, agent) => {
  502. inner.on('agent/created', () => {
  503. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Visit again.' }], source: { kind: 'user' } }))
  504. }, { prepend: true })
  505. },
  506. })
  507. const rejected = expect(canceledResume).rejects.toThrow('cancel browser resume')
  508. await vi.waitFor(async () => { expect((await events(root)).filter(event => event.event === 'probe')).toHaveLength(2) })
  509. expect(model.requests).toHaveLength(2)
  510. controller.abort(new Error('cancel browser resume'))
  511. await rejected
  512. expect(ctx.agents.get(sessionId)).toBeUndefined()
  513. expect(ctx.sessions.get(sessionId)).toBeUndefined()
  514. for (const { pid } of (await events(root)).filter(event => event.event === 'start')) {
  515. expect(() => process.kill(pid, 0)).toThrow(expect.objectContaining({ code: 'ESRCH' }))
  516. }
  517. let resumed = false
  518. const resuming = ctx.agents.resume({ resumeSessionId: sessionId }).then((handle) => {
  519. resumed = true
  520. return handle
  521. })
  522. await vi.waitFor(async () => { expect((await events(root)).filter(event => event.event === 'probe')).toHaveLength(3) })
  523. expect(resumed).toBe(false)
  524. expect(model.requests).toHaveLength(2)
  525. await writeFile(join(root, 'release'), '')
  526. const owner = await resuming
  527. expect(ctx.tools.schemas(owner.agent).some(tool => tool.name === TOOL)).toBe(true)
  528. expect((await execute(ctx, owner.agent)).content).toEqual([{ type: 'text', text: 'Visit 1: direct' }])
  529. await owner.dispose()
  530. })
  531. it('initializes only future activations after provider reload', async () => {
  532. const { ctx, root, browser } = await load()
  533. await browser.dispose()
  534. const existing = await ctx.agents.create({ sessionId: SessionId('existing') })
  535. await ctx.plugin({
  536. inject: ['browserUse', 'agents', 'tools', 'systemPrompt'],
  537. apply(inner: Context) {
  538. mountSessionMcp(inner, { name: 'browser-fixture', exclusive: false, command: process.execPath, args: [fixture, root] })
  539. },
  540. })
  541. expect((await warm(ctx, existing.agent)).tools).toEqual([])
  542. const future = await ctx.agents.create({ sessionId: SessionId('future') })
  543. expect((await warm(ctx, future.agent)).tools.some(tool => tool.name === TOOL)).toBe(true)
  544. expect((await execute(ctx, existing.agent)).isError).toBe(true)
  545. expect((await execute(ctx, future.agent)).isError).toBe(false)
  546. })
  547. })