diff --git a/ProwlCLI/Commands/HandoffCommand.swift b/ProwlCLI/Commands/HandoffCommand.swift index 684f69cb..1afe1a42 100644 --- a/ProwlCLI/Commands/HandoffCommand.swift +++ b/ProwlCLI/Commands/HandoffCommand.swift @@ -53,6 +53,20 @@ struct HandoffBriefOptions: ParsableArguments { } } +/// The HUD prefixes its injected shell command with a UUID environment value. +/// Normal interactive CLI use never sets it. +enum HandoffRequestContext { + static var currentID: UUID? { + requestID(in: ProcessInfo.processInfo.environment) + } + + static func requestID(in environment: [String: String]) -> UUID? { + guard let rawID = environment[HandoffInput.requestIDEnvironmentKey] else { return nil } + return UUID(uuidString: rawID) + } +} + + struct HandoffSaveCommand: ParsableCommand { static let configuration = CommandConfiguration( commandName: "save", @@ -80,7 +94,9 @@ struct HandoffSaveCommand: ParsableCommand { selector: try selector.resolve(positionalTarget: target), note: note, brief: resolvedBrief.brief, - contextOnly: resolvedBrief.contextOnly + contextOnly: resolvedBrief.contextOnly, + requestID: HandoffRequestContext.currentID + ) ) ) @@ -141,7 +157,8 @@ struct HandoffToCommand: ParsableCommand { note: note, launch: !noLaunch, brief: resolvedBrief.brief, - contextOnly: resolvedBrief.contextOnly + contextOnly: resolvedBrief.contextOnly, + requestID: HandoffRequestContext.currentID ) ) ) diff --git a/ProwlCLITests/HandoffCommandParsingTests.swift b/ProwlCLITests/HandoffCommandParsingTests.swift index bddf05a4..028af136 100644 --- a/ProwlCLITests/HandoffCommandParsingTests.swift +++ b/ProwlCLITests/HandoffCommandParsingTests.swift @@ -1,4 +1,5 @@ import ProwlCLIShared +import Foundation import XCTest @testable import prowl @@ -51,6 +52,15 @@ final class HandoffCommandParsingTests: XCTestCase { XCTAssertFalse(resolved.contextOnly) } + func testHUDRequestEnvironmentParsesOnlyValidUUID() { + let requestID = UUID() + let key = HandoffInput.requestIDEnvironmentKey + + XCTAssertEqual(HandoffRequestContext.requestID(in: [key: requestID.uuidString]), requestID) + XCTAssertNil(HandoffRequestContext.requestID(in: [key: "not-a-uuid"])) + XCTAssertNil(HandoffRequestContext.requestID(in: [:])) + } + func testToAcceptsPositionalTargetAfterAgent() throws { let command = try HandoffToCommand.parse(["claude", "App"]) diff --git a/docs-ai/047-cross-agent-handoff/000-plan.md b/docs-ai/047-cross-agent-handoff/000-plan.md index d8e231ff..12da90f4 100644 --- a/docs-ai/047-cross-agent-handoff/000-plan.md +++ b/docs-ai/047-cross-agent-handoff/000-plan.md @@ -102,3 +102,6 @@ cwd, which is weaker than the pid-anchored, ambiguity-safe native session identi explicit fallback, pure archive-first transition with template/manual-maintenance abolished, headless launch + completion notification, `status`/auto-save removed — see [004-inline-handoff-redesign](004-inline-handoff-redesign.md). +- Updated 2026-07-21: HUD request ownership — injected requests now need an + atomic single-transition claim, and fallback cancellation needs a visible commit + boundary — see [005-hud-request-ownership](005-hud-request-ownership.md). diff --git a/docs-ai/047-cross-agent-handoff/005-hud-request-ownership.md b/docs-ai/047-cross-agent-handoff/005-hud-request-ownership.md new file mode 100644 index 00000000..7fe07b87 --- /dev/null +++ b/docs-ai/047-cross-agent-handoff/005-hud-request-ownership.md @@ -0,0 +1,67 @@ +# 047.005 — HUD Request Ownership and Commit Boundary + +| | | +| --- | --- | +| **Status** | Implemented | +| **Anchor date** | 2026-07-21 | +| **Primary PRs** | #607 | +| **Related** | [047 plan](000-plan.md), [047.004](004-inline-handoff-redesign.md), `docs/components/handoff.md` | + +## Context + +The inline HUD path in 047.004 injects a real `prowl handoff` request into the live +source agent, then observes a CLI completion by source pane and action. Its fork and +context-only fallbacks start independent transitions while that injected request remains +queued. If the source agent later consumes the request, both paths transition the same +artifact and can launch two receivers. Pane/action matching can also let an unrelated +handoff from the source pane satisfy the HUD. Finally, fallback cancellation is only safe +while collecting a fork briefing; once persistence begins, unstructured detached work can +complete after the UI claims cancellation. + +## Goals + +- Authorize exactly one transition for an injected HUD request. +- Correlate HUD completion to the exact request and intended destination. +- Keep ordinary CLI handoffs independent of HUD request bookkeeping. +- Make fallback cancellation truthful: cancellable while gathering a briefing, non-cancellable + once the artifact transition commits. + +### Non-goals + +- Change the public handoff artifact format or source-pane resolution. +- Cancel a user-dismissed inline request; dismissing the waiting HUD intentionally leaves that + request running headlessly. + +## Design / Approach + +- Generate a request UUID when the HUD injects a command and include it in the injected CLI + invocation. Thread the optional ID through `HandoffInput` and `HandoffCLICompletion`. +- Add one main-actor request registry shared by the HUD and `HandoffCommandHandler`. It + atomically moves a registered request from `pending` to either `claimed` by its CLI handler + or `superseded` by a HUD fallback. A rejected CLI claim returns before briefing collection or + filesystem side effects. +- The HUD accepts a completion only while it is requesting, when the request IDs and intended + action/destination match, and when an agent transition includes a launched pane. +- Split the fallback into a cancellable briefing-collection phase and an explicit finishing + phase. The reducer enters finishing before archive/write/save/log/launch work begins, removes + the Cancel affordance, and launches a non-cancellable persistence effect. The transition + completes as one unit after that boundary. + +## Alternatives & decisions + +- **Pane/action plus destination matching only** — rejected: it cannot reject a delayed queued + request after fallback commits, and unrelated manual commands remain ambiguously correlated. +- **Best-effort cancellation checks around detached filesystem work** — rejected: a check after + archive/write can leave an artifact transition without its receiver or log. A visible commit + boundary preserves a consistent outcome. +- **Removing fallbacks** — rejected: fork and context-only remain necessary rescue paths when a + source agent is wedged or unavailable. + +## Verification + +- `HandoffHudFeatureTests`, `HandoffCommandHandlerTests`, and + `HandoffRequestRegistryTests` pass (41 tests): request supersession, exact completion + correlation, and the non-cancellable finishing phase are covered. +- `AppFeatureHandoffTests` pass (9 tests), exercising injected request correlation through the + app reducer. +- CLI build, parser tests, and socket integration tests pass. diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index 644e1790..1047d65f 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -200,6 +200,8 @@ struct SupacodeApp: App { let worktreeInfoWatcher = WorktreeInfoWatcherManager() _worktreeInfoWatcher = State(initialValue: worktreeInfoWatcher) let storeBox = SupacodeAppStoreBox() + let handoffRequestRegistry = HandoffRequestRegistry() + let coordinator = Self.makePullRequestRefreshCoordinator(storeBox: storeBox) _pullRequestRefreshCoordinator = State(initialValue: coordinator) let keyObserver = CommandKeyObserver() @@ -229,11 +231,26 @@ struct SupacodeApp: App { values.pullRequestRefreshCoordinator = Self.makePullRequestRefreshCoordinatorClient( coordinator: coordinator ) + values.handoffRequestClient = HandoffRequestClient( + register: { requestID in + handoffRequestRegistry.register(requestID) + }, + supersede: { requestID in + handoffRequestRegistry.supersede(requestID) + } + ) + } + _store = State(initialValue: appStore) storeBox.store = appStore - let cliServer = Self.makeCLISocketServer(appStore: appStore, terminalManager: terminalManager) + let cliServer = Self.makeCLISocketServer( + appStore: appStore, + terminalManager: terminalManager, + handoffRequestRegistry: handoffRequestRegistry + ) + _cliSocketServer = State(initialValue: cliServer) let watchdog = Self.makeMemoryWatchdog(appStore: appStore, terminalManager: terminalManager) @@ -499,8 +516,10 @@ struct SupacodeApp: App { // swiftlint:disable:next function_body_length static func makeCLICommandRouter( appStore: StoreOf, - terminalManager: WorktreeTerminalManager + terminalManager: WorktreeTerminalManager, + handoffRequestRegistry: HandoffRequestRegistry = HandoffRequestRegistry() ) -> CLICommandRouter { + let listHandler = ListCommandHandler { ListRuntimeSnapshotBuilder.makeSnapshot( repositoriesState: appStore.state.repositories, @@ -758,7 +777,11 @@ struct SupacodeApp: App { }, completionObserver: { completion in appStore.send(.handoffCliCompleted(completion)) + }, + requestAuthorizer: { requestID in + handoffRequestRegistry.claim(requestID) } + ) return CLICommandRouter( openHandler: openHandler, @@ -849,9 +872,15 @@ struct SupacodeApp: App { private static func makeCLISocketServer( appStore: StoreOf, - terminalManager: WorktreeTerminalManager + terminalManager: WorktreeTerminalManager, + handoffRequestRegistry: HandoffRequestRegistry ) -> CLISocketServer { - let cliRouter = makeCLICommandRouter(appStore: appStore, terminalManager: terminalManager) + + let cliRouter = makeCLICommandRouter( + appStore: appStore, + terminalManager: terminalManager, + handoffRequestRegistry: handoffRequestRegistry + ) let cliServer = CLISocketServer(router: cliRouter) let logger = SupaLogger("CLIService") do { diff --git a/supacode/CLIService/HandoffCommandHandler.swift b/supacode/CLIService/HandoffCommandHandler.swift index 953a2d07..12771d1b 100644 --- a/supacode/CLIService/HandoffCommandHandler.swift +++ b/supacode/CLIService/HandoffCommandHandler.swift @@ -41,6 +41,8 @@ struct HandoffCLICompletion: Sendable, Equatable { let toAgent: String? let briefing: HandoffBriefing let launched: HandoffLaunchedPane? + /// ID generated by the HUD, or nil for an ordinary CLI handoff. + let requestID: UUID? } @MainActor @@ -55,6 +57,8 @@ final class HandoffCommandHandler: CommandHandler { typealias LaunchNotifier = @MainActor (HandoffLaunchedPane, String, String) -> Void /// Announces every successful save/to so the UI can observe injected requests. typealias CompletionObserver = @MainActor (HandoffCLICompletion) -> Void + /// Atomically claims a HUD request before its transition starts. + typealias RequestAuthorizer = (UUID) -> Bool /// Agents this command can launch (it injects an agent-specific kickoff command). static let supportedAgents = HandoffAgentSupport.launchableAgents @@ -64,6 +68,8 @@ final class HandoffCommandHandler: CommandHandler { private let forkProvider: ForkProvider private let notifyLaunch: LaunchNotifier private let completionObserver: CompletionObserver + private let requestAuthorizer: RequestAuthorizer + private let now: @Sendable () -> Date init( @@ -72,13 +78,17 @@ final class HandoffCommandHandler: CommandHandler { forkProvider: @escaping ForkProvider, notifyLaunch: @escaping LaunchNotifier = { _, _, _ in }, completionObserver: @escaping CompletionObserver = { _ in }, + requestAuthorizer: @escaping RequestAuthorizer = { _ in true }, now: @escaping @Sendable () -> Date = { Date() } + ) { self.resolveProvider = resolveProvider self.launchProvider = launchProvider self.forkProvider = forkProvider self.notifyLaunch = notifyLaunch self.completionObserver = completionObserver + self.requestAuthorizer = requestAuthorizer + self.now = now } @@ -128,6 +138,13 @@ final class HandoffCommandHandler: CommandHandler { return response } + if let requestID = input.requestID, !requestAuthorizer(requestID) { + return errorResponse( + code: CLIErrorCode.handoffRequestSuperseded, + message: "This HUD handoff request was superseded by a fallback. Nothing was changed." + ) + } + let store = HandoffStore(rootURL: URL(fileURLWithPath: target.rootPath, isDirectory: true)) let timestamp = now() @@ -214,7 +231,9 @@ final class HandoffCommandHandler: CommandHandler { sourcePaneID: target.paneID, toAgent: nil, briefing: briefing, - launched: nil + launched: nil, + requestID: input.requestID + ) ) return success( @@ -317,7 +336,8 @@ final class HandoffCommandHandler: CommandHandler { sourcePaneID: target.paneID, toAgent: toAgent, briefing: artifacts.briefing, - launched: launched + launched: launched, + requestID: input.requestID ) ) diff --git a/supacode/CLIService/Shared/ErrorCodes.swift b/supacode/CLIService/Shared/ErrorCodes.swift index e7c71409..1dbc5a6b 100644 --- a/supacode/CLIService/Shared/ErrorCodes.swift +++ b/supacode/CLIService/Shared/ErrorCodes.swift @@ -55,6 +55,8 @@ public enum CLIErrorCode { public static let invalidBrief = "INVALID_BRIEF" /// No selector was given and the caller is not inside a Prowl pane. public static let sourceRequired = "SOURCE_REQUIRED" + /// A HUD-injected request was already superseded by its fallback. + public static let handoffRequestSuperseded = "HANDOFF_REQUEST_SUPERSEDED" // Transport public static let transportFailed = "TRANSPORT_FAILED" diff --git a/supacode/CLIService/Shared/InputModels.swift b/supacode/CLIService/Shared/InputModels.swift index 8148b068..f1ea2844 100644 --- a/supacode/CLIService/Shared/InputModels.swift +++ b/supacode/CLIService/Shared/InputModels.swift @@ -178,6 +178,10 @@ public enum HandoffAction: String, Codable, Sendable { } public struct HandoffInput: Codable, Sendable { + /// Environment variable used only by HUD-injected shell commands to carry + /// the one-shot request authorization ID to the socket payload. + public nonisolated static let requestIDEnvironmentKey = "PROWL_HANDOFF_REQUEST_ID" + public let action: HandoffAction public let selector: TargetSelector /// Target agent for `to` (e.g. "claude", "codex"). Required for `.to`, nil otherwise. @@ -193,6 +197,9 @@ public struct HandoffInput: Codable, Sendable { /// Explicit context-only run (`--no-brief`): no briefing is collected and /// no fork resume is attempted. public let contextOnly: Bool + /// Optional ID assigned by the HUD to authorize one injected transition. + /// Ordinary CLI handoffs omit it and remain independent of HUD state. + public let requestID: UUID? enum CodingKeys: String, CodingKey { case action @@ -202,6 +209,8 @@ public struct HandoffInput: Codable, Sendable { case launch case brief case contextOnly = "context_only" + case requestID = "request_id" + } public init( @@ -211,7 +220,9 @@ public struct HandoffInput: Codable, Sendable { note: String? = nil, launch: Bool = true, brief: String? = nil, - contextOnly: Bool = false + contextOnly: Bool = false, + requestID: UUID? = nil + ) { self.action = action self.selector = selector @@ -220,6 +231,8 @@ public struct HandoffInput: Codable, Sendable { self.launch = launch self.brief = brief self.contextOnly = contextOnly + self.requestID = requestID + } public init(from decoder: Decoder) throws { @@ -231,6 +244,7 @@ public struct HandoffInput: Codable, Sendable { self.launch = try container.decodeIfPresent(Bool.self, forKey: .launch) ?? true self.brief = try container.decodeIfPresent(String.self, forKey: .brief) self.contextOnly = try container.decodeIfPresent(Bool.self, forKey: .contextOnly) ?? false + self.requestID = try container.decodeIfPresent(UUID.self, forKey: .requestID) } } diff --git a/supacode/Clients/Handoff/HandoffRequestClient.swift b/supacode/Clients/Handoff/HandoffRequestClient.swift new file mode 100644 index 00000000..f6675477 --- /dev/null +++ b/supacode/Clients/Handoff/HandoffRequestClient.swift @@ -0,0 +1,28 @@ +import ComposableArchitecture +import Foundation + +/// Connects the HUD reducer to the app-owned request registry that authorizes +/// injected CLI transitions. +struct HandoffRequestClient: Sendable { + var register: @MainActor @Sendable (UUID) -> Void + var supersede: @MainActor @Sendable (UUID) -> Bool +} + +extension HandoffRequestClient: DependencyKey { + static let liveValue = Self( + register: { _ in }, + supersede: { _ in true } + ) + + static let testValue = Self( + register: { _ in }, + supersede: { _ in true } + ) +} + +extension DependencyValues { + var handoffRequestClient: HandoffRequestClient { + get { self[HandoffRequestClient.self] } + set { self[HandoffRequestClient.self] = newValue } + } +} diff --git a/supacode/Domain/Handoff/HandoffCoordinator.swift b/supacode/Domain/Handoff/HandoffCoordinator.swift index 896271f9..636f4b28 100644 --- a/supacode/Domain/Handoff/HandoffCoordinator.swift +++ b/supacode/Domain/Handoff/HandoffCoordinator.swift @@ -28,6 +28,15 @@ nonisolated enum HandoffBriefingError: Error, Equatable, Sendable { case invalidInlineBrief } +/// A briefing collected before the handoff's irreversible artifact work +/// begins. HUD fallbacks use this to establish a visible commit boundary. +nonisolated struct HandoffPreparedBriefing: Equatable, Sendable { + let artifact: String? + let outcome: HandoffBriefing + + static let contextOnly = Self(artifact: nil, outcome: .none) +} + /// The one pure transition core every handoff entry point drives — the CLI /// handler for agent-initiated handoffs and the HUD's fork/context-only /// fallbacks. A transition always runs the same sequence: @@ -75,31 +84,33 @@ nonisolated struct HandoffCoordinator: Sendable { /// fails validation throws (the caller reports it; nothing was written). /// A cancelled fork rethrows `CancellationError` so an aborted UI run never /// degrades into a context-only transition behind the user's back. - private func collectBriefing( - _ source: HandoffBriefingSource - ) async throws -> (artifact: String?, briefing: HandoffBriefing) { + /// Resolve a briefing source to validated artifact text. Inline text that + /// fails validation throws (the caller reports it; nothing was written). + /// A cancelled fork rethrows `CancellationError` so an aborted UI run never + /// degrades into a context-only transition behind the user's back. + func collectBriefing(_ source: HandoffBriefingSource) async throws -> HandoffPreparedBriefing { switch source { case .inline(let raw): guard let artifact = HandoffStore.validatedBriefing(from: raw) else { throw HandoffBriefingError.invalidInlineBrief } - return (artifact, .inline) + return HandoffPreparedBriefing(artifact: artifact, outcome: .inline) case .fork(let request): do { let reply = try await resume(request, store.rootURL) try Task.checkCancellation() guard let artifact = HandoffStore.validatedBriefing(from: reply) else { - return (nil, .failed) + return HandoffPreparedBriefing(artifact: nil, outcome: .failed) } - return (artifact, .fork) + return HandoffPreparedBriefing(artifact: artifact, outcome: .fork) } catch is CancellationError { throw CancellationError() } catch { if Task.isCancelled { throw CancellationError() } - return (nil, .failed) + return HandoffPreparedBriefing(artifact: nil, outcome: .failed) } case .none: - return (nil, .none) + return .contextOnly } } @@ -115,12 +126,29 @@ nonisolated struct HandoffCoordinator: Sendable { briefingSource: HandoffBriefingSource, now: Date ) async throws -> TransitionArtifacts { - let (artifact, briefing) = try await collectBriefing(briefingSource) + try await makeTransitionArtifacts( + outgoingAgent: outgoingAgent, + toAgent: toAgent, + sessionContext: sessionContext, + briefing: try await collectBriefing(briefingSource), + now: now + ) + } + + /// Performs an already-authorized artifact transition. HUD fallbacks call + /// this only after their reducer enters the non-cancellable finishing stage. + func makeTransitionArtifacts( + outgoingAgent: String?, + toAgent: String, + sessionContext: HandoffStore.SessionContext?, + briefing: HandoffPreparedBriefing, + now: Date + ) async throws -> TransitionArtifacts { let store = self.store let from = outgoingAgent ?? "agent" return try await Task.detached { let archivedPath = try store.archiveCurrent(from: from, toAgent: toAgent, now: now) - if let artifact { + if let artifact = briefing.artifact { try store.writeBriefing(artifact, archivingPrevious: false, now: now) } else { try store.removeCurrentArtifact() @@ -132,7 +160,7 @@ nonisolated struct HandoffCoordinator: Sendable { briefing: nil, now: now ) - return TransitionArtifacts(briefing: briefing, save: save, archivedPath: archivedPath) + return TransitionArtifacts(briefing: briefing.outcome, save: save, archivedPath: archivedPath) }.value } @@ -147,21 +175,37 @@ nonisolated struct HandoffCoordinator: Sendable { briefingSource: HandoffBriefingSource, now: Date ) async throws -> (save: HandoffStore.SaveResult, briefing: HandoffBriefing) { - let (artifact, briefing) = try await collectBriefing(briefingSource) + try await makeCheckpoint( + outgoingAgent: outgoingAgent, + sessionContext: sessionContext, + note: note, + briefing: try await collectBriefing(briefingSource), + now: now + ) + } + + /// Persists a briefing collected before the HUD's commit boundary. + func makeCheckpoint( + outgoingAgent: String?, + sessionContext: HandoffStore.SessionContext?, + note: String?, + briefing: HandoffPreparedBriefing, + now: Date + ) async throws -> (save: HandoffStore.SaveResult, briefing: HandoffBriefing) { let store = self.store let save = try await Task.detached { - if let artifact { + if let artifact = briefing.artifact { try store.writeBriefing(artifact, archivingPrevious: true, now: now) } return try store.save( outgoingAgent: outgoingAgent, sessionContext: sessionContext, note: note, - briefing: briefing, + briefing: briefing.outcome, now: now ) }.value - return (save, briefing) + return (save, briefing.outcome) } /// Append the single transition line; every entry point shares this format. diff --git a/supacode/Domain/Handoff/HandoffInjection.swift b/supacode/Domain/Handoff/HandoffInjection.swift index 0cc22a31..fab4babe 100644 --- a/supacode/Domain/Handoff/HandoffInjection.swift +++ b/supacode/Domain/Handoff/HandoffInjection.swift @@ -9,18 +9,26 @@ nonisolated enum HandoffInjection { case checkpoint } - static func instruction(for purpose: Purpose) -> String { + static func instruction(for purpose: Purpose, requestID: UUID) -> String { + let sections = HandoffStore.briefingSections.joined(separator: ", ") let ask = switch purpose { case .handOff(let agent): - "Please hand this task off to \(agent): run `prowl handoff to \(agent) --brief -`" + "Please hand this task off to \(agent): run " + + "`\(requestEnvironment(requestID))prowl handoff to \(agent) --brief -`" case .checkpoint: - "Please checkpoint your progress for a later handoff: run `prowl handoff save --brief -`" + "Please checkpoint your progress for a later handoff: run " + + "`\(requestEnvironment(requestID))prowl handoff save --brief -`" + } return "[Prowl] \(ask) with your briefing on stdin as a heredoc — a markdown document " + "with the sections \(sections), written from your current working knowledge. " + "Keep Next Steps ordered and concrete. The command replies with guidance if the " + "briefing is incomplete." } + + private static func requestEnvironment(_ requestID: UUID) -> String { + "\(HandoffInput.requestIDEnvironmentKey)=\(requestID.uuidString) " + } } diff --git a/supacode/Domain/Handoff/HandoffRequestRegistry.swift b/supacode/Domain/Handoff/HandoffRequestRegistry.swift new file mode 100644 index 00000000..0ff3b0b1 --- /dev/null +++ b/supacode/Domain/Handoff/HandoffRequestRegistry.swift @@ -0,0 +1,38 @@ +import Foundation + +/// Owns the one-shot authorization for a HUD-injected handoff request. The +/// HUD and socket handler both run on the main actor, so claiming the request +/// or superseding it for a fallback is one serialized state transition. +@MainActor +final class HandoffRequestRegistry { + private enum State { + case pending + case claimed + case superseded + } + + private var states: [UUID: State] = [:] + + func register(_ requestID: UUID) { + states[requestID] = .pending + } + + /// Claims a pending request for its CLI transition. A request can only be + /// claimed once and cannot run after a HUD fallback supersedes it. + @discardableResult + func claim(_ requestID: UUID) -> Bool { + guard states[requestID] == .pending else { return false } + states[requestID] = .claimed + return true + } + + /// Supersedes a still-pending injected request before the HUD begins its + /// independent fallback transition. A handler that already claimed it owns + /// the transition and must be allowed to finish instead. + @discardableResult + func supersede(_ requestID: UUID) -> Bool { + guard states[requestID] == .pending else { return false } + states[requestID] = .superseded + return true + } +} diff --git a/supacode/Features/HandoffHud/Reducer/HandoffHudFeature.swift b/supacode/Features/HandoffHud/Reducer/HandoffHudFeature.swift index 69faa84e..c0a288e1 100644 --- a/supacode/Features/HandoffHud/Reducer/HandoffHudFeature.swift +++ b/supacode/Features/HandoffHud/Reducer/HandoffHudFeature.swift @@ -42,8 +42,9 @@ enum HandoffStage: Equatable, Sendable { case requesting /// Fallback: fork briefing + transition, headless. case forking - /// Fallback: context-only transition (sub-second). - case saving + /// Artifact persistence and receiver launch have committed; this phase runs + /// to completion rather than presenting a misleading cancel affordance. + case finishing } enum HandoffHudOutcome: Equatable, Sendable { @@ -56,6 +57,9 @@ struct HandoffHudRun: Equatable, Sendable { let target: HandoffTargetOption let startedAt: Date var stage: HandoffStage + /// Non-nil only for an inline request injected by this HUD run. + let requestID: UUID? + } enum HandoffHudPhase: Equatable { @@ -171,6 +175,10 @@ struct HandoffHudFeature { case confirmSelection case fallbackForkTapped case fallbackContextOnlyTapped + /// Fork collection has produced a validated (or context-only) briefing. + /// Handling this action establishes the non-cancellable commit boundary. + case fallbackBriefingCollected(HandoffPreparedBriefing) + /// A CLI handoff completed somewhere in the app; the reducer ignores it /// unless it came from this HUD's source pane. case cliCompleted(HandoffCLICompletion) @@ -192,6 +200,9 @@ struct HandoffHudFeature { @Dependency(AgentRuntimeClient.self) private var agentRuntimeClient @Dependency(TerminalClient.self) private var terminalClient + @Dependency(HandoffRequestClient.self) private var handoffRequestClient + @Dependency(\.uuid) private var uuid + @Dependency(\.date.now) private var now var body: some Reducer { @@ -216,14 +227,22 @@ struct HandoffHudFeature { case .agent(let agent): .handOff(agent: agent.rawValue) case .briefOnly: .checkpoint } + let requestID = uuid() + handoffRequestClient.register(requestID) let delivered = terminalClient.sendTextToSurface( state.worktree.id, state.source.sourceSurfaceID, - HandoffInjection.instruction(for: purpose) + HandoffInjection.instruction(for: purpose, requestID: requestID) ) state.phase = .running( - HandoffHudRun(target: target, startedAt: now, stage: .requesting) + HandoffHudRun( + target: target, + startedAt: now, + stage: .requesting, + requestID: requestID + ) ) + if delivered { // The panel goes non-modal while waiting: hand the keyboard back to // the terminal so the user can approve any permission prompt the @@ -234,36 +253,51 @@ struct HandoffHudFeature { await client.send(.focusSelectedTab(worktree)) } } - // The pane cannot take input (gone or wedged) — fall back without a - // detour through the waiting state. + // The pane cannot take input (gone or wedged) — cancel its just-registered + // request before the HUD takes the independent fallback path. + _ = handoffRequestClient.supersede(requestID) return state.canFork ? startForkFallback(&state) : startContextOnlyFallback(&state) case .fallbackForkTapped: - guard state.run?.stage == .requesting else { return .none } + guard + let requestID = state.run?.requestID, + handoffRequestClient.supersede(requestID) + else { return .none } return startForkFallback(&state) case .fallbackContextOnlyTapped: - guard state.run?.stage == .requesting else { return .none } + guard + let requestID = state.run?.requestID, + handoffRequestClient.supersede(requestID) + else { return .none } return startContextOnlyFallback(&state) + case .fallbackBriefingCollected(let briefing): + guard state.run?.stage == .forking else { return .none } + return startFallbackCommit(&state, briefing: briefing) + case .cliCompleted(let completion): guard let run = state.run, - completion.sourcePaneID == state.source.sourceSurfaceID.uuidString + run.stage == .requesting, + completion.sourcePaneID == state.source.sourceSurfaceID.uuidString, + completion.requestID == run.requestID else { return .none } let expectedAction: HandoffAction = run.target.kind == .briefOnly ? .save : .toAgent guard completion.action == expectedAction else { return .none } switch run.target.kind { case .briefOnly: + guard completion.toAgent == nil else { return .none } state.phase = .finished(.briefSaved) - case .agent: + case .agent(let expectedAgent): + guard completion.toAgent == expectedAgent.rawValue, let launched = completion.launched, + let paneID = UUID(uuidString: launched.paneID) + else { return .none } state.phase = .finished(.handedOff(agentDisplayName: run.target.title)) - if let launched = completion.launched, let paneID = UUID(uuidString: launched.paneID) { - // The user is present and asked for this hand-off — jump to the - // receiver. The transition core itself never focuses anything. - _ = terminalClient.focusSurface(launched.worktreeID, paneID) - } + // The user is present and asked for this hand-off — jump to the + // receiver. The transition core itself never focuses anything. + _ = terminalClient.focusSurface(launched.worktreeID, paneID) } return .cancel(id: FallbackCancelID(worktreeID: state.worktree.id)) @@ -285,13 +319,17 @@ struct HandoffHudFeature { // The injected request cannot be unsent; if the agent still hands // off, the CLI path completes headlessly and notifies. return .send(.delegate(.dismiss)) - case .running: - // Abort the in-flight fallback: a cancelled fork never mutates the - // artifact. + case .running(let run) where run.stage == .forking: + // Fork collection is the only cancellable fallback phase. No + // artifact work begins until it reports a prepared briefing. return .merge( .cancel(id: FallbackCancelID(worktreeID: state.worktree.id)), .send(.delegate(.dismiss)) ) + case .running: + // The fallback crossed its commit boundary and must finish as one + // transition; cancellation here would leave a partial handoff. + return .none case .finished: return .none } @@ -322,21 +360,37 @@ struct HandoffHudFeature { guard let forkRequest = state.source.forkRequest else { return startContextOnlyFallback(&state) } - return startFallback(&state, briefingSource: .fork(forkRequest), stage: .forking) + guard var run = state.run else { return .none } + run.stage = .forking + state.phase = .running(run) + + let coordinator = makeCoordinator(state) + let worktreeID = state.worktree.id + return .run { send in + let briefing = try await coordinator.collectBriefing(.fork(forkRequest)) + await send(.fallbackBriefingCollected(briefing)) + } catch: { error, send in + guard !(error is CancellationError) else { return } + await send(.runFailed(message: error.localizedDescription)) + } + .cancellable(id: FallbackCancelID(worktreeID: worktreeID), cancelInFlight: true) } private func startContextOnlyFallback(_ state: inout State) -> Effect { - startFallback(&state, briefingSource: HandoffBriefingSource.none, stage: .saving) + startFallbackCommit(&state, briefing: .contextOnly) } - private func startFallback( + /// Crosses the fallback's visible commit boundary. From here onward the + /// transition must run to one consistent outcome: archive/write/save/log and + /// receiver launch cannot be independently cancelled. + private func startFallbackCommit( _ state: inout State, - briefingSource: HandoffBriefingSource, - stage: HandoffStage + briefing: HandoffPreparedBriefing ) -> Effect { guard var run = state.run else { return .none } - run.stage = stage + run.stage = .finishing state.phase = .running(run) + let coordinator = makeCoordinator(state) let source = state.source let worktree = state.worktree @@ -352,15 +406,13 @@ struct HandoffHudFeature { outgoingAgent: source.agentToken, sessionContext: source.sessionContext, note: nil, - briefingSource: briefingSource, + briefing: briefing, now: timestamp ) await send(.fallbackFinished(.briefSaved)) } catch: { error, send in - guard !(error is CancellationError) else { return } await send(.runFailed(message: error.localizedDescription)) } - .cancellable(id: FallbackCancelID(worktreeID: worktree.id), cancelInFlight: true) case .agent(let destination): let configuration = inheritedConfiguration(source: source, destination: destination) @@ -370,7 +422,7 @@ struct HandoffHudFeature { outgoingAgent: source.agentToken, toAgent: destination.rawValue, sessionContext: source.sessionContext, - briefingSource: briefingSource, + briefing: briefing, now: timestamp ) let request = AgentStartRequest( @@ -400,10 +452,8 @@ struct HandoffHudFeature { ) await send(.fallbackFinished(.handedOff(agentDisplayName: targetTitle))) } catch: { error, send in - guard !(error is CancellationError) else { return } await send(.runFailed(message: error.localizedDescription)) } - .cancellable(id: FallbackCancelID(worktreeID: worktree.id), cancelInFlight: true) } } diff --git a/supacode/Features/HandoffHud/Views/HandoffHudOverlayView.swift b/supacode/Features/HandoffHud/Views/HandoffHudOverlayView.swift index c0b53675..dbcf7038 100644 --- a/supacode/Features/HandoffHud/Views/HandoffHudOverlayView.swift +++ b/supacode/Features/HandoffHud/Views/HandoffHudOverlayView.swift @@ -131,8 +131,8 @@ private struct HandoffHudCard: View { return "Waiting for \(store.source.displayName) to write its briefing and run the hand-off" case .forking: return "Collecting a briefing from \(store.source.displayName)'s recorded session" - case .saving: - return "Preparing a context-only hand-off" + case .finishing: + return "Finishing the hand-off" } case .finished(.handedOff): return "The receiving agent picks up the task in a new tab" @@ -148,7 +148,7 @@ private struct HandoffHudCard: View { case .choosing, .finished: return true case .running(let run): - return run.stage != .requesting + return run.stage == .forking } } @@ -334,11 +334,13 @@ private struct HandoffHudRunView: View { } .help("Don't wait: hand off with generated context only, no briefing") } - Button("Cancel") { - onCancel() + if run.stage != .finishing { + Button("Cancel") { + onCancel() + } + .keyboardShortcut(.cancelAction) + .help(cancelHelp) } - .keyboardShortcut(.cancelAction) - .help(cancelHelp) } .padding(12) } @@ -355,8 +357,8 @@ private struct HandoffHudRunView: View { } case .forking: return "Collecting a briefing from \(sourceDisplayName)'s recorded session" - case .saving: - return "Handing off with generated context only" + case .finishing: + return "Finishing the hand-off" } } @@ -366,8 +368,8 @@ private struct HandoffHudRunView: View { return "The request is queued if \(sourceDisplayName) is busy." case .forking: return "This can take a moment; the live session is untouched." - case .saving: - return "" + case .finishing: + return "This hand-off has started and will finish in the background." } } @@ -375,8 +377,10 @@ private struct HandoffHudRunView: View { switch run.stage { case .requesting: return "Close this panel; if \(sourceDisplayName) still hands off, it completes in the background" - case .forking, .saving: + case .forking: return "Stop this hand-off; nothing is changed" + case .finishing: + return "" } } } diff --git a/supacodeTests/AppFeatureHandoffTests.swift b/supacodeTests/AppFeatureHandoffTests.swift index d69b11ae..93ccb839 100644 --- a/supacodeTests/AppFeatureHandoffTests.swift +++ b/supacodeTests/AppFeatureHandoffTests.swift @@ -283,6 +283,7 @@ struct AppFeatureHandoffTests { AppFeature() } withDependencies: { $0.date.now = Date(timeIntervalSince1970: 1_760_000_000) + $0.uuid = .incrementing $0.terminalClient.sendTextToSurface = { _, _, text in injected.withValue { $0.append(text) } return true @@ -327,6 +328,7 @@ struct AppFeatureHandoffTests { Issue.record("Expected requesting stage, got \(String(describing: store.state.handoffHud?.phase))") return } + let requestID = try #require(run.requestID) // The agent ran the CLI; the socket handler announces the completion and // the app routes it into the HUD, which finishes and focuses the receiver. @@ -344,7 +346,8 @@ struct AppFeatureHandoffTests { tabID: uuid(12).uuidString, paneID: launchedPaneID.uuidString, paneTitle: "claude" - ) + ), + requestID: requestID ) ) ) diff --git a/supacodeTests/HandoffCommandHandlerTests.swift b/supacodeTests/HandoffCommandHandlerTests.swift index ee37a42a..227c7a72 100644 --- a/supacodeTests/HandoffCommandHandlerTests.swift +++ b/supacodeTests/HandoffCommandHandlerTests.swift @@ -62,7 +62,9 @@ struct HandoffCommandHandlerTests { resolveFailure: HandoffResolveError? = nil, launchSpy: (@MainActor (AgentStartRequest) -> Void)? = nil, forkSpy: (@Sendable (AgentResumeRequest, URL) async throws -> String)? = nil, - completionSpy: (@MainActor (HandoffCLICompletion) -> Void)? = nil + completionSpy: (@MainActor (HandoffCLICompletion) -> Void)? = nil, + requestClaim: ((UUID) -> Bool)? = nil + ) -> HandoffCommandHandler { HandoffCommandHandler( resolveProvider: { _, _ in @@ -94,6 +96,9 @@ struct HandoffCommandHandlerTests { completionObserver: { completion in completionSpy?(completion) }, + requestAuthorizer: { requestID in + requestClaim?(requestID) ?? true + }, now: { [fixedDate] in fixedDate } ) } @@ -217,6 +222,34 @@ struct HandoffCommandHandlerTests { #expect(!FileManager.default.fileExists(atPath: root.appending(path: ".prowl").path(percentEncoded: false))) } + @Test func supersededHudRequestIsRejectedBeforeSideEffects() async throws { + let root = try makeTempRoot() + defer { remove(root) } + let requestID = UUID() + + let handler = makeHandler( + root: root, + outgoingAgent: "codex", + requestClaim: { _ in false } + + ) + + let response = await handler.handle( + envelope: envelope( + HandoffInput( + action: .toAgent, + toAgent: "claude", + brief: validHandoffBriefing, + requestID: requestID + ) + ) + ) + + #expect(response.ok == false) + #expect(response.error?.code == CLIErrorCode.handoffRequestSuperseded) + #expect(!FileManager.default.fileExists(atPath: root.appending(path: ".prowl").path(percentEncoded: false))) + } + @Test func briefAndNoBriefAreMutuallyExclusive() async throws { let root = try makeTempRoot() defer { remove(root) } diff --git a/supacodeTests/HandoffHudFeatureTests.swift b/supacodeTests/HandoffHudFeatureTests.swift index aaa8ebc8..bc09a147 100644 --- a/supacodeTests/HandoffHudFeatureTests.swift +++ b/supacodeTests/HandoffHudFeatureTests.swift @@ -32,6 +32,7 @@ struct HandoffHudFeatureTests { } private let sourcePaneID = UUID(uuidString: "5A0B7B44-11A2-4C6C-9A0F-2B93A8B0E001")! + private let requestID = UUID(uuidString: "5A0B7B44-11A2-4C6C-9A0F-2B93A8B0E002")! private func makeSourceContext( agent: String = "codex", @@ -218,6 +219,7 @@ struct HandoffHudFeatureTests { HandoffHudFeature() } withDependencies: { $0.date.now = startedAt + $0.uuid = UUIDGenerator { requestID } $0[TerminalClient.self].sendTextToSurface = { worktreeID, surfaceID, text in injected.withValue { $0.append(InjectedRequest(worktreeID: worktreeID, surfaceID: surfaceID, text: text)) @@ -228,11 +230,7 @@ struct HandoffHudFeatureTests { await store.send(.confirmSelection) { $0.phase = .running( - HandoffHudRun( - target: $0.targets[claudeIndex], - startedAt: startedAt, - stage: .requesting - ) + HandoffHudRun(target: $0.targets[claudeIndex], startedAt: startedAt, stage: .requesting, requestID: requestID) ) } @@ -240,6 +238,7 @@ struct HandoffHudFeatureTests { #expect(request.worktreeID == worktree.id) #expect(request.surfaceID == sourcePaneID) #expect(request.text.contains("prowl handoff to claude --brief -")) + #expect(request.text.contains("\(HandoffInput.requestIDEnvironmentKey)=\(requestID.uuidString)")) #expect(request.text.contains("## Objective")) #expect(!request.text.contains("\n")) } @@ -260,6 +259,7 @@ struct HandoffHudFeatureTests { HandoffHudFeature() } withDependencies: { $0.date.now = Date(timeIntervalSince1970: 1_760_000_000) + $0.uuid = UUIDGenerator { requestID } $0[TerminalClient.self].sendTextToSurface = { _, _, _ in true } $0[TerminalClient.self].focusSurface = { worktreeID, surfaceID in focused.withValue { $0.append((worktreeID, surfaceID)) } @@ -270,10 +270,8 @@ struct HandoffHudFeatureTests { await store.send(.confirmSelection) { $0.phase = .running( HandoffHudRun( - target: $0.targets[claudeIndex], - startedAt: Date(timeIntervalSince1970: 1_760_000_000), - stage: .requesting - ) + target: $0.targets[claudeIndex], startedAt: Date(timeIntervalSince1970: 1_760_000_000), stage: .requesting, + requestID: requestID) ) } @@ -284,7 +282,8 @@ struct HandoffHudFeatureTests { sourcePaneID: sourcePaneID.uuidString, toAgent: "claude", briefing: .inline, - launched: launched + launched: launched, + requestID: requestID ) ) ) { @@ -310,16 +309,15 @@ struct HandoffHudFeatureTests { HandoffHudFeature() } withDependencies: { $0.date.now = Date(timeIntervalSince1970: 1_760_000_000) + $0.uuid = UUIDGenerator { requestID } $0[TerminalClient.self].sendTextToSurface = { _, _, _ in true } } await store.send(.confirmSelection) { $0.phase = .running( HandoffHudRun( - target: $0.targets[claudeIndex], - startedAt: Date(timeIntervalSince1970: 1_760_000_000), - stage: .requesting - ) + target: $0.targets[claudeIndex], startedAt: Date(timeIntervalSince1970: 1_760_000_000), stage: .requesting, + requestID: requestID) ) } @@ -331,7 +329,8 @@ struct HandoffHudFeatureTests { sourcePaneID: UUID().uuidString, toAgent: "claude", briefing: .inline, - launched: nil + launched: nil, + requestID: requestID ) ) ) @@ -343,7 +342,34 @@ struct HandoffHudFeatureTests { sourcePaneID: sourcePaneID.uuidString, toAgent: nil, briefing: .inline, - launched: nil + launched: nil, + requestID: requestID + ) + ) + ) + // Our pane and request, but a different destination: not this HUD run. + await store.send( + .cliCompleted( + HandoffCLICompletion( + action: .toAgent, + sourcePaneID: sourcePaneID.uuidString, + toAgent: "codex", + briefing: .inline, + launched: launchedPane(worktreeID: worktree.id), + requestID: requestID + ) + ) + ) + // A matching destination without a receiver is still not a completed handoff. + await store.send( + .cliCompleted( + HandoffCLICompletion( + action: .toAgent, + sourcePaneID: sourcePaneID.uuidString, + toAgent: "claude", + briefing: .inline, + launched: nil, + requestID: requestID ) ) ) @@ -364,6 +390,7 @@ struct HandoffHudFeatureTests { HandoffHudFeature() } withDependencies: { $0.date.now = Date(timeIntervalSince1970: 1_760_000_000) + $0.uuid = UUIDGenerator { requestID } $0[TerminalClient.self].sendTextToSurface = { _, _, text in injected.withValue { $0.append(text) } return true @@ -373,10 +400,8 @@ struct HandoffHudFeatureTests { await store.send(.confirmSelection) { $0.phase = .running( HandoffHudRun( - target: $0.targets[briefIndex], - startedAt: Date(timeIntervalSince1970: 1_760_000_000), - stage: .requesting - ) + target: $0.targets[briefIndex], startedAt: Date(timeIntervalSince1970: 1_760_000_000), stage: .requesting, + requestID: requestID) ) } #expect(injected.value.first?.contains("prowl handoff save --brief -") == true) @@ -388,7 +413,8 @@ struct HandoffHudFeatureTests { sourcePaneID: sourcePaneID.uuidString, toAgent: nil, briefing: .inline, - launched: nil + launched: nil, + requestID: requestID ) ) ) { @@ -410,11 +436,18 @@ struct HandoffHudFeatureTests { let sent = LockIsolated<[TerminalClient.Command]>([]) let startedAt = Date(timeIntervalSince1970: 1_760_000_000) + let requestRegistry = HandoffRequestRegistry() let store = TestStore(initialState: initial) { HandoffHudFeature() } withDependencies: { $0.date.now = startedAt + $0.uuid = UUIDGenerator { requestID } + $0.handoffRequestClient = HandoffRequestClient( + register: { requestRegistry.register($0) }, + supersede: { requestRegistry.supersede($0) } + ) + $0[TerminalClient.self].sendTextToSurface = { _, _, _ in true } $0[TerminalClient.self].send = { command in sent.withValue { $0.append(command) } @@ -424,14 +457,21 @@ struct HandoffHudFeatureTests { await store.send(.confirmSelection) { $0.phase = .running( - HandoffHudRun(target: $0.targets[claudeIndex], startedAt: startedAt, stage: .requesting) + HandoffHudRun(target: $0.targets[claudeIndex], startedAt: startedAt, stage: .requesting, requestID: requestID) ) } await store.send(.fallbackForkTapped) { $0.phase = .running( - HandoffHudRun(target: $0.targets[claudeIndex], startedAt: startedAt, stage: .forking) + HandoffHudRun(target: $0.targets[claudeIndex], startedAt: startedAt, stage: .forking, requestID: requestID) + ) + } + #expect(!requestRegistry.claim(requestID)) + await store.receive(\.fallbackBriefingCollected) { + $0.phase = .running( + HandoffHudRun(target: $0.targets[claudeIndex], startedAt: startedAt, stage: .finishing, requestID: requestID) ) } + await store.receive(\.fallbackFinished) { $0.phase = .finished(.handedOff(agentDisplayName: "Claude Code")) } @@ -476,18 +516,20 @@ struct HandoffHudFeatureTests { HandoffHudFeature() } withDependencies: { $0.date.now = startedAt + $0.uuid = UUIDGenerator { requestID } $0[TerminalClient.self].sendTextToSurface = { _, _, _ in true } $0[TerminalClient.self].send = { _ in } } await store.send(.confirmSelection) { $0.phase = .running( - HandoffHudRun(target: $0.targets[claudeIndex], startedAt: startedAt, stage: .requesting) + HandoffHudRun(target: $0.targets[claudeIndex], startedAt: startedAt, stage: .requesting, requestID: requestID) ) } await store.send(.fallbackContextOnlyTapped) { $0.phase = .running( - HandoffHudRun(target: $0.targets[claudeIndex], startedAt: startedAt, stage: .saving) + HandoffHudRun(target: $0.targets[claudeIndex], startedAt: startedAt, stage: .finishing, requestID: requestID) + ) } await store.receive(\.fallbackFinished) { @@ -515,6 +557,7 @@ struct HandoffHudFeatureTests { HandoffHudFeature() } withDependencies: { $0.date.now = startedAt + $0.uuid = UUIDGenerator { requestID } $0[TerminalClient.self].sendTextToSurface = { _, _, _ in false } $0[TerminalClient.self].send = { _ in } $0[AgentRuntimeClient.self] = AgentRuntimeClient(resume: { _, _ in Self.usableReply }) @@ -522,9 +565,15 @@ struct HandoffHudFeatureTests { await store.send(.confirmSelection) { $0.phase = .running( - HandoffHudRun(target: $0.targets[claudeIndex], startedAt: startedAt, stage: .forking) + HandoffHudRun(target: $0.targets[claudeIndex], startedAt: startedAt, stage: .forking, requestID: requestID) + ) + } + await store.receive(\.fallbackBriefingCollected) { + $0.phase = .running( + HandoffHudRun(target: $0.targets[claudeIndex], startedAt: startedAt, stage: .finishing, requestID: requestID) ) } + await store.receive(\.fallbackFinished) { $0.phase = .finished(.handedOff(agentDisplayName: "Claude Code")) } @@ -546,16 +595,15 @@ struct HandoffHudFeatureTests { HandoffHudFeature() } withDependencies: { $0.date.now = Date(timeIntervalSince1970: 1_760_000_000) + $0.uuid = UUIDGenerator { requestID } $0[TerminalClient.self].sendTextToSurface = { _, _, _ in true } } await store.send(.confirmSelection) { $0.phase = .running( HandoffHudRun( - target: $0.targets[claudeIndex], - startedAt: Date(timeIntervalSince1970: 1_760_000_000), - stage: .requesting - ) + target: $0.targets[claudeIndex], startedAt: Date(timeIntervalSince1970: 1_760_000_000), stage: .requesting, + requestID: requestID) ) } await store.send(.cancelTapped) @@ -577,6 +625,7 @@ struct HandoffHudFeatureTests { HandoffHudFeature() } withDependencies: { $0.date.now = startedAt + $0.uuid = UUIDGenerator { requestID } $0[TerminalClient.self].sendTextToSurface = { _, _, _ in true } $0[TerminalClient.self].send = { _ in } $0[AgentRuntimeClient.self] = AgentRuntimeClient(resume: { _, _ in @@ -586,14 +635,28 @@ struct HandoffHudFeatureTests { await store.send(.confirmSelection) { $0.phase = .running( - HandoffHudRun(target: $0.targets[claudeIndex], startedAt: startedAt, stage: .requesting) + HandoffHudRun(target: $0.targets[claudeIndex], startedAt: startedAt, stage: .requesting, requestID: requestID) ) } await store.send(.fallbackForkTapped) { $0.phase = .running( - HandoffHudRun(target: $0.targets[claudeIndex], startedAt: startedAt, stage: .forking) + HandoffHudRun(target: $0.targets[claudeIndex], startedAt: startedAt, stage: .forking, requestID: requestID) ) } + // A delayed CLI completion from the injected request cannot replace the + // fallback that already superseded it. + await store.send( + .cliCompleted( + HandoffCLICompletion( + action: .toAgent, + sourcePaneID: sourcePaneID.uuidString, + toAgent: "claude", + briefing: .inline, + launched: launchedPane(worktreeID: worktree.id), + requestID: requestID + ) + ) + ) await store.send(.cancelTapped) await store.receive(\.delegate.dismiss) await store.finish() @@ -601,4 +664,29 @@ struct HandoffHudFeatureTests { // The aborted fork never touched the filesystem. #expect(!FileManager.default.fileExists(atPath: root.appending(path: ".prowl").path(percentEncoded: false))) } + + @Test func cancelWhileFinishingIsIgnored() async throws { + let root = try makeTempRoot() + defer { remove(root) } + var initial = try #require( + HandoffHudFeature.State.make(worktree: makeWorktree(root: root), source: makeSourceContext()) + ) + let claudeIndex = try #require(initial.targets.firstIndex { $0.agent == .claude }) + initial.phase = .running( + HandoffHudRun( + target: initial.targets[claudeIndex], + startedAt: Date(timeIntervalSince1970: 1_760_000_000), + stage: .finishing, + requestID: requestID + ) + ) + + let store = TestStore(initialState: initial) { + HandoffHudFeature() + } + + await store.send(.cancelTapped) + await store.finish() + } + } diff --git a/supacodeTests/HandoffRequestRegistryTests.swift b/supacodeTests/HandoffRequestRegistryTests.swift new file mode 100644 index 00000000..e7037543 --- /dev/null +++ b/supacodeTests/HandoffRequestRegistryTests.swift @@ -0,0 +1,37 @@ +import Foundation +import Testing + +@testable import supacode + +@MainActor +struct HandoffRequestRegistryTests { + @Test func requestCanBeClaimedOnlyOnce() { + let registry = HandoffRequestRegistry() + let requestID = UUID() + + registry.register(requestID) + + #expect(registry.claim(requestID)) + #expect(!registry.claim(requestID)) + } + + @Test func fallbackSupersedesPendingRequest() { + let registry = HandoffRequestRegistry() + let requestID = UUID() + + registry.register(requestID) + + #expect(registry.supersede(requestID)) + #expect(!registry.claim(requestID)) + } + + @Test func fallbackCannotSupersedeClaimedRequest() { + let registry = HandoffRequestRegistry() + let requestID = UUID() + + registry.register(requestID) + #expect(registry.claim(requestID)) + + #expect(!registry.supersede(requestID)) + } +}