diff --git a/src/main/agent-host/agent-dashboard-bridge.ts b/src/main/agent-host/agent-dashboard-bridge.ts new file mode 100644 index 00000000..3eaf5a82 --- /dev/null +++ b/src/main/agent-host/agent-dashboard-bridge.ts @@ -0,0 +1,48 @@ +/** + * Agent Dashboard Event Bridge + * + * Translates AgentManagerEvent envelopes (from the utility-process agent host) + * into AGENT_DASHBOARD_EVENTS IPC events on the renderer-facing router. + * + * Event.data payloads are aligned by AgentManagerService to match each channel's + * Zod contract, so this module is a pure type → channel mapping. + */ + +import { AGENT_DASHBOARD_EVENTS } from '@shared/ipc/agent-dashboard/channels'; +import type { EventChannel, EventPayload } from '@shared/ipc-contract'; + +import type { AgentManagerEvent } from '../services/agent-manager/agent-manager-service'; + +type EmitFn = (channel: T, payload: EventPayload) => void; + +interface AgentManagerLike { + onEvent: (handler: (event: AgentManagerEvent) => void) => () => void; +} + +interface RouterLike { + emit: EmitFn; +} + +const CHANNEL_BY_TYPE: Record = { + 'session.started': AGENT_DASHBOARD_EVENTS.SESSION.STARTED, + 'session.ended': AGENT_DASHBOARD_EVENTS.SESSION.ENDED, + 'status.changed': AGENT_DASHBOARD_EVENTS.SESSION['STATUS-CHANGED'], + 'message.received': AGENT_DASHBOARD_EVENTS.MESSAGE.RECEIVED, + 'stream.event': AGENT_DASHBOARD_EVENTS.STREAM.EVENT, +}; + +export function forwardAgentManagerEvent(event: AgentManagerEvent, emit: EmitFn): void { + const channel = (CHANNEL_BY_TYPE as Partial>)[event.type]; + if (!channel) return; + emit(channel, event.data as EventPayload); +} + +export function wireAgentDashboardBridge( + agentManager: AgentManagerLike, + router: RouterLike, +): () => void { + const emit = router.emit.bind(router); + return agentManager.onEvent((event) => { + forwardAgentManagerEvent(event, emit); + }); +} diff --git a/src/main/agent-host/index.ts b/src/main/agent-host/index.ts index da910dbe..06654870 100644 --- a/src/main/agent-host/index.ts +++ b/src/main/agent-host/index.ts @@ -17,7 +17,6 @@ import type { ControlRequest, ControlResponse, } from './host-protocol'; -import type { IpcRouter } from '../ipc/router'; import type { AgentManagerEvent, AgentManagerService, @@ -30,37 +29,6 @@ let controlPort: Electron.MessagePortMain | null = null; let eventPort: Electron.MessagePortMain | null = null; let agentManager: AgentManagerService | null = null; -// ── Router Shim ───────────────────────────────────────────── - -/** - * Creates a minimal IpcRouter shim for the utility process. - * - * The AgentManagerService calls `router.emit(channel, payload)` to - * send events to the renderer. In the utility process there is no - * BrowserWindow, so the shim is a no-op — event forwarding is handled - * separately via `agentManager.onEvent()` which posts to the eventPort. - * - * `handle` and `setBus` are stubs since no IPC handlers or command bus - * exist in the utility process. - */ -function createRouterShim(): IpcRouter { - // The AgentManagerService also emits events via its own emitEvent() - // which we subscribe to via onEvent(). That path forwards to eventPort. - // The router.emit() calls are therefore redundant here — we make them - // no-ops to avoid double-sending. - return { - emit: (_channel: string, _payload: unknown) => { - // No-op: events are forwarded via agentManager.onEvent() instead - }, - handle: () => { - // No-op: utility process does not register IPC handlers - }, - setBus: () => { - // No-op: no command bus in utility process - }, - } as unknown as IpcRouter; -} - // ── Bootstrap ─────────────────────────────────────────────── function bootstrap(): void { @@ -68,9 +36,7 @@ function bootstrap(): void { throw new Error('Cannot bootstrap: ports not received'); } - const routerShim = createRouterShim(); - - agentManager = createAgentManagerService({ router: routerShim }); + agentManager = createAgentManagerService({}); // Capture eventPort in a local const so the closure doesn't need // a non-null assertion on every call. diff --git a/src/main/bootstrap/service-registry.ts b/src/main/bootstrap/service-registry.ts index b5f8c16e..377b37d3 100644 --- a/src/main/bootstrap/service-registry.ts +++ b/src/main/bootstrap/service-registry.ts @@ -18,6 +18,7 @@ import { WORKFLOW_ENGINE_EVENTS } from '@shared/ipc/workflow-engine/channels'; import { computeSchemaHash } from '@shared/replication/schema-hash'; import type { AppChannel } from '@shared/types/channel'; +import { wireAgentDashboardBridge } from '../agent-host/agent-dashboard-bridge'; import { createOAuthManager } from '../auth/oauth-manager'; import { GITHUB_OAUTH_CONFIG } from '../auth/providers/github'; import { GOOGLE_OAUTH_CONFIG } from '../auth/providers/google'; @@ -210,6 +211,11 @@ export function createServiceRegistry( const userSessionManager = createUserSessionManager(); const projectService = createProjectService({ db }); + // Wire agent-host events onto the renderer-facing IPC router. + // Must run before any other agentHostClient.onEvent() subscriber so the + // renderer receives session lifecycle + chat + stream events. + wireAgentDashboardBridge(agentHostClient, router); + const commandBus = createCommandBus(db); const busSessionManager = createBusSessionManager(db, agentHostClient); busSessionManager.recoverInterrupted(); diff --git a/src/main/features/agent-dashboard/agent-dashboard-handlers.ts b/src/main/features/agent-dashboard/agent-dashboard-handlers.ts index dce63848..a1af1667 100644 --- a/src/main/features/agent-dashboard/agent-dashboard-handlers.ts +++ b/src/main/features/agent-dashboard/agent-dashboard-handlers.ts @@ -5,9 +5,10 @@ * No business logic — just delegates to service methods and forwards events. * * Note: Agent manager events (session.started, message.received, etc.) are - * emitted directly by the AgentManagerService via the router it receives - * at construction. Only teammate events need forwarding here since the - * TeamWatcher service does not have a router reference. + * forwarded from the utility-process agent host onto the renderer-facing + * router by `wireAgentDashboardBridge` in `src/main/agent-host/agent-dashboard-bridge.ts` + * (wired in service-registry). Only teammate and QA events are emitted from + * this file. */ import { readFileSync } from 'node:fs'; diff --git a/src/main/services/agent-manager/agent-manager-service.ts b/src/main/services/agent-manager/agent-manager-service.ts index 3a229cc9..284a3424 100644 --- a/src/main/services/agent-manager/agent-manager-service.ts +++ b/src/main/services/agent-manager/agent-manager-service.ts @@ -11,7 +11,6 @@ import { randomUUID } from 'node:crypto'; -import { AGENT_DASHBOARD_EVENTS } from '@shared/ipc/agent-dashboard/channels'; import type { AgentChatMessage, AgentSession, @@ -31,7 +30,6 @@ import { SubprocessStrategy } from './subprocess-strategy'; import type { AgentConnectionStrategy } from './agent-connection-strategy'; import type { ManagedProcess } from './process-manager'; import type { StreamJsonParser } from './stream-json-parser'; -import type { IpcRouter } from '../../ipc/router'; // ── Configuration Types ────────────────────────────────────── @@ -120,7 +118,6 @@ interface InternalSession { // ── Factory ────────────────────────────────────────────────── export interface AgentManagerDeps { - router: IpcRouter; /** Optional connection strategy — defaults to SubprocessStrategy */ strategy?: AgentConnectionStrategy; } @@ -128,11 +125,12 @@ export interface AgentManagerDeps { /** * Create an AgentManagerService instance. * - * Follows the ADC factory pattern: returns synchronous values, - * emits events via IPC router for renderer updates. + * Follows the ADC factory pattern: returns synchronous values. + * Emits AgentManagerEvents to internal subscribers via `eventHandlers`. + * The renderer-facing IPC bridge is wired separately — see + * `agent-host/agent-dashboard-bridge.ts`. */ export function createAgentManagerService(deps: AgentManagerDeps): AgentManagerService { - const { router } = deps; const processManager = createProcessManager(); // Strategy is available for future use — currently SubprocessStrategy delegates to processManager const _strategy: AgentConnectionStrategy = deps.strategy ?? new SubprocessStrategy(processManager); @@ -159,13 +157,11 @@ export function createAgentManagerService(deps: AgentManagerDeps): AgentManagerS emitEvent({ type: 'status.changed', sessionId: internal.session.id, - data: { previousStatus, newStatus }, - }); - - router.emit(AGENT_DASHBOARD_EVENTS.SESSION['STATUS-CHANGED'], { - sessionId: internal.session.id, - previousStatus, - newStatus, + data: { + sessionId: internal.session.id, + previousStatus, + newStatus, + }, }); } @@ -202,23 +198,19 @@ export function createAgentManagerService(deps: AgentManagerDeps): AgentManagerS } // Emit stream event to renderer - router.emit(AGENT_DASHBOARD_EVENTS.STREAM.EVENT, { - sessionId: internal.session.id, - event, - }); - emitEvent({ type: 'stream.event', sessionId: internal.session.id, - data: event, + data: { + sessionId: internal.session.id, + event, + }, }); } function handleChatMessage(internal: InternalSession, message: AgentChatMessage): void { internal.messages.push(message); - router.emit(AGENT_DASHBOARD_EVENTS.MESSAGE.RECEIVED, message); - emitEvent({ type: 'message.received', sessionId: internal.session.id, @@ -274,16 +266,15 @@ export function createAgentManagerService(deps: AgentManagerDeps): AgentManagerS const exitStatus: AgentStatus = code === 0 ? 'completed' : 'failed'; updateSessionStatus(internal, exitStatus); - router.emit(AGENT_DASHBOARD_EVENTS.SESSION.ENDED, { - sessionId: internal.session.id, - status: exitStatus, - exitCode: code ?? undefined, - }); - emitEvent({ type: 'session.ended', sessionId: internal.session.id, - data: { code, signal }, + data: { + sessionId: internal.session.id, + status: exitStatus, + exitCode: code ?? undefined, + signal: signal ?? undefined, + }, }); }); internal.cleanups.push(cleanExit); @@ -347,7 +338,6 @@ export function createAgentManagerService(deps: AgentManagerDeps): AgentManagerS sessions.set(session.id, internal); wireProcessToParser(internal); - router.emit(AGENT_DASHBOARD_EVENTS.SESSION.STARTED, session); emitEvent({ type: 'session.started', sessionId: session.id, @@ -405,7 +395,6 @@ export function createAgentManagerService(deps: AgentManagerDeps): AgentManagerS `[AgentManager] Team Lead session started: ${session.id} (PID ${String(managedProcess.pid)})`, ); - router.emit(AGENT_DASHBOARD_EVENTS.SESSION.STARTED, session); emitEvent({ type: 'session.started', sessionId: session.id, @@ -500,16 +489,15 @@ export function createAgentManagerService(deps: AgentManagerDeps): AgentManagerS // Emit session.ended so subscribers (AssistantService, WorkspaceSessionManager) can clean up. // The normal onExit handler was removed above, so we must emit manually. - router.emit(AGENT_DASHBOARD_EVENTS.SESSION.ENDED, { - sessionId: internal.session.id, - status: 'completed', - exitCode: undefined, - }); - emitEvent({ type: 'session.ended', sessionId: internal.session.id, - data: { code: 0, signal: null }, + data: { + sessionId: internal.session.id, + status: 'completed' as const, + exitCode: undefined, + signal: undefined, + }, }); agentLogger.info(`[AgentManager] Session stopped: ${sessionId}`); diff --git a/src/renderer/features/workspace/api/useWorkspace.ts b/src/renderer/features/workspace/api/useWorkspace.ts index 27cf435f..e62bb007 100644 --- a/src/renderer/features/workspace/api/useWorkspace.ts +++ b/src/renderer/features/workspace/api/useWorkspace.ts @@ -19,7 +19,6 @@ import { useEffect } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { WORKSPACE, WORKSPACE_EVENTS } from '@shared/ipc/workspace/channels'; -import type { AgentChatMessage } from '@shared/types/agent-dashboard'; import { useIpcEvent } from '@renderer/shared/hooks'; import { ipc } from '@renderer/shared/lib/ipc'; @@ -73,36 +72,9 @@ export function useWorkspaceInit(projectId: string | null, projectPath: string | /** Send a message to a workspace session. */ export function useWorkspaceSend() { - const queryClient = useQueryClient(); - return useMutation({ mutationFn: ({ sessionId, message }: { sessionId: string; message: string }) => ipc(WORKSPACE.SEND.MESSAGE, { sessionId, message }), - - onMutate: ({ sessionId, message }) => { - const optimisticMessage: AgentChatMessage = { - id: crypto.randomUUID(), - agentId: sessionId, - role: 'user', - content: [{ type: 'text', text: message }], - timestamp: new Date().toISOString(), - }; - - queryClient.setQueryData( - ['agent-dashboard', 'messages', sessionId], - (old) => [...(old ?? []), optimisticMessage], - ); - - return { optimisticId: optimisticMessage.id }; - }, - - onError: (_err, { sessionId }, context) => { - if (!context) return; - queryClient.setQueryData( - ['agent-dashboard', 'messages', sessionId], - (old) => (old ?? []).filter((m) => m.id !== context.optimisticId), - ); - }, }); } diff --git a/tests/unit/main/agent-host/agent-dashboard-bridge.test.ts b/tests/unit/main/agent-host/agent-dashboard-bridge.test.ts new file mode 100644 index 00000000..08523f0f --- /dev/null +++ b/tests/unit/main/agent-host/agent-dashboard-bridge.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { AGENT_DASHBOARD_EVENTS } from '@shared/ipc/agent-dashboard/channels'; + +import { + forwardAgentManagerEvent, + wireAgentDashboardBridge, +} from '@main/agent-host/agent-dashboard-bridge'; +import type { AgentManagerEvent } from '@main/services/agent-manager/agent-manager-service'; + +describe('forwardAgentManagerEvent', () => { + it('maps session.started to SESSION.STARTED with the session payload', () => { + const emit = vi.fn(); + const session = { id: 's1', name: 'a' } as unknown as Record; + forwardAgentManagerEvent( + { type: 'session.started', sessionId: 's1', data: session }, + emit, + ); + expect(emit).toHaveBeenCalledWith(AGENT_DASHBOARD_EVENTS.SESSION.STARTED, session); + }); + + it('maps session.ended to SESSION.ENDED with status + exitCode', () => { + const emit = vi.fn(); + const payload = { sessionId: 's1', status: 'completed' as const, exitCode: 0 }; + forwardAgentManagerEvent( + { type: 'session.ended', sessionId: 's1', data: payload }, + emit, + ); + expect(emit).toHaveBeenCalledWith(AGENT_DASHBOARD_EVENTS.SESSION.ENDED, payload); + }); + + it('maps status.changed to SESSION.STATUS-CHANGED with previous + new status', () => { + const emit = vi.fn(); + const payload = { + sessionId: 's1', + previousStatus: 'idle' as const, + newStatus: 'running' as const, + }; + forwardAgentManagerEvent( + { type: 'status.changed', sessionId: 's1', data: payload }, + emit, + ); + expect(emit).toHaveBeenCalledWith( + AGENT_DASHBOARD_EVENTS.SESSION['STATUS-CHANGED'], + payload, + ); + }); + + it('maps message.received to MESSAGE.RECEIVED with the chat message', () => { + const emit = vi.fn(); + const message = { id: 'm1', agentId: 's1', role: 'assistant', content: [], timestamp: '' }; + forwardAgentManagerEvent( + { type: 'message.received', sessionId: 's1', data: message }, + emit, + ); + expect(emit).toHaveBeenCalledWith(AGENT_DASHBOARD_EVENTS.MESSAGE.RECEIVED, message); + }); + + it('maps stream.event to STREAM.EVENT with sessionId + event payload', () => { + const emit = vi.fn(); + const payload = { sessionId: 's1', event: { type: 'assistant' as const } }; + forwardAgentManagerEvent( + { type: 'stream.event', sessionId: 's1', data: payload }, + emit, + ); + expect(emit).toHaveBeenCalledWith(AGENT_DASHBOARD_EVENTS.STREAM.EVENT, payload); + }); + + it('ignores unknown event types without throwing', () => { + const emit = vi.fn(); + forwardAgentManagerEvent( + { type: 'made-up' as unknown as AgentManagerEvent['type'], sessionId: 's1', data: null }, + emit, + ); + expect(emit).not.toHaveBeenCalled(); + }); +}); + +describe('wireAgentDashboardBridge', () => { + it('subscribes to agentManager.onEvent and forwards each event to router.emit; returns the unsub', () => { + let captured: ((e: AgentManagerEvent) => void) | undefined; + const unsub = vi.fn(); + const agentManager = { + onEvent: vi.fn((handler: (e: AgentManagerEvent) => void) => { + captured = handler; + return unsub; + }), + }; + const router = { emit: vi.fn() }; + + const cleanup = wireAgentDashboardBridge(agentManager, router); + expect(agentManager.onEvent).toHaveBeenCalledTimes(1); + + captured?.({ + type: 'message.received', + sessionId: 's1', + data: { id: 'm1', agentId: 's1', role: 'user', content: [], timestamp: '' }, + }); + expect(router.emit).toHaveBeenCalledWith( + AGENT_DASHBOARD_EVENTS.MESSAGE.RECEIVED, + expect.objectContaining({ id: 'm1' }), + ); + + cleanup(); + expect(unsub).toHaveBeenCalledTimes(1); + }); +});