mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
* fix(agent-launch): wait longer for cold-boot Codex composer before dropping prompt (STA-3367)
Continue-in-new-session pastes the handoff prompt once Codex renders its
composer glyph, gated on an 8s readiness budget. A cold/first-run Codex can
take longer than 8s to mount its composer, so the wait timed out and the
prompt was silently dropped into an empty terminal.
Marker-gated ready signals (Codex glyph, opencode show-cursor) are positive
proofs: the paste fires only when the marker actually renders, so a longer
budget can never paste prematurely — it only tolerates slow cold boots. Give
those signals a 20s budget while the markerless quiet-window signal keeps 8s.
* fix(agent-launch): share the composer-readiness budget across all three delivery owners (STA-3367)
The cold-boot fix was correct but landed as a single-path exception, and it
double-spent its own budget. Three follow-ups so the behavior is a system rule:
1. Split the PTY-spawn wait from the composer wait in pasteDraftWhenAgentReady.
Both were handed the same budget, so a codex tab took up to 41s to report a
dropped prompt. "Tab has a PTY" and "composer accepts input" are separate
states: spawn keeps a fixed 8s, and the readiness budget now starts once the
PTY exists, so a slow spawn can't shorten a cold composer's window.
2. Move the per-signal budget to draftPasteReadyBudgetMs() beside the shared
readiness scanner. The budget is a property of the ready signal — only that
module knows which signals are marker-gated — so all three delivery owners
(renderer tab paste, renderer startup paste, main runtime startup paste)
consume one policy instead of three hardcoded 8s constants.
3. Give the main-runtime startup paste the process-ownership fallback both
renderer paths already have. It resolved null on budget expiry, silently
dropping the prompt on worktree-create / CLI / remote-host delivery — the
same STA-3367 failure, on the path the original fix didn't reach.
Adds coverage for the main-runtime waiter, which had none.
Test: vitest src/main/runtime src/shared src/renderer/src/lib
src/renderer/src/components/terminal-pane — all green; tsc clean.
* test(agent-launch): consume the shared readiness budget instead of restating it
Hardcoding 20000 in the runtime waiter test meant it would keep passing if
OrcaRuntimeService stopped consuming draftPasteReadyBudgetMs — the exact drift
this PR exists to prevent. The literal values stay pinned once, in the scanner
test.
* refactor(agent-launch): collapse the readiness budget to one flat timeout
The per-signal budget (marker 20s / quiet-window 8s) tied the timeout to how
readiness is DETECTED. The budget is really a property of how slowly an agent
can boot — a marker, a quiet window, and a process check all wait out the same
cold start — so one number covers all three signals.
Replaces draftPasteReadyBudgetMs() with DRAFT_PASTE_READY_TIMEOUT_MS: drops a
constant, a branch, and two tests, and removes the only reason a delivery path
needed to know which signal class it was using.
Cost: a launch that never emits DECSET 2004 now surfaces its 'prompt not sent'
toast at 20s instead of 8s. That is the failed-launch path only; successful
markerless delivery still resolves on the 1.5s quiet window as before.
* fix(agent-launch): constrain cold Codex readiness budget
* fix(agent-launch): observe Codex readiness from PTY bind
* fix(agent-launch): anchor early Codex prompt to TUI screen
292 lines
14 KiB
TypeScript
292 lines
14 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
||
import { createDraftPasteReadyScanner } from './draft-paste-ready-scanner'
|
||
|
||
const DECSET_BRACKETED_PASTE = '\x1b[?2004h'
|
||
const SHOW_CURSOR = '\x1b[?25h'
|
||
const HIDE_CURSOR = '\x1b[?25l'
|
||
const CODEX_PROMPT = '\x1b[1m›\x1b[0m Ask Codex to do anything'
|
||
const CODEX_DYNAMIC_PROMPT = '\x1b[1m›\x1b[0m Implement {feature}'
|
||
const ALT_SCREEN_ENTER = '\x1b[?1049h'
|
||
const ALT_SCREEN_LEAVE = '\x1b[?1049l'
|
||
const GROK_ALT_SCREEN_ENTER = '\x1b[?1049h\x1b[?2004h\x1b[?25l'
|
||
const GROK_ALT_SCREEN_LEAVE = '\x1b[?1049l\x1b[?25h'
|
||
const GROK_COMPOSER_FRAME = '\x1b[38;2;80;80;88m│\x1b[38;2;200;200;200m❯ \x1b[0m'
|
||
|
||
describe('createDraftPasteReadyScanner', () => {
|
||
describe('render-cursor-after-bracketed-paste (opencode / mimo-code)', () => {
|
||
it('is ready when show-cursor renders after bracketed paste in one chunk', () => {
|
||
const scanner = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste')
|
||
expect(scanner.observe(`${DECSET_BRACKETED_PASTE}${SHOW_CURSOR}`)).toEqual({
|
||
ready: true,
|
||
armQuietTimer: false
|
||
})
|
||
})
|
||
|
||
it('does not fire on bracketed paste alone, then fires once show-cursor arrives', () => {
|
||
const scanner = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste')
|
||
// Why: opencode enables bracketed paste ~1.5-2s before its composer mounts
|
||
// and stays SILENT in between. The cursor gates delivery and must NOT arm
|
||
// the quiet window, which would otherwise fire during that silent gap and
|
||
// paste before the composer exists.
|
||
expect(scanner.observe(DECSET_BRACKETED_PASTE)).toEqual({
|
||
ready: false,
|
||
armQuietTimer: false
|
||
})
|
||
expect(scanner.observe('startup banner output')).toEqual({
|
||
ready: false,
|
||
armQuietTimer: false
|
||
})
|
||
expect(scanner.observe(SHOW_CURSOR)).toEqual({ ready: true, armQuietTimer: false })
|
||
})
|
||
|
||
it('resolves from a single replayed buffer holding both markers (SSH/remote replay path)', () => {
|
||
// Why: the runtime waiter feeds recentPtyOutputById as one observe() call
|
||
// when the agent emitted 2004 + show-cursor before the subscription
|
||
// attached; a single combined buffer must still resolve.
|
||
const scanner = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste')
|
||
expect(
|
||
scanner.observe(`banner\n${DECSET_BRACKETED_PASTE}composer\n${SHOW_CURSOR}rest`)
|
||
).toEqual({ ready: true, armQuietTimer: false })
|
||
})
|
||
|
||
it('detects a bracketed-paste handshake split across a chunk boundary', () => {
|
||
// Why: the pre-handshake `recent` ring must reassemble a \x1b[?2004h that
|
||
// straddles two PTY packets, or cursor-gated readiness breaks for
|
||
// fragmented startup output.
|
||
const scanner = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste')
|
||
expect(scanner.observe('\x1b[?20')).toEqual({ ready: false, armQuietTimer: false })
|
||
expect(scanner.observe('04h')).toEqual({ ready: false, armQuietTimer: false })
|
||
expect(scanner.observe(SHOW_CURSOR)).toEqual({ ready: true, armQuietTimer: false })
|
||
})
|
||
|
||
it('detects show-cursor split across a later chunk boundary', () => {
|
||
const scanner = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste')
|
||
scanner.observe(DECSET_BRACKETED_PASTE)
|
||
// The escape sequence is split mid-bytes across two separate chunks.
|
||
expect(scanner.observe('render noise \x1b[?')).toEqual({ ready: false, armQuietTimer: false })
|
||
expect(scanner.observe('25h')).toEqual({ ready: true, armQuietTimer: false })
|
||
})
|
||
|
||
it('never arms the quiet window during the silent pre-composer gap', () => {
|
||
const scanner = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste')
|
||
scanner.observe(DECSET_BRACKETED_PASTE)
|
||
// Why: opencode is silent here; arming the quiet window would fire before
|
||
// the composer mounts and pre-empt the cursor signal (the original bug).
|
||
// Delivery waits for show-cursor, bounded by the caller's hard timeout.
|
||
for (let i = 0; i < 5; i += 1) {
|
||
expect(scanner.observe(`setup output ${i}`)).toEqual({ ready: false, armQuietTimer: false })
|
||
}
|
||
})
|
||
|
||
it('does not treat hide-cursor as the ready signal', () => {
|
||
const scanner = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste')
|
||
// \x1b[?25l (hide) must not be mistaken for \x1b[?25h (show).
|
||
expect(scanner.observe(`${DECSET_BRACKETED_PASTE}${HIDE_CURSOR}`)).toEqual({
|
||
ready: false,
|
||
armQuietTimer: false
|
||
})
|
||
})
|
||
|
||
it('ignores show-cursor that appears before bracketed paste is enabled', () => {
|
||
const scanner = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste')
|
||
// A pre-handshake cursor toggle must not trip readiness.
|
||
expect(scanner.observe(SHOW_CURSOR)).toEqual({ ready: false, armQuietTimer: false })
|
||
expect(scanner.observe(DECSET_BRACKETED_PASTE)).toEqual({
|
||
ready: false,
|
||
armQuietTimer: false
|
||
})
|
||
})
|
||
})
|
||
|
||
describe('codex-composer-prompt', () => {
|
||
it('is ready on the composer glyph after bracketed paste and never arms the quiet timer', () => {
|
||
const scanner = createDraftPasteReadyScanner('codex-composer-prompt')
|
||
expect(scanner.observe(DECSET_BRACKETED_PASTE)).toEqual({
|
||
ready: false,
|
||
armQuietTimer: false
|
||
})
|
||
expect(scanner.observe(CODEX_PROMPT)).toEqual({ ready: true, armQuietTimer: false })
|
||
})
|
||
|
||
it('detects the composer glyph inside a large first render chunk', () => {
|
||
const scanner = createDraftPasteReadyScanner('codex-composer-prompt')
|
||
expect(scanner.observe(`${DECSET_BRACKETED_PASTE}${CODEX_PROMPT}${'x'.repeat(900)}`)).toEqual(
|
||
{ ready: true, armQuietTimer: false }
|
||
)
|
||
})
|
||
|
||
it('is ready when Codex renders its composer before enabling bracketed paste', () => {
|
||
const scanner = createDraftPasteReadyScanner('codex-composer-prompt')
|
||
expect(scanner.observe(`${ALT_SCREEN_ENTER}${CODEX_DYNAMIC_PROMPT}`)).toEqual({
|
||
ready: false,
|
||
armQuietTimer: false
|
||
})
|
||
expect(scanner.observe(DECSET_BRACKETED_PASTE)).toEqual({
|
||
ready: true,
|
||
armQuietTimer: false
|
||
})
|
||
})
|
||
|
||
it('forgets a pre-anchor glyph when Codex leaves the alternate screen', () => {
|
||
const scanner = createDraftPasteReadyScanner('codex-composer-prompt')
|
||
scanner.observe(`${ALT_SCREEN_ENTER}${CODEX_DYNAMIC_PROMPT}${ALT_SCREEN_LEAVE}`)
|
||
expect(scanner.observe(DECSET_BRACKETED_PASTE)).toEqual({
|
||
ready: false,
|
||
armQuietTimer: false
|
||
})
|
||
})
|
||
|
||
it('ignores a stale shell glyph before bracketed paste is enabled', () => {
|
||
const scanner = createDraftPasteReadyScanner('codex-composer-prompt')
|
||
expect(scanner.observe('› codex\r\nstartup output')).toEqual({
|
||
ready: false,
|
||
armQuietTimer: false
|
||
})
|
||
expect(scanner.observe(DECSET_BRACKETED_PASTE)).toEqual({
|
||
ready: false,
|
||
armQuietTimer: false
|
||
})
|
||
})
|
||
|
||
it('never arms the quiet-window fallback', () => {
|
||
const scanner = createDraftPasteReadyScanner('codex-composer-prompt')
|
||
expect(scanner.observe(DECSET_BRACKETED_PASTE)).toEqual({
|
||
ready: false,
|
||
armQuietTimer: false
|
||
})
|
||
expect(scanner.observe('noise')).toEqual({ ready: false, armQuietTimer: false })
|
||
})
|
||
})
|
||
|
||
describe('grok-composer-prompt', () => {
|
||
it('is ready on the composer glyph after the alternate-screen switch', () => {
|
||
const scanner = createDraftPasteReadyScanner('grok-composer-prompt')
|
||
expect(scanner.observe(GROK_ALT_SCREEN_ENTER)).toEqual({ ready: false, armQuietTimer: true })
|
||
expect(scanner.observe(GROK_COMPOSER_FRAME)).toEqual({ ready: true, armQuietTimer: false })
|
||
})
|
||
|
||
it('ignores a shell prompt glyph emitted before grok takes the screen', () => {
|
||
// Why: `❯` is starship's / pure's default prompt too, and that prompt —
|
||
// with its own DECSET 2004 — renders in the normal buffer while the shell
|
||
// still owns the PTY. Firing there would paste the draft into the shell.
|
||
// The shell's 2004 still arms the quiet floor, exactly as it does today
|
||
// for every agent on the default signal.
|
||
const scanner = createDraftPasteReadyScanner('grok-composer-prompt')
|
||
expect(scanner.observe(`${DECSET_BRACKETED_PASTE}\x1b[32m❯\x1b[0m grok\r\n`)).toEqual({
|
||
ready: false,
|
||
armQuietTimer: true
|
||
})
|
||
expect(scanner.observe(GROK_ALT_SCREEN_ENTER)).toEqual({ ready: false, armQuietTimer: true })
|
||
expect(scanner.observe(GROK_COMPOSER_FRAME)).toEqual({ ready: true, armQuietTimer: false })
|
||
})
|
||
|
||
it('resolves from a single replayed buffer holding both markers (SSH/remote replay path)', () => {
|
||
const scanner = createDraftPasteReadyScanner('grok-composer-prompt')
|
||
expect(scanner.observe(`${GROK_ALT_SCREEN_ENTER}logo frames${GROK_COMPOSER_FRAME}`)).toEqual({
|
||
ready: true,
|
||
armQuietTimer: false
|
||
})
|
||
})
|
||
|
||
it('keeps arming the quiet window so a missed composer frame still delivers', () => {
|
||
// Why: grok renders differentially — the glyph is painted once, so a
|
||
// scanner that attached after that frame would otherwise wait out the
|
||
// caller's hard timeout. Output only goes quiet once startup settles.
|
||
const scanner = createDraftPasteReadyScanner('grok-composer-prompt')
|
||
scanner.observe(GROK_ALT_SCREEN_ENTER)
|
||
expect(scanner.observe('logo shimmer frame')).toEqual({ ready: false, armQuietTimer: true })
|
||
})
|
||
|
||
it('arms the quiet window from DECSET 2004 when grok renders inline', () => {
|
||
// Why: `--no-alt-screen` / `[ui] screen_mode = "minimal"` emits no 1049h,
|
||
// so the glyph never anchors. The quiet window must still arm off 2004 or
|
||
// readiness never resolves and the main-process caller drops the draft.
|
||
const scanner = createDraftPasteReadyScanner('grok-composer-prompt')
|
||
expect(scanner.observe(DECSET_BRACKETED_PASTE)).toEqual({ ready: false, armQuietTimer: true })
|
||
expect(scanner.observe(GROK_COMPOSER_FRAME)).toEqual({ ready: false, armQuietTimer: true })
|
||
})
|
||
|
||
it('does not treat a legacy-console `> ` prompt as the glyph', () => {
|
||
// grok draws `> ` instead of `❯` on legacy Windows consoles; it is too
|
||
// generic to match, so those launches ride the quiet window.
|
||
const scanner = createDraftPasteReadyScanner('grok-composer-prompt')
|
||
scanner.observe(GROK_ALT_SCREEN_ENTER)
|
||
expect(scanner.observe('\x1b[38;2;80;80;88m│\x1b[0m> ')).toEqual({
|
||
ready: false,
|
||
armQuietTimer: true
|
||
})
|
||
})
|
||
|
||
it('disarms when grok leaves the alternate screen before painting a composer', () => {
|
||
// Why: grok entering the alt screen and then dying hands the terminal back to
|
||
// the shell. A latched anchor would treat the shell's `❯` prompt as grok's
|
||
// composer and paste the draft into the shell.
|
||
const scanner = createDraftPasteReadyScanner('grok-composer-prompt')
|
||
scanner.observe(GROK_ALT_SCREEN_ENTER)
|
||
expect(scanner.observe(GROK_ALT_SCREEN_LEAVE)).toEqual({
|
||
ready: false,
|
||
armQuietTimer: true
|
||
})
|
||
expect(scanner.observe(`\x1b[32m❯\x1b[0m `)).toEqual({ ready: false, armQuietTimer: true })
|
||
})
|
||
|
||
it('ignores a shell prompt after an rc-file program used the alternate screen', () => {
|
||
// Why: a pager/editor launched from the user's shell rc enters and leaves the
|
||
// alt screen before grok is even launched; the prompt that follows is the
|
||
// shell's, so the anchor must not survive the leave.
|
||
const scanner = createDraftPasteReadyScanner('grok-composer-prompt')
|
||
expect(
|
||
scanner.observe(`rc pager${GROK_ALT_SCREEN_ENTER}paged${GROK_ALT_SCREEN_LEAVE}`)
|
||
).toEqual({ ready: false, armQuietTimer: true })
|
||
expect(scanner.observe(`${DECSET_BRACKETED_PASTE}\x1b[32m❯\x1b[0m grok\r\n`)).toEqual({
|
||
ready: false,
|
||
armQuietTimer: true
|
||
})
|
||
// grok's own launch still resolves normally afterwards.
|
||
expect(scanner.observe(GROK_ALT_SCREEN_ENTER)).toEqual({ ready: false, armQuietTimer: true })
|
||
expect(scanner.observe(GROK_COMPOSER_FRAME)).toEqual({ ready: true, armQuietTimer: false })
|
||
})
|
||
|
||
it('ignores a glyph that precedes the alt-screen switch inside one chunk', () => {
|
||
const scanner = createDraftPasteReadyScanner('grok-composer-prompt')
|
||
expect(scanner.observe(`❯ ${GROK_ALT_SCREEN_ENTER}`)).toEqual({
|
||
ready: false,
|
||
armQuietTimer: true
|
||
})
|
||
})
|
||
|
||
it('does not fire on a glyph that lands after the leave inside one chunk', () => {
|
||
const scanner = createDraftPasteReadyScanner('grok-composer-prompt')
|
||
scanner.observe(GROK_ALT_SCREEN_ENTER)
|
||
expect(scanner.observe(`${GROK_ALT_SCREEN_LEAVE}\x1b[32m❯\x1b[0m `)).toEqual({
|
||
ready: false,
|
||
armQuietTimer: true
|
||
})
|
||
})
|
||
|
||
it('detects the alt-screen anchor split across a chunk boundary', () => {
|
||
const scanner = createDraftPasteReadyScanner('grok-composer-prompt')
|
||
expect(scanner.observe('\x1b[?10')).toEqual({ ready: false, armQuietTimer: false })
|
||
expect(scanner.observe('49h')).toEqual({ ready: false, armQuietTimer: false })
|
||
expect(scanner.observe(GROK_COMPOSER_FRAME)).toEqual({ ready: true, armQuietTimer: false })
|
||
})
|
||
})
|
||
|
||
describe('render-quiet-after-bracketed-paste (default)', () => {
|
||
it('arms the quiet timer after bracketed paste and never reports a signal', () => {
|
||
const scanner = createDraftPasteReadyScanner('render-quiet-after-bracketed-paste')
|
||
expect(scanner.observe(DECSET_BRACKETED_PASTE)).toEqual({ ready: false, armQuietTimer: true })
|
||
// Show-cursor is not a signal for the default path; it just keeps arming.
|
||
expect(scanner.observe(SHOW_CURSOR)).toEqual({ ready: false, armQuietTimer: true })
|
||
})
|
||
|
||
it('does nothing until bracketed paste is enabled', () => {
|
||
const scanner = createDraftPasteReadyScanner('render-quiet-after-bracketed-paste')
|
||
expect(scanner.observe('pre-handshake output')).toEqual({
|
||
ready: false,
|
||
armQuietTimer: false
|
||
})
|
||
})
|
||
})
|
||
})
|