diff --git a/Sources/AgentPetCore/AgentSession.swift b/Sources/AgentPetCore/AgentSession.swift index 0b7ab5c..852eb54 100644 --- a/Sources/AgentPetCore/AgentSession.swift +++ b/Sources/AgentPetCore/AgentSession.swift @@ -14,6 +14,8 @@ public struct AgentSession: Identifiable, Sendable, Equatable { public var updatedAt: Date /// When the session entered its current `state`; resets on state change. public var stateSince: Date + /// When the session was first created (first `apply` event). + public var createdAt: Date public init( id: String, @@ -24,7 +26,8 @@ public struct AgentSession: Identifiable, Sendable, Equatable { message: String? = nil, source: AgentSource, updatedAt: Date, - stateSince: Date? = nil + stateSince: Date? = nil, + createdAt: Date? = nil ) { self.id = id self.agentKind = agentKind @@ -35,5 +38,6 @@ public struct AgentSession: Identifiable, Sendable, Equatable { self.source = source self.updatedAt = updatedAt self.stateSince = stateSince ?? updatedAt + self.createdAt = createdAt ?? updatedAt } } diff --git a/Sources/AgentPetCore/SessionArchive.swift b/Sources/AgentPetCore/SessionArchive.swift new file mode 100644 index 0000000..c6b833c --- /dev/null +++ b/Sources/AgentPetCore/SessionArchive.swift @@ -0,0 +1,36 @@ +import Foundation + +/// A snapshot of a completed agent session, persisted to disk for historical review. +public struct SessionArchive: Codable, Sendable { + public let sessionId: String + public let agentKind: AgentKind + public let project: String? + public let title: String? + public let message: String? + public let tokenCount: Int? + public let startedAt: Date + public let endedAt: Date + public let duration: TimeInterval + + public init( + sessionId: String, + agentKind: AgentKind, + project: String?, + title: String?, + message: String?, + tokenCount: Int?, + startedAt: Date, + endedAt: Date, + duration: TimeInterval + ) { + self.sessionId = sessionId + self.agentKind = agentKind + self.project = project + self.title = title + self.message = message + self.tokenCount = tokenCount + self.startedAt = startedAt + self.endedAt = endedAt + self.duration = duration + } +} diff --git a/Sources/AgentPetCore/SessionArchiveStore.swift b/Sources/AgentPetCore/SessionArchiveStore.swift new file mode 100644 index 0000000..12b569f --- /dev/null +++ b/Sources/AgentPetCore/SessionArchiveStore.swift @@ -0,0 +1,172 @@ +import Foundation + +/// Persists completed agent sessions to daily JSON files under `baseURL`. +public final class SessionArchiveStore: @unchecked Sendable { + + /// Default singleton backed by `~/.agentpet/history/`. + public static let shared: SessionArchiveStore = SessionArchiveStore( + baseURL: FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".agentpet/history", isDirectory: true) + ) + + /// Root directory where daily archive files are stored. + public let baseURL: URL + + private let queue = DispatchQueue(label: "com.agentpet.SessionArchiveStore") + private var archivedIds = Set() + + private static let dateFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd" + f.timeZone = TimeZone(identifier: "UTC") + return f + }() + + private static let encoder: JSONEncoder = { + let e = JSONEncoder() + e.dateEncodingStrategy = .iso8601 + return e + }() + + private static let decoder: JSONDecoder = { + let d = JSONDecoder() + d.dateDecodingStrategy = .iso8601 + return d + }() + + public init(baseURL: URL) { + self.baseURL = baseURL + queue.sync { + do { + try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true) + } catch { + fputs("SessionArchiveStore: failed to create directory \(baseURL.path): \(error)\n", stderr) + } + self.archivedIds = self._rebuildDedupSet() + } + } + + /// Persists a completed session to the daily archive file. + public func archive(_ session: AgentSession, startedAt: Date, endedAt: Date) { + queue.sync { + guard !archivedIds.contains(session.id) else { return } + + let record = SessionArchive( + sessionId: session.id, + agentKind: session.agentKind, + project: session.project, + title: session.title, + message: session.message, + tokenCount: nil, + startedAt: startedAt, + endedAt: endedAt, + duration: endedAt.timeIntervalSince(startedAt) + ) + + let fileURL = self._fileURL(for: startedAt) + var existing = self._readRecords(from: fileURL) + existing.append(record) + + do { + let data = try SessionArchiveStore.encoder.encode(existing) + try data.write(to: fileURL, options: .atomic) + archivedIds.insert(session.id) + } catch { + fputs("SessionArchiveStore: failed to write archive \(fileURL.path): \(error)\n", stderr) + } + } + } + + /// Returns all archived records for the calendar day containing `date` (UTC). + public func records(for date: Date) -> [SessionArchive] { + queue.sync { + let fetched = self._readRecords(from: self._fileURL(for: date)) + for r in fetched { archivedIds.insert(r.sessionId) } + return fetched + } + } + + /// Returns all records from the calendar day of `date` (UTC) up to today. + public func allRecords(since date: Date) -> [SessionArchive] { + queue.sync { + guard var current = self._startOfDay(date) else { return [] } + let today = Date() + var result: [SessionArchive] = [] + + while current <= today { + result.append(contentsOf: self._readRecords(from: self._fileURL(for: current))) + guard let next = Self.utcCalendar.date(byAdding: .day, value: 1, to: current) else { break } + current = next + } + return result + } + } + + /// Deletes daily archive files older than `days` days. + /// Uses the most recent archive file's date (or today if none) as the reference point. + public func pruneOlderThan(days: Int) { + queue.sync { + guard let contents = try? FileManager.default.contentsOfDirectory( + at: baseURL, includingPropertiesForKeys: nil + ) else { return } + + let jsonFiles = contents.filter { $0.pathExtension == "json" } + let names = jsonFiles.map { $0.deletingPathExtension().lastPathComponent } + + // Find the latest archive date to use as reference; fall back to today + let referenceString = names.max() ?? SessionArchiveStore.dateFormatter.string(from: Date()) + guard let referenceDate = SessionArchiveStore.dateFormatter.date(from: referenceString) else { return } + + let cutoffDate = referenceDate.addingTimeInterval(-TimeInterval(days) * 86_400) + let cutoffString = SessionArchiveStore.dateFormatter.string(from: cutoffDate) + + for url in jsonFiles { + let name = url.deletingPathExtension().lastPathComponent + if name < cutoffString { + try? FileManager.default.removeItem(at: url) + } + } + } + } + + // MARK: - Private helpers (must be called inside queue) + + private static let utcCalendar: Calendar = { + var c = Calendar(identifier: .gregorian) + c.timeZone = TimeZone(identifier: "UTC")! + return c + }() + + /// Returns midnight UTC on the same day as `date`, or `nil` if the calendar conversion fails. + private func _startOfDay(_ date: Date) -> Date? { + var components = Self.utcCalendar.dateComponents([.year, .month, .day], from: date) + components.hour = 0 + components.minute = 0 + components.second = 0 + components.nanosecond = 0 + return Self.utcCalendar.date(from: components) + } + + private func _fileURL(for date: Date) -> URL { + let name = SessionArchiveStore.dateFormatter.string(from: date) + return baseURL.appendingPathComponent("\(name).json") + } + + private func _readRecords(from url: URL) -> [SessionArchive] { + guard let data = try? Data(contentsOf: url) else { return [] } + return (try? SessionArchiveStore.decoder.decode([SessionArchive].self, from: data)) ?? [] + } + + private func _rebuildDedupSet() -> Set { + guard let contents = try? FileManager.default.contentsOfDirectory( + at: baseURL, includingPropertiesForKeys: nil + ) else { return [] } + + var ids = Set() + for url in contents where url.pathExtension == "json" { + let records = _readRecords(from: url) + for r in records { ids.insert(r.sessionId) } + } + return ids + } +} diff --git a/Sources/AgentPetCore/SessionStore.swift b/Sources/AgentPetCore/SessionStore.swift index f92c7b1..db448e4 100644 --- a/Sources/AgentPetCore/SessionStore.swift +++ b/Sources/AgentPetCore/SessionStore.swift @@ -18,6 +18,8 @@ public final class SessionStore { /// anything, so a quiet/abandoned one shouldn't linger as "running". public var staleRegisteredAfter: TimeInterval + public var archiveStore: SessionArchiveStore? + private var byID: [String: AgentSession] = [:] public init(doneToIdleAfter: TimeInterval = 30, @@ -70,6 +72,9 @@ public final class SessionStore { // A session-end event (agent quit/closed) removes the session at once, // so it doesn't linger as "done" until the idle timeout. if StateMapper.isSessionEnd(for: event.agentKind, eventName: event.eventName) { + if let session = byID[event.sessionId] { + archiveStore?.archive(session, startedAt: session.createdAt, endedAt: now) + } byID.removeValue(forKey: event.sessionId) return nil } @@ -116,14 +121,17 @@ public final class SessionStore { } case .idle: if quiet >= removeIdleAfter { + archiveStore?.archive(session, startedAt: session.createdAt, endedAt: now) byID.removeValue(forKey: id) } case .registered: if quiet >= staleRegisteredAfter { + archiveStore?.archive(session, startedAt: session.createdAt, endedAt: now) byID.removeValue(forKey: id) } case .working, .waiting: if quiet >= staleActiveAfter { + archiveStore?.archive(session, startedAt: session.createdAt, endedAt: now) byID.removeValue(forKey: id) } } diff --git a/Sources/App/AppDaemon.swift b/Sources/App/AppDaemon.swift index 9938ce6..c19f684 100644 --- a/Sources/App/AppDaemon.swift +++ b/Sources/App/AppDaemon.swift @@ -12,7 +12,11 @@ final class AppDaemon: ObservableObject { @Published private(set) var sessions: [AgentSession] = [] - private let store = SessionStore() + private let store: SessionStore = { + let s = SessionStore() + s.archiveStore = SessionArchiveStore.shared + return s + }() private let server = EventSocketServer(path: AgentPetPaths.socketPath) private var pruneTimer: Timer? diff --git a/Sources/App/HistoryTabView.swift b/Sources/App/HistoryTabView.swift new file mode 100644 index 0000000..b9b2b01 --- /dev/null +++ b/Sources/App/HistoryTabView.swift @@ -0,0 +1,237 @@ +import SwiftUI +import AgentPetCore + +struct HistoryTabView: View { + enum Filter: String, CaseIterable { + case today = "Today" + case week = "Past 7 Days" + case month = "Past 30 Days" + } + + @State private var filter: Filter = .today + @State private var records: [SessionArchive] = [] + + var body: some View { + Form { + Section { + Picker("Period", selection: $filter) { + ForEach(Filter.allCases, id: \.self) { f in + Text(f.rawValue).tag(f) + } + } + .pickerStyle(.segmented) + .labelsHidden() + } + + if records.isEmpty { + Section { + Text("No sessions yet") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 8) + } + } else { + Section { + SummaryBar(records: records) + } + ForEach(groupedByDay, id: \.0) { day, dayRecords in + Section(header: DaySectionHeader(date: day, count: dayRecords.count)) { + ForEach(dayRecords, id: \.sessionId) { record in + SessionRow(record: record) + } + } + } + } + } + .formStyle(.grouped) + .task(id: filter) { + await loadRecords() + } + } + + // MARK: - Helpers + + private var groupedByDay: [(Date, [SessionArchive])] { + let cal = Calendar.current + var dict: [Date: [SessionArchive]] = [:] + for record in records { + let day = cal.startOfDay(for: record.startedAt) + dict[day, default: []].append(record) + } + return dict.keys + .sorted(by: >) + .map { day in (day, dict[day]!.sorted { $0.startedAt > $1.startedAt }) } + } + + private func loadRecords() async { + let store = SessionArchiveStore.shared + let now = Date() + let since: Date? + switch filter { + case .today: + since = nil + case .week: + since = Calendar.current.date(byAdding: .day, value: -6, to: now) + case .month: + since = Calendar.current.date(byAdding: .day, value: -29, to: now) + } + let fetched: [SessionArchive] + if let since { + fetched = await Task.detached { store.allRecords(since: since) }.value + } else { + fetched = await Task.detached { store.records(for: now) }.value + } + records = fetched.sorted { $0.startedAt > $1.startedAt } + } +} + +// MARK: - Summary Bar + +private struct SummaryBar: View { + let records: [SessionArchive] + + private var totalDuration: TimeInterval { records.reduce(0) { $0 + $1.duration } } + + private var kindCounts: [(AgentKind, Int)] { + var dict: [AgentKind: Int] = [:] + for r in records { dict[r.agentKind, default: 0] += 1 } + return dict.sorted { $0.value > $1.value }.map { ($0.key, $0.value) } + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 16) { + Label("\(records.count) sessions", systemImage: "clock.arrow.circlepath") + .font(.caption).foregroundStyle(.secondary) + Label(formatBarDuration(totalDuration), systemImage: "timer") + .font(.caption).foregroundStyle(.secondary) + } + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(kindCounts, id: \.0.rawValue) { kind, count in + KindChip(kind: kind, count: count) + } + } + } + } + .padding(.vertical, 2) + } +} + +private struct KindChip: View { + let kind: AgentKind + let count: Int + + var body: some View { + HStack(spacing: 4) { + Image(systemName: sfSymbol(for: kind)) + .font(.system(size: 10)) + Text("\(kind.rawValue) ×\(count)") + .font(.caption2) + } + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(Capsule().fill(Color.systemAccent.opacity(0.15))) + .foregroundStyle(Color.systemAccent) + } +} + +// MARK: - Section Header + +private struct DaySectionHeader: View { + let date: Date + let count: Int + + var body: some View { + HStack { + Text(date, style: .date) + Spacer() + Text("\(count) session\(count == 1 ? "" : "s")") + .foregroundStyle(.secondary) + } + .font(.caption) + } +} + +// MARK: - Row + +private struct SessionRow: View { + let record: SessionArchive + + private var displayTitle: String { + if let t = record.title, !t.isEmpty { return t } + if let m = record.message, !m.isEmpty { return String(m.prefix(50)) } + return record.sessionId + } + + var body: some View { + HStack(spacing: 10) { + Image(systemName: sfSymbol(for: record.agentKind)) + .font(.system(size: 16)) + .frame(width: 28, height: 28) + .background(RoundedRectangle(cornerRadius: 6).fill(Color.systemAccent.opacity(0.12))) + .foregroundStyle(Color.systemAccent) + + VStack(alignment: .leading, spacing: 2) { + Text(displayTitle) + .font(.callout) + .lineLimit(1) + if let project = record.project, !project.isEmpty { + Text(project) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + + Spacer() + + VStack(alignment: .trailing, spacing: 2) { + Text(formatRowDuration(record.duration)) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + if let tokens = record.tokenCount { + Text("\(tokens)t") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + } + .padding(.vertical, 2) + } +} + +// MARK: - Helpers + +private func formatBarDuration(_ interval: TimeInterval) -> String { + let h = Int(interval) / 3600 + let m = (Int(interval) % 3600) / 60 + if h > 0 { return "\(h)h \(m)m" } + return "\(m)m" +} + +private func formatRowDuration(_ interval: TimeInterval) -> String { + let h = Int(interval) / 3600 + let m = (Int(interval) % 3600) / 60 + let s = Int(interval) % 60 + if h > 0 { return String(format: "%d:%02d:%02d", h, m, s) } + return String(format: "%d:%02d", m, s) +} + +// MARK: - SF Symbol mapping + +private func sfSymbol(for kind: AgentKind) -> String { + switch kind { + case .claude: return "a.circle.fill" + case .codex: return "gearshape.fill" + case .gemini: return "sparkle" + case .cursor: return "cursorarrow" + case .opencode: return "chevron.left.forwardslash.chevron.right" + case .windsurf: return "wind" + case .antigravity: return "arrow.up.circle.fill" + case .copilot: return "airplane" + case .kiroCLI: return "k.circle.fill" + case .cli: return "terminal.fill" + case .unknown: return "questionmark.circle" + } +} diff --git a/Sources/App/SetupView.swift b/Sources/App/SetupView.swift index 540758e..5880acf 100644 --- a/Sources/App/SetupView.swift +++ b/Sources/App/SetupView.swift @@ -13,7 +13,7 @@ struct SetupView: View { /// panel can slide in on the right. Provided by SettingsWindowController. var onResize: (CGFloat) -> Void = { _ in } - enum Tab { case general, pet, care, bubble, about } + enum Tab { case general, pet, care, bubble, history, about } @State private var tab: Tab = .general @State private var demoOpen = false @@ -59,6 +59,8 @@ struct SetupView: View { CareTabView() case .bubble: BubbleSettingsView() + case .history: + HistoryTabView() case .about: AboutTab() } @@ -89,6 +91,7 @@ struct SetupView: View { TabButton(icon: "pawprint.fill", label: "Pet", selected: tab == .pet) { tab = .pet } TabButton(icon: "fork.knife", label: "Care", selected: tab == .care) { tab = .care } TabButton(icon: "bubble.left.and.bubble.right.fill", label: "Bubble", selected: tab == .bubble) { tab = .bubble } + TabButton(icon: "clock.arrow.circlepath", label: "History", selected: tab == .history) { tab = .history } TabButton(icon: "heart.fill", label: "About", selected: tab == .about) { tab = .about } } .frame(maxWidth: .infinity) diff --git a/Tests/AgentPetCoreTests/SessionArchiveStoreTests.swift b/Tests/AgentPetCoreTests/SessionArchiveStoreTests.swift new file mode 100644 index 0000000..9648b0a --- /dev/null +++ b/Tests/AgentPetCoreTests/SessionArchiveStoreTests.swift @@ -0,0 +1,255 @@ +import XCTest +@testable import AgentPetCore + +// MARK: - Helpers + +private func makeSession( + id: String = "session-001", + agentKind: AgentKind = .claude, + project: String? = "/proj/alpha", + title: String? = "Test Title", + message: String? = "All done" +) -> AgentSession { + AgentSession( + id: id, + agentKind: agentKind, + project: project, + title: title, + state: .done, + message: message, + source: .hook, + updatedAt: Date(timeIntervalSince1970: 1_000_000) + ) +} + +private func makeTmpStore() -> (SessionArchiveStore, URL) { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("SessionArchiveStoreTests-\(UUID().uuidString)", isDirectory: true) + return (SessionArchiveStore(baseURL: dir), dir) +} + +// MARK: - SessionArchive Model Tests + +final class SessionArchiveModelTests: XCTestCase { + + // [TEST] SessionArchive JSON encode/decode round-trip,Date 必須以 ISO8601 保留精度 + func testJSONRoundTripPreservesAllFields() throws { + let startedAt = Date(timeIntervalSince1970: 1_700_000_000) + let endedAt = Date(timeIntervalSince1970: 1_700_000_120) + let archive = SessionArchive( + sessionId: "abc-123", + agentKind: .claude, + project: "/proj/beta", + title: "Feature: dark mode", + message: "Implemented successfully", + tokenCount: 4200, + startedAt: startedAt, + endedAt: endedAt, + duration: 120 + ) + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(archive) + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let decoded = try decoder.decode(SessionArchive.self, from: data) + + XCTAssertEqual(decoded.sessionId, "abc-123") + XCTAssertEqual(decoded.agentKind, .claude) + XCTAssertEqual(decoded.project, "/proj/beta") + XCTAssertEqual(decoded.title, "Feature: dark mode") + XCTAssertEqual(decoded.message, "Implemented successfully") + XCTAssertEqual(decoded.tokenCount, 4200) + XCTAssertEqual(decoded.duration, 120, accuracy: 0.001) + // ISO8601 精度到秒,允許 1 秒誤差 + XCTAssertEqual(decoded.startedAt.timeIntervalSince1970, startedAt.timeIntervalSince1970, accuracy: 1) + XCTAssertEqual(decoded.endedAt.timeIntervalSince1970, endedAt.timeIntervalSince1970, accuracy: 1) + } + + // tokenCount 為 optional,nil 時不影響其他欄位 encode/decode + func testJSONRoundTripWithNilOptionals() throws { + let archive = SessionArchive( + sessionId: "xyz-999", + agentKind: .codex, + project: nil, + title: nil, + message: nil, + tokenCount: nil, + startedAt: Date(timeIntervalSince1970: 1_000_000), + endedAt: Date(timeIntervalSince1970: 1_000_060), + duration: 60 + ) + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(archive) + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let decoded = try decoder.decode(SessionArchive.self, from: data) + + XCTAssertEqual(decoded.sessionId, "xyz-999") + XCTAssertNil(decoded.project) + XCTAssertNil(decoded.title) + XCTAssertNil(decoded.message) + XCTAssertNil(decoded.tokenCount) + } +} + +// MARK: - SessionArchiveStore Tests + +final class SessionArchiveStoreTests: XCTestCase { + + // [TEST] archive() 後 records(for:) 返回正確記錄 + func testArchiveAndRetrieveOnSameDay() { + let (store, dir) = makeTmpStore() + defer { try? FileManager.default.removeItem(at: dir) } + + let startedAt = Date(timeIntervalSince1970: 1_700_000_000) + let endedAt = startedAt.addingTimeInterval(90) + let session = makeSession(id: "s-001") + + store.archive(session, startedAt: startedAt, endedAt: endedAt) + + let records = store.records(for: startedAt) + XCTAssertEqual(records.count, 1) + XCTAssertEqual(records.first?.sessionId, "s-001") + XCTAssertEqual(records.first?.duration ?? -1, 90, accuracy: 0.001) + } + + // [TEST] 去重:同一 sessionId archive 兩次,records(for:) 只返回一筆 + func testDeduplicationPreventsDuplicateSessionId() { + let (store, dir) = makeTmpStore() + defer { try? FileManager.default.removeItem(at: dir) } + + let startedAt = Date(timeIntervalSince1970: 1_700_000_000) + let endedAt = startedAt.addingTimeInterval(60) + let session = makeSession(id: "dup-session") + + store.archive(session, startedAt: startedAt, endedAt: endedAt) + store.archive(session, startedAt: startedAt, endedAt: endedAt) + + let records = store.records(for: startedAt) + XCTAssertEqual(records.count, 1, "same sessionId archived twice should only appear once") + } + + // [TEST] app restart 後去重仍然有效(新 store 實例讀取同一 baseURL) + func testDeduplicationPersistsAcrossStoreInstances() { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("SessionArchiveStoreTests-restart-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let startedAt = Date(timeIntervalSince1970: 1_700_000_000) + let endedAt = startedAt.addingTimeInterval(60) + let session = makeSession(id: "persist-dedup") + + // 第一個 store 實例 archive + let store1 = SessionArchiveStore(baseURL: dir) + store1.archive(session, startedAt: startedAt, endedAt: endedAt) + + // 第二個 store 實例(模擬 app restart)嘗試重複 archive + let store2 = SessionArchiveStore(baseURL: dir) + store2.archive(session, startedAt: startedAt, endedAt: endedAt) + + let records = store2.records(for: startedAt) + XCTAssertEqual(records.count, 1, "dedup set must be rebuilt from disk on init so restart cannot create duplicates") + } + + // [TEST] pruneOlderThan(days:) 刪除超過天數嘅檔案,保留符合條件嘅 + func testPruneOlderThanRemovesOldFilesAndKeepsRecent() { + let (store, dir) = makeTmpStore() + defer { try? FileManager.default.removeItem(at: dir) } + + let now = Date(timeIntervalSince1970: 1_700_000_000) + // 91 日前 + let oldDate = now.addingTimeInterval(-91 * 86400) + // 45 日前 + let midDate = now.addingTimeInterval(-45 * 86400) + // 今天 + let recentDate = now + + let s1 = makeSession(id: "old-session") + let s2 = makeSession(id: "mid-session") + let s3 = makeSession(id: "recent-session") + + store.archive(s1, startedAt: oldDate, endedAt: oldDate.addingTimeInterval(60)) + store.archive(s2, startedAt: midDate, endedAt: midDate.addingTimeInterval(60)) + store.archive(s3, startedAt: recentDate, endedAt: recentDate.addingTimeInterval(60)) + + store.pruneOlderThan(days: 90) + + // 91 日前的應該被刪除 + XCTAssertTrue(store.records(for: oldDate).isEmpty, "file older than 90 days should be pruned") + // 45 日前的應該保留 + XCTAssertEqual(store.records(for: midDate).count, 1, "file within 90 days should be kept") + // 今天的應該保留 + XCTAssertEqual(store.records(for: recentDate).count, 1, "today's file should be kept") + } + + // [TEST] allRecords(since:) 跨日期聚合返回所有符合日期範圍的記錄 + func testAllRecordsSinceAggregatesAcrossMultipleDays() { + let (store, dir) = makeTmpStore() + defer { try? FileManager.default.removeItem(at: dir) } + + let now = Date(timeIntervalSince1970: 1_700_000_000) + let day1 = now.addingTimeInterval(-2 * 86400) + let day2 = now.addingTimeInterval(-1 * 86400) + + store.archive(makeSession(id: "day1-s1"), startedAt: day1, endedAt: day1.addingTimeInterval(60)) + store.archive(makeSession(id: "day2-s1"), startedAt: day2, endedAt: day2.addingTimeInterval(60)) + store.archive(makeSession(id: "today-s1"), startedAt: now, endedAt: now.addingTimeInterval(60)) + + let all = store.allRecords(since: day1) + XCTAssertEqual(all.count, 3, "allRecords(since:) should include records from day1 up to today") + } + + // [TEST] allRecords(since:) 不返回 since 日期之前的記錄 + func testAllRecordsSinceExcludesBeforeCutoff() { + let (store, dir) = makeTmpStore() + defer { try? FileManager.default.removeItem(at: dir) } + + let now = Date(timeIntervalSince1970: 1_700_000_000) + let beforeCutoff = now.addingTimeInterval(-10 * 86400) + let cutoff = now.addingTimeInterval(-5 * 86400) + let afterCutoff = now.addingTimeInterval(-2 * 86400) + + store.archive(makeSession(id: "before"), startedAt: beforeCutoff, endedAt: beforeCutoff.addingTimeInterval(60)) + store.archive(makeSession(id: "after"), startedAt: afterCutoff, endedAt: afterCutoff.addingTimeInterval(60)) + + let records = store.allRecords(since: cutoff) + let ids = records.map(\.sessionId) + XCTAssertFalse(ids.contains("before"), "records before cutoff date should be excluded") + XCTAssertTrue(ids.contains("after"), "records after cutoff date should be included") + } + + // [TEST] 每日 JSON 檔名格式為 YYYY-MM-DD.json + func testDailyFileUsesDateBasedFilename() throws { + let (store, dir) = makeTmpStore() + defer { try? FileManager.default.removeItem(at: dir) } + + // 使用固定時間確保可預測的日期(UTC) + // 1_700_000_000 = 2023-11-14 22:13:20 UTC + let startedAt = Date(timeIntervalSince1970: 1_700_000_000) + store.archive(makeSession(id: "filename-test"), startedAt: startedAt, endedAt: startedAt.addingTimeInterval(30)) + + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + formatter.timeZone = TimeZone(identifier: "UTC") + let expectedFilename = formatter.string(from: startedAt) + ".json" + let expectedPath = dir.appendingPathComponent(expectedFilename) + + XCTAssertTrue(FileManager.default.fileExists(atPath: expectedPath.path), + "archive file should be named \(expectedFilename)") + } + + // [TEST] shared singleton baseURL 預設指向 ~/.agentpet/history/ + func testSharedSingletonBaseURLIsDefaultPath() { + let shared = SessionArchiveStore.shared + let expectedBase = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".agentpet/history", isDirectory: true) + XCTAssertEqual(shared.baseURL, expectedBase, + "shared singleton baseURL must default to ~/.agentpet/history/") + } +} diff --git a/Tests/AgentPetCoreTests/SessionStoreArchiveTests.swift b/Tests/AgentPetCoreTests/SessionStoreArchiveTests.swift new file mode 100644 index 0000000..44411b3 --- /dev/null +++ b/Tests/AgentPetCoreTests/SessionStoreArchiveTests.swift @@ -0,0 +1,210 @@ +import XCTest +@testable import AgentPetCore + +// MARK: - Helpers + +private func makeTmpArchiveStore() -> (SessionArchiveStore, URL) { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("SessionStoreArchiveTests-\(UUID().uuidString)", isDirectory: true) + return (SessionArchiveStore(baseURL: dir), dir) +} + +// MARK: - SessionStore + archiveStore Integration Tests + +final class SessionStoreArchiveTests: XCTestCase { + + private let t0 = Date(timeIntervalSince1970: 1_000_000) + + private func event( + _ name: String, + session: String = "s1", + project: String? = "/proj" + ) -> AgentEvent { + AgentEvent( + sessionId: session, + agentKind: .claude, + eventName: name, + project: project, + message: nil, + timestamp: t0 + ) + } + + // MARK: Test 1 + // idle session 被 prune 時,archiveStore 收到正確 sessionId、createdAt、endedAt + + func testIdleSessionPruneArchivesWithCorrectTimestamps() { + let (archiveStore, dir) = makeTmpArchiveStore() + defer { try? FileManager.default.removeItem(at: dir) } + + let store = SessionStore(doneToIdleAfter: 30, removeIdleAfter: 60) + store.archiveStore = archiveStore + + // working → done + store.apply(event("PreToolUse"), now: t0) + store.apply(event("Stop"), now: t0.addingTimeInterval(10)) + + // prune: done → idle at t0+40 + store.prune(now: t0.addingTimeInterval(40)) + // prune: idle timeout → remove + archive at t0+110 + let pruneNow = t0.addingTimeInterval(110) + store.prune(now: pruneNow) + + let records = archiveStore.records(for: t0) + XCTAssertEqual(records.count, 1, "idle session removed by prune should be archived") + + let record = records.first! + XCTAssertEqual(record.sessionId, "s1") + // createdAt 應係 session 首次出現時(t0),endedAt 係 prune 發生時 + XCTAssertEqual(record.startedAt.timeIntervalSince1970, t0.timeIntervalSince1970, accuracy: 1, + "startedAt should equal session createdAt") + XCTAssertEqual(record.endedAt.timeIntervalSince1970, pruneNow.timeIntervalSince1970, accuracy: 1, + "endedAt should equal the prune timestamp") + } + + // MARK: Test 2 + // isSessionEnd 路徑(SessionEnd event)觸發 archive + + func testSessionEndEventArchivesBeforeRemoval() { + let (archiveStore, dir) = makeTmpArchiveStore() + defer { try? FileManager.default.removeItem(at: dir) } + + let store = SessionStore() + store.archiveStore = archiveStore + + // 建立 session,做一些工作 + store.apply(event("PreToolUse"), now: t0) + store.apply(event("Stop"), now: t0.addingTimeInterval(5)) + + // SessionEnd → isSessionEnd path → should archive then remove + let endNow = t0.addingTimeInterval(10) + store.apply(event("SessionEnd"), now: endNow) + + // session 已被 remove + XCTAssertNil(store.session(id: "s1"), "session should be removed after SessionEnd") + + // archive 應該有記錄 + let records = archiveStore.records(for: t0) + XCTAssertEqual(records.count, 1, "SessionEnd should trigger archive") + + let record = records.first! + XCTAssertEqual(record.sessionId, "s1") + XCTAssertEqual(record.endedAt.timeIntervalSince1970, endNow.timeIntervalSince1970, accuracy: 1, + "endedAt should equal the SessionEnd event timestamp") + } + + // MARK: Test 3 + // stale working session 被 prune 時觸發 archive + + func testStaleWorkingSessionPruneArchives() { + let (archiveStore, dir) = makeTmpArchiveStore() + defer { try? FileManager.default.removeItem(at: dir) } + + let store = SessionStore(staleActiveAfter: 300) + store.archiveStore = archiveStore + + store.apply(event("PreToolUse"), now: t0) + + // 超過 staleActiveAfter 閾值 + let pruneNow = t0.addingTimeInterval(300) + store.prune(now: pruneNow) + + XCTAssertNil(store.session(id: "s1"), "stale working session should be removed") + + let records = archiveStore.records(for: t0) + XCTAssertEqual(records.count, 1, "stale working session removed by prune should be archived") + XCTAssertEqual(records.first?.sessionId, "s1") + XCTAssertEqual(records.first!.endedAt.timeIntervalSince1970, pruneNow.timeIntervalSince1970, accuracy: 1) + } + + // MARK: Test 4 + // archiveStore == nil 時 prune/apply 無 crash(backward compatible) + + func testNilArchiveStoreDoesNotCrashOnPrune() { + let store = SessionStore(doneToIdleAfter: 10, removeIdleAfter: 20) + // archiveStore 預設 nil,唔設定 + + store.apply(event("PreToolUse"), now: t0) + store.apply(event("Stop"), now: t0.addingTimeInterval(1)) + + // prune → done → idle + store.prune(now: t0.addingTimeInterval(15)) + // prune → remove idle(no archiveStore → should not crash) + store.prune(now: t0.addingTimeInterval(45)) + + XCTAssertNil(store.session(id: "s1"), "session should be removed even without archiveStore") + } + + func testNilArchiveStoreDoesNotCrashOnSessionEnd() { + let store = SessionStore() + // archiveStore == nil + + store.apply(event("PreToolUse"), now: t0) + store.apply(event("SessionEnd"), now: t0.addingTimeInterval(5)) + + XCTAssertNil(store.session(id: "s1"), "session removed without archiveStore should not crash") + } + + // MARK: Test 5 + // 現有測試 regression check(SessionStore 基本行為唔受影響) + + func testExistingBehaviourUnaffected_applyCreatesSession() { + let store = SessionStore() + let s = store.apply(event("PreToolUse"), now: t0) + XCTAssertEqual(s?.state, .working) + XCTAssertEqual(store.sessions.count, 1) + } + + func testExistingBehaviourUnaffected_pruneRemovesLongIdle() { + let store = SessionStore(doneToIdleAfter: 30, removeIdleAfter: 60) + store.apply(event("Stop"), now: t0) + store.prune(now: t0.addingTimeInterval(40)) // done → idle + store.prune(now: t0.addingTimeInterval(110)) // idle → remove + XCTAssertNil(store.session(id: "s1")) + } + + // MARK: Test 6 + // 同一 session 跨 prune cycle 唔會 archive 兩次 + + func testSameSessionNotArchivedTwiceAcrossPruneCycles() { + let (archiveStore, dir) = makeTmpArchiveStore() + defer { try? FileManager.default.removeItem(at: dir) } + + let store = SessionStore(doneToIdleAfter: 10, removeIdleAfter: 20) + store.archiveStore = archiveStore + + store.apply(event("PreToolUse"), now: t0) + store.apply(event("Stop"), now: t0.addingTimeInterval(1)) + + // first prune cycle: done → idle + store.prune(now: t0.addingTimeInterval(15)) + // second prune cycle: idle → remove (archive here) + store.prune(now: t0.addingTimeInterval(40)) + // third prune cycle: session already gone, no-op + store.prune(now: t0.addingTimeInterval(60)) + + let records = archiveStore.records(for: t0) + XCTAssertEqual(records.count, 1, "same session must not be archived more than once across prune cycles") + } + + // MARK: - createdAt correctness + + // createdAt 應係 session 首次 apply 時嘅 `now`,唔隨後續 event 更新 + + func testCreatedAtIsSetOnFirstApplyAndNotUpdatedBySubsequentEvents() { + let store = SessionStore() + + let firstEventTime = t0 + let laterEventTime = t0.addingTimeInterval(50) + + store.apply(event("PreToolUse"), now: firstEventTime) + store.apply(event("Stop"), now: laterEventTime) + + let session = store.session(id: "s1") + XCTAssertNotNil(session) + XCTAssertEqual(session!.createdAt.timeIntervalSince1970, firstEventTime.timeIntervalSince1970, accuracy: 0.001, + "createdAt must equal the first event time and not be overwritten by later events") + XCTAssertEqual(session!.updatedAt.timeIntervalSince1970, laterEventTime.timeIntervalSince1970, accuracy: 0.001, + "updatedAt should reflect the most recent event") + } +}