Allow another phone send after delivery is unconfirmed (#26392)

* Allow another phone send after delivery is unconfirmed

* Update phone image recordings after journal removal
This commit is contained in:
Brennan Benson
2026-10-08 01:46:31 -07:00
committed by GitHub
parent b1cd85820b
commit a747c1c013
42 changed files with 507 additions and 2740 deletions
+50 -74
View File
@@ -325,24 +325,10 @@
"terminal automation completion",
"local and relayed cancellation"
],
"platforms": [
"macos",
"linux",
"windows"
],
"providers": [
"local",
"ssh",
"wsl",
"remote-runtime"
],
"coveredPlatforms": [
"macos"
],
"coveredProviders": [
"local",
"remote-runtime"
],
"platforms": ["macos", "linux", "windows"],
"providers": ["local", "ssh", "wsl", "remote-runtime"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["local", "remote-runtime"],
"coverageNotes": "Real HTTP hook ingress and relay forwarding, production runtime automation observer, and captured Claude native ready bytes run locally on macOS. SSH/WSL transport ownership is simulated; physical remote hosts and native Windows/Linux are not verified.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/24878",
@@ -1456,7 +1442,7 @@
},
{
"id": "agent-session.structured-send-at-most-once",
"title": "Ambiguous structured sends never become a second provider delivery",
"title": "One structured-send attempt dispatches once; a later Send is a new message",
"maturity": "experimental",
"protection": "partial",
"owner": "agent-session-runtime",
@@ -1466,13 +1452,13 @@
"providers": ["local", "ssh", "remote-runtime", "mobile"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["local", "mobile"],
"coverageNotes": "Deterministic host, direct/Relay transport, and mobile hook tests cover durable identity, caller changes, journal loss, acknowledgement loss, expiry, and mobile remount; renderer tests cover the desktop's single request per send, its 30 s bound and the hand-back. The host code is execution-location neutral, but live SSH/remote runtimes and physical iOS/Android lifecycle are not exercised.",
"coverageNotes": "Deterministic host and direct/Relay transport tests cover per-operation duplicate protection, caller changes, missing host journal rows, acknowledgement loss, and expiry. Mobile tests cover new-action identity, remount, storage refusal, current attachment paths, and capability changes; renderer tests cover one request per send and the bounded hand-back. Live SSH/remote runtimes and physical iOS/Android lifecycle are not exercised.",
"motivatingLinks": ["https://github.com/stablyai/orca/pull/20133"],
"invariant": "One structured-send operation id causes at most one provider dispatch. On the phone a transport-ambiguous send reuses that id for every resend and caller reconnect, across remount and journal recovery. The desktop never resends: each send makes one request, and an ambiguous or unanswered one, or one still out after 30 s, hands the text back to check; a send unsettled at a reload or crash is not resent. Only a terminal rejection may rotate to a first delivery.",
"oracle": "Inject adapter acknowledgement loss, RPC response loss, caller replacement, logical-client close after response, auth recovery with a written request, missing journal submissions, legacy pending rows, stale fences, operation expiry, mobile remount, and durable-journal capacity. Assert one provider dispatch or one operation id for every ambiguous retry, fresh identity only after rejection, and no eviction of ambiguous mobile ids.",
"invariant": "One structured-send operation id causes at most one provider dispatch. Each user Send on phone or desktop is a new action with a fresh id and one request; neither automatically resends an ambiguous action. The host retains the original action's outcome, and a later phone Send remains possible with equal text, after remount, or when phone storage fails. Repeated transmission of the same operation remains protected by host deduplication.",
"oracle": "Inject adapter/RPC acknowledgement loss, caller replacement, logical-client close after response, auth recovery with a written request, missing host submissions, legacy pending rows, stale fences, expiry, mobile remount, and phone storage refusal. Assert at most one dispatch for repeated transmission of one operation. For a later phone Send, assert a fresh id and second delivery while the original host entry remains unknown; equal images use current uploaded paths and delivery fields follow current host capabilities.",
"commands": [
"ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-evidence.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/structured-agent-session-message-sender.test.ts src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts",
"ORCA_BACKGROUND_LAUNCH=1 pnpm --dir mobile test ../mobile/src/session/mobile-native-chat-image-attachment.test.ts ../mobile/src/session/use-mobile-native-chat-image-attachments.test.ts ../mobile/src/session/mobile-structured-send-operation-journal.test.ts ../mobile/src/session/mobile-structured-send-delivery.test.ts ../mobile/src/session/use-mobile-structured-agent-session-send.test.tsx ../mobile/src/session/use-mobile-structured-agent-session.test.tsx ../mobile/src/transport/mobile-relay-rpc-session.test.ts ../mobile/src/transport/rpc-client-delivery-ambiguity.test.ts ../mobile/src/transport/stable-logical-rpc-client.test.ts"
"ORCA_BACKGROUND_LAUNCH=1 pnpm --dir mobile test ../mobile/src/session/mobile-structured-send-new-action.test.ts ../mobile/src/session/use-mobile-structured-agent-session-send.test.tsx ../mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx ../mobile/src/session/mobile-structured-send-delivery.test.ts ../mobile/src/session/use-mobile-structured-agent-session-background-tasks.test.tsx ../mobile/src/session/use-mobile-structured-agent-session-newer-approval-send.test.tsx ../mobile/src/session/use-mobile-structured-agent-session-read-failure.test.tsx ../mobile/src/session/use-mobile-structured-agent-session.test.tsx ../mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx ../mobile/src/session/mobile-native-chat-image-attachment.test.ts ../mobile/src/session/use-mobile-native-chat-image-attachments.test.ts ../mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts ../mobile/src/mobile-web-shell/page-storage-keys.test.ts ../mobile/src/mobile-web-shell/bridge/page-async-storage.test.ts ../mobile/src/mobile-web-shell/use-page-host-snapshot.test.tsx ../mobile/src/mobile-web-shell/bridge-host-init.test.ts ../mobile/src/transport/mobile-relay-rpc-session.test.ts ../mobile/src/transport/rpc-client-delivery-ambiguity.test.ts ../mobile/src/transport/stable-logical-rpc-client.test.ts --maxWorkers=2"
],
"testFiles": [
"src/shared/agent-session-operation-ledger.test.ts",
@@ -1488,13 +1474,23 @@
"src/renderer/src/lib/launch-structured-agent-session.test.ts",
"mobile/src/session/mobile-native-chat-image-attachment.test.ts",
"mobile/src/session/use-mobile-native-chat-image-attachments.test.ts",
"mobile/src/session/mobile-structured-send-operation-journal.test.ts",
"mobile/src/session/mobile-structured-send-new-action.test.ts",
"mobile/src/session/mobile-structured-send-delivery.test.ts",
"mobile/src/session/use-mobile-structured-agent-session-send.test.tsx",
"mobile/src/session/use-mobile-structured-agent-session.test.tsx",
"mobile/src/transport/mobile-relay-rpc-session.test.ts",
"mobile/src/transport/rpc-client-delivery-ambiguity.test.ts",
"mobile/src/transport/stable-logical-rpc-client.test.ts"
"mobile/src/transport/stable-logical-rpc-client.test.ts",
"mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx",
"mobile/src/session/use-mobile-structured-agent-session-background-tasks.test.tsx",
"mobile/src/session/use-mobile-structured-agent-session-newer-approval-send.test.tsx",
"mobile/src/session/use-mobile-structured-agent-session-read-failure.test.tsx",
"mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx",
"mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts",
"mobile/src/mobile-web-shell/page-storage-keys.test.ts",
"mobile/src/mobile-web-shell/bridge/page-async-storage.test.ts",
"mobile/src/mobile-web-shell/use-page-host-snapshot.test.tsx",
"mobile/src/mobile-web-shell/bridge-host-init.test.ts"
],
"assertionRefs": [
{
@@ -1514,12 +1510,11 @@
{
"file": "mobile/src/session/use-mobile-structured-agent-session-send.test.tsx",
"assertions": [
"keeps one id across acknowledgement loss and host unknown replays",
"reuses an ambiguous id after the session hook remounts",
"keeps an ambiguous id after the host replay window expires",
"reuses the original uploaded attachment identity after acknowledgement loss",
"keeps the send id after a pending-admission refusal",
"rotates after a %s pre-handler RPC refusal that proves the send did not run"
"a later Send owns a new id even when the earlier host answer was %s",
"keeps the original unknown host row while allowing another Send",
"does not replay an old action after remount",
"uses newly uploaded attachment paths for a new action with the same image",
"never reads an old phone journal, even when storage is full or unreadable"
]
},
{
@@ -1556,6 +1551,15 @@
}
],
"evidenceRuns": [
{
"date": "2026-10-07",
"runner": "local",
"platform": "macos",
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm --dir mobile test ../mobile/src/session/mobile-structured-send-new-action.test.ts ../mobile/src/session/use-mobile-structured-agent-session-send.test.tsx ../mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx ../mobile/src/session/mobile-structured-send-delivery.test.ts ../mobile/src/session/use-mobile-structured-agent-session-background-tasks.test.tsx ../mobile/src/session/use-mobile-structured-agent-session-newer-approval-send.test.tsx ../mobile/src/session/use-mobile-structured-agent-session-read-failure.test.tsx ../mobile/src/session/use-mobile-structured-agent-session.test.tsx ../mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx ../mobile/src/session/mobile-native-chat-image-attachment.test.ts ../mobile/src/session/use-mobile-native-chat-image-attachments.test.ts ../mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts ../mobile/src/mobile-web-shell/page-storage-keys.test.ts ../mobile/src/mobile-web-shell/bridge/page-async-storage.test.ts ../mobile/src/mobile-web-shell/use-page-host-snapshot.test.tsx ../mobile/src/mobile-web-shell/bridge-host-init.test.ts ../mobile/src/transport/mobile-relay-rpc-session.test.ts ../mobile/src/transport/rpc-client-delivery-ambiguity.test.ts ../mobile/src/transport/stable-logical-rpc-client.test.ts --maxWorkers=2",
"result": "passed",
"durationSeconds": 5.42,
"summary": "Nineteen focused mobile files passed 241 tests for fresh Send identity, remount, attachment paths, storage refusal, mixed host capabilities, queued cards, and written-request ambiguity."
},
{
"date": "2026-10-06",
"runner": "local",
@@ -1564,15 +1568,6 @@
"result": "passed",
"durationSeconds": 14,
"summary": "Eleven focused host, shared, renderer, and orchestration files passed 173 tests after the desktop outbox gave way to the in-memory sender, which makes one request per send and never resends."
},
{
"date": "2026-09-12",
"runner": "local",
"platform": "macos",
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm --dir mobile test ../mobile/src/session/mobile-native-chat-image-attachment.test.ts ../mobile/src/session/use-mobile-native-chat-image-attachments.test.ts ../mobile/src/session/mobile-structured-send-operation-journal.test.ts ../mobile/src/session/mobile-structured-send-delivery.test.ts ../mobile/src/session/use-mobile-structured-agent-session-send.test.tsx ../mobile/src/session/use-mobile-structured-agent-session.test.tsx ../mobile/src/transport/mobile-relay-rpc-session.test.ts ../mobile/src/transport/rpc-client-delivery-ambiguity.test.ts ../mobile/src/transport/stable-logical-rpc-client.test.ts",
"result": "passed",
"durationSeconds": 2.37,
"summary": "Ten focused mobile session, attachment, and transport files passed 113 tests."
}
],
"runtimeBudget": {
@@ -1585,27 +1580,24 @@
},
"redGreenEvidence": {
"status": "partial",
"evidence": "Four targeted failures were observed: spending the mobile id after acknowledgement loss made three retries use two ids; settling cross-caller recovery under the new caller left the original ledger row pending; returning no synthetic result for a legacy or crash-left pending row dispatched it as a new accepted send; and a8d3de8d20's blanket settlement rotated a stale-fence option id and failed the renderer invariant. The focused tests pass with the production fixes restored; a byte-identical old-production run is not recorded."
"evidence": "The exact phone acknowledgement-loss/later-equal-Send test fails twice with the original sender and classifier restored byte-for-byte: the second Send returns unknown under the earlier id. It passes with per-action identity. Existing host tests also pin cross-caller recovery, missing submissions, and legacy pending rows against repeat dispatch; those host paths were not changed."
},
"performanceBudget": {
"required": true,
"evidence": "No polling, timers, subprocesses, or provider fanout were added. Each mobile journal mutation parses the bounded journal and performs at most one full AsyncStorage rewrite; entries retain hashes, ids, and original attachment paths, cap at 4,096, and fail closed at capacity. A first host send performs three serialized whole-store durable transactions: admission, the pre-effect unknown tombstone, and final settlement. Lease and fence admission now share one transaction with the operation row, so a transient pre-effect refusal leaves no row and, absent pruning, performs no store write. No capacity-scale host fsync or mobile AsyncStorage latency benchmark is recorded."
"evidence": "No polling, timers, subprocesses, or provider fanout were added. The phone send path no longer reads or writes AsyncStorage or hashes image contents. The existing host admission, pre-effect unknown record, and settlement transactions are unchanged. No capacity-scale host fsync benchmark is recorded."
},
"promotionCriteria": [
"Soak both commands in CI with zero unexplained flakes.",
"Add live physical-mobile and remote-runtime interruption evidence before claiming full provider coverage."
],
"knownGaps": [
"A payload-keyed mobile ambiguity cannot distinguish retrying the original send from an intentional later send with identical content. It fails closed until authoritative settlement, so the claim that retention costs no liveness is false without a durable composer-action identity.",
"Clearing or externally corrupting desktop localStorage or mobile AsyncStorage can erase a client-owned ambiguous identity.",
"An ambiguous id retained past host tombstone expiry remains safely blocked rather than becoming live again.",
"A host crash after premarking the operation unknown but before journal append or provider dispatch can conservatively suppress a message that never reached the provider.",
"A replay synthesized from an operation row whose journal submission is missing is not republished into the journal; desktop stops automatic polling but remains safely blocked on that FIFO head.",
"The changed send guarantee is not capability-negotiated. New mobile clients fail closed across caller-identity change, but old clients can rotate an ambiguous id against a new host, and an old mobile client can interpret an ok response carrying unknown as accepted.",
"An ambiguous attachment retry depends on the original host temp path remaining usable when the first request never reached the host; if it is gone, the retry rejects rather than rotating.",
"Phone composer drafts remain in memory: killing the app between Send and host recording loses that text, as before.",
"A later intentional Send can deliver another copy of text from an earlier unknown action; the original action stays unresolved until the host can establish its outcome.",
"A host crash after recording the operation unknown but before journal append or provider dispatch can conservatively suppress an attempt that never reached the provider.",
"Older phone bundles retain their payload journal behavior until upgraded; legacy embedded-page storage access remains permitted.",
"No live SSH, remote-runtime, physical iOS/Android, Linux, or Windows interruption run is recorded."
],
"demotionRule": "Keep experimental or demote if any ambiguous retry changes operation id, provider dispatch count exceeds one, durable identity is evicted by age or capacity, or the focused commands flake without a diagnosed harness defect."
"demotionRule": "Keep experimental or demote if one operation dispatches twice, uncertainty is reported as definite rejection, a later phone Send reuses the earlier action or is blocked by phone storage, or the focused commands flake without a diagnosed harness defect."
},
{
"id": "agent-session.completed-turn-duration",
@@ -3788,9 +3780,7 @@
},
{
"file": "src/main/system-resume-broadcast.test.ts",
"assertions": [
"publishes suspend and resume to main-process lifecycle consumers"
]
"assertions": ["publishes suspend and resume to main-process lifecycle consumers"]
},
{
"file": "src/main/system-power-lifecycle.test.ts",
@@ -5067,9 +5057,7 @@
},
{
"file": "src/cli/runtime/serve-signal-exit-diagnostic.test.ts",
"assertions": [
"late update handoff failure cannot rearm termination after child exit"
]
"assertions": ["late update handoff failure cannot rearm termination after child exit"]
},
{
"file": "src/main/serve-update-handoff.test.ts",
@@ -5485,9 +5473,7 @@
"coveredPlatforms": ["macos"],
"coveredProviders": ["remote-runtime"],
"coverageNotes": "Deterministic main-IPC contract tests cover disconnect-driven close delivery, exactly-once close, per-subscription teardown isolation against a failing socket close and a throwing liveness probe, containment of a throwing renderer send on the unguarded host-close path, and continued suppression of stale payloads from a retired transport. A headed paired-server journey (real Orca host plus a separate paired Orca desktop client) covers hidden-but-mounted reveal, cold-parked reveal, and cold-parked reveal across a disconnect/reconnect. Live Linux and Windows paired-server evidence and real sleep/wake transport loss remain uncollected.",
"motivatingLinks": [
"tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts"
],
"motivatingLinks": ["tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts"],
"invariant": "Every renderer-held runtime subscription receives exactly one terminal close event when its transport is retired, including when the retirement advanced the transport generation first, and a single failing teardown never abandons that environment's remaining subscriptions nor escapes into the transport that reported the close. Payload frames from a retired transport stay suppressed. A revealed remote terminal therefore reattaches over a live multiplex connection: its buffer restores, typed input reaches the host PTY, the echo paints without a tab flip, and the PTY converges on the revealed pane grid.",
"oracle": "The main IPC contract test subscribes terminal.multiplex through the real handler, disconnects the environment, and asserts the renderer received exactly one {type: close} subscription event. Two isolation tests subscribe a second stream to the same environment and make the first one fail -- in its socket close, and in the liveness probe inside notifyClosed -- then assert the disconnect does not throw, both transports closed, and every close the renderer could still receive was delivered. A third drives a host-initiated close through the transport callback, which is the one notifyClosed call site with no surrounding guard, with a renderer send that throws, and asserts it cannot escape into the WebSocket close handler. A fourth test asserts that after retirement a late response frame is not forwarded and a late transport close does not re-send. The paired-server journey runs three reveal scenarios against one real host and one real paired desktop client, and for each records buffer restore, host-side receipt of the typed marker through an out-of-band host sink file, live paint without a tab flip, and PTY-versus-pane grid convergence.",
"commands": [
@@ -12625,9 +12611,7 @@
},
{
"file": "src/main/ipc/pty-startup-barrier-and-listing.test.ts",
"assertions": [
"global inventory starts local and SSH provider listings concurrently"
]
"assertions": ["global inventory starts local and SSH provider listings concurrently"]
},
{
"file": "src/renderer/src/components/status-bar/resource-session-inventory.test.ts",
@@ -21094,9 +21078,7 @@
},
{
"file": "src/main/browser/agent-browser-bridge-tab-routing.test.ts",
"assertions": [
"closing a tab retires the exact named agent-browser session"
]
"assertions": ["closing a tab retires the exact named agent-browser session"]
},
{
"file": "src/main/startup/serve-signal-handlers.test.ts",
@@ -21535,9 +21517,7 @@
},
{
"file": "config/scripts/verify-packaged-browser-participation.test.mjs",
"assertions": [
"reject missing, substituted, skipped and retried scenarios"
]
"assertions": ["reject missing, substituted, skipped and retried scenarios"]
}
],
"evidenceRuns": [
@@ -21599,15 +21579,11 @@
"gh run view 34074017928 --log",
"pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/terminal-hangul-terminating-digit-native.spec.ts --project=electron-headful --workers=1 --repeat-each=3 --retries=0 --reporter=list,json"
],
"testFiles": [
"tests/e2e/terminal-hangul-terminating-digit-native.spec.ts"
],
"testFiles": ["tests/e2e/terminal-hangul-terminating-digit-native.spec.ts"],
"assertionRefs": [
{
"file": "tests/e2e/terminal-hangul-terminating-digit-native.spec.ts",
"assertions": [
"a digit typed right after a Hangul syllable reaches the pty"
]
"assertions": ["a digit typed right after a Hangul syllable reaches the pty"]
}
],
"evidenceRuns": [
@@ -31,7 +31,6 @@ const MIRRORED_WRITERS = [
keys: ['TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY']
},
{ file: 'src/components/CustomKeyModal.tsx', keys: ['CUSTOM_ACCESSORY_KEYS_STORAGE_KEY'] },
{ file: 'src/session/mobile-structured-send-operation-journal.ts', keys: ['STORAGE_KEY'] },
{
file: 'src/worktree/last-visited-worktree-repo.ts',
keys: ['LAST_VISITED_WORKTREE_STORAGE_KEY']
@@ -374,7 +374,6 @@ describeRender(
* `mobile-web-app-screencast-lane-grant.test.mjs` derives from this closure.
*
* **The storage refusals a control makes.** The case above covers the writes a mount makes on its
* own; a refusal a user's own write earns still needs the control. That chain is
* `mobile/src/session/mobile-structured-send-page-storage-refusal.test.ts` end to end over the
* real `page-async-storage`.
* own; store-level write refusals are covered by
* `mobile/src/mobile-web-shell/bridge/page-async-storage.test.ts`.
*/
@@ -4,15 +4,6 @@
"family": "nativeChat.image-upload",
"namedDeltas": [],
"values": {
"2ec71b490683": {
"name": "image-uploaded",
"ordinal": 8,
"value": {
"contentFingerprint": "f75f583c67e2aaf12284c94d09b56e3caa9d7b93b71565d2fd3e09ef635cef8d",
"path": "/tmp/img-1.png",
"previewUri": "data:image/png;base64,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
}
},
"2fd17c25e4d0": {
"name": "clipboard.startImageUpload#2",
"ordinal": 9,
@@ -49,6 +40,14 @@
}
}
},
"31f4965e4ed4": {
"name": "image-uploaded",
"ordinal": 8,
"value": {
"path": "/tmp/img-1.png",
"previewUri": "data:image/png;base64,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
}
},
"6696a47819e1": {
"name": "clipboard.startImageUpload#1",
"ordinal": 3,
@@ -204,7 +203,7 @@
"two": "765ab192e1a5"
},
"state": "b2b1a4389f58",
"effects": ["994fccf9b305", "2ec71b490683"]
"effects": ["994fccf9b305", "31f4965e4ed4"]
}
}
]
@@ -4,13 +4,23 @@
"family": "nativeChat.image-upload",
"namedDeltas": [],
"values": {
"5c7ed03288e6": {
"0d87f193fb59": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": [
{
"path": "/tmp/img-1.png",
"previewUri": "file:///a.png"
}
]
},
"15d714ff61ff": {
"failure": {
"$rpc": "null"
},
"uploaded": [
{
"contentFingerprint": "f75f583c67e2aaf12284c94d09b56e3caa9d7b93b71565d2fd3e09ef635cef8d",
"path": "/tmp/img-1.png",
"previewUri": "file:///a.png"
}
@@ -71,17 +81,13 @@
"ordinal": 1,
"value": {}
},
"9a61808dee44": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": [
{
"contentFingerprint": "f75f583c67e2aaf12284c94d09b56e3caa9d7b93b71565d2fd3e09ef635cef8d",
"path": "/tmp/img-1.png",
"previewUri": "file:///a.png"
}
]
"dd123d00d140": {
"name": "image-uploaded",
"ordinal": 8,
"value": {
"path": "/tmp/img-1.png",
"previewUri": "file:///a.png"
}
},
"ec8f73d25f26": {
"name": "clipboard.commitImageUpload#1",
@@ -150,15 +156,6 @@
}
}
}
},
"f4c66fc96af9": {
"name": "image-uploaded",
"ordinal": 8,
"value": {
"contentFingerprint": "f75f583c67e2aaf12284c94d09b56e3caa9d7b93b71565d2fd3e09ef635cef8d",
"path": "/tmp/img-1.png",
"previewUri": "file:///a.png"
}
}
},
"recording": {
@@ -170,10 +167,10 @@
"sender": ["7c2e3ea286aa", "f05e4695ffa3", "ec8f73d25f26"],
"payloads": ["6696a47819e1", "814ccd56a769", "8452698ac11a"],
"settlements": {
"normal": "9a61808dee44"
"normal": "0d87f193fb59"
},
"state": "5c7ed03288e6",
"effects": ["994fccf9b305", "f4c66fc96af9"]
"state": "15d714ff61ff",
"effects": ["994fccf9b305", "dd123d00d140"]
}
}
]
@@ -4,41 +4,14 @@
"family": "nativeChat.image-upload",
"namedDeltas": [],
"values": {
"147a6d6af2f1": {
"name": "image-uploaded",
"ordinal": 15,
"value": {
"contentFingerprint": "f75f583c67e2aaf12284c94d09b56e3caa9d7b93b71565d2fd3e09ef635cef8d",
"path": "/tmp/img-2.png",
"previewUri": "file:///b.png"
}
},
"2ec71b490683": {
"31f4965e4ed4": {
"name": "image-uploaded",
"ordinal": 8,
"value": {
"contentFingerprint": "f75f583c67e2aaf12284c94d09b56e3caa9d7b93b71565d2fd3e09ef635cef8d",
"path": "/tmp/img-1.png",
"previewUri": "data:image/png;base64,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
}
},
"4a3413975ca6": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": [
{
"contentFingerprint": "f75f583c67e2aaf12284c94d09b56e3caa9d7b93b71565d2fd3e09ef635cef8d",
"path": "/tmp/img-1.png",
"previewUri": "data:image/png;base64,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
},
{
"contentFingerprint": "f75f583c67e2aaf12284c94d09b56e3caa9d7b93b71565d2fd3e09ef635cef8d",
"path": "/tmp/img-2.png",
"previewUri": "file:///b.png"
}
]
},
"5c19acb9611e": {
"name": "clipboard.commitImageUpload#2",
"ordinal": 13,
@@ -121,18 +94,16 @@
"ordinal": 7,
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"clipboard.commitImageUpload\",\"params\":{\"uploadId\":\"upload-1\"}}"
},
"8d51e9899739": {
"failure": {
"$rpc": "null"
},
"uploaded": [
"87c17cf78a60": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": [
{
"contentFingerprint": "f75f583c67e2aaf12284c94d09b56e3caa9d7b93b71565d2fd3e09ef635cef8d",
"path": "/tmp/img-1.png",
"previewUri": "data:image/png;base64,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
},
{
"contentFingerprint": "f75f583c67e2aaf12284c94d09b56e3caa9d7b93b71565d2fd3e09ef635cef8d",
"path": "/tmp/img-2.png",
"previewUri": "file:///b.png"
}
@@ -224,6 +195,14 @@
}
}
},
"d7dc1460b465": {
"name": "image-uploaded",
"ordinal": 15,
"value": {
"path": "/tmp/img-2.png",
"previewUri": "file:///b.png"
}
},
"e74ddb684ed1": {
"name": "clipboard.startImageUpload#2",
"ordinal": 10,
@@ -296,6 +275,21 @@
}
}
}
},
"f6cdfabc3790": {
"failure": {
"$rpc": "null"
},
"uploaded": [
{
"path": "/tmp/img-1.png",
"previewUri": "data:image/png;base64,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
},
{
"path": "/tmp/img-2.png",
"previewUri": "file:///b.png"
}
]
}
},
"recording": {
@@ -321,10 +315,10 @@
"a77c2409e01f"
],
"settlements": {
"two": "4a3413975ca6"
"two": "87c17cf78a60"
},
"state": "8d51e9899739",
"effects": ["994fccf9b305", "2ec71b490683", "147a6d6af2f1"]
"state": "f6cdfabc3790",
"effects": ["994fccf9b305", "31f4965e4ed4", "d7dc1460b465"]
}
}
]
@@ -29,7 +29,7 @@ export const PAGE_STORAGE_EXACT_KEYS = [
'orca:custom-accessory-keys',
/** Whether a supported agent session opens on the terminal or the native chat. */
'orca:defaultSessionView',
/** The durable send journal: which `agentSession.send` operation ids are still unsettled. */
/** Legacy journal access for embedded pages served by older hosts. */
'orca:mobileStructuredSendOperations:v1',
/** The terminal's text scale, which pinch-to-zoom writes. */
'orca:terminalTextScale',
@@ -1 +0,0 @@
export { mobileNativeChatImageContentFingerprint as fingerprintNativeChatImage } from '../session/mobile-native-chat-image-attachment'
@@ -1,113 +0,0 @@
import { createHash, webcrypto } from 'node:crypto'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
serializeStructuredAgentSessionFingerprintPayload,
structuredAgentSessionPayloadFingerprint
} from '../../../src/shared/structured-agent-session-mutation'
import * as portableHash from '../../../src/shared/sha256'
import { mobileNativeChatImageContentFingerprint } from '../session/mobile-native-chat-image-attachment'
import { fingerprintNativeChatImage as fingerprintNativeImage } from './native-chat-image-fingerprint'
import { fingerprintNativeChatImage } from './native-chat-image-fingerprint.web'
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
describe('native chat image fingerprints', () => {
it('preserves the existing fixed image fingerprint domain', async () => {
const expected = createHash('sha256')
.update('{"fields":{"base64":"AQID"},"method":"mobile.nativeChat.image","sessionId":""}')
.digest('hex')
vi.stubGlobal('crypto', webcrypto)
expect(mobileNativeChatImageContentFingerprint('AQID')).toBe(expected)
expect(await fingerprintNativeChatImage('AQID')).toBe(expected)
})
it('keeps the selected canonical envelope, nested ordering, undefined and Unicode', () => {
const input = {
method: 'm',
sessionId: 's',
fields: {
z: undefined,
nested: { z: null, a: '\ud800' },
array: [undefined, '😀'],
'2': 'two',
'10': 'ten'
},
ignoredExtra: 'outside the envelope'
}
const canonical =
'{"fields":{"10":"ten","2":"two","array":[null,"😀"],"nested":{"a":"\\ud800","z":null}},"method":"m","sessionId":"s"}'
expect(serializeStructuredAgentSessionFingerprintPayload(input)).toBe(canonical)
expect(
serializeStructuredAgentSessionFingerprintPayload({
...input,
fields: {
'10': 'ten',
'2': 'two',
array: [undefined, '😀'],
nested: { a: '\ud800', z: null }
}
})
).toBe(canonical)
expect(structuredAgentSessionPayloadFingerprint(input)).toBe(
createHash('sha256').update(canonical).digest('hex')
)
})
it.each(['', 'AAAA', 'data:image/png;base64,AAAA', '\ud800', '日本語😀', 'AQID'.repeat(4096)])(
'keeps the existing fingerprint for %j',
async (base64) => {
vi.stubGlobal('crypto', webcrypto)
expect(await fingerprintNativeChatImage(base64)).toBe(
mobileNativeChatImageContentFingerprint(base64)
)
}
)
it('uses browser crypto without running the portable hash or encoding twice', async () => {
const base64 = 'AQID'.repeat(256 * 1024)
const expected = mobileNativeChatImageContentFingerprint(base64)
vi.stubGlobal('crypto', webcrypto)
const nativeDigest = vi.spyOn(webcrypto.subtle, 'digest')
const fallbackHash = vi.spyOn(portableHash, 'sha256')
const encode = vi.spyOn(TextEncoder.prototype, 'encode')
expect(await fingerprintNativeChatImage(base64)).toBe(expected)
expect(nativeDigest).toHaveBeenCalledExactlyOnceWith('SHA-256', expect.any(Uint8Array))
expect(fallbackHash).not.toHaveBeenCalled()
expect(encode).toHaveBeenCalledTimes(1)
})
it.each(['missing', 'insecure', 'rejected', 'throwing'])(
'keeps a successful upload fingerprint when browser crypto is %s',
async (mode) => {
const base64 = 'data:image/png;base64,AQID'
const expected = mobileNativeChatImageContentFingerprint(base64)
const digest = vi.fn(() => {
if (mode === 'throwing') {
throw new Error('crypto unavailable')
}
return Promise.reject(new Error('crypto unavailable'))
})
vi.stubGlobal(
'crypto',
mode === 'missing' ? undefined : { subtle: mode === 'insecure' ? undefined : { digest } }
)
const fallbackHash = vi.spyOn(portableHash, 'sha256')
const encode = vi.spyOn(TextEncoder.prototype, 'encode')
expect(await fingerprintNativeChatImage(base64)).toBe(expected)
expect(fallbackHash).toHaveBeenCalledTimes(1)
expect(encode).toHaveBeenCalledTimes(1)
}
)
it('keeps the native platform function synchronous and identical', () => {
expect(fingerprintNativeImage).toBe(mobileNativeChatImageContentFingerprint)
expect(fingerprintNativeImage('AAAA')).toBe(mobileNativeChatImageContentFingerprint('AAAA'))
})
})
@@ -1,27 +0,0 @@
import { serializeStructuredAgentSessionFingerprintPayload } from '../../../src/shared/structured-agent-session-mutation'
import { sha256 } from '../../../src/shared/sha256'
import { MOBILE_NATIVE_CHAT_IMAGE_FINGERPRINT_DOMAIN } from '../session/mobile-native-chat-image-attachment'
function fingerprintHex(bytes: Uint8Array): string {
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
}
export async function fingerprintNativeChatImage(base64: string): Promise<string> {
const bytes = new TextEncoder().encode(
serializeStructuredAgentSessionFingerprintPayload({
method: MOBILE_NATIVE_CHAT_IMAGE_FINGERPRINT_DOMAIN,
sessionId: '',
fields: { base64 }
})
)
const subtle = globalThis.crypto?.subtle
if (!subtle) {
return fingerprintHex(sha256(bytes))
}
try {
return fingerprintHex(new Uint8Array(await subtle.digest('SHA-256', bytes)))
} catch {
// A crypto backend failure must not discard an image the host already saved.
return fingerprintHex(sha256(bytes))
}
}
@@ -99,7 +99,6 @@ export type MobileNativeChatController = {
id?: string
path: string
previewUri: string
contentFingerprint?: string
}[]
) => Promise<MobileNativeChatSendOutcome>
/** Launch-context text still parked on the agent's TUI input line, or null.
@@ -38,77 +38,6 @@ function clientWithResponses(responses: RpcResponse[]): Pick<RpcClient, 'sendReq
}
describe('uploadMobileNativeChatImages', () => {
it('waits for each fingerprint before its callback and the next image', async () => {
const events: string[] = []
let completeFingerprint: (fingerprint: string) => void = () => {
throw new Error('fingerprint did not start')
}
let fingerprintStarted: () => void = () => {}
const started = new Promise<void>((resolve) => {
fingerprintStarted = resolve
})
const client = clientWithResponses([
methodNotFound('start-a'),
ok('save-a', '/tmp/a.png'),
methodNotFound('start-b'),
failed('save-b', 'second upload failed')
])
const pending = uploadMobileNativeChatImages('library', {
client,
getConnectionId: async () => 'original-connection',
pickImages: async function* () {
events.push('read:first')
yield { base64: 'AAAA' }
events.push('read:second')
yield { base64: 'AQID' }
},
fingerprintImage: () => {
events.push('fingerprint')
fingerprintStarted()
return new Promise<string>((resolve) => {
completeFingerprint = resolve
})
},
onImageUploaded: (image) => events.push(`uploaded:${image.contentFingerprint}`)
})
const rejection = expect(pending).rejects.toThrow('second upload failed')
await started
expect(events).toEqual(['read:first', 'fingerprint'])
expect(client.calls).toHaveLength(2)
completeFingerprint('first-fingerprint')
await rejection
expect(events).toEqual([
'read:first',
'fingerprint',
'uploaded:first-fingerprint',
'read:second'
])
expect(client.calls.map((call) => call.params)).toEqual([
{ expectedBase64Length: 4, connectionId: 'original-connection' },
{ contentBase64: 'AAAA', connectionId: 'original-connection' },
{ expectedBase64Length: 4, connectionId: 'original-connection' },
{ contentBase64: 'AQID', connectionId: 'original-connection' }
])
})
it('does not add an await between a synchronous fingerprint and its callback', async () => {
const events: string[] = []
const client = clientWithResponses([methodNotFound('start'), ok('save', '/tmp/a.png')])
await uploadMobileNativeChatImages('library', {
client,
getConnectionId: async () => null,
pickImages: () => [{ base64: 'AAAA' }],
fingerprintImage: () => {
events.push('fingerprint')
queueMicrotask(() => events.push('microtask'))
return 'synchronous-fingerprint'
},
onImageUploaded: () => events.push('callback')
})
expect(events).toEqual(['fingerprint', 'callback', 'microtask'])
})
it('uploads the picked image and returns its host path + local preview uri, without any terminal.send', async () => {
const client = clientWithResponses([
methodNotFound('start'),
@@ -124,8 +53,7 @@ describe('uploadMobileNativeChatImages', () => {
expect(result).toEqual([
{
path: '/tmp/orca-attach.png',
previewUri: 'file:///photo.jpg',
contentFingerprint: expect.stringMatching(/^[0-9a-f]{64}$/)
previewUri: 'file:///photo.jpg'
}
])
// Native chat defers the paste to submit — nothing is sent to the terminal here.
@@ -165,18 +93,15 @@ describe('uploadMobileNativeChatImages', () => {
expect(result).toEqual([
{
path: '/tmp/a.png',
previewUri: 'file:///a.jpg',
contentFingerprint: expect.stringMatching(/^[0-9a-f]{64}$/)
previewUri: 'file:///a.jpg'
},
{
path: '/tmp/b.png',
previewUri: 'file:///b.jpg',
contentFingerprint: expect.stringMatching(/^[0-9a-f]{64}$/)
previewUri: 'file:///b.jpg'
},
{
path: '/tmp/c.png',
previewUri: 'file:///c.jpg',
contentFingerprint: expect.stringMatching(/^[0-9a-f]{64}$/)
previewUri: 'file:///c.jpg'
}
])
expect(order).toEqual([
@@ -225,8 +150,7 @@ describe('uploadMobileNativeChatImages', () => {
expect(onImageUploaded).toHaveBeenCalledOnce()
expect(onImageUploaded).toHaveBeenCalledWith({
path: '/tmp/a.png',
previewUri: 'file:///a.jpg',
contentFingerprint: expect.stringMatching(/^[0-9a-f]{64}$/)
previewUri: 'file:///a.jpg'
})
})
@@ -242,8 +166,7 @@ describe('uploadMobileNativeChatImages', () => {
expect(result).toEqual([
{
path: '/tmp/x.png',
previewUri: 'data:image/png;base64,BBBB',
contentFingerprint: expect.stringMatching(/^[0-9a-f]{64}$/)
previewUri: 'data:image/png;base64,BBBB'
}
])
})
@@ -1,6 +1,5 @@
import { saveMobileClipboardImageAsTempFile } from './mobile-clipboard-image'
import type { MobileClipboardImageRpcSender } from './mobile-clipboard-image-operations'
import { structuredAgentSessionDomainFingerprint } from '../../../src/shared/structured-agent-session-mutation'
// Type-only import so this module (and its unit test) stays free of the expo/
// react-native picker chain; the concrete `pickImage` is injected by the hook.
import type { MobileImageSource, PickedMobileImage } from './mobile-image-source-picker'
@@ -12,18 +11,6 @@ export type PendingNativeChatImage = {
readonly id: string
readonly path: string
readonly previewUri: string
/** Stable across repeat uploads of the same bytes; never contains the image. */
readonly contentFingerprint?: string
}
export const MOBILE_NATIVE_CHAT_IMAGE_FINGERPRINT_DOMAIN = 'mobile.nativeChat.image'
export function mobileNativeChatImageContentFingerprint(base64: string): string {
return structuredAgentSessionDomainFingerprint({
domain: MOBILE_NATIVE_CHAT_IMAGE_FINGERPRINT_DOMAIN,
sessionId: '',
fields: { base64 }
})
}
export function appendPendingNativeChatImages(
@@ -50,7 +37,6 @@ export type UploadNativeChatImagesDeps = {
| Iterable<PickedMobileImage>
| AsyncIterable<PickedMobileImage>
| Promise<Iterable<PickedMobileImage> | AsyncIterable<PickedMobileImage>>
readonly fingerprintImage?: (base64: string) => string | Promise<string>
// Fired once the user has picked an image and the host upload is about to start —
// lets the UI show the attach spinner only for the transfer, not the picker.
readonly onUploadStart?: () => void
@@ -69,7 +55,6 @@ export async function uploadMobileNativeChatImages(
client,
getConnectionId,
pickImages,
fingerprintImage = mobileNativeChatImageContentFingerprint,
onUploadStart,
onImageUploaded
}: UploadNativeChatImagesDeps
@@ -86,11 +71,9 @@ export async function uploadMobileNativeChatImages(
// Prefer the picker's local URI for the thumbnail; fall back to an inline data
// URI when the source omitted one (RN <Image> renders both).
const previewUri = image.uri ?? `data:image/png;base64,${image.base64}`
const fingerprint = fingerprintImage(image.base64)
const result = {
path,
previewUri,
contentFingerprint: typeof fingerprint === 'string' ? fingerprint : await fingerprint
previewUri
}
uploaded.push(result)
onImageUploaded?.(result)
@@ -27,10 +27,7 @@ export const STRUCTURED_SEND_TIMEOUT_MS = 15_000
export type StructuredAgentSessionMutationCallResult<TValue> =
| { status: 'accepted'; value: TValue }
| { status: 'refused'; code: AgentSessionWireRefusalCode; message: string }
/** `hostRejectedByRequestSchema`: the host's schema turned this request away before running
* it, so the same request can never be accepted there. An auth refusal does not set it:
* it says nothing about an earlier delivery of the same id. */
| { status: 'failed'; message: string; hostRejectedByRequestSchema?: true }
| { status: 'failed'; message: string }
| { status: 'unknown' }
export type StructuredAgentSessionMutationResult<TValue> =
@@ -188,10 +185,7 @@ export async function requestStructuredAgentSessionMutation<TValue>(args: {
status: 'failed',
message: agentSessionWriteNoticeEnglish(
agentSessionWriteNoticeParts(answered, phoneWriteKind(fingerprintMethod, fields))
),
...(error instanceof AgentSessionRpcResponseError && error.code === 'invalid_argument'
? { hostRejectedByRequestSchema: true }
: {})
)
}
}
if (
@@ -1,215 +0,0 @@
// A resend past a saved record storage would not clear goes out under a fresh id. A retry of that
// text in the same app run must replay that id, never mint another: if the first resend's answer
// was lost, a second fresh id could deliver the message twice.
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
import type { RpcResponse } from '../transport/types'
import { DISPATCH_REJECTED_CANCELLED } from '../../../src/shared/structured-agent-session-dispatch-rejection'
import { sendMobileStructuredAgentSessionMessage } from './mobile-structured-agent-session-send'
import { resetMobileStructuredSendOperationJournalForTests } from './mobile-structured-send-operation-journal'
const asyncStorage = vi.hoisted(() => ({
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn()
}))
vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage }))
function ok(result: unknown): RpcResponse {
return { id: 'request-1', ok: true, result, _meta: { runtimeId: 'runtime-1' } }
}
/** Its own submission, which a Stop took back before the agent started it. */
function stoppedAnswer(clientMessageId: string): RpcResponse {
return ok({
ok: true,
replayed: false,
fence: 3,
cursor: { epoch: 'epoch-1', sequence: 1 },
value: {
clientMessageId,
submission: {
clientMessageId,
fence: 3,
payloadFingerprint: 'fingerprint',
dispatchState: 'rejected',
providerItemId: null,
reason: DISPATCH_REJECTED_CANCELLED,
submittedAt: 10,
resolvedAt: 10
}
}
})
}
function queuedAnswer(clientMessageId: string, state: 'waiting' | 'withdrawn'): RpcResponse {
return ok({
ok: true,
replayed: false,
fence: 3,
cursor: { epoch: 'epoch-1', sequence: 1 },
value: { clientMessageId, queued: { messageId: clientMessageId, position: 1, state } }
})
}
/** Each `agentSession.send` answered in turn: a lost answer, a queued draft in that state, or its
* submission stopped. */
function hostAnswering(answers: readonly ('lost' | 'waiting' | 'withdrawn' | 'stopped')[]) {
const ids: string[] = []
const deliveries: unknown[] = []
const sendRequest = vi.fn<RpcClient['sendRequest']>(async (_method, params) => {
const envelope = Object(Object(params).envelope)
const id = String(envelope.clientOperationId)
const answer = answers[ids.length]
ids.push(id)
deliveries.push(Object(params).delivery)
if (answer === 'lost' || answer === undefined) {
throw markRpcDeliveryUnknown(new Error('Connection closed'))
}
return answer === 'stopped' ? stoppedAnswer(id) : queuedAnswer(id, answer)
})
const client: RpcClient = {
sendRequest,
subscribe: vi.fn(() => vi.fn()),
updateTerminalSubscriptionViewport: () => {},
getState: () => 'connected',
getReconnectAttempt: () => 0,
getLastConnectedAt: () => null,
onStateChange: () => () => {},
notifyForeground: () => {},
close: () => {}
}
return { client, ids, deliveries }
}
function sendAgain(client: RpcClient, onError: (message: string) => void, queue = true) {
return sendMobileStructuredAgentSessionMessage({
client,
sessionId: 'session-1',
sessionKey: 'host-a:session-1',
callerIdentity: 'device-a',
expectedRuntimeFence: 3,
text: 'again',
attachments: [],
...(queue ? { delivery: 'queue-if-active' as const } : {}),
onError
})
}
describe('a resend past a saved record storage would not clear', () => {
let stored: Map<string, string>
beforeEach(() => {
vi.clearAllMocks()
resetMobileStructuredSendOperationJournalForTests()
stored = new Map()
asyncStorage.getItem.mockImplementation(async (key: string) => stored.get(key) ?? null)
asyncStorage.setItem.mockImplementation(async (key: string, value: string) => {
stored.set(key, value)
})
asyncStorage.removeItem.mockImplementation(async (key: string) => {
stored.delete(key)
})
})
it('replays its own id once storage recovers, even without the queue capability now', async () => {
const { client, ids, deliveries } = hostAnswering([
'lost',
'withdrawn',
'lost',
'withdrawn',
'waiting'
])
const onError = vi.fn()
expect(await sendAgain(client, onError)).toBe('unknown')
asyncStorage.setItem.mockRejectedValue(new Error('disk full'))
asyncStorage.removeItem.mockRejectedValue(new Error('disk full'))
expect(await sendAgain(client, onError)).toBe('unknown')
// Storage recovers: the lost send's record now clears, and the queue capability is gone.
asyncStorage.setItem.mockImplementation(async (key: string, value: string) => {
stored.set(key, value)
})
asyncStorage.removeItem.mockImplementation(async (key: string) => {
stored.delete(key)
})
expect(await sendAgain(client, onError, false)).toBe('queued')
expect(ids).toHaveLength(5)
// The resend's id is replayed, as first sent, never replaced by a fresh one.
expect(ids[4]).toBe(ids[2])
expect(deliveries[4]).toBe('queue-if-active')
})
it('replays its own id on a retry after a lost answer, and reports the record only once sent', async () => {
// Lost first send; its withdrawn replay; the fresh resend's answer lost; the retry's
// withdrawn replay; then the resend answered.
const { client, ids } = hostAnswering(['lost', 'withdrawn', 'lost', 'withdrawn', 'waiting'])
const onError = vi.fn()
const send = () =>
sendMobileStructuredAgentSessionMessage({
client,
sessionId: 'session-1',
sessionKey: 'host-a:session-1',
callerIdentity: 'device-a',
expectedRuntimeFence: 3,
text: 'again',
attachments: [],
delivery: 'queue-if-active',
onError
})
expect(await send()).toBe('unknown')
// From here the lost send's record can never be cleared.
asyncStorage.setItem.mockRejectedValue(new Error('disk full'))
asyncStorage.removeItem.mockRejectedValue(new Error('disk full'))
expect(await send()).toBe('unknown')
// An unconfirmed resend may not have gone out, so nothing says it was sent.
expect(onError).not.toHaveBeenCalled()
expect(await send()).toBe('queued')
expect(ids).toHaveLength(5)
expect(ids[1]).toBe(ids[0])
expect(ids[3]).toBe(ids[0])
expect(ids[2]).not.toBe(ids[0])
// The retry replays the resend's id rather than minting one that could deliver twice.
expect(ids[4]).toBe(ids[2])
expect(onError).toHaveBeenCalledWith(
"Sent, but this phone couldn't update its record of sent messages."
)
})
// Lost first send; its withdrawn replay; the fresh resend's answer lost; the retry's withdrawn
// replay; then that resend's own replay withdrawn too. A resend is sent once, never again.
it('sends a withdrawn replay once more at most, then hands a queued draft back', async () => {
const { client, ids } = hostAnswering(['lost', 'withdrawn', 'lost', 'withdrawn', 'withdrawn'])
const onError = vi.fn()
expect(await sendAgain(client, onError)).toBe('unknown')
asyncStorage.setItem.mockRejectedValue(new Error('disk full'))
asyncStorage.removeItem.mockRejectedValue(new Error('disk full'))
expect(await sendAgain(client, onError)).toBe('unknown')
expect(await sendAgain(client, onError)).toBe('rejected')
expect(ids).toHaveLength(5)
expect(ids[4]).toBe(ids[2])
expect(onError).toHaveBeenCalledWith('Message not sent')
})
it('reads a resend a Stop took back again as sent, since the chat draws it, not as not sent', async () => {
const { client, ids } = hostAnswering(['lost', 'stopped', 'lost', 'stopped', 'stopped'])
const onError = vi.fn()
expect(await sendAgain(client, onError, false)).toBe('unknown')
asyncStorage.setItem.mockRejectedValue(new Error('disk full'))
asyncStorage.removeItem.mockRejectedValue(new Error('disk full'))
expect(await sendAgain(client, onError, false)).toBe('unknown')
expect(await sendAgain(client, onError, false)).toBe('accepted')
expect(ids).toHaveLength(5)
expect(ids[4]).toBe(ids[2])
// Only the storage failure is reported; nothing says the message was not sent.
expect(onError.mock.calls).toEqual([
["Sent, but this phone couldn't update its record of sent messages."]
])
})
})
@@ -3,248 +3,46 @@ import {
structuredAgentSessionSendBody,
type StructuredAgentSessionAttachment
} from '../../../src/shared/structured-agent-session-send-mutation'
import {
structuredAgentSessionDomainFingerprint,
structuredAgentSessionPayloadFingerprint
} from '../../../src/shared/structured-agent-session-mutation'
import type { RpcClient } from '../transport/rpc-client'
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
import {
requestStructuredAgentSessionMutation,
timeoutForDeadline
} from './mobile-structured-agent-session-rpc'
import { structuredSessionOperationId } from './structured-session-operation-id'
import {
mobileStructuredSendDelivery,
mobileStructuredSendWithdrawnBeforeStart
} from './mobile-structured-send-delivery'
import {
bypassedMobileStructuredSendOperationId,
clearMobileStructuredSendOperation,
forgetBypassedMobileStructuredSendOperation,
getOrCreateMobileStructuredSendOperation,
mobileStructuredSendOperationKey,
rememberBypassedMobileStructuredSendOperation
} from './mobile-structured-send-operation-journal'
import { mobileStructuredSendDelivery } from './mobile-structured-send-delivery'
export async function sendMobileStructuredAgentSessionMessage(input: {
client: RpcClient
sessionId: string
sessionKey: string
callerIdentity: string
expectedRuntimeFence: number
text: string
attachments: readonly (StructuredAgentSessionAttachment & { contentFingerprint?: string })[]
attachments: readonly StructuredAgentSessionAttachment[]
/** Sent only when the host advertises `agent-session.queued-messages.v1`. */
delivery?: 'queue-if-active'
deadline?: number
onError: (message: string) => void
/** Internal: the one fresh-id resend after a withdrawn replay. */
resendingAfterWithdrawal?: true
/** Internal: that resend when the withdrawn id's record could not be cleared. It bypasses the
* record, so failing storage never blocks the send; its id is kept in memory for this app run,
* so a retry after a lost answer replays it rather than sending again. */
bypassRetainedRecord?: true
/** Internal: on that resend, the key the withdrawn record matched. The remembered id is keyed
* by it, so a capability change since the lost answer never mints another id. */
resendOperationKey?: string
}): Promise<MobileNativeChatSendOutcome> {
const timeoutMs = timeoutForDeadline(input.deadline)
if (timeoutMs === null) {
input.onError('Message not sent')
return 'rejected'
}
const requestedBody = structuredAgentSessionSendBody(input.text, input.attachments)
const requestedPayloadFingerprint = structuredAgentSessionPayloadFingerprint({
method: 'agentSession.send',
sessionId: input.sessionId,
fields: { body: requestedBody }
})
const intentFields = {
text: input.text.trimEnd(),
attachments: input.attachments.map(
(attachment) =>
attachment.contentFingerprint ??
structuredAgentSessionDomainFingerprint({
domain: 'mobile.nativeChat.image.preview',
sessionId: '',
fields: { previewUri: attachment.previewUri }
})
)
}
// `delivery` is part of the intent key, never a stored journal field: the
// immediate key is exactly today's, so an older build still reads the journal.
const operationKeyFor = (delivery: 'queue-if-active' | undefined): string =>
mobileStructuredSendOperationKey({
sessionKey: input.sessionKey,
intentFingerprint: structuredAgentSessionDomainFingerprint({
domain: 'mobile.agentSession.send.intent',
sessionId: input.sessionKey,
fields: delivery ? { ...intentFields, delivery } : intentFields
})
})
const queuedOperationKey = operationKeyFor('queue-if-active')
const immediateOperationKey = operationKeyFor(undefined)
const requestedOperationKey = input.delivery ? queuedOperationKey : immediateOperationKey
const attachmentPaths = input.attachments.map((attachment) => attachment.path)
const resendKey = input.resendOperationKey ?? requestedOperationKey
let operation: Awaited<ReturnType<typeof getOrCreateMobileStructuredSendOperation>>
try {
if (input.bypassRetainedRecord) {
operation = bypassOperation(resendKey, requestedPayloadFingerprint, attachmentPaths)
} else if (
input.resendOperationKey !== undefined &&
bypassedMobileStructuredSendOperationId(resendKey) !== undefined
) {
// Storage recovered after a bypassed resend's answer was lost: that id is replayed, never
// replaced by a fresh one that could deliver the message twice.
operation = await adoptBypassedOperation({
operationKey: resendKey,
callerIdentity: input.callerIdentity,
payloadFingerprint: requestedPayloadFingerprint,
attachmentPaths
})
} else {
operation = await getOrCreateMobileStructuredSendOperation({
operationKey: requestedOperationKey,
// A retained id replays exactly as first sent, whatever the capability says now;
// a host that refuses that request shape retires it, so the next send goes out fresh.
alternateOperationKey: input.delivery ? immediateOperationKey : queuedOperationKey,
callerIdentity: input.callerIdentity,
payloadFingerprint: requestedPayloadFingerprint,
attachmentPaths,
createOperationId: structuredSessionOperationId
})
}
} catch {
input.onError('Message not sent')
return 'rejected'
}
const operationKey = operation.operationKey
const delivery = operationKey === queuedOperationKey ? 'queue-if-active' : undefined
const body = structuredAgentSessionSendBody(
input.text,
operation.attachmentPaths.map((path) => ({ path, previewUri: '' }))
)
const payloadFingerprint = structuredAgentSessionPayloadFingerprint({
method: 'agentSession.send',
sessionId: input.sessionId,
fields: { body }
})
if (payloadFingerprint !== operation.payloadFingerprint) {
input.onError('Message not sent')
return 'rejected'
}
// Each Send is a new action; the shared mutation sender mints its operation id once.
const result = await requestStructuredAgentSessionMutation<AgentSessionSendResult>({
client: input.client,
method: 'agentSession.send',
fingerprintMethod: 'agentSession.send',
sessionId: input.sessionId,
expectedRuntimeFence: input.expectedRuntimeFence,
// `delivery` joins the wire fields — and so the operation fingerprint — but
// never the journal's body-only fingerprint the submission echo recomputes.
fields: { body, ...(delivery ? { delivery } : {}) },
clientOperationId: operation.operationId,
fields: {
body: structuredAgentSessionSendBody(input.text, input.attachments),
...(input.delivery ? { delivery: input.delivery } : {})
},
timeoutMs
})
const outcome = mobileStructuredSendDelivery(result, operation.retained)
if (input.bypassRetainedRecord && outcome.operationIdSpent) {
forgetBypassedMobileStructuredSendOperation(operationKey, operation.operationId)
}
let released = false
if (outcome.operationIdSpent) {
try {
await clearMobileStructuredSendOperation({
operationKey,
operationId: operation.operationId
})
released = true
} catch {
// A retained settled id can suppress a later identical send, never
// duplicate this one; the next replay gets another clear chance.
}
}
const withdrawnReplay =
(result.status === 'accepted' &&
'queued' in result.value &&
result.value.queued?.state === 'withdrawn') ||
(operation.retained && mobileStructuredSendWithdrawnBeforeStart(result))
if (withdrawnReplay && operation.retained && !input.resendingAfterWithdrawal) {
// The retained id's draft or submission was withdrawn, so it never reached the agent:
// this identical message is a new one, not a replay to swallow. A record
// storage would not clear is bookkeeping: it is reported, never allowed to
// block the send.
const resent = await sendMobileStructuredAgentSessionMessage({
...input,
resendingAfterWithdrawal: true,
resendOperationKey: operationKey,
...(released ? {} : { bypassRetainedRecord: true as const })
})
// Only when the resend is known to have gone out; an unconfirmed one may not have.
if (!released && (resent === 'accepted' || resent === 'queued')) {
input.onError("Sent, but this phone couldn't update its record of sent messages.")
}
return resent
}
if (withdrawnReplay && mobileStructuredSendWithdrawnBeforeStart(result)) {
// Not resent, but the chat draws it with its stop row: handing it back too would show it twice.
return 'accepted'
}
if (withdrawnReplay) {
// Not resent: no card and no bubble holds the text, so it goes back to the
// composer rather than vanishing.
input.onError('Message not sent')
return 'rejected'
}
const outcome = mobileStructuredSendDelivery(result)
if (outcome.error !== null) {
input.onError(outcome.error)
}
return outcome.outcome
}
/** The id a resend past an uncleared record goes out under: the one this app run already used
* for this text, replayed, or a fresh one remembered for the next retry. */
function bypassOperation(
operationKey: string,
payloadFingerprint: string,
attachmentPaths: string[]
): Awaited<ReturnType<typeof getOrCreateMobileStructuredSendOperation>> {
const bypassed = bypassedMobileStructuredSendOperationId(operationKey)
const operationId = bypassed ?? structuredSessionOperationId()
if (bypassed === undefined) {
rememberBypassedMobileStructuredSendOperation(operationKey, operationId)
}
return {
operationKey,
operationId,
retained: bypassed !== undefined,
payloadFingerprint,
attachmentPaths
}
}
/** A remembered bypassed id handed back to the saved record once storage works again: the record
* replays it, and memory lets it go. Should storage fail again, memory keeps replaying it. */
async function adoptBypassedOperation(input: {
operationKey: string
callerIdentity: string
payloadFingerprint: string
attachmentPaths: string[]
}): Promise<Awaited<ReturnType<typeof getOrCreateMobileStructuredSendOperation>>> {
const operationId = bypassedMobileStructuredSendOperationId(input.operationKey)
if (operationId === undefined) {
throw new Error('No bypassed send id to adopt')
}
try {
const recorded = await getOrCreateMobileStructuredSendOperation({
...input,
createOperationId: () => operationId
})
if (recorded.operationId === operationId) {
forgetBypassedMobileStructuredSendOperation(input.operationKey, operationId)
}
return { ...recorded, retained: true }
} catch {
return bypassOperation(input.operationKey, input.payloadFingerprint, input.attachmentPaths)
}
}
@@ -6,8 +6,6 @@ import { mobileStructuredSendDelivery } from './mobile-structured-send-delivery'
import type { StructuredAgentSessionMutationCallResult } from './mobile-structured-agent-session-rpc'
import { structuredSendResultFixture } from './structured-agent-send-result.test-fixture'
const WITHDRAWN_SENTENCE = 'This message was withdrawn before the agent started it.'
function accepted(
dispatchState: AgentJournalDispatchState,
reason: string | null = null
@@ -16,291 +14,147 @@ function accepted(
}
describe('mobileStructuredSendDelivery', () => {
it('keeps the operation id for every unknown, host-recorded or ack-lost', () => {
// The one answer that may be a delivery. Spending the id here turns the next
// identical send into a second copy in front of the model.
it('reports transport and host uncertainty on this send', () => {
expect(mobileStructuredSendDelivery({ status: 'unknown' })).toEqual({
outcome: 'unknown',
operationIdSpent: false,
error: null
})
expect(mobileStructuredSendDelivery(accepted('unknown'))).toEqual({
outcome: 'unknown',
operationIdSpent: false,
error: null
})
})
it('reports a written send as sent and spends its id', () => {
// `pending` is written and awaiting the provider's acknowledgement — not doubt.
for (const dispatchState of ['accepted', 'pending'] as const) {
expect(mobileStructuredSendDelivery(accepted(dispatchState))).toEqual({
outcome: 'accepted',
operationIdSpent: true,
error: null
})
}
it.each(['accepted', 'pending'] as const)('reports a written %s send as accepted', (state) => {
expect(mobileStructuredSendDelivery(accepted(state))).toEqual({
outcome: 'accepted',
error: null
})
})
it('classifies a queued draft answer as spent, card-rendered, never a bubble', () => {
// The host holds the message now; a later identical send is a new message.
// A dispatched draft answers as `queued` only when the host lost its
// submission, so no echo would retire a bubble: it too shows nothing.
for (const state of ['waiting', 'dispatched', 'returned', 'withdrawn'] as const) {
const queued: StructuredAgentSessionMutationCallResult<AgentSessionSendResult> = {
it.each(['waiting', 'dispatched', 'returned'] as const)(
'lets the host own its %s queued message without an optimistic bubble',
(state) => {
expect(
mobileStructuredSendDelivery({
status: 'accepted',
value: {
clientMessageId: 'client-1',
queued: { messageId: 'client-1', position: 1, state }
}
})
).toEqual({ outcome: 'queued', error: null })
}
)
it('hands back a withdrawn card instead of silently sending again', () => {
expect(
mobileStructuredSendDelivery({
status: 'accepted',
value: {
clientMessageId: 'client-1',
queued: { messageId: 'client-1', position: 1, state }
queued: { messageId: 'client-1', position: 1, state: 'withdrawn' }
}
}
const outcome = 'queued'
expect(mobileStructuredSendDelivery(queued)).toEqual({
outcome,
operationIdSpent: true,
error: null
})
expect(mobileStructuredSendDelivery(queued, true)).toEqual({
outcome,
operationIdSpent: true,
error: null
})
}
).toEqual({ outcome: 'rejected', error: 'Message not sent' })
})
it("reads a replay answered by its draft's hand-off as unconfirmed, and spends the id", () => {
// The hand-off names the replayed id as its draft: the host's answer states the link, so the
// id is spent without waiting for a stream that may never carry the hand-off.
const handedOff = structuredSendResultFixture('accepted')
if (!('submission' in handedOff)) {
it('keeps a handed-off queued message unconfirmed until the stream identifies it', () => {
const value = structuredSendResultFixture('accepted')
if (!('submission' in value)) {
throw new Error('expected a submission answer')
}
const replay: StructuredAgentSessionMutationCallResult<AgentSessionSendResult> = {
status: 'accepted',
value: {
clientMessageId: 'retained-draft-id',
submission: {
...handedOff.submission,
clientMessageId: 'fresh-id',
queuedMessageId: 'retained-draft-id'
expect(
mobileStructuredSendDelivery({
status: 'accepted',
value: {
clientMessageId: 'draft-id',
submission: {
...value.submission,
clientMessageId: 'fresh-id',
queuedMessageId: 'draft-id'
}
}
}
}
expect(mobileStructuredSendDelivery(replay, true)).toEqual({
outcome: 'unknown',
operationIdSpent: true,
error: null
})
})
it('never spends a retained id on a malformed answer with no submission and no id', () => {
const malformed: StructuredAgentSessionMutationCallResult<AgentSessionSendResult> = {
status: 'accepted',
value: JSON.parse('{}')
}
expect(mobileStructuredSendDelivery(malformed, true)).toEqual({
outcome: 'unknown',
operationIdSpent: false,
error: null
})
})
it('does not report a retained payload replay as a new accepted send', () => {
for (const dispatchState of ['accepted', 'pending'] as const) {
expect(mobileStructuredSendDelivery(accepted(dispatchState), true)).toEqual({
outcome: 'unknown',
operationIdSpent: false,
error: null
})
}
).toEqual({ outcome: 'unknown', error: null })
})
it('spends the id of a rejection and withholds its internal reason', () => {
// Provably undelivered and terminal, so the id can only replay it: spending the
// id makes the retry a first delivery. The marker itself names nothing a person
// can act on, so it must not reach the screen.
it('fails closed when a malformed answer has no submission', () => {
expect(mobileStructuredSendDelivery({ status: 'accepted', value: JSON.parse('{}') })).toEqual({
outcome: 'unknown',
error: null
})
})
it('withholds internal provider write reasons', () => {
expect(
mobileStructuredSendDelivery(accepted('rejected', 'provider_write_failed: broken pipe'))
).toEqual({
outcome: 'rejected',
operationIdSpent: true,
error: "Orca couldn't reach the agent. Your message was not sent. Send it again."
})
})
it('answers a send the host kept as a card like a queued one, first send or replay', () => {
// The card shows the text, so neither an error nor a composer hand-back may repeat it.
const kept: StructuredAgentSessionMutationCallResult<AgentSessionSendResult> = {
status: 'accepted',
value: {
clientMessageId: 'msg-1',
submission: {
clientMessageId: 'msg-1',
fence: 3,
payloadFingerprint: 'fingerprint',
dispatchState: 'rejected',
providerItemId: null,
reason: 'Orca restarted before this was sent.',
submittedAt: 10,
resolvedAt: 10,
keptAsQueuedMessageId: 'msg-1'
}
}
it('lets a kept queued card hold the text after a provider rejection', () => {
const value = structuredSendResultFixture('rejected', 'Orca restarted before this was sent.')
if (!('submission' in value)) {
throw new Error('expected a submission answer')
}
for (const retained of [false, true]) {
expect(mobileStructuredSendDelivery(kept, retained)).toEqual({
outcome: 'queued',
operationIdSpent: true,
error: null
})
}
})
// The chat draws a send a Stop took back with its stop row, so it never goes back to the draft. A
// retained replay is resent by the caller, which needs the id spent and no words of its own.
it.each([
{ retained: false, reason: DISPATCH_REJECTED_CANCELLED, fact: false, outcome: 'accepted' },
{ retained: false, reason: WITHDRAWN_SENTENCE, fact: true, outcome: 'accepted' },
{ retained: true, reason: DISPATCH_REJECTED_CANCELLED, fact: false, outcome: 'rejected' },
{ retained: true, reason: WITHDRAWN_SENTENCE, fact: true, outcome: 'rejected' }
] as const)(
'reads a send a Stop took back (retained $retained, typed fact $fact) as $outcome, spent, wordless',
({ retained, reason, fact, outcome }) => {
const value = structuredSendResultFixture('rejected', reason)
if (fact && 'submission' in value) {
value.submission.rejection = { kind: 'cancelled' }
}
expect(mobileStructuredSendDelivery({ status: 'accepted', value }, retained)).toEqual({
outcome,
operationIdSpent: true,
error: null
})
}
)
it('shows a provider content rejection verbatim', () => {
expect(
mobileStructuredSendDelivery(accepted('rejected', 'Claude does not support .bmp'))
).toEqual({
outcome: 'rejected',
operationIdSpent: true,
error: 'Claude does not support .bmp'
value.submission.keptAsQueuedMessageId = 'card-id'
expect(mobileStructuredSendDelivery({ status: 'accepted', value })).toEqual({
outcome: 'queued',
error: null
})
})
it('spends only refusals that prove the operation is settled', () => {
it.each([
{ reason: DISPATCH_REJECTED_CANCELLED, typed: false },
{ reason: 'This message was withdrawn before the agent started it.', typed: true }
])('leaves a stopped submission in its existing chat row (typed $typed)', ({ reason, typed }) => {
const value = structuredSendResultFixture('rejected', reason)
if (typed && 'submission' in value) {
value.submission.rejection = { kind: 'cancelled' }
}
expect(mobileStructuredSendDelivery({ status: 'accepted', value })).toEqual({
outcome: 'accepted',
error: null
})
})
it('shows an actionable content rejection', () => {
expect(
mobileStructuredSendDelivery({
status: 'refused',
code: 'agent_session_operation_invalid',
message: 'Invalid operation'
})
).toEqual({ outcome: 'rejected', operationIdSpent: true, error: 'Invalid operation' })
expect(
mobileStructuredSendDelivery({
status: 'refused',
code: 'agent_session_checkpoint_stale',
message: 'Fence moved'
})
).toEqual({ outcome: 'rejected', operationIdSpent: false, error: 'Fence moved' })
mobileStructuredSendDelivery(accepted('rejected', 'Claude does not support .bmp'))
).toEqual({ outcome: 'rejected', error: 'Claude does not support .bmp' })
})
it.each([
['agent_session_operation_invalid', 'Invalid operation'],
['agent_session_checkpoint_stale', 'Fence moved'],
['agent_session_operation_expired', 'Operation expired'],
['agent_session_operation_conflict', 'Operation conflict']
] as const)('reports the current %s refusal', (code, message) => {
expect(mobileStructuredSendDelivery({ status: 'refused', code, message })).toEqual({
outcome: 'rejected',
error: message
})
})
it('preserves an unknown operation refusal as uncertainty', () => {
expect(
mobileStructuredSendDelivery({
status: 'refused',
code: 'agent_session_operation_unknown',
message: 'Outcome unknown'
})
).toEqual({ outcome: 'unknown', operationIdSpent: false, error: null })
).toEqual({ outcome: 'unknown', error: null })
})
it('reports a request failure without consulting earlier sends', () => {
expect(
mobileStructuredSendDelivery({
status: 'failed',
message: 'Your message was not sent. Send it again.'
})
).toEqual({
outcome: 'rejected',
operationIdSpent: true,
error: 'Your message was not sent. Send it again.'
})
})
it('spends an ambiguous id the host has expired, and says to check the chat', () => {
// The host refuses an expired id on every replay; keeping it would refuse this text forever.
// The earlier attempt may still be in the chat, so the words never say it was not sent.
expect(
mobileStructuredSendDelivery(
{
status: 'refused',
code: 'agent_session_operation_expired',
message: 'Operation expired'
},
true
)
).toEqual({
outcome: 'rejected',
operationIdSpent: true,
error:
"Orca couldn't confirm your message reached the agent. Check the chat, then send it again if needed."
})
})
it('never releases an ambiguous id on any other later RPC refusal or failure', () => {
expect(
mobileStructuredSendDelivery(
{
status: 'refused',
code: 'agent_session_operation_conflict',
message: 'Operation conflict'
},
true
)
).toEqual({ outcome: 'rejected', operationIdSpent: false, error: 'Operation conflict' })
expect(
mobileStructuredSendDelivery(
{ status: 'failed', message: 'Your message was not sent. Send it again.' },
true
)
).toEqual({
outcome: 'rejected',
operationIdSpent: false,
error: 'Your message was not sent. Send it again.'
})
})
it('releases a replay only when the host refuses its request shape itself', () => {
// An older host's strict schema refuses `delivery` before it runs anything:
// that replay can never be accepted, so keeping the id refuses the text forever.
expect(
mobileStructuredSendDelivery(
{
status: 'failed',
message: 'Your message was not sent. Send it again.',
hostRejectedByRequestSchema: true
},
true
)
).toEqual({
outcome: 'rejected',
operationIdSpent: true,
error: 'Your message was not sent. Send it again.'
})
// Any other refusal (an auth failure, a host without the method) proves nothing
// about an earlier delivery of this id.
expect(
mobileStructuredSendDelivery(
{ status: 'failed', message: 'Your message was not sent.' },
true
)
).toMatchObject({ operationIdSpent: false })
})
it('fails closed when an invalid host response omits the required submission', () => {
const result = {
status: 'accepted',
value: { clientMessageId: 'msg-1' }
} as unknown as StructuredAgentSessionMutationCallResult<AgentSessionSendResult>
expect(mobileStructuredSendDelivery(result)).toEqual({
outcome: 'unknown',
operationIdSpent: false,
error: null
})
).toEqual({ outcome: 'rejected', error: 'Your message was not sent. Send it again.' })
})
})
@@ -1,40 +1,5 @@
// What one `agentSession.send` answer means to a client with no outbox.
//
// The desktop reads the same four dispatch states through
// `disposeStructuredAgentSessionSendResult`; mobile has no queue to move, so it
// needs only two facts: the outcome to report, and whether the operation id it
// sent under is spent.
//
// The id is the whole safety mechanism here. Mobile keys its retained ids by
// message body, so re-sending the same text reuses the id — and one id is one
// delivery: `performSend` answers a second request under a recorded id from the
// ledger and never puts it back on the wire. Releasing the id turns that replay
// into a genuine second delivery, which is why only a settled answer releases it:
//
// accepted/pending — the send happened. The id is spent; a later identical
// message is a new message and must carry a new id.
// withdrawn — a submission a Stop took back before the agent started it is
// drawn in the chat with its stop row, so it spends the id and goes back to no
// draft. Answering a first send, it is that message: sent, then stopped. As a
// retained replay it cannot be told from a new send of the same text, and it
// provably never ran, so the caller sends it again under a fresh id.
// rejected — a terminal refusal or rejected submission spends a fresh id. A
// pending-admission refusal, or any refusal after earlier transport doubt,
// keeps it because neither proves a retained delivery did not happen. Two
// exceptions spend it anyway, because the host can never accept the replay
// and keeping the id would only refuse every later send of the same text:
// a host that refuses the replay's request shape itself (an older host's
// strict schema turning `delivery` away), and an id the host has expired.
// A rejection the host kept as a card answers as `queued`: the card holds the text.
// unknown — the one answer that KEEPS its id, whether it came from the host or
// from an ack-loss on the way back. The message may be with the provider, so
// the retry has to stay a replay. Rotating here is what sent one message to a
// model five times.
import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types'
import type { AgentSessionSendResult } from '../../../src/shared/agent-session-wire'
import { agentSessionRefusalOperationState } from '../../../src/shared/agent-session-refusal-retry'
import { agentSessionWriteNoticeEnglish } from '../../../src/shared/agent-session-refusal-notice'
import { structuredAgentSessionRejectionNotice } from '../../../src/shared/structured-agent-session-rejection-words'
import { dispatchWasWithdrawn } from '../../../src/shared/structured-agent-session-dispatch-rejection'
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
@@ -42,103 +7,47 @@ import type { StructuredAgentSessionMutationCallResult } from './mobile-structur
export type MobileStructuredSendDelivery = {
outcome: MobileNativeChatSendOutcome
/** True when a retry is safe under a fresh operation id. */
operationIdSpent: boolean
/** Copy for the user, or null when the outcome needs none. */
error: string | null
}
/** Whether a send answer is its own submission, which a Stop took back before the agent started it. */
export function mobileStructuredSendWithdrawnBeforeStart(
result: StructuredAgentSessionMutationCallResult<AgentSessionSendResult>
): boolean {
if (result.status !== 'accepted' || !('submission' in result.value)) {
return false
}
const { submission } = result.value
return (
submission.queuedMessageId === undefined &&
submission.dispatchState === 'rejected' &&
dispatchWasWithdrawn(submission)
)
}
export function mobileStructuredSendDelivery(
result: StructuredAgentSessionMutationCallResult<AgentSessionSendResult>,
retained = false
result: StructuredAgentSessionMutationCallResult<AgentSessionSendResult>
): MobileStructuredSendDelivery {
if (result.status === 'unknown') {
return { outcome: 'unknown', operationIdSpent: false, error: null }
return { outcome: 'unknown', error: null }
}
if (result.status === 'refused') {
const refusalState = agentSessionRefusalOperationState(result.code)
if (refusalState === 'unknown') {
return { outcome: 'unknown', operationIdSpent: false, error: null }
}
if (retained && result.code === 'agent_session_operation_expired') {
// The host refuses this id for good once its day is up, so keeping it would refuse this text
// forever. The earlier attempt may already be in the chat, so the words say to check first.
return {
outcome: 'rejected',
operationIdSpent: true,
error: agentSessionWriteNoticeEnglish(['sendOutcomeLost'])
}
}
return {
outcome: 'rejected',
operationIdSpent: refusalState === 'settled-rejected' && !retained,
error: result.message
}
return agentSessionRefusalOperationState(result.code) === 'unknown'
? { outcome: 'unknown', error: null }
: { outcome: 'rejected', error: result.message }
}
if (result.status !== 'accepted') {
return {
outcome: 'rejected',
operationIdSpent: !retained || result.hostRejectedByRequestSchema === true,
error: result.message
}
return { outcome: 'rejected', error: result.message }
}
if ('queued' in result.value && result.value.queued) {
// The host holds (or already settled) the draft: the send is spent — a
// later identical message is a new message. A withdrawn replay is spent
// too, never unknown: its card was deleted or carried by a /clear, and the
// caller resends it or hands the text back. A dispatched draft answers here
// only when the host could not find the submission it became (a live one
// answers with that submission), so no echo would retire an optimistic
// bubble: it shows nothing, and the transcript or the card owns the text.
return { outcome: 'queued', operationIdSpent: true, error: null }
}
const submission: AgentJournalSubmission | undefined =
'submission' in result.value ? result.value.submission : undefined
if (submission !== undefined && submission.queuedMessageId === result.value.clientMessageId) {
// The host says this id's queued draft was handed off as that submission: the send reached
// it, so the id is spent now, not when a stream that may never carry the hand-off shows it.
// Which send the phone meant stays unconfirmed, as for any retained replay of a live send.
return { outcome: 'unknown', operationIdSpent: true, error: null }
return result.value.queued.state === 'withdrawn'
? { outcome: 'rejected', error: 'Message not sent' }
: { outcome: 'queued', error: null }
}
const submission = 'submission' in result.value ? result.value.submission : undefined
if (!submission || submission.dispatchState === 'unknown') {
return { outcome: 'unknown', operationIdSpent: false, error: null }
return { outcome: 'unknown', error: null }
}
if (submission.dispatchState === 'rejected' && submission.keptAsQueuedMessageId !== undefined) {
// The host kept it as a card, which holds the text: no error, and nothing handed back to the
// composer, so the words never show twice.
return { outcome: 'queued', operationIdSpent: true, error: null }
}
if (mobileStructuredSendWithdrawnBeforeStart(result)) {
// The chat draws it with its stop row, so it is never handed back to the composer as well. A
// retained replay is resent by the caller.
return { outcome: retained ? 'rejected' : 'accepted', operationIdSpent: true, error: null }
if (submission.queuedMessageId === result.value.clientMessageId) {
return { outcome: 'unknown', error: null }
}
if (submission.dispatchState === 'rejected') {
if (submission.keptAsQueuedMessageId !== undefined) {
return { outcome: 'queued', error: null }
}
if (submission.queuedMessageId === undefined && dispatchWasWithdrawn(submission)) {
// The host transcript already owns this send's stopped row.
return { outcome: 'accepted', error: null }
}
return {
outcome: 'rejected',
operationIdSpent: true,
error: structuredAgentSessionRejectionNotice(submission.reason, 'composer-send')
}
}
if (retained) {
// A payload match cannot distinguish retrying the ambiguous action from a
// later identical intent. Wait for the stream to settle and release it.
return { outcome: 'unknown', operationIdSpent: false, error: null }
}
return { outcome: 'accepted', operationIdSpent: true, error: null }
return { outcome: 'accepted', error: null }
}
@@ -0,0 +1,97 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { fieldsOf } from './use-mobile-structured-agent-session-queued.test-fixture'
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
import { sendMobileStructuredAgentSessionMessage } from './mobile-structured-agent-session-send'
const storage = vi.hoisted(() => ({ getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() }))
vi.mock('@react-native-async-storage/async-storage', () => ({ default: storage }))
describe('a new phone send after an acknowledgement was lost', () => {
beforeEach(() => {
vi.clearAllMocks()
const saved = new Map<string, string>()
storage.getItem.mockImplementation(async (key: string) => saved.get(key) ?? null)
storage.setItem.mockImplementation(async (key: string, value: string) => saved.set(key, value))
storage.removeItem.mockImplementation(async (key: string) => saved.delete(key))
})
it.each([false, true])(
'delivers identical text as a new action while the old host entry stays unknown (relaunch %s)',
async (relaunch) => {
const ids: string[] = []
const delivered: string[] = []
const ledger = new Set<string>()
const sendRequest = vi.fn<RpcClient['sendRequest']>(async (_method, params) => {
const id = String(fieldsOf(fieldsOf(params).envelope).clientOperationId)
const replayed = ledger.has(id)
ids.push(id)
if (!replayed) {
ledger.add(id)
delivered.push(id)
if (delivered.length === 1) {
throw markRpcDeliveryUnknown(new Error('Connection closed after dispatch'))
}
}
return {
id: 'response',
ok: true,
result: {
ok: true,
replayed,
fence: 3,
cursor: { epoch: 'epoch', sequence: 1 },
value: {
clientMessageId: id,
submission: {
clientMessageId: id,
fence: 3,
payloadFingerprint: String(fieldsOf(fieldsOf(params).envelope).payloadFingerprint),
dispatchState: id === delivered[0] ? 'unknown' : 'accepted',
providerItemId: null,
reason: null,
submittedAt: Date.now(),
resolvedAt: null
}
}
}
}
})
const clientFor = (): RpcClient => ({
sendRequest,
subscribe: vi.fn(() => vi.fn()),
updateTerminalSubscriptionViewport: () => {},
getState: () => 'connected',
getReconnectAttempt: () => 0,
getLastConnectedAt: () => null,
onStateChange: () => () => {},
notifyForeground: () => {},
close: () => {}
})
let client = clientFor()
const onError = vi.fn()
const message = {
sessionId: 'session',
sessionKey: 'remote-host:folder:session',
callerIdentity: 'phone',
expectedRuntimeFence: 3,
text: 'please continue',
attachments: [],
onError
}
const send = () => sendMobileStructuredAgentSessionMessage({ ...message, client })
expect(await send()).toBe('unknown')
if (relaunch) {
client = clientFor()
}
expect(await send()).toBe('accepted')
expect(delivered).toHaveLength(2)
expect(new Set(ids).size).toBe(2)
expect(onError).not.toHaveBeenCalled()
expect(storage.getItem).not.toHaveBeenCalled()
expect(storage.setItem).not.toHaveBeenCalled()
}
)
})
@@ -1,450 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AGENT_SESSION_MAX_OPERATION_REPLAY_AGE_MS } from '../../../src/shared/agent-session-host-authority'
import { AGENT_SESSION_DURABLE_OPERATION_GLOBAL_LIMIT } from '../../../src/shared/agent-session-operation-ledger'
const asyncStorage = vi.hoisted(() => ({
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn()
}))
vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage }))
import {
clearMobileStructuredSendOperation,
clearMobileStructuredSettledSendOperations,
getOrCreateMobileStructuredSendOperation as getOrCreatePersistedOperation,
mobileStructuredSendCallerFingerprint,
mobileStructuredSendOperationKey,
resetMobileStructuredSendOperationJournalForTests
} from './mobile-structured-send-operation-journal'
import { readMirroredStorage } from '../storage/mirrored-storage-keys'
const NOW = 1_900_000_000_000
/** The key the journal persists under, which the hybrid shell mirrors into every `init`. */
const JOURNAL_KEY = 'orca:mobileStructuredSendOperations:v1'
const OPERATION_KEY = 'a'.repeat(64)
const CALLER_IDENTITY = 'mobile-device-a'
function getOrCreateMobileStructuredSendOperation(
input: Omit<
Parameters<typeof getOrCreatePersistedOperation>[0],
'callerIdentity' | 'payloadFingerprint' | 'attachmentPaths'
> & { payloadFingerprint?: string; attachmentPaths?: readonly string[] }
) {
const { payloadFingerprint = 'b'.repeat(64), attachmentPaths = [], ...operation } = input
return getOrCreatePersistedOperation({
...operation,
callerIdentity: CALLER_IDENTITY,
payloadFingerprint,
attachmentPaths
}).then(({ operationId, retained }) => ({ operationId, retained }))
}
function operationIdAt(timestamp: number, entropy: string): string {
return `${timestamp}-${entropy.repeat(32).slice(0, 32)}`
}
describe('mobile structured send operation journal', () => {
let values: Map<string, string>
beforeEach(() => {
vi.clearAllMocks()
resetMobileStructuredSendOperationJournalForTests()
values = new Map()
asyncStorage.getItem.mockImplementation(async (key: string) => values.get(key) ?? null)
asyncStorage.setItem.mockImplementation(async (key: string, value: string) => {
values.set(key, value)
})
asyncStorage.removeItem.mockImplementation(async (key: string) => {
values.delete(key)
})
})
it('persists an ambiguous id before dispatch and reuses it after remount', async () => {
const firstId = operationIdAt(NOW, '1')
await expect(
getOrCreateMobileStructuredSendOperation({
operationKey: OPERATION_KEY,
createOperationId: () => firstId,
now: NOW
})
).resolves.toEqual({ operationId: firstId, retained: false })
resetMobileStructuredSendOperationJournalForTests()
const createAfterRemount = vi.fn(() => operationIdAt(NOW, '2'))
await expect(
getOrCreateMobileStructuredSendOperation({
operationKey: OPERATION_KEY,
createOperationId: createAfterRemount,
now: NOW
})
).resolves.toEqual({ operationId: firstId, retained: true })
expect(createAfterRemount).not.toHaveBeenCalled()
})
/**
* A mirror the page reads is not allowed to run ahead of the store (round 4, CodeRabbit).
*
* The hybrid shell builds `init` from the mirror synchronously, so the page is handed whatever
* was noted here. Noting the write before it is persisted is what keeps an `init` in the same
* turn current; keeping the note after the persist was refused publishes a journal that does
* not exist, and the page resumes operations the device never wrote down.
*/
it('rolls the mirror back when persisting an added entry fails', async () => {
await getOrCreateMobileStructuredSendOperation({
operationKey: OPERATION_KEY,
createOperationId: () => operationIdAt(NOW, '8'),
now: NOW
})
const held = readMirroredStorage([JOURNAL_KEY])[JOURNAL_KEY]
asyncStorage.setItem.mockRejectedValueOnce(new Error('the store is full'))
await expect(
getOrCreateMobileStructuredSendOperation({
operationKey: 'c'.repeat(64),
createOperationId: () => operationIdAt(NOW, '9'),
now: NOW
})
).rejects.toThrow('the store is full')
expect(readMirroredStorage([JOURNAL_KEY])[JOURNAL_KEY]).toBe(held)
})
it('rolls the mirror back when persisting the last clear fails', async () => {
const firstId = operationIdAt(NOW, 'a')
await getOrCreateMobileStructuredSendOperation({
operationKey: OPERATION_KEY,
createOperationId: () => firstId,
now: NOW
})
const held = readMirroredStorage([JOURNAL_KEY])[JOURNAL_KEY]
asyncStorage.removeItem.mockRejectedValueOnce(new Error('the store is full'))
await expect(
clearMobileStructuredSendOperation({ operationKey: OPERATION_KEY, operationId: firstId })
).rejects.toThrow('the store is full')
expect(readMirroredStorage([JOURNAL_KEY])[JOURNAL_KEY]).toBe(held)
})
it('clears only the exact settled operation', async () => {
const firstId = operationIdAt(NOW, '3')
await getOrCreateMobileStructuredSendOperation({
operationKey: OPERATION_KEY,
createOperationId: () => firstId,
now: NOW
})
await expect(
clearMobileStructuredSendOperation({
operationKey: OPERATION_KEY,
operationId: operationIdAt(NOW, '4')
})
).rejects.toThrow('identity changed')
await clearMobileStructuredSendOperation({
operationKey: OPERATION_KEY,
operationId: firstId
})
const secondId = operationIdAt(NOW, '5')
await expect(
getOrCreateMobileStructuredSendOperation({
operationKey: OPERATION_KEY,
createOperationId: () => secondId,
now: NOW
})
).resolves.toEqual({ operationId: secondId, retained: false })
})
it('clears an ack-lost id only when its journal submission settles', async () => {
const sessionKey = 'host-a:session-a'
const payloadFingerprint = 'c'.repeat(64)
const operationKey = mobileStructuredSendOperationKey({
sessionKey,
intentFingerprint: payloadFingerprint
})
const operationId = operationIdAt(NOW, 'd')
await getOrCreateMobileStructuredSendOperation({
operationKey,
payloadFingerprint,
createOperationId: () => operationId,
now: NOW
})
const submission = {
clientMessageId: operationId,
fence: 1,
payloadFingerprint,
dispatchState: 'unknown' as const,
providerItemId: null,
reason: 'ack lost',
submittedAt: NOW,
resolvedAt: NOW
}
await clearMobileStructuredSettledSendOperations({ submissions: [submission] })
expect(values.size).toBe(1)
await clearMobileStructuredSettledSendOperations({
submissions: [{ ...submission, dispatchState: 'accepted', reason: null }]
})
expect(values.size).toBe(0)
})
it("clears an id a draft hand-off names, whatever that hand-off's state", async () => {
const sessionKey = 'host-a:session-a'
const payloadFingerprint = '9'.repeat(64)
const operationKey = mobileStructuredSendOperationKey({
sessionKey,
intentFingerprint: payloadFingerprint
})
const operationId = operationIdAt(NOW, '7')
await getOrCreateMobileStructuredSendOperation({
operationKey,
payloadFingerprint,
createOperationId: () => operationId,
now: NOW
})
// The drain handed the draft off under a fresh id; only the link names this send.
await clearMobileStructuredSettledSendOperations({
submissions: [
{
clientMessageId: operationIdAt(NOW + 1, '8'),
queuedMessageId: operationId,
fence: 1,
payloadFingerprint,
dispatchState: 'pending',
providerItemId: null,
reason: null,
submittedAt: NOW + 1,
resolvedAt: null
}
]
})
expect(values.size).toBe(0)
})
it('does not clear a newer id for an older matching-payload submission', async () => {
const sessionKey = 'host-a:session-a'
const payloadFingerprint = 'e'.repeat(64)
const operationKey = mobileStructuredSendOperationKey({
sessionKey,
intentFingerprint: payloadFingerprint
})
const operationId = operationIdAt(NOW, 'f')
await getOrCreateMobileStructuredSendOperation({
operationKey,
payloadFingerprint,
createOperationId: () => operationId,
now: NOW
})
await clearMobileStructuredSettledSendOperations({
submissions: [
{
clientMessageId: operationIdAt(NOW - 1, '1'),
fence: 1,
payloadFingerprint,
dispatchState: 'accepted',
providerItemId: null,
reason: null,
submittedAt: NOW - 1,
resolvedAt: NOW - 1
}
]
})
await expect(
getOrCreateMobileStructuredSendOperation({
operationKey,
createOperationId: () => operationIdAt(NOW, '2'),
now: NOW
})
).resolves.toEqual({ operationId, retained: true })
})
it('reuses the original attachment paths for an ambiguous repeat upload', async () => {
const operationId = operationIdAt(NOW, 'e')
const payloadFingerprint = 'd'.repeat(64)
await getOrCreatePersistedOperation({
operationKey: OPERATION_KEY,
callerIdentity: CALLER_IDENTITY,
payloadFingerprint,
attachmentPaths: ['/tmp/original.png'],
createOperationId: () => operationId,
now: NOW
})
await expect(
getOrCreatePersistedOperation({
operationKey: OPERATION_KEY,
callerIdentity: CALLER_IDENTITY,
payloadFingerprint: 'e'.repeat(64),
attachmentPaths: ['/tmp/reuploaded.png'],
createOperationId: () => operationIdAt(NOW, 'f'),
now: NOW
})
).resolves.toEqual({
operationKey: OPERATION_KEY,
operationId,
retained: true,
payloadFingerprint,
attachmentPaths: ['/tmp/original.png']
})
})
it('replays an entry retained under the alternate key, and says which key matched', async () => {
const operationId = operationIdAt(NOW, 'e')
const alternateKey = 'b'.repeat(64)
await getOrCreatePersistedOperation({
operationKey: alternateKey,
callerIdentity: CALLER_IDENTITY,
payloadFingerprint: 'd'.repeat(64),
attachmentPaths: [],
createOperationId: () => operationId,
now: NOW
})
await expect(
getOrCreatePersistedOperation({
operationKey: OPERATION_KEY,
alternateOperationKey: alternateKey,
callerIdentity: CALLER_IDENTITY,
payloadFingerprint: 'd'.repeat(64),
attachmentPaths: [],
createOperationId: () => operationIdAt(NOW, 'f'),
now: NOW
})
).resolves.toMatchObject({ operationKey: alternateKey, operationId, retained: true })
})
it('keeps an ambiguous id after the host replay window closes', async () => {
const expired = operationIdAt(NOW - AGENT_SESSION_MAX_OPERATION_REPLAY_AGE_MS - 1, '6')
asyncStorage.getItem.mockResolvedValueOnce(
JSON.stringify({
v: 1,
entries: [
{
operationKey: OPERATION_KEY,
operationId: expired,
callerFingerprint: mobileStructuredSendCallerFingerprint(CALLER_IDENTITY),
payloadFingerprint: 'b'.repeat(64),
attachmentPaths: []
}
]
})
)
await expect(
getOrCreateMobileStructuredSendOperation({
operationKey: OPERATION_KEY,
createOperationId: () => operationIdAt(NOW, '7'),
now: NOW
})
).resolves.toEqual({ operationId: expired, retained: true })
})
it('fails closed when a retained operation id is malformed', async () => {
asyncStorage.getItem.mockResolvedValueOnce(
JSON.stringify({
v: 1,
entries: [
{
operationKey: OPERATION_KEY,
operationId: 'not-an-operation-id',
callerFingerprint: mobileStructuredSendCallerFingerprint(CALLER_IDENTITY),
payloadFingerprint: 'b'.repeat(64),
attachmentPaths: []
}
]
})
)
await expect(
getOrCreateMobileStructuredSendOperation({
operationKey: OPERATION_KEY,
createOperationId: () => operationIdAt(NOW, '8'),
now: NOW
})
).rejects.toThrow('unreadable')
})
it('fails closed when durable identity cannot be read or written', async () => {
asyncStorage.getItem.mockRejectedValueOnce(new Error('storage unavailable'))
await expect(
getOrCreateMobileStructuredSendOperation({
operationKey: OPERATION_KEY,
createOperationId: () => operationIdAt(NOW, '9'),
now: NOW
})
).rejects.toThrow('storage unavailable')
asyncStorage.setItem.mockRejectedValueOnce(new Error('disk full'))
await expect(
getOrCreateMobileStructuredSendOperation({
operationKey: OPERATION_KEY,
createOperationId: () => operationIdAt(NOW, 'a'),
now: NOW
})
).rejects.toThrow('disk full')
expect(values.size).toBe(0)
})
it('refuses new sends rather than evicting an ambiguous id at capacity', async () => {
asyncStorage.getItem.mockResolvedValueOnce(
JSON.stringify({
v: 1,
entries: Array.from(
{ length: AGENT_SESSION_DURABLE_OPERATION_GLOBAL_LIMIT },
(_, index) => ({
operationKey: index.toString(16).padStart(64, '0'),
operationId: operationIdAt(NOW, index.toString(16).padStart(32, '0')),
callerFingerprint: mobileStructuredSendCallerFingerprint(CALLER_IDENTITY),
payloadFingerprint: 'b'.repeat(64),
attachmentPaths: []
})
)
})
)
await expect(
getOrCreateMobileStructuredSendOperation({
operationKey: OPERATION_KEY,
createOperationId: () => operationIdAt(NOW, 'b'),
now: NOW
})
).rejects.toThrow('journal is full')
})
it('hashes the session scope and payload instead of retaining message bodies', () => {
const first = mobileStructuredSendOperationKey({
sessionKey: 'host-a:session-a',
intentFingerprint: 'message-body-fingerprint'
})
const second = mobileStructuredSendOperationKey({
sessionKey: 'host-b:session-a',
intentFingerprint: 'message-body-fingerprint'
})
expect(first).toMatch(/^[0-9a-f]{64}$/)
expect(second).not.toBe(first)
expect(first).not.toContain('message-body')
})
it('fails closed when a retained id crosses authenticated caller identities', async () => {
await getOrCreatePersistedOperation({
operationKey: OPERATION_KEY,
callerIdentity: 'mobile-device-before-repair',
payloadFingerprint: 'b'.repeat(64),
attachmentPaths: [],
createOperationId: () => operationIdAt(NOW, '3'),
now: NOW
})
await expect(
getOrCreatePersistedOperation({
operationKey: OPERATION_KEY,
callerIdentity: 'mobile-device-after-repair',
payloadFingerprint: 'b'.repeat(64),
attachmentPaths: [],
createOperationId: () => operationIdAt(NOW, '4'),
now: NOW
})
).rejects.toThrow('caller identity changed')
})
})
@@ -1,281 +0,0 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import { z } from 'zod'
import { persistMirrored } from '../storage/mirrored-storage-keys'
import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types'
import {
AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS,
AGENT_SESSION_OPERATION_FUTURE_SKEW_MS,
parseAgentSessionOperationTimestamp
} from '../../../src/shared/agent-session-host-authority'
import { AGENT_SESSION_DURABLE_OPERATION_GLOBAL_LIMIT } from '../../../src/shared/agent-session-operation-ledger'
import { structuredAgentSessionDomainFingerprint } from '../../../src/shared/structured-agent-session-mutation'
const STORAGE_KEY = 'orca:mobileStructuredSendOperations:v1'
const OperationEntrySchema = z
.object({
operationKey: z.string().regex(/^[0-9a-f]{64}$/),
operationId: z.string().max(128),
callerFingerprint: z.string().regex(/^[0-9a-f]{64}$/),
payloadFingerprint: z.string().regex(/^[0-9a-f]{64}$/),
attachmentPaths: z.array(z.string().max(4096)).max(128)
})
.strict()
const OperationJournalSchema = z
.object({
v: z.literal(1),
entries: z.array(OperationEntrySchema).max(AGENT_SESSION_DURABLE_OPERATION_GLOBAL_LIMIT)
})
.strict()
type OperationEntry = z.infer<typeof OperationEntrySchema>
type OperationJournal = z.infer<typeof OperationJournalSchema>
const mutations: { tail: Promise<void> } = { tail: Promise.resolve() }
export function mobileStructuredSendOperationKey(input: {
sessionKey: string
intentFingerprint: string
}): string {
return structuredAgentSessionDomainFingerprint({
domain: 'mobile.agentSession.send.operation',
sessionId: input.sessionKey,
fields: { intentFingerprint: input.intentFingerprint }
})
}
export function mobileStructuredSendCallerFingerprint(callerIdentity: string): string {
return structuredAgentSessionDomainFingerprint({
domain: 'mobile.agentSession.send.caller',
sessionId: callerIdentity,
fields: {}
})
}
function newOperationIdIsAdmissible(operationId: string, now: number): boolean {
const timestamp = parseAgentSessionOperationTimestamp(operationId)
return (
timestamp !== null &&
timestamp <= now + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS &&
now - timestamp <= AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS
)
}
function parseJournal(raw: string | null): OperationJournal {
if (raw === null) {
return { v: 1, entries: [] }
}
let value: unknown
try {
value = JSON.parse(raw)
} catch {
throw new Error('Structured send operation journal is unreadable')
}
const parsed = OperationJournalSchema.safeParse(value)
if (!parsed.success) {
throw new Error('Structured send operation journal is unreadable')
}
if (
new Set(parsed.data.entries.map((entry) => entry.operationKey)).size !==
parsed.data.entries.length ||
parsed.data.entries.some(
(entry) => parseAgentSessionOperationTimestamp(entry.operationId) === null
)
) {
throw new Error('Structured send operation journal is unreadable')
}
return parsed.data
}
async function writeEntries(entries: OperationEntry[]): Promise<void> {
// Through the one write path, which notes the mirror on an accepted write and on nothing else
// (ruling 35). The rejection this can raise is the point of the key: a journal the device never
// wrote must not reach the page, and the composer above catches it as "Message not sent".
await persistMirrored(
STORAGE_KEY,
entries.length === 0 ? null : JSON.stringify({ v: 1, entries })
)
}
async function serialize<T>(action: () => Promise<T>): Promise<T> {
const operation = mutations.tail.then(action, action)
mutations.tail = operation.then(
() => undefined,
() => undefined
)
return operation
}
export async function getOrCreateMobileStructuredSendOperation(input: {
operationKey: string
/** The same intent under its other delivery: a retained entry there is replayed
* as-is, so the key that matched — never a stored field — says how it was sent. */
alternateOperationKey?: string
callerIdentity: string
payloadFingerprint: string
attachmentPaths: readonly string[]
createOperationId: () => string
now?: number
}): Promise<{
operationKey: string
operationId: string
retained: boolean
payloadFingerprint: string
attachmentPaths: string[]
}> {
return serialize(async () => {
const now = input.now ?? Date.now()
const callerFingerprint = mobileStructuredSendCallerFingerprint(input.callerIdentity)
const journal = parseJournal(await AsyncStorage.getItem(STORAGE_KEY))
const entries = journal.entries
const existing =
entries.find((entry) => entry.operationKey === input.operationKey) ??
entries.find(
(entry) =>
input.alternateOperationKey !== undefined &&
entry.operationKey === input.alternateOperationKey
)
if (existing) {
if (existing.callerFingerprint !== callerFingerprint) {
throw new Error('Structured send caller identity changed')
}
return {
operationKey: existing.operationKey,
operationId: existing.operationId,
retained: true,
payloadFingerprint: existing.payloadFingerprint,
attachmentPaths: [...existing.attachmentPaths]
}
}
// Ambiguity has no TTL. At the fixed capacity, refusing a new send is safer
// than evicting an id whose message may already be in provider context.
if (entries.length >= AGENT_SESSION_DURABLE_OPERATION_GLOBAL_LIMIT) {
throw new Error('Structured send operation journal is full')
}
const operationId = input.createOperationId()
if (!newOperationIdIsAdmissible(operationId, now)) {
throw new Error('Structured send operation id is invalid')
}
const entry = OperationEntrySchema.parse({
operationKey: input.operationKey,
operationId,
callerFingerprint,
payloadFingerprint: input.payloadFingerprint,
attachmentPaths: [...input.attachmentPaths]
})
await writeEntries([...entries, entry])
return {
operationKey: input.operationKey,
operationId,
retained: false,
payloadFingerprint: input.payloadFingerprint,
attachmentPaths: [...input.attachmentPaths]
}
})
}
export async function clearMobileStructuredSendOperation(input: {
operationKey: string
operationId: string
}): Promise<void> {
return serialize(async () => {
const journal = parseJournal(await AsyncStorage.getItem(STORAGE_KEY))
const entries = journal.entries
const existing = entries.find((entry) => entry.operationKey === input.operationKey)
if (!existing) {
return
}
if (existing.operationId !== input.operationId) {
throw new Error('Structured send operation identity changed')
}
await writeEntries(entries.filter((entry) => entry !== existing))
})
}
/** Reconcile an ack-lost operation once the authoritative journal settles it, or hands it off:
* a submission naming the operation's id as its `queuedMessageId` is that send's queued draft
* going out, in whatever state, so the send reached the host and its id is spent. */
export async function clearMobileStructuredSettledSendOperations(input: {
submissions: readonly AgentJournalSubmission[]
}): Promise<void> {
const settled = new Set(
input.submissions.flatMap((submission) =>
submission.dispatchState === 'accepted' || submission.dispatchState === 'rejected'
? [`${submission.payloadFingerprint}\u0000${submission.clientMessageId}`]
: []
)
)
const handedOff = new Set(
input.submissions.flatMap((submission) =>
submission.queuedMessageId !== undefined ? [submission.queuedMessageId] : []
)
)
if (settled.size === 0 && handedOff.size === 0) {
return
}
return serialize(async () => {
const journal = parseJournal(await AsyncStorage.getItem(STORAGE_KEY))
const entries = journal.entries.filter(
(entry) =>
!settled.has(`${entry.payloadFingerprint}\u0000${entry.operationId}`) &&
!handedOff.has(entry.operationId)
)
if (entries.length !== journal.entries.length) {
await writeEntries(entries)
}
})
}
/**
* Spend the ids the host publishes as queued drafts. A draft is named by the
* operation id of the send that created it, so seeing one is the host's own
* receipt of that send — the `queued` answer a lost acknowledgement never
* brought. Without this, a draft another device later withdraws leaves an entry
* that no submission will ever settle.
*/
export async function clearMobileStructuredQueuedSendOperations(input: {
queuedMessageIds: readonly string[]
}): Promise<void> {
if (input.queuedMessageIds.length === 0) {
return
}
const held = new Set(input.queuedMessageIds)
return serialize(async () => {
const journal = parseJournal(await AsyncStorage.getItem(STORAGE_KEY))
const entries = journal.entries.filter((entry) => !held.has(entry.operationId))
if (entries.length !== journal.entries.length) {
await writeEntries(entries)
}
})
}
/** Ids sent past a saved record storage would not clear, keyed by the operation key that record
* matched, for this app run only: a retry of that text replays the same id instead of minting
* another, which could deliver it twice. An entry dies at app restart, when the host answers the
* id as spent, or when a saved record takes the id over; a replay the host keeps keeps it. */
const bypassedOperationIds = new Map<string, string>()
export function bypassedMobileStructuredSendOperationId(operationKey: string): string | undefined {
return bypassedOperationIds.get(operationKey)
}
export function rememberBypassedMobileStructuredSendOperation(
operationKey: string,
operationId: string
): void {
bypassedOperationIds.set(operationKey, operationId)
}
export function forgetBypassedMobileStructuredSendOperation(
operationKey: string,
operationId: string
): void {
if (bypassedOperationIds.get(operationKey) === operationId) {
bypassedOperationIds.delete(operationKey)
}
}
/** Test-only: drain in-memory serialization and bypassed ids while preserving durable storage. */
export function resetMobileStructuredSendOperationJournalForTests(): void {
mutations.tail = Promise.resolve()
bypassedOperationIds.clear()
}
@@ -1,143 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
/**
* The page's own AsyncStorage under the durable send journal, which is the one allowlisted value
* that outgrows what the bridge will carry.
*
* Measured on this tree: a journal entry with no attachment costs 343 characters in the array —
* 342 of its own plus the comma that joins it — and the schema admits 4,096 of them, so 47
* unsettled sends measure 16,140 and 48 measure 16,483, past `PAGE_STORAGE_MAX_VALUE_CHARS`. Every
* page write goes through `page-async-storage`, which the
* bundler aliases over the real module, so this is the module the journal actually writes to
* inside the page — and the reason the refusal must be a rejection rather than a dropped write.
*
* What a resolved refusal would cost, which is more than a lost preference: the journal would
* answer with an operation id no store holds, the mutation would go out carrying it, and a retry
* after a crash would send the same message again. Ruling 7's "nothing silently no-ops", at the
* one key where silence is a duplicate message rather than a default.
*
* The other half of the chain — that the rejection reaches the composer as "Message not sent" — is
* in `use-mobile-structured-agent-session-send.test.tsx`, which already drives the real send with
* a client double; asserting it here would mean building a second one.
*/
vi.mock('@react-native-async-storage/async-storage', async () => ({
default: (await import('../mobile-web-shell/bridge/page-async-storage')).default
}))
const { publishPageStorage } = await import('../mobile-web-shell/bridge/page-async-storage')
const { PAGE_STORAGE_MAX_VALUE_CHARS, pageStorageEntriesForInit } =
await import('../mobile-web-shell/page-storage-keys')
const {
getOrCreateMobileStructuredSendOperation,
resetMobileStructuredSendOperationJournalForTests
} = await import('./mobile-structured-send-operation-journal')
const HOST_ID = 'host-1'
const SESSION_ROUTE = '/h/host-1/session/wt-1'
const JOURNAL = 'orca:mobileStructuredSendOperations:v1'
const hex = (fill: string) => fill.repeat(64)
/**
* A journal the module itself reads back, built past the cap out of real entries rather than
* filler: a value the parser refuses reads as "unreadable" and never reaches the write at all.
* `ENTRIES_OVER_THE_CAP` is the measured number — one entry costs 343 characters in the array.
*/
const ENTRIES_OVER_THE_CAP = 48
function storedJournal(count: number): string {
return JSON.stringify({
v: 1,
entries: Array.from({ length: count }, (_, index) => ({
operationKey: index.toString(16).padStart(64, '0'),
operationId: `17584320${String(index).padStart(5, '0')}-${'b'.repeat(32)}`,
callerFingerprint: hex('c'),
payloadFingerprint: hex('d'),
attachmentPaths: []
}))
})
}
const posted: { key: string; value: string | null }[] = []
/**
* The page seated the way the shell seats it, rather than from a hand-written record.
*
* `pageStorageEntriesForInit` is the split the shell runs before `init` is built, so driving the
* page through it is what makes these states ones production can reach: a journal over the cap
* never arrives as a value, it arrives as a name on the oversize list.
*/
function publishAsTheShellWould(held: Record<string, string>): void {
posted.length = 0
const { entries, oversize } = pageStorageEntriesForInit(held)
publishPageStorage(
entries,
(key, value) => {
posted.push({ key, value })
return true
},
HOST_ID,
SESSION_ROUTE,
oversize
)
}
function publish(entries: Record<string, string>): void {
publishAsTheShellWould(entries)
}
function claim() {
return getOrCreateMobileStructuredSendOperation({
operationKey: hex('a'),
callerIdentity: 'caller-1',
payloadFingerprint: hex('d'),
attachmentPaths: [],
createOperationId: () => `${String(Date.now())}-${hex('b').slice(0, 32)}`
})
}
beforeEach(() => {
resetMobileStructuredSendOperationJournalForTests()
publish({})
})
describe('the durable send journal against the page store', () => {
it('rejects the send that would take the journal past what the page may write', async () => {
// The real precondition, seated through the shell's own split: 47 entries fit, so `init`
// carries them and the page holds a journal it can read. The 48th is the one that does not.
const held = storedJournal(ENTRIES_OVER_THE_CAP - 1)
expect(held.length).toBeLessThanOrEqual(PAGE_STORAGE_MAX_VALUE_CHARS)
expect(storedJournal(ENTRIES_OVER_THE_CAP).length).toBeGreaterThan(PAGE_STORAGE_MAX_VALUE_CHARS)
publishAsTheShellWould({ [JOURNAL]: held })
await expect(claim()).rejects.toThrow(/could not save/)
// Nothing posted either: the value the wire would have dropped never left the page.
expect(posted).toEqual([])
})
/**
* The destructive one (ruling 33.6).
*
* A native journal past the cap is dropped from `init` for size, and the key stays in
* `pageStorageKeysForRoute`. Without the oversize list the page reads `null`, `parseJournal`
* answers an empty journal, and the first send writes a one-entry value over the device's — the
* native entries gone and a fresh `operationId` for an operation native already holds, which is
* the duplicate send ruling 7 exists to prevent.
*/
it('refuses a send when init could not carry the journal, instead of replacing it', async () => {
const held = storedJournal(ENTRIES_OVER_THE_CAP)
const { entries, oversize } = pageStorageEntriesForInit({ [JOURNAL]: held })
// The precondition itself: the shell hands no value for this key and names it instead.
expect(entries).toEqual({})
expect(oversize).toEqual([JOURNAL])
publishAsTheShellWould({ [JOURNAL]: held })
await expect(claim()).rejects.toThrow(/could not save/)
// The half that makes it destructive: nothing was posted, so the native journal is untouched.
expect(posted).toEqual([])
})
it('claims an id when the journal fits, so the refusal above is the size and not the path', async () => {
publish({})
await expect(claim()).resolves.toEqual(expect.objectContaining({ retained: false }))
expect(posted.map((write) => write.key)).toEqual([JOURNAL])
})
})
@@ -97,7 +97,6 @@ export function useMobileNativeChatController(args: {
transcriptPath: activeChatResolution?.transcriptPath ?? null,
sessionId: activeChatSessionId,
sourceIdentity,
callerIdentity: deviceTokenRef.current ?? '',
enabled: showNativeChat,
connState,
hostSupport: agentSessionHostSupport,
@@ -126,8 +126,7 @@ describe('useMobileNativeChatImageAttachments', () => {
{
id: 'img-1',
path: '/tmp/a.png',
previewUri: 'file:///a.jpg',
contentFingerprint: expect.stringMatching(/^[0-9a-f]{64}$/)
previewUri: 'file:///a.jpg'
}
])
expect(client.calls.some((c) => c.method === 'terminal.send')).toBe(false)
@@ -3,7 +3,6 @@ import { CLIPBOARD_IMAGE_TOO_LARGE_ERROR } from '../../../src/shared/clipboard-i
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
import { useMediaPicker } from '../platform/media-picker'
import { fingerprintNativeChatImage } from '../platform/native-chat-image-fingerprint'
import {
ImageLibraryPermissionError,
type MobileImageSource
@@ -71,7 +70,6 @@ export function useMobileNativeChatImageUpload(args: {
client,
getConnectionId: getActiveWorktreeConnectionId,
pickImages: picker.pickImages,
fingerprintImage: fingerprintNativeChatImage,
onImageUploaded: (image) => uploadedImages.push(image),
onUploadStart: () => {
started = true
@@ -16,7 +16,6 @@ export function useMobileNativeChatSessionLane({
transcriptPath,
sessionId,
sourceIdentity,
callerIdentity,
hostSupport,
appendComposerTextRef,
enabled,
@@ -33,7 +32,6 @@ export function useMobileNativeChatSessionLane({
transcriptPath: string | null
sessionId: string | null
sourceIdentity: Parameters<typeof useMobileNativeChatSession>[0]['sourceIdentity']
callerIdentity: string
hostSupport: StructuredAgentSessionHostSupport | null
/** The active pane's live composer; a queued card's Edit copies through it.
* A ref because the drafts (and their append) mount after this lane. */
@@ -62,7 +60,6 @@ export function useMobileNativeChatSessionLane({
client,
sessionId: structured ? sessionId : null,
sourceIdentity,
callerIdentity,
hostSupport,
appendComposerText,
enabled,
@@ -35,7 +35,6 @@ type Args = {
id: string
path: string
previewUri: string
contentFingerprint?: string
}[]
) => Promise<MobileNativeChatSendOutcome>
/** Structured agent sessions do not have a terminal paste path. */
@@ -8,7 +8,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentChildWorkView } from '../../../src/shared/agent-status-child-work-view'
import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire'
import type { RpcClient } from '../transport/rpc-client'
import { resetMobileStructuredSendOperationJournalForTests } from './mobile-structured-send-operation-journal'
import {
backgroundTaskRowMeta,
buildBackgroundTaskGroupsFromViews
@@ -102,7 +101,7 @@ describe('mobile structured session background tasks', () => {
beforeEach(() => {
vi.clearAllMocks()
resetMobileStructuredSendOperationJournalForTests()
sendRequest.mockImplementation(async (method) =>
method === 'agentSession.cancel'
? mutationOk({})
@@ -7,7 +7,6 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types'
import type { RpcClient } from '../transport/rpc-client'
import { resetMobileStructuredSendOperationJournalForTests } from './mobile-structured-send-operation-journal'
import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session'
import {
CAPABLE,
@@ -98,7 +97,7 @@ async function sentDelivery(items: AgentJournalRenderItem[]): Promise<unknown> {
beforeEach(() => {
vi.clearAllMocks()
resetMobileStructuredSendOperationJournalForTests()
sendRequest.mockImplementation(async (method) =>
method === 'agentSession.send'
? mutationOk({ clientMessageId: 'client-1' })
@@ -34,9 +34,6 @@ vi.mock('./use-mobile-structured-agent-options', () => ({
vi.mock('./use-mobile-structured-prompt-responses', () => ({
useMobileStructuredPromptResponses: mocks.promptResponses
}))
vi.mock('./use-mobile-structured-send-operation-reconciliation', () => ({
useMobileStructuredSendOperationReconciliation: vi.fn()
}))
import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session'
@@ -15,7 +15,6 @@ import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-sessi
import type { RpcClient } from '../transport/rpc-client'
import type { RpcResponse } from '../transport/types'
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
import { resetMobileStructuredSendOperationJournalForTests } from './mobile-structured-send-operation-journal'
import type { StructuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support'
import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session'
import {
@@ -124,7 +123,7 @@ describe('mobile structured queued messages', () => {
beforeEach(() => {
vi.clearAllMocks()
resetMobileStructuredSendOperationJournalForTests()
stored = new Map()
asyncStorage.getItem.mockImplementation(async (key: string) => stored.get(key) ?? null)
asyncStorage.setItem.mockImplementation(async (key: string, value: string) => {
@@ -167,10 +166,7 @@ describe('mobile structured queued messages', () => {
fields: { body: params.body, delivery: 'queue-if-active' }
})
)
// Spent at `queued`: the durable send-operation entry is released.
await vi.waitFor(() =>
expect(stored.has('orca:mobileStructuredSendOperations:v1')).toBe(false)
)
expect(asyncStorage.setItem).not.toHaveBeenCalled()
})
it('keeps today’s request exactly against an incapable host', async () => {
@@ -207,317 +203,61 @@ describe('mobile structured queued messages', () => {
)
})
it('replays an ack-lost delivery send under one id and the delivery it was sent with', async () => {
let attempts = 0
sendRequest.mockImplementation(async (method) => {
if (method === 'agentSession.send') {
attempts += 1
throw markRpcDeliveryUnknown(new Error('Connection closed'))
}
return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({})
})
await mountSession(CAPABLE)
await act(async () => {
expect(await hook!.sendWithOutcome('retry me')).toBe('unknown')
})
unmountSession()
// The capability probe has not answered after the reload, but the recorded
// operation must replay bit-for-bit — content-derived key, same id, same
// delivery field — or the host would refuse it as a fingerprint conflict.
await mountSession(LEGACY)
await act(async () => {
expect(await hook!.sendWithOutcome('retry me')).toBe('unknown')
})
expect(attempts).toBe(2)
const first = requestOf('agentSession.send', 0)
const second = requestOf('agentSession.send', 1)
expect(second.params.delivery).toBe('queue-if-active')
expect(second.envelope.clientOperationId).toBe(first.envelope.clientOperationId)
// Nothing new is persisted: an older build still reads the send journal.
const journal = stored.get('orca:mobileStructuredSendOperations:v1') ?? ''
expect(journal).not.toContain('delivery')
})
it('retires an ack-lost delivery send an older host refuses, so the next send goes out plain', async () => {
const journalKey = 'orca:mobileStructuredSendOperations:v1'
it('a new send follows the current host capability after acknowledgement loss', async () => {
let attempts = 0
sendRequest.mockImplementation(async (method, params) => {
if (method === 'agentSession.send') {
attempts += 1
if (attempts <= 2) {
throw markRpcDeliveryUnknown(new Error('Connection closed'))
}
// The downgraded host's strict schema turns `delivery` away before running anything.
if ('delivery' in fieldsOf(params)) {
return {
id: 'request-1',
ok: false,
error: { code: 'invalid_argument', message: 'Unrecognized key: "delivery"' }
}
}
return mutationOk({
clientMessageId: 'client-plain',
submission: {
clientMessageId: 'client-plain',
fence: 3,
payloadFingerprint: 'fp',
dispatchState: 'accepted',
providerItemId: null,
reason: null,
submittedAt: 10,
resolvedAt: 10
}
})
if (method !== 'agentSession.send') {
return ok({ models: [], current: {} })
}
return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({})
if (++attempts === 1) {
throw markRpcDeliveryUnknown(new Error('Connection closed'))
}
const id = String(fieldsOf(fieldsOf(params).envelope).clientOperationId)
return mutationOk({ clientMessageId: id, submission: acceptedSubmission(id) })
})
await mountSession(CAPABLE)
await act(async () => {
expect(await hook!.sendWithOutcome('downgraded')).toBe('unknown')
expect(await hook!.sendWithOutcome('again')).toBe('unknown')
})
unmountSession()
await mountSession(LEGACY)
// A lost answer is still doubt: the replay keeps the id and its delivery.
await act(async () => {
expect(await hook!.sendWithOutcome('downgraded')).toBe('unknown')
expect(await hook!.sendWithOutcome('again')).toBe('accepted')
})
const first = requestOf('agentSession.send', 0)
expect(requestOf('agentSession.send', 1).envelope.clientOperationId).toBe(
first.envelope.clientOperationId
)
expect(stored.get(journalKey)).toContain(String(first.envelope.clientOperationId))
// The host answering that it cannot take the request retires the entry, once.
await act(async () => {
expect(await hook!.sendWithOutcome('downgraded')).toBe('rejected')
})
const refused = requestOf('agentSession.send', 2)
expect(refused.params.delivery).toBe('queue-if-active')
expect(refused.envelope.clientOperationId).toBe(first.envelope.clientOperationId)
expect(onSendError).toHaveBeenCalledTimes(1)
await act(async () => {
expect(await hook!.sendWithOutcome('downgraded')).toBe('accepted')
})
const plain = requestOf('agentSession.send', 3)
expect('delivery' in plain.params).toBe(false)
expect(plain.envelope.clientOperationId).not.toBe(first.envelope.clientOperationId)
expect(attempts).toBe(4)
})
it('keeps an ack-lost send when the host refuses its replay as unauthorized', async () => {
let attempts = 0
sendRequest.mockImplementation(async (method) => {
if (method === 'agentSession.send') {
attempts += 1
if (attempts === 1) {
throw markRpcDeliveryUnknown(new Error('Connection closed'))
}
// An auth refusal says nothing about whether the first attempt was delivered.
return {
id: 'request-1',
ok: false,
error: { code: 'unauthorized', message: 'Pairing revoked' }
}
}
return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({})
})
await mountSession(CAPABLE)
await act(async () => {
expect(await hook!.sendWithOutcome('in doubt')).toBe('unknown')
})
await act(async () => {
expect(await hook!.sendWithOutcome('in doubt')).toBe('rejected')
})
await act(async () => {
await hook!.sendWithOutcome('in doubt')
})
const first = requestOf('agentSession.send', 0)
expect(requestOf('agentSession.send', 2).envelope.clientOperationId).toBe(
first.envelope.clientOperationId
)
const second = requestOf('agentSession.send', 1)
expect(first.params.delivery).toBe('queue-if-active')
expect('delivery' in second.params).toBe(false)
expect(second.envelope.clientOperationId).not.toBe(first.envelope.clientOperationId)
})
})
it('an ack-lost send is spent once the host publishes it as a draft, even one later withdrawn', async () => {
const journalKey = 'orca:mobileStructuredSendOperations:v1'
it('an ack-lost queued send never absorbs the next identical message', async () => {
let attempts = 0
sendRequest.mockImplementation(async (method) => {
if (method === 'agentSession.send') {
attempts += 1
if (attempts === 1) {
throw markRpcDeliveryUnknown(new Error('Connection closed'))
}
return mutationOk({
clientMessageId: `client-${attempts}`,
queued: { messageId: `client-${attempts}`, position: 1, state: 'waiting' }
})
sendRequest.mockImplementation(async (method, params) => {
if (method !== 'agentSession.send') {
return ok({ models: [], current: {} })
}
return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({})
if (++attempts === 1) {
throw markRpcDeliveryUnknown(new Error('Connection closed'))
}
const id = String(fieldsOf(fieldsOf(params).envelope).clientOperationId)
return mutationOk({
clientMessageId: id,
queued: { messageId: id, position: 1, state: 'waiting' }
})
})
await mountSession(CAPABLE, snapshotEvent({ runningTurn: true }))
await act(async () => {
expect(await hook!.sendWithOutcome('held')).toBe('unknown')
})
const operationId = String(requestOf('agentSession.send').envelope.clientOperationId)
expect(stored.get(journalKey)).toContain(operationId)
// Someone else's draft proves nothing about this send.
act(() => listener?.(batchEvent([queuedDraft({ messageId: 'other-device' })])))
await act(async () => {})
expect(stored.get(journalKey)).toContain(operationId)
// The host names the draft by this send's operation id: that is its receipt.
act(() => listener?.(batchEvent([queuedDraft({ messageId: operationId })])))
await vi.waitFor(() => expect(stored.has(journalKey)).toBe(false))
// Deleted elsewhere: no submission will ever settle it, and nothing has to.
act(() => listener?.(batchEvent(null)))
const firstId = String(requestOf('agentSession.send').envelope.clientOperationId)
act(() => listener?.(batchEvent([queuedDraft({ messageId: firstId })])))
await act(async () => {
expect(await hook!.sendWithOutcome('held')).toBe('queued')
})
expect(requestOf('agentSession.send', 1).envelope.clientOperationId).not.toBe(operationId)
})
it('an identical send whose retained id replays as withdrawn goes out fresh', async () => {
let attempts = 0
sendRequest.mockImplementation(async (method) => {
if (method === 'agentSession.send') {
attempts += 1
if (attempts === 1) {
throw markRpcDeliveryUnknown(new Error('Connection closed'))
}
const state = attempts === 2 ? 'withdrawn' : 'waiting'
// A pruned deleted card's receipt names no place in the queue: position 0.
return mutationOk({
clientMessageId: `client-${attempts}`,
queued: {
messageId: `client-${attempts}`,
position: state === 'withdrawn' ? 0 : 1,
state
}
})
}
return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({})
})
await mountSession(CAPABLE)
await act(async () => {
expect(await hook!.sendWithOutcome('again')).toBe('unknown')
})
// A Delete spent the ack-lost draft before it reached the agent; typing the
// same words again is a new message, not a replay to swallow.
await act(async () => {
expect(await hook!.sendWithOutcome('again')).toBe('queued')
})
expect(attempts).toBe(3)
const ids = [0, 1, 2].map(
(index) => requestOf('agentSession.send', index).envelope.clientOperationId
)
expect(ids[1]).toBe(ids[0])
expect(ids[2]).not.toBe(ids[0])
})
it('a withdrawn replay whose record storage will not clear still goes out fresh, and says so', async () => {
let attempts = 0
sendRequest.mockImplementation(async (method) => {
if (method === 'agentSession.send') {
attempts += 1
if (attempts === 1) {
throw markRpcDeliveryUnknown(new Error('Connection closed'))
}
const state = attempts === 2 ? 'withdrawn' : 'waiting'
return mutationOk({
clientMessageId: `client-${attempts}`,
queued: { messageId: `client-${attempts}`, position: 1, state }
})
}
return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({})
})
await mountSession(CAPABLE)
await act(async () => {
expect(await hook!.sendWithOutcome('again')).toBe('unknown')
})
// The retained record cannot be cleared; that must not keep this text from being sent.
asyncStorage.setItem.mockRejectedValue(new Error('disk full'))
asyncStorage.removeItem.mockRejectedValue(new Error('disk full'))
await act(async () => {
expect(await hook!.sendWithOutcome('again')).toBe('queued')
})
expect(attempts).toBe(3)
const ids = [0, 1, 2].map(
(index) => requestOf('agentSession.send', index).envelope.clientOperationId
)
expect(ids[1]).toBe(ids[0])
expect(ids[2]).not.toBe(ids[0])
expect(onSendError).toHaveBeenCalledWith(
"Sent, but this phone couldn't update its record of sent messages."
)
})
describe('a lost answer, then Stop, then the drain under a fresh id', () => {
const journalKey = 'orca:mobileStructuredSendOperations:v1'
let attempts = 0
let lostId = ''
beforeEach(() => {
attempts = 0
lostId = ''
sendRequest.mockImplementation(async (method, params) => {
if (method === 'agentSession.send') {
attempts += 1
const id = String(fieldsOf(fieldsOf(params).envelope).clientOperationId)
if (attempts === 1) {
lostId = id
throw markRpcDeliveryUnknown(new Error('Connection closed'))
}
if (id === lostId) {
// The replay answers with the hand-off, which names the replayed id as its draft.
return mutationOk({
clientMessageId: id,
submission: acceptedSubmission('fresh-hand-off', id)
})
}
return mutationOk({
clientMessageId: id,
queued: { messageId: id, position: 1, state: 'waiting' }
})
}
return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({})
})
})
it('spends the record once the stream carries the hand-off naming it; a direct send does not', async () => {
await mountSession(CAPABLE)
await act(async () => {
expect(await hook!.sendWithOutcome('again')).toBe('unknown')
})
expect(stored.get(journalKey)).toContain(lostId)
// A direct send's submission names no draft, so it settles nothing here.
act(() => listener?.(batchEvent(undefined, [acceptedSubmission('someone-else')])))
await act(async () => {})
expect(stored.get(journalKey)).toContain(lostId)
// The drain's hand-off went out under a fresh id and names the lost send's draft.
act(() => listener?.(batchEvent(undefined, [acceptedSubmission('fresh-hand-off', lostId)])))
await vi.waitFor(() => expect(stored.has(journalKey)).toBe(false))
await act(async () => {
expect(await hook!.sendWithOutcome('again')).toBe('queued')
})
expect(attempts).toBe(2)
expect(requestOf('agentSession.send', 1).envelope.clientOperationId).not.toBe(lostId)
})
it('spends the record from a replay answered by the hand-off, which no page carries', async () => {
await mountSession(CAPABLE)
await act(async () => {
expect(await hook!.sendWithOutcome('again')).toBe('unknown')
})
// The host holds that message now; the phone paints no bubble for it. The stream never
// carries the hand-off, so the answer's link is what spends the record.
await act(async () => {
expect(await hook!.sendWithOutcome('again')).toBe('unknown')
})
expect(requestOf('agentSession.send', 1).envelope.clientOperationId).toBe(lostId)
await vi.waitFor(() => expect(stored.has(journalKey)).toBe(false))
await act(async () => {
expect(await hook!.sendWithOutcome('again')).toBe('queued')
})
expect(requestOf('agentSession.send', 2).envelope.clientOperationId).not.toBe(lostId)
})
expect(attempts).toBe(2)
expect(requestOf('agentSession.send', 1).envelope.clientOperationId).not.toBe(firstId)
})
describe('cards from the published list', () => {
@@ -5,7 +5,6 @@ import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { resetMobileStructuredSendOperationJournalForTests } from './mobile-structured-send-operation-journal'
import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session'
import { CAPABLE, SESSION_ID, ok } from './use-mobile-structured-agent-session-queued.test-fixture'
@@ -72,7 +71,7 @@ async function readFailedWith(reason: string) {
beforeEach(() => {
vi.clearAllMocks()
resetMobileStructuredSendOperationJournalForTests()
sendRequest.mockImplementation(async (method) =>
method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({})
)
@@ -5,8 +5,8 @@ import type { AgentJournalDispatchState } from '../../../src/shared/agent-sessio
import { DISPATCH_REJECTED_CANCELLED } from '../../../src/shared/structured-agent-session-dispatch-rejection'
import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire'
import type { RpcClient } from '../transport/rpc-client'
import { fieldsOf, ok } from './use-mobile-structured-agent-session-queued.test-fixture'
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
import { resetMobileStructuredSendOperationJournalForTests } from './mobile-structured-send-operation-journal'
import { structuredSendResultFixture } from './structured-agent-send-result.test-fixture'
import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session'
@@ -18,10 +18,6 @@ const asyncStorage = vi.hoisted(() => ({
vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage }))
function ok(result: unknown) {
return { ok: true, result, _meta: { runtimeId: 'runtime-1' } }
}
function sendResult(dispatchState: AgentJournalDispatchState, reason: string | null = null) {
return ok({
ok: true,
@@ -32,7 +28,7 @@ function sendResult(dispatchState: AgentJournalDispatchState, reason: string | n
})
}
function snapshotEvent(): AgentSessionSubscribeEvent {
function snapshotEvent(): Extract<AgentSessionSubscribeEvent, { type: 'snapshot' }> {
return {
type: 'snapshot',
sessionId: 'session-1',
@@ -57,18 +53,28 @@ function snapshotEvent(): AgentSessionSubscribeEvent {
}
}
describe('mobile structured send retries', () => {
describe('mobile structured send actions', () => {
let renderer: ReactTestRenderer | null = null
let hook: ReturnType<typeof useMobileStructuredAgentSession> | null = null
let listener: ((value: unknown) => void) | null = null
let storedOperations: Map<string, string>
const onSendError = vi.fn()
const sendRequest = vi.fn()
const subscribe = vi.fn((_method: string, _params: unknown, onData: (value: unknown) => void) => {
const sendRequest = vi.fn<RpcClient['sendRequest']>()
const subscribe = vi.fn<RpcClient['subscribe']>((_method, _params, onData) => {
listener = onData
return vi.fn()
})
const client = { sendRequest, subscribe } as unknown as RpcClient
const client: RpcClient = {
sendRequest,
subscribe,
updateTerminalSubscriptionViewport: () => {},
getState: () => 'connected',
getReconnectAttempt: () => 0,
getLastConnectedAt: () => null,
onStateChange: () => () => {},
notifyForeground: () => {},
close: () => {}
}
function Harness(): null {
hook = useMobileStructuredAgentSession({
@@ -78,8 +84,9 @@ describe('mobile structured send retries', () => {
enabled: true,
connected: true,
agent: 'codex',
hostSupport: null,
onSendError
} as never)
})
return null
}
@@ -96,15 +103,14 @@ describe('mobile structured send retries', () => {
}
function sentIds(): string[] {
return calls().map(
([, params]) =>
(params as { envelope: { clientOperationId: string } }).envelope.clientOperationId
return calls().map(([, params]) =>
String(fieldsOf(fieldsOf(params).envelope).clientOperationId)
)
}
beforeEach(() => {
vi.clearAllMocks()
resetMobileStructuredSendOperationJournalForTests()
storedOperations = new Map()
asyncStorage.getItem.mockImplementation(
async (key: string) => storedOperations.get(key) ?? null
@@ -127,137 +133,59 @@ describe('mobile structured send retries', () => {
listener = null
})
it('keeps one id across acknowledgement loss and host unknown replays', async () => {
let attempts = 0
sendRequest.mockImplementation(async (method) => {
if (method !== 'agentSession.send') {
return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({})
}
attempts += 1
if (attempts === 1) {
throw markRpcDeliveryUnknown(new Error('Connection closed'))
}
return sendResult('unknown')
})
await mountSession()
it.each(['unknown', 'pending', 'accepted'] as const)(
'a later Send owns a new id even when the earlier host answer was %s',
async (state) => {
sendRequest.mockImplementation(async (method) =>
method === 'agentSession.send' ? sendResult(state) : ok({ models: [], current: {} })
)
await mountSession()
await act(async () => {
const outcome = state === 'unknown' ? 'unknown' : 'accepted'
expect(await hook!.sendWithOutcome('same text')).toBe(outcome)
expect(await hook!.sendWithOutcome('same text')).toBe(outcome)
})
expect(new Set(sentIds()).size).toBe(2)
}
)
await act(async () => {
expect(await hook!.sendWithOutcome('retry me')).toBe('unknown')
expect(await hook!.sendWithOutcome('retry me')).toBe('unknown')
expect(await hook!.sendWithOutcome('retry me')).toBe('unknown')
})
expect(calls()).toHaveLength(3)
expect(new Set(sentIds()).size).toBe(1)
expect(calls().every(([, params]) => !('retryUnknown' in (params as object)))).toBe(true)
})
// A replay of a retained id cannot be told from a new send of the same text, and a Stop took the
// first one back before the agent started it, so this press goes out as a new message.
it('sends a replay a Stop took back once more, under a fresh id, saying nothing', async () => {
let attempts = 0
sendRequest.mockImplementation(async (method) => {
if (method !== 'agentSession.send') {
return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({})
}
attempts += 1
if (attempts === 1) {
throw markRpcDeliveryUnknown(new Error('Connection closed'))
}
return attempts === 2
? sendResult('rejected', DISPATCH_REJECTED_CANCELLED)
: sendResult('pending')
})
await mountSession()
await act(async () => {
expect(await hook!.sendWithOutcome('stopped one')).toBe('unknown')
expect(await hook!.sendWithOutcome('stopped one')).toBe('accepted')
})
expect(onSendError).not.toHaveBeenCalled()
// The lost first send, its replay, and exactly one resend under a new id.
const ids = sentIds()
expect(ids).toHaveLength(3)
expect(ids[1]).toBe(ids[0])
expect(ids[2]).not.toBe(ids[0])
})
it('reads a first answer a Stop took back as sent, saying nothing, and spends its id', async () => {
sendRequest.mockImplementation(async (method) =>
method === 'agentSession.send'
? sendResult('rejected', DISPATCH_REJECTED_CANCELLED)
: method === 'agentSession.options'
? ok({ models: [], current: {} })
: ok({})
)
await mountSession()
await act(async () => {
expect(await hook!.sendWithOutcome('stopped first')).toBe('accepted')
expect(await hook!.sendWithOutcome('stopped first')).toBe('accepted')
})
expect(onSendError).not.toHaveBeenCalled()
const ids = sentIds()
expect(ids).toHaveLength(2)
expect(ids[1]).not.toBe(ids[0])
})
it('releases an ack-lost id after the journal accepts it for a later identical intent', async () => {
let attempts = 0
sendRequest.mockImplementation(async (method) => {
if (method !== 'agentSession.send') {
return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({})
}
attempts += 1
return attempts === 1
? Promise.reject(markRpcDeliveryUnknown(new Error('Connection closed')))
: sendResult('accepted')
})
await mountSession()
await act(async () => {
expect(await hook!.sendWithOutcome('same text, later intent')).toBe('unknown')
})
const firstRequest = calls()[0]![1] as {
envelope: { clientOperationId: string; payloadFingerprint: string }
it('keeps the original unknown host row while allowing another Send', async () => {
const original = structuredSendResultFixture('unknown')
if (!('submission' in original)) {
throw new Error('expected a submission answer')
}
const event = snapshotEvent()
act(() =>
listener?.({
...event,
page: {
...event.page,
submissions: [
{
...structuredSendResultFixture('accepted').submission,
clientMessageId: firstRequest.envelope.clientOperationId,
payloadFingerprint: firstRequest.envelope.payloadFingerprint
}
]
}
})
event.page.submissions = [original.submission]
event.page.items = [
{
itemId: 'original-message',
revision: 1,
sequence: 1,
observedAt: 10,
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'same text' }] }
}
]
sendRequest.mockImplementation(async (method) =>
method === 'agentSession.send' ? sendResult('accepted') : ok({ models: [], current: {} })
)
await vi.waitFor(() => expect(storedOperations.size).toBe(0))
await mountSession()
act(() => listener?.(event))
const messages = hook!.session.messages
expect(messages).toHaveLength(1)
await act(async () => {
expect(await hook!.sendWithOutcome('same text, later intent')).toBe('accepted')
expect(await hook!.sendWithOutcome('same text')).toBe('accepted')
})
expect(sentIds()).toHaveLength(2)
expect(new Set(sentIds()).size).toBe(2)
expect(hook!.session.messages).toEqual(messages)
expect(event.page.submissions[0]?.dispatchState).toBe('unknown')
expect(calls()).toHaveLength(1)
})
it('reuses an ambiguous id after the session hook remounts', async () => {
let attempts = 0
it('does not replay an old action after remount', async () => {
sendRequest.mockImplementation(async (method) => {
if (method !== 'agentSession.send') {
return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({})
if (method === 'agentSession.send') {
throw markRpcDeliveryUnknown(new Error('Connection closed'))
}
attempts += 1
return attempts === 1
? Promise.reject(markRpcDeliveryUnknown(new Error('Connection closed')))
: sendResult('unknown')
return ok({ models: [], current: {} })
})
await mountSession()
await act(async () => {
@@ -265,131 +193,87 @@ describe('mobile structured send retries', () => {
})
act(() => renderer?.unmount())
renderer = null
hook = null
listener = null
await mountSession()
await act(async () => {
expect(await hook!.sendWithOutcome('survive remount')).toBe('unknown')
})
expect(new Set(sentIds()).size).toBe(2)
})
expect(new Set(sentIds()).size).toBe(1)
expect(calls().every(([, params]) => !('retryUnknown' in (params as object)))).toBe(true)
expect(asyncStorage.setItem.mock.invocationCallOrder[0]).toBeLessThan(
sendRequest.mock.invocationCallOrder.find(
(_, index) => sendRequest.mock.calls[index]?.[0] === 'agentSession.send'
)!
it('uses newly uploaded attachment paths for a new action with the same image', async () => {
sendRequest.mockImplementation(async (method) =>
method === 'agentSession.send' ? sendResult('unknown') : ok({ models: [], current: {} })
)
})
it('keeps the id when the host fails after provider dispatch', async () => {
let attempts = 0
sendRequest.mockImplementation(async (method) => {
if (method !== 'agentSession.send') {
return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({})
}
attempts += 1
return attempts === 1
? {
id: 'request-1',
ok: false as const,
error: { code: 'runtime_error', message: 'journal resolve failed' },
_meta: { runtimeId: 'runtime-1' }
}
: sendResult('unknown')
})
await mountSession()
await act(async () => {
expect(await hook!.sendWithOutcome('possibly delivered')).toBe('unknown')
expect(await hook!.sendWithOutcome('possibly delivered')).toBe('unknown')
})
expect(calls()).toHaveLength(2)
expect(new Set(sentIds()).size).toBe(1)
expect(calls().every(([, params]) => !('retryUnknown' in (params as object)))).toBe(true)
})
it('reuses the original uploaded attachment identity after acknowledgement loss', async () => {
let attempts = 0
sendRequest.mockImplementation(async (method) => {
if (method !== 'agentSession.send') {
return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({})
for (const path of ['/tmp/original.png', '/tmp/reuploaded.png']) {
expect(
await hook!.sendWithOutcome('describe', undefined, undefined, [
{
path,
previewUri: 'file:///photo.jpg'
}
])
).toBe('unknown')
}
attempts += 1
return attempts === 1
? Promise.reject(markRpcDeliveryUnknown(new Error('Connection closed')))
: sendResult('unknown')
})
await mountSession()
const contentFingerprint = 'f'.repeat(64)
await act(async () => {
expect(
await hook!.sendWithOutcome('describe', undefined, undefined, [
{
path: '/tmp/original.png',
previewUri: 'file:///photo.jpg',
contentFingerprint
}
])
).toBe('unknown')
expect(
await hook!.sendWithOutcome('describe', undefined, undefined, [
{
path: '/tmp/reuploaded.png',
previewUri: 'file:///photo.jpg',
contentFingerprint
}
])
).toBe('unknown')
})
expect(new Set(sentIds()).size).toBe(1)
expect(new Set(sentIds()).size).toBe(2)
expect(calls()[1]?.[1]).toMatchObject({
body: {
blocks: expect.arrayContaining([{ type: 'image-ref', path: '/tmp/original.png' }])
blocks: expect.arrayContaining([{ type: 'image-ref', path: '/tmp/reuploaded.png' }])
}
})
})
it('reports a send stopped before the agent started it without sending it twice', async () => {
sendRequest.mockImplementation(async (method) =>
method === 'agentSession.send'
? sendResult('rejected', DISPATCH_REJECTED_CANCELLED)
: ok({ models: [], current: {} })
)
await mountSession()
await act(async () => {
expect(await hook!.sendWithOutcome('stopped')).toBe('accepted')
expect(await hook!.sendWithOutcome('stopped')).toBe('accepted')
})
expect(calls()).toHaveLength(2)
expect(new Set(sentIds()).size).toBe(2)
expect(onSendError).not.toHaveBeenCalled()
})
it.each(['invalid_argument', 'unauthorized'])(
'rotates after a %s pre-handler RPC refusal that proves the send did not run',
'a later Send is independent after a %s refusal',
async (code) => {
let attempts = 0
sendRequest.mockImplementation(async (method) => {
if (method !== 'agentSession.send') {
return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({})
return ok({ models: [], current: {} })
}
attempts += 1
return attempts === 1
return ++attempts === 1
? {
id: 'request-1',
ok: false as const,
error: { code, message: 'Message is not authorized' },
_meta: { runtimeId: 'runtime-1' }
error: { code, message: 'Message is not authorized' }
}
: sendResult('accepted')
})
await mountSession()
await act(async () => {
expect(await hook!.sendWithOutcome('never reached the handler')).toBe('rejected')
expect(await hook!.sendWithOutcome('never reached the handler')).toBe('accepted')
expect(await hook!.sendWithOutcome('again')).toBe('rejected')
expect(await hook!.sendWithOutcome('again')).toBe('accepted')
})
expect(sentIds()).toHaveLength(2)
expect(new Set(sentIds()).size).toBe(2)
}
)
it('keeps the send id after a pending-admission refusal', async () => {
it('a new Send goes through after a stale-fence refusal', async () => {
let attempts = 0
sendRequest.mockImplementation(async (method) => {
if (method !== 'agentSession.send') {
return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({})
return ok({ models: [], current: {} })
}
attempts += 1
return attempts === 1
return ++attempts === 1
? ok({
ok: false,
refusal: {
@@ -401,112 +285,33 @@ describe('mobile structured send retries', () => {
: sendResult('accepted')
})
await mountSession()
await act(async () => {
expect(await hook!.sendWithOutcome('retry at the current fence')).toBe('rejected')
expect(await hook!.sendWithOutcome('retry at the current fence')).toBe('unknown')
expect(await hook!.sendWithOutcome('again')).toBe('rejected')
expect(await hook!.sendWithOutcome('again')).toBe('accepted')
})
expect(sentIds()).toHaveLength(2)
expect(new Set(sentIds()).size).toBe(1)
expect(new Set(sentIds()).size).toBe(2)
})
it('keeps the id when an older host refuses an unknown replay', async () => {
let attempts = 0
sendRequest.mockImplementation(async (method) => {
if (method !== 'agentSession.send') {
return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({})
}
attempts += 1
if (attempts === 1) {
throw markRpcDeliveryUnknown(new Error('Connection closed'))
}
return attempts === 2
? ok({
ok: false,
refusal: {
code: 'agent_session_operation_unknown',
message: 'The outcome is unknown.'
}
})
: sendResult('unknown')
})
await mountSession()
await act(async () => {
expect(await hook!.sendWithOutcome('old host replay')).toBe('unknown')
expect(await hook!.sendWithOutcome('old host replay')).toBe('unknown')
expect(await hook!.sendWithOutcome('old host replay')).toBe('unknown')
})
expect(new Set(sentIds()).size).toBe(1)
expect(calls().every(([, params]) => !('retryUnknown' in (params as object)))).toBe(true)
})
it('sends under a new id once the host has expired an ambiguous one', async () => {
let attempts = 0
sendRequest.mockImplementation(async (method) => {
if (method !== 'agentSession.send') {
return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({})
}
attempts += 1
if (attempts === 1) {
throw markRpcDeliveryUnknown(new Error('Connection closed'))
}
if (attempts === 3) {
return sendResult('accepted')
}
return ok({
ok: false,
refusal: {
code: 'agent_session_operation_expired',
message: 'Operation expired.'
}
})
})
await mountSession()
await act(async () => {
expect(await hook!.sendWithOutcome('old ambiguity')).toBe('unknown')
expect(await hook!.sendWithOutcome('old ambiguity')).toBe('rejected')
expect(await hook!.sendWithOutcome('old ambiguity')).toBe('accepted')
})
expect(calls()).toHaveLength(3)
const [first, replay, fresh] = sentIds()
expect(replay).toBe(first)
expect(fresh).not.toBe(first)
expect(onSendError).toHaveBeenCalledWith(
"Orca couldn't confirm your message reached the agent. Check the chat, then send it again if needed."
it('never reads an old phone journal, even when storage is full or unreadable', async () => {
asyncStorage.getItem.mockRejectedValue(new Error('unreadable old journal'))
asyncStorage.setItem.mockRejectedValue(new Error('disk full'))
sendRequest.mockImplementation(async (method) =>
method === 'agentSession.send' ? sendResult('accepted') : ok({ models: [], current: {} })
)
await mountSession()
await act(async () => {
expect(await hook!.sendWithOutcome('new action')).toBe('accepted')
})
expect(asyncStorage.getItem).not.toHaveBeenCalled()
expect(asyncStorage.setItem).not.toHaveBeenCalled()
expect(onSendError).not.toHaveBeenCalled()
})
it('does not retain an id when the action budget expires before dispatch', async () => {
it('sends nothing once the action budget expires', async () => {
await mountSession()
await act(async () => {
expect(await hook!.sendWithOutcome('never attempted', undefined, 0)).toBe('rejected')
})
expect(calls()).toHaveLength(0)
expect(asyncStorage.setItem).not.toHaveBeenCalled()
})
it('puts a store that would not take the journal on screen, and sends nothing', async () => {
// Inside the page the store is the app's, reached over the `storage` grant, and it rejects a
// journal past `PAGE_STORAGE_MAX_VALUE_CHARS` — 48 unsettled sends, measured. A refusal that
// resolved instead would put a mutation on the wire carrying an operation id nothing holds,
// and a retry after a crash would send this message twice (rulings-ota-c7.md ruling 7).
asyncStorage.setItem.mockImplementation(async () => {
throw new Error('Orca could not save orca:mobileStructuredSendOperations:v1')
})
await mountSession()
await act(async () => {
expect(await hook!.sendWithOutcome('the journal will not take this')).toBe('rejected')
})
expect(onSendError).toHaveBeenCalledWith('Message not sent')
expect(calls()).toHaveLength(0)
})
})
@@ -22,8 +22,6 @@ const asyncStorage = vi.hoisted(() => ({
vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage }))
import { resetMobileStructuredSendOperationJournalForTests } from './mobile-structured-send-operation-journal'
function ok(result: unknown) {
return { ok: true, result, _meta: { runtimeId: 'runtime-1' } }
}
@@ -271,7 +269,7 @@ describe('useMobileStructuredAgentSession', () => {
beforeEach(() => {
vi.clearAllMocks()
resetMobileStructuredSendOperationJournalForTests()
storedOperations = new Map()
asyncStorage.getItem.mockImplementation(
async (key: string) => storedOperations.get(key) ?? null
@@ -30,7 +30,6 @@ import { useMobileStructuredPromptResponses } from './use-mobile-structured-prom
import type { StructuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support'
import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-options'
import { useMobileStructuredAgentTurnTiming } from './use-mobile-structured-agent-turn-timing'
import { useMobileStructuredSendOperationReconciliation } from './use-mobile-structured-send-operation-reconciliation'
import {
pendingStructuredPromptIdentity,
requestMobileStructuredAgentSessionCancel
@@ -83,8 +82,6 @@ export function useMobileStructuredAgentSession(args: {
sessionId: string | null
/** Host/workspace scope used to keep same provider ids isolated. */
sourceIdentity?: string
/** Authenticated identity the host keys mutation admission under. */
callerIdentity?: string
enabled: boolean
/** Live transport only; gates the connection-scoped hold, and whether child rows may read live. */
connected: boolean
@@ -100,7 +97,6 @@ export function useMobileStructuredAgentSession(args: {
const {
agent,
appendComposerText,
callerIdentity = '',
client,
connected,
sessionId,
@@ -121,7 +117,6 @@ export function useMobileStructuredAgentSession(args: {
const stateArgs = { client, sessionId, sessionKey, enabled, connected }
const { state, stateRef, queuedMessages, queuePause, loadingOlder, loadEarlier } =
useMobileStructuredAgentState(stateArgs)
useMobileStructuredSendOperationReconciliation(state.submissions, queuedMessages)
const mutate = useMobileStructuredAgentMutate({
client,
@@ -154,10 +149,8 @@ export function useMobileStructuredAgentSession(args: {
const sendWithOutcome = useMobileStructuredSendWithOutcome({
agent,
callerIdentity,
client,
sessionId,
sessionKey,
enabled,
queueCapable,
stateRef,
@@ -8,7 +8,6 @@ type StructuredNativeChatAttachment = {
id?: string
path: string
previewUri: string
contentFingerprint?: string
}
export function useMobileStructuredNativeChatSendBridge(args: {
@@ -1,24 +0,0 @@
import { useEffect } from 'react'
import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types'
import type { MobileQueuedMessageFeed } from './mobile-structured-queued-message-feed'
import {
clearMobileStructuredQueuedSendOperations,
clearMobileStructuredSettledSendOperations
} from './mobile-structured-send-operation-journal'
export function useMobileStructuredSendOperationReconciliation(
submissions: readonly AgentJournalSubmission[],
queuedMessages: MobileQueuedMessageFeed = null
): void {
useEffect(() => {
void clearMobileStructuredSettledSendOperations({ submissions }).catch(() => undefined)
}, [submissions])
useEffect(() => {
if (!queuedMessages || queuedMessages.length === 0) {
return
}
void clearMobileStructuredQueuedSendOperations({
queuedMessageIds: queuedMessages.map((draft) => draft.messageId)
}).catch(() => undefined)
}, [queuedMessages])
}
@@ -33,15 +33,12 @@ export function mobileStructuredSendQueues(
export type StructuredMobileSendAttachment = StructuredAgentSessionAttachment & {
id?: string
contentFingerprint?: string
}
export function useMobileStructuredSendWithOutcome(args: {
agent: string | null
callerIdentity: string
client: RpcClient | null
sessionId: string | null
sessionKey: string
enabled: boolean
queueCapable: boolean
stateRef: { readonly current: StructuredAgentSessionState }
@@ -59,7 +56,6 @@ export function useMobileStructuredSendWithOutcome(args: {
) => Promise<MobileNativeChatSendOutcome> {
const {
agent,
callerIdentity,
client,
commandPending,
controller,
@@ -67,7 +63,6 @@ export function useMobileStructuredSendWithOutcome(args: {
onSendError,
queueCapable,
sessionId,
sessionKey,
stateRef
} = args
return useCallback(
@@ -121,8 +116,6 @@ export function useMobileStructuredSendWithOutcome(args: {
return sendMobileStructuredAgentSessionMessage({
client,
sessionId,
sessionKey,
callerIdentity,
expectedRuntimeFence: currentFence,
text,
attachments: sendAttachments,
@@ -135,7 +128,6 @@ export function useMobileStructuredSendWithOutcome(args: {
},
[
agent,
callerIdentity,
client,
commandPending,
controller,
@@ -143,7 +135,6 @@ export function useMobileStructuredSendWithOutcome(args: {
onSendError,
queueCapable,
sessionId,
sessionKey,
stateRef
]
)
+1 -2
View File
@@ -78,8 +78,7 @@ function note(key: string, value: string | null): void {
* refused page write left this map holding a value no store had taken and the next `init` handed
* the page exactly that. There is nothing to undo, because nothing is written until the answer.
*
* Returns the store's own promise, so a caller that has something to say about a refusal — the
* durable send journal is the one — still hears it, and a caller that has not is unchanged.
* Returns the store's own promise so callers can report refusals.
*/
export function persistMirrored(key: string, value: string | null): Promise<void> {
const write = value === null ? AsyncStorage.removeItem(key) : AsyncStorage.setItem(key, value)
-1
View File
@@ -63,7 +63,6 @@ src/session/use-mobile-native-chat-image-attachments.test.ts
src/session/use-mobile-pr-branch-context.test.ts
src/session/use-mobile-pr-sidebar-controller.test.ts
src/session/use-mobile-session-terminal-create-actions.test.ts
src/session/use-mobile-structured-agent-session-send.test.tsx
src/session/use-mobile-structured-agent-session.test.tsx
src/session/use-mobile-structured-prompt-responses.test.tsx
src/session/use-mobile-terminal-inventory-recovery.test.ts
-4
View File
@@ -29,10 +29,6 @@
"file": "src/transport/host-device-token-store.web.ts",
"reason": "expo-secure-store resolves to {} on web, and the bridge carries the RPC, so the page holds no device token."
},
{
"file": "src/platform/native-chat-image-fingerprint.web.ts",
"reason": "The web image uploader uses WebCrypto SHA-256 when available to reduce JavaScript hashing of large images. Native keeps the synchronous portable fingerprint; unavailable or failed web crypto uses that same portable fallback."
},
{
"file": "src/platform/host-os.web.ts",
"reason": "Platform.OS is web inside the shell's page, but the keyboard the page lifts over is the phone's, reported in that OS's own terms: iOS counts the home indicator. Keyboard and inset arithmetic branches on the host OS, so this answers the phone's OS from the WebView's user agent, and the page computes what the native screen computes."
@@ -17,21 +17,15 @@ function canonicalize(value: unknown): string {
return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalize(entry)}`).join(',')}}`
}
export function serializeStructuredAgentSessionFingerprintPayload(input: {
method: string
sessionId: string
fields: Record<string, unknown>
}): string {
return canonicalize({ method: input.method, sessionId: input.sessionId, fields: input.fields })
}
export function structuredAgentSessionPayloadFingerprint(input: {
method: string
sessionId: string
fields: Record<string, unknown>
}): string {
const bytes = sha256(
new TextEncoder().encode(serializeStructuredAgentSessionFingerprintPayload(input))
new TextEncoder().encode(
canonicalize({ method: input.method, sessionId: input.sessionId, fields: input.fields })
)
)
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
}