diff --git a/src/ui/shared/canonical-key.ts b/src/ui/shared/canonical-key.ts new file mode 100644 index 00000000..ca44d646 --- /dev/null +++ b/src/ui/shared/canonical-key.ts @@ -0,0 +1,27 @@ +function encodeNumber(value: number): string { + if (Number.isNaN(value)) return 'number:NaN'; + if (value === Infinity) return 'number:Infinity'; + if (value === -Infinity) return 'number:-Infinity'; + if (Object.is(value, -0)) return 'number:-0'; + return `number:${value}`; +} + +export function canonicalRequestKey(value: unknown): string { + if (value === null) return 'null'; + if (Array.isArray(value)) return `array:[${value.map(canonicalRequestKey).join(',')}]`; + + switch (typeof value) { + case 'string': return `string:${JSON.stringify(value)}`; + case 'number': return encodeNumber(value); + case 'boolean': return `boolean:${value}`; + case 'undefined': return 'undefined'; + case 'object': { + const object = value as Record; + return `object:{${Object.keys(object).sort().map( + (key) => `${JSON.stringify(key)}:${canonicalRequestKey(object[key])}` + ).join(',')}}`; + } + default: + throw new TypeError(`Unsupported request-key value: ${typeof value}`); + } +} diff --git a/src/ui/shared/tool-bridge.ts b/src/ui/shared/tool-bridge.ts index 0f43b6c9..d3d74237 100644 --- a/src/ui/shared/tool-bridge.ts +++ b/src/ui/shared/tool-bridge.ts @@ -1,5 +1,7 @@ type ToolArgs = Record; +import { canonicalRequestKey } from './canonical-key.js'; + type ToolHelper = { callTool: (name: string, args: ToolArgs) => Promise | unknown; }; @@ -100,6 +102,7 @@ export function createToolBridge(options: ToolBridgeOptions = {}) { const targetOrigin = normalizeTargetOrigin(options.targetOrigin ?? getDefaultTargetOrigin()); const idPrefix = options.idPrefix ?? 'tool-bridge'; let requestCounter = 0; + const inFlight = new Map>(); async function callViaFallback(name: string, args: ToolArgs): Promise { if (!host.parent || !host.addEventListener || !host.removeEventListener) { @@ -166,8 +169,7 @@ export function createToolBridge(options: ToolBridgeOptions = {}) { }); } - async function callTool(name: string, args?: ToolArgs): Promise { - const normalizedArgs = normalizeToolArgs(args); + async function callToolOnce(name: string, normalizedArgs: ToolArgs): Promise { const helperCandidates = [host.openai, host.mcp].filter( (candidate): candidate is ToolHelper => Boolean(candidate?.callTool) ); @@ -190,6 +192,25 @@ export function createToolBridge(options: ToolBridgeOptions = {}) { } } + async function callTool(name: string, args?: ToolArgs): Promise { + const normalizedArgs = normalizeToolArgs(args); + const key = `${name}:${canonicalRequestKey(normalizedArgs)}`; + const existing = inFlight.get(key); + if (existing) { + return existing; + } + + const pending = callToolOnce(name, normalizedArgs); + inFlight.set(key, pending); + try { + return await pending; + } finally { + if (inFlight.get(key) === pending) { + inFlight.delete(key); + } + } + } + return { callTool, }; diff --git a/src/ui/shared/ui-event-tracker.ts b/src/ui/shared/ui-event-tracker.ts index 9ddde38b..4b5b2c26 100644 --- a/src/ui/shared/ui-event-tracker.ts +++ b/src/ui/shared/ui-event-tracker.ts @@ -1,5 +1,7 @@ type UiEventParamValue = string | number | boolean | null; +import { canonicalRequestKey } from './canonical-key.js'; + export type UiEventParams = Record; type ToolCaller = (name: string, args: Record) => Promise; @@ -27,15 +29,36 @@ function normalizeUiEventParams(params: Record | undefined): Ui export function createUiEventTracker(callTool: ToolCaller, options: UiEventTrackerOptions) { const baseParams = options.baseParams ?? {}; + const recentEvents = new Map(); + const duplicateWindowMs = 250; return (event: string, params: Record = {}): void => { + const normalizedParams = { + ...baseParams, + ...normalizeUiEventParams(params), + }; + const key = canonicalRequestKey([event, normalizedParams]); + const now = Date.now(); + const lastSeen = recentEvents.get(key); + if (lastSeen !== undefined && now - lastSeen < duplicateWindowMs) { + return; + } + recentEvents.set(key, now); + if (recentEvents.size > 100) { + for (const [candidate, timestamp] of recentEvents) { + if (now - timestamp >= duplicateWindowMs) recentEvents.delete(candidate); + } + while (recentEvents.size > 100) { + const oldest = recentEvents.keys().next().value; + if (oldest === undefined) break; + recentEvents.delete(oldest); + } + } + void callTool('track_ui_event', { event, component: options.component, - params: { - ...baseParams, - ...normalizeUiEventParams(params), - }, + params: normalizedParams, }).catch(() => { // UI analytics should never block UI interactions. }); diff --git a/test/test-ui-event-tracking.js b/test/test-ui-event-tracking.js index 29b35dce..150c5d88 100644 --- a/test/test-ui-event-tracking.js +++ b/test/test-ui-event-tracking.js @@ -5,6 +5,8 @@ import assert from 'assert'; import { server } from '../dist/server.js'; import { buildTrackUiEventCapturePayload } from '../dist/handlers/history-handlers.js'; +import { createToolBridge } from '../dist/ui/shared/tool-bridge.js'; +import { createUiEventTracker } from '../dist/ui/shared/ui-event-tracker.js'; function getRequestHandler(method) { const handlers = server._requestHandlers; @@ -54,10 +56,108 @@ async function testTrackUiEventPayloadCollisionProtection() { console.log('✓ track_ui_event payload collision protection works'); } +async function testConcurrentWidgetCallsAreCoalesced() { + console.log('\n--- Test: identical concurrent widget calls are coalesced ---'); + let calls = 0; + let release; + const gate = new Promise((resolve) => { release = resolve; }); + const bridge = createToolBridge({ + host: { + openai: { + callTool: async () => { + calls++; + await gate; + return { content: [{ type: 'text', text: 'ok' }] }; + }, + }, + }, + }); + + const first = bridge.callTool('read_file', { path: 'same.txt', options: { offset: 0 } }); + const second = bridge.callTool('read_file', { options: { offset: 0 }, path: 'same.txt' }); + release(); + const [a, b] = await Promise.all([first, second]); + + assert.strictEqual(calls, 1, 'equivalent in-flight requests should share one host call'); + assert.deepStrictEqual(a, b); + console.log('✓ identical concurrent calls share one request'); +} + +async function testSequentialWidgetCallsRunAgain() { + console.log('\n--- Test: sequential widget calls are not suppressed ---'); + let calls = 0; + const bridge = createToolBridge({ + host: { openai: { callTool: async () => ({ call: ++calls }) } }, + }); + + await bridge.callTool('get_config', {}); + await bridge.callTool('get_config', {}); + assert.strictEqual(calls, 2, 'request should run again after the prior call settles'); + console.log('✓ sequential calls execute normally'); +} + +async function testCanonicalWidgetCallKeys() { + console.log('\n--- Test: canonical widget keys preserve distinct values ---'); + let calls = 0; + let release; + const gate = new Promise((resolve) => { release = resolve; }); + const bridge = createToolBridge({ + host: { openai: { callTool: async () => { calls++; await gate; return {}; } } }, + }); + + const reorderedA = bridge.callTool('read_file', { options: { offset: 0, length: 1 } }); + const reorderedB = bridge.callTool('read_file', { options: { length: 1, offset: 0 } }); + const nanCall = bridge.callTool('read_file', { value: Number.NaN }); + const nullCall = bridge.callTool('read_file', { value: null }); + release(); + await Promise.all([reorderedA, reorderedB, nanCall, nullCall]); + + assert.strictEqual(calls, 3, 'reordered keys coalesce, while NaN and null remain distinct'); + console.log('✓ canonical keys coalesce only equivalent requests'); +} + +async function testDuplicateUiEventsAreSuppressed() { + console.log('\n--- Test: immediate duplicate UI events are suppressed ---'); + const calls = []; + const track = createUiEventTracker( + async (name, args) => { calls.push({ name, args }); return {}; }, + { component: 'test-widget' }, + ); + + track('click', { target: 'refresh' }); + track('click', { target: 'refresh' }); + track('click', { target: 'other' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.strictEqual(calls.length, 2, 'duplicate should collapse while distinct event remains'); + console.log('✓ duplicate event collapsed without suppressing distinct event'); +} + +async function testUiEventCacheStaysBounded() { + console.log('\n--- Test: UI event cache stays bounded ---'); + const calls = []; + const track = createUiEventTracker( + async (_name, args) => { calls.push(args); return {}; }, + { component: 'test-widget' }, + ); + + for (let index = 0; index < 101; index++) track('click', { index }); + track('click', { index: 0 }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.strictEqual(calls.length, 102, 'oldest unique event should be evicted once the cache exceeds 100'); + console.log('✓ unique-event bursts retain at most 100 dedupe keys'); +} + export default async function runTests() { try { await testTrackUiEventCall(); await testTrackUiEventPayloadCollisionProtection(); + await testConcurrentWidgetCallsAreCoalesced(); + await testSequentialWidgetCallsRunAgain(); + await testCanonicalWidgetCallKeys(); + await testDuplicateUiEventsAreSuppressed(); + await testUiEventCacheStaysBounded(); console.log('\n✅ UI event tracking tests passed!'); return true; } catch (error) {