Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions packages/js/scripts/size-limit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
];

Expand Down
35 changes: 35 additions & 0 deletions packages/js/src/agent-chat/agent-chat-definition.types.ts
Original file line number Diff line number Diff line change
@@ -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<string, AgentToolDefinition>;

/**
* 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<MyChat['tools'], 'getOrder'>;
*/
export type AgentChatDefinition<TTools extends AgentChatToolsDefinition = AgentChatToolsDefinition> = {
tools: TTools;
};

export type AgentToolPartFor<TTools extends AgentChatToolsDefinition, TName extends string> = TName extends keyof TTools
? AgentToolPart & {
toolName: TName;
input: TTools[TName]['input'] extends undefined ? Record<string, unknown> | undefined : TTools[TName]['input'];
output: TTools[TName]['output'] extends undefined
? AgentToolResultContent[] | undefined
: TTools[TName]['output'];
}
: AgentToolPart;
87 changes: 81 additions & 6 deletions packages/js/src/agent-chat/agent-chat-store.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import type { AgentEventEnvelope } from '@novu/agent-event-protocol';
import type { NovuError } from '../utils/errors';
import {
type AgentConversationState,
type AgentMessage,
createInitialAgentConversationState,
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';
Expand Down Expand Up @@ -39,8 +40,18 @@ export type ConversationEntry = AgentConversationState & {
reportedActionIds: Set<string>;
/** Terminal MCP results retained while history pages load independently. */
mcpConnectionResults: Map<string, McpConnectionResult>;
/** 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<void>;
/** 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 {
Expand Down Expand Up @@ -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);

Expand All @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down
Loading