Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/desktop/fixture-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
DesktopCaptureBackend,
CaptureDesktopOptions,
DesktopElement,
DesktopTargetSummary,
ExecuteDesktopOptions,
ExecuteDesktopResult,
} from './types'
Expand Down Expand Up @@ -51,6 +52,22 @@ export class FixtureBackend implements DesktopCaptureBackend {
this.latencyMs = opts.latencyMs ?? 0
}

async listTargets(): Promise<DesktopTargetSummary[]> {
if (this.latencyMs) await delay(this.latencyMs)

// One summary per registered preset. Use the preset key as
// window_id so a subsequent capture({ target: { window_id: key }})
// resolves back to the same preset.
return Object.entries(this.presets).map(([key, cap]) => ({
window_id: key,
process_name: cap.process_name,
process_id: cap.process_id,
window_title: cap.window_title,
window_class: cap.window_class,
has_focus: key === this.defaultPreset,
}))
}

async capture(opts: CaptureDesktopOptions = {}): Promise<DesktopCapture> {
if (this.latencyMs) await delay(this.latencyMs)

Expand Down
22 changes: 22 additions & 0 deletions src/desktop/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ export interface DesktopCaptureBackend {
* or `fixture` for tests. */
readonly name: string

/** Enumerate top-level windows the backend can see. Returns lightweight
* summaries (no element tree) so an agent can pick a target before
* paying for the full capture. */
listTargets(): Promise<DesktopTargetSummary[]>

/** Capture the current state of a target window/application. */
capture(opts: CaptureDesktopOptions): Promise<DesktopCapture>

Expand All @@ -34,6 +39,23 @@ export interface DesktopCaptureBackend {
close?(): Promise<void>
}

/** Lightweight summary of one open window — what `listTargets()` returns. */
export interface DesktopTargetSummary {
/** Opaque handle the caller passes back as `target.window_id` to
* capture this specific window. Format is backend-defined
* (Windows: `hwnd:0x...`; macOS: `axapi:<pid>:<index>`; fixture:
* the preset key). */
window_id: string
process_name?: string
process_id?: number
window_title: string
/** Toolkit / class hint — Windows class name (e.g. `XLMAIN`),
* macOS AX role (e.g. `AXWindow`), useful for UI inspectors. */
window_class?: string
/** True when this window currently has keyboard focus. */
has_focus: boolean
}

/** Identifies a target window. Backends accept any combination they can
* resolve; if nothing is provided the currently focused window is used. */
export interface DesktopTarget {
Expand Down
24 changes: 24 additions & 0 deletions src/desktop/windows-uia-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import type {
CaptureDesktopOptions,
DesktopCapture,
DesktopCaptureBackend,
DesktopTargetSummary,
ExecuteDesktopAction,
ExecuteDesktopOptions,
ExecuteDesktopResult,
Expand Down Expand Up @@ -118,6 +119,20 @@ export class WindowsUiaBackend implements DesktopCaptureBackend {

// ── DesktopCaptureBackend implementation ─────────────────────────

async listTargets(): Promise<DesktopTargetSummary[]> {
await this.ensureStarted()
const result = (await this.call('list_windows', {})) as { windows?: RawWindowSummary[] }
const raw = result?.windows ?? []
return raw.map((w) => ({
window_id: w.windowId,
process_name: w.processName ?? undefined,
process_id: w.processId ?? undefined,
window_title: w.windowTitle,
window_class: w.windowClass ?? undefined,
has_focus: !!w.hasFocus,
}))
}

async capture(opts: CaptureDesktopOptions = {}): Promise<DesktopCapture> {
await this.ensureStarted()
const params = {
Expand Down Expand Up @@ -416,6 +431,15 @@ function resolveBridgePath(): string {
// Wire format — raw shapes returned by the bridge
// ──────────────────────────────────────────────────────────────────────

interface RawWindowSummary {
windowId: string
processName?: string | null
processId?: number | null
windowTitle: string
windowClass?: string | null
hasFocus: boolean
}

interface RawDesktopCapture {
platform: 'windows' | 'macos' | 'linux'
processName?: string | null
Expand Down
11 changes: 11 additions & 0 deletions src/mcp/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ export async function dispatch(
return await openDesktop(state, args)
case 'agentmark_desktop_close':
return await closeDesktop(state, args)
case 'agentmark_desktop_list_targets':
return await desktopListTargets(state, args)
case 'agentmark_desktop_snapshot':
return await desktopSnapshot(state, args)
case 'agentmark_desktop_execute':
Expand Down Expand Up @@ -395,6 +397,15 @@ async function closeDesktop(state: DispatcherState, args: Record<string, unknown
return { text: `Desktop session ${id} closed.` }
}

async function desktopListTargets(state: DispatcherState, args: Record<string, unknown>): Promise<DispatchResult> {
const id = requireString(args, 'desktop_id')
const session = requireDesktop(state, id)
const windows = await session.backend.listTargets()
return {
text: JSON.stringify({ windows }, null, 2),
}
}

async function desktopSnapshot(state: DispatcherState, args: Record<string, unknown>): Promise<DispatchResult> {
const id = requireString(args, 'desktop_id')
const session = requireDesktop(state, id)
Expand Down
17 changes: 17 additions & 0 deletions src/mcp/tool-defs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,23 @@ const DESKTOP_TOOLS: McpToolDef[] = [
required: ['desktop_id'],
},
},
{
name: 'agentmark_desktop_list_targets',
description:
'Enumerate top-level windows the backend can see. Returns one '
+ 'lightweight summary per window (process_name, process_id, '
+ 'window_title, window_class, window_id, has_focus) without '
+ 'walking the full element tree. Use this to let the user (or '
+ 'agent) pick a window before calling agentmark_desktop_snapshot '
+ 'with that specific window_id.',
inputSchema: {
type: 'object',
properties: {
desktop_id: { type: 'string' },
},
required: ['desktop_id'],
},
},
{
name: 'agentmark_desktop_snapshot',
description:
Expand Down
13 changes: 13 additions & 0 deletions test/desktop/windows-uia-backend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,19 @@ describe('WindowsUiaBackend', () => {
expect(() => new WindowsUiaBackend({ bridgePath: FAKE_BRIDGE })).toThrow(/requires Windows/)
})

it('listTargets maps the bridge `windows` array to snake_case DesktopTargetSummary', async () => {
backend = makeBackend()
const targets = await backend.listTargets()
expect(targets.length).toBeGreaterThan(0)
const w = targets[0]
// The fake bridge ships one synthetic window.
expect(w.window_id).toMatch(/^hwnd:/)
expect(w.window_title).toBe('Fake Window 1')
expect(w.process_name).toBe('FakeApp.exe')
expect(w.process_id).toBe(42)
expect(w.has_focus).toBe(true)
})

it('captures via the bridge and maps the response to DesktopCapture', async () => {
backend = makeBackend()
const cap = await backend.capture({})
Expand Down
33 changes: 32 additions & 1 deletion test/mcp/desktop-dispatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,45 @@ afterEach(async () => {
})

describe('MCP — desktop tools', () => {
it('registers all four desktop tools in ALL_TOOLS', () => {
it('registers all five desktop tools in ALL_TOOLS', () => {
const names = ALL_TOOLS.map(t => t.name)
expect(names).toContain('agentmark_desktop_open')
expect(names).toContain('agentmark_desktop_close')
expect(names).toContain('agentmark_desktop_list_targets')
expect(names).toContain('agentmark_desktop_snapshot')
expect(names).toContain('agentmark_desktop_execute')
})

it('agentmark_desktop_list_targets returns the fixture preset windows', async () => {
const open = await dispatch(state, 'agentmark_desktop_open', {})
const { desktop_id } = JSON.parse(open.text)

const result = await dispatch(state, 'agentmark_desktop_list_targets', { desktop_id })
expect(result.isError).toBeFalsy()

const body = JSON.parse(result.text)
expect(Array.isArray(body.windows)).toBe(true)
// Fixture ships two preset shapes: excel_blank and nowcerts_customer.
// Each is exposed under two keys (excel + excel_blank; nowcerts + nowcerts_customer)
// so length is 4. Don't over-constrain on the exact number; just
// require both preset families are represented.
const titles = body.windows.map((w: { window_title: string }) => w.window_title)
expect(titles.some((t: string) => t.includes('Excel'))).toBe(true)
expect(titles.some((t: string) => t.includes('NowCerts'))).toBe(true)
// Every entry has the right shape.
for (const w of body.windows) {
expect(w).toHaveProperty('window_id')
expect(w).toHaveProperty('window_title')
expect(typeof w.has_focus).toBe('boolean')
}
})

it('agentmark_desktop_list_targets errors on unknown desktop_id', async () => {
const result = await dispatch(state, 'agentmark_desktop_list_targets', { desktop_id: 'dt_missing' })
expect(result.isError).toBe(true)
expect(result.text).toContain('Unknown desktop_id')
})

it('agentmark_desktop_open with no args defaults to the fixture backend', async () => {
const result = await dispatch(state, 'agentmark_desktop_open', {})
expect(result.isError).toBeFalsy()
Expand Down
Loading