import Foundation
import Testing

@testable import NucleicCore

private let fixedNow = Date(timeIntervalSince1970: 1_700_000_000)

private func makeController(
    backend: any AgentBackend,
    worktreeManager: (any WorktreeManaging)? = nil,
    project: Project? = nil,
    worktree: Worktree? = nil,
    conversational: Bool = false
) throws -> (controller: SessionController, transcriptURL: URL, cleanup: @Sendable () -> Void) {
    let dir = (NSTemporaryDirectory() as NSString)
        .appendingPathComponent("nucleic-sc-\(UUID().uuidString)")
    let url = URL(fileURLWithPath: (dir as NSString).appendingPathComponent("transcript.jsonl"))
    let sessionID = SessionID.generate()
    let header = SessionHeader(
        sessionID: sessionID, backend: .claudeCode,
        worktree: worktree?.path ?? "/tmp", createdAt: fixedNow)
    let writer = try TranscriptWriter(url: url, header: header)
    let session = Session(
        id: sessionID, projectID: project?.id ?? .generate(), backend: .claudeCode,
        title: "test session", status: .idle,
        worktreePath: worktree?.path, branch: worktree?.branch, baseSHA: worktree?.baseSHA,
        transcriptPath: url.path, createdAt: fixedNow, updatedAt: fixedNow)
    let controller = SessionController(
        session: session, backend: backend, transcript: writer,
        worktreeManager: worktreeManager, project: project, worktree: worktree,
        conversational: conversational, now: { fixedNow })
    return (controller, url, { try? FileManager.default.removeItem(atPath: dir) })
}

/// A Project whose rootPath sits under the Nucleic Control base, so `isNucleicControlled`
/// is true. `isControlled` is pure path math (canonical-prefix), so no filesystem is needed.
private func controlledProject() -> Project {
    Project(
        name: "ctl",
        rootPath: (ProjectCloner.controlBase as NSString).appendingPathComponent("repo"),
        defaultBranch: "main")
}

/// A Project outside the control base — `isNucleicControlled` is false.
private func uncontrolledProject() -> Project {
    Project(name: "plain", rootPath: "/tmp/nucleic-test-plain-repo", defaultBranch: "main")
}

private func waitForStatus(
    _ controller: SessionController, _ status: SessionStatus, timeoutMs: Int = 3000
) async {
    var elapsed = 0
    while await controller.snapshot.session.status != status && elapsed < timeoutMs {
        try? await Task.sleep(for: .milliseconds(10))
        elapsed += 10
    }
}

private func simpleTurn(_ e: ScriptedBackend.Emitter) {
    e.emit(.sessionStarted(SessionStarted(
        backendSessionID: "be-123", model: "claude-opus-4-8", cwd: "/tmp", toolNames: ["Read"])))
    e.emit(.assistantText(TextChunk(messageID: "m1", text: "Hello.", isPartial: false)))
    e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
    e.emit(.runFinished(RunFinished(outcome: .completed)))
}

@Suite("SessionController — single-writer pipeline")
struct SessionControllerTests {

    @Test func assignsCanonicalSeqAndReachesFinished() async throws {
        let backend = ScriptedBackend { e, _ in simpleTurn(e) }
        let (controller, _, cleanup) = try makeController(backend: backend)
        defer { cleanup() }

        let stream = await controller.subscribe()
        let collected = Task { () -> [AgentEvent] in
            var events: [AgentEvent] = []
            for await event in stream { events.append(event) }
            return events
        }
        await controller.start(prompt: AgentInput(text: "hi"))
        let events = await collected.value

        // seq 1 is the injected user turn; then the 4 scripted events.
        #expect(events.map(\.seq) == [1, 2, 3, 4, 5])
        #expect(events.first.map { if case .userText(let c) = $0.kind { c.text == "hi" } else { false } } == true)
        let snap = await controller.snapshot
        #expect(snap.session.status == .finished)
        #expect(snap.session.lastSeq == 5)
        #expect(snap.session.backendSessionID == "be-123")
        #expect(snap.session.model == "claude-opus-4-8")
        #expect(snap.pendingApprovals.isEmpty)
    }

    @Test func orchestraConsentIsGatedOnNucleicControl() async throws {
        // Run one Orchestra turn against `project` and report the effort, appended prompt, and
        // whether the controller flagged the run Orchestra-active (which pre-allows the
        // subagent-spawn tools) to the backend.
        func orchestraRun(on project: Project) async throws
            -> (effort: String?, prompt: String?, orchestraActive: Bool)
        {
            let backend = ScriptedBackend { e, _ in simpleTurn(e) }
            let (controller, _, cleanup) = try makeController(backend: backend, project: project)
            defer { cleanup() }
            await controller.setEffort(OrchestrationMode.effortSentinel)
            await controller.start(prompt: AgentInput(text: "hi"))
            await controller.join()
            return (backend.lastRun?.effort, backend.lastRun?.appendSystemPrompt,
                    backend.lastRun?.orchestraActive ?? false)
        }

        // Control project → Orchestra honored: the sentinel resolves to xhigh, the standing-consent
        // text rides along on the appended system prompt, and the run is flagged Orchestra-active so
        // the backend pre-allows the subagent-spawn tools (no approval prompt on every spawn).
        let onControl = try await orchestraRun(on: controlledProject())
        #expect(onControl.effort == "xhigh")
        #expect(onControl.prompt?.lowercased().contains("standing consent") == true)
        #expect(onControl.orchestraActive)

        // Non-control project → consent withheld. The effort still resolves to xhigh (a valid
        // level), but neither the fan-out grant nor the Orchestra-active pre-allow flag may reach a
        // project that isn't Nucleic Control.
        let offControl = try await orchestraRun(on: uncontrolledProject())
        #expect(offControl.effort == "xhigh")
        #expect(offControl.prompt?.lowercased().contains("standing consent") != true)
        #expect(!offControl.orchestraActive)
    }

    @Test func nvrsionGuidanceRidesOnlyOnTrunkSessions() async throws {
        // Report the system prompt the controller appends for a run on `project` with `worktree`.
        func appendedPrompt(project: Project, worktree: Worktree?) async throws -> String? {
            let backend = ScriptedBackend { e, _ in simpleTurn(e) }
            let (controller, _, cleanup) = try makeController(
                backend: backend, project: project, worktree: worktree)
            defer { cleanup() }
            await controller.start(prompt: AgentInput(text: "hi"))
            await controller.join()
            return backend.lastRun?.appendSystemPrompt
        }

        let project = controlledProject()

        // An nvrsion chat's working dir IS the shared trunk (path == resolvedTrunkPath), so the
        // version-control guidance rides along — and names the project's own base branch.
        let trunk = Worktree(
            sessionID: .generate(), path: project.resolvedTrunkPath,
            branch: "nucleic/trunk", baseSHA: "", createdAt: fixedNow)
        let onTrunk = try await appendedPrompt(project: project, worktree: trunk)
        #expect(onTrunk?.contains("uses nvrsion") == true)
        #expect(onTrunk?.contains("Integrate trunk → main") == true)

        // A worktree chat in the same project edits a private worktree, not the trunk → the ordinary
        // git model holds, so the nvrsion guidance must NOT be injected (it'd be actively wrong).
        let wt = Worktree(
            sessionID: .generate(), path: "/tmp/nucleic-test-private-worktree",
            branch: "feature", baseSHA: "", createdAt: fixedNow)
        let offTrunk = try await appendedPrompt(project: project, worktree: wt)
        #expect(offTrunk?.contains("uses nvrsion") != true)
    }

