mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 00:03:59 +00:00
Merge remote-tracking branch 'origin/brennanb2025/structured-holder-and-entry-fixes' into brennanb2025/structured-session-eviction
This commit is contained in:
@@ -21,6 +21,17 @@ describe('Codex TUI rollout proof', () => {
|
||||
expect(parseCodexTuiStatusSessionId('Session: not-a-session')).toBeNull()
|
||||
})
|
||||
|
||||
it('parses the Codex 0.148 status screen and semantic thread labels', () => {
|
||||
const status = [
|
||||
'\u001b[2m│ >_ OpenAI Codex (v0.148.0) │',
|
||||
'│ Model: gpt-5.6-sol (reasoning high, summaries auto) │',
|
||||
`│ Session: \u001b[22m${THREAD}\u001b[2m │`
|
||||
].join('\n')
|
||||
|
||||
expect(parseCodexTuiStatusSessionId(status)).toBe(THREAD)
|
||||
expect(parseCodexTuiStatusSessionId(`Thread ID: ${OTHER_THREAD}`)).toBe(OTHER_THREAD)
|
||||
})
|
||||
|
||||
it('uses Kitty Enter only while the TUI negotiated Kitty input', () => {
|
||||
expect(codexTuiStatusProbeInput(0)).toEqual({
|
||||
command: '\u001b[200~/status\u001b[201~',
|
||||
@@ -151,4 +162,33 @@ describe('Codex TUI rollout proof', () => {
|
||||
'\u001b'
|
||||
])
|
||||
})
|
||||
|
||||
it('accepts a proven blank Codex 0.148 session before its lazy rollout exists', async () => {
|
||||
let reads = 0
|
||||
const write = vi.fn((_data: string) => true)
|
||||
const resolveRollout = vi.fn(async () => null)
|
||||
|
||||
await expect(
|
||||
resolveLiveCodexTuiRollout({
|
||||
codexHome: '/pinned',
|
||||
kittyKeyboardFlags: 1,
|
||||
readOutput: () => ({
|
||||
text:
|
||||
reads++ > 0
|
||||
? `│ >_ OpenAI Codex (v0.148.0) │\n│ Session: \u001b[22m${THREAD}\u001b[2m │`
|
||||
: '',
|
||||
lastOutputAt: reads > 1 ? 2 : 1
|
||||
}),
|
||||
write,
|
||||
resolveRollout,
|
||||
delay: async () => undefined
|
||||
})
|
||||
).resolves.toEqual({ threadId: THREAD })
|
||||
expect(resolveRollout).toHaveBeenCalledTimes(5)
|
||||
expect(write.mock.calls.map(([data]) => data)).toEqual([
|
||||
'\u001b[200~/status\u001b[201~',
|
||||
'\u001b[13u',
|
||||
'\u001b'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,10 @@ import { relativePathInsideRoot } from '../../shared/cross-platform-path'
|
||||
import { listCodexSessionJsonlFilesIncrementally } from './codex-session-file-listing'
|
||||
|
||||
const SESSION_ID_PATTERN = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'
|
||||
const STATUS_SESSION_RE = new RegExp(`\\bSession(?: ID)?\\s*:\\s*(${SESSION_ID_PATTERN})\\b`, 'gi')
|
||||
const STATUS_SESSION_RE = new RegExp(
|
||||
`\\b(?:Session|Thread)(?:\\s+ID)?\\s*:\\s*(${SESSION_ID_PATTERN})\\b`,
|
||||
'gi'
|
||||
)
|
||||
const ROLLOUT_READ_LIMIT = 64 * 1024
|
||||
const STATUS_COMMAND_PASTE = '\u001b[200~/status\u001b[201~'
|
||||
const KITTY_ENTER = '\u001b[13u'
|
||||
@@ -125,7 +128,7 @@ export async function resolveLiveCodexTuiRollout(input: {
|
||||
timeoutMs?: number
|
||||
resolveRollout?: (codexHome: string, threadId: string) => Promise<string | null>
|
||||
delay?: (ms: number) => Promise<void>
|
||||
}): Promise<{ threadId: string; transcriptPath: string }> {
|
||||
}): Promise<{ threadId: string; transcriptPath?: string }> {
|
||||
const baselineOutputAt = input.readOutput().lastOutputAt
|
||||
const probe = codexTuiStatusProbeInput(input.kittyKeyboardFlags)
|
||||
if (!input.write(probe.command)) {
|
||||
@@ -144,14 +147,19 @@ export async function resolveLiveCodexTuiRollout(input: {
|
||||
const threadId = parseCodexTuiStatusSessionId(output.text)
|
||||
if (threadId) {
|
||||
const resolveRollout = input.resolveRollout ?? resolvePinnedCodexRolloutProof
|
||||
const transcriptPath = await resolveRollout(input.codexHome, threadId)
|
||||
let transcriptPath: string | null = null
|
||||
// Codex 0.148 allocates a session before it writes a rollout; give a just-written
|
||||
// file a short visibility window without rejecting a genuinely blank conversation.
|
||||
for (let attempt = 0; attempt < 5 && !transcriptPath; attempt += 1) {
|
||||
transcriptPath = await resolveRollout(input.codexHome, threadId)
|
||||
if (!transcriptPath && attempt < 4) {
|
||||
await delay(100)
|
||||
}
|
||||
}
|
||||
if (!input.write('\u001b')) {
|
||||
throw new Error('The agent terminal could not finish Codex session verification.')
|
||||
}
|
||||
if (!transcriptPath) {
|
||||
throw new Error('Could not find the rollout for this Codex conversation.')
|
||||
}
|
||||
return { threadId, transcriptPath }
|
||||
return { threadId, ...(transcriptPath ? { transcriptPath } : {}) }
|
||||
}
|
||||
}
|
||||
await delay(100)
|
||||
@@ -178,12 +186,20 @@ async function readCodexRolloutSessionMetaId(filePath: string): Promise<string |
|
||||
const record = JSON.parse(firstLine) as {
|
||||
type?: unknown
|
||||
id?: unknown
|
||||
payload?: { id?: unknown }
|
||||
session_id?: unknown
|
||||
thread_id?: unknown
|
||||
payload?: { id?: unknown; session_id?: unknown; thread_id?: unknown }
|
||||
}
|
||||
if (record.type !== 'session_meta') {
|
||||
return null
|
||||
}
|
||||
const id = record.payload?.id ?? record.id
|
||||
const id =
|
||||
record.payload?.id ??
|
||||
record.payload?.session_id ??
|
||||
record.payload?.thread_id ??
|
||||
record.id ??
|
||||
record.session_id ??
|
||||
record.thread_id
|
||||
return typeof id === 'string' && id.length > 0 ? id : null
|
||||
} catch {
|
||||
return null
|
||||
|
||||
@@ -55,6 +55,8 @@ describe('structured session cold restoration', () => {
|
||||
|
||||
it('normalizes a restored tab id and removes it when closed', async () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const closeSessionTab = vi.fn(async () => undefined)
|
||||
runtime.setNotifier({ closeSessionTab } as never)
|
||||
const internal = runtime as unknown as {
|
||||
getKnownWorkspaceSessionWorktreeIds(): Set<string>
|
||||
hydrateHeadlessMobileSessionTabsFromWorkspaceSession(): Set<string>
|
||||
@@ -146,6 +148,11 @@ describe('structured session cold restoration', () => {
|
||||
reason: 'user'
|
||||
})
|
||||
|
||||
expect(closeSessionTab).toHaveBeenCalledWith(
|
||||
'structured-agent-session-restored-session',
|
||||
'workspace-1'
|
||||
)
|
||||
|
||||
const closed = await runtime.listMobileSessionTabs('id:workspace-1')
|
||||
expect(closed.tabs.map((tab) => tab.id)).toEqual([
|
||||
'terminal-tab::leaf-1',
|
||||
@@ -153,4 +160,26 @@ describe('structured session cold restoration', () => {
|
||||
])
|
||||
expect(closed.tabGroups?.[0]?.tabOrder).toEqual(['terminal-tab'])
|
||||
})
|
||||
|
||||
it('commits the host close when the renderer already removed the structured tab', async () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
runtime.setNotifier({
|
||||
closeSessionTab: vi.fn(async () => {
|
||||
throw new Error('session_tab_not_found')
|
||||
})
|
||||
} as never)
|
||||
runtime.publishStructuredAgentSessionTab({
|
||||
workspaceId: 'workspace-1',
|
||||
sessionId: 'session-1',
|
||||
agent: 'codex',
|
||||
activate: true
|
||||
})
|
||||
|
||||
await runtime.closeMobileSessionTab('id:workspace-1', 'agent-session:session-1', {
|
||||
reason: 'user'
|
||||
})
|
||||
|
||||
const snapshot = await runtime.listMobileSessionTabs('id:workspace-1')
|
||||
expect(snapshot.tabs).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -76,7 +76,10 @@ function installProofPty(
|
||||
ptysById: Map<string, { tailBuffer: string[]; lastOutputAt: number }>
|
||||
}
|
||||
const pty = record.ptysById.get(PTY_ID)!
|
||||
pty.tailBuffer = [`Session ID: ${threadId}`]
|
||||
pty.tailBuffer = [
|
||||
'\u001b[2m│ >_ OpenAI Codex (v0.148.0) │',
|
||||
`│ Session: \u001b[22m${threadId}\u001b[2m │`
|
||||
]
|
||||
pty.lastOutputAt = ptyRecord.lastOutputAt + 1_000
|
||||
}
|
||||
return true
|
||||
@@ -237,6 +240,21 @@ describe('structured Codex adoption against the real PTY write gate', () => {
|
||||
})
|
||||
}, 20_000)
|
||||
|
||||
it('adopts a blank Codex 0.148 session before the lazy rollout exists', async () => {
|
||||
await rm(join(rig.codexHome, 'sessions'), { recursive: true, force: true })
|
||||
|
||||
const result = await rig.runtime.adoptStructuredAgentSessionTerminal(adoptInput(), {
|
||||
callerKey: 'renderer-1'
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ ok: true })
|
||||
expect(rig.refusedWrites).toEqual([])
|
||||
expect(rig.store.getRecord(SESSION_ID)).toMatchObject({
|
||||
providerHandleChain: [{ origin: 'adopted', handle: { threadId: THREAD_ID } }],
|
||||
lease: { runtimeKind: 'tui', claimStatus: 'live' }
|
||||
})
|
||||
}, 20_000)
|
||||
|
||||
it('leaves nothing latched when the proof fails, so the next attempt still succeeds', async () => {
|
||||
await rm(join(rig.codexHome, 'sessions'), { recursive: true, force: true })
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@ import {
|
||||
type StructuredTuiOwner
|
||||
} from '../native-chat/agent-session-wire/structured-agent-session-handoff-types'
|
||||
import type { AgentSessionRecord } from '../../shared/agent-session-record'
|
||||
import { SESSION_TAB_NOT_FOUND_ERROR } from '../../shared/session-tab-close'
|
||||
import {
|
||||
agentSessionOwnerBindingsEqual,
|
||||
cloneAgentSessionOwnerBinding,
|
||||
@@ -9471,7 +9472,19 @@ export class OrcaRuntimeService {
|
||||
// snapshot so paired clients stop showing it.
|
||||
await this.closeHeadlessMobileBrowserTab(worktreeId, snapshot, tab)
|
||||
} else if (tab.type === 'agent-session') {
|
||||
this.notifier?.closeSessionTab?.(tab.id, worktreeId)
|
||||
if (this.notifier?.closeSessionTab) {
|
||||
try {
|
||||
await this.notifier.closeSessionTab(
|
||||
structuredAgentSessionTabId(tab.sessionId),
|
||||
worktreeId
|
||||
)
|
||||
} catch (error) {
|
||||
// The renderer already having removed the tab is an idempotent close, not a veto.
|
||||
if (!(error instanceof Error && error.message === SESSION_TAB_NOT_FOUND_ERROR)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
this.closeStructuredAgentSessionTab(worktreeId, snapshot, tab)
|
||||
} else {
|
||||
if (!this.notifier?.closeSessionTab) {
|
||||
@@ -11616,7 +11629,7 @@ export class OrcaRuntimeService {
|
||||
spawnToken
|
||||
}) ?? false
|
||||
}
|
||||
let transcriptPath: string
|
||||
let transcriptPath: string | undefined
|
||||
if (threadId) {
|
||||
const proof = await proveCodexTuiRollout({ ...proofInput, threadId })
|
||||
transcriptPath = proof.transcriptPath
|
||||
@@ -11651,7 +11664,7 @@ export class OrcaRuntimeService {
|
||||
fence,
|
||||
observedAt: Date.now()
|
||||
}),
|
||||
transcriptPath,
|
||||
...(transcriptPath ? { transcriptPath } : {}),
|
||||
historySource: 'provider-resume',
|
||||
adoptedTerminal: true
|
||||
}
|
||||
|
||||
@@ -159,6 +159,31 @@ describe('a client that disappears without cleanup', () => {
|
||||
expect(closeSession).not.toHaveBeenCalled()
|
||||
expect(host.hasSession(SESSION)).toBe(true)
|
||||
})
|
||||
|
||||
it('releases a desktop subscription when its renderer transport dies', async () => {
|
||||
const transport = new AbortController()
|
||||
await dispatcher.dispatchStreaming(
|
||||
{
|
||||
id: 'desktop-subscription',
|
||||
authToken: 'token',
|
||||
method: 'agentSession.subscribe',
|
||||
params: { sessionId: SESSION }
|
||||
},
|
||||
() => {},
|
||||
{
|
||||
signal: transport.signal,
|
||||
clientId: 'desktop-renderer',
|
||||
clientKind: 'runtime',
|
||||
clientCapabilities: CLIENT.clientCapabilities
|
||||
}
|
||||
)
|
||||
expect(host.isHeld(SESSION)).toBe(true)
|
||||
|
||||
transport.abort()
|
||||
|
||||
await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false))
|
||||
expect(closeSession).toHaveBeenCalledWith(SESSION)
|
||||
})
|
||||
})
|
||||
|
||||
// Paired builds ship on their own schedule, and the ones already in the field never learned to
|
||||
|
||||
@@ -252,15 +252,23 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [
|
||||
)
|
||||
let closed = false
|
||||
let dispose = (): void => {}
|
||||
ctx.runtime.registerSubscriptionCleanup(
|
||||
let releaseTransportSubscription = (): void => {}
|
||||
const onTransportAbort = (): void => releaseTransportSubscription()
|
||||
const registration = ctx.runtime.registerOwnedSubscriptionCleanup(
|
||||
subscriptionId,
|
||||
() => {
|
||||
closed = true
|
||||
ctx.signal?.removeEventListener('abort', onTransportAbort)
|
||||
dispose()
|
||||
host.release(params.sessionId, streamHolder)
|
||||
},
|
||||
ctx.connectionId
|
||||
)
|
||||
releaseTransportSubscription = registration.releaseIfCurrent
|
||||
ctx.signal?.addEventListener('abort', onTransportAbort, { once: true })
|
||||
if (ctx.signal?.aborted) {
|
||||
onTransportAbort()
|
||||
}
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import type { Tab } from '../../../../shared/tab-types'
|
||||
import { useAppStore } from '../../store'
|
||||
import { destroyWorkspaceWebviews } from '../../store/slices/browser-webview-cleanup'
|
||||
@@ -12,6 +13,17 @@ import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner
|
||||
import { browserWorkspaceHasRemoteOwner } from '@/runtime/remote-browser-tab-ownership'
|
||||
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
|
||||
import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
function reportStructuredSessionCloseError(error: unknown): void {
|
||||
toast.error(
|
||||
translate(
|
||||
'components.native-chat.structuredSessionCloseFailed',
|
||||
'Could not close this Codex chat'
|
||||
),
|
||||
{ description: error instanceof Error ? error.message : String(error) }
|
||||
)
|
||||
}
|
||||
|
||||
export function useTabGroupTabCloseCommands({
|
||||
worktreeId,
|
||||
@@ -87,10 +99,13 @@ export function useTabGroupTabCloseCommands({
|
||||
tabId: `agent-session:${item.entityId}`,
|
||||
reason: 'user'
|
||||
})
|
||||
closeUnifiedTab(item.id)
|
||||
if (!opts?.skipEmptyCheck) {
|
||||
leaveWorktreeIfEmpty()
|
||||
}
|
||||
.then(() => {
|
||||
closeUnifiedTab(item.id)
|
||||
if (!opts?.skipEmptyCheck) {
|
||||
leaveWorktreeIfEmpty()
|
||||
}
|
||||
})
|
||||
.catch(reportStructuredSessionCloseError)
|
||||
return
|
||||
}
|
||||
if (item.contentType === 'terminal') {
|
||||
@@ -164,7 +179,8 @@ export function useTabGroupTabCloseCommands({
|
||||
tabId: `agent-session:${item.entityId}`,
|
||||
reason: 'user'
|
||||
})
|
||||
closeUnifiedTab(item.id)
|
||||
.then(() => closeUnifiedTab(item.id))
|
||||
.catch(reportStructuredSessionCloseError)
|
||||
continue
|
||||
}
|
||||
if (item.contentType === 'terminal' && isWebRuntimeSessionActive(runtimeEnvironmentId)) {
|
||||
|
||||
@@ -16113,7 +16113,9 @@
|
||||
"orchestrationPaused": {
|
||||
"label": "Orchestration paused",
|
||||
"message": "Structured Chat blocks terminal prompts and sends. Orchestration messages remain queued; switch to Terminal, then check the Orca inbox with"
|
||||
}
|
||||
},
|
||||
"structuredSessionCloseFailed": "Could not close this Codex chat",
|
||||
"structuredSessionLaunchFailed": "Could not open Codex chat"
|
||||
},
|
||||
"tab": {
|
||||
"bar": {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { toast } from 'sonner'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { AgentStartupPlan } from '@/lib/tui-agent-startup'
|
||||
import { planLaunchAgentStartupPrompt } from '@/lib/launch-agent-startup-prompt-plan'
|
||||
@@ -31,6 +32,8 @@ import { resolveInitialNativeChatSessionOptions } from '@/components/native-chat
|
||||
import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache'
|
||||
import { canUseStructuredNativeChat } from '@/lib/structured-native-chat-availability'
|
||||
import { waitForAgentReady } from '@/lib/agent-ready-wait'
|
||||
import { launchStructuredCodexSession } from '@/lib/launch-structured-codex-session'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export type LaunchAgentInNewTabArgs = {
|
||||
agent: TuiAgent
|
||||
@@ -176,6 +179,26 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI
|
||||
}
|
||||
}
|
||||
|
||||
const launchDirectStructuredChat =
|
||||
agent === 'codex' &&
|
||||
!hasPrompt &&
|
||||
initialViewModeProps.viewMode === 'chat' &&
|
||||
canUseStructuredNativeChat(store, worktreeId)
|
||||
if (launchDirectStructuredChat) {
|
||||
void launchStructuredCodexSession(worktreeId).catch((error) => {
|
||||
toast.error(
|
||||
translate(
|
||||
'components.native-chat.structuredSessionLaunchFailed',
|
||||
'Could not open Codex chat'
|
||||
),
|
||||
{
|
||||
description: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
)
|
||||
})
|
||||
return { tabId: null, startupPlan, pasteDraftAfterLaunch: false }
|
||||
}
|
||||
|
||||
// Why: queue startup BEFORE TerminalPane mounts — it snapshots pendingStartupByTabId in useState on first render.
|
||||
// Why: followup path pastes an unsubmitted draft, so gate the initial chat view like a draft launch, not auto-submit.
|
||||
const adoptStructuredChat =
|
||||
|
||||
@@ -5,6 +5,7 @@ const mockSetTabViewMode = vi.fn()
|
||||
const mockWaitForAgentReady = vi.fn()
|
||||
const mockPasteDraftWhenAgentReady = vi.fn()
|
||||
const mockMarkNativeChatLaunchPromptFailed = vi.fn()
|
||||
const mockLaunchStructuredCodexSession = vi.fn()
|
||||
|
||||
const store = {
|
||||
activeRepoId: 'repo-1',
|
||||
@@ -61,6 +62,9 @@ vi.mock('@/runtime/web-runtime-session', () => ({
|
||||
isWebRuntimeSessionActive: vi.fn(() => false),
|
||||
isWebTerminalSurfaceTabId: vi.fn(() => false)
|
||||
}))
|
||||
vi.mock('@/lib/launch-structured-codex-session', () => ({
|
||||
launchStructuredCodexSession: mockLaunchStructuredCodexSession
|
||||
}))
|
||||
|
||||
/** Structured adoption creates the tab in terminal mode and flips it to chat once
|
||||
* Codex is ready; the bridge stamps `viewMode: 'chat'` on the tab up front. That
|
||||
@@ -73,24 +77,25 @@ describe('structured chat adoption guard on the launch path', () => {
|
||||
mockCreateTab.mockReturnValue({ id: 'tab-1' })
|
||||
mockWaitForAgentReady.mockResolvedValue({ ready: true, reason: 'foreground-match' })
|
||||
mockPasteDraftWhenAgentReady.mockResolvedValue(true)
|
||||
mockLaunchStructuredCodexSession.mockResolvedValue('codex-session-1')
|
||||
})
|
||||
|
||||
it('adopts a local Codex tab into the structured stack', async () => {
|
||||
it('honors the persisted chat default by launching Codex directly as a structured session', async () => {
|
||||
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
|
||||
|
||||
launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
|
||||
const result = launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
|
||||
|
||||
expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, {
|
||||
launchAgent: 'codex'
|
||||
})
|
||||
await vi.waitFor(() => expect(mockSetTabViewMode).toHaveBeenCalledWith('tab-1', 'chat'))
|
||||
expect(result).toMatchObject({ tabId: null, pasteDraftAfterLaunch: false })
|
||||
expect(mockLaunchStructuredCodexSession).toHaveBeenCalledWith('wt-1')
|
||||
expect(mockCreateTab).not.toHaveBeenCalled()
|
||||
expect(mockWaitForAgentReady).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stays in terminal view when Codex never became ready', async () => {
|
||||
mockWaitForAgentReady.mockResolvedValue({ ready: false, reason: 'timeout' })
|
||||
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
|
||||
|
||||
launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
|
||||
launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1', prompt: 'start this task' })
|
||||
|
||||
await vi.waitFor(() => expect(mockWaitForAgentReady).toHaveBeenCalled())
|
||||
// Let every continuation the ready wait could have queued run before asserting it did not flip.
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation'
|
||||
import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client'
|
||||
import { launchStructuredCodexSession } from './launch-structured-codex-session'
|
||||
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
callStructuredAgentSession: vi.fn()
|
||||
}))
|
||||
|
||||
describe('structured Codex launch', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(callStructuredAgentSession).mockReset()
|
||||
})
|
||||
|
||||
it('creates a native session with a host-verifiable launch intent', async () => {
|
||||
vi.mocked(callStructuredAgentSession).mockImplementation(async (_target, _method, params) => ({
|
||||
ok: true,
|
||||
replayed: false,
|
||||
fence: 1,
|
||||
cursor: { epoch: 'epoch-1', sequence: 0 },
|
||||
value: {
|
||||
sessionId: (params as { envelope: { sessionId: string } }).envelope.sessionId,
|
||||
fence: 1,
|
||||
snapshot: { cursor: { epoch: 'epoch-1', sequence: 0 }, items: [] },
|
||||
unconfirmedClientMessageIds: []
|
||||
}
|
||||
}))
|
||||
|
||||
const sessionId = await launchStructuredCodexSession('workspace-1')
|
||||
const params = vi.mocked(callStructuredAgentSession).mock.calls[0]?.[2] as {
|
||||
envelope: { sessionId: string; payloadFingerprint: string }
|
||||
worktree: string
|
||||
agent: 'codex'
|
||||
}
|
||||
|
||||
expect(sessionId).toMatch(/^codex_[A-Za-z0-9_]{36}$/)
|
||||
expect(callStructuredAgentSession).toHaveBeenCalledWith(
|
||||
{ kind: 'local' },
|
||||
'agentSession.create',
|
||||
expect.objectContaining({ worktree: 'id:workspace-1', agent: 'codex' })
|
||||
)
|
||||
expect(params.envelope.payloadFingerprint).toBe(
|
||||
structuredAgentSessionPayloadFingerprint({
|
||||
method: 'agentSession.create',
|
||||
sessionId: params.envelope.sessionId,
|
||||
fields: { worktree: 'id:workspace-1', agent: 'codex' }
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import type {
|
||||
AgentSessionAttachResult,
|
||||
AgentSessionMutationResult
|
||||
} from '../../../shared/agent-session-wire'
|
||||
import {
|
||||
createStructuredAgentSessionOperationId,
|
||||
structuredAgentSessionPayloadFingerprint
|
||||
} from '../../../shared/structured-agent-session-mutation'
|
||||
import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client'
|
||||
import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector'
|
||||
|
||||
function newSessionId(): string {
|
||||
return `codex_${crypto.randomUUID().replaceAll('-', '_')}`
|
||||
}
|
||||
|
||||
export async function launchStructuredCodexSession(worktreeId: string): Promise<string> {
|
||||
const sessionId = newSessionId()
|
||||
const fields = { worktree: toRuntimeWorktreeSelector(worktreeId), agent: 'codex' as const }
|
||||
const result = await callStructuredAgentSession<
|
||||
AgentSessionMutationResult<AgentSessionAttachResult>
|
||||
>({ kind: 'local' }, 'agentSession.create', {
|
||||
envelope: {
|
||||
sessionId,
|
||||
clientOperationId: createStructuredAgentSessionOperationId(() => crypto.randomUUID()),
|
||||
expectedRuntimeFence: null,
|
||||
payloadFingerprint: structuredAgentSessionPayloadFingerprint({
|
||||
method: 'agentSession.create',
|
||||
sessionId,
|
||||
fields
|
||||
})
|
||||
},
|
||||
...fields
|
||||
})
|
||||
if (!result.ok) {
|
||||
throw new Error(result.refusal.message)
|
||||
}
|
||||
return result.value.sessionId
|
||||
}
|
||||
Reference in New Issue
Block a user