Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
0c7cdef
fix(native-chat): give every chat write press its own operation id
brennanb2025 Sep 29, 2026
869b03f
fix(native-chat): answer a /clear pressed again after it committed wi…
brennanb2025 Sep 29, 2026
85fa9bb
test(native-chat): scope the prompt-cancel tests' journal rows to the…
brennanb2025 Sep 29, 2026
19ae463
test(native-chat): give the repeated prompt Cancel its own host test …
brennanb2025 Sep 29, 2026
b044e60
chore(reliability): drop the deleted mobile id-retention test from th…
brennanb2025 Sep 29, 2026
75cefde
Merge origin/main into brennanb2025/chat-op-id-per-action
brennanb2025 Sep 30, 2026
f98f6aa
test(native-chat): a repeated draft press under its own id is answere…
brennanb2025 Sep 30, 2026
5f67247
fix(native-chat): the host joins a second Stop of a turn it is still …
brennanb2025 Sep 30, 2026
ed59598
Merge origin/main into brennanb2025/chat-op-id-per-action
brennanb2025 Sep 30, 2026
1b0e726
fix(mobile): keep the Stop's fence narrowed, and type the held card a…
brennanb2025 Sep 30, 2026
45734b8
Merge brennanb2025/clear-starts-on-first-send into brennanb2025/chat-…
brennanb2025 Sep 30, 2026
7801b8f
test(native-chat): open the repeat-press host on the shared journal d…
brennanb2025 Sep 30, 2026
b0a654a
fix(native-chat): a second Stop of a turn an earlier Stop answered fo…
brennanb2025 Sep 30, 2026
54bce9f
Merge brennanb2025/clear-starts-on-first-send into brennanb2025/chat-…
brennanb2025 Sep 30, 2026
25541b7
fix(native-chat): a /compact or /clear pressed again is answered from…
brennanb2025 Sep 30, 2026
f5e0ace
Revert "fix(native-chat): a /compact or /clear pressed again is answe…
brennanb2025 Sep 30, 2026
7e95063
fix(native-chat): a Stop writes its one note only when it stopped som…
brennanb2025 Sep 30, 2026
8867990
chore: the repeated-Stop capability's comments say what it now means
brennanb2025 Sep 30, 2026
37bc020
Merge brennanb2025/clear-starts-on-first-send into brennanb2025/chat-…
brennanb2025 Sep 30, 2026
4380ad8
test(native-chat): the command-start test's client mock knows the rep…
brennanb2025 Sep 30, 2026
13a6333
Merge remote-tracking branch 'origin/brennanb2025/clear-starts-on-fir…
brennanb2025 Sep 30, 2026
65bde90
fix(native-chat): a Stop keeps the fence it was pressed at while the …
brennanb2025 Sep 30, 2026
3e11a8f
test(native-chat): a Stop keeps its pressed fence against either kind…
brennanb2025 Sep 30, 2026
a974746
Merge PR 1 (#23935) into PR 2
brennanb2025 Oct 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions config/reliability-gates.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -949,7 +949,7 @@
"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-session-operation-retention.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-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"
],
"testFiles": [
"src/shared/agent-session-operation-ledger.test.ts",
Expand All @@ -967,7 +967,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-session-operation-retention.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",
Expand Down Expand Up @@ -1046,7 +1045,7 @@
"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-session-operation-retention.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/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."
Expand Down
69 changes: 44 additions & 25 deletions mobile/src/session/mobile-structured-agent-session-cancel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { activeStructuredAgentSessionTurnId } from '../../../src/shared/structur
import type { RpcClient } from '../transport/rpc-client'
import {
requestStructuredAgentSessionMutation,
retainStructuredSessionOperationId,
type StructuredAgentSessionMutationCallResult
} from './mobile-structured-agent-session-rpc'

Expand All @@ -27,55 +26,75 @@ export async function requestMobileStructuredAgentSessionCancel(args: {
sessionId: string | null
enabled: boolean
stateRef: { readonly current: StructuredAgentSessionState }
sessionKey: string
operationIds: Map<string, string>
promptCancelSupported: boolean | null
prompt?: PromptIdentity
/** Whether the host answers a repeated Stop of a turn quietly; null until the status probe answers. */
hostAnswersRepeatedStops: boolean | null
/** Stops still on their way, by what they stop; against a host that does not answer a repeat
* quietly, a press for the same one joins it here. */
inFlight: Map<string, Promise<boolean>>
onSendError: (message: string) => void
}): Promise<boolean> {
const { client, enabled, onSendError, operationIds, sessionId, sessionKey, stateRef } = args
const { client, enabled, inFlight, onSendError, sessionId, stateRef } = args
const current = stateRef.current
const turnId = activeStructuredAgentSessionTurnId(current.items)
if (!client || !sessionId || !enabled || current.fence === null || !turnId) {
onSendError('Stop not sent')
return false
}
// Check the capability before fields enter either the fingerprint or operation key.
// Check the capability before fields enter the fingerprint.
const fields = {
turnId,
...(args.prompt && args.promptCancelSupported === true ? { prompt: args.prompt } : {})
}
const key = `${sessionKey}:agentSession.cancel:${JSON.stringify(fields)}`
const clientOperationId = retainStructuredSessionOperationId(
operationIds,
key,
operationIds.get(key)
)
// Every press is its own Stop: a kept id would be answered from the last one and stop nothing.
const fence = current.fence
const stop = () => sendStop({ client, sessionId, fence, fields, onSendError })
if (args.hostAnswersRepeatedStops === true) {
return stop()
}
// Temporary, for a host that predates the quiet repeated Stop: remove once none is supported.
const key = `${sessionId}:agentSession.cancel:${JSON.stringify(fields)}`
const joined = inFlight.get(key)
if (joined) {
return joined
}
const stopping = stop()
inFlight.set(key, stopping)
// Gone once it settles, so the next press is a new Stop.
void stopping.finally(() => {
if (inFlight.get(key) === stopping) {
inFlight.delete(key)
}
})
return stopping
}

async function sendStop(input: {
client: RpcClient
sessionId: string
fence: number
fields: Record<string, unknown>
onSendError: (message: string) => void
}): Promise<boolean> {
const result: StructuredAgentSessionMutationCallResult<AgentSessionCancelResult> =
await requestStructuredAgentSessionMutation<AgentSessionCancelResult>({
client,
client: input.client,
method: 'agentSession.cancel',
fingerprintMethod: 'agentSession.cancel',
sessionId,
expectedRuntimeFence: current.fence,
fields,
clientOperationId
sessionId: input.sessionId,
expectedRuntimeFence: input.fence,
fields: input.fields
})
// Cancel's plan recovers no unknown ledger row, so an id the host answered that
// way earns the same refusal until it expires; keeping it leaves Stop unusable.
// Transport doubt proves nothing about delivery, so it stays a replay.
if (result.status !== 'unknown' || result.hostReportedOperationUnknown === true) {
operationIds.delete(key)
}
if (result.status === 'accepted') {
return true
}
if (result.status === 'unknown') {
onSendError('Stop unconfirmed — check chat before retrying')
input.onSendError('Stop unconfirmed — check chat before retrying')
} else if (result.status === 'refused') {
onSendError(result.message)
input.onSendError(result.message)
} else if (result.status === 'failed') {
onSendError(result.message)
input.onSendError(result.message)
}
return false
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest'
import {
AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY,
AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY,
AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY
AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY,
AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY
} from '../../../src/shared/protocol-version'
import { structuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support'

Expand All @@ -11,16 +12,40 @@ describe('structuredAgentSessionHostSupport', () => {
expect(structuredAgentSessionHostSupport([])).toEqual({
promptCancel: false,
questionAnswers: false,
queuedMessages: false
queuedMessages: false,
quietRepeatedStop: false
})
expect(
structuredAgentSessionHostSupport([AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY])
).toEqual({ promptCancel: false, questionAnswers: true, queuedMessages: false })
).toEqual({
promptCancel: false,
questionAnswers: true,
queuedMessages: false,
quietRepeatedStop: false
})
expect(
structuredAgentSessionHostSupport([AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY])
).toEqual({ promptCancel: true, questionAnswers: false, queuedMessages: false })
).toEqual({
promptCancel: true,
questionAnswers: false,
queuedMessages: false,
quietRepeatedStop: false
})
expect(
structuredAgentSessionHostSupport([AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY])
).toEqual({ promptCancel: false, questionAnswers: false, queuedMessages: true })
).toEqual({
promptCancel: false,
questionAnswers: false,
queuedMessages: true,
quietRepeatedStop: false
})
expect(
structuredAgentSessionHostSupport([AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY])
).toEqual({
promptCancel: false,
questionAnswers: false,
queuedMessages: false,
quietRepeatedStop: true
})
})
})
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import {
AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY,
AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY,
AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY
AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY,
AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY
} from '../../../src/shared/protocol-version'

