import Foundation
import Testing

@testable import NucleicCore

@Suite("Intelligence heuristics & export")
struct IntelligenceTests {
    @Test func heuristicTitleFromMessage() {
        #expect(HeuristicTitle.fromMessage("what's in this repo") == "What's In This Repo")
        #expect(HeuristicTitle.fromMessage("Fix the login button that won't respond on tap")
            == "Fix The Login Button That Won't")  // capped at 6 words
        #expect(HeuristicTitle.fromMessage("   ") == "New chat")
    }

    @Test func looksLikeTitleRejectsRefusalsAndRambles() {
        #expect(HeuristicTitle.looksLikeTitle("Explore Repository Contents"))
        #expect(!HeuristicTitle.looksLikeTitle("Apple GPT is not applicable here"))  // the bug
        #expect(!HeuristicTitle.looksLikeTitle("I'm sorry, I cannot do that"))
        #expect(!HeuristicTitle.looksLikeTitle(
            "This is a very long sentence that clearly is not a short title at all really"))
        #expect(!HeuristicTitle.looksLikeTitle("line one\nline two"))
        // Small-model token looping and leaked few-shot scaffolding are gibberish, not titles.
        #expect(!HeuristicTitle.looksLikeTitle("Login Login Button Button"))
        #expect(!HeuristicTitle.looksLikeTitle("Title: Fix Login Button"))
    }

    @Test func foldTrunkSummaryAccumulatesDedupedClauses() {
        // First fold off an empty summary derives a clause from the digest's Goal line.
        let one = HeuristicSummary.foldTrunkSummary(
            previous: "", digest: "Chat: Login\nGoal: Add a login button\nFiles changed: ui/Login.swift")
        #expect(one == "Add a login button")
        // A second turn appends its clause; clauses join with "; ".
        let two = HeuristicSummary.foldTrunkSummary(
            previous: one, digest: "Chat: Auth\nGoal: Fix token refresh\nFiles changed: Auth.swift")
        #expect(two == "Add a login button; Fix token refresh")
        // Re-folding the same contribution is idempotent (case-insensitive de-dup).
        let again = HeuristicSummary.foldTrunkSummary(
            previous: two, digest: "Goal: fix token refresh")
        #expect(again == two)
    }

    @Test func foldTrunkSummaryFallsBackToChatThenFirstLineAndCaps() {
        // No Goal line → key off the Chat line.
        #expect(HeuristicSummary.foldTrunkSummary(previous: "", digest: "Chat: Tidy settings layout")
            == "Tidy settings layout")
        // No labels at all → the first non-empty line, clipped to ~10 words.
        #expect(HeuristicSummary.foldTrunkSummary(
            previous: "", digest: "one two three four five six seven eight nine ten eleven twelve")
            == "one two three four five six seven eight nine ten")
        // Past six clauses the tail collapses to "+N more" rather than growing without bound.
        var s = ""
        for i in 1...8 { s = HeuristicSummary.foldTrunkSummary(previous: s, digest: "Goal: change \(i)") }
        #expect(s == "change 1; change 2; change 3; change 4; change 5; change 6; +2 more")
    }

    @Test func clipTruncatesOnWordBoundary() {
        // Drops the trailing partial word rather than leaving a mangled fragment.
        #expect(HeuristicTitle.clip("Fix The Authentication Middleware Layer", max: 30)
            == "Fix The Authentication")
        // Short strings pass through untouched.
        #expect(HeuristicTitle.clip("Toolbar Styling Polish") == "Toolbar Styling Polish")
        // A single over-long word still has to be hard-cut.
        #expect(HeuristicTitle.clip("Supercalifragilisticexpialidocious", max: 10) == "Supercalif")
    }

    @Test func fromMessageKeepsApostrophesAndClipsCleanly() {
        // The mid-word apostrophe survives (no "Dont"); the 6-word cap stays.
        #expect(HeuristicTitle.fromMessage("Don't break the login flow on tap please")
            == "Don't Break The Login Flow On")
    }

    @Test func weakFirstMessageDetection() {
        #expect(HeuristicTitle.isWeakFirstMessage("continue"))
        #expect(HeuristicTitle.isWeakFirstMessage("ok do it"))
        #expect(HeuristicTitle.isWeakFirstMessage("fix this"))
        #expect(HeuristicTitle.isWeakFirstMessage("go ahead and proceed"))  // vague opener
        #expect(!HeuristicTitle.isWeakFirstMessage("Fix the login button not responding"))
    }

    @Test func toolGroupLinesMergeRedundantCallsByFamily() {
        let calls = [
            ToolCall(toolCallID: "1", name: "Read", input: ["file_path": "/x/A.swift"]),
            ToolCall(toolCallID: "2", name: "Read", input: ["file_path": "/x/B.swift"]),
            ToolCall(toolCallID: "3", name: "Bash", input: ["command": "swift build"]),
        ]
        let lines = HeuristicSummary.toolGroupLines(calls)
        // The two reads fold into one line; the bash call is its own line, in run order.
        #expect(lines.count == 2)
        #expect(lines[0] == "Read A.swift, B.swift")
        #expect(lines[1].hasPrefix("Ran "))
    }

    @Test func toolGroupLinesShowPathsRelativeToWorktree() {
        let calls = [
            ToolCall(toolCallID: "1", name: "Read", input: ["file_path": "/repo/Sources/A.swift"]),
            ToolCall(toolCallID: "2", name: "Read", input: ["file_path": "/repo/Sources/sub/B.swift"]),
            ToolCall(toolCallID: "3", name: "Read", input: ["file_path": "/elsewhere/C.swift"]),
        ]
        let lines = HeuristicSummary.toolGroupLines(calls, relativeTo: "/repo")
        // In-tree paths drop the worktree prefix; an out-of-tree path falls back to basename.
        #expect(lines == ["Read Sources/A.swift; Sources/sub/B.swift; C.swift"])
    }

    @Test func toolGroupLinesFactorOutSharedDirectory() {
        let calls = [
            ToolCall(toolCallID: "1", name: "Read", input: ["file_path": "/repo/dir1/dir2/file.txt"]),
            ToolCall(toolCallID: "2", name: "Read", input: ["file_path": "/repo/dir1/dir2/file2.txt"]),
            ToolCall(toolCallID: "3", name: "Read", input: ["file_path": "/repo/dir1/dir2/file3.txt"]),
        ]
        let lines = HeuristicSummary.toolGroupLines(calls, relativeTo: "/repo")
        // Files sharing a directory list the path once, at the end.
        #expect(lines == ["Read file.txt, file2.txt, file3.txt from dir1/dir2/"])
    }

    @Test func grepHeadlineRendersTopLevelPipesAsNaturalList() {
        let one = HeuristicSummary.toolGroupLines(
            [ToolCall(toolCallID: "1", name: "Grep", input: ["pattern": "auth|login|session"])])
        // A top-level alternation reads as a comma list with a trailing "or", not raw pipes.
        #expect(one == ["Searched for auth, login, or session"])

        let two = HeuristicSummary.toolGroupLines(
            [ToolCall(toolCallID: "1", name: "Grep", input: ["pattern": "foo|bar"])])
        // Two alternatives: "a or b", no Oxford comma.
        #expect(two == ["Searched for foo or bar"])
    }

    @Test func grepHeadlineLeavesNonSeparatorPipesAlone() {
        // Pipe inside a group is not a top-level separator — pattern is shown unchanged.
        #expect(HeuristicSummary.toolGroupLines(
            [ToolCall(toolCallID: "1", name: "Grep", input: ["pattern": "(foo|bar)baz"])])
            == ["Searched for (foo|bar)baz"])
        // No pipe at all: untouched.
        #expect(HeuristicSummary.toolGroupLines(
            [ToolCall(toolCallID: "1", name: "Grep", input: ["pattern": "needle"])])
            == ["Searched for needle"])
    }

    @Test func terminatedAddsPeriodOnlyWhenMissing() {
        // A bare summary line gains a period.
        #expect(HeuristicSummary.terminated("Searched for needle") == "Searched for needle.")
        #expect(HeuristicSummary.terminated("Read A.swift, B.swift") == "Read A.swift, B.swift.")
        // A line that already ends in terminal punctuation is left alone — no doubling.
        #expect(HeuristicSummary.terminated("Ran the test suite.") == "Ran the test suite.")
        #expect(HeuristicSummary.terminated("Did it work?") == "Did it work?")
        // A clipped alternation ending in an ellipsis stays as-is (no "….").
        #expect(HeuristicSummary.terminated("Searched for a, b, …") == "Searched for a, b, …")
        // Trailing whitespace is trimmed before the period lands.
        #expect(HeuristicSummary.terminated("Wrote x.swift  ") == "Wrote x.swift.")
        #expect(HeuristicSummary.terminated("") == "")
    }

    @Test func joinBashSummariesStripsPerItemPeriods() {
        // Per-command gists that each end in a period must not strand "a., b." mid-list;
        // the single terminating period is added later by `terminated`, not here.
        let line = HeuristicSummary.joinBashSummaries(["Built the app.", "Ran the tests."])
        #expect(line == "Ran Built the app, Ran the tests")
        #expect(HeuristicSummary.terminated(line) == "Ran Built the app, Ran the tests.")
        // A lone gist's period is stripped too, leaving the final period to `terminated`.
        #expect(HeuristicSummary.joinBashSummaries(["Staged the change."]) == "Ran Staged the change")
        // A meaningful "?" or ellipsis on an item is preserved — only "." is dropped.
        #expect(HeuristicSummary.joinBashSummaries(["Checked the state?", "Tidied up."])
                == "Ran Checked the state?, Tidied up")
    }

    @Test func filePathBodySplitsFileFamilyLines() {
        // File-family lines split into verb + path so the path can be styled apart.
        #expect(HeuristicSummary.filePathBody(of: "Read A.swift")?.verb == "Read")
        #expect(HeuristicSummary.filePathBody(of: "Read A.swift")?.path == "A.swift")
        #expect(HeuristicSummary.filePathBody(of: "Read a, b from src/")?.path == "a, b from src/")
        #expect(HeuristicSummary.filePathBody(of: "Wrote x.swift")?.verb == "Wrote")
        #expect(HeuristicSummary.filePathBody(of: "Edited a.swift, b.swift from src/")?.verb == "Edited")
        // The "Read, Edited"/"Read, Wrote" folds win over the bare "Read" prefix.
        #expect(HeuristicSummary.filePathBody(of: "Read, Edited src/a.swift")?.verb == "Read, Edited")
        #expect(HeuristicSummary.filePathBody(of: "Read, Edited src/a.swift")?.path == "src/a.swift")
        #expect(HeuristicSummary.filePathBody(of: "Read, Wrote x.swift")?.verb == "Read, Wrote")
        // Non-file lines have no path to set apart.
        #expect(HeuristicSummary.filePathBody(of: "Ran the test suite") == nil)
        #expect(HeuristicSummary.filePathBody(of: "Searched for needle") == nil)
        // A bare verb with no body isn't a path line.
        #expect(HeuristicSummary.filePathBody(of: "Read") == nil)
    }

    @Test func topLevelAlternativesIgnoresGroupsClassesAndEscapes() {
        // Only depth-0, unescaped, out-of-class pipes split.
        #expect(HeuristicSummary.topLevelAlternatives("a|b|c") == ["a", "b", "c"])
        #expect(HeuristicSummary.topLevelAlternatives("(a|b)|c") == ["(a|b)", "c"])
        #expect(HeuristicSummary.topLevelAlternatives("[a|b]|c") == ["[a|b]", "c"])
        #expect(HeuristicSummary.topLevelAlternatives(#"a\|b|c"# ) == [#"a\|b"#, "c"])
        #expect(HeuristicSummary.topLevelAlternatives("solo") == ["solo"])
    }

    @Test func naturalizeSearchPatternDropsEmptySegments() {
        // A stray doubled/leading pipe shouldn't leave an empty list item.
        #expect(HeuristicSummary.naturalizeSearchPattern("a||b") == "a or b")
        #expect(HeuristicSummary.naturalizeSearchPattern("|a|b") == "a or b")
    }

    @Test func naturalizeSearchPatternClipsOnWholeTermBoundary() {
        // A long alternation, clipped to a width budget, ends on a whole term plus "…" —
        // never mid-term, which would strand a dangling comma.
        let clipped = HeuristicSummary.naturalizeSearchPattern(
            "summary|summarize|description|title|toolSummary|bash.*command", limit: 40)
        #expect(clipped == "summary, summarize, description, …")
        #expect(!clipped.contains(", ,"))
        #expect(!clipped.hasSuffix(", "))
        // Within budget, the full Oxford-comma list is kept.
        #expect(HeuristicSummary.naturalizeSearchPattern("auth|login|session", limit: 40)
            == "auth, login, or session")
        // No default limit: unchanged behavior for the per-call sentence form.
        #expect(HeuristicSummary.naturalizeSearchPattern("auth|login|session")
            == "auth, login, or session")
    }

    @Test func grepHeadlineNeverStrandsDoubledCommas() {
        // Several long-alternation Grep calls folded into one headline must not produce the
        // ",, ," garbage that came from clipping the expanded comma list mid-term.
        let calls = [
            ToolCall(toolCallID: "1", name: "Grep",
                     input: ["pattern": "summary|summarize|description|title|toolSummary"]),
            ToolCall(toolCallID: "2", name: "Grep",
                     input: ["pattern": "bash.*command|tool.*call|command.*summary|bash.*"]),
        ]
        let line = HeuristicSummary.toolGroupLines(calls).first ?? ""
        #expect(line.hasPrefix("Searched for "))
        #expect(!line.contains(", ,"))
        #expect(!line.contains(",,"))
    }

    @Test func naturalizeGlobPatternReadsAsPlainEnglish() {
        // A recursive `**` reads as "all"; a trailing `*.ext` becomes the language name.
        #expect(HeuristicSummary.naturalizeGlobPattern("**/*.swift") == "all Swift files")
        #expect(HeuristicSummary.naturalizeGlobPattern("*.ts") == "TypeScript files")
        #expect(HeuristicSummary.naturalizeGlobPattern("**/*") == "all files")
        #expect(HeuristicSummary.naturalizeGlobPattern("**") == "all files")
        // A non-glob directory prefix is named; the `**` still reads as "all".
        #expect(HeuristicSummary.naturalizeGlobPattern("Sources/**/*.py") == "all Python files in Sources")
        #expect(HeuristicSummary.naturalizeGlobPattern("src/*.js") == "JavaScript files in src")
    }

    @Test func naturalizeGlobPatternHandlesBraceListsAndUnknownExtensions() {
        // A brace list naturalizes each extension and dedupes to one name when they agree.
        #expect(HeuristicSummary.naturalizeGlobPattern("**/*.{ts,tsx}") == "all TypeScript files")
        // Distinct languages join with an Oxford "or".
        #expect(HeuristicSummary.naturalizeGlobPattern("*.{js,json}") == "JavaScript or JSON files")
        // An unmapped extension keeps its bare (lowercased) token rather than guessing.
        #expect(HeuristicSummary.naturalizeGlobPattern("**/*.GRAPHQL") == "all graphql files")
    }

    @Test func naturalizeGlobPatternFallsBackOnUnparseableShapes() {
        // A compound filename glob, a dotted double extension, or a glob in the directory
        // prefix can't be phrased cleanly — the raw pattern comes back untouched.
        #expect(HeuristicSummary.naturalizeGlobPattern("**/test_*.py") == "**/test_*.py")
        #expect(HeuristicSummary.naturalizeGlobPattern("**/*.d.ts") == "**/*.d.ts")
        #expect(HeuristicSummary.naturalizeGlobPattern("src-*/*.js") == "src-*/*.js")
    }

    @Test func globHeadlineReadsListedAllSwiftFiles() {
        // The end-to-end headline a "Listed **/*.swift" tool call produces.
        let calls = [ToolCall(toolCallID: "1", name: "Glob", input: ["pattern": "**/*.swift"])]
        #expect(HeuristicSummary.toolGroupLines(calls) == ["Listed all Swift files"])
    }

    @Test func toolGroupLinesFoldAdjacentReadThenEditOfSameFile() {
        let calls = [
            ToolCall(toolCallID: "1", name: "Read", input: ["file_path": "/repo/src/a.swift"]),
            ToolCall(toolCallID: "2", name: "Edit", input: ["file_path": "/repo/src/a.swift"]),
        ]
        let lines = HeuristicSummary.toolGroupLines(calls, relativeTo: "/repo")
        // A read immediately followed by an edit of the same file is one combined line.
        #expect(lines == ["Read, Edited src/a.swift"])
    }

    @Test func toolGroupLinesDoNotFoldReadAndEditOfDifferentFiles() {
        let calls = [
            ToolCall(toolCallID: "1", name: "Read", input: ["file_path": "/repo/a.swift"]),
            ToolCall(toolCallID: "2", name: "Edit", input: ["file_path": "/repo/b.swift"]),
        ]
        let lines = HeuristicSummary.toolGroupLines(calls, relativeTo: "/repo")
        // Different paths: stays a separate Read line and Edited line (bucketed by family).
        #expect(lines == ["Read a.swift", "Edited b.swift"])
    }

    @Test func toolGroupLinesFoldReadEditPairAroundOtherCalls() {
        let calls = [
            ToolCall(toolCallID: "1", name: "Read", input: ["file_path": "/repo/a.swift"]),
            ToolCall(toolCallID: "2", name: "Edit", input: ["file_path": "/repo/a.swift"]),
            ToolCall(toolCallID: "3", name: "Bash", input: ["command": "swift build"]),
        ]
        let lines = HeuristicSummary.toolGroupLines(calls, relativeTo: "/repo")
        #expect(lines.count == 2)
        #expect(lines[0] == "Read, Edited a.swift")
        #expect(lines[1].hasPrefix("Ran "))
    }

    @Test func toolGroupLinesDirectoryMergeAppliesToEditsToo() {
        let calls = [
            ToolCall(toolCallID: "1", name: "Edit", input: ["file_path": "/repo/src/a.swift"]),
            ToolCall(toolCallID: "2", name: "Edit", input: ["file_path": "/repo/src/b.swift"]),
        ]
        let lines = HeuristicSummary.toolGroupLines(calls, relativeTo: "/repo")
        #expect(lines == ["Edited a.swift, b.swift from src/"])
    }

    @Test func relativizePathsStripsWorktreePrefixInFreeText() {
        let root = "/repo"
        // Paths embedded in a command (not just the file_path arg) are trimmed.
        #expect(HeuristicSummary.relativizePaths("swift build /repo/Sources/x.swift", relativeTo: root)
                == "swift build Sources/x.swift")
        #expect(HeuristicSummary.relativizePaths("x /repo/a /repo/b", relativeTo: root) == "x a b")
        #expect(HeuristicSummary.relativizePaths("cat a.txt", relativeTo: root) == "cat a.txt")
        #expect(HeuristicSummary.relativizePaths("/other/x.swift", relativeTo: root) == "/other/x.swift")
        #expect(HeuristicSummary.relativizePaths("anything", relativeTo: nil) == "anything")
    }

    @Test func displayPathStripsWorktreePrefix() {
        #expect(HeuristicSummary.displayPath("/repo/dir/file.swift", relativeTo: "/repo") == "dir/file.swift")
        #expect(HeuristicSummary.displayPath("/repo/file.swift", relativeTo: "/repo/") == "file.swift")
        #expect(HeuristicSummary.displayPath("/other/file.swift", relativeTo: "/repo") == "file.swift")
        #expect(HeuristicSummary.displayPath("/repo/file.swift", relativeTo: nil) == "file.swift")
    }

    @Test func aiEligibilityNeverIncludesReadAndRespectsScope() {
        let reads = [
            ToolCall(toolCallID: "1", name: "Read", input: ["file_path": "/r/A.swift"]),
            ToolCall(toolCallID: "2", name: "Read", input: ["file_path": "/r/B.swift"]),
        ]
        let bash = [
            ToolCall(toolCallID: "3", name: "Bash", input: ["command": "swift build"]),
            ToolCall(toolCallID: "4", name: "Bash", input: ["command": "swift test"]),
        ]
        let edits = [
            ToolCall(toolCallID: "5", name: "Edit", input: ["file_path": "/r/A.swift"]),
            ToolCall(toolCallID: "6", name: "Edit", input: ["file_path": "/r/B.swift"]),
        ]
        let families = HeuristicSummary.families(reads + bash + edits)
        let readFamily = families.first { $0.isRead }!
        let bashFamily = families.first { $0.isBash }!
        let editFamily = families.first { $0.key == "edit" }!

        // Read is never AI-eligible, whatever the scope.
        #expect(!readFamily.aiEligible(scope: .all))
        #expect(!readFamily.aiEligible(scope: .bashOnly))
        // Bash is eligible under both scopes; non-bash, non-read only under `.all`.
        #expect(bashFamily.aiEligible(scope: .bashOnly))
        #expect(bashFamily.aiEligible(scope: .all))
        #expect(!editFamily.aiEligible(scope: .bashOnly))
        #expect(editFamily.aiEligible(scope: .all))

        // A lone Bash call is still eligible — its summary is the run's PURPOSE, not a
        // merge — but a lone non-bash call has nothing to merge, so it isn't.
        let loneBash = HeuristicSummary.families([bash[0]])[0]
        #expect(loneBash.aiEligible(scope: .all))
        #expect(loneBash.aiEligible(scope: .bashOnly))
        let loneEdit = HeuristicSummary.families([edits[0]])[0]
        #expect(!loneEdit.aiEligible(scope: .all))
    }

    @Test func echoesCommandsCatchesParrotedBashLines() {
        // A line that just relists the commands — with or without the "Ran" verb, and
        // through backticks/commas — is an echo, not a summary.
        #expect(HeuristicSummary.echoesCommands("Ran swift build, swift test",
                                                commands: ["swift build", "swift test"]))
        #expect(HeuristicSummary.echoesCommands("Ran `git status`, `git diff`",
                                                commands: ["git status", "git diff"]))
        #expect(HeuristicSummary.echoesCommands("swift test", commands: ["swift test"]))
        // A purpose summary that doesn't just relist the command text is NOT an echo,
        // even when it names the tool.
        #expect(!HeuristicSummary.echoesCommands("Ran the test suite", commands: ["swift test"]))
        #expect(!HeuristicSummary.echoesCommands("Built the package and ran the tests",
                                                 commands: ["swift build", "swift test"]))
        #expect(!HeuristicSummary.echoesCommands("Staged and committed the changes",
                                                 commands: ["git add -A", "git commit -m wip"]))
        // Degenerate input never reports an echo.
        #expect(!HeuristicSummary.echoesCommands("Ran the tests", commands: []))
    }

    @Test func groundingCatchesParrotedExampleInMerge() {
        // A merge of read-only inspections that comes back as the merge prompt's first
        // example ("Built and tested the app") shares no content word with the gists — the
        // on-device model parroted the instruction. Rejected so the deterministic line shows.
        let inspections = ["Listed the project files", "Searched the codebase", "Searched the codebase"]
        #expect(!HeuristicSummary.isGrounded("Built and tested the app", in: inspections))
        // A merge that actually reflects the gists is grounded and kept.
        #expect(HeuristicSummary.isGrounded("Searched and listed the project files", in: inspections))
        // A genuine build + test run still merges to the canonical line — "built"/"tested"
        // are present in the gists, so the line is grounded (light stemming aligns test↔tests).
        let buildTest = ["Built the app", "Ran the tests"]
        #expect(HeuristicSummary.isGrounded("Built and tested the app", in: buildTest))
        // Staging + commit, likewise grounded.
        #expect(HeuristicSummary.isGrounded("Staged and committed the changes",
                                            in: ["Staged the changes", "Committed the work"]))
        // An empty line, or no gists to ground against, are handled without false positives.
        #expect(!HeuristicSummary.isGrounded("", in: inspections))
        #expect(HeuristicSummary.isGrounded("Built and tested the app", in: []))
    }

    @Test func summaryIsGroundedInAgentActionsNotProse() {
        let sid = SessionID(rawValue: "S")
        func ev(_ kind: AgentEvent.Kind) -> AgentEvent {
            AgentEvent(sessionID: sid, seq: 0, at: Date(timeIntervalSince1970: 1_700_000_000),
                       backend: .claudeCode, nativeType: nil, kind: kind)
        }
        let events = [
            ev(.userText(TextChunk(messageID: "u", text: "what's in this repo", isPartial: false))),
            ev(.toolCallCompleted(ToolCall(toolCallID: "t1", name: "Read", input: ["file_path": "README.md"]))),
            ev(.toolCallCompleted(ToolCall(toolCallID: "t2", name: "Bash", input: ["command": "ls -la"]))),
            // Prose that mentions OTHER work — must NOT be treated as this agent's "done".
            ev(.assistantText(TextChunk(messageID: "a",
                text: "The repo contains a Swift package; another agent already added GRDB and the iPhone client.",
                isPartial: false))),
            ev(.runFinished(RunFinished(outcome: .completed))),
        ]

        let turn = HeuristicSummary.lastTurn(events)
        #expect(turn.request == "what's in this repo")
        #expect(turn.actions == ["read README.md", "ran `ls -la`"])

        let digest = HeuristicSummary.lastTurnDigest(events)
        #expect(digest.contains("read README.md"))
        #expect(digest.contains("ran `ls -la`"))
        // The reply is explicitly flagged as context-only, not work done.
        #expect(digest.contains("do not summarize its contents as work done"))

        let session = Session(id: sid, projectID: .generate(), backend: .claudeCode, title: "t",
                              status: .awaitingInput, transcriptPath: "/t",
                              createdAt: Date(timeIntervalSince1970: 1_700_000_000),
                              updatedAt: Date(timeIntervalSince1970: 1_700_000_000))
        let text = HeuristicSummary.text(session: session, events: events)
        // Sectioned + abstracted: an overview, not a literal command log.
        #expect(text.contains("**Just now:**"))
        #expect(text.lowercased().contains("explored the codebase"))
        #expect(!text.contains("ls -la"))     // literal command not echoed
        #expect(!text.contains("another agent"))  // prose not echoed

        let sessionDigest = HeuristicSummary.sessionDigest(events)
        #expect(sessionDigest.contains("what's in this repo"))   // the task
        #expect(sessionDigest.contains("explored the codebase"))  // abstracted, not "ran ls -la"
        #expect(!sessionDigest.contains("ls -la"))
    }

    @Test func turnClassifierTellsAskingApartFromDone() {
        // Ends with a question / an ask → the agent is waiting on the user.
        #expect(HeuristicTurnClassifier.classify(
            "I found two approaches. Which would you prefer?") == .awaitingInput)
        #expect(HeuristicTurnClassifier.classify(
            "I can refactor this now. Want me to go ahead?") == .awaitingInput)
        #expect(HeuristicTurnClassifier.classify(
            "There are unstaged changes. Let me know how you'd like to proceed.") == .awaitingInput)
        // Ends by reporting finished work → done, nothing required from the user.
        #expect(HeuristicTurnClassifier.classify(
            "Fixed the login bug and added a regression test. All tests pass.") == .completed)
        #expect(HeuristicTurnClassifier.classify("Done.") == .completed)
        // A long completion (question-free tail) still reads as done — the classifier
        // weighs the ending, not the full reply.
        #expect(HeuristicTurnClassifier.classify(
            String(repeating: "I traced the failure through the call stack. ", count: 12)
            + "Root cause was a nil unwrap; patched and verified, all green.") == .completed)
        // Empty / whitespace → treat as done rather than spuriously "awaiting".
        #expect(HeuristicTurnClassifier.classify("   \n ") == .completed)
    }

    @Test func completedRecapMergesDoneAndJustNowDropsNextAndHeadings() {
        let sectioned = """
            **Done:**
            - Explored the codebase
            - Edited 2 files (App.swift, Store.swift)
            **Just now:**
            - Edited 2 files (App.swift, Store.swift)
            - Added a regression test
            **Next:**
            - Send your next instruction
            """
        let recap = HeuristicSummary.completedRecap(sectioned)

        // Headings gone, "Next" content gone.
        #expect(!recap.contains("**"))
        #expect(!recap.contains("Send your next instruction"))
        // Done + Just now merged, with the shared bullet deduped to a single line.
        #expect(recap.contains("- Explored the codebase"))
        #expect(recap.contains("- Added a regression test"))
        let edited = recap.components(separatedBy: "- Edited 2 files (App.swift, Store.swift)").count - 1
        #expect(edited == 1)

        // Degenerate input (no sections/bullets) passes through unchanged.
        #expect(HeuristicSummary.completedRecap("All set.") == "All set.")
    }

    @Test func exportIncludesDebugFieldsAndRawEvents() {
        let session = Session(
            id: SessionID(rawValue: "S1"), projectID: .generate(), backend: .claudeCode,
            backendSessionID: "be-xyz", title: "Fix Login", status: .awaitingInput,
            worktreePath: "/wt/fix", branch: "nucleic/fix-login", baseSHA: "abc123",
            model: "opus", effort: "high", transcriptPath: "/t.jsonl",
            createdAt: Date(timeIntervalSince1970: 1_700_000_000),
            updatedAt: Date(timeIntervalSince1970: 1_700_000_000))
        let events = [
            AgentEvent(sessionID: session.id, seq: 1, at: Date(timeIntervalSince1970: 1_700_000_000),
                       backend: .claudeCode, nativeType: nil,
                       kind: .userText(TextChunk(messageID: "u1", text: "fix the login", isPartial: false))),
            AgentEvent(sessionID: session.id, seq: 2, at: Date(timeIntervalSince1970: 1_700_000_000),
                       backend: .claudeCode, nativeType: nil,
                       kind: .assistantText(TextChunk(messageID: "a1", text: "Done.", isPartial: false))),
        ]
        let md = ConversationExport.markdown(session: session, project: nil, events: events)

        #expect(md.contains("Backend session id:** be-xyz"))   // debug info
        #expect(md.contains("nucleic/fix-login"))
        #expect(md.contains("**User:** fix the login"))
        #expect(md.contains("**Assistant:** Done."))
        #expect(md.contains("```jsonl"))                       // raw appendix
        #expect(md.contains("\"type\":\"userText\""))          // raw event encoded
        #expect(ConversationExport.suggestedFileName(for: session).hasSuffix(".md"))
    }
}