    @Test func orchestraIsAOneWayLatchOnceActive() async throws {
        // On a Control project, Orchestra is active once selected — and can't be turned off.
        let backend = ScriptedBackend { e, _ in simpleTurn(e) }
        let (controller, _, cleanup) = try makeController(backend: backend, project: controlledProject())
        defer { cleanup() }

        await controller.setEffort(OrchestrationMode.effortSentinel)
        #expect(await controller.snapshot.session.effort == OrchestrationMode.effortSentinel)

        // Every attempt to leave Orchestra is ignored — a named level, and clearing to default.
        for attempt in ["high", "max", "low"] {
            await controller.setEffort(attempt)
            #expect(await controller.snapshot.session.effort == OrchestrationMode.effortSentinel)
        }
        await controller.setEffort(nil)
        #expect(await controller.snapshot.session.effort == OrchestrationMode.effortSentinel)

        // Re-selecting Orchestra is fine (idempotent); the legacy token normalizes to the current one.
        await controller.setEffort(OrchestrationMode.legacyEffortSentinel)
        #expect(OrchestrationMode.isOrchestra(await controller.snapshot.session.effort))
    }

    @Test func ordinaryEffortStaysFreelyChangeable() async throws {
        // The latch is Orchestra-only: ordinary levels switch in both directions as before, and a
        // stray Orchestra selection on a non-Control project (where it never took effect) isn't
        // latched either.
        let backend = ScriptedBackend { e, _ in simpleTurn(e) }

        let (onCtl, _, cleanCtl) = try makeController(backend: backend, project: controlledProject())
        defer { cleanCtl() }
        await onCtl.setEffort("high")
        await onCtl.setEffort("low")
        #expect(await onCtl.snapshot.session.effort == "low")

        let (offCtl, _, cleanOff) = try makeController(backend: backend, project: uncontrolledProject())
        defer { cleanOff() }
        await offCtl.setEffort(OrchestrationMode.effortSentinel)
        await offCtl.setEffort("high")  // not active here, so not latched
        #expect(await offCtl.snapshot.session.effort == "high")
    }

    @Test func persistsCanonicalTranscriptJSONL() async throws {
        let backend = ScriptedBackend { e, _ in simpleTurn(e) }
        let (controller, url, cleanup) = try makeController(backend: backend)
        defer { cleanup() }

        await controller.start(prompt: AgentInput(text: "hi"))
        await controller.join()
        await controller.shutdown()

        let (header, events) = try TranscriptReader(url: url).read()
        #expect(header.backend == .claudeCode)
        #expect(events.map(\.seq) == [1, 2, 3, 4, 5])  // injected userText + 4 scripted
        #expect(events.last.map { if case .runFinished = $0.kind { true } else { false } } == true)
    }

    @Test func approvalRoundTripDrivesStatus() async throws {
        let backend = ScriptedBackend { e, approvals in
            e.emit(.sessionStarted(SessionStarted(
                backendSessionID: "be-1", model: "m", cwd: "/tmp", toolNames: [])))
            let req = ApprovalRequest(
                id: .generate(), sessionID: e.sessionID, toolCallID: "t1", toolName: "Write",
                input: .object(["path": .string("a.txt")]), title: "Write a.txt", risk: .write,
                createdAt: fixedNow)
            e.emit(.approvalRequested(req))
            let resolved = await approvals.waitForDecision(req)
            e.emit(.approvalResolved(resolved))
            if case .allow = resolved.decision {
                e.emit(.toolResult(ToolResult(toolCallID: "t1", content: .string("ok"), isError: false)))
            }
            e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
            e.emit(.runFinished(RunFinished(outcome: .completed)))
        }
        let (controller, _, cleanup) = try makeController(backend: backend)
        defer { cleanup() }

        let statusAtApproval = LockedBox<SessionStatus?>(nil)
        let stream = await controller.subscribe()
        let collected = Task { () -> [AgentEvent] in
            var events: [AgentEvent] = []
            for await event in stream {
                events.append(event)
                if case .approvalRequested(let req) = event.kind {
                    statusAtApproval.set(await controller.snapshot.session.status)
                    try? await controller.respondToApproval(req.id, .allow())
                }
            }
            return events
        }
        await controller.start(prompt: AgentInput(text: "edit"))
        let events = await collected.value

        #expect(statusAtApproval.get() == .awaitingApproval)
        #expect(events.contains { if case .approvalResolved = $0.kind { true } else { false } })
        #expect(events.contains { if case .toolResult = $0.kind { true } else { false } })
        let snap = await controller.snapshot
        #expect(snap.session.status == .finished)
        #expect(snap.pendingApprovals.isEmpty)
    }

    @Test func orphanedApprovalResolvedWhenRunEndsWithoutAnswer() async throws {
        // Models the Mac sleeping mid-approval (or any transport drop): the agent's
        // `approve` call is abandoned and the run ends WITHOUT ever emitting
        // `approvalResolved`, so the request card is orphaned — its Allow/Deny buttons
        // would resolve into a coordinator with no waiter and change nothing on screen.
        // `finishRun` must auto-resolve it so the card dismisses. Regression: the card
        // previously lingered forever with dead buttons.
        let reqID = ApprovalID.generate()
        let backend = ScriptedBackend { e, _ in
            e.emit(.sessionStarted(SessionStarted(
                backendSessionID: "be-orphan", model: "m", cwd: "/tmp", toolNames: [])))
            e.emit(.approvalRequested(ApprovalRequest(
                id: reqID, sessionID: e.sessionID, toolCallID: "t1", toolName: "Write",
                input: .object(["path": .string("a.txt")]), title: "Write a.txt", risk: .write,
                createdAt: fixedNow)))
            // The run ends here — no `waitForDecision`, no `approvalResolved`. The waiter is gone.
            e.emit(.runFinished(RunFinished(outcome: .completed)))
        }
        let (controller, _, cleanup) = try makeController(backend: backend)
        defer { cleanup() }

        let stream = await controller.subscribe()
        let collected = Task { () -> [AgentEvent] in
            var events: [AgentEvent] = []
            for await event in stream { events.append(event) }
            return events
        }
        await controller.start(prompt: AgentInput(text: "edit"))
        let events = await collected.value

        // The orphaned card is gone…
        #expect(await controller.snapshot.pendingApprovals.isEmpty)
        // …because the run-end resolved it exactly once: a deny attributed to "system".
        let resolved = events.compactMap { event -> ApprovalResolved? in
            if case .approvalResolved(let r) = event.kind { return r } else { return nil }
        }
        #expect(resolved.count == 1)
        #expect(resolved.first?.id == reqID)
        #expect(resolved.first?.decidedBy == "system")
        if case .deny = resolved.first?.decision {} else {
            Issue.record("expected a deny decision, got \(String(describing: resolved.first?.decision))")
        }
    }

