From 23b548858f53a32370af7536914e8a92be66f9e1 Mon Sep 17 00:00:00 2001 From: DaniAkash Date: Tue, 23 Jun 2026 17:06:57 +0530 Subject: [PATCH 1/3] feat(agent-mcp-interface): tab activity registry + GET /tabs/activity route Wraps the existing executeTool dispatch in mcp/register.ts so every successful browser-tool call is recorded against the calling agent and the targeted CDP target id. Failed dispatches and tools without a page arg (tab_groups, windows, run) are skipped. Records live in an in-memory map keyed by targetId; status is derived at read time (active for 5s after the last tool, idle afterwards). Closed tabs are evicted lazily when the next snapshot read finds the pageId no longer maps to the original targetId (pageIds are reused after close). The GET /tabs/activity route surfaces the current snapshot and is mounted into the AppType chain so the UI hono-rpc client picks it up automatically. No server-package edits anywhere; the cockpit reads PageManager via the shared BrowserSession singleton it already owns. --- .../src/lib/tab-activity/extract-page-id.ts | 41 ++++ .../src/lib/tab-activity/index.ts | 20 ++ .../src/lib/tab-activity/registry.ts | 114 +++++++++ .../agent-mcp-interface/src/mcp/register.ts | 35 +++ .../src/routes/tabs/index.ts | 20 ++ .../apps/agent-mcp-interface/src/server.ts | 2 + .../lib/tab-activity/extract-page-id.test.ts | 61 +++++ .../tests/lib/tab-activity/registry.test.ts | 229 ++++++++++++++++++ .../tests/routes/tabs/routes.test.ts | 83 +++++++ 9 files changed, 605 insertions(+) create mode 100644 packages/browseros-agent/apps/agent-mcp-interface/src/lib/tab-activity/extract-page-id.ts create mode 100644 packages/browseros-agent/apps/agent-mcp-interface/src/lib/tab-activity/index.ts create mode 100644 packages/browseros-agent/apps/agent-mcp-interface/src/lib/tab-activity/registry.ts create mode 100644 packages/browseros-agent/apps/agent-mcp-interface/src/routes/tabs/index.ts create mode 100644 packages/browseros-agent/apps/agent-mcp-interface/tests/lib/tab-activity/extract-page-id.test.ts create mode 100644 packages/browseros-agent/apps/agent-mcp-interface/tests/lib/tab-activity/registry.test.ts create mode 100644 packages/browseros-agent/apps/agent-mcp-interface/tests/routes/tabs/routes.test.ts diff --git a/packages/browseros-agent/apps/agent-mcp-interface/src/lib/tab-activity/extract-page-id.ts b/packages/browseros-agent/apps/agent-mcp-interface/src/lib/tab-activity/extract-page-id.ts new file mode 100644 index 000000000..5fc3b31f6 --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/src/lib/tab-activity/extract-page-id.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Pulls the `page` argument out of a browser-tool dispatch so the + * cockpit's tab-activity registry can attribute the call to a tab. + * Tools without a `page` parameter (`tab_groups`, `windows`, `run`) + * always yield null. Tools that accept it optionally (`tabs` action + * variants like `list` vs `close`) yield null when the caller omits + * it. Non-integer / non-positive values are rejected to keep the + * registry from holding garbage keys. + */ + +const TOOLS_WITH_PAGE: ReadonlySet = new Set([ + 'act', + 'diff', + 'download', + 'evaluate', + 'grep', + 'navigate', + 'pdf', + 'read', + 'screenshot', + 'snapshot', + 'tabs', + 'upload', + 'wait', +]) + +export function extractPageId( + toolName: string, + rawArgs: unknown, +): number | null { + if (!TOOLS_WITH_PAGE.has(toolName)) return null + if (!rawArgs || typeof rawArgs !== 'object') return null + const page = (rawArgs as { page?: unknown }).page + if (typeof page !== 'number') return null + if (!Number.isInteger(page) || page < 1) return null + return page +} diff --git a/packages/browseros-agent/apps/agent-mcp-interface/src/lib/tab-activity/index.ts b/packages/browseros-agent/apps/agent-mcp-interface/src/lib/tab-activity/index.ts new file mode 100644 index 000000000..a1648020f --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/src/lib/tab-activity/index.ts @@ -0,0 +1,20 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Process-wide singleton registry. Bound to the same + * `getBrowserSession` accessor the rest of the cockpit uses so the + * registry sees the same `PageManager` instance the tool dispatches + * write to. + */ + +import { getBrowserSession } from '../browser-session' +import { createTabActivityRegistry, type TabActivityRegistry } from './registry' + +export const tabActivityRegistry: TabActivityRegistry = + createTabActivityRegistry({ getSession: getBrowserSession }) + +export { extractPageId } from './extract-page-id' +export type { TabActivityRecord, TabActivityRegistry } from './registry' +export { ACTIVE_WINDOW_MS, createTabActivityRegistry } from './registry' diff --git a/packages/browseros-agent/apps/agent-mcp-interface/src/lib/tab-activity/registry.ts b/packages/browseros-agent/apps/agent-mcp-interface/src/lib/tab-activity/registry.ts new file mode 100644 index 000000000..e91e38d91 --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/src/lib/tab-activity/registry.ts @@ -0,0 +1,114 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * In-memory registry mapping a stable CDP target id to the most + * recent agent-tool dispatch that touched it. The cockpit's + * `mcp/register.ts` wrapper writes a record after every successful + * `executeTool` call; the homepage polls `GET /tabs/activity` to + * render the live view. + * + * `status` is derived at read time: a record is `active` when the + * last tool fired within `ACTIVE_WINDOW_MS`, otherwise `idle`. No + * background timers. Records whose underlying tab has closed are + * evicted lazily on the next `snapshot()` read; we detect that by + * looking up `pageId` on the live `PageManager` and confirming the + * targetId still matches (pageIds are reused after a tab closes). + */ + +import type { BrowserSession } from '@browseros/server/browser/core/session' + +export interface TabActivityRecord { + targetId: string + pageId: number + url: string + title: string + agentId: string + slug: string + lastToolAt: number + lastToolName: string + status: 'active' | 'idle' +} + +export const ACTIVE_WINDOW_MS = 5000 + +export interface RegistryDeps { + getSession(): BrowserSession | null + now?: () => number +} + +interface RawRecord { + targetId: string + pageId: number + agentId: string + slug: string + lastToolAt: number + lastToolName: string +} + +export interface TabActivityRegistry { + recordTool(input: { + agentId: string + slug: string + pageId: number + targetId: string + toolName: string + }): void + snapshot(): TabActivityRecord[] + // Test-only escape hatch; lets unit tests assert eviction without + // mocking BrowserSession internals. + size(): number +} + +export function createTabActivityRegistry( + deps: RegistryDeps, +): TabActivityRegistry { + const records = new Map() + const now = deps.now ?? (() => Date.now()) + + return { + recordTool(input) { + records.set(input.targetId, { + targetId: input.targetId, + pageId: input.pageId, + agentId: input.agentId, + slug: input.slug, + lastToolAt: now(), + lastToolName: input.toolName, + }) + }, + snapshot() { + const session = deps.getSession() + if (!session) return [] + const out: TabActivityRecord[] = [] + const t = now() + for (const [targetId, raw] of records) { + const live = session.pages.getInfo(raw.pageId) + // PageManager reuses pageId after a tab closes; the targetId + // is the stable identity. If they no longer match, the + // original tab is gone (the pageId may now belong to a + // different tab). + if (!live || live.targetId !== targetId) { + records.delete(targetId) + continue + } + out.push({ + targetId: raw.targetId, + pageId: raw.pageId, + url: live.url, + title: live.title, + agentId: raw.agentId, + slug: raw.slug, + lastToolAt: raw.lastToolAt, + lastToolName: raw.lastToolName, + status: t - raw.lastToolAt < ACTIVE_WINDOW_MS ? 'active' : 'idle', + }) + } + return out.sort((a, b) => b.lastToolAt - a.lastToolAt) + }, + size() { + return records.size + }, + } +} diff --git a/packages/browseros-agent/apps/agent-mcp-interface/src/mcp/register.ts b/packages/browseros-agent/apps/agent-mcp-interface/src/mcp/register.ts index fe11acaef..adc4c91ea 100644 --- a/packages/browseros-agent/apps/agent-mcp-interface/src/mcp/register.ts +++ b/packages/browseros-agent/apps/agent-mcp-interface/src/mcp/register.ts @@ -35,6 +35,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { ZodRawShape } from 'zod' import { getBrowserSession } from '../lib/browser-session' import { logger } from '../lib/logger' +import { extractPageId, tabActivityRegistry } from '../lib/tab-activity' import type { StoredAgentProfile } from '../routes/agents/schemas' import { check } from '../services/permissions' import { asRegister, type ToolResult } from './register-fn' @@ -117,6 +118,32 @@ function domainForCall( return agent.selectedSites[0] ?? '*' } +/** + * Records a successful dispatch into the tab-activity registry. The + * homepage attributes the tab to the agent and surfaces the latest + * tool name. Failed dispatches and tools without a `page` arg are + * skipped at the call site by `extractPageId` returning `null`. + */ +function recordSuccessfulDispatch(args: { + toolName: string + rawArgs: unknown + agent: StoredAgentProfile + session: ReturnType +}): void { + if (!args.session) return + const pageId = extractPageId(args.toolName, args.rawArgs) + if (pageId === null) return + const live = args.session.pages.getInfo(pageId) + if (!live) return + tabActivityRegistry.recordTool({ + agentId: args.agent.id, + slug: args.agent.slug, + pageId, + targetId: live.targetId, + toolName: args.toolName, + }) +} + export function registerBrowserTools( server: McpServer, agent: StoredAgentProfile, @@ -214,6 +241,14 @@ export function registerBrowserTools( session, signal: extra?.signal, }) + if (!result.isError) { + recordSuccessfulDispatch({ + toolName: tool.name, + rawArgs, + agent, + session, + }) + } return { content: result.content as ToolResult['content'], isError: result.isError, diff --git a/packages/browseros-agent/apps/agent-mcp-interface/src/routes/tabs/index.ts b/packages/browseros-agent/apps/agent-mcp-interface/src/routes/tabs/index.ts new file mode 100644 index 000000000..8f3fb439c --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/src/routes/tabs/index.ts @@ -0,0 +1,20 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Read endpoint backing the cockpit homepage's "which tabs are + * being driven right now" view. The registry behind this route is + * fed by `apps/agent-mcp-interface/src/mcp/register.ts` every time a + * browser tool dispatch succeeds; this route just publishes the + * current snapshot. Polling is the v1 transport (the UI hook polls + * every 1500 ms); SSE on `?stream=1` is a future option if polling + * proves chatty. + */ + +import { Hono } from 'hono' +import { tabActivityRegistry } from '../../lib/tab-activity' + +export const tabsRoute = new Hono().get('/tabs/activity', (c) => + c.json({ tabs: tabActivityRegistry.snapshot() }), +) diff --git a/packages/browseros-agent/apps/agent-mcp-interface/src/server.ts b/packages/browseros-agent/apps/agent-mcp-interface/src/server.ts index 3f30bb71b..246330461 100644 --- a/packages/browseros-agent/apps/agent-mcp-interface/src/server.ts +++ b/packages/browseros-agent/apps/agent-mcp-interface/src/server.ts @@ -22,6 +22,7 @@ import { mcpRoute } from './routes/mcp' import { permissionsRoute } from './routes/permissions' import { siteRulesRoute } from './routes/site-rules' import { systemRoute } from './routes/system' +import { tabsRoute } from './routes/tabs' // Telemetry capture is injectable so the server module stays usable // from the bun-test runner without pulling Sentry into the import @@ -70,6 +71,7 @@ const routes = app .route('/', siteRulesRoute) .route('/', permissionsRoute) .route('/', mcpRoute) + .route('/', tabsRoute) export type AppType = typeof routes export default routes diff --git a/packages/browseros-agent/apps/agent-mcp-interface/tests/lib/tab-activity/extract-page-id.test.ts b/packages/browseros-agent/apps/agent-mcp-interface/tests/lib/tab-activity/extract-page-id.test.ts new file mode 100644 index 000000000..cfe8a6a73 --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/tests/lib/tab-activity/extract-page-id.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'bun:test' +import { extractPageId } from '../../../src/lib/tab-activity/extract-page-id' + +describe('extractPageId', () => { + it('returns the page id for every tool that takes a page arg', () => { + for (const tool of [ + 'act', + 'diff', + 'download', + 'evaluate', + 'grep', + 'navigate', + 'pdf', + 'read', + 'screenshot', + 'snapshot', + 'tabs', + 'upload', + 'wait', + ]) { + expect(extractPageId(tool, { page: 7 })).toBe(7) + } + }) + + it('returns null for tools without a page arg', () => { + expect(extractPageId('tab_groups', { page: 7 })).toBeNull() + expect(extractPageId('windows', { page: 7 })).toBeNull() + expect(extractPageId('run', { page: 7 })).toBeNull() + }) + + it('returns null for unknown tools', () => { + expect(extractPageId('completely_unknown', { page: 1 })).toBeNull() + }) + + it('returns null when page is missing', () => { + expect(extractPageId('navigate', { url: 'https://example.com' })).toBeNull() + expect(extractPageId('navigate', {})).toBeNull() + }) + + it('returns null when page is not a number', () => { + expect(extractPageId('navigate', { page: '7' })).toBeNull() + expect(extractPageId('navigate', { page: null })).toBeNull() + expect(extractPageId('navigate', { page: undefined })).toBeNull() + }) + + it('returns null for non-integer page', () => { + expect(extractPageId('navigate', { page: 1.5 })).toBeNull() + }) + + it('returns null for non-positive page', () => { + expect(extractPageId('navigate', { page: 0 })).toBeNull() + expect(extractPageId('navigate', { page: -1 })).toBeNull() + }) + + it('returns null for non-object args', () => { + expect(extractPageId('navigate', null)).toBeNull() + expect(extractPageId('navigate', undefined)).toBeNull() + expect(extractPageId('navigate', 'page=1')).toBeNull() + expect(extractPageId('navigate', 42)).toBeNull() + }) +}) diff --git a/packages/browseros-agent/apps/agent-mcp-interface/tests/lib/tab-activity/registry.test.ts b/packages/browseros-agent/apps/agent-mcp-interface/tests/lib/tab-activity/registry.test.ts new file mode 100644 index 000000000..13f29b2a8 --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/tests/lib/tab-activity/registry.test.ts @@ -0,0 +1,229 @@ +import { beforeEach, describe, expect, it } from 'bun:test' +import type { BrowserSession } from '@browseros/server/browser/core/session' +import { + ACTIVE_WINDOW_MS, + createTabActivityRegistry, + type TabActivityRegistry, +} from '../../../src/lib/tab-activity/registry' + +interface FakePageInfo { + targetId: string + url: string + title: string +} + +function makeSession(pages: Map): BrowserSession { + return { + pages: { + getInfo: (pageId: number) => pages.get(pageId) ?? undefined, + }, + } as unknown as BrowserSession +} + +describe('TabActivityRegistry', () => { + let pages: Map + let session: BrowserSession + let nowMs: number + let registry: TabActivityRegistry + + beforeEach(() => { + pages = new Map() + session = makeSession(pages) + nowMs = 1_000_000 + registry = createTabActivityRegistry({ + getSession: () => session, + now: () => nowMs, + }) + }) + + it('records a tool dispatch and surfaces it via snapshot', () => { + pages.set(1, { targetId: 't1', url: 'https://example.com/', title: 'Ex' }) + registry.recordTool({ + agentId: 'a1', + slug: 'finance-ops', + pageId: 1, + targetId: 't1', + toolName: 'navigate', + }) + const snap = registry.snapshot() + expect(snap).toHaveLength(1) + expect(snap[0]).toMatchObject({ + targetId: 't1', + pageId: 1, + url: 'https://example.com/', + title: 'Ex', + agentId: 'a1', + slug: 'finance-ops', + lastToolName: 'navigate', + status: 'active', + }) + }) + + it('updates an existing record rather than appending a duplicate', () => { + pages.set(1, { targetId: 't1', url: 'https://example.com/', title: 'Ex' }) + registry.recordTool({ + agentId: 'a1', + slug: 'finance-ops', + pageId: 1, + targetId: 't1', + toolName: 'navigate', + }) + nowMs += 1000 + registry.recordTool({ + agentId: 'a1', + slug: 'finance-ops', + pageId: 1, + targetId: 't1', + toolName: 'snapshot', + }) + const snap = registry.snapshot() + expect(snap).toHaveLength(1) + expect(snap[0].lastToolName).toBe('snapshot') + expect(snap[0].lastToolAt).toBe(1_001_000) + }) + + it('marks records active within the window and idle outside it', () => { + pages.set(1, { targetId: 't1', url: 'https://example.com/', title: 'Ex' }) + registry.recordTool({ + agentId: 'a1', + slug: 'finance-ops', + pageId: 1, + targetId: 't1', + toolName: 'navigate', + }) + expect(registry.snapshot()[0].status).toBe('active') + nowMs += ACTIVE_WINDOW_MS - 1 + expect(registry.snapshot()[0].status).toBe('active') + nowMs += 2 + expect(registry.snapshot()[0].status).toBe('idle') + }) + + it('evicts records whose pageId no longer maps to the original targetId', () => { + pages.set(1, { targetId: 't1', url: 'https://example.com/', title: 'Ex' }) + registry.recordTool({ + agentId: 'a1', + slug: 'finance-ops', + pageId: 1, + targetId: 't1', + toolName: 'navigate', + }) + expect(registry.size()).toBe(1) + // The tab closes, pageId 1 is reused by a fresh tab with a new targetId. + pages.set(1, { targetId: 't2-different', url: 'about:blank', title: '' }) + expect(registry.snapshot()).toHaveLength(0) + expect(registry.size()).toBe(0) + }) + + it('evicts records whose pageId no longer exists at all', () => { + pages.set(1, { targetId: 't1', url: 'https://example.com/', title: 'Ex' }) + registry.recordTool({ + agentId: 'a1', + slug: 'finance-ops', + pageId: 1, + targetId: 't1', + toolName: 'navigate', + }) + pages.delete(1) + expect(registry.snapshot()).toHaveLength(0) + expect(registry.size()).toBe(0) + }) + + it('returns an empty snapshot when no session is connected', () => { + pages.set(1, { targetId: 't1', url: 'https://example.com/', title: 'Ex' }) + registry.recordTool({ + agentId: 'a1', + slug: 'finance-ops', + pageId: 1, + targetId: 't1', + toolName: 'navigate', + }) + const detached = createTabActivityRegistry({ + getSession: () => null, + now: () => nowMs, + }) + detached.recordTool({ + agentId: 'a1', + slug: 'finance-ops', + pageId: 1, + targetId: 't1', + toolName: 'navigate', + }) + expect(detached.snapshot()).toEqual([]) + }) + + it('keeps separate records per target id', () => { + pages.set(1, { targetId: 't1', url: 'https://a.com/', title: 'A' }) + pages.set(2, { targetId: 't2', url: 'https://b.com/', title: 'B' }) + registry.recordTool({ + agentId: 'a1', + slug: 'finance', + pageId: 1, + targetId: 't1', + toolName: 'navigate', + }) + nowMs += 100 + registry.recordTool({ + agentId: 'a2', + slug: 'travel', + pageId: 2, + targetId: 't2', + toolName: 'read', + }) + const snap = registry.snapshot() + expect(snap).toHaveLength(2) + expect(snap.map((r) => r.targetId)).toEqual(['t2', 't1']) + }) + + it('sorts the snapshot by lastToolAt descending', () => { + pages.set(1, { targetId: 't1', url: 'https://a.com/', title: 'A' }) + pages.set(2, { targetId: 't2', url: 'https://b.com/', title: 'B' }) + registry.recordTool({ + agentId: 'a1', + slug: 'finance', + pageId: 1, + targetId: 't1', + toolName: 'navigate', + }) + nowMs += 100 + registry.recordTool({ + agentId: 'a2', + slug: 'travel', + pageId: 2, + targetId: 't2', + toolName: 'read', + }) + nowMs += 100 + registry.recordTool({ + agentId: 'a1', + slug: 'finance', + pageId: 1, + targetId: 't1', + toolName: 'snapshot', + }) + const snap = registry.snapshot() + expect(snap.map((r) => r.targetId)).toEqual(['t1', 't2']) + }) + + it('last write wins on agent attribution when two agents touch the same tab', () => { + pages.set(1, { targetId: 't1', url: 'https://a.com/', title: 'A' }) + registry.recordTool({ + agentId: 'a1', + slug: 'finance', + pageId: 1, + targetId: 't1', + toolName: 'navigate', + }) + nowMs += 100 + registry.recordTool({ + agentId: 'a2', + slug: 'travel', + pageId: 1, + targetId: 't1', + toolName: 'snapshot', + }) + const snap = registry.snapshot() + expect(snap).toHaveLength(1) + expect(snap[0].agentId).toBe('a2') + expect(snap[0].slug).toBe('travel') + }) +}) diff --git a/packages/browseros-agent/apps/agent-mcp-interface/tests/routes/tabs/routes.test.ts b/packages/browseros-agent/apps/agent-mcp-interface/tests/routes/tabs/routes.test.ts new file mode 100644 index 000000000..d1cbaa7b6 --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/tests/routes/tabs/routes.test.ts @@ -0,0 +1,83 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Integration test for the /tabs/activity route. Pins the response + * shape and the empty-state behaviour. The registry-population path + * is exercised by mcp/register tests; this file only verifies the + * route surface. + */ + +import { afterEach, describe, expect, test } from 'bun:test' +import { hc } from 'hono/client' +import { setBrowserSession } from '../../../src/lib/browser-session' +import { tabActivityRegistry } from '../../../src/lib/tab-activity' +import app, { type AppType } from '../../../src/server' + +function client() { + return hc('http://localhost', { + fetch: (input, init) => app.fetch(new Request(input, init)), + }) +} + +afterEach(() => { + // Clear the singleton registry between cases so test ordering does + // not leak state. We snapshot then drop everything: there is no + // public clear() so we evict by detaching the session. + setBrowserSession(null) +}) + +describe('/tabs/activity route', () => { + test('returns an empty list when nothing has been recorded', async () => { + const api = client() + const res = await api.tabs.activity.$get() + expect(res.status).toBe(200) + const body = (await res.json()) as { tabs: unknown[] } + expect(body).toEqual({ tabs: [] }) + }) + + test('returns the registry snapshot once tools have been recorded', async () => { + // Plant a fake session whose PageManager resolves a single page, + // record a tool against it, and expect the route to surface it. + setBrowserSession({ + pages: { + getInfo: (pageId: number) => + pageId === 1 + ? { targetId: 't1', url: 'https://example.com/', title: 'Ex' } + : undefined, + }, + // biome-ignore lint/suspicious/noExplicitAny: stub for test + } as any) + tabActivityRegistry.recordTool({ + agentId: 'a-1', + slug: 'finance-ops', + pageId: 1, + targetId: 't1', + toolName: 'navigate', + }) + const api = client() + const res = await api.tabs.activity.$get() + expect(res.status).toBe(200) + const body = (await res.json()) as { + tabs: Array<{ + targetId: string + agentId: string + slug: string + toolName?: string + lastToolName: string + url: string + status: 'active' | 'idle' + }> + } + expect(body.tabs).toHaveLength(1) + expect(body.tabs[0]).toMatchObject({ + targetId: 't1', + agentId: 'a-1', + slug: 'finance-ops', + lastToolName: 'navigate', + url: 'https://example.com/', + status: 'active', + }) + }) +}) From 4daee64d4c30e021a1e80d605cc58774b9046b6d Mon Sep 17 00:00:00 2001 From: DaniAkash Date: Tue, 23 Jun 2026 17:11:48 +0530 Subject: [PATCH 2/3] feat(agent-mcp-ui): drive cockpit homepage from real tab-activity polling useTabsActivity polls GET /cockpit/tabs/activity every 1500ms via the existing hono-rpc client; cockpit.data composes that with the existing mocked approvals/handoffs so the screen calls one hook only. Active records become RunningGrid cards (status=running); idle records become RecentActivity rows (status=done) with a relative-time string. Helper file derives a stable per-slug color, parses the site from the URL, and formats the relative timestamp. Mocked useAgents / useRecentActivity stay in place for any other surface that imports them; the homepage just stops consuming them. --- .../agent-mcp-ui/modules/api/tabs.hooks.ts | 40 +++++++ .../agent-mcp-ui/screens/cockpit/Cockpit.tsx | 26 ++--- .../screens/cockpit/cockpit.data.ts | 44 ++++++++ .../screens/cockpit/cockpit.helpers.test.ts | 105 ++++++++++++++++++ .../screens/cockpit/cockpit.helpers.ts | 86 ++++++++++++++ 5 files changed, 284 insertions(+), 17 deletions(-) create mode 100644 packages/browseros-agent/apps/agent-mcp-ui/modules/api/tabs.hooks.ts create mode 100644 packages/browseros-agent/apps/agent-mcp-ui/screens/cockpit/cockpit.data.ts create mode 100644 packages/browseros-agent/apps/agent-mcp-ui/screens/cockpit/cockpit.helpers.test.ts create mode 100644 packages/browseros-agent/apps/agent-mcp-ui/screens/cockpit/cockpit.helpers.ts diff --git a/packages/browseros-agent/apps/agent-mcp-ui/modules/api/tabs.hooks.ts b/packages/browseros-agent/apps/agent-mcp-ui/modules/api/tabs.hooks.ts new file mode 100644 index 000000000..be36c51eb --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-ui/modules/api/tabs.hooks.ts @@ -0,0 +1,40 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Polls `GET /cockpit/tabs/activity` so the homepage can render a + * live view of which tabs each agent has touched and how recently. + * Backed by the in-memory registry in + * `apps/agent-mcp-interface/src/lib/tab-activity/`; refer to that + * module for the record shape and the active-window threshold. + */ + +import { createQuery } from 'react-query-kit' +import { api } from './client' +import { parseResponse } from './parseResponse' + +export interface TabActivityRecord { + targetId: string + pageId: number + url: string + title: string + agentId: string + slug: string + lastToolAt: number + lastToolName: string + status: 'active' | 'idle' +} + +interface TabsActivityResponse { + tabs: TabActivityRecord[] +} + +export const useTabsActivity = createQuery({ + queryKey: ['tabs', 'activity'], + fetcher: async () => { + const res = await api.tabs.activity.$get() + return parseResponse(res) + }, + refetchInterval: 1500, +}) diff --git a/packages/browseros-agent/apps/agent-mcp-ui/screens/cockpit/Cockpit.tsx b/packages/browseros-agent/apps/agent-mcp-ui/screens/cockpit/Cockpit.tsx index 1472f0e4c..eede5e35a 100644 --- a/packages/browseros-agent/apps/agent-mcp-ui/screens/cockpit/Cockpit.tsx +++ b/packages/browseros-agent/apps/agent-mcp-ui/screens/cockpit/Cockpit.tsx @@ -2,35 +2,27 @@ import { CockpitHero } from '@/components/cockpit/CockpitHero' import { RecentActivity } from '@/components/cockpit/RecentActivity' import { RunningGrid } from '@/components/cockpit/RunningGrid' import { WaitingStrip } from '@/components/cockpit/WaitingStrip' -import { useRecentActivity } from '@/modules/api/activity.hooks' -import { useAgents } from '@/modules/api/agents.hooks' -import { useApprovals, useHandoffs } from '@/modules/api/waiting.hooks' +import { useCockpitData } from './cockpit.data' /** * Cockpit home. Four stacked sections matching the design's dashboard * order: hero, waiting strip (sticky-attention surface), running * grid (the agents themselves), recent activity (cross-agent log). * - * Data comes from mock hooks for now; each hook is `react-query-kit` - * with a setTimeout fetcher so loading states render and the eventual - * swap to the real agent-mcp-interface endpoints is a fetcher-body - * change rather than a refactor. + * PR 1 wires `RunningGrid` and `RecentActivity` to the real + * `GET /cockpit/tabs/activity` registry; `WaitingStrip`'s approvals + * and handoffs remain on their mocked hooks until later PRs supply + * them. */ export function Cockpit() { - const agents = useAgents() - const activity = useRecentActivity() - const approvals = useApprovals() - const handoffs = useHandoffs() + const { agents, activity, approvals, handoffs } = useCockpitData() return (
- - - + + +
) } diff --git a/packages/browseros-agent/apps/agent-mcp-ui/screens/cockpit/cockpit.data.ts b/packages/browseros-agent/apps/agent-mcp-ui/screens/cockpit/cockpit.data.ts new file mode 100644 index 000000000..49ee4b16f --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-ui/screens/cockpit/cockpit.data.ts @@ -0,0 +1,44 @@ +import type { ActivityRow } from '@/modules/api/activity.hooks' +import type { AgentRow } from '@/modules/api/agents.hooks' +import { useTabsActivity } from '@/modules/api/tabs.hooks' +import { + type ApprovalItem, + type HandoffItem, + useApprovals, + useHandoffs, +} from '@/modules/api/waiting.hooks' +import { tabsToActivityRows, tabsToAgentRows } from './cockpit.helpers' + +export interface CockpitData { + agents: AgentRow[] + activity: ActivityRow[] + approvals: ApprovalItem[] + handoffs: HandoffItem[] + isPending: boolean +} + +/** + * Single data aggregation hook for the homepage. Per the project + * convention, the screen calls this and nothing else. PR 1 wires the + * running grid and recent activity to the real + * `GET /cockpit/tabs/activity` registry; approvals and handoffs + * remain on their mocked hooks until later PRs supply them. + */ +export function useCockpitData(): CockpitData { + const tabs = useTabsActivity() + const approvals = useApprovals() + const handoffs = useHandoffs() + + // We pass `Date.now()` at render time; the slight non-determinism + // is fine for a 1.5s-polling display and avoids dragging a clock + // injection through the component tree. + const records = tabs.data?.tabs ?? [] + const now = Date.now() + return { + agents: tabsToAgentRows(records), + activity: tabsToActivityRows(records, now), + approvals: approvals.data ?? [], + handoffs: handoffs.data ?? [], + isPending: tabs.isPending, + } +} diff --git a/packages/browseros-agent/apps/agent-mcp-ui/screens/cockpit/cockpit.helpers.test.ts b/packages/browseros-agent/apps/agent-mcp-ui/screens/cockpit/cockpit.helpers.test.ts new file mode 100644 index 000000000..a50a3bc06 --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-ui/screens/cockpit/cockpit.helpers.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'bun:test' +import type { TabActivityRecord } from '@/modules/api/tabs.hooks' +import { + colorForSlug, + formatRelative, + siteOf, + tabsToActivityRows, + tabsToAgentRows, +} from './cockpit.helpers' + +function record(over: Partial = {}): TabActivityRecord { + return { + targetId: 't1', + pageId: 1, + url: 'https://example.com/foo', + title: 'Ex', + agentId: 'a1', + slug: 'finance', + lastToolAt: 1_000_000, + lastToolName: 'navigate', + status: 'active', + ...over, + } +} + +describe('siteOf', () => { + it('returns the host without leading www', () => { + expect(siteOf('https://www.example.com/foo')).toBe('example.com') + expect(siteOf('https://docs.google.com/sheets/abc')).toBe('docs.google.com') + }) + + it('falls back to the raw url for invalid input', () => { + expect(siteOf('not a url')).toBe('not a url') + }) +}) + +describe('formatRelative', () => { + it('returns seconds within a minute', () => { + expect(formatRelative(99_000, 99_500)).toBe('0s ago') + expect(formatRelative(95_000, 100_000)).toBe('5s ago') + }) + it('returns minutes within an hour', () => { + expect(formatRelative(0, 60_000)).toBe('1m ago') + expect(formatRelative(0, 3_540_000)).toBe('59m ago') + }) + it('returns hours within a day', () => { + expect(formatRelative(0, 3_600_000)).toBe('1h ago') + expect(formatRelative(0, 23 * 3_600_000)).toBe('23h ago') + }) + it('returns days otherwise', () => { + expect(formatRelative(0, 24 * 3_600_000)).toBe('1d ago') + }) +}) + +describe('colorForSlug', () => { + it('is deterministic per slug', () => { + expect(colorForSlug('finance')).toBe(colorForSlug('finance')) + }) + it('returns a hex string', () => { + expect(colorForSlug('travel')).toMatch(/^#[0-9A-F]{6}$/i) + }) +}) + +describe('tabsToAgentRows', () => { + it('filters out idle records and maps to AgentRow shape', () => { + const rows = tabsToAgentRows([ + record({ targetId: 't1', status: 'active', slug: 'finance' }), + record({ targetId: 't2', status: 'idle', slug: 'travel' }), + ]) + expect(rows.map((r) => r.id)).toEqual(['t1']) + expect(rows[0]).toMatchObject({ + label: 'finance', + harness: 'Claude Code', + site: 'example.com', + task: 'Ex', + status: 'running', + }) + }) +}) + +describe('tabsToActivityRows', () => { + it('filters out active records and maps to ActivityRow shape', () => { + const rows = tabsToActivityRows( + [ + record({ targetId: 't1', status: 'active' }), + record({ + targetId: 't2', + status: 'idle', + slug: 'travel', + lastToolAt: 950_000, + lastToolName: 'read', + }), + ], + 1_000_000, + ) + expect(rows.map((r) => r.id)).toEqual(['t2']) + expect(rows[0]).toMatchObject({ + agentLabel: 'travel', + status: 'done', + action: 'read on Ex', + site: 'example.com', + when: '50s ago', + }) + }) +}) diff --git a/packages/browseros-agent/apps/agent-mcp-ui/screens/cockpit/cockpit.helpers.ts b/packages/browseros-agent/apps/agent-mcp-ui/screens/cockpit/cockpit.helpers.ts new file mode 100644 index 000000000..4f5aa3411 --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-ui/screens/cockpit/cockpit.helpers.ts @@ -0,0 +1,86 @@ +import type { ActivityRow } from '@/modules/api/activity.hooks' +import type { AgentRow } from '@/modules/api/agents.hooks' +import type { TabActivityRecord } from '@/modules/api/tabs.hooks' + +// Small palette so each agent gets a stable colour without joining a +// profile lookup. Hash the slug; if two agents collide it is purely +// cosmetic. +const PALETTE = [ + '#F26B2A', + '#2F6FE0', + '#7A5AF8', + '#10A37F', + '#E0561C', + '#0EA5E9', + '#F59E0B', + '#DB2777', +] + +export function colorForSlug(slug: string): string { + let hash = 0 + for (let i = 0; i < slug.length; i++) { + hash = (hash * 31 + slug.charCodeAt(i)) >>> 0 + } + return PALETTE[hash % PALETTE.length] ?? PALETTE[0] +} + +export function siteOf(url: string): string { + try { + return new URL(url).hostname.replace(/^www\./, '') + } catch { + return url + } +} + +export function formatRelative(ms: number, now: number): string { + const delta = Math.max(0, now - ms) + const seconds = Math.floor(delta / 1000) + if (seconds < 60) return `${seconds}s ago` + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${minutes}m ago` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h ago` + const days = Math.floor(hours / 24) + return `${days}d ago` +} + +/** + * Tabs whose `status === 'active'` become live agent cards. The + * label, harness, and color are derived locally from the slug since + * PR 1 does not join the agent profile. + */ +export function tabsToAgentRows(records: TabActivityRecord[]): AgentRow[] { + return records + .filter((r) => r.status === 'active') + .map((r) => ({ + id: r.targetId, + label: r.slug, + harness: 'Claude Code', + site: siteOf(r.url), + task: r.title || siteOf(r.url), + status: 'running' as const, + liveLine: `${r.lastToolName} - ${r.title || siteOf(r.url)}`, + color: colorForSlug(r.slug), + })) +} + +/** + * Idle records flow into RecentActivity so the user can see the last + * thing each agent did on a tab even after the active window expires. + */ +export function tabsToActivityRows( + records: TabActivityRecord[], + now: number, +): ActivityRow[] { + return records + .filter((r) => r.status === 'idle') + .map((r) => ({ + id: r.targetId, + agentLabel: r.slug, + color: colorForSlug(r.slug), + status: 'done' as const, + action: `${r.lastToolName} on ${r.title || siteOf(r.url)}`, + site: siteOf(r.url), + when: formatRelative(r.lastToolAt, now), + })) +} From 306c3d96544e12fcc84a0fad5f3a9f754053723f Mon Sep 17 00:00:00 2001 From: DaniAkash Date: Tue, 23 Jun 2026 17:23:14 +0530 Subject: [PATCH 3/3] fix(cockpit): test-isolation clear() + honest isPending + harness TODO - TabActivityRegistry gains a clear() escape hatch next to size(); the routes/tabs test now calls it in afterEach so a stale record from one test cannot surface in another that re-attaches a session. - useCockpitData isPending now OR-combines tabs/approvals/handoffs so any future caller wiring a spinner sees the actual loading state. - tabsToAgentRows annotates the hardcoded harness with a TODO pointing at the PR-3 profile join so the simplification stays visible. --- .../src/lib/tab-activity/registry.ts | 10 ++++++++-- .../tests/routes/tabs/routes.test.ts | 8 ++++++-- .../apps/agent-mcp-ui/screens/cockpit/cockpit.data.ts | 2 +- .../agent-mcp-ui/screens/cockpit/cockpit.helpers.ts | 3 +++ 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/browseros-agent/apps/agent-mcp-interface/src/lib/tab-activity/registry.ts b/packages/browseros-agent/apps/agent-mcp-interface/src/lib/tab-activity/registry.ts index e91e38d91..e63a27f5c 100644 --- a/packages/browseros-agent/apps/agent-mcp-interface/src/lib/tab-activity/registry.ts +++ b/packages/browseros-agent/apps/agent-mcp-interface/src/lib/tab-activity/registry.ts @@ -56,9 +56,12 @@ export interface TabActivityRegistry { toolName: string }): void snapshot(): TabActivityRecord[] - // Test-only escape hatch; lets unit tests assert eviction without - // mocking BrowserSession internals. + // Test-only escape hatches; let unit tests assert eviction and + // restore isolation without mocking BrowserSession internals. The + // singleton lives across the whole test run, so explicit clearing + // is the only safe way to keep `afterEach` honest. size(): number + clear(): void } export function createTabActivityRegistry( @@ -110,5 +113,8 @@ export function createTabActivityRegistry( size() { return records.size }, + clear() { + records.clear() + }, } } diff --git a/packages/browseros-agent/apps/agent-mcp-interface/tests/routes/tabs/routes.test.ts b/packages/browseros-agent/apps/agent-mcp-interface/tests/routes/tabs/routes.test.ts index d1cbaa7b6..9b2ca6cf6 100644 --- a/packages/browseros-agent/apps/agent-mcp-interface/tests/routes/tabs/routes.test.ts +++ b/packages/browseros-agent/apps/agent-mcp-interface/tests/routes/tabs/routes.test.ts @@ -23,8 +23,12 @@ function client() { afterEach(() => { // Clear the singleton registry between cases so test ordering does - // not leak state. We snapshot then drop everything: there is no - // public clear() so we evict by detaching the session. + // not leak state. Setting the session to null short-circuits + // `snapshot()` but does NOT empty the underlying records Map; only + // the explicit `clear()` does that. Skipping it would leave a stale + // record visible to a later test that re-attaches a session whose + // stub resolves the same pageId. + tabActivityRegistry.clear() setBrowserSession(null) }) diff --git a/packages/browseros-agent/apps/agent-mcp-ui/screens/cockpit/cockpit.data.ts b/packages/browseros-agent/apps/agent-mcp-ui/screens/cockpit/cockpit.data.ts index 49ee4b16f..afc2b78ac 100644 --- a/packages/browseros-agent/apps/agent-mcp-ui/screens/cockpit/cockpit.data.ts +++ b/packages/browseros-agent/apps/agent-mcp-ui/screens/cockpit/cockpit.data.ts @@ -39,6 +39,6 @@ export function useCockpitData(): CockpitData { activity: tabsToActivityRows(records, now), approvals: approvals.data ?? [], handoffs: handoffs.data ?? [], - isPending: tabs.isPending, + isPending: tabs.isPending || approvals.isPending || handoffs.isPending, } } diff --git a/packages/browseros-agent/apps/agent-mcp-ui/screens/cockpit/cockpit.helpers.ts b/packages/browseros-agent/apps/agent-mcp-ui/screens/cockpit/cockpit.helpers.ts index 4f5aa3411..92c3a56a4 100644 --- a/packages/browseros-agent/apps/agent-mcp-ui/screens/cockpit/cockpit.helpers.ts +++ b/packages/browseros-agent/apps/agent-mcp-ui/screens/cockpit/cockpit.helpers.ts @@ -55,6 +55,9 @@ export function tabsToAgentRows(records: TabActivityRecord[]): AgentRow[] { .map((r) => ({ id: r.targetId, label: r.slug, + // TODO(pr-3 homepage): join the agent profile and surface the + // real harness; today every row reads "Claude Code" because the + // TabActivityRecord does not carry it. harness: 'Claude Code', site: siteOf(r.url), task: r.title || siteOf(r.url),