native macOS codings agent orchestrator prowl.onev.cat
Something went wrong. Try again.
Swift
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268import Testing
@testable import Prowl
/// Reconstructed screens for the background-agent lifecycle.////// The captured evidence lives in the fixture corpus; these are the synthetic/// boundary cases around it, kept inline per the corpus README.struct ClaudeBackgroundAgentDetectionTests { private func detect(_ screen: String) -> AgentScreenDetection { DetectedAgent.claude.detectScreen(in: screen) }
@Test func waitRowReportsWorkingWithoutASpinnerEllipsis() { // Claude Code 2.1.224. The turn has ended, so the only live marker is the wait // row. It carries a spinner glyph but no "…", which hasSpinnerActivity requires. let detection = detect( """ ⏺ Subagent launched in the background, running sleep 300.
✻ Waiting for 1 background agent to finish ───────── ❯ ───────── ⏵⏵ auto mode on · 1 shell
⏺ main ◯ general-purpose Sleep 300 then reply 1m 5s · ↓ 21.4k tokens """ )
#expect(detection.state == .working) #expect(detection.reason == .matched(ClaudeScreenProfile.RuleID.backgroundWork)) }
@Test func retainedAgentRowAloneDoesNotReportWorking() { // Claude Code 2.1.224, observed live. A subagent that returned control but is // still awaiting collection keeps its switcher row, with the elapsed value // frozen at the moment it stopped. Nothing is running, so the pane must not // report working — matching the "◯" row shape alone would pin it there. let detection = detect( """ ⏺ Agent "Sleep 300 then reply" finished · 1m 54s
Ran 3 shell commands
✻ Churned for 2m 49s · 2 shells still running ───────── ❯ ───────── ⏵⏵ auto mode on · 2 shells
⏺ main ◯ general-purpose Sleep 300 then reply 1m 54s · ↓ 23.0k tokens """ )
#expect(detection.state == .idle) }
@Test func waitRowQuotedEarlierInTheTranscriptDoesNotReportWorking() { // The rule reads the live status region, which is the last three non-blank // lines above the prompt box. A wait row quoted further back in the // transcript falls outside that window and stays idle. // // A quote inside the window is indistinguishable from live chrome on a single // screen and does report working; the same limit applies to every rule scoped // this way, including the pre-existing spinner rule. let detection = detect( """ ⏺ Earlier the screen showed:
✻ Waiting for 1 background agent to finish
⏺ That row is the one the detector keys on. ⏺ It is quoted here, four lines above the box. ⏺ Nothing is running now. ───────── ❯ ───────── ⏵⏵ auto mode on (shift+tab to cycle) """ )
#expect(detection.state == .idle) }
@Test func workflowFooterVariantStillReportsWorking() { // The pre-existing "agents done" workflow row keeps its behaviour and reason. let detection = detect( """ Task complete. ───────── ❯ ───────── ◯ scout Map idle detection 3/5 agents done · 7m 29s """ )
#expect(detection.state == .working) #expect(detection.reason == .matched(ClaudeScreenProfile.RuleID.backgroundWork)) }
@Test func elapsedStatusAcceptsMultiWordLabelsAndCompoundElapsed() { let working = [ "● Running gates and merge lifecycle… (45s · ↓ 13.2k tokens)", "● Actioning… (28m 34s · ↓ 47.7k tokens)", "● Actioning… (1h 4m 2s · ↓ 47.7k tokens)", "● Perusing… (3s · ↓ 77 tokens)", ] for row in working { let detection = detect( """ \(row) ───────── ❯ ───────── """ ) #expect(detection.state == .working, "expected working for: \(row)") #expect(detection.reason == .matched(ClaudeScreenProfile.RuleID.elapsedStatus)) } }
@Test func wrappedRowsAreReadAsOneLogicalRow() { // Claude Code 2.1.225 at 40 columns. Both shapes were observed on a real // narrow pane; the continuation is indented two spaces. let wrappedWait = detect( """ ⏺ The subagent is running; waiting for its reply.
✻ Waiting for 1 background agent to finish ──────── ❯ ──────── """ ) #expect(wrappedWait.state == .working) #expect(wrappedWait.reason == .matched(ClaudeScreenProfile.RuleID.backgroundWork))
let wrappedElapsed = detect( """ ● Running gates and merge lifecycle… (1m 38s · ↓ 187 tokens) ──────── ❯ ──────── """ ) #expect(wrappedElapsed.state == .working) #expect(wrappedElapsed.reason == .matched(ClaudeScreenProfile.RuleID.elapsedStatus)) }
@Test func rowWrappedPastTheRegionLimitKeepsItsHead() { // The live status region is the last three rows above the box. A row narrow // enough to wrap onto four or more physical lines pushes its own head out of // a window counted in physical lines, and the head carries the glyph every // rule keys on. The region is therefore reconstructed into logical rows // before the limit applies. let wrappedWait = detect( """ ⏺ Launched it.
✻ Waiting for 1 background agent to finish ──── ❯ ──── """ ) #expect(wrappedWait.state == .working) #expect(wrappedWait.reason == .matched(ClaudeScreenProfile.RuleID.backgroundWork))
let wrappedElapsed = detect( """ ⏺ Started.
● Running gates… (1m 38s · ↓ 187 tokens) ──── ❯ ──── """ ) #expect(wrappedElapsed.state == .working) #expect(wrappedElapsed.reason == .matched(ClaudeScreenProfile.RuleID.elapsedStatus)) }
@Test func continuationJoiningDoesNotInventRows() { // A following line that carries its own marker starts a new row rather than // continuing the previous one, so nothing is stitched into a status row that // was never on screen. let detection = detect( """ ⏺ Wrote the report. ⎿ Waiting for 1 background agent to finish is what it said earlier ⏺ Nothing is running now. ──────── ❯ ──────── """ )
#expect(detection.state == .idle) }
@Test func labelContainingParenthesesStillParses() { // The label is free text, so the elapsed segment is located from the end of // the row. Anchoring at the first "(" reads "focused)… (1m 38s" as elapsed. let detection = detect( """ ● Running tests (focused)… (1m 38s · ↓ 187 tokens) ──────── ❯ ──────── """ )
#expect(detection.state == .working) #expect(detection.reason == .matched(ClaudeScreenProfile.RuleID.elapsedStatus)) }
@Test func narrowPaneDropsDetailSegmentsAndStillParses() { // At 32 columns Claude shortens the row rather than wrapping it, leaving the // elapsed terminated by the closing paren with no " · " detail at all. let detection = detect( """ ● Razzle-dazzling… (1m 7s) ──────── ❯ ──────── """ )
#expect(detection.state == .working) #expect(detection.reason == .matched(ClaudeScreenProfile.RuleID.elapsedStatus)) }
@Test func incompleteElapsedSegmentsStayIdle() { let notWorking = [ "● Retrying the merge lifecycle… (1st attempt)", "● Actioning… (10seconds · ↓ 47.7k tokens)", "● Actioning… (28m 34 · ↓ 47.7k tokens)", // Incomplete elapsed carried across a wrapped row stays incomplete. "● Running gates and merge lifecycle…\n (1st attempt)", "● Running tests (focused)…\n (10seconds · ↓ 187 tokens)", ] for row in notWorking { let detection = detect( """ \(row) ───────── ❯ ───────── """ ) #expect(detection.state == .idle, "expected idle for: \(row)") } }}