Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/main/runtime/agent-prompt-submission-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,43 @@ describe('agent prompt submission runtime', () => {
expect(writes).toEqual([])
})

it('does not refuse a ready live screen because the retained tail has an old blocked prompt', async () => {
vi.useFakeTimers()
const { runtime, handle, writes } = await createAgentPromptSubmissionRuntime(
(runtime, data) => {
if (data.includes('\r')) {
runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now())
}
},
'codex'
)
runtime.onPtyData(
'pty-prompt',
'Hooks need review\nPress enter to confirm\n',
Date.now()
)
const internals = runtime as unknown as {
headlessTerminals: Map<string, { writeChain: Promise<void> }>
readLiveTerminalScreenLines: (ptyId: string) => string[] | null
}
await internals.headlessTerminals.get('pty-prompt')?.writeChain
vi.spyOn(internals, 'readLiveTerminalScreenLines').mockReturnValue([
'╭───╮',
'│ >_ openai codex (v0.157.0) │',
'│ model: gpt-5 │',
'│ directory: ~/repo │',
'╰───╯'
])

const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', {
inputKind: 'driving'
})
await vi.runAllTimersAsync()

await expect(submission).resolves.toMatchObject({ accepted: true })
expect(writes.some((data) => data.includes('review this'))).toBe(true)
})

