From 616e8365dadcd98de21e902daabe66bd900caf52 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 7 Aug 2026 03:53:38 +0900 Subject: [PATCH] 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