2709 lines
147 KiB
Swift
2709 lines
147 KiB
Swift
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]))
|
||
}
|
||
}
|