diff --git a/CLI/CodexTeamsApprovalBridge.swift b/CLI/CodexTeamsApprovalBridge.swift index ce6e99c0f8b..d129ddab644 100644 --- a/CLI/CodexTeamsApprovalBridge.swift +++ b/CLI/CodexTeamsApprovalBridge.swift @@ -3,6 +3,24 @@ import Foundation enum CodexTeamsApprovalBridge { private typealias CodexPermissionCapabilities = (supportsOnce: Bool, supportsAlways: Bool, supportsAll: Bool) + struct ApprovalFeedPayload { + let toolName: String + let toolInput: [String: Any] + let context: [String: Any] + let cwd: String? + } + + static func isApprovalMethod(_ method: String) -> Bool { + switch method { + case "item/commandExecution/requestApproval", + "item/fileChange/requestApproval", + "item/permissions/requestApproval": + return true + default: + return false + } + } + static func feedEvent( method: String, requestId: Any, @@ -10,9 +28,37 @@ enum CodexTeamsApprovalBridge { workspaceId: String, relatedItem: [String: Any]? = nil ) -> [String: Any] { + let payload = approvalFeedPayload( + method: method, + requestId: requestId, + params: params, + relatedItem: relatedItem + ) let threadId = stringValue(in: params, keys: ["threadId", "thread_id"]) ?? stringValue(in: params, keys: ["threadID", "thread_id"]) ?? "unknown" + let itemId = stringValue(in: params, keys: ["approvalId", "approval_id", "itemId", "item_id"]) + ?? requestIdString(requestId) + var event: [String: Any] = [ + "session_id": "codex-\(threadId)", + "hook_event_name": "PermissionRequest", + "_source": "codex", + "workspace_id": workspaceId, + "tool_name": payload.toolName, + "tool_input": payload.toolInput, + "context": payload.context, + "_opencode_request_id": "codex-app-server-\(itemId)" + ] + if let cwd = payload.cwd { event["cwd"] = cwd } + return event + } + + static func approvalFeedPayload( + method: String, + requestId: Any, + params: [String: Any], + relatedItem: [String: Any]? = nil + ) -> ApprovalFeedPayload { let turnId = stringValue(in: params, keys: ["turnId", "turn_id"]) let itemId = stringValue(in: params, keys: ["approvalId", "approval_id", "itemId", "item_id"]) ?? requestIdString(requestId) @@ -73,18 +119,12 @@ enum CodexTeamsApprovalBridge { context["toolSummary"] = command } - var event: [String: Any] = [ - "session_id": "codex-\(threadId)", - "hook_event_name": "PermissionRequest", - "_source": "codex", - "workspace_id": workspaceId, - "tool_name": toolName, - "tool_input": toolInput, - "context": context, - "_opencode_request_id": "codex-app-server-\(itemId)" - ] - if let cwd { event["cwd"] = cwd } - return event + return ApprovalFeedPayload( + toolName: toolName, + toolInput: toolInput, + context: context, + cwd: cwd + ) } static func permissionMode(fromFeedPushResponse response: [String: Any]) -> String? { diff --git a/CLI/FeedEventClassifier.swift b/CLI/FeedEventClassifier.swift index 818880615d5..6230da27767 100644 --- a/CLI/FeedEventClassifier.swift +++ b/CLI/FeedEventClassifier.swift @@ -73,13 +73,20 @@ struct FeedEventClassifier { toolName: String ) -> FeedEventClassification { let semantic = feedEventSemantic(source: source, event: event) - return wireMapping(for: semantic, source: source, toolName: toolName) + // `feedEventSemantic` normalizes aliases before looking up the + // registry. Pass that same key to the wire mapper so source-specific + // approval rules cannot diverge for aliases such as `claude-code`. + return wireMapping( + for: semantic, + source: normalizedSource(source), + toolName: toolName + ) } /// Whether any of `source`'s registered events carry the /// ``FeedEventSemantic/nativeApprovalPrompt`` semantic. private static func sourceRaisesNativeApprovalPrompts(_ source: String) -> Bool { - feedEventSemanticRegistry[source]?.values.contains(.nativeApprovalPrompt) == true + feedEventSemanticRegistry[normalizedSource(source)]?.values.contains(.nativeApprovalPrompt) == true } /// User-attention semantic of a hook/feed event, independent of the @@ -92,6 +99,10 @@ struct FeedEventClassifier { /// Resolved against the tool name so Claude's `ExitPlanMode` / /// `AskUserQuestion` approvals route to their dedicated kinds. case approvalRequest + /// A structured user question, boolean confirmation, or elicitation + /// form. These share the AskUserQuestion wire envelope so older + /// agents still receive the same reply bridge. + case questionRequest /// A tool is about to run but no approval is pending. Telemetry /// only. Used by agents that expose a *separate* approval event /// (Claude, Codex, Hermes) so their pre-tool hook never escalates. @@ -140,8 +151,61 @@ struct FeedEventClassifier { source: String, event: String ) -> FeedEventSemantic { - let table = feedEventSemanticRegistry[source] ?? telemetryOnlyFeedEventSemantics - return table[event] ?? .unknown + let sourceKey = normalizedSource(source) + let table = feedEventSemanticRegistry[sourceKey] ?? telemetryOnlyFeedEventSemantics + if let semantic = table[event] { + return semantic + } + // The agent ecosystems do not share one spelling for a structured + // user-input request. Once a source is registered in the cmux hook + // catalog, normalize the small, explicit family of question names so + // new MCP/app-server adapters do not silently become telemetry. This + // remains fail-closed for unknown sources and for all other unknown + // event names. + if registeredFeedSources.contains(sourceKey), isQuestionEventName(event) { + return .questionRequest + } + return .unknown + } + + private static func normalizedSource(_ source: String) -> String { + let value = source.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + switch value { + case "claude-code": return "claude" + case "cursor-agent": return "cursor" + case "open-code": return "opencode" + case "gemini-cli": return "gemini" + case "grok-code": return "grok" + case "rovo": return "rovodev" + case "agy": return "antigravity" + default: return value + } + } + + private static func isQuestionEventName(_ event: String) -> Bool { + let normalized = event.unicodeScalars.filter { CharacterSet.alphanumerics.contains($0) } + .map(String.init) + .joined() + .lowercased() + return [ + "askuserquestion", + "askuserconfirmation", + "askuser", + "booleanquestion", + "confirmationrequest", + "questionasked", + "questionv2asked", + "questionrequest", + "elicitation", + "elicitationrequest", + "mcpelicitation", + "mcpserverelicitationrequest", + "requestuserinput", + "userinputrequest", + "inputrequest", + "toolrequestuserinput", + "itemtoolrequestuserinput", + ].contains(normalized) } /// Tool names that carry their own dedicated approval wire event rather @@ -166,6 +230,8 @@ struct FeedEventClassifier { switch semantic { case .approvalRequest: return actionable(dedicatedApprovalEvent(for: toolName) ?? "PermissionRequest") + case .questionRequest: + return actionable("AskUserQuestion") case .toolStartMaybeApproval: if let dedicated = dedicatedApprovalEvent(for: toolName) { return actionable(dedicated) @@ -271,6 +337,11 @@ struct FeedEventClassifier { "SubagentStart": .subagentStart, "SubagentStop": .subagentResponse, "Notification": .statusNotification, + "AskUserQuestion": .questionRequest, + "AskUserConfirmation": .questionRequest, + "BooleanQuestion": .questionRequest, + "Elicitation": .questionRequest, + "ElicitationRequest": .questionRequest, ], "codex": [ // Codex runs PermissionRequest hooks before its own approval @@ -303,6 +374,13 @@ struct FeedEventClassifier { "subagent_stop": .subagentResponse, "Notification": .statusNotification, "notification": .statusNotification, + "AskUserQuestion": .questionRequest, + "AskUserConfirmation": .questionRequest, + "BooleanQuestion": .questionRequest, + "Elicitation": .questionRequest, + "ElicitationRequest": .questionRequest, + "tool/requestUserInput": .questionRequest, + "requestUserInput": .questionRequest, ], "hermes-agent": [ // `pre_tool_call` is a tool *starting* — Hermes raises a @@ -321,6 +399,9 @@ struct FeedEventClassifier { "on_session_reset": .sessionStart, "on_session_end": .sessionEnd, "on_session_finalize": .sessionEnd, + "ask_user": .questionRequest, + "question": .questionRequest, + "elicitation": .questionRequest, ], // Gemini CLI consumes the generic PreToolUse decision schema and has // no separate approval event, so it deliberately opts in to blocking. @@ -336,6 +417,8 @@ struct FeedEventClassifier { "userPromptSubmit": .promptSubmit, "agentSpawn": .sessionStart, "stop": .response, + "askUserQuestion": .questionRequest, + "ask_user": .questionRequest, ], ] @@ -356,6 +439,13 @@ struct FeedEventClassifier { "SubagentStart": .subagentStart, "SubagentStop": .subagentResponse, "Notification": .statusNotification, + "AskUserQuestion": .questionRequest, + "AskUserConfirmation": .questionRequest, + "BooleanQuestion": .questionRequest, + "Elicitation": .questionRequest, + "ElicitationRequest": .questionRequest, + "tool/requestUserInput": .questionRequest, + "requestUserInput": .questionRequest, ] /// Safe fallback for unregistered sources. Familiar event names preserve @@ -376,6 +466,15 @@ struct FeedEventClassifier { "Notification": .statusNotification, ] + /// Sources with an installed cmux hook integration. They may opt into the + /// explicit question-name normalization above; unknown integrations stay + /// telemetry-only until their wire contract is reviewed. + private static let registeredFeedSources: Set = [ + "claude", "codex", "opencode", "grok", "pi", "omp", "campfire", "amp", + "cursor", "gemini", "kiro", "antigravity", "rovodev", "hermes-agent", + "copilot", "codebuddy", "factory", "qoder", "kimi", + ] + /// Tools that mutate state and deserve a user-visible approve/ /// deny prompt in Feed. Keyed on the canonical tool names Claude, /// Codex, and similar agents emit. Read-only tools (Read, Grep, diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticAppEventDetail.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticAppEventDetail.swift index 91d566a4ea4..afd18d8ee6d 100644 --- a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticAppEventDetail.swift +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticAppEventDetail.swift @@ -63,6 +63,7 @@ public enum DiagnosticPrimaryTab: Int, Sendable, Codable, CaseIterable { case workspaces = 1 case notifications = 2 case search = 3 + case feed = 4 } /// Fixed search owner stored in the value payload of search lifecycle events. diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift index f132f65c2fe..551bb04db3d 100644 --- a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift @@ -731,12 +731,18 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable { "mobile.events.probe": return false case "notification.feed.list", "notification.feed.mark_read", "notification.feed.mark_unread", - "notification.feed.mark_all_read": + "notification.feed.mark_all_read", "workstream.feed.list": // Feed authority is the authenticated account/peer connection, not // a workspace-selection ticket. Omit an irrelevant scoped attach // token so legacy pairings cannot accidentally narrow the global // feed; Stack auth is still attached to every TCP request. return true + case "workstream.feed.action", "workstream.feed.reply": + return !ticketCoverage.ticketCoversTerminalRequest( + ticket: ticket, + workspaceSelection: workspaceSelection.value, + terminalSelection: terminalSelection.value + ) case "mobile.browser.list", "mobile.browser.create": return !ticketCoverage.ticketCoversWorkspaceRequest( ticket: ticket, diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileWorkstreamFeedExports.swift b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileWorkstreamFeedExports.swift new file mode 100644 index 00000000000..cebf66c17fd --- /dev/null +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileWorkstreamFeedExports.swift @@ -0,0 +1,3 @@ +/// Workstream DTOs live in the lower shell-model package so aggregation and +/// RPC decoding share one schema without a dependency cycle. +public import CmuxMobileShellModel diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCNotificationFeedAuthTests.swift b/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCNotificationFeedAuthTests.swift index e88136d6b0e..a7e19cd0a82 100644 --- a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCNotificationFeedAuthTests.swift +++ b/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCNotificationFeedAuthTests.swift @@ -9,6 +9,7 @@ import Testing "notification.feed.mark_read", "notification.feed.mark_unread", "notification.feed.mark_all_read", + "workstream.feed.list", ]) func feedRequestsUseAccountAuthorizationWithoutWorkspaceTicketScope(method: String) async throws { let route = try hostPortRoute(kind: .debugLoopback, host: "127.0.0.1", port: 58_465) @@ -32,9 +33,14 @@ import Testing ticket: ticket, allowsStackAuthFallback: true ) - let params: [String: Any] = ["notification.feed.mark_read", "notification.feed.mark_unread"].contains(method) - ? ["notification_ids": ["notification"]] - : [:] + let params: [String: Any] + if ["notification.feed.mark_read", "notification.feed.mark_unread"].contains(method) { + params = ["notification_ids": ["notification"]] + } else if method == "workstream.feed.list" { + params = ["cursor": "00000000-0000-0000-0000-000000000300"] + } else { + params = [:] + } let request = try MobileCoreRPCClient.requestData(method: method, params: params) let task = Task { try await client.sendRequest(request) } @@ -47,5 +53,46 @@ import Testing #expect(frame.attachToken == nil) #expect(frame.stackAccessToken == "test-stack-token") #expect(frame.hasAuth) + if method == "workstream.feed.list" { + #expect(frame.cursor == "00000000-0000-0000-0000-000000000300") + } + } + + @Test(arguments: ["workstream.feed.action", "workstream.feed.reply"]) + func feedMutationsPreserveMatchingScopedTicket(method: String) async throws { + let route = try hostPortRoute(kind: .debugLoopback, host: "127.0.0.1", port: 58_465) + let transport = QueuedCancellationProbeTransport() + let runtime = TestMobileSyncRuntime( + transportFactory: QueuedCancellationProbeTransportFactory(transport: transport), + stackAccessToken: "test-stack-token" + ) + let ticket = try CmxAttachTicket( + workspaceID: "workspace-main", + terminalID: "surface-main", + macDeviceID: "test-mac", + macDisplayName: "Test Mac", + routes: [route], + expiresAt: Date().addingTimeInterval(60), + authToken: "ticket-secret" + ) + let client = MobileCoreRPCClient( + runtime: runtime, + route: route, + ticket: ticket, + allowsStackAuthFallback: true + ) + let request = try MobileCoreRPCClient.requestData(method: method, params: [ + "workspace_id": "workspace-main", + "surface_id": "surface-main", + ]) + + let task = Task { try await client.sendRequest(request) } + let sent = try await transport.waitForSentRequestCount(1) + task.cancel() + _ = try? await task.value + + let frame = try #require(sent.first) + #expect(frame.attachToken == "ticket-secret") + #expect(frame.stackAccessToken == "test-stack-token") } } diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/TransportTestDoubles.swift b/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/TransportTestDoubles.swift index 9eddb418dfd..bdfd29953e7 100644 --- a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/TransportTestDoubles.swift +++ b/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/TransportTestDoubles.swift @@ -73,6 +73,7 @@ struct RecordedRPCRequest: Sendable { var workspaceID: String? var terminalID: String? var text: String? + var cursor: String? var hasAuth: Bool var attachToken: String? var stackAccessToken: String? @@ -88,6 +89,7 @@ func recordedRPCRequest(from payload: Data) throws -> RecordedRPCRequest { workspaceID: params["workspace_id"] as? String, terminalID: params["terminal_id"] as? String ?? params["surface_id"] as? String, text: params["text"] as? String, + cursor: params["cursor"] as? String, hasAuth: auth != nil, attachToken: auth?["attach_token"] as? String, stackAccessToken: auth?["stack_access_token"] as? String diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/AgentFeedCacheStore.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/AgentFeedCacheStore.swift new file mode 100644 index 00000000000..5f9c36b69b7 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/AgentFeedCacheStore.swift @@ -0,0 +1,72 @@ +import Foundation + +/// Account/team-scoped, bounded disk cache for authenticated Feed snapshots. +actor AgentFeedCacheStore { + private let directory: URL + private let fileManager: FileManager + private let maxMacCount = 20 + + init(directory: URL? = nil, fileManager: FileManager = .default) { + self.fileManager = fileManager + self.directory = directory + ?? URL.cachesDirectory.appending(path: "AgentFeed", directoryHint: .isDirectory) + } + + func load(scopeKey: String) -> [AgentFeedCachedSnapshot] { + guard let data = try? Data(contentsOf: fileURL(scopeKey: scopeKey)), + let snapshots = try? JSONDecoder().decode([AgentFeedCachedSnapshot].self, from: data) else { + return [] + } + return Array(snapshots.sorted { $0.cachedAt > $1.cachedAt }.prefix(maxMacCount)) + } + + func upsert(_ snapshot: AgentFeedCachedSnapshot, scopeKey: String) { + var snapshots = load(scopeKey: scopeKey) + snapshots.removeAll { $0.ownerKey == snapshot.ownerKey } + snapshots.insert(snapshot, at: 0) + do { + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + try JSONEncoder().encode(Array(snapshots.prefix(maxMacCount))) + .write(to: fileURL(scopeKey: scopeKey), options: .atomic) + } catch { + // Cache failure is non-fatal; the authenticated host remains authoritative. + } + } + + /// Removes snapshots for owners that are no longer visible in this + /// account/team scope. Persisting the removal prevents hidden or replaced + /// app instances from returning after launch. + func remove(ownerKeys: Set, scopeKey: String) { + guard !ownerKeys.isEmpty else { return } + var snapshots = load(scopeKey: scopeKey) + let originalCount = snapshots.count + snapshots.removeAll { ownerKeys.contains($0.ownerKey) } + guard snapshots.count != originalCount else { return } + let url = fileURL(scopeKey: scopeKey) + guard !snapshots.isEmpty else { + try? fileManager.removeItem(at: url) + return + } + do { + try JSONEncoder().encode(snapshots).write(to: url, options: .atomic) + } catch { + // Cache failure is non-fatal; the authenticated host remains authoritative. + } + } + + func clear(scopeKey: String) { + try? fileManager.removeItem(at: fileURL(scopeKey: scopeKey)) + } + + func clearAll() { + try? fileManager.removeItem(at: directory) + } + + private func fileURL(scopeKey: String) -> URL { + let filename = Data(scopeKey.utf8).base64EncodedString() + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "=", with: "") + return directory.appending(path: "\(filename).json") + } +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/AgentFeedCachedSnapshot.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/AgentFeedCachedSnapshot.swift new file mode 100644 index 00000000000..75f234c0806 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/AgentFeedCachedSnapshot.swift @@ -0,0 +1,11 @@ +import Foundation + +/// Authenticated host response retained for offline Feed restoration. +struct AgentFeedCachedSnapshot: Codable, Sendable { + let ownerKey: String + let macDeviceID: String + let instanceTag: String? + let displayName: String + let responseData: Data + let cachedAt: Date +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/AgentFeedMacSnapshot.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/AgentFeedMacSnapshot.swift new file mode 100644 index 00000000000..fe8af5fed79 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/AgentFeedMacSnapshot.swift @@ -0,0 +1,11 @@ +import CmuxMobileShellModel + +/// Last authoritative workstream snapshot received from one Mac. +struct AgentFeedMacSnapshot { + var pages: MobileAgentFeedPageAccumulator + var items: [MobileAgentFeedItem] + + var revision: UInt64 { pages.revision } + var nextCursor: String? { pages.nextCursor } + var hasMore: Bool { pages.hasMore } +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+AgentFeed.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+AgentFeed.swift new file mode 100644 index 00000000000..d0ff0f2e602 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+AgentFeed.swift @@ -0,0 +1,563 @@ +internal import CMUXMobileCore +import CmuxMobilePairedMac +import CmuxMobileRPC +public import CmuxMobileShellModel +import Foundation +internal import OSLog + +nonisolated private let agentFeedLog = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "dev.cmux.ios", + category: "agent-feed" +) + +private struct AgentFeedTarget { + let ownerKey: String + let macDeviceID: String + let instanceTag: String? + let displayName: String + let client: MobileCoreRPCClient +} + +@MainActor +extension MobileShellComposite { + static let agentFeedCapability = "workstream.feed.v1" + + /// Fetches all capable Macs and retains cached snapshots from offline Macs. + public func refreshAgentFeed() async { + await restoreAgentFeedCacheIfNeeded() + let targets = agentFeedTargets() + guard !targets.isEmpty else { + recomputeAgentFeedItems() + if agentFeedItems.isEmpty { + agentFeedStatus = connectedAgentFeedMacCount > 0 ? .requiresMacUpdate : .unavailable + } else { + agentFeedStatus = connectedAgentFeedMacCount > 0 ? .requiresMacUpdate : .offlineCached + } + return + } + agentFeedStatus = .loading + let tasks = targets.map { target in + scheduleAgentFeedRefresh(target) + } + for task in tasks { await task.value } + recomputeAgentFeedItems() + agentFeedStatus = resolvedAgentFeedStatus() + } + + /// Loads one persisted 300-item history page from every eligible Mac. + public func loadOlderAgentFeed() async { + guard !agentFeedIsLoadingOlder else { return } + let targets = agentFeedTargets().filter { target in + agentFeedSnapshotsByMac[target.ownerKey]?.hasMore == true + && agentFeedSnapshotsByMac[target.ownerKey]?.nextCursor != nil + } + guard !targets.isEmpty else { + recomputeAgentFeedPagingState() + return + } + agentFeedIsLoadingOlder = true + defer { + agentFeedIsLoadingOlder = false + recomputeAgentFeedPagingState() + } + for target in targets { + guard let cursor = agentFeedSnapshotsByMac[target.ownerKey]?.nextCursor else { continue } + await fetchAgentFeed(target, cursor: cursor, appending: true) + } + agentFeedStatus = resolvedAgentFeedStatus() + } + + /// Coalesces invalidations per Mac onto one list request. + func handleAgentFeedChangedEvent( + _ event: MobileEventEnvelope, + ownerKey: String, + client: MobileCoreRPCClient + ) { + guard event.topic == "workstream.feed.changed", + let data = event.payloadJSON, + let changed = MobileWorkstreamFeedChangedEvent.decode(data), + agentFeedClient(for: ownerKey) === client, + changed.revision > (agentFeedKnownRevisionsByMac[ownerKey] ?? 0), + let target = agentFeedTarget(for: ownerKey) else { return } + agentFeedKnownRevisionsByMac[ownerKey] = changed.revision + _ = scheduleAgentFeedRefresh(target) + } + + func scheduleForegroundAgentFeedRefresh(client: MobileCoreRPCClient) { + guard supportedHostCapabilities.contains(Self.agentFeedCapability), + remoteClient === client, + let target = agentFeedTargets().first(where: { $0.client === client }) else { return } + _ = scheduleAgentFeedRefresh(target) + } + + func scheduleSecondaryAgentFeedRefresh( + ownerKey: String, + client: MobileCoreRPCClient + ) { + guard let target = agentFeedTarget(for: ownerKey), target.client === client else { return } + _ = scheduleAgentFeedRefresh(target) + } + + /// Sends one exact Feed decision. No optimistic resolution is applied; + /// the authoritative list acknowledgement wins. + public func sendAgentFeedAction( + _ action: MobileAgentFeedAction, + for item: MobileAgentFeedItem + ) async { + guard agentFeedMutationStates[item.id] != .sending, + agentFeedMutationStates[item.id] != .awaitingReconciliation, + item.wire.status.isPending, + let requestID = item.wire.payload.requestID, + let workspaceID = item.wire.workspaceID, + let surfaceID = item.wire.surfaceID, + let target = agentFeedTarget(for: ownerKey(for: item)) else { return } + agentFeedMutationStates[item.id] = .sending + var params: [String: Any] = [ + "item_id": item.wire.id.uuidString, + "request_id": requestID, + ] + params["workspace_id"] = workspaceID + params["surface_id"] = surfaceID + switch action { + case .permission(let mode): + params["kind"] = "permission"; params["mode"] = mode + case .exitPlan(let mode, let feedback): + params["kind"] = "exit_plan"; params["mode"] = mode + if let feedback, !feedback.isEmpty { params["feedback"] = feedback } + case .question(let selections): + params["kind"] = "question"; params["selections"] = selections + case .boolean(let value): + params["kind"] = "boolean"; params["value"] = value + case .form(let action, let selections): + params["kind"] = "form" + params["action"] = action + params["selections"] = selections + } + do { + let request = try MobileCoreRPCClient.requestData(method: "workstream.feed.action", params: params) + _ = try await target.client.sendRequest(request) + agentFeedMutationStates[item.id] = .awaitingReconciliation + await scheduleAgentFeedRefresh(target).value + } catch { + agentFeedMutationStates[item.id] = .failed + } + } + + /// Sends a multiline reply once to the item snapshot's pinned route. + public func sendAgentFeedReply(for item: MobileAgentFeedItem) async { + guard agentFeedMutationStates[item.id] != .sending, + agentFeedMutationStates[item.id] != .awaitingReconciliation, + item.isReplyableTurnCompletion, + let workspaceID = item.wire.workspaceID, + let surfaceID = item.wire.surfaceID, + let draft = agentFeedDrafts[item.id], + !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let target = agentFeedTarget(for: ownerKey(for: item)) else { return } + agentFeedMutationStates[item.id] = .sending + do { + let request = try MobileCoreRPCClient.requestData( + method: "workstream.feed.reply", + params: [ + "item_id": item.wire.id.uuidString, + "workstream_id": item.wire.workstreamID, + "workspace_id": workspaceID, + "surface_id": surfaceID, + "text": draft, + ] + ) + _ = try await target.client.sendRequest(request) + agentFeedMutationStates[item.id] = .awaitingReconciliation + await scheduleAgentFeedRefresh(target).value + } catch { + agentFeedMutationStates[item.id] = .failed + } + } + + /// Opens the exact Mac/workspace/surface and leaves the Feed navigation + /// stack intact so Back returns to its filter and expanded-card state. + public func openAgentFeedItem(_ item: MobileAgentFeedItem) async -> Bool { + let foregroundMatches = item.macDeviceID == foregroundMacDeviceID + && macInstanceTagAuthority.sameStoredAuthority(item.macInstanceTag, activeMacInstanceTag) + if !foregroundMatches, + !(await switchToMac(macDeviceID: item.macDeviceID, instanceTag: item.macInstanceTag)) { + return false + } + guard let remoteWorkspaceID = item.wire.workspaceID, + let workspaceID = rowWorkspaceID( + forRemoteWorkspaceID: MobileWorkspacePreview.ID(rawValue: remoteWorkspaceID), + macDeviceID: item.macDeviceID, + instanceTag: item.macInstanceTag + ) else { return false } + guard let surfaceID = item.wire.surfaceID, + workspace(workspaceID, containsSurfaceID: surfaceID) else { return false } + navigateToWorkspaceForDeeplink(workspaceID, origin: .agentFeed) + selectTerminal(MobileTerminalPreview.ID(rawValue: surfaceID)) + return true + } + + func recomputeAgentFeedItems() { + agentFeedItems = agentFeedAggregation.items(from: agentFeedSnapshotsByMac.values.map(\.items)).map { item in + let isForeground = item.macDeviceID == foregroundMacDeviceID + && macInstanceTagAuthority.sameStoredAuthority(item.macInstanceTag, activeMacInstanceTag) + let status = isForeground + ? macConnectionStatus + : macConnectionStatuses[item.macDeviceID] ?? .unavailable + return MobileAgentFeedItem( + macDeviceID: item.macDeviceID, + macInstanceTag: item.macInstanceTag, + macDisplayName: item.macDisplayName, + connectionStatus: status, + wire: item.wire + ) + } + let retainedIDs = Set(agentFeedItems.map(\.id)) + agentFeedDrafts = agentFeedDrafts.filter { retainedIDs.contains($0.key) } + var latestByWorkstream: [String: MobileAgentFeedItemID] = [:] + for item in agentFeedItems { + latestByWorkstream[agentFeedWorkstreamKey(item)] = latestByWorkstream[agentFeedWorkstreamKey(item)] ?? item.id + } + let itemByID = Dictionary(uniqueKeysWithValues: agentFeedItems.map { ($0.id, $0) }) + agentFeedMutationStates = agentFeedMutationStates.filter { id, state in + guard retainedIDs.contains(id), let item = itemByID[id] else { return false } + switch state { + case .awaitingReconciliation: + // Keep the card locked while the host still reports it as + // pending. A successful reply/action must never become + // tappable again during a delayed list refresh. + return item.wire.status.isPending + || (item.isReplyableTurnCompletion + && latestByWorkstream[agentFeedWorkstreamKey(item)] == item.id) + default: + return true + } + } + recomputeAgentFeedPagingState() + } + + func resetAgentFeed() { + resetAgentFeedForScopeChange() + let previousClear = agentFeedCacheClearTask + let token = UUID() + agentFeedCacheClearToken = token + agentFeedCacheClearTask = Task { [agentFeedCacheStore] in + await previousClear?.value + await agentFeedCacheStore.clearAll() + } + } + + /// Drops old-team in-memory rows without deleting that team's scoped cache, + /// so switching back can restore it while the new team never sees it. + func resetAgentFeedForScopeChange() { + agentFeedRefreshTasks.cancelAll() + agentFeedSnapshotsByMac = [:] + agentFeedKnownRevisionsByMac = [:] + agentFeedFailedOwnerKeys = [] + agentFeedItems = [] + agentFeedDrafts = [:] + agentFeedMutationStates = [:] + agentFeedStatus = .idle + agentFeedHasMoreItems = false + agentFeedCanLoadOlder = false + agentFeedIsLoadingOlder = false + agentFeedCacheScopeKey = nil + } + + private func scheduleAgentFeedRefresh(_ target: AgentFeedTarget) -> Task { + agentFeedRefreshTasks.schedule(ownerKey: target.ownerKey) { @MainActor [weak self] in + guard let self else { return } + await self.restoreAgentFeedCacheIfNeeded() + guard !Task.isCancelled else { return } + await self.fetchAgentFeed(target) + guard !Task.isCancelled else { return } + self.agentFeedStatus = self.resolvedAgentFeedStatus() + } + } + + private func fetchAgentFeed( + _ target: AgentFeedTarget, + cursor: String? = nil, + appending: Bool = false + ) async { + do { + var params: [String: Any] = [:] + if let cursor { params["cursor"] = cursor } + let request = try MobileCoreRPCClient.requestData(method: "workstream.feed.list", params: params) + let data = try await target.client.sendRequest(request) + let response = try await Task.detached { + try MobileWorkstreamFeedListResponse.decode(data) + }.value + guard !Task.isCancelled, + agentFeedClient(for: target.ownerKey) === target.client else { return } + var pages: MobileAgentFeedPageAccumulator + if var existing = agentFeedSnapshotsByMac[target.ownerKey]?.pages { + if appending { + existing.append(response) + } else { + existing.applyFirstPage(response) + } + pages = existing + } else { + pages = MobileAgentFeedPageAccumulator(response: response) + } + let rows = pages.items.map { wire in + MobileAgentFeedItem( + macDeviceID: target.macDeviceID, + macInstanceTag: target.instanceTag, + macDisplayName: target.displayName, + connectionStatus: .connected, + wire: wire + ) + } + agentFeedSnapshotsByMac[target.ownerKey] = AgentFeedMacSnapshot( + pages: pages, + items: rows + ) + // A Mac process relaunch resets its revision namespace. The list is + // authoritative, so adopt its cursor instead of retaining a larger + // revision from the previous process forever. + agentFeedKnownRevisionsByMac[target.ownerKey] = response.revision + agentFeedFailedOwnerKeys.remove(target.ownerKey) + recomputeAgentFeedItems() + if !appending { + let sanitized = await Task.detached { + Self.sanitizedAgentFeedCacheData(data) + }.value + guard !Task.isCancelled else { return } + await persistAgentFeedSnapshot(sanitized, target: target) + } + } catch { + guard !Task.isCancelled else { return } + agentFeedFailedOwnerKeys.insert(target.ownerKey) + agentFeedLog.error( + "list failed mac=\(target.macDeviceID, privacy: .private(mask: .hash)) error=\(String(describing: error), privacy: .private)" + ) + agentFeedStatus = agentFeedSnapshotsByMac[target.ownerKey] == nil ? .failed : .offlineCached + } + } + + private func restoreAgentFeedCacheIfNeeded() async { + await awaitAgentFeedCacheClearIfNeeded() + guard agentFeedSnapshotsByMac.isEmpty, + let scope = await currentScopeSnapshot() else { return } + let scopeKey = pairedMacScopeKey(scope) + agentFeedCacheScopeKey = scopeKey + let cached = await agentFeedCacheStore.load(scopeKey: scopeKey) + guard await isScopeCurrent(scope) else { return } + let eligibleCached: [AgentFeedCachedSnapshot] + if let pairedMacStore { + guard let stored = try? await pairedMacStore.loadAll( + stackUserID: scope.userID, + teamID: scope.teamID + ) else { return } + let visible = await visibleStoredPairedMacs(from: stored, scope: scope) + guard await isScopeCurrent(scope) else { return } + eligibleCached = cached.filter { snapshot in + visible.contains { mac in + guard cmxCanonicalDeviceID(mac.macDeviceID) + == cmxCanonicalDeviceID(snapshot.macDeviceID), + macInstanceTagAuthority.sameStoredAuthority( + mac.instanceTag, + snapshot.instanceTag + ) else { return false } + return snapshot.ownerKey == mac.id + || snapshot.ownerKey == cmxCanonicalDeviceID(mac.macDeviceID) + } + } + let staleOwnerKeys = Set(cached.map(\.ownerKey)) + .subtracting(eligibleCached.map(\.ownerKey)) + await agentFeedCacheStore.remove(ownerKeys: staleOwnerKeys, scopeKey: scopeKey) + } else { + // Preview and in-memory configurations have no paired-Mac store; + // their scoped cache is already the only available authority. + eligibleCached = cached + } + let decoded: [(AgentFeedCachedSnapshot, MobileWorkstreamFeedListResponse)] = await Task.detached { + eligibleCached.compactMap { snapshot in + guard let response = try? MobileWorkstreamFeedListResponse.decode(snapshot.responseData) else { + return nil + } + return (snapshot, response) + } + }.value + guard !Task.isCancelled else { return } + for (snapshot, response) in decoded { + let rows = response.items.prefix(MobileAgentFeedAggregation.maxItemCount).map { wire in + MobileAgentFeedItem( + macDeviceID: snapshot.macDeviceID, + macInstanceTag: snapshot.instanceTag, + macDisplayName: snapshot.displayName, + connectionStatus: .unavailable, + wire: wire + ) + } + agentFeedSnapshotsByMac[snapshot.ownerKey] = AgentFeedMacSnapshot( + pages: MobileAgentFeedPageAccumulator(response: response), + items: rows + ) + agentFeedKnownRevisionsByMac[snapshot.ownerKey] = response.revision + } + recomputeAgentFeedItems() + if !agentFeedItems.isEmpty { agentFeedStatus = .offlineCached } + } + + private func persistAgentFeedSnapshot(_ data: Data, target: AgentFeedTarget) async { + await awaitAgentFeedCacheClearIfNeeded() + guard !data.isEmpty, + let scope = await currentScopeSnapshot(), + await isScopeCurrent(scope) else { return } + let scopeKey = pairedMacScopeKey(scope) + agentFeedCacheScopeKey = scopeKey + await agentFeedCacheStore.upsert( + AgentFeedCachedSnapshot( + ownerKey: target.ownerKey, + macDeviceID: target.macDeviceID, + instanceTag: target.instanceTag, + displayName: target.displayName, + responseData: data, + cachedAt: Date() + ), + scopeKey: scopeKey + ) + } + + /// Removes raw tool input before writing a host response to disk. Cards use + /// the host-produced redacted summary, so retaining raw values is needless. + nonisolated static func sanitizedAgentFeedCacheData(_ data: Data) -> Data { + guard var root = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], + var items = root["items"] as? [[String: Any]] else { return Data() } + for index in items.indices { + items[index]["tool_input"] = nil + items[index]["tool_input_capabilities"] = nil + if items[index]["tool_result_is_error"] as? Bool == true { + items[index]["tool_result"] = nil + } + } + root["items"] = items + return (try? JSONSerialization.data(withJSONObject: root, options: [.sortedKeys])) ?? Data() + } + + private var connectedAgentFeedMacCount: Int { + (remoteClient == nil ? 0 : 1) + secondaryMacSubscriptions.count + } + + private func recomputeAgentFeedPagingState() { + agentFeedHasMoreItems = agentFeedSnapshotsByMac.values.contains { $0.hasMore } + let eligibleOwnerKeys = Set(agentFeedTargets().map(\.ownerKey)) + agentFeedCanLoadOlder = agentFeedSnapshotsByMac.contains { ownerKey, snapshot in + eligibleOwnerKeys.contains(ownerKey) && snapshot.hasMore && snapshot.nextCursor != nil + } + } + + private func resolvedAgentFeedStatus() -> MobileAgentFeedStatus { + guard connectedAgentFeedMacCount > 0 else { + return agentFeedItems.isEmpty ? .unavailable : .offlineCached + } + let capable = agentFeedTargets().count + guard capable > 0 else { return .requiresMacUpdate } + let failedCount = agentFeedTargets().lazy.filter { self.agentFeedFailedOwnerKeys.contains($0.ownerKey) }.count + if failedCount == capable { return agentFeedItems.isEmpty ? .failed : .offlineCached } + if failedCount > 0 { return .partial } + if capable < connectedAgentFeedMacCount { return .partial } + return .ready + } + + private func agentFeedTargets() -> [AgentFeedTarget] { + var result: [AgentFeedTarget] = [] + if let client = remoteClient, + supportedHostCapabilities.contains(Self.agentFeedCapability), + let macDeviceID = foregroundMacDeviceID ?? activeTicket?.macDeviceID { + result.append(AgentFeedTarget( + ownerKey: macDeviceID, + macDeviceID: macDeviceID, + instanceTag: activeMacInstanceTag, + displayName: activeTicket?.macDisplayName ?? connectedHostName, + client: client + )) + } + for (key, subscription) in secondaryMacSubscriptions + where subscription.supportedHostCapabilities.contains(Self.agentFeedCapability) { + result.append(AgentFeedTarget( + ownerKey: key.pairingID, + macDeviceID: subscription.macDeviceID, + instanceTag: subscription.storedInstanceTag, + displayName: subscription.displayName ?? subscription.macDeviceID, + client: subscription.client + )) + } + return result + } + + private func agentFeedTarget(for ownerKey: String) -> AgentFeedTarget? { + agentFeedTargets().first { $0.ownerKey == ownerKey } + } + + private func agentFeedClient(for ownerKey: String) -> MobileCoreRPCClient? { + agentFeedTarget(for: ownerKey)?.client + } + + private func ownerKey(for item: MobileAgentFeedItem) -> String { + if item.macDeviceID == foregroundMacDeviceID, + macInstanceTagAuthority.sameStoredAuthority(item.macInstanceTag, activeMacInstanceTag) { + return item.macDeviceID + } + return MobilePairedMac.pairingID(macDeviceID: item.macDeviceID, instanceTag: item.macInstanceTag) + } + + func removeAgentFeedSnapshot(ownerKey: String, scopeKey explicitScopeKey: String? = nil) { + agentFeedRefreshTasks.cancel(ownerKey: ownerKey) + agentFeedSnapshotsByMac[ownerKey] = nil + agentFeedKnownRevisionsByMac[ownerKey] = nil + agentFeedFailedOwnerKeys.remove(ownerKey) + recomputeAgentFeedItems() + agentFeedStatus = resolvedAgentFeedStatus() + let knownScopeKey = explicitScopeKey ?? agentFeedCacheScopeKey + let cacheStore = agentFeedCacheStore + Task { @MainActor [weak self, cacheStore] in + let scopeKey: String + if let knownScopeKey { + scopeKey = knownScopeKey + } else { + guard let self, + let scope = await self.currentScopeSnapshot(), + await self.isScopeCurrent(scope) else { return } + scopeKey = self.pairedMacScopeKey(scope) + } + await cacheStore.remove(ownerKeys: [ownerKey], scopeKey: scopeKey) + } + } + + func resetForegroundAgentFeedIfInstanceChanged( + previousDeviceID: String?, + previousTag: String?, + newDeviceID: String?, + newTag: String? + ) { + guard let newDeviceID, !newDeviceID.isEmpty, + previousDeviceID == newDeviceID, + !macInstanceTagAuthority.sameStoredAuthority(previousTag, newTag) else { return } + removeAgentFeedSnapshot(ownerKey: newDeviceID) + } + + private func awaitAgentFeedCacheClearIfNeeded() async { + guard let task = agentFeedCacheClearTask, + let token = agentFeedCacheClearToken else { return } + await task.value + guard agentFeedCacheClearToken == token else { return } + agentFeedCacheClearTask = nil + agentFeedCacheClearToken = nil + } + + private func agentFeedWorkstreamKey(_ item: MobileAgentFeedItem) -> String { + "\(item.macDeviceID)|\(item.macInstanceTag ?? "")|\(item.wire.workstreamID)" + } +} + +private extension MobileWorkstreamFeedPayload { + var requestID: String? { + switch self { + case .permission(let id, _, _, _), .exitPlan(let id, _, _, _), .question(let id, _): id + case .boolean(let id, _, _, _, _), .form(let id, _, _, _): id + default: nil + } + } +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+DeeplinkNavigation.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+DeeplinkNavigation.swift index 8061914b37a..7be4b15cebb 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+DeeplinkNavigation.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+DeeplinkNavigation.swift @@ -13,6 +13,7 @@ public import Foundation public enum DeeplinkWorkspaceNavigationOrigin: Equatable, Sendable { case external case notificationFeed + case agentFeed } public struct DeeplinkWorkspaceNavigationRequest: Equatable, Sendable { diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+HiddenMacs.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+HiddenMacs.swift index fc62d02a505..ef921bed390 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+HiddenMacs.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+HiddenMacs.swift @@ -733,6 +733,10 @@ extension MobileShellComposite { } workspacesByMac[ownerKey] = nil removeNotificationFeedSnapshot(macDeviceID: pairingID) + removeAgentFeedSnapshot( + ownerKey: pairingID, + scopeKey: pairedMacScopeKey(scope) + ) } // The foreground pairing's feed snapshot may live under the bare // DEVICE key. Hiding it while a sibling pairing stays visible skips @@ -740,11 +744,19 @@ extension MobileShellComposite { if let foregroundPairingID, targetPairingIDs.contains(foregroundPairingID) { let identity = MobilePairedMac.pairingIdentity(from: foregroundPairingID) removeNotificationFeedSnapshot(macDeviceID: identity.macDeviceID) + removeAgentFeedSnapshot( + ownerKey: identity.macDeviceID, + scopeKey: pairedMacScopeKey(scope) + ) } let fullyHiddenPhysicalIDs = targetPhysicalIDs.subtracting(remainingPhysicalIDs) for id in fullyHiddenPhysicalIDs { pruneWorkspaceStateForHiddenMac(id) removeNotificationFeedSnapshot(macDeviceID: id) + removeAgentFeedSnapshot( + ownerKey: id, + scopeKey: pairedMacScopeKey(scope) + ) } guard !Task.isCancelled, diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+SecondaryPromotion.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+SecondaryPromotion.swift index b9fc87e52c0..e4fc1590d1c 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+SecondaryPromotion.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+SecondaryPromotion.swift @@ -72,7 +72,7 @@ extension MobileShellComposite { return } connection.client.retire() - Task { await connection.client.disconnect() } + await connection.client.disconnect() return } await installControlConnection(from: connection) @@ -86,7 +86,7 @@ extension MobileShellComposite { removeControlCapability(ifMatching: connection) removeFocusedConnection(ifMatching: connection) connection.client.retire() - Task { await connection.client.disconnect() } + await connection.client.disconnect() return } let existing = secondaryMacSubscriptions[connection.ownerKey] @@ -647,6 +647,7 @@ extension MobileShellComposite { removeNotificationFeedSnapshot( macDeviceID: previousForegroundID ) + removeAgentFeedSnapshot(ownerKey: previousForegroundID) } } else { removeControlCapability( @@ -667,7 +668,7 @@ extension MobileShellComposite { // to demote. Retire synchronously before replacing `remoteClient`; // the asynchronous close removes all of their server registrations. unregisteredPreviousClient.retire() - Task { await unregisteredPreviousClient.disconnect() } + await unregisteredPreviousClient.disconnect() } let liveConnectionGeneration = adoptPooledRemoteClient(sub.client) activeTicket = sub.ticket @@ -677,12 +678,19 @@ extension MobileShellComposite { // and a sibling switch must not reuse the old build's device-keyed // revision floor. removeNotificationFeedSnapshot(macDeviceID: ownerKey.pairingID) + removeAgentFeedSnapshot(ownerKey: ownerKey.pairingID) resetForegroundNotificationFeedIfInstanceChanged( previousDeviceID: previousForegroundID, previousTag: previousForegroundTag, newDeviceID: macID, newTag: activeMacInstanceTag ) + resetForegroundAgentFeedIfInstanceChanged( + previousDeviceID: previousForegroundID, + previousTag: previousForegroundTag, + newDeviceID: macID, + newTag: activeMacInstanceTag + ) connectedHostName = placeholderHostName(for: sub.ticket, firstRoute: sub.route) foregroundMacDeviceID = macID // The control entry's aggregate state was keyed by the STORED tag. diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 958b8f9ced7..299cf660021 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -58,7 +58,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { return [ "workspace.updated", "mobile.sync.delta", "terminal.bytes", "terminal.render_grid", "terminal.set_font", - "notification.dismissed", "notification.badge", "notification.feed.changed", + "notification.dismissed", "notification.badge", "notification.feed.changed", "workstream.feed.changed", "phone_push.status.changed", "browser.frame", "browser.state", "browser.closed", "browser.dialog", "browser.dialog.resolved", "simulator.frame", "simulator.state", "simulator.closed", @@ -67,7 +67,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { return [ "workspace.updated", "mobile.sync.delta", "terminal.render_grid", "terminal.set_font", - "notification.dismissed", "notification.badge", "notification.feed.changed", + "notification.dismissed", "notification.badge", "notification.feed.changed", "workstream.feed.changed", "phone_push.status.changed", "browser.frame", "browser.state", "browser.closed", "browser.dialog", "browser.dialog.resolved", "simulator.frame", "simulator.state", "simulator.closed", @@ -76,7 +76,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { return [ "workspace.updated", "mobile.sync.delta", "terminal.bytes", "terminal.set_font", - "notification.dismissed", "notification.badge", "notification.feed.changed", + "notification.dismissed", "notification.badge", "notification.feed.changed", "workstream.feed.changed", "phone_push.status.changed", "browser.frame", "browser.state", "browser.closed", "browser.dialog", "browser.dialog.resolved", "simulator.frame", "simulator.state", "simulator.closed", @@ -262,6 +262,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { didSet { guard oldValue != macConnectionStatus else { return } recomputeNotificationFeedItems() + recomputeAgentFeedItems() } } public internal(set) var connectedHostName: String @@ -366,6 +367,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { let newStatuses = workspacesByMac.mapValues(\.status) if oldStatuses != newStatuses { recomputeNotificationFeedItems() + recomputeAgentFeedItems() } } } @@ -392,6 +394,24 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { public internal(set) var notificationFeedStatus: MobileNotificationFeedStatus = .idle /// The number of currently retained unread notifications across all Macs. public private(set) var notificationFeedUnreadCount: Int = 0 + /// Immutable, bounded coding-agent activity aggregated across every Mac. + public internal(set) var agentFeedItems: [MobileAgentFeedItem] = [] { + didSet { + agentFeedNeedsInputCount = MobileAgentFeedFilter.needsInput.apply(to: agentFeedItems).count + } + } + /// Count shown on the Feed tab badge. Pending requests and replyable turn-complete rows count. + public private(set) var agentFeedNeedsInputCount: Int = 0 + public internal(set) var agentFeedStatus: MobileAgentFeedStatus = .idle + /// True when at least one Mac reports older persisted Feed history. + public internal(set) var agentFeedHasMoreItems = false + /// True when an online, capable Mac can service the next history page. + public internal(set) var agentFeedCanLoadOlder = false + public internal(set) var agentFeedIsLoadingOlder = false + /// Drafts are keyed by stable item identity so list insertion/filtering never + /// attaches text to another card. + public var agentFeedDrafts: [MobileAgentFeedItemID: String] = [:] + public internal(set) var agentFeedMutationStates: [MobileAgentFeedItemID: MobileAgentFeedMutationState] = [:] /// Last authoritative chat-session snapshots, keyed by the workspace row id the UI renders. var chatSessionSnapshotsByWorkspaceID: [String: [ChatSessionDescriptor]] = [:] /// The group sections the UI renders. A materialized derivation of every @@ -1198,6 +1218,15 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { @ObservationIgnored var notificationFeedOpenTask: Task? @ObservationIgnored var notificationFeedOpenToken: UUID? let notificationFeedAggregation = MobileNotificationFeedAggregation() + @ObservationIgnored var agentFeedSnapshotsByMac: [String: AgentFeedMacSnapshot] = [:] + @ObservationIgnored var agentFeedKnownRevisionsByMac: [String: UInt64] = [:] + @ObservationIgnored let agentFeedRefreshTasks = MobileAgentFeedRefreshTaskCoalescer() + @ObservationIgnored var agentFeedFailedOwnerKeys: Set = [] + @ObservationIgnored var agentFeedCacheScopeKey: String? + @ObservationIgnored var agentFeedCacheClearTask: Task? + @ObservationIgnored var agentFeedCacheClearToken: UUID? + let agentFeedCacheStore = AgentFeedCacheStore() + let agentFeedAggregation = MobileAgentFeedAggregation() var createWorkspaceTaskID: UUID? private var createTerminalTaskID: UUID? var connectionGeneration: UUID @@ -1923,6 +1952,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { replaceRemoteClient(with: nil) cancelRemoteOperationTasks() resetNotificationFeed() + resetAgentFeed() // Tear down secondary-Mac aggregation at the account boundary: cancel any // in-flight aggregation pass and every live secondary subscription so the // previous user's Macs/workspaces cannot be re-seeded into the next @@ -2006,6 +2036,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { let foregroundKey = foregroundMacKey workspacesByMac = workspacesByMac.filter { $0.key == foregroundKey }; pruneStableMacColorSlots(keepingForegroundKey: foregroundKey.canonicalMacDeviceID) retainForegroundNotificationFeedSnapshot() + resetAgentFeedForScopeChange() // Restore memo: invalidate so the next read re-restores for the new // (account, team) scope, and a suspended old-team restore can't resume. // Invalidate the shared boundary synchronously first; actor cleanup is @@ -6046,6 +6077,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { client: subscription.client, displayName: displayName ) + scheduleSecondaryAgentFeedRefresh( + ownerKey: subscription.ownerKey.pairingID, + client: subscription.client + ) if subscription.supportedHostCapabilities.contains("events.v1") { startSecondaryEventConsumer( subscription, @@ -6152,6 +6187,12 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { fallback: displayName ) ) + } else if event.topic == "workstream.feed.changed" { + handleAgentFeedChangedEvent( + event, + ownerKey: ownerKey.pairingID, + client: client + ) } return true } @@ -6250,6 +6291,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { client: subscription.client, displayName: subscription.displayName ) + scheduleSecondaryAgentFeedRefresh( + ownerKey: ownerKey.pairingID, + client: subscription.client + ) } return } @@ -6511,6 +6556,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { client: subscription.client, displayName: subscription.displayName ) + scheduleSecondaryAgentFeedRefresh( + ownerKey: ownerKey.pairingID, + client: subscription.client + ) if subscription.refreshPending, subscription.refreshTask == nil, subscription.deferredRefreshTask == nil { @@ -9309,6 +9358,14 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { : resolvedForegroundMacID, newTag: resolvedInstanceTag ) + resetForegroundAgentFeedIfInstanceChanged( + previousDeviceID: previousForegroundDeviceIDForFeedReset, + previousTag: previousForegroundTagForFeedReset, + newDeviceID: resolvedForegroundMacID.isEmpty + ? previousForegroundDeviceIDForFeedReset + : resolvedForegroundMacID, + newTag: resolvedInstanceTag + ) // Mirror of the promotion path: the foreground refetches // its feed under the bare device key, so a secondary-era // pairing-keyed snapshot for THIS target would linger as a @@ -9322,6 +9379,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { removeNotificationFeedSnapshot( macDeviceID: takeoverPairingID ) + removeAgentFeedSnapshot(ownerKey: takeoverPairingID) } } prepareTerminalThemeRevisionAuthority( @@ -11685,6 +11743,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { return } self?.scheduleForegroundNotificationFeedRefresh(client: client) + self?.scheduleForegroundAgentFeedRefresh(client: client) let topics = outputTransport.eventTopics let stream = await client.subscribe(to: Set(topics)) // Kick off the server-side enable handshake CONCURRENTLY with @@ -11753,6 +11812,13 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { macDeviceID: macDeviceID ) ) + } else if event.topic == "workstream.feed.changed", + let macDeviceID = self.foregroundMacDeviceID ?? self.activeTicket?.macDeviceID { + self.handleAgentFeedChangedEvent( + event, + ownerKey: macDeviceID, + client: client + ) } else if event.topic == "phone_push.status.changed" { await self.refreshPhonePushStatus( client: client, diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryMacSubscription.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryMacSubscription.swift index a35f41adc36..aa500a89f40 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryMacSubscription.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryMacSubscription.swift @@ -11,6 +11,7 @@ final class SecondaryMacSubscription { static let eventTopics: Set = [ "workspace.updated", "notification.feed.changed", + "workstream.feed.changed", ] let macDeviceID: String diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/AgentFeedCacheStoreTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/AgentFeedCacheStoreTests.swift new file mode 100644 index 00000000000..453855a51a2 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/AgentFeedCacheStoreTests.swift @@ -0,0 +1,82 @@ +import Foundation +import Testing +@testable import CmuxMobileShell + +@Suite struct AgentFeedCacheStoreTests { + @Test func snapshotsAreBoundedScopedAndClearable() async throws { + let directory = FileManager.default.temporaryDirectory + .appending(path: "agent-feed-cache-\(UUID().uuidString)", directoryHint: .isDirectory) + defer { try? FileManager.default.removeItem(at: directory) } + let store = AgentFeedCacheStore(directory: directory) + let now = Date(timeIntervalSinceReferenceDate: 800_000_000) + + for index in 0..<22 { + await store.upsert( + AgentFeedCachedSnapshot( + ownerKey: "mac-\(index)", + macDeviceID: "mac-\(index)", + instanceTag: "dev", + displayName: "Mac \(index)", + responseData: Data("snapshot-\(index)".utf8), + cachedAt: now.addingTimeInterval(TimeInterval(index)) + ), + scopeKey: "user-a\tteam-a" + ) + } + + let scoped = await store.load(scopeKey: "user-a\tteam-a") + #expect(scoped.count == 20) + #expect(scoped.first?.ownerKey == "mac-21") + #expect(await store.load(scopeKey: "user-b\tteam-a").isEmpty) + + await store.remove(ownerKeys: ["mac-21", "missing"], scopeKey: "user-a\tteam-a") + let afterRemoval = await AgentFeedCacheStore(directory: directory) + .load(scopeKey: "user-a\tteam-a") + #expect(afterRemoval.count == 19) + #expect(!afterRemoval.contains { $0.ownerKey == "mac-21" }) + + await store.clear(scopeKey: "user-a\tteam-a") + #expect(await store.load(scopeKey: "user-a\tteam-a").isEmpty) + } + + @Test @MainActor func diskPayloadRemovesRawToolInput() throws { + let raw = Data(#"{"revision":1,"items":[{"tool_input":"secret","tool_input_capabilities":"capability","tool_input_summary":"command: …","tool_result":"private failure","tool_result_is_error":true,"text":"safe"}]}"#.utf8) + + let sanitized = MobileShellComposite.sanitizedAgentFeedCacheData(raw) + let root = try #require(JSONSerialization.jsonObject(with: sanitized) as? [String: Any]) + let items = try #require(root["items"] as? [[String: Any]]) + let item = try #require(items.first) + + #expect(item["tool_input"] == nil) + #expect(item["tool_input_capabilities"] == nil) + #expect(item["tool_result"] == nil) + #expect(item["tool_result_is_error"] as? Bool == true) + #expect(item["tool_input_summary"] as? String == "command: …") + #expect(item["text"] as? String == "safe") + } + + @Test func clearAllRemovesEveryAccountScope() async { + let directory = FileManager.default.temporaryDirectory + .appending(path: "agent-feed-cache-all-\(UUID().uuidString)", directoryHint: .isDirectory) + defer { try? FileManager.default.removeItem(at: directory) } + let store = AgentFeedCacheStore(directory: directory) + for scope in ["user-a\tteam-a", "user-b\tteam-b"] { + await store.upsert( + AgentFeedCachedSnapshot( + ownerKey: scope, + macDeviceID: "mac", + instanceTag: nil, + displayName: "Mac", + responseData: Data("snapshot".utf8), + cachedAt: Date(timeIntervalSinceReferenceDate: 800_000_000) + ), + scopeKey: scope + ) + } + + await store.clearAll() + + #expect(await store.load(scopeKey: "user-a\tteam-a").isEmpty) + #expect(await store.load(scopeKey: "user-b\tteam-b").isEmpty) + } +} diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitRoutingTestSupport.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitRoutingTestSupport.swift index cfcbcbb493f..9c4bba9e452 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitRoutingTestSupport.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitRoutingTestSupport.swift @@ -55,6 +55,8 @@ actor RoutingHostRouter { private(set) var directorySearchQueries: [String] = [] private(set) var dismisses: [(notificationIDs: [String], clientID: String?)] = [] private var notificationFeedMarkAllReadCount = 0 + private var workstreamFeedTotalCount = 0 + private var workstreamFeedListCursors: [String?] = [] private var workspaceCreates: [WorkspaceCreateRecord] = [] /// Reject the Nth (0-based) and later paste_image requests; `nil` accepts all. private var rejectPasteImageFromIndex: Int? @@ -197,6 +199,8 @@ actor RoutingHostRouter { } func recordedDismisses() -> [(notificationIDs: [String], clientID: String?)] { dismisses } func recordedNotificationFeedMarkAllReadCount() -> Int { notificationFeedMarkAllReadCount } + func setWorkstreamFeedTotalCount(_ count: Int) { workstreamFeedTotalCount = count } + func recordedWorkstreamFeedListCursors() -> [String?] { workstreamFeedListCursors } /// Sendable extract of the request fields the router needs, pulled off the /// non-Sendable params dictionary before crossing the Task boundary. @@ -219,6 +223,7 @@ actor RoutingHostRouter { var directoryPath: String? var directoryOffset: Int? var directoryLimit: Int? + var cursor: String? var provider: String? } @@ -431,6 +436,30 @@ actor RoutingHostRouter { "marked": 1, "revision": notificationFeedMarkAllReadCount + 100, ]) + case "workstream.feed.list": + workstreamFeedListCursors.append(info.cursor) + let end = min(Int(info.cursor ?? "") ?? workstreamFeedTotalCount, workstreamFeedTotalCount) + let start = max(0, end - 300) + let formatter = ISO8601DateFormatter() + let items: [[String: Any]] = (start.. 0 ? String(start) : NSNull(), + "has_more": start > 0, + ]) case "mobile.events.unsubscribe": return try? Self.resultFrame(id: id, result: [ "stream_id": info.streamID ?? "", @@ -525,6 +554,7 @@ private actor RoutingTransport: CmxByteTransport { directoryPath: params?["path"] as? String, directoryOffset: params?["offset"] as? Int, directoryLimit: params?["limit"] as? Int, + cursor: params?["cursor"] as? String, provider: params?["provider"] as? String ) Task { [router, weak self] in diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellAgentFeedPagingTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellAgentFeedPagingTests.swift new file mode 100644 index 00000000000..3dc5ddbe43c --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellAgentFeedPagingTests.swift @@ -0,0 +1,105 @@ +@testable import CmuxMobileShell +import CmuxMobileShellModel +import Foundation +import Testing + +@MainActor +@Suite("Mobile shell agent feed paging") +struct MobileShellAgentFeedPagingTests { + @Test("Exact navigation fails closed when the captured surface is stale") + func exactNavigationRejectsMissingSurface() async { + var workspace = MobileWorkspacePreview( + id: "workspace-row", + macDeviceID: "mac", + name: "Workspace", + terminals: [MobileTerminalPreview(id: "other-surface", name: "Other")] + ) + workspace.remoteWorkspaceID = "workspace-remote" + let store = MobileShellComposite(connectionState: .connected, workspaces: [workspace]) + store.foregroundMacDeviceID = "mac" + + let opened = await store.openAgentFeedItem(feedItem(surfaceID: "stale-surface")) + + #expect(!opened) + #expect(store.deeplinkWorkspaceNavigationRequest == nil) + #expect(store.selectedWorkspaceID == "workspace-row") + #expect(store.selectedTerminalID == "other-surface") + } + + @Test("Exact navigation selects the captured workspace and surface") + func exactNavigationSelectsSurface() async { + var workspace = MobileWorkspacePreview( + id: "workspace-row", + macDeviceID: "mac", + name: "Workspace", + terminals: [MobileTerminalPreview(id: "surface", name: "Agent")] + ) + workspace.remoteWorkspaceID = "workspace-remote" + let store = MobileShellComposite(connectionState: .connected, workspaces: [workspace]) + store.foregroundMacDeviceID = "mac" + + let opened = await store.openAgentFeedItem(feedItem(surfaceID: "surface")) + + #expect(opened) + #expect(store.selectedWorkspaceID == "workspace-row") + #expect(store.selectedTerminalID == "surface") + #expect(store.deeplinkWorkspaceNavigationRequest?.origin == .agentFeed) + } + + @Test("Phone retention limit removes paging and prevents another request") + func retentionLimitStopsRequests() async throws { + let store = MobileShellComposite() + let router = RoutingHostRouter() + await router.setWorkstreamFeedTotalCount(2_400) + try installSecondaryClient( + on: store, + macDeviceID: "feed-mac", + router: router, + supportedHostCapabilities: [MobileShellComposite.agentFeedCapability] + ) + let client = try #require(store.secondaryMacSubscriptions["feed-mac".pairingKey]?.client) + defer { Task { await client.disconnect() } } + + await store.refreshAgentFeed() + while store.agentFeedCanLoadOlder { + await store.loadOlderAgentFeed() + } + + let cursors = await router.recordedWorkstreamFeedListCursors() + #expect(cursors.count == 7) + #expect(cursors.map { $0 ?? "newest" } == [ + "newest", "2100", "1800", "1500", "1200", "900", "600", + ]) + #expect(store.agentFeedItems.count == MobileAgentFeedAggregation.maxItemCount) + #expect(Set(store.agentFeedItems.map(\.id)).count == MobileAgentFeedAggregation.maxItemCount) + #expect(!store.agentFeedHasMoreItems) + #expect(!store.agentFeedCanLoadOlder) + #expect(store.agentFeedSnapshotsByMac.values.first?.pages.reachedHistoryLimit == true) + + await store.loadOlderAgentFeed() + + #expect(await router.recordedWorkstreamFeedListCursors().count == 7) + } + + private func feedItem(surfaceID: String) -> MobileAgentFeedItem { + MobileAgentFeedItem( + macDeviceID: "mac", + macInstanceTag: nil, + macDisplayName: "Mac", + connectionStatus: .connected, + wire: MobileWorkstreamFeedListItem( + id: UUID(), + workstreamID: "agent", + source: "codex", + kind: "assistantMessage", + createdAt: Date(), + updatedAt: Date(), + title: "Agent update", + workspaceID: "workspace-remote", + surfaceID: surfaceID, + status: .telemetry, + payload: .message(text: "Done", fromUser: false) + ) + ) + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAgentFeedAction.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAgentFeedAction.swift new file mode 100644 index 00000000000..6514c434135 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAgentFeedAction.swift @@ -0,0 +1,8 @@ +/// One inline decision sent to the Mac that owns a Feed item. +public enum MobileAgentFeedAction: Equatable, Sendable { + case permission(mode: String) + case exitPlan(mode: String, feedback: String?) + case question(selections: [String]) + case boolean(value: Bool) + case form(action: String, selections: [String]) +} diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAgentFeedAggregation.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAgentFeedAggregation.swift new file mode 100644 index 00000000000..646086668a1 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAgentFeedAggregation.swift @@ -0,0 +1,25 @@ +/// Deterministic bounded aggregation for workstream snapshots from every Mac. +public struct MobileAgentFeedAggregation: Sendable { + public static let maxItemCount = 2_000 + + public init() {} + + public func items(from snapshots: [[MobileAgentFeedItem]]) -> [MobileAgentFeedItem] { + var newestByID: [MobileAgentFeedItemID: MobileAgentFeedItem] = [:] + newestByID.reserveCapacity(Self.maxItemCount) + for item in snapshots.joined() { + if let existing = newestByID[item.id], + item.wire.updatedAt <= existing.wire.updatedAt { continue } + newestByID[item.id] = item + } + return Array(newestByID.values.sorted(by: Self.precedes).prefix(Self.maxItemCount)) + } + + /// `createdAt` owns position; status-only `updatedAt` never moves a card. + public static func precedes(_ lhs: MobileAgentFeedItem, _ rhs: MobileAgentFeedItem) -> Bool { + if lhs.wire.createdAt != rhs.wire.createdAt { + return lhs.wire.createdAt > rhs.wire.createdAt + } + return lhs.id < rhs.id + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAgentFeedFilter.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAgentFeedFilter.swift new file mode 100644 index 00000000000..205b53af83a --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAgentFeedFilter.swift @@ -0,0 +1,37 @@ +/// User-selected projection over coding-agent activity. +public enum MobileAgentFeedFilter: Hashable, Sendable { + case needsInput + case allActivity + + public func apply(to items: [MobileAgentFeedItem]) -> [MobileAgentFeedItem] { + switch self { + case .needsInput: + // Aggregation supplies newest-first rows. A stop remains replyable + // only until a later event proves that workstream continued. + var seenWorkstreams: Set = [] + return items.filter { item in + let isNewestForWorkstream = seenWorkstreams.insert(item.workstreamScope).inserted + return item.wire.status.isPending + || (isNewestForWorkstream && item.isReplyableTurnCompletion) + } + case .allActivity: + return items + } + } +} + +private struct MobileAgentFeedWorkstreamScope: Hashable { + let macDeviceID: String + let macInstanceTag: String? + let workstreamID: String +} + +private extension MobileAgentFeedItem { + var workstreamScope: MobileAgentFeedWorkstreamScope { + MobileAgentFeedWorkstreamScope( + macDeviceID: macDeviceID, + macInstanceTag: macInstanceTag, + workstreamID: wire.workstreamID + ) + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAgentFeedItem.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAgentFeedItem.swift new file mode 100644 index 00000000000..f13ca57dbd9 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAgentFeedItem.swift @@ -0,0 +1,53 @@ +/// Immutable presentation and routing snapshot for one agent event. +public struct MobileAgentFeedItem: Identifiable, Equatable, Sendable { + public let id: MobileAgentFeedItemID + public let macDeviceID: String + public let macInstanceTag: String? + public let macDisplayName: String + public let connectionStatus: MobileMacConnectionStatus + public let wire: MobileWorkstreamFeedListItem + + public init( + macDeviceID: String, + macInstanceTag: String?, + macDisplayName: String, + connectionStatus: MobileMacConnectionStatus, + wire: MobileWorkstreamFeedListItem + ) { + id = MobileAgentFeedItemID( + macDeviceID: macDeviceID, + macInstanceTag: macInstanceTag, + eventID: wire.id.uuidString + ) + self.macDeviceID = macDeviceID + self.macInstanceTag = macInstanceTag + self.macDisplayName = macDisplayName + self.connectionStatus = connectionStatus + self.wire = wire + } + + /// Whether this row can represent work awaiting a response. Turn-complete + /// rows still need collection context so only the latest turn is offered. + public var isActionable: Bool { + wire.status.isPending || isReplyableTurnCompletion + } + + /// Whether this row marks a point where another prompt can continue the agent. + public var isTurnCompletion: Bool { + switch wire.payload { + case .stop: + return true + case .lifecycle: + return wire.kind == "sessionEnd" + default: + return false + } + } + + /// A completed turn can accept a reply only while it remains a live + /// telemetry boundary. Expired and already-resolved rows stay visible in + /// All Activity, but never regain an input control. + public var isReplyableTurnCompletion: Bool { + isTurnCompletion && wire.status == .telemetry + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAgentFeedItemID.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAgentFeedItemID.swift new file mode 100644 index 00000000000..364d6ad2221 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAgentFeedItemID.swift @@ -0,0 +1,18 @@ +/// Stable cross-Mac identity for one coding-agent event. +public struct MobileAgentFeedItemID: Hashable, Comparable, Sendable { + public let macDeviceID: String + public let macInstanceTag: String? + public let eventID: String + + public init(macDeviceID: String, macInstanceTag: String?, eventID: String) { + self.macDeviceID = macDeviceID + self.macInstanceTag = macInstanceTag + self.eventID = eventID + } + + public static func < (lhs: Self, rhs: Self) -> Bool { + if lhs.macDeviceID != rhs.macDeviceID { return lhs.macDeviceID < rhs.macDeviceID } + if lhs.macInstanceTag != rhs.macInstanceTag { return (lhs.macInstanceTag ?? "") < (rhs.macInstanceTag ?? "") } + return lhs.eventID < rhs.eventID + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAgentFeedMutationState.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAgentFeedMutationState.swift new file mode 100644 index 00000000000..9abd74a4cda --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAgentFeedMutationState.swift @@ -0,0 +1,9 @@ +/// Client-side state for an acknowledged inline Feed mutation. +public enum MobileAgentFeedMutationState: Equatable, Sendable { + case idle + case sending + /// The host accepted the mutation, but the next authoritative Feed list + /// has not reconciled it yet. Rows stay disabled in this state. + case awaitingReconciliation + case failed +} diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAgentFeedPageAccumulator.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAgentFeedPageAccumulator.swift new file mode 100644 index 00000000000..d1c336d690d --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAgentFeedPageAccumulator.swift @@ -0,0 +1,81 @@ +/// Per-Mac paged Feed history retained by the iOS client. +public struct MobileAgentFeedPageAccumulator: Equatable, Sendable { + /// Latest host revision represented by the accumulator. + public private(set) var revision: UInt64 + /// Deduplicated retained items in reverse chronological order. + public private(set) var items: [MobileWorkstreamFeedListItem] + /// Opaque cursor for this Mac's next older page. + public private(set) var nextCursor: String? + /// Whether this Mac retains items before the loaded pages. + public private(set) var hasMore: Bool + /// Whether at least one older page has been appended. + public private(set) var hasLoadedOlder: Bool + /// Whether paging stopped because the phone retention limit was reached. + public private(set) var reachedHistoryLimit: Bool + + /// Starts an accumulator from the host's newest page. + public init(response: MobileWorkstreamFeedListResponse) { + revision = response.revision + let merge = Self.merged(response.items) + items = merge.items + nextCursor = response.nextCursor + hasMore = response.hasMore + hasLoadedOlder = false + reachedHistoryLimit = false + applyRetentionBoundary(wasTruncated: merge.wasTruncated) + } + + /// Merges a refreshed newest page without discarding already loaded history. + public mutating func applyFirstPage(_ response: MobileWorkstreamFeedListResponse) { + revision = response.revision + let merge = Self.merged(response.items + (hasLoadedOlder ? items : [])) + items = merge.items + if !hasLoadedOlder { + nextCursor = response.nextCursor + hasMore = response.hasMore + } + applyRetentionBoundary(wasTruncated: merge.wasTruncated) + } + + /// Appends the next older page and advances this Mac's stable cursor. + public mutating func append(_ response: MobileWorkstreamFeedListResponse) { + revision = max(revision, response.revision) + let merge = Self.merged(items + response.items) + items = merge.items + nextCursor = response.nextCursor + hasMore = response.hasMore + hasLoadedOlder = true + applyRetentionBoundary(wasTruncated: merge.wasTruncated) + } + + private static func merged( + _ candidates: [MobileWorkstreamFeedListItem] + ) -> (items: [MobileWorkstreamFeedListItem], wasTruncated: Bool) { + var newestByID: [MobileWorkstreamFeedListItem.ID: MobileWorkstreamFeedListItem] = [:] + for item in candidates { + if let existing = newestByID[item.id], existing.updatedAt >= item.updatedAt { continue } + newestByID[item.id] = item + } + return ( + Array(newestByID.values.sorted(by: precedes).prefix(MobileAgentFeedAggregation.maxItemCount)), + newestByID.count > MobileAgentFeedAggregation.maxItemCount + ) + } + + private mutating func applyRetentionBoundary(wasTruncated: Bool) { + reachedHistoryLimit = reachedHistoryLimit + || wasTruncated + || (items.count == MobileAgentFeedAggregation.maxItemCount && hasMore) + guard reachedHistoryLimit else { return } + nextCursor = nil + hasMore = false + } + + private static func precedes( + _ lhs: MobileWorkstreamFeedListItem, + _ rhs: MobileWorkstreamFeedListItem + ) -> Bool { + if lhs.createdAt != rhs.createdAt { return lhs.createdAt > rhs.createdAt } + return lhs.id.uuidString < rhs.id.uuidString + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAgentFeedRefreshTaskCoalescer.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAgentFeedRefreshTaskCoalescer.swift new file mode 100644 index 00000000000..8f14ea4397a --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAgentFeedRefreshTaskCoalescer.swift @@ -0,0 +1,67 @@ +import Foundation + +/// Coalesces one in-flight Feed refresh per Mac and owns task cancellation. +@MainActor +public final class MobileAgentFeedRefreshTaskCoalescer { + private struct Entry { + let token: UUID + let task: Task + var pending = false + var operation: @MainActor @Sendable () async -> Void + } + + private var entries: [String: Entry] = [:] + + /// Creates an empty task owner. + public init() {} + + /// Number of currently owned refresh tasks. + public var activeCount: Int { entries.count } + + /// Starts a refresh unless one already exists for `ownerKey`. + @discardableResult + public func schedule( + ownerKey: String, + operation: @escaping @MainActor @Sendable () async -> Void + ) -> Task { + if var existing = entries[ownerKey] { + existing.pending = true + existing.operation = operation + entries[ownerKey] = existing + return existing.task + } + let token = UUID() + let task = Task { @MainActor [weak self] in + guard let self else { return } + await self.drain(ownerKey: ownerKey, token: token) + } + entries[ownerKey] = Entry(token: token, task: task, operation: operation) + return task + } + + private func drain(ownerKey: String, token: UUID) async { + while let entry = entries[ownerKey], entry.token == token { + await entry.operation() + guard !Task.isCancelled, + var current = entries[ownerKey], + current.token == token else { break } + if current.pending { + current.pending = false + entries[ownerKey] = current + } else { + entries[ownerKey] = nil + } + } + if entries[ownerKey]?.token == token { entries[ownerKey] = nil } + } + + /// Cancels and releases every owned refresh task. + public func cancelAll() { + for entry in entries.values { entry.task.cancel() } + entries = [:] + } + + public func cancel(ownerKey: String) { + entries.removeValue(forKey: ownerKey)?.task.cancel() + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAgentFeedStatus.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAgentFeedStatus.swift new file mode 100644 index 00000000000..cb2972f275f --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAgentFeedStatus.swift @@ -0,0 +1,12 @@ +/// Loading and capability state for the cross-Mac coding-agent Feed. +public enum MobileAgentFeedStatus: Equatable, Sendable { + case idle + case loading + case ready + case offlineCached + case partial + case reconnecting + case unavailable + case requiresMacUpdate + case failed +} diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkstreamFeedListItem.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkstreamFeedListItem.swift new file mode 100644 index 00000000000..ea289495a58 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkstreamFeedListItem.swift @@ -0,0 +1,263 @@ +public import Foundation + +/// One typed coding-agent feed row returned by `workstream.feed.list`. +public struct MobileWorkstreamFeedListItem: Decodable, Equatable, Sendable, Identifiable { + public let id: UUID + public let workstreamID: String + public let source: String + public let kind: String + public let createdAt: Date + public let updatedAt: Date + public let cwd: String? + public let title: String? + /// Last assistant text attached to a completed turn. The host sends this + /// separately from the typed stop payload so a stale/offline row can show + /// the answer instead of presenting a disabled reply composer. + public let lastAssistantMessage: String? + public let workspaceID: String? + public let surfaceID: String? + public let status: MobileWorkstreamFeedStatus + public let payload: MobileWorkstreamFeedPayload + + public init( + id: UUID, + workstreamID: String, + source: String, + kind: String, + createdAt: Date, + updatedAt: Date, + cwd: String? = nil, + title: String? = nil, + lastAssistantMessage: String? = nil, + workspaceID: String? = nil, + surfaceID: String? = nil, + status: MobileWorkstreamFeedStatus, + payload: MobileWorkstreamFeedPayload + ) { + self.id = id + self.workstreamID = workstreamID + self.source = source + self.kind = kind + self.createdAt = createdAt + self.updatedAt = updatedAt + self.cwd = cwd + self.title = title + self.lastAssistantMessage = lastAssistantMessage + self.workspaceID = workspaceID + self.surfaceID = surfaceID + self.status = status + self.payload = payload + } + + private enum CodingKeys: String, CodingKey { + case id, source, kind, status, title, cwd, decision, questions, fields, selections, mode, feedback + case lastAssistantMessage = "last_assistant_message" + case lastAssistantMessageCamel = "lastAssistantMessage" + case assistantMessage = "assistant_message" + case assistantMessageCamel = "assistantMessage" + case assistantPreamble = "assistantPreamble" + case assistantPreambleSnake = "assistant_preamble" + case workstreamID = "workstream_id" + case createdAt = "created_at" + case updatedAt = "updated_at" + case workspaceID = "workspace_id" + case surfaceID = "surface_id" + case requestID = "request_id" + case toolName = "tool_name" + case toolInput = "tool_input" + case toolInputSummary = "tool_input_summary" + case supportedModes = "supported_modes" + case plan, planSummary = "plan_summary", defaultMode = "default_mode" + case toolResult = "tool_result", toolResultIsError = "tool_result_is_error" + case text, reason + case interactionKind = "interaction_kind" + case interactionKindCamel = "interactionKind" + case booleanPrompt = "boolean_prompt" + case booleanPromptCamel = "booleanPrompt" + case booleanYesLabel = "boolean_yes_label" + case booleanYesLabelCamel = "booleanYesLabel" + case booleanNoLabel = "boolean_no_label" + case booleanNoLabelCamel = "booleanNoLabel" + case booleanDefault = "boolean_default" + case booleanDefaultCamel = "booleanDefault" + case formTitle = "form_title" + case formTitleCamel = "formTitle" + case formURL = "form_url" + case formURLCamel = "formURL" + } + + private enum DecisionKeys: String, CodingKey { case kind, mode, feedback, action, selections } + + public init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(UUID.self, forKey: .id) + workstreamID = try c.decode(String.self, forKey: .workstreamID) + source = try c.decode(String.self, forKey: .source) + kind = try c.decode(String.self, forKey: .kind) + createdAt = try Date( + c.decode(String.self, forKey: .createdAt), + strategy: .iso8601 + ) + updatedAt = try Date( + c.decode(String.self, forKey: .updatedAt), + strategy: .iso8601 + ) + cwd = try c.decodeIfPresent(String.self, forKey: .cwd) + title = try c.decodeIfPresent(String.self, forKey: .title) + var decodedAssistantMessage = try c.decodeIfPresent(String.self, forKey: .lastAssistantMessage) + if decodedAssistantMessage == nil { + decodedAssistantMessage = try c.decodeIfPresent(String.self, forKey: .lastAssistantMessageCamel) + } + if decodedAssistantMessage == nil { + decodedAssistantMessage = try c.decodeIfPresent(String.self, forKey: .assistantMessage) + } + if decodedAssistantMessage == nil { + decodedAssistantMessage = try c.decodeIfPresent(String.self, forKey: .assistantMessageCamel) + } + if decodedAssistantMessage == nil { + decodedAssistantMessage = try c.decodeIfPresent(String.self, forKey: .assistantPreamble) + } + if decodedAssistantMessage == nil { + decodedAssistantMessage = try c.decodeIfPresent(String.self, forKey: .assistantPreambleSnake) + } + lastAssistantMessage = decodedAssistantMessage + workspaceID = try c.decodeIfPresent(String.self, forKey: .workspaceID) + surfaceID = try c.decodeIfPresent(String.self, forKey: .surfaceID) + + let statusRaw = try c.decode(String.self, forKey: .status) + switch statusRaw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "pending": status = .pending + case "expired": status = .expired + case "telemetry": status = .telemetry + case "resolved": status = .resolved(decision: Self.decodeDecision(c)) + default: status = .unknown(statusRaw) + } + + let requestID = try c.decodeIfPresent(String.self, forKey: .requestID) + let normalizedKind = Self.normalizedToken(kind) + switch normalizedKind { + case "permissionrequest": + guard let requestID else { throw Self.missing("request_id", decoder) } + payload = .permission( + requestID: requestID, + toolName: try c.decodeIfPresent(String.self, forKey: .toolName) ?? "", + safeInput: try c.decodeIfPresent(String.self, forKey: .toolInputSummary) ?? "", + supportedModes: try c.decodeIfPresent([String].self, forKey: .supportedModes) ?? [] + ) + case "exitplan": + guard let requestID else { throw Self.missing("request_id", decoder) } + payload = .exitPlan( + requestID: requestID, + plan: try c.decodeIfPresent(String.self, forKey: .plan) ?? "", + summary: try c.decodeIfPresent(String.self, forKey: .planSummary), + defaultMode: try c.decodeIfPresent(String.self, forKey: .defaultMode) ?? "manual" + ) + case "question", "boolean", "confirmation", "approval", "form", "elicitation", + "questionrequest", "questionasked", "questionv2asked", "askuserquestion", "askuserconfirmation", "booleanquestion", + "elicitationrequest", "mcpelicitation", "mcpserverelicitationrequest", + "requestuserinput", "userinputrequest", "inputrequest", "toolrequestuserinput", + "itemtoolrequestuserinput": + guard let requestID else { throw Self.missing("request_id", decoder) } + let questions = try c.decodeIfPresent([MobileWorkstreamQuestion].self, forKey: .questions) + ?? c.decodeIfPresent([MobileWorkstreamQuestion].self, forKey: .fields) + ?? [] + let rawInteractionKind = try c.decodeIfPresent(String.self, forKey: .interactionKind) + ?? c.decodeIfPresent(String.self, forKey: .interactionKindCamel) + let interactionKind = rawInteractionKind.map(Self.normalizedToken) + ?? (["boolean", "confirmation", "approval", "booleanquestion"].contains(normalizedKind) ? "boolean" : nil) + ?? (["form", "elicitation", "elicitationrequest", "mcpelicitation", "mcpserverelicitationrequest"].contains(normalizedKind) ? "form" : nil) + if interactionKind == "boolean" { + let first = questions.first + let defaultValue = try c.decodeIfPresent(Bool.self, forKey: .booleanDefault) + ?? c.decodeIfPresent(Bool.self, forKey: .booleanDefaultCamel) + ?? first?.defaultValue.flatMap(Self.decodeBool) + payload = .boolean( + requestID: requestID, + prompt: try c.decodeIfPresent(String.self, forKey: .booleanPrompt) + ?? c.decodeIfPresent(String.self, forKey: .booleanPromptCamel) + ?? first?.prompt + ?? "", + yesLabel: try c.decodeIfPresent(String.self, forKey: .booleanYesLabel) + ?? c.decodeIfPresent(String.self, forKey: .booleanYesLabelCamel) + ?? first?.options.first?.label + ?? "", + noLabel: try c.decodeIfPresent(String.self, forKey: .booleanNoLabel) + ?? c.decodeIfPresent(String.self, forKey: .booleanNoLabelCamel) + ?? first?.options.dropFirst().first?.label + ?? "", + defaultValue: defaultValue + ) + } else if interactionKind == "form" { + payload = .form( + requestID: requestID, + title: try c.decodeIfPresent(String.self, forKey: .formTitle) + ?? c.decodeIfPresent(String.self, forKey: .formTitleCamel), + fields: questions, + externalURL: try c.decodeIfPresent(String.self, forKey: .formURL) + ?? c.decodeIfPresent(String.self, forKey: .formURLCamel) + ?? questions.compactMap(\.externalURL).first + ) + } else { + payload = .question(requestID: requestID, questions: questions) + } + case "tooluse": + payload = .toolUse( + name: try c.decodeIfPresent(String.self, forKey: .toolName) ?? "", + input: try c.decodeIfPresent(String.self, forKey: .toolInput) ?? "" + ) + case "toolresult": + payload = .toolResult( + name: try c.decodeIfPresent(String.self, forKey: .toolName) ?? "", + result: try c.decodeIfPresent(String.self, forKey: .toolResult) ?? "", + isError: try c.decodeIfPresent(Bool.self, forKey: .toolResultIsError) ?? false + ) + case "userprompt", "assistantmessage": + payload = .message( + text: try c.decodeIfPresent(String.self, forKey: .text) ?? "", + fromUser: normalizedKind == "userprompt" + ) + case "stop": payload = .stop(reason: try c.decodeIfPresent(String.self, forKey: .reason)) + case "todos": payload = .todos + case "sessionstart", "sessionend": payload = .lifecycle + default: payload = .unknown(kind: kind) + } + } + + private static func decodeDecision(_ c: KeyedDecodingContainer) -> MobileWorkstreamDecision? { + guard let nested = try? c.nestedContainer(keyedBy: DecisionKeys.self, forKey: .decision), + let kind = try? nested.decode(String.self, forKey: .kind) else { return nil } + switch kind { + case "permission": return .permission(mode: (try? nested.decode(String.self, forKey: .mode)) ?? "") + case "exit_plan": return .exitPlan( + mode: (try? nested.decode(String.self, forKey: .mode)) ?? "", + feedback: try? nested.decodeIfPresent(String.self, forKey: .feedback) + ) + case "question": return .question(selections: (try? nested.decode([String].self, forKey: .selections)) ?? []) + case "form": return .form( + action: (try? nested.decode(String.self, forKey: .action)) ?? "accept", + selections: (try? nested.decode([String].self, forKey: .selections)) ?? [] + ) + default: return .unknown(kind: kind) + } + } + + private static func decodeBool(_ value: String) -> Bool? { + switch value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "1", "true", "yes", "y", "on": return true + case "0", "false", "no", "n", "off": return false + default: return nil + } + } + + private static func normalizedToken(_ value: String) -> String { + value.unicodeScalars + .filter { CharacterSet.alphanumerics.contains($0) } + .map(String.init) + .joined() + .lowercased() + } + + private static func missing(_ field: String, _ decoder: any Decoder) -> DecodingError { + .dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Missing \(field)")) + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkstreamFeedListResponse.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkstreamFeedListResponse.swift new file mode 100644 index 00000000000..bdd4de59cf5 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkstreamFeedListResponse.swift @@ -0,0 +1,55 @@ +public import Foundation + +/// Authoritative revisioned coding-agent feed snapshot from one Mac. +public struct MobileWorkstreamFeedListResponse: Decodable, Equatable, Sendable { + /// Host revision represented by this page. + public let revision: UInt64 + /// Feed items in reverse chronological order. + public let items: [MobileWorkstreamFeedListItem] + /// Opaque cursor for the next older page. + public let nextCursor: String? + /// Whether the host retains items before this page. + public let hasMore: Bool + + /// Creates a decoded Feed page. + public init( + revision: UInt64, + items: [MobileWorkstreamFeedListItem], + nextCursor: String?, + hasMore: Bool + ) { + self.revision = revision + self.items = items + self.nextCursor = nextCursor + self.hasMore = hasMore + } + + private enum CodingKeys: String, CodingKey { + case revision, items + case nextCursor = "next_cursor" + case hasMore = "has_more" + } + + /// Decodes a Feed page, accepting legacy responses without paging fields. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + revision = try container.decode(UInt64.self, forKey: .revision) + items = try container.decode([MobileWorkstreamFeedListItem].self, forKey: .items) + nextCursor = try container.decodeIfPresent(String.self, forKey: .nextCursor) + hasMore = try container.decodeIfPresent(Bool.self, forKey: .hasMore) ?? false + } + + /// Decodes an authenticated RPC result envelope. + public static func decode(_ data: Data) throws -> Self { + try JSONDecoder().decode(Self.self, from: data) + } +} + +/// Revision-only invalidation for `workstream.feed.changed`. +public struct MobileWorkstreamFeedChangedEvent: Decodable, Equatable, Sendable { + public let revision: UInt64 + + public static func decode(_ data: Data) -> Self? { + try? JSONDecoder().decode(Self.self, from: data) + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkstreamFeedPayload.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkstreamFeedPayload.swift new file mode 100644 index 00000000000..ffe97e99af6 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkstreamFeedPayload.swift @@ -0,0 +1,233 @@ +import Foundation + +/// One answer option in an AskUserQuestion payload. +public struct MobileWorkstreamQuestionOption: Decodable, Equatable, Sendable, Identifiable { + public let id: String + public let label: String + public let description: String? + + public init(id: String, label: String, description: String? = nil) { + self.id = id + self.label = label + self.description = description + } + + private enum CodingKeys: String, CodingKey { + case id, label, title, description, detail + } + + public init(from decoder: any Decoder) throws { + if let single = try? decoder.singleValueContainer(), + let label = try? single.decode(String.self) { + let normalized = label.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty else { + throw DecodingError.dataCorruptedError( + in: single, + debugDescription: "Question option label must not be empty" + ) + } + id = normalized + self.label = normalized + description = nil + return + } + let container = try decoder.container(keyedBy: CodingKeys.self) + let decodedID = try container.decodeIfPresent(String.self, forKey: .id) + let decodedLabel = try ( + container.decodeIfPresent(String.self, forKey: .label) + ?? container.decodeIfPresent(String.self, forKey: .title) + ) + let resolvedLabel = decodedLabel?.trimmingCharacters(in: .whitespacesAndNewlines) + guard let resolvedLabel, !resolvedLabel.isEmpty else { + throw DecodingError.dataCorrupted( + .init(codingPath: decoder.codingPath, debugDescription: "Question option is missing a label") + ) + } + id = decodedID?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + ? decodedID! + : resolvedLabel + label = resolvedLabel + description = try ( + container.decodeIfPresent(String.self, forKey: .description) + ?? container.decodeIfPresent(String.self, forKey: .detail) + ) + } +} + +/// One prompt in a multi-question AskUserQuestion request. +public struct MobileWorkstreamQuestion: Decodable, Equatable, Sendable, Identifiable { + public let id: String + public let header: String? + public let prompt: String + public let multiSelect: Bool + public let options: [MobileWorkstreamQuestionOption] + public let allowsOther: Bool? + public let inputType: String? + public let required: Bool? + public let defaultValue: String? + public let placeholder: String? + public let externalURL: String? + public let minimum: Double? + public let maximum: Double? + public let minLength: Int? + public let maxLength: Int? + public let minSelections: Int? + public let maxSelections: Int? + + public init( + id: String, + header: String? = nil, + prompt: String, + multiSelect: Bool, + options: [MobileWorkstreamQuestionOption], + allowsOther: Bool? = nil, + inputType: String? = nil, + required: Bool? = nil, + defaultValue: String? = nil, + placeholder: String? = nil, + externalURL: String? = nil, + minimum: Double? = nil, + maximum: Double? = nil, + minLength: Int? = nil, + maxLength: Int? = nil, + minSelections: Int? = nil, + maxSelections: Int? = nil + ) { + self.id = id + self.header = header + self.prompt = prompt + self.multiSelect = multiSelect + self.options = options + self.allowsOther = allowsOther + self.inputType = inputType + self.required = required + self.defaultValue = defaultValue + self.placeholder = placeholder + self.externalURL = externalURL + self.minimum = minimum + self.maximum = maximum + self.minLength = minLength + self.maxLength = maxLength + self.minSelections = minSelections + self.maxSelections = maxSelections + } + + private enum CodingKeys: String, CodingKey { + case id, header, prompt, question, title, description, options, required, placeholder + case multiSelect = "multi_select" + case multiSelectCamel = "multiSelect" + case inputType = "input_type" + case inputTypeCamel = "inputType" + case defaultValue = "default_value" + case defaultValueCamel = "defaultValue" + case externalURL = "external_url" + case externalURLCamel = "externalURL" + case allowsOther = "allows_other" + case allowsOtherCamel = "allowsOther" + case isOther = "is_other" + case isOtherCamel = "isOther" + case minimum, maximum + case minLength = "min_length" + case minLengthCamel = "minLength" + case maxLength = "max_length" + case maxLengthCamel = "maxLength" + case minSelections = "min_selections" + case minSelectionsCamel = "minSelections" + case maxSelections = "max_selections" + case maxSelectionsCamel = "maxSelections" + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let decodedID = try container.decode(String.self, forKey: .id) + id = decodedID + header = try ( + container.decodeIfPresent(String.self, forKey: .header) + ?? container.decodeIfPresent(String.self, forKey: .title) + ) + let decodedPrompt = try ( + container.decodeIfPresent(String.self, forKey: .prompt) + ?? container.decodeIfPresent(String.self, forKey: .question) + ) + let decodedPlaceholder = try ( + container.decodeIfPresent(String.self, forKey: .placeholder) + ?? container.decodeIfPresent(String.self, forKey: .description) + ) + prompt = decodedPrompt?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + ? decodedPrompt! + : (decodedPlaceholder?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + ? decodedPlaceholder! + : decodedID) + multiSelect = try container.decodeIfPresent(Bool.self, forKey: .multiSelect) + ?? container.decodeIfPresent(Bool.self, forKey: .multiSelectCamel) + ?? false + options = try container.decodeIfPresent([MobileWorkstreamQuestionOption].self, forKey: .options) ?? [] + inputType = try container.decodeIfPresent(String.self, forKey: .inputType) + ?? container.decodeIfPresent(String.self, forKey: .inputTypeCamel) + required = try container.decodeIfPresent(Bool.self, forKey: .required) + allowsOther = try container.decodeIfPresent(Bool.self, forKey: .allowsOther) + ?? container.decodeIfPresent(Bool.self, forKey: .allowsOtherCamel) + ?? container.decodeIfPresent(Bool.self, forKey: .isOther) + ?? container.decodeIfPresent(Bool.self, forKey: .isOtherCamel) + defaultValue = try Self.decodeScalarString(container, forKey: .defaultValue) + ?? Self.decodeScalarString(container, forKey: .defaultValueCamel) + placeholder = try container.decodeIfPresent(String.self, forKey: .placeholder) + externalURL = try container.decodeIfPresent(String.self, forKey: .externalURL) + ?? container.decodeIfPresent(String.self, forKey: .externalURLCamel) + minimum = try container.decodeIfPresent(Double.self, forKey: .minimum) + maximum = try container.decodeIfPresent(Double.self, forKey: .maximum) + minLength = try container.decodeIfPresent(Int.self, forKey: .minLength) + ?? container.decodeIfPresent(Int.self, forKey: .minLengthCamel) + maxLength = try container.decodeIfPresent(Int.self, forKey: .maxLength) + ?? container.decodeIfPresent(Int.self, forKey: .maxLengthCamel) + minSelections = try container.decodeIfPresent(Int.self, forKey: .minSelections) + ?? container.decodeIfPresent(Int.self, forKey: .minSelectionsCamel) + maxSelections = try container.decodeIfPresent(Int.self, forKey: .maxSelections) + ?? container.decodeIfPresent(Int.self, forKey: .maxSelectionsCamel) + } + + private static func decodeScalarString( + _ container: KeyedDecodingContainer, + forKey key: CodingKeys + ) throws -> String? { + if let value = try container.decodeIfPresent(String.self, forKey: key) { return value } + if let value = try container.decodeIfPresent(Bool.self, forKey: key) { return value ? "true" : "false" } + if let value = try container.decodeIfPresent(Double.self, forKey: key) { + return value.rounded() == value ? String(Int(value)) : String(value) + } + return nil + } +} + +/// Forward-compatible typed presentation payload for one workstream event. +public enum MobileWorkstreamFeedPayload: Equatable, Sendable { + case permission(requestID: String, toolName: String, safeInput: String, supportedModes: [String]) + case exitPlan(requestID: String, plan: String, summary: String?, defaultMode: String) + case question(requestID: String, questions: [MobileWorkstreamQuestion]) + /// A yes/no or confirmation primitive. It uses the same authoritative + /// question reply channel as AskUserQuestion, but renders a dedicated + /// two-choice control on mobile. + case boolean( + requestID: String, + prompt: String, + yesLabel: String, + noLabel: String, + defaultValue: Bool? + ) + /// A structured form or MCP elicitation request. Fields retain their + /// input type so the client can render text, numeric, URL, secret, and + /// choice controls without knowing the originating agent. + case form( + requestID: String, + title: String?, + fields: [MobileWorkstreamQuestion], + externalURL: String? + ) + case toolUse(name: String, input: String) + case toolResult(name: String, result: String, isError: Bool) + case message(text: String, fromUser: Bool) + case stop(reason: String?) + case todos + case lifecycle + case unknown(kind: String) +} diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkstreamFeedStatus.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkstreamFeedStatus.swift new file mode 100644 index 00000000000..24a1bf7f2d2 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkstreamFeedStatus.swift @@ -0,0 +1,25 @@ +import Foundation + +/// Authoritative lifecycle state for one coding-agent feed item. +public enum MobileWorkstreamFeedStatus: Equatable, Sendable { + case pending + case resolved(decision: MobileWorkstreamDecision?) + case expired + case telemetry + case unknown(String) + + /// Whether the item still blocks an agent for user input. + public var isPending: Bool { + if case .pending = self { return true } + return false + } +} + +/// A resolved decision preserved for display and reconciliation. +public enum MobileWorkstreamDecision: Equatable, Sendable { + case permission(mode: String) + case exitPlan(mode: String, feedback: String?) + case question(selections: [String]) + case form(action: String, selections: [String]) + case unknown(kind: String) +} diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileAgentFeedTests.swift b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileAgentFeedTests.swift new file mode 100644 index 00000000000..8ce43d2404e --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileAgentFeedTests.swift @@ -0,0 +1,453 @@ +import Foundation +import Testing +@testable import CmuxMobileShellModel + +struct MobileAgentFeedTests { + @Test func unknownSourceKindAndStatusRemainInspectable() throws { + let row = try decodeRow( + id: "00000000-0000-0000-0000-000000000001", + source: "future-agent", + kind: "future-event", + status: "future-status" + ) + + #expect(row.source == "future-agent") + #expect(row.payload == .unknown(kind: "future-event")) + #expect(row.status == .unknown("future-status")) + } + + @Test func actionableRowsRequireAnExactRequestID() { + #expect(throws: DecodingError.self) { + try decodeRow( + id: "00000000-0000-0000-0000-000000000002", + kind: "permissionRequest", + status: "pending" + ) + } + } + + @Test func permissionPayloadExposesOnlyTheRedactedSummary() throws { + let row = try decodeRow( + id: "00000000-0000-0000-0000-000000000003", + kind: "permissionRequest", + status: "pending", + extra: "\"request_id\":\"request-3\",\"tool_name\":\"Bash\",\"tool_input\":\"secret-token\",\"tool_input_summary\":\"keys: command\",\"supported_modes\":[\"once\",\"deny\"]" + ) + + #expect(row.payload == .permission( + requestID: "request-3", + toolName: "Bash", + safeInput: "keys: command", + supportedModes: ["once", "deny"] + )) + } + + @Test func booleanAndFormPrimitivesDecodeIntoInlinePayloads() throws { + let boolean = try decodeRow( + id: "00000000-0000-0000-0000-000000000009", + kind: "boolean", + status: "pending", + extra: "\"request_id\":\"bool-9\",\"boolean_prompt\":\"Continue?\",\"boolean_yes_label\":\"Continue\",\"boolean_no_label\":\"Stop\",\"boolean_default\":true,\"questions\":[{\"id\":\"q0\",\"prompt\":\"Continue?\",\"multi_select\":false,\"input_type\":\"boolean\",\"options\":[{\"id\":\"yes\",\"label\":\"Continue\"},{\"id\":\"no\",\"label\":\"Stop\"}]}]" + ) + #expect(boolean.payload == .boolean( + requestID: "bool-9", + prompt: "Continue?", + yesLabel: "Continue", + noLabel: "Stop", + defaultValue: true + )) + + let form = try decodeRow( + id: "00000000-0000-0000-0000-000000000010", + kind: "form", + status: "pending", + extra: "\"request_id\":\"form-10\",\"form_title\":\"Release\",\"form_url\":\"https://example.com/form\",\"questions\":[{\"id\":\"branch\",\"prompt\":\"Branch\",\"multi_select\":false,\"input_type\":\"text\",\"required\":true,\"min_length\":2,\"max_length\":20},{\"id\":\"targets\",\"prompt\":\"Targets\",\"multi_select\":true,\"input_type\":\"choice\",\"min_selections\":1,\"max_selections\":2,\"options\":[{\"id\":\"ios\",\"label\":\"iOS\"}]}]" + ) + guard case .form(let requestID, let title, let fields, let externalURL) = form.payload else { + Issue.record("Expected form payload") + return + } + #expect(requestID == "form-10") + #expect(title == "Release") + #expect(fields.first?.inputType == "text") + #expect(fields.first?.minLength == 2) + #expect(fields.first?.maxLength == 20) + #expect(fields.last?.minSelections == 1) + #expect(fields.last?.maxSelections == 2) + #expect(externalURL == "https://example.com/form") + } + + @Test func resolvedFormActionRemainsInspectable() throws { + let row = try decodeRow( + id: "00000000-0000-0000-0000-000000000012", + source: "codex", + kind: "form", + status: "resolved", + extra: "\"request_id\":\"form-12\",\"questions\":[],\"decision\":{\"kind\":\"form\",\"action\":\"decline\",\"selections\":[]}" + ) + #expect(row.status == .resolved(decision: .form(action: "decline", selections: []))) + } + + @Test func appServerQuestionAliasesAndOtherMetadataDecode() throws { + let row = try decodeRow( + id: "00000000-0000-0000-0000-000000000011", + kind: "item/tool/requestUserInput", + status: "pending", + extra: "\"request_id\":\"codex-11\",\"questions\":[{\"id\":\"mode\",\"header\":\"Mode\",\"question\":\"Choose\",\"is_other\":false,\"options\":[{\"label\":\"Fast\",\"description\":\"Quick\"}]}]" + ) + guard case .question(let requestID, let questions) = row.payload else { + Issue.record("Expected question payload") + return + } + #expect(requestID == "codex-11") + #expect(questions.first?.allowsOther == false) + #expect(questions.first?.options.first?.id == "Fast") + } + + @Test func rowPreservesExactWorkspaceAndSurfaceRoute() throws { + let row = try decodeRow( + id: "00000000-0000-0000-0000-000000000006", + extra: "\"text\":\"working\",\"workspace_id\":\"workspace-exact\",\"surface_id\":\"surface-exact\"" + ) + + #expect(row.workspaceID == "workspace-exact") + #expect(row.surfaceID == "surface-exact") + } + + @Test func completedTurnPreservesLastAssistantMessage() throws { + let row = try decodeRow( + id: "00000000-0000-0000-0000-000000000013", + kind: "stop", + extra: #""reason":"waiting","last_assistant_message":"The patch is ready.""# + ) + + #expect(row.lastAssistantMessage == "The patch is ready.") + } + + @Test func legacyListResponseDefaultsToNoOlderHistory() throws { + let data = Data(""" + {"revision":1,"items":[]} + """.utf8) + let response = try MobileWorkstreamFeedListResponse.decode(data) + + #expect(!response.hasMore) + #expect(response.nextCursor == nil) + } + + @Test func aggregationIsStableBoundedAndDeduplicated() throws { + let oldest = try item(id: 1, mac: "mac-a", createdAt: "2026-08-09T10:00:00Z") + let newest = try item(id: 2, mac: "mac-b", createdAt: "2026-08-09T12:00:00Z") + let updatedWithoutReordering = try item( + id: 1, + mac: "mac-a", + createdAt: "2026-08-09T10:00:00Z", + updatedAt: "2026-08-09T13:00:00Z", + status: "resolved" + ) + + let rows = MobileAgentFeedAggregation().items(from: [[oldest, newest], [updatedWithoutReordering]]) + + #expect(rows.map(\.id) == [newest.id, updatedWithoutReordering.id]) + #expect(rows.last?.wire.status == .resolved(decision: nil)) + } + + @Test func needsInputProjectionIncludesPendingDecisionsAndLatestCompletedTurns() throws { + let pending = try item(id: 4, mac: "mac-a", status: "pending", kind: "question", extra: "\"request_id\":\"request-4\",\"questions\":[]") + let activity = try item(id: 5, mac: "mac-b") + let stoppedTurn = try item( + id: 6, + mac: "mac-c", + kind: "stop", + extra: "\"reason\":\"Waiting for the next instruction\"" + ) + let endedSession = try item( + id: 7, + mac: "mac-d", + kind: "sessionEnd", + extra: "\"reason\":null" + ) + let resumedTurn = try item( + id: 8, + mac: "mac-c", + createdAt: "2026-08-09T12:00:00Z" + ) + + let items = [resumedTurn, pending, activity, stoppedTurn, endedSession] + + #expect( + MobileAgentFeedFilter.needsInput.apply(to: items).map(\.id) + == [pending.id, endedSession.id] + ) + #expect(MobileAgentFeedFilter.allActivity.apply(to: items).count == 5) + } + + @Test func aggregationCapsTwoThousandRowsAcrossTenAgents() throws { + var snapshots: [[MobileAgentFeedItem]] = [] + for macIndex in 0..<10 { + snapshots.append(try (0..<205).map { rowIndex in + try item(id: macIndex * 205 + rowIndex + 10, mac: "mac-\(macIndex)") + }) + } + + let rows = MobileAgentFeedAggregation().items(from: snapshots) + + #expect(rows.count == MobileAgentFeedAggregation.maxItemCount) + #expect(Set(rows.map(\.macDeviceID)).count == 10) + } + + @Test func perMacPagingMergesTwoPagesWithoutDuplicatesAndExhaustsIndependently() throws { + let macAFirst = try response(ids: 301...600, revision: 1, cursor: "a-301", hasMore: true) + let macASecond = try response(ids: 1...301, revision: 1, cursor: nil, hasMore: false) + let macBFirst = try response(ids: 901...1_200, revision: 7, cursor: "b-901", hasMore: true) + let macBSecond = try response(ids: 601...901, revision: 7, cursor: nil, hasMore: false) + var macA = MobileAgentFeedPageAccumulator(response: macAFirst) + var macB = MobileAgentFeedPageAccumulator(response: macBFirst) + + macA.append(macASecond) + macB.append(macBSecond) + + #expect(macA.items.count == 600) + #expect(macB.items.count == 600) + #expect(Set(macA.items.map(\.id)).count == 600) + #expect(Set(macB.items.map(\.id)).count == 600) + #expect(!macA.hasMore && macA.nextCursor == nil) + #expect(!macB.hasMore && macB.nextCursor == nil) + + let aggregated = MobileAgentFeedAggregation().items(from: [ + macA.items.map { mobileItem($0, mac: "mac-a") }, + macB.items.map { mobileItem($0, mac: "mac-b") }, + ]) + #expect(aggregated.count == 1_200) + #expect(Set(aggregated.map(\.id)).count == 1_200) + #expect(Set(aggregated.map(\.macDeviceID)) == ["mac-a", "mac-b"]) + } + + @Test func firstPageRefreshPreservesAlreadyLoadedOlderRows() throws { + var pages = MobileAgentFeedPageAccumulator( + response: try response(ids: 301...600, revision: 1, cursor: "301", hasMore: true) + ) + pages.append(try response(ids: 1...301, revision: 1, cursor: nil, hasMore: false)) + pages.applyFirstPage(try response(ids: 302...601, revision: 2, cursor: "302", hasMore: true)) + + #expect(pages.items.count == 601) + #expect(Set(pages.items.map(\.id)).count == 601) + #expect(!pages.hasMore) + #expect(pages.nextCursor == nil) + } + + @Test func pagingStopsAtPhoneHistoryLimitWithoutRequestingDiscardedRows() throws { + var oldestLoadedID = 2_101 + var requestCount = 1 + var pages = MobileAgentFeedPageAccumulator( + response: try hostResponse(ids: oldestLoadedID...2_400) + ) + + while pages.hasMore { + let pageOldestID = oldestLoadedID - 300 + pages.append(try hostResponse(ids: pageOldestID...(oldestLoadedID - 1))) + oldestLoadedID = pageOldestID + requestCount += 1 + } + + #expect(requestCount == 7) + #expect(pages.items.count == MobileAgentFeedAggregation.maxItemCount) + #expect(Set(pages.items.map(\.id)).count == MobileAgentFeedAggregation.maxItemCount) + let expectedIDs = Set((401...2_400).compactMap { UUID(uuidString: feedItemID($0)) }) + #expect(Set(pages.items.map(\.id)) == expectedIDs) + #expect(pages.reachedHistoryLimit) + #expect(!pages.hasMore) + #expect(pages.nextCursor == nil) + } + + @MainActor + @Test func repeatedInvalidationsCoalesceAndLeaveNoRefreshTasks() async { + let coalescer = MobileAgentFeedRefreshTaskCoalescer() + var tasks: [Task] = [] + for _ in 0..<100 { + tasks.append(coalescer.schedule(ownerKey: "mac-a") {}) + } + for task in tasks { await task.value } + + #expect(coalescer.activeCount == 0) + } + + @MainActor + @Test func invalidationDuringRefreshRunsOneTrailingRefresh() async { + let coalescer = MobileAgentFeedRefreshTaskCoalescer() + let gate = AgentFeedRefreshGate() + var refreshCount = 0 + let first = coalescer.schedule(ownerKey: "mac-a") { + refreshCount += 1 + await gate.wait() + } + await Task.yield() + let trailing = coalescer.schedule(ownerKey: "mac-a") { + refreshCount += 1 + } + #expect(coalescer.activeCount == 1) + await gate.release() + await first.value + await trailing.value + + #expect(refreshCount == 2) + #expect(coalescer.activeCount == 0) + } + + @MainActor + @Test func cancellingOwnerDropsItsPendingTrailingRefresh() async { + let coalescer = MobileAgentFeedRefreshTaskCoalescer() + let gate = AgentFeedRefreshGate() + var trailingRuns = 0 + let first = coalescer.schedule(ownerKey: "mac-a") { await gate.wait() } + await Task.yield() + _ = coalescer.schedule(ownerKey: "mac-a") { trailingRuns += 1 } + + coalescer.cancel(ownerKey: "mac-a") + await gate.release() + await first.value + + #expect(trailingRuns == 0) + #expect(coalescer.activeCount == 0) + } + + @Test func aggregationBenchmarkReportsIncreasingInputSizes() throws { + var elapsedBySize: [Int: Double] = [:] + for size in [300, 1_200, 2_400, 4_800] { + var snapshots = Array(repeating: [MobileAgentFeedItem](), count: 12) + for index in 0.. MobileAgentFeedItem { + let row = try decodeRow( + id: String(format: "00000000-0000-0000-0000-%012d", id), + kind: kind, + status: status, + createdAt: createdAt, + updatedAt: updatedAt ?? createdAt, + extra: extra + ) + return MobileAgentFeedItem( + macDeviceID: mac, + macInstanceTag: "dev", + macDisplayName: mac, + connectionStatus: .connected, + wire: row + ) + } + + private func response( + ids: ClosedRange, + revision: UInt64, + cursor: String?, + hasMore: Bool + ) throws -> MobileWorkstreamFeedListResponse { + try MobileWorkstreamFeedListResponse( + revision: revision, + items: ids.map { index in + try decodeRow( + id: String(format: "00000000-0000-0000-0000-%012d", index), + createdAt: "2026-08-09T11:\(String(format: "%02d", index % 60)):00Z", + updatedAt: "2026-08-09T11:\(String(format: "%02d", index % 60)):00Z" + ) + }, + nextCursor: cursor, + hasMore: hasMore + ) + } + + private func hostResponse(ids: ClosedRange) throws -> MobileWorkstreamFeedListResponse { + let formatter = ISO8601DateFormatter() + return try MobileWorkstreamFeedListResponse( + revision: 1, + items: ids.map { index in + let timestamp = formatter.string(from: Date(timeIntervalSince1970: TimeInterval(index))) + return try decodeRow( + id: feedItemID(index), + createdAt: timestamp, + updatedAt: timestamp + ) + }, + nextCursor: ids.lowerBound > 1 ? String(ids.lowerBound - 1) : nil, + hasMore: ids.lowerBound > 1 + ) + } + + private func feedItemID(_ index: Int) -> String { + String(format: "00000000-0000-0000-0000-%012d", index) + } + + private func mobileItem(_ wire: MobileWorkstreamFeedListItem, mac: String) -> MobileAgentFeedItem { + MobileAgentFeedItem( + macDeviceID: mac, + macInstanceTag: "dev", + macDisplayName: mac, + connectionStatus: .connected, + wire: wire + ) + } + + private func decodeRow( + id: String, + source: String = "codex", + kind: String = "assistantMessage", + status: String = "telemetry", + createdAt: String = "2026-08-09T11:00:00Z", + updatedAt: String = "2026-08-09T11:00:00Z", + extra: String? = "\"text\":\"working\"" + ) throws -> MobileWorkstreamFeedListItem { + let suffix = extra.map { ",\($0)" } ?? "" + let data = Data(""" + {"id":"\(id)","workstream_id":"agent-1","source":"\(source)","kind":"\(kind)","created_at":"\(createdAt)","updated_at":"\(updatedAt)","status":"\(status)"\(suffix)} + """.utf8) + return try JSONDecoder().decode(MobileWorkstreamFeedListItem.self, from: data) + } +} + +private actor AgentFeedRefreshGate { + private var released = false + private var waiters: [CheckedContinuation] = [] + + func wait() async { + guard !released else { return } + await withCheckedContinuation { waiters.append($0) } + } + + func release() { + released = true + let current = waiters + waiters.removeAll() + current.forEach { $0.resume() } + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentFeedL10n.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentFeedL10n.swift new file mode 100644 index 00000000000..42fbd07e16e --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentFeedL10n.swift @@ -0,0 +1,26 @@ +import CmuxMobileSupport +import Foundation +import SwiftUI + +struct AgentFeedLocalizer { + let bundle: Bundle + + init(bundle: Bundle = .module) { + self.bundle = bundle + } + + func string(_ key: StaticString, defaultValue: String.LocalizationValue) -> String { + L10n.string(key, defaultValue: defaultValue, bundle: bundle) + } +} + +private struct AgentFeedLocalizerKey: EnvironmentKey { + static let defaultValue = AgentFeedLocalizer() +} + +extension EnvironmentValues { + var agentFeedLocalizer: AgentFeedLocalizer { + get { self[AgentFeedLocalizerKey.self] } + set { self[AgentFeedLocalizerKey.self] = newValue } + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentFeedRow.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentFeedRow.swift new file mode 100644 index 00000000000..0d7aa6879af --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentFeedRow.swift @@ -0,0 +1,1149 @@ +#if os(iOS) +import CmuxMobileShellModel +import CmuxMobileSupport +import SwiftUI + +struct AgentFeedRowActions { + let setExpanded: @MainActor (Bool) -> Void + let setDraft: @MainActor (String) -> Void + let setPlanFeedback: @MainActor (String) -> Void + let setQuestionSelection: @MainActor (String, Set) -> Void + let setOtherAnswer: @MainActor (String, String) -> Void + let setFormValue: @MainActor (String, String) -> Void + let reply: @MainActor () -> Void + let decide: @MainActor (MobileAgentFeedAction) -> Void + let open: @MainActor () -> Void +} + +struct AgentFeedRow: View, Equatable { + let item: MobileAgentFeedItem + let design: MobileAgentFeedDesign + let requiresResponse: Bool + let isExpanded: Bool + let draft: String + let mutationState: MobileAgentFeedMutationState + let interactionsEnabled: Bool + let planFeedback: String + let questionSelections: [String: Set] + let otherAnswers: [String: String] + let formValues: [String: String] + let actions: AgentFeedRowActions + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @Environment(\.agentFeedLocalizer) private var localizer + private var copy: AgentFeedRowCopy { AgentFeedRowCopy(localizer: localizer) } + + nonisolated static func == (lhs: Self, rhs: Self) -> Bool { + lhs.item == rhs.item + && lhs.design == rhs.design + && lhs.requiresResponse == rhs.requiresResponse + && lhs.isExpanded == rhs.isExpanded + && lhs.draft == rhs.draft + && lhs.mutationState == rhs.mutationState + && lhs.interactionsEnabled == rhs.interactionsEnabled + && lhs.planFeedback == rhs.planFeedback + && lhs.questionSelections == rhs.questionSelections + && lhs.otherAnswers == rhs.otherAnswers + && lhs.formValues == rhs.formValues + } + + var body: some View { + AgentFeedRowChrome( + design: design, + sourceLabel: copy.sourceLabel(item.wire.source), + isActionable: requiresResponse && interactionsEnabled, + actionNeededLabel: localizer.string( + "mobile.agentFeed.chrome.actionNeeded", + defaultValue: "Action needed" + ), + activityLabel: localizer.string( + "mobile.agentFeed.chrome.activity", + defaultValue: "Activity" + ) + ) { + VStack(alignment: .leading, spacing: design == .compact ? 6 : 10) { + DisclosureGroup( + isExpanded: Binding( + get: { isExpanded }, + set: { newValue in actions.setExpanded(newValue) } + ) + ) { + actionArea + } label: { + VStack(alignment: .leading, spacing: design == .compact ? 6 : 10) { + AgentFeedRowHeader( + item: item, + design: design, + requiresResponse: requiresResponse, + localizer: localizer + ) + AgentFeedContext( + item: item, + isExpanded: isExpanded, + requiresResponse: requiresResponse, + interactionsEnabled: interactionsEnabled, + localizer: localizer + ) + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .ignore) + .accessibilityLabel(disclosureAccessibilityLabel) + .accessibilityIdentifier("MobileAgentFeedExpand-\(suffix)") + } + .frame(maxWidth: .infinity, alignment: .leading) + + footer + } + .padding(.vertical, design == .compact ? 2 : 8) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("MobileAgentFeedCard-\(suffix)") + } + + @ViewBuilder + private var footer: some View { + if dynamicTypeSize.isAccessibilitySize { + VStack(alignment: .leading, spacing: 4) { + mutationCaption + openAgentControl + } + } else { + HStack { + mutationCaption + Spacer() + openAgentControl + } + } + } + + @ViewBuilder + private var openAgentControl: some View { + if item.wire.workspaceID != nil, item.wire.surfaceID != nil { + Button(action: actions.open) { + Label( + localizer.string("mobile.agentFeed.openAgent", defaultValue: "Open Agent"), + systemImage: "terminal" + ) + } + .frame(minHeight: 44) + .buttonStyle(.borderless) + .disabled(!interactionsEnabled) + .accessibilityIdentifier("MobileAgentFeedOpenAgent-\(suffix)") + } else { + Text(localizer.string("mobile.agentFeed.targetUnavailable", defaultValue: "Agent location unavailable")) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + @ViewBuilder + private var actionArea: some View { + VStack(alignment: .leading, spacing: 10) { + if requiresResponse && !interactionsEnabled && !item.isTurnCompletion { + Text(localizer.string( + "mobile.agentFeed.card.responseUnavailable", + defaultValue: "No response. This request is no longer available." + )) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("MobileAgentFeedResponseUnavailable-\(suffix)") + } + + switch item.wire.payload { + case .permission(_, let toolName, let safeInput, let supportedModes) + where requiresResponse: + VStack(alignment: .leading, spacing: 8) { + Text(toolName).font(.headline) + if !safeInput.isEmpty { Text(safeInput).font(.caption).foregroundStyle(.secondary) } + if supportedModes.isEmpty { + Text(localizer.string( + "mobile.agentFeed.permission.malformed", + defaultValue: "No inline permission options were provided. Open Agent to respond." + )) + .foregroundStyle(.secondary) + } else { + ViewThatFits { + HStack { permissionButtons(supportedModes) } + VStack(alignment: .leading) { permissionButtons(supportedModes) } + } + } + } + case .exitPlan(_, let plan, let summary, _) where requiresResponse: + VStack(alignment: .leading, spacing: 8) { + if let summary { Text(summary).font(.headline) } + Text(plan).font(.body).textSelection(.enabled) + TextField( + localizer.string("mobile.agentFeed.plan.feedback", defaultValue: "Request changes"), + text: Binding(get: { planFeedback }, set: { actions.setPlanFeedback($0) }), + axis: .vertical + ) + .lineLimit(2...6) + .disabled(!interactionsEnabled || isSending) + .accessibilityIdentifier("MobileAgentFeedPlanFeedback-\(suffix)") + ViewThatFits { + HStack { planButtons } + VStack(alignment: .leading) { planButtons } + } + } + case .question(_, let questions) where requiresResponse: + if questions.isEmpty { + Text(localizer.string("mobile.agentFeed.question.malformed", defaultValue: "This question could not be displayed. Open the agent to respond.")) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 14) { + ForEach(questions) { question in questionView(question) } + Button(localizer.string("mobile.agentFeed.question.submit", defaultValue: "Submit Answers")) { + actions.decide(.question(selections: encodedQuestionAnswers(questions))) + } + .disabled(!interactionsEnabled || isSending || !questionsAreValid(questions)) + .frame(minHeight: 44) + .buttonStyle(.borderless) + .accessibilityIdentifier("MobileAgentFeedQuestionSubmit-\(suffix)") + } + } + case .boolean(_, let prompt, let yesLabel, let noLabel, let defaultValue) where requiresResponse: + let resolvedYesLabel = booleanLabel(yesLabel, value: true) + let resolvedNoLabel = booleanLabel(noLabel, value: false) + VStack(alignment: .leading, spacing: 10) { + Text(prompt).font(.headline) + ViewThatFits { + HStack { + booleanButton(label: resolvedNoLabel, value: false, systemImage: "xmark.circle") + booleanButton(label: resolvedYesLabel, value: true, systemImage: "checkmark.circle") + } + VStack(alignment: .leading) { + booleanButton(label: resolvedNoLabel, value: false, systemImage: "xmark.circle") + booleanButton(label: resolvedYesLabel, value: true, systemImage: "checkmark.circle") + } + } + if let defaultValue { + Text(defaultValue ? resolvedYesLabel : resolvedNoLabel) + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityHidden(true) + } + } + case .form(_, let title, let fields, let externalURL) where requiresResponse: + formView(title: title, fields: fields, externalURL: externalURL) + case .stop where requiresResponse: + turnCompletionActionArea + case .lifecycle where item.isTurnCompletion && requiresResponse: + turnCompletionActionArea + case .unknown where requiresResponse: + Text(localizer.string( + "mobile.agentFeed.action.unsupported", + defaultValue: "This request needs a newer version of cmux. Open Agent to respond." + )) + .foregroundStyle(.secondary) + default: + EmptyView() + } + } + } + + @ViewBuilder + private func permissionButtons(_ modes: [String]) -> some View { + ForEach(modes, id: \.self) { mode in + Button(copy.permissionModeLabel(mode, source: item.wire.source)) { + actions.decide(.permission(mode: mode)) + } + .disabled(!interactionsEnabled || isSending || !item.wire.status.isPending) + .frame(minHeight: 44) + .buttonStyle(.borderless) + .accessibilityIdentifier("MobileAgentFeedPermission-\(mode)-\(suffix)") + } + } + + @ViewBuilder + private func booleanButton(label: String, value: Bool, systemImage: String) -> some View { + Button { + actions.decide(.boolean(value: value)) + } label: { + Label(label, systemImage: systemImage) + .frame(maxWidth: .infinity, minHeight: 44) + } + .disabled(!interactionsEnabled || isSending || !item.wire.status.isPending) + .buttonStyle(.borderless) + .accessibilityIdentifier("MobileAgentFeedBoolean-\(value)-\(suffix)") + } + + @ViewBuilder + private func formView( + title: String?, + fields: [MobileWorkstreamQuestion], + externalURL: String? + ) -> some View { + VStack(alignment: .leading, spacing: 12) { + if let title, !title.isEmpty { Text(title).font(.headline) } + if fields.isEmpty { + Text(localizer.string( + "mobile.agentFeed.form.malformed", + defaultValue: "This form could not be displayed. Open the agent to respond." + )) + .foregroundStyle(.secondary) + } else { + ForEach(fields) { field in formFieldView(field) } + } + if let externalURL, let url = safeExternalURL(externalURL) { + Link(destination: url) { + Label( + localizer.string("mobile.agentFeed.form.open", defaultValue: "Open form"), + systemImage: "arrow.up.right.square" + ) + } + .frame(minHeight: 44) + } + if fields.contains(where: { normalizedInputType($0.inputType) != "external" }) { + Button( + localizer.string("mobile.agentFeed.form.submit", defaultValue: "Submit Form") + ) { + actions.decide(.form( + action: "accept", + selections: formSelectionsForSubmission(fields) + )) + } + .disabled(!interactionsEnabled || isSending || fields.isEmpty || !formIsValid(fields)) + .frame(minHeight: 44) + .buttonStyle(.borderless) + .accessibilityIdentifier("MobileAgentFeedFormSubmit-\(suffix)") + } + if item.wire.source.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "codex" { + ViewThatFits { + HStack { formDismissalButtons } + VStack(alignment: .leading) { formDismissalButtons } + } + } + } + } + + @ViewBuilder + private var formDismissalButtons: some View { + Button(localizer.string("mobile.agentFeed.form.decline", defaultValue: "Decline")) { + actions.decide(.form(action: "decline", selections: [])) + } + .disabled(!interactionsEnabled || isSending || !item.wire.status.isPending) + .frame(minHeight: 44) + .buttonStyle(.borderless) + .accessibilityIdentifier("MobileAgentFeedFormDecline-\(suffix)") + + Button(localizer.string("mobile.agentFeed.form.cancel", defaultValue: "Cancel")) { + actions.decide(.form(action: "cancel", selections: [])) + } + .disabled(!interactionsEnabled || isSending || !item.wire.status.isPending) + .frame(minHeight: 44) + .buttonStyle(.borderless) + .accessibilityIdentifier("MobileAgentFeedFormCancel-\(suffix)") + } + + @ViewBuilder + private func formFieldView(_ field: MobileWorkstreamQuestion) -> some View { + let inputType = normalizedInputType(field.inputType) + VStack(alignment: .leading, spacing: 6) { + Text(field.prompt).font(.subheadline.weight(.semibold)) + switch inputType { + case "external": + EmptyView() + case "choice": + ForEach(field.options) { option in + Button { + var selections = questionSelections[field.id] ?? [] + if field.multiSelect { + if selections.contains(option.id) { + selections.remove(option.id) + } else { + selections.insert(option.id) + } + } else { + selections = [option.id] + } + actions.setQuestionSelection(field.id, selections) + } label: { + HStack { + Image( + systemName: formSelectionSymbol(field, option.id) + ) + Text(option.label) + Spacer() + } + } + .buttonStyle(.plain) + .frame(minHeight: 44) + .disabled(!interactionsEnabled || isSending) + } + case "boolean": + Toggle( + field.placeholder ?? localizer.string("mobile.agentFeed.form.boolean", defaultValue: "Enable"), + isOn: Binding( + get: { Self.decodeBool(formValues[field.id] ?? field.defaultValue ?? "") ?? false }, + set: { actions.setFormValue(field.id, $0 ? "true" : "false") } + ) + ) + .disabled(!interactionsEnabled || isSending) + case "secret": + SecureField( + field.placeholder ?? localizer.string("mobile.agentFeed.form.value", defaultValue: "Value"), + text: Binding( + get: { formValues[field.id] ?? field.defaultValue ?? "" }, + set: { actions.setFormValue(field.id, $0) } + ) + ) + .disabled(!interactionsEnabled || isSending) + default: + TextField( + field.placeholder ?? localizer.string("mobile.agentFeed.form.value", defaultValue: "Value"), + text: Binding( + get: { formValues[field.id] ?? field.defaultValue ?? "" }, + set: { actions.setFormValue(field.id, $0) } + ), + axis: .vertical + ) + .lineLimit(1...4) + .textInputAutocapitalization(inputType == "url" ? .never : .sentences) + .disabled(!interactionsEnabled || isSending) + } + } + } + + private func normalizedInputType(_ raw: String?) -> String { + switch raw?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "choice", "select", "enum", "radio", "multiselect", "multi_select": return "choice" + case "boolean", "bool", "confirmation", "confirm", "yes_no": return "boolean" + case "number", "decimal": return "number" + case "integer", "int": return "integer" + case "url", "uri": return "url" + case "email": return "email" + case "date": return "date" + case "datetime", "date_time", "date-time": return "date_time" + case "secret", "password": return "secret" + case "external", "external_url", "link": return "external" + default: return "text" + } + } + + private func safeExternalURL(_ raw: String) -> URL? { + guard let url = URL(string: raw.trimmingCharacters(in: .whitespacesAndNewlines)), + let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https" else { return nil } + return url + } + + private func formIsValid(_ fields: [MobileWorkstreamQuestion]) -> Bool { + fields.allSatisfy(fieldValueIsValid) + } + + private func fieldValueIsValid(_ field: MobileWorkstreamQuestion) -> Bool { + let inputType = normalizedInputType(field.inputType) + // URL-mode elicitation is completed by the agent server after the + // external page closes. It is not a value the Feed form submits. + if inputType == "external" { return true } + if inputType == "choice" { + let selections = questionSelections[field.id] + ?? field.defaultValue.map { Set([$0]) } + ?? [] + guard !selections.isEmpty || field.required == false, + field.multiSelect || selections.count <= 1, + field.minSelections.map({ selections.count >= $0 }) ?? true, + field.maxSelections.map({ selections.count <= $0 }) ?? true else { return false } + let optionIDs = Set(field.options.map(\.id)) + return selections.allSatisfy(optionIDs.contains) + } + let value = (formValues[field.id] + ?? field.defaultValue + ?? (inputType == "boolean" ? "false" : "")) + .trimmingCharacters(in: .whitespacesAndNewlines) + if value.isEmpty { + return field.required == false + } + switch inputType { + case "boolean": + return Self.decodeBool(value) != nil + case "number": + guard let number = Double(value) else { return false } + return number.isFinite + && (field.minimum.map { number >= $0 } ?? true) + && (field.maximum.map { number <= $0 } ?? true) + case "integer": + guard let number = Double(value) else { return false } + return number.isFinite + && number.rounded(.towardZero) == number + && (field.minimum.map { number >= $0 } ?? true) + && (field.maximum.map { number <= $0 } ?? true) + case "url": + guard let url = URL(string: value), + let scheme = url.scheme?.lowercased() else { return false } + return !scheme.isEmpty && stringLengthIsValid(value, field: field) + case "email": + let pieces = value.split(separator: "@", omittingEmptySubsequences: false) + return pieces.count == 2 + && !pieces[0].isEmpty + && !pieces[1].isEmpty + && !value.contains(where: \.isWhitespace) + && stringLengthIsValid(value, field: field) + case "date": + return validISODate(value) && stringLengthIsValid(value, field: field) + case "date_time": + return ISO8601DateFormatter().date(from: value) != nil + && stringLengthIsValid(value, field: field) + default: + return stringLengthIsValid(value, field: field) + } + } + + private func formSelectionsForSubmission( + _ fields: [MobileWorkstreamQuestion] + ) -> [String] { + fields.flatMap { field -> [String] in + let inputType = normalizedInputType(field.inputType) + if inputType == "external" { return [] } + if inputType == "choice" { + let selections = questionSelections[field.id] + ?? field.defaultValue.map { Set([$0]) } + ?? [] + return selections.sorted().map { "\(field.id)=\($0)" } + } + let value = formValues[field.id] + ?? field.defaultValue + ?? (inputType == "boolean" ? "false" : "") + return value.isEmpty ? [] : ["\(field.id)=\(value)"] + } + } + + private func formSelectionSymbol( + _ field: MobileWorkstreamQuestion, + _ optionID: String + ) -> String { + let selections = questionSelections[field.id] + ?? field.defaultValue.map { Set([$0]) } + ?? [] + let selected = selections.contains(optionID) + if field.multiSelect { return selected ? "checkmark.square.fill" : "square" } + return selected ? "largecircle.fill.circle" : "circle" + } + + private func stringLengthIsValid( + _ value: String, + field: MobileWorkstreamQuestion + ) -> Bool { + (field.minLength.map { value.count >= $0 } ?? true) + && (field.maxLength.map { value.count <= $0 } ?? true) + } + + private func validISODate(_ value: String) -> Bool { + let pieces = value.split(separator: "-", omittingEmptySubsequences: false) + guard value.count == 10, + pieces.count == 3, + pieces[0].count == 4, + pieces[1].count == 2, + pieces[2].count == 2, + let year = Int(pieces[0]), + let month = Int(pieces[1]), + let day = Int(pieces[2]) else { return false } + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar.date(from: DateComponents(year: year, month: month, day: day)) != nil + } + + @ViewBuilder + private var planButtons: some View { + ForEach(["ultraplan", "bypassPermissions", "autoAccept", "manual", "deny"], id: \.self) { mode in + Button(copy.planModeLabel(mode)) { + actions.decide(.exitPlan( + mode: mode, + feedback: planFeedback.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : planFeedback + )) + } + .disabled(!interactionsEnabled || isSending || !item.wire.status.isPending) + .frame(minHeight: 44) + .buttonStyle(.borderless) + .accessibilityIdentifier("MobileAgentFeedPlan-\(mode)-\(suffix)") + } + } + + private var replyComposer: some View { + VStack(alignment: .leading, spacing: 8) { + TextField( + localizer.string("mobile.agentFeed.reply.placeholder", defaultValue: "Reply to this agent"), + text: Binding(get: { draft }, set: { actions.setDraft($0) }), + axis: .vertical + ) + .lineLimit(2...8) + .disabled(!interactionsEnabled || isSending) + .accessibilityIdentifier("MobileAgentFeedReplyComposer-\(suffix)") + Button(localizer.string("mobile.agentFeed.reply.send", defaultValue: "Send Reply"), action: actions.reply) + .disabled(!interactionsEnabled || isSending || draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || item.wire.surfaceID == nil) + .frame(minHeight: 44) + .buttonStyle(.borderless) + .accessibilityIdentifier("MobileAgentFeedReplySubmit-\(suffix)") + } + } + + /// A completed turn is only replyable while its exact route is live. When + /// the row came from cache, a disconnected Mac, or a stale route, do not + /// leave a disabled editor on screen. Show the final answer when the host + /// captured one, otherwise make the absence of a response explicit. + @ViewBuilder + private var turnCompletionActionArea: some View { + VStack(alignment: .leading, spacing: 8) { + if interactionsEnabled { + replyComposer + } else if item.wire.lastAssistantMessage == nil { + Text(localizer.string( + "mobile.agentFeed.card.noResponse", + defaultValue: "No response. The agent stopped waiting." + )) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } + + private func questionView(_ question: MobileWorkstreamQuestion) -> some View { + VStack(alignment: .leading, spacing: 8) { + if question.inputType != nil, + normalizedInputType(question.inputType) != "choice" { + formFieldView(question) + } else { + if let header = question.header { Text(header).font(.caption.weight(.semibold)) } + Text(question.prompt).font(.headline) + ForEach(question.options) { option in + Button { + var selections = questionSelections[question.id] ?? [] + if question.multiSelect { + if selections.contains(option.id) { selections.remove(option.id) } else { selections.insert(option.id) } + } else { + selections = [option.id] + } + actions.setQuestionSelection(question.id, selections) + } label: { + HStack(alignment: .top) { + Image(systemName: selectionSymbol(question, option.id)) + VStack(alignment: .leading) { + Text(option.label) + if let description = option.description { + Text(description).font(.caption).foregroundStyle(.secondary) + } + } + } + } + .buttonStyle(.plain) + .frame(minHeight: 44) + .disabled(!interactionsEnabled || isSending) + .accessibilityIdentifier("MobileAgentFeedQuestion-\(question.id)-\(option.id)-\(suffix)") + } + if question.allowsOther != false { + TextField( + localizer.string("mobile.agentFeed.question.other", defaultValue: "Other"), + text: Binding( + get: { otherAnswers[question.id] ?? "" }, + set: { actions.setOtherAnswer(question.id, $0) } + ), + axis: .vertical + ) + .lineLimit(1...4) + .disabled(!interactionsEnabled || isSending) + .accessibilityIdentifier("MobileAgentFeedQuestionOther-\(question.id)-\(suffix)") + } + } + } + } + + private func selectionSymbol(_ question: MobileWorkstreamQuestion, _ optionID: String) -> String { + let selected = questionSelections[question.id]?.contains(optionID) == true + if question.multiSelect { return selected ? "checkmark.square.fill" : "square" } + return selected ? "largecircle.fill.circle" : "circle" + } + + private func questionsAreValid(_ questions: [MobileWorkstreamQuestion]) -> Bool { + questions.allSatisfy { question in + if question.inputType != nil, + normalizedInputType(question.inputType) != "choice" { + return fieldValueIsValid(question) + } + let selectedCount = (questionSelections[question.id] ?? []).count + let hasOther = !(otherAnswers[question.id] ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + let answerCount = selectedCount + (hasOther ? 1 : 0) + if answerCount == 0 { + return question.required == false && (question.minSelections ?? 0) == 0 + } + guard question.multiSelect || answerCount == 1, + question.minSelections.map({ answerCount >= $0 }) ?? true, + question.maxSelections.map({ answerCount <= $0 }) ?? true else { return false } + let optionIDs = Set(question.options.map(\.id)) + let selectedAreValid = (questionSelections[question.id] ?? []).allSatisfy(optionIDs.contains) + return selectedAreValid && (!hasOther || question.allowsOther != false) + } + } + + private func encodedQuestionAnswers(_ questions: [MobileWorkstreamQuestion]) -> [String] { + questions.flatMap { question in + if question.inputType != nil, + normalizedInputType(question.inputType) != "choice" { + let inputType = normalizedInputType(question.inputType) + let value = (formValues[question.id] + ?? question.defaultValue + ?? (inputType == "boolean" ? "false" : "")) + .trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? [] : ["\(question.id)=\(value)"] + } + let selected = (questionSelections[question.id] ?? []).sorted().map { "\(question.id)=\($0)" } + let other = (otherAnswers[question.id] ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + return selected + (other.isEmpty ? [] : ["\(question.id)=other:\(other)"]) + } + } + + private var isSending: Bool { + switch mutationState { + case .sending, .awaitingReconciliation: return true + default: return false + } + } + + @ViewBuilder private var mutationCaption: some View { + switch mutationState { + case .idle: EmptyView() + case .sending: + Label(localizer.string("mobile.agentFeed.status.sending", defaultValue: "Sending…"), systemImage: "paperplane") + .font(.caption) + .accessibilityIdentifier("MobileAgentFeedSending-\(suffix)") + case .awaitingReconciliation: + Label( + localizer.string("mobile.agentFeed.status.reconciling", defaultValue: "Sent. Waiting for agent."), + systemImage: "arrow.triangle.2.circlepath" + ) + .font(.caption) + .accessibilityIdentifier("MobileAgentFeedReconciling-\(suffix)") + case .failed: + Label(localizer.string("mobile.agentFeed.status.failed", defaultValue: "Failed. Try again."), systemImage: "exclamationmark.circle") + .font(.caption).foregroundStyle(.red) + .accessibilityIdentifier("MobileAgentFeedFailed-\(suffix)") + } + } + + private var suffix: String { "\(item.macDeviceID)-\(item.wire.id.uuidString)" } + + private static func decodeBool(_ value: String) -> Bool? { + switch value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "1", "true", "yes", "y", "on": return true + case "0", "false", "no", "n", "off": return false + default: return nil + } + } + + private func booleanLabel(_ label: String, value: Bool) -> String { + guard label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return label } + return value + ? localizer.string("mobile.agentFeed.boolean.yes", defaultValue: "Yes") + : localizer.string("mobile.agentFeed.boolean.no", defaultValue: "No") + } + + private var disclosureAccessibilityLabel: String { + [ + copy.sourceLabel(item.wire.source), + copy.statusLabel(for: item, requiresResponse: requiresResponse), + item.macDisplayName, + item.connectionStatus.label, + item.wire.workstreamID, + copy.workspaceRouteLabel(item.wire.workspaceID), + copy.surfaceRouteLabel(item.wire.surfaceID), + item.wire.title, + copy.payloadSummary(item.wire.payload), + copy.resolutionLabel( + item.wire.status, + payload: item.wire.payload, + source: item.wire.source + ), + completionAccessibilityText, + item.wire.createdAt.formatted(.relative(presentation: .named, unitsStyle: .abbreviated)), + ] + .compactMap { $0 } + .formatted() + } + + private var completionAccessibilityText: String? { + guard item.isTurnCompletion else { return nil } + if let message = item.wire.lastAssistantMessage { + return message + } + guard !interactionsEnabled else { return nil } + return localizer.string( + "mobile.agentFeed.card.noResponse", + defaultValue: "No response. The agent stopped waiting." + ) + } +} + +private struct AgentFeedRowHeader: View { + let item: MobileAgentFeedItem + let design: MobileAgentFeedDesign + let requiresResponse: Bool + let localizer: AgentFeedLocalizer + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + private var copy: AgentFeedRowCopy { AgentFeedRowCopy(localizer: localizer) } + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: requiresResponse ? "exclamationmark.bubble.fill" : "bubble.left.and.text.bubble.right") + .font(design == .compact ? .caption : .body) + VStack(alignment: .leading) { + if dynamicTypeSize.isAccessibilitySize { + VStack(alignment: .leading, spacing: 5) { + sourceLabel + statusLabel + } + } else { + HStack { + sourceLabel + statusLabel + } + } + Text(computerContext) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(dynamicTypeSize.isAccessibilitySize ? nil : 2) + .fixedSize(horizontal: false, vertical: dynamicTypeSize.isAccessibilitySize) + if dynamicTypeSize.isAccessibilitySize { relativeTime } + } + Spacer(minLength: 8) + if !dynamicTypeSize.isAccessibilitySize { relativeTime } + } + } + + private var sourceLabel: some View { + Text(copy.sourceLabel(item.wire.source)) + .font(.headline) + .fixedSize(horizontal: false, vertical: true) + } + + private var statusLabel: some View { + Text(copy.statusLabel(for: item, requiresResponse: requiresResponse)) + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(.secondary.opacity(0.12), in: Capsule()) + .fixedSize(horizontal: false, vertical: true) + } + + private var computerContext: String { + localizer.string( + "mobile.agentFeed.card.computerContext", + defaultValue: "\(item.macDisplayName) · \(item.connectionStatus.label) · \(item.wire.workstreamID)" + ) + } + + private var relativeTime: some View { + Text(item.wire.createdAt, format: .relative(presentation: .named, unitsStyle: .abbreviated)) + .font(.caption2) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } +} + +private struct AgentFeedContext: View { + let item: MobileAgentFeedItem + let isExpanded: Bool + let requiresResponse: Bool + let interactionsEnabled: Bool + let localizer: AgentFeedLocalizer + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + private var copy: AgentFeedRowCopy { AgentFeedRowCopy(localizer: localizer) } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + if let title = item.wire.title { + Text(title) + .font(.subheadline.weight(.semibold)) + .fixedSize(horizontal: false, vertical: true) + } + if item.isTurnCompletion, + (!requiresResponse || !interactionsEnabled), + item.wire.lastAssistantMessage == nil { + Text(localizer.string( + "mobile.agentFeed.card.noResponse", + defaultValue: "No response. The agent stopped waiting." + )) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } else { + Text(copy.payloadSummary(item.wire.payload)) + .font(.subheadline) + .lineLimit(isExpanded || dynamicTypeSize.isAccessibilitySize ? nil : 4) + .fixedSize(horizontal: false, vertical: true) + } + if let resolution = copy.resolutionLabel( + item.wire.status, + payload: item.wire.payload, + source: item.wire.source + ) { + Text(resolution) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } + if item.isTurnCompletion, + let lastAssistantMessage = item.wire.lastAssistantMessage { + VStack(alignment: .leading, spacing: 4) { + Text(localizer.string( + "mobile.agentFeed.card.lastResponse", + defaultValue: "Last response" + )) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Text(lastAssistantMessage) + .textSelection(.enabled) + .lineLimit(isExpanded || dynamicTypeSize.isAccessibilitySize ? nil : 6) + .fixedSize(horizontal: false, vertical: true) + } + } + if isExpanded, let cwd = item.wire.cwd { + Text(cwd) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(dynamicTypeSize.isAccessibilitySize ? nil : 1) + .fixedSize(horizontal: false, vertical: dynamicTypeSize.isAccessibilitySize) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private struct AgentFeedRowCopy { + let localizer: AgentFeedLocalizer + func workspaceRouteLabel(_ workspaceID: String?) -> String { + localizer.string( + "mobile.agentFeed.card.workspaceID", + defaultValue: "Workspace ID: \(routeValue(workspaceID))" + ) + } + + func surfaceRouteLabel(_ surfaceID: String?) -> String { + localizer.string( + "mobile.agentFeed.card.surfaceID", + defaultValue: "Surface ID: \(routeValue(surfaceID))" + ) + } + + private func routeValue(_ value: String?) -> String { + guard let value, !value.isEmpty else { + return localizer.string( + "mobile.agentFeed.card.routeUnavailable", + defaultValue: "Unavailable" + ) + } + return value + } + + func statusLabel(_ status: MobileWorkstreamFeedStatus) -> String { + switch status { + case .pending: localizer.string("mobile.agentFeed.card.pending", defaultValue: "Needs input") + case .resolved: localizer.string("mobile.agentFeed.card.resolved", defaultValue: "Resolved") + case .expired: localizer.string("mobile.agentFeed.card.expired", defaultValue: "Expired") + case .telemetry: localizer.string("mobile.agentFeed.card.activity", defaultValue: "Activity") + case .unknown: localizer.string("mobile.agentFeed.card.unknown", defaultValue: "Unknown status") + } + } + + func statusLabel(for item: MobileAgentFeedItem, requiresResponse: Bool) -> String { + requiresResponse + ? localizer.string("mobile.agentFeed.card.pending", defaultValue: "Needs input") + : statusLabel(item.wire.status) + } + + func sourceLabel(_ source: String) -> String { + switch source.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "claude": return localizer.string("mobile.agentFeed.source.claude", defaultValue: "Claude") + case "codex": return localizer.string("mobile.agentFeed.source.codex", defaultValue: "Codex") + case "opencode": return localizer.string("mobile.agentFeed.source.opencode", defaultValue: "OpenCode") + case "hermes-agent": return localizer.string("mobile.agentFeed.source.hermes", defaultValue: "Hermes") + case "gemini": return localizer.string("mobile.agentFeed.source.gemini", defaultValue: "Gemini") + case "amp": return localizer.string("mobile.agentFeed.source.amp", defaultValue: "Amp") + case "antigravity", "agy": return localizer.string("mobile.agentFeed.source.antigravity", defaultValue: "Antigravity") + case "codebuddy": return localizer.string("mobile.agentFeed.source.codebuddy", defaultValue: "CodeBuddy") + case "copilot": return localizer.string("mobile.agentFeed.source.copilot", defaultValue: "Copilot") + case "cursor", "cursor-agent": return localizer.string("mobile.agentFeed.source.cursor", defaultValue: "Cursor") + case "factory": return localizer.string("mobile.agentFeed.source.factory", defaultValue: "Factory") + case "grok", "grok-code": return localizer.string("mobile.agentFeed.source.grok", defaultValue: "Grok") + case "kimi": return localizer.string("mobile.agentFeed.source.kimi", defaultValue: "Kimi Code") + case "kiro": return localizer.string("mobile.agentFeed.source.kiro", defaultValue: "Kiro") + case "qoder": return localizer.string("mobile.agentFeed.source.qoder", defaultValue: "Qoder") + case "rovodev", "rovo": return localizer.string("mobile.agentFeed.source.rovodev", defaultValue: "Rovo Dev") + default: + return localizer.string( + "mobile.agentFeed.source.other", + defaultValue: "Agent: \(source)" + ) + } + } + + func payloadSummary(_ payload: MobileWorkstreamFeedPayload) -> String { + switch payload { + case .permission(_, let tool, let summary, _): return summary.isEmpty ? tool : "\(tool)\n\(summary)" + case .exitPlan(_, let plan, let summary, _): return summary ?? plan + case .question(_, let questions): return questions.map(\.prompt).formatted() + case .boolean(_, let prompt, let yesLabel, let noLabel, _): + return "\(prompt) (\(yesLabel) / \(noLabel))" + case .form(_, let title, let fields, _): + return title ?? fields.map(\.prompt).formatted() + case .toolUse(let name, _): + return localizer.string("mobile.agentFeed.activity.toolUse", defaultValue: "Using \(name)") + case .toolResult(let name, let result, let isError): + return isError + ? localizer.string( + "mobile.agentFeed.activity.toolErrorGeneric", + defaultValue: "\(name) failed" + ) + : result + case .message(let text, _): return text + case .stop(let reason): return reason ?? localizer.string("mobile.agentFeed.activity.turnComplete", defaultValue: "Turn complete. Reply to continue.") + case .todos: return localizer.string("mobile.agentFeed.activity.todos", defaultValue: "Task list updated") + case .lifecycle: return localizer.string("mobile.agentFeed.activity.lifecycle", defaultValue: "Session activity") + case .unknown: return localizer.string("mobile.agentFeed.activity.unknown", defaultValue: "Agent activity") + } + } + + func permissionModeLabel(_ mode: String, source: String? = nil) -> String { + switch mode { + case "once": localizer.string("mobile.agentFeed.permission.once", defaultValue: "Allow Once") + case "always": source?.lowercased() == "codex" + ? localizer.string("mobile.agentFeed.permission.session", defaultValue: "Allow for Session") + : localizer.string("mobile.agentFeed.permission.always", defaultValue: "Always Allow") + case "persistent": localizer.string("mobile.agentFeed.permission.persistent", defaultValue: "Always Allow") + case "all": localizer.string("mobile.agentFeed.permission.all", defaultValue: "Allow All") + case "bypass": localizer.string("mobile.agentFeed.permission.bypass", defaultValue: "Bypass") + default: localizer.string("mobile.agentFeed.permission.deny", defaultValue: "Deny") + } + } + + func decisionLabel( + _ decision: MobileWorkstreamDecision?, + payload: MobileWorkstreamFeedPayload, + source: String? + ) -> String? { + switch decision { + case .permission(let mode): return permissionModeLabel(mode, source: source) + case .exitPlan(let mode, let feedback): + let label = planModeLabel(mode) + if let feedback, !feedback.isEmpty { return "\(label): \(feedback)" } + return label + case .question(let selections): + let answers = resolvedQuestionAnswers(selections, payload: payload) + return answers.isEmpty ? nil : answers.formatted() + case .form(let action, let selections): + switch action { + case "decline": + return localizer.string("mobile.agentFeed.form.declined", defaultValue: "Declined") + case "cancel": + return localizer.string("mobile.agentFeed.form.cancelled", defaultValue: "Cancelled") + default: + let answers = resolvedQuestionAnswers(selections, payload: payload) + return answers.isEmpty + ? localizer.string("mobile.agentFeed.form.accepted", defaultValue: "Accepted") + : answers.formatted() + } + case .unknown(let kind): return kind + case nil: return nil + } + } + + func resolutionLabel( + _ status: MobileWorkstreamFeedStatus, + payload: MobileWorkstreamFeedPayload, + source: String? = nil + ) -> String? { + switch status { + case .resolved(let decision): + if let answer = decisionLabel(decision, payload: payload, source: source) { + return localizer.string( + "mobile.agentFeed.card.resolution", + defaultValue: "Resolved: \(answer)" + ) + } + return localizer.string( + "mobile.agentFeed.card.resolved", + defaultValue: "Resolved" + ) + case .expired: + return localizer.string( + "mobile.agentFeed.card.noResponse", + defaultValue: "No response. The agent stopped waiting." + ) + default: + return nil + } + } + + private func resolvedQuestionAnswers( + _ selections: [String], + payload: MobileWorkstreamFeedPayload + ) -> [String] { + selections.compactMap { selection in + guard let separator = selection.firstIndex(of: "=") else { + return selection.isEmpty ? nil : selection + } + let questionID = String(selection[.. String { + let normalizedInputType = question?.inputType? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + if ["secret", "password", "passphrase"].contains(normalizedInputType) + || rawValue == "" { + return localizer.string( + "mobile.agentFeed.card.secretProvided", + defaultValue: "Provided" + ) + } + let value = rawValue.hasPrefix("other:") + ? String(rawValue.dropFirst("other:".count)) + : rawValue + if let option = question?.options.first(where: { $0.id == value }) { + return option.label + } + if normalizedInputType == "boolean" { + switch value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "1", "true", "yes", "y", "on": + return localizer.string("mobile.agentFeed.boolean.yes", defaultValue: "Yes") + case "0", "false", "no", "n", "off": + return localizer.string("mobile.agentFeed.boolean.no", defaultValue: "No") + default: + break + } + } + return value + } + + func planModeLabel(_ mode: String) -> String { + switch mode { + case "ultraplan": localizer.string("mobile.agentFeed.plan.ultraplan", defaultValue: "Ultraplan") + case "bypassPermissions": localizer.string("mobile.agentFeed.plan.bypass", defaultValue: "Bypass Permissions") + case "autoAccept": localizer.string("mobile.agentFeed.plan.autoAccept", defaultValue: "Auto-Accept") + case "manual": localizer.string("mobile.agentFeed.plan.manual", defaultValue: "Manual") + default: localizer.string("mobile.agentFeed.plan.deny", defaultValue: "Deny") + } + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentFeedRowChrome.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentFeedRowChrome.swift new file mode 100644 index 00000000000..751b604498e --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentFeedRowChrome.swift @@ -0,0 +1,122 @@ +#if os(iOS) +import SwiftUI + +/// Visual chrome for one Feed experiment. Interaction content stays identical +/// across designs so Labs changes presentation without changing capabilities. +struct AgentFeedRowChrome: View { + let design: MobileAgentFeedDesign + let sourceLabel: String + let isActionable: Bool + let actionNeededLabel: String + let activityLabel: String + let content: Content + + init( + design: MobileAgentFeedDesign, + sourceLabel: String, + isActionable: Bool, + actionNeededLabel: String, + activityLabel: String, + @ViewBuilder content: () -> Content + ) { + self.design = design + self.sourceLabel = sourceLabel + self.isActionable = isActionable + self.actionNeededLabel = actionNeededLabel + self.activityLabel = activityLabel + self.content = content() + } + + @ViewBuilder + var body: some View { + switch design { + case .timeline: + HStack(alignment: .top, spacing: 12) { + VStack(spacing: 0) { + avatar(size: 40) + Rectangle() + .fill(.quaternary) + .frame(width: 2) + .frame(maxHeight: .infinity) + .accessibilityHidden(true) + } + content + .padding(.bottom, 12) + } + case .cards: + content + .padding(14) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18)) + .overlay { + RoundedRectangle(cornerRadius: 18) + .stroke( + isActionable + ? Color.accentColor.opacity(0.5) + : Color.secondary.opacity(0.16) + ) + } + .shadow(color: .black.opacity(0.06), radius: 8, y: 3) + .padding(.vertical, 6) + case .compact: + HStack(alignment: .top, spacing: 9) { + RoundedRectangle(cornerRadius: 2) + .fill(isActionable ? Color.accentColor : Color.secondary.opacity(0.35)) + .frame(width: 4) + .accessibilityHidden(true) + content + } + .padding(.vertical, 5) + case .conversation: + HStack(alignment: .top, spacing: 10) { + avatar(size: 32) + content + .padding(12) + .background( + isActionable ? Color.accentColor.opacity(0.12) : Color.secondary.opacity(0.09), + in: RoundedRectangle(cornerRadius: 18) + ) + } + .padding(.vertical, 5) + case .commandCenter: + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 6) { + Image(systemName: isActionable ? "bolt.fill" : "waveform.path.ecg") + Text(isActionable ? actionNeededLabel : activityLabel) + } + .font(.caption2.weight(.bold)) + .foregroundStyle(isActionable ? Color.accentColor : Color.secondary) + .padding(.horizontal, 12) + .padding(.vertical, 8) + Divider() + content.padding(12) + } + .background(.background, in: RoundedRectangle(cornerRadius: 12)) + .overlay { + RoundedRectangle(cornerRadius: 12) + .stroke( + isActionable + ? Color.accentColor.opacity(0.7) + : Color.secondary.opacity(0.3) + ) + } + .padding(.vertical, 6) + } + } + + private func avatar(size: CGFloat) -> some View { + Text(sourceLabel.prefix(1).uppercased()) + .font(.system(size: size * 0.4, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + .frame(width: size, height: size) + .background( + LinearGradient( + colors: [.accentColor, .purple], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + in: Circle() + ) + .accessibilityHidden(true) + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentFeedStoreView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentFeedStoreView.swift new file mode 100644 index 00000000000..8e510a58ff8 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentFeedStoreView.swift @@ -0,0 +1,42 @@ +#if os(iOS) +import CmuxMobileShell +import CmuxMobileShellModel +import SwiftUI + +/// The only agent-feed view that retains the observable shell store. +struct AgentFeedStoreView: View { + @Bindable var store: CMUXMobileShellStore + private let localizer = AgentFeedLocalizer() + @State private var filter: MobileAgentFeedFilter = .needsInput + @AppStorage(MobileAgentFeedDesign.storageKey) private var designRawValue = + MobileAgentFeedDesign.timeline.rawValue + + private var design: MobileAgentFeedDesign { + MobileAgentFeedDesign(rawValue: designRawValue) ?? .timeline + } + + var body: some View { + AgentFeedView( + items: store.agentFeedItems, + status: store.agentFeedStatus, + design: design, + filter: $filter, + drafts: store.agentFeedDrafts, + mutationStates: store.agentFeedMutationStates, + hasMoreItems: store.agentFeedHasMoreItems, + canLoadOlder: store.agentFeedCanLoadOlder, + isLoadingOlder: store.agentFeedIsLoadingOlder, + actions: AgentFeedActions( + setDraft: { id, value in store.agentFeedDrafts[id] = value }, + reply: { item in Task { await store.sendAgentFeedReply(for: item) } }, + decide: { item, action in Task { await store.sendAgentFeedAction(action, for: item) } }, + open: { item in Task { _ = await store.openAgentFeedItem(item) } }, + refresh: { Task { await store.refreshAgentFeed() } }, + loadOlder: { Task { await store.loadOlderAgentFeed() } }, + recordTopRowAppearance: { _ in } + ) + ) + .environment(\.agentFeedLocalizer, localizer) + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentFeedView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentFeedView.swift new file mode 100644 index 00000000000..d3566aa66cd --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentFeedView.swift @@ -0,0 +1,344 @@ +#if os(iOS) +import CmuxMobileShellModel +import CmuxMobileSupport +import SwiftUI + +struct AgentFeedActions { + let setDraft: @MainActor (MobileAgentFeedItemID, String) -> Void + let reply: @MainActor (MobileAgentFeedItem) -> Void + let decide: @MainActor (MobileAgentFeedItem, MobileAgentFeedAction) -> Void + let open: @MainActor (MobileAgentFeedItem) -> Void + let refresh: @MainActor () -> Void + let loadOlder: @MainActor () -> Void + let recordTopRowAppearance: @MainActor (MobileAgentFeedItemID) -> Void +} + +struct AgentFeedView: View { + let items: [MobileAgentFeedItem] + let status: MobileAgentFeedStatus + let design: MobileAgentFeedDesign + @Binding var filter: MobileAgentFeedFilter + let drafts: [MobileAgentFeedItemID: String] + let mutationStates: [MobileAgentFeedItemID: MobileAgentFeedMutationState] + let hasMoreItems: Bool + let canLoadOlder: Bool + let isLoadingOlder: Bool + let actions: AgentFeedActions + @State private var expansionOverrides: [MobileAgentFeedItemID: Bool] = [:] + @State private var planFeedback: [MobileAgentFeedItemID: String] = [:] + @State private var questionSelections: [MobileAgentFeedItemID: [String: Set]] = [:] + @State private var otherAnswers: [MobileAgentFeedItemID: [String: String]] = [:] + @State private var formValues: [MobileAgentFeedItemID: [String: String]] = [:] + @State private var renderedItems: [MobileAgentFeedItem]? + @State private var pendingViewportAnchor: MobileAgentFeedItemID? + @State private var heldViewportAnchor: MobileAgentFeedItemID? + @State private var visibilityTracker = AgentFeedVisibilityTracker() + @State private var unseenItemCount = 0 + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @Environment(\.agentFeedLocalizer) private var localizer + + var body: some View { + let source = AgentFeedSourceSnapshot(items: items, filter: filter) + let visibleItems = renderedItems ?? source.visibleItems + let needsInputItemIDs = source.needsInputItemIDs + VStack(spacing: 0) { + AgentFeedStatusBanner(status: status, retry: actions.refresh, localizer: localizer) + filterControl + .padding(.horizontal) + .padding(.vertical, 8) + + if visibleItems.isEmpty { + ContentUnavailableView( + localizer.string("mobile.agentFeed.empty.title", defaultValue: "No agent activity"), + systemImage: "text.bubble", + description: Text(localizer.string( + "mobile.agentFeed.empty.message", + defaultValue: "Agent updates and requests will appear here." + )) + ) + .accessibilityIdentifier("MobileAgentFeedEmpty") + } else { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 0) { + ForEach(visibleItems) { item in + AgentFeedRow( + item: item, + design: design, + requiresResponse: needsInputItemIDs.contains(item.id), + isExpanded: expansionOverrides[item.id] + ?? needsInputItemIDs.contains(item.id), + draft: drafts[item.id] ?? "", + mutationState: mutationStates[item.id] ?? .idle, + interactionsEnabled: interactionsEnabled(for: item), + planFeedback: planFeedback[item.id] ?? "", + questionSelections: questionSelections[item.id] ?? [:], + otherAnswers: otherAnswers[item.id] ?? [:], + formValues: formValues[item.id] ?? [:], + actions: AgentFeedRowActions( + setExpanded: { setExpanded($0, id: item.id) }, + setDraft: { actions.setDraft(item.id, $0) }, + setPlanFeedback: { planFeedback[item.id] = $0 }, + setQuestionSelection: { question, selection in + questionSelections[item.id, default: [:]][question] = selection + }, + setOtherAnswer: { question, value in + otherAnswers[item.id, default: [:]][question] = value + }, + setFormValue: { field, value in + formValues[item.id, default: [:]][field] = value + }, + reply: { actions.reply(item) }, + decide: { actions.decide(item, $0) }, + open: { actions.open(item) } + ) + ) + .equatable() + .padding(.horizontal) + .id(item.id) + .onAppear { + guard item.id == visibleItems.first?.id else { return } + actions.recordTopRowAppearance(item.id) + } + Divider().padding(.leading) + } + if hasMoreItems { + loadOlderControl.padding(.horizontal) + } + } + .scrollTargetLayout() + } + .refreshable { actions.refresh() } + .accessibilityIdentifier("MobileAgentFeedList") + .onScrollTargetVisibilityChange( + idType: MobileAgentFeedItemID.self, + threshold: 0.5 + ) { visibleIDs in + visibilityTracker.replaceVisibleIDs(visibleIDs) + if unseenItemCount > 0, + let newestID = visibleItems.first?.id, + visibleIDs.contains(newestID) { + unseenItemCount = 0 + heldViewportAnchor = nil + } + } + .overlay(alignment: .top) { + if unseenItemCount > 0 { + Button { + if let newest = visibleItems.first { + withAnimation { proxy.scrollTo(newest.id, anchor: .top) } + } + unseenItemCount = 0 + heldViewportAnchor = nil + } label: { + Label( + localizer.string( + "mobile.agentFeed.newActivity.count", + defaultValue: "\(unseenItemCount) new activities" + ), + systemImage: "arrow.up" + ) + } + .frame(minHeight: 44) + .accessibilityIdentifier("MobileAgentFeedNewActivity") + .background(.regularMaterial, in: Capsule()) + } + } + .onChange(of: visibleItems.map(\.id)) { _, _ in + guard let anchorID = pendingViewportAnchor else { return } + pendingViewportAnchor = nil + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + proxy.scrollTo(anchorID, anchor: .top) + } + } + } + } + } + .onChange(of: source, initial: true) { oldSource, newSource in + let oldVisibleItems = renderedItems ?? oldSource.visibleItems + let oldIDs = oldVisibleItems.map(\.id) + let newVisibleItems = newSource.visibleItems + let newIDs = newVisibleItems.map(\.id) + let genuinelyNewIDs = Set(newSource.items.map(\.id)) + .subtracting(oldSource.items.map(\.id)) + let oldNewestID = oldIDs.first + let anchorID = visibilityTracker.topVisibleID(orderedBy: oldIDs) + let insertedBeforeOldNewest: Set + if let oldNewestID, + let oldNewestIndex = newIDs.firstIndex(of: oldNewestID) { + insertedBeforeOldNewest = Set(newIDs[.. 0 { + unseenItemCount += insertedVisibleCount + if heldViewportAnchor == nil { + heldViewportAnchor = anchorID + } + pendingViewportAnchor = heldViewportAnchor + } + renderedItems = newVisibleItems + } + .navigationTitle(localizer.string("mobile.tabs.feed", defaultValue: "Feed")) + .overlay(alignment: .topLeading) { + ZStack { + Color.clear + .frame(width: 1, height: 1) + .accessibilityElement(children: .ignore) + .accessibilityIdentifier("MobileAgentFeed") + Color.clear + .frame(width: 1, height: 1) + .accessibilityElement(children: .ignore) + .accessibilityIdentifier("MobileAgentFeedDesign-\(design.rawValue)") + .accessibilityValue(design.title(using: localizer)) + } + } + } + + private var loadOlderControl: some View { + Button(action: actions.loadOlder) { + HStack { + if isLoadingOlder { ProgressView() } + Text(isLoadingOlder + ? localizer.string( + "mobile.agentFeed.history.loadingOlder", + defaultValue: "Loading older activity…" + ) + : localizer.string( + "mobile.agentFeed.history.loadOlder", + defaultValue: "Load Older" + )) + Spacer() + } + .frame(minHeight: 44) + } + .disabled(!canLoadOlder || isLoadingOlder) + .accessibilityIdentifier("MobileAgentFeedLoadOlder") + } + + private func setExpanded(_ isExpanded: Bool, id: MobileAgentFeedItemID) { + expansionOverrides[id] = isExpanded + } + + private func interactionsEnabled(for item: MobileAgentFeedItem) -> Bool { + guard item.connectionStatus == .connected else { return false } + // A completed-turn reply must target the exact live surface that + // emitted it. Cached rows can retain the event after that surface was + // removed, so keep them readable without exposing a disabled composer. + if item.isTurnCompletion { + guard let workspaceID = item.wire.workspaceID, + !workspaceID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let surfaceID = item.wire.surfaceID, + !surfaceID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return false + } + } + switch status { + case .ready, .partial: return true + case .idle, .loading, .offlineCached, .reconnecting, .unavailable, .requiresMacUpdate, .failed: + return false + } + } + + @ViewBuilder + private var filterControl: some View { + if dynamicTypeSize.isAccessibilitySize { + filterPicker + .pickerStyle(.menu) + .frame(minHeight: 44) + } else { + filterPicker + .pickerStyle(.segmented) + } + } + + private var filterPicker: some View { + Picker( + localizer.string("mobile.agentFeed.filter.label", defaultValue: "Feed filter"), + selection: $filter + ) { + Text(localizer.string("mobile.agentFeed.filter.needsInput", defaultValue: "Needs Input")) + .tag(MobileAgentFeedFilter.needsInput) + Text(localizer.string("mobile.agentFeed.filter.all", defaultValue: "All Activity")) + .tag(MobileAgentFeedFilter.allActivity) + } + .accessibilityIdentifier("MobileAgentFeedFilter") + } +} + +private struct AgentFeedSourceSnapshot: Equatable { + let items: [MobileAgentFeedItem] + let filter: MobileAgentFeedFilter + let needsInputItems: [MobileAgentFeedItem] + + init(items: [MobileAgentFeedItem], filter: MobileAgentFeedFilter) { + self.items = items + self.filter = filter + needsInputItems = MobileAgentFeedFilter.needsInput.apply(to: items) + } + + var visibleItems: [MobileAgentFeedItem] { + filter == .needsInput ? needsInputItems : items + } + + var needsInputItemIDs: Set { + Set(needsInputItems.map(\.id)) + } +} + +/// Observes row visibility without publishing per-frame SwiftUI state. The +/// scroll view remains the sole owner of gesture physics; Feed reads this set +/// only when an item insertion must preserve an off-top viewport. +@MainActor +private final class AgentFeedVisibilityTracker { + private(set) var visibleIDs: Set = [] + + func replaceVisibleIDs(_ ids: [MobileAgentFeedItemID]) { + visibleIDs = Set(ids) + } + + func topVisibleID(orderedBy ids: [MobileAgentFeedItemID]) -> MobileAgentFeedItemID? { + ids.first(where: visibleIDs.contains) + } +} + +private struct AgentFeedStatusBanner: View { + let status: MobileAgentFeedStatus + let retry: @MainActor () -> Void + let localizer: AgentFeedLocalizer + + var body: some View { + switch status { + case .idle, .ready: EmptyView() + case .loading: + Label(localizer.string("mobile.agentFeed.status.syncing", defaultValue: "Syncing Feed…"), systemImage: "arrow.triangle.2.circlepath") + .accessibilityIdentifier("MobileAgentFeedStatusSyncing") + case .offlineCached: + Label(localizer.string("mobile.agentFeed.status.offline", defaultValue: "Offline. Showing cached activity."), systemImage: "wifi.slash") + .accessibilityIdentifier("MobileAgentFeedStatusOffline") + case .partial: + Label(localizer.string("mobile.agentFeed.status.partial", defaultValue: "Some computers are unavailable."), systemImage: "exclamationmark.triangle") + .accessibilityIdentifier("MobileAgentFeedStatusPartial") + case .reconnecting: + Label(localizer.string("mobile.agentFeed.status.reconnecting", defaultValue: "Reconnecting…"), systemImage: "network") + .accessibilityIdentifier("MobileAgentFeedStatusReconnecting") + case .requiresMacUpdate: + Label(localizer.string("mobile.agentFeed.status.updateMac", defaultValue: "Update cmux on your Mac to use Feed."), systemImage: "arrow.down.app") + .accessibilityIdentifier("MobileAgentFeedStatusUpdateMac") + case .unavailable, .failed: + Button(action: retry) { + Label(localizer.string("mobile.agentFeed.status.retry", defaultValue: "Feed unavailable. Retry"), systemImage: "arrow.clockwise") + } + .accessibilityIdentifier("MobileAgentFeedRetry") + } + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/AgentFeed/AgentFeedPerformanceProbe.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/AgentFeed/AgentFeedPerformanceProbe.swift new file mode 100644 index 00000000000..7a78cd11d4f --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/AgentFeed/AgentFeedPerformanceProbe.swift @@ -0,0 +1,104 @@ +#if DEBUG && os(iOS) +import CmuxMobileShellModel +import Foundation +import Observation +import QuartzCore + +/// UI-test-only collector for real frame intervals and Feed row visibility latency. +@MainActor +@Observable +final class AgentFeedPerformanceProbe: NSObject { + private(set) var markerValue = "state=idle;frames=0;visibility=0" + + @ObservationIgnored private var displayLink: CADisplayLink? + @ObservationIgnored private var burst: [MobileAgentFeedItem] = [] + @ObservationIgnored private var didInjectBurst = false + @ObservationIgnored private var frameIntervals: [TimeInterval] = [] + @ObservationIgnored private var visibilityLatencies: [TimeInterval] = [] + @ObservationIgnored private var injectionTimes: [MobileAgentFeedItemID: CFTimeInterval] = [:] + @ObservationIgnored private var inject: (([MobileAgentFeedItem]) -> Void)? + @ObservationIgnored private var previousFrameTimestamp: CFTimeInterval? + + func start( + burst: [MobileAgentFeedItem], + inject: @escaping ([MobileAgentFeedItem]) -> Void + ) { + displayLink?.invalidate() + self.burst = burst + self.inject = inject + didInjectBurst = false + frameIntervals = [] + visibilityLatencies = [] + injectionTimes = [:] + previousFrameTimestamp = nil + markerValue = "state=running;frames=0;visibility=0" + let link = CADisplayLink(target: self, selector: #selector(frameTick(_:))) + // Keep the probe's sampling clock deterministic. An unconstrained + // display link may settle near 24 Hz for this otherwise static screen, + // which makes a healthy callback cadence exceed the 33 ms stall gate. + link.preferredFrameRateRange = CAFrameRateRange( + minimum: 60, + maximum: 60, + preferred: 60 + ) + link.add(to: .main, forMode: .common) + displayLink = link + } + + func recordTopRowAppearance(_ id: MobileAgentFeedItemID) { + guard let injectedAt = injectionTimes.removeValue(forKey: id) else { return } + visibilityLatencies.append(CACurrentMediaTime() - injectedAt) + } + + @objc private func frameTick(_ link: CADisplayLink) { + if let previousFrameTimestamp { + frameIntervals.append(link.timestamp - previousFrameTimestamp) + } + previousFrameTimestamp = link.timestamp + + if !didInjectBurst { + didInjectBurst = true + if let newest = burst.last { + injectionTimes[newest.id] = CACurrentMediaTime() + } + inject?(burst) + } + + let collectedSettleFrames = frameIntervals.count >= 60 + let observedBatch = burst.isEmpty || visibilityLatencies.count == 1 + let reachedFrameDeadline = frameIntervals.count >= 180 + if (didInjectBurst && collectedSettleFrames && observedBatch) + || reachedFrameDeadline { + link.invalidate() + displayLink = nil + inject = nil + updateMarker(state: "complete") + } + } + + private func updateMarker(state: String) { + markerValue = [ + "state=\(state)", + "frames=\(frameIntervals.count)", + "frame_p95_ms=\(milliseconds(percentile95(frameIntervals)))", + "frame_max_ms=\(milliseconds(frameIntervals.max() ?? 0))", + "frame_ge250=\(frameIntervals.lazy.filter { $0 >= 0.250 }.count)", + "visibility=\(visibilityLatencies.count)", + "visibility_p95_ms=\(milliseconds(percentile95(visibilityLatencies)))", + "visibility_max_ms=\(milliseconds(visibilityLatencies.max() ?? 0))", + "visibility_ge250=\(visibilityLatencies.lazy.filter { $0 >= 0.250 }.count)", + ].joined(separator: ";") + } + + private func percentile95(_ values: [TimeInterval]) -> TimeInterval { + guard !values.isEmpty else { return 0 } + let sorted = values.sorted() + let index = min(sorted.count - 1, Int(ceil(Double(sorted.count) * 0.95)) - 1) + return sorted[index] + } + + private func milliseconds(_ interval: TimeInterval) -> String { + String(format: "%.2f", interval * 1_000) + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/AgentFeed/AgentFeedPreviewScenario.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/AgentFeed/AgentFeedPreviewScenario.swift new file mode 100644 index 00000000000..b16ab95f28f --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/AgentFeed/AgentFeedPreviewScenario.swift @@ -0,0 +1,463 @@ +#if DEBUG && os(iOS) +import CmuxMobileShellModel +import Foundation + +enum AgentFeedPreviewScenario: String, CaseIterable { + case empty + case mixed + case newActivity = "new-activity" + case reply + case permission + case plan + case questions + case boolean + case form + case toolError = "tool-error" + case expired + case offline + case reconnect + case exactNavigation = "exact-navigation" + case capabilityGap = "capability-gap" + case malformed + case japanese + case accessibility + case stress + + static func resolve( + environment: [String: String] = ProcessInfo.processInfo.environment, + arguments: [String] = ProcessInfo.processInfo.arguments + ) -> Self { + let environmentValue = environment["CMUX_UITEST_AGENT_FEED_SCENARIO"] + let assignmentValue = arguments.first { $0.hasPrefix("CMUX_UITEST_AGENT_FEED_SCENARIO=") }? + .split(separator: "=", maxSplits: 1).last.map(String.init) + let flagIndex = arguments.firstIndex(of: "--agent-feed-scenario") + let flagValue = flagIndex.flatMap { index in + arguments.indices.contains(index + 1) ? arguments[index + 1] : nil + } + // XCUITest can relaunch the same application identity several times in + // one method. Prefer the per-launch argument so a prior process's + // inherited environment cannot select the next fixture. + return Self(rawValue: flagValue ?? assignmentValue ?? environmentValue ?? "mixed") ?? .mixed + } +} + +struct AgentFeedPreviewConfiguration { + let scenario: AgentFeedPreviewScenario + let items: [MobileAgentFeedItem] + let status: MobileAgentFeedStatus + let filter: MobileAgentFeedFilter + let hostEventCount: Int + + static func current() -> Self { fixture(for: .resolve()) } + + static func fixture(for scenario: AgentFeedPreviewScenario) -> Self { + switch scenario { + case .empty: + return Self(scenario: scenario, items: [], status: .loading, filter: .needsInput, hostEventCount: 0) + case .mixed, .accessibility: + return Self(scenario: scenario, items: mixedItems, status: .ready, filter: .allActivity, hostEventCount: 14) + case .japanese: + return Self(scenario: scenario, items: japaneseItems, status: .ready, filter: .allActivity, hostEventCount: 1) + case .newActivity: + return Self(scenario: scenario, items: activityItems, status: .ready, filter: .allActivity, hostEventCount: 36) + case .reply: + return one(scenario, item: replyItem) + case .permission: + return one(scenario, item: permissionItem) + case .plan: + return one(scenario, item: planItem) + case .questions: + return one(scenario, item: questionItem) + case .boolean: + return one(scenario, item: booleanItem) + case .form: + return one(scenario, item: formItem) + case .toolError: + return one(scenario, item: toolErrorItem, filter: .allActivity) + case .expired: + return one(scenario, item: expiredItem, filter: .allActivity) + case .offline: + return Self(scenario: scenario, items: [offlineItem], status: .offlineCached, filter: .needsInput, hostEventCount: 1) + case .reconnect: + return Self(scenario: scenario, items: [reconnectingItem], status: .reconnecting, filter: .allActivity, hostEventCount: 2) + case .exactNavigation: + return one(scenario, item: planItem) + case .capabilityGap: + return Self(scenario: scenario, items: [], status: .requiresMacUpdate, filter: .allActivity, hostEventCount: 0) + case .malformed: + return Self(scenario: scenario, items: malformedItems, status: .ready, filter: .allActivity, hostEventCount: 2) + case .stress: + return Self( + scenario: scenario, + items: Array(stressItems.prefix(300)), + status: .ready, + filter: .allActivity, + hostEventCount: stressHostEventCount + ) + } + } + + static let permissionItem = item( + id: 101, + source: "codex", + kind: "permissionRequest", + title: "Codex needs permission", + payload: .permission( + requestID: "permission-101", + toolName: "Bash", + safeInput: "command: …, timeout: …", + supportedModes: ["once", "always", "persistent", "deny"] + ) + ) + + static var japaneseItems: [MobileAgentFeedItem] { + let localizer = AgentFeedLocalizer() + return [item( + id: 111, + source: "codex", + kind: "permissionRequest", + title: localizer.string( + "mobile.agentFeed.fixture.japanese.title", + defaultValue: "Codex is requesting permission" + ), + payload: .permission( + requestID: "permission-111", + toolName: localizer.string( + "mobile.agentFeed.fixture.japanese.tool", + defaultValue: "Shell command" + ), + safeInput: localizer.string( + "mobile.agentFeed.fixture.japanese.input", + defaultValue: "Command: run focused verification" + ), + supportedModes: ["once", "always", "deny"] + ), + macDisplayName: localizer.string( + "mobile.agentFeed.fixture.japanese.computer", + defaultValue: "Development Mac" + ), + workstreamID: "検証-1", + cwd: "/プロジェクト/エージェントフィード" + )] + } + + static let planItem = item( + id: 102, + source: "claude", + kind: "exitPlan", + title: "Claude finished a plan", + payload: .exitPlan( + requestID: "plan-102", + plan: (1...24).map { "\($0). Validate the authenticated feed, exact routing, and recovery behavior." }.joined(separator: "\n"), + summary: "Review the iOS Agent Feed plan", + defaultMode: "manual" + ), + macIndex: 1 + ) + + static let questionItem = item( + id: 103, + source: "gemini", + kind: "question", + title: "Gemini has two questions", + payload: .question(requestID: "question-103", questions: [ + .init( + id: "scope", + header: "Scope", + prompt: "Which clients should receive the feed?", + multiSelect: true, + options: [ + .init(id: "iphone", label: "iPhone", description: "The signed iOS client"), + .init(id: "ipad", label: "iPad", description: "The tablet layout"), + ] + ), + .init( + id: "priority", + header: "Priority", + prompt: "Which request should appear first?", + multiSelect: false, + options: [ + .init(id: "blocking", label: "Blocking requests"), + .init(id: "recent", label: "Most recent activity"), + ] + ), + ]), + macIndex: 2 + ) + + static let booleanItem = item( + id: 112, + source: "grok", + kind: "boolean", + title: "Grok is waiting for confirmation", + payload: .boolean( + requestID: "boolean-112", + prompt: "Continue with the proposed change?", + yesLabel: "Continue", + noLabel: "Stop", + defaultValue: true + ), + macIndex: 6 + ) + + static let formItem = item( + id: 113, + source: "cursor", + kind: "form", + title: "Cursor needs project details", + payload: .form( + requestID: "form-113", + title: "Project details", + fields: [ + .init( + id: "environment", + prompt: "Environment", + multiSelect: false, + options: [ + .init(id: "staging", label: "Staging"), + .init(id: "production", label: "Production"), + ], + inputType: "choice", + required: true + ), + .init( + id: "ticket", + prompt: "Tracking URL", + multiSelect: false, + options: [], + inputType: "url", + required: false, + placeholder: "https://…" + ), + .init( + id: "approved", + prompt: "Approved for deployment", + multiSelect: false, + options: [], + inputType: "boolean", + required: true, + defaultValue: "true" + ), + ], + externalURL: "https://example.com/form" + ), + macIndex: 7 + ) + + static let replyItem = item( + id: 104, + source: "opencode", + kind: "stop", + title: "OpenCode finished a turn", + status: .telemetry, + payload: .stop(reason: "Implementation is ready for a reply."), + macIndex: 3, + lastAssistantMessage: "The implementation is ready. I can continue with the focused verification when you reply." + ) + + static let toolErrorItem = item( + id: 105, + source: "hermes-agent", + kind: "toolResult", + title: "Hermes command failed", + status: .telemetry, + payload: .toolResult(name: "swift test", result: "Exited with status 1", isError: true), + macIndex: 4 + ) + + static let expiredItem = item( + id: 106, + source: "codex", + kind: "permissionRequest", + title: "Agent disconnected before responding", + status: .expired, + payload: .permission(requestID: "expired-106", toolName: "Bash", safeInput: "command: …", supportedModes: ["once", "deny"]), + macIndex: 5, + connectionStatus: .unavailable + ) + + static let offlineItem = item( + id: 107, + source: "claude", + kind: "permissionRequest", + title: "Cached permission request", + payload: .permission(requestID: "offline-107", toolName: "Write", safeInput: "path: …", supportedModes: ["once", "deny"]), + connectionStatus: .unavailable + ) + + static let reconnectingItem = item( + id: 108, + source: "gemini", + kind: "assistantMessage", + title: "Reconnecting agent", + status: .telemetry, + payload: .message(text: "Connection recovery in progress", fromUser: false), + connectionStatus: .reconnecting + ) + + static let malformedItems = [ + item( + id: 109, + source: "future-agent-v9", + kind: "futureEvent", + title: "Unknown agent event", + status: .telemetry, + payload: .unknown(kind: "futureEvent"), + routeAvailable: false + ), + item( + id: 110, + source: "codex", + kind: "question", + title: "Malformed question payload", + payload: .question(requestID: "malformed-110", questions: []) + ), + ] + + static var mixedItems: [MobileAgentFeedItem] { + [permissionItem, planItem, questionItem, booleanItem, formItem, replyItem, toolErrorItem, expiredItem] + + (6..<12).map { index in + item( + id: 200 + index, + source: ["codex", "claude", "gemini", "opencode"][index % 4], + kind: "assistantMessage", + title: "Agent \(index + 1) activity", + status: .telemetry, + payload: .message(text: "Deterministic activity from agent \(index + 1)", fromUser: false), + macIndex: index + ) + } + } + + static var activityItems: [MobileAgentFeedItem] { + (0..<36).map { index in + item( + id: 300 + index, + source: ["codex", "claude", "gemini"][index % 3], + kind: "assistantMessage", + minutesAgo: TimeInterval(index + 1), + title: "Scrollable activity \(index + 1)", + status: .telemetry, + payload: .message(text: "Activity \(index + 1)", fromUser: false), + macIndex: index % 12 + ) + } + } + + static var stressSnapshots: [[MobileAgentFeedItem]] { + (0..<12).map { agent in + (0..<200).map { event in + let sequence = agent * 200 + event + let actionable = sequence % 10 == 0 + return item( + id: 10_000 + sequence, + source: ["codex", "claude", "gemini", "opencode", "hermes-agent"][agent % 5], + kind: actionable ? "permissionRequest" : "assistantMessage", + minutesAgo: TimeInterval(sequence) / 10, + title: actionable ? "Agent \(agent + 1) needs input" : "Agent \(agent + 1) event \(event + 1)", + status: actionable ? .pending : .telemetry, + payload: actionable + ? .permission(requestID: "stress-\(sequence)", toolName: "Bash", safeInput: "command: …", supportedModes: ["once", "deny"]) + : .message(text: "Stress event \(sequence + 1)", fromUser: false), + macIndex: agent + ) + } + } + } + + static let stressHostEventCount = 2_400 + static let stressRetainedItemLimit = MobileAgentFeedAggregation.maxItemCount + + static var stressItems: [MobileAgentFeedItem] { + MobileAgentFeedAggregation().items(from: stressSnapshots) + } + + static func injectedActivityBurst() -> [MobileAgentFeedItem] { + (0..<100).map { index in + item( + id: 900 + index, + source: ["codex", "claude", "gemini"][index % 3], + kind: "assistantMessage", + minutesAgo: -TimeInterval(index + 1) / 10, + title: "New activity burst \(index + 1)", + status: .telemetry, + payload: .message(text: "Burst event \(index + 1)", fromUser: false), + macIndex: index % 12 + ) + } + } + + static func reconciledItems() -> [MobileAgentFeedItem] { + let connected = item( + id: 108, + source: "gemini", + kind: "assistantMessage", + minutesAgo: 0, + title: "Reconnected without duplicate", + status: .telemetry, + payload: .message(text: "Authoritative snapshot won", fromUser: false), + connectionStatus: .connected + ) + return MobileAgentFeedAggregation().items(from: [[reconnectingItem], [connected]]) + } + + private static func one( + _ scenario: AgentFeedPreviewScenario, + item: MobileAgentFeedItem, + filter: MobileAgentFeedFilter = .needsInput + ) -> Self { + Self(scenario: scenario, items: [item], status: .ready, filter: filter, hostEventCount: 1) + } + + private static func item( + id: Int, + source: String, + kind: String, + minutesAgo: TimeInterval = 1, + title: String, + status: MobileWorkstreamFeedStatus = .pending, + payload: MobileWorkstreamFeedPayload, + macIndex: Int = 0, + connectionStatus: MobileMacConnectionStatus = .connected, + macDisplayName: String? = nil, + workstreamID: String? = nil, + cwd: String? = "/cmux/worktrees/agent-feed", + routeAvailable: Bool = true, + lastAssistantMessage: String? = nil + ) -> MobileAgentFeedItem { + let date = Date(timeIntervalSince1970: 1_800_000_000 - minutesAgo * 60) + let uuid = UUID(uuidString: String(format: "00000000-0000-0000-0000-%012d", id))! + let macDeviceID = switch macIndex { + case 0: "macbook" + case 1: "mac-studio" + default: "mac-\(macIndex + 1)" + } + let defaultMacDisplayName = switch macIndex { + case 0: "MacBook Pro" + case 1: "Studio" + default: "Mac \(macIndex + 1)" + } + return MobileAgentFeedItem( + macDeviceID: macDeviceID, + macInstanceTag: "fixture", + macDisplayName: macDisplayName ?? defaultMacDisplayName, + connectionStatus: connectionStatus, + wire: MobileWorkstreamFeedListItem( + id: uuid, + workstreamID: workstreamID ?? "\(source)-fixture-\(macIndex + 1)", + source: source, + kind: kind, + createdAt: date, + updatedAt: date, + cwd: cwd, + title: title, + lastAssistantMessage: lastAssistantMessage, + workspaceID: routeAvailable ? "workspace-\(macIndex + 1)" : nil, + surfaceID: routeAvailable ? "surface-\(id)" : nil, + status: status, + payload: payload + ) + ) + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/AgentFeed/AgentFeedPreviewView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/AgentFeed/AgentFeedPreviewView.swift new file mode 100644 index 00000000000..61a76ddf932 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/AgentFeed/AgentFeedPreviewView.swift @@ -0,0 +1,230 @@ +#if DEBUG && os(iOS) +import CmuxMobileShellModel +import CmuxMobileSupport +import SwiftUI + +/// Deterministic production-view fixture for Agent Feed UI and interaction tests. +public struct AgentFeedPreviewView: View { + @Environment(\.agentFeedLocalizer) private var localizer + private let scenario: AgentFeedPreviewScenario + private let hostEventCount: Int + @State private var selectedTab: MobilePrimaryTab = .feed + @State private var searchCoordinator = MobilePrimarySearchCoordinator(initialScope: .notifications) + @State private var filter: MobileAgentFeedFilter + @State private var items: [MobileAgentFeedItem] + @State private var status: MobileAgentFeedStatus + @State private var drafts: [MobileAgentFeedItemID: String] = [:] + @State private var mutationStates: [MobileAgentFeedItemID: MobileAgentFeedMutationState] = [:] + @State private var openedItem: MobileAgentFeedItem? + @State private var performanceProbe = AgentFeedPerformanceProbe() + @AppStorage(MobileAgentFeedDesign.storageKey) private var designRawValue = + MobileAgentFeedDesign.timeline.rawValue + + private var design: MobileAgentFeedDesign { + MobileAgentFeedDesign(rawValue: designRawValue) ?? .timeline + } + + public init() { + let configuration = AgentFeedPreviewConfiguration.current() + scenario = configuration.scenario + hostEventCount = configuration.hostEventCount + _filter = State(initialValue: configuration.filter) + _items = State(initialValue: configuration.items) + _status = State(initialValue: configuration.status) + } + + public var body: some View { + MobilePrimaryTabScaffold( + selection: $selectedTab, + searchCoordinator: searchCoordinator, + notificationUnreadCount: 0, + agentFeedNeedsInputCount: MobileAgentFeedFilter.needsInput.apply(to: items).count + ) { + Text(localizer.string("mobile.agentFeed.fixture.title", defaultValue: "Agent Feed fixture")) + } notifications: { + Text(localizer.string("mobile.tabs.notifications", defaultValue: "Notifications")) + } feed: { + NavigationStack { + scenarioFeed + .navigationDestination(isPresented: Binding( + get: { openedItem != nil }, + set: { if !$0 { openedItem = nil } } + )) { + VStack(spacing: 12) { + Image(systemName: "terminal") + Text(openedItem?.wire.workstreamID ?? "") + Text(openedItem?.wire.workspaceID ?? "") + Text(openedItem?.wire.surfaceID ?? "") + } + .navigationTitle(localizer.string("mobile.agentFeed.fixture.agent", defaultValue: "Agent")) + .accessibilityIdentifier("MobileAgentFeedPreviewAgentDestination") + } + } + } workspaceSearch: { + Text(localizer.string("mobile.agentFeed.fixture.title", defaultValue: "Agent Feed fixture")) + } notificationSearch: { + Text(localizer.string("mobile.tabs.notifications", defaultValue: "Notifications")) + } + .dynamicTypeSize(scenario == .accessibility ? .accessibility3 : .large) + .accessibilityIdentifier("AgentFeedScenarioScreen-\(scenario.rawValue)") + } + + private var scenarioFeed: some View { + VStack(spacing: 0) { + scenarioMarker + feed + fixtureControls + if scenario == .newActivity { + Color.clear + .frame(height: 1) + .accessibilityElement(children: .ignore) + .accessibilityIdentifier("AgentFeedPerformanceMetrics") + .accessibilityValue(performanceProbe.markerValue) + } + } + } + + private var scenarioMarker: some View { + Color.clear + .frame(height: 1) + .accessibilityElement(children: .ignore) + .accessibilityIdentifier("AgentFeedScenario-\(scenario.rawValue)") + .accessibilityValue(Text(verbatim: "\(hostEventCount)/\(items.count)")) + } + + @ViewBuilder + private var fixtureControls: some View { + switch scenario { + case .empty: + Button(localizer.string( + "mobile.agentFeed.fixture.completeFirstLoad", + defaultValue: "Complete first load" + )) { status = .ready } + .accessibilityIdentifier("AgentFeedFixtureCompleteFirstLoad") + case .newActivity: + Button(localizer.string( + "mobile.agentFeed.fixture.injectBurst", + defaultValue: "Inject 100-event burst" + )) { + performanceProbe.start( + burst: AgentFeedPreviewConfiguration.injectedActivityBurst() + ) { burst in + items.insert(contentsOf: burst.reversed(), at: 0) + } + } + .accessibilityIdentifier("AgentFeedFixtureInjectNewActivity") + case .stress: + if canLoadStressHistory { + Button( + localizer.string( + "mobile.agentFeed.history.loadOlder", + defaultValue: "Load Older" + ), + action: loadOlder + ) + .accessibilityIdentifier("AgentFeedFixtureLoadOlder") + } + case .reply: + if mutationStates.values.contains(.sending) { + Button(localizer.string( + "mobile.agentFeed.fixture.acknowledgeReply", + defaultValue: "Acknowledge reply" + )) { + drafts.removeAll() + mutationStates.removeAll() + } + .accessibilityIdentifier("AgentFeedFixtureAcknowledgeReply") + } + case .reconnect: + Button(localizer.string( + "mobile.agentFeed.fixture.finishReconciliation", + defaultValue: "Finish reconciliation" + )) { + items = AgentFeedPreviewConfiguration.reconciledItems() + status = .ready + } + .accessibilityIdentifier("AgentFeedFixtureFinishReconciliation") + default: + EmptyView() + } + } + + private var feed: some View { + AgentFeedView( + items: items, + status: status, + design: design, + filter: $filter, + drafts: drafts, + mutationStates: mutationStates, + hasMoreItems: (scenario == .stress && items.count < AgentFeedPreviewConfiguration.stressRetainedItemLimit) + || scenario == .offline, + canLoadOlder: canLoadStressHistory, + isLoadingOlder: false, + actions: AgentFeedActions( + setDraft: { id, value in drafts[id] = value }, + reply: { item in mutationStates[item.id] = .sending }, + decide: { item, action in resolve(item, action: action) }, + open: { item in openedItem = item }, + refresh: { status = .loading }, + loadOlder: loadOlder, + recordTopRowAppearance: performanceProbe.recordTopRowAppearance + ) + ) + } + + private func loadOlder() { + guard canLoadStressHistory else { return } + let allItems = AgentFeedPreviewConfiguration.stressItems + let end = min( + items.count + 300, + AgentFeedPreviewConfiguration.stressRetainedItemLimit, + allItems.count + ) + items = Array(allItems.prefix(end)) + } + + private var canLoadStressHistory: Bool { + scenario == .stress + && status == .ready + && items.count < AgentFeedPreviewConfiguration.stressRetainedItemLimit + } + + private func resolve(_ item: MobileAgentFeedItem, action: MobileAgentFeedAction) { + let decision: MobileWorkstreamDecision + switch action { + case .permission(let mode): decision = .permission(mode: mode) + case .exitPlan(let mode, let feedback): decision = .exitPlan(mode: mode, feedback: feedback) + case .question(let selections): decision = .question(selections: selections) + case .boolean(let value): + decision = .question(selections: ["q0=\(value ? "yes" : "no")"]) + case .form(let action, let selections): + decision = .form(action: action, selections: selections) + } + guard let index = items.firstIndex(where: { $0.id == item.id }) else { return } + let wire = item.wire + items[index] = MobileAgentFeedItem( + macDeviceID: item.macDeviceID, + macInstanceTag: item.macInstanceTag, + macDisplayName: item.macDisplayName, + connectionStatus: item.connectionStatus, + wire: MobileWorkstreamFeedListItem( + id: wire.id, + workstreamID: wire.workstreamID, + source: wire.source, + kind: wire.kind, + createdAt: wire.createdAt, + updatedAt: Date(), + cwd: wire.cwd, + title: wire.title, + lastAssistantMessage: wire.lastAssistantMessage, + workspaceID: wire.workspaceID, + surfaceID: wire.surfaceID, + status: .resolved(decision: decision), + payload: wire.payload + ) + ) + mutationStates[item.id] = .idle + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileAgentFeedDesign.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileAgentFeedDesign.swift new file mode 100644 index 00000000000..fb5d4816ec8 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileAgentFeedDesign.swift @@ -0,0 +1,31 @@ +#if os(iOS) +import Foundation + +/// The five CMUX Labs presentations available for the iOS agent Feed. +enum MobileAgentFeedDesign: String, CaseIterable, Identifiable { + case timeline + case cards + case compact + case conversation + case commandCenter + + static let storageKey = "cmux.labs.agentFeedDesign" + + var id: String { rawValue } + + func title(using localizer: AgentFeedLocalizer) -> String { + switch self { + case .timeline: + localizer.string("mobile.agentFeed.design.timeline", defaultValue: "Timeline") + case .cards: + localizer.string("mobile.agentFeed.design.cards", defaultValue: "Cards") + case .compact: + localizer.string("mobile.agentFeed.design.compact", defaultValue: "Compact") + case .conversation: + localizer.string("mobile.agentFeed.design.conversation", defaultValue: "Conversation") + case .commandCenter: + localizer.string("mobile.agentFeed.design.commandCenter", defaultValue: "Command Center") + } + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimarySearchCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimarySearchCoordinator.swift index 55f4520a8aa..833fb5269e4 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimarySearchCoordinator.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimarySearchCoordinator.swift @@ -212,7 +212,7 @@ extension MobilePrimaryTab { .workspaces case .notifications: .notifications - case .search: + case .feed, .search: nil } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimaryTab.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimaryTab.swift index 4dbcd66c6c9..5d569ecb9e9 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimaryTab.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimaryTab.swift @@ -3,6 +3,7 @@ enum MobilePrimaryTab: Hashable { case workspaces case notifications + case feed case search } #endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimaryTabScaffold.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimaryTabScaffold.swift index 018fbe349cd..6854ef21d09 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimaryTabScaffold.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimaryTabScaffold.swift @@ -8,15 +8,19 @@ import SwiftUI struct MobilePrimaryTabScaffold< Workspaces: View, Notifications: View, + Feed: View, WorkspaceSearch: View, NotificationSearch: View >: View { + @Environment(\.agentFeedLocalizer) private var agentFeedLocalizer @Binding var selection: MobilePrimaryTab @Bindable var searchCoordinator: MobilePrimarySearchCoordinator let notificationUnreadCount: Int + let agentFeedNeedsInputCount: Int let taskComposerAction: (() -> Void)? let workspaces: Workspaces let notifications: Notifications + let feed: Feed let workspaceSearch: WorkspaceSearch let notificationSearch: NotificationSearch @@ -24,18 +28,22 @@ struct MobilePrimaryTabScaffold< selection: Binding, searchCoordinator: MobilePrimarySearchCoordinator, notificationUnreadCount: Int, + agentFeedNeedsInputCount: Int, taskComposerAction: (() -> Void)? = nil, @ViewBuilder workspaces: () -> Workspaces, @ViewBuilder notifications: () -> Notifications, + @ViewBuilder feed: () -> Feed, @ViewBuilder workspaceSearch: () -> WorkspaceSearch, @ViewBuilder notificationSearch: () -> NotificationSearch ) { _selection = selection self.searchCoordinator = searchCoordinator self.notificationUnreadCount = notificationUnreadCount + self.agentFeedNeedsInputCount = agentFeedNeedsInputCount self.taskComposerAction = taskComposerAction self.workspaces = workspaces() self.notifications = notifications() + self.feed = feed() self.workspaceSearch = workspaceSearch() self.notificationSearch = notificationSearch() } @@ -102,7 +110,7 @@ struct MobilePrimaryTabScaffold< get: { selection }, set: { newValue in if (selection == .search || searchCoordinator.isPresented), - newValue.searchScope != nil { + newValue != .search { searchCoordinator.deactivateCurrentSearch() } selection = newValue @@ -199,6 +207,17 @@ struct MobilePrimaryTabScaffold< .accessibilityIdentifier("MobilePrimaryTabNotifications") } .badge(notificationUnreadCount) + + Tab(value: MobilePrimaryTab.feed) { + feed + } label: { + Label( + agentFeedLocalizer.string("mobile.tabs.feed", defaultValue: "Feed"), + systemImage: "text.bubble" + ) + .accessibilityIdentifier("MobilePrimaryTabFeed") + } + .badge(agentFeedNeedsInputCount) } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileRootPresentationState.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileRootPresentationState.swift index 42d773f5f35..616ce116e7a 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileRootPresentationState.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileRootPresentationState.swift @@ -1,3 +1,5 @@ +import CmuxMobileShell + /// The single iOS modal owner and its root-sheet and child-sheet transitions. /// /// Root presentations share one SwiftUI sheet host. Child presentations claim diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift index e293f60d069..fe530a690c6 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift @@ -26,6 +26,7 @@ struct MobileSettingsView: View { MobileConnectionMethodStore? @Environment(ToastCenter.self) private var toasts @Environment(\.irohSettingsController) private var irohSettingsController + @Environment(\.agentFeedLocalizer) private var agentFeedLocalizer @Environment(\.mobileDiagnosticLog) private var diagnosticLog let connectedHostName: String let startPairingScanner: (() -> Void)? @@ -38,6 +39,12 @@ struct MobileSettingsView: View { /// Lets the root modal coordinator advance directly to queued content. var dismissAction: (() -> Void)? = nil @AppStorage(MobileSettingsView.sendAnonymousTelemetryKey) private var sendAnonymousTelemetry = false + @AppStorage(MobileAgentFeedDesign.storageKey) private var agentFeedDesignRawValue = + MobileAgentFeedDesign.timeline.rawValue + + private var agentFeedDesign: MobileAgentFeedDesign { + MobileAgentFeedDesign(rawValue: agentFeedDesignRawValue) ?? .timeline + } @Environment(\.dismiss) private var dismiss @State private var showingShortcuts = false @@ -263,6 +270,33 @@ struct MobileSettingsView: View { .accessibilityIdentifier("MobileSettingsToastsEnabled") } + Section { + Picker( + L10n.string( + "mobile.settings.cmuxLabs.feedDesign", + defaultValue: "Feed Design" + ), + selection: $agentFeedDesignRawValue + ) { + ForEach(MobileAgentFeedDesign.allCases) { design in + Text(design.title(using: agentFeedLocalizer)) + .tag(design.rawValue) + .accessibilityIdentifier( + "MobileSettingsAgentFeedDesignOption-\(design.rawValue)" + ) + } + } + .accessibilityIdentifier("MobileSettingsAgentFeedDesign") + .accessibilityValue(agentFeedDesign.title(using: agentFeedLocalizer)) + } header: { + Text(L10n.string("mobile.settings.cmuxLabs", defaultValue: "CMUX Labs")) + } footer: { + Text(L10n.string( + "mobile.settings.cmuxLabs.feedDesign.footer", + defaultValue: "Switch between five agent Feed designs. Notifications stay separate." + )) + } + #if DEBUG Section(L10n.string("mobile.settings.developer", defaultValue: "Developer")) { Button { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationFeedPreviewView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationFeedPreviewView.swift index 9732f41ff41..00e26889c78 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationFeedPreviewView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationFeedPreviewView.swift @@ -41,7 +41,8 @@ public struct NotificationFeedPreviewView: View { MobilePrimaryTabScaffold( selection: $selectedTab, searchCoordinator: primarySearchCoordinator, - notificationUnreadCount: items.lazy.filter { !$0.isRead }.count + notificationUnreadCount: items.lazy.filter { !$0.isRead }.count, + agentFeedNeedsInputCount: 0 ) { NotificationFeedPreviewWorkspacesView() } notifications: { @@ -56,6 +57,8 @@ public struct NotificationFeedPreviewView: View { .onChange(of: pendingSearchNotificationNavigationID) { _, _ in consumePendingSearchNavigation(for: .notifications) } + } feed: { + Color.clear } workspaceSearch: { NotificationFeedPreviewWorkspacesView() } notificationSearch: { @@ -200,7 +203,7 @@ public struct NotificationFeedPreviewView: View { ) -> Bool { let previousTab = selectedTab if (selectedTab == .search || primarySearchCoordinator.isPresented), - tab.searchScope != nil { + tab != .search { primarySearchCoordinator.deactivateCurrentSearch() } beforeSelection() diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings index 7a847247bb1..2e06b029655 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings @@ -1376,6 +1376,789 @@ "ja" : { "stringUnit" : { "state" : "translated", "value" : "キャンセル" } } } }, + "mobile.agentFeed.activity.lifecycle" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Session activity" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "セッションのアクティビティ" } } + } + }, + "mobile.agentFeed.activity.todos" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Task list updated" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "タスクリストを更新しました" } } + } + }, + "mobile.agentFeed.activity.toolError" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "%@ failed: %@" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "%@ が失敗しました: %@" } } + } + }, + "mobile.agentFeed.activity.toolErrorGeneric" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "%@ failed" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "%@ が失敗しました" } } + } + }, + "mobile.agentFeed.activity.toolUse" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Using %@" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "%@ を使用中" } } + } + }, + "mobile.agentFeed.activity.turnComplete" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Turn complete. Reply to continue." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "ターンが完了しました。返信して続行できます。" } } + } + }, + "mobile.agentFeed.activity.unknown" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Agent activity" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "エージェントのアクティビティ" } } + } + }, + "mobile.agentFeed.card.activity" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Activity" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "アクティビティ" } } + } + }, + "mobile.agentFeed.card.computerContext" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "%@ · %@ · %@" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "%@ · %@ · %@" } } + } + }, + "mobile.agentFeed.card.expired" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Expired" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "期限切れ" } } + } + }, + "mobile.agentFeed.card.collapse" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Collapse details" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "詳細を閉じる" } } + } + }, + "mobile.agentFeed.card.expand" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Expand details" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "詳細を開く" } } + } + }, + "mobile.agentFeed.card.pending" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Needs input" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "入力が必要" } } + } + }, + "mobile.agentFeed.card.resolved" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Resolved" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "解決済み" } } + } + }, + "mobile.agentFeed.card.resolution" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Resolved: %@" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "解決済み: %@" } } + } + }, + "mobile.agentFeed.card.secretProvided" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Provided" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "入力済み" } } + } + }, + "mobile.agentFeed.card.noResponse" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "No response. The agent stopped waiting." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "応答がありません。エージェントは待機を終了しました。" } } + } + }, + "mobile.agentFeed.card.responseUnavailable" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "No response. This request is no longer available." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "応答がありません。このリクエストは利用できなくなりました。" } } + } + }, + "mobile.agentFeed.card.lastResponse" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Last response" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "最後の応答" } } + } + }, + "mobile.agentFeed.card.routeUnavailable" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Unavailable" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "利用不可" } } + } + }, + "mobile.agentFeed.card.surfaceID" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Surface ID: %@" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "サーフェスID: %@" } } + } + }, + "mobile.agentFeed.card.unknown" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Unknown status" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "不明な状態" } } + } + }, + "mobile.agentFeed.card.workspaceID" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Workspace ID: %@" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "ワークスペースID: %@" } } + } + }, + "mobile.agentFeed.empty.message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Agent updates and requests will appear here." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "エージェントの更新とリクエストがここに表示されます。" } } + } + }, + "mobile.agentFeed.empty.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "No agent activity" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "エージェントのアクティビティはありません" } } + } + }, + "mobile.agentFeed.filter.all" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "All Activity" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "すべてのアクティビティ" } } + } + }, + "mobile.agentFeed.filter.label" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Feed filter" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "フィードフィルタ" } } + } + }, + "mobile.agentFeed.filter.needsInput" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Needs Input" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "入力が必要" } } + } + }, + "mobile.agentFeed.fixture.agent" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Agent" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "エージェント" } } + } + }, + "mobile.agentFeed.fixture.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Agent Feed fixture" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "エージェントフィードのフィクスチャ" } } + } + }, + "mobile.agentFeed.fixture.japanese.computer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Development Mac" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "開発用Mac" } } + } + }, + "mobile.agentFeed.fixture.japanese.input" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Command: run focused verification" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "コマンド: 対象を絞った検証を実行" } } + } + }, + "mobile.agentFeed.fixture.japanese.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Codex is requesting permission" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "Codexが権限をリクエストしています" } } + } + }, + "mobile.agentFeed.fixture.japanese.tool" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Shell command" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "シェルコマンド" } } + } + }, + "mobile.agentFeed.openAgent" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Open Agent" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "エージェントを開く" } } + } + }, + "mobile.agentFeed.newActivity.count" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { "stringUnit" : { "state" : "translated", "value" : "%lld new activity" } }, + "other" : { "stringUnit" : { "state" : "translated", "value" : "%lld new activities" } } + } + } + }, + "ja" : { + "variations" : { + "plural" : { + "other" : { "stringUnit" : { "state" : "translated", "value" : "新しいアクティビティ %lld件" } } + } + } + } + } + }, + "mobile.agentFeed.history.loadOlder" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Load Older" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "以前の履歴を読み込む" } } + } + }, + "mobile.agentFeed.history.loadingOlder" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Loading older activity…" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "以前のアクティビティを読み込み中…" } } + } + }, + "mobile.agentFeed.permission.all" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Allow All" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "すべて許可" } } + } + }, + "mobile.agentFeed.permission.always" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Always Allow" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "常に許可" } } + } + }, + "mobile.agentFeed.permission.persistent" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Always Allow" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "常に許可" } } + } + }, + "mobile.agentFeed.permission.session" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Allow for Session" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "セッション中は許可" } } + } + }, + "mobile.agentFeed.permission.bypass" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Bypass" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "バイパス" } } + } + }, + "mobile.agentFeed.permission.deny" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Deny" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "拒否" } } + } + }, + "mobile.agentFeed.permission.once" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Allow Once" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "一度だけ許可" } } + } + }, + "mobile.agentFeed.plan.autoAccept" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Auto-Accept" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "自動承認" } } + } + }, + "mobile.agentFeed.plan.bypass" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Bypass Permissions" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "権限をバイパス" } } + } + }, + "mobile.agentFeed.plan.deny" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Deny" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "拒否" } } + } + }, + "mobile.agentFeed.plan.feedback" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Request changes" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "変更をリクエスト" } } + } + }, + "mobile.agentFeed.plan.manual" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Manual" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "手動" } } + } + }, + "mobile.agentFeed.plan.ultraplan" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Ultraplan" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "ウルトラプラン" } } + } + }, + "mobile.agentFeed.question.malformed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "This question could not be displayed. Open the agent to respond." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "この質問を表示できません。エージェントを開いて回答してください。" } } + } + }, + "mobile.agentFeed.boolean.no" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "No" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "いいえ" } } + } + }, + "mobile.agentFeed.boolean.yes" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Yes" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "はい" } } + } + }, + "mobile.agentFeed.question.other" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Other" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "その他" } } + } + }, + "mobile.agentFeed.question.submit" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Submit Answers" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "回答を送信" } } + } + }, + "mobile.agentFeed.form.boolean" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Enable" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "有効にする" } } + } + }, + "mobile.agentFeed.form.accepted" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Accepted" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "承認済み" } } + } + }, + "mobile.agentFeed.form.cancel" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Cancel" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "キャンセル" } } + } + }, + "mobile.agentFeed.form.cancelled" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Cancelled" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "キャンセル済み" } } + } + }, + "mobile.agentFeed.form.decline" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Decline" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "拒否" } } + } + }, + "mobile.agentFeed.form.declined" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Declined" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "拒否済み" } } + } + }, + "mobile.agentFeed.form.malformed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "This form could not be displayed. Open the agent to respond." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "このフォームを表示できません。エージェントを開いて回答してください。" } } + } + }, + "mobile.agentFeed.form.open" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Open form" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "フォームを開く" } } + } + }, + "mobile.agentFeed.form.submit" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Submit Form" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "フォームを送信" } } + } + }, + "mobile.agentFeed.form.value" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Value" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "値" } } + } + }, + "mobile.agentFeed.reply.placeholder" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Reply to this agent" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "このエージェントに返信" } } + } + }, + "mobile.agentFeed.reply.send" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Send Reply" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "返信を送信" } } + } + }, + "mobile.agentFeed.source.claude" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Claude" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "Claude" } } + } + }, + "mobile.agentFeed.source.codex" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Codex" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "Codex" } } + } + }, + "mobile.agentFeed.source.amp" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Amp" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "Amp" } } + } + }, + "mobile.agentFeed.source.antigravity" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Antigravity" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "Antigravity" } } + } + }, + "mobile.agentFeed.source.codebuddy" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "CodeBuddy" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "CodeBuddy" } } + } + }, + "mobile.agentFeed.source.copilot" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Copilot" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "Copilot" } } + } + }, + "mobile.agentFeed.source.gemini" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Gemini" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "Gemini" } } + } + }, + "mobile.agentFeed.source.grok" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Grok" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "Grok" } } + } + }, + "mobile.agentFeed.source.hermes" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Hermes" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "Hermes" } } + } + }, + "mobile.agentFeed.source.opencode" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "OpenCode" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "OpenCode" } } + } + }, + "mobile.agentFeed.source.cursor" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Cursor" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "Cursor" } } + } + }, + "mobile.agentFeed.source.factory" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Factory" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "Factory" } } + } + }, + "mobile.agentFeed.source.kimi" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Kimi Code" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "Kimi Code" } } + } + }, + "mobile.agentFeed.source.kiro" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Kiro" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "Kiro" } } + } + }, + "mobile.agentFeed.source.qoder" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Qoder" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "Qoder" } } + } + }, + "mobile.agentFeed.source.rovodev" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Rovo Dev" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "Rovo Dev" } } + } + }, + "mobile.agentFeed.source.other" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Agent: %@" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "エージェント: %@" } } + } + }, + "mobile.agentFeed.status.failed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Failed. Try again." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "失敗しました。もう一度お試しください。" } } + } + }, + "mobile.agentFeed.status.offline" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Offline. Showing cached activity." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "オフラインです。キャッシュしたアクティビティを表示しています。" } } + } + }, + "mobile.agentFeed.status.partial" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Some computers are unavailable." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "一部のコンピュータを利用できません。" } } + } + }, + "mobile.agentFeed.status.reconnecting" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Reconnecting…" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "再接続中…" } } + } + }, + "mobile.agentFeed.status.retry" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Feed unavailable. Retry" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "フィードを利用できません。再試行" } } + } + }, + "mobile.agentFeed.status.sending" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Sending…" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "送信中…" } } + } + }, + "mobile.agentFeed.status.reconciling" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Sent. Waiting for agent." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "送信済み。エージェントを待機中です。" } } + } + }, + "mobile.agentFeed.status.syncing" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Syncing Feed…" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "フィードを同期中…" } } + } + }, + "mobile.agentFeed.status.updateMac" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Update cmux on your Mac to use Feed." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "フィードを使うにはMacのcmuxを更新してください。" } } + } + }, + "mobile.agentFeed.targetUnavailable" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Agent location unavailable" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "エージェントの場所を利用できません" } } + } + }, + "mobile.agentFeed.action.unsupported" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "This request needs a newer version of cmux. Open Agent to respond." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "このリクエストに応答するには新しいバージョンのcmuxが必要です。エージェントを開いて応答してください。" } } + } + }, + "mobile.agentFeed.chrome.actionNeeded" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Action needed" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "対応が必要" } } + } + }, + "mobile.agentFeed.chrome.activity" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Activity" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "アクティビティ" } } + } + }, + "mobile.agentFeed.design.timeline" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Timeline" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "タイムライン" } } + } + }, + "mobile.agentFeed.design.cards" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Cards" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "カード" } } + } + }, + "mobile.agentFeed.design.compact" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Compact" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "コンパクト" } } + } + }, + "mobile.agentFeed.design.conversation" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Conversation" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "会話" } } + } + }, + "mobile.agentFeed.design.commandCenter" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Command Center" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "コマンドセンター" } } + } + }, + "mobile.agentFeed.permission.malformed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "No inline permission options were provided. Open Agent to respond." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "インライン権限オプションがありません。エージェントを開いて応答してください。" } } + } + }, + "mobile.agentFeed.fixture.completeFirstLoad" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Complete first load" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "初回読み込みを完了" } } + } + }, + "mobile.agentFeed.fixture.injectBurst" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Inject 100-event burst" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "100件のイベントを追加" } } + } + }, + "mobile.agentFeed.fixture.acknowledgeReply" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Acknowledge reply" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "返信を確認" } } + } + }, + "mobile.agentFeed.fixture.finishReconciliation" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Finish reconciliation" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "再同期を完了" } } + } + }, + "mobile.tabs.notifications" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Notifications" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "通知" } } + } + }, + "mobile.tabs.feed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Feed" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "フィード" } } + } + }, "mobile.iroh.diagnostics.failure.unknown" : { "extractionState" : "manual", "localizations" : { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListLayoutPreviewView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListLayoutPreviewView.swift index 5b52bbf07d2..09b9cf87397 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListLayoutPreviewView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListLayoutPreviewView.swift @@ -743,12 +743,15 @@ public struct WorkspaceListLayoutPreviewView: View { selection: $selectedPrimaryTab, searchCoordinator: primarySearchCoordinator, notificationUnreadCount: 0, + agentFeedNeedsInputCount: 0, taskComposerAction: {} ) { workspaceListStack } notifications: { Text("Notification feed fixture") .foregroundStyle(.secondary) + } feed: { + Color.clear } workspaceSearch: { NavigationStack(path: $searchFixturePath) { MobilePrimaryWorkspaceSearchContentHost( diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift index e6f378976f2..2256cc8ebfc 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift @@ -144,6 +144,7 @@ private struct WorkspaceShellRenderPresentation { let notificationFeedItems: [MobileNotificationFeedItem] let notificationUnreadCount: Int let notificationFeedStatus: MobileNotificationFeedStatus + let agentFeedNeedsInputCount: Int let selectedNotificationFeedMacDeviceIDs: Set? let toolbarMachineSnapshots: WorkspaceMachineSnapshots let canCreateWorkspaceForSelection: Bool @@ -173,6 +174,7 @@ struct WorkspaceShellView: View { #if os(iOS) @State private var selectedPrimaryTab: MobilePrimaryTab = .workspaces @State private var notificationNavigationPath: [MobileWorkspacePreview.ID] = [] + @State private var agentFeedNavigationPath: [MobileWorkspacePreview.ID] = [] @State private var notificationSearchNavigationPath: [MobileWorkspacePreview.ID] = [] @State private var workspaceSearchNavigationPath: [MobileWorkspacePreview.ID] = [] @State private var pendingPrimarySearchWorkspaceNavigationID: MobileWorkspacePreview.ID? @@ -243,6 +245,7 @@ struct WorkspaceShellView: View { selection: $selectedPrimaryTab, searchCoordinator: primarySearchCoordinator, notificationUnreadCount: presentation.notificationUnreadCount, + agentFeedNeedsInputCount: presentation.agentFeedNeedsInputCount, taskComposerAction: usesCompactStack && !compactNavigationPath.isEmpty ? nil : taskComposerAction @@ -283,6 +286,23 @@ struct WorkspaceShellView: View { .onChange(of: pendingPrimarySearchNotificationNavigationID) { _, _ in consumePendingPrimarySearchNavigation(for: .notifications) } + } feed: { + NavigationStack(path: $agentFeedNavigationPath) { + AgentFeedStoreView(store: store) + .toolbar { + if agentFeedNavigationPath.isEmpty { + rootToolbarContent + } + } + .navigationDestination(for: MobileWorkspacePreview.ID.self) { workspaceID in + workspaceDestination( + for: workspaceID, + createWorkspace: createWorkspaceInCompactStack, + canCreateWorkspaceForSelection: presentation.canCreateWorkspaceForSelection + ) + .toolbarVisibility(.hidden, for: .tabBar) + } + } } workspaceSearch: { workspaceSearchTabContent( canCreateWorkspaceForSelection: presentation.canCreateWorkspaceForSelection @@ -771,6 +791,7 @@ struct WorkspaceShellView: View { notificationFeedItems: visibleNotificationFeedItems, notificationUnreadCount: notificationUnreadCount, notificationFeedStatus: store.notificationFeedStatus(scopedTo: selectedMachineIDs), + agentFeedNeedsInputCount: store.agentFeedNeedsInputCount, selectedNotificationFeedMacDeviceIDs: selectedMachineIDs, toolbarMachineSnapshots: toolbarMachineSnapshots, canCreateWorkspaceForSelection: scope.canCreateWorkspace( @@ -862,6 +883,13 @@ struct WorkspaceShellView: View { guard let request = store.deeplinkWorkspaceNavigationRequest else { return } guard let workspaceID = store.consumeDeeplinkWorkspaceNavigationRequest() else { return } #if os(iOS) + if request.origin == .agentFeed { + transitionPrimaryTab(to: .feed) + if agentFeedNavigationPath.last != workspaceID { + agentFeedNavigationPath = [workspaceID] + } + return + } if request.origin == .notificationFeed { switch primarySearchCoordinator.notificationFeedNavigationRoute( selectedTab: selectedPrimaryTab @@ -912,7 +940,7 @@ struct WorkspaceShellView: View { if notificationNavigationPath.last != workspaceID { notificationNavigationPath = [workspaceID] } - case .search: + case .feed, .search: break } } @@ -924,7 +952,7 @@ struct WorkspaceShellView: View { ) -> Bool { let previousTab = selectedPrimaryTab if (selectedPrimaryTab == .search || primarySearchCoordinator.isPresented), - tab.searchScope != nil { + tab != .search { primarySearchCoordinator.deactivateCurrentSearch() } beforeSelection() @@ -977,6 +1005,7 @@ struct WorkspaceShellView: View { switch tab { case .workspaces: .workspaces case .notifications: .notifications + case .feed: .feed case .search: .search } } diff --git a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/Debug/UITestConfig+AgentFeedPreview.swift b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/Debug/UITestConfig+AgentFeedPreview.swift new file mode 100644 index 00000000000..657513f0769 --- /dev/null +++ b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/Debug/UITestConfig+AgentFeedPreview.swift @@ -0,0 +1,22 @@ +import Foundation + +public extension UITestConfig { + static var agentFeedPreviewEnabled: Bool { + agentFeedPreviewEnabled( + from: ProcessInfo.processInfo.environment, + arguments: ProcessInfo.processInfo.arguments + ) + } + + static func agentFeedPreviewEnabled( + from env: [String: String], + arguments: [String] = [] + ) -> Bool { + #if DEBUG + env["CMUX_UITEST_AGENT_FEED_PREVIEW"] == "1" + || arguments.contains("CMUX_UITEST_AGENT_FEED_PREVIEW=1") + #else + false + #endif + } +} diff --git a/Packages/iOS/CmuxMobileSupport/Tests/CmuxMobileSupportTests/UITestConfigTests.swift b/Packages/iOS/CmuxMobileSupport/Tests/CmuxMobileSupportTests/UITestConfigTests.swift index df0d3c20aee..935074e4606 100644 --- a/Packages/iOS/CmuxMobileSupport/Tests/CmuxMobileSupportTests/UITestConfigTests.swift +++ b/Packages/iOS/CmuxMobileSupport/Tests/CmuxMobileSupportTests/UITestConfigTests.swift @@ -434,5 +434,18 @@ import Testing environment: ["CMUX_UITEST_SCANNER_PREVIEW": "0"] ).pairingScannerPreviewEnabled == false) } + + @Test func agentFeedPreviewAcceptsEnvironmentOrLaunchArgument() { + #expect(UITestConfig.agentFeedPreviewEnabled( + from: ["CMUX_UITEST_AGENT_FEED_PREVIEW": "1"] + )) + #expect(UITestConfig.agentFeedPreviewEnabled( + from: [:], + arguments: ["CMUX_UITEST_AGENT_FEED_PREVIEW=1"] + )) + #expect(!UITestConfig.agentFeedPreviewEnabled( + from: ["CMUX_UITEST_AGENT_FEED_PREVIEW": "0"] + )) + } #endif } diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamEvent.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamEvent.swift index b26b9516659..866e1ea6854 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamEvent.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamEvent.swift @@ -10,6 +10,10 @@ import Foundation public struct WorkstreamEvent: Codable, Sendable, Equatable { public let sessionId: String public let hookEventName: HookEventName + /// Original discriminator when a newer agent sends an event this build + /// does not know yet. The typed enum falls back to `.notification` so the + /// event remains visible instead of being rejected at the socket boundary. + public let rawHookEventName: String? public let source: String public let workspaceId: String? public let surfaceId: String? @@ -28,6 +32,7 @@ public struct WorkstreamEvent: Codable, Sendable, Equatable { public init( sessionId: String, hookEventName: HookEventName, + rawHookEventName: String? = nil, source: String, workspaceId: String? = nil, surfaceId: String? = nil, @@ -44,6 +49,7 @@ public struct WorkstreamEvent: Codable, Sendable, Equatable { ) { self.sessionId = sessionId self.hookEventName = hookEventName + self.rawHookEventName = rawHookEventName self.source = source self.workspaceId = workspaceId self.surfaceId = surfaceId @@ -103,7 +109,14 @@ public struct WorkstreamEvent: Codable, Sendable, Equatable { public init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) self.sessionId = try c.decode(String.self, forKey: .sessionId) - self.hookEventName = try c.decode(HookEventName.self, forKey: .hookEventName) + let rawHookEventName = try c.decode(String.self, forKey: .hookEventName) + if let knownHookEventName = HookEventName(rawValue: rawHookEventName) { + self.hookEventName = knownHookEventName + self.rawHookEventName = nil + } else { + self.hookEventName = .notification + self.rawHookEventName = rawHookEventName + } self.source = try c.decode(String.self, forKey: .source) self.workspaceId = try c.decodeIfPresent(String.self, forKey: .workspaceId) self.surfaceId = try c.decodeIfPresent(String.self, forKey: .surfaceId) @@ -136,7 +149,7 @@ public struct WorkstreamEvent: Codable, Sendable, Equatable { public func encode(to encoder: Encoder) throws { var c = encoder.container(keyedBy: CodingKeys.self) try c.encode(sessionId, forKey: .sessionId) - try c.encode(hookEventName, forKey: .hookEventName) + try c.encode(rawHookEventName ?? hookEventName.rawValue, forKey: .hookEventName) try c.encode(source, forKey: .source) try c.encodeIfPresent(workspaceId, forKey: .workspaceId) try c.encodeIfPresent(surfaceId, forKey: .surfaceId) diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamItem.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamItem.swift index 09706d73c60..43ee4a90bcb 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamItem.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamItem.swift @@ -1,5 +1,11 @@ import Foundation +public enum WorkstreamFormAction: String, Codable, Sendable, Equatable { + case accept + case decline + case cancel +} + /// The user's decision on a resolved actionable item. public enum WorkstreamDecision: Codable, Sendable, Equatable { case permission(WorkstreamPermissionMode) @@ -9,6 +15,9 @@ public enum WorkstreamDecision: Codable, Sendable, Equatable { /// rather than proceeding. case exitPlan(WorkstreamExitPlanMode, feedback: String? = nil) case question(selections: [String]) + /// Explicit form lifecycle actions used by MCP elicitation. Accept carries + /// the submitted field selections; decline and cancel carry none. + case form(action: WorkstreamFormAction, selections: [String]) } /// Lifecycle state of a `WorkstreamItem`. @@ -35,11 +44,19 @@ public struct WorkstreamItem: Identifiable, Codable, Sendable, Equatable { public let id: UUID public let workstreamId: String public let source: WorkstreamSource + /// Original source tag from the hook wire frame. This preserves future + /// agents that this build does not yet know instead of relabeling them as + /// Claude when the item is forwarded to another client. + public let sourceRawValue: String? public let kind: WorkstreamKind public let createdAt: Date public var updatedAt: Date public var cwd: String? public var title: String? + /// Exact workspace route captured when the event entered Feed. + public var workspaceId: String? + /// Exact surface route captured when the event entered Feed. + public var surfaceId: String? public var status: WorkstreamStatus public var payload: WorkstreamPayload public var context: WorkstreamContext? @@ -54,11 +71,14 @@ public struct WorkstreamItem: Identifiable, Codable, Sendable, Equatable { id: UUID = UUID(), workstreamId: String, source: WorkstreamSource, + sourceRawValue: String? = nil, kind: WorkstreamKind, createdAt: Date = Date(), updatedAt: Date? = nil, cwd: String? = nil, title: String? = nil, + workspaceId: String? = nil, + surfaceId: String? = nil, status: WorkstreamStatus? = nil, payload: WorkstreamPayload, context: WorkstreamContext? = nil, @@ -67,11 +87,14 @@ public struct WorkstreamItem: Identifiable, Codable, Sendable, Equatable { self.id = id self.workstreamId = workstreamId self.source = source + self.sourceRawValue = sourceRawValue self.kind = kind self.createdAt = createdAt self.updatedAt = updatedAt ?? createdAt self.cwd = cwd self.title = title + self.workspaceId = workspaceId + self.surfaceId = surfaceId let resolvedStatus = status ?? (kind.isActionable ? .pending : .telemetry) self.status = kind.isActionable ? resolvedStatus : .telemetry self.payload = payload diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamPayload.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamPayload.swift index 911a1a739f9..f1095db8c24 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamPayload.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamPayload.swift @@ -5,6 +5,9 @@ import Foundation public enum WorkstreamPermissionMode: String, Codable, Sendable, Equatable, CaseIterable { case once case always + /// Persist this exact approval across agent sessions when the provider + /// advertises a durable approval primitive. + case persistent case all case bypass case deny @@ -35,6 +38,26 @@ public struct WorkstreamQuestionOption: Codable, Sendable, Equatable { } } +/// The editor primitive represented by one Feed question. Keeping this +/// metadata on the existing question envelope lets newer agents expose +/// booleans and elicitation forms without introducing a second reply +/// transport. Unknown values decode as `nil` and remain inspectable. +public enum WorkstreamQuestionInputType: String, Codable, Sendable, Equatable { + case choice + case boolean + case text + case number + case integer + case url + case email + case date + case dateTime + case secret + /// A URL-mode elicitation completed outside cmux. The Feed exposes the + /// trusted HTTP(S) link and waits for the agent to resolve the request. + case external +} + /// One prompt inside a `.question` payload. Claude Code's /// `AskUserQuestion` tool can include several questions in a single /// call, so a payload carries an array of these. @@ -46,19 +69,62 @@ public struct WorkstreamQuestionPrompt: Codable, Sendable, Equatable, Identifiab public let prompt: String public let multiSelect: Bool public let options: [WorkstreamQuestionOption] + /// Whether a free-form "Other" answer is accepted. Legacy Claude + /// questions omit this field and keep the historical free-form control. + public let allowsOther: Bool? + /// Optional input primitive. Existing question payloads leave this nil. + public let inputType: WorkstreamQuestionInputType? + /// Whether a form field must have a value before submission. + public let required: Bool? + public let defaultValue: String? + public let placeholder: String? + /// MCP elicitation URL forms can be opened in the owning Agent surface. + public let externalURL: String? + /// Restricted JSON Schema constraints preserved end to end so both the + /// phone and authoritative Mac validate the same response. + public let minimum: Double? + public let maximum: Double? + public let minLength: Int? + public let maxLength: Int? + public let minSelections: Int? + public let maxSelections: Int? public init( id: String, header: String? = nil, prompt: String, multiSelect: Bool, - options: [WorkstreamQuestionOption] + options: [WorkstreamQuestionOption], + allowsOther: Bool? = nil, + inputType: WorkstreamQuestionInputType? = nil, + required: Bool? = nil, + defaultValue: String? = nil, + placeholder: String? = nil, + externalURL: String? = nil, + minimum: Double? = nil, + maximum: Double? = nil, + minLength: Int? = nil, + maxLength: Int? = nil, + minSelections: Int? = nil, + maxSelections: Int? = nil ) { self.id = id self.header = header self.prompt = prompt self.multiSelect = multiSelect self.options = options + self.allowsOther = allowsOther + self.inputType = inputType + self.required = required + self.defaultValue = defaultValue + self.placeholder = placeholder + self.externalURL = externalURL + self.minimum = minimum + self.maximum = maximum + self.minLength = minLength + self.maxLength = maxLength + self.minSelections = minSelections + self.maxSelections = maxSelections } } diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamPersistence.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamPersistence.swift index b0543c58903..f7ee2e6aa33 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamPersistence.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamPersistence.swift @@ -10,28 +10,43 @@ import Foundation /// load runs once per process at launch. public actor WorkstreamPersistence { public struct Page: Sendable, Equatable { + /// Decoded items in oldest-first order. public let items: [WorkstreamItem] + /// Whether the log contains rows before this page. public let hasMoreBefore: Bool + /// Byte offset of the first selected JSONL row. public let startOffset: UInt64? + /// Byte offsets corresponding one-to-one with decoded `items`. + public let itemStartOffsets: [UInt64] + /// Creates a persisted-history page. public init( items: [WorkstreamItem], hasMoreBefore: Bool, - startOffset: UInt64? + startOffset: UInt64?, + itemStartOffsets: [UInt64] = [] ) { self.items = items self.hasMoreBefore = hasMoreBefore self.startOffset = startOffset + self.itemStartOffsets = itemStartOffsets } } private let fileURL: URL private let encoder: JSONEncoder private let decoder: JSONDecoder + private let beforeAppend: (@Sendable () async -> Void)? private var handle: FileHandle? + private(set) var loadPageCallCount = 0 public init(fileURL: URL) { + self.init(fileURL: fileURL, beforeAppend: nil) + } + + init(fileURL: URL, beforeAppend: (@Sendable () async -> Void)?) { self.fileURL = fileURL + self.beforeAppend = beforeAppend let enc = JSONEncoder() enc.dateEncodingStrategy = .iso8601 self.encoder = enc @@ -50,7 +65,8 @@ public actor WorkstreamPersistence { /// Appends a single item as a JSON line. Creates the file and parent /// directory lazily on first write. - public func append(_ item: WorkstreamItem) throws { + public func append(_ item: WorkstreamItem) async throws { + await beforeAppend?() let data = try encoder.encode(item.redactedForPersistence()) var line = data line.append(0x0A) // "\n" @@ -73,6 +89,7 @@ public actor WorkstreamPersistence { endingBefore endOffset: UInt64? = nil, limit: Int ) throws -> Page { + loadPageCallCount += 1 guard limit > 0 else { return Page(items: [], hasMoreBefore: false, startOffset: nil) } @@ -110,11 +127,13 @@ public actor WorkstreamPersistence { } let selectedRanges = lineRanges.suffix(limit) var out: [WorkstreamItem] = [] + var outOffsets: [UInt64] = [] out.reserveCapacity(selectedRanges.count) for lineRange in selectedRanges { let slice = tail.subdata(in: lineRange.range) if let item = try? decoder.decode(WorkstreamItem.self, from: slice) { out.append(item) + outOffsets.append(lineRange.startOffset) } // Malformed lines are dropped silently; the audit log is // append-only and we don't want a corrupt row to block startup. @@ -123,10 +142,43 @@ public actor WorkstreamPersistence { return Page( items: out, hasMoreBefore: (startOffset ?? 0) > 0, - startOffset: startOffset + startOffset: startOffset, + itemStartOffsets: outOffsets ) } + /// Returns the persisted item's identity when `startOffset` points at the + /// beginning of a valid JSONL row. The lookup seeks directly to the row, + /// so cursor validation is independent of the number of persisted items. + func itemID(startingAt startOffset: UInt64) throws -> UUID? { + guard FileManager.default.fileExists(atPath: fileURL.path) else { return nil } + let fh = try FileHandle(forReadingFrom: fileURL) + defer { try? fh.close() } + let fileSize = try fh.seekToEnd() + guard startOffset < fileSize else { return nil } + + if startOffset > 0 { + try fh.seek(toOffset: startOffset - 1) + guard try fh.read(upToCount: 1)?.first == 0x0A else { return nil } + } + + try fh.seek(toOffset: startOffset) + var row = Data() + let chunkSize = 64 * 1024 + while let chunk = try fh.read(upToCount: chunkSize), !chunk.isEmpty { + if let newline = chunk.firstIndex(of: 0x0A) { + row.append(chunk.prefix(upTo: newline)) + break + } + row.append(chunk) + } + guard !row.isEmpty, + let item = try? decoder.decode(WorkstreamItem.self, from: row) else { + return nil + } + return item.id + } + /// Truncates the JSONL file. Used by `cmux feed clear`. public func clear() throws { if let fh = handle { diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamQuestionPrompt+Parsing.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamQuestionPrompt+Parsing.swift index 2bebcb819aa..d33e94e7cb8 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamQuestionPrompt+Parsing.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamQuestionPrompt+Parsing.swift @@ -16,37 +16,238 @@ extension WorkstreamQuestionPrompt { makeParsedQuestion(from: question, fallbackId: "q\(index)") } } + if let fields = root["fields"] as? [[String: Any]] { + return fields.enumerated().map { index, field in + makeParsedQuestion(from: field, fallbackId: "field\(index)", formField: true) + } + } + let schema = (root["schema"] as? [String: Any]) + ?? (root["requestedSchema"] as? [String: Any]) + ?? (root["requested_schema"] as? [String: Any]) + ?? root + if let properties = schema["properties"] as? [String: Any] { + let required = Set(schema["required"] as? [String] ?? []) + return properties.keys.sorted().enumerated().compactMap { index, key in + guard var field = properties[key] as? [String: Any] else { return nil } + field["id"] = key + field["required"] = required.contains(key) + return makeParsedQuestion(from: field, fallbackId: "field\(index)", formField: true) + } + } return [makeParsedQuestion(from: root, fallbackId: "q0")] } private static func makeParsedQuestion( from dictionary: [String: Any], - fallbackId: String + fallbackId: String, + formField: Bool = false ) -> WorkstreamQuestionPrompt { let header = (dictionary["header"] as? String) ?? (dictionary["title"] as? String) let prompt = (dictionary["question"] as? String) ?? (dictionary["prompt"] as? String) - ?? "" + ?? (dictionary["title"] as? String) + ?? (dictionary["description"] as? String) + ?? (dictionary["message"] as? String) + ?? (dictionary["id"] as? String) + ?? fallbackId + let declaredType = (dictionary["input_type"] as? String) + ?? (dictionary["inputType"] as? String) + ?? (dictionary["type"] as? String) + ?? (dictionary["kind"] as? String) let multiSelect = (dictionary["multiSelect"] as? Bool) ?? (dictionary["multi_select"] as? Bool) - ?? false - let rawOptions = dictionary["options"] as? [Any] ?? [] + ?? (dictionary["multiple"] as? Bool) + ?? (declaredType?.lowercased() == "array") + let items = dictionary["items"] as? [String: Any] + let rawOptions: [Any] + let enumNames: [String]? + if let options = dictionary["options"] as? [Any] { + rawOptions = options + enumNames = nil + } else if let values = dictionary["oneOf"] as? [Any] { + rawOptions = values + enumNames = nil + } else if let values = items?["anyOf"] as? [Any] { + rawOptions = values + enumNames = nil + } else if let values = dictionary["enum"] as? [Any] { + rawOptions = values + enumNames = dictionary["enumNames"] as? [String] + } else if let values = items?["enum"] as? [Any] { + rawOptions = values + enumNames = items?["enumNames"] as? [String] + } else { + rawOptions = [] + enumNames = nil + } let options = rawOptions.enumerated().compactMap { index, raw -> WorkstreamQuestionOption? in - if let label = raw as? String { - return WorkstreamQuestionOption(id: "opt\(index)", label: label) + if let scalar = scalarString(raw) { + return WorkstreamQuestionOption( + id: "opt\(index)", + label: enumNames?[safe: index] ?? scalar + ) } guard let option = raw as? [String: Any] else { return nil } - let id = (option["id"] as? String) ?? "opt\(index)" + let constant = option["const"].flatMap(scalarString) + let id = (option["id"] as? String) + ?? (option["value"] as? String) + ?? constant + ?? "opt\(index)" let label = (option["label"] as? String) ?? (option["title"] as? String) ?? id let description = (option["description"] as? String) ?? (option["detail"] as? String) return WorkstreamQuestionOption(id: id, label: label, description: description) } + let formatType: String? = { + guard let format = dictionary["format"] as? String else { return nil } + switch format.lowercased() { + case "url", "uri", "uri-reference": return "url" + case "email": return "email" + case "date": return "date" + case "date-time", "datetime": return "date_time" + case "password", "secret": return "secret" + default: return nil + } + }() + let rawType: String? = { + if let declaredType { + if !options.isEmpty, + ["array", "string", "number", "integer"].contains(declaredType.lowercased()) { + return "choice" + } + if declaredType.lowercased() == "string", let formatType { + return formatType + } + return declaredType + } + if let formatType { + return formatType + } + return (dictionary["isSecret"] as? Bool) == true + || (dictionary["writeOnly"] as? Bool) == true ? "secret" : nil + }() + let normalizedType = rawType?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let inputType: WorkstreamQuestionInputType? + switch normalizedType { + case "boolean", "bool", "confirmation", "confirm", "yes_no": + inputType = .boolean + case "text", "string", "textarea": + inputType = .text + case "number", "decimal": + inputType = .number + case "integer", "int": + inputType = .integer + case "url", "uri": + inputType = .url + case "email": + inputType = .email + case "date": + inputType = .date + case "date_time", "datetime", "date-time": + inputType = .dateTime + case "secret", "password": + inputType = .secret + case "external", "external_url", "link": + inputType = .external + case "choice", "select", "enum", "radio", "multiselect", "multi_select": + inputType = .choice + default: + inputType = formField ? .text : nil + } + var resolvedOptions = options + if inputType == .boolean, resolvedOptions.isEmpty { + resolvedOptions = [ + WorkstreamQuestionOption( + id: "yes", + label: String(localized: "feed.question.boolean.yes", defaultValue: "Yes") + ), + WorkstreamQuestionOption( + id: "no", + label: String(localized: "feed.question.boolean.no", defaultValue: "No") + ), + ] + } + let rawDefault: Any? = dictionary["default"] ?? dictionary["default_value"] + let rawDefaultValue: String? + if let value = rawDefault as? String { + rawDefaultValue = value + } else if let value = rawDefault as? Bool { + rawDefaultValue = value ? "true" : "false" + } else if let value = rawDefault as? NSNumber { + rawDefaultValue = value.stringValue + } else { + rawDefaultValue = nil + } + let defaultValue: String? = rawDefaultValue.map { value in + if inputType == .boolean { + switch value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "1", "true", "yes", "y", "on": return "yes" + case "0", "false", "no", "n", "off": return "no" + default: break + } + } + guard inputType == .choice else { return value } + if let option = resolvedOptions.first(where: { $0.id == value || $0.label == value }) { + return option.id + } + if let index = rawOptions.firstIndex(where: { raw in + if let dictionary = raw as? [String: Any], let constant = dictionary["const"] { + return scalarString(constant) == value + } + return scalarString(raw) == value + }), resolvedOptions.indices.contains(index) { + return resolvedOptions[index].id + } + return value + } + let explicitAllowsOther = (dictionary["isOther"] as? Bool) + ?? (dictionary["is_other"] as? Bool) + ?? (dictionary["allowsOther"] as? Bool) + ?? (dictionary["allows_other"] as? Bool) + ?? (dictionary["custom"] as? Bool) + let allowsOther = explicitAllowsOther ?? (formField && !resolvedOptions.isEmpty ? false : nil) return WorkstreamQuestionPrompt( id: (dictionary["id"] as? String) ?? fallbackId, header: header, prompt: prompt, multiSelect: multiSelect, - options: options + options: resolvedOptions, + allowsOther: allowsOther, + inputType: inputType, + required: dictionary["required"] as? Bool, + defaultValue: defaultValue, + placeholder: (dictionary["placeholder"] as? String) + ?? (dictionary["description"] as? String), + externalURL: (dictionary["url"] as? String) + ?? (dictionary["uri"] as? String) + ?? (dictionary["external_url"] as? String) + ?? (dictionary["externalURL"] as? String), + minimum: (dictionary["minimum"] as? NSNumber)?.doubleValue, + maximum: (dictionary["maximum"] as? NSNumber)?.doubleValue, + minLength: (dictionary["minLength"] as? NSNumber)?.intValue, + maxLength: (dictionary["maxLength"] as? NSNumber)?.intValue, + minSelections: (dictionary["minItems"] as? NSNumber)?.intValue, + maxSelections: (dictionary["maxItems"] as? NSNumber)?.intValue ) } + + private static func scalarString(_ raw: Any) -> String? { + if let value = raw as? String { return value } + if let value = raw as? NSNumber { + // JSONSerialization bridges booleans and numbers through + // NSNumber. Inspect the Objective-C type so numeric 0/1 do not + // become boolean options accidentally. + if String(cString: value.objCType) == "c" { + return value.boolValue ? "true" : "false" + } + return value.stringValue + } + if let value = raw as? Bool { return value ? "true" : "false" } + return nil + } +} + +private extension Collection { + subscript(safe index: Index) -> Element? { + indices.contains(index) ? self[index] : nil + } } diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamStore.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamStore.swift index 90e780918c1..626744a9b1e 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamStore.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamStore.swift @@ -21,6 +21,23 @@ public let WorkstreamDefaultHistoryPageSize = 300 @MainActor @Observable public final class WorkstreamStore { + /// One page of immutable Feed history for mobile clients. + public struct HistoryPage: Sendable, Equatable { + /// Items in oldest-first order. + public let items: [WorkstreamItem] + /// Opaque cursor for the next older page. + public let nextCursor: String? + /// Whether persisted history exists before this page. + public let hasMore: Bool + + /// Creates a mobile history page. + public init(items: [WorkstreamItem], nextCursor: String?, hasMore: Bool) { + self.items = items + self.nextCursor = nextCursor + self.hasMore = hasMore + } + } + public private(set) var items: [WorkstreamItem] = [] public private(set) var hasMorePersistedItems = false public private(set) var isLoadingOlderItems = false @@ -41,6 +58,10 @@ public final class WorkstreamStore { private let clock: @Sendable () -> Date private let titleProvider: (WorkstreamEvent) -> String? private var oldestLoadedPersistenceOffset: UInt64? + private var pendingPersistenceItems: [WorkstreamItem] = [] + private var persistenceDrainTask: Task? + + var activePersistenceDrainCount: Int { persistenceDrainTask == nil ? 0 : 1 } /// Last known conversational context for each workstream. Tool hooks /// usually arrive without the surrounding user prompt, so the store @@ -78,7 +99,7 @@ public final class WorkstreamStore { public func start() async { if let persistence { if let page = try? await persistence.loadPage(limit: min(initialLoadLimit, ringCapacity)) { - items = page.items + items = expiringRestoredPendingItems(page.items) hasMorePersistedItems = page.hasMoreBefore oldestLoadedPersistenceOffset = page.startOffset rebuildContextIndex() @@ -116,7 +137,8 @@ public final class WorkstreamStore { } let existingIds = Set(items.map(\.id)) - let olderItems = page.items.filter { !existingIds.contains($0.id) } + let olderItems = expiringRestoredPendingItems(page.items) + .filter { !existingIds.contains($0.id) } if !olderItems.isEmpty { items.insert(contentsOf: olderItems, at: 0) } @@ -125,6 +147,103 @@ public final class WorkstreamStore { rebuildContextIndex() } + /// Loads one immutable persisted-history page for authenticated mobile Feed. + /// The item cursor is stable while newer JSONL rows are appended. + public func historyPage(endingBefore cursor: String?, limit: Int) async throws -> HistoryPage { + let boundedLimit = min(max(limit, 1), WorkstreamDefaultHistoryPageSize) + guard let persistence else { + let end: Int + if let cursor { + let decoded = try Self.decodeHistoryCursor(cursor, expectedVersion: "m1") + guard decoded.position < UInt64(items.count), + items[Int(decoded.position)].id == decoded.itemID else { + throw WorkstreamHistoryError.invalidCursor + } + end = Int(decoded.position) + } else { + end = items.count + } + let start = max(0, end - boundedLimit) + let pageItems = Array(items[start.. 0 ? pageItems.first.map { Self.historyCursor(version: "m1", position: UInt64(start), itemID: $0.id) } : nil, + hasMore: start > 0 + ) + } + while let drain = persistenceDrainTask { + await drain.value + } + let endOffset: UInt64? + if let cursor { + let decoded = try Self.decodeHistoryCursor(cursor, expectedVersion: "p1") + guard try await persistence.itemID(startingAt: decoded.position) == decoded.itemID else { + throw WorkstreamHistoryError.invalidCursor + } + endOffset = decoded.position + } else { + endOffset = nil + } + let page = try await persistence.loadPage(endingBefore: endOffset, limit: boundedLimit) + let currentByID = Dictionary(uniqueKeysWithValues: items.map { ($0.id, $0) }) + var pageItems = expiringRestoredPendingItems(page.items) + .map { currentByID[$0.id] ?? $0 } + var droppedPersistedItems = false + if cursor == nil { + let persistedIDs = Set(pageItems.map(\.id)) + let liveTailStart = page.items.last + .flatMap { persisted in items.firstIndex(where: { $0.id == persisted.id }) } + .map { items.index(after: $0) } + ?? items.startIndex + let liveCandidates = items[liveTailStart...].filter { !persistedIDs.contains($0.id) } + let liveLimit = pageItems.isEmpty ? boundedLimit : max(0, boundedLimit - 1) + let liveTail = liveCandidates.suffix(liveLimit) + pageItems.append(contentsOf: liveTail) + if pageItems.count > boundedLimit { + let overflow = pageItems.count - boundedLimit + droppedPersistedItems = overflow > 0 && !page.items.isEmpty + pageItems.removeFirst(overflow) + } + } + let firstPersisted = pageItems.first.flatMap { first in + page.items.firstIndex(where: { $0.id == first.id }).flatMap { index in + page.itemStartOffsets.indices.contains(index) ? page.itemStartOffsets[index] : nil + }.map { (first, $0) } + } + let nextCursor = (page.hasMoreBefore || droppedPersistedItems) + ? firstPersisted.map { item, offset in + Self.historyCursor(version: "p1", position: offset, itemID: item.id) + } + : nil + return HistoryPage( + items: pageItems, + nextCursor: nextCursor, + hasMore: nextCursor != nil + ) + } + + private static func historyCursor(version: String, position: UInt64, itemID: UUID) -> String { + Data("\(version):\(position):\(itemID.uuidString)".utf8).base64EncodedString() + } + + private static func decodeHistoryCursor( + _ cursor: String, + expectedVersion: String + ) throws -> (position: UInt64, itemID: UUID) { + guard let data = Data(base64Encoded: cursor), + let raw = String(data: data, encoding: .utf8) else { + throw WorkstreamHistoryError.invalidCursor + } + let parts = raw.split(separator: ":", omittingEmptySubsequences: false) + guard parts.count == 3, + parts[0] == Substring(expectedVersion), + let position = UInt64(parts[1]), + let itemID = UUID(uuidString: String(parts[2])) else { + throw WorkstreamHistoryError.invalidCursor + } + return (position, itemID) + } + // MARK: - Ingest /// Applies an inbound wire frame. Creates or updates a @@ -135,9 +254,8 @@ public final class WorkstreamStore { insert(item) updateContextIndex(with: item) if let persistence { - Task { [persistence, item] in - try? await persistence.append(item) - } + pendingPersistenceItems.append(item) + startPersistenceDrainIfNeeded(persistence: persistence) } } @@ -157,7 +275,10 @@ public final class WorkstreamStore { guard let idx = items.firstIndex(where: { $0.id == itemId }) else { return } guard items[idx].status.isPending else { return } let now = clock() - items[idx].status = .resolved(decision, at: now) + items[idx].status = .resolved( + Self.decisionForHistory(decision, payload: items[idx].payload), + at: now + ) items[idx].updatedAt = now } @@ -170,6 +291,50 @@ public final class WorkstreamStore { items[idx].updatedAt = now } + /// Appends a user reply after a completed turn. The synthetic user-prompt + /// row is authoritative for mobile Feed filtering: once it exists, the + /// preceding stop/session-end row is historical and cannot be submitted + /// again. The exact item identity and route are validated by the caller. + public func canAppendUserReply(to itemId: UUID, text: String) -> Bool { + guard let sourceIndex = items.firstIndex(where: { $0.id == itemId }), + !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return false + } + return isTurnCompletion(items[sourceIndex]) + && items.lastIndex(where: { $0.workstreamId == items[sourceIndex].workstreamId }) == sourceIndex + } + + @discardableResult + public func appendUserReply(to itemId: UUID, text: String) -> Bool { + guard canAppendUserReply(to: itemId, text: text), + let sourceIndex = items.firstIndex(where: { $0.id == itemId }) else { return false } + let source = items[sourceIndex] + let now = clock() + let reply = WorkstreamItem( + workstreamId: source.workstreamId, + source: source.source, + sourceRawValue: source.sourceRawValue, + kind: .userPrompt, + createdAt: now, + updatedAt: now, + cwd: source.cwd, + title: source.title, + workspaceId: source.workspaceId, + surfaceId: source.surfaceId, + status: .telemetry, + payload: .userPrompt(text: text), + context: source.context, + ppid: source.ppid + ) + insert(reply) + updateContextIndex(with: reply) + if let persistence { + pendingPersistenceItems.append(reply) + startPersistenceDrainIfNeeded(persistence: persistence) + } + return true + } + /// Marks every still-pending item created before `threshold` as /// expired. Call periodically to clean stale items. public func expirePending(olderThan threshold: TimeInterval) { @@ -193,6 +358,32 @@ public final class WorkstreamStore { } } + private func expiringRestoredPendingItems(_ restoredItems: [WorkstreamItem]) -> [WorkstreamItem] { + let now = clock() + return restoredItems.map { restoredItem in + guard restoredItem.status.isPending else { return restoredItem } + var expiredItem = restoredItem + expiredItem.status = .expired(at: now) + expiredItem.updatedAt = now + return expiredItem + } + } + + private func startPersistenceDrainIfNeeded(persistence: WorkstreamPersistence) { + guard persistenceDrainTask == nil else { return } + persistenceDrainTask = Task { @MainActor [weak self, persistence] in + guard let self else { return } + while !Task.isCancelled, !self.pendingPersistenceItems.isEmpty { + let batch = self.pendingPersistenceItems + self.pendingPersistenceItems.removeAll(keepingCapacity: true) + for item in batch { + try? await persistence.append(item) + } + } + self.persistenceDrainTask = nil + } + } + private func applyResolution(for action: WorkstreamAction) { switch action { case .approvePermission(let itemId, let mode): @@ -214,11 +405,14 @@ public final class WorkstreamStore { return WorkstreamItem( workstreamId: event.sessionId, source: source, + sourceRawValue: event.source, kind: kind, createdAt: event.receivedAt, updatedAt: event.receivedAt, cwd: event.cwd, title: defaultTitle(for: event), + workspaceId: event.workspaceId, + surfaceId: event.surfaceId, status: status, payload: payload, context: context(for: event, payload: payload), @@ -226,6 +420,61 @@ public final class WorkstreamStore { ) } + private func isTurnCompletion(_ item: WorkstreamItem) -> Bool { + switch item.payload { + case .stop, .sessionEnd: + return true + default: + return false + } + } + + /// Feed keeps resolved choices visible, but secret elicitation values must + /// never enter history or the phone cache. The blocking waiter retains the + /// original decision independently, so the originating agent still gets + /// the exact submitted value. + private static func decisionForHistory( + _ decision: WorkstreamDecision, + payload: WorkstreamPayload + ) -> WorkstreamDecision { + let selections: [String] + let formAction: WorkstreamFormAction? + switch decision { + case .question(let values): + selections = values + formAction = nil + case .form(let action, let values): + selections = values + formAction = action + default: + return decision + } + guard case .question(_, let questions) = payload else { + return decision + } + let secretIDs = Set( + questions.lazy + .filter { $0.inputType == .secret } + .map(\.id) + ) + guard !secretIDs.isEmpty else { return decision } + let safeSelections = selections.map { selection in + guard let separator = selection.firstIndex(of: "=") else { + // Legacy desktop and notification clients may submit one + // unkeyed value per prompt. Once a secret field is present, + // do not persist an ambiguous value that could be its answer. + return "" + } + let fieldID = String(selection[.." + } + if let formAction { + return .form(action: formAction, selections: safeSelections) + } + return .question(selections: safeSelections) + } + /// Marks every pending item with `ppid` as `.expired`. Meant to /// be called from a kqueue/DispatchSource process-exit handler /// so the exact moment an agent dies, its pending cards close. @@ -278,6 +527,21 @@ public final class WorkstreamStore { source: WorkstreamSource ) -> (WorkstreamKind, WorkstreamPayload) { let toolInput = event.toolInputJSON ?? "{}" + if let rawEvent = event.rawHookEventName, + Self.isQuestionEvent(rawEvent) { + let parsed = WorkstreamQuestionPrompt.parse(toolInputJSON: event.toolInputJSON) + return ( + .question, + .question( + requestId: event.requestId ?? event.sessionId, + questions: parsed + ) + ) + } + if let rawEvent = event.rawHookEventName, + let telemetry = Self.unknownTelemetry(rawEvent, event: event, toolInput: toolInput) { + return telemetry + } switch event.hookEventName { case .permissionRequest: return ( @@ -343,8 +607,81 @@ public final class WorkstreamStore { case .todoWrite: return (.todos, .todos(Self.todos(from: event.toolInputJSON))) case .notification: - return (.toolResult, .toolResult(toolName: "notification", resultJSON: toolInput, isError: false)) + return ( + .toolResult, + .toolResult( + toolName: event.rawHookEventName ?? "notification", + resultJSON: toolInput, + isError: event.isError ?? false + ) + ) + } + } + + private static func isQuestionEvent(_ rawEvent: String) -> Bool { + let normalized = rawEvent.unicodeScalars + .filter { CharacterSet.alphanumerics.contains($0) } + .map(String.init) + .joined() + .lowercased() + return [ + "askuserquestion", + "askuserconfirmation", + "booleanquestion", + "questionasked", + "questionv2asked", + "elicitation", + "elicitationrequest", + "mcpelicitation", + "mcpserverelicitationrequest", + "requestuserinput", + "userinputrequest", + "inputrequest", + "toolrequestuserinput", + "itemtoolrequestuserinput", + "question", + "askuser", + ].contains(normalized) + } + + private static func unknownTelemetry( + _ rawEvent: String, + event: WorkstreamEvent, + toolInput: String + ) -> (WorkstreamKind, WorkstreamPayload)? { + let normalized = rawEvent + .replacingOccurrences(of: "_", with: "") + .replacingOccurrences(of: "/", with: "") + .lowercased() + let toolName = event.toolName ?? rawEvent + if normalized.contains("failure") || normalized.contains("error") { + return ( + .toolResult, + .toolResult(toolName: toolName, resultJSON: toolInput, isError: true) + ) + } + if normalized.contains("completed") || normalized.contains("result") || normalized.contains("stop") || normalized.contains("idle") { + return ( + .toolResult, + .toolResult(toolName: toolName, resultJSON: toolInput, isError: event.isError ?? false) + ) } + if normalized.contains("reasoning") || normalized.contains("message") { + return ( + .assistantMessage, + .assistantMessage(text: Self.promptText(from: event.toolInputJSON)) + ) + } + if normalized.contains("task") || normalized.contains("plan") || normalized.contains("started") || normalized.contains("command") || normalized.contains("filechange") { + return ( + .toolUse, + .toolUse(toolName: toolName, toolInputJSON: toolInput) + ) + } + return ( + .toolResult, + .toolResult(toolName: toolName, resultJSON: toolInput, isError: event.isError ?? false) + ) } private func defaultTitle(for event: WorkstreamEvent) -> String? { @@ -385,6 +722,23 @@ public final class WorkstreamStore { context = WorkstreamContext(lastUserMessage: text).mergingMissing(from: context) case .assistantMessage(let text): context = WorkstreamContext(assistantPreamble: text).mergingMissing(from: context) + case .stop, .sessionEnd: + // Stop hooks commonly carry the final assistant turn as a + // top-level `last_assistant_message` field rather than inside + // `tool_input`. Preserve it in the same carried context used by + // the desktop Feed so mobile can render a useful completed-turn + // card even when the reply route is stale or offline. + // Some adapters put the answer in the explicit event context + // instead. A fallback context from an earlier assistant preamble + // is deliberately not treated as a final answer, otherwise a + // stopped turn with no response would display an old preamble. + if let finalMessage = Self.assistantMessage(from: event) + ?? event.context?.assistantPreamble { + context = WorkstreamContext(assistantPreamble: finalMessage) + .mergingMissing(from: context) + } else { + context = context.flatMap { Self.removingAssistantMessage(from: $0) } + } case .exitPlan(_, let plan, _): let preview = WorkstreamExitPlanPreview(rawPlan: plan) context = WorkstreamContext( @@ -439,6 +793,76 @@ public final class WorkstreamStore { return nil } + /// Extracts the final assistant text emitted by stop/session adapters. + /// Providers disagree on whether this is in `tool_input`, a nested + /// notification/data object, or an extra top-level field, so keep the + /// accepted key set deliberately small and fail closed for other values. + private static func assistantMessage(from event: WorkstreamEvent) -> String? { + let keys = [ + "last_assistant_message", + "lastAssistantMessage", + "assistant_message", + "assistantMessage", + "assistantPreamble", + "assistant_preamble", + "assistant_response", + "assistantResponse", + "last_response", + "lastResponse", + "final_message", + "finalMessage", + "final_response", + "finalResponse", + "last_agent_message", + "lastAgentMessage", + ] + for json in [event.extraFieldsJSON, event.toolInputJSON] { + guard let json, + let data = json.data(using: .utf8), + let value = try? JSONSerialization.jsonObject( + with: data, + options: [.fragmentsAllowed] + ) else { continue } + if let text = assistantMessage(from: value, keys: keys) { + return text + } + } + return nil + } + + private static func assistantMessage(from value: Any, keys: [String]) -> String? { + guard let dictionary = value as? [String: Any] else { return nil } + for key in keys { + if let text = dictionary[key] as? String, + let normalized = normalizedMessage(text) { + return normalized + } + } + for key in ["notification", "data", "context", "message", "extra", "payload", "response"] { + if let nested = dictionary[key] as? [String: Any], + let text = assistantMessage(from: nested, keys: keys) { + return text + } + } + return nil + } + + private static func removingAssistantMessage(from context: WorkstreamContext) -> WorkstreamContext? { + let stripped = WorkstreamContext( + lastUserMessage: context.lastUserMessage, + planSummary: context.planSummary, + allowedPrompts: context.allowedPrompts, + toolSummary: context.toolSummary, + permissionMode: context.permissionMode + ) + return stripped.isEmpty ? nil : stripped + } + + private static func normalizedMessage(_ value: String) -> String? { + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) + return normalized.isEmpty ? nil : normalized + } + private static func todos(from json: String?) -> [WorkstreamTaskTodo] { let rawTodos: [Any] if let dict = jsonObject(from: json) as? [String: Any] { @@ -473,3 +897,9 @@ public final class WorkstreamStore { } } } + +/// Failure to resolve an immutable Feed history page. +public enum WorkstreamHistoryError: Error, Sendable, Equatable { + /// The cursor is malformed, stale, or does not identify its claimed row. + case invalidCursor +} diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/Workstream/WorkstreamEventTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/Workstream/WorkstreamEventTests.swift index ebf90a57282..dd6d5c46084 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/Workstream/WorkstreamEventTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/Workstream/WorkstreamEventTests.swift @@ -148,6 +148,19 @@ struct WorkstreamEventTests { #expect(encodedFuture["count"] as? Int == 2) } + @Test("Unknown hook names stay visible and round-trip unchanged") + func unknownHookNameRoundTrip() throws { + let json = """ + {"session_id":"s","hook_event_name":"PostToolUseFailure","_source":"codex","tool_input":{"error":"nope"}} + """.data(using: .utf8)! + let event = try JSONDecoder().decode(WorkstreamEvent.self, from: json) + #expect(event.hookEventName == .notification) + #expect(event.rawHookEventName == "PostToolUseFailure") + let encoded = try JSONEncoder().encode(event) + let object = try #require(try JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + #expect(object["hook_event_name"] as? String == "PostToolUseFailure") + } + @Test("Non-JSON tool input re-encodes as a string") func encodesRawToolInputString() throws { let event = WorkstreamEvent( diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/Workstream/WorkstreamItemTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/Workstream/WorkstreamItemTests.swift index 4f71e344349..91d3e724b38 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/Workstream/WorkstreamItemTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/Workstream/WorkstreamItemTests.swift @@ -33,6 +33,7 @@ struct WorkstreamItemTests { id: UUID(uuidString: "11111111-1111-1111-1111-111111111111")!, workstreamId: "codex-42", source: .codex, + sourceRawValue: "future-codex", kind: .permissionRequest, payload: .permissionRequest( requestId: "req-7", diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/Workstream/WorkstreamQuestionPromptParsingTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/Workstream/WorkstreamQuestionPromptParsingTests.swift index 2b1013de4a2..8261088953d 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/Workstream/WorkstreamQuestionPromptParsingTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/Workstream/WorkstreamQuestionPromptParsingTests.swift @@ -43,4 +43,217 @@ struct WorkstreamQuestionPromptParsingTests { .init(id: "opt1", label: "Beta"), ]) } + + @Test("parses boolean confirmations into a yes/no primitive") + func parsesBooleanConfirmation() throws { + let parsed = WorkstreamQuestionPrompt.parse(toolInputJSON: #""" + { + "type":"boolean", + "prompt":"Apply the migration?", + "default":true + } + """#) + + let question = try #require(parsed.first) + #expect(question.inputType == .boolean) + #expect(question.options.map(\.id) == ["yes", "no"]) + #expect(question.defaultValue == "1") + } + + @Test("parses JSON schema elicitation fields") + func parsesFormSchema() throws { + let parsed = WorkstreamQuestionPrompt.parse(toolInputJSON: #""" + { + "schema": { + "type":"object", + "properties": { + "branch": {"type":"string", "description":"Branch name"}, + "count": {"type":"integer", "default":2} + }, + "required":["branch"] + } + } + """#) + + #expect(parsed.map(\.id) == ["branch", "count"]) + #expect(parsed[0].inputType == .text) + #expect(parsed[0].required == true) + #expect(parsed[0].placeholder == "Branch name") + #expect(parsed[1].inputType == .integer) + #expect(parsed[1].defaultValue == "2") + } + + @Test("parses Codex user-input metadata and MCP elicitation aliases") + func parsesCodexAndMCPMetadata() throws { + let parsed = WorkstreamQuestionPrompt.parse(toolInputJSON: #""" + { + "questions": [{ + "id": "secret", + "header": "Credentials", + "question": "Token", + "isSecret": true, + "isOther": false, + "options": [] + }] + } + """#) + + let question = try #require(parsed.first) + #expect(question.inputType == .secret) + #expect(question.allowsOther == false) + + let schemaFields = WorkstreamQuestionPrompt.parse(toolInputJSON: #""" + { + "message": "Authorize the MCP server", + "requestedSchema": { + "type": "object", + "properties": { + "callback": {"type": "string", "format": "uri"} + }, + "required": ["callback"] + } + } + """#) + let field = try #require(schemaFields.first) + #expect(field.id == "callback") + #expect(field.inputType == .url) + #expect(field.required == true) + } + + @Test("parses JSON schema enums as bounded single and multiple choices") + func parsesSchemaEnums() throws { + let parsed = WorkstreamQuestionPrompt.parse(toolInputJSON: #""" + { + "requestedSchema": { + "type": "object", + "properties": { + "mode": {"type":"string", "enum":["Fast", "Safe"], "default":"Safe"}, + "targets": {"type":"array", "items":{"type":"string", "enum":["iOS", "macOS"]}} + }, + "required": ["mode", "targets"] + } + } + """#) + + let mode = try #require(parsed.first { $0.id == "mode" }) + #expect(mode.inputType == .choice) + #expect(mode.multiSelect == false) + #expect(mode.options.map(\.label) == ["Fast", "Safe"]) + #expect(mode.defaultValue == "opt1") + #expect(mode.allowsOther == false) + + let targets = try #require(parsed.first { $0.id == "targets" }) + #expect(targets.inputType == .choice) + #expect(targets.multiSelect) + #expect(targets.options.map(\.label) == ["iOS", "macOS"]) + #expect(targets.allowsOther == false) + } + + @Test("parses OpenCode multiple-choice and custom-answer aliases") + func parsesOpenCodeQuestionAliases() throws { + let parsed = WorkstreamQuestionPrompt.parse(toolInputJSON: #""" + { + "questions": [{ + "header": "Targets", + "question": "Which targets?", + "multiple": true, + "custom": false, + "options": [{"label":"iOS","description":"Phone"},{"label":"macOS","description":"Desktop"}] + }] + } + """#) + + let question = try #require(parsed.first) + #expect(question.multiSelect) + #expect(question.allowsOther == false) + #expect(question.options.map(\.label) == ["iOS", "macOS"]) + } + + @Test("parses URL-mode and secret elicitation fields without exposing false controls") + func parsesExternalAndSecretFields() throws { + let external = WorkstreamQuestionPrompt.parse(toolInputJSON: #""" + { + "fields": [{ + "id": "continue", + "prompt": "Finish authorization in the browser", + "input_type": "external", + "required": false, + "external_url": "https://example.com/authorize" + }] + } + """#) + let link = try #require(external.first) + #expect(link.inputType == .external) + #expect(link.externalURL == "https://example.com/authorize") + + let schema = WorkstreamQuestionPrompt.parse(toolInputJSON: #""" + { + "schema": { + "type": "object", + "properties": { + "email": {"type":"string", "format":"email"}, + "token": {"type":"string", "format":"password"} + } + } + } + """#) + #expect(schema.first { $0.id == "email" }?.inputType == .email) + #expect(schema.first { $0.id == "token" }?.inputType == .secret) + } + + @Test("parses current MCP titled enums and validation constraints") + func parsesCurrentMCPSchemaPrimitives() throws { + let parsed = WorkstreamQuestionPrompt.parse(toolInputJSON: #""" + { + "requestedSchema": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "oneOf": [ + {"const":"fast","title":"Fast path"}, + {"const":"safe","title":"Safe path"} + ], + "default": "safe" + }, + "targets": { + "type": "array", + "items": {"anyOf":[ + {"const":"ios","title":"iOS"}, + {"const":"mac","title":"macOS"} + ]}, + "minItems": 1, + "maxItems": 2 + }, + "count": {"type":"integer","minimum":1,"maximum":5}, + "name": {"type":"string","minLength":2,"maxLength":12}, + "when": {"type":"string","format":"date-time"} + } + } + } + """#) + + let mode = try #require(parsed.first { $0.id == "mode" }) + #expect(mode.options == [ + .init(id: "fast", label: "Fast path"), + .init(id: "safe", label: "Safe path"), + ]) + #expect(mode.defaultValue == "safe") + + let targets = try #require(parsed.first { $0.id == "targets" }) + #expect(targets.multiSelect) + #expect(targets.options.map(\.id) == ["ios", "mac"]) + #expect(targets.minSelections == 1) + #expect(targets.maxSelections == 2) + + let count = try #require(parsed.first { $0.id == "count" }) + #expect(count.inputType == .integer) + #expect(count.minimum == 1) + #expect(count.maximum == 5) + + let name = try #require(parsed.first { $0.id == "name" }) + #expect(name.minLength == 2) + #expect(name.maxLength == 12) + #expect(parsed.first { $0.id == "when" }?.inputType == .dateTime) + } } diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/Workstream/WorkstreamStoreTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/Workstream/WorkstreamStoreTests.swift index 631e2deb8f3..d58f355e8d4 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/Workstream/WorkstreamStoreTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/Workstream/WorkstreamStoreTests.swift @@ -28,6 +28,119 @@ struct WorkstreamStoreTests { } } + @Test("Resolved elicitation history redacts secret answers") + func resolvedSecretAnswerIsRedacted() { + let store = WorkstreamStore(ringCapacity: 10) + store.ingest(WorkstreamEvent( + sessionId: "s-secret", + hookEventName: .askUserQuestion, + source: "codex", + toolInputJSON: #"{"fields":[{"id":"name","prompt":"Name","input_type":"text"},{"id":"token","prompt":"Token","input_type":"secret"}]}"#, + requestId: "r-secret" + )) + let itemID = store.items[0].id + + store.markResolved( + itemID, + decision: .question(selections: ["name=cmux", "token=top-secret"]) + ) + + guard case .resolved(.question(let selections), _) = store.items[0].status else { + Issue.record("expected resolved question") + return + } + #expect(selections == ["name=cmux", "token="]) + } + + @Test("Appending a completed-turn reply creates authoritative user activity") + func appendCompletedTurnReply() { + let store = WorkstreamStore(ringCapacity: 10) + store.ingest(WorkstreamEvent( + sessionId: "s-reply", + hookEventName: .stop, + source: "claude", + toolInputJSON: #"{"reason":"waiting"}"# + )) + let stopID = store.items[0].id + + #expect(store.appendUserReply(to: stopID, text: "Continue with tests")) + #expect(store.items.count == 2) + #expect(store.items.last?.kind == .userPrompt) + if case .userPrompt(let text) = store.items.last?.payload { + #expect(text == "Continue with tests") + } else { + Issue.record("expected synthetic user prompt") + } + #expect(!store.appendUserReply(to: stopID, text: "Duplicate")) + } + + @Test("Stop hooks retain the final assistant message in carried context") + func stopCarriesFinalAssistantMessage() throws { + let data = Data(#"{"session_id":"s-final","hook_event_name":"Stop","_source":"codex","last_assistant_message":"The patch is ready for review."}"#.utf8) + let event = try JSONDecoder().decode(WorkstreamEvent.self, from: data) + let store = WorkstreamStore(ringCapacity: 10) + + store.ingest(event) + + #expect(store.items.first?.context?.assistantPreamble == "The patch is ready for review.") + } + + @Test("Session-end hooks retain an explicit final assistant message") + func sessionEndCarriesFinalAssistantMessage() throws { + let data = Data(#"{"session_id":"s-session-end","hook_event_name":"SessionEnd","_source":"opencode","extra":{"assistant_response":"The session is complete."}}"#.utf8) + let event = try JSONDecoder().decode(WorkstreamEvent.self, from: data) + let store = WorkstreamStore(ringCapacity: 10) + + store.ingest(event) + + #expect(store.items.first?.context?.assistantPreamble == "The session is complete.") + } + + @Test("Session-end event context is treated as the final response") + func sessionEndContextCarriesFinalAssistantMessage() throws { + let data = Data(#"{"session_id":"s-session-context","hook_event_name":"SessionEnd","_source":"opencode","context":{"assistantPreamble":"Closed cleanly."}}"#.utf8) + let event = try JSONDecoder().decode(WorkstreamEvent.self, from: data) + let store = WorkstreamStore(ringCapacity: 10) + + store.ingest(event) + + #expect(store.items.first?.context?.assistantPreamble == "Closed cleanly.") + } + + @Test("A stop without an explicit answer does not reuse an older assistant preamble") + func stopWithoutFinalAnswerDoesNotClaimOldPreamble() { + let store = WorkstreamStore(ringCapacity: 10) + store.ingest(WorkstreamEvent( + sessionId: "s-no-final", + hookEventName: .notification, + source: "codex", + context: WorkstreamContext(assistantPreamble: "I am starting the work.") + )) + store.ingest(WorkstreamEvent( + sessionId: "s-no-final", + hookEventName: .stop, + source: "codex", + toolInputJSON: #"{"reason":"waiting"}"# + )) + + #expect(store.items.last?.context?.assistantPreamble == nil) + } + + @Test("Unknown major lifecycle events remain chronological telemetry") + func unknownLifecycleTelemetry() throws { + let store = WorkstreamStore(ringCapacity: 10) + let eventData = Data(#"{"session_id":"s-future","hook_event_name":"TaskCompleted","_source":"codex","tool_name":"apply_patch","tool_input":{"ok":true}}"#.utf8) + let event = try JSONDecoder().decode(WorkstreamEvent.self, from: eventData) + store.ingest(event) + #expect(store.items.count == 1) + #expect(store.items[0].kind == .toolResult) + if case .toolResult(let toolName, _, _) = store.items[0].payload { + #expect(toolName == "apply_patch") + } else { + Issue.record("expected tool result telemetry") + } + } + @Test("Ring buffer evicts oldest items past capacity") func ringEviction() { let store = WorkstreamStore(ringCapacity: 3) @@ -62,6 +175,7 @@ struct WorkstreamStoreTests { ) await store.start() #expect(store.items.map(\.workstreamId) == ["s3", "s4"]) + #expect(store.items.allSatisfy { !$0.status.isPending }) #expect(store.hasMorePersistedItems) await store.loadOlderItems() @@ -73,6 +187,185 @@ struct WorkstreamStoreTests { #expect(!store.hasMorePersistedItems) } + @Test("restored pending items stay expired across older and mobile history pages") + func restoredPendingHistoryExpires() async throws { + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-workstream-restored-pending-\(UUID().uuidString).jsonl") + defer { try? FileManager.default.removeItem(at: tmp) } + let persistence = WorkstreamPersistence(fileURL: tmp) + for index in 0..<5 { + try await persistence.append(WorkstreamItem( + workstreamId: "session-\(index)", + source: .claude, + kind: .permissionRequest, + payload: .permissionRequest( + requestId: "request-\(index)", + toolName: "Bash", + toolInputJSON: "{}", + pattern: nil + ) + )) + } + + let store = WorkstreamStore( + persistence: persistence, + ringCapacity: 10, + initialLoadLimit: 2, + historyPageSize: 2 + ) + await store.start() + await store.loadOlderItems() + let mobilePage = try await store.historyPage(endingBefore: nil, limit: 5) + + #expect(store.items.allSatisfy { !$0.status.isPending }) + #expect(mobilePage.items.allSatisfy { !$0.status.isPending }) + } + + @Test("mobile history pages persisted rows by stable item cursor") + func mobilePersistedHistoryPages() async throws { + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-workstream-mobile-page-\(UUID().uuidString).jsonl") + defer { try? FileManager.default.removeItem(at: tmp) } + let persistence = WorkstreamPersistence(fileURL: tmp) + var ids: [UUID] = [] + for index in 0..<650 { + let id = UUID() + ids.append(id) + try await persistence.append(WorkstreamItem( + id: id, + workstreamId: "session-\(index)", + source: .codex, + kind: .assistantMessage, + workspaceId: "workspace-\(index)", + surfaceId: "surface-\(index)", + payload: .assistantMessage(text: "event \(index)") + )) + } + let store = WorkstreamStore(persistence: persistence, ringCapacity: 2_000) + let readsBefore = await persistence.loadPageCallCount + + let first = try await store.historyPage(endingBefore: nil, limit: 300) + let second = try await store.historyPage(endingBefore: first.nextCursor, limit: 300) + let third = try await store.historyPage(endingBefore: second.nextCursor, limit: 300) + + #expect(first.items.map(\.id) == Array(ids[350..<650])) + #expect(second.items.map(\.id) == Array(ids[50..<350])) + #expect(third.items.map(\.id) == Array(ids[0..<50])) + #expect(Set(first.items.map(\.id)).isDisjoint(with: second.items.map(\.id))) + #expect(Set(second.items.map(\.id)).isDisjoint(with: third.items.map(\.id))) + #expect(first.hasMore && second.hasMore && !third.hasMore) + #expect(first.items.first?.workspaceId == "workspace-350") + #expect(first.items.first?.surfaceId == "surface-350") + let readsAfter = await persistence.loadPageCallCount + #expect(readsAfter - readsBefore == 3) + } + + @Test("mobile persisted history rejects a cursor whose offset names another row") + func mobilePersistedHistoryRejectsTamperedOffset() async throws { + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-workstream-mobile-cursor-\(UUID().uuidString).jsonl") + defer { try? FileManager.default.removeItem(at: tmp) } + let persistence = WorkstreamPersistence(fileURL: tmp) + for index in 0..<4 { + try await persistence.append(WorkstreamItem( + workstreamId: "session-\(index)", + source: .codex, + kind: .assistantMessage, + payload: .assistantMessage(text: "event \(index)") + )) + } + let store = WorkstreamStore(persistence: persistence, ringCapacity: 10) + let first = try await store.historyPage(endingBefore: nil, limit: 2) + let cursor = try #require(first.nextCursor) + let data = try #require(Data(base64Encoded: cursor)) + let raw = try #require(String(data: data, encoding: .utf8)) + let parts = raw.split(separator: ":", omittingEmptySubsequences: false) + let tampered = Data("p1:0:\(parts[2])".utf8).base64EncodedString() + + await #expect(throws: WorkstreamHistoryError.invalidCursor) { + try await store.historyPage(endingBefore: tampered, limit: 2) + } + } + + @Test("mobile first page includes newly ingested rows") + func mobileHistoryIncludesLiveTail() async throws { + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-workstream-mobile-live-\(UUID().uuidString).jsonl") + defer { try? FileManager.default.removeItem(at: tmp) } + let persistence = WorkstreamPersistence(fileURL: tmp) + try await persistence.append(WorkstreamItem( + workstreamId: "persisted", + source: .codex, + kind: .assistantMessage, + payload: .assistantMessage(text: "persisted") + )) + let store = WorkstreamStore(persistence: persistence, ringCapacity: 10) + await store.start() + store.ingest(WorkstreamEvent( + sessionId: "live", + hookEventName: .notification, + source: "codex" + )) + + let page = try await store.historyPage(endingBefore: nil, limit: 10) + + #expect(page.items.map(\.workstreamId) == ["persisted", "live"]) + } + + @Test("mobile history drains one ordered persistence writer before paging a burst") + func mobileHistoryDrainsBoundedPersistenceBurst() async throws { + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-workstream-mobile-burst-\(UUID().uuidString).jsonl") + defer { try? FileManager.default.removeItem(at: tmp) } + let gate = PersistenceAppendGate() + let persistence = WorkstreamPersistence(fileURL: tmp, beforeAppend: { await gate.wait() }) + let store = WorkstreamStore(persistence: persistence, ringCapacity: 1_000) + for index in 0..<650 { + store.ingest(WorkstreamEvent( + sessionId: "burst-\(index)", + hookEventName: .notification, + source: "codex" + )) + } + let acceptedIDs = store.items.map(\.id) + #expect(store.activePersistenceDrainCount == 1) + + let firstTask = Task { try await store.historyPage(endingBefore: nil, limit: 300) } + await gate.release() + let first = try await firstTask.value + let second = try await store.historyPage(endingBefore: first.nextCursor, limit: 300) + let third = try await store.historyPage(endingBefore: second.nextCursor, limit: 300) + + #expect(first.items.map(\.id) == Array(acceptedIDs[350..<650])) + #expect(second.items.map(\.id) == Array(acceptedIDs[50..<350])) + #expect(third.items.map(\.id) == Array(acceptedIDs[0..<50])) + #expect(first.hasMore && second.hasMore && !third.hasMore) + #expect(store.activePersistenceDrainCount == 0) + } + + @Test("mobile in-memory history rejects a cursor invalidated by ring eviction") + func mobileInMemoryHistoryRejectsEvictedCursor() async throws { + let store = WorkstreamStore(ringCapacity: 4) + for index in 0..<4 { + store.ingest(WorkstreamEvent( + sessionId: "session-\(index)", + hookEventName: .notification, + source: "codex" + )) + } + let first = try await store.historyPage(endingBefore: nil, limit: 2) + let cursor = try #require(first.nextCursor) + store.ingest(WorkstreamEvent( + sessionId: "session-4", + hookEventName: .notification, + source: "codex" + )) + + await #expect(throws: WorkstreamHistoryError.invalidCursor) { + try await store.historyPage(endingBefore: cursor, limit: 2) + } + } + @Test("expireAbandonedItems expires items whose agent PID is dead") func expireAbandoned() { let clock = TestClock(initial: Date(timeIntervalSince1970: 0)) @@ -322,6 +615,23 @@ struct WorkstreamStoreTests { } } +private actor PersistenceAppendGate { + private var isReleased = false + private var waiters: [CheckedContinuation] = [] + + func wait() async { + guard !isReleased else { return } + await withCheckedContinuation { waiters.append($0) } + } + + func release() { + isReleased = true + let current = waiters + waiters.removeAll() + current.forEach { $0.resume() } + } +} + /// Mutable clock wrapper safe to capture by a `@Sendable` closure in tests. private final class TestClock: @unchecked Sendable { private let lock = NSLock() diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Feed/ControlCommandCoordinator+Feed.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Feed/ControlCommandCoordinator+Feed.swift index eb1c3d53d22..8a476853cfe 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Feed/ControlCommandCoordinator+Feed.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Feed/ControlCommandCoordinator+Feed.swift @@ -7,7 +7,7 @@ internal import Foundation /// wire bytes match. /// /// The worker-lane feed methods (`feed.push`, `feed.permission.reply`, -/// `feed.question.reply`, `feed.exit_plan.reply`) block or await on the socket +/// `feed.question.reply`, `feed.exit_plan.reply`, `feed.invalidate`) block or await on the socket /// worker and remain on the app-side worker path — they are deliberately NOT /// dispatched here. extension ControlCommandCoordinator { diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift index c6aeaae69bd..226916ffad2 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift @@ -87,6 +87,7 @@ public enum ControlCommandExecutionPolicy: Sendable, Equatable { "feed.permission.reply", "feed.question.reply", "feed.exit_plan.reply", + "feed.invalidate", "browser.download.wait", "browser.profiles.list", "browser.profiles.create", diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 7d71a134ac0..805c24e9b40 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -204,6 +204,41 @@ "ja": { "stringUnit": { "state": "translated", "value": "その他…" } } } }, + "feed.question.boolean.no": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "No" } }, + "ja": { "stringUnit": { "state": "translated", "value": "いいえ" } } + } + }, + "feed.question.boolean.yes": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Yes" } }, + "ja": { "stringUnit": { "state": "translated", "value": "はい" } } + } + }, + "feed.form.accepted": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Accepted" } }, + "ja": { "stringUnit": { "state": "translated", "value": "承認済み" } } + } + }, + "feed.form.cancelled": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Cancelled" } }, + "ja": { "stringUnit": { "state": "translated", "value": "キャンセル済み" } } + } + }, + "feed.form.declined": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Declined" } }, + "ja": { "stringUnit": { "state": "translated", "value": "拒否済み" } } + } + }, "terminal.notification.action.reply": { "extractionState": "manual", "localizations": { @@ -6632,6 +6667,41 @@ } } }, + "agentSession.codex.error.inputParametersMalformed": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Codex input parameters were malformed."}}, + "ja": {"stringUnit": {"state": "translated", "value": "Codex 入力パラメータが不正です。"}} + } + }, + "agentSession.codex.error.inputResponseEncoding": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Codex input response could not be encoded."}}, + "ja": {"stringUnit": {"state": "translated", "value": "Codex 入力の応答をエンコードできませんでした。"}} + } + }, + "agentSession.codex.error.inputResponseNotObject": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Codex input response was not a JSON object."}}, + "ja": {"stringUnit": {"state": "translated", "value": "Codex 入力の応答が JSON オブジェクトではありません。"}} + } + }, + "agentSession.codex.error.inputTargetUnavailable": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Codex input target is unavailable."}}, + "ja": {"stringUnit": {"state": "translated", "value": "Codex 入力の対象を利用できません。"}} + } + }, + "agentSession.codex.input.continue": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Continue"}}, + "ja": {"stringUnit": {"state": "translated", "value": "続行"}} + } + }, "agentSession.codex.error.invalidJSON": { "extractionState": "manual", "localizations": { @@ -116179,6 +116249,40 @@ } } }, + "feed.permission.persistent": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Always Allow" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "常に許可" + } + } + } + }, + "feed.permission.session": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Allow for Session" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セッション中は許可" + } + } + } + }, "feed.permission.bypass": { "extractionState": "manual", "localizations": { @@ -116247,6 +116351,23 @@ } } }, + "feed.permission.mode.persistent": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "remembered" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "常に許可" + } + } + } + }, "feed.permission.mode.bypass": { "extractionState": "manual", "localizations": { diff --git a/Resources/opencode-plugin.js b/Resources/opencode-plugin.js index 6e8a9a63402..ebb4f3d69f1 100644 --- a/Resources/opencode-plugin.js +++ b/Resources/opencode-plugin.js @@ -17,6 +17,9 @@ export const CMUXFeed = async (ctx) => { let client = null; let buffered = ""; const pending = new Map(); + const requestSessions = new Map(); + const requestKinds = new Map(); + const questionRequests = new Map(); const messageRoles = new Map(); const sessions = new Map(); @@ -93,7 +96,19 @@ export const CMUXFeed = async (ctx) => { remember: reply === "always", }); - const replyPermission = async ({ sessionId, requestId, reply, message }) => { + const replyPermission = async ({ sessionId, requestId, reply, message, apiVersion = 1 }) => { + if ( + apiVersion === 2 && + sessionId && + await tryRawClientRequest("post", { + url: "/api/session/{sessionID}/permission/{requestID}/reply", + path: { sessionID: sessionId, requestID: requestId }, + body: message ? { reply, message } : { reply }, + }) + ) { + return; + } + if ( await tryRawClientRequest("post", { url: "/permission/{requestID}/reply", @@ -116,7 +131,19 @@ export const CMUXFeed = async (ctx) => { } }; - const replyQuestion = async (requestId, answers) => { + const replyQuestion = async (sessionId, requestId, answers, apiVersion = 1) => { + if ( + apiVersion === 2 && + sessionId && + await tryRawClientRequest("post", { + url: "/api/session/{sessionID}/question/{requestID}/reply", + path: { sessionID: sessionId, requestID: requestId }, + body: { answers }, + }) + ) { + return; + } + if ( await tryRawClientRequest("post", { url: "/question/{requestID}/reply", @@ -130,7 +157,19 @@ export const CMUXFeed = async (ctx) => { await callClientMethod(ctx?.client?.question, "reply", { requestID: requestId, answers }); }; - const rejectQuestion = async (requestId) => { + const rejectQuestion = async (sessionId, requestId, apiVersion = 1) => { + if ( + apiVersion === 2 && + sessionId && + await tryRawClientRequest("post", { + url: "/api/session/{sessionID}/question/{requestID}/reject", + path: { sessionID: sessionId, requestID: requestId }, + body: {}, + }) + ) { + return; + } + if ( await tryRawClientRequest("post", { url: "/question/{requestID}/reject", @@ -223,9 +262,38 @@ export const CMUXFeed = async (ctx) => { } }; - const questionAnswers = (selections) => { - if (!Array.isArray(selections) || selections.length === 0) return [[]]; - return selections.map((selection) => [String(selection)]); + const questionAnswers = (selections, questions) => { + const orderedQuestions = Array.isArray(questions) ? questions : []; + const answers = orderedQuestions.map(() => []); + for (const encoded of Array.isArray(selections) ? selections : []) { + const selection = String(encoded); + const separator = selection.indexOf("="); + if (separator < 1) continue; + const questionId = selection.slice(0, separator); + const questionIndex = orderedQuestions.findIndex((question) => question.id === questionId); + if (questionIndex < 0) continue; + const value = selection.slice(separator + 1); + const answer = value.startsWith("other:") + ? value.slice("other:".length) + : orderedQuestions[questionIndex].options.find((option) => option.id === value)?.label || value; + if (answer && !answers[questionIndex].includes(answer)) answers[questionIndex].push(answer); + } + return answers.length > 0 ? answers : [[]]; + }; + + const feedSelectionsForAnswers = (answers, questions) => { + const orderedQuestions = Array.isArray(questions) ? questions : []; + const selections = []; + for (let index = 0; index < orderedQuestions.length; index += 1) { + const question = orderedQuestions[index]; + const values = Array.isArray(answers?.[index]) ? answers[index] : []; + for (const rawValue of values) { + const value = String(rawValue); + const option = question.options.find((candidate) => candidate.label === value); + selections.push(`${question.id}=${option ? option.id : `other:${value}`}`); + } + } + return selections; }; const resolveSessionPlanPath = (sid, rawPlanPath) => { @@ -288,12 +356,12 @@ export const CMUXFeed = async (ctx) => { }; }; - const handleExitPlanDecision = async (sid, requestId, decision) => { + const handleExitPlanDecision = async (sid, requestId, decision, apiVersion) => { const mode = decision?.mode || "manual"; const feedback = normalizeText(decision?.feedback, 1800); if (feedback) { - await replyQuestion(requestId, [["No"]]); + await replyQuestion(sid, requestId, [["No"]], apiVersion); await sendPlanFeedback( sid, `User rejected the plan via cmux Feed and wants this change: ${feedback}\n\nUpdate the plan file, then call plan_exit again.` @@ -302,12 +370,12 @@ export const CMUXFeed = async (ctx) => { } if (mode === "deny") { - await replyQuestion(requestId, [["No"]]); + await replyQuestion(sid, requestId, [["No"]], apiVersion); return; } if (mode === "ultraplan") { - await replyQuestion(requestId, [["No"]]); + await replyQuestion(sid, requestId, [["No"]], apiVersion); await sendPlanFeedback( sid, "User chose Ultraplan via cmux Feed. Refine the plan more deeply, update the plan file, then call plan_exit again." @@ -323,14 +391,14 @@ export const CMUXFeed = async (ctx) => { permissionsApplied = false; } if (!permissionsApplied) { - await replyQuestion(requestId, [["No"]]); + await replyQuestion(sid, requestId, [["No"]], apiVersion); await sendPlanFeedback( sid, "cmux could not apply the selected permission mode. Ask the user to approve the plan again before switching to build mode." ); return; } - await replyQuestion(requestId, [["Yes"]]); + await replyQuestion(sid, requestId, [["Yes"]], apiVersion); }; const resolvePending = (requestId, value) => { @@ -401,6 +469,38 @@ export const CMUXFeed = async (ctx) => { } }; + const writeDetached = (method, params) => { + try { + const conn = net.createConnection(SOCKET_PATH); + conn.once("connect", () => { + conn.end(JSON.stringify({ + id: `opencode-lifecycle-${Date.now()}`, + method, + params, + }) + "\n"); + }); + conn.once("error", () => conn.destroy()); + } catch (_) {} + }; + + const rememberRequest = (requestId, sessionId, kind, questions) => { + requestSessions.set(requestId, sessionId); + requestKinds.set(requestId, kind); + if (questions) questionRequests.set(requestId, questions); + }; + + const forgetRequest = (requestId) => { + requestSessions.delete(requestId); + requestKinds.delete(requestId); + questionRequests.delete(requestId); + }; + + const invalidateRequest = (requestId) => { + if (!requestId || !pending.has(requestId)) return; + writeDetached("feed.invalidate", { request_id: requestId }); + resolvePending(requestId, { status: "invalidated" }); + }; + const base = (sessionId, extra) => { const state = sessionState(sessionId); const context = extra?.context || contextForSession(sessionId); @@ -516,6 +616,9 @@ export const CMUXFeed = async (ctx) => { case "session.deleted": { const sid = event.properties?.info?.id; if (!sid) break; + for (const [requestId, requestSessionId] of requestSessions) { + if (requestSessionId === sid) invalidateRequest(requestId); + } sessions.delete(sid); pushTelemetry(base(sid, { hook_event_name: "SessionEnd", @@ -531,12 +634,49 @@ export const CMUXFeed = async (ctx) => { })); break; } - case "permission.asked": { + case "permission.replied": + case "permission.v2.replied": { + const props = event.properties || {}; + const requestId = props.requestID || props.id; + if (!requestId || !pending.has(requestId)) break; + const mode = props.reply === "always" ? "always" : props.reply === "reject" ? "deny" : "once"; + writeDetached("feed.permission.reply", { request_id: requestId, mode }); + resolvePending(requestId, { status: "invalidated" }); + break; + } + case "question.replied": + case "question.v2.replied": { + const props = event.properties || {}; + const requestId = props.requestID || props.id; + if (!requestId || !pending.has(requestId)) break; + if (requestKinds.get(requestId) === "exit_plan") { + const answer = String(props.answers?.[0]?.[0] || "").toLowerCase(); + writeDetached("feed.exit_plan.reply", { + request_id: requestId, + mode: answer === "yes" ? "manual" : "deny", + }); + } else { + writeDetached("feed.question.reply", { + request_id: requestId, + selections: feedSelectionsForAnswers(props.answers, questionRequests.get(requestId)), + }); + } + resolvePending(requestId, { status: "invalidated" }); + break; + } + case "question.rejected": + case "question.v2.rejected": { + invalidateRequest(event.properties?.requestID || event.properties?.id); + break; + } + case "permission.asked": + case "permission.v2.asked": { const props = event.properties || {}; const requestId = props.id; if (!requestId) break; const sid = props.sessionID || "unknown"; - const permission = firstString(props.permission, props.tool?.name) || "permission"; + const apiVersion = event.type === "permission.v2.asked" ? 2 : 1; + const permission = firstString(props.permission, props.action, props.tool?.name) || "permission"; const metadata = isObject(props.metadata) ? props.metadata : {}; const frame = base(sid, { hook_event_name: "PermissionRequest", @@ -544,16 +684,17 @@ export const CMUXFeed = async (ctx) => { tool_name: permission, tool_input: { permission, - patterns: Array.isArray(props.patterns) ? props.patterns : [], - always: Array.isArray(props.always) ? props.always : [], + patterns: Array.isArray(props.patterns) ? props.patterns : (props.resources || []), + always: Array.isArray(props.always) ? props.always : (props.save || []), metadata, - tool: props.tool, + tool: props.tool || props.source, }, context: { ...(contextForSession(sid) || {}), permissionMode: "opencode", }, }); + rememberRequest(requestId, sid, "permission"); const result = await pushBlocking(frame, requestId); if (result?.status === "resolved" && result.decision?.kind === "permission") { const mode = result.decision.mode; @@ -566,21 +707,26 @@ export const CMUXFeed = async (ctx) => { requestId, reply: permissionReplyForMode(mode), message: mode === "deny" ? "User denied permission via cmux Feed." : undefined, + apiVersion, }); } catch (e) { /* ignore - opencode already moved on */ } } + forgetRequest(requestId); break; } - case "question.asked": { + case "question.asked": + case "question.v2.asked": { const props = event.properties || {}; const requestId = props.id; const sid = props.sessionID || "unknown"; if (!requestId) break; + const apiVersion = event.type === "question.v2.asked" ? 2 : 1; const questions = (props.questions || []).map((q, idx) => ({ id: q.id || `q${idx}`, header: q.header || q.title, question: q.question || q.prompt || "", multiSelect: q.multiSelect === true || q.multiple === true, + custom: q.custom, options: (q.options || []).map((o, optionIdx) => ({ id: o.id || `opt${optionIdx}`, label: o.label || o.title || String(o), @@ -603,12 +749,14 @@ export const CMUXFeed = async (ctx) => { permissionMode: "plan", }, }); + rememberRequest(requestId, sid, "exit_plan", questions); const result = await pushBlocking(frame, requestId); if (result?.status === "resolved" && result.decision?.kind === "exit_plan") { try { - await handleExitPlanDecision(sid, requestId, result.decision); + await handleExitPlanDecision(sid, requestId, result.decision, apiVersion); } catch (_) {} } + forgetRequest(requestId); break; } @@ -618,14 +766,21 @@ export const CMUXFeed = async (ctx) => { tool_name: "question", tool_input: { questions }, }); + rememberRequest(requestId, sid, "question", questions); const result = await pushBlocking(frame, requestId); if (result?.status === "resolved" && result.decision?.kind === "question") { try { - await replyQuestion(requestId, questionAnswers(result.decision.selections)); + await replyQuestion( + sid, + requestId, + questionAnswers(result.decision.selections, questions), + apiVersion + ); } catch (_) { - try { await rejectQuestion(requestId); } catch (_) {} + try { await rejectQuestion(sid, requestId, apiVersion); } catch (_) {} } } + forgetRequest(requestId); break; } default: diff --git a/Sources/CmuxSocketEventMapper.swift b/Sources/CmuxSocketEventMapper.swift index 6a62d878ba5..43f6dbead8d 100644 --- a/Sources/CmuxSocketEventMapper.swift +++ b/Sources/CmuxSocketEventMapper.swift @@ -125,6 +125,8 @@ enum CmuxSocketEventMapper { return DomainEventMapping(name: "notification.jump_to_unread_requested", category: "notification", params: .unchanged) case "feed.permission.reply", "feed.question.reply", "feed.exit_plan.reply": return DomainEventMapping(name: "feed.item.resolved", category: "feed", params: .unchanged) + case "feed.invalidate": + return DomainEventMapping(name: "feed.item.invalidated", category: "feed", params: .unchanged) case "app.focus_override.set": return DomainEventMapping(name: "app.focus_override.changed", category: "app", params: .unchanged) case "app.simulate_active": diff --git a/Sources/Feed/FeedCoordinator.swift b/Sources/Feed/FeedCoordinator.swift index ba20f70020c..062754ac437 100644 --- a/Sources/Feed/FeedCoordinator.swift +++ b/Sources/Feed/FeedCoordinator.swift @@ -30,6 +30,10 @@ final class FeedCoordinator: @unchecked Sendable { // so it hops to main explicitly when touching the store. @MainActor private(set) var store: WorkstreamStore! @MainActor private var userNotificationCenter: (any UserNotificationCenterServing)? + /// Live in-process routes belong to the Feed coordinator lifecycle. Hook + /// sessions remain file-backed and are resolved separately off-main. + @MainActor private var registeredTargets: [String: FeedJumpResolver.Target] = [:] + @MainActor private var registeredTextSenders: [String: FeedRegisteredTextSender] = [:] /// The bounded notification-center boundary. `install(store:)` injects it; /// the shared store's service covers the pre-install window. @@ -42,6 +46,10 @@ final class FeedCoordinator: @unchecked Sendable { /// handler signals the semaphore after filling the slot. private let waiterLock = NSLock() private var waiters: [String: PendingWaiter] = [:] + /// Monotonic snapshot revision used by authenticated mobile clients to + /// repair missed invalidations. Guarded by `waiterLock` with the waiter + /// table so a resolution and its revision are one ordered mutation. + private var mobileRevision: UInt64 = 0 /// One kqueue-backed DispatchSource per distinct agent PID we've /// ever seen. The kernel fires `.exit` the instant the process @@ -83,6 +91,8 @@ final class FeedCoordinator: @unchecked Sendable { userNotificationCenter: (any UserNotificationCenterServing)? = nil ) { self.store = store + registeredTargets.removeAll() + registeredTextSenders.removeAll() // Resolved here rather than as a default argument: default-argument // expressions evaluate outside the method's main-actor isolation. self.userNotificationCenter = userNotificationCenter @@ -113,7 +123,22 @@ final class FeedCoordinator: @unchecked Sendable { src.setEventHandler { [weak self] in Task { @MainActor in guard let self else { return } + let requestIDs = self.store?.items.compactMap { item -> String? in + guard item.status.isPending, item.ppid == ppid else { return nil } + switch item.payload { + case .permissionRequest(let requestID, _, _, _), + .exitPlan(let requestID, _, _), + .question(let requestID, _): + return requestID + default: + return nil + } + } ?? [] + for requestID in requestIDs { + self.invalidateBlockingRequest(requestId: requestID) + } self.store?.expireItems(forPpid: ppid) + self.publishMobileChange() self.pidWatchers[ppid]?.cancel() self.pidWatchers.removeValue(forKey: ppid) } @@ -144,6 +169,7 @@ final class FeedCoordinator: @unchecked Sendable { func ingestRevalidatedOnMainActor(_ event: WorkstreamEvent) -> UUID? { guard let store else { return nil } store.ingest(event) + publishMobileChange() if let ppid = event.ppid, ppid > 0 { armPidWatcher(ppid: ppid) } @@ -250,7 +276,7 @@ final class FeedCoordinator: @unchecked Sendable { // Resolve before entering the global delivery lane so hook-session disk // I/O for one agent cannot stall otherwise unrelated Feed ingress. - let resolvedAttentionTarget = Self.isBlockingDecisionEvent(event.hookEventName) + let resolvedAttentionTarget = Self.isBlockingDecisionEvent(event) ? Self.resolveAttentionTarget(event: event) : nil let semaphore = DispatchSemaphore(value: 0) @@ -478,13 +504,39 @@ final class FeedCoordinator: @unchecked Sendable { /// Called by the `feed.*.reply` handlers. Marks the corresponding /// item resolved on the main-actor store and wakes any waiter. - func deliverReply(requestId: String, decision: WorkstreamDecision) { + @discardableResult + func deliverReply(requestId: String, decision: WorkstreamDecision) -> MobileReplyOutcome { + deliverReply( + requestId: requestId, + itemId: nil, + decision: decision, + requiresLiveWaiter: false + ) + } + + private func deliverReply( + requestId: String, + itemId: UUID?, + decision: WorkstreamDecision, + requiresLiveWaiter: Bool + ) -> MobileReplyOutcome { waiterLock.lock() - let attentionTarget = waiters[requestId]?.attentionTarget - if let waiter = waiters[requestId] { - waiter.decision = decision - waiter.semaphore.signal() + let waiter = waiters[requestId] + if requiresLiveWaiter, waiter == nil { + waiterLock.unlock() + return .notFound + } + if waiter?.invalidated == true { + waiterLock.unlock() + return .expired } + if waiter?.decision != nil { + waiterLock.unlock() + return .alreadyResolved + } + let attentionTarget = waiter?.attentionTarget + waiter?.decision = decision + waiter?.semaphore.signal() waiterLock.unlock() // The user decided: conclude the needs-input overlay so the agent's @@ -492,29 +544,354 @@ final class FeedCoordinator: @unchecked Sendable { // decision on the same panel keeps it lit until it too concludes). concludeAttentionOnMain(attentionTarget) - let resolve: @Sendable () -> Void = { [requestId, decision] in + let resolve: @Sendable () -> Void = { [requestId, itemId, decision] in MainActor.assumeIsolated { let store = FeedCoordinator.shared.store guard let store else { return } - if let itemId = Self.findItemId(for: requestId, in: store.items) { - store.markResolved(itemId, decision: decision) + if let resolvedItemId = itemId ?? Self.findItemId(for: requestId, in: store.items) { + store.markResolved(resolvedItemId, decision: decision) } + FeedCoordinator.shared.publishMobileChange() + FeedCoordinator.shared.cancelNotification(requestId: requestId) } } if Thread.isMainThread { resolve() } else { + precondition(!requiresLiveWaiter, "Mobile replies must resolve on the main actor") DispatchQueue.main.async(execute: resolve) } + return .delivered + } + + enum MobileReplyOutcome: String, Sendable { + case delivered + case alreadyResolved = "already_resolved" + case expired + case invalidAction = "invalid_action" + case notFound = "not_found" + } + + /// Resolves exactly one immutable feed item. Both ids must match the same + /// pending card, preventing identical request ids in other sessions or Mac + /// connections from crossing the action boundary. + @MainActor + func deliverMobileReply( + itemId: UUID, + requestId: String, + workspaceId: String, + surfaceId: String, + decision: WorkstreamDecision + ) -> MobileReplyOutcome { + guard let item = snapshot(pendingOnly: false).first(where: { $0.id == itemId }) else { + return .notFound + } + switch item.status { + case .expired: + return .expired + case .resolved: + return .alreadyResolved + case .telemetry: + return .notFound + case .pending: + break + } + guard item.workspaceId.map({ $0 == workspaceId }) ?? true, + item.surfaceId.map({ $0 == surfaceId }) ?? true, + let target = target(for: item.workstreamId), + target.workspaceId == workspaceId, + target.surfaceId == surfaceId else { + return .notFound + } + guard Self.mobileDecisionIsValid(decision, for: item, requestId: requestId) else { + return .invalidAction + } + return deliverReply( + requestId: requestId, + itemId: itemId, + decision: decision, + requiresLiveWaiter: true + ) + } + + private static func mobileDecisionIsValid( + _ decision: WorkstreamDecision, + for item: WorkstreamItem, + requestId: String + ) -> Bool { + switch (item.payload, decision) { + case let (.permissionRequest(itemRequestId, _, toolInput, _), .permission(mode)): + guard itemRequestId == requestId else { return false } + let sourceIsKnown = item.sourceRawValue.map { $0 == item.source.rawValue } ?? true + guard sourceIsKnown || mode == .deny else { return false } + switch mode { + case .deny: return true + case .once: return FeedPermissionActionPolicy.supportsOncePermissionMode(source: item.source, toolInputJSON: toolInput) + case .always: return FeedPermissionActionPolicy.supportsAlwaysPermissionMode(source: item.source, toolInputJSON: toolInput) + case .persistent: return FeedPermissionActionPolicy.supportsPersistentPermissionMode(source: item.source, toolInputJSON: toolInput) + case .all: return FeedPermissionActionPolicy.supportsAllPermissionMode(source: item.source, toolInputJSON: toolInput) + case .bypass: return FeedPermissionActionPolicy.supportsBypassPermissions(source: item.source) + } + case let (.exitPlan(itemRequestId, _, _), .exitPlan(_, _)): + return itemRequestId == requestId + case let (.question(itemRequestId, questions), .question(selections)): + return itemRequestId == requestId + && Self.questionSelectionsAreValid(selections, questions: questions) + case let (.question(itemRequestId, questions), .form(action, selections)): + guard itemRequestId == requestId else { return false } + switch action { + case .accept: + return Self.questionSelectionsAreValid(selections, questions: questions) + case .decline, .cancel: + return selections.isEmpty + } + default: + return false + } + } + + private static func questionSelectionsAreValid( + _ selections: [String], + questions: [WorkstreamQuestionPrompt] + ) -> Bool { + guard !questions.isEmpty else { return false } + let answers = Dictionary(grouping: selections.compactMap { selection -> (String, String)? in + guard let separator = selection.firstIndex(of: "=") else { return nil } + return (String(selection[.. maximum { return false } + let optionIDs = Set(question.options.map(\.id)) + switch question.inputType { + case .text, .secret, .email, .date, .dateTime: + return values.allSatisfy { value in + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, + question.minLength.map({ trimmed.count >= $0 }) ?? true, + question.maxLength.map({ trimmed.count <= $0 }) ?? true else { return false } + switch question.inputType { + case .email: return Self.validEmail(trimmed) + case .date: return Self.validISODate(trimmed) + case .dateTime: return ISO8601DateFormatter().date(from: trimmed) != nil + default: return true + } + } + case .number, .integer: + return values.allSatisfy { value in + guard let number = Double(value.trimmingCharacters(in: .whitespacesAndNewlines)), + number.isFinite, + question.inputType != .integer || number.rounded(.towardZero) == number, + question.minimum.map({ number >= $0 }) ?? true, + question.maximum.map({ number <= $0 }) ?? true else { return false } + return true + } + case .url: + return values.allSatisfy { value in + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard question.minLength.map({ trimmed.count >= $0 }) ?? true, + question.maxLength.map({ trimmed.count <= $0 }) ?? true, + let url = URL(string: trimmed), + let scheme = url.scheme else { return false } + return !scheme.isEmpty + } + case .boolean: + return values.allSatisfy { + ["1", "0", "yes", "no", "true", "false", "y", "n", "on", "off"] + .contains($0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()) + } + case .external: + return false + default: + return values.allSatisfy { + optionIDs.contains($0) + || ($0.hasPrefix("other:") && $0.count > 6 && question.allowsOther != false) + } + } + } + } - cancelNotification(requestId: requestId) + private static func validEmail(_ value: String) -> Bool { + let pieces = value.split(separator: "@", omittingEmptySubsequences: false) + return pieces.count == 2 + && !pieces[0].isEmpty + && !pieces[1].isEmpty + && !value.contains(where: \.isWhitespace) + } + + private static func validISODate(_ value: String) -> Bool { + let pieces = value.split(separator: "-", omittingEmptySubsequences: false) + guard value.count == 10, + pieces.count == 3, + pieces[0].count == 4, + pieces[1].count == 2, + pieces[2].count == 2, + let year = Int(pieces[0]), + let month = Int(pieces[1]), + let day = Int(pieces[2]) else { return false } + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar.date(from: DateComponents(year: year, month: month, day: day)) != nil + } + + /// Revision paired with `snapshot`; callers use it as the reconciliation + /// cursor for revision-only mobile invalidations. + func mobileSnapshot(pendingOnly: Bool) -> (revision: UInt64, items: [WorkstreamItem]) { + waiterLock.lock() + let revision = mobileRevision + waiterLock.unlock() + return (revision, snapshot(pendingOnly: pendingOnly)) + } + + /// Returns one stable page from persisted Feed history for authenticated mobile clients. + @MainActor + func mobileHistoryPage(endingBefore cursor: String?, limit: Int) async throws + -> (revision: UInt64, page: WorkstreamStore.HistoryPage) + { + guard let store else { throw WorkstreamHistoryError.invalidCursor } + let revision = waiterLock.withLock { mobileRevision } + return (revision, try await store.historyPage(endingBefore: cursor, limit: limit)) + } + + /// Returns the current route without changing focus. Mobile list rows pin + /// this target so later selection changes cannot reroute an action. + @MainActor + func target(for workstreamId: String) -> FeedJumpResolver.Target? { + guard let parsed = FeedJumpResolver.parse(workstreamId) else { return nil } + if let target = registeredTargets[workstreamId] { return target } + return FeedJumpResolver.lookup(agent: parsed.agent, sessionId: parsed.sessionId) + } + + /// Installs one live route for an in-process coding-agent session. + @MainActor + func registerTarget( + agent: String, + sessionId: String, + target: FeedJumpResolver.Target, + textSender: (@MainActor (String) async -> Bool)? = nil + ) { + let workstreamId = "\(agent)-\(sessionId)" + registeredTargets[workstreamId] = target + registeredTextSenders[workstreamId] = textSender.map(FeedRegisteredTextSender.init) + } + + /// Removes one live route without disturbing a replacement registered by + /// a newer session owner. + @MainActor + func unregisterTarget( + agent: String, + sessionId: String, + expected target: FeedJumpResolver.Target? = nil + ) { + let workstreamId = "\(agent)-\(sessionId)" + if target == nil || registeredTargets[workstreamId] == target { + registeredTargets.removeValue(forKey: workstreamId) + registeredTextSenders.removeValue(forKey: workstreamId) + } + } + + /// Returns only in-process routes. Persisted hook routes are loaded on a + /// detached worker and merged by the caller so history encoding stays off + /// the main actor. + @MainActor + func registeredTargets(for workstreamIds: [String]) -> [String: FeedJumpResolver.Target] { + workstreamIds.reduce(into: [:]) { result, workstreamId in + if let target = registeredTargets[workstreamId] { + result[workstreamId] = target + } + } + } + + @MainActor + private func sendRegisteredText(workstreamId: String, text: String) async -> Bool? { + await registeredTextSenders[workstreamId]?.send(text) + } + + /// Accepts one ordinary reply only when the caller's immutable target is + /// still the authoritative route for this workstream. + @MainActor + func sendTextToTarget( + workstreamId: String, + itemId: UUID, + workspaceId: String, + surfaceId: String, + text: String + ) async -> Bool { + guard let store, + let item = store.items.first(where: { $0.id == itemId }), + item.workstreamId == workstreamId, + item.workspaceId.map({ $0 == workspaceId }) ?? true, + item.surfaceId.map({ $0 == surfaceId }) ?? true, + item.status == .telemetry, + Self.isTurnCompletion(item), + store.canAppendUserReply(to: itemId, text: text) else { + return false + } + guard let target = target(for: workstreamId), + target.workspaceId == workspaceId, + target.surfaceId == surfaceId, + !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return false + } + let directDelivery = await sendRegisteredText( + workstreamId: workstreamId, + text: text + ) + if directDelivery == false { return false } + if directDelivery == nil { + FeedJumpResolver.sendText( + workspaceId: workspaceId, + surfaceId: surfaceId, + text: text + ) + } + guard store.appendUserReply(to: itemId, text: text) else { return false } + publishMobileChange() + return true + } + + @MainActor + private func publishMobileChange() { + waiterLock.lock() + mobileRevision &+= 1 + let revision = mobileRevision + waiterLock.unlock() + MobileHostService.emitEvent( + topic: "workstream.feed.changed", + payload: ["revision": revision] + ) } fileprivate func isAwaitingDecision(requestId: String) -> Bool { waiterLock.lock() defer { waiterLock.unlock() } guard let waiter = waiters[requestId] else { return false } - return waiter.decision == nil + return waiter.decision == nil && !waiter.invalidated + } + + /// Wakes a blocking request that the originating agent invalidated before + /// the user answered. The ingest path owns expiry and mobile publication, + /// so stale controls become disabled through the same authoritative state + /// transition as a timeout. + func invalidateBlockingRequest(requestId: String) { + waiterLock.lock() + let waiter = waiters[requestId] + guard let waiter, waiter.decision == nil, !waiter.invalidated else { + waiterLock.unlock() + return + } + waiter.invalidated = true + waiter.semaphore.signal() + waiterLock.unlock() } private static func findItemId( @@ -536,11 +913,21 @@ final class FeedCoordinator: @unchecked Sendable { return nil } + private static func isTurnCompletion(_ item: WorkstreamItem) -> Bool { + switch item.payload { + case .stop, .sessionEnd: + return true + default: + return false + } + } + private func expireTimedOutItem(_ itemId: UUID?) { guard let itemId else { return } let expire: @Sendable () -> Void = { [itemId] in MainActor.assumeIsolated { FeedCoordinator.shared.store?.markExpired(itemId) + FeedCoordinator.shared.publishMobileChange() } } if Thread.isMainThread { @@ -582,6 +969,34 @@ extension FeedCoordinator { } } + static func isBlockingDecisionEvent(_ event: WorkstreamEvent) -> Bool { + if isBlockingDecisionEvent(event.hookEventName) { return true } + guard let raw = event.rawHookEventName else { return false } + let normalized = raw.unicodeScalars + .filter { CharacterSet.alphanumerics.contains($0) } + .map(String.init) + .joined() + .lowercased() + return [ + "askuserquestion", + "askuserconfirmation", + "booleanquestion", + "questionasked", + "questionv2asked", + "elicitation", + "elicitationrequest", + "mcpelicitation", + "mcpserverelicitationrequest", + "requestuserinput", + "userinputrequest", + "inputrequest", + "toolrequestuserinput", + "itemtoolrequestuserinput", + "question", + "askuser", + ].contains(normalized) + } + /// Maps a feed `source` (agent id) to the agent-lifecycle status key the /// sidebar reads. Claude reports under `claude_code`; every other agent /// keys its status by its own source name. Returning the agent's own key @@ -642,7 +1057,7 @@ extension FeedCoordinator { resolved: (ownerId: UUID, surfaceId: UUID?)?, tabManager: TabManager? ) -> FeedAttentionTarget? { - guard Self.isBlockingDecisionEvent(event.hookEventName) else { return nil } + guard Self.isBlockingDecisionEvent(event) else { return nil } #if DEBUG if let observer = FeedCoordinatorTestHooks.attentionSurfaceObserver { @@ -851,7 +1266,8 @@ extension FeedCoordinator { /// and the owning window UUID for window-Dock surfaces. Prefer that live /// value so a stale hook-session map cannot redirect attention; fall back /// to the session store only when the event omits a parseable owner. A - /// stored surface is trusted only when its stored owner also matches. + /// surface carried by the event wins; a stored surface is trusted only + /// when its stored owner also matches. private static func resolveAttentionTarget( event: WorkstreamEvent ) -> (ownerId: UUID, surfaceId: UUID?)? { @@ -866,13 +1282,17 @@ extension FeedCoordinator { let eventOwnerId = event.workspaceId.flatMap { UUID(uuidString: $0.trimmingCharacters(in: .whitespacesAndNewlines)) } + let eventSurfaceId = event.surfaceId.flatMap { + UUID(uuidString: $0.trimmingCharacters(in: .whitespacesAndNewlines)) + } guard let ownerId = eventOwnerId ?? sessionMatch?.ownerId else { return nil } // Only trust the session store's surface if it belongs to the owner // we're actually targeting. - let surfaceId = (sessionMatch?.ownerId == ownerId) ? sessionMatch?.surfaceId : nil + let surfaceId = eventSurfaceId + ?? ((sessionMatch?.ownerId == ownerId) ? sessionMatch?.surfaceId : nil) return (ownerId, surfaceId) } @@ -892,6 +1312,7 @@ private final class AttentionOverlayState { private final class PendingWaiter: @unchecked Sendable { let semaphore: DispatchSemaphore var decision: WorkstreamDecision? + var invalidated = false /// The attention overlay target for this decision, if one was surfaced. /// Set inside the ingest `main.sync` (before the card can render and a /// reply can fire) and read when the decision concludes, so the @@ -951,11 +1372,9 @@ extension FeedCoordinator { /// /// Actual focus (workspace.select + surface.focus) is scheduled via /// `FeedJumpResolver.focusIfPossible` on the main actor. + @MainActor func resolvePossibleSurface(for workstreamId: String) -> Bool { - guard let parsed = FeedJumpResolver.parse(workstreamId) else { - return false - } - return FeedJumpResolver.lookup(agent: parsed.agent, sessionId: parsed.sessionId) != nil + target(for: workstreamId) != nil } /// Fires a best-effort focus for the given `workstreamId`. Returns @@ -964,11 +1383,7 @@ extension FeedCoordinator { /// touch AppKit state. @MainActor func focusIfPossible(workstreamId: String) -> Bool { - guard let parsed = FeedJumpResolver.parse(workstreamId), - let target = FeedJumpResolver.lookup( - agent: parsed.agent, sessionId: parsed.sessionId - ) - else { return false } + guard let target = target(for: workstreamId) else { return false } FeedJumpResolver.focus(workspaceId: target.workspaceId, surfaceId: target.surfaceId) return true } @@ -980,11 +1395,7 @@ extension FeedCoordinator { @MainActor @discardableResult func sendTextToWorkstream(workstreamId: String, text: String) -> Bool { - guard let parsed = FeedJumpResolver.parse(workstreamId), - let target = FeedJumpResolver.lookup( - agent: parsed.agent, sessionId: parsed.sessionId - ) - else { return false } + guard let target = target(for: workstreamId) else { return false } FeedJumpResolver.sendText( workspaceId: target.workspaceId, surfaceId: target.surfaceId, @@ -998,7 +1409,7 @@ extension FeedCoordinator { /// to map a feed `workstream_id` back to a cmux `(workspaceId, surfaceId)` pair. /// The schema is the same one written by `cmux -hook session-start`. enum FeedJumpResolver { - struct Target: Equatable { + struct Target: Equatable, Sendable { let workspaceId: String let surfaceId: String } @@ -1012,13 +1423,31 @@ enum FeedJumpResolver { } static func lookup(agent: String, sessionId: String) -> Target? { + return sessions(agent: agent)[sessionId] + } + + static func targets(for workstreamIds: [String]) -> [String: Target] { + let parsed = workstreamIds.compactMap { workstreamId in + parse(workstreamId).map { (workstreamId, $0.agent, $0.sessionId) } + } + let sessionsByAgent = Dictionary(uniqueKeysWithValues: Set(parsed.map { $0.1 }).map { agent in + (agent, sessions(agent: agent)) + }) + return parsed.reduce(into: [:]) { result, entry in + if let target = sessionsByAgent[entry.1]?[entry.2] { + result[entry.0] = target + } + } + } + + private static func sessions(agent: String) -> [String: Target] { let home = FileManager.default.homeDirectoryForCurrentUser let file = home .appendingPathComponent(".cmuxterm", isDirectory: true) .appendingPathComponent("\(agent)-hook-sessions.json", isDirectory: false) guard let data = try? Data(contentsOf: file), let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] - else { return nil } + else { return [:] } // Stores have a consistent shape: top-level `sessions` dict keyed // by sessionId. Tolerate older flat layouts too. let sessions: [String: Any] @@ -1027,12 +1456,13 @@ enum FeedJumpResolver { } else { sessions = root } - guard let entry = sessions[sessionId] as? [String: Any], - let workspaceId = entry["workspaceId"] as? String, - let surfaceId = entry["surfaceId"] as? String, - !workspaceId.isEmpty, !surfaceId.isEmpty - else { return nil } - return Target(workspaceId: workspaceId, surfaceId: surfaceId) + return sessions.reduce(into: [:]) { result, pair in + guard let entry = pair.value as? [String: Any], + let workspaceId = entry["workspaceId"] as? String, + let surfaceId = entry["surfaceId"] as? String, + !workspaceId.isEmpty, !surfaceId.isEmpty else { return } + result[pair.key] = Target(workspaceId: workspaceId, surfaceId: surfaceId) + } } /// Dispatches a workspace-select + surface-focus intent. Posts @@ -1068,6 +1498,14 @@ enum FeedJumpResolver { } } +private final class FeedRegisteredTextSender: @unchecked Sendable { + let send: @MainActor (String) async -> Bool + + init(send: @escaping @MainActor (String) async -> Bool) { + self.send = send + } +} + extension Notification.Name { static let feedRequestFocus = Notification.Name("cmux.feedRequestFocus") static let feedRequestSendText = Notification.Name("cmux.feedRequestSendText") @@ -1109,6 +1547,16 @@ private extension FeedCoordinator { let title: String let body: String switch event.hookEventName { + case .notification where Self.isBlockingDecisionEvent(event): + categoryId = "CMUXFeedQuestion" + title = String( + localized: "feed.notification.question.title", + defaultValue: "\(event.source.capitalized) question" + ) + body = String( + localized: "feed.notification.question.body", + defaultValue: "Agent is asking a question" + ) case .permissionRequest: categoryId = Self.permissionNotificationCategoryId(for: event) title = String( @@ -1639,6 +2087,12 @@ enum FeedSocketEncoding { return dict case .question(let selections): return ["kind": "question", "selections": selections] + case .form(let action, let selections): + return [ + "kind": "form", + "action": action.rawValue, + "selections": selections, + ] } } @@ -1666,6 +2120,30 @@ enum FeedSocketEncoding { "id": question.id, "multi_select": question.multiSelect, ] + if let inputType = question.inputType { + dict["input_type"] = inputType.rawValue + } + if let allowsOther = question.allowsOther { + dict["allows_other"] = allowsOther + } + if let required = question.required { + dict["required"] = required + } + if let defaultValue = question.defaultValue { + dict["default_value"] = defaultValue + } + if let placeholder = question.placeholder { + assignLimitedText(placeholder, key: "placeholder", to: &dict, limit: secondaryTextLimit) + } + if let externalURL = question.externalURL { + assignLimitedText(externalURL, key: "external_url", to: &dict, limit: secondaryTextLimit) + } + if let minimum = question.minimum { dict["minimum"] = minimum } + if let maximum = question.maximum { dict["maximum"] = maximum } + if let minLength = question.minLength { dict["min_length"] = minLength } + if let maxLength = question.maxLength { dict["max_length"] = maxLength } + if let minSelections = question.minSelections { dict["min_selections"] = minSelections } + if let maxSelections = question.maxSelections { dict["max_selections"] = maxSelections } if let header = question.header { assignLimitedText(header, key: "header", to: &dict, limit: secondaryTextLimit) } @@ -1688,13 +2166,34 @@ enum FeedSocketEncoding { var dict: [String: Any] = [ "id": item.id.uuidString, "workstream_id": item.workstreamId, - "source": item.source.rawValue, + "source": item.sourceRawValue ?? item.source.rawValue, "kind": item.kind.rawValue, "created_at": isoFormatter.string(from: item.createdAt), "updated_at": isoFormatter.string(from: item.updatedAt), ] if let cwd = item.cwd { dict["cwd"] = cwd } if let title = item.title { dict["title"] = title } + // Completed-turn cards are useful only when the user can see what the + // agent last said. Keep this bounded and separate from raw tool + // payloads so the mobile cache can retain the answer without exposing + // command input or failed output. + let carriesCompletedTurnAnswer: Bool = { + switch item.payload { + case .stop, .sessionEnd: + return true + default: + return false + } + }() + if carriesCompletedTurnAnswer, + let lastAssistantMessage = item.context?.assistantPreamble { + assignLimitedText( + lastAssistantMessage, + key: "last_assistant_message", + to: &dict, + limit: primaryTextLimit + ) + } switch item.status { case .pending: dict["status"] = "pending" @@ -1719,6 +2218,26 @@ enum FeedSocketEncoding { dict["tool_input_capabilities"] = capabilityJSON } assignLimitedText(toolInputJSON, key: "tool_input", to: &dict) + dict["tool_input_summary"] = safeToolInputSummary(toolInputJSON) + var modes: [String] = [] + let sourceIsKnown = item.sourceRawValue.map { $0 == item.source.rawValue } ?? true + if sourceIsKnown, FeedPermissionActionPolicy.supportsOncePermissionMode(source: item.source, toolInputJSON: toolInputJSON) { + modes.append(WorkstreamPermissionMode.once.rawValue) + } + if sourceIsKnown, FeedPermissionActionPolicy.supportsAlwaysPermissionMode(source: item.source, toolInputJSON: toolInputJSON) { + modes.append(WorkstreamPermissionMode.always.rawValue) + } + if sourceIsKnown, FeedPermissionActionPolicy.supportsPersistentPermissionMode(source: item.source, toolInputJSON: toolInputJSON) { + modes.append(WorkstreamPermissionMode.persistent.rawValue) + } + if sourceIsKnown, FeedPermissionActionPolicy.supportsAllPermissionMode(source: item.source, toolInputJSON: toolInputJSON) { + modes.append(WorkstreamPermissionMode.all.rawValue) + } + if sourceIsKnown, FeedPermissionActionPolicy.supportsBypassPermissions(source: item.source) { + modes.append(WorkstreamPermissionMode.bypass.rawValue) + } + modes.append(WorkstreamPermissionMode.deny.rawValue) + dict["supported_modes"] = modes if let pattern { dict["pattern"] = pattern } case .exitPlan(let requestId, let plan, let defaultMode): dict["request_id"] = requestId @@ -1729,6 +2248,18 @@ enum FeedSocketEncoding { dict["default_mode"] = defaultMode.rawValue case .question(let requestId, let questions): dict["request_id"] = requestId + let interactionKind = Self.questionInteractionKind(questions) + if let interactionKind { + dict["interaction_kind"] = interactionKind + switch interactionKind { + case "boolean": + dict["kind"] = "boolean" + case "form": + dict["kind"] = "form" + default: + break + } + } dict["questions"] = questions.map(questionDict) if let firstQuestion = questions.first { assignLimitedText(firstQuestion.prompt, key: "question_prompt", to: &dict) @@ -1744,6 +2275,28 @@ enum FeedSocketEncoding { return optionDict } } + if let firstQuestion = questions.first, + firstQuestion.inputType == .boolean { + dict["boolean_prompt"] = firstQuestion.prompt + dict["boolean_yes_label"] = firstQuestion.options.first?.label ?? String( + localized: "feed.question.boolean.yes", + defaultValue: "Yes" + ) + dict["boolean_no_label"] = firstQuestion.options.dropFirst().first?.label ?? String( + localized: "feed.question.boolean.no", + defaultValue: "No" + ) + if let defaultValue = firstQuestion.defaultValue, + let boolValue = Self.decodeBool(defaultValue) { + dict["boolean_default"] = boolValue + } + } + if let title = questions.first?.header, interactionKind == "form" { + assignLimitedText(title, key: "form_title", to: &dict, limit: secondaryTextLimit) + } + if let formURL = questions.compactMap(\.externalURL).first { + assignLimitedText(formURL, key: "form_url", to: &dict, limit: secondaryTextLimit) + } case .toolUse(let toolName, let toolInputJSON): dict["tool_name"] = toolName assignLimitedText(toolInputJSON, key: "tool_input", to: &dict) @@ -1768,4 +2321,47 @@ enum FeedSocketEncoding { } return dict } + + private static func questionInteractionKind(_ questions: [WorkstreamQuestionPrompt]) -> String? { + guard !questions.isEmpty else { return nil } + if questions.allSatisfy({ $0.inputType == .boolean }) { return "boolean" } + // The form wire action carries one value per field. Keep any + // multi-select schema on the question channel, which preserves every + // selected value while still rendering text and boolean fields inline. + if questions.contains(where: { $0.multiSelect }) { return nil } + if questions.contains(where: { + $0.inputType == .text + || $0.inputType == .number + || $0.inputType == .integer + || $0.inputType == .url + || $0.inputType == .email + || $0.inputType == .date + || $0.inputType == .dateTime + || $0.inputType == .secret + || $0.inputType == .external + }) { + return "form" + } + if questions.contains(where: { $0.inputType == .choice && $0.required != nil }) { return "form" } + return nil + } + + private static func decodeBool(_ value: String) -> Bool? { + switch value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "1", "true", "yes", "y", "on": return true + case "0", "false", "no", "n", "off": return false + default: return nil + } + } + + /// Redacts values while retaining the field names a user needs to + /// understand the shape of a permission request. + private static func safeToolInputSummary(_ json: String) -> String { + guard let data = json.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data), + let dictionary = object as? [String: Any] else { + return "" + } + return dictionary.keys.sorted().map { "\($0): …" }.joined(separator: ", ") + } } diff --git a/Sources/Feed/FeedPanelView.swift b/Sources/Feed/FeedPanelView.swift index 08e839cd910..49260e2e6c4 100644 --- a/Sources/Feed/FeedPanelView.swift +++ b/Sources/Feed/FeedPanelView.swift @@ -17,6 +17,8 @@ private extension WorkstreamPermissionMode { return String(localized: "feed.permission.mode.once", defaultValue: "once") case .always: return String(localized: "feed.permission.mode.always", defaultValue: "always") + case .persistent: + return String(localized: "feed.permission.mode.persistent", defaultValue: "remembered") case .all: return String(localized: "feed.permission.mode.all", defaultValue: "all tools") case .bypass: @@ -1313,6 +1315,17 @@ struct FeedItemRow: View, Equatable { return "\(submitted) · \(m.displayLabel)" case .question: return submitted + case .form(let action, _): + let label: String + switch action { + case .accept: + label = String(localized: "feed.form.accepted", defaultValue: "Accepted") + case .decline: + label = String(localized: "feed.form.declined", defaultValue: "Declined") + case .cancel: + label = String(localized: "feed.form.cancelled", defaultValue: "Cancelled") + } + return "\(submitted) · \(label)" } } @@ -1457,13 +1470,26 @@ private struct PermissionActionArea: View { source: source, toolInputJSON: toolInputJSON ) { - FeedButton(label: String(localized: "feed.permission.always", defaultValue: "Always Allow"), + FeedButton(label: source == .codex + ? String(localized: "feed.permission.session", defaultValue: "Allow for Session") + : String(localized: "feed.permission.always", defaultValue: "Always Allow"), kind: .primary, size: .medium, fullWidth: true) { onActionRow() onApprove(.always) } .accessibilityIdentifier("FeedPermissionAlwaysAllowButton") } + if FeedPermissionActionPolicy.supportsPersistentPermissionMode( + source: source, + toolInputJSON: toolInputJSON + ) { + FeedButton(label: String(localized: "feed.permission.persistent", defaultValue: "Always Allow"), + kind: .primary, size: .medium, fullWidth: true) { + onActionRow() + onApprove(.persistent) + } + .accessibilityIdentifier("FeedPermissionPersistentAllowButton") + } if FeedPermissionActionPolicy.supportsAllPermissionMode( source: source, toolInputJSON: toolInputJSON diff --git a/Sources/Feed/FeedPermissionActionPolicy.swift b/Sources/Feed/FeedPermissionActionPolicy.swift index 8aaa62054b8..fea92871eac 100644 --- a/Sources/Feed/FeedPermissionActionPolicy.swift +++ b/Sources/Feed/FeedPermissionActionPolicy.swift @@ -2,7 +2,12 @@ import Foundation import CMUXAgentLaunch enum FeedPermissionActionPolicy { - private typealias CodexPermissionCapabilities = (supportsOnce: Bool, supportsAlways: Bool, supportsAll: Bool) + private typealias CodexPermissionCapabilities = ( + supportsOnce: Bool, + supportsAlways: Bool, + supportsPersistent: Bool, + supportsAll: Bool + ) static func supportsPersistentPermissionModes(source: WorkstreamSource) -> Bool { source != .hermesAgent @@ -19,6 +24,11 @@ enum FeedPermissionActionPolicy { return codexCapabilities(toolInputJSON: toolInputJSON).supportsAlways } + static func supportsPersistentPermissionMode(source: WorkstreamSource, toolInputJSON: String?) -> Bool { + guard source == .codex else { return false } + return codexCapabilities(toolInputJSON: toolInputJSON).supportsPersistent + } + static func supportsAllPermissionMode(source: WorkstreamSource, toolInputJSON: String?) -> Bool { guard supportsPersistentPermissionModes(source: source) else { return false } guard source == .codex else { return true } @@ -26,7 +36,7 @@ enum FeedPermissionActionPolicy { } static func supportsBypassPermissions(source: WorkstreamSource) -> Bool { - source != .codex && source != .claude && source != .hermesAgent + return source != .codex && source != .claude && source != .hermesAgent } static func codexCapabilityToolInputJSON(source: WorkstreamSource, toolInputJSON: String) -> String? { @@ -48,6 +58,9 @@ enum FeedPermissionActionPolicy { if let decisions = codexAvailableDecisions(in: object) { snapshot["available_decisions"] = decisions.sorted() } + if let persistScopes = codexMCPPersistScopes(in: object) { + snapshot["mcp_persist"] = persistScopes.sorted() + } if let amendment = object["proposed_execpolicy_amendment"], !(amendment is NSNull) { snapshot["proposed_execpolicy_amendment"] = true @@ -65,12 +78,22 @@ enum FeedPermissionActionPolicy { private static func codexCapabilities(toolInputJSON: String?) -> CodexPermissionCapabilities { guard let toolInputJSON else { - return (supportsOnce: true, supportsAlways: true, supportsAll: true) + return ( + supportsOnce: true, + supportsAlways: true, + supportsPersistent: false, + supportsAll: true + ) } guard let data = toolInputJSON.data(using: .utf8), let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else { - return (supportsOnce: false, supportsAlways: false, supportsAll: false) + return ( + supportsOnce: false, + supportsAlways: false, + supportsPersistent: false, + supportsAll: false + ) } let method = object["app_server_method"] as? String @@ -78,33 +101,76 @@ enum FeedPermissionActionPolicy { let acceptsOnce = decisions?.contains("accept") ?? true let acceptsSession = decisions?.contains("acceptForSession") ?? true switch method { + case "mcpServer/elicitation/request": + let persistScopes = codexMCPPersistScopes(in: object) ?? [] + return ( + supportsOnce: acceptsOnce, + supportsAlways: persistScopes.contains("session"), + supportsPersistent: persistScopes.contains("always"), + supportsAll: false + ) case "item/permissions/requestApproval": return ( supportsOnce: true, supportsAlways: true, + supportsPersistent: false, supportsAll: true ) case "item/commandExecution/requestApproval": return ( supportsOnce: acceptsOnce, supportsAlways: acceptsSession, + supportsPersistent: false, supportsAll: codexSupportsAmendmentDecision(object: object, decisions: decisions) ) case "item/fileChange/requestApproval": return ( supportsOnce: acceptsOnce, supportsAlways: acceptsSession, + supportsPersistent: false, supportsAll: false ) default: return ( supportsOnce: acceptsOnce, supportsAlways: acceptsSession, + supportsPersistent: false, supportsAll: false ) } } + /// Codex advertises MCP approval persistence on elicitation metadata. A + /// malformed value is preserved as an empty set so clients fail closed. + private static func codexMCPPersistScopes(in object: [String: Any]) -> Set? { + let raw: Any? + if let normalized = object["mcp_persist"] { + raw = normalized + } else if let metadata = object["metadata"] as? [String: Any], + let persist = metadata["persist"] { + raw = persist + } else if let metadata = object["_meta"] as? [String: Any], + let persist = metadata["persist"] { + raw = persist + } else if let metadata = object["meta"] as? [String: Any], + let persist = metadata["persist"] { + raw = persist + } else { + raw = nil + } + guard let raw else { return nil } + let values: [String] + if let value = raw as? String { + values = [value] + } else if let array = raw as? [Any] { + values = array.compactMap { $0 as? String } + guard values.count == array.count else { return [] } + } else { + return [] + } + return Set(values.filter { $0 == "session" || $0 == "always" }) + } + private static func codexSupportsAmendmentDecision(object: [String: Any], decisions: Set?) -> Bool { if let amendment = object["proposed_execpolicy_amendment"], codexDecisionAvailableOrUnspecified("acceptWithExecpolicyAmendment", decisions: decisions), diff --git a/Sources/Mobile/MobileHostService+Capabilities.swift b/Sources/Mobile/MobileHostService+Capabilities.swift index cb89829cfb4..715d5d06bff 100644 --- a/Sources/Mobile/MobileHostService+Capabilities.swift +++ b/Sources/Mobile/MobileHostService+Capabilities.swift @@ -64,6 +64,7 @@ extension MobileHostService { "notification.badge.v1", "notification.dismiss.v1", "notification.feed.v1", + "workstream.feed.v1", "notification.reconcile.v1", "terminal.bytes.v1", "terminal.render_grid.v1", diff --git a/Sources/Mobile/MobileHostService+TicketAuthorization.swift b/Sources/Mobile/MobileHostService+TicketAuthorization.swift index eba693d4a8e..bf940091a4c 100644 --- a/Sources/Mobile/MobileHostService+TicketAuthorization.swift +++ b/Sources/Mobile/MobileHostService+TicketAuthorization.swift @@ -111,7 +111,7 @@ extension MobileHostService { terminalSelection: terminalSelection.value ) case "notification.feed.list", "notification.feed.mark_read", "notification.feed.mark_unread", - "notification.feed.mark_all_read": + "notification.feed.mark_all_read", "workstream.feed.list": // The Stack same-account check (or admitted Iroh peer identity) is // the authority for the account-wide feed, just as it is for the // account-wide workspace list. An attach ticket only narrows @@ -119,6 +119,12 @@ extension MobileHostService { // narrow this read model would make it less capable than a tokenless // persisted pairing from the same authenticated account. return nil + case "workstream.feed.action", "workstream.feed.reply": + return ticketTerminalAuthorizationError( + authorization: authorization, + workspaceSelection: workspaceSelection.value, + terminalSelection: terminalSelection.value + ) case "mobile.events.subscribe": // Subscription payloads are revision-only invalidations. The // request already passed connection/account authorization, and the diff --git a/Sources/Panels/AgentSessionProcessStore.swift b/Sources/Panels/AgentSessionProcessStore.swift index 44b2c042e1e..fc53630b2b2 100644 --- a/Sources/Panels/AgentSessionProcessStore.swift +++ b/Sources/Panels/AgentSessionProcessStore.swift @@ -1,5 +1,6 @@ import Foundation import Darwin +import CMUXAgentLaunch @MainActor final class AgentSessionProcessStore { @@ -16,7 +17,12 @@ final class AgentSessionProcessStore { private var lastEmittedHasActiveProviderSession: Bool? private static let terminationEscalationInterval: DispatchTimeInterval = .seconds(3) - func start(plan: AgentSessionLaunchPlan, workingDirectory: String?) async throws -> AgentSessionStartedSession { + func start( + plan: AgentSessionLaunchPlan, + workingDirectory: String?, + workspaceId: UUID? = nil, + surfaceId: UUID? = nil + ) async throws -> AgentSessionStartedSession { guard sessions.isEmpty else { throw AgentSessionBridgeError.sessionAlreadyRunning } @@ -47,12 +53,15 @@ final class AgentSessionProcessStore { executablePath: plan.executableURL.path, arguments: launchArguments, workingDirectory: workingDirectory, + workspaceId: workspaceId?.uuidString, + surfaceId: surfaceId?.uuidString, process: process, stdin: stdin, inputWriter: inputWriter, openCodeAuthorizationHeader: openCodeAuth?.authorizationHeader ) if plan.provider == .codex { + let workstreamID = "codex-\(sessionId)" running.codexAppServerSession = CodexAppServerSession( workingDirectory: workingDirectory, writeData: { data in @@ -81,10 +90,58 @@ final class AgentSessionProcessStore { }, failureSink: { [weak self] _ in self?.failSession(sessionId: sessionId, status: 1) + }, + userInputHandler: { [weak self] request in + guard let self else { + return .error( + code: -32001, + message: String( + localized: "agentSession.codex.error.inputTargetUnavailable", + defaultValue: "Codex input target is unavailable." + ) + ) + } + return await self.handleCodexUserInput( + request, + sessionId: sessionId, + workstreamID: workstreamID, + workspaceID: workspaceId?.uuidString, + surfaceID: surfaceId?.uuidString, + processIdentifier: process.processIdentifier + ) + }, + userInputResolvedSink: { requestID in + FeedCoordinator.shared.invalidateBlockingRequest( + requestId: "codex-\(sessionId)-\(requestID)" + ) } ) } sessions[sessionId] = running + if plan.provider == .codex, + let workspaceId, + let surfaceId { + FeedCoordinator.shared.registerTarget( + agent: "codex", + sessionId: sessionId, + target: FeedJumpResolver.Target( + workspaceId: workspaceId.uuidString, + surfaceId: surfaceId.uuidString + ), + textSender: { [weak self] text in + guard let self, + self.sessions[sessionId]?.providerID == .codex else { + return false + } + do { + try await self.writeLine(sessionId: sessionId, text: text) + return true + } catch { + return false + } + } + ) + } running.stdoutReadTask = makeReadTask(stdout.fileHandleForReading, sessionId: sessionId, stream: "stdout") running.stderrReadTask = makeReadTask(stderr.fileHandleForReading, sessionId: sessionId, stream: "stderr") @@ -109,6 +166,7 @@ final class AgentSessionProcessStore { } running.openCodeEventTask?.cancel() sessions.removeValue(forKey: sessionId) + unregisterFeedTarget(for: running) emitActiveProviderStateIfNeeded() throw error } @@ -133,6 +191,7 @@ final class AgentSessionProcessStore { guard let codexAppServerSession = session.codexAppServerSession else { throw AgentSessionBridgeError.providerNotReady(session.providerID.displayName) } + session.didEmitFeedTurnCompletion = false try await codexAppServerSession.submit(text, permissionMode: permissionMode) case .claude: try await writeClaudeStreamJSON(text, to: session.inputWriter) @@ -189,6 +248,674 @@ final class AgentSessionProcessStore { } } + private func handleCodexUserInput( + _ request: CodexAppServerUserInputRequest, + sessionId: String, + workstreamID: String, + workspaceID: String?, + surfaceID: String?, + processIdentifier: Int32 + ) async -> CodexAppServerUserInputResolution { + guard let params = Self.jsonObject(request.paramsJSON) else { + return .error( + code: -32602, + message: String( + localized: "agentSession.codex.error.inputParametersMalformed", + defaultValue: "Codex input parameters were malformed." + ) + ) + } + let isMCPToolApproval = request.method == "mcpServer/elicitation/request" + && Self.isMCPToolApproval(params) + let isCodexApproval = CodexTeamsApprovalBridge.isApprovalMethod(request.method) + if request.method == "mcpServer/elicitation/request", + !isMCPToolApproval, + !Self.mcpElicitationIsSupported(params) { + let event = WorkstreamEvent( + sessionId: workstreamID, + hookEventName: .notification, + rawHookEventName: "mcpServer/elicitation/unsupported", + source: "codex", + workspaceId: workspaceID, + surfaceId: surfaceID, + toolName: request.method, + toolInputJSON: request.paramsJSON, + requestId: nil, + ppid: processIdentifier > 0 ? Int(processIdentifier) : nil + ) + Task.detached(priority: .utility) { + _ = FeedCoordinator.shared.ingestBlocking(event: event, waitTimeout: 0) + } + return Self.mcpResolution(action: "cancel", content: nil) + } + let approvalPayload = isCodexApproval + ? CodexTeamsApprovalBridge.approvalFeedPayload( + method: request.method, + requestId: request.rpcID, + params: params + ) + : nil + let payload: [String: Any] + if let approvalPayload { + payload = approvalPayload.toolInput + } else if isMCPToolApproval { + payload = Self.mcpApprovalFeedPayload(params) + } else { + payload = Self.codexFeedPayload(method: request.method, params: params) + } + guard JSONSerialization.isValidJSONObject(payload), + let payloadData = try? JSONSerialization.data(withJSONObject: payload, options: []), + let payloadJSON = String(data: payloadData, encoding: .utf8) else { + return .error( + code: -32602, + message: String( + localized: "agentSession.codex.error.inputParametersMalformed", + defaultValue: "Codex input parameters were malformed." + ) + ) + } + + let requestID = "codex-\(sessionId)-\(request.rpcID)" + let event = WorkstreamEvent( + sessionId: workstreamID, + hookEventName: isMCPToolApproval || isCodexApproval ? .permissionRequest : .notification, + rawHookEventName: isMCPToolApproval || isCodexApproval ? nil : request.method, + source: "codex", + workspaceId: workspaceID, + surfaceId: surfaceID, + cwd: approvalPayload?.cwd, + toolName: approvalPayload?.toolName + ?? (isMCPToolApproval ? Self.mcpApprovalDisplayName(params) : request.method), + toolInputJSON: payloadJSON, + context: approvalPayload.map { + WorkstreamContext( + assistantPreamble: $0.context["assistantPreamble"] as? String, + toolSummary: $0.context["toolSummary"] as? String, + permissionMode: $0.context["permissionMode"] as? String + ) + }, + requestId: requestID, + ppid: processIdentifier > 0 ? Int(processIdentifier) : nil + ) + let timeout: TimeInterval + if request.isBlocking { + // Codex defines blocking input as waiting indefinitely. Keep a + // distant safety deadline while serverRequest/resolved and process + // exit provide the normal lifecycle-driven cancellation paths. + timeout = 7 * 24 * 60 * 60 + } else { + timeout = min( + max(Double(request.autoResolutionMilliseconds ?? 120_000) / 1_000, 1), + 120 + ) + } + let outcome = await Task.detached(priority: .userInitiated) { + FeedCoordinator.shared.ingestBlockingWithOutcome( + event: event, + waitTimeout: timeout + ) + }.value + return Self.codexResolution( + outcome, + method: request.method, + params: params + ) + } + + private static func jsonObject(_ json: String) -> [String: Any]? { + guard let data = json.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data), + let dictionary = object as? [String: Any] else { + return nil + } + return dictionary + } + + static func mcpElicitationIsSupported(_ params: [String: Any]) -> Bool { + let mode = (params["mode"] as? String)?.lowercased() ?? "form" + if mode == "url" { + guard let rawURL = params["url"] as? String, + let url = URL(string: rawURL), + let scheme = url.scheme?.lowercased() else { return false } + return scheme == "http" || scheme == "https" + } + guard mode == "form" || mode == "openai/form" else { return false } + guard let schema = (params["requestedSchema"] as? [String: Any]) + ?? (params["requested_schema"] as? [String: Any]) + ?? (params["schema"] as? [String: Any]), + let properties = schema["properties"] as? [String: Any] else { + return false + } + return properties.values.allSatisfy { rawField in + guard let field = rawField as? [String: Any] else { return false } + if field["anyOf"] != nil || field["allOf"] != nil { + return false + } + let type = (field["type"] as? String)?.lowercased() ?? "string" + switch type { + case "string": + if let oneOf = field["oneOf"] { + return Self.mcpTitledEnumIsSupported(oneOf) + } + let format = (field["format"] as? String)?.lowercased() + guard format == nil || ["date", "date-time", "email", "uri"].contains(format!), + Self.mcpNonnegativeInteger(field["minLength"]), + Self.mcpNonnegativeInteger(field["maxLength"]), + Self.mcpOrderedBounds(field["minLength"], field["maxLength"]), + Self.mcpEnumIsSupported(field["enum"]) else { return false } + if let names = field["enumNames"] { + guard let values = field["enum"] as? [Any], + let labels = names as? [String], + values.count == labels.count else { return false } + } + return true + case "number", "integer": + return field["oneOf"] == nil + && Self.mcpOrderedBounds(field["minimum"], field["maximum"]) + && Self.mcpEnumIsSupported(field["enum"]) + case "boolean": + return field["oneOf"] == nil && field["enum"] == nil + case "array": + guard field["oneOf"] == nil, + Self.mcpNonnegativeInteger(field["minItems"]), + Self.mcpNonnegativeInteger(field["maxItems"]), + Self.mcpOrderedBounds(field["minItems"], field["maxItems"]), + let items = field["items"] as? [String: Any] else { return false } + if let anyOf = items["anyOf"] { + return Self.mcpTitledEnumIsSupported(anyOf) + } + return items["enum"] != nil && Self.mcpEnumIsSupported(items["enum"]) + default: + return false + } + } + } + + static func isMCPToolApproval(_ params: [String: Any]) -> Bool { + let metadata = (params["_meta"] as? [String: Any]) + ?? (params["meta"] as? [String: Any]) + return (metadata?["codex_approval_kind"] as? String) == "mcp_tool_call" + } + + private static func mcpApprovalDisplayName(_ params: [String: Any]) -> String { + let server = (params["serverName"] as? String) + ?? (params["server_name"] as? String) + ?? "MCP" + guard let message = params["message"] as? String, + !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return server + } + return "\(server): \(message)" + } + + private static func mcpApprovalFeedPayload(_ params: [String: Any]) -> [String: Any] { + var payload: [String: Any] = [ + "app_server_method": "mcpServer/elicitation/request", + "available_decisions": ["accept", "decline"], + ] + if let serverName = params["serverName"] ?? params["server_name"] { + payload["server_name"] = serverName + } + if let message = params["message"] { + payload["message"] = message + } + if let metadata = params["_meta"] ?? params["meta"] { + payload["metadata"] = metadata + } + return payload + } + + private static func mcpEnumIsSupported(_ raw: Any?) -> Bool { + guard let raw else { return true } + guard let values = raw as? [Any] else { return false } + return values.allSatisfy { + $0 is String || $0 is NSNumber + } + } + + private static func mcpTitledEnumIsSupported(_ raw: Any?) -> Bool { + guard let values = raw as? [[String: Any]], !values.isEmpty else { return false } + return values.allSatisfy { option in + option["const"] is String + && option["title"] is String + } + } + + private static func mcpNonnegativeInteger(_ raw: Any?) -> Bool { + guard let raw else { return true } + guard let number = raw as? NSNumber else { return false } + let value = number.doubleValue + return value.isFinite && value >= 0 && value.rounded(.towardZero) == value + } + + private static func mcpOrderedBounds(_ minimum: Any?, _ maximum: Any?) -> Bool { + let lower = (minimum as? NSNumber)?.doubleValue + let upper = (maximum as? NSNumber)?.doubleValue + if minimum != nil, lower == nil { return false } + if maximum != nil, upper == nil { return false } + if let lower, !lower.isFinite { return false } + if let upper, !upper.isFinite { return false } + guard let lower, let upper else { return true } + return lower <= upper + } + + private static func codexFeedPayload( + method: String, + params: [String: Any] + ) -> [String: Any] { + guard method == "mcpServer/elicitation/request" else { return params } + var payload = params + if let requestedSchema = params["requestedSchema"] { + payload["schema"] = requestedSchema + } else if let requestedSchema = params["requested_schema"] { + payload["schema"] = requestedSchema + } + if let message = params["message"] as? String { + payload["prompt"] = message + payload["title"] = message + } + if payload["schema"] == nil, + payload["fields"] == nil { + var field: [String: Any] = [ + "id": "continue", + "prompt": (params["message"] as? String) ?? String( + localized: "agentSession.codex.input.continue", + defaultValue: "Continue" + ), + "input_type": "external", + "required": false, + ] + if let url = params["url"] as? String { + field["external_url"] = url + } + payload["fields"] = [field] + } + return payload + } + + static func codexResolution( + _ outcome: FeedCoordinator.IngestBlockingOutcome, + method: String, + params: [String: Any] + ) -> CodexAppServerUserInputResolution { + if CodexTeamsApprovalBridge.isApprovalMethod(method) { + let mode: WorkstreamPermissionMode + switch outcome.result { + case .resolved(_, .permission(let resolvedMode)): + mode = resolvedMode + case .resolved, .timedOut, .notFound, .unavailable, .acknowledged: + mode = .deny + } + guard let response = CodexTeamsApprovalBridge.appServerApprovalResponse( + method: method, + params: params, + mode: mode.rawValue + ) else { + return .error( + code: -32601, + message: String( + localized: "agentSession.codex.error.unsupportedServerRequest", + defaultValue: "Request from Codex app-server is not supported: %@" + ).replacingOccurrences(of: "%@", with: method) + ) + } + return jsonResolution(response) + } + + let isMCP = method == "mcpServer/elicitation/request" + switch outcome.result { + case .resolved(_, let decision): + if isMCP, Self.isMCPToolApproval(params) { + guard case .permission(let mode) = decision else { + return mcpResolution(action: "cancel", content: nil) + } + switch mode { + case .once: + return mcpResolution(action: "accept", content: [:]) + case .always: + return mcpResolution( + action: "accept", + content: [:], + metadata: ["persist": "session"] + ) + case .persistent: + return mcpResolution( + action: "accept", + content: [:], + metadata: ["persist": "always"] + ) + case .deny: + return mcpResolution(action: "decline", content: nil) + case .all, .bypass: + return mcpResolution(action: "cancel", content: nil) + } + } + if isMCP { + switch decision { + case .form(let action, let selections): + guard action != .accept || Self.mcpContent( + selections: selections, + params: params + ) != nil else { + return mcpResolution(action: "cancel", content: nil) + } + return mcpResolution( + action: action.rawValue, + content: action == .accept + ? mcpContent(selections: selections, params: params) + : nil + ) + case .question(let selections): + guard let content = mcpContent(selections: selections, params: params) else { + return mcpResolution(action: "cancel", content: nil) + } + return mcpResolution( + action: "accept", + content: content + ) + default: + return mcpResolution(action: "cancel", content: nil) + } + } + switch decision { + case .question(let selections), .form(.accept, let selections): + return codexAnswersResolution(selections: selections, params: params) + default: + return codexAnswersResolution(selections: [], params: params) + } + case .timedOut, .notFound, .unavailable, .acknowledged: + return isMCP + ? mcpResolution(action: "cancel", content: nil) + : codexAnswersResolution(selections: [], params: params) + } + } + + private static func codexAnswersResolution( + selections: [String], + params: [String: Any] + ) -> CodexAppServerUserInputResolution { + var answers: [String: [String]] = [:] + for selection in selections { + guard let separator = selection.firstIndex(of: "=") else { continue } + let key = String(selection[.. String { + guard let questions = params["questions"] as? [[String: Any]], + let question = questions.first(where: { $0["id"] as? String == questionID }), + let options = question["options"] as? [[String: Any]] else { + return value + } + if let option = options.first(where: { + let id = ($0["id"] as? String) ?? ($0["value"] as? String) + return id == value + }), + let label = (option["label"] as? String) ?? (option["title"] as? String) { + return label + } + let indexValue = value.lowercased().hasPrefix("opt") + ? String(value.dropFirst(3)) + : value + if let index = Int(indexValue), options.indices.contains(index), + let label = (options[index]["label"] as? String) + ?? (options[index]["title"] as? String) { + return label + } + return value + } + + private static func mcpContent( + selections: [String], + params: [String: Any] + ) -> [String: Any]? { + let schema = (params["requestedSchema"] as? [String: Any]) + ?? (params["requested_schema"] as? [String: Any]) + ?? (params["schema"] as? [String: Any]) + guard let schema, + let properties = schema["properties"] as? [String: Any] else { + return nil + } + let required: Set + if let rawRequired = schema["required"] { + guard let values = rawRequired as? [String] else { return nil } + required = Set(values) + } else { + required = [] + } + guard required.isSubset(of: Set(properties.keys)) else { return nil } + var grouped: [String: [String]] = [:] + for selection in selections { + guard let separator = selection.firstIndex(of: "=") else { return nil } + let key = String(selection[..= (minimum ?? 0), + maximum.map({ values.count <= $0 }) ?? true, + let itemSchema = fieldSchema["items"] as? [String: Any] else { + return nil + } + let converted = values.compactMap { mcpValue($0, schema: itemSchema) } + guard converted.count == values.count else { return nil } + content[key] = converted + } else { + guard values.count == 1, + let converted = mcpValue(values[0], schema: fieldSchema) else { + return nil + } + content[key] = converted + } + } + return content + } + + private static func mcpValue( + _ value: String, + schema: [String: Any]? + ) -> Any? { + guard let schema else { return nil } + if let allowedValues = mcpAllowedValues(schema) { + guard !allowedValues.isEmpty else { return nil } + let indexValue = value.lowercased().hasPrefix("opt") + ? String(value.dropFirst(3)) + : value + if let index = Int(indexValue), allowedValues.indices.contains(index) { + return allowedValues[index] + } + if let matched = allowedValues.first(where: { + if mcpScalarString($0) == value { return true } + guard let scalar = mcpScalarString($0) else { return false } + return scalar.caseInsensitiveCompare(value) == .orderedSame + }) { + return matched + } + return nil + } + return typedMCPValue(value, schema: schema) + } + + /// Returns nil when the schema has no enum constraint, and an empty array + /// when an enum declaration is malformed or empty. The distinction lets + /// callers fail closed instead of silently accepting an unsupported value. + private static func mcpAllowedValues(_ schema: [String: Any]) -> [Any]? { + if let raw = schema["enum"] { + guard let values = raw as? [Any] else { return [] } + return values + } + for key in ["oneOf", "anyOf"] { + guard let raw = schema[key] else { continue } + guard let options = raw as? [[String: Any]], + !options.isEmpty else { return [] } + let values = options.compactMap { $0["const"] } + return values.count == options.count ? values : [] + } + return nil + } + + private static func mcpScalarString(_ value: Any) -> String? { + if let value = value as? String { return value } + if let value = value as? Bool { return value ? "true" : "false" } + if let value = value as? NSNumber { return value.stringValue } + return nil + } + + private static func typedMCPValue( + _ value: String, + schema: [String: Any]? + ) -> Any? { + guard let schema else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + switch (schema["type"] as? String)?.lowercased() { + case "boolean": + switch trimmed.lowercased() { + case "1", "true", "yes", "y", "on": return true + case "0", "false", "no", "n", "off": return false + default: return nil + } + case "integer": + guard let number = Int(trimmed) else { return nil } + guard mcpNumberValueIsValid(Double(number), schema: schema) else { return nil } + return number + case "number": + guard let number = Double(trimmed), number.isFinite else { return nil } + guard mcpNumberValueIsValid(number, schema: schema) else { return nil } + return number + case "string", nil: + guard mcpStringValueIsValid(trimmed, schema: schema) else { return nil } + return value + default: + return nil + } + } + + private static func mcpIntegerConstraint(_ raw: Any?) -> Int? { + guard let number = raw as? NSNumber else { return nil } + let value = number.doubleValue + guard value.isFinite, + value >= 0, + value.rounded(.towardZero) == value, + value <= Double(Int.max) else { return nil } + return Int(value) + } + + private static func mcpStringValueIsValid( + _ value: String, + schema: [String: Any] + ) -> Bool { + let minimum = mcpIntegerConstraint(schema["minLength"]) + let maximum = mcpIntegerConstraint(schema["maxLength"]) + guard (schema["minLength"] == nil || minimum != nil), + (schema["maxLength"] == nil || maximum != nil) else { + return false + } + if let minimum, value.count < minimum { + return false + } + if let maximum, value.count > maximum { + return false + } + switch (schema["format"] as? String)?.lowercased() { + case "email": + let pieces = value.split(separator: "@", omittingEmptySubsequences: false) + guard pieces.count == 2, + !pieces[0].isEmpty, + !pieces[1].isEmpty, + !value.contains(where: \.isWhitespace) else { return false } + case "date": + guard value.count == 10, + ISO8601DateFormatter().date(from: "\(value)T00:00:00Z") != nil else { + return false + } + case "date-time": + guard ISO8601DateFormatter().date(from: value) != nil else { return false } + case "uri", "uri-reference": + guard let url = URL(string: value), url.scheme?.isEmpty == false else { return false } + default: + break + } + return true + } + + private static func mcpNumberValueIsValid( + _ value: Double, + schema: [String: Any] + ) -> Bool { + guard value.isFinite else { return false } + let minimum = (schema["minimum"] as? NSNumber)?.doubleValue + let maximum = (schema["maximum"] as? NSNumber)?.doubleValue + guard (schema["minimum"] == nil || minimum?.isFinite == true), + (schema["maximum"] == nil || maximum?.isFinite == true) else { + return false + } + if let minimum, value < minimum { return false } + if let maximum, value > maximum { return false } + return true + } + + private static func mcpResolution( + action: String, + content: [String: Any]?, + metadata: [String: Any]? = nil + ) -> CodexAppServerUserInputResolution { + let resolvedContent: Any + if let content { + resolvedContent = content + } else { + resolvedContent = NSNull() + } + var result: [String: Any] = ["action": action, "content": resolvedContent] + if let metadata { + result["_meta"] = metadata + } + return jsonResolution(result) + } + + private static func jsonResolution(_ result: [String: Any]) -> CodexAppServerUserInputResolution { + guard let data = try? JSONSerialization.data(withJSONObject: result, options: []), + let json = String(data: data, encoding: .utf8) else { + return .error( + code: -32603, + message: String( + localized: "agentSession.codex.error.inputResponseEncoding", + defaultValue: "Codex input response could not be encoded." + ) + ) + } + return .result(json: json) + } + private func finishSessionIfExitedAndDrained(_ session: AgentSessionRunningSession) { guard let status = session.pendingExitStatus, session.drainedStreams.isSuperset(of: ["stdout", "stderr"]), @@ -196,11 +923,11 @@ final class AgentSessionProcessStore { return } sessions.removeValue(forKey: session.sessionId) + unregisterFeedTarget(for: session) cancelSessionTasks(session) emitActiveProviderStateIfNeeded() emitExit( - sessionId: session.sessionId, - providerID: session.providerID, + session: session, status: status ) } @@ -209,12 +936,12 @@ final class AgentSessionProcessStore { guard let session = sessions.removeValue(forKey: sessionId) else { return } + unregisterFeedTarget(for: session) emitActiveProviderStateIfNeeded() cancelSessionTasks(session) requestTermination(for: session) emitExit( - sessionId: session.sessionId, - providerID: session.providerID, + session: session, status: status ) } @@ -227,6 +954,25 @@ final class AgentSessionProcessStore { installTerminationEscalationTimer(for: session) } + private func unregisterFeedTarget(for session: AgentSessionRunningSession) { + guard session.providerID == .codex else { return } + let expectedTarget: FeedJumpResolver.Target? + if let workspaceId = session.workspaceId, + let surfaceId = session.surfaceId { + expectedTarget = FeedJumpResolver.Target( + workspaceId: workspaceId, + surfaceId: surfaceId + ) + } else { + expectedTarget = nil + } + FeedCoordinator.shared.unregisterTarget( + agent: "codex", + sessionId: session.sessionId, + expected: expectedTarget + ) + } + private func installTerminationEscalationTimer(for session: AgentSessionRunningSession) { guard session.terminationEscalationTimer == nil else { return @@ -259,6 +1005,7 @@ final class AgentSessionProcessStore { } private func cancelSessionTasks(_ session: AgentSessionRunningSession) { + session.codexAppServerSession?.cancelPendingUserInputRequests() session.terminationEscalationTimer?.cancel() session.terminationEscalationTimer = nil session.stdoutReadTask?.cancel() @@ -375,6 +1122,7 @@ final class AgentSessionProcessStore { removedSession === session else { return } + self.unregisterFeedTarget(for: session) self.emitActiveProviderStateIfNeeded() self.cancelSessionTasks(session) self.requestTermination(for: session) @@ -390,8 +1138,7 @@ final class AgentSessionProcessStore { text: "\(message)\n" ) self.emitExit( - sessionId: session.sessionId, - providerID: session.providerID, + session: session, status: 1 ) } @@ -612,6 +1359,12 @@ final class AgentSessionProcessStore { } private func emitStarted(session: AgentSessionRunningSession) { + ingestCodexFeedEvent( + session: session, + hookEventName: .sessionStart, + toolName: nil, + toolInput: nil + ) eventSink?([ "type": "provider.started", "sessionId": session.sessionId, @@ -641,6 +1394,17 @@ final class AgentSessionProcessStore { providerID: AgentSessionProviderID, activity: [String: Any] ) { + if providerID == .codex, + let session = sessions[sessionId] { + let status = activity["status"] as? String + ingestCodexFeedEvent( + session: session, + hookEventName: status == "inProgress" ? .preToolUse : .postToolUse, + toolName: activity["kind"] as? String, + toolInput: activity, + isError: status == "failed" + ) + } var event = activity event["type"] = "provider.activity" event["sessionId"] = sessionId @@ -652,6 +1416,17 @@ final class AgentSessionProcessStore { sessionId: String, providerID: AgentSessionProviderID ) { + if let session = sessions[sessionId], + providerID == .codex, + !session.didEmitFeedTurnCompletion { + session.didEmitFeedTurnCompletion = true + ingestCodexFeedEvent( + session: session, + hookEventName: .stop, + toolName: nil, + toolInput: ["reason": "turn_complete"] + ) + } eventSink?([ "type": "provider.turnComplete", "sessionId": sessionId, @@ -660,18 +1435,57 @@ final class AgentSessionProcessStore { } private func emitExit( - sessionId: String, - providerID: AgentSessionProviderID, + session: AgentSessionRunningSession, status: Int32 ) { + if session.providerID == .codex { + ingestCodexFeedEvent( + session: session, + hookEventName: .sessionEnd, + toolName: nil, + toolInput: nil + ) + } eventSink?([ "type": "provider.exit", - "sessionId": sessionId, - "providerId": providerID.rawValue, + "sessionId": session.sessionId, + "providerId": session.providerID.rawValue, "status": status ]) } + private func ingestCodexFeedEvent( + session: AgentSessionRunningSession, + hookEventName: WorkstreamEvent.HookEventName, + toolName: String?, + toolInput: [String: Any]?, + isError: Bool? = nil + ) { + guard session.providerID == .codex, + let workspaceId = session.workspaceId, + let surfaceId = session.surfaceId else { return } + let toolInputJSON: String? = toolInput.flatMap { + guard JSONSerialization.isValidJSONObject($0), + let data = try? JSONSerialization.data(withJSONObject: $0, options: []) + else { return nil } + return String(data: data, encoding: .utf8) + } + let event = WorkstreamEvent( + sessionId: "codex-\(session.sessionId)", + hookEventName: hookEventName, + source: "codex", + workspaceId: workspaceId, + surfaceId: surfaceId, + toolName: toolName, + toolInputJSON: toolInputJSON, + isError: isError, + ppid: Int(session.process.processIdentifier) + ) + Task.detached(priority: .utility) { + _ = FeedCoordinator.shared.ingestBlocking(event: event, waitTimeout: 0) + } + } + private func emitActiveProviderStateIfNeeded() { let hasActiveProviderSession = self.hasActiveProviderSession guard lastEmittedHasActiveProviderSession != hasActiveProviderSession else { return } diff --git a/Sources/Panels/AgentSessionRunningSession.swift b/Sources/Panels/AgentSessionRunningSession.swift index aee046e84b9..6d2b62d3cc5 100644 --- a/Sources/Panels/AgentSessionRunningSession.swift +++ b/Sources/Panels/AgentSessionRunningSession.swift @@ -6,6 +6,8 @@ final class AgentSessionRunningSession { let executablePath: String let arguments: [String] let workingDirectory: String? + let workspaceId: String? + let surfaceId: String? let process: Process let stdin: Pipe let inputWriter: AgentSessionInputWriter @@ -21,6 +23,7 @@ final class AgentSessionRunningSession { var terminationEscalationTimer: DispatchSourceTimer? var pendingExitStatus: Int32? var drainedStreams: Set = [] + var didEmitFeedTurnCompletion = false private var stdoutBuffer = AgentSessionOutputLineBuffer() private var stderrBuffer = AgentSessionOutputLineBuffer() private var openCodeEventTextAccumulator = OpenCodeEventTextAccumulator() @@ -31,6 +34,8 @@ final class AgentSessionRunningSession { executablePath: String, arguments: [String], workingDirectory: String?, + workspaceId: String?, + surfaceId: String?, process: Process, stdin: Pipe, inputWriter: AgentSessionInputWriter, @@ -41,6 +46,8 @@ final class AgentSessionRunningSession { self.executablePath = executablePath self.arguments = arguments self.workingDirectory = workingDirectory + self.workspaceId = workspaceId + self.surfaceId = surfaceId self.process = process self.stdin = stdin self.inputWriter = inputWriter diff --git a/Sources/Panels/AgentSessionWebRendererCoordinator.swift b/Sources/Panels/AgentSessionWebRendererCoordinator.swift index a8d69ee1dd5..13bca77e4a0 100644 --- a/Sources/Panels/AgentSessionWebRendererCoordinator.swift +++ b/Sources/Panels/AgentSessionWebRendererCoordinator.swift @@ -594,7 +594,9 @@ final class AgentSessionWebRendererCoordinator: NSObject, WKNavigationDelegate, } let session = try await processStore.start( plan: plan, - workingDirectory: request.string("workingDirectory") ?? workingDirectory + workingDirectory: request.string("workingDirectory") ?? workingDirectory, + workspaceId: workspaceId, + surfaceId: panelId ) return [ "sessionId": session.sessionId, diff --git a/Sources/Panels/CodexAppServerSession.swift b/Sources/Panels/CodexAppServerSession.swift index 1fd0c0256b5..0ae37fec8d3 100644 --- a/Sources/Panels/CodexAppServerSession.swift +++ b/Sources/Panels/CodexAppServerSession.swift @@ -1,5 +1,18 @@ import Foundation +struct CodexAppServerUserInputRequest: Sendable { + let rpcID: String + let method: String + let paramsJSON: String + let isBlocking: Bool + let autoResolutionMilliseconds: Int? +} + +enum CodexAppServerUserInputResolution: Sendable { + case result(json: String) + case error(code: Int, message: String) +} + @MainActor final class CodexAppServerSession { typealias DataWriter = (Data) async throws -> Void @@ -7,6 +20,10 @@ final class CodexAppServerSession { typealias ActivitySink = (_ activity: [String: Any]) -> Void typealias TurnCompleteSink = () -> Void typealias FailureSink = (_ details: String?) -> Void + typealias UserInputHandler = ( + _ request: CodexAppServerUserInputRequest + ) async -> CodexAppServerUserInputResolution + typealias UserInputResolvedSink = (_ rpcID: String) -> Void private static let maxQueuedInputCount = 1 private static let maxQueuedInputBytes = 64 * 1024 @@ -17,6 +34,8 @@ final class CodexAppServerSession { private let activitySink: ActivitySink private let turnCompleteSink: TurnCompleteSink private let failureSink: FailureSink + private let userInputHandler: UserInputHandler? + private let userInputResolvedSink: UserInputResolvedSink private var nextRequestID = 1 private var initializeRequestID: Int? private var didInitialize = false @@ -28,6 +47,9 @@ final class CodexAppServerSession { private var activePermissionMode: AgentSessionPermissionMode = .standard private var isTurnInFlight = false private var turnStartRequestIDs: Set = [] + private var activeUserInputRequestIDs: Set = [] + private var resolvedServerRequestIDs: Set = [] + private var userInputTasks: [String: Task] = [:] init( workingDirectory: String?, @@ -35,7 +57,9 @@ final class CodexAppServerSession { outputSink: @escaping OutputSink, activitySink: @escaping ActivitySink = { _ in }, turnCompleteSink: @escaping TurnCompleteSink = {}, - failureSink: @escaping FailureSink = { _ in } + failureSink: @escaping FailureSink = { _ in }, + userInputHandler: UserInputHandler? = nil, + userInputResolvedSink: @escaping UserInputResolvedSink = { _ in } ) { self.workingDirectory = workingDirectory self.writeData = writeData @@ -43,6 +67,8 @@ final class CodexAppServerSession { self.activitySink = activitySink self.turnCompleteSink = turnCompleteSink self.failureSink = failureSink + self.userInputHandler = userInputHandler + self.userInputResolvedSink = userInputResolvedSink } func start() async throws { @@ -56,7 +82,9 @@ final class CodexAppServerSession { ], "capabilities": [ "experimentalApi": true, - "requestAttestation": false + "requestAttestation": false, + "mcpServerOpenaiFormElicitation": true, + "extensions": ["openai/form": [String: Any]()] ] ] ) @@ -220,6 +248,13 @@ final class CodexAppServerSession { case "turn/completed", "turn/complete", "turn/finished", "turn/end", "turn/ended", "turn/stopped", "turn/failed", "turn/canceled", "turn/cancelled": completeTurn() + case "serverRequest/resolved": + guard let rawRequestID = params?["requestId"], + let requestID = Self.rpcIDString(from: rawRequestID), + activeUserInputRequestIDs.remove(requestID) != nil else { break } + resolvedServerRequestIDs.insert(requestID) + userInputTasks.removeValue(forKey: requestID)?.cancel() + userInputResolvedSink(requestID) case "item/commandExecution/outputDelta": guard let itemID = params?["itemId"] as? String else { break } emitActivity( @@ -433,6 +468,13 @@ final class CodexAppServerSession { private func handleServerRequest(_ object: [String: Any], method: String) { guard let id = object["id"] else { return } + if method == "item/tool/requestUserInput" + || method == "mcpServer/elicitation/request" + || (userInputHandler != nil && CodexTeamsApprovalBridge.isApprovalMethod(method)) + { + handleUserInputRequest(object, method: method, id: id) + return + } let result: [String: Any] switch method { case "item/commandExecution/requestApproval": @@ -476,6 +518,94 @@ final class CodexAppServerSession { } } + private func handleUserInputRequest( + _ object: [String: Any], + method: String, + id: Any + ) { + guard let userInputHandler, + let rpcID = Self.rpcIDString(from: id), + let params = object["params"] as? [String: Any], + JSONSerialization.isValidJSONObject(params), + let data = try? JSONSerialization.data(withJSONObject: params, options: []), + let paramsJSON = String(data: data, encoding: .utf8) else { + Task { @MainActor in + do { + try await sendErrorResponse( + id: id, + code: -32601, + message: String( + localized: "agentSession.codex.error.unsupportedServerRequest", + defaultValue: "Request from Codex app-server is not supported: %@" + ).replacingOccurrences(of: "%@", with: method) + ) + } catch { + emitCodexRPCFailure(error) + } + } + return + } + + let autoResolutionMilliseconds = Self.integerValue(params["autoResolutionMs"]) + ?? Self.integerValue(params["auto_resolution_ms"]) + let request = CodexAppServerUserInputRequest( + rpcID: rpcID, + method: method, + paramsJSON: paramsJSON, + isBlocking: Self.boolValue(params["isBlocking"]) ?? true, + autoResolutionMilliseconds: autoResolutionMilliseconds + ) + activeUserInputRequestIDs.insert(rpcID) + userInputTasks[rpcID] = Task { @MainActor in + let resolution = await userInputHandler(request) + defer { + activeUserInputRequestIDs.remove(rpcID) + resolvedServerRequestIDs.remove(rpcID) + userInputTasks.removeValue(forKey: rpcID) + } + guard !Task.isCancelled, + !resolvedServerRequestIDs.contains(rpcID) else { return } + do { + switch resolution { + case .result(let json): + guard let data = json.data(using: .utf8), + let result = try JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + try await sendErrorResponse( + id: id, + code: -32603, + message: String( + localized: "agentSession.codex.error.inputResponseNotObject", + defaultValue: "Codex input response was not a JSON object." + ) + ) + return + } + try await sendJSONObject(["id": id, "result": result]) + case .error(let code, let message): + try await sendErrorResponse(id: id, code: code, message: message) + } + } catch is CancellationError { + return + } catch { + emitCodexRPCFailure(error) + } + } + } + + /// Invalidates every response-bearing server request owned by this + /// session. The sink wakes Feed waiters before task cancellation releases + /// the app-server session, so process exit cannot strand a seven-day wait. + func cancelPendingUserInputRequests() { + let requestIDs = activeUserInputRequestIDs + activeUserInputRequestIDs.removeAll() + for requestID in requestIDs { + resolvedServerRequestIDs.insert(requestID) + userInputTasks.removeValue(forKey: requestID)?.cancel() + userInputResolvedSink(requestID) + } + } + private func commandApprovalDecision() -> String { switch activePermissionMode { case .fullAccess: @@ -641,6 +771,33 @@ final class CodexAppServerSession { return nil } + private static func rpcIDString(from value: Any) -> String? { + if let value = value as? String, !value.isEmpty { return value } + if let value = value as? NSNumber { return value.stringValue } + if let value = value as? Int { return String(value) } + return nil + } + + private static func integerValue(_ value: Any?) -> Int? { + if let value = value as? Int { return value } + if let value = value as? NSNumber { return value.intValue } + if let value = value as? String { return Int(value) } + return nil + } + + private static func boolValue(_ value: Any?) -> Bool? { + if let value = value as? Bool { return value } + if let value = value as? NSNumber { return value.boolValue } + if let value = value as? String { + switch value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "1", "true", "yes": return true + case "0", "false", "no": return false + default: return nil + } + } + return nil + } + private func codexMessage(from params: [String: Any]?) -> String? { if let message = params?["message"] as? String { return message diff --git a/Sources/TerminalController+ControlFeedContext.swift b/Sources/TerminalController+ControlFeedContext.swift index b0bd8d84751..140fb2390e0 100644 --- a/Sources/TerminalController+ControlFeedContext.swift +++ b/Sources/TerminalController+ControlFeedContext.swift @@ -10,7 +10,7 @@ import Foundation /// /// Only the MAIN-ACTOR feed methods move here. The worker-lane feed methods /// (`feed.push`, `feed.permission.reply`, `feed.question.reply`, -/// `feed.exit_plan.reply`) stay on the app-side socket-worker path. +/// `feed.exit_plan.reply`, `feed.invalidate`) stay on the app-side socket-worker path. extension TerminalController: ControlFeedContext { func controlFeedResolvePossibleSurface(workstreamID: String) -> Bool { FeedCoordinator.shared.resolvePossibleSurface(for: workstreamID) diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index acd93aece50..b372aeecd0a 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1380,6 +1380,8 @@ class TerminalController { return v2Result(id: request.id, v2FeedQuestionReply(params: request.params)) case "feed.exit_plan.reply": return v2Result(id: request.id, v2FeedExitPlanReply(params: request.params)) + case "feed.invalidate": + return v2Result(id: request.id, v2FeedInvalidate(params: request.params)) case "browser.download.wait": return v2Result(id: request.id, v2BrowserDownloadWaitOnSocketWorker(params: request.params)) case "browser.navigate", "browser.back", "browser.forward", "browser.reload", @@ -2734,6 +2736,7 @@ class TerminalController { "feed.permission.reply", "feed.question.reply", "feed.exit_plan.reply", + "feed.invalidate", "feed.jump", "feed.list", "surface.list", @@ -6099,6 +6102,19 @@ class TerminalController { return .ok(["delivered": true]) } + private nonisolated func v2FeedInvalidate(params: [String: Any]) -> V2CallResult { + guard let requestId = params["request_id"] as? String, + !requestId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return .err( + code: "invalid_params", + message: "feed.invalidate requires request_id", + data: nil + ) + } + FeedCoordinator.shared.invalidateBlockingRequest(requestId: requestId) + return .ok(["invalidated": true]) + } + private nonisolated func v2FeedExitPlanReply(params: [String: Any]) -> V2CallResult { guard let requestId = params["request_id"] as? String else { return .err( @@ -14422,6 +14438,12 @@ class TerminalController { result = v2MobileNotificationFeedMarkUnread(params: request.params) case "notification.feed.mark_all_read": result = v2MobileNotificationFeedMarkAllRead(params: request.params) + case "workstream.feed.list": + result = await v2MobileWorkstreamFeedList(params: request.params) + case "workstream.feed.action": + result = v2MobileWorkstreamFeedAction(params: request.params) + case "workstream.feed.reply": + result = await v2MobileWorkstreamFeedReply(params: request.params) case "dogfood.feedback.submit": result = await v2MobileDogfoodFeedbackSubmit(params: request.params) case "mobile.sync.fetch": @@ -14443,6 +14465,166 @@ class TerminalController { return mobileHostResult(result) } + /// Authenticated, authoritative coding-agent Feed snapshot for iOS. + private func v2MobileWorkstreamFeedList(params: [String: Any]) async -> V2CallResult { + let cursor = params["cursor"] as? String + let history: (revision: UInt64, page: WorkstreamStore.HistoryPage) + do { + history = try await FeedCoordinator.shared.mobileHistoryPage( + endingBefore: cursor, + limit: WorkstreamDefaultHistoryPageSize + ) + } catch WorkstreamHistoryError.invalidCursor { + return .err(code: "invalid_params", message: "Unknown Feed history cursor", data: nil) + } catch { + return .err(code: "internal_error", message: "Unable to load Feed history", data: nil) + } + let workstreamIds = history.page.items.map(\.workstreamId) + let persistedTargets = await Task.detached(priority: .utility) { + FeedJumpResolver.targets(for: workstreamIds) + }.value + let liveTargets = persistedTargets.merging( + FeedCoordinator.shared.registeredTargets(for: workstreamIds), + uniquingKeysWith: { _, registered in registered } + ) + let items = history.page.items.map { item -> [String: Any] in + var payload = FeedSocketEncoding.itemDict(item) + let liveTarget = liveTargets[item.workstreamId] + if let workspaceID = liveTarget?.workspaceId ?? item.workspaceId { + payload["workspace_id"] = workspaceID + } + if let surfaceID = liveTarget?.surfaceId ?? item.surfaceId { + payload["surface_id"] = surfaceID + } + // Mobile history must never carry the raw output of a failed + // tool. The desktop socket retains it for local diagnostics, but + // the authenticated phone response is a durable privacy boundary. + if payload["tool_result_is_error"] as? Bool == true { + payload["tool_result"] = nil + } + return payload + } + var response: [String: Any] = [ + "revision": history.revision, + "items": items, + "has_more": history.page.hasMore, + ] + if let nextCursor = history.page.nextCursor { response["next_cursor"] = nextCursor } + return .ok(response) + } + + /// Resolves one exact pending item. The item id and request id must name the + /// same card; this fails closed for stale, expired, or already-resolved UI. + private func v2MobileWorkstreamFeedAction(params: [String: Any]) -> V2CallResult { + guard let itemRaw = params["item_id"] as? String, + let itemId = UUID(uuidString: itemRaw), + let requestId = params["request_id"] as? String, + let workspaceId = params["workspace_id"] as? String, + let surfaceId = params["surface_id"] as? String, + let kind = params["kind"] as? String else { + return .err(code: "invalid_params", message: "Missing feed action identity", data: nil) + } + let decision: WorkstreamDecision + switch kind { + case "permission": + guard let raw = params["mode"] as? String, + let mode = WorkstreamPermissionMode(rawValue: raw) else { + return .err(code: "invalid_params", message: "Invalid permission mode", data: nil) + } + decision = .permission(mode) + case "exit_plan": + guard let raw = params["mode"] as? String, + let mode = WorkstreamExitPlanMode(rawValue: raw) else { + return .err(code: "invalid_params", message: "Invalid plan mode", data: nil) + } + decision = .exitPlan(mode, feedback: params["feedback"] as? String) + case "question": + guard let selections = params["selections"] as? [String] else { + return .err(code: "invalid_params", message: "Missing question selections", data: nil) + } + decision = .question(selections: selections) + case "boolean", "confirmation", "approval": + guard let value = params["value"] as? Bool else { + return .err(code: "invalid_params", message: "Missing boolean value", data: nil) + } + let question = FeedCoordinator.shared + .snapshot(pendingOnly: false) + .first(where: { $0.id == itemId }) + .flatMap { item in + if case .question(_, let questions) = item.payload { + return questions.first + } + return nil + } + let questionID = question?.id ?? "q0" + let optionID = value + ? (question?.options.first?.id ?? "yes") + : (question?.options.dropFirst().first?.id ?? "no") + decision = .question(selections: ["\(questionID)=\(optionID)"]) + case "form", "elicitation": + guard let action = WorkstreamFormAction( + rawValue: (params["action"] as? String) ?? WorkstreamFormAction.accept.rawValue + ) else { + return .err(code: "invalid_params", message: "Invalid form action", data: nil) + } + let selections: [String] + if let encoded = params["selections"] as? [String] { + selections = encoded + } else if let values = params["values"] as? [String: String] { + selections = values + .sorted { $0.key < $1.key } + .map { "\($0.key)=\($0.value)" } + } else { + selections = [] + } + guard action == .accept || selections.isEmpty else { + return .err(code: "invalid_params", message: "Non-accept form actions cannot include values", data: nil) + } + decision = .form(action: action, selections: selections) + default: + return .err(code: "invalid_params", message: "Unknown feed action", data: nil) + } + let outcome = FeedCoordinator.shared.deliverMobileReply( + itemId: itemId, + requestId: requestId, + workspaceId: workspaceId, + surfaceId: surfaceId, + decision: decision + ) + guard outcome == .delivered else { + return .err(code: outcome.rawValue, message: "Feed item is no longer actionable", data: nil) + } + return .ok(["status": outcome.rawValue]) + } + + /// Sends one acknowledged ordinary turn reply to a pinned route. + private func v2MobileWorkstreamFeedReply(params: [String: Any]) async -> V2CallResult { + guard let itemRaw = params["item_id"] as? String, + let itemId = UUID(uuidString: itemRaw), + let workstreamId = params["workstream_id"] as? String, + let workspaceId = params["workspace_id"] as? String, + let surfaceId = params["surface_id"] as? String, + let text = params["text"] as? String, + !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return .err(code: "invalid_params", message: "Missing feed reply target or text", data: nil) + } + guard await FeedCoordinator.shared.sendTextToTarget( + workstreamId: workstreamId, + itemId: itemId, + workspaceId: workspaceId, + surfaceId: surfaceId, + text: text + ) else { + return .err(code: "target_unavailable", message: "Agent target moved or is unavailable", data: nil) + } + return .ok([ + "status": "acknowledged", + "workstream_id": workstreamId, + "workspace_id": workspaceId, + "surface_id": surfaceId, + ]) + } + /// Privileged agent feedback sink (the Mac↔phone feedback loop). /// /// Reads `{ text, terminal_text, build_stamp, diagnostic_blob_base64 }` off diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 324d0d3fe2f..bdecdae64d7 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -902,6 +902,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C8711D000000000000000002 /* CodexRolloutIdentityResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8711D000000000000000001 /* CodexRolloutIdentityResolver.swift */; }; C0DECAFE0000000000000001 /* CodexTeamsApprovalBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DECAFE0000000000000002 /* CodexTeamsApprovalBridge.swift */; }; C0DECAFE0000000000000003 /* CodexTeamsApprovalBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DECAFE0000000000000002 /* CodexTeamsApprovalBridge.swift */; }; + C0DECAFE0000000000000004 /* CodexTeamsApprovalBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DECAFE0000000000000002 /* CodexTeamsApprovalBridge.swift */; }; C0DEBACC0000000000000001 /* CodexTeamsAppServerFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEBACC0000000000000004 /* CodexTeamsAppServerFixture.swift */; }; C0DEBACC0000000000000003 /* CodexTeamsResumedBackfillTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEBACC0000000000000006 /* CodexTeamsResumedBackfillTests.swift */; }; C0DEBACC0000000000000002 /* CodexTeamsSocketFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEBACC0000000000000005 /* CodexTeamsSocketFixture.swift */; }; @@ -9383,6 +9384,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A9E02000000000000000000E /* CodexAppServerSession.swift in Sources */, C8711C000000000000000002 /* CodexRolloutIdentity.swift in Sources */, C8711D000000000000000002 /* CodexRolloutIdentityResolver.swift in Sources */, + C0DECAFE0000000000000004 /* CodexTeamsApprovalBridge.swift in Sources */, C4041001000000000000001B /* CommandClickFileOpenRouter.swift in Sources */, C0DE86060000000000000001 /* CommandPaletteFocusRestoreCoordinator.swift in Sources */, C0DEFF200000000000000001 /* CommandPaletteOverlay.swift in Sources */, diff --git a/cmuxTests/CodexAppServerSessionTests.swift b/cmuxTests/CodexAppServerSessionTests.swift index a7d97bd9445..57abdbc7444 100644 --- a/cmuxTests/CodexAppServerSessionTests.swift +++ b/cmuxTests/CodexAppServerSessionTests.swift @@ -138,6 +138,333 @@ struct CodexAppServerSessionTests { ) } + @Test + func testCodexMcpElicitationSchemaSupportFailsClosed() { + expectTrue(AgentSessionProcessStore.mcpElicitationIsSupported([ + "mode": "openai/form", + "requestedSchema": [ + "type": "object", + "properties": [ + "target": ["type": "string", "enum": ["iOS", "macOS"]], + "confirm": ["type": "boolean"], + "count": ["type": "integer", "minimum": 1, "maximum": 5], + "named": [ + "type": "string", + "oneOf": [ + ["const": "a", "title": "Alpha"], + ["const": "b", "title": "Beta"], + ], + ], + "tags": [ + "type": "array", + "items": [ + "anyOf": [ + ["const": "a", "title": "Alpha"], + ["const": "b", "title": "Beta"], + ], + ], + "minItems": 1, + "maxItems": 2, + ], + ], + ], + ])) + expectTrue(AgentSessionProcessStore.mcpElicitationIsSupported([ + "mode": "url", + "url": "https://example.com/approve", + ])) + expectFalse(AgentSessionProcessStore.mcpElicitationIsSupported([ + "mode": "openai/form", + "requestedSchema": [ + "type": "object", + "properties": ["nested": ["type": "object"]], + ], + ])) + expectFalse(AgentSessionProcessStore.mcpElicitationIsSupported([ + "mode": "url", + "url": "javascript:alert(1)", + ])) + expectFalse(AgentSessionProcessStore.mcpElicitationIsSupported([ + "mode": "form", + "requestedSchema": [ + "type": "object", + "properties": ["bad": ["type": "string", "minLength": 5, "maxLength": 2]], + ], + ])) + } + + @Test + func testCodexMcpToolApprovalUsesPermissionDecisions() throws { + let params: [String: Any] = [ + "message": "Allow the tool?", + "_meta": ["codex_approval_kind": "mcp_tool_call"], + ] + expectTrue(AgentSessionProcessStore.isMCPToolApproval(params)) + + let accepted = AgentSessionProcessStore.codexResolution( + .init(result: .resolved(itemId: nil, decision: .permission(.once)), authoritativeEvent: nil), + method: "mcpServer/elicitation/request", + params: params + ) + guard case .result(let acceptedJSON) = accepted else { + Issue.record("expected an accepted MCP approval response") + return + } + let acceptedObject = try #require( + JSONSerialization.jsonObject(with: Data(acceptedJSON.utf8)) as? [String: Any] + ) + expectEqual(acceptedObject["action"] as? String, "accept") + #expect(acceptedObject["content"] is [String: Any]) + + for (mode, scope) in [ + (WorkstreamPermissionMode.always, "session"), + (.persistent, "always"), + ] { + let resolution = AgentSessionProcessStore.codexResolution( + .init(result: .resolved(itemId: nil, decision: .permission(mode)), authoritativeEvent: nil), + method: "mcpServer/elicitation/request", + params: params + ) + guard case .result(let json) = resolution, + let object = try JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any] else { + Issue.record("expected a persistent MCP approval response") + continue + } + expectEqual(object["action"] as? String, "accept") + expectEqual((object["_meta"] as? [String: Any])?["persist"] as? String, scope) + } + + let denied = AgentSessionProcessStore.codexResolution( + .init(result: .resolved(itemId: nil, decision: .permission(.deny)), authoritativeEvent: nil), + method: "mcpServer/elicitation/request", + params: params + ) + guard case .result(let deniedJSON) = denied else { + Issue.record("expected a declined MCP approval response") + return + } + let deniedObject = try #require( + JSONSerialization.jsonObject(with: Data(deniedJSON.utf8)) as? [String: Any] + ) + expectEqual(deniedObject["action"] as? String, "decline") + #expect(deniedObject["content"] is NSNull) + } + + @Test + func testCodexAppServerApprovalResolutionsFollowFeedPermissionModes() throws { + func resultObject( + _ result: FeedCoordinator.IngestBlockingResult, + method: String, + params: [String: Any] + ) throws -> [String: Any] { + let resolution = AgentSessionProcessStore.codexResolution( + .init(result: result, authoritativeEvent: nil), + method: method, + params: params + ) + guard case .result(let json) = resolution else { + Issue.record("expected a Codex approval result") + return [:] + } + return try #require( + JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any] + ) + } + + let commandParams: [String: Any] = [ + "availableDecisions": [ + "accept", + "acceptForSession", + ["acceptWithExecpolicyAmendment": [String: Any]()], + "decline", + ], + "proposedExecpolicyAmendment": [["kind": "prefix", "value": "npm test"]], + ] + let commandOnce = try resultObject( + .resolved(itemId: nil, decision: .permission(.once)), + method: "item/commandExecution/requestApproval", + params: commandParams + ) + expectEqual(commandOnce["decision"] as? String, "accept") + + let commandAlways = try resultObject( + .resolved(itemId: nil, decision: .permission(.always)), + method: "item/commandExecution/requestApproval", + params: commandParams + ) + expectEqual(commandAlways["decision"] as? String, "acceptForSession") + + let commandAll = try resultObject( + .resolved(itemId: nil, decision: .permission(.all)), + method: "item/commandExecution/requestApproval", + params: commandParams + ) + let commandAllDecision = try #require(commandAll["decision"] as? [String: Any]) + #expect(commandAllDecision["acceptWithExecpolicyAmendment"] != nil) + + let deniedFile = try resultObject( + .resolved(itemId: nil, decision: .permission(.deny)), + method: "item/fileChange/requestApproval", + params: [:] + ) + expectEqual(deniedFile["decision"] as? String, "decline") + + let requestedPermissions: [String: Any] = [ + "network": ["enabled": true], + ] + for (mode, scope) in [ + (WorkstreamPermissionMode.once, "turn"), + (.always, "session"), + ] { + let permissionResult = try resultObject( + .resolved(itemId: nil, decision: .permission(mode)), + method: "item/permissions/requestApproval", + params: ["permissions": requestedPermissions] + ) + expectEqual(permissionResult["scope"] as? String, scope) + let permissions = try #require(permissionResult["permissions"] as? [String: Any]) + let network = try #require(permissions["network"] as? [String: Any]) + expectEqual(network["enabled"] as? Bool, true) + } + + for result in [ + FeedCoordinator.IngestBlockingResult.timedOut(itemId: nil), + .resolved(itemId: nil, decision: .question(selections: [])), + ] { + let failedClosed = try resultObject( + result, + method: "item/commandExecution/requestApproval", + params: commandParams + ) + expectEqual(failedClosed["decision"] as? String, "decline") + } + } + + @Test + func testCodexMcpFormResolutionPreservesSchemaTypesAndEnumValues() throws { + let params: [String: Any] = [ + "requestedSchema": [ + "type": "object", + "properties": [ + "confirm": ["type": "boolean"], + "count": ["type": "integer"], + "ratio": ["type": "number"], + "target": ["type": "string", "enum": ["iOS", "macOS"]], + "tags": [ + "type": "array", + "items": ["type": "string", "enum": ["fast", "safe"]], + ], + ], + ], + ] + let resolution = AgentSessionProcessStore.codexResolution( + .init( + result: .resolved( + itemId: nil, + decision: .question(selections: [ + "confirm=true", + "count=3", + "ratio=1.5", + "target=opt1", + "tags=opt0", + "tags=opt1", + ]) + ), + authoritativeEvent: nil + ), + method: "mcpServer/elicitation/request", + params: params + ) + + guard case .result(let json) = resolution else { + Issue.record("expected an accepted MCP form response") + return + } + let object = try #require( + JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any] + ) + expectEqual(object["action"] as? String, "accept") + let content = try #require(object["content"] as? [String: Any]) + expectEqual(content["confirm"] as? Bool, true) + expectEqual(content["count"] as? Int, 3) + expectEqual(content["ratio"] as? Double, 1.5) + expectEqual(content["target"] as? String, "macOS") + expectEqual(content["tags"] as? [String], ["fast", "safe"]) + } + + @Test + func testCodexMcpTitledEnumsAndExplicitFormActions() throws { + let params: [String: Any] = [ + "requestedSchema": [ + "type": "object", + "properties": [ + "target": [ + "type": "string", + "oneOf": [ + ["const": "ios", "title": "iOS"], + ["const": "mac", "title": "macOS"], + ], + ], + "tags": [ + "type": "array", + "items": [ + "anyOf": [ + ["const": "fast", "title": "Fast"], + ["const": "safe", "title": "Safe"], + ], + ], + ], + ], + ], + ] + let accepted = AgentSessionProcessStore.codexResolution( + .init( + result: .resolved( + itemId: nil, + decision: .form( + action: .accept, + selections: ["target=mac", "tags=fast", "tags=safe"] + ) + ), + authoritativeEvent: nil + ), + method: "mcpServer/elicitation/request", + params: params + ) + guard case .result(let acceptedJSON) = accepted else { + Issue.record("expected an accepted MCP form response") + return + } + let acceptedObject = try #require( + JSONSerialization.jsonObject(with: Data(acceptedJSON.utf8)) as? [String: Any] + ) + expectEqual(acceptedObject["action"] as? String, "accept") + let content = try #require(acceptedObject["content"] as? [String: Any]) + expectEqual(content["target"] as? String, "mac") + expectEqual(content["tags"] as? [String], ["fast", "safe"]) + + for action in [WorkstreamFormAction.decline, .cancel] { + let resolution = AgentSessionProcessStore.codexResolution( + .init( + result: .resolved( + itemId: nil, + decision: .form(action: action, selections: []) + ), + authoritativeEvent: nil + ), + method: "mcpServer/elicitation/request", + params: params + ) + guard case .result(let json) = resolution, + let object = try JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any] else { + Issue.record("expected an explicit MCP form response") + continue + } + expectEqual(object["action"] as? String, action.rawValue) + #expect(object["content"] is NSNull) + } + } + @Test func testOpenCodeEventTextAccumulatorEmitsAssistantTextDeltasAfterRoleAndPartAreKnown() { var accumulator = OpenCodeEventTextAccumulator() @@ -730,7 +1057,13 @@ struct CodexAppServerSessionTests { ) try await session.start() - expectEqual(jsonLine(sentLines[0])["method"] as? String, "initialize") + let initialize = jsonLine(sentLines[0]) + expectEqual(initialize["method"] as? String, "initialize") + let initializeParams = try #require(initialize["params"] as? [String: Any]) + let capabilities = try #require(initializeParams["capabilities"] as? [String: Any]) + expectEqual(capabilities["mcpServerOpenaiFormElicitation"] as? Bool, true) + let extensions = try #require(capabilities["extensions"] as? [String: Any]) + #expect(extensions["openai/form"] is [String: Any]) session.consumeStdout( #"{"id":1,"result":{"userAgent":"codex","codexHome":"/tmp","platformFamily":"unix","platformOs":"macos"}}"# @@ -993,6 +1326,164 @@ struct CodexAppServerSessionTests { expectEqual(networkPermissions["enabled"] as? Bool, true) } + @Test + func testAppServerUserInputRequestsUseTheFeedResolutionHandler() async throws { + var sentLines: [String] = [] + var receivedRequest: CodexAppServerUserInputRequest? + let session = CodexAppServerSession( + workingDirectory: nil, + writeData: { data in + sentLines.append(String(decoding: data, as: UTF8.self).trimmingCharacters(in: .newlines)) + }, + outputSink: { _, _ in }, + userInputHandler: { request in + receivedRequest = request + return .result(json: #"{"answers":{"mode":{"answers":["Fast"]}}}"#) + } + ) + + session.consumeStdout( + #"{"id":"input-1","method":"item/tool/requestUserInput","params":{"threadId":"thread-1","questions":[{"id":"mode","question":"Choose","options":[{"label":"Fast"}]}],"isBlocking":false,"autoResolutionMs":4500}}"# + + "\n" + ) + for _ in 0..<3 { await Task.yield() } + + let request = try #require(receivedRequest) + expectEqual(request.rpcID, "input-1") + expectEqual(request.method, "item/tool/requestUserInput") + expectEqual(request.isBlocking, false) + expectEqual(request.autoResolutionMilliseconds, 4500) + let response = try #require(sentLines.first.flatMap(jsonLine(_:))["result"] as? [String: Any]) + let answers = try #require(response["answers"] as? [String: Any]) + let mode = try #require(answers["mode"] as? [String: Any]) + expectEqual(mode["answers"] as? [String], ["Fast"]) + } + + @Test + func testAppServerApprovalRequestsUseTheFeedResolutionHandler() async throws { + var sentLines: [String] = [] + var receivedRequest: CodexAppServerUserInputRequest? + let session = CodexAppServerSession( + workingDirectory: nil, + writeData: { data in + sentLines.append(String(decoding: data, as: UTF8.self).trimmingCharacters(in: .newlines)) + }, + outputSink: { _, _ in }, + userInputHandler: { request in + receivedRequest = request + return .result(json: #"{"decision":"accept"}"#) + } + ) + + session.consumeStdout( + #"{"id":"approval-1","method":"item/commandExecution/requestApproval","params":{"threadId":"thread-1","command":"swift test","availableDecisions":["accept","decline"]}}"# + + "\n" + ) + for _ in 0..<3 { await Task.yield() } + + let request = try #require(receivedRequest) + expectEqual(request.rpcID, "approval-1") + expectEqual(request.method, "item/commandExecution/requestApproval") + let params = try #require( + JSONSerialization.jsonObject(with: Data(request.paramsJSON.utf8)) as? [String: Any] + ) + expectEqual(params["command"] as? String, "swift test") + let response = try #require(sentLines.first.flatMap(jsonLine(_:))) + expectEqual(response["id"] as? String, "approval-1") + expectEqual((response["result"] as? [String: Any])?["decision"] as? String, "accept") + } + + @Test + func testMCPElicitationRequestsPreserveBidirectionalMethodAndResult() async throws { + var sentLines: [String] = [] + let session = CodexAppServerSession( + workingDirectory: nil, + writeData: { data in + sentLines.append(String(decoding: data, as: UTF8.self).trimmingCharacters(in: .newlines)) + }, + outputSink: { _, _ in }, + userInputHandler: { request in + expectEqual(request.method, "mcpServer/elicitation/request") + return .result(json: #"{"action":"accept","content":{"branch":"main"}}"#) + } + ) + + session.consumeStdout( + #"{"id":9,"method":"mcpServer/elicitation/request","params":{"message":"Choose a branch","requestedSchema":{"type":"object","properties":{"branch":{"type":"string"}}}}}"# + + "\n" + ) + for _ in 0..<3 { await Task.yield() } + + let response = try #require(sentLines.first.flatMap(jsonLine(_:))) + expectEqual(response["id"] as? Int, 9) + expectEqual((response["result"] as? [String: Any])?["action"] as? String, "accept") + } + + @Test + func testResolvedServerRequestInvalidatesPendingFeedInputWithoutLateResponse() async throws { + var sentLines: [String] = [] + var resolvedRequestIDs: [String] = [] + let session = CodexAppServerSession( + workingDirectory: nil, + writeData: { data in + sentLines.append(String(decoding: data, as: UTF8.self).trimmingCharacters(in: .newlines)) + }, + outputSink: { _, _ in }, + userInputHandler: { _ in + .result(json: #"{"answers":{}}"#) + }, + userInputResolvedSink: { resolvedRequestIDs.append($0) } + ) + + session.consumeStdout( + #"{"id":"input-stale","method":"item/tool/requestUserInput","params":{"questions":[],"isBlocking":true}}"# + + "\n" + ) + session.consumeStdout( + #"{"method":"serverRequest/resolved","params":{"threadId":"thread-1","requestId":"input-stale"}}"# + + "\n" + ) + for _ in 0..<3 { await Task.yield() } + + expectEqual(resolvedRequestIDs, ["input-stale"]) + expectTrue(sentLines.isEmpty) + } + + @Test + func testSessionCancellationInvalidatesEveryPendingFeedInput() async throws { + var sentLines: [String] = [] + var resolvedRequestIDs: [String] = [] + var handlerStarted = false + let session = CodexAppServerSession( + workingDirectory: nil, + writeData: { data in + sentLines.append(String(decoding: data, as: UTF8.self).trimmingCharacters(in: .newlines)) + }, + outputSink: { _, _ in }, + userInputHandler: { _ in + handlerStarted = true + do { + try await Task.sleep(for: .seconds(60)) + } catch {} + return .result(json: #"{"answers":{}}"#) + }, + userInputResolvedSink: { resolvedRequestIDs.append($0) } + ) + + session.consumeStdout( + #"{"id":"input-cancel","method":"item/tool/requestUserInput","params":{"questions":[],"isBlocking":true}}"# + + "\n" + ) + for _ in 0..<10 where !handlerStarted { await Task.yield() } + expectTrue(handlerStarted) + + session.cancelPendingUserInputRequests() + for _ in 0..<3 { await Task.yield() } + + expectEqual(resolvedRequestIDs, ["input-cancel"]) + expectTrue(sentLines.isEmpty) + } + @Test func testMapsAgentMessageDeltaToStdout() { var output: [(String, String)] = [] diff --git a/cmuxTests/FeedCoordinatorTests.swift b/cmuxTests/FeedCoordinatorTests.swift index ec681b17a82..5ab803a6ec7 100644 --- a/cmuxTests/FeedCoordinatorTests.swift +++ b/cmuxTests/FeedCoordinatorTests.swift @@ -86,6 +86,20 @@ struct FeedCoordinatorTests { #expect(FeedPermissionActionPolicy.supportsAlwaysPermissionMode(source: .codex, toolInputJSON: codexSession)) #expect(CodexTeamsApprovalBridge.feedSourceSupportsAlwaysPermissionMode("codex", toolInputJSON: codexSession)) + let codexMCPApproval = #""" + {"app_server_method":"mcpServer/elicitation/request","available_decisions":["accept","decline"],"metadata":{"codex_approval_kind":"mcp_tool_call","persist":["session","always"]}} + """# + #expect(FeedPermissionActionPolicy.supportsOncePermissionMode(source: .codex, toolInputJSON: codexMCPApproval)) + #expect(FeedPermissionActionPolicy.supportsAlwaysPermissionMode(source: .codex, toolInputJSON: codexMCPApproval)) + #expect(FeedPermissionActionPolicy.supportsPersistentPermissionMode(source: .codex, toolInputJSON: codexMCPApproval)) + #expect(!FeedPermissionActionPolicy.supportsAllPermissionMode(source: .codex, toolInputJSON: codexMCPApproval)) + + let malformedMCPPersistence = #""" + {"app_server_method":"mcpServer/elicitation/request","available_decisions":["accept","decline"],"metadata":{"codex_approval_kind":"mcp_tool_call","persist":["session",7]}} + """# + #expect(!FeedPermissionActionPolicy.supportsAlwaysPermissionMode(source: .codex, toolInputJSON: malformedMCPPersistence)) + #expect(!FeedPermissionActionPolicy.supportsPersistentPermissionMode(source: .codex, toolInputJSON: malformedMCPPersistence)) + let truncatedCodexToolInput = #"{"app_server_method":"item/commandExecution/requestApproval","available_decisions":["accept"]"# #expect(!FeedPermissionActionPolicy.supportsOncePermissionMode(source: .codex, toolInputJSON: truncatedCodexToolInput)) #expect(!FeedPermissionActionPolicy.supportsAlwaysPermissionMode(source: .codex, toolInputJSON: truncatedCodexToolInput)) @@ -155,6 +169,61 @@ struct FeedCoordinatorTests { #expect(!FeedPermissionActionPolicy.supportsAllPermissionMode(source: .codex, toolInputJSON: capabilityToolInput)) } + @Test func mobileFeedEncodingPreservesFutureSourceAndRedactsPermissionValues() throws { + let item = WorkstreamItem( + workstreamId: "future-session", + source: .claude, + sourceRawValue: "future-agent", + kind: .permissionRequest, + payload: .permissionRequest( + requestId: "request-future", + toolName: "Deploy", + toolInputJSON: #"{"token":"secret","region":"us-west-2"}"#, + pattern: nil + ) + ) + + let dict = FeedSocketEncoding.itemDict(item) + + #expect(dict["source"] as? String == "future-agent") + #expect(dict["tool_input_summary"] as? String == "region: …, token: …") + #expect(!(try #require(dict["tool_input_summary"] as? String)).contains("secret")) + #expect(dict["supported_modes"] as? [String] == ["deny"]) + } + + @Test func mobileFeedEncodingCarriesCompletedTurnAnswer() throws { + let item = WorkstreamItem( + workstreamId: "completed-turn", + source: .codex, + kind: .stop, + payload: .stop(reason: nil), + context: WorkstreamContext( + assistantPreamble: "The implementation is ready for your next instruction." + ) + ) + + let dict = FeedSocketEncoding.itemDict(item) + + #expect( + dict["last_assistant_message"] as? String + == "The implementation is ready for your next instruction." + ) + } + + @Test func mobileFeedEncodingDoesNotRepeatAnswerOnToolRows() throws { + let item = WorkstreamItem( + workstreamId: "tool-row", + source: .codex, + kind: .toolUse, + payload: .toolUse(toolName: "shell", toolInputJSON: "{}"), + context: WorkstreamContext(assistantPreamble: "A previous turn is complete.") + ) + + let dict = FeedSocketEncoding.itemDict(item) + + #expect(dict["last_assistant_message"] == nil) + } + @Test func codexAppServerApprovalBuildsActionableFeedEvent() throws { let event = CodexTeamsApprovalBridge.feedEvent( method: "item/commandExecution/requestApproval", @@ -469,6 +538,45 @@ struct FeedCoordinatorTests { } } + @Test func originatingAgentInvalidationExpiresPendingQuestionImmediately() async { + defer { Self.resetFeedCoordinatorTestHooks() } + let requestID = "codex-session-input-stale" + + await MainActor.run { + FeedCoordinator.shared.install(store: WorkstreamStore(ringCapacity: 10)) + FeedCoordinatorTestHooks.afterBlockingEventIngested = { _, ingestedRequestID in + guard ingestedRequestID == requestID else { return } + FeedCoordinator.shared.invalidateBlockingRequest(requestId: ingestedRequestID) + } + } + + let event = WorkstreamEvent( + sessionId: "codex-session", + hookEventName: .notification, + rawHookEventName: "item/tool/requestUserInput", + source: "codex", + toolInputJSON: #"{"questions":[{"id":"mode","question":"Choose","options":[{"label":"Fast","description":""}]}]}"#, + requestId: requestID + ) + let done = DispatchSemaphore(value: 0) + let resultBox = IngestResultBox() + DispatchQueue.global(qos: .userInitiated).async { + resultBox.value = FeedCoordinator.shared.ingestBlocking(event: event, waitTimeout: 5) + done.signal() + } + + #expect(done.wait(timeout: .now() + 2) == .success) + guard case .timedOut = resultBox.value else { + Issue.record("invalidated request should stop waiting without a decision") + return + } + let status = await MainActor.run { FeedCoordinator.shared.store.items.first?.status } + guard case .expired = status else { + Issue.record("invalidated question should be visibly expired") + return + } + } + @Test func blockingIngestUsesOneEndToEndDeadline() async { await MainActor.run { FeedCoordinator.shared.install(store: WorkstreamStore(ringCapacity: 10)) diff --git a/cmuxTests/FeedEventClassificationTests.swift b/cmuxTests/FeedEventClassificationTests.swift index 0463656538d..810de115fee 100644 --- a/cmuxTests/FeedEventClassificationTests.swift +++ b/cmuxTests/FeedEventClassificationTests.swift @@ -359,4 +359,41 @@ struct FeedEventClassificationTests { #expect(attentionCommand("claude", "PermissionRequest", tool: "Bash") == nil) #expect(attentionCommand("totally-new-agent", "PermissionRequest", tool: "Bash") == nil) } + + /// Every catalog-backed agent accepts the common structured-input names. + /// The event spelling is normalized only after the source has opted into + /// cmux's hook catalog, so an unrelated future executable cannot block on + /// a guessed question protocol. + @Test func registeredAgentsExposeStructuredQuestionPrimitives() { + let sources = [ + "claude", "codex", "opencode", "grok", "pi", "omp", "campfire", "amp", + "cursor", "gemini", "kiro", "antigravity", "rovodev", "hermes-agent", + "copilot", "codebuddy", "factory", "qoder", "kimi", + ] + let events = [ + "AskUserQuestion", "ask_user_confirmation", "item/tool/requestUserInput", + "mcp-elicitation", "userInputRequest", + ] + for source in sources { + for event in events { + let result = classify(source, event) + #expect(result.actionable == true) + #expect(result.name == "AskUserQuestion") + } + } + } + + @Test func sourceAliasesShareTheirRegisteredQuestionContract() { + #expect(classify("Claude-Code", "askUserQuestion").actionable == true) + #expect(classify("cursor-agent", "request_user_input").actionable == true) + #expect(classify("agy", "elicitationRequest").actionable == true) + #expect(classify("rovo", "boolean-question").actionable == true) + } + + @Test func unknownSourceQuestionNamesRemainTelemetryOnly() { + let result = classify("future-agent", "requestUserInput", tool: "shell") + #expect(result.actionable == false) + #expect(result.name == "PreToolUse") + #expect(result.notifiesNativeApprovalPrompt == false) + } } diff --git a/cmuxTests/MobileHostAuthorizationTests.swift b/cmuxTests/MobileHostAuthorizationTests.swift index 55fda407e4a..78be32b8357 100644 --- a/cmuxTests/MobileHostAuthorizationTests.swift +++ b/cmuxTests/MobileHostAuthorizationTests.swift @@ -768,6 +768,36 @@ struct MobileHostAuthorizationTests { let error = MobileHostService.ticketAuthorizationError(ticket: ticket, request: request) #expect(error == nil) } + @Test(arguments: ["workstream.feed.action", "workstream.feed.reply"]) + func testScopedAttachTicketRejectsFeedMutationOutsidePinnedRoute(method: String) throws { + let ticket = try scopedAttachTicket(workspaceID: "workspace", terminalID: "terminal") + let request = MobileHostRPCRequest( + id: "feed-mutation", + method: method, + params: [ + "workspace_id": "other-workspace", + "surface_id": "other-terminal", + ], + auth: MobileHostRPCAuth(attachToken: ticket.authToken, stackAccessToken: nil) + ) + let error = MobileHostService.ticketAuthorizationError(ticket: ticket, request: request) + #expect(error?.code == "forbidden") + } + @Test(arguments: ["workstream.feed.action", "workstream.feed.reply"]) + func testMacScopedAttachTicketAcceptsFeedMutationInAnyWorkspace(method: String) throws { + let ticket = try scopedAttachTicket(workspaceID: "", terminalID: nil) + let request = MobileHostRPCRequest( + id: "feed-mutation", + method: method, + params: [ + "workspace_id": "other-workspace", + "surface_id": "other-terminal", + ], + auth: MobileHostRPCAuth(attachToken: ticket.authToken, stackAccessToken: nil) + ) + let error = MobileHostService.ticketAuthorizationError(ticket: ticket, request: request) + #expect(error == nil) + } @Test func testStackUserIDAuthorizationRequiresSignedInMacUser() throws { #expect(throws: (any Error).self) { try MobileHostAuthorizationPolicy.authorizeStackUserID( diff --git a/cmuxTests/MobileHostWorkspaceTicketAuthorizationTests.swift b/cmuxTests/MobileHostWorkspaceTicketAuthorizationTests.swift index c64630fb85b..40c19a6015d 100644 --- a/cmuxTests/MobileHostWorkspaceTicketAuthorizationTests.swift +++ b/cmuxTests/MobileHostWorkspaceTicketAuthorizationTests.swift @@ -360,6 +360,9 @@ struct MobileHostWorkspaceTicketAuthorizationTests { params: ["topics": ["notification.feed.changed"]], auth: nil ), + MobileHostRPCRequest(id: "agent-feed-list", method: "workstream.feed.list", params: [:], auth: nil), + MobileHostRPCRequest(id: "agent-feed-action", method: "workstream.feed.action", params: [:], auth: nil), + MobileHostRPCRequest(id: "agent-feed-reply", method: "workstream.feed.reply", params: [:], auth: nil), ] for request in requests { diff --git a/ios/cmux-ios.xcodeproj/project.pbxproj b/ios/cmux-ios.xcodeproj/project.pbxproj index 91e4a4333a2..e6fd82f1472 100644 --- a/ios/cmux-ios.xcodeproj/project.pbxproj +++ b/ios/cmux-ios.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 8B8A10042DF5000000A66F90 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 8B8A101B2DF5000000A66F90 /* Localizable.xcstrings */; }; 8B8A10E02DF5000000A66F90 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 8B8A10E12DF5000000A66F90 /* PrivacyInfo.xcprivacy */; }; 8B8A10052DF5000000A66F90 /* cmuxUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B8A101C2DF5000000A66F90 /* cmuxUITests.swift */; }; + AFEE00012DF5000000A66F90 /* AgentFeedUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFEE00022DF5000000A66F90 /* AgentFeedUITests.swift */; }; 8B8A10252DF5000000A66F90 /* TerminalThemeParityUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B8A10262DF5000000A66F90 /* TerminalThemeParityUITests.swift */; }; 8B8A10302DF5000000A66F90 /* PushReadinessUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B8A10312DF5000000A66F90 /* PushReadinessUITests.swift */; }; 8B8A10E22DF5000000A66F90 /* SnapshotHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B8A10E32DF5000000A66F90 /* SnapshotHelper.swift */; }; @@ -57,6 +58,7 @@ 8B8A101B2DF5000000A66F90 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; 8B8A10E12DF5000000A66F90 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 8B8A101C2DF5000000A66F90 /* cmuxUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = cmuxUITests.swift; sourceTree = ""; }; + AFEE00022DF5000000A66F90 /* AgentFeedUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentFeedUITests.swift; sourceTree = ""; }; 8B8A10262DF5000000A66F90 /* TerminalThemeParityUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalThemeParityUITests.swift; sourceTree = ""; }; 8B8A10312DF5000000A66F90 /* PushReadinessUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushReadinessUITests.swift; sourceTree = ""; }; 8B8A10E32DF5000000A66F90 /* SnapshotHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotHelper.swift; sourceTree = ""; }; @@ -106,6 +108,7 @@ isa = PBXGroup; children = ( 8B8A101C2DF5000000A66F90 /* cmuxUITests.swift */, + AFEE00022DF5000000A66F90 /* AgentFeedUITests.swift */, 8B8A10262DF5000000A66F90 /* TerminalThemeParityUITests.swift */, 8B8A10312DF5000000A66F90 /* PushReadinessUITests.swift */, 8B8A10E52DF5000000A66F90 /* SnapshotUITests.swift */, @@ -288,6 +291,7 @@ buildActionMask = 2147483647; files = ( 8B8A10052DF5000000A66F90 /* cmuxUITests.swift in Sources */, + AFEE00012DF5000000A66F90 /* AgentFeedUITests.swift in Sources */, 8B8A10252DF5000000A66F90 /* TerminalThemeParityUITests.swift in Sources */, 8B8A10302DF5000000A66F90 /* PushReadinessUITests.swift in Sources */, 8B8A10E42DF5000000A66F90 /* SnapshotUITests.swift in Sources */, diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index 749c3120f22..689a8466b08 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -7260,6 +7260,40 @@ } } }, + "mobile.settings.cmuxLabs.feedDesign": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Feed Design" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィードのデザイン" + } + } + } + }, + "mobile.settings.cmuxLabs.feedDesign.footer": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Switch between five agent Feed designs. Notifications stay separate." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "5つのエージェントフィードデザインを切り替えます。通知は別に表示されます。" + } + } + } + }, "mobile.settings.developer": { "extractionState": "manual", "localizations": { diff --git a/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift b/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift index 566f5c2e1f7..99a9d95712a 100644 --- a/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift +++ b/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift @@ -363,6 +363,8 @@ public struct CMUXMobileRootScene: View { #if DEBUG if UITestConfig.taskComposerPreviewEnabled { TaskComposerAccessibilityPreviewView() + } else if UITestConfig.agentFeedPreviewEnabled { + AgentFeedPreviewView() } else if UITestConfig.notificationFeedPreviewEnabled { NotificationFeedPreviewView() } else if UITestConfig.workspaceListLayoutPreviewEnabled { diff --git a/ios/cmuxUITests/AgentFeedUITests.swift b/ios/cmuxUITests/AgentFeedUITests.swift new file mode 100644 index 00000000000..edc3dd76838 --- /dev/null +++ b/ios/cmuxUITests/AgentFeedUITests.swift @@ -0,0 +1,379 @@ +import XCTest + +final class AgentFeedUITests: XCTestCase { + @MainActor + func testAgentFeedPermissionResolutionAndExactNavigation() throws { + var app = launchFixture(scenario: "permission") + defer { app.terminate() } + + XCTAssertTrue(app.descendants(matching: .any)["MobileAgentFeed"].waitForExistence(timeout: 8)) + XCTAssertTrue(app.tabBars.buttons["Feed"].isSelected) + + let permissionCard = app.descendants(matching: .any)[ + "MobileAgentFeedCard-macbook-00000000-0000-0000-0000-000000000101" + ] + XCTAssertTrue(permissionCard.waitForExistence(timeout: 3)) + let permissionExpandID = "MobileAgentFeedExpand-macbook-00000000-0000-0000-0000-000000000101" + let permissionExpand = app.buttons[permissionExpandID] + XCTAssertEqual(app.buttons.matching(identifier: permissionExpandID).count, 1) + let denyID = "MobileAgentFeedPermission-deny-macbook-00000000-0000-0000-0000-000000000101" + let deny = app.buttons[denyID] + XCTAssertEqual(app.buttons.matching(identifier: denyID).count, 1) + XCTAssertTrue(deny.waitForExistence(timeout: 3)) + XCTAssertTrue(deny.isHittable) + let session = app.buttons[ + "MobileAgentFeedPermission-always-macbook-00000000-0000-0000-0000-000000000101" + ] + XCTAssertTrue(session.exists) + XCTAssertEqual(session.label, "Allow for Session") + let persistent = app.buttons[ + "MobileAgentFeedPermission-persistent-macbook-00000000-0000-0000-0000-000000000101" + ] + XCTAssertTrue(persistent.exists) + XCTAssertEqual(persistent.label, "Always Allow") + deny.tap() + waitForExistence(permissionCard, expected: false) + app.buttons["All Activity"].tap() + XCTAssertTrue(permissionCard.waitForExistence(timeout: 3)) + let resolvedExpand = app.buttons[permissionExpandID] + XCTAssertEqual(app.buttons.matching(identifier: permissionExpandID).count, 1) + XCTAssertTrue(resolvedExpand.label.contains("Resolved: Deny")) + XCTAssertFalse(app.buttons[denyID].exists) + XCTAssertFalse(app.descendants(matching: .any)["MobileAgentFeedPreviewAgentDestination"].exists) + + app.terminate() + app = launchFixture(scenario: "exact-navigation") + let planOpen = app.buttons[ + "MobileAgentFeedOpenAgent-mac-studio-00000000-0000-0000-0000-000000000102" + ] + XCTAssertTrue(planOpen.waitForExistence(timeout: 3)) + makeHittable(planOpen, in: app) + planOpen.tap() + let destination = app.descendants(matching: .any)["MobileAgentFeedPreviewAgentDestination"] + XCTAssertTrue(destination.waitForExistence(timeout: 3)) + app.navigationBars.buttons.firstMatch.tap() + XCTAssertTrue(app.descendants(matching: .any)["MobileAgentFeed"].waitForExistence(timeout: 3)) + XCTAssertTrue(app.tabBars.buttons["Feed"].isSelected) + } + + @MainActor + func testAgentFeedMultiQuestionRequiresEveryAnswerAndSupportsOther() throws { + let app = launchFixture(scenario: "questions") + defer { app.terminate() } + + let suffix = "mac-3-00000000-0000-0000-0000-000000000103" + let expandID = "MobileAgentFeedExpand-\(suffix)" + let expand = app.buttons[expandID] + XCTAssertEqual(app.buttons.matching(identifier: expandID).count, 1) + XCTAssertTrue(expand.waitForExistence(timeout: 8)) + + let submit = app.buttons["MobileAgentFeedQuestionSubmit-\(suffix)"] + XCTAssertTrue(submit.waitForExistence(timeout: 3)) + waitForEnabled(submit, expected: false) + app.buttons["MobileAgentFeedQuestion-scope-iphone-\(suffix)"].tap() + waitForEnabled(submit, expected: false) + let other = app.textFields["MobileAgentFeedQuestionOther-priority-\(suffix)"] + XCTAssertTrue(other.waitForExistence(timeout: 3)) + other.tap() + other.typeText("Oldest blocked request") + waitForEnabled(submit, expected: true) + submit.tap() + waitForExistence(expand, expected: false) + app.buttons["All Activity"].tap() + XCTAssertTrue(expand.waitForExistence(timeout: 3)) + let resolved = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "label CONTAINS %@", "Resolved"), + object: expand + ) + XCTAssertEqual(XCTWaiter.wait(for: [resolved], timeout: 5), .completed) + XCTAssertFalse(submit.exists) + + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "agent-feed-multi-question-resolved" + attachment.lifetime = .keepAlways + add(attachment) + } + + @MainActor + func testAgentFeedBurstPublishesRealFrameAndVisibilityMetrics() throws { + let app = launchFixture(scenario: "new-activity") + defer { app.terminate() } + + let metrics = app.descendants(matching: .any)["AgentFeedPerformanceMetrics"] + XCTAssertTrue(metrics.waitForExistence(timeout: 8)) + let inject = app.buttons["AgentFeedFixtureInjectNewActivity"] + XCTAssertTrue(inject.exists) + XCTAssertTrue(inject.isHittable) + inject.tap() + let complete = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "value CONTAINS %@", "state=complete"), + object: metrics + ) + XCTAssertEqual(XCTWaiter.wait(for: [complete], timeout: 20), .completed) + let value = try XCTUnwrap(metrics.value as? String) + print("AgentFeedPerformanceMetrics: \(value)") + let fields: [String: String] = metricFields(value) + let frames: Int = try XCTUnwrap(fields["frames"].flatMap { Int($0) }, value) + let frameP95: Double = try XCTUnwrap(fields["frame_p95_ms"].flatMap { Double($0) }, value) + let frameStalls: Int = try XCTUnwrap(fields["frame_ge250"].flatMap { Int($0) }, value) + let visibility: Int = try XCTUnwrap(fields["visibility"].flatMap { Int($0) }, value) + let visibilityP95: Double = try XCTUnwrap(fields["visibility_p95_ms"].flatMap { Double($0) }, value) + let visibilityStalls: Int = try XCTUnwrap(fields["visibility_ge250"].flatMap { Int($0) }, value) + + XCTAssertGreaterThanOrEqual(frames, 60, value) + XCTAssertEqual(visibility, 1, value) + XCTAssertLessThanOrEqual(frameP95, 33, value) + XCTAssertLessThanOrEqual(visibilityP95, 250, value) + XCTAssertEqual(frameStalls, 0, value) + XCTAssertEqual(visibilityStalls, 0, value) + } + + @MainActor + func testAgentFeedBurstPreservesOffTopViewportAndOffersJumpToNewest() throws { + let app = launchFixture(scenario: "new-activity") + defer { app.terminate() } + + let list = app.descendants(matching: .any)["MobileAgentFeedList"] + XCTAssertTrue(list.waitForExistence(timeout: 8)) + list.swipeUp() + list.swipeUp() + list.swipeUp() + let newestID = "MobileAgentFeedExpand-mac-4-00000000-0000-0000-0000-000000000999" + XCTAssertFalse(app.buttons[newestID].exists) + + let inject = app.buttons["AgentFeedFixtureInjectNewActivity"] + XCTAssertTrue(inject.exists) + inject.tap() + let newActivity = app.buttons["MobileAgentFeedNewActivity"] + XCTAssertTrue(newActivity.waitForExistence(timeout: 8)) + XCTAssertFalse(app.buttons[newestID].exists) + + newActivity.tap() + XCTAssertTrue(app.buttons[newestID].waitForExistence(timeout: 5)) + } + + @MainActor + func testAgentFeedDeterministicStressAndOfflineScenarios() throws { + var app = launchFixture(scenario: "stress") + var marker = app.descendants(matching: .any)["AgentFeedScenario-stress"] + XCTAssertTrue(marker.waitForExistence(timeout: 8)) + XCTAssertEqual(marker.value as? String, "2400/300") + for retainedCount in [600, 900, 1_200, 1_500, 1_800, 2_000] { + let loadOlder = app.buttons["AgentFeedFixtureLoadOlder"] + XCTAssertTrue(loadOlder.waitForExistence(timeout: 3)) + XCTAssertTrue(loadOlder.isHittable) + loadOlder.tap() + let pageLoaded = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "value == %@", "2400/\(retainedCount)"), + object: marker + ) + XCTAssertEqual(XCTWaiter.wait(for: [pageLoaded], timeout: 5), .completed) + } + XCTAssertFalse(app.buttons["AgentFeedFixtureLoadOlder"].exists) + app.terminate() + + app = launchFixture(scenario: "offline") + marker = app.descendants(matching: .any)["AgentFeedScenario-offline"] + XCTAssertTrue(marker.waitForExistence(timeout: 8)) + XCTAssertTrue(app.descendants(matching: .any)["MobileAgentFeedStatusOffline"].exists) + let offlineLoadOlder = app.buttons["MobileAgentFeedLoadOlder"] + XCTAssertTrue(offlineLoadOlder.exists) + XCTAssertFalse(offlineLoadOlder.isEnabled) + let expand = app.buttons["MobileAgentFeedExpand-macbook-00000000-0000-0000-0000-000000000107"] + makeHittable(expand, in: app) + let action = app.buttons["MobileAgentFeedPermission-once-macbook-00000000-0000-0000-0000-000000000107"] + XCTAssertTrue(action.waitForExistence(timeout: 3)) + XCTAssertFalse(action.isEnabled) + app.terminate() + + app = launchFixture(scenario: "capability-gap") + XCTAssertTrue(app.descendants(matching: .any)["MobileAgentFeedStatusUpdateMac"].waitForExistence(timeout: 8)) + XCTAssertFalse(app.buttons["MobileAgentFeedLoadOlder"].exists) + app.terminate() + } + + @MainActor + func testAgentFeedJapaneseLocalizationAndAccessibilityLayout() throws { + var app = launchFixture(scenario: "japanese", language: "ja", locale: "ja_JP") + XCTAssertTrue(app.navigationBars["フィード"].waitForExistence(timeout: 8)) + XCTAssertTrue(app.tabBars.buttons["フィード"].isSelected) + XCTAssertTrue(app.staticTexts["入力が必要"].exists) + XCTAssertTrue(app.staticTexts["Codexが権限をリクエストしています"].exists) + XCTAssertTrue(app.buttons["エージェントを開く"].exists) + let japaneseExpand = app.buttons[ + "MobileAgentFeedExpand-macbook-00000000-0000-0000-0000-000000000111" + ] + XCTAssertTrue(japaneseExpand.label.contains("ワークスペースID: workspace-1")) + XCTAssertTrue(japaneseExpand.label.contains("サーフェスID: surface-111")) + XCTAssertFalse(app.staticTexts.matching(NSPredicate(format: "label CONTAINS 'japanese' OR label CONTAINS 'host='")).firstMatch.exists) + app.terminate() + + app = launchFixture(scenario: "accessibility") + let source = app.staticTexts["Codex"].firstMatch + let status = app.staticTexts["Needs input"].firstMatch + XCTAssertTrue(source.waitForExistence(timeout: 8)) + XCTAssertTrue(status.exists) + XCTAssertEqual(source.label, "Codex") + XCTAssertEqual(status.label, "Needs input") + let filter = app.descendants(matching: .any)["MobileAgentFeedFilter"] + XCTAssertTrue(filter.waitForExistence(timeout: 3)) + XCTAssertTrue(filter.isHittable) + let suffix = "macbook-00000000-0000-0000-0000-000000000101" + let expandID = "MobileAgentFeedExpand-\(suffix)" + let expand = app.buttons[expandID] + XCTAssertEqual(app.buttons.matching(identifier: expandID).count, 1) + XCTAssertTrue(expand.exists) + XCTAssertTrue(expand.isHittable) + XCTAssertTrue(expand.label.contains("Workspace ID: workspace-1")) + XCTAssertTrue(expand.label.contains("Surface ID: surface-101")) + let denyID = "MobileAgentFeedPermission-deny-\(suffix)" + let deny = app.buttons[denyID] + XCTAssertEqual(app.buttons.matching(identifier: denyID).count, 1) + XCTAssertTrue(deny.waitForExistence(timeout: 3)) + makeHittable(deny, in: app) + deny.tap() + let resolvedExpand = app.buttons[expandID] + XCTAssertEqual(app.buttons.matching(identifier: expandID).count, 1) + XCTAssertTrue(resolvedExpand.label.contains("Resolved: Deny")) + XCTAssertFalse(app.descendants(matching: .any)["MobileAgentFeedPreviewAgentDestination"].exists) + app.terminate() + + app = launchFixture(scenario: "malformed") + let unavailable = app.buttons[ + "MobileAgentFeedExpand-macbook-00000000-0000-0000-0000-000000000109" + ] + XCTAssertTrue(unavailable.waitForExistence(timeout: 8)) + XCTAssertTrue(unavailable.label.contains("Workspace ID: Unavailable")) + XCTAssertTrue(unavailable.label.contains("Surface ID: Unavailable")) + XCTAssertTrue(app.staticTexts["Agent location unavailable"].exists) + app.terminate() + } + + @MainActor + func testAgentFeedFiveDesignsKeepNotificationsSeparateAndActionsInline() throws { + for design in ["timeline", "cards", "compact", "conversation", "commandCenter"] { + let app = launchFixture(scenario: "permission", design: design) + XCTAssertTrue( + app.descendants(matching: .any)["MobileAgentFeedDesign-\(design)"] + .waitForExistence(timeout: 8), + design + ) + XCTAssertTrue(app.tabBars.buttons["Feed"].isSelected, design) + XCTAssertTrue(app.tabBars.buttons["Notifications"].exists, design) + let inlineAction = app.buttons[ + "MobileAgentFeedPermission-deny-macbook-00000000-0000-0000-0000-000000000101" + ] + XCTAssertTrue(inlineAction.waitForExistence(timeout: 3), design) + makeHittable(inlineAction, in: app) + + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "agent-feed-design-\(design)" + attachment.lifetime = .keepAlways + add(attachment) + app.terminate() + } + } + + @MainActor + func testAgentFeedPlanAndCompletedTurnCanBeAnsweredInline() throws { + var app = launchFixture(scenario: "plan") + let planSuffix = "mac-studio-00000000-0000-0000-0000-000000000102" + let feedback = app.textFields["MobileAgentFeedPlanFeedback-\(planSuffix)"] + XCTAssertTrue(feedback.waitForExistence(timeout: 8)) + makeHittable(feedback, in: app) + feedback.tap() + feedback.typeText("Cover the reconnect case") + let manual = app.buttons["MobileAgentFeedPlan-manual-\(planSuffix)"] + makeHittable(manual, in: app) + manual.tap() + let planExpand = app.buttons["MobileAgentFeedExpand-\(planSuffix)"] + waitForExistence(planExpand, expected: false) + app.buttons["All Activity"].tap() + XCTAssertTrue(planExpand.waitForExistence(timeout: 3)) + XCTAssertTrue(planExpand.label.contains("Resolved")) + XCTAssertFalse(feedback.exists) + app.terminate() + + app = launchFixture(scenario: "reply") + let replySuffix = "mac-4-00000000-0000-0000-0000-000000000104" + let composer = app.textFields["MobileAgentFeedReplyComposer-\(replySuffix)"] + XCTAssertTrue(composer.waitForExistence(timeout: 8)) + XCTAssertTrue(app.staticTexts["Needs input"].exists) + composer.tap() + composer.typeText("Please continue with the focused tests") + let send = app.buttons["MobileAgentFeedReplySubmit-\(replySuffix)"] + makeHittable(send, in: app) + send.tap() + XCTAssertTrue( + app.descendants(matching: .any)["MobileAgentFeedSending-\(replySuffix)"] + .waitForExistence(timeout: 3) + ) + app.terminate() + } + + @MainActor + private func launchFixture( + scenario: String = "mixed", + language: String = "en", + locale: String = "en_US", + design: String = "timeline" + ) -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments = [ + "-AppleLanguages", "(\(language))", + "-AppleLocale", locale, + "-cmux.labs.agentFeedDesign", design, + "--agent-feed-scenario", scenario, + ] + app.launchEnvironment["CMUX_UITEST_AGENT_FEED_PREVIEW"] = "1" + app.launchEnvironment["CMUX_UITEST_AGENT_FEED_SCENARIO"] = scenario + app.launch() + return app + } + + @MainActor + private func makeHittable( + _ element: XCUIElement, + in app: XCUIApplication, + attempts: Int = 8 + ) { + for _ in 0.. [String: String] { + var fields: [String: String] = [:] + for component in marker.split(separator: ";") { + let pair = component.split(separator: "=", maxSplits: 1) + guard pair.count == 2 else { continue } + fields[String(pair[0])] = String(pair[1]) + } + return fields + } +}