Skip to content

fix(omni): intercept SendMessage(to: omni) in SDK executor → NATS reply path - #1091

Merged
vasconceloscezar merged 2 commits into
devfrom
fix/1088-sdk-sendmessage-omni-routing
Apr 7, 2026
Merged

vasconceloscezar merged 2 commits into
devfrom
fix/1088-sdk-sendmessage-omni-routing

Conversation

@vasconceloscezar

Copy link
Copy Markdown
Collaborator

Summary

Fixes #1088. Agents spawned via the omni bridge SDK executor (eugenia-seller, etc.) call SendMessage(recipient: "omni", message: "...") to send replies. In SDK mode this was a silent no-op — the done MCP tool was the only NATS publish path, and SendMessage calls fell through with no interception. Bridge logs showed sessions spawning and messages arriving, but replies never reached omni.reply.{instance}.{chatId} and tests timed out.

Approach

Added a PreToolUse hook in _processDelivery() that fires only when OMNI_INSTANCE is set in the executor env (non-bridge SDK sessions are unaffected). The hook:

  1. Intercepts SendMessage to recipient "omni"
  2. Side-effect publishes the body to omni.reply.{instance}.{chatId} — mirrors handleDoneTool's text action exactly
  3. Returns permissionDecision: 'deny' with reason "Message delivered to user via omni bridge." — the deny reason becomes the tool result the agent reads, so the agent treats it as a successful send

The provider's existing mergeHooks() deep-merges this with the permission gate hook, so both run on every PreToolUse.

Why not an MCP tool replacement? Namespacing (mcp__genie-omni-tools__SendMessage) would break the agent's expectation that SendMessage is the bare tool name — the same name it uses in tmux mode. A hook keeps the call site identical across transports.

Field shape: defensive — accepts both recipient/to and message/content (matches identity-inject.ts's pattern).

Bonus fix

turn-based-prompt.ts previously taught agents to use omni say / omni speak CLI verbs. These don't exist in SDK mode (no shell, in-process query) — so even though the issue title is about SendMessage interception, the prompt was steering agents toward a dead path. Updated the prompt to make SendMessage(recipient: "omni", ...) the canonical reply verb, with omni done still serving the turn-close protocol via the existing MCP tool.

Test plan

  • bun run typecheck passes
  • bunx biome check clean on all touched files
  • bun test src/services/executors/__tests__/claude-sdk.test.ts — 32 pass / 0 fail (24 existing + 8 new)
  • bun test full suite — 2251 pass / 0 fail

New test coverage (SendMessage omni interception describe block)

  • publish + deny when recipient === 'omni'
  • alternate to/content field shape accepted
  • passthrough (no decision) for non-omni recipients
  • passthrough for non-SendMessage tool calls
  • bridge-unavailable deny when natsPublish is null
  • passthrough when OMNI_INSTANCE is absent (non-bridge session)
  • wiring proof: SendMessage matcher present in runQuery options when env set
  • wiring proof: matcher absent when env unset

Files

  • src/services/executors/claude-sdk.ts (+88 -2) — parseSendMessageInput(), createSendMessageOmniHook(), _processDelivery wiring
  • src/services/executors/turn-based-prompt.ts (+14 -10) — SendMessage as canonical reply verb
  • src/services/executors/__tests__/claude-sdk.test.ts (+180 -3) — 8 new tests + 1 prompt assertion update

Sibling work

#1089 (omni-bridge missing omni.session.reset.* subscription) is the matching reset-path fix — different file, different abstraction, lands as a separate PR.

🤖 Generated with Claude Code

…ly path

Agents spawned via the omni bridge SDK executor (eugenia-seller, etc.)
call `SendMessage(recipient: "omni", message: "...")` to send replies,
mirroring the tmux mode contract. In SDK mode the call was a no-op:
the `done` MCP tool was the only NATS publish path, and the agent's
SendMessage calls fell through with no interception.

Fix: add a PreToolUse hook in `_processDelivery()` that fires only when
OMNI_INSTANCE is set in the executor env. The hook intercepts
`SendMessage` to recipient "omni", side-effect publishes the body to
`omni.reply.{instance}.{chatId}` (mirrors `handleDoneTool`'s text
action), and returns deny + reason "Message delivered to user via omni
bridge." The deny reason becomes the tool result the agent reads, so
the agent treats it as a successful send.

Defensive on field shape: accepts both `recipient`/`to` and
`message`/`content` (matches identity-inject's pattern).

Also updates `turn-based-prompt.ts` to teach SendMessage as the
canonical reply verb — the old prompt referenced `omni say` CLI verbs
that don't exist in SDK mode (no shell, in-process query).

Tests: 8 new cases covering publish path, alternate field shapes,
non-omni passthrough, non-SendMessage passthrough, bridge-unavailable
deny, OMNI_INSTANCE-absent passthrough, and wiring proof in both
directions. Full suite: 2251 pass / 0 fail.

Closes #1088

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Apr 7, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: a2511f2c-2b7a-473a-8048-edfb9bfd4176

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/1088-sdk-sendmessage-omni-routing

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.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a mechanism to intercept SendMessage tool calls within the Claude SDK executor, specifically routing messages directed to the 'omni' recipient through a NATS bridge. It includes a new PreToolUse hook, createSendMessageOmniHook, which handles the interception and publication of these messages. Additionally, the turn-based system prompt has been updated to promote SendMessage as the primary reply method, replacing the legacy omni say command. Comprehensive unit tests have been added to verify the routing logic and hook integration. I have no feedback to provide.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3e65e35a42

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/services/executors/claude-sdk.ts Outdated
Comment on lines +225 to +227
natsPublish(
`omni.reply.${instanceId}.${chatId}`,
buildReplyPayload(agent, chatId, instanceId, { content: body ?? '' }),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid publishing empty omni replies on malformed SendMessage

When SendMessage targets recipient: "omni" but omits/invalidates the message field (for example, the model emits text instead of message/content), this hook still publishes content: "" and returns a success-like deny reason. In that scenario the agent believes delivery succeeded and won’t retry, so users can receive a blank message (or no usable reply) instead of an explicit tool error. Guarding on a non-empty parsed body before publishing would prevent this silent failure mode.

Useful? React with 👍 / 👎.

Address Codex P2 on #1091.

Previously, if the model emitted SendMessage(recipient: 'omni') with the
wrong field name (e.g. `text` instead of `message`/`content`) or an
empty/whitespace string, the hook still published `content: ""` to the
omni reply path AND returned a deny reason that read like success. The
agent believed delivery succeeded and would not retry, leaving the user
with a blank reply or no reply at all.

The hook now rejects empty/whitespace-only bodies with an explicit error
reason that prompts the model to retry with a real payload, BEFORE the
NATS publish.

- Two new tests cover the missing-field and whitespace-only cases.
- All 34 claude-sdk tests pass; full gate green (2253/2253).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@vasconceloscezar
vasconceloscezar merged commit d3428ba into dev Apr 7, 2026
6 checks passed
@namastex888
namastex888 deleted the fix/1088-sdk-sendmessage-omni-routing branch April 10, 2026 16:18
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.

2 participants