Skip to content

feat(framework): promise reply syntax — return value as reply fixes NV-7384#10887

Merged
ChmaraX merged 3 commits intonextfrom
nv-7384-promise-reply-syntax-compared-to-ctxreply
Apr 27, 2026
Merged

feat(framework): promise reply syntax — return value as reply fixes NV-7384#10887
ChmaraX merged 3 commits intonextfrom
nv-7384-promise-reply-syntax-compared-to-ctxreply

Conversation

@ChmaraX
Copy link
Copy Markdown
Contributor

@ChmaraX ChmaraX commented Apr 27, 2026

What

Allows agent handlers to return MessageContent directly instead of always calling ctx.reply() explicitly.

// Before — always required ctx.reply
agent('support-bot', {
  onMessage: async (ctx) => {
    await ctx.reply('How can I help?')
  }
})

// After — return value is sent as the reply
agent('support-bot', {
  onMessage: (ctx) => {
    if (ctx.message?.text.includes('billing')) return 'Head to /billing'
    return 'How can I help?'
  }
})

Works for all four handlers: onMessage, onAction, onReaction, onResolve.

Returning void/undefined keeps existing behaviour — no reply sent via the return path.

If ctx.reply() is called inside the handler and a value is returned, both are sent (two-reply model — useful for "Thinking…" + final answer patterns).

Why

Reduces boilerplate for the common case where a handler just replies once. Return syntax is especially natural in onReaction where early returns model "do nothing on reaction removes".

How

  • AgentHandlers return types changed from Promise<void>Awaitable<MessageContent | void> in agent.types.ts
  • runAgentHandler in handler.ts captures the return value and calls ctx.reply(result) when non-null, before ctx.flush()
sequenceDiagram
    participant Bridge
    participant runAgentHandler
    participant handler as User Handler
    participant ctx

    Bridge->>runAgentHandler: event fires
    runAgentHandler->>handler: await handler(ctx)
    handler-->>runAgentHandler: result (MessageContent | void)
    alt result != null
        runAgentHandler->>ctx: ctx.reply(result)
    end
    runAgentHandler->>ctx: ctx.flush()
Loading

Test coverage

  • onMessage return → reply
  • onAction return → reply
  • onReaction return → reply (added=true)
  • onReaction early return → silence (added=false)
  • onResolve return → reply
  • ctx.reply() + return value → two replies
  • All 34 existing tests still pass

Made with Cursor

What changed

Agent handlers (onMessage, onAction, onReaction, onResolve) may now return MessageContent (or a Promise resolving to it) directly; returned non-null values are automatically sent as replies. Handlers that return void/undefined preserve previous behavior. If a handler both calls ctx.reply() and returns content, both replies are sent (two-reply model). This simplifies handler code and lets authors use a return-value-as-reply pattern instead of always calling ctx.reply().

Affected areas

  • framework: Agent handler typings (AgentHandlers) were updated from Promise to Awaitable<MessageContent | void>, and runAgentHandler now awaits handler returns and forwards non-null results to ctx.reply() before ctx.flush(). Agent scaffold template updated to demonstrate the return-value-as-reply pattern.
  • templates (app-agent): example agent scaffold updated to use return-value-as-reply in handler examples.
  • tests: agent unit tests updated/added to cover return→reply behavior across onMessage/onAction/onReaction/onResolve, reaction-removed silence, and two-reply behavior.

Key technical decisions

  • API contract: AgentHandlers now accept Awaitable<MessageContent | void>, introducing an explicit typed pathway for returning replies (breaking change for TypeScript signatures).
  • Reply emission happens before ctx.flush() to preserve correct ordering and batching of signals/reactions.
  • The implementation intentionally supports sending both an explicit ctx.reply() and a returned reply within the same handler.

Testing

New and updated unit tests verify return-value-as-reply for all handler types, the reaction-removed (no-reply) case, and the two-reply scenario; existing tests remain passing.

ChmaraX added 2 commits April 27, 2026 15:01
…7384]

Handlers can now return MessageContent directly instead of calling
ctx.reply() explicitly. The framework captures the return value and
sends it as a reply before flushing. Returning void/undefined keeps
the existing behaviour. Returning a value when ctx.reply() was already
called sends a second reply (two-reply model).

Made-with: Cursor
…gent handlers [NV-7384]

Tests for onAction, onReaction (including early-return-as-silence for
reaction removes), onResolve, and the two-reply model (explicit
ctx.reply + return value sends two messages).

Made-with: Cursor
@linear
Copy link
Copy Markdown

linear Bot commented Apr 27, 2026

@netlify
Copy link
Copy Markdown

