mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-07 01:15:47 +01:00
fix: keep review input on the active native question
This commit is contained in:
1 parent
74bef511f9
commit
5769be57a7
3 files changed
+368
-11
No files matched your search
@@ -1224,7 +1224,45 @@ export function capturePlanCountQuestion(
|
||||
seen: Set<string>,
|
||||
observedAtMs: number,
|
||||
preReview: boolean,
|
||||
pending?: NativePlanQuestionCall,
|
||||
): AskUserQuestionFingerprint | null {
|
||||
const tail = stripPtyResidue(visible).replace(/\r+\n?/g, '\n').slice(-4096);
|
||||
const cursor = [...tail.matchAll(/❯\s*1\./g)].at(-1);
|
||||
// The options parser can fall back to ordinary numbered prose when an
|
||||
// old cursor leaves its window. Do not pair that prose with the prompt
|
||||
// parser's still-visible historical question and queue spurious input.
|
||||
if (!cursor) return null;
|
||||
|
||||
if (pending && !pending.answered && !pending.failed && pending.questions.length === 1) {
|
||||
const beforeCursor = tail.slice(0, cursor.index);
|
||||
const header = [...beforeCursor.matchAll(/(?:^|\n)[\t │┃]*[☐□]([^\n│]*)/g)].at(-1);
|
||||
const compact = (value: string) => value.replace(/\s+/g, '').toLowerCase();
|
||||
const question = pending.questions[0]!;
|
||||
const questionId = question.question.match(/<gstack-qid:[^>]+>/i)?.[0];
|
||||
const identity = questionId ?? question.question;
|
||||
const footer = /Enter\s*to\s*select\s*·\s*↑\/↓\s*to\s*navigate\s*·\s*Esc\s*to\s*cancel/i;
|
||||
// A captured B menu lost the dot in option 2. The native call supplies
|
||||
// its actual choices, but only after the matching boxed question,
|
||||
// cursor, and complete navigation footer establish the active UI.
|
||||
if (header && compact(header[1]!) === compact(question.header) &&
|
||||
!/❯\s*[1-9]\./.test(beforeCursor.slice(header.index)) &&
|
||||
compact(beforeCursor.slice(header.index)).includes(compact(identity)) &&
|
||||
footer.test(tail.slice(cursor.index))) {
|
||||
const fp = nativePlanCallFingerprint(pending, observedAtMs, preReview);
|
||||
const renderedOptions = parseNumberedOptions(visible);
|
||||
const renderedPrompt = parseQuestionPrompt(visible);
|
||||
const renderedSignature = renderedOptions.length >= 2 && renderedPrompt
|
||||
? auqFingerprint(renderedPrompt, renderedOptions) : null;
|
||||
const alreadyHandled = seen.has(fp.signature) || Boolean(renderedSignature && seen.has(renderedSignature));
|
||||
// Once answered, the same menu may linger while the native call is
|
||||
// no longer pending, or the native record may arrive after the UI
|
||||
// answer. Bind both identities before testing either direction.
|
||||
if (renderedSignature) seen.add(renderedSignature);
|
||||
seen.add(fp.signature);
|
||||
if (alreadyHandled) return null;
|
||||
return fp;
|
||||
}
|
||||
}
|
||||
const options = parseNumberedOptions(visible);
|
||||
if (options.length < 2) return null;
|
||||
const promptSnippet = parseQuestionPrompt(visible);
|
||||
@@ -1292,11 +1330,17 @@ export function planCountSubmissionInput(visible: string): string | null {
|
||||
const bar = bars.at(-1);
|
||||
if (!bar) return null;
|
||||
const panel = visible.slice(bar.index + bar[0].length).replace(/\s+/g, '');
|
||||
// Captured cursor redraws can omit the dot after 1 and the e in answers.
|
||||
// The native tab bar and explicit Submit label still identify this control.
|
||||
const cursor = [...panel.matchAll(/❯1\.?/g)].at(-1);
|
||||
if (!/Reviewyouranswe?rs/i.test(panel) || !cursor ||
|
||||
!/^❯1\.?Submit/i.test(panel.slice(cursor.index))) return null;
|
||||
const cursor = [...panel.matchAll(/❯([1-9])\.?/g)].at(-1);
|
||||
if (!/^Reviewyouranswe?rs/i.test(panel) || !cursor || cursor[1] !== '1') return null;
|
||||
const beforeCursor = panel.slice(0, cursor.index);
|
||||
// The native tab bar, review heading and immediately preceding question
|
||||
// identify Submit even when its button caption loses letters in a redraw.
|
||||
const readyPrompt = /Readytosubmityouranswers\?$/i.test(beforeCursor);
|
||||
// Older captures lack an intact Ready prompt. Keep their exact Submit
|
||||
// caption path only while no later question/menu has entered scrollback.
|
||||
const legacyCaption = /^❯1\.?Submit/i.test(panel.slice(cursor.index)) &&
|
||||
!/❯[1-9]|[☐☒]/.test(beforeCursor);
|
||||
if (!readyPrompt && !legacyCaption) return null;
|
||||
const tabs = [...bar[0].matchAll(/[☐☒]/g)].map((match) => match[0]);
|
||||
const unanswered = tabs.indexOf('☐');
|
||||
// At the Submit tab, move back to the first unanswered question. Only
|
||||
@@ -1428,6 +1472,15 @@ export const ceoFirstReviewAUQ: Step0BoundaryPredicate = (fp) =>
|
||||
}) ?? false;
|
||||
|
||||
export const engStep0Boundary: Step0BoundaryPredicate = (fp) =>
|
||||
// The native complexity gate can say "Reduce scope or proceed as-is"
|
||||
// without the historical "scope reduction recommendation" wording.
|
||||
// It closes setup only once that exact scope question has an answer.
|
||||
(fp.nativeCall?.answered === true && fp.nativeCall.questions.some(q =>
|
||||
Boolean(fp.nativeCall?.answers?.[q.question]) && /^scope$/i.test(q.header.trim()) &&
|
||||
/<gstack-qid:\s*plan-eng-scope-complexity\s*>/i.test(q.question) &&
|
||||
/\bcomplexity\s+check\b/i.test(q.question) &&
|
||||
q.options.some(o => /^proceed\s+as[- ]is\b/i.test(o.label)) &&
|
||||
q.options.some(o => /^reduce\b/i.test(o.label)))) ||
|
||||
/scope\s*reduction\s*recommendation|cross[\s-]*project\s*learnings/i.test(
|
||||
fp.promptSnippet,
|
||||
) ||
|
||||
@@ -2394,15 +2447,12 @@ export async function runPlanSkillCounting(opts: {
|
||||
);
|
||||
}
|
||||
|
||||
// Numbered option list?
|
||||
if (!isNumberedOptionListVisible(visible)) continue;
|
||||
|
||||
// Dedupe the complete question, not just its answer labels: separate
|
||||
// findings often reuse the same Add to plan / Defer / Skip menu.
|
||||
const fp = capturePlanCountQuestion(visible, seen, Date.now() - startedAt, !boundaryFired);
|
||||
const pending = transcript.calls.find(c => !c.answered && !c.failed);
|
||||
const fp = capturePlanCountQuestion(visible, seen, Date.now() - startedAt, !boundaryFired, pending);
|
||||
if (!fp) continue;
|
||||
// Press to advance — first AUQ may use the override pick.
|
||||
const pending = transcript.calls.find(c => !c.answered && !c.failed);
|
||||
const routing = pending?.questions.length === 1
|
||||
? nativePlanCallFingerprint(pending, fp.observedAtMs, fp.preReview) : fp;
|
||||
const prerequisitePick = planCountPrerequisitePick(routing);
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
designStep0Boundary,
|
||||
designFirstReviewAUQ,
|
||||
planCountQuestionPhase,
|
||||
nativePlanCallFingerprint,
|
||||
devexStep0Boundary,
|
||||
type ClaudePtyOptions,
|
||||
type AskUserQuestionFingerprint,
|
||||
@@ -720,6 +721,23 @@ describe('classifyVisible (runtime path through the runner classifier)', () => {
|
||||
});
|
||||
|
||||
describe('parseNumberedOptions', () => {
|
||||
test('does not combine an old AUQ prompt with the later ordinary test-case list', () => {
|
||||
// B CEO retry, 2026-09-08: the old prompt cursor slid outside the
|
||||
// option parser's 4KB window. Its prose fallback then supplied a new
|
||||
// five-item test list while the prompt parser retained the old AUQ.
|
||||
const visible = '☐Stripe event types\nWhich event should the handler accept?\n' +
|
||||
'❯1.Specify one canonical event\n2.Accept all events\n' + '·'.repeat(4200) + '\n' +
|
||||
'Minimum required test cases (all must be specified in the plan):\n' +
|
||||
'1.Happypath:validcanonicalevent,knownuser→userupdated,emailsent\n' +
|
||||
'2.Email failure:emailthrows→userupdated,errorlogged,HTTP200\n' +
|
||||
'3.DB timeout: DB throws onuser update →exceptin ropagates, non-200\n' +
|
||||
'4.Unkown event typ: non-canonical event→ HTTP200,nouserupdate\n' +
|
||||
'5.Unknown user: valid event, usernotinDB→existingguard→HTTP200\n❯1\n';
|
||||
const seen = new Set<string>();
|
||||
expect(capturePlanCountQuestion(visible, seen, 0, false)).toBeNull();
|
||||
expect(seen.size).toBe(0);
|
||||
});
|
||||
|
||||
test('extracts options from a clean cursor list', () => {
|
||||
const visible = `
|
||||
❯ 1. HOLD SCOPE
|
||||
@@ -787,6 +805,67 @@ describe('parseNumberedOptions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('pending native question on a damaged option render', () => {
|
||||
// Exact final B CEO Test scope shape. The native call had been read in
|
||||
// an in-progress snapshot, but option 2's missing dot prevented input.
|
||||
const frame = [
|
||||
'☐Test scope',
|
||||
'│Section 6 (Tests) — Theplanhasnotestsforanewpaymentprocessingcodepath.Theexistingintegrationsuitehas',
|
||||
'│never seen this handlerand cannotcatchregressionsinit.Minimumviabletestplanforminimalpatch:5unittests',
|
||||
'│(happy path, mal failur, DB timeout, unknowneventtype,unknownuser).Shouldtheplanalsoincludeanintegration',
|
||||
'│testhittingthefullwebhookstack?<gstack-qid:plan-ceo-test-scope>',
|
||||
'❯1.Unittestsonlyfornow(recommended)',
|
||||
'5 unit tests covering the criticalpaths. No integration stin v1.',
|
||||
'2Uni tsts + one integration test',
|
||||
'5 uit tsts + on ed-to-end integrationtestsendiga signe Stripeevent.',
|
||||
'3.Integrationtestonly',
|
||||
'4.Typesomething.',
|
||||
'5. Chataboutthis',
|
||||
'Enter to select · ↑/↓ to navigate · Esc to cancel',
|
||||
'❯1',
|
||||
].join('\n');
|
||||
const pending = {
|
||||
sessionId: '66fb6218-4a68-4f1a-a729-6407f14fd6b8',
|
||||
toolUseId: 'toolu_017DicePqWNVyDsLCd2Y2MCi', answered: false,
|
||||
questions: [{ header: 'Test scope', question: 'Should the plan also include an integration test hitting the full webhook stack? <gstack-qid:plan-ceo-test-scope>',
|
||||
options: ['Unit tests only for now (recommended)', 'Unit tests + one integration test', 'Integration test only'].map(label => ({ label })) }],
|
||||
};
|
||||
|
||||
test('uses lossless pending options after a positively matched native question has rendered', () => {
|
||||
const seen = new Set<string>();
|
||||
const captured = capturePlanCountQuestion(frame, seen, 0, false, pending);
|
||||
expect(captured?.nativeCall).toBe(pending);
|
||||
expect(captured?.options).toEqual(pending.questions[0].options.map((o, i) => ({ index: i + 1, label: o.label })));
|
||||
expect(capturePlanCountQuestion(frame, seen, 1, false, pending)).toBeNull();
|
||||
// A corrected redraw is still the same pending native question.
|
||||
expect(capturePlanCountQuestion(frame.replace('2Uni tsts', '2.Unit tests'), seen, 2, false, pending)).toBeNull();
|
||||
expect(capturePlanCountQuestion(frame.replace('2Uni tsts', '2.Unit tests'), seen, 3, false)).toBeNull();
|
||||
expect(seen.has(captured!.signature)).toBe(true);
|
||||
});
|
||||
|
||||
test('binds delayed native metadata to the already-answered visible question', () => {
|
||||
const seen = new Set<string>();
|
||||
const clean = frame.replace('2Uni tsts', '2.Unit tests');
|
||||
expect(capturePlanCountQuestion(clean, seen, 0, false)).not.toBeNull();
|
||||
expect(capturePlanCountQuestion(clean, seen, 1, false, pending)).toBeNull();
|
||||
expect(capturePlanCountQuestion(frame, seen, 2, false, pending)).toBeNull();
|
||||
});
|
||||
|
||||
test('requires pending single-question metadata, matching current header, cursor, and navigation footer', () => {
|
||||
for (const call of [undefined, { ...pending, answered: true }, { ...pending, failed: true },
|
||||
{ ...pending, questions: [...pending.questions, ...pending.questions] },
|
||||
{ ...pending, questions: [{ ...pending.questions[0], header: 'Prior decision' }] },
|
||||
{ ...pending, questions: [{ ...pending.questions[0], question: 'Different issue <gstack-qid:plan-ceo-different-test-scope>' }] }]) {
|
||||
expect(capturePlanCountQuestion(frame, new Set(), 0, false, call)).toBeNull();
|
||||
}
|
||||
for (const altered of [frame.replace('☐Test scope', 'Test scope'), frame.replace('❯1.', '1.'),
|
||||
frame.replace('Enter to select · ↑/↓ to navigate · Esc to cancel', ''),
|
||||
frame + '\n☐Different question\n❯1.Waiting for its choices']) {
|
||||
expect(capturePlanCountQuestion(altered, new Set(), 0, false, pending)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('runPlanSkillObservation env passthrough surface', () => {
|
||||
test('ClaudePtyOptions exposes env: Record<string, string>', () => {
|
||||
// Type-level guard: this file would fail to compile if the env field
|
||||
@@ -1357,6 +1436,26 @@ describe('classifyPlanCountFrame replay', () => {
|
||||
});
|
||||
|
||||
describe('planCountSubmissionInput replay', () => {
|
||||
test('uses the captured DevEx panel anchors when the Submit button label is damaged', () => {
|
||||
const captured = [
|
||||
'← ☒ Routing setup ☐ Cross-project ✔ Submit →',
|
||||
'Review your answers',
|
||||
'⚠You have not answere all questions',
|
||||
' │ ●D1 — Shouldgstack add skill routingrulestothisproject\'sCLAUDE.md?<gstack-qid:routing-injection>',
|
||||
'→dd routing rules (Recmmeded)',
|
||||
'Ready to submit your answers?',
|
||||
'❯1.Sbmi answers',
|
||||
'2Cancel',
|
||||
].join('\r\r');
|
||||
expect(planCountSubmissionInput(captured)).toBe('\x1b[Z');
|
||||
expect(planCountSubmissionInput(captured.replace('☐ Cross-project', '☒ Cross-project'))).toBe('\r');
|
||||
expect(planCountSubmissionInput(captured + '\r☐ Retry spec\rRetry once?\r❯1.Yes\r2.No')).toBeNull();
|
||||
expect(planCountSubmissionInput(captured + '\r☐ Proposal\rSend this proposal?\r❯1.Submit proposal\r2.Keep editing')).toBeNull();
|
||||
expect(planCountSubmissionInput(captured + '\r☐ Retry spec\rRetry once?\r❯2.No\r3.Other')).toBeNull();
|
||||
expect(planCountSubmissionInput(captured.replace('Review your answers', 'Review context'))).toBeNull();
|
||||
expect(planCountSubmissionInput(captured.replace('Ready to submit your answers?', 'Read the proposed answers.'))).toBeNull();
|
||||
});
|
||||
|
||||
test('the captured mode Submit panel with a damaged caption and dotless cursor returns to its unanswered tab', () => {
|
||||
// Exact final active panel from targeted-a's SCOPE EXPANSION retry.
|
||||
const captured = [
|
||||
@@ -1550,6 +1649,136 @@ describe('Step0BoundaryPredicate per-skill', () => {
|
||||
});
|
||||
|
||||
describe('engStep0Boundary', () => {
|
||||
// Captured native question text, labels and answers from targeted-b's
|
||||
// engineering retry. Descriptions are immaterial to the phase boundary.
|
||||
const captured = [
|
||||
{
|
||||
"header": "Scope",
|
||||
"question": "D1 — Multi-tenant Auth Refactor complexity check: 12 files + 4 new classes. Reduce scope or proceed as-is? <gstack-qid:plan-eng-scope-complexity>",
|
||||
"options": [
|
||||
"Proceed as-is",
|
||||
"Reduce: cut TokenStore + RequestPolicy",
|
||||
"Reduce: single-pass strangler"
|
||||
],
|
||||
"answer": "Proceed as-is"
|
||||
},
|
||||
{
|
||||
"header": "Shared Cache",
|
||||
"question": "D2 — Arch issue 1: AuthBroker and SessionMint both mutate a global shared AuthCache via module-level export. How should this be fixed? <gstack-qid:plan-eng-shared-mutable-cache>",
|
||||
"options": [
|
||||
"Inject AuthCache as a dependency (recommended)",
|
||||
"Make mutations go through a single owner",
|
||||
"Accept the risk for now, document it"
|
||||
],
|
||||
"answer": "Inject AuthCache as a dependency (recommended)"
|
||||
},
|
||||
{
|
||||
"header": "TOCTOU",
|
||||
"question": "D3 — Arch issue 2: TOCTOU window during tenant suspension. The plan says AuthCache invalidates entries on tenant suspension, but with two services mutating the cache, a token validation begun before suspension completes may still succeed after the tenant is suspended. How should this be addressed? <gstack-qid:plan-eng-toctou-suspension>",
|
||||
"options": [
|
||||
"Add suspension check at session issuance boundary (recommended)",
|
||||
"Add invalidation ordering guarantee to the plan",
|
||||
"Accept the window, note it as an edge case"
|
||||
],
|
||||
"answer": "Add suspension check at session issuance boundary (recommended)"
|
||||
},
|
||||
{
|
||||
"header": "Error handling",
|
||||
"question": "D4 — Code quality issue 1: validateAndDispatch() swallows three distinct error classes in nested catch blocks with no re-throw, logging, or metrics. Errors disappear silently. How should this be handled? <gstack-qid:plan-eng-error-swallowing>",
|
||||
"options": [
|
||||
"Refactor to flat error handling with explicit re-throw or typed result (recommended)",
|
||||
"Add logging inside each catch, keep structure",
|
||||
"Leave it, add a lint rule to catch new instances"
|
||||
],
|
||||
"answer": "Refactor to flat error handling with explicit re-throw or typed result (recommended)"
|
||||
},
|
||||
{
|
||||
"header": "Test coverage",
|
||||
"question": "D5 — Test issue 1: 0/18 code paths covered in the plan. The plan scopes tests to 'new components and their success/error paths' but omits: cache invalidation edge cases, all three catch blocks in validateAndDispatch(), and the 5 IDP call failure modes. Should the test scope be expanded? <gstack-qid:plan-eng-test-coverage>",
|
||||
"options": [
|
||||
"Expand test scope to cover all 18 paths (recommended)",
|
||||
"Cover new paths only, defer legacy and edge cases",
|
||||
"Accept current test scope as stated in the plan"
|
||||
],
|
||||
"answer": "Expand test scope to cover all 18 paths (recommended)"
|
||||
},
|
||||
{
|
||||
"header": "IDP calls",
|
||||
"question": "D6 — Performance issue 1: token validation makes 5 sequential IDP API calls. The plan acknowledges they are independent and could be parallelized via Promise.all. Should this be fixed in this PR or deferred? <gstack-qid:plan-eng-idp-sequential-calls>",
|
||||
"options": [
|
||||
"Parallelize now with Promise.all (recommended)",
|
||||
"Defer to a follow-up PR, add a TODO",
|
||||
"Add a concurrency cap via Promise.all with limit"
|
||||
],
|
||||
"answer": "Parallelize now with Promise.all (recommended)"
|
||||
},
|
||||
{
|
||||
"header": "Cache bounds",
|
||||
"question": "D7 — Performance issue 2 (medium confidence): AuthCache evicts on token expiry but the plan doesn't mention a max-size bound. In a high-tenant deployment, long-lived non-expiring tokens could grow the cache without bound. Is there already a size cap, or should one be added? <gstack-qid:plan-eng-cache-unbounded>",
|
||||
"options": [
|
||||
"Verify existing cap exists and document it in the plan",
|
||||
"Add explicit max-size eviction policy to AuthCache (recommended)",
|
||||
"Defer, this is a scaling concern not a correctness one"
|
||||
],
|
||||
"answer": "Verify existing cap exists and document it in the plan"
|
||||
},
|
||||
{
|
||||
"header": "TODO",
|
||||
"question": "D8 — TODO candidate: Auth failure observability. The plan replaces silently-swallowed errors with typed errors, but adds no metrics, logs, or alerts for auth failure patterns. This gap won't surface until production incidents occur. Add a TODO? <gstack-qid:plan-eng-todo-observability>",
|
||||
"options": [
|
||||
"Add to TODOS.md (recommended)",
|
||||
"Build it now in this PR instead of deferring",
|
||||
"Skip — not valuable enough"
|
||||
],
|
||||
"answer": "Add to TODOS.md (recommended)"
|
||||
}
|
||||
];
|
||||
function nativeScopeFingerprint(index = 0): AskUserQuestionFingerprint {
|
||||
const row = captured[index];
|
||||
return nativePlanCallFingerprint({
|
||||
sessionId: 'captured-eng-retry', toolUseId: `call-${index}`, answered: true,
|
||||
questions: [{ header: row.header, question: row.question, options: row.options.map(label => ({ label })) }],
|
||||
answers: { [row.question]: row.answer },
|
||||
}, index, true);
|
||||
}
|
||||
|
||||
test('keeps captured scope-complexity setup and counts the six following findings', () => {
|
||||
let started = false;
|
||||
const phases = captured.map((_, index) => {
|
||||
const question = nativeScopeFingerprint(index);
|
||||
const phase = planCountQuestionPhase(question, started, engStep0Boundary);
|
||||
started = phase.reviewStarted;
|
||||
return phase;
|
||||
});
|
||||
expect(phases[0]).toEqual({ preReview: true, reviewStarted: true });
|
||||
expect(phases.slice(1, 7).filter(phase => !phase.preReview)).toHaveLength(6);
|
||||
// The later answered observability TODO retains the existing phase policy.
|
||||
expect(phases.filter(phase => !phase.preReview)).toHaveLength(7);
|
||||
});
|
||||
|
||||
test('requires an answered native scope decision with its opposed scope choices', () => {
|
||||
const original = nativeScopeFingerprint();
|
||||
expect(engStep0Boundary(original)).toBe(true);
|
||||
expect(engStep0Boundary({ ...original, nativeCall: undefined })).toBe(false);
|
||||
const pending = structuredClone(original);
|
||||
pending.nativeCall!.answered = false;
|
||||
delete pending.nativeCall!.answers;
|
||||
expect(engStep0Boundary(pending)).toBe(false);
|
||||
const unansweredScope = structuredClone(original);
|
||||
unansweredScope.nativeCall!.answers = { 'Separate answered setup question': 'Continue' };
|
||||
expect(engStep0Boundary(unansweredScope)).toBe(false);
|
||||
for (const alter of [
|
||||
(q: any) => { q.header = 'Architecture'; },
|
||||
(q: any) => { q.question = q.question.replace('plan-eng-scope-complexity', 'plan-eng-cache-complexity'); },
|
||||
(q: any) => { q.options = [{ label: 'Change cache size' }, { label: 'Keep cache size' }]; },
|
||||
]) {
|
||||
const unrelated = structuredClone(original);
|
||||
alter(unrelated.nativeCall!.questions[0]);
|
||||
unrelated.nativeCall!.answers = { [unrelated.nativeCall!.questions[0].question]: captured[0].answer };
|
||||
expect(engStep0Boundary(unrelated)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('FIRES on cross-project learnings prompt', () => {
|
||||
const f = fp('Enable cross-project learnings on this machine?', ['Yes', 'No']);
|
||||
expect(engStep0Boundary(f)).toBe(true);
|
||||
|
||||
@@ -177,6 +177,8 @@ try {
|
||||
{ name: 'caller-policy', skillName: 'plan-devex-review', prompt: '# Native DX plan', mode: 'prerequisite', skipIndex: 2, custom: true },
|
||||
{ name: 'late-mode', skillName: 'plan-devex-review', prompt: '# Native DX plan', mode: 'late-mode' },
|
||||
{ name: 'batched-mode', skillName: 'plan-devex-review', prompt: '# Native DX plan', mode: 'batched-mode' },
|
||||
{ name: 'damaged-submit', skillName: 'plan-devex-review', prompt: '# Native DX plan', mode: 'damaged-submit' },
|
||||
{ name: 'damaged-menu', skillName: 'plan-ceo-review', prompt: '# Native CEO plan', mode: 'damaged-menu' },
|
||||
].map((item) => ({ ...item, record: path.join(dir, `${item.name}.jsonl`) }));
|
||||
|
||||
// The fake snapshots its environment BEFORE installing the first stdin
|
||||
@@ -206,6 +208,14 @@ const answer = () => native('user', [{ type: 'tool_result', tool_use_id: 'questi
|
||||
{ toolUseResult: { answers: Object.fromEntries(nativeQuestions.map(q => [q.question, q.options[0].label])) } });
|
||||
const questionMetadata = (header, question, labels) => ({ header, question, options: labels.map(label => ({ label })) });
|
||||
const modeQuestion = questionMetadata('Review mode', 'How deep should this DX review go? <gstack-qid:plan-devex-review-mode>', ['DX EXPANSION', 'DX POLISH', 'DX TRIAGE']);
|
||||
const submitQuestions = [
|
||||
questionMetadata('Routing setup', 'Should gstack add skill routing rules? <gstack-qid:routing-injection>', ['Add routing rules', 'Skip']),
|
||||
questionMetadata('Cross-project', 'Enable cross-project learnings?', ['Enable cross-project', 'Keep project-scoped']),
|
||||
];
|
||||
const submitPanel = (answered) => '\r← ☒ Routing setup ' + (answered ? '☒' : '☐') + ' Cross-project ✔ Submit →\r' +
|
||||
'Review your answers\r' + (answered ? '' : '⚠You have not answere all questions\r') +
|
||||
'│ ●D1 — Should gstack add skill routing rules?\r→Add routing rules\r' +
|
||||
'Ready to submit your answers?\r❯1.Sbmi answers\r2Cancel\r';
|
||||
const skillDir = path.join(process.env.CLAUDE_CONFIG_DIR, 'skills', process.env.FIXTURE_SKILL);
|
||||
const planPath = path.join(process.cwd(), 'PLAN.md');
|
||||
const contextPath = path.join(process.cwd(), 'CLAUDE.md');
|
||||
@@ -239,6 +249,41 @@ process.stdin.on('data', (data) => {
|
||||
const input = data.toString('utf8');
|
||||
record({ type: 'input', data: input });
|
||||
if (!firstInput) {
|
||||
if (process.env.FIXTURE_MODE === 'damaged-menu') {
|
||||
if (question === 0) record({ type: 'input-during-prose', input });
|
||||
else if (input.includes('\r')) {
|
||||
record({ type: 'damaged-menu-answer', input });
|
||||
answer();
|
||||
process.stdout.write('\nGSTACK REVIEW REPORT\n');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (process.env.FIXTURE_MODE === 'damaged-submit') {
|
||||
if (question === 0 && input === '\x1b[Z') {
|
||||
question = 1;
|
||||
record({ type: 'returned-to-unanswered' });
|
||||
process.stdout.write('\r← ☒ Routing setup ☐ Cross-project ✔ Submit →\r' +
|
||||
'│ Enable cross-project learnings?\r❯1.Enable cross-project\r2.Keep project-scoped\r');
|
||||
} else if (question === 1) {
|
||||
selected += input.replace(/\r/g, '');
|
||||
if (input.includes('\r')) {
|
||||
question = 2;
|
||||
record({ type: 'answered-remaining-tab', selected });
|
||||
process.stdout.write(submitPanel(true));
|
||||
}
|
||||
} else if (question === 2 && input === '\r') {
|
||||
// Native JSONL can arrive only after the whole packet is submitted.
|
||||
ask(submitQuestions);
|
||||
native('user', [{ type: 'tool_result', tool_use_id: 'question-' + callId, content: 'Your questions have been answered.' }],
|
||||
{ toolUseResult: { answers: {
|
||||
[submitQuestions[0].question]: submitQuestions[0].options[0].label,
|
||||
[submitQuestions[1].question]: submitQuestions[1].options[Number(selected) - 1]?.label,
|
||||
} } });
|
||||
record({ type: 'submitted-both-answers' });
|
||||
process.stdout.write('\nGSTACK REVIEW REPORT\n');
|
||||
} else record({ type: 'unexpected-submit-input', question, input });
|
||||
return;
|
||||
}
|
||||
if (['late-mode', 'batched-mode'].includes(process.env.FIXTURE_MODE)) {
|
||||
selected += input.replace(/\r/g, '');
|
||||
if (input.includes('\r')) {
|
||||
@@ -294,6 +339,23 @@ process.stdin.on('data', (data) => {
|
||||
}
|
||||
firstInput = false;
|
||||
if (process.env.FIXTURE_MODE === 'exit') process.exit(7);
|
||||
if (process.env.FIXTURE_MODE === 'damaged-menu') {
|
||||
process.stdout.write('☐Stripe event types\nWhich event should the handler accept?\n❯1.Specify one canonical event\n2.Accept all events\n' +
|
||||
'·'.repeat(4200) + '\nMinimum required test cases:\n1.Happy path\n2.Email failure\n3.DB timeout\n4.Unknown event\n5.Unknown user\n❯1\n');
|
||||
completion = setTimeout(() => {
|
||||
question = 1;
|
||||
ask([questionMetadata('Test scope', 'Should the plan include an integration test? <gstack-qid:plan-ceo-test-scope>',
|
||||
['Unit tests only for now (recommended)', 'Unit tests + one integration test', 'Integration test only'])]);
|
||||
process.stdout.write('\n☐Test scope\nShould the plan include an integration test? <gstack-qid:plan-ceo-test-scope>\n' +
|
||||
'❯1.Unittestsonlyfornow(recommended)\n2Uni tsts + one integration test\n3.Integrationtestonly\n' +
|
||||
'Enter to select · ↑/↓ to navigate · Esc to cancel\n');
|
||||
}, 4100);
|
||||
return;
|
||||
}
|
||||
if (process.env.FIXTURE_MODE === 'damaged-submit') {
|
||||
process.stdout.write(submitPanel(false));
|
||||
return;
|
||||
}
|
||||
if (['late-mode', 'batched-mode'].includes(process.env.FIXTURE_MODE)) {
|
||||
if (process.env.FIXTURE_MODE === 'batched-mode') ask([modeQuestion,
|
||||
questionMetadata('Separate decision', 'Which fixture target should be used?', ['First target', 'Second target'])]);
|
||||
@@ -425,8 +487,9 @@ await Bun.write(${JSON.stringify(resultPath)}, JSON.stringify({ results, onboard
|
||||
expect(startup.sections).toBe(fs.readFileSync(path.join(ROOT, item.skillName, 'sections/review-sections.md'), 'utf8'));
|
||||
expect(events.filter((event) => event.type === 'input').map((event) => event.data).join(''))
|
||||
.toBe(`/${item.skillName}\r` + (item.mode === 'prerequisite' ? `${item.custom ? 1 : 2}\r${item.skipIndex}\r`
|
||||
: item.mode === 'damaged-submit' ? '\x1b[Z2\r\r'
|
||||
: item.mode === 'batched-finding' ? '2\r1\r' : item.mode === 'batched-mode' ? '1\r'
|
||||
: ['direct-finding', 'failed-call', 'permission', 'late-mode'].includes(item.mode) ? '2\r' : ''));
|
||||
: ['direct-finding', 'failed-call', 'permission', 'late-mode', 'damaged-menu'].includes(item.mode) ? '2\r' : ''));
|
||||
expect(fs.existsSync(startup.cwd)).toBe(false);
|
||||
expect(fs.existsSync(startup.stateRoot)).toBe(false);
|
||||
expect(() => process.kill(startup.pid, 0)).toThrow();
|
||||
@@ -447,6 +510,12 @@ await Bun.write(${JSON.stringify(resultPath)}, JSON.stringify({ results, onboard
|
||||
expect(result.observation.fingerprints).toHaveLength(1);
|
||||
expect(result.observation.fingerprints[0].nativeCall.questions).toHaveLength(item.mode === 'batched-finding' ? 2 : 1);
|
||||
}
|
||||
if (item.mode === 'damaged-menu') {
|
||||
expect(events.filter(event => event.type === 'input-during-prose')).toEqual([]);
|
||||
expect(events.filter(event => event.type === 'damaged-menu-answer').map(event => event.input)).toEqual(['2\r']);
|
||||
expect(result.observation.fingerprints).toHaveLength(1);
|
||||
expect(result.observation.fingerprints[0].nativeCall.questions[0].header).toBe('Test scope');
|
||||
}
|
||||
if (item.mode === 'failed-call') {
|
||||
expect(result.observation.transcript.calls[0].failure).toContain('is_error');
|
||||
expect(result.observation.transcript.calls[0].answered).toBe(false);
|
||||
@@ -477,6 +546,15 @@ await Bun.write(${JSON.stringify(resultPath)}, JSON.stringify({ results, onboard
|
||||
expect(call.questions).toHaveLength(item.mode === 'late-mode' ? 1 : 2);
|
||||
expect(call.unansweredQuestionIndices).toEqual(item.mode === 'late-mode' ? [] : [1]);
|
||||
}
|
||||
if (item.mode === 'damaged-submit') {
|
||||
expect(events.filter(event => ['returned-to-unanswered', 'answered-remaining-tab', 'submitted-both-answers', 'unexpected-submit-input'].includes(event.type)))
|
||||
.toEqual([{ type: 'returned-to-unanswered' }, { type: 'answered-remaining-tab', selected: '2' }, { type: 'submitted-both-answers' }]);
|
||||
expect(result.observation.step0Count).toBe(1);
|
||||
expect(result.observation.reviewCount).toBe(0);
|
||||
expect(result.observation.transcript.calls).toHaveLength(1);
|
||||
expect(result.observation.transcript.calls[0].unansweredQuestionIndices).toEqual([]);
|
||||
expect(result.observation.transcript.calls[0].questions).toHaveLength(2);
|
||||
}
|
||||
cwds.add(startup.cwd);
|
||||
stateRoots.add(startup.stateRoot);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user