Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
33 changes: 31 additions & 2 deletions src/ui/shared/tool-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,16 @@ function normalizeToolArgs(args: ToolArgs | undefined): ToolArgs {
return args ?? {};
}

function stableStringify(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map(stableStringify).join(',')}]`;
}
if (isObject(value)) {
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`;
}
return JSON.stringify(value) ?? String(value);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

function extractErrorMessage(error: unknown): string {
if (error instanceof Error && error.message) {
return error.message;
Expand Down Expand Up @@ -100,6 +110,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 +177,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 +200,25 @@ export function createToolBridge(options: ToolBridgeOptions = {}) {
}
}

async function callTool(name: string, args?: ToolArgs): Promise<unknown> {
const normalizedArgs = normalizeToolArgs(args);
const key = `${name}:${stableStringify(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
24 changes: 20 additions & 4 deletions src/ui/shared/ui-event-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,31 @@ 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 = JSON.stringify([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);
}
}
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
62 changes: 62 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,70 @@ 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 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');
}

export default async function runTests() {
try {
await testTrackUiEventCall();
await testTrackUiEventPayloadCollisionProtection();
await testConcurrentWidgetCallsAreCoalesced();
await testSequentialWidgetCallsRunAgain();
await testDuplicateUiEventsAreSuppressed();
console.log('\n✅ UI event tracking tests passed!');
return true;
} catch (error) {
Expand Down