Decoupling AgentService With Event Emitter

by SLV Team 43 views
Decoupling AgentService from IPC using Event Emitter Pattern

Hey guys! Let's talk about how we can decouple our AgentService from the IPC (Inter-Process Communication) layer using a neat trick called the Event Emitter pattern. This is super important because it helps us write cleaner, more testable code and makes our lives a whole lot easier down the road. Currently, our AgentService is tightly coupled with Electron's IPC, which means it's directly calling sender.send() to communicate with the renderer process. This is a big no-no because it makes testing a pain and limits the reusability of our service outside of an Electron environment. So, what's the plan?

The Strategy: Event Emitter Abstraction

The game plan is to introduce an Event Emitter abstraction. This will act as an intermediary between our AgentService and the actual IPC implementation. This approach allows us to switch from the current IPC to other communication methods without modifying AgentService. Let's break it down step-by-step:

Step 1: Create the Abstraction (New Code, Zero Risk)

First, we create an IEventEmitter interface, which defines a simple emit() method. This method accepts an AgentEvent type, an object that represents different types of events that the AgentService needs to communicate. These events include things like stream_start, tool_start, tool_complete, and so on. The AgentEvent type ensures type safety and helps us keep track of all the different events happening in our service. We'll also create a WebContentsEventEmitter class. This class implements the IEventEmitter interface and is specifically designed to work with Electron's WebContents. It takes a WebContents instance in its constructor and uses its send() method to transmit events over IPC. In essence, WebContentsEventEmitter translates the generic AgentEvents into the specific IPC calls our app uses today.

// services/base/EventEmitter.ts
export type AgentEvent =
  | { type: 'stream_start'; streamId?: string }
  | { type: 'stream_chunk'; chunk: string }
  | { type: 'stream_end' }
  | { type: 'tool_start'; payload: ToolStartPayload }
  | { type: 'tool_complete'; payload: ToolCompletePayload }
  | { type: 'tool_error'; payload: ToolErrorPayload }
  | { type: 'turn2_start' }
  | { type: 'result'; payload: IntentResultPayload };

export interface IEventEmitter {
  emit(event: AgentEvent): void;
}

// Adapter for current IPC pattern
export class WebContentsEventEmitter implements IEventEmitter {
  constructor(private sender: WebContents) {}

  emit(event: AgentEvent): void {
    switch (event.type) {
      case 'stream_start':
        this.sender.send(ON_INTENT_STREAM_START, { streamId: event.streamId });
        break;
      case 'stream_chunk':
        this.sender.send(ON_INTENT_STREAM_CHUNK, { chunk: event.chunk });
        break;
      case 'tool_start':
        this.sender.send(ON_INTENT_TOOL_START, event.payload);
        break;
      case 'tool_complete':
        this.sender.send(ON_INTENT_TOOL_COMPLETE, event.payload);
        break;
      case 'tool_error':
        this.sender.send(ON_INTENT_TOOL_ERROR, event.payload);
        break;
      case 'stream_end':
        this.sender.send(ON_INTENT_STREAM_END, {});
        break;
      case 'turn2_start':
        this.sender.send(ON_INTENT_TURN2_START, {});
        break;
      case 'result':
        this.sender.send(ON_INTENT_RESULT, event.payload);
        break;
    }
  }
}

// For testing - no IPC!
export class MockEventEmitter implements IEventEmitter {
  events: AgentEvent[] = [];
  emit(event: AgentEvent): void {
    this.events.push(event);
  }
}

To make testing easier, we'll also create a MockEventEmitter class. This class is also an IEventEmitter but doesn't actually send any IPC messages. Instead, it stores the emitted events in an array, allowing us to verify the order and content of the events in our tests. This setup allows us to test our business logic without dealing with the complexities of IPC.

Step 2: Refactor handleToolCalls (Low Risk)

Next up, let's refactor the handleToolCalls method within AgentService. This method is responsible for handling tool calls and sending the results back to the renderer process. The core change here is to replace the direct calls to sender.send() with calls to emitter.emit(). We change the method signature to accept an IEventEmitter instance. This means that instead of relying on WebContents directly, handleToolCalls will now use the emit() method of whatever IEventEmitter is passed to it. This is a small change. We're simply changing the way events are communicated, not the underlying logic of the method. The change involves emitting a tool_start event before calling the tool, a tool_complete event after the tool has finished successfully, and a tool_error event if an error occurs. Each event carries relevant information, such as the tool name, tool ID, and any results or errors. The use of this emitter abstraction makes this method independent of the specific IPC implementation.

