mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 08:03:12 +00:00
fix claude structured lane blockers
This commit is contained in:
@@ -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={[
|
||||
|
||||
Generated
+41
-20
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+19
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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. */
|
||||
|
||||
+2
-4
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+5
-2
@@ -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>()
|
||||
|
||||
+1
@@ -158,6 +158,7 @@ function adapter(): StructuredAgentSessionAdapter {
|
||||
})),
|
||||
closeSession: vi.fn(async () => {
|
||||
activeModel = DEFAULT_MODEL
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
|
||||
+7
-1
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user