    @Test func multiTurnChatInjectsUserTextAndStaysAlive() async throws {
        // Turn 1, then await a follow-up turn, then end — modeling streaming Claude
        // where a success `result` is a turn boundary (awaitingInput), not run end.
        let backend = ScriptedBackend(multiTurn: { e, _, inputs in
            e.emit(.sessionStarted(SessionStarted(
                backendSessionID: "be", model: "m", cwd: "/tmp", toolNames: [])))
            e.emit(.assistantText(TextChunk(messageID: "a1", text: "First answer.", isPartial: false)))
            e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
            var iterator = inputs.makeAsyncIterator()
            if await iterator.next() != nil {
                e.emit(.assistantText(TextChunk(messageID: "a2", text: "Second answer.", isPartial: false)))
                e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
            }
        })
        let (controller, _, cleanup) = try makeController(backend: backend)
        defer { cleanup() }

        let stream = await controller.subscribe()
        let collected = Task { () -> [AgentEvent] in
            var events: [AgentEvent] = []
            for await event in stream { events.append(event) }
            return events
        }
        await controller.start(prompt: AgentInput(text: "First question"))

        // After turn 1 the session is awaiting input, not finished.
        await waitForStatus(controller, .awaitingInput)
        #expect(await controller.snapshot.session.status == .awaitingInput)

        try await controller.sendInput(AgentInput(text: "Second question"))
        await waitForStatus(controller, .awaitingInput)

        await controller.shutdown()
        let events = await collected.value

        let userTexts = events.compactMap { event -> String? in
            if case .userText(let chunk) = event.kind { return chunk.text }
            return nil
        }
        #expect(userTexts == ["First question", "Second question"])
        let assistantTexts = events.compactMap { event -> String? in
            if case .assistantText(let chunk) = event.kind { return chunk.text }
            return nil
        }
        #expect(assistantTexts == ["First answer.", "Second answer."])
    }

    @Test func autoshipNoteIsMarkedLatestUntilSomethingElseHappens() async throws {
        // Drives the sidebar's autoship icon: an autoship merge-queue note becomes the
        // most recent event (flag set), and a later user message clears it.
        let backend = ScriptedBackend(multiTurn: { e, _, inputs in
            e.emit(.sessionStarted(SessionStarted(
                backendSessionID: "be", model: "m", cwd: "/tmp", toolNames: [])))
            e.emit(.assistantText(TextChunk(messageID: "a1", text: "Shipped.", isPartial: false)))
            e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
            var iterator = inputs.makeAsyncIterator()
            if await iterator.next() != nil {
                e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
            }
        })
        let (controller, _, cleanup) = try makeController(backend: backend)
        defer { cleanup() }

        await controller.start(prompt: AgentInput(text: "do the work"))
        await waitForStatus(controller, .awaitingInput)
        #expect(await controller.snapshot.session.lastEventWasAutoship == false)

        // The merge queue logs a *successful* autoship note — now the newest event.
        await controller.note(
            "\(NoteEvent.autoshipPrefix) merged into dev · abc1234",
            icon: "checkmark.seal", autoshipShipped: true)
        #expect(await controller.snapshot.session.lastEventWasAutoship == true)

        // A later user message means autoship is no longer the most recent thing.
        try await controller.sendInput(AgentInput(text: "one more thing"))
        var elapsed = 0
        while await controller.snapshot.session.lastEventWasAutoship && elapsed < 3000 {
            try? await Task.sleep(for: .milliseconds(10))
            elapsed += 10
        }
        #expect(await controller.snapshot.session.lastEventWasAutoship == false)

        await controller.shutdown()
    }

    @Test func onlySuccessfulAutoshipLightsShippedMarker() async throws {
        // The sidebar's shipped-package marker must reflect the *most recent* autoship's
        // outcome. A failed/conflict autoship note shares the `Autoship:` prefix but is
        // not a ship — even when it follows an earlier success, it must leave the marker
        // dark (regression: any autoship note used to light it).
        let backend = ScriptedBackend { e, _ in simpleTurn(e) }
        let (controller, _, cleanup) = try makeController(backend: backend)
        defer { cleanup() }

        await controller.start(prompt: AgentInput(text: "do the work"))
        await waitForStatus(controller, .awaitingInput)

        // An earlier autoship succeeded — marker lit.
        await controller.note(
            "\(NoteEvent.autoshipPrefix) merged into dev · abc1234",
            icon: "checkmark.seal", autoshipShipped: true)
        #expect(await controller.snapshot.session.lastEventWasAutoship == true)

        // The most recent autoship failed — newest event, but not a success, so the
        // marker goes dark rather than carrying over the earlier ship.
        await controller.note(
            "\(NoteEvent.autoshipPrefix) merge conflict in a.swift — branch left intact. "
                + "Autoship stays armed; resolve the conflict and it ships on the next merge.",
            icon: "exclamationmark.triangle")
        #expect(await controller.snapshot.session.lastEventWasAutoship == false)

        await controller.shutdown()
    }

    @Test func autoshipFailureSetsStickyFlagClearedOnReenable() async throws {
        // Drives the sidebar's "autoship needs attention" icon: a merge-queue abort
        // turns autoship off and sets a sticky failure marker, and re-enabling autoship
        // clears it. A clean user toggle-off must NOT set the marker.
        let backend = ScriptedBackend { e, _ in simpleTurn(e) }
        // Autoship is Control-gated, so this session's project must be Nucleic-controlled.
        let (controller, _, cleanup) = try makeController(backend: backend, project: controlledProject())
        defer { cleanup() }
        await controller.setAutoShip(true)
        #expect(await controller.snapshot.session.autoShip == true)
        #expect(await controller.snapshot.session.autoShipFailed == false)

        // A clean toggle-off is not a failure.
        await controller.setAutoShip(false)
        #expect(await controller.snapshot.session.autoShipFailed == false)

        // A merge-queue abort: autoship off + sticky failure marker.
        await controller.markAutoShipFailed()
        #expect(await controller.snapshot.session.autoShip == false)
        #expect(await controller.snapshot.session.autoShipFailed == true)

        // Re-enabling autoship clears the marker (the user opted back in).
        await controller.setAutoShip(true)
        #expect(await controller.snapshot.session.autoShip == true)
        #expect(await controller.snapshot.session.autoShipFailed == false)
    }

