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
126 changes: 126 additions & 0 deletions src/main/runtime/agent-prompt-composer-pending.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { describe, expect, it } from 'vitest'
import {
buildAgentPromptFragment,
detectAgentPromptComposerVerdict
} from './agent-prompt-composer-pending'

const PROMPT = [
'You are working on task task_123 for dispatch ctx_456.',
'',
'Send worker_done when finished.'
].join('\n')

describe('detectAgentPromptComposerVerdict', () => {
it('is unknown when no rendered screen exists', () => {
expect(detectAgentPromptComposerVerdict(null, PROMPT)).toBe('unknown')
expect(detectAgentPromptComposerVerdict({ lines: [] }, PROMPT)).toBe('unknown')
})

it('is pending when the emulator extracted a draft holding the payload', () => {
expect(
detectAgentPromptComposerVerdict(
{ lines: ['›'], draft: '[Pasted Content 5033 chars]' },
PROMPT
)
).toBe('pending')
expect(
detectAgentPromptComposerVerdict(
{ lines: ['›'], draft: 'You are working on task task_123 for dispatch ctx_456.' },
PROMPT
)
).toBe('pending')
})

it('is clear when the extracted draft is something else entirely', () => {
expect(
detectAgentPromptComposerVerdict({ lines: ['›'], draft: 'notes the operator typed' }, PROMPT)
).toBe('clear')
})

it('is pending for a Codex pasted-content placeholder on the prompt line', () => {
const lines = [
' >_ OpenAI Codex (v0.152.0)',
' model: gpt-5.6-sol medium',
'› [Pasted Content 5033 chars]',
' tab to queue message',
' gpt-5.6-sol · 100% context left'
]
expect(detectAgentPromptComposerVerdict({ lines }, PROMPT)).toBe('pending')
})

it.each([
'❯ [Pasted text #1 +91 lines]',
'❯ [Pasted: 91 lines]',
'> [[ You are working.. [91 lines] .. ]]'
])('is pending for the placeholder %s', (composerLine) => {
expect(detectAgentPromptComposerVerdict({ lines: ['history', composerLine] }, PROMPT)).toBe(
'pending'
)
})

it('is pending when the prompt text itself sits after the prompt glyph', () => {
const lines = ['› You are working on task task_123 for dispatch ctx_456.', 'Send worker_done']
expect(detectAgentPromptComposerVerdict({ lines }, PROMPT)).toBe('pending')
})

it('is clear when the last prompt line is empty even if history echoes the prompt', () => {
const lines = [
'> [Pasted text #1 +2 lines]',
'✻ Thinking…',
'────────',
'❯',
'────────',
'? for shortcuts'
]
expect(detectAgentPromptComposerVerdict({ lines }, PROMPT)).toBe('clear')
})

it('is clear when the prompt line holds unrelated text', () => {
expect(
detectAgentPromptComposerVerdict({ lines: ['› something the operator typed'] }, PROMPT)
).toBe('clear')
})

it('is unknown when no prompt glyph line exists on screen', () => {
expect(detectAgentPromptComposerVerdict({ lines: ['plain output', 'more'] }, PROMPT)).toBe(
'unknown'
)
})

it('ignores a prompt glyph line that scrolled far above the bottom of the screen', () => {
const lines = [
'› [Pasted Content 5033 chars]',
...Array.from({ length: 20 }, (_, i) => `l${i}`)
]
expect(detectAgentPromptComposerVerdict({ lines }, PROMPT)).toBe('unknown')
})

it('does not mistake the Codex banner for a composer line', () => {
expect(
detectAgentPromptComposerVerdict(
{ lines: [' >_ OpenAI Codex (v0.152.0)', 'model: gpt'] },
PROMPT
)
).toBe('unknown')
})

it('does not treat a shell prompt ending in > as a composer', () => {
expect(detectAgentPromptComposerVerdict({ lines: ['PS C:\\repo> codex'] }, PROMPT)).toBe(
'unknown'
)
})
})

