Skip to content
Open
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
27 changes: 27 additions & 0 deletions src/ui/shared/canonical-key.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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}`);
}
}
25 changes: 23 additions & 2 deletions src/ui/shared/tool-bridge.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
type ToolArgs = Record<string, unknown>;

import { canonicalRequestKey } from './canonical-key.js';

type ToolHelper = {
callTool: (name: string, args: ToolArgs) => Promise<unknown> | unknown;
};
Expand Down Expand Up @@ -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<string, Promise<unknown>>();

async function callViaFallback(name: string, args: ToolArgs): Promise<unknown> {
if (!host.parent || !host.addEventListener || !host.removeEventListener) {
Expand Down Expand Up @@ -166,8 +169,7 @@ export function createToolBridge(options: ToolBridgeOptions = {}) {
});
}

async function callTool(name: string, args?: ToolArgs): Promise<unknown> {
const normalizedArgs = normalizeToolArgs(args);
async function callToolOnce(name: string, normalizedArgs: ToolArgs): Promise<unknown> {
const helperCandidates = [host.openai, host.mcp].filter(
(candidate): candidate is ToolHelper => Boolean(candidate?.callTool)
);
Expand All @@ -190,6 +192,25 @@ export function createToolBridge(options: ToolBridgeOptions = {}) {
}
}

async function callTool(name: string, args?: ToolArgs): Promise<unknown> {
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,
};
Expand Down
31 changes: 27 additions & 4 deletions src/ui/shared/ui-event-tracker.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
type UiEventParamValue = string | number | boolean | null;

import { canonicalRequestKey } from './canonical-key.js';

export type UiEventParams = Record<string, UiEventParamValue>;

type ToolCaller = (name: string, args: Record<string, unknown>) => Promise<unknown>;
Expand Down Expand Up @@ -27,15 +29,36 @@ function normalizeUiEventParams(params: Record<string, unknown> | undefined): Ui

export function createUiEventTracker(callTool: ToolCaller, options: UiEventTrackerOptions) {
const baseParams = options.baseParams ?? {};
const recentEvents = new Map<string, number>();
const duplicateWindowMs = 250;

return (event: string, params: Record<string, unknown> = {}): 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);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

void callTool('track_ui_event', {
event,
component: options.component,
params: {
...baseParams,
...normalizeUiEventParams(params),
},
params: normalizedParams,
}).catch(() => {
// UI analytics should never block UI interactions.
});
Expand Down
100 changes: 100 additions & 0 deletions test/test-ui-event-tracking.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down