## CHANGES

- Symphony IPC now validates active contributions against stored sessions 🧩
- Orphaned contributions auto-filtered when sessions disappear, keeping UI clean 🧹
- `symphony:getState` returns only session-backed active items for accuracy 🎯
- `symphony:getActive` now excludes contributions tied to missing sessions 🚫
- Added reusable `filterOrphanedContributions` helper with detailed logging 🪵
- Wired `sessionsStore` dependency through main + handler registration flow 🔌
- Integration tests now mock sessions store for realistic state scenarios 🧪
- Expanded handler tests to cover missing-session filtering behavior 🛡️
This commit is contained in:
Pedram Amini
2026-02-03 23:21:06 -06:00
parent bcf6c4e60d
commit a7e504e205
5 changed files with 135 additions and 4 deletions

View File

@@ -222,10 +222,17 @@ describe('Symphony Integration Tests', () => {
},
} as unknown as BrowserWindow;
// Setup mock sessions store (returns empty by default - no sessions)
const mockSessionsStore = {
get: vi.fn().mockReturnValue([]),
set: vi.fn(),
};
// Setup dependencies
mockDeps = {
app: mockApp,
getMainWindow: () => mockMainWindow,
sessionsStore: mockSessionsStore as any,
};
// Default fetch mock (successful responses)

View File

