Repository navigation
feat(desktop): confirm send_message from the Messages history - #21325
sauranshbhardwaj wants to merge 11 commits into
Conversation
After Omi sends a message, read the outgoing row back from the Messages history through the existing reader and report delivered, sent, not_delivered or unconfirmed. Only rows in the recipient's direct chat created after the send started count, rows that existed before the send are excluded, the check polls for at most five seconds, and the send is never retried. Unknown outcomes are reported as unconfirmed rather than failed, and only the outcome code and timing are logged.
Update the send_message manifest guidance so the model reports delivered, sent, not_delivered and unconfirmed in plain words, never sends again on its own, and asks the person to check Messages before anything is sent again when the outcome is unconfirmed. Regenerate the tool surfaces.
The hermetic device tools probe now runs the send check with a stand-in send against the absent Messages store and reports an unconfirmed, history_unreadable outcome with exactly one send. The device-tool-surface flow asserts it and covers MessageSendConfirmation.swift.
…decide over the whole window Two phone numbers now match only when their digits are equal or one is the international form of the other's national number in the Mac's region, and read_message_history and the send check share one chat lookup. The check records the newest message row before sending, counts only rows past it dated at most a second before the send, reads for the whole window and decides over every new row, so an iMessage error followed by a text-message success reads as sent and two identical new rows stay ambiguous. A stop before the send sends nothing, a failed read is retried on the next tick, the reader waits out a brief lock, and a timed-out send is checked instead of reported as a failure. The test fixture now runs in WAL mode with one handle per service, a text-message chat beside the iMessage chat, long attributed bodies and other people's chats, and the probe's send check no longer runs anything real.
…ool ledger A Swift executor result that carries outcome_unknown true now completes its invocation as outcome_unknown with error code executor_reported_outcome_unknown instead of succeeded, so later operation receipts never describe an unconfirmed send as done. The flag can only lower a success to unknown. The model still receives the full result and its plain-words summary.
A row half a second early must count, which fails if the clock tolerance is removed, and two international numbers must never match through the Mac's region.
The probe's clock was fixed, so a check that ever reached the poll would never reach its deadline. It now moves half a second per sleep and still never waits.
…ing for the send check The check now stops once the reads have shown one delivered row and no error row for a second, and reads the whole window otherwise. A row that matched is tracked by row id, so a later date change cannot drop it. Handles match national and international spellings through the region's trunk prefixes, North American 011 dialling, a bracketed 0 and extensions, never across forms without a region, and a recipient matching two different stored numbers reads as ambiguous. A stop before the send returns cancelled with nothing sent, a timed-out send reports only the service the history shows, the calling-code table gains the missing ITU regions, and two private test counters use the shared one.
… ledger The ledger honours outcome_unknown only from send_message and only as the exact boolean true.
The settled second restarts when the delivered row changes or a read fails, and a Sapporo number written 011 stays national in Japan.
…or the send check A live send from a Mac set to India to a US number typed nationally read as unconfirmed, because the region rule tried +91 while Messages had resolved the number to +1 through the account. A national number now matches an international handle when it equals that handle without a real calling code: only the sending account's code when chat.account_login is a phone number, otherwise any code in the table. A request that matches two different stored numbers stays ambiguous, two international numbers never match, and the incoming copy of a text to yourself never counts. The calling-code table is now data and is checked for duplicate regions.
|
Thanks @sauranshbhardwaj — this is careful work, and the test coverage is exceptional. I reviewed the full diff; notes below. What stands out well:
Two things I'd want a maintainer to sign off on before merge:
Minor observations: Both items above need a human maintainer decision before this can merge; the implementation quality itself is not in question. This feedback is automated, posted on behalf of the Omi maintainers. by AI on behalf of David — if you need David’s attention urgently, please @Git-on-my-level and escalate with |
|
Thanks for the review. On the two sign-off points:
|
What changed and why
Today
send_messagereports"status": "sent"as soon as Messages accepts the send. Nothing checks whether Messages actually recorded the message as sent or delivered, so a message Messages marked "Not Delivered" still reads as sent to the model and to the person.This PR makes
send_messagecheck its own send in the Messages history and report what it finds. It is piece 2 of #21162, in its narrow form: it checks only messages Omi sends throughsend_message. Drafts the person sends themselves are not checked. It builds on the device tool surface from #20938.The check reuses
MessagesReaderService, the reader behindread_message_historyandlist_message_chats. It adds two reads to that reader, a baseline row id and the outgoing rows after it, and it shares one chat lookup withread_message_history. There is no second reader.Results
statusokdeliveredsucceededsentsucceedednot_deliveredreason: not_delivered)failedunconfirmedoutcome_unknown: trueoutcome_unknowncancelledreason: cancelled)failedunconfirmedis an unknown outcome, never a failure and never a success. The result has noerrorkey, and it carriesoutcome_unknown: true, which makes the kernel record the invocation asoutcome_unknown(error codeexecutor_reported_outcome_unknown) instead ofsucceeded. Later operation receipts therefore never describe an unconfirmed send as done. Onlysend_messagecan lower its own success this way, and only with the exact boolean. Anunconfirmed_reasonsays why:no_matching_row,not_yet_sent,ambiguous,ambiguous_recipient,history_unreadableorcheck_stopped.summarythat says in plain words what happened. Forunconfirmedit says the message may or may not have been sent, not to send it again, and to ask the person to check Messages first.deliveredorsentwithsucceeded_after_error: true, and the summary says so.send_timed_out: trueand the checked status, instead of the old "may or may not have been sent" error. Itsserviceis only what the history shows (the service of the row that confirmed it, otherwise empty), andattached_fileis empty because whether an attachment went is unknown.sentwithout a row marked sent.cancelledwith "The send was stopped before Messages was asked to send it. Nothing was sent." instead of raw error text. When the stop also ends the owner-bound run, the kernel's existing owner-changed result is what reaches the model, as for every physical tool.How the check finds Omi's message
Before sending, the check records the newest
message.ROWID. Afterwards a row counts only when all of these hold:is_from_me = 1);text, or fromattributedBodywhentextis empty, as the reader already does.The check decides over every new matching row. Rows with an error are failed attempts. Exactly one row without an error is Omi's message. Two or more rows without an error mean an identical message went to the same person at the same moment, and the result is
unconfirmed/ambiguous.Accepted limit. An identical message sent to the same person from another of the person's devices (their phone, say) within about a second of Omi's send lands above the baseline, inside the tolerance, and can confirm Omi's send even if Omi's own send wrote nothing. That needs the same text to the same person in the same second, and it is the residual this design accepts.
Handle matching. Contacts gives "(267) 361-8369" while Messages stores "+12673618369". Two phone numbers name the same handle only when:
+,00, or011with a North American region) with equal digits, or both national with equal digits; orThe trunk prefixes come from a small table: "0" in most places, "8" in Russia, Kazakhstan, Tajikistan and Turkmenistan, "8 0" in Belarus, "8" or "0" in Lithuania, "06" in Hungary and "1" in North America.
Which calling codes count. Messages resolves a national number through the sending account, not the Mac's region:
chat.account_login"P:+1…"), only that account's calling code counts.unconfirmed, because the region rule tried +91 while Messages had sent to +1.account_logincolumn fall back to every code.If the request matches two different stored numbers, the check does not choose between them: it reports
unconfirmed/ambiguous_recipient. Examples: "+12673618369" and "+912673618369" both present with no phone account to decide, or, in Italy, "06 1234 5678" against both "+390612345678" and "+39612345678". Two different international numbers never match, so "+14155550123" and "+914155550123" stay two people.Extensions ("ext. 45", ";45", "x45") and a "(0)" inside an international number ("+44 (0) 7700 900123") are ignored. Argentine mobiles written nationally with the "15" mobile prefix are not rewritten to the international "9" form, so they read
unconfirmed. Email addresses compare without case.The calling codes come from a table of ITU calling codes in
MessagingHandle.swift, checked against every ISO region macOS knows and tested for duplicate regions. Regions with no calling code of their own (uninhabited territories, "001") have none.What changed for
read_message_history. It now uses the same lookup. Before, it matched the stored handle string exactly. Now it also matches the other spellings of the same number (national and international forms, by the rule above) and emails without case. So:The direct chat still wins over a group. Approval is still keyed on the requested handle string.
The window
The check reads every half second, for up to 5 seconds after the send returns. It stops early in one case only: when the reads have shown the same single delivered row, and no error row, for at least 1 second. At the half-second interval that is three agreeing reads (at 0, 0.5 and 1 s), so an online recipient answers in about 1 to 3 seconds. Everything else reads the whole window: a sent-but-not-delivered row, an error (a text-message fallback may follow), a row not yet sent, or no row. A second identical row before the delivery settles makes the result
ambiguous. A failed read breaks a run of agreeing reads.Messages writes the outgoing row when it accepts the send. It sets
is_sentonce the service takes the message, usually within a second for iMessage. It setsis_deliveredwhen the recipient's device acknowledges it, usually a second or two later when that device is online. A recipient whose device is offline getssent, which is true. Errors that Messages records only after a long service timeout show assent, which was true when the check ended.A failed read during the window (a locked store, a checkpoint) is retried on the next tick. The outcome is
history_unreadableonly if no read after the send works. The reader also waits up to 0.25 s for a lock before failing. The check runs off the main actor: the reads go through the reader actor, and the wait is an async sleep. A stopped tool call ends the check withunconfirmed/check_stopped.History columns used
The new reads use columns the reader already relies on, plus four:
message.is_sent,message.is_delivered,message.errorandmessage.service. They also usemessage.ROWID,is_from_me,date,textandattributedBody, pluschat_message_join,chat_handle_joinandhandle.id.message.dateis nanoseconds since 2001-01-01 UTC, which the reader already converts. If a future Messages schema drops a column, the read fails asstore_read_failedand the result isunconfirmed.Privacy
send_messageis unchanged.Model guidance
The
send_messageguidance inomi-tool-manifest.tsnow tells the model to report the status in plain words, never to send again on its own, to ask the person to check Messages before anything is sent again when the result isunconfirmed, and to send again afternot_deliveredonly if the person asks. The generated surfaces and the fixture are regenerated withscripts/generate-tool-surfaces.mjs.Product invariants affected
INV-INT-1(product/invariants/integrations.md) is named becauseMessagesReaderService.swiftmatches**/*ReaderService*. The result comes from a deterministic read of the store, covered by fixture tests. Matching a person's chats depends on the chat's sending account and on a table of calling codes and trunk prefixes. Where that cannot decide between two stored numbers, the check readsunconfirmedrather than guessing.INV-AGENT-*(product/invariants/agent-control-plane.md), Authority:desktop/macos/agent/src/runtime/**changes, and the ledger is affected. A Swift executor result that carriesoutcome_unknown: truenow completes its dispatched invocation asoutcome_unknown(error codeexecutor_reported_outcome_unknown) instead ofsucceeded, throughfinalizedToolInvocationLedgerOutcomeinrelay-tool-result.tsandcompleteToolInvocation. The flag is honoured only forsend_messageand only as the exact booleantrue. It can only lower a success to unknown. A failure stays a failure, and capability checks run exactly as before completion. Policy and approval are unchanged, andsend_messagestaysnever_auto_retry. Guard test:run-tool-capability.test.tscovers completion asoutcome_unknownunder live authority;relay-tool-result.test.tsandconversation-operations.test.tscover the mapping and the receipt.INV-CHAT-1(product/invariants/chat-continuity.md) is named for the same glob and is unaffected: the result is an ordinary tool result in the kernel's invocation ledger, and no transcript or second store is added.How it was verified
xcrun swift build -c debug --package-path Desktop --build-tests: passes.MessageSendConfirmationTests(52),MessagingHandleTests(21),SendMessageResultTests(8) andLockedCounterTests(2);KnowledgeGraphPaginationTests(11) andRealtimeHubResumeOnceGateTests, which now use the shared counter.scripts/test-tool-surfaces.sh:generate-tool-surfaces --checkreports all outputs match; agent vitest 72 files, 905 tests passed (including the ledger tests);pi-mono-extension143 passed.tsc --noEmitis clean.desktop-flow-lintpasses.fb32c6628a(swift test --skip SubscriptionEntitlementServiceTestsindesktop/macos/Desktop): 7959 tests, 10 skipped, 2 failures, both known and unrelated to this change:DesktopAutomationWindowPresentationTests.testQuietPresentationAlsoParksNonactivatingOverlayPanels(display geometry) andRecentScreensMenuContentTests.testDayOldFrameShowsACalendarDateNotAClockTime(an Indian locale date format).fb32c6628a; that change is covered by the new tests listed below.PRE_PUSH_BASE_REMOTE=upstream OMI_PR_BODY_FILE=<this body> scripts/pre-push-singleflight upstream. It reports "PR preflight passed: 33 checks" and "Bounded pre-push gate passed".Packaged-app check (to run by hand). On a packaged build of this branch with Full Disk Access and Automation for Messages granted:
Ask Omi: "Send an iMessage to saying: testing the send check".
Approve the send.
After a second or two, expect the reply to say the message was sent and delivered. The tool result shows
"status": "delivered".Optional: turn off Full Disk Access for the build, relaunch it, and send again. Expect
"status": "unconfirmed"withhistory_unreadable, and a reply asking you to check Messages before anything is sent again. The message is still sent once.Optional, if you have a number that is not on iMessage: send to it with the service left unset. If Messages records an iMessage error and then sends a text message, expect
deliveredorsentwithsucceeded_after_error: true. Whether real Messages writes that error row first has not been observed yet.If your Mac's region differs from your number's country (for example an Indian locale and a US number), ask Omi to text your own number typed without the country code. Expect
delivered.A person ran step 1 on an earlier build of this branch (
256163fe5c), on a Mac with an Indian locale, texting their own US number typed nationally. Messages delivered it (the thread showed "Read"), but the check reportedunconfirmed/no_matching_row, because the region rule tried +91. The account-based matching above fixes that, and the case is now a test. Rerun on the current build (fb32c6628a, packaged together with the approval-card stack sosend_messagecan run): same Mac, same number typed nationally, Allow Once on the card, and the reply said it was marked delivered. The log line wassend_message outcome=delivered reason=none timed_out=false after_error=false checks=5 waited_ms=2377. Steps 4 to 6 have not been run.Tests
MessagesHistoryFixture(inTests/Support) builds a store shaped like the Messages history inside each test. Like the real store, it runs in WAL mode, keeps onehandlerow per address and service, and gives one person separate iMessage and text-message chats. It encodesattributedBodythe way the reader decodes it, including bodies of 128 bytes or more. The real reader is pointed at it. The fake send writes the rows Messages would write. An injected clock and sleeper drive the poll, so no test sleeps on the wall clock.MessageSendConfirmationTestscovers:not_deliveredafter the full window;deliveredwithsucceeded_after_error, service "SMS", after the full window;sent, and beside an unsent row asunconfirmed;unconfirmed, neversent.ambiguous. A recipient that matches two different stored numbers (region IT, "06 1234 5678" against "+390612345678" and "+39612345678") isambiguous_recipient.+91…beside+1…), the same person's email chat, a group chat with the recipient, and an incoming identical row. A handle spelled the Contacts way finds the chat.ambiguous_recipient;is_from_me = 0) is ignored, so the outgoing row decides. An incoming copy alone never confirms.attributedBody-only bodies of 9, 127, 128, 180 and 520 bytes, including emoji.history_unreadablewith no checks and still sends once;history_unreadable.DeviceToolsProbe.sendCheckDetailruns the check against the absent store with a stand-in send and a clock that moves only with its sleeps, and reportsunconfirmed/history_unreadablewith one send. It runs nothing real. Thedevice-tool-surfacee2e flow asserts the same keys and covers the new files. The flow itself was not run, because that needs a running app.MessagingHandleTestscovers:+1vs+91,+1vs+61,+44vs+244, and two international forms compared through the region;+91…;04…never matching Bermuda+1441…;+1, an Indian0-prefixed number vs+91with the region set to IN (and not with US), UK, Italian landlines that keep their 0,00dialling, emails without case, and short codes;ambiguous_recipient;read_message_historyfinding the chat the send check uses, still preferring the direct chat, reading the more recent chat stored in national form for an exact international request, and matching emails without case;SendMessageResultTestscovers:ok,status,reason,outcome_unknownandunconfirmed_reasonfor each outcome and all six reasons;succeeded_after_error;oktrue when confirmed or unknown,send_timed_out, and aservicethat is only what the history showed, with no attachment claimed;cancelledwith "Nothing was sent" and no raw error text, and other failures keeping their reasons without raw text.LockedCounterTestscovers the shared counter.KnowledgeGraphPaginationTestsandRealtimeHubResumeOnceGateTestsnow use it in place of their private copies and still pass.Agent vitest:
relay-tool-result.test.ts:outcome_unknownand keeps its summary;succeededand a failure staysfailed;run-tool-capability.test.tscovers completion asoutcome_unknownunder live authority.conversation-operations.test.tscovers the receipt.UI evidence
UI-Evidence: none -- tool result, ledger status and model guidance only; no UI changes
Failure class (fixes)
Failure-Class: none