Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 181 additions & 0 deletions Tests/StackNudgePanelCoreTests/SessionMuteTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import XCTest

@testable import StackNudgePanelCore

// Per-session mute: which sessions share a mute, and whether unmuting is the
// inverse of muting. Both went wrong for sessions in the same project, which
// is the common case for anyone running two agents in one repo.
@MainActor
final class SessionMuteTests: XCTestCase {

private var tempURL: URL!

override func setUp() {
super.setUp()
tempURL = FileManager.default.temporaryDirectory
.appendingPathComponent("stack-nudge-mute-\(UUID().uuidString).json")
}

override func tearDown() {
if let tempURL { try? FileManager.default.removeItem(at: tempURL) }
super.tearDown()
}

private func makeStore() -> SessionPersistence { SessionPersistence(path: tempURL) }

private func session(pid: Int,
path: String = "/Users/x/redteaming",
tabId: String? = nil,
tty: String? = nil,
claudeSessionID: String? = nil) -> Session {
Session(
id: pid, pid: pid, agent: "claude",
projectPath: path, projectName: "redteaming",
terminalPID: 1, terminalApp: "Zed", elapsed: "01:00",
customName: nil, status: .active,
tabId: tabId, tabName: nil, tty: tty,
claudeSessionID: claudeSessionID
)
}

// MARK: - Scope ladder

func test_muteScope_prefersTabIdThenTtyThenSessionID() {
XCTAssertEqual(SessionPersistence.muteScope(
for: session(pid: 1, tabId: "w1", tty: "ttys014", claudeSessionID: "uuid")), "w1")
XCTAssertEqual(SessionPersistence.muteScope(
for: session(pid: 1, tty: "ttys014", claudeSessionID: "uuid")), "ttys014")
// Sidecar-only sessions (no controlling terminal) still get an identity.
XCTAssertEqual(SessionPersistence.muteScope(
for: session(pid: 1, claudeSessionID: "uuid")), "uuid")
XCTAssertNil(SessionPersistence.muteScope(for: session(pid: 1)))
}

func test_muteScope_ignoresEmptyStrings() {
XCTAssertEqual(SessionPersistence.muteScope(
for: session(pid: 1, tabId: "", tty: "ttys014")), "ttys014")
}

// MARK: - Isolation

// The reported bug: two agents in one repo under a terminal we have no
// conformer for (Zed, a bare shell) both resolved to the project-wide key,
// so muting either silenced both.
func test_mutingOneSession_leavesSiblingInSameProjectAudible() {
let store = makeStore()
let first = session(pid: 1, tty: "ttys014")
let second = session(pid: 2, tty: "ttys016")

store.toggleMuted(first)

XCTAssertTrue(store.isMuted(first))
XCTAssertFalse(store.isMuted(second))
}

func test_mutesAreScopedPerSessionForAgentsWithoutASidecar() {
// codex and gemini expose no per-session id at all; the tty is the only
// thing separating them, so this is the whole fix for those agents.
let store = makeStore()
func codex(pid: Int, tty: String) -> Session {
Session(id: pid, pid: pid, agent: "codex",
projectPath: "/Users/x/redteaming", projectName: "redteaming",
terminalPID: 1, terminalApp: "Zed", elapsed: "01:00",
customName: nil, status: .active,
tabId: nil, tabName: nil, tty: tty)
}
store.toggleMuted(codex(pid: 1, tty: "ttys037"))

XCTAssertTrue(store.isMuted(codex(pid: 1, tty: "ttys037")))
XCTAssertFalse(store.isMuted(codex(pid: 2, tty: "ttys038")))
}

// MARK: - Toggle is an inverse

func test_toggleMuted_roundTrips() {
let store = makeStore()
let target = session(pid: 1, tty: "ttys014")

store.toggleMuted(target)
XCTAssertTrue(store.isMuted(target))
store.toggleMuted(target)
XCTAssertFalse(store.isMuted(target))
}

func test_unmuting_leavesNoEntryBehind() {
let store = makeStore()
let target = session(pid: 1, tty: "ttys014")

store.toggleMuted(target)
store.toggleMuted(target)

XCTAssertTrue(store.entries.isEmpty, "an unmuted, unnamed session should hold no preference")
}

// A project-wide entry is what the legacy muted-sessions.json migration
// writes, and what any pre-fix mute left behind. Sessions inherit it...
func test_projectWideMute_isInheritedBySessionsWithoutAnOpinion() {
let store = makeStore()
store.toggleMuted(session(pid: 1)) // no scope → project-wide key

XCTAssertTrue(store.isMuted(session(pid: 2, tty: "ttys014")))
}

// ...and clicking unmute on one of them used to negate the *stored* flag,
// which was false, so the session re-muted itself and could never be
// cleared from its own row.
func test_inheritedMute_canBeClearedOnOneSessionOnly() {
let store = makeStore()
store.toggleMuted(session(pid: 1))
let target = session(pid: 2, tty: "ttys014")
let sibling = session(pid: 3, tty: "ttys016")

store.toggleMuted(target)

XCTAssertFalse(store.isMuted(target))
XCTAssertTrue(store.isMuted(sibling), "the project-wide mute still covers everyone else")
}

func test_clearedInheritedMute_canBeReapplied() {
let store = makeStore()
store.toggleMuted(session(pid: 1))
let target = session(pid: 2, tty: "ttys014")

store.toggleMuted(target)
store.toggleMuted(target)

XCTAssertTrue(store.isMuted(target))
}

func test_explicitUnmute_survivesReload() {
let first = makeStore()
first.toggleMuted(session(pid: 1))
let target = session(pid: 2, tty: "ttys014")
first.toggleMuted(target)

XCTAssertFalse(makeStore().isMuted(target))
}

// MARK: - Legacy files

func test_legacyEntryWithoutMutedField_decodesAsNoOpinion() throws {
try Data(#"{"claude::/Users/x/redteaming":{"customName":"attack","lastSeenAt":1}}"#.utf8)
.write(to: tempURL)
let store = makeStore()

XCTAssertFalse(store.isMuted(session(pid: 1, tty: "ttys014")))
XCTAssertEqual(store.customName(agent: "claude", projectPath: "/Users/x/redteaming"), "attack")
}

func test_renamedSession_keepsItsNameWhenMuteIsCleared() {
let store = makeStore()
store.setCustomName(agent: "claude", projectPath: "/Users/x/redteaming", tabId: "w1", "attack")
let target = session(pid: 1, tabId: "w1")

store.toggleMuted(target)
store.toggleMuted(target)

XCTAssertFalse(store.isMuted(target))
XCTAssertEqual(store.customName(agent: "claude", projectPath: "/Users/x/redteaming", tabId: "w1"),
"attack")
}
}
81 changes: 56 additions & 25 deletions panel/SessionPersistence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,14 @@ enum Agent {
// future dormancy-based eviction pass.
struct SessionEntry: Codable {
var customName: String?
var muted: Bool
// Tri-state on purpose. nil means "no opinion, ask the project-level
// entry"; `false` is an explicit override that outranks it. Without the
// distinction a session inheriting a project-wide mute had no way to say
// no — see `toggleMuted`.
var muted: Bool?
var lastSeenAt: TimeInterval

init(customName: String? = nil, muted: Bool = false, lastSeenAt: TimeInterval) {
init(customName: String? = nil, muted: Bool? = nil, lastSeenAt: TimeInterval) {
self.customName = customName
self.muted = muted
self.lastSeenAt = lastSeenAt
Expand All @@ -37,7 +41,7 @@ struct SessionEntry: Codable {
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.customName = try c.decodeIfPresent(String.self, forKey: .customName)
self.muted = (try c.decodeIfPresent(Bool.self, forKey: .muted)) ?? false
self.muted = try c.decodeIfPresent(Bool.self, forKey: .muted)
self.lastSeenAt = try c.decode(TimeInterval.self, forKey: .lastSeenAt)
}
}
Expand Down Expand Up @@ -82,12 +86,12 @@ final class SessionPersistence: ObservableObject {
guard let projectPath else { return nil }
let canon = Agent.canonical(agent)
if let tabId, !tabId.isEmpty {
if let trimmed = entries[Self.key(agent: canon, projectPath: projectPath, tabId: tabId)]?.customName,
if let trimmed = entries[Self.key(agent: canon, projectPath: projectPath, scope: tabId)]?.customName,
!trimmed.isEmpty {
return trimmed
}
}
let trimmed = entries[Self.key(agent: canon, projectPath: projectPath, tabId: nil)]?.customName
let trimmed = entries[Self.key(agent: canon, projectPath: projectPath, scope: nil)]?.customName
guard let trimmed, !trimmed.isEmpty else { return nil }
return trimmed
}
Expand All @@ -102,14 +106,14 @@ final class SessionPersistence: ObservableObject {
// (if any) keeps working as the fallback.
func setCustomName(agent: String, projectPath: String?, tabId: String? = nil, _ name: String?) {
guard let projectPath else { return }
let key = Self.key(agent: Agent.canonical(agent), projectPath: projectPath, tabId: tabId)
let key = Self.key(agent: Agent.canonical(agent), projectPath: projectPath, scope: tabId)
let trimmed = name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let now = Date().timeIntervalSince1970
if trimmed.isEmpty {
// Clearing the name: keep the entry only if it has other prefs.
guard var existing = entries[key] else { return }
existing.customName = nil
if existing.muted {
if existing.muted != nil {
existing.lastSeenAt = now
entries[key] = existing
} else {
Expand All @@ -126,31 +130,56 @@ final class SessionPersistence: ObservableObject {

// MARK: - Mute

func isMuted(agent: String, projectPath: String?, tabId: String? = nil) -> Bool {
// Which segment scopes a session's mute. Ordered most- to least-specific:
// an integration's tab id where we have one, else the tty, else Claude's
// sidecar session id for the rare tty-less session. Names deliberately do
// not use this ladder — they're also looked up from events, which carry a
// tab id but no tty, so widening the key there would orphan every rename.
static func muteScope(for session: Session) -> String? {
for candidate in [session.tabId, session.tty, session.claudeSessionID] {
if let candidate, !candidate.isEmpty { return candidate }
}
return nil
}

// The most specific entry that exists wins outright, including when it says
// `false`. Falling back past an explicit override would resurrect a mute the
// user just cleared.
func isMuted(agent: String, projectPath: String?, scope: String? = nil) -> Bool {
guard let projectPath else { return false }
let canon = Agent.canonical(agent)
if let tabId, !tabId.isEmpty,
entries[Self.key(agent: canon, projectPath: projectPath, tabId: tabId)]?.muted == true {
return true
if let scope, !scope.isEmpty,
let own = entries[Self.key(agent: canon, projectPath: projectPath, scope: scope)]?.muted {
return own
}
return entries[Self.key(agent: canon, projectPath: projectPath, tabId: nil)]?.muted == true
return entries[Self.key(agent: canon, projectPath: projectPath, scope: nil)]?.muted == true
}

func isMuted(_ session: Session) -> Bool {
isMuted(agent: session.agent, projectPath: session.projectPath, tabId: session.tabId)
isMuted(agent: session.agent, projectPath: session.projectPath,
scope: Self.muteScope(for: session))
}

func toggleMuted(_ session: Session) {
guard let projectPath = session.projectPath, !projectPath.isEmpty else { return }
let key = Self.key(agent: Agent.canonical(session.agent),
projectPath: projectPath, tabId: session.tabId)
let canon = Agent.canonical(session.agent)
let key = Self.key(agent: canon, projectPath: projectPath,
scope: Self.muteScope(for: session))
let projectKey = Self.key(agent: canon, projectPath: projectPath, scope: nil)
let inherited = key != projectKey && entries[projectKey]?.muted == true
// Negate what the user is looking at, not the stored flag — they differ
// whenever the visible state came from the project-level entry, and
// negating the flag there re-muted an already-muted session.
let next = !isMuted(session)
let now = Date().timeIntervalSince1970
var entry = entries[key] ?? SessionEntry(lastSeenAt: now)
entry.muted = !entry.muted
// Unmuting only needs recording when a project-level mute would
// otherwise reassert; otherwise absence says the same thing.
entry.muted = (next || inherited) ? next : nil
entry.lastSeenAt = now
// Drop entries that hold no surviving preference, matching the
// "deliberate user intent only" invariant for the on-disk store.
if entry.muted == false, entry.customName?.isEmpty ?? true {
if entry.muted == nil, entry.customName?.isEmpty ?? true {
entries.removeValue(forKey: key)
} else {
entries[key] = entry
Expand All @@ -170,11 +199,11 @@ final class SessionPersistence: ObservableObject {
let candidates: [String] = {
if let tabId, !tabId.isEmpty {
return [
Self.key(agent: canon, projectPath: projectPath, tabId: tabId),
Self.key(agent: canon, projectPath: projectPath, tabId: nil),
Self.key(agent: canon, projectPath: projectPath, scope: tabId),
Self.key(agent: canon, projectPath: projectPath, scope: nil),
]
}
return [Self.key(agent: canon, projectPath: projectPath, tabId: nil)]
return [Self.key(agent: canon, projectPath: projectPath, scope: nil)]
}()
for key in candidates {
guard !seenThisLaunch.contains(key), entries[key] != nil else { continue }
Expand All @@ -187,19 +216,21 @@ final class SessionPersistence: ObservableObject {

// MARK: - I/O

// Stable across PID churn and restarts; shared with PanelNav.mutedSessions.
static func key(agent: String, projectPath: String, tabId: String?) -> String {
// Stable across PID churn and restarts. The trailing segment scopes the
// entry within a project — a tab id for names, `muteScope` for mutes — and
// is omitted entirely when there is none, which is the project-wide key.
static func key(agent: String, projectPath: String, scope: String?) -> String {
let canon = Agent.canonical(agent)
if let tabId, !tabId.isEmpty {
return "\(canon)::\(projectPath)::\(tabId)"
if let scope, !scope.isEmpty {
return "\(canon)::\(projectPath)::\(scope)"
}
return "\(canon)::\(projectPath)"
}

// nil when the session has no projectPath — no stable identity possible.
static func key(for session: Session) -> String? {
guard let path = session.projectPath, !path.isEmpty else { return nil }
return key(agent: session.agent, projectPath: path, tabId: session.tabId)
return key(agent: session.agent, projectPath: path, scope: session.tabId)
}

private func load() {
Expand Down
Loading