netlify Bot commented Apr 27, 2026

Deploy Preview for dashboard-v2-novu-staging canceled.

Name Link
🔨 Latest commit b67ff70
🔍 Latest deploy log https://app.netlify.com/projects/dashboard-v2-novu-staging/deploys/69ef6470f4502a0008704b29

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 27, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 55525ee1-234d-44b8-8642-4256adb29478

📥 Commits

Reviewing files that changed from the base of the PR and between a8174d8 and b67ff70.

📒 Files selected for processing (1)
  • packages/novu/src/commands/init/templates/app-agent/ts/app/novu/agents/support-agent.tsx

📝 Walkthrough

Walkthrough

Agent handlers may now return MessageContent (synchronously or asynchronously) in addition to void. runAgentHandler awaits handler results and, if non-null, sends the returned content via ctx.reply() before ctx.flush(). Handler typings updated to Awaitable<MessageContent | void>.

Changes

Cohort / File(s) Summary
Handler runtime
packages/framework/src/handler.ts
runAgentHandler now awaits handler return values and calls ctx.reply(result) when a non-null MessageContent is returned; reply is emitted prior to ctx.flush().
Type signature updates
packages/framework/src/resources/agent/agent.types.ts
AgentHandlers methods (onMessage, onReaction, onAction, onResolve) changed from Promise<void> to `Awaitable<MessageContent
Tests
packages/framework/src/resources/agent/agent.test.ts
Added tests asserting returned handler values produce /reply requests for onMessage, onAction, onReaction (when added), and onResolve; includes negative test for reaction-removed and a test for dual replies when both ctx.reply() and a return value exist.
Template/example agent
packages/novu/src/commands/init/templates/app-agent/ts/app/novu/agents/support-agent.tsx
Updated demo agent handlers to return MessageContent/strings/JSX instead of calling await ctx.reply(...), matching the new handler-return behavior.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title follows Conventional Commits format with valid type (feat) and scope (framework), includes a clear lowercase imperative description, and properly ends with the Linear ticket reference (fixes NV-7384).
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
packages/framework/src/resources/agent/agent.types.ts (1)

197-202: Document the new return-value-as-reply contract on AgentHandlers.

Since packages/framework is the developer-facing SDK and AgentHandlers is part of the public API, the new "return MessageContent → automatic ctx.reply()" behavior should be documented in JSDoc on the interface (or per-handler). This is also a subtle behavior change for existing users: with the old Promise<void> signature, a handler that incidentally returned a non-void value (e.g., return await someHelper(ctx)) was a no-op; with this PR, any non-null return is now sent as a reply, which can cause duplicate posts or runtime serialization errors if the returned value isn't a valid MessageContent. A JSDoc note (and a mention in the changelog/migration notes) would help users update existing handlers safely.

📝 Proposed JSDoc
+/**
+ * Agent event handlers.
+ *
+ * Each handler may either:
+ *   - call `ctx.reply(...)` explicitly, and/or
+ *   - return a `MessageContent` value (or a Promise resolving to one), which the
+ *     framework will automatically send as a reply before flushing queued signals.
+ *
+ * Returning `void`/`undefined` produces no reply. If a handler both calls
+ * `ctx.reply()` and returns a `MessageContent`, two replies are sent in order.
+ */
 export interface AgentHandlers {
   onMessage:   (ctx: AgentContext) => Awaitable<MessageContent | void>;
   onReaction?: (ctx: AgentContext) => Awaitable<MessageContent | void>;
   onAction?:   (ctx: AgentContext) => Awaitable<MessageContent | void>;
   onResolve?:  (ctx: AgentContext) => Awaitable<MessageContent | void>;
 }

As per coding guidelines: "packages/framework defines the code-first workflow SDK and serves as the interface between user-defined workflows and Novu's engine; write clear, minimal abstractions as changes affect the developer-facing API".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/framework/src/resources/agent/agent.types.ts` around lines 197 -
202, Update the public API docs for AgentHandlers to document the new
return-as-reply contract: add JSDoc to the AgentHandlers interface (or each
handler signature: onMessage, onReaction, onAction, onResolve) stating that
returning a non-null MessageContent from a handler will automatically call
ctx.reply() with that value, and warn that incidental non-void returns (e.g.,
returning a helper result) will now be sent as replies and must be valid
MessageContent to avoid duplicate posts or serialization errors; reference
AgentContext and ctx.reply() in the note and suggest returning void or
explicitly calling ctx.reply() to preserve previous behavior.
packages/framework/src/resources/agent/agent.test.ts (1)

