Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
1b143aa
feat: recut desktop grounding v1 into a clean review slice
Apr 12, 2026
40d42ff
feat(computer-use-mcp): add browser-native action routing for chrome_…
Apr 12, 2026
c69c46e
feat(computer-use-mcp): add type/checkbox browser-dom routing (v2 sli…
Apr 12, 2026
da1a86d
test(computer-use-mcp): comprehensive v2 browser action routing tests
Apr 12, 2026
7cdfdb2
test(computer-use-mcp): handler-level integration tests for desktop_c…
Apr 12, 2026
17fed12
fix(computer-use-mcp): gate desktop_click_target through policy pipel…
Apr 20, 2026
cba93f9
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 20, 2026
3c45fdc
fix(computer-use-mcp): relay executeAction policy result before repor…
Apr 20, 2026
716043d
fix(computer-use-mcp): expose top-level x/y in getClickTarget and mar…
Apr 23, 2026
6f77ca0
fix(computer-use-mcp): skip stale browser-dom type route on explicit …
Apr 23, 2026
d22a1b7
fix(stage-ui): add explicit mcp-tool-bridge export entry to package.json
Apr 23, 2026
01da3ad
fix(computer-use-mcp): validate bridge frame results and fix textarea…
Apr 23, 2026
81bb526
fix(computer-use-mcp): remove fake click event from checkCheckbox
Apr 23, 2026
9533ceb
fix(stage-tamagotchi,computer-use-mcp): fix mcp-tool-bridge resolutio…
Apr 23, 2026
f5faa43
fix(stage-ui): restore mcp-tool-bridge.ts needed by desktop-overlay
Apr 23, 2026
fd912f8
fix(stage-tamagotchi): wire MCP polling through eventa invoke
Apr 23, 2026
10cd0f0
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 23, 2026
83cb95a
fix(stage-tamagotchi): fix relative import path for shared/eventa
Apr 23, 2026
0f2a417
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 23, 2026
8277725
fix(computer-use-mcp): P1 setInputValue opts, duplicate click guard, …
Apr 23, 2026
335a1e0
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 23, 2026
bdb720a
fix(computer-use-mcp): implement missing browser_dom actions in chrom…
Apr 24, 2026
c29f6a9
fix(computer-use-mcp): address Copilot review — bounds match, README,…
Apr 24, 2026
96917f8
fix(computer-use-mcp): validate clickSelector frame results before re…
Apr 24, 2026
5e3d877
fix(computer-use-mcp): stop routing contenteditable to setInputValue,…
Apr 24, 2026
819f2d3
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 24, 2026
fdb539e
fix(computer-use-mcp): validate clickResults in browser_dom_click han…
Apr 24, 2026
cef7c77
fix(computer-use-mcp): remove double-envelope in content.js message h…
Apr 24, 2026
ab1114d
fix: resolve rebase conflicts with main (1/3 merge)
Apr 24, 2026
9b6bbb3
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 24, 2026
da94fe7
fix(stage-tamagotchi): replace undefined noop with inline arrow
Apr 24, 2026
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
5 changes: 5 additions & 0 deletions apps/stage-tamagotchi/electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,11 @@ export default defineConfig({
alias: {
'@proj-airi/server-sdk': resolve(join(import.meta.dirname, '..', '..', 'packages', 'server-sdk', 'src')),
'@proj-airi/i18n': resolve(join(import.meta.dirname, '..', '..', 'packages', 'i18n', 'src')),
// NOTICE: the @proj-airi/stage-ui alias resolves to a directory; rolldown
// concatenates sub-paths without a file extension, so bare .ts files at the
// stores/ root (e.g. mcp-tool-bridge.ts) are not found. Add explicit aliases
// for each such file that the renderer imports from @proj-airi/stage-ui.
'@proj-airi/stage-ui/stores/mcp-tool-bridge': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src', 'stores', 'mcp-tool-bridge.ts')),
'@proj-airi/stage-ui': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src')),
'@proj-airi/stage-pages': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src')),
'@proj-airi/stage-shared': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-shared', 'src')),
Expand Down
2 changes: 1 addition & 1 deletion apps/stage-tamagotchi/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ app.whenReady().then(async () => {
// provider depends on 'windows:desktop-overlay'.
injeca.invoke({
dependsOn: { desktopOverlay },
callback: noop,
callback: () => {},
})
}

Expand Down
130 changes: 13 additions & 117 deletions apps/stage-tamagotchi/src/renderer/pages/desktop-overlay-polling.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { McpCallToolResult } from '@proj-airi/stage-ui/tools/mcp'

import type { ElectronMcpCallToolResult } from '../../shared/eventa'
import type { OverlayState } from './desktop-overlay-polling'

import { afterEach, describe, expect, it, vi } from 'vitest'
Expand Down Expand Up @@ -162,7 +161,7 @@ describe('createOverlayPollController', () => {
it('calls tool and delivers state on successful poll', async () => {
vi.useFakeTimers()

const mockResult: McpCallToolResult = {
const mockResult: ElectronMcpCallToolResult = {
structuredContent: {
runState: {
lastGroundingSnapshot: {
Expand All @@ -176,7 +175,7 @@ describe('createOverlayPollController', () => {
},
}

const callTool = vi.fn<(name: string) => Promise<McpCallToolResult>>()
const callTool = vi.fn<(name: string) => Promise<ElectronMcpCallToolResult>>()
.mockResolvedValue(mockResult)

const received: OverlayState[] = []
Expand All @@ -201,32 +200,10 @@ describe('createOverlayPollController', () => {
controller.stop()
})

it('clears the per-call timeout when the tool resolves before the timeout fires', async () => {
vi.useFakeTimers()

const callTool = vi.fn<(name: string) => Promise<McpCallToolResult>>()
.mockResolvedValue({ structuredContent: {} })

const controller = createOverlayPollController({
callTool,
onState: () => {},
intervalMs: 100,
callTimeoutMs: 500,
})

controller.start()
await vi.advanceTimersByTimeAsync(0)

// Only the next poll should remain scheduled. The per-call timeout must be cleared.
expect(vi.getTimerCount()).toBe(1)

controller.stop()
})

it('stops polling after stop() is called', async () => {
vi.useFakeTimers()

const callTool = vi.fn<(name: string) => Promise<McpCallToolResult>>()
const callTool = vi.fn<(name: string) => Promise<ElectronMcpCallToolResult>>()
.mockResolvedValue({ structuredContent: {} })

const controller = createOverlayPollController({
Expand All @@ -250,7 +227,7 @@ describe('createOverlayPollController', () => {
it('continues polling after a single failure', async () => {
vi.useFakeTimers()

const callTool = vi.fn<(name: string) => Promise<McpCallToolResult>>()
const callTool = vi.fn<(name: string) => Promise<ElectronMcpCallToolResult>>()
.mockRejectedValueOnce(new Error('MCP down'))
.mockResolvedValue({
structuredContent: {
Expand Down Expand Up @@ -292,7 +269,7 @@ describe('createOverlayPollController', () => {
it('is a no-op to call start() twice', async () => {
vi.useFakeTimers()

const callTool = vi.fn<(name: string) => Promise<McpCallToolResult>>()
const callTool = vi.fn<(name: string) => Promise<ElectronMcpCallToolResult>>()
.mockResolvedValue({ structuredContent: {} })

const controller = createOverlayPollController({
Expand All @@ -313,8 +290,9 @@ describe('createOverlayPollController', () => {
it('recovers from a hanging callTool via per-call timeout', async () => {
vi.useFakeTimers()

const callTool = vi.fn<(name: string) => Promise<McpCallToolResult>>()
.mockImplementationOnce(() => new Promise<McpCallToolResult>(() => {}))
// First call hangs forever (simulates startup race when RPC not ready)
const callTool = vi.fn<(name: string) => Promise<ElectronMcpCallToolResult>>()
.mockImplementationOnce(() => new Promise(() => {})) // never resolves
.mockResolvedValue({
structuredContent: {
runState: {
Expand Down Expand Up @@ -344,97 +322,15 @@ describe('createOverlayPollController', () => {
expect(callTool).toHaveBeenCalledTimes(1)
expect(received).toHaveLength(0)

// Advance past the timeout and several fallback windows. The controller
// should allow a bounded recovery retry even though the original invoke
// is still hung in the background.
await vi.advanceTimersByTimeAsync(500)
await vi.advanceTimersByTimeAsync(200)
expect(callTool).toHaveBeenCalledTimes(2)
expect(received).toHaveLength(1)
expect(received[0].snapshotId).toBe('dg_after_timeout')

controller.stop()
})

it('caps outstanding timed-out polls to avoid unbounded buildup', async () => {
vi.useFakeTimers()

const callTool = vi.fn<(name: string) => Promise<McpCallToolResult>>()
.mockImplementation(() => new Promise<McpCallToolResult>(() => {}))

const controller = createOverlayPollController({
callTool,
onState: () => {},
intervalMs: 100,
fallbackIntervalMs: 200,
callTimeoutMs: 500,
})

controller.start()

await vi.advanceTimersByTimeAsync(0)
expect(callTool).toHaveBeenCalledTimes(1)

await vi.advanceTimersByTimeAsync(500)
await vi.advanceTimersByTimeAsync(200)
expect(callTool).toHaveBeenCalledTimes(2)

// Advance past the 500ms timeout → catch triggers, schedules fallback
await vi.advanceTimersByTimeAsync(500)
await vi.advanceTimersByTimeAsync(1000)
expect(callTool).toHaveBeenCalledTimes(2)

controller.stop()
})

it('recovers again once a timed-out hung-call slot lease expires', async () => {
vi.useFakeTimers()

const callTool = vi.fn<(name: string) => Promise<McpCallToolResult>>()
.mockImplementationOnce(() => new Promise<McpCallToolResult>(() => {}))
.mockImplementationOnce(() => new Promise<McpCallToolResult>(() => {}))
.mockResolvedValue({
structuredContent: {
runState: {
lastGroundingSnapshot: {
snapshotId: 'dg_after_lease',
targetCandidates: [],
staleFlags: { screenshot: false, ax: false, chromeSemantic: false },
},
},
},
})

const received: OverlayState[] = []

const controller = createOverlayPollController({
callTool,
onState: (state) => {
received.push(state)
},
intervalMs: 100,
fallbackIntervalMs: 200,
callTimeoutMs: 500,
hungCallLeaseMs: 1000,
})

controller.start()

await vi.advanceTimersByTimeAsync(0)
expect(callTool).toHaveBeenCalledTimes(1)

await vi.advanceTimersByTimeAsync(500)
await vi.advanceTimersByTimeAsync(200)
expect(callTool).toHaveBeenCalledTimes(2)

await vi.advanceTimersByTimeAsync(500)
await vi.advanceTimersByTimeAsync(200)
expect(callTool).toHaveBeenCalledTimes(2)
expect(received).toHaveLength(0)

// Advance past the 200ms fallback interval → second poll fires and succeeds
await vi.advanceTimersByTimeAsync(200)
expect(callTool).toHaveBeenCalledTimes(3)
expect(callTool).toHaveBeenCalledTimes(2)
expect(received).toHaveLength(1)
expect(received[0].snapshotId).toBe('dg_after_lease')
expect(received[0].snapshotId).toBe('dg_after_timeout')

controller.stop()
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* without a DOM environment or Vue test-utils.
*/

import type { McpCallToolResult } from '@proj-airi/stage-ui/tools/mcp'
import type { ElectronMcpCallToolResult } from '../../shared/eventa'

// ---------------------------------------------------------------------------
// Types — minimal shapes matching RunState fields the overlay consumes
Expand Down Expand Up @@ -90,7 +90,7 @@ export function extractOverlayState(runState: Record<string, unknown>): OverlayS
* Extract runState from an MCP call result.
* Returns undefined if the result is an error or has no structured content.
*/
export function extractRunStateFromResult(result: McpCallToolResult): Record<string, unknown> | undefined {
export function extractRunStateFromResult(result: ElectronMcpCallToolResult): Record<string, unknown> | undefined {
if (result.isError)
return undefined

Expand Down Expand Up @@ -121,7 +121,7 @@ export interface OverlayPollController {

export interface OverlayPollConfig {
/** Function to call MCP tool. */
callTool: (name: string) => Promise<McpCallToolResult>
callTool: (name: string) => Promise<ElectronMcpCallToolResult>
/** Callback with extracted state on each successful poll. */
onState: (state: OverlayState) => void
/** Normal poll interval in ms. Default: 250. */
Expand All @@ -130,15 +130,11 @@ export interface OverlayPollConfig {
fallbackIntervalMs?: number
/** Per-call timeout in ms. Default: 5000. Prevents poll loop hang on startup race. */
callTimeoutMs?: number
/** How long a timed-out background call occupies a recovery slot before we probe again. */
hungCallLeaseMs?: number
}

const DEFAULT_INTERVAL = 250
const DEFAULT_FALLBACK_INTERVAL = 500
const DEFAULT_CALL_TIMEOUT = 5000
const DEFAULT_HUNG_CALL_LEASE = 5000
const MAX_BACKGROUND_HUNG_CALLS = 2

/**
* MCP server name for computer-use-mcp. Matches the key in mcp.json.
Expand All @@ -152,75 +148,21 @@ export const MCP_TOOL_NAME = 'computer_use::desktop_get_state'
export function createOverlayPollController(config: OverlayPollConfig): OverlayPollController {
const normalInterval = config.intervalMs ?? DEFAULT_INTERVAL
const fallbackInterval = config.fallbackIntervalMs ?? DEFAULT_FALLBACK_INTERVAL
const hungCallLeaseMs = config.hungCallLeaseMs ?? DEFAULT_HUNG_CALL_LEASE

let timer: ReturnType<typeof setTimeout> | null = null
let running = false
let inFlightCall: Promise<McpCallToolResult> | null = null
let backgroundHungSlots: Array<{ expiresAt: number }> = []

function scheduleNext(nextInterval: number) {
if (running) {
timer = setTimeout(poll, nextInterval)
}
}

function pruneHungCallSlots(now: number) {
backgroundHungSlots = backgroundHungSlots.filter(slot => slot.expiresAt > now)
}

async function poll() {
pruneHungCallSlots(Date.now())

if (inFlightCall || backgroundHungSlots.length >= MAX_BACKGROUND_HUNG_CALLS) {
scheduleNext(fallbackInterval)
return
}

let nextInterval = normalInterval
let timeoutId: ReturnType<typeof setTimeout> | undefined

try {
// NOTICE: Wrap callTool with a timeout to prevent the poll loop from
// hanging forever if the eventa invoke never resolves (e.g. during
// startup when the main-process RPC handlers may not be ready yet).
// NOTICE: The bridge does not expose abort semantics, so a timed-out
// call may still be pending in the background. We therefore track
// timed-out calls as expiring lease slots: the cap bounds how many
// unrecoverable invokes we tolerate at once, while lease expiry still
// lets the overlay probe again after a cooling-off window.
let timedOutSlot: { expiresAt: number } | null = null
const currentCall = config.callTool(MCP_TOOL_NAME)
inFlightCall = currentCall
currentCall.then(() => {
if (timedOutSlot) {
backgroundHungSlots = backgroundHungSlots.filter(slot => slot !== timedOutSlot)
}
else if (inFlightCall === currentCall) {
inFlightCall = null
}
}, () => {
if (timedOutSlot) {
backgroundHungSlots = backgroundHungSlots.filter(slot => slot !== timedOutSlot)
}
else if (inFlightCall === currentCall) {
inFlightCall = null
}
})

const result = await Promise.race([
currentCall,
config.callTool(MCP_TOOL_NAME),
new Promise<never>((_, reject) =>
Comment on lines 162 to 164
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Cap timed-out overlay polls to prevent invoke buildup

This poll loop races callTool against a timeout but never cancels or tracks the timed-out invoke. If the Eventa call hangs (the code comment already calls out this startup race), each cycle schedules a new invoke while previous ones remain pending indefinitely, so prolonged outages accumulate unresolved RPC calls. Reintroduce an in-flight/lease cap (or equivalent guard) so fallback retries do not create unbounded outstanding calls.

Useful? React with 👍 / 👎.

timeoutId = setTimeout(() => {
timedOutSlot = {
expiresAt: Date.now() + hungCallLeaseMs,
}
backgroundHungSlots = [...backgroundHungSlots, timedOutSlot]
if (inFlightCall === currentCall) {
inFlightCall = null
}
reject(new Error('callTool timeout'))
}, config.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT),
setTimeout(() => reject(new Error('callTool timeout')), config.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT),
),
])
Comment thread
3361559784 marked this conversation as resolved.
const runState = extractRunStateFromResult(result)
Expand All @@ -236,13 +178,10 @@ export function createOverlayPollController(config: OverlayPollConfig): OverlayP
// MCP server not running, bridge disconnected, or timeout — graceful degradation
nextInterval = fallbackInterval
}
finally {
if (timeoutId !== undefined) {
clearTimeout(timeoutId)
}
}

scheduleNext(nextInterval)
if (running) {
timer = setTimeout(poll, nextInterval)
}
}

return {
Expand Down
Loading
Loading