/** Structured-session features the connected host advertised; null until the status probe answers. */
Expand All @@ -10,6 +11,8 @@ export type StructuredAgentSessionHostSupport = {
questionAnswers: boolean
/** Mid-turn sends queue as host-held drafts; an older host keeps today's immediate path. */
queuedMessages: boolean
/** A Stop that stopped nothing adds no row, so a repeated Stop is quiet. */
quietRepeatedStop: boolean
}

export function structuredAgentSessionHostSupport(
Expand All @@ -18,6 +21,7 @@ export function structuredAgentSessionHostSupport(
return {
promptCancel: capabilities.includes(AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY),
questionAnswers: capabilities.includes(AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY),
queuedMessages: capabilities.includes(AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY)
queuedMessages: capabilities.includes(AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY),
quietRepeatedStop: capabilities.includes(AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY)
}
}
49 changes: 2 additions & 47 deletions mobile/src/session/mobile-structured-agent-session-rpc.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
import {
AGENT_SESSION_MAX_OPERATION_REPLAY_AGE_MS,
AGENT_SESSION_OPERATION_FUTURE_SKEW_MS,
parseAgentSessionOperationTimestamp
} from '../../../src/shared/agent-session-host-authority'
import type {
AgentSessionMutationResult,
AgentSessionWireRefusalCode
Expand Down Expand Up @@ -36,11 +31,7 @@ export type StructuredAgentSessionMutationCallResult<TValue> =
* 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 }
/** `hostReportedOperationUnknown` separates a host answer about the id from doubt
* about the effect. Whether that id can still be retried is the method's own
* question: a plan that recovers an unknown ledger row replays or reruns it, one
* that does not refuses the same id until the row expires. */
| { status: 'unknown'; hostReportedOperationUnknown?: true }
| { status: 'unknown' }

export type StructuredAgentSessionMutationResult<TValue> =
| { status: 'accepted'; value: TValue; sameFence: boolean }
Expand Down Expand Up @@ -108,42 +99,6 @@ export async function callAgentSession<TResult>(
return response.result as TResult
}

function isReplayableStructuredSessionOperationId(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_OPERATION_REPLAY_AGE_MS
)
}

