Repository navigation
Conversation
On a v2-primary daemon the read API hands out v2 conversation and message IDs, but POST /api/react had no v2-primary branch: it told platforms apart by a signal:/whatsapp: prefix or a legacy-store lookup and fell back to Google Messages. Every reaction to a v2 message, on any platform, went down the Google path with a v2 message ID (503 "not connected to Google Messages" with no Google client). The react_to_message MCP tool followed it in client mode and used the legacy senders in-process. MessageService.SendReaction, the durable path, had no production caller. - v2wire.SubmitReactionV2 resolves the target by v2 message ID; the target decides account and conversation, and a supplied conversation key must name that conversation (v2 ID or remote ID). - POST /api/v1/outbox/reactions (v2-primary only, idempotency key required) queues a reaction like the other outbox submissions. POST /api/react delegates to the outbox on v2-primary and waits up to 8 s: 200 delivered, 202 queued and still retrying, 502 uncertain or rejected, 409 canceled, 422 unknown target. Legacy-primary daemons are unchanged. - The dispatcher confirms a reaction and records it as this account's own (reactor_key "self") in one transaction (OutboxRepository.ConfirmReaction), so the UI shows it without an echo; undelivered reactions write nothing. - react_to_message uses the outbox in-process on v2-primary and, in client mode, when the app reports v2_primary; localapi gains SubmitReaction and a typed React result. - The web UI reports a queued reaction and lists it in the outbox tray while it is queued, dispatching or retrying. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch has not been deployed
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.
Stacked on #226 (base
claude/signal-v2-reaction-timestamp, head6b3b8750), which is stacked on #225. #226 fixes the Signal transport half: before it, an outbox reaction to an incoming Signal message passed a SHA-1 tosignal-cli sendReaction -tand endeduncertain. Routing reactions through the outbox without it would hit that. Merge #225, then #226, then this. This PR's change is the single commit on top.What was wrong (reproduced)
On a v2-primary daemon the read API hands out v2 conversation and message IDs (32-hex keys; runbook "v2 cutover: conversation IDs re-key"), and the web UI posts exactly those to
POST /api/react. That handler had noV2Primarybranch. It told platforms apart by asignal:/whatsapp:prefix or a lookup in the legacy store, and anything else fell to Google Messages. So every reaction to a v2 message, on any platform, went down the Google path with a v2 message ID.cmd/r5_react_v2_primary_test.goruns the daemon's handler wiring over a migrated v2-primary store with scripted adapters and posts what the UI posts, for a Signal, a WhatsApp and a Google conversation. On unchangedmain(19e35d97) and on #226's head:With a Google client connected, the request instead asks Google to react to an ID that is not a Google message ID. The
react_to_messageMCP tool followed/api/reactin client mode, and in-process it called the legacy senders, which look the target up in the legacymessages.dbby message ID. MeanwhileMessageService.SendReaction, the durable path, had no production caller. The live install reportsv2_primary: true(GET/api/status, 2026-10-10 01:46Z), so this is live there.The fix
v2wire.SubmitReactionV2resolves the target by its v2 message ID. The target decides the account and the conversation. A suppliedconversation_idmust name that conversation, by v2 ID or by its remote ID (signal:+1…, a WhatsApp JID, a Google thread id: the keys pre-cutover callers hold, issue v2-primary cutover makes Signal 1:1 threads unaddressable: legacy conversation IDs stop resolving, direct conversations project without participants/title #155). An unknown message, or one outside the named conversation, is refused (422 reaction_target_unavailable) before anything is queued.POST /api/v1/outbox/reactions(v2-primary only,idempotency_keyrequired) queues a reaction like/api/v1/outbox/messages, and returns the submission at once. A legacy-primary daemon answers409 legacy primary: use /api/react.POST /api/reactdelegates to the outbox on v2-primary. Legacy-primary daemons, including ones withOPENMESSAGES_V2_SEND=1(whose read API still hands out legacy IDs), keep the legacy senders unchanged.reactor_key = 'self', which reads show as "me"/"You") in one transaction:OutboxRepository.ConfirmReaction. The adapters store nothing when they send a reaction (the legacy senders updated the stored reactions themselves after the send), so without this the reaction would appear only if a transport later reported it. The own-reaction row uses the transport'sAcceptedAt(or the dispatcher clock), and goes through the sameApplyReactionordering an ingest report uses, so a later report for that reactor replaces it. A reaction that ends uncertain, rejected, not_dispatched or canceled writes nothing. The ingest worker and the migration now use the sameSelfReactorKey/SelfReactorLabelconstants.react_to_messagequeues on the outbox in-process on v2-primary (waiting at most 25 s) and, in transportless client mode, when the app's/api/statusreportsv2_primary; otherwise it keeps/api/react. On v2 it acceptsidempotency_keyand returnsoutbox_id,state,settledand the key. A reaction that has been queued is never reported as an error, because an error invites a second reaction.localapigainsSubmitReaction,DaemonStatus.ReactionsViaOutbox, and a typedReactresult.queued,dispatchingornot_dispatched, with Cancel. It hides them once they settle, because a reaction has no "send again" and an action on it would fail.What the HTTP answer means now
The legacy handler answered only after the transport call. Delivery is now the dispatcher's job, so on v2-primary
/api/reactqueues the reaction, waits up to 8 s (under localapi's 10 s client timeout), and says how far it got:success: trueconfirmed,store_failed: the platform accepted itsuccess: true, queued: truequeued,dispatching,not_dispatched: stored, still the app's to send; do not repeatsuccess: false, erroruncertain(may have been applied),rejectedsuccess: false, errorcancelederrorEvery 2xx/409/502 answer carries
outbox_idandstate. A/api/reactrequest withoutidempotency_keymints one, so a repeated POST queues a second reaction (as before, where each POST called the transport). The UI keeps using/api/reactand treats a non-2xx as failure, as before.Invariants (stated, and executed as tests)
TestSubmitReactionV2QueuesOnlyInTheTargetsConversation(property, 300 cases; acceptable keys written out per conversation, not derived with the code under test)dispatchingand the read model untouched.TestConfirmReactionIsAllOrNothingTestConfirmedOwnReactionIsStoredAsItsEchoWouldBe(property, 150 cases)TestReactionsAReaderSeesAreTheLatestDeliveredOnes(property, 150 cases)successiff 2xx; 200 iff accepted;queuediff 202;erroriff the intent will not deliver.TestReactOutcomeAnswersEveryState/api/reactcalls the legacy sender, answers{success:true}, and writes no outbox row.TestReactStaysOnLegacySendersUntilV2IsPrimary, existingTestReactUses*IsError.TestV2ReactToMessage*,TestDaemonReactToMessageOnAV2PrimaryAppA mutation check: stamping the own-reaction row with the confirm-time clock instead of the transport's accepted time fails four of these tests (including both properties), and they pass again once it is reverted.
End to end:
TestR5ReactRoutesThroughTheOutboxOnV2Primary(the reproduction above) now routes all three platforms to their own transport with the right conversation and target remote IDs, and the reloaded thread shows the 👍 as "me" with no echo. Playwright: four new specs (tray visibility per state; delivered reaction shows a "You" pill; a disconnected platform gives 202, the queued notice and a tray row that cancels; a refused reaction gives 502 and its message), using a scriptable reaction sender added tocmd/e2e-server.Verification
Targeted, per the host's disk hold:
go vetandgo testoninternal/{web,tools,localapi,v2wire,messaging,storage/sqlite,ingest,migration,bridgeadapters/...},cmd/e2e-server,go test ./cmd/ -run 'R5|React|MCP|Serve', and-raceon the new reaction tests. Playwright: the 17 reaction / outbox / send-queue specs. CI runs the full suite.Not in this PR
local:<sha1>own messages and the like) staysnot_dispatchedand retries every 5 s with no cap. It is now visible in the tray and cancelable. Stop Google sends sticking on 'no conversation': detect the account-pairing switch, bound retries, and show refused sends #204 proposes the retry budget that would bound it.ReplaceEmbeddedReactions), which removes active reactors the snapshot omits, and the Google decoder names reactors by participant ID, never as self. So the next frame for the message replaces the "self" row with the reactor the phone names, or removes it if the phone has not applied the reaction yet: a reaction can briefly disappear and come back. It never shows twice. Documented in the runbook; left as is because the phone is the source of truth for Google reactions.anon:👍). Reacting 👍 again adds "me" beside it, so the pill reads 2 with only "You" listed (seen in the Google fixture; documented in the runbook).Docs: the runbook gains "Reactions on v2-primary go through the outbox" (routes, the status table, what the user sees, how to find and cancel stuck reactions) and corrects the MCP-serving line that said reactions went to
/api/v1/outbox. CLAUDE.md lists both routes.🤖 Generated with Claude Code