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/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/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-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 58% rename from mobile/src/session/mobile-structured-send-delivery.test.ts rename to src/shared/structured-agent-session-send-retention.test.ts index 6305bf75df57..248022a13157 100644 --- a/mobile/src/session/mobile-structured-send-delivery.test.ts +++ b/src/shared/structured-agent-session-send-retention.test.ts @@ -1,8 +1,15 @@ 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 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' import { structuredSendResultFixture } from './structured-agent-send-result.test-fixture' function accepted( @@ -12,16 +19,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 +38,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 +59,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 +90,7 @@ describe('mobileStructuredSendDelivery', () => { } } } - expect(mobileStructuredSendDelivery(replay, true)).toEqual({ + expect(structuredAgentSessionSendDelivery(replay, true)).toEqual({ outcome: 'unknown', operationIdSpent: true, error: null @@ -95,7 +102,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 +111,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 +124,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 +134,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 +144,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 +178,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 +188,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 +203,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 +219,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 +227,105 @@ 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' } + ] + + // Rebuilt from the domains alone, so renaming any of them fails the pin below. + function savedKey(fields: Record): string { + return structuredAgentSessionDomainFingerprint({ + domain: 'mobile.agentSession.send.operation', + sessionId: sessionKey, + fields: { + 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 + }) + }) +}