Files
nucleic/Tests/NucleicCoreTests/AppStoreTests.swift
T
abkslmandClaude Opus 4.8 720dfe54d6 Summarize each to-do item; show original prompt beneath; sort oldest-first
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]>
2026-06-12 17:48:14 -07:00

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))
}
}