it('does not paste into a coalesced live permission title', async () => {
const { runtime, handle, writes } = await createPromptRuntime(() => undefined)
runtime.onPtyData(
Expand Down
7 changes: 7 additions & 0 deletions src/main/runtime/construction/runtime-service-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,13 @@ export abstract class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWith
getTabTitle: (tabId) => this.tabs.get(tabId)?.title ?? null,
getExplicitStatus: (handle) => this.getFreshExplicitAgentStatusForHandle(handle),
getLifecycleStatus: (ptyId) => this.agentPromptLifecycleByPtyId.get(ptyId),
readLiveScreenLines: (ptyId) => {
const state = this.headlessTerminals.get(ptyId)
if (!state || state.outputSequence !== this.getPtyOutputSequence(ptyId)) {
return null
}
return this.readLiveTerminalScreenLines(ptyId)
},
isRunning: (handle) => this.isTerminalRunningAgent(handle),
getTitleDisplayClear: (ptyId) => this.getPtyTitleDisplayClear(ptyId)
})
Expand Down
66 changes: 65 additions & 1 deletion src/main/runtime/orca-runtime-tail-wait-memo.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { describe, expect, it, vi } from 'vitest'
import { computeTerminalTailWaitState } from './terminal-wait-tail-state'
import {
computeTerminalTailWaitState,
clearMirroredLeafWaitStamps,
resolveWaitBlockedAt,
tailSettledReadyClearsBlockedWait
} from './terminal-wait-tail-state'

describe('terminal tail wait state', () => {
it('computeTerminalTailWaitState reports fromTail and blocked signals', () => {
Expand Down Expand Up @@ -34,4 +39,63 @@ describe('terminal tail wait state', () => {
lastIndexOf.mockRestore()
}
})

it('reads a ready tail only when a blocked stamp needs settled-ready evidence', () => {
const lines = [
'╭───╮',
'│ >_ openai codex (v0.157.0) │',
'│ model: gpt-5 │',
'│ directory: ~/repo │',
'╰───╯'
]

const unstamped = computeTerminalTailWaitState(lines, '', '')
expect(unstamped.fromTail).toBe(true)
expect(unstamped.waitText).toBe('')

const stamped = computeTerminalTailWaitState(lines, '', '', true)
expect(stamped.fromTail).toBe(true)
expect(stamped.waitText).toBe('')
expect(stamped.signal).toBeNull()
expect(stamped.settledReadyPrompt).toBe(true)
expect(tailSettledReadyClearsBlockedWait(stamped)).toBe(true)
expect(resolveWaitBlockedAt(12, false, stamped, 99)).toBeNull()
})

it('clears a blocked stamp only for a settled ready tail', () => {
const ready = {
waitText:
'╭───╮\n│ >_ openai codex (v0.157.0) │\n│ model: gpt-5 │\n│ directory: ~/repo │\n╰───╯',
signal: null,
fromTail: true
}
const blocked = {
waitText: 'Hooks need review\nPress enter to confirm',
signal: { reason: 'agent-hooks-review-prompt' as const, index: 0 },
fromTail: true
}
const preview = { ...ready, fromTail: false }
const inconclusive = { ...ready, waitText: 'ordinary output' }

expect(tailSettledReadyClearsBlockedWait(ready)).toBe(true)
expect(resolveWaitBlockedAt(12, false, ready, 99)).toBeNull()
expect(resolveWaitBlockedAt(12, true, ready, 99)).toBe(99)
expect(resolveWaitBlockedAt(12, false, blocked, 99)).toBe(12)
expect(resolveWaitBlockedAt(12, false, preview, 99)).toBe(12)
expect(resolveWaitBlockedAt(12, false, inconclusive, 99)).toBe(12)
expect(resolveWaitBlockedAt(null, false, ready, 99)).toBeNull()
})

it('clears deferred stamps only on leaves that share the PTY tail', () => {
const tail = ['ready tail']
const matching = { tailBuffer: tail, waitBlockedAt: 4 }
const other = { tailBuffer: ['other tail'], waitBlockedAt: 4 }

clearMirroredLeafWaitStamps(4, null, tail, [matching, other])

expect(matching.waitBlockedAt).toBeNull()
expect(other.waitBlockedAt).toBe(4)
clearMirroredLeafWaitStamps(null, null, tail, [other])
expect(other.waitBlockedAt).toBe(4)
})
})
9 changes: 8 additions & 1 deletion src/main/runtime/runtime-terminal-agent-status-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import { buildTerminalWaitText } from './terminal-wait-tail-state'

export type RuntimeTerminalAgentStatusSnapshot = {
waitText: string
/** Current whole emulator screen when it is caught up; null falls back to retained tail text. */
liveScreenText: string | null
waitBlockedAt: number | null
title: string | null
titleStatus: AgentStatus | null
Expand All @@ -41,6 +43,7 @@ type Dependencies = {
getLifecycleStatus(
ptyId: string
): { status: AgentStatus | null; updatedAt: number } | null | undefined
readLiveScreenLines(ptyId: string): readonly string[] | null
isRunning(handle: string): Promise<boolean>
getTitleDisplayClear(ptyId: string): TitleDisplayClear | null
}
Expand Down Expand Up @@ -75,7 +78,9 @@ export class RuntimeTerminalAgentStatusQuery {
const terminal = this.getSnapshot(handle, ptyId, clear)
const explicitStatus = this.deps.getExplicitStatus(handle)
const lifecycle = getDisplayPromptLifecycle(this.deps.getLifecycleStatus(ptyId), clear)
const blockedByWaitText = detectTerminalWaitBlockedReason(terminal.waitText)
const blockedByWaitText = detectTerminalWaitBlockedReason(
terminal.liveScreenText ?? terminal.waitText
)
const liveTitleClearsBlockedText =
terminal.titleStatusIsLive &&
terminal.titleStatus !== null &&
Expand Down Expand Up @@ -198,6 +203,7 @@ export class RuntimeTerminalAgentStatusQuery {
)
return {
waitText,
liveScreenText: this.deps.readLiveScreenLines(expectedPtyId)?.join('\n') ?? null,
waitBlockedAt: pty.pty.waitBlockedAt,
title: ptyTitle?.title ?? null,
titleStatus: ptyTitle
Expand All @@ -224,6 +230,7 @@ export class RuntimeTerminalAgentStatusQuery {
)
return {
waitText: buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview),
liveScreenText: this.deps.readLiveScreenLines(expectedPtyId)?.join('\n') ?? null,
waitBlockedAt: leaf.waitBlockedAt,
title: title?.title ?? null,
titleStatus: title ? detectAgentStatusFromTitle(title.title) : leaf.lastAgentStatus,
Expand Down
73 changes: 68 additions & 5 deletions src/main/runtime/terminal-wait-tail-state.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types'
import { buildTailLines } from './terminal-tail-state'
import { tailMayContainBlockedSignal } from './terminal-tail-sentinel-index'
import { findActionableTerminalWaitBlockedSignal } from './terminal-wait-detection'
import {
findActionableTerminalWaitBlockedSignal,
isKnownReadyPromptSettled
} from './terminal-wait-detection'
import { terminalWaitBlockedSentinelRe } from './agent-state-rules/blocked-text-layer'

export function buildTerminalWaitText(
Expand All @@ -20,6 +23,8 @@ export function buildTerminalWaitText(
export type TerminalTailWaitState = {
waitText: string
signal: { reason: RuntimeTerminalWaitBlockedReason; index: number } | null
// Why optional: stamped checks record the settled-ready decision without retaining a rebuilt tail.
settledReadyPrompt?: boolean
// Why: preview is only an empty-tail fallback, recomputed each append, so a preview-derived state can't be reused as the next previous state (gated on fromTail).
fromTail: boolean
}
Expand All @@ -28,7 +33,8 @@ export type TerminalTailWaitState = {
export function computeTerminalTailWaitState(
lines: string[],
partialLine: string,
preview: string
preview: string,
inspectTailForReadyPrompt = false
): TerminalTailWaitState {
const tailInspection = inspectTerminalWaitTail(lines, partialLine)
if (!tailInspection.fromTail) {
Expand All @@ -38,7 +44,7 @@ export function computeTerminalTailWaitState(
fromTail: false
}
}
if (!tailInspection.mayContainBlockedSignal) {
if (!tailInspection.mayContainBlockedSignal && !inspectTailForReadyPrompt) {
// Why: reads waitText only when a signal exists; avoid retaining a rebuilt 256 KiB string in the common case.
return { waitText: '', signal: null, fromTail: true }
}
Expand All @@ -48,9 +54,14 @@ export function computeTerminalTailWaitState(
.join('\n')
const fromTail = tailText.length > 0
const waitText = fromTail ? tailText : preview
const checkedReadyPrompt = inspectTailForReadyPrompt && !tailInspection.mayContainBlockedSignal
return {
waitText,
signal: findActionableTerminalWaitBlockedSignal(waitText.toLowerCase()),
waitText: checkedReadyPrompt ? '' : waitText,
signal: tailInspection.mayContainBlockedSignal
? findActionableTerminalWaitBlockedSignal(waitText.toLowerCase())
: null,
settledReadyPrompt:
checkedReadyPrompt && fromTail ? isKnownReadyPromptSettled(waitText) : undefined,
fromTail
}
}
Expand Down Expand Up @@ -95,3 +106,55 @@ export function tailGainedNewerBlockedReason(
)
return appendCandidateSignal !== null && appendCandidateSignal.index > previous.signal.index
}

// Why: a blocked stamp is otherwise sticky, and a pane that has gone quiet never
// runs another check. Only a settled ready tail is positive evidence the dialog
// is gone. Empty fast-path text, a preview fallback, and a tail that still has
// a blocked signal are not — failing to gain a newer reason must not clear.
export function tailSettledReadyClearsBlockedWait(state: TerminalTailWaitState): boolean {
if (!state.fromTail || state.signal !== null) {
return false
}
if (state.settledReadyPrompt === true) {
return true
}
if (state.waitText.length === 0) {
return false
}
return isKnownReadyPromptSettled(state.waitText)
}
Comment thread
innocarpe marked this conversation as resolved.
Resolved

export function resolveWaitBlockedAt(
current: number | null,
gainedNewerBlockedReason: boolean,
next: TerminalTailWaitState,
at: number
): number | null {
if (gainedNewerBlockedReason) {
return at
}
// A null stamp has nothing to clear. Skip the ready-prompt scan on that hot path.
if (current !== null && tailSettledReadyClearsBlockedWait(next)) {
return null
}
return current
}

/** The deferred check cleared the PTY stamp. Matching leaves share that tail and must drop its copy too. */
export function clearMirroredLeafWaitStamps<
T extends { tailBuffer: unknown; waitBlockedAt: number | null }
>(
ptyWaitBlockedAt: number | null,
nextBlockedAt: number | null,
ptyTailBuffer: unknown,
leaves: readonly T[]
): void {
if (ptyWaitBlockedAt === null || nextBlockedAt !== null) {
return
}
for (const leaf of leaves) {
if (leaf.tailBuffer === ptyTailBuffer) {
leaf.waitBlockedAt = null
}
}
}
13 changes: 9 additions & 4 deletions src/main/runtime/terminal/output/pty-output-ingestion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
} from '../../terminal-tail-state'
import {
computeTerminalTailWaitState,
resolveWaitBlockedAt,
tailGainedNewerBlockedReason
} from '../../terminal-wait-tail-state'
import { extractOscTitleScanTail } from '../../../../shared/osc-title-scan-tail'
Expand Down Expand Up @@ -247,11 +248,15 @@ export abstract class OrcaRuntimeWithOnPtyData extends OrcaRuntimeWithPreparePty
const nextWaitState = computeTerminalTailWaitState(
nextTail.lines,
nextTail.partialLine,
leaf.preview
leaf.preview,
leaf.waitBlockedAt !== null
)
leaf.waitBlockedAt = resolveWaitBlockedAt(
leaf.waitBlockedAt,
tailGainedNewerBlockedReason(previousWaitState, nextWaitState, normalized.text),
nextWaitState,
at
)
if (tailGainedNewerBlockedReason(previousWaitState, nextWaitState, normalized.text)) {
leaf.waitBlockedAt = at
}
leaf.tailWaitState = nextWaitState
leaf.tailBuffer = nextTail.lines
leaf.tailTranscriptBuffer = nextTranscript.lines
Expand Down
34 changes: 25 additions & 9 deletions src/main/runtime/terminal/wait/terminal-wait-blocked-checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import {
type WaitBlockedCheckState
} from '../../wait-blocked-check-state'
import {
clearMirroredLeafWaitStamps,
computeTerminalTailWaitState,
resolveWaitBlockedAt,
tailGainedNewerBlockedReason
} from '../../terminal-wait-tail-state'
import { ownRetainedString } from '../../../../shared/own-retained-string'
Expand Down Expand Up @@ -83,23 +85,37 @@ export abstract class OrcaRuntimeWithScheduleWaitBlockedCheck extends OrcaRuntim
const nextWaitState = computeTerminalTailWaitState(
pty.tailBuffer,
pty.tailPartialLine,
pty.preview
pty.preview,
pty.waitBlockedAt !== null
)
const previousWaitState = state.lastWaitState ?? {
waitText: '',
signal: null,
fromTail: false
}
if (
tailGainedNewerBlockedReason(
previousWaitState,
nextWaitState,
readWaitBlockedCarry(state.appended)
)
) {
pty.waitBlockedAt = at
const gainedNewerBlockedReason = tailGainedNewerBlockedReason(
previousWaitState,
nextWaitState,
readWaitBlockedCarry(state.appended)
)
if (gainedNewerBlockedReason) {
this.recordAgentPromptPermissionObservation(ptyId)
}
const nextBlockedAt = resolveWaitBlockedAt(
pty.waitBlockedAt,
gainedNewerBlockedReason,
nextWaitState,
at
)
// Why: the mirror copy in onPtyData already ran when this check was deferred,
// so a quiet pane would keep the leaf stamp after the pty one is cleared.
clearMirroredLeafWaitStamps(
pty.waitBlockedAt,
nextBlockedAt,
pty.tailBuffer,
this.getLeavesForPty(ptyId)
)
pty.waitBlockedAt = nextBlockedAt
state.lastAt = at
state.lastWaitState = nextWaitState
resetWaitBlockedCarry(state.appended)
Expand Down
4 changes: 3 additions & 1 deletion src/main/runtime/terminal/wait/terminal-wait-permission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,9 @@ export abstract class OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission
lifecycle: { status: AgentStatus | null; updatedAt: number } | null | undefined,
observedOnScreen = false
): RuntimeTerminalWaitBlockedReason | null {
const blockedByWaitText = detectTerminalWaitBlockedReason(terminal.waitText)
const blockedByWaitText = detectTerminalWaitBlockedReason(
terminal.liveScreenText ?? terminal.waitText
)
if (!blockedByWaitText) {
return null
}
Expand Down