Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
a35b5b6
fix: keep chat Stop and Close independent of receipt bookkeeping
brennanb2025 Oct 8, 2026
d619db9
fix: bound receipt history without refusing chat cleanup
brennanb2025 Oct 8, 2026
38fe28e
fix: keep Stop task identity comparison browser safe
brennanb2025 Oct 8, 2026
0d91f25
Expire settled receipts without replaying old sends
brennanb2025 Oct 8, 2026
f272173
Merge main and retain current phone send actions
brennanb2025 Oct 8, 2026
3982971
Fix receipt settlement after reconnect and reject expired reservations
brennanb2025 Oct 8, 2026
5be8084
Check expired crash-start retries use a fresh operation
brennanb2025 Oct 8, 2026
44f121e
fix(native-chat): finish targeted Stop: no expiry, ended tasks, phone…
brennanb2025 Oct 8, 2026
4056d8e
fix(native-chat): preserve unavailable background Stop evidence
brennanb2025 Oct 8, 2026
0f61515
Merge remote-tracking branch 'origin/main' into brennanb2025/ssh-chat…
brennanb2025 Oct 8, 2026
d5e860e
fix: follow submission Stop through Codex turn admission
brennanb2025 Oct 8, 2026
5971aa6
fix: stop the agent before settling a closed chat's queue
brennanb2025 Oct 9, 2026
ee0b3a2
Merge remote-tracking branch 'origin/main' into brennanb2025/ssh-chat…
brennanb2025 Oct 9, 2026
2f3f301
fix: decide a submission Stop from the named send's own state
brennanb2025 Oct 9, 2026
a9a1bd7
fix: a Stop that failed is not proof its send ended
brennanb2025 Oct 9, 2026
fac6037
Merge remote-tracking branch 'origin/main' into brennanb2025/ssh-chat…
brennanb2025 Oct 10, 2026
0cf04b5
test: run the Codex submission Stop test in the Node SQLite runtime
brennanb2025 Oct 10, 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
1 change: 1 addition & 0 deletions config/scripts/vitest-sqlite-runtime-files.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-note-end.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-codex-submission-stop.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts',
Expand Down
10 changes: 8 additions & 2 deletions mobile/src/session/mobile-structured-agent-session-cancel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ 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 { runningStructuredAgentSessionTurnId } from '../../../src/shared/structured-agent-session-live-turn'
import { agentSessionStopTarget } from '../../../src/shared/agent-session-stop-target'
import type { RpcClient } from '../transport/rpc-client'
import {
requestStructuredAgentSessionMutation,
Expand Down Expand Up @@ -30,6 +31,7 @@ export async function requestMobileStructuredAgentSessionCancel(args: {
prompt?: PromptIdentity
/** Whether the host answers a repeated Stop of a turn quietly; null until the status probe answers. */
hostAnswersRepeatedStops: boolean | null
targetedStopSupported?: boolean
/** 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>>
Expand All @@ -38,13 +40,17 @@ export async function requestMobileStructuredAgentSessionCancel(args: {
const { client, enabled, inFlight, onSendError, sessionId, stateRef } = args
const current = stateRef.current
const turnId = runningStructuredAgentSessionTurnId(current)
if (!client || !sessionId || !enabled || current.fence === null || !turnId) {
const stopTarget = args.targetedStopSupported
? agentSessionStopTarget(turnId, current.submissions, current.fence)
: undefined
if (!client || !sessionId || !enabled || current.fence === null || (!turnId && !stopTarget)) {
onSendError('Stop not sent')
return false
}
// Check the capability before fields enter the fingerprint.
const fields = {
turnId,
...(turnId ? { turnId } : {}),
...(stopTarget ? { stopTarget } : {}),
...(args.prompt && args.promptCancelSupported === true ? { prompt: args.prompt } : {})
}
// Every press is its own Stop: a kept id would be answered from the last one and stop nothing.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ describe('structuredAgentSessionHostSupport', () => {
queuedMessages: false,
queuedCommands: false,
quietRepeatedStop: false,
targetedStop: false,
statusFeed: false
}
expect(structuredAgentSessionHostSupport([])).toEqual(none)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import {
AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY,
AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY,
AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY,
AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY
AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY,
AGENT_SESSION_TARGETED_STOP_RUNTIME_CAPABILITY
} from '../../../src/shared/protocol-version'

/** Structured-session features the connected host advertised; null until the status probe answers. */
Expand All @@ -20,6 +21,7 @@ export type StructuredAgentSessionHostSupport = {
statusFeed: boolean
/** A Stop that stopped nothing adds no row, so a repeated Stop is quiet. */
quietRepeatedStop: boolean
targetedStop?: boolean
}

export function structuredAgentSessionHostSupport(
Expand All @@ -31,6 +33,7 @@ export function structuredAgentSessionHostSupport(
queuedMessages: capabilities.includes(AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY),
queuedCommands: capabilities.includes(AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY),
statusFeed: capabilities.includes(AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY),
quietRepeatedStop: capabilities.includes(AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY)
quietRepeatedStop: capabilities.includes(AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY),
targetedStop: capabilities.includes(AGENT_SESSION_TARGETED_STOP_RUNTIME_CAPABILITY)
}
}
109 changes: 109 additions & 0 deletions mobile/src/session/mobile-structured-agent-session-stop-target.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { beforeEach, expect, it, vi } from 'vitest'
import { EMPTY_STRUCTURED_AGENT_SESSION } from '../../../src/shared/structured-agent-session-reducer'
import { isStructuredAgentSessionMainAgentWorking } from '../../../src/shared/structured-agent-session-main-agent-working'
import type { StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer'
import type { RpcClient } from '../transport/rpc-client'
import { requestMobileStructuredAgentSessionCancel } from './mobile-structured-agent-session-cancel'
import { requestStructuredAgentSessionMutation } from './mobile-structured-agent-session-rpc'

vi.mock('./mobile-structured-agent-session-rpc', () => ({
requestStructuredAgentSessionMutation: vi.fn(async () => ({
status: 'accepted',
value: { cancelled: true }
}))
}))

beforeEach(() => vi.clearAllMocks())

const client: RpcClient = {
sendRequest: vi.fn(),
subscribe: () => () => {},
updateTerminalSubscriptionViewport: () => {},
getState: () => 'connected',
getReconnectAttempt: () => 0,
getLastConnectedAt: () => null,
onStateChange: () => () => {},
notifyForeground: () => {},
close: () => {}
}

function stop(state: StructuredAgentSessionState, targetedStopSupported: boolean) {
const onSendError = vi.fn()
return {
onSendError,
result: requestMobileStructuredAgentSessionCancel({
client,
sessionId: 'phone-chat',
enabled: true,
stateRef: { current: state },
promptCancelSupported: false,
hostAnswersRepeatedStops: true,
targetedStopSupported,
inFlight: new Map(),
onSendError
})
}
}

it.each([false, true])('keeps turn targets capability-gated: %s', async (supported) => {
const state: StructuredAgentSessionState = {
...EMPTY_STRUCTURED_AGENT_SESSION,
fence: 1,
latestTurn: {
itemId: 'turn-item',
observedAt: 1,
turn: { turnId: 'one', state: 'running' }
}
}
expect(await stop(state, supported).result).toBe(true)
expect(vi.mocked(requestStructuredAgentSessionMutation).mock.calls[0]?.[0].fields).toEqual(
supported ? { turnId: 'one', stopTarget: { kind: 'turn', turnId: 'one' } } : { turnId: 'one' }
)
})

const pendingState: StructuredAgentSessionState = {
...EMPTY_STRUCTURED_AGENT_SESSION,
fence: 1,
submissions: [
{
clientMessageId: 'host-published-send',
fence: 1,
payloadFingerprint: 'fp',
dispatchState: 'pending',
providerItemId: null,
reason: null,
submittedAt: 1,
resolvedAt: null
}
]
}

it('stops the host-published unanswered submission before a turn opens', async () => {
expect(isStructuredAgentSessionMainAgentWorking(null, pendingState.submissions, 1)).toBe(true)
const request = stop(pendingState, true)
expect(await request.result).toBe(true)
expect(requestStructuredAgentSessionMutation).toHaveBeenCalledWith(
expect.objectContaining({
fields: { stopTarget: { kind: 'submission', clientMessageId: 'host-published-send' } }
})
)
expect(request.onSendError).not.toHaveBeenCalled()
})

it('keeps the no-turn refusal and feedback for an older host', async () => {
const request = stop(pendingState, false)
expect(await request.result).toBe(false)
expect(requestStructuredAgentSessionMutation).not.toHaveBeenCalled()
expect(request.onSendError).toHaveBeenCalledWith('Stop not sent')
})

it.each([
{ ...pendingState, fence: 2 },
{ ...pendingState, fence: null },
{ ...EMPTY_STRUCTURED_AGENT_SESSION, fence: 1 }
])('does not invent a target without current unanswered work', async (state) => {
const request = stop(state, true)
expect(await request.result).toBe(false)
expect(requestStructuredAgentSessionMutation).not.toHaveBeenCalled()
expect(request.onSendError).toHaveBeenCalledWith('Stop not sent')
})
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,28 @@ function cancelArgs(client: RpcClient, hostAnswersRepeatedStops: boolean | null
}

describe('structured mutation id retirement', () => {
it.each([false, true])(
'uses a receipt-free turn target only for a capable host: %s',
async (targetedStopSupported) => {
const sendRequest = vi.fn(async (_method: string, _params: SentParams) => ({
ok: true,
result: { ok: true, value: { cancelled: true } },
_meta: { runtimeId: 'runtime-1' }
}))
expect(
await requestMobileStructuredAgentSessionCancel({
...cancelArgs(fakeClient(sendRequest)),
targetedStopSupported
})
).toBe(true)
const params = sendRequest.mock.calls[0]?.[1]
expect(params).toMatchObject(
targetedStopSupported
? { stopTarget: { kind: 'turn', turnId: 'turn-1' } }
: expect.not.objectContaining({ stopTarget: expect.anything() })
)
}
)
it('reports a host that cannot say what became of the id as unknown', async () => {
const result = await requestStructuredAgentSessionMutation({
client: fakeClient(async () => operationRefusedAsUnknown()),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,23 +77,29 @@ describe('mobile structured session background tasks', () => {
close: () => {}
}

function Harness({ connected }: { connected: boolean }): null {
function Harness({
connected,
targetedStop = false
}: {
connected: boolean
targetedStop?: boolean
}): null {
hook = useMobileStructuredAgentSession({
client,
sessionId: 'session-1',
sourceIdentity: 'host-a\0workspace-a',
enabled: true,
connected,
agent: 'claude',
hostSupport: CAPABLE,
hostSupport: { ...CAPABLE, targetedStop },
onSendError
})
return null
}

async function mount(event: AgentSessionSubscribeEvent): Promise<void> {
async function mount(event: AgentSessionSubscribeEvent, targetedStop = false): Promise<void> {
act(() => {
renderer = create(createElement(Harness, { connected: true }))
renderer = create(createElement(Harness, { connected: true, targetedStop }))
})
await vi.waitFor(() => expect(listener).toEqual(expect.any(Function)))
act(() => listener?.(event))
Expand Down Expand Up @@ -181,4 +187,18 @@ describe('mobile structured session background tasks', () => {
taskId: 'task-a'
})
})

it('names the host child invocation when targeted Stop is supported', async () => {
await mount(withChildren(), true)
await act(async () => {
await hook?.backgroundTasks.stop('task-a')
})
const cancel = sendRequest.mock.calls.find(([method]) => method === 'agentSession.cancel')
expect(fieldsOf(cancel?.[1])).toMatchObject({
stopTarget: {
kind: 'background-tasks',
tasks: [{ id: 'a', invocation: { invocationId: 'spawn-a', generation: 1 } }]
}
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentJournalDispatchState } from '../../../src/shared/agent-session-journal-types'
import { DISPATCH_REJECTED_CANCELLED } from '../../../src/shared/structured-agent-session-dispatch-rejection'
import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire'
import { SendParams } from '../../../src/shared/rpc-contract/structured-agent-session-params'
import type { RpcClient } from '../transport/rpc-client'
import { fieldsOf, ok } from './use-mobile-structured-agent-session-queued.test-fixture'
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
Expand Down Expand Up @@ -197,6 +198,60 @@ describe('mobile structured send actions', () => {
expect(calls()).toHaveLength(1)
})

it.each(['accepted', 'rejected'] as const)(
'an expired reply and later %s history never automatically replace the Send',
async (dispatchState) => {
sendRequest.mockImplementation(async (method) => {
if (method !== 'agentSession.send') {
return ok({ models: [], current: {} })
}
return calls().length === 1
? ok({
ok: false,
refusal: { code: 'agent_session_operation_expired', message: 'Operation expired' }
})
: sendResult('accepted')
})
await mountSession()
await act(async () => {
expect(await hook!.sendWithOutcome('same text')).toBe('rejected')
})
expect(calls()).toHaveLength(1)
const request = SendParams.parse(calls()[0]?.[1])
const original = structuredSendResultFixture(dispatchState)
if (!('submission' in original)) {
throw new Error('expected a submission answer')
}
const event = snapshotEvent()
event.page.submissions = [
{
...original.submission,
clientMessageId: request.envelope.clientOperationId,
payloadFingerprint: request.envelope.payloadFingerprint
}
]
event.page.items = [
{
itemId: 'original-message',
revision: 1,
sequence: 1,
observedAt: 10,
body: request.body
}
]
act(() => listener?.(event))
expect(hook!.session.messages).toHaveLength(1)
expect(calls()).toHaveLength(1)
expect(new Set(sentIds()).size).toBe(1)
await act(async () => {
expect(await hook!.sendWithOutcome('same text')).toBe('accepted')
})
expect(calls()).toHaveLength(2)
expect(sentIds()[1]).not.toBe(request.envelope.clientOperationId)
expect(asyncStorage.getItem).not.toHaveBeenCalled()
}
)

it.each(['reply-before-row', 'row-before-commit'] as const)(
'states delivery once and leaves auth guidance in the transcript for %s',
async (order) => {
Expand Down
3 changes: 3 additions & 0 deletions mobile/src/session/use-mobile-structured-agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ export function useMobileStructuredAgentSession(args: {
const thinking = isStructuredAgentSessionThinking(state)
const isWorking = isStructuredAgentSessionMainAgentWorking(turnId, state.submissions, state.fence)
const backgroundTasks = useMobileStructuredBackgroundTasks({
targetedStopSupported: hostSupport?.targetedStop === true,
sessionKey,
state,
turnId,
Expand Down Expand Up @@ -234,6 +235,7 @@ export function useMobileStructuredAgentSession(args: {
client,
enabled,
hostAnswersRepeatedStops,
targetedStopSupported: hostSupport?.targetedStop === true,
inFlight: inFlightStopsRef.current,
onSendError,
prompt,
Expand All @@ -245,6 +247,7 @@ export function useMobileStructuredAgentSession(args: {
client,
enabled,
hostAnswersRepeatedStops,
hostSupport?.targetedStop,
onSendError,
promptCancelSupported,
sessionId,
Expand Down
11 changes: 9 additions & 2 deletions mobile/src/session/use-mobile-structured-background-tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from '../../../src/shared/structured-session-background-tasks-view'
import type { StructuredAgentSessionMutationResult } from './mobile-structured-agent-session-rpc'
import type { MobileStructuredAgentMutate } from './use-mobile-structured-agent-mutation'
import { agentSessionBackgroundStopTarget } from '../../../src/shared/agent-session-background-stop-target'

export type MobileStructuredBackgroundTasks = {
/** The conversation these tasks belong to; the strip's open and stopping state is kept per one. */
Expand All @@ -33,6 +34,7 @@ export function useMobileStructuredBackgroundTasks(args: {
turnId: string | null
/** Live transport; without it the kept roster is the last thing heard, not what runs now. */
connected: boolean
targetedStopSupported?: boolean
mutate: MobileStructuredAgentMutate
}): MobileStructuredBackgroundTasks {
const { connected, mutate, sessionKey, state, turnId } = args
Expand All @@ -58,9 +60,14 @@ export function useMobileStructuredBackgroundTasks(args: {
mutate<AgentSessionCancelResult>('agentSession.cancel', 'agentSession.cancel', {
turnId: 'background-tasks',
scope: 'background-tasks',
...(taskId ? { taskId } : {})
...(taskId ? { taskId } : {}),
...(args.targetedStopSupported
? {
stopTarget: agentSessionBackgroundStopTarget(view.children, taskId)
}
: {})
}),
[mutate]
[args.targetedStopSupported, mutate, view.children]
)
return useMemo(
() => ({ sessionKey, view, rowContext, stop }),
Expand Down
Loading
Loading