fix claude structured lane blockers

This commit is contained in:
Merge Sim
2026-08-31 20:04:41 -07:00
parent 12c8e7731a
commit b76c8125f6
51 changed files with 1212 additions and 331 deletions
@@ -2936,6 +2936,9 @@ export default function SessionScreen() {
void readFileTab(tab)
return
}
if (tab.type === 'agent-session') {
return
}
const cached = markdownDocs.get(tab.id)
if (cached?.status === 'ready' && cached.isDirty) {
return
@@ -3134,6 +3137,8 @@ export default function SessionScreen() {
setMarkdownActionTarget(tab)
} else if (tab.type === 'file') {
setFileActionTarget(tab)
} else if (tab.type === 'agent-session') {
return
} else {
setBrowserActionTarget(tab)
}
@@ -4610,7 +4615,7 @@ export default function SessionScreen() {
) : showEmptyState ? (
<View style={styles.emptyState}>
<Text style={styles.emptyText}>No tabs in this session</Text>
{createError ? <Text style={styles.createError}>{createError}</Text> : null}
{createError ? <Text style={styles.markdownError}>{createError}</Text> : null}
<View style={styles.emptyActions}>
<Pressable
style={[
+41 -20
View File
@@ -8,12 +8,8 @@ overrides:
xcode>uuid: 11.1.1
patchedDependencies:
react-native-webview@13.16.2:
hash: de6761dfa76a5491a23e49f1262566a8831cab26736a336fdffc5d4e7d05ff27
path: patches/react-native-webview@13.16.2.patch
react-native@0.83.10:
hash: 44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d
path: patches/react-native@0.83.10.patch
react-native-webview@13.16.2: de6761dfa76a5491a23e49f1262566a8831cab26736a336fdffc5d4e7d05ff27
react-native@0.83.10: 44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d
importers:
@@ -2891,9 +2887,6 @@ packages:
'@types/node@25.6.0':
resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==}
'@types/node@26.1.1':
resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==}
'@types/node@26.1.2':
resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==}
@@ -10339,10 +10332,6 @@ snapshots:
dependencies:
undici-types: 7.19.2
'@types/node@26.1.1':
dependencies:
undici-types: 8.3.0
'@types/node@26.1.2':
dependencies:
undici-types: 8.3.0
@@ -10389,7 +10378,7 @@ snapshots:
dependencies:
'@types/yargs-parser': 21.0.3
'@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)':
'@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3)
@@ -10409,7 +10398,7 @@ snapshots:
dependencies:
'@typescript-eslint/scope-manager': 8.59.2
'@typescript-eslint/types': 8.59.2
'@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3)
'@typescript-eslint/typescript-estree': 8.59.2(typescript@6.0.3)
'@typescript-eslint/visitor-keys': 8.59.2
debug: 4.4.3
eslint: 9.39.4
@@ -10426,6 +10415,15 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/project-service@8.59.2(typescript@6.0.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3)
'@typescript-eslint/types': 8.59.2
debug: 4.4.3
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/scope-manager@8.59.2':
dependencies:
'@typescript-eslint/types': 8.59.2
@@ -10435,6 +10433,10 @@ snapshots:
dependencies:
typescript: 5.9.3
'@typescript-eslint/tsconfig-utils@8.59.2(typescript@6.0.3)':
dependencies:
typescript: 6.0.3
'@typescript-eslint/type-utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)':
dependencies:
'@typescript-eslint/types': 8.59.2
@@ -10464,6 +10466,21 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/typescript-estree@8.59.2(typescript@6.0.3)':
dependencies:
'@typescript-eslint/project-service': 8.59.2(typescript@6.0.3)
'@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3)
'@typescript-eslint/types': 8.59.2
'@typescript-eslint/visitor-keys': 8.59.2
debug: 4.4.3
minimatch: 10.2.5
semver: 7.8.5
tinyglobby: 0.2.17
ts-api-utils: 2.5.0(typescript@6.0.3)
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4)
@@ -11776,11 +11793,11 @@ snapshots:
eslint-config-universe@15.0.4(eslint@9.39.4)(prettier@2.8.8)(typescript@5.9.3):
dependencies:
'@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)
'@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)(typescript@5.9.3)
'@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3)
eslint: 9.39.4
eslint-config-prettier: 9.1.2(eslint@9.39.4)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)
eslint-plugin-n: 17.24.0(eslint@9.39.4)(typescript@5.9.3)
eslint-plugin-node: 11.1.0(eslint@9.39.4)
eslint-plugin-prettier: 5.5.5(eslint-config-prettier@9.1.2(eslint@9.39.4))(eslint@9.39.4)(prettier@2.8.8)
@@ -11804,7 +11821,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4):
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4):
dependencies:
debug: 3.2.7
optionalDependencies:
@@ -11827,7 +11844,7 @@ snapshots:
eslint-utils: 2.1.0
regexpp: 3.2.0
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4):
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@@ -11838,7 +11855,7 @@ snapshots:
doctrine: 2.1.0
eslint: 9.39.4
eslint-import-resolver-node: 0.3.10
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4)
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4)
hasown: 2.0.3
is-core-module: 2.16.2
is-glob: 4.0.3
@@ -15262,6 +15279,10 @@ snapshots:
dependencies:
typescript: 5.9.3
ts-api-utils@2.5.0(typescript@6.0.3):
dependencies:
typescript: 6.0.3
ts-declaration-location@1.0.7(typescript@5.9.3):
dependencies:
picomatch: 4.0.4
@@ -38,6 +38,7 @@ import {
import { buildMobileAgentHistoryResumeActionState } from './agent-history-session-card'
import { styles } from './agent-history-styles'
import { useNow } from '../hooks/use-now'
import { useOpenMobileSession } from '../session/use-open-mobile-session'
export type MobileAgentSessionHistoryPanelProps = {
hostId: string
@@ -57,6 +58,7 @@ export function MobileAgentSessionHistoryPanel({
name = ''
}: MobileAgentSessionHistoryPanelProps) {
const router = useRouter()
const openMobileSession = useOpenMobileSession()
const { client, state: connState } = useHostClient(hostId)
const [worktrees, setWorktrees] = useState<Worktree[]>([])
const [worktreesLoaded, setWorktreesLoaded] = useState(false)
@@ -163,11 +165,7 @@ export function MobileAgentSessionHistoryPanel({
try {
const structuredWorkspaceId = await activateStructuredAiVaultSession(client, session)
if (structuredWorkspaceId) {
router.push(
`/h/${encodeURIComponent(hostId)}/session/${encodeURIComponent(structuredWorkspaceId)}` as Parameters<
typeof router.push
>[0]
)
openMobileSession({ hostId, worktreeId: structuredWorkspaceId })
return
}
const {
@@ -220,11 +218,7 @@ export function MobileAgentSessionHistoryPanel({
resumeMutationRegistryRef.current.releaseOnSuccess(session.id)
triggerSuccess()
setResumeMessage('Agent session queued.')
router.push(
`/h/${encodeURIComponent(hostId)}/session/${encodeURIComponent(target.worktreeId)}` as Parameters<
typeof router.push
>[0]
)
openMobileSession({ hostId, worktreeId: target.worktreeId })
} catch (err) {
triggerError()
setResumeMessage(err instanceof Error ? err.message : 'Failed to resume session.')
@@ -239,7 +233,7 @@ export function MobileAgentSessionHistoryPanel({
hostId,
hostPlatform,
hostTerminalWindowsShell,
router,
openMobileSession,
worktreeId,
worktrees
]
@@ -8,6 +8,16 @@ import {
} from '../../../src/shared/agent-session-question-answer'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
function toggleSortedOption(selected: readonly number[], optionIndex: number): number[] {
return selected.includes(optionIndex)
? selected.filter((value) => value !== optionIndex)
: [
...selected.filter((value) => value < optionIndex),
optionIndex,
...selected.filter((value) => value > optionIndex)
]
}
export function MobileStructuredQuestionGroupCard(props: {
questions: readonly AgentJournalQuestion[]
onAnswer: (encoded: string) => Promise<boolean>
@@ -41,14 +51,11 @@ export function MobileStructuredQuestionGroupCard(props: {
const toggleOption = (optionIndex: number): void => {
setSelections((current) => {
const next = current.map((selected) => [...selected])
const selected = next[index] ?? []
next[index] = question.multiSelect
? selected.includes(optionIndex)
? selected.filter((value) => value !== optionIndex)
: [...selected, optionIndex].sort((left, right) => left - right)
const selected = current[index] ?? []
const nextSelected = question.multiSelect
? toggleSortedOption(selected, optionIndex)
: [optionIndex]
return next
return current.map((value, i) => (i === index ? nextSelected : value))
})
if (!question.multiSelect) {
setOtherText((current) => current.map((text, i) => (i === index ? '' : text)))
@@ -17,11 +17,28 @@ function item(id: string, sequence: number, revision = 1): AgentJournalRenderIte
}
function snapshot(epoch: string, items: AgentJournalRenderItem[]) {
const oldest = items[0]?.sequence ?? 0
const newest = items.at(-1)?.sequence ?? 0
return {
type: 'snapshot' as const,
sessionId: 'session-a',
fence: 1,
snapshot: { sessionId: 'session-a', cursor: { epoch, sequence: 50 }, items, submissions: [] }
page: {
sessionId: 'session-a',
epoch,
direction: 'tail' as const,
items,
removedItemIds: [],
submissions: [],
window: {
oldest: items[0] ? { epoch, sequence: oldest } : null,
newest: items.at(-1) ? { epoch, sequence: newest } : null,
nextCursor: { epoch, sequence: items.length > 0 ? newest : 0 }
},
liveCursor: items.at(-1) ? { epoch, sequence: newest } : undefined,
hasOlder: false,
hasNewer: false
}
}
}
@@ -92,8 +109,8 @@ describe('mobile structured session reducer', () => {
event: {
...snapshot('epoch-a', []),
fence: index + 1,
snapshot: {
...snapshot('epoch-a', []).snapshot,
page: {
...snapshot('epoch-a', []).page,
cursor: { epoch: 'epoch-a', sequence: index }
},
handoff
@@ -7,6 +7,7 @@ import {
pasteMobileNativeChatImagePaths
} from './mobile-native-chat-image-send'
import {
clearMobileNativeChatInput,
openMobileNativeChatSendBudget,
sendMobileNativeChatMessageWithOutcome,
typeMobileNativeChatCommandWithOutcome,
@@ -22,6 +23,7 @@ import {
acquireMobileNativeChatTerminalWrite,
releaseMobileNativeChatTerminalWrite
} from './mobile-native-chat-terminal-write-lock'
import { AGENT_TUI_CLEAR_INPUT_LINE } from '../../../src/shared/agent-tui-input-clear'
type MobileStructuredTuiSendArgs = {
client: RpcClient
@@ -86,23 +88,35 @@ export async function sendMobileStructuredTuiMessage(
const textDeadline =
args.attachments.length > 0 ? deadline + MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS : deadline
const classification = classifyMobileNativeChatSend(args.agent, args.text)
const outcome =
const shouldTypeCommand =
classification !== 'chat' && isSlashCommandDraft(args.text) && args.attachments.length === 0
? await typeMobileNativeChatCommandWithOutcome({
client: args.client,
terminal: args.terminal,
command: args.text,
...(mobileClient ? { mobileClient } : {}),
deadline: textDeadline
})
: await sendMobileNativeChatMessageWithOutcome({
client: args.client,
terminal: args.terminal,
text: args.text,
clearInputFirst: args.attachments.length === 0,
...(mobileClient ? { mobileClient } : {}),
deadline: textDeadline
})
if (!shouldTypeCommand && args.attachments.length === 0) {
const cleared = await clearMobileNativeChatInput({
client: args.client,
terminal: args.terminal,
clearInput: AGENT_TUI_CLEAR_INPUT_LINE,
...(mobileClient ? { mobileClient } : {}),
deadline: textDeadline
})
if (!cleared) {
return 'rejected'
}
}
const outcome = shouldTypeCommand
? await typeMobileNativeChatCommandWithOutcome({
client: args.client,
terminal: args.terminal,
command: args.text,
...(mobileClient ? { mobileClient } : {}),
deadline: textDeadline
})
: await sendMobileNativeChatMessageWithOutcome({
client: args.client,
terminal: args.terminal,
text: args.text,
...(mobileClient ? { mobileClient } : {}),
deadline: textDeadline
})
if (args.attachments.length > 0 && outcome !== 'accepted') {
markMobileNativeChatInputStale(args.terminal)
}
@@ -37,10 +37,13 @@ function openStructuredAgentSessionSubscription(args: {
{ sessionId: args.sessionId, ...(args.cursor ? { cursor: args.cursor } : {}) },
args.onEvent,
{
paramsForReconnect: () => ({
sessionId: args.sessionId,
...(args.resumeCursor() ? { cursor: args.resumeCursor() } : {})
})
paramsForReconnect: () => {
const resumeCursor = args.resumeCursor()
return {
sessionId: args.sessionId,
...(resumeCursor ? { cursor: resumeCursor } : {})
}
}
}
)
}
@@ -63,13 +66,19 @@ export function useMobileStructuredAgentSession(args: {
const { client, sessionId } = args
const [state, dispatch] = useReducer(reduceStructuredAgentSession, EMPTY_STRUCTURED_AGENT_SESSION)
const stateRef = useRef(state)
stateRef.current = state
const resumeCursorRef = useRef<AgentJournalCursor | null>(state.cursor)
resumeCursorRef.current = state.cursor
const [loadingOlder, setLoadingOlder] = useState(false)
const reconnectRef = useRef(createMobileStructuredReconnectState())
const cancelLongevityRef = useRef<() => void>(() => {})
useEffect(() => {
stateRef.current = state
}, [state])
useEffect(() => {
resumeCursorRef.current = state.cursor
}, [state.cursor])
useEffect(() => {
dispatch({ type: 'loading' })
setLoadingOlder(false)
@@ -125,7 +134,7 @@ export function useMobileStructuredAgentSession(args: {
}
if (event.type === 'snapshot' || event.type === 'reset') {
setLoadingOlder(false)
resumeCursorRef.current = event.snapshot.cursor
resumeCursorRef.current = event.page.liveCursor ?? event.page.window.nextCursor
} else if (event.type === 'batch') {
const current = resumeCursorRef.current
if (shouldAdvanceStructuredResumeCursor(current, event.batch.cursor)) {
@@ -156,8 +165,8 @@ export function useMobileStructuredAgentSession(args: {
const result = response.result as AgentSessionHistoryResult
if (result.ok) {
dispatch({ type: 'tail-page', page: result.page })
resumeCursorRef.current = result.page.liveCursor ?? null
openSubscription(result.page.liveCursor ?? null)
resumeCursorRef.current = result.page.liveCursor ?? result.page.window.nextCursor
openSubscription(resumeCursorRef.current)
return
}
dispatch({
@@ -166,12 +175,12 @@ export function useMobileStructuredAgentSession(args: {
type: 'reset',
sessionId,
reset: result.reset,
snapshot: result.snapshot,
page: result.page,
fence: result.fence ?? 0
}
})
resumeCursorRef.current = result.snapshot.cursor
openSubscription(result.snapshot.cursor)
resumeCursorRef.current = result.page.liveCursor ?? result.page.window.nextCursor
openSubscription(resumeCursorRef.current)
})
.catch((error: unknown) => {
if (!closed) {
@@ -227,7 +236,7 @@ export function useMobileStructuredAgentSession(args: {
type: 'reset',
sessionId,
reset: result.reset,
snapshot: result.snapshot,
page: result.page,
fence: result.fence ?? 0
}
})
@@ -40,6 +40,11 @@ export class FakeSession implements RpcClient {
}
export class FakeRelaySession extends FakeSession implements MobileRelayRpcSession {
private structuredReconnectSignal = {
backgroundRestart: false,
streamLongevityConfirmed: false
}
constructor(
state: ConnectionState,
private readonly failure: Error | null = null,
@@ -61,6 +66,14 @@ export class FakeRelaySession extends FakeSession implements MobileRelayRpcSessi
resumeExpiresAt: this.resumeExpiry
})
getFailure = () => this.failure
consumeStructuredReconnectSignal = () => {
const signal = this.structuredReconnectSignal
this.structuredReconnectSignal = {
backgroundRestart: false,
streamLongevityConfirmed: false
}
return signal
}
}
export class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient {
@@ -72,6 +72,11 @@ class FakeSession implements RpcClient {
}
class FakeRelaySession extends FakeSession implements MobileRelayRpcSession {
private structuredReconnectSignal = {
backgroundRestart: false,
streamLongevityConfirmed: false
}
constructor(
state: ConnectionState,
private readonly failure: Error | null = null
@@ -84,6 +89,14 @@ class FakeRelaySession extends FakeSession implements MobileRelayRpcSession {
getResumeExpiresAt = () => Date.now() + 30 * 24 * 3_600_000
getResumeConfirmation = () => null
getFailure = () => this.failure
consumeStructuredReconnectSignal = () => {
const signal = this.structuredReconnectSignal
this.structuredReconnectSignal = {
backgroundRestart: false,
streamLongevityConfirmed: false
}
return signal
}
}
class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient {
+3
View File
@@ -17,6 +17,7 @@ export type SendRequestOptions = {
type SubscribeOptions = {
onBinaryFrame?: (frame: BrowserScreencastFrame) => void
paramsForReconnect?: () => unknown
}
type StreamingListener = (result: unknown) => void
@@ -43,6 +44,8 @@ export type RpcClient = {
getLastInboundAt?: () => number | null
onStateChange: (listener: (state: ConnectionState) => void) => () => void
notifyForeground: (reason?: ForegroundNudgeReason) => void
confirmStructuredStreamLongevity?: () => void
restartAfterStructuredBackground?: () => void
close: () => void
}
@@ -170,16 +170,10 @@ export function createStableLogicalRpcClient(
activeSession.notifyForeground(reason)
}
},
restartAfterStructuredBackground: () => {
if (!suspended) {
activeSession.restartAfterStructuredBackground?.()
}
},
confirmStructuredStreamLongevity: () => {
if (!suspended) {
activeSession.confirmStructuredStreamLongevity?.()
}
},
restartAfterStructuredBackground: () =>
suspended ? undefined : activeSession.restartAfterStructuredBackground?.(),
confirmStructuredStreamLongevity: () =>
suspended ? undefined : activeSession.confirmStructuredStreamLongevity?.(),
close() {
if (closed) {
return
+19
View File
@@ -6,6 +6,9 @@ importers:
.:
configDependencies: {}
packageManagerDependencies:
'@pnpm/exe':
specifier: 12.0.0
version: 12.0.0
pnpm:
specifier: 12.0.0
version: 12.0.0
@@ -56,6 +59,11 @@ packages:
cpu: [x64]
os: [win32]
'@pnpm/exe@12.0.0':
resolution: {integrity: sha512-405vw2qYPPghNxoPRt2cvkGtGNU5gnInDcIe5TztQxe73yUZLqFJN/jCJFGs1xbVaIWDXEVuQ71P8npFZeatZQ==}
engines: {node: '>=18.*'}
hasBin: true
pnpm@12.0.0:
resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==}
engines: {node: '>=18.*'}
@@ -87,6 +95,17 @@ snapshots:
'@pnpm/exe.win32-x64@12.0.0':
optional: true
'@pnpm/exe@12.0.0':
optionalDependencies:
'@pnpm/exe.darwin-arm64': 12.0.0
'@pnpm/exe.darwin-x64': 12.0.0
'@pnpm/exe.linux-arm64': 12.0.0
'@pnpm/exe.linux-arm64-musl': 12.0.0
'@pnpm/exe.linux-x64': 12.0.0
'@pnpm/exe.linux-x64-musl': 12.0.0
'@pnpm/exe.win32-arm64': 12.0.0
'@pnpm/exe.win32-x64': 12.0.0
pnpm@12.0.0:
optionalDependencies:
'@pnpm/exe.darwin-arm64': 12.0.0
@@ -71,7 +71,11 @@ describe('Claude structured dispatch image limits', () => {
type: 'user',
parent_tool_use_id: null,
session_id: 'provider-session',
uuid: 'user-replay-uuid'
uuid: 'user-replay-uuid',
message: {
role: 'user',
content: [{ type: 'text', text: 'hello' }]
}
})
await expect(dispatched).resolves.toMatchObject({
@@ -80,6 +84,48 @@ describe('Claude structured dispatch image limits', () => {
})
})
it('ignores a top-level tool-result user frame while waiting for a slash command replay', async () => {
const session = sessionFor()
const dispatched = dispatchClaudeTurn(
session,
{ clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: '/permissions' }]) },
100
)
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
resolveClaudeReplayWaiter(session, {
type: 'user',
parent_tool_use_id: null,
session_id: 'provider-session',
uuid: 'tool-result-uuid',
message: {
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 'tool-1', content: 'done' }]
}
})
expect(session.dispatchWaiters).toHaveLength(1)
resolveClaudeReplayWaiter(session, {
type: 'user',
parent_tool_use_id: null,
session_id: 'provider-session',
uuid: 'user-replay-uuid',
message: {
role: 'user',
content: [{ type: 'text', text: '/permissions' }]
}
})
await expect(dispatched).resolves.toEqual({
state: 'accepted',
providerIdentity: {
provider: 'claude',
sessionId: 'provider-session',
uuid: 'user-replay-uuid'
}
})
})
it('rejects more than twenty URL images before sending', async () => {
const session = sessionFor()
const body = userMessage(
@@ -3,6 +3,10 @@ import { open } from 'node:fs/promises'
import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types'
import type { NativeChatBlock } from '../../shared/native-chat-types'
import type { AgentSessionDispatchOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import {
claudeHasReplayContent,
readClaudeMessageEnvelope
} from './claude-structured-item-translation'
import type { ClaudeSession } from './claude-structured-session-state'
import { readClaudeFrameString } from './claude-structured-init-proof'
@@ -54,7 +58,11 @@ export function resolveClaudeReplayWaiter(
session: ClaudeSession,
message: Record<string, unknown>
): void {
const isUserReplay = message.type === 'user' && message.parent_tool_use_id === null
const envelope = readClaudeMessageEnvelope(message)
const isUserReplay =
envelope?.role === 'user' &&
message.parent_tool_use_id === null &&
claudeHasReplayContent(envelope)
const isCompletedCommand = message.type === 'result'
if (
(!isUserReplay && !isCompletedCommand) ||
@@ -78,6 +78,13 @@ export function claudeMessageBody(envelope: ClaudeMessageEnvelope): AgentJournal
return blocks.length > 0 ? { kind: 'message', role: envelope.role, blocks } : null
}
export function claudeHasReplayContent(envelope: ClaudeMessageEnvelope): boolean {
return envelope.content.some((value) => {
const part = claudeRecord(value)
return part !== null && part.type !== 'tool_result'
})
}
export function claudeToolUses(envelope: ClaudeMessageEnvelope): ClaudeToolUse[] {
return envelope.content.flatMap((value) => {
const part = claudeRecord(value)
@@ -173,6 +173,31 @@ describe('Claude structured journal translation', () => {
})
})
it('does not start a lifecycle for a top-level user tool result', () => {
const state = sinkState()
const translator = createClaudeJournalTranslator({ sink: state.sink })
translator.handle(
message('user', 'tool-result-only', [
{ type: 'tool_result', tool_use_id: 'tool-1', content: 'done' }
])
)
expect(state.items.map((item) => agentJournalItemKey(item.identity))).toEqual([
'orca:claude-tool%3Aclaude-session%3Atool-1'
])
expect(state.items[0]?.body).toMatchObject({
kind: 'tool-call',
state: 'completed',
output: { head: 'done' }
})
expect(
state.items.some(
(item) => item.body.kind === 'status' && item.body.turnLifecycle !== undefined
)
).toBe(false)
})
it('renders empty user frames through the provider fallback', () => {
const state = sinkState()
const translator = createClaudeJournalTranslator({ sink: state.sink })
@@ -191,14 +216,14 @@ describe('Claude structured journal translation', () => {
)
})
it('renders every unmodeled Claude frame family as a bounded provider row', () => {
it('renders unmodeled substantive Claude frames as bounded provider rows', () => {
const state = sinkState()
const translator = createClaudeJournalTranslator({ sink: state.sink })
translator.handle({
type: 'message',
sessionId: 'orca-session',
message: { type: 'system', subtype: 'compact_boundary', summary: 'x'.repeat(100_000) }
message: { type: 'system', subtype: 'local_command_output', summary: 'x'.repeat(100_000) }
})
translator.handle({
type: 'message',
@@ -242,18 +267,22 @@ describe('Claude structured journal translation', () => {
)
expect(frames.map((frame) => frame.kind)).toEqual(
expect.arrayContaining([
'message:system:compact_boundary',
'message:system:hook_response',
'message:system:local_command_output',
'message:system:command_started',
'message:result',
'message:tool_progress',
'message:prompt_suggestion',
'message:user:content:document',
'control_request:future_control'
])
)
expect(frames.map((frame) => frame.kind)).not.toEqual(
expect.arrayContaining([
'message:system:hook_response',
'message:tool_progress',
'message:prompt_suggestion'
])
)
expect(
frames.find((frame) => frame.kind === 'message:system:compact_boundary')?.payload
frames.find((frame) => frame.kind === 'message:system:local_command_output')?.payload
).toEqual(expect.objectContaining({ truncated: true, byteLength: expect.any(Number) }))
})
@@ -13,6 +13,7 @@ import type { ClaudeStructuredSessionEvent } from './claude-structured-session-s
import {
claudeMessageBody,
claudeMessageIdentity,
claudeHasReplayContent,
claudeRecord,
claudeStreamingMessageBody,
claudeText,
@@ -219,7 +220,7 @@ export function createClaudeJournalTranslator(
}
if (
envelope.role === 'user' &&
envelope.content.length > 0 &&
claudeHasReplayContent(envelope) &&
message.parent_tool_use_id === null
) {
if (currentTurn) {
@@ -87,22 +87,16 @@ describe('claude structured launch resolution', () => {
})
it('keeps the session launch environment pinned after account settings change', async () => {
const pinned = record({
launchEnv: {
ANTHROPIC_AUTH_TOKEN: 'first-token',
ANTHROPIC_BASE_URL: 'https://gateway.example.test'
}
})
const resolver = resolverFor(pinned, () => ({
const resolver = resolverFor(record(), () => ({
ANTHROPIC_AUTH_TOKEN: 'rotated-token',
ANTHROPIC_BASE_URL: 'https://gateway.example.test'
}))
expect((await resolver({ identity: IDENTITY })).env).toEqual({
ANTHROPIC_AUTH_TOKEN: 'first-token',
ANTHROPIC_AUTH_TOKEN: 'rotated-token',
ANTHROPIC_BASE_URL: 'https://gateway.example.test'
})
expect((await resolver({ identity: IDENTITY })).env?.ANTHROPIC_AUTH_TOKEN).toBe('first-token')
expect((await resolver({ identity: IDENTITY })).env?.ANTHROPIC_AUTH_TOKEN).toBe('rotated-token')
})
it('refuses other hosts, WSL, providers, and account-home variables', async () => {
@@ -38,7 +38,10 @@ export type ClaudeStructuredLaunchResolverDeps = {
store: AgentSessionRecordStore
resolveWorkspacePath: (workspaceId: string) => Promise<string>
resolveCommand?: () => string
resolveEnv?: () => Record<string, string>
resolveEnv?: () =>
| Promise<Record<string, string> | undefined>
| Record<string, string>
| undefined
}
export function claudeSessionIdForOrcaSession(sessionId: string): string {
@@ -85,15 +88,12 @@ export function createClaudeStructuredLaunchResolver(
...CLAUDE_STRUCTURED_BASE_ARGS,
...providerArgs
])
const env = await deps.resolveEnv?.()
return {
command: spawnCmd,
args: spawnArgs,
cwd: await deps.resolveWorkspacePath(record.location.workspaceId),
...(record.launchEnv
? { env: { ...record.launchEnv } }
: deps.resolveEnv
? { env: deps.resolveEnv() }
: {}),
...(env ? { env } : {}),
claudeConfigDir: record.accountHome.path,
providerSessionId,
resumeLeafUuid: head?.handle.provider === 'claude' ? head.handle.leafUuid : null,
@@ -46,7 +46,7 @@ function questionAnswer(prompt: ClaudePendingPrompt, questionId: string, optionI
return optionId
}
const questionIndex = prompt.questionIds.indexOf(questionId)
if (questionIndex < 0) {
if (questionIndex === -1) {
return optionId
}
const choice = /^choice-([1-9]\d*)$/.exec(decoded.answer)
@@ -102,7 +102,13 @@ export async function readClaudeStructuredSessionOptions(
}
const effort = session.options.get('effort') ?? session.reportedOptions.effort
return {
models: models.map(({ resolvedModel: _, ...entry }) => entry),
models: models.map((entry) => ({
id: entry.id,
label: entry.label,
...(entry.description ? { description: entry.description } : {}),
isDefault: entry.isDefault,
efforts: entry.efforts
})),
current: { model, ...(effort ? { effort } : {}) }
}
}
@@ -77,14 +77,11 @@ describe('Claude TUI resume launch', () => {
const build = createClaudeTuiResumeLaunchBuilder({
resolveWorkspacePath: async () => '/workspace',
resolveCommand: () => 'claude',
resolveEnv: () => ({ ANTHROPIC_AUTH_TOKEN: 'rotated-token' }),
resolveEnv: () => ({ ANTHROPIC_AUTH_TOKEN: 'pinned-token' }),
inheritedEnv: {}
})
const launch = await build({
record: record({ launchEnv: { ANTHROPIC_AUTH_TOKEN: 'pinned-token' } }),
spawnToken: 'spawn'
})
const launch = await build({ record: record(), spawnToken: 'spawn' })
expect(launch.env.ANTHROPIC_AUTH_TOKEN).toBe('pinned-token')
})
+1 -1
View File
@@ -48,7 +48,7 @@ export function createClaudeTuiResumeLaunchBuilder(
'--resume',
head.handle.sessionId
])
const configuredEnv = record.launchEnv ?? deps.resolveEnv?.() ?? {}
const configuredEnv = deps.resolveEnv?.() ?? {}
const env = buildClaudeChildProcessEnv(
{
...configuredEnv,
@@ -27,16 +27,18 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi
return acquired
}
async releaseAcquisition(input: { sessionId: string }): Promise<void> {
async releaseAcquisition(input: { sessionId: string }): Promise<boolean> {
const adapter = this.owners.get(input.sessionId)
if (adapter) {
await adapter.releaseAcquisition?.(input)
const released = await adapter.releaseAcquisition?.(input)
this.owners.delete(input.sessionId)
return
return released === true
}
let released = false
for (const candidate of Object.values(this.adapters)) {
await candidate.releaseAcquisition?.(input)
released = (await candidate.releaseAcquisition?.(input)) === true || released
}
return released
}
dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) =>
@@ -62,13 +64,14 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi
historyFilePath = (input: { identity: AgentSessionJournalIdentity }) =>
this.requireAgent(input.identity).historyFilePath?.(input) ?? Promise.resolve(null)
async closeSession(sessionId: string): Promise<void> {
async closeSession(sessionId: string): Promise<boolean> {
const adapter = this.owners.get(sessionId)
if (!adapter) {
return
return false
}
await adapter.closeSession?.(sessionId)
const closed = await adapter.closeSession?.(sessionId)
this.owners.delete(sessionId)
return closed === true
}
async closeAll(): Promise<void> {
@@ -5,7 +5,6 @@
// the record store's compare-and-swap, which also owns the idempotency row, so
// a retried attach replays instead of reserving a second owner.
import type { AgentType } from '../../../shared/agent-status-types'
import type {
AgentSessionJournalIdentity,
AgentSessionProviderHandle
@@ -51,7 +50,7 @@ export type AgentSessionAttachParams = {
envelope: AgentSessionMutationEnvelope
location: AgentSessionExecutionLocation
provider: AgentSessionHandleProvider
agent: AgentType
agent: AgentSessionHandleProvider
accountHome: AgentSessionAccountHome
runtimeKind: AgentSessionOwnerRuntimeKind
/** Omitted only for create-by-intent; the adapter proves the durable handle. */
@@ -84,10 +84,7 @@ function transport(): StructuredAgentSessionHandoffTransport {
record.lease.ownerProcess?.spawnToken ?? record.lease.reservedSpawnToken ?? 'recovered'
),
stopRecoveredOwner: async () => undefined,
waitForTuiExit: async (current, persistHandle) => {
await persistHandle(current.link)
return { transcriptPath: current.transcriptPath }
},
waitForTuiExit: async (current) => ({ transcriptPath: current.transcriptPath }),
waitForTuiIdleOrExit: async () => 'idle',
tuiStatus: () => 'idle'
}
@@ -119,6 +116,7 @@ function adapter(): StructuredAgentSessionAdapter {
readOptions: vi.fn(async () => ({ current: { model: activeModel }, models: [] })),
closeSession: vi.fn(async () => {
activeModel = DEFAULT_MODEL
return true
})
}
}
@@ -1,5 +1,8 @@
import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire'
import type { AgentSessionOperationOutcome } from '../../../shared/agent-session-operation-ledger'
import type {
AgentSessionOperationOutcome,
AgentSessionOperationRefusalCode
} from '../../../shared/agent-session-operation-ledger'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
type ActiveOperation = { callerKey: string; operationId: string; fingerprint: string }
@@ -8,7 +11,7 @@ export type HandoffOperationDecision =
| { decision: 'new' }
| { decision: 'replay'; outcome: AgentSessionOperationOutcome }
| { decision: 'retry' }
| { decision: 'refused'; code: 'agent_session_operation_conflict' | string }
| { decision: 'refused'; code: AgentSessionOperationRefusalCode }
export class StructuredAgentSessionHandoffOperationGuard {
private readonly activeBySession = new Map<string, ActiveOperation>()
@@ -158,6 +158,7 @@ function adapter(): StructuredAgentSessionAdapter {
})),
closeSession: vi.fn(async () => {
activeModel = DEFAULT_MODEL
return true
})
}
}
@@ -57,7 +57,7 @@ export function createStructuredAgentSessionHandoffTestCoordinator(
journal: input.journal,
fence: input.store.getRecord(input.sessionId)?.lease.runtimeFence ?? 1
}),
suspendNative: async () => undefined,
suspendNative: async () => ({ state: 'stopped' as const }),
acquireNative: input.acquireNative,
acquireNativeStop: (_sessionId, turnId) => input.acquireNativeStop(turnId),
importTuiHistory: async ({ fence }) => {
@@ -1,11 +1,37 @@
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire'
import type {
AgentSessionHandoffRequest,
AgentSessionHandoffResult,
AgentSessionHandoffStatus,
AgentSessionMutationResult,
AgentSessionWireRefusal
} from '../../../shared/agent-session-wire'
import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection'
import {
admitStructuredHandoffRequest,
refuseAdmittedStructuredHandoff,
replayedStructuredHandoffRefusal,
structuredHandoffRetryIsAdmissible
} from './structured-agent-session-handoff-admission'
import {
createStructuredHandoffFlowContext,
requireStructuredHandoffRecord
} from './structured-agent-session-handoff-flow-context'
import { restoreStructuredAgentSessionHandoff } from './structured-agent-session-handoff-restart'
import { StructuredAgentSessionHandoffFlowRunner } from './structured-agent-session-handoff-flow-runner'
import { StructuredAgentSessionHandoffOperationGuard } from './structured-agent-session-handoff-operation-guard'
import { StructuredAgentSessionHandoffQueue } from './structured-agent-session-handoff-queue'
import { closeRetainedTuiOwner } from './structured-agent-session-handoff-owner-close'
import { restoreStructuredAgentSessionHandoff } from './structured-agent-session-handoff-restart'
import {
structuredHandoffRefusal as refusal,
structuredHandoffSuccess
} from './structured-agent-session-handoff-result'
import {
failedStructuredHandoffStatus,
idleStructuredHandoffStatus,
structuredSessionHasPendingPrompt,
structuredTuiStatus
} from './structured-agent-session-handoff-status'
import type {
StructuredAgentSessionHandoffDeps,
StructuredAgentSessionHandoffFlowContext
@@ -14,50 +40,263 @@ import { StructuredAgentSessionHandoffState } from './structured-agent-session-h
export class StructuredAgentSessionHandoffCoordinator {
private readonly state: StructuredAgentSessionHandoffState
private readonly queue = new StructuredAgentSessionHandoffQueue()
private readonly operationGuard: StructuredAgentSessionHandoffOperationGuard
private readonly flowRunner: StructuredAgentSessionHandoffFlowRunner
constructor(private readonly deps: StructuredAgentSessionHandoffDeps) {
// oxfmt-ignore
this.state = new StructuredAgentSessionHandoffState({ requireRecord: (sessionId) => this.requireRecord(sessionId), publish: deps.publish, hostLabel: deps.transport?.hostLabel })
this.state = new StructuredAgentSessionHandoffState({
requireRecord: (sessionId) => this.requireRecord(sessionId),
publish: deps.publish,
hostLabel: deps.transport?.hostLabel
})
this.operationGuard = new StructuredAgentSessionHandoffOperationGuard(deps.store)
this.flowRunner = new StructuredAgentSessionHandoffFlowRunner({
deps,
operationGuard: this.operationGuard,
flowContext: () => this.flowContext(),
fail: (params, error) => this.fail(params, error)
})
}
status = (sessionId: string) => this.state.status(sessionId)
status = (sessionId: string): AgentSessionHandoffStatus => this.state.status(sessionId)
drain = (): Promise<void> => this.flowRunner.drain()
closeRetainedTuiOwner = (sessionId: string): Promise<boolean> =>
closeRetainedTuiOwner({
sessionId,
deps: this.deps,
owner: this.state.owner,
requireRecord: this.requireRecord,
releaseOwner: this.state.releaseOwner
})
this.closeRetainedOwner(sessionId)
setStatus = (sessionId: string, status: AgentSessionHandoffStatus): void =>
this.state.setStatus(sessionId, status)
async request(
callerKey: string,
params: AgentSessionHandoffRequest
): Promise<AgentSessionMutationResult<AgentSessionHandoffResult>> {
const record = this.requireRecord(params.envelope.sessionId)
const currentStatus = this.state.cachedStatus(record.sessionId)
const admission = await admitStructuredHandoffRequest({
deps: this.deps,
operationGuard: this.operationGuard,
callerKey,
params,
record,
...(currentStatus ? { status: currentStatus } : {})
})
if (admission.decision === 'replay') {
const replayedRefusal = replayedStructuredHandoffRefusal(admission.outcome)
if (replayedRefusal) {
return { ok: false, refusal: replayedRefusal }
}
return this.success(record.sessionId, true)
}
if (admission.decision === 'refused') {
return { ok: false, refusal: admission.refusal }
}
const { fingerprint } = admission
const action = params.action ?? 'start'
if (action === 'cancel-queued') {
if (currentStatus?.phase !== 'queued' || currentStatus?.direction !== params.direction) {
return this.refuseAdmitted(
callerKey,
params,
'agent_session_operation_conflict',
'No matching queued handoff exists.'
)
}
this.queue.cancel(record.sessionId)
this.setStatus(record.sessionId, idleStructuredHandoffStatus(record))
await this.deps.store.recordOperationOutcome({
callerKey,
operationId: params.envelope.clientOperationId,
outcome: { status: 'succeeded', sessionId: record.sessionId }
})
return this.success(record.sessionId, false)
}
if (!this.deps.transport) {
return this.refuseAdmitted(
callerKey,
params,
'structured_agent_session_unsupported',
'Agent TUI handoff is unavailable on this host.'
)
}
if (action === 'retry') {
if (!structuredHandoffRetryIsAdmissible(this.status(record.sessionId), params)) {
return this.refuseAdmitted(
callerKey,
params,
'agent_session_operation_conflict',
'This handoff is no longer retryable.'
)
}
this.begin(callerKey, params, null, fingerprint)
return this.success(record.sessionId, false)
}
const expectedOwner = params.direction === 'to-tui' ? 'native' : 'tui'
if (record.lease.runtimeKind !== expectedOwner || record.lease.claimStatus !== 'live') {
return this.refuseAdmitted(
callerKey,
params,
'agent_session_conflict',
`The ${expectedOwner} runtime does not own this session.`
)
}
if (structuredSessionHasPendingPrompt(this.deps.session(record.sessionId).journal)) {
return this.refuseAdmitted(
callerKey,
params,
'agent_session_conflict',
'Resolve the pending question or approval before switching.'
)
}
const turnId = activeStructuredAgentSessionTurnId(
this.deps.session(record.sessionId).journal.snapshot().items
)
const tuiOwner = this.state.owner(record.sessionId)
const busy =
expectedOwner === 'native'
? turnId !== null
: structuredTuiStatus(tuiOwner, this.deps.transport) !== 'idle'
if (busy && params.mode === 'now') {
return this.refuseAdmitted(
callerKey,
params,
'agent_session_conflict',
'The current turn must finish before switching.'
)
}
if (busy && params.mode === 'after-turn') {
this.queueAfterTurn(callerKey, params, fingerprint)
return this.success(record.sessionId, false)
}
if (busy && expectedOwner === 'tui' && params.mode === 'stop-turn') {
return this.refuseAdmitted(
callerKey,
params,
'structured_agent_session_unsupported',
'Exit the agent terminal after this turn to continue in chat.'
)
}
this.begin(callerKey, params, turnId, fingerprint)
return this.success(record.sessionId, false)
}
async restore(sessionId: string): Promise<void> {
await restoreStructuredAgentSessionHandoff(
{
deps: this.deps,
requireRecord: (id) => this.requireRecord(id),
flowContext: () => this.flowContext(),
retainOwner: this.state.retainOwner,
setStatus: this.state.setStatus
retainOwner: (id, owner) => this.state.retainOwner(id, owner),
setStatus: (id, status) => this.state.setStatus(id, status)
},
sessionId
)
}
private refuseAdmitted(
callerKey: string,
params: AgentSessionHandoffRequest,
code: AgentSessionWireRefusal['code'],
message: string
): Promise<AgentSessionMutationResult<AgentSessionHandoffResult>> {
return refuseAdmittedStructuredHandoff({
deps: this.deps,
callerKey,
params,
refusal: refusal(code, message)
})
}
private success(
sessionId: string,
replayed: boolean
): AgentSessionMutationResult<AgentSessionHandoffResult> {
return structuredHandoffSuccess(this.deps, sessionId, replayed, this.status(sessionId))
}
private queueAfterTurn(
callerKey: string,
params: AgentSessionHandoffRequest,
fingerprint: string
): void {
const sessionId = params.envelope.sessionId
let tuiReadiness: 'idle' | 'exited' | null = null
this.setStatus(sessionId, {
owner: params.direction === 'to-tui' ? 'native' : 'tui',
direction: params.direction,
phase: 'queued',
stage: null,
operationId: params.envelope.clientOperationId,
hostLabel: this.deps.transport?.hostLabel
})
const tuiOwner = this.state.owner(sessionId)
this.queue.enqueue(
sessionId,
async (signal) => {
if (params.direction === 'to-tui') {
return !activeStructuredAgentSessionTurnId(
this.deps.session(sessionId).journal.snapshot().items
)
}
tuiReadiness = tuiOwner
? ((await this.deps.transport?.waitForTuiIdleOrExit(tuiOwner, signal)) ?? null)
: null
return tuiReadiness !== null
},
() => {
const next = { ...params, mode: 'now' as const }
this.begin(callerKey, next, null, fingerprint, tuiReadiness === 'exited')
}
)
}
private begin(
callerKey: string,
params: AgentSessionHandoffRequest,
turnId: string | null,
fingerprint: string,
tuiAlreadyExited = false
): void {
this.flowRunner.begin({
callerKey,
params,
turnId,
fingerprint,
tuiAlreadyExited
})
}
private flowContext(): StructuredAgentSessionHandoffFlowContext {
return createStructuredHandoffFlowContext({
deps: this.deps,
owner: this.state.owner,
retainOwner: this.state.retainOwner,
releaseOwner: this.state.releaseOwner,
setStatus: this.state.setStatus,
owner: (sessionId) => this.state.owner(sessionId),
retainOwner: (sessionId, owner) => this.state.retainOwner(sessionId, owner),
releaseOwner: (sessionId) => this.state.releaseOwner(sessionId),
setStatus: (sessionId, status) => this.state.setStatus(sessionId, status),
requireRecord: (sessionId) => this.requireRecord(sessionId)
})
}
private fail(params: AgentSessionHandoffRequest, error: unknown): void {
const record = this.requireRecord(params.envelope.sessionId)
this.setStatus(
record.sessionId,
failedStructuredHandoffStatus(record, params, error, this.deps.transport?.hostLabel)
)
}
private closeRetainedOwner(sessionId: string): Promise<boolean> {
return closeRetainedTuiOwner({
sessionId,
deps: this.deps,
owner: this.state.owner,
requireRecord: this.requireRecord,
releaseOwner: this.state.releaseOwner
})
}
private requireRecord = (sessionId: string): AgentSessionRecord =>
requireStructuredHandoffRecord(this.deps, sessionId)
}
@@ -6,6 +6,8 @@ import type {
AgentSessionAttachResult,
AgentSessionHistoryRequest,
AgentSessionHistoryResult,
AgentSessionHandoffRequest,
AgentSessionHandoffResult,
AgentSessionHandoffStatus,
AgentSessionMutationResult,
AgentSessionOptionsResult,
@@ -258,6 +260,19 @@ export class StructuredAgentSessionHost {
): ReturnType<typeof setStructuredAgentSessionOption> =>
setStructuredAgentSessionOption(this.mutationContext(), caller, params)
requestHandoff = (
caller: StructuredAgentSessionCaller,
params: AgentSessionHandoffRequest
): Promise<AgentSessionMutationResult<AgentSessionHandoffResult>> =>
(
this.handoffs as {
request: (
callerKey: string,
params: AgentSessionHandoffRequest
) => Promise<AgentSessionMutationResult<AgentSessionHandoffResult>>
}
).request(caller.callerKey, params)
readOptions = (sessionId: string): Promise<AgentSessionOptionsResult> =>
readStructuredAgentSessionOptions(this.mutationContext(), sessionId)
@@ -158,7 +158,13 @@ describe('structured session proven-dead TUI retry', () => {
}
expect(coordinator.status(SESSION)).toMatchObject({ phase: 'failed', owner: 'tui' })
expect(await coordinator.request('client-1', request)).toMatchObject({ ok: true })
expect(
await (
coordinator as {
request: (callerKey: string, params: AgentSessionHandoffRequest) => Promise<unknown>
}
).request('client-1', request)
).toMatchObject({ ok: true })
await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('native'))
expect(closeTuiOwner).not.toHaveBeenCalled()
expect(store.getRecord(SESSION)?.lease).toMatchObject({
@@ -62,9 +62,9 @@ describe('legacy agent session launch environment', () => {
)
const reopened = await AgentSessionRecordStore.open({ directory, hostId: 'local' })
expect(reopened.getRecord(SESSION)?.launchEnv).toEqual({
ANTHROPIC_AUTH_TOKEN: 'pinned-token'
})
expect(
(reopened.getRecord(SESSION) as { launchEnv?: Record<string, string> } | null)?.launchEnv
).toBeUndefined()
})
it('rejects an environment that could not be reloaded before writing it', async () => {
@@ -40,6 +40,16 @@ const CLIENT = {
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
}
const { readClaudeTranscriptLeafUuid, resolveSessionFilePath } = vi.hoisted(() => ({
readClaudeTranscriptLeafUuid: vi.fn(),
resolveSessionFilePath: vi.fn()
}))
vi.mock('../native-chat/session-file-resolver', () => ({
readClaudeTranscriptLeafUuid,
resolveSessionFilePath
}))
type FakeClaudeConnection = Omit<ClaudeStreamJsonConnection, 'closed'> & {
closed: boolean
launch: ClaudeStreamJsonLaunch
@@ -234,7 +244,7 @@ function itemsOf(frames: AgentSessionSubscribeEvent[]): AgentJournalRenderItem[]
for (const frame of frames) {
const rows =
frame.type === 'snapshot' || frame.type === 'reset'
? frame.snapshot.items
? frame.page.items
: frame.type === 'batch'
? frame.batch.items
: []
@@ -256,6 +266,8 @@ beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-claude-structured-integration-'))
transcriptPath = join(root, 'claude-home', 'projects', 'workspace', `${PROVIDER_SESSION}.jsonl`)
await mkdir(join(root, 'claude-home', 'projects', 'workspace'), { recursive: true })
resolveSessionFilePath.mockResolvedValue(transcriptPath)
readClaudeTranscriptLeafUuid.mockResolvedValue('init-leaf')
claude = fakeClaude()
tuiOwner = null
cleanups = new Map()
@@ -287,7 +299,21 @@ beforeEach(async () => {
}
return tuiOwner
},
reproveTuiOwner: async ({ owner }) => owner,
reproveTuiOwner: async ({ owner }) => {
if (owner.link.handle.provider !== 'claude' || !owner.transcriptPath) {
return owner
}
return {
...owner,
link: claudeProviderHandleLink({
sessionId: owner.link.handle.sessionId,
leafUuid: await readClaudeTranscriptLeafUuid(owner.transcriptPath),
resumed: true,
fence: owner.link.mintedAtFence,
observedAt: Date.now()
})
}
},
recoverTuiOwner: async () => {
if (!tuiOwner) {
throw new Error('scripted TUI owner missing')
@@ -295,18 +321,7 @@ beforeEach(async () => {
return tuiOwner
},
stopRecoveredOwner: async () => {},
waitForTuiExit: async (owner, persistHandle) => {
await persistHandle(
claudeProviderHandleLink({
sessionId: PROVIDER_SESSION,
leafUuid: 'tui-assistant',
resumed: true,
fence: owner.link.mintedAtFence,
observedAt: Date.now()
})
)
return { transcriptPath }
},
waitForTuiExit: async (owner) => ({ transcriptPath: owner.transcriptPath }),
waitForTuiIdleOrExit: async () => 'idle',
tuiStatus: () => 'idle',
stopFailedTuiLaunch: async () => {}
@@ -328,6 +343,7 @@ beforeEach(async () => {
resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`,
resolveCodexCommand: () => '/usr/local/bin/codex',
resolveClaudeCommand: () => '/usr/local/bin/claude',
readProcessStartTime: async (pid: number) => pid * 10,
resolveClaudeLaunchEnv: () => ({
ANTHROPIC_AUTH_TOKEN: 'configured-token',
ANTHROPIC_BASE_URL: 'https://gateway.example.test'
@@ -524,6 +540,7 @@ describe('a structured Claude session over agentSession.*', () => {
deps: { store: { getRecord: (id: string) => { lease: { runtimeFence: number } } } }
}
).deps.store.getRecord(SESSION).lease.runtimeFence
readClaudeTranscriptLeafUuid.mockResolvedValueOnce('tui-assistant')
await ok('agentSession.requestHandoff', handoffParams('to-native', tuiFence))
await vi.waitFor(async () =>
expect(await host.handoffStatus(SESSION)).toMatchObject({ owner: 'native', phase: 'idle' })
@@ -52,4 +52,54 @@ describe('structured agent-session create intent', () => {
path: '/accounts/selected/home'
})
})
it('pins the configured Claude launch home without Codex launch preparation', async () => {
const prepareCodexStructuredLaunch = vi.fn()
const runtime = new OrcaRuntimeService(
{
getSettings: () => ({
agentDefaultEnv: {
claude: { CLAUDE_CONFIG_DIR: '/configured/claude-home' }
}
})
} as never,
undefined,
{ prepareCodexStructuredLaunch }
)
vi.spyOn(runtime, 'getStructuredAgentSessionCreateSupport').mockResolvedValue({
supported: true
})
const internal = runtime as unknown as {
resolveStructuredAgentSessionLocation: (selector: string) => Promise<{
executionHostId: string
wslDistro: null
workspaceId: string
workspaceKind: 'git-worktree'
}>
resolveRuntimeFileTarget: (selector: string) => Promise<{
worktree: { path: string }
}>
}
internal.resolveStructuredAgentSessionLocation = vi.fn(async () => ({
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-1',
workspaceKind: 'git-worktree' as const
}))
internal.resolveRuntimeFileTarget = vi.fn(async () => ({
worktree: { path: '/repos/workspace-1' }
}))
const intent = await runtime.resolveStructuredAgentSessionCreateIntent({
envelope: { sessionId: 'session-1', clientOperationId: 'operation-1' },
worktree: 'id:workspace-1',
agent: 'claude'
})
expect(prepareCodexStructuredLaunch).not.toHaveBeenCalled()
expect(intent.accountHome).toEqual({
variable: 'CLAUDE_CONFIG_DIR',
path: '/configured/claude-home'
})
})
})
@@ -218,6 +218,56 @@ describe('structured session cold restoration', () => {
expect(closed.tabGroups?.[0]?.tabOrder).toEqual(['terminal-tab'])
})
it('publishes restored Claude tabs with the Claude title', async () => {
const runtime = new OrcaRuntimeService()
const publish = vi.spyOn(runtime, 'publishStructuredAgentSessionTab')
const internal = runtime as unknown as {
hasPersistedStructuredAgentSessionStore(): boolean
getKnownWorkspaceSessionWorktreeIds(): Set<string>
hydrateHeadlessMobileSessionTabsFromWorkspaceSession(): Set<string>
refreshMobileSessionPtyRecords(): Promise<Set<string> | null>
ensureStructuredAgentSessionHost(): Promise<void>
}
internal.hasPersistedStructuredAgentSessionStore = () => true
internal.getKnownWorkspaceSessionWorktreeIds = () => new Set()
internal.hydrateHeadlessMobileSessionTabsFromWorkspaceSession = () => new Set()
internal.refreshMobileSessionPtyRecords = async () => new Set()
internal.ensureStructuredAgentSessionHost = async () => undefined
setStructuredAgentSessionHost({
reconcileRestartLeases: async () => undefined,
restoreReadableSessions: async () => undefined,
listSessionTabs: () => [
{
sessionId: 'agent-session:agent-session:restored-claude',
workspaceId: 'workspace-1',
agent: 'claude'
}
]
} as never)
await runtime.restoreStructuredAgentSessionTabs()
expect(publish).toHaveBeenCalledWith({
workspaceId: 'workspace-1',
sessionId: 'restored-claude',
agent: 'claude',
activate: false,
notify: false
})
const restored = await runtime.listMobileSessionTabs('id:workspace-1')
expect(restored.tabs).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'agent-session',
id: 'agent-session:restored-claude',
title: 'Claude Chat',
agent: 'claude'
})
])
)
})
it('commits the host close when the renderer already removed the structured tab', async () => {
const runtime = new OrcaRuntimeService()
runtime.setNotifier({
@@ -8,12 +8,16 @@ import { OrcaRuntimeService } from './orca-runtime'
const {
probeAgentSessionProcessIdentity,
proveCodexTuiRollout,
readClaudeTranscriptLeafUuid,
readStructuredTuiProcessIdentity,
resolveSessionFilePath,
resolvePinnedCodexRolloutProof
} = vi.hoisted(() => ({
probeAgentSessionProcessIdentity: vi.fn(),
proveCodexTuiRollout: vi.fn(),
readClaudeTranscriptLeafUuid: vi.fn(),
readStructuredTuiProcessIdentity: vi.fn(),
resolveSessionFilePath: vi.fn(),
resolvePinnedCodexRolloutProof: vi.fn()
}))
@@ -22,6 +26,10 @@ vi.mock('../codex/codex-tui-rollout-proof', () => ({
proveCodexTuiRollout,
resolvePinnedCodexRolloutProof
}))
vi.mock('../native-chat/session-file-resolver', () => ({
readClaudeTranscriptLeafUuid,
resolveSessionFilePath
}))
vi.mock('./agent-session-process-identity-probe', async (importOriginal) => ({
...(await importOriginal()),
probeAgentSessionProcessIdentity
@@ -111,7 +119,13 @@ describe('structured TUI launch tab binding', () => {
getAgentSessionExecutionNamespace(): typeof namespace
ptysById: Map<
string,
{ launchToken: string | null; launchAgent: string | null; agentSessionOwners: unknown[] }
{
launchToken: string | null
launchAgent: string | null
agentSessionOwners: unknown[]
tabId?: string | null
paneKey?: string | null
}
>
}
internal.listResolvedWorktrees = vi.fn(async () => [
@@ -125,6 +139,9 @@ describe('structured TUI launch tab binding', () => {
folderWorkspace: null
}))
internal.getAgentSessionExecutionNamespace = () => namespace
proveCodexTuiRollout.mockResolvedValueOnce({
transcriptPath: '/tmp/codex-home/sessions/thread-1.jsonl'
})
probeAgentSessionProcessIdentity.mockResolvedValue({
outcome: 'identity-matched',
matchedOn: ['process-start-time']
@@ -134,6 +151,37 @@ describe('structured TUI launch tab binding', () => {
const coldPty = internal.ptysById.get('pty-cold-owner')!
expect(coldPty).toMatchObject({ launchToken: null, launchAgent: null })
expect(coldPty.agentSessionOwners).toHaveLength(1)
const runtimeId = (runtime as unknown as { runtimeId: string }).runtimeId
;(
runtime as unknown as {
handles: Map<
string,
{
handle: string
runtimeId: string
rendererGraphEpoch: number
worktreeId: string
tabId: string
leafId: string
ptyId: string
ptyGeneration: number
}
>
}
).handles.set(terminalHandle, {
handle: terminalHandle,
runtimeId,
rendererGraphEpoch: 0,
worktreeId: WORKTREE_ID,
tabId: 'pty:pty-cold-owner',
leafId: 'pty:pty-cold-owner',
ptyId: 'pty-cold-owner',
ptyGeneration: 0
})
coldPty.tabId = 'tab-cold-owner'
coldPty.paneKey = `tab-cold-owner:${leafId}`
coldPty.launchToken = 'spawn-token'
coldPty.launchAgent = 'codex'
const owner = await internal.createStructuredAgentSessionHandoffTransport().recoverTuiOwner({
sessionId: 'session-1',
@@ -157,7 +205,15 @@ describe('structured TUI launch tab binding', () => {
paneKey: `tab-cold-owner:${leafId}`,
ptyId: 'pty-cold-owner'
})
expect(resolvePinnedCodexRolloutProof).toHaveBeenCalledWith(namespace.providerRoot, 'thread-1')
expect(proveCodexTuiRollout).toHaveBeenCalledWith(
expect.objectContaining({
codexHome: namespace.providerRoot,
threadId: 'thread-1',
readOutput: expect.any(Function),
write: expect.any(Function)
})
)
expect(resolvePinnedCodexRolloutProof).not.toHaveBeenCalled()
expect(writeAgentSessionProof).not.toHaveBeenCalled()
expect(agentSessionPtyWriteGate.boundSessionId('pty-cold-owner')).toBe('session-1')
agentSessionPtyWriteGate.unbindPty('pty-cold-owner')
@@ -181,7 +237,7 @@ describe('structured TUI launch tab binding', () => {
state: 'done',
prompt: '',
agentType: 'claude',
receivedAt: 10,
receivedAt: Date.now() + 1000,
stateStartedAt: 10,
providerSession: { key: 'session_id', id: sessionId, transcriptPath }
}
@@ -190,6 +246,7 @@ describe('structured TUI launch tab binding', () => {
const internal = runtime as unknown as {
createStructuredAgentSessionHandoffTransport(): StructuredAgentSessionHandoffTransport
ptysById: Map<string, unknown>
restoredOrchestrationAuthorityByPtyId: Map<string, unknown>
}
internal.ptysById.set('pty-claude', {
ptyId: 'pty-claude',
@@ -201,6 +258,8 @@ describe('structured TUI launch tab binding', () => {
launchAgent: 'claude',
connected: true
})
resolveSessionFilePath.mockResolvedValue('/tmp/claude-home/projects/worktree/session.jsonl')
readClaudeTranscriptLeafUuid.mockResolvedValue('leaf-before-resume')
const record = {
sessionId: 'session-1',
accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/tmp/claude-home' },
@@ -231,9 +290,7 @@ describe('structured TUI launch tab binding', () => {
const transport = internal.createStructuredAgentSessionHandoffTransport()
const recovered = await transport.recoverTuiOwner(record)
const reproved = await transport.reproveTuiOwner({ record, owner: recovered })
expect(reproved).toMatchObject({
expect(recovered).toMatchObject({
transcriptPath,
link: {
handle: { provider: 'claude', sessionId, leafUuid: 'leaf-before-resume' },
@@ -241,8 +298,47 @@ describe('structured TUI launch tab binding', () => {
mintedAtFence: 3
}
})
expect(reproved.link.linkId).not.toBe('claude-old')
expect(attestAgentHookCompatibilityAuthority).not.toHaveBeenCalled()
expect(recovered.link.linkId).not.toBe('claude-old')
const pty = internal.ptysById.get('pty-claude') as {
launchToken: string | null
}
pty.launchToken = null
const dispatchAuthority = runtime.getOrchestrationDispatchAuthority(recovered.terminal.handle)!
internal.restoredOrchestrationAuthorityByPtyId.set('pty-claude', {
ptyId: 'pty-claude',
worktreeId: WORKTREE_ID,
terminalHandle: recovered.terminal.handle,
paneKey: recovered.terminal.paneKey,
processIncarnation: dispatchAuthority.processIncarnation,
hostScope: dispatchAuthority.hostScope
})
expect(
runtime.verifyOrchestrationCompatibilityCaller({
terminalHandle: recovered.terminal.handle,
paneKey,
launchToken: spawnToken
})
).toMatchObject({
paneKey,
terminalHandle: recovered.terminal.handle,
processIncarnation: dispatchAuthority.processIncarnation
})
expect(attestAgentHookCompatibilityAuthority).toHaveBeenCalledWith({
paneKey,
launchTokenHash: createHash('sha256').update(spawnToken).digest('hex'),
connectionId: null,
terminalProvenance: 'restored'
})
attestAgentHookCompatibilityAuthority.mockReturnValueOnce(null as never)
expect(
runtime.verifyOrchestrationCompatibilityCaller({
terminalHandle: recovered.terminal.handle,
paneKey,
launchToken: spawnToken
})
).toBeNull()
expect(agentSessionPtyWriteGate.boundSessionId('pty-claude')).toBe('session-1')
agentSessionPtyWriteGate.unbindPty('pty-claude')
})
@@ -265,7 +361,7 @@ describe('structured TUI launch tab binding', () => {
state: 'done',
prompt: '',
agentType: 'claude',
receivedAt: 10,
receivedAt: Date.now() + 1000,
stateStartedAt: 10,
providerSession: { key: 'session_id', id: sessionId, transcriptPath }
}
@@ -286,7 +382,8 @@ describe('structured TUI launch tab binding', () => {
launchAgent: 'claude',
connected: true
})
internal.restoredOrchestrationAuthorityByPtyId.set('pty-restored', {})
resolveSessionFilePath.mockResolvedValue('/tmp/claude-home/projects/worktree/restored.jsonl')
readClaudeTranscriptLeafUuid.mockResolvedValue('leaf-restored')
const record = {
sessionId: 'session-restored',
accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/tmp/claude-home' },
@@ -311,8 +408,34 @@ describe('structured TUI launch tab binding', () => {
}
} as never
await internal.createStructuredAgentSessionHandoffTransport().recoverTuiOwner(record)
const recovered = await internal
.createStructuredAgentSessionHandoffTransport()
.recoverTuiOwner(record)
const restoredPty = internal.ptysById.get('pty-restored') as {
launchToken: string | null
}
restoredPty.launchToken = null
const dispatchAuthority = runtime.getOrchestrationDispatchAuthority(recovered.terminal.handle)!
internal.restoredOrchestrationAuthorityByPtyId.set('pty-restored', {
ptyId: 'pty-restored',
worktreeId: WORKTREE_ID,
terminalHandle: recovered.terminal.handle,
paneKey: recovered.terminal.paneKey,
processIncarnation: dispatchAuthority.processIncarnation,
hostScope: dispatchAuthority.hostScope
})
expect(
runtime.verifyOrchestrationCompatibilityCaller({
terminalHandle: recovered.terminal.handle,
paneKey,
launchToken: spawnToken
})
).toMatchObject({
paneKey,
terminalHandle: recovered.terminal.handle,
processIncarnation: dispatchAuthority.processIncarnation
})
expect(attestAgentHookCompatibilityAuthority).toHaveBeenCalledWith({
paneKey,
launchTokenHash: createHash('sha256').update(spawnToken).digest('hex'),
@@ -321,8 +444,12 @@ describe('structured TUI launch tab binding', () => {
})
attestAgentHookCompatibilityAuthority.mockReturnValueOnce(null as never)
await expect(
internal.createStructuredAgentSessionHandoffTransport().recoverTuiOwner(record)
).rejects.toThrow('launch-token authority')
runtime.verifyOrchestrationCompatibilityCaller({
terminalHandle: recovered.terminal.handle,
paneKey,
launchToken: spawnToken
})
).toBeNull()
agentSessionPtyWriteGate.unbindPty('pty-restored')
})
@@ -375,7 +502,7 @@ describe('structured TUI launch tab binding', () => {
}>
markLocalWorkspaceTrustedForAgent(): void
waitForTerminal(): Promise<unknown>
waitForStructuredTuiProof(): Promise<{ transcriptPath?: string }>
waitForAdoptedStructuredTuiProof(): Promise<{ transcriptPath?: string }>
waitForStructuredTuiPtyExit(): Promise<void>
closeTerminal(handle: string): Promise<unknown>
handles: Map<
@@ -398,7 +525,7 @@ describe('structured TUI launch tab binding', () => {
internal.markLocalWorkspaceTrustedForAgent = vi.fn()
const waitForTerminal = vi.fn(async () => ({}))
internal.waitForTerminal = waitForTerminal
const waitForStructuredTuiProof = vi.fn(async () => {
const waitForAdoptedStructuredTuiProof = vi.fn(async () => {
const snapshot = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)
expect(snapshot.tabs).toContainEqual(
expect.objectContaining({
@@ -412,7 +539,7 @@ describe('structured TUI launch tab binding', () => {
expect(revealTerminalSession).not.toHaveBeenCalled()
return { transcriptPath: '/tmp/rollout.jsonl' }
})
internal.waitForStructuredTuiProof = waitForStructuredTuiProof
internal.waitForAdoptedStructuredTuiProof = waitForAdoptedStructuredTuiProof
const waitForStructuredTuiPtyExit = vi.fn(async () => {})
internal.waitForStructuredTuiPtyExit = waitForStructuredTuiPtyExit
const closeTerminal = vi.fn(async () => undefined)
@@ -458,7 +585,7 @@ describe('structured TUI launch tab binding', () => {
expect.any(String),
expect.objectContaining({ condition: 'tui-idle' })
)
expect(waitForStructuredTuiProof).toHaveBeenCalledOnce()
expect(waitForAdoptedStructuredTuiProof).toHaveBeenCalledOnce()
expect(onSpawned).toHaveBeenCalledWith(
expect.objectContaining({
terminal: expect.objectContaining({ ptyId: 'pty-structured' }),
@@ -468,7 +595,7 @@ describe('structured TUI launch tab binding', () => {
expect(onSpawned.mock.invocationCallOrder[0]).toBeLessThan(
waitForTerminal.mock.invocationCallOrder[0]!
)
expect(waitForStructuredTuiProof.mock.invocationCallOrder[0]).toBeLessThan(
expect(waitForAdoptedStructuredTuiProof.mock.invocationCallOrder[0]).toBeLessThan(
revealTerminalSession.mock.invocationCallOrder[0]!
)
const launchCommand = spawn.mock.calls[0]?.[0]?.command
@@ -553,9 +680,9 @@ describe('structured TUI launch tab binding', () => {
paneKey: owner.terminal.paneKey
})
expect(rebound.terminal.handle).not.toBe(owner.terminal.handle)
await transport.waitForTuiExit(rebound, async () => {})
await transport.waitForTuiExit(rebound)
expect(waitForStructuredTuiPtyExit).toHaveBeenCalledWith('pty-structured')
expect(waitForStructuredTuiProof).toHaveBeenCalledOnce()
expect(waitForAdoptedStructuredTuiProof).toHaveBeenCalledOnce()
await expect(transport.closeTuiOwner?.(rebound)).resolves.toEqual({
transcriptPath: '/tmp/rollout.jsonl'
@@ -569,4 +696,33 @@ describe('structured TUI launch tab binding', () => {
).resolves.toBe('exited')
await expect(transport.stopFailedTuiLaunch?.(rebound)).resolves.toBeUndefined()
})
it('reveals Claude structured native sessions into the mobile graph', () => {
const runtime = new OrcaRuntimeService()
const publish = vi.spyOn(runtime, 'publishStructuredAgentSessionTab')
const focusEditorTab = vi.fn()
runtime.setNotifier({ focusEditorTab } as never)
const internal = runtime as unknown as {
createStructuredAgentSessionHandoffTransport(): StructuredAgentSessionHandoffTransport
}
internal.createStructuredAgentSessionHandoffTransport().revealNativeSession?.({
workspaceId: WORKTREE_ID,
sessionId: 'session-claude',
agent: 'claude'
})
expect(publish).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: WORKTREE_ID,
sessionId: 'session-claude',
agent: 'claude',
activate: false
})
)
expect(focusEditorTab).toHaveBeenCalledWith(
'structured-agent-session-session-claude',
WORKTREE_ID
)
})
})
+35 -13
View File
@@ -650,6 +650,7 @@ import {
resolveTuiAgentLaunchArgs,
resolveTuiAgentLaunchEnv
} from '../../shared/tui-agent-launch-defaults'
import { resolveStartupShell, tokenizeStartupCommand } from '../../shared/tui-agent-startup-shell'
import { resolveCodexStructuredAppServerArgs } from '../codex/codex-structured-app-server-args'
import { resolveLocalWindowsAgentStartupShell } from '../../shared/windows-terminal-shell'
import {
@@ -11324,15 +11325,34 @@ export class OrcaRuntimeService {
// in a plain folder lands in the folder rather than failing to resolve.
resolveWorkspacePath: async (workspaceId) =>
(await this.resolveRuntimeFileTarget(`id:${workspaceId}`)).worktree.path,
resolveLaunchArgs: () => this.resolveConfiguredCodexStructuredArgs(),
resolveLaunchArgs: (provider) => this.resolveConfiguredStructuredLaunchArgs(provider),
resolveLaunchEnvOverlay: () =>
resolveTuiAgentLaunchEnv('codex', this.requireStore().getSettings().agentDefaultEnv),
resolveClaudeLaunchEnv: () =>
resolveTuiAgentLaunchEnv('claude', this.requireStore().getSettings().agentDefaultEnv),
handoffTransport: this.createStructuredAgentSessionHandoffTransport()
})
}
private resolveConfiguredCodexStructuredArgs(): string[] {
private resolveConfiguredStructuredLaunchArgs(
provider: AgentSessionRecord['provider']
): string[] {
const settings = this.requireStore().getSettings()
if (provider === 'claude') {
const shell = resolveStartupShell(
process.platform,
resolveLocalWindowsAgentStartupShell({
platform: process.platform,
isRemote: false,
terminalWindowsShell: settings.terminalWindowsShell
})
)
const tokenized = tokenizeStartupCommand(
resolveTuiAgentLaunchArgs('claude', settings.agentDefaultArgs),
shell
)
return tokenized.ok ? tokenized.tokens : []
}
const shell = resolveLocalWindowsAgentStartupShell({
platform: process.platform,
isRemote: false,
@@ -11763,7 +11783,7 @@ export class OrcaRuntimeService {
tuiStatus: (owner) => this.structuredTuiStatus(owner),
closeTuiOwner: (owner) => this.closeStructuredTuiOwner(owner),
revealNativeSession: ({ workspaceId, sessionId, agent = 'codex', adoptedTerminal }) => {
if (adoptedTerminal || agent !== 'codex') {
if (adoptedTerminal) {
return
}
this.publishStructuredAgentSessionTab({
@@ -12174,7 +12194,7 @@ export class OrcaRuntimeService {
async getStructuredAgentSessionCreateSupport(
worktreeSelector: string,
agent: 'codex'
agent: 'claude' | 'codex'
): Promise<{ supported: boolean; reason?: 'agent' | 'remote' | 'wsl' }> {
const location = await this.resolveStructuredAgentSessionLocation(worktreeSelector)
await this.ensureStructuredAgentSessionHost()
@@ -12239,8 +12259,14 @@ export class OrcaRuntimeService {
async resolveStructuredAgentSessionCreateIntent(input: {
envelope: { sessionId: string; clientOperationId: string }
worktree: string
agent: 'codex'
agent: 'claude' | 'codex'
}): Promise<AgentSessionAttachParams> {
if (input.agent === 'claude') {
return this.resolveStructuredAgentSessionIntent(
input,
async ({ launchEnv }) => launchEnv.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude')
)
}
return this.resolveStructuredAgentSessionIntent(input, async ({ workspacePath, launchEnv }) => {
// A create has no process yet, so the current selection is what it must follow.
const preparedHome = await this.prepareCodexStructuredLaunchFn?.({ workspacePath, launchEnv })
@@ -12257,7 +12283,7 @@ export class OrcaRuntimeService {
input: {
envelope: { sessionId: string; clientOperationId: string }
worktree: string
agent: 'codex'
agent: 'claude' | 'codex'
},
resolveAccountHomePath: (context: {
workspacePath: string
@@ -12283,7 +12309,7 @@ export class OrcaRuntimeService {
provider: input.agent,
agent: input.agent,
accountHome: {
variable: 'CODEX_HOME',
variable: input.agent === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME',
path: await resolveAccountHomePath({ workspacePath, launchEnv })
},
runtimeKind: 'native'
@@ -12338,16 +12364,12 @@ export class OrcaRuntimeService {
}
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession()
for (const session of host?.listSessionTabs() ?? []) {
if (session.agent !== 'codex') {
continue
}
let sessionId = session.sessionId
while (sessionId.startsWith('agent-session:')) {
sessionId = sessionId.slice('agent-session:'.length)
}
this.publishStructuredAgentSessionTab({
...session,
agent: 'codex',
sessionId,
activate: false,
notify: false
@@ -12358,7 +12380,7 @@ export class OrcaRuntimeService {
publishStructuredAgentSessionTab(input: {
workspaceId: string
sessionId: string
agent: 'codex'
agent: 'claude' | 'codex'
activate: boolean
notify?: boolean
}): void {
@@ -12370,7 +12392,7 @@ export class OrcaRuntimeService {
const tab: RuntimeMobileSessionAgentTab = {
type: 'agent-session',
id,
title: 'Codex Chat',
title: input.agent === 'claude' ? 'Claude Chat' : 'Codex Chat',
sessionId: input.sessionId,
agent: input.agent,
isActive: input.activate
@@ -103,7 +103,7 @@ describe('projectSessionTabAgentStatus', () => {
expect(capable).toBe(snapshot)
})
it('withholds legacy Claude rows from paired structured clients', () => {
it('keeps structured Claude and Codex rows visible to paired structured clients', () => {
const snapshot = {
...makeSnapshot(false),
tabs: [
@@ -132,7 +132,7 @@ describe('projectSessionTabAgentStatus', () => {
projectSessionTabAgentStatus(snapshot, 'runtime', [
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
]).tabs.map((tab) => tab.id)
).toEqual(['agent-session:codex'])
).toEqual(['agent-session:codex', 'agent-session:claude'])
})
it('withholds session boundaries from legacy paired clients', () => {
@@ -22,9 +22,6 @@ export function projectSessionTabAgentStatus<TPayload extends SessionTabsPayload
(clientKind === undefined ||
(clientCapabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) ?? false))
let projected = structuredVisible ? payload : projectAgentSessionTabsOut(payload, () => true)
if (structuredVisible && clientKind !== undefined) {
projected = projectAgentSessionTabsOut(projected, (tab) => tab.agent !== 'codex')
}
// Why: only paired runtimes have legacy `done` completion side effects; mobile must keep its row without changing the exact v2 auth shape.
if (
clientKind !== 'runtime' ||
@@ -98,7 +98,7 @@ export const CreateIntentParams = z
.object({
envelope: MutationEnvelope,
worktree: Identifier('Invalid worktree selector'),
agent: z.literal('codex')
agent: z.enum(['claude', 'codex'])
})
.strict()
@@ -107,7 +107,7 @@ export const CreateParams = z.union([AttachParams, CreateIntentParams])
export const CreateSupportParams = z
.object({
worktree: Identifier('Invalid worktree selector'),
agent: z.literal('codex')
agent: z.enum(['claude', 'codex'])
})
.strict()
@@ -170,6 +170,15 @@ export const SetOptionParams = z
})
.strict()
export const HandoffParams = z
.object({
envelope: MutationEnvelope,
direction: z.enum(['to-tui', 'to-native']),
mode: z.enum(['now', 'after-turn', 'stop-turn']),
action: z.enum(['start', 'cancel-queued', 'retry', 'recover']).optional()
})
.strict()
export const OptionsParams = z.object({ sessionId: SessionId }).strict()
/** One surface's claim on one session. The id names the surface, not the client: two chat views
@@ -97,6 +97,21 @@ function hostStub(): StructuredAgentSessionHost {
cancel: vi.fn(async () => ({ ok: true, replayed: false })),
respondToPrompt: vi.fn(async () => ({ ok: true, replayed: false })),
setOption: vi.fn(async () => ({ ok: true, replayed: false })),
requestHandoff: vi.fn(async () => ({
ok: true,
replayed: false,
fence: 1,
cursor: { epoch: 'epoch-a', sequence: 0 },
value: {
status: {
owner: 'native',
direction: null,
phase: 'idle',
stage: null,
operationId: null
}
}
})),
handoffStatus: vi.fn(async () => ({ owner: 'native' })),
readOptions: vi.fn(async () => ({
models: [{ id: 'gpt-live', label: 'GPT Live', isDefault: true, efforts: [] }],
@@ -120,9 +135,12 @@ function dispatcher(): RpcDispatcher {
workspaceId: 'workspace-1',
workspaceKind: 'git-worktree'
},
provider: 'codex',
agent: 'codex',
accountHome: { variable: 'CODEX_HOME', path: '/host/.codex' },
provider: params.agent,
agent: params.agent,
accountHome: {
variable: params.agent === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME',
path: params.agent === 'claude' ? '/host/.claude' : '/host/.codex'
},
runtimeKind: 'native'
})),
publishStructuredAgentSessionTab: vi.fn()
@@ -193,7 +211,7 @@ describe('capability gating', () => {
}
// Bump deliberately: the whole agentSession.* surface is behind the structured capability,
// so an additive method is invisible to old clients and needs no protocol bump.
expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(16)
expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(17)
})
it('hides the surface from a declared client that did not advertise it', async () => {
@@ -282,6 +300,49 @@ describe('method routing', () => {
)
})
it('routes Claude create support and create through the provider-aware runtime', async () => {
const worktree = 'id:workspace-1'
const support = await call(
'agentSession.createSupport',
{ worktree, agent: 'claude' },
STRUCTURED_CLIENT
)
expect(support).toMatchObject({ ok: true, result: { supported: true } })
expect(runtimeCalls.getStructuredAgentSessionCreateSupport).toHaveBeenCalledWith(
worktree,
'claude'
)
const params = {
envelope: envelope({
expectedRuntimeFence: null,
payloadFingerprint: computeAgentSessionPayloadFingerprint({
method: 'agentSession.create',
sessionId: SESSION,
fields: { worktree, agent: 'claude' }
})
}),
worktree,
agent: 'claude'
}
const created = await call('agentSession.create', params, STRUCTURED_CLIENT)
expect(created).toMatchObject({ ok: true, result: { ok: true } })
expect(runtimeCalls.resolveStructuredAgentSessionCreateIntent).toHaveBeenCalledWith(params)
expect(hostCalls.attach).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/host/.claude' }
})
)
expect(runtimeCalls.publishStructuredAgentSessionTab).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: SESSION,
activate: true,
agent: 'claude'
})
)
})
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 })
@@ -308,7 +369,7 @@ describe('method routing', () => {
])
})
it('does not register the structured handoff mutation', async () => {
it('routes the structured handoff mutation through the host', async () => {
const response = await call('agentSession.requestHandoff', {
envelope: envelope(),
direction: 'to-tui',
@@ -316,7 +377,11 @@ describe('method routing', () => {
action: 'start'
})
expect(response).toMatchObject({ ok: false, error: { code: 'method_not_found' } })
expect(response).toMatchObject({ ok: true })
expect(hostCalls.requestHandoff).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ direction: 'to-tui', mode: 'now', action: 'start' })
)
})
})
@@ -356,25 +421,6 @@ describe('parameter validation', () => {
)
})
it('rejects Claude structured create shapes', async () => {
await rejects('agentSession.createSupport', {
worktree: 'id:workspace-1',
agent: 'claude'
})
const fields = { worktree: 'id:workspace-1', agent: 'claude' }
await rejects('agentSession.create', {
envelope: envelope({
expectedRuntimeFence: null,
payloadFingerprint: computeAgentSessionPayloadFingerprint({
method: 'agentSession.create',
sessionId: SESSION,
fields
})
}),
...fields
})
})
it('requires a sha256 fingerprint and a positive fence', async () => {
await rejects(
'agentSession.send',
@@ -18,6 +18,7 @@ import {
structuredCallerFor as callerFor,
supportsStructuredSessions
} from './structured-agent-session-gate'
import type { AgentSessionAttachParams } from '../../../native-chat/agent-session-wire/structured-agent-session-attach'
import { STRUCTURED_AGENT_SESSION_HOLD_METHODS } from './structured-agent-session-hold'
import {
AttachParams,
@@ -25,6 +26,7 @@ import {
CreateParams,
CreateSupportParams,
HistoryParams,
HandoffParams,
HandoffStatusParams,
OptionsParams,
RespondParams,
@@ -86,22 +88,31 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [
}
})
await ensureHostInstalled(ctx)
const result = await requireHost(ctx).attach(callerFor(ctx), {
...resolved,
const { agent: _resolvedAgent, provider: _resolvedProvider, ...resolvedAttach } = resolved
const attachParams: AgentSessionAttachParams = {
...resolvedAttach,
provider: resolved.provider as 'claude' | 'codex',
agent: resolved.agent as 'claude' | 'codex',
envelope: { ...params.envelope, payloadFingerprint: hostFingerprint }
})
if (result.ok && resolved.agent === 'codex') {
}
const result = await requireHost(ctx).attach(callerFor(ctx), attachParams)
if (result.ok) {
ctx.runtime.publishStructuredAgentSessionTab({
workspaceId: resolved.location.workspaceId,
sessionId: result.value.sessionId,
agent: 'codex',
agent: resolved.agent as 'claude' | 'codex',
activate: true
})
}
return result
}
await ensureHostInstalled(ctx)
return requireHost(ctx).attach(callerFor(ctx), params)
const { agent: _attachAgent, provider: _attachProvider, ...attachWithoutAgent } = params
return requireHost(ctx).attach(callerFor(ctx), {
...attachWithoutAgent,
provider: params.provider as 'claude' | 'codex',
agent: params.agent as 'claude' | 'codex'
} as AgentSessionAttachParams)
}
}),
defineMethod({
@@ -109,7 +120,12 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [
params: AttachParams,
handler: async (params, ctx) => {
await ensureHostInstalled(ctx)
return requireHost(ctx).attach(callerFor(ctx), params)
const { agent: _attachAgent, provider: _attachProvider, ...attachWithoutAgent } = params
return requireHost(ctx).attach(callerFor(ctx), {
...attachWithoutAgent,
provider: params.provider as 'claude' | 'codex',
agent: params.agent as 'claude' | 'codex'
} as AgentSessionAttachParams)
}
}),
defineMethod({
@@ -149,6 +165,11 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [
params: SetOptionParams,
handler: async (params, ctx) => requireHost(ctx).setOption(callerFor(ctx), params)
}),
defineMethod({
name: 'agentSession.requestHandoff',
params: HandoffParams,
handler: async (params, ctx) => requireHost(ctx).requestHandoff(callerFor(ctx), params)
}),
defineMethod({
name: 'agentSession.handoffStatus',
params: HandoffStatusParams,
@@ -16,7 +16,15 @@ import {
CodexStructuredSessionAdapter,
type CodexStructuredSessionAdapterDeps
} from '../codex/codex-structured-session-adapter'
import { createClaudeStructuredLaunchResolver } from '../claude/claude-structured-launch-resolution'
import {
ClaudeStructuredSessionAdapter,
type ClaudeStructuredSessionAdapterDeps
} from '../claude/claude-structured-session-adapter'
import { claudeProviderHandleLink } from '../claude/claude-structured-owner-identity'
import { resolveClaudeCommand } from '../codex-cli/command'
import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host'
import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router'
import type { StructuredAgentSessionHandoffTransport } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types'
import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
import { AgentSessionRecordStore } from './agent-session-record-store'
@@ -55,13 +63,16 @@ export type StructuredAgentSessionRuntimeDeps = {
claimKeyId: string
resolveWorkspacePath: (workspaceId: string) => Promise<string>
resolveCodexCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string
resolveClaudeCommand?: () => string
/** Provider transports are overridden only to drive the runtime against scripted children. */
openCodexConnection?: CodexStructuredSessionAdapterDeps['openConnection']
openClaudeConnection?: ClaudeStructuredSessionAdapterDeps['openConnection']
/** Scripted app-servers carry fake pids the real start-time read cannot answer for. */
readProcessStartTime?: CodexStructuredSessionAdapterDeps['readProcessStartTime']
resolveLaunchArgs?: (provider: AgentSessionRecord['provider']) => Promise<string[]> | string[]
resolveLaunchEnv?: () => Promise<NodeJS.ProcessEnv>
resolveLaunchEnvOverlay?: () => Promise<Record<string, string>> | Record<string, string>
resolveClaudeLaunchEnv?: () => Promise<Record<string, string>> | Record<string, string>
resolveEnvironment?: () => Promise<NodeJS.ProcessEnv>
resolveCodexOverrides?: () => NodeJS.ProcessEnv
onError?: (input: { scope: string; error: unknown }) => void
@@ -71,7 +82,7 @@ export type StructuredAgentSessionRuntimeDeps = {
type InstalledRuntime = {
host: StructuredAgentSessionHost
adapter: CodexStructuredSessionAdapter
adapter: { closeAll(): Promise<void> }
}
let installing: Promise<InstalledRuntime> | null = null
@@ -110,7 +121,7 @@ export async function stopStructuredAgentSessionRuntime(): Promise<void> {
async function install(deps: StructuredAgentSessionRuntimeDeps): Promise<InstalledRuntime> {
const bootEnvironment = (deps.resolveEnvironment ?? resolveLoginShellEnvironment)()
const resolveEnvironment = async (): Promise<NodeJS.ProcessEnv> => ({
const resolveCodexEnvironment = async (): Promise<NodeJS.ProcessEnv> => ({
...(await bootEnvironment),
...(await deps.resolveLaunchEnv?.()),
...(await deps.resolveLaunchEnvOverlay?.()),
@@ -141,13 +152,43 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise<Install
resolveLaunch: createCodexStructuredLaunchResolver({
store,
resolveWorkspacePath: deps.resolveWorkspacePath,
resolveEnvironment,
resolveEnvironment: resolveCodexEnvironment,
...(deps.resolveCodexCommand ? { resolveCommand: deps.resolveCodexCommand } : {})
}),
...(deps.openCodexConnection ? { openConnection: deps.openCodexConnection } : {}),
...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {})
})
const adapter = codex
const claude = new ClaudeStructuredSessionAdapter({
resolveLaunch: createClaudeStructuredLaunchResolver({
store,
resolveWorkspacePath: deps.resolveWorkspacePath,
resolveCommand: deps.resolveClaudeCommand ?? resolveClaudeCommand,
...(deps.resolveClaudeLaunchEnv ? { resolveEnv: deps.resolveClaudeLaunchEnv } : {})
}),
persistHandle: async ({ sessionId, providerSessionId, leafUuid, fence }) => {
const currentFence = store.getRecord(sessionId)?.lease.runtimeFence ?? fence
const observedAt = Date.now()
await store.transitionHandoff(sessionId, (record) =>
recordAgentSessionProviderHandle({
record,
fence: currentFence,
link: claudeProviderHandleLink({
sessionId: providerSessionId,
leafUuid,
resumed: true,
fence: currentFence,
observedAt
}),
now: observedAt
})
)
},
...(deps.openClaudeConnection ? { openConnection: deps.openClaudeConnection } : {}),
...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {})
})
const adapter = new StructuredAgentSessionAdapterRouter({ codex, claude }, async () => {
await Promise.all([codex.closeAll(), claude.closeAll()])
})
const host = new StructuredAgentSessionHost({
store,
adapter,
+37 -2
View File
@@ -3304,7 +3304,10 @@
"ec2adf093e": "Launch {{value0}} in a new terminal",
"465e432ef1": "Could not build launch command for {{value0}}.",
"e518f544b1": "No agents detected",
"8dea9b5cdf": "No enabled agents"
"8dea9b5cdf": "No enabled agents",
"f378201fbd": "Could not create chat session",
"a847779775": "Start {{value0}} without a terminal",
"0ea6a78efb": "Chat session"
},
"RecentTabSwitcher": {
"329638ff6f": "Switch Tab",
@@ -16641,7 +16644,39 @@
},
"structuredSessionCloseFailed": "Could not close this Codex chat",
"structuredSessionLaunchFailed": "Could not open Codex chat",
"structuredSessionCloseFailedDescription": "The terminal stayed open so the provider remains recoverable."
"structuredSessionCloseFailedDescription": "The terminal stayed open so the provider remains recoverable.",
"handoff": {
"stage": {
"finishingChat": "Finishing chat session…",
"finishingTerminal": "Finishing agent terminal…",
"openingTerminal": "Opening agent terminal…",
"resumingChat": "Resuming chat session…",
"verifyingTerminal": "Verifying agent terminal…",
"verifyingChat": "Verifying chat session…",
"recovering": "Recovering agent session…",
"manualRecovery": "Agent session needs recovery"
},
"switchingOwner": "Switching session owner…",
"mode": {
"switching": "Switching",
"terminal": "Terminal",
"chat": "Chat"
},
"switchingAfterTurn": "Switching after this turn",
"returningAfterTurn": "Returning after this turn",
"cancel": "Cancel",
"switchAfterTurn": "Switch after this turn",
"stopTurnAndSwitch": "Stop turn and switch",
"openAgentTui": "Open agent TUI",
"returnAfterTurn": "Return after this turn",
"returnToChat": "Return to chat",
"agentOpenOnHost": "Agent is open in terminal on {{value0}}.",
"agentOpen": "Agent is open in terminal.",
"exitTerminal": "Exit the agent terminal to continue in chat.",
"retryProof": "Retry proof",
"retry": "Retry",
"details": "Details"
}
},
"tab": {
"bar": {
@@ -127,91 +127,6 @@ describe('applyWebSessionTabsSnapshot', () => {
expect(patch.activeTabTypeByWorktree?.[WT]).toBe('terminal')
})
it('projects structured agent sessions as native unified tabs', () => {
const agentTab = {
type: 'agent-session' as const,
id: 'agent-session:session-1',
title: 'Codex Chat',
sessionId: 'session-1',
agent: 'codex' as const,
isActive: true
}
const patch = applyWebSessionTabsSnapshot(
makeState(),
makeSnapshot([agentTab], {
activeTabId: agentTab.id,
activeTabType: 'agent-session',
tabGroups: [
{
id: 'host-group-1',
activeTabId: agentTab.id,
tabOrder: [agentTab.id]
}
]
}),
ENV,
NOW
)
expect(patch.unifiedTabsByWorktree?.[WT]).toEqual([
expect.objectContaining({
id: 'structured-agent-session-session-1',
entityId: 'session-1',
contentType: 'agent-session',
agentSessionAgent: 'codex'
})
])
expect(patch.activeTabTypeByWorktree?.[WT]).toBe('agent-session')
expect(
resolveHostSessionTabIdForWebSessionTab(
{ ...makeState(), ...patch },
{ environmentId: ENV, worktreeId: WT, tabId: 'structured-agent-session-session-1' }
)
).toBe(agentTab.id)
})
it('removes a restored structured tab when the host publishes no structured sessions', () => {
const structuredTab: Tab = {
id: 'structured-agent-session-session-1',
entityId: 'session-1',
groupId: 'host-group-1',
worktreeId: WT,
contentType: 'agent-session',
agentSessionAgent: 'codex',
label: 'Codex Chat',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: NOW
}
const patch = applyWebSessionTabsSnapshot(
makeState({
activeTabId: structuredTab.id,
activeTabIdByWorktree: { [WT]: structuredTab.id },
activeTabType: 'agent-session',
activeTabTypeByWorktree: { [WT]: 'agent-session' },
unifiedTabsByWorktree: { [WT]: [structuredTab] },
tabBarOrderByWorktree: { [WT]: [structuredTab.id] },
groupsByWorktree: {
[WT]: [
{
id: 'host-group-1',
worktreeId: WT,
activeTabId: structuredTab.id,
tabOrder: [structuredTab.id]
}
]
}
}),
makeSnapshot([], { activeTabType: null }),
ENV,
NOW
)
expect(patch.unifiedTabsByWorktree?.[WT]).toBeUndefined()
expect(patch.activeTabTypeByWorktree?.[WT]).toBe('terminal')
})
it('ignores stale or duplicate same-epoch snapshots after a newer version was applied', () => {
const state = makeState()
const newer = makeSnapshot([], { snapshotVersion: 3, activeTabType: null })
+19 -1
View File
@@ -63,7 +63,24 @@ const Block = z.union([
z.object({ type: z.string() }).refine((block) => !KNOWN_BLOCK_TYPES.has(block.type))
])
const PromptOption = z.object({ id: z.string(), label: z.string() })
const PromptOption = z
.object({
id: z.string(),
label: z.string(),
description: z.string().optional()
})
.strict()
const Question = z
.object({
id: z.string(),
question: z.string(),
header: z.string().optional(),
multiSelect: z.boolean(),
options: z.array(PromptOption),
freeTextQuestionId: z.string().optional()
})
.strict()
const Resolution = z.object({
state: z.string().min(1),
@@ -100,6 +117,7 @@ export const AgentJournalItemBodySchema = z.discriminatedUnion('kind', [
kind: z.literal('question'),
question: z.string(),
options: z.array(PromptOption),
questions: z.array(Question).optional(),
freeTextQuestionId: z.string().optional(),
resolution: Resolution
}),
+12
View File
@@ -111,6 +111,17 @@ export type AgentJournalResolution = {
export type AgentJournalPromptOption = {
id: string
label: string
description?: string
}
export type AgentJournalQuestion = {
id: string
question: string
header?: string
multiSelect: boolean
options: AgentJournalPromptOption[]
/** Present when the provider accepts an answer outside the offered options. */
freeTextQuestionId?: string
}
export type AgentJournalApprovalItem = {
@@ -125,6 +136,7 @@ export type AgentJournalQuestionItem = {
kind: 'question'
question: string
options: AgentJournalPromptOption[]
questions?: AgentJournalQuestion[]
/** Present when the provider accepts an answer outside the offered options. */
freeTextQuestionId?: string
resolution: AgentJournalResolution
+4
View File
@@ -123,6 +123,10 @@ export const AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY =
// can neither display nor drive. The host also refuses every agentSession.*
// method from a connection that does not advertise this.
export const STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY = 'agent-session.structured.v1' as const
// Why: mobile clients advertise Claude-structured session support during E2EE
// pairing so the desktop can keep structured-specific affordances enabled.
export const CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY =
'agent-session.structured.claude.v1' as const
// Why: paired structured clients explicitly hold every visible session surface, allowing the host
// to stop provider children after the last surface closes without tying lifetime to a transport.
export const STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY =
@@ -91,7 +91,7 @@ export type RuntimeMobileSessionAgentTab = {
id: string
title: string
sessionId: string
agent: 'codex'
agent: 'claude' | 'codex'
color?: string | null
isPinned?: boolean
isActive: boolean
@@ -26,6 +26,33 @@ export function structuredAgentSessionPayloadFingerprint(input: {
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
}
export function structuredAgentSessionCreateFingerprint(input: {
sessionId: string
worktree: string
agent: 'claude' | 'codex'
}): string {
return structuredAgentSessionPayloadFingerprint({
method: 'agentSession.create',
sessionId: input.sessionId,
fields: {
worktree: input.worktree,
agent: input.agent
}
})
}
export function showStructuredAgentSessionChoice(input: {
hostCapability: boolean
workspaceSupport: boolean
agent: string
}): boolean {
return (
input.hostCapability &&
input.workspaceSupport &&
(input.agent === 'claude' || input.agent === 'codex')
)
}
export function createStructuredAgentSessionOperationId(
randomUuid: () => string,
now: number = Date.now()