Files
nucleic/Tests/NucleicCoreTests/AppStoreTests.swift
T
abkslmandnucleic 5e00404575 nvrsion: promote trunk to dev
Nucleic-Promote: 1
Co-authored-by: Nucleic <[email protected]>
2026-06-27 19:33:40 -07:00

2709 lines
147 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import Testing
@testable import NucleicCore
private struct FakeIntelligence: IntelligenceProviding {
func summarize(session: Session, events: [AgentEvent]) async -> String { "FAKE SUMMARY" }
func sessionName(fromFirstMessage message: String) async -> String? { "Title: \(message)" }
}
private final class CountingIntelligence: IntelligenceProviding, @unchecked Sendable {
let calls = LockedBox(0)
func summarize(session: Session, events: [AgentEvent]) async -> String {
calls.set(calls.get() + 1)
return "SUMMARY \(calls.get())"
}
func sessionName(fromFirstMessage message: String) async -> String? { nil }
}
/// Exercises the Bash map→reduce path: each command is gisted (map), then the gist list is
/// merged into one line (reduce). The distinctive shapes let a test prove the per-command
/// gists flow through the merge as a list rather than the raw commands being re-summarized.
private struct MapReduceIntelligence: IntelligenceProviding {
func summarize(session: Session, events: [AgentEvent]) async -> String { "" }
func sessionName(fromFirstMessage message: String) async -> String? { nil }
func summarizeBashCommand(_ command: String) async -> String { "gist(\(command))" }
func mergeBashSummaries(_ summaries: [String]) async -> String {
"MERGED[" + summaries.joined(separator: " | ") + "]"
}
}
/// A one-shot gate: `wait()` suspends until `open()` (and returns immediately once open).
private actor TestGate {
private var opened = false
private var waiters: [CheckedContinuation<Void, Never>] = []
func wait() async {
if opened { return }
await withCheckedContinuation { waiters.append($0) }
}
func open() {
opened = true
for waiter in waiters { waiter.resume() }
waiters.removeAll()
}
}
/// Counts group-gist passes STARTED, per group, and holds each in flight on a gate — so a
/// test can keep a pass running while it triggers more regenerations, the window in which the
/// duplicate-enqueue bug fired.
private final class GatedGistIntelligence: IntelligenceProviding, @unchecked Sendable {
private let starts = LockedBox([String: Int]())
private let gate = TestGate()
func summarize(session: Session, events: [AgentEvent]) async -> String { "" }
func sessionName(fromFirstMessage message: String) async -> String? { nil }
func summarizeTodos(_ items: [String], group: String) async -> String {
var counts = starts.get(); counts[group, default: 0] += 1; starts.set(counts)
await gate.wait()
return "GIST(\(group))"
}
func startCount(_ group: String) -> Int { starts.get()[group, default: 0] }
func release() async { await gate.open() }
}
/// Counts group-gist passes per group (completing each immediately) and triages every group
/// into REVERSE capture order — so a group of ≥2 ideas flips its display order the moment
/// triage lands. Lets a test prove the gist's de-dup fingerprint ignores a pure reorder of an
/// otherwise-unchanged idea set.
private final class ReorderingGistIntelligence: IntelligenceProviding, @unchecked Sendable {
private let starts = LockedBox([String: Int]())
func summarize(session: Session, events: [AgentEvent]) async -> String { "" }
func sessionName(fromFirstMessage message: String) async -> String? { nil }
func summarizeTodos(_ items: [String], group: String) async -> String {
var counts = starts.get(); counts[group, default: 0] += 1; starts.set(counts)
return "GIST(\(group))"
}
func triageTodos(_ items: [TriageInput], group: String) async -> [TriageItem] {
// Rank last-captured first (rank 0 = top) so a ≥2-idea group's display order flips.
let n = items.count
return items.enumerated().map {
TriageItem(id: $0.element.id, level: .medium, rank: n - 1 - $0.offset, reason: nil)
}
}
func startCount(_ group: String) -> Int { starts.get()[group, default: 0] }
}
@MainActor
// Serialized: every test here drives an @MainActor AppStore and polls async state via
// `waitFor`, so they can't truly run in parallel anyway — parallel scheduling only adds
// main-actor contention that stretches turn pipelines past the poll timeouts and flakes
// the timing-based assertions.
@Suite("AppStore — object-graph root", .serialized)
struct AppStoreTests {
private func makeStore(
repo: GitTestRepo,
now: @escaping @Sendable () -> Date = { Date(timeIntervalSince1970: 1_700_000_000) }
) -> AppStore {
let database = try! GRDBMetadataStore(path: nil)
let worktrees = GitWorktreeManager(now: now)
let transcriptsDir = URL(fileURLWithPath: (repo.container as NSString)
.appendingPathComponent("transcripts"))
return AppStore(
database: database, worktrees: worktrees, transcriptsDir: transcriptsDir,
now: now
) { session in
// A scripted agent that writes a file into its worktree, then finishes.
let wtPath = session.worktreePath ?? ""
return ScriptedBackend { e, _ in
e.emit(.sessionStarted(SessionStarted(
backendSessionID: "be-app", model: "m", cwd: wtPath, toolNames: ["Write"])))
let file = (wtPath as NSString).appendingPathComponent("made.txt")
try? "by agent\n".write(toFile: file, atomically: true, encoding: .utf8)
e.emit(.fileChange(FileChange(path: "made.txt", kind: .add)))
e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
e.emit(.runFinished(RunFinished(outcome: .completed)))
}
}
}
private func waitFor(_ condition: @escaping () -> Bool, timeoutMs: Int = 3000) async {
var elapsed = 0
while !condition() && elapsed < timeoutMs {
try? await Task.sleep(for: .milliseconds(10))
elapsed += 10
}
}
/// Async analogue of `waitFor`, for conditions that must hop to an actor (e.g. the
/// `lockQueueSnapshot()` accessor) and so can't run in `waitFor`'s synchronous closure.
private func waitForAsync(_ condition: @escaping () async -> Bool, timeoutMs: Int = 3000) async {
var elapsed = 0
while await !condition() && elapsed < timeoutMs {
try? await Task.sleep(for: .milliseconds(10))
elapsed += 10
}
}
/// Autoship transcript notes (e.g. "merging into main…", "merged into main · abc1234")
/// in event order — the `Autoship:`-prefixed notes `handleShipUpdate` logs.
private func autoshipNotes(_ events: [AgentEvent]) -> [String] {
events.compactMap {
if case .note(let n) = $0.kind, n.isAutoship { return n.text } else { return nil }
}
}
@Test func createSessionRunsAndSummarizes() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let store = makeStore(repo: repo)
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")
let projectID = try #require(project?.id)
let sessionID = try await store.createSession(
in: store.project(projectID)!, title: "make a file", prompt: "go")
store.openSessionID = sessionID
// Wait for the run to be fully observed (terminal event ingested into the
// transcript), then settle the live diffstat deterministically (the in-stream
// refresh is best-effort and can be skipped under heavy parallel git load).
await store.awaitOpenSessionSettled()
await store.refreshOpenStatus()
let summary = try #require(store.summaries.first)
#expect(summary.title == "make a file")
#expect(summary.status == .awaitingInput) // conversational: ready for next message
#expect((summary.diffStat?.filesChanged ?? 0) >= 1)
#expect(store.summaries(for: projectID).count == 1)
// Open transcript was populated for the selected session.
#expect(!store.openTranscript.isEmpty)
#expect(store.openTranscript.contains {
if case .runFinished = $0.kind { return true } else { return false }
})
// The agent's file is in the worktree.
#expect(FileManager.default.fileExists(
atPath: (repo.root as NSString)
.appendingPathComponent(".nucleic/worktrees/make-a-file/made.txt")))
}
// MARK: - nvrsion (NVRSION) — shared-trunk mode, end to end
/// Turn a control project into an nvrsion project and return the refreshed `Project`.
private func enableNvrsion(_ store: AppStore, _ project: Project) async -> Project {
var p = project
p.nvrsion = ProjectNvrsion(enabled: true)
await store.updateProject(p)
return store.project(project.id) ?? p
}
@Test func nvrsionLandsEditOnTrunkAndMakesNoPerSessionWorktree() async throws {
let repo = try await GitTestRepo(controlled: true) // nvrsion is Control-only (NVRSION §10)
defer { repo.cleanup() }
let store = makeStore(repo: repo)
let created = try #require(await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main"))
let project = await enableNvrsion(store, created)
#expect(project.nvrsionActive)
let session = try await store.createSession(in: project, title: "make a file", prompt: "go")
store.openSessionID = session
await store.awaitOpenSessionSettled()
let trunkPath = project.resolvedTrunkPath
// The scripted edit (made.txt) landed on `nucleic/trunk` as a path-scoped, attributed commit.
let names = try await repo.run(["show", "--name-only", "--format=", "HEAD"], in: trunkPath).stdout
#expect(names.contains("made.txt"))
// The land is logged into the session's chat (nvrsion's in-chat ops log, the analog of
// autoship's commit/merge notes), tagged with the shared nvrsion prefix.
#expect(store.openTranscript.contains {
if case .note(let n) = $0.kind { return n.isNvrsion && n.text.contains("landed made.txt") }
return false
})
let body = try await repo.run(["log", "-1", "--format=%B", "HEAD"], in: trunkPath).stdout
#expect(body.contains("Nucleic-Session: \(session.rawValue)"))
// No isolated per-session worktree/branch — only the shared trunk branch exists.
let branches = try await repo.run(["branch", "--list"]).stdout
#expect(branches.contains("nucleic/trunk"))
#expect(!branches.contains("make-a-file"))
// And no per-session worktree directory was created under .nucleic/worktrees.
#expect(!FileManager.default.fileExists(
atPath: (repo.root as NSString).appendingPathComponent(".nucleic/worktrees/make-a-file")))
}
@Test func nvrsionSessionsShareTheOneTrunkLockDomain() async throws {
let repo = try await GitTestRepo(controlled: true)
defer { repo.cleanup() }
let store = makeStore(repo: repo)
store.lockReconcileInterval = .milliseconds(20)
let created = try #require(await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main"))
let project = await enableNvrsion(store, created)
let a = try await store.createSession(in: project, title: "alpha", prompt: "go")
let b = try await store.createSession(in: project, title: "bravo", prompt: "go")
await waitFor {
store.summaries.first { $0.id == a }?.status == .awaitingInput
&& store.summaries.first { $0.id == b }?.status == .awaitingInput
}
// A acquires shared.txt. Because every nvrsion session shares ONE lock domain (the trunk),
// B asking for the same file must queue — not proceed independently.
#expect(await store.arbitrate(sessionID: a, task: "edit", files: ["shared.txt"]).resolution == .proceed)
let arbitration = Task { await store.arbitrate(sessionID: b, task: "edit", files: ["shared.txt"]) }
await waitFor { store.sessionsWaitingForAccess.contains(b) }
#expect(store.sessionsWaitingForAccess.contains(b))
// Releasing A grants B — proving they contended in the same domain.
await store.forceReleaseLocks(a)
#expect(await arbitration.value.resolution == .proceed)
}
@Test func nvrsionKeepWarmSweepReleasesAnIdleHeldLock() async throws {
let repo = try await GitTestRepo(controlled: true)
defer { repo.cleanup() }
let clock = MutableClock(Date(timeIntervalSince1970: 1_700_000_000))
let store = makeStore(repo: repo, now: clock.now)
let created = try #require(await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main"))
let project = await enableNvrsion(store, created)
// A session that doesn't run (empty prompt) — so no turn-end clears the warm set under us.
let a = try await store.createSession(in: project, title: "alpha", prompt: "")
// It holds shared.txt (acquired via arbitrate); mark it warm with a 1s window, as a land would.
#expect(await store.arbitrate(sessionID: a, task: "edit", files: ["shared.txt"]).resolution == .proceed)
await store.nvrsionGovernor.warmed(a, "shared.txt", idleSeconds: 1)
// Before the window elapses, the sweep keeps the lock held (keep-warm).
#expect(await store.runNvrsionSweep() == false)
let held = await store.lockQueueSnapshot()
#expect(held.files.contains { $0.path == "shared.txt" && $0.holders.contains { $0.sessionID == a } })
// Past the window, the sweep releases it without waiting for the turn to end (NVRSION §4).
clock.advance(2)
#expect(await store.runNvrsionSweep() == true)
let freed = await store.lockQueueSnapshot()
#expect(!freed.files.contains { $0.path == "shared.txt" })
}
@Test func nvrsionPromoteShipsTrunkWorkToTheRealBranch() async throws {
let repo = try await GitTestRepo(controlled: true)
defer { repo.cleanup() }
let store = makeStore(repo: repo)
let created = try #require(await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main"))
let project = await enableNvrsion(store, created)
// Nothing landed yet → the sidebar Integrate button rests at its checkmark.
#expect(store.nvrsionHasPendingIntegration(project.id) == false)
// A scripted session lands made.txt on the trunk.
let session = try await store.createSession(in: project, title: "alpha", prompt: "go")
store.openSessionID = session
await store.awaitOpenSessionSettled()
// The landed edit flips the button to its up-arrow — there's work to integrate.
#expect(store.nvrsionHasPendingIntegration(project.id) == true)
let mainBefore = try await repo.revParse("refs/heads/main")
// Promote → the real branch (main) gets the trunk's work as one squashed commit.
#expect(await store.promoteNvrsionTrunk(project.id) == true)
#expect(try await repo.revParse("refs/heads/main") != mainBefore)
#expect(repo.read("made.txt", in: repo.root) == "by agent\n")
// And once promoted, it settles back to the checkmark — nothing left to integrate.
#expect(store.nvrsionHasPendingIntegration(project.id) == false)
// Nothing new on the trunk now → a second promote reports nothing-to-promote.
#expect(await store.promoteNvrsionTrunk(project.id) == false)
#expect(store.lastError?.contains("Nothing on the nvrsion trunk") == true)
}
@Test func nvrsionTrunkAutoIntegratesOnceTheProjectFallsQuiet() async throws {
let repo = try await GitTestRepo(controlled: true)
defer { repo.cleanup() }
let store = makeStore(repo: repo)
let created = try #require(await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main"))
let project = await enableNvrsion(store, created)
// A scripted session lands made.txt on the trunk and then finishes its turn.
let session = try await store.createSession(in: project, title: "alpha", prompt: "go")
store.openSessionID = session
await store.awaitOpenSessionSettled()
// There's work to integrate and no chat is mid-turn → integration is available, so the
// auto-integrate countdown is armed (we don't wait out the real 3 s — we fire it directly).
#expect(store.nvrsionHasPendingIntegration(project.id) == true)
#expect(store.nvrsionIntegrationAvailable(project.id) == true)
#expect(store.nvrsionAutoIntegrationArmed(project.id) == true)
// The countdown elapses → it promotes on its own, exactly as the manual button would.
let mainBefore = try await repo.revParse("refs/heads/main")
await store.fireNvrsionAutoIntegration(project.id)
#expect(try await repo.revParse("refs/heads/main") != mainBefore)
#expect(repo.read("made.txt", in: repo.root) == "by agent\n")
// Promoted → nothing left to integrate and the countdown is stood down.
#expect(store.nvrsionHasPendingIntegration(project.id) == false)
#expect(store.nvrsionIntegrationAvailable(project.id) == false)
#expect(store.nvrsionAutoIntegrationArmed(project.id) == false)
}
@Test func nvrsionAutoIntegrateFlagsAConflictAndStandsDown() async throws {
let repo = try await GitTestRepo(controlled: true)
defer { repo.cleanup() }
let store = makeStore(repo: repo)
let created = try #require(await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main"))
let project = await enableNvrsion(store, created)
// A scripted session lands made.txt="by agent\n" on the trunk and finishes.
let session = try await store.createSession(in: project, title: "alpha", prompt: "go")
store.openSessionID = session
await store.awaitOpenSessionSettled()
#expect(store.nvrsionHasPendingIntegration(project.id) == true)
// Meanwhile the real branch grows a *different* made.txt — so squashing the trunk into main
// will conflict add/add.
try repo.write("made.txt", "hand edit\n")
try await repo.run(["add", "made.txt"])
try await repo.run(["-c", "commit.gpgsign=false", "commit", "-m", "hand edit"])
let mainConflicted = try await repo.revParse("refs/heads/main")
// The countdown fires → the promote conflicts. Integration couldn't run automatically, so the
// project is flagged (sidebar shows a red ✗), main is untouched, and the work still pends.
await store.fireNvrsionAutoIntegration(project.id)
#expect(store.nvrsionIntegrationFailure(project.id)?.contains("conflicted") == true)
#expect(try await repo.revParse("refs/heads/main") == mainConflicted) // promote rolled back
#expect(store.nvrsionHasPendingIntegration(project.id) == true)
// While flagged the countdown stands down — it must not auto-retry a wedged promote.
await store.refreshNvrsionPending(project.id)
#expect(store.nvrsionAutoIntegrationArmed(project.id) == false)
// Resolve the conflict on the base branch (drop the hand edit), then a manual retry succeeds
// and clears the flag — the sidebar drops the red ✗.
try await repo.run(["reset", "--hard", "HEAD~1"])
#expect(await store.promoteNvrsionTrunk(project.id) == true)
#expect(store.nvrsionIntegrationFailure(project.id) == nil)
#expect(store.nvrsionHasPendingIntegration(project.id) == false)
#expect(repo.read("made.txt", in: repo.root) == "by agent\n")
}
@Test func nvrsionAutoIntegrateStaysDownWithNothingToIntegrate() async throws {
let repo = try await GitTestRepo(controlled: true)
defer { repo.cleanup() }
let store = makeStore(repo: repo)
let created = try #require(await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main"))
let project = await enableNvrsion(store, created)
// A quiet project with nothing landed on its trunk is not integratable, so the countdown
// never arms and firing it is a harmless no-op (the gate the running-chat case also relies on).
#expect(store.nvrsionHasPendingIntegration(project.id) == false)
#expect(store.nvrsionIntegrationAvailable(project.id) == false)
#expect(store.nvrsionAutoIntegrationArmed(project.id) == false)
let mainBefore = try await repo.revParse("refs/heads/main")
await store.fireNvrsionAutoIntegration(project.id)
#expect(try await repo.revParse("refs/heads/main") == mainBefore)
}
@Test func nvrsionPrelandHookRejectsAnEditFromLanding() async throws {
let repo = try await GitTestRepo(controlled: true)
defer { repo.cleanup() }
let store = makeStore(repo: repo)
let created = try #require(await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main"))
// Enable nvrsion with a pre-land hook that always fails.
var p = created
p.nvrsion = ProjectNvrsion(enabled: true, prelandHook: "exit 1")
await store.updateProject(p)
let project = try #require(store.project(created.id))
#expect(project.nvrsionActive)
let session = try await store.createSession(in: project, title: "alpha", prompt: "go")
store.openSessionID = session
await store.awaitOpenSessionSettled()
// The scripted edit was rejected by the hook, so it never landed on the trunk.
let trunkPath = project.resolvedTrunkPath
let inTrunk = try await repo.run(["cat-file", "-e", "HEAD:made.txt"], in: trunkPath)
#expect(inTrunk.status != 0) // made.txt is not in the trunk's committed tree
// And a rejection note was posted.
#expect(store.openTranscript.contains {
if case .note(let n) = $0.kind { return n.text.contains("pre-land check rejected") }
return false
})
}
/// A store whose scripted agent writes a file named after the chat's title (so two chats land
/// *disjoint* files on the shared trunk), and — for any title in `slowTitles` — stays mid-turn
/// (lands its file, then blocks without finishing) so a sibling can be promoted while it "works".
private func makeStorePerSessionFileStore(
repo: GitTestRepo, slowTitles: Set<String> = []
) -> AppStore {
let database = try! GRDBMetadataStore(path: nil)
let now: @Sendable () -> Date = { Date(timeIntervalSince1970: 1_700_000_000) }
let worktrees = GitWorktreeManager(now: now)
let transcriptsDir = URL(fileURLWithPath: (repo.container as NSString)
.appendingPathComponent("transcripts"))
return AppStore(
database: database, worktrees: worktrees, transcriptsDir: transcriptsDir, now: now
) { session in
let wtPath = session.worktreePath ?? ""
let title = session.title
let isSlow = slowTitles.contains(title)
return ScriptedBackend { e, _ in
e.emit(.sessionStarted(SessionStarted(
backendSessionID: "be-\(title)", model: "m", cwd: wtPath, toolNames: ["Write"])))
let rel = "\(title).txt"
try? "by \(title)\n".write(
toFile: (wtPath as NSString).appendingPathComponent(rel),
atomically: true, encoding: .utf8)
e.emit(.fileChange(FileChange(path: rel, kind: .add)))
if isSlow {
// Stay mid-turn: never emit turnCompleted/runFinished. Cancellation-aware sleep
// so shutdown unblocks it cleanly at teardown.
try? await Task.sleep(for: .seconds(3600))
return
}
e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
e.emit(.runFinished(RunFinished(outcome: .completed)))
}
}
}
@Test func nvrsionPromotesOneChatWithoutWaitingOnALongRunningSibling() async throws {
let repo = try await GitTestRepo(controlled: true)
defer { repo.cleanup() }
// bravo never finishes its turn — it stands in for the "very long session" the user shouldn't
// have to wait on before integrating alpha's finished work.
let store = makeStorePerSessionFileStore(repo: repo, slowTitles: ["bravo"])
let created = try #require(await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main"))
let project = await enableNvrsion(store, created)
let trunkPath = project.resolvedTrunkPath
// alpha lands alpha.txt and finishes its turn.
let alpha = try await store.createSession(in: project, title: "alpha", prompt: "go")
store.openSessionID = alpha
await store.awaitOpenSessionSettled()
// bravo lands bravo.txt but keeps working (mid-turn) — wait until its file is on the trunk.
let bravo = try await store.createSession(in: project, title: "bravo", prompt: "go")
await waitForAsync {
((try? await repo.run(["cat-file", "-e", "HEAD:bravo.txt"], in: trunkPath))?.status ?? 1) == 0
}
// A long-running sibling means the *whole-trunk* integrate is unavailable (it would squash
// bravo's half-written work) — this is exactly the wait the per-chat promote removes.
#expect(store.isAnySessionRunning(in: project.id) == true)
#expect(store.nvrsionIntegrationAvailable(project.id) == false)
let mainBefore = try await repo.revParse("refs/heads/main")
// Promote ONLY alpha — it ships even though bravo is still working.
#expect(await store.promoteNvrsionSession(alpha) == true)
#expect(try await repo.revParse("refs/heads/main") != mainBefore)
#expect(repo.read("alpha.txt", in: repo.root) == "by alpha\n")
#expect(repo.read("bravo.txt", in: repo.root) == nil) // bravo's work did NOT ship
// bravo's landed work is untouched on the trunk, and its lock/keep-warm state is intact —
// the per-chat promote never reset or rewrote the trunk branch.
#expect(repo.read("bravo.txt", in: trunkPath) == "by bravo\n")
#expect(store.isAnySessionRunning(in: project.id) == true)
#expect(store.nvrsionHasPendingIntegration(project.id) == true) // bravo still pending
// Trying to promote bravo while it's mid-turn is refused (don't ship a half-written change),
// without disturbing anything — the gate is on *this* chat's turn, not the project's.
#expect(await store.promoteNvrsionSession(bravo) == false)
#expect(store.lastError?.contains("still working") == true)
}
@Test func nvrsionPerChatPromoteReportsNothingWhenTheChatHasNoTrunkWork() async throws {
let repo = try await GitTestRepo(controlled: true)
defer { repo.cleanup() }
let store = makeStorePerSessionFileStore(repo: repo)
let created = try #require(await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main"))
let project = await enableNvrsion(store, created)
// alpha lands work; a second, never-prompted chat lands nothing.
let alpha = try await store.createSession(in: project, title: "alpha", prompt: "go")
store.openSessionID = alpha
await store.awaitOpenSessionSettled()
let idle = try await store.createSession(in: project, title: "idle", prompt: "")
// Promoting the idle chat ships nothing and says so; alpha's work stays pending.
#expect(await store.promoteNvrsionSession(idle) == false)
#expect(store.lastError?.contains("nothing on the nvrsion trunk") == true)
#expect(store.nvrsionHasPendingIntegration(project.id) == true)
}
/// Regression: a session that writes a file *outside* the trunk worktree — e.g. a plan or
/// memory file under the app-support `claude-home` dir — must NOT be landed. Agents report
/// absolute paths, and one outside the trunk can't reduce to a repo-relative pathspec; handing
/// it to `git add` drew `pathspec '…' did not match any file(s) known to git` and surfaced a
/// spurious "nvrsion could not land" note. The out-of-trunk edit is now ignored, while a real
/// in-trunk edit in the same turn still lands cleanly.
@Test func nvrsionIgnoresEditsOutsideTheTrunkWorktree() async throws {
let repo = try await GitTestRepo(controlled: true) // nvrsion is Control-only (NVRSION §10)
defer { repo.cleanup() }
let worktrees = GitWorktreeManager(now: { Date(timeIntervalSince1970: 1_700_000_000) })
let transcriptsDir = URL(fileURLWithPath: (repo.container as NSString)
.appendingPathComponent("transcripts"))
// A claude-home-style plan path under app-support — absolute, but NOT under the trunk.
let outsidePlan = (repo.container as NSString)
.appendingPathComponent("claude-home/plans/tidy-plotting-yeti.md")
let store = AppStore(
database: try GRDBMetadataStore(path: nil), worktrees: worktrees,
transcriptsDir: transcriptsDir,
now: { Date(timeIntervalSince1970: 1_700_000_000) }
) { session in
let wtPath = session.worktreePath ?? ""
return ScriptedBackend { e, _ in
e.emit(.sessionStarted(SessionStarted(
backendSessionID: "be", model: "m", cwd: wtPath, toolNames: ["Write"])))
// A real in-trunk edit — lands.
try? "by agent\n".write(
toFile: (wtPath as NSString).appendingPathComponent("made.txt"),
atomically: true, encoding: .utf8)
e.emit(.fileChange(FileChange(path: "made.txt", kind: .add)))
// The agent also writes a plan under claude-home (outside the trunk) — must be ignored.
e.emit(.fileChange(FileChange(path: outsidePlan, kind: .add)))
e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
e.emit(.runFinished(RunFinished(outcome: .completed)))
}
}
let created = try #require(await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main"))
let project = await enableNvrsion(store, created)
#expect(project.nvrsionActive)
let session = try await store.createSession(in: project, title: "make a file", prompt: "go")
store.openSessionID = session
await store.awaitOpenSessionSettled()
let trunkPath = project.resolvedTrunkPath
// The in-trunk edit landed as a path-scoped commit; the out-of-trunk plan did not.
let names = try await repo.run(["show", "--name-only", "--format=", "HEAD"], in: trunkPath).stdout
#expect(names.contains("made.txt"))
#expect(!names.contains("tidy-plotting-yeti.md"))
// And no spurious land-failure note was surfaced for the out-of-trunk write.
#expect(!store.openTranscript.contains {
if case .note(let n) = $0.kind { return n.text.contains("could not land") }
return false
})
}
@Test func nvrsionCanBeEnabledWithIdleChatsAndExistingSessionsKeepTheirMode() async throws {
let repo = try await GitTestRepo(controlled: true)
defer { repo.cleanup() }
let store = makeStore(repo: repo)
let project0 = try #require(await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main"))
// A worktree-mode chat (nvrsion off) finishes its turn and rests at awaitingInput
// ("complete"). It made its own isolated per-session worktree branch.
let legacy = try await store.createSession(in: project0, title: "legacy", prompt: "go")
await waitFor { store.summaries.first { $0.id == legacy }?.status == .awaitingInput }
#expect(try await repo.run(["branch", "--list"]).stdout.contains("nucleic/legacy"))
// Enabling nvrsion is ALLOWED despite the idle chat — there is no flip-safety block, because
// a session's mode is fixed at creation (the user's reported bug: "complete" chats blocked it).
let project = await enableNvrsion(store, project0)
#expect(project.nvrsionActive)
// A NEW chat uses the shared trunk; the existing chat keeps its worktree, so the two models
// never mix on live work.
let fresh = try await store.createSession(in: project, title: "fresh", prompt: "go")
await waitFor { store.summaries.first { $0.id == fresh }?.status == .awaitingInput }
let branches = try await repo.run(["branch", "--list"]).stdout
#expect(branches.contains("nucleic/legacy")) // legacy still on its own worktree branch
#expect(branches.contains("nucleic/trunk")) // fresh's edit landed on the shared trunk
#expect(!branches.contains("nucleic/fresh")) // fresh made NO per-session worktree
}
@Test func archivedWorktreeCleanupReclaimsPastThresholdThenRestoresOnUnarchive() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let t0 = Date(timeIntervalSince1970: 1_700_000_000)
let clock = LockedBox(t0)
let store = makeStore(repo: repo, now: { clock.get() })
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")
let projectID = try #require(project?.id)
let sessionID = try await store.createSession(
in: store.project(projectID)!, title: "make a file", prompt: "go")
store.openSessionID = sessionID
await store.awaitOpenSessionSettled()
let wtPath = (repo.root as NSString).appendingPathComponent(".nucleic/worktrees/make-a-file")
#expect(FileManager.default.fileExists(atPath: wtPath))
store.archivedWorktreeCleanupInterval = 86_400 // one day
await store.setSessionArchived(sessionID, true)
// Just archived (archivedAt == now): under the threshold, so the worktree stays.
await store.sweepArchivedWorktrees()
#expect(FileManager.default.fileExists(atPath: wtPath))
// Two days later it's past the threshold — the checkout is reclaimed, the branch kept.
clock.set(t0.addingTimeInterval(2 * 86_400))
await store.sweepArchivedWorktrees()
#expect(!FileManager.default.fileExists(atPath: wtPath))
let branch = try await repo.run(["rev-parse", "--verify", "--quiet", "refs/heads/nucleic/make-a-file"])
#expect(branch.ok)
// Unarchiving re-creates the worktree, with the agent's (WIP-committed) file restored.
await store.setSessionArchived(sessionID, false)
#expect(FileManager.default.fileExists(atPath: wtPath))
#expect(FileManager.default.fileExists(
atPath: (wtPath as NSString).appendingPathComponent("made.txt")))
}
@Test func archivedWorktreeCleanupDisabledByNeverSetting() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let store = makeStore(repo: repo)
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")
let projectID = try #require(project?.id)
let sessionID = try await store.createSession(
in: store.project(projectID)!, title: "keep me", prompt: "go")
store.openSessionID = sessionID
await store.awaitOpenSessionSettled()
let wtPath = (repo.root as NSString).appendingPathComponent(".nucleic/worktrees/keep-me")
store.archivedWorktreeCleanupInterval = nil // "Never"
await store.setSessionArchived(sessionID, true)
await store.sweepArchivedWorktrees()
#expect(FileManager.default.fileExists(atPath: wtPath)) // nothing reclaimed
}
/// Builds a store whose scripted agent emits one finished tool call per `(id, name,
/// input)`, all in a row, so they coalesce into a single group.
private func storeEmitting(
_ calls: [(id: String, name: String, input: JSONValue)],
repo: GitTestRepo
) -> AppStore {
let database = try! GRDBMetadataStore(path: nil)
let worktrees = GitWorktreeManager(now: { Date(timeIntervalSince1970: 1_700_000_000) })
let transcriptsDir = URL(fileURLWithPath: (repo.container as NSString)
.appendingPathComponent("transcripts"))
// Default intelligence is HeuristicIntelligence — deterministic merged lines.
return AppStore(
database: database, worktrees: worktrees, transcriptsDir: transcriptsDir,
now: { Date(timeIntervalSince1970: 1_700_000_000) }
) { session in
let wtPath = session.worktreePath ?? ""
return ScriptedBackend { e, _ in
e.emit(.sessionStarted(SessionStarted(
backendSessionID: "be", model: "m", cwd: wtPath, toolNames: [])))
for spec in calls {
let call = ToolCall(toolCallID: spec.id, name: spec.name, input: spec.input)
e.emit(.toolCallStarted(call))
e.emit(.toolCallCompleted(call))
e.emit(.toolResult(ToolResult(toolCallID: spec.id, content: "ok", isError: false)))
}
e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
e.emit(.runFinished(RunFinished(outcome: .completed)))
}
}
}
private func openSettled(_ store: AppStore, repo: GitTestRepo, title: String) async throws {
let project = try #require(await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main"))
let sessionID = try await store.createSession(
in: store.project(project.id)!, title: title, prompt: "go")
store.openSessionID = sessionID
await store.awaitOpenSessionSettled()
}
@Test func consecutiveReadsCoalesceButAreNeverAISummarized() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let store = storeEmitting([
("t1", "Read", ["file_path": .string("A.swift")]),
("t2", "Read", ["file_path": .string("B.swift")]),
("t3", "Read", ["file_path": .string("C.swift")]),
], repo: repo)
try await openSettled(store, repo: repo, title: "reads")
// The three reads coalesce into one group, but Read is never AI-summarized, so no
// summary is cached — the block renders the deterministic heuristic merge directly.
let group = try #require(TranscriptProjection.toolGroups(store.openTranscript).first)
#expect(group.calls.count == 3)
#expect(store.bashBlockLine("t1") == nil) // not a Bash block
let familySignature = TranscriptProjection.toolGroupSignature(group.calls)
#expect(store.toolFamilyLines(familySignature) == nil) // Read is never AI-summarized
#expect(HeuristicSummary.toolGroupLines(group.calls) == ["Read A.swift, B.swift, C.swift"])
}
@Test func consecutiveBashCallsGetACachedMergedSummary() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let store = storeEmitting([
("t1", "Bash", ["command": .string("swift build")]),
("t2", "Bash", ["command": .string("swift test")]),
], repo: repo)
try await openSettled(store, repo: repo, title: "builds")
// A Bash run IS AI-eligible: each command is gisted (map) and the block's gist list
// is merged into one live line (reduce), keyed by the block's stable id (the first
// call's). HeuristicIntelligence backs it here, producing the deterministic line.
let group = try #require(TranscriptProjection.toolGroups(store.openTranscript).first)
#expect(group.calls.count == 2)
let line = try #require(store.bashBlockLine("t1"))
#expect(line == "Ran swift build, swift test")
}
@Test func loneBashCallGetsACachedSummaryJustLikeAGroup() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
// A single Bash call doesn't coalesce into a group (it stays a lone `.tool`), but it
// is summarized exactly as a one-call group would be — keyed by its own id — so the
// standalone row shows the model line, not a raw "Bash <command>".
let store = storeEmitting([
("t1", "Bash", ["command": .string("swift build")]),
], repo: repo)
try await openSettled(store, repo: repo, title: "build")
// It is NOT a group (no run of ≥2)…
#expect(TranscriptProjection.toolGroups(store.openTranscript).isEmpty)
// …yet it IS a summarizable unit, and gets a cached line keyed by its id.
#expect(TranscriptProjection.summarizableUnits(store.openTranscript).count == 1)
#expect(store.bashBlockLine("t1") == "Ran swift build")
}
@Test func bashBlockLineIsTheReduceOverPerCommandGists() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let store = storeEmitting([
("t1", "Bash", ["command": .string("swift build")]),
("t2", "Bash", ["command": .string("swift test")]),
], repo: repo)
store.intelligence = MapReduceIntelligence()
try await openSettled(store, repo: repo, title: "builds")
// The block's line is the reduce over the per-command gists (map), in order — proving
// the additive list-of-summaries is what gets merged, not the raw commands.
#expect(store.bashBlockLine("t1") == "MERGED[gist(swift build) | gist(swift test)]")
}
@Test func bashBlockLineGrowsAdditivelyAsCommandsLand() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let store = storeEmitting([
("t1", "Bash", ["command": .string("git add -A")]),
("t2", "Bash", ["command": .string("git commit -m x")]),
("t3", "Bash", ["command": .string("git push")]),
], repo: repo)
store.intelligence = MapReduceIntelligence()
try await openSettled(store, repo: repo, title: "ship")
// Every command that landed is folded into the one block line, in run order.
#expect(store.bashBlockLine("t1")
== "MERGED[gist(git add -A) | gist(git commit -m x) | gist(git push)]")
}
@Test func openingAChatFromAPriorRunSkipsAISummaryOfItsHistory() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let dbPath = (repo.container as NSString).appendingPathComponent("nucleic.sqlite")
let transcriptsDir = URL(fileURLWithPath: (repo.container as NSString)
.appendingPathComponent("transcripts"))
let worktrees = GitWorktreeManager(now: { Date(timeIntervalSince1970: 1_700_000_000) })
let makeBackend: @Sendable (Session) -> any AgentBackend = { session in
let wtPath = session.worktreePath ?? ""
return ScriptedBackend { e, _ in
e.emit(.sessionStarted(SessionStarted(
backendSessionID: "be", model: "m", cwd: wtPath, toolNames: [])))
for (id, cmd) in [("t1", "swift build"), ("t2", "swift test")] {
let call = ToolCall(toolCallID: id, name: "Bash", input: ["command": .string(cmd)])
e.emit(.toolCallStarted(call))
e.emit(.toolCallCompleted(call))
e.emit(.toolResult(ToolResult(toolCallID: id, content: "ok", isError: false)))
}
e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
e.emit(.runFinished(RunFinished(outcome: .completed)))
}
}
// First launch: a chat created + run this session has only live activity, so its
// Bash group IS summarized.
let sessionID: SessionID
do {
let db = try GRDBMetadataStore(path: dbPath)
let store = AppStore(
database: db, worktrees: worktrees, transcriptsDir: transcriptsDir,
now: { Date(timeIntervalSince1970: 1_700_000_000) }, backendFactory: makeBackend)
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
sessionID = try await store.createSession(in: project, title: "builds", prompt: "go")
store.openSessionID = sessionID
await store.awaitOpenSessionSettled()
_ = try #require(TranscriptProjection.toolGroups(store.openTranscript).first)
#expect(store.bashBlockLine("t1") != nil) // live activity → summarized
}
// Second launch: a fresh store over the same DB + transcripts. The chat is now
// pre-existing history, so opening it must NOT AI-summarize its tool groups.
let db2 = try GRDBMetadataStore(path: dbPath)
let store2 = AppStore(
database: db2, worktrees: worktrees, transcriptsDir: transcriptsDir,
now: { Date(timeIntervalSince1970: 1_700_000_000) }, backendFactory: makeBackend)
// A distinguishable AI backend: had history been AI-summarized, the line would be
// "MERGED[…]" rather than the deterministic heuristic.
store2.intelligence = MapReduceIntelligence()
await store2.loadProjects()
await store2.loadSessions()
store2.openSessionID = sessionID
await waitFor { !store2.openTranscript.isEmpty }
let group = try #require(TranscriptProjection.toolGroups(store2.openTranscript).first)
#expect(group.calls.count == 2)
// History fires no AI work, but it IS seeded with its heuristic line up front, so the
// view shows a finished line immediately instead of a placeholder that would otherwise
// spin forever waiting on an AI summary that's never generated.
#expect(store2.bashBlockLine("t1") == "Ran swift build, swift test")
}
@Test func todoDispatchSpawnsAndOpensSession() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let store = makeStore(repo: repo)
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
// Capture an unassigned idea.
let todo = try #require(await store.addTodo("make a file"))
#expect(store.openTodos.count == 1)
#expect(todo.projectID == nil)
// Dispatch it into the project.
let sessionID = try #require(await store.dispatchTodo(todo.id, in: project))
#expect(store.openSessionID == sessionID)
// The idea leaves the open list, now tagged + linked to the run.
#expect(store.openTodos.isEmpty)
let dispatched = try #require(store.todos.first { $0.id == todo.id })
#expect(dispatched.status == .dispatched)
#expect(dispatched.projectID == project.id)
#expect(dispatched.dispatchedSessionID == sessionID)
// The agent actually ran for the dispatched session.
await waitFor { store.summaries.first { $0.id == sessionID }?.status == .awaitingInput }
#expect(store.summaries.contains { $0.id == sessionID })
// Persisted across a reload.
await store.loadTodos()
#expect(store.todos.first { $0.id == todo.id }?.status == .dispatched)
}
@Test func todoEditDeleteAndComplete() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let store = makeStore(repo: repo)
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
let a = try #require(await store.addTodo("first"))
let b = try #require(await store.addTodo("second"))
#expect(store.openTodos.count == 2)
// Blank text is ignored.
#expect(await store.addTodo(" ") == nil)
#expect(store.openTodos.count == 2)
await store.updateTodo(a.id, text: "edited", projectID: project.id)
#expect(store.todos.first { $0.id == a.id }?.text == "edited")
#expect(store.todos.first { $0.id == a.id }?.projectID == project.id)
await store.setTodoStatus(b.id, .done)
#expect(store.openTodos.map(\.id) == [a.id]) // done drops from the open list
await store.deleteTodo(a.id)
#expect(store.todos.contains { $0.id == a.id } == false)
}
@Test func longTodoGetsItemSummaryShortDoesNot() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let store = makeStore(repo: repo)
let long = try #require(await store.addTodo(
"refactor the authentication module so it issues short-lived tokens and refreshes them automatically before they expire"))
let short = try #require(await store.addTodo("fix typo"))
// Per-item summaries are generated asynchronously (heuristic, no model here).
await waitFor {
store.todos.first { $0.id == long.id }?.summary != nil
&& store.todos.first { $0.id == short.id }?.summary != nil
}
let longTodo = try #require(store.todos.first { $0.id == long.id })
let shortTodo = try #require(store.todos.first { $0.id == short.id })
#expect(longTodo.summary?.isEmpty == false) // long → a distinct summary
#expect(longTodo.summary != longTodo.text)
#expect(shortTodo.summary == "") // short → no distinct summary
// Persisted: a reload keeps the generated summary (no re-summarize needed).
await store.loadTodos()
#expect(store.todos.first { $0.id == long.id }?.summary?.isEmpty == false)
#expect(store.todos.first { $0.id == short.id }?.summary == "")
}
@Test func openTodosSortedOldestFirst() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let clock = LockedBox(Date(timeIntervalSince1970: 1_700_000_000))
let store = AppStore(
database: try! GRDBMetadataStore(path: nil),
worktrees: GitWorktreeManager(),
transcriptsDir: URL(fileURLWithPath: (repo.container as NSString).appendingPathComponent("t")),
now: { let date = clock.get(); clock.set(date.addingTimeInterval(1)); return date }
) { _ in ScriptedBackend { _, _ in } }
let first = try #require(await store.addTodo("first in"))
let second = try #require(await store.addTodo("second in"))
let third = try #require(await store.addTodo("third in"))
// First-in at the top.
#expect(store.openTodos.map(\.id) == [first.id, second.id, third.id])
// Grouping preserves oldest-first within a group.
let project = await store.addProject(name: "p", rootPath: repo.root, defaultBranch: "main")!
await store.updateTodo(third.id, projectID: project.id)
await store.updateTodo(first.id, projectID: project.id)
#expect(store.todoGroups.first?.todos.map(\.id) == [first.id, third.id])
}
@Test func triageRanksEachProjectIndependently() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let store = makeStore(repo: repo)
let alpha = try #require(await store.addProject(
name: "alpha", rootPath: repo.root, defaultBranch: "main"))
// Two buckets: a project, and the unassigned group. Each gets a clearly-urgent and a
// clearly-calm idea.
let urgentA = try #require(await store.addTodo(
"production outage: users hit a crash, data loss", projectID: alpha.id))
let calmA = try #require(await store.addTodo("rename a variable", projectID: alpha.id))
let urgentU = try #require(await store.addTodo(
"production outage: users hit a crash, data loss")) // unassigned
let calmU = try #require(await store.addTodo("rename a variable")) // unassigned
// Each bucket is triaged on its own, so each has its OWN rank-0 top idea — proof the
// passes are per-project, not one global ranking (which would assign four unique
// ranks 0…3 with a single rank-0 across the whole backlog).
await waitFor {
!store.triaging
&& store.triageItem(for: urgentA.id)?.rank == 0
&& store.triageItem(for: urgentU.id)?.rank == 0
}
#expect(store.triageItem(for: calmA.id)?.rank == 1)
#expect(store.triageItem(for: calmU.id)?.rank == 1)
#expect(store.triageLevel(for: urgentA.id) == .critical)
#expect(store.triageLevel(for: urgentU.id) == .critical)
}
@Test func groupGistIsNotReEnqueuedWhileInFlight() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let store = makeStore(repo: repo)
let gist = GatedGistIntelligence()
store.intelligence = gist
let alpha = try #require(await store.addProject(
name: "alpha", rootPath: repo.root, defaultBranch: "main"))
// Add an idea to project alpha: its group-gist pass starts and blocks in flight (the
// gate is never opened until the end), so its summary hasn't landed yet.
_ = try #require(await store.addTodo("refactor the auth module to use tokens", projectID: alpha.id))
await waitFor { gist.startCount(alpha.id.rawValue) == 1 }
// Add an idea to a DIFFERENT group (unassigned). This re-runs regenerateTodoSummaries
// while alpha's gist is still in flight. alpha's ideas are unchanged, so it must NOT
// enqueue a second identical pass; the unassigned group legitimately starts its own.
_ = try #require(await store.addTodo("an unfiled stray idea"))
await waitFor { gist.startCount("") == 1 }
// Let any (buggy) duplicate alpha pass start before asserting it didn't.
try? await Task.sleep(for: .milliseconds(50))
// The fix: alpha's gist ran exactly once despite the second regeneration. Before the
// fix, the in-flight window re-enqueued an identical pass and this was 2 — wasted
// on-device model work and the duplicate history entries the user saw.
#expect(gist.startCount(alpha.id.rawValue) == 1)
await gist.release() // let the in-flight passes finish so nothing dangles
}
@Test func groupGistIgnoresPureTriageReorder() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
// Distinct, increasing timestamps so capture order is deterministic ([a, b]) and the
// triage reorder ([b, a]) is a real flip — not a sort tie on equal `createdAt`.
let tick = LockedBox(0)
let store = makeStore(repo: repo) {
let v = tick.get(); tick.set(v + 1)
return Date(timeIntervalSince1970: 1_700_000_000 + Double(v))
}
let intel = ReorderingGistIntelligence()
store.intelligence = intel
store.enableTriageSort() // display order follows triage rank, so a reorder bites
let alpha = try #require(await store.addProject(
name: "alpha", rootPath: repo.root, defaultBranch: "main"))
// Two ideas in alpha. Each add re-gists the (genuinely changed) set, so alpha's gist
// runs exactly twice — that's the baseline the reorder must not push past.
let a = try #require(await store.addTodo("refactor the auth module", projectID: alpha.id))
let b = try #require(await store.addTodo("rename the session token", projectID: alpha.id))
await waitFor { intel.startCount(alpha.id.rawValue) == 2 }
// Let triage flip alpha's display order to [b, a] (last-captured ranked top).
await waitFor {
store.triageItem(for: b.id)?.rank == 0 && store.triageItem(for: a.id)?.rank == 1
}
// Touch a DIFFERENT group (unassigned). This regenerates every group's gist — alpha's
// ideas are UNCHANGED, only reordered, so it must NOT re-gist.
_ = try #require(await store.addTodo("an unfiled stray idea"))
await waitFor { intel.startCount("") == 1 }
try? await Task.sleep(for: .milliseconds(50)) // let any (buggy) reorder re-gist start
// The fix: the de-dup fingerprint is order-independent, so the reorder didn't re-fire
// alpha's gist. Before the fix, hashing the reordered texts made this 3 — wasted
// on-device model work on an idea set that never changed.
#expect(intel.startCount(alpha.id.rawValue) == 2)
}
@Test func triageReordersOpenTodosByImpactAndTogglesBack() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let clock = LockedBox(Date(timeIntervalSince1970: 1_700_000_000))
let store = AppStore(
database: try! GRDBMetadataStore(path: nil),
worktrees: GitWorktreeManager(),
transcriptsDir: URL(fileURLWithPath: (repo.container as NSString).appendingPathComponent("t")),
now: { let date = clock.get(); clock.set(date.addingTimeInterval(1)); return date }
) { _ in ScriptedBackend { _, _ in } }
let calm = try #require(await store.addTodo("rename a variable"))
let urgent = try #require(await store.addTodo("production outage: users hit a crash, data loss"))
let impactful = try #require(await store.addTodo("refactor the billing architecture for scale"))
// FIFO and unsorted until the user flips on impact order.
#expect(store.openTodos.map(\.id) == [calm.id, urgent.id, impactful.id])
#expect(store.triageSortEnabled == false)
// Triage runs in the background on add (default HeuristicIntelligence) and is
// stored on each idea — no pass on the button click. Wait for the final pass
// (over all three) to settle before asserting the steady-state ranking.
await waitFor { !store.triaging && store.triageLevel(for: calm.id) == .low }
// Flipping on impact order just re-sorts using the stored verdicts.
store.enableTriageSort()
#expect(store.triageSortEnabled)
// Critical/urgent floats to the top; the calm rename sinks to the bottom.
#expect(store.openTodos.first?.id == urgent.id)
#expect(store.openTodos.last?.id == calm.id)
#expect(store.triageLevel(for: urgent.id) == .critical)
#expect(store.triageLevel(for: calm.id) == .low)
// Toggling triage off restores FIFO (verdicts are kept around).
store.clearTriageSort()
#expect(store.openTodos.map(\.id) == [calm.id, urgent.id, impactful.id])
#expect(store.triageLevel(for: urgent.id) == .critical)
// The stored verdicts survive a reload.
await store.loadTodos()
#expect(store.triageLevel(for: urgent.id) == .critical)
#expect(store.triageLevel(for: calm.id) == .low)
}
@Test func editingTodoTextRegeneratesSummary() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let store = makeStore(repo: repo)
let todo = try #require(await store.addTodo(
"draft a long detailed plan for migrating the database to the new schema safely"))
await waitFor { store.todo(todo.id)?.summary?.isEmpty == false }
let original = try #require(store.todo(todo.id)?.summary)
// Editing the text updates it and regenerates the summary from the new text.
await store.updateTodo(todo.id, text:
"investigate and fix the flaky integration tests that fail intermittently on CI")
#expect(store.todo(todo.id)?.text.contains("flaky") == true)
await waitFor { store.todo(todo.id)?.summary?.isEmpty == false }
#expect(store.todo(todo.id)?.summary != original)
}
@Test func quickTodoSeedsCurrentProjectContext() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let store = makeStore(repo: repo)
let project = await store.addProject(name: "alpha", rootPath: repo.root, defaultBranch: "main")!
// On the home dashboard there is no context — a plain global capture.
store.presentQuickTodo()
#expect(store.quickTodoPresented)
#expect(store.quickTodoProjectID == nil)
// Inside a session, Cmd-T pre-tags that session's project.
let sessionID = try await store.createSession(in: project, title: "work", prompt: "go")
store.openSessionID = sessionID
await waitFor { store.openSession?.projectID == project.id }
store.presentQuickTodo()
#expect(store.quickTodoProjectID == project.id)
// On the project overview page, the same project is seeded.
store.openProject(project.id)
store.presentQuickTodo()
#expect(store.quickTodoProjectID == project.id)
// An explicit unfiled capture still wins.
store.presentQuickTodo(projectID: nil)
#expect(store.quickTodoProjectID == nil)
}
@Test func moveTodoReassignsProject() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let store = makeStore(repo: repo)
let project = await store.addProject(name: "alpha", rootPath: repo.root, defaultBranch: "main")!
let todo = try #require(await store.addTodo("misfiled idea")) // unassigned
#expect(store.todoGroups.first?.projectID == nil)
// Unassigned → project.
await store.moveTodo(todo.id, toProject: project.id)
#expect(store.todos.first { $0.id == todo.id }?.projectID == project.id)
#expect(store.todoGroups.count == 1)
#expect(store.todoGroups.first?.projectID == project.id)
// Project → unassigned (unfiled).
await store.moveTodo(todo.id, toProject: nil)
#expect(store.todos.first { $0.id == todo.id }?.projectID == nil)
#expect(store.todoGroups.first?.projectID == nil)
// Persisted across a reload.
await store.loadTodos()
#expect(store.todos.first { $0.id == todo.id }?.projectID == nil)
}
@Test func todosGroupByProjectWithSummaries() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let store = makeStore(repo: repo)
let project = await store.addProject(name: "alpha", rootPath: repo.root, defaultBranch: "main")!
await store.addTodo("refactor the auth module to use tokens", projectID: project.id)
await store.addTodo("add a dark mode toggle to settings", projectID: project.id)
await store.addTodo("an unfiled stray idea") // unassigned
let groups = store.todoGroups
#expect(groups.count == 2)
// Project group first, unassigned bucket last.
#expect(groups.first?.projectID == project.id)
#expect(groups.first?.projectName == "alpha")
#expect(groups.first?.todos.count == 2)
#expect(groups.last?.projectID == nil)
#expect(groups.last?.projectName == "Unassigned")
#expect(groups.last?.todos.count == 1)
// Summaries land asynchronously (heuristic gist when no model is configured).
await waitFor { store.todoGroups.allSatisfy { !$0.summary.isEmpty } }
#expect(store.todoGroups.first?.summary.contains("2 ideas") == true)
// A single-idea group shows the idea itself, not an "N ideas:" rollup.
#expect(store.todoGroups.last?.summary.contains("ideas:") == false)
}
@Test func deletingProjectUnassignsItsTodos() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let store = makeStore(repo: repo)
let project = await store.addProject(name: "alpha", rootPath: repo.root, defaultBranch: "main")!
let todo = try #require(await store.addTodo("keep this idea", projectID: project.id))
#expect(store.todoGroups.first?.projectID == project.id)
await store.deleteProject(project.id)
// The idea survives the project's deletion, now unassigned.
#expect(store.todos.first { $0.id == todo.id }?.projectID == nil)
#expect(store.todoGroups.count == 1)
#expect(store.todoGroups.first?.projectID == nil)
#expect(store.todoGroups.first?.projectName == "Unassigned")
}
@Test func integrateOpenSessionLandsToMain() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let store = makeStore(repo: repo)
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
let sessionID = try await store.createSession(in: project, title: "ship it", prompt: "go")
store.openSessionID = sessionID
await waitFor { store.summaries.first?.status == .awaitingInput }
let result = await store.integrateOpenSession(strategy: .squash)
guard case .clean = result else {
Issue.record("expected clean integration, got \(String(describing: result))")
return
}
#expect(repo.read("made.txt") == "by agent\n")
}
@Test func waitForAccessGrantsOnceConflictingWorkMerges() async throws {
let repo = try await GitTestRepo(controlled: true) // file locking applies only to Nucleic Control
defer { repo.cleanup() }
let store = makeStore(repo: repo)
store.lockReconcileInterval = .milliseconds(20) // detect landed/lifecycle promptly
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
// `doer` writes made.txt and acquires its lock; `asker` wants the same file.
let doer = try await store.createSession(in: project, title: "doer", prompt: "go")
let asker = try await store.createSession(in: project, title: "asker", prompt: "go")
await waitFor {
store.summaries.first { $0.id == doer }?.status == .awaitingInput
&& store.summaries.first { $0.id == asker }?.status == .awaitingInput
}
#expect(await store.arbitrate(sessionID: doer, task: "edit made", files: ["made.txt"]).resolution == .proceed)
// The asker's check_conflict auto-queues: made.txt is held by the doer.
let arbitration = Task { await store.arbitrate(sessionID: asker, task: "edit made", files: ["made.txt"]) }
await waitFor { store.sessionsWaitingForAccess.contains(asker) }
#expect(store.sessionsWaitingForAccess.contains(asker))
// The doer merges into its parent, releasing its lock; the waiter is then granted.
store.openSessionID = doer
guard case .clean = await store.integrateOpenSession(strategy: .squash) else {
Issue.record("expected the doer to integrate cleanly")
return
}
#expect(await arbitration.value.resolution == .proceed)
await waitFor { store.sessionsWaitingForAccess.isEmpty }
#expect(store.sessionsWaitingForAccess.isEmpty)
}
@Test func forceReleaseGrantsWaiterWithoutMerging() async throws {
let repo = try await GitTestRepo(controlled: true) // file locking applies only to Nucleic Control
defer { repo.cleanup() }
let store = makeStore(repo: repo)
store.lockReconcileInterval = .milliseconds(20)
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
// `doer` writes made.txt and acquires its lock; `asker` wants the same file.
let doer = try await store.createSession(in: project, title: "doer", prompt: "go")
let asker = try await store.createSession(in: project, title: "asker", prompt: "go")
await waitFor {
store.summaries.first { $0.id == doer }?.status == .awaitingInput
&& store.summaries.first { $0.id == asker }?.status == .awaitingInput
}
#expect(await store.arbitrate(sessionID: doer, task: "edit made", files: ["made.txt"]).resolution == .proceed)
// The asker's check_conflict auto-queues behind the held lock.
let arbitration = Task { await store.arbitrate(sessionID: asker, task: "edit made", files: ["made.txt"]) }
await waitFor { store.sessionsWaitingForAccess.contains(asker) }
// The doer's lock persists (no merge). Force-releasing it grants the waiter.
await store.forceReleaseLocks(doer)
#expect(await arbitration.value.resolution == .proceed)
await waitFor { store.sessionsWaitingForAccess.isEmpty }
#expect(store.sessionsWaitingForAccess.isEmpty)
}
@Test func lockQueueSnapshotReportsHoldersAndWaiters() async throws {
let repo = try await GitTestRepo(controlled: true) // file locking applies only to Nucleic Control
defer { repo.cleanup() }
let store = makeStore(repo: repo)
store.lockReconcileInterval = .milliseconds(20)
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
let doer = try await store.createSession(in: project, title: "doer", prompt: "go")
let asker = try await store.createSession(in: project, title: "asker", prompt: "go")
await waitFor {
store.summaries.first { $0.id == doer }?.status == .awaitingInput
&& store.summaries.first { $0.id == asker }?.status == .awaitingInput
}
// The doer acquires made.txt; the snapshot shows it as the holder, no waiters yet.
#expect(await store.arbitrate(sessionID: doer, task: "edit made", files: ["made.txt"]).resolution == .proceed)
let held = await store.lockQueueSnapshot()
let madeHeld = held.files.first { $0.path == "made.txt" }
#expect(madeHeld?.holders.contains { $0.sessionID == doer } == true)
#expect(madeHeld?.waiters.isEmpty == true)
#expect(held.waiters.isEmpty)
// The asker auto-queues behind the held lock.
let arbitration = Task { await store.arbitrate(sessionID: asker, task: "edit made", files: ["made.txt"]) }
await waitFor { store.sessionsWaitingForAccess.contains(asker) }
// Now the snapshot shows the doer holding made.txt and the asker waiting on it.
let queued = await store.lockQueueSnapshot()
let madeQueued = try #require(queued.files.first { $0.path == "made.txt" })
#expect(madeQueued.holders.contains { $0.sessionID == doer })
#expect(madeQueued.waiters.contains { $0.sessionID == asker })
let waiter = try #require(queued.waiters.first { $0.sessionID == asker })
#expect(waiter.wantedFiles.contains("made.txt"))
#expect(waiter.blockedBy.contains { $0.sessionID == doer })
// Clean up the blocked arbitration so the task doesn't outlive the test.
await store.forceReleaseLocks(doer)
_ = await arbitration.value
}
@Test func archivingReleasesLocksAndGrantsWaiter() async throws {
let repo = try await GitTestRepo(controlled: true) // file locking applies only to Nucleic Control
defer { repo.cleanup() }
let store = makeStore(repo: repo)
store.lockReconcileInterval = .milliseconds(20) // the reconcile poll releases archived holders
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
let doer = try await store.createSession(in: project, title: "doer", prompt: "go")
let asker = try await store.createSession(in: project, title: "asker", prompt: "go")
await waitFor {
store.summaries.first { $0.id == doer }?.status == .awaitingInput
&& store.summaries.first { $0.id == asker }?.status == .awaitingInput
}
#expect(await store.arbitrate(sessionID: doer, task: "edit made", files: ["made.txt"]).resolution == .proceed)
// The asker's check_conflict auto-queues behind the held lock.
let arbitration = Task { await store.arbitrate(sessionID: asker, task: "edit made", files: ["made.txt"]) }
await waitFor { store.sessionsWaitingForAccess.contains(asker) }
// Archiving the doer releases its lock (via the reconcile poll), granting the waiter.
await store.setSessionArchived(doer, true)
#expect(await arbitration.value.resolution == .proceed)
await waitFor { store.sessionsWaitingForAccess.isEmpty }
#expect(store.sessionsWaitingForAccess.isEmpty)
}
/// The headline fix: a lock releases when its file lands in the parent by ANY means — not
/// just a Nucleic-mediated merge. Here the doer's change stops differing from the parent (as
/// an agent's own `git merge` would), and the reconcile poll detects it and grants the waiter
/// without Nucleic performing any merge.
@Test func reconcilePollReleasesWhenWorkLandsOutsideNucleic() async throws {
let repo = try await GitTestRepo(controlled: true) // file locking applies only to Nucleic Control
defer { repo.cleanup() }
let store = makeStore(repo: repo)
store.lockReconcileInterval = .milliseconds(20)
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
let doer = try await store.createSession(in: project, title: "doer", prompt: "go")
let asker = try await store.createSession(in: project, title: "asker", prompt: "go")
await waitFor {
store.summaries.first { $0.id == doer }?.status == .awaitingInput
&& store.summaries.first { $0.id == asker }?.status == .awaitingInput
}
#expect(await store.arbitrate(sessionID: doer, task: "edit made", files: ["made.txt"]).resolution == .proceed)
let arbitration = Task { await store.arbitrate(sessionID: asker, task: "edit made", files: ["made.txt"]) }
await waitFor { store.sessionsWaitingForAccess.contains(asker) }
// The doer's made.txt stops differing from the parent (its work "landed") — no Nucleic merge.
store.openSessionID = doer
await waitFor { store.openSession?.id == doer }
let worktree = try #require(store.openSession?.worktreePath)
try FileManager.default.removeItem(atPath: (worktree as NSString).appendingPathComponent("made.txt"))
// The reconcile poll detects the file landed and grants the waiter — no mediated merge ran.
#expect(await arbitration.value.resolution == .proceed)
await waitFor { store.sessionsWaitingForAccess.isEmpty }
#expect(store.sessionsWaitingForAccess.isEmpty)
}
@Test func lockingIsScopedToNucleicControl() async throws {
// File locking now applies ONLY to Nucleic Control projects (that's the only place we
// can reliably intercept `git merge` to unlock). A non-control project's arbitration
// fails open and acquires nothing.
let repo = try await GitTestRepo() // NOT controlled
defer { repo.cleanup() }
let store = makeStore(repo: repo)
let project = await store.addProject(name: "plain", rootPath: repo.root, defaultBranch: "main")!
#expect(!project.isNucleicControlled)
let s = try await store.createSession(in: project, title: "s", prompt: "go")
await waitFor { store.summaries.first { $0.id == s }?.status == .awaitingInput }
#expect(await store.arbitrate(sessionID: s, task: "edit made", files: ["made.txt"]).resolution == .noConflict)
#expect(await store.lockQueueSnapshot().files.isEmpty)
}
@Test func observeGitOpReleasesLockOnObservedMerge() async throws {
// The interceptor's certain `git merge` signal (`observeGitOp`) releases a holder's
// landed locks immediately, without the background poll. The poll is parked far out, so
// this isolates the interceptor-driven release.
let repo = try await GitTestRepo(controlled: true)
defer { repo.cleanup() }
let store = makeStore(repo: repo)
store.lockReconcileInterval = .seconds(3600) // effectively disable the periodic poll
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
let doer = try await store.createSession(in: project, title: "doer", prompt: "go")
let asker = try await store.createSession(in: project, title: "asker", prompt: "go")
await waitFor {
store.summaries.first { $0.id == doer }?.status == .awaitingInput
&& store.summaries.first { $0.id == asker }?.status == .awaitingInput
}
#expect(await store.arbitrate(sessionID: doer, task: "edit made", files: ["made.txt"]).resolution == .proceed)
let arbitration = Task { await store.arbitrate(sessionID: asker, task: "edit made", files: ["made.txt"]) }
await waitFor { store.sessionsWaitingForAccess.contains(asker) }
// doer's made.txt stops differing from the parent (its work "landed", agent-merge style).
store.openSessionID = doer
await waitFor { store.openSession?.id == doer }
let worktree = try #require(store.openSession?.worktreePath)
try FileManager.default.removeItem(atPath: (worktree as NSString).appendingPathComponent("made.txt"))
// The interceptor reports the agent's merge → the held-but-landed lock releases at once,
// granting the waiter — the poll never fired.
await store.observeGitOp(sessionID: doer, argv: ["merge", "made"], exitCode: 0)
#expect(await arbitration.value.resolution == .proceed)
#expect(store.sessionsWaitingForAccess.isEmpty)
// `doer` never armed autoship, so an ordinary agent `git merge` must NOT conjure an
// autoship ship status (which would put it in the Control panel's autoship list as
// "Shipped"). The recovery write only applies to sessions participating in autoship.
#expect(store.shipStatuses[doer] == nil)
}
@Test func agentMergeDoesNotDoublePostShippedNote() async throws {
// Once a session's work is shipped (here by the autoship merge queue), a follow-up
// agent sync-merge observed in-container must NOT re-post a duplicate "merged" note —
// the queue ship and the interceptor share one dedup latch, cleared only by new edits.
let repo = try await GitTestRepo(controlled: true) // autoship + locking need Control
defer { repo.cleanup() }
let worktrees = GitWorktreeManager(now: { Date(timeIntervalSince1970: 1_700_000_000) })
let transcriptsDir = URL(fileURLWithPath: (repo.container as NSString)
.appendingPathComponent("transcripts"))
let store = AppStore(
database: try GRDBMetadataStore(path: nil), worktrees: worktrees,
transcriptsDir: transcriptsDir,
now: { Date(timeIntervalSince1970: 1_700_000_000) }
) { session in
let wtPath = session.worktreePath ?? ""
let name = session.title.replacingOccurrences(of: " ", with: "-") + ".txt"
return ScriptedBackend { e, _ in
e.emit(.sessionStarted(SessionStarted(
backendSessionID: "be", model: "m", cwd: wtPath, toolNames: ["Write"])))
try? "by agent\n".write(
toFile: (wtPath as NSString).appendingPathComponent(name),
atomically: true, encoding: .utf8)
e.emit(.fileChange(FileChange(path: name, kind: .add)))
e.emit(.assistantText(TextChunk(messageID: "a",
text: "Fixed the bug and added a test. All tests pass.", isPartial: false)))
e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
e.emit(.runFinished(RunFinished(outcome: .completed)))
}
}
await store.activateConflictArbitration() // wires the autoship merge queue
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
let landed = try await store.createSession(
in: project, title: "landed", prompt: "go", useWorktree: true, autoShip: true)
store.openSessionID = landed
await store.awaitOpenSessionSettled()
await waitFor({
self.autoshipNotes(store.openTranscript).contains { $0.contains("merged into main") }
}, timeoutMs: 8000)
let mergedNotes = { self.autoshipNotes(store.openTranscript).filter { $0.contains("merged into") }.count }
#expect(mergedNotes() == 1)
// A follow-up agent sync-merge (no new edits) must not add a second shipped note.
await store.observeGitOp(sessionID: landed, argv: ["merge", "main"], exitCode: 0)
await store.observeGitOp(sessionID: landed, argv: ["merge", "main"], exitCode: 0)
#expect(mergedNotes() == 1)
// The ship status stays `.merged` for this armed session — the agent-merge recovery
// reflects a landing without clobbering the queue's existing `.merged`.
guard case .merged = store.shipStatuses[landed] else {
Issue.record("expected shipStatuses[landed] == .merged, got \(String(describing: store.shipStatuses[landed]))")
return
}
}
@Test func autoshipRowRevertsToArmedOnNewWorkThenRetiresAfterTTL() async throws {
// The Control panel's Autoship row is event-driven: it shows the recorded ship status
// directly. After a ship the row reads "Shipped"; it reverts to "Armed" only when the agent
// starts genuinely new, not-yet-landed work (an `arbitrate` grant clears the settled
// status), then returns to "Shipped" when that work lands. Finally, a settled "Shipped" row
// retires from the section once it has lingered past `autoshipShippedTTL`, so the list can't
// grow without bound. (This replaces the old poll-derived demotion off hasTurnInFlight/held
// locks, whose volatile sampling left rows inconsistently stuck between "Armed" and
// "Shipped" — the reported bug.)
let repo = try await GitTestRepo(controlled: true) // autoship + locking need Control
defer { repo.cleanup() }
let clock = LockedBox(Date(timeIntervalSince1970: 1_700_000_000)) // drives the retire TTL
let worktrees = GitWorktreeManager(now: { clock.get() })
let transcriptsDir = URL(fileURLWithPath: (repo.container as NSString)
.appendingPathComponent("transcripts"))
let store = AppStore(
database: try GRDBMetadataStore(path: nil), worktrees: worktrees,
transcriptsDir: transcriptsDir, now: { clock.get() }
) { session in
let wtPath = session.worktreePath ?? ""
return ScriptedBackend(multiTurn: { e, _, _ in
// One shipping turn: write work and let the merge queue ship it → row "Shipped".
e.emit(.sessionStarted(SessionStarted(
backendSessionID: "be", model: "m", cwd: wtPath, toolNames: ["Write"])))
try? "by agent\n".write(
toFile: (wtPath as NSString).appendingPathComponent("made.txt"),
atomically: true, encoding: .utf8)
e.emit(.fileChange(FileChange(path: "made.txt", kind: .add)))
e.emit(.assistantText(TextChunk(messageID: "a",
text: "Fixed the bug and added a test. All tests pass.", isPartial: false)))
e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
e.emit(.runFinished(RunFinished(outcome: .completed)))
})
}
store.autoshipShippedTTL = 300 // advance the test clock past this to retire the row
await store.activateConflictArbitration() // wires the autoship merge queue
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
let landed = try await store.createSession(
in: project, title: "landed", prompt: "go", useWorktree: true, autoShip: true)
store.openSessionID = landed
await store.awaitOpenSessionSettled()
func row() async -> AutoshipEntry? {
await store.controlSnapshot().autoship.first { $0.id == landed }
}
func isShipped() async -> Bool {
if case .merged = (await row())?.status { return true } else { return false }
}
// "Armed" = the session is still in the section (autoship armed) but with no live status.
func isArmed() async -> Bool {
guard let r = await row() else { return false }
return r.status == nil
}
// Turn 1 ships → the row settles on "Shipped".
await waitForAsync({ await isShipped() }, timeoutMs: 8000)
#expect(await isShipped())
// The agent starts genuinely new work: it declares a file via `arbitrate`, which clears the
// settled ship status. The row reverts to "Armed" (and the new file's lock is held).
let worktree = try #require(store.openSession?.worktreePath)
try "more\n".write(
toFile: (worktree as NSString).appendingPathComponent("made2.txt"),
atomically: true, encoding: .utf8)
#expect(await store.arbitrate(
sessionID: landed, task: "more", files: ["made2.txt"]).resolution == .proceed)
#expect(await isArmed())
#expect(!(await store.lockQueueSnapshot().files.isEmpty)) // armed because new work is locked
// The new work lands (footprint empties, lock releases) → the row returns to "Shipped".
try FileManager.default.removeItem(
atPath: (worktree as NSString).appendingPathComponent("made2.txt"))
await store.observeGitOp(sessionID: landed, argv: ["merge", "main"], exitCode: 0)
await waitForAsync({ await isShipped() }, timeoutMs: 4000)
#expect(await isShipped())
// Past the TTL, the settled "Shipped" row retires from the section so it can't accumulate.
clock.set(clock.get().addingTimeInterval(store.autoshipShippedTTL + 1))
#expect(await row() == nil)
}
@Test func observeGitOpRecordsInterceptorEventForControlSessions() async throws {
// Every mutating op the interceptor reports for a Control session lands in the activity
// feed, newest-first, including non-zero exits (a failed op is itself worth surfacing).
let repo = try await GitTestRepo(controlled: true)
defer { repo.cleanup() }
let store = makeStore(repo: repo)
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
let s = try await store.createSession(in: project, title: "doer", prompt: "go")
await waitFor { store.summaries.first { $0.id == s }?.status == .awaitingInput }
await store.observeGitOp(sessionID: s, argv: ["commit", "-m", "Fix the bug"], exitCode: 0)
#expect(store.gitInterceptorEvents.count == 1)
let first = try #require(store.gitInterceptorEvents.first)
#expect(first.kind == "commit")
#expect(first.sessionID == s)
#expect(first.sessionTitle == "doer")
#expect(first.succeeded)
// A failed op is recorded too, and ends up newest-first ahead of the commit.
await store.observeGitOp(sessionID: s, argv: ["merge", "feature"], exitCode: 1)
#expect(store.gitInterceptorEvents.count == 2)
#expect(store.gitInterceptorEvents.first?.kind == "merge")
#expect(store.gitInterceptorEvents.first?.succeeded == false)
// A read-only op the shim wouldn't normally report is not fed even if it arrives.
await store.observeGitOp(sessionID: s, argv: ["status"], exitCode: 0)
#expect(store.gitInterceptorEvents.count == 2)
}
@Test func observeGitOpIgnoresNonControlSessions() async throws {
// Locking + the activity feed are Control-only: a non-control session's git ops are
// dropped entirely (the interceptor isn't even installed in non-control containers).
let repo = try await GitTestRepo() // not a Nucleic Control repo
defer { repo.cleanup() }
let store = makeStore(repo: repo)
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
let s = try await store.createSession(in: project, title: "doer", prompt: "go")
await waitFor { store.summaries.first { $0.id == s }?.status == .awaitingInput }
await store.observeGitOp(sessionID: s, argv: ["commit", "-m", "x"], exitCode: 0)
#expect(store.gitInterceptorEvents.isEmpty)
}
@Test func observeGhOpRecordsAgenticActionsForControlSessions() async throws {
// The `gh` interceptor feeds the same Control activity feed as `git`, tagged `.gh`:
// agentic GitHub actions (open/merge a PR) are recorded; reads (pr view) are dropped.
let repo = try await GitTestRepo(controlled: true)
defer { repo.cleanup() }
let store = makeStore(repo: repo)
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
let s = try await store.createSession(in: project, title: "doer", prompt: "go")
await waitFor { store.summaries.first { $0.id == s }?.status == .awaitingInput }
await store.observeGhOp(
sessionID: s, argv: ["pr", "create", "--title", "Fix the bug"], exitCode: 0)
#expect(store.gitInterceptorEvents.count == 1)
let first = try #require(store.gitInterceptorEvents.first)
#expect(first.tool == .gh)
#expect(first.kind == "pr.create")
#expect(first.label == "Open PR: Fix the bug")
#expect(first.succeeded)
// A merge is flagged as a merge landing and is newest-first.
await store.observeGhOp(sessionID: s, argv: ["pr", "merge", "42"], exitCode: 0)
#expect(store.gitInterceptorEvents.first?.kind == "pr.merge")
#expect(store.gitInterceptorEvents.first?.isMerge == true)
#expect(store.gitInterceptorEvents.count == 2)
// A read (pr view) classifies to nil and never enters the feed.
await store.observeGhOp(sessionID: s, argv: ["pr", "view", "42"], exitCode: 0)
#expect(store.gitInterceptorEvents.count == 2)
}
@Test func observeCommandRecordsClassifiedEventsForControlSessions() async throws {
// Every non-git command the interceptor reports for a Control session lands in the command
// feed, classified (label/category/symbol), newest-first, with output preview + duration.
let repo = try await GitTestRepo(controlled: true)
defer { repo.cleanup() }
let store = makeStore(repo: repo)
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
let s = try await store.createSession(in: project, title: "doer", prompt: "go")
await waitFor { store.summaries.first { $0.id == s }?.status == .awaitingInput }
// A rich shim report (argv + captured output).
await store.observeCommand(sessionID: s, call: Self.shimCall(
command: "grep", argv: ["grep", "-rn", "TODO", "src"], exitCode: 0,
durationMs: 12, stdout: "src/a.swift:3:// TODO\nsrc/b.swift:9:// TODO\n"))
#expect(store.commandInterceptorEvents.count == 1)
let first = try #require(store.commandInterceptorEvents.first)
#expect(first.command == "grep")
#expect(first.category == "search")
#expect(first.label == #"Search for "TODO""#)
#expect(first.sessionID == s)
#expect(first.sessionTitle == "doer")
#expect(first.durationMs == 12)
#expect(first.outputPreview == "src/a.swift:3:// TODO")
#expect(first.source == .shim)
#expect(first.succeeded)
// A failing tracer report (metadata only) ends up newest-first.
await store.observeCommand(sessionID: s, call: Self.tracerCall(
commandLine: "make build", cwd: "/repo", exitCode: 2, durationMs: 1500))
#expect(store.commandInterceptorEvents.count == 2)
#expect(store.commandInterceptorEvents.first?.command == "make")
#expect(store.commandInterceptorEvents.first?.category == "build")
#expect(store.commandInterceptorEvents.first?.succeeded == false)
#expect(store.commandInterceptorEvents.first?.source == .tracer)
}
@Test func observeGhOpIgnoresNonControlSessions() async throws {
let repo = try await GitTestRepo() // not a Nucleic Control repo
defer { repo.cleanup() }
let store = makeStore(repo: repo)
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
let s = try await store.createSession(in: project, title: "doer", prompt: "go")
await waitFor { store.summaries.first { $0.id == s }?.status == .awaitingInput }
await store.observeGhOp(sessionID: s, argv: ["pr", "create", "--title", "x"], exitCode: 0)
#expect(store.gitInterceptorEvents.isEmpty)
}
@Test func observeCommandIgnoresNonControlSessions() async throws {
// The command feed is Control-only, like the git feed.
let repo = try await GitTestRepo() // not a Nucleic Control repo
defer { repo.cleanup() }
let store = makeStore(repo: repo)
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
let s = try await store.createSession(in: project, title: "doer", prompt: "go")
await waitFor { store.summaries.first { $0.id == s }?.status == .awaitingInput }
await store.observeCommand(sessionID: s, call: Self.shimCall(
command: "cat", argv: ["cat", "f.txt"], exitCode: 0, durationMs: 1, stdout: "hi"))
#expect(store.commandInterceptorEvents.isEmpty)
}
private static func shimCall(
command: String, argv: [String], exitCode: Int, durationMs: Int, stdout: String
) -> MCPApprovalServer.CommandReportCall {
MCPApprovalServer.CommandReportCall(
command: command, argv: argv, commandLine: nil, cwd: "/repo", exitCode: exitCode,
durationMs: durationMs, stdout: stdout, stderr: "", truncated: false,
source: .shim, sessionID: nil)
}
private static func tracerCall(
commandLine: String, cwd: String, exitCode: Int, durationMs: Int
) -> MCPApprovalServer.CommandReportCall {
MCPApprovalServer.CommandReportCall(
command: nil, argv: [], commandLine: commandLine, cwd: cwd, exitCode: exitCode,
durationMs: durationMs, stdout: "", stderr: "", truncated: false,
source: .tracer, sessionID: nil)
}
@Test func controlSnapshotAggregatesControlState() async throws {
// The Control panel's one aggregator call gathers container counts, autoship entries, and
// the activity feed for the Nucleic Control subsystem.
let repo = try await GitTestRepo(controlled: true)
defer { repo.cleanup() }
let store = makeStore(repo: repo)
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
let s = try await store.createSession(
in: project, title: "ship me", prompt: "go", useWorktree: true, autoShip: true)
await waitFor { store.summaries.first { $0.id == s }?.status == .awaitingInput }
await store.observeGitOp(sessionID: s, argv: ["commit", "-m", "work"], exitCode: 0)
let snap = await store.controlSnapshot()
// No container manager is wired in tests, so the runtime probe is skipped (all false),
// but the session/project counts come from the store's own state.
#expect(snap.container.controlProjects == 1)
#expect(snap.container.activeSessions >= 1)
#expect(!snap.container.running)
// The autoship-armed session appears (membership holds whether armed or already acted on).
#expect(snap.autoship.contains { $0.id == s })
// The observed commit is in the activity feed.
#expect(snap.activity.contains { $0.kind == "commit" && $0.sessionID == s })
}
/// Liveness regression (LOCKING §4.4): the release-reconcile driver must keep detecting
/// landings across the lock system going fully idle. A lone holder (no waiter forcing the loop
/// alive) acquires, its work lands by an agent-style merge, and the driver releases it with an
/// empty queue — then, *after* the driver has parked idle, a second session repeats the cycle
/// and is released too. The previous self-stopping loop decided to stop from a stale snapshot
/// and could terminate holding a lock no one was watching for its landing, stranding it until a
/// mediated merge or manual release; this proves the driver re-wakes on the next acquire.
@Test func detectedReleaseSurvivesTheLockSystemGoingIdle() async throws {
let repo = try await GitTestRepo(controlled: true) // file locking applies only to Nucleic Control
defer { repo.cleanup() }
let store = makeStore(repo: repo)
store.lockReconcileInterval = .milliseconds(20)
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
// One full acquire → land → detected-release cycle for a lone holder (no contention).
func landAndExpectRelease(_ title: String) async throws {
let s = try await store.createSession(in: project, title: title, prompt: "go")
await waitFor { store.summaries.first { $0.id == s }?.status == .awaitingInput }
#expect(await store.arbitrate(sessionID: s, task: "edit made", files: ["made.txt"]).resolution == .proceed)
// It holds made.txt and nobody is queued behind it.
await waitForAsync { await store.lockQueueSnapshot().files.contains { $0.path == "made.txt" } }
// made.txt stops differing from the parent (its work "landed", agent-merge style).
store.openSessionID = s
await waitFor { store.openSession?.id == s }
let worktree = try #require(store.openSession?.worktreePath)
try FileManager.default.removeItem(
atPath: (worktree as NSString).appendingPathComponent("made.txt"))
// The driver detects the landing and releases — with an empty queue — then parks idle.
await waitForAsync { await store.lockQueueSnapshot().files.isEmpty }
#expect(await store.lockQueueSnapshot().files.isEmpty)
}
try await landAndExpectRelease("first") // round 1: driver starts, releases, parks idle
try await landAndExpectRelease("second") // round 2 (post-idle): driver must re-wake & release
}
/// The reported leak (LOCKING §4.4): a *completed* chat (awaitingInput + disposition
/// `.completed`, so not terminal/archived) kept locks on files it declared via `check_conflict`
/// but whose work had already landed — they released only via the background poll or a manual
/// click. Completion now releases the landed ones immediately, while in-flight unmerged work
/// stays locked until it merges. The poll is disabled here so this exercises *only* the
/// completion-release path.
@Test func completedChatReleasesLandedLocksButKeepsUnmergedWork() async throws {
let repo = try await GitTestRepo(controlled: true) // file locking applies only to Nucleic Control
defer { repo.cleanup() }
let store = makeStore(repo: repo)
store.lockReconcileInterval = .seconds(3600) // isolate completion-release from the poll
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
let s = try await store.createSession(in: project, title: "doer", prompt: "go")
await waitFor { store.summaries.first { $0.id == s }?.status == .awaitingInput }
// The chat declared two files: made.txt (which the scripted agent actually wrote → differs
// from the parent → unmerged) and phantom.txt (never created → matches the parent → landed).
#expect(await store.arbitrate(
sessionID: s, task: "edit", files: ["made.txt", "phantom.txt"]).resolution == .proceed)
await waitForAsync { await store.lockQueueSnapshot().files.count == 2 }
// Completion releases the landed lock immediately; the unmerged in-flight file is retained
// (it will release when it merges — never on a bare commit, LOCKING invariant 4).
await store.releaseCompletedSessionLocks(s)
await waitForAsync { await store.lockQueueSnapshot().files.map(\.path) == ["made.txt"] }
let snap = await store.lockQueueSnapshot()
#expect(snap.files.map(\.path) == ["made.txt"]) // unmerged work kept
#expect(!snap.files.contains { $0.path == "phantom.txt" }) // landed lock released
}
/// Regression: completion-release must fail CLOSED. The landed set is `held unmerged`, so a
/// git failure computing `unmergedFiles` (here: the parent ref it diffs against is gone) would,
/// under a `try? … ?? []`, read as "nothing unmerged → everything landed" and dump every lock
/// on work that never merged — defeating the lock. It must instead keep all locks held.
@Test func completionKeepsAllLocksWhenLandedCheckFails() async throws {
let repo = try await GitTestRepo(controlled: true) // file locking applies only to Nucleic Control
defer { repo.cleanup() }
let store = makeStore(repo: repo)
store.lockReconcileInterval = .seconds(3600) // isolate completion-release from the poll
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
let s = try await store.createSession(in: project, title: "doer", prompt: "go")
await waitFor { store.summaries.first { $0.id == s }?.status == .awaitingInput }
#expect(await store.arbitrate(
sessionID: s, task: "edit", files: ["made.txt", "phantom.txt"]).resolution == .proceed)
await waitForAsync { await store.lockQueueSnapshot().files.count == 2 }
// Break the parent ref the landed-check diffs against (parentRef = "main") so
// `unmergedFiles()` throws. Free `main` from the main checkout first, then delete it.
try await repo.run(["checkout", "-b", "parking"], in: repo.root)
try await repo.run(["branch", "-D", "main"], in: repo.root)
// Can't verify what landed → keep ALL locks (fail closed), release nothing.
await store.releaseCompletedSessionLocks(s)
let snap = await store.lockQueueSnapshot()
#expect(Set(snap.files.map(\.path)) == ["made.txt", "phantom.txt"])
}
/// Persistence (LOCKING §6): on launch, held locks are rebuilt from git — a session that
/// edited a file but never merged it still holds it, so a fresh session contends.
@Test func reconstructLocksRestoresHeldFromGitOnLaunch() async throws {
let repo = try await GitTestRepo(controlled: true) // file locking applies only to Nucleic Control
defer { repo.cleanup() }
let store = makeStore(repo: repo)
store.lockReconcileInterval = .milliseconds(20)
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
// The doer writes made.txt (unmerged) but never went through arbitrate.
let doer = try await store.createSession(in: project, title: "doer", prompt: "go")
await waitFor { store.summaries.first { $0.id == doer }?.status == .awaitingInput }
// A launch reconstruction recognizes the doer as holding made.txt (its unmerged file).
await store.reconstructLocks()
let snap = await store.lockQueueSnapshot()
#expect(snap.files.first { $0.path == "made.txt" }?.holders.contains { $0.sessionID == doer } == true)
// A fresh session now contends and queues behind the reconstructed lock.
let asker = try await store.createSession(in: project, title: "asker", prompt: "go")
await waitFor { store.summaries.first { $0.id == asker }?.status == .awaitingInput }
let arbitration = Task { await store.arbitrate(sessionID: asker, task: "edit made", files: ["made.txt"]) }
await waitFor { store.sessionsWaitingForAccess.contains(asker) }
#expect(store.sessionsWaitingForAccess.contains(asker))
await store.forceReleaseLocks(doer) // cleanup the blocked arbitration
_ = await arbitration.value
}
/// Cascade (LOCKING §5.2): deleting a parent session re-targets its children to the
/// grandparent (the parent's own parent); the lock domain `rootRef` is unchanged.
@Test func cascadeRetargetsChildrenToGrandparentOnParentDelete() async throws {
let repo = try await GitTestRepo(controlled: true) // file locking applies only to Nucleic Control
defer { repo.cleanup() }
let db = try GRDBMetadataStore(path: nil)
let worktrees = GitWorktreeManager(now: { Date(timeIntervalSince1970: 1_700_000_000) })
let transcriptsDir = URL(fileURLWithPath: (repo.container as NSString)
.appendingPathComponent("transcripts"))
let backend: @Sendable (Session) -> any AgentBackend = { _ in
ScriptedBackend { e, _ in e.emit(.runFinished(RunFinished(outcome: .completed))) }
}
let store = AppStore(
database: db, worktrees: worktrees, transcriptsDir: transcriptsDir,
now: { Date(timeIntervalSince1970: 1_700_000_000) }, backendFactory: backend)
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
let parent = try await store.createSession(in: project, title: "parent")
let parentBranch = try #require(await db.loadSession(id: parent)?.branch)
let child = try await store.createSession(in: project, title: "child", base: GitRef(parentBranch))
let before = try #require(await db.loadSession(id: child))
#expect(before.parentSessionID == parent)
#expect(before.parentRef == parentBranch)
#expect(before.rootRef == "main")
// Delete the parent → the child re-targets to the grandparent (parent was top-level off "main").
await store.deleteSession(parent)
let after = try #require(await db.loadSession(id: child))
#expect(after.parentRef == "main") // grandparent ref
#expect(after.parentSessionID == nil) // grandparent is a project branch, no session
#expect(after.rootRef == "main") // lock domain unchanged
}
@Test func rejectsDuplicateProject() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let store = makeStore(repo: repo)
let first = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")
#expect(first != nil)
let dup = await store.addProject(name: "demo-again", rootPath: repo.root, defaultBranch: "main")
#expect(dup == nil)
#expect(store.projects.count == 1)
#expect(store.lastError != nil)
}
@Test func changingLastErrorClearsDeepLink() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let store = makeStore(repo: repo)
// An error that names a responsible chat carries a deep-link to it…
store.lastError = "Autoship failed."
store.lastErrorSessionID = SessionID(rawValue: "s1")
#expect(store.lastErrorSessionID != nil)
// …but a subsequent, unrelated error must not inherit that stale target.
store.lastError = "Something else broke."
#expect(store.lastErrorSessionID == nil)
}
@Test func renameSessionAndProject() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let store = makeStore(repo: repo)
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
let sessionID = try await store.newSession(in: project)!
await store.renameOpenSession(to: "my-cool-chat")
#expect(store.openSession?.title == "my-cool-chat")
#expect(store.summaries.first { $0.id == sessionID }?.title == "my-cool-chat")
await store.renameProject(project.id, to: "Renamed Project")
#expect(store.projects.first?.name == "Renamed Project")
}
@Test func modelAndEffortPersist() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let store = makeStore(repo: repo)
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
let sessionID = try await store.newSession(in: project)!
await store.setOpenSessionModel("opus")
await store.setOpenSessionEffort("high")
#expect(store.openSession?.model == "opus")
#expect(store.openSession?.effort == "high")
#expect(store.openSession?.id == sessionID)
}
@Test func intelligenceSummarizesAndAutoNamesFromFirstMessage() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let store = makeStore(repo: repo)
store.intelligence = FakeIntelligence()
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
let sessionID = try await store.createSession(in: project, title: "amber-quiet-otter", prompt: "build a login screen")
store.openSessionID = sessionID
await waitFor { store.openSession?.title == "Title: build a login screen" }
#expect(store.openSession?.title == "Title: build a login screen")
#expect(store.summaries.first { $0.id == sessionID }?.title == "Title: build a login screen")
await waitFor { store.openSummary == "FAKE SUMMARY" }
#expect(store.openSummary == "FAKE SUMMARY")
}
/// Phase A (LOCKING §3): a session records its parent (release target) and root (lock
/// domain). A top-level chat forks from the default branch, so both are that branch and it
/// has no parent session. A nested child (forked from another session's branch) takes that
/// branch as its parent, **inherits the tree's root** (not the immediate parent), links to
/// the parent session — and all three survive a relaunch (migration v14 columns round-trip).
@Test func parentAndRootRefsTrackTheTreeAndPersist() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let dbPath = (repo.container as NSString).appendingPathComponent("nucleic.sqlite")
let transcriptsDir = URL(fileURLWithPath: (repo.container as NSString)
.appendingPathComponent("transcripts"))
let worktrees = GitWorktreeManager(now: { Date(timeIntervalSince1970: 1_700_000_000) })
let backend: @Sendable (Session) -> any AgentBackend = { _ in
ScriptedBackend { e, _ in e.emit(.runFinished(RunFinished(outcome: .completed))) }
}
let parentID: SessionID
let childID: SessionID
let parentBranch: String
do {
let db = try GRDBMetadataStore(path: dbPath)
let store = AppStore(
database: db, worktrees: worktrees, transcriptsDir: transcriptsDir,
now: { Date(timeIntervalSince1970: 1_700_000_000) }, backendFactory: backend)
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
// Top-level: forks from the default branch ⇒ parentRef = rootRef = "main", no parent.
parentID = try await store.createSession(in: project, title: "parent")
let parent = try #require(await db.loadSession(id: parentID))
#expect(parent.parentRef == "main")
#expect(parent.rootRef == "main")
#expect(parent.parentSessionID == nil)
parentBranch = try #require(parent.branch)
// Nested child: forks from the parent's branch ⇒ parentRef = that branch,
// rootRef inherited ("main", NOT the immediate parent), parentSessionID = parent.
childID = try await store.createSession(in: project, title: "child", base: GitRef(parentBranch))
let child = try #require(await db.loadSession(id: childID))
#expect(child.parentRef == parentBranch)
#expect(child.rootRef == "main")
#expect(child.parentSessionID == parentID)
}
// Relaunch on the same DB: the three columns round-trip (migration v14).
let db2 = try GRDBMetadataStore(path: dbPath)
let reloaded = try #require(await db2.loadSession(id: childID))
#expect(reloaded.parentRef == parentBranch)
#expect(reloaded.rootRef == "main")
#expect(reloaded.parentSessionID == parentID)
}
@Test func sessionsReloadIntoSidebarAfterRelaunch() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
// On-disk DB so a second store sees the first store's writes (a "relaunch").
let dbPath = (repo.container as NSString).appendingPathComponent("nucleic.sqlite")
let transcriptsDir = URL(fileURLWithPath: (repo.container as NSString)
.appendingPathComponent("transcripts"))
let worktrees = GitWorktreeManager(now: { Date(timeIntervalSince1970: 1_700_000_000) })
let makeBackend: @Sendable (Session) -> any AgentBackend = { session in
let wtPath = session.worktreePath ?? ""
return ScriptedBackend { e, _ in
e.emit(.sessionStarted(SessionStarted(
backendSessionID: "be", model: "m", cwd: wtPath, toolNames: [])))
e.emit(.assistantText(TextChunk(messageID: "a", text: "hello", isPartial: false)))
e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
e.emit(.runFinished(RunFinished(outcome: .completed)))
}
}
// First "launch": create a project + a session that runs a turn.
let sessionID: SessionID
do {
let db = try GRDBMetadataStore(path: dbPath)
let store = AppStore(
database: db, worktrees: worktrees, transcriptsDir: transcriptsDir,
now: { Date(timeIntervalSince1970: 1_700_000_000) }, backendFactory: makeBackend)
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
sessionID = try await store.createSession(in: project, title: "kept-chat", prompt: "hi")
store.openSessionID = sessionID
await waitFor { store.summaries.first?.status == .awaitingInput }
}
// Second "launch": a fresh store on the same DB + transcripts.
let db2 = try GRDBMetadataStore(path: dbPath)
let store2 = AppStore(
database: db2, worktrees: worktrees, transcriptsDir: transcriptsDir,
now: { Date(timeIntervalSince1970: 1_700_000_000) }, backendFactory: makeBackend)
await store2.loadProjects()
await store2.loadSessions()
// The chat is back in the sidebar…
let project = try #require(store2.projects.first)
#expect(store2.summaries(for: project.id).map(\.id) == [sessionID])
#expect(store2.summaries.first?.title == "kept-chat")
// …and opening it replays the prior transcript.
store2.openSessionID = sessionID
await waitFor { !store2.openTranscript.isEmpty }
#expect(store2.openTranscript.contains {
if case .assistantText(let c) = $0.kind { return c.text == "hello" } else { return false }
})
}
@Test func backfillsDispositionForUnclassifiedSessionOnLoad() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let dbPath = (repo.container as NSString).appendingPathComponent("nucleic.sqlite")
let transcriptsDir = URL(fileURLWithPath: (repo.container as NSString)
.appendingPathComponent("transcripts"))
let worktrees = GitWorktreeManager(now: { Date(timeIntervalSince1970: 1_700_000_000) })
let makeBackend: @Sendable (Session) -> any AgentBackend = { session in
let wtPath = session.worktreePath ?? ""
return ScriptedBackend { e, _ in
e.emit(.sessionStarted(SessionStarted(
backendSessionID: "be", model: "m", cwd: wtPath, toolNames: [])))
e.emit(.assistantText(TextChunk(messageID: "a",
text: "The worktree is quiet. Nothing left to push.", isPartial: false)))
e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
e.emit(.runFinished(RunFinished(outcome: .completed)))
}
}
// First launch: run a turn, then simulate a pre-feature session by clearing the
// persisted disposition (as if it had finished before classification existed).
let sessionID: SessionID
let db = try GRDBMetadataStore(path: dbPath)
do {
let store = AppStore(
database: db, worktrees: worktrees, transcriptsDir: transcriptsDir,
now: { Date(timeIntervalSince1970: 1_700_000_000) }, backendFactory: makeBackend)
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
sessionID = try await store.createSession(in: project, title: "done-chat", prompt: "hi")
await waitFor { store.summaries.first?.status == .awaitingInput }
}
var stored = try #require(await db.loadSession(id: sessionID))
stored.lastTurnDisposition = nil
try await db.saveSession(stored)
// Relaunch: loadSessions backfills the disposition from the transcript's last reply.
let store2 = AppStore(
database: try GRDBMetadataStore(path: dbPath), worktrees: worktrees,
transcriptsDir: transcriptsDir,
now: { Date(timeIntervalSince1970: 1_700_000_000) }, backendFactory: makeBackend)
await store2.loadProjects()
await store2.loadSessions()
#expect(store2.summaries.first { $0.id == sessionID }?.disposition == .completed)
}
@Test func unseenCompletionFlagsCompletedChatsUntilOpened() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let worktrees = GitWorktreeManager(now: { Date(timeIntervalSince1970: 1_700_000_000) })
let transcriptsDir = URL(fileURLWithPath: (repo.container as NSString)
.appendingPathComponent("transcripts"))
// Every chat ends by reporting it finished the work, so the heuristic classifier
// lands the turn on `.completed` (an "unread, done" chat for the sidebar).
let store = AppStore(
database: try GRDBMetadataStore(path: nil), worktrees: worktrees,
transcriptsDir: transcriptsDir,
now: { Date(timeIntervalSince1970: 1_700_000_000) }
) { session in
ScriptedBackend { e, _ in
e.emit(.sessionStarted(SessionStarted(
backendSessionID: "be", model: "m", cwd: session.worktreePath ?? "", toolNames: [])))
e.emit(.assistantText(TextChunk(messageID: "a",
text: "Fixed the bug and added a test. All tests pass.", isPartial: false)))
e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
e.emit(.runFinished(RunFinished(outcome: .completed)))
}
}
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
// A chat that completes while the user is looking elsewhere becomes "unread".
let background = try await store.createSession(in: project, title: "bg", prompt: "go")
await waitFor({
store.summaries.first { $0.id == background }?.unseenCompletion == true
}, timeoutMs: 8000)
#expect(store.summaries.first { $0.id == background }?.disposition == .completed)
#expect(store.summaries.first { $0.id == background }?.unseenCompletion == true)
// Opening it marks it seen — the dot clears.
store.openSessionID = background
await store.awaitOpenSessionSettled()
await waitFor({
store.summaries.first { $0.id == background }?.unseenCompletion == false
}, timeoutMs: 8000)
#expect(store.summaries.first { $0.id == background }?.unseenCompletion == false)
// A chat that completes while it's the open one was never unread.
let foreground = try await store.createSession(in: project, title: "fg", prompt: "go")
store.openSessionID = foreground
await store.awaitOpenSessionSettled()
await waitFor({
store.summaries.first { $0.id == foreground }?.disposition == .completed
}, timeoutMs: 8000)
#expect(store.summaries.first { $0.id == foreground }?.unseenCompletion == false)
}
@Test func classifiesTurnDispositionForSidebar() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let worktrees = GitWorktreeManager(now: { Date(timeIntervalSince1970: 1_700_000_000) })
let transcriptsDir = URL(fileURLWithPath: (repo.container as NSString)
.appendingPathComponent("transcripts"))
// The agent's final reply varies by the session title, so one chat ends by
// asking and the other by reporting done — the heuristic classifier (default
// intelligence) then drives the sidebar disposition.
let store = AppStore(
database: try GRDBMetadataStore(path: nil), worktrees: worktrees,
transcriptsDir: transcriptsDir,
now: { Date(timeIntervalSince1970: 1_700_000_000) }
) { session in
let asking = session.title.contains("ask")
return ScriptedBackend { e, _ in
e.emit(.sessionStarted(SessionStarted(
backendSessionID: "be", model: "m", cwd: session.worktreePath ?? "", toolNames: [])))
e.emit(.assistantText(TextChunk(messageID: "a",
text: asking ? "I found two options. Which would you prefer?"
: "Fixed the bug and added a test. All tests pass.",
isPartial: false)))
e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
e.emit(.runFinished(RunFinished(outcome: .completed)))
}
}
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
let asker = try await store.createSession(in: project, title: "ask-chat", prompt: "go")
let doer = try await store.createSession(in: project, title: "done-chat", prompt: "go")
// Status settles to awaitingInput for both; the disposition refines which kind.
// (Both the turn pipeline and classification are async, so wait generously.)
await waitFor({
store.summaries.first { $0.id == asker }?.disposition == .awaitingInput
&& store.summaries.first { $0.id == doer }?.disposition == .completed
}, timeoutMs: 8000)
#expect(store.summaries.first { $0.id == asker }?.status == .awaitingInput)
#expect(store.summaries.first { $0.id == asker }?.disposition == .awaitingInput)
#expect(store.summaries.first { $0.id == doer }?.status == .awaitingInput)
#expect(store.summaries.first { $0.id == doer }?.disposition == .completed)
}
@Test func markSessionDoneFlipsAwaitingInputToCompleted() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let worktrees = GitWorktreeManager(now: { Date(timeIntervalSince1970: 1_700_000_000) })
let transcriptsDir = URL(fileURLWithPath: (repo.container as NSString)
.appendingPathComponent("transcripts"))
// Every chat ends by asking the user something, so the heuristic classifier lands the
// turn on `.awaitingInput` — the "stuck on Awaiting Input" state the sidebar's Mark Done
// action is there to clear.
let store = AppStore(
database: try GRDBMetadataStore(path: nil), worktrees: worktrees,
transcriptsDir: transcriptsDir,
now: { Date(timeIntervalSince1970: 1_700_000_000) }
) { session in
ScriptedBackend { e, _ in
e.emit(.sessionStarted(SessionStarted(
backendSessionID: "be", model: "m", cwd: session.worktreePath ?? "", toolNames: [])))
e.emit(.assistantText(TextChunk(messageID: "a",
text: "I found two options. Which would you prefer?", isPartial: false)))
e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
e.emit(.runFinished(RunFinished(outcome: .completed)))
}
}
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
let chat = try await store.createSession(in: project, title: "ask-chat", prompt: "go")
await waitFor({
store.summaries.first { $0.id == chat }?.disposition == .awaitingInput
}, timeoutMs: 8000)
// Marking it done by hand flips the disposition to `.completed` — the row now reads "Done"
// and drops out of "needs attention" — without leaving `.awaitingInput`.
await store.markSessionDone(chat)
#expect(store.summaries.first { $0.id == chat }?.status == .awaitingInput)
#expect(store.summaries.first { $0.id == chat }?.disposition == .completed)
#expect(store.summaries.first { $0.id == chat }?.isCompleted == true)
#expect(store.summaries.first { $0.id == chat }?.needsAttention == false)
// Re-marking an already-done chat is a harmless no-op.
await store.markSessionDone(chat)
#expect(store.summaries.first { $0.id == chat }?.disposition == .completed)
}
@Test func dashboardCountsProjectsChatsAndMessages() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let store = makeStore(repo: repo)
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
// One chat that runs a turn (the scripted backend injects "go" as a user turn).
let sessionID = try await store.createSession(in: project, title: "with-msgs", prompt: "go")
store.openSessionID = sessionID
await waitFor { store.summaries.first?.status == .awaitingInput }
await store.loadDashboard()
#expect(store.dashboard.projects == 1)
#expect(store.dashboard.chats == 1)
#expect(store.dashboard.messages >= 1) // the injected user turn
#expect(store.dashboard.activeChats == 1) // awaitingInput is non-terminal
#expect(!store.activityByDay.isEmpty) // at least today has activity
// Archived chats drop out of both the chats and active counts.
await store.setSessionArchived(sessionID, true)
await store.loadDashboard()
#expect(store.dashboard.chats == 0)
#expect(store.dashboard.activeChats == 0)
}
@Test func activityContributionTalliesMessagesAndTokensByDay() {
var cal = Calendar(identifier: .gregorian)
cal.timeZone = TimeZone(identifier: "UTC")!
let dayA = Date(timeIntervalSince1970: 1_750_000_000) // some day
let dayB = cal.date(byAdding: .day, value: 1, to: dayA)! // the next day
func event(_ at: Date, _ kind: AgentEvent.Kind) -> AgentEvent {
AgentEvent(sessionID: SessionID(rawValue: "s"), seq: 0, at: at,
backend: .claudeCode, nativeType: nil, kind: kind)
}
let chunk = TextChunk(messageID: "m", text: "hi", isPartial: false)
let usageA = Usage(inputTokens: 10, cachedInputTokens: 90, outputTokens: 20) // 120
let usageB = Usage(inputTokens: 5, outputTokens: 5) // 10
let events = [
event(dayA, .userText(chunk)),
event(dayA, .userText(chunk)),
event(dayA, .usage(usageA)),
// The matching turnCompleted carries a *copy* of the same usage — it must NOT be
// tallied again, or the day's tokens would double.
event(dayA, .turnCompleted(TurnCompleted(stopReason: "end_turn", usage: usageA))),
event(dayB, .userText(chunk)),
event(dayB, .usage(usageB)),
]
let c = AppStore.activityContribution(events: events, calendar: cal)
#expect(c.messages == 3)
#expect(c.tokens == 130) // 120 + 10, no double-count
let startA = cal.startOfDay(for: dayA)
let startB = cal.startOfDay(for: dayB)
#expect(c.messagesByDay[startA] == 2)
#expect(c.messagesByDay[startB] == 1)
#expect(c.tokensByDay[startA] == 120)
#expect(c.tokensByDay[startB] == 10)
}
@Test func newChatsInheritDefaults() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let store = makeStore(repo: repo)
store.defaultModel = "claude-sonnet-4-6"
store.defaultEffort = "low"
store.defaultAuto = true
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
_ = try await store.newSession(in: project)
await waitFor { store.openSession != nil }
#expect(store.openSession?.model == "claude-sonnet-4-6")
#expect(store.openSession?.effort == "low")
#expect(store.openSession?.auto == true)
}
@Test func worktreelessSessionRunsInRepoRootAndDeletesCleanly() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let store = makeStore(repo: repo)
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
let id = try await store.createSession(in: project, title: "c", prompt: "", useWorktree: false)
store.openSessionID = id
await waitFor { store.openSession != nil }
// Runs in the main checkout on the chosen branch — no isolated worktree.
#expect(store.openSession?.worktreePath == repo.root)
#expect(store.openSession?.branch == "main")
// Nothing to remove ⇒ deletes without surfacing an error.
await store.deleteSession(id)
#expect(store.lastError == nil)
#expect(store.summaries.contains { $0.id == id } == false)
}
@Test func worktreelessSessionDoesNotAutoship() async throws {
let repo = try await GitTestRepo(controlled: true) // autoship requires Nucleic Control
defer { repo.cleanup() }
let worktrees = GitWorktreeManager(now: { Date(timeIntervalSince1970: 1_700_000_000) })
let transcriptsDir = URL(fileURLWithPath: (repo.container as NSString)
.appendingPathComponent("transcripts"))
// Each chat writes a file named after its title (distinct files, no merge collision)
// and reports done, so the heuristic classifier lands the turn on `.completed` with a
// non-empty footprint — the precondition for autoship to fire.
let store = AppStore(
database: try GRDBMetadataStore(path: nil), worktrees: worktrees,
transcriptsDir: transcriptsDir,
now: { Date(timeIntervalSince1970: 1_700_000_000) }
) { session in
let wtPath = session.worktreePath ?? ""
let name = session.title.replacingOccurrences(of: " ", with: "-") + ".txt"
return ScriptedBackend { e, _ in
e.emit(.sessionStarted(SessionStarted(
backendSessionID: "be", model: "m", cwd: wtPath, toolNames: ["Write"])))
try? "by agent\n".write(
toFile: (wtPath as NSString).appendingPathComponent(name),
atomically: true, encoding: .utf8)
e.emit(.fileChange(FileChange(path: name, kind: .add)))
e.emit(.assistantText(TextChunk(messageID: "a",
text: "Fixed the bug and added a test. All tests pass.", isPartial: false)))
e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
e.emit(.runFinished(RunFinished(outcome: .completed)))
}
}
await store.activateConflictArbitration() // wires the autoship merge queue
// Autoship is Control-gated, so the project must be Nucleic-controlled to ship.
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
#expect(project.isNucleicControlled)
// The session under test runs directly in the main checkout on `main` — there is no
// isolated branch to merge (a branch can't merge into itself). Autoship must skip it
// entirely: no "merging into…" note, no surfaced failure — even though it has a real
// file footprint that would otherwise be shippable.
let inPlace = try await store.createSession(
in: project, title: "in place", prompt: "go", useWorktree: false, autoShip: true)
store.openSessionID = inPlace
await store.awaitOpenSessionSettled()
// Wait until the turn is classified done (so shipIfCompleted has made its decision),
// then give any erroneous enqueue a grace window to surface its `.merging` note.
await waitFor({
store.summaries.first { $0.id == inPlace }?.disposition == .completed
}, timeoutMs: 8000)
await waitFor({ self.autoshipNotes(store.openTranscript).isEmpty == false }, timeoutMs: 1000)
#expect(autoshipNotes(store.openTranscript).isEmpty)
#expect(store.lastError == nil)
// Sanity: a worktree session with autoship DOES land and logs a merge note — proof
// the queue is actually wired in this store, so the skip above is real and not just
// an unwired no-op.
let landed = try await store.createSession(
in: project, title: "landed", prompt: "go", useWorktree: true, autoShip: true)
store.openSessionID = landed
await store.awaitOpenSessionSettled()
await waitFor({
self.autoshipNotes(store.openTranscript).contains { $0.contains("merged into main") }
}, timeoutMs: 8000)
#expect(autoshipNotes(store.openTranscript).contains { $0.contains("merged into main") })
}
@Test func erroredRunDoesNotAutoship() async throws {
let repo = try await GitTestRepo(controlled: true) // autoship requires Nucleic Control
defer { repo.cleanup() }
let worktrees = GitWorktreeManager(now: { Date(timeIntervalSince1970: 1_700_000_000) })
let transcriptsDir = URL(fileURLWithPath: (repo.container as NSString)
.appendingPathComponent("transcripts"))
// Each chat writes a file and signs off with done-looking prose (so the heuristic
// classifier lands the turn on `.completed`) — but the run's *outcome* is driven by
// the title: a chat titled "broken" ends `.errored`, everything else `.completed`.
let store = AppStore(
database: try GRDBMetadataStore(path: nil), worktrees: worktrees,
transcriptsDir: transcriptsDir,
now: { Date(timeIntervalSince1970: 1_700_000_000) }
) { session in
let wtPath = session.worktreePath ?? ""
let name = session.title.replacingOccurrences(of: " ", with: "-") + ".txt"
let errored = session.title.contains("broken")
return ScriptedBackend { e, _ in
e.emit(.sessionStarted(SessionStarted(
backendSessionID: "be", model: "m", cwd: wtPath, toolNames: ["Write"])))
try? "by agent\n".write(
toFile: (wtPath as NSString).appendingPathComponent(name),
atomically: true, encoding: .utf8)
e.emit(.fileChange(FileChange(path: name, kind: .add)))
e.emit(.assistantText(TextChunk(messageID: "a",
text: "Fixed the bug and added a test. All tests pass.", isPartial: false)))
e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
e.emit(.runFinished(RunFinished(outcome: errored ? .errored : .completed)))
}
}
await store.activateConflictArbitration() // wires the autoship merge queue
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
// The run errored mid-flight even though it left done-looking prose and a real file
// footprint. Autoship must not fire: half-finished work never lands. The classifier
// still runs (so the turn is marked done for the sidebar), giving us a settle point;
// we then allow any erroneous enqueue a grace window to surface its "merging…" note.
let broken = try await store.createSession(
in: project, title: "broken", prompt: "go", useWorktree: true, autoShip: true)
store.openSessionID = broken
await store.awaitOpenSessionSettled()
await waitFor({
store.summaries.first { $0.id == broken }?.disposition == .completed
}, timeoutMs: 8000)
await waitFor({ self.autoshipNotes(store.openTranscript).isEmpty == false }, timeoutMs: 1000)
#expect(autoshipNotes(store.openTranscript).isEmpty)
#expect(store.lastError == nil)
// Sanity: an identical chat whose run completes cleanly DOES land — proof the queue is
// wired in this store, so the skip above is the outcome gate, not an unwired no-op.
let landed = try await store.createSession(
in: project, title: "landed", prompt: "go", useWorktree: true, autoShip: true)
store.openSessionID = landed
await store.awaitOpenSessionSettled()
await waitFor({
self.autoshipNotes(store.openTranscript).contains { $0.contains("merged into main") }
}, timeoutMs: 8000)
#expect(autoshipNotes(store.openTranscript).contains { $0.contains("merged into main") })
}
@Test func summaryIsCachedAndNotRegeneratedOnReopen() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let store = makeStore(repo: repo)
let counter = CountingIntelligence()
store.intelligence = counter
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
let id = try await store.createSession(in: project, title: "c", prompt: "go")
store.openSessionID = id
await waitFor { store.summaries.first?.status == .awaitingInput }
// Drain all in-flight intelligence work (classification → summary) so the count
// is stable, then confirm the summary was generated and cached on the session.
await store.awaitOpenSessionSettled()
#expect(store.openSession?.summary?.isEmpty == false) // cached on the session
let callsAfterRun = counter.calls.get()
#expect(callsAfterRun >= 1)
// Switch away and back — the cache is used, no new generation.
store.openSessionID = nil
await waitFor { store.openSession == nil }
store.openSessionID = id
await store.awaitOpenSessionSettled()
#expect(!store.openSummary.isEmpty)
#expect(counter.calls.get() == callsAfterRun)
}
@Test func favoriteArchiveDeleteChat() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let store = makeStore(repo: repo)
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
let id = try await store.createSession(in: project, title: "chat", prompt: "go")
store.openSessionID = id
await waitFor { store.summaries.first?.status == .awaitingInput }
await store.setSessionFavorite(id, true)
#expect(store.summaries(for: project.id).first?.favorite == true)
await store.setSessionArchived(id, true)
#expect(store.summaries(for: project.id).isEmpty) // hidden from main list
#expect(store.archivedSummaries(for: project.id).map(\.id) == [id])
#expect(store.openSessionID == nil) // closed on archive
await store.setSessionArchived(id, false)
#expect(store.summaries(for: project.id).map(\.id) == [id])
await store.deleteSession(id)
#expect(store.summaries.isEmpty)
}
@Test func deleteProjectCascadesSessionsAndWorktrees() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let store = makeStore(repo: repo)
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
let sessionID = try await store.createSession(in: project, title: "doomed", prompt: "go")
store.openSessionID = sessionID
await waitFor { store.summaries.first?.status == .awaitingInput }
let wtPath = (repo.root as NSString).appendingPathComponent(".nucleic/worktrees/doomed")
#expect(FileManager.default.fileExists(atPath: wtPath))
await store.deleteProject(project.id)
#expect(store.projects.isEmpty)
#expect(store.summaries.isEmpty)
#expect(store.openSessionID == nil)
#expect(!FileManager.default.fileExists(atPath: wtPath)) // worktree removed
// Branch gone too.
let branch = try await repo.run(["rev-parse", "--verify", "--quiet", "refs/heads/nucleic/doomed"])
#expect(!branch.ok)
}
@Test func discardRemovesSessionAndWorktree() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let store = makeStore(repo: repo)
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
let sessionID = try await store.createSession(in: project, title: "scrap", prompt: "go")
store.openSessionID = sessionID
await waitFor { store.summaries.first?.status == .awaitingInput }
let wtPath = (repo.root as NSString).appendingPathComponent(".nucleic/worktrees/scrap")
#expect(FileManager.default.fileExists(atPath: wtPath))
await store.discardOpenSession()
#expect(store.summaries.isEmpty)
#expect(store.openSessionID == nil)
#expect(!FileManager.default.fileExists(atPath: wtPath))
}
// MARK: - Auto-archive
/// A store whose clock can be advanced, and whose scripted agent ends each turn
/// with a reply that the heuristic classifier reads as "done" — unless the chat's
/// title starts with "asking", in which case the reply ends on a question (so its
/// disposition lands on `.awaitingInput`).
private func makeArchiveStore(repo: GitTestRepo, clock: LockedBox<Date>) -> AppStore {
let database = try! GRDBMetadataStore(path: nil)
let worktrees = GitWorktreeManager(now: { Date(timeIntervalSince1970: 1_700_000_000) })
let transcriptsDir = URL(fileURLWithPath: (repo.container as NSString)
.appendingPathComponent("transcripts"))
return AppStore(
database: database, worktrees: worktrees, transcriptsDir: transcriptsDir,
now: { clock.get() }
) { session in
let asking = session.title.hasPrefix("asking")
return ScriptedBackend { e, _ in
e.emit(.sessionStarted(SessionStarted(
backendSessionID: "be", model: "m",
cwd: session.worktreePath ?? "", toolNames: [])))
e.emit(.assistantText(TextChunk(
messageID: "a",
text: asking ? "Want me to proceed?" : "All done.", isPartial: false)))
e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
e.emit(.runFinished(RunFinished(outcome: .completed)))
}
}
}
@Test func isCompletedDistinguishesDoneFromAwaiting() {
func summary(_ status: SessionStatus, _ disposition: TurnDisposition? = nil) -> SessionSummary {
let session = Session(
id: .generate(), projectID: .generate(), backend: .claudeCode,
title: "t", status: status, transcriptPath: "/tmp/t",
lastTurnDisposition: disposition,
createdAt: Date(timeIntervalSince1970: 0), updatedAt: Date(timeIntervalSince1970: 0))
return SessionSummary(session, pendingApprovalCount: 0)
}
#expect(summary(.finished).isCompleted)
#expect(summary(.awaitingInput, .completed).isCompleted)
// Awaiting the user (asking, or not yet classified) is NOT "done".
#expect(!summary(.awaitingInput, .awaitingInput).isCompleted)
#expect(!summary(.awaitingInput, nil).isCompleted)
#expect(!summary(.running).isCompleted)
#expect(!summary(.awaitingApproval).isCompleted)
#expect(!summary(.interrupted).isCompleted)
#expect(!summary(.error).isCompleted)
#expect(!summary(.idle).isCompleted)
#expect(!summary(.provisioning).isCompleted)
}
@Test func needsAttentionFlagsChatsBlockedOnTheUser() {
func summary(_ status: SessionStatus, disposition: TurnDisposition? = nil,
approvals: Int = 0, autoShipFailed: Bool = false,
autoShipConflict: Bool = false) -> SessionSummary {
let session = Session(
id: .generate(), projectID: .generate(), backend: .claudeCode,
title: "t", status: status, transcriptPath: "/tmp/t",
autoShipFailed: autoShipFailed, autoShipConflict: autoShipConflict,
lastTurnDisposition: disposition,
createdAt: Date(timeIntervalSince1970: 0), updatedAt: Date(timeIntervalSince1970: 0))
return SessionSummary(session, pendingApprovalCount: approvals)
}
// Blocked on a decision, or asking the user something (incl. not-yet-classified).
#expect(summary(.awaitingApproval).needsAttention)
#expect(summary(.awaitingInput, disposition: .awaitingInput).needsAttention)
#expect(summary(.awaitingInput, disposition: nil).needsAttention)
#expect(summary(.idle, approvals: 1).needsAttention)
// Autoship stalled (hard failure, or armed-but-conflicted) needs a hand.
#expect(summary(.running, autoShipFailed: true).needsAttention)
#expect(summary(.running, autoShipConflict: true).needsAttention)
// A chat that simply finished its work is NOT "needs attention" (unread, not blocked).
#expect(!summary(.awaitingInput, disposition: .completed).needsAttention)
#expect(!summary(.finished).needsAttention)
#expect(!summary(.running).needsAttention)
#expect(!summary(.idle).needsAttention)
#expect(!summary(.provisioning).needsAttention)
}
@Test func autoArchiveSweepArchivesCompletedIdleChats() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let clock = LockedBox(Date(timeIntervalSince1970: 1_700_000_000))
let store = makeArchiveStore(repo: repo, clock: clock)
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
let pid = project.id
let done = try await store.createSession(in: project, title: "done", prompt: "go")
let open = try await store.createSession(in: project, title: "done-open", prompt: "go")
let fav = try await store.createSession(in: project, title: "done-fav", prompt: "go")
let asking = try await store.createSession(in: project, title: "asking", prompt: "go")
func disposition(_ id: SessionID) -> TurnDisposition? {
store.summaries.first { $0.id == id }?.disposition
}
// Let every turn finish and be classified before we judge idleness.
await waitFor {
disposition(done) == .completed && disposition(open) == .completed
&& disposition(fav) == .completed && disposition(asking) == .awaitingInput
}
#expect(disposition(asking) == .awaitingInput)
await store.setSessionFavorite(fav, true)
store.openSessionID = open
// "Never" (nil interval) archives nothing, even after a long idle.
store.autoArchiveIdleInterval = nil
clock.set(clock.get().addingTimeInterval(60 * 60))
await store.sweepAutoArchive()
#expect(store.summaries(for: pid).count == 4)
#expect(store.archivedSummaries(for: pid).isEmpty)
// 30-minute policy: only the plain completed-and-idle chat is archived; the
// favorite, the open chat, and the one still asking a question are spared.
store.autoArchiveIdleInterval = 30 * 60
await store.sweepAutoArchive()
#expect(store.archivedSummaries(for: pid).map(\.id) == [done])
#expect(Set(store.summaries(for: pid).map(\.id)) == Set([open, fav, asking]))
}
}