From ce4131131b42948e0387517718036715081877e8 Mon Sep 17 00:00:00 2001 From: Adam Chmara Date: Fri, 21 Aug 2026 15:45:18 +0200 Subject: [PATCH 1/3] fix(js,react): correct agent-chat pagination state and run-error exposure fixes NV-8638 (#12414) --- .../js/src/agent-chat/agent-chat-store.ts | 68 ++++- packages/js/src/agent-chat/agent-chat.test.ts | 255 +++++++++++++++++- packages/js/src/agent-chat/agent-chat.ts | 64 +++-- .../js/src/agent-chat/apply-envelope.test.ts | 15 ++ packages/js/src/agent-chat/apply-envelope.ts | 3 +- packages/js/src/agent-chat/index.ts | 2 + packages/js/src/agent-chat/types.ts | 10 + packages/js/src/index.ts | 2 + packages/react/src/hooks/useAgentChat.ts | 66 +++-- packages/react/src/server/index.tsx | 8 +- .../agent-chat/src/components/agent-chat.tsx | 8 +- .../agent-chat/src/components/chat-panel.tsx | 14 +- .../agent-chat/src/components/chat-thread.tsx | 24 +- 13 files changed, 451 insertions(+), 88 deletions(-) diff --git a/packages/js/src/agent-chat/agent-chat-store.ts b/packages/js/src/agent-chat/agent-chat-store.ts index 79acca291d9..b8e3986e945 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'; @@ -41,6 +42,12 @@ export type ConversationEntry = AgentConversationState & { mcpConnectionResults: Map; /** 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 +221,8 @@ export class AgentChatStore { olderCursor: null, reportedActionIds: new Set(), mcpConnectionResults: new Map(), + paginationStatus: 'idle', + paginationEpoch: 0, }; this.#byKey.set(args.key, entry); @@ -227,15 +236,15 @@ export class AgentChatStore { */ appendSending(entry: ConversationEntry, text: string): string { const messageId = createOptimisticMessageId(); - applyState( - entry, - appendUserMessage(entry, { + 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 +303,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. diff --git a/packages/js/src/agent-chat/agent-chat.test.ts b/packages/js/src/agent-chat/agent-chat.test.ts index f0fbd948368..0a3f151ded8 100644 --- a/packages/js/src/agent-chat/agent-chat.test.ts +++ b/packages/js/src/agent-chat/agent-chat.test.ts @@ -1669,17 +1669,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 () => { @@ -1810,7 +1811,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..61b69412a59 100644 --- a/packages/js/src/agent-chat/agent-chat.ts +++ b/packages/js/src/agent-chat/agent-chat.ts @@ -7,11 +7,12 @@ 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 { AgentChatMessagesUpdated, + AgentChatPagination, FetchMoreArgs, FetchMoreResult, LoadConversationArgs, @@ -24,6 +25,17 @@ import type { SendMessageResult, } from './types'; +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, + }; +} + export class AgentChat extends BaseModule { #agentChatService: AgentChatService; #store: AgentChatStore; @@ -62,6 +74,8 @@ export class AgentChat extends BaseModule { typing: entry.typing, status: entry.status, hasMore: entry.olderCursor != null, + pagination: entryPagination(entry), + error: entry.error ? conversationErrorToNovuError(entry.error) : undefined, change, }, }); @@ -163,6 +177,8 @@ export class AgentChat extends BaseModule { typing?: ConversationEntry['typing']; status: ConversationEntry['status']; hasMore: boolean; + pagination: AgentChatPagination; + error?: NovuError; } | undefined { const entry = key @@ -183,6 +199,8 @@ export class AgentChat extends BaseModule { typing: entry.typing, status: entry.status, hasMore: entry.olderCursor != null, + pagination: entryPagination(entry), + error: entry.error ? conversationErrorToNovuError(entry.error) : undefined, }; } @@ -301,22 +319,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) }; + } + }); }); } 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..ebaac4b8803 100644 --- a/packages/js/src/agent-chat/index.ts +++ b/packages/js/src/agent-chat/index.ts @@ -16,6 +16,8 @@ export type { export type { AgentChatChange, AgentChatMessagesUpdated, + AgentChatPagination, + AgentChatPaginationStatus, AgentConversationStatus, AgentConversationTyping, AgentEventEnvelope, diff --git a/packages/js/src/agent-chat/types.ts b/packages/js/src/agent-chat/types.ts index 6aba5b0f70f..6218aae954f 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,7 @@ export type AgentChatMessagesUpdated = { typing?: AgentConversationTyping; status: AgentConversationStatus; hasMore: boolean; + pagination: AgentChatPagination; + error?: NovuError; change: AgentChatChange; }; diff --git a/packages/js/src/index.ts b/packages/js/src/index.ts index deb4b701459..3743e3471b0 100644 --- a/packages/js/src/index.ts +++ b/packages/js/src/index.ts @@ -1,6 +1,8 @@ export type * from 'json-logic-js'; export type { AgentChatChange, + AgentChatPagination, + AgentChatPaginationStatus, AgentConversationPaginationSnapshot, AgentConversationRunSnapshot, AgentConversationRuntimeActions, diff --git a/packages/react/src/hooks/useAgentChat.ts b/packages/react/src/hooks/useAgentChat.ts index ce4b474f947..ce58e555fd3 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,16 @@ 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; + }>; + }; refetch: () => Promise; - fetchMore: () => Promise<{ - data?: { messages: AgentMessage[]; hasMore: boolean }; - error?: NovuError; - }>; sendMessage: (text: string) => Promise<{ data?: SendMessageResult; error?: NovuError | AgentChatPlanLimitError; @@ -102,7 +102,8 @@ type ConversationSnapshot = { isRunning: boolean; typing?: AgentConversationTyping; status: AgentConversationStatus; - hasMore: boolean; + pagination: AgentChatPagination; + error?: NovuError | AgentChatPlanLimitError; }; const EMPTY_CONVERSATION: ConversationSnapshot = { @@ -110,7 +111,7 @@ const EMPTY_CONVERSATION: ConversationSnapshot = { isRunning: false, typing: undefined, status: 'active', - hasMore: false, + pagination: { status: 'idle', hasMore: false }, }; function applyConversationSnapshot( @@ -120,14 +121,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,10 +154,9 @@ 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 fetchGenerationRef = useRef(0); const pendingActions = useMemo(() => derivePendingActions(messages), [messages]); @@ -165,7 +167,8 @@ export const useAgentChat = (props: UseAgentChatProps): UseAgentChatResult => { setIsRunning, setTyping, setStatus, - setHasMore, + setPagination, + setError, }), [] ); @@ -205,7 +208,6 @@ export const useAgentChat = (props: UseAgentChatProps): UseAgentChatResult => { const generation = ++fetchGenerationRef.current; setError(undefined); setIsLoading(true); - setIsFetching(true); const response = await novu.agentChat.loadConversation({ agentId, @@ -221,12 +223,19 @@ 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] ); @@ -246,7 +255,8 @@ 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 ); @@ -274,7 +284,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 ); @@ -329,12 +340,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 +437,10 @@ export const useAgentChat = (props: UseAgentChatProps): UseAgentChatResult => { conversationId, error, isLoading, - isFetching, isRunning, typing, status, - hasMore, + pagination: paginationWithFetch, refetch, - fetchMore, }; }; diff --git a/packages/react/src/server/index.tsx b/packages/react/src/server/index.tsx index 9e103dfcc54..db874d6f0c9 100644 --- a/packages/react/src/server/index.tsx +++ b/packages/react/src/server/index.tsx @@ -82,13 +82,15 @@ 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 }), + }, 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 ? (