    @Test func autoshipConflictKeepsAutoshipArmedAndClearsOnLanding() async throws {
        // A merge *conflict* must NOT disarm autoship (unlike a hard failure): it sets a sticky
        // "needs attention" marker while leaving autoship on, so the work re-ships on the next
        // clean merge. The marker clears when the work lands, and re-arming clears it too.
        let backend = ScriptedBackend { e, _ in simpleTurn(e) }
        let (controller, _, cleanup) = try makeController(backend: backend, project: controlledProject())
        defer { cleanup() }
        await controller.setAutoShip(true)
        #expect(await controller.snapshot.session.autoShip == true)
        #expect(await controller.snapshot.session.autoShipConflict == false)

        // A merge-queue conflict: marker set, autoship STAYS armed (and the failure flag is
        // untouched — a conflict is not a hard failure).
        await controller.markAutoShipConflict()
        #expect(await controller.snapshot.session.autoShip == true)
        #expect(await controller.snapshot.session.autoShipConflict == true)
        #expect(await controller.snapshot.session.autoShipFailed == false)

        // The work lands (queue's next merge, or the agent's own): the marker clears, still armed.
        await controller.clearAutoShipConflict()
        #expect(await controller.snapshot.session.autoShipConflict == false)
        #expect(await controller.snapshot.session.autoShip == true)

        // Re-arming also clears a pending conflict (an explicit "I've handled it").
        await controller.markAutoShipConflict()
        #expect(await controller.snapshot.session.autoShipConflict == true)
        await controller.setAutoShip(true)
        #expect(await controller.snapshot.session.autoShipConflict == false)
    }

    @Test func autoshipEnableRequiresNucleicControl() async throws {
        // Autoship is a Nucleic Control capability: enabling it on a non-controlled project
        // is a no-op at the single runtime chokepoint (so the UI gate can't be bypassed).
        let backend = ScriptedBackend { e, _ in simpleTurn(e) }
        let (controller, _, cleanup) = try makeController(backend: backend, project: uncontrolledProject())
        defer { cleanup() }
        await controller.setAutoShip(true)
        #expect(await controller.snapshot.session.autoShip == false)
        // And it did not flip `auto` on as a side effect of a blocked enable.
        #expect(await controller.snapshot.session.auto == false)
        // Disabling is always allowed (clears a flag persisted true under an older build).
        await controller.setAutoShip(false)
        #expect(await controller.snapshot.session.autoShip == false)
    }

    @Test func autoshipEnableAllowedUnderNucleicControl() async throws {
        // A Nucleic-controlled project may enable autoship, which implies Auto.
        let backend = ScriptedBackend { e, _ in simpleTurn(e) }
        let (controller, _, cleanup) = try makeController(backend: backend, project: controlledProject())
        defer { cleanup() }
        await controller.setAutoShip(true)
        #expect(await controller.snapshot.session.autoShip == true)
        #expect(await controller.snapshot.session.auto == true)
    }

    @Test func openingChatClearsAutoshipFailureMarkerButLeavesAutoshipOff() async throws {
        // Opening the chat means the user saw the failure note, so the sticky marker (and
        // its sidebar icon) clears — but autoship stays off, since clearing the warning is
        // not the same as opting back in. `clearAutoShipFailed` is a no-op when unset.
        let backend = ScriptedBackend { e, _ in simpleTurn(e) }
        let (controller, _, cleanup) = try makeController(backend: backend)
        defer { cleanup() }

        // No-op when there's no failure to clear.
        await controller.clearAutoShipFailed()
        #expect(await controller.snapshot.session.autoShipFailed == false)

        await controller.markAutoShipFailed()
        #expect(await controller.snapshot.session.autoShip == false)
        #expect(await controller.snapshot.session.autoShipFailed == true)

        // Opening the chat retires the marker without re-enabling autoship.
        await controller.clearAutoShipFailed()
        #expect(await controller.snapshot.session.autoShipFailed == false)
        #expect(await controller.snapshot.session.autoShip == false)
    }

    @Test func conversationalModeResumesPerTurnAndStaysAlive() async throws {
        // Each turn is its own single-shot run that emits a terminal runFinished;
        // in conversational mode that means "awaiting input", and the next message
        // resumes — modeling per-turn `claude --resume`.
        let turnCount = LockedBox(0)
        let backend = ScriptedBackend { e, _ in
            let n = turnCount.get() + 1
            turnCount.set(n)
            e.emit(.sessionStarted(SessionStarted(
                backendSessionID: "be-conv", model: "m", cwd: "/tmp", toolNames: [])))
            e.emit(.assistantText(TextChunk(messageID: "a\(n)", text: "answer \(n)", isPartial: false)))
            e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
            e.emit(.runFinished(RunFinished(outcome: .completed)))
        }
        let (controller, _, cleanup) = try makeController(backend: backend, conversational: true)
        defer { cleanup() }

        let stream = await controller.subscribe()
        let collected = Task { () -> [AgentEvent] in
            var events: [AgentEvent] = []
            for await event in stream { events.append(event) }
            return events
        }

        await controller.start(prompt: AgentInput(text: "q1"))
        await waitForStatus(controller, .awaitingInput)
        // Terminal runFinished does NOT end a conversational session.
        #expect(await controller.snapshot.session.status == .awaitingInput)

        try await controller.sendInput(AgentInput(text: "q2"))
        await waitForStatus(controller, .awaitingInput)

        await controller.shutdown()
        let events = await collected.value

        let userTexts = events.compactMap { e -> String? in
            if case .userText(let c) = e.kind { return c.text } else { return nil }
        }
        let answers = events.compactMap { e -> String? in
            if case .assistantText(let c) = e.kind { return c.text } else { return nil }
        }
        #expect(userTexts == ["q1", "q2"])
        #expect(answers == ["answer 1", "answer 2"])
        #expect(turnCount.get() == 2)  // two separate runs (start + resume)
    }

