Skip to content
Merged
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
192 changes: 192 additions & 0 deletions apps/mcp-server/src/shared/event-bridge-reader.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { EventBridgeReader, EVENT_TYPES } from './event-bridge-reader';
import type { EventType } from './event-bridge-reader';

/**
* Real file system tests — No Mocking Principle (augmented-coding.md)
*/
describe('event-bridge-reader', () => {
let eventsDir: string;

beforeEach(async () => {
eventsDir = await fs.mkdtemp(join(tmpdir(), 'event-bridge-test-'));
});

afterEach(async () => {
await fs.rm(eventsDir, { recursive: true, force: true });
});

describe('EVENT_TYPES', () => {
it('should contain all five required event types', () => {
const required: EventType[] = [
'tool_call',
'session_start',
'session_end',
'pattern_detected',
'rule_suggested',
];
for (const t of required) {
expect(EVENT_TYPES).toContain(t);
}
});
});

describe('readNewEvents', () => {
it('should return empty array when file does not exist', async () => {
const reader = new EventBridgeReader('no-session', eventsDir);
const events = await reader.readNewEvents();
expect(events).toEqual([]);
});

it('should return empty array when file is empty', async () => {
const sessionId = 'empty-session';
await fs.writeFile(join(eventsDir, `${sessionId}.jsonl`), '', 'utf-8');

const reader = new EventBridgeReader(sessionId, eventsDir);
const events = await reader.readNewEvents();
expect(events).toEqual([]);
});

it('should parse all JSON lines from the file', async () => {
const sessionId = 'parse-test';
const lines = [
JSON.stringify({
ts: '2026-01-01T00:00:00Z',
type: 'session_start',
session_id: sessionId,
payload: {},
}),
JSON.stringify({
ts: '2026-01-01T00:00:01Z',
type: 'tool_call',
session_id: sessionId,
payload: { tool_name: 'Bash', success: true },
}),
];
await fs.writeFile(join(eventsDir, `${sessionId}.jsonl`), lines.join('\n') + '\n', 'utf-8');

const reader = new EventBridgeReader(sessionId, eventsDir);
const events = await reader.readNewEvents();
expect(events).toHaveLength(2);
expect(events[0].type).toBe('session_start');
expect(events[1].type).toBe('tool_call');
});

it('should only return new events on subsequent reads', async () => {
const sessionId = 'incremental';
const filePath = join(eventsDir, `${sessionId}.jsonl`);
const line1 =
JSON.stringify({
ts: '2026-01-01T00:00:00Z',
type: 'session_start',
session_id: sessionId,
payload: {},
}) + '\n';
await fs.writeFile(filePath, line1, 'utf-8');

const reader = new EventBridgeReader(sessionId, eventsDir);

// First read: gets line1
const first = await reader.readNewEvents();
expect(first).toHaveLength(1);

// Append line2
const line2 =
JSON.stringify({
ts: '2026-01-01T00:00:01Z',
type: 'tool_call',
session_id: sessionId,
payload: { tool_name: 'Read', success: true },
}) + '\n';
await fs.appendFile(filePath, line2, 'utf-8');

// Second read: only gets line2
const second = await reader.readNewEvents();
expect(second).toHaveLength(1);
expect(second[0].type).toBe('tool_call');
});

it('should correctly parse event schema fields', async () => {
const sessionId = 'schema-test';
const event = {
ts: '2026-03-25T06:00:00+00:00',
type: 'pattern_detected',
session_id: sessionId,
payload: { pattern: 'repeated_bash_fail' },
};
await fs.writeFile(
join(eventsDir, `${sessionId}.jsonl`),
JSON.stringify(event) + '\n',
'utf-8',
);

const reader = new EventBridgeReader(sessionId, eventsDir);
const events = await reader.readNewEvents();

expect(events[0]).toEqual(
expect.objectContaining({
ts: '2026-03-25T06:00:00+00:00',
type: 'pattern_detected',
session_id: sessionId,
payload: { pattern: 'repeated_bash_fail' },
}),
);
});

it('should skip malformed JSON lines gracefully', async () => {
const sessionId = 'malformed';
const content =
[
JSON.stringify({
ts: '2026-01-01T00:00:00Z',
type: 'session_start',
session_id: sessionId,
payload: {},
}),
'NOT VALID JSON{{{',
JSON.stringify({
ts: '2026-01-01T00:00:01Z',
type: 'session_end',
session_id: sessionId,
payload: {},
}),
].join('\n') + '\n';
await fs.writeFile(join(eventsDir, `${sessionId}.jsonl`), content, 'utf-8');

const reader = new EventBridgeReader(sessionId, eventsDir);
const events = await reader.readNewEvents();
expect(events).toHaveLength(2);
expect(events[0].type).toBe('session_start');
expect(events[1].type).toBe('session_end');
});
});

describe('cleanup', () => {
it('should remove the session event file', async () => {
const sessionId = 'cleanup-test';
const filePath = join(eventsDir, `${sessionId}.jsonl`);
await fs.writeFile(filePath, '{}' + '\n', 'utf-8');

const reader = new EventBridgeReader(sessionId, eventsDir);
await reader.cleanup();

await expect(fs.access(filePath)).rejects.toThrow();
});

it('should not throw when file does not exist', async () => {
const reader = new EventBridgeReader('nonexistent', eventsDir);
await expect(reader.cleanup()).resolves.toBeUndefined();
});
});

describe('default eventsDir', () => {
it('should default to ~/.codingbuddy/events/', () => {
const reader = new EventBridgeReader('s1');
const home = process.env.HOME || process.env.USERPROFILE || '';
expect(reader.eventsDir).toBe(join(home, '.codingbuddy', 'events'));
});
});
});
89 changes: 89 additions & 0 deletions apps/mcp-server/src/shared/event-bridge-reader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* File-based event bridge reader for Plugin(Python) → MCP(TypeScript) communication.
*
* Reads JSON-lines events from ~/.codingbuddy/events/<session_id>.jsonl,
* tracking file offset to only return new events on subsequent reads.
*/
import { promises as fs } from 'fs';
import { join } from 'path';
import { homedir } from 'os';

/** All supported event types emitted by the Python plugin. */
export const EVENT_TYPES = [
'tool_call',
'session_start',
'session_end',
'pattern_detected',
'rule_suggested',
] as const;

export type EventType = (typeof EVENT_TYPES)[number];

/** Schema for a single bridge event (matches Python EventBridge output). */
export interface BridgeEvent {
ts: string;
type: EventType;
session_id: string;
payload: Record<string, unknown>;
}

/**
* Reads new events from a session's JSONL file, tracking byte offset
* so each call only returns events appended since the last read.
*/
export class EventBridgeReader {
readonly eventsDir: string;
private readonly sessionId: string;
private offset = 0;

constructor(sessionId: string, eventsDir?: string) {
this.sessionId = sessionId;
this.eventsDir = eventsDir ?? join(homedir(), '.codingbuddy', 'events');
}

private get filePath(): string {
return join(this.eventsDir, `${this.sessionId}.jsonl`);
}

/**
* Read new events appended since the last call.
* Returns an empty array if the file does not exist or has no new content.
* Malformed JSON lines are silently skipped.
*/
async readNewEvents(): Promise<BridgeEvent[]> {
let content: string;
try {
const buffer = await fs.readFile(this.filePath, 'utf-8');
content = buffer.slice(this.offset);
this.offset = buffer.length;
} catch {
return [];
}

if (!content) {
return [];
}

const events: BridgeEvent[] = [];
const lines = content.split('\n').filter(line => line.trim().length > 0);

for (const line of lines) {
try {
events.push(JSON.parse(line) as BridgeEvent);
} catch {
// Skip malformed JSON lines
}
}

return events;
}

/** Remove the session event file if it exists. */
async cleanup(): Promise<void> {
try {
await fs.unlink(this.filePath);
} catch {
// Ignore if file doesn't exist
}
}
}
70 changes: 70 additions & 0 deletions packages/claude-code-plugin/hooks/lib/event_bridge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""File-based event bridge for Plugin(Python) → MCP(TypeScript) communication.

Emits events as JSON lines to ~/.codingbuddy/events/<session_id>.jsonl.
"""
import json
import os
from datetime import datetime, timezone
from typing import Optional

EVENT_TYPES = (
"tool_call",
"session_start",
"session_end",
"pattern_detected",
"rule_suggested",
)


class EventBridge:
"""Append-only JSON-lines event emitter for a single session."""

def __init__(self, session_id: str, events_dir: Optional[str] = None):
self.session_id = session_id
self.events_dir = events_dir or os.path.join(
os.path.expanduser("~"), ".codingbuddy", "events"
)

@property
def _file_path(self) -> str:
return os.path.join(self.events_dir, f"{self.session_id}.jsonl")

def emit(self, event_type: str, payload: dict) -> None:
"""Append one JSON-line event to the session file.

Args:
event_type: One of EVENT_TYPES.
payload: Arbitrary dict attached to the event.

Raises:
ValueError: If event_type is not in EVENT_TYPES.
"""
if event_type not in EVENT_TYPES:
raise ValueError(f"Unknown event type: {event_type}")

os.makedirs(self.events_dir, exist_ok=True)

event = {
"ts": datetime.now(timezone.utc).isoformat(),
"type": event_type,
"session_id": self.session_id,
"payload": payload,
}

# Open with restricted permissions; create if needed
fd = os.open(
self._file_path,
os.O_WRONLY | os.O_CREAT | os.O_APPEND,
0o600,
)
try:
os.write(fd, (json.dumps(event) + "\n").encode())
finally:
os.close(fd)

def cleanup(self) -> None:
"""Remove the session event file if it exists."""
try:
os.remove(self._file_path)
except FileNotFoundError:
pass
Loading
Loading