fix(agent-launch): observe Codex readiness from PTY bind

This commit is contained in:
Brennan Benson
2026-08-12 21:15:06 -07:00
parent 57ce467521
commit fc14b09028
4 changed files with 131 additions and 34 deletions
+48 -8
View File
@@ -23,6 +23,7 @@ const testState = vi.hoisted(() => ({
repos: [] as { id: string; connectionId: string | null; executionHostId?: string | null }[],
worktreesByRepo: {} as Record<string, { id: string; repoId: string }[]>
},
storeSubscribers: new Set<(state: { ptyIdsByTabId: Record<string, string[]> }) => void>(),
ptyObserver: null as ((data: string) => void) | null,
unsubscribe: vi.fn(),
subscribeToPtyData: vi.fn(),
@@ -34,7 +35,13 @@ const testState = vi.hoisted(() => ({
vi.mock('@/store', () => ({
useAppStore: {
getState: () => testState.appState
getState: () => testState.appState,
subscribe: (
subscriber: (state: { ptyIdsByTabId: Record<string, string[]> }) => void
): (() => void) => {
testState.storeSubscribers.add(subscriber)
return () => testState.storeSubscribers.delete(subscriber)
}
}
}))
@@ -71,6 +78,7 @@ describe('pasteDraftWhenAgentReady', () => {
testState.appState.tabsByWorktree = {}
testState.appState.repos = []
testState.appState.worktreesByRepo = {}
testState.storeSubscribers.clear()
testState.ptyObserver = null
testState.unsubscribe.mockReset()
testState.subscribeToPtyData.mockReset()
@@ -97,7 +105,7 @@ describe('pasteDraftWhenAgentReady', () => {
vi.useRealTimers()
})
it('pastes into Codex as soon as its composer prompt renders after bracketed paste is enabled', async () => {
it('pastes into Codex once its composer and bracketed paste are both ready', async () => {
const promise = pasteDraftWhenAgentReady({
tabId: 'tab-1',
content: ISSUE_URL,
@@ -110,10 +118,6 @@ describe('pasteDraftWhenAgentReady', () => {
expect(testState.sendRuntimePtyInputVerified).not.toHaveBeenCalled()
testState.ptyObserver?.(DECSET_BRACKETED_PASTE)
await flushMicrotasks()
expect(testState.sendRuntimePtyInputVerified).not.toHaveBeenCalled()
testState.ptyObserver?.(CODEX_COMPOSER_PROMPT_RENDER)
await expect(promise).resolves.toBe(true)
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith(
@@ -124,6 +128,41 @@ describe('pasteDraftWhenAgentReady', () => {
expect(vi.getTimerCount()).toBe(0)
})
it('subscribes during PTY binding before buffered Codex output drains', async () => {
testState.appState.ptyIdsByTabId = {}
const promise = pasteDraftWhenAgentReady({
tabId: 'tab-1',
content: ISSUE_URL,
agent: 'codex',
submit: true
})
expect(testState.storeSubscribers.size).toBe(1)
testState.appState.ptyIdsByTabId = { 'tab-1': ['pty-1'] }
for (const subscriber of testState.storeSubscribers) {
subscriber(testState.appState)
}
expect(testState.storeSubscribers.size).toBe(0)
expect(testState.ptyObserver).not.toBeNull()
// Matches pty-transport: updateTabPtyId notifies the store before the
// pre-handler buffer drains, with Codex 0.147 rendering its glyph first.
testState.ptyObserver?.(CODEX_COMPOSER_PROMPT_RENDER)
testState.ptyObserver?.(DECSET_BRACKETED_PASTE)
await flushMicrotasks()
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith(
{},
'pty-1',
PASTED_ISSUE_URL
)
await vi.advanceTimersByTimeAsync(POST_PASTE_SUBMIT_DELAY_MS)
await expect(promise).resolves.toBe(true)
expect(testState.sendRuntimePtyInputVerified).toHaveBeenNthCalledWith(2, {}, 'pty-1', '\r')
expect(testState.unsubscribe).toHaveBeenCalledTimes(1)
expect(vi.getTimerCount()).toBe(0)
})
it('detects the Codex composer prompt inside a large first render chunk', async () => {
const promise = pasteDraftWhenAgentReady({
tabId: 'tab-1',
@@ -520,8 +559,9 @@ describe('pasteDraftWhenAgentReady', () => {
// PTY takes 4s to appear — most of the old shared 8s budget.
await vi.advanceTimersByTimeAsync(4000)
testState.appState.ptyIdsByTabId = { 'tab-1': ['pty-1'] }
await vi.advanceTimersByTimeAsync(100)
await flushMicrotasks(5)
for (const subscriber of testState.storeSubscribers) {
subscriber(testState.appState)
}
testState.ptyObserver?.(DECSET_BRACKETED_PASTE)
// 19s of cold boot after the PTY appeared: past a shared budget, inside the
+65 -24
View File
@@ -101,19 +101,23 @@ export async function pasteDraftWhenAgentReady(args: {
}
const readySignal = agentConfig?.draftPasteReadySignal ?? 'render-quiet-after-bracketed-paste'
const ptyId = await waitForPtyId(tabId, PTY_SPAWN_TIMEOUT_MS)
if (!ptyId) {
const settings = getSettingsForAgentTabRuntimeOwner(tabId)
const readinessTimeoutMs =
timeoutMs ?? (agent === 'codex' ? CODEX_COMPOSER_READY_TIMEOUT_MS : READINESS_TIMEOUT_MS)
const readiness = await waitForAgentDraftInputReadyOnTab({
tabId,
spawnTimeoutMs: PTY_SPAWN_TIMEOUT_MS,
readinessTimeoutMs,
readySignal,
settings
})
if (!readiness) {
onTimeout?.()
return false
}
const settings = getSettingsForAgentTabRuntimeOwner(tabId)
// Why: the readiness budget starts once the PTY exists, so a slow spawn
// shortens nothing — a cold composer still gets its full window.
const budget =
timeoutMs ?? (agent === 'codex' ? CODEX_COMPOSER_READY_TIMEOUT_MS : READINESS_TIMEOUT_MS)
const ready = await waitForAgentDraftInputReady(ptyId, budget, readySignal, settings)
if (!ready) {
const { ptyId } = readiness
if (!readiness.ready) {
// Why: fast-starting TUIs can emit the paste-ready escape sequence before
// this sidecar subscription attaches. If process/title inspection says the
// launched agent owns the PTY, fall back to a best-effort paste instead of
@@ -221,22 +225,59 @@ async function sendBracketedPasteToAgent(args: {
}
}
/**
* Why: activation creates the tab synchronously but the PTY spawn is
* async. Poll the store until the primary PTY id appears or the budget
* expires. Tight interval because the wait is normally <200ms — only the
* first launch on a cold app reaches the tail of this.
*/
async function waitForPtyId(tabId: string, timeoutMs: number): Promise<string | null> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
const ptyId = useAppStore.getState().ptyIdsByTabId[tabId]?.[0]
if (ptyId) {
return ptyId
function waitForAgentDraftInputReadyOnTab(args: {
tabId: string
spawnTimeoutMs: number
readinessTimeoutMs: number
readySignal: Parameters<typeof waitForAgentDraftInputReady>[2]
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
}): Promise<{ ptyId: string; ready: boolean } | null> {
return new Promise((resolve) => {
let selectedPtyId: string | null = null
let settled = false
let spawnTimer: number | null = null
let unsubscribeStore: (() => void) | null = null
const finish = (result: { ptyId: string; ready: boolean } | null): void => {
if (settled) {
return
}
settled = true
if (spawnTimer !== null) {
window.clearTimeout(spawnTimer)
}
unsubscribeStore?.()
resolve(result)
}
await new Promise<void>((resolve) => window.setTimeout(resolve, 50))
}
return null
const bindPty = (ptyId: string): void => {
if (selectedPtyId || settled) {
return
}
selectedPtyId = ptyId
if (spawnTimer !== null) {
window.clearTimeout(spawnTimer)
}
unsubscribeStore?.()
// Why: Zustand subscribers run inside updateTabPtyId. Registering the
// sidecar here precedes the transport's immediate pre-handler drain.
void waitForAgentDraftInputReady(
ptyId,
args.readinessTimeoutMs,
args.readySignal,
args.settings
).then((ready) => finish({ ptyId, ready }))
}
const bindFromState = (state: ReturnType<typeof useAppStore.getState>): void => {
const ptyId = state.ptyIdsByTabId[args.tabId]?.[0]
if (ptyId) {
bindPty(ptyId)
}
}
spawnTimer = window.setTimeout(() => finish(null), args.spawnTimeoutMs)
unsubscribeStore = useAppStore.subscribe(bindFromState)
bindFromState(useAppStore.getState())
})
}
async function waitForExpectedAgentOnPty(
@@ -109,6 +109,15 @@ describe('createDraftPasteReadyScanner', () => {
)
})
it('is ready when Codex renders its composer before enabling bracketed paste', () => {
const scanner = createDraftPasteReadyScanner('codex-composer-prompt')
expect(scanner.observe(CODEX_PROMPT)).toEqual({ ready: false, armQuietTimer: false })
expect(scanner.observe(DECSET_BRACKETED_PASTE)).toEqual({
ready: true,
armQuietTimer: false
})
})
it('never arms the quiet-window fallback', () => {
const scanner = createDraftPasteReadyScanner('codex-composer-prompt')
expect(scanner.observe(DECSET_BRACKETED_PASTE)).toEqual({
+9 -2
View File
@@ -28,8 +28,8 @@ export type DraftPasteReadyScanResult = {
* and return types differ.
*
* Per agent signal:
* - `codex-composer-prompt`: ready when the `›` glyph renders after DECSET
* 2004; never arms the quiet window (`armQuietTimer` stays false).
* - `codex-composer-prompt`: ready once both the `›` glyph and DECSET 2004
* render, in either order; never arms the quiet window.
* - `render-cursor-after-bracketed-paste`: ready when DECTCEM show-cursor
* (`\x1b[?25h`) renders after DECSET 2004. Like Codex it does NOT arm the
* quiet window: opencode stays silent for ~1.5-2s between enabling
@@ -49,6 +49,7 @@ export function createDraftPasteReadyScanner(readySignal: DraftPasteReadySignal)
let recent = ''
let postHandshakeRecent = ''
let saw2004 = false
let sawCodexPrompt = false
const signalMarker =
readySignal === 'codex-composer-prompt'
@@ -61,12 +62,18 @@ export function createDraftPasteReadyScanner(readySignal: DraftPasteReadySignal)
observe(data: string): DraftPasteReadyScanResult {
const combined = recent + data
recent = combined.slice(-512)
if (readySignal === 'codex-composer-prompt' && combined.includes(CODEX_COMPOSER_PROMPT)) {
sawCodexPrompt = true
}
if (!saw2004) {
const markerIndex = combined.indexOf(DECSET_BRACKETED_PASTE)
if (markerIndex === -1) {
return { ready: false, armQuietTimer: false }
}
saw2004 = true
if (sawCodexPrompt) {
return { ready: true, armQuietTimer: false }
}
const postHandshakeChunk = combined.slice(markerIndex + DECSET_BRACKETED_PASTE.length)
if (signalMarker !== null && postHandshakeChunk.includes(signalMarker)) {
return { ready: true, armQuietTimer: false }