mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
feat(mobile): structured native Codex chat (#18074)
* feat(mobile): finalize structured native Codex chat * fix(mobile): close structured chat lifecycle gaps * wip(mobile): fence stale structured inventory and bound operation-id retention Fence local structured-session inventory and subscription responses with a sync generation so a toggle-off clear, reconnect restore, or retry cannot apply a mirror from a superseded instance. Bound mobile ambiguous operation-ID retention at 128 with unmount cleanup. Staged on the reconcile branch only: the sync module is now 312 lines and needs a real split before this can reach the PR head. * fix(ci): split the structured session-tabs sync and give static analysis mobile types The local structured session-tabs sync module outgrew the 300-line cap once it took on generation fencing, so split it along its real seams instead of raising the cap: the generation/cursor fence, snapshot projection, snapshot apply, inventory refresh, and the subscription loop. The original path stays as a barrel so no importer moves. Repoint the host-session-mirror settle census at the apply module, which owns two receipts now — the snapshot it mirrors in, and the toggle-off teardown that retracts what it published. The teardown receipt is named rather than anonymous so the pin says which direction it settles. The changed-code quality gate lints mobile files and resolves their types from mobile/node_modules, but mobile is a separate pnpm project that the root install never populates, so every mobile type degraded to an `error` type and the gate reported phantom findings. Install mobile dependencies in static analysis when the diff touches mobile, gated on a new classifier output. * fix(mobile): let a slow capability handshake still reach connected The mobile capability update is an advisory whose result is discarded, yet an unanswered one was fatal while an explicit rejection was tolerated. A 5s timeout on the direct client force-closed the socket, and on the relay path it failed `confirmResume` before `connected` was ever published, so a consistently slow link redialled forever. Both paths now share one helper that settles every ambiguous outcome (timeout, mid-flight drop) like a rejection and rejects only when the frame never reached the wire — the one case nothing else recovers from, since the socket's own desync force-close is gated on already being connected. The generation guard still keeps a replaced session from connecting. Retained structured-session operation ids were capped at 128 with oldest-first eviction, but every retained id belongs to a send whose outcome is unknown, so eviction turned a user's retry into a second message on the host. Bound the map by expiry against the id's own embedded timestamp instead, mirroring the host's operation ledger, so no id is released while the host would still honour it. Also give the mobile CI install the root install's lockfile drift guard (mobile's lockfile carries patchedDependencies a silent rewrite would drop), gate mobile_dependencies on should_run, and key the pnpm store cache on both lockfiles. * refactor(mobile): extract the relay pending-request registry The merge composed two independently-sized changes — this branch's capability handshake settle and main's dial-stage tracking — pushing the relay session file to 304 lines against a 300 cap. Neither side broke it alone. Move the in-flight request registry (id generation, tracking, settlement, and reject-all with its delivery-ambiguity marking) into RelayPendingRequests, matching the existing collaborator pattern alongside RelayDialStageTracker and RpcSessionLivenessWatchdog. No behavior change. --------- Co-authored-by: Merge Sim <sim@local>
This commit is contained in:
co-authored by
Merge Sim
parent
c79e1c097b
commit
98e77ef1a7
@@ -39,6 +39,9 @@ runs:
|
||||
with:
|
||||
install: false
|
||||
|
||||
# Why both lockfiles: setup-node keys the pnpm store on the root lockfile alone, so
|
||||
# jobs that also install mobile restored a store with none of the React Native tree
|
||||
# in it and re-downloaded the lot on every run.
|
||||
- name: Setup Node.js
|
||||
id: default-node
|
||||
if: inputs.node-version == ''
|
||||
@@ -46,6 +49,9 @@ runs:
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
cache-dependency-path: |
|
||||
pnpm-lock.yaml
|
||||
mobile/pnpm-lock.yaml
|
||||
|
||||
- name: Setup requested Node.js
|
||||
id: requested-node
|
||||
@@ -54,6 +60,9 @@ runs:
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
cache: pnpm
|
||||
cache-dependency-path: |
|
||||
pnpm-lock.yaml
|
||||
mobile/pnpm-lock.yaml
|
||||
|
||||
- name: Validate native runtime
|
||||
shell: bash
|
||||
|
||||
@@ -28,6 +28,7 @@ jobs:
|
||||
outputs:
|
||||
should_run: ${{ steps.filter.outputs.should_run }}
|
||||
native_cache_changed: ${{ steps.filter.outputs.native_cache_changed }}
|
||||
mobile_dependencies: ${{ steps.filter.outputs.mobile_dependencies }}
|
||||
static_analysis: ${{ steps.filter.outputs.static_analysis }}
|
||||
typecheck: ${{ steps.filter.outputs.typecheck }}
|
||||
git_compatibility: ${{ steps.filter.outputs.git_compatibility }}
|
||||
@@ -95,6 +96,25 @@ jobs:
|
||||
- name: Enforce type-aware code-quality baseline
|
||||
run: pnpm run audit:code-quality:type-aware
|
||||
|
||||
# Why: the changed-code gate lints mobile files too, and its type-aware pass
|
||||
# resolves types from mobile/node_modules. Mobile is a separate pnpm project,
|
||||
# so the root install above leaves it empty and every mobile type degrades to
|
||||
# an `error` type — reported as phantom findings against the changed lines.
|
||||
# Why no --ignore-scripts, unlike the root install: mobile's postinstall generates
|
||||
# the gitignored terminal/mermaid webview engine modules that tracked source imports,
|
||||
# and skipping it degrades those very types the step exists to resolve. The drift
|
||||
# guard mirrors the root install so a stale mobile lockfile fails by name — mobile's
|
||||
# lockfile carries patchedDependencies that a silent rewrite would drop.
|
||||
- name: Install mobile dependencies
|
||||
if: needs.code_paths.outputs.mobile_dependencies == 'true'
|
||||
working-directory: mobile
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
if [ "$(git -C "$GITHUB_WORKSPACE" rev-parse --is-inside-work-tree 2>/dev/null)" = true ]; then
|
||||
git -C "$GITHUB_WORKSPACE" diff --exit-code -- \
|
||||
mobile/package.json mobile/pnpm-lock.yaml mobile/pnpm-workspace.yaml
|
||||
fi
|
||||
|
||||
- name: Enforce changed-code quality
|
||||
run: pnpm run check:code-quality:changed -- "${{ github.event.pull_request.base.sha }}"
|
||||
|
||||
|
||||
@@ -263,6 +263,14 @@ export function shouldRunPrChecks(changedFiles) {
|
||||
return changedFiles.some((file) => !isDocsOnlyPath(file) && !isDesktopIrrelevantPath(file))
|
||||
}
|
||||
|
||||
export function needsMobileDependencies(changedFiles) {
|
||||
// Why: static analysis lints CHANGED files, mobile ones included, and its
|
||||
// type-aware pass resolves types from mobile/node_modules. Mobile is a
|
||||
// separate pnpm project, so without this the root-only install leaves every
|
||||
// mobile type an `error` type and the gate reports phantom findings.
|
||||
return changedFiles.length === 0 || changedFiles.some((file) => file.startsWith('mobile/'))
|
||||
}
|
||||
|
||||
export function classifyPrJobs(changedFiles) {
|
||||
const emptyDiff = changedFiles.length === 0
|
||||
const shouldRun = shouldRunPrChecks(changedFiles)
|
||||
@@ -276,6 +284,7 @@ export function classifyPrJobs(changedFiles) {
|
||||
return {
|
||||
should_run: shouldRun,
|
||||
native_cache_changed: shouldRun && (emptyDiff || changedFiles.some(isNativeCacheInputPath)),
|
||||
mobile_dependencies: shouldRun && needsMobileDependencies(changedFiles),
|
||||
...jobs
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,6 +316,24 @@ describe('per-job path classification', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Why: static analysis lints changed mobile files with a type-aware pass, and
|
||||
// mobile is a separate pnpm project. Without its node_modules every mobile type
|
||||
// resolves to an `error` type and the changed-code gate fails on phantom
|
||||
// findings, which is exactly how a react-test-renderer union broke a PR.
|
||||
it('installs mobile dependencies exactly when mobile files change', () => {
|
||||
expect(classifyPrJobs([]).mobile_dependencies).toBe(true)
|
||||
expect(classifyPrJobs(['README.md']).mobile_dependencies).toBe(false)
|
||||
expect(classifyPrJobs(['src/main/index.ts']).mobile_dependencies).toBe(false)
|
||||
expect(
|
||||
classifyPrJobs(['src/main/index.ts', 'mobile/src/session/a.test.ts']).mobile_dependencies
|
||||
).toBe(true)
|
||||
// Why false: a mobile-only diff skips every desktop job, so the install step's own
|
||||
// job never runs and claiming the install is needed contradicts should_run.
|
||||
expect(classifyPrJobs(['mobile/package.json']).mobile_dependencies).toBe(false)
|
||||
expect(classifyPrJobs(['mobile/package.json']).should_run).toBe(false)
|
||||
expect(classifyPrJobs(['README.md', 'mobile/src/a.ts']).mobile_dependencies).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps unit-test-only diffs out of packaging', () => {
|
||||
expectClassification(['src/main/git/git-status.test.ts'], {
|
||||
git_compatibility: true
|
||||
@@ -354,6 +372,20 @@ describe('PR Checks skip wiring', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('gives static analysis the mobile types its type-aware pass resolves', () => {
|
||||
expect(prWorkflow.jobs.code_paths.outputs.mobile_dependencies).toBe(
|
||||
'${{ steps.filter.outputs.mobile_dependencies }}'
|
||||
)
|
||||
const steps = prWorkflow.jobs.static_analysis.steps
|
||||
const install = steps.findIndex((step) => step.name === 'Install mobile dependencies')
|
||||
const gate = steps.findIndex((step) => step.name === 'Enforce changed-code quality')
|
||||
expect(install).toBeGreaterThan(-1)
|
||||
expect(install).toBeLessThan(gate)
|
||||
expect(steps[install].if).toBe("needs.code_paths.outputs.mobile_dependencies == 'true'")
|
||||
expect(steps[install]['working-directory']).toBe('mobile')
|
||||
expect(steps[install].run).toContain('--frozen-lockfile')
|
||||
})
|
||||
|
||||
it('keeps the cheap root-directory guard on docs-only PRs', () => {
|
||||
expect(prWorkflow.jobs.root_directory_guard.if).toBeUndefined()
|
||||
expect(prWorkflow.jobs.root_directory_guard.needs).toBeUndefined()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,142 @@
|
||||
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('never creates a legacy sibling after an unclassified create exception', async () => {
|
||||
const client = clientReturning({ ok: true, result: { supported: true } })
|
||||
client.sendRequest.mockImplementationOnce(async () => ({
|
||||
ok: true,
|
||||
result: { supported: true }
|
||||
}))
|
||||
client.sendRequest.mockRejectedValue(new Error('internal error after commit'))
|
||||
|
||||
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('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,158 @@
|
||||
import type {
|
||||
AgentSessionAttachResult,
|
||||
AgentSessionMutationResult
|
||||
} from '../../../src/shared/agent-session-wire'
|
||||
import { structuredAgentSessionPayloadFingerprint } from '../../../src/shared/structured-agent-session-mutation'
|
||||
import type { RpcClient } from '../transport/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.'
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
// 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,152 @@
|
||||
import {
|
||||
AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS,
|
||||
parseAgentSessionOperationTimestamp
|
||||
} from '../../../src/shared/agent-session-host-authority'
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounded by expiry, never by count: every retained id belongs to a send whose outcome is still
|
||||
* unknown, so dropping one turns the user's retry into a second message on the host. Only an id
|
||||
* the host would already refuse — unparseable, or past the window in which it can be admitted —
|
||||
* is safe to release, which matches the host's own tombstone retention.
|
||||
*/
|
||||
export function retainStructuredSessionOperationId(
|
||||
operationIds: Map<string, string>,
|
||||
key: string,
|
||||
operationId = structuredSessionOperationId(),
|
||||
now: number = Date.now()
|
||||
): string {
|
||||
operationIds.delete(key)
|
||||
operationIds.set(key, operationId)
|
||||
for (const [retainedKey, retainedId] of operationIds) {
|
||||
if (retainedKey === key) {
|
||||
continue
|
||||
}
|
||||
const timestamp = parseAgentSessionOperationTimestamp(retainedId)
|
||||
if (timestamp === null || now - timestamp > AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS) {
|
||||
operationIds.delete(retainedKey)
|
||||
}
|
||||
}
|
||||
return operationId
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS } from '../../../src/shared/agent-session-host-authority'
|
||||
import { retainStructuredSessionOperationId } from './mobile-structured-agent-session-rpc'
|
||||
|
||||
const NOW = 1_900_000_000_000
|
||||
|
||||
function operationIdAt(timestamp: number, entropy: string): string {
|
||||
return `${timestamp}-${entropy.repeat(32).slice(0, 32)}`
|
||||
}
|
||||
|
||||
describe('structured session operation retention', () => {
|
||||
it('keeps every unconfirmed operation id past the old 128-entry cap', () => {
|
||||
const operationIds = new Map<string, string>()
|
||||
for (let index = 0; index < 400; index += 1) {
|
||||
retainStructuredSessionOperationId(
|
||||
operationIds,
|
||||
`request-${index}`,
|
||||
operationIdAt(NOW, 'a'),
|
||||
NOW
|
||||
)
|
||||
}
|
||||
|
||||
expect(operationIds.size).toBe(400)
|
||||
// Why: the first send is exactly the one a retry would duplicate if it were evicted.
|
||||
expect(operationIds.get('request-0')).toBe(operationIdAt(NOW, 'a'))
|
||||
})
|
||||
|
||||
it('releases only ids the host would already refuse as expired', () => {
|
||||
const operationIds = new Map<string, string>()
|
||||
const expired = operationIdAt(NOW - AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS - 1, 'b')
|
||||
const admissible = operationIdAt(NOW - AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS, 'c')
|
||||
retainStructuredSessionOperationId(operationIds, 'stale', expired, NOW)
|
||||
retainStructuredSessionOperationId(operationIds, 'live', admissible, NOW)
|
||||
|
||||
retainStructuredSessionOperationId(operationIds, 'fresh', operationIdAt(NOW, 'd'), NOW)
|
||||
|
||||
expect(operationIds.has('stale')).toBe(false)
|
||||
expect(operationIds.get('live')).toBe(admissible)
|
||||
expect(operationIds.get('fresh')).toBe(operationIdAt(NOW, 'd'))
|
||||
})
|
||||
|
||||
it('drops ids the host could never admit and re-keys a repeated send', () => {
|
||||
const operationIds = new Map<string, string>()
|
||||
retainStructuredSessionOperationId(operationIds, 'unparseable', 'not-an-operation-id', NOW)
|
||||
const reused = retainStructuredSessionOperationId(
|
||||
operationIds,
|
||||
'send',
|
||||
operationIdAt(NOW, 'e'),
|
||||
NOW
|
||||
)
|
||||
|
||||
// A retry of the same send reuses the retained id rather than minting a duplicate.
|
||||
expect(
|
||||
retainStructuredSessionOperationId(operationIds, 'send', operationIds.get('send'), NOW)
|
||||
).toBe(reused)
|
||||
expect(operationIds.has('unparseable')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -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,51 @@ 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,
|
||||
sourceIdentity,
|
||||
enabled: showNativeChat,
|
||||
// Holds are connection-scoped; dropping this on transport loss lets the hook
|
||||
// reacquire the provider without clearing the cached transcript.
|
||||
connected: connState === 'connected',
|
||||
agent: activeChatStructured ? activeChatAgent : null,
|
||||
onSendError
|
||||
})
|
||||
const nativeChatSession = activeChatStructured
|
||||
? structuredNativeChat.session
|
||||
: legacyNativeChatSession
|
||||
const {
|
||||
composerText: chatComposerText,
|
||||
setComposerText: setChatComposerText,
|
||||
@@ -117,27 +132,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 +163,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 +172,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 +195,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 +212,7 @@ export function useMobileNativeChatController(args: {
|
||||
|
||||
const handleNativeChatStop = useMobileNativeChatStop({
|
||||
client,
|
||||
enabled: inputSendable,
|
||||
enabled: inputSendable && !activeChatStructured,
|
||||
handleRef: activeHandleRef,
|
||||
deviceTokenRef,
|
||||
streamIdentity,
|
||||
@@ -216,11 +233,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 +251,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 +307,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,849 @@
|
||||
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 snapshotWithMessage(): AgentSessionSubscribeEvent {
|
||||
const event = snapshotEvent()
|
||||
return {
|
||||
...event,
|
||||
page: {
|
||||
...event.page,
|
||||
items: [
|
||||
{
|
||||
itemId: 'msg-1',
|
||||
revision: 1,
|
||||
sequence: 1,
|
||||
observedAt: 10,
|
||||
body: {
|
||||
kind: 'message',
|
||||
role: 'user',
|
||||
blocks: [{ type: 'text', text: 'sent before the blip' }]
|
||||
}
|
||||
}
|
||||
],
|
||||
window: {
|
||||
oldest: { epoch: 'epoch-1', sequence: 1 },
|
||||
newest: { epoch: 'epoch-1', sequence: 1 },
|
||||
nextCursor: { epoch: 'epoch-1', sequence: 2 }
|
||||
},
|
||||
liveCursor: { epoch: 'epoch-1', sequence: 1 }
|
||||
}
|
||||
} as AgentSessionSubscribeEvent
|
||||
}
|
||||
|
||||
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,
|
||||
sourceIdentity = 'host-a\0workspace-a'
|
||||
}: {
|
||||
sessionId?: string | null
|
||||
agent?: string | null
|
||||
connected?: boolean
|
||||
sourceIdentity?: string
|
||||
}): null {
|
||||
hook = useMobileStructuredAgentSession({
|
||||
client,
|
||||
sessionId,
|
||||
sourceIdentity,
|
||||
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)
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the transcript visible while reconnecting', async () => {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { connected: true }))
|
||||
})
|
||||
await vi.waitFor(() => expect(listener).toEqual(expect.any(Function)))
|
||||
act(() => listener?.(snapshotWithMessage()))
|
||||
expect(hook?.session.messages).toHaveLength(1)
|
||||
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(Harness, { connected: false }))
|
||||
})
|
||||
expect(hook?.session.messages).toHaveLength(1)
|
||||
expect(hook?.session.status).toBe('ready')
|
||||
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(Harness, { connected: true }))
|
||||
})
|
||||
expect(hook?.session.messages).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('restores the correct cached transcript when switching tabs offline', async () => {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { connected: true, sessionId: 'session-1' }))
|
||||
})
|
||||
await vi.waitFor(() => expect(listener).toEqual(expect.any(Function)))
|
||||
act(() => listener?.(snapshotWithMessage()))
|
||||
expect(hook?.session.messages).toHaveLength(1)
|
||||
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(Harness, { connected: false, sessionId: 'session-2' }))
|
||||
})
|
||||
expect(hook?.session.messages).toEqual([])
|
||||
expect(hook?.session.status).toBe('idle')
|
||||
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(Harness, { connected: false, sessionId: 'session-1' }))
|
||||
})
|
||||
expect(hook?.session.messages).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('isolates matching provider session ids across host and workspace sources', async () => {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(Harness, {
|
||||
connected: true,
|
||||
sessionId: 'session-1',
|
||||
sourceIdentity: 'host-a\0workspace-a'
|
||||
})
|
||||
)
|
||||
})
|
||||
await vi.waitFor(() => expect(listener).toEqual(expect.any(Function)))
|
||||
act(() => listener?.(snapshotWithMessage()))
|
||||
expect(hook?.session.messages).toHaveLength(1)
|
||||
|
||||
await act(async () => {
|
||||
renderer?.update(
|
||||
createElement(Harness, {
|
||||
connected: false,
|
||||
sessionId: 'session-1',
|
||||
sourceIdentity: 'host-b\0workspace-b'
|
||||
})
|
||||
)
|
||||
})
|
||||
expect(hook?.session.messages).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,315 @@
|
||||
import { useCallback, useEffect, 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 { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention'
|
||||
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,
|
||||
retainStructuredSessionOperationId as retainStructuredOpId,
|
||||
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
|
||||
/** Host/workspace scope used to keep same provider ids isolated. */
|
||||
sourceIdentity?: string
|
||||
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, sourceIdentity = '', enabled, onSendError } = args
|
||||
const sessionKey = encodeNativeChatTranscriptIdentity([sourceIdentity, agent, sessionId])
|
||||
const operationIdsRef = useRef(new Map<string, string>())
|
||||
useEffect(() => () => operationIdsRef.current.clear(), [])
|
||||
const retainOperationId = (key: string, operationId?: string): string =>
|
||||
retainStructuredOpId(operationIdsRef.current, key, operationId)
|
||||
const stateArgs = { client, sessionId, sessionKey, enabled, connected }
|
||||
const { state, stateRef, loadingOlder, loadEarlier } = useMobileStructuredAgentState(stateArgs)
|
||||
|
||||
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 = `${sessionKey}:${fingerprintMethod}:${JSON.stringify(fields)}`
|
||||
const clientOperationId = retainOperationId(key, operationIdsRef.current.get(key))
|
||||
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, sessionKey]
|
||||
)
|
||||
|
||||
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 = `${sessionKey}:agentSession.send:${JSON.stringify(fields)}`
|
||||
const priorOperationId = operationIdsRef.current.get(key)
|
||||
const clientOperationId = retainOperationId(key, priorOperationId)
|
||||
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, sessionKey]
|
||||
)
|
||||
|
||||
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 = `${sessionKey}:agentSession.cancel:${JSON.stringify(fields)}`
|
||||
const clientOperationId = retainOperationId(key, operationIdsRef.current.get(key))
|
||||
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, sessionKey])
|
||||
|
||||
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,198 @@
|
||||
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'
|
||||
|
||||
const MAX_RETAINED_SESSION_STATES = 32
|
||||
|
||||
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
|
||||
sessionKey: string | null
|
||||
enabled: boolean
|
||||
/** Live transport only. The hold dies with the connection and has to be retaken,
|
||||
* but the transcript must survive the outage rather than blank out with it. */
|
||||
connected: boolean
|
||||
}): {
|
||||
state: StructuredAgentSessionState
|
||||
stateRef: { readonly current: StructuredAgentSessionState }
|
||||
loadingOlder: boolean
|
||||
loadEarlier: () => void
|
||||
} {
|
||||
const { client, connected, enabled, sessionId, sessionKey } = args
|
||||
// Keep a bounded cache so offline tab switches select the right transcript
|
||||
// synchronously without growing for the lifetime of the app.
|
||||
const [sessionStates, setSessionStates] = useState<Map<string, StructuredAgentSessionState>>(
|
||||
() => new Map()
|
||||
)
|
||||
const state =
|
||||
enabled && sessionKey
|
||||
? (sessionStates.get(sessionKey) ?? EMPTY_STRUCTURED_AGENT_SESSION)
|
||||
: EMPTY_STRUCTURED_AGENT_SESSION
|
||||
const [loadingOlder, setLoadingOlder] = useState(false)
|
||||
const stateRef = useRef(state)
|
||||
const sessionKeyRef = useRef(sessionKey)
|
||||
const streamGenerationRef = useRef(0)
|
||||
useLayoutEffect(() => {
|
||||
stateRef.current = state
|
||||
sessionKeyRef.current = sessionKey
|
||||
}, [sessionKey, state])
|
||||
|
||||
const apply = useCallback(
|
||||
(action: StructuredAgentSessionAction) => {
|
||||
if (!sessionKey) {
|
||||
return
|
||||
}
|
||||
setSessionStates((current) => {
|
||||
const previous = current.get(sessionKey) ?? EMPTY_STRUCTURED_AGENT_SESSION
|
||||
const next = reduceStructuredAgentSession(previous, action)
|
||||
if (next === previous) {
|
||||
return current
|
||||
}
|
||||
const updated = new Map(current)
|
||||
updated.delete(sessionKey)
|
||||
updated.set(sessionKey, next)
|
||||
while (updated.size > MAX_RETAINED_SESSION_STATES) {
|
||||
const oldest = updated.keys().next().value
|
||||
if (oldest === undefined) {
|
||||
break
|
||||
}
|
||||
updated.delete(oldest)
|
||||
}
|
||||
return updated
|
||||
})
|
||||
},
|
||||
[sessionKey]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
streamGenerationRef.current += 1
|
||||
sessionKeyRef.current = sessionKey
|
||||
setLoadingOlder(false)
|
||||
if (!client || !sessionId || !enabled) {
|
||||
return
|
||||
}
|
||||
if (!connected) {
|
||||
// The cleanup above drops the dead hold and stream; keyed state keeps this
|
||||
// session's transcript visible while another tab can be selected.
|
||||
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, sessionKey])
|
||||
|
||||
const loadEarlier = useCallback(() => {
|
||||
const current = stateRef.current
|
||||
if (!client || !sessionId || !sessionKey || loadingOlder || !current.hasOlder) {
|
||||
return
|
||||
}
|
||||
const cursor = oldestStructuredAgentSessionCursor(current)
|
||||
if (!cursor) {
|
||||
return
|
||||
}
|
||||
const requestSessionKey = sessionKey
|
||||
const requestGeneration = streamGenerationRef.current
|
||||
setLoadingOlder(true)
|
||||
void callAgentSession<AgentSessionHistoryResult>(client, 'agentSession.history', {
|
||||
sessionId,
|
||||
direction: 'before',
|
||||
cursor,
|
||||
limit: AGENT_SESSION_HISTORY_MAX_LIMIT
|
||||
})
|
||||
.then((result) => {
|
||||
if (
|
||||
result.ok &&
|
||||
sessionKeyRef.current === requestSessionKey &&
|
||||
streamGenerationRef.current === requestGeneration
|
||||
) {
|
||||
apply({ type: 'older-page', requestedEpoch: cursor.epoch, page: result.page })
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (
|
||||
sessionKeyRef.current === requestSessionKey &&
|
||||
streamGenerationRef.current === requestGeneration
|
||||
) {
|
||||
apply({ type: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (
|
||||
sessionKeyRef.current === requestSessionKey &&
|
||||
streamGenerationRef.current === requestGeneration
|
||||
) {
|
||||
setLoadingOlder(false)
|
||||
}
|
||||
})
|
||||
}, [apply, client, loadingOlder, sessionId, sessionKey])
|
||||
|
||||
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 }
|
||||
}
|
||||
@@ -19,6 +19,10 @@ vi.mock('./e2ee', () => ({
|
||||
decryptBytes: (bytes: Uint8Array) => bytes
|
||||
}))
|
||||
|
||||
vi.mock('./mobile-runtime-capability-negotiation', () => ({
|
||||
negotiateMobileRuntimeCapabilities: (args: { onReady: () => void }) => args.onReady()
|
||||
}))
|
||||
|
||||
type CarrierBehavior =
|
||||
// Carrier silently drops the SYN to a LAN/CGNAT destination: the socket sits
|
||||
// CONNECTING until the client's 12s connect timeout fires.
|
||||
|
||||
@@ -43,4 +43,8 @@ export class DirectConnectionLog {
|
||||
{ code: 'liveness-timeout' }
|
||||
)
|
||||
}
|
||||
|
||||
connected = (): void => {
|
||||
this.emit('success', 'Authenticated', 'Channel ready for RPC', { code: 'direct-connected' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { negotiateMobileRuntimeCapabilities } from './mobile-runtime-capability-negotiation'
|
||||
|
||||
const LIVENESS_REQUEST_ID_PREFIX = 'mobile-liveness-'
|
||||
|
||||
@@ -226,17 +227,22 @@ export class DirectRpcClient implements RpcClient {
|
||||
}
|
||||
|
||||
private handleAuthenticated(session: RpcClientSocketSession): void {
|
||||
console.log('[net] e2ee_authenticated — connected', { streamCount: this.streams.size() })
|
||||
this.livenessSession = session
|
||||
this.liveness.start(session)
|
||||
this.authenticationGeneration++
|
||||
this.reconnect.authenticated()
|
||||
this.authenticationRetry.accepted()
|
||||
this.connectionState.publish('connected')
|
||||
this.connectionLog.emit('success', 'Authenticated', 'Channel ready for RPC', {
|
||||
code: 'direct-connected'
|
||||
const generation = ++this.authenticationGeneration
|
||||
negotiateMobileRuntimeCapabilities({
|
||||
sendRequest: (method, params) =>
|
||||
this.requests.sendAuthenticatedRequest(method, params, 5_000),
|
||||
current: () => this.socketSession === session && this.authenticationGeneration === generation,
|
||||
onReady: () => {
|
||||
this.reconnect.authenticated()
|
||||
this.authenticationRetry.accepted()
|
||||
this.connectionState.publish('connected')
|
||||
this.connectionLog.connected()
|
||||
this.streams.replayAfterAuthentication()
|
||||
},
|
||||
onFailure: () => this.socketClose.forceClose(session)
|
||||
})
|
||||
this.streams.replayAfterAuthentication()
|
||||
}
|
||||
|
||||
private handleRpcResponse(response: RpcResponse): void {
|
||||
|
||||
@@ -25,6 +25,10 @@ vi.mock('./e2ee', () => ({
|
||||
decryptBytes: (bytes: Uint8Array) => bytes
|
||||
}))
|
||||
|
||||
vi.mock('./mobile-runtime-capability-negotiation', () => ({
|
||||
negotiateMobileRuntimeCapabilities: (args: { onReady: () => void }) => args.onReady()
|
||||
}))
|
||||
|
||||
// Mirrors React Native's WebSocket: readyState lives in JS and only advances on
|
||||
// a delivered event, so a socket the OS killed while the app was suspended stays
|
||||
// CONNECTING forever from the client's point of view.
|
||||
|
||||
@@ -74,6 +74,16 @@ async function authenticateSession(onLog?: ConnectionLogSink) {
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
)
|
||||
await vi.waitFor(() => expect(fakes.sendText).toHaveBeenCalledTimes(2))
|
||||
const capabilities = sentRequests()[1]!
|
||||
fakes.linkOptions!.onText(
|
||||
JSON.stringify({
|
||||
id: capabilities.id,
|
||||
ok: true,
|
||||
result: {},
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
)
|
||||
await vi.waitFor(() => expect(session.getState()).toBe('connected'))
|
||||
fakes.sendText.mockClear()
|
||||
return session
|
||||
|
||||
@@ -55,7 +55,7 @@ function openSession() {
|
||||
})
|
||||
}
|
||||
|
||||
async function authenticateSession() {
|
||||
async function confirmResume() {
|
||||
const session = openSession()
|
||||
fakes.linkOptions!.onHello({
|
||||
type: 'relay-hello',
|
||||
@@ -93,9 +93,39 @@ async function authenticateSession() {
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
)
|
||||
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[] }
|
||||
}
|
||||
return { session, confirmationRequest: request, capabilityRequest }
|
||||
}
|
||||
|
||||
async function authenticateSession(capabilitySupported = true) {
|
||||
const { session, confirmationRequest, capabilityRequest } = await confirmResume()
|
||||
expect(session.getState()).toBe('handshaking')
|
||||
fakes.linkOptions!.onText(
|
||||
JSON.stringify(
|
||||
capabilitySupported
|
||||
? {
|
||||
id: capabilityRequest.id,
|
||||
ok: true,
|
||||
result: capabilityRequest.params,
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
}
|
||||
: {
|
||||
id: capabilityRequest.id,
|
||||
ok: false,
|
||||
error: { code: 'method_not_found', message: 'Unknown method' },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
}
|
||||
)
|
||||
)
|
||||
await vi.waitFor(() => expect(session.getState()).toBe('connected'))
|
||||
fakes.sendText.mockClear()
|
||||
return { session, confirmationRequest: request }
|
||||
return { session, confirmationRequest, capabilityRequest }
|
||||
}
|
||||
|
||||
describe('mobile relay RPC session', () => {
|
||||
@@ -107,7 +137,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,
|
||||
@@ -121,9 +151,32 @@ 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))
|
||||
})
|
||||
|
||||
it('connects when an older runtime rejects capability negotiation', async () => {
|
||||
const { session } = await authenticateSession(false)
|
||||
|
||||
expect(session.getState()).toBe('connected')
|
||||
expect(session.getFailure()).toBeNull()
|
||||
})
|
||||
|
||||
it('connects when the relay never answers capability negotiation', async () => {
|
||||
const { session } = await confirmResume()
|
||||
|
||||
// Why: the advisory's own deadline used to fail confirmResume, so a link too slow to
|
||||
// answer within the request timeout never published 'connected' — it just redialled.
|
||||
await vi.waitFor(() => expect(session.getState()).toBe('connected'), { timeout: 5_000 })
|
||||
expect(session.getFailure()).toBeNull()
|
||||
})
|
||||
|
||||
// Why: ConnectionState stays 'connecting' until relay-hello, so the migration bound
|
||||
// needs a separate signal to tell "cell never answered the upgrade" from "cell took
|
||||
// relay-auth and is still resolving the assignment".
|
||||
|
||||
@@ -10,7 +10,9 @@ import { markRpcDeliveryUnknown } from './rpc-delivery-ambiguity'
|
||||
import { openRpcRequestBudget, resolvePostConnectRequestTimeout } from './rpc-request-budget'
|
||||
import { isRpcResponse } from './rpc-response-shape'
|
||||
import { RelayDialStageTracker, type RelayDialStageSource } from './relay-dial-stage'
|
||||
import { RelayPendingRequests } from './relay-pending-requests'
|
||||
import { RpcSessionLivenessWatchdog } from './rpc-session-liveness-watchdog'
|
||||
import { settleMobileRuntimeCapabilities } from './mobile-runtime-capability-negotiation'
|
||||
import type { RpcClient } from './rpc-client'
|
||||
import type { ConnectionLogSink, ConnectionState, RpcResponse } from './types'
|
||||
|
||||
@@ -19,12 +21,6 @@ const RELAY_MISSED_PROBE_LIMIT = 2
|
||||
const RELAY_FOREGROUND_PROBE_MIN_INTERVAL_MS = 10_000
|
||||
let relayRpcSessionSequence = 0
|
||||
|
||||
type PendingRequest = {
|
||||
resolve: (response: RpcResponse) => void
|
||||
reject: (error: Error) => void
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
export type MobileRelayRpcSession = RpcClient &
|
||||
RelayDialStageSource & {
|
||||
// The cell's attach-reservation deadline (~10s). Diagnostics only — never
|
||||
@@ -47,10 +43,9 @@ export function connectMobileRelayRpcSession(args: {
|
||||
onLog?: ConnectionLogSink
|
||||
}): MobileRelayRpcSession {
|
||||
const requestTimeoutMs = args.requestTimeoutMs ?? 30_000
|
||||
const pending = new Map<string, PendingRequest>()
|
||||
const pending = new RelayPendingRequests()
|
||||
const stateListeners = new Set<(state: ConnectionState) => void>()
|
||||
let state: ConnectionState = 'connecting'
|
||||
let requestCounter = 0
|
||||
let lastConnectedAt: number | null = null
|
||||
let attachDeadlineAt: number | null = null
|
||||
let resumeExpiresAt: number | null = null
|
||||
@@ -62,7 +57,7 @@ export function connectMobileRelayRpcSession(args: {
|
||||
const livenessIdentity = {}
|
||||
const dialStage = new RelayDialStageTracker()
|
||||
const streams = new MobileRelayRpcStreams({
|
||||
nextId,
|
||||
nextId: () => pending.nextId(),
|
||||
sendFrame,
|
||||
waitForConnected: () => waitForConnected()
|
||||
})
|
||||
@@ -137,7 +132,7 @@ export function connectMobileRelayRpcSession(args: {
|
||||
closed = true
|
||||
livenessWatchdog.stop(livenessIdentity)
|
||||
link.close()
|
||||
rejectPending(new Error('Client closed'))
|
||||
pending.rejectAll(new Error('Client closed'))
|
||||
streams.clear()
|
||||
publishState('disconnected')
|
||||
},
|
||||
@@ -155,7 +150,8 @@ export function connectMobileRelayRpcSession(args: {
|
||||
missedProbeLimit: RELAY_MISSED_PROBE_LIMIT,
|
||||
voluntaryProbeMinIntervalMs: RELAY_FOREGROUND_PROBE_MIN_INTERVAL_MS,
|
||||
sendProbe: () =>
|
||||
state === 'connected' && sendFrame({ id: nextId(), method: 'status.get', params: undefined }),
|
||||
state === 'connected' &&
|
||||
sendFrame({ id: pending.nextId(), method: 'status.get', params: undefined }),
|
||||
onTimeout: (evidence) => {
|
||||
args.onLog?.({
|
||||
id: `relay-liveness-${logSessionId}-${++logSequence}`,
|
||||
@@ -190,6 +186,10 @@ export function connectMobileRelayRpcSession(args: {
|
||||
resumeConfirmation = result.resumeConfirmation
|
||||
resumeExpiresAt = result.resumeConfirmation.resumeExpiresAt
|
||||
lastConnectedAt = Date.now()
|
||||
// Why: an unanswered advisory must not keep a slow relay from ever reaching connected.
|
||||
await settleMobileRuntimeCapabilities((method, params) =>
|
||||
sendRpc(method, params, requestTimeoutMs, true)
|
||||
)
|
||||
livenessWatchdog.start(livenessIdentity)
|
||||
publishState('connected')
|
||||
} catch (error) {
|
||||
@@ -206,17 +206,17 @@ export function connectMobileRelayRpcSession(args: {
|
||||
if (closed || (!beforeConnected && state !== 'connected')) {
|
||||
return Promise.reject(new Error('relay session not connected'))
|
||||
}
|
||||
const id = nextId()
|
||||
const id = pending.nextId()
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete(id)
|
||||
pending.drop(id)
|
||||
// Why: the frame was written long ago — the desktop may have processed it.
|
||||
reject(markRpcDeliveryUnknown(new Error(`relay RPC timed out: ${method}`)))
|
||||
}, timeoutMs)
|
||||
pending.set(id, { resolve, reject, timer })
|
||||
pending.track(id, { resolve, reject, timer })
|
||||
if (!sendFrame({ id, method, params })) {
|
||||
clearTimeout(timer)
|
||||
pending.delete(id)
|
||||
pending.drop(id)
|
||||
reject(new Error('relay E2EE channel not ready'))
|
||||
}
|
||||
})
|
||||
@@ -236,11 +236,7 @@ export function connectMobileRelayRpcSession(args: {
|
||||
if (!isRpcResponse(value)) {
|
||||
return
|
||||
}
|
||||
const request = pending.get(value.id)
|
||||
if (request) {
|
||||
clearTimeout(request.timer)
|
||||
pending.delete(value.id)
|
||||
request.resolve(value)
|
||||
if (pending.settle(value)) {
|
||||
return
|
||||
}
|
||||
streams.handleResponse(value)
|
||||
@@ -296,28 +292,9 @@ export function connectMobileRelayRpcSession(args: {
|
||||
failure = error
|
||||
livenessWatchdog.stop(livenessIdentity)
|
||||
link.close()
|
||||
rejectPending(error)
|
||||
pending.rejectAll(error)
|
||||
publishState(error instanceof MobileE2EEAuthenticationError ? 'auth-failed' : 'disconnected')
|
||||
}
|
||||
|
||||
function rejectPending(error: Error): void {
|
||||
if (pending.size === 0) {
|
||||
return
|
||||
}
|
||||
// Why: pending entries only exist after their frame reached the authenticated
|
||||
// link (sendFrame failures delete them synchronously), so the desktop may
|
||||
// have processed them — mark the ambiguity for callers.
|
||||
markRpcDeliveryUnknown(error)
|
||||
for (const request of pending.values()) {
|
||||
clearTimeout(request.timer)
|
||||
request.reject(error)
|
||||
}
|
||||
pending.clear()
|
||||
}
|
||||
|
||||
function nextId(): string {
|
||||
return `relay-rpc-${++requestCounter}-${Date.now()}`
|
||||
}
|
||||
}
|
||||
|
||||
function asError(error: unknown): Error {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { negotiateMobileRuntimeCapabilities } from './mobile-runtime-capability-negotiation'
|
||||
import { markRpcDeliveryUnknown } from './rpc-delivery-ambiguity'
|
||||
import type { RpcResponse } from './types'
|
||||
|
||||
function negotiate(args: { reject: unknown; current?: boolean }): {
|
||||
onReady: ReturnType<typeof vi.fn>
|
||||
onFailure: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const onReady = vi.fn()
|
||||
const onFailure = vi.fn()
|
||||
negotiateMobileRuntimeCapabilities({
|
||||
sendRequest: () => Promise.reject(args.reject),
|
||||
current: () => args.current ?? true,
|
||||
onReady,
|
||||
onFailure
|
||||
})
|
||||
return { onReady, onFailure }
|
||||
}
|
||||
|
||||
describe('mobile runtime capability negotiation', () => {
|
||||
it('proceeds when the host never answers, so a slow link still reaches connected', async () => {
|
||||
const timedOut = markRpcDeliveryUnknown(
|
||||
new Error('Request timed out: runtime.clientCapabilities.update')
|
||||
)
|
||||
const { onReady, onFailure } = negotiate({ reject: timedOut })
|
||||
|
||||
await vi.waitFor(() => expect(onReady).toHaveBeenCalledTimes(1))
|
||||
expect(onFailure).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('proceeds when the socket drops the request mid-flight', async () => {
|
||||
const interrupted = markRpcDeliveryUnknown(new Error('Connection interrupted'))
|
||||
const { onReady, onFailure } = negotiate({ reject: interrupted })
|
||||
|
||||
await vi.waitFor(() => expect(onReady).toHaveBeenCalledTimes(1))
|
||||
expect(onFailure).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails a socket that could not put the advisory on the wire', async () => {
|
||||
const { onReady, onFailure } = negotiate({ reject: new Error('Connection interrupted') })
|
||||
|
||||
await vi.waitFor(() => expect(onFailure).toHaveBeenCalledTimes(1))
|
||||
expect(onReady).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves a replaced session alone on an unanswered request', async () => {
|
||||
const timedOut = markRpcDeliveryUnknown(new Error('Request timed out'))
|
||||
const { onReady, onFailure } = negotiate({ reject: timedOut, current: false })
|
||||
|
||||
await vi.waitFor(() => expect(onReady).not.toHaveBeenCalled())
|
||||
expect(onFailure).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves a replaced session alone on a successful response', async () => {
|
||||
const onReady = vi.fn()
|
||||
const onFailure = vi.fn()
|
||||
negotiateMobileRuntimeCapabilities({
|
||||
sendRequest: () =>
|
||||
Promise.resolve({ id: 'capability-1', ok: true, result: {} } as RpcResponse),
|
||||
current: () => false,
|
||||
onReady,
|
||||
onFailure
|
||||
})
|
||||
|
||||
await vi.waitFor(() => expect(onReady).not.toHaveBeenCalled())
|
||||
expect(onFailure).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
MOBILE_RUNTIME_CLIENT_CAPABILITY_UPDATE_METHOD,
|
||||
mobileRuntimeClientCapabilityUpdateParams
|
||||
} from './mobile-runtime-client-capabilities'
|
||||
import { isRpcDeliveryUnknown } from './rpc-delivery-ambiguity'
|
||||
import type { RpcResponse } from './types'
|
||||
|
||||
type CapabilityRequest = (method: string, params: unknown) => Promise<RpcResponse>
|
||||
|
||||
/**
|
||||
* The advisory is one-way and its result is discarded, so an unanswered request says nothing about
|
||||
* the link — only a frame that never reached the wire proves the socket cannot carry traffic.
|
||||
* Everything else (timeout, mid-flight drop) settles like an explicit rejection: capabilities
|
||||
* unavailable, proceed. Rejects for the unsent case alone.
|
||||
*/
|
||||
export async function settleMobileRuntimeCapabilities(
|
||||
sendRequest: CapabilityRequest
|
||||
): Promise<void> {
|
||||
let response: RpcResponse
|
||||
try {
|
||||
response = await sendRequest(
|
||||
MOBILE_RUNTIME_CLIENT_CAPABILITY_UPDATE_METHOD,
|
||||
mobileRuntimeClientCapabilityUpdateParams()
|
||||
)
|
||||
} catch (error) {
|
||||
if (!isRpcDeliveryUnknown(error)) {
|
||||
throw error
|
||||
}
|
||||
console.warn('[net] mobile capability negotiation unanswered — proceeding', error)
|
||||
return
|
||||
}
|
||||
if (!response.ok) {
|
||||
console.warn('[net] mobile capability negotiation unavailable', response.error.code)
|
||||
}
|
||||
}
|
||||
|
||||
export function negotiateMobileRuntimeCapabilities(args: {
|
||||
sendRequest: CapabilityRequest
|
||||
current: () => boolean
|
||||
onReady: () => void
|
||||
onFailure: () => void
|
||||
}): void {
|
||||
void settleMobileRuntimeCapabilities(args.sendRequest)
|
||||
.then(() => {
|
||||
if (args.current()) {
|
||||
args.onReady()
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!args.current()) {
|
||||
return
|
||||
}
|
||||
// Why: nothing else force-closes a socket that cannot send before `connected` is published.
|
||||
console.warn('[net] mobile capability negotiation could not be sent', error)
|
||||
args.onFailure()
|
||||
})
|
||||
}
|
||||
@@ -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,53 @@
|
||||
import { markRpcDeliveryUnknown } from './rpc-delivery-ambiguity'
|
||||
import type { RpcResponse } from './types'
|
||||
|
||||
type PendingRequest = {
|
||||
resolve: (response: RpcResponse) => void
|
||||
reject: (error: Error) => void
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
/** In-flight relay RPC requests awaiting their response frame, keyed by request id. */
|
||||
export class RelayPendingRequests {
|
||||
private readonly pending = new Map<string, PendingRequest>()
|
||||
private requestCounter = 0
|
||||
|
||||
nextId(): string {
|
||||
return `relay-rpc-${++this.requestCounter}-${Date.now()}`
|
||||
}
|
||||
|
||||
track(id: string, request: PendingRequest): void {
|
||||
this.pending.set(id, request)
|
||||
}
|
||||
|
||||
drop(id: string): void {
|
||||
this.pending.delete(id)
|
||||
}
|
||||
|
||||
/** Settle the waiter for this response; false when no request owns it. */
|
||||
settle(response: RpcResponse): boolean {
|
||||
const request = this.pending.get(response.id)
|
||||
if (!request) {
|
||||
return false
|
||||
}
|
||||
clearTimeout(request.timer)
|
||||
this.pending.delete(response.id)
|
||||
request.resolve(response)
|
||||
return true
|
||||
}
|
||||
|
||||
rejectAll(error: Error): void {
|
||||
if (this.pending.size === 0) {
|
||||
return
|
||||
}
|
||||
// Why: pending entries only exist after their frame reached the authenticated
|
||||
// link (sendFrame failures delete them synchronously), so the desktop may
|
||||
// have processed them — mark the ambiguity for callers.
|
||||
markRpcDeliveryUnknown(error)
|
||||
for (const request of this.pending.values()) {
|
||||
clearTimeout(request.timer)
|
||||
request.reject(error)
|
||||
}
|
||||
this.pending.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
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('waits for mobile capability acknowledgement before replaying streams', async () => {
|
||||
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.some((payload) => payload.includes('session.tabs.subscribe'))).toBe(false)
|
||||
|
||||
socket.receive(
|
||||
`encrypted:${JSON.stringify({
|
||||
id: capabilityRequest.id,
|
||||
ok: true,
|
||||
result: capabilityRequest.params,
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})}`
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(sentRequest(socket, 'session.tabs.subscribe')).toBeDefined())
|
||||
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('replays streams when an older runtime rejects capability negotiation', async () => {
|
||||
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')
|
||||
socket.receive(
|
||||
`encrypted:${JSON.stringify({
|
||||
id: capabilityRequest.id,
|
||||
ok: false,
|
||||
error: { code: 'method_not_found', message: 'Unknown method' },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})}`
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(sentRequest(socket, 'session.tabs.subscribe')).toBeDefined())
|
||||
expect(client.getState()).toBe('connected')
|
||||
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('reaches connected when a slow host never answers capability negotiation', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
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"}')
|
||||
sentRequest(socket, 'runtime.clientCapabilities.update')
|
||||
|
||||
// Why: the 5s capability deadline used to force-close the socket, so a link
|
||||
// this slow never left 'connecting' — it just redialled forever.
|
||||
await vi.advanceTimersByTimeAsync(5_001)
|
||||
|
||||
expect(client.getState()).toBe('connected')
|
||||
expect(sentRequest(socket, 'session.tabs.subscribe')).toBeDefined()
|
||||
expect(socket.readyState).toBe(MockWebSocket.OPEN)
|
||||
|
||||
client.close()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -14,6 +14,10 @@ vi.mock('./e2ee', () => ({
|
||||
decryptBytes: (bytes: Uint8Array) => bytes
|
||||
}))
|
||||
|
||||
vi.mock('./mobile-runtime-capability-negotiation', () => ({
|
||||
negotiateMobileRuntimeCapabilities: (args: { onReady: () => void }) => args.onReady()
|
||||
}))
|
||||
|
||||
class MockWebSocket {
|
||||
static CONNECTING = 0
|
||||
static OPEN = 1
|
||||
|
||||
@@ -15,6 +15,10 @@ vi.mock('./e2ee', () => ({
|
||||
decryptBytes: (bytes: Uint8Array) => bytes
|
||||
}))
|
||||
|
||||
vi.mock('./mobile-runtime-capability-negotiation', () => ({
|
||||
negotiateMobileRuntimeCapabilities: (args: { onReady: () => void }) => args.onReady()
|
||||
}))
|
||||
|
||||
class MockWebSocket {
|
||||
static CONNECTING = 0
|
||||
static OPEN = 1
|
||||
|
||||
@@ -14,6 +14,10 @@ vi.mock('./e2ee', () => ({
|
||||
decryptBytes: (bytes: Uint8Array) => bytes
|
||||
}))
|
||||
|
||||
vi.mock('./mobile-runtime-capability-negotiation', () => ({
|
||||
negotiateMobileRuntimeCapabilities: (args: { onReady: () => void }) => args.onReady()
|
||||
}))
|
||||
|
||||
class MockWebSocket {
|
||||
static CONNECTING = 0
|
||||
static OPEN = 1
|
||||
|
||||
@@ -42,9 +42,28 @@ export class RpcClientRequestTracker {
|
||||
})
|
||||
}
|
||||
|
||||
return this.sendConnectedRequest(
|
||||
method,
|
||||
params,
|
||||
resolvePostConnectRequestTimeout(budget, REQUEST_TIMEOUT_MS)
|
||||
)
|
||||
}
|
||||
|
||||
sendAuthenticatedRequest(
|
||||
method: string,
|
||||
params: unknown,
|
||||
timeoutMs = REQUEST_TIMEOUT_MS
|
||||
): Promise<RpcResponse> {
|
||||
return this.sendConnectedRequest(method, params, timeoutMs)
|
||||
}
|
||||
|
||||
private sendConnectedRequest(
|
||||
method: string,
|
||||
params: unknown,
|
||||
timeoutMs: number
|
||||
): Promise<RpcResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = this.options.nextId()
|
||||
const timeoutMs = resolvePostConnectRequestTimeout(budget, REQUEST_TIMEOUT_MS)
|
||||
const timeout = setTimeout(() => {
|
||||
this.pending.delete(id)
|
||||
console.log('[net] sendRequest TIMEOUT', {
|
||||
|
||||
@@ -14,6 +14,10 @@ vi.mock('./e2ee', () => ({
|
||||
decryptBytes: (bytes: Uint8Array) => bytes
|
||||
}))
|
||||
|
||||
vi.mock('./mobile-runtime-capability-negotiation', () => ({
|
||||
negotiateMobileRuntimeCapabilities: (args: { onReady: () => void }) => args.onReady()
|
||||
}))
|
||||
|
||||
class RuntimeEventTestSocket {
|
||||
static CONNECTING = 0
|
||||
static OPEN = 1
|
||||
|
||||
@@ -17,6 +17,10 @@ vi.mock('./e2ee', () => ({
|
||||
decryptBytes: (bytes: Uint8Array) => bytes
|
||||
}))
|
||||
|
||||
vi.mock('./mobile-runtime-capability-negotiation', () => ({
|
||||
negotiateMobileRuntimeCapabilities: (args: { onReady: () => void }) => args.onReady()
|
||||
}))
|
||||
|
||||
// Why: close() deliberately never fires onclose — that is the wedged-transport bug being modelled.
|
||||
class WedgedWebSocket {
|
||||
static CONNECTING = 0
|
||||
|
||||
@@ -15,6 +15,10 @@ vi.mock('./e2ee', () => ({
|
||||
decryptBytes: (bytes: Uint8Array) => bytes
|
||||
}))
|
||||
|
||||
vi.mock('./mobile-runtime-capability-negotiation', () => ({
|
||||
negotiateMobileRuntimeCapabilities: (args: { onReady: () => void }) => args.onReady()
|
||||
}))
|
||||
|
||||
class MockWebSocket {
|
||||
static CONNECTING = 0
|
||||
static OPEN = 1
|
||||
|
||||
@@ -17,6 +17,10 @@ vi.mock('./e2ee', () => ({
|
||||
decryptBytes: (bytes: Uint8Array) => bytes
|
||||
}))
|
||||
|
||||
vi.mock('./mobile-runtime-capability-negotiation', () => ({
|
||||
negotiateMobileRuntimeCapabilities: (args: { onReady: () => void }) => args.onReady()
|
||||
}))
|
||||
|
||||
class MockWebSocket {
|
||||
static CONNECTING = 0
|
||||
static OPEN = 1
|
||||
|
||||
@@ -15,6 +15,11 @@ vi.mock('./e2ee', () => ({
|
||||
decryptBytes: (bytes: Uint8Array) => bytes
|
||||
}))
|
||||
|
||||
// Capability ordering has dedicated coverage; keep connection tests focused on socket behavior.
|
||||
vi.mock('./mobile-runtime-capability-negotiation', () => ({
|
||||
negotiateMobileRuntimeCapabilities: (args: { onReady: () => void }) => args.onReady()
|
||||
}))
|
||||
|
||||
class MockWebSocket {
|
||||
static CONNECTING = 0
|
||||
static OPEN = 1
|
||||
@@ -64,36 +69,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 {
|
||||
|
||||
@@ -15,6 +15,10 @@ vi.mock('./e2ee', () => ({
|
||||
decryptBytes: (bytes: Uint8Array) => bytes
|
||||
}))
|
||||
|
||||
vi.mock('./mobile-runtime-capability-negotiation', () => ({
|
||||
negotiateMobileRuntimeCapabilities: (args: { onReady: () => void }) => args.onReady()
|
||||
}))
|
||||
|
||||
class MockWebSocket {
|
||||
static readonly CONNECTING = 0
|
||||
static readonly OPEN = 1
|
||||
|
||||
@@ -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,10 @@ 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) {
|
||||
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
|
||||
@@ -41,6 +43,10 @@ export class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWi
|
||||
}
|
||||
this.storeMobileSessionSnapshot(worktreeId, nextSnapshot)
|
||||
this.emitMobileSessionTabsSnapshot(nextSnapshot)
|
||||
// Retire durable visibility and the runtime snapshot before stopping the provider.
|
||||
if (typeof host?.close === 'function') {
|
||||
await host.close(tab.sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: a refused echoed close means the echoing client already pruned its
|
||||
|
||||
@@ -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,11 @@ 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 () => {
|
||||
const snapshot = await runtime.listMobileSessionTabs('id:workspace-1')
|
||||
expect(snapshot.tabs.some((tab) => tab.type === 'agent-session')).toBe(false)
|
||||
})
|
||||
const setSessionTabVisibility = vi.fn(async () => undefined)
|
||||
runtime.setNotifier({ closeSessionTab } as never)
|
||||
const internal = runtime as unknown as {
|
||||
hasPersistedStructuredAgentSessionStore(): boolean
|
||||
@@ -221,6 +226,8 @@ describe('structured session cold restoration', () => {
|
||||
setStructuredAgentSessionHost({
|
||||
reconcileRestartLeases: async () => undefined,
|
||||
restoreReadableSessions: async () => undefined,
|
||||
close: closeStructuredSession,
|
||||
setSessionTabVisibility,
|
||||
listSessionTabs: () => [
|
||||
{
|
||||
sessionId: 'agent-session:agent-session:restored-session',
|
||||
@@ -304,6 +311,11 @@ describe('structured session cold restoration', () => {
|
||||
'structured-agent-session-restored-session',
|
||||
'workspace-1'
|
||||
)
|
||||
expect(closeStructuredSession).toHaveBeenCalledWith('restored-session')
|
||||
expect(setSessionTabVisibility).toHaveBeenCalledWith('restored-session', false)
|
||||
expect(setSessionTabVisibility.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
closeStructuredSession.mock.invocationCallOrder[0]!
|
||||
)
|
||||
|
||||
const closed = await runtime.listMobileSessionTabs('id:workspace-1')
|
||||
expect(closed.tabs.map((tab) => tab.id)).toEqual([
|
||||
|
||||
@@ -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?: (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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())
|
||||
@@ -186,6 +192,18 @@ describe('capability gating', () => {
|
||||
expect(response).toMatchObject({ ok: true, result: { ok: true } })
|
||||
expect(hostCalls.close).toHaveBeenCalledWith(SESSION)
|
||||
expect(hostCalls.setSessionTabVisibility).toHaveBeenCalledWith(SESSION, false)
|
||||
expect(hostCalls.setSessionTabVisibility.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
hostCalls.close.mock.invocationCallOrder[0]!
|
||||
)
|
||||
})
|
||||
|
||||
it('does not stop the provider when durable tab retirement fails', async () => {
|
||||
hostCalls.setSessionTabVisibility.mockRejectedValueOnce(new Error('visibility write failed'))
|
||||
|
||||
const response = await call('agentSession.close', { sessionId: SESSION }, STRUCTURED_CLIENT)
|
||||
|
||||
expect(response).toMatchObject({ ok: false })
|
||||
expect(hostCalls.close).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('advertises the capability without bumping the protocol version', () => {
|
||||
@@ -250,6 +268,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 })
|
||||
@@ -292,6 +329,37 @@ describe('method routing', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('reports an unknown create outcome when attach commits before tab publication fails', async () => {
|
||||
const worktree = 'id:workspace-1'
|
||||
const params = {
|
||||
envelope: envelope({
|
||||
expectedRuntimeFence: null,
|
||||
payloadFingerprint: computeAgentSessionPayloadFingerprint({
|
||||
method: 'agentSession.create',
|
||||
sessionId: SESSION,
|
||||
fields: { worktree, agent: 'codex' }
|
||||
})
|
||||
}),
|
||||
worktree,
|
||||
agent: 'codex'
|
||||
}
|
||||
|
||||
const response = await call('agentSession.create', params, STRUCTURED_CLIENT, {
|
||||
publishStructuredAgentSessionTab: vi.fn(async () => {
|
||||
throw new Error('publish failed')
|
||||
})
|
||||
})
|
||||
|
||||
expect(hostCalls.attach).toHaveBeenCalledOnce()
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
ok: false,
|
||||
refusal: { code: 'agent_session_operation_unknown' }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('separates create from ensure by the fence the client may declare', async () => {
|
||||
const created = await call('agentSession.create', attachParams())
|
||||
expect(created).toMatchObject({ ok: true })
|
||||
|
||||
@@ -91,12 +91,23 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [
|
||||
envelope: { ...params.envelope, payloadFingerprint: hostFingerprint }
|
||||
})
|
||||
if (result.ok && resolved.agent === 'codex') {
|
||||
await ctx.runtime.publishStructuredAgentSessionTab({
|
||||
workspaceId: resolved.location.workspaceId,
|
||||
sessionId: result.value.sessionId,
|
||||
agent: 'codex',
|
||||
activate: true
|
||||
})
|
||||
try {
|
||||
await ctx.runtime.publishStructuredAgentSessionTab({
|
||||
workspaceId: resolved.location.workspaceId,
|
||||
sessionId: result.value.sessionId,
|
||||
agent: 'codex',
|
||||
activate: true
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('[agent-session] create committed before tab publication failed', error)
|
||||
return {
|
||||
ok: false,
|
||||
refusal: {
|
||||
code: 'agent_session_operation_unknown',
|
||||
message: 'The Codex chat may have been created, but its tab could not be confirmed.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -129,11 +140,11 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [
|
||||
params: OptionsParams,
|
||||
handler: async (params, ctx) => {
|
||||
const host = requireHost(ctx)
|
||||
await host.close(params.sessionId)
|
||||
// Terminal-disposal closes use this RPC without the session-tabs retirement RPC.
|
||||
if (typeof host.setSessionTabVisibility === 'function') {
|
||||
await host.setSessionTabVisibility(params.sessionId, false)
|
||||
}
|
||||
await host.close(params.sessionId)
|
||||
return { ok: true as const }
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user