From b7ffb0b78e2cc598f3b130deae332952e551292b Mon Sep 17 00:00:00 2001 From: Zephyr Date: Tue, 14 Jul 2026 13:49:20 +0800 Subject: [PATCH 1/3] fix(sessions): fold Cursor Tasks under Agent Sub-Sessions like Codex Cursor IDE Tasks use a child session_id while transcript_path still points at the parent chat, which created duplicate cards. Honor separate/merge/hide, keep closed subagent ids across Stop (and idle-orphan merge drops), deny late Permission/AskUserQuestion/Question for closed agent ids, drop idle orphans without resuscitating via a shared IDE pid, fill missing parent identity from merge-first Task hooks, reopen Cursor Tasks via beforeSubmitPrompt after Stop, keep the parent active while folded Tasks run, and avoid dual-tailing the parent transcript when splitting to separate cards. --- Sources/CodeIsland/AppState.swift | 392 +++++++++- Sources/CodeIsland/HookServer.swift | 49 +- Sources/CodeIsland/L10n.swift | 14 +- Sources/CodeIsland/SessionPersistence.swift | 8 +- .../CodeIslandCore/CursorSessionFolding.swift | 114 +++ Sources/CodeIslandCore/SessionSnapshot.swift | 209 ++++- .../CursorSessionFoldingTests.swift | 463 +++++++++++ .../AppStateCursorSubsessionTests.swift | 724 ++++++++++++++++++ .../SessionPersistenceTests.swift | 152 +++- 9 files changed, 2087 insertions(+), 38 deletions(-) create mode 100644 Sources/CodeIslandCore/CursorSessionFolding.swift create mode 100644 Tests/CodeIslandCoreTests/CursorSessionFoldingTests.swift create mode 100644 Tests/CodeIslandTests/AppStateCursorSubsessionTests.swift diff --git a/Sources/CodeIsland/AppState.swift b/Sources/CodeIsland/AppState.swift index 33a8bf53..cb612f8c 100644 --- a/Sources/CodeIsland/AppState.swift +++ b/Sources/CodeIsland/AppState.swift @@ -1223,13 +1223,26 @@ final class AppState { sessions[sessionId] = SessionSnapshot() } // Extract metadata so blocking-first parent sessions have cwd/source/PID. - // Subagent events are routed through the parent session ID; their metadata - // can describe the child session and should not overwrite the parent. + // Subagent events are routed through the parent session ID; their full metadata + // can describe the child session and should not overwrite the parent — only fill gaps. if event.agentId == nil { extractMetadata(into: &sessions, sessionId: sessionId, event: event) + } else { + fillMissingParentMetadataFromSubagentEvent(into: &sessions, sessionId: sessionId, event: event) } tryMonitorSession(sessionId) + // Closed Task/subagent ids must not surface new permission UI (parity with + // ensureSubagent refusing late tool hooks after Stop). + if let agentId = event.agentId, + sessions[sessionId]?.closedSubagentIds.contains(agentId) == true { + let denyResponse = Data( + #"{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny"}}}"#.utf8 + ) + continuation.resume(returning: denyResponse) + return + } + // New incoming permission request means session needs user decision again. dismissedPermissionSessionIds.remove(sessionId) @@ -1240,6 +1253,7 @@ final class AppState { sessions[sessionId]?.currentTool = event.toolName sessions[sessionId]?.toolDescription = event.toolDescription sessions[sessionId]?.lastActivity = Date() + markMergedSubagentWaiting(sessionId: sessionId, agentId: event.agentId, status: .waitingApproval) // Backfill tool name/description from cached PreToolUse when the payload is thin. enrichPermissionRequestFromCache(sessionId: sessionId, event: event) @@ -1463,9 +1477,17 @@ final class AppState { } if event.agentId == nil { extractMetadata(into: &sessions, sessionId: sessionId, event: event) + } else { + fillMissingParentMetadataFromSubagentEvent(into: &sessions, sessionId: sessionId, event: event) } tryMonitorSession(sessionId) + if let agentId = event.agentId, + sessions[sessionId]?.closedSubagentIds.contains(agentId) == true { + continuation.resume(returning: Data("{}".utf8)) + return + } + guard let question = QuestionPayload.from(event: event) else { continuation.resume(returning: Data("{}".utf8)) return @@ -1474,6 +1496,7 @@ final class AppState { sessions[sessionId]?.status = .waitingQuestion sessions[sessionId]?.lastActivity = Date() + markMergedSubagentWaiting(sessionId: sessionId, agentId: event.agentId, status: .waitingQuestion) let request = QuestionRequest(event: event, question: question, continuation: continuation) questionQueue.append(request) @@ -1497,9 +1520,20 @@ final class AppState { } if event.agentId == nil { extractMetadata(into: &sessions, sessionId: sessionId, event: event) + } else { + fillMissingParentMetadataFromSubagentEvent(into: &sessions, sessionId: sessionId, event: event) } tryMonitorSession(sessionId) + if let agentId = event.agentId, + sessions[sessionId]?.closedSubagentIds.contains(agentId) == true { + let denyResponse = Data( + #"{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny"}}}"#.utf8 + ) + continuation.resume(returning: denyResponse) + return + } + let originalQuestions = event.toolInput?["questions"] as? [[String: Any]] var askItems: [AskUserQuestionItem] = [] if let questions = originalQuestions { @@ -1581,6 +1615,7 @@ final class AppState { sessions[sessionId]?.status = .waitingQuestion sessions[sessionId]?.lastActivity = Date() + markMergedSubagentWaiting(sessionId: sessionId, agentId: event.agentId, status: .waitingQuestion) let askState = AskUserQuestionState(items: askItems, answers: [:]) let request = QuestionRequest( @@ -2201,6 +2236,10 @@ final class AppState { snapshot.zellijSessionName = p.zellijSessionName snapshot.weztermPaneId = p.weztermPaneId snapshot.lastActivity = p.lastActivity + snapshot.transcriptPath = p.transcriptPath + if let closed = p.closedSubagentIds, !closed.isEmpty { + snapshot.closedSubagentIds = Set(closed) + } // Restore persisted cliPid only if the process is still alive — avoids // stale sessions reappearing briefly after the app or IDE restarts (#46). if let pid = p.cliPid, pid > 0 { @@ -2211,7 +2250,16 @@ final class AppState { } } // Skip sessions whose process is dead and status was idle — nothing to show. - if snapshot.cliPid == nil && snapshot.status == .idle && snapshot.lastUserPrompt == nil { + // Keep Cursor Task tombstones / foldable orphans so applyCursor… can still + // honor closedSubagentIds after relaunch (otherwise merge can revive them). + if snapshot.cliPid == nil && snapshot.status == .idle && snapshot.lastUserPrompt == nil, + !Self.shouldKeepRestoredIdleCursorSession( + source: source, + sessionId: p.sessionId, + providerSessionId: snapshot.providerSessionId, + transcriptPath: snapshot.transcriptPath, + closedSubagentIds: snapshot.closedSubagentIds + ) { continue } sessions[p.sessionId] = snapshot @@ -2223,6 +2271,7 @@ final class AppState { } SessionPersistence.clear() _ = applyCodexSubsessionModeToKnownSessions() + _ = applyCursorSubsessionModeToKnownSessions() if activeSessionId == nil { activeSessionId = sessions.first(where: { $0.value.status != .idle })?.key ?? sessions.keys.sorted().first @@ -2230,6 +2279,21 @@ final class AppState { refreshDerivedState() } + /// Idle snapshots with no live process are usually discarded on restore. + /// Keep Cursor Task cards that carry a Stop tombstone so merge can still + /// honor `closedSubagentIds` after relaunch. A foldable transcript alone is + /// not enough — that would rehydrate finished Tasks when Stop was missed. + nonisolated static func shouldKeepRestoredIdleCursorSession( + source: String, + sessionId: String, + providerSessionId: String?, + transcriptPath: String?, + closedSubagentIds: Set + ) -> Bool { + guard source == "cursor" || source == "cursor-cli" else { return false } + return !closedSubagentIds.isEmpty + } + private nonisolated static func findDiscoveredSessions() -> [DiscoveredSession] { let candidatePids = allProcessIds() var discovered: [DiscoveredSession] = [] @@ -2529,6 +2593,9 @@ final class AppState { if applyCodexSubsessionModeToKnownSessions() { didMutate = true } + if applyCursorSubsessionModeToKnownSessions() { + didMutate = true + } if didMutate && activeSessionId == nil { activeSessionId = sessions.keys.sorted().first } @@ -2609,6 +2676,231 @@ final class AppState { return didMutate } + /// Apply Agent Sub-Sessions to known Cursor Task/subagent cards + /// (`transcriptPath` is the parent chat; `session_id` is the child). + /// `merge` / `hide` only; `separate` is handled by `separateMergedCursorSubagents()`. + @discardableResult + func applyCursorSubsessionModeToKnownSessions() -> Bool { + let mode = Self.currentPluginSessionMode() + guard mode == "hide" || mode == "merge" else { + return false + } + + let candidates = sessions.map { (sessionId: $0.key, session: $0.value) } + var didMutate = false + + if mode == "hide" { + for candidate in candidates { + let source = candidate.session.source + guard source == "cursor" || source == "cursor-cli" else { continue } + guard cursorFoldIdentity(for: candidate) != nil else { continue } + if sessions[candidate.sessionId] != nil { + removeSession(candidate.sessionId) + didMutate = true + } + } + return hideMergedCursorSubagents() || didMutate + } + + for candidate in candidates { + let source = candidate.session.source + guard source == "cursor" || source == "cursor-cli" else { continue } + guard let fold = cursorFoldIdentity(for: candidate) else { continue } + let parentId = fold.parentId + let childId = fold.childId + + // If fold identity collides with this card, prefer the real parent id + // (child wrongly reused the parent's providerSessionId). + var parentKey = findSessionId(providerSessionId: parentId) ?? parentId + if parentKey == candidate.sessionId { + parentKey = parentId + } + if parentKey == candidate.sessionId { continue } + + // Closed ids may sit on the parent (merge Stop) or the child card + // (separate Stop). Keep them on the parent even if we must synthesize it. + let childClosed = candidate.session.closedSubagentIds + let childWasStopped = sessions[parentKey]?.closedSubagentIds.contains(childId) == true + || childClosed.contains(childId) + || childClosed.contains(candidate.sessionId) + if childWasStopped { + if sessions[parentKey] == nil { + var parent = SessionSnapshot(startTime: candidate.session.startTime) + parent.source = source + parent.cwd = candidate.session.cwd + parent.model = candidate.session.model + parent.termApp = candidate.session.termApp + parent.termBundleId = candidate.session.termBundleId + parent.transcriptPath = candidate.session.transcriptPath + parent.providerSessionId = parentId + // Closed ids only — parent is not actively working. + parent.status = .idle + parent.lastActivity = candidate.session.lastActivity + sessions[parentKey] = parent + } + promoteCursorClosedIds( + onto: parentKey, + childId: childId, + candidateSessionId: candidate.sessionId, + childClosed: childClosed + ) + if sessions[candidate.sessionId] != nil { + removeSession(candidate.sessionId) + didMutate = true + } + continue + } + + // Idle orphan: drop the card (do not fold as running just because a shared + // Cursor IDE `_ppid` is still alive). Still promote a parent tombstone so + // late merged PreToolUse cannot revive after AfterAgentResponse→idle. + if candidate.session.status == .idle, + sessions[parentKey]?.subagents[childId] == nil { + if sessions[parentKey] == nil { + var parent = SessionSnapshot(startTime: candidate.session.startTime) + parent.source = source + parent.cwd = candidate.session.cwd + parent.model = candidate.session.model + parent.termApp = candidate.session.termApp + parent.termBundleId = candidate.session.termBundleId + parent.transcriptPath = candidate.session.transcriptPath + parent.providerSessionId = parentId + parent.status = .idle + parent.lastActivity = candidate.session.lastActivity + sessions[parentKey] = parent + } + promoteCursorClosedIds( + onto: parentKey, + childId: childId, + candidateSessionId: candidate.sessionId, + childClosed: childClosed + ) + if sessions[candidate.sessionId] != nil { + removeSession(candidate.sessionId) + didMutate = true + } + continue + } + + if sessions[parentKey] == nil { + var parent = SessionSnapshot(startTime: candidate.session.startTime) + parent.source = source + parent.cwd = candidate.session.cwd + parent.model = candidate.session.model + parent.termApp = candidate.session.termApp + parent.termBundleId = candidate.session.termBundleId + parent.transcriptPath = candidate.session.transcriptPath + // Do not copy the Task/subagent process identity onto the parent chat. + parent.providerSessionId = parentId + parent.status = candidate.session.status == .idle ? .processing : candidate.session.status + parent.lastActivity = candidate.session.lastActivity + sessions[parentKey] = parent + } else if sessions[parentKey]?.transcriptPath == nil, + let path = candidate.session.transcriptPath { + sessions[parentKey]?.transcriptPath = path + } + + if sessions[candidate.sessionId] != nil { + removeSession(candidate.sessionId) + didMutate = true + } + + // Prefer an existing parent monitor; skip if we only synthesized metadata. + if sessions[parentKey]?.cliPid != nil || sessions[parentKey]?.transcriptPath != nil { + if sessions[parentKey]?.transcriptPath != nil { + attachTranscriptTailerIfNeeded(sessionId: parentKey) + } + if sessions[parentKey]?.cliPid != nil { + tryMonitorSession(parentKey) + } + } + + var subagent = sessions[parentKey]?.subagents[childId] + ?? SubagentState(agentId: childId, agentType: "cursor-subagent") + subagent.status = candidate.session.status + subagent.currentTool = candidate.session.currentTool + subagent.toolDescription = candidate.session.toolDescription + if candidate.session.lastActivity > subagent.lastActivity { + subagent.lastActivity = candidate.session.lastActivity + } + sessions[parentKey]?.subagents[childId] = subagent + + if sessions[parentKey]?.status != .waitingApproval + && sessions[parentKey]?.status != .waitingQuestion { + sessions[parentKey]?.status = .running + if sessions[parentKey]?.currentTool == nil { + sessions[parentKey]?.currentTool = "Agent" + sessions[parentKey]?.toolDescription = "cursor-subagent" + } + } + if candidate.session.lastActivity > (sessions[parentKey]?.lastActivity ?? .distantPast) { + sessions[parentKey]?.lastActivity = candidate.session.lastActivity + } + activeSessionId = parentKey + didMutate = true + } + + return didMutate + } + + /// Record the foldable child id(s) on the parent — not an arbitrary union of + /// whatever closed set the orphan card carried. + private func promoteCursorClosedIds( + onto parentKey: String, + childId: String, + candidateSessionId: String, + childClosed: Set + ) { + sessions[parentKey]?.closedSubagentIds.insert(childId) + if candidateSessionId != childId { + sessions[parentKey]?.closedSubagentIds.insert(candidateSessionId) + } + for id in childClosed where id == childId || id == candidateSessionId { + sessions[parentKey]?.closedSubagentIds.insert(id) + } + } + + private func markMergedSubagentWaiting( + sessionId: String, + agentId: String?, + status: AgentStatus + ) { + guard let agentId else { return } + if sessions[sessionId]?.subagents[agentId] == nil { + sessions[sessionId]?.subagents[agentId] = SubagentState( + agentId: agentId, + agentType: "cursor-subagent" + ) + } + sessions[sessionId]?.subagents[agentId]?.status = status + sessions[sessionId]?.subagents[agentId]?.lastActivity = Date() + } + + /// Parent/child ids when this card's transcript belongs to another Cursor chat. + private func cursorFoldIdentity( + for candidate: (sessionId: String, session: SessionSnapshot) + ) -> (parentId: String, childId: String)? { + // Prefer providerSessionId if it folds; else the card key (used as agent_id). + let primaryId = candidate.session.providerSessionId ?? candidate.sessionId + let parentFromPrimary = CursorSessionFolding.foldTarget( + childSessionId: primaryId, + transcriptPath: candidate.session.transcriptPath + ) + let parentFromCard = candidate.sessionId == primaryId + ? nil + : CursorSessionFolding.foldTarget( + childSessionId: candidate.sessionId, + transcriptPath: candidate.session.transcriptPath + ) + if let parentFromPrimary { + return (parentFromPrimary, primaryId) + } + if let parentFromCard { + return (parentFromCard, candidate.sessionId) + } + return nil + } + func applyCurrentPluginSessionMode(persist: Bool = true) { let mode = Self.currentPluginSessionMode() var didMutate = false @@ -2616,11 +2908,14 @@ final class AppState { switch mode { case "separate": didMutate = separateMergedCodexSubagents() + didMutate = separateMergedCursorSubagents() || didMutate case "merge": didMutate = applyCodexSubsessionModeToKnownSessions() + didMutate = applyCursorSubsessionModeToKnownSessions() || didMutate case "hide": didMutate = applyCodexSubsessionModeToKnownSessions() didMutate = hideMergedCodexSubagents() || didMutate + didMutate = applyCursorSubsessionModeToKnownSessions() || didMutate default: return } @@ -2717,6 +3012,94 @@ final class AppState { return didMutate } + /// Split Cursor parent.subagents into standalone cards (Agent Sub-Sessions: separate). + @discardableResult + private func separateMergedCursorSubagents() -> Bool { + let parentCandidates = sessions.map { (sessionId: $0.key, session: $0.value) } + var didMutate = false + + for parent in parentCandidates + where (parent.session.source == "cursor" || parent.session.source == "cursor-cli") + && !parent.session.subagents.isEmpty { + for (agentId, subagent) in parent.session.subagents { + let childKey = findSessionId(providerSessionId: agentId) ?? agentId + guard childKey != parent.sessionId else { continue } + + var child = sessions[childKey] ?? SessionSnapshot(startTime: subagent.startTime) + child.source = parent.session.source + child.providerSessionId = agentId + child.cwd = child.cwd ?? parent.session.cwd + child.model = child.model ?? parent.session.model + child.permissionMode = child.permissionMode ?? parent.session.permissionMode + child.termApp = child.termApp ?? parent.session.termApp + child.itermSessionId = child.itermSessionId ?? parent.session.itermSessionId + child.ttyPath = child.ttyPath ?? parent.session.ttyPath + child.kittyWindowId = child.kittyWindowId ?? parent.session.kittyWindowId + child.tmuxPane = child.tmuxPane ?? parent.session.tmuxPane + child.tmuxClientTty = child.tmuxClientTty ?? parent.session.tmuxClientTty + child.tmuxEnv = child.tmuxEnv ?? parent.session.tmuxEnv + child.termBundleId = child.termBundleId ?? parent.session.termBundleId + child.cmuxSurfaceId = child.cmuxSurfaceId ?? parent.session.cmuxSurfaceId + child.cmuxWorkspaceId = child.cmuxWorkspaceId ?? parent.session.cmuxWorkspaceId + child.zellijPaneId = child.zellijPaneId ?? parent.session.zellijPaneId + child.zellijSessionName = child.zellijSessionName ?? parent.session.zellijSessionName + child.weztermPaneId = child.weztermPaneId ?? parent.session.weztermPaneId + child.remoteHostId = child.remoteHostId ?? parent.session.remoteHostId + child.remoteHostName = child.remoteHostName ?? parent.session.remoteHostName + // Keep the child's own process identity only — the parent Cursor chat + // often shares the IDE process, which must not be attributed to Tasks. + child.status = subagent.status + child.currentTool = subagent.currentTool + child.toolDescription = subagent.toolDescription ?? subagent.agentType + child.lastActivity = subagent.lastActivity + if child.sessionTitle == nil { + child.sessionTitle = subagent.toolDescription ?? subagent.agentType + } + // Keep parent transcriptPath for later fold identity, but do not + // attach a second JSONLTailer on the same parent file (steals the + // parent's live tail). Prefer a child-specific path when present. + let parentTranscript = parent.session.transcriptPath + if child.transcriptPath == nil { + child.transcriptPath = parentTranscript + } + let shouldTailChildTranscript = + child.transcriptPath != nil && child.transcriptPath != parentTranscript + + sessions[childKey] = child + refreshProviderTitle(for: childKey, providerSessionId: agentId) + if shouldTailChildTranscript { + attachTranscriptTailerIfNeeded(sessionId: childKey) + } + if child.cliPid != nil { + tryMonitorSession(childKey) + } + sessions[parent.sessionId]?.subagents.removeValue(forKey: agentId) + if subagent.status != .idle { + activeSessionId = childKey + } + didMutate = true + } + if sessions[parent.sessionId]?.subagents.isEmpty == true { + clearSubagentProjection(fromParentSession: parent.sessionId) + } + } + + return didMutate + } + + /// Clear Cursor parent.subagents (Agent Sub-Sessions: hide). + @discardableResult + private func hideMergedCursorSubagents() -> Bool { + var didMutate = false + for (sessionId, session) in sessions + where (session.source == "cursor" || session.source == "cursor-cli") && !session.subagents.isEmpty { + sessions[sessionId]?.subagents.removeAll() + clearSubagentProjection(fromParentSession: sessionId) + didMutate = true + } + return didMutate + } + private func clearSubagentProjection(fromParentSession sessionId: String) { guard sessions[sessionId]?.currentTool == "Agent" else { return } sessions[sessionId]?.currentTool = nil @@ -3719,7 +4102,8 @@ final class AppState { pid: pid, modifiedAt: best.modified, recentMessages: messages, - source: "cursor" + source: "cursor", + transcriptPath: best.path )) } diff --git a/Sources/CodeIsland/HookServer.swift b/Sources/CodeIsland/HookServer.swift index a85e3628..0f26f709 100644 --- a/Sources/CodeIsland/HookServer.swift +++ b/Sources/CodeIsland/HookServer.swift @@ -312,6 +312,8 @@ class HookServer { private static let pluginMarkerBytes = Data("_via_plugin".utf8) private static let sourceMarkerBytes = Data(#""_source""#.utf8) private static let codexMarkerBytes = Data("codex".utf8) + private static let cursorTranscriptMarkerBytes = Data("agent-transcripts".utf8) + private static let cursorSourceMarkerBytes = Data("cursor".utf8) private static func codexSubagentMetadata(from raw: [String: Any]) -> CodexSubagentMetadata? { guard let path = nonEmptyString(raw["transcript_path"]) else { return nil } @@ -346,15 +348,44 @@ class HookServer { } private func routeSubsessionPayloadIfNeeded(data: Data) -> (processedData: Data, responseData: Data?) { - let mayNeedRouting = data.range(of: Self.pluginMarkerBytes) != nil + let mayNeedPluginOrCodex = data.range(of: Self.pluginMarkerBytes) != nil || (data.range(of: Self.sourceMarkerBytes) != nil && data.range(of: Self.codexMarkerBytes) != nil) - guard mayNeedRouting, + let mayNeedCursor = data.range(of: Self.cursorTranscriptMarkerBytes) != nil + && data.range(of: Self.cursorSourceMarkerBytes) != nil + + let mode = UserDefaults.standard.string(forKey: SettingsKey.pluginSessionMode) + ?? SettingsDefaults.pluginSessionMode + + // Cursor Task routing is a no-op in separate mode; skip JSON parse when + // the payload is Cursor-only (Codex/plugin may still need it below). + if mayNeedCursor && !mayNeedPluginOrCodex && mode != "hide" && mode != "merge" { + return (data, nil) + } + + guard mayNeedPluginOrCodex || mayNeedCursor, let raw = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return (data, nil) } - let mode = UserDefaults.standard.string(forKey: SettingsKey.pluginSessionMode) - ?? SettingsDefaults.pluginSessionMode + // Cursor Task/subagent: apply Agent Sub-Sessions (separate / merge / hide). + switch CursorSubsessionRouter.decide(raw: raw, mode: mode) { + case .leave: + break + case .hide: + return (data, Self.hiddenPluginResponse(for: raw)) + case .merge(let parentSessionId, let childSessionId): + var rewritten = raw + CursorSubsessionRouter.applyMerge( + to: &rewritten, + parentSessionId: parentSessionId, + childSessionId: childSessionId + ) + if let newData = try? JSONSerialization.data(withJSONObject: rewritten) { + return (newData, nil) + } + return (data, nil) + } + guard mode == "hide" || mode == "merge" else { return (data, nil) } @@ -414,13 +445,11 @@ class HookServer { } private func processRequest(data: Data, connection: NWConnection) { - // Sub-session mode pre-filter (#123, #151): events that arrived through a - // plugin proxy (`_via_plugin`) or from a Codex native subagent can be - // merged into the matching main session, hidden, or kept separate per - // the user's setting. "separate" preserves prior behavior. + // Sub-session pre-filter (#123, #151): plugin, Codex, or Cursor Task hooks + // (Cursor: transcript parent ≠ session_id) follow Agent Sub-Sessions — + // merge into the parent, hide, or keep separate. // - // Cheap byte probes first — JSONSerialization on every PostToolUse on - // the main thread is not free. + // Cheap byte probes first; avoid JSONSerialization on every PostToolUse. let routed = routeSubsessionPayloadIfNeeded(data: data) if let responseData = routed.responseData { sendResponse(connection: connection, data: responseData) diff --git a/Sources/CodeIsland/L10n.swift b/Sources/CodeIsland/L10n.swift index f3b8852e..a8e5fa3b 100644 --- a/Sources/CodeIsland/L10n.swift +++ b/Sources/CodeIsland/L10n.swift @@ -126,7 +126,7 @@ final class L10n: ObservableObject { "tool_history_limit": "Tool History Limit", "tool_history_limit_desc": "Max number of recent tool calls shown per session", "plugin_session_mode": "Agent Sub-Sessions", - "plugin_session_mode_desc": "How to show child sessions spawned by agents, including Codex subagents and plugin hook events (e.g. omo in OpenCode)", + "plugin_session_mode_desc": "How to show child sessions spawned by agents, including Codex subagents, Cursor IDE Task/subagents, and plugin hook events (e.g. omo in OpenCode)", "plugin_session_mode_separate": "Show separately", "plugin_session_mode_merge": "Merge into main", "plugin_session_mode_hide": "Hide", @@ -466,7 +466,7 @@ final class L10n: ObservableObject { "tool_history_limit": "Tool-Verlaufslimit", "tool_history_limit_desc": "Maximale Anzahl der zuletzt angezeigten Tool-Aufrufe pro Sitzung", "plugin_session_mode": "Agent-Sub-Sitzungen", - "plugin_session_mode_desc": "Darstellung von durch Agenten gestarteten Kind-Sitzungen, einschließlich Codex-Subagenten und Plugin-Hook-Ereignissen (z. B. omo in OpenCode)", + "plugin_session_mode_desc": "Darstellung von durch Agenten gestarteten Kind-Sitzungen, einschließlich Codex-Subagenten, Cursor-IDE-Task/Subagenten und Plugin-Hook-Ereignissen (z. B. omo in OpenCode)", "plugin_session_mode_separate": "Separat anzeigen", "plugin_session_mode_merge": "In Hauptsitzung zusammenführen", "plugin_session_mode_hide": "Ausblenden", @@ -806,7 +806,7 @@ final class L10n: ObservableObject { "tool_history_limit": "工具历史上限", "tool_history_limit_desc": "每个会话显示的最近工具调用数量上限", "plugin_session_mode": "Agent 子会话", - "plugin_session_mode_desc": "处理由 agent 派生的子会话,包括 Codex subagent 和插件 hook 事件(例如 OpenCode 中的 omo)", + "plugin_session_mode_desc": "处理由 agent 派生的子会话,包括 Codex subagent、Cursor IDE Task/subagent 和插件 hook 事件(例如 OpenCode 中的 omo)", "plugin_session_mode_separate": "独立显示", "plugin_session_mode_merge": "合并到主会话", "plugin_session_mode_hide": "隐藏", @@ -1146,7 +1146,7 @@ final class L10n: ObservableObject { "tool_history_limit": "工具歷史上限", "tool_history_limit_desc": "每個會話顯示的最近工具呼叫數量上限", "plugin_session_mode": "Agent 子會話", - "plugin_session_mode_desc": "處理由 agent 衍生的子會話,包括 Codex subagent 和外掛 hook 事件(例如 OpenCode 中的 omo)", + "plugin_session_mode_desc": "處理由 agent 衍生的子會話,包括 Codex subagent、Cursor IDE Task/subagent 和外掛 hook 事件(例如 OpenCode 中的 omo)", "plugin_session_mode_separate": "獨立顯示", "plugin_session_mode_merge": "合併到主會話", "plugin_session_mode_hide": "隱藏", @@ -1486,7 +1486,7 @@ final class L10n: ObservableObject { "tool_history_limit": "ツール履歴の上限", "tool_history_limit_desc": "セッションごとに表示する最近のツール呼び出し数の上限です", "plugin_session_mode": "エージェントのサブセッション", - "plugin_session_mode_desc": "Codex subagent やプラグイン hook イベントなど、エージェントから派生した子セッションの表示方法を選択します(例: OpenCode の omo)", + "plugin_session_mode_desc": "Codex subagent、Cursor IDE Task/subagent、プラグイン hook イベントなど、エージェントから派生した子セッションの表示方法を選択します(例: OpenCode の omo)", "plugin_session_mode_separate": "別々に表示", "plugin_session_mode_merge": "メインセッションに統合", "plugin_session_mode_hide": "非表示", @@ -1826,7 +1826,7 @@ final class L10n: ObservableObject { "tool_history_limit": "도구 기록 제한", "tool_history_limit_desc": "세션별로 표시할 최근 도구 호출의 최대 개수입니다", "plugin_session_mode": "에이전트 하위 세션", - "plugin_session_mode_desc": "Codex subagent 및 플러그인 hook 이벤트 등 에이전트가 만든 하위 세션을 표시하는 방식 (예: OpenCode의 omo)", + "plugin_session_mode_desc": "Codex subagent, Cursor IDE Task/subagent 및 플러그인 hook 이벤트 등 에이전트가 만든 하위 세션을 표시하는 방식 (예: OpenCode의 omo)", "plugin_session_mode_separate": "별도로 표시", "plugin_session_mode_merge": "메인 세션으로 병합", "plugin_session_mode_hide": "숨김", @@ -2166,7 +2166,7 @@ final class L10n: ObservableObject { "tool_history_limit": "Araç Geçmişi Limiti", "tool_history_limit_desc": "Oturum başına gösterilen son araç çağrı sayısı", "plugin_session_mode": "Ajan Alt Oturumları", - "plugin_session_mode_desc": "Codex subagent'ları ve eklenti hook olayları dahil, ajanların başlattığı alt oturumların nasıl gösterileceği (örn: OpenCode içinde omo)", + "plugin_session_mode_desc": "Codex subagent'ları, Cursor IDE Task/subagent'ları ve eklenti hook olayları dahil, ajanların başlattığı alt oturumların nasıl gösterileceği (örn: OpenCode içinde omo)", "plugin_session_mode_separate": "Ayrı göster", "plugin_session_mode_merge": "Ana oturuma birleştir", "plugin_session_mode_hide": "Gizle", diff --git a/Sources/CodeIsland/SessionPersistence.swift b/Sources/CodeIsland/SessionPersistence.swift index 3f26b7dd..8afac8b6 100644 --- a/Sources/CodeIsland/SessionPersistence.swift +++ b/Sources/CodeIsland/SessionPersistence.swift @@ -31,6 +31,10 @@ struct PersistedSession: Codable { let cliStartTime: Date? let startTime: Date let lastActivity: Date + /// Absolute JSONL path for session fold and transcript tailing. + let transcriptPath: String? + /// Closed subagent ids; kept across relaunch so merge cannot revive them. + let closedSubagentIds: [String]? } enum SessionPersistence { @@ -66,7 +70,9 @@ enum SessionPersistence { cliPid: s.cliPid, cliStartTime: s.cliStartTime, startTime: s.startTime, - lastActivity: s.lastActivity + lastActivity: s.lastActivity, + transcriptPath: s.transcriptPath, + closedSubagentIds: s.closedSubagentIds.isEmpty ? nil : Array(s.closedSubagentIds).sorted() ) } do { diff --git a/Sources/CodeIslandCore/CursorSessionFolding.swift b/Sources/CodeIslandCore/CursorSessionFolding.swift new file mode 100644 index 00000000..47227b4e --- /dev/null +++ b/Sources/CodeIslandCore/CursorSessionFolding.swift @@ -0,0 +1,114 @@ +import Foundation + +/// Maps Cursor Task/subagent hooks onto their parent chat card. +/// +/// Child hooks use a different `session_id`, but `transcript_path` still points at +/// `…/agent-transcripts//…`. Without rewriting, each Task appears as a +/// duplicate Cursor card for the same IDE conversation. +public enum CursorSessionFolding { + /// UUID segment under `agent-transcripts/`. + private static let uuidDirPattern = #"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"# + + /// Parent conversation id from a Cursor `transcript_path`, when present. + /// + /// Recognized layouts: + /// - `…/agent-transcripts//.jsonl` + /// - `…/agent-transcripts//subagents/.jsonl` + public static func parentConversationId(fromTranscriptPath path: String) -> String? { + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + let parts = (trimmed as NSString).pathComponents + guard let transcriptsIdx = parts.lastIndex(of: "agent-transcripts"), + transcriptsIdx + 1 < parts.count else { + return nil + } + + let parentCandidate = parts[transcriptsIdx + 1] + guard parentCandidate.range(of: uuidDirPattern, options: .regularExpression) != nil else { + return nil + } + return parentCandidate + } + + /// Parent id when `childSessionId` is a Task of that conversation + /// (`transcript_path` parent ≠ `childSessionId`). + public static func foldTarget(childSessionId: String, transcriptPath: String?) -> String? { + let child = childSessionId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !child.isEmpty, + let path = transcriptPath, + let parentId = parentConversationId(fromTranscriptPath: path), + parentId != child else { + return nil + } + return parentId + } +} + +/// How to handle a Cursor Task/subagent hook under Agent Sub-Sessions. +public enum CursorSubsessionRouteDecision: Equatable { + case leave + case hide + case merge(parentSessionId: String, childSessionId: String) +} + +public enum CursorSubsessionRouter { + /// Apply Agent Sub-Sessions (`separate` / `merge` / `hide`) to Cursor hooks. + /// Same three modes as Codex and plugin children. + public static func decide(raw: [String: Any], mode: String) -> CursorSubsessionRouteDecision { + let normalizedSource = SessionSnapshot.normalizedSupportedSource( + (raw["_source"] as? String) ?? (raw["source"] as? String) + ) + guard normalizedSource == "cursor" || normalizedSource == "cursor-cli" else { + return .leave + } + + let childSessionId = nonEmptyString(raw["session_id"]) ?? nonEmptyString(raw["sessionId"]) + guard let childSessionId else { return .leave } + + let transcriptPath = nonEmptyString(raw["transcript_path"]) ?? nonEmptyString(raw["transcriptPath"]) + guard let parentId = CursorSessionFolding.foldTarget( + childSessionId: childSessionId, + transcriptPath: transcriptPath + ) else { + return .leave + } + + switch mode { + case "hide": + return .hide + case "merge": + return .merge(parentSessionId: parentId, childSessionId: childSessionId) + default: + // separate (or unknown): leave as its own session card + return .leave + } + } + + /// Rewrite the hook onto the parent session and attach `agent_id` = child. + public static func applyMerge( + to raw: inout [String: Any], + parentSessionId: String, + childSessionId: String + ) { + raw["session_id"] = parentSessionId + raw["agent_id"] = nonEmptyString(raw["agent_id"]) ?? childSessionId + if nonEmptyString(raw["agent_type"]) == nil { + raw["agent_type"] = "cursor-subagent" + } + raw["_cursor_subagent"] = true + raw["_cursor_subagent_session_id"] = childSessionId + if let eventName = nonEmptyString(raw["hook_event_name"]) + ?? nonEmptyString(raw["hookEventName"]) + ?? nonEmptyString(raw["event_name"]) + ?? nonEmptyString(raw["eventName"]) { + raw["_cursor_subagent_event"] = EventNormalizer.normalize(eventName) + } + } + + private static func nonEmptyString(_ value: Any?) -> String? { + guard let string = value as? String else { return nil } + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/Sources/CodeIslandCore/SessionSnapshot.swift b/Sources/CodeIslandCore/SessionSnapshot.swift index ffa7dec0..61ba50ff 100644 --- a/Sources/CodeIslandCore/SessionSnapshot.swift +++ b/Sources/CodeIslandCore/SessionSnapshot.swift @@ -84,6 +84,9 @@ public struct SessionSnapshot: Sendable { public var toolHistory: [ToolHistoryEntry] = [] public var totalToolCallCount: Int = 0 public var subagents: [String: SubagentState] = [:] + /// Agent ids closed by SubagentStop/Stop/SessionEnd. + /// Blocks merge/fold from putting a finished subagent back on the parent. + public var closedSubagentIds: Set = [] public var startTime: Date = Date() public var lastUserPrompt: String? public var lastAssistantMessage: String? @@ -773,10 +776,14 @@ public func reduceEvent( } // Always update metadata from parent events. Subagent events are routed - // through the parent session ID, so applying their metadata here would + // through the parent session ID, so applying their full metadata here would // overwrite the parent's model/transcript/title with child-session values. + // Still fill *missing* identity fields so a merge-first Task hook does not + // leave the parent stuck as the default `source == "claude"`. if event.agentId == nil { extractMetadata(into: &sessions, sessionId: sessionId, event: event) + } else { + fillMissingParentMetadataFromSubagentEvent(into: &sessions, sessionId: sessionId, event: event) } let isRemote = sessions[sessionId]?.isRemote == true @@ -818,6 +825,11 @@ public func reduceEvent( sessions[sessionId]?.status = .processing sessions[sessionId]?.currentTool = nil sessions[sessionId]?.toolDescription = nil + // Separate-mode Cursor Task Stop self-tombstones; a new prompt means relaunch. + if let source = sessions[sessionId]?.source, + source == "cursor" || source == "cursor-cli" { + sessions[sessionId]?.closedSubagentIds.remove(sessionId) + } // Probe a wider set of field names + nested containers. Qwen Code (#103), // Hermes (#117), and most Claude forks put the prompt at "prompt" top-level, // but some forks nest it inside `input` / `data` / `payload` / `params`, @@ -891,12 +903,26 @@ public func reduceEvent( sessions[sessionId]?.lastAssistantMessage = text sessions[sessionId]?.addRecentMessage(ChatMessage(isUser: false, text: text)) } + let hasActiveSubagents = sessions[sessionId]?.subagents.values.contains { + $0.status != .idle + } == true if let source = sessions[sessionId]?.source, SessionSnapshot.ideCompletionSources.contains(source) { - sessions[sessionId]?.status = .idle - sessions[sessionId]?.currentTool = nil - sessions[sessionId]?.toolDescription = nil - effects.append(.enqueueCompletion(sessionId: sessionId)) + // Parent chat finished a turn while folded Tasks are still working — + // keep the card active and do not pop a premature completion. + if hasActiveSubagents { + if !isWaiting { + sessions[sessionId]?.status = .running + if sessions[sessionId]?.currentTool == nil { + sessions[sessionId]?.currentTool = "Agent" + } + } + } else { + sessions[sessionId]?.status = .idle + sessions[sessionId]?.currentTool = nil + sessions[sessionId]?.toolDescription = nil + effects.append(.enqueueCompletion(sessionId: sessionId)) + } } else { sessions[sessionId]?.status = .processing } @@ -927,10 +953,23 @@ public func reduceEvent( case "Stop": // Detect ESC/Ctrl+C interruption let stopReason = event.rawJSON["stop_reason"] as? String ?? "" - sessions[sessionId]?.interrupted = (stopReason == "user" || stopReason == "interrupted") - sessions[sessionId]?.status = .idle - sessions[sessionId]?.currentTool = nil - sessions[sessionId]?.toolDescription = nil + let wasInterrupted = (stopReason == "user" || stopReason == "interrupted") + let hasActiveSubagents = sessions[sessionId]?.subagents.values.contains { + $0.status != .idle + } == true + // Separate-mode Cursor Tasks have no agent_id, so merge later must see + // this id as closed — otherwise a still-live IDE `_ppid` resuscitates them. + // Skip when this is a parent chat that still has folded Tasks running. + if !hasActiveSubagents, + let source = sessions[sessionId]?.source, + source == "cursor" || source == "cursor-cli" { + sessions[sessionId]?.closedSubagentIds.insert(sessionId) + // Drop process identity so a still-open IDE `_ppid` does not look like + // a live Task after Stop when the card is later considered for merge. + sessions[sessionId]?.cliPid = nil + sessions[sessionId]?.cliStartTime = nil + } + let assistantMsg = firstStringFromEvent( event, keys: ["last_assistant_message", "text", "message", "summary"], @@ -952,7 +991,24 @@ public func reduceEvent( sessions[sessionId]?.insertRecentMessage(ChatMessage(isUser: true, text: prompt), at: insertAt) } } - effects.append(.enqueueCompletion(sessionId: sessionId)) + + // Parent chat Stop while folded Tasks are still working — keep the card + // active and do not pop a premature completion (same as AfterAgentResponse). + if hasActiveSubagents { + if !isWaiting { + sessions[sessionId]?.status = .running + if sessions[sessionId]?.currentTool == nil { + sessions[sessionId]?.currentTool = "Agent" + } + } + // Do not latch `interrupted` onto a still-active parent+Task card. + } else { + sessions[sessionId]?.interrupted = wasInterrupted + sessions[sessionId]?.status = .idle + sessions[sessionId]?.currentTool = nil + sessions[sessionId]?.toolDescription = nil + effects.append(.enqueueCompletion(sessionId: sessionId)) + } case "SessionStart": effects.append(.stopMonitor(sessionId: sessionId)) sessions[sessionId] = SessionSnapshot(startTime: Date()) @@ -1129,6 +1185,63 @@ private func applyEnvMetadata(into sessions: inout [String: SessionSnapshot], se } } +/// Fill identity fields on a parent card from a merged Task/subagent hook. +/// Unlike `extractMetadata`, never overwrites values already set on the parent +/// (child payloads can carry Task-specific cwd/model/title). +public func fillMissingParentMetadataFromSubagentEvent( + into sessions: inout [String: SessionSnapshot], + sessionId: String, + event: HookEvent +) { + // Default SessionSnapshot.source is "claude". Upgrade when the hook carries a + // real source so merge-first Cursor Tasks are not branded as Claude forever. + if sessions[sessionId]?.source == "claude", + let source = SessionSnapshot.normalizedSupportedSource(event.rawJSON["_source"] as? String) + ?? SessionSnapshot.normalizedSupportedSource(event.rawJSON["source"] as? String), + source != "claude" { + sessions[sessionId]?.source = source + } + + let cwdMissing = sessions[sessionId]?.cwd == nil + || SessionSnapshot.isUnhelpfulHookCwd(sessions[sessionId]?.cwd ?? "") + if cwdMissing { + if let cwd = event.rawJSON["cwd"] as? String, !cwd.isEmpty, + !SessionSnapshot.isUnhelpfulHookCwd(cwd) { + sessions[sessionId]?.cwd = cwd + } else if let roots = event.rawJSON["workspace_roots"] as? [String], + let first = roots.first, !first.isEmpty { + sessions[sessionId]?.cwd = first + } else if let tp = event.rawJSON["transcript_path"] as? String, + tp.contains("/.cursor/projects/") { + let parts = tp.split(separator: "/") + if let idx = parts.firstIndex(of: "projects"), idx + 1 < parts.count { + let projectName = String(parts[idx + 1]) + sessions[sessionId]?.cwd = "/\(parts[...idx].joined(separator: "/"))/\(projectName)" + } + } + } + + if sessions[sessionId]?.transcriptPath == nil, + let transcriptPath = event.rawJSON["transcript_path"] as? String, !transcriptPath.isEmpty { + sessions[sessionId]?.transcriptPath = transcriptPath + } + + if sessions[sessionId]?.cliPid == nil, + let ppid = event.rawJSON["_ppid"] as? Int, ppid > 0 { + sessions[sessionId]?.cliPid = pid_t(ppid) + } +} + +private func shouldReopenCursorSubagentOnPrompt(event: HookEvent, session: SessionSnapshot?) -> Bool { + if (event.rawJSON["_cursor_subagent"] as? Bool) == true { + return true + } + let source = SessionSnapshot.normalizedSupportedSource(event.rawJSON["_source"] as? String) + ?? SessionSnapshot.normalizedSupportedSource(event.rawJSON["source"] as? String) + ?? session?.source + return source == "cursor" || source == "cursor-cli" +} + public func extractMetadata(into sessions: inout [String: SessionSnapshot], sessionId: String, event: HookEvent) { let eventCwd = event.rawJSON["cwd"] as? String if let cwd = eventCwd, !cwd.isEmpty, @@ -1294,13 +1407,20 @@ private func ensureSubagent( sessionId: String, agentId: String, event: HookEvent -) { +) -> Bool { + // Only SubagentStart/SessionStart may reopen a closed agent id for most + // providers. Cursor Tasks relaunch via UserPromptSubmit (see handleSubagentEvent). + // Later tool/response hooks with the same agent_id must not revive it. + if sessions[sessionId]?.closedSubagentIds.contains(agentId) == true { + return false + } if sessions[sessionId]?.subagents[agentId] == nil { sessions[sessionId]?.subagents[agentId] = SubagentState( agentId: agentId, agentType: subagentType(from: event) ) } + return true } /// Handle subagent events. Returns true if the event was consumed. @@ -1316,6 +1436,7 @@ private func handleSubagentEvent( switch eventName { case "SubagentStart", "SessionStart": let agentType = subagentType(from: event) + sessions[sessionId]?.closedSubagentIds.remove(agentId) sessions[sessionId]?.subagents[agentId] = SubagentState( agentId: agentId, agentType: agentType @@ -1331,7 +1452,13 @@ private func handleSubagentEvent( return true case "UserPromptSubmit": - ensureSubagent(sessions: &sessions, sessionId: sessionId, agentId: agentId, event: event) + // Cursor has no SessionStart for Tasks; beforeSubmitPrompt is the relaunch signal. + if shouldReopenCursorSubagentOnPrompt(event: event, session: sessions[sessionId]) { + sessions[sessionId]?.closedSubagentIds.remove(agentId) + } + guard ensureSubagent(sessions: &sessions, sessionId: sessionId, agentId: agentId, event: event) else { + return true + } sessions[sessionId]?.subagents[agentId]?.status = .processing sessions[sessionId]?.subagents[agentId]?.lastActivity = Date() if sessions[sessionId]?.status != .waitingApproval && sessions[sessionId]?.status != .waitingQuestion { @@ -1346,6 +1473,7 @@ private func handleSubagentEvent( case "SubagentStop", "Stop", "SessionEnd": sessions[sessionId]?.subagents.removeValue(forKey: agentId) + sessions[sessionId]?.closedSubagentIds.insert(agentId) // If no more subagents, revert parent to processing (waiting for main thread to continue) if sessions[sessionId]?.subagents.isEmpty == true { if sessions[sessionId]?.status == .running && sessions[sessionId]?.currentTool == "Agent" { @@ -1358,7 +1486,9 @@ private func handleSubagentEvent( return true case "PreToolUse": - ensureSubagent(sessions: &sessions, sessionId: sessionId, agentId: agentId, event: event) + guard ensureSubagent(sessions: &sessions, sessionId: sessionId, agentId: agentId, event: event) else { + return true + } sessions[sessionId]?.subagents[agentId]?.status = .running sessions[sessionId]?.subagents[agentId]?.currentTool = event.toolName sessions[sessionId]?.subagents[agentId]?.toolDescription = event.toolDescription @@ -1371,7 +1501,9 @@ private func handleSubagentEvent( return true case "PostToolUse": - ensureSubagent(sessions: &sessions, sessionId: sessionId, agentId: agentId, event: event) + guard ensureSubagent(sessions: &sessions, sessionId: sessionId, agentId: agentId, event: event) else { + return true + } if let tool = sessions[sessionId]?.subagents[agentId]?.currentTool { let agentType = sessions[sessionId]?.subagents[agentId]?.agentType let desc = sessions[sessionId]?.subagents[agentId]?.toolDescription @@ -1385,7 +1517,9 @@ private func handleSubagentEvent( return true case "PostToolUseFailure": - ensureSubagent(sessions: &sessions, sessionId: sessionId, agentId: agentId, event: event) + guard ensureSubagent(sessions: &sessions, sessionId: sessionId, agentId: agentId, event: event) else { + return true + } if let tool = sessions[sessionId]?.subagents[agentId]?.currentTool { let agentType = sessions[sessionId]?.subagents[agentId]?.agentType let desc = sessions[sessionId]?.subagents[agentId]?.toolDescription @@ -1397,6 +1531,51 @@ private func handleSubagentEvent( sessions[sessionId]?.lastActivity = Date() return true + case "AfterAgentResponse": + // Merged Cursor Task events arrive with parent session_id + child agent_id. + // Handle here so they do not overwrite the parent's reply or enqueue a false completion. + guard ensureSubagent(sessions: &sessions, sessionId: sessionId, agentId: agentId, event: event) else { + return true + } + sessions[sessionId]?.subagents[agentId]?.status = .processing + sessions[sessionId]?.subagents[agentId]?.lastActivity = Date() + sessions[sessionId]?.lastActivity = Date() + if sessions[sessionId]?.status != .waitingApproval + && sessions[sessionId]?.status != .waitingQuestion + && sessions[sessionId]?.status != .running { + sessions[sessionId]?.status = .processing + } + return true + + case "Notification": + // Keep folded Tasks' ask/question notifications on the parent card. + // Swallowing them here (as AfterAgentResponse did) dropped waitingQuestion. + guard ensureSubagent(sessions: &sessions, sessionId: sessionId, agentId: agentId, event: event) else { + return true + } + sessions[sessionId]?.subagents[agentId]?.lastActivity = Date() + sessions[sessionId]?.lastActivity = Date() + let notificationText = firstStringFromEvent( + event, + keys: ["message", "text", "summary", "status", "detail"], + includeNested: true + ) + if let msg = notificationText, !msg.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines).isEmpty { + sessions[sessionId]?.toolDescription = msg + sessions[sessionId]?.subagents[agentId]?.toolDescription = msg + } + if QuestionPayload.from(event: event) != nil { + sessions[sessionId]?.status = .waitingQuestion + sessions[sessionId]?.subagents[agentId]?.status = .waitingQuestion + effects.append(.setActiveSession(sessionId: sessionId)) + } else if sessions[sessionId]?.status != .waitingApproval + && sessions[sessionId]?.status != .waitingQuestion + && sessions[sessionId]?.status != .running { + sessions[sessionId]?.status = .processing + sessions[sessionId]?.subagents[agentId]?.status = .processing + } + return true + default: return false // Fall through to normal session handling } diff --git a/Tests/CodeIslandCoreTests/CursorSessionFoldingTests.swift b/Tests/CodeIslandCoreTests/CursorSessionFoldingTests.swift new file mode 100644 index 00000000..02824ee1 --- /dev/null +++ b/Tests/CodeIslandCoreTests/CursorSessionFoldingTests.swift @@ -0,0 +1,463 @@ +import XCTest +@testable import CodeIslandCore + +final class CursorSessionFoldingTests: XCTestCase { + + func testParentIdFromParentTranscriptPath() { + let path = "/Users/u/.cursor/projects/Users-u-Code-Reelay/agent-transcripts/e1247fd5-d9a0-48ef-8457-0304606b1833/e1247fd5-d9a0-48ef-8457-0304606b1833.jsonl" + XCTAssertEqual( + CursorSessionFolding.parentConversationId(fromTranscriptPath: path), + "e1247fd5-d9a0-48ef-8457-0304606b1833" + ) + } + + func testParentIdFromSubagentsTranscriptPath() { + let path = "/Users/u/.cursor/projects/Users-u-Code-Reelay/agent-transcripts/e1247fd5-d9a0-48ef-8457-0304606b1833/subagents/2528cb91-6379-48f2-aff8-40f4b804dafa.jsonl" + XCTAssertEqual( + CursorSessionFolding.parentConversationId(fromTranscriptPath: path), + "e1247fd5-d9a0-48ef-8457-0304606b1833" + ) + } + + func testFoldTargetWhenChildSessionDiffersFromTranscriptParent() { + let path = "/Users/u/.cursor/projects/x/agent-transcripts/e1247fd5-d9a0-48ef-8457-0304606b1833/e1247fd5-d9a0-48ef-8457-0304606b1833.jsonl" + XCTAssertEqual( + CursorSessionFolding.foldTarget( + childSessionId: "2528cb91-6379-48f2-aff8-40f4b804dafa", + transcriptPath: path + ), + "e1247fd5-d9a0-48ef-8457-0304606b1833" + ) + } + + func testFoldTargetNilWhenSessionMatchesParent() { + let path = "/Users/u/.cursor/projects/x/agent-transcripts/e1247fd5-d9a0-48ef-8457-0304606b1833/e1247fd5-d9a0-48ef-8457-0304606b1833.jsonl" + XCTAssertNil( + CursorSessionFolding.foldTarget( + childSessionId: "e1247fd5-d9a0-48ef-8457-0304606b1833", + transcriptPath: path + ) + ) + } + + func testFoldTargetNilWithoutTranscriptPath() { + XCTAssertNil( + CursorSessionFolding.foldTarget( + childSessionId: "2528cb91-6379-48f2-aff8-40f4b804dafa", + transcriptPath: nil + ) + ) + } + + func testRouterLeavesCursorChildWhenModeIsSeparate() { + let raw: [String: Any] = [ + "_source": "cursor", + "session_id": "2528cb91-6379-48f2-aff8-40f4b804dafa", + "transcript_path": "/Users/u/.cursor/projects/x/agent-transcripts/e1247fd5-d9a0-48ef-8457-0304606b1833/e1247fd5-d9a0-48ef-8457-0304606b1833.jsonl", + "hook_event_name": "beforeReadFile", + ] + XCTAssertEqual(CursorSubsessionRouter.decide(raw: raw, mode: "separate"), .leave) + } + + func testRouterMergesCursorChildWhenModeIsMerge() { + let raw: [String: Any] = [ + "_source": "cursor", + "session_id": "2528cb91-6379-48f2-aff8-40f4b804dafa", + "transcript_path": "/Users/u/.cursor/projects/x/agent-transcripts/e1247fd5-d9a0-48ef-8457-0304606b1833/e1247fd5-d9a0-48ef-8457-0304606b1833.jsonl", + "hook_event_name": "beforeReadFile", + ] + XCTAssertEqual( + CursorSubsessionRouter.decide(raw: raw, mode: "merge"), + .merge( + parentSessionId: "e1247fd5-d9a0-48ef-8457-0304606b1833", + childSessionId: "2528cb91-6379-48f2-aff8-40f4b804dafa" + ) + ) + } + + func testRouterHidesCursorChildWhenModeIsHide() { + let raw: [String: Any] = [ + "_source": "cursor", + "session_id": "2528cb91-6379-48f2-aff8-40f4b804dafa", + "transcript_path": "/Users/u/.cursor/projects/x/agent-transcripts/e1247fd5-d9a0-48ef-8457-0304606b1833/e1247fd5-d9a0-48ef-8457-0304606b1833.jsonl", + ] + XCTAssertEqual(CursorSubsessionRouter.decide(raw: raw, mode: "hide"), .hide) + } + + func testRouterLeavesUnrelatedCursorSession() { + let raw: [String: Any] = [ + "_source": "cursor", + "session_id": "e1247fd5-d9a0-48ef-8457-0304606b1833", + "transcript_path": "/Users/u/.cursor/projects/x/agent-transcripts/e1247fd5-d9a0-48ef-8457-0304606b1833/e1247fd5-d9a0-48ef-8457-0304606b1833.jsonl", + ] + XCTAssertEqual(CursorSubsessionRouter.decide(raw: raw, mode: "merge"), .leave) + } + + func testRouterLeavesNonCursorSources() { + let raw: [String: Any] = [ + "_source": "claude", + "session_id": "2528cb91-6379-48f2-aff8-40f4b804dafa", + "transcript_path": "/Users/u/.cursor/projects/x/agent-transcripts/e1247fd5-d9a0-48ef-8457-0304606b1833/e1247fd5-d9a0-48ef-8457-0304606b1833.jsonl", + ] + XCTAssertEqual(CursorSubsessionRouter.decide(raw: raw, mode: "merge"), .leave) + } + + func testApplyMergeRewritesSessionAndSetsAgentId() { + var raw: [String: Any] = [ + "session_id": "2528cb91-6379-48f2-aff8-40f4b804dafa", + "hook_event_name": "beforeShellExecution", + ] + CursorSubsessionRouter.applyMerge( + to: &raw, + parentSessionId: "e1247fd5-d9a0-48ef-8457-0304606b1833", + childSessionId: "2528cb91-6379-48f2-aff8-40f4b804dafa" + ) + XCTAssertEqual(raw["session_id"] as? String, "e1247fd5-d9a0-48ef-8457-0304606b1833") + XCTAssertEqual(raw["agent_id"] as? String, "2528cb91-6379-48f2-aff8-40f4b804dafa") + XCTAssertEqual(raw["agent_type"] as? String, "cursor-subagent") + XCTAssertEqual(raw["_cursor_subagent"] as? Bool, true) + XCTAssertEqual(raw["_cursor_subagent_event"] as? String, "PreToolUse") + } + + func testMergedCursorSubagentAfterAgentResponseDoesNotCompleteParent() throws { + var parent = SessionSnapshot() + parent.source = "cursor" + parent.status = .running + parent.lastUserPrompt = "main prompt" + parent.lastAssistantMessage = "parent reply" + + var sessions = ["e1247fd5-d9a0-48ef-8457-0304606b1833": parent] + let data = try JSONSerialization.data(withJSONObject: [ + "hook_event_name": "afterAgentResponse", + "session_id": "e1247fd5-d9a0-48ef-8457-0304606b1833", + "_source": "cursor", + "agent_id": "2528cb91-6379-48f2-aff8-40f4b804dafa", + "agent_type": "cursor-subagent", + "text": "child-only reply", + ] as [String: Any]) + let event = try XCTUnwrap(HookEvent(from: data)) + let effects = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + + XCTAssertEqual( + sessions["e1247fd5-d9a0-48ef-8457-0304606b1833"]?.lastAssistantMessage, + "parent reply" + ) + XCTAssertEqual(sessions["e1247fd5-d9a0-48ef-8457-0304606b1833"]?.status, .running) + XCTAssertFalse(effects.contains(where: { + if case .enqueueCompletion = $0 { return true } + return false + })) + XCTAssertEqual( + sessions["e1247fd5-d9a0-48ef-8457-0304606b1833"]? + .subagents["2528cb91-6379-48f2-aff8-40f4b804dafa"]?.status, + .processing + ) + } + + func testSubagentStopRecordsClosedTombstoneAndClearsOnRestart() throws { + let parentId = "e1247fd5-d9a0-48ef-8457-0304606b1833" + let childId = "2528cb91-6379-48f2-aff8-40f4b804dafa" + var parent = SessionSnapshot() + parent.source = "cursor" + parent.status = .running + parent.subagents[childId] = SubagentState(agentId: childId, agentType: "cursor-subagent") + var sessions = [parentId: parent] + + let stopData = try JSONSerialization.data(withJSONObject: [ + "hook_event_name": "stop", + "session_id": parentId, + "_source": "cursor", + "agent_id": childId, + "agent_type": "cursor-subagent", + ] as [String: Any]) + let stopEvent = try XCTUnwrap(HookEvent(from: stopData)) + _ = reduceEvent(sessions: &sessions, event: stopEvent, maxHistory: 10) + + XCTAssertNil(sessions[parentId]?.subagents[childId]) + XCTAssertEqual(sessions[parentId]?.closedSubagentIds, [childId]) + + let startData = try JSONSerialization.data(withJSONObject: [ + "hook_event_name": "SubagentStart", + "session_id": parentId, + "_source": "cursor", + "agent_id": childId, + "agent_type": "cursor-subagent", + ] as [String: Any]) + let startEvent = try XCTUnwrap(HookEvent(from: startData)) + _ = reduceEvent(sessions: &sessions, event: startEvent, maxHistory: 10) + + XCTAssertNotNil(sessions[parentId]?.subagents[childId]) + XCTAssertTrue(sessions[parentId]?.closedSubagentIds.isEmpty == true) + } + + func testLateAfterAgentResponseDoesNotClearStopTombstone() throws { + let parentId = "e1247fd5-d9a0-48ef-8457-0304606b1833" + let childId = "2528cb91-6379-48f2-aff8-40f4b804dafa" + var parent = SessionSnapshot() + parent.source = "cursor" + parent.status = .processing + parent.closedSubagentIds = [childId] + var sessions = [parentId: parent] + + let data = try JSONSerialization.data(withJSONObject: [ + "hook_event_name": "afterAgentResponse", + "session_id": parentId, + "_source": "cursor", + "agent_id": childId, + "agent_type": "cursor-subagent", + "text": "late child reply", + ] as [String: Any]) + let event = try XCTUnwrap(HookEvent(from: data)) + _ = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + + XCTAssertNil(sessions[parentId]?.subagents[childId]) + XCTAssertEqual(sessions[parentId]?.closedSubagentIds, [childId]) + XCTAssertEqual(sessions[parentId]?.status, .processing) + } + + func testSeparateCursorStopSelfTombstonesSessionId() throws { + let childId = "2528cb91-6379-48f2-aff8-40f4b804dafa" + var child = SessionSnapshot() + child.source = "cursor" + child.status = .running + child.cliPid = 12_345 + child.cliStartTime = Date(timeIntervalSince1970: 1_700_000_000) + var sessions = [childId: child] + + let stopData = try JSONSerialization.data(withJSONObject: [ + "hook_event_name": "stop", + "session_id": childId, + "_source": "cursor", + ] as [String: Any]) + let stopEvent = try XCTUnwrap(HookEvent(from: stopData)) + _ = reduceEvent(sessions: &sessions, event: stopEvent, maxHistory: 10) + + XCTAssertEqual(sessions[childId]?.status, .idle) + XCTAssertEqual(sessions[childId]?.closedSubagentIds, [childId]) + XCTAssertNil(sessions[childId]?.cliPid) + XCTAssertNil(sessions[childId]?.cliStartTime) + } + + func testParentAfterAgentResponseKeepsActiveWhileFoldedTaskRunning() throws { + let parentId = "e1247fd5-d9a0-48ef-8457-0304606b1833" + let childId = "2528cb91-6379-48f2-aff8-40f4b804dafa" + var parent = SessionSnapshot() + parent.source = "cursor" + parent.status = .running + parent.currentTool = "Agent" + parent.toolDescription = "cursor-subagent" + var sub = SubagentState(agentId: childId, agentType: "cursor-subagent") + sub.status = .running + parent.subagents[childId] = sub + var sessions = [parentId: parent] + + let data = try JSONSerialization.data(withJSONObject: [ + "hook_event_name": "afterAgentResponse", + "session_id": parentId, + "_source": "cursor", + "text": "parent turn done; task still working", + ] as [String: Any]) + let event = try XCTUnwrap(HookEvent(from: data)) + let effects = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + + XCTAssertEqual( + sessions[parentId]?.lastAssistantMessage, + "parent turn done; task still working" + ) + XCTAssertEqual(sessions[parentId]?.status, .running) + XCTAssertEqual(sessions[parentId]?.currentTool, "Agent") + XCTAssertEqual(sessions[parentId]?.subagents[childId]?.status, .running) + XCTAssertFalse(effects.contains(where: { + if case .enqueueCompletion = $0 { return true } + return false + })) + } + + func testUserPromptSubmitClearsCursorSelfTombstone() throws { + let childId = "2528cb91-6379-48f2-aff8-40f4b804dafa" + var child = SessionSnapshot() + child.source = "cursor" + child.status = .idle + child.closedSubagentIds = [childId] + var sessions = [childId: child] + + let data = try JSONSerialization.data(withJSONObject: [ + "hook_event_name": "beforeSubmitPrompt", + "session_id": childId, + "_source": "cursor", + "prompt": "retry", + ] as [String: Any]) + let event = try XCTUnwrap(HookEvent(from: data)) + _ = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + + XCTAssertTrue(sessions[childId]?.closedSubagentIds.isEmpty == true) + XCTAssertEqual(sessions[childId]?.status, .processing) + } + + func testMergedCursorNotificationWithQuestionSetsWaitingQuestion() throws { + let parentId = "e1247fd5-d9a0-48ef-8457-0304606b1833" + let childId = "2528cb91-6379-48f2-aff8-40f4b804dafa" + var parent = SessionSnapshot() + parent.source = "cursor" + parent.status = .running + parent.subagents[childId] = SubagentState(agentId: childId, agentType: "cursor-subagent") + var sessions = [parentId: parent] + + let data = try JSONSerialization.data(withJSONObject: [ + "hook_event_name": "Notification", + "session_id": parentId, + "_source": "cursor", + "agent_id": childId, + "agent_type": "cursor-subagent", + "question": "Which approach should we take?", + "options": ["A", "B"], + ] as [String: Any]) + let event = try XCTUnwrap(HookEvent(from: data)) + let effects = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + + XCTAssertEqual(sessions[parentId]?.status, .waitingQuestion) + XCTAssertEqual(sessions[parentId]?.subagents[childId]?.status, .waitingQuestion) + XCTAssertTrue(effects.contains(where: { + if case .setActiveSession(let id) = $0 { return id == parentId } + return false + })) + } + + func testParentStopKeepsActiveWhileFoldedTaskRunning() throws { + let parentId = "e1247fd5-d9a0-48ef-8457-0304606b1833" + let childId = "2528cb91-6379-48f2-aff8-40f4b804dafa" + var parent = SessionSnapshot() + parent.source = "cursor" + parent.status = .running + parent.currentTool = "Agent" + var sub = SubagentState(agentId: childId, agentType: "cursor-subagent") + sub.status = .running + parent.subagents[childId] = sub + var sessions = [parentId: parent] + + let data = try JSONSerialization.data(withJSONObject: [ + "hook_event_name": "stop", + "session_id": parentId, + "_source": "cursor", + "stop_reason": "user", + "last_assistant_message": "main turn ended", + ] as [String: Any]) + let event = try XCTUnwrap(HookEvent(from: data)) + let effects = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + + XCTAssertEqual(sessions[parentId]?.status, .running) + XCTAssertEqual(sessions[parentId]?.currentTool, "Agent") + XCTAssertEqual(sessions[parentId]?.subagents[childId]?.status, .running) + XCTAssertFalse(sessions[parentId]?.interrupted == true) + XCTAssertFalse(sessions[parentId]?.closedSubagentIds.contains(parentId) == true) + XCTAssertFalse(effects.contains(where: { + if case .enqueueCompletion = $0 { return true } + return false + })) + } + + func testMergeFirstTaskHookFillsParentSourceAndTranscript() throws { + let parentId = "e1247fd5-d9a0-48ef-8457-0304606b1833" + let childId = "2528cb91-6379-48f2-aff8-40f4b804dafa" + let transcriptPath = "/Users/u/.cursor/projects/x/agent-transcripts/\(parentId)/\(parentId).jsonl" + var sessions: [String: SessionSnapshot] = [:] + + let data = try JSONSerialization.data(withJSONObject: [ + "hook_event_name": "beforeReadFile", + "session_id": parentId, + "_source": "cursor", + "agent_id": childId, + "agent_type": "cursor-subagent", + "_cursor_subagent": true, + "transcript_path": transcriptPath, + "_ppid": 42_424, + "tool_name": "Read", + ] as [String: Any]) + let event = try XCTUnwrap(HookEvent(from: data)) + _ = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + + XCTAssertEqual(sessions[parentId]?.source, "cursor") + XCTAssertEqual(sessions[parentId]?.transcriptPath, transcriptPath) + XCTAssertEqual(sessions[parentId]?.cliPid, 42_424) + XCTAssertEqual(sessions[parentId]?.subagents[childId]?.currentTool, "Read") + } + + func testMergeFirstTaskHookDoesNotOverwriteKnownParentSource() throws { + let parentId = "e1247fd5-d9a0-48ef-8457-0304606b1833" + let childId = "2528cb91-6379-48f2-aff8-40f4b804dafa" + var parent = SessionSnapshot() + parent.source = "cursor" + parent.cwd = "/tmp/known" + parent.transcriptPath = "/known.jsonl" + parent.cliPid = 7 + var sessions = [parentId: parent] + + let data = try JSONSerialization.data(withJSONObject: [ + "hook_event_name": "beforeReadFile", + "session_id": parentId, + "_source": "cursor-cli", + "agent_id": childId, + "cwd": "/tmp/child-only", + "transcript_path": "/child.jsonl", + "_ppid": 99, + "tool_name": "Read", + ] as [String: Any]) + let event = try XCTUnwrap(HookEvent(from: data)) + _ = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + + XCTAssertEqual(sessions[parentId]?.source, "cursor") + XCTAssertEqual(sessions[parentId]?.cwd, "/tmp/known") + XCTAssertEqual(sessions[parentId]?.transcriptPath, "/known.jsonl") + XCTAssertEqual(sessions[parentId]?.cliPid, 7) + } + + func testCursorUserPromptSubmitClearsMergedTombstoneAndReopens() throws { + let parentId = "e1247fd5-d9a0-48ef-8457-0304606b1833" + let childId = "2528cb91-6379-48f2-aff8-40f4b804dafa" + var parent = SessionSnapshot() + parent.source = "cursor" + parent.status = .processing + parent.closedSubagentIds = [childId] + var sessions = [parentId: parent] + + let data = try JSONSerialization.data(withJSONObject: [ + "hook_event_name": "beforeSubmitPrompt", + "session_id": parentId, + "_source": "cursor", + "agent_id": childId, + "agent_type": "cursor-subagent", + "_cursor_subagent": true, + "prompt": "retry the task", + ] as [String: Any]) + let event = try XCTUnwrap(HookEvent(from: data)) + _ = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + + XCTAssertTrue(sessions[parentId]?.closedSubagentIds.isEmpty == true) + XCTAssertNotNil(sessions[parentId]?.subagents[childId]) + XCTAssertEqual(sessions[parentId]?.subagents[childId]?.status, .processing) + XCTAssertEqual(sessions[parentId]?.status, .running) + } + + func testLatePreToolUseStillBlockedByClosedTombstone() throws { + let parentId = "e1247fd5-d9a0-48ef-8457-0304606b1833" + let childId = "2528cb91-6379-48f2-aff8-40f4b804dafa" + var parent = SessionSnapshot() + parent.source = "cursor" + parent.status = .processing + parent.closedSubagentIds = [childId] + var sessions = [parentId: parent] + + let data = try JSONSerialization.data(withJSONObject: [ + "hook_event_name": "beforeReadFile", + "session_id": parentId, + "_source": "cursor", + "agent_id": childId, + "tool_name": "Read", + ] as [String: Any]) + let event = try XCTUnwrap(HookEvent(from: data)) + _ = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + + XCTAssertNil(sessions[parentId]?.subagents[childId]) + XCTAssertEqual(sessions[parentId]?.closedSubagentIds, [childId]) + } +} diff --git a/Tests/CodeIslandTests/AppStateCursorSubsessionTests.swift b/Tests/CodeIslandTests/AppStateCursorSubsessionTests.swift new file mode 100644 index 00000000..6053f0cf --- /dev/null +++ b/Tests/CodeIslandTests/AppStateCursorSubsessionTests.swift @@ -0,0 +1,724 @@ +import XCTest +@testable import CodeIsland +import CodeIslandCore + +@MainActor +final class AppStateCursorSubsessionTests: XCTestCase { + func testKnownCursorSubagentSessionMergesIntoParentFromTranscriptPath() { + let previousMode = UserDefaults.standard.object(forKey: SettingsKey.pluginSessionMode) + UserDefaults.standard.set("merge", forKey: SettingsKey.pluginSessionMode) + defer { + if let previousMode { + UserDefaults.standard.set(previousMode, forKey: SettingsKey.pluginSessionMode) + } else { + UserDefaults.standard.removeObject(forKey: SettingsKey.pluginSessionMode) + } + } + + let parentId = "e1247fd5-d9a0-48ef-8457-0304606b1833" + let childId = "2528cb91-6379-48f2-aff8-40f4b804dafa" + let transcriptPath = "/Users/u/.cursor/projects/x/agent-transcripts/\(parentId)/\(parentId).jsonl" + + let appState = AppState() + var parent = SessionSnapshot() + parent.source = "cursor" + parent.status = .running + parent.providerSessionId = parentId + parent.transcriptPath = transcriptPath + + var child = SessionSnapshot() + child.source = "cursor" + child.status = .running + child.currentTool = "Read" + child.transcriptPath = transcriptPath + child.lastActivity = Date() + + appState.sessions[parentId] = parent + appState.sessions[childId] = child + + XCTAssertTrue(appState.applyCursorSubsessionModeToKnownSessions()) + XCTAssertNil(appState.sessions[childId]) + XCTAssertEqual(appState.sessions[parentId]?.subagents[childId]?.agentType, "cursor-subagent") + XCTAssertEqual(appState.sessions[parentId]?.subagents[childId]?.currentTool, "Read") + XCTAssertEqual(appState.activeSessionId, parentId) + } + + func testSeparateModeSplitsMergedCursorSubagent() { + let previousMode = UserDefaults.standard.object(forKey: SettingsKey.pluginSessionMode) + UserDefaults.standard.set("separate", forKey: SettingsKey.pluginSessionMode) + defer { + if let previousMode { + UserDefaults.standard.set(previousMode, forKey: SettingsKey.pluginSessionMode) + } else { + UserDefaults.standard.removeObject(forKey: SettingsKey.pluginSessionMode) + } + } + + let parentId = "e1247fd5-d9a0-48ef-8457-0304606b1833" + let childId = "2528cb91-6379-48f2-aff8-40f4b804dafa" + let transcriptPath = "/Users/u/.cursor/projects/x/agent-transcripts/\(parentId)/\(parentId).jsonl" + + let appState = AppState() + var parent = SessionSnapshot() + parent.source = "cursor" + parent.status = .running + parent.currentTool = "Agent" + parent.toolDescription = "cursor-subagent" + parent.providerSessionId = parentId + parent.transcriptPath = transcriptPath + parent.cwd = "/tmp/project" + parent.cliPid = 11_111 + parent.cliStartTime = Date(timeIntervalSince1970: 1_700_000_000) + var subagent = SubagentState(agentId: childId, agentType: "cursor-subagent") + subagent.status = .running + subagent.currentTool = "Read" + parent.subagents[childId] = subagent + appState.sessions[parentId] = parent + + appState.applyCurrentPluginSessionMode(persist: false) + + XCTAssertTrue(appState.sessions[parentId]?.subagents.isEmpty == true) + XCTAssertNil(appState.sessions[parentId]?.currentTool) + XCTAssertEqual(appState.sessions[parentId]?.cliPid, 11_111) + XCTAssertEqual(appState.sessions[childId]?.source, "cursor") + XCTAssertEqual(appState.sessions[childId]?.providerSessionId, childId) + XCTAssertEqual(appState.sessions[childId]?.currentTool, "Read") + // Parent path kept for fold identity when switching back to merge. + XCTAssertEqual(appState.sessions[childId]?.transcriptPath, transcriptPath) + // Split cards must not inherit the parent IDE process identity. + XCTAssertNil(appState.sessions[childId]?.cliPid) + XCTAssertNil(appState.sessions[childId]?.cliStartTime) + XCTAssertEqual(appState.activeSessionId, childId) + } + + func testSeparateModeTailsOnlyDistinctChildTranscript() { + let previousMode = UserDefaults.standard.object(forKey: SettingsKey.pluginSessionMode) + UserDefaults.standard.set("separate", forKey: SettingsKey.pluginSessionMode) + defer { + if let previousMode { + UserDefaults.standard.set(previousMode, forKey: SettingsKey.pluginSessionMode) + } else { + UserDefaults.standard.removeObject(forKey: SettingsKey.pluginSessionMode) + } + } + + let parentId = "e1247fd5-d9a0-48ef-8457-0304606b1833" + let childId = "2528cb91-6379-48f2-aff8-40f4b804dafa" + let parentTranscript = "/Users/u/.cursor/projects/x/agent-transcripts/\(parentId)/\(parentId).jsonl" + let childTranscript = "/Users/u/.cursor/projects/x/agent-transcripts/\(parentId)/subagents/\(childId).jsonl" + + let appState = AppState() + var parent = SessionSnapshot() + parent.source = "cursor" + parent.status = .running + parent.providerSessionId = parentId + parent.transcriptPath = parentTranscript + var subagent = SubagentState(agentId: childId, agentType: "cursor-subagent") + subagent.status = .running + parent.subagents[childId] = subagent + appState.sessions[parentId] = parent + + var existingChild = SessionSnapshot() + existingChild.source = "cursor" + existingChild.transcriptPath = childTranscript + appState.sessions[childId] = existingChild + + appState.applyCurrentPluginSessionMode(persist: false) + + XCTAssertEqual(appState.sessions[childId]?.transcriptPath, childTranscript) + XCTAssertEqual(appState.sessions[parentId]?.transcriptPath, parentTranscript) + } + + func testSeparateModeDoesNotFoldKnownCursorChildCards() { + let previousMode = UserDefaults.standard.object(forKey: SettingsKey.pluginSessionMode) + UserDefaults.standard.set("separate", forKey: SettingsKey.pluginSessionMode) + defer { + if let previousMode { + UserDefaults.standard.set(previousMode, forKey: SettingsKey.pluginSessionMode) + } else { + UserDefaults.standard.removeObject(forKey: SettingsKey.pluginSessionMode) + } + } + + let parentId = "e1247fd5-d9a0-48ef-8457-0304606b1833" + let childId = "2528cb91-6379-48f2-aff8-40f4b804dafa" + let transcriptPath = "/Users/u/.cursor/projects/x/agent-transcripts/\(parentId)/\(parentId).jsonl" + + let appState = AppState() + var child = SessionSnapshot() + child.source = "cursor" + child.status = .running + child.transcriptPath = transcriptPath + appState.sessions[childId] = child + + XCTAssertFalse(appState.applyCursorSubsessionModeToKnownSessions()) + XCTAssertNotNil(appState.sessions[childId]) + XCTAssertNil(appState.sessions[parentId]) + } + + func testHideModeRemovesCursorSubagentCard() { + let previousMode = UserDefaults.standard.object(forKey: SettingsKey.pluginSessionMode) + UserDefaults.standard.set("hide", forKey: SettingsKey.pluginSessionMode) + defer { + if let previousMode { + UserDefaults.standard.set(previousMode, forKey: SettingsKey.pluginSessionMode) + } else { + UserDefaults.standard.removeObject(forKey: SettingsKey.pluginSessionMode) + } + } + + let parentId = "e1247fd5-d9a0-48ef-8457-0304606b1833" + let childId = "2528cb91-6379-48f2-aff8-40f4b804dafa" + let transcriptPath = "/Users/u/.cursor/projects/x/agent-transcripts/\(parentId)/\(parentId).jsonl" + + let appState = AppState() + var child = SessionSnapshot() + child.source = "cursor" + child.transcriptPath = transcriptPath + appState.sessions[childId] = child + + XCTAssertTrue(appState.applyCursorSubsessionModeToKnownSessions()) + XCTAssertNil(appState.sessions[childId]) + XCTAssertNil(appState.sessions[parentId]) + } + + func testHideModeClearsMergedCursorSubagentsOnParent() { + let previousMode = UserDefaults.standard.object(forKey: SettingsKey.pluginSessionMode) + UserDefaults.standard.set("hide", forKey: SettingsKey.pluginSessionMode) + defer { + if let previousMode { + UserDefaults.standard.set(previousMode, forKey: SettingsKey.pluginSessionMode) + } else { + UserDefaults.standard.removeObject(forKey: SettingsKey.pluginSessionMode) + } + } + + let parentId = "e1247fd5-d9a0-48ef-8457-0304606b1833" + let childId = "2528cb91-6379-48f2-aff8-40f4b804dafa" + + let appState = AppState() + var parent = SessionSnapshot() + parent.source = "cursor" + parent.status = .running + parent.currentTool = "Agent" + parent.toolDescription = "cursor-subagent" + parent.providerSessionId = parentId + parent.subagents[childId] = SubagentState(agentId: childId, agentType: "cursor-subagent") + appState.sessions[parentId] = parent + + appState.applyCurrentPluginSessionMode(persist: false) + + XCTAssertTrue(appState.sessions[parentId]?.subagents.isEmpty == true) + XCTAssertNil(appState.sessions[parentId]?.currentTool) + XCTAssertNil(appState.sessions[parentId]?.toolDescription) + XCTAssertEqual(appState.sessions[parentId]?.status, .processing) + } + + func testFoldDoesNotResurrectIdleStoppedCursorSubagent() { + let previousMode = UserDefaults.standard.object(forKey: SettingsKey.pluginSessionMode) + UserDefaults.standard.set("merge", forKey: SettingsKey.pluginSessionMode) + defer { + if let previousMode { + UserDefaults.standard.set(previousMode, forKey: SettingsKey.pluginSessionMode) + } else { + UserDefaults.standard.removeObject(forKey: SettingsKey.pluginSessionMode) + } + } + + let parentId = "e1247fd5-d9a0-48ef-8457-0304606b1833" + let childId = "2528cb91-6379-48f2-aff8-40f4b804dafa" + let transcriptPath = "/Users/u/.cursor/projects/x/agent-transcripts/\(parentId)/\(parentId).jsonl" + + let appState = AppState() + var parent = SessionSnapshot() + parent.source = "cursor" + parent.status = .processing + parent.providerSessionId = parentId + parent.transcriptPath = transcriptPath + // Stop already removed this child from the parent. + appState.sessions[parentId] = parent + + var staleChild = SessionSnapshot() + staleChild.source = "cursor" + staleChild.status = .idle + staleChild.transcriptPath = transcriptPath + appState.sessions[childId] = staleChild + + XCTAssertTrue(appState.applyCursorSubsessionModeToKnownSessions()) + XCTAssertNil(appState.sessions[childId]) + XCTAssertTrue(appState.sessions[parentId]?.subagents.isEmpty == true) + XCTAssertEqual(appState.sessions[parentId]?.status, .processing) + } + + func testFoldDoesNotResurrectRunningChildAfterStopTombstone() { + let previousMode = UserDefaults.standard.object(forKey: SettingsKey.pluginSessionMode) + UserDefaults.standard.set("merge", forKey: SettingsKey.pluginSessionMode) + defer { + if let previousMode { + UserDefaults.standard.set(previousMode, forKey: SettingsKey.pluginSessionMode) + } else { + UserDefaults.standard.removeObject(forKey: SettingsKey.pluginSessionMode) + } + } + + let parentId = "e1247fd5-d9a0-48ef-8457-0304606b1833" + let childId = "2528cb91-6379-48f2-aff8-40f4b804dafa" + let transcriptPath = "/Users/u/.cursor/projects/x/agent-transcripts/\(parentId)/\(parentId).jsonl" + + let appState = AppState() + var parent = SessionSnapshot() + parent.source = "cursor" + parent.status = .processing + parent.providerSessionId = parentId + parent.transcriptPath = transcriptPath + parent.closedSubagentIds = [childId] + appState.sessions[parentId] = parent + + var staleChild = SessionSnapshot() + staleChild.source = "cursor" + staleChild.status = .running + staleChild.currentTool = "Shell" + staleChild.transcriptPath = transcriptPath + appState.sessions[childId] = staleChild + + XCTAssertTrue(appState.applyCursorSubsessionModeToKnownSessions()) + XCTAssertNil(appState.sessions[childId]) + XCTAssertTrue(appState.sessions[parentId]?.subagents.isEmpty == true) + XCTAssertEqual(appState.sessions[parentId]?.closedSubagentIds, [childId]) + XCTAssertEqual(appState.sessions[parentId]?.status, .processing) + } + + func testFoldHonorsClosedTombstoneOnChildCardWhenSwitchingToMerge() { + let previousMode = UserDefaults.standard.object(forKey: SettingsKey.pluginSessionMode) + UserDefaults.standard.set("merge", forKey: SettingsKey.pluginSessionMode) + defer { + if let previousMode { + UserDefaults.standard.set(previousMode, forKey: SettingsKey.pluginSessionMode) + } else { + UserDefaults.standard.removeObject(forKey: SettingsKey.pluginSessionMode) + } + } + + let parentId = "e1247fd5-d9a0-48ef-8457-0304606b1833" + let childId = "2528cb91-6379-48f2-aff8-40f4b804dafa" + let transcriptPath = "/Users/u/.cursor/projects/x/agent-transcripts/\(parentId)/\(parentId).jsonl" + + let appState = AppState() + var parent = SessionSnapshot() + parent.source = "cursor" + parent.status = .running + parent.providerSessionId = parentId + parent.transcriptPath = transcriptPath + appState.sessions[parentId] = parent + + // Separate-mode Stop stored closedSubagentIds on the child card. + var child = SessionSnapshot() + child.source = "cursor" + child.status = .running + child.currentTool = "Shell" + child.transcriptPath = transcriptPath + child.closedSubagentIds = [childId] + appState.sessions[childId] = child + + XCTAssertTrue(appState.applyCursorSubsessionModeToKnownSessions()) + XCTAssertNil(appState.sessions[childId]) + XCTAssertTrue(appState.sessions[parentId]?.subagents.isEmpty == true) + XCTAssertTrue(appState.sessions[parentId]?.closedSubagentIds.contains(childId) == true) + } + + func testFoldFallbackUsesCardKeyWhenProviderSessionIdIsParent() { + let previousMode = UserDefaults.standard.object(forKey: SettingsKey.pluginSessionMode) + UserDefaults.standard.set("merge", forKey: SettingsKey.pluginSessionMode) + defer { + if let previousMode { + UserDefaults.standard.set(previousMode, forKey: SettingsKey.pluginSessionMode) + } else { + UserDefaults.standard.removeObject(forKey: SettingsKey.pluginSessionMode) + } + } + + let parentId = "e1247fd5-d9a0-48ef-8457-0304606b1833" + let childId = "2528cb91-6379-48f2-aff8-40f4b804dafa" + let transcriptPath = "/Users/u/.cursor/projects/x/agent-transcripts/\(parentId)/\(parentId).jsonl" + + let appState = AppState() + var child = SessionSnapshot() + child.source = "cursor" + child.status = .running + child.currentTool = "Shell" + child.providerSessionId = parentId + child.transcriptPath = transcriptPath + child.lastActivity = Date() + appState.sessions[childId] = child + + XCTAssertTrue(appState.applyCursorSubsessionModeToKnownSessions()) + XCTAssertNil(appState.sessions[childId]) + XCTAssertNotNil(appState.sessions[parentId]) + XCTAssertEqual(appState.sessions[parentId]?.providerSessionId, parentId) + XCTAssertEqual(appState.sessions[parentId]?.transcriptPath, transcriptPath) + // Subagent key must be the child card id, not providerSessionId (parent). + XCTAssertNil(appState.sessions[parentId]?.subagents[parentId]) + XCTAssertEqual(appState.sessions[parentId]?.subagents[childId]?.currentTool, "Shell") + } + + func testSynthesizedParentKeepsTranscriptPathAfterChildFold() { + let previousMode = UserDefaults.standard.object(forKey: SettingsKey.pluginSessionMode) + UserDefaults.standard.set("merge", forKey: SettingsKey.pluginSessionMode) + defer { + if let previousMode { + UserDefaults.standard.set(previousMode, forKey: SettingsKey.pluginSessionMode) + } else { + UserDefaults.standard.removeObject(forKey: SettingsKey.pluginSessionMode) + } + } + + let parentId = "e1247fd5-d9a0-48ef-8457-0304606b1833" + let childId = "2528cb91-6379-48f2-aff8-40f4b804dafa" + let transcriptPath = "/Users/u/.cursor/projects/x/agent-transcripts/\(parentId)/\(parentId).jsonl" + + let appState = AppState() + var child = SessionSnapshot() + child.source = "cursor" + child.status = .running + child.transcriptPath = transcriptPath + child.cwd = "/tmp/project" + child.cliPid = 4242 + child.cliStartTime = Date(timeIntervalSince1970: 1_700_000_000) + child.lastActivity = Date() + appState.sessions[childId] = child + + XCTAssertTrue(appState.applyCursorSubsessionModeToKnownSessions()) + XCTAssertNil(appState.sessions[childId]) + XCTAssertEqual(appState.sessions[parentId]?.transcriptPath, transcriptPath) + XCTAssertEqual(appState.sessions[parentId]?.cwd, "/tmp/project") + XCTAssertNil(appState.sessions[parentId]?.cliPid) + XCTAssertNil(appState.sessions[parentId]?.cliStartTime) + XCTAssertEqual(appState.sessions[parentId]?.subagents[childId]?.agentType, "cursor-subagent") + XCTAssertEqual(appState.sessions[parentId]?.subagents[childId]?.status, .running) + } + + func testMergeDropsIdleChildAndPromotesTombstoneOntoParent() { + let previousMode = UserDefaults.standard.object(forKey: SettingsKey.pluginSessionMode) + UserDefaults.standard.set("merge", forKey: SettingsKey.pluginSessionMode) + defer { + if let previousMode { + UserDefaults.standard.set(previousMode, forKey: SettingsKey.pluginSessionMode) + } else { + UserDefaults.standard.removeObject(forKey: SettingsKey.pluginSessionMode) + } + } + + let parentId = "e1247fd5-d9a0-48ef-8457-0304606b1833" + let childId = "2528cb91-6379-48f2-aff8-40f4b804dafa" + let transcriptPath = "/Users/u/.cursor/projects/x/agent-transcripts/\(parentId)/\(parentId).jsonl" + + let appState = AppState() + var child = SessionSnapshot() + child.source = "cursor" + child.status = .idle + child.transcriptPath = transcriptPath + appState.sessions[childId] = child + + XCTAssertTrue(appState.applyCursorSubsessionModeToKnownSessions()) + XCTAssertNil(appState.sessions[childId]) + XCTAssertNotNil(appState.sessions[parentId]) + XCTAssertTrue(appState.sessions[parentId]?.subagents.isEmpty == true) + XCTAssertEqual(appState.sessions[parentId]?.closedSubagentIds, [childId]) + XCTAssertEqual(appState.sessions[parentId]?.status, .idle) + XCTAssertEqual(appState.sessions[parentId]?.transcriptPath, transcriptPath) + } + + func testStoppedChildWithoutParentKeepsClosedIdsOnSynthesizedParent() { + let previousMode = UserDefaults.standard.object(forKey: SettingsKey.pluginSessionMode) + UserDefaults.standard.set("merge", forKey: SettingsKey.pluginSessionMode) + defer { + if let previousMode { + UserDefaults.standard.set(previousMode, forKey: SettingsKey.pluginSessionMode) + } else { + UserDefaults.standard.removeObject(forKey: SettingsKey.pluginSessionMode) + } + } + + let parentId = "e1247fd5-d9a0-48ef-8457-0304606b1833" + let childId = "2528cb91-6379-48f2-aff8-40f4b804dafa" + let transcriptPath = "/Users/u/.cursor/projects/x/agent-transcripts/\(parentId)/\(parentId).jsonl" + + let appState = AppState() + var child = SessionSnapshot() + child.source = "cursor" + child.status = .running + child.transcriptPath = transcriptPath + child.closedSubagentIds = [childId] + appState.sessions[childId] = child + + XCTAssertTrue(appState.applyCursorSubsessionModeToKnownSessions()) + XCTAssertNil(appState.sessions[childId]) + XCTAssertNotNil(appState.sessions[parentId]) + XCTAssertTrue(appState.sessions[parentId]?.subagents.isEmpty == true) + XCTAssertEqual(appState.sessions[parentId]?.closedSubagentIds, [childId]) + XCTAssertEqual(appState.sessions[parentId]?.transcriptPath, transcriptPath) + XCTAssertNil(appState.sessions[parentId]?.cliPid) + XCTAssertEqual(appState.sessions[parentId]?.status, .idle) + } + + func testMergeDropsIdleChildEvenWhenProcessIsStillLive() { + let previousMode = UserDefaults.standard.object(forKey: SettingsKey.pluginSessionMode) + UserDefaults.standard.set("merge", forKey: SettingsKey.pluginSessionMode) + defer { + if let previousMode { + UserDefaults.standard.set(previousMode, forKey: SettingsKey.pluginSessionMode) + } else { + UserDefaults.standard.removeObject(forKey: SettingsKey.pluginSessionMode) + } + } + + let parentId = "e1247fd5-d9a0-48ef-8457-0304606b1833" + let childId = "2528cb91-6379-48f2-aff8-40f4b804dafa" + let transcriptPath = "/Users/u/.cursor/projects/x/agent-transcripts/\(parentId)/\(parentId).jsonl" + + let appState = AppState() + var parent = SessionSnapshot() + parent.source = "cursor" + parent.status = .running + parent.providerSessionId = parentId + parent.transcriptPath = transcriptPath + appState.sessions[parentId] = parent + + var child = SessionSnapshot() + child.source = "cursor" + child.status = .idle + child.transcriptPath = transcriptPath + // Shared Cursor IDE `_ppid` is still live — must not revive a finished idle Task. + child.cliPid = getpid() + appState.sessions[childId] = child + + XCTAssertTrue(appState.applyCursorSubsessionModeToKnownSessions()) + XCTAssertNil(appState.sessions[childId]) + XCTAssertTrue(appState.sessions[parentId]?.subagents.isEmpty == true) + XCTAssertEqual(appState.sessions[parentId]?.closedSubagentIds, [childId]) + XCTAssertEqual(appState.sessions[parentId]?.status, .running) + } + + func testMergeDoesNotResurrectIdleLiveChildAfterSeparateStop() { + let previousMode = UserDefaults.standard.object(forKey: SettingsKey.pluginSessionMode) + UserDefaults.standard.set("merge", forKey: SettingsKey.pluginSessionMode) + defer { + if let previousMode { + UserDefaults.standard.set(previousMode, forKey: SettingsKey.pluginSessionMode) + } else { + UserDefaults.standard.removeObject(forKey: SettingsKey.pluginSessionMode) + } + } + + let parentId = "e1247fd5-d9a0-48ef-8457-0304606b1833" + let childId = "2528cb91-6379-48f2-aff8-40f4b804dafa" + let transcriptPath = "/Users/u/.cursor/projects/x/agent-transcripts/\(parentId)/\(parentId).jsonl" + + let appState = AppState() + var parent = SessionSnapshot() + parent.source = "cursor" + parent.status = .running + parent.providerSessionId = parentId + parent.transcriptPath = transcriptPath + appState.sessions[parentId] = parent + + var child = SessionSnapshot() + child.source = "cursor" + child.status = .idle + child.transcriptPath = transcriptPath + // Separate-mode Stop self-tombstone + live IDE `_ppid` must not revive as running. + child.closedSubagentIds = [childId] + child.cliPid = getpid() + appState.sessions[childId] = child + + XCTAssertTrue(appState.applyCursorSubsessionModeToKnownSessions()) + XCTAssertNil(appState.sessions[childId]) + XCTAssertTrue(appState.sessions[parentId]?.subagents.isEmpty == true) + XCTAssertEqual(appState.sessions[parentId]?.closedSubagentIds, [childId]) + } + + func testShouldKeepRestoredIdleCursorSessionWithTombstone() { + let childId = "2528cb91-6379-48f2-aff8-40f4b804dafa" + XCTAssertTrue( + AppState.shouldKeepRestoredIdleCursorSession( + source: "cursor", + sessionId: childId, + providerSessionId: nil, + transcriptPath: nil, + closedSubagentIds: [childId] + ) + ) + XCTAssertFalse( + AppState.shouldKeepRestoredIdleCursorSession( + source: "cursor", + sessionId: childId, + providerSessionId: nil, + transcriptPath: nil, + closedSubagentIds: [] + ) + ) + XCTAssertFalse( + AppState.shouldKeepRestoredIdleCursorSession( + source: "claude", + sessionId: childId, + providerSessionId: nil, + transcriptPath: nil, + closedSubagentIds: [childId] + ) + ) + } + + func testShouldKeepRestoredIdleCursorSessionWithFoldableTranscript() { + let parentId = "e1247fd5-d9a0-48ef-8457-0304606b1833" + let childId = "2528cb91-6379-48f2-aff8-40f4b804dafa" + let transcriptPath = "/Users/u/.cursor/projects/x/agent-transcripts/\(parentId)/\(parentId).jsonl" + // Foldable path alone is not enough — requires a Stop tombstone. + XCTAssertFalse( + AppState.shouldKeepRestoredIdleCursorSession( + source: "cursor", + sessionId: childId, + providerSessionId: nil, + transcriptPath: transcriptPath, + closedSubagentIds: [] + ) + ) + XCTAssertTrue( + AppState.shouldKeepRestoredIdleCursorSession( + source: "cursor", + sessionId: childId, + providerSessionId: nil, + transcriptPath: transcriptPath, + closedSubagentIds: [childId] + ) + ) + } + + func testClosedSubagentPermissionRequestIsDeniedWithoutQueueing() async throws { + let parentId = "e1247fd5-d9a0-48ef-8457-0304606b1833" + let childId = "2528cb91-6379-48f2-aff8-40f4b804dafa" + let appState = AppState() + var parent = SessionSnapshot() + parent.source = "cursor" + parent.status = .processing + parent.closedSubagentIds = [childId] + appState.sessions[parentId] = parent + + let data = try JSONSerialization.data(withJSONObject: [ + "hook_event_name": "PermissionRequest", + "session_id": parentId, + "_source": "cursor", + "agent_id": childId, + "tool_name": "Bash", + ] as [String: Any]) + let event = try XCTUnwrap(HookEvent(from: data)) + + let response = await withCheckedContinuation { continuation in + appState.handlePermissionRequest(event, continuation: continuation) + } + + XCTAssertTrue(appState.permissionQueue.isEmpty) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: response) as? [String: Any]) + let hook = try XCTUnwrap(json["hookSpecificOutput"] as? [String: Any]) + let decision = try XCTUnwrap(hook["decision"] as? [String: Any]) + XCTAssertEqual(decision["behavior"] as? String, "deny") + XCTAssertEqual(appState.sessions[parentId]?.status, .processing) + } + + func testClosedSubagentQuestionIsDroppedWithoutQueueing() async throws { + let parentId = "e1247fd5-d9a0-48ef-8457-0304606b1833" + let childId = "2528cb91-6379-48f2-aff8-40f4b804dafa" + let appState = AppState() + var parent = SessionSnapshot() + parent.source = "cursor" + parent.status = .processing + parent.closedSubagentIds = [childId] + appState.sessions[parentId] = parent + + let data = try JSONSerialization.data(withJSONObject: [ + "hook_event_name": "Notification", + "session_id": parentId, + "_source": "cursor", + "agent_id": childId, + "question": "Still asking after stop?", + "options": ["Yes", "No"], + ] as [String: Any]) + let event = try XCTUnwrap(HookEvent(from: data)) + + let response = await withCheckedContinuation { continuation in + appState.handleQuestion(event, continuation: continuation) + } + + XCTAssertTrue(appState.questionQueue.isEmpty) + XCTAssertEqual(response, Data("{}".utf8)) + XCTAssertEqual(appState.sessions[parentId]?.status, .processing) + } + + func testClosedSubagentAskUserQuestionIsDeniedWithoutQueueing() async throws { + let parentId = "e1247fd5-d9a0-48ef-8457-0304606b1833" + let childId = "2528cb91-6379-48f2-aff8-40f4b804dafa" + let appState = AppState() + var parent = SessionSnapshot() + parent.source = "cursor" + parent.status = .processing + parent.closedSubagentIds = [childId] + appState.sessions[parentId] = parent + + let data = try JSONSerialization.data(withJSONObject: [ + "hook_event_name": "PermissionRequest", + "session_id": parentId, + "_source": "cursor", + "agent_id": childId, + "tool_name": "AskUserQuestion", + "tool_input": [ + "questions": [ + ["question": "Continue?", "options": [["label": "Yes"], ["label": "No"]]] + ] + ], + ] as [String: Any]) + let event = try XCTUnwrap(HookEvent(from: data)) + + let response = await withCheckedContinuation { continuation in + appState.handleAskUserQuestion(event, continuation: continuation) + } + + XCTAssertTrue(appState.questionQueue.isEmpty) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: response) as? [String: Any]) + let hook = try XCTUnwrap(json["hookSpecificOutput"] as? [String: Any]) + let decision = try XCTUnwrap(hook["decision"] as? [String: Any]) + XCTAssertEqual(decision["behavior"] as? String, "deny") + XCTAssertEqual(appState.sessions[parentId]?.status, .processing) + } + + func testMergedPermissionMarksSubagentWaitingApproval() async throws { + let parentId = "e1247fd5-d9a0-48ef-8457-0304606b1833" + let childId = "2528cb91-6379-48f2-aff8-40f4b804dafa" + let appState = AppState() + var parent = SessionSnapshot() + parent.source = "cursor" + parent.status = .running + appState.sessions[parentId] = parent + + let data = try JSONSerialization.data(withJSONObject: [ + "hook_event_name": "PermissionRequest", + "session_id": parentId, + "_source": "cursor", + "agent_id": childId, + "tool_name": "Bash", + ] as [String: Any]) + let event = try XCTUnwrap(HookEvent(from: data)) + + let response = await withCheckedContinuation { continuation in + appState.handlePermissionRequest(event, continuation: continuation) + XCTAssertEqual(appState.sessions[parentId]?.status, .waitingApproval) + XCTAssertEqual(appState.sessions[parentId]?.subagents[childId]?.status, .waitingApproval) + XCTAssertEqual(appState.permissionQueue.count, 1) + appState.denyPermission() + } + + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: response) as? [String: Any]) + let hook = try XCTUnwrap(json["hookSpecificOutput"] as? [String: Any]) + let decision = try XCTUnwrap(hook["decision"] as? [String: Any]) + XCTAssertEqual(decision["behavior"] as? String, "deny") + XCTAssertTrue(appState.permissionQueue.isEmpty) + } +} diff --git a/Tests/CodeIslandTests/SessionPersistenceTests.swift b/Tests/CodeIslandTests/SessionPersistenceTests.swift index b75402eb..d2817970 100644 --- a/Tests/CodeIslandTests/SessionPersistenceTests.swift +++ b/Tests/CodeIslandTests/SessionPersistenceTests.swift @@ -66,7 +66,9 @@ final class SessionPersistenceTests: XCTestCase { cliPid: 456, cliStartTime: cliStartTime, startTime: startTime, - lastActivity: startTime.addingTimeInterval(30) + lastActivity: startTime.addingTimeInterval(30), + transcriptPath: nil, + closedSubagentIds: nil ) let encoder = JSONEncoder() @@ -80,4 +82,152 @@ final class SessionPersistenceTests: XCTestCase { XCTAssertEqual(decoded.cliPid, 456) XCTAssertEqual(decoded.cliStartTime, cliStartTime) } + + func testPersistedSessionRoundTripPreservesClosedSubagentIds() throws { + let startTime = ISO8601DateFormatter().date(from: "2026-04-09T10:00:00Z")! + let session = PersistedSession( + sessionId: "session-3", + cwd: "/tmp/demo", + source: "cursor", + model: nil, + sessionTitle: nil, + sessionTitleSource: nil, + providerSessionId: "parent-1", + lastUserPrompt: nil, + lastAssistantMessage: nil, + termApp: nil, + itermSessionId: nil, + ttyPath: nil, + kittyWindowId: nil, + tmuxPane: nil, + tmuxClientTty: nil, + tmuxEnv: nil, + termBundleId: nil, + cmuxSurfaceId: nil, + cmuxWorkspaceId: nil, + zellijPaneId: nil, + zellijSessionName: nil, + weztermPaneId: nil, + cliPid: nil, + cliStartTime: nil, + startTime: startTime, + lastActivity: startTime, + transcriptPath: nil, + closedSubagentIds: ["2528cb91-6379-48f2-aff8-40f4b804dafa"] + ) + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let decoded = try decoder.decode(PersistedSession.self, from: try encoder.encode(session)) + + XCTAssertEqual(decoded.closedSubagentIds, ["2528cb91-6379-48f2-aff8-40f4b804dafa"]) + } + + func testPersistedSessionDecodesWithoutClosedSubagentIdsForBackwardCompatibility() throws { + let json = """ + { + "sessionId": "session-4", + "cwd": "/tmp/demo", + "source": "cursor", + "model": null, + "sessionTitle": null, + "sessionTitleSource": null, + "providerSessionId": null, + "lastUserPrompt": "hi", + "lastAssistantMessage": null, + "termApp": null, + "itermSessionId": null, + "ttyPath": null, + "kittyWindowId": null, + "tmuxPane": null, + "tmuxClientTty": null, + "tmuxEnv": null, + "termBundleId": null, + "cliPid": null, + "startTime": "2026-04-09T10:00:00Z", + "lastActivity": "2026-04-09T10:01:00Z" + } + """ + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let session = try decoder.decode(PersistedSession.self, from: Data(json.utf8)) + XCTAssertNil(session.closedSubagentIds) + } + + func testPersistedSessionRoundTripPreservesTranscriptPath() throws { + let startTime = ISO8601DateFormatter().date(from: "2026-04-09T10:00:00Z")! + let path = "/Users/u/.cursor/projects/x/agent-transcripts/e1247fd5-d9a0-48ef-8457-0304606b1833/e1247fd5-d9a0-48ef-8457-0304606b1833.jsonl" + let session = PersistedSession( + sessionId: "session-5", + cwd: "/tmp/demo", + source: "cursor", + model: nil, + sessionTitle: nil, + sessionTitleSource: nil, + providerSessionId: "e1247fd5-d9a0-48ef-8457-0304606b1833", + lastUserPrompt: nil, + lastAssistantMessage: nil, + termApp: nil, + itermSessionId: nil, + ttyPath: nil, + kittyWindowId: nil, + tmuxPane: nil, + tmuxClientTty: nil, + tmuxEnv: nil, + termBundleId: nil, + cmuxSurfaceId: nil, + cmuxWorkspaceId: nil, + zellijPaneId: nil, + zellijSessionName: nil, + weztermPaneId: nil, + cliPid: nil, + cliStartTime: nil, + startTime: startTime, + lastActivity: startTime, + transcriptPath: path, + closedSubagentIds: nil + ) + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let decoded = try decoder.decode(PersistedSession.self, from: try encoder.encode(session)) + XCTAssertEqual(decoded.transcriptPath, path) + } + + func testPersistedSessionDecodesWithoutTranscriptPathForBackwardCompatibility() throws { + let json = """ + { + "sessionId": "session-6", + "cwd": "/tmp/demo", + "source": "cursor", + "model": null, + "sessionTitle": null, + "sessionTitleSource": null, + "providerSessionId": null, + "lastUserPrompt": "hi", + "lastAssistantMessage": null, + "termApp": null, + "itermSessionId": null, + "ttyPath": null, + "kittyWindowId": null, + "tmuxPane": null, + "tmuxClientTty": null, + "tmuxEnv": null, + "termBundleId": null, + "cliPid": null, + "startTime": "2026-04-09T10:00:00Z", + "lastActivity": "2026-04-09T10:01:00Z" + } + """ + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let session = try decoder.decode(PersistedSession.self, from: Data(json.utf8)) + XCTAssertNil(session.transcriptPath) + } } From 23e5e824f08483bbb9ae32c18a0c29852a645095 Mon Sep 17 00:00:00 2001 From: Zephyr Date: Tue, 14 Jul 2026 14:33:25 +0800 Subject: [PATCH 2/3] fix(sessions): harden Cursor Task fold permission and Stop edge cases Keep folded deny/answer from idling the parent, drop idle orphans without overwriting live Tasks, and only self-tombstone foldable separate Task cards. --- Sources/CodeIsland/AppState.swift | 163 +++++++++----- Sources/CodeIslandCore/SessionSnapshot.swift | 10 +- .../CursorSessionFoldingTests.swift | 28 +++ .../AppStateCursorSubsessionTests.swift | 204 ++++++++++++++++-- 4 files changed, 343 insertions(+), 62 deletions(-) diff --git a/Sources/CodeIsland/AppState.swift b/Sources/CodeIsland/AppState.swift index cb612f8c..2448d2bd 100644 --- a/Sources/CodeIsland/AppState.swift +++ b/Sources/CodeIsland/AppState.swift @@ -1234,8 +1234,7 @@ final class AppState { // Closed Task/subagent ids must not surface new permission UI (parity with // ensureSubagent refusing late tool hooks after Stop). - if let agentId = event.agentId, - sessions[sessionId]?.closedSubagentIds.contains(agentId) == true { + if shouldSuppressClosedSubagentUI(sessionId: sessionId, agentId: event.agentId) { let denyResponse = Data( #"{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny"}}}"#.utf8 ) @@ -1323,7 +1322,13 @@ final class AppState { responseData = Data(response.utf8) } pending.continuation.resume(returning: responseData) - sessions[sessionId]?.status = .running + resolveMergedSubagentAfterUI( + sessionId: sessionId, + agentId: pending.event.agentId, + subagentStatus: .running, + keepSubagentTool: pending.event.toolName, + idleParentWhenNoAgent: false + ) showNextPending() refreshDerivedState() @@ -1440,9 +1445,14 @@ final class AppState { dismissedPermissionSessionIds.remove(sessionId) let response = #"{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny"}}}"# pending.continuation.resume(returning: Data(response.utf8)) - sessions[sessionId]?.status = .idle - sessions[sessionId]?.currentTool = nil - sessions[sessionId]?.toolDescription = nil + // Folded Task deny must not idle the whole parent chat card. + resolveMergedSubagentAfterUI( + sessionId: sessionId, + agentId: pending.event.agentId, + subagentStatus: .processing, + keepSubagentTool: nil, + idleParentWhenNoAgent: true + ) if activeSessionId == sessionId { activeSessionId = mostActiveSessionId() @@ -1482,8 +1492,7 @@ final class AppState { } tryMonitorSession(sessionId) - if let agentId = event.agentId, - sessions[sessionId]?.closedSubagentIds.contains(agentId) == true { + if shouldSuppressClosedSubagentUI(sessionId: sessionId, agentId: event.agentId) { continuation.resume(returning: Data("{}".utf8)) return } @@ -1525,8 +1534,7 @@ final class AppState { } tryMonitorSession(sessionId) - if let agentId = event.agentId, - sessions[sessionId]?.closedSubagentIds.contains(agentId) == true { + if shouldSuppressClosedSubagentUI(sessionId: sessionId, agentId: event.agentId) { let denyResponse = Data( #"{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny"}}}"#.utf8 ) @@ -1690,7 +1698,13 @@ final class AppState { } pending.resolution.resumeHook(returning: responseData) let sessionId = pending.event.sessionId ?? "default" - sessions[sessionId]?.status = .processing + resolveMergedSubagentAfterUI( + sessionId: sessionId, + agentId: pending.event.agentId, + subagentStatus: .running, + keepSubagentTool: nil, + idleParentWhenNoAgent: false + ) showNextPending() refreshDerivedState() @@ -1760,7 +1774,13 @@ final class AppState { } pending.resolution.resumeHook(returning: responseData) let sessionId = pending.event.sessionId ?? "default" - sessions[sessionId]?.status = .processing + resolveMergedSubagentAfterUI( + sessionId: sessionId, + agentId: pending.event.agentId, + subagentStatus: .running, + keepSubagentTool: nil, + idleParentWhenNoAgent: false + ) showNextPending() refreshDerivedState() @@ -1803,7 +1823,13 @@ final class AppState { pending.resolution.resumeHook(returning: responseData) } let sessionId = pending.event.sessionId ?? "default" - sessions[sessionId]?.status = .processing + resolveMergedSubagentAfterUI( + sessionId: sessionId, + agentId: pending.event.agentId, + subagentStatus: .processing, + keepSubagentTool: nil, + idleParentWhenNoAgent: false + ) showNextPending() refreshDerivedState() @@ -2718,12 +2744,14 @@ final class AppState { if parentKey == candidate.sessionId { continue } // Closed ids may sit on the parent (merge Stop) or the child card - // (separate Stop). Keep them on the parent even if we must synthesize it. + // (separate Stop). Parent tombstone always wins over a late-running + // orphan card — relaunch clears via UserPromptSubmit on the hook path. let childClosed = candidate.session.closedSubagentIds - let childWasStopped = sessions[parentKey]?.closedSubagentIds.contains(childId) == true - || childClosed.contains(childId) + let candidateCarriesClosed = childClosed.contains(childId) || childClosed.contains(candidate.sessionId) - if childWasStopped { + let parentHoldsTombstone = sessions[parentKey]?.closedSubagentIds.contains(childId) == true + + if candidateCarriesClosed || parentHoldsTombstone { if sessions[parentKey] == nil { var parent = SessionSnapshot(startTime: candidate.session.startTime) parent.source = source @@ -2751,30 +2779,10 @@ final class AppState { continue } - // Idle orphan: drop the card (do not fold as running just because a shared - // Cursor IDE `_ppid` is still alive). Still promote a parent tombstone so - // late merged PreToolUse cannot revive after AfterAgentResponse→idle. - if candidate.session.status == .idle, - sessions[parentKey]?.subagents[childId] == nil { - if sessions[parentKey] == nil { - var parent = SessionSnapshot(startTime: candidate.session.startTime) - parent.source = source - parent.cwd = candidate.session.cwd - parent.model = candidate.session.model - parent.termApp = candidate.session.termApp - parent.termBundleId = candidate.session.termBundleId - parent.transcriptPath = candidate.session.transcriptPath - parent.providerSessionId = parentId - parent.status = .idle - parent.lastActivity = candidate.session.lastActivity - sessions[parentKey] = parent - } - promoteCursorClosedIds( - onto: parentKey, - childId: childId, - candidateSessionId: candidate.sessionId, - childClosed: childClosed - ) + // Idle orphan: always drop — never overwrite a live merged Task slot + // with an AfterAgentResponse→idle discovery card. Tombstones were + // already handled above; plain idle must not invent parents either. + if candidate.session.status == .idle { if sessions[candidate.sessionId] != nil { removeSession(candidate.sessionId) didMutate = true @@ -2866,14 +2874,73 @@ final class AppState { status: AgentStatus ) { guard let agentId else { return } - if sessions[sessionId]?.subagents[agentId] == nil { - sessions[sessionId]?.subagents[agentId] = SubagentState( - agentId: agentId, - agentType: "cursor-subagent" - ) + guard var session = sessions[sessionId] else { return } + var subagent = session.subagents[agentId] + ?? SubagentState(agentId: agentId, agentType: "cursor-subagent") + subagent.status = status + subagent.lastActivity = Date() + session.subagents[agentId] = subagent + sessions[sessionId] = session + } + + /// After Permission/Question UI resolves for a possibly folded Task. + /// With `agent_id`, never idle the parent — even if the subagent slot was + /// already removed (Stop race). Uses local copies to avoid exclusivity traps + /// when mutating nested `sessions[id].subagents[id]` fields. + private func resolveMergedSubagentAfterUI( + sessionId: String, + agentId: String?, + subagentStatus: AgentStatus, + keepSubagentTool: String?, + idleParentWhenNoAgent: Bool + ) { + if let agentId { + guard var session = sessions[sessionId] else { return } + if var subagent = session.subagents[agentId] { + subagent.status = subagentStatus + if subagentStatus == .processing { + subagent.currentTool = nil + subagent.toolDescription = nil + } else if let keepSubagentTool { + subagent.currentTool = keepSubagentTool + } + session.subagents[agentId] = subagent + } + let hasNonIdleSubagents = session.subagents.values.contains { $0.status != .idle } + if hasNonIdleSubagents { + let agentType = session.subagents[agentId]?.agentType ?? "cursor-subagent" + session.status = .running + session.currentTool = "Agent" + if session.toolDescription == nil { + session.toolDescription = agentType + } + } else { + session.status = .processing + session.currentTool = nil + session.toolDescription = nil + } + sessions[sessionId] = session + return + } + + if idleParentWhenNoAgent { + sessions[sessionId]?.status = .idle + sessions[sessionId]?.currentTool = nil + sessions[sessionId]?.toolDescription = nil + } else { + sessions[sessionId]?.status = .processing + sessions[sessionId]?.currentTool = nil + sessions[sessionId]?.toolDescription = nil } - sessions[sessionId]?.subagents[agentId]?.status = status - sessions[sessionId]?.subagents[agentId]?.lastActivity = Date() + } + + /// Suppress Permission/Question UI for Stop'd Tasks: merged `agent_id` tombstones + /// or separate-mode self-tombstones (`closedSubagentIds` contains the card id). + private func shouldSuppressClosedSubagentUI(sessionId: String, agentId: String?) -> Bool { + let closed = sessions[sessionId]?.closedSubagentIds ?? [] + if let agentId, closed.contains(agentId) { return true } + if agentId == nil, closed.contains(sessionId) { return true } + return false } /// Parent/child ids when this card's transcript belongs to another Cursor chat. diff --git a/Sources/CodeIslandCore/SessionSnapshot.swift b/Sources/CodeIslandCore/SessionSnapshot.swift index 61ba50ff..9469159a 100644 --- a/Sources/CodeIslandCore/SessionSnapshot.swift +++ b/Sources/CodeIslandCore/SessionSnapshot.swift @@ -959,10 +959,16 @@ public func reduceEvent( } == true // Separate-mode Cursor Tasks have no agent_id, so merge later must see // this id as closed — otherwise a still-live IDE `_ppid` resuscitates them. - // Skip when this is a parent chat that still has folded Tasks running. + // Only self-tombstone foldable Task cards (transcript parent ≠ session id), + // never the parent chat — that would suppress late Permission UI and wipe + // the IDE `_ppid` monitor after a normal turn Stop. if !hasActiveSubagents, let source = sessions[sessionId]?.source, - source == "cursor" || source == "cursor-cli" { + source == "cursor" || source == "cursor-cli", + CursorSessionFolding.foldTarget( + childSessionId: sessionId, + transcriptPath: sessions[sessionId]?.transcriptPath + ) != nil { sessions[sessionId]?.closedSubagentIds.insert(sessionId) // Drop process identity so a still-open IDE `_ppid` does not look like // a live Task after Stop when the card is later considered for merge. diff --git a/Tests/CodeIslandCoreTests/CursorSessionFoldingTests.swift b/Tests/CodeIslandCoreTests/CursorSessionFoldingTests.swift index 02824ee1..d8c2232e 100644 --- a/Tests/CodeIslandCoreTests/CursorSessionFoldingTests.swift +++ b/Tests/CodeIslandCoreTests/CursorSessionFoldingTests.swift @@ -216,10 +216,13 @@ final class CursorSessionFoldingTests: XCTestCase { } func testSeparateCursorStopSelfTombstonesSessionId() throws { + let parentId = "e1247fd5-d9a0-48ef-8457-0304606b1833" let childId = "2528cb91-6379-48f2-aff8-40f4b804dafa" + let transcriptPath = "/Users/u/.cursor/projects/x/agent-transcripts/\(parentId)/\(parentId).jsonl" var child = SessionSnapshot() child.source = "cursor" child.status = .running + child.transcriptPath = transcriptPath child.cliPid = 12_345 child.cliStartTime = Date(timeIntervalSince1970: 1_700_000_000) var sessions = [childId: child] @@ -238,6 +241,31 @@ final class CursorSessionFoldingTests: XCTestCase { XCTAssertNil(sessions[childId]?.cliStartTime) } + func testParentCursorStopDoesNotSelfTombstoneOrClearCliPid() throws { + let parentId = "e1247fd5-d9a0-48ef-8457-0304606b1833" + let transcriptPath = "/Users/u/.cursor/projects/x/agent-transcripts/\(parentId)/\(parentId).jsonl" + var parent = SessionSnapshot() + parent.source = "cursor" + parent.status = .running + parent.transcriptPath = transcriptPath + parent.cliPid = 98_765 + parent.cliStartTime = Date(timeIntervalSince1970: 1_700_000_000) + var sessions = [parentId: parent] + + let stopData = try JSONSerialization.data(withJSONObject: [ + "hook_event_name": "stop", + "session_id": parentId, + "_source": "cursor", + ] as [String: Any]) + let stopEvent = try XCTUnwrap(HookEvent(from: stopData)) + _ = reduceEvent(sessions: &sessions, event: stopEvent, maxHistory: 10) + + XCTAssertEqual(sessions[parentId]?.status, .idle) + XCTAssertTrue(sessions[parentId]?.closedSubagentIds.isEmpty == true) + XCTAssertEqual(sessions[parentId]?.cliPid, 98_765) + XCTAssertEqual(sessions[parentId]?.cliStartTime, Date(timeIntervalSince1970: 1_700_000_000)) + } + func testParentAfterAgentResponseKeepsActiveWhileFoldedTaskRunning() throws { let parentId = "e1247fd5-d9a0-48ef-8457-0304606b1833" let childId = "2528cb91-6379-48f2-aff8-40f4b804dafa" diff --git a/Tests/CodeIslandTests/AppStateCursorSubsessionTests.swift b/Tests/CodeIslandTests/AppStateCursorSubsessionTests.swift index 6053f0cf..da69b4e0 100644 --- a/Tests/CodeIslandTests/AppStateCursorSubsessionTests.swift +++ b/Tests/CodeIslandTests/AppStateCursorSubsessionTests.swift @@ -397,7 +397,7 @@ final class AppStateCursorSubsessionTests: XCTestCase { XCTAssertEqual(appState.sessions[parentId]?.subagents[childId]?.status, .running) } - func testMergeDropsIdleChildAndPromotesTombstoneOntoParent() { + func testMergeDropsIdleChildWithoutInventingTombstoneParent() { let previousMode = UserDefaults.standard.object(forKey: SettingsKey.pluginSessionMode) UserDefaults.standard.set("merge", forKey: SettingsKey.pluginSessionMode) defer { @@ -421,11 +421,8 @@ final class AppStateCursorSubsessionTests: XCTestCase { XCTAssertTrue(appState.applyCursorSubsessionModeToKnownSessions()) XCTAssertNil(appState.sessions[childId]) - XCTAssertNotNil(appState.sessions[parentId]) - XCTAssertTrue(appState.sessions[parentId]?.subagents.isEmpty == true) - XCTAssertEqual(appState.sessions[parentId]?.closedSubagentIds, [childId]) - XCTAssertEqual(appState.sessions[parentId]?.status, .idle) - XCTAssertEqual(appState.sessions[parentId]?.transcriptPath, transcriptPath) + // Plain idle (AfterAgentResponse) must not synthesize a ghost parent / tombstone. + XCTAssertNil(appState.sessions[parentId]) } func testStoppedChildWithoutParentKeepsClosedIdsOnSynthesizedParent() { @@ -495,10 +492,42 @@ final class AppStateCursorSubsessionTests: XCTestCase { XCTAssertTrue(appState.applyCursorSubsessionModeToKnownSessions()) XCTAssertNil(appState.sessions[childId]) XCTAssertTrue(appState.sessions[parentId]?.subagents.isEmpty == true) - XCTAssertEqual(appState.sessions[parentId]?.closedSubagentIds, [childId]) + XCTAssertTrue(appState.sessions[parentId]?.closedSubagentIds.isEmpty == true) XCTAssertEqual(appState.sessions[parentId]?.status, .running) } + func testMergeIdleChildWithOwnTombstonePromotesOntoParent() { + let previousMode = UserDefaults.standard.object(forKey: SettingsKey.pluginSessionMode) + UserDefaults.standard.set("merge", forKey: SettingsKey.pluginSessionMode) + defer { + if let previousMode { + UserDefaults.standard.set(previousMode, forKey: SettingsKey.pluginSessionMode) + } else { + UserDefaults.standard.removeObject(forKey: SettingsKey.pluginSessionMode) + } + } + + let parentId = "e1247fd5-d9a0-48ef-8457-0304606b1833" + let childId = "2528cb91-6379-48f2-aff8-40f4b804dafa" + let transcriptPath = "/Users/u/.cursor/projects/x/agent-transcripts/\(parentId)/\(parentId).jsonl" + + let appState = AppState() + var child = SessionSnapshot() + child.source = "cursor" + child.status = .idle + child.transcriptPath = transcriptPath + child.closedSubagentIds = [childId] + appState.sessions[childId] = child + + XCTAssertTrue(appState.applyCursorSubsessionModeToKnownSessions()) + XCTAssertNil(appState.sessions[childId]) + XCTAssertNotNil(appState.sessions[parentId]) + XCTAssertTrue(appState.sessions[parentId]?.subagents.isEmpty == true) + XCTAssertEqual(appState.sessions[parentId]?.closedSubagentIds, [childId]) + XCTAssertEqual(appState.sessions[parentId]?.status, .idle) + XCTAssertEqual(appState.sessions[parentId]?.transcriptPath, transcriptPath) + } + func testMergeDoesNotResurrectIdleLiveChildAfterSeparateStop() { let previousMode = UserDefaults.standard.object(forKey: SettingsKey.pluginSessionMode) UserDefaults.standard.set("merge", forKey: SettingsKey.pluginSessionMode) @@ -707,18 +736,169 @@ final class AppStateCursorSubsessionTests: XCTestCase { ] as [String: Any]) let event = try XCTUnwrap(HookEvent(from: data)) + let responseTask = Task { + await withCheckedContinuation { continuation in + appState.handlePermissionRequest(event, continuation: continuation) + } + } + + await Task.yield() + XCTAssertEqual(appState.permissionQueue.count, 1) + XCTAssertEqual(appState.sessions[parentId]?.status, .waitingApproval) + XCTAssertEqual(appState.sessions[parentId]?.subagents[childId]?.status, .waitingApproval) + + appState.handleBuddyControlCommand(.denyCurrentPermission) + let response = await responseTask.value + + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: response) as? [String: Any]) + let hook = try XCTUnwrap(json["hookSpecificOutput"] as? [String: Any]) + let decision = try XCTUnwrap(hook["decision"] as? [String: Any]) + XCTAssertEqual(decision["behavior"] as? String, "deny") + XCTAssertTrue(appState.permissionQueue.isEmpty) + // Denying a folded Task must not idle the parent chat. + XCTAssertEqual(appState.sessions[parentId]?.status, .running) + XCTAssertEqual(appState.sessions[parentId]?.subagents[childId]?.status, .processing) + XCTAssertEqual(appState.sessions[parentId]?.currentTool, "Agent") + } + + func testMergeDoesNotOverwriteLiveSubagentWithIdleOrphan() { + let previousMode = UserDefaults.standard.object(forKey: SettingsKey.pluginSessionMode) + UserDefaults.standard.set("merge", forKey: SettingsKey.pluginSessionMode) + defer { + if let previousMode { + UserDefaults.standard.set(previousMode, forKey: SettingsKey.pluginSessionMode) + } else { + UserDefaults.standard.removeObject(forKey: SettingsKey.pluginSessionMode) + } + } + + let parentId = "e1247fd5-d9a0-48ef-8457-0304606b1833" + let childId = "2528cb91-6379-48f2-aff8-40f4b804dafa" + let transcriptPath = "/Users/u/.cursor/projects/x/agent-transcripts/\(parentId)/\(parentId).jsonl" + + let appState = AppState() + var parent = SessionSnapshot() + parent.source = "cursor" + parent.status = .running + parent.providerSessionId = parentId + parent.transcriptPath = transcriptPath + var live = SubagentState(agentId: childId, agentType: "cursor-subagent") + live.status = .running + live.currentTool = "Read" + parent.subagents[childId] = live + appState.sessions[parentId] = parent + + var orphan = SessionSnapshot() + orphan.source = "cursor" + orphan.status = .idle + orphan.transcriptPath = transcriptPath + appState.sessions[childId] = orphan + + XCTAssertTrue(appState.applyCursorSubsessionModeToKnownSessions()) + XCTAssertNil(appState.sessions[childId]) + XCTAssertEqual(appState.sessions[parentId]?.subagents[childId]?.status, .running) + XCTAssertEqual(appState.sessions[parentId]?.subagents[childId]?.currentTool, "Read") + } + + func testDenyPermissionWithAgentIdButMissingSubagentDoesNotIdleParent() async throws { + let parentId = "e1247fd5-d9a0-48ef-8457-0304606b1833" + let childId = "2528cb91-6379-48f2-aff8-40f4b804dafa" + let appState = AppState() + var parent = SessionSnapshot() + parent.source = "cursor" + parent.status = .running + appState.sessions[parentId] = parent + + let data = try JSONSerialization.data(withJSONObject: [ + "hook_event_name": "PermissionRequest", + "session_id": parentId, + "_source": "cursor", + "agent_id": childId, + "tool_name": "Bash", + ] as [String: Any]) + let event = try XCTUnwrap(HookEvent(from: data)) + + let responseTask = Task { + await withCheckedContinuation { continuation in + appState.handlePermissionRequest(event, continuation: continuation) + } + } + await Task.yield() + XCTAssertEqual(appState.permissionQueue.count, 1) + + // Simulate Stop removing the Task between enqueue and deny. + appState.sessions[parentId]?.subagents.removeValue(forKey: childId) + appState.sessions[parentId]?.closedSubagentIds.insert(childId) + + appState.handleBuddyControlCommand(.denyCurrentPermission) + _ = await responseTask.value + + XCTAssertTrue(appState.permissionQueue.isEmpty) + XCTAssertNotEqual(appState.sessions[parentId]?.status, .idle) + XCTAssertEqual(appState.sessions[parentId]?.status, .processing) + } + + func testAnswerFoldedQuestionClearsSubagentWaitingQuestion() async throws { + let parentId = "e1247fd5-d9a0-48ef-8457-0304606b1833" + let childId = "2528cb91-6379-48f2-aff8-40f4b804dafa" + let appState = AppState() + var parent = SessionSnapshot() + parent.source = "cursor" + parent.status = .running + appState.sessions[parentId] = parent + + let data = try JSONSerialization.data(withJSONObject: [ + "hook_event_name": "Notification", + "session_id": parentId, + "_source": "cursor", + "agent_id": childId, + "question": "Ship it?", + "options": ["Yes", "No"], + ] as [String: Any]) + let event = try XCTUnwrap(HookEvent(from: data)) + + let responseTask = Task { + await withCheckedContinuation { continuation in + appState.handleQuestion(event, continuation: continuation) + } + } + await Task.yield() + XCTAssertEqual(appState.sessions[parentId]?.subagents[childId]?.status, .waitingQuestion) + + appState.answerQuestion("Yes") + _ = await responseTask.value + + XCTAssertTrue(appState.questionQueue.isEmpty) + XCTAssertEqual(appState.sessions[parentId]?.subagents[childId]?.status, .running) + XCTAssertEqual(appState.sessions[parentId]?.status, .running) + XCTAssertEqual(appState.sessions[parentId]?.currentTool, "Agent") + } + + func testSeparateSelfTombstoneDeniesPermissionWithoutAgentId() async throws { + let childId = "2528cb91-6379-48f2-aff8-40f4b804dafa" + let appState = AppState() + var child = SessionSnapshot() + child.source = "cursor" + child.status = .idle + child.closedSubagentIds = [childId] + appState.sessions[childId] = child + + let data = try JSONSerialization.data(withJSONObject: [ + "hook_event_name": "PermissionRequest", + "session_id": childId, + "_source": "cursor", + "tool_name": "Bash", + ] as [String: Any]) + let event = try XCTUnwrap(HookEvent(from: data)) + let response = await withCheckedContinuation { continuation in appState.handlePermissionRequest(event, continuation: continuation) - XCTAssertEqual(appState.sessions[parentId]?.status, .waitingApproval) - XCTAssertEqual(appState.sessions[parentId]?.subagents[childId]?.status, .waitingApproval) - XCTAssertEqual(appState.permissionQueue.count, 1) - appState.denyPermission() } + XCTAssertTrue(appState.permissionQueue.isEmpty) let json = try XCTUnwrap(JSONSerialization.jsonObject(with: response) as? [String: Any]) let hook = try XCTUnwrap(json["hookSpecificOutput"] as? [String: Any]) let decision = try XCTUnwrap(hook["decision"] as? [String: Any]) XCTAssertEqual(decision["behavior"] as? String, "deny") - XCTAssertTrue(appState.permissionQueue.isEmpty) } } From 02e303e9b16a5a8389341462d5d7d714acf800ca Mon Sep 17 00:00:00 2001 From: Zephyr Date: Tue, 14 Jul 2026 15:16:10 +0800 Subject: [PATCH 3/3] fix(appstate): allow AppState deinit off the main actor Async XCTest teardown released AppState off MainActor and trapped in assumeIsolated; use weak-boxed FSEvents teardown so discovery stays safe. --- Sources/CodeIsland/AppState.swift | 113 +++++++++++++----- .../CodeIslandTests/AppStateDeinitTests.swift | 26 ++++ 2 files changed, 111 insertions(+), 28 deletions(-) create mode 100644 Tests/CodeIslandTests/AppStateDeinitTests.swift diff --git a/Sources/CodeIsland/AppState.swift b/Sources/CodeIsland/AppState.swift index 2448d2bd..88fe2053 100644 --- a/Sources/CodeIsland/AppState.swift +++ b/Sources/CodeIsland/AppState.swift @@ -7,6 +7,29 @@ import CodeIslandCore private let log = Logger(subsystem: "com.codeisland", category: "AppState") +/// FSEventStream context target. Callbacks hold an unretained pointer to this +/// box (not `AppState`), and reach the owner only through `weak`, so queued +/// main-queue deliveries stay safe if `AppState` tears down off the main actor. +private final class ProjectsWatcherBox: @unchecked Sendable { + weak var appState: AppState? + private let lock = NSLock() + private var cancelled = false + + func cancel() { + lock.lock() + cancelled = true + lock.unlock() + } + + func handleChange() { + lock.lock() + let isCancelled = cancelled + lock.unlock() + guard !isCancelled else { return } + appState?.handleProjectsDirChange() + } +} + struct CodexSubagentMetadata: Equatable, Sendable { let parentThreadId: String let agentType: String? @@ -137,7 +160,10 @@ final class AppState { } private var maxHistory: Int { SettingsManager.shared.maxToolHistory } - private var cleanupTimer: Timer? + /// Torn down from `deinit`, which may run off the main actor (e.g. async + /// XCTest ARC). Only mutated on the main actor while `self` is alive. + @ObservationIgnored + nonisolated(unsafe) private var cleanupTimer: Timer? private var autoCollapseTask: Task? private var completionQueue: [String] = [] /// Mouse must enter the panel before auto-collapse is allowed (prevents instant dismiss) @@ -148,12 +174,19 @@ final class AppState { /// attached. Processes that already had ppid <= 1 at attach time are launchd-managed /// daemons (e.g. a Hermes gateway with KeepAlive=true), NOT orphans of a closed /// terminal — they must never be terminated by orphan cleanup (#243). - private var processMonitors: [String: (source: DispatchSourceProcess, process: ProcessIdentity, attachParentPid: pid_t?)] = [:] + /// Cancelled from `deinit` off the main actor. + @ObservationIgnored + nonisolated(unsafe) private var processMonitors: [String: (source: DispatchSourceProcess, process: ProcessIdentity, attachParentPid: pid_t?)] = [:] private var exitingSessions: [String: ProcessIdentity] = [:] - private var saveTimer: Timer? - private var fsEventStream: FSEventStreamRef? + @ObservationIgnored + nonisolated(unsafe) private var saveTimer: Timer? + @ObservationIgnored + nonisolated(unsafe) private var fsEventStream: FSEventStreamRef? + @ObservationIgnored + nonisolated(unsafe) private var projectsWatcherBox: ProjectsWatcherBox? private var lastFSScanTime: Date = .distantPast - private var discoveryScanTask: Task? + @ObservationIgnored + nonisolated(unsafe) private var discoveryScanTask: Task? private var pendingDiscoveryRescan = false private var isShowingCompletion: Bool { if case .completionCard = surface { return true } @@ -181,7 +214,8 @@ final class AppState { guard let rid = rotatingSessionId else { return nil } return sessions[rid] } - private var rotationTimer: Timer? + @ObservationIgnored + nonisolated(unsafe) private var rotationTimer: Timer? private func startCleanupTimer() { guard cleanupTimer == nil else { return } @@ -2431,19 +2465,21 @@ final class AppState { let watchRoots = Self.discoveryWatchRoots() guard !watchRoots.isEmpty else { return } + let box = ProjectsWatcherBox() + box.appState = self + var context = FSEventStreamContext() - // passUnretained is safe here: the stream is dispatched on .main (same as - // @MainActor), so callbacks cannot interleave with deinit. Both - // stopSessionDiscovery() and deinit stop/invalidate the stream synchronously - // on the main thread before self is deallocated. - context.info = Unmanaged.passUnretained(self).toOpaque() + // Unretained box is owned by `projectsWatcherBox` until + // `tearDownProjectsWatcher()`; the weak back-pointer keeps callbacks + // safe across off-main `AppState` deinit. + context.info = Unmanaged.passUnretained(box).toOpaque() let stream = FSEventStreamCreate( nil, { (_, info, _, _, _, _) in guard let info = info else { return } - let appState = Unmanaged.fromOpaque(info).takeUnretainedValue() - appState.handleProjectsDirChange() + let box = Unmanaged.fromOpaque(info).takeUnretainedValue() + box.handleChange() }, &context, watchRoots as CFArray, @@ -2455,12 +2491,13 @@ final class AppState { guard let stream = stream else { return } FSEventStreamSetDispatchQueue(stream, .main) FSEventStreamStart(stream) + self.projectsWatcherBox = box self.fsEventStream = stream log.info("Discovery watcher started on \(watchRoots.joined(separator: ", "))") } /// Called by FSEventStream when a known session-store directory changes. - nonisolated private func handleProjectsDirChange() { + nonisolated fileprivate func handleProjectsDirChange() { Task { @MainActor [weak self] in guard let self = self else { return } // Debounce: skip if scanned within the last 3 seconds @@ -3227,12 +3264,7 @@ final class AppState { } func stopSessionDiscovery() { - if let stream = fsEventStream { - FSEventStreamStop(stream) - FSEventStreamInvalidate(stream) - FSEventStreamRelease(stream) - fsEventStream = nil - } + tearDownProjectsWatcher() cleanupTimer?.invalidate() cleanupTimer = nil saveTimer?.invalidate() @@ -3243,21 +3275,46 @@ final class AppState { for key in Array(processMonitors.keys) { stopMonitor(key) } } - deinit { - MainActor.assumeIsolated { - rotationTimer?.invalidate() - cleanupTimer?.invalidate() - saveTimer?.invalidate() + /// Stops the FSEvents watcher on the main queue so Stop/Invalidate cannot + /// race a queued callback. Safe to call from `deinit` (any thread). + nonisolated private func tearDownProjectsWatcher() { + let teardown = { [self] in + // Flip cancel before stopping so any already-queued callback no-ops + // instead of touching a dying AppState / freed box. + projectsWatcherBox?.cancel() if let stream = fsEventStream { FSEventStreamStop(stream) FSEventStreamInvalidate(stream) FSEventStreamRelease(stream) + fsEventStream = nil } - discoveryScanTask?.cancel() - for (_, monitor) in processMonitors { - monitor.source.cancel() + let box = projectsWatcherBox + projectsWatcherBox = nil + // Keep the box alive until after previously queued main-queue + // callbacks drain (Invalidate does not flush them). + if let box { + DispatchQueue.main.async { _ = box } } } + if Thread.isMainThread { + teardown() + } else { + DispatchQueue.main.sync(execute: teardown) + } + } + + deinit { + // Must not use MainActor.assumeIsolated: async callers (notably XCTest) + // can release AppState off the main actor via ARC. Weak-boxed FSEvents + // + main-synced stream teardown keep discovery teardown crash-free. + rotationTimer?.invalidate() + cleanupTimer?.invalidate() + saveTimer?.invalidate() + tearDownProjectsWatcher() + discoveryScanTask?.cancel() + for (_, monitor) in processMonitors { + monitor.source.cancel() + } } private struct DiscoveredSession { diff --git a/Tests/CodeIslandTests/AppStateDeinitTests.swift b/Tests/CodeIslandTests/AppStateDeinitTests.swift new file mode 100644 index 00000000..1a1d7c52 --- /dev/null +++ b/Tests/CodeIslandTests/AppStateDeinitTests.swift @@ -0,0 +1,26 @@ +import XCTest +@testable import CodeIsland + +/// Regression: async XCTest / background ARC must be able to release AppState +/// without trapping in deinit (previous MainActor.assumeIsolated path), including +/// when session discovery's FSEventStream was started. +final class AppStateDeinitTests: XCTestCase { + func testDeinitOffMainActorAfterDiscoveryDoesNotTrap() async { + final class Holder: @unchecked Sendable { + var state: AppState? + } + let holder = Holder() + await MainActor.run { + let state = AppState() + state.startSessionDiscovery() + holder.state = state + } + + await Task.detached { + holder.state = nil + }.value + + // Give the main-queue box drain a turn so teardown completes cleanly. + await MainActor.run { } + } +}