    @Test func revertToUserMessageTruncatesTranscriptAndReturnsText() async throws {
        let turnCount = LockedBox(0)
        let backend = ScriptedBackend { e, _ in
            let n = turnCount.get() + 1
            turnCount.set(n)
            e.emit(.sessionStarted(SessionStarted(
                backendSessionID: "be-conv", model: "m", cwd: "/tmp", toolNames: [])))
            e.emit(.assistantText(TextChunk(messageID: "a\(n)", text: "answer \(n)", isPartial: false)))
            e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
            e.emit(.runFinished(RunFinished(outcome: .completed)))
        }
        let (controller, url, cleanup) = try makeController(backend: backend, conversational: true)
        defer { cleanup() }

        await controller.start(prompt: AgentInput(text: "q1"))
        await waitForStatus(controller, .awaitingInput)
        try await controller.sendInput(AgentInput(text: "q2"))
        await waitForStatus(controller, .awaitingInput)

        // The seq of the second user message ("q2").
        let q2Seq = try #require(await controller.transcriptSoFar().first { e in
            if case .userText(let c) = e.kind { return c.text == "q2" } else { return false }
        }).seq

        let reverted = await controller.revert(toSeq: q2Seq)
        #expect(reverted == "q2")

        // History now ends just before "q2" — the whole second turn is gone, the first kept.
        let history = await controller.transcriptSoFar()
        #expect(history.allSatisfy { $0.seq < q2Seq })
        let userTexts = history.compactMap { e -> String? in
            if case .userText(let c) = e.kind { return c.text } else { return nil }
        }
        #expect(userTexts == ["q1"])

        let snap = await controller.snapshot
        #expect(snap.session.status == .awaitingInput)
        #expect(snap.session.lastSeq == history.last?.seq)
        // A turn ran before the reverted one, so the native session is kept for resume.
        #expect(snap.session.backendSessionID == "be-conv")

        // The on-disk transcript was rewritten to match (header + retained events only).
        let (_, persisted) = try TranscriptReader(url: url).read()
        #expect(persisted.map(\.seq) == history.map(\.seq))

        // The next message reuses the freed turn id and runs as a normal resumed turn.
        try await controller.sendInput(AgentInput(text: "q2 edited"))
        await waitForStatus(controller, .awaitingInput)
        let after = await controller.transcriptSoFar().compactMap { e -> String? in
            if case .userText(let c) = e.kind { return c.text } else { return nil }
        }
        #expect(after == ["q1", "q2 edited"])
    }

    @Test func revertToFirstUserMessageClearsNativeSession() async throws {
        let backend = ScriptedBackend { e, _ in
            e.emit(.sessionStarted(SessionStarted(
                backendSessionID: "be-conv", model: "m", cwd: "/tmp", toolNames: [])))
            e.emit(.assistantText(TextChunk(messageID: "a1", text: "answer", isPartial: false)))
            e.emit(.runFinished(RunFinished(outcome: .completed)))
        }
        let (controller, _, cleanup) = try makeController(backend: backend, conversational: true)
        defer { cleanup() }

        await controller.start(prompt: AgentInput(text: "only"))
        await waitForStatus(controller, .awaitingInput)
        #expect(await controller.snapshot.session.backendSessionID == "be-conv")

        let firstSeq = try #require(await controller.transcriptSoFar().first { e in
            if case .userText = e.kind { return true } else { return false }
        }).seq

        let reverted = await controller.revert(toSeq: firstSeq)
        #expect(reverted == "only")
        #expect(await controller.transcriptSoFar().isEmpty)
        // Nothing left to resume from → the next turn starts a fresh native session.
        #expect(await controller.snapshot.session.backendSessionID == nil)
    }

    @Test func claudeProjectDirNameMatchesCLIEscaping() {
        #expect(SessionController.claudeProjectDirName("/Users/me/proj") == "-Users-me-proj")
        // Every non-alphanumeric (dots, tildes, spaces) collapses to a single `-` each.
        #expect(
            SessionController.claudeProjectDirName("/a/com~apple~CloudDocs/x.y")
                == "-a-com-apple-CloudDocs-x-y")
    }

    @Test func isRealUserPromptDistinguishesPromptsFromToolResults() {
        func line(_ s: String) -> Data { Data(s.utf8) }
        // A genuine text prompt.
        #expect(SessionController.isRealUserPrompt(
            line(#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"hi"}]}}"#)))
        // Plain-string content is also a prompt.
        #expect(SessionController.isRealUserPrompt(
            line(#"{"type":"user","message":{"role":"user","content":"hi"}}"#)))
        // A tool-result echo is stored as a user record but is NOT a prompt.
        #expect(!SessionController.isRealUserPrompt(
            line(#"{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"ok"}]}}"#)))
        // Injected meta messages don't count.
        #expect(!SessionController.isRealUserPrompt(
            line(#"{"type":"user","isMeta":true,"message":{"role":"user","content":[{"type":"text","text":"x"}]}}"#)))
        // Assistant records aren't prompts.
        #expect(!SessionController.isRealUserPrompt(
            line(#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"hi"}]}}"#)))
    }

    @Test func truncateNativeTranscriptCutsAtTheNextPromptBoundary() throws {
        let dir = (NSTemporaryDirectory() as NSString)
            .appendingPathComponent("nucleic-native-\(UUID().uuidString)")
        try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
        defer { try? FileManager.default.removeItem(atPath: dir) }
        let path = (dir as NSString).appendingPathComponent("native.jsonl")

        // Two turns: prompt → assistant → tool_result → assistant, twice.
        let lines = [
            #"{"type":"summary","summary":"s"}"#,
            #"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"q1"}]}}"#,
            #"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"a1"}]}}"#,
            #"{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"r"}]}}"#,
            #"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"a1b"}]}}"#,
            #"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"q2"}]}}"#,
            #"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"a2"}]}}"#,
        ]
        try lines.joined(separator: "\n").write(toFile: path, atomically: true, encoding: .utf8)

        // Keep one real prompt → cut at the second one ("q2"), dropping it and after.
        #expect(SessionController.truncateNativeTranscript(at: path, keepingRealUserPrompts: 1))
        let kept = try String(contentsOfFile: path, encoding: .utf8)
            .split(separator: "\n").map(String.init)
        #expect(kept.count == 5)  // summary + q1 turn (4 lines), q2 onward gone
        #expect(kept.last?.contains("a1b") == true)
        #expect(!kept.contains { $0.contains("q2") })

        // Asking to keep more prompts than exist leaves the file untouched (degrade, not corrupt).
        let before = try String(contentsOfFile: path, encoding: .utf8)
        #expect(!SessionController.truncateNativeTranscript(at: path, keepingRealUserPrompts: 5))
        #expect(try String(contentsOfFile: path, encoding: .utf8) == before)
    }

    @Test func revertRejectsNonUserSeqAndRunningTurn() async throws {
        let backend = ScriptedBackend { e, _ in
            e.emit(.sessionStarted(SessionStarted(
                backendSessionID: "be-conv", model: "m", cwd: "/tmp", toolNames: [])))
            e.emit(.assistantText(TextChunk(messageID: "a1", text: "hi", isPartial: false)))
            e.emit(.runFinished(RunFinished(outcome: .completed)))
        }
        let (controller, _, cleanup) = try makeController(backend: backend, conversational: true)
        defer { cleanup() }

        await controller.start(prompt: AgentInput(text: "q1"))
        await waitForStatus(controller, .awaitingInput)

        // An assistant-text seq isn't a user message → no revert.
        let assistantSeq = try #require(await controller.transcriptSoFar().first { e in
            if case .assistantText = e.kind { return true } else { return false }
        }).seq
        #expect(await controller.revert(toSeq: assistantSeq) == nil)
    }

    @Test func conversationalFollowUpSentDuringRunCloseIsNotDropped() async throws {
        // Regression: the real backend emits a turn's terminal `runFinished` (→ the UI
        // flips to awaitingInput and re-enables the composer) BEFORE its event stream
        // actually closes and `runTask` clears. A follow-up sent in that window used to
        // hit `guard runTask == nil` and be silently dropped — the message landed in the
        // transcript with no turn ever started, leaving the chat stuck with no reply.
        let backend = GatedTurnBackend()
        let (controller, _, cleanup) = try makeController(backend: backend, conversational: true)
        defer { cleanup() }

        let stream = await controller.subscribe()
        let collected = Task { () -> [AgentEvent] in
            var events: [AgentEvent] = []
            for await event in stream { events.append(event) }
            return events
        }

        await controller.start(prompt: AgentInput(text: "q1"))
        // Turn 1's runFinished has been ingested → awaitingInput, but the stream is held
        // open, so `runTask` is still set: precisely the drop window.
        await waitForStatus(controller, .awaitingInput)

        try await controller.sendInput(AgentInput(text: "q2"))
        // The follow-up must be queued and keep the session live, not dropped.
        #expect(await controller.snapshot.session.status == .running)

        // Release turn 1; the queued q2 must now run as a real (resumed) turn.
        backend.release(turn: 1)
        await waitFor { await self.assistantTexts(controller).contains("answer 2") }
        backend.release(turn: 2)

        await controller.shutdown()
        let events = await collected.value
        let userTexts = events.compactMap { e -> String? in
            if case .userText(let c) = e.kind { return c.text } else { return nil }
        }
        let answers = events.compactMap { e -> String? in
            if case .assistantText(let c) = e.kind { return c.text } else { return nil }
        }
        #expect(userTexts == ["q1", "q2"])
        #expect(answers == ["answer 1", "answer 2"])
    }

    private func assistantTexts(_ controller: SessionController) async -> [String] {
        await controller.transcriptSoFar().compactMap { e -> String? in
            if case .assistantText(let c) = e.kind { return c.text } else { return nil }
        }
    }

    private func userTexts(_ controller: SessionController) async -> [String] {
        await controller.transcriptSoFar().compactMap { e -> String? in
            if case .userText(let c) = e.kind { return c.text } else { return nil }
        }
    }

    @Test func queuedFollowUpIsHeldOutOfTranscriptUntilItSends() async throws {
        // A follow-up sent while a turn is still in flight is held — surfaced via
        // `session.queuedMessage` for the composer's "queued" indicator — but kept OUT of
        // the transcript until it actually sends, so it never interleaves with the in-flight
        // turn's streaming output.
        let backend = GatedTurnBackend()
        let (controller, _, cleanup) = try makeController(backend: backend, conversational: true)
        defer { cleanup() }

        await controller.start(prompt: AgentInput(text: "q1"))
        await waitForStatus(controller, .awaitingInput)  // turn 1 done, stream held open

        try await controller.sendInput(AgentInput(text: "q2"))
        var snap = await controller.snapshot
        #expect(snap.session.status == .running)
        #expect(snap.session.queuedMessage == "q2")
        #expect(await self.userTexts(controller) == ["q1"])  // q2 not in transcript yet

        // A second follow-up coalesces into the held one.
        try await controller.sendInput(AgentInput(text: "q3"))
        #expect(await controller.snapshot.session.queuedMessage == "q2\nq3")

        // Release turn 1 → the queued message is injected and runs; the indicator clears.
        backend.release(turn: 1)
        await waitFor { await self.assistantTexts(controller).contains("answer 2") }
        snap = await controller.snapshot
        #expect(snap.session.queuedMessage == nil)
        #expect(await self.userTexts(controller) == ["q1", "q2\nq3"])
        backend.release(turn: 2)

        await controller.shutdown()
    }

    @Test func cancelQueuedInputRemovesItAndReturnsTheText() async throws {
        let backend = GatedTurnBackend()
        let (controller, _, cleanup) = try makeController(backend: backend, conversational: true)
        defer { cleanup() }

        await controller.start(prompt: AgentInput(text: "q1"))
        await waitForStatus(controller, .awaitingInput)
        try await controller.sendInput(AgentInput(text: "draft"))
        #expect(await controller.snapshot.session.queuedMessage == "draft")

        // Cancel hands the text back (so the composer can refill it) and clears the queue.
        let restored = await controller.cancelQueuedInput()
        #expect(restored == "draft")
        #expect(await controller.snapshot.session.queuedMessage == nil)

        // Releasing the held turn settles to awaitingInput with nothing queued to run, and
        // the cancelled draft never reached the transcript.
        backend.release(turn: 1)
        await waitForStatus(controller, .awaitingInput)
        #expect(await self.userTexts(controller) == ["q1"])

        await controller.shutdown()
    }

    private func waitFor(timeoutMs: Int = 3000, _ predicate: @Sendable () async -> Bool) async {
        var elapsed = 0
        while await predicate() == false && elapsed < timeoutMs {
            try? await Task.sleep(for: .milliseconds(10))
            elapsed += 10
        }
    }

    @Test func streamErrorSynthesizesTerminalStatus() async throws {
        struct Boom: Error {}
        let backend = ScriptedBackend { e, _ in
            e.emit(.sessionStarted(SessionStarted(
                backendSessionID: "be", model: "m", cwd: "/tmp", toolNames: [])))
            e.fail(Boom())
        }
        let (controller, _, cleanup) = try makeController(backend: backend)
        defer { cleanup() }

        await controller.start(prompt: AgentInput(text: "x"))
        await controller.join()
        let snap = await controller.snapshot
        #expect(snap.session.status == .error)
        #expect(snap.session.lastSeq >= 1)
    }

    @Test func integrateThroughControllerLandsBranch() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let manager = GitWorktreeManager(now: { fixedNow })
        let project = repo.project()
        let worktree = try await manager.create(
            for: .generate(), in: project, base: "main", slug: "agent-work")

        // The agent "edits" a file in its worktree, then the turn completes.
        let wtPath = worktree.path
        let backend = ScriptedBackend { e, _ in
            e.emit(.sessionStarted(SessionStarted(
                backendSessionID: "be", model: "m", cwd: wtPath, toolNames: ["Write"])))
            let file = (wtPath as NSString).appendingPathComponent("agent.txt")
            try? "from agent\n".write(toFile: file, atomically: true, encoding: .utf8)
            e.emit(.fileChange(FileChange(path: "agent.txt", kind: .add)))
            e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
            e.emit(.runFinished(RunFinished(outcome: .completed)))
        }
        let (controller, _, cleanup) = try makeController(
            backend: backend, worktreeManager: manager, project: project, worktree: worktree)
        defer { cleanup() }

        await controller.start(prompt: AgentInput(text: "write a file"))
        await controller.join()

        // Live diff reflects the uncommitted agent edit.
        let diff = try await controller.diff()
        #expect(diff.files.contains { $0.path == "agent.txt" })

        // Integrate squashes the work onto main.
        let result = try await controller.integrate(strategy: .squash)
        guard case .clean = result else {
            Issue.record("expected clean integration, got \(result)")
            return
        }
        #expect(repo.read("agent.txt") == "from agent\n")
    }

    /// A turn that only talks — no file edits — leaves the worktree clean, so its ship
    /// footprint is empty. This is the exact signal the autoship gate
    /// (`AppStore.hasShippableChanges` → `unmergedFiles`) keys off to skip a conversational
    /// turn: autoship only runs when files were actually changed in the chat.
    @Test func conversationalTurnHasNoShippableFootprint() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let manager = GitWorktreeManager(now: { fixedNow })
        let project = repo.project()
        let worktree = try await manager.create(
            for: .generate(), in: project, base: "main", slug: "chat")

        let wtPath = worktree.path
        let backend = ScriptedBackend { e, _ in
            e.emit(.sessionStarted(SessionStarted(
                backendSessionID: "be", model: "m", cwd: wtPath, toolNames: ["Read"])))
            e.emit(.assistantText(TextChunk(messageID: "m", text: "Here's how it works.", isPartial: false)))
            e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
            e.emit(.runFinished(RunFinished(outcome: .completed)))
        }
        let (controller, _, cleanup) = try makeController(
            backend: backend, worktreeManager: manager, project: project, worktree: worktree)
        defer { cleanup() }

        await controller.start(prompt: AgentInput(text: "explain this code"))
        await controller.join()

        // Nothing was edited, so there is nothing to ship — the gate skips this turn.
        #expect(try await controller.unmergedFiles().isEmpty)
    }

    /// Update-on-grant reconciles against the ship destination too (LOCKING §4.5). When a session's
    /// `shipTarget` (here the project's `autoShipBranch`) diverges from its `parentTarget` (the fork
    /// base), a grant pulls BOTH in: the parent branch's own advances *and* a sibling's work that
    /// landed on the ship branch — so the just-granted session is not left stale on the fork base.
    @Test func updateFromParentReconcilesShipTargetWhenDivergent() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let manager = GitWorktreeManager(now: { fixedNow })

        // A sibling's work lives on a separate ship branch ("staging"), forked from main's initial
        // commit — main itself never gets `sibling.txt`.
        try await repo.run(["checkout", "-b", "staging"])
        try repo.write("sibling.txt", "sibling work\n")
        try await repo.run(["add", "-A"])
        try await repo.run(["commit", "-m", "sibling ships to staging"])
        try await repo.run(["checkout", "main"])

        // This session forks from main (parentTarget = main) but ships to staging (shipTarget).
        let project = Project(
            name: "test", rootPath: repo.root, defaultBranch: "main", autoShipBranch: "staging")
        let worktree = try await manager.create(
            for: .generate(), in: project, base: "main", slug: "granted")

        // The parent (main) also advances after the fork with an unrelated file.
        try repo.write("parent.txt", "parent work\n")
        try await repo.run(["add", "-A"])
        try await repo.run(["commit", "-m", "parent advances main"])

        let (controller, _, cleanup) = try makeController(
            backend: ScriptedBackend { _, _ in },
            worktreeManager: manager, project: project, worktree: worktree)
        defer { cleanup() }

        let result = await controller.updateFromParent(requestedFiles: [])
        #expect(result == .clean)
        #expect(repo.read("parent.txt", in: worktree.path) == "parent work\n")   // from parentTarget (main)
        #expect(repo.read("sibling.txt", in: worktree.path) == "sibling work\n") // from shipTarget (staging)
    }

    /// A conflict against the *ship* target (the second reconciled ref) surfaces as `.conflicted`
    /// holding the lock, while the clean `parentTarget` merge already applied stays in place — the
    /// first-conflict-wins, monotonic-progress contract of `updateOnGrantSources` (LOCKING §4.5).
    @Test func updateFromParentShipTargetConflictSurfacesAndKeepsParentMerge() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let manager = GitWorktreeManager(now: { fixedNow })

        // Seed a shared base for the contended file on main (the fork base), plus a separate ship
        // branch that diverges that file — so a later worktree edit collides with staging, not main.
        try repo.write("hot.txt", "base\n")
        try await repo.run(["add", "-A"])
        try await repo.run(["commit", "-m", "seed hot.txt on main"])
        try await repo.run(["checkout", "-b", "staging"])
        try repo.write("hot.txt", "staging change\n")
        try await repo.run(["add", "-A"])
        try await repo.run(["commit", "-m", "staging diverges hot.txt"])
        try await repo.run(["checkout", "main"])

        let project = Project(
            name: "test", rootPath: repo.root, defaultBranch: "main", autoShipBranch: "staging")
        // Fork the worktree from main BEFORE main advances, so the parentTarget merge actually
        // brings `parent.txt` in (rather than it already being present at the fork point).
        let worktree = try await manager.create(
            for: .generate(), in: project, base: "main", slug: "granted")

        // main advances an unrelated file (its own clean parentTarget merge, to verify it sticks).
        try repo.write("parent.txt", "parent work\n")
        try await repo.run(["add", "-A"])
        try await repo.run(["commit", "-m", "parent advances main"])

        // The session commits its own conflicting edit to hot.txt (committed, so the merge reaches a
        // real content conflict rather than a dirty-tree refusal).
        try repo.write("hot.txt", "local change\n", in: worktree.path)
        try await repo.run(["add", "-A"], in: worktree.path)
        try await repo.run(["commit", "-m", "local edits hot.txt"], in: worktree.path)

        let (controller, _, cleanup) = try makeController(
            backend: ScriptedBackend { _, _ in },
            worktreeManager: manager, project: project, worktree: worktree)
        defer { cleanup() }

        let result = await controller.updateFromParent(requestedFiles: [])
        #expect(result == .conflicted(["hot.txt"]))                              // ship-target merge conflicted
        #expect(repo.read("parent.txt", in: worktree.path) == "parent work\n")   // parentTarget merge stuck
        #expect(repo.read("hot.txt", in: worktree.path) == "local change\n")     // conflict aborted, edit intact
    }
}

