fix(push): an agent's reply must reach the phone too - #199
Merged
Merged
Conversation
Push dispatch lived in exactly one place — the fire-and-forget block in POST /conversations/:id/messages — and that route is gated by requireCompany(req), a human session. Agents never traverse it: `cumora reply` runs server-side through runCli, and cmdReply committed the row and enqueued the realtime broadcast, nothing more. notifyMessage had one caller in the whole server. So the exact case push exists for was the case it missed. A human asks an agent something on their phone and locks the screen; the socket drops and their status goes 'resting'. The agent answers and the phone stays silent. In an agent-first workspace that is most inbound messages. Nothing suggests this was deliberate. The in-app surface has never made the distinction — NotificationToasts fires on any message.new that is not yours and not a system row, and resolves the author out of the participants roster, which holds agents too. Desktop notified, phone did not. push.ts's own header says the design "lets the WS bridge call notifyMessage(…) unconditionally". Extract the dispatch into dispatchMessagePush and call it from both paths. Recipients are unchanged — computeMessageRecipients joins `users`, so only humans are ever notified, and the mute and "currently looking at the app" filters still apply; two of the four tests pin that, and pass either way. Author name now resolves through participants with a users fallback. Agents have no users row, so the previous users-only lookup would have titled the notification with a raw agent id. notifyMessage gains a test-only observation hook: APNs soft-disables without credentials, so the send itself is unobservable and the payload the caller built is the interesting assertion.
Merged
WhichPaths
added a commit
that referenced
this pull request
Sep 8, 2026
Follow-on to #199, which gave `cumora reply` the push dispatch it was missing. dispatchMessagePush still had exactly two callers -- the HTTP message route and cmdReply -- while an agent can also START a thread, through two of its own tools: dm_with -> startPrivateChat (agents/private_chat.ts) kind=text pull_group -> startPulledGroup (agents/scanner_helper.ts) kind=text Both commit the row and enqueue the same message.new broadcast cmdReply does, then stop. So the human's desktop toasts -- NotificationToasts fires on any message.new that is not theirs and not a system row -- and the phone stays silent. The same asymmetry #199 was about, in the paths it did not touch. An opening line is the worst message to drop. A reply lands in a thread the recipient already knows about; the first message of a conversation that did not exist a second ago is the one they have no other way to discover. And a pull exists specifically to interrupt someone -- there is a six-hour per-agent cooldown on it for that reason. startPrivateChat is not agent-to-agent only despite the tool's copy: it validates kind IN ('agent','human') and carries a users/company_members existence branch for the human case. pull_group's own comment says humans in members[] are a normal choice. Every other INSERT INTO messages in the server is correct as it stands: membership.ts, calendar.ts and inproc-client.ts write kind='system', which the in-app surface skips too, and ws.ts's doc-mention row is addressed to an agent whose only human is its own author. Recipients need no new filtering: computeMessageRecipients joins `users`, so an agent-to-agent DM or an agent-only pull pushes to nobody on its own, and the mute and "currently looking at the app" filters apply unchanged. A test pins that.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A human asks an agent a question on their phone and locks the screen. The agent answers. The phone stays silent.
Push dispatch existed in exactly one place — the fire-and-forget block in
POST /conversations/:id/messages:That route is gated by
const { userId: me, companyId: tenant } = await requireCompany(req)— a human session. Agents never traverse it:cumora replyruns server-side throughrunCli, andcmdReplycommits the row and enqueues the realtime broadcast, and stops there.So the case push exists for — the recipient is offline,
status = 'resting', no websocket — is precisely the case it missed. In an agent-first workspace that is most of the inbound messages a person would want to hear about.Not a deliberate exclusion
The in-app surface has never made this distinction.
NotificationToasts.tsxfires on anymessage.newthat is not yours and not a system row:byIdholds agents, so the desktop toast has always shown agent replies. The two surfaces disagreed, and the one that was never wired is the one that reaches a locked phone.push.ts's own header points the same way — the soft-disable exists so callers can invoke it freely:The change
dispatchMessagePushinpush.ts, called from both the HTTP route andcmdReplyafter COMMIT. The HTTP route's behaviour is unchanged — it now calls the extracted function instead of its own copy.Recipients are unchanged.
computeMessageRecipientsjoinsusers, so only humans are ever notified, and the mute and "currently looking at the app" filters still apply. Two of the four tests pin exactly that, and they pass with or without the fix — this must not become a broadcast.Author name now resolves through
participantswith ausersfallback:Agents have no
usersrow, so the previous users-only lookup would have titled the notification with a raw agent id likea-1f2e3d4c.Verification
Four integration cases driving the real
runCli. Two go red without the dispatch:The other two are the guards against over-reaching, and pass either way: a human whose status is
'avail'is still skipped, and a muted conversation is still muted.notifyMessagegains a test-only observation hook. APNs soft-disables without credentials, so the send itself is unobservable in a test and the payload the caller built is the assertion that matters; the hook is three lines and changes no behaviour.Unit suite 1102 pass / 0 fail. The neighbouring integration suites that exercise the routes I touched stay green (
agent-cli-side-effects5/5,human-cli-collaboration1/1).tsc --noEmit,biome lint ., all three source guards clean.Worth a second opinion
This makes phones buzz for agent replies where they previously did not, so it is a real change in what users experience — I believe it is the intended behaviour rather than a new feature, on the evidence above, but it is the kind of call worth disagreeing with me on. Every existing filter is untouched, so the blast radius is exactly "the messages the in-app toast already showed".