diff --git a/ProwlCLI/Commands/HandoffCommand.swift b/ProwlCLI/Commands/HandoffCommand.swift index fbd4f14d..7b8ed11b 100644 --- a/ProwlCLI/Commands/HandoffCommand.swift +++ b/ProwlCLI/Commands/HandoffCommand.swift @@ -7,37 +7,70 @@ import ProwlCLIShared struct HandoffCommand: ParsableCommand { static let configuration = CommandConfiguration( commandName: "handoff", - abstract: "Refresh, archive, and hand off the cross-agent task artifact in a runnable target.", + abstract: "Hand a task off between agents: archive, brief, and launch the receiver.", subcommands: [ HandoffSaveCommand.self, HandoffToCommand.self, - HandoffStatusCommand.self, ] ) } +/// Shared briefing options. `--brief ` supplies the agent-authored +/// briefing inline (`-` reads stdin, the standard heredoc posture for a +/// self-handoff); `--no-brief` is the explicit context-only escape. +struct HandoffBriefOptions: ParsableArguments { + @Option( + name: .long, + help: "Inline agent-authored briefing; pass '-' to read it from stdin (heredoc)." + ) + var brief: String? + + @Flag(name: .customLong("no-brief"), help: "Context-only: skip the briefing entirely.") + var noBrief = false + + /// Resolve to the wire shape, reading stdin for the `-` sentinel. + func resolve() throws -> (brief: String?, contextOnly: Bool) { + if noBrief, brief != nil { + throw ExitError( + code: CLIErrorCode.invalidArgument, + message: "--brief and --no-brief are mutually exclusive." + ) + } + guard var text = brief else { return (nil, noBrief) } + if text == "-" { + guard + let data = try? FileHandle.standardInput.readToEnd(), + let stdinText = String(data: data, encoding: .utf8) + else { + throw ExitError(code: CLIErrorCode.emptyInput, message: "Failed to read the briefing from stdin.") + } + text = stdinText + } + guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw ExitError(code: CLIErrorCode.emptyInput, message: "The briefing is empty.") + } + return (text, false) + } +} + struct HandoffSaveCommand: ParsableCommand { static let configuration = CommandConfiguration( commandName: "save", - abstract: "Refresh the handoff artifact's auto context appendix from live git state." + abstract: "Checkpoint: install a fresh briefing and refresh generated context, without launching." ) @OptionGroup var selector: SelectorOptions + @OptionGroup var briefOptions: HandoffBriefOptions @OptionGroup var options: GlobalOptions - @Argument(help: "Target pane/tab UUID or worktree id/name/path (auto-resolved).") + @Argument(help: "Source pane/tab UUID or worktree id/name/path (defaults to the calling pane).") var target: String? @Option(name: .long, help: "Optional note appended to the handoff log.") var note: String? - @Flag( - name: .customLong("no-prepare"), - help: "Skip asking the detected source agent to refresh current.md before saving." - ) - var noPrepare = false - mutating func run() throws { + let resolvedBrief = try briefOptions.resolve() try CLIExecution.run(command: "handoff", output: options.outputMode, colorEnabled: options.colorEnabled) { let envelope = CommandEnvelope( output: options.outputMode, @@ -46,7 +79,8 @@ struct HandoffSaveCommand: ParsableCommand { action: .save, selector: try selector.resolve(positionalTarget: target), note: note, - prepare: !noPrepare + brief: resolvedBrief.brief, + contextOnly: resolvedBrief.contextOnly ) ) ) @@ -58,7 +92,7 @@ struct HandoffSaveCommand: ParsableCommand { struct HandoffToCommand: ParsableCommand { static let configuration = CommandConfiguration( commandName: "to", - abstract: "Save + archive the handoff, then launch the receiving agent in a new tab." + abstract: "Archive the outgoing state, install the briefing, and launch the receiving agent." ) @Argument( @@ -67,10 +101,11 @@ struct HandoffToCommand: ParsableCommand { ) var agent: String - @Argument(help: "Target pane/tab UUID or worktree id/name/path (auto-resolved).") + @Argument(help: "Source pane/tab UUID or worktree id/name/path (defaults to the calling pane).") var target: String? @OptionGroup var selector: SelectorOptions + @OptionGroup var briefOptions: HandoffBriefOptions @OptionGroup var options: GlobalOptions @Option(name: .long, help: "Optional note appended to the handoff log.") @@ -79,28 +114,23 @@ struct HandoffToCommand: ParsableCommand { @Flag(name: .customLong("no-launch"), help: "Archive + save only; do not launch the receiving agent.") var noLaunch = false - @Flag( - name: .customLong("no-prepare"), - help: "Skip asking the detected source agent to refresh current.md before saving." - ) - var noPrepare = false - mutating func run() throws { + let rawAgent = agent.lowercased() + guard let normalizedAgent = HandoffAgentSupport.normalize(rawAgent) else { + throw ExitError( + code: CLIErrorCode.invalidArgument, + message: "handoff to requires an agent of: \(HandoffAgentSupport.supportedAgentsDescription)." + ) + } + if !noLaunch, !HandoffAgentSupport.canLaunch(normalizedAgent) { + throw ExitError( + code: CLIErrorCode.invalidArgument, + message: + "handoff can only launch: \(HandoffAgentSupport.launchableAgentsDescription). Use --no-launch for other agents." + ) + } + let resolvedBrief = try briefOptions.resolve() try CLIExecution.run(command: "handoff", output: options.outputMode, colorEnabled: options.colorEnabled) { - let rawAgent = agent.lowercased() - guard let normalizedAgent = HandoffAgentSupport.normalize(rawAgent) else { - throw ExitError( - code: CLIErrorCode.invalidArgument, - message: "handoff to requires an agent of: \(HandoffAgentSupport.supportedAgentsDescription)." - ) - } - if !noLaunch, !HandoffAgentSupport.canLaunch(normalizedAgent) { - throw ExitError( - code: CLIErrorCode.invalidArgument, - message: - "handoff can only launch: \(HandoffAgentSupport.launchableAgentsDescription). Use --no-launch for other agents." - ) - } let envelope = CommandEnvelope( output: options.outputMode, command: .handoff( @@ -110,7 +140,8 @@ struct HandoffToCommand: ParsableCommand { toAgent: normalizedAgent, note: note, launch: !noLaunch, - prepare: !noPrepare + brief: resolvedBrief.brief, + contextOnly: resolvedBrief.contextOnly ) ) ) @@ -118,28 +149,3 @@ struct HandoffToCommand: ParsableCommand { } } } - -struct HandoffStatusCommand: ParsableCommand { - static let configuration = CommandConfiguration( - commandName: "status", - abstract: "Show the handoff artifact path, existence, and last log line." - ) - - @OptionGroup var selector: SelectorOptions - @OptionGroup var options: GlobalOptions - - @Argument(help: "Target pane/tab UUID or worktree id/name/path (auto-resolved).") - var target: String? - - mutating func run() throws { - try CLIExecution.run(command: "handoff", output: options.outputMode, colorEnabled: options.colorEnabled) { - let envelope = CommandEnvelope( - output: options.outputMode, - command: .handoff( - HandoffInput(action: .status, selector: try selector.resolve(positionalTarget: target)) - ) - ) - try CLIRunner.execute(envelope) - } - } -} diff --git a/ProwlCLI/Output/OutputRenderer.swift b/ProwlCLI/Output/OutputRenderer.swift index 1378b2cf..94a5297b 100644 --- a/ProwlCLI/Output/OutputRenderer.swift +++ b/ProwlCLI/Output/OutputRenderer.swift @@ -288,20 +288,10 @@ enum OutputRenderer { var lines: [String] = [] switch payload.action { - case .status: - let existsLabel = (payload.exists ?? false) ? "yes".green : "no".dim - lines.append("Handoff \("status".cyan.bold) \("exists:".dim) \(existsLabel)") - lines.append(" \("artifact:".dim) \(payload.artifactPath)") - if let agent = payload.outgoingAgent { - lines.append(" \("current agent:".dim) \(agent)") - } - if let lastLog = payload.lastLog { - lines.append(" \("last:".dim) \(lastLog)") - } case .save: lines.append("Handoff \("saved".green.bold) \("changed:".dim) \(payload.changedFileCount) files") lines.append(" \("artifact:".dim) \(payload.artifactPath)") - lines.append(contentsOf: renderHandoffPreparation(payload.preparation)) + lines.append(contentsOf: renderHandoffBriefing(payload.briefing)) lines.append(contentsOf: renderHandoffSession(payload.sessionContext)) lines.append(contentsOf: renderHandoffRepos(payload.repos)) case .toAgent: @@ -312,7 +302,7 @@ enum OutputRenderer { if let archived = payload.archivedPath { lines.append(" \("archived:".dim) \(archived)") } - lines.append(contentsOf: renderHandoffPreparation(payload.preparation)) + lines.append(contentsOf: renderHandoffBriefing(payload.briefing)) lines.append(contentsOf: renderHandoffSession(payload.sessionContext)) if let pane = payload.launchedPane { lines.append(" \("launched:".dim) \(to.green) → \(pane.paneTitle.green) \(pane.paneID.dim)") @@ -325,15 +315,15 @@ enum OutputRenderer { return lines.joined(separator: "\n") } - private static func renderHandoffPreparation(_ preparation: String?) -> [String] { - guard let preparation else { return [] } + private static func renderHandoffBriefing(_ briefing: String?) -> [String] { + guard let briefing else { return [] } let label = - switch preparation { - case "completed": preparation.green - case "failed": preparation.yellow - default: preparation.dim + switch briefing { + case "inline", "fork": briefing.green + case "failed": briefing.yellow + default: briefing.dim } - return [" \("source prep:".dim) \(label)"] + return [" \("briefing:".dim) \(label)"] } private static func renderHandoffRepos(_ repos: [HandoffRepoPayload]) -> [String] { diff --git a/ProwlCLITests/HandoffCommandParsingTests.swift b/ProwlCLITests/HandoffCommandParsingTests.swift index 626757c9..bddf05a4 100644 --- a/ProwlCLITests/HandoffCommandParsingTests.swift +++ b/ProwlCLITests/HandoffCommandParsingTests.swift @@ -19,9 +19,36 @@ final class HandoffCommandParsingTests: XCTestCase { XCTAssertThrowsError(try command.selector.resolve(positionalTarget: command.target)) } - func testSaveParsesNoPrepareFlag() throws { - XCTAssertFalse(try HandoffSaveCommand.parse(["App"]).noPrepare) - XCTAssertTrue(try HandoffSaveCommand.parse(["App", "--no-prepare"]).noPrepare) + func testSaveParsesBriefOptions() throws { + let plain = try HandoffSaveCommand.parse(["App"]) + XCTAssertNil(plain.briefOptions.brief) + XCTAssertFalse(plain.briefOptions.noBrief) + + let inline = try HandoffSaveCommand.parse(["App", "--brief", "# Handoff\ntext"]) + XCTAssertEqual(inline.briefOptions.brief, "# Handoff\ntext") + + let contextOnly = try HandoffSaveCommand.parse(["App", "--no-brief"]) + XCTAssertTrue(contextOnly.briefOptions.noBrief) + } + + func testBriefAndNoBriefAreMutuallyExclusive() throws { + let command = try HandoffSaveCommand.parse(["App", "--brief", "text", "--no-brief"]) + + XCTAssertThrowsError(try command.briefOptions.resolve()) + } + + func testEmptyInlineBriefIsRejected() throws { + let command = try HandoffSaveCommand.parse(["App", "--brief", " "]) + + XCTAssertThrowsError(try command.briefOptions.resolve()) + } + + func testInlineBriefValueResolvesVerbatim() throws { + let command = try HandoffToCommand.parse(["claude", "--brief", "# Handoff\nbody"]) + + let resolved = try command.briefOptions.resolve() + XCTAssertEqual(resolved.brief, "# Handoff\nbody") + XCTAssertFalse(resolved.contextOnly) } func testToAcceptsPositionalTargetAfterAgent() throws { @@ -34,19 +61,10 @@ final class HandoffCommandParsingTests: XCTestCase { ) } - func testToParsesNoPrepareFlag() throws { - let command = try HandoffToCommand.parse(["claude", "App", "--no-prepare", "--no-launch"]) + func testToParsesNoBriefAndNoLaunchFlags() throws { + let command = try HandoffToCommand.parse(["claude", "App", "--no-brief", "--no-launch"]) - XCTAssertTrue(command.noPrepare) + XCTAssertTrue(command.briefOptions.noBrief) XCTAssertTrue(command.noLaunch) } - - func testStatusAcceptsPositionalTarget() throws { - let command = try HandoffStatusCommand.parse(["App"]) - - XCTAssertEqual( - try command.selector.resolve(positionalTarget: command.target), - .auto("App") - ) - } } diff --git a/ProwlCLITests/ProwlCLIIntegrationTests.swift b/ProwlCLITests/ProwlCLIIntegrationTests.swift index 7fca7ed4..b23202a0 100644 --- a/ProwlCLITests/ProwlCLIIntegrationTests.swift +++ b/ProwlCLITests/ProwlCLIIntegrationTests.swift @@ -1546,7 +1546,7 @@ final class ProwlCLIIntegrationTests: XCTestCase { let response = try CommandResponse( ok: true, command: "handoff", - schemaVersion: "prowl.cli.handoff.v1", + schemaVersion: "prowl.cli.handoff.v2", data: RawJSON(encoding: makeHandoffPayload(action: .save)) ) @@ -1563,6 +1563,8 @@ final class ProwlCLIIntegrationTests: XCTestCase { XCTAssertEqual(input.selector, .worktree("App")) XCTAssertEqual(input.note, "wip") XCTAssertTrue(input.launch) + XCTAssertNil(input.brief) + XCTAssertFalse(input.contextOnly) } else { XCTFail("Expected handoff command envelope") } @@ -1572,12 +1574,63 @@ final class ProwlCLIIntegrationTests: XCTestCase { XCTAssertEqual(payload["command"] as? String, "handoff") } + func testHandoffToSendsInlineBriefAndContextOnly() throws { + let socketPath = temporarySocketPath(suffix: "handoff-brief") + let response = try CommandResponse( + ok: true, + command: "handoff", + schemaVersion: "prowl.cli.handoff.v2", + data: RawJSON(encoding: makeHandoffPayload(action: .toAgent)) + ) + + let brief = "# Handoff\n\n## Objective\nShip.\n\n## Current State\nGreen.\n\n## Next Steps\n1. Go." + let (requestData, result) = try runWithMockServer( + socketPath: socketPath, + response: response, + args: ["handoff", "to", "claude", "--brief", brief, "--json"] + ) + + XCTAssertEqual(result.exitCode, 0) + let envelope = try JSONDecoder().decode(CommandEnvelope.self, from: requestData) + if case .handoff(let input) = envelope.command { + XCTAssertEqual(input.brief, brief) + XCTAssertFalse(input.contextOnly) + } else { + XCTFail("Expected handoff command envelope") + } + + let contextOnlySocket = temporarySocketPath(suffix: "handoff-no-brief") + let (contextOnlyRequest, contextOnlyResult) = try runWithMockServer( + socketPath: contextOnlySocket, + response: response, + args: ["handoff", "to", "claude", "--no-brief", "--json"] + ) + XCTAssertEqual(contextOnlyResult.exitCode, 0) + let contextOnlyEnvelope = try JSONDecoder().decode(CommandEnvelope.self, from: contextOnlyRequest) + if case .handoff(let input) = contextOnlyEnvelope.command { + XCTAssertNil(input.brief) + XCTAssertTrue(input.contextOnly) + } else { + XCTFail("Expected handoff command envelope") + } + } + + func testHandoffBriefConflictFailsBeforeTransport() throws { + let result = try runProwl(args: ["handoff", "to", "claude", "--brief", "x", "--no-brief", "--json"]) + + XCTAssertNotEqual(result.exitCode, 0) + let payload = try jsonObject(from: result.stdout) + XCTAssertEqual(payload["ok"] as? Bool, false) + let error = try XCTUnwrap(payload["error"] as? [String: Any]) + XCTAssertEqual(error["code"] as? String, CLIErrorCode.invalidArgument) + } + func testHandoffToRoundTripsOverSocket() throws { let socketPath = temporarySocketPath(suffix: "handoff-to") let response = try CommandResponse( ok: true, command: "handoff", - schemaVersion: "prowl.cli.handoff.v1", + schemaVersion: "prowl.cli.handoff.v2", data: RawJSON(encoding: makeHandoffPayload(action: .toAgent)) ) @@ -1604,7 +1657,7 @@ final class ProwlCLIIntegrationTests: XCTestCase { let response = try CommandResponse( ok: true, command: "handoff", - schemaVersion: "prowl.cli.handoff.v1", + schemaVersion: "prowl.cli.handoff.v2", data: RawJSON(encoding: makeHandoffPayload(action: .toAgent)) ) @@ -1629,7 +1682,7 @@ final class ProwlCLIIntegrationTests: XCTestCase { let response = try CommandResponse( ok: true, command: "handoff", - schemaVersion: "prowl.cli.handoff.v1", + schemaVersion: "prowl.cli.handoff.v2", data: RawJSON(encoding: makeHandoffPayload(action: .toAgent)) ) @@ -1660,43 +1713,12 @@ final class ProwlCLIIntegrationTests: XCTestCase { XCTAssertEqual(error["code"] as? String, CLIErrorCode.invalidArgument) } - func testHandoffStatusRoundTripsOverSocket() throws { - let socketPath = temporarySocketPath(suffix: "handoff-status") - let response = try CommandResponse( - ok: true, - command: "handoff", - schemaVersion: "prowl.cli.handoff.v1", - data: RawJSON(encoding: HandoffCommandPayload( - action: .status, - artifactPath: "/Projects/App/.prowl/handoff/current.md", - outgoingAgent: "codex", - exists: true, - lastLog: "- 2026-06-12T14:30:00Z save agent=codex repos=2 changed=3" - )) - ) - - let (requestData, result) = try runWithMockServer( - socketPath: socketPath, - response: response, - args: ["handoff", "status", "--json"] - ) - - XCTAssertEqual(result.exitCode, 0) - let envelope = try JSONDecoder().decode(CommandEnvelope.self, from: requestData) - if case .handoff(let input) = envelope.command { - XCTAssertEqual(input.action, .status) - XCTAssertEqual(input.selector, .none) - } else { - XCTFail("Expected handoff command envelope") - } - } - func testHandoffToTextRenderingFromSocket() throws { let socketPath = temporarySocketPath(suffix: "handoff-to-text") let response = try CommandResponse( ok: true, command: "handoff", - schemaVersion: "prowl.cli.handoff.v1", + schemaVersion: "prowl.cli.handoff.v2", data: RawJSON(encoding: HandoffCommandPayload( action: .toAgent, artifactPath: "/Projects/App/.prowl/handoff/current.md", @@ -1717,6 +1739,8 @@ final class ProwlCLIIntegrationTests: XCTestCase { excerptPath: "handoff/sessions/2026-06-12T1430-pane-0.md", transcriptPath: "/tmp/codex.jsonl" ), + briefing: "inline", + hasBriefing: true, launchedPane: HandoffPanePayload( worktreeID: "App:/Projects/App", worktreeName: "App", @@ -1736,6 +1760,7 @@ final class ProwlCLIIntegrationTests: XCTestCase { XCTAssertEqual(result.exitCode, 0) XCTAssertTrue(result.stdout.contains("codex → claude"), "Missing transition header: \(result.stdout)") XCTAssertTrue(result.stdout.contains("artifact:"), "Missing artifact line: \(result.stdout)") + XCTAssertTrue(result.stdout.contains("briefing:"), "Missing briefing line: \(result.stdout)") XCTAssertTrue(result.stdout.contains("session:"), "Missing session line: \(result.stdout)") XCTAssertTrue(result.stdout.contains("launched:"), "Missing launched line: \(result.stdout)") XCTAssertTrue(result.stdout.contains("pane-9"), "Missing launched pane id: \(result.stdout)") @@ -1746,7 +1771,7 @@ final class ProwlCLIIntegrationTests: XCTestCase { let response = try CommandResponse( ok: true, command: "handoff", - schemaVersion: "prowl.cli.handoff.v1", + schemaVersion: "prowl.cli.handoff.v2", data: RawJSON(encoding: HandoffCommandPayload( action: .toAgent, artifactPath: "/Projects/App/.prowl/handoff/current.md", diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index 75aa62dc..644e1790 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -365,6 +365,11 @@ struct SupacodeApp: App { focusSurface: { worktreeID, surfaceID in terminalManager.focusSurface(worktreeID: worktreeID, surfaceID: surfaceID) }, + sendTextToSurface: { worktreeID, surfaceID, text in + guard let state = terminalManager.stateIfExists(for: worktreeID) else { return false } + guard state.insertCommittedText(text, in: surfaceID) else { return false } + return state.submitLine(in: surfaceID) + }, markNotificationRead: { worktreeID, notificationID in terminalManager.markNotificationRead(worktreeID: worktreeID, notificationID: notificationID) }, @@ -687,33 +692,52 @@ struct SupacodeApp: App { } ) let handoffHandler = HandoffCommandHandler( - resolveProvider: { selector in + resolveProvider: { selector, callerPID in let resolver = TargetResolver { TargetResolutionSnapshotBuilder.makeSnapshot( repositoriesState: appStore.state.repositories, terminalManager: terminalManager ) } - return resolver.resolve(selector).map { resolved in - let agentState = terminalManager.stateIfExists(for: resolved.worktreeID)? - .surfaceAgentStates[resolved.paneID] - let agent = agentState?.detectedAgent?.rawValue - return HandoffResolvedTarget( - worktreeID: resolved.worktreeID, - worktreeName: resolved.worktreeName, - rootPath: resolved.worktreePath, - paneID: resolved.paneID.uuidString, - outgoingAgent: agent, - outgoingLaunchObservation: agentState?.launchObservation, - outgoingSession: agentState?.session, - sessionContext: makeHandoffSessionContext( - worktreeID: resolved.worktreeID, - paneID: resolved.paneID, - paneTitle: resolved.paneTitle, - terminalManager: terminalManager - ) + // The caller's own pane is the default source; an explicit selector + // overrides it. Never fall back to the focused pane — a handoff must + // not guess its subject from unstable UI state. + let callerPane = callerPID.flatMap { pid in + CallerPaneResolver.pane( + forCallerProcess: pid, + paneByShellPID: terminalManager.paneByShellPID() ) } + let effectiveSelector: TargetSelector + if case .none = selector { + guard let callerPane else { return .failure(.noCallerPane) } + effectiveSelector = .pane(callerPane.surfaceID.uuidString) + } else { + effectiveSelector = selector + } + return resolver.resolve(effectiveSelector) + .map { resolved in + let agentState = terminalManager.stateIfExists(for: resolved.worktreeID)? + .surfaceAgentStates[resolved.paneID] + let agent = agentState?.detectedAgent?.rawValue + return HandoffResolvedTarget( + worktreeID: resolved.worktreeID, + worktreeName: resolved.worktreeName, + rootPath: resolved.worktreePath, + paneID: resolved.paneID.uuidString, + outgoingAgent: agent, + outgoingLaunchObservation: agentState?.launchObservation, + outgoingSession: agentState?.session, + sessionContext: makeHandoffSessionContext( + worktreeID: resolved.worktreeID, + paneID: resolved.paneID, + paneTitle: resolved.paneTitle, + terminalManager: terminalManager + ), + isSelfHandoff: callerPane?.surfaceID == resolved.paneID + ) + } + .mapError { .resolver($0) } }, launchProvider: { target, request in Self.launchHandoffReceiver( @@ -723,7 +747,18 @@ struct SupacodeApp: App { terminalManager: terminalManager ) }, - preparationProvider: Self.prepareHandoffSource + forkProvider: Self.forkHandoffBriefing, + notifyLaunch: { launched, from, toAgent in + Self.notifyHandoffLaunch( + launched: launched, + from: from, + toAgent: toAgent, + terminalManager: terminalManager + ) + }, + completionObserver: { completion in + appStore.send(.handoffCliCompleted(completion)) + } ) return CLICommandRouter( openHandler: openHandler, @@ -739,6 +774,9 @@ struct SupacodeApp: App { ) } + /// Headless by construction: the receiving agent starts in a background tab + /// of the source's worktree — no worktree switch, no focus steal, no tab + /// selection. Awareness comes from `notifyHandoffLaunch`. private static func launchHandoffReceiver( target: HandoffResolvedTarget, request: AgentStartRequest, @@ -749,17 +787,14 @@ struct SupacodeApp: App { guard let worktree = resolveCLITerminalWorktree(id: target.worktreeID, repositories: repositories) else { return nil } - selectCLIWorktreeContext( - worktreeID: target.worktreeID, - appStore: appStore, - terminalManager: terminalManager - ) guard let initialInput = try? AgentRuntimeAdapterRegistry.makeStartInvocation(request).terminalInput else { return nil } let state = terminalManager.state(for: worktree) guard let tabID = state.createTab( + focusing: false, + selecting: false, initialInput: initialInput, workingDirectoryOverride: URL(fileURLWithPath: target.rootPath, isDirectory: true) ) @@ -781,7 +816,31 @@ struct SupacodeApp: App { } } - nonisolated private static func prepareHandoffSource( + /// One completion notification per launched transition, suppressed when the + /// user is already watching the target worktree (the appearing tab is the + /// signal there). Click-to-focus routes to the receiving pane through the + /// regular notification pipeline. + private static func notifyHandoffLaunch( + launched: HandoffLaunchedPane, + from: String, + toAgent: String, + terminalManager: WorktreeTerminalManager + ) { + let watching = + terminalManager.selectedWorktreeID == launched.worktreeID + && NSApplication.shared.isActive + guard !watching else { return } + guard let paneID = UUID(uuidString: launched.paneID), + let state = terminalManager.stateIfExists(for: launched.worktreeID) + else { return } + state.appendNotification( + title: "\(from) → \(toAgent)", + body: "Took over in \(launched.worktreeName)", + surfaceId: paneID + ) + } + + nonisolated private static func forkHandoffBriefing( request: AgentResumeRequest, directory: URL ) async throws -> String { diff --git a/supacode/CLIService/HandoffCommandHandler.swift b/supacode/CLIService/HandoffCommandHandler.swift index 7aaa458b..953a2d07 100644 --- a/supacode/CLIService/HandoffCommandHandler.swift +++ b/supacode/CLIService/HandoffCommandHandler.swift @@ -2,8 +2,9 @@ import Foundation -/// A handoff target resolved on the main actor: the runnable root to store the -/// artifact under, plus the agent currently detected in that target's pane. +/// A handoff source resolved on the main actor: the runnable root to store the +/// artifact under, the agent currently detected in that pane, and whether the +/// pane belongs to the calling process itself. struct HandoffResolvedTarget: Sendable, Equatable { let worktreeID: String let worktreeName: String @@ -13,6 +14,14 @@ struct HandoffResolvedTarget: Sendable, Equatable { let outgoingLaunchObservation: AgentLaunchObservation? let outgoingSession: AgentSession? let sessionContext: HandoffStore.SessionContext? + /// The resolved source pane is the pane the calling `prowl` process runs in. + let isSelfHandoff: Bool +} + +enum HandoffResolveError: Error { + case resolver(TargetResolverError) + /// No selector was given and the caller is not inside a Prowl pane. + case noCallerPane } /// The pane the receiving agent was launched into. @@ -24,39 +33,69 @@ struct HandoffLaunchedPane: Sendable, Equatable { let paneTitle: String } +/// A successful CLI handoff, announced so the UI (the Hand Off HUD waiting on +/// an injected request) can correlate it with the source pane and finish. +struct HandoffCLICompletion: Sendable, Equatable { + let action: HandoffAction + let sourcePaneID: String + let toAgent: String? + let briefing: HandoffBriefing + let launched: HandoffLaunchedPane? +} + @MainActor final class HandoffCommandHandler: CommandHandler { - typealias ResolveProvider = @MainActor (TargetSelector) -> Result + typealias ResolveProvider = + @MainActor (TargetSelector, pid_t?) -> Result typealias LaunchProvider = @MainActor (HandoffResolvedTarget, AgentStartRequest) -> HandoffLaunchedPane? - /// Resumes the source session headlessly and returns its reply text; the - /// handler validates the reply and transcribes it into `current.md`. - typealias PreparationProvider = @Sendable (AgentResumeRequest, URL) async throws -> String + /// Resumes the source session headlessly and returns its reply text + /// (the fork briefing fallback). + typealias ForkProvider = @Sendable (AgentResumeRequest, URL) async throws -> String + /// Announces a completed transition (`from`, `to`) for the launched pane. + typealias LaunchNotifier = @MainActor (HandoffLaunchedPane, String, String) -> Void + /// Announces every successful save/to so the UI can observe injected requests. + typealias CompletionObserver = @MainActor (HandoffCLICompletion) -> Void /// Agents this command can launch (it injects an agent-specific kickoff command). static let supportedAgents = HandoffAgentSupport.launchableAgents private let resolveProvider: ResolveProvider private let launchProvider: LaunchProvider - private let preparationProvider: PreparationProvider + private let forkProvider: ForkProvider + private let notifyLaunch: LaunchNotifier + private let completionObserver: CompletionObserver private let now: @Sendable () -> Date init( resolveProvider: @escaping ResolveProvider, launchProvider: @escaping LaunchProvider, - preparationProvider: @escaping PreparationProvider, + forkProvider: @escaping ForkProvider, + notifyLaunch: @escaping LaunchNotifier = { _, _, _ in }, + completionObserver: @escaping CompletionObserver = { _ in }, now: @escaping @Sendable () -> Date = { Date() } ) { self.resolveProvider = resolveProvider self.launchProvider = launchProvider - self.preparationProvider = preparationProvider + self.forkProvider = forkProvider + self.notifyLaunch = notifyLaunch + self.completionObserver = completionObserver self.now = now } func handle(envelope: CommandEnvelope) async -> CommandResponse { + await handle(envelope: envelope, context: CLICommandContext()) + } + + func handle(envelope: CommandEnvelope, context: CLICommandContext) async -> CommandResponse { guard case .handoff(let input) = envelope.command else { return errorResponse(code: CLIErrorCode.handoffFailed, message: "Invalid command.") } - + if input.brief != nil, input.contextOnly { + return errorResponse( + code: CLIErrorCode.invalidArgument, + message: "--brief and --no-brief are mutually exclusive." + ) + } if input.action == .toAgent { guard let rawAgent = input.toAgent, let toAgent = HandoffAgentSupport.normalize(rawAgent) else { return errorResponse( @@ -74,11 +113,19 @@ final class HandoffCommandHandler: CommandHandler { } let target: HandoffResolvedTarget - switch resolveProvider(input.selector) { + switch resolveProvider(input.selector, context.callerProcessID) { case .success(let resolved): target = resolved case .failure(let error): - return mapResolverError(error) + return mapResolveError(error) + } + + let briefingSource: HandoffBriefingSource + switch briefingDecision(for: input, target: target) { + case .source(let source): + briefingSource = source + case .rejected(let response): + return response } let store = HandoffStore(rootURL: URL(fileURLWithPath: target.rootPath, isDirectory: true)) @@ -86,12 +133,61 @@ final class HandoffCommandHandler: CommandHandler { switch input.action { case .save: - return await handleSave(input: input, target: target, store: store, timestamp: timestamp) + return await handleSave( + input: input, + target: target, + store: store, + briefingSource: briefingSource, + timestamp: timestamp + ) case .toAgent: - return await handleTo(input: input, target: target, store: store, timestamp: timestamp) - case .status: - return await handleStatus(target: target, store: store) + return await handleTo( + input: input, + target: target, + store: store, + briefingSource: briefingSource, + timestamp: timestamp + ) + } + } + + // MARK: - Briefing decision + + private enum BriefingDecision { + case source(HandoffBriefingSource) + case rejected(CommandResponse) + } + + /// Inline when provided, context-only when explicit, error for a brief-less + /// self-handoff (the author is on the command line — asking it to rerun with + /// `--brief` is the cheapest correct outcome), fork for third-party sources, + /// context-only when no safe fork exists. + private func briefingDecision( + for input: HandoffInput, + target: HandoffResolvedTarget + ) -> BriefingDecision { + if let brief = input.brief { + return .source(.inline(brief)) + } + if input.contextOnly { + return .source(.none) + } + if target.isSelfHandoff { + return .rejected( + errorResponse( + code: CLIErrorCode.briefRequired, + message: Self.briefRequiredMessage(action: input.action, toAgent: input.toAgent) + ) + ) } + if let request = Self.forkRequest( + outgoingAgent: target.outgoingAgent, + session: target.outgoingSession, + observation: target.outgoingLaunchObservation + ) { + return .source(.fork(request)) + } + return .source(.none) } // MARK: - save @@ -100,20 +196,32 @@ final class HandoffCommandHandler: CommandHandler { input: HandoffInput, target: HandoffResolvedTarget, store: HandoffStore, + briefingSource: HandoffBriefingSource, timestamp: Date ) async -> CommandResponse { - let outgoing = target.outgoingAgent - let note = input.note let coordinator = makeCoordinator(store: store) do { - let (result, preparation) = try await coordinator.save( - outgoingAgent: outgoing, + let (result, briefing) = try await coordinator.makeCheckpoint( + outgoingAgent: target.outgoingAgent, sessionContext: target.sessionContext, - note: note, - preparationRequest: preparationRequest(input: input, target: target), + note: input.note, + briefingSource: briefingSource, now: timestamp ) - return success(payload: makePayload(action: .save, save: result, preparation: preparation)) + completionObserver( + HandoffCLICompletion( + action: .save, + sourcePaneID: target.paneID, + toAgent: nil, + briefing: briefing, + launched: nil + ) + ) + return success( + payload: makePayload(action: .save, save: result, briefing: briefing) + ) + } catch HandoffBriefingError.invalidInlineBrief { + return errorResponse(code: CLIErrorCode.invalidBrief, message: Self.invalidBriefMessage()) } catch { return errorResponse( code: CLIErrorCode.handoffFailed, @@ -128,6 +236,7 @@ final class HandoffCommandHandler: CommandHandler { input: HandoffInput, target: HandoffResolvedTarget, store: HandoffStore, + briefingSource: HandoffBriefingSource, timestamp: Date ) async -> CommandResponse { guard let rawAgent = input.toAgent, let toAgent = HandoffAgentSupport.normalize(rawAgent) else { @@ -142,14 +251,15 @@ final class HandoffCommandHandler: CommandHandler { let artifacts: HandoffCoordinator.TransitionArtifacts do { - // Prepare, refresh the appendix, then archive before launching. artifacts = try await coordinator.makeTransitionArtifacts( outgoingAgent: outgoing, toAgent: toAgent, sessionContext: target.sessionContext, - preparationRequest: preparationRequest(input: input, target: target), + briefingSource: briefingSource, now: timestamp ) + } catch HandoffBriefingError.invalidInlineBrief { + return errorResponse(code: CLIErrorCode.invalidBrief, message: Self.invalidBriefMessage()) } catch { return errorResponse( code: CLIErrorCode.handoffFailed, @@ -173,100 +283,82 @@ final class HandoffCommandHandler: CommandHandler { target, AgentStartRequest( agent: destinationAgent, - prompt: Self.kickoffPrompt(), + prompt: Self.kickoffPrompt(hasBriefing: artifacts.hasBriefing), configuration: configuration ) ) - if launched == nil { + guard let launched else { await coordinator.logTransition( from: from, toAgent: toAgent, disposition: .failed, - preparation: artifacts.preparation, + briefing: artifacts.briefing, archivedPath: artifacts.archivedPath, note: input.note, now: timestamp ) return errorResponse(code: CLIErrorCode.handoffFailed, message: "Failed to launch \(toAgent).") } + notifyLaunch(launched, from, toAgent) } await coordinator.logTransition( from: from, toAgent: toAgent, disposition: launched.map { .pane($0.paneID) } ?? .skipped, - preparation: artifacts.preparation, + briefing: artifacts.briefing, note: input.note, now: timestamp ) + completionObserver( + HandoffCLICompletion( + action: .toAgent, + sourcePaneID: target.paneID, + toAgent: toAgent, + briefing: artifacts.briefing, + launched: launched + ) + ) + return success( payload: makePayload( action: .toAgent, save: artifacts.save, + briefing: artifacts.briefing, toAgent: toAgent, archivedPath: artifacts.archivedPath, - launched: launched, - preparation: artifacts.preparation + launched: launched ) ) } private func makeCoordinator(store: HandoffStore) -> HandoffCoordinator { - HandoffCoordinator(store: store, resume: preparationProvider) - } - - /// The safe source-preparation request, or nil when `--no-prepare` was - /// passed or no exact/high-confidence resumable session exists. - private func preparationRequest(input: HandoffInput, target: HandoffResolvedTarget) -> AgentResumeRequest? { - guard input.prepare else { return nil } - return Self.preparationRequest( - outgoingAgent: target.outgoingAgent, - session: target.outgoingSession, - observation: target.outgoingLaunchObservation - ) - } - - // MARK: - status - - private func handleStatus(target: HandoffResolvedTarget, store: HandoffStore) async -> CommandResponse { - let (status, sessionContext) = await Task.detached { - (store.readStatus(), target.sessionContext) - }.value - return success( - payload: HandoffCommandPayload( - action: .status, - artifactPath: status.artifactPath, - outgoingAgent: target.outgoingAgent, - sessionContext: sessionContext.map { - HandoffSessionPayload( - agent: $0.agent, - sessionID: $0.sessionID, - paneID: $0.paneID, - paneTitle: $0.paneTitle, - source: $0.source, - confidence: $0.confidence, - transcriptPath: $0.transcriptPath - ) - }, - exists: status.exists, - lastLog: status.lastLogLine - ) - ) + HandoffCoordinator(store: store, resume: forkProvider) } // MARK: - Kickoff prompt - nonisolated static func kickoffPrompt() -> String { - "Take over this Prowl workspace task. Read .prowl/handoff/current.md (agent notes), " - + ".prowl/handoff/context.md (generated state), and .prowl/workspace.json (repo layout, if present), " - + "then continue from Next Steps. Do not redo work already listed under What Has Been Done. " - + "If context.md lists a Session Context excerpt, read it before changing code. Earlier hand-off " - + "snapshots are under .prowl/handoff/archive/ if you need deeper history. " - + "Ask before any commit/push or destructive git." + nonisolated static func kickoffPrompt(hasBriefing: Bool) -> String { + if hasBriefing { + "Take over this Prowl workspace task. Read .prowl/handoff/current.md (the previous agent's " + + "briefing), .prowl/handoff/context.md (generated state), and .prowl/workspace.json " + + "(repo layout, if present), then continue from Next Steps. Do not redo work already listed " + + "under What Has Been Done. If context.md lists a Session Context excerpt, read it before " + + "changing code. Earlier hand-off snapshots are under .prowl/handoff/archive/ if you need " + + "deeper history. Ask before any commit/push or destructive git." + } else { + "Take over this Prowl workspace task. There is no briefing from the previous agent: orient " + + "from .prowl/handoff/context.md (generated repository and session state) and " + + ".prowl/workspace.json (repo layout, if present). If context.md lists a Session Context " + + "excerpt, read it before changing code. Earlier hand-off snapshots are under " + + ".prowl/handoff/archive/ if you need history. Ask before any commit/push or destructive git." + } } - nonisolated static func preparationRequest( + // MARK: - Fork briefing (fallback) + + nonisolated static func forkRequest( outgoingAgent: String?, session: AgentSession?, observation: AgentLaunchObservation? @@ -283,13 +375,13 @@ final class HandoffCommandHandler: CommandHandler { return AgentResumeRequest( agent: agent, session: session, - prompt: preparationPrompt(), + prompt: forkBriefingPrompt(), model: observation?.model ) } - nonisolated static func preparationPrompt() -> String { - "Prowl handoff preparation: another agent with none of your context will take over this task, " + nonisolated static func forkBriefingPrompt() -> String { + "Prowl handoff briefing: another agent with none of your context will take over this task, " + "starting only from the document you write now. Reply with the complete contents of a fresh " + ".prowl/handoff/current.md and nothing else — a markdown document titled \"# Handoff\" with the " + "sections \"## Objective\", \"## Current State\", \"## What Has Been Done\", \"## Open Questions\", " @@ -301,15 +393,54 @@ final class HandoffCommandHandler: CommandHandler { + "the file from your reply. Be concise and answer in a single reply." } + // MARK: - Error messages + + nonisolated static func briefRequiredMessage(action: HandoffAction, toAgent: String?) -> String { + let command = + switch action { + case .save: "prowl handoff save --brief -" + case .toAgent: "prowl handoff to \(toAgent ?? "") --brief -" + } + return """ + Self-handoff requires an inline briefing — you are the author. Rerun with your briefing on stdin: + \(command) <<'EOF' + # Handoff + ## Objective + … + ## Current State + … + ## What Has Been Done + … + ## Open Questions + … + ## Risks / Watch Out + … + ## Next Steps + … + ## Suggested Prompt For Next Agent + … + EOF + Write it from your current working knowledge. Use --no-brief only for an intentional \ + context-only handoff. + """ + } + + nonisolated static func invalidBriefMessage() -> String { + "The briefing is missing required sections. Include at least \"## Objective\", " + + "\"## Current State\", and \"## Next Steps\" " + + "(recommended: the full skeleton \(HandoffStore.briefingSections.joined(separator: " / "))). " + + "Nothing was written — fix the briefing and rerun." + } + // MARK: - Payload private func makePayload( action: HandoffAction, save: HandoffStore.SaveResult, + briefing: HandoffBriefing, toAgent: String? = nil, archivedPath: String? = nil, - launched: HandoffLaunchedPane? = nil, - preparation: HandoffPreparationOutcome? = nil + launched: HandoffLaunchedPane? = nil ) -> HandoffCommandPayload { HandoffCommandPayload( action: action, @@ -329,7 +460,8 @@ final class HandoffCommandHandler: CommandHandler { changedFileCount: save.totalChangedFiles, archivedPath: archivedPath, sessionContext: save.sessionContext, - preparation: preparation?.rawValue, + briefing: briefing.rawValue, + hasBriefing: briefing.wroteBriefing, launchedPane: launched.map { HandoffPanePayload( worktreeID: $0.worktreeID, @@ -349,7 +481,7 @@ final class HandoffCommandHandler: CommandHandler { return try CommandResponse( ok: true, command: "handoff", - schemaVersion: "prowl.cli.handoff.v1", + schemaVersion: "prowl.cli.handoff.v2", data: RawJSON(encoding: payload) ) } catch { @@ -357,12 +489,18 @@ final class HandoffCommandHandler: CommandHandler { } } - private func mapResolverError(_ error: TargetResolverError) -> CommandResponse { + private func mapResolveError(_ error: HandoffResolveError) -> CommandResponse { switch error { - case .notFound(let message): + case .resolver(.notFound(let message)): return errorResponse(code: CLIErrorCode.targetNotFound, message: message) - case .notUnique(let message): + case .resolver(.notUnique(let message)): return errorResponse(code: CLIErrorCode.targetNotUnique, message: message) + case .noCallerPane: + return errorResponse( + code: CLIErrorCode.sourceRequired, + message: "No source pane: run this inside a Prowl pane (the calling agent's pane becomes " + + "the source), or pass an explicit selector (--pane p3, --tab t2, --worktree )." + ) } } @@ -370,7 +508,7 @@ final class HandoffCommandHandler: CommandHandler { CommandResponse( ok: false, command: "handoff", - schemaVersion: "prowl.cli.handoff.v1", + schemaVersion: "prowl.cli.handoff.v2", error: CommandError(code: code, message: message) ) } diff --git a/supacode/CLIService/Shared/ErrorCodes.swift b/supacode/CLIService/Shared/ErrorCodes.swift index cad0aa7a..e7c71409 100644 --- a/supacode/CLIService/Shared/ErrorCodes.swift +++ b/supacode/CLIService/Shared/ErrorCodes.swift @@ -49,6 +49,12 @@ public enum CLIErrorCode { // Handoff public static let handoffFailed = "HANDOFF_FAILED" + /// Self-handoff invoked without `--brief`/`--no-brief`. + public static let briefRequired = "BRIEF_REQUIRED" + /// Inline briefing text failed validation; nothing was written. + 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" // Transport public static let transportFailed = "TRANSPORT_FAILED" diff --git a/supacode/CLIService/Shared/HandoffCommandPayload.swift b/supacode/CLIService/Shared/HandoffCommandPayload.swift index 14e11cc0..f9c536f7 100644 --- a/supacode/CLIService/Shared/HandoffCommandPayload.swift +++ b/supacode/CLIService/Shared/HandoffCommandPayload.swift @@ -13,14 +13,12 @@ public struct HandoffCommandPayload: Codable, Sendable, Equatable { public let archivedPath: String? /// Auto-captured outgoing session context saved alongside `current.md`. public let sessionContext: HandoffSessionPayload? - /// Whether source preparation completed, failed, or was skipped (save/to). - public let preparation: String? + /// How the briefing was obtained: "inline", "fork", "none", or "failed". + public let briefing: String? + /// Whether a fresh `current.md` was written for the receiver. + public let hasBriefing: Bool /// The pane the receiving agent was launched into (for `to` with launch). public let launchedPane: HandoffPanePayload? - /// Whether `current.md` exists (for `status`). - public let exists: Bool? - /// Most recent handoff-log line (for `status`). - public let lastLog: String? enum CodingKeys: String, CodingKey { case action @@ -31,10 +29,9 @@ public struct HandoffCommandPayload: Codable, Sendable, Equatable { case changedFileCount = "changed_file_count" case archivedPath = "archived_path" case sessionContext = "session_context" - case preparation + case briefing + case hasBriefing = "has_briefing" case launchedPane = "launched_pane" - case exists - case lastLog = "last_log" } public init( @@ -46,10 +43,9 @@ public struct HandoffCommandPayload: Codable, Sendable, Equatable { changedFileCount: Int = 0, archivedPath: String? = nil, sessionContext: HandoffSessionPayload? = nil, - preparation: String? = nil, - launchedPane: HandoffPanePayload? = nil, - exists: Bool? = nil, - lastLog: String? = nil + briefing: String? = nil, + hasBriefing: Bool = false, + launchedPane: HandoffPanePayload? = nil ) { self.action = action self.artifactPath = artifactPath @@ -59,10 +55,9 @@ public struct HandoffCommandPayload: Codable, Sendable, Equatable { self.changedFileCount = changedFileCount self.archivedPath = archivedPath self.sessionContext = sessionContext - self.preparation = preparation + self.briefing = briefing + self.hasBriefing = hasBriefing self.launchedPane = launchedPane - self.exists = exists - self.lastLog = lastLog } } diff --git a/supacode/CLIService/Shared/InputModels.swift b/supacode/CLIService/Shared/InputModels.swift index 677b0df3..8148b068 100644 --- a/supacode/CLIService/Shared/InputModels.swift +++ b/supacode/CLIService/Shared/InputModels.swift @@ -175,7 +175,6 @@ public struct TabInput: Codable, Sendable { public enum HandoffAction: String, Codable, Sendable { case save case toAgent = "to" - case status } public struct HandoffInput: Codable, Sendable { @@ -188,9 +187,12 @@ public struct HandoffInput: Codable, Sendable { /// When false, `to` refreshes + archives the handoff but does not launch the /// receiving agent (the human takes over manually). public let launch: Bool - /// When false, skip asking the detected source agent to refresh `current.md` - /// before the save (`--no-prepare`). - public let prepare: Bool + /// Inline agent-authored briefing text (`--brief`); the primary briefing + /// path for self-handoffs. + public let brief: String? + /// Explicit context-only run (`--no-brief`): no briefing is collected and + /// no fork resume is attempted. + public let contextOnly: Bool enum CodingKeys: String, CodingKey { case action @@ -198,7 +200,8 @@ public struct HandoffInput: Codable, Sendable { case toAgent = "to_agent" case note case launch - case prepare + case brief + case contextOnly = "context_only" } public init( @@ -207,14 +210,16 @@ public struct HandoffInput: Codable, Sendable { toAgent: String? = nil, note: String? = nil, launch: Bool = true, - prepare: Bool = true + brief: String? = nil, + contextOnly: Bool = false ) { self.action = action self.selector = selector self.toAgent = toAgent self.note = note self.launch = launch - self.prepare = prepare + self.brief = brief + self.contextOnly = contextOnly } public init(from decoder: Decoder) throws { @@ -224,7 +229,8 @@ public struct HandoffInput: Codable, Sendable { self.toAgent = try container.decodeIfPresent(String.self, forKey: .toAgent) self.note = try container.decodeIfPresent(String.self, forKey: .note) self.launch = try container.decodeIfPresent(Bool.self, forKey: .launch) ?? true - self.prepare = try container.decodeIfPresent(Bool.self, forKey: .prepare) ?? true + self.brief = try container.decodeIfPresent(String.self, forKey: .brief) + self.contextOnly = try container.decodeIfPresent(Bool.self, forKey: .contextOnly) ?? false } } diff --git a/supacode/Clients/Terminal/TerminalClient.swift b/supacode/Clients/Terminal/TerminalClient.swift index 5316a8ce..03a3c585 100644 --- a/supacode/Clients/Terminal/TerminalClient.swift +++ b/supacode/Clients/Terminal/TerminalClient.swift @@ -19,6 +19,9 @@ struct TerminalClient { var handoffSessionContextForSurface: @MainActor @Sendable (Worktree.ID, UUID) -> HandoffStore.SessionContext? var latestUnreadNotification: @MainActor @Sendable () -> NotificationLocation? var focusSurface: @MainActor @Sendable (Worktree.ID, UUID) -> Bool + /// Types a line into a specific pane and submits it. The UI handoff path + /// injects its request to the live source agent this way. + var sendTextToSurface: @MainActor @Sendable (Worktree.ID, UUID, String) -> Bool var markNotificationRead: @MainActor @Sendable (Worktree.ID, UUID) -> Void var markNotificationsReadForSurface: @MainActor @Sendable (Worktree.ID, UUID) -> Void @@ -97,6 +100,7 @@ extension TerminalClient: DependencyKey { handoffSessionContextForSurface: { _, _ in nil }, latestUnreadNotification: { nil }, focusSurface: { _, _ in false }, + sendTextToSurface: { _, _, _ in false }, markNotificationRead: { _, _ in }, markNotificationsReadForSurface: { _, _ in } ) @@ -111,6 +115,7 @@ extension TerminalClient: DependencyKey { handoffSessionContextForSurface: { _, _ in nil }, latestUnreadNotification: { nil }, focusSurface: { _, _ in false }, + sendTextToSurface: { _, _, _ in false }, markNotificationRead: { _, _ in }, markNotificationsReadForSurface: { _, _ in } ) diff --git a/supacode/Domain/Handoff/HandoffCoordinator.swift b/supacode/Domain/Handoff/HandoffCoordinator.swift index 7d9f2e9d..896271f9 100644 --- a/supacode/Domain/Handoff/HandoffCoordinator.swift +++ b/supacode/Domain/Handoff/HandoffCoordinator.swift @@ -1,31 +1,42 @@ import Foundation -/// The outgoing side of a handoff as observed on the selected pane: the +/// The outgoing side of a handoff as observed on the source pane: the /// session context persisted into the artifact, the argv-derived launch -/// observation, and the pid-anchored native session used for preparation. +/// observation, and the pid-anchored native session used for a fork briefing. nonisolated struct HandoffSourceContext: Sendable, Equatable { let sessionContext: HandoffStore.SessionContext? let observation: AgentLaunchObservation? let session: AgentSession? } -/// A source preparation reply before it is accepted for persistence. -/// HUD callers keep this transient until their reducer accepts the briefing -/// completion; a cancelled HUD must never let a late reply mutate the artifact. -nonisolated enum HandoffPreparationReply: Equatable, Sendable { - case reply(String) - case skipped - case failed +/// Where a transition's briefing comes from. The entry point decides; the +/// coordinator executes. Inline is the primary path (the author is present), +/// fork is the explicit fallback (the author is not), none is context-only. +nonisolated enum HandoffBriefingSource: Sendable, Equatable { + /// Agent-authored text supplied with the command (`--brief`). Invalid text + /// throws before any filesystem side effect. + case inline(String) + /// Resume the source session headlessly and use its validated reply. + /// Failure degrades the transition to context-only (`HandoffBriefing.failed`). + case fork(AgentResumeRequest) + /// Intentionally context-only (`--no-brief`, or no resumable source). + case none } -/// Shared orchestration core for a handoff: optional source-authored -/// preparation, the mechanical context save, the pre-launch archive, and the -/// transition log line. The CLI's `HandoffCommandHandler` and the Command -/// Palette reducer drive this one sequence so the entry points cannot drift; -/// a future toolbar UI becomes a third caller of the same type. +nonisolated enum HandoffBriefingError: Error, Equatable, Sendable { + /// Inline briefing text failed validation; nothing was written. + case invalidInlineBrief +} + +/// 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: +/// +/// collect briefing → archive outgoing state → install fresh briefing +/// (or remove the stale one) → refresh generated context → [launch] → log /// /// Launching the receiving agent stays with the caller — the CLI needs the -/// synchronously resolved pane for its payload while the palette fires a +/// synchronously resolved pane for its payload while UI callers fire a /// terminal command — but every persisted artifact and log format lives here. nonisolated struct HandoffCoordinator: Sendable { /// Resumes a source session headlessly and returns its reply text. @@ -41,16 +52,18 @@ nonisolated struct HandoffCoordinator: Sendable { /// Everything `handoff to` persists before the receiving agent launches. struct TransitionArtifacts: Sendable { - let preparation: HandoffPreparationOutcome + let briefing: HandoffBriefing let save: HandoffStore.SaveResult let archivedPath: String? + /// A fresh `current.md` exists for the receiver to read. + var hasBriefing: Bool { briefing.wroteBriefing } } /// How the receiving agent was (or wasn't) started, for the log line. enum LaunchDisposition: Sendable { /// Launched into a resolved pane (CLI path). case pane(String) - /// Launch was handed to the terminal without a resolved pane (palette path). + /// Launch was handed to the terminal without a resolved pane (UI path). case requested /// `--no-launch`. case skipped @@ -58,110 +71,97 @@ nonisolated struct HandoffCoordinator: Sendable { case failed } - /// Resume the source read-only without mutating the artifact. Staged callers - /// must explicitly accept and apply a reply so cancellation remains a real - /// filesystem transaction boundary. - func collectPreparation(_ request: AgentResumeRequest?) async -> HandoffPreparationReply { - guard let request else { return .skipped } - do { - return .reply(try await resume(request, store.rootURL)) - } catch { - return .failed - } - } - - /// Validate and transcribe an accepted preparation reply into `current.md`. - /// A cancelled task never writes, including when its resume dependency - /// returned a reply after observing cancellation late. - func applyPreparation(_ reply: HandoffPreparationReply, now: Date) -> HandoffPreparationOutcome { - switch reply { - case .reply(let text): - guard !Task.isCancelled else { return .skipped } - return store.applyPreparationReply(text, now: now) ? .completed : .failed - case .skipped: - return .skipped - case .failed: - return .failed + /// Resolve the 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. + private func collectBriefing( + _ source: HandoffBriefingSource + ) async throws -> (artifact: String?, briefing: HandoffBriefing) { + switch source { + case .inline(let raw): + guard let artifact = HandoffStore.validatedBriefing(from: raw) else { + throw HandoffBriefingError.invalidInlineBrief + } + return (artifact, .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 (artifact, .fork) + } catch is CancellationError { + throw CancellationError() + } catch { + if Task.isCancelled { throw CancellationError() } + return (nil, .failed) + } + case .none: + return (nil, .none) } } - /// Resume, then immediately apply the reply for single-shot callers such as - /// the CLI. HUD callers use `collectPreparation` and `applyPreparation` - /// separately so reducer state decides whether a reply may be persisted. - func prepare(_ request: AgentResumeRequest?, now: Date) async -> HandoffPreparationOutcome { - applyPreparation(await collectPreparation(request), now: now) - } - - /// Refresh generated context with an already-decided preparation outcome. - /// Staged callers collect a reply before the reducer accepts it, while - /// `save`/`makeTransitionArtifacts` compose collection and persistence. - func saveArtifact( + /// `handoff to`, up to the destination launch: collect the briefing, archive + /// the outgoing state, install the fresh briefing (or remove the stale one), + /// and refresh generated context. The archive precedes every rewrite, so the + /// outgoing round always survives in `archive/` regardless of what the new + /// briefing contains. + func makeTransitionArtifacts( outgoingAgent: String?, + toAgent: String, sessionContext: HandoffStore.SessionContext?, - note: String?, - preparation: HandoffPreparationOutcome?, + briefingSource: HandoffBriefingSource, now: Date - ) async throws -> HandoffStore.SaveResult { + ) async throws -> TransitionArtifacts { + let (artifact, briefing) = try await collectBriefing(briefingSource) let store = self.store + let from = outgoingAgent ?? "agent" return try await Task.detached { - try store.save( + let archivedPath = try store.archiveCurrent(from: from, toAgent: toAgent, now: now) + if let artifact { + try store.writeBriefing(artifact, archivingPrevious: false, now: now) + } else { + try store.removeCurrentArtifact() + } + let save = try store.save( outgoingAgent: outgoingAgent, sessionContext: sessionContext, - note: note, - preparation: preparation, + note: nil, + briefing: nil, now: now ) + return TransitionArtifacts(briefing: briefing, save: save, archivedPath: archivedPath) }.value } - /// Archive the combined artifact snapshot ahead of the destination launch. - func archive(from: String, toAgent: String, now: Date) async throws -> String? { - let store = self.store - return try await Task.detached { - try store.archiveCurrent(from: from, toAgent: toAgent, now: now) - }.value - } - - /// `handoff save`: prepare, then refresh generated context, recording the - /// preparation outcome on the single save log line. - func save( + /// `handoff save`: a deferred-handoff checkpoint. Installs a fresh briefing + /// when one is available (archiving the replaced one) and refreshes + /// generated context. Unlike a transition it never removes an earlier + /// checkpoint — with no receiver, the last validated briefing stays valid. + func makeCheckpoint( outgoingAgent: String?, sessionContext: HandoffStore.SessionContext?, note: String?, - preparationRequest: AgentResumeRequest?, - now: Date - ) async throws -> (result: HandoffStore.SaveResult, preparation: HandoffPreparationOutcome) { - let preparation = await prepare(preparationRequest, now: now) - let result = try await saveArtifact( - outgoingAgent: outgoingAgent, - sessionContext: sessionContext, - note: note, - preparation: preparation, - now: now - ) - return (result, preparation) - } - - /// `handoff to`, up to the destination launch: prepare, refresh generated - /// context, and archive the combined artifact snapshot. The preparation - /// outcome is recorded on the transition log line, not the save line. - func makeTransitionArtifacts( - outgoingAgent: String?, - toAgent: String, - sessionContext: HandoffStore.SessionContext?, - preparationRequest: AgentResumeRequest?, + briefingSource: HandoffBriefingSource, now: Date - ) async throws -> TransitionArtifacts { - let preparation = await prepare(preparationRequest, now: now) - let save = try await saveArtifact( - outgoingAgent: outgoingAgent, - sessionContext: sessionContext, - note: nil, - preparation: nil, - now: now - ) - let archivedPath = try await archive(from: outgoingAgent ?? "agent", toAgent: toAgent, now: now) - return TransitionArtifacts(preparation: preparation, save: save, archivedPath: archivedPath) + ) async throws -> (save: HandoffStore.SaveResult, briefing: HandoffBriefing) { + let (artifact, briefing) = try await collectBriefing(briefingSource) + let store = self.store + let save = try await Task.detached { + if let artifact { + try store.writeBriefing(artifact, archivingPrevious: true, now: now) + } + return try store.save( + outgoingAgent: outgoingAgent, + sessionContext: sessionContext, + note: note, + briefing: briefing, + now: now + ) + }.value + return (save, briefing) } /// Append the single transition line; every entry point shares this format. @@ -169,7 +169,7 @@ nonisolated struct HandoffCoordinator: Sendable { from: String, toAgent: String, disposition: LaunchDisposition, - preparation: HandoffPreparationOutcome, + briefing: HandoffBriefing, archivedPath: String? = nil, note: String? = nil, source: String? = nil, @@ -179,7 +179,7 @@ nonisolated struct HandoffCoordinator: Sendable { from: from, toAgent: toAgent, disposition: disposition, - preparation: preparation, + briefing: briefing, archivedPath: archivedPath, note: note, source: source @@ -194,7 +194,7 @@ nonisolated struct HandoffCoordinator: Sendable { from: String, toAgent: String, disposition: LaunchDisposition, - preparation: HandoffPreparationOutcome, + briefing: HandoffBriefing, archivedPath: String? = nil, note: String? = nil, source: String? = nil @@ -206,7 +206,7 @@ nonisolated struct HandoffCoordinator: Sendable { case .skipped: " (no launch)" case .failed: " launch=failed" } - var line = "\(from) → \(toAgent)\(launchPart) preparation=\(preparation.rawValue)" + var line = "\(from) → \(toAgent)\(launchPart) briefing=\(briefing.rawValue)" if case .failed = disposition, let archivedPath { line += " archive=\(archivedPath)" } diff --git a/supacode/Domain/Handoff/HandoffInjection.swift b/supacode/Domain/Handoff/HandoffInjection.swift new file mode 100644 index 00000000..0cc22a31 --- /dev/null +++ b/supacode/Domain/Handoff/HandoffInjection.swift @@ -0,0 +1,26 @@ +import Foundation + +/// The one-line, self-contained request the UI types into the live source +/// agent. The agent composes the heredoc itself — nothing multi-line is ever +/// injected, so any TUI input box can take it. +nonisolated enum HandoffInjection { + enum Purpose: Equatable, Sendable { + case handOff(agent: String) + case checkpoint + } + + static func instruction(for purpose: Purpose) -> 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 -`" + case .checkpoint: + "Please checkpoint your progress for a later handoff: run `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." + } +} diff --git a/supacode/Domain/Handoff/HandoffStore.swift b/supacode/Domain/Handoff/HandoffStore.swift index 5f72a9de..dce7e937 100644 --- a/supacode/Domain/Handoff/HandoffStore.swift +++ b/supacode/Domain/Handoff/HandoffStore.swift @@ -1,11 +1,21 @@ import Darwin import Foundation -/// Outcome of asking the outgoing agent to refresh `current.md` before a save. -nonisolated enum HandoffPreparationOutcome: String, Equatable, Sendable { - case completed - case skipped +/// How the transition's briefing was (or wasn't) obtained. `current.md` exists +/// iff a validated briefing produced it, so this value is the whole story of +/// the semantic side of a handoff. +nonisolated enum HandoffBriefing: String, Equatable, Sendable { + /// Agent-authored brief supplied inline with the command (`--brief`). + case inline + /// Brief collected by resuming the source session headlessly (fallback). + case fork + /// Intentionally context-only (`--no-brief`, or no resumable source). + case none + /// A fork was attempted and failed; the transition degraded to context-only. case failed + + /// A validated briefing was written for this outcome. + var wroteBriefing: Bool { self == .inline || self == .fork } } /// On-disk store for the cross-agent handoff artifact that lives under a @@ -57,36 +67,19 @@ nonisolated struct HandoffStore: Sendable { FileManager.default.fileExists(atPath: currentURL.path(percentEncoded: false)) } - /// Initial agent-authored artifact. Generated state is kept separately in - /// `context.md` so Prowl never needs to rewrite this file after scaffolding. - static let template = """ - # Handoff - - ## Objective - - - ## Current State - - - ## What Has Been Done - - - ## Open Questions - - - ## Risks / Watch Out - - - ## Next Steps - - - ## Suggested Prompt For Next Agent - - - --- - Generated repository and session state: [context.md](context.md) (managed by Prowl). - - """ + /// The section skeleton a briefing must follow. Referenced by error messages + /// and the pane-injected UI instruction; never written to disk by Prowl — + /// `current.md` exists iff a validated briefing produced it. + static let briefingSections = [ + "# Handoff", + "## Objective", + "## Current State", + "## What Has Been Done", + "## Open Questions", + "## Risks / Watch Out", + "## Next Steps", + "## Suggested Prompt For Next Agent", + ] // MARK: - Result models @@ -140,35 +133,18 @@ nonisolated struct HandoffStore: Sendable { } } - struct StatusResult: Sendable, Equatable { - let artifactPath: String - let exists: Bool - let workspaceTitle: String? - let lastLogLine: String? - } - - // MARK: - Scaffold + // MARK: - Layout - /// Create the `.prowl/handoff/` tree and seed `current.md` from the template - /// when it does not yet exist. - func ensureScaffold() throws { + /// Create the `.prowl/handoff/` tree and its self-ignoring `.gitignore`. + /// Never seeds `current.md`: the artifact exists only as the product of a + /// validated briefing. + func ensureLayout() throws { let fileManager = FileManager.default try fileManager.createDirectory(at: archiveDirectory, withIntermediateDirectories: true) try fileManager.createDirectory(at: sessionDirectory, withIntermediateDirectories: true) if !fileManager.fileExists(atPath: ignoreURL.path(percentEncoded: false)) { try "*\n".write(to: ignoreURL, atomically: true, encoding: .utf8) } - if !fileManager.fileExists(atPath: currentURL.path(percentEncoded: false)) { - let temporaryURL = handoffDirectory.appending(path: ".current-\(UUID().uuidString).tmp") - defer { try? fileManager.removeItem(at: temporaryURL) } - try Data(Self.template.utf8).write(to: temporaryURL, options: .atomic) - if Darwin.link( - temporaryURL.path(percentEncoded: false), - currentURL.path(percentEncoded: false) - ) != 0, errno != EEXIST { - throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) - } - } } // MARK: - Save @@ -180,10 +156,10 @@ nonisolated struct HandoffStore: Sendable { outgoingAgent: String?, sessionContext: SessionContext? = nil, note: String?, - preparation: HandoffPreparationOutcome? = nil, + briefing: HandoffBriefing? = nil, now: Date ) throws -> SaveResult { - try ensureScaffold() + try ensureLayout() let repos = repoSummaries() let changedFiles = changedFilePaths(for: repos) @@ -200,8 +176,8 @@ nonisolated struct HandoffStore: Sendable { let total = repos.reduce(0) { $0 + $1.changedFileCount } var logLine = "save agent=\(outgoingAgent ?? "unknown") repos=\(repos.count) changed=\(total)" - if let preparation { - logLine += " preparation=\(preparation.rawValue)" + if let briefing { + logLine += " briefing=\(briefing.rawValue)" } try appendLog(logLine + Self.noteSuffix(note), now: now) @@ -214,38 +190,52 @@ nonisolated struct HandoffStore: Sendable { ) } - // MARK: - Prepared artifact + // MARK: - Briefing - /// Validates a source agent's preparation reply and transcribes it into - /// `current.md`. Prowl never authors semantic prose: the reply text is the - /// source agent's, this method only checks shape and writes it verbatim. - /// Returns false (leaving the existing artifact in place) when the reply is - /// empty, still the seeded template, or missing the core semantic sections. - /// The previous artifact is snapshotted into `archive/` first, so a reply - /// that drops sections never destroys the only copy of earlier notes. - func applyPreparationReply(_ reply: String, now: Date) -> Bool { - guard let artifact = Self.preparedArtifact(fromAgentReply: reply) else { return false } - do { - try FileManager.default.createDirectory(at: handoffDirectory, withIntermediateDirectories: true) - try snapshotCurrentBeforePreparation(now: now) - try artifact.write(to: currentURL, atomically: true, encoding: .utf8) - return true - } catch { - return false + /// Normalizes agent-authored briefing text — an inline `--brief` payload or a + /// fork reply — into artifact content, or nil when unusable. Prowl never + /// authors semantic prose: this only strips chat wrapping (fences, preamble) + /// and checks that the core sections are present. + static func validatedBriefing(from text: String) -> String? { + var text = text.trimmingCharacters(in: .whitespacesAndNewlines) + let hadOpeningFence = text.hasPrefix("```") + text = droppingOpeningFence(text) + text = droppingPreamble(text) + text = droppingClosingFence(text, truncatingTrailer: hadOpeningFence) + let requiredSections = ["## Objective", "## Current State", "## Next Steps"] + guard !text.isEmpty, requiredSections.allSatisfy(text.contains) else { return nil } + return text + "\n" + } + + /// Write a validated briefing to `current.md`. With `archivingPrevious` the + /// existing artifact is snapshotted into `archive/` first, so a rewrite can + /// never destroy the only copy of the previous briefing. Transitions pass + /// `false` because they already archived the outgoing state as a combined + /// snapshot. + func writeBriefing(_ artifact: String, archivingPrevious: Bool, now: Date) throws { + try ensureLayout() + if archivingPrevious { + try snapshotCurrentBeforeRewrite(now: now) } + try artifact.write(to: currentURL, atomically: true, encoding: .utf8) } - /// Copy the existing `current.md` into `archive/-preparation-backup.md` - /// before a preparation reply overwrites it. The seeded template carries no - /// prose and is not worth archiving; a missing artifact means a first-ever - /// preparation. An unreadable artifact throws, failing the preparation and - /// leaving `current.md` untouched. - private func snapshotCurrentBeforePreparation(now: Date) throws { - guard FileManager.default.fileExists(atPath: currentURL.path(percentEncoded: false)) else { return } + /// Remove `current.md` after the caller archived it: with no fresh briefing + /// the receiver must read `context.md` and the `archive/` chain — a previous + /// round's briefing must never impersonate the handoff contract. + func removeCurrentArtifact() throws { + guard hasCurrentArtifact else { return } + try FileManager.default.removeItem(at: currentURL) + } + + /// Copy the existing `current.md` into `archive/-replaced-current.md` + /// before a checkpoint rewrite. A missing artifact means a first-ever write. + /// An unreadable artifact throws, leaving `current.md` untouched. + private func snapshotCurrentBeforeRewrite(now: Date) throws { + guard hasCurrentArtifact else { return } let existing = try String(contentsOf: currentURL, encoding: .utf8) - guard existing != Self.template else { return } try FileManager.default.createDirectory(at: archiveDirectory, withIntermediateDirectories: true) - let stem = "\(Self.fileStamp(now))-preparation-backup" + let stem = "\(Self.fileStamp(now))-replaced-current" let destination = try Self.reserveFileURL(in: archiveDirectory, stem: stem, fileExtension: "md") var didWrite = false defer { @@ -257,19 +247,6 @@ nonisolated struct HandoffStore: Sendable { didWrite = true } - /// Normalizes a preparation reply into artifact content, or nil when unusable. - static func preparedArtifact(fromAgentReply reply: String) -> String? { - var text = reply.trimmingCharacters(in: .whitespacesAndNewlines) - let hadOpeningFence = text.hasPrefix("```") - text = droppingOpeningFence(text) - text = droppingPreamble(text) - text = droppingClosingFence(text, truncatingTrailer: hadOpeningFence) - let requiredSections = ["## Objective", "## Current State", "## Next Steps"] - guard !text.isEmpty, requiredSections.allSatisfy(text.contains) else { return nil } - guard text != template.trimmingCharacters(in: .whitespacesAndNewlines) else { return nil } - return text + "\n" - } - /// Unwraps the opening line of a markdown code fence ("```markdown"). private static func droppingOpeningFence(_ text: String) -> String { guard text.hasPrefix("```") else { return text } @@ -351,22 +328,6 @@ nonisolated struct HandoffStore: Sendable { try handle.write(contentsOf: Data(line.utf8)) } - // MARK: - Status - - func readStatus() -> StatusResult { - let exists = FileManager.default.fileExists(atPath: currentURL.path(percentEncoded: false)) - let lastLogLine = (try? String(contentsOf: logURL, encoding: .utf8))? - .split(separator: "\n", omittingEmptySubsequences: true) - .last { $0.hasPrefix("- ") } - .map(String.init) - return StatusResult( - artifactPath: currentURL.path(percentEncoded: false), - exists: exists, - workspaceTitle: workspaceTitle, - lastLogLine: lastLogLine - ) - } - // MARK: - Repo enumeration /// Repos in scope: workspace children when `workspace.json` is present, diff --git a/supacode/Features/App/Reducer/AppFeature+TerminalEvents.swift b/supacode/Features/App/Reducer/AppFeature+TerminalEvents.swift index 923f9365..71058526 100644 --- a/supacode/Features/App/Reducer/AppFeature+TerminalEvents.swift +++ b/supacode/Features/App/Reducer/AppFeature+TerminalEvents.swift @@ -144,21 +144,15 @@ extension AppFeature { ) -> Effect? { switch event { case .agentEntryChanged(let entry): - let autoSaveEffect = handoffAutoSaveEffect(for: entry, state: &state) - return .merge( - autoSaveEffect, - .send( - .repositories( - .activeAgents( - .agentEntryChanged(entry, autoShowPanel: state.settings.autoShowActiveAgentsPanel) - ) + return .send( + .repositories( + .activeAgents( + .agentEntryChanged(entry, autoShowPanel: state.settings.autoShowActiveAgentsPanel) ) ) ) case .agentEntryRemoved(let id): - state.handoffAutoSaveDisplayStates.removeValue(forKey: id) - state.handoffAutoSaveLastSavedAt.removeValue(forKey: id) return .send(.repositories(.activeAgents(.agentEntryRemoved(id)))) default: @@ -176,82 +170,6 @@ extension AppFeature { let isViewed: Bool } - private func handoffAutoSaveEffect( - for entry: ActiveAgentEntry, - state: inout State - ) -> Effect { - let previous = state.handoffAutoSaveDisplayStates[entry.id] - state.handoffAutoSaveDisplayStates[entry.id] = entry.displayState - - guard previous == .working, - entry.displayState == .done || entry.displayState == .blocked, - let rootURL = handoffAutoSaveRootURL(for: entry.worktreeID, state: state) - else { - return .none - } - let store = HandoffStore(rootURL: rootURL) - guard store.hasCurrentArtifact, - shouldAutoSaveHandoff(for: entry, state: &state) - else { - return .none - } - - let agent = entry.agent.rawValue - let displayState = entry.displayState - let note = "auto-save: \(agent) \(displayState.rawValue)" - let sessionContext = terminalClient.handoffSessionContextForSurface(entry.worktreeID, entry.surfaceID) - let saveDate = now - return .run { _ in - do { - _ = try await Task.detached { - try store.save( - outgoingAgent: agent, - sessionContext: sessionContext, - note: note, - now: saveDate - ) - }.value - } catch { - await MainActor.run { - appLogger.warning("[HandoffAutoSave] failed for \(rootURL.path(percentEncoded: false)): \(error)") - } - } - } - } - - private func shouldAutoSaveHandoff( - for entry: ActiveAgentEntry, - state: inout State - ) -> Bool { - let minimumInterval: TimeInterval = 30 - let currentTime = now - if let lastSavedAt = state.handoffAutoSaveLastSavedAt[entry.id], - currentTime.timeIntervalSince(lastSavedAt) < minimumInterval - { - return false - } - state.handoffAutoSaveLastSavedAt[entry.id] = currentTime - return true - } - - private func handoffAutoSaveRootURL( - for worktreeID: Worktree.ID, - state: State - ) -> URL? { - for repository in state.repositories.repositories { - if let worktree = repository.worktrees[id: worktreeID] { - return worktree.workingDirectory - } - if repository.id == worktreeID, - repository.capabilities.supportsRunnableFolderActions, - !repository.capabilities.supportsWorktrees - { - return repository.rootURL - } - } - return nil - } - func terminalNotificationReceivedEffect( _ notification: TerminalNotificationPayload, state: State diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 82c5b35b..1c5edb0b 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -30,8 +30,6 @@ struct AppFeature { var suppressLayoutSaveUntilRelaunch = false var launchedAt: Date? var leftSidebarVisibility: NavigationSplitViewVisibility = .all - var handoffAutoSaveDisplayStates: [ActiveAgentEntry.ID: AgentDisplayState] = [:] - var handoffAutoSaveLastSavedAt: [ActiveAgentEntry.ID: Date] = [:] @Presents var handoffHud: HandoffHudFeature.State? @Presents var alert: AlertState? @@ -89,6 +87,9 @@ struct AppFeature { case terminalEvent(TerminalClient.Event) case openHandoffHud case handoffHud(PresentationAction) + /// A CLI handoff completed (announced by the socket-service handler); the + /// HUD uses it to observe the request it injected into the source pane. + case handoffCliCompleted(HandoffCLICompletion) } enum Alert: Equatable { @@ -991,6 +992,10 @@ struct AppFeature { case .handoffHud: return .none + case .handoffCliCompleted(let completion): + guard state.handoffHud != nil else { return .none } + return .send(.handoffHud(.presented(.cliCompleted(completion)))) + case .terminalEvent(let event): return reduceTerminalEvent(event, state: &state) } diff --git a/supacode/Features/HandoffHud/Reducer/HandoffHudFeature.swift b/supacode/Features/HandoffHud/Reducer/HandoffHudFeature.swift index aa94f1ec..f615c418 100644 --- a/supacode/Features/HandoffHud/Reducer/HandoffHudFeature.swift +++ b/supacode/Features/HandoffHud/Reducer/HandoffHudFeature.swift @@ -27,17 +27,23 @@ struct HandoffHudSource: Equatable, Sendable { /// Detected agent token, e.g. "codex". let agentToken: String let displayName: String - /// Non-nil only for a resumable exact/high-confidence session. - let preparationRequest: AgentResumeRequest? + /// The source pane the injected request goes to (and the pane whose CLI + /// completion the HUD waits for). + let sourceSurfaceID: UUID + /// Non-nil only for a resumable exact/high-confidence session — enables the + /// fork fallback. + let forkRequest: AgentResumeRequest? let sessionContext: HandoffStore.SessionContext? let observation: AgentLaunchObservation? } enum HandoffStage: Equatable, Sendable { - case briefing + /// Request injected into the live source agent; waiting for its CLI call. + case requesting + /// Fallback: fork briefing + transition, headless. + case forking + /// Fallback: context-only transition (sub-second). case saving - case archiving - case launching } enum HandoffHudOutcome: Equatable, Sendable { @@ -49,10 +55,7 @@ enum HandoffHudOutcome: Equatable, Sendable { struct HandoffHudRun: Equatable, Sendable { let target: HandoffTargetOption let startedAt: Date - /// Stages this run displays, in order (briefing only when resumable). - let stages: [HandoffStage] var stage: HandoffStage - var preparation: HandoffPreparationOutcome? } enum HandoffHudPhase: Equatable { @@ -61,14 +64,14 @@ enum HandoffHudPhase: Equatable { case finished(HandoffHudOutcome) } -/// The staged hand-off HUD (docs-ai 049): choose a receiving agent, then run -/// briefing → save → archive → launch with per-stage progress. Execution -/// state lives in this reducer — the HUD view is a projection, so a later -/// wave can dismiss the panel while a run continues. -/// -/// The reducer drives `HandoffCoordinator` directly; the CLI handler shares -/// the same coordinator, so both entry points persist identical artifacts -/// and log lines. +/// The staged hand-off HUD (docs-ai 047.004): choose a receiving agent, then +/// ask the *live* source agent to run the CLI self-handoff by injecting a +/// one-line request into its pane. The agent authors its briefing inline and +/// the shared CLI transition completes headlessly; the HUD observes the +/// completion (`cliCompleted`) and finishes. Resume-fork and context-only are +/// explicit fallbacks the user picks while waiting — the inline path is the +/// primary one because the live agent holds context no transcript fork can +/// reconstruct. @Reducer struct HandoffHudFeature { @ObservableState @@ -87,14 +90,20 @@ struct HandoffHudFeature { var isChoosing: Bool { phase == .choosing } + var canFork: Bool { source.forkRequest != nil } + /// Build the HUD for a pane with a detected agent; nil without one — the - /// no-source mechanical handoff stays CLI-only for now. + /// no-source mechanical handoff stays CLI-only. static func make(worktree: Worktree, source: HandoffSourceContext?) -> State? { - guard let sessionContext = source?.sessionContext, let agentToken = sessionContext.agent else { + guard + let sessionContext = source?.sessionContext, + let agentToken = sessionContext.agent, + let sourceSurfaceID = UUID(uuidString: sessionContext.paneID) + else { return nil } let sourceAgent = DetectedAgent(rawValue: agentToken) - let preparationRequest = HandoffCommandHandler.preparationRequest( + let forkRequest = HandoffCommandHandler.forkRequest( outgoingAgent: agentToken, session: source?.session, observation: source?.observation @@ -116,7 +125,7 @@ struct HandoffHudFeature { HandoffTargetOption( kind: .briefOnly, title: "Only save progress, don't hand off", - subtitle: "Saves the current state for a later hand-off", + subtitle: "Saves a briefing checkpoint for a later hand-off", isCurrentAgent: false ) ) @@ -126,7 +135,8 @@ struct HandoffHudFeature { source: HandoffHudSource( agentToken: agentToken, displayName: sourceAgent?.displayName ?? agentToken, - preparationRequest: preparationRequest, + sourceSurfaceID: sourceSurfaceID, + forkRequest: forkRequest, sessionContext: sessionContext, observation: source?.observation ), @@ -159,15 +169,15 @@ struct HandoffHudFeature { case moveSelection(delta: Int) case setSelectedIndex(Int) case confirmSelection - case skipBriefingTapped + case fallbackForkTapped + case fallbackContextOnlyTapped + /// A CLI handoff completed somewhere in the app; the reducer ignores it + /// unless it came from this HUD's source pane. + case cliCompleted(HandoffCLICompletion) + case fallbackFinished(HandoffHudOutcome) + case runFailed(message: String) case cancelTapped case closeTapped - case briefingReplyReceived(HandoffPreparationReply) - case briefingFinished(HandoffPreparationOutcome) - case savingFinished - case archivingFinished - case launchFinished - case runFailed(message: String) case delegate(Delegate) } @@ -176,7 +186,7 @@ struct HandoffHudFeature { case dismiss } - private nonisolated struct BriefingCancelID: Hashable { + private nonisolated struct FallbackCancelID: Hashable { let worktreeID: Worktree.ID } @@ -200,50 +210,59 @@ struct HandoffHudFeature { case .confirmSelection: guard state.isChoosing, state.targets.indices.contains(state.selectedIndex) else { return .none } - return startRun(&state, target: state.targets[state.selectedIndex]) - - case .briefingReplyReceived(let reply): - guard var run = state.run, run.stage == .briefing else { return .none } - switch reply { - case .reply: - // Leaving briefing is the reducer's commit decision. A late reply - // after Skip or Cancel is ignored before it reaches the filesystem. - run.stage = .saving - state.phase = .running(run) - let coordinator = makeCoordinator(state) - let timestamp = run.startedAt - return .run { send in - let outcome = coordinator.applyPreparation(reply, now: timestamp) - await send(.briefingFinished(outcome)) + let target = state.targets[state.selectedIndex] + let purpose: HandoffInjection.Purpose = + switch target.kind { + case .agent(let agent): .handOff(agent: agent.rawValue) + case .briefOnly: .checkpoint } - .cancellable(id: BriefingCancelID(worktreeID: state.worktree.id), cancelInFlight: true) - - case .skipped: - run.preparation = .skipped - case .failed: - run.preparation = .failed + let delivered = terminalClient.sendTextToSurface( + state.worktree.id, + state.source.sourceSurfaceID, + HandoffInjection.instruction(for: purpose) + ) + state.phase = .running( + HandoffHudRun(target: target, startedAt: now, stage: .requesting) + ) + if delivered { + return .none } - return advance(&state, run: run, to: .saving) - - case .briefingFinished(let outcome): - guard var run = state.run, run.stage == .saving else { return .none } - run.preparation = outcome - return advance(&state, run: run, to: .saving) - case .savingFinished: - guard let run = state.run, run.stage == .saving else { return .none } - if run.target.kind == .briefOnly { + // The pane cannot take input (gone or wedged) — fall back without a + // detour through the waiting state. + return state.canFork + ? startForkFallback(&state) + : startContextOnlyFallback(&state) + + case .fallbackForkTapped: + guard state.run?.stage == .requesting else { return .none } + return startForkFallback(&state) + + case .fallbackContextOnlyTapped: + guard state.run?.stage == .requesting else { return .none } + return startContextOnlyFallback(&state) + + case .cliCompleted(let completion): + guard let run = state.run, + completion.sourcePaneID == state.source.sourceSurfaceID.uuidString + 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: state.phase = .finished(.briefSaved) - return .none + case .agent: + 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) + } } - return advance(&state, run: run, to: .archiving) - - case .archivingFinished: - guard let run = state.run, run.stage == .archiving else { return .none } - return advance(&state, run: run, to: .launching) + return .cancel(id: FallbackCancelID(worktreeID: state.worktree.id)) - case .launchFinished: - guard let run = state.run, run.stage == .launching else { return .none } - state.phase = .finished(.handedOff(agentDisplayName: run.target.title)) + case .fallbackFinished(let outcome): + guard state.run != nil else { return .none } + state.phase = .finished(outcome) return .none case .runFailed(let message): @@ -251,26 +270,22 @@ struct HandoffHudFeature { state.phase = .finished(.failed(message: message)) return .none - case .skipBriefingTapped: - guard var run = state.run, run.stage == .briefing else { return .none } - run.preparation = .skipped - return .merge( - .cancel(id: BriefingCancelID(worktreeID: state.worktree.id)), - advance(&state, run: run, to: .saving) - ) - case .cancelTapped: switch state.phase { case .choosing: return .send(.delegate(.dismiss)) - case .running(let run) where run.stage == .briefing: - // Abort entirely: the artifact is untouched and no log line is - // written — parity with Ctrl-C on the CLI path. + case .running(let run) where run.stage == .requesting: + // 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. return .merge( - .cancel(id: BriefingCancelID(worktreeID: state.worktree.id)), + .cancel(id: FallbackCancelID(worktreeID: state.worktree.id)), .send(.delegate(.dismiss)) ) - case .running, .finished: + case .finished: return .none } @@ -284,7 +299,7 @@ struct HandoffHudFeature { } } - // MARK: - Run orchestration + // MARK: - Fallbacks private func makeCoordinator(_ state: State) -> HandoffCoordinator { let client = agentRuntimeClient @@ -296,78 +311,64 @@ struct HandoffHudFeature { ) } - private func startRun(_ state: inout State, target: HandoffTargetOption) -> Effect { - let stages: [HandoffStage] = - (state.source.preparationRequest == nil ? [] : [.briefing]) - + [.saving] - + (target.kind == .briefOnly ? [] : [.archiving, .launching]) - var run = HandoffHudRun( - target: target, - startedAt: now, - stages: stages, - stage: stages[0] - ) - guard let request = state.source.preparationRequest else { - run.preparation = .skipped - return advance(&state, run: run, to: .saving) + private func startForkFallback(_ state: inout State) -> Effect { + guard let forkRequest = state.source.forkRequest else { + return startContextOnlyFallback(&state) } - state.phase = .running(run) - let coordinator = makeCoordinator(state) - return .run { send in - let reply = await coordinator.collectPreparation(request) - await send(.briefingReplyReceived(reply)) - } - .cancellable(id: BriefingCancelID(worktreeID: state.worktree.id), cancelInFlight: true) + return startFallback(&state, briefingSource: .fork(forkRequest), stage: .forking) + } + + private func startContextOnlyFallback(_ state: inout State) -> Effect { + startFallback(&state, briefingSource: HandoffBriefingSource.none, stage: .saving) } - private func advance(_ state: inout State, run: HandoffHudRun, to stage: HandoffStage) -> Effect { - var run = run + private func startFallback( + _ state: inout State, + briefingSource: HandoffBriefingSource, + stage: HandoffStage + ) -> Effect { + guard var run = state.run else { return .none } run.stage = stage state.phase = .running(run) let coordinator = makeCoordinator(state) - let timestamp = run.startedAt let source = state.source let worktree = state.worktree let rootURL = state.rootURL + let target = run.target + let timestamp = now + let client = terminalClient - switch stage { - case .briefing: - return .none - - case .saving: - let preparation = run.target.kind == .briefOnly ? run.preparation : nil + switch target.kind { + case .briefOnly: return .run { send in - _ = try await coordinator.saveArtifact( + _ = try await coordinator.makeCheckpoint( outgoingAgent: source.agentToken, sessionContext: source.sessionContext, note: nil, - preparation: preparation, + briefingSource: briefingSource, now: timestamp ) - await send(.savingFinished) + 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 .archiving: - guard let toAgent = run.target.agent else { return .none } - return .run { send in - _ = try await coordinator.archive(from: source.agentToken, toAgent: toAgent.rawValue, now: timestamp) - await send(.archivingFinished) - } catch: { error, send in - await send(.runFailed(message: error.localizedDescription)) - } - - case .launching: - guard let destination = run.target.agent else { return .none } + case .agent(let destination): let configuration = inheritedConfiguration(source: source, destination: destination) - let preparation = run.preparation ?? .skipped - let targetTitle = run.target.title - let client = terminalClient + let targetTitle = target.title return .run { send in + let artifacts = try await coordinator.makeTransitionArtifacts( + outgoingAgent: source.agentToken, + toAgent: destination.rawValue, + sessionContext: source.sessionContext, + briefingSource: briefingSource, + now: timestamp + ) let request = AgentStartRequest( agent: destination, - prompt: HandoffCommandHandler.kickoffPrompt(), + prompt: HandoffCommandHandler.kickoffPrompt(hasBriefing: artifacts.hasBriefing), configuration: configuration ) let kickoff = try AgentRuntimeAdapterRegistry.makeStartInvocation(request).terminalInput @@ -375,7 +376,7 @@ struct HandoffHudFeature { from: source.agentToken, toAgent: destination.rawValue, disposition: .requested, - preparation: preparation, + briefing: artifacts.briefing, source: "agents-hud", now: timestamp ) @@ -390,10 +391,12 @@ struct HandoffHudFeature { customCommandIcon: nil ) ) - await send(.launchFinished) + 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 21182629..ebdb6c45 100644 --- a/supacode/Features/HandoffHud/Views/HandoffHudOverlayView.swift +++ b/supacode/Features/HandoffHud/Views/HandoffHudOverlayView.swift @@ -50,11 +50,14 @@ private struct HandoffHudCard: View { case .choosing: HandoffHudChooseView(store: store) case .running(let run): - HandoffHudRunView(run: run, sourceDisplayName: store.source.displayName) { - store.send(.skipBriefingTapped) - } onCancel: { - store.send(.cancelTapped) - } + HandoffHudRunView( + run: run, + sourceDisplayName: store.source.displayName, + canFork: store.canFork, + onFork: { store.send(.fallbackForkTapped) }, + onContextOnly: { store.send(.fallbackContextOnlyTapped) }, + onCancel: { store.send(.cancelTapped) } + ) case .finished(let outcome): HandoffHudFinishedView(outcome: outcome) { store.send(.closeTapped) @@ -65,8 +68,7 @@ private struct HandoffHudCard: View { HandoffHudKeyCaptureView( onMove: { delta in store.send(.moveSelection(delta: delta)) }, onConfirm: { confirmForCurrentPhase() }, - onEscape: { escapeForCurrentPhase() }, - onSkip: { store.send(.skipBriefingTapped) } + onEscape: { escapeForCurrentPhase() } ) } .frame(maxWidth: 560) @@ -110,16 +112,17 @@ private struct HandoffHudCard: View { private var headerSubtitle: String { switch store.phase { case .choosing: - if store.source.preparationRequest != nil { - return "Pass this task to another agent in a new tab. " - + "\(store.source.displayName) will summarize its progress first." - } - return "Pass this task to another agent in a new tab." + return "Pass this task to another agent in a new tab. " + + "\(store.source.displayName) writes its own briefing first." case .running(let run): - if run.stage == .briefing { - return "\(store.source.displayName) is summarizing its progress" + switch run.stage { + case .requesting: + 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" } - return "Preparing the hand-off…" case .finished(.handedOff): return "The receiving agent picks up the task in a new tab" case .finished(.briefSaved): @@ -276,90 +279,84 @@ private struct HandoffTargetRow: View { private struct HandoffHudRunView: View { let run: HandoffHudRun let sourceDisplayName: String - let onSkip: () -> Void + let canFork: Bool + let onFork: () -> Void + let onContextOnly: () -> Void let onCancel: () -> Void var body: some View { VStack(alignment: .leading, spacing: 0) { - VStack(alignment: .leading, spacing: 10) { - ForEach(Array(run.stages.enumerated()), id: \.element) { index, stage in - stageRow(stage: stage, index: index) - } + HStack(spacing: 10) { + ProgressView() + .controlSize(.small) + Text(stageDescription) + .font(.body) + Spacer(minLength: 0) } .padding(16) Divider() HStack { - if run.stage == .briefing { - Text("This can take a moment while \(sourceDisplayName) summarizes its progress.") - .font(.caption) - .foregroundStyle(.secondary) - } + Text(footerHint) + .font(.caption) + .foregroundStyle(.secondary) Spacer() - if run.stage == .briefing { - Button("Cancel") { - onCancel() + if run.stage == .requesting { + if canFork { + Button("Fork Briefing") { + onFork() + } + .help("Don't wait: collect the briefing by resuming \(sourceDisplayName)'s recorded session") + } + Button("Context Only") { + onContextOnly() } - .keyboardShortcut(.cancelAction) - .help("Stop this hand-off; nothing is changed") + .help("Don't wait: hand off with generated context only, no briefing") } + Button("Cancel") { + onCancel() + } + .keyboardShortcut(.cancelAction) + .help(cancelHelp) } .padding(12) } } - @ViewBuilder - private func stageRow(stage: HandoffStage, index: Int) -> some View { - let currentIndex = run.stages.firstIndex(of: run.stage) ?? 0 - HStack(spacing: 8) { - stageIndicator(stage: stage, index: index, currentIndex: currentIndex) - Text(stageTitle(stage)) - .font(.body) - .foregroundStyle(index <= currentIndex ? .primary : .secondary) - Spacer(minLength: 0) - if stage == .briefing, run.stage == .briefing { - Button("Skip") { - onSkip() - } - .controlSize(.small) - .keyboardShortcut("s") - .help("Hand off now with the current summary and repo state (S)") + private var stageDescription: String { + switch run.stage { + case .requesting: + switch run.target.kind { + case .agent: + return "Asked \(sourceDisplayName) to write its briefing and hand off to \(run.target.title)" + case .briefOnly: + return "Asked \(sourceDisplayName) to write a briefing checkpoint" } + case .forking: + return "Collecting a briefing from \(sourceDisplayName)'s recorded session" + case .saving: + return "Handing off with generated context only" } } - @ViewBuilder - private func stageIndicator(stage: HandoffStage, index: Int, currentIndex: Int) -> some View { - if index < currentIndex { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - .accessibilityLabel("Completed") - } else if index == currentIndex { - ProgressView() - .controlSize(.small) - } else { - Image(systemName: "circle") - .foregroundStyle(.quaternary) - .accessibilityLabel("Pending") + private var footerHint: String { + switch run.stage { + case .requesting: + 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 "" } } - private func stageTitle(_ stage: HandoffStage) -> String { - switch stage { - case .briefing: - return "Collect progress summary from \(sourceDisplayName)" - case .saving: - return "Save context" - case .archiving: - return "Archive" - case .launching: - switch run.target.kind { - case .agent: - return "Launch \(run.target.title)" - case .briefOnly: - return "Launch" - } + private var cancelHelp: String { + switch run.stage { + case .requesting: + return "Close this panel; if \(sourceDisplayName) still hands off, it completes in the background" + case .forking, .saving: + return "Stop this hand-off; nothing is changed" } } } @@ -431,14 +428,12 @@ private struct HandoffHudKeyCaptureView: NSViewRepresentable { let onMove: (Int) -> Void let onConfirm: () -> Void let onEscape: () -> Void - let onSkip: () -> Void func makeNSView(context: Context) -> KeyCaptureNSView { let view = KeyCaptureNSView() view.onMove = onMove view.onConfirm = onConfirm view.onEscape = onEscape - view.onSkip = onSkip return view } @@ -446,7 +441,6 @@ private struct HandoffHudKeyCaptureView: NSViewRepresentable { nsView.onMove = onMove nsView.onConfirm = onConfirm nsView.onEscape = onEscape - nsView.onSkip = onSkip nsView.grabFocusIfNeeded() } @@ -454,7 +448,6 @@ private struct HandoffHudKeyCaptureView: NSViewRepresentable { var onMove: ((Int) -> Void)? var onConfirm: (() -> Void)? var onEscape: (() -> Void)? - var onSkip: (() -> Void)? override var acceptsFirstResponder: Bool { true } @@ -469,11 +462,6 @@ private struct HandoffHudKeyCaptureView: NSViewRepresentable { } override func keyDown(with event: NSEvent) { - if event.charactersIgnoringModifiers?.lowercased() == "s" { - onSkip?() - return - } - switch event.keyCode { case 126: // up arrow onMove?(-1) diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index df8934b9..4708f42a 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -390,12 +390,6 @@ struct WorktreeDetailView: View { let paneState = state.surfaceAgentStates[surfaceID], let agent = paneState.detectedAgent else { return nil } - let resumable = - HandoffCommandHandler.preparationRequest( - outgoingAgent: agent.rawValue, - session: paneState.session, - observation: paneState.launchObservation - ) != nil let iconSource = paneState.iconLookupToken.flatMap(CommandIconMap.iconForFirstToken) ?? CommandIconMap.iconForFirstToken(agent.iconLookupToken) @@ -408,10 +402,8 @@ struct WorktreeDetailView: View { return AgentsCapsuleState( displayName: displayName, iconSource: iconSource, - infoLine: resumable - ? "Pass this task to another agent in a new tab. " - + "\(displayName) will summarize its progress first." - : "Pass this task to another agent in a new tab." + infoLine: "Pass this task to another agent in a new tab. " + + "\(displayName) writes its own briefing first." ) } diff --git a/supacode/Features/Terminal/Models/TerminalTabManager.swift b/supacode/Features/Terminal/Models/TerminalTabManager.swift index 57606c54..98072a19 100644 --- a/supacode/Features/Terminal/Models/TerminalTabManager.swift +++ b/supacode/Features/Terminal/Models/TerminalTabManager.swift @@ -13,7 +13,15 @@ final class TerminalTabManager { var selectedTabId: TerminalTabID? private(set) var editingTabID: TerminalTabID? - func createTab(title: String, icon: String?, isTitleLocked: Bool = false) -> TerminalTabID { + /// Creates a tab next to the current selection. With `select: false` the + /// selection is left untouched (background creation, e.g. a headless handoff + /// launch) unless nothing was selected yet. + func createTab( + title: String, + icon: String?, + isTitleLocked: Bool = false, + select: Bool = true + ) -> TerminalTabID { let tab = TerminalTabItem(title: title, icon: icon, isTitleLocked: isTitleLocked) if let selectedTabId, let selectedIndex = tabs.firstIndex(where: { $0.id == selectedTabId }) @@ -22,7 +30,9 @@ final class TerminalTabManager { } else { tabs.append(tab) } - selectedTabId = tab.id + if select || selectedTabId == nil { + selectedTabId = tab.id + } return tab.id } diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index f633a209..5a821a39 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -337,6 +337,7 @@ final class WorktreeTerminalState { @discardableResult func createTab( focusing: Bool = true, + selecting: Bool = true, title: String? = nil, setupScript: String? = nil, initialInput: String? = nil, @@ -370,6 +371,7 @@ final class WorktreeTerminalState { isTitleLocked: false, initialInput: resolvedInput, focusing: focusing, + selecting: selecting, inheritingFromSurfaceId: resolvedInheritanceSurfaceId, context: context, workingDirectoryOverride: workingDirectoryOverride @@ -451,6 +453,7 @@ final class WorktreeTerminalState { let isTitleLocked: Bool let initialInput: String? let focusing: Bool + var selecting: Bool = true let inheritingFromSurfaceId: UUID? let context: ghostty_surface_context_e let workingDirectoryOverride: URL? @@ -460,7 +463,8 @@ final class WorktreeTerminalState { let tabId = tabManager.createTab( title: creation.title, icon: creation.icon, - isTitleLocked: creation.isTitleLocked + isTitleLocked: creation.isTitleLocked, + select: creation.selecting ) let tree = splitTree( for: tabId, diff --git a/supacodeTests/AppFeatureHandoffTests.swift b/supacodeTests/AppFeatureHandoffTests.swift index d47bfb61..d69b11ae 100644 --- a/supacodeTests/AppFeatureHandoffTests.swift +++ b/supacodeTests/AppFeatureHandoffTests.swift @@ -124,7 +124,7 @@ struct AppFeatureHandoffTests { HandoffSourceContext( sessionContext: HandoffStore.SessionContext( agent: "codex", - paneID: "pane-0", + paneID: UUID(uuid: (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9)).uuidString, paneTitle: "codex", source: "terminal-scrollback", confidence: "fallback", @@ -140,7 +140,7 @@ struct AppFeatureHandoffTests { await store.send(.openHandoffHud) { let hud = try #require($0.handoffHud) #expect(hud.source.agentToken == "codex") - #expect(hud.source.preparationRequest == nil) + #expect(hud.source.forkRequest == nil) #expect(hud.phase == .choosing) } @@ -266,30 +266,36 @@ struct AppFeatureHandoffTests { #expect(store.state.handoffHud == nil) } - // MARK: - Palette delegate opens the HUD; the HUD runs the hand-off + // MARK: - Palette delegate opens the HUD; the HUD asks the live agent - @Test(.dependencies) func paletteHandOffRunsThroughHud() async throws { + @Test(.dependencies) func paletteHandOffInjectsRequestAndObservesCliCompletion() async throws { let root = try makeTempRoot() defer { remove(root) } + let sourceSurfaceID = uuid(4) let state = AppFeature.State( repositories: makeWorkspaceState(root: root), settings: SettingsFeature.State() ) - let sent = LockIsolated<[TerminalClient.Command]>([]) - let resumed = LockIsolated(nil) + let injected = LockIsolated<[String]>([]) + let focused = LockIsolated<[(Worktree.ID, UUID)]>([]) let store = TestStore(initialState: state) { AppFeature() } withDependencies: { $0.date.now = Date(timeIntervalSince1970: 1_760_000_000) - $0.terminalClient.send = { command in - sent.withValue { $0.append(command) } + $0.terminalClient.sendTextToSurface = { _, _, text in + injected.withValue { $0.append(text) } + return true + } + $0.terminalClient.focusSurface = { worktreeID, surfaceID in + focused.withValue { $0.append((worktreeID, surfaceID)) } + return true } $0.terminalClient.handoffSourceContext = { _ in HandoffSourceContext( sessionContext: HandoffStore.SessionContext( agent: "codex", - paneID: "pane-0", + paneID: sourceSurfaceID.uuidString, paneTitle: "codex", source: "terminal-scrollback", confidence: "fallback", @@ -304,23 +310,6 @@ struct AppFeatureHandoffTests { ) ) } - $0.agentRuntimeClient = AgentRuntimeClient( - resume: { request, _ in - resumed.setValue(request) - return """ - # Handoff - - ## Objective - Palette source status. - - ## Current State - Ready to hand off. - - ## Next Steps - 1. Continue in claude. - """ - } - ) } store.exhaustivity = .off @@ -331,57 +320,43 @@ struct AppFeatureHandoffTests { await store.send(.handoffHud(.presented(.setSelectedIndex(claudeIndex)))) await store.send(.handoffHud(.presented(.confirmSelection))) - await store.receive(\.handoffHud.presented.launchFinished) - await store.finish() - guard case .finished(.handedOff(let name))? = store.state.handoffHud?.phase else { - Issue.record("Expected handed-off outcome, got \(String(describing: store.state.handoffHud?.phase))") + // The HUD asked the live agent to hand off itself — nothing launched yet. + #expect(injected.value.first?.contains("prowl handoff to claude --brief -") == true) + guard case .running(let run)? = store.state.handoffHud?.phase, run.stage == .requesting else { + Issue.record("Expected requesting stage, got \(String(describing: store.state.handoffHud?.phase))") return } - #expect(name == "Claude Code") - #expect(sent.value.count == 1) - expectClaudeLaunchCommand(sent.value.first, root: root) - - // The handoff artifact was materialized in the workspace root. - let store2 = HandoffStore(rootURL: root) - let log = try String(contentsOf: store2.logURL, encoding: .utf8) - #expect(log.contains("codex → claude")) - #expect(log.contains("launch=requested")) - #expect(log.contains("preparation=completed")) - #expect(log.contains("source=agents-hud")) - #expect(resumed.value?.agent == .codex) - #expect(resumed.value?.model == "gpt-5.4") - // Prowl transcribed the source reply into current.md. - let current = try String(contentsOf: store2.currentURL, encoding: .utf8) - #expect(current.hasPrefix("# Handoff")) - #expect(current.contains("Palette source status.")) - } + // 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. + let launchedPaneID = uuid(11) + await store.send( + .handoffCliCompleted( + HandoffCLICompletion( + action: .toAgent, + sourcePaneID: sourceSurfaceID.uuidString, + toAgent: "claude", + briefing: .inline, + launched: HandoffLaunchedPane( + worktreeID: root.path(percentEncoded: false), + worktreeName: "Checkout Flow", + tabID: uuid(12).uuidString, + paneID: launchedPaneID.uuidString, + paneTitle: "claude" + ) + ) + ) + ) + await store.receive(\.handoffHud.presented.cliCompleted) + await store.finish() - private func expectClaudeLaunchCommand(_ command: TerminalClient.Command?, root: URL) { - guard - case .createTabWithInput( - _, - input: let input, - workingDirectory: let workingDirectory, - runSetupScriptIfNew: let runSetup, - autoCloseOnSuccess: let autoClose, - customCommandName: let commandName, - customCommandIcon: let icon - )? = command - else { - Issue.record("Expected createTabWithInput, got \(String(describing: command))") + guard case .finished(.handedOff(let name))? = store.state.handoffHud?.phase else { + Issue.record("Expected handed-off outcome, got \(String(describing: store.state.handoffHud?.phase))") return } - #expect(input.contains("'claude'")) - #expect(input.contains("'--dangerously-skip-permissions'")) - #expect(!input.contains("gpt-5.4")) - #expect(input.contains(HandoffCommandHandler.kickoffPrompt())) - #expect(runSetup == false) - #expect(autoClose == false) - #expect(commandName == "Hand off → Claude Code") - #expect(icon == nil) - #expect(workingDirectory == root) + #expect(name == "Claude Code") + #expect(focused.value.first?.1 == launchedPaneID) } @Test(.dependencies) func paletteHandOffAttributesFocusedAgent() async throws { @@ -421,159 +396,6 @@ struct AppFeatureHandoffTests { #expect(claudeTarget.isCurrentAgent) } - @Test(.dependencies) func agentDoneAutoSavesExistingHandoffArtifact() async throws { - let root = try makeTempRoot() - defer { remove(root) } - let handoffStore = HandoffStore(rootURL: root) - try handoffStore.ensureScaffold() - - let surfaceID = uuid(7) - let tabID = TerminalTabID(rawValue: uuid(8)) - var state = AppFeature.State( - repositories: makeWorkspaceState(root: root), - settings: SettingsFeature.State() - ) - state.settings.autoShowActiveAgentsPanel = false - - let store = TestStore(initialState: state) { - AppFeature() - } withDependencies: { - $0.date.now = Date(timeIntervalSince1970: 1_760_000_000) - $0.terminalClient.handoffSessionContextForSurface = { _, _ in - HandoffStore.SessionContext( - agent: "codex", - sessionID: "session-1", - paneID: surfaceID.uuidString, - paneTitle: "codex", - source: "terminal-scrollback", - confidence: "fallback", - transcriptPath: "/tmp/codex.jsonl", - excerptText: "finished implementation" - ) - } - } - store.exhaustivity = .off - - let working = activeAgentEntry( - id: surfaceID, - worktreeID: root.path(percentEncoded: false), - tabID: tabID, - surfaceID: surfaceID, - displayState: .working - ) - let done = activeAgentEntry( - id: surfaceID, - worktreeID: root.path(percentEncoded: false), - tabID: tabID, - surfaceID: surfaceID, - displayState: .done - ) - - await store.send(.terminalEvent(.agentEntryChanged(working))) - #expect(store.state.handoffAutoSaveDisplayStates[surfaceID] == .working) - - await store.send(.terminalEvent(.agentEntryChanged(done))) - #expect(store.state.handoffAutoSaveDisplayStates[surfaceID] == .done) - #expect(store.state.handoffAutoSaveLastSavedAt[surfaceID] == Date(timeIntervalSince1970: 1_760_000_000)) - await store.finish() - - let context = try String(contentsOf: handoffStore.contextURL, encoding: .utf8) - #expect(context.contains("Session ID: session-1")) - #expect(context.contains(".prowl/handoff/sessions/")) - let sessionFiles = try FileManager.default.contentsOfDirectory( - at: handoffStore.sessionDirectory, - includingPropertiesForKeys: nil - ) - let sessionFile = try #require(sessionFiles.first) - let session = try String(contentsOf: sessionFile, encoding: .utf8) - #expect(session.contains("finished implementation")) - let log = try String(contentsOf: handoffStore.logURL, encoding: .utf8) - #expect(log.contains("auto-save: codex done")) - } - - @Test(.dependencies) func agentDoneDoesNotCreateHandoffArtifact() async throws { - let root = try makeTempRoot() - defer { remove(root) } - let handoffStore = HandoffStore(rootURL: root) - - let surfaceID = uuid(9) - let state = AppFeature.State( - repositories: makeWorkspaceState(root: root), - settings: SettingsFeature.State() - ) - let store = TestStore(initialState: state) { - AppFeature() - } - store.exhaustivity = .off - - let working = activeAgentEntry( - id: surfaceID, - worktreeID: root.path(percentEncoded: false), - surfaceID: surfaceID, - displayState: .working - ) - let done = activeAgentEntry( - id: surfaceID, - worktreeID: root.path(percentEncoded: false), - surfaceID: surfaceID, - displayState: .done - ) - - await store.send(.terminalEvent(.agentEntryChanged(working))) - #expect(store.state.handoffAutoSaveDisplayStates[surfaceID] == .working) - - await store.send(.terminalEvent(.agentEntryChanged(done))) - #expect(store.state.handoffAutoSaveDisplayStates[surfaceID] == .done) - await store.finish() - - #expect(handoffStore.hasCurrentArtifact == false) - } - - @Test(.dependencies) func agentDoneAutoSavesToNonMainWorktreeRoot() async throws { - let root = try makeTempRoot() - defer { remove(root) } - let repositoryRoot = root.appending(path: "main", directoryHint: .isDirectory) - let worktreeRoot = root.appending(path: "feature", directoryHint: .isDirectory) - try FileManager.default.createDirectory(at: repositoryRoot, withIntermediateDirectories: true) - try FileManager.default.createDirectory(at: worktreeRoot, withIntermediateDirectories: true) - let worktreeStore = HandoffStore(rootURL: worktreeRoot) - try worktreeStore.ensureScaffold() - - let surfaceID = uuid(10) - var state = AppFeature.State( - repositories: makeGitWorktreeState(repositoryRoot: repositoryRoot, worktreeRoot: worktreeRoot), - settings: SettingsFeature.State() - ) - state.settings.autoShowActiveAgentsPanel = false - let store = TestStore(initialState: state) { - AppFeature() - } withDependencies: { - $0.date.now = Date(timeIntervalSince1970: 1_760_000_000) - } - store.exhaustivity = .off - - let working = activeAgentEntry( - id: surfaceID, - worktreeID: worktreeRoot.path(percentEncoded: false), - surfaceID: surfaceID, - displayState: .working - ) - let done = activeAgentEntry( - id: surfaceID, - worktreeID: worktreeRoot.path(percentEncoded: false), - surfaceID: surfaceID, - displayState: .done - ) - - await store.send(.terminalEvent(.agentEntryChanged(working))) - await store.send(.terminalEvent(.agentEntryChanged(done))) - await store.finish() - - let context = try String(contentsOf: worktreeStore.contextURL, encoding: .utf8) - #expect(context.contains("Outgoing agent (detected): codex")) - #expect(HandoffStore(rootURL: repositoryRoot).hasCurrentArtifact == false) - } - private func activeAgentEntry( id: UUID, worktreeID: Worktree.ID, diff --git a/supacodeTests/AppFeatureSettingsChangedTests.swift b/supacodeTests/AppFeatureSettingsChangedTests.swift index b013fb79..592c8bb0 100644 --- a/supacodeTests/AppFeatureSettingsChangedTests.swift +++ b/supacodeTests/AppFeatureSettingsChangedTests.swift @@ -78,7 +78,6 @@ struct AppFeatureSettingsChangedTests { } await store.send(.terminalEvent(.agentEntryChanged(entry))) { - $0.handoffAutoSaveDisplayStates[entry.id] = .working $0.repositories.activeAgents.$isPanelHidden.withLock { $0 = false } } await store.receive(\.repositories.activeAgents.agentEntryChanged) { @@ -97,9 +96,7 @@ struct AppFeatureSettingsChangedTests { AppFeature() } - await store.send(.terminalEvent(.agentEntryChanged(entry))) { - $0.handoffAutoSaveDisplayStates[entry.id] = .working - } + await store.send(.terminalEvent(.agentEntryChanged(entry))) await store.receive(\.repositories.activeAgents.agentEntryChanged) { $0.repositories.activeAgents.entries = [entry] } diff --git a/supacodeTests/HandoffCommandHandlerTests.swift b/supacodeTests/HandoffCommandHandlerTests.swift index 36968260..ee37a42a 100644 --- a/supacodeTests/HandoffCommandHandlerTests.swift +++ b/supacodeTests/HandoffCommandHandlerTests.swift @@ -4,9 +4,8 @@ import Testing @testable import supacode -/// A shape-valid preparation reply used when a test does not care about the -/// reply content itself. -nonisolated private let preparedHandoffReply = """ +/// A shape-valid briefing used when a test does not care about the content. +nonisolated private let validHandoffBriefing = """ # Handoff ## Objective @@ -38,6 +37,7 @@ struct HandoffCommandHandlerTests { private func makeHandler( root: URL, outgoingAgent: String?, + isSelfHandoff: Bool = false, outgoingLaunchObservation: AgentLaunchObservation? = AgentLaunchObservation( model: "gpt-5.4", executionMode: .unrestricted @@ -59,12 +59,17 @@ struct HandoffCommandHandlerTests { launched: HandoffLaunchedPane? = HandoffLaunchedPane( worktreeID: "ws", worktreeName: "Workspace", tabID: "tab-1", paneID: "pane-1", paneTitle: "claude" ), + resolveFailure: HandoffResolveError? = nil, launchSpy: (@MainActor (AgentStartRequest) -> Void)? = nil, - preparationSpy: (@Sendable (AgentResumeRequest, URL) async throws -> String)? = nil, + forkSpy: (@Sendable (AgentResumeRequest, URL) async throws -> String)? = nil, + completionSpy: (@MainActor (HandoffCLICompletion) -> Void)? = nil ) -> HandoffCommandHandler { HandoffCommandHandler( - resolveProvider: { _ in - .success( + resolveProvider: { _, _ in + if let resolveFailure { + return .failure(resolveFailure) + } + return .success( HandoffResolvedTarget( worktreeID: "ws", worktreeName: "Workspace", @@ -73,7 +78,8 @@ struct HandoffCommandHandlerTests { outgoingAgent: outgoingAgent, outgoingLaunchObservation: outgoingLaunchObservation, outgoingSession: outgoingSession, - sessionContext: sessionContext + sessionContext: sessionContext, + isSelfHandoff: isSelfHandoff ) ) }, @@ -81,9 +87,12 @@ struct HandoffCommandHandlerTests { launchSpy?(request) return launched }, - preparationProvider: { request, directory in - guard let preparationSpy else { return preparedHandoffReply } - return try await preparationSpy(request, directory) + forkProvider: { request, directory in + guard let forkSpy else { return validHandoffBriefing } + return try await forkSpy(request, directory) + }, + completionObserver: { completion in + completionSpy?(completion) }, now: { [fixedDate] in fixedDate } ) @@ -93,36 +102,48 @@ struct HandoffCommandHandlerTests { CommandEnvelope(output: .json, command: .handoff(input)) } - @Test func saveWritesArtifactAndReturnsPayload() async throws { + // MARK: - save + + @Test func saveWithInlineBriefWritesArtifactAndReturnsPayload() async throws { let root = try makeTempRoot() defer { remove(root) } let handler = makeHandler(root: root, outgoingAgent: "codex") - let response = await handler.handle(envelope: envelope(HandoffInput(action: .save, note: "wip"))) + let response = await handler.handle( + envelope: envelope(HandoffInput(action: .save, note: "wip", brief: validHandoffBriefing)) + ) #expect(response.ok) #expect(response.command == "handoff") let payload = try #require(try response.data?.decode(as: HandoffCommandPayload.self)) #expect(payload.action == .save) #expect(payload.outgoingAgent == "codex") + #expect(payload.briefing == "inline") + #expect(payload.hasBriefing) let session = try #require(payload.sessionContext) #expect(session.excerptPath?.hasPrefix("handoff/sessions/") == true) let store = HandoffStore(rootURL: root) - #expect(FileManager.default.fileExists(atPath: store.currentURL.path(percentEncoded: false))) + let current = try String(contentsOf: store.currentURL, encoding: .utf8) + #expect(current == validHandoffBriefing + "\n") let content = try String(contentsOf: store.contextURL, encoding: .utf8) #expect(content.contains("Session Context:")) #expect(content.contains(".prowl/handoff/sessions/")) + // One save produces exactly one log line, carrying the briefing outcome. + let log = try String(contentsOf: store.logURL, encoding: .utf8) + let entries = log.split(separator: "\n").filter { $0.hasPrefix("- ") } + #expect(entries.count == 1) + #expect(entries.first?.contains("briefing=inline") == true) } - @Test func saveTranscribesVerifiedSourceReplyBeforePersistingHandoff() async throws { + @Test func saveForksBriefingForThirdPartySource() async throws { let root = try makeTempRoot() defer { remove(root) } let resumed = LockIsolated(nil) let handler = makeHandler( root: root, outgoingAgent: "codex", - preparationSpy: { request, _ in + forkSpy: { request, _ in resumed.setValue(request) return """ Here is the updated artifact: @@ -148,112 +169,150 @@ struct HandoffCommandHandlerTests { #expect(resumed.value?.session.confidence == .exact) #expect(resumed.value?.model == "gpt-5.4") let payload = try #require(try response.data?.decode(as: HandoffCommandPayload.self)) - #expect(payload.preparation == "completed") + #expect(payload.briefing == "fork") + #expect(payload.hasBriefing) // Prowl transcribed the reply (preamble dropped) into current.md. let content = try String(contentsOf: HandoffStore(rootURL: root).currentURL, encoding: .utf8) #expect(content.hasPrefix("# Handoff")) #expect(content.contains("Source-authored status.")) - // One save produces exactly one log line, carrying the preparation outcome. - let log = try String(contentsOf: HandoffStore(rootURL: root).logURL, encoding: .utf8) - let entries = log.split(separator: "\n").filter { $0.hasPrefix("- ") } - #expect(entries.count == 1) - #expect(entries.first?.contains("preparation=completed") == true) } - /// The preparation prompt, the seeded template, and the reply validator - /// must agree on the section names — drift in any one silently breaks - /// carry-forward or validation. - @Test func preparationPromptTemplateAndValidatorAgreeOnSections() { - let sections = [ - "## Objective", - "## Current State", - "## What Has Been Done", - "## Open Questions", - "## Risks / Watch Out", - "## Next Steps", - "## Suggested Prompt For Next Agent", - ] - let prompt = HandoffCommandHandler.preparationPrompt() - for section in sections { - #expect(prompt.contains("\"\(section)\"")) - #expect(HandoffStore.template.contains(section)) - } - // A document with exactly the advertised sections passes validation. - let document = "# Handoff\n\n" + sections.map { "\($0)\ncontent\n" }.joined(separator: "\n") - #expect(HandoffStore.preparedArtifact(fromAgentReply: document) != nil) + @Test func selfHandoffSaveWithoutBriefIsRejectedWithGuidance() async throws { + let root = try makeTempRoot() + defer { remove(root) } + let forkCalled = LockIsolated(false) + let handler = makeHandler( + root: root, + outgoingAgent: "claude", + isSelfHandoff: true, + forkSpy: { _, _ in + forkCalled.setValue(true) + return validHandoffBriefing + } + ) + + let response = await handler.handle(envelope: envelope(HandoffInput(action: .save))) + + #expect(response.ok == false) + #expect(response.error?.code == CLIErrorCode.briefRequired) + #expect(response.error?.message.contains("--brief -") == true) + #expect(forkCalled.value == false) + // Zero side effects: nothing was scaffolded or written. + #expect(!FileManager.default.fileExists(atPath: root.appending(path: ".prowl").path(percentEncoded: false))) + } + + @Test func invalidInlineBriefIsRejectedWithZeroSideEffects() async throws { + let root = try makeTempRoot() + defer { remove(root) } + let handler = makeHandler(root: root, outgoingAgent: "claude", isSelfHandoff: true) + + let response = await handler.handle( + envelope: envelope( + HandoffInput(action: .toAgent, toAgent: "codex", brief: "not a briefing") + ) + ) + + #expect(response.ok == false) + #expect(response.error?.code == CLIErrorCode.invalidBrief) + #expect(!FileManager.default.fileExists(atPath: root.appending(path: ".prowl").path(percentEncoded: false))) } - /// Snapshot semantics: the preparation prompt never embeds or references - /// the previous artifact — the source writes a fresh document from its own - /// session knowledge; history flows through the receiver's read and the - /// archive chain. - @Test func preparationPromptIsIndependentOfTheExistingArtifact() async throws { + @Test func briefAndNoBriefAreMutuallyExclusive() async throws { + let root = try makeTempRoot() + defer { remove(root) } + let handler = makeHandler(root: root, outgoingAgent: "codex") + + let response = await handler.handle( + envelope: envelope( + HandoffInput(action: .save, brief: validHandoffBriefing, contextOnly: true) + ) + ) + + #expect(response.ok == false) + #expect(response.error?.code == CLIErrorCode.invalidArgument) + } + + @Test func contextOnlySaveSkipsBriefingAndKeepsCurrentArtifact() async throws { let root = try makeTempRoot() defer { remove(root) } let store = HandoffStore(rootURL: root) - try store.ensureScaffold() - try "# Handoff\n\n## Objective\nEarlier notes from a previous round.\n" - .write(to: store.currentURL, atomically: true, encoding: .utf8) - let resumed = LockIsolated(nil) + try store.writeBriefing(validHandoffBriefing + "\n", archivingPrevious: false, now: fixedDate) + let forkCalled = LockIsolated(false) let handler = makeHandler( root: root, outgoingAgent: "codex", - preparationSpy: { request, _ in - resumed.setValue(request) - return "unusable" + forkSpy: { _, _ in + forkCalled.setValue(true) + return validHandoffBriefing } ) - _ = await handler.handle(envelope: envelope(HandoffInput(action: .save))) + let response = await handler.handle( + envelope: envelope(HandoffInput(action: .save, contextOnly: true)) + ) - let prompt = try #require(resumed.value?.prompt) - #expect(prompt == HandoffCommandHandler.preparationPrompt()) - #expect(!prompt.contains("Earlier notes from a previous round.")) + #expect(response.ok) + #expect(forkCalled.value == false) + let payload = try #require(try response.data?.decode(as: HandoffCommandPayload.self)) + #expect(payload.briefing == "none") + #expect(payload.hasBriefing == false) + // A context-only checkpoint never touches the last valid briefing. + let current = try String(contentsOf: store.currentURL, encoding: .utf8) + #expect(current == validHandoffBriefing + "\n") } - @Test func saveMarksPreparationFailedForUnusableReply() async throws { + @Test func saveMarksBriefingFailedForUnusableForkReply() async throws { let root = try makeTempRoot() defer { remove(root) } let handler = makeHandler( root: root, outgoingAgent: "codex", - preparationSpy: { _, _ in "I could not update the handoff file." } + forkSpy: { _, _ in "I could not update the handoff file." } ) let response = await handler.handle(envelope: envelope(HandoffInput(action: .save))) #expect(response.ok) let payload = try #require(try response.data?.decode(as: HandoffCommandPayload.self)) - #expect(payload.preparation == "failed") - // The scaffolded template stays in place; no reply prose leaks into it. - let content = try String(contentsOf: HandoffStore(rootURL: root).currentURL, encoding: .utf8) - #expect(content == HandoffStore.template) + #expect(payload.briefing == "failed") + #expect(payload.hasBriefing == false) + // No briefing was ever written; a checkpoint failure leaves no artifact. + #expect(!HandoffStore(rootURL: root).hasCurrentArtifact) let log = try String(contentsOf: HandoffStore(rootURL: root).logURL, encoding: .utf8) - #expect(log.contains("preparation=failed")) + #expect(log.contains("briefing=failed")) } - @Test func saveSkipsPreparationWhenDisabled() async throws { + @Test func savePreservesResolvedNativeSessionContext() async throws { let root = try makeTempRoot() defer { remove(root) } - let resumeCalled = LockIsolated(false) let handler = makeHandler( root: root, outgoingAgent: "codex", - preparationSpy: { _, _ in - resumeCalled.setValue(true) - return preparedHandoffReply - } + sessionContext: HandoffStore.SessionContext( + agent: "codex", + sessionID: "native-session", + paneID: "pane-0", + paneTitle: "codex", + source: "open_file", + confidence: "exact", + transcriptPath: "/tmp/native-session.jsonl", + excerptText: "working on handoff" + ) ) - let response = await handler.handle(envelope: envelope(HandoffInput(action: .save, prepare: false))) + let response = await handler.handle(envelope: envelope(HandoffInput(action: .save))) - #expect(response.ok) - #expect(resumeCalled.value == false) let payload = try #require(try response.data?.decode(as: HandoffCommandPayload.self)) - #expect(payload.preparation == "skipped") + let session = try #require(payload.sessionContext) + #expect(session.sessionID == "native-session") + #expect(session.source == "open_file") + #expect(session.confidence == "exact") + #expect(session.transcriptPath == "/tmp/native-session.jsonl") } - @Test func preparationRequiresVerifiableSourceSession() { + // MARK: - fork request (fallback identity rules) + + @Test func forkRequiresVerifiableSourceSession() { let session = AgentSession( id: "ambiguous-session", transcriptPath: nil, @@ -262,7 +321,7 @@ struct HandoffCommandHandlerTests { ) #expect( - HandoffCommandHandler.preparationRequest( + HandoffCommandHandler.forkRequest( outgoingAgent: "codex", session: session, observation: AgentLaunchObservation(executionMode: .unrestricted) @@ -270,9 +329,9 @@ struct HandoffCommandHandlerTests { ) } - @Test func preparationRequestKeepsSameAdapterModelOnly() throws { + @Test func forkRequestKeepsSameAdapterModelOnly() throws { let request = try #require( - HandoffCommandHandler.preparationRequest( + HandoffCommandHandler.forkRequest( outgoingAgent: "codex", session: AgentSession( id: "9B0E3B0E-67B3-4D45-A3A0-7DD9BC713711", @@ -288,56 +347,91 @@ struct HandoffCommandHandlerTests { #expect(request.model == "gpt-5.4") } - @Test func savePreservesResolvedNativeSessionContext() async throws { + /// The fork prompt, the advertised section skeleton, and the validator must + /// agree on the section names — drift in any one silently breaks validation. + @Test func forkPromptSectionsAndValidatorAgree() { + let prompt = HandoffCommandHandler.forkBriefingPrompt() + for section in HandoffStore.briefingSections where section.hasPrefix("##") { + #expect(prompt.contains("\"\(section)\"")) + } + // A document with exactly the advertised sections passes validation. + let document = + "# Handoff\n\n" + + HandoffStore.briefingSections.dropFirst().map { "\($0)\ncontent\n" }.joined(separator: "\n") + #expect(HandoffStore.validatedBriefing(from: document) != nil) + } + + /// Snapshot semantics: the fork prompt never embeds or references the + /// previous artifact — the source writes a fresh document from its own + /// session knowledge; history flows through the archive chain. + @Test func forkPromptIsIndependentOfTheExistingArtifact() async throws { let root = try makeTempRoot() defer { remove(root) } + let store = HandoffStore(rootURL: root) + try store.writeBriefing( + "# Handoff\n\n## Objective\nEarlier notes from a previous round.\n\n## Current State\nx\n\n## Next Steps\n1. y\n", + archivingPrevious: false, + now: fixedDate + ) + let resumed = LockIsolated(nil) let handler = makeHandler( root: root, outgoingAgent: "codex", - sessionContext: HandoffStore.SessionContext( - agent: "codex", - sessionID: "native-session", - paneID: "pane-0", - paneTitle: "codex", - source: "open_file", - confidence: "exact", - transcriptPath: "/tmp/native-session.jsonl", - excerptText: "working on handoff" - ) + forkSpy: { request, _ in + resumed.setValue(request) + return "unusable" + } ) - let response = await handler.handle(envelope: envelope(HandoffInput(action: .save))) + _ = await handler.handle(envelope: envelope(HandoffInput(action: .save))) - let payload = try #require(try response.data?.decode(as: HandoffCommandPayload.self)) - let session = try #require(payload.sessionContext) - #expect(session.sessionID == "native-session") - #expect(session.source == "open_file") - #expect(session.confidence == "exact") - #expect(session.transcriptPath == "/tmp/native-session.jsonl") + let prompt = try #require(resumed.value?.prompt) + #expect(prompt == HandoffCommandHandler.forkBriefingPrompt()) + #expect(!prompt.contains("Earlier notes from a previous round.")) } - @Test func toRefreshesArchivesAndLaunches() async throws { + // MARK: - to + + @Test func toArchivesOutgoingStateInstallsBriefingAndLaunches() async throws { let root = try makeTempRoot() defer { remove(root) } + let store = HandoffStore(rootURL: root) + let outgoing = + "# Handoff\n\n## Objective\nOutgoing round to preserve.\n\n## Current State\nx\n\n## Next Steps\n1. y\n" + try store.writeBriefing(outgoing, archivingPrevious: false, now: fixedDate) var launchedRequest: AgentStartRequest? + let completions = LockIsolated<[HandoffCLICompletion]>([]) let handler = makeHandler( root: root, outgoingAgent: "codex", - launchSpy: { launchedRequest = $0 } + launchSpy: { launchedRequest = $0 }, + completionSpy: { completion in completions.withValue { $0.append(completion) } } ) let response = await handler.handle( - envelope: envelope(HandoffInput(action: .toAgent, toAgent: "claude", note: "over to you")) + envelope: envelope( + HandoffInput(action: .toAgent, toAgent: "claude", note: "over to you", brief: validHandoffBriefing) + ) ) #expect(response.ok) let payload = try #require(try response.data?.decode(as: HandoffCommandPayload.self)) #expect(payload.action == .toAgent) #expect(payload.toAgent == "claude") + #expect(payload.briefing == "inline") #expect(payload.archivedPath?.hasPrefix("handoff/archive/") == true) #expect(payload.launchedPane?.paneID == "pane-1") + // The archive holds the *outgoing* round; current.md is the new briefing. + let archiveURL = store.handoffDirectory.appending( + path: try #require(payload.archivedPath).replacing("handoff/", with: "") + ) + let archive = try String(contentsOf: archiveURL, encoding: .utf8) + #expect(archive.contains("Outgoing round to preserve.")) + let current = try String(contentsOf: store.currentURL, encoding: .utf8) + #expect(current == validHandoffBriefing + "\n") + // The receiving adapter gets a semantic handoff prompt and only portable // source configuration. Cross-agent model identifiers must not leak. #expect(launchedRequest?.agent == .claude) @@ -345,15 +439,58 @@ struct HandoffCommandHandlerTests { #expect(launchedRequest?.configuration.executionMode == .unrestricted) #expect(launchedRequest?.prompt.contains(".prowl/handoff/current.md") == true) - // Log records the transition. - let store = HandoffStore(rootURL: root) + // Log records the transition; the completion observer fired for the HUD. let log = try String(contentsOf: store.logURL, encoding: .utf8) #expect(log.contains("codex → claude")) + #expect(log.contains("briefing=inline")) + let completion = try #require(completions.value.first) + #expect(completion.action == .toAgent) + #expect(completion.sourcePaneID == "pane-0") + #expect(completion.toAgent == "claude") + #expect(completion.launched?.paneID == "pane-1") + } + + @Test func toWithFailedForkDegradesToContextOnlyAndRemovesStaleBriefing() async throws { + let root = try makeTempRoot() + defer { remove(root) } + let store = HandoffStore(rootURL: root) + let stale = + "# Handoff\n\n## Objective\nStale round.\n\n## Current State\nx\n\n## Next Steps\n1. y\n" + try store.writeBriefing(stale, archivingPrevious: false, now: fixedDate) + + var launchedRequest: AgentStartRequest? + let handler = makeHandler( + root: root, + outgoingAgent: "codex", + launchSpy: { launchedRequest = $0 }, + forkSpy: { _, _ in throw AgentRuntimeError.resumeTimedOut } + ) + + let response = await handler.handle( + envelope: envelope(HandoffInput(action: .toAgent, toAgent: "claude")) + ) + + #expect(response.ok) + let payload = try #require(try response.data?.decode(as: HandoffCommandPayload.self)) + #expect(payload.briefing == "failed") + #expect(payload.hasBriefing == false) + // The stale briefing was archived and removed — it must never impersonate + // a fresh contract for the receiver. + #expect(!store.hasCurrentArtifact) + #expect(payload.archivedPath != nil) + // The kickoff prompt points at context + archive, not current.md. + let prompt = try #require(launchedRequest?.prompt) + #expect(!prompt.contains("current.md")) + #expect(prompt.contains(".prowl/handoff/context.md")) + let log = try String(contentsOf: store.logURL, encoding: .utf8) + #expect(log.contains("briefing=failed")) } @Test func toWithoutLaunchSkipsAgentButArchives() async throws { let root = try makeTempRoot() defer { remove(root) } + let store = HandoffStore(rootURL: root) + try store.writeBriefing(validHandoffBriefing + "\n", archivingPrevious: false, now: fixedDate) var launchCalled = false let handler = makeHandler( @@ -431,22 +568,30 @@ struct HandoffCommandHandlerTests { let log = try String(contentsOf: HandoffStore(rootURL: root).logURL, encoding: .utf8) #expect(log.contains("codex → claude")) #expect(log.contains("launch=failed")) - #expect(log.contains("archive=handoff/archive/")) } - @Test func statusReflectsExistence() async throws { + // MARK: - source resolution + + @Test func missingCallerPaneIsRejectedWithGuidance() async throws { let root = try makeTempRoot() defer { remove(root) } - let handler = makeHandler(root: root, outgoingAgent: "codex") + let handler = makeHandler(root: root, outgoingAgent: "codex", resolveFailure: .noCallerPane) + + let response = await handler.handle(envelope: envelope(HandoffInput(action: .save))) - let before = await handler.handle(envelope: envelope(HandoffInput(action: .status))) - let beforePayload = try #require(try before.data?.decode(as: HandoffCommandPayload.self)) - #expect(beforePayload.exists == false) + #expect(response.ok == false) + #expect(response.error?.code == CLIErrorCode.sourceRequired) + #expect(response.error?.message.contains("--pane") == true) + } - _ = await handler.handle(envelope: envelope(HandoffInput(action: .save))) + // MARK: - kickoff prompt - let after = await handler.handle(envelope: envelope(HandoffInput(action: .status))) - let afterPayload = try #require(try after.data?.decode(as: HandoffCommandPayload.self)) - #expect(afterPayload.exists == true) + @Test func kickoffPromptAdaptsToBriefingPresence() { + let with = HandoffCommandHandler.kickoffPrompt(hasBriefing: true) + #expect(with.contains(".prowl/handoff/current.md")) + let without = HandoffCommandHandler.kickoffPrompt(hasBriefing: false) + #expect(!without.contains("current.md")) + #expect(without.contains(".prowl/handoff/context.md")) + #expect(without.contains("archive/")) } } diff --git a/supacodeTests/HandoffHudFeatureTests.swift b/supacodeTests/HandoffHudFeatureTests.swift index 99905959..aaa8ebc8 100644 --- a/supacodeTests/HandoffHudFeatureTests.swift +++ b/supacodeTests/HandoffHudFeatureTests.swift @@ -31,6 +31,8 @@ struct HandoffHudFeatureTests { ) } + private let sourcePaneID = UUID(uuidString: "5A0B7B44-11A2-4C6C-9A0F-2B93A8B0E001")! + private func makeSourceContext( agent: String = "codex", confidence: AgentSession.Confidence = .exact, @@ -39,7 +41,7 @@ struct HandoffHudFeatureTests { HandoffSourceContext( sessionContext: HandoffStore.SessionContext( agent: agent, - paneID: "pane-0", + paneID: sourcePaneID.uuidString, paneTitle: agent, source: "terminal-scrollback", confidence: "fallback", @@ -68,9 +70,25 @@ struct HandoffHudFeatureTests { 1. Ship it. """ + private struct InjectedRequest: Equatable { + let worktreeID: Worktree.ID + let surfaceID: UUID + let text: String + } + + private func launchedPane(worktreeID: String) -> HandoffLaunchedPane { + HandoffLaunchedPane( + worktreeID: worktreeID, + worktreeName: "feature-handoff", + tabID: UUID().uuidString, + paneID: UUID().uuidString, + paneTitle: "claude" + ) + } + // MARK: - State construction - @Test func makeRequiresDetectedAgent() throws { + @Test func makeRequiresDetectedAgentAndPaneIdentity() throws { let root = try makeTempRoot() defer { remove(root) } let worktree = makeWorktree(root: root) @@ -79,7 +97,7 @@ struct HandoffHudFeatureTests { let noAgent = HandoffSourceContext( sessionContext: HandoffStore.SessionContext( agent: nil, - paneID: "pane-0", + paneID: sourcePaneID.uuidString, paneTitle: nil, source: "terminal-scrollback", confidence: "fallback", @@ -89,6 +107,19 @@ struct HandoffHudFeatureTests { session: nil ) #expect(HandoffHudFeature.State.make(worktree: worktree, source: noAgent) == nil) + let noPaneUUID = HandoffSourceContext( + sessionContext: HandoffStore.SessionContext( + agent: "codex", + paneID: "not-a-uuid", + paneTitle: nil, + source: "terminal-scrollback", + confidence: "fallback", + excerptText: nil + ), + observation: nil, + session: nil + ) + #expect(HandoffHudFeature.State.make(worktree: worktree, source: noPaneUUID) == nil) } @Test func makeBuildsRegistryTargetsAndMarksCurrentAgent() throws { @@ -105,11 +136,13 @@ struct HandoffHudFeatureTests { #expect(codexTarget.isCurrentAgent) let claudeTarget = try #require(state.targets.first { $0.agent == .claude }) #expect(!claudeTarget.isCurrentAgent) - #expect(state.source.preparationRequest != nil) + #expect(state.source.forkRequest != nil) + #expect(state.canFork) #expect(state.source.displayName == "codex") + #expect(state.source.sourceSurfaceID == sourcePaneID) } - @Test func makeWithMediumConfidenceSkipsPreparationRequest() throws { + @Test func makeWithMediumConfidenceSkipsForkRequest() throws { let root = try makeTempRoot() defer { remove(root) } let state = try #require( @@ -118,7 +151,8 @@ struct HandoffHudFeatureTests { source: makeSourceContext(confidence: .medium) ) ) - #expect(state.source.preparationRequest == nil) + #expect(state.source.forkRequest == nil) + #expect(!state.canFork) } @Test func makeSurfacesCarriedOverUnrestrictedMode() throws { @@ -165,9 +199,9 @@ struct HandoffHudFeatureTests { } } - // MARK: - Full hand-off run + // MARK: - Inline path: inject, then observe the CLI completion - @Test(.dependencies) func handOffRunPersistsArtifactsAndLaunches() async throws { + @Test(.dependencies) func confirmInjectsRequestIntoSourcePane() async throws { let root = try makeTempRoot() defer { remove(root) } let worktree = makeWorktree(root: root) @@ -177,304 +211,333 @@ struct HandoffHudFeatureTests { let claudeIndex = try #require(initial.targets.firstIndex { $0.agent == .claude }) initial.selectedIndex = claudeIndex - let sent = LockIsolated<[TerminalClient.Command]>([]) + let injected = LockIsolated<[InjectedRequest]>([]) let startedAt = Date(timeIntervalSince1970: 1_760_000_000) let store = TestStore(initialState: initial) { HandoffHudFeature() } withDependencies: { $0.date.now = startedAt - $0[TerminalClient.self].send = { command in - sent.withValue { $0.append(command) } + $0[TerminalClient.self].sendTextToSurface = { worktreeID, surfaceID, text in + injected.withValue { + $0.append(InjectedRequest(worktreeID: worktreeID, surfaceID: surfaceID, text: text)) + } + return true } - $0[AgentRuntimeClient.self] = AgentRuntimeClient(resume: { _, _ in Self.usableReply }) } - let claudeTarget = initial.targets[claudeIndex] await store.send(.confirmSelection) { $0.phase = .running( HandoffHudRun( - target: claudeTarget, + target: $0.targets[claudeIndex], startedAt: startedAt, - stages: [.briefing, .saving, .archiving, .launching], - stage: .briefing + stage: .requesting ) ) } - await store.receive(\.briefingReplyReceived) { - $0.phase = .running( - HandoffHudRun( - target: claudeTarget, - startedAt: startedAt, - stages: [.briefing, .saving, .archiving, .launching], - stage: .saving - ) - ) - } - await store.receive(\.briefingFinished) { - $0.phase = .running( - HandoffHudRun( - target: claudeTarget, - startedAt: startedAt, - stages: [.briefing, .saving, .archiving, .launching], - stage: .saving, - preparation: .completed - ) - ) + + let request = try #require(injected.value.first) + #expect(request.worktreeID == worktree.id) + #expect(request.surfaceID == sourcePaneID) + #expect(request.text.contains("prowl handoff to claude --brief -")) + #expect(request.text.contains("## Objective")) + #expect(!request.text.contains("\n")) + } + + @Test(.dependencies) func cliCompletionFromSourcePaneFinishesAndFocusesReceiver() async throws { + let root = try makeTempRoot() + defer { remove(root) } + let worktree = makeWorktree(root: root) + var initial = try #require( + HandoffHudFeature.State.make(worktree: worktree, source: makeSourceContext()) + ) + let claudeIndex = try #require(initial.targets.firstIndex { $0.agent == .claude }) + initial.selectedIndex = claudeIndex + let launched = launchedPane(worktreeID: worktree.id) + let focused = LockIsolated<[(Worktree.ID, UUID)]>([]) + + let store = TestStore(initialState: initial) { + HandoffHudFeature() + } withDependencies: { + $0.date.now = Date(timeIntervalSince1970: 1_760_000_000) + $0[TerminalClient.self].sendTextToSurface = { _, _, _ in true } + $0[TerminalClient.self].focusSurface = { worktreeID, surfaceID in + focused.withValue { $0.append((worktreeID, surfaceID)) } + return true + } } - await store.receive(\.savingFinished) { + + await store.send(.confirmSelection) { $0.phase = .running( HandoffHudRun( - target: claudeTarget, - startedAt: startedAt, - stages: [.briefing, .saving, .archiving, .launching], - stage: .archiving, - preparation: .completed + target: $0.targets[claudeIndex], + startedAt: Date(timeIntervalSince1970: 1_760_000_000), + stage: .requesting ) ) } - await store.receive(\.archivingFinished) { - $0.phase = .running( - HandoffHudRun( - target: claudeTarget, - startedAt: startedAt, - stages: [.briefing, .saving, .archiving, .launching], - stage: .launching, - preparation: .completed + + await store.send( + .cliCompleted( + HandoffCLICompletion( + action: .toAgent, + sourcePaneID: sourcePaneID.uuidString, + toAgent: "claude", + briefing: .inline, + launched: launched ) ) - } - await store.receive(\.launchFinished) { + ) { $0.phase = .finished(.handedOff(agentDisplayName: "Claude Code")) } - let handoffStore = HandoffStore(rootURL: root) - let current = try String(contentsOf: handoffStore.currentURL, encoding: .utf8) - #expect(current.contains("Finish the HUD.")) - - // Mechanical context, archive, and the unified transition log line exist. - #expect(FileManager.default.fileExists(atPath: handoffStore.contextURL.path(percentEncoded: false))) - let archives = try FileManager.default.contentsOfDirectory(atPath: handoffStore.archiveDirectory.path) - #expect(archives.contains { $0.contains("codex-to-claude") }) - let log = try String(contentsOf: handoffStore.logURL, encoding: .utf8) - #expect(log.contains("codex → claude")) - #expect(log.contains("launch=requested")) - #expect(log.contains("preparation=completed")) - #expect(log.contains("source=agents-hud")) - - // The receiving tab was requested with the adapter invocation. - guard case .createTabWithInput(_, let input, let workingDirectory, _, _, let name, _)? = sent.value.first - else { - Issue.record("Expected createTabWithInput, got \(sent.value)") - return - } - #expect(input.contains("'claude'")) - #expect(input.contains(HandoffCommandHandler.kickoffPrompt())) - #expect(workingDirectory == root) - #expect(name == "Hand off → Claude Code") + let (focusWorktreeID, focusSurfaceID) = try #require(focused.value.first) + #expect(focusWorktreeID == launched.worktreeID) + #expect(focusSurfaceID.uuidString == launched.paneID) } - @Test(.dependencies) func briefOnlyRunSavesWithoutArchiveOrLaunch() async throws { + @Test(.dependencies) func cliCompletionFromOtherPaneOrActionIsIgnored() async throws { let root = try makeTempRoot() defer { remove(root) } let worktree = makeWorktree(root: root) var initial = try #require( HandoffHudFeature.State.make(worktree: worktree, source: makeSourceContext()) ) - let briefIndex = try #require(initial.targets.firstIndex { $0.kind == .briefOnly }) - initial.selectedIndex = briefIndex + let claudeIndex = try #require(initial.targets.firstIndex { $0.agent == .claude }) + initial.selectedIndex = claudeIndex - let sent = LockIsolated<[TerminalClient.Command]>([]) let store = TestStore(initialState: initial) { HandoffHudFeature() } withDependencies: { $0.date.now = Date(timeIntervalSince1970: 1_760_000_000) - $0[TerminalClient.self].send = { command in - sent.withValue { $0.append(command) } - } - $0[AgentRuntimeClient.self] = AgentRuntimeClient(resume: { _, _ in Self.usableReply }) + $0[TerminalClient.self].sendTextToSurface = { _, _, _ in true } } - store.exhaustivity = .off - await store.send(.confirmSelection) - await store.receive(\.briefingReplyReceived) - await store.receive(\.briefingFinished) - await store.receive(\.savingFinished) { - $0.phase = .finished(.briefSaved) + await store.send(.confirmSelection) { + $0.phase = .running( + HandoffHudRun( + target: $0.targets[claudeIndex], + startedAt: Date(timeIntervalSince1970: 1_760_000_000), + stage: .requesting + ) + ) } - await store.finish() - #expect(sent.value.isEmpty) - let handoffStore = HandoffStore(rootURL: root) - let log = try String(contentsOf: handoffStore.logURL, encoding: .utf8) - #expect(log.contains("save")) - #expect(log.contains("preparation=completed")) - #expect(!log.contains("→")) - let archiveContents = - (try? FileManager.default.contentsOfDirectory(atPath: handoffStore.archiveDirectory.path)) ?? [] - #expect(archiveContents.isEmpty) + // Another pane's handoff: not ours. + await store.send( + .cliCompleted( + HandoffCLICompletion( + action: .toAgent, + sourcePaneID: UUID().uuidString, + toAgent: "claude", + briefing: .inline, + launched: nil + ) + ) + ) + // Our pane, but a checkpoint — the run waits for a transition. + await store.send( + .cliCompleted( + HandoffCLICompletion( + action: .save, + sourcePaneID: sourcePaneID.uuidString, + toAgent: nil, + briefing: .inline, + launched: nil + ) + ) + ) } - @Test(.dependencies) func runWithoutResumableSessionSkipsBriefingStage() async throws { + @Test(.dependencies) func briefOnlyCompletionFinishesAsSaved() async throws { let root = try makeTempRoot() defer { remove(root) } + let worktree = makeWorktree(root: root) var initial = try #require( - HandoffHudFeature.State.make( - worktree: makeWorktree(root: root), - source: makeSourceContext(confidence: .medium) - ) + HandoffHudFeature.State.make(worktree: worktree, source: makeSourceContext()) ) - let claudeIndex = try #require(initial.targets.firstIndex { $0.agent == .claude }) - initial.selectedIndex = claudeIndex + let briefIndex = try #require(initial.targets.firstIndex { $0.kind == .briefOnly }) + initial.selectedIndex = briefIndex + let injected = LockIsolated<[String]>([]) let store = TestStore(initialState: initial) { HandoffHudFeature() } withDependencies: { $0.date.now = Date(timeIntervalSince1970: 1_760_000_000) - $0[TerminalClient.self].send = { _ in } - $0[AgentRuntimeClient.self] = AgentRuntimeClient( - resume: { _, _ in - Issue.record("Resume must not run without a preparation request") - return "" - } - ) + $0[TerminalClient.self].sendTextToSurface = { _, _, text in + injected.withValue { $0.append(text) } + return true + } } - store.exhaustivity = .off await store.send(.confirmSelection) { - #expect($0.run?.stages == [.saving, .archiving, .launching]) - #expect($0.run?.stage == .saving) - #expect($0.run?.preparation == .skipped) + $0.phase = .running( + HandoffHudRun( + target: $0.targets[briefIndex], + startedAt: Date(timeIntervalSince1970: 1_760_000_000), + stage: .requesting + ) + ) + } + #expect(injected.value.first?.contains("prowl handoff save --brief -") == true) + + await store.send( + .cliCompleted( + HandoffCLICompletion( + action: .save, + sourcePaneID: sourcePaneID.uuidString, + toAgent: nil, + briefing: .inline, + launched: nil + ) + ) + ) { + $0.phase = .finished(.briefSaved) } - await store.receive(\.launchFinished) - await store.finish() - - let log = try String(contentsOf: HandoffStore(rootURL: root).logURL, encoding: .utf8) - #expect(log.contains("preparation=skipped")) } - // MARK: - Skip and Cancel + // MARK: - Fallbacks - @Test(.dependencies) func skipDuringBriefingContinuesMechanically() async throws { + @Test(.dependencies) func forkFallbackRunsTransitionAndLaunches() async throws { let root = try makeTempRoot() defer { remove(root) } + let worktree = makeWorktree(root: root) var initial = try #require( - HandoffHudFeature.State.make(worktree: makeWorktree(root: root), source: makeSourceContext()) + HandoffHudFeature.State.make(worktree: worktree, source: makeSourceContext()) ) let claudeIndex = try #require(initial.targets.firstIndex { $0.agent == .claude }) initial.selectedIndex = claudeIndex + let sent = LockIsolated<[TerminalClient.Command]>([]) + let startedAt = Date(timeIntervalSince1970: 1_760_000_000) + let store = TestStore(initialState: initial) { HandoffHudFeature() } withDependencies: { - $0.date.now = Date(timeIntervalSince1970: 1_760_000_000) - $0[TerminalClient.self].send = { _ in } - $0[AgentRuntimeClient.self] = AgentRuntimeClient( - resume: { _, _ in - // Hangs until Skip cancels the briefing effect. - try await Task.never() - } - ) + $0.date.now = startedAt + $0[TerminalClient.self].sendTextToSurface = { _, _, _ in true } + $0[TerminalClient.self].send = { command in + sent.withValue { $0.append(command) } + } + $0[AgentRuntimeClient.self] = AgentRuntimeClient(resume: { _, _ in Self.usableReply }) } - store.exhaustivity = .off await store.send(.confirmSelection) { - #expect($0.run?.stage == .briefing) + $0.phase = .running( + HandoffHudRun(target: $0.targets[claudeIndex], startedAt: startedAt, stage: .requesting) + ) } - await store.send(.skipBriefingTapped) { - #expect($0.run?.stage == .saving) - #expect($0.run?.preparation == .skipped) + await store.send(.fallbackForkTapped) { + $0.phase = .running( + HandoffHudRun(target: $0.targets[claudeIndex], startedAt: startedAt, stage: .forking) + ) } - await store.receive(\.launchFinished) { + await store.receive(\.fallbackFinished) { $0.phase = .finished(.handedOff(agentDisplayName: "Claude Code")) } - await store.finish() - let handoffStore = HandoffStore(rootURL: root) - let log = try String(contentsOf: handoffStore.logURL, encoding: .utf8) - #expect(log.contains("preparation=skipped")) - // The seeded template stays: no reply was transcribed. - let current = try String(contentsOf: handoffStore.currentURL, encoding: .utf8) - #expect(current == HandoffStore.template) + // The transition persisted the forked briefing and launched visibly. + let store2 = HandoffStore(rootURL: root) + let current = try String(contentsOf: store2.currentURL, encoding: .utf8) + #expect(current.contains("Finish the HUD.")) + let log = try String(contentsOf: store2.logURL, encoding: .utf8) + #expect(log.contains("codex → claude")) + #expect(log.contains("briefing=fork")) + #expect(log.contains("source=agents-hud")) + let commands = sent.value + #expect( + commands.contains { command in + if case .createTabWithInput(let commandWorktree, let input, _, _, _, _, _) = command { + return commandWorktree.id == worktree.id && input.contains("claude") + } + return false + } + ) } - @Test(.dependencies) func cancelDuringBriefingAbortsWithoutArtifacts() async throws { + @Test(.dependencies) func contextOnlyFallbackRemovesStaleBriefing() async throws { let root = try makeTempRoot() defer { remove(root) } + let worktree = makeWorktree(root: root) + let handoffStore = HandoffStore(rootURL: root) + try handoffStore.writeBriefing( + "# Handoff\n\n## Objective\nStale.\n\n## Current State\nx\n\n## Next Steps\n1. y\n", + archivingPrevious: false, + now: Date(timeIntervalSince1970: 1_759_000_000) + ) var initial = try #require( - HandoffHudFeature.State.make(worktree: makeWorktree(root: root), source: makeSourceContext()) + HandoffHudFeature.State.make(worktree: worktree, source: makeSourceContext()) ) let claudeIndex = try #require(initial.targets.firstIndex { $0.agent == .claude }) initial.selectedIndex = claudeIndex + let startedAt = Date(timeIntervalSince1970: 1_760_000_000) let store = TestStore(initialState: initial) { HandoffHudFeature() } withDependencies: { - $0.date.now = Date(timeIntervalSince1970: 1_760_000_000) + $0.date.now = startedAt + $0[TerminalClient.self].sendTextToSurface = { _, _, _ in true } $0[TerminalClient.self].send = { _ in } - $0[AgentRuntimeClient.self] = AgentRuntimeClient(resume: { _, _ in try await Task.never() }) } - store.exhaustivity = .off - await store.send(.confirmSelection) - await store.send(.cancelTapped) - await store.receive(\.delegate.dismiss) - await store.finish() + await store.send(.confirmSelection) { + $0.phase = .running( + HandoffHudRun(target: $0.targets[claudeIndex], startedAt: startedAt, stage: .requesting) + ) + } + await store.send(.fallbackContextOnlyTapped) { + $0.phase = .running( + HandoffHudRun(target: $0.targets[claudeIndex], startedAt: startedAt, stage: .saving) + ) + } + await store.receive(\.fallbackFinished) { + $0.phase = .finished(.handedOff(agentDisplayName: "Claude Code")) + } - // Nothing was persisted and nothing was logged. - let handoffDirectory = HandoffStore(rootURL: root).handoffDirectory - #expect(!FileManager.default.fileExists(atPath: handoffDirectory.path(percentEncoded: false))) + // Context-only: the stale briefing was archived away, never handed over. + #expect(!handoffStore.hasCurrentArtifact) + let log = try String(contentsOf: handoffStore.logURL, encoding: .utf8) + #expect(log.contains("briefing=none")) } - @Test(.dependencies) func lateCancellationInsensitiveReplyAfterCancelDoesNotWriteArtifact() async throws { + @Test(.dependencies) func failedInjectionFallsBackAutomatically() async throws { let root = try makeTempRoot() defer { remove(root) } + let worktree = makeWorktree(root: root) var initial = try #require( - HandoffHudFeature.State.make(worktree: makeWorktree(root: root), source: makeSourceContext()) + HandoffHudFeature.State.make(worktree: worktree, source: makeSourceContext()) ) let claudeIndex = try #require(initial.targets.firstIndex { $0.agent == .claude }) initial.selectedIndex = claudeIndex - let replyContinuation = LockIsolated?>(nil) + let startedAt = Date(timeIntervalSince1970: 1_760_000_000) let store = TestStore(initialState: initial) { HandoffHudFeature() } withDependencies: { - $0.date.now = Date(timeIntervalSince1970: 1_760_000_000) + $0.date.now = startedAt + $0[TerminalClient.self].sendTextToSurface = { _, _, _ in false } $0[TerminalClient.self].send = { _ in } - $0[AgentRuntimeClient.self] = AgentRuntimeClient( - resume: { _, _ in - await withCheckedContinuation { continuation in - replyContinuation.setValue(continuation) - } - } - ) - } - store.exhaustivity = .off - - await store.send(.confirmSelection) - while replyContinuation.value == nil { - await Task.yield() + $0[AgentRuntimeClient.self] = AgentRuntimeClient(resume: { _, _ in Self.usableReply }) } - await store.send(.cancelTapped) - await store.receive(\.delegate.dismiss) - replyContinuation.withValue { continuation in - continuation?.resume(returning: Self.usableReply) - continuation = nil + await store.send(.confirmSelection) { + $0.phase = .running( + HandoffHudRun(target: $0.targets[claudeIndex], startedAt: startedAt, stage: .forking) + ) } - for _ in 0..<10 { - await Task.yield() + await store.receive(\.fallbackFinished) { + $0.phase = .finished(.handedOff(agentDisplayName: "Claude Code")) } - - let handoffDirectory = HandoffStore(rootURL: root).handoffDirectory - #expect(!FileManager.default.fileExists(atPath: handoffDirectory.path(percentEncoded: false))) } - @Test(.dependencies) func lateBriefingResultAfterSkipIsIgnored() async throws { + // MARK: - Cancellation + + @Test(.dependencies) func cancelWhileRequestingDismisses() async throws { let root = try makeTempRoot() defer { remove(root) } + let worktree = makeWorktree(root: root) var initial = try #require( - HandoffHudFeature.State.make(worktree: makeWorktree(root: root), source: makeSourceContext()) + HandoffHudFeature.State.make(worktree: worktree, source: makeSourceContext()) ) let claudeIndex = try #require(initial.targets.firstIndex { $0.agent == .claude }) initial.selectedIndex = claudeIndex @@ -483,51 +546,59 @@ struct HandoffHudFeatureTests { HandoffHudFeature() } withDependencies: { $0.date.now = Date(timeIntervalSince1970: 1_760_000_000) - $0[TerminalClient.self].send = { _ in } - $0[AgentRuntimeClient.self] = AgentRuntimeClient(resume: { _, _ in try await Task.never() }) + $0[TerminalClient.self].sendTextToSurface = { _, _, _ in true } } - store.exhaustivity = .off - - await store.send(.confirmSelection) - await store.send(.skipBriefingTapped) - await store.receive(\.launchFinished) - // A racing reply that arrives after Skip must not resurrect the run or - // transcribe source prose after the mechanical hand-off has started. - await store.send(.briefingReplyReceived(.reply(Self.usableReply))) - let log = try String(contentsOf: HandoffStore(rootURL: root).logURL, encoding: .utf8) - #expect(log.contains("preparation=skipped")) - #expect(!log.contains("preparation=completed")) + + await store.send(.confirmSelection) { + $0.phase = .running( + HandoffHudRun( + target: $0.targets[claudeIndex], + startedAt: Date(timeIntervalSince1970: 1_760_000_000), + stage: .requesting + ) + ) + } + await store.send(.cancelTapped) + await store.receive(\.delegate.dismiss) } - @Test(.dependencies) func saveFailureFinishesRunAsFailed() async throws { + @Test(.dependencies) func cancelWhileForkingAbortsWithoutWriting() async throws { let root = try makeTempRoot() + defer { remove(root) } let worktree = makeWorktree(root: root) var initial = try #require( - HandoffHudFeature.State.make(worktree: worktree, source: makeSourceContext(confidence: .medium)) + HandoffHudFeature.State.make(worktree: worktree, source: makeSourceContext()) ) let claudeIndex = try #require(initial.targets.firstIndex { $0.agent == .claude }) initial.selectedIndex = claudeIndex - // Break the root: a plain file where the directory should be. - try FileManager.default.removeItem(at: root) - try "not a directory".write(to: root, atomically: true, encoding: .utf8) - defer { remove(root) } + let startedAt = Date(timeIntervalSince1970: 1_760_000_000) let store = TestStore(initialState: initial) { HandoffHudFeature() } withDependencies: { - $0.date.now = Date(timeIntervalSince1970: 1_760_000_000) + $0.date.now = startedAt + $0[TerminalClient.self].sendTextToSurface = { _, _, _ in true } $0[TerminalClient.self].send = { _ in } - $0[AgentRuntimeClient.self] = AgentRuntimeClient(resume: { _, _ in "" }) + $0[AgentRuntimeClient.self] = AgentRuntimeClient(resume: { _, _ in + try await Task.never() + }) } - store.exhaustivity = .off - await store.send(.confirmSelection) - await store.receive(\.runFailed) + await store.send(.confirmSelection) { + $0.phase = .running( + HandoffHudRun(target: $0.targets[claudeIndex], startedAt: startedAt, stage: .requesting) + ) + } + await store.send(.fallbackForkTapped) { + $0.phase = .running( + HandoffHudRun(target: $0.targets[claudeIndex], startedAt: startedAt, stage: .forking) + ) + } + await store.send(.cancelTapped) + await store.receive(\.delegate.dismiss) await store.finish() - guard case .finished(.failed) = store.state.phase else { - Issue.record("Expected failed outcome, got \(store.state.phase)") - return - } + // The aborted fork never touched the filesystem. + #expect(!FileManager.default.fileExists(atPath: root.appending(path: ".prowl").path(percentEncoded: false))) } } diff --git a/supacodeTests/HandoffStoreTests.swift b/supacodeTests/HandoffStoreTests.swift index e75f9589..3b52d195 100644 --- a/supacodeTests/HandoffStoreTests.swift +++ b/supacodeTests/HandoffStoreTests.swift @@ -42,6 +42,19 @@ struct HandoffStoreTests { private let fixedDate = Date(timeIntervalSince1970: 1_760_000_000) + private let validBriefing = """ + # Handoff + + ## Objective + Ship it. + + ## Current State + Green. + + ## Next Steps + 1. Review. + """ + // MARK: - parseShortstat (pure) @Test func parseShortstatExtractsInsertionsAndDeletions() { @@ -64,25 +77,13 @@ struct HandoffStoreTests { #expect(deletions == 0) } - // MARK: - preparedArtifact (pure) + // MARK: - validatedBriefing (pure) - @Test func preparedArtifactAcceptsPlainDocument() { - let reply = """ - # Handoff - - ## Objective - Ship it. - - ## Current State - Green. - - ## Next Steps - 1. Review. - """ - #expect(HandoffStore.preparedArtifact(fromAgentReply: reply) == reply + "\n") + @Test func validatedBriefingAcceptsPlainDocument() { + #expect(HandoffStore.validatedBriefing(from: validBriefing) == validBriefing + "\n") } - @Test func preparedArtifactUnwrapsCodeFenceAndDropsPreamble() { + @Test func validatedBriefingUnwrapsCodeFenceAndDropsPreamble() { let reply = """ Sure! Here's the updated handoff: @@ -99,13 +100,13 @@ struct HandoffStoreTests { 1. Review. ``` """ - let artifact = HandoffStore.preparedArtifact(fromAgentReply: reply) + let artifact = HandoffStore.validatedBriefing(from: reply) #expect(artifact?.hasPrefix("# Handoff") == true) #expect(artifact?.contains("```") == false) #expect(artifact?.contains("Sure!") == false) } - @Test func preparedArtifactDropsChatterAfterTheClosingFence() { + @Test func validatedBriefingDropsChatterAfterTheClosingFence() { let reply = """ ```markdown # Handoff @@ -122,12 +123,12 @@ struct HandoffStoreTests { Let me know if you need anything else! """ - let artifact = HandoffStore.preparedArtifact(fromAgentReply: reply) + let artifact = HandoffStore.validatedBriefing(from: reply) #expect(artifact?.hasSuffix("1. Address review feedback.\n") == true) #expect(artifact?.contains("Let me know") == false) } - @Test func preparedArtifactKeepsEmbeddedCodeBlocksIntact() { + @Test func validatedBriefingKeepsEmbeddedCodeBlocksIntact() { // A fence-wrapped reply whose document embeds its own code block: the // embedded pair closes before the wrapper's closing fence, so the last // fence line is the correct cut point and the block survives. @@ -148,11 +149,11 @@ struct HandoffStoreTests { ``` ``` """ - let artifact = HandoffStore.preparedArtifact(fromAgentReply: reply) + let artifact = HandoffStore.validatedBriefing(from: reply) #expect(artifact?.contains("make test") == true) } - @Test func preparedArtifactNeverCutsAtAFenceInsideAnUnwrappedDocument() { + @Test func validatedBriefingNeverCutsAtAFenceInsideAnUnwrappedDocument() { // No wrapper fence: an embedded code block's fence lines are body // content, never a truncation point. let reply = """ @@ -171,48 +172,38 @@ struct HandoffStoreTests { ``` 2. Push the branch. """ - let artifact = HandoffStore.preparedArtifact(fromAgentReply: reply) + let artifact = HandoffStore.validatedBriefing(from: reply) #expect(artifact?.contains("make test") == true) #expect(artifact?.contains("2. Push the branch.") == true) } - @Test func preparedArtifactRejectsUnusableReplies() { - #expect(HandoffStore.preparedArtifact(fromAgentReply: "") == nil) - #expect(HandoffStore.preparedArtifact(fromAgentReply: "I could not update the file.") == nil) + @Test func validatedBriefingRejectsUnusableText() { + #expect(HandoffStore.validatedBriefing(from: "") == nil) + #expect(HandoffStore.validatedBriefing(from: "I could not update the file.") == nil) // Missing required sections. - #expect(HandoffStore.preparedArtifact(fromAgentReply: "# Handoff\n\n## Objective\nOnly this.") == nil) - // Echoing the seeded template back is not a prepared artifact. - #expect(HandoffStore.preparedArtifact(fromAgentReply: HandoffStore.template) == nil) + #expect(HandoffStore.validatedBriefing(from: "# Handoff\n\n## Objective\nOnly this.") == nil) } - @Test func applyPreparationReplyTranscribesIntoCurrent() throws { + // MARK: - writeBriefing / removeCurrentArtifact + + @Test func writeBriefingCreatesCurrentArtifact() throws { let root = try makeTempRoot() defer { remove(root) } let store = HandoffStore(rootURL: root) - let reply = """ - # Handoff - - ## Objective - Ship it. + try store.writeBriefing(validBriefing + "\n", archivingPrevious: true, now: fixedDate) - ## Current State - Green. - - ## Next Steps - 1. Review. - """ - #expect(store.applyPreparationReply(reply, now: fixedDate)) let content = try String(contentsOf: store.currentURL, encoding: .utf8) - #expect(content == reply + "\n") - - // An unusable reply leaves the transcribed artifact untouched. - #expect(store.applyPreparationReply("nope", now: fixedDate) == false) - let unchanged = try String(contentsOf: store.currentURL, encoding: .utf8) - #expect(unchanged == reply + "\n") + #expect(content == validBriefing + "\n") + // First-ever write: nothing to archive. + let archived = try FileManager.default.contentsOfDirectory( + at: store.archiveDirectory, + includingPropertiesForKeys: nil + ) + #expect(archived.isEmpty) } - @Test func applyPreparationReplyArchivesPreviousArtifact() throws { + @Test func writeBriefingArchivesReplacedArtifact() throws { let root = try makeTempRoot() defer { remove(root) } let store = HandoffStore(rootURL: root) @@ -220,79 +211,81 @@ struct HandoffStoreTests { try FileManager.default.createDirectory(at: store.handoffDirectory, withIntermediateDirectories: true) try previous.write(to: store.currentURL, atomically: true, encoding: .utf8) - let reply = """ - # Handoff - - ## Objective - Ship it. - - ## Current State - Green. - - ## Next Steps - 1. Review. - """ - #expect(store.applyPreparationReply(reply, now: fixedDate)) + try store.writeBriefing(validBriefing + "\n", archivingPrevious: true, now: fixedDate) let archived = try FileManager.default.contentsOfDirectory( at: store.archiveDirectory, includingPropertiesForKeys: nil ) - let backup = try #require(archived.first { $0.lastPathComponent.hasSuffix("-preparation-backup.md") }) + let backup = try #require(archived.first { $0.lastPathComponent.contains("-replaced-current") }) #expect(archived.count == 1) #expect(try String(contentsOf: backup, encoding: .utf8) == previous) - #expect(try String(contentsOf: store.currentURL, encoding: .utf8) == reply + "\n") + #expect(try String(contentsOf: store.currentURL, encoding: .utf8) == validBriefing + "\n") } - @Test func applyPreparationReplySkipsArchiveForTemplateOrMissingArtifact() throws { - let reply = """ - # Handoff - - ## Objective - Ship it. + @Test func writeBriefingWithoutArchivingLeavesArchiveAlone() throws { + // The transition path archives the outgoing state as a combined snapshot + // first, so its write must not add a second backup. + let root = try makeTempRoot() + defer { remove(root) } + let store = HandoffStore(rootURL: root) + let previous = "# Handoff\n\n## Objective\nOutgoing round.\n" + try FileManager.default.createDirectory(at: store.handoffDirectory, withIntermediateDirectories: true) + try previous.write(to: store.currentURL, atomically: true, encoding: .utf8) - ## Current State - Green. + try store.writeBriefing(validBriefing + "\n", archivingPrevious: false, now: fixedDate) - ## Next Steps - 1. Review. - """ - - // First-ever preparation: no current.md to snapshot. - let freshRoot = try makeTempRoot() - defer { remove(freshRoot) } - let freshStore = HandoffStore(rootURL: freshRoot) - #expect(freshStore.applyPreparationReply(reply, now: fixedDate)) - #expect(!FileManager.default.fileExists(atPath: freshStore.archiveDirectory.path(percentEncoded: false))) - - // A never-edited seeded template carries no prose worth archiving. - let seededRoot = try makeTempRoot() - defer { remove(seededRoot) } - let seededStore = HandoffStore(rootURL: seededRoot) - try seededStore.ensureScaffold() - #expect(seededStore.applyPreparationReply(reply, now: fixedDate)) let archived = try FileManager.default.contentsOfDirectory( - at: seededStore.archiveDirectory, + at: store.archiveDirectory, includingPropertiesForKeys: nil ) #expect(archived.isEmpty) + #expect(try String(contentsOf: store.currentURL, encoding: .utf8) == validBriefing + "\n") } - // MARK: - save (filesystem; non-git root) + @Test func removeCurrentArtifactDeletesAndToleratesAbsence() throws { + let root = try makeTempRoot() + defer { remove(root) } + let store = HandoffStore(rootURL: root) + + // Absent: a no-op. + try store.removeCurrentArtifact() - @Test func scaffoldSelfIgnoresHandoffInGitRepository() throws { + try store.writeBriefing(validBriefing + "\n", archivingPrevious: false, now: fixedDate) + #expect(store.hasCurrentArtifact) + try store.removeCurrentArtifact() + #expect(!store.hasCurrentArtifact) + } + + // MARK: - layout + + @Test func layoutSelfIgnoresHandoffInGitRepository() throws { let root = try makeTempRoot() defer { remove(root) } _ = try runGit(["init", "--quiet"], in: root) let store = HandoffStore(rootURL: root) - try store.ensureScaffold() + try store.ensureLayout() let ignore = try String(contentsOf: store.ignoreURL, encoding: .utf8) #expect(ignore == "*\n") #expect(try runGit(["status", "--porcelain"], in: root).isEmpty) } + @Test func layoutNeverSeedsCurrentArtifact() throws { + // current.md exists iff a validated briefing produced it — no template. + let root = try makeTempRoot() + defer { remove(root) } + let store = HandoffStore(rootURL: root) + + try store.ensureLayout() + _ = try store.save(outgoingAgent: "codex", note: nil, now: fixedDate) + + #expect(!store.hasCurrentArtifact) + } + + // MARK: - save (filesystem; non-git root) + @Test func saveKeepsNonASCIIChangedFileNamesReadable() throws { let root = try makeTempRoot() defer { remove(root) } @@ -307,16 +300,13 @@ struct HandoffStoreTests { #expect(context.contains("交接记录.md")) } - @Test func saveSeedsArtifactWithAppendixForNonGitRoot() throws { + @Test func saveWritesAppendixForNonGitRoot() throws { let root = try makeTempRoot() defer { remove(root) } let store = HandoffStore(rootURL: root) let result = try store.save(outgoingAgent: "codex", note: "wip", now: fixedDate) - #expect(FileManager.default.fileExists(atPath: store.currentURL.path(percentEncoded: false))) - let current = try String(contentsOf: store.currentURL, encoding: .utf8) - #expect(current.contains("## Objective")) let context = try String(contentsOf: store.contextURL, encoding: .utf8) #expect(context.contains("Outgoing agent (detected): codex")) #expect(context.contains("(not a git repo)")) @@ -325,6 +315,17 @@ struct HandoffStoreTests { #expect(result.totalChangedFiles == 0) } + @Test func saveRecordsBriefingOnLogLine() throws { + let root = try makeTempRoot() + defer { remove(root) } + let store = HandoffStore(rootURL: root) + + _ = try store.save(outgoingAgent: "codex", note: nil, briefing: .inline, now: fixedDate) + + let log = try String(contentsOf: store.logURL, encoding: .utf8) + #expect(log.contains("briefing=inline")) + } + @Test func saveWritesSessionContextExcerpt() throws { let root = try makeTempRoot() defer { remove(root) } @@ -439,44 +440,20 @@ struct HandoffStoreTests { #expect(!content.contains("Outgoing agent (detected): codex")) } - @Test func savePreservesEditedProse() throws { + @Test func saveNeverTouchesCurrentArtifact() throws { let root = try makeTempRoot() defer { remove(root) } let store = HandoffStore(rootURL: root) - - _ = try store.save(outgoingAgent: "codex", note: nil, now: fixedDate) - // Simulate the agent editing the semantic section. - var content = try String(contentsOf: store.currentURL, encoding: .utf8) - content = content.replacingOccurrences( - of: "## Objective\n", - with: "## Objective\nFinish the checkout flow." - ) - try content.write(to: store.currentURL, atomically: true, encoding: .utf8) + try store.writeBriefing(validBriefing + "\n", archivingPrevious: false, now: fixedDate) _ = try store.save(outgoingAgent: "claude", note: nil, now: fixedDate) let updated = try String(contentsOf: store.currentURL, encoding: .utf8) - #expect(updated == content) + #expect(updated == validBriefing + "\n") let context = try String(contentsOf: store.contextURL, encoding: .utf8) #expect(context.contains("Outgoing agent (detected): claude")) } - @Test func saveNeverRewritesCurrentArtifactAfterScaffold() throws { - let root = try makeTempRoot() - defer { remove(root) } - let store = HandoffStore(rootURL: root) - try store.ensureScaffold() - let prose = "# Handoff\n\n## Objective\nPreserve this concurrent objective.\n" - try prose.write(to: store.currentURL, atomically: true, encoding: .utf8) - - _ = try store.save(outgoingAgent: "codex", note: nil, now: fixedDate) - - let updated = try String(contentsOf: store.currentURL, encoding: .utf8) - #expect(updated == prose) - let context = try String(contentsOf: store.contextURL, encoding: .utf8) - #expect(context.contains("Outgoing agent (detected): codex")) - } - // MARK: - log + archive @Test func appendLogGrowsAndArchiveCopies() throws { @@ -484,6 +461,7 @@ struct HandoffStoreTests { defer { remove(root) } let store = HandoffStore(rootURL: root) + try store.writeBriefing(validBriefing + "\n", archivingPrevious: false, now: fixedDate) _ = try store.save(outgoingAgent: "codex", note: nil, now: fixedDate) try store.appendLog("codex → claude", now: fixedDate) @@ -501,8 +479,18 @@ struct HandoffStoreTests { let archive = try String(contentsOf: archiveURL, encoding: .utf8) #expect(archive.contains("## Objective")) #expect(archive.contains("# Handoff Context (generated)")) - // current.md remains for the receiving agent. - #expect(FileManager.default.fileExists(atPath: store.currentURL.path(percentEncoded: false))) + // current.md remains until the transition decides its fate. + #expect(store.hasCurrentArtifact) + } + + @Test func archiveCurrentReturnsNilWithoutArtifact() throws { + let root = try makeTempRoot() + defer { remove(root) } + let store = HandoffStore(rootURL: root) + + _ = try store.save(outgoingAgent: "codex", note: nil, now: fixedDate) + + #expect(try store.archiveCurrent(from: "codex", toAgent: "claude", now: fixedDate) == nil) } @Test func appendLogPreservesConcurrentEntries() async throws { @@ -533,6 +521,7 @@ struct HandoffStoreTests { defer { remove(root) } let store = HandoffStore(rootURL: root) + try store.writeBriefing(validBriefing + "\n", archivingPrevious: false, now: fixedDate) _ = try store.save(outgoingAgent: "codex", note: nil, now: fixedDate) let first = try #require(try store.archiveCurrent(from: "codex", toAgent: "claude", now: fixedDate)) let second = try #require(try store.archiveCurrent(from: "codex", toAgent: "claude", now: fixedDate)) @@ -550,18 +539,4 @@ struct HandoffStoreTests { #expect(FileManager.default.fileExists(atPath: firstURL.path(percentEncoded: false))) #expect(FileManager.default.fileExists(atPath: secondURL.path(percentEncoded: false))) } - - @Test func readStatusReportsExistenceAndLastLog() throws { - let root = try makeTempRoot() - defer { remove(root) } - let store = HandoffStore(rootURL: root) - - let before = store.readStatus() - #expect(before.exists == false) - - _ = try store.save(outgoingAgent: "codex", note: nil, now: fixedDate) - let after = store.readStatus() - #expect(after.exists == true) - #expect(after.lastLogLine?.contains("save") == true) - } } diff --git a/supacodeTests/SupacodeAppCLITests.swift b/supacodeTests/SupacodeAppCLITests.swift index ee0afb7f..c4c8826e 100644 --- a/supacodeTests/SupacodeAppCLITests.swift +++ b/supacodeTests/SupacodeAppCLITests.swift @@ -55,9 +55,15 @@ struct SupacodeAppCLITests { ) } - @Test func handoffStatusUsesNonMainWorktreePath() async throws { - let repositoryRoot = URL(fileURLWithPath: "/tmp/Prowl", isDirectory: true) - let worktreeRoot = URL(fileURLWithPath: "/tmp/Prowl-feature", isDirectory: true) + @Test func handoffSaveUsesNonMainWorktreePath() async throws { + let base = FileManager.default.temporaryDirectory + .appending(path: "app-cli-handoff-tests", directoryHint: .isDirectory) + .appending(path: UUID().uuidString, directoryHint: .isDirectory) + let repositoryRoot = base.appending(path: "Prowl", directoryHint: .isDirectory) + let worktreeRoot = base.appending(path: "Prowl-feature", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: repositoryRoot, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: worktreeRoot, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: base) } let worktree = Worktree( id: worktreeRoot.path(percentEncoded: false), name: "feature", @@ -90,12 +96,19 @@ struct SupacodeAppCLITests { let response = await router.route( CommandEnvelope( output: .json, - command: .handoff(HandoffInput(action: .status, selector: .worktree(worktree.id))) + command: .handoff( + HandoffInput(action: .save, selector: .worktree(worktree.id), contextOnly: true) + ) ) ) #expect(response.ok) let payload = try #require(try response.data?.decode(as: HandoffCommandPayload.self)) - #expect(payload.artifactPath == worktreeRoot.appending(path: ".prowl/handoff/current.md").path) + #expect( + payload.artifactPath + == worktreeRoot.standardizedFileURL.appending(path: ".prowl/handoff/current.md") + .path(percentEncoded: false) + ) + #expect(payload.briefing == "none") } }