test: fix parseNumberedOptions for inline-cursor box-layout AUQs

Calibration run 1 timed out with step0=0 review=0 because the parser
could not find the cursor in /plan-ceo-review's scope-selection AUQ.
The TTY's box-layout rendering inlines divider + header + prompt +
"1." onto one logical line — cursor escapes get stripped, leaving
text crushed onto a single line.

Cursor anchor regex changed from anchored to unanchored so it matches
mid-line. Cursor-line option extraction uses a non-anchored regex;
subsequent options stay with the original start-of-line parser.

parseQuestionPrompt picks up the inline prompt text BEFORE the cursor
on the cursor line (after stripping box-drawing chars + sigil) and
appends it after any walked-up multi-line prompt above.

Three new unit tests: clean-cursor still works, inline-cursor
extracts all 7 options, prompt extraction strips box chars.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-04-29 16:04:11 -07:00
parent f479134fba
commit 5a66bbddac
2 changed files with 127 additions and 8 deletions
+58 -8
View File
@@ -234,12 +234,14 @@ export function parseNumberedOptions(
// this, parseNumberedOptions returns stale options after the dialog is
// dismissed.
const lines = tail.split('\n');
// Anchor on the LAST ` 1.` line (cursor is on option 1 of the active
// AskUserQuestion). Greedy character classes don't help here — we need a literal
// `` after optional leading whitespace.
// Anchor on the LAST line containing `<spaces>1.` ANYWHERE on the line.
// The /plan-*-review skill's box-layout AUQ uses TTY cursor-positioning
// escapes that stripAnsi removes — leaving the cursor `1.` mid-line,
// after dividers + header + prompt text on the same logical line. The
// earlier `^\s*` anchor missed those entirely.
let cursorLineIdx = -1;
for (let i = lines.length - 1; i >= 0; i--) {
if (/^\s*\s*1\./.test(lines[i] ?? '')) {
if (/\s*1\./.test(lines[i] ?? '')) {
cursorLineIdx = i;
break;
}
@@ -259,7 +261,37 @@ export function parseNumberedOptions(
if (cursorLineIdx < 0) return [];
const found: Array<{ index: number; label: string }> = [];
const seenIndices = new Set<number>();
for (let i = cursorLineIdx; i < lines.length; i++) {
// Cursor line: option 1 may be inline after box dividers + prompt header
// (`...divider...header...1. label`). Use a non-anchored regex that
// captures `N. label` from anywhere on the line through end-of-line.
// Only used for the cursor line — subsequent options are parsed with the
// start-of-line `optionRe`.
const cursorLine = lines[cursorLineIdx] ?? '';
const cursorInlineRe = /\s*([1-9])\.\s*(\S.*?)\s*$/;
const inlineMatch = cursorInlineRe.exec(cursorLine);
if (inlineMatch) {
const idx = Number(inlineMatch[1]);
const label = (inlineMatch[2] ?? '').trim();
if (label.length > 0 && !seenIndices.has(idx)) {
seenIndices.add(idx);
found.push({ index: idx, label });
}
} else {
// No inline cursor match — fall back to start-of-line regex.
const startMatch = optionRe.exec(cursorLine);
if (startMatch) {
const idx = Number(startMatch[1]);
const label = (startMatch[2] ?? '').trim();
if (label.length > 0 && !seenIndices.has(idx)) {
seenIndices.add(idx);
found.push({ index: idx, label });
}
}
}
// Subsequent lines: standard start-of-line option parsing.
for (let i = cursorLineIdx + 1; i < lines.length; i++) {
const m = optionRe.exec(lines[i] ?? '');
if (!m) continue;
const idx = Number(m[1]);
@@ -442,16 +474,33 @@ export function parseQuestionPrompt(visible: string): string {
const tail = visible.length > 4096 ? visible.slice(-4096) : visible;
const lines = tail.split('\n');
// Find the latest ` 1.` cursor line (matching parseNumberedOptions).
// Find the latest line containing `<spaces>1.` (matching parseNumberedOptions
// unanchored to handle the box-layout case where cursor is mid-line after
// divider + header + prompt text on the same logical line).
let cursorLineIdx = -1;
for (let i = lines.length - 1; i >= 0; i--) {
if (/^\s*\s*1\./.test(lines[i] ?? '')) {
if (/\s*1\./.test(lines[i] ?? '')) {
cursorLineIdx = i;
break;
}
}
if (cursorLineIdx < 0) return '';
// Box-layout case: prompt text may be ON the cursor line, BEFORE `1.`.
// Extract that prefix (after stripping leading box-drawing characters and
// dividers) as the last piece of the prompt — appended after any prior
// multi-line prompt text we walk up to find.
const cursorLine = lines[cursorLineIdx] ?? '';
let inlinePrompt = '';
const cursorPos = cursorLine.search(/\s*1\./);
if (cursorPos > 0) {
inlinePrompt = cursorLine
.slice(0, cursorPos)
// Strip box-drawing chars + dividers + leading checkbox sigil.
.replace(/^[─━┄┅┈┉─┌┐└┘├┤┬┴┼│┃☐□■\s]+/, '')
.trim();
}
// Walk up at most 6 lines collecting prompt text. Stop at:
// - a blank line preceded by another blank line (paragraph break)
// - top of buffer
@@ -472,7 +521,8 @@ export function parseQuestionPrompt(visible: string): string {
promptLines.unshift(trimmed);
}
const joined = promptLines.join(' ').replace(/\s+/g, ' ').trim();
const all = inlinePrompt.length > 0 ? [...promptLines, inlinePrompt] : promptLines;
const joined = all.join(' ').replace(/\s+/g, ' ').trim();
return joined.slice(0, 240);
}
@@ -312,6 +312,57 @@ describe('parseNumberedOptions', () => {
test('returns empty array on prose-with-numbers (no cursor)', () => {
expect(parseNumberedOptions('text 1. one 2. two')).toEqual([]);
});
test('extracts options when the cursor is INLINE with prompt header (box-layout)', () => {
// Real /plan-ceo-review rendering: the TTY's cursor-positioning escapes
// collapse divider + header + prompt + cursor onto one logical line.
// Subsequent options (2..7) still start their own lines.
const visible = [
'────────────────────────────────────────',
'☐ Review scope What scope do you want me to CEO-review? 1. The branch\'s diff vs main',
' Review the full branch: ~10K LOC.',
'2. A specific plan file or design doc',
' You point me at a file (path) and I review that.',
'3. An idea you\'ll describe inline',
'4. Cancel — wrong skill',
'5. Type something.',
'────────────────────────────────────────',
'6. Chat about this',
'7. Skip interview and plan immediately',
].join('\n');
const opts = parseNumberedOptions(visible);
expect(opts).toHaveLength(7);
expect(opts[0]).toEqual({ index: 1, label: "The branch's diff vs main" });
expect(opts[1]?.index).toBe(2);
expect(opts[6]?.index).toBe(7);
expect(opts[6]?.label).toBe('Skip interview and plan immediately');
});
test('inline-cursor and start-of-line cursor both produce 7 options for the box-layout case', () => {
// The inline path captures option 1 from the cursor line itself; the
// subsequent-lines path captures 2..7 with the existing optionRe.
const inlineLayout = [
'header text 1. first option',
'2. second',
'3. third',
].join('\n');
expect(parseNumberedOptions(inlineLayout)).toEqual([
{ index: 1, label: 'first option' },
{ index: 2, label: 'second' },
{ index: 3, label: 'third' },
]);
const cleanLayout = [
' 1. first option',
' 2. second',
' 3. third',
].join('\n');
expect(parseNumberedOptions(cleanLayout)).toEqual([
{ index: 1, label: 'first option' },
{ index: 2, label: 'second' },
{ index: 3, label: 'third' },
]);
});
});
describe('runPlanSkillObservation env passthrough surface', () => {
@@ -428,6 +479,24 @@ describe('parseQuestionPrompt', () => {
2. no`;
expect(parseQuestionPrompt(visible)).toBe('D1 — Spaced out');
});
test('inline-cursor box-layout: extracts prompt text BEFORE 1. on the cursor line', () => {
// Real /plan-ceo-review rendering: divider + ☐ header + prompt text +
// cursor are all on one logical line because TTY cursor-positioning
// escapes collapse the box layout under stripAnsi.
const visible = [
'──────────────────',
'☐ Review scope What scope do you want me to CEO-review? 1. The branch\'s diff vs main',
'2. A specific plan file',
'3. An idea inline',
].join('\n');
const prompt = parseQuestionPrompt(visible);
// Should extract "Review scope" and the prompt text, dropping the ☐ box-drawing sigil.
expect(prompt).toContain('Review scope');
expect(prompt).toContain('What scope do you want me to CEO-review?');
expect(prompt).not.toContain('');
expect(prompt).not.toMatch(/^☐/);
});
});
describe('auqFingerprint', () => {