1189-1196: Negative assertion relies on a fixed 50 ms timer — flaky risk.

This negative test depends on a hard-coded 50 ms sleep to "prove" no reply was sent. On a slow/contended CI runner the background runAgentHandler may not have finished by the time the assertion runs, which would cause a false pass (test green, but a regression that does send a reply might still slip through). The other negative-style tests in this file await a deterministic signal first (vi.waitFor on a captured ctx, fetch call count, etc.).

Consider awaiting handler completion deterministically — e.g., a spy on onReaction so you can vi.waitFor on its invocation, or wait for ctx.flush() to settle — and only then assert that no /reply call exists in fetchMock.mock.calls.

♻️ Sketch of a more deterministic wait
-  it('should not send a reply when onReaction returns nothing (reaction removed)', async () => {
+  it('should not send a reply when onReaction returns nothing (reaction removed)', async () => {
+    const onReactionSpy = vi.fn((ctx: any) => {
+      if (!ctx.reaction?.added) return;
+
+      return 'thumbs up noted';
+    });
     const testBot = agent('test-bot', {
       onMessage: async (ctx) => { await ctx.reply('noop'); },
-      onReaction: (ctx) => {
-        if (!ctx.reaction?.added) return;
-
-        return 'thumbs up noted';
-      },
+      onReaction: onReactionSpy,
     });
     ...
     await handler.createHandler()();
-    await new Promise((r) => setTimeout(r, 50));
+    await vi.waitFor(() => expect(onReactionSpy).toHaveBeenCalled());

     const replyCall = fetchMock.mock.calls.find(
       (call: any[]) => call[0] === 'https://api.novu.co/v1/agents/test-bot/reply'
     );
     expect(replyCall).toBeUndefined();
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/framework/src/resources/agent/agent.test.ts` around lines 1189 -
1196, The test uses a fixed 50ms sleep after await handler.createHandler()();
replace that flaky wait with a deterministic signal that the background
runAgentHandler has settled: add a spy/spyOn for the agent's onReaction (or
otherwise await ctx.flush()) and then use vi.waitFor to wait for that spy or
ctx.flush() to complete before asserting against fetchMock.mock.calls;
specifically, ensure you wait for the runAgentHandler completion (via onReaction
spy or ctx.flush()) and only then check that no call to
'https://api.novu.co/v1/agents/test-bot/reply' exists in fetchMock.mock.calls.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/framework/src/resources/agent/agent.test.ts`:
- Around line 1189-1196: The test uses a fixed 50ms sleep after await
handler.createHandler()(); replace that flaky wait with a deterministic signal
that the background runAgentHandler has settled: add a spy/spyOn for the agent's
onReaction (or otherwise await ctx.flush()) and then use vi.waitFor to wait for
that spy or ctx.flush() to complete before asserting against
fetchMock.mock.calls; specifically, ensure you wait for the runAgentHandler
completion (via onReaction spy or ctx.flush()) and only then check that no call
to 'https://api.novu.co/v1/agents/test-bot/reply' exists in
fetchMock.mock.calls.

In `@packages/framework/src/resources/agent/agent.types.ts`:
- Around line 197-202: Update the public API docs for AgentHandlers to document
the new return-as-reply contract: add JSDoc to the AgentHandlers interface (or
each handler signature: onMessage, onReaction, onAction, onResolve) stating that
returning a non-null MessageContent from a handler will automatically call
ctx.reply() with that value, and warn that incidental non-void returns (e.g.,
returning a helper result) will now be sent as replies and must be valid
MessageContent to avoid duplicate posts or serialization errors; reference
AgentContext and ctx.reply() in the note and suggest returning void or
explicitly calling ctx.reply() to preserve previous behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 5f4f7bbd-61fe-4082-a373-d364a1b74ef5

📥 Commits

Reviewing files that changed from the base of the PR and between c123476 and a8174d8.

📒 Files selected for processing (3)
  • packages/framework/src/handler.ts
  • packages/framework/src/resources/agent/agent.test.ts
  • packages/framework/src/resources/agent/agent.types.ts

…ly [NV-7384]

Replace await ctx.reply() + return with direct return in the init
template so new users see the idiomatic pattern from the start.

Made-with: Cursor
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 27, 2026

Open in StackBlitz

npm i https://pkg.pr.new/@novu/framework@10887
npm i https://pkg.pr.new/novu@10887

commit: b67ff70

@ChmaraX ChmaraX merged commit 5346a36 into next Apr 27, 2026
39 checks passed
@ChmaraX ChmaraX deleted the nv-7384-promise-reply-syntax-compared-to-ctxreply branch April 27, 2026 14:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant