diff --git a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift index 571cdb14..33c2f55e 100644 --- a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift +++ b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift @@ -16,7 +16,7 @@ extension DetectedAgent { case .omp: state = detectOMP(text) case .claude: - state = detectClaude(text) + return ClaudeScreenProfile.detect(in: AgentScreenSnapshot(canonicalText: text)) case .codex: return CodexScreenProfile.detect(in: AgentScreenSnapshot(canonicalText: text)) case .gemini: @@ -131,25 +131,6 @@ nonisolated private func hasOMPBrailleSpinner(_ line: String) -> Bool { && rest.contains(where: \.isLetter) } -nonisolated private func detectClaude(_ content: String) -> AgentRawState { - if hasClaudeViewerChrome(content) { - return .unknown - } - let currentInteraction = claudeCurrentInteractionRegion(content) - if hasClaudeBlockedPrompt(content: currentInteraction, lower: currentInteraction.lowercased()) { - return .blocked - } - - let liveStatus = agentDetectionRecentLines(contentAbovePromptBox(content), limit: 3) - if hasSpinnerActivity(liveStatus) || hasClaudeElapsedStatusLine(liveStatus) { - return .working - } - if hasClaudeBackgroundWork(content) { - return .working - } - return .idle -} - nonisolated private func detectGemini(_ content: String) -> AgentRawState { let lower = content.lowercased() if lower.contains("waiting for user confirmation") @@ -291,83 +272,6 @@ nonisolated private func detectAmp(_ content: String) -> AgentRawState { return .idle } -// Transcript (ctrl+r/ctrl+o) and history-search views cover the live status -// area, so a frame showing their chrome carries no task-state signal — the -// caller maps `.unknown` to "keep the previous state". The hint strings are -// only trusted on the bottom chrome lines: matching them anywhere on screen -// misreads conversation text that merely quotes them (e.g. a discussion -// about these very heuristics). -nonisolated private func hasClaudeViewerChrome(_ content: String) -> Bool { - let bottomLines = content.split(separator: "\n", omittingEmptySubsequences: false) - .map { $0.trimmingCharacters(in: .whitespaces) } - .filter { !$0.isEmpty } - .suffix(3) - return bottomLines.contains { line in - line.contains("⌕ Search…") || line.lowercased().contains("ctrl+r to toggle") - } -} - -nonisolated private func contentAbovePromptBox(_ content: String) -> String { - let lines = content.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) - guard let promptIndex = lines.lastIndex(where: { $0.contains("❯") }) else { - return content - } - let borderIndex = lines[.. Bool { - let trimmed = line.trimmingCharacters(in: .whitespaces) - guard trimmed.count >= 3 else { return false } - return trimmed.allSatisfy { $0 == "─" || $0 == "-" } -} - -// Everything rendered AFTER the input prompt line — i.e. the footer area where -// Claude shows its persistent background-work / workflow status. Mirrors -// `contentAbovePromptBox` so the background-work check can stay anchored to the -// footer and never trip on transcript text that merely mentions the marker. -nonisolated private func contentBelowPromptBox(_ content: String) -> String { - let lines = content.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) - guard let promptIndex = lines.lastIndex(where: { $0.contains("❯") }) else { - return "" - } - let startIndex = lines.index(after: promptIndex) - guard startIndex < lines.endIndex else { return "" } - return lines[startIndex...].joined(separator: "\n") -} - -// While a background workflow runs, Claude's turn has ended (so the spinner and -// "esc to interrupt" hint above the prompt are gone) but it keeps a persistent -// status line BELOW the input box, e.g. -// "◯ my-workflow 3/5 agents done · 7m 29s · ↓ 288.5k tokens" -// The "/ agents done" segment is Claude's stable statusText -// template, so "agents done" is a distinctive marker. Anchored to the -// below-prompt footer so it can't be tripped by conversation text. -nonisolated private func hasClaudeBackgroundWork(_ content: String) -> Bool { - contentBelowPromptBox(content).lowercased().contains("agents done") -} - -nonisolated private func claudeCurrentInteractionRegion(_ content: String) -> String { - let lines = content.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) - guard let promptIndex = lines.lastIndex(where: { $0.contains("❯") }) else { - return lines.suffix(18).joined(separator: "\n") - } - guard isClaudeNumberedSelectionLine(lines[promptIndex]) else { - return "" - } - - let lowerBound = max(lines.startIndex, promptIndex - 10) - return lines[lowerBound.. Bool { - let trimmed = line.trimmingCharacters(in: .whitespaces) - guard trimmed.first == "❯" else { return false } - let option = trimmed.dropFirst().trimmingCharacters(in: .whitespaces) - return isNumberedChoice(option) -} - nonisolated func isNumberedChoice(_ option: String) -> Bool { guard let firstToken = option.split(whereSeparator: { $0.isWhitespace }).first, firstToken.last == "." @@ -378,44 +282,6 @@ nonisolated func isNumberedChoice(_ option: String) -> Bool { return !number.isEmpty && number.allSatisfy(\.isNumber) } -nonisolated private func hasClaudeBlockedPrompt(content: String, lower: String) -> Bool { - if lower.contains("do you want to proceed?") - || lower.contains("would you like to proceed?") - || lower.contains("waiting for permission") - || lower.contains("do you want to allow this connection?") - || lower.contains("tab to amend") - || lower.contains("ctrl+e to explain") - || lower.contains("chat about this") - || lower.contains("review your answers") - || lower.contains("skip interview and plan immediately") - { - return true - } - return hasConfirmationPrompt(lower) - || (hasClaudeSelectionPrompt(content) && hasClaudeYesNoChoice(content)) -} - -nonisolated private func hasClaudeSelectionPrompt(_ content: String) -> Bool { - content.split(separator: "\n").contains { isClaudeNumberedSelectionLine(String($0)) } -} - -nonisolated private func hasClaudeYesNoChoice(_ content: String) -> Bool { - content.split(separator: "\n").contains { line in - let line = line.trimmingCharacters(in: .whitespaces) - let option = - line.hasPrefix("❯") - ? String(line.dropFirst()).trimmingCharacters(in: .whitespaces) - : line - let trimmed = option.lowercased() - return trimmed == "yes" - || trimmed == "no" - || trimmed.hasPrefix("1. yes") - || trimmed.hasPrefix("2. no") - || trimmed.hasPrefix("yes, and ") - || trimmed.hasPrefix("no, and tell claude") - } -} - nonisolated private func hasCursorPermissionPrompt(content: String, lower: String) -> Bool { if lower.contains("(y) (enter)") { return true @@ -504,30 +370,6 @@ nonisolated func hasSpinnerActivity(_ content: String) -> Bool { } } -nonisolated private func hasClaudeElapsedStatusLine(_ content: String) -> Bool { - content.split(separator: "\n").contains { line in - let trimmed = line.trimmingCharacters(in: .whitespaces) - guard trimmed.first == "●" else { return false } - let body = trimmed.dropFirst().trimmingCharacters(in: .whitespaces) - guard let open = body.firstIndex(of: "(") else { return false } - - let label = body[.. Bool { content.split(separator: "\n").contains { line in let trimmed = line.trimmingCharacters(in: .whitespaces).lowercased() diff --git a/supacodeTests/AgentScreenDetectionTests.swift b/supacodeTests/AgentScreenDetectionTests.swift index 689e6499..e89092cc 100644 --- a/supacodeTests/AgentScreenDetectionTests.swift +++ b/supacodeTests/AgentScreenDetectionTests.swift @@ -7,7 +7,7 @@ struct AgentScreenDetectionTests { @Test func unmigratedDetectorsReturnTheirExistingStateWithAStableReason() { let screen = "screen without a live rule" - for agent in DetectedAgent.allCases where agent != .codex { + for agent in DetectedAgent.allCases where agent != .codex && agent != .claude { let detection = agent.detectScreen(in: screen) #expect(detection.state == agent.detectState(in: screen)) diff --git a/supacodeTests/ClaudeScreenProfileTests.swift b/supacodeTests/ClaudeScreenProfileTests.swift index aa2107fe..5ed61562 100644 --- a/supacodeTests/ClaudeScreenProfileTests.swift +++ b/supacodeTests/ClaudeScreenProfileTests.swift @@ -22,7 +22,7 @@ struct ClaudeScreenProfileTests { "claude/2.1.223/idle/quoted-permission.txt": .matched( ClaudeScreenProfile.RuleID.idleComposer ), - "claude/2.1.223/known-misdetection/unknown/idle/676-history-search-viewer.txt": .matched( + "claude/2.1.223/unknown/676-history-search-viewer.txt": .matched( ClaudeScreenProfile.RuleID.viewer ), "claude/2.1.223/working/backgrounded-subagent.txt": .matched( @@ -39,9 +39,7 @@ struct ClaudeScreenProfileTests { #expect(fixtures.count == expectedReasons.count) for fixture in fixtures { - let detection = ClaudeScreenProfile.detect( - in: AgentScreenSnapshot(canonicalText: fixture.text) - ) + let detection = DetectedAgent.claude.detectScreen(in: fixture.text) #expect(detection.state == fixture.expectedState) #expect(detection.reason == expectedReasons[fixture.relativePath]) } @@ -101,22 +99,4 @@ struct ClaudeScreenProfileTests { #expect(detection.state == .idle) #expect(detection.reason == .noRuleMatched) } - - /// Temporary migration harness. The one quarantined viewer is the only - /// capture-backed intentional difference; all other fixtures stay in parity. - @Test func capturedCorpusMatchesLegacyExceptViewerFix() throws { - for fixture in try AgentScreenFixtureCorpus.load() where fixture.agent == .claude { - let legacy = DetectedAgent.claude.detectState(in: fixture.text) - let profile = ClaudeScreenProfile.detect( - in: AgentScreenSnapshot(canonicalText: fixture.text) - ) - - if fixture.isQuarantined { - #expect(legacy == fixture.currentState) - #expect(profile.state == fixture.expectedState) - } else { - #expect(profile.state == legacy) - } - } - } } diff --git a/supacodeTests/Fixtures/AgentScreenDetection/claude/2.1.223/known-misdetection/unknown/idle/676-history-search-viewer.metadata.json b/supacodeTests/Fixtures/AgentScreenDetection/claude/2.1.223/unknown/676-history-search-viewer.metadata.json similarity index 91% rename from supacodeTests/Fixtures/AgentScreenDetection/claude/2.1.223/known-misdetection/unknown/idle/676-history-search-viewer.metadata.json rename to supacodeTests/Fixtures/AgentScreenDetection/claude/2.1.223/unknown/676-history-search-viewer.metadata.json index 0ff9c7eb..b31054bd 100644 --- a/supacodeTests/Fixtures/AgentScreenDetection/claude/2.1.223/known-misdetection/unknown/idle/676-history-search-viewer.metadata.json +++ b/supacodeTests/Fixtures/AgentScreenDetection/claude/2.1.223/unknown/676-history-search-viewer.metadata.json @@ -15,5 +15,5 @@ "prompt history entries replaced with ", "terminal right-edge padding removed without changing line wrapping" ], - "issue": "https://github.com/onevcat/Prowl/issues/676" + "issue": null } diff --git a/supacodeTests/Fixtures/AgentScreenDetection/claude/2.1.223/known-misdetection/unknown/idle/676-history-search-viewer.txt b/supacodeTests/Fixtures/AgentScreenDetection/claude/2.1.223/unknown/676-history-search-viewer.txt similarity index 100% rename from supacodeTests/Fixtures/AgentScreenDetection/claude/2.1.223/known-misdetection/unknown/idle/676-history-search-viewer.txt rename to supacodeTests/Fixtures/AgentScreenDetection/claude/2.1.223/unknown/676-history-search-viewer.txt diff --git a/supacodeTests/ScreenHeuristicsTests.swift b/supacodeTests/ScreenHeuristicsTests.swift index 61441937..04b283c2 100644 --- a/supacodeTests/ScreenHeuristicsTests.swift +++ b/supacodeTests/ScreenHeuristicsTests.swift @@ -97,7 +97,7 @@ struct ScreenHeuristicsTests { @Test func claudeDetection() { #expect( - claudeLegacyParityState( + claudeProfileState( in: """ Reading file ✽ Tempering… @@ -108,7 +108,7 @@ struct ScreenHeuristicsTests { ) == .working ) #expect( - claudeLegacyParityState( + claudeProfileState( in: """ Do you want to proceed? ❯ 1. Yes @@ -119,7 +119,7 @@ struct ScreenHeuristicsTests { ) == .blocked ) #expect( - claudeLegacyParityState( + claudeProfileState( in: """ Task complete. ───────── @@ -132,7 +132,7 @@ struct ScreenHeuristicsTests { @Test func claudeCurrentTrustAndSubagentScreensRemainClassified() { #expect( - claudeLegacyParityState( + claudeProfileState( in: """ Accessing workspace: @@ -147,7 +147,7 @@ struct ScreenHeuristicsTests { ) == .blocked ) #expect( - claudeLegacyParityState( + claudeProfileState( in: """ ✢ Manifesting… (5s · ↓ 180 tokens · thought for 1s) ───────── @@ -162,7 +162,7 @@ struct ScreenHeuristicsTests { @Test func claudeIgnoresStalePermissionPromptNearCurrentIdlePrompt() { #expect( - claudeLegacyParityState( + claudeProfileState( in: """ Do you want to proceed? ❯ 1. Yes @@ -191,7 +191,7 @@ struct ScreenHeuristicsTests { @Test func claudeShortCompletedResponseQuestionBeforeIdlePromptIsIdle() { #expect( - claudeLegacyParityState( + claudeProfileState( in: """ ⏺ The completed response explains: Do you want to proceed? ───────── @@ -202,7 +202,7 @@ struct ScreenHeuristicsTests { ) == .idle ) #expect( - claudeLegacyParityState( + claudeProfileState( in: """ ❯ Quote the phrase Do you want to proceed? ───────── @@ -213,7 +213,7 @@ struct ScreenHeuristicsTests { @Test func claudeIgnoresStalePermissionPromptOutsideRecentTail() { #expect( - claudeLegacyParityState( + claudeProfileState( in: """ Do you want to proceed? ❯ 1. Yes @@ -254,7 +254,7 @@ struct ScreenHeuristicsTests { @Test func claudeDetectsBlockedWhenFirstOptionSelectedInLongMenu() { #expect( - claudeLegacyParityState( + claudeProfileState( in: """ 需要决策:/release 跳进去发现 APK 没有链时,怎么走接? @@ -282,7 +282,7 @@ struct ScreenHeuristicsTests { @Test func claudeDoesNotTreatHistoryInputAndBranchNameAsPermissionPrompt() { #expect( - claudeLegacyParityState( + claudeProfileState( in: """ ✻ Crunched for 10s @@ -313,7 +313,7 @@ struct ScreenHeuristicsTests { @Test func claudeViewerChromeAtBottomCarriesNoSignal() { #expect( - claudeLegacyParityState( + claudeProfileState( in: """ ✻ Tempering… (12s · esc to interrupt) older transcript content @@ -322,7 +322,7 @@ struct ScreenHeuristicsTests { ) == .unknown ) #expect( - claudeLegacyParityState( + claudeProfileState( in: """ Task complete. ⌕ Search… @@ -336,7 +336,7 @@ struct ScreenHeuristicsTests { // Regression: a chat message quoting "ctrl+r to toggle" used to force // idle while the spinner below showed Claude still working. #expect( - claudeLegacyParityState( + claudeProfileState( in: """ ⏺ 收尾完成,现状如下: @@ -363,7 +363,7 @@ struct ScreenHeuristicsTests { ] for statusRow in statusRows { #expect( - claudeLegacyParityState( + claudeProfileState( in: """ \(statusRow) ───────── @@ -377,7 +377,7 @@ struct ScreenHeuristicsTests { @Test func claudeQuotedInterruptHintInIdleResponseIsIdle() { #expect( - claudeLegacyParityState( + claudeProfileState( in: """ ⏺ The live status row includes the phrase "esc to interrupt". ───────── @@ -391,7 +391,7 @@ struct ScreenHeuristicsTests { @Test func claudeElapsedStatusLineIsScopedAndRequiresACompleteToken() { #expect( - claudeLegacyParityState( + claudeProfileState( in: """ ● Forging… (10s · thinking with high effort) ───────── @@ -407,7 +407,7 @@ struct ScreenHeuristicsTests { ] for statusRow in invalidRows { #expect( - claudeLegacyParityState( + claudeProfileState( in: """ \(statusRow) ───────── @@ -419,7 +419,7 @@ struct ScreenHeuristicsTests { } #expect( - claudeLegacyParityState( + claudeProfileState( in: """ ● Retrying… (10s · thinking with high effort) Completed line 1 @@ -438,7 +438,7 @@ struct ScreenHeuristicsTests { // interrupt" above the prompt), but Claude keeps a status line BELOW the // input box. The "/ agents done" segment marks active work. #expect( - claudeLegacyParityState( + claudeProfileState( in: """ ⏺ Kicked off the scout workflow in the background. ───────── @@ -450,7 +450,7 @@ struct ScreenHeuristicsTests { ) // Idle with an ordinary footer (no workflow line) stays idle. #expect( - claudeLegacyParityState( + claudeProfileState( in: """ Task complete. ───────── @@ -463,7 +463,7 @@ struct ScreenHeuristicsTests { // The marker quoted in conversation (above the prompt) must NOT force // working — the check is anchored to the below-prompt footer. #expect( - claudeLegacyParityState( + claudeProfileState( in: """ ⏺ The run showed 3/5 agents done before it wrapped up. ───────── @@ -475,13 +475,10 @@ struct ScreenHeuristicsTests { ) } - private func claudeLegacyParityState(in screen: String) -> AgentRawState { - let legacy = DetectedAgent.claude.detectState(in: screen) - let profile = ClaudeScreenProfile.detect( - in: AgentScreenSnapshot(canonicalText: agentDetectionRecentText(screen)) - ) - #expect(profile.state == legacy) - return legacy + private func claudeProfileState(in screen: String) -> AgentRawState { + let detection = DetectedAgent.claude.detectScreen(in: screen) + #expect(detection.reason != .legacyDetector) + return detection.state } @Test func codexDetection() {