@Suite("SessionStatus — state machine")
struct SessionStatusTests {
    @Test func terminalStatesAreSticky() {
        let after = SessionStatus.transition(.finished, on: .assistantText(
            TextChunk(messageID: "m", text: "x", isPartial: false)))
        #expect(after == .finished)
    }

    @Test func approvalCycleReturnsToRunning() {
        var s = SessionStatus.idle
        s = SessionStatus.transition(s, on: .sessionStarted(SessionStarted(
            backendSessionID: "b", model: "m", cwd: "/", toolNames: [])))
        #expect(s == .running)
        let req = ApprovalRequest(
            id: .generate(), sessionID: .generate(), toolCallID: nil, toolName: "Write",
            input: .null, title: "t", risk: .write, createdAt: fixedNow)
        s = SessionStatus.transition(s, on: .approvalRequested(req))
        #expect(s == .awaitingApproval)
        s = SessionStatus.transition(s, on: .approvalResolved(ApprovalResolved(
            id: req.id, decision: .allow(), decidedBy: "ui", decidedAt: fixedNow)))
        #expect(s == .running)
    }

    @Test func recoverableErrorDoesNotTerminate() {
        let s = SessionStatus.transition(.running, on: .error(
            AgentError(recoverable: true, message: "retry")))
        #expect(s == .running)
        let fatal = SessionStatus.transition(.running, on: .error(
            AgentError(recoverable: false, message: "boom")))
        #expect(fatal == .error)
    }

