Skip to content

Commit 85a2570

Browse files
authored
feat(framework): add @novu/framework/ai-sdk agent plugin fixes NV-8117 (#11656)
1 parent c232e12 commit 85a2570

21 files changed

Lines changed: 681 additions & 148 deletions

apps/api/src/app/agents/conversation-runtime/reply/agent-reply.controller.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,18 @@ import { HandleAgentReply } from './handle-agent-reply/handle-agent-reply.usecas
1212
@Controller('/agents')
1313
@ApiExcludeController()
1414
export class AgentReplyController {
15-
constructor(private handleAgentReplyUsecase: HandleAgentReply) {}
15+
constructor(private handleAgentReply: HandleAgentReply) {}
1616

1717
@Post('/:agentId/reply')
1818
@HttpCode(HttpStatus.OK)
1919
@RequireAuthentication()
2020
@ExternalApiAccessible()
21-
async handleAgentReply(
21+
async handleAgentReplyHandler(
2222
@UserSession() user: UserSessionData,
2323
@Param('agentId') agentId: string,
2424
@Body() body: AgentReplyPayloadDto
2525
) {
26-
return this.handleAgentReplyUsecase.execute(
26+
return this.handleAgentReply.execute(
2727
HandleAgentReplyCommand.create({
2828
userId: user._id,
2929
environmentId: user.environmentId,
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"main": "../dist/cjs/ai-sdk/index.cjs",
3+
"types": "../dist/cjs/ai-sdk/index.d.cts"
4+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"main": "../dist/cjs/cards.cjs",
3+
"types": "../dist/cjs/cards.d.cts"
4+
}

packages/framework/package.json

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
"jsx-runtime",
3030
"jsx-dev-runtime",
3131
"validators",
32+
"ai-sdk",
33+
"cards",
3234
"README.md"
3335
],
3436
"scripts": {
@@ -204,12 +206,33 @@
204206
"types": "./dist/esm/validators.d.ts",
205207
"default": "./dist/esm/validators.js"
206208
}
209+
},
210+
"./ai-sdk": {
211+
"require": {
212+
"types": "./dist/cjs/ai-sdk/index.d.cts",
213+
"default": "./dist/cjs/ai-sdk/index.cjs"
214+
},
215+
"import": {
216+
"types": "./dist/esm/ai-sdk/index.d.ts",
217+
"default": "./dist/esm/ai-sdk/index.js"
218+
}
219+
},
220+
"./cards": {
221+
"require": {
222+
"types": "./dist/cjs/cards.d.cts",
223+
"default": "./dist/cjs/cards.cjs"
224+
},
225+
"import": {
226+
"types": "./dist/esm/cards.d.ts",
227+
"default": "./dist/esm/cards.js"
228+
}
207229
}
208230
},
209231
"peerDependencies": {
210232
"@nestjs/common": ">=10.0.0",
211233
"@sveltejs/kit": ">=1.27.3",
212234
"@vercel/node": ">=2.15.9",
235+
"ai": "^6.0.0",
213236
"aws-lambda": ">=1.0.7",
214237
"express": ">=4.19.2",
215238
"h3": ">=1.8.1",
@@ -242,6 +265,9 @@
242265
"next": {
243266
"optional": true
244267
},
268+
"ai": {
269+
"optional": true
270+
},
245271
"zod": {
246272
"optional": true
247273
},
@@ -260,6 +286,7 @@
260286
"@types/pluralize": "^0.0.33",
261287
"@types/sanitize-html": "2.11.0",
262288
"@vercel/node": "^2.15.9",
289+
"ai": "^6.0.208",
263290
"aws-lambda": "^1.0.7",
264291
"express": "^4.19.2",
265292
"h3": "^1.11.1",
@@ -274,11 +301,11 @@
274301
"zod-to-json-schema": "^3.23.3"
275302
},
276303
"dependencies": {
277-
"chat": "4.30.0",
278304
"ajv": "^8.20.0",
279305
"ajv-formats": "^2.1.1",
280306
"better-ajv-errors": "^1.2.0",
281307
"chalk": "^4.1.2",
308+
"chat": "4.30.0",
282309
"cross-fetch": "^4.0.0",
283310
"json-schema-to-ts": "^3.0.0",
284311
"jsonrepair": "^3.13.1",
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import type { AgentMessageContext } from '../resources/agent/agent.types';
3+
import { agent } from './ai-sdk-agent';
4+
import type { AiSdkResult } from './types';
5+
6+
function fakeCtx() {
7+
const reply = vi.fn().mockResolvedValue({ messageId: 'm', platformThreadId: 'p' });
8+
9+
return { reply } as unknown as AgentMessageContext & { reply: ReturnType<typeof vi.fn> };
10+
}
11+
12+
describe('agent', () => {
13+
it('accepts a bare function as onMessage and returns an Agent with the given id', () => {
14+
const a = agent('support', async () => 'hi');
15+
expect(a.id).toBe('support');
16+
expect(typeof a.handlers.onMessage).toBe('function');
17+
});
18+
19+
it('accepts an object of handlers', () => {
20+
const a = agent('support', {
21+
onMessage: async () => undefined,
22+
onAction: async () => undefined,
23+
});
24+
expect(typeof a.handlers.onMessage).toBe('function');
25+
expect(typeof a.handlers.onAction).toBe('function');
26+
});
27+
28+
it('throws when onMessage is missing', () => {
29+
// @ts-expect-error intentionally invalid
30+
expect(() => agent('support', {})).toThrow(/onMessage/);
31+
});
32+
33+
it('passes through string returns for runtime replyIfPresent', async () => {
34+
const supportAgent = agent('support', async () => 'hello');
35+
const ctx = fakeCtx();
36+
37+
const result = await supportAgent.handlers.onMessage({} as never, ctx);
38+
39+
expect(result).toBe('hello');
40+
expect(ctx.reply).not.toHaveBeenCalled();
41+
});
42+
43+
it('auto-delivers AI SDK results and returns void', async () => {
44+
const supportAgent = agent('support', async () => ({
45+
text: Promise.resolve('model reply'),
46+
textStream: (async function* () {})(),
47+
}));
48+
const ctx = fakeCtx();
49+
50+
const result = await supportAgent.handlers.onMessage({} as never, ctx);
51+
52+
expect(result).toBeUndefined();
53+
expect(ctx.reply).toHaveBeenCalledWith('model reply');
54+
});
55+
56+
it('auto-delivers generateText-style results', async () => {
57+
const supportAgent = agent(
58+
'support',
59+
async () =>
60+
({
61+
text: 'done',
62+
steps: [],
63+
}) as AiSdkResult
64+
);
65+
const ctx = fakeCtx();
66+
67+
await supportAgent.handlers.onMessage({} as never, ctx);
68+
69+
expect(ctx.reply).toHaveBeenCalledWith('done');
70+
});
71+
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { agent as frameworkAgent } from '../resources/agent/agent.resource';
2+
import type { Agent } from '../resources/agent/agent.types';
3+
import { deliverResult, isAiSdkResult } from './reply-mapper';
4+
import type { AiSdkAgentHandlers } from './types';
5+
6+
type AiSdkMessageHandler = AiSdkAgentHandlers['onMessage'];
7+
8+
function normalize(id: string, handlers: AiSdkMessageHandler | AiSdkAgentHandlers): AiSdkAgentHandlers {
9+
const normalized = typeof handlers === 'function' ? { onMessage: handlers } : handlers;
10+
if (typeof normalized.onMessage !== 'function') {
11+
throw new Error(`agent('${id}') requires an onMessage handler`);
12+
}
13+
14+
return normalized;
15+
}
16+
17+
export function agent(id: string, handlers: AiSdkMessageHandler | AiSdkAgentHandlers): Agent {
18+
const h = normalize(id, handlers);
19+
20+
return frameworkAgent(id, {
21+
onMessage: async (message, ctx) => {
22+
const result = await h.onMessage(message, ctx);
23+
24+
if (isAiSdkResult(result)) {
25+
await deliverResult(result, ctx);
26+
27+
return;
28+
}
29+
30+
return result;
31+
},
32+
...(h.onAction && { onAction: h.onAction }),
33+
...(h.onReaction && { onReaction: h.onReaction }),
34+
...(h.onResolve && { onResolve: h.onResolve }),
35+
});
36+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { toModelMessages } from './history-mapper';
3+
4+
describe('toModelMessages', () => {
5+
it('maps agent role to assistant and others to user, in order', () => {
6+
const result = toModelMessages([
7+
{ role: 'user', type: 'message', content: 'hi', createdAt: '1' },
8+
{ role: 'agent', type: 'message', content: 'hello!', createdAt: '2' },
9+
]);
10+
11+
expect(result).toEqual([
12+
{ role: 'user', content: 'hi' },
13+
{ role: 'assistant', content: 'hello!' },
14+
]);
15+
});
16+
17+
it('maps bridge sender roles (subscriber/agent) to user/assistant', () => {
18+
const result = toModelMessages([
19+
{ role: 'subscriber', type: 'message', content: 'hi', createdAt: '1' },
20+
{ role: 'agent', type: 'message', content: 'hello!', createdAt: '2' },
21+
]);
22+
23+
expect(result).toEqual([
24+
{ role: 'user', content: 'hi' },
25+
{ role: 'assistant', content: 'hello!' },
26+
]);
27+
});
28+
29+
it('skips system/metadata (signalData) entries', () => {
30+
const result = toModelMessages([
31+
{ role: 'system', type: 'signal', content: '', signalData: { type: 'metadata' }, createdAt: '1' },
32+
{ role: 'user', type: 'message', content: 'q', createdAt: '2' },
33+
]);
34+
35+
expect(result).toEqual([{ role: 'user', content: 'q' }]);
36+
});
37+
38+
it('skips signal-type entries and empty content', () => {
39+
const result = toModelMessages([
40+
{ role: 'system', type: 'signal', content: 'Conversation resolved', createdAt: '1' },
41+
{ role: 'user', type: 'message', content: ' ', createdAt: '2' },
42+
{ role: 'user', type: 'message', content: 'real question', createdAt: '3' },
43+
]);
44+
45+
expect(result).toEqual([{ role: 'user', content: 'real question' }]);
46+
});
47+
48+
it('prepends a system message when provided', () => {
49+
const result = toModelMessages([], 'You are support.');
50+
51+
expect(result[0]).toEqual({ role: 'system', content: 'You are support.' });
52+
});
53+
54+
it('prefixes sender name when multiple distinct human senders exist', () => {
55+
const result = toModelMessages([
56+
{ role: 'user', type: 'message', content: 'one', senderName: 'Alice', createdAt: '1' },
57+
{ role: 'user', type: 'message', content: 'two', senderName: 'Bob', createdAt: '2' },
58+
]);
59+
60+
expect(result).toEqual([
61+
{ role: 'user', content: 'Alice: one' },
62+
{ role: 'user', content: 'Bob: two' },
63+
]);
64+
});
65+
});
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { ModelMessage } from 'ai';
2+
import type { AgentHistoryEntry } from '../resources/agent/agent.types';
3+
4+
function isAssistantRole(role: string): boolean {
5+
return role === 'agent' || role === 'assistant';
6+
}
7+
8+
function distinctHumanSenders(history: AgentHistoryEntry[]): number {
9+
const names = new Set<string>();
10+
for (const entry of history) {
11+
if (!isAssistantRole(entry.role) && entry.role !== 'system' && entry.senderName) {
12+
names.add(entry.senderName);
13+
}
14+
}
15+
16+
return names.size;
17+
}
18+
19+
/**
20+
* Map Novu conversation history into AI SDK `ModelMessage[]`.
21+
* v1 is text-only: tool-call/tool-result replay from richContent is intentionally
22+
* not reconstructed. System/metadata entries (carrying `signalData`) are skipped.
23+
*
24+
* The current inbound message is already appended to `history` by Novu before the
25+
* bridge fires — do not append the handler's `message` arg again.
26+
*
27+
* TODO: hook up full tool calls and system messages
28+
*/
29+
export function toModelMessages(history: AgentHistoryEntry[], system?: string): ModelMessage[] {
30+
const messages: ModelMessage[] = [];
31+
if (system) {
32+
messages.push({ role: 'system', content: system });
33+
}
34+
35+
const multiSender = distinctHumanSenders(history) > 1;
36+
37+
for (const entry of history) {
38+
if (entry.signalData || entry.role === 'system' || entry.type === 'signal' || !entry.content.trim()) {
39+
continue;
40+
}
41+
42+
const isAssistant = isAssistantRole(entry.role);
43+
const text =
44+
!isAssistant && multiSender && entry.senderName ? `${entry.senderName}: ${entry.content}` : entry.content;
45+
46+
messages.push({ role: isAssistant ? 'assistant' : 'user', content: text });
47+
}
48+
49+
return messages;
50+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { agent } from './ai-sdk-agent';
2+
export { toModelMessages } from './history-mapper';
3+
export type { AiSdkAgentHandlers, AiSdkResult } from './types';

0 commit comments

Comments
 (0)