describe('buildAgentPromptFragment', () => {
it('uses the first non-empty line, collapsed and capped', () => {
expect(buildAgentPromptFragment('\n\n You are working\ton task\nrest')).toBe(
'You are working on task'
)
expect(buildAgentPromptFragment('x'.repeat(100))?.length).toBe(48)
})

it('refuses fragments too short to be distinctive', () => {
expect(buildAgentPromptFragment('ok')).toBeNull()
expect(buildAgentPromptFragment(' \n\n')).toBeNull()
})
})
76 changes: 76 additions & 0 deletions src/main/runtime/agent-prompt-composer-pending.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/** What the rendered screen says about an injected prompt: `pending` = the composer still holds
* it, `clear` = a recognized composer is empty (or holds something else), `unknown` = no rendered
* screen, or no composer line this detector recognizes — never grounds for another Enter. */
export type AgentPromptComposerVerdict = 'pending' | 'clear' | 'unknown'

export type AgentPromptComposerScreen = {
lines: readonly string[]
/** Composer text the emulator extracted from the cursor rows, when it recognized one. */
draft?: string
}

// Why: multi-line pastes collapse to a placeholder whose shape differs per TUI — Codex
// `[Pasted Content 5033 chars]`, Claude `[Pasted text #1 +91 lines]`, Grok `[Pasted: 91 lines]`,
// Hermes `[[ … [91 lines] .. ]]` — so match the shared "pasted"/"N lines" bracket, not a vendor.
const PASTE_PLACEHOLDER_RE = /\[+\s*pasted\b[^\]]*\]+|\[\d+\s+lines\]/i
// Why: composer prompts start the line with their glyph; a shell prompt like `PS C:\repo>` does not,
// and a bare `>` only counts when spaced — Codex's own banner opens with `>_ OpenAI Codex`.
const COMPOSER_PROMPT_LINE_RE = /^\s*(?:[❯›»]|>(?=\s|$))\s?(.*)$/
// Why: the composer lives at the bottom of the frame; a glyph line further up is transcript.
const COMPOSER_SCAN_LINES = 12
const PROMPT_FRAGMENT_MIN_CHARS = 6
const PROMPT_FRAGMENT_MAX_CHARS = 48

/** The prompt's first non-empty line, normalized, or null when too short to be distinctive. */
export function buildAgentPromptFragment(prompt: string): string | null {
for (const line of prompt.split('\n')) {
const collapsed = line.replace(/\s+/g, ' ').trim()
if (!collapsed) {
continue
}
return collapsed.length >= PROMPT_FRAGMENT_MIN_CHARS
? collapsed.slice(0, PROMPT_FRAGMENT_MAX_CHARS)
: null
}
return null
}

export function detectAgentPromptComposerVerdict(
screen: AgentPromptComposerScreen | null | undefined,
prompt: string
): AgentPromptComposerVerdict {
if (!screen || screen.lines.length === 0) {
return 'unknown'
}
const fragment = buildAgentPromptFragment(prompt)
// Why: a draft the operator typed is not the payload; another Enter would submit it, not ours.
const draft = screen.draft?.trim()
if (draft) {
return holdsInjectedPayload(draft, fragment) ? 'pending' : 'clear'
}
const composerText = findLastComposerLineText(screen.lines)
if (composerText === null) {
return 'unknown'
}
return holdsInjectedPayload(composerText, fragment) ? 'pending' : 'clear'
}

function holdsInjectedPayload(composerText: string, fragment: string | null): boolean {
return (
PASTE_PLACEHOLDER_RE.test(composerText) ||
(fragment !== null && composerText.includes(fragment))
)
}

// Why the last glyph line only: Claude keeps `> [Pasted text …]` in its transcript after submit,
// while the empty `❯` below it is the composer that actually answers "is anything parked?".
function findLastComposerLineText(lines: readonly string[]): string | null {
const tail = lines.filter((line) => line.trim().length > 0).slice(-COMPOSER_SCAN_LINES)
for (let index = tail.length - 1; index >= 0; index -= 1) {
const match = tail[index]!.match(COMPOSER_PROMPT_LINE_RE)
if (match) {
return match[1]!.trim()
}
}
return null
}
Loading