mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
Add mobile structured session read flow
This commit is contained in:
@@ -43,6 +43,7 @@ const MOBILE_STREAMING_CLEANUP_RPC_METHODS = [
|
||||
// Why: shared-control unsubscribe methods are sent from generated cleanup
|
||||
// paths, so literal mobile source scanning cannot discover every one.
|
||||
'accounts.unsubscribe',
|
||||
'agentSession.unsubscribe',
|
||||
'browser.screencast.unsubscribe',
|
||||
'notifications.unsubscribe',
|
||||
'runtime.clientEvents.unsubscribe',
|
||||
|
||||
@@ -70,7 +70,10 @@ import {
|
||||
type AgentSessionClaimSigner
|
||||
} from './agent-session-claim-identity'
|
||||
import { ensureStructuredAgentSessionHost as installStructuredAgentSessionHost } from './structured-agent-session-runtime'
|
||||
import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
|
||||
import type { AgentSessionAttachParams } from '../native-chat/agent-session-wire/structured-agent-session-attach'
|
||||
import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths'
|
||||
import { getSystemCodexHomePath } from '../codex/codex-home-paths'
|
||||
import {
|
||||
agentSessionPtyWriteGate,
|
||||
type AgentSessionPtyWriteAdmittance
|
||||
@@ -337,6 +340,7 @@ import {
|
||||
type RuntimeMarkdownReadTabResult,
|
||||
type RuntimeMarkdownSaveTabResult,
|
||||
type RuntimeMobileSessionCreateTerminalResult,
|
||||
type RuntimeMobileSessionAgentTab,
|
||||
type RuntimeMobileSessionClientTab,
|
||||
type RuntimeMobileSessionTabCloseResult,
|
||||
type RuntimeMobileSessionMarkdownTab,
|
||||
@@ -8767,6 +8771,25 @@ export class OrcaRuntimeService {
|
||||
// Why: clients reorder the sanitized session.tabs.list model; raw groups
|
||||
// can still contain stale browser ids hidden from paired web clients.
|
||||
.filter((tabId) => returnedIds.has(tabId))
|
||||
const structuredIds = expected.filter((tabId) =>
|
||||
snapshot?.tabs.some((tab) => tab.type === 'agent-session' && tab.id === tabId)
|
||||
)
|
||||
if (structuredIds.some((tabId) => !seen.has(tabId))) {
|
||||
if (structuredIds.some((tabId) => seen.has(tabId))) {
|
||||
throw new Error('invalid_tab_order')
|
||||
}
|
||||
const visibleExpected = expected.filter((tabId) => !structuredIds.includes(tabId))
|
||||
if (
|
||||
normalized.length !== visibleExpected.length ||
|
||||
visibleExpected.some((tabId) => !seen.has(tabId))
|
||||
) {
|
||||
throw new Error('invalid_tab_order')
|
||||
}
|
||||
for (const tabId of structuredIds) {
|
||||
normalized.splice(Math.min(expected.indexOf(tabId), normalized.length), 0, tabId)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
// Why: reorder is a pure permutation of one existing group. Missing or
|
||||
// extra ids would let a paired web client silently move/lose host tabs.
|
||||
if (normalized.length !== expected.length || expected.some((tabId) => !seen.has(tabId))) {
|
||||
@@ -9168,6 +9191,150 @@ export class OrcaRuntimeService {
|
||||
})
|
||||
}
|
||||
|
||||
async getStructuredAgentSessionCreateSupport(
|
||||
worktreeSelector: string,
|
||||
agent: 'codex'
|
||||
): Promise<{ supported: boolean; reason?: 'agent' | 'remote' | 'wsl' }> {
|
||||
if (agent !== 'codex') {
|
||||
return { supported: false, reason: 'agent' }
|
||||
}
|
||||
const location = await this.resolveStructuredAgentSessionLocation(worktreeSelector)
|
||||
await this.ensureStructuredAgentSessionHost()
|
||||
if (getStructuredAgentSessionHost()?.supportsCreate(location, agent)) {
|
||||
return { supported: true }
|
||||
}
|
||||
return {
|
||||
supported: false,
|
||||
reason:
|
||||
location.executionHostId !== LOCAL_EXECUTION_HOST_ID
|
||||
? 'remote'
|
||||
: location.wslDistro
|
||||
? 'wsl'
|
||||
: 'agent'
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveStructuredAgentSessionLocation(worktreeSelector: string) {
|
||||
const target = await this.resolveRuntimeFileTarget(worktreeSelector)
|
||||
const repo = this.store?.getRepo(target.worktree.repoId)
|
||||
const wslDistro =
|
||||
repo && !target.connectionId
|
||||
? (getLocalProjectWorktreeGitOptions(this.requireStore(), repo).wslDistro ?? null)
|
||||
: null
|
||||
const folderWorkspace = this.store
|
||||
?.getFolderWorkspaces?.()
|
||||
.some((workspace) => workspace.id === target.worktree.id)
|
||||
return {
|
||||
executionHostId: getRuntimeFileTargetExecutionHostId({
|
||||
worktree: target.worktree,
|
||||
connectionId: target.connectionId
|
||||
}),
|
||||
wslDistro,
|
||||
workspaceId: target.worktree.id,
|
||||
workspaceKind: folderWorkspace ? ('folder' as const) : ('git-worktree' as const)
|
||||
}
|
||||
}
|
||||
|
||||
async resolveStructuredAgentSessionCreateIntent(input: {
|
||||
envelope: { sessionId: string; clientOperationId: string }
|
||||
worktree: string
|
||||
agent: 'codex'
|
||||
}): Promise<AgentSessionAttachParams> {
|
||||
const support = await this.getStructuredAgentSessionCreateSupport(input.worktree, input.agent)
|
||||
if (!support.supported) {
|
||||
throw new Error('structured_agent_session_unsupported')
|
||||
}
|
||||
const settings = this.requireStore().getSettings()
|
||||
const configuredHome = resolveTuiAgentLaunchEnv('codex', settings.agentDefaultEnv).CODEX_HOME
|
||||
const location = await this.resolveStructuredAgentSessionLocation(input.worktree)
|
||||
return {
|
||||
envelope: {
|
||||
sessionId: input.envelope.sessionId,
|
||||
clientOperationId: input.envelope.clientOperationId,
|
||||
expectedRuntimeFence: null,
|
||||
payloadFingerprint: ''
|
||||
},
|
||||
location,
|
||||
provider: 'codex',
|
||||
agent: 'codex',
|
||||
accountHome: {
|
||||
variable: 'CODEX_HOME',
|
||||
path: configuredHome?.trim() || getSystemCodexHomePath()
|
||||
},
|
||||
runtimeKind: 'native'
|
||||
}
|
||||
}
|
||||
|
||||
async restoreStructuredAgentSessionTabs(): Promise<void> {
|
||||
await this.ensureStructuredAgentSessionHost()
|
||||
const host = getStructuredAgentSessionHost()
|
||||
await host?.restoreReadableSessions()
|
||||
for (const session of host?.listSessionTabs() ?? []) {
|
||||
this.publishStructuredAgentSessionTab({ ...session, activate: false, notify: false })
|
||||
}
|
||||
}
|
||||
|
||||
publishStructuredAgentSessionTab(input: {
|
||||
workspaceId: string
|
||||
sessionId: string
|
||||
agent: 'codex'
|
||||
activate: boolean
|
||||
notify?: boolean
|
||||
}): void {
|
||||
const existing = this.mobileSessionTabsByWorktree.get(input.workspaceId)
|
||||
const id = `agent-session:${input.sessionId}`
|
||||
if (existing?.tabs.some((tab) => tab.id === id)) {
|
||||
return
|
||||
}
|
||||
const tab: RuntimeMobileSessionAgentTab = {
|
||||
type: 'agent-session',
|
||||
id,
|
||||
title: 'Codex Chat',
|
||||
sessionId: input.sessionId,
|
||||
agent: input.agent,
|
||||
isActive: input.activate
|
||||
}
|
||||
const tabs = [...(existing?.tabs ?? [])].map((candidate) => ({
|
||||
...candidate,
|
||||
isActive: input.activate ? false : candidate.isActive
|
||||
}))
|
||||
tabs.push(tab)
|
||||
const priorGroups = existing?.tabGroups ?? [
|
||||
{
|
||||
id: this.getHeadlessMobileSessionGroupId(input.workspaceId),
|
||||
activeTabId: existing?.activeTabId ?? null,
|
||||
tabOrder: []
|
||||
}
|
||||
]
|
||||
const groupId = priorGroups.some((group) => group.id === existing?.activeGroupId)
|
||||
? existing!.activeGroupId!
|
||||
: priorGroups[0]!.id
|
||||
const tabGroups = priorGroups.map((group) =>
|
||||
group.id === groupId
|
||||
? {
|
||||
...group,
|
||||
activeTabId: input.activate ? id : group.activeTabId,
|
||||
tabOrder: [...group.tabOrder, id]
|
||||
}
|
||||
: group
|
||||
)
|
||||
const snapshot: RuntimeMobileSessionTabsSnapshot = {
|
||||
worktree: input.workspaceId,
|
||||
publicationEpoch: existing?.publicationEpoch ?? `structured:${Date.now().toString(36)}`,
|
||||
snapshotVersion: (existing?.snapshotVersion ?? 0) + 1,
|
||||
activeGroupId: input.activate ? groupId : (existing?.activeGroupId ?? groupId),
|
||||
activeTabId: input.activate ? id : (existing?.activeTabId ?? null),
|
||||
activeTabType: input.activate ? 'agent-session' : (existing?.activeTabType ?? null),
|
||||
tabGroups,
|
||||
...(existing?.tabGroupLayout ? { tabGroupLayout: existing.tabGroupLayout } : {}),
|
||||
tabs
|
||||
}
|
||||
this.mobileSessionTabsByWorktree.set(input.workspaceId, snapshot)
|
||||
if (input.notify !== false) {
|
||||
this.emitMobileSessionTabsSnapshot(snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveRuntimeGitTarget(worktreeSelector: string): Promise<{
|
||||
worktree: ResolvedWorktree
|
||||
repo?: Repo
|
||||
@@ -30010,6 +30177,12 @@ export class OrcaRuntimeService {
|
||||
const terminalTabs = tabs.filter(
|
||||
(tab): tab is RuntimeMobileSessionTerminalTab => tab.type === 'terminal'
|
||||
)
|
||||
const tabGroups = this.mergeMobileSessionTabGroups(
|
||||
snapshot.worktree,
|
||||
snapshot.tabGroups ?? existing.tabGroups ?? [],
|
||||
terminalTabs,
|
||||
activeTab?.type === 'terminal' ? activeTab : null
|
||||
)
|
||||
return {
|
||||
...snapshot,
|
||||
publicationEpoch: this.getMergedMobileSessionPublicationEpoch(
|
||||
@@ -30020,16 +30193,40 @@ export class OrcaRuntimeService {
|
||||
activeGroupId: snapshot.activeGroupId ?? existing.activeGroupId,
|
||||
activeTabId: activeTab?.id ?? null,
|
||||
activeTabType: activeTab?.type ?? null,
|
||||
tabGroups: this.mergeMobileSessionTabGroups(
|
||||
snapshot.worktree,
|
||||
snapshot.tabGroups ?? existing.tabGroups ?? [],
|
||||
terminalTabs,
|
||||
activeTab?.type === 'terminal' ? activeTab : null
|
||||
tabGroups: this.mergeStructuredAgentSessionTabGroups(
|
||||
tabGroups,
|
||||
existing.tabGroups ?? [],
|
||||
normalizedPreservedTabs,
|
||||
activeTab?.id ?? null
|
||||
),
|
||||
tabs
|
||||
}
|
||||
}
|
||||
|
||||
private mergeStructuredAgentSessionTabGroups(
|
||||
groups: readonly RuntimeMobileSessionTabGroup[],
|
||||
existingGroups: readonly RuntimeMobileSessionTabGroup[],
|
||||
preservedTabs: readonly RuntimeMobileSessionSnapshotTab[],
|
||||
activeTabId: string | null
|
||||
): RuntimeMobileSessionTabGroup[] {
|
||||
const structuredTabs = preservedTabs.filter((tab) => tab.type === 'agent-session')
|
||||
if (structuredTabs.length === 0) {
|
||||
return [...groups]
|
||||
}
|
||||
const next = groups.map((group) => ({ ...group, tabOrder: [...group.tabOrder] }))
|
||||
for (const tab of structuredTabs) {
|
||||
const priorGroupId = existingGroups.find((group) => group.tabOrder.includes(tab.id))?.id
|
||||
const target = next.find((group) => group.id === priorGroupId) ?? next[0]
|
||||
if (target && !target.tabOrder.includes(tab.id)) {
|
||||
target.tabOrder.push(tab.id)
|
||||
}
|
||||
if (target && tab.id === activeTabId) {
|
||||
target.activeTabId = tab.id
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
private buildPreservedHeadlessMobileSessionSnapshot(
|
||||
existing: RuntimeMobileSessionTabsSnapshot
|
||||
): RuntimeMobileSessionTabsSnapshot | null {
|
||||
@@ -30100,6 +30297,9 @@ export class OrcaRuntimeService {
|
||||
snapshot: RuntimeMobileSessionTabsSnapshot,
|
||||
tab: RuntimeMobileSessionSnapshotTab
|
||||
): boolean {
|
||||
if (tab.type === 'agent-session') {
|
||||
return true
|
||||
}
|
||||
// Why: headless offscreen browser tabs exist only server-side, so a renderer-graph merge must keep them, not prune as "not in the graph".
|
||||
if (tab.type === 'browser') {
|
||||
if (!this.offscreenBrowserBackend) {
|
||||
@@ -30412,7 +30612,7 @@ export class OrcaRuntimeService {
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (tab.type === 'markdown' || tab.type === 'file') {
|
||||
if (tab.type === 'markdown' || tab.type === 'file' || tab.type === 'agent-session') {
|
||||
tabs.push(tab)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineMethod, type RpcAnyMethod } from '../core'
|
||||
import { ActivateTab, SaveMarkdownTab } from './session-tabs-schemas'
|
||||
|
||||
export const MOBILE_MARKDOWN_TAB_METHODS: RpcAnyMethod[] = [
|
||||
defineMethod({
|
||||
name: 'markdown.readTab',
|
||||
params: ActivateTab,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.readMobileMarkdownTab(params.worktree, params.tabId)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'markdown.saveTab',
|
||||
params: SaveMarkdownTab,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.saveMobileMarkdownTab(
|
||||
params.worktree,
|
||||
params.tabId,
|
||||
params.baseVersion,
|
||||
params.content
|
||||
)
|
||||
})
|
||||
]
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
|
||||
import {
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
|
||||
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
|
||||
} from '../../../../shared/protocol-version'
|
||||
import type { RuntimeMobileSessionTabsSnapshot } from '../../../../shared/runtime-types'
|
||||
import { projectSessionTabAgentStatus } from './session-tab-agent-status-projection'
|
||||
|
||||
@@ -34,6 +37,66 @@ function makeSnapshot(sessionBoundary: boolean): RuntimeMobileSessionTabsSnapsho
|
||||
}
|
||||
|
||||
describe('projectSessionTabAgentStatus', () => {
|
||||
it('projects structured tabs and dangling group focus out of old clients', () => {
|
||||
const snapshot: RuntimeMobileSessionTabsSnapshot = {
|
||||
...makeSnapshot(false),
|
||||
activeGroupId: 'group-a',
|
||||
activeTabId: 'agent-session:session-a',
|
||||
activeTabType: 'agent-session',
|
||||
tabGroups: [
|
||||
{
|
||||
id: 'group-a',
|
||||
activeTabId: 'agent-session:session-a',
|
||||
tabOrder: ['tab-1::leaf-1', 'agent-session:session-a'],
|
||||
recentTabIds: ['agent-session:session-a', 'tab-1::leaf-1']
|
||||
},
|
||||
{
|
||||
id: 'group-b',
|
||||
activeTabId: 'agent-session:session-b',
|
||||
tabOrder: ['agent-session:session-b']
|
||||
}
|
||||
],
|
||||
tabGroupLayout: {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: { type: 'leaf', groupId: 'group-a' },
|
||||
second: { type: 'leaf', groupId: 'group-b' }
|
||||
},
|
||||
tabs: [
|
||||
{ ...makeSnapshot(false).tabs[0]!, isActive: false },
|
||||
{
|
||||
type: 'agent-session',
|
||||
id: 'agent-session:session-a',
|
||||
title: 'Codex Chat',
|
||||
sessionId: 'session-a',
|
||||
agent: 'codex',
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
type: 'agent-session',
|
||||
id: 'agent-session:session-b',
|
||||
title: 'Codex Chat',
|
||||
sessionId: 'session-b',
|
||||
agent: 'codex',
|
||||
isActive: false
|
||||
}
|
||||
]
|
||||
}
|
||||
const oldClient = projectSessionTabAgentStatus(snapshot, 'mobile', [])
|
||||
expect(oldClient.tabs.map((tab) => tab.type)).toEqual(['terminal'])
|
||||
expect(oldClient.activeTabId).toBe('tab-1::leaf-1')
|
||||
expect(oldClient.activeTabType).toBe('terminal')
|
||||
expect(oldClient.tabs[0]?.isActive).toBe(true)
|
||||
expect(oldClient.tabGroups?.[0]?.tabOrder).toEqual(['tab-1::leaf-1'])
|
||||
expect(oldClient.tabGroups).toHaveLength(1)
|
||||
expect(oldClient.tabGroupLayout).toEqual({ type: 'leaf', groupId: 'group-a' })
|
||||
|
||||
const capable = projectSessionTabAgentStatus(snapshot, 'mobile', [
|
||||
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
|
||||
])
|
||||
expect(capable).toBe(snapshot)
|
||||
})
|
||||
|
||||
it('withholds session boundaries from legacy paired clients', () => {
|
||||
const projected = projectSessionTabAgentStatus(makeSnapshot(true), 'runtime', [])
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import type { RuntimeCapability } from '../../../../shared/protocol-version'
|
||||
import { AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
|
||||
import {
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
|
||||
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
|
||||
type RuntimeCapability
|
||||
} from '../../../../shared/protocol-version'
|
||||
import type {
|
||||
RuntimeMobileSessionTabsResult,
|
||||
RuntimeMobileSessionTabsSnapshot
|
||||
} from '../../../../shared/runtime-types'
|
||||
import type { TabGroupLayoutNode } from '../../../../shared/types'
|
||||
|
||||
type SessionTabsPayload = RuntimeMobileSessionTabsResult | RuntimeMobileSessionTabsSnapshot
|
||||
|
||||
@@ -12,16 +16,20 @@ export function projectSessionTabAgentStatus<TPayload extends SessionTabsPayload
|
||||
clientKind: 'mobile' | 'runtime' | undefined,
|
||||
clientCapabilities: readonly RuntimeCapability[] | undefined
|
||||
): TPayload {
|
||||
const structuredVisible =
|
||||
clientKind === undefined ||
|
||||
(clientCapabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) ?? false)
|
||||
const projected = structuredVisible ? payload : projectStructuredTabsOut(payload)
|
||||
// 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' ||
|
||||
clientCapabilities?.includes(AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY)
|
||||
) {
|
||||
return payload
|
||||
return projected
|
||||
}
|
||||
|
||||
let changed = false
|
||||
const tabs = payload.tabs.map((tab) => {
|
||||
const tabs = projected.tabs.map((tab) => {
|
||||
if (tab.type !== 'terminal' || !tab.agentStatus?.sessionBoundary) {
|
||||
return tab
|
||||
}
|
||||
@@ -29,5 +37,67 @@ export function projectSessionTabAgentStatus<TPayload extends SessionTabsPayload
|
||||
const { agentStatus: _boundary, ...legacyTab } = tab
|
||||
return legacyTab
|
||||
})
|
||||
return changed ? ({ ...payload, tabs } as TPayload) : payload
|
||||
return changed ? ({ ...projected, tabs } as TPayload) : projected
|
||||
}
|
||||
|
||||
function projectStructuredTabsOut<TPayload extends SessionTabsPayload>(
|
||||
payload: TPayload
|
||||
): TPayload {
|
||||
const hiddenIds = new Set(
|
||||
payload.tabs.filter((tab) => tab.type === 'agent-session').map((tab) => tab.id)
|
||||
)
|
||||
if (hiddenIds.size === 0) {
|
||||
return payload
|
||||
}
|
||||
const tabs = payload.tabs.filter((tab) => !hiddenIds.has(tab.id))
|
||||
const groups = payload.tabGroups
|
||||
?.map((group) => {
|
||||
const tabOrder = group.tabOrder.filter((id) => !hiddenIds.has(id))
|
||||
if (tabOrder.length === 0) {
|
||||
return null
|
||||
}
|
||||
const recentTabIds = group.recentTabIds?.filter((id) => !hiddenIds.has(id))
|
||||
return {
|
||||
...group,
|
||||
activeTabId:
|
||||
group.activeTabId && tabOrder.includes(group.activeTabId)
|
||||
? group.activeTabId
|
||||
: (recentTabIds?.find((id) => tabOrder.includes(id)) ?? tabOrder[0] ?? null),
|
||||
tabOrder,
|
||||
...(recentTabIds ? { recentTabIds } : {})
|
||||
}
|
||||
})
|
||||
.filter((group): group is NonNullable<typeof group> => group !== null)
|
||||
const active =
|
||||
tabs.find((tab) => tab.id === payload.activeTabId) ??
|
||||
tabs.find((tab) => tab.isActive) ??
|
||||
tabs[0] ??
|
||||
null
|
||||
const validGroupIds = new Set(groups?.map((group) => group.id) ?? [])
|
||||
return {
|
||||
...payload,
|
||||
activeGroupId:
|
||||
groups?.find((group) => group.tabOrder.includes(active?.id ?? ''))?.id ??
|
||||
groups?.[0]?.id ??
|
||||
null,
|
||||
activeTabId: active?.id ?? null,
|
||||
activeTabType: active?.type ?? null,
|
||||
...(groups ? { tabGroups: groups } : { tabGroups: undefined }),
|
||||
...(payload.tabGroupLayout !== undefined
|
||||
? { tabGroupLayout: pruneStructuredTabGroupLayout(payload.tabGroupLayout, validGroupIds) }
|
||||
: {}),
|
||||
tabs: tabs.map((tab) => ({ ...tab, isActive: tab.id === active?.id }))
|
||||
} as TPayload
|
||||
}
|
||||
|
||||
function pruneStructuredTabGroupLayout(
|
||||
layout: TabGroupLayoutNode | null,
|
||||
validGroupIds: ReadonlySet<string>
|
||||
): TabGroupLayoutNode | null {
|
||||
if (!layout || layout.type === 'leaf') {
|
||||
return layout && validGroupIds.has(layout.groupId) ? layout : null
|
||||
}
|
||||
const first = pruneStructuredTabGroupLayout(layout.first, validGroupIds)
|
||||
const second = pruneStructuredTabGroupLayout(layout.second, validGroupIds)
|
||||
return first && second ? { ...layout, first, second } : (first ?? second)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,17 @@ function setMobileSessionSnapshot(
|
||||
).mobileSessionTabsByWorktree.set(snapshot.worktree, snapshot)
|
||||
}
|
||||
|
||||
function getMobileSessionSnapshot(
|
||||
runtime: OrcaRuntimeService,
|
||||
worktree: string
|
||||
): RuntimeMobileSessionTabsSnapshot | undefined {
|
||||
return (
|
||||
runtime as unknown as {
|
||||
mobileSessionTabsByWorktree: Map<string, RuntimeMobileSessionTabsSnapshot>
|
||||
}
|
||||
).mobileSessionTabsByWorktree.get(worktree)
|
||||
}
|
||||
|
||||
function terminalTab() {
|
||||
return {
|
||||
type: 'terminal' as const,
|
||||
@@ -50,6 +61,154 @@ function browserTab({
|
||||
}
|
||||
|
||||
describe('session tab move validation', () => {
|
||||
it('preserves a structured tab across renderer-authored snapshot sync', () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const structured = {
|
||||
type: 'agent-session' as const,
|
||||
id: 'agent-session:session-a',
|
||||
title: 'Codex Chat',
|
||||
sessionId: 'session-a',
|
||||
agent: 'codex' as const,
|
||||
isActive: false
|
||||
}
|
||||
setMobileSessionSnapshot(runtime, {
|
||||
worktree: 'wt-1',
|
||||
publicationEpoch: 'structured-epoch',
|
||||
snapshotVersion: 2,
|
||||
activeGroupId: 'group-1',
|
||||
activeTabId: 'terminal-tab::leaf-1',
|
||||
activeTabType: 'terminal',
|
||||
tabGroups: [
|
||||
{
|
||||
id: 'group-1',
|
||||
activeTabId: 'terminal-tab',
|
||||
tabOrder: ['terminal-tab', structured.id]
|
||||
}
|
||||
],
|
||||
tabs: [terminalTab(), structured]
|
||||
})
|
||||
const incoming: RuntimeMobileSessionTabsSnapshot = {
|
||||
worktree: 'wt-1',
|
||||
publicationEpoch: 'renderer-epoch',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: 'group-1',
|
||||
activeTabId: 'terminal-tab::leaf-1',
|
||||
activeTabType: 'terminal',
|
||||
tabGroups: [{ id: 'group-1', activeTabId: 'terminal-tab', tabOrder: ['terminal-tab'] }],
|
||||
tabs: [terminalTab()]
|
||||
}
|
||||
|
||||
;(
|
||||
runtime as unknown as {
|
||||
syncMobileSessionTabs(snapshots: RuntimeMobileSessionTabsSnapshot[]): Set<string>
|
||||
}
|
||||
).syncMobileSessionTabs([incoming])
|
||||
|
||||
const snapshot = getMobileSessionSnapshot(runtime, 'wt-1')
|
||||
expect(snapshot?.tabs).toContainEqual(structured)
|
||||
expect(snapshot?.tabGroups?.[0]?.tabOrder).toEqual(['terminal-tab', 'agent-session:session-a'])
|
||||
})
|
||||
|
||||
it('publishes a structured tab into the active group instead of the first group', () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
setMobileSessionSnapshot(runtime, {
|
||||
worktree: 'wt-1',
|
||||
publicationEpoch: 'epoch-1',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: 'group-2',
|
||||
activeTabId: 'file-tab',
|
||||
activeTabType: 'file',
|
||||
tabGroups: [
|
||||
{ id: 'group-1', activeTabId: 'terminal-tab', tabOrder: ['terminal-tab'] },
|
||||
{ id: 'group-2', activeTabId: 'file-tab', tabOrder: ['file-tab'] }
|
||||
],
|
||||
tabs: [
|
||||
terminalTab(),
|
||||
{
|
||||
type: 'file',
|
||||
id: 'file-tab',
|
||||
title: 'README.md',
|
||||
filePath: 'README.md',
|
||||
relativePath: 'README.md',
|
||||
language: 'markdown',
|
||||
isDirty: false,
|
||||
isActive: true
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
runtime.publishStructuredAgentSessionTab({
|
||||
workspaceId: 'wt-1',
|
||||
sessionId: 'session-a',
|
||||
agent: 'codex',
|
||||
activate: true
|
||||
})
|
||||
|
||||
const snapshot = getMobileSessionSnapshot(runtime, 'wt-1')
|
||||
expect(snapshot?.activeGroupId).toBe('group-2')
|
||||
expect(snapshot?.tabGroups?.[0]?.tabOrder).toEqual(['terminal-tab'])
|
||||
expect(snapshot?.tabGroups?.[1]).toMatchObject({
|
||||
activeTabId: 'agent-session:session-a',
|
||||
tabOrder: ['file-tab', 'agent-session:session-a']
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves a capability-hidden structured tab during an old-client reorder', async () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const moveSessionTab = vi.fn()
|
||||
runtime.setNotifier({ moveSessionTab } as never)
|
||||
setMobileSessionSnapshot(runtime, {
|
||||
worktree: 'wt-1',
|
||||
publicationEpoch: 'epoch-1',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: 'group-1',
|
||||
activeTabId: 'terminal-tab::leaf-1',
|
||||
activeTabType: 'terminal',
|
||||
tabGroups: [
|
||||
{
|
||||
id: 'group-1',
|
||||
activeTabId: 'terminal-tab',
|
||||
tabOrder: ['terminal-tab', 'agent-session:session-a', 'file-tab']
|
||||
}
|
||||
],
|
||||
tabs: [
|
||||
terminalTab(),
|
||||
{
|
||||
type: 'agent-session',
|
||||
id: 'agent-session:session-a',
|
||||
title: 'Codex Chat',
|
||||
sessionId: 'session-a',
|
||||
agent: 'codex',
|
||||
isActive: false
|
||||
},
|
||||
{
|
||||
type: 'file',
|
||||
id: 'file-tab',
|
||||
title: 'README.md',
|
||||
filePath: 'README.md',
|
||||
relativePath: 'README.md',
|
||||
language: 'markdown',
|
||||
isDirty: false,
|
||||
isActive: false
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
await runtime.moveMobileSessionTab('id:wt-1', {
|
||||
kind: 'reorder',
|
||||
tabId: 'file-tab',
|
||||
targetGroupId: 'group-1',
|
||||
tabOrder: ['file-tab', 'terminal-tab']
|
||||
})
|
||||
|
||||
expect(moveSessionTab).toHaveBeenCalledWith('wt-1', {
|
||||
kind: 'reorder',
|
||||
tabId: 'file-tab',
|
||||
targetGroupId: 'group-1',
|
||||
tabOrder: ['file-tab', 'agent-session:session-a', 'terminal-tab']
|
||||
})
|
||||
})
|
||||
|
||||
it('validates reorder moves against sanitized visible tab groups', async () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const moveSessionTab = vi.fn()
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
ActivateTab,
|
||||
CreateTerminalTab,
|
||||
MoveTab,
|
||||
SaveMarkdownTab,
|
||||
SessionTabsUnsubscribe,
|
||||
SetTabProps,
|
||||
UpdatePaneLayout,
|
||||
@@ -13,26 +12,33 @@ import {
|
||||
} from './session-tabs-schemas'
|
||||
import { SESSION_TAB_CLOSE_METHODS } from './session-tab-close-methods'
|
||||
import { projectSessionTabAgentStatus } from './session-tab-agent-status-projection'
|
||||
import { restoreStructuredTabsIfSupported } from './structured-session-tab-restore'
|
||||
import { MOBILE_MARKDOWN_TAB_METHODS } from './mobile-markdown-tab-methods'
|
||||
|
||||
export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
|
||||
defineMethod({
|
||||
name: 'session.tabs.list',
|
||||
params: WorktreeTabSelector,
|
||||
handler: async (params, { runtime, pairedDeviceId, clientKind, clientCapabilities }) =>
|
||||
projectSessionTabAgentStatus(
|
||||
handler: async (params, { runtime, pairedDeviceId, clientKind, clientCapabilities }) => {
|
||||
await restoreStructuredTabsIfSupported(runtime, clientCapabilities)
|
||||
return projectSessionTabAgentStatus(
|
||||
await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId),
|
||||
clientKind,
|
||||
clientCapabilities
|
||||
)
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'session.tabs.listAll',
|
||||
params: null,
|
||||
handler: async (_params, { runtime, pairedDeviceId, clientKind, clientCapabilities }) => ({
|
||||
snapshots: (await runtime.listAllMobileSessionTabs(pairedDeviceId)).map((snapshot) =>
|
||||
projectSessionTabAgentStatus(snapshot, clientKind, clientCapabilities)
|
||||
)
|
||||
})
|
||||
handler: async (_params, { runtime, pairedDeviceId, clientKind, clientCapabilities }) => {
|
||||
await restoreStructuredTabsIfSupported(runtime, clientCapabilities)
|
||||
return {
|
||||
snapshots: (await runtime.listAllMobileSessionTabs(pairedDeviceId)).map((snapshot) =>
|
||||
projectSessionTabAgentStatus(snapshot, clientKind, clientCapabilities)
|
||||
)
|
||||
}
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'session.tabs.activate',
|
||||
@@ -144,6 +150,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
|
||||
let unsubscribe = (): void => {}
|
||||
let closed = false
|
||||
let initialized = false
|
||||
await restoreStructuredTabsIfSupported(runtime, clientCapabilities)
|
||||
const initial = await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId)
|
||||
if (closed) {
|
||||
return
|
||||
@@ -222,8 +229,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
|
||||
let initialized = false
|
||||
const cleanupPrefix = `session.tabs:${connectionId ?? 'local'}:*`
|
||||
const subscriptionId = requestId ? `${cleanupPrefix}:${requestId}` : cleanupPrefix
|
||||
// Why: shared-control can carry multiple all-tab subscribers on one
|
||||
// socket; include the RPC id so closing one does not evict siblings.
|
||||
// Why: include the RPC id so one shared-control stream cannot evict siblings.
|
||||
runtime.registerSubscriptionCleanup(
|
||||
subscriptionId,
|
||||
() => {
|
||||
@@ -239,6 +245,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
await restoreStructuredTabsIfSupported(runtime, clientCapabilities)
|
||||
const snapshots = await Promise.resolve(
|
||||
runtime.listAllMobileSessionTabs(pairedDeviceId)
|
||||
).catch((error) => {
|
||||
@@ -285,21 +292,5 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
|
||||
return { unsubscribed: true }
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'markdown.readTab',
|
||||
params: ActivateTab,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.readMobileMarkdownTab(params.worktree, params.tabId)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'markdown.saveTab',
|
||||
params: SaveMarkdownTab,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.saveMobileMarkdownTab(
|
||||
params.worktree,
|
||||
params.tabId,
|
||||
params.baseVersion,
|
||||
params.content
|
||||
)
|
||||
})
|
||||
...MOBILE_MARKDOWN_TAB_METHODS
|
||||
]
|
||||
|
||||
@@ -94,6 +94,23 @@ export const AttachParams = z
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const CreateIntentParams = z
|
||||
.object({
|
||||
envelope: MutationEnvelope,
|
||||
worktree: Identifier('Invalid worktree selector'),
|
||||
agent: z.literal('codex')
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const CreateParams = z.union([AttachParams, CreateIntentParams])
|
||||
|
||||
export const CreateSupportParams = z
|
||||
.object({
|
||||
worktree: Identifier('Invalid worktree selector'),
|
||||
agent: z.literal('codex')
|
||||
})
|
||||
.strict()
|
||||
|
||||
/** Clients may only author user turns. Accepting an assistant or tool role here
|
||||
* would let one client write words into the agent's mouth in another's
|
||||
* timeline, and the provider — not the client — owns those. */
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { RpcRequest, RpcResponse } from '../core'
|
||||
import { RpcDispatcher } from '../dispatcher'
|
||||
import { ALL_RPC_METHODS } from './index'
|
||||
import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session'
|
||||
import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope'
|
||||
|
||||
const SESSION = 'session-alpha'
|
||||
const FINGERPRINT = 'f'.repeat(64)
|
||||
@@ -60,10 +61,27 @@ function request(method: string, params: unknown): RpcRequest {
|
||||
}
|
||||
|
||||
let hostCalls: Record<string, ReturnType<typeof vi.fn>>
|
||||
let runtimeCalls: Record<string, ReturnType<typeof vi.fn>>
|
||||
|
||||
function hostStub(): StructuredAgentSessionHost {
|
||||
hostCalls = {
|
||||
attach: vi.fn(async () => ({ ok: true, replayed: false })),
|
||||
attach: vi.fn(async () => ({
|
||||
ok: true,
|
||||
replayed: false,
|
||||
fence: 1,
|
||||
cursor: { epoch: 'epoch-a', sequence: 0 },
|
||||
value: {
|
||||
sessionId: SESSION,
|
||||
fence: 1,
|
||||
snapshot: {
|
||||
sessionId: SESSION,
|
||||
cursor: { epoch: 'epoch-a', sequence: 0 },
|
||||
items: [],
|
||||
submissions: []
|
||||
},
|
||||
unconfirmedClientMessageIds: []
|
||||
}
|
||||
})),
|
||||
send: vi.fn(async () => ({ ok: true, replayed: false })),
|
||||
cancel: vi.fn(async () => ({ ok: true, replayed: false })),
|
||||
respondToPrompt: vi.fn(async () => ({ ok: true, replayed: false })),
|
||||
@@ -76,11 +94,29 @@ function hostStub(): StructuredAgentSessionHost {
|
||||
}
|
||||
|
||||
function dispatcher(): RpcDispatcher {
|
||||
runtimeCalls = {
|
||||
getStructuredAgentSessionCreateSupport: vi.fn(async () => ({ supported: true })),
|
||||
resolveStructuredAgentSessionCreateIntent: vi.fn(async (params) => ({
|
||||
envelope: params.envelope,
|
||||
location: {
|
||||
executionHostId: 'local',
|
||||
wslDistro: null,
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceKind: 'git-worktree'
|
||||
},
|
||||
provider: 'codex',
|
||||
agent: 'codex',
|
||||
accountHome: { variable: 'CODEX_HOME', path: '/host/.codex' },
|
||||
runtimeKind: 'native'
|
||||
})),
|
||||
publishStructuredAgentSessionTab: vi.fn()
|
||||
}
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'runtime-1',
|
||||
registerSubscriptionCleanup: vi.fn(),
|
||||
cleanupSubscription: vi.fn(),
|
||||
cleanupSubscriptionsByPrefix: vi.fn()
|
||||
cleanupSubscriptionsByPrefix: vi.fn(),
|
||||
...runtimeCalls
|
||||
}
|
||||
return new RpcDispatcher({
|
||||
runtime: runtime as unknown as OrcaRuntimeService,
|
||||
@@ -134,7 +170,7 @@ describe('capability gating', () => {
|
||||
for (const method of STRUCTURED_AGENT_SESSION_METHODS) {
|
||||
expect(names).toContain(method.name)
|
||||
}
|
||||
expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(10)
|
||||
expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(11)
|
||||
})
|
||||
|
||||
it('hides the surface from a declared client that did not advertise it', async () => {
|
||||
@@ -149,6 +185,32 @@ describe('capability gating', () => {
|
||||
expect(hostCalls.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects create intent before resolving host-owned fields for an old client', async () => {
|
||||
const worktree = 'id:workspace-1'
|
||||
const response = await call(
|
||||
'agentSession.create',
|
||||
{
|
||||
envelope: envelope({
|
||||
expectedRuntimeFence: null,
|
||||
payloadFingerprint: computeAgentSessionPayloadFingerprint({
|
||||
method: 'agentSession.create',
|
||||
sessionId: SESSION,
|
||||
fields: { worktree, agent: 'codex' }
|
||||
})
|
||||
}),
|
||||
worktree,
|
||||
agent: 'codex'
|
||||
},
|
||||
{ clientKind: 'mobile', clientCapabilities: [] }
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: false,
|
||||
error: { message: expect.stringContaining('structured_agent_session_unsupported') }
|
||||
})
|
||||
expect(runtimeCalls.resolveStructuredAgentSessionCreateIntent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('serves a client that advertised it', async () => {
|
||||
const response = await call('agentSession.send', sendParams(), STRUCTURED_CLIENT)
|
||||
expect(response).toMatchObject({ ok: true })
|
||||
@@ -168,6 +230,35 @@ describe('capability gating', () => {
|
||||
})
|
||||
|
||||
describe('method routing', () => {
|
||||
it('creates from mobile intent while the host resolves paths and provider identity', async () => {
|
||||
const worktree = 'id:workspace-1'
|
||||
const params = {
|
||||
envelope: envelope({
|
||||
expectedRuntimeFence: null,
|
||||
payloadFingerprint: computeAgentSessionPayloadFingerprint({
|
||||
method: 'agentSession.create',
|
||||
sessionId: SESSION,
|
||||
fields: { worktree, agent: 'codex' }
|
||||
})
|
||||
}),
|
||||
worktree,
|
||||
agent: 'codex'
|
||||
}
|
||||
const 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: 'CODEX_HOME', path: '/host/.codex' }
|
||||
})
|
||||
)
|
||||
expect(hostCalls.attach.mock.calls[0]?.[1]).not.toHaveProperty('providerHandle')
|
||||
expect(runtimeCalls.publishStructuredAgentSessionTab).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sessionId: SESSION, activate: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('separates create from ensure by the fence the client may declare', async () => {
|
||||
const created = await call('agentSession.create', attachParams(), STRUCTURED_CLIENT)
|
||||
expect(created).toMatchObject({ ok: true })
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
// structured session.
|
||||
|
||||
import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
|
||||
import {
|
||||
agentSessionFingerprintConflict,
|
||||
computeAgentSessionPayloadFingerprint
|
||||
} from '../../../../shared/agent-session-mutation-envelope'
|
||||
import { getStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry'
|
||||
import type {
|
||||
StructuredAgentSessionCaller,
|
||||
@@ -16,6 +20,8 @@ import { defineMethod, defineStreamingMethod, type RpcAnyMethod, type RpcContext
|
||||
import {
|
||||
AttachParams,
|
||||
CancelParams,
|
||||
CreateParams,
|
||||
CreateSupportParams,
|
||||
HistoryParams,
|
||||
RespondParams,
|
||||
SendParams,
|
||||
@@ -37,8 +43,15 @@ function supportsStructuredSessions(ctx: RpcContext): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
function requireStructuredCapability(ctx: RpcContext): void {
|
||||
if (!supportsStructuredSessions(ctx)) {
|
||||
throw new Error('structured_agent_session_unsupported')
|
||||
}
|
||||
}
|
||||
|
||||
function requireHost(ctx: RpcContext): StructuredAgentSessionHost {
|
||||
const host = supportsStructuredSessions(ctx) ? getStructuredAgentSessionHost() : null
|
||||
requireStructuredCapability(ctx)
|
||||
const host = getStructuredAgentSessionHost()
|
||||
if (!host) {
|
||||
throw new Error('structured_agent_session_unsupported')
|
||||
}
|
||||
@@ -74,12 +87,61 @@ function subscriptionIdFor(ctx: RpcContext, sessionId: string): string {
|
||||
|
||||
export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [
|
||||
defineMethod({
|
||||
name: 'agentSession.create',
|
||||
params: AttachParams,
|
||||
name: 'agentSession.createSupport',
|
||||
params: CreateSupportParams,
|
||||
handler: async (params, ctx) => {
|
||||
if (!supportsStructuredSessions(ctx)) {
|
||||
throw new Error('structured_agent_session_unsupported')
|
||||
}
|
||||
return ctx.runtime.getStructuredAgentSessionCreateSupport(params.worktree, params.agent)
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'agentSession.create',
|
||||
params: CreateParams,
|
||||
handler: async (params, ctx) => {
|
||||
requireStructuredCapability(ctx)
|
||||
if (params.envelope.expectedRuntimeFence !== null) {
|
||||
throw new Error('agent_session_operation_invalid')
|
||||
}
|
||||
if ('worktree' in params) {
|
||||
const intentFingerprint = computeAgentSessionPayloadFingerprint({
|
||||
method: 'agentSession.create',
|
||||
sessionId: params.envelope.sessionId,
|
||||
fields: { worktree: params.worktree, agent: params.agent }
|
||||
})
|
||||
const conflict = agentSessionFingerprintConflict(params.envelope, intentFingerprint)
|
||||
if (conflict) {
|
||||
return { ok: false, refusal: conflict }
|
||||
}
|
||||
const resolved = await ctx.runtime.resolveStructuredAgentSessionCreateIntent(params)
|
||||
const hostFingerprint = computeAgentSessionPayloadFingerprint({
|
||||
method: 'agentSession.attach',
|
||||
sessionId: params.envelope.sessionId,
|
||||
fields: {
|
||||
location: resolved.location,
|
||||
provider: resolved.provider,
|
||||
agent: resolved.agent,
|
||||
accountHome: resolved.accountHome,
|
||||
runtimeKind: resolved.runtimeKind,
|
||||
expectedRuntimeFence: null
|
||||
}
|
||||
})
|
||||
await ensureHostInstalled(ctx)
|
||||
const result = await requireHost(ctx).attach(callerFor(ctx), {
|
||||
...resolved,
|
||||
envelope: { ...params.envelope, payloadFingerprint: hostFingerprint }
|
||||
})
|
||||
if (result.ok) {
|
||||
ctx.runtime.publishStructuredAgentSessionTab({
|
||||
workspaceId: resolved.location.workspaceId,
|
||||
sessionId: result.value.sessionId,
|
||||
agent: 'codex',
|
||||
activate: true
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
await ensureHostInstalled(ctx)
|
||||
return requireHost(ctx).attach(callerFor(ctx), params)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
|
||||
import type { RpcContext } from '../core'
|
||||
|
||||
export async function restoreStructuredTabsIfSupported(
|
||||
runtime: RpcContext['runtime'],
|
||||
capabilities: readonly string[] | undefined
|
||||
): Promise<void> {
|
||||
if (capabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY)) {
|
||||
await runtime.restoreStructuredAgentSessionTabs()
|
||||
}
|
||||
}
|
||||
@@ -185,6 +185,11 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
|
||||
'accounts.unsubscribe',
|
||||
'aiVault.listSessions',
|
||||
'aiVault.prepareSessionResume',
|
||||
'agentSession.create',
|
||||
'agentSession.createSupport',
|
||||
'agentSession.history',
|
||||
'agentSession.subscribe',
|
||||
'agentSession.unsubscribe',
|
||||
'browser.back',
|
||||
'browser.dialogAccept',
|
||||
'browser.dialogDismiss',
|
||||
|
||||
Reference in New Issue
Block a user