Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
62 changes: 62 additions & 0 deletions packages/core/src/confirmation-bus/message-bus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,4 +348,66 @@ describe('MessageBus', () => {
);
});
});

describe('subscribe with AbortSignal', () => {
it('should remove listener when signal is aborted', async () => {
const handler = vi.fn();
const controller = new AbortController();

messageBus.subscribe(MessageBusType.TOOL_EXECUTION_SUCCESS, handler, {
signal: controller.signal,
});

const message: ToolExecutionSuccess<string> = {
type: MessageBusType.TOOL_EXECUTION_SUCCESS as const,
toolCall: { name: 'test' },
result: 'test',
};

controller.abort();

await messageBus.publish(message);

expect(handler).not.toHaveBeenCalled();
});

it('should not add listener if signal is already aborted', async () => {
const handler = vi.fn();
const controller = new AbortController();
controller.abort();

messageBus.subscribe(MessageBusType.TOOL_EXECUTION_SUCCESS, handler, {
signal: controller.signal,
});

const message: ToolExecutionSuccess<string> = {
type: MessageBusType.TOOL_EXECUTION_SUCCESS as const,
toolCall: { name: 'test' },
result: 'test',
};

await messageBus.publish(message);

expect(handler).not.toHaveBeenCalled();
});

it('should remove abort listener when unsubscribe is called', async () => {
const handler = vi.fn();
const controller = new AbortController();
const signal = controller.signal;

const removeEventListenerSpy = vi.spyOn(signal, 'removeEventListener');

messageBus.subscribe(MessageBusType.TOOL_EXECUTION_SUCCESS, handler, {
signal,
});

messageBus.unsubscribe(MessageBusType.TOOL_EXECUTION_SUCCESS, handler);

expect(removeEventListenerSpy).toHaveBeenCalledWith(
'abort',
expect.any(Function),
);
});
});
});
43 changes: 43 additions & 0 deletions packages/core/src/confirmation-bus/message-bus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ import { safeJsonStringify } from '../utils/safeJsonStringify.js';
import { debugLogger } from '../utils/debugLogger.js';

