diff --git a/test/fixtures/plan-create-cropped-title-batching.json b/test/fixtures/plan-create-cropped-title-batching.json new file mode 100644 index 000000000..8f970a06e --- /dev/null +++ b/test/fixtures/plan-create-cropped-title-batching.json @@ -0,0 +1,13 @@ +{ + "source": "local rerun smoke-2.1.284-1790709409 (Claude Code 2.1.284) of plan-eng-multi-finding-batching: the Create pane stayed unanswered for 1,372 s because its title row was cropped above the file row", + "cwd": "/tmp/gstack-plan-count-Z3cntL", + "screen": " ../gstack-e2e-plan-eng-batching-DINQ9m/gstack-test-plan-eng-batching.md\n\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\n 1 # Eng Review \u2014 Plan: Add background job retry framework\n 2\n 3 Review target (fixed): `/tmp/gstack-plan-count-Z3cntL/PLAN.md` on branch `main` (commit 844c6ae)\n 4 Reviewer: /plan-eng-review (Claude, session 196868-1790709430-09cebc2c), 2026-09-29\n 5 Report file: this file (user-requested destination)\n 6\n 7 ## Original plan (unchanged copy)\n 8\n 9 # Plan: Add background job retry framework\n 10\n 11 ## Architecture\n 12 We'll roll a custom exponential-backoff scheduler inline in each worker\n 13 rather than use the existing job library's built-in retry hooks. Same\n 14 shape as the library version, but we want full control over the curve.\n 15\n 16 ## Code quality\n 17 The retry envelope (compute delay, log attempt, dispatch) is duplicated\n 18 across 5 worker files with copy-pasted bodies. We will leave the\n 19 duplication for now and refactor \"later.\"\n 20\n 21 ## Tests\n 22 The existing `processWebhookJob()` flow gets rewritten as part of this\n 23 change. No regression test for the prior at-most-once delivery guarantee\n 24 is planned.\n 25\n 26 ## Performance\n 27 On every retry we re-fetch the full job payload from the database, then\n 28 iterate the payload to recompute the dependency graph. Could cache the\n 29 graph on the first attempt; not planned.\n\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\u254c\n Do you want to create gstack-test-plan-eng-batching.md?\n \u276f 1. Yes\n 2. Yes, and switch to accept edits (auto-approve file edits and common file commands) for this session; Yes, and\n always allow access to /tmp/gstack-e2e-plan-eng-batching-DINQ9m for this session (shift+tab)\n 3. No\n\n Esc to cancel \u00b7 Tab to amend\n", + "event": { + "sessionId": "16914623-f7a4-49ca-bea5-fb4b56274cb1", + "toolUseId": "toolu_01BaNDAjTWTr5bf2sdwWC4DA", + "input": { + "file_path": "/tmp/gstack-e2e-plan-eng-batching-DINQ9m/gstack-test-plan-eng-batching.md", + "content": "# Eng Review \u2014 Plan: Add background job retry framework\n\nReview target (fixed): `/tmp/gstack-plan-count-Z3cntL/PLAN.md` on branch `main` (commit 844c6ae)\nReviewer: /plan-eng-review (Claude, session 196868-1790709430-09cebc2c), 2026-09-29\nReport file: this file (user-requested destination)\n\n## Original plan (unchanged copy)\n\n# Plan: Add background job retry framework\n\n## Architecture\nWe'll roll a custom exponential-backoff scheduler inline in each worker\nrather than use the existing job library's built-in retry hooks. Same\nshape as the library version, but we want full control over the curve.\n\n## Code quality\nThe retry envelope (compute delay, log attempt, dispatch) is duplicated\nacross 5 worker files with copy-pasted bodies. We will leave the\nduplication for now and refactor \"later.\"\n\n## Tests\nThe existing `processWebhookJob()` flow gets rewritten as part of this\nchange. No regression test for the prior at-most-once delivery guarantee\nis planned.\n\n## Performance\nOn every retry we re-fetch the full job payload from the database, then\niterate the payload to recompute the dependency graph. Could cache the\ngraph on the first attempt; not planned.\n" + } + } +} \ No newline at end of file diff --git a/test/helpers/plan-count-file-permission.ts b/test/helpers/plan-count-file-permission.ts index 8a792fa0d..824df6fa5 100644 --- a/test/helpers/plan-count-file-permission.ts +++ b/test/helpers/plan-count-file-permission.ts @@ -284,7 +284,11 @@ function currentCreatePreview(preview: string, r: any, config: string, cwd: stri if(event.name!=='Write'||`${event.sessionId}:${event.toolUseId}`!==r.pendingId||event.input?.file_path!==r.expected|| Date.parse(event.timestamp)MAX_WRITE_INPUT_BYTES) return false; - const source=event.input.content.split(/\r?\n/), rows=preview.split('\n'); + // A crop can keep the pane's file row and rule above the preview while its + // "Create file" title scrolls away. That row must name the owned path. + const header=/^ {0,3}(?![1-9]\d*(?:[ \t]|\n))(\S[^\n]*)\n[╌─━]{3,}[ \t]*\n/.exec(preview); + if(header && path.resolve(cwd,header[1]!.trim())!==r.expected) return false; + const source=event.input.content.split(/\r?\n/), rows=preview.slice(header?.[0].length ?? 0).split('\n'); const numbered:Array<{line:number;text:string}>=[]; let leading=''; for(const row of rows) { diff --git a/test/plan-create-combined-permission.test.ts b/test/plan-create-combined-permission.test.ts index a61523c62..639421938 100644 --- a/test/plan-create-combined-permission.test.ts +++ b/test/plan-create-combined-permission.test.ts @@ -1,4 +1,4 @@ -import {expect, test} from 'bun:test'; +import {describe, expect, test} from 'bun:test'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; @@ -6,6 +6,7 @@ import {createFilePermissionRecorder, recordFilePermission, currentFilePermissio import {readPlanCountTranscript} from './helpers/plan-count-transcript'; import {createPlanCountPermissionGuard} from './helpers/claude-pty-runner'; import captures from './fixtures/plan-create-combined-permission-70b.json'; +import croppedTitle from './fixtures/plan-create-cropped-title-batching.json'; function fixture(captured: typeof captures[number]) { const dir=fs.mkdtempSync(path.join(os.tmpdir(),'create-combined-')); @@ -101,3 +102,50 @@ for(const captured of captures) { }finally{f.close();} }); } + +describe('Create pane cropped below its title (plan-eng-multi-finding-batching, CLI 2.1.284)', () => { + function cropped() { + const dir=fs.mkdtempSync(path.join(os.tmpdir(),'create-cropped-')); + const cwd=path.join(dir,path.basename(croppedTitle.cwd)), config=path.join(dir,'config');fs.mkdirSync(cwd); + const originalDir=path.dirname(croppedTitle.event.input.file_path); + const expected=path.join(dir,path.basename(originalDir),path.basename(croppedTitle.event.input.file_path)); + fs.mkdirSync(path.dirname(expected)); + const {sessionId,toolUseId:id}=croppedTitle.event, timestamp=new Date().toISOString(); + const journal=path.join(config,'projects','owned',sessionId+'.jsonl');fs.mkdirSync(path.dirname(journal),{recursive:true}); + const recorder=createFilePermissionRecorder(cwd,config,expected)!; + const input={...croppedTitle.event.input,file_path:expected}; + fs.writeFileSync(journal,JSON.stringify({cwd,sessionId,isSidechain:false,timestamp, + message:{role:'assistant',content:[{type:'text',text:'Writing the review report.'},{type:'tool_use',id,name:'Write',input}]}})+'\n'); + recordFilePermission(JSON.stringify({hook_event_name:'PreToolUse',tool_name:'Write',session_id:sessionId, + tool_use_id:id,cwd,transcript_path:journal,tool_input:input}),recorder.file,cwd,config,expected); + // The relative file row keeps its captured sibling layout; only the footer's + // absolute directory is rebound to this fixture. + const screen=croppedTitle.screen.replaceAll(originalDir,path.dirname(expected)); + const read=(s=screen)=>currentFilePermissionEpoch(recorder.file,expected,cwd,config,Date.now()-1000,readPlanCountTranscript(config,cwd),s); + return {screen,read,id:`${sessionId}:${id}`,close(){recorder.dispose();fs.rmSync(dir,{recursive:true,force:true});}}; + } + + test('the captured pane shows its file row and rule but not the Create file title', () => { + expect(croppedTitle.screen).not.toMatch(/(?:^|\n) {0,3}Create file[ \t]*\n/); + expect(croppedTitle.screen.split('\n')[0]).toBe(' ../gstack-e2e-plan-eng-batching-DINQ9m/gstack-test-plan-eng-batching.md'); + }); + + test('the owned file row binds the pending Write and grants it once', () => { + const f=cropped();try { + const epoch=f.read();expect(epoch?.pendingId).toBe(f.id); + const guard=createPlanCountPermissionGuard(); + expect(guard(f.screen,'',epoch)).toBe('grant'); + expect(guard(f.screen,'',epoch)).toBe('handled'); + }finally{f.close();} + }); + + for(const [name,change] of [ + ['a file row naming another file',(s:string)=>s.replace('/gstack-test-plan-eng-batching.md\n','/other.md\n')], + ['a file row in another directory',(s:string)=>s.replace(' ../gstack-e2e-plan-eng-batching-DINQ9m/',' ../elsewhere/')], + ['an edited preview row',(s:string)=>s.replace('We will leave the','We will fix the')], + ] as const) test(`the cropped pane rejects ${name}`,()=>{ + const f=cropped();try { + const screen=change(f.screen);expect(screen).not.toBe(f.screen);expect(f.read(screen)).toBeFalsy(); + }finally{f.close();} + }); +});