Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions config/scripts/vitest-sqlite-runtime-files.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-attachment-admission.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-background-stop-unreadable.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts',
Expand Down Expand Up @@ -189,6 +190,7 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-unnamed-stop-races.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-unopened-tab.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,8 @@ const HOST_SUPPORT = {
queuedMessages: true,
queuedCommands: false,
statusFeed: true,
quietRepeatedStop: true
quietRepeatedStop: true,
conversationStop: false
}

type Phone = {
Expand Down
15 changes: 9 additions & 6 deletions mobile/src/session/mobile-structured-agent-session-cancel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ 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
/** Whether the host takes a Stop naming no turn; null until the status probe answers. */
hostStopsConversation: 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>>
Expand All @@ -38,15 +40,16 @@ 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) {
// Check the capability before fields enter the fingerprint.
const prompt = args.prompt && args.promptCancelSupported === true ? args.prompt : undefined
// Such a host stops whatever the chat has in flight, a send the agent has not echoed included;
// a card's Cancel names the turn that raised it, prompt identity sent or not.
const unnamed = args.hostStopsConversation === true && !args.prompt
if (!client || !sessionId || !enabled || current.fence === null || (!turnId && !unnamed)) {
onSendError('Stop not sent')
return false
}
// Check the capability before fields enter the fingerprint.
const fields = {
turnId,
...(args.prompt && args.promptCancelSupported === true ? { prompt: args.prompt } : {})
}
const fields = unnamed ? {} : { turnId, ...(prompt ? { prompt } : {}) }
// 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 })
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY,
AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY,
AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY,
AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY,
Expand All @@ -17,7 +18,8 @@ describe('structuredAgentSessionHostSupport', () => {
queuedMessages: false,
queuedCommands: false,
quietRepeatedStop: false,
statusFeed: false
statusFeed: false,
conversationStop: false
}
expect(structuredAgentSessionHostSupport([])).toEqual(none)
expect(
Expand All @@ -38,5 +40,8 @@ describe('structuredAgentSessionHostSupport', () => {
expect(
structuredAgentSessionHostSupport([AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY])
).toEqual({ ...none, statusFeed: true })
expect(
structuredAgentSessionHostSupport([AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY])
).toEqual({ ...none, conversationStop: true })
})
})
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY,
AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY,
AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY,
AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY,
Expand All @@ -20,6 +21,8 @@ export type StructuredAgentSessionHostSupport = {
statusFeed: boolean
/** A Stop that stopped nothing adds no row, so a repeated Stop is quiet. */
quietRepeatedStop: boolean
/** A Stop naming no turn stops what the chat has in flight; an older host needs the turn. */
conversationStop: boolean
}

export function structuredAgentSessionHostSupport(
Expand All @@ -31,6 +34,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),
conversationStop: capabilities.includes(AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { describe, expect, it, vi } from 'vitest'
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'

type SentParams = Record<string, unknown> & { envelope: { clientOperationId: string } }

function recordingClient(sent: SentParams[]): RpcClient {
const sendRequest = async (_method: string, params: SentParams) => {
sent.push(params)
return { ok: true, result: { ok: true, value: { cancelled: true } }, _meta: { runtimeId: 'r' } }
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: cancel reaches only `sendRequest`.
return { sendRequest } as unknown as RpcClient
}

function state(turnId: string | null): StructuredAgentSessionState {
const items = turnId
? [
{
itemId: 'status-1',
revision: 1,
sequence: 1,
observedAt: 10,
body: { kind: 'status', text: 'Working', turnLifecycle: { turnId, state: 'running' } }
}
]
: []
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: cancel reads only the fence and the running turn.
return { fence: 3, items } as unknown as StructuredAgentSessionState
}

function press(input: {
sent: SentParams[]
turnId: string | null
hostStopsConversation: boolean | null
prompt?: { itemId: string; expectedRevision: number }
promptCancelSupported?: boolean
}) {
const onSendError = vi.fn()
const pressed = requestMobileStructuredAgentSessionCancel({
client: recordingClient(input.sent),
sessionId: 'session-1',
enabled: true,
stateRef: { current: state(input.turnId) },
promptCancelSupported: input.promptCancelSupported ?? true,
...(input.prompt ? { prompt: input.prompt } : {}),
hostAnswersRepeatedStops: true,
hostStopsConversation: input.hostStopsConversation,
inFlight: new Map(),
onSendError
})
return { pressed, onSendError }
}

describe('phone Stop against a host that stops what the chat has in flight', () => {
it('stops a send the agent has not answered yet, naming no turn', async () => {
const sent: SentParams[] = []
const { pressed, onSendError } = press({ sent, turnId: null, hostStopsConversation: true })
expect(await pressed).toBe(true)
expect(onSendError).not.toHaveBeenCalled()
expect(sent).toHaveLength(1)
expect(sent[0]).not.toHaveProperty('turnId')
})

it('names no turn while a turn runs either', async () => {
const sent: SentParams[] = []
expect(await press({ sent, turnId: 'turn-1', hostStopsConversation: true }).pressed).toBe(true)
expect(sent[0]).not.toHaveProperty('turnId')
})

it("keeps naming a card's turn", async () => {
const sent: SentParams[] = []
const prompt = { itemId: 'prompt-1', expectedRevision: 2 }
await press({ sent, turnId: 'turn-1', hostStopsConversation: true, prompt }).pressed
expect(sent[0]).toMatchObject({ turnId: 'turn-1', prompt })
})

it("keeps naming a card's turn when the host cannot take the card's identity", async () => {
const sent: SentParams[] = []
const prompt = { itemId: 'prompt-1', expectedRevision: 2 }
await press({
sent,
turnId: 'turn-1',
hostStopsConversation: true,
prompt,
promptCancelSupported: false
}).pressed
expect(sent[0]).toMatchObject({ turnId: 'turn-1' })
expect(sent[0]).not.toHaveProperty('prompt')
})

it('names the turn to an older host, and sends nothing there before one opens', async () => {
const sent: SentParams[] = []
await press({ sent, turnId: 'turn-1', hostStopsConversation: false }).pressed
expect(sent[0]).toMatchObject({ turnId: 'turn-1' })
const before = press({ sent, turnId: null, hostStopsConversation: null })
expect(await before.pressed).toBe(false)
expect(before.onSendError).toHaveBeenCalledWith('Stop not sent')
expect(sent).toHaveLength(1)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ function cancelArgs(client: RpcClient, hostAnswersRepeatedStops: boolean | null
stateRef: { current: runningState() },
promptCancelSupported: null,
hostAnswersRepeatedStops,
hostStopsConversation: null,
inFlight: new Map<string, Promise<boolean>>(),
onSendError: vi.fn()
}
Expand Down
10 changes: 10 additions & 0 deletions mobile/src/session/use-mobile-native-chat-controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ const structuredQuestion = {
optionTokens: ['choice-a', 'choice-b']
}
const structuredActivity = { isWorking: false, turnId: null as string | null }
let harnessHostSupport: ReturnType<typeof structuredAgentSessionHostSupport> | null = null
const structuredSessionState = {
messages: [] as unknown[],
status: 'ready',
Expand Down Expand Up @@ -162,6 +163,7 @@ import {
} from './use-mobile-native-chat-controller'
import type { MobileNativeChatStatus } from './use-mobile-native-chat-session'
import { mobileNativeChatPromptDismissals } from './mobile-native-chat-prompt-dismissals'
import { structuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support'

const sendWithOutcome = vi.mocked(sendMobileNativeChatMessageWithOutcome)

Expand Down Expand Up @@ -219,6 +221,7 @@ describe('useMobileNativeChatController handleNativeChatSend', () => {
deviceTokenRef: { current: null },
nativeChatTranscriptIsLocalReadable: true,
nativeChatInputLeaseReady: inputLeaseReady,
agentSessionHostSupport: harnessHostSupport,
onSendError,
onSendResolved
})
Expand Down Expand Up @@ -385,7 +388,14 @@ describe('useMobileNativeChatController handleNativeChatSend', () => {
})
expect(controller?.nativeChatCanStop).toBe(true)
expect(controller?.handleNativeChatCancelPrompt).toEqual(expect.any(Function))
// A host that stops what the chat has in flight takes Stop before a turn; a card still can't.
structuredActivity.turnId = null
harnessHostSupport = structuredAgentSessionHostSupport(['agent-session.conversation-stop.v1'])
await act(async () => renderer?.update(createElement(Harness, props)))
expect(controller?.nativeChatCanStop).toBe(true)
expect(controller?.handleNativeChatCancelPrompt).toBeUndefined()
} finally {
harnessHostSupport = null
structuredActivity.isWorking = false
structuredActivity.turnId = null
}
Expand Down
4 changes: 3 additions & 1 deletion mobile/src/session/use-mobile-native-chat-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,8 +302,10 @@ export function useMobileNativeChatController(args: {
nativeChatTurnJournal: activeChatStructured ? structuredNativeChat.turnJournal : null,
// Only the structured lane names a refusal's cause; the bridge lane's starved ones drop none.
nativeChatCommandRefusalCauses: structuredNativeChat.commandRefusalCauses,
// A host that stops what the chat has in flight takes Stop before the agent opens a turn.
nativeChatCanStop: activeChatStructured
? structuredNativeChat.turnId !== null
? structuredNativeChat.turnId !== null ||
(agentSessionHostSupport?.conversationStop === true && structuredNativeChat.isWorking)
: nativeChatAgentWorking,
nativeChatStreamingText,
nativeChatStreamLive,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,8 @@ function Harness({
queuedMessages: false,
queuedCommands: false,
statusFeed: false,
quietRepeatedStop: false
quietRepeatedStop: false,
conversationStop: false
},
onSendError: vi.fn()
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ export const CAPABLE: StructuredAgentSessionHostSupport = {
queuedMessages: true,
queuedCommands: false,
statusFeed: false,
quietRepeatedStop: false
quietRepeatedStop: false,
conversationStop: false
}
export const LEGACY: StructuredAgentSessionHostSupport = { ...CAPABLE, queuedMessages: false }

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 @@ -77,6 +77,7 @@ export function useMobileStructuredAgentSession(args: {
const commandsWait = queueCapable && hostSupport?.queuedCommands === true
const promptCancelSupported = hostSupport?.promptCancel ?? null
const hostAnswersRepeatedStops = hostSupport?.quietRepeatedStop ?? null
const hostStopsConversation = hostSupport?.conversationStop ?? null
const sessionKey = encodeNativeChatTranscriptIdentity([sourceIdentity, agent, sessionId])
const commandPendingRef = useRef(false)
// Against a host that predates the quiet repeated Stop, a Stop of a turn still being stopped joins it.
Expand Down Expand Up @@ -248,6 +249,7 @@ export function useMobileStructuredAgentSession(args: {
client,
enabled,
hostAnswersRepeatedStops,
hostStopsConversation,
inFlight: inFlightStopsRef.current,
onSendError,
prompt,
Expand All @@ -259,6 +261,7 @@ export function useMobileStructuredAgentSession(args: {
client,
enabled,
hostAnswersRepeatedStops,
hostStopsConversation,
onSendError,
promptCancelSupported,
sessionId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@ describe("useMobileStructuredAgentSession and the host's Stopping", () => {
queuedMessages: false,
queuedCommands: false,
quietRepeatedStop: false,
conversationStop: false,
statusFeed
},
agent: 'codex',
Expand Down
Loading
Loading