diff --git a/Packages/Sources/RxCodeCore/Backend/IDEToolRegistry.swift b/Packages/Sources/RxCodeCore/Backend/IDEToolRegistry.swift index 6d0921b..ffc3187 100644 --- a/Packages/Sources/RxCodeCore/Backend/IDEToolRegistry.swift +++ b/Packages/Sources/RxCodeCore/Backend/IDEToolRegistry.swift @@ -198,7 +198,7 @@ public enum IDEToolRegistry { ), IDETool( name: "ide__memory_add", - description: "Add a durable memory to RxCode. Use only for stable user preferences, project facts, or decisions that should help future work.", + description: "Add a durable memory to RxCode. Use only when the user explicitly asks to remember something, states a stable preference, or gives a recurring instruction for future/next-time agent runs. Do not save completed work, build results, files changed, available tools, or other transient task details.", visibility: .alwaysIDEOnly, inputSchema: .object([ "type": .string("object"), diff --git a/Packages/Sources/RxCodeCore/Models/JobActivityTracker.swift b/Packages/Sources/RxCodeCore/Models/JobActivityTracker.swift new file mode 100644 index 0000000..4c3b75c --- /dev/null +++ b/Packages/Sources/RxCodeCore/Models/JobActivityTracker.swift @@ -0,0 +1,184 @@ +import Foundation + +/// Latest content the desktop knows for one job in the aggregate Live +/// Activity. Stored in `JobActivityTracker.trackedJobs` in start order. +public struct JobContent: Sendable, Equatable { + public var sessionID: String + public var title: String + public var projectName: String + public var todoDone: Int + public var todoTotal: Int + public var currentStep: String? + /// `true` once the job has finished. It shows the "done" phase but stays + /// in the aggregate list so the activity can report the completed batch. + public var isDone: Bool + /// `true` once the job has finished and the user has viewed it. A read job + /// is frozen — later summaries no longer mutate the tracked entry, so an + /// acknowledged job stops generating Live Activity pushes while staying + /// visible. Deliberately excluded from `signature`: a read-state flip + /// alone must never trigger a push. + public var isRead: Bool + + public init( + sessionID: String, + title: String, + projectName: String, + todoDone: Int, + todoTotal: Int, + currentStep: String?, + isDone: Bool, + isRead: Bool + ) { + self.sessionID = sessionID + self.title = title + self.projectName = projectName + self.todoDone = todoDone + self.todoTotal = todoTotal + self.currentStep = currentStep + self.isDone = isDone + self.isRead = isRead + } + + /// Identifies a distinct rendered state for one job, so an update only + /// pushes on a real change rather than on every session event. Includes + /// `title` so the activity refreshes when the desktop swaps in an + /// AI-summarized title. `isRead` is intentionally excluded. + public var signature: String { + "\(sessionID)|\(isDone ? "done" : "run")|\(title)|\(todoDone)/\(todoTotal)|\(currentStep ?? "")" + } +} + +/// Pure, testable state machine behind the aggregate Live Activity. Owns the +/// tracked-job list and the set of streaming sessions, and folds session +/// updates into them. Holds no networking or scheduling — `MobileSyncService` +/// wraps it and performs the throttled APNs pushes on top. +public struct JobActivityTracker: Sendable { + /// Every job tracked by the single aggregate Live Activity: those still + /// running plus recently finished ones, in start order. + public private(set) var trackedJobs: [JobContent] = [] + /// Session ids currently streaming — the live job count for the widget. + public private(set) var streamingSessionIDs: Set = [] + + /// Maximum jobs retained before the oldest finished ones are dropped, so a + /// long-lived device never accumulates an unbounded history. + public static let cap = 6 + + public init() {} + + /// Concatenated per-job signatures — identifies a distinct rendered state. + public var jobsSignature: String { + trackedJobs.map(\.signature).joined(separator: ";") + } + + /// `true` once every tracked job has finished. + public var allJobsDone: Bool { + !trackedJobs.isEmpty && trackedJobs.allSatisfy(\.isDone) + } + + /// Outcome of folding one session update into the tracker. + public struct IngestResult: Equatable, Sendable { + /// The tracked-job list changed — the Live Activity may need a push. + public var jobsChanged: Bool + /// A finished batch was cleared or a job was re-keyed: the caller must + /// reset its last-pushed signature so the next push is forced out. + public var batchReset: Bool + /// The activity went from every job finished to a job running again. + /// The caller should push immediately, bypassing the update throttle, + /// so the Live Activity wakes up at once instead of a window later. + public var resumedWork: Bool + } + + /// Fold one session update into the job set, mirroring the desktop's + /// `updateJobTracking`: + /// + /// - A previously-tracked session that re-keys (`previousSessionID`) is + /// moved to the new id, or dropped when no content is supplied. + /// - The streaming set follows `streamingOverride` when given. + /// - A running session is inserted or updated; a finished session updates + /// an existing entry only. When a new job starts while every tracked job + /// is already done, the previous (acknowledged) batch is cleared. + /// - A finished job the user has already read is frozen — later non- + /// streaming updates for it are ignored, but a fresh stream revives it. + @discardableResult + public mutating func ingest( + sessionID: String, + content: JobContent?, + streamingOverride: Bool?, + previousSessionID: String? + ) -> IngestResult { + var jobsChanged = false + var batchReset = false + // Captured before folding so a complete → running transition can be + // reported back to the caller for an immediate, un-throttled push. + let wasAllDone = allJobsDone + + if let previousSessionID, previousSessionID != sessionID { + streamingSessionIDs.remove(previousSessionID) + if let prevIdx = trackedJobs.firstIndex(where: { $0.sessionID == previousSessionID }) { + if let content { + trackedJobs[prevIdx] = content + } else { + trackedJobs.remove(at: prevIdx) + } + jobsChanged = true + batchReset = true + } + } + + if let streamingOverride { + if streamingOverride { + streamingSessionIDs.insert(sessionID) + } else { + streamingSessionIDs.remove(sessionID) + } + } + + if let content { + if let idx = trackedJobs.firstIndex(where: { $0.sessionID == content.sessionID }) { + // A finished job the user has already viewed is frozen: keep it + // exactly as last rendered while it stays non-streaming. The + // freeze lifts the instant the session streams again, so a + // follow-up turn brings the job back to "running". + let frozen = trackedJobs[idx].isDone + && trackedJobs[idx].isRead + && content.isDone + if !frozen, trackedJobs[idx] != content { + trackedJobs[idx] = content + jobsChanged = true + } + } else if !content.isDone { + // A new running job. If every tracked job is already finished, + // clear that acknowledged batch so the activity starts fresh. + if !trackedJobs.isEmpty, trackedJobs.allSatisfy(\.isDone) { + trackedJobs.removeAll() + batchReset = true + } + trackedJobs.append(content) + jobsChanged = true + } + if prune() { jobsChanged = true } + } + + return IngestResult( + jobsChanged: jobsChanged, + batchReset: batchReset, + resumedWork: wasAllDone && !allJobsDone + ) + } + + /// Cap the tracked-job list, dropping the oldest finished jobs first. + /// Returns `true` when anything was removed. + @discardableResult + private mutating func prune() -> Bool { + var removed = false + while trackedJobs.count > Self.cap { + if let doneIdx = trackedJobs.firstIndex(where: \.isDone) { + trackedJobs.remove(at: doneIdx) + } else { + trackedJobs.removeFirst() + } + removed = true + } + return removed + } +} diff --git a/Packages/Tests/RxCodeCoreTests/JobActivityTrackerTests.swift b/Packages/Tests/RxCodeCoreTests/JobActivityTrackerTests.swift new file mode 100644 index 0000000..b5b948d --- /dev/null +++ b/Packages/Tests/RxCodeCoreTests/JobActivityTrackerTests.swift @@ -0,0 +1,225 @@ +import Testing +import RxCodeCore + +/// Tests for the aggregate Live Activity job-tracking state machine — the +/// logic that decides which jobs the Live Activity shows and whether each is +/// running or done. Covers the five lifecycle scenarios that previously +/// produced stale "in progress" / wrong "all done" states. +@Suite("Job activity tracker — Live Activity job state") +struct JobActivityTrackerTests { + + // MARK: - Helpers + + /// Build job content the way `MobileSyncService.makeJobContent` does: a + /// non-streaming session is `done`, and a done session with no pending + /// unread flag counts as `read`. + private func content( + _ id: String, + streaming: Bool, + unread: Bool = false, + title: String = "Job", + done: Int = 0, + total: Int = 0, + step: String? = nil + ) -> JobContent { + let isDone = !streaming + return JobContent( + sessionID: id, title: title, projectName: "Proj", + todoDone: done, todoTotal: total, currentStep: step, + isDone: isDone, isRead: isDone && !unread + ) + } + + /// A `.streamingStarted` update — the session begins a turn. + @discardableResult + private func start( + _ t: inout JobActivityTracker, _ id: String, title: String = "Job" + ) -> JobActivityTracker.IngestResult { + t.ingest(sessionID: id, content: content(id, streaming: true, title: title), + streamingOverride: true, previousSessionID: nil) + } + + /// A `.streamingFinished` update. `unread` mirrors a background completion + /// the user has not opened yet; the default is a foreground finish. + @discardableResult + private func finish( + _ t: inout JobActivityTracker, _ id: String, unread: Bool = false, title: String = "Job" + ) -> JobActivityTracker.IngestResult { + t.ingest(sessionID: id, content: content(id, streaming: false, unread: unread, title: title), + streamingOverride: false, previousSessionID: nil) + } + + /// A `.statusChanged` update — progress, title summarization, read flips. + @discardableResult + private func status( + _ t: inout JobActivityTracker, _ id: String, streaming: Bool, + unread: Bool = false, title: String = "Job", done: Int = 0, total: Int = 0 + ) -> JobActivityTracker.IngestResult { + t.ingest( + sessionID: id, + content: content(id, streaming: streaming, unread: unread, title: title, done: done, total: total), + streamingOverride: streaming, previousSessionID: nil + ) + } + + private func ids(_ t: JobActivityTracker) -> [String] { t.trackedJobs.map(\.sessionID) } + private func job(_ t: JobActivityTracker, _ id: String) -> JobContent? { + t.trackedJobs.first { $0.sessionID == id } + } + + // MARK: - 1. Continue on the same thread + + @Test("1. Continuing the same thread revives a finished job") + func continueSameThread() { + var t = JobActivityTracker() + start(&t, "A") + #expect(job(t, "A")?.isDone == false) + + finish(&t, "A") + #expect(job(t, "A")?.isDone == true) + #expect(job(t, "A")?.isRead == true) + #expect(t.allJobsDone) + + // A follow-up turn in the same thread must bring the job back to life. + let resumed = start(&t, "A") + #expect(t.trackedJobs.count == 1) + #expect(job(t, "A")?.isDone == false) + #expect(t.allJobsDone == false, "A resumed job must not report all-done") + #expect(resumed.resumedWork, "Resuming after all-done must trigger an immediate push") + } + + @Test("A read+done job is frozen against post-completion churn") + func readJobFrozenAgainstChurn() { + var t = JobActivityTracker() + start(&t, "A", title: "Old title") + finish(&t, "A", title: "Old title") + + // A late title-summarization status update for the finished job: a + // read+done job must stay frozen and generate no further change. + let r = status(&t, "A", streaming: false, title: "AI summarized title") + #expect(job(t, "A")?.title == "Old title") + #expect(r.jobsChanged == false) + } + + @Test("A background-finished job can still flip to read") + func backgroundFinishedBecomesRead() { + var t = JobActivityTracker() + start(&t, "A") + finish(&t, "A", unread: true) + #expect(job(t, "A")?.isDone == true) + #expect(job(t, "A")?.isRead == false) + + // The user opens the thread → desktop rebroadcasts with the flag cleared. + status(&t, "A", streaming: false, unread: false) + #expect(job(t, "A")?.isRead == true) + #expect(job(t, "A")?.isDone == true) + } + + // MARK: - 2. New thread + + @Test("2. A new thread clears the finished, acknowledged batch") + func newThread() { + var t = JobActivityTracker() + start(&t, "A") + finish(&t, "A") + #expect(ids(t) == ["A"]) + + let r = start(&t, "B") + #expect(ids(t) == ["B"]) + #expect(job(t, "B")?.isDone == false) + #expect(t.allJobsDone == false) + #expect(r.batchReset) + #expect(r.resumedWork, "A new job after the all-done batch must push immediately") + } + + // MARK: - 3. Multiple threads + + @Test("3. Multiple concurrent threads are all tracked") + func multipleThreads() { + var t = JobActivityTracker() + start(&t, "A") + start(&t, "B") + start(&t, "C") + #expect(ids(t) == ["A", "B", "C"]) + #expect(t.streamingSessionIDs == ["A", "B", "C"]) + let noneDone = t.trackedJobs.allSatisfy { !$0.isDone } + #expect(noneDone) + #expect(t.allJobsDone == false) + } + + // MARK: - 4. All stops + + @Test("4. All threads stopping reports every job done") + func allStop() { + var t = JobActivityTracker() + start(&t, "A") + start(&t, "B") + finish(&t, "A") + finish(&t, "B") + #expect(ids(t) == ["A", "B"]) + #expect(t.allJobsDone) + #expect(t.streamingSessionIDs.isEmpty) + } + + // MARK: - 5. Partial stops + + @Test("5. A partial stop keeps the still-running job active") + func partialStop() { + var t = JobActivityTracker() + start(&t, "A") + start(&t, "B") + finish(&t, "A") + #expect(job(t, "A")?.isDone == true) + #expect(job(t, "B")?.isDone == false) + #expect(t.allJobsDone == false, "B is still running") + #expect(t.streamingSessionIDs == ["B"]) + + // B keeps emitting progress — A must stay done, B stays running. + status(&t, "B", streaming: true, done: 2, total: 5) + #expect(job(t, "A")?.isDone == true) + #expect(job(t, "B")?.isDone == false) + #expect(job(t, "B")?.todoTotal == 5) + #expect(t.allJobsDone == false) + } + + // MARK: - Resumed work (immediate push) + + @Test("Resuming work after every job finished signals an immediate push") + func resumedWorkSignalsImmediatePush() { + var t = JobActivityTracker() + start(&t, "A") + finish(&t, "A") + + // A new job started after the all-done batch. + let newJob = start(&t, "B") + #expect(newJob.resumedWork) + + finish(&t, "B") + // The same thread resuming after the all-done batch. + let resumed = start(&t, "B") + #expect(resumed.resumedWork) + } + + @Test("Ordinary start, progress and finish updates do not signal resumed work") + func ordinaryUpdatesDoNotResumeWork() { + var t = JobActivityTracker() + let first = start(&t, "A") + #expect(first.resumedWork == false, "The very first job is not a resume") + + let progress = status(&t, "A", streaming: true, done: 1, total: 3) + #expect(progress.resumedWork == false) + + let done = finish(&t, "A") + #expect(done.resumedWork == false) + } + + // MARK: - Capacity + + @Test("The tracked list is capped, dropping the oldest job first") + func pruneCapsList() { + var t = JobActivityTracker() + for i in 0..<(JobActivityTracker.cap + 1) { start(&t, "J\(i)") } + #expect(t.trackedJobs.count == JobActivityTracker.cap) + #expect(ids(t).contains("J0") == false, "The oldest job is dropped to honor the cap") + } +} diff --git a/RxCode.xcodeproj/project.pbxproj b/RxCode.xcodeproj/project.pbxproj index 8c0f973..41371e7 100644 --- a/RxCode.xcodeproj/project.pbxproj +++ b/RxCode.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 101010102FCB100000000002 /* AppStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 101010102FCB100000000001 /* AppStateTests.swift */; }; 33993F0F87CF4DB09F2813A8 /* AppStateProjectSwitchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4381E755142272EB2DAA9C96 /* AppStateProjectSwitchTests.swift */; }; 6E17B0012FC8000100A10001 /* LocalAIProviderAcceptanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E17B0002FC8000100A10001 /* LocalAIProviderAcceptanceTests.swift */; }; 92E180F9B311F3C72D5DE6B7 /* Cocoa.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A9993BB72A5307039A88B729 /* Cocoa.framework */; }; @@ -31,6 +32,7 @@ DFA0CCD22FB4CC01005991E1 /* PlanCardViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA0CCC12FB4CC01005991E1 /* PlanCardViewTests.swift */; }; DFA0CCD42FB4CC01005991E1 /* RxCodeChatKit in Frameworks */ = {isa = PBXBuildFile; productRef = DFA0CCC32FB4CC01005991E1 /* RxCodeChatKit */; }; DFA0CCE12FB4CC02005991E1 /* HistoryListArchiveFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA0CCD52FB4CC02005991E1 /* HistoryListArchiveFilterTests.swift */; }; + E62000012FCB000100000001 /* MemoryIntentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E62000002FCB000100000001 /* MemoryIntentTests.swift */; }; E6821AC12F7CEE7200829FC9 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = E6A001012F8A000100000001 /* SwiftTerm */; }; E6C001022F9B000100000001 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = E6C001012F9B000100000001 /* Sparkle */; }; E6D001032FA0000100000001 /* RxCodeCore in Frameworks */ = {isa = PBXBuildFile; productRef = E6D001012FA0000100000001 /* RxCodeCore */; }; @@ -99,6 +101,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 101010102FCB100000000001 /* AppStateTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppStateTests.swift; sourceTree = ""; }; 4381E755142272EB2DAA9C96 /* AppStateProjectSwitchTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppStateProjectSwitchTests.swift; sourceTree = ""; }; 6E17B0002FC8000100A10001 /* LocalAIProviderAcceptanceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocalAIProviderAcceptanceTests.swift; sourceTree = ""; }; 6E17B0032FC8000100A10001 /* RxCodeUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RxCodeUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -117,6 +120,7 @@ DFA0CCC02FB4CC01005991E1 /* PlanDecisionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PlanDecisionTests.swift; sourceTree = ""; }; DFA0CCC12FB4CC01005991E1 /* PlanCardViewTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PlanCardViewTests.swift; sourceTree = ""; }; DFA0CCD52FB4CC02005991E1 /* HistoryListArchiveFilterTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HistoryListArchiveFilterTests.swift; sourceTree = ""; }; + E62000002FCB000100000001 /* MemoryIntentTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MemoryIntentTests.swift; sourceTree = ""; }; E67335382F7356F600FD26C7 /* RxCode.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = RxCode.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -277,10 +281,12 @@ 1525FE6BFB6F06A3F00B92D3 /* RxCodeTests */ = { isa = PBXGroup; children = ( + 101010102FCB100000000001 /* AppStateTests.swift */, 4381E755142272EB2DAA9C96 /* AppStateProjectSwitchTests.swift */, DFA0CCC02FB4CC01005991E1 /* PlanDecisionTests.swift */, DFA0CCC12FB4CC01005991E1 /* PlanCardViewTests.swift */, DFA0CCD52FB4CC02005991E1 /* HistoryListArchiveFilterTests.swift */, + E62000002FCB000100000001 /* MemoryIntentTests.swift */, ); path = RxCodeTests; sourceTree = ""; @@ -682,10 +688,12 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 101010102FCB100000000002 /* AppStateTests.swift in Sources */, 33993F0F87CF4DB09F2813A8 /* AppStateProjectSwitchTests.swift in Sources */, DFA0CCD12FB4CC01005991E1 /* PlanDecisionTests.swift in Sources */, DFA0CCD22FB4CC01005991E1 /* PlanCardViewTests.swift in Sources */, DFA0CCE12FB4CC02005991E1 /* HistoryListArchiveFilterTests.swift in Sources */, + E62000012FCB000100000001 /* MemoryIntentTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/RxCode/App/AppState+Agents.swift b/RxCode/App/AppState+Agents.swift index 97c1c89..81a8a3d 100644 --- a/RxCode/App/AppState+Agents.swift +++ b/RxCode/App/AppState+Agents.swift @@ -16,6 +16,16 @@ extension AppState { await memoryService.search(query, projectId: projectId, limit: limit) } + func systemPromptMemoryItems(projectId: UUID?) async -> [MemoryItem] { + let items = await memoryService.allMemories() + return items.filter { item in + if let memoryProjectId = item.projectId { + guard memoryProjectId == projectId else { return false } + } + return Self.shouldInjectMemoryIntoSystemPrompt(item) + } + } + @discardableResult func addMemoryItem(content: String, projectId: UUID?, kind: String = "fact", scope: String = "project") async -> MemoryItem? { guard memoryEnabled else { return nil } @@ -57,17 +67,35 @@ extension AppState { memoryRevision &+= 1 } - func memoryContextSystemPrompt(for hits: [MemoryService.Hit]) -> String { - guard memoryEnabled, memoryInjectEnabled, !hits.isEmpty else { return "" } - let lines = hits.prefix(memoryMaxContextItems).enumerated().map { idx, hit in - "\(idx + 1). \(hit.item.content)" + func memoryContextSystemPrompt( + systemItems: [MemoryItem], + relatedHits: [MemoryService.Hit] + ) -> String { + guard memoryEnabled, memoryInjectEnabled, !systemItems.isEmpty || !relatedHits.isEmpty else { return "" } + + let systemIds = Set(systemItems.map(\.id)) + let systemLines = systemItems.enumerated().map { idx, item in + "\(idx + 1). \(item.content)" }.joined(separator: "\n") + let relatedLines = relatedHits + .filter { !systemIds.contains($0.item.id) } + .prefix(memoryMaxContextItems) + .enumerated() + .map { idx, hit in + "\(idx + 1). \(hit.item.content)" + } + .joined(separator: "\n") + let sections = [ + systemLines.isEmpty ? nil : "Always apply these saved user preferences and recurring instructions:\n\(systemLines)", + relatedLines.isEmpty ? nil : "Related memories for this turn:\n\(relatedLines)" + ].compactMap { $0 }.joined(separator: "\n\n") + return """ # Relevant user memory The notes below are durable user/project memories saved locally in RxCode. Use them as background context for this turn. They may be incomplete or stale; the current user message still has priority. - \(lines) + \(sections) """ } @@ -91,6 +119,7 @@ extension AppState { let userMessage = lastUserMessageText(in: messages) let finalResponse = lastAssistantResponseText(in: messages) guard !userMessage.isEmpty, !finalResponse.isEmpty else { return } + guard Self.hasExplicitMemoryIntent(userMessage) else { return } let sourceMessageId = messages.last(where: { $0.role == .user && !$0.isError })?.id let summary = allSessionSummaries.first(where: { $0.id == sessionId }) ?? summaryFor(sessionId: sessionId, projectId: projectId) diff --git a/RxCode/App/AppState+CrossProject.swift b/RxCode/App/AppState+CrossProject.swift index 2011c7e..16cd3bb 100644 --- a/RxCode/App/AppState+CrossProject.swift +++ b/RxCode/App/AppState+CrossProject.swift @@ -235,8 +235,9 @@ extension AppState { let resolvedMemoryContext: String if memoryEnabled, memoryInjectEnabled { + let systemItems = await systemPromptMemoryItems(projectId: projectId) let hits = await memoryService.search(prompt, projectId: projectId, limit: memoryMaxContextItems) - resolvedMemoryContext = memoryContextSystemPrompt(for: hits) + resolvedMemoryContext = memoryContextSystemPrompt(systemItems: systemItems, relatedHits: hits) } else { resolvedMemoryContext = "" } diff --git a/RxCode/App/AppState+MemoryIntent.swift b/RxCode/App/AppState+MemoryIntent.swift new file mode 100644 index 0000000..61f994b --- /dev/null +++ b/RxCode/App/AppState+MemoryIntent.swift @@ -0,0 +1,138 @@ +import Foundation +import RxCodeCore + +extension AppState { + static func hasExplicitMemoryIntent(_ message: String) -> Bool { + let text = normalizedMemoryIntentText(message) + guard !text.isEmpty else { return false } + + return containsAny(text, phrases: explicitMemoryPhrases) + || containsAny(text, phrases: preferencePhrases) + || containsAny(text, phrases: futureInstructionPhrases) + } + + static func shouldAcceptAgentMemoryAdd(content: String, kind: String?) -> Bool { + let text = normalizedMemoryIntentText(content) + guard !text.isEmpty else { return false } + if hasExplicitMemoryIntent(content) { return true } + if containsAny(text, phrases: transientMemoryPhrases) { return false } + + switch kind?.lowercased() { + case "preference": + return true + default: + return false + } + } + + static func shouldInjectMemoryIntoSystemPrompt(_ item: MemoryItem) -> Bool { + if item.kind.lowercased() == "preference" { return true } + let text = normalizedMemoryIntentText(item.content) + return containsAny(text, phrases: systemPromptMemoryPhrases) + } + + private static let explicitMemoryPhrases = [ + "please remember", + "remember that", + "remember to", + "remember this", + "remember my", + "save this", + "save that", + "store this", + "store that", + "add this to memory", + "add that to memory", + "add to memory", + "keep this in memory", + "keep that in memory", + "memorize this", + "forget that", + "forget this", + "delete that memory", + "delete this memory", + "remove that memory", + "remove this memory", + "that memory is wrong", + "this memory is wrong", + "memory is wrong", + "no longer remember" + ] + + private static let preferencePhrases = [ + "i prefer", + "my preference is", + "my preferred", + "i like to", + "i don't like", + "i do not like", + "i want agents to", + "i want codex to", + "i want the agent to", + "i want you to always", + "i want you to never", + "the user prefers", + "user prefers" + ] + + private static let futureInstructionPhrases = [ + "from now on", + "going forward", + "in the future", + "next time", + "next time you", + "for future", + "in future", + "always use", + "always do", + "always ask", + "always run", + "never use", + "never do", + "never ask", + "never run", + "by default", + "default to" + ] + + private static let systemPromptMemoryPhrases = [ + "always", + "never", + "from now on", + "going forward", + "in the future", + "next time", + "for future", + "in future", + "by default", + "default to" + ] + + private static let transientMemoryPhrases = [ + "0 errors", + "0 warnings", + "added ", + "build successfully", + "builds successfully", + "deleted ", + "fixed ", + "i have access", + "implemented ", + "make lint", + "removed ", + "script removed", + "untracked", + "works" + ] + + private static func normalizedMemoryIntentText(_ value: String) -> String { + value.lowercased() + .components(separatedBy: .whitespacesAndNewlines) + .filter { !$0.isEmpty } + .joined(separator: " ") + } + + private static func containsAny(_ text: String, phrases: [String]) -> Bool { + phrases.contains { text.contains($0) } + } +} diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index 75e327d..d01534f 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -758,7 +758,7 @@ final class AppState { let acpRegistryService = ACPRegistryService() let openAISummarization = OpenAISummarizationService() let foundationModelSummarization = FoundationModelSummarizationService() - let persistence: PersistenceService + let persistence: any AppStatePersistenceService let marketplace = MarketplaceService() let mcp: MCPService let threadStore: ThreadStore @@ -853,7 +853,10 @@ final class AppState { /// in the (window-less) Settings sheet. var activeProjectPath: String? - init() { + init( + persistence injectedPersistence: (any AppStatePersistenceService)? = nil, + startBackgroundServices: Bool = true + ) { let metaStore = self.metaStore let cliStore = CLISessionStore(metaStore: metaStore) self.cliStore = cliStore @@ -862,7 +865,7 @@ final class AppState { self.codex = CodexAppServer() let acp = ACPService() self.acp = acp - self.persistence = PersistenceService(metaStore: metaStore, cliStore: cliStore) + self.persistence = injectedPersistence ?? PersistenceService(metaStore: metaStore, cliStore: cliStore) self.mcp = MCPService(claudeService: claude) self.threadStore = ThreadStore.make() self.runService.onTasksChanged = { [weak self] in @@ -871,37 +874,39 @@ final class AppState { } } - // Bridge ACP `session/request_permission` and Codex in-band permission - // requests into the existing PermissionServer. - let permission = self.permission - let codex = self.codex - let ideMCPServer = self.ideMCPServer - Task { - await acp.setPermissionServer(permission) - await codex.setPermissionServer(permission) - await ideMCPServer.setHandler(self) - } - - // Boot the on-device thread search index in the background. The actor - // loads cached chunks on `start`, then kicks off a one-time backfill - // of any threads that don't have chunks yet. - let searchService = self.searchService - let memoryService = self.memoryService - let threadStore = self.threadStore - let persistence = self.persistence - Task.detached(priority: .utility) { [weak self] in - await searchService.start(threadStore: threadStore) - await memoryService.start(threadStore: threadStore) - await searchService.backfillIfNeeded( - loadAll: { @MainActor in threadStore.loadAllSummaries() }, - loadFull: { @MainActor summary -> ChatSession? in - let cwd = self?.projects.first(where: { $0.id == summary.projectId })?.path ?? "" - return await persistence.loadFullSession(summary: summary, cwd: cwd) - } - ) - } + if startBackgroundServices { + // Bridge ACP `session/request_permission` and Codex in-band permission + // requests into the existing PermissionServer. + let permission = self.permission + let codex = self.codex + let ideMCPServer = self.ideMCPServer + Task { + await acp.setPermissionServer(permission) + await codex.setPermissionServer(permission) + await ideMCPServer.setHandler(self) + } - setupMobileSyncBridge() + // Boot the on-device thread search index in the background. The actor + // loads cached chunks on `start`, then kicks off a one-time backfill + // of any threads that don't have chunks yet. + let searchService = self.searchService + let memoryService = self.memoryService + let threadStore = self.threadStore + let persistence = self.persistence + Task.detached(priority: .utility) { [weak self] in + await searchService.start(threadStore: threadStore) + await memoryService.start(threadStore: threadStore) + await searchService.backfillIfNeeded( + loadAll: { @MainActor in threadStore.loadAllSummaries() }, + loadFull: { @MainActor summary -> ChatSession? in + let cwd = self?.projects.first(where: { $0.id == summary.projectId })?.path ?? "" + return await persistence.loadFullSession(summary: summary, cwd: cwd) + } + ) + } + + setupMobileSyncBridge() + } } diff --git a/RxCode/Services/ClaudeService+Process.swift b/RxCode/Services/ClaudeService+Process.swift index d393d8c..a40a747 100644 --- a/RxCode/Services/ClaudeService+Process.swift +++ b/RxCode/Services/ClaudeService+Process.swift @@ -342,24 +342,25 @@ extension ClaudeCodeServer { guessing. Prefer reading threads first; only use `ide__send_to_thread` when \ you actually need another agent to act, since it costs tokens. - RxCode also persists durable memories — stable user preferences, project \ - facts, and decisions — across sessions. Use these tools to recall and \ - store them: + RxCode also persists durable memories across sessions. Use these tools to \ + recall saved preferences and to store only explicit user-requested memory: - `mcp__rxcode-ide__ide__memory_search` — before starting a task, search \ for saved preferences, facts, or decisions relevant to it instead of \ asking the user to repeat themselves. - - `mcp__rxcode-ide__ide__memory_add` — when the user states a stable \ - preference, project fact, or decision ("remember…", "from now on…", \ - "always…"), save it. Set `kind` (`preference`/`fact`/`decision`) and \ - `scope` (`global` for cross-project, `project` for repo-specific). + - `mcp__rxcode-ide__ide__memory_add` — only when the user explicitly asks \ + you to remember something, states a stable preference, or gives a \ + recurring instruction for future/next-time agent runs ("remember…", \ + "from now on…", "always…", "next time…"). Set `kind` \ + (`preference`/`fact`/`decision`) and `scope` (`global` for cross-project, \ + `project` for repo-specific). - `mcp__rxcode-ide__ide__memory_update` — when saved information changes, \ update the existing entry by `id` rather than adding a duplicate. - `mcp__rxcode-ide__ide__memory_delete` — remove a memory by `id` when it \ is no longer valid. - Only store stable, reusable information in memory — not transient task \ - details. + Do not store completed work, build results, files changed, available \ + tools, routine requests, or other transient task details. """ /// Build arguments array for the CLI invocation. diff --git a/RxCode/Services/CodexAppServer+Protocol.swift b/RxCode/Services/CodexAppServer+Protocol.swift index 0431b16..04f293d 100644 --- a/RxCode/Services/CodexAppServer+Protocol.swift +++ b/RxCode/Services/CodexAppServer+Protocol.swift @@ -84,23 +84,24 @@ extension CodexAppServer { reading threads first; only use `ide__send_to_thread` when you actually \ need another agent to act, since it costs tokens. - RxCode also persists durable memories — stable user preferences, project \ - facts, and decisions — across sessions. Use these tools to recall and \ - store them: + RxCode also persists durable memories across sessions. Use these tools to \ + recall saved preferences and to store only explicit user-requested memory: - `ide__memory_search` — before starting a task, search for saved \ preferences, facts, or decisions relevant to it instead of asking the \ user to repeat themselves. - - `ide__memory_add` — when the user states a stable preference, project \ - fact, or decision ("remember…", "from now on…", "always…"), save it. Set \ - `kind` (`preference`/`fact`/`decision`) and `scope` (`global` for \ - cross-project, `project` for repo-specific). + - `ide__memory_add` — only when the user explicitly asks you to remember \ + something, states a stable preference, or gives a recurring instruction \ + for future/next-time agent runs ("remember…", "from now on…", \ + "always…", "next time…"). Set `kind` \ + (`preference`/`fact`/`decision`) and `scope` (`global` for cross-project, \ + `project` for repo-specific). - `ide__memory_update` — when saved information changes, update the \ existing entry by `id` rather than adding a duplicate. - `ide__memory_delete` — remove a memory by `id` when it is no longer valid. - Only store stable, reusable information in memory — not transient task \ - details. + Do not store completed work, build results, files changed, available \ + tools, routine requests, or other transient task details. """ /// True when the `rxcode-ide` MCP bridge is present in the `-c` overrides. diff --git a/RxCode/Services/CodexAppServer+Turn.swift b/RxCode/Services/CodexAppServer+Turn.swift index b6f5af6..f14bbc0 100644 --- a/RxCode/Services/CodexAppServer+Turn.swift +++ b/RxCode/Services/CodexAppServer+Turn.swift @@ -321,8 +321,48 @@ extension CodexAppServer { try Self.writeJSONLine(Self.response(id: requestId, result: [ "decision": .string(decision == .allow ? "accept" : "reject") ]), to: stdin) + case "mcpServer/elicitation/request": + let toolUseId = Self.firstString(in: params, keys: ["elicitationId", "elicitation_id", "requestId", "request_id"]) ?? requestId + let serverName = Self.firstString(in: params, keys: ["serverName", "server_name", "server"]) ?? "mcp" + let request = params["request"]?.objectValue ?? params + let meta = request["_meta"]?.objectValue + ?? request["meta"]?.objectValue + ?? params["_meta"]?.objectValue + ?? params["meta"]?.objectValue + ?? [:] + let toolTitle = Self.firstString(in: meta, keys: ["tool_title", "toolTitle", "tool", "name"]) + let toolName = toolTitle.map { "mcp__\(serverName)__\($0)" } ?? "mcp__\(serverName)" + var input = params + if let message = Self.firstString(in: request, keys: ["message"]) { + input["message"] = .string(message) + } + if let toolParams = meta["tool_params"] ?? meta["toolParams"] { + input["tool_params"] = toolParams + } + + let decision = await permissionServer.requestDecision( + toolUseId: toolUseId, + sessionId: activeThreadId, + toolName: toolName, + toolInput: input, + mode: permissionMode + ) + let approved = Self.isApprovalDecision(decision) + try Self.writeJSONLine(Self.response(id: requestId, result: [ + "action": .string(approved ? "accept" : "decline"), + "content": approved ? .object([:]) : .null, + "_meta": .null, + ]), to: stdin) default: try Self.writeJSONLine(Self.response(id: requestId, result: [:]), to: stdin) } } + + static func isApprovalDecision(_ decision: PermissionDecision) -> Bool { + if decision == .allow { return true } + if case .allowAlwaysCommand = decision { return true } + if case .allowSessionTool = decision { return true } + if case .allowAndSetMode = decision { return true } + return false + } } diff --git a/RxCode/Services/IDEServer/AppState+IDEToolHandling.swift b/RxCode/Services/IDEServer/AppState+IDEToolHandling.swift index abba47c..bc7dbf2 100644 --- a/RxCode/Services/IDEServer/AppState+IDEToolHandling.swift +++ b/RxCode/Services/IDEServer/AppState+IDEToolHandling.swift @@ -257,11 +257,17 @@ extension AppState: IDEToolHandling { throw IDEToolError.invalidArguments("missing 'content'") } let scope = arguments["scope"]?.stringValue ?? "project" + let kind = arguments["kind"]?.stringValue ?? "fact" + guard Self.shouldAcceptAgentMemoryAdd(content: content, kind: kind) else { + throw IDEToolError.invalidArguments( + "memory_add requires an explicit user preference, future instruction, or remember-this request" + ) + } let projectId = try parseOptionalProjectId(arguments["project_id"]?.stringValue) guard let item = await addMemoryItem( content: content, projectId: projectId, - kind: arguments["kind"]?.stringValue ?? "fact", + kind: kind, scope: scope ) else { throw IDEToolError.handlerFailed("Memory could not be embedded or stored.") diff --git a/RxCode/Services/IDEServer/IDEMCPServer.swift b/RxCode/Services/IDEServer/IDEMCPServer.swift index f1f6057..14d2db1 100644 --- a/RxCode/Services/IDEServer/IDEMCPServer.swift +++ b/RxCode/Services/IDEServer/IDEMCPServer.swift @@ -336,11 +336,36 @@ actor IDEMCPServer { // MARK: - Helpers private static func toolDescriptor(_ tool: IDETool) -> [String: Any] { - [ + var descriptor: [String: Any] = [ "name": tool.name, "description": tool.description, "inputSchema": tool.inputSchema.toAny() ?? [:] ] + if let annotations = toolAnnotations(for: tool) { + descriptor["annotations"] = annotations + } + return descriptor + } + + private static func toolAnnotations(for tool: IDETool) -> [String: Any]? { + let readOnlyTools: Set = [ + "ide__get_running_jobs", + "ide__get_job_output", + "ide__get_projects", + "ide__get_threads", + "ide__get_thread_messages", + "ide__get_thread_detail", + "ide__memory_search", + "ide__get_usage", + ] + + guard readOnlyTools.contains(tool.name) else { return nil } + return [ + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + ] } /// Wrap a handler's `JSONValue` result in the MCP tool-call response diff --git a/RxCode/Services/MobileSyncService+EventDispatch.swift b/RxCode/Services/MobileSyncService+EventDispatch.swift index 6c690fb..1a7b65d 100644 --- a/RxCode/Services/MobileSyncService+EventDispatch.swift +++ b/RxCode/Services/MobileSyncService+EventDispatch.swift @@ -68,6 +68,8 @@ extension MobileSyncService { jobsActivityLocallyStarted = false lastPushedJobsSignature = "" cancelJobsActivityStart() + pendingJobsPushTask?.cancel() + pendingJobsPushTask = nil } logger.info("[LiveActivity] aggregate activity dismissed by user activity=\(t.activityID ?? "", privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") } else if t.activityStartedLocally == true { @@ -94,12 +96,15 @@ extension MobileSyncService { } else { // One aggregate activity per device — replace any prior token. pairedDevices[idx].liveActivityTokens = [ - LiveActivityTokenRef(activityID: activityID, sessionID: "", token: activityToken) + LiveActivityTokenRef(activityID: activityID, token: activityToken) ] logger.info("[LiveActivity] aggregate activity token registered activity=\(activityID, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") // Push the latest known state straight away so a freshly // started activity isn't left blank until the next change. + pendingJobsPushTask?.cancel() + pendingJobsPushTask = nil lastPushedJobsSignature = jobsSignature + lastJobsPushDate = Date() sendJobsActivityUpdate(staleAfter: allJobsDone ? 8 * 3600 : 3600) } } diff --git a/RxCode/Services/MobileSyncService+LiveActivity.swift b/RxCode/Services/MobileSyncService+LiveActivity.swift index 7328a34..df2b7fe 100644 --- a/RxCode/Services/MobileSyncService+LiveActivity.swift +++ b/RxCode/Services/MobileSyncService+LiveActivity.swift @@ -20,92 +20,56 @@ extension MobileSyncService { summary: RxCodeSync.SessionSummary?, previousSessionID: String? ) { - if let previousSessionID, previousSessionID != sessionID { - streamingSessionIDs.remove(previousSessionID) - if let previousIdx = trackedJobs.firstIndex(where: { $0.sessionID == previousSessionID }) { - if let summary { - trackedJobs[previousIdx] = makeJobContent(from: summary) - } else { - trackedJobs.remove(at: previousIdx) - } - lastPushedJobsSignature = "" - } - } - let streaming: Bool? + let streamingOverride: Bool? switch kind { - case .streamingStarted: streaming = true - case .streamingFinished: streaming = false - default: streaming = isStreaming - } - if let streaming { - if streaming { streamingSessionIDs.insert(sessionID) } - else { streamingSessionIDs.remove(sessionID) } + case .streamingStarted: streamingOverride = true + case .streamingFinished: streamingOverride = false + default: streamingOverride = isStreaming } // Summaries carry title/progress/todos — they drive the Live Activity. - if let summary { - foldSummaryIntoJobs(summary) - pushJobsActivity() - } - pushWidgetUpdateIfJobCountChanged() - } - - /// Merge one session summary into `trackedJobs`. - /// - /// A running session is inserted or updated. A finished session updates - /// the job only if it is already tracked, and is otherwise ignored — the - /// aggregate activity follows jobs it saw start. When a new job begins - /// while every tracked job is already done, the previous (acknowledged) - /// batch is cleared so the activity starts a fresh list. - func foldSummaryIntoJobs(_ summary: RxCodeSync.SessionSummary) { - let content = makeJobContent(from: summary) - if let idx = trackedJobs.firstIndex(where: { $0.sessionID == summary.id }) { - trackedJobs[idx] = content - } else if summary.isStreaming { - if !trackedJobs.isEmpty, trackedJobs.allSatisfy(\.isDone) { - trackedJobs.removeAll() - lastPushedJobsSignature = "" - } - trackedJobs.append(content) + let content = summary.map { makeJobContent(from: $0) } + let result = jobsTracker.ingest( + sessionID: sessionID, + content: content, + streamingOverride: streamingOverride, + previousSessionID: previousSessionID + ) + if result.batchReset { + // A finished batch was cleared or a job re-keyed — force the next + // push out instead of letting the signature dedup swallow it. + lastPushedJobsSignature = "" } - pruneTrackedJobs() - } - - /// Cap the tracked-job list, dropping the oldest finished jobs first so a - /// long-lived device never accumulates an unbounded history. - func pruneTrackedJobs() { - let cap = 6 - while trackedJobs.count > cap { - if let doneIdx = trackedJobs.firstIndex(where: \.isDone) { - trackedJobs.remove(at: doneIdx) - } else { - trackedJobs.removeFirst() - } + if content != nil { + // A complete → running transition wakes the activity up at once; + // ordinary changes go through the throttle. + pushJobsActivity(immediate: result.resumedWork) } + pushWidgetUpdateIfJobCountChanged() } func makeJobContent(from summary: RxCodeSync.SessionSummary) -> JobContent { - JobContent( + let isDone = !summary.isStreaming + return JobContent( sessionID: summary.id, title: summary.title, projectName: projectNameResolver?(summary.projectId) ?? "", todoDone: summary.progress?.done ?? 0, todoTotal: summary.progress?.total ?? 0, currentStep: summary.todos?.first { $0.status == .inProgress }?.activeForm, - isDone: !summary.isStreaming + isDone: isDone, + // A finished job with no unchecked completion has been seen: it + // either completed in the foreground or the user already viewed it. + isRead: isDone && !summary.hasUncheckedCompletion ) } // MARK: Aggregate Live Activity /// Concatenated per-job signatures — identifies a distinct rendered state. - var jobsSignature: String { - trackedJobs.map(\.signature).joined(separator: ";") - } + var jobsSignature: String { jobsTracker.jobsSignature } /// `true` once every tracked job has finished. - var allJobsDone: Bool { - !trackedJobs.isEmpty && trackedJobs.allSatisfy(\.isDone) - } + var allJobsDone: Bool { jobsTracker.allJobsDone } /// `true` when some paired device has registered the aggregate activity's /// update token — i.e. the activity exists and can be pushed `update`s. @@ -119,18 +83,38 @@ extension MobileSyncService { /// the lifetime of the device session: it is never ended or auto-dismissed /// by the desktop, only updated. One activity for every job keeps re-runs /// off the scarce iOS push-to-start budget; the user dismisses it. - func pushJobsActivity() { + /// + /// Update pushes are throttled to one per `jobsPushInterval`: the first + /// change in a quiet window pushes immediately, further changes coalesce + /// into a single trailing push. This keeps bursts of job/todo events from + /// exhausting the APNs Live Activity budget — the cause of the activity + /// stalling on "running" after a job has finished. + /// + /// `immediate` skips the throttle window — used when work resumes after + /// every job had finished, so the activity wakes up at once. + func pushJobsActivity(immediate: Bool = false) { guard !trackedJobs.isEmpty else { return } - let staleAfter: TimeInterval = allJobsDone ? 8 * 3600 : 3600 if hasAnyActivityToken { - let signature = jobsSignature - guard signature != lastPushedJobsSignature else { + guard jobsSignature != lastPushedJobsSignature else { logger.debug("[LiveActivity] jobs activity unchanged — skip update") return } - lastPushedJobsSignature = signature - logger.info("[LiveActivity] jobs activity update jobs=\(self.trackedJobs.count, privacy: .public) running=\(self.trackedJobs.filter { !$0.isDone }.count, privacy: .public)") - sendJobsActivityUpdate(staleAfter: staleAfter) + let elapsed = lastJobsPushDate.map { Date().timeIntervalSince($0) } + if !immediate, let elapsed, elapsed < Self.jobsPushInterval { + // Inside the throttle window — coalesce into a trailing push. + // A pending task already covers later changes: when it fires + // it re-reads `trackedJobs`, so the latest state is sent. + guard pendingJobsPushTask == nil else { return } + let delay = Self.jobsPushInterval - elapsed + logger.debug("[LiveActivity] jobs activity update coalesced — trailing push in \(Int(delay), privacy: .public)s") + pendingJobsPushTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(delay)) + guard !Task.isCancelled, let self else { return } + self.flushJobsActivityPush() + } + } else { + flushJobsActivityPush() + } } else if jobsActivityLocallyStarted { // The activity exists locally; its update token has not been // minted yet. The first push goes out when that token registers. @@ -140,6 +124,21 @@ extension MobileSyncService { } } + /// Send the coalesced aggregate update now, if the rendered state actually + /// changed since the last push. Reads `trackedJobs` at call time so a + /// trailing flush always carries the latest state. + func flushJobsActivityPush() { + pendingJobsPushTask?.cancel() + pendingJobsPushTask = nil + guard !trackedJobs.isEmpty, hasAnyActivityToken else { return } + let signature = jobsSignature + guard signature != lastPushedJobsSignature else { return } + lastPushedJobsSignature = signature + lastJobsPushDate = Date() + logger.info("[LiveActivity] jobs activity update jobs=\(self.trackedJobs.count, privacy: .public) running=\(self.trackedJobs.filter { !$0.isDone }.count, privacy: .public)") + sendJobsActivityUpdate(staleAfter: allJobsDone ? 8 * 3600 : 3600) + } + /// Schedule the push-to-start after a short delay. A foregrounded device /// starts the activity itself (no push-to-start budget) and reports it /// within a second or two, cancelling this task. Only a backgrounded @@ -192,6 +191,7 @@ extension MobileSyncService { "stale-date": Int(now.addingTimeInterval(staleAfter).timeIntervalSince1970), ]] lastPushedJobsSignature = jobsSignature + lastJobsPushDate = now logger.info("[LiveActivity] start jobs activity devices=\(devices.count, privacy: .public) jobs=\(self.trackedJobs.count, privacy: .public)") for device in devices { guard let token = device.liveActivityStartToken else { continue } @@ -359,25 +359,3 @@ extension MobileSyncService { } } } - -/// Latest content the desktop knows for one job in the aggregate Live -/// Activity. Stored in `MobileSyncService.trackedJobs` in start order. -struct JobContent { - var sessionID: String - var title: String - var projectName: String - var todoDone: Int - var todoTotal: Int - var currentStep: String? - /// `true` once the job has finished. It shows the "done" phase but stays - /// in the aggregate list so the activity can report the completed batch. - var isDone: Bool - - /// Identifies a distinct rendered state for one job, so an update only - /// pushes on a real change rather than on every session event. Includes - /// `title` so the activity refreshes when the desktop swaps in an - /// AI-summarized title. - var signature: String { - "\(sessionID)|\(isDone ? "done" : "run")|\(title)|\(todoDone)/\(todoTotal)|\(currentStep ?? "")" - } -} diff --git a/RxCode/Services/MobileSyncService.swift b/RxCode/Services/MobileSyncService.swift index abaad6a..04034ac 100644 --- a/RxCode/Services/MobileSyncService.swift +++ b/RxCode/Services/MobileSyncService.swift @@ -6,11 +6,10 @@ import RxCodeCore import RxCodeSync import os.log -/// One per-activity Live Activity push token registered by a paired mobile. -/// The desktop targets `update`/`end` pushes at `token`, scoped to `sessionID`. +/// The single aggregate Live Activity push token registered by a paired +/// mobile. The desktop targets `update`/`end` pushes at `token`. struct LiveActivityTokenRef: Codable, Sendable, Hashable { var activityID: String - var sessionID: String var token: String } @@ -100,11 +99,16 @@ final class MobileSyncService: ObservableObject { /// background push. Set by `AppState`; `nil` before that. var usageSnapshotProvider: (@MainActor () -> (cc: Double?, codex: Double?))? - /// Session ids currently streaming — the live job count for the widget. - var streamingSessionIDs: Set = [] + /// Pure state machine behind the aggregate Live Activity — the tracked-job + /// list and the streaming-session set. The folding logic lives here so it + /// can be unit-tested without the service's networking; this service layers + /// the throttled APNs pushes on top. + var jobsTracker = JobActivityTracker() /// Every job tracked by the single aggregate Live Activity: those still /// running plus recently finished ones, in start order. - var trackedJobs: [JobContent] = [] + var trackedJobs: [JobContent] { jobsTracker.trackedJobs } + /// Session ids currently streaming — the live job count for the widget. + var streamingSessionIDs: Set { jobsTracker.streamingSessionIDs } /// `true` once a foregrounded device reported it started the activity /// locally; suppresses the push-to-start until the activity goes away. var jobsActivityLocallyStarted = false @@ -115,6 +119,16 @@ final class MobileSyncService: ObservableObject { /// a foregrounded device can start the activity locally instead; this task /// is cancelled once a device reports it did. var pendingStartTask: Task? + /// Minimum spacing between aggregate Live Activity update pushes. Coalesces + /// bursts of job/todo events so APNs's scarce Live Activity budget isn't + /// exhausted — otherwise the terminal "done" push gets dropped and the + /// activity stalls on "running". + static let jobsPushInterval: TimeInterval = 5 + /// When the last aggregate update push actually went out. + var lastJobsPushDate: Date? + /// Pending trailing push that flushes coalesced changes at the end of the + /// throttle window; cancelled if the activity goes away first. + var pendingJobsPushTask: Task? /// Last widget job count pushed, so a widget push only fires on a change. var lastWidgetJobCount: Int = -1 @@ -171,6 +185,8 @@ final class MobileSyncService: ObservableObject { func stop() { eventTask?.cancel() eventTask = nil + pendingJobsPushTask?.cancel() + pendingJobsPushTask = nil Task { await client.stop() } } diff --git a/RxCode/Services/OpenAISummarizationService.swift b/RxCode/Services/OpenAISummarizationService.swift index b185000..7e334fc 100644 --- a/RxCode/Services/OpenAISummarizationService.swift +++ b/RxCode/Services/OpenAISummarizationService.swift @@ -275,14 +275,15 @@ actor OpenAISummarizationService { : existingMemories.map { "- id: \($0.id)\n content: \($0.content)" }.joined(separator: "\n") return """ - Decide whether the latest chat turn contains durable user memory for a local coding IDE. + Decide whether the latest chat turn contains explicit durable user memory for a local coding IDE. - Return [] unless the turn contains information that is likely to be useful in future, separate agent runs. - Store only stable preferences, recurring workflow instructions, project-specific decisions, naming conventions, or durable facts that would help future agent runs. - Prefer facts stated by the user. Use the assistant response only to capture confirmed outcomes or project decisions. - Do not save routine requests, one-off tasks, bug reports, temporary debugging details, implementation steps, command requests, secrets, API keys, credentials, or vague observations. + Return [] unless the user explicitly asks to remember something, states a stable preference, or gives a recurring instruction for future/next-time agent runs. + Store only the user's reusable preference, recurring workflow instruction, naming convention, or explicitly requested remember-this note. + For recurring instructions, preserve the recurrence marker in the memory text, such as "Always...", "Never...", "From now on...", "By default...", or "Next time...". + Use the assistant response only to confirm the exact wording of an explicit user-requested memory. + Do not save routine requests, one-off tasks, bug reports, temporary debugging details, implementation steps, command requests, build/test results, files changed, tool availability, secrets, API keys, credentials, or vague observations. Do not add memory simply because the user sent a message. Most user messages should produce []. - Add at most 1-3 memories, and only when each memory is clearly reusable beyond the current conversation. + Add at most 1-2 memories, and only when each memory is clearly reusable beyond the current conversation. If an existing memory should be refined, return an update operation with its id. If a memory is no longer valid because the user corrected it, return delete. Reply with ONLY a JSON array. No markdown. Each entry must be one of: diff --git a/RxCode/Services/PersistenceService.swift b/RxCode/Services/PersistenceService.swift index 10de2df..e6c3419 100644 --- a/RxCode/Services/PersistenceService.swift +++ b/RxCode/Services/PersistenceService.swift @@ -2,7 +2,39 @@ import Foundation import RxCodeCore import os -actor PersistenceService { +protocol AppStatePersistenceService: Actor { + func saveProjects(_ projects: [Project]) throws + func loadProjects() -> [Project] + + func saveSession(_ session: ChatSession, persistTitle: Bool) async throws + func loadLegacySessions(for projectId: UUID) -> [ChatSession.Summary] + func loadAllLegacySessionSummaries() -> [ChatSession.Summary] + func deleteSession(projectId: UUID, sessionId: String, origin: SessionOrigin, cwd: String?) async throws + func loadFullSession(summary: ChatSession.Summary, cwd: String) async -> ChatSession? + nonisolated func legacySessionURL(projectId: UUID, sessionId: String) -> URL + nonisolated func loadLegacySessionSync(projectId: UUID, sessionId: String) -> ChatSession? + + func saveRunProfiles(_ profiles: [RunProfile], projectId: UUID) throws + func loadRunProfiles(projectId: UUID) -> [RunProfile] + + func saveACPClients(_ clients: [ACPClientSpec]) throws + func loadACPClients() -> [ACPClientSpec] + nonisolated func acpRegistrySnapshotURL() -> URL + + func saveCustomRepos(_ repos: [CustomRepo]) throws + func loadCustomRepos() -> [CustomRepo] + + func saveGitHubUser(_ user: GitHubUser) throws + func loadGitHubUser() -> GitHubUser? +} + +extension AppStatePersistenceService { + func saveSession(_ session: ChatSession) async throws { + try await saveSession(session, persistTitle: false) + } +} + +actor PersistenceService: AppStatePersistenceService { private let baseURL: URL private let metaStore: SessionMetaStore diff --git a/RxCodeTests/AppStateProjectSwitchTests.swift b/RxCodeTests/AppStateProjectSwitchTests.swift index 1378da4..3b39477 100644 --- a/RxCodeTests/AppStateProjectSwitchTests.swift +++ b/RxCodeTests/AppStateProjectSwitchTests.swift @@ -9,7 +9,7 @@ final class AppStateProjectSwitchTests: XCTestCase { private var window: WindowState! override func setUp() async throws { - appState = AppState() + appState = AppState(startBackgroundServices: false) window = WindowState() } diff --git a/RxCodeTests/AppStateTests.swift b/RxCodeTests/AppStateTests.swift new file mode 100644 index 0000000..08c1b4a --- /dev/null +++ b/RxCodeTests/AppStateTests.swift @@ -0,0 +1,635 @@ +import XCTest +import RxCodeCore +@testable import RxCode + +@MainActor +final class AppStateTests: XCTestCase { + + private var persistence: MockAppStatePersistence! + private var appState: AppState! + private var window: WindowState! + private var defaultsSnapshot: [String: Any?] = [:] + + override func setUp() async throws { + defaultsSnapshot = [ + "selectedAgentProvider": UserDefaults.standard.object(forKey: "selectedAgentProvider"), + "selectedModel": UserDefaults.standard.object(forKey: "selectedModel"), + "selectedACPClientId": UserDefaults.standard.object(forKey: "selectedACPClientId"), + "memoryMaxContextItems": UserDefaults.standard.object(forKey: "memoryMaxContextItems"), + ] + persistence = MockAppStatePersistence() + appState = AppState(persistence: persistence, startBackgroundServices: false) + window = WindowState() + } + + override func tearDown() async throws { + for (key, value) in defaultsSnapshot { + if let value { + UserDefaults.standard.set(value, forKey: key) + } else { + UserDefaults.standard.removeObject(forKey: key) + } + } + window = nil + appState = nil + persistence = nil + } + + // MARK: - Session counters and accessors + + func testInProgressSessionCountCountsOnlyStreamingStates() { + appState.sessionStates = [ + "idle": streamState(isStreaming: false), + "live-a": streamState(isStreaming: true), + "live-b": streamState(isStreaming: true), + ] + + XCTAssertEqual(appState.inProgressSessionCount, 2) + } + + func testUncheckedFinishedSessionCountCountsOnlyUncheckedCompletions() { + var seen = SessionStreamState() + seen.hasUncheckedCompletion = false + var unseen = SessionStreamState() + unseen.hasUncheckedCompletion = true + appState.sessionStates = ["seen": seen, "unseen": unseen] + + XCTAssertEqual(appState.uncheckedFinishedSessionCount, 1) + } + + func testWindowScopedAccessorsUseCurrentSessionState() { + let sessionId = "thread-1" + window.currentSessionId = sessionId + + var state = SessionStreamState() + state.messages = [ + ChatMessage(role: .user, content: "Question"), + ChatMessage(role: .assistant, content: "Answer"), + ] + state.isStreaming = true + state.isThinking = true + state.streamingStartDate = Date(timeIntervalSince1970: 42) + state.activeModelName = "Actual Model" + state.lastTurnContextUsedPercentage = 72.5 + state.costUsd = 1.25 + state.turns = 3 + state.inputTokens = 100 + state.outputTokens = 200 + state.cacheCreationTokens = 30 + state.cacheReadTokens = 40 + state.durationMs = 5_000 + appState.sessionStates[sessionId] = state + + XCTAssertEqual(appState.messages(in: window).map(\.content), ["Question", "Answer"]) + XCTAssertTrue(appState.isStreaming(in: window)) + XCTAssertTrue(appState.isThinking(in: window)) + XCTAssertEqual(appState.streamingStartDate(in: window), Date(timeIntervalSince1970: 42)) + XCTAssertEqual(appState.activeModelName(in: window), "Actual Model") + XCTAssertEqual(appState.lastTurnContextUsedPercentage(in: window), 72.5) + XCTAssertEqual(appState.sessionCostUsd(in: window), 1.25) + XCTAssertEqual(appState.sessionTurns(in: window), 3) + XCTAssertEqual(appState.sessionInputTokens(in: window), 100) + XCTAssertEqual(appState.sessionOutputTokens(in: window), 200) + XCTAssertEqual(appState.sessionCacheCreationTokens(in: window), 30) + XCTAssertEqual(appState.sessionCacheReadTokens(in: window), 40) + XCTAssertEqual(appState.sessionDurationMs(in: window), 5_000) + } + + func testStreamStateFallsBackToNewSessionKey() { + let project = makeProject("A") + window.selectedProject = project + + var state = SessionStreamState() + state.messages = [ChatMessage(role: .user, content: "Draft thread")] + appState.sessionStates[window.newSessionKey] = state + + XCTAssertEqual(appState.messages(in: window).map(\.content), ["Draft thread"]) + } + + // MARK: - Drafts and queues + + func testDraftKeyIsProjectScopedBeforeSessionExists() { + let project = makeProject("Project") + window.selectedProject = project + + XCTAssertEqual(appState.draftKey(for: window), "new:\(project.id.uuidString)") + } + + func testSaveDraftStoresNonEmptyTextAndRemovesBlankText() { + window.currentSessionId = "session" + window.inputText = " keep me " + + appState.saveDraft(in: window) + XCTAssertEqual(window.draftTexts["session"], " keep me ") + + window.inputText = " \n\t " + appState.saveDraft(in: window) + XCTAssertNil(window.draftTexts["session"]) + } + + func testSaveQueueStoresAndRemovesCurrentQueue() { + window.currentSessionId = "session" + window.messageQueue = [QueuedMessage(text: "queued", attachments: [])] + + appState.saveQueue(in: window) + XCTAssertEqual(window.draftQueues["session"]?.map(\.text), ["queued"]) + + window.messageQueue = [] + appState.saveQueue(in: window) + XCTAssertNil(window.draftQueues["session"]) + } + + func testRenameDraftStateMovesTextAndMergesQueues() { + let moving = QueuedMessage(text: "moving", attachments: []) + let existing = QueuedMessage(text: "existing", attachments: []) + window.draftTexts["old"] = "draft" + window.draftQueues["old"] = [moving] + window.draftQueues["new"] = [existing] + + appState.renameDraftState(from: "old", to: "new", in: window) + + XCTAssertNil(window.draftTexts["old"]) + XCTAssertEqual(window.draftTexts["new"], "draft") + XCTAssertNil(window.draftQueues["old"]) + XCTAssertEqual(window.draftQueues["new"]?.map(\.text), ["existing", "moving"]) + } + + func testResetToNewChatRestoresProjectScopedDraftAndQueue() { + let project = makeProject("A") + window.selectedProject = project + window.currentSessionId = "old" + let draftKey = "new:\(project.id.uuidString)" + let stateKey = window.newSessionKey + window.draftTexts[draftKey] = "draft" + window.draftQueues[draftKey] = [QueuedMessage(text: "queued", attachments: [])] + appState.sessionStates[stateKey] = streamState(isStreaming: false) + + appState.resetToNewChat(in: window) + + XCTAssertNil(window.currentSessionId) + XCTAssertNil(window.sessionModel) + XCTAssertFalse(window.sessionPlanMode) + XCTAssertEqual(window.inputText, "draft") + XCTAssertEqual(window.messageQueue.map(\.text), ["queued"]) + XCTAssertNil(appState.sessionStates[stateKey]) + XCTAssertTrue(window.requestInputFocus) + } + + // MARK: - Message cleanup and titles + + func testCleanLoadedMessagesDropsEmptyAssistantAndStopsStreaming() { + let cleaned = appState.cleanLoadedMessages([ + ChatMessage(role: .assistant, content: ""), + ChatMessage(role: .assistant, content: "done", isStreaming: true), + ChatMessage(role: .user, content: ""), + ]) + + XCTAssertEqual(cleaned.count, 2) + XCTAssertEqual(cleaned.map(\.role), [.assistant, .user]) + XCTAssertEqual(cleaned.first?.content, "done") + XCTAssertFalse(cleaned[0].isStreaming) + } + + func testLastResponseDatePrefersLastAssistantMessage() { + let userDate = Date(timeIntervalSince1970: 10) + let firstAssistantDate = Date(timeIntervalSince1970: 20) + let lastAssistantDate = Date(timeIntervalSince1970: 30) + + let result = appState.lastResponseDate(from: [ + ChatMessage(role: .user, content: "q", timestamp: userDate), + ChatMessage(role: .assistant, content: "a1", timestamp: firstAssistantDate), + ChatMessage(role: .user, content: "follow up", timestamp: Date(timeIntervalSince1970: 40)), + ChatMessage(role: .assistant, content: "a2", timestamp: lastAssistantDate), + ]) + + XCTAssertEqual(result, lastAssistantDate) + } + + func testAutoGeneratedTitleDetectionMatchesKnownPlaceholders() { + XCTAssertTrue(appState.isAutoGeneratedTitle(ChatSession.defaultTitle, firstUserMessage: "Build this")) + XCTAssertTrue(appState.isAutoGeneratedTitle("Build this", firstUserMessage: "Build this")) + XCTAssertTrue(appState.isAutoGeneratedTitle("New session", firstUserMessage: "Build this")) + XCTAssertTrue(appState.isAutoGeneratedTitle("", firstUserMessage: "Build this")) + XCTAssertFalse(appState.isAutoGeneratedTitle("User Rename", firstUserMessage: "Build this")) + } + + func testResolveCurrentSessionIdFollowsRedirectChainAndStopsAtCycle() { + appState.sessionIdRedirect = [ + "pending": "real", + "real": "compacted", + "cycle-a": "cycle-b", + "cycle-b": "cycle-a", + ] + + XCTAssertEqual(appState.resolveCurrentSessionId("pending"), "compacted") + XCTAssertEqual(appState.resolveCurrentSessionId("cycle-a"), "cycle-b") + } + + // MARK: - Model state + + func testSetSessionModelUpdatesWindowStateSessionStateAndProjectDefault() async { + let project = makeProject("A") + appState.projects = [project] + window.selectedProject = project + window.currentSessionId = "session" + + appState.setSessionModel("gpt-5.4", provider: .codex, in: window) + try? await Task.sleep(nanoseconds: 50_000_000) + + XCTAssertEqual(window.sessionAgentProvider, .codex) + XCTAssertEqual(window.sessionModel, "gpt-5.4") + XCTAssertEqual(appState.sessionStates["session"]?.agentProvider, .codex) + XCTAssertEqual(appState.sessionStates["session"]?.model, "gpt-5.4") + XCTAssertNil(appState.sessionStates["session"]?.activeModelName) + XCTAssertEqual(appState.projects.first?.lastAgentProvider, .codex) + XCTAssertEqual(appState.projects.first?.lastModel, "gpt-5.4") + let savedProjectModel = await persistence.savedProjectsSnapshots().last?.first?.lastModel + XCTAssertEqual(savedProjectModel, "gpt-5.4") + } + + func testSetSessionEffortPermissionAndPlanModeUpdateCurrentSessionState() { + window.currentSessionId = "session" + + appState.setSessionEffort("high", in: window) + appState.setSessionPermissionMode(.auto, in: window) + appState.toggleSessionPlanMode(in: window) + appState.toggleSessionPlanMode(in: window) + + XCTAssertEqual(window.sessionEffort, "high") + XCTAssertEqual(window.sessionPermissionMode, .auto) + XCTAssertFalse(window.sessionPlanMode) + XCTAssertEqual(appState.sessionStates["session"]?.effort, "high") + XCTAssertEqual(appState.sessionStates["session"]?.permissionMode, .auto) + XCTAssertEqual(appState.sessionStates["session"]?.planMode, false) + } + + func testDefaultModelSelectionPrefersValidProjectDefault() { + let project = Project( + name: "A", + path: "/tmp/a", + lastAgentProvider: .codex, + lastModel: "gpt-5.4" + ) + + let selection = appState.defaultModelSelection(for: project) + + XCTAssertEqual(selection.provider, .codex) + XCTAssertEqual(selection.model, "gpt-5.4") + } + + func testDefaultModelSelectionFallsBackWhenProjectDefaultUnavailable() { + appState.selectedAgentProvider = .claudeCode + appState.selectedModel = "sonnet" + let project = Project( + name: "A", + path: "/tmp/a", + lastAgentProvider: .codex, + lastModel: "not-installed" + ) + + let selection = appState.defaultModelSelection(for: project) + + XCTAssertEqual(selection.provider, .claudeCode) + XCTAssertEqual(selection.model, "sonnet") + } + + func testACPModelDisplayResolvesClientAndModelOptionNames() { + appState.acpClients = [ + ACPClientSpec( + id: "client", + displayName: "Gemini CLI", + launch: .custom(command: "gemini", args: [], env: [:]), + modelOptions: [ + ACPModelOption(value: "pro", name: "Google/Gemini Pro"), + ] + ), + ] + + XCTAssertEqual(AppState.splitACPModelKey("client::pro")?.clientId, "client") + XCTAssertEqual(AppState.splitACPModelKey("client::pro")?.model, "pro") + XCTAssertEqual(appState.acpSelectionParts(for: "client::pro")?.clientId, "client") + XCTAssertEqual(appState.modelDisplayLabel("client::pro", provider: .acp), "Gemini CLI · Gemini Pro") + } + + func testACPModelSectionsIncludeEnabledClientsAndSkipDisabledClients() { + appState.acpClients = [ + ACPClientSpec( + id: "enabled", + displayName: "Enabled", + enabled: true, + launch: .custom(command: "enabled", args: [], env: [:]), + models: ["model-a"] + ), + ACPClientSpec( + id: "disabled", + displayName: "Disabled", + enabled: false, + launch: .custom(command: "disabled", args: [], env: [:]), + models: ["model-b"] + ), + ] + + let acpSections = appState.availableAgentModelSections().filter { $0.provider == .acp } + + XCTAssertEqual(acpSections.map(\.id), ["acp:enabled"]) + XCTAssertEqual(acpSections.first?.models.map(\.id), ["enabled::model-a"]) + } + + // MARK: - Project and session persistence + + func testAddProjectPersistsNewProjectAndSkipsDuplicatePath() async { + await appState.addProject(name: "A", path: "/tmp/a", gitHubRepo: nil) + await appState.addProject(name: "Duplicate", path: "/tmp/a", gitHubRepo: nil) + + XCTAssertEqual(appState.projects.map(\.name), ["A"]) + let saveCount = await persistence.savedProjectsSnapshots().count + XCTAssertEqual(saveCount, 1) + } + + func testSaveSessionSkipsEmptyMessages() async { + await appState.saveSession(sessionId: "empty", projectId: UUID(), messages: []) + + let savedSessions = await persistence.savedSessions() + XCTAssertTrue(savedSessions.isEmpty) + XCTAssertTrue(appState.allSessionSummaries.isEmpty) + } + + func testSaveSessionBuildsSessionFromStateAndUpdatesSummariesAndProject() async { + let project = makeProject("A") + appState.projects = [project] + + var state = SessionStreamState() + state.agentProvider = .codex + state.model = "gpt-5.4" + state.effort = "high" + state.permissionMode = .auto + appState.sessionStates["session"] = state + + let user = ChatMessage(role: .user, content: "Question", timestamp: Date(timeIntervalSince1970: 1)) + let assistant = ChatMessage(role: .assistant, content: "Answer", timestamp: Date(timeIntervalSince1970: 2)) + + await appState.saveSession(sessionId: "session", projectId: project.id, messages: [user, assistant]) + + let saved = await persistence.savedSessions().last?.session + XCTAssertEqual(saved?.id, "session") + XCTAssertEqual(saved?.title, ChatSession.defaultTitle) + XCTAssertEqual(saved?.agentProvider, .codex) + XCTAssertEqual(saved?.model, "gpt-5.4") + XCTAssertEqual(saved?.effort, "high") + XCTAssertEqual(saved?.permissionMode, .auto) + XCTAssertEqual(saved?.updatedAt, Date(timeIntervalSince1970: 2)) + XCTAssertEqual(appState.allSessionSummaries.map(\.id), ["session"]) + XCTAssertEqual(appState.projects.first?.lastSessionId, "session") + let savedProjectSessionId = await persistence.savedProjectsSnapshots().last?.first?.lastSessionId + XCTAssertEqual(savedProjectSessionId, "session") + } + + func testSaveSessionPreservesExistingSummaryMetadata() async { + let project = makeProject("A") + let archivedAt = Date(timeIntervalSince1970: 99) + appState.projects = [project] + appState.allSessionSummaries = [ + ChatSession.Summary( + id: "session", + projectId: project.id, + title: "Manual Title", + createdAt: Date(timeIntervalSince1970: 1), + updatedAt: Date(timeIntervalSince1970: 2), + isPinned: true, + agentProvider: .claudeCode, + model: "opus", + effort: "medium", + permissionMode: .acceptEdits, + origin: .legacyRxCode, + worktreePath: "/tmp/worktree", + worktreeBranch: "feature", + isArchived: true, + archivedAt: archivedAt + ), + ] + + await appState.saveSession( + sessionId: "session", + projectId: project.id, + messages: [ChatMessage(role: .assistant, content: "Answer")] + ) + + let saved = await persistence.savedSessions().last?.session + XCTAssertEqual(saved?.title, "Manual Title") + XCTAssertEqual(saved?.isPinned, true) + XCTAssertEqual(saved?.agentProvider, .claudeCode) + XCTAssertEqual(saved?.model, "opus") + XCTAssertEqual(saved?.effort, "medium") + XCTAssertEqual(saved?.permissionMode, .acceptEdits) + XCTAssertEqual(saved?.origin, .legacyRxCode) + XCTAssertEqual(saved?.worktreePath, "/tmp/worktree") + XCTAssertEqual(saved?.worktreeBranch, "feature") + XCTAssertEqual(saved?.isArchived, true) + XCTAssertEqual(saved?.archivedAt, archivedAt) + } + + func testSaveSessionDoesNotUpdateSummaryWhileStreaming() async { + let project = makeProject("A") + appState.projects = [project] + var state = SessionStreamState() + state.isStreaming = true + appState.sessionStates["streaming"] = state + + await appState.saveSession( + sessionId: "streaming", + projectId: project.id, + messages: [ChatMessage(role: .assistant, content: "Still going")] + ) + + let savedCount = await persistence.savedSessions().count + XCTAssertEqual(savedCount, 1) + XCTAssertTrue(appState.allSessionSummaries.isEmpty) + } + + func testRenameProjectTrimsNameAndPersistsProjects() async { + let project = makeProject("Old") + appState.projects = [project] + + await appState.renameProject(project, to: " New Name ") + + XCTAssertEqual(appState.projects.first?.name, "New Name") + let savedProjectName = await persistence.savedProjectsSnapshots().last?.first?.name + XCTAssertEqual(savedProjectName, "New Name") + } + + func testRenameProjectIgnoresBlankName() async { + let project = makeProject("Old") + appState.projects = [project] + + await appState.renameProject(project, to: " \n ") + + XCTAssertEqual(appState.projects.first?.name, "Old") + let savedProjects = await persistence.savedProjectsSnapshots() + XCTAssertTrue(savedProjects.isEmpty) + } + + // MARK: - Notifications and settings + + func testProjectWindowRegistrationIsReferenceCounted() { + let projectId = UUID() + + appState.registerOpenProjectWindow(projectId) + appState.registerOpenProjectWindow(projectId) + XCTAssertTrue(appState.hasOpenProjectWindow(for: projectId)) + + appState.unregisterOpenProjectWindow(projectId) + XCTAssertTrue(appState.hasOpenProjectWindow(for: projectId)) + + appState.unregisterOpenProjectWindow(projectId) + XCTAssertFalse(appState.hasOpenProjectWindow(for: projectId)) + } + + func testHandleNotificationTapQueuesSessionForOpenProjectWindow() { + let projectId = UUID() + appState.registerOpenProjectWindow(projectId) + + appState.handleNotificationTap(projectId: projectId, sessionId: "session", mainWindow: window) + + XCTAssertEqual(appState.pendingNotificationSession[projectId], "session") + XCTAssertNil(window.currentSessionId) + } + + func testHandleNotificationTapSelectsSessionWhenMainWindowAlreadyShowsProject() { + let project = makeProject("A") + window.selectedProject = project + + appState.handleNotificationTap(projectId: project.id, sessionId: "session", mainWindow: window) + + XCTAssertEqual(window.currentSessionId, "session") + } + + func testMemoryMaxContextItemsClampsToSupportedRange() { + appState.memoryMaxContextItems = 0 + XCTAssertEqual(appState.memoryMaxContextItems, 1) + + appState.memoryMaxContextItems = 99 + XCTAssertEqual(appState.memoryMaxContextItems, 12) + + appState.memoryMaxContextItems = 7 + XCTAssertEqual(appState.memoryMaxContextItems, 7) + } + + // MARK: - Helpers + + private func makeProject(_ name: String) -> Project { + Project(name: name, path: "/tmp/\(name.lowercased())", gitHubRepo: nil) + } + + private func streamState(isStreaming: Bool) -> SessionStreamState { + var state = SessionStreamState() + state.isStreaming = isStreaming + return state + } +} + +private actor MockAppStatePersistence: AppStatePersistenceService { + private var projectSnapshots: [[Project]] = [] + private var sessionSaves: [(session: ChatSession, persistTitle: Bool)] = [] + private var deletedSessions: [(projectId: UUID, sessionId: String, origin: SessionOrigin, cwd: String?)] = [] + private var runProfiles: [UUID: [RunProfile]] = [:] + private var acpClients: [ACPClientSpec] = [] + private var customRepos: [CustomRepo] = [] + private var githubUser: GitHubUser? + private var fullSessions: [String: ChatSession] = [:] + private var legacySessions: [String: ChatSession] = [:] + + func savedProjectsSnapshots() -> [[Project]] { + projectSnapshots + } + + func savedSessions() -> [(session: ChatSession, persistTitle: Bool)] { + sessionSaves + } + + func deletedSessionRecords() -> [(projectId: UUID, sessionId: String, origin: SessionOrigin, cwd: String?)] { + deletedSessions + } + + func stubFullSession(_ session: ChatSession) { + fullSessions[session.id] = session + } + + func stubLegacySession(_ session: ChatSession) { + legacySessions[session.id] = session + } + + func saveProjects(_ projects: [Project]) throws { + projectSnapshots.append(projects) + } + + func loadProjects() -> [Project] { + projectSnapshots.last ?? [] + } + + func saveSession(_ session: ChatSession, persistTitle: Bool) async throws { + sessionSaves.append((session, persistTitle)) + } + + func loadLegacySessions(for projectId: UUID) -> [ChatSession.Summary] { + legacySessions.values + .filter { $0.projectId == projectId } + .map(\.summary) + .sorted { $0.updatedAt > $1.updatedAt } + } + + func loadAllLegacySessionSummaries() -> [ChatSession.Summary] { + legacySessions.values.map(\.summary).sorted { $0.updatedAt > $1.updatedAt } + } + + func deleteSession(projectId: UUID, sessionId: String, origin: SessionOrigin, cwd: String?) async throws { + deletedSessions.append((projectId, sessionId, origin, cwd)) + } + + func loadFullSession(summary: ChatSession.Summary, cwd: String) async -> ChatSession? { + fullSessions[summary.id] + } + + nonisolated func legacySessionURL(projectId: UUID, sessionId: String) -> URL { + URL(fileURLWithPath: "/tmp/\(projectId.uuidString)/\(sessionId).json") + } + + nonisolated func loadLegacySessionSync(projectId: UUID, sessionId: String) -> ChatSession? { + nil + } + + func saveRunProfiles(_ profiles: [RunProfile], projectId: UUID) throws { + runProfiles[projectId] = profiles + } + + func loadRunProfiles(projectId: UUID) -> [RunProfile] { + runProfiles[projectId] ?? [] + } + + func saveACPClients(_ clients: [ACPClientSpec]) throws { + acpClients = clients + } + + func loadACPClients() -> [ACPClientSpec] { + acpClients + } + + nonisolated func acpRegistrySnapshotURL() -> URL { + URL(fileURLWithPath: "/tmp/acp_registry.json") + } + + func saveCustomRepos(_ repos: [CustomRepo]) throws { + customRepos = repos + } + + func loadCustomRepos() -> [CustomRepo] { + customRepos + } + + func saveGitHubUser(_ user: GitHubUser) throws { + githubUser = user + } + + func loadGitHubUser() -> GitHubUser? { + githubUser + } +} diff --git a/RxCodeTests/MemoryIntentTests.swift b/RxCodeTests/MemoryIntentTests.swift new file mode 100644 index 0000000..f7eb1c7 --- /dev/null +++ b/RxCodeTests/MemoryIntentTests.swift @@ -0,0 +1,90 @@ +import XCTest +import RxCodeCore +@testable import RxCode + +@MainActor +final class MemoryIntentTests: XCTestCase { + func testRoutineTaskDoesNotAllowAutomaticMemoryExtraction() { + XCTAssertFalse( + AppState.hasExplicitMemoryIntent("Run make lint and fix the warnings.") + ) + } + + func testTaskResultDoesNotAllowAgentMemoryAdd() { + XCTAssertFalse( + AppState.shouldAcceptAgentMemoryAdd( + content: "Added Makefile to include make lint and make lint-ci commands.", + kind: "fact" + ) + ) + } + + func testRememberRequestAllowsAutomaticMemoryExtraction() { + XCTAssertTrue( + AppState.hasExplicitMemoryIntent("Please remember to use English for project text.") + ) + } + + func testFutureInstructionAllowsAutomaticMemoryExtraction() { + XCTAssertTrue( + AppState.hasExplicitMemoryIntent("From now on, run make lint before finishing.") + ) + } + + func testPreferenceAllowsAutomaticMemoryExtraction() { + XCTAssertTrue( + AppState.hasExplicitMemoryIntent("I prefer concise final answers.") + ) + } + + func testPreferenceKindAllowsAgentMemoryAdd() { + XCTAssertTrue( + AppState.shouldAcceptAgentMemoryAdd( + content: "Use English for project text.", + kind: "preference" + ) + ) + } + + func testExplicitFutureInstructionAllowsAgentMemoryAddEvenAsFact() { + XCTAssertTrue( + AppState.shouldAcceptAgentMemoryAdd( + content: "Always run make lint before finishing.", + kind: "fact" + ) + ) + } + + func testPreferenceMemoryInjectsIntoSystemPrompt() { + XCTAssertTrue( + AppState.shouldInjectMemoryIntoSystemPrompt(memory(content: "Use English for project text.", kind: "preference")) + ) + } + + func testAlwaysFactMemoryInjectsIntoSystemPrompt() { + XCTAssertTrue( + AppState.shouldInjectMemoryIntoSystemPrompt(memory(content: "Always run make lint before finishing.", kind: "fact")) + ) + } + + func testRoutineFactMemoryDoesNotInjectIntoSystemPrompt() { + XCTAssertFalse( + AppState.shouldInjectMemoryIntoSystemPrompt(memory(content: "Added Makefile lint commands.", kind: "fact")) + ) + } + + private func memory(content: String, kind: String) -> MemoryItem { + MemoryItem( + id: UUID().uuidString, + content: content, + projectId: nil, + sessionId: nil, + sourceMessageId: nil, + createdAt: Date(), + updatedAt: Date(), + lastUsedAt: nil, + kind: kind, + scope: "global" + ) + } +}