export class MessageBus extends EventEmitter {
private listenerToAbortCleanup = new WeakMap<
object,
Map<string, () => void>
>();

constructor(
private readonly policyEngine: PolicyEngine,
private readonly debug = false,
Expand Down Expand Up @@ -145,7 +150,34 @@ export class MessageBus extends EventEmitter {
subscribe<T extends Message>(
type: T['type'],
listener: (message: T) => void,
options?: { signal?: AbortSignal },
): void {
if (options?.signal) {
const signal = options.signal;
if (signal.aborted) return;

const abortHandler = () => {
this.off(type, listener);
const typeToCleanup = this.listenerToAbortCleanup.get(listener);
if (typeToCleanup) {
typeToCleanup.delete(type);
if (typeToCleanup.size === 0) {
this.listenerToAbortCleanup.delete(listener);
}
}
};
signal.addEventListener('abort', abortHandler, { once: true });

let typeToCleanup = this.listenerToAbortCleanup.get(listener);
if (!typeToCleanup) {
typeToCleanup = new Map<string, () => void>();
this.listenerToAbortCleanup.set(listener, typeToCleanup);
}
typeToCleanup.set(type, () => {
signal.removeEventListener('abort', abortHandler);
});
}
Comment thread
abhipatel12 marked this conversation as resolved.
Resolved
Comment thread
abhipatel12 marked this conversation as resolved.
Resolved

this.on(type, listener);
}

Expand All @@ -154,6 +186,17 @@ export class MessageBus extends EventEmitter {
listener: (message: T) => void,
): void {
this.off(type, listener);
const typeToCleanup = this.listenerToAbortCleanup.get(listener);
if (typeToCleanup) {
const cleanup = typeToCleanup.get(type);
if (cleanup) {
cleanup();
typeToCleanup.delete(type);
}
if (typeToCleanup.size === 0) {
this.listenerToAbortCleanup.delete(listener);
}
}
}

/**
Expand Down
93 changes: 93 additions & 0 deletions packages/core/src/scheduler/scheduler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import { resolveConfirmation } from './confirmation.js';
import { checkPolicy, updatePolicy } from './policy.js';
import { ToolExecutor } from './tool-executor.js';
import { ToolModificationHandler } from './tool-modifier.js';
import { MessageBusType, type Message } from '../confirmation-bus/types.js';

vi.mock('./state-manager.js');
vi.mock('./confirmation.js');
Expand Down Expand Up @@ -1299,6 +1300,64 @@ describe('Scheduler (Orchestrator)', () => {
});
});

describe('Fallback Handlers', () => {
it('should respond to TOOL_CONFIRMATION_REQUEST with requiresUserConfirmation: true', async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: what does this test have to do with the new functionality?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

this was added b/c we didn't have any coverage to make sure that the listeners were actually working. I can remove if needed

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

fine to leave. just checking

const listeners: Record<
string,
Array<(message: Message) => void | Promise<void>>
> = {};

const mockBus = {
subscribe: vi.fn(
(
type: string,
handler: (message: Message) => void | Promise<void>,
) => {
listeners[type] = listeners[type] || [];
listeners[type].push(handler);
},
),
publish: vi.fn(async (message: Message) => {
const type = message.type as string;
if (listeners[type]) {
for (const handler of listeners[type]) {
await handler(message);
}
}
}),
} as unknown as MessageBus;

const scheduler = new Scheduler({
context: mockConfig,
messageBus: mockBus,
getPreferredEditor,
schedulerId: 'fallback-test',
});

const handler = vi.fn();
mockBus.subscribe(MessageBusType.TOOL_CONFIRMATION_RESPONSE, handler);

await mockBus.publish({
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
correlationId: 'test-correlation-id',
toolCall: { name: 'test-tool' },
});

// Wait for async handler to fire
await new Promise((resolve) => setTimeout(resolve, 10));

expect(handler).toHaveBeenCalledWith(
expect.objectContaining({
correlationId: 'test-correlation-id',
confirmed: false,
requiresUserConfirmation: true,
}),
);

scheduler.dispose();
});
});

describe('Cleanup', () => {
it('should unregister McpProgress listener on dispose()', () => {
const onSpy = vi.spyOn(coreEvents, 'on');
Expand All @@ -1323,6 +1382,40 @@ describe('Scheduler (Orchestrator)', () => {
expect.any(Function),
);
});

it('should abort disposeController signal on dispose()', () => {
const mockSubscribe =
vi.fn<
(
type: unknown,
listener: unknown,
options?: { signal?: AbortSignal },
) => void
>();
const mockBus = {
subscribe: mockSubscribe,
publish: vi.fn(),
} as unknown as MessageBus;

let capturedSignal: AbortSignal | undefined;
mockSubscribe.mockImplementation((type, listener, options) => {
capturedSignal = options?.signal;
});

const s = new Scheduler({
context: mockConfig,
messageBus: mockBus,
getPreferredEditor,
schedulerId: 'cleanup-test-2',
});

expect(capturedSignal).toBeDefined();
expect(capturedSignal?.aborted).toBe(false);

s.dispose();

expect(capturedSignal?.aborted).toBe(true);
});
});
});

Expand Down
11 changes: 3 additions & 8 deletions packages/core/src/scheduler/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,7 @@ const createErrorResponse = (
* Coordinates execution via state updates and event listening.
*/
export class Scheduler {
// Tracks which MessageBus instances have the legacy listener attached to prevent duplicates.
private static subscribedMessageBuses = new WeakSet<MessageBus>();
private readonly disposeController = new AbortController();

private readonly state: SchedulerStateManager;
private readonly executor: ToolExecutor;
Expand Down Expand Up @@ -136,6 +135,7 @@ export class Scheduler {

dispose(): void {
coreEvents.off(CoreEvent.McpProgress, this.handleMcpProgress);
this.disposeController.abort();
}

private readonly handleMcpProgress = (payload: McpProgressPayload) => {
Expand Down Expand Up @@ -164,10 +164,6 @@ export class Scheduler {
};

private setupMessageBusListener(messageBus: MessageBus): void {
if (Scheduler.subscribedMessageBuses.has(messageBus)) {
Comment thread
abhipatel12 marked this conversation as resolved.
Resolved
return;
}

// TODO: Optimize policy checks. Currently, tools check policy via
// MessageBus even though the Scheduler already checked it.
messageBus.subscribe(
Expand All @@ -180,9 +176,8 @@ export class Scheduler {
requiresUserConfirmation: true,
});
},
{ signal: this.disposeController.signal },
);

Scheduler.subscribedMessageBuses.add(messageBus);
}

/**
Expand Down
Loading