From e1e0944428a341d9c8fc877e69ecfbb2068117fd Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 7 Aug 2026 03:12:33 +0900 Subject: [PATCH] Add explainable agent screen detection results --- ProwlCLITests/ProwlCLIIntegrationTests.swift | 45 +++++++++ .../030-agent-status-detection/000-plan.md | 4 + ...10-explainable-screen-detection-results.md | 85 +++++++++++++++++ docs/components/agent-detection.md | 4 + docs/components/cli.md | 4 + skills/prowl-cli/SKILL.md | 2 +- supacode/App/supacodeApp.swift | 8 +- .../CLIService/AgentsCommandHandler.swift | 10 +- .../Shared/AgentsCommandPayload.swift | 4 + .../AgentDetection/AgentScreenDetection.swift | 30 ++++++ ...WorktreeTerminalState+AgentDetection.swift | 92 +++++++++---------- .../Models/WorktreeTerminalState.swift | 26 +++++- .../AgentDetection/ScreenHeuristics.swift | 12 ++- supacodeTests/AgentScreenDetectionTests.swift | 42 +++++++++ supacodeTests/AgentScreenScanCacheTests.swift | 75 +++++++++++---- .../CLIAgentsCommandHandlerTests.swift | 10 +- 16 files changed, 373 insertions(+), 80 deletions(-) create mode 100644 docs-ai/030-agent-status-detection/010-explainable-screen-detection-results.md create mode 100644 supacode/Domain/AgentDetection/AgentScreenDetection.swift create mode 100644 supacodeTests/AgentScreenDetectionTests.swift diff --git a/ProwlCLITests/ProwlCLIIntegrationTests.swift b/ProwlCLITests/ProwlCLIIntegrationTests.swift index ae91c8d9..4a31e55d 100644 --- a/ProwlCLITests/ProwlCLIIntegrationTests.swift +++ b/ProwlCLITests/ProwlCLIIntegrationTests.swift @@ -139,6 +139,45 @@ final class ProwlCLIIntegrationTests: XCTestCase { XCTAssertEqual(payload["command"] as? String, "agents") } + func testAgentsPayloadDetectionReasonRemainsBackwardCompatible() throws { + let modernData = try JSONEncoder().encode( + AgentsResponseData( + count: 1, + agents: [ + makeAgentResponse( + id: "modern-pane", + name: "codex", + status: "blocked", + projectName: "Prowl", + branch: "main", + tabTitle: "Modern", + detectionReason: "codex.directoryTrust" + ) + ] + ) + ) + let modernPayload = try JSONDecoder().decode(AgentsCommandPayload.self, from: modernData) + XCTAssertEqual(modernPayload.agents.first?.detectionReason, "codex.directoryTrust") + + let legacyData = try JSONEncoder().encode( + AgentsResponseData( + count: 1, + agents: [ + makeAgentResponse( + id: "legacy-pane", + name: "claude", + status: "idle", + projectName: "Prowl", + branch: "main", + tabTitle: "Legacy" + ) + ] + ) + ) + let legacyPayload = try JSONDecoder().decode(AgentsCommandPayload.self, from: legacyData) + XCTAssertNil(legacyPayload.agents.first?.detectionReason) + } + func testJSONModePreservesEscapedControlCharactersFromAppResponse() throws { let socketPath = temporarySocketPath(suffix: "json-control") let responseJSON = [ @@ -536,6 +575,7 @@ final class ProwlCLIIntegrationTests: XCTestCase { projectName: "Prowl", branch: "main", tabTitle: "Done tab", + detectionReason: "legacy.detector", session: AgentsResponseSession( id: "019f4e9e-1234-4567-89ab-0123456789ab", path: "/Users/me/.codex/sessions/rollout.jsonl", @@ -582,6 +622,7 @@ final class ProwlCLIIntegrationTests: XCTestCase { XCTAssertTrue(lines[1].contains("Working"), "Expected working second: \(result.stdout)") XCTAssertTrue(lines[2].contains("Done"), "Expected done third: \(result.stdout)") XCTAssertTrue(lines[2].contains("session=019f4e9e-1234-4567-89ab-0123456789ab [exact]")) + XCTAssertFalse(result.stdout.contains("legacy.detector"), "Detection reason must remain JSON-only") } func testAgentsEmptyPayloadShowsNoAgentsFound() throws { @@ -1980,6 +2021,7 @@ final class ProwlCLIIntegrationTests: XCTestCase { projectName: String, branch: String, tabTitle: String, + detectionReason: String? = nil, session: AgentsResponseSession? = nil ) -> AgentsResponseAgent { AgentsResponseAgent( @@ -1988,6 +2030,7 @@ final class ProwlCLIIntegrationTests: XCTestCase { name: name, status: status, rawState: status, + detectionReason: detectionReason, lastChangedAt: "2026-06-13T04:12:25Z", project: AgentsResponseProject(name: projectName, branch: branch, path: "/Projects/\(projectName)"), worktree: ListWorktree( @@ -2240,6 +2283,7 @@ private struct AgentsResponseAgent: Encodable { case name case status case rawState = "raw_state" + case detectionReason = "detection_reason" case lastChangedAt = "last_changed_at" case project case worktree @@ -2249,6 +2293,7 @@ private struct AgentsResponseAgent: Encodable { } let rawState: String + let detectionReason: String? let lastChangedAt: String let project: AgentsResponseProject let worktree: ListWorktree diff --git a/docs-ai/030-agent-status-detection/000-plan.md b/docs-ai/030-agent-status-detection/000-plan.md index e93c3255..968ef08d 100644 --- a/docs-ai/030-agent-status-detection/000-plan.md +++ b/docs-ai/030-agent-status-detection/000-plan.md @@ -131,3 +131,7 @@ the failed attempt to extend the first signal to plain commands is - Updated 2026-08-07: a 15-screen Claude/Codex captured corpus, executable quarantine, provenance validation, and Release classifier baseline are in place — see [009-captured-screen-fixture-corpus.md](009-captured-screen-fixture-corpus.md) +- Updated 2026-08-07: screen classification now returns a typed state/reason result, + 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) diff --git a/docs-ai/030-agent-status-detection/010-explainable-screen-detection-results.md b/docs-ai/030-agent-status-detection/010-explainable-screen-detection-results.md new file mode 100644 index 00000000..ccf4bd0e --- /dev/null +++ b/docs-ai/030-agent-status-detection/010-explainable-screen-detection-results.md @@ -0,0 +1,85 @@ +# 030.010 — Explainable Screen Detection Results: Action + +| | | +| --- | --- | +| **Status** | Implemented | +| **Date** | 2026-08-07 | +| **Branch** | `feat/agent-detection-reasons` | +| **PR** | TBD (stacked on #685) | +| **Plan** | [007-screen-profile-migration-plan.md](007-screen-profile-migration-plan.md), Phase 3 | + +## Result + +Screen classification now returns an internal typed result without changing any raw or +stabilized state: + +```swift +struct AgentScreenDetection { + let state: AgentRawState + let reason: AgentScreenDetectionReason +} +``` + +`detectState(in:)` remains the state-only compatibility projection. Every runtime still +uses its existing classifier in this phase and reports `legacy.detector`; profile-owned +rule matches and `fallback.noRuleMatched` become reachable only when the Codex and Claude +profiles migrate in later PRs. + +`AgentScreenRuleID` is a small string-backed value rather than a global rule enum. This +keeps future constants beside their runtime profile and avoids introducing a matcher DSL, +profile protocol, or shared priority table. + +## Cache and diagnostics + +`AgentScreenScan` now caches the complete `AgentScreenDetection` for the same exact +`(agent, active-screen text)` identity. Cache hits preserve both state and reason; changing +either the screen or detected runtime recomputes both. Stabilization, the 3-second working +hold, `.unknown` handling, polling cadence, and UI state remain untouched. + +Existing transition diagnostics append only the stable reason identifier. They never log +screen text. + +## CLI contract + +`prowl agents --json` adds optional `detection_reason`: + +- a future profile match emits its stable rule ID, such as `codex.directoryTrust`; +- an ordinary migrated-profile miss emits `fallback.noRuleMatched`; +- an unmigrated classifier emits `legacy.detector`; +- the field is omitted when no current screen result is available. + +The existing `prowl.cli.agents.v1` schema remains additive. Older app payloads without the +field still decode, while text-mode output and all app UI remain unchanged. The handler +projects state and reason from the same cached result, so a reason cannot describe a +different raw scan. Payloads contain no screen text. + +## Validation + +TDD evidence: + +- initial focused compile failed with 40 expected missing-type/API errors; +- diagnostic-reason coverage was separately observed red before adding reason plumbing; +- detection result, cache, corpus, heuristic, and CLI handler tests passed after + implementation; +- cache tests include a sentinel matched rule ID to prove a cache hit preserves the full + result rather than reconstructing only the state; +- CLI compatibility tests cover modern payload decoding, old payload decoding, and + unchanged text output. + +Executed verification: + +- focused app tests: 55 passed before diagnostic plumbing; 10 focused result/cache/CLI + tests passed after it; +- complete captured corpus remains green with unchanged current classification for all 15 + fixtures; +- full app suite: xcsift reported 2,281 passed; xcresult independently verified 2,283 + tests and zero failures; +- `make build-cli`, `make test-cli-smoke`, and `make test-cli-integration`: 68 integration + tests passed; +- `make check` and `make build-app` passed after final diagnostic plumbing; +- final Release corpus benchmark: pending committed code SHA. + +A second Debug app successfully served the new CLI socket, but the host was at the locked +`loginwindow`; Ghostty surfaces therefore never became active and no live +`detection_reason` result was claimed. Retry the independent-app check when the GUI session +is unlocked, and again on the final simulated-integration branch. diff --git a/docs/components/agent-detection.md b/docs/components/agent-detection.md index 99b4a2c9..da72217c 100644 --- a/docs/components/agent-detection.md +++ b/docs/components/agent-detection.md @@ -58,6 +58,10 @@ returns the exact active-screen buffer used by stage 2. It is explicitly request because it can differ from the visible viewport when a pane is scrolled; the default `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. + To avoid flicker, detection **stabilizes**: it tolerates several consecutive 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 diff --git a/docs/components/cli.md b/docs/components/cli.md index 6735efc3..5c88b15e 100644 --- a/docs/components/cli.md +++ b/docs/components/cli.md @@ -122,6 +122,10 @@ Each agent contains: `pi`; Oh My Pi uses `omp`, with `oh-my-pi` preserved as a display alias. - `status`, `raw_state`: detected agent state. `status` is one of `blocked`, `working`, `done`, `idle`; `raw_state` is the lower-level detector state. +- `detection_reason`: optional stable screen-classifier explanation. A profile rule + emits its rule ID, an ordinary profile miss emits `fallback.noRuleMatched`, and an + unmigrated classifier emits `legacy.detector`. The field is omitted when no current + screen result is available and never includes screen text. - `last_changed_at`: ISO-8601 timestamp for the most recent state change. - `project`: display-oriented `name`, `branch`, `path` resolved from the agent's working directory. diff --git a/skills/prowl-cli/SKILL.md b/skills/prowl-cli/SKILL.md index e44d534d..b84e490b 100644 --- a/skills/prowl-cli/SKILL.md +++ b/skills/prowl-cli/SKILL.md @@ -133,7 +133,7 @@ Key fields by command (see `docs/components/cli.md` for the full contract): - `read` → `.data.text`, `.data.line_count`, `.data.truncated`, `.data.mode` (`snapshot`|`last`), `.data.source` (`screen`|`scrollback`|`mixed`|`detection`), plus `.data.stabilized` / `.data.waited_ms` / `.data.samples` when `--wait-stable`. - `send` → `.data.input` (source/characters/bytes/trailing_enter_sent); `.data.wait.exit_code` and `.data.wait.duration_ms` when waiting; `.data.capture.text` / `.data.capture.line_count` / `.data.capture.truncated` when `--capture`. -- `list` / `agents` → `.data.items[]` / `.data.agents[]`, each with `.pane.id`, `.tab.id`, `.worktree.{id,name,path}`, `.task.status`. +- `list` / `agents` → `.data.items[]` / `.data.agents[]`, each with `.pane.id`, `.tab.id`, and `.worktree.{id,name,path}`. Agent entries also include `.status`, `.raw_state`, and optional `.detection_reason`; list entries include `.task.status`. - `tab create` / `open` → `.data.target.{pane,tab,worktree}`. ## Reading Agent Output diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index 62c29207..1ea911f5 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -564,10 +564,10 @@ struct SupacodeApp: App { ) } let agentsHandler = AgentsCommandHandler { - var rawStatesBySurfaceID: [UUID: AgentRawState] = [:] + var screenDetectionsBySurfaceID: [UUID: AgentScreenDetection] = [:] for terminalState in terminalManager.activeWorktreeStates { - for (surfaceID, agentState) in terminalState.surfaceAgentStates { - rawStatesBySurfaceID[surfaceID] = agentState.fallbackState + for (surfaceID, scan) in terminalState.lastAgentScreenScanBySurface { + screenDetectionsBySurfaceID[surfaceID] = scan.detection } } return AgentsRuntimeSnapshot( @@ -576,7 +576,7 @@ struct SupacodeApp: App { repositoriesState: appStore.state.repositories, terminalManager: terminalManager ), - rawStatesBySurfaceID: rawStatesBySurfaceID + screenDetectionsBySurfaceID: screenDetectionsBySurfaceID ) } let sendHandler = SendCommandHandler( diff --git a/supacode/CLIService/AgentsCommandHandler.swift b/supacode/CLIService/AgentsCommandHandler.swift index 0ce2e2d3..f3f3786a 100644 --- a/supacode/CLIService/AgentsCommandHandler.swift +++ b/supacode/CLIService/AgentsCommandHandler.swift @@ -3,10 +3,10 @@ import Foundation struct AgentsRuntimeSnapshot { let repositoriesState: RepositoriesFeature.State let listSnapshot: ListRuntimeSnapshot - /// Live detector output keyed by pane. Reducer entries intentionally skip + /// Live detector results keyed by pane. Reducer entries intentionally skip /// raw-state-only changes to avoid invalidating the sidebar, so CLI snapshots - /// must source this field from terminal state instead. - let rawStatesBySurfaceID: [UUID: AgentRawState] + /// must source state and reason from terminal state instead. + let screenDetectionsBySurfaceID: [UUID: AgentScreenDetection] } final class AgentsCommandHandler: CommandHandler { @@ -82,12 +82,14 @@ final class AgentsCommandHandler: CommandHandler { let projectPath = projectPath( for: entry, repositoriesState: repositoriesState, worktreeContexts: worktreeContexts) + let screenDetection = snapshot.screenDetectionsBySurfaceID[entry.surfaceID] return AgentsCommandAgent( id: entry.surfaceID.uuidString, type: entry.agent.rawValue, name: entry.displayName, status: AgentsCommandStatus(rawValue: entry.displayState.rawValue) ?? .idle, - rawState: (snapshot.rawStatesBySurfaceID[entry.surfaceID] ?? entry.rawState).rawValue, + rawState: (screenDetection?.state ?? entry.rawState).rawValue, + detectionReason: screenDetection?.reason.identifier, lastChangedAt: dateFormatter.string(from: entry.lastChangedAt), project: AgentsCommandProject( name: display.repositoryName, diff --git a/supacode/CLIService/Shared/AgentsCommandPayload.swift b/supacode/CLIService/Shared/AgentsCommandPayload.swift index 152e996d..a1038874 100644 --- a/supacode/CLIService/Shared/AgentsCommandPayload.swift +++ b/supacode/CLIService/Shared/AgentsCommandPayload.swift @@ -16,6 +16,7 @@ public struct AgentsCommandAgent: Codable, Equatable { public let name: String public let status: AgentsCommandStatus public let rawState: String + public let detectionReason: String? public let lastChangedAt: String public let project: AgentsCommandProject public let worktree: AgentsCommandWorktree @@ -29,6 +30,7 @@ public struct AgentsCommandAgent: Codable, Equatable { case name case status case rawState = "raw_state" + case detectionReason = "detection_reason" case lastChangedAt = "last_changed_at" case project case worktree @@ -43,6 +45,7 @@ public struct AgentsCommandAgent: Codable, Equatable { name: String, status: AgentsCommandStatus, rawState: String, + detectionReason: String? = nil, lastChangedAt: String, project: AgentsCommandProject, worktree: AgentsCommandWorktree, @@ -55,6 +58,7 @@ public struct AgentsCommandAgent: Codable, Equatable { self.name = name self.status = status self.rawState = rawState + self.detectionReason = detectionReason self.lastChangedAt = lastChangedAt self.project = project self.worktree = worktree diff --git a/supacode/Domain/AgentDetection/AgentScreenDetection.swift b/supacode/Domain/AgentDetection/AgentScreenDetection.swift new file mode 100644 index 00000000..ef97e2ca --- /dev/null +++ b/supacode/Domain/AgentDetection/AgentScreenDetection.swift @@ -0,0 +1,30 @@ +struct AgentScreenRuleID: Equatable, Hashable, Sendable { + let rawValue: String + + init(_ rawValue: String) { + precondition(!rawValue.isEmpty, "Agent screen rule IDs must not be empty.") + self.rawValue = rawValue + } +} + +enum AgentScreenDetectionReason: Equatable, Sendable { + case matched(AgentScreenRuleID) + case noRuleMatched + case legacyDetector + + nonisolated var identifier: String { + switch self { + case .matched(let ruleID): + return ruleID.rawValue + case .noRuleMatched: + return "fallback.noRuleMatched" + case .legacyDetector: + return "legacy.detector" + } + } +} + +struct AgentScreenDetection: Equatable, Sendable { + let state: AgentRawState + let reason: AgentScreenDetectionReason +} diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift index 70ef9134..06f2be9a 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift @@ -85,6 +85,7 @@ extension WorktreeTerminalState { identified: identified, retainedAgent: nil, raw: nil, + reason: nil, stabilized: nil ) ) @@ -102,7 +103,8 @@ extension WorktreeTerminalState { // identical text is the bulk of steady-state detection cost. Time-based // stabilization below still runs every tick, so working→idle decay is // unaffected. - let raw = cachedRawState(forSurfaceID: surfaceID, agent: agent, text: activeText) + let detection = cachedScreenDetection(forSurfaceID: surfaceID, agent: agent, text: activeText) + let raw = detection.state guard surfaces[surfaceID] != nil else { return false } var lastWorkingAt = lastWorkingAtBySurface[surfaceID] @@ -115,18 +117,11 @@ extension WorktreeTerminalState { ) lastWorkingAtBySurface[surfaceID] = lastWorkingAt - let isForeground = isSelected() && isFocusedSurface(surfaceID) - let becameIdleFromActive = - (previous.state == .working || previous.state == .blocked) - && stabilized == .idle - let seen: Bool - if isForeground || stabilized == .blocked { - seen = true - } else if becameIdleFromActive { - seen = false - } else { - seen = previous.seen - } + let seen = Self.resolvedSeen( + previous: previous, + stabilized: stabilized, + isForeground: isSelected() && isFocusedSurface(surfaceID) + ) let iconLookupToken = identified?.iconLookupToken ?? previous.iconLookupToken ?? agent.iconLookupToken let workingDirectory = activeAgentWorkingDirectory(surfaceID: surfaceID) let (session, sessionMissStreak) = await resolveRetainedSession( @@ -170,6 +165,7 @@ extension WorktreeTerminalState { identified: identified, retainedAgent: agent, raw: raw, + reason: detection.reason, stabilized: stabilized ) ) @@ -181,34 +177,56 @@ extension WorktreeTerminalState { return true } - /// Resolves the raw agent state for `text`, reusing `cache` when it already - /// holds a scan for the same `agent` and identical `text`. Returns the raw - /// state and the scan to store back for the next call. + /// Resolves the screen detection for `text`, reusing `cache` when it already + /// holds a scan for the same `agent` and identical `text`. Returns the full + /// detection and the scan to store back for the next call. /// - /// `detectState` is a `nonisolated` pure function of the screen, so reusing + /// `detectScreen` is a `nonisolated` pure function of the screen, so reusing /// its result for identical input is exactly equivalent to recomputing it. /// It runs inline (no `Task.detached`): the detached hop bought only allocator /// churn — over a long session each tick left a task stack + closure capture /// that never reached ARC, adding up to hundreds of MB of unreferenced /// allocations. - nonisolated static func resolveRawState( + nonisolated static func resolveScreenDetection( agent: DetectedAgent, text: String, cache: AgentScreenScan? - ) -> (raw: AgentRawState, scan: AgentScreenScan) { + ) -> (detection: AgentScreenDetection, scan: AgentScreenScan) { if let cache, cache.agent == agent, cache.text == text { - return (cache.raw, cache) + return (cache.detection, cache) } - let raw = agent.detectState(in: text) - return (raw, AgentScreenScan(agent: agent, text: text, raw: raw)) + let detection = agent.detectScreen(in: text) + return (detection, AgentScreenScan(agent: agent, text: text, detection: detection)) } - /// Instance wrapper over `resolveRawState` that reads and writes the per-surface - /// memo, keeping `detectAgentState` to a single line at the call site. - private func cachedRawState(forSurfaceID surfaceID: UUID, agent: DetectedAgent, text: String) -> AgentRawState { - let (raw, scan) = Self.resolveRawState(agent: agent, text: text, cache: lastAgentScreenScanBySurface[surfaceID]) + /// Instance wrapper over `resolveScreenDetection` that reads and writes the + /// per-surface memo, keeping `detectAgentState` concise at the call site. + private func cachedScreenDetection( + forSurfaceID surfaceID: UUID, + agent: DetectedAgent, + text: String + ) -> AgentScreenDetection { + let (detection, scan) = Self.resolveScreenDetection( + agent: agent, + text: text, + cache: lastAgentScreenScanBySurface[surfaceID] + ) lastAgentScreenScanBySurface[surfaceID] = scan - return raw + return detection + } + + private static func resolvedSeen( + previous: PaneAgentState, + stabilized: AgentRawState, + isForeground: Bool + ) -> Bool { + if isForeground || stabilized == .blocked { + return true + } + if (previous.state == .working || previous.state == .blocked) && stabilized == .idle { + return false + } + return previous.seen } private func resolvedLaunchObservation( @@ -471,27 +489,9 @@ extension WorktreeTerminalState { } } - func agentDetectionDiagnosticMessage(_ diagnostic: AgentDetectionDiagnostic) -> String { - let processSummary = - diagnostic.job?.processes - .map { "\($0.pid):\($0.argv0 ?? $0.name)" } - .joined(separator: ",") ?? "none" - return [ - "tab=\(diagnostic.tabId.rawValue.uuidString.prefix(8))", - "childPID=\(diagnostic.childPID.map(String.init) ?? "nil")", - "ptyPGID=\(diagnostic.processGroupID.map(String.init) ?? "nil")", - "fgPGID=\(diagnostic.job.map { String($0.processGroupID) } ?? "nil")", - "processes=\(processSummary)", - "identified=\(diagnostic.identified.map { "\($0.agent.rawValue)(\($0.name))" } ?? "nil")", - "retained=\(diagnostic.retainedAgent?.rawValue ?? "nil")", - "raw=\(diagnostic.raw?.rawValue ?? "nil")", - "state=\(diagnostic.stabilized?.rawValue ?? "nil")", - ].joined(separator: " ") - } - func logAgentDetectionDiagnostic(surfaceID: UUID, diagnostic: AgentDetectionDiagnostic) { #if DEBUG - let message = agentDetectionDiagnosticMessage(diagnostic) + let message = diagnostic.summary guard lastAgentDetectionDiagnosticsBySurface[surfaceID] != message else { return } lastAgentDetectionDiagnosticsBySurface[surfaceID] = message terminalStateLogger.debug( diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 2dc08975..5be53523 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -50,7 +50,27 @@ struct AgentDetectionDiagnostic { let identified: IdentifiedAgentProcess? let retainedAgent: DetectedAgent? let raw: AgentRawState? + let reason: AgentScreenDetectionReason? let stabilized: AgentRawState? + + var summary: String { + let processSummary = + job?.processes + .map { "\($0.pid):\($0.argv0 ?? $0.name)" } + .joined(separator: ",") ?? "none" + return [ + "tab=\(tabId.rawValue.uuidString.prefix(8))", + "childPID=\(childPID.map(String.init) ?? "nil")", + "ptyPGID=\(processGroupID.map(String.init) ?? "nil")", + "fgPGID=\(job.map { String($0.processGroupID) } ?? "nil")", + "processes=\(processSummary)", + "identified=\(identified.map { "\($0.agent.rawValue)(\($0.name))" } ?? "nil")", + "retained=\(retainedAgent?.rawValue ?? "nil")", + "raw=\(raw?.rawValue ?? "nil")", + "reason=\(reason?.identifier ?? "nil")", + "state=\(stabilized?.rawValue ?? "nil")", + ].joined(separator: " ") + } } @MainActor @@ -61,13 +81,13 @@ final class WorktreeTerminalState { let isFocused: Bool } - /// One memoized agent-screen scan: the `raw` state `detectState` produced for + /// One memoized agent-screen scan: the complete detection result produced for /// `text` under `agent`. Cached per surface so an unchanged screen is not - /// re-parsed on the next poll. + /// re-parsed on the next poll and retains the rule or fallback reason. struct AgentScreenScan: Equatable { let agent: DetectedAgent let text: String - let raw: AgentRawState + let detection: AgentScreenDetection } let tabManager: TerminalTabManager diff --git a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift index b57e7740..23558023 100644 --- a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift +++ b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift @@ -4,7 +4,17 @@ nonisolated let agentDetectionRecentLineLimit = 24 extension DetectedAgent { nonisolated func detectState(in screen: String) -> AgentRawState { - let screen = agentDetectionRecentText(screen) + detectScreen(in: screen).state + } + + nonisolated func detectScreen(in screen: String) -> AgentScreenDetection { + AgentScreenDetection( + state: detectLegacyState(in: agentDetectionRecentText(screen)), + reason: .legacyDetector + ) + } + + nonisolated private func detectLegacyState(in screen: String) -> AgentRawState { switch self { case .pi: return detectPi(screen) diff --git a/supacodeTests/AgentScreenDetectionTests.swift b/supacodeTests/AgentScreenDetectionTests.swift new file mode 100644 index 00000000..652f55ca --- /dev/null +++ b/supacodeTests/AgentScreenDetectionTests.swift @@ -0,0 +1,42 @@ +import Foundation +import Testing + +@testable import supacode + +struct AgentScreenDetectionTests { + @Test func legacyDetectorsReturnTheirExistingStateWithAStableReason() { + let screen = "screen without a live rule" + + for agent in DetectedAgent.allCases { + let detection = agent.detectScreen(in: screen) + + #expect(detection.state == agent.detectState(in: screen)) + #expect(detection.reason == .legacyDetector) + #expect(detection.reason.identifier == "legacy.detector") + } + } + + @Test func transitionDiagnosticsIncludeTheStableReasonWithoutScreenText() { + let diagnostic = AgentDetectionDiagnostic( + tabId: TerminalTabID(rawValue: UUID()), + childPID: nil, + processGroupID: nil, + job: nil, + identified: nil, + retainedAgent: .codex, + raw: .blocked, + reason: .matched(AgentScreenRuleID("codex.directoryTrust")), + stabilized: .blocked + ) + + #expect(diagnostic.summary.contains("reason=codex.directoryTrust")) + #expect(!diagnostic.summary.contains("screen=")) + } + + @Test func reasonIdentifiersDistinguishMatchesAndFallbacks() { + let ruleID = AgentScreenRuleID("codex.directoryTrust") + + #expect(AgentScreenDetectionReason.matched(ruleID).identifier == "codex.directoryTrust") + #expect(AgentScreenDetectionReason.noRuleMatched.identifier == "fallback.noRuleMatched") + } +} diff --git a/supacodeTests/AgentScreenScanCacheTests.swift b/supacodeTests/AgentScreenScanCacheTests.swift index 08587c05..5c62515d 100644 --- a/supacodeTests/AgentScreenScanCacheTests.swift +++ b/supacodeTests/AgentScreenScanCacheTests.swift @@ -4,50 +4,85 @@ import Testing struct AgentScreenScanCacheTests { /// With no cache, the helper scans from scratch and returns a scan that - /// round-trips the inputs and the freshly computed raw state. + /// round-trips the inputs and the freshly computed detection result. @Test func scansFromScratchWithoutCache() { - let (raw, scan) = WorktreeTerminalState.resolveRawState( + let (detection, scan) = WorktreeTerminalState.resolveScreenDetection( agent: .claude, text: "screen", cache: nil ) - #expect(raw == DetectedAgent.claude.detectState(in: "screen")) - #expect(scan == WorktreeTerminalState.AgentScreenScan(agent: .claude, text: "screen", raw: raw)) + #expect(detection == DetectedAgent.claude.detectScreen(in: "screen")) + #expect( + scan + == WorktreeTerminalState.AgentScreenScan( + agent: .claude, + text: "screen", + detection: detection + ) + ) } - /// When the cached agent and text both match, the helper returns the cached - /// raw state without recomputing. Proven by seeding a sentinel raw a fresh - /// scan would never produce and asserting it comes back unchanged. - @Test func reusesCachedRawWhenAgentAndTextMatch() { + /// When the cached agent and text both match, the helper returns the complete + /// cached result without recomputing. A sentinel rule ID proves the reason, + /// not only the raw state, survives the cache hit. + @Test func reusesCachedDetectionWhenAgentAndTextMatch() { let text = "" - let sentinel = WorktreeTerminalState.AgentScreenScan(agent: .claude, text: text, raw: .blocked) - #expect(DetectedAgent.claude.detectState(in: text) != .blocked) + let sentinel = AgentScreenDetection( + state: .blocked, + reason: .matched(AgentScreenRuleID("test.sentinel")) + ) + let cachedScan = WorktreeTerminalState.AgentScreenScan( + agent: .claude, + text: text, + detection: sentinel + ) + #expect(DetectedAgent.claude.detectScreen(in: text) != sentinel) - let (raw, scan) = WorktreeTerminalState.resolveRawState(agent: .claude, text: text, cache: sentinel) + let (detection, scan) = WorktreeTerminalState.resolveScreenDetection( + agent: .claude, + text: text, + cache: cachedScan + ) - #expect(raw == .blocked) - #expect(scan == sentinel) + #expect(detection == sentinel) + #expect(scan == cachedScan) } /// A changed screen invalidates the cache and forces a rescan. @Test func rescansWhenTextChanges() { - let cache = WorktreeTerminalState.AgentScreenScan(agent: .claude, text: "old", raw: .blocked) + let cachedScan = WorktreeTerminalState.AgentScreenScan( + agent: .claude, + text: "old", + detection: AgentScreenDetection(state: .blocked, reason: .legacyDetector) + ) - let (raw, scan) = WorktreeTerminalState.resolveRawState(agent: .claude, text: "new", cache: cache) + let (detection, scan) = WorktreeTerminalState.resolveScreenDetection( + agent: .claude, + text: "new", + cache: cachedScan + ) - #expect(raw == DetectedAgent.claude.detectState(in: "new")) + #expect(detection == DetectedAgent.claude.detectScreen(in: "new")) #expect(scan.text == "new") } /// A different detected agent invalidates the cache even when the text is - /// identical, since raw state is agent-specific. + /// identical, since screen detections are agent-specific. @Test func rescansWhenAgentChanges() { - let cache = WorktreeTerminalState.AgentScreenScan(agent: .codex, text: "screen", raw: .blocked) + let cachedScan = WorktreeTerminalState.AgentScreenScan( + agent: .codex, + text: "screen", + detection: AgentScreenDetection(state: .blocked, reason: .legacyDetector) + ) - let (raw, scan) = WorktreeTerminalState.resolveRawState(agent: .claude, text: "screen", cache: cache) + let (detection, scan) = WorktreeTerminalState.resolveScreenDetection( + agent: .claude, + text: "screen", + cache: cachedScan + ) - #expect(raw == DetectedAgent.claude.detectState(in: "screen")) + #expect(detection == DetectedAgent.claude.detectScreen(in: "screen")) #expect(scan.agent == .claude) } } diff --git a/supacodeTests/CLIAgentsCommandHandlerTests.swift b/supacodeTests/CLIAgentsCommandHandlerTests.swift index e777e22a..ceb5a3c1 100644 --- a/supacodeTests/CLIAgentsCommandHandlerTests.swift +++ b/supacodeTests/CLIAgentsCommandHandlerTests.swift @@ -33,6 +33,7 @@ struct CLIAgentsCommandHandlerTests { // the sidebar does not re-render. CLI snapshots must use the live terminal // raw state instead of that UI-deduplicated value. #expect(agent.rawState == "working") + #expect(agent.detectionReason == "omp.askPrompt") #expect(agent.lastChangedAt == "2026-09-21T14:00:00Z") #expect(agent.project.name == "Prowl") #expect(agent.project.branch == "feature/agents") @@ -56,12 +57,14 @@ struct CLIAgentsCommandHandlerTests { let idleAgent = payload.agents[1] #expect(idleAgent.status == .idle) + #expect(idleAgent.detectionReason == nil) #expect(idleAgent.project.name == "Tab Repo") #expect(idleAgent.project.branch == "main") #expect(idleAgent.pane.focused == false) let rawPayload = try #require(response.data?.bytes) let rawPayloadString = try #require(String(bytes: rawPayload, encoding: .utf8)) #expect(!rawPayloadString.contains("\"handle\"")) + #expect(rawPayloadString.contains("\"detection_reason\":\"omp.askPrompt\"")) } @Test func includesPaneHandlesOnlyInTextPayload() async throws { @@ -183,7 +186,12 @@ struct CLIAgentsCommandHandlerTests { tabID: tabID, tabWorktree: tabWorktree ), - rawStatesBySurfaceID: [tabPaneID: .working] + screenDetectionsBySurfaceID: [ + tabPaneID: AgentScreenDetection( + state: .working, + reason: .matched(AgentScreenRuleID("omp.askPrompt")) + ) + ] ) ) } -- 2.51.2