Each open idea now gets its own short summary (model-backed with heuristic fallback), shown emphasized with a sparkles "summarized" icon, and the full original text beneath it — smaller and dimmer, since that text is what gets dispatched as the agent's prompt. Lists are sorted oldest-first (first-in at the top). - Todo.summary (persisted, v6 migration): nil = not yet summarized, "" = no distinct summary, else the summary; text stays canonical as the prompt - summarizeTodo on IntelligenceProviding (default heuristic todoLine + AFM override that compresses to a short imperative title) - AppStore generates item summaries on add / on load (once) / on text edit, off the main loop; short ideas fold to "" so they aren't retried - openTodos + grouping sorted by createdAt ascending (stable) - TodoRow: summary + sparkles icon over a dimmer original snippet - Tests: long-vs-short item summary, oldest-first ordering, summary column Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
519 lines
24 KiB
Swift
519 lines
24 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 }
|
|
}
|
|
|
|
@MainActor
|
|
@Suite("AppStore — object-graph root")
|
|
struct AppStoreTests {
|
|
private func makeStore(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"))
|
|
return AppStore(
|
|
database: database, worktrees: worktrees, transcriptsDir: transcriptsDir,
|
|
now: { Date(timeIntervalSince1970: 1_700_000_000) }
|
|
) { 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
|
|
}
|
|
}
|
|
|
|
@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
|
|
|
|
await waitFor { store.summaries.first?.status == .awaitingInput }
|
|
// Settle the live diffstat deterministically (the in-stream refresh is
|
|
// best-effort and can be skipped under heavy parallel git load).
|
|
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.
|
|
await waitFor { !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.container as NSString)
|
|
.appendingPathComponent(".nucleic-worktrees/repo/make-a-file/made.txt")))
|
|
}
|
|
|
|
@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 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 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 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")
|
|
}
|
|
|
|
@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 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
|
|
}
|
|
|
|
@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 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 }
|
|
// Summary generated and cached onto the session.
|
|
await waitFor { store.openSession?.summary?.isEmpty == false }
|
|
#expect(store.openSession?.summary?.isEmpty == false) // cached on the session
|
|
|
|
// Let any in-flight generation settle, then record the count.
|
|
try? await Task.sleep(for: .milliseconds(100))
|
|
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 waitFor { !store.openSummary.isEmpty }
|
|
try? await Task.sleep(for: .milliseconds(100))
|
|
#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.container as NSString).appendingPathComponent(".nucleic-worktrees/repo/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.container as NSString).appendingPathComponent(".nucleic-worktrees/repo/scrap")
|
|
#expect(FileManager.default.fileExists(atPath: wtPath))
|
|
|
|
await store.discardOpenSession()
|
|
|
|
#expect(store.summaries.isEmpty)
|
|
#expect(store.openSessionID == nil)
|
|
#expect(!FileManager.default.fileExists(atPath: wtPath))
|
|
}
|
|
}
|