From 4a5d72dc3db6cfd4565880f11dcac87693ee2c95 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 14:10:08 +0000 Subject: [PATCH] fix(flaky): the PTY floor runner ends on an idle plan-mode turn and reports an API error as api_error, not observer_timeout Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- test/fixtures/pty-idle-turn-end.json | 10 +++++++++- .../claude-pty-runner.runners.unit.test.ts | 13 ++++++++++++ .../claude-pty-runner.screen.unit.test.ts | 20 +++++++++++++++++++ test/helpers/pty/runners/floor.ts | 9 +++++++-- test/helpers/pty/screen.ts | 7 ++++--- 5 files changed, 53 insertions(+), 6 deletions(-) diff --git a/test/fixtures/pty-idle-turn-end.json b/test/fixtures/pty-idle-turn-end.json index d6d7d1908..846209986 100644 --- a/test/fixtures/pty-idle-turn-end.json +++ b/test/fixtures/pty-idle-turn-end.json @@ -1,5 +1,5 @@ { - "provenance": "terminal.visible.log captures (session.visibleSince(command mark), byte-exact, ESC/CR residue kept) from paid-slice-5-a1 artifacts of test/skill-e2e-plan-mode-no-op.test.ts.", + "provenance": "terminal.visible.log captures (session.visibleSince(command mark), byte-exact, ESC/CR residue kept); each entry names its run, head and test. The first three are paid-slice-5-a1 artifacts of test/skill-e2e-plan-mode-no-op.test.ts; planModeApiErrorIdle is paid-slice-9-a1 of test/skill-e2e-plan-devex-finding-floor.test.ts.", "scopePendingIdle": { "runId": 37082906717, "headSha": "988e985d", @@ -23,5 +23,13 @@ "recordedPassed": true, "note": "Whole capture. The pending line followed by the full prose menu, then the idle prompt; classified 'asked'.", "visible": "\u000f\u001b[?2004h\u001b[?1000h\u001b[?1002h\u001b[?1003h\u001b[?1006h\u001b[?25l\n❯ /plan-eng-review \n*Computing… \n❯ \nesc to interrupt · ← for agents\u001b[?25h\u001b[?25l\n /plan-eng-review\u001b[?25h\u001b[?25l\n✢\u001b[?25h\u001b[?25l\n·\u001b[?25h\u001b[?25l\n…\u001b[?25h\u001b[?25l\n ● high · /effort\u001b[?25h\u001b[?25l\n✢g\u001b[?25h\u001b[?25l\n*n\u001b[?25h\u001b[?25l\n✶\u001b[?25h\u001b[?25l\ni…\u001b[?25h\u001b[?25l\n✻\u001b[?25h\u001b[?25l\n✽tg\u001b[?25h\u001b[?25l\nun\u001b[?25h\u001b[?25l\n✻pi\u001b[?25h\u001b[?25l\nGallop\u001b[?25h\u001b[?25l\n(1s · thinking)\u001b[?25h\u001b[?25l\n✶lpthinking\u001b[?25h\u001b[?25l\n*\u001b[?25h\u001b[?25l\n✢ao2\u001b[?25h\u001b[?25l\nthinking\u001b[?25h\u001b[?25l\n·Glthinking\u001b[?25h\u001b[?25l\nthinking\u001b[?25h\u001b[?25l\nlthinking\u001b[?25h\u001b[?25l\nthinking\u001b[?25h\u001b[?25l\n✢athinking\u001b[?25h\u001b[?25l\n*Gthinking\u001b[?25h\u001b[?25l\n✶\u001b[?25h\u001b[?25l\n✻3\u001b[?25h\u001b[?25l\nthinking\u001b[?25h\u001b[?25l\n✽thinking\u001b[?25h\u001b[?25l\nthinking\u001b[?25h\u001b[?25l\nthinking\u001b[?25h\u001b[?25l\nthinking\u001b[?25h\u001b[?25l\n✻thinking\u001b[?25h\u001b[?25l\n✶thinking\u001b[?25h\u001b[?25l\n*\u001b[?25h\u001b[?25l\n✢4\u001b[?25h\u001b[?25l\nthinking\u001b[?25h\u001b[?25l\n·thinking\u001b[?25h\u001b[?25l\nthinking\u001b[?25h\u001b[?25l\nthinking\u001b[?25h\u001b[?25l\n✢thinking\u001b[?25h\u001b[?25l\nthinking\u001b[?25h\u001b[?25l\n*thinking\u001b[?25h\u001b[?25l\n✶\u001b[?25h\u001b[?25l\n✻5\u001b[?25h\u001b[?25l\nthinking\u001b[?25h\u001b[?25l\n✽thinking\u001b[?25h\u001b[?25l\nthinking\u001b[?25h\u001b[?25l\nthinking\u001b[?25h\u001b[?25l\n✻thinking\u001b[?25h\u001b[?25l\n✶thinking\u001b[?25h\u001b[?25l\n*\u001b[?25h\u001b[?25l\n✢6thinking\u001b[?25h\u001b[?25l\n·thinking\u001b[?25h\u001b[?25l\n…thinking\u001b[?25h\u001b[?25l\nthinking\u001b[?25h\u001b[?25l\n✢gthinking\u001b[?25h\u001b[?25l\n*nthinking\u001b[?25h\u001b[?25l\n✶\u001b[?25h\u001b[?25l\n✻i…7thinking\u001b[?25h\u001b[?25l\n✽pgthinking\u001b[?25h\u001b[?25l\nonthinking\u001b[?25h\u001b[?25l\nthinking\u001b[?25h\u001b[?25l\n✻lithinking\u001b[?25h\u001b[?25l\nthinking\u001b[?25h\u001b[?25l\n✶lp\u001b[?25h\u001b[?25l\n*\u001b[?25h\u001b[?25l\n✢ao8thinking\u001b[?25h\u001b[?25l\nthinking\u001b[?25h\u001b[?25l\n·Glthinking\u001b[?25h\u001b[?25l\nlthinking\u001b[?25h\u001b[?25l\nthinking\u001b[?25h\u001b[?25l\n✢athinking\u001b[?25h\u001b[?25l\nthinking\u001b[?25h\u001b[?25l\n*G\u001b[?25h\u001b[?25l\n✶\u001b[?25h\u001b[?25l\n✻9thinking\u001b[?25h\u001b[?25l\n✽thinking\u001b[?25h\u001b[?25l\nthinking\u001b[?25h\u001b[?25l\nthinking\u001b[?25h\u001b[?25l\nthinking\u001b[?25h\u001b[?25l\n✻thinking\u001b[?25h\u001b[?25l\nthinking\u001b[?25h\u001b[?25l\n✶\u001b[?25h\u001b[?25l\n*\u001b[?25h\u001b[?25l\n✢10s · thinking)\u001b[?25h\u001b[?25l\n·thinking\u001b[?25h\u001b[?25l\nthinking\u001b[?25h\u001b[?25l\nthinking\u001b[?25h\u001b[?25l\n\u001b[?25h\u001b[?25l\nthinking\u001b[?25h\u001b[?25l\n✢thinking\u001b[?25h\u001b[?25l\nthinking\u001b[?25h\u001b[?25l\n*\u001b[?25h\u001b[?25l\n✶\u001b[?25h\u001b[?25l\n✻1thinking\u001b[?25h\u001b[?25l\n✽thinking\u001b[?25h\u001b[?25l\nthinking\u001b[?25h\u001b[?25l\nthinking\u001b[?25h\u001b[?25l\nthinking\u001b[?25h\u001b[?25l\n✻thinking\u001b[?25h\u001b[?25l\n✶still thinking)\u001b[?25h\u001b[?25l\n*\u001b[?25h\u001b[?25l\n✢2still thinking\u001b[?25h\u001b[?25l\n·still thinking\u001b[?25h\u001b[?25l\nstill thinking\u001b[?25h\u001b[?25l\nstill thinking\u001b[?25h\u001b[?25l\nstill thinking\u001b[?25h\u001b[?25l\n✢still thinking\u001b[?25h\u001b[?25l\n*still thinking\u001b[?25h\u001b[?25l\n*Galloping… still thinking\u001b[?25h\u001b[?25l\n✶3\u001b[?25h\u001b[?25l\n✻still thinking\u001b[?25h\u001b[?25l\n✽still thinking\u001b[?25h\u001b[?25l\nstill thinking\u001b[?25h\u001b[?25l\nstill thinking\u001b[?25h\u001b[?25l\nstill thinking\u001b[?25h\u001b[?25l\n✻still thinking\u001b[?25h\u001b[?25l\n✶still thinking\u001b[?25h\u001b[?25l\n*\u001b[?25h\u001b[?25l\n4\u001b[?25h\u001b[?25l\n✢Galloping… still thinking\u001b[?25h\u001b[?25l\n·still thinking\u001b[?25h\u001b[?25l\nstill thinking\u001b[?25h\u001b[?25l\nstill thinking\u001b[?25h\u001b[?25l\nstill thinking\u001b[?25h\u001b[?25l\n✢still thinking\u001b[?25h\u001b[?25l\n*still thinking\u001b[?25h\u001b[?25l\n✶\u001b[?25h\u001b[?25l\n5\u001b[?25h\u001b[?25l\n✻still thinking\u001b[?25h\u001b[?25l\n✽still thinking\u001b[?25h\u001b[?25l\n✽Galloping… still thinking\u001b[?25h\u001b[?25l\nstill thinking\u001b[?25h\u001b[?25l\nstill thinking\u001b[?25h\u001b[?25l\n✻still thinking\u001b[?25h\u001b[?25l\n✶still thinking\u001b[?25h\u001b[?25l\n*\u001b[?25h\u001b[?25l\n*Galloping… ↓ 1.1k tokens · thinking)\u001b[?25h\u001b[?25l\nthought for 14s)\u001b[?25h\u001b[?25l\n✢6\u001b[?25h\u001b[?25l\n●\u001b[?25h\u001b[?25l\nScopepending:provideaplan/pathorexplicitlyrequestbranchdiff\n\u001b[?25h\u001b[?25l\nNoreviewtargetwasnamedandnoplanisactiveinthissession,soIcan'tpickoneforyou.Whenyoureply,tellme\u001b[?25h\u001b[?25l\nwhichofthesetoreview:\u001b[?25h\u001b[?25l\nWhatshouldIreview?\u001b[?25h\u001b[?25l\nA)Thecurrentbranchdiff—theworkinprogressonthisbranch.\u001b[?25h\u001b[?25l\nB)AplanordesigndocI'llpasteorpointyouto.\u001b[?25h\u001b[?25l\nC)Aspecificfile,directory,orpath.\u001b[?25h\u001b[?25l\nRecommendation:Awhenabranchdiffexists,otherwiseB.ReplywithA,B,orC.STOPandwaitfortheanswer.\n✻Brewed for 17s · done 11:31 PM\n❯ \n← for agents\u001b[?25h" + }, + "planModeApiErrorIdle": { + "runId": 40851840940, + "headSha": "6c67e023", + "testName": "seeded forcing finding causes the agent to fire at least one AskUserQuestion", + "recordedPassed": false, + "note": "Last 4000 characters. Plan mode. After the D1 setup reply the provider dropped the response mid-turn; the turn ended at the idle plan-mode prompt and the floor runner waited out its 600000ms budget (outcome=timeout, ledger end observer_timeout).", + "visible": "escing…\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\rCoalescing…\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r*\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r✶\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r✻Coalescing…\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r✽Coalescing…1m 0s· ↓ 3.8k tokens)\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\rCoalescing…\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\rCoalescing…\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\rCoalescing…\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r✻Coalescing…1\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r✶Coalescing…\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r*\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r✢Coalescing…\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\rCoalescing…\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r · thinking)\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r·thinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\rthinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\rthinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\rthinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r✢2thinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r*thinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r…\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r✶g\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r✻thinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r✽nthinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\rthinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\ri…thinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\rthinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r✻cg3thinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r✶thinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r*sn\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\rei\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r✢thinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r·lcthinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\rthinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\rasthinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\rthinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r✢oe4thinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r*Clthinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r✶\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\ra\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r✻thinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r✽othinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\rthinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\rCthinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\rthinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r✻5thinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r✶thinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r*\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r✢\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\rthinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r·thinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\rthinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\rthinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\rthinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r✢6thinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r*thinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r✶\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r✻\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\rthinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r✽thinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\rthinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\rthinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\rthinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r✻7thinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r✶thinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r*\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r✢\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\rthinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r·thinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\rthinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\rthinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r✢thinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r8thinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r*thinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r✶\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r✻…\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\rthinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r✽gthinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\rthinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\rnthinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r✻i…thinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r9\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r✶cgthinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r*\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r✢snthinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r·eithinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\rlcthinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\rthinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r✢asthinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r10s · ↓ 3.8k tokens · thinking)\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r*oethinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r✶\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r✻Clthinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r✽athinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\rothinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\rthinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r✻C1thinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\rthinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r✶\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r*\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r✢thinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\rstill thinking)\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r·still thinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\rstill thinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\rstill thinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r✢2still thinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\rstill thinking\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r●I pulled comparison data via WebSearch since Aside isn'tinstalled,treatingtheresultsasreportedindustryclaims\r rather than hard measurements. I built a table comparing Stripe, typical token-auth RESTAPIs,andthisplan's\ronboardingflow—butsincenonesharethesamestart/endpoints,I'mflaggingthatthiscomparesDXchoices,notactual\r times.\r\r✢ Coalescing… (1m 12s · ↓ 4.7k tokens · thought for 11s)\r ⎿  Tip: Use /btw to ask aquik side questin withot interrupting Claude's current work\r\r\n\r\r\n────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\r\r\n❯  \r\r\n────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\r\r\n⏸planmodeon (shift+tabtocycle)·esctointerrupt·←foragents\r\r\n\r*\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r✶\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r✻\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r✽\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r8\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r✻3\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r✶9\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r*\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r✢\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r●API Error: Connection lost mid-response. The response above may be incomplete.\r\r✻Brewed for 1m 13s · done 9:51 AM\r\r────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\r❯  \r────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\r\r\n⏸planmodeon (shift+tabtocycle)·←foragents\r\r\n" } } \ No newline at end of file diff --git a/test/helpers/claude-pty-runner.runners.unit.test.ts b/test/helpers/claude-pty-runner.runners.unit.test.ts index 71a79c0d0..1649ef29e 100644 --- a/test/helpers/claude-pty-runner.runners.unit.test.ts +++ b/test/helpers/claude-pty-runner.runners.unit.test.ts @@ -209,6 +209,19 @@ describe('runPlanSkillFloorCheck through the fake driver', () => { expect(obs.summary).toBe('no qualifying finding question within 30000ms'); }); + test('the 40851840940 provider error at an idle plan-mode prompt ends the floor check with its panel', async () => { + const plan: string = JSON.parse(fs.readFileSync(new URL('../fixtures/pty-idle-turn-end.json', import.meta.url), 'utf8')).planModeApiErrorIdle.visible; + const { obs } = await floor([{ onInput: '/plan-devex-review PLAN.md\r', screen: plan, effect: delivered }]); + expect(obs.outcome).toBe('api_error'); + expect(obs.auqObserved).toBe(false); + expect(obs.elapsedMs).toBeLessThan(30_000); + expect(obs.summary).toBe('the CLI turn ended at an idle prompt (✻Brewed for 1m 13s · done 9:51 AM) after its public error panel: ' + + '●API Error: Connection lost mid-response. The response above may be incomplete.'); + const running = plan.replace('⏸planmodeon (shift+tabtocycle)·←foragents', '⏸planmodeon (shift+tabtocycle)·esctointerrupt·←foragents'); + const live = await floor([{ onInput: '/plan-devex-review PLAN.md\r', screen: running, effect: delivered }]); + expect(live.obs).toMatchObject({ outcome: 'timeout', summary: 'no qualifying finding question within 30000ms' }); + }); + test('permission prompt: an unowned permission is neither granted nor counted', async () => { const { obs, fake } = await floor([ { onInput: '/plan-devex-review PLAN.md\r', screen: PERMISSION, effect: delivered }, diff --git a/test/helpers/claude-pty-runner.screen.unit.test.ts b/test/helpers/claude-pty-runner.screen.unit.test.ts index 92cd4539b..dc303ff76 100644 --- a/test/helpers/claude-pty-runner.screen.unit.test.ts +++ b/test/helpers/claude-pty-runner.screen.unit.test.ts @@ -13,6 +13,7 @@ import { classifyPlanCountFrame, idleTurnEnd, } from './claude-pty-runner'; +import { screenPanelEnd } from './pty/screen'; describe('saved preference annotation', () => { test('recognizes the explicit preference attribution from the timed-out CEO capture', () => { @@ -252,6 +253,25 @@ describe('idle turn end (captured CI screens)', () => { expect(idleTurnEnd(scope + '\r' + question)).toBeNull(); }); + test('the 40851840940 plan-mode idle footer ends the turn and names its provider error for the ledger', () => { + const plan: string = captures.planModeApiErrorIdle.visible; + const panel = '●API Error: Connection lost mid-response. The response above may be incomplete.'; + expect(idleTurnEnd(plan)).toEqual({ done: '✻Brewed for 1m 13s · done 9:51 AM', apiError: panel }); + expect(screenPanelEnd(plan)).toEqual({ end: 'api_error', evidence: panel }); + expect(idleTurnEnd(plan.replace('⏸planmodeon (shift+tabtocycle)·←foragents', '⏸ plan mode on (shift+tab to cycle) · ← for agents'))) + .toMatchObject({ apiError: panel }); + }); + + test('negative controls: the running plan-mode footer and extra plan-mode footer text are not idle', () => { + const plan: string = captures.planModeApiErrorIdle.visible; + const footer = '⏸planmodeon (shift+tabtocycle)·←foragents'; + expect(plan.split(footer)).toHaveLength(2); + expect(idleTurnEnd(plan.replace(footer, '⏸planmodeon (shift+tabtocycle)·esctointerrupt·←foragents'))).toBeNull(); + expect(idleTurnEnd(plan.replace(footer, footer + ' · 1 background task'))).toBeNull(); + expect(idleTurnEnd(plan.replace(footer, '⏸planmodeon (shift+tabtocycle)'))).toBeNull(); + expect(screenPanelEnd(plan.replace(footer, '⏸planmodeon (shift+tabtocycle)·esctointerrupt·←foragents'))).toBeNull(); + }); + test('an API Error mention in prose or in an earlier turn is not this turn\'s error panel', () => { expect(idleTurnEnd('The docs mention API Error: as an example.\r✻ Worked for 3s · done 1:00 PM\r❯ \r← for agents')).toEqual({ done: '✻ Worked for 3s · done 1:00 PM' }); const earlier = api.replace(/✻ Sautéedfor1m 20s · done11:34PM[\s\S]*$/, '✻ Sautéedfor1m 20s · done11:34PM\r❯ /plan-design-review\r●Done.\r✻ Worked for 2s · done 11:35PM\r❯ \r← for agents'); diff --git a/test/helpers/pty/runners/floor.ts b/test/helpers/pty/runners/floor.ts index 54e214583..3c68f1421 100644 --- a/test/helpers/pty/runners/floor.ts +++ b/test/helpers/pty/runners/floor.ts @@ -18,7 +18,7 @@ import { capturePlanCountQuestion, createPlanCountPermissionGuard, matchesNative import { resolveClaudeBinary } from '../binary'; import { SANCTIONED_WRITE_SUBSTRINGS, isProseAUQVisible, planCountSubmissionInput } from '../classify'; import type { ClaudePtySession } from '../launch'; -import { isNumberedOptionListVisible, isPermissionDialogVisible, isPlanReadyVisible, isRejectedSlashCommand, stripPtyResidue } from '../screen'; +import { idleTurnEnd, isNumberedOptionListVisible, isPermissionDialogVisible, isPlanReadyVisible, isRejectedSlashCommand, stripPtyResidue } from '../screen'; import { realPtyDriver, runPtySession, type PtyDriver, type PtyStep } from '../session'; // ──────────────────────────────────────────────────────────────────────────── @@ -41,7 +41,8 @@ export interface PlanSkillFloorObservation { | 'exited' | 'timeout' | 'assessment_error' - | 'permission_unanswered'; + | 'permission_unanswered' + | 'api_error'; summary: string; /** Public current viewport for an accepted finding; terminal tail otherwise. */ evidence: string; @@ -351,6 +352,10 @@ async function floorTick(run: FloorRun, session: ClaudePtySession): Promise CREDENTIAL_REJECTION.test(line))?.trim(); return idlePanelEnd(visible) ?? (credential ? { end: 'api_error', evidence: credential } : null); @@ -432,8 +432,9 @@ export function screenPanelEnd(visible: string): { end: 'refusal' | 'api_error'; /** The CLI's end-of-turn line, e.g. "✻ Cooked for 24s · done 12:46 AM" (spaces may collapse in the PTY text). */ const TURN_DONE_RE = /✻[ \t]*\p{L}[\p{L}'’-]*[ \t]*for[ \t]*(?=\d)(?:\d+h[ \t]*)?(?:\d+m[ \t]*)?(?:\d+s)?[ \t]*·[ \t]*done[^\n]*/gu; -/** An empty input prompt and its known idle footers, nothing else. */ -const IDLE_PROMPT_RE = /^[\s─]*❯[\s─]*(?:(?:←[ \t]*for[ \t]*agents|\?[ \t]*for[ \t]*shortcuts)[\s─]*)*$/; +/** An empty input prompt and its known idle footers, nothing else. The plan-mode footer is idle only + * without its running "esc to interrupt" segment (run 40851840940, plan-devex-finding-floor). */ +const IDLE_PROMPT_RE = /^[\s─]*❯[\s─]*(?:(?:(?:⏸[ \t]*plan[ \t]*mode[ \t]*on[ \t]*\(shift\+tab[ \t]*to[ \t]*cycle\)[ \t]*·[ \t]*)?←[ \t]*for[ \t]*agents|\?[ \t]*for[ \t]*shortcuts)[\s─]*)*$/; /** * The CLI ended its turn and sits at an empty prompt: the last end-of-turn