feat(mobile): finalize structured native Codex chat

This commit is contained in:
Merge Sim
2026-09-03 00:52:15 -07:00
parent 316ec38f67
commit 6cb09d8f97
91 changed files with 4357 additions and 424 deletions
+45 -36
View File
@@ -2,7 +2,11 @@ import { useMemo, useRef, useState } from 'react'
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'
import { ArrowUp, Check, CircleHelp } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { formatQuestionAnswer, type MobileChatQuestion } from './mobile-native-chat-question'
import {
formatQuestionAnswer,
formatQuestionFreeTextAnswer,
type MobileChatQuestion
} from './mobile-native-chat-question'
type Props = {
question: MobileChatQuestion
@@ -18,6 +22,7 @@ export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.J
const [freeText, setFreeText] = useState('')
const [sending, setSending] = useState(false)
const sendingRef = useRef(false)
const allowOther = question.allowOther !== false
const hasOptions = question.options.length > 0
const trimmedFreeText = freeText.trim()
@@ -42,8 +47,9 @@ export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.J
}
}
const answerSingle = async (option: string): Promise<void> => {
await sendAnswer(formatQuestionAnswer(question, [option]))
const answerSingle = async (option: string, optionIndex: number): Promise<void> => {
const token = question.optionTokens[optionIndex]
await sendAnswer(token && token.length > 0 ? token : formatQuestionAnswer(question, [option]))
}
const submitMulti = async (): Promise<void> => {
@@ -57,14 +63,13 @@ export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.J
if (trimmedFreeText.length === 0) {
return
}
// Free text is an unknown entry; formatQuestionAnswer passes it through.
if (await sendAnswer(formatQuestionAnswer(question, [trimmedFreeText]))) {
if (await sendAnswer(formatQuestionFreeTextAnswer(question, trimmedFreeText))) {
setFreeText('')
}
}
const canSubmitMulti = selected.length > 0 && !sending
const canSendFreeText = trimmedFreeText.length > 0 && !sending
const canSendFreeText = allowOther && trimmedFreeText.length > 0 && !sending
// Stable keys for option rows even if an agent repeats a label.
const optionRows = useMemo(
@@ -81,7 +86,7 @@ export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.J
{hasOptions ? (
<View style={styles.options}>
{optionRows.map(({ label, key }) => {
{optionRows.map(({ label, key }, optIndex) => {
const isSelected = selected.includes(label)
return (
<Pressable
@@ -93,7 +98,9 @@ export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.J
isSelected && styles.optionSelected,
pressed && styles.pressed
]}
onPress={() => (question.multiSelect ? toggle(label) : answerSingle(label))}
onPress={() =>
question.multiSelect ? toggle(label) : answerSingle(label, optIndex)
}
>
{question.multiSelect ? (
<View style={[styles.checkbox, isSelected && styles.checkboxOn]}>
@@ -124,35 +131,37 @@ export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.J
</Pressable>
) : null}
<View style={styles.freeTextRow}>
<TextInput
style={styles.freeInput}
value={freeText}
onChangeText={setFreeText}
placeholder={hasOptions ? 'Or type a reply…' : 'Type your reply…'}
placeholderTextColor={colors.textMuted}
selectionColor={colors.accentBlue}
onSubmitEditing={submitFreeText}
returnKeyType="send"
multiline
/>
<Pressable
accessibilityLabel="Send reply"
style={({ pressed }) => [
styles.freeSend,
!canSendFreeText && styles.freeSendDisabled,
pressed && canSendFreeText && styles.pressed
]}
onPress={submitFreeText}
disabled={!canSendFreeText}
>
<ArrowUp
size={18}
color={canSendFreeText ? colors.bgBase : colors.textMuted}
strokeWidth={2.6}
{allowOther ? (
<View style={styles.freeTextRow}>
<TextInput
style={styles.freeInput}
value={freeText}
onChangeText={setFreeText}
placeholder={hasOptions ? 'Or type a reply…' : 'Type your reply…'}
placeholderTextColor={colors.textMuted}
selectionColor={colors.accentBlue}
onSubmitEditing={submitFreeText}
returnKeyType="send"
multiline
/>
</Pressable>
</View>
<Pressable
accessibilityLabel="Send reply"
style={({ pressed }) => [
styles.freeSend,
!canSendFreeText && styles.freeSendDisabled,
pressed && canSendFreeText && styles.pressed
]}
onPress={submitFreeText}
disabled={!canSendFreeText}
>
<ArrowUp
size={18}
color={canSendFreeText ? colors.bgBase : colors.textMuted}
strokeWidth={2.6}
/>
</Pressable>
</View>
) : null}
</View>
)
}
@@ -38,7 +38,7 @@ export function MobileSessionActiveContent({
browserScreencastSupported,
showToast,
nativeChatSendError,
nativeChatInputLockReason,
nativeChatOverlayInputLockReason,
nativeChatController,
dictation,
handleDictationToggle,
@@ -240,7 +240,7 @@ export function MobileSessionActiveContent({
dictationMode={dictationMode}
onMicPressIn={handleDictationPressIn}
onMicPressOut={handleDictationPressOut}
inputLockReason={nativeChatInputLockReason}
inputLockReason={nativeChatOverlayInputLockReason}
sendErrorMessage={nativeChatSendError.message}
onClearSendError={nativeChatSendError.clear}
sendSurfaceId={controller.nativeChatScopeKey ?? ''}
@@ -168,6 +168,7 @@ export function MobileSessionHeader({ controller }: { controller: MobileSessionC
{t.type === 'file' && (
<File size={13} color={colors.textSecondary} strokeWidth={2.1} />
)}
{t.type === 'agent-session' && <MobileAgentIcon agentId={t.agent} size={13} />}
{t.type === 'terminal' &&
(() => {
const agentId = resolveMobileTerminalTabAgentId(t)
@@ -43,6 +43,8 @@ export function MobileSessionSheets({ controller }: { controller: MobileSessionC
setFileActionTarget,
browserActionTarget,
setBrowserActionTarget,
agentSessionActionTarget,
setAgentSessionActionTarget,
discardMarkdownTarget,
setDiscardMarkdownTarget,
leaveDrafts,
@@ -261,6 +263,14 @@ export function MobileSessionSheets({ controller }: { controller: MobileSessionC
onCloseTab={handleCloseSessionTab}
bulkCloseActions={bulkCloseActions}
/>
<ActionSheetModal
visible={agentSessionActionTarget != null}
title={agentSessionActionTarget?.title || 'Chat'}
actions={closeWithBulkActions(agentSessionActionTarget, () =>
setAgentSessionActionTarget(null)
)}
onClose={() => setAgentSessionActionTarget(null)}
/>
<ActionSheetModal
visible={leaveDrafts != null}
title="Unsaved markdown changes"
+3 -1
View File
@@ -36,8 +36,10 @@ export type OpenMobileFileTapOptions<T extends FileTapSessionTab> = {
activated: boolean
activationSeq: number
latestActivationSeq: number
sourceTerminalHandle: string
sourceTerminalHandle: string | null
activeTerminalHandle: string | null
sourceSessionTabId?: string | null
activeSessionTabId?: string | null
activeTabType: string | null
}
switchSessionTab: (tab: T) => void
@@ -58,7 +58,12 @@ export type MobileNativeChatController = {
handleNativeChatSendWithOutcome: (
text: string,
images?: string[],
deadline?: number
deadline?: number,
attachments?: readonly {
id?: string
path: string
previewUri: string
}[]
) => Promise<MobileNativeChatSendOutcome>
/** Launch-context text still parked on the agent's TUI input line, or null.
* Image sends read it to size their leading clear (one Ctrl+U per line). */
@@ -123,6 +123,30 @@ describe('resolveMobileNativeChat', () => {
expect(resolveMobileNativeChat({ type: 'browser', launchAgent: 'claude' })).toBeNull()
})
it('resolves Codex structured agent-session tabs directly', () => {
expect(
resolveMobileNativeChat({
type: 'agent-session',
sessionId: 'structured-1',
agent: 'codex'
})
).toEqual({
agent: 'codex',
sessionId: 'structured-1',
transcriptPath: null
})
})
it('rejects non-Codex structured agent-session tabs', () => {
expect(
resolveMobileNativeChat({
type: 'agent-session',
sessionId: 'structured-1',
agent: 'claude'
} as never)
).toBeNull()
})
it('canShowMobileNativeChat mirrors resolution', () => {
expect(canShowMobileNativeChat({ type: 'terminal', launchAgent: 'claude' })).toBe(true)
expect(canShowMobileNativeChat(null)).toBe(false)
@@ -32,6 +32,8 @@ export type MobileNativeChatTab = {
/** Host-provided launch context still parked as an unsent TUI-input draft. */
launchDraft?: string
launchDraftCreatedAt?: number
sessionId?: string | null
agent?: string | null
}
/** Resolve a session tab to the transcript identity native chat needs, or
@@ -42,7 +44,15 @@ export function resolveMobileNativeChat(
tab: MobileNativeChatTab | null,
nativeChatTranscriptIsLocalReadable = false
): MobileNativeChatResolution | null {
if (!tab || tab.type !== 'terminal') {
if (!tab) {
return null
}
if (tab.type === 'agent-session') {
return tab.sessionId && tab.agent === 'codex'
? { agent: tab.agent, sessionId: tab.sessionId, transcriptPath: null }
: null
}
if (tab.type !== 'terminal') {
return null
}
const liveAgent = tab.agentStatus?.agentType ?? null
@@ -71,3 +81,15 @@ export function canShowMobileNativeChat(
): boolean {
return resolveMobileNativeChat(tab, nativeChatTranscriptIsLocalReadable) !== null
}
export function resolveMobileNativeChatFileSessionId(
tab: MobileNativeChatTab | null
): string | null {
if (tab?.type === 'agent-session') {
return tab.sessionId ?? null
}
if (tab?.type === 'terminal') {
return tab.agentStatus?.providerSession?.id ?? null
}
return null
}
@@ -0,0 +1,18 @@
import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment'
export const NO_NATIVE_CHAT_IMAGE_ATTACHMENTS: PendingNativeChatImage[] = []
export type MobileNativeChatImagesByScope = Record<string, PendingNativeChatImage[]>
export function withScopeAttachments(
byScope: MobileNativeChatImagesByScope,
scope: string,
next: PendingNativeChatImage[]
): MobileNativeChatImagesByScope {
if (next.length > 0) {
return { ...byScope, [scope]: next }
}
const remaining = { ...byScope }
delete remaining[scope]
return remaining
}
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
formatQuestionAnswer,
formatQuestionFreeTextAnswer,
mobileChatQuestionKey,
parseAgentQuestion,
type MobileChatQuestion
@@ -141,6 +142,12 @@ describe('formatQuestionAnswer', () => {
expect(formatQuestionAnswer(numbered, [])).toBe('')
expect(formatQuestionAnswer(numbered, [' '])).toBe('')
})
it('prefixes free-text answers with an opaque prompt token when provided', () => {
expect(
formatQuestionFreeTextAnswer({ ...numbered, freeTextToken: 'target' }, ' hi there ')
).toBe(`target:${encodeURIComponent('hi there')}`)
})
})
describe('mobileChatQuestionKey', () => {
@@ -154,5 +161,8 @@ describe('mobileChatQuestionKey', () => {
expect(mobileChatQuestionKey({ ...first, options: ['A', 'C'] })).not.toBe(
mobileChatQuestionKey(first)
)
expect(mobileChatQuestionKey({ ...first, freeTextToken: 'target-2' })).not.toBe(
mobileChatQuestionKey(first)
)
})
})
@@ -7,10 +7,14 @@ export type MobileChatQuestion = {
question: string
options: string[]
multiSelect: boolean
/** Structured questions hide the free-text row when the provider does not accept it. */
allowOther?: boolean
/** Per-option leading marker ("1", "b", …) when the source line carried one,
* parallel to `options`. Null where the option was a plain bullet. Used to
* echo the exact choice the agent listed back to the terminal. */
optionTokens: (string | null)[]
/** Opaque prefix used when free-text answers must target a specific prompt. */
freeTextToken?: string
}
export function mobileChatQuestionKey(question: MobileChatQuestion): string {
@@ -152,3 +156,13 @@ export function formatQuestionAnswer(question: MobileChatQuestion, selected: str
return parts.join(question.multiSelect ? ', ' : ' ')
}
export function formatQuestionFreeTextAnswer(question: MobileChatQuestion, text: string): string {
const trimmed = text.trim()
if (trimmed.length === 0) {
return ''
}
return question.freeTextToken
? `${question.freeTextToken}:${encodeURIComponent(trimmed)}`
: formatQuestionAnswer(question, [trimmed])
}
@@ -62,15 +62,15 @@ const HOST_COMPONENT_NAMES = new Set([
'View'
])
const HEAD_MAIN_HOOK_SHA256 = '5c475b904928f418c76a7885afdbed7adbfea3fe3ea05e85d956dc22f958a302'
const HEAD_HOOK_BINDING_SHA256 = '028f99dd14fea2110cff446418ee71513aeed38484c2dcea68bf0da8eff377c0'
const HEAD_MAIN_HOOK_SHA256 = '10071240ef9edafc2b9c8bed73be83dceaf7828e3b29f17dab55da020a7697a6'
const HEAD_HOOK_BINDING_SHA256 = 'ecd4c1dad066cf13698447b8ffb61f82e6cc3ebe7d484f71189626efed430272'
const HEAD_CALLBACK_IDENTITY_SHA256 =
'd60ffe53f8d77f2dd3ebd14a5de162bb399113c170b59bdc917de6318ec433ec'
const HEAD_CALLBACK_BODY_SHA256 = '69dfda53fd700f4395a18a37ffdaa530e187bc24b4986d8fdc0184127c00b52d'
'df073bc13d94a93e7fbd8b1fca2b57eaf43cbf7ca799a649e0ebb783e5b8eecc'
const HEAD_CALLBACK_BODY_SHA256 = '690e3069e08ecf805af726b658e900c973565259160f25e3a643175e2ab1bc75'
const HEAD_EFFECT_SHA256 = '346d384ea0bf2f8f926c5092c5bf57bc2a03494f49f9639e9d6b8a2c51c9f882'
const HEAD_CONTENT_HOOK_SHA256 = '9c3b612fef3f370d66873aefdbe1d701f20cb64ded31fef5cc45fde6f8189581'
const HEAD_NESTED_FUNCTION_SHA256 =
'b562c117eb1e4532dd656d8bdd3ca3bc58ce65d78a7ed740dbd866a48d4d8dbe'
'6a13919ede2a8033436fb03e0ff7c426fbed97f470875a7b21b00aaada17fb73'
const HEAD_NATIVE_REGISTRATION_SHA256 =
'cab85e4e4a3f43289ba93ddea9ccce57aea83e0bf14fd1620a965aad0c1cb49e'
const HEAD_NATIVE_REMOVAL_SHA256 =
@@ -79,9 +79,9 @@ const HEAD_TIMER_CREATION_SHA256 =
'1a31b625e2174c3db77272249843196d2b6b06ab1e654a96d8f7858e3082e66b'
const HEAD_TIMER_CLEANUP_SHA256 = 'c73f1d1c2cc89642f3d727d6f3b6b81860a9d6f34234541a2065ec3d1a8cd116'
const HEAD_RUNTIME_STRING_SHA256 =
'ad0def23206f08d0523c155fe730e86824876e67cf1db6b597541b9c35b54447'
'1cb95fe0095c1c57e1b0629472e1cce5328eb7f5bfeca38095f41f4612a37887'
const HEAD_HOST_JSX_SHA256 = '390405926b1695fa3a33686f0bc192b432f5468d8576499d7cafbb4922defbb5'
const HEAD_LEAF_JSX_SHA256 = 'b070e25c47b3e298be02a4ffe1572b36e204446fc161bad894690e9939403f54'
const HEAD_LEAF_JSX_SHA256 = '21dba981875e173f692590bf910d60964660c5f4cbb79f3a377c7e54f6a1f016'
const HEAD_STYLE_REFERENCE_SHA256 =
'295a3501c2c6d7bea7c8bbf38b3f3534f01344cd7e1b91bb8e07c040821d596a'
const HEAD_IDENTITY_FIELD_SHA256 =
@@ -472,10 +472,10 @@ describe('mobile session route extraction parity', () => {
const contentBindings = CONTENT_COMPONENT_NAMES.flatMap(
(name) => readHookFacts(name, definitions).bindings
)
expect(main.hooks).toHaveLength(269)
expect(main.hooks).toHaveLength(266)
expect(hash(main.hooks)).toBe(HEAD_MAIN_HOOK_SHA256)
expect(hash(main.bindings)).toBe(HEAD_HOOK_BINDING_SHA256)
expect(main.callbacks).toHaveLength(78)
expect(main.callbacks).toHaveLength(77)
expect(hash(main.callbacks)).toBe(HEAD_CALLBACK_IDENTITY_SHA256)
expect(hash(main.callbackBodies)).toBe(HEAD_CALLBACK_BODY_SHA256)
expect(main.effects).toHaveLength(24)
@@ -517,12 +517,12 @@ describe('mobile session route extraction parity', () => {
it('preserves runtime strings, styles, and the expanded JSX tree', () => {
const strings = readRuntimeStrings()
expect(strings).toHaveLength(537)
expect(strings).toHaveLength(545)
expect(hash(strings)).toBe(HEAD_RUNTIME_STRING_SHA256)
const jsx = readJsxFacts(readDefinitions())
expect(jsx.host).toHaveLength(124)
expect(hash(jsx.host)).toBe(HEAD_HOST_JSX_SHA256)
expect(jsx.leaf).toHaveLength(59)
expect(jsx.leaf).toHaveLength(61)
expect(hash(jsx.leaf)).toBe(HEAD_LEAF_JSX_SHA256)
expect(jsx.styleReferences).toHaveLength(172)
expect(hash(jsx.styleReferences)).toBe(HEAD_STYLE_REFERENCE_SHA256)
@@ -9,7 +9,7 @@ import type { TerminalRecord } from './mobile-terminal-records'
export type Terminal = TerminalRecord
export type MobileSessionTabType = 'terminal' | 'markdown' | 'file' | 'browser'
export type MobileSessionTabType = 'terminal' | 'markdown' | 'file' | 'browser' | 'agent-session'
export type MobileSessionTab =
| {
@@ -30,6 +30,14 @@ export type MobileSessionTab =
terminalTheme?: MobileTerminalTheme
isActive: boolean
}
| {
type: 'agent-session'
id: string
title: string
sessionId: string
agent: 'codex'
isActive: boolean
}
| {
type: 'markdown'
id: string
@@ -0,0 +1,251 @@
import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types'
import type { MobileChatPermission } from './mobile-native-chat-permission'
import type { MobileChatQuestion } from './mobile-native-chat-question'
export type StructuredApprovalItem = AgentJournalRenderItem & {
body: Extract<AgentJournalRenderItem['body'], { kind: 'approval' }>
}
export type StructuredQuestionItem = AgentJournalRenderItem & {
body: Extract<AgentJournalRenderItem['body'], { kind: 'question' }>
}
export type StructuredPromptResponseTarget = {
itemId: string
expectedRevision: number
optionId: string
}
type PromptTokenPayload =
| {
kind: 'approval'
itemId: string
revision: number
optionId: string
}
| {
kind: 'question-option'
itemId: string
revision: number
optionId: string
}
| {
kind: 'question-free-text'
itemId: string
revision: number
questionId: string
}
const STRUCTURED_PROMPT_TOKEN_PREFIX = 'structured-agent-prompt:'
export function pendingStructuredApproval(
item: AgentJournalRenderItem
): item is StructuredApprovalItem {
return item.body.kind === 'approval' && item.body.resolution.state === 'pending'
}
export function pendingStructuredQuestion(
item: AgentJournalRenderItem
): item is StructuredQuestionItem {
return item.body.kind === 'question' && item.body.resolution.state === 'pending'
}
function encodeQuestionAnswer(questionId: string, answer: string): string {
return `${encodeURIComponent(questionId)}:${encodeURIComponent(answer)}`
}
function encodePromptToken(payload: PromptTokenPayload): string {
return `${STRUCTURED_PROMPT_TOKEN_PREFIX}${encodeURIComponent(JSON.stringify(payload))}`
}
function decodePromptToken(value: string): PromptTokenPayload | null {
if (!value.startsWith(STRUCTURED_PROMPT_TOKEN_PREFIX)) {
return null
}
try {
const decoded = JSON.parse(
decodeURIComponent(value.slice(STRUCTURED_PROMPT_TOKEN_PREFIX.length))
) as Record<string, unknown>
if (
typeof decoded.itemId !== 'string' ||
typeof decoded.revision !== 'number' ||
!Number.isFinite(decoded.revision)
) {
return null
}
if (decoded.kind === 'approval' && typeof decoded.optionId === 'string') {
return {
kind: decoded.kind,
itemId: decoded.itemId,
revision: decoded.revision,
optionId: decoded.optionId
}
}
if (decoded.kind === 'question-option' && typeof decoded.optionId === 'string') {
return {
kind: decoded.kind,
itemId: decoded.itemId,
revision: decoded.revision,
optionId: decoded.optionId
}
}
if (decoded.kind === 'question-free-text' && typeof decoded.questionId === 'string') {
return {
kind: decoded.kind,
itemId: decoded.itemId,
revision: decoded.revision,
questionId: decoded.questionId
}
}
} catch {
return null
}
return null
}
function decodeQuestionFreeTextAnswer(value: string): {
payload: Extract<PromptTokenPayload, { kind: 'question-free-text' }>
answer: string
} | null {
if (!value.startsWith(STRUCTURED_PROMPT_TOKEN_PREFIX)) {
return null
}
const separator = value.indexOf(':', STRUCTURED_PROMPT_TOKEN_PREFIX.length)
if (separator === -1) {
return null
}
const payload = decodePromptToken(value.slice(0, separator))
if (payload?.kind !== 'question-free-text') {
return null
}
return { payload, answer: decodeURIComponent(value.slice(separator + 1)) }
}
export function projectStructuredPermission(
prompt: StructuredApprovalItem | null
): MobileChatPermission | null {
if (prompt?.body.kind !== 'approval') {
return null
}
return {
title: prompt.body.title,
...(prompt.body.detail ? { detail: prompt.body.detail } : {}),
options: prompt.body.options.map((option) => ({
label: option.label,
send: encodePromptToken({
kind: 'approval',
itemId: prompt.itemId,
revision: prompt.revision,
optionId: option.id
})
}))
}
}
export function projectStructuredQuestion(
prompt: StructuredQuestionItem | null
): MobileChatQuestion | null {
if (prompt?.body.kind !== 'question') {
return null
}
return {
question: prompt.body.question,
options: prompt.body.options.map((option) => option.label),
multiSelect: false,
allowOther: Boolean(prompt.body.freeTextQuestionId),
optionTokens: prompt.body.options.map((option) =>
encodePromptToken({
kind: 'question-option',
itemId: prompt.itemId,
revision: prompt.revision,
optionId: option.id
})
),
...(prompt.body.freeTextQuestionId
? {
freeTextToken: encodePromptToken({
kind: 'question-free-text',
itemId: prompt.itemId,
revision: prompt.revision,
questionId: prompt.body.freeTextQuestionId
})
}
: {})
}
}
export function structuredApprovalResponseTarget(
response: string,
currentPrompt: StructuredApprovalItem | null
): StructuredPromptResponseTarget | null {
const token = decodePromptToken(response)
if (token?.kind === 'approval') {
return {
itemId: token.itemId,
expectedRevision: token.revision,
optionId: token.optionId
}
}
if (token) {
return null
}
const option = currentPrompt?.body.options.find(
(candidate) => candidate.id === response || candidate.label === response
)
return currentPrompt && option
? {
itemId: currentPrompt.itemId,
expectedRevision: currentPrompt.revision,
optionId: option.id
}
: null
}
export function structuredQuestionResponseTarget(
response: string,
currentPrompt: StructuredQuestionItem | null
): StructuredPromptResponseTarget | null {
const token = decodePromptToken(response)
if (token?.kind === 'question-option') {
return {
itemId: token.itemId,
expectedRevision: token.revision,
optionId: token.optionId
}
}
if (token) {
return null
}
const freeText = decodeQuestionFreeTextAnswer(response)
if (freeText) {
const answer = freeText.answer.trim()
return answer.length > 0
? {
itemId: freeText.payload.itemId,
expectedRevision: freeText.payload.revision,
optionId: encodeQuestionAnswer(freeText.payload.questionId, answer)
}
: null
}
if (!currentPrompt) {
return null
}
const trimmed = response.trim()
const option = currentPrompt.body.options.find(
(candidate) => candidate.id === response || candidate.label === trimmed
)
if (option) {
return {
itemId: currentPrompt.itemId,
expectedRevision: currentPrompt.revision,
optionId: option.id
}
}
return currentPrompt.body.freeTextQuestionId && trimmed
? {
itemId: currentPrompt.itemId,
expectedRevision: currentPrompt.revision,
optionId: encodeQuestionAnswer(currentPrompt.body.freeTextQuestionId, trimmed)
}
: null
}
@@ -0,0 +1,123 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
import { createMobileStructuredCodexSession } from './mobile-structured-agent-session-launch'
function clientReturning(
...responses: unknown[]
): RpcClient & { sendRequest: ReturnType<typeof vi.fn> } {
let responseIndex = 0
const sendRequest = vi.fn(async () => responses[responseIndex++])
return { sendRequest } as unknown as RpcClient & { sendRequest: ReturnType<typeof vi.fn> }
}
const acceptedCreateResult = {
ok: true,
replayed: false,
fence: 1,
cursor: { epoch: 'epoch-1', sequence: 0 },
value: {
sessionId: 'codex_session_1',
fence: 1,
page: {
sessionId: 'codex_session_1',
epoch: 'epoch-1',
direction: 'tail',
items: [],
removedItemIds: [],
submissions: [],
window: { oldest: null, newest: null, nextCursor: { epoch: 'epoch-1', sequence: 0 } },
liveCursor: { epoch: 'epoch-1', sequence: 0 },
hasOlder: false,
hasNewer: false
},
unconfirmedClientMessageIds: []
}
}
const acceptedCreate = { ok: true, result: acceptedCreateResult }
describe('mobile structured Codex launch', () => {
it('creates through the structured agent-session intent after support is confirmed', async () => {
const client = clientReturning({ ok: true, result: { supported: true } }, acceptedCreate)
await expect(createMobileStructuredCodexSession(client, 'workspace-1')).resolves.toMatchObject({
kind: 'created',
sessionId: expect.stringMatching(/^codex_[A-Za-z0-9_]{8,128}$/)
})
expect(client.sendRequest).toHaveBeenNthCalledWith(1, 'agentSession.createSupport', {
worktree: 'id:workspace-1',
agent: 'codex'
})
expect(client.sendRequest).toHaveBeenNthCalledWith(
2,
'agentSession.create',
expect.objectContaining({
worktree: 'id:workspace-1',
agent: 'codex',
envelope: expect.objectContaining({ expectedRuntimeFence: null })
}),
expect.objectContaining({ budgetSpansConnect: true })
)
const params = client.sendRequest.mock.calls[1]?.[1] as {
envelope: { sessionId: string; payloadFingerprint: string }
worktree: string
agent: 'codex'
}
expect(params.envelope.payloadFingerprint).toMatch(/^[0-9a-f]{64}$/)
expect(params.envelope.sessionId).toMatch(/^codex_[A-Za-z0-9_]{8,128}$/)
})
it('reports unsupported without creating a terminal when the structured path is unavailable', async () => {
const client = clientReturning({ ok: true, result: { supported: false, reason: 'remote' } })
await expect(createMobileStructuredCodexSession(client, 'workspace-1')).resolves.toEqual({
kind: 'unsupported',
reason: 'remote'
})
expect(client.sendRequest).toHaveBeenCalledTimes(1)
})
it('keeps an unknown create outcome distinct so callers do not create a duplicate terminal', async () => {
const client = clientReturning({ ok: true, result: { supported: true } })
client.sendRequest.mockImplementationOnce(async () => ({
ok: true,
result: { supported: true }
}))
client.sendRequest.mockRejectedValue(markRpcDeliveryUnknown(new Error('response lost')))
await expect(createMobileStructuredCodexSession(client, 'workspace-1')).resolves.toMatchObject({
kind: 'unknown'
})
expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([
'agentSession.createSupport',
'agentSession.create',
'agentSession.create'
])
expect(client.sendRequest.mock.calls[1]?.[1]).toBe(client.sendRequest.mock.calls[2]?.[1])
})
it('keeps the outcome unknown when the idempotent retry cannot be sent', async () => {
const client = clientReturning({ ok: true, result: { supported: true } })
client.sendRequest.mockImplementationOnce(async () => ({
ok: true,
result: { supported: true }
}))
client.sendRequest.mockRejectedValueOnce(markRpcDeliveryUnknown(new Error('response lost')))
client.sendRequest.mockRejectedValueOnce(new Error('connection interrupted'))
await expect(createMobileStructuredCodexSession(client, 'workspace-1')).resolves.toMatchObject({
kind: 'unknown'
})
})
it('treats malformed structured responses as unknown', async () => {
const client = clientReturning(
{ ok: true, result: { supported: true } },
{ ok: true, result: { ok: true, value: { sessionId: '' } } }
)
await expect(createMobileStructuredCodexSession(client, 'workspace-1')).resolves.toMatchObject({
kind: 'unknown'
})
})
})
@@ -0,0 +1,170 @@
import type {
AgentSessionAttachResult,
AgentSessionMutationResult
} from '../../../src/shared/agent-session-wire'
import { structuredAgentSessionPayloadFingerprint } from '../../../src/shared/structured-agent-session-mutation'
import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
import type { RpcClient } from '../transport/rpc-client'
import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import { structuredSessionOperationId } from './mobile-structured-agent-session-rpc'
type StructuredCreateSupport = {
supported?: boolean
reason?: 'agent' | 'remote' | 'wsl'
}
export type MobileStructuredCodexLaunchResult =
| { kind: 'created'; sessionId: string }
| { kind: 'unsupported'; reason?: StructuredCreateSupport['reason'] }
| { kind: 'failed'; message: string }
| { kind: 'unknown'; message: string }
type StructuredCreateParams = {
envelope: {
sessionId: string
clientOperationId: string
expectedRuntimeFence: null
payloadFingerprint: string
}
worktree: string
agent: 'codex'
}
function createStructuredCodexSessionId(): string {
return `codex_${createRandomUuid().replaceAll('-', '_')}`
}
function createRandomUuid(): string {
if (typeof globalThis.crypto?.randomUUID === 'function') {
return globalThis.crypto.randomUUID()
}
return Array.from({ length: 32 }, () => Math.floor(Math.random() * 16).toString(16)).join('')
}
function createStructuredCodexSessionParams(worktreeId: string): StructuredCreateParams {
const sessionId = createStructuredCodexSessionId()
const worktree = `id:${worktreeId}`
const fields = { worktree, agent: 'codex' as const }
return {
envelope: {
sessionId,
clientOperationId: structuredSessionOperationId(),
expectedRuntimeFence: null,
payloadFingerprint: structuredAgentSessionPayloadFingerprint({
method: 'agentSession.create',
sessionId,
fields
})
},
...fields
}
}
function unknownCreateResult(error: unknown): MobileStructuredCodexLaunchResult {
const message = error instanceof Error ? error.message.trim() : ''
return {
kind: 'unknown',
message: message || 'The Codex chat result could not be confirmed.'
}
}
function isUnknownCreateError(error: unknown): boolean {
return isRpcDeliveryUnknown(error) || isLogicalClientCutoverError(error)
}
export async function createMobileStructuredCodexSession(
client: RpcClient,
worktreeId: string
): Promise<MobileStructuredCodexLaunchResult> {
const worktree = `id:${worktreeId}`
let supportResponse
try {
supportResponse = await client.sendRequest('agentSession.createSupport', {
worktree,
agent: 'codex'
})
} catch {
// A support probe has no side effect; an unavailable probe safely degrades to terminal chat.
return { kind: 'unsupported' }
}
if (
!supportResponse ||
typeof supportResponse !== 'object' ||
typeof supportResponse.ok !== 'boolean' ||
!supportResponse.ok
) {
return { kind: 'unsupported' }
}
const support = supportResponse.result as StructuredCreateSupport | null
if (!support || typeof support !== 'object' || support.supported !== true) {
return { kind: 'unsupported', reason: support?.reason }
}
const params = createStructuredCodexSessionParams(worktreeId)
let response
try {
response = await client.sendRequest('agentSession.create', params, {
timeoutMs: 15_000,
budgetSpansConnect: true
})
} catch (error) {
if (!isUnknownCreateError(error)) {
return {
kind: 'failed',
message: error instanceof Error ? error.message : 'Could not open Codex chat.'
}
}
// Replay the durable envelope once so a lost acknowledgement cannot create a sibling.
try {
response = await client.sendRequest('agentSession.create', params, {
timeoutMs: 15_000,
budgetSpansConnect: true
})
} catch (retryError) {
// A second transport error cannot disprove the first attempt committed.
return unknownCreateResult(retryError)
}
}
if (!response || typeof response !== 'object' || typeof response.ok !== 'boolean') {
return unknownCreateResult(new Error('The Codex chat result could not be confirmed.'))
}
if (!response.ok) {
if (
!response.error ||
typeof response.error !== 'object' ||
typeof response.error.code !== 'string'
) {
return unknownCreateResult(new Error('The Codex chat result could not be confirmed.'))
}
if (response.error.code === 'agent_session_operation_unknown') {
return unknownCreateResult(new Error(response.error.message))
}
return { kind: 'failed', message: response.error.message || 'Could not open Codex chat.' }
}
const result = response.result as AgentSessionMutationResult<AgentSessionAttachResult>
if (!result || typeof result !== 'object' || typeof result.ok !== 'boolean') {
return unknownCreateResult(new Error('The Codex chat result could not be confirmed.'))
}
if (!result.ok) {
if (
!result.refusal ||
typeof result.refusal !== 'object' ||
typeof result.refusal.code !== 'string'
) {
return unknownCreateResult(new Error('The Codex chat result could not be confirmed.'))
}
if (result.refusal.code === 'agent_session_operation_unknown') {
return unknownCreateResult(new Error(result.refusal.message))
}
return { kind: 'failed', message: result.refusal.message || 'Could not open Codex chat.' }
}
if (
!result.value ||
typeof result.value.sessionId !== 'string' ||
!result.value.sessionId.trim()
) {
return unknownCreateResult(new Error('The Codex chat result could not be confirmed.'))
}
return { kind: 'created', sessionId: result.value.sessionId }
}
@@ -0,0 +1,122 @@
import type { AgentSessionMutationResult } from '../../../src/shared/agent-session-wire'
import {
createStructuredAgentSessionOperationId,
structuredAgentSessionPayloadFingerprint
} from '../../../src/shared/structured-agent-session-mutation'
import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
import type { RpcClient } from '../transport/rpc-client'
import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import { MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS } from './mobile-native-chat-send'
export const STRUCTURED_SEND_TIMEOUT_MS = 15_000
export type StructuredAgentSessionMutationCallResult<TValue> =
| { status: 'accepted'; value: TValue }
| { status: 'refused'; message: string }
| { status: 'failed'; message: string }
| { status: 'unknown' }
export type StructuredAgentSessionMutationResult<TValue> =
| { status: 'accepted'; value: TValue; sameFence: boolean }
| { status: 'rejected' }
| { status: 'unknown' }
export type StructuredAgentSessionMutate = <TValue>(
method: string,
fingerprintMethod: string,
fields: Record<string, unknown>
) => Promise<StructuredAgentSessionMutationResult<TValue>>
export async function callAgentSession<TResult>(
client: RpcClient,
method: string,
params: unknown,
timeoutMs = STRUCTURED_SEND_TIMEOUT_MS,
options?: { failWhenDisconnected?: boolean }
): Promise<TResult> {
const response = await client.sendRequest(method, params, {
timeoutMs,
budgetSpansConnect: true,
...(options?.failWhenDisconnected ? { failWhenDisconnected: true } : {})
})
if (!response.ok) {
throw new Error(response.error.message)
}
return response.result as TResult
}
export function structuredSessionOperationId(): string {
const randomUuid =
typeof globalThis.crypto?.randomUUID === 'function'
? () => globalThis.crypto.randomUUID()
: () => {
return Array.from({ length: 32 }, () => Math.floor(Math.random() * 16).toString(16)).join(
''
)
}
return createStructuredAgentSessionOperationId(randomUuid)
}
export function timeoutForDeadline(deadline: number | undefined): number | null {
if (deadline === undefined) {
return STRUCTURED_SEND_TIMEOUT_MS
}
const timeoutMs = deadline - Date.now()
return timeoutMs >= MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS ? timeoutMs : null
}
export async function requestStructuredAgentSessionMutation<TValue>(args: {
client: RpcClient
method: string
fingerprintMethod: string
sessionId: string
expectedRuntimeFence: number
fields: Record<string, unknown>
clientOperationId?: string
retryUnknown?: boolean
timeoutMs?: number
}): Promise<StructuredAgentSessionMutationCallResult<TValue>> {
const {
client,
method,
fingerprintMethod,
sessionId,
expectedRuntimeFence,
fields,
clientOperationId,
retryUnknown,
timeoutMs
} = args
try {
const result = await callAgentSession<AgentSessionMutationResult<TValue>>(
client,
method,
{
envelope: {
sessionId,
clientOperationId: clientOperationId ?? structuredSessionOperationId(),
expectedRuntimeFence,
payloadFingerprint: structuredAgentSessionPayloadFingerprint({
method: fingerprintMethod,
sessionId,
fields
})
},
...(retryUnknown ? { retryUnknown: true } : {}),
...fields
},
timeoutMs
)
return result.ok
? { status: 'accepted', value: result.value }
: { status: 'refused', message: result.refusal.message }
} catch (error) {
if (isRpcDeliveryUnknown(error) || isLogicalClientCutoverError(error)) {
return { status: 'unknown' }
}
return {
status: 'failed',
message: error instanceof Error ? error.message : 'Request not sent'
}
}
}
@@ -182,6 +182,20 @@ describe('mobile terminal records', () => {
).toBe(false)
})
it('treats structured agent-session identity changes as session-tab changes', () => {
const base = {
type: 'agent-session' as const,
id: 'agent-tab-1',
title: 'Codex',
sessionId: 'session-1',
agent: 'codex',
isActive: true
}
expect(mobileSessionTabsEqual([base], [{ ...base }])).toBe(true)
expect(mobileSessionTabsEqual([base], [{ ...base, sessionId: 'session-2' }])).toBe(false)
})
const record = (over: Partial<TerminalRecord> & { handle: string }): TerminalRecord => ({
title: 'Terminal',
terminalTheme: undefined,
@@ -62,6 +62,14 @@ type MobileSessionTabLike =
canGoForward?: boolean
isActive?: boolean
}
| {
type: 'agent-session'
id: string
title?: string
sessionId?: string
agent?: string
isActive?: boolean
}
export function mobileTerminalThemesEqual(
left: MobileTerminalTheme | null | undefined,
@@ -152,6 +160,8 @@ function mobileSessionTabEqual(
a.canGoBack === b.canGoBack &&
a.canGoForward === b.canGoForward
)
case 'agent-session':
return b.type === 'agent-session' && a.sessionId === b.sessionId && a.agent === b.agent
}
}
@@ -120,4 +120,17 @@ describe('getMobileSessionTabTitle', () => {
expect(getMobileSessionTabTitle(blankBrowserTab)).toBe('New Browser')
})
it('labels structured agent-session tabs without terminal decoration rules', () => {
expect(
getMobileSessionTabTitle({
type: 'agent-session',
id: 'agent-tab-1',
title: 'Codex Chat',
sessionId: 'session-1',
agent: 'codex',
isActive: true
})
).toBe('Codex Chat')
})
})
@@ -62,6 +62,9 @@ export function getMobileSessionTabTitle(tab: MobileSessionTab): string {
if (tab.type === 'file') {
return tab.title || 'File'
}
if (tab.type === 'agent-session') {
return tab.title || 'Chat'
}
// Why: strip the leading agent status glyph (✳ etc.) once the tab shows the
// provider icon. Mobile falls back for glyph-only titles because iOS can
// render the bare status glyph as a stray colored box beside the icon.
@@ -378,4 +378,19 @@ describe('shouldActivateOpenedMobileSessionTab', () => {
})
).toBe(false)
})
it('allows a structured agent-session tab to anchor chat file activation', () => {
expect(
shouldActivateOpenedMobileSessionTab({
activated: false,
activationSeq: 2,
latestActivationSeq: 2,
sourceTerminalHandle: null,
activeTerminalHandle: null,
sourceSessionTabId: 'agent-tab-1',
activeSessionTabId: 'agent-tab-1',
activeTabType: 'agent-session'
})
).toBe(true)
})
})
@@ -9,8 +9,10 @@ export type OpenedMobileSessionTabActivationState = {
activated: boolean
activationSeq: number
latestActivationSeq: number
sourceTerminalHandle: string
sourceTerminalHandle: string | null
activeTerminalHandle: string | null
sourceSessionTabId?: string | null
activeSessionTabId?: string | null
activeTabType: string | null
}
@@ -114,12 +116,15 @@ export async function activateOpenedSourceControlDiffTab<T extends OpenedMobileS
export function shouldActivateOpenedMobileSessionTab(
state: OpenedMobileSessionTabActivationState
): boolean {
return (
!state.activated &&
state.activationSeq === state.latestActivationSeq &&
state.activeTabType === 'terminal' &&
state.activeTerminalHandle === state.sourceTerminalHandle
)
const sourceStillActive =
state.activeTabType === 'agent-session'
? state.sourceSessionTabId !== null &&
state.sourceSessionTabId !== undefined &&
state.activeSessionTabId === state.sourceSessionTabId
: state.activeTabType === 'terminal' &&
state.sourceTerminalHandle !== null &&
state.activeTerminalHandle === state.sourceTerminalHandle
return !state.activated && state.activationSeq === state.latestActivationSeq && sourceStillActive
}
export async function activateOpenedMobileSessionTab<T extends OpenedMobileSessionTabCandidate>(
@@ -142,4 +142,31 @@ describe('useMobileFileTapHandlers', () => {
)
expect(options.reportChatTapFailure).toHaveBeenCalledWith("Couldn't open mobile/src/x.ts:12")
})
it('lets structured chat file taps resolve without a backing terminal handle', async () => {
const sendRequest = vi.fn(async () => ok({ exists: false, isDirectory: false }))
const options = {
...createOptions(sendRequest),
activeHandleRef: { current: null as string | null },
getActiveSessionTabId: () => 'agent-tab-1',
getActiveSessionTabType: () => 'agent-session'
}
act(() => {
renderer = create(createElement(Harness, { options }))
})
handlers!.handleNativeChatFileTap('src/app.ts')
await act(async () => {})
expect(sendRequest).toHaveBeenCalledWith(
'files.resolveTerminalPath',
{
worktree: 'id:wt-1',
pathText: 'src/app.ts',
crossWorkspace: true,
nativeChatContext: { tabId: 'agent-tab-1', sessionId: 'session-1' }
},
{ timeoutMs: 10_000 }
)
})
})
@@ -141,15 +141,13 @@ export function useMobileFileTapHandlers<T extends FileTapSessionTab>(
const handleNativeChatFileTap = useCallback((pathText: string) => {
const current = optionsRef.current
// The chat overlay rides on its backing terminal tab; that handle anchors
// the activation gate even though resolution ignores the terminal's cwd.
const sourceTerminalHandle = current.activeHandleRef.current
if (!current.client || !sourceTerminalHandle) {
const nativeChatSessionId = current.nativeChatSessionId
const nativeChatTabId = current.getActiveSessionTabId()
if (!current.client || (!sourceTerminalHandle && !(nativeChatSessionId && nativeChatTabId))) {
return
}
const activationSeq = ++activationSeqRef.current
const nativeChatSessionId = current.nativeChatSessionId
const nativeChatTabId = current.getActiveSessionTabId()
openMobileNativeChatFileTap<T>({
client: current.client,
hostId: current.hostId,
@@ -172,6 +170,8 @@ export function useMobileFileTapHandlers<T extends FileTapSessionTab>(
latestActivationSeq: activationSeqRef.current,
sourceTerminalHandle,
activeTerminalHandle: current.activeHandleRef.current,
sourceSessionTabId: nativeChatTabId,
activeSessionTabId: current.getActiveSessionTabId(),
activeTabType: current.getActiveSessionTabType()
}),
switchSessionTab: current.switchSessionTab,
@@ -0,0 +1,83 @@
import { useLayoutEffect, useRef, type MutableRefObject } from 'react'
import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention'
import { resolveMobileNativeChat, type MobileNativeChatTab } from './mobile-native-chat-eligibility'
import { useMobileSessionViewMode } from './use-mobile-session-view-mode'
export function useMobileNativeChatActiveResolution(args: {
hostId: string
worktreeId: string
activeSessionTab: MobileNativeChatTab | null
activeSessionTabId: string | null
activeHandleRef: MutableRefObject<string | null>
nativeChatTranscriptIsLocalReadable: boolean
}): {
isTabChatView: (tabId: string) => boolean
toggleTabChatView: (tabId: string) => void
showNativeChat: boolean
showNativeChatRef: MutableRefObject<boolean>
activeChatAgent: string | null
activeChatAgentRef: MutableRefObject<string | null>
activeChatSessionId: string | null
activeChatStructured: boolean
activeChatResolution: ReturnType<typeof resolveMobileNativeChat>
activeTabAgentWorking: boolean
nativeChatStatus: MobileNativeChatTab['agentStatus'] | null
sourceIdentity: string
streamIdentity: string
streamScopeKey: string
} {
const {
activeHandleRef,
activeSessionTab,
activeSessionTabId,
hostId,
nativeChatTranscriptIsLocalReadable,
worktreeId
} = args
const { isTabChatView, toggleTabChatView } = useMobileSessionViewMode({ hostId, worktreeId })
const tabWantsChat =
activeSessionTab?.type === 'agent-session' ||
(activeSessionTabId ? isTabChatView(activeSessionTabId) : false)
const activeChatResolution =
activeSessionTab && activeSessionTabId && tabWantsChat
? resolveMobileNativeChat(activeSessionTab, nativeChatTranscriptIsLocalReadable)
: null
const showNativeChat = activeChatResolution != null
const showNativeChatRef = useRef(showNativeChat)
const activeChatAgent = activeChatResolution?.agent ?? null
const activeChatAgentRef = useRef<string | null>(activeChatAgent)
useLayoutEffect(() => {
showNativeChatRef.current = showNativeChat
activeChatAgentRef.current = activeChatAgent
}, [activeChatAgent, showNativeChat])
const activeChatSessionId = activeChatResolution?.sessionId ?? null
const activeChatStructured =
activeChatResolution != null && activeSessionTab?.type === 'agent-session'
const activeTabStatus = activeSessionTab?.agentStatus
const activeTabAgentWorking =
activeTabStatus?.state === 'working' && activeTabStatus.workingMode !== 'monitoring'
const nativeChatStatus = activeChatResolution && !activeChatStructured ? activeTabStatus : null
const routeKey = `${hostId}\0${worktreeId}\0${activeSessionTabId ?? ''}`
const streamIdentity = `${routeKey}\0${activeChatSessionId ?? ''}\0${activeHandleRef.current ?? ''}`
const providerSessionId = activeSessionTab?.agentStatus?.providerSession?.id ?? ''
const streamScopeKey = `${routeKey}\0${activeChatSessionId ?? providerSessionId}\0${activeHandleRef.current ?? ''}`
return {
isTabChatView,
toggleTabChatView,
showNativeChat,
showNativeChatRef,
activeChatAgent,
activeChatAgentRef,
activeChatSessionId,
activeChatStructured,
activeChatResolution,
activeTabAgentWorking,
nativeChatStatus,
sourceIdentity: encodeNativeChatTranscriptIdentity([hostId, worktreeId]),
streamIdentity,
streamScopeKey
}
}
@@ -1,6 +1,7 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { SessionOptionDescriptor } from '../../../src/shared/native-chat-session-options'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
@@ -14,6 +15,55 @@ const holdUnconfirmedSend = vi.fn()
// and transcript state; defaults keep the send-seam tests unchanged.
const viewMode = { isTabChatView: (_tabId: string) => true }
const sessionState = { messages: [] as unknown[], status: 'ready', transcriptLoading: false }
const structuredSendWithOutcome = vi.fn()
const structuredCancel = vi.fn()
const structuredRespondPermission = vi.fn(async () => true)
const structuredRespondQuestion = vi.fn(async () => true)
const structuredSetOption = vi.fn(async () => true)
const structuredInvokeOption = vi.fn(async () => true)
const structuredOptionSnapshot: SessionOptionDescriptor[] = [
{
id: 'model',
label: 'Model',
category: 'model',
kind: {
type: 'select',
currentValue: 'gpt-fast',
choices: [{ value: 'gpt-fast', label: 'GPT Fast' }]
},
valueSource: 'reported',
settable: true
}
]
const structuredOptionSurface = {
getSnapshot: () => structuredOptionSnapshot,
setOption: async () => ({ snapshot: structuredOptionSnapshot }),
invokeAction: async () => ({ snapshot: structuredOptionSnapshot }),
subscribe: () => () => {}
}
const structuredPermission = {
title: 'Allow Bash?',
detail: 'rm -rf build',
options: [
{ label: 'Allow once', send: 'allow-once' },
{ label: 'Deny', send: 'deny' }
]
}
const structuredQuestion = {
question: 'Pick destination',
options: ['Choice A', 'Choice B'],
allowOther: true,
optionTokens: ['choice-a', 'choice-b']
}
const structuredSessionState = {
messages: [] as unknown[],
status: 'ready',
transcriptLoading: false,
error: undefined,
hasMore: false,
loadingEarlier: false,
loadEarlier: vi.fn()
}
const draftsArgs: Record<string, unknown>[] = []
const promptsState = {
permission: null as unknown,
@@ -33,6 +83,24 @@ vi.mock('./use-mobile-session-view-mode', () => ({
vi.mock('./use-mobile-native-chat-session', () => ({
useMobileNativeChatSession: () => sessionState
}))
vi.mock('./use-mobile-structured-agent-session', () => ({
useMobileStructuredAgentSession: () => ({
session: structuredSessionState,
isWorking: false,
turnId: null,
sendWithOutcome: structuredSendWithOutcome,
cancel: structuredCancel,
permission: structuredPermission,
question: structuredQuestion,
optionSnapshot: structuredOptionSnapshot,
optionSurface: structuredOptionSurface,
pendingOptionId: 'model',
respondPermission: structuredRespondPermission,
respondQuestion: structuredRespondQuestion,
setStructuredOption: structuredSetOption,
invokeStructuredOption: structuredInvokeOption
})
}))
vi.mock('./use-mobile-native-chat-drafts', () => ({
useMobileNativeChatDrafts: (args: Record<string, unknown>) => {
draftsArgs.push(args)
@@ -110,18 +178,28 @@ describe('useMobileNativeChatController handleNativeChatSend', () => {
// itself is mocked above).
const clientStub = { sendRequest: vi.fn() }
function Harness({ connState = 'connected' }: { connState?: ConnectionState }): null {
function Harness({
connState = 'connected',
tab = null,
activeHandle = 'term-1',
inputLeaseReady = true
}: {
connState?: ConnectionState
tab?: unknown
activeHandle?: string | null
inputLeaseReady?: boolean
}): null {
controller = useMobileNativeChatController({
client: clientStub as unknown as RpcClient,
connState,
hostId: 'h',
worktreeId: 'w',
activeSessionTab: null,
activeSessionTabId: 'tab-1',
activeHandleRef: { current: 'term-1' },
activeSessionTab: tab as never,
activeSessionTabId: (tab as { id?: string } | null)?.id ?? 'tab-1',
activeHandleRef: { current: activeHandle },
deviceTokenRef: { current: null },
nativeChatTranscriptIsLocalReadable: true,
nativeChatInputLeaseReady: true,
nativeChatInputLeaseReady: inputLeaseReady,
onSendError,
onSendResolved
})
@@ -138,6 +216,7 @@ describe('useMobileNativeChatController handleNativeChatSend', () => {
})
resetMobileNativeChatStaleInputForTests()
captureSendOrigin.mockReturnValue(ORIGIN)
structuredSendWithOutcome.mockResolvedValue('accepted')
act(() => {
renderer = create(createElement(Harness))
})
@@ -233,6 +312,80 @@ describe('useMobileNativeChatController handleNativeChatSend', () => {
expect(restoreRejectedDraft).not.toHaveBeenCalled()
})
it('routes structured agent-session sends away from terminal/nativeChat transports', async () => {
await act(async () => {
renderer?.update(
createElement(Harness, {
tab: {
type: 'agent-session',
id: 'agent-tab-1',
title: 'Codex Chat',
sessionId: 'session-structured',
agent: 'codex',
isActive: true
},
activeHandle: null,
inputLeaseReady: false
})
)
})
let accepted = false
await act(async () => {
accepted = await controller!.handleNativeChatSend('look')
})
expect(accepted).toBe(true)
expect(structuredSendWithOutcome).toHaveBeenCalledWith('look')
expect(sendWithOutcome).not.toHaveBeenCalled()
expect(clientStub.sendRequest).not.toHaveBeenCalled()
})
it('exposes structured prompt cards and session options on structured tabs', async () => {
await act(async () => {
renderer?.update(
createElement(Harness, {
tab: {
type: 'agent-session',
id: 'agent-tab-1',
title: 'Codex Chat',
sessionId: 'session-structured',
agent: 'codex',
isActive: true
},
activeHandle: null,
inputLeaseReady: false
})
)
})
expect(controller!.nativeChatPermission).toEqual(structuredPermission)
expect(controller!.nativeChatQuestion).toEqual(structuredQuestion)
expect(controller!.nativeChatSessionOptions).not.toBeNull()
expect(controller!.nativeChatSessionOptions?.controller.snapshot).toEqual(
structuredOptionSnapshot
)
await act(async () => {
expect(await controller!.handleNativeChatRespondPermission('allow-once')).toBe(true)
})
expect(structuredRespondPermission).toHaveBeenCalledWith('allow-once')
expect(sendWithOutcome).not.toHaveBeenCalled()
await act(async () => {
expect(await controller!.handleNativeChatQuestionAnswer('choice-a')).toBe(true)
})
expect(structuredRespondQuestion).toHaveBeenCalledWith('choice-a')
expect(clientStub.sendRequest).not.toHaveBeenCalled()
await act(async () => {
expect(
await controller!.nativeChatSessionOptions!.controller.setOption('model', 'gpt-fast')
).toBe(true)
})
expect(structuredSetOption).toHaveBeenCalledWith('model', 'gpt-fast')
})
it('pre-clears separately for a text-only send but never for an image send', async () => {
// The image path pastes the image behind its OWN leading Ctrl+U and then calls
// this send; a second clear here wipes the image off the input line and the
@@ -1,9 +1,7 @@
import { useCallback, useLayoutEffect, useRef, type MutableRefObject } from 'react'
import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention'
import { useMobileSessionViewMode } from './use-mobile-session-view-mode'
import { useLayoutEffect, useRef, type MutableRefObject } from 'react'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
import { type MobileNativeChatTab, resolveMobileNativeChat } from './mobile-native-chat-eligibility'
import type { MobileNativeChatTab } from './mobile-native-chat-eligibility'
import { useMobileNativeChatPermissionSend } from './mobile-native-chat-permission-send'
import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send'
import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss'
@@ -11,15 +9,17 @@ import { useMobileNativeChatCancelAsk } from './use-mobile-native-chat-cancel-as
import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts'
import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search'
import { useMobileNativeChatMessageSend } from './use-mobile-native-chat-message-send'
import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key'
import { mobileNativeChatStreamPreview } from './mobile-native-chat-streaming-gate'
import { useMobileNativeChatSession } from './use-mobile-native-chat-session'
import { useMobileNativeChatSessionOptions } from './use-mobile-native-chat-session-options'
import { useMobileNativeChatSessionOptionController } from './use-mobile-native-chat-session-option-controller'
import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session'
import { useMobileStructuredNativeChatSendBridge } from './use-mobile-structured-native-chat-send-bridge'
import { useMobileNativeChatPrompts } from './use-mobile-native-chat-prompts'
import { useMobileNativeChatStop } from './use-mobile-native-chat-stop'
import { useNativeChatAcceptedAction } from './use-native-chat-action-outcomes'
import { useThrottledLatestValue } from './use-throttled-latest-value'
import type { MobileNativeChatController } from './mobile-native-chat-controller-contract'
import { useMobileNativeChatActiveResolution } from './use-mobile-native-chat-active-resolution'
export type { MobileNativeChatController } from './mobile-native-chat-controller-contract'
@@ -58,36 +58,48 @@ export function useMobileNativeChatController(args: {
onSendError,
onSendResolved
} = args
const { isTabChatView, toggleTabChatView } = useMobileSessionViewMode({ hostId, worktreeId })
const activeChatResolution =
activeSessionTab && activeSessionTabId && isTabChatView(activeSessionTabId)
? resolveMobileNativeChat(activeSessionTab, nativeChatTranscriptIsLocalReadable)
: null
const showNativeChat = activeChatResolution != null
const showNativeChatRef = useRef(showNativeChat)
const activeChatAgent = activeChatResolution?.agent ?? null
const activeChatAgentRef = useRef<string | null>(activeChatAgent)
useLayoutEffect(() => {
showNativeChatRef.current = showNativeChat
activeChatAgentRef.current = activeChatAgent
}, [activeChatAgent, showNativeChat])
const activeChatSessionId = activeChatResolution?.sessionId ?? null
const routeKey = `${hostId}\0${worktreeId}\0${activeSessionTabId ?? ''}`
const streamIdentity = `${routeKey}\0${activeChatSessionId ?? ''}\0${activeHandleRef.current ?? ''}`
// Same chat, but keyed off the tab rather than the view-gated resolution:
// `streamIdentity` goes session-less the moment the user peeks at the terminal,
// and a scope that flips on a view toggle throws the gate's baseline away.
const streamScopeKey = `${routeKey}\0${activeSessionTab?.agentStatus?.providerSession?.id ?? ''}\0${activeHandleRef.current ?? ''}`
const nativeChatSession = useMobileNativeChatSession({
client,
sourceIdentity: encodeNativeChatTranscriptIdentity([hostId, worktreeId]),
agent: activeChatResolution?.agent ?? null,
sessionId: activeChatSessionId,
transcriptPath: activeChatResolution?.transcriptPath ?? null
const {
activeChatAgent,
activeChatAgentRef,
activeChatResolution,
activeChatSessionId,
activeChatStructured,
activeTabAgentWorking,
isTabChatView,
nativeChatStatus,
showNativeChat,
showNativeChatRef,
sourceIdentity,
streamIdentity,
streamScopeKey,
toggleTabChatView
} = useMobileNativeChatActiveResolution({
hostId,
worktreeId,
activeSessionTab,
activeSessionTabId,
activeHandleRef,
nativeChatTranscriptIsLocalReadable
})
const legacyNativeChatSession = useMobileNativeChatSession({
client,
sourceIdentity,
agent: activeChatStructured ? null : (activeChatResolution?.agent ?? null),
sessionId: activeChatStructured ? null : activeChatSessionId,
transcriptPath: activeChatStructured ? null : (activeChatResolution?.transcriptPath ?? null)
})
const structuredNativeChat = useMobileStructuredAgentSession({
client,
sessionId: activeChatStructured ? activeChatSessionId : null,
enabled: showNativeChat,
connected: connState === 'connected',
agent: activeChatStructured ? activeChatAgent : null,
onSendError
})
const nativeChatSession = activeChatStructured
? structuredNativeChat.session
: legacyNativeChatSession
const {
composerText: chatComposerText,
setComposerText: setChatComposerText,
@@ -117,27 +129,29 @@ export function useMobileNativeChatController(args: {
transcriptSettled: nativeChatSession.status === 'ready'
})
const activeTabStatus = activeSessionTab?.agentStatus
const activeTabAgentWorking =
activeTabStatus?.state === 'working' && activeTabStatus.workingMode !== 'monitoring'
const nativeChatStatus = activeChatResolution ? activeTabStatus : null
const nativeChatAgentWorking = activeChatResolution != null && activeTabAgentWorking
const nativeChatAgentWorking = activeChatStructured
? structuredNativeChat.isWorking
: activeChatResolution != null && activeTabAgentWorking
// Deliberately not gated on the chat view being visible: the streaming gate
// has to tell "hidden mid-turn" from "the turn ended".
const nativeChatStreamLive = activeTabAgentWorking
const nativeChatStreamLive = activeChatStructured
? structuredNativeChat.isWorking
: activeTabAgentWorking
// Throttle the streaming bubble: OpenCode emits a status frame per streamed
// part, and each one re-renders and re-parses the whole accumulated markdown.
const nativeChatStreamingText = useThrottledLatestValue(
mobileNativeChatStreamPreview(nativeChatStatus, nativeChatAgentWorking),
activeChatStructured
? undefined
: mobileNativeChatStreamPreview(nativeChatStatus, nativeChatAgentWorking),
NATIVE_CHAT_STREAM_THROTTLE_MS
)
const {
permission: nativeChatPermission,
question: nativeChatQuestion,
permission: legacyNativeChatPermission,
question: legacyNativeChatQuestion,
detectedAsk: nativeChatDetectedAsk,
ask: nativeChatAskPrompt
} = useMobileNativeChatPrompts({
enabled: activeChatResolution != null,
enabled: activeChatResolution != null && !activeChatStructured,
status: nativeChatStatus,
messages: nativeChatSession.messages,
transcriptLoading: nativeChatSession.transcriptLoading
@@ -146,8 +160,6 @@ export function useMobileNativeChatController(args: {
const nativeChatTranscriptSettled =
nativeChatSession.status === 'ready' ||
(nativeChatSession.status === 'error' && nativeChatSession.messages.length > 0)
const nativeChatAskObservable =
showNativeChat && (nativeChatDetectedAsk != null || nativeChatTranscriptSettled)
const {
askKey: nativeChatAskKey,
showAsk: showNativeChatAsk,
@@ -157,17 +169,19 @@ export function useMobileNativeChatController(args: {
detectedAsk: nativeChatDetectedAsk,
scopeKey: activeSessionTabId,
sessionKey: activeChatSessionId,
observing: nativeChatAskObservable
observing: showNativeChat && (nativeChatDetectedAsk != null || nativeChatTranscriptSettled)
})
// Every chat write gates on both: the lease proves the input floor is ours, and
// `connState` collapses a render before the lease does on disconnect.
const inputSendable = nativeChatInputLeaseReady && connState === 'connected'
const inputSendable = activeChatStructured
? client != null && activeChatSessionId != null && connState === 'connected'
: nativeChatInputLeaseReady && connState === 'connected'
const { answerAsk: handleNativeChatAnswerAsk, cancelPending: cancelNativeChatAnswer } =
useMobileNativeChatAnswerSend({
client,
enabled: inputSendable,
enabled: inputSendable && !activeChatStructured,
handleRef: activeHandleRef,
deviceTokenRef,
agentRef: activeChatAgentRef,
@@ -178,16 +192,16 @@ export function useMobileNativeChatController(args: {
const handleNativeChatCancelAsk = useMobileNativeChatCancelAsk({
client,
enabled: inputSendable,
enabled: inputSendable && !activeChatStructured,
handleRef: activeHandleRef,
deviceTokenRef,
cancelPending: cancelNativeChatAnswer,
onSendError
})
const handleNativeChatRespondPermission = useMobileNativeChatPermissionSend({
const legacyHandleNativeChatRespondPermission = useMobileNativeChatPermissionSend({
client,
enabled: inputSendable,
enabled: inputSendable && !activeChatStructured,
handleRef: activeHandleRef,
deviceTokenRef,
onSendError
@@ -195,7 +209,7 @@ export function useMobileNativeChatController(args: {
const handleNativeChatStop = useMobileNativeChatStop({
client,
enabled: inputSendable,
enabled: inputSendable && !activeChatStructured,
handleRef: activeHandleRef,
deviceTokenRef,
streamIdentity,
@@ -216,11 +230,11 @@ export function useMobileNativeChatController(args: {
const {
send: handleNativeChatSend,
sendWithOutcome: handleNativeChatSendWithOutcome,
answerQuestion: handleNativeChatQuestionAnswer,
answerQuestion: legacyHandleNativeChatQuestionAnswer,
dispatchCommand: handleNativeChatDispatchCommand
} = useMobileNativeChatMessageSend({
client,
enabled: inputSendable,
enabled: inputSendable && !activeChatStructured,
handleRef: activeHandleRef,
deviceTokenRef,
agentRef: activeChatAgentRef,
@@ -234,26 +248,44 @@ export function useMobileNativeChatController(args: {
onSendError
})
// Bring the terminal view forward when an agent-owned picker command is used.
const handleAgentPicker = useCallback(() => {
if (activeSessionTabId && isTabChatView(activeSessionTabId)) {
toggleTabChatView(activeSessionTabId)
}
}, [activeSessionTabId, isTabChatView, toggleTabChatView])
const sessionOptions = useMobileNativeChatSessionOptions({
agent: activeChatResolution?.agent ?? null,
scopeKey: mobileNativeChatScopeKey(hostId, worktreeId, activeSessionTabId),
reportedModel: activeSessionTab?.agentStatus?.model ?? null,
dispatchCommand: handleNativeChatDispatchCommand,
onAgentPicker: handleAgentPicker
const structuredNativeChatSend = useMobileStructuredNativeChatSendBridge({
sendStructured: structuredNativeChat.sendWithOutcome,
captureSendOrigin,
clearDraftForSend,
acceptSend,
holdUnconfirmedSend,
restoreRejectedDraft,
onSendError
})
const { nativeChatSessionOptions, recordCommand: recordNativeChatSessionOptionCommand } =
useMobileNativeChatSessionOptionController({
activeChatStructured,
activeSessionTabId,
agent: activeChatResolution?.agent ?? null,
dispatchCommand: handleNativeChatDispatchCommand,
hostId,
isTabChatView,
isWorking: nativeChatAgentWorking,
reportedModel: activeSessionTab?.agentStatus?.model ?? null,
structured: {
snapshot: structuredNativeChat.optionSnapshot,
pendingId: structuredNativeChat.pendingOptionId,
setOption: structuredNativeChat.setStructuredOption,
invokeAction: structuredNativeChat.invokeStructuredOption
},
toggleTabChatView,
worktreeId
})
useLayoutEffect(() => {
recordSessionOptionCommandRef.current = sessionOptions.recordCommand
}, [sessionOptions.recordCommand])
recordSessionOptionCommandRef.current = recordNativeChatSessionOptionCommand
}, [recordNativeChatSessionOptionCommand])
// Card actions retire the route's held failure banner too, not just sends.
const answerAsk = useNativeChatAcceptedAction(handleNativeChatAnswerAsk, onSendResolved)
const cancelAsk = useNativeChatAcceptedAction(handleNativeChatCancelAsk, onSendResolved)
const handleNativeChatRespondPermission = activeChatStructured
? structuredNativeChat.respondPermission
: legacyHandleNativeChatRespondPermission
const respond = useNativeChatAcceptedAction(handleNativeChatRespondPermission, onSendResolved)
return {
@@ -272,24 +304,31 @@ export function useMobileNativeChatController(args: {
nativeChatStreamingText,
nativeChatStreamLive,
nativeChatStreamScopeKey: streamScopeKey,
nativeChatPermission,
nativeChatQuestion,
nativeChatAsk: showNativeChatAsk ? nativeChatAskPrompt : null,
nativeChatPermission: activeChatStructured
? structuredNativeChat.permission
: legacyNativeChatPermission,
nativeChatQuestion: activeChatStructured
? structuredNativeChat.question
: legacyNativeChatQuestion,
nativeChatAsk: !activeChatStructured && showNativeChatAsk ? nativeChatAskPrompt : null,
nativeChatAskKey,
dismissNativeChatAsk,
handleNativeChatAnswerAsk: answerAsk,
handleNativeChatCancelAsk: cancelAsk,
handleNativeChatRespondPermission: respond,
handleNativeChatStop,
handleNativeChatStop: activeChatStructured ? structuredNativeChat.cancel : handleNativeChatStop,
nativeChatFilePaths,
loadNativeChatFiles,
handleNativeChatQuestionAnswer,
handleNativeChatSend,
handleNativeChatSendWithOutcome,
handleNativeChatQuestionAnswer: activeChatStructured
? structuredNativeChat.respondQuestion
: legacyHandleNativeChatQuestionAnswer,
handleNativeChatSend: activeChatStructured
? structuredNativeChatSend.send
: handleNativeChatSend,
handleNativeChatSendWithOutcome: activeChatStructured
? structuredNativeChatSend.sendWithOutcome
: handleNativeChatSendWithOutcome,
readSeededLaunchDraft,
nativeChatSessionOptions:
sessionOptions.snapshot.length > 0
? { controller: sessionOptions, isWorking: nativeChatAgentWorking }
: null
nativeChatSessionOptions
}
}
@@ -1,18 +1,17 @@
import { useCallback, useRef, useState } from 'react'
import { CLIPBOARD_IMAGE_TOO_LARGE_ERROR } from '../../../src/shared/clipboard-image'
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
import {
ImageLibraryPermissionError,
pickMobileImages,
type MobileImageSource
} from './mobile-image-source-picker'
import type { MobileImageSource } from './mobile-image-source-picker'
import {
appendPendingNativeChatImages,
uploadMobileNativeChatImages,
type PendingNativeChatImage
} from './mobile-native-chat-image-attachment'
import {
NO_NATIVE_CHAT_IMAGE_ATTACHMENTS,
withScopeAttachments,
type MobileNativeChatImagesByScope
} from './mobile-native-chat-image-scope-state'
import {
MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS,
pasteMobileNativeChatImagePaths
@@ -31,6 +30,7 @@ import {
acquireMobileNativeChatTerminalWrite,
releaseMobileNativeChatTerminalWrite
} from './mobile-native-chat-terminal-write-lock'
import { useMobileNativeChatImageUpload } from './use-mobile-native-chat-image-upload'
type CurrentRef<T> = { readonly current: T }
type ShowToast = (message: string, durationMs?: number) => void
@@ -60,8 +60,11 @@ type Args = {
readonly baseSend: (
text: string,
imagePreviewUris?: string[],
deadline?: number
deadline?: number,
attachments?: readonly PendingNativeChatImage[]
) => Promise<MobileNativeChatSendOutcome>
/** Structured sessions send attachments without the terminal paste path. */
readonly structuredNativeChat: boolean
/** Launch-context text parked on the agent's TUI input line, or null. The
* paste's leading clear must cover every line of it, or the draft's earlier
* lines survive and ride along with the image. */
@@ -83,21 +86,6 @@ export type MobileNativeChatImageAttachments = {
readonly sendNativeChat: (text: string) => Promise<boolean>
}
const NO_ATTACHMENTS: PendingNativeChatImage[] = []
function withScopeAttachments(
byScope: Record<string, PendingNativeChatImage[]>,
scope: string,
next: PendingNativeChatImage[]
): Record<string, PendingNativeChatImage[]> {
if (next.length > 0) {
return { ...byScope, [scope]: next }
}
const remaining = { ...byScope }
delete remaining[scope]
return remaining
}
const defaultSleep = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms))
@@ -112,98 +100,40 @@ export function useMobileNativeChatImageAttachments({
showToast,
onSendError,
baseSend,
structuredNativeChat,
readSeededLaunchDraft,
onAttachSuccess,
onError,
sleep = defaultSleep
}: Args): MobileNativeChatImageAttachments {
const [attachmentsByScope, setAttachmentsByScope] = useState<
Record<string, PendingNativeChatImage[]>
>({})
const [isAttaching, setIsAttaching] = useState(false)
const [attachmentsByScope, setAttachmentsByScope] = useState<MobileNativeChatImagesByScope>({})
const idCounter = useRef(0)
// Count in-flight uploads so an overlapping attach can't clear the flag early.
const attachingCount = useRef(0)
// Live connState for attachImage's catch: the closure's value was already
// checked 'connected' at entry, so only a ref can see a mid-upload disconnect.
const connStateRef = useRef(connState)
connStateRef.current = connState
const attachments =
(scopeKey ? attachmentsByScope[scopeKey] : undefined) ?? NO_NATIVE_CHAT_IMAGE_ATTACHMENTS
const attachments = (scopeKey ? attachmentsByScope[scopeKey] : undefined) ?? NO_ATTACHMENTS
const attachImage = useCallback(
async (source: MobileImageSource): Promise<void> => {
// The chip lands in the scope that initiated the pick, even if the user
// switches tabs while the upload is in flight.
const scope = scopeKey
if (!client || !scope || !activeHandleRef.current || connState !== 'connected') {
return
}
// Only this call's own increment may be undone in `finally`; a cancelled
// pick or pre-upload error never ran `onUploadStart`, so decrementing the
// shared counter would clear a concurrent upload's in-flight flag early.
let started = false
const uploadedImages: Omit<PendingNativeChatImage, 'id'>[] = []
let uploadError: unknown = null
try {
await uploadMobileNativeChatImages(source, {
client,
getConnectionId: getActiveWorktreeConnectionId,
pickImages: pickMobileImages,
onImageUploaded: (image) => uploadedImages.push(image),
onUploadStart: () => {
started = true
attachingCount.current += 1
setIsAttaching(true)
}
})
} catch (error) {
uploadError = error
} finally {
if (started) {
attachingCount.current -= 1
if (attachingCount.current === 0) {
setIsAttaching(false)
}
}
}
if (uploadedImages.length > 0) {
setAttachmentsByScope((prev) => ({
...prev,
[scope]: appendPendingNativeChatImages(prev[scope] ?? [], uploadedImages, idCounter)
}))
onAttachSuccess?.()
}
if (uploadError !== null) {
const message = uploadError instanceof Error ? uploadError.message : String(uploadError)
onError?.()
if (connStateRef.current !== 'connected') {
showToast('Attach failed (disconnected)', 1500)
return
}
if (uploadError instanceof ImageLibraryPermissionError) {
showToast('Photo permission denied', 1500)
return
}
if (message === CLIPBOARD_IMAGE_TOO_LARGE_ERROR) {
showToast('Image too large to attach', 1500)
return
}
showToast('Attach failed', 1500)
}
const addUploadedImages = useCallback(
(scope: string, uploadedImages: Omit<PendingNativeChatImage, 'id'>[]) => {
setAttachmentsByScope((prev) => ({
...prev,
[scope]: appendPendingNativeChatImages(prev[scope] ?? [], uploadedImages, idCounter)
}))
},
[
activeHandleRef,
client,
connState,
getActiveWorktreeConnectionId,
onAttachSuccess,
onError,
scopeKey,
showToast
]
[]
)
const { attachImage, isAttaching } = useMobileNativeChatImageUpload({
client,
activeHandleRef,
getActiveWorktreeConnectionId,
connState,
scopeKey,
structuredNativeChat,
showToast,
onImagesUploaded: addUploadedImages,
onAttachSuccess,
onError
})
const removeAttachment = useCallback(
(id: string): void => {
const scope = scopeKey
@@ -238,7 +168,32 @@ export function useMobileNativeChatImageAttachments({
const deadline = openMobileNativeChatSendBudget()
try {
const scope = scopeKey
const pendingImages = (scope ? attachmentsByScope[scope] : undefined) ?? NO_ATTACHMENTS
const pendingImages =
(scope ? attachmentsByScope[scope] : undefined) ?? NO_NATIVE_CHAT_IMAGE_ATTACHMENTS
if (structuredNativeChat && pendingImages.length > 0 && scope) {
if (!client || !enabled || connState !== 'connected') {
onError?.()
onSendError('Message not sent (disconnected)')
return false
}
const outcome = await baseSend(
text,
pendingImages.map((attachment) => attachment.previewUri),
deadline,
pendingImages
)
if (outcome !== 'rejected') {
const sentIds = new Set(pendingImages.map((attachment) => attachment.id))
setAttachmentsByScope((prev) =>
withScopeAttachments(
prev,
scope,
(prev[scope] ?? []).filter((attachment) => !sentIds.has(attachment.id))
)
)
}
return outcome !== 'rejected'
}
if (pendingImages.length === 0 || !scope) {
// Heal a previously failed paste: a text-only send to that terminal would
// otherwise glue the stale image paste onto this message. Best-effort —
@@ -0,0 +1,126 @@
import { useCallback, useLayoutEffect, useRef, useState } from 'react'
import { CLIPBOARD_IMAGE_TOO_LARGE_ERROR } from '../../../src/shared/clipboard-image'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
import {
ImageLibraryPermissionError,
pickMobileImages,
type MobileImageSource
} from './mobile-image-source-picker'
import {
uploadMobileNativeChatImages,
type PendingNativeChatImage
} from './mobile-native-chat-image-attachment'
type CurrentRef<T> = { readonly current: T }
type UploadedNativeChatImage = Omit<PendingNativeChatImage, 'id'>
type ShowToast = (message: string, durationMs?: number) => void
export function useMobileNativeChatImageUpload(args: {
client: RpcClient | null
activeHandleRef: CurrentRef<string | null>
getActiveWorktreeConnectionId: () => Promise<string | null>
connState: ConnectionState
scopeKey: string | null
structuredNativeChat: boolean
showToast: ShowToast
onImagesUploaded: (scope: string, images: UploadedNativeChatImage[]) => void
onAttachSuccess?: () => void
onError?: () => void
}): {
attachImage: (source: MobileImageSource) => Promise<void>
isAttaching: boolean
} {
const {
activeHandleRef,
client,
connState,
getActiveWorktreeConnectionId,
onAttachSuccess,
onError,
onImagesUploaded,
scopeKey,
showToast,
structuredNativeChat
} = args
const [isAttaching, setIsAttaching] = useState(false)
const attachingCount = useRef(0)
const connStateRef = useRef(connState)
useLayoutEffect(() => {
connStateRef.current = connState
}, [connState])
const attachImage = useCallback(
async (source: MobileImageSource): Promise<void> => {
const scope = scopeKey
if (
!client ||
!scope ||
connState !== 'connected' ||
(!activeHandleRef.current && !structuredNativeChat)
) {
return
}
let started = false
const uploadedImages: UploadedNativeChatImage[] = []
let uploadError: unknown = null
try {
await uploadMobileNativeChatImages(source, {
client,
getConnectionId: getActiveWorktreeConnectionId,
pickImages: pickMobileImages,
onImageUploaded: (image) => uploadedImages.push(image),
onUploadStart: () => {
started = true
attachingCount.current += 1
setIsAttaching(true)
}
})
} catch (error) {
uploadError = error
} finally {
if (started) {
attachingCount.current -= 1
if (attachingCount.current === 0) {
setIsAttaching(false)
}
}
}
if (uploadedImages.length > 0) {
onImagesUploaded(scope, uploadedImages)
onAttachSuccess?.()
}
if (uploadError !== null) {
const message = uploadError instanceof Error ? uploadError.message : String(uploadError)
onError?.()
if (connStateRef.current !== 'connected') {
showToast('Attach failed (disconnected)', 1500)
return
}
if (uploadError instanceof ImageLibraryPermissionError) {
showToast('Photo permission denied', 1500)
return
}
if (message === CLIPBOARD_IMAGE_TOO_LARGE_ERROR) {
showToast('Image too large to attach', 1500)
return
}
showToast('Attach failed', 1500)
}
},
[
activeHandleRef,
client,
connState,
getActiveWorktreeConnectionId,
onAttachSuccess,
onError,
onImagesUploaded,
scopeKey,
showToast,
structuredNativeChat
]
)
return { attachImage, isAttaching }
}
@@ -0,0 +1,100 @@
import { useCallback, useMemo } from 'react'
import type {
SessionOptionDescriptor,
SessionOptionValue
} from '../../../src/shared/native-chat-session-options'
import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key'
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers'
import {
useMobileNativeChatSessionOptions,
type MobileNativeChatSessionOptionsController
} from './use-mobile-native-chat-session-options'
export function useMobileNativeChatSessionOptionController(args: {
activeChatStructured: boolean
activeSessionTabId: string | null
agent: string | null
dispatchCommand: (text: string) => Promise<MobileNativeChatSendOutcome>
hostId: string
isTabChatView: (tabId: string) => boolean
isWorking: boolean
reportedModel: string | null
structured: {
snapshot: SessionOptionDescriptor[]
pendingId: string | null
setOption: (id: string, value: SessionOptionValue) => Promise<boolean>
invokeAction: (id: string) => Promise<boolean>
}
toggleTabChatView: (tabId: string) => void
worktreeId: string
}): {
nativeChatSessionOptions: MobileNativeChatSessionOptionPickersProps | null
recordCommand: (command: string) => void
} {
const {
activeChatStructured,
activeSessionTabId,
agent,
dispatchCommand,
hostId,
isTabChatView,
isWorking,
reportedModel,
structured,
toggleTabChatView,
worktreeId
} = args
const {
invokeAction: invokeStructuredAction,
pendingId: structuredPendingId,
setOption: setStructuredOption,
snapshot: structuredSnapshot
} = structured
const handleAgentPicker = useCallback(() => {
if (activeSessionTabId && isTabChatView(activeSessionTabId)) {
toggleTabChatView(activeSessionTabId)
}
}, [activeSessionTabId, isTabChatView, toggleTabChatView])
const sessionOptions = useMobileNativeChatSessionOptions({
agent: activeChatStructured ? null : agent,
scopeKey: mobileNativeChatScopeKey(hostId, worktreeId, activeSessionTabId),
reportedModel,
dispatchCommand,
onAgentPicker: handleAgentPicker
})
const structuredController = useMemo<MobileNativeChatSessionOptionsController | null>(
() =>
activeChatStructured && structuredSnapshot.length > 0
? {
snapshot: structuredSnapshot,
pendingId: structuredPendingId,
setOption: setStructuredOption,
invokeAction: invokeStructuredAction,
recordCommand: () => {}
}
: null,
[
activeChatStructured,
invokeStructuredAction,
setStructuredOption,
structuredPendingId,
structuredSnapshot
]
)
const nativeChatSessionOptions = useMemo<MobileNativeChatSessionOptionPickersProps | null>(
() =>
activeChatStructured
? structuredController
? { controller: structuredController, isWorking }
: null
: sessionOptions.snapshot.length > 0
? { controller: sessionOptions, isWorking }
: null,
[activeChatStructured, isWorking, sessionOptions, structuredController]
)
return { nativeChatSessionOptions, recordCommand: sessionOptions.recordCommand }
}
@@ -36,7 +36,8 @@ export function useMobileSessionAttachments(scope: MobileSessionAccessorySelecti
nativeChatInputLeaseReady,
nativeChatController,
getActiveWorktreeConnectionId,
refreshCanPaste
refreshCanPaste,
activeSessionTab
} = scope
const handlePaste = useMobileTerminalPaste({
client,
@@ -80,6 +81,7 @@ export function useMobileSessionAttachments(scope: MobileSessionAccessorySelecti
getActiveWorktreeConnectionId,
beforeTerminalSend: flushPendingLiveInputBeforeAttachmentSend,
nativeChatBaseSend: nativeChatController.handleNativeChatSendWithOutcome,
structuredNativeChat: activeSessionTab?.type === 'agent-session',
readSeededLaunchDraft: nativeChatController.readSeededLaunchDraft,
showToast,
onNativeChatSendError: nativeChatSendError.show,
@@ -1,6 +1,7 @@
import { useRef, useCallback } from 'react'
import { Linking } from 'react-native'
import { useMobileFileTapHandlers } from './use-mobile-file-tap-handlers'
import { resolveMobileNativeChatFileSessionId } from './mobile-native-chat-eligibility'
import { activateOpenedSourceControlDiffTab } from './opened-mobile-session-tab'
import type { MobileSessionTab } from './mobile-session-route-types'
import type { MobileSessionTerminalSendActionsModel } from './use-mobile-session-terminal-send-actions'
@@ -31,10 +32,7 @@ export function useMobileSessionFileActions(scope: MobileSessionTerminalSendActi
hostId,
worktreeId,
worktreeName: routeWorktreeName,
nativeChatSessionId:
activeSessionTab?.type === 'terminal'
? (activeSessionTab.agentStatus?.providerSession?.id ?? null)
: null,
nativeChatSessionId: resolveMobileNativeChatFileSessionId(activeSessionTab),
activeHandleRef,
terminalCwdRef,
openBrowser: (url) => void handleCreateBrowserRef.current?.(url),
@@ -0,0 +1,123 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { useMobileSessionImageAttachments } from './use-mobile-session-image-attachments'
const mocks = vi.hoisted(() => ({
useMobileImageAttachment: vi.fn(),
useMobileNativeChatImageAttachments: vi.fn()
}))
vi.mock('./use-mobile-image-attachment', () => ({
useMobileImageAttachment: mocks.useMobileImageAttachment
}))
vi.mock('./use-mobile-native-chat-image-attachments', () => ({
useMobileNativeChatImageAttachments: mocks.useMobileNativeChatImageAttachments
}))
type HookArgs = Parameters<typeof useMobileSessionImageAttachments>[0]
function baseArgs(overrides: Partial<HookArgs> = {}): HookArgs {
return {
client: {} as RpcClient,
activeHandle: 'term-1',
activeHandleRef: { current: null },
canSend: true,
connState: 'connected',
deviceTokenRef: { current: null },
nativeChatScopeKey: 'scope-1',
nativeChatInputLeaseReady: false,
getActiveWorktreeConnectionId: async () => 'conn-1',
beforeTerminalSend: async () => true,
nativeChatBaseSend: vi.fn().mockResolvedValue('accepted'),
structuredNativeChat: true,
readSeededLaunchDraft: () => null,
showToast: vi.fn(),
onNativeChatSendError: vi.fn(),
onSuccess: vi.fn(),
onError: vi.fn(),
...overrides
}
}
describe('useMobileSessionImageAttachments', () => {
let renderer: ReactTestRenderer | null = null
function Harness({ args }: { args: HookArgs }): null {
useMobileSessionImageAttachments(args)
return null
}
beforeEach(() => {
mocks.useMobileImageAttachment.mockReturnValue({
attachImage: vi.fn(),
isAttaching: false
})
mocks.useMobileNativeChatImageAttachments.mockReturnValue({
attachments: [],
isAttaching: false,
attachImage: vi.fn(),
removeAttachment: vi.fn(),
sendNativeChat: vi.fn()
})
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
vi.clearAllMocks()
})
function render(args: HookArgs): void {
act(() => {
renderer = create(createElement(Harness, { args }))
})
}
it('enables native-chat image sends for connected structured sessions without a terminal lease', () => {
render(baseArgs())
expect(mocks.useMobileNativeChatImageAttachments).toHaveBeenCalledWith(
expect.objectContaining({
enabled: true,
structuredNativeChat: true
})
)
})
it('keeps terminal-backed native-chat image sends gated on the input lease', () => {
render(
baseArgs({
activeHandleRef: { current: 'term-1' },
nativeChatInputLeaseReady: false,
structuredNativeChat: false
})
)
expect(mocks.useMobileNativeChatImageAttachments).toHaveBeenCalledWith(
expect.objectContaining({
enabled: false,
structuredNativeChat: false
})
)
})
it('disables structured native-chat image sends while disconnected', () => {
render(
baseArgs({
connState: 'connecting',
nativeChatInputLeaseReady: true,
structuredNativeChat: true
})
)
expect(mocks.useMobileNativeChatImageAttachments).toHaveBeenCalledWith(
expect.objectContaining({
enabled: false,
structuredNativeChat: true
})
)
})
})
@@ -29,8 +29,15 @@ type Args = {
readonly nativeChatBaseSend: (
text: string,
images?: string[],
deadline?: number
deadline?: number,
attachments?: readonly {
id: string
path: string
previewUri: string
}[]
) => Promise<MobileNativeChatSendOutcome>
/** Structured agent sessions do not have a terminal paste path. */
readonly structuredNativeChat: boolean
/** Launch-context text parked on the agent's TUI input line, or null — sizes
* the image paste's leading clear so a multi-line draft cannot ride along. */
readonly readSeededLaunchDraft: () => string | null
@@ -57,6 +64,7 @@ export function useMobileSessionImageAttachments({
getActiveWorktreeConnectionId,
beforeTerminalSend,
nativeChatBaseSend,
structuredNativeChat,
readSeededLaunchDraft,
showToast,
onNativeChatSendError,
@@ -86,7 +94,8 @@ export function useMobileSessionImageAttachments({
getActiveWorktreeConnectionId,
connState,
scopeKey: nativeChatScopeKey,
enabled: nativeChatInputLeaseReady,
enabled: structuredNativeChat ? connState === 'connected' : nativeChatInputLeaseReady,
structuredNativeChat,
showToast,
onSendError: onNativeChatSendError,
baseSend: nativeChatBaseSend,
@@ -77,6 +77,12 @@ export function useMobileSessionNativeChatDictation(
})
const { toggleTabChatView, showNativeChat, showNativeChatRef } = nativeChatController
nativeChatSendError.bannerMountedRef.current = showNativeChat
const nativeChatOverlayInputLockReason =
activeSessionTab?.type === 'agent-session'
? connState === 'connected'
? null
: 'disconnected'
: nativeChatInputLockReason
const routeKey = nativeChatScopeKey ?? `${hostId}\0${worktreeId}`
const getSendCompletionGeneration = useMobileSendCompletionGeneration({
onBlur: resetLiveInputFocus,
@@ -211,6 +217,7 @@ export function useMobileSessionNativeChatDictation(
nativeChatInputLeaseReady,
nativeChatInputLeaseReadyRef,
nativeChatInputLockReason,
nativeChatOverlayInputLockReason,
markNativeChatInputLeaseReady,
clearNativeChatInputLease,
nativeChatController,
@@ -23,6 +23,7 @@ import type {
MobileSessionTab,
Terminal
} from './mobile-session-route-types'
import { useMobileSessionTabActionTargets } from './use-mobile-session-tab-action-targets'
import type { MobileSessionFoundationModel } from './use-mobile-session-foundation'
export function useMobileSessionScreenState(scope: MobileSessionFoundationModel) {
@@ -90,19 +91,7 @@ export function useMobileSessionScreenState(scope: MobileSessionFoundationModel)
const [createTabAgentOptions, setCreateTabAgentOptions] = useState<MobileNewTabAgentOption[]>([])
const [showCreateBrowserModal, setShowCreateBrowserModal] = useState(false)
const [showHeaderMoreActions, setShowHeaderMoreActions] = useState(false)
const [actionTarget, setActionTarget] = useState<Terminal | null>(null)
const [markdownActionTarget, setMarkdownActionTarget] = useState<Extract<
MobileSessionTab,
{ type: 'markdown' }
> | null>(null)
const [fileActionTarget, setFileActionTarget] = useState<Extract<
MobileSessionTab,
{ type: 'file' }
> | null>(null)
const [browserActionTarget, setBrowserActionTarget] = useState<Extract<
MobileSessionTab,
{ type: 'browser' }
> | null>(null)
const sessionTabActionTargets = useMobileSessionTabActionTargets()
const [discardMarkdownTarget, setDiscardMarkdownTarget] = useState<Extract<
MobileSessionTab,
{ type: 'markdown' }
@@ -211,14 +200,7 @@ export function useMobileSessionScreenState(scope: MobileSessionFoundationModel)
setShowCreateBrowserModal,
showHeaderMoreActions,
setShowHeaderMoreActions,
actionTarget,
setActionTarget,
markdownActionTarget,
setMarkdownActionTarget,
fileActionTarget,
setFileActionTarget,
browserActionTarget,
setBrowserActionTarget,
...sessionTabActionTargets,
discardMarkdownTarget,
setDiscardMarkdownTarget,
leaveDrafts,
@@ -0,0 +1,85 @@
import {
useCallback,
useState,
type Dispatch,
type MutableRefObject,
type SetStateAction
} from 'react'
import type { MobileSessionTab, Terminal } from './mobile-session-route-types'
type MarkdownTab = Extract<MobileSessionTab, { type: 'markdown' }>
type FileTab = Extract<MobileSessionTab, { type: 'file' }>
type BrowserTab = Extract<MobileSessionTab, { type: 'browser' }>
type AgentSessionTab = Extract<MobileSessionTab, { type: 'agent-session' }>
type SetActionTarget<T> = Dispatch<SetStateAction<T | null>>
export function useMobileSessionTabActionTargets() {
const [actionTarget, setActionTarget] = useState<Terminal | null>(null)
const [markdownActionTarget, setMarkdownActionTarget] = useState<MarkdownTab | null>(null)
const [fileActionTarget, setFileActionTarget] = useState<FileTab | null>(null)
const [browserActionTarget, setBrowserActionTarget] = useState<BrowserTab | null>(null)
const [agentSessionActionTarget, setAgentSessionActionTarget] = useState<AgentSessionTab | null>(
null
)
return {
actionTarget,
agentSessionActionTarget,
browserActionTarget,
fileActionTarget,
markdownActionTarget,
setActionTarget,
setAgentSessionActionTarget,
setBrowserActionTarget,
setFileActionTarget,
setMarkdownActionTarget
}
}
export function useMobileSessionTabActionSheetOpener(args: {
activeHandleRef: MutableRefObject<string | null>
setActionTarget: SetActionTarget<Terminal>
setMarkdownActionTarget: SetActionTarget<MarkdownTab>
setFileActionTarget: SetActionTarget<FileTab>
setBrowserActionTarget: SetActionTarget<BrowserTab>
setAgentSessionActionTarget: SetActionTarget<AgentSessionTab>
}): (tab: MobileSessionTab) => void {
const {
activeHandleRef,
setActionTarget,
setAgentSessionActionTarget,
setBrowserActionTarget,
setFileActionTarget,
setMarkdownActionTarget
} = args
return useCallback(
(tab: MobileSessionTab) => {
if (tab.type === 'terminal') {
if (typeof tab.terminal !== 'string') {
return
}
setActionTarget({
handle: tab.terminal,
title: tab.title,
isActive: tab.terminal === activeHandleRef.current
})
} else if (tab.type === 'markdown') {
setMarkdownActionTarget(tab)
} else if (tab.type === 'file') {
setFileActionTarget(tab)
} else if (tab.type === 'agent-session') {
setAgentSessionActionTarget(tab)
} else {
setBrowserActionTarget(tab)
}
},
[
activeHandleRef,
setActionTarget,
setAgentSessionActionTarget,
setBrowserActionTarget,
setFileActionTarget,
setMarkdownActionTarget
]
)
}
@@ -136,6 +136,9 @@ export function useMobileSessionTabSwitching(scope: MobileSessionKeyboardStateMo
void readFileTab(tab)
return
}
if (tab.type === 'agent-session') {
return
}
const cached = markdownDocs.get(tab.id)
if (cached?.status === 'ready' && cached.isDirty) {
return
@@ -0,0 +1,231 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
import { useMobileSessionTerminalCreateActions } from './use-mobile-session-terminal-create-actions'
vi.mock('../platform/haptics', () => ({
triggerSuccess: vi.fn(),
triggerError: vi.fn()
}))
function clientReturning(...responses: unknown[]): RpcClient {
let responseIndex = 0
return {
sendRequest: vi.fn(async () => responses[responseIndex++])
} as unknown as RpcClient
}
function terminalCreateResponse() {
return {
ok: true,
result: {
tab: {
type: 'terminal',
id: 'terminal-tab-1',
title: 'Codex',
terminal: 'terminal-1',
isActive: true
}
}
}
}
function createScope(client: RpcClient) {
return {
worktreeId: 'workspace-1',
client,
connState: 'connected',
setTerminals: vi.fn(),
terminalsRef: { current: [] },
setSessionTabs: vi.fn(),
defaultTerminalHandlesToLiveInput: vi.fn(),
setActiveHandle: vi.fn(),
activeSessionTabId: 'existing-tab',
activeSessionTabIdRef: { current: 'existing-tab' },
setActiveSessionTabId: vi.fn(),
setCreating: vi.fn(),
creatingTerminalRef: { current: false },
creatingBrowser: false,
creatingMarkdown: false,
setCreateError: vi.fn(),
deviceTokenRef: { current: null },
initializedHandlesRef: { current: new Set<string>() },
activeHandleRef: { current: 'existing-terminal' },
activeSessionTabTypeRef: { current: 'terminal' },
pendingActiveSessionTabIdRef: { current: null },
pendingActiveTerminalHandleRef: { current: null },
scheduleDelayedAction: vi.fn(),
showToast: vi.fn(),
unsubscribeTerminal: vi.fn(),
subscribeToTerminal: vi.fn(),
fetchSessionTabs: vi.fn(async () => {})
}
}
describe('mobile + Codex tab creation routing', () => {
let renderer: ReactTestRenderer | undefined
afterEach(() => renderer?.unmount())
it('uses the structured agent-session path for a bare Codex launch', async () => {
const client = clientReturning(
{ ok: true, result: { supported: true } },
{
ok: true,
result: {
ok: true,
value: { sessionId: 'codex_session_1' }
}
}
)
const scope = createScope(client)
let actions: ReturnType<typeof useMobileSessionTerminalCreateActions> | undefined
function Harness() {
actions = useMobileSessionTerminalCreateActions(scope as never)
return null
}
await act(async () => {
renderer = create(createElement(Harness))
})
await act(async () => {
await actions?.handleCreateTerminal('codex')
})
expect(client.sendRequest).toHaveBeenNthCalledWith(1, 'agentSession.createSupport', {
worktree: 'id:workspace-1',
agent: 'codex'
})
expect(client.sendRequest).toHaveBeenNthCalledWith(
2,
'agentSession.create',
expect.objectContaining({ worktree: 'id:workspace-1', agent: 'codex' }),
expect.anything()
)
expect(client.sendRequest).not.toHaveBeenCalledWith(
'session.tabs.createTerminal',
expect.anything()
)
expect(scope.setActiveSessionTabId).toHaveBeenCalledWith('agent-session:codex_session_1')
expect(scope.setActiveHandle).toHaveBeenCalledWith(null)
expect(scope.unsubscribeTerminal).toHaveBeenCalledWith('existing-terminal')
})
it('keeps the legacy terminal path when structured support is disabled', async () => {
const client = clientReturning(
{ ok: false, error: { code: 'structured_agent_session_unsupported', message: 'off' } },
terminalCreateResponse()
)
const scope = createScope(client)
let actions: ReturnType<typeof useMobileSessionTerminalCreateActions> | undefined
function Harness() {
actions = useMobileSessionTerminalCreateActions(scope as never)
return null
}
await act(async () => {
renderer = create(createElement(Harness))
})
await act(async () => {
await actions?.handleCreateTerminal('codex')
})
expect(client.sendRequest).toHaveBeenNthCalledWith(
2,
'session.tabs.createTerminal',
expect.objectContaining({ worktree: 'id:workspace-1', agent: 'codex' })
)
expect(scope.setActiveSessionTabId).toHaveBeenCalledWith('terminal-tab-1')
})
it('falls back to a terminal when structured creation is refused', async () => {
const client = clientReturning(
{ ok: true, result: { supported: true } },
{
ok: true,
result: {
ok: false,
refusal: { code: 'agent_session_ownership_unknown', message: 'provider unavailable' }
}
},
terminalCreateResponse()
)
const scope = createScope(client)
let actions: ReturnType<typeof useMobileSessionTerminalCreateActions> | undefined
function Harness() {
actions = useMobileSessionTerminalCreateActions(scope as never)
return null
}
await act(async () => {
renderer = create(createElement(Harness))
})
await act(async () => {
await actions?.handleCreateTerminal('codex')
})
expect(client.sendRequest).toHaveBeenNthCalledWith(
3,
'session.tabs.createTerminal',
expect.objectContaining({ worktree: 'id:workspace-1', agent: 'codex' })
)
expect(scope.setActiveSessionTabId).toHaveBeenCalledWith('terminal-tab-1')
})
it('keeps prompted Codex launches on the legacy terminal path', async () => {
const client = clientReturning(terminalCreateResponse(), {
ok: true,
result: { send: { accepted: true } }
})
const scope = createScope(client)
let actions: ReturnType<typeof useMobileSessionTerminalCreateActions> | undefined
function Harness() {
actions = useMobileSessionTerminalCreateActions(scope as never)
return null
}
await act(async () => {
renderer = create(createElement(Harness))
})
await act(async () => {
await actions?.handleCreateTerminal('codex', { initialPrompt: 'Inspect this diff' })
})
expect(client.sendRequest).toHaveBeenCalledWith(
'session.tabs.createTerminal',
expect.objectContaining({ agent: 'codex' })
)
expect(client.sendRequest).not.toHaveBeenCalledWith(
'agentSession.createSupport',
expect.anything()
)
})
it('does not create a legacy sibling after an unknown structured outcome', async () => {
const client = clientReturning({ ok: true, result: { supported: true } })
const sendRequest = client.sendRequest as unknown as ReturnType<typeof vi.fn>
sendRequest.mockImplementationOnce(async () => ({
ok: true,
result: { supported: true }
}))
sendRequest.mockRejectedValueOnce(markRpcDeliveryUnknown(new Error('response lost')))
sendRequest.mockRejectedValueOnce(markRpcDeliveryUnknown(new Error('still unknown')))
const scope = createScope(client)
let actions: ReturnType<typeof useMobileSessionTerminalCreateActions> | undefined
function Harness() {
actions = useMobileSessionTerminalCreateActions(scope as never)
return null
}
await act(async () => {
renderer = create(createElement(Harness))
})
await act(async () => {
await actions?.handleCreateTerminal('codex')
})
expect(sendRequest.mock.calls.map(([method]) => method)).toEqual([
'agentSession.createSupport',
'agentSession.create',
'agentSession.create'
])
expect(scope.setCreateError).toHaveBeenCalledWith('still unknown')
expect(scope.showToast).toHaveBeenCalledWith('still unknown', 1800)
})
})
@@ -10,6 +10,7 @@ import type { MobileNewTabAgentOption } from './mobile-new-tab-agent-options'
import type { TerminalQuickCommand } from '../../../src/shared/terminal-quick-command-types'
import type { Terminal, TerminalCreateResult } from './mobile-session-route-types'
import type { MobileSessionAttachmentsModel } from './use-mobile-session-attachments'
import { createMobileStructuredCodexSession } from './mobile-structured-agent-session-launch'
export function useMobileSessionTerminalCreateActions(scope: MobileSessionAttachmentsModel) {
const {
@@ -22,6 +23,7 @@ export function useMobileSessionTerminalCreateActions(scope: MobileSessionAttach
defaultTerminalHandlesToLiveInput,
setActiveHandle,
activeSessionTabId,
activeSessionTabIdRef,
setActiveSessionTabId,
setCreating,
creatingTerminalRef,
@@ -61,6 +63,35 @@ export function useMobileSessionTerminalCreateActions(scope: MobileSessionAttach
.slice(2, 10)}`
try {
// Bare Codex launches follow structured support; prompted launches keep their startup semantics.
if (agent === 'codex' && options === undefined) {
const structured = await createMobileStructuredCodexSession(client, worktreeId)
if (structured.kind === 'created') {
const previous = activeHandleRef.current
if (previous) {
unsubscribeTerminal(previous)
initializedHandlesRef.current.delete(previous)
}
const tabId = `agent-session:${structured.sessionId}`
pendingActiveSessionTabIdRef.current = tabId
pendingActiveTerminalHandleRef.current = null
activeSessionTabTypeRef.current = 'agent-session'
activeSessionTabIdRef.current = tabId
setActiveSessionTabId(tabId)
activeHandleRef.current = null
setActiveHandle(null)
// Refresh if the create response beats its published tab frame.
scheduleDelayedAction(() => void fetchSessionTabs(), 500)
return
}
if (structured.kind === 'unknown') {
// Never create a legacy sibling when the host may already have committed.
setCreateError(structured.message)
triggerError()
showToast(structured.message, 1800)
return
}
}
const response = await client.sendRequest('session.tabs.createTerminal', {
worktree: `id:${worktreeId}`,
afterTabId: activeSessionTabId ?? undefined,
@@ -16,6 +16,7 @@ import {
import { normalizeTerminalTextInput } from '../terminal/terminal-text-input-normalization'
import { useAgentSendKeyboardDismissal } from './use-agent-send-keyboard-dismissal'
import type { MobileSessionTab } from './mobile-session-route-types'
import { useMobileSessionTabActionSheetOpener } from './use-mobile-session-tab-action-targets'
import type { MobileSessionTerminalWebviewModel } from './use-mobile-session-terminal-webview'
export function useMobileSessionTerminalSendActions(scope: MobileSessionTerminalWebviewModel) {
@@ -27,6 +28,7 @@ export function useMobileSessionTerminalSendActions(scope: MobileSessionTerminal
setMarkdownActionTarget,
setFileActionTarget,
setBrowserActionTarget,
setAgentSessionActionTarget,
keyboardHeight,
deviceTokenRef,
clientRef,
@@ -175,24 +177,14 @@ export function useMobileSessionTerminalSendActions(scope: MobileSessionTerminal
sessionTabActionSheetKeyboardHideSubRef.current = null
}, [])
const openSessionTabActionSheet = useCallback((tab: MobileSessionTab) => {
if (tab.type === 'terminal') {
if (typeof tab.terminal !== 'string') {
return
}
setActionTarget({
handle: tab.terminal,
title: tab.title,
isActive: tab.terminal === activeHandleRef.current
})
} else if (tab.type === 'markdown') {
setMarkdownActionTarget(tab)
} else if (tab.type === 'file') {
setFileActionTarget(tab)
} else {
setBrowserActionTarget(tab)
}
}, [])
const openSessionTabActionSheet = useMobileSessionTabActionSheetOpener({
activeHandleRef,
setActionTarget,
setMarkdownActionTarget,
setFileActionTarget,
setBrowserActionTarget,
setAgentSessionActionTarget
})
const openSessionTabActionSheetAfterKeyboardDismiss = useCallback(
(tab: MobileSessionTab) => {
@@ -0,0 +1,161 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { getAgentSessionOptionCatalog } from '../../../src/shared/agent-session-option-catalog'
import type {
AgentSessionOptionResult,
AgentSessionOptionsResult
} from '../../../src/shared/agent-session-wire'
import type {
SessionOptionDescriptor,
SessionOptionsSurface,
SessionOptionValue
} from '../../../src/shared/native-chat-session-options'
import {
applyStructuredAgentSessionOptions,
canSetStructuredAgentSessionOption,
commitStructuredAgentSessionOption,
commitStructuredAgentSessionOptionValues,
createStructuredAgentSessionOptionState,
structuredAgentSessionOptionSnapshot
} from '../../../src/shared/structured-agent-session-options'
import type { RpcClient } from '../transport/rpc-client'
import {
callAgentSession,
type StructuredAgentSessionMutate
} from './mobile-structured-agent-session-rpc'
type StructuredOptionsController = {
optionSnapshot: SessionOptionDescriptor[]
optionSurface: SessionOptionsSurface
pendingOptionId: string | null
setStructuredOption: (id: string, value: SessionOptionValue) => Promise<boolean>
invokeStructuredOption: (id: string) => Promise<boolean>
}
export function useMobileStructuredAgentOptions(args: {
agent: string | null
client: RpcClient | null
sessionId: string | null
enabled: boolean
fence: number | null
mutate: StructuredAgentSessionMutate
}): StructuredOptionsController {
const { agent, client, enabled, fence, mutate, sessionId } = args
const [optionState, setOptionState] = useState(() =>
createStructuredAgentSessionOptionState(agent ?? 'codex')
)
const activeOptionRecordRef = useRef(optionState.record)
const optionCatalog = useMemo(
() => (agent === 'claude' || agent === 'codex' ? getAgentSessionOptionCatalog(agent) : null),
[agent]
)
useEffect(() => {
const next = createStructuredAgentSessionOptionState(agent ?? 'codex')
activeOptionRecordRef.current = next.record
setOptionState(next)
}, [agent, enabled, fence, sessionId])
useEffect(() => {
if (!client || !sessionId || !enabled || !optionCatalog) {
return
}
let stale = false
void callAgentSession<AgentSessionOptionsResult>(client, 'agentSession.options', { sessionId })
.then((result) => {
if (!stale) {
setOptionState((current) =>
current.record === activeOptionRecordRef.current
? applyStructuredAgentSessionOptions(current, optionCatalog, result)
: current
)
}
})
.catch(() => undefined)
return () => {
stale = true
}
}, [client, enabled, optionCatalog, sessionId, fence])
const optionSnapshot = useMemo(
() => structuredAgentSessionOptionSnapshot(optionState),
[optionState]
)
const setStructuredOption = useCallback(
async (id: string, value: SessionOptionValue): Promise<boolean> => {
if (
!canSetStructuredAgentSessionOption(optionState, id, value) ||
typeof value !== 'string'
) {
return false
}
const targetRecord = optionState.record
setOptionState((current) => ({ ...current, pendingId: id }))
try {
const result = await mutate<AgentSessionOptionResult>(
'agentSession.setOption',
'agentSession.setOption',
{ key: id, value }
)
if (activeOptionRecordRef.current !== targetRecord) {
return result.status !== 'rejected'
}
if (result.status === 'accepted') {
setOptionState((current) =>
current.record === targetRecord && result.sameFence
? commitStructuredAgentSessionOptionValues(
current,
result.value.options ?? { [id]: value }
)
: current
)
return true
}
if (result.status === 'unknown') {
setOptionState((current) =>
current.record === targetRecord
? commitStructuredAgentSessionOption(current, id, value)
: current
)
return true
}
return false
} finally {
setOptionState((current) =>
current.record === targetRecord && current.pendingId === id
? { ...current, pendingId: null }
: current
)
}
},
[mutate, optionState]
)
const invokeStructuredOption = useCallback(async () => false, [])
const setOption = useCallback(
async (id: string, value: SessionOptionValue) => {
await setStructuredOption(id, value)
return { snapshot: optionSnapshot }
},
[optionSnapshot, setStructuredOption]
)
const optionSurface = useMemo<SessionOptionsSurface>(
() => ({
getSnapshot: () => optionSnapshot,
setOption,
invokeAction: async () => ({ snapshot: optionSnapshot }),
subscribe: () => () => {}
}),
[optionSnapshot, setOption]
)
return {
optionSnapshot,
optionSurface,
pendingOptionId: optionState.pendingId,
setStructuredOption,
invokeStructuredOption
}
}
@@ -0,0 +1,751 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type {
AgentJournalRenderItem,
AgentJournalResolution
} from '../../../src/shared/agent-session-journal-types'
import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire'
import type { RpcClient } from '../transport/rpc-client'
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
import { formatQuestionFreeTextAnswer } from './mobile-native-chat-question'
import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session'
function ok(result: unknown) {
return { ok: true, result, _meta: { runtimeId: 'runtime-1' } }
}
function snapshotEvent(fence = 3): AgentSessionSubscribeEvent {
return {
type: 'snapshot',
sessionId: 'session-1',
fence,
page: {
sessionId: 'session-1',
epoch: 'epoch-1',
fence,
direction: 'tail',
items: [],
removedItemIds: [],
submissions: [],
window: {
oldest: null,
newest: null,
nextCursor: { epoch: 'epoch-1', sequence: 0 }
},
liveCursor: { epoch: 'epoch-1', sequence: 0 },
hasOlder: false,
hasNewer: false
}
}
}
function pendingResolution(): AgentJournalResolution {
return {
state: 'pending',
selectedOptionId: null,
resolvedBy: null,
resolvedAt: null
}
}
function approvalItem(): AgentJournalRenderItem {
return {
itemId: 'approval-1',
revision: 2,
sequence: 1,
observedAt: 10,
body: {
kind: 'approval',
title: 'Allow Bash?',
detail: 'rm -rf build',
options: [
{ id: 'allow-once', label: 'Allow once' },
{ id: 'deny', label: 'Deny' }
],
resolution: pendingResolution()
}
}
}
function approvalItemWithIdentity(itemId: string, revision: number): AgentJournalRenderItem {
return { ...approvalItem(), itemId, revision }
}
function questionItem(): AgentJournalRenderItem {
return {
itemId: 'question-1',
revision: 7,
sequence: 2,
observedAt: 12,
body: {
kind: 'question',
question: 'Pick destination',
freeTextQuestionId: 'free-q',
options: [
{ id: 'choice-a', label: 'Choice A' },
{ id: 'choice-b', label: 'Choice B' }
],
resolution: pendingResolution()
}
}
}
function questionItemWithIdentity(itemId: string, revision: number): AgentJournalRenderItem {
return { ...questionItem(), itemId, revision }
}
function runningStatusItem(): AgentJournalRenderItem {
return {
itemId: 'status-1',
revision: 1,
sequence: 3,
observedAt: 14,
body: {
kind: 'status',
text: 'Working',
turnLifecycle: { turnId: 'turn-1', state: 'running' }
}
}
}
function defaultSendRequest(method: string, params?: Record<string, unknown>) {
if (method === 'agentSession.send') {
return ok({
ok: true,
replayed: false,
fence: 3,
cursor: { epoch: 'epoch-1', sequence: 1 },
value: { turnId: 'turn-1' }
})
}
if (method === 'agentSession.options') {
return ok({
models: [
{
id: 'gpt-fast',
label: 'GPT Fast',
isDefault: true,
defaultEffort: 'low',
efforts: [
{ value: 'low', label: 'Low' },
{ value: 'high', label: 'High' }
]
},
{
id: 'gpt-slow',
label: 'GPT Slow',
isDefault: false,
defaultEffort: 'high',
efforts: [
{ value: 'low', label: 'Low' },
{ value: 'high', label: 'High' }
]
}
],
current: {
model: 'gpt-fast',
effort: 'low'
}
})
}
if (method === 'agentSession.setOption') {
return ok({
ok: true,
replayed: false,
fence: 3,
cursor: { epoch: 'epoch-1', sequence: 2 },
value: {
key: 'model',
value: 'gpt-fast',
options: { model: 'gpt-fast' }
}
})
}
if (method === 'agentSession.respondToApproval' || method === 'agentSession.respondToQuestion') {
return ok({
ok: true,
replayed: false,
fence: 3,
cursor: { epoch: 'epoch-1', sequence: 3 },
value: {
itemId: String(params?.itemId ?? ''),
revision: 2,
resolution: {
state: 'resolved',
selectedOptionId: String(params?.optionId ?? ''),
resolvedBy: 'mobile',
resolvedAt: 123
}
}
})
}
return ok({})
}
describe('useMobileStructuredAgentSession', () => {
let renderer: ReactTestRenderer | null = null
let hook: ReturnType<typeof useMobileStructuredAgentSession> | null = null
let listener: ((value: unknown) => void) | null = null
const onSendError = vi.fn()
const unsubscribe = vi.fn()
const sendRequest = vi.fn(defaultSendRequest)
const subscribe = vi.fn((_method: string, _params: unknown, onData: (value: unknown) => void) => {
listener = onData
return unsubscribe
})
const client = {
sendRequest,
subscribe
} as unknown as RpcClient
function Harness({
sessionId = 'session-1',
agent = 'codex',
connected = true
}: {
sessionId?: string | null
agent?: string | null
connected?: boolean
}): null {
hook = useMobileStructuredAgentSession({
client,
sessionId,
enabled: true,
connected,
agent,
onSendError
} as never)
return null
}
beforeEach(() => {
vi.clearAllMocks()
sendRequest.mockImplementation(defaultSendRequest)
listener = null
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
hook = null
})
it('subscribes and holds structured sessions without nativeChat or terminal RPCs', async () => {
act(() => {
renderer = create(createElement(Harness))
})
await vi.waitFor(() =>
expect(subscribe).toHaveBeenCalledWith(
'agentSession.subscribe',
{ sessionId: 'session-1' },
expect.any(Function)
)
)
await vi.waitFor(() =>
expect(sendRequest).toHaveBeenCalledWith(
'agentSession.hold',
expect.objectContaining({ sessionId: 'session-1', holderId: expect.any(String) }),
expect.any(Object)
)
)
expect(sendRequest).not.toHaveBeenCalledWith(
expect.stringMatching(/^(nativeChat|terminal)\./),
expect.anything(),
expect.anything()
)
})
it('re-holds after a reconnect that outlives the host release grace', async () => {
act(() => {
renderer = create(createElement(Harness, { connected: true }))
})
await vi.waitFor(() =>
expect(
sendRequest.mock.calls.filter(([method]) => method === 'agentSession.hold')
).toHaveLength(1)
)
await vi.waitFor(() => expect(subscribe).toHaveBeenCalledTimes(1))
// A transport loss retires the connection-scoped hold; after the host's 15s grace
// it may evict the provider child. Reconnect must acquire before replaying the stream.
await act(async () => {
renderer?.update(createElement(Harness, { connected: false }))
})
expect(unsubscribe).toHaveBeenCalledTimes(1)
await act(async () => {
renderer?.update(createElement(Harness, { connected: true }))
})
await vi.waitFor(() =>
expect(
sendRequest.mock.calls.filter(([method]) => method === 'agentSession.hold')
).toHaveLength(2)
)
await vi.waitFor(() => expect(subscribe).toHaveBeenCalledTimes(2))
const holdOrders = sendRequest.mock.calls
.map((call, index) =>
call[0] === 'agentSession.hold' ? sendRequest.mock.invocationCallOrder[index] : null
)
.filter((order): order is number => order !== null)
const subscribeOrders = subscribe.mock.invocationCallOrder
const secondHoldOrder = holdOrders[1]
const secondSubscribeOrder = subscribeOrders[1]
if (secondHoldOrder === undefined || secondSubscribeOrder === undefined) {
throw new Error('reconnect calls were not recorded')
}
expect(secondHoldOrder).toBeLessThan(secondSubscribeOrder)
})
it('sends with the shared structured mutation envelope after the stream fence lands', async () => {
act(() => {
renderer = create(createElement(Harness))
})
await vi.waitFor(() => expect(listener).toEqual(expect.any(Function)))
act(() => listener?.(snapshotEvent()))
let outcome: 'accepted' | 'unknown' | 'rejected' = 'rejected'
await act(async () => {
outcome = await hook!.sendWithOutcome('hello')
})
expect(outcome).toBe('accepted')
expect(sendRequest).toHaveBeenCalledWith(
'agentSession.send',
expect.objectContaining({
envelope: expect.objectContaining({
sessionId: 'session-1',
expectedRuntimeFence: 3,
clientOperationId: expect.stringMatching(/^\d{13}-[0-9a-f]{32}$/),
payloadFingerprint: expect.any(String)
}),
body: {
kind: 'message',
role: 'user',
blocks: [{ type: 'text', text: 'hello' }]
}
}),
expect.any(Object)
)
})
it('surfaces structured prompt cards and option snapshots', async () => {
act(() => {
renderer = create(createElement(Harness))
})
await vi.waitFor(() => expect(listener).toEqual(expect.any(Function)))
act(() => listener?.(snapshotEvent(3)))
act(() => listener?.(snapshotEvent(3)))
act(() =>
listener?.({
...snapshotEvent(3),
page: {
...snapshotEvent(3).page,
items: [approvalItem(), questionItem()]
}
})
)
if (!hook) {
throw new Error('hook not ready')
}
await vi.waitFor(() => expect(hook.permission).not.toBeNull())
await vi.waitFor(() => expect(hook.question).not.toBeNull())
await vi.waitFor(() => expect(hook.optionSnapshot.length).toBeGreaterThan(0))
expect(hook.permission).toMatchObject({
title: 'Allow Bash?',
detail: 'rm -rf build',
options: [
{ label: 'Allow once', send: expect.any(String) },
{ label: 'Deny', send: expect.any(String) }
]
})
expect(hook.question).toMatchObject({
question: 'Pick destination',
allowOther: true,
optionTokens: [expect.any(String), expect.any(String)],
freeTextToken: expect.any(String)
})
expect(hook.optionSurface.getSnapshot()).toEqual(hook.optionSnapshot)
await act(async () => {
expect(await hook.setStructuredOption('model', 'gpt-fast')).toBe(true)
})
expect(sendRequest).toHaveBeenCalledWith(
'agentSession.setOption',
expect.objectContaining({
envelope: expect.objectContaining({
sessionId: 'session-1',
expectedRuntimeFence: 3,
clientOperationId: expect.any(String),
payloadFingerprint: expect.any(String)
}),
key: 'model',
value: 'gpt-fast'
}),
expect.any(Object)
)
await act(async () => {
expect(await hook.respondPermission(hook.permission!.options[0]!.send)).toBe(true)
})
expect(sendRequest).toHaveBeenCalledWith(
'agentSession.respondToApproval',
expect.objectContaining({
envelope: expect.objectContaining({
sessionId: 'session-1',
expectedRuntimeFence: 3
}),
itemId: 'approval-1',
optionId: 'allow-once'
}),
expect.any(Object)
)
await act(async () => {
expect(
await hook.respondQuestion(formatQuestionFreeTextAnswer(hook.question!, 'custom answer'))
).toBe(true)
})
expect(sendRequest).toHaveBeenCalledWith(
'agentSession.respondToQuestion',
expect.objectContaining({
envelope: expect.objectContaining({
sessionId: 'session-1',
expectedRuntimeFence: 3
}),
itemId: 'question-1',
optionId: `${encodeURIComponent('free-q')}:${encodeURIComponent('custom answer')}`
}),
expect.any(Object)
)
})
it('sends structured image attachments in the message body', async () => {
act(() => {
renderer = create(createElement(Harness))
})
await vi.waitFor(() => expect(listener).toEqual(expect.any(Function)))
act(() => listener?.(snapshotEvent(3)))
let outcome: 'accepted' | 'unknown' | 'rejected' = 'rejected'
await act(async () => {
outcome = await hook.sendWithOutcome('look at this', undefined, undefined, [
{ path: '/tmp/a.png', previewUri: 'file:///a.jpg' }
])
})
expect(outcome).toBe('accepted')
expect(sendRequest).toHaveBeenCalledWith(
'agentSession.send',
expect.objectContaining({
envelope: expect.objectContaining({
sessionId: 'session-1',
expectedRuntimeFence: 3,
clientOperationId: expect.any(String),
payloadFingerprint: expect.any(String)
}),
body: {
kind: 'message',
role: 'user',
blocks: [
{ type: 'text', text: 'look at this' },
{ type: 'image-ref', path: '/tmp/a.png' }
]
}
}),
expect.any(Object)
)
})
it('rejects preview-only structured image URIs instead of sending them as host paths', async () => {
act(() => {
renderer = create(createElement(Harness))
})
await vi.waitFor(() => expect(listener).toEqual(expect.any(Function)))
act(() => listener?.(snapshotEvent(3)))
sendRequest.mockClear()
let outcome: 'accepted' | 'unknown' | 'rejected' = 'accepted'
await act(async () => {
outcome = await hook!.sendWithOutcome('look at this', ['file:///a.jpg'])
})
expect(outcome).toBe('rejected')
expect(onSendError).toHaveBeenCalledWith('Message not sent')
expect(sendRequest).not.toHaveBeenCalledWith(
'agentSession.send',
expect.objectContaining({
body: expect.objectContaining({
blocks: expect.arrayContaining([{ type: 'image-ref', path: 'file:///a.jpg' }])
})
}),
expect.any(Object)
)
})
it('answers the prompt captured by a structured card after a newer prompt lands', async () => {
act(() => {
renderer = create(createElement(Harness))
})
await vi.waitFor(() => expect(listener).toEqual(expect.any(Function)))
act(() =>
listener?.({
...snapshotEvent(3),
page: {
...snapshotEvent(3).page,
items: [
approvalItemWithIdentity('approval-old', 4),
questionItemWithIdentity('question-old', 8)
]
}
})
)
const approvalToken = hook!.permission!.options[0]!.send
const questionToken = hook!.question!.optionTokens[0]!
const freeText = formatQuestionFreeTextAnswer(hook!.question!, 'old answer')
act(() =>
listener?.({
...snapshotEvent(3),
page: {
...snapshotEvent(3).page,
items: [
approvalItemWithIdentity('approval-new', 9),
questionItemWithIdentity('question-new', 10)
]
}
})
)
sendRequest.mockClear()
await act(async () => {
expect(await hook!.respondPermission(approvalToken)).toBe(true)
expect(await hook!.respondQuestion(questionToken)).toBe(true)
expect(await hook!.respondQuestion(freeText)).toBe(true)
})
expect(sendRequest).toHaveBeenCalledWith(
'agentSession.respondToApproval',
expect.objectContaining({
itemId: 'approval-old',
expectedRevision: 4,
optionId: 'allow-once'
}),
expect.any(Object)
)
expect(sendRequest).toHaveBeenCalledWith(
'agentSession.respondToQuestion',
expect.objectContaining({
itemId: 'question-old',
expectedRevision: 8,
optionId: 'choice-a'
}),
expect.any(Object)
)
expect(sendRequest).toHaveBeenCalledWith(
'agentSession.respondToQuestion',
expect.objectContaining({
itemId: 'question-old',
expectedRevision: 8,
optionId: `${encodeURIComponent('free-q')}:${encodeURIComponent('old answer')}`
}),
expect.any(Object)
)
})
it('surfaces unknown structured prompt responses as unconfirmed', async () => {
act(() => {
renderer = create(createElement(Harness))
})
await vi.waitFor(() => expect(listener).toEqual(expect.any(Function)))
act(() =>
listener?.({
...snapshotEvent(3),
page: {
...snapshotEvent(3).page,
items: [approvalItem(), questionItem()]
}
})
)
sendRequest.mockImplementation(async (method, params) => {
if (method === 'agentSession.respondToApproval') {
throw markRpcDeliveryUnknown(new Error('Connection closed'))
}
return defaultSendRequest(method, params)
})
onSendError.mockClear()
await act(async () => {
expect(await hook!.respondPermission(hook!.permission!.options[0]!.send)).toBe(false)
})
expect(onSendError).toHaveBeenCalledWith('Response unconfirmed — check chat before retrying')
sendRequest.mockImplementation(async (method, params) => {
if (method === 'agentSession.respondToQuestion') {
throw markRpcDeliveryUnknown(new Error('Connection closed'))
}
return defaultSendRequest(method, params)
})
onSendError.mockClear()
await act(async () => {
expect(await hook!.respondQuestion(hook!.question!.optionTokens[0]!)).toBe(false)
})
expect(onSendError).toHaveBeenCalledWith('Answer unconfirmed — check chat before retrying')
})
it('uses a fresh operation id when a prompt response delivery is unknown', async () => {
act(() => {
renderer = create(createElement(Harness))
})
await vi.waitFor(() => expect(listener).toEqual(expect.any(Function)))
act(() =>
listener?.({
...snapshotEvent(3),
page: { ...snapshotEvent(3).page, items: [approvalItem()] }
})
)
let attempts = 0
sendRequest.mockImplementation(async (method, params) => {
if (method === 'agentSession.respondToApproval' && attempts++ === 0) {
throw markRpcDeliveryUnknown(new Error('Connection closed'))
}
return defaultSendRequest(method, params)
})
const token = hook!.permission!.options[0]!.send
await act(async () => {
expect(await hook!.respondPermission(token)).toBe(false)
expect(await hook!.respondPermission(token)).toBe(true)
})
const calls = sendRequest.mock.calls.filter(
([method]) => method === 'agentSession.respondToApproval'
)
expect(calls).toHaveLength(2)
const firstId = (calls[0]![1] as { envelope: { clientOperationId: string } }).envelope
.clientOperationId
const retryId = (calls[1]![1] as { envelope: { clientOperationId: string } }).envelope
.clientOperationId
expect(firstId).toMatch(/^\d{13}-[0-9a-f]{32}$/)
expect(retryId).toMatch(/^\d{13}-[0-9a-f]{32}$/)
expect(retryId).not.toBe(firstId)
})
it('marks a retried send as retryUnknown after ambiguous delivery', async () => {
act(() => {
renderer = create(createElement(Harness))
})
await vi.waitFor(() => expect(listener).toEqual(expect.any(Function)))
act(() => listener?.(snapshotEvent(3)))
let attempts = 0
sendRequest.mockImplementation(async (method, params) => {
if (method === 'agentSession.send' && attempts++ === 0) {
throw markRpcDeliveryUnknown(new Error('Connection closed'))
}
return defaultSendRequest(method, params)
})
await act(async () => {
expect(await hook!.sendWithOutcome('retry me')).toBe('unknown')
expect(await hook!.sendWithOutcome('retry me')).toBe('accepted')
})
const calls = sendRequest.mock.calls.filter(([method]) => method === 'agentSession.send')
expect(calls).toHaveLength(2)
expect(calls[0]![1]).not.toHaveProperty('retryUnknown')
expect(calls[1]![1]).toMatchObject({ retryUnknown: true })
const firstId = (calls[0]![1] as { envelope: { clientOperationId: string } }).envelope
.clientOperationId
const retryId = (calls[1]![1] as { envelope: { clientOperationId: string } }).envelope
.clientOperationId
expect(retryId).toBe(firstId)
})
it('keeps structured option changes dispatched after unknown delivery', async () => {
act(() => {
renderer = create(createElement(Harness))
})
await vi.waitFor(() => expect(listener).toEqual(expect.any(Function)))
act(() => listener?.(snapshotEvent(3)))
await vi.waitFor(() => expect(hook!.optionSnapshot.length).toBeGreaterThan(0))
sendRequest.mockImplementation(async (method, params) => {
if (method === 'agentSession.setOption') {
throw markRpcDeliveryUnknown(new Error('Connection closed'))
}
return defaultSendRequest(method, params)
})
onSendError.mockClear()
await act(async () => {
expect(await hook!.setStructuredOption('model', 'gpt-slow')).toBe(true)
})
const model = hook!.optionSnapshot.find((descriptor) => descriptor.id === 'model')
expect(model).toMatchObject({
valueSource: 'dispatched',
kind: expect.objectContaining({ currentValue: 'gpt-slow' })
})
expect(onSendError).not.toHaveBeenCalled()
})
it('reports structured Stop as unconfirmed after unknown delivery', async () => {
act(() => {
renderer = create(createElement(Harness))
})
await vi.waitFor(() => expect(listener).toEqual(expect.any(Function)))
act(() =>
listener?.({
...snapshotEvent(3),
page: {
...snapshotEvent(3).page,
items: [runningStatusItem()]
}
})
)
sendRequest.mockImplementation(async (method, params) => {
if (method === 'agentSession.cancel') {
throw markRpcDeliveryUnknown(new Error('Connection closed'))
}
return defaultSendRequest(method, params)
})
onSendError.mockClear()
await act(async () => {
hook!.cancel()
await Promise.resolve()
})
expect(onSendError).toHaveBeenCalledWith('Stop unconfirmed — check chat before retrying')
})
it('releases a landed hold when the structured tab unmounts', async () => {
act(() => {
renderer = create(createElement(Harness))
})
await vi.waitFor(() =>
expect(sendRequest).toHaveBeenCalledWith(
'agentSession.hold',
expect.objectContaining({ sessionId: 'session-1' }),
expect.any(Object)
)
)
const held = sendRequest.mock.calls.find((call) => call[0] === 'agentSession.hold')?.[1] as {
holderId: string
}
act(() => renderer?.unmount())
await vi.waitFor(() =>
expect(sendRequest).toHaveBeenCalledWith(
'agentSession.release',
{ sessionId: 'session-1', holderId: held.holderId },
expect.any(Object)
)
)
})
})
@@ -0,0 +1,315 @@
import { useCallback, useMemo, useRef } from 'react'
import type {
AgentSessionCancelResult,
AgentSessionPromptResult,
AgentSessionSendResult
} from '../../../src/shared/agent-session-wire'
import type {
SessionOptionDescriptor,
SessionOptionsSurface,
SessionOptionValue
} from '../../../src/shared/native-chat-session-options'
import {
structuredAgentSessionSendBody,
type StructuredAgentSessionAttachment
} from '../../../src/shared/structured-agent-session-outbox'
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
import { projectStructuredAgentSessionMessages } from '../../../src/shared/structured-agent-session-message-projection'
import { activeStructuredAgentSessionTurnId } from '../../../src/shared/structured-agent-session-projection'
import {
pendingStructuredApproval,
pendingStructuredQuestion,
projectStructuredPermission,
projectStructuredQuestion,
structuredApprovalResponseTarget,
structuredQuestionResponseTarget
} from './mobile-structured-agent-prompts'
import {
requestStructuredAgentSessionMutation,
structuredSessionOperationId,
timeoutForDeadline,
type StructuredAgentSessionMutationResult
} from './mobile-structured-agent-session-rpc'
import type { RpcClient } from '../transport/rpc-client'
import type { MobileChatPermission } from './mobile-native-chat-permission'
import type { MobileChatQuestion } from './mobile-native-chat-question'
import type { MobileNativeChatSession } from './use-mobile-native-chat-session'
import { useMobileStructuredAgentState } from './use-mobile-structured-agent-state'
import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-options'
type StructuredMobileAttachment = StructuredAgentSessionAttachment & { id?: string }
type StructuredMobileSession = {
session: MobileNativeChatSession
isWorking: boolean
turnId: string | null
sendWithOutcome: (
text: string,
images?: string[],
deadline?: number,
attachments?: readonly StructuredMobileAttachment[]
) => Promise<MobileNativeChatSendOutcome>
cancel: () => void
permission: MobileChatPermission | null
question: MobileChatQuestion | null
optionSnapshot: SessionOptionDescriptor[]
optionSurface: SessionOptionsSurface
pendingOptionId: string | null
respondPermission: (optionId: string) => Promise<boolean>
respondQuestion: (answer: string) => Promise<boolean>
setStructuredOption: (id: string, value: SessionOptionValue) => Promise<boolean>
invokeStructuredOption: (id: string) => Promise<boolean>
}
export function useMobileStructuredAgentSession(args: {
client: RpcClient | null
sessionId: string | null
enabled: boolean
/** Live transport only; gates the connection-scoped hold, nothing else. */
connected: boolean
agent: string | null
onSendError: (message: string) => void
}): StructuredMobileSession {
const { agent, client, connected, sessionId, enabled, onSendError } = args
const operationIdsRef = useRef(new Map<string, string>())
const { state, stateRef, loadingOlder, loadEarlier } = useMobileStructuredAgentState({
client,
sessionId,
enabled,
connected
})
const mutate = useCallback(
async <TValue>(
method: string,
fingerprintMethod: string,
fields: Record<string, unknown>
): Promise<StructuredAgentSessionMutationResult<TValue>> => {
const current = stateRef.current
if (!client || !sessionId || !enabled || current.fence === null) {
return { status: 'rejected' }
}
const targetFence = current.fence
const key = `${sessionId}:${fingerprintMethod}:${JSON.stringify(fields)}`
const clientOperationId = operationIdsRef.current.get(key) ?? structuredSessionOperationId()
operationIdsRef.current.set(key, clientOperationId)
const result = await requestStructuredAgentSessionMutation<TValue>({
client,
method,
fingerprintMethod,
sessionId,
expectedRuntimeFence: targetFence,
fields,
clientOperationId
})
if (result.status === 'accepted') {
operationIdsRef.current.delete(key)
return {
status: 'accepted',
value: result.value,
sameFence: stateRef.current.fence === targetFence
}
}
if (result.status === 'unknown') {
// Prompt/option/cancel plans cannot redispatch an unknown ledger row;
// issue a fresh id so a retry can be admitted after the user checks the
// stream. Sends opt into explicit retryUnknown below.
operationIdsRef.current.delete(key)
return result
}
operationIdsRef.current.delete(key)
onSendError(result.message)
return { status: 'rejected' }
},
[client, enabled, onSendError, sessionId]
)
const {
invokeStructuredOption,
optionSnapshot,
optionSurface,
pendingOptionId,
setStructuredOption
} = useMobileStructuredAgentOptions({
agent,
client,
sessionId,
enabled,
fence: state.fence,
mutate
})
const sendWithOutcome = useCallback(
async (
text: string,
images?: string[],
deadline?: number,
attachments?: readonly StructuredMobileAttachment[]
): Promise<MobileNativeChatSendOutcome> => {
const currentFence = stateRef.current.fence
if (!client || !sessionId || !enabled || currentFence === null) {
onSendError('Message not sent (disconnected)')
return 'rejected'
}
const timeoutMs = timeoutForDeadline(deadline)
if (timeoutMs === null) {
onSendError('Message not sent')
return 'rejected'
}
if (attachments === undefined && images !== undefined && images.length > 0) {
onSendError('Message not sent')
return 'rejected'
}
const sendAttachments = attachments ?? []
const body = structuredAgentSessionSendBody(text, sendAttachments)
if (body.blocks.length === 0) {
return 'rejected'
}
const fields = { body }
const key = `${sessionId}:agentSession.send:${JSON.stringify(fields)}`
const priorOperationId = operationIdsRef.current.get(key)
const clientOperationId = priorOperationId ?? structuredSessionOperationId()
operationIdsRef.current.set(key, clientOperationId)
const result = await requestStructuredAgentSessionMutation<AgentSessionSendResult>({
client,
method: 'agentSession.send',
fingerprintMethod: 'agentSession.send',
sessionId,
expectedRuntimeFence: currentFence,
fields,
clientOperationId,
...(priorOperationId ? { retryUnknown: true } : {}),
timeoutMs
})
if (result.status === 'accepted') {
operationIdsRef.current.delete(key)
return 'accepted'
}
if (result.status === 'unknown') {
return 'unknown'
}
operationIdsRef.current.delete(key)
onSendError(result.message === 'Request not sent' ? 'Message not sent' : result.message)
return 'rejected'
},
[client, enabled, onSendError, sessionId]
)
const respondPermission = useCallback(
async (optionId: string): Promise<boolean> => {
const target = structuredApprovalResponseTarget(
optionId,
stateRef.current.items.find(pendingStructuredApproval) ?? null
)
if (!target) {
return false
}
const result = await mutate<AgentSessionPromptResult>(
'agentSession.respondToApproval',
'agentSession.respondTo:approval',
target
)
if (result.status === 'unknown') {
onSendError('Response unconfirmed — check chat before retrying')
return false
}
return result.status === 'accepted'
},
[mutate, onSendError]
)
const respondQuestion = useCallback(
async (answer: string): Promise<boolean> => {
const target = structuredQuestionResponseTarget(
answer,
stateRef.current.items.find(pendingStructuredQuestion) ?? null
)
if (!target) {
return false
}
const result = await mutate<AgentSessionPromptResult>(
'agentSession.respondToQuestion',
'agentSession.respondTo:question',
target
)
if (result.status === 'unknown') {
onSendError('Answer unconfirmed — check chat before retrying')
return false
}
return result.status === 'accepted'
},
[mutate, onSendError]
)
const cancel = useCallback(() => {
const current = stateRef.current
const turnId = activeStructuredAgentSessionTurnId(current.items)
if (!client || !sessionId || !enabled || current.fence === null || !turnId) {
onSendError('Stop not sent')
return
}
const fields = { turnId }
const key = `${sessionId}:agentSession.cancel:${JSON.stringify(fields)}`
const clientOperationId = operationIdsRef.current.get(key) ?? structuredSessionOperationId()
operationIdsRef.current.set(key, clientOperationId)
void requestStructuredAgentSessionMutation<AgentSessionCancelResult>({
client,
method: 'agentSession.cancel',
fingerprintMethod: 'agentSession.cancel',
sessionId,
expectedRuntimeFence: current.fence,
fields,
clientOperationId
}).then((result) => {
if (result.status !== 'unknown') {
operationIdsRef.current.delete(key)
}
if (result.status === 'unknown') {
onSendError('Stop unconfirmed — check chat before retrying')
} else if (result.status === 'refused') {
onSendError(result.message)
} else if (result.status === 'failed') {
onSendError(result.message === 'Request not sent' ? 'Stop not sent' : result.message)
}
})
}, [client, enabled, onSendError, sessionId])
const messages = useMemo(
() => projectStructuredAgentSessionMessages(state.items, [], state.submissions),
[state.items, state.submissions]
)
const status = state.status === 'idle' ? 'idle' : state.status
const approvalPrompt = useMemo(
() => state.items.find(pendingStructuredApproval) ?? null,
[state.items]
)
const questionPrompt = useMemo(
() => state.items.find(pendingStructuredQuestion) ?? null,
[state.items]
)
return {
session: {
messages,
status,
transcriptLoading: status === 'loading',
error: state.error,
hasMore: state.hasOlder,
loadingEarlier: loadingOlder,
loadEarlier
},
isWorking: activeStructuredAgentSessionTurnId(state.items) !== null,
turnId: activeStructuredAgentSessionTurnId(state.items),
sendWithOutcome,
cancel,
permission: projectStructuredPermission(approvalPrompt),
question: projectStructuredQuestion(questionPrompt),
optionSnapshot,
optionSurface,
pendingOptionId,
respondPermission,
respondQuestion,
setStructuredOption,
invokeStructuredOption
}
}
@@ -0,0 +1,155 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import type {
AgentSessionHistoryResult,
AgentSessionSubscribeEvent
} from '../../../src/shared/agent-session-wire'
import { AGENT_SESSION_HISTORY_MAX_LIMIT } from '../../../src/shared/agent-session-wire'
import { structuredAgentSessionHolderId } from '../../../src/shared/structured-agent-session-holder'
import {
EMPTY_STRUCTURED_AGENT_SESSION,
oldestStructuredAgentSessionCursor,
reduceStructuredAgentSession,
type StructuredAgentSessionAction,
type StructuredAgentSessionState
} from '../../../src/shared/structured-agent-session-reducer'
import type { RpcClient } from '../transport/rpc-client'
import { callAgentSession } from './mobile-structured-agent-session-rpc'
function isSubscribeEvent(value: unknown): value is AgentSessionSubscribeEvent {
if (typeof value !== 'object' || value === null) {
return false
}
const type = (value as { type?: unknown }).type
return type === 'snapshot' || type === 'batch' || type === 'reset' || type === 'end'
}
export function useMobileStructuredAgentState(args: {
client: RpcClient | null
sessionId: string | null
enabled: boolean
/** Live transport only; gates the connection-scoped hold, nothing else. */
connected: boolean
}): {
state: StructuredAgentSessionState
stateRef: { readonly current: StructuredAgentSessionState }
loadingOlder: boolean
loadEarlier: () => void
} {
const { client, connected, enabled, sessionId } = args
const [state, setState] = useState<StructuredAgentSessionState>(EMPTY_STRUCTURED_AGENT_SESSION)
const [loadingOlder, setLoadingOlder] = useState(false)
const stateRef = useRef(state)
const sessionIdentityRef = useRef<{ client: RpcClient; sessionId: string } | null>(null)
useLayoutEffect(() => {
stateRef.current = state
}, [state])
const apply = useCallback((action: StructuredAgentSessionAction) => {
setState((current) => reduceStructuredAgentSession(current, action))
}, [])
useEffect(() => {
const sessionChanged =
client !== null &&
sessionId !== null &&
(sessionIdentityRef.current?.client !== client ||
sessionIdentityRef.current?.sessionId !== sessionId)
if (client && sessionId) {
sessionIdentityRef.current = { client, sessionId }
}
if (!client || !sessionId || !enabled) {
sessionIdentityRef.current = null
setState(EMPTY_STRUCTURED_AGENT_SESSION)
setLoadingOlder(false)
return
}
if (sessionChanged) {
// Never show one structured tab's transcript under another tab.
setState(EMPTY_STRUCTURED_AGENT_SESSION)
}
if (!connected) {
// The connection-scoped hold and stream are retired in the prior cleanup;
// retain the last transcript until the transport comes back.
return
}
apply({ type: 'loading' })
const holderId = structuredAgentSessionHolderId('mobile-chat')
let cancelled = false
let unsubscribe = (): void => {}
const held = callAgentSession(client, 'agentSession.hold', {
sessionId,
holderId
})
void held
.then(() => {
if (cancelled) {
return
}
unsubscribe = client.subscribe('agentSession.subscribe', { sessionId }, (raw) => {
if (
typeof raw === 'object' &&
raw !== null &&
(raw as { type?: unknown }).type === 'error'
) {
apply({ type: 'error', message: String((raw as { message?: unknown }).message ?? '') })
return
}
if (isSubscribeEvent(raw)) {
apply({ type: 'event', event: raw })
}
})
})
.catch((error: unknown) => {
if (!cancelled) {
apply({ type: 'error', message: error instanceof Error ? error.message : String(error) })
}
})
return () => {
cancelled = true
unsubscribe()
void held
.then(() =>
callAgentSession(
client,
'agentSession.release',
{
sessionId,
holderId
},
undefined,
{ failWhenDisconnected: true }
).catch(() => undefined)
)
.catch(() => undefined)
}
}, [apply, client, connected, enabled, sessionId])
const loadEarlier = useCallback(() => {
const current = stateRef.current
if (!client || !sessionId || loadingOlder || !current.hasOlder) {
return
}
const cursor = oldestStructuredAgentSessionCursor(current)
if (!cursor) {
return
}
setLoadingOlder(true)
void callAgentSession<AgentSessionHistoryResult>(client, 'agentSession.history', {
sessionId,
direction: 'before',
cursor,
limit: AGENT_SESSION_HISTORY_MAX_LIMIT
})
.then((result) => {
if (result.ok) {
apply({ type: 'older-page', requestedEpoch: cursor.epoch, page: result.page })
}
})
.catch((error: unknown) => {
apply({ type: 'error', message: error instanceof Error ? error.message : String(error) })
})
.finally(() => setLoadingOlder(false))
}, [apply, client, loadingOlder, sessionId])
return { state, stateRef, loadingOlder, loadEarlier }
}
@@ -0,0 +1,100 @@
import { useCallback } from 'react'
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
import type { MobileNativeChatSendOrigin } from './use-mobile-native-chat-drafts'
type StructuredNativeChatAttachment = {
id?: string
path: string
previewUri: string
}
export function useMobileStructuredNativeChatSendBridge(args: {
sendStructured: (
text: string,
images?: string[],
deadline?: number,
attachments?: readonly StructuredNativeChatAttachment[]
) => Promise<MobileNativeChatSendOutcome>
captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null
clearDraftForSend: (origin: MobileNativeChatSendOrigin, text: string) => void
acceptSend: (origin: MobileNativeChatSendOrigin, text: string, images?: string[]) => void
holdUnconfirmedSend: (
origin: MobileNativeChatSendOrigin,
text: string,
onUnconfirmed: () => void
) => void
restoreRejectedDraft: (origin: MobileNativeChatSendOrigin, text: string) => void
onSendError: (message: string) => void
}): {
send: (text: string, images?: string[]) => Promise<boolean>
sendWithOutcome: (
text: string,
images?: string[],
deadline?: number,
attachments?: readonly StructuredNativeChatAttachment[]
) => Promise<MobileNativeChatSendOutcome>
} {
const {
acceptSend,
captureSendOrigin,
clearDraftForSend,
holdUnconfirmedSend,
onSendError,
restoreRejectedDraft,
sendStructured
} = args
const sendWithOutcome = useCallback(
async (
text: string,
images?: string[],
deadline?: number,
attachments?: readonly StructuredNativeChatAttachment[]
): Promise<MobileNativeChatSendOutcome> => {
const origin = captureSendOrigin(text.trimEnd())
if (!origin) {
onSendError('Message not sent (disconnected)')
return 'rejected'
}
clearDraftForSend(origin, text)
const outcome =
attachments !== undefined
? await sendStructured(text, images, deadline, attachments)
: deadline !== undefined
? await sendStructured(text, images, deadline)
: images !== undefined
? await sendStructured(text, images)
: await sendStructured(text)
if (outcome === 'accepted') {
acceptSend(origin, text.trimEnd(), images)
return 'accepted'
}
if (outcome === 'unknown') {
holdUnconfirmedSend(origin, text.trimEnd(), () =>
onSendError('Delivery unconfirmed — check chat before retrying')
)
return 'unknown'
}
restoreRejectedDraft(origin, text)
return 'rejected'
},
[
acceptSend,
captureSendOrigin,
clearDraftForSend,
holdUnconfirmedSend,
onSendError,
restoreRejectedDraft,
sendStructured
]
)
const send = useCallback(
async (
text: string,
images?: string[],
deadline?: number,
attachments?: readonly StructuredNativeChatAttachment[]
) => (await sendWithOutcome(text, images, deadline, attachments)) !== 'rejected',
[sendWithOutcome]
)
return { send, sendWithOutcome }
}
@@ -18,6 +18,7 @@ import {
import { RpcSessionLivenessWatchdog } from './rpc-session-liveness-watchdog'
import { isStaleForegroundDial } from './rpc-stale-dial'
import type { ConnectionState, ForegroundNudgeReason, RpcResponse } from './types'
import { advertiseMobileRuntimeClientCapabilities } from './mobile-runtime-client-capabilities'
const LIVENESS_REQUEST_ID_PREFIX = 'mobile-liveness-'
@@ -232,6 +233,11 @@ export class DirectRpcClient implements RpcClient {
this.authenticationGeneration++
this.reconnect.authenticated()
this.authenticationRetry.accepted()
advertiseMobileRuntimeClientCapabilities(
(request) => this.sendEncrypted(request),
`mobile-capabilities-${this.nextId()}`,
this.deviceToken
)
this.connectionState.publish('connected')
this.connectionLog.emit('success', 'Authenticated', 'Channel ready for RPC', {
code: 'direct-connected'
@@ -93,8 +93,15 @@ async function authenticateSession() {
})
)
await vi.waitFor(() => expect(session.getState()).toBe('connected'))
await vi.waitFor(() => expect(fakes.sendText).toHaveBeenCalledTimes(2))
const capabilityRequest = JSON.parse(fakes.sendText.mock.calls[1]![0] as string) as {
id: string
method: string
deviceToken: string
params: { clientCapabilities?: string[] }
}
fakes.sendText.mockClear()
return { session, confirmationRequest: request }
return { session, confirmationRequest: request, capabilityRequest }
}
describe('mobile relay RPC session', () => {
@@ -106,7 +113,7 @@ describe('mobile relay RPC session', () => {
afterEach(() => vi.useRealTimers())
it('requires exact resume observations and confirms by request ID before becoming connected', async () => {
const { session, confirmationRequest } = await authenticateSession()
const { session, confirmationRequest, capabilityRequest } = await authenticateSession()
expect(fakes.linkOptions).toMatchObject({
endpoint: relay,
@@ -120,6 +127,13 @@ describe('mobile relay RPC session', () => {
})
expect(confirmationRequest.params).not.toHaveProperty('relayDeviceId')
expect(confirmationRequest.params).not.toHaveProperty('acceptedCredentialVersion')
expect(capabilityRequest).toMatchObject({
method: 'runtime.clientCapabilities.update',
params: {
clientCapabilities: expect.arrayContaining(['agent-session.structured.v1'])
},
deviceToken: 'device-token'
})
expect(session.getAttachDeadlineAt()).toEqual(expect.any(Number))
})
@@ -10,6 +10,7 @@ import { markRpcDeliveryUnknown } from './rpc-delivery-ambiguity'
import { openRpcRequestBudget, resolvePostConnectRequestTimeout } from './rpc-request-budget'
import { isRpcResponse } from './rpc-response-shape'
import { RpcSessionLivenessWatchdog } from './rpc-session-liveness-watchdog'
import { mobileRuntimeClientCapabilityUpdateRequest } from './mobile-runtime-client-capabilities'
import type { RpcClient } from './rpc-client'
import type { ConnectionLogSink, ConnectionState, RpcResponse } from './types'
@@ -182,6 +183,9 @@ export function connectMobileRelayRpcSession(args: {
resumeConfirmation = result.resumeConfirmation
resumeExpiresAt = result.resumeConfirmation.resumeExpiresAt
lastConnectedAt = Date.now()
sendFrame(
mobileRuntimeClientCapabilityUpdateRequest({ id: nextId(), deviceToken: args.deviceToken })
)
livenessWatchdog.start(livenessIdentity)
publishState('connected')
} catch (error) {
@@ -0,0 +1,44 @@
import {
STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
} from '../../../src/shared/protocol-version'
import { remoteRuntimeClientCapabilities } from '../../../src/shared/remote-runtime-client-capabilities'
export const MOBILE_RUNTIME_CLIENT_CAPABILITIES = remoteRuntimeClientCapabilities([
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY
])
export const MOBILE_RUNTIME_CLIENT_CAPABILITY_UPDATE_METHOD =
'runtime.clientCapabilities.update' as const
export function mobileRuntimeClientCapabilityUpdateParams(): {
clientCapabilities: string[]
} {
return { clientCapabilities: [...MOBILE_RUNTIME_CLIENT_CAPABILITIES] }
}
export function mobileRuntimeClientCapabilityUpdateRequest(args: {
id: string
deviceToken: string
}): {
id: string
deviceToken: string
method: typeof MOBILE_RUNTIME_CLIENT_CAPABILITY_UPDATE_METHOD
params: { clientCapabilities: string[] }
} {
return {
id: args.id,
deviceToken: args.deviceToken,
method: MOBILE_RUNTIME_CLIENT_CAPABILITY_UPDATE_METHOD,
params: mobileRuntimeClientCapabilityUpdateParams()
}
}
export function advertiseMobileRuntimeClientCapabilities(
send: (request: unknown) => boolean | void,
id: string,
deviceToken: string
): void {
send(mobileRuntimeClientCapabilityUpdateRequest({ id, deviceToken }))
}
@@ -0,0 +1,103 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { connect } from './rpc-client'
vi.mock('./e2ee', () => ({
generateKeyPair: () => ({
publicKey: new Uint8Array(32),
secretKey: new Uint8Array(32)
}),
deriveSharedKey: () => new Uint8Array(32),
publicKeyFromBase64: () => new Uint8Array(32),
publicKeyToBase64: () => 'client-public-key',
encrypt: (plaintext: string) => `encrypted:${plaintext}`,
decrypt: (raw: string) => raw.replace(/^encrypted:/, ''),
decryptBytes: (bytes: Uint8Array) => bytes
}))
class MockWebSocket {
static CONNECTING = 0
static OPEN = 1
static CLOSING = 2
static CLOSED = 3
readonly CONNECTING = MockWebSocket.CONNECTING
readonly OPEN = MockWebSocket.OPEN
readonly CLOSING = MockWebSocket.CLOSING
readonly CLOSED = MockWebSocket.CLOSED
readyState = MockWebSocket.CONNECTING
onopen: (() => void) | null = null
onmessage: ((event: { data: unknown }) => void) | null = null
onclose: (() => void) | null = null
sent: string[] = []
constructor(readonly endpoint: string) {
mockSockets.push(this)
}
send(payload: string): void {
this.sent.push(payload)
}
close(): void {
this.readyState = MockWebSocket.CLOSED
this.onclose?.()
}
open(): void {
this.readyState = MockWebSocket.OPEN
this.onopen?.()
}
receive(payload: unknown): void {
this.onmessage?.({ data: payload })
}
}
type SentRpcRequest = { id: string; method: string; params?: unknown }
const mockSockets: MockWebSocket[] = []
const originalWebSocket = globalThis.WebSocket
function sentRequest(socket: MockWebSocket, method: string): SentRpcRequest {
const request = socket.sent
.map((payload) => JSON.parse(payload.replace(/^encrypted:/, '')) as SentRpcRequest)
.find((candidate) => candidate.method === method)
if (!request) {
throw new Error(`Request not sent: ${method}`)
}
return request
}
describe('mobile rpc-client capabilities', () => {
beforeEach(() => {
mockSockets.length = 0
globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket
})
afterEach(() => {
globalThis.WebSocket = originalWebSocket
})
it('advertises mobile runtime capabilities after auth and before replayed streams', () => {
const client = connect('ws://desktop.invalid', 'token', 'server-key')
const socket = mockSockets[0]!
client.subscribe('session.tabs.subscribe', { worktree: 'id:wt-1' }, () => {})
socket.open()
socket.receive(JSON.stringify({ type: 'e2ee_ready' }))
socket.receive('encrypted:{"type":"e2ee_authenticated"}')
const capabilityRequest = sentRequest(socket, 'runtime.clientCapabilities.update')
expect(capabilityRequest.params).toMatchObject({
clientCapabilities: expect.arrayContaining(['agent-session.structured.v1'])
})
expect(socket.sent.findIndex((payload) => payload.includes(capabilityRequest.id))).toBeLessThan(
socket.sent.findIndex((payload) =>
payload.includes(sentRequest(socket, 'session.tabs.subscribe').id)
)
)
client.close()
})
})
+10 -26
View File
@@ -64,36 +64,20 @@ class MockWebSocket {
const mockSockets: MockWebSocket[] = []
const originalWebSocket = globalThis.WebSocket
function sentRequest(socket: MockWebSocket, method: string): { id: string; params?: unknown } {
for (const payload of socket.sent) {
const decoded = JSON.parse(payload.replace(/^encrypted:/, '')) as {
id: string
method: string
params?: unknown
}
if (decoded.method === method) {
return { id: decoded.id, params: decoded.params }
}
type SentRpcRequest = { id: string; method: string; params?: unknown }
function sentRequest(socket: MockWebSocket, method: string): SentRpcRequest {
const request = sentRequests(socket, method)[0]
if (request) {
return request
}
throw new Error(`Request not sent: ${method}`)
}
function sentRequests(
socket: MockWebSocket,
method: string
): Array<{ id: string; params?: unknown }> {
const requests: Array<{ id: string; params?: unknown }> = []
for (const payload of socket.sent) {
const decoded = JSON.parse(payload.replace(/^encrypted:/, '')) as {
id: string
method: string
params?: unknown
}
if (decoded.method === method) {
requests.push({ id: decoded.id, params: decoded.params })
}
}
return requests
function sentRequests(socket: MockWebSocket, method: string): SentRpcRequest[] {
return socket.sent
.map((payload) => JSON.parse(payload.replace(/^encrypted:/, '')) as SentRpcRequest)
.filter((request) => request.method === method)
}
function encodeBrowserFrame(): Uint8Array {
+28 -3
View File
@@ -36,7 +36,13 @@ const MOBILE_DYNAMIC_RPC_METHODS = [
'github.resolveReviewThread',
'github.project.updateIssueCommentBySlug',
'github.project.deleteIssueCommentBySlug',
'hostedReview.forBranch'
'hostedReview.forBranch',
'runtime.clientCapabilities.update',
'agentSession.send',
'agentSession.cancel',
'agentSession.history',
'agentSession.hold',
'agentSession.release'
]
const MOBILE_STREAMING_CLEANUP_RPC_METHODS = [
@@ -145,9 +151,28 @@ describe('mobile RPC allowlist', () => {
).toEqual([])
})
it('does not expose structured agent sessions to mobile credentials', () => {
it('exposes only the mobile structured agent-session surface', () => {
expect(
[...mobileRpcAllowlist()].filter((method) => method.startsWith('agentSession.'))
).toEqual([])
).toEqual([
'agentSession.createSupport',
'agentSession.create',
'agentSession.ensure',
'agentSession.send',
'agentSession.cancel',
'agentSession.close',
'agentSession.respondToApproval',
'agentSession.respondToQuestion',
'agentSession.setOption',
'agentSession.handoffStatus',
'agentSession.options',
'agentSession.history',
'agentSession.subscribe',
'agentSession.unsubscribe',
'agentSession.hold',
'agentSession.release'
])
expect(mobileRpcAllowlist().has('agentSession.attach')).toBe(false)
expect(mobileRpcAllowlist().has('agentSession.requestHandoff')).toBe(false)
})
})
@@ -21,8 +21,15 @@ export class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWi
tab: RuntimeMobileSessionAgentTab
): Promise<void> {
const host = getStructuredAgentSessionHost()
if (typeof host?.setSessionTabVisibility === 'function') {
await host.setSessionTabVisibility(tab.sessionId, false)
if (host) {
// Closing the mobile tab is the structured-session view close: stop the
// provider child before removing the durable tab reference.
if (typeof host.close === 'function') {
await host.close(tab.sessionId)
}
if (typeof host.setSessionTabVisibility === 'function') {
await host.setSessionTabVisibility(tab.sessionId, false)
}
}
const nextTabs = snapshot.tabs.filter((candidate) => candidate.id !== tab.id)
const active = nextTabs.find((candidate) => candidate.isActive) ?? nextTabs[0] ?? null
@@ -87,6 +87,11 @@ export class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCommands {
) {
super()
this.store = store
store?.onSettingsChanged?.((updates) => {
if ('experimentalStructuredNativeChat' in updates) {
this.notifyMobileSessionTabsChanged()
}
})
const runtime = this as RuntimeCommandSurfaceHost<this>
installRuntimeFileCommandSurface(runtime, this.fileCommands)
installRuntimeGitCommandSurface(runtime, this.gitCommands)
@@ -0,0 +1,21 @@
import { describe, expect, it, vi } from 'vitest'
import { OrcaRuntimeService } from './orca-runtime'
describe('structured native chat settings', () => {
it('republishes mobile session tabs when the host visibility setting changes', () => {
const settingsListeners: ((updates: Record<string, unknown>) => void)[] = []
const runtime = new OrcaRuntimeService({
onSettingsChanged: vi.fn((listener) => {
settingsListeners.push(listener as (updates: Record<string, unknown>) => void)
return vi.fn()
})
} as never)
const notify = vi.spyOn(runtime, 'notifyMobileSessionTabsChanged').mockImplementation(() => {})
settingsListeners[0]?.({ compactWorktreeCards: true })
expect(notify).not.toHaveBeenCalled()
settingsListeners[0]?.({ experimentalStructuredNativeChat: true })
expect(notify).toHaveBeenCalledTimes(1)
})
})
@@ -205,6 +205,7 @@ describe('structured session cold restoration', () => {
it('normalizes a restored tab id and removes it when closed', async () => {
const runtime = new OrcaRuntimeService()
const closeSessionTab = vi.fn(async () => undefined)
const closeStructuredSession = vi.fn(async () => undefined)
runtime.setNotifier({ closeSessionTab } as never)
const internal = runtime as unknown as {
hasPersistedStructuredAgentSessionStore(): boolean
@@ -221,6 +222,7 @@ describe('structured session cold restoration', () => {
setStructuredAgentSessionHost({
reconcileRestartLeases: async () => undefined,
restoreReadableSessions: async () => undefined,
close: closeStructuredSession,
listSessionTabs: () => [
{
sessionId: 'agent-session:agent-session:restored-session',
@@ -304,6 +306,7 @@ describe('structured session cold restoration', () => {
'structured-agent-session-restored-session',
'workspace-1'
)
expect(closeStructuredSession).toHaveBeenCalledWith('restored-session')
const closed = await runtime.listMobileSessionTabs('id:workspace-1')
expect(closed.tabs.map((tab) => tab.id)).toEqual([
+2
View File
@@ -77,6 +77,8 @@ export type RpcContext = {
clientKind?: 'mobile' | 'runtime'
// Why: negotiation is bound to the authenticated socket, never asserted by a destructive request.
clientCapabilities?: readonly RuntimeCapability[]
// Why: mobile v2 auth is exact-key validated; capability upgrades must mutate only the authenticated socket after auth.
updateClientCapabilities?: (capabilities: readonly RuntimeCapability[]) => void
// Why: Dispatch authority rides in the authenticated RPC envelope, never in user payload fields.
orchestrationCapability?: string
// Why: long-lived mutations such as ask can durably expose acceptance before their waiter settles.
@@ -10,6 +10,7 @@ export type RpcDispatchStreamingOptions = {
pairedDeviceId?: string
clientKind?: 'mobile' | 'runtime'
clientCapabilities?: readonly RuntimeCapability[]
updateClientCapabilities?: (capabilities: readonly RuntimeCapability[]) => void
pairing?: PairingRpcContext
sendBinary?: (bytes: Uint8Array<ArrayBufferLike>) => boolean | void
registerBinaryStreamHandler?: (
+2 -2
View File
@@ -29,8 +29,7 @@ import { RpcStreamingDispatcher } from './rpc-streaming-dispatcher'
export type DispatcherOptions = { runtime: OrcaRuntimeService; methods?: readonly RpcAnyMethod[] }
// oxfmt-ignore
type DispatchCallOptions = Pick<RpcDispatchStreamingOptions, 'signal' | 'connectionId' | 'clientId' | 'clientKind' | 'clientCapabilities' | 'authenticatedCallerFingerprint'>
type DispatchCallOptions = RpcDispatchStreamingOptions
export class RpcDispatcher {
private readonly runtime: OrcaRuntimeService
@@ -131,6 +130,7 @@ export class RpcDispatcher {
clientId: options?.clientId,
clientKind: options?.clientKind,
clientCapabilities: options?.clientCapabilities,
updateClientCapabilities: options?.updateClientCapabilities,
orchestrationCapability: request.orchestrationCapability,
authenticatedCallerFingerprint:
mutation?.identity.callerFingerprint ??
@@ -31,6 +31,7 @@ describe('client UI RPC methods', () => {
visibleTaskProviders: ['github', 'gitlab'],
defaultRepoSelection: ['repo-1'],
defaultLinearTeamSelection: ['team-1'],
experimentalStructuredNativeChat: true,
compactWorktreeCards: true,
minimaxGroupId: 'group-42',
minimaxUsageModels: 'general,abab6.5',
@@ -60,6 +61,24 @@ describe('client UI RPC methods', () => {
expect(response).toMatchObject({ ok: true, result: { settings } })
})
it('rejects paired attempts to mutate the host-owned structured chat setting', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
updateClientSettings: vi.fn()
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS })
const response = await dispatcher.dispatch(
makeRequest('settings.update', { experimentalStructuredNativeChat: true })
)
expect(response).toMatchObject({
ok: false,
error: { code: 'invalid_argument' }
})
expect(runtime.updateClientSettings).not.toHaveBeenCalled()
})
it('persists the runtime host task source settings for mobile Tasks', async () => {
const settings = {
defaultTuiAgent: null,
+2
View File
@@ -38,6 +38,7 @@ import { PLUGIN_METHODS } from './plugins'
import { SKILL_METHODS } from './skills'
import { CLIPBOARD_METHODS } from './clipboard'
import { HOST_CAPABILITY_METHODS } from './host-capabilities'
import { RUNTIME_CLIENT_CAPABILITY_METHODS } from './runtime-client-capabilities'
import { EMULATOR_METHODS } from './emulator'
import { PAIRING_METHODS } from './pairing'
import { UPDATER_METHODS } from './updater'
@@ -91,6 +92,7 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [
...SKILL_METHODS,
...CLIPBOARD_METHODS,
...HOST_CAPABILITY_METHODS,
...RUNTIME_CLIENT_CAPABILITY_METHODS,
...CLIENT_EVENT_METHODS,
...CLIENT_UI_METHODS,
...EMULATOR_METHODS,
@@ -0,0 +1,74 @@
import { describe, expect, it, vi } from 'vitest'
import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import type { OrcaRuntimeService } from '../../orca-runtime'
import type { RpcRequest } from '../core'
import { RpcDispatcher } from '../dispatcher'
import { RUNTIME_CLIENT_CAPABILITY_METHODS } from './runtime-client-capabilities'
function makeRequest(params: unknown): RpcRequest {
return {
id: 'req-1',
authToken: 'tok',
method: 'runtime.clientCapabilities.update',
params
}
}
function dispatcher(): RpcDispatcher {
return new RpcDispatcher({
runtime: { getRuntimeId: () => 'runtime-1' } as unknown as OrcaRuntimeService,
methods: RUNTIME_CLIENT_CAPABILITY_METHODS
})
}
describe('runtime.clientCapabilities.update', () => {
it('updates the authenticated socket capability set after auth', async () => {
const updateClientCapabilities = vi.fn()
const response = await dispatcher().dispatch(
makeRequest({
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
}),
{ clientKind: 'mobile', updateClientCapabilities }
)
expect(response).toMatchObject({
ok: true,
result: { clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] }
})
expect(updateClientCapabilities).toHaveBeenCalledWith([
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
])
})
it('rejects malformed upgrades without mutating authenticated state', async () => {
const updateClientCapabilities = vi.fn()
const response = await dispatcher().dispatch(
makeRequest({
clientCapabilities: [42]
}),
{ clientKind: 'mobile', updateClientCapabilities }
)
expect(response).toMatchObject({
ok: false,
error: { code: 'invalid_argument' }
})
expect(updateClientCapabilities).not.toHaveBeenCalled()
})
it('fails closed when a transport has no post-auth updater', async () => {
const response = await dispatcher().dispatch(
makeRequest({
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
}),
{ clientKind: 'runtime' }
)
expect(response).toMatchObject({
ok: false,
error: { message: 'client_capabilities_update_unsupported' }
})
})
})
@@ -0,0 +1,24 @@
import { z } from 'zod'
import type { RuntimeCapability } from '../../../../shared/protocol-version'
import { defineMethod, type RpcAnyMethod } from '../core'
const ClientCapabilitiesUpdate = z
.object({
clientCapabilities: z.array(z.string().min(1).max(128)).max(64)
})
.strict()
export const RUNTIME_CLIENT_CAPABILITY_METHODS: RpcAnyMethod[] = [
defineMethod({
name: 'runtime.clientCapabilities.update',
params: ClientCapabilitiesUpdate,
handler: (params, { updateClientCapabilities }) => {
if (!updateClientCapabilities) {
throw new Error('client_capabilities_update_unsupported')
}
const clientCapabilities = params.clientCapabilities as RuntimeCapability[]
updateClientCapabilities(clientCapabilities)
return { clientCapabilities }
}
})
]
@@ -75,6 +75,48 @@ describe('session tab structured capability mutations', () => {
expect(fixture.calls[method.runtimeMethod]).not.toHaveBeenCalled()
})
}
it.each(['session.tabs.close', 'session.tabs.closeLifecycle'] as const)(
'allows capable mobile clients to close structured tabs when the experiment is enabled (%s)',
async (method) => {
const snapshot = agentSnapshot()
const closeMobileSessionTab = vi.fn().mockResolvedValue({ closed: true })
const runtime = {
getRuntimeId: () => 'test-runtime',
getClientSettings: vi.fn(() => ({ experimentalStructuredNativeChat: true })),
listMobileSessionTabs: vi.fn().mockResolvedValue(snapshot),
closeMobileSessionTab
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
const replies: string[] = []
await dispatcher.dispatchStreaming(
{
id: 'request-1',
authToken: 'token',
method,
params:
method === 'session.tabs.close'
? { worktree: 'id:wt-1', tabId: 'codex-session', reason: 'user' }
: {
worktree: 'id:wt-1',
tabId: 'codex-session',
reason: 'cleanup',
publicationEpoch: 'epoch-1',
terminal: 'pty-1'
}
},
(response) => replies.push(response),
{
clientKind: 'mobile',
pairedDeviceId: 'paired-mobile',
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
}
)
expect(JSON.parse(replies[0]!).ok).toBe(true)
expect(closeMobileSessionTab).toHaveBeenCalledOnce()
}
)
})
function createFixture(capabilities: RuntimeCapability[]) {
@@ -96,6 +96,22 @@ describe('projectSessionTabAgentStatus', () => {
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
])
).toEqual(oldClient)
expect(
projectSessionTabAgentStatus(
snapshot,
'mobile',
[STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY],
false
)
).toEqual(oldClient)
const capableMobile = projectSessionTabAgentStatus(
snapshot,
'mobile',
[STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY],
true
)
expect(capableMobile).toBe(snapshot)
const capable = projectSessionTabAgentStatus(snapshot, 'runtime', [
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
@@ -133,6 +149,14 @@ describe('projectSessionTabAgentStatus', () => {
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
]).tabs.map((tab) => tab.id)
).toEqual(['agent-session:codex'])
expect(
projectSessionTabAgentStatus(
snapshot,
'mobile',
[STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY],
true
).tabs.map((tab) => tab.id)
).toEqual(['agent-session:codex'])
})
it('withholds session boundaries from legacy paired clients', () => {
@@ -1,6 +1,5 @@
import {
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
type RuntimeCapability
} from '../../../../shared/protocol-version'
import type {
@@ -9,18 +8,21 @@ import type {
RuntimeMobileSessionTabsSnapshot
} from '../../../../shared/runtime-types'
import type { TabGroupLayoutNode } from '../../../../shared/tab-types'
import { structuredNativeChatProjectionEnabled } from './structured-agent-session-policy'
type SessionTabsPayload = RuntimeMobileSessionTabsResult | RuntimeMobileSessionTabsSnapshot
export function projectSessionTabAgentStatus<TPayload extends SessionTabsPayload>(
payload: TPayload,
clientKind: 'mobile' | 'runtime' | undefined,
clientCapabilities: readonly RuntimeCapability[] | undefined
clientCapabilities: readonly RuntimeCapability[] | undefined,
structuredNativeChatEnabled?: boolean
): TPayload {
const structuredVisible =
clientKind !== 'mobile' &&
(clientKind === undefined ||
(clientCapabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) ?? false))
const structuredVisible = structuredNativeChatProjectionEnabled({
clientKind,
clientCapabilities,
structuredNativeChatEnabled
})
let projected = structuredVisible ? payload : projectAgentSessionTabsOut(payload, () => true)
if (structuredVisible && clientKind !== undefined) {
projected = projectAgentSessionTabsOut(projected, (tab) => tab.agent !== 'codex')
@@ -4,6 +4,7 @@ import { defineMethod, type RpcAnyMethod } from '../core'
import { CloseLifecycleTab, CloseTab } from './session-tabs-schemas'
import { assertProjectedSessionTabVisible } from './session-tab-browser-placement-projection'
import { projectSessionTabsForClient } from './session-tabs-inventory'
import { isStructuredNativeChatEnabled } from './structured-agent-session-policy'
export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [
defineMethod({
@@ -14,7 +15,10 @@ export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [
const visible = projectSessionTabsForClient(
await context.runtime.listMobileSessionTabs(params.worktree, context.pairedDeviceId),
context.clientKind,
context.clientCapabilities
context.clientCapabilities,
context.clientKind === 'mobile'
? isStructuredNativeChatEnabled(context.runtime)
: undefined
)
assertProjectedSessionTabVisible(visible, params.tabId)
}
@@ -80,7 +84,10 @@ export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [
const visible = projectSessionTabsForClient(
await context.runtime.listMobileSessionTabs(params.worktree, context.pairedDeviceId),
context.clientKind,
context.clientCapabilities
context.clientCapabilities,
context.clientKind === 'mobile'
? isStructuredNativeChatEnabled(context.runtime)
: undefined
)
assertProjectedSessionTabVisible(visible, params.tabId)
}
@@ -6,6 +6,7 @@ import {
translateProjectedSessionTabMove
} from './session-tab-browser-placement-projection'
import { projectSessionTabsForClient } from './session-tabs-inventory'
import { isStructuredNativeChatEnabled } from './structured-agent-session-policy'
import { ActivateTab, MoveTab, SetTabProps, UpdatePaneLayout } from './session-tabs-schemas'
export const SESSION_TAB_MUTATION_METHODS: RpcAnyMethod[] = [
@@ -17,7 +18,8 @@ export const SESSION_TAB_MUTATION_METHODS: RpcAnyMethod[] = [
const visible = projectSessionTabsForClient(
await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId),
clientKind,
clientCapabilities
clientCapabilities,
clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined
)
assertProjectedSessionTabVisible(visible, params.tabId)
}
@@ -36,7 +38,12 @@ export const SESSION_TAB_MUTATION_METHODS: RpcAnyMethod[] = [
})
}
)
return projectSessionTabsForMutationClient(result, clientKind, clientCapabilities)
return projectSessionTabsForMutationClient(
result,
clientKind,
clientCapabilities,
clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined
)
}
}),
defineMethod({
@@ -46,7 +53,12 @@ export const SESSION_TAB_MUTATION_METHODS: RpcAnyMethod[] = [
let translated: Parameters<typeof translateProjectedSessionTabMove>[2] = params
if (clientKind) {
const raw = await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId)
const projected = projectSessionTabsForClient(raw, clientKind, clientCapabilities)
const projected = projectSessionTabsForClient(
raw,
clientKind,
clientCapabilities,
clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined
)
translated = translateProjectedSessionTabMove(raw, projected, params)
}
const base = { tabId: translated.tabId, targetGroupId: translated.targetGroupId }
@@ -129,7 +141,8 @@ async function assertVisibleMutationTab(
const visible = projectSessionTabsForClient(
await runtime.listMobileSessionTabs(worktree, pairedDeviceId),
clientKind,
clientCapabilities
clientCapabilities,
clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined
)
assertProjectedSessionTabVisible(visible, tabId)
}
@@ -4,6 +4,7 @@ import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-
import type { RpcContext } from '../core'
import { projectSessionTabAgentStatus } from './session-tab-agent-status-projection'
import { projectSessionTabBrowserPlacements } from './session-tab-browser-placement-projection'
import { isStructuredNativeChatEnabled } from './structured-agent-session-policy'
type SessionTabsInventory = {
snapshots: RuntimeMobileSessionTabsResult[]
@@ -26,21 +27,38 @@ function clientUnderstandsAuthoritativeInventory(context: RpcContext): boolean {
export function projectSessionTabsForClient(
snapshot: RuntimeMobileSessionTabsResult,
clientKind: 'mobile' | 'runtime' | undefined,
clientCapabilities: Parameters<typeof projectSessionTabAgentStatus>[2]
clientCapabilities: Parameters<typeof projectSessionTabAgentStatus>[2],
structuredNativeChatEnabled?: boolean
): RuntimeMobileSessionTabsResult {
return projectSessionTabBrowserPlacements(
projectSessionTabAgentStatus(snapshot, clientKind, clientCapabilities),
projectSessionTabAgentStatus(
snapshot,
clientKind,
clientCapabilities,
structuredNativeChatEnabled
),
clientCapabilities
)
}
function structuredNativeChatEnabledForContext(context: RpcContext): boolean | undefined {
return context.clientKind === 'mobile'
? isStructuredNativeChatEnabled(context.runtime)
: undefined
}
function projectInventory(
inventory: SessionTabsInventory,
context: RpcContext
): SessionTabsInventory {
return {
snapshots: inventory.snapshots.map((snapshot) =>
projectSessionTabsForClient(snapshot, context.clientKind, context.clientCapabilities)
projectSessionTabsForClient(
snapshot,
context.clientKind,
context.clientCapabilities,
structuredNativeChatEnabledForContext(context)
)
),
...(inventory.authoritative && clientUnderstandsAuthoritativeInventory(context)
? { authoritative: true as const }
@@ -109,7 +127,8 @@ export async function subscribeSessionTabsInventory(
projectSessionTabsForClient(
snapshot,
context.clientKind,
context.clientCapabilities
context.clientCapabilities,
structuredNativeChatEnabledForContext(context)
) as SessionTabsChange
const withoutNavigationIntent = (snapshot: SessionTabsChange): SessionTabsChange => {
if (snapshot.navigationIntent === undefined) {
@@ -2,7 +2,10 @@ import { describe, expect, it, vi } from 'vitest'
import { RpcDispatcher } from '../dispatcher'
import type { RpcRequest } from '../core'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import {
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
} from '../../../../shared/protocol-version'
import { SESSION_TAB_METHODS } from './session-tabs'
function makeRequest(method: string, params?: unknown): RpcRequest {
@@ -10,6 +13,48 @@ function makeRequest(method: string, params?: unknown): RpcRequest {
}
describe('session tab RPC methods', () => {
it('does not restore structured tabs for mobile while the host setting is off', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
getClientSettings: vi.fn(() => ({ experimentalStructuredNativeChat: false })),
restoreStructuredAgentSessionTabs: vi.fn(),
listMobileSessionTabs: vi.fn().mockResolvedValue(visibleSnapshot())
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
const response = await dispatcher.dispatch(
makeRequest('session.tabs.list', { worktree: 'id:wt-1' }),
{
clientKind: 'mobile',
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
}
)
expect(response.ok).toBe(true)
expect(runtime.restoreStructuredAgentSessionTabs).not.toHaveBeenCalled()
})
it('restores structured tabs for mobile only after capability and setting are present', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
getClientSettings: vi.fn(() => ({ experimentalStructuredNativeChat: true })),
restoreStructuredAgentSessionTabs: vi.fn(),
listMobileSessionTabs: vi.fn().mockResolvedValue(visibleSnapshot())
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
const response = await dispatcher.dispatch(
makeRequest('session.tabs.list', { worktree: 'id:wt-1' }),
{
clientKind: 'mobile',
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
}
)
expect(response.ok).toBe(true)
expect(runtime.restoreStructuredAgentSessionTabs).toHaveBeenCalledTimes(1)
})
it('routes mobile-only activation without notifying desktop clients', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
+19 -7
View File
@@ -15,6 +15,7 @@ import {
import { SESSION_TAB_MARKDOWN_METHODS } from './session-tab-markdown-methods'
import { SESSION_TAB_MUTATION_METHODS } from './session-tab-mutation-methods'
import { restoreStructuredTabsIfSupported } from './structured-session-tab-restore'
import { isStructuredNativeChatEnabled } from './structured-agent-session-policy'
import { assertLegacyAiVaultResumeCommandAllowed } from '../../../ai-vault/structured-session-ownership'
export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
@@ -22,11 +23,12 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
name: 'session.tabs.list',
params: WorktreeTabSelector,
handler: async (params, { runtime, pairedDeviceId, clientKind, clientCapabilities }) => {
await restoreStructuredTabsIfSupported(runtime, clientCapabilities)
await restoreStructuredTabsIfSupported({ runtime, clientKind, clientCapabilities })
return projectSessionTabsForClient(
await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId),
clientKind,
clientCapabilities
clientCapabilities,
clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined
)
}
}),
@@ -34,7 +36,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
name: 'session.tabs.listAll',
params: null,
handler: async (_params, context) => {
await restoreStructuredTabsIfSupported(context.runtime, context.clientCapabilities)
await restoreStructuredTabsIfSupported(context)
return listSessionTabsInventory(context)
}
}),
@@ -89,7 +91,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
let unsubscribe = (): void => {}
let closed = false
let initialized = false
await restoreStructuredTabsIfSupported(runtime, clientCapabilities)
await restoreStructuredTabsIfSupported({ runtime, clientKind, clientCapabilities })
const initial = await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId)
if (closed) {
return
@@ -115,7 +117,12 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
}
emit({
type: 'snapshot',
...projectSessionTabsForClient(initial, clientKind, clientCapabilities)
...projectSessionTabsForClient(
initial,
clientKind,
clientCapabilities,
clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined
)
})
initialized = true
if (closed) {
@@ -126,7 +133,12 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
if (snapshot.worktree === subscribedWorktree) {
emit({
type: 'updated',
...projectSessionTabsForClient(snapshot, clientKind, clientCapabilities)
...projectSessionTabsForClient(
snapshot,
clientKind,
clientCapabilities,
clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined
)
})
}
}, pairedDeviceId)
@@ -157,7 +169,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
name: 'session.tabs.subscribeAll',
params: null,
handler: async (_params, context, emit) => {
await restoreStructuredTabsIfSupported(context.runtime, context.clientCapabilities)
await restoreStructuredTabsIfSupported(context)
return subscribeSessionTabsInventory(context, emit)
}
}),
@@ -5,21 +5,18 @@
// handed a session it cannot render or drive — and, just as importantly, cannot make the host EXIST
// by calling into it, which is an observable side effect.
import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import { getStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry'
import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host'
import type { StructuredAgentSessionCaller } from '../../../native-chat/agent-session-wire/structured-agent-session-host-types'
import type { RpcContext } from '../core'
import { supportsStructuredAgentSessions } from './structured-agent-session-policy'
/**
* In-process callers are the same build as the host, so they carry no negotiated
* capability list; every remote client must say it can read structured sessions.
*/
export function supportsStructuredSessions(ctx: RpcContext): boolean {
return (
ctx.clientKind === undefined ||
(ctx.clientCapabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) ?? false)
)
return supportsStructuredAgentSessions(ctx)
}
export function requireStructuredCapability(ctx: RpcContext): void {
@@ -0,0 +1,47 @@
import {
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
type RuntimeCapability
} from '../../../../shared/protocol-version'
import type { OrcaRuntimeService } from '../../orca-runtime'
import type { RpcContext } from '../core'
type StructuredPolicyContext = Pick<RpcContext, 'clientCapabilities' | 'clientKind'> & {
runtime?: Pick<OrcaRuntimeService, 'getClientSettings'>
structuredNativeChatEnabled?: boolean
}
export function isStructuredNativeChatEnabled(
runtime: Pick<OrcaRuntimeService, 'getClientSettings'>
): boolean {
try {
return runtime.getClientSettings().experimentalStructuredNativeChat === true
} catch {
return false
}
}
export function supportsStructuredAgentSessions(context: StructuredPolicyContext): boolean {
if (context.clientKind === undefined) {
return true
}
const hasCapability =
context.clientCapabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) === true
if (!hasCapability) {
return false
}
if (context.clientKind !== 'mobile') {
return true
}
return (
context.structuredNativeChatEnabled === true ||
(context.runtime ? isStructuredNativeChatEnabled(context.runtime) : false)
)
}
export function structuredNativeChatProjectionEnabled(args: {
clientKind: 'mobile' | 'runtime' | undefined
clientCapabilities: readonly RuntimeCapability[] | undefined
structuredNativeChatEnabled?: boolean
}): boolean {
return supportsStructuredAgentSessions(args)
}
@@ -111,7 +111,7 @@ function hostStub(): StructuredAgentSessionHost {
return hostCalls as unknown as StructuredAgentSessionHost
}
function dispatcher(): RpcDispatcher {
function dispatcher(runtimeOverrides: Record<string, unknown> = {}): RpcDispatcher {
runtimeCalls = {
getStructuredAgentSessionCreateSupport: vi.fn(async () => ({ supported: true })),
resolveStructuredAgentSessionCreateIntent: vi.fn(async (params) => ({
@@ -134,7 +134,8 @@ function dispatcher(): RpcDispatcher {
registerSubscriptionCleanup: vi.fn(),
cleanupSubscription: vi.fn(),
cleanupSubscriptionsByPrefix: vi.fn(),
...runtimeCalls
...runtimeCalls,
...runtimeOverrides
}
return new RpcDispatcher({
runtime: runtime as unknown as OrcaRuntimeService,
@@ -151,10 +152,11 @@ async function call(
clientId?: string
clientKind?: 'mobile' | 'runtime'
clientCapabilities?: string[]
}
},
runtimeOverrides: Record<string, unknown> = {}
): Promise<RpcResponse> {
const replies: RpcResponse[] = []
await dispatcher().dispatchStreaming(
await dispatcher(runtimeOverrides).dispatchStreaming(
request(method, params),
(raw) => replies.push(JSON.parse(raw) as RpcResponse),
client
@@ -170,6 +172,10 @@ const STRUCTURED_CLIENT = {
clientKind: 'runtime' as const,
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
}
const STRUCTURED_MOBILE_CLIENT = {
clientKind: 'mobile' as const,
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
}
beforeEach(() => {
setStructuredAgentSessionHost(hostStub())
@@ -250,6 +256,25 @@ describe('capability gating', () => {
expect(hostCalls.send).toHaveBeenCalledTimes(1)
})
it('requires the host structured-chat setting for mobile clients', async () => {
const response = await call('agentSession.send', sendParams(), STRUCTURED_MOBILE_CLIENT, {
getClientSettings: () => ({ experimentalStructuredNativeChat: false })
})
expect(response).toMatchObject({
ok: false,
error: { message: expect.stringContaining('structured_agent_session_unsupported') }
})
expect(hostCalls.send).not.toHaveBeenCalled()
})
it('serves mobile clients only after capability and setting negotiation', async () => {
const response = await call('agentSession.send', sendParams(), STRUCTURED_MOBILE_CLIENT, {
getClientSettings: () => ({ experimentalStructuredNativeChat: true })
})
expect(response).toMatchObject({ ok: true })
expect(hostCalls.send).toHaveBeenCalledTimes(1)
})
it('serves an in-process caller, which negotiates no capabilities at all', async () => {
const response = await call('agentSession.send', sendParams())
expect(response).toMatchObject({ ok: true })
@@ -1,11 +1,13 @@
import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import type { RpcContext } from '../core'
import { supportsStructuredAgentSessions } from './structured-agent-session-policy'
export async function restoreStructuredTabsIfSupported(
runtime: RpcContext['runtime'],
capabilities: readonly string[] | undefined
context: Pick<RpcContext, 'runtime' | 'clientKind' | 'clientCapabilities'>
): Promise<void> {
if (capabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY)) {
await runtime.restoreStructuredAgentSessionTabs()
if (
supportsStructuredAgentSessions(context) &&
typeof context.runtime.restoreStructuredAgentSessionTabs === 'function'
) {
await context.runtime.restoreStructuredAgentSessionTabs()
}
}
@@ -113,6 +113,7 @@ export class RpcStreamingDispatcher {
pairedDeviceId: options?.pairedDeviceId,
clientKind: options?.clientKind,
clientCapabilities: options?.clientCapabilities,
updateClientCapabilities: options?.updateClientCapabilities,
orchestrationCapability: request.orchestrationCapability,
authenticatedCallerFingerprint:
mutation?.identity.callerFingerprint ??
@@ -165,6 +166,7 @@ export class RpcStreamingDispatcher {
pairedDeviceId: options?.pairedDeviceId,
clientKind: options?.clientKind,
clientCapabilities: options?.clientCapabilities,
updateClientCapabilities: options?.updateClientCapabilities,
orchestrationCapability: request.orchestrationCapability,
pairing: options?.pairing,
sendBinary: options?.sendBinary,
@@ -32,6 +32,7 @@ export type RuntimeClientSettings = Pick<
| 'defaultLinearTeamSelection'
| 'githubProjects'
| 'experimentalNewWorktreeCardStyle'
| 'experimentalStructuredNativeChat'
| 'compactWorktreeCards'
| 'minimaxGroupId'
| 'minimaxUsageModels'
@@ -97,6 +98,7 @@ export class RuntimeClientSettingsController {
defaultLinearTeamSelection: settings.defaultLinearTeamSelection ?? null,
githubProjects: settings.githubProjects,
experimentalNewWorktreeCardStyle: settings.experimentalNewWorktreeCardStyle === true,
experimentalStructuredNativeChat: settings.experimentalStructuredNativeChat === true,
compactWorktreeCards: settings.compactWorktreeCards === true,
minimaxGroupId: settings.minimaxGroupId ?? '',
minimaxUsageModels: settings.minimaxUsageModels ?? 'general',
@@ -188,6 +188,7 @@ export const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'repo.searchRefs',
'repo.sparsePresets',
'repo.update',
'runtime.clientCapabilities.update',
'runtime.clientEvents.subscribe',
'runtime.clientEvents.unsubscribe',
'session.tabs.activate',
@@ -201,6 +202,22 @@ export const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'session.tabs.subscribeAll',
'session.tabs.unsubscribe',
'session.tabs.unsubscribeAll',
'agentSession.createSupport',
'agentSession.create',
'agentSession.ensure',
'agentSession.send',
'agentSession.cancel',
'agentSession.close',
'agentSession.respondToApproval',
'agentSession.respondToQuestion',
'agentSession.setOption',
'agentSession.handoffStatus',
'agentSession.options',
'agentSession.history',
'agentSession.subscribe',
'agentSession.unsubscribe',
'agentSession.hold',
'agentSession.release',
'nativeChat.readSession',
'nativeChat.subscribe',
'nativeChat.unsubscribe',
@@ -141,6 +141,12 @@ export class RuntimeRpcWebSocketDispatch extends RuntimeRpcRequestAdmission {
// Why: gates the mobile-only payload diet so full-screen web/desktop clients aren't truncated.
clientKind: device.scope,
clientCapabilities: authenticatedSocket?.clientCapabilities,
updateClientCapabilities:
authenticatedSocket && device.scope === 'mobile'
? (clientCapabilities) => {
authenticatedSocket.clientCapabilities = clientCapabilities
}
: undefined,
pairing: pairingContext,
signal: abortRegistration?.signal,
sendBinary,
@@ -87,6 +87,7 @@ export type RuntimeStore = {
terminalWindowsShell?: GlobalSettings['terminalWindowsShell']
floatingTerminalEnabled?: GlobalSettings['floatingTerminalEnabled']
agentStatusHooksEnabled?: GlobalSettings['agentStatusHooksEnabled']
experimentalStructuredNativeChat?: GlobalSettings['experimentalStructuredNativeChat']
defaultTaskSource?: GlobalSettings['defaultTaskSource']
defaultTaskViewPreset?: GlobalSettings['defaultTaskViewPreset']
visibleTaskProviders?: GlobalSettings['visibleTaskProviders']
@@ -122,4 +123,5 @@ export type RuntimeStore = {
updates: Partial<GlobalSettings>,
options?: { notifyListeners?: boolean; originWebContentsId?: number }
) => unknown
onSettingsChanged?: Store['onSettingsChanged']
}
@@ -274,9 +274,11 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta
await timeRendererStartupStep('recover-legacy-worker-terminals-post-reconnect', () =>
window.api.app.recoverLegacyWorkerTerminalsForRendererStartup()
)
await timeRendererStartupStep('project-structured-session-tabs', () =>
restoreLocalStructuredSessionTabsOnce()
)
if (useAppStore.getState().settings?.experimentalStructuredNativeChat === true) {
await timeRendererStartupStep('project-structured-session-tabs', () =>
restoreLocalStructuredSessionTabsOnce()
)
}
if (cancelled) {
return
}
@@ -357,6 +357,16 @@ describe('renderer startup runtime routing', () => {
expect(reconnectIndex).toBeGreaterThan(capabilityIndex)
})
it('skips startup structured tab projection while the host setting is off', () => {
const source = readSource(STARTUP_HYDRATION_PATH)
const projectIndex = source.indexOf("timeRendererStartupStep('project-structured-session-tabs'")
expect(projectIndex).toBeGreaterThanOrEqual(0)
expect(source.slice(projectIndex - 180, projectIndex)).toContain(
'settings?.experimentalStructuredNativeChat === true'
)
})
it('orders packaged restoration before adoption, projection, and default creation', () => {
// Why this file: the startup sequence moved out of App.tsx into the hydration hook;
// the ordering it asserts is unchanged, only the module that now spells it out.
@@ -1,36 +1 @@
import type {
AgentJournalRenderItem,
AgentJournalSubmission
} from '../../../../shared/agent-session-journal-types'
import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key'
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import {
reconcileStructuredAgentSessionOutbox,
type StructuredAgentSessionOutboxEntry
} from '../../../../shared/structured-agent-session-outbox'
import { projectStructuredItemsToNativeChat } from '../../../../shared/structured-agent-session-projection'
export function projectStructuredAgentSessionMessages(
items: readonly AgentJournalRenderItem[],
outbox: readonly StructuredAgentSessionOutboxEntry[],
submissions: readonly AgentJournalSubmission[]
): NativeChatMessage[] {
const optimistic = reconcileStructuredAgentSessionOutbox(outbox, submissions)
// Why: the host renders its own bubble off the submission WAL row, which lands
// while the dispatch is still `pending`. Reconciliation only retires the echo on
// `accepted`, so keying visibility on that alone double-rendered the bubble for
// the whole provider round trip. The entry itself stays for retry/unconfirmed.
const journalled = new Set(items.map((item) => item.itemId))
return [
...projectStructuredItemsToNativeChat(items),
...optimistic
.filter((entry) => !journalled.has(agentJournalSubmissionKey(entry.clientMessageId)))
.map((entry): NativeChatMessage => ({
id: agentJournalSubmissionKey(entry.clientMessageId),
role: 'user',
source: 'transcript',
timestamp: entry.queuedAt,
blocks: entry.body.blocks
}))
]
}
export { projectStructuredAgentSessionMessages } from '../../../../shared/structured-agent-session-message-projection'
@@ -10,16 +10,10 @@
// would otherwise release a hold that has not landed yet, and the late hold would never be undone.
import { useEffect, useRef } from 'react'
import { structuredAgentSessionHolderId } from '../../../../shared/structured-agent-session-holder'
import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client'
let holderOrdinal = 0
export function structuredAgentSessionHolderId(surface: string): string {
holderOrdinal += 1
return `${surface}:${holderOrdinal}`
}
export function useStructuredAgentSessionHold(args: {
sessionId: string
target: RuntimeClientTarget
@@ -282,7 +282,10 @@ export async function startLocalStructuredSessionTabsSync(args: {
export function useLocalStructuredSessionTabsSync(): void {
const ready = useAppStore(
(state) => state.workspaceSessionReady && state.terminalStartupRestorationReady
(state) =>
state.workspaceSessionReady &&
state.terminalStartupRestorationReady &&
state.settings?.experimentalStructuredNativeChat === true
)
useEffect(() => {
if (!ready) {
@@ -0,0 +1,6 @@
let holderOrdinal = 0
export function structuredAgentSessionHolderId(surface: string): string {
holderOrdinal += 1
return `${surface}:${holderOrdinal}`
}
@@ -0,0 +1,29 @@
import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types'
import { agentJournalSubmissionKey } from './agent-session-journal-item-key'
import type { NativeChatMessage } from './native-chat-types'
import {
reconcileStructuredAgentSessionOutbox,
type StructuredAgentSessionOutboxEntry
} from './structured-agent-session-outbox'
import { projectStructuredItemsToNativeChat } from './structured-agent-session-projection'
export function projectStructuredAgentSessionMessages(
items: readonly AgentJournalRenderItem[],
outbox: readonly StructuredAgentSessionOutboxEntry[],
submissions: readonly AgentJournalSubmission[]
): NativeChatMessage[] {
const optimistic = reconcileStructuredAgentSessionOutbox(outbox, submissions)
const journalled = new Set(items.map((item) => item.itemId))
return [
...projectStructuredItemsToNativeChat(items),
...optimistic
.filter((entry) => !journalled.has(agentJournalSubmissionKey(entry.clientMessageId)))
.map((entry): NativeChatMessage => ({
id: agentJournalSubmissionKey(entry.clientMessageId),
role: 'user',
source: 'transcript',
timestamp: entry.queuedAt,
blocks: entry.body.blocks
}))
]
}
@@ -35,6 +35,7 @@ const SESSION = 'session-alpha'
const WORKSPACE = 'workspace-1'
const THREAD = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60'
const NOW = 1_800_000_000_000
const CLIENT_CAPABILITY_UPDATE_METHOD = 'runtime.clientCapabilities.update'
/** Every method the structured surface publishes: the host method it must reach,
* and the result it must hand back. A gate that hides one method and leaks
@@ -484,6 +485,49 @@ describe('cross-version structured agent sessions', () => {
)
})
describe('post-auth mobile capability negotiation', () => {
it('is an additive method that lets the current host record mobile capabilities', async () => {
const updates: string[][] = []
const replies = await callBuild(
current,
CLIENT_CAPABILITY_UPDATE_METHOD,
{ clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] },
{
clientKind: 'mobile',
clientCapabilities: [],
updateClientCapabilities: (capabilities) => updates.push([...capabilities])
}
)
expect(current.methodNames).toContain(CLIENT_CAPABILITY_UPDATE_METHOD)
expect(current.protocolVersion).toBe(baseline.protocolVersion)
expect(replies).toHaveLength(1)
expect(replies[0]).toMatchObject({
ok: true,
result: { clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] }
})
expect(updates).toEqual([[STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]])
})
it('gets a normal answer from an old host instead of changing the auth shape', async () => {
const replies = await callBuild(
baseline,
CLIENT_CAPABILITY_UPDATE_METHOD,
{ clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] },
{ clientKind: 'mobile', clientCapabilities: [] }
)
expect(replies).toHaveLength(1)
if (!baseline.methodNames.includes(CLIENT_CAPABILITY_UPDATE_METHOD)) {
expect(replies[0]).toMatchObject({
ok: false,
error: { code: 'method_not_found' }
})
}
})
})
describe('an old client against a structured-owned AI Vault row', () => {
let root: string
let store: AgentSessionRecordStore
@@ -29,6 +29,7 @@ export type RpcReply = {
export type RpcClientIdentity = {
clientKind?: 'mobile' | 'runtime'
clientCapabilities?: readonly string[]
updateClientCapabilities?: (capabilities: readonly string[]) => void
connectionId?: string
clientId?: string
}