From 874513264eb8f7b07eba3e2cd73d3a0ac51dabcd Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 7 Aug 2026 04:20:30 +0900 Subject: [PATCH] Add Claude screen profile parity harness --- .../AgentDetection/AgentScreenSnapshot.swift | 4 + .../AgentDetection/ClaudeScreenProfile.swift | 216 ++++++++++++++++++ .../AgentDetection/CodexScreenProfile.swift | 1 + .../AgentDetection/ScreenHeuristics.swift | 4 +- supacodeTests/ClaudeScreenProfileTests.swift | 122 ++++++++++ supacodeTests/ScreenHeuristicsTests.swift | 53 +++-- 6 files changed, 376 insertions(+), 24 deletions(-) create mode 100644 supacode/Infrastructure/AgentDetection/ClaudeScreenProfile.swift create mode 100644 supacodeTests/ClaudeScreenProfileTests.swift diff --git a/supacode/Domain/AgentDetection/AgentScreenSnapshot.swift b/supacode/Domain/AgentDetection/AgentScreenSnapshot.swift index 7b764169..801a1f6d 100644 --- a/supacode/Domain/AgentDetection/AgentScreenSnapshot.swift +++ b/supacode/Domain/AgentDetection/AgentScreenSnapshot.swift @@ -3,6 +3,10 @@ struct AgentScreenSnapshot: Equatable, Sendable { 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) } diff --git a/supacode/Infrastructure/AgentDetection/ClaudeScreenProfile.swift b/supacode/Infrastructure/AgentDetection/ClaudeScreenProfile.swift new file mode 100644 index 00000000..dc398265 --- /dev/null +++ b/supacode/Infrastructure/AgentDetection/ClaudeScreenProfile.swift @@ -0,0 +1,216 @@ +import Foundation + +enum ClaudeScreenProfile { + enum RuleID { + nonisolated static let viewer = AgentScreenRuleID("claude.viewer") + nonisolated static let blockedPrompt = AgentScreenRuleID("claude.blockedPrompt") + nonisolated static let spinner = AgentScreenRuleID("claude.spinner") + nonisolated static let elapsedStatus = AgentScreenRuleID("claude.elapsedStatus") + nonisolated static let backgroundWork = AgentScreenRuleID("claude.backgroundWork") + nonisolated static let idleComposer = AgentScreenRuleID("claude.idleComposer") + + // Keep exhaustive so prefix and uniqueness tests cover every emitted ID. + nonisolated static let all = [ + viewer, + blockedPrompt, + spinner, + elapsedStatus, + backgroundWork, + idleComposer, + ] + } + + nonisolated static func detect(in snapshot: AgentScreenSnapshot) -> AgentScreenDetection { + let regions = ClaudeScreenRegions(snapshot: snapshot) + + if hasViewerChrome(regions) { + return AgentScreenDetection(state: .unknown, reason: .matched(RuleID.viewer)) + } + if hasBlockedPrompt(regions) { + return AgentScreenDetection(state: .blocked, reason: .matched(RuleID.blockedPrompt)) + } + if hasSpinnerActivity(regions.liveStatus) { + return AgentScreenDetection(state: .working, reason: .matched(RuleID.spinner)) + } + if hasElapsedStatusLine(regions.liveStatus) { + return AgentScreenDetection(state: .working, reason: .matched(RuleID.elapsedStatus)) + } + if regions.belowPromptLower.contains("agents done") { + return AgentScreenDetection(state: .working, reason: .matched(RuleID.backgroundWork)) + } + if regions.hasIdleComposer { + return AgentScreenDetection(state: .idle, reason: .matched(RuleID.idleComposer)) + } + return AgentScreenDetection(state: .idle, reason: .noRuleMatched) + } + + nonisolated private static func hasViewerChrome(_ regions: ClaudeScreenRegions) -> Bool { + if regions.bottomChromeLines.contains(where: { line in + line.contains("⌕ Search…") || line.lowercased().contains("ctrl+r to toggle") + }) { + return true + } + + let lower = regions.bottomViewerLines.joined(separator: "\n").lowercased() + return lower.contains("⌕ filter history") + && lower.contains("↑/↓ to nav") + && lower.contains("enter to use") + && lower.contains("esc to cancel") + && lower.contains("ctrl+s to scope") + } + + nonisolated private static func hasBlockedPrompt(_ regions: ClaudeScreenRegions) -> Bool { + let lower = regions.currentInteractionLower + 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) + || (hasSelectionPrompt(regions.currentInteractionLines) + && hasYesNoChoice(regions.currentInteractionLines)) + } + + nonisolated private static func hasSelectionPrompt(_ lines: [String]) -> Bool { + lines.contains(where: isClaudeNumberedSelectionLine) + } + + nonisolated private static func hasYesNoChoice(_ lines: [String]) -> Bool { + lines.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 static func hasElapsedStatusLine(_ 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[.. [String] { + guard let promptIndex else { + return Array(screenLines.suffix(18)) + } + guard isClaudeNumberedSelectionLine(screenLines[promptIndex]) else { + return [] + } + let lowerBound = max(screenLines.startIndex, promptIndex - 10) + return Array(screenLines[lowerBound.. String { + guard let promptIndex else { + return screenLines.joined(separator: "\n") + } + let borderIndex = screenLines[.. String { + guard let promptIndex else { return "" } + let startIndex = screenLines.index(after: promptIndex) + guard startIndex < screenLines.endIndex else { return "" } + return screenLines[startIndex...].joined(separator: "\n") + } + + nonisolated private static func hasIdleComposer( + screenLines: [String], + promptIndex: Int? + ) -> Bool { + guard let promptIndex, promptIndex > screenLines.startIndex else { return false } + let nextIndex = screenLines.index(after: promptIndex) + guard nextIndex < screenLines.endIndex else { return false } + return isBoxBorderLine(screenLines[screenLines.index(before: promptIndex)]) + && isBoxBorderLine(screenLines[nextIndex]) + } +} + +nonisolated private func isClaudeNumberedSelectionLine(_ line: String) -> Bool { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard trimmed.first == "❯" else { return false } + let option = trimmed.dropFirst().trimmingCharacters(in: .whitespaces) + return isNumberedChoice(option) +} + +nonisolated private func isBoxBorderLine(_ line: String) -> Bool { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard trimmed.count >= 3 else { return false } + return trimmed.allSatisfy { $0 == "─" || $0 == "-" } +} diff --git a/supacode/Infrastructure/AgentDetection/CodexScreenProfile.swift b/supacode/Infrastructure/AgentDetection/CodexScreenProfile.swift index 894afa52..4d4d7ea0 100644 --- a/supacode/Infrastructure/AgentDetection/CodexScreenProfile.swift +++ b/supacode/Infrastructure/AgentDetection/CodexScreenProfile.swift @@ -9,6 +9,7 @@ enum CodexScreenProfile { nonisolated static let confirmationChoices = AgentScreenRuleID("codex.confirmationChoices") nonisolated static let workingFooter = AgentScreenRuleID("codex.workingFooter") + // Keep exhaustive so prefix and uniqueness tests cover every emitted ID. nonisolated static let all = [ directoryTrust, hookReview, diff --git a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift index c294008a..571cdb14 100644 --- a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift +++ b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift @@ -471,7 +471,7 @@ nonisolated private func hasKimiToolSpinner(content: String, lower: String) -> B } } -nonisolated private func hasConfirmationPrompt(_ lower: String) -> Bool { +nonisolated func hasConfirmationPrompt(_ lower: String) -> Bool { guard let range = lower.range(of: "do you want") ?? lower.range(of: "would you like") else { @@ -487,7 +487,7 @@ nonisolated private func hasInterruptPattern(_ lower: String) -> Bool { || (lower.contains("esc") && lower.contains("interrupt")) } -nonisolated private func hasSpinnerActivity(_ content: String) -> Bool { +nonisolated func hasSpinnerActivity(_ content: String) -> Bool { let spinnerScalars: Set = [ "·", "✱", "✲", "✳", "✴", "✵", "✶", "✷", "✸", "✹", "✺", "✻", "✼", "✽", "✾", "✿", "❀", "❁", "❂", "❃", "❇", "❈", "❉", "❊", "❋", "✢", "✣", "✤", "✥", "✦", "✧", "✨", diff --git a/supacodeTests/ClaudeScreenProfileTests.swift b/supacodeTests/ClaudeScreenProfileTests.swift new file mode 100644 index 00000000..aa2107fe --- /dev/null +++ b/supacodeTests/ClaudeScreenProfileTests.swift @@ -0,0 +1,122 @@ +import Testing + +@testable import supacode + +struct ClaudeScreenProfileTests { + @Test func ruleIDsAreUniqueAndRuntimePrefixed() { + let ruleIDs = ClaudeScreenProfile.RuleID.all + + #expect(Set(ruleIDs).count == ruleIDs.count) + #expect(ruleIDs.allSatisfy { $0.rawValue.hasPrefix("claude.") }) + } + + @Test func capturedFixturesHaveStableReasonsIncludingViewerFix() throws { + let expectedReasons: [String: AgentScreenDetectionReason] = [ + "claude/2.1.223/blocked/command-permission.txt": .matched( + ClaudeScreenProfile.RuleID.blockedPrompt + ), + "claude/2.1.223/blocked/workspace-trust.txt": .matched( + ClaudeScreenProfile.RuleID.blockedPrompt + ), + "claude/2.1.223/idle/composer.txt": .matched(ClaudeScreenProfile.RuleID.idleComposer), + "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( + ClaudeScreenProfile.RuleID.viewer + ), + "claude/2.1.223/working/backgrounded-subagent.txt": .matched( + ClaudeScreenProfile.RuleID.spinner + ), + "claude/2.1.223/working/foreground-spinner.txt": .matched( + ClaudeScreenProfile.RuleID.spinner + ), + "claude/2.1.223/working/subagent-active.txt": .matched( + ClaudeScreenProfile.RuleID.spinner + ), + ] + let fixtures = try AgentScreenFixtureCorpus.load().filter { $0.agent == .claude } + + #expect(fixtures.count == expectedReasons.count) + for fixture in fixtures { + let detection = ClaudeScreenProfile.detect( + in: AgentScreenSnapshot(canonicalText: fixture.text) + ) + #expect(detection.state == fixture.expectedState) + #expect(detection.reason == expectedReasons[fixture.relativePath]) + } + } + + @Test func elapsedAndBackgroundWorkHaveDistinctReasons() { + let elapsed = ClaudeScreenProfile.detect( + in: AgentScreenSnapshot( + canonicalText: """ + ● Forging… (10s · thinking with high effort) + ───────── + ❯ + ───────── + """ + ) + ) + #expect(elapsed.state == .working) + #expect(elapsed.reason == .matched(ClaudeScreenProfile.RuleID.elapsedStatus)) + + let background = ClaudeScreenProfile.detect( + in: AgentScreenSnapshot( + canonicalText: """ + Task complete. + ───────── + ❯ + ───────── + ◯ scout Map idle detection 3/5 agents done · 7m 29s + """ + ) + ) + #expect(background.state == .working) + #expect(background.reason == .matched(ClaudeScreenProfile.RuleID.backgroundWork)) + } + + @Test func blockerOutranksRetainedSpinner() { + let detection = ClaudeScreenProfile.detect( + in: AgentScreenSnapshot( + canonicalText: """ + ✻ Tempering… (12s · esc to interrupt) + Do you want to proceed? + ❯ 1. Yes + 2. No + Esc to cancel · Tab to amend + """ + ) + ) + + #expect(detection.state == .blocked) + #expect(detection.reason == .matched(ClaudeScreenProfile.RuleID.blockedPrompt)) + } + + @Test func unstructuredScreenUsesExplicitFallback() { + let detection = ClaudeScreenProfile.detect( + in: AgentScreenSnapshot(canonicalText: "screen without live Claude chrome") + ) + + #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/ScreenHeuristicsTests.swift b/supacodeTests/ScreenHeuristicsTests.swift index 12707bbd..61441937 100644 --- a/supacodeTests/ScreenHeuristicsTests.swift +++ b/supacodeTests/ScreenHeuristicsTests.swift @@ -97,7 +97,7 @@ struct ScreenHeuristicsTests { @Test func claudeDetection() { #expect( - DetectedAgent.claude.detectState( + claudeLegacyParityState( in: """ Reading file ✽ Tempering… @@ -108,7 +108,7 @@ struct ScreenHeuristicsTests { ) == .working ) #expect( - DetectedAgent.claude.detectState( + claudeLegacyParityState( in: """ Do you want to proceed? ❯ 1. Yes @@ -119,7 +119,7 @@ struct ScreenHeuristicsTests { ) == .blocked ) #expect( - DetectedAgent.claude.detectState( + claudeLegacyParityState( in: """ Task complete. ───────── @@ -132,7 +132,7 @@ struct ScreenHeuristicsTests { @Test func claudeCurrentTrustAndSubagentScreensRemainClassified() { #expect( - DetectedAgent.claude.detectState( + claudeLegacyParityState( in: """ Accessing workspace: @@ -147,7 +147,7 @@ struct ScreenHeuristicsTests { ) == .blocked ) #expect( - DetectedAgent.claude.detectState( + claudeLegacyParityState( in: """ ✢ Manifesting… (5s · ↓ 180 tokens · thought for 1s) ───────── @@ -162,7 +162,7 @@ struct ScreenHeuristicsTests { @Test func claudeIgnoresStalePermissionPromptNearCurrentIdlePrompt() { #expect( - DetectedAgent.claude.detectState( + claudeLegacyParityState( in: """ Do you want to proceed? ❯ 1. Yes @@ -191,7 +191,7 @@ struct ScreenHeuristicsTests { @Test func claudeShortCompletedResponseQuestionBeforeIdlePromptIsIdle() { #expect( - DetectedAgent.claude.detectState( + claudeLegacyParityState( in: """ ⏺ The completed response explains: Do you want to proceed? ───────── @@ -202,7 +202,7 @@ struct ScreenHeuristicsTests { ) == .idle ) #expect( - DetectedAgent.claude.detectState( + claudeLegacyParityState( in: """ ❯ Quote the phrase Do you want to proceed? ───────── @@ -213,7 +213,7 @@ struct ScreenHeuristicsTests { @Test func claudeIgnoresStalePermissionPromptOutsideRecentTail() { #expect( - DetectedAgent.claude.detectState( + claudeLegacyParityState( in: """ Do you want to proceed? ❯ 1. Yes @@ -254,7 +254,7 @@ struct ScreenHeuristicsTests { @Test func claudeDetectsBlockedWhenFirstOptionSelectedInLongMenu() { #expect( - DetectedAgent.claude.detectState( + claudeLegacyParityState( in: """ 需要决策:/release 跳进去发现 APK 没有链时,怎么走接? @@ -282,7 +282,7 @@ struct ScreenHeuristicsTests { @Test func claudeDoesNotTreatHistoryInputAndBranchNameAsPermissionPrompt() { #expect( - DetectedAgent.claude.detectState( + claudeLegacyParityState( in: """ ✻ Crunched for 10s @@ -313,7 +313,7 @@ struct ScreenHeuristicsTests { @Test func claudeViewerChromeAtBottomCarriesNoSignal() { #expect( - DetectedAgent.claude.detectState( + claudeLegacyParityState( in: """ ✻ Tempering… (12s · esc to interrupt) older transcript content @@ -322,7 +322,7 @@ struct ScreenHeuristicsTests { ) == .unknown ) #expect( - DetectedAgent.claude.detectState( + claudeLegacyParityState( 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( - DetectedAgent.claude.detectState( + claudeLegacyParityState( in: """ ⏺ 收尾完成,现状如下: @@ -363,7 +363,7 @@ struct ScreenHeuristicsTests { ] for statusRow in statusRows { #expect( - DetectedAgent.claude.detectState( + claudeLegacyParityState( in: """ \(statusRow) ───────── @@ -377,7 +377,7 @@ struct ScreenHeuristicsTests { @Test func claudeQuotedInterruptHintInIdleResponseIsIdle() { #expect( - DetectedAgent.claude.detectState( + claudeLegacyParityState( in: """ ⏺ The live status row includes the phrase "esc to interrupt". ───────── @@ -391,7 +391,7 @@ struct ScreenHeuristicsTests { @Test func claudeElapsedStatusLineIsScopedAndRequiresACompleteToken() { #expect( - DetectedAgent.claude.detectState( + claudeLegacyParityState( in: """ ● Forging… (10s · thinking with high effort) ───────── @@ -407,7 +407,7 @@ struct ScreenHeuristicsTests { ] for statusRow in invalidRows { #expect( - DetectedAgent.claude.detectState( + claudeLegacyParityState( in: """ \(statusRow) ───────── @@ -419,7 +419,7 @@ struct ScreenHeuristicsTests { } #expect( - DetectedAgent.claude.detectState( + claudeLegacyParityState( 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( - DetectedAgent.claude.detectState( + claudeLegacyParityState( 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( - DetectedAgent.claude.detectState( + claudeLegacyParityState( 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( - DetectedAgent.claude.detectState( + claudeLegacyParityState( in: """ ⏺ The run showed 3/5 agents done before it wrapped up. ───────── @@ -475,6 +475,15 @@ 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 + } + @Test func codexDetection() { #expect( codexProfileState( -- 2.51.2