Skip to content

fix(push): an agent's reply must reach the phone too - #199

Merged
yetone merged 1 commit into
yetone:mainfrom
WhichPaths:fix/push-for-agent-messages
Sep 5, 2026
Merged

yetone merged 1 commit into
yetone:mainfrom
WhichPaths:fix/push-for-agent-messages

Conversation

@WhichPaths

Copy link
Copy Markdown
Collaborator

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:

$ grep -rn "notifyMessage(" server/src --exclude-dir=__tests__
server/src/api/router.ts:4234:      await notifyMessage({

That route is gated by const { userId: me, companyId: tenant } = await requireCompany(req) — a human session. Agents never traverse it: cumora reply runs server-side through runCli, and cmdReply commits 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.tsx fires on any message.new that is not yours and not a system row:

if (e.type !== 'message.new') return
if (m.authorId === meRef.current) return
if (m.kind === 'system') return

const author = useParticipants.getState().byId[m.authorId]

byId holds 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:

lets the WS bridge call notifyMessage(…) unconditionally

The change

dispatchMessagePush in push.ts, called from both the HTTP route and cmdReply after COMMIT. The HTTP route's behaviour is unchanged — it now calls the extracted function instead of its own copy.

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 exactly that, and they pass with or without the fix — this must not become a broadcast.

Author name now resolves through participants with a users fallback:

SELECT COALESCE(
         (SELECT name FROM participants WHERE id = $1 AND company_id = $2),
         (SELECT display_name FROM users WHERE id = $1)
       ) AS name

Agents have no users row, so the previous users-only lookup would have titled the notification with a raw agent id like a-1f2e3d4c.

Verification

Four integration cases driving the real runCli. Two go red without the dispatch:

not ok 1 - an agent reply reaches the push path
not ok 2 - the notification is titled with the agent name, not its id
# pass 2  # fail 2

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.

notifyMessage gains 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-effects 5/5, human-cli-collaboration 1/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".

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.
@yetone
yetone merged commit 948016a into yetone:main Sep 5, 2026
7 checks passed
@yetone yetone mentioned this pull request Sep 5, 2026
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.
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