/**
* Retains transient non-send mutation ids while the host can still replay them. Structured sends
* use the durable journal because delivery ambiguity itself does not expire.
*/
export function retainStructuredSessionOperationId(
operationIds: Map<string, string>,
key: string,
operationId?: string,
now: number = Date.now()
): string {
const retainedOperationId =
operationId && isReplayableStructuredSessionOperationId(operationId, now)
? operationId
: structuredSessionOperationId(now)
operationIds.delete(key)
operationIds.set(key, retainedOperationId)
for (const [retainedKey, retainedId] of operationIds) {
if (retainedKey === key) {
continue
}
if (!isReplayableStructuredSessionOperationId(retainedId, now)) {
operationIds.delete(retainedKey)
}
}
return retainedOperationId
}

export function timeoutForDeadline(deadline: number | undefined): number | null {
if (deadline === undefined) {
return STRUCTURED_SEND_TIMEOUT_MS
Expand Down Expand Up @@ -205,7 +160,7 @@ export async function requestStructuredAgentSessionMutation<TValue>(args: {
(method === 'agentSession.cancel' || method === 'agentSession.conversationCommand') &&
result.refusal.code === 'agent_session_operation_unknown'
) {
return { status: 'unknown', hostReportedOperationUnknown: true }
return { status: 'unknown' }
}
return result.ok
? { status: 'accepted', value: result.value }
Expand Down
53 changes: 22 additions & 31 deletions mobile/src/session/mobile-structured-composer-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,7 @@ function setup() {
client: { sendRequest } as unknown as RpcClient,
sessionId: 'session',
fence: 1,
sessionKey: 'session:1',
pending: { current: false },
operationIds: new Map(),
controller: {
agent: 'codex',
snapshot: [],
Expand Down Expand Up @@ -61,41 +59,34 @@ describe('mobile structured conversation commands', () => {
expect.objectContaining({ command: text.slice(1) }),
expect.anything()
)
expect(input.operationIds.size).toBe(0)
}
)
it('retains the exact operation ID after an unknown response', async () => {
const { input, sendRequest } = setup()
sendRequest.mockResolvedValueOnce({
ok: true,
result: { ok: true, value: { command: 'compact', state: 'unknown' } }
})
expect(await dispatchMobileStructuredCommand(input)).toBe('unknown')
expect(await dispatchMobileStructuredCommand(input)).toBe('accepted')
expect(sendRequest.mock.calls[0]?.[1]).toEqual(sendRequest.mock.calls[1]?.[1])
})
it('retains operation identity when the host explicitly reports an unknown ledger outcome', async () => {
const { input, sendRequest } = setup()
sendRequest.mockResolvedValueOnce({
ok: true,
result: {
ok: false,
refusal: { code: 'agent_session_operation_unknown', message: 'unconfirmed' }
it.each([
[
'the host reports the command unconfirmed',
{ ok: true, result: { ok: true, value: { command: 'compact', state: 'unknown' } } }
],
[
'the host cannot say what became of it',
{
ok: true,
result: {
ok: false,
refusal: { code: 'agent_session_operation_unknown', message: 'unconfirmed' }
}
}
} as never)
expect(await dispatchMobileStructuredCommand(input)).toBe('unknown')
expect(await dispatchMobileStructuredCommand(input)).toBe('accepted')
expect(sendRequest.mock.calls[0]?.[1]).toEqual(sendRequest.mock.calls[1]?.[1])
})
it('retains operation identity when the host fails after starting the command', async () => {
],
[
'the host fails after starting it',
{ ok: false, error: { code: 'runtime_error', message: 'settlement failed' } }
]
])('sends the next press as a new command after %s', async (_case, answer) => {
const { input, sendRequest } = setup()
sendRequest.mockResolvedValueOnce({
ok: false,
error: { code: 'runtime_error', message: 'settlement failed' }
} as never)
sendRequest.mockResolvedValueOnce(answer)
expect(await dispatchMobileStructuredCommand(input)).toBe('unknown')
expect(await dispatchMobileStructuredCommand(input)).toBe('accepted')
expect(sendRequest.mock.calls[0]?.[1]).toEqual(sendRequest.mock.calls[1]?.[1])
// Only the operation id differs between the two requests.
expect(sendRequest.mock.calls[1]?.[1]).not.toEqual(sendRequest.mock.calls[0]?.[1])
})
it.each(['attachments', 'old host', 'arguments', 'pending work'])(
'guards %s without provider dispatch',
Expand Down
Loading
Loading