From fc14b09028dcf5a9a41be6ac2f24f484c6ac4edf Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:15:06 -0700 Subject: [PATCH] fix(agent-launch): observe Codex readiness from PTY bind --- .../src/lib/agent-paste-draft.test.ts | 56 ++++++++++-- src/renderer/src/lib/agent-paste-draft.ts | 89 ++++++++++++++----- src/shared/draft-paste-ready-scanner.test.ts | 9 ++ src/shared/draft-paste-ready-scanner.ts | 11 ++- 4 files changed, 131 insertions(+), 34 deletions(-) diff --git a/src/renderer/src/lib/agent-paste-draft.test.ts b/src/renderer/src/lib/agent-paste-draft.test.ts index ea3f3735c1a..e1344df2781 100644 --- a/src/renderer/src/lib/agent-paste-draft.test.ts +++ b/src/renderer/src/lib/agent-paste-draft.test.ts @@ -23,6 +23,7 @@ const testState = vi.hoisted(() => ({ repos: [] as { id: string; connectionId: string | null; executionHostId?: string | null }[], worktreesByRepo: {} as Record }, + storeSubscribers: new Set<(state: { ptyIdsByTabId: Record }) => 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 }) => 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 diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts index fae72d9d159..5ad84389e4f 100644 --- a/src/renderer/src/lib/agent-paste-draft.ts +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -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 { - 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[2] + settings: Pick | 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((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): 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( diff --git a/src/shared/draft-paste-ready-scanner.test.ts b/src/shared/draft-paste-ready-scanner.test.ts index 3af02f06795..b14ab73961a 100644 --- a/src/shared/draft-paste-ready-scanner.test.ts +++ b/src/shared/draft-paste-ready-scanner.test.ts @@ -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({ diff --git a/src/shared/draft-paste-ready-scanner.ts b/src/shared/draft-paste-ready-scanner.ts index da491dc2d93..9a6c107ee1a 100644 --- a/src/shared/draft-paste-ready-scanner.ts +++ b/src/shared/draft-paste-ready-scanner.ts @@ -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 }