diff --git a/src/app/core/audio/audio-recorder.service.ts b/src/app/core/audio/audio-recorder.service.ts index 794e5d6..7e75bf0 100644 --- a/src/app/core/audio/audio-recorder.service.ts +++ b/src/app/core/audio/audio-recorder.service.ts @@ -296,6 +296,17 @@ export class AudioRecorderService { } if (this.webSocket) { + // Flush the sub-chunk tail (< CHUNK_SIZE samples) before closing, so the last + // ~100ms of speech — often the end of the final sentence — reaches the STT. + if (this.webSocket.readyState === WebSocket.OPEN && this.pcmBufferAccumulator.length > 0) { + const tail = this.pcmBufferAccumulator; + const buffer = new ArrayBuffer(tail.length * 2); + const view = new DataView(buffer); + for (let i = 0; i < tail.length; i++) { + view.setInt16(i * 2, tail[i], true); // little-endian + } + this.webSocket.send(buffer); + } // Detach first: a late close event from this socket must not tear down the next one. this.webSocket.onopen = null; this.webSocket.onerror = null; diff --git a/src/app/features/discovery/data/discovery-chat.store.spec.ts b/src/app/features/discovery/data/discovery-chat.store.spec.ts index 3f671ee..d7e1ab5 100644 --- a/src/app/features/discovery/data/discovery-chat.store.spec.ts +++ b/src/app/features/discovery/data/discovery-chat.store.spec.ts @@ -360,6 +360,37 @@ describe('DiscoveryChatStore', () => { expect(store.blocks()).toHaveLength(0); }); + it('warns and ignores an unknown realtime message type instead of failing silently', () => { + flushInit([session()]); + http + .expectOne((r) => r.url === '/api/projects/proj-1/suggestions') + .flush(page([])); + // Settle the historical block with each endpoint's real shape: the session's segments and + // suggestions are plain arrays (a page here would break the pending-queue reduce), its + // stories a page, its transcript an object. + http + .match(() => true) + .forEach((r) => { + const url = r.request.url; + if (url.endsWith('/transcript')) r.flush({ sessionId: 'sess-1', transcript: '' }); + else if (url.endsWith('/stories')) r.flush(page([])); + else r.flush([]); + }); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + store.applyRealtime({ + sessionId: 's-1', + type: 'BRAND_NEW_EVENT', + occurredAt: new Date().toISOString(), + } as never); + + expect(warnSpy).toHaveBeenCalledWith( + '[discovery] Unhandled realtime message type:', + 'BRAND_NEW_EVENT', + ); + warnSpy.mockRestore(); + }); + describe('decide', () => { /** Boots one completed session whose queue holds a single pending suggestion. */ function setupWithPending(): SuggestionResponse { diff --git a/src/app/features/discovery/data/discovery-chat.store.ts b/src/app/features/discovery/data/discovery-chat.store.ts index 39340a1..d1869f0 100644 --- a/src/app/features/discovery/data/discovery-chat.store.ts +++ b/src/app/features/discovery/data/discovery-chat.store.ts @@ -873,31 +873,39 @@ export class DiscoveryChatStore { return; } + // Creation is announced on the project topic; a session block has nothing to update. + if (message.type === 'SESSION_CREATED') return; + const status = STATUS_BY_EVENT[message.type]; - if (status) { - const error = - message.type === 'FAILED' ? (message as SessionProcessingFailedMessage).reason : null; - this.updateBlock(sessionId, (b) => ({ - ...b, - session: { - ...b.session, - status, - processingError: error ?? b.session.processingError, - }, - })); - this.recording.syncStatus(sessionId, status); - // Keep the project-level live flag in sync even when the lifecycle - // reaches us only through the per-session topic. - if (status === 'RECORDING' || status === 'PAUSED') { - this._liveSessionId.set(sessionId); - } else if (this._liveSessionId() === sessionId) { - this._liveSessionId.set(null); - } - // A block created while the session was live never fetched its persisted - // timeline. Once the session settles, reload it as a historical block so - // segments and resolved decisions survive exactly like after a reload. - if (HISTORICAL_STATUSES.includes(status)) this.upgradeToHistorical(sessionId); + if (!status) { + // A type this build doesn't know (backend shipped a new event first). Log it so a + // schema drift is visible in the console instead of a silently dead feature. + console.warn('[discovery] Unhandled realtime message type:', message.type); + return; } + + const error = + message.type === 'FAILED' ? (message as SessionProcessingFailedMessage).reason : null; + this.updateBlock(sessionId, (b) => ({ + ...b, + session: { + ...b.session, + status, + processingError: error ?? b.session.processingError, + }, + })); + this.recording.syncStatus(sessionId, status); + // Keep the project-level live flag in sync even when the lifecycle + // reaches us only through the per-session topic. + if (status === 'RECORDING' || status === 'PAUSED') { + this._liveSessionId.set(sessionId); + } else if (this._liveSessionId() === sessionId) { + this._liveSessionId.set(null); + } + // A block created while the session was live never fetched its persisted + // timeline. Once the session settles, reload it as a historical block so + // segments and resolved decisions survive exactly like after a reload. + if (HISTORICAL_STATUSES.includes(status)) this.upgradeToHistorical(sessionId); } /** diff --git a/src/app/features/discovery/data/discovery.models.ts b/src/app/features/discovery/data/discovery.models.ts index 5c4ea4a..f21be98 100644 --- a/src/app/features/discovery/data/discovery.models.ts +++ b/src/app/features/discovery/data/discovery.models.ts @@ -455,11 +455,11 @@ export function editableToAcceptRequest( // ---- Realtime (STOMP topic /topic/sessions/{id}) ---- export type SessionEventType = + | 'SESSION_CREATED' | 'RECORDING_STARTED' | 'RECORDING_PAUSED' | 'RECORDING_RESUMED' | 'RECORDING_STOPPED' - | 'SESSION_RESET' | 'TRANSCRIPT_SEGMENT' | 'TRANSCRIPT_UPLOADED' | 'PROCESSING' diff --git a/src/app/features/discovery/pages/discovery-chat/discovery-chat.ts b/src/app/features/discovery/pages/discovery-chat/discovery-chat.ts index fce7cb1..418b372 100644 --- a/src/app/features/discovery/pages/discovery-chat/discovery-chat.ts +++ b/src/app/features/discovery/pages/discovery-chat/discovery-chat.ts @@ -42,6 +42,7 @@ import { lucideUsers, } from '@ng-icons/lucide'; import { AuthStore } from '../../../../core/auth/auth.store'; +import { PermissionsStore } from '../../../../core/authz/permissions.store'; import { WorkspaceStore } from '../../../workspace/data/workspace.store'; import { ToastService } from '../../../../shared/toast/toast.service'; import { messageForError } from '../../../../core/errors/error-message'; @@ -50,7 +51,6 @@ import { AudioSource, supportsMeetingAudio } from '../../../../core/audio/audio- import { DiscoveryChatStore, RenderBlock } from '../../data/discovery-chat.store'; import { AssistantChatStore } from '../../data/assistant-chat.store'; import { ASSISTANT_MESSAGE_MAX, messageToSend } from '../../data/assistant-chat'; -import { PermissionsStore } from '../../../../core/authz/permissions.store'; import { SessionRecordingService } from '../../data/session-recording.service'; import { DecisionEntry } from '../../data/feed'; import { SessionSpeakersStore } from '../../data/session-speakers.store';