diff --git a/ProwlCLI/Commands/ReadCommand.swift b/ProwlCLI/Commands/ReadCommand.swift index 1bd7fd94..4af3d143 100644 --- a/ProwlCLI/Commands/ReadCommand.swift +++ b/ProwlCLI/Commands/ReadCommand.swift @@ -3,6 +3,18 @@ import ArgumentParser import ProwlCLIShared +private enum ReadSourceOption: String, ExpressibleByArgument { + case viewport + case detection + + var inputSource: ReadInputSource { + switch self { + case .viewport: .viewport + case .detection: .detection + } + } +} + struct ReadCommand: ParsableCommand { static let configuration = CommandConfiguration( commandName: "read", @@ -15,6 +27,12 @@ struct ReadCommand: ParsableCommand { @Option(name: .long, help: "Number of recent lines to read (omit for snapshot).") var last: Int? + @Option( + name: .long, + help: "Content source: viewport, or detection for the exact active-screen agent detector input." + ) + private var source: ReadSourceOption = .viewport + @Flag(name: .long, help: "Re-read the pane until its output stops changing before returning (good for live TUIs).") var waitStable = false @@ -57,6 +75,7 @@ struct ReadCommand: ParsableCommand { command: .read(ReadInput( selector: sel, last: last, + source: source.inputSource, waitStable: waitStable, stableIntervalMs: stableInterval, stablePeriodMs: stablePeriod, diff --git a/ProwlCLITests/ProwlCLIIntegrationTests.swift b/ProwlCLITests/ProwlCLIIntegrationTests.swift index b23202a0..96758544 100644 --- a/ProwlCLITests/ProwlCLIIntegrationTests.swift +++ b/ProwlCLITests/ProwlCLIIntegrationTests.swift @@ -1391,6 +1391,7 @@ final class ProwlCLIIntegrationTests: XCTestCase { if case .read(let input) = envelope.command { XCTAssertEqual(input.selector, .pane("pane-123")) XCTAssertEqual(input.last, 5) + XCTAssertEqual(input.source, .viewport) } else { XCTFail("Expected read command envelope") } @@ -1401,6 +1402,52 @@ final class ProwlCLIIntegrationTests: XCTestCase { XCTAssertEqual(payload["schema_version"] as? String, "prowl.cli.read.v1") } + func testReadDetectionSourcePassesExactSourceToApp() throws { + let socketPath = temporarySocketPath(suffix: "read-detection") + let response = try CommandResponse( + ok: true, + command: "read", + schemaVersion: "prowl.cli.read.v1", + data: RawJSON(encoding: ReadResponseData( + target: ReadResponseTarget( + worktree: ListWorktree( + id: "Prowl:/Projects/Prowl", + name: "Prowl", + path: "/Projects/Prowl", + rootPath: "/Projects/Prowl", + kind: "git" + ), + tab: ReadResponseTab(id: "tab-1", title: "Prowl 1", selected: true), + pane: ReadResponsePane(id: "pane-1", title: "zsh", cwd: "/Projects/Prowl", focused: true) + ), + mode: "snapshot", + last: nil, + source: "detection", + truncated: false, + lineCount: 2, + text: "active-line-1\nactive-line-2" + )) + ) + + let (requestData, result) = try runWithMockServer( + socketPath: socketPath, + response: response, + args: ["read", "--source", "detection", "--json"] + ) + + XCTAssertEqual(result.exitCode, 0) + let envelope = try JSONDecoder().decode(CommandEnvelope.self, from: requestData) + if case .read(let input) = envelope.command { + XCTAssertEqual(input.source, .detection) + } else { + XCTFail("Expected read command envelope") + } + + let payload = try jsonObject(from: result.stdout) + let data = try XCTUnwrap(payload["data"] as? [String: Any]) + XCTAssertEqual(data["source"] as? String, "detection") + } + func testReadWithoutLastDefaultsToSnapshot() throws { let socketPath = temporarySocketPath(suffix: "read-snapshot") let response = CommandResponse( diff --git a/docs-ai/030-agent-status-detection/000-plan.md b/docs-ai/030-agent-status-detection/000-plan.md index c25b8a5e..1eb543e2 100644 --- a/docs-ai/030-agent-status-detection/000-plan.md +++ b/docs-ai/030-agent-status-detection/000-plan.md @@ -125,3 +125,6 @@ the failed attempt to extend the first signal to plain commands is - Updated 2026-08-06: a staged migration to typed Claude/Codex profiles, detector-faithful captures, versioned fixtures, and explainable reasons is proposed — see [007-screen-profile-migration-plan.md](007-screen-profile-migration-plan.md) +- Updated 2026-08-06: `prowl read --source detection` now captures the exact active-screen + buffer used by production state detection — see + [008-detector-faithful-cli-capture.md](008-detector-faithful-cli-capture.md) diff --git a/docs-ai/030-agent-status-detection/008-detector-faithful-cli-capture.md b/docs-ai/030-agent-status-detection/008-detector-faithful-cli-capture.md new file mode 100644 index 00000000..9d36073d --- /dev/null +++ b/docs-ai/030-agent-status-detection/008-detector-faithful-cli-capture.md @@ -0,0 +1,76 @@ +# 030.008 — Detector-Faithful CLI Capture Source + +| | | +| --- | --- | +| **Status** | Implemented | +| **Anchor date** | 2026-08-06 | +| **Primary PR** | TBD | +| **Plan** | [007-screen-profile-migration-plan.md](007-screen-profile-migration-plan.md), Phase 1 | +| **Related** | `docs/components/cli.md`, `docs/components/agent-detection.md` | + +## Context + +The production state detector reads `GHOSTTY_POINT_ACTIVE` through +`GhosttySurfaceBridge.readActiveText()`. Before this change, `prowl read` used +`GHOSTTY_POINT_VIEWPORT`; a scrolled pane could therefore produce a capture different +from the detector's actual input. A versioned fixture corpus built from that command +would have false provenance. + +## Change + +`prowl read` now accepts an explicit source: + +```bash +prowl read --pane "$pane" --source detection --json +``` + +- `viewport` is the default and preserves all existing snapshot/scrollback behavior. +- `detection` reads `GhosttySurfaceView.readActiveContentsForCLI()`, the same method + reached by production `readActiveText()`. +- The success payload reports `source: "detection"`; fixture tooling must verify this + value before accepting text. +- Omitting `--last` returns the complete active buffer unchanged. `--last` remains a + supported line projection but is not used for detector fixture capture. +- `--wait-stable` polls only the requested source. Normal reads do not pay for an extra + Ghostty text read. + +The read request source decodes to `viewport` when absent, preserving requests from an +older CLI. The app's capture provider carries the requested source explicitly and rejects +a mismatched provider result. The response schema remains `prowl.cli.read.v1`; the new +source value appears only when explicitly requested. + +Raw screen content is returned only to the explicit `read` caller. It is not logged, +added to status payloads, or committed automatically. CLI/component/skill documentation +requires private staging, source verification, and redaction before a fixture is added. + +## Validation + +Test-first failures established the missing parser and handler contracts. After the +implementation: + +- `CLIReadCommandHandlerTests`: 18 passed, including exact trailing-newline preservation, + detection-only `--last`, mismatched-source rejection, and legacy request decoding. +- SwiftPM: 75 passed. +- CLI integration filter: 65 passed. +- Full app suite: xcsift reported 2,272 passed; the xcresult backstop independently + verified 2,275 tests and zero failures. +- `make test-cli-smoke`, `make build-cli`, `make test-cli-integration`, `make check`, and + `make build-app` passed. + +A second Debug app was launched against an isolated Unix socket and temporary plain +workspace. After printing 120 synthetic sentinel lines and scrolling its real Ghostty +viewport with an AppKit/CGEvent input path: + +- default read returned `source: "screen"`; +- detection read returned `source: "detection"`; +- viewport and active-buffer SHA-256 values differed; +- the detection capture retained the newest sentinel while the scrolled viewport did not. + +No raw user or agent content was used in this live verification. + +## Result + +Phase 1's exit condition is met: a live pane's exact detector input can be captured +reproducibly without a local patch or Debug-only app facility. Fixture loading, +provenance metadata, redaction policy enforcement, and fresh Claude/Codex captures remain +Phase 2 work. diff --git a/docs/components/agent-detection.md b/docs/components/agent-detection.md index 7580630b..99b4a2c9 100644 --- a/docs/components/agent-detection.md +++ b/docs/components/agent-detection.md @@ -53,6 +53,11 @@ at a `~/.grok/` install (so Cursor's own `agent` entrypoint stays Cursor). ended; Prowl reads that footer as **Working**, so a churning workflow isn't mistaken for idle. +For diagnostics and sanitized regression captures, `prowl read --source detection` +returns the exact active-screen buffer used by stage 2. It is explicitly requested +because it can differ from the visible viewport when a pane is scrolled; the default +`prowl read` behavior is 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 0bc6d77b..4de0a397 100644 --- a/docs/components/cli.md +++ b/docs/components/cli.md @@ -153,6 +153,9 @@ It prints a pane handle such as `p7` for each agent; use it as Read a pane's content. - `--last ` — last N lines (scrollback + screen); omit for a full snapshot. +- `--source ` — `viewport` preserves the normal read behavior + (default); `detection` reads the exact active-screen buffer used by agent-state + detection, which can differ from the viewport when a pane is scrolled. - `--wait-stable` — re-read until the screen stops changing (best for live TUIs). - `--stable-interval <50–5000ms>` (default 200), `--stable-period <100–60000ms>` (default 800), `--wait-timeout <1–300s>` (default 10) — tune the stable wait. @@ -160,11 +163,23 @@ Read a pane's content. ```bash prowl read --pane "$pane" --last 200 --wait-stable --json ``` -Response includes `mode` (snapshot|last), `source` (screen|scrollback|mixed), -`truncated`, `line_count`, `text`, and (when waiting) `stabilized`, `waited_ms`, -`samples`. **`truncated: false` with fewer lines than `--last` just means the pane -has less history — don't retry.** `truncated: true` flags a possibly-incomplete -read. +Response includes `mode` (snapshot|last), `source` +(screen|scrollback|mixed|detection), `truncated`, `line_count`, `text`, and (when +waiting) `stabilized`, `waited_ms`, `samples`. **`truncated: false` with fewer +lines than `--last` just means the pane has less history — don't retry.** +`truncated: true` flags a possibly-incomplete read. + +For detector regression captures, omit `--last`, require the returned source, and +extract the JSON string without adding a newline: + +```bash +capture="$(prowl read --pane "$pane" --source detection --json)" +printf '%s\n' "$capture" | jq -e '.data.source == "detection"' >/dev/null +printf '%s\n' "$capture" | jq -j '.data.text' > /path/to/private/raw-capture.txt +``` + +This is a diagnostic/capture source, not a more complete terminal-history read; +normal pane inspection should keep the default viewport source. ### `prowl send [target] [text]` Type into a pane, optionally wait for completion and capture output. diff --git a/skills/prowl-cli/SKILL.md b/skills/prowl-cli/SKILL.md index 8cd0bc9c..7c44108e 100644 --- a/skills/prowl-cli/SKILL.md +++ b/skills/prowl-cli/SKILL.md @@ -131,7 +131,7 @@ prowl list --json | jq -e '.ok == true' >/dev/null || echo "command failed" 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`), plus `.data.stabilized` / `.data.waited_ms` / `.data.samples` when `--wait-stable`. +- `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`. - `tab create` / `open` → `.data.target.{pane,tab,worktree}`. @@ -157,6 +157,16 @@ done prowl read --pane "$pane" --last 200 --wait-stable --json ``` +The default read source follows the viewport. Only when diagnosing agent-state detection or collecting a sanitized detector fixture, request the exact active-screen detector input and verify the returned source before trusting it: + +```bash +capture="$(prowl read --pane "$pane" --source detection --json)" +printf '%s\n' "$capture" | jq -e '.data.source == "detection"' >/dev/null +printf '%s\n' "$capture" | jq -j '.data.text' > /path/to/private/raw-capture.txt +``` + +Omit `--last` for detector captures. A scrolled pane's detection source can differ from its viewport. Treat the raw capture as private and redact it before committing any fixture. + When you need complete output from an agent, prefer writing or redirecting to a file over reading rendered TUI output. Screen capture can be truncated or miss folded content. For non-interactive agent CLIs, redirect stdout from the shell instead of asking the agent's tool layer to write outside its sandbox: diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index 1490eb2f..62c29207 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -526,6 +526,30 @@ struct SupacodeApp: App { } } + private static func makeReadCapture( + request: ReadCaptureRequest, + terminalManager: WorktreeTerminalManager + ) -> ReadCaptureInput? { + let target = request.target + guard let state = terminalManager.stateIfExists(for: target.worktreeID), + let surface = state.surfaceView(for: target.paneID) + else { + return nil + } + + switch request.source { + case .viewport: + guard let viewportText = surface.readViewportContentsForCLI() else { return nil } + return ReadCaptureInput( + viewportText: viewportText, + screenText: surface.readScreenContentsForCLI() + ) + case .detection: + guard let detectionText = surface.readActiveContentsForCLI() else { return nil } + return ReadCaptureInput(detectionText: detectionText) + } + } + // swiftlint:disable:next function_body_length static func makeCLICommandRouter( appStore: StoreOf, @@ -630,17 +654,8 @@ struct SupacodeApp: App { } return resolver.resolve(selector).map { ReadResolvedTarget(from: $0) } }, - captureProvider: { target in - guard let state = terminalManager.stateIfExists(for: target.worktreeID), - let surface = state.surfaceView(for: target.paneID), - let viewportText = surface.readViewportContentsForCLI() - else { - return nil - } - return ReadCaptureInput( - viewportText: viewportText, - screenText: surface.readScreenContentsForCLI() - ) + captureProvider: { request in + Self.makeReadCapture(request: request, terminalManager: terminalManager) } ) let keyHandler = KeyCommandHandler( diff --git a/supacode/CLIService/ReadCommandHandler.swift b/supacode/CLIService/ReadCommandHandler.swift index 3fdc7d8a..6107250f 100644 --- a/supacode/CLIService/ReadCommandHandler.swift +++ b/supacode/CLIService/ReadCommandHandler.swift @@ -9,9 +9,39 @@ private struct ReadCapture { let truncated: Bool } +struct ReadCaptureRequest: Sendable { + let target: ReadResolvedTarget + let source: ReadInputSource +} + struct ReadCaptureInput: Sendable { - let viewportText: String - let screenText: String? + enum Content: Sendable { + case viewport(text: String, screenText: String?) + case detection(text: String) + } + + let content: Content + + var viewportText: String { + switch content { + case .viewport(let text, _), .detection(let text): text + } + } + + var screenText: String? { + switch content { + case .viewport(_, let screenText): screenText + case .detection: nil + } + } + + init(viewportText: String, screenText: String?) { + self.content = .viewport(text: viewportText, screenText: screenText) + } + + init(detectionText: String) { + self.content = .detection(text: detectionText) + } } /// Resolved target metadata for read payload construction. @@ -50,7 +80,7 @@ extension ReadResolvedTarget { @MainActor final class ReadCommandHandler: CommandHandler { typealias ResolveProvider = @MainActor (TargetSelector) -> Result - typealias CaptureProvider = @MainActor (ReadResolvedTarget) -> ReadCaptureInput? + typealias CaptureProvider = @MainActor (ReadCaptureRequest) -> ReadCaptureInput? private let resolveProvider: ResolveProvider private let captureProvider: CaptureProvider @@ -92,7 +122,7 @@ final class ReadCommandHandler: CommandHandler { waitedMs = result.waitedMs samples = result.samples } else { - guard let single = makeCapture(target: target, last: input.last) else { + guard let single = makeCapture(target: target, input: input) else { return errorResponse(code: CLIErrorCode.readFailed, message: "Failed to read terminal text.") } capture = single @@ -140,21 +170,42 @@ final class ReadCommandHandler: CommandHandler { static let timeoutSeconds = 10 } - /// Capture the pane's current content, applying `--last` line selection when requested. - private func makeCapture(target: ReadResolvedTarget, last: Int?) -> ReadCapture? { - guard let captureInput = captureProvider(target) else { return nil } - if let last { - return captureLast( - requestedLineCount: last, - viewportText: captureInput.viewportText, - screenText: captureInput.screenText + /// Capture the requested terminal source, applying `--last` line selection when requested. + private func makeCapture(target: ReadResolvedTarget, input: ReadInput) -> ReadCapture? { + let request = ReadCaptureRequest(target: target, source: input.source) + guard let captureInput = captureProvider(request) else { return nil } + + switch (input.source, captureInput.content) { + case (.viewport, .viewport(let viewportText, let screenText)): + if let last = input.last { + return captureLast( + requestedLineCount: last, + viewportText: viewportText, + screenText: screenText + ) + } + return ReadCapture( + text: viewportText, + source: .screen, + truncated: false + ) + + case (.detection, .detection(let text)): + let output: String + if let last = input.last { + output = joinLines(splitLines(text).suffix(last)) + } else { + output = text + } + return ReadCapture( + text: output, + source: .detection, + truncated: false ) + + default: + return nil } - return ReadCapture( - text: captureInput.viewportText, - source: .screen, - truncated: false - ) } /// Re-read the pane on a fixed interval until its content stops changing for a streak of @@ -170,7 +221,7 @@ final class ReadCommandHandler: CommandHandler { let requiredStreak = max(1, Int((Double(periodMs) / Double(intervalMs)).rounded(.up))) let maxSleeps = max(1, Int((Double(timeoutMs) / Double(intervalMs)).rounded(.up))) - guard var current = makeCapture(target: target, last: input.last) else { return nil } + guard var current = makeCapture(target: target, input: input) else { return nil } var streak = 0 var samples = 1 var sleeps = 0 @@ -191,7 +242,7 @@ final class ReadCommandHandler: CommandHandler { break // Cancelled: return the best capture so far. } sleeps += 1 - guard let next = makeCapture(target: target, last: input.last) else { + guard let next = makeCapture(target: target, input: input) else { break // Capture became unavailable mid-poll (e.g. pane closed): stop with what we have. } samples += 1 diff --git a/supacode/CLIService/Shared/InputModels.swift b/supacode/CLIService/Shared/InputModels.swift index f1ea2844..9adff204 100644 --- a/supacode/CLIService/Shared/InputModels.swift +++ b/supacode/CLIService/Shared/InputModels.swift @@ -109,9 +109,16 @@ public struct KeyInput: Codable, Sendable { } } +public enum ReadInputSource: String, Codable, Sendable { + case viewport + case detection +} + public struct ReadInput: Codable, Sendable { public let selector: TargetSelector public let last: Int? + /// The terminal buffer requested by the caller. + public let source: ReadInputSource /// When true, the app re-reads the pane until its output stops changing before responding. public let waitStable: Bool /// Sampling interval in milliseconds while waiting for stable output (nil → app default). @@ -121,9 +128,20 @@ public struct ReadInput: Codable, Sendable { /// Maximum seconds to keep waiting for stable output before returning the latest snapshot (nil → app default). public let waitTimeoutSeconds: Int? + enum CodingKeys: String, CodingKey { + case selector + case last + case source + case waitStable + case stableIntervalMs + case stablePeriodMs + case waitTimeoutSeconds + } + public init( selector: TargetSelector = .none, last: Int? = nil, + source: ReadInputSource = .viewport, waitStable: Bool = false, stableIntervalMs: Int? = nil, stablePeriodMs: Int? = nil, @@ -131,11 +149,34 @@ public struct ReadInput: Codable, Sendable { ) { self.selector = selector self.last = last + self.source = source self.waitStable = waitStable self.stableIntervalMs = stableIntervalMs self.stablePeriodMs = stablePeriodMs self.waitTimeoutSeconds = waitTimeoutSeconds } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.selector = try container.decodeIfPresent(TargetSelector.self, forKey: .selector) ?? .none + self.last = try container.decodeIfPresent(Int.self, forKey: .last) + self.source = try container.decodeIfPresent(ReadInputSource.self, forKey: .source) ?? .viewport + self.waitStable = try container.decodeIfPresent(Bool.self, forKey: .waitStable) ?? false + self.stableIntervalMs = try container.decodeIfPresent(Int.self, forKey: .stableIntervalMs) + self.stablePeriodMs = try container.decodeIfPresent(Int.self, forKey: .stablePeriodMs) + self.waitTimeoutSeconds = try container.decodeIfPresent(Int.self, forKey: .waitTimeoutSeconds) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(selector, forKey: .selector) + try container.encodeIfPresent(last, forKey: .last) + try container.encode(source, forKey: .source) + try container.encode(waitStable, forKey: .waitStable) + try container.encodeIfPresent(stableIntervalMs, forKey: .stableIntervalMs) + try container.encodeIfPresent(stablePeriodMs, forKey: .stablePeriodMs) + try container.encodeIfPresent(waitTimeoutSeconds, forKey: .waitTimeoutSeconds) + } } public enum TabAction: String, Codable, Sendable { diff --git a/supacode/CLIService/Shared/ReadCommandPayload.swift b/supacode/CLIService/Shared/ReadCommandPayload.swift index 89ab1e68..a82a508a 100644 --- a/supacode/CLIService/Shared/ReadCommandPayload.swift +++ b/supacode/CLIService/Shared/ReadCommandPayload.swift @@ -65,6 +65,7 @@ public enum ReadSource: String, Codable, Sendable { case screen case scrollback case mixed + case detection } public struct ReadTarget: Codable, Sendable, Equatable { diff --git a/supacodeTests/CLIReadCommandHandlerTests.swift b/supacodeTests/CLIReadCommandHandlerTests.swift index 431ceeed..51531c84 100644 --- a/supacodeTests/CLIReadCommandHandlerTests.swift +++ b/supacodeTests/CLIReadCommandHandlerTests.swift @@ -26,10 +26,13 @@ struct CLIReadCommandHandlerTests { ) } - private static func makeEnvelope(last: Int? = nil) -> CommandEnvelope { + private static func makeEnvelope( + last: Int? = nil, + source: ReadInputSource = .viewport + ) -> CommandEnvelope { CommandEnvelope( output: .json, - command: .read(ReadInput(selector: .none, last: last)) + command: .read(ReadInput(selector: .none, last: last, source: source)) ) } @@ -58,6 +61,66 @@ struct CLIReadCommandHandlerTests { #expect(payload.text == "line-1\nline-2") } + @Test func detectionSnapshotUsesExactDetectorInput() async throws { + let handler = ReadCommandHandler( + resolveProvider: { _ in .success(Self.makeTarget()) }, + captureProvider: { request in + #expect(request.source == .detection) + return ReadCaptureInput(detectionText: "active-line-1\nactive-line-2\n") + } + ) + + let response = await handler.handle(envelope: Self.makeEnvelope(source: .detection)) + + #expect(response.ok) + let payload = try #require(try response.data?.decode(as: ReadCommandPayload.self)) + #expect(payload.mode == .snapshot) + #expect(payload.source == .detection) + #expect(payload.truncated == false) + #expect(payload.lineCount == 3) + #expect(payload.text == "active-line-1\nactive-line-2\n") + } + + @Test func detectionLastUsesOnlyDetectorInput() async throws { + let handler = ReadCommandHandler( + resolveProvider: { _ in .success(Self.makeTarget()) }, + captureProvider: { _ in ReadCaptureInput(detectionText: "one\ntwo\nthree") } + ) + + let response = await handler.handle(envelope: Self.makeEnvelope(last: 2, source: .detection)) + + #expect(response.ok) + let payload = try #require(try response.data?.decode(as: ReadCommandPayload.self)) + #expect(payload.mode == .last) + #expect(payload.source == .detection) + #expect(payload.truncated == false) + #expect(payload.lineCount == 2) + #expect(payload.text == "two\nthree") + } + + @Test func detectionRejectsMismatchedCaptureSource() async { + let handler = ReadCommandHandler( + resolveProvider: { _ in .success(Self.makeTarget()) }, + captureProvider: { _ in ReadCaptureInput(viewportText: "viewport", screenText: nil) } + ) + + let response = await handler.handle(envelope: Self.makeEnvelope(source: .detection)) + + #expect(response.ok == false) + #expect(response.error?.code == CLIErrorCode.readFailed) + } + + @Test func readInputWithoutSourceDecodesAsViewport() throws { + let encoded = try JSONEncoder().encode(ReadInput()) + var object = try #require(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + object.removeValue(forKey: "source") + let legacyData = try JSONSerialization.data(withJSONObject: object) + + let input = try JSONDecoder().decode(ReadInput.self, from: legacyData) + + #expect(input.source == .viewport) + } + @Test func snapshotSucceedsWhenScreenCaptureUnavailable() async throws { let handler = ReadCommandHandler( resolveProvider: { _ in .success(Self.makeTarget()) },