mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 16:03:26 +00:00
fix(agent-launch): observe Codex readiness from PTY bind
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user