From 35d1f172f0651a85719990a414c44fbf281645bc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:45:41 -0700 Subject: [PATCH 1/2] refactor(native-chat): move the phone's send-id retention rule into shared code The phone decides when a retained send operation id is spent, and keys retained ids by session + trimmed text + attachment fingerprints. Both now live in src/shared/structured-agent-session-send-retention.ts so the desktop can reuse them. The phone imports them from there; no behaviour change, and the saved key domains are unchanged so retained ids on an upgraded phone still match. --- .../mobile-structured-agent-session-cancel.ts | 6 +- .../mobile-structured-agent-session-rpc.ts | 19 +- .../mobile-structured-agent-session-send.ts | 42 ++--- ...-structured-send-operation-journal.test.ts | 23 +-- ...obile-structured-send-operation-journal.ts | 11 -- .../structured-agent-session-mutation.ts | 11 ++ ...tured-agent-session-send-retention.test.ts | 170 +++++++++++++++--- ...structured-agent-session-send-retention.ts | 97 +++++++--- 8 files changed, 252 insertions(+), 127 deletions(-) rename mobile/src/session/mobile-structured-send-delivery.test.ts => src/shared/structured-agent-session-send-retention.test.ts (56%) rename mobile/src/session/mobile-structured-send-delivery.ts => src/shared/structured-agent-session-send-retention.ts (51%) diff --git a/mobile/src/session/mobile-structured-agent-session-cancel.ts b/mobile/src/session/mobile-structured-agent-session-cancel.ts index 3a3286510529..0fa7e1953c7c 100644 --- a/mobile/src/session/mobile-structured-agent-session-cancel.ts +++ b/mobile/src/session/mobile-structured-agent-session-cancel.ts @@ -2,11 +2,9 @@ import type { AgentSessionCancelResult } from '../../../src/shared/agent-session import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types' import type { StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer' import { activeStructuredAgentSessionTurnId } from '../../../src/shared/structured-agent-session-live-turn' +import type { StructuredAgentSessionMutationCallResult } from '../../../src/shared/structured-agent-session-mutation' import type { RpcClient } from '../transport/rpc-client' -import { - requestStructuredAgentSessionMutation, - type StructuredAgentSessionMutationCallResult -} from './mobile-structured-agent-session-rpc' +import { requestStructuredAgentSessionMutation } from './mobile-structured-agent-session-rpc' type PromptIdentity = { itemId: string; expectedRevision: number } diff --git a/mobile/src/session/mobile-structured-agent-session-rpc.ts b/mobile/src/session/mobile-structured-agent-session-rpc.ts index 7ccbc10e2198..72d77cc2dd12 100644 --- a/mobile/src/session/mobile-structured-agent-session-rpc.ts +++ b/mobile/src/session/mobile-structured-agent-session-rpc.ts @@ -1,8 +1,8 @@ -import type { - AgentSessionMutationResult, - AgentSessionWireRefusalCode -} from '../../../src/shared/agent-session-wire' -import { structuredAgentSessionPayloadFingerprint } from '../../../src/shared/structured-agent-session-mutation' +import type { AgentSessionMutationResult } from '../../../src/shared/agent-session-wire' +import { + structuredAgentSessionPayloadFingerprint, + type StructuredAgentSessionMutationCallResult +} from '../../../src/shared/structured-agent-session-mutation' import { agentSessionRefusalNotice, agentSessionWriteFailureNotice, @@ -24,15 +24,6 @@ import { MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS } from './mobile-native-chat-se export const STRUCTURED_SEND_TIMEOUT_MS = 15_000 -export type StructuredAgentSessionMutationCallResult = - | { 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: 'unknown' } - export type StructuredAgentSessionMutationResult = | { status: 'accepted'; value: TValue; sameFence: boolean } | { status: 'rejected' } diff --git a/mobile/src/session/mobile-structured-agent-session-send.ts b/mobile/src/session/mobile-structured-agent-session-send.ts index ce7f60a8b929..97a3a530149d 100644 --- a/mobile/src/session/mobile-structured-agent-session-send.ts +++ b/mobile/src/session/mobile-structured-agent-session-send.ts @@ -3,10 +3,11 @@ import { structuredAgentSessionSendBody, type StructuredAgentSessionAttachment } from '../../../src/shared/structured-agent-session-outbox' +import { structuredAgentSessionPayloadFingerprint } from '../../../src/shared/structured-agent-session-mutation' import { - structuredAgentSessionDomainFingerprint, - structuredAgentSessionPayloadFingerprint -} from '../../../src/shared/structured-agent-session-mutation' + structuredAgentSessionSendDelivery, + structuredAgentSessionSendIntentKey +} from '../../../src/shared/structured-agent-session-send-retention' import type { RpcClient } from '../transport/rpc-client' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import { @@ -14,13 +15,11 @@ import { timeoutForDeadline } from './mobile-structured-agent-session-rpc' import { structuredSessionOperationId } from './structured-session-operation-id' -import { mobileStructuredSendDelivery } from './mobile-structured-send-delivery' import { bypassedMobileStructuredSendOperationId, clearMobileStructuredSendOperation, forgetBypassedMobileStructuredSendOperation, getOrCreateMobileStructuredSendOperation, - mobileStructuredSendOperationKey, rememberBypassedMobileStructuredSendOperation } from './mobile-structured-send-operation-journal' @@ -57,31 +56,12 @@ export async function sendMobileStructuredAgentSessionMessage(input: { 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 intent = { sessionKey: input.sessionKey, text: input.text, attachments: input.attachments } + const queuedOperationKey = structuredAgentSessionSendIntentKey({ + ...intent, + delivery: 'queue-if-active' + }) + const immediateOperationKey = structuredAgentSessionSendIntentKey(intent) const requestedOperationKey = input.delivery ? queuedOperationKey : immediateOperationKey const attachmentPaths = input.attachments.map((attachment) => attachment.path) const resendKey = input.resendOperationKey ?? requestedOperationKey @@ -144,7 +124,7 @@ export async function sendMobileStructuredAgentSessionMessage(input: { clientOperationId: operation.operationId, timeoutMs }) - const outcome = mobileStructuredSendDelivery(result, operation.retained) + const outcome = structuredAgentSessionSendDelivery(result, operation.retained) if (input.bypassRetainedRecord && outcome.operationIdSpent) { forgetBypassedMobileStructuredSendOperation(operationKey, operation.operationId) } diff --git a/mobile/src/session/mobile-structured-send-operation-journal.test.ts b/mobile/src/session/mobile-structured-send-operation-journal.test.ts index 3c76e1d1c3fc..a4879755e1c7 100644 --- a/mobile/src/session/mobile-structured-send-operation-journal.test.ts +++ b/mobile/src/session/mobile-structured-send-operation-journal.test.ts @@ -15,9 +15,9 @@ import { clearMobileStructuredSettledSendOperations, getOrCreateMobileStructuredSendOperation as getOrCreatePersistedOperation, mobileStructuredSendCallerFingerprint, - mobileStructuredSendOperationKey, resetMobileStructuredSendOperationJournalForTests } from './mobile-structured-send-operation-journal' +import { structuredAgentSessionSendOperationKey } from '../../../src/shared/structured-agent-session-send-retention' import { readMirroredStorage } from '../storage/mirrored-storage-keys' const NOW = 1_900_000_000_000 @@ -156,7 +156,7 @@ describe('mobile structured send operation journal', () => { 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({ + const operationKey = structuredAgentSessionSendOperationKey({ sessionKey, intentFingerprint: payloadFingerprint }) @@ -190,7 +190,7 @@ describe('mobile structured send operation journal', () => { 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({ + const operationKey = structuredAgentSessionSendOperationKey({ sessionKey, intentFingerprint: payloadFingerprint }) @@ -223,7 +223,7 @@ describe('mobile structured send operation journal', () => { 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({ + const operationKey = structuredAgentSessionSendOperationKey({ sessionKey, intentFingerprint: payloadFingerprint }) @@ -411,21 +411,6 @@ describe('mobile structured send operation journal', () => { ).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, diff --git a/mobile/src/session/mobile-structured-send-operation-journal.ts b/mobile/src/session/mobile-structured-send-operation-journal.ts index 98937ad1bbbb..4d8eea76086c 100644 --- a/mobile/src/session/mobile-structured-send-operation-journal.ts +++ b/mobile/src/session/mobile-structured-send-operation-journal.ts @@ -32,17 +32,6 @@ type OperationJournal = z.infer const mutations: { tail: Promise } = { 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', diff --git a/src/shared/structured-agent-session-mutation.ts b/src/shared/structured-agent-session-mutation.ts index 4c25de1a1bdc..6c20d08ad929 100644 --- a/src/shared/structured-agent-session-mutation.ts +++ b/src/shared/structured-agent-session-mutation.ts @@ -1,5 +1,16 @@ +import type { AgentSessionWireRefusalCode } from './agent-session-wire' import { sha256 } from './sha256' +/** What a client's `agentSession.*` mutation call came to, before any lane reads it. */ +export type StructuredAgentSessionMutationCallResult = + | { 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: 'unknown' } + function canonicalize(value: unknown): string { if (value === null || typeof value !== 'object') { return JSON.stringify(value ?? null) diff --git a/mobile/src/session/mobile-structured-send-delivery.test.ts b/src/shared/structured-agent-session-send-retention.test.ts similarity index 56% rename from mobile/src/session/mobile-structured-send-delivery.test.ts rename to src/shared/structured-agent-session-send-retention.test.ts index 6305bf75df57..0d12b9423bc1 100644 --- a/mobile/src/session/mobile-structured-send-delivery.test.ts +++ b/src/shared/structured-agent-session-send-retention.test.ts @@ -1,9 +1,36 @@ import { describe, expect, it } from 'vitest' -import type { AgentJournalDispatchState } from '../../../src/shared/agent-session-journal-types' -import type { AgentSessionSendResult } from '../../../src/shared/agent-session-wire' -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' +import type { AgentJournalDispatchState } from './agent-session-journal-types' +import type { AgentSessionSendResult } from './agent-session-wire' +import { + structuredAgentSessionDomainFingerprint, + type StructuredAgentSessionMutationCallResult +} from './structured-agent-session-mutation' +import { + structuredAgentSessionSendDelivery, + structuredAgentSessionSendIntentKey, + structuredAgentSessionSendOperationKey +} from './structured-agent-session-send-retention' + +// The shape a real host answers `agentSession.send` with: its `dispatchState` is the only +// thing that says whether the message landed. +function structuredSendResultFixture( + dispatchState: AgentJournalDispatchState, + reason: string | null = null +): AgentSessionSendResult { + return { + clientMessageId: 'msg-1', + submission: { + clientMessageId: 'msg-1', + fence: 3, + payloadFingerprint: 'fingerprint', + dispatchState, + providerItemId: null, + reason, + submittedAt: 10, + resolvedAt: dispatchState === 'pending' ? null : 10 + } + } +} function accepted( dispatchState: AgentJournalDispatchState, @@ -12,16 +39,16 @@ function accepted( return { status: 'accepted', value: structuredSendResultFixture(dispatchState, reason) } } -describe('mobileStructuredSendDelivery', () => { +describe('structuredAgentSessionSendDelivery', () => { 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. - expect(mobileStructuredSendDelivery({ status: 'unknown' })).toEqual({ + expect(structuredAgentSessionSendDelivery({ status: 'unknown' })).toEqual({ outcome: 'unknown', operationIdSpent: false, error: null }) - expect(mobileStructuredSendDelivery(accepted('unknown'))).toEqual({ + expect(structuredAgentSessionSendDelivery(accepted('unknown'))).toEqual({ outcome: 'unknown', operationIdSpent: false, error: null @@ -31,7 +58,7 @@ describe('mobileStructuredSendDelivery', () => { 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({ + expect(structuredAgentSessionSendDelivery(accepted(dispatchState))).toEqual({ outcome: 'accepted', operationIdSpent: true, error: null @@ -52,12 +79,12 @@ describe('mobileStructuredSendDelivery', () => { } } const outcome = 'queued' - expect(mobileStructuredSendDelivery(queued)).toEqual({ + expect(structuredAgentSessionSendDelivery(queued)).toEqual({ outcome, operationIdSpent: true, error: null }) - expect(mobileStructuredSendDelivery(queued, true)).toEqual({ + expect(structuredAgentSessionSendDelivery(queued, true)).toEqual({ outcome, operationIdSpent: true, error: null @@ -83,7 +110,7 @@ describe('mobileStructuredSendDelivery', () => { } } } - expect(mobileStructuredSendDelivery(replay, true)).toEqual({ + expect(structuredAgentSessionSendDelivery(replay, true)).toEqual({ outcome: 'unknown', operationIdSpent: true, error: null @@ -95,7 +122,7 @@ describe('mobileStructuredSendDelivery', () => { status: 'accepted', value: JSON.parse('{}') } - expect(mobileStructuredSendDelivery(malformed, true)).toEqual({ + expect(structuredAgentSessionSendDelivery(malformed, true)).toEqual({ outcome: 'unknown', operationIdSpent: false, error: null @@ -104,7 +131,7 @@ describe('mobileStructuredSendDelivery', () => { 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({ + expect(structuredAgentSessionSendDelivery(accepted(dispatchState), true)).toEqual({ outcome: 'unknown', operationIdSpent: false, error: null @@ -117,7 +144,7 @@ describe('mobileStructuredSendDelivery', () => { // id makes the retry a first delivery. The marker itself names nothing a person // can act on, so it must not reach the screen. expect( - mobileStructuredSendDelivery(accepted('rejected', 'provider_write_failed: broken pipe')) + structuredAgentSessionSendDelivery(accepted('rejected', 'provider_write_failed: broken pipe')) ).toEqual({ outcome: 'rejected', operationIdSpent: true, @@ -127,7 +154,7 @@ describe('mobileStructuredSendDelivery', () => { it('shows a provider content rejection verbatim', () => { expect( - mobileStructuredSendDelivery(accepted('rejected', 'Claude does not support .bmp')) + structuredAgentSessionSendDelivery(accepted('rejected', 'Claude does not support .bmp')) ).toEqual({ outcome: 'rejected', operationIdSpent: true, @@ -137,28 +164,28 @@ describe('mobileStructuredSendDelivery', () => { it('spends only refusals that prove the operation is settled', () => { expect( - mobileStructuredSendDelivery({ + structuredAgentSessionSendDelivery({ status: 'refused', code: 'agent_session_operation_invalid', message: 'Invalid operation' }) ).toEqual({ outcome: 'rejected', operationIdSpent: true, error: 'Invalid operation' }) expect( - mobileStructuredSendDelivery({ + structuredAgentSessionSendDelivery({ status: 'refused', code: 'agent_session_checkpoint_stale', message: 'Fence moved' }) ).toEqual({ outcome: 'rejected', operationIdSpent: false, error: 'Fence moved' }) expect( - mobileStructuredSendDelivery({ + structuredAgentSessionSendDelivery({ status: 'refused', code: 'agent_session_operation_unknown', message: 'Outcome unknown' }) ).toEqual({ outcome: 'unknown', operationIdSpent: false, error: null }) expect( - mobileStructuredSendDelivery({ + structuredAgentSessionSendDelivery({ status: 'failed', message: 'Your message was not sent. Send it again.' }) @@ -171,7 +198,7 @@ describe('mobileStructuredSendDelivery', () => { it('never releases an ambiguous id on a later RPC refusal or failure', () => { expect( - mobileStructuredSendDelivery( + structuredAgentSessionSendDelivery( { status: 'refused', code: 'agent_session_operation_expired', @@ -181,7 +208,7 @@ describe('mobileStructuredSendDelivery', () => { ) ).toEqual({ outcome: 'rejected', operationIdSpent: false, error: 'Operation expired' }) expect( - mobileStructuredSendDelivery( + structuredAgentSessionSendDelivery( { status: 'failed', message: 'Your message was not sent. Send it again.' }, true ) @@ -196,7 +223,7 @@ describe('mobileStructuredSendDelivery', () => { // 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( + structuredAgentSessionSendDelivery( { status: 'failed', message: 'Your message was not sent. Send it again.', @@ -212,7 +239,7 @@ describe('mobileStructuredSendDelivery', () => { // Any other refusal (an auth failure, a host without the method) proves nothing // about an earlier delivery of this id. expect( - mobileStructuredSendDelivery( + structuredAgentSessionSendDelivery( { status: 'failed', message: 'Your message was not sent.' }, true ) @@ -220,14 +247,101 @@ describe('mobileStructuredSendDelivery', () => { }) it('fails closed when an invalid host response omits the required submission', () => { - const result = { + const result: StructuredAgentSessionMutationCallResult = { status: 'accepted', - value: { clientMessageId: 'msg-1' } - } as unknown as StructuredAgentSessionMutationCallResult - expect(mobileStructuredSendDelivery(result)).toEqual({ + value: JSON.parse('{"clientMessageId":"msg-1"}') + } + expect(structuredAgentSessionSendDelivery(result)).toEqual({ outcome: 'unknown', operationIdSpent: false, error: null }) }) }) + +describe('structuredAgentSessionSendOperationKey', () => { + it('hashes the session scope and payload instead of retaining message bodies', () => { + const first = structuredAgentSessionSendOperationKey({ + sessionKey: 'host-a:session-a', + intentFingerprint: 'message-body-fingerprint' + }) + const second = structuredAgentSessionSendOperationKey({ + 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') + }) +}) + +describe('structuredAgentSessionSendIntentKey', () => { + const sessionKey = 'host-a:session-a' + const attachments = [ + { path: '/tmp/a.png', previewUri: 'file:///a.png', contentFingerprint: 'a'.repeat(64) }, + { path: '/tmp/b.png', previewUri: 'file:///b.png' } + ] + + function savedKey(fields: Record): string { + return structuredAgentSessionSendOperationKey({ + sessionKey, + intentFingerprint: structuredAgentSessionDomainFingerprint({ + domain: 'mobile.agentSession.send.intent', + sessionId: sessionKey, + fields + }) + }) + } + + it('keeps the key the phone already saved its retained ids under', () => { + // Any change here orphans every retained id on an upgraded phone. + const fields = { + text: 'hello', + attachments: [ + 'a'.repeat(64), + structuredAgentSessionDomainFingerprint({ + domain: 'mobile.nativeChat.image.preview', + sessionId: '', + fields: { previewUri: 'file:///b.png' } + }) + ] + } + expect( + structuredAgentSessionSendIntentKey({ sessionKey, text: 'hello \n', attachments }) + ).toBe(savedKey(fields)) + expect( + structuredAgentSessionSendIntentKey({ + sessionKey, + text: 'hello', + attachments, + delivery: 'queue-if-active' + }) + ).toBe(savedKey({ ...fields, delivery: 'queue-if-active' })) + }) + + it('separates sessions, texts, attachments and delivery', () => { + const base = structuredAgentSessionSendIntentKey({ sessionKey, text: 'hello', attachments: [] }) + expect( + structuredAgentSessionSendIntentKey({ + sessionKey: 'host-b:session-a', + text: 'hello', + attachments: [] + }) + ).not.toBe(base) + expect( + structuredAgentSessionSendIntentKey({ sessionKey, text: 'hello!', attachments: [] }) + ).not.toBe(base) + expect( + structuredAgentSessionSendIntentKey({ sessionKey, text: 'hello', attachments }) + ).not.toBe(base) + expect( + structuredAgentSessionSendIntentKey({ + sessionKey, + text: 'hello', + attachments: [], + delivery: 'queue-if-active' + }) + ).not.toBe(base) + }) +}) diff --git a/mobile/src/session/mobile-structured-send-delivery.ts b/src/shared/structured-agent-session-send-retention.ts similarity index 51% rename from mobile/src/session/mobile-structured-send-delivery.ts rename to src/shared/structured-agent-session-send-retention.ts index 466a0d29ff8a..3a3173f1875e 100644 --- a/mobile/src/session/mobile-structured-send-delivery.ts +++ b/src/shared/structured-agent-session-send-retention.ts @@ -1,15 +1,16 @@ -// What one `agentSession.send` answer means to a client with no outbox. +// When a client may keep, and must give up, the operation id it sent a message under. // // 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. +// `disposeStructuredAgentSessionSendResult`; a client with no outbox 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: +// The id is the whole safety mechanism here. A client keys its retained ids by +// intent (`structuredAgentSessionSendIntentKey`), 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. @@ -25,25 +26,32 @@ // 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 { structuredAgentSessionRejectionNotice } from '../../../src/shared/structured-agent-session-send-disposition' -import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' -import type { StructuredAgentSessionMutationCallResult } from './mobile-structured-agent-session-rpc' +import type { AgentJournalSubmission } from './agent-session-journal-types' +import type { AgentSessionSendResult } from './agent-session-wire' +import { agentSessionRefusalOperationState } from './agent-session-refusal-retry' +import type { StructuredAgentSessionAttachment } from './structured-agent-session-outbox' +import { + structuredAgentSessionDomainFingerprint, + type StructuredAgentSessionMutationCallResult +} from './structured-agent-session-mutation' +import { structuredAgentSessionRejectionNotice } from './structured-agent-session-send-disposition' -export type MobileStructuredSendDelivery = { - outcome: MobileNativeChatSendOutcome +/** 'queued' = the host holds the message as a queued draft, so it shows as a + * card above the composer, never a transcript echo. */ +export type StructuredAgentSessionSendOutcome = 'accepted' | 'rejected' | 'unknown' | 'queued' + +export type StructuredAgentSessionSendDelivery = { + outcome: StructuredAgentSessionSendOutcome /** 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 } -export function mobileStructuredSendDelivery( +export function structuredAgentSessionSendDelivery( result: StructuredAgentSessionMutationCallResult, retained = false -): MobileStructuredSendDelivery { +): StructuredAgentSessionSendDelivery { if (result.status === 'unknown') { return { outcome: 'unknown', operationIdSpent: false, error: null } } @@ -80,7 +88,7 @@ export function mobileStructuredSendDelivery( 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. + // Which send the client meant stays unconfirmed, as for any retained replay of a live send. return { outcome: 'unknown', operationIdSpent: true, error: null } } if (!submission || submission.dispatchState === 'unknown') { @@ -100,3 +108,52 @@ export function mobileStructuredSendDelivery( } return { outcome: 'accepted', operationIdSpent: true, error: null } } + +export type StructuredAgentSessionSendIntentAttachment = StructuredAgentSessionAttachment & { + contentFingerprint?: string +} + +// The `mobile.*` domains are part of keys the phone has already saved; renaming them orphans +// every retained id. +export function structuredAgentSessionSendOperationKey(input: { + sessionKey: string + intentFingerprint: string +}): string { + return structuredAgentSessionDomainFingerprint({ + domain: 'mobile.agentSession.send.operation', + sessionId: input.sessionKey, + fields: { intentFingerprint: input.intentFingerprint } + }) +} + +/** The key a retained send id is saved under: the session, the trimmed text and each + * attachment's fingerprint, plus `delivery` when the send asks to queue. */ +export function structuredAgentSessionSendIntentKey(input: { + sessionKey: string + text: string + attachments: readonly StructuredAgentSessionSendIntentAttachment[] + delivery?: 'queue-if-active' +}): string { + 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. + return structuredAgentSessionSendOperationKey({ + sessionKey: input.sessionKey, + intentFingerprint: structuredAgentSessionDomainFingerprint({ + domain: 'mobile.agentSession.send.intent', + sessionId: input.sessionKey, + fields: input.delivery ? { ...intentFields, delivery: input.delivery } : intentFields + }) + }) +} From 9c99a650f6f3b658cc939f458377b61d7d0ce558 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:30:07 -0700 Subject: [PATCH 2/2] test(native-chat): point the send-at-most-once gate at the moved rule's tests The reliability gate still named the phone's old delivery test, which the move deleted, so the manifest check failed. It now runs the shared test, with fresh evidence runs. The send-answer fixture moves to src/shared so the shared and phone tests use one copy, and the phone's send outcome type becomes an alias of the shared one. The saved-key pin now rebuilds the operation domain literally, so renaming it fails the test. --- config/reliability-gates.jsonc | 22 +++++----- mobile/src/session/mobile-native-chat-send.ts | 3 +- ...ile-structured-agent-session-send.test.tsx | 2 +- ...e-mobile-structured-agent-session.test.tsx | 2 +- ...ructured-agent-send-result.test-fixture.ts | 4 +- ...tured-agent-session-send-retention.test.ts | 40 ++++++------------- 6 files changed, 29 insertions(+), 44 deletions(-) rename {mobile/src/session => src/shared}/structured-agent-send-result.test-fixture.ts (80%) diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 5b4c135c1da4..f876ee8b6058 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -948,12 +948,13 @@ "invariant": "One structured-send operation id causes at most one provider dispatch. A recorded or transport-ambiguous send reuses that id across retry, caller reconnect, client remount, and journal recovery; 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.", "commands": [ - "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-disposition.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/use-structured-agent-session-outbox.test.tsx src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.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 test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-disposition.test.ts src/shared/structured-agent-session-send-retention.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/use-structured-agent-session-outbox.test.tsx src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.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/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" ], "testFiles": [ "src/shared/agent-session-operation-ledger.test.ts", "src/shared/structured-agent-session-send-disposition.test.ts", + "src/shared/structured-agent-session-send-retention.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", @@ -967,7 +968,6 @@ "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", @@ -1033,22 +1033,22 @@ ], "evidenceRuns": [ { - "date": "2026-09-12", + "date": "2026-10-02", "runner": "local", "platform": "macos", - "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-disposition.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/use-structured-agent-session-outbox.test.tsx src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-disposition.test.ts src/shared/structured-agent-session-send-retention.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/use-structured-agent-session-outbox.test.tsx src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", "result": "passed", - "durationSeconds": 22.1, - "summary": "Thirteen focused host, shared, renderer, and orchestration files passed 148 tests." + "durationSeconds": 29.3, + "summary": "Thirteen focused host, shared, renderer, and orchestration files passed 196 tests." }, { - "date": "2026-09-12", + "date": "2026-10-02", "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", + "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/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." + "durationSeconds": 2.68, + "summary": "Eight focused mobile session, attachment, and transport files passed 112 tests." } ], "runtimeBudget": { diff --git a/mobile/src/session/mobile-native-chat-send.ts b/mobile/src/session/mobile-native-chat-send.ts index 2a3bc5a60b2a..c3e53a055922 100644 --- a/mobile/src/session/mobile-native-chat-send.ts +++ b/mobile/src/session/mobile-native-chat-send.ts @@ -4,6 +4,7 @@ import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client' import { nativeChatTerminalWrite } from './mobile-session-write-operations' import { typeAgentTuiCommand } from '../../../src/shared/agent-tui-command-typing' +import type { StructuredAgentSessionSendOutcome } from '../../../src/shared/structured-agent-session-send-retention' /** What a native-chat write takes, named from an operation so no module names the raw port. */ export type MobileNativeChatRpcSender = Parameters[0] @@ -31,7 +32,7 @@ type MobileNativeChatSendArgs = { * frame was written) — callers must not present it as a definite send failure. * 'queued' = structured lane only: the host holds the message as a queued * draft, so it shows as a card above the composer, never a transcript echo. */ -export type MobileNativeChatSendOutcome = 'accepted' | 'rejected' | 'unknown' | 'queued' +export type MobileNativeChatSendOutcome = StructuredAgentSessionSendOutcome /** What a terminal write can answer: the PTY lane has no draft queue. */ export type MobileNativeChatWriteOutcome = Exclude diff --git a/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx index 34b66f1dd1fc..8e5c207f575f 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx @@ -6,7 +6,7 @@ import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-sessi import type { RpcClient } from '../transport/rpc-client' 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 { structuredSendResultFixture } from '../../../src/shared/structured-agent-send-result.test-fixture' import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' const asyncStorage = vi.hoisted(() => ({ diff --git a/mobile/src/session/use-mobile-structured-agent-session.test.tsx b/mobile/src/session/use-mobile-structured-agent-session.test.tsx index 04b8e87304a2..d2f377eb1bfc 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session.test.tsx @@ -11,7 +11,7 @@ import type { RpcClient } from '../transport/rpc-client' import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import { formatQuestionFreeTextAnswer } from './mobile-native-chat-question' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' -import { structuredSendResultFixture } from './structured-agent-send-result.test-fixture' +import { structuredSendResultFixture } from '../../../src/shared/structured-agent-send-result.test-fixture' import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' const asyncStorage = vi.hoisted(() => ({ diff --git a/mobile/src/session/structured-agent-send-result.test-fixture.ts b/src/shared/structured-agent-send-result.test-fixture.ts similarity index 80% rename from mobile/src/session/structured-agent-send-result.test-fixture.ts rename to src/shared/structured-agent-send-result.test-fixture.ts index 546d9531f29b..684ca968c567 100644 --- a/mobile/src/session/structured-agent-send-result.test-fixture.ts +++ b/src/shared/structured-agent-send-result.test-fixture.ts @@ -3,8 +3,8 @@ // message landed. A fixture that omits it lets a client claim delivery from `ok` // alone, which is the bug these tests exist to hold shut. -import type { AgentJournalDispatchState } from '../../../src/shared/agent-session-journal-types' -import type { AgentSessionSendResult } from '../../../src/shared/agent-session-wire' +import type { AgentJournalDispatchState } from './agent-session-journal-types' +import type { AgentSessionSendResult } from './agent-session-wire' export function structuredSendResultFixture( dispatchState: AgentJournalDispatchState, diff --git a/src/shared/structured-agent-session-send-retention.test.ts b/src/shared/structured-agent-session-send-retention.test.ts index 0d12b9423bc1..248022a13157 100644 --- a/src/shared/structured-agent-session-send-retention.test.ts +++ b/src/shared/structured-agent-session-send-retention.test.ts @@ -10,27 +10,7 @@ import { structuredAgentSessionSendIntentKey, structuredAgentSessionSendOperationKey } from './structured-agent-session-send-retention' - -// The shape a real host answers `agentSession.send` with: its `dispatchState` is the only -// thing that says whether the message landed. -function structuredSendResultFixture( - dispatchState: AgentJournalDispatchState, - reason: string | null = null -): AgentSessionSendResult { - return { - clientMessageId: 'msg-1', - submission: { - clientMessageId: 'msg-1', - fence: 3, - payloadFingerprint: 'fingerprint', - dispatchState, - providerItemId: null, - reason, - submittedAt: 10, - resolvedAt: dispatchState === 'pending' ? null : 10 - } - } -} +import { structuredSendResultFixture } from './structured-agent-send-result.test-fixture' function accepted( dispatchState: AgentJournalDispatchState, @@ -283,14 +263,18 @@ describe('structuredAgentSessionSendIntentKey', () => { { path: '/tmp/b.png', previewUri: 'file:///b.png' } ] + // Rebuilt from the domains alone, so renaming any of them fails the pin below. function savedKey(fields: Record): string { - return structuredAgentSessionSendOperationKey({ - sessionKey, - intentFingerprint: structuredAgentSessionDomainFingerprint({ - domain: 'mobile.agentSession.send.intent', - sessionId: sessionKey, - fields - }) + return structuredAgentSessionDomainFingerprint({ + domain: 'mobile.agentSession.send.operation', + sessionId: sessionKey, + fields: { + intentFingerprint: structuredAgentSessionDomainFingerprint({ + domain: 'mobile.agentSession.send.intent', + sessionId: sessionKey, + fields + }) + } }) }