diff --git a/supacode/Domain/AgentDetection/AgentScreenDetection.swift b/supacode/Domain/AgentDetection/AgentScreenDetection.swift index ef97e2ca..f8f8d104 100644 --- a/supacode/Domain/AgentDetection/AgentScreenDetection.swift +++ b/supacode/Domain/AgentDetection/AgentScreenDetection.swift @@ -1,7 +1,7 @@ struct AgentScreenRuleID: Equatable, Hashable, Sendable { let rawValue: String - init(_ rawValue: String) { + nonisolated init(_ rawValue: String) { precondition(!rawValue.isEmpty, "Agent screen rule IDs must not be empty.") self.rawValue = rawValue } diff --git a/supacode/Domain/AgentDetection/AgentScreenSnapshot.swift b/supacode/Domain/AgentDetection/AgentScreenSnapshot.swift new file mode 100644 index 00000000..7b764169 --- /dev/null +++ b/supacode/Domain/AgentDetection/AgentScreenSnapshot.swift @@ -0,0 +1,9 @@ +struct AgentScreenSnapshot: Equatable, Sendable { + let text: String + let lines: [String] + + nonisolated init(canonicalText: String) { + self.text = canonicalText + self.lines = canonicalText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + } +} diff --git a/supacode/Infrastructure/AgentDetection/CodexScreenProfile.swift b/supacode/Infrastructure/AgentDetection/CodexScreenProfile.swift new file mode 100644 index 00000000..894afa52 --- /dev/null +++ b/supacode/Infrastructure/AgentDetection/CodexScreenProfile.swift @@ -0,0 +1,210 @@ +import Foundation + +enum CodexScreenProfile { + enum RuleID { + nonisolated static let directoryTrust = AgentScreenRuleID("codex.directoryTrust") + nonisolated static let hookReview = AgentScreenRuleID("codex.hookReview") + nonisolated static let signIn = AgentScreenRuleID("codex.signIn") + nonisolated static let confirmationFooter = AgentScreenRuleID("codex.confirmationFooter") + nonisolated static let confirmationChoices = AgentScreenRuleID("codex.confirmationChoices") + nonisolated static let workingFooter = AgentScreenRuleID("codex.workingFooter") + + nonisolated static let all = [ + directoryTrust, + hookReview, + signIn, + confirmationFooter, + confirmationChoices, + workingFooter, + ] + } + + nonisolated static func detect(in snapshot: AgentScreenSnapshot) -> AgentScreenDetection { + let regions = CodexScreenRegions(snapshot: snapshot) + + if hasDirectoryTrustPrompt(regions) { + return AgentScreenDetection(state: .blocked, reason: .matched(RuleID.directoryTrust)) + } + if hasHookReviewPrompt(regions) { + return AgentScreenDetection(state: .blocked, reason: .matched(RuleID.hookReview)) + } + if hasSignInPrompt(regions) { + return AgentScreenDetection(state: .blocked, reason: .matched(RuleID.signIn)) + } + if hasConfirmationFooter(regions) { + return AgentScreenDetection(state: .blocked, reason: .matched(RuleID.confirmationFooter)) + } + if hasConfirmationChoices(regions) { + return AgentScreenDetection(state: .blocked, reason: .matched(RuleID.confirmationChoices)) + } + if hasWorkingFooter(regions) { + return AgentScreenDetection(state: .working, reason: .matched(RuleID.workingFooter)) + } + return AgentScreenDetection(state: .idle, reason: .noRuleMatched) + } + + nonisolated private static func hasDirectoryTrustPrompt(_ regions: CodexScreenRegions) -> Bool { + hasSelectedChoice( + regions, + matchingAnyOf: ["1. yes, continue", "2. no, quit"], + withBefore: ["do you trust the contents of this directory?"], + andAround: ["1. yes, continue", "2. no, quit"], + andAfter: ["press enter to continue"] + ) + } + + nonisolated private static func hasHookReviewPrompt(_ regions: CodexScreenRegions) -> Bool { + hasSelectedChoice( + regions, + matchingAnyOf: [ + "1. review hooks", + "2. trust all and continue", + "3. continue without trusting (hooks won't run)", + ], + withBefore: ["hooks need review"], + andAround: ["1. review hooks", "2. trust all and continue", "3. continue without trusting"], + andAfter: ["press enter to confirm or esc to go back"] + ) + } + + nonisolated private static func hasSelectedChoice( + _ regions: CodexScreenRegions, + matchingAnyOf options: Set, + withBefore beforeNeedles: [String], + andAround aroundNeedles: [String], + andAfter afterNeedles: [String] + ) -> Bool { + guard let selection = regions.selectedChoice, options.contains(selection.choice) else { + return false + } + return beforeNeedles.allSatisfy(selection.beforeLower.contains) + && aroundNeedles.allSatisfy(selection.aroundLower.contains) + && afterNeedles.allSatisfy(selection.afterLower.contains) + } + + nonisolated private static func hasSignInPrompt(_ regions: CodexScreenRegions) -> Bool { + guard + let selected = regions.signInMenuLines.last(where: { line in + let trimmed = line.trimmingCharacters(in: .whitespaces) + return trimmed.first == "›" || trimmed.first == ">" + }) + else { + return false + } + let choice = selected.trimmingCharacters(in: .whitespaces) + .dropFirst() + .trimmingCharacters(in: .whitespaces) + .lowercased() + guard + choice.hasPrefix("1. sign in with chatgpt") + || choice.hasPrefix("2. sign in with device code") + || choice.hasPrefix("3. provide your own api key") + else { + return false + } + let lower = regions.signInMenuLines.joined(separator: "\n").lowercased() + return lower.contains("welcome to codex, openai's command-line coding agent") + && lower.contains("2. sign in with device code") + && lower.contains("3. provide your own api key") + && lower.contains("press enter to continue") + } + + nonisolated private static func hasConfirmationFooter(_ regions: CodexScreenRegions) -> Bool { + guard let selection = regions.selectedChoice, isNumberedChoice(selection.choice) else { + return false + } + return selection.footerLower.contains("press enter to confirm or esc to cancel") + || selection.footerLower.contains("enter to submit answer") + || selection.footerLower.contains("allow command?") + || selection.footerLower.contains("[y/n]") + || selection.footerLower.contains("yes (y)") + } + + nonisolated private static func hasConfirmationChoices(_ regions: CodexScreenRegions) -> Bool { + guard let selection = regions.selectedChoice, isNumberedChoice(selection.choice) else { + return false + } + guard + selection.interactionLower.contains("do you want") + || selection.interactionLower.contains("would you like") + else { + return false + } + + let hasYes = selection.options.contains { option in + option == "yes" || option.hasPrefix("1. yes") || option.hasPrefix("2. yes") + } + let hasNo = selection.options.contains { option in + option == "no" || option.hasPrefix("2. no") || option.hasPrefix("3. no") + } + return hasYes && hasNo + } + + nonisolated private static func hasWorkingFooter(_ regions: CodexScreenRegions) -> Bool { + regions.workingFooter.split(separator: "\n").contains { line in + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard trimmed.first == "•" || trimmed.first == "◦" else { return false } + let body = trimmed.dropFirst() + guard body.hasPrefix(" Working (") else { return false } + guard let hint = body.range(of: "esc to interrupt)") else { return false } + let trailing = body[hint.upperBound...] + return trailing.isEmpty || trailing.hasPrefix(" · ") + } + } +} + +private struct CodexScreenRegions: Sendable { + struct SelectedChoice: Sendable { + let choice: String + let beforeLower: String + let aroundLower: String + let afterLower: String + let footerLower: String + let interactionLower: String + let options: [String] + } + + let selectedChoice: SelectedChoice? + let signInMenuLines: [String] + let workingFooter: String + + nonisolated init(snapshot: AgentScreenSnapshot) { + self.selectedChoice = Self.makeSelectedChoice(from: snapshot.lines) + self.signInMenuLines = Array(snapshot.lines.suffix(18)) + self.workingFooter = agentDetectionRecentLines(snapshot.text, limit: 3) + } + + nonisolated private static func makeSelectedChoice(from lines: [String]) -> SelectedChoice? { + guard let promptIndex = lines.lastIndex(where: isCodexPromptLine) else { + return nil + } + + let lowerBound = max(lines.startIndex, promptIndex - 6) + let afterStart = lines.index(after: promptIndex) + let afterEnd = min(lines.endIndex, afterStart + 6) + let interactionLines = lines[lowerBound.. Bool { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard trimmed.first == "›" else { return false } + let remainder = trimmed.dropFirst() + return remainder.isEmpty || remainder.first?.isWhitespace == true +} + +nonisolated private func normalizedCodexChoice(_ line: String) -> String { + let trimmed = line.trimmingCharacters(in: .whitespaces).lowercased() + let withoutSelection = trimmed.hasPrefix("›") ? trimmed.dropFirst() : trimmed[...] + return withoutSelection.trimmingCharacters(in: .whitespaces) +} diff --git a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift index 23558023..b2f800c5 100644 --- a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift +++ b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift @@ -51,10 +51,10 @@ extension DetectedAgent { } nonisolated func agentDetectionRecentText(_ content: String) -> String { - recentLines(content, limit: agentDetectionRecentLineLimit) + agentDetectionRecentLines(content, limit: agentDetectionRecentLineLimit) } -nonisolated private func recentLines(_ content: String, limit: Int) -> String { +nonisolated func agentDetectionRecentLines(_ content: String, limit: Int) -> String { let lines = content.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) var remainingNonBlankLines = limit var startIndex = lines.startIndex @@ -144,7 +144,7 @@ nonisolated private func detectClaude(_ content: String) -> AgentRawState { return .blocked } - let liveStatus = recentLines(contentAbovePromptBox(content), limit: 3) + let liveStatus = agentDetectionRecentLines(contentAbovePromptBox(content), limit: 3) if hasSpinnerActivity(liveStatus) || hasClaudeElapsedStatusLine(liveStatus) { return .working } @@ -498,7 +498,7 @@ nonisolated private func hasCodexConfirmationFooter(_ content: String) -> Bool { let footerStart = lines.index(after: promptIndex) guard footerStart < lines.endIndex else { return false } - let footerLower = recentLines(lines[footerStart...].joined(separator: "\n"), limit: 3).lowercased() + let footerLower = agentDetectionRecentLines(lines[footerStart...].joined(separator: "\n"), limit: 3).lowercased() return footerLower.contains("press enter to confirm or esc to cancel") || footerLower.contains("enter to submit answer") || footerLower.contains("allow command?") @@ -537,7 +537,7 @@ nonisolated private func normalizedCodexChoice(_ line: String) -> String { return withoutSelection.trimmingCharacters(in: .whitespaces) } -nonisolated private func isNumberedChoice(_ option: String) -> Bool { +nonisolated func isNumberedChoice(_ option: String) -> Bool { guard let firstToken = option.split(whereSeparator: { $0.isWhitespace }).first, firstToken.last == "." else { @@ -657,7 +657,7 @@ nonisolated private func hasInterruptPattern(_ lower: String) -> Bool { } nonisolated private func hasCodexWorkingFooter(_ content: String) -> Bool { - recentLines(content, limit: 3).split(separator: "\n").contains { line in + agentDetectionRecentLines(content, limit: 3).split(separator: "\n").contains { line in let trimmed = line.trimmingCharacters(in: .whitespaces) guard trimmed.first == "•" || trimmed.first == "◦" else { return false } let body = trimmed.dropFirst() diff --git a/supacodeTests/CodexScreenProfileTests.swift b/supacodeTests/CodexScreenProfileTests.swift new file mode 100644 index 00000000..308e445a --- /dev/null +++ b/supacodeTests/CodexScreenProfileTests.swift @@ -0,0 +1,66 @@ +import Testing + +@testable import supacode + +struct CodexScreenProfileTests { + @Test func ruleIDsAreUniqueAndRuntimePrefixed() { + let ruleIDs = CodexScreenProfile.RuleID.all + + #expect(Set(ruleIDs).count == ruleIDs.count) + #expect(ruleIDs.allSatisfy { $0.rawValue.hasPrefix("codex.") }) + } + + @Test func capturedFixturesHaveStableReasons() throws { + let expectedReasons: [String: AgentScreenDetectionReason] = [ + "codex/0.146.1/blocked/command-permission.txt": .matched( + CodexScreenProfile.RuleID.confirmationFooter + ), + "codex/0.146.1/blocked/directory-trust.txt": .matched( + CodexScreenProfile.RuleID.directoryTrust + ), + "codex/0.146.1/blocked/hook-review.txt": .matched(CodexScreenProfile.RuleID.hookReview), + "codex/0.146.1/blocked/sign-in-selection.txt": .matched(CodexScreenProfile.RuleID.signIn), + "codex/0.146.1/idle/composer.txt": .noRuleMatched, + "codex/0.146.1/idle/quoted-directory-trust.txt": .noRuleMatched, + "codex/0.146.1/working/foreground-footer.txt": .matched( + CodexScreenProfile.RuleID.workingFooter + ), + ] + let fixtures = try AgentScreenFixtureCorpus.load().filter { $0.agent == .codex } + + #expect(fixtures.count == expectedReasons.count) + for fixture in fixtures { + let detection = CodexScreenProfile.detect( + in: AgentScreenSnapshot(canonicalText: fixture.text) + ) + #expect(detection.state == fixture.currentState) + #expect(detection.reason == expectedReasons[fixture.relativePath]) + } + } + + @Test func structuredChoicesExplainBlockedWithoutAFooter() { + let detection = CodexScreenProfile.detect( + in: AgentScreenSnapshot( + canonicalText: """ + Would you like to run the following command? + › 1. Yes, proceed + 2. No, cancel + """ + ) + ) + + #expect(detection.state == .blocked) + #expect(detection.reason == .matched(CodexScreenProfile.RuleID.confirmationChoices)) + } + + /// Temporary migration harness. Removed when production switches to the + /// profile and the legacy Codex detector is deleted. + @Test func capturedCorpusMatchesLegacyDetector() throws { + for fixture in try AgentScreenFixtureCorpus.load() where fixture.agent == .codex { + let profile = CodexScreenProfile.detect( + in: AgentScreenSnapshot(canonicalText: fixture.text) + ) + #expect(profile.state == DetectedAgent.codex.detectState(in: fixture.text)) + } + } +} diff --git a/supacodeTests/ScreenHeuristicsTests.swift b/supacodeTests/ScreenHeuristicsTests.swift index 99555ebe..66e6b749 100644 --- a/supacodeTests/ScreenHeuristicsTests.swift +++ b/supacodeTests/ScreenHeuristicsTests.swift @@ -477,20 +477,20 @@ struct ScreenHeuristicsTests { @Test func codexDetection() { #expect( - DetectedAgent.codex.detectState( + codexLegacyParityState( in: """ › 1. Yes, proceed (y) Press enter to confirm or esc to cancel """ ) == .blocked ) - #expect(DetectedAgent.codex.detectState(in: "• Working (12s • esc to interrupt)") == .working) - #expect(DetectedAgent.codex.detectState(in: "Ready for input") == .idle) + #expect(codexLegacyParityState(in: "• Working (12s • esc to interrupt)") == .working) + #expect(codexLegacyParityState(in: "Ready for input") == .idle) } @Test func codexCurrentPreSessionBlockersAreBlocked() { #expect( - DetectedAgent.codex.detectState( + codexLegacyParityState( in: """ > You are in /tmp/detection-workspace @@ -505,7 +505,7 @@ struct ScreenHeuristicsTests { ) == .blocked ) #expect( - DetectedAgent.codex.detectState( + codexLegacyParityState( in: """ Hooks need review 1 hook is new or changed. @@ -520,7 +520,7 @@ struct ScreenHeuristicsTests { ) == .blocked ) #expect( - DetectedAgent.codex.detectState( + codexLegacyParityState( in: """ Welcome to Codex, OpenAI's command-line coding agent @@ -544,7 +544,7 @@ struct ScreenHeuristicsTests { @Test func codexSignInAlternativeSelectedChoiceIsBlocked() { #expect( - DetectedAgent.codex.detectState( + codexLegacyParityState( in: """ Welcome to Codex, OpenAI's command-line coding agent @@ -560,7 +560,7 @@ struct ScreenHeuristicsTests { @Test func codexStalePreSessionPromptBeforeCurrentInputIsIdle() { #expect( - DetectedAgent.codex.detectState( + codexLegacyParityState( in: """ Do you trust the contents of this directory? › 1. Yes, continue @@ -576,7 +576,7 @@ struct ScreenHeuristicsTests { @Test func codexStaleSignInMenuBeforeCurrentInputIsIdle() { #expect( - DetectedAgent.codex.detectState( + codexLegacyParityState( in: """ Welcome to Codex, OpenAI's command-line coding agent @@ -595,7 +595,7 @@ struct ScreenHeuristicsTests { @Test func codexTranscriptConfirmationVocabularyDoesNotOverrideLiveState() { #expect( - DetectedAgent.codex.detectState( + codexLegacyParityState( in: """ › Reply with two lines containing do you want and yes. • Working (2s • esc to interrupt) @@ -605,7 +605,7 @@ struct ScreenHeuristicsTests { ) == .working ) #expect( - DetectedAgent.codex.detectState( + codexLegacyParityState( in: """ › Reply with two lines containing do you want and yes. • The parser looks for do you want. @@ -616,7 +616,7 @@ struct ScreenHeuristicsTests { ) == .idle ) #expect( - DetectedAgent.codex.detectState( + codexLegacyParityState( in: """ › Explain a confirmation dialog without opening one. • A dialog might say: Would you like to run the command? @@ -631,7 +631,7 @@ struct ScreenHeuristicsTests { @Test func codexConfirmationVocabularyWithoutPromptIsIdle() { #expect( - DetectedAgent.codex.detectState( + codexLegacyParityState( in: """ • The previous prompt said: press enter to confirm or esc to cancel. It also mentioned allow command? and [y/n]. @@ -642,7 +642,7 @@ struct ScreenHeuristicsTests { @Test func codexUserPromptConfirmationVocabularyIsIdle() { #expect( - DetectedAgent.codex.detectState( + codexLegacyParityState( in: """ › Explain why the UI says press enter to confirm or esc to cancel. gpt-5.6-terra xhigh · Context 5% used @@ -650,7 +650,7 @@ struct ScreenHeuristicsTests { ) == .idle ) #expect( - DetectedAgent.codex.detectState( + codexLegacyParityState( in: """ Press enter to confirm or esc to cancel › 1. Explain this footer ordering. @@ -664,9 +664,9 @@ struct ScreenHeuristicsTests { › Describe the confirmation footer. • The footer says: Press enter to confirm or esc to cancel. """ - #expect(DetectedAgent.codex.detectState(in: completedResponse) == .idle) + #expect(codexLegacyParityState(in: completedResponse) == .idle) #expect( - DetectedAgent.codex.detectState( + codexLegacyParityState( in: """ \(completedResponse) • Working (2s • esc to interrupt) @@ -674,7 +674,7 @@ struct ScreenHeuristicsTests { ) == .working ) #expect( - DetectedAgent.codex.detectState( + codexLegacyParityState( in: """ \(completedResponse) › @@ -685,7 +685,7 @@ struct ScreenHeuristicsTests { @Test func codexCurrentConfirmationOutranksRetainedWorkingFooter() { #expect( - DetectedAgent.codex.detectState( + codexLegacyParityState( in: """ • Working (4s • esc to interrupt) Would you like to run the following command? @@ -699,7 +699,7 @@ struct ScreenHeuristicsTests { @Test func codexWorkingFooterMustBeInTheLiveBottomRegion() { #expect( - DetectedAgent.codex.detectState( + codexLegacyParityState( in: """ • Working (4s • esc to interrupt) • Completed line 1 @@ -717,10 +717,19 @@ struct ScreenHeuristicsTests { "• Retrying… (10s)", ] for bullet in transcriptBullets { - #expect(DetectedAgent.codex.detectState(in: bullet) == .idle) + #expect(codexLegacyParityState(in: bullet) == .idle) } } + private func codexLegacyParityState(in screen: String) -> AgentRawState { + let legacy = DetectedAgent.codex.detectState(in: screen) + let profile = CodexScreenProfile.detect( + in: AgentScreenSnapshot(canonicalText: agentDetectionRecentText(screen)) + ) + #expect(profile.state == legacy) + return legacy + } + @Test func geminiDetection() { #expect(DetectedAgent.gemini.detectState(in: "│ Apply this change") == .blocked) #expect(DetectedAgent.gemini.detectState(in: "esc to cancel") == .working) -- 2.51.2 From 616e8365dadcd98de21e902daabe66bd900caf52 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 7 Aug 2026 03:53:38 +0900 Subject: [PATCH 2/4] Route Codex detection through typed profile --- .../AgentDetection/ScreenHeuristics.swift | 217 ++---------------- supacodeTests/AgentScreenDetectionTests.swift | 4 +- supacodeTests/CodexScreenProfileTests.swift | 15 +- supacodeTests/ScreenHeuristicsTests.swift | 53 ++--- 4 files changed, 46 insertions(+), 243 deletions(-) diff --git a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift index b2f800c5..c294008a 100644 --- a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift +++ b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift @@ -8,45 +8,41 @@ extension DetectedAgent { } nonisolated func detectScreen(in screen: String) -> AgentScreenDetection { - AgentScreenDetection( - state: detectLegacyState(in: agentDetectionRecentText(screen)), - reason: .legacyDetector - ) - } - - nonisolated private func detectLegacyState(in screen: String) -> AgentRawState { + let text = agentDetectionRecentText(screen) + let state: AgentRawState switch self { case .pi: - return detectPi(screen) + state = detectPi(text) case .omp: - return detectOMP(screen) + state = detectOMP(text) case .claude: - return detectClaude(screen) + state = detectClaude(text) case .codex: - return detectCodex(screen) + return CodexScreenProfile.detect(in: AgentScreenSnapshot(canonicalText: text)) case .gemini: - return detectGemini(screen) + state = detectGemini(text) case .cursor: - return detectCursor(screen) + state = detectCursor(text) case .cline: - return detectCline(screen) + state = detectCline(text) case .opencode: - return detectOpenCode(screen) + state = detectOpenCode(text) case .copilot: - return detectCopilot(screen) + state = detectCopilot(text) case .kimi: - return detectKimi(screen) + state = detectKimi(text) case .droid: - return detectDroid(screen) + state = detectDroid(text) case .amp: - return detectAmp(screen) + state = detectAmp(text) case .qoder: - return detectQoder(screen) + state = detectQoder(text) case .qwen: - return detectQwen(screen) + state = detectQwen(text) case .grok: - return detectGrok(screen) + state = detectGrok(text) } + return AgentScreenDetection(state: state, reason: .legacyDetector) } } @@ -154,16 +150,6 @@ nonisolated private func detectClaude(_ content: String) -> AgentRawState { return .idle } -nonisolated private func detectCodex(_ content: String) -> AgentRawState { - if hasCodexPreSessionBlockedPrompt(content) || hasCodexBlockedPrompt(content) { - return .blocked - } - if hasCodexWorkingFooter(content) { - return .working - } - return .idle -} - nonisolated private func detectGemini(_ content: String) -> AgentRawState { let lower = content.lowercased() if lower.contains("waiting for user confirmation") @@ -382,161 +368,6 @@ nonisolated private func isClaudeNumberedSelectionLine(_ line: String) -> Bool { return isNumberedChoice(option) } -nonisolated private func isCodexPromptLine(_ line: String) -> Bool { - let trimmed = line.trimmingCharacters(in: .whitespaces) - guard trimmed.first == "›" else { return false } - let remainder = trimmed.dropFirst() - return remainder.isEmpty || remainder.first?.isWhitespace == true -} - -nonisolated private func hasCodexPreSessionBlockedPrompt(_ content: String) -> Bool { - hasCodexDirectoryTrustPrompt(content) || hasCodexHookReviewPrompt(content) || hasCodexSignInPrompt(content) -} - -nonisolated private func hasCodexDirectoryTrustPrompt(_ content: String) -> Bool { - hasCodexSelectedChoice( - content, - matchingAnyOf: ["1. yes, continue", "2. no, quit"], - withBefore: ["do you trust the contents of this directory?"], - andAround: ["1. yes, continue", "2. no, quit"], - andAfter: ["press enter to continue"] - ) -} - -nonisolated private func hasCodexHookReviewPrompt(_ content: String) -> Bool { - hasCodexSelectedChoice( - content, - matchingAnyOf: [ - "1. review hooks", - "2. trust all and continue", - "3. continue without trusting (hooks won't run)", - ], - withBefore: ["hooks need review"], - andAround: ["1. review hooks", "2. trust all and continue", "3. continue without trusting"], - andAfter: ["press enter to confirm or esc to go back"] - ) -} - -nonisolated private func hasCodexSelectedChoice( - _ content: String, - matchingAnyOf options: Set, - withBefore beforeNeedles: [String], - andAround aroundNeedles: [String], - andAfter afterNeedles: [String] -) -> Bool { - let lines = content.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) - guard let promptIndex = lines.lastIndex(where: isCodexPromptLine) else { - return false - } - - let selectedChoice = normalizedCodexChoice(lines[promptIndex]) - guard options.contains(selectedChoice) else { - return false - } - - let lowerBound = max(lines.startIndex, promptIndex - 6) - let before = lines[lowerBound.. Bool { - let lines = content.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) - let recent = lines.suffix(18) - // The live sign-in menu renders no composer below it, so its selected option - // is the last `›`/`>` marker line on screen. A later marker line means the - // menu text above is stale transcript (or a quotation), not the current - // interaction. - guard - let selected = recent.last(where: { line in - let trimmed = line.trimmingCharacters(in: .whitespaces) - return trimmed.first == "›" || trimmed.first == ">" - }) - else { - return false - } - let choice = selected.trimmingCharacters(in: .whitespaces) - .dropFirst() - .trimmingCharacters(in: .whitespaces) - .lowercased() - guard - choice.hasPrefix("1. sign in with chatgpt") - || choice.hasPrefix("2. sign in with device code") - || choice.hasPrefix("3. provide your own api key") - else { - return false - } - let lower = recent.joined(separator: "\n").lowercased() - return lower.contains("welcome to codex, openai's command-line coding agent") - && lower.contains("2. sign in with device code") - && lower.contains("3. provide your own api key") - && lower.contains("press enter to continue") -} - -nonisolated private func hasCodexBlockedPrompt(_ content: String) -> Bool { - hasCodexConfirmationFooter(content) || hasCodexConfirmationChoices(content) -} - -nonisolated private func hasCodexConfirmationFooter(_ content: String) -> Bool { - let lines = content.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) - guard let promptIndex = lines.lastIndex(where: isCodexPromptLine) else { - return false - } - let selectedChoice = normalizedCodexChoice(lines[promptIndex]) - guard isNumberedChoice(selectedChoice) else { return false } - - let footerStart = lines.index(after: promptIndex) - guard footerStart < lines.endIndex else { return false } - let footerLower = agentDetectionRecentLines(lines[footerStart...].joined(separator: "\n"), limit: 3).lowercased() - return footerLower.contains("press enter to confirm or esc to cancel") - || footerLower.contains("enter to submit answer") - || footerLower.contains("allow command?") - || footerLower.contains("[y/n]") - || footerLower.contains("yes (y)") -} - -nonisolated private func hasCodexConfirmationChoices(_ content: String) -> Bool { - let lines = content.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) - guard let promptIndex = lines.lastIndex(where: isCodexPromptLine) else { - return false - } - let selectedChoice = normalizedCodexChoice(lines[promptIndex]) - guard isNumberedChoice(selectedChoice) else { return false } - - let lowerBound = max(lines.startIndex, promptIndex - 6) - let interactionLines = lines[lowerBound.. String { - let trimmed = line.trimmingCharacters(in: .whitespaces).lowercased() - let withoutSelection = trimmed.hasPrefix("›") ? trimmed.dropFirst() : trimmed[...] - return withoutSelection.trimmingCharacters(in: .whitespaces) -} - nonisolated func isNumberedChoice(_ option: String) -> Bool { guard let firstToken = option.split(whereSeparator: { $0.isWhitespace }).first, firstToken.last == "." @@ -656,18 +487,6 @@ nonisolated private func hasInterruptPattern(_ lower: String) -> Bool { || (lower.contains("esc") && lower.contains("interrupt")) } -nonisolated private func hasCodexWorkingFooter(_ content: String) -> Bool { - agentDetectionRecentLines(content, limit: 3).split(separator: "\n").contains { line in - let trimmed = line.trimmingCharacters(in: .whitespaces) - guard trimmed.first == "•" || trimmed.first == "◦" else { return false } - let body = trimmed.dropFirst() - guard body.hasPrefix(" Working (") else { return false } - guard let hint = body.range(of: "esc to interrupt)") else { return false } - let trailing = body[hint.upperBound...] - return trailing.isEmpty || trailing.hasPrefix(" · ") - } -} - nonisolated private func hasSpinnerActivity(_ content: String) -> Bool { let spinnerScalars: Set = [ "·", "✱", "✲", "✳", "✴", "✵", "✶", "✷", "✸", "✹", "✺", "✻", "✼", "✽", "✾", "✿", diff --git a/supacodeTests/AgentScreenDetectionTests.swift b/supacodeTests/AgentScreenDetectionTests.swift index 652f55ca..689e6499 100644 --- a/supacodeTests/AgentScreenDetectionTests.swift +++ b/supacodeTests/AgentScreenDetectionTests.swift @@ -4,10 +4,10 @@ import Testing @testable import supacode struct AgentScreenDetectionTests { - @Test func legacyDetectorsReturnTheirExistingStateWithAStableReason() { + @Test func unmigratedDetectorsReturnTheirExistingStateWithAStableReason() { let screen = "screen without a live rule" - for agent in DetectedAgent.allCases { + for agent in DetectedAgent.allCases where agent != .codex { let detection = agent.detectScreen(in: screen) #expect(detection.state == agent.detectState(in: screen)) diff --git a/supacodeTests/CodexScreenProfileTests.swift b/supacodeTests/CodexScreenProfileTests.swift index 308e445a..e670b356 100644 --- a/supacodeTests/CodexScreenProfileTests.swift +++ b/supacodeTests/CodexScreenProfileTests.swift @@ -30,9 +30,7 @@ struct CodexScreenProfileTests { #expect(fixtures.count == expectedReasons.count) for fixture in fixtures { - let detection = CodexScreenProfile.detect( - in: AgentScreenSnapshot(canonicalText: fixture.text) - ) + let detection = DetectedAgent.codex.detectScreen(in: fixture.text) #expect(detection.state == fixture.currentState) #expect(detection.reason == expectedReasons[fixture.relativePath]) } @@ -52,15 +50,4 @@ struct CodexScreenProfileTests { #expect(detection.state == .blocked) #expect(detection.reason == .matched(CodexScreenProfile.RuleID.confirmationChoices)) } - - /// Temporary migration harness. Removed when production switches to the - /// profile and the legacy Codex detector is deleted. - @Test func capturedCorpusMatchesLegacyDetector() throws { - for fixture in try AgentScreenFixtureCorpus.load() where fixture.agent == .codex { - let profile = CodexScreenProfile.detect( - in: AgentScreenSnapshot(canonicalText: fixture.text) - ) - #expect(profile.state == DetectedAgent.codex.detectState(in: fixture.text)) - } - } } diff --git a/supacodeTests/ScreenHeuristicsTests.swift b/supacodeTests/ScreenHeuristicsTests.swift index 66e6b749..12707bbd 100644 --- a/supacodeTests/ScreenHeuristicsTests.swift +++ b/supacodeTests/ScreenHeuristicsTests.swift @@ -477,20 +477,20 @@ struct ScreenHeuristicsTests { @Test func codexDetection() { #expect( - codexLegacyParityState( + codexProfileState( in: """ › 1. Yes, proceed (y) Press enter to confirm or esc to cancel """ ) == .blocked ) - #expect(codexLegacyParityState(in: "• Working (12s • esc to interrupt)") == .working) - #expect(codexLegacyParityState(in: "Ready for input") == .idle) + #expect(codexProfileState(in: "• Working (12s • esc to interrupt)") == .working) + #expect(codexProfileState(in: "Ready for input") == .idle) } @Test func codexCurrentPreSessionBlockersAreBlocked() { #expect( - codexLegacyParityState( + codexProfileState( in: """ > You are in /tmp/detection-workspace @@ -505,7 +505,7 @@ struct ScreenHeuristicsTests { ) == .blocked ) #expect( - codexLegacyParityState( + codexProfileState( in: """ Hooks need review 1 hook is new or changed. @@ -520,7 +520,7 @@ struct ScreenHeuristicsTests { ) == .blocked ) #expect( - codexLegacyParityState( + codexProfileState( in: """ Welcome to Codex, OpenAI's command-line coding agent @@ -544,7 +544,7 @@ struct ScreenHeuristicsTests { @Test func codexSignInAlternativeSelectedChoiceIsBlocked() { #expect( - codexLegacyParityState( + codexProfileState( in: """ Welcome to Codex, OpenAI's command-line coding agent @@ -560,7 +560,7 @@ struct ScreenHeuristicsTests { @Test func codexStalePreSessionPromptBeforeCurrentInputIsIdle() { #expect( - codexLegacyParityState( + codexProfileState( in: """ Do you trust the contents of this directory? › 1. Yes, continue @@ -576,7 +576,7 @@ struct ScreenHeuristicsTests { @Test func codexStaleSignInMenuBeforeCurrentInputIsIdle() { #expect( - codexLegacyParityState( + codexProfileState( in: """ Welcome to Codex, OpenAI's command-line coding agent @@ -595,7 +595,7 @@ struct ScreenHeuristicsTests { @Test func codexTranscriptConfirmationVocabularyDoesNotOverrideLiveState() { #expect( - codexLegacyParityState( + codexProfileState( in: """ › Reply with two lines containing do you want and yes. • Working (2s • esc to interrupt) @@ -605,7 +605,7 @@ struct ScreenHeuristicsTests { ) == .working ) #expect( - codexLegacyParityState( + codexProfileState( in: """ › Reply with two lines containing do you want and yes. • The parser looks for do you want. @@ -616,7 +616,7 @@ struct ScreenHeuristicsTests { ) == .idle ) #expect( - codexLegacyParityState( + codexProfileState( in: """ › Explain a confirmation dialog without opening one. • A dialog might say: Would you like to run the command? @@ -631,7 +631,7 @@ struct ScreenHeuristicsTests { @Test func codexConfirmationVocabularyWithoutPromptIsIdle() { #expect( - codexLegacyParityState( + codexProfileState( in: """ • The previous prompt said: press enter to confirm or esc to cancel. It also mentioned allow command? and [y/n]. @@ -642,7 +642,7 @@ struct ScreenHeuristicsTests { @Test func codexUserPromptConfirmationVocabularyIsIdle() { #expect( - codexLegacyParityState( + codexProfileState( in: """ › Explain why the UI says press enter to confirm or esc to cancel. gpt-5.6-terra xhigh · Context 5% used @@ -650,7 +650,7 @@ struct ScreenHeuristicsTests { ) == .idle ) #expect( - codexLegacyParityState( + codexProfileState( in: """ Press enter to confirm or esc to cancel › 1. Explain this footer ordering. @@ -664,9 +664,9 @@ struct ScreenHeuristicsTests { › Describe the confirmation footer. • The footer says: Press enter to confirm or esc to cancel. """ - #expect(codexLegacyParityState(in: completedResponse) == .idle) + #expect(codexProfileState(in: completedResponse) == .idle) #expect( - codexLegacyParityState( + codexProfileState( in: """ \(completedResponse) • Working (2s • esc to interrupt) @@ -674,7 +674,7 @@ struct ScreenHeuristicsTests { ) == .working ) #expect( - codexLegacyParityState( + codexProfileState( in: """ \(completedResponse) › @@ -685,7 +685,7 @@ struct ScreenHeuristicsTests { @Test func codexCurrentConfirmationOutranksRetainedWorkingFooter() { #expect( - codexLegacyParityState( + codexProfileState( in: """ • Working (4s • esc to interrupt) Would you like to run the following command? @@ -699,7 +699,7 @@ struct ScreenHeuristicsTests { @Test func codexWorkingFooterMustBeInTheLiveBottomRegion() { #expect( - codexLegacyParityState( + codexProfileState( in: """ • Working (4s • esc to interrupt) • Completed line 1 @@ -717,17 +717,14 @@ struct ScreenHeuristicsTests { "• Retrying… (10s)", ] for bullet in transcriptBullets { - #expect(codexLegacyParityState(in: bullet) == .idle) + #expect(codexProfileState(in: bullet) == .idle) } } - private func codexLegacyParityState(in screen: String) -> AgentRawState { - let legacy = DetectedAgent.codex.detectState(in: screen) - let profile = CodexScreenProfile.detect( - in: AgentScreenSnapshot(canonicalText: agentDetectionRecentText(screen)) - ) - #expect(profile.state == legacy) - return legacy + private func codexProfileState(in screen: String) -> AgentRawState { + let detection = DetectedAgent.codex.detectScreen(in: screen) + #expect(detection.reason != .legacyDetector) + return detection.state } @Test func geminiDetection() { -- 2.51.2 From 7ac6f0a7ba1fb8d64acbabf06fe1ef78c16cb993 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 7 Aug 2026 04:01:47 +0900 Subject: [PATCH 3/4] Document Codex screen profile migration --- .../030-agent-status-detection/000-plan.md | 3 + .../011-codex-screen-profile.md | 83 +++++++++++++++++++ docs/components/agent-detection.md | 6 +- 3 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 docs-ai/030-agent-status-detection/011-codex-screen-profile.md diff --git a/docs-ai/030-agent-status-detection/000-plan.md b/docs-ai/030-agent-status-detection/000-plan.md index 968ef08d..7192925e 100644 --- a/docs-ai/030-agent-status-detection/000-plan.md +++ b/docs-ai/030-agent-status-detection/000-plan.md @@ -135,3 +135,6 @@ the failed attempt to extend the first signal to plain commands is caches the complete result, records stable transition reasons, and exposes an optional JSON-only `detection_reason` — see [010-explainable-screen-detection-results.md](010-explainable-screen-detection-results.md) +- Updated 2026-08-07: Codex state detection moved to a runtime-owned typed profile with + explicit regions, ordered rules, stable reason IDs, and legacy-parity proof — see + [011-codex-screen-profile.md](011-codex-screen-profile.md) diff --git a/docs-ai/030-agent-status-detection/011-codex-screen-profile.md b/docs-ai/030-agent-status-detection/011-codex-screen-profile.md new file mode 100644 index 00000000..852ff394 --- /dev/null +++ b/docs-ai/030-agent-status-detection/011-codex-screen-profile.md @@ -0,0 +1,83 @@ +# 030.011 — Codex Screen Profile: Action + +| | | +| --- | --- | +| **Status** | Implemented | +| **Date** | 2026-08-07 | +| **Branch** | `feat/codex-screen-profile` | +| **PR** | TBD (stacked on #686) | +| **Plan** | [007-screen-profile-migration-plan.md](007-screen-profile-migration-plan.md), Phase 4 | + +## Result + +Codex state detection now runs through one runtime-owned typed Swift profile. The profile +constructs its live regions once from the canonical 24-non-empty-line detector input and +evaluates rules in explicit source order: + +1. directory trust → `blocked / codex.directoryTrust`; +2. hook review → `blocked / codex.hookReview`; +3. sign-in selection → `blocked / codex.signIn`; +4. numbered choice plus live confirmation footer → + `blocked / codex.confirmationFooter`; +5. structurally complete Yes/No choices → `blocked / codex.confirmationChoices`; +6. exact live bottom working footer → `working / codex.workingFooter`; +7. no match → `idle / fallback.noRuleMatched`. + +Blocked therefore still outranks a retained working footer. Ordinary composer frames, +quoted directory-trust text, stale menus, completed response prose, and unsupported +working-like bullets remain idle with an explicit no-match reason. + +`CodexScreenRegions` owns Codex prompt/menu/footer boundaries. It splits canonical text +once and derives the selected-choice windows, recent sign-in menu, and bottom working +footer without a generic matcher or named-region registry. Mechanical recent-line and +numbered-choice predicates keep one shared owner because Claude still uses them. + +## Migration protocol + +The migration used the two-commit parity protocol from the approved plan: + +- `3d1d118d` added `AgentScreenSnapshot`, the non-production Codex profile, stable IDs, + reason assertions, and a temporary legacy-parity harness. All 21 existing inline Codex + call sites and all seven captured Codex fixtures compared the profile state against the + production legacy detector. +- `616e8365` routed production to the proven profile, required every existing inline test + to return a non-legacy reason, removed the temporary comparator, and deleted the legacy + Codex classifier plus its private helpers. + +At no point does a shipped production path run both classifiers. Other runtimes still use +their unchanged legacy detectors and continue to report `legacy.detector`. + +## Behavior and ownership + +No intentional state behavior changed. The 0.146.1 corpus remains entirely green, so no +Codex fixture entered quarantine and no capture-backed exception was needed. Existing +screen-only authority, canonical-tail semantics, exact `(agent, active-screen text)` cache +identity, 3-second working hold, polling, and display-state projection are unchanged. + +The legacy Codex implementation and exclusive helpers were removed from +`ScreenHeuristics.swift`; shared helpers remain there until their last non-profile caller +moves. Profile rule IDs are unique and `codex.`-prefixed by test. + +## Validation + +TDD and parity evidence: + +- profile tests initially failed to compile because the snapshot/profile APIs did not + exist; +- the non-production profile then matched all 21 existing Codex inline cases and all seven + captured fixtures; +- the production-routing test next failed in 12 Codex test methods because production + still returned `legacy.detector`, then passed after the switch; +- focused profile, heuristic, corpus, result, cache, and benchmark-smoke tests: 58 passed; +- full app suite: xcsift reported 2,285 passed; xcresult independently verified 2,287 + tests and zero failures; +- `make check` passed; +- `make bench`: 6 passed. At `616e8365`, the complete 15-fixture corpus measured + **2.960 ms median**, versus Phase 3's 3.116 ms (**-5.0%**). The profile derives shared + regions once instead of repeatedly splitting the same Codex screen. + +The host remained locked at `loginwindow`, so no new live CLI reason observation is +claimed. Current-version provenance comes from the detector-faithful Codex 0.146.1 +captures recorded in [009](009-captured-screen-fixture-corpus.md); retry live +`prowl agents --json` when the GUI session is unlocked and on the final simulated +integration branch. diff --git a/docs/components/agent-detection.md b/docs/components/agent-detection.md index da72217c..2031b4d4 100644 --- a/docs/components/agent-detection.md +++ b/docs/components/agent-detection.md @@ -59,8 +59,10 @@ because it can differ from the visible viewport when a pane is scrolled; the def `prowl read` behavior is unchanged. `prowl agents --json` may also include `detection_reason`, a stable classifier rule or -fallback identifier for the latest screen scan. It does not include screen text, and the -text-mode command and app UI remain unchanged. +fallback identifier for the latest screen scan. Codex currently reports runtime-owned IDs +for directory trust, hook review, sign-in, confirmation, and working-footer matches; an +ordinary Codex miss reports `fallback.noRuleMatched`. It does not include screen text, and +the text-mode command and app UI remain unchanged. To avoid flicker, detection **stabilizes**: it tolerates several consecutive misses before declaring an agent gone, and a working agent gets a short (~3s) -- 2.51.2 From badcdc2c1044c8247a88078a1c10f6f0e6b1bc40 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 7 Aug 2026 04:02:34 +0900 Subject: [PATCH 4/4] Link Codex profile pull request --- .../007-screen-profile-migration-plan.md | 2 +- docs-ai/030-agent-status-detection/011-codex-screen-profile.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs-ai/030-agent-status-detection/007-screen-profile-migration-plan.md b/docs-ai/030-agent-status-detection/007-screen-profile-migration-plan.md index baff7c48..8f62d6ea 100644 --- a/docs-ai/030-agent-status-detection/007-screen-profile-migration-plan.md +++ b/docs-ai/030-agent-status-detection/007-screen-profile-migration-plan.md @@ -4,7 +4,7 @@ | --- | --- | | **Status** | Approved — implementation in progress | | **Anchor date** | 2026-08-06 | -| **Primary PRs** | #684 (Phase 1), #685 (Phase 2), #686 (Phase 3); Phases 4–5 TBD | +| **Primary PRs** | #684 (Phase 1), #685 (Phase 2), #686 (Phase 3), #687 (Phase 4); Phase 5 TBD | | **Sources** | Issue #676, PRs #674/#683, herdr `fae0b236` (v0.8.0-era) | | **Related** | [000-plan.md](000-plan.md), [004-live-region-evidence.md](004-live-region-evidence.md), [005-confirmation-live-structure.md](005-confirmation-live-structure.md), [006-current-cli-pre-session-blockers.md](006-current-cli-pre-session-blockers.md), `docs/components/agent-detection.md` | diff --git a/docs-ai/030-agent-status-detection/011-codex-screen-profile.md b/docs-ai/030-agent-status-detection/011-codex-screen-profile.md index 852ff394..aa04d09c 100644 --- a/docs-ai/030-agent-status-detection/011-codex-screen-profile.md +++ b/docs-ai/030-agent-status-detection/011-codex-screen-profile.md @@ -5,7 +5,7 @@ | **Status** | Implemented | | **Date** | 2026-08-07 | | **Branch** | `feat/codex-screen-profile` | -| **PR** | TBD (stacked on #686) | +| **PR** | #687 (stacked on #686) | | **Plan** | [007-screen-profile-migration-plan.md](007-screen-profile-migration-plan.md), Phase 4 | ## Result