diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index 84383cf9..03ac7e1c 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -589,7 +589,7 @@ struct SupacodeApp: App { } let detection = detectedAgent.detectScreen(in: activeText) - let canonicalScreen = AgentScreenSnapshot(canonicalText: agentDetectionRecentText(activeText)) + let canonicalScreen = AgentScreenSnapshot(text: detectedAgent.detectionScreenText(from: activeText)) let blockerText: String? = switch detectedAgent { case .codex: CodexScreenProfile.blockerText(in: canonicalScreen) diff --git a/supacode/Domain/AgentDetection/AgentScreenSnapshot.swift b/supacode/Domain/AgentDetection/AgentScreenSnapshot.swift index 801a1f6d..9c69816a 100644 --- a/supacode/Domain/AgentDetection/AgentScreenSnapshot.swift +++ b/supacode/Domain/AgentDetection/AgentScreenSnapshot.swift @@ -2,12 +2,11 @@ struct AgentScreenSnapshot: Equatable, Sendable { let text: String let lines: [String] - nonisolated init(canonicalText: String) { - assert( - canonicalText == agentDetectionRecentText(canonicalText), - "AgentScreenSnapshot requires canonical detector-tail text." - ) - self.text = canonicalText - self.lines = canonicalText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + /// `text` must be the exact detector input for the agent consuming this + /// snapshot — produce it with `DetectedAgent.detectionScreenText(from:)` so + /// state detection and blocker extraction always read the same screen. + nonisolated init(text: String) { + self.text = text + self.lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) } } diff --git a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift index f5f63eb9..c2e23049 100644 --- a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift +++ b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift @@ -7,8 +7,21 @@ extension DetectedAgent { detectScreen(in: screen).state } + /// The slice of the active screen this agent's detector consumes. + /// + /// Claude reads the full active screen: its rules are all region-anchored + /// (live status from the last rows above the composer, blockers from around + /// the prompt, chrome from the bottom lines), while the shared recent-line + /// tail is measured from the bottom — a long todo list plus a multi-line + /// status line pushes the live spinner row past the limit and a working + /// agent reads as idle. Every other detector matches with whole-text scans, + /// so the bounded tail stays on as their guard against transcript history. + nonisolated func detectionScreenText(from screen: String) -> String { + self == .claude ? screen : agentDetectionRecentText(screen) + } + nonisolated func detectScreen(in screen: String) -> AgentScreenDetection { - let text = agentDetectionRecentText(screen) + let text = detectionScreenText(from: screen) let state: AgentRawState switch self { case .pi: @@ -16,9 +29,9 @@ extension DetectedAgent { case .omp: state = detectOMP(text) case .claude: - return ClaudeScreenProfile.detect(in: AgentScreenSnapshot(canonicalText: text)) + return ClaudeScreenProfile.detect(in: AgentScreenSnapshot(text: text)) case .codex: - return CodexScreenProfile.detect(in: AgentScreenSnapshot(canonicalText: text)) + return CodexScreenProfile.detect(in: AgentScreenSnapshot(text: text)) case .gemini: state = detectGemini(text) case .cursor: diff --git a/supacodeTests/AgentScreenFixtureCorpusTests.swift b/supacodeTests/AgentScreenFixtureCorpusTests.swift index 1d189ac4..81e2c185 100644 --- a/supacodeTests/AgentScreenFixtureCorpusTests.swift +++ b/supacodeTests/AgentScreenFixtureCorpusTests.swift @@ -67,8 +67,8 @@ struct AgentScreenFixtureCorpusTests { #expect(!fixtures.isEmpty, "The captured screen corpus must not be empty.") for fixture in fixtures { #expect( - fixture.text == AgentScreenFixtureCorpus.canonicalTail(fixture.text), - "Fixture is not the canonical 24-non-empty-line detector tail: \(fixture.relativePath)" + fixture.text == fixture.agent.detectionScreenText(from: fixture.text), + "Fixture is not the exact detector input for its agent: \(fixture.relativePath)" ) let actualState = fixture.agent.detectState(in: fixture.text) @@ -148,10 +148,6 @@ enum AgentScreenFixtureCorpus { return fixtures } - static func canonicalTail(_ content: String) -> String { - agentDetectionRecentText(content) - } - private static func loadFixture(at screenURL: URL, root: URL) throws -> AgentScreenFixture { let relativePath = relativePath(for: screenURL, root: root) let components = relativePath.split(separator: "/").map(String.init) diff --git a/supacodeTests/ClaudeScreenProfileTests.swift b/supacodeTests/ClaudeScreenProfileTests.swift index 9382ea5a..46977bd9 100644 --- a/supacodeTests/ClaudeScreenProfileTests.swift +++ b/supacodeTests/ClaudeScreenProfileTests.swift @@ -57,7 +57,7 @@ struct ClaudeScreenProfileTests { @Test func elapsedAndBackgroundWorkHaveDistinctReasons() { let elapsed = ClaudeScreenProfile.detect( in: AgentScreenSnapshot( - canonicalText: """ + text: """ ● Forging… (10s · thinking with high effort) ───────── ❯ @@ -70,7 +70,7 @@ struct ClaudeScreenProfileTests { let background = ClaudeScreenProfile.detect( in: AgentScreenSnapshot( - canonicalText: """ + text: """ Task complete. ───────── ❯ @@ -86,7 +86,7 @@ struct ClaudeScreenProfileTests { @Test func blockerOutranksRetainedSpinner() { let detection = ClaudeScreenProfile.detect( in: AgentScreenSnapshot( - canonicalText: """ + text: """ ✻ Tempering… (12s · esc to interrupt) Do you want to proceed? ❯ 1. Yes @@ -105,7 +105,7 @@ struct ClaudeScreenProfileTests { .first { $0.relativePath == "claude/2.1.223/blocked/command-permission.txt" } let text = try #require(fixture).text - let blocker = ClaudeScreenProfile.blockerText(in: AgentScreenSnapshot(canonicalText: text)) + let blocker = ClaudeScreenProfile.blockerText(in: AgentScreenSnapshot(text: text)) #expect(blocker?.contains("Do you want to proceed?") == true) #expect(blocker?.contains("❯ 1. Yes") == true) @@ -113,9 +113,40 @@ struct ClaudeScreenProfileTests { #expect(blocker?.contains("Esc to cancel · Tab to amend · ctrl+e to explain") == true) } + @Test func spinnerAboveLongTodoListStaysWorking() { + // Regression: the shared recent-line tail is measured from the bottom of the + // screen, so a long todo list plus the composer and a multi-line status line + // pushed the live spinner row out of the detector window and a working agent + // was reported idle (via the idle-composer rule). + var lines = [ + "✻ Implementing hybridTopK… (5m 5s · ↓ 21.1k tokens)", + " ⎿ \u{00A0}✔ Write failing tests for hybridTopK and per-scope pickCandidates", + ] + for index in 2...16 { + lines.append(" ◻ Todo item number \(index) still pending") + } + lines.append( + contentsOf: [ + "──────────────────────────────────────────", + "❯ ", + "──────────────────────────────────────────", + " [Fable 5 | Enterprise] ██░░░░░░░░ 16% | will git:(master*)", + " ███░░░░░░░ 34% (4h 3m / 5h) | ███░░░░░░░ 29% (4d 19h / 7d)", + " ✓ Bash ×7 | ✓ Read ×6 | ✓ Edit ×5", + " ▸ Implement hybridTopK in similarity.ts (2/16)", + " ⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents", + ] + ) + + let detection = DetectedAgent.claude.detectScreen(in: lines.joined(separator: "\n")) + + #expect(detection.state == .working) + #expect(detection.reason == .matched(ClaudeScreenProfile.RuleID.spinner)) + } + @Test func unstructuredScreenUsesExplicitFallback() { let detection = ClaudeScreenProfile.detect( - in: AgentScreenSnapshot(canonicalText: "screen without live Claude chrome") + in: AgentScreenSnapshot(text: "screen without live Claude chrome") ) #expect(detection.state == .idle) diff --git a/supacodeTests/CodexScreenProfileTests.swift b/supacodeTests/CodexScreenProfileTests.swift index 7f09d357..f59a583c 100644 --- a/supacodeTests/CodexScreenProfileTests.swift +++ b/supacodeTests/CodexScreenProfileTests.swift @@ -39,7 +39,7 @@ struct CodexScreenProfileTests { @Test func structuredChoicesExplainBlockedWithoutAFooter() { let detection = CodexScreenProfile.detect( in: AgentScreenSnapshot( - canonicalText: """ + text: """ Would you like to run the following command? › 1. Yes, proceed 2. No, cancel @@ -58,7 +58,7 @@ struct CodexScreenProfileTests { for fixture in fixtures { let blocker = CodexScreenProfile.blockerText( - in: AgentScreenSnapshot(canonicalText: agentDetectionRecentText(fixture.text)) + in: AgentScreenSnapshot(text: agentDetectionRecentText(fixture.text)) ) #expect(blocker != nil) @@ -75,7 +75,7 @@ struct CodexScreenProfileTests { ) let blocker = CodexScreenProfile.blockerText( - in: AgentScreenSnapshot(canonicalText: agentDetectionRecentText(fixture.text)) + in: AgentScreenSnapshot(text: agentDetectionRecentText(fixture.text)) ) #expect(blocker == nil) @@ -84,7 +84,7 @@ struct CodexScreenProfileTests { @Test func blockerTextKeepsQuestionAboveLongWrappedInteraction() { let filler = (1...14).map { " wrapped command detail \($0)" }.joined(separator: "\n") let snapshot = AgentScreenSnapshot( - canonicalText: agentDetectionRecentText( + text: agentDetectionRecentText( """ Historical output that must not be returned. @@ -110,7 +110,7 @@ struct CodexScreenProfileTests { .first { $0.relativePath == "codex/0.146.1/blocked/command-permission.txt" } let text = try #require(fixture).text - let blocker = CodexScreenProfile.blockerText(in: AgentScreenSnapshot(canonicalText: text)) + let blocker = CodexScreenProfile.blockerText(in: AgentScreenSnapshot(text: text)) #expect(blocker?.contains("Would you like to run the following command?") == true) #expect(blocker?.contains("Environment: local") == true)