// In AgentService - only change the signature and replace sender.send() calls
private async handleToolCalls(
  response: Anthropic.Message,
  sessionId: string,
  senderId: string,
  emitter: IEventEmitter, // ← Changed from WebContents
  correlationId?: string
): Promise<ToolCallResult[]> {
  const toolResults: ToolCallResult[] = [];

  for (const content of response.content) {
    if (content.type === 'tool_use') {
      const toolStartTime = Date.now();

      // Old: sender.send(ON_INTENT_TOOL_START, toolStartPayload);
      // New:
      emitter.emit({
        type: 'tool_start',
        payload: {
          toolName: content.name,
          toolId: content.id,
          timestamp: new Date().toISOString()
        }
      });

      try {
        const result = await this.deps.toolService.executeTool(...);
        const duration = Date.now() - toolStartTime;
        const category = result.immediateReturn ? 'end-response' : 'mid-response';

        // Old: sender.send(ON_INTENT_TOOL_COMPLETE, toolCompletePayload);
        // New:
        emitter.emit({
          type: 'tool_complete',
          payload: {
            toolName: content.name,
            toolId: content.id,
            category,
            result: category === 'mid-response' ? result.content : undefined,
            timestamp: new Date().toISOString(),
            duration
          }
        });

        toolResults.push(result);
      } catch (error) {
        // Old: sender.send(ON_INTENT_TOOL_ERROR, toolErrorPayload);
        // New:
        emitter.emit({
          type: 'tool_error',
          payload: {
            toolName: content.name,
            toolId: content.id,
            error: error instanceof Error ? error.message : 'Unknown error',
            timestamp: new Date().toISOString()
          }
        });
        throw error;
      }
    }
  }

  return toolResults;
}

Step 3: Update Caller (Trivial Change)

Now we need to update the callers of the handleToolCalls method. The change is simple: we wrap the sender (which is the WebContents instance) in a WebContentsEventEmitter before passing it to handleToolCalls. The updated code would look something like this. The original code directly passes the sender, which is the WebContents instance, to handleToolCalls. The change consists of creating a WebContentsEventEmitter instance, passing the sender to its constructor and then passing the new emitter instance to the handleToolCalls function. By wrapping the sender in a WebContentsEventEmitter, we ensure that the handleToolCalls method interacts with the IEventEmitter abstraction instead of directly with the IPC mechanism. This small modification achieves our goal of decoupling the AgentService from the IPC details.

// In processIntent - just wrap sender in adapter
const emitter = new WebContentsEventEmitter(sender);

// Old: await this.handleToolCalls(response, sessionId, effectiveSenderId, sender, correlationId);
// New:
await this.handleToolCalls(response, sessionId, effectiveSenderId, emitter, correlationId);

Step 4: Add Tests (Proof It Works)

And now the fun part – testing! We write a test to make sure that handleToolCalls emits the correct events in the correct order. We use the MockEventEmitter we created earlier. It allows us to assert that the proper events are emitted when handleToolCalls runs. The test creates a MockEventEmitter instance and passes it to the handleToolCalls method. The test then asserts that the events emitted by the MockEventEmitter match the expected events. This ensures that the refactoring works as intended and that our code is still behaving correctly after the changes.

describe('AgentService.handleToolCalls', () => {
  it('should emit tool events in correct order', async () => {
    const emitter = new MockEventEmitter();

    await agentService.handleToolCalls(response, sessionId, senderId, emitter);

    expect(emitter.events).toMatchObject([
      { type: 'tool_start', payload: { toolName: 'search' } },
      { type: 'tool_complete', payload: { toolName: 'search' } }
    ]);
  });
});

Why This Is Low-Risk

This approach is low-risk because:

  • No behavior changes: We're not changing how things work, just how they're communicated. The adapter ensures that the same IPC methods are called as before.
  • Incremental: We refactor one method at a time, making it easier to manage and debug.
  • Testable immediately: The MockEventEmitter lets us test the new code right away.
  • Easy rollback: If something goes wrong, we can easily revert back to the old code.
  • Type-safe: The use of TypeScript and the AgentEvent type ensures that all events are handled correctly by the compiler.
  • No async generator complexity yet: We're keeping things simple for now, saving the async generator for later if we need it.

Migration Path

Here's a quick rundown of the steps:

  1. ✅ Create abstraction (15 min, zero risk)
  2. ✅ Refactor handleToolCalls (30 min, low risk)
  3. ✅ Add tests (15 min, validates approach)
  4. → Refactor processIntent (1 hour, proven pattern)
  5. → Refactor streamFinalResponse (30 min, same pattern)
  6. → Refactor IntentService handlers (2 hours, mechanical)
  7. → (Optional) Convert to AsyncGenerator if streaming benefits emerge

Total time for proof of concept: extasciitilde{}1 hour

Total time for complete refactor: extasciitilde{}6 hours

By following these steps, you can decouple your AgentService from the IPC layer and make your code more modular, testable, and reusable. This is a win-win for your project! So, are you guys ready to level up your code?