diff --git a/src/supervisor/agent-history.service.ts b/src/supervisor/agent-history.service.ts index 4bee402..a028c74 100644 --- a/src/supervisor/agent-history.service.ts +++ b/src/supervisor/agent-history.service.ts @@ -14,9 +14,15 @@ export type AgentEventType = | 'ACW_START' | 'ACW_END'; -type PendingStart = { - eventType: 'PAUSE' | 'CALL_START' | 'ACW_START'; - at: number; // ms timestamp +// Separate pending slots per event category. Call + ACW overlap (agent +// enters ACW before the CALL_END arrives), so a single shared slot would +// let ACW_START clobber pending CALL_START and produce 0-second call +// durations. Keep one slot per category so each END event pairs cleanly. +type PendingSlot = 'pause' | 'call' | 'acw'; +type PendingStarts = { + pause?: number; // PAUSE eventAt ms + call?: number; // CALL_START eventAt ms + acw?: number; // ACW_START eventAt ms }; /** @@ -36,9 +42,9 @@ export class AgentHistoryService implements OnModuleInit { // ozonetelAgentId → Agent entity UUID. Loaded at startup. private readonly agentUuidByOzonetelId = new Map(); - // agentId → most recent "start" event that's awaiting a paired "end", - // used to compute durationSec on the END event. - private readonly pendingStartByAgent = new Map(); + // agentId → map of pending start events per category, used to compute + // durationSec on the matching END event. + private readonly pendingStartsByAgent = new Map(); constructor(private readonly platform: PlatformGraphqlService) {} @@ -117,24 +123,34 @@ export class AgentHistoryService implements OnModuleInit { return; } - // Compute duration when closing out a pending start. Ozonetel - // emits a single "release" action for both post-pause and post-ACW, - // so a READY event closes whatever was open. Specific close events - // (RESUME / ACW_END / CALL_END) also pair to their explicit start. + // Pair START → END events by category. CALL and ACW can overlap + // (agent enters ACW before CALL_END arrives), so each lives in its + // own slot. READY is a fallback close — supervisor.service already + // maps 'release'/'IDLE' to RESUME / ACW_END when it knows the prior + // state; READY only fires when that disambiguation failed, so it + // clears anything dangling. let durationSec: number | null = null; - if (this.closesAnyOpenStart(params.eventType)) { - const pending = this.pendingStartByAgent.get(params.ozonetelAgentId); - if (pending) { - durationSec = Math.max(0, Math.round((new Date(params.eventAt).getTime() - pending.at) / 1000)); - this.pendingStartByAgent.delete(params.ozonetelAgentId); + const endSlot = this.slotForEnd(params.eventType); + const startSlot = this.slotForStart(params.eventType); + const eventMs = new Date(params.eventAt).getTime(); + + if (endSlot) { + const pending = this.pendingStartsByAgent.get(params.ozonetelAgentId); + const at = pending?.[endSlot]; + if (at !== undefined) { + durationSec = Math.max(0, Math.round((eventMs - at) / 1000)); + delete pending![endSlot]; + if (!pending!.pause && !pending!.call && !pending!.acw) { + this.pendingStartsByAgent.delete(params.ozonetelAgentId); + } } - } else if (this.isStartEvent(params.eventType)) { - // Overwrite any existing pending start for this agent. Ozonetel - // doesn't nest states, so a new start implicitly ends the previous. - this.pendingStartByAgent.set(params.ozonetelAgentId, { - eventType: params.eventType as PendingStart['eventType'], - at: new Date(params.eventAt).getTime(), - }); + } else if (startSlot) { + const existing = this.pendingStartsByAgent.get(params.ozonetelAgentId) ?? {}; + existing[startSlot] = eventMs; + this.pendingStartsByAgent.set(params.ozonetelAgentId, existing); + } else if (params.eventType === 'READY' || params.eventType === 'LOGOUT') { + // Defensive flush of any lingering slots on session boundaries. + this.pendingStartsByAgent.delete(params.ozonetelAgentId); } const data: Record = { @@ -172,17 +188,18 @@ export class AgentHistoryService implements OnModuleInit { || msg.includes('AgentEventCreateInput') || msg.includes('AgentSessionCreateInput'); } - private closesAnyOpenStart(endType: AgentEventType): boolean { - // READY, RESUME, CALL_END, ACW_END all close whatever start was open. - // LOGIN/LOGOUT don't — they're session boundaries. - return endType === 'READY' - || endType === 'RESUME' - || endType === 'CALL_END' - || endType === 'ACW_END'; + private slotForStart(eventType: AgentEventType): PendingSlot | null { + if (eventType === 'PAUSE') return 'pause'; + if (eventType === 'CALL_START') return 'call'; + if (eventType === 'ACW_START') return 'acw'; + return null; } - private isStartEvent(eventType: AgentEventType): boolean { - return eventType === 'PAUSE' || eventType === 'CALL_START' || eventType === 'ACW_START'; + private slotForEnd(eventType: AgentEventType): PendingSlot | null { + if (eventType === 'RESUME') return 'pause'; + if (eventType === 'CALL_END') return 'call'; + if (eventType === 'ACW_END') return 'acw'; + return null; } /**