fix(push): an agent opening a conversation must reach the phone too - #241
Merged
WhichPaths merged 1 commit intoSep 8, 2026
Merged
Conversation
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.
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.
Follow-on to #199, which gave
cumora replythe 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
dispatchMessagePushhas exactly two callers:POST /conversations/:id/messagesandcmdReply. But an agent can also start a thread, through two of its own tools:dm_withstartPrivateChat(agents/private_chat.ts)textpull_groupstartPulledGroup(agents/scanner_helper.ts)textBoth commit the row and enqueue the same
message.newbroadcast thatcmdReplydoes, and then stop. So the human's desktop toasts —NotificationToastsfires on anymessage.newthat 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.
startPrivateChatis not agent-to-agent only, despite what the tool's copy suggests: it validateskind IN ('agent','human')and carries a wholeusers/company_membersexistence branch for the human case.pull_group's comment likewise says humans inmembers[]are a normal choice ("Do NOT auto-add a human — agents ... can pull true agent-only groups").I checked every other
INSERT INTO messagesin the server. The rest are correct as they stand:membership.ts,calendar.tsandinproc-client.tswritekind='system', which the in-app surface skips too, andws.ts's doc-mention row is addressed to an agent with the only human being its own author.The fix
One fire-and-forget
dispatchMessagePushafterCOMMITin 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:
computeMessageRecipientsjoinsusers, 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:usersrow)The first two are red before the change; the third passes either way, on purpose:
Also green:
tsc --noEmit,biome lint ., all threescripts/guard-*.mjs, theagent-toolsandconversationsintegration suites, and the unit suite.