Skip to content

fix(push): an agent opening a conversation must reach the phone too - #241

Merged
WhichPaths merged 1 commit into
yetone:mainfrom
WhichPaths:fix/push-agent-opened-conversations
Sep 8, 2026
Merged

WhichPaths merged 1 commit into
yetone:mainfrom
WhichPaths:fix/push-agent-opened-conversations

Conversation

@WhichPaths

Copy link
Copy Markdown
Collaborator

Follow-on to #199, which gave cumora reply the push dispatch it was missing. Two more agent-authored writes never had it either — and unlike a reply, both of them open a conversation that did not exist a second ago.

The gap

dispatchMessagePush has exactly two callers: POST /conversations/:id/messages and cmdReply. But an agent can also start a thread, through two of its own tools:

tool writer kind pushes
dm_with startPrivateChat (agents/private_chat.ts) text no
pull_group startPulledGroup (agents/scanner_helper.ts) text no

Both commit the row and enqueue the same message.new broadcast that cmdReply does, and 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 desktop/phone asymmetry #199 was about, in the two paths #199 did not touch.

An opening line is the worst message to drop. A reply lands in a thread the recipient already knows about and will see when they next open the app; the first message of a brand-new conversation 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 — so reaching every surface except the phone was backwards.

startPrivateChat is not agent-to-agent only, despite what the tool's copy suggests: it validates kind IN ('agent','human') and carries a whole users/company_members existence branch for the human case. pull_group's comment likewise says humans in members[] are a normal choice ("Do NOT auto-add a human — agents ... can pull true agent-only groups").

I checked every other INSERT INTO messages in the server. The rest are correct as they stand: 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 with the only human being its own author.

The fix

One fire-and-forget dispatchMessagePush after COMMIT in each, matching how the other two callers do it — the row is durable first, and a push never holds up the write.

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 / "currently looking at the app" filters apply unchanged.

Tests

Three added to the existing agent-reply-push.test.ts, which already owns this contract and its seed helpers:

  • an agent opening a DM reaches the phone — recipient is the offline human, body is the opening line, and the title is the agent's name rather than its raw id (agents have no users row)
  • an agent pulling a group reaches the phone
  • an agent-only conversation still pushes to nobody — the guard rail against this becoming "push on every agent write"

The first two are red before the change; the third passes either way, on purpose:

without the fix:
  ok 1-4   (the existing reply tests)
  not ok 5 - an agent opening a DM reaches the phone
  not ok 6 - an agent pulling a group reaches the phone
  ok 7     - an agent-only conversation still pushes to nobody
  # pass 5  # fail 2

with the fix:  # pass 7  # fail 0

Also green: tsc --noEmit, biome lint ., all three scripts/guard-*.mjs, the agent-tools and conversations integration suites, and the unit suite.

Follow-on to yetone#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 yetone#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.
@WhichPaths
WhichPaths merged commit 04cf66a into yetone:main Sep 8, 2026
7 checks passed
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