diff --git a/packages/js/scripts/size-limit.mjs b/packages/js/scripts/size-limit.mjs index d934f59dce8..be85dbbebbc 100644 --- a/packages/js/scripts/size-limit.mjs +++ b/packages/js/scripts/size-limit.mjs @@ -15,13 +15,14 @@ const modules = [ { name: 'UMD minified', filePath: umdPath, - // Raised for agent conversation runtime (NV-8640). Split to ./agent-chat later if needed. + // Raised for agent conversation runtime (NV-8640) and protocol validation (NV-8644). limitInBytes: 235_000, }, { name: 'UMD gzip', filePath: umdGzipPath, - limitInBytes: 64_000, + // NV-8644 runtime envelope validation adds ~150 B gzip over the NV-8640 baseline. + limitInBytes: 65_000, }, ]; diff --git a/packages/js/src/agent-chat/agent-chat-definition.types.ts b/packages/js/src/agent-chat/agent-chat-definition.types.ts new file mode 100644 index 00000000000..db0e3474478 --- /dev/null +++ b/packages/js/src/agent-chat/agent-chat-definition.types.ts @@ -0,0 +1,35 @@ +import type { AgentToolResultContent } from '@novu/agent-event-protocol'; +import type { AgentToolPart } from './agent-message.types'; + +/** Per-tool input/output shapes for compile-time narrowing on `AgentToolPart`. */ +export type AgentToolDefinition = { + input?: unknown; + output?: unknown; +}; + +export type AgentChatToolsDefinition = Record; + +/** + * Optional integrator-defined tool catalog for narrowing `AgentToolPart` by `toolName`. + * + * @example + * type MyChat = AgentChatDefinition<{ + * tools: { + * getOrder: { input: { orderId: string }; output: { status: string } }; + * }; + * }>; + * type OrderTool = AgentToolPartFor; + */ +export type AgentChatDefinition = { + tools: TTools; +}; + +export type AgentToolPartFor = TName extends keyof TTools + ? AgentToolPart & { + toolName: TName; + input: TTools[TName]['input'] extends undefined ? Record | undefined : TTools[TName]['input']; + output: TTools[TName]['output'] extends undefined + ? AgentToolResultContent[] | undefined + : TTools[TName]['output']; + } + : AgentToolPart; diff --git a/packages/js/src/agent-chat/agent-chat-store.ts b/packages/js/src/agent-chat/agent-chat-store.ts index 79acca291d9..59f3a6a4a08 100644 --- a/packages/js/src/agent-chat/agent-chat-store.ts +++ b/packages/js/src/agent-chat/agent-chat-store.ts @@ -1,4 +1,5 @@ import type { AgentEventEnvelope } from '@novu/agent-event-protocol'; +import type { NovuError } from '../utils/errors'; import { type AgentConversationState, type AgentMessage, @@ -6,7 +7,7 @@ import { derivePendingActions, } from './agent-message.types'; import { appendUserMessage, applyEnvelope, applyEnvelopes } from './apply-envelope'; -import type { AgentChatChange, AgentChatChangeSource } from './types'; +import type { AgentChatChange, AgentChatChangeSource, AgentChatPaginationStatus, FetchMoreResult } from './types'; type McpConnectionResult = { status: 'connected' | 'failed'; @@ -39,8 +40,18 @@ export type ConversationEntry = AgentConversationState & { reportedActionIds: Set; /** Terminal MCP results retained while history pages load independently. */ mcpConnectionResults: Map; + /** True while reconnect catch-up is in flight for this holder. */ + isRecovering: boolean; + /** Set when catch-up hits the safety page limit or HTTP fails. Cleared on success. */ + catchUpError?: NovuError; /** One create at a time on this holder until a conversation id exists. */ pendingCreate?: Promise; + /** History pagination state for `fetchMore`. */ + paginationStatus: AgentChatPaginationStatus; + /** Invalidates in-flight `fetchMore` status updates after history reload. */ + paginationEpoch: number; + /** Coalesces overlapping `fetchMore` calls on this holder. */ + pendingFetchMore?: Promise<{ data?: FetchMoreResult; error?: NovuError }>; }; function mintClientId(prefix: string): string { @@ -214,6 +225,9 @@ export class AgentChatStore { olderCursor: null, reportedActionIds: new Set(), mcpConnectionResults: new Map(), + isRecovering: false, + paginationStatus: 'idle', + paginationEpoch: 0, }; this.#byKey.set(args.key, entry); @@ -227,15 +241,16 @@ export class AgentChatStore { */ appendSending(entry: ConversationEntry, text: string): string { const messageId = createOptimisticMessageId(); - applyState( - entry, - appendUserMessage(entry, { + this.setRecoveryState(entry, { isRecovering: entry.isRecovering, catchUpError: undefined }); + applyState(entry, { + ...appendUserMessage(entry, { id: messageId, createdAt: new Date().toISOString(), status: 'sending', parts: [{ type: 'text', text, state: 'done' }], - }) - ); + }), + error: undefined, + }); this.#publish(entry, { kind: 'local' }, []); return messageId; @@ -294,12 +309,59 @@ export class AgentChatStore { messages: this.#applyMcpConnectionResults(entry, [...folded.messages, ...localOnly]), }); entry.olderCursor = olderCursor; + entry.paginationEpoch += 1; + entry.paginationStatus = 'idle'; + entry.pendingFetchMore = undefined; this.#publish(entry, { kind: 'history' }, messagesAddedSince(previous, entry.messages)); return entry; } + /** + * Run one history page fetch for this holder. + * Overlapping calls reuse the same in-flight promise. + * Message-id filtering in `prependOlderPage` prevents duplicate-message corruption when + * overlapping pagination wastes network or cursor work. + */ + withFetchMoreClaim( + entry: ConversationEntry, + fetch: () => Promise<{ data?: FetchMoreResult; error?: NovuError }> + ): Promise<{ data?: FetchMoreResult; error?: NovuError }> { + if (entry.pendingFetchMore) { + return entry.pendingFetchMore; + } + + entry.paginationStatus = 'loading'; + this.#publish(entry, { kind: 'local' }, []); + + const epoch = entry.paginationEpoch; + const current = fetch().then((result) => { + if (epoch !== entry.paginationEpoch) { + return { + data: { + messages: entry.messages, + hasMore: entry.olderCursor != null, + }, + }; + } + + entry.paginationStatus = result.error ? 'error' : 'idle'; + this.#publish(entry, { kind: 'local' }, []); + + return result; + }); + + const claim = current.finally(() => { + if (entry.pendingFetchMore === claim) { + entry.pendingFetchMore = undefined; + } + }); + entry.pendingFetchMore = claim; + + return current; + } + /** * Fold an older history page into this holder without resetting live timeline fields. * Preserves `lastSequence` so the live sequence gate stays valid after pagination. @@ -330,6 +392,19 @@ export class AgentChatStore { * Apply one live envelope onto this holder and notify listeners. * Drops envelopes at or behind `lastSequence` so catch-up HTTP + buffered WS overlap is safe. */ + setRecoveryState( + entry: ConversationEntry, + state: { isRecovering: boolean; catchUpError?: NovuError | undefined } + ): ConversationEntry { + entry.isRecovering = state.isRecovering; + if ('catchUpError' in state) { + entry.catchUpError = state.catchUpError; + } + this.#publish(entry, { kind: 'local' }, []); + + return entry; + } + applyLiveEnvelope(entry: ConversationEntry, envelope: AgentEventEnvelope): ConversationEntry { if (envelope.sequence <= entry.lastSequence) { return entry; diff --git a/packages/js/src/agent-chat/agent-chat.test.ts b/packages/js/src/agent-chat/agent-chat.test.ts index f0fbd948368..af62467cb65 100644 --- a/packages/js/src/agent-chat/agent-chat.test.ts +++ b/packages/js/src/agent-chat/agent-chat.test.ts @@ -1,6 +1,7 @@ import { AGENT_EVENT_PROTOCOL_VERSION } from '@novu/agent-event-protocol'; import { AgentChatPlanLimitError, AgentChatService } from '../api'; import { NovuEventEmitter } from '../event-emitter'; +import { NovuError } from '../utils/errors'; import { AgentChat } from './agent-chat'; import { derivePendingActions } from './agent-message.types'; import type { AgentChatChange } from './types'; @@ -51,13 +52,13 @@ describe('AgentChat', () => { expect(result).toEqual({ data: { conversationId: 'conv_abcdefghijkl', messageId: 'msg_abcdefghijkl' }, }); - expect(updates[0]?.key).toBe('local_session1'); - expect(updates[0]?.conversationId).toBeUndefined(); - expect(updates[0]?.messages[0]?.status).toBe('sending'); - expect(updates[0]?.messages[0]?.id).toMatch(/^opt_/); expect(updates[1]?.key).toBe('local_session1'); - expect(updates[1]?.conversationId).toBe('conv_abcdefghijkl'); - expect(updates[1]?.messages).toEqual([{ id: 'msg_abcdefghijkl', status: 'sent', role: 'user' }]); + expect(updates[1]?.conversationId).toBeUndefined(); + expect(updates[1]?.messages[0]?.status).toBe('sending'); + expect(updates[1]?.messages[0]?.id).toMatch(/^opt_/); + expect(updates[2]?.key).toBe('local_session1'); + expect(updates[2]?.conversationId).toBe('conv_abcdefghijkl'); + expect(updates[2]?.messages).toEqual([{ id: 'msg_abcdefghijkl', status: 'sent', role: 'user' }]); const snapshot = agentChat.getConversation({ agentId: 'agent_1', @@ -86,7 +87,7 @@ describe('AgentChat', () => { await agentChat.sendMessage({ agentId: 'agent_1', text: 'hello', key: 'local_session1' }); - expect(statuses).toEqual(['sending', 'sent']); + expect(statuses).toEqual(['', 'sending', 'sent']); }); it('marks the optimistic message failed when the request errors', async () => { @@ -701,6 +702,62 @@ describe('AgentChat', () => { expect(updates.at(-1)).toEqual(['msg_user0000001', 'msg_asst0000001']); }); + it('accepts live deltas after history that ends mid-stream', async () => { + getEvents.mockResolvedValue({ + events: [ + { + version: AGENT_EVENT_PROTOCOL_VERSION, + conversationId: 'internal', + conversationIdentifier: 'conv_abcdefghijkl', + agentId: 'agent_1', + runId: 'run_1', + turnId: 'turn_1', + sequence: 1, + timestamp: '2026-08-07T12:00:00.000Z', + event: { type: 'run-start' }, + }, + { + version: AGENT_EVENT_PROTOCOL_VERSION, + conversationId: 'internal', + conversationIdentifier: 'conv_abcdefghijkl', + agentId: 'agent_1', + runId: 'run_1', + turnId: 'turn_1', + sequence: 2, + timestamp: '2026-08-07T12:00:01.000Z', + event: { type: 'message-start', messageId: 'msg_asst0000001' }, + }, + ], + olderCursor: null, + }); + + await agentChat.loadConversation({ + agentId: 'agent_1', + conversationId: 'conv_abcdefghijkl', + }); + + emitter.emit('agent_chat.agent_event', { + result: { + version: AGENT_EVENT_PROTOCOL_VERSION, + conversationId: 'internal', + conversationIdentifier: 'conv_abcdefghijkl', + agentId: 'agent_1', + runId: 'run_1', + turnId: 'turn_1', + sequence: 3, + timestamp: '2026-08-07T12:00:02.000Z', + event: { type: 'message-delta', messageId: 'msg_asst0000001', delta: 'Hello' }, + }, + }); + + const snapshot = agentChat.getConversation({ + agentId: 'agent_1', + conversationId: 'conv_abcdefghijkl', + }); + expect(snapshot?.error).toBeUndefined(); + expect(snapshot?.messages.some((message) => message.id === 'msg_asst0000001')).toBe(true); + }); + it('ignores live envelopes for conversations that are not open', async () => { sendMessage.mockResolvedValue({ identifier: 'conv_abcdefghijkl', messageId: 'msg_user0000001' }); await agentChat.sendMessage({ agentId: 'agent_1', text: 'hello', key: 'local_session1' }); @@ -776,13 +833,14 @@ describe('AgentChat', () => { role: 'user' | 'assistant'; markdown: string; }>, - olderCursor: string | null = null + olderCursor: string | null = null, + conversationId = 'conv_abcdefghijkl' ) { return { events: events.map((event) => ({ version: AGENT_EVENT_PROTOCOL_VERSION, conversationId: 'internal', - conversationIdentifier: 'conv_abcdefghijkl', + conversationIdentifier: conversationId, agentId: 'agent_1', runId: 'history', turnId: 't1', @@ -799,12 +857,18 @@ describe('AgentChat', () => { }; } - function liveAssistantEnvelope(args: { sequence: number; messageId: string; markdown: string }) { + function liveAssistantEnvelope(args: { + sequence: number; + messageId: string; + markdown: string; + conversationId?: string; + role?: 'user' | 'assistant'; + }) { return { result: { version: AGENT_EVENT_PROTOCOL_VERSION, conversationId: 'internal', - conversationIdentifier: 'conv_abcdefghijkl', + conversationIdentifier: args.conversationId ?? 'conv_abcdefghijkl', agentId: 'agent_1', runId: 'run_1', turnId: 't1', @@ -812,7 +876,7 @@ describe('AgentChat', () => { timestamp: '2026-08-07T12:00:03.000Z', event: { type: 'message' as const, - role: 'assistant' as const, + role: args.role ?? ('assistant' as const), messageId: args.messageId, content: { markdown: args.markdown }, }, @@ -843,8 +907,8 @@ describe('AgentChat', () => { }); } - async function waitForMessageIds(expected: string[]): Promise { - const current = agentChat.getConversation({ agentId: 'agent_1', key: 'local_session1' }); + async function waitForMessageIds(expected: string[], key = 'local_session1'): Promise { + const current = agentChat.getConversation({ agentId: 'agent_1', key }); if (current && current.messages.map((message) => message.id).join() === expected.join()) { return; } @@ -852,7 +916,7 @@ describe('AgentChat', () => { await new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error(`timed out waiting for ${expected.join(',')}`)), 1000); const unsubscribe = emitter.on('agent_chat.messages.updated', ({ data }) => { - if (data.key !== 'local_session1') { + if (data.key !== key) { return; } @@ -1028,7 +1092,7 @@ describe('AgentChat', () => { expect(assistant?.parts).toEqual([{ type: 'text', text: 'same as http page', state: 'done' }]); }); - it('flushes buffered live envelopes when catch-up HTTP fails', async () => { + it('flushes buffered live envelopes and sets error when catch-up HTTP fails', async () => { await openClaimedConversation(); let rejectEvents!: (error: Error) => void; @@ -1039,6 +1103,13 @@ describe('AgentChat', () => { }) ); + const catchUpFailureUpdates: Array<{ isRecovering: boolean; catchUpError?: unknown }> = []; + emitter.on('agent_chat.messages.updated', ({ data }) => { + if (data.catchUpError) { + catchUpFailureUpdates.push({ isRecovering: data.isRecovering, catchUpError: data.catchUpError }); + } + }); + emitter.emit('socket.connect.resolved', { args: { socketUrl: 'http://127.0.0.1:8787' } }); await waitForGetEventsCalls(1); @@ -1053,6 +1124,302 @@ describe('AgentChat', () => { rejectEvents(new Error('getEvents failed')); await waitForMessageIds(['msg_user0000001', 'msg_asst_flush001']); + expect(catchUpFailureUpdates.length).toBeGreaterThanOrEqual(1); + const failedUpdate = catchUpFailureUpdates.find((update) => update.isRecovering === false); + expect(failedUpdate).toEqual({ + isRecovering: false, + catchUpError: expect.objectContaining({ + message: 'Failed to recover agent chat conversation after reconnect', + }), + }); + expect(failedUpdate?.catchUpError).toBeDefined(); + expect((failedUpdate as { error?: unknown }).error).toBeUndefined(); + expect(agentChat.getConversation({ agentId: 'agent_1', key: 'local_session1' })?.catchUpError).toMatchObject({ + message: 'Failed to recover agent chat conversation after reconnect', + }); + }); + + it('drops malformed agent_chat.agent_event envelopes without folding them', async () => { + await openClaimedConversation(); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + + emitter.emit('agent_chat.agent_event', { result: { invalid: true } as any }); + + expect(warn).toHaveBeenCalledWith('[novu agent-chat] skipping live envelope:', 'invalid-schema'); + expect(agentChat.getConversation({ agentId: 'agent_1', key: 'local_session1' })?.messages).toHaveLength(1); + + warn.mockRestore(); + }); + + it('does not buffer live envelopes for conversations that are not catching up', async () => { + sendMessage + .mockResolvedValueOnce({ identifier: 'conv_aaaaaaaaaaaa', messageId: 'msg_user_a00001' }) + .mockResolvedValueOnce({ identifier: 'conv_bbbbbbbbbbbb', messageId: 'msg_user_b00001' }); + + await agentChat.sendMessage({ agentId: 'agent_1', text: 'hello A', key: 'local_a' }); + await agentChat.sendMessage({ agentId: 'agent_1', text: 'hello B', key: 'local_b' }); + agentChat.subscribe(); + + let resolveSlowCatchUp!: (value: ReturnType) => void; + getEvents.mockImplementation((args: { conversationId: string }) => { + if (args.conversationId === 'conv_aaaaaaaaaaaa') { + return new Promise((resolve) => { + resolveSlowCatchUp = resolve; + }); + } + + return Promise.resolve( + historyPage( + [{ sequence: 1, messageId: 'msg_user_b00001', role: 'user', markdown: 'hello B' }], + null, + args.conversationId + ) + ); + }); + + emitter.emit('socket.connect.resolved', { args: { socketUrl: 'http://127.0.0.1:8787' } }); + await waitForGetEventsCalls(1); + + emitter.emit( + 'agent_chat.agent_event', + liveAssistantEnvelope({ + conversationId: 'conv_bbbbbbbbbbbb', + sequence: 2, + messageId: 'msg_asst_b_live01', + markdown: 'live on B during A catch-up', + }) + ); + + expect( + agentChat.getConversation({ agentId: 'agent_1', key: 'local_b' })?.messages.map((message) => message.id) + ).toEqual(['msg_user_b00001', 'msg_asst_b_live01']); + expect(agentChat.getConversation({ agentId: 'agent_1', key: 'local_a' })?.messages).toHaveLength(1); + + emitter.emit( + 'agent_chat.agent_event', + liveAssistantEnvelope({ + conversationId: 'conv_aaaaaaaaaaaa', + sequence: 2, + messageId: 'msg_asst_a_buf01', + markdown: 'buffered on A during catch-up', + }) + ); + + expect(agentChat.getConversation({ agentId: 'agent_1', key: 'local_a' })?.messages).toHaveLength(1); + + resolveSlowCatchUp( + historyPage( + [{ sequence: 1, messageId: 'msg_user_a00001', role: 'user', markdown: 'hello A' }], + null, + 'conv_aaaaaaaaaaaa' + ) + ); + + await waitForMessageIds(['msg_user_a00001', 'msg_asst_a_buf01'], 'local_a'); + }); + + it('sets catchUpError when reconnect catch-up exceeds the safety page limit', async () => { + getEvents.mockResolvedValueOnce( + historyPage([{ sequence: 1, messageId: 'msg_user0000001', role: 'user', markdown: 'hello' }], null) + ); + await agentChat.loadConversation({ + agentId: 'agent_1', + conversationId: 'conv_abcdefghijkl', + }); + agentChat.subscribe(); + getEvents.mockReset(); + + let page = 0; + let resolveFirstPage!: (value: ReturnType) => void; + getEvents.mockImplementation(() => { + page += 1; + if (page === 1) { + return new Promise((resolve) => { + resolveFirstPage = resolve; + }); + } + + return Promise.resolve( + historyPage( + [ + { + sequence: 100 + page, + messageId: `msg_asst_page${page.toString().padStart(2, '0')}`, + role: 'assistant', + markdown: `page ${page}`, + }, + ], + `act_page${page.toString().padStart(2, '0')}` + ) + ); + }); + + const catchUpErrors: unknown[] = []; + const catchUpErrorPromise = new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('timed out waiting for catchUpError')), 2000); + emitter.on('agent_chat.messages.updated', ({ data }) => { + if (data.catchUpError && data.isRecovering === false) { + clearTimeout(timeout); + catchUpErrors.push(data.catchUpError); + resolve(); + } + }); + }); + + emitter.emit('socket.connect.resolved', { args: { socketUrl: 'http://127.0.0.1:8787' } }); + await waitForGetEventsCalls(1); + + emitter.emit( + 'agent_chat.agent_event', + liveAssistantEnvelope({ + sequence: 200, + messageId: 'msg_asst_buffered', + markdown: 'discarded on limit exceeded', + }) + ); + + resolveFirstPage( + historyPage( + [ + { + sequence: 101, + messageId: 'msg_asst_page01', + role: 'assistant', + markdown: 'page 1', + }, + ], + 'act_page01' + ) + ); + + await catchUpErrorPromise; + expect(getEvents).toHaveBeenCalledTimes(20); + + expect(catchUpErrors).toHaveLength(1); + expect(catchUpErrors[0]).toMatchObject({ + message: expect.stringContaining('safety page limit'), + }); + + const conversation = agentChat.getConversation({ + agentId: 'agent_1', + conversationId: 'conv_abcdefghijkl', + }); + expect(conversation?.messages).toHaveLength(1); + expect(conversation?.messages[0]?.id).toBe('msg_user0000001'); + expect(conversation?.messages.some((message) => message.id === 'msg_asst_buffered')).toBe(false); + expect(conversation?.messages.some((message) => message.id.startsWith('msg_asst_page'))).toBe(false); + expect(conversation?.catchUpError).toMatchObject({ + message: expect.stringContaining('safety page limit'), + }); + }); + + it('emits catchUpError separately from conversation error on catch-up failure', async () => { + await openClaimedConversation(); + + let rejectEvents!: (error: Error) => void; + getEvents.mockImplementation( + () => + new Promise((_resolve, reject) => { + rejectEvents = reject; + }) + ); + + const updates: Array<{ error?: unknown; catchUpError?: unknown }> = []; + emitter.on('agent_chat.messages.updated', ({ data }) => { + if (data.catchUpError) { + updates.push({ error: data.error, catchUpError: data.catchUpError }); + } + }); + + emitter.emit('socket.connect.resolved', { args: { socketUrl: 'http://127.0.0.1:8787' } }); + await waitForGetEventsCalls(1); + rejectEvents(new Error('getEvents failed')); + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('timed out waiting for catchUpError event')), 2000); + emitter.on('agent_chat.messages.updated', ({ data }) => { + if (data.catchUpError) { + clearTimeout(timeout); + resolve(); + } + }); + }); + + expect(updates.length).toBeGreaterThanOrEqual(1); + expect(updates[0]?.catchUpError).toMatchObject({ + message: 'Failed to recover agent chat conversation after reconnect', + }); + expect(updates[0]?.error).toBeUndefined(); + }); + + it('exposes isRecovering while catch-up is in flight and clears it after success', async () => { + await openClaimedConversation(); + + let resolveEvents!: (value: ReturnType) => void; + getEvents.mockImplementation( + () => + new Promise((resolve) => { + resolveEvents = resolve; + }) + ); + + const recoveringStarted = new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('timed out waiting for isRecovering start event')), 1000); + emitter.on('agent_chat.messages.updated', ({ data }) => { + if (data.key === 'local_session1' && data.isRecovering) { + clearTimeout(timeout); + resolve(); + } + }); + }); + const recoveringUpdates: boolean[] = []; + emitter.on('agent_chat.messages.updated', ({ data }) => { + if (data.key === 'local_session1') { + recoveringUpdates.push(data.isRecovering); + } + }); + + emitter.emit('socket.connect.resolved', { args: { socketUrl: 'http://127.0.0.1:8787' } }); + await waitForGetEventsCalls(1); + await recoveringStarted; + + resolveEvents( + historyPage([ + { sequence: 1, messageId: 'msg_user0000001', role: 'user', markdown: 'hello' }, + { sequence: 2, messageId: 'msg_asst_done001', role: 'assistant', markdown: 'caught up' }, + ]) + ); + + await waitForMessageIds(['msg_user0000001', 'msg_asst_done001']); + expect(agentChat.getConversation({ agentId: 'agent_1', key: 'local_session1' })?.isRecovering).toBe(false); + expect(recoveringUpdates.at(-1)).toBe(false); + }); + + it('emits isRecovering false after successful catch-up with an empty live buffer', async () => { + await openClaimedConversation(); + getEvents.mockResolvedValue( + historyPage([{ sequence: 1, messageId: 'msg_user0000001', role: 'user', markdown: 'hello' }], null) + ); + + const recoveringUpdates: boolean[] = []; + const recovered = new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('timed out waiting for isRecovering false')), 2000); + emitter.on('agent_chat.messages.updated', ({ data }) => { + if (data.key !== 'local_session1') { + return; + } + + recoveringUpdates.push(data.isRecovering); + if (recoveringUpdates.includes(true) && data.isRecovering === false) { + clearTimeout(timeout); + resolve(); + } + }); + }); + + emitter.emit('socket.connect.resolved', { args: { socketUrl: 'http://127.0.0.1:8787' } }); + await recovered; + expect(recoveringUpdates.at(-1)).toBe(false); + expect(agentChat.getConversation({ agentId: 'agent_1', key: 'local_session1' })?.isRecovering).toBe(false); }); it('runs a second catch-up when reconnect arrives during an in-flight catch-up', async () => { @@ -1669,17 +2036,18 @@ describe('AgentChat', () => { liveAssistantEnvelope({ sequence: 4, messageId: 'msg_live0000001', markdown: 'live during fetchMore' }) ); - expect(changes).toHaveLength(1); - expect(changes[0]?.kind).toBe('live'); - expect(changes[0]?.addedMessages.map((message) => message.id)).toEqual(['msg_live0000001']); + expect(changes).toHaveLength(2); + expect(changes[0]?.kind).toBe('local'); + expect(changes[1]?.kind).toBe('live'); + expect(changes[1]?.addedMessages.map((message) => message.id)).toEqual(['msg_live0000001']); resolveOlderPage( historyPage([{ sequence: 1, messageId: 'msg_old0000001', role: 'user', markdown: 'older' }], null) ); await older; - expect(changes[1]?.kind).toBe('history'); - expect(changes[1]?.addedMessages.map((message) => message.id)).toEqual(['msg_old0000001']); + const historyChange = changes.find((change) => change.kind === 'history'); + expect(historyChange?.addedMessages.map((message) => message.id)).toEqual(['msg_old0000001']); }); it('carries the envelope that caused a live fold', async () => { @@ -1718,8 +2086,9 @@ describe('AgentChat', () => { await agentChat.sendMessage({ agentId: 'agent_1', text: 'hello', key: 'local_session1' }); - expect(changes.map((change) => change.kind)).toEqual(['local', 'local']); + expect(changes.map((change) => change.kind)).toEqual(['local', 'local', 'local']); expect(changes.map((change) => change.addedMessages.map((message) => message.id))).toEqual([ + [], [], ['msg_abcdefghijkl'], ]); @@ -1753,8 +2122,8 @@ describe('AgentChat', () => { await agentChat.sendMessage({ agentId: 'agent_1', text: 'hello', key: 'local_session1' }); - expect(changes.map((change) => change.kind)).toEqual(['local', 'local']); - expect(changes.map((change) => change.addedMessages)).toEqual([[], []]); + expect(changes.map((change) => change.kind)).toEqual(['local', 'local', 'local']); + expect(changes.map((change) => change.addedMessages)).toEqual([[], [], []]); }); it('reports a pending approval from history once across reloads', async () => { @@ -1810,7 +2179,247 @@ describe('AgentChat', () => { const snapshot = agentChat.getConversation({ agentId: 'agent_1', conversationId: 'conv_abcdefghijkl' }); expect(derivePendingActions(snapshot?.messages ?? []).map((action) => action.id)).toEqual(['approval_000001']); - expect(changes[0]?.kind).toBe('history'); - expect(changes[0]?.newActions).toEqual([]); + const historyChange = changes.find((change) => change.kind === 'history'); + expect(historyChange?.newActions).toEqual([]); + }); + + it('updates pagination.status during fetchMore success and failure', async () => { + getEvents.mockResolvedValueOnce( + historyPage([{ sequence: 3, messageId: 'msg_new0000001', role: 'user', markdown: 'recent' }], 'act_page0001') + ); + await agentChat.loadConversation({ agentId: 'agent_1', conversationId: 'conv_abcdefghijkl' }); + + getEvents.mockRejectedValueOnce(new Error('network')); + const failed = await agentChat.fetchMore({ agentId: 'agent_1', conversationId: 'conv_abcdefghijkl' }); + expect(failed.error).toBeDefined(); + expect( + agentChat.getConversation({ agentId: 'agent_1', conversationId: 'conv_abcdefghijkl' })?.pagination.status + ).toBe('error'); + + let resolveOlderPage!: (value: ReturnType) => void; + getEvents.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveOlderPage = resolve; + }) + ); + + const older = agentChat.fetchMore({ agentId: 'agent_1', conversationId: 'conv_abcdefghijkl' }); + expect( + agentChat.getConversation({ agentId: 'agent_1', conversationId: 'conv_abcdefghijkl' })?.pagination.status + ).toBe('loading'); + + resolveOlderPage( + historyPage([{ sequence: 1, messageId: 'msg_old0000001', role: 'user', markdown: 'older' }], null) + ); + await older; + + expect( + agentChat.getConversation({ agentId: 'agent_1', conversationId: 'conv_abcdefghijkl' })?.pagination.status + ).toBe('idle'); + }); + + it('deduplicates concurrent fetchMore calls into one in-flight request', async () => { + getEvents.mockResolvedValueOnce( + historyPage([{ sequence: 3, messageId: 'msg_new0000001', role: 'user', markdown: 'recent' }], 'act_page0001') + ); + await agentChat.loadConversation({ agentId: 'agent_1', conversationId: 'conv_abcdefghijkl' }); + + let resolveOlderPage!: (value: ReturnType) => void; + getEvents.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveOlderPage = resolve; + }) + ); + + const first = agentChat.fetchMore({ agentId: 'agent_1', conversationId: 'conv_abcdefghijkl' }); + const second = agentChat.fetchMore({ agentId: 'agent_1', conversationId: 'conv_abcdefghijkl' }); + + expect(getEvents).toHaveBeenCalledTimes(2); + + resolveOlderPage( + historyPage([{ sequence: 1, messageId: 'msg_old0000001', role: 'user', markdown: 'older' }], null) + ); + const [firstResult, secondResult] = await Promise.all([first, second]); + + expect(firstResult.data?.messages.map((message) => message.id)).toEqual(['msg_old0000001', 'msg_new0000001']); + expect(secondResult).toEqual(firstResult); + expect(getEvents).toHaveBeenCalledTimes(2); + }); + + it('exposes a terminal run-error on getConversation and messages.updated', async () => { + await openClaimedConversation(); + + const errors: Array<{ message: string } | undefined> = []; + emitter.on('agent_chat.messages.updated', ({ data }) => { + if (data.key === 'local_session1') { + errors.push(data.error ? { message: data.error.message } : undefined); + } + }); + + emitter.emit( + 'agent_chat.agent_event', + liveEnvelope({ + sequence: 2, + event: { type: 'run-start' }, + }) + ); + emitter.emit( + 'agent_chat.agent_event', + liveEnvelope({ + sequence: 3, + event: { type: 'run-error', message: 'agent handler failed', code: 'handler_failed' }, + }) + ); + + const snapshot = agentChat.getConversation({ agentId: 'agent_1', key: 'local_session1' }); + expect(snapshot?.error).toMatchObject({ message: 'agent handler failed' }); + expect(errors.at(-1)).toEqual({ message: 'agent handler failed' }); + + sendMessage.mockResolvedValue({ identifier: 'conv_abcdefghijkl', messageId: 'msg_retry000001' }); + await agentChat.sendMessage({ agentId: 'agent_1', text: 'retry', key: 'local_session1' }); + expect(agentChat.getConversation({ agentId: 'agent_1', key: 'local_session1' })?.error).toBeUndefined(); + }); + + it('does not let a stale fetchMore failure overwrite pagination status after reload', async () => { + getEvents.mockResolvedValueOnce( + historyPage([{ sequence: 3, messageId: 'msg_new0000001', role: 'user', markdown: 'recent' }], 'act_page0001') + ); + await agentChat.loadConversation({ agentId: 'agent_1', conversationId: 'conv_abcdefghijkl' }); + + let rejectOlderPage!: (error: Error) => void; + getEvents.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectOlderPage = reject; + }) + ); + + const older = agentChat.fetchMore({ agentId: 'agent_1', conversationId: 'conv_abcdefghijkl' }); + expect( + agentChat.getConversation({ agentId: 'agent_1', conversationId: 'conv_abcdefghijkl' })?.pagination.status + ).toBe('loading'); + + getEvents.mockResolvedValueOnce( + historyPage([{ sequence: 3, messageId: 'msg_new0000001', role: 'user', markdown: 'recent' }], 'act_page0001') + ); + await agentChat.loadConversation({ agentId: 'agent_1', conversationId: 'conv_abcdefghijkl' }); + expect(getEvents).toHaveBeenCalledTimes(3); + + rejectOlderPage(new Error('network')); + const staleResult = await older; + expect(staleResult.error).toBeUndefined(); + expect(staleResult.data?.messages.map((message) => message.id)).toEqual(['msg_new0000001']); + expect( + agentChat.getConversation({ agentId: 'agent_1', conversationId: 'conv_abcdefghijkl' })?.pagination.status + ).toBe('idle'); + + let resolveFreshPage!: (value: ReturnType) => void; + getEvents.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFreshPage = resolve; + }) + ); + + const fresh = agentChat.fetchMore({ agentId: 'agent_1', conversationId: 'conv_abcdefghijkl' }); + expect(getEvents).toHaveBeenCalledTimes(4); + + resolveFreshPage( + historyPage([{ sequence: 1, messageId: 'msg_old0000001', role: 'user', markdown: 'older' }], null) + ); + await fresh; + expect( + agentChat.getConversation({ agentId: 'agent_1', conversationId: 'conv_abcdefghijkl' })?.pagination.status + ).toBe('idle'); + }); + + it('does not prepend a stale fetchMore page after reload', async () => { + getEvents.mockResolvedValueOnce( + historyPage([{ sequence: 3, messageId: 'msg_new0000001', role: 'user', markdown: 'recent' }], 'act_page0001') + ); + await agentChat.loadConversation({ agentId: 'agent_1', conversationId: 'conv_abcdefghijkl' }); + + let resolveOlderPage!: (value: ReturnType) => void; + getEvents.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveOlderPage = resolve; + }) + ); + + const older = agentChat.fetchMore({ agentId: 'agent_1', conversationId: 'conv_abcdefghijkl' }); + + getEvents.mockResolvedValueOnce( + historyPage([{ sequence: 3, messageId: 'msg_new0000001', role: 'user', markdown: 'recent' }], 'act_page0001') + ); + await agentChat.loadConversation({ agentId: 'agent_1', conversationId: 'conv_abcdefghijkl' }); + + resolveOlderPage( + historyPage([{ sequence: 1, messageId: 'msg_stale000001', role: 'user', markdown: 'stale' }], null) + ); + await older; + + expect( + agentChat + .getConversation({ agentId: 'agent_1', conversationId: 'conv_abcdefghijkl' }) + ?.messages.map((message) => message.id) + ).toEqual(['msg_new0000001']); + }); + + it('does not let a stale fetchMore finalizer clear a fresh pagination claim after reload', async () => { + getEvents.mockResolvedValueOnce( + historyPage([{ sequence: 3, messageId: 'msg_new0000001', role: 'user', markdown: 'recent' }], 'act_page0001') + ); + await agentChat.loadConversation({ agentId: 'agent_1', conversationId: 'conv_abcdefghijkl' }); + + let resolveStalePage!: (value: ReturnType) => void; + getEvents.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveStalePage = resolve; + }) + ); + + const stale = agentChat.fetchMore({ agentId: 'agent_1', conversationId: 'conv_abcdefghijkl' }); + + getEvents.mockResolvedValueOnce( + historyPage([{ sequence: 3, messageId: 'msg_new0000001', role: 'user', markdown: 'recent' }], 'act_page0001') + ); + await agentChat.loadConversation({ agentId: 'agent_1', conversationId: 'conv_abcdefghijkl' }); + + let resolveFreshPage!: (value: ReturnType) => void; + getEvents.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFreshPage = resolve; + }) + ); + + const fresh = agentChat.fetchMore({ agentId: 'agent_1', conversationId: 'conv_abcdefghijkl' }); + expect(getEvents).toHaveBeenCalledTimes(4); + expect( + agentChat.getConversation({ agentId: 'agent_1', conversationId: 'conv_abcdefghijkl' })?.pagination.status + ).toBe('loading'); + + resolveStalePage( + historyPage([{ sequence: 1, messageId: 'msg_stale000001', role: 'user', markdown: 'stale' }], null) + ); + await stale; + + const deduped = agentChat.fetchMore({ agentId: 'agent_1', conversationId: 'conv_abcdefghijkl' }); + expect(getEvents).toHaveBeenCalledTimes(4); + expect( + agentChat.getConversation({ agentId: 'agent_1', conversationId: 'conv_abcdefghijkl' })?.pagination.status + ).toBe('loading'); + + resolveFreshPage( + historyPage([{ sequence: 1, messageId: 'msg_old0000001', role: 'user', markdown: 'older' }], null) + ); + await Promise.all([fresh, deduped]); + expect( + agentChat.getConversation({ agentId: 'agent_1', conversationId: 'conv_abcdefghijkl' })?.pagination.status + ).toBe('idle'); }); }); diff --git a/packages/js/src/agent-chat/agent-chat.ts b/packages/js/src/agent-chat/agent-chat.ts index 8cb7981767c..8b59af1d9b8 100644 --- a/packages/js/src/agent-chat/agent-chat.ts +++ b/packages/js/src/agent-chat/agent-chat.ts @@ -7,11 +7,13 @@ import { NovuError } from '../utils/errors'; import type { BaseSocketInterface } from '../ws/base-socket'; import { AgentChatStore, type ConversationEntry, createLocalConversationKey } from './agent-chat-store'; import { AgentConversationRuntime } from './agent-conversation-runtime'; -import { type AgentMessage, derivePendingActions } from './agent-message.types'; +import { type AgentConversationError, type AgentMessage, derivePendingActions } from './agent-message.types'; import type { ConversationArgs, ConversationResult } from './conversation-runtime.types'; import { runtimeCacheKey } from './runtime-cache-key'; import type { + AgentChatChange, AgentChatMessagesUpdated, + AgentChatPagination, FetchMoreArgs, FetchMoreResult, LoadConversationArgs, @@ -23,6 +25,21 @@ import type { SendMessageArgs, SendMessageResult, } from './types'; +import { parseAgentEventEnvelope } from './validate-envelope'; + +function conversationErrorToNovuError(error: AgentConversationError): NovuError { + return new NovuError(error.message, new Error(error.code ?? error.message)); +} + +function entryPagination(entry: ConversationEntry): AgentChatPagination { + return { + status: entry.paginationStatus, + hasMore: entry.olderCursor != null, + }; +} + +/** Safety cap on reconnect catch-up HTTP pages. Exceeding this sets a public error. */ +const CATCH_UP_PAGE_LIMIT = 20; export class AgentChat extends BaseModule { #agentChatService: AgentChatService; @@ -31,10 +48,10 @@ export class AgentChat extends BaseModule { #liveSubscriberCount = 0; #runtimes = new Map(); /** - * Non-null while a reconnect catch-up is in flight: live envelopes are buffered here - * and applied after the HTTP page is absorbed. Serialized via `#catchUpChain`. + * Per-conversation live envelope buffers while reconnect catch-up is in flight. + * Map key means catch-up is in flight; only those conversations buffer live envelopes. */ - #catchUpBuffer: AgentEventEnvelope[] | null = null; + #catchUpBuffers = new Map(); #catchUpChain: Promise = Promise.resolve(); constructor({ @@ -52,19 +69,7 @@ export class AgentChat extends BaseModule { this.#agentChatService = agentChatService; this.#socket = socket; this.#store = new AgentChatStore((entry, change) => { - this._emitter.emit('agent_chat.messages.updated', { - data: { - agentId: entry.agentId, - conversationId: entry.conversationId, - key: entry.key, - messages: entry.messages, - isRunning: entry.isRunning, - typing: entry.typing, - status: entry.status, - hasMore: entry.olderCursor != null, - change, - }, - }); + this.#emitMessagesUpdated(entry, change); }); this._emitter.on('agent_chat.agent_event', ({ result }) => { this.#handleAgentEvent(result); @@ -103,7 +108,7 @@ export class AgentChat extends BaseModule { } this.#store.clear(); - this.#catchUpBuffer = null; + this.#catchUpBuffers.clear(); this.#runtimes.clear(); } @@ -163,6 +168,10 @@ export class AgentChat extends BaseModule { typing?: ConversationEntry['typing']; status: ConversationEntry['status']; hasMore: boolean; + pagination: AgentChatPagination; + isRecovering: boolean; + error?: NovuError; + catchUpError?: NovuError; } | undefined { const entry = key @@ -183,6 +192,10 @@ export class AgentChat extends BaseModule { typing: entry.typing, status: entry.status, hasMore: entry.olderCursor != null, + pagination: entryPagination(entry), + isRecovering: entry.isRecovering, + error: entry.error ? conversationErrorToNovuError(entry.error) : undefined, + catchUpError: entry.catchUpError, }; } @@ -301,22 +314,36 @@ export class AgentChat extends BaseModule { }; } - try { - const page = await this.#agentChatService.getEvents({ - conversationId: entry.conversationId, - before: entry.olderCursor, - }); - const next = this.#store.prependOlderPage(entry, page.events, page.olderCursor); + return this.#store.withFetchMoreClaim(entry, async () => { + const epochAtStart = entry.paginationEpoch; - return { - data: { - messages: next.messages, - hasMore: next.olderCursor != null, - }, - }; - } catch (error) { - return { error: new NovuError('Failed to load older agent chat messages', error) }; - } + try { + const page = await this.#agentChatService.getEvents({ + conversationId: entry.conversationId!, + before: entry.olderCursor!, + }); + + if (epochAtStart !== entry.paginationEpoch) { + return { + data: { + messages: entry.messages, + hasMore: entry.olderCursor != null, + }, + }; + } + + const next = this.#store.prependOlderPage(entry, page.events, page.olderCursor); + + return { + data: { + messages: next.messages, + hasMore: next.olderCursor != null, + }, + }; + } catch (error) { + return { error: new NovuError('Failed to load older agent chat messages', error) }; + } + }); }); } @@ -469,15 +496,25 @@ export class AgentChat extends BaseModule { /** * Live WS path: apply envelopes into open conversations only. * Unknown conversations are dropped — mount/resume creates the entry. - * During reconnect catch-up, all live envelopes are buffered until HTTP finishes. + * During reconnect catch-up for a conversation, only that conversation's envelopes buffer. */ - #handleAgentEvent(envelope: AgentEventEnvelope): void { - if (!envelope.conversationIdentifier) { + #handleAgentEvent(raw: unknown): void { + const parsed = parseAgentEventEnvelope(raw); + if (!parsed.ok) { + console.warn('[novu agent-chat] skipping live envelope:', parsed.reason); + return; } - if (this.#catchUpBuffer) { - this.#catchUpBuffer.push(envelope); + const envelope = parsed.envelope; + const conversationId = envelope.conversationIdentifier; + if (!conversationId) { + return; + } + + if (this.#catchUpBuffers.has(conversationId)) { + const buffer = this.#catchUpBuffers.get(conversationId); + buffer?.push(envelope); return; } @@ -523,41 +560,58 @@ export class AgentChat extends BaseModule { byConversationId.set(entry.conversationId, holders); } - this.#catchUpBuffer = []; - - try { - await Promise.all( - [...byConversationId.entries()].map(([conversationId, holders]) => - this.#catchUpConversation(conversationId, holders) - ) - ); - } finally { - const buffered = this.#catchUpBuffer ?? []; - this.#catchUpBuffer = null; + await Promise.all( + [...byConversationId.entries()].map(([conversationId, holders]) => + this.#catchUpConversation(conversationId, holders) + ) + ); + } - for (const envelope of buffered) { - this.#applyLiveEnvelope(envelope); - } - } + #emitMessagesUpdated(entry: ConversationEntry, change: AgentChatChange): void { + this._emitter.emit('agent_chat.messages.updated', { + data: { + agentId: entry.agentId, + conversationId: entry.conversationId, + key: entry.key, + messages: entry.messages, + isRunning: entry.isRunning, + typing: entry.typing, + status: entry.status, + hasMore: entry.olderCursor != null, + pagination: entryPagination(entry), + error: entry.error ? conversationErrorToNovuError(entry.error) : undefined, + isRecovering: entry.isRecovering, + ...(entry.catchUpError ? { catchUpError: entry.catchUpError } : {}), + change, + }, + }); } async #catchUpConversation(conversationId: string, holders: ConversationEntry[]): Promise { - try { - const activeHolders = holders - .map((holder) => this.#store.get(holder.key)) - .filter((entry): entry is ConversationEntry => entry != null && entry.conversationId === conversationId); + const activeHolders = holders + .map((holder) => this.#store.get(holder.key)) + .filter((entry): entry is ConversationEntry => entry != null && entry.conversationId === conversationId); - if (activeHolders.length === 0) { - return; - } + if (activeHolders.length === 0) { + return; + } + + this.#catchUpBuffers.set(conversationId, []); + for (const entry of activeHolders) { + this.#store.setRecoveryState(entry, { isRecovering: true }); + } + let discardBufferedEnvelopes = false; + + try { // Page toward older events until we reach already-known sequence territory. // One newest page is not enough when the offline gap exceeds the server page size. const knownThrough = Math.min(...activeHolders.map((entry) => entry.lastSequence)); const missed: AgentEventEnvelope[] = []; let before: string | undefined; + let completed = false; - for (let pageIndex = 0; pageIndex < 20; pageIndex += 1) { + for (let pageIndex = 0; pageIndex < CATCH_UP_PAGE_LIMIT; pageIndex += 1) { const page = await this.#agentChatService.getEvents({ conversationId, ...(before ? { before } : {}), @@ -567,12 +621,27 @@ export class AgentChat extends BaseModule { const oldestInPage = envelopes[0]?.sequence; if (page.olderCursor == null || oldestInPage == null || oldestInPage <= knownThrough) { + completed = true; break; } before = page.olderCursor; } + if (!completed) { + // On catch-up failure the conversation stays stale and errored rather than showing messages across a known gap. + discardBufferedEnvelopes = true; + const catchUpError = new NovuError( + 'Agent chat reconnect catch-up exceeded the safety page limit; conversation history may be incomplete', + new Error('catch_up_limit_exceeded') + ); + for (const entry of activeHolders) { + this.#store.setRecoveryState(entry, { isRecovering: entry.isRecovering, catchUpError }); + } + + return; + } + // Apply oldest→newest so message order stays chronological across pages. missed.sort((left, right) => left.sequence - right.sequence); @@ -582,12 +651,39 @@ export class AgentChat extends BaseModule { continue; } + this.#store.setRecoveryState(entry, { isRecovering: entry.isRecovering, catchUpError: undefined }); for (const envelope of missed) { this.#store.applyLiveEnvelope(entry, envelope); } } - } catch { - // Best-effort catch-up; buffered live envelopes still flush in the outer finally. + } catch (error) { + const catchUpError = new NovuError('Failed to recover agent chat conversation after reconnect', error); + for (const holder of activeHolders) { + const entry = this.#store.get(holder.key); + if (!entry || entry.conversationId !== conversationId) { + continue; + } + + this.#store.setRecoveryState(entry, { isRecovering: entry.isRecovering, catchUpError }); + } + } finally { + const buffered = this.#catchUpBuffers.get(conversationId) ?? []; + this.#catchUpBuffers.delete(conversationId); + + for (const holder of activeHolders) { + const entry = this.#store.get(holder.key); + if (!entry || entry.conversationId !== conversationId) { + continue; + } + + this.#store.setRecoveryState(entry, { isRecovering: false, catchUpError: entry.catchUpError }); + } + + if (!discardBufferedEnvelopes) { + for (const envelope of buffered) { + this.#applyLiveEnvelope(envelope); + } + } } } } diff --git a/packages/js/src/agent-chat/agent-conversation-runtime.test.ts b/packages/js/src/agent-chat/agent-conversation-runtime.test.ts index 34a5bb08edd..da754cfc17b 100644 --- a/packages/js/src/agent-chat/agent-conversation-runtime.test.ts +++ b/packages/js/src/agent-chat/agent-conversation-runtime.test.ts @@ -102,6 +102,7 @@ describe('AgentConversationRuntime', () => { it('notifies subscribers only when the snapshot reference changes', async () => { sendMessage.mockResolvedValue({ identifier: 'conv_abcdefghijkl', messageId: 'msg_abcdefghijkl' }); + getEvents.mockResolvedValue({ events: [], olderCursor: null }); const created = agentChat.conversation({ agentId: 'agent_1' }); if (!created.ok) { @@ -120,17 +121,18 @@ describe('AgentConversationRuntime', () => { await runtime.sendMessage('hello'); - // Optimistic send publishes twice: sending, then sent. - expect(seen).toHaveLength(3); + // Optimistic send publishes recovery clear, sending, sent, then catch-up recovery states. + expect(seen).toHaveLength(7); expect(seen[0]).toBe(initial); - expect(seen[1]).not.toBe(initial); - expect(seen[2]).not.toBe(seen[1]); + for (let index = 1; index < seen.length; index += 1) { + expect(seen[index]).not.toBe(seen[index - 1]); + } const current = runtime.getSnapshot(); runtime.getSnapshot(); - expect(seen).toHaveLength(3); + expect(seen).toHaveLength(7); expect(seen.every((snapshot, index) => snapshot === seen[index])).toBe(true); - expect(current).toBe(seen[2]); + expect(current).toBe(seen[6]); unsubscribe(); runtime.dispose(); diff --git a/packages/js/src/agent-chat/apply-envelope.test.ts b/packages/js/src/agent-chat/apply-envelope.test.ts index db19b35fe4e..ec0322fe5e3 100644 --- a/packages/js/src/agent-chat/apply-envelope.test.ts +++ b/packages/js/src/agent-chat/apply-envelope.test.ts @@ -144,6 +144,21 @@ describe('applyEnvelope', () => { expect(finished.activeAssistantMessageId).toBeUndefined(); }); + it('clears a prior run-error on run-start and run-finish', () => { + const failed = applyEnvelope(createInitialAgentConversationState(), { + ...envelope(1, { type: 'run-error', message: 'handler failed', code: 'handler_failed' }), + }); + expect(failed.error).toMatchObject({ message: 'handler failed' }); + + const restarted = applyEnvelope(failed, envelope(2, { type: 'run-start' })); + expect(restarted.error).toBeUndefined(); + expect(restarted.isRunning).toBe(true); + + const finished = applyEnvelope(restarted, envelope(3, { type: 'run-finish', outcome: 'completed' })); + expect(finished.error).toBeUndefined(); + expect(finished.isRunning).toBe(false); + }); + it('accumulates fragmented tool input deltas without corrupting partial JSON', () => { const state = applyEnvelopes(createInitialAgentConversationState(), [ envelope(1, { type: 'run-start' }), diff --git a/packages/js/src/agent-chat/apply-envelope.ts b/packages/js/src/agent-chat/apply-envelope.ts index f79d960772c..89ba9216745 100644 --- a/packages/js/src/agent-chat/apply-envelope.ts +++ b/packages/js/src/agent-chat/apply-envelope.ts @@ -35,13 +35,14 @@ function applyEvent(state: AgentConversationState, envelope: AgentEventEnvelope) switch (event.type) { case 'run-start': - return { ...state, isRunning: true }; + return { ...state, isRunning: true, error: undefined }; case 'run-finish': return finalizeOpenStreamingParts({ ...state, isRunning: false, activeAssistantMessageId: undefined, + error: undefined, }); case 'run-error': diff --git a/packages/js/src/agent-chat/index.ts b/packages/js/src/agent-chat/index.ts index 14fadece595..8bf45c4f6a2 100644 --- a/packages/js/src/agent-chat/index.ts +++ b/packages/js/src/agent-chat/index.ts @@ -1,5 +1,37 @@ export { AgentChat } from './agent-chat'; +export type { + AgentChatDefinition, + AgentChatToolsDefinition, + AgentToolDefinition, + AgentToolPartFor, +} from './agent-chat-definition.types'; export { AgentConversationRuntime } from './agent-conversation-runtime'; +export type { + AgentApprovalPart, + AgentApprovalPartState, + AgentCardPart, + AgentConversationError, + AgentConversationState, + AgentConversationStatus, + AgentConversationTyping, + AgentFilePart, + AgentMcpConnectionAction, + AgentMcpConnectionPart, + AgentMcpConnectionPartState, + AgentMessage, + AgentMessagePart, + AgentMessageRole, + AgentMessageStatus, + AgentPendingAction, + AgentSourcePart, + AgentTextPart, + AgentTextPartState, + AgentThinkingPart, + AgentToolApprovalAction, + AgentToolApprovalDecision, + AgentToolPart, + AgentToolPartState, +} from './agent-message.types'; export { derivePendingActions } from './agent-message.types'; export type { AgentConversationPaginationSnapshot, @@ -16,16 +48,10 @@ export type { export type { AgentChatChange, AgentChatMessagesUpdated, - AgentConversationStatus, - AgentConversationTyping, + AgentChatPagination, + AgentChatPaginationStatus, AgentEventEnvelope, AgentHashFields, - AgentMcpConnectionAction, - AgentMcpConnectionPart, - AgentMessage, - AgentPendingAction, - AgentToolApprovalAction, - AgentToolApprovalDecision, FetchMoreArgs, FetchMoreResult, LoadConversationArgs, diff --git a/packages/js/src/agent-chat/types.ts b/packages/js/src/agent-chat/types.ts index 6aba5b0f70f..e681ed45a12 100644 --- a/packages/js/src/agent-chat/types.ts +++ b/packages/js/src/agent-chat/types.ts @@ -1,4 +1,5 @@ import type { AgentEventEnvelope } from '@novu/agent-event-protocol'; +import type { NovuError } from '../utils/errors'; import type { AgentConversationStatus, AgentConversationTyping, @@ -74,6 +75,13 @@ export type FetchMoreResult = { hasMore: boolean; }; +export type AgentChatPaginationStatus = 'idle' | 'loading' | 'error'; + +export type AgentChatPagination = { + status: AgentChatPaginationStatus; + hasMore: boolean; +}; + export type RespondToActionArgs = AgentHashFields & { agentId: string; actionId: string; @@ -129,5 +137,11 @@ export type AgentChatMessagesUpdated = { typing?: AgentConversationTyping; status: AgentConversationStatus; hasMore: boolean; + pagination: AgentChatPagination; + error?: NovuError; + /** True while reconnect catch-up is in flight for this conversation. */ + isRecovering: boolean; + /** Set when catch-up hits the safety page limit or HTTP ultimately fails. */ + catchUpError?: NovuError; change: AgentChatChange; }; diff --git a/packages/js/src/agent-chat/validate-envelope.test.ts b/packages/js/src/agent-chat/validate-envelope.test.ts new file mode 100644 index 00000000000..4eb7c07c69e --- /dev/null +++ b/packages/js/src/agent-chat/validate-envelope.test.ts @@ -0,0 +1,90 @@ +import { AGENT_EVENT_PROTOCOL_VERSION, type AgentEvent, type AgentEventEnvelope } from '@novu/agent-event-protocol'; +import { parseAgentEventEnvelope, validateHistoryPageResponse } from './validate-envelope'; + +const BASE_IDS = { + conversationId: 'conv-1', + agentId: 'agent-1', + runId: 'run-1', + turnId: 'turn-1', +} as const; + +function envelope(sequence: number, event: AgentEvent, overrides: Partial = {}): AgentEventEnvelope { + return { + version: AGENT_EVENT_PROTOCOL_VERSION, + sequence, + timestamp: `2026-07-28T12:00:${String(sequence).padStart(2, '0')}.000Z`, + ...BASE_IDS, + ...overrides, + event, + }; +} + +describe('parseAgentEventEnvelope', () => { + it('skips unknown protocol versions without error', () => { + const value = { ...envelope(1, { type: 'run-start' }), version: 99 }; + const result = parseAgentEventEnvelope(value); + + expect(result).toEqual({ ok: false, skip: true, reason: 'unknown-version' }); + }); + + it('skips non-numeric protocol versions', () => { + const value = { ...envelope(1, { type: 'run-start' }), version: '1' }; + const result = parseAgentEventEnvelope(value); + + expect(result).toEqual({ ok: false, skip: true, reason: 'unknown-version' }); + }); + + it('rejects invalid envelope shape', () => { + const result = parseAgentEventEnvelope({ version: AGENT_EVENT_PROTOCOL_VERSION, event: { type: 1 } }); + + expect(result).toMatchObject({ + ok: false, + skip: true, + reason: 'invalid-schema', + error: { code: 'protocol.schema' }, + }); + }); + + it('rejects durable message without role', () => { + const result = parseAgentEventEnvelope( + envelope(1, { + type: 'message', + messageId: 'm1', + role: 'system' as 'assistant', + content: { markdown: 'Hi' }, + }) + ); + + expect(result).toMatchObject({ ok: false, reason: 'invalid-schema' }); + }); +}); + +describe('validateHistoryPageResponse', () => { + it('rejects missing events array', () => { + const result = validateHistoryPageResponse({ olderCursor: null }); + + expect(result).toMatchObject({ + ok: false, + error: { code: 'protocol.history' }, + }); + }); + + it('filters unknown-version envelopes from history', () => { + const known = envelope(1, { type: 'run-start' }); + const unknown = { ...envelope(2, { type: 'run-finish', outcome: 'completed' as const }), version: 99 }; + const result = validateHistoryPageResponse({ events: [known, unknown], olderCursor: null }); + + expect(result).toEqual({ ok: true, events: [known], olderCursor: null }); + }); + + it('skips invalid envelopes and returns the valid ones', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + const known = envelope(1, { type: 'run-start' }); + const invalid = { version: AGENT_EVENT_PROTOCOL_VERSION, event: { type: 'run-start' } }; + const result = validateHistoryPageResponse({ events: [known, invalid], olderCursor: null }); + + expect(result).toEqual({ ok: true, events: [known], olderCursor: null }); + expect(warnSpy).toHaveBeenCalledWith('[novu agent-chat] skipping history envelope:', 'invalid-schema'); + warnSpy.mockRestore(); + }); +}); diff --git a/packages/js/src/agent-chat/validate-envelope.ts b/packages/js/src/agent-chat/validate-envelope.ts new file mode 100644 index 00000000000..14dbbe10adc --- /dev/null +++ b/packages/js/src/agent-chat/validate-envelope.ts @@ -0,0 +1,100 @@ +import { + AGENT_EVENT_PROTOCOL_VERSION, + type AgentEventEnvelope, + isAgentEventEnvelope, +} from '@novu/agent-event-protocol'; +import type { AgentConversationError } from './agent-message.types'; + +type AgentProtocolErrorCode = 'protocol.schema' | 'protocol.history'; + +function createProtocolError(message: string, code: AgentProtocolErrorCode): AgentConversationError { + return { message, code }; +} + +export type ParseEnvelopeResult = + | { ok: true; envelope: AgentEventEnvelope } + | { ok: false; skip: true; reason: 'unknown-version' } + | { ok: false; skip: true; reason: 'invalid-schema'; error: AgentConversationError }; + +export function parseAgentEventEnvelope(value: unknown): ParseEnvelopeResult { + if (typeof value !== 'object' || value === null) { + return { + ok: false, + skip: true, + reason: 'invalid-schema', + error: createProtocolError('Agent event envelope must be an object', 'protocol.schema'), + }; + } + + const candidate = value as Record; + + if (candidate.version !== undefined && typeof candidate.version !== 'number') { + return { ok: false, skip: true, reason: 'unknown-version' }; + } + + if (typeof candidate.version === 'number' && candidate.version !== AGENT_EVENT_PROTOCOL_VERSION) { + return { ok: false, skip: true, reason: 'unknown-version' }; + } + + if (!isAgentEventEnvelope(value)) { + return { + ok: false, + skip: true, + reason: 'invalid-schema', + error: createProtocolError('Agent event envelope failed schema validation', 'protocol.schema'), + }; + } + + return { ok: true, envelope: value }; +} + +export type ValidateHistoryPageResult = + | { ok: true; events: AgentEventEnvelope[]; olderCursor: string | null } + | { ok: false; error: AgentConversationError }; + +export function validateHistoryPageResponse(value: unknown): ValidateHistoryPageResult { + if (typeof value !== 'object' || value === null) { + return { + ok: false, + error: createProtocolError('History response must be an object', 'protocol.history'), + }; + } + + const candidate = value as Record; + + if (!Array.isArray(candidate.events)) { + return { + ok: false, + error: createProtocolError('History response missing events array', 'protocol.history'), + }; + } + + const olderCursor = + candidate.olderCursor === null || typeof candidate.olderCursor === 'string' ? candidate.olderCursor : null; + + if ( + candidate.olderCursor !== undefined && + candidate.olderCursor !== null && + typeof candidate.olderCursor !== 'string' + ) { + return { + ok: false, + error: createProtocolError('History response olderCursor must be a string or null', 'protocol.history'), + }; + } + + const events: AgentEventEnvelope[] = []; + + for (const item of candidate.events) { + const parsed = parseAgentEventEnvelope(item); + + if (parsed.ok) { + events.push(parsed.envelope); + continue; + } + + console.warn('[novu agent-chat] skipping history envelope:', parsed.reason); + } + + return { ok: true, events, olderCursor }; +} diff --git a/packages/js/src/api/agent-chat-service.test.ts b/packages/js/src/api/agent-chat-service.test.ts index 811e389e201..441f9a7921f 100644 --- a/packages/js/src/api/agent-chat-service.test.ts +++ b/packages/js/src/api/agent-chat-service.test.ts @@ -1,3 +1,4 @@ +import { AGENT_EVENT_PROTOCOL_VERSION } from '@novu/agent-event-protocol'; import { AgentChatService } from './agent-chat-service'; import { HttpClient } from './http-client'; @@ -230,4 +231,30 @@ describe('AgentChatService', () => { expect.objectContaining({ method: 'GET' }) ); }); + + it('skips invalid envelopes in history pages', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + const fetchMock = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + data: { + events: [{ version: AGENT_EVENT_PROTOCOL_VERSION, event: { type: 'run-start' } }], + olderCursor: null, + }, + }), + } as Response); + global.fetch = fetchMock as unknown as typeof fetch; + + const httpClient = new HttpClient({ apiUrl: 'https://test.novu.co' }); + const service = new AgentChatService({ httpClient }); + + const result = await service.getEvents({ + conversationId: 'conv_abcdefghijkl', + }); + + expect(result).toEqual({ events: [], olderCursor: null }); + expect(warnSpy).toHaveBeenCalledWith('[novu agent-chat] skipping history envelope:', 'invalid-schema'); + warnSpy.mockRestore(); + }); }); diff --git a/packages/js/src/api/agent-chat-service.ts b/packages/js/src/api/agent-chat-service.ts index 3cfcffa7103..f9c9a470f26 100644 --- a/packages/js/src/api/agent-chat-service.ts +++ b/packages/js/src/api/agent-chat-service.ts @@ -1,5 +1,6 @@ import type { AgentEventEnvelope } from '@novu/agent-event-protocol'; import type { AgentHashFields } from '../agent-chat/types'; +import { validateHistoryPageResponse } from '../agent-chat/validate-envelope'; import { HttpClient } from './http-client'; const AGENT_CHAT_CONVERSATIONS_ROUTE = '/agent-chat/conversations'; @@ -143,8 +144,18 @@ export class AgentChatService { const query = params.toString(); const suffix = query ? `?${query}` : ''; - return this.#httpClient.get( + const raw = await this.#httpClient.get( `${AGENT_CHAT_CONVERSATIONS_ROUTE}/${encodeURIComponent(args.conversationId)}/events${suffix}` ); + + const validated = validateHistoryPageResponse(raw); + if (!validated.ok) { + throw new Error(validated.error.message); + } + + return { + events: validated.events, + olderCursor: validated.olderCursor, + }; } } diff --git a/packages/js/src/index.ts b/packages/js/src/index.ts index deb4b701459..8eef932711c 100644 --- a/packages/js/src/index.ts +++ b/packages/js/src/index.ts @@ -1,21 +1,43 @@ export type * from 'json-logic-js'; export type { + AgentApprovalPart, + AgentApprovalPartState, + AgentCardPart, AgentChatChange, + AgentChatDefinition, + AgentChatPagination, + AgentChatPaginationStatus, + AgentChatToolsDefinition, + AgentConversationError, AgentConversationPaginationSnapshot, AgentConversationRunSnapshot, AgentConversationRuntimeActions, AgentConversationSessionStatus, AgentConversationSnapshot, + AgentConversationState, AgentConversationStatus, AgentConversationTyping, AgentEventEnvelope, + AgentFilePart, AgentHashFields, AgentMcpConnectionAction, AgentMcpConnectionPart, + AgentMcpConnectionPartState, AgentMessage, + AgentMessagePart, + AgentMessageRole, + AgentMessageStatus, AgentPendingAction, + AgentSourcePart, + AgentTextPart, + AgentTextPartState, + AgentThinkingPart, AgentToolApprovalAction, AgentToolApprovalDecision, + AgentToolDefinition, + AgentToolPart, + AgentToolPartFor, + AgentToolPartState, ConversationArgs, ConversationErr, ConversationOk, diff --git a/packages/react/src/hooks/useAgentChat.ts b/packages/react/src/hooks/useAgentChat.ts index ce4b474f947..fb2f9537a5a 100644 --- a/packages/react/src/hooks/useAgentChat.ts +++ b/packages/react/src/hooks/useAgentChat.ts @@ -1,4 +1,5 @@ import type { + AgentChatPagination, AgentChatPlanLimitError, AgentConversationStatus, AgentConversationTyping, @@ -57,17 +58,20 @@ export type UseAgentChatResult = { error?: NovuError | AgentChatPlanLimitError; /** True until the first history fetch completes. False when there is no `conversationId` prop. */ isLoading: boolean; - isFetching: boolean; isRunning: boolean; typing?: AgentConversationTyping; status: AgentConversationStatus; - /** True when older history pages are available via `fetchMore`. */ - hasMore: boolean; + pagination: AgentChatPagination & { + fetchMore: () => Promise<{ + data?: { messages: AgentMessage[]; hasMore: boolean }; + error?: NovuError; + }>; + }; + /** True while reconnect catch-up is in flight for this conversation. */ + isRecovering: boolean; + /** Set when reconnect catch-up fails. Separate from send/fetch `error`. */ + catchUpError?: NovuError; refetch: () => Promise; - fetchMore: () => Promise<{ - data?: { messages: AgentMessage[]; hasMore: boolean }; - error?: NovuError; - }>; sendMessage: (text: string) => Promise<{ data?: SendMessageResult; error?: NovuError | AgentChatPlanLimitError; @@ -102,7 +106,8 @@ type ConversationSnapshot = { isRunning: boolean; typing?: AgentConversationTyping; status: AgentConversationStatus; - hasMore: boolean; + pagination: AgentChatPagination; + error?: NovuError | AgentChatPlanLimitError; }; const EMPTY_CONVERSATION: ConversationSnapshot = { @@ -110,7 +115,7 @@ const EMPTY_CONVERSATION: ConversationSnapshot = { isRunning: false, typing: undefined, status: 'active', - hasMore: false, + pagination: { status: 'idle', hasMore: false }, }; function applyConversationSnapshot( @@ -120,14 +125,16 @@ function applyConversationSnapshot( setIsRunning: (isRunning: boolean) => void; setTyping: (typing?: AgentConversationTyping) => void; setStatus: (status: AgentConversationStatus) => void; - setHasMore: (hasMore: boolean) => void; + setPagination: (pagination: AgentChatPagination) => void; + setError: (error?: NovuError | AgentChatPlanLimitError) => void; } ): void { setters.setMessages(snapshot.messages); setters.setIsRunning(snapshot.isRunning); setters.setTyping(snapshot.typing); setters.setStatus(snapshot.status); - setters.setHasMore(snapshot.hasMore); + setters.setPagination(snapshot.pagination); + setters.setError(snapshot.error); } export const useAgentChat = (props: UseAgentChatProps): UseAgentChatResult => { @@ -151,11 +158,14 @@ export const useAgentChat = (props: UseAgentChatProps): UseAgentChatResult => { const [isRunning, setIsRunning] = useState(false); const [typing, setTyping] = useState(); const [status, setStatus] = useState('active'); - const [hasMore, setHasMore] = useState(false); + const [pagination, setPagination] = useState({ status: 'idle', hasMore: false }); const [error, setError] = useState(); const [isLoading, setIsLoading] = useState(Boolean(conversationIdProp)); - const [isFetching, setIsFetching] = useState(false); + const [isRecovering, setIsRecovering] = useState(false); + const [catchUpError, setCatchUpError] = useState(); const fetchGenerationRef = useRef(0); + const notifiedCatchUpErrorRef = useRef(); + const lastReportedErrorKeyRef = useRef(); const pendingActions = useMemo(() => derivePendingActions(messages), [messages]); @@ -165,7 +175,8 @@ export const useAgentChat = (props: UseAgentChatProps): UseAgentChatResult => { setIsRunning, setTyping, setStatus, - setHasMore, + setPagination, + setError, }), [] ); @@ -182,6 +193,7 @@ export const useAgentChat = (props: UseAgentChatProps): UseAgentChatResult => { applyConversationSnapshot(EMPTY_CONVERSATION, snapshotSetters); setError(undefined); setIsLoading(Boolean(conversationIdProp)); + lastReportedErrorKeyRef.current = undefined; return; } @@ -204,8 +216,8 @@ export const useAgentChat = (props: UseAgentChatProps): UseAgentChatResult => { async (targetConversationId: string) => { const generation = ++fetchGenerationRef.current; setError(undefined); + lastReportedErrorKeyRef.current = undefined; setIsLoading(true); - setIsFetching(true); const response = await novu.agentChat.loadConversation({ agentId, @@ -221,17 +233,28 @@ export const useAgentChat = (props: UseAgentChatProps): UseAgentChatResult => { propsRef.current.onError?.(response.error); } else if (response.data) { setMessages(response.data.messages); - setHasMore(response.data.hasMore); + const snapshot = novu.agentChat.getConversation({ + agentId, + conversationId: targetConversationId, + }); + if (snapshot) { + setPagination(snapshot.pagination); + } else { + setPagination({ status: 'idle', hasMore: response.data.hasMore }); + } propsRef.current.onSuccess?.(response.data); } setIsLoading(false); - setIsFetching(false); }, [novu, agentId, propsRef] ); useEffect(() => { + // Agent chat always subscribes for live WS events while mounted, regardless of + // ``. That flag only disables notification/count + // auto-sync (`useNotifications`, `useCounts`). To stop agent-chat live updates, + // unmount the hook or call `novu.agentChat.unsubscribe()` yourself. novu.agentChat.subscribe(); const snapshot = novu.agentChat.getConversation({ @@ -246,10 +269,17 @@ export const useAgentChat = (props: UseAgentChatProps): UseAgentChatResult => { isRunning: snapshot.isRunning, typing: snapshot.typing, status: snapshot.status, - hasMore: snapshot.hasMore, + pagination: snapshot.pagination, + error: snapshot.error, }, snapshotSetters ); + setIsRecovering(snapshot.isRecovering); + setCatchUpError(snapshot.catchUpError); + if (snapshot.catchUpError && snapshot.catchUpError !== notifiedCatchUpErrorRef.current) { + notifiedCatchUpErrorRef.current = snapshot.catchUpError; + propsRef.current.onError?.(snapshot.catchUpError); + } if (snapshot.conversationId && !conversationIdProp) { setAssignedConversationId(snapshot.conversationId); } @@ -274,7 +304,8 @@ export const useAgentChat = (props: UseAgentChatProps): UseAgentChatResult => { isRunning: data.isRunning, typing: data.typing, status: data.status, - hasMore: data.hasMore, + pagination: data.pagination, + error: data.error, }, snapshotSetters ); @@ -282,6 +313,25 @@ export const useAgentChat = (props: UseAgentChatProps): UseAgentChatResult => { setAssignedConversationId(data.conversationId); } + setIsRecovering(data.isRecovering); + setCatchUpError(data.catchUpError); + if (data.catchUpError && data.catchUpError !== notifiedCatchUpErrorRef.current) { + notifiedCatchUpErrorRef.current = data.catchUpError; + propsRef.current.onError?.(data.catchUpError); + } else if (data.catchUpError === undefined) { + notifiedCatchUpErrorRef.current = undefined; + } + + if (data.error) { + const errorKey = `${data.error.message}:${data.error.originalError?.message ?? ''}`; + if (lastReportedErrorKeyRef.current !== errorKey) { + lastReportedErrorKeyRef.current = errorKey; + propsRef.current.onError?.(data.error); + } + } else { + lastReportedErrorKeyRef.current = undefined; + } + const { change } = data; if (change.kind === 'live') { propsRef.current.onEvent?.(change.envelope); @@ -329,12 +379,23 @@ export const useAgentChat = (props: UseAgentChatProps): UseAgentChatResult => { propsRef.current.onError?.(response.error); } else if (response.data) { setMessages(response.data.messages); - setHasMore(response.data.hasMore); + setPagination((current: AgentChatPagination) => ({ + ...current, + hasMore: response.data!.hasMore, + })); } return response; }, [novu, agentId, sessionKeyRef, conversationIdRef, propsRef]); + const paginationWithFetch = useMemo( + () => ({ + ...pagination, + fetchMore, + }), + [pagination, fetchMore] + ); + const sendMessage = useCallback( async (text: string) => { setError(undefined); @@ -415,12 +476,12 @@ export const useAgentChat = (props: UseAgentChatProps): UseAgentChatResult => { conversationId, error, isLoading, - isFetching, isRunning, typing, status, - hasMore, + pagination: paginationWithFetch, + isRecovering, + catchUpError, refetch, - fetchMore, }; }; diff --git a/packages/react/src/server/index.tsx b/packages/react/src/server/index.tsx index 9e103dfcc54..97425476a3c 100644 --- a/packages/react/src/server/index.tsx +++ b/packages/react/src/server/index.tsx @@ -82,13 +82,17 @@ export function useAgentChat(_: UseAgentChatProps): UseAgentChatResult { messages: [], pendingActions: [], isLoading: false, - isFetching: false, isRunning: false, typing: undefined, status: 'active', - hasMore: false, + pagination: { + status: 'idle', + hasMore: false, + fetchMore: () => Promise.resolve({ data: undefined, error: undefined }), + }, + isRecovering: false, + catchUpError: undefined, refetch: () => Promise.resolve(), - fetchMore: () => Promise.resolve({ data: undefined, error: undefined }), sendMessage: () => Promise.resolve({ data: undefined, error: undefined }), respondToAction: () => Promise.resolve({ data: undefined, error: undefined }), sendAction: () => Promise.resolve({ data: undefined, error: undefined }), diff --git a/playground/agent-chat/src/components/agent-chat.tsx b/playground/agent-chat/src/components/agent-chat.tsx index 7b4f397e87d..a385a273acf 100644 --- a/playground/agent-chat/src/components/agent-chat.tsx +++ b/playground/agent-chat/src/components/agent-chat.tsx @@ -72,9 +72,7 @@ export function AgentChat({ conversationId, onAssistantMessage, sidebar }: Agent isRunning, status, isLoading, - isFetching, - hasMore, - fetchMore, + pagination, typing, } = useAgentChat({ agentId: config.agentId, @@ -118,9 +116,7 @@ export function AgentChat({ conversationId, onAssistantMessage, sidebar }: Agent pendingActions={pendingActions} isRunning={isRunning} typing={typing} - hasMore={hasMore} - isFetching={isFetching} - onFetchMore={fetchMore} + pagination={pagination} onRespond={respondToAction} composerDisabled={sending || isRunning || isLoading} onSend={onSend} diff --git a/playground/agent-chat/src/components/chat-panel.tsx b/playground/agent-chat/src/components/chat-panel.tsx index 2c7da6bf15b..49679f458a8 100644 --- a/playground/agent-chat/src/components/chat-panel.tsx +++ b/playground/agent-chat/src/components/chat-panel.tsx @@ -1,6 +1,6 @@ 'use client'; -import type { AgentConversationTyping, AgentMessage, AgentPendingAction } from '@novu/react'; +import type { AgentConversationTyping, AgentMessage, AgentPendingAction, UseAgentChatResult } from '@novu/react'; import type { RespondToAction } from './approval-card'; import { ApprovalDock } from './approval-dock'; import { ChatThread } from './chat-thread'; @@ -17,9 +17,7 @@ export type ChatPanelProps = { pendingActions: AgentPendingAction[]; isRunning: boolean; typing?: AgentConversationTyping; - hasMore: boolean; - isFetching: boolean; - onFetchMore: () => Promise; + pagination: UseAgentChatResult['pagination']; onRespond: RespondToAction; composerDisabled: boolean; onSend: (text: string) => void; @@ -32,9 +30,7 @@ export function ChatPanel({ pendingActions, isRunning, typing, - hasMore, - isFetching, - onFetchMore, + pagination, onRespond, composerDisabled, onSend, @@ -56,9 +52,7 @@ export function ChatPanel({ messages={messages} isRunning={isRunning} typing={typing} - hasMore={hasMore} - isFetching={isFetching} - onFetchMore={onFetchMore} + pagination={pagination} onRespond={onRespond} /> diff --git a/playground/agent-chat/src/components/chat-thread.tsx b/playground/agent-chat/src/components/chat-thread.tsx index df4b69ddd14..61b983fb711 100644 --- a/playground/agent-chat/src/components/chat-thread.tsx +++ b/playground/agent-chat/src/components/chat-thread.tsx @@ -1,6 +1,6 @@ 'use client'; -import type { AgentMessage } from '@novu/react'; +import type { AgentMessage, UseAgentChatResult } from '@novu/react'; import { useCallback, useEffect, useRef } from 'react'; import type { RespondToAction } from './approval-card'; import { ChatIcon, SparkIcon } from './icons'; @@ -15,9 +15,7 @@ type ChatThreadProps = { isRunning: boolean; /** Live `channel.typing` from `useAgentChat`. Absent when the agent is idle. */ typing?: TypingState; - hasMore: boolean; - isFetching: boolean; - onFetchMore: () => Promise; + pagination: UseAgentChatResult['pagination']; onRespond?: RespondToAction; }; @@ -35,15 +33,9 @@ function AgentStatusRow({ status }: { status?: string }) { ); } -export function ChatThread({ - messages, - isRunning, - typing, - hasMore, - isFetching, - onFetchMore, - onRespond, -}: ChatThreadProps) { +export function ChatThread({ messages, isRunning, typing, pagination, onRespond }: ChatThreadProps) { + const isFetching = pagination.status === 'loading'; + const scrollRef = useRef(null); const bottomRef = useRef(null); const lastMessage = messages[messages.length - 1]; @@ -57,19 +49,19 @@ export function ChatThread({ const container = scrollRef.current; const heightBefore = container?.scrollHeight ?? 0; - await onFetchMore(); + await pagination.fetchMore(); // Hold the reading position: the prepended page grows the thread upwards. requestAnimationFrame(() => { if (!container) return; container.scrollTop += container.scrollHeight - heightBefore; }); - }, [onFetchMore]); + }, [pagination]); return (
- {hasMore ? ( + {pagination.hasMore ? (