From 696ecf687ff7b4e72731e9713b86f62cacdf2522 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 13 Jun 2026 00:07:00 +0900 Subject: [PATCH] Hold working state for all agents to stop status flapping Agents briefly clear their working indicators (spinner, "esc to interrupt") between steps, which flipped the stabilized state to idle/Done for a moment before snapping back to Working. The hold that papered over this was Claude-only and too short (1.2s). Generalize the working -> idle hold to every detected agent and widen it to 3s. Blocked still bypasses the hold so permission prompts surface immediately. --- docs/components/agent-detection.md | 7 ++-- .../AgentDetection/PaneAgentState.swift | 19 ++++++---- ...WorktreeTerminalState+AgentDetection.swift | 12 +++--- .../Models/WorktreeTerminalState.swift | 2 +- supacodeTests/PaneAgentStateTests.swift | 37 ++++++++++--------- 5 files changed, 42 insertions(+), 35 deletions(-) diff --git a/docs/components/agent-detection.md b/docs/components/agent-detection.md index 15e9af3a..7de89063 100644 --- a/docs/components/agent-detection.md +++ b/docs/components/agent-detection.md @@ -32,9 +32,10 @@ bash, etc.) so agents launched indirectly are still found. Kimi's moon phases, etc.). To avoid flicker, detection **stabilizes**: it tolerates several consecutive -misses before declaring an agent gone, and Claude specifically gets a short -(~1.2s) hold so brief pauses between thinking and output don't drop it out of -"working". +misses before declaring an agent gone, and a working agent gets a short (~3s) +hold so brief pauses between thinking and output don't drop it out of +"working" (a genuine finish therefore reports up to ~3s late; "blocked" +bypasses the hold and surfaces immediately). ## The state machine diff --git a/supacode/Domain/AgentDetection/PaneAgentState.swift b/supacode/Domain/AgentDetection/PaneAgentState.swift index 85a8a17d..d13b9b48 100644 --- a/supacode/Domain/AgentDetection/PaneAgentState.swift +++ b/supacode/Domain/AgentDetection/PaneAgentState.swift @@ -67,31 +67,36 @@ struct AgentDetectionPresence: Equatable, Sendable { } } -private let claudeWorkingHold: TimeInterval = 1.2 +// Agents briefly clear their working indicators between steps (output gaps, +// tool-call boundaries), so a raw working → idle flip is only trusted after +// the screen has read idle for this long. Keeps Working from flapping to +// Done and back during those pauses, at the cost of reporting a genuine +// finish up to this much later. +private let workingStateHold: TimeInterval = 3.0 func stabilizeAgentState( agent: DetectedAgent?, previous: AgentRawState, raw: AgentRawState, now: Date, - lastClaudeWorkingAt: inout Date? + lastWorkingAt: inout Date? ) -> AgentRawState { - guard agent == .claude else { - lastClaudeWorkingAt = nil + guard agent != nil else { + lastWorkingAt = nil return raw } switch raw { case .working: - lastClaudeWorkingAt = now + lastWorkingAt = now return .working case .blocked: return .blocked case .idle where previous == .working: - guard let lastClaudeWorkingAt else { + guard let lastWorkingAt else { return .idle } - return now.timeIntervalSince(lastClaudeWorkingAt) < claudeWorkingHold ? .working : .idle + return now.timeIntervalSince(lastWorkingAt) < workingStateHold ? .working : .idle case .idle, .unknown: return raw } diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift index 431c3a58..2e8c35cd 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift @@ -78,15 +78,15 @@ extension WorktreeTerminalState { let raw = agent.detectState(in: activeText) guard surfaces[surfaceID] != nil else { return } - var lastClaudeWorkingAt = lastClaudeWorkingAtBySurface[surfaceID] + var lastWorkingAt = lastWorkingAtBySurface[surfaceID] let stabilized = stabilizeAgentState( agent: agent, previous: previous.state, raw: raw, now: now, - lastClaudeWorkingAt: &lastClaudeWorkingAt + lastWorkingAt: &lastWorkingAt ) - lastClaudeWorkingAtBySurface[surfaceID] = lastClaudeWorkingAt + lastWorkingAtBySurface[surfaceID] = lastWorkingAt let isForeground = isSelected() && isFocusedSurface(surfaceID) let becameIdleFromActive = @@ -143,7 +143,7 @@ extension WorktreeTerminalState { func removeAgentEntryIfNeeded(surfaceID: UUID) { guard surfaceAgentStates[surfaceID]?.detectedAgent != nil else { return } surfaceAgentStates[surfaceID] = PaneAgentState(lastChangedAt: Date()) - lastClaudeWorkingAtBySurface.removeValue(forKey: surfaceID) + lastWorkingAtBySurface.removeValue(forKey: surfaceID) onAgentEntryRemoved?(surfaceID) } @@ -203,7 +203,7 @@ extension WorktreeTerminalState { agentDetectionTasks.removeValue(forKey: surfaceId) surfaceAgentStates.removeValue(forKey: surfaceId) agentDetectionPresenceBySurface.removeValue(forKey: surfaceId) - lastClaudeWorkingAtBySurface.removeValue(forKey: surfaceId) + lastWorkingAtBySurface.removeValue(forKey: surfaceId) lastAgentDetectionDiagnosticsBySurface.removeValue(forKey: surfaceId) onAgentEntryRemoved?(surfaceId) } @@ -216,7 +216,7 @@ extension WorktreeTerminalState { agentDetectionTasks.removeAll() surfaceAgentStates.removeAll() agentDetectionPresenceBySurface.removeAll() - lastClaudeWorkingAtBySurface.removeAll() + lastWorkingAtBySurface.removeAll() lastAgentDetectionDiagnosticsBySurface.removeAll() for id in removedIDs { onAgentEntryRemoved?(id) diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 2385a55c..c02f67ec 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -72,7 +72,7 @@ final class WorktreeTerminalState { var surfaceAgentStates: [UUID: PaneAgentState] = [:] var agentDetectionTasks: [UUID: Task] = [:] var agentDetectionPresenceBySurface: [UUID: AgentDetectionPresence] = [:] - var lastClaudeWorkingAtBySurface: [UUID: Date] = [:] + var lastWorkingAtBySurface: [UUID: Date] = [:] var lastAgentDetectionDiagnosticsBySurface: [UUID: String] = [:] var agentDetectionEnabled = true var tabIsRunningById: [TerminalTabID: Bool] = [:] diff --git a/supacodeTests/PaneAgentStateTests.swift b/supacodeTests/PaneAgentStateTests.swift index 89a4ecdd..d7f0c16b 100644 --- a/supacodeTests/PaneAgentStateTests.swift +++ b/supacodeTests/PaneAgentStateTests.swift @@ -18,58 +18,59 @@ struct PaneAgentStateTests { #expect(state.displayState == .idle) } - @Test func claudeWorkingIsStickyForShortIdleGap() { + @Test(arguments: [DetectedAgent.claude, .codex, .gemini]) + func workingIsStickyForShortIdleGap(agent: DetectedAgent) { let now = Date(timeIntervalSince1970: 100) var lastWorking: Date? let working = stabilizeAgentState( - agent: .claude, + agent: agent, previous: .idle, raw: .working, now: now, - lastClaudeWorkingAt: &lastWorking + lastWorkingAt: &lastWorking ) #expect(working == .working) let stillWorking = stabilizeAgentState( - agent: .claude, + agent: agent, previous: .working, raw: .idle, - now: now.addingTimeInterval(0.4), - lastClaudeWorkingAt: &lastWorking + now: now.addingTimeInterval(2.9), + lastWorkingAt: &lastWorking ) #expect(stillWorking == .working) } - @Test func claudeTransitionsToIdleAfterStickyWindow() { + @Test(arguments: [DetectedAgent.claude, .codex]) + func transitionsToIdleAfterStickyWindow(agent: DetectedAgent) { let now = Date(timeIntervalSince1970: 100) var lastWorking: Date? = now let idle = stabilizeAgentState( - agent: .claude, + agent: agent, previous: .working, raw: .idle, - now: now.addingTimeInterval(1.201), - lastClaudeWorkingAt: &lastWorking + now: now.addingTimeInterval(3.001), + lastWorkingAt: &lastWorking ) #expect(idle == .idle) } - @Test func nonClaudeDoesNotUseStickyWindow() { + @Test func blockedBypassesStickyWindow() { let now = Date(timeIntervalSince1970: 100) var lastWorking: Date? = now - let idle = stabilizeAgentState( - agent: .codex, + let blocked = stabilizeAgentState( + agent: .claude, previous: .working, - raw: .idle, - now: now, - lastClaudeWorkingAt: &lastWorking + raw: .blocked, + now: now.addingTimeInterval(0.3), + lastWorkingAt: &lastWorking ) - #expect(idle == .idle) - #expect(lastWorking == nil) + #expect(blocked == .blocked) } @Test func presenceRequiresSixMissesBeforeRelease() { -- 2.51.2