    @Test func turnCompletedAwaitsInput() {
        let s = SessionStatus.transition(.running, on: .turnCompleted(
            TurnCompleted(stopReason: "end_turn", usage: nil)))
        #expect(s == .awaitingInput)
    }
}

/// A backend that emits a turn's terminal `runFinished` and then HOLDS its event stream
/// open until `release(turn:)` — reproducing the real `ClaudeCodeBackend` window where the
/// turn already looks finished (status → awaitingInput) but the stream hasn't closed and
/// `runTask` hasn't cleared. Each `start`/`resume` is the next turn, answering "answer N".
private final class GatedTurnBackend: AgentBackend, @unchecked Sendable {
    static let id = BackendID.claudeCode

    let capabilities = BackendCapabilities(
        interactiveApprovals: true, allowAlwaysScopes: [.session], canModifyToolInput: false,
        partialMessageStreaming: false, emitsThinking: false, emitsFileChangeEvents: false,
        nativeResume: true, sandboxModes: [], followUpWhileRunning: false)

    private let turnCount = LockedBox(0)
    private let gates = LockedBox<[Int: AsyncThrowingStream<AgentEvent, Error>.Continuation]>([:])

    /// Finish the held stream of turn `n`, letting the controller's run loop complete.
    func release(turn n: Int) {
        var g = gates.get()
        let cont = g.removeValue(forKey: n)
        gates.set(g)
        cont?.finish()
    }