@@ -64,6 +64,7 @@ describe('Symphony IPC handlers', () => {
let mockApp: App;
let mockMainWindow: BrowserWindow;
let mockDeps: SymphonyHandlerDependencies;
let mockSessionsStore: { get: ReturnType<typeof vi.fn>; set: ReturnType<typeof vi.fn> };
beforeEach(() => {
vi.clearAllMocks();
@@ -88,10 +89,17 @@ describe('Symphony IPC handlers', () => {
},
} as unknown as BrowserWindow;
// Setup mock sessions store (exposed for individual tests to modify)
mockSessionsStore = {
get: vi.fn().mockReturnValue([]),
set: vi.fn(),
};
// Setup dependencies
mockDeps = {
app: mockApp,
getMainWindow: () => mockMainWindow,
sessionsStore: mockSessionsStore as any,
};
// Default mock for fs operations
@@ -1209,7 +1217,7 @@ describe('Symphony IPC handlers', () => {
expect(result.state.stats.repositoriesContributed).toEqual([]);
});
it('should return persisted state from disk', async () => {
it('should return persisted state from disk (with valid sessions)', async () => {
const persistedState = {
active: [
{
@@ -1267,17 +1275,49 @@ describe('Symphony IPC handlers', () => {
},
};
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(persistedState));
// Mock the session to exist so the active contribution is included
mockSessionsStore.get.mockReturnValue([{ id: 'session-123', name: 'Test Session' }]);
const handler = handlers.get('symphony:getState');
const result = await handler!({} as any);
expect(result.state).toEqual(persistedState);
expect(result.state.active).toHaveLength(1);
expect(result.state.active[0].id).toBe('contrib_123');
expect(result.state.history).toHaveLength(1);
expect(result.state.stats.totalContributions).toBe(1);
});
it('should filter out active contributions with missing sessions', async () => {
const persistedState = {
active: [
{
id: 'contrib_with_session',
repoSlug: 'owner/repo',
sessionId: 'session-exists',
status: 'running',
},
{
id: 'contrib_orphaned',
repoSlug: 'owner/repo2',
sessionId: 'session-gone',
status: 'running',
},
],
history: [],
stats: { totalContributions: 0 },
};
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(persistedState));
// Only one session exists
mockSessionsStore.get.mockReturnValue([{ id: 'session-exists', name: 'Existing' }]);
const handler = handlers.get('symphony:getState');
const result = await handler!({} as any);
// Only the contribution with an existing session should be returned
expect(result.state.active).toHaveLength(1);
expect(result.state.active[0].id).toBe('contrib_with_session');
});
it('should handle file read errors gracefully', async () => {
vi.mocked(fs.readFile).mockRejectedValue(new Error('Permission denied'));
@@ -1302,7 +1342,7 @@ describe('Symphony IPC handlers', () => {
expect(result.contributions).toEqual([]);
});
it('should return all active contributions from state', async () => {
it('should return active contributions that have matching sessions', async () => {
const stateWithActive = {
active: [
{
@@ -1310,18 +1350,25 @@ describe('Symphony IPC handlers', () => {
repoSlug: 'owner/repo1',
issueNumber: 1,
status: 'running',
sessionId: 'session_1',
},
{
id: 'contrib_2',
repoSlug: 'owner/repo2',
issueNumber: 2,
status: 'paused',
sessionId: 'session_2',
},
],
history: [],
stats: {},
};
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(stateWithActive));
// Mock sessions store to return matching sessions
mockSessionsStore.get.mockReturnValue([
{ id: 'session_1', name: 'Session 1' },
{ id: 'session_2', name: 'Session 2' },
]);
const handler = handlers.get('symphony:getActive');
const result = await handler!({} as any);
@@ -1330,6 +1377,39 @@ describe('Symphony IPC handlers', () => {
expect(result.contributions[0].id).toBe('contrib_1');
expect(result.contributions[1].id).toBe('contrib_2');
});
it('should filter out contributions whose sessions no longer exist', async () => {
const stateWithActive = {
active: [
{
id: 'contrib_1',
repoSlug: 'owner/repo1',
issueNumber: 1,
status: 'running',
sessionId: 'session_exists',
},
{
id: 'contrib_2',
repoSlug: 'owner/repo2',
issueNumber: 2,
status: 'paused',
sessionId: 'session_gone',
},
],
history: [],
stats: {},
};
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(stateWithActive));
// Only return session_exists, session_gone is missing
mockSessionsStore.get.mockReturnValue([{ id: 'session_exists', name: 'Existing Session' }]);
const handler = handlers.get('symphony:getActive');
const result = await handler!({} as any);
// Only contrib_1 should be returned since contrib_2's session is gone
expect(result.contributions).toHaveLength(1);
expect(result.contributions[0].id).toBe('contrib_1');
});
});
describe('symphony:getCompleted', () => {

View File

@@ -608,6 +608,7 @@ function setupIpcHandlers() {
registerSymphonyHandlers({
app,
getMainWindow: () => mainWindow,
sessionsStore,
});
// Register tab naming handlers for automatic tab naming

View File

@@ -259,6 +259,7 @@ export function registerAllHandlers(deps: HandlerDependencies): void {
registerSymphonyHandlers({
app: deps.app,
getMainWindow: deps.getMainWindow,
sessionsStore: deps.sessionsStore,
});
// Register agent error handlers (error state management)
registerAgentErrorHandlers();

View File

@@ -11,10 +11,12 @@
*/
import { ipcMain, App, BrowserWindow } from 'electron';
import type Store from 'electron-store';
import fs from 'fs/promises';
import path from 'path';
import { logger } from '../../utils/logger';
import { isWebContentsAvailable } from '../../utils/safe-send';
import type { SessionsData, StoredSession } from '../../stores/types';
import { createIpcHandler, CreateHandlerOptions } from '../../utils/ipcHandler';
import { execFileNoThrow } from '../../utils/execFile';
import { getExpandedEnv } from '../../agents/path-prober';
@@ -194,6 +196,7 @@ function validateContributionParams(params: {
export interface SymphonyHandlerDependencies {
app: App;
getMainWindow: () => BrowserWindow | null;
sessionsStore: Store<SessionsData>;
}
// ============================================================================
@@ -865,6 +868,39 @@ function broadcastSymphonyUpdate(getMainWindow: () => BrowserWindow | null): voi
}
}
/**
* Filter out orphaned contributions whose sessions no longer exist.
* Returns only contributions that have a corresponding session in the sessions store.
*/
function filterOrphanedContributions(
contributions: ActiveContribution[],
sessionsStore: Store<SessionsData>
): ActiveContribution[] {
const sessions = sessionsStore.get('sessions', []) as StoredSession[];
const sessionIds = new Set(sessions.map((s) => s.id));
const validContributions: ActiveContribution[] = [];
const orphanedIds: string[] = [];
for (const contribution of contributions) {
if (sessionIds.has(contribution.sessionId)) {
validContributions.push(contribution);
} else {
orphanedIds.push(contribution.id);
}
}
if (orphanedIds.length > 0) {
logger.info(
`Filtering ${orphanedIds.length} orphaned contribution(s) with missing sessions`,
LOG_CONTEXT,
{ orphanedIds }
);
}
return validContributions;
}
// ============================================================================
// Handler Options Helper
// ============================================================================
@@ -882,6 +918,7 @@ const handlerOpts = (operation: string, logSuccess = true): CreateHandlerOptions
export function registerSymphonyHandlers({
app,
getMainWindow,
sessionsStore,
}: SymphonyHandlerDependencies): void {
// ─────────────────────────────────────────────────────────────────────────
// Registry Operations
@@ -1031,6 +1068,7 @@ export function registerSymphonyHandlers({
/**
* Get current symphony state.
* Filters out contributions whose sessions no longer exist.
*/
ipcMain.handle(
'symphony:getState',
@@ -1038,6 +1076,8 @@ export function registerSymphonyHandlers({
handlerOpts('getState', false),
async (): Promise<{ state: SymphonyState }> => {
const state = await readState(app);
// Filter out orphaned contributions whose sessions are gone
state.active = filterOrphanedContributions(state.active, sessionsStore);
return { state };
}
)
@@ -1045,6 +1085,7 @@ export function registerSymphonyHandlers({
/**
* Get active contributions.
* Filters out contributions whose sessions no longer exist.
*/
ipcMain.handle(
'symphony:getActive',
@@ -1052,7 +1093,8 @@ export function registerSymphonyHandlers({
handlerOpts('getActive', false),
async (): Promise<{ contributions: ActiveContribution[] }> => {
const state = await readState(app);
return { contributions: state.active };
const validContributions = filterOrphanedContributions(state.active, sessionsStore);
return { contributions: validContributions };
}
)
);