    private func run(_ sessionID: SessionID) -> AsyncThrowingStream<AgentEvent, Error> {
        AsyncThrowingStream { continuation in
            let n = turnCount.get() + 1
            turnCount.set(n)
            var seq: UInt64 = 0
            func emit(_ kind: AgentEvent.Kind) {
                seq += 1
                continuation.yield(AgentEvent(
                    sessionID: sessionID, seq: seq, at: Date(timeIntervalSince1970: 1_700_000_000),
                    backend: Self.id, nativeType: nil, kind: kind))
            }
            emit(.sessionStarted(SessionStarted(
                backendSessionID: "be", model: "m", cwd: "/tmp", toolNames: [])))
            emit(.assistantText(TextChunk(messageID: "a\(n)", text: "answer \(n)", isPartial: false)))
            emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
            emit(.runFinished(RunFinished(outcome: .completed)))
            // Hold the stream open until the test releases this turn.
            var g = gates.get()
            g[n] = continuation
            gates.set(g)
        }
    }

    func start(_ run: RunSpec) -> AsyncThrowingStream<AgentEvent, Error> { self.run(run.sessionID) }
    func resume(_ resume: ResumeSpec) -> AsyncThrowingStream<AgentEvent, Error> { run(resume.sessionID) }
    func send(_ input: AgentInput) async throws {}
    func respond(to approvalID: ApprovalID, _ decision: Decision) async throws {}
    func interrupt() async {}
    func shutdown() async {
        let g = gates.get()
        gates.set([:])
        for cont in g.values { cont.finish() }
    }
}

@Suite("SessionController — sandbox .claude.json seed")
struct ClaudeConfigSeedTests {
    private func object(_ data: Data) throws -> [String: Any] {
        try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any])
    }

    @Test func dropsProjectsButKeepsTheRestOfTheHostConfig() throws {
        // A control session points claude at the per-session home via CLAUDE_CONFIG_DIR, which
        // needs a `.claude.json`. The seed carries the host config (onboarding/account) but drops
        // the per-cwd `projects` map (large + would leak other projects' prompt history).
        let host = try JSONSerialization.data(withJSONObject: [
            "hasCompletedOnboarding": true,
            "oauthAccount": ["emailAddress": "user@example.com"],
            "numStartups": 42,
            "projects": ["/Users/x/secret-repo": ["history": ["my private prompt"]]],
        ])
        let seed = try object(SessionController.claudeConfigSeed(fromHostConfig: host))
        #expect(seed["projects"] == nil)
        #expect(seed["hasCompletedOnboarding"] as? Bool == true)
        #expect(seed["numStartups"] as? Int == 42)
        #expect((seed["oauthAccount"] as? [String: Any])?["emailAddress"] as? String == "user@example.com")
    }

    @Test func fallsBackToAMinimalValidConfigWhenHostFileIsUnreadable() throws {
        // No host file (nil) or unparseable bytes → a minimal valid config that still satisfies
        // claude and skips onboarding, rather than a missing/garbage file.
        for data in [Data?.none, Data("not json".utf8)] {
            let seed = try object(SessionController.claudeConfigSeed(fromHostConfig: data))
            #expect(seed["hasCompletedOnboarding"] as? Bool == true)
        }
    }
}

@Suite("ContainerServiceSettings — control auth mode", .serialized)
struct ControlAuthModeTests {
    @Test func defaultsToOAuthAndParsesStoredValue() {
        let key = ContainerServiceSettings.controlAuthModeKey
        let prior = UserDefaults.standard.object(forKey: key)
        defer {
            if let prior { UserDefaults.standard.set(prior, forKey: key) }
            else { UserDefaults.standard.removeObject(forKey: key) }
        }

        UserDefaults.standard.removeObject(forKey: key)
        #expect(ContainerServiceSettings.controlAuthMode == .oauth)  // unset → OAuth

        UserDefaults.standard.set(ControlAuthMode.apiKey.rawValue, forKey: key)
        #expect(ContainerServiceSettings.controlAuthMode == .apiKey)

        UserDefaults.standard.set("nonsense", forKey: key)
        #expect(ContainerServiceSettings.controlAuthMode == .oauth)  // unknown → OAuth
    }
}

@Suite("SessionController — blank ANTHROPIC_API_KEY")
struct BlankAPIKeyTests {
    @Test func treatsEmptyOrWhitespaceAsBlankButKeepsRealKeys() {
        // A present-but-blank key (e.g. inherited empty from launchd) must be dropped — claude
        // prefers ANTHROPIC_API_KEY over OAuth and would 401 on an empty one.
        #expect(SessionController.isBlankAPIKey(""))
        #expect(SessionController.isBlankAPIKey("   "))
        #expect(SessionController.isBlankAPIKey("\n\t "))
        // Unset (nil) isn't blank — there's nothing to forward — and a real key is kept.
        #expect(!SessionController.isBlankAPIKey(nil))
        #expect(!SessionController.isBlankAPIKey("sk-ant-api03-xyz"))
    }
}

/// Minimal thread-safe box for capturing a value across the test's concurrent tasks.
final class LockedBox<T>: @unchecked Sendable {
    private let lock = NSLock()
    private var value: T
    init(_ value: T) { self.value = value }
    func set(_ newValue: T) { lock.lock(); value = newValue; lock.unlock() }
    func get() -> T { lock.lock(); defer { lock.unlock() }; return value }
}
