diff --git a/Packages/iOS/CmuxTabsKit/Sources/CmuxTabsKit/Logic/CloseAllConfirmation.swift b/Packages/iOS/CmuxTabsKit/Sources/CmuxTabsKit/Logic/CloseAllConfirmation.swift new file mode 100644 index 000000000000..eaa09b521a53 --- /dev/null +++ b/Packages/iOS/CmuxTabsKit/Sources/CmuxTabsKit/Logic/CloseAllConfirmation.swift @@ -0,0 +1,11 @@ +/// What Close All Tabs asks before it closes every tab (as Safari's grid does). +struct CloseAllConfirmation: Equatable { + /// The tabs shown when the person asked: exactly these close on confirm. + var ids: [String] + + var count: Int { ids.count } + /// The destructive button. + var confirmTitle: String { count == 1 ? "Close 1 Tab" : "Close All \(count) Tabs" } + /// The line under the question. + var message: String { "This can’t be undone." } +} diff --git a/Packages/iOS/CmuxTabsKit/Sources/CmuxTabsKit/UI/TabsView.swift b/Packages/iOS/CmuxTabsKit/Sources/CmuxTabsKit/UI/TabsView.swift index 03954ed3efbc..cf8d4408fdff 100644 --- a/Packages/iOS/CmuxTabsKit/Sources/CmuxTabsKit/UI/TabsView.swift +++ b/Packages/iOS/CmuxTabsKit/Sources/CmuxTabsKit/UI/TabsView.swift @@ -51,6 +51,8 @@ struct TabsScreen: View { let keyboard: CGFloat @State private var chrome = ChromeState() + /// Close All Tabs is waiting for the person's answer. + @State private var confirmingCloseAll: CloseAllConfirmation? /// The page <-> grid transition (one interruptible progress value). @State private var zoomDriver = TabZoomDriver() /// Swipe-to-close offsets of the grid's cards. @@ -154,7 +156,7 @@ struct TabsScreen: View { onClose: { id in await closeFromGrid(id) }, onNewTab: newTabFromOverview, onDone: { closeOverview(selecting: activeID) }, - onCloseAll: closeAll, + onCloseAll: { confirmingCloseAll = CloseAllConfirmation(ids: tabs.map(\.id)) }, pending: newTabSlot?.showsPlaceholder(in: tabs.map(\.id)) == true ? pendingNewTab : nil, onScrolling: { source.overviewScrollingChanged($0) }, onQueueSelect: { queuedOpen = $0 }, @@ -162,6 +164,16 @@ struct TabsScreen: View { .opacity(overviewVisible ? 1 : 0) .allowsHitTesting(overviewVisible) .accessibilityHidden(!zoomState.gridInteractive) + // Close All Tabs asks first, as Safari does: every tab closes with no way back. + .confirmationDialog(confirmingCloseAll?.confirmTitle ?? "", isPresented: Binding( + get: { confirmingCloseAll != nil }, set: { if !$0 { confirmingCloseAll = nil } } + ), titleVisibility: .hidden, presenting: confirmingCloseAll) { confirmation in + Button(confirmation.confirmTitle, role: .destructive) { closeAll(confirmation.ids) } + .accessibilityIdentifier("tabs.closeAll.confirm") + Button("Cancel", role: .cancel) {} + } message: { confirmation in + Text(confirmation.message) + } if chrome.swiping { swipeNeighbor @@ -615,8 +627,7 @@ struct TabsScreen: View { zoomDriver.openNewTab(spring: newTabZoom.spring) } - private func closeAll() { - let ids = tabs.map(\.id) + private func closeAll(_ ids: [String]) { Task { for id in ids { _ = await source.close(id) } } if let option = source.newTabOptions.first { newTabFromOverview(option) } } diff --git a/Packages/iOS/CmuxTabsKit/Tests/CmuxTabsKitTests/CloseAllConfirmationTests.swift b/Packages/iOS/CmuxTabsKit/Tests/CmuxTabsKitTests/CloseAllConfirmationTests.swift new file mode 100644 index 000000000000..7b9be4aa8013 --- /dev/null +++ b/Packages/iOS/CmuxTabsKit/Tests/CmuxTabsKitTests/CloseAllConfirmationTests.swift @@ -0,0 +1,17 @@ +@testable import CmuxTabsKit +import Testing + +/// Close All Tabs asks first, as Safari does: one tap on the grid's More menu +/// closed every tab of a workspace with no way back (cnx-ios live recheck, 2026-10-20). +struct CloseAllConfirmationTests { + @Test func namesHowManyTabsClose() { + #expect(CloseAllConfirmation(ids: ["a", "b", "c", "d", "e"]).confirmTitle == "Close All 5 Tabs") + #expect(CloseAllConfirmation(ids: ["a"]).confirmTitle == "Close 1 Tab") + #expect(CloseAllConfirmation(ids: ["a", "b", "c", "d", "e"]).message == "This can’t be undone.") + } + + /// The tabs asked about are the ones closed, even if the host lists another meanwhile. + @Test func closesTheTabsItAskedAbout() { + #expect(CloseAllConfirmation(ids: ["a", "b"]).ids == ["a", "b"]) + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/AppServices+Cloud.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/AppServices+Cloud.swift index e2e1b8df7c2e..2683044f94ee 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/AppServices+Cloud.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/AppServices+Cloud.swift @@ -1,6 +1,7 @@ import CmuxNextActions import CmuxNextCompat import CmuxNextDaemon +import CmuxNextHomeEngine import CmuxNextMobileHost import Observation @@ -42,6 +43,11 @@ extension AppServices { newChatPicks: { [weak self] in NewChatPicks(lastAgent: self?.newTabChoices.agent, picks: self?.newTabChoices.picks) }, + // hostd serves phones this build's own Chief (a DEV or tagged build's is not the default home). + chief: { [weak self] in + guard let owner = self?.home.chief else { return nil } + return IPhoneHostService.ChiefPlacement(session: owner.home.session, socket: await owner.socketPath()) + }, settings: { [weak self] in self?.settings }, bundleID: environment.launch.bundleID, computerName: { await MacName.computerName() }) iphone.pairWindow = { [weak self] in self?.windows.active?.window } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextMobile/CnxHost/CnxHostLaunch.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextMobile/CnxHost/CnxHostLaunch.swift index 39c231d166af..395abc17b779 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextMobile/CnxHost/CnxHostLaunch.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextMobile/CnxHost/CnxHostLaunch.swift @@ -29,11 +29,17 @@ public struct CnxHostLaunch: Sendable, Equatable { /// acpmux and shows here as this Mac's own. Nil: phones cannot create agent tabs. public var agentTabHost: String? /// The Chief's conversation owner session (`ChiefHome.session`), served - /// to phones by hostd's scoped `chief` service; nil lets hostd derive it - /// from `~/.cmux/chief/default`, the home of every build that is not an - /// isolated launch. The app's wiring sets it with - /// `ChiefHome.resolve(tag:).session` (cnx-ios/verify/relay-security.md). + /// to phones by hostd's scoped `chief` service. The app always sets it from + /// its own Chief home (`IPhoneHostService.Dependencies.chief`): a DEV or + /// tagged build's Chief lives at `~/.cmux/chief/`, not the + /// `~/.cmux/chief/default` hostd derives when it is nil (only a hostd + /// started by hand relies on that). public var chiefSession: String? + /// The Chief owner's socket once the app is connected to it + /// (`ChiefConversationOwner.socketPath()`): hostd dials it as is + /// (`CNX_CHIEF_SOCKET`), so a socket outside the app daemon's folder + /// (cmux-tui's short-path fallback) is still found. + public var chiefSocket: String? /// This build streams the phone's browser tabs (a bundled remote browser host): /// hostd serves `browser` and says `browser-stream-v1` (`CNX_BROWSER_HOSTS=1`). /// Off, phones hide New Browser Tab for this Mac. @@ -87,7 +93,8 @@ public struct CnxHostLaunch: Sendable, Equatable { "NO_COLOR": "1", ]) { $1 } if let agentTabHost, !agentTabHost.isEmpty { environment["CNX_AGENT_TAB_HOST"] = agentTabHost } - if let chiefSession { environment["CNX_CHIEF_SESSION"] = chiefSession } + if let chiefSession, !chiefSession.isEmpty { environment["CNX_CHIEF_SESSION"] = chiefSession } + if let chiefSocket, !chiefSocket.isEmpty { environment["CNX_CHIEF_SOCKET"] = chiefSocket } if browserHosts { environment["CNX_BROWSER_HOSTS"] = "1" } return environment } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextMobileHost/IPhoneHostService+Wiring.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextMobileHost/IPhoneHostService+Wiring.swift index fbeb801edcca..ad95b59798ff 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextMobileHost/IPhoneHostService+Wiring.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextMobileHost/IPhoneHostService+Wiring.swift @@ -30,12 +30,13 @@ extension IPhoneHostService { /// - computerName: How phones list this Mac. /// - processEnvironment: Read for the Debug-only overrides. /// - acpmuxEnvironment: The app's acpmux, for the phones' approval keys (nil: none). - /// - agentTabHost: This Mac's agent tab host (`install:`), for agent tabs a phone creates. + /// - chief: This app's Chief owner (session, and socket once connected), which hostd serves to phones. public func wire(auth: CloudAuth, daemon: DaemonService, acpmuxHost: @escaping @MainActor @Sendable () -> (any AgentPaneHostProviding)?, acpmuxEnvironment: AcpmuxEnvironment? = nil, agentTabHost: @escaping @MainActor () -> String? = { nil }, newChatPicks: @escaping @MainActor @Sendable () -> NewChatPicks = { NewChatPicks() }, + chief: @escaping @MainActor () async -> ChiefPlacement? = { nil }, settings: @escaping @MainActor @Sendable () -> SettingsController?, bundleID: String?, computerName: @escaping @Sendable () async -> String, processEnvironment: [String: String] = ProcessInfo.processInfo.environment) { @@ -106,7 +107,8 @@ extension IPhoneHostService { computerName: computerName, acpmuxEnvironment: acpmuxEnvironment, browserHosts: browserHosts, - pairingSeed: IPhonePairingSeed.fromEnvironment(processEnvironment) + pairingSeed: IPhonePairingSeed.fromEnvironment(processEnvironment), + chief: chief ) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextMobileHost/IPhoneHostService.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextMobileHost/IPhoneHostService.swift index f51e1f78f511..22ae07d4b210 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextMobileHost/IPhoneHostService.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextMobileHost/IPhoneHostService.swift @@ -64,6 +64,37 @@ public final class IPhoneHostService { var browserHosts: (any CnxBrowserHosting)? = nil /// Test-only (DEBUG): phones this tagged test instance trusts without the prompt. var pairingSeed: IPhonePairingSeed? = nil + /// This app's own Chief owner: its session (from the app's `ChiefHome`, always known) + /// and its socket once connected. hostd's `chief` service dials it; without it hostd + /// guesses `~/.cmux/chief/default`, which is not a DEV or tagged build's Chief. + var chief: @MainActor () async -> ChiefPlacement? = { nil } + } + + /// Where this app's Chief owner listens (``Dependencies/chief``). + public struct ChiefPlacement: Equatable, Sendable { + /// `ChiefHome.session` of the app's Chief home. + public var session: String + /// The owner's socket, once the app is connected to it. + public var socket: String? + + public init(session: String, socket: String?) { + self.session = session + self.socket = socket + } + } + + /// hostd's launch for `account`: the app's daemon, chat roots, agent tab host and its own Chief. + static func launch(_ dependencies: Dependencies, executable: URL, account: Account, deviceID: String, + deviceName: String, daemonSocket: String, chief: ChiefPlacement?) -> CnxHostLaunch { + var launch = CnxHostLaunch( + executable: executable, baseURL: dependencies.baseURL, teamID: account.teamID, userID: account.userID, + deviceID: deviceID, deviceName: deviceName, daemonSocket: daemonSocket, + stateDirectory: dependencies.stateDirectory, fileRoots: dependencies.fileRoots(), + stackProjectID: dependencies.stackProjectID, agentTabHost: dependencies.agentTabHost()) + launch.browserHosts = dependencies.browserHosts != nil + launch.chiefSession = chief?.session + launch.chiefSocket = chief?.socket + return launch } /// `mobile.allowIPhoneConnections`, as the settings file has it. @@ -351,13 +382,10 @@ public final class IPhoneHostService { // task-owner: one Keychain read per hostd start; awaited here let identity = try await Task.detached { try store.loadOrCreate() }.value let name = await dependencies.computerName() + let chief = await dependencies.chief() guard !Task.isCancelled, self?.running == key else { return } - var launch = CnxHostLaunch( - executable: executable, baseURL: dependencies.baseURL, teamID: want.teamID, userID: want.userID, - deviceID: deviceID, deviceName: name, daemonSocket: socket, - stateDirectory: dependencies.stateDirectory, fileRoots: dependencies.fileRoots(), - stackProjectID: dependencies.stackProjectID, agentTabHost: dependencies.agentTabHost()) - launch.browserHosts = dependencies.browserHosts != nil + let launch = Self.launch(dependencies, executable: executable, account: want, deviceID: deviceID, + deviceName: name, daemonSocket: socket, chief: chief) await supervisor.start(.init( launch: launch, accessToken: dependencies.accessToken, acpmux: dependencies.acpmux, log: dependencies.stateDirectory.appendingPathComponent("cnx-hostd.log", isDirectory: false), diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextMobileHostTests/IPhoneHostChiefTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextMobileHostTests/IPhoneHostChiefTests.swift new file mode 100644 index 000000000000..afa26b676cb4 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextMobileHostTests/IPhoneHostChiefTests.swift @@ -0,0 +1,44 @@ +import Foundation +@testable import CmuxNextMobileHost +import Testing + +/// hostd serves phones the app's own Chief (cnx-ios full verify D2): a DEV or tagged build keeps its +/// Chief at `~/.cmux/chief/`, so the launch must name that owner instead of letting hostd +/// guess `~/.cmux/chief/default`. +@MainActor +struct IPhoneHostChiefTests { + static func dependencies(chief: IPhoneHostService.ChiefPlacement?) -> IPhoneHostService.Dependencies { + IPhoneHostService.Dependencies( + executable: URL(fileURLWithPath: "/bin/cnx-hostd"), baseURL: URL(fileURLWithPath: "/dev/null"), + stackProjectID: "p", stateDirectory: URL(fileURLWithPath: "/tmp/cnx-state"), + accessToken: { _ in "t" }, daemonSocket: { "/tmp/cmux-tui-501/cmux-app-tag.sock" }, acpmux: { nil }, + fileRoots: { ["/a"] }, computerName: { "Mac" }, chief: { chief }) + } + + static let account = IPhoneHostService.Account(userID: "u", teamID: "personal-u", teamName: nil, email: nil) + + @Test func theLaunchNamesTheAppsOwnChief() async throws { + let placement = IPhoneHostService.ChiefPlacement(session: "cmux-chief-1a2b3c4d", + socket: "/tmp/cmux-tui-501/cmux-chief-1a2b3c4d.sock") + let dependencies = Self.dependencies(chief: placement) + let launch = IPhoneHostService.launch(dependencies, executable: URL(fileURLWithPath: "/bin/cnx-hostd"), + account: Self.account, deviceID: "d", deviceName: "Mac", + daemonSocket: "/tmp/cmux-tui-501/cmux-app-tag.sock", + chief: await dependencies.chief()) + let environment = launch.environment(inherited: [:]) + #expect(environment["CNX_CHIEF_SESSION"] == "cmux-chief-1a2b3c4d") + #expect(environment["CNX_CHIEF_SOCKET"] == "/tmp/cmux-tui-501/cmux-chief-1a2b3c4d.sock") + #expect(environment["CNX_TEAM_ID"] == "personal-u") + } + + @Test func aChiefNotConnectedYetStillNamesItsSession() async throws { + let dependencies = Self.dependencies(chief: .init(session: "cmux-chief-1a2b3c4d", socket: nil)) + let launch = IPhoneHostService.launch(dependencies, executable: URL(fileURLWithPath: "/bin/cnx-hostd"), + account: Self.account, deviceID: "d", deviceName: "Mac", + daemonSocket: "/tmp/cmux-tui-501/cmux-app-tag.sock", + chief: await dependencies.chief()) + let environment = launch.environment(inherited: [:]) + #expect(environment["CNX_CHIEF_SESSION"] == "cmux-chief-1a2b3c4d") + #expect(environment["CNX_CHIEF_SOCKET"] == nil) + } +} diff --git a/cmux-tui/crates/acpmux/src/hub/lifecycle.rs b/cmux-tui/crates/acpmux/src/hub/lifecycle.rs index 9c2a8f25a7d4..7862682aeb66 100644 --- a/cmux-tui/crates/acpmux/src/hub/lifecycle.rs +++ b/cmux-tui/crates/acpmux/src/hub/lifecycle.rs @@ -61,6 +61,9 @@ impl Hub { } else { (None, None) }; + // A catalog default (not a saved pick) is skipped when the harness doesn't offer it. + let model_from_catalog = !model_pinned + && start_model.as_ref().is_some_and(|s| s.source == super::start_defaults::StartSource::Catalog); let (model, model_soft) = match (model_pinned, start_model) { (true, _) | (false, None) => (pinned_model, false), (false, Some(start)) => (Some(start.value), true), @@ -214,7 +217,12 @@ impl Hub { let catalog = self.catalog_ids(agent).await; let full = format!("{head}/{m}"); let m = if !catalog.is_empty() && !catalog.iter().any(|id| id == m) && catalog.contains(&full) { full } else { m.clone() }; - if let Err(e) = self.set_model(&session, &m).await { + // A start value this session's harness does not list (its account + // lacks it): the harness accepts set_model, then fails every prompt. + if model_from_catalog && !self.harness_offers(agent, &profile, &m) { + tracing::warn!(harness = agent, model = %m, "start model skipped: not offered by the harness"); + self.append(&session, "mux", "start_default_skipped", json!({"modelId": m, "error": "the harness does not offer this model"})); + } else if let Err(e) = self.set_model(&session, &m).await { // A start value the harness refuses (start_defaults.rs): the // chat runs the harness's own default instead. if model_soft { diff --git a/cmux-tui/crates/acpmux/src/hub/start_defaults.rs b/cmux-tui/crates/acpmux/src/hub/start_defaults.rs index bba321bcebf2..9745edf979ef 100644 --- a/cmux-tui/crates/acpmux/src/hub/start_defaults.rs +++ b/cmux-tui/crates/acpmux/src/hub/start_defaults.rs @@ -223,6 +223,10 @@ impl Hub { model: Option<&str>, ) -> (Option, Option) { let curated = super::models_view::curated_for(&self.catalog, agent, profile); + // The catalog default only when the harness offers it: an account that + // lacks it (Codex refusing GPT-6.1 Sol at the first prompt) starts on the + // harness's own default, its config's model, instead (cnx-ios full verify D4). + let reported = self.reported_model_ids(agent); let start_model = match model { Some(_) => None, None => saved @@ -233,6 +237,10 @@ impl Hub { curated .as_ref() .and_then(|c| c.default_model.clone()) + .filter(|m| { + let ids: Vec = c_ids(curated.as_ref()); + offers(&reported, &ids, m) + }) .map(|value| StartValue { value, source: StartSource::Catalog }) }), }; @@ -257,6 +265,23 @@ impl Hub { (start_model, start_effort) } + /// The model ids `agent` itself reported (its last session or probe); + /// empty when it reported none (Claude Code, a harness not probed yet). + pub(super) fn reported_model_ids(&self, agent: &str) -> Vec { + self.known_models + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(agent) + .map(|list| list.iter().map(|(id, _)| id.clone()).collect()) + .unwrap_or_default() + } + + /// Whether the catalog start model `model` is one `agent`'s harness offers ([`offers`]). + pub(super) fn harness_offers(&self, agent: &str, profile: &HarnessProfile, model: &str) -> bool { + let curated = super::models_view::curated_for(&self.catalog, agent, profile); + offers(&self.reported_model_ids(agent), &c_ids(curated.as_ref()), model) + } + /// `start` for one `_acpmux/harnesses` row: `{model?, effort?, /// modelSource?, effortSource?}`, what a new chat there starts on. pub(super) fn start_view( @@ -294,3 +319,29 @@ impl Hub { Some(v) } } + +/// The curated model ids of a catalog entry. +fn c_ids(curated: Option<&CatalogHarness>) -> Vec { + curated.map(|c| c.models.iter().map(|m| m.id.clone()).collect()).unwrap_or_default() +} + +/// Whether reported id `id` names `model`: the same id, or a DSH route +/// (`["provider","model"]`) for `model` or `provider/model`, as +/// `model_catalog::resolve` accepts them. +fn names(id: &str, model: &str) -> bool { + id == model + || serde_json::from_str::>(id) + .is_ok_and(|p| p.len() == 2 && (p[1] == model || format!("{}/{}", p[0], p[1]) == model)) +} + +/// Whether a harness that reported `reported` offers the catalog's `model`. +/// Any model when it reported none, or when its ids are not the catalog's +/// (Claude Code's aliases `default`/`opus`/`sonnet`): then the harness decides, +/// as before. Otherwise only a model it lists, so an account that lacks the +/// catalog default (Codex without GPT-6.1 Sol) starts on its own default. +pub(super) fn offers(reported: &[String], curated: &[String], model: &str) -> bool { + if reported.is_empty() || !reported.iter().any(|r| curated.iter().any(|c| names(r, c))) { + return true; + } + reported.iter().any(|r| names(r, model)) +} diff --git a/cmux-tui/crates/acpmux/tests/fake_agent.py b/cmux-tui/crates/acpmux/tests/fake_agent.py index bd01f97fb283..cfa7fa14577b 100644 --- a/cmux-tui/crates/acpmux/tests/fake_agent.py +++ b/cmux-tui/crates/acpmux/tests/fake_agent.py @@ -606,7 +606,12 @@ def main(): "options": [{"value": v, "name": v} for v in ("low", "medium", "high")]} if m == "session/new": sessions += 1 - send({"jsonrpc": "2.0", "id": rid, "result": {"sessionId": f"{SESSION_PREFIX}{sessions}", "configOptions": [effort]}}) + result = {"sessionId": f"{SESSION_PREFIX}{sessions}", "configOptions": [effort]} + # FAKE_MODELS=: the models this account offers (ACP `models`), as Codex reports them. + if os.environ.get("FAKE_MODELS"): + ids = os.environ["FAKE_MODELS"].split(",") + result["models"] = {"currentModelId": ids[0], "availableModels": [{"modelId": i, "name": i} for i in ids]} + send({"jsonrpc": "2.0", "id": rid, "result": result}) elif m == "session/set_model" and params.get("modelId") == os.environ.get("FAKE_REFUSE_ID"): send({"jsonrpc": "2.0", "id": rid, "error": {"code": -32602, "message": "model not available"}}) elif m == "session/set_model": diff --git a/cmux-tui/crates/acpmux/tests/start_defaults.rs b/cmux-tui/crates/acpmux/tests/start_defaults.rs index b0a9f184850e..c8fca3318e5a 100644 --- a/cmux-tui/crates/acpmux/tests/start_defaults.rs +++ b/cmux-tui/crates/acpmux/tests/start_defaults.rs @@ -213,3 +213,32 @@ async fn a_model_set_from_the_cli_is_no_saved_pick() { assert_eq!(saved(&dir, "codex"), Value::Null); let _ = std::fs::remove_dir_all(&dir); } + +/// cnx-ios full verify D4 (2026-10-19): an account whose Codex does not offer +/// the catalog default (GPT-6.1 Sol) failed every prompt of a new chat. The +/// catalog default applies only when the harness lists it; otherwise the chat +/// starts on the harness's own default (its config's model). +#[tokio::test] +async fn a_catalog_default_the_harness_does_not_offer_is_not_used() { + let (dir, calls) = home("not-offered"); + let mut config: Value = serde_json::from_slice(&std::fs::read(dir.join("config.json")).unwrap()).unwrap(); + config["harnesses"]["codex"]["env"]["FAKE_MODELS"] = json!("gpt-5.5,gpt-6-astra"); + // Claude Code lists aliases, not the catalog's ids: the catalog default still applies. + config["harnesses"]["claude"]["env"]["FAKE_MODELS"] = json!("default,opus"); + std::fs::write(dir.join("config.json"), serde_json::to_vec_pretty(&config).unwrap()).unwrap(); + let daemon = hub(&dir); + let mut app = client(&daemon); + + // The first chat learns the list; it never sets the model the account lacks. + app.new_chat("codex").await; + assert!(!take_calls(&calls).iter().any(|(k, v)| k == "model" && v == "gpt-6.1-sol")); + // Now the list is known: New Agent Chat says no start model, and none is set. + let harnesses = app.call("_acpmux/harnesses", json!({})).await; + assert!(harnesses["harnesses"]["codex"]["start"]["model"].is_null(), "{}", harnesses["harnesses"]["codex"]["start"]); + app.new_chat("codex").await; + assert!(!take_calls(&calls).iter().any(|(k, _)| k == "model")); + // A harness whose ids are not the catalog's (Claude Code's aliases) still starts on the catalog default. + app.new_chat("claude").await; + app.new_chat("claude").await; + assert_eq!(take_calls(&calls).first(), Some(&("model".to_owned(), "claude-opus-5-5".to_owned()))); +} diff --git a/cnx-ios/CnxKit/Sources/CnxAgentChat/AgentChatStore.swift b/cnx-ios/CnxKit/Sources/CnxAgentChat/AgentChatStore.swift index 2a24906257c7..c0120a3c55b4 100644 --- a/cnx-ios/CnxKit/Sources/CnxAgentChat/AgentChatStore.swift +++ b/cnx-ios/CnxKit/Sources/CnxAgentChat/AgentChatStore.swift @@ -79,6 +79,13 @@ public struct AgentTurn: Identifiable, Hashable, Sendable { return nil } + /// The turn's last agent message: what a list's preview shows (the chat's newest + /// words, not the turn's first message, full verify D13). + public var lastAnswerMarkdown: String? { + for case .message(_, let text) in items.reversed() where !text.isEmpty { return text } + return nil + } + /// The final answer text (copy, share). public var answerMarkdown: String { items.compactMap { @@ -186,7 +193,17 @@ public final class AgentChatStore { public var id = UUID() public var text: String public var attachments: [AgentAttachment] - } + /// The prompt's acpmux id: sending the same text again reuses it, so the Mac runs it once. + public var promptID: String? + } + /// A returned draft the Mac turned out to have run (its prompt id showed up in the + /// transcript after the link came back): the composer takes it out again (then clears this). + public var withdrawnDraft: ReturnedDraft? + /// The last returned prompt still waiting for a resend: what it held and its id. + private var resendable: (text: String, attachments: [AgentAttachment], promptID: String)? + /// Prompt ids the transcript showed running or queued (a lost reply for one of them is not + /// a prompt that never went out). + private var seenPromptIDs: Set = [] /// Bumps on every streamed change (follow-mode scroll keys off it). public private(set) var revision = 0 private var currentMessageID: String? @@ -246,7 +263,25 @@ public final class AgentChatStore { let deny = options.first { $0.kind == .rejectOnce } ?? options.first { !$0.isAllow } respond(to: permission.request, option: deny) } - source.send(.prompt(text: text, attachments: attachments, steer: steer)) + // The same prompt sent again after its reply was lost keeps its id: acpmux runs it once. + let resend = resendable.flatMap { pending in + pending.text == text && pending.attachments.map(\.id) == attachments.map(\.id) ? pending.promptID : nil + } + resendable = nil + source.send(.prompt(text: text, attachments: attachments, steer: steer, + promptID: resend ?? UUID().uuidString.lowercased(), resend: resend != nil)) + } + + /// The Mac shows `promptID` ran (or queued): a draft returned for it goes away, and it is + /// never sent again under that id. + private func reached(_ promptID: String) { + seenPromptIDs.insert(promptID) + guard let pending = resendable, pending.promptID == promptID else { return } + resendable = nil + let draft = returnedDraft?.promptID == promptID ? returnedDraft : nil + if draft != nil { returnedDraft = nil } + withdrawnDraft = draft ?? ReturnedDraft(text: pending.text, attachments: pending.attachments, promptID: promptID) + if clientError != nil { clientError = nil } } public func cancel() { source.send(.cancel) } @@ -296,12 +331,14 @@ public final class AgentChatStore { func apply(_ event: AgentSessionEvent) { revision &+= 1 switch event { - case .userMessage(let text, _, let turnId, let attachments, let at): + case .userMessage(let text, let promptId, let turnId, let attachments, let at): currentMessageID = nil + reached(promptId) if !turns.contains(where: { $0.id == turnId }) { turns.append(AgentTurn(id: turnId, prompt: text, attachments: attachments, promptAt: at)) } case .queued(let text, let promptId, _): + reached(promptId) queue.append(QueuedPrompt(id: promptId, text: text)) case .dequeued(let promptId): queue.removeAll { $0.id == promptId } @@ -396,9 +433,12 @@ public final class AgentChatStore { case .clientError(let message): approvingOptionID = nil clientError = message - case .promptNotSent(let text, let attachments, let reason): + case .promptNotSent(let text, let attachments, let promptID, let reason): + // The transcript already showed it run: only its reply was lost. + guard !seenPromptIDs.contains(promptID) else { break } clientError = reason - returnedDraft = ReturnedDraft(text: text, attachments: attachments) + returnedDraft = ReturnedDraft(text: text, attachments: attachments, promptID: promptID) + resendable = (text, attachments, promptID) case .subagent(let change): applySubagent(change) case .batch(let events): diff --git a/cnx-ios/CnxKit/Sources/CnxAgentChat/AgentSessionSource.swift b/cnx-ios/CnxKit/Sources/CnxAgentChat/AgentSessionSource.swift index c6c3444eafc0..f81820dffee9 100644 --- a/cnx-ios/CnxKit/Sources/CnxAgentChat/AgentSessionSource.swift +++ b/cnx-ios/CnxKit/Sources/CnxAgentChat/AgentSessionSource.swift @@ -62,7 +62,8 @@ public enum AgentSessionEvent: Hashable, Sendable { case clientError(String) /// A prompt that never reached the agent (an attachment upload failed, the /// link was down): the composer gets the draft back with the reason. - case promptNotSent(text: String, attachments: [AgentAttachment], reason: String) + /// `promptID` is the prompt's acpmux id: sent again with it, the Mac runs it at most once. + case promptNotSent(text: String, attachments: [AgentAttachment], promptID: String, reason: String) /// `_meta.acpmux.subagents`: a subagent started or changed state. case subagent(AgentSubagentChange) /// An update from a subagent's own session (`_meta.acpmux.subagent`). @@ -76,7 +77,9 @@ public enum AgentSessionEvent: Hashable, Sendable { /// `session/prompt`, `session/cancel`, `_acpmux/permission_respond`, `session/set_model`, /// `session/set_mode`, `session/set_config_option`, `_acpmux/queue_remove`, `_acpmux/queue_edit`. public enum AgentClientIntent: Hashable, Sendable { - case prompt(text: String, attachments: [AgentAttachment], steer: Bool) + /// `promptID`: `_meta.acpmux.promptId`, which acpmux runs once; `resend`: the same prompt + /// sent again after its reply was lost (acpmux also looks for the id in the session log). + case prompt(text: String, attachments: [AgentAttachment], steer: Bool, promptID: String, resend: Bool) case cancel case respondPermission(permissionId: String, optionId: String?) case answerQuestion(questionId: String, answer: AgentQuestionAnswer) diff --git a/cnx-ios/CnxKit/Sources/CnxAgentChat/ChatComposer.swift b/cnx-ios/CnxKit/Sources/CnxAgentChat/ChatComposer.swift index 8f3731156285..ee7ee738e3b4 100644 --- a/cnx-ios/CnxKit/Sources/CnxAgentChat/ChatComposer.swift +++ b/cnx-ios/CnxKit/Sources/CnxAgentChat/ChatComposer.swift @@ -80,6 +80,14 @@ struct ChatComposer: View { if focused { expandRequested = false } } .onAppear { if expandOnAppear { expand() } } + // A returned prompt the Mac turned out to have run: out of the composer again, + // unless the person already changed it. + .onChange(of: store.withdrawnDraft) { _, draft in + guard let draft else { return } + store.withdrawnDraft = nil + if trimmed == draft.text.trimmingCharacters(in: .whitespacesAndNewlines) { text = "" } + attachments.removeAll { own in draft.attachments.contains { $0.id == own.id } } + } // A prompt that never reached the agent comes back to the composer. .onChange(of: store.returnedDraft) { _, draft in guard let draft else { return } @@ -126,6 +134,9 @@ struct ChatComposer: View { attachmentStrip } TextField(placeholder, text: $text, axis: .vertical) + // Prompts are code-like: no autocorrect, no capitals (full verify D5). + .autocorrectionDisabled() + .textInputAutocapitalization(.never) .font(.body) .lineLimit(1...8) .focused(focus) @@ -148,7 +159,9 @@ struct ChatComposer: View { .accessibilityAddTraits(.isButton) .accessibilityLabel(placeholder) } else { - if store.currentModelName != nil || !store.modes.isEmpty { modelPill } + // The menu is there whenever the agent offers models, even when the + // current one is not among them (a default the account lacks, D4). + if store.currentModelName != nil || !store.models.isEmpty || !store.modes.isEmpty { modelPill } Spacer(minLength: 0) if let usage = store.contextUsage { ContextUsageRing(usage: usage) } } @@ -309,7 +322,7 @@ struct ChatComposer: View { private func modelPillLabel(withEffort: Bool) -> some View { HStack(spacing: 4) { - Text(store.currentModelName ?? currentModeName ?? "") + Text(store.currentModelName ?? store.currentModelID ?? currentModeName ?? "Model") if withEffort, store.currentModelName != nil, let mode = currentModeName, store.currentModeID != store.modes.first?.id { Text(mode).foregroundStyle(.secondary) } @@ -523,6 +536,9 @@ struct QueuedPromptsView: View { .alert("Edit queued prompt", isPresented: Binding(get: { editing != nil }, set: { if !$0 { editing = nil } }), presenting: editing) { item in TextField("Message", text: $editText, axis: .vertical) + // Prompts are code-like: no autocorrect, no capitals (full verify D5). + .autocorrectionDisabled() + .textInputAutocapitalization(.never) Button("Cancel", role: .cancel) {} Button("Save") { store.editQueued(item.id, text: editText) } .disabled(editText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) diff --git a/cnx-ios/CnxKit/Sources/CnxAgentChat/MockAgentSession.swift b/cnx-ios/CnxKit/Sources/CnxAgentChat/MockAgentSession.swift index 2faa616e87a4..25a223a4ad04 100644 --- a/cnx-ios/CnxKit/Sources/CnxAgentChat/MockAgentSession.swift +++ b/cnx-ios/CnxKit/Sources/CnxAgentChat/MockAgentSession.swift @@ -61,7 +61,7 @@ public final class MockAgentSession: AgentSessionSource { public func send(_ intent: AgentClientIntent) { switch intent { - case .prompt(let text, let attachments, let steer): + case .prompt(let text, let attachments, let steer, _, _): let promptID = UUID().uuidString if running != nil, !steer { queued.append((promptID, text, attachments)) diff --git a/cnx-ios/CnxKit/Sources/CnxAgentChat/TurnFailureCopy.swift b/cnx-ios/CnxKit/Sources/CnxAgentChat/TurnFailureCopy.swift index e9138e73b92e..77e68a46261d 100644 --- a/cnx-ios/CnxKit/Sources/CnxAgentChat/TurnFailureCopy.swift +++ b/cnx-ios/CnxKit/Sources/CnxAgentChat/TurnFailureCopy.swift @@ -10,7 +10,7 @@ public struct TurnFailureCopy: Hashable, Sendable { public enum Kind: String, Hashable, Sendable { case trustPending, trustUntrusted case modelServiceUnavailable, modelServiceNotConnected, modelServiceExpired, modelServiceUnreachable - case rateLimited, unreachable, invalidKey, subscriptionLogin, proxyAuth, auth, other + case rateLimited, unreachable, invalidKey, subscriptionLogin, proxyAuth, auth, modelUnavailable, other } public let kind: Kind @@ -51,6 +51,7 @@ public struct TurnFailureCopy: Hashable, Sendable { case .proxyAuth: "\(via ?? "The route") refused \(name)’s request (\(status ?? "401")). Check that route’s key or account." case .subscriptionLogin: "\(name) is signed out or its sign-in expired." case .auth: "The provider refused \(name)’s credentials (\(status ?? "401"))." + case .modelUnavailable: "\(name) can't use this model with the Mac's account. Pick another model." case .other: parts.message.map(Self.capitalized) ?? raw } self.kind = kind @@ -58,6 +59,13 @@ public struct TurnFailureCopy: Hashable, Sendable { detail = sentence == raw ? nil : raw } + /// `message` for a toast or a refusal line: a JSON error body becomes its sentence; + /// plain text stays as it is. + public static func plain(_ message: String, harness: String? = nil, harnessName: String? = nil) -> String { + guard message.contains("{"), message.contains("}") else { return message } + return TurnFailureCopy(message: message, harness: harness, harnessName: harnessName).sentence + } + // MARK: - Classification (the Mac's order: trust, capacity and network first) static func kind(_ raw: String, parts: (code: String?, message: String?), harness: String?) -> Kind { @@ -75,7 +83,9 @@ public struct TurnFailureCopy: Hashable, Sendable { if routerRefusal, parts.code == "auth.forbidden" || raw.range(of: #"\b403\b"#, options: .regularExpression) != nil { return .modelServiceUnavailable } + // The account can't run the chat's model (Codex with a ChatGPT account, a retired model). if matches(Self.rate, raw) { return .rateLimited } + if matches(Self.modelUnavailable, parts.message ?? raw) { return .modelUnavailable } if matches(Self.unreachable, raw) { return .unreachable } let via = via(raw, harness: harness) if matches(Self.invalidKey, raw), !(matches(Self.subscription, raw) && via == nil) { return .invalidKey } @@ -98,6 +108,7 @@ public struct TurnFailureCopy: Hashable, Sendable { return (code, message?.isEmpty == false ? message : nil) } + private static let modelUnavailable = #"\bmodel\b.{0,80}\b(?:is not supported|isn't supported|not supported|does not exist|is not available|not available|not found|unknown model|no access)|(?:unknown|unsupported|invalid) model"# private static let rate = #"\b(?:429|529)\b|rate.?limit|too many requests|overloaded|at capacity|no non-exhausted|usage limit|quota|\b503\b|service unavailable"# private static let unreachable = #"ECONNREFUSED|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|ECONNRESET|EHOSTUNREACH|fetch failed|socket hang up|connection (?:refused|reset|closed|error)|could not (?:connect|resolve)|network (?:error|is unreachable)|\bunreachable\b|timed out"# private static let invalidKey = #"invalid[ _-]?(?:x-)?api[ _-]?key|incorrect api key|api key (?:is )?(?:missing|not set|not found|invalid)|no api key|(?:ANTHROPIC|OPENAI)_API_KEY"# diff --git a/cnx-ios/CnxKit/Sources/CnxArtifacts/LinkFileArtifactSource.swift b/cnx-ios/CnxKit/Sources/CnxArtifacts/LinkFileArtifactSource.swift index 07a04d83585c..8ea327b590f0 100644 --- a/cnx-ios/CnxKit/Sources/CnxArtifacts/LinkFileArtifactSource.swift +++ b/cnx-ios/CnxKit/Sources/CnxArtifacts/LinkFileArtifactSource.swift @@ -109,8 +109,9 @@ public struct LinkFileArtifactSource: ArtifactSource { var value: Bool { lock.withLock { fired } } } - /// Where the person shares a folder: the Mac's Chat Folders setting (cnx-hostd's file roots). - static let shareHint = "To open it here, add its project folder in cmux on the Mac: Settings › General › Chats › Chat Folders." + /// Where the person shares a folder: a workspace open on the Mac in that folder, or the Mac's + /// Chat Folders setting (cnx-hostd's file roots and the Mac's workspace folders). + static let shareHint = "To open it here, open a workspace in its project folder on the Mac, or add the folder in cmux on the Mac: Settings › General › Chats › Chat Folders." static func unavailable(_ error: LinkError, path: String, macName: String) -> ArtifactUnavailable { let name = (path as NSString).lastPathComponent diff --git a/cnx-ios/CnxKit/Sources/CnxAuth/CnxAuthModel.swift b/cnx-ios/CnxKit/Sources/CnxAuth/CnxAuthModel.swift index 867a8ab9e209..d9424da333ac 100644 --- a/cnx-ios/CnxKit/Sources/CnxAuth/CnxAuthModel.swift +++ b/cnx-ios/CnxKit/Sources/CnxAuth/CnxAuthModel.swift @@ -322,12 +322,22 @@ public final class CnxAuthModel { try? await coordinator?.selectTeam(id: id) } - /// Creates a team and selects it. + /// Creates a team and selects it. Stack refuses team creation from the + /// client (`client_team_creation_enabled` is off in both projects), so it + /// goes through the cmux service (`POST /v1/teams`), which creates it with + /// the project's server key; then the team list is read again from Stack. public func createTeam(named name: String) async throws -> AppStore.AccountTeam { if isMock { return AppStore.AccountTeam(id: "team-\(UUID().uuidString.prefix(8))", name: name) } guard let coordinator else { throw AuthError.unauthorized } - let team = try await coordinator.createTeam(displayName: name) - return AppStore.AccountTeam(id: team.id, name: team.displayName) + guard let apiBase else { throw ServiceTeamCreation.Failure.unavailable } + let creation = ServiceTeamCreation(apiBase: apiBase) + let tokens = try await coordinator.currentTokens() + let (data, response) = try await URLSession.shared.data(for: creation.request(accessToken: tokens.accessToken, name: name)) + let team = try creation.team(from: data, status: (response as? HTTPURLResponse)?.statusCode ?? 0) + // The new membership, then the selection (best effort, as in Stack's own flow). + await coordinator.refreshTeams() + try? await coordinator.selectTeam(id: team.id) + return team } /// Joins a team from a Stack invitation link (`...?code=...`) and refreshes diff --git a/cnx-ios/CnxKit/Sources/CnxChief/ChiefConversationScreen.swift b/cnx-ios/CnxKit/Sources/CnxChief/ChiefConversationScreen.swift index 6a5eeacbff90..6479fd30bdfd 100644 --- a/cnx-ios/CnxKit/Sources/CnxChief/ChiefConversationScreen.swift +++ b/cnx-ios/CnxKit/Sources/CnxChief/ChiefConversationScreen.swift @@ -43,6 +43,9 @@ public struct ChiefConversationScreen: View { public var body: some View { ChiefConversationController(session: session, compose: compose, focusesComposer: focusesComposer, back: { dismiss() }) + // The conversation draws one store; a new backend (Try Again, a new link) makes a + // new store, so the open screen is rebuilt on it instead of waiting on the old one. + .id(ObjectIdentifier(session.store)) .ignoresSafeArea() .toolbar(.hidden, for: .navigationBar) .toolbar(.hidden, for: .tabBar) @@ -130,10 +133,131 @@ public final class ChiefConversationContainer: UIViewController, UIGestureRecogn // the Mac (chief-profile-changed) redraws it in place. headerAvatar = chiefInitials conversation.store.addObserver { [weak self] _ in - guard let self, self.isViewLoaded, self.chiefInitials != self.headerAvatar else { return } + guard let self, self.isViewLoaded else { return } + self.updateAvailability() + guard self.chiefInitials != self.headerAvatar else { return } self.headerAvatar = self.chiefInitials if self.view.window != nil { self.conversation.setBackUnreadCount(self.backUnreadCount) } } + installSendGate() + observeAvailability() + } + + // MARK: Chief unavailable (full verify D2, D3) + + /// The package's send action, kept while the gate holds the button. + private var packageSendActions: [UIAction] = [] + private weak var sendButton: UIButton? + + /// Before Chief ever connected, the package's send clears the composer + /// and then drops the message (its store has no sender yet). The send + /// button goes through this gate: once Chief takes sends it runs the + /// package's own action; until then the text stays in the composer and + /// the screen says why, with Try Again and Send When Chief Connects. + private func installSendGate() { + guard let button = conversation.view.firstSubview(where: { $0.accessibilityIdentifier == "conversation.composer.send" }) as? UIButton + else { return } + var actions: [UIAction] = [] + button.enumerateEventHandlers { action, _, event, _ in + if event == .touchUpInside, let action { actions.append(action) } + } + guard !actions.isEmpty else { return } + sendButton = button + packageSendActions = actions + for action in actions { button.removeAction(action, for: .touchUpInside) } + button.addAction(UIAction { [weak self] _ in self?.sendTapped() }, for: .touchUpInside) + updateAvailability() + } + + private func sendTapped() { + guard let button = sendButton else { return } + if session.acceptsSendsNow { + for action in packageSendActions { button.sendAction(action) } + return + } + holdUnsentMessage() + } + + private var composerTextView: UITextView? { + conversation.view.firstSubview { $0.accessibilityIdentifier == "conversation.composer.text" } as? UITextView + } + + /// The message stays in the composer; the alert says why and offers to + /// try Chief again or to send it once Chief connects (the outbox). + private func holdUnsentMessage() { + guard presentedViewController == nil else { return } + let text = composerTextView?.text.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let reason = session.unavailableReason ?? String(localized: "Chief isn't connected yet.") + let alert = UIAlertController( + title: String(localized: "Message Not Sent"), + message: String(localized: "\(reason) Your message is still in the box."), + preferredStyle: .alert) + if session.canRetryConnecting { + alert.addAction(UIAlertAction(title: String(localized: "Try Again"), style: .default) { [weak self] _ in + self?.session.retryConnecting() + }) + } + if !text.isEmpty { + alert.addAction(UIAlertAction(title: String(localized: "Send When Chief Connects"), style: .default) { [weak self] _ in + self?.moveComposerToOutbox(text) + }) + } + alert.addAction(UIAlertAction(title: String(localized: "OK"), style: .cancel)) + present(alert, animated: true) + } + + private func moveComposerToOutbox(_ text: String) { + session.send(ConversationComposeMessage(text: text)) + guard let field = composerTextView else { return } + field.text = "" + // The composer follows its field through the delegate (send button, height). + field.delegate?.textViewDidChange?(field) + } + + /// Follows the session for the gate and the spinner. + private func observeAvailability() { + withObservationTracking { + _ = session.barItem + _ = session.isConnected + } onChange: { [weak self] in + Task { @MainActor [weak self] in + self?.updateAvailability() + self?.observeAvailability() + } + } + } + + /// The package's first-load spinner never ends while Chief is unavailable: + /// hidden while the bar says why. The effect long-press on Send would + /// also clear and drop: off until Chief takes sends. + private func updateAvailability() { + let unavailable: Bool + if case .unavailable? = session.barItem { unavailable = true } else { unavailable = false } + let sends = session.acceptsSendsNow + // Found once (the store changes on every keystroke and stream chunk). + if gatedViews.spinner == nil { gatedViews.spinner = packageView("conversation.initialLoading") } + if gatedViews.record == nil { gatedViews.record = packageView("conversation.composer.record") as? UIControl } + if gatedViews.plus == nil { gatedViews.plus = packageView("conversation.composer.plus") as? UIControl } + gatedViews.spinner?.alpha = unavailable ? 0 : 1 + // Voice messages, photos, polls and Send Later clear and drop the same way before + // Chief connects: off until it takes sends (the text field and its gated Send stay). + gatedViews.record?.isEnabled = sends + gatedViews.plus?.isEnabled = sends + for recognizer in sendButton?.gestureRecognizers ?? [] where recognizer is UILongPressGestureRecognizer { + recognizer.isEnabled = sends + } + } + + private struct GatedViews { + weak var spinner: UIView? + weak var record: UIControl? + weak var plus: UIControl? + } + + private var gatedViews = GatedViews() + + private func packageView(_ identifier: String) -> UIView? { + conversation.view.firstSubview { $0.accessibilityIdentifier == identifier } } // MARK: Chief's questions @@ -413,7 +537,15 @@ public final class ChiefConversationContainer: UIViewController, UIGestureRecogn if let pop = navigation.interactivePopGestureRecognizer { recognizers.append(pop) } if #available(iOS 26.0, *), let content = navigation.interactiveContentPopGestureRecognizer { recognizers.append(content) } for recognizer in recognizers { - if recognizer.delegate !== self { previousPopDelegates[ObjectIdentifier(recognizer)] = WeakDelegate(value: recognizer.delegate) } + if recognizer.delegate !== self { + // A Chief screen rebuilt on a new store (Try Again) takes over from the old one: + // keep the navigation's own delegate, never the outgoing Chief screen. + if let outgoing = recognizer.delegate as? ChiefConversationContainer { + previousPopDelegates[ObjectIdentifier(recognizer)] = outgoing.previousPopDelegates[ObjectIdentifier(recognizer)] + } else { + previousPopDelegates[ObjectIdentifier(recognizer)] = WeakDelegate(value: recognizer.delegate) + } + } recognizer.isEnabled = true recognizer.delegate = self } @@ -447,10 +579,19 @@ public final class ChiefConversationContainer: UIViewController, UIGestureRecogn field?.becomeFirstResponder() } + /// The Chief screen on screen now: a rebuilt one may appear before the old one disappears. + private static weak var shown: ChiefConversationContainer? + public override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) enableEdgePop() + Self.shown = self session.isVisible = true + // Text written on the screen this one replaced (Try Again rebuilt it on a new store). + if let carried = session.takeCarriedComposerText(), let field = composerTextView, field.text.isEmpty { + field.text = carried + field.delegate?.textViewDidChange?(field) + } session.markSeen() showOutboxIfWaiting() if focusesComposerOnAppear { @@ -465,12 +606,14 @@ public final class ChiefConversationContainer: UIViewController, UIGestureRecogn public override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) - // A sheet over Chief keeps it on screen. - if presentedViewController == nil { session.isVisible = false } + // A sheet over Chief keeps it on screen; so does the screen that replaced this one. + if presentedViewController == nil, Self.shown === self || Self.shown == nil { session.isVisible = false } } public override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) + // Unsent text goes with the session to a rebuilt screen (it reads it on appear). + session.carryComposerText(composerTextView?.text ?? "") // Hand the recognizers back to their own delegates for the next screen. for recognizer in popRecognizers where recognizer.delegate === self { recognizer.delegate = previousPopDelegates[ObjectIdentifier(recognizer)]?.value diff --git a/cnx-ios/CnxKit/Sources/CnxChief/ChiefFailure.swift b/cnx-ios/CnxKit/Sources/CnxChief/ChiefFailure.swift index 4f99eaff51ef..ff2fd1c0faaf 100644 --- a/cnx-ios/CnxKit/Sources/CnxChief/ChiefFailure.swift +++ b/cnx-ios/CnxKit/Sources/CnxChief/ChiefFailure.swift @@ -257,4 +257,6 @@ public enum ChiefBarItem: Equatable, Sendable { case question(AgentQuestion) /// `id` is the failed message's id. case failure(id: String, ChiefTurnFailure) + /// The Mac's Chief can't be reached: why, and whether Try Again is running. + case unavailable(reason: String, retrying: Bool) } diff --git a/cnx-ios/CnxKit/Sources/CnxChief/ChiefQuestionViews.swift b/cnx-ios/CnxKit/Sources/CnxChief/ChiefQuestionViews.swift index 9269b0ac4ac3..ca5ee8f863ad 100644 --- a/cnx-ios/CnxKit/Sources/CnxChief/ChiefQuestionViews.swift +++ b/cnx-ios/CnxKit/Sources/CnxChief/ChiefQuestionViews.swift @@ -60,6 +60,7 @@ struct ChiefQuestionBar: View { switch shown { case .question(let question)?: question.id + (session.answers[question.id]?.statusText ?? "") case .failure(let id, _)?: id + case .unavailable(let reason, let retrying)?: reason + (retrying ? "\u{0}" : "") case nil: "" } } @@ -67,6 +68,8 @@ struct ChiefQuestionBar: View { @ViewBuilder private var content: some View { if case .failure(_, let failure)? = shown { failureBar(failure) + } else if case .unavailable(let reason, let retrying)? = shown { + unavailableBar(reason: reason, retrying: retrying) } else if case .question(let question)? = shown { let flow = session.answers[question.id] ?? ChiefAnswerFlow() Button { open(question) } label: { @@ -122,6 +125,43 @@ struct ChiefQuestionBar: View { .allowsHitTesting(visible) } + /// The Mac's Chief can't be reached (refused, not running, no Mac): why, + /// in its own words, and Try Again when the link can try. Replaces the + /// conversation's first-load spinner, which never ends without Chief. + private func unavailableBar(reason: String, retrying: Bool) -> some View { + Button { session.retryConnecting() } label: { + HStack(spacing: 10) { + if retrying { + ProgressView().controlSize(.small) + } else { + Image(systemName: "exclamationmark.triangle.fill").font(.body).foregroundStyle(.orange) + } + VStack(alignment: .leading, spacing: 1) { + Text("Chief isn't available").font(.footnote.weight(.semibold)) + Text(reason).font(.footnote).foregroundStyle(.secondary).lineLimit(3) + } + Spacer(minLength: 4) + if session.canRetryConnecting { + Text(retrying ? "Trying…" : "Try Again").font(.footnote.weight(.semibold)) + } + } + .foregroundStyle(.primary) + .padding(.horizontal, 14) + .padding(.vertical, 9) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(.rect) + .background(Color(uiColor: .secondarySystemBackground), in: .rect(cornerRadius: 20, style: .continuous)) + } + .dynamicTypeSize(...DynamicTypeSize.accessibility1) + .buttonStyle(.plain) + .disabled(retrying || !session.canRetryConnecting) + .padding(.horizontal, 16) + .accessibilityIdentifier("chief.unavailable.bar") + .accessibilityHint(session.canRetryConnecting ? "Tries to reach Chief on your Mac again" : "") + .opacity(visible ? 1 : 0) + .allowsHitTesting(visible) + } + @ViewBuilder private func label(_ question: AgentQuestion, flow: ChiefAnswerFlow) -> some View { let icon = Group { if flow.isBusy { diff --git a/cnx-ios/CnxKit/Sources/CnxChief/ChiefSession.swift b/cnx-ios/CnxKit/Sources/CnxChief/ChiefSession.swift index b11ebfda88ad..46bd201d4c18 100644 --- a/cnx-ios/CnxKit/Sources/CnxChief/ChiefSession.swift +++ b/cnx-ios/CnxKit/Sources/CnxChief/ChiefSession.swift @@ -55,7 +55,16 @@ public final class ChiefSession { public private(set) var isConnected = false /// Why Chief is not reachable (no Mac, Chief not started there), set by /// whoever swaps the backend; nil when there is nothing to explain. - public var status: String? + /// Setting it ends a Try Again in flight (the try came back). + public var status: String? { + didSet { isReconnecting = false } + } + /// Tries the Mac's Chief again (set by the link's coordinator); nil + /// where there is nothing to try (no link). + @ObservationIgnored public var reconnect: (@MainActor () -> Void)? + /// A Try Again is in flight: the bar shows progress until ``status`` is + /// set again or Chief connects. + public private(set) var isReconnecting = false /// The backend the store runs on. public private(set) var backend: any ConversationBackend /// Who is in the conversation; the Cloud Chief's ids come with its backend. @@ -166,10 +175,49 @@ public final class ChiefSession { /// else Try Again for a failed newest turn not tried again yet. public var barItem: ChiefBarItem? { if let question = openQuestion { return .question(question) } + // The Mac's Chief refused or is missing (D2): say why, instead of + // the conversation's endless first-load spinner. + if !isConnected, let status { return .unavailable(reason: status, retrying: isReconnecting) } guard let id = lastTurnFailureID, let failure = lastTurnFailure, id != retriedFailureID, isConnected else { return nil } return .failure(id: id, failure) } + /// Try Again on the unavailable bar has something to try. + public var canRetryConnecting: Bool { reconnect != nil } + + /// Try Again on the unavailable bar: asks the coordinator to open the + /// Mac's Chief again. A second tap while one runs does nothing. + public func retryConnecting() { + guard let reconnect, !isReconnecting else { return } + isReconnecting = true + reconnect() + } + + /// Composer text of a Chief screen that went away, for the screen that replaces it. + private var carriedComposerText: String? + + /// The leaving screen's unsent text (empty clears it). + public func carryComposerText(_ text: String) { + carriedComposerText = text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : text + } + + /// The carried text, once. + public func takeCarriedComposerText() -> String? { + defer { carriedComposerText = nil } + return carriedComposerText + } + + /// The coordinator's try ended (connected, refused or replaced): Try Again is offered again. + public func endReconnect() { + if isReconnecting { isReconnecting = false } + } + + /// The conversation's own send takes a message now. Before Chief ever + /// connected it has no sender and drops the message after clearing the + /// composer (D3), so the screen holds the text and sends it through + /// ``send(_:)`` (the outbox) instead. + public var acceptsSendsNow: Bool { store.meID != nil } + /// What Chief is doing right now (Live frames v1), when its backend /// carries live frames (``ChiefLiveSource``); nil when nothing runs. public private(set) var live: ChiefLive? @@ -454,6 +502,7 @@ public final class ChiefSession { if isTyping != typing { isTyping = typing } let connected = store.connection == .connected if isConnected != connected { isConnected = connected } + if connected, isReconnecting { isReconnecting = false } if title != store.info?.title { title = store.info?.title } // Chief's avatar as the conversation draws it (the Mac's text, live). if let drawn = store.info?.participants.first(where: { $0.id == identity.chiefID })?.initials, diff --git a/cnx-ios/CnxKit/Sources/CnxChief/ConversationsHome.swift b/cnx-ios/CnxKit/Sources/CnxChief/ConversationsHome.swift index 39e0813f7d1f..27eb43fe6170 100644 --- a/cnx-ios/CnxKit/Sources/CnxChief/ConversationsHome.swift +++ b/cnx-ios/CnxKit/Sources/CnxChief/ConversationsHome.swift @@ -95,7 +95,7 @@ public struct ConversationsHome: View { private func conversation(_ tab: WorkspaceTab, in workspace: Workspace) -> AgentConversation { let chat = AgentSessionDirectory.shared.existingStore(id: tab.id) let lastTurn = chat?.turns.last - let answer: String? = lastTurn.map { $0.answerMarkdown.isEmpty ? $0.prompt : $0.answerMarkdown } + let answer: String? = lastTurn.map { $0.lastAnswerMarkdown ?? $0.prompt } let agentTabs = workspace.tabs.filter { $0.kind == .agent } return AgentConversation( workspaceID: workspace.id, tabID: tab.id, @@ -293,6 +293,9 @@ public struct ConversationsHome: View { .task { sources.refreshIfNeeded() } .alert("New Chief", isPresented: $namingChief) { TextField("Name", text: $newChiefName) + // Names and prompts are code-like: no autocorrect, no capitals (full verify D5). + .autocorrectionDisabled() + .textInputAutocapitalization(.never) .accessibilityIdentifier("home.chief.newName") Button("Cancel", role: .cancel) {} Button("Create") { diff --git a/cnx-ios/CnxKit/Sources/CnxDiff/DiffFileScreen.swift b/cnx-ios/CnxKit/Sources/CnxDiff/DiffFileScreen.swift index 8ebf26c1231d..69dcc9e20b94 100644 --- a/cnx-ios/CnxKit/Sources/CnxDiff/DiffFileScreen.swift +++ b/cnx-ios/CnxKit/Sources/CnxDiff/DiffFileScreen.swift @@ -541,11 +541,14 @@ extension DiffLine { } } + /// VoiceOver's reading of the row. A snippet edit has no line numbers (where it sits in + /// the file is unknown): it reads without one, never "line 0" (full verify D6). var accessibilityText: String { - switch kind { - case .added: "added line \(newNumber ?? 0): \(text)" - case .removed: "removed line \(oldNumber ?? 0): \(text)" - case .context: "line \(newNumber ?? 0): \(text)" + func at(_ number: Int?) -> String { number.map { " \($0)" } ?? "" } + return switch kind { + case .added: "added line\(at(newNumber)): \(text)" + case .removed: "removed line\(at(oldNumber)): \(text)" + case .context: "line\(at(newNumber)): \(text)" } } } diff --git a/cnx-ios/CnxKit/Sources/CnxFeed/FeedCell.swift b/cnx-ios/CnxKit/Sources/CnxFeed/FeedCell.swift index e510934f2597..3e95f6cbb08c 100644 --- a/cnx-ios/CnxKit/Sources/CnxFeed/FeedCell.swift +++ b/cnx-ios/CnxKit/Sources/CnxFeed/FeedCell.swift @@ -39,9 +39,16 @@ struct FeedCell: View { self.item = item self.context = context self.expanded = expanded - _contentHeight = State(initialValue: WebMarkdownRenderer.shared.cachedHeight(markdown: item.markdown, style: .body)) + let plain = FeedPlainText(item.markdown) + inlineText = plain.isInline ? plain.attributed : nil + // Native rows never overflow; only web rows need the measured height. + _contentHeight = State(initialValue: inlineText == nil + ? WebMarkdownRenderer.shared.cachedHeight(markdown: item.markdown, style: .body) : 0) } + /// Short inline text drawn natively instead of in a web view (D10); nil: the web view. + private let inlineText: AttributedString? + private var store: FeedStore { context.store } private func open() { context.open(item) } @@ -211,12 +218,24 @@ struct FeedCell: View { private var bodyView: some View { VStack(alignment: .leading, spacing: 6) { - WebMarkdownView( - markdown: item.markdown, style: .body, - collapsedHeight: collapsedHeight, isExpanded: expanded || !isOverflowing, - onTap: open - ) { height in - contentHeight = height + Group { + if let inlineText { + Text(inlineText) + .font(.body) + .foregroundStyle(.primary) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + .contentShape(.rect) + .onTapGesture(perform: open) + } else { + WebMarkdownView( + markdown: item.markdown, style: .body, + collapsedHeight: collapsedHeight, isExpanded: expanded || !isOverflowing, + onTap: open + ) { height in + contentHeight = height + } + } } .overlay(alignment: .bottom) { if isOverflowing, !expanded { diff --git a/cnx-ios/CnxKit/Sources/CnxFeed/FeedPlainText.swift b/cnx-ios/CnxKit/Sources/CnxFeed/FeedPlainText.swift new file mode 100644 index 000000000000..5926be6a573e --- /dev/null +++ b/cnx-ios/CnxKit/Sources/CnxFeed/FeedPlainText.swift @@ -0,0 +1,44 @@ +import Foundation + +/// Feed rows whose text needs no web view (full verify D10: 86 events, each +/// with a pooled WKWebView measured in JavaScript, hitched while scrolling). +/// Short text with at most inline markdown (bold, italics, code spans, links) +/// draws as native text; anything with blocks (code fences, lists, quotes, +/// headings, tables, HTML) keeps the web renderer. +public struct FeedPlainText: Sendable { + /// At most this many characters and lines: always under the collapsed + /// height (8 body lines), so no Show more is needed. + static let maxCharacters = 280 + static let maxLines = 4 + + public let markdown: String + + public init(_ markdown: String) { + self.markdown = markdown + } + + /// Whether native text draws this as the web view would. + public var isInline: Bool { + guard markdown.count <= Self.maxCharacters, !markdown.contains("```"), !markdown.contains("<") else { return false } + let lines = markdown.split(separator: "\n", omittingEmptySubsequences: false) + guard lines.count <= Self.maxLines else { return false } + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("#") || trimmed.hasPrefix(">") || trimmed.hasPrefix("|") || trimmed.hasPrefix("- ") + || trimmed.hasPrefix("* ") || trimmed.hasPrefix("+ ") || trimmed.hasPrefix(" ") + || trimmed.range(of: #"^\d+[.)] "#, options: .regularExpression) != nil { + return false + } + if line.hasPrefix(" ") || line.hasPrefix("\t") { return false } + // Setext underlines and thematic breaks (`===`, `---`, `***`, `___`). + if trimmed.count >= 3, Set(trimmed.replacingOccurrences(of: " ", with: "")).count == 1, + let mark = trimmed.first, "=-*_".contains(mark) { return false } + } + return true + } + + /// The inline markdown as attributed text; nil when it does not parse. + public var attributed: AttributedString? { + try? AttributedString(markdown: markdown, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)) + } +} diff --git a/cnx-ios/CnxKit/Sources/CnxFeed/FeedStore.swift b/cnx-ios/CnxKit/Sources/CnxFeed/FeedStore.swift index 90267225e74f..e60b9858bb26 100644 --- a/cnx-ios/CnxKit/Sources/CnxFeed/FeedStore.swift +++ b/cnx-ios/CnxKit/Sources/CnxFeed/FeedStore.swift @@ -483,7 +483,7 @@ public final class FeedStore { mutate(id) { guard $0.attempt == attempt else { return } $0.pending = nil - $0.resolution = "Allowed: \(option.name)" + $0.resolution = Self.allowedText(option.name) $0.isRead = true } } catch { @@ -513,9 +513,20 @@ public final class FeedStore { applyRespond(id, option: option) } + /// The card's line after an Allow: the option's own words when they say more than + /// "Allow" ("Allowed: Always for this chat"), else "Allowed" (full verify D12: "Allowed: Allow"). + static func allowedText(_ name: String) -> String { + let plain = name.trimmingCharacters(in: .whitespacesAndNewlines) + switch plain.lowercased() { + case "", "allow", "yes", "approve", "ok": return "Allowed" + case "allow once", "yes, once", "once": return "Allowed once" + default: return "Allowed: \(plain)" + } + } + private func applyRespond(_ id: String, option: ACPPermissionOption) { mutate(id) { - $0.resolution = option.isAllow ? "Allowed: \(option.name)" : "Denied" + $0.resolution = option.isAllow ? Self.allowedText(option.name) : "Denied" $0.isRead = true } } diff --git a/cnx-ios/CnxKit/Sources/CnxLive/AcpmuxHub.swift b/cnx-ios/CnxKit/Sources/CnxLive/AcpmuxHub.swift index 39678fc43614..b998d3d86120 100644 --- a/cnx-ios/CnxKit/Sources/CnxLive/AcpmuxHub.swift +++ b/cnx-ios/CnxKit/Sources/CnxLive/AcpmuxHub.swift @@ -314,22 +314,28 @@ public final class AcpmuxSessionSource: AgentSessionSource { Task { do { switch intent { - case .prompt(let text, let attachments, let steer): + case .prompt(let text, let attachments, let steer, let promptID, let resend): let blocks: [JSONValue] do { blocks = try await self.promptBlocks(text: text, attachments: attachments, hub: hub) } catch { - self.emit(.promptNotSent(text: text, attachments: attachments, reason: AcpmuxSessionSource.message(error))) + self.emit(.promptNotSent(text: text, attachments: attachments, promptID: promptID, + reason: AcpmuxSessionSource.message(error))) return } - let meta: JSONValue = .object(["acpmux": .object(["promptId": .string(UUID().uuidString.lowercased()), "steer": .bool(steer)])]) + // acpmux runs each promptId once; `resend` also checks the session log. + let meta: JSONValue = .object(["acpmux": .object([ + "promptId": .string(promptID), "steer": .bool(steer), "resend": .bool(resend), + ])]) do { _ = try await hub.request("session/prompt", .object([ "sessionId": session, "prompt": .array(blocks), "_meta": meta, ])) } catch let error as LinkError { - // Never reached the Mac: the draft comes back. - self.emit(.promptNotSent(text: text, attachments: attachments, reason: AcpmuxSessionSource.message(error))) + // Maybe never reached the Mac: the draft comes back with its id, so a resend + // runs at most once, and it leaves again if the transcript shows it ran. + self.emit(.promptNotSent(text: text, attachments: attachments, promptID: promptID, + reason: AcpmuxSessionSource.message(error))) } case .cancel: try await hub.notify("session/cancel", .object(["sessionId": session])) @@ -343,6 +349,8 @@ public final class AcpmuxSessionSource: AgentSessionSource { try await hub.answer(question, with: answer) case .setModel(let id): _ = try await hub.request("session/set_model", .object(["sessionId": session, "modelId": .string(id)])) + // The menu's check follows the pick (no record carries the model change). + if !self.knownModels.isEmpty { self.emit(.modelsAvailable(self.knownModels, current: id)) } case .setMode(let id): _ = try await hub.request("session/set_mode", .object(["sessionId": session, "modeId": .string(id)])) case .setConfigOption(let id, let value): @@ -545,7 +553,8 @@ public final class AcpmuxSessionSource: AgentSessionSource { case "remote.lasting_grant_refused": return "From an iPhone you can allow once or deny. Allow for this chat on the Mac." default: if error.message.hasPrefix("no pending permission") { return "Already answered on your Mac." } - return error.message + // An agent's raw JSON error (a model the account lacks) reads as a sentence. + return TurnFailureCopy.plain(error.message) } } return LinkController.reason(error) @@ -593,6 +602,16 @@ public final class AcpmuxSessionSource: AgentSessionSource { } attached = true for record in page { receive(record: record) } + // The session's models, modes and options as acpmux keeps them now (D4: a running + // chat had no model menu, as they only came with later updates). After the page: + // a pick (set_model, set_mode, set_config_option) writes no record, so an older + // update in the page must not win over the state now. + if let detail = result["session"] { + for event in Self.sessionState(detail) { + if case .modelsAvailable(let models, _) = event { knownModels = models } + emit(event) + } + } let pending = held held = [] for record in pending { receive(record: record) } @@ -607,6 +626,37 @@ public final class AcpmuxSessionSource: AgentSessionSource { } } + /// The session's models (from attach), for the menu after a pick. + private var knownModels: [ACPModelInfo] = [] + + /// `_acpmux/attach`'s session detail as chat events: `models` ({currentModelId, + /// availableModels}), else a config option of category `model`; `modes` ({currentModeId, + /// availableModes}); `configOptions` (effort and the rest). + static func sessionState(_ detail: JSONValue) -> [AgentSessionEvent] { + var events: [AgentSessionEvent] = [] + let options: [ACPConfigOption] = detail["configOptions"].flatMap { value in + guard case .array = value, let data = try? JSONEncoder().encode(value) else { return nil } + return try? JSONDecoder().decode([ACPConfigOption].self, from: data) + } ?? [] + var models = (detail["models"]?["availableModels"]?.arrayValue ?? []).compactMap { model -> ACPModelInfo? in + guard let id = model["modelId"]?.stringValue, !id.isEmpty else { return nil } + return ACPModelInfo(id: id, name: model["name"]?.stringValue.flatMap { $0.isEmpty ? nil : $0 } ?? id) + } + var current = detail["models"]?["currentModelId"]?.stringValue + if models.isEmpty, let option = options.first(where: { $0.category == "model" || $0.id == "model" }) { + models = option.options.map { ACPModelInfo(id: $0.value, name: $0.name.flatMap { $0.isEmpty ? nil : $0 } ?? $0.value) } + current = option.currentValue + } + if !models.isEmpty { events.append(.modelsAvailable(models, current: current)) } + let modes = (detail["modes"]?["availableModes"]?.arrayValue ?? []).compactMap { mode -> ACPMode? in + guard let id = mode["id"]?.stringValue, !id.isEmpty else { return nil } + return ACPMode(id: id, name: mode["name"]?.stringValue ?? id, description: mode["description"]?.stringValue) + } + if !modes.isEmpty { events.append(.modesAvailable(modes, current: detail["modes"]?["currentModeId"]?.stringValue)) } + if !options.isEmpty { events.append(.update(.configOptions(options))) } + return events + } + func receive(record: JSONValue) { guard attached else { held.append(record) diff --git a/cnx-ios/CnxKit/Sources/CnxLive/LiveCoordinator.swift b/cnx-ios/CnxKit/Sources/CnxLive/LiveCoordinator.swift index 73f72552dcbe..e6383654aef4 100644 --- a/cnx-ios/CnxKit/Sources/CnxLive/LiveCoordinator.swift +++ b/cnx-ios/CnxKit/Sources/CnxLive/LiveCoordinator.swift @@ -100,6 +100,8 @@ public final class LiveCoordinator { @ObservationIgnored private var terminals: [WeakTerminal] = [] private struct WeakTerminal { weak var session: DaemonTerminalSession? } @ObservationIgnored private var chiefLive = false + /// A `startChief` is opening the channel (Try Again does not start a second one). + @ObservationIgnored private var chiefStarting = false @ObservationIgnored private var discoveryTask: Task? /// Follows the phone's network (Wi-Fi, cellular, airplane mode). @ObservationIgnored private var networkTask: Task? @@ -1113,6 +1115,14 @@ public final class LiveCoordinator { /// reason; there is no stand-in Chief. private func startChief(_ link: any MacLink) async { let session = ChiefSession.shared + chiefStarting = true + // Every way out ends a Try Again in flight, even one for a link replaced meanwhile. + defer { + chiefStarting = false + session.endReconnect() + } + // Try Again on Chief's unavailable bar opens the channel again on this link. + session.reconnect = { [weak self] in self?.retryChief() } session.macName = self.link.target?.name // Chief's settings show this Mac's last known model until it answers. session.macID = self.link.target?.macID @@ -1162,10 +1172,36 @@ public final class LiveCoordinator { } catch { // An older hostd without the `chief` service closes it as unknown. let reason = LinkController.reason(error) - offline(reason.contains("unknown_service") || reason.contains("no service chief") - ? String(localized: "This Mac's cmux is too old for Chief on iPhone. Update cmux on the Mac.") - : String(localized: "Chief on this Mac: \(reason)")) + log.info("chief channel refused: \(reason, privacy: .public)") + offline(Self.chiefRefusal(reason, macName: self.link.target?.name)) + } + } + + /// Try Again on Chief's unavailable bar: the Chief channel again, on the + /// link that is up now. Without one, the bar says so. + func retryChief() { + // One open at a time: the one running now ends the try. + guard !chiefStarting else { return } + guard let link = servedLink else { + ChiefSession.shared.status = String(localized: "Your Mac isn't connected. Chief comes back when it is.") + return + } + Task { [weak self] in await self?.startChief(link) } + } + + /// The bar's sentence for a refused `chief` channel. hostd's own text + /// ("cmux-tui session chief: No such file or directory") is for the log. + static func chiefRefusal(_ reason: String, macName: String?) -> String { + let mac = macName ?? String(localized: "your Mac") + if reason.contains("unknown_service") || reason.contains("no service chief") { + return String(localized: "This Mac's cmux is too old for Chief on iPhone. Update cmux on the Mac.") + } + // hostd closes the channel when the owner's socket is missing ("daemon channel closed"). + if reason.contains("No such file") || reason.contains("not set up") || reason.contains("Connection refused") + || reason.contains("channel closed") { + return String(localized: "Chief isn't running on \(mac). Open cmux on the Mac to start it, then try again.") } + return String(localized: "Chief on \(mac) didn't answer (\(reason)).") } private func apply(_ tree: DaemonTree) { diff --git a/cnx-ios/CnxKit/Sources/CnxLive/TerminalFeed.swift b/cnx-ios/CnxKit/Sources/CnxLive/TerminalFeed.swift index 7d1998b2d8a0..d885d1171a94 100644 --- a/cnx-ios/CnxKit/Sources/CnxLive/TerminalFeed.swift +++ b/cnx-ios/CnxKit/Sources/CnxLive/TerminalFeed.swift @@ -343,7 +343,13 @@ public final class TerminalFeed { .filter { !$0.isEmpty && $0 != headline } .joined(separator: "\n\n") switch (headline.isEmpty, detail.isEmpty) { - case (true, true): return kind == .needsInput ? "Needs your input." : kind == .failed ? "Reported an error." : "Finished." + case (true, true): return kind == .needsInput ? "Needs your input." : kind == .failed ? "Reported an error." + // The hook said nothing more (Codex's finished event has no body): say where (D12). + : "Finished its turn in the terminal. Open it to see the result." + case (false, true) where kind == .finished && ["finished", "done", "completed", "turn complete", "turn completed"] + .contains(headline.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: ".!"))): + // "Codex finished" and nothing else (Codex's hook): say where to look (D12). + return "Finished its turn in the terminal. Open it to see the result." case (false, true): return headline.hasSuffix(".") || headline.hasSuffix("?") || headline.hasSuffix("!") ? headline : headline + "." case (true, false): return detail case (false, false): return "**\(headline):** \(detail)" diff --git a/cnx-ios/CnxKit/Sources/CnxModel/AgentCatalog.swift b/cnx-ios/CnxKit/Sources/CnxModel/AgentCatalog.swift index e2710b164140..996eae73d7c6 100644 --- a/cnx-ios/CnxKit/Sources/CnxModel/AgentCatalog.swift +++ b/cnx-ios/CnxKit/Sources/CnxModel/AgentCatalog.swift @@ -60,6 +60,14 @@ public struct AgentHarnessChoice: Identifiable, Hashable, Sendable { } public var canStart: Bool { unavailableReason == nil } + + /// ``defaultModel`` when this Mac's account offers it: a harness that lists its models + /// and leaves the default out can't run it (full verify D4), so Default is not named. + public var offeredDefaultModel: String? { + guard let defaultModel else { return nil } + guard !models.isEmpty else { return defaultModel } + return models.contains { $0.id == defaultModel } ? defaultModel : nil + } } /// The agents a Mac offers a phone: the Mac's catalog (`_acpmux/harnesses`, `_acpmux/models`) @@ -102,7 +110,13 @@ public struct AgentCatalog: Hashable, Sendable { } } guard let harness = start.harness.map({ harness($0) }) ?? defaultChoice, harness.canStart else { return nil } - guard let model = start.model, model != harness.defaultModel, model != "default" else { return (harness, nil) } + guard let model = start.model, model != "default" else { + // Default on a named agent: the Mac's own pick for that agent (in `folder`, else + // anywhere), so a phone's chat runs the model the Mac's chats do (full verify D4). + let picked = lastPicks?.picks(in: folder).first { $0.harness == harness.id }?.model + return (harness, picked.flatMap { $0 == harness.defaultModel || $0 == "default" ? nil : $0 }) + } + guard model != harness.defaultModel else { return (harness, nil) } return (harness, model) } diff --git a/cnx-ios/CnxKit/Sources/CnxModel/RelativeStamp.swift b/cnx-ios/CnxKit/Sources/CnxModel/RelativeStamp.swift index c669966a45bf..b2cfff297ee1 100644 --- a/cnx-ios/CnxKit/Sources/CnxModel/RelativeStamp.swift +++ b/cnx-ios/CnxKit/Sources/CnxModel/RelativeStamp.swift @@ -14,6 +14,17 @@ public struct RelativeStamp: Sendable { self.calendar = calendar } + /// What VoiceOver says for `date`, matching ``string(for:)``: "now" under a minute + /// (also for a time a little ahead of the minute clock, which read "in 10 seconds", + /// full verify D14), else "5 minutes ago". + public func spoken(for date: Date) -> String { + let seconds = now.timeIntervalSince(date) + if seconds < 60 { return "now" } + let formatter = RelativeDateTimeFormatter() + formatter.calendar = calendar + return formatter.localizedString(for: date, relativeTo: now) + } + public func string(for date: Date) -> String { let seconds = max(0, now.timeIntervalSince(date)) switch seconds { diff --git a/cnx-ios/CnxKit/Sources/CnxModel/ServiceTeamCreation.swift b/cnx-ios/CnxKit/Sources/CnxModel/ServiceTeamCreation.swift new file mode 100644 index 000000000000..2a6921f4e544 --- /dev/null +++ b/cnx-ios/CnxKit/Sources/CnxModel/ServiceTeamCreation.swift @@ -0,0 +1,43 @@ +import Foundation + +/// Create Team through the cmux service (`POST /v1/teams`, PROTOCOL.md). +/// Stack refuses team creation from the client (`client_team_creation_enabled` +/// is off in both projects), so the service creates it with the project's +/// server key (full verify D1, 2026-10-19). +public struct ServiceTeamCreation: Sendable { + /// Why a team could not be created. + public enum Failure: Error, Equatable, Sendable { + /// The service can't create teams for this account (no server key for its project). + case unavailable + case failed + } + + public let apiBase: URL + + public init(apiBase: URL) { + self.apiBase = apiBase + } + + /// `POST /v1/teams {name}`. + public func request(accessToken: String, name: String) -> URLRequest { + var request = URLRequest(url: apiBase.appending(path: "v1/teams")) + request.httpMethod = "POST" + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try? JSONSerialization.data(withJSONObject: ["name": name]) + return request + } + + /// The created team from the service's answer; any other answer is an error, never a silent success. + public func team(from data: Data, status: Int) throws -> AppStore.AccountTeam { + let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + guard status == 200 else { + let code = (object?["error"] as? [String: Any])?["code"] as? String + throw code == "team_creation_unavailable" ? Failure.unavailable : Failure.failed + } + guard let team = object?["team"] as? [String: Any], let id = team["id"] as? String, !id.isEmpty else { + throw Failure.failed + } + return AppStore.AccountTeam(id: id, name: team["name"] as? String ?? "") + } +} diff --git a/cnx-ios/CnxKit/Sources/CnxOnboarding/Setup.swift b/cnx-ios/CnxKit/Sources/CnxOnboarding/Setup.swift index 14c5113f2c5c..be9444be9d25 100644 --- a/cnx-ios/CnxKit/Sources/CnxOnboarding/Setup.swift +++ b/cnx-ios/CnxKit/Sources/CnxOnboarding/Setup.swift @@ -453,6 +453,9 @@ private struct CreateTeamPage: View { SetupPage(symbol: "person.2", title: "Create your team") { VStack(alignment: .leading, spacing: 10) { TextField("Team name", text: $name) + // Names are code-like: no autocorrect, no capitals (full verify D5). + .autocorrectionDisabled() + .textInputAutocapitalization(.never) .font(theme.type.body) .cnxInputField() .onSubmit(create) diff --git a/cnx-ios/CnxKit/Sources/CnxShell/ShellModel.swift b/cnx-ios/CnxKit/Sources/CnxShell/ShellModel.swift index c36e55933b2c..3d91f9076381 100644 --- a/cnx-ios/CnxKit/Sources/CnxShell/ShellModel.swift +++ b/cnx-ios/CnxKit/Sources/CnxShell/ShellModel.swift @@ -427,6 +427,9 @@ final class ShellModel { } theme.haptics.success() switchTeam(to: team.id) + } catch ServiceTeamCreation.Failure.unavailable { + toast = CnxToast(symbol: "exclamationmark.circle", text: "Creating teams isn't available for this account yet") + theme.haptics.error() } catch { toast = CnxToast(symbol: "exclamationmark.circle", text: "Couldn't create the team") theme.haptics.error() diff --git a/cnx-ios/CnxKit/Sources/CnxShell/TeamSidebar.swift b/cnx-ios/CnxKit/Sources/CnxShell/TeamSidebar.swift index e81fbf4ec582..c847d1cf7af3 100644 --- a/cnx-ios/CnxKit/Sources/CnxShell/TeamSidebar.swift +++ b/cnx-ios/CnxKit/Sources/CnxShell/TeamSidebar.swift @@ -62,6 +62,9 @@ struct TeamSidebar: View { .background(theme.colors.bg.color.ignoresSafeArea()) .alert("Create team", isPresented: $creatingTeam) { TextField("Team name", text: $newTeamName) + // Names and prompts are code-like: no autocorrect, no capitals (full verify D5). + .autocorrectionDisabled() + .textInputAutocapitalization(.never) Button("Cancel", role: .cancel) { newTeamName = "" } Button("Create") { shell.createTeam(named: newTeamName); newTeamName = "" } } message: { diff --git a/cnx-ios/CnxKit/Sources/CnxWorkspaces/AgentChooser.swift b/cnx-ios/CnxKit/Sources/CnxWorkspaces/AgentChooser.swift index 4bdfb8a6ed5f..f1b56a5aaf1f 100644 --- a/cnx-ios/CnxKit/Sources/CnxWorkspaces/AgentChooser.swift +++ b/cnx-ios/CnxKit/Sources/CnxWorkspaces/AgentChooser.swift @@ -65,7 +65,7 @@ struct AgentChooser: View { } if let harness = selectedHarness(in: catalog), !harness.models.isEmpty { Section("Model") { - modelRow(id: nil, name: harness.defaultModel.map { "Default (\(modelName($0, in: harness)))" } ?? "Default", + modelRow(id: nil, name: harness.offeredDefaultModel.map { "Default (\(modelName($0, in: harness)))" } ?? "Default", reason: nil) ForEach(harness.models.filter { $0.id != "default" }) { model in modelRow(id: model.id, name: model.name, reason: model.unavailableReason) diff --git a/cnx-ios/CnxKit/Sources/CnxWorkspaces/TabMoveAndGroupSheets.swift b/cnx-ios/CnxKit/Sources/CnxWorkspaces/TabMoveAndGroupSheets.swift index 2ae09732517d..328a5879d97a 100644 --- a/cnx-ios/CnxKit/Sources/CnxWorkspaces/TabMoveAndGroupSheets.swift +++ b/cnx-ios/CnxKit/Sources/CnxWorkspaces/TabMoveAndGroupSheets.swift @@ -57,6 +57,9 @@ struct TabMoveAndGroupSheets: ViewModifier { set: { shown in if !shown { source.renamingGroupTab = nil } } ), presenting: source.renamingGroupTab) { tab in TextField(tab.group?.name.isEmpty == false ? tab.group!.name : "Name", text: $groupName) + // Names and prompts are code-like: no autocorrect, no capitals (full verify D5). + .autocorrectionDisabled() + .textInputAutocapitalization(.never) Button("Cancel", role: .cancel) {} Button("Rename") { Task { await source.editGroup(.rename(groupName), tab.id) } } } diff --git a/cnx-ios/CnxKit/Sources/CnxWorkspaces/WorkspaceScreen.swift b/cnx-ios/CnxKit/Sources/CnxWorkspaces/WorkspaceScreen.swift index dd6f649eeec7..330822957ca2 100644 --- a/cnx-ios/CnxKit/Sources/CnxWorkspaces/WorkspaceScreen.swift +++ b/cnx-ios/CnxKit/Sources/CnxWorkspaces/WorkspaceScreen.swift @@ -108,6 +108,9 @@ public struct WorkspaceScreen: View { set: { shown in if !shown { source.renamingTab = nil } } )) { TextField(renamePlaceholder, text: $renameText) + // Names and prompts are code-like: no autocorrect, no capitals (full verify D5). + .autocorrectionDisabled() + .textInputAutocapitalization(.never) Button("Cancel", role: .cancel) {} Button("Rename") { if let id = renameID { Task { await source.rename(id, to: renameText) } } diff --git a/cnx-ios/CnxKit/Sources/CnxWorkspaces/WorkspaceTabsSource.swift b/cnx-ios/CnxKit/Sources/CnxWorkspaces/WorkspaceTabsSource.swift index d84a33bd9d71..10d39f5a149d 100644 --- a/cnx-ios/CnxKit/Sources/CnxWorkspaces/WorkspaceTabsSource.swift +++ b/cnx-ios/CnxKit/Sources/CnxWorkspaces/WorkspaceTabsSource.swift @@ -368,7 +368,13 @@ public final class WorkspaceTabsSource: TabsDataSource { showUnavailable(option) return nil } - guard pendingTabTitle == nil else { return nil } + // One tab at a time; a second request says so instead of doing nothing (full verify D8). + if let pending = pendingTabTitle { + tabsLog.info("tab request \(option.id, privacy: .public) while \(pending, privacy: .public) is opening") + show("Still opening \(pending). Try again once it's open.") + return nil + } + tabsLog.info("tab request \(option.id, privacy: .public) on \(self.macName, privacy: .public)") pendingTabTitle = option.title Task { @MainActor [weak self] in guard let self else { return } diff --git a/cnx-ios/CnxKit/Sources/CnxWorkspaces/WorkspacesList.swift b/cnx-ios/CnxKit/Sources/CnxWorkspaces/WorkspacesList.swift index 335c883e536d..775186ef9078 100644 --- a/cnx-ios/CnxKit/Sources/CnxWorkspaces/WorkspacesList.swift +++ b/cnx-ios/CnxKit/Sources/CnxWorkspaces/WorkspacesList.swift @@ -175,6 +175,9 @@ public struct WorkspacesList: View { } .alert("Rename Workspace", isPresented: isPresented($renaming), presenting: renaming) { ws in TextField("Name", text: $renameText) + // Names and prompts are code-like: no autocorrect, no capitals (full verify D5). + .autocorrectionDisabled() + .textInputAutocapitalization(.never) .submitLabel(.done) Button("Cancel", role: .cancel) {} Button("Rename") { creator.rename(ws, to: renameText) } @@ -187,6 +190,9 @@ public struct WorkspacesList: View { view .alert("Move Workspace to New Group", isPresented: isPresented($newGroupFor), presenting: newGroupFor) { (ws: Workspace) in TextField(MacWorkspaceGroup.newGroupName, text: $groupNameText) + // Names and prompts are code-like: no autocorrect, no capitals (full verify D5). + .autocorrectionDisabled() + .textInputAutocapitalization(.never) Button("Cancel", role: .cancel) {} Button("Create") { withAnimation(theme.motion.smooth.animation) { creator.moveToNewGroup(ws, name: groupNameText) } @@ -194,6 +200,9 @@ public struct WorkspacesList: View { } .alert("Rename Group", isPresented: isPresented($renamingGroup), presenting: renamingGroup) { (target: GroupTarget) in TextField("Name", text: $groupNameText) + // Names and prompts are code-like: no autocorrect, no capitals (full verify D5). + .autocorrectionDisabled() + .textInputAutocapitalization(.never) .submitLabel(.done) Button("Cancel", role: .cancel) {} Button("Rename") { creator.renameGroup(target.group, on: target.macID, to: groupNameText) } @@ -375,6 +384,17 @@ public struct WorkspacesList: View { } } } + // Loading with Mac lines shown: the placeholder rows go below them, never over + // them (full verify D7, D11: an overlay drew over the lines and took their + // Connect taps). + if inbox.isLoading, !inbox.folds.isEmpty { + SkeletonList(rows: 4, leadingSquare: nil) + .allowsHitTesting(false) + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + .accessibilityIdentifier("workspaces.loading") + } } .listStyle(.plain) .onScrollGeometryChange(for: CGFloat.self) { $0.containerSize.height - $0.contentInsets.bottom } action: { _, height in @@ -386,7 +406,7 @@ public struct WorkspacesList: View { .overlay(alignment: .topLeading) { closeAnchor } .overlay { if inbox.isLoading { - skeleton + if inbox.folds.isEmpty { skeleton } } else if inbox.isEmpty { emptyState } @@ -1301,7 +1321,7 @@ struct WorkspaceRow: View { let line = StatusLine.words(workspace, status: status, macName: macName) parts.append(contentsOf: line) if workspace.hasActivityTime, status != .creating { - parts.append(RelativeDateTimeFormatter().localizedString(for: workspace.lastActivityAt, relativeTo: MinuteClock.shared.now)) + parts.append(RelativeStamp(now: MinuteClock.shared.now).spoken(for: workspace.lastActivityAt)) } return parts.joined(separator: ", ") } diff --git a/cnx-ios/CnxKit/Tests/CnxAgentChatTests/PromptResendTests.swift b/cnx-ios/CnxKit/Tests/CnxAgentChatTests/PromptResendTests.swift new file mode 100644 index 000000000000..6b46dca41d33 --- /dev/null +++ b/cnx-ios/CnxKit/Tests/CnxAgentChatTests/PromptResendTests.swift @@ -0,0 +1,102 @@ +import Foundation +import Testing +@testable import CnxAgentChat + +/// Live recheck (2026-10-20): after a link drop, a prompt the Mac already ran came back into +/// the composer, and Send would have run it twice. A prompt keeps one id across a resend +/// (acpmux runs each `promptId` once), and a returned draft goes away once the Mac shows it ran. +@MainActor +@Suite struct PromptResendTests { + final class Recorder: AgentSessionSource { + let summary = AgentSessionSummary(id: "s", name: "S", harness: "codex", agentName: "Codex", cwd: "~", workspaceTitle: "w", macName: "m") + var sent: [AgentClientIntent] = [] + func events() -> AsyncStream { AsyncStream { _ in } } + func send(_ intent: AgentClientIntent) { sent.append(intent) } + } + + static func prompt(_ intent: AgentClientIntent?) -> (text: String, id: String, resend: Bool)? { + guard case .prompt(let text, _, _, let id, let resend)? = intent else { return nil } + return (text, id, resend) + } + + @Test func aReturnedPromptSentAgainKeepsItsID() throws { + let source = Recorder() + let store = AgentChatStore(source: source) + store.send(prompt: "run the tests") + let first = try #require(Self.prompt(source.sent.last)) + #expect(!first.resend) + // The link dropped before the reply: the draft comes back with its id. + store.apply(.promptNotSent(text: "run the tests", attachments: [], promptID: first.id, reason: "link down")) + #expect(store.returnedDraft?.promptID == first.id) + store.returnedDraft = nil // the composer took it + store.send(prompt: "run the tests") + let again = try #require(Self.prompt(source.sent.last)) + #expect(again.id == first.id) + #expect(again.resend) + // A new prompt gets a new id. + store.send(prompt: "something else") + #expect(Self.prompt(source.sent.last)?.id != first.id) + } + + @Test func anEditedDraftIsANewPrompt() throws { + let source = Recorder() + let store = AgentChatStore(source: source) + store.send(prompt: "run the tests") + let first = try #require(Self.prompt(source.sent.last)) + store.apply(.promptNotSent(text: "run the tests", attachments: [], promptID: first.id, reason: "link down")) + store.returnedDraft = nil + store.send(prompt: "run the unit tests") + #expect(Self.prompt(source.sent.last)?.id != first.id) + } + + @Test func aDraftTheMacRanIsWithdrawn() throws { + let source = Recorder() + let store = AgentChatStore(source: source) + store.send(prompt: "run the tests") + let first = try #require(Self.prompt(source.sent.last)) + store.apply(.promptNotSent(text: "run the tests", attachments: [], promptID: first.id, reason: "link down")) + store.returnedDraft = nil + // The replayed transcript shows the Mac ran it. + store.apply(.userMessage(text: "run the tests", promptId: first.id, turnId: "t1", attachments: [], at: Date())) + #expect(store.withdrawnDraft?.text == "run the tests") + // Nothing is left to resend under that id. + store.send(prompt: "run the tests") + let again = try #require(Self.prompt(source.sent.last)) + #expect(again.id != first.id) + #expect(!again.resend) + } + + /// Review: the reply was lost after the transcript already showed the prompt: nothing comes back. + @Test func aPromptSeenRunningNeverComesBack() throws { + let source = Recorder() + let store = AgentChatStore(source: source) + store.send(prompt: "run the tests") + let first = try #require(Self.prompt(source.sent.last)) + store.apply(.userMessage(text: "run the tests", promptId: first.id, turnId: "t1", attachments: [], at: Date())) + store.apply(.promptNotSent(text: "run the tests", attachments: [], promptID: first.id, reason: "link down")) + #expect(store.returnedDraft == nil) + store.send(prompt: "run the tests") + #expect(Self.prompt(source.sent.last)?.id != first.id) + } + + /// Review: other attachments are another prompt; a withdrawal takes the attachments too. + @Test func attachmentsCountAndLeaveWithTheDraft() throws { + let source = Recorder() + let store = AgentChatStore(source: source) + let photo = AgentAttachment(kind: .file, name: "a.txt") + store.send(prompt: "look", attachments: [photo]) + let first = try #require(Self.prompt(source.sent.last)) + store.apply(.promptNotSent(text: "look", attachments: [photo], promptID: first.id, reason: "link down")) + store.returnedDraft = nil + store.send(prompt: "look", attachments: []) + #expect(Self.prompt(source.sent.last)?.id != first.id) + + store.send(prompt: "look again", attachments: [photo]) + let second = try #require(Self.prompt(source.sent.last)) + store.apply(.promptNotSent(text: "look again", attachments: [photo], promptID: second.id, reason: "link down")) + store.returnedDraft = nil + store.apply(.queued(text: "look again", promptId: second.id, position: 0)) + #expect(store.withdrawnDraft?.attachments == [photo]) + } +} + diff --git a/cnx-ios/CnxKit/Tests/CnxAgentChatTests/TurnFailureCopyTests.swift b/cnx-ios/CnxKit/Tests/CnxAgentChatTests/TurnFailureCopyTests.swift index 2d070a92900e..af13a06f498e 100644 --- a/cnx-ios/CnxKit/Tests/CnxAgentChatTests/TurnFailureCopyTests.swift +++ b/cnx-ios/CnxKit/Tests/CnxAgentChatTests/TurnFailureCopyTests.swift @@ -64,4 +64,29 @@ import Testing store.apply(.retrying(attempt: 3, maxAttempts: 10, status: nil)) #expect(store.turns.last?.retry == nil) } + + /// Full verify D4: the account refusing the chat's model reads as a sentence, never raw JSON. + @Test func aModelTheAccountLacksIsPlain() { + let raw = #"{"type":"error","status":400,"error":{"type":"invalid_request_error","message":"The 'gpt-6.1-sol' model is not supported when using Codex with a ChatGPT account."}}"# + let copy = TurnFailureCopy(message: raw, harness: "codex", harnessName: "Codex") + #expect(copy.kind == .modelUnavailable) + #expect(copy.sentence == "Codex can't use this model with the Mac's account. Pick another model.") + #expect(copy.detail == raw) + #expect(!TurnFailureCopy.plain(raw, harness: "codex", harnessName: "Codex").contains("{")) + // A capacity error that mentions the model stays a capacity error. + #expect(TurnFailureCopy(message: "The model is currently not available (503)").kind == .rateLimited) + // Plain text stays as it is. + #expect(TurnFailureCopy.plain("Already answered.") == "Already answered.") + } +} + + +/// Full verify D13: a chat's preview is its newest agent message, not the turn's first. +@Suite struct LastAnswerTests { + @Test func theLastMessageOfTheTurn() { + var turn = AgentTurn(id: "t", prompt: "update both files", promptAt: Date()) + turn.items = [.message(id: "a", markdown: "I'll update both files"), .message(id: "b", markdown: "Done: both updated.")] + #expect(turn.lastAnswerMarkdown == "Done: both updated.") + #expect(AgentTurn(id: "u", prompt: "x", promptAt: Date()).lastAnswerMarkdown == nil) + } } diff --git a/cnx-ios/CnxKit/Tests/CnxChiefTests/ChiefUnavailableTests.swift b/cnx-ios/CnxKit/Tests/CnxChiefTests/ChiefUnavailableTests.swift new file mode 100644 index 000000000000..f9b70062156a --- /dev/null +++ b/cnx-ios/CnxKit/Tests/CnxChiefTests/ChiefUnavailableTests.swift @@ -0,0 +1,54 @@ +import CmuxConversationCore +import Foundation +import Testing +@testable import CnxChief + +/// Full verify D2/D3 (2026-10-19): with the Mac's Chief refused, the +/// conversation spun forever and a sent message vanished. Chief says why it +/// is unavailable, offers Try Again, and keeps what the user wrote. +@MainActor +@Suite struct ChiefUnavailableTests { + @Test func aRefusedChiefShowsWhyAndTryAgain() { + let session = ChiefSession(backend: ChiefOfflineBackend(), outbox: ChiefOutbox(file: nil), clock: ImmediateClock()) + // Before the link says anything, nothing is shown (the link may still come up). + #expect(session.barItem == nil) + var retries = 0 + session.reconnect = { retries += 1 } + session.status = "Chief isn't running on Studio." + guard case .unavailable(let reason, let retrying)? = session.barItem else { + Issue.record("expected the unavailable bar, got \(String(describing: session.barItem))") + return + } + #expect(reason == "Chief isn't running on Studio.") + #expect(!retrying) + session.retryConnecting() + #expect(retries == 1) + #expect(session.barItem == .unavailable(reason: "Chief isn't running on Studio.", retrying: true)) + // A second tap while it tries does nothing. + session.retryConnecting() + #expect(retries == 1) + // The try ended with the same refusal: Try Again is offered again. + session.status = "Chief isn't running on Studio." + #expect(session.barItem == .unavailable(reason: "Chief isn't running on Studio.", retrying: false)) + } + + @Test func aMessageWrittenWhileChiefIsUnavailableIsKept() { + let session = ChiefSession(backend: ChiefOfflineBackend(), outbox: ChiefOutbox(file: nil), clock: ImmediateClock()) + session.status = "Chief isn't running on Studio." + // The conversation's own send drops a message before Chief ever connected. + #expect(!session.acceptsSendsNow) + session.send(ConversationComposeMessage(text: "Hello Chief")) + #expect(session.outbox.map(\.text) == ["Hello Chief"]) + #expect(session.preview.contains("Hello Chief")) + } + + @Test func withoutRefusalOrReconnectHookNoTryAgain() { + let session = ChiefSession(backend: ChiefOfflineBackend(), outbox: ChiefOutbox(file: nil), clock: ImmediateClock()) + session.status = "No Mac" + #expect(session.barItem == .unavailable(reason: "No Mac", retrying: false)) + // No hook: Try Again does nothing and never shows as trying. + session.retryConnecting() + #expect(session.barItem == .unavailable(reason: "No Mac", retrying: false)) + #expect(!session.canRetryConnecting) + } +} diff --git a/cnx-ios/CnxKit/Tests/CnxLiveTests/AgentCatalogTests.swift b/cnx-ios/CnxKit/Tests/CnxLiveTests/AgentCatalogTests.swift index deca10ce9037..0282fe94716e 100644 --- a/cnx-ios/CnxKit/Tests/CnxLiveTests/AgentCatalogTests.swift +++ b/cnx-ios/CnxKit/Tests/CnxLiveTests/AgentCatalogTests.swift @@ -171,6 +171,29 @@ import Testing #expect(agentOnly.resolve(.macDefault)?.harness.id == "claude") } + /// Full verify D4: Default on a named agent is the Mac's own pick for that agent, and a + /// catalog default the Mac's account doesn't list is not offered by name. + @Test func defaultOnANamedAgentIsTheMacsOwnPick() throws { + let catalog = AgentCatalog(harnesses: try json(harnesses)) + #expect(catalog.resolve(AgentChatStart(harness: "codex"), folder: "/Users/p/other")?.model == "gpt-5.5") + #expect(catalog.resolve(AgentChatStart(harness: "cmux"), folder: "/Users/p/web")?.model == "z-ai/glm-5.3") + // No pick for that agent: the agent's own default (none sent). + #expect(catalog.resolve(AgentChatStart(harness: "claude"), folder: "/Users/p/web")?.model == nil) + // An explicit model still wins. + #expect(catalog.resolve(AgentChatStart(harness: "codex", model: "gpt-6-astra"))?.model == "gpt-6-astra") + } + + @Test func aDefaultTheAccountDoesNotListIsNotNamed() { + let listed = AgentHarnessChoice(id: "codex", name: "Codex", family: "codex", + models: [AgentModelChoice(id: "gpt-5.5", name: "GPT-5.5")], defaultModel: "gpt-5.5") + #expect(listed.offeredDefaultModel == "gpt-5.5") + let unlisted = AgentHarnessChoice(id: "codex", name: "Codex", family: "codex", + models: [AgentModelChoice(id: "gpt-5.5", name: "GPT-5.5")], defaultModel: "gpt-6.1-sol") + #expect(unlisted.offeredDefaultModel == nil) + // No list (Claude Code): the default stands. + #expect(AgentHarnessChoice(id: "claude", name: "Claude", family: "claude", defaultModel: "opus").offeredDefaultModel == "opus") + } + @Test func aPhonePicksOnlyModesThatAsk() { let modes = ["default", "acceptEdits", "plan", "bypassPermissions", "ask", "agent", "agent-full-access"] .map { ACPMode(id: $0, name: $0) } diff --git a/cnx-ios/CnxKit/Tests/CnxLiveTests/FeedPendingTests.swift b/cnx-ios/CnxKit/Tests/CnxLiveTests/FeedPendingTests.swift index b5da3ae0fca8..9f73d628d165 100644 --- a/cnx-ios/CnxKit/Tests/CnxLiveTests/FeedPendingTests.swift +++ b/cnx-ios/CnxKit/Tests/CnxLiveTests/FeedPendingTests.swift @@ -49,7 +49,7 @@ import Testing #expect(actions.responds == 1) actions.release?.resume() await settle { store.item(id: "p1")?.resolution != nil } - #expect(store.item(id: "p1")?.resolution == "Allowed: Allow once") + #expect(store.item(id: "p1")?.resolution == "Allowed once") #expect(store.item(id: "p1")?.pending == nil) } diff --git a/cnx-ios/CnxKit/Tests/CnxLiveTests/FeedPlainTextTests.swift b/cnx-ios/CnxKit/Tests/CnxLiveTests/FeedPlainTextTests.swift new file mode 100644 index 000000000000..4c5782f108e2 --- /dev/null +++ b/cnx-ios/CnxKit/Tests/CnxLiveTests/FeedPlainTextTests.swift @@ -0,0 +1,24 @@ +import Foundation +import Testing +@testable import CnxFeed + +/// Full verify D10: short inline rows skip the web view. +@Suite struct FeedPlainTextTests { + @Test func shortInlineTextIsNative() { + #expect(FeedPlainText("Finished its turn in the terminal. Open it to see the result.").isInline) + #expect(FeedPlainText("**Needs approval:** run `ls -la` in **web**").isInline) + #expect(FeedPlainText("**Needs approval:** run `ls`").attributed != nil) + } + + @Test func blocksKeepTheWebRenderer() { + #expect(!FeedPlainText("Here:\n```swift\nlet a = 1\n```").isInline) + #expect(!FeedPlainText("- one\n- two").isInline) + #expect(!FeedPlainText("# Title").isInline) + #expect(!FeedPlainText("| a | b |\n|---|---|").isInline) + #expect(!FeedPlainText("1. first").isInline) + #expect(!FeedPlainText(String(repeating: "word ", count: 80)).isInline) + #expect(!FeedPlainText("a\nb\nc\nd\ne").isInline) + #expect(!FeedPlainText("Title\n===").isInline) + #expect(!FeedPlainText("above\n---\nbelow").isInline) + } +} diff --git a/cnx-ios/CnxKit/Tests/CnxLiveTests/SessionStateTests.swift b/cnx-ios/CnxKit/Tests/CnxLiveTests/SessionStateTests.swift new file mode 100644 index 000000000000..d03666594693 --- /dev/null +++ b/cnx-ios/CnxKit/Tests/CnxLiveTests/SessionStateTests.swift @@ -0,0 +1,43 @@ +import Foundation +import Testing +@testable import CnxAgentChat +@testable import CnxLive + +/// Full verify D4 (live recheck): a running Codex chat on the phone had no model menu. The phone +/// read the session's models and modes only from later updates, never from `_acpmux/attach`'s +/// session detail, where acpmux keeps them (`models`, `modes`, `configOptions`). +@MainActor +@Suite struct SessionStateTests { + func json(_ text: String) throws -> JSONValue { + try JSONDecoder().decode(JSONValue.self, from: Data(text.utf8)) + } + + @Test func attachCarriesModelsModesAndOptions() throws { + let detail = try json(""" + {"models": {"currentModelId": "gpt-5.5", "availableModels": [{"modelId": "gpt-5.5", "name": "GPT-5.5"}, {"modelId": "gpt-6-astra", "name": "GPT-6 Astra"}]}, + "modes": {"currentModeId": "ask", "availableModes": [{"id": "ask", "name": "Ask"}, {"id": "auto", "name": "Auto"}]}, + "configOptions": [{"id": "effort", "name": "Reasoning", "category": "thought_level", "currentValue": "medium", "type": "select", + "options": [{"value": "low", "name": "low"}, {"value": "medium", "name": "medium"}]}]} + """) + let events = AcpmuxSessionSource.sessionState(detail) + #expect(events.contains(.modelsAvailable([ACPModelInfo(id: "gpt-5.5", name: "GPT-5.5"), ACPModelInfo(id: "gpt-6-astra", name: "GPT-6 Astra")], + current: "gpt-5.5"))) + #expect(events.contains(.modesAvailable([ACPMode(id: "ask", name: "Ask"), ACPMode(id: "auto", name: "Auto")], current: "ask"))) + #expect(events.contains { if case .update(.configOptions(let options)) = $0 { return options.first?.isEffort == true }; return false }) + } + + /// A harness that offers its model as a config option (category `model`) still gets a menu. + @Test func aModelConfigOptionIsTheModelList() throws { + let detail = try json(""" + {"configOptions": [{"id": "model", "name": "Model", "category": "model", "currentValue": "gpt-5.5", "type": "select", + "options": [{"value": "gpt-5.5", "name": "GPT-5.5"}, {"value": "gpt-6-astra"}]}]} + """) + let events = AcpmuxSessionSource.sessionState(detail) + #expect(events.contains(.modelsAvailable([ACPModelInfo(id: "gpt-5.5", name: "GPT-5.5"), ACPModelInfo(id: "gpt-6-astra", name: "gpt-6-astra")], + current: "gpt-5.5"))) + } + + @Test func nothingKnownIsNothing() throws { + #expect(AcpmuxSessionSource.sessionState(try json(#"{"models": null, "modes": null, "configOptions": null}"#)).isEmpty) + } +} diff --git a/cnx-ios/CnxKit/Tests/CnxLiveTests/TerminalFeedTests.swift b/cnx-ios/CnxKit/Tests/CnxLiveTests/TerminalFeedTests.swift index aafac4e091ae..3c0be9eed208 100644 --- a/cnx-ios/CnxKit/Tests/CnxLiveTests/TerminalFeedTests.swift +++ b/cnx-ios/CnxKit/Tests/CnxLiveTests/TerminalFeedTests.swift @@ -90,7 +90,7 @@ import Testing #expect(finished?.workspaceID == "ws-a") #expect(finished?.workspaceTitle == "cmux") #expect(finished?.tabID == "s1") - #expect(finished?.markdown == "Finished.") + #expect(finished?.markdown == "Finished its turn in the terminal. Open it to see the result.") #expect(finished?.opensTerminal == true) #expect(finished?.createdAt == Date(timeIntervalSince1970: 1000)) diff --git a/cnx-ios/CnxKit/Tests/CnxLiveTests/TerminalFloodTests.swift b/cnx-ios/CnxKit/Tests/CnxLiveTests/TerminalFloodTests.swift index da0455d749d3..321bfabb4a81 100644 --- a/cnx-ios/CnxKit/Tests/CnxLiveTests/TerminalFloodTests.swift +++ b/cnx-ios/CnxKit/Tests/CnxLiveTests/TerminalFloodTests.swift @@ -160,7 +160,7 @@ import Testing Issue.record("a refused read must not load") } catch let error as ArtifactUnavailable { #expect(error.message == "sample.md is outside the folders Studio shares with this phone. " - + "To open it here, add its project folder in cmux on the Mac: Settings › General › Chats › Chat Folders.") + + "To open it here, open a workspace in its project folder on the Mac, or add the folder in cmux on the Mac: Settings › General › Chats › Chat Folders.") } catch { Issue.record("unexpected \(error)") } @@ -174,7 +174,7 @@ import Testing Issue.record("a refused read must not load") } catch let error as ArtifactUnavailable { #expect(error.message == "Studio isn't sharing any folders with this phone yet, so notes.md can't be opened here. " - + "To open it here, add its project folder in cmux on the Mac: Settings › General › Chats › Chat Folders.") + + "To open it here, open a workspace in its project folder on the Mac, or add the folder in cmux on the Mac: Settings › General › Chats › Chat Folders.") } catch { Issue.record("unexpected \(error)") } diff --git a/cnx-ios/CnxKit/Tests/CnxModelTests/ServiceTeamCreationTests.swift b/cnx-ios/CnxKit/Tests/CnxModelTests/ServiceTeamCreationTests.swift new file mode 100644 index 000000000000..655e1ece5d5c --- /dev/null +++ b/cnx-ios/CnxKit/Tests/CnxModelTests/ServiceTeamCreationTests.swift @@ -0,0 +1,42 @@ +import Foundation +import Testing +@testable import CnxModel + +/// Full verify D1 (2026-10-19): Stack refuses team creation from the +/// client, so the phone creates teams through the cmux service. +@Suite struct ServiceTeamCreationTests { + let creation = ServiceTeamCreation(apiBase: URL(string: "https://api.example")!) + + @Test func theRequestGoesToTheServiceWithTheName() throws { + let request = creation.request(accessToken: "tok", name: "fv team") + #expect(request.url?.absoluteString == "https://api.example/v1/teams") + #expect(request.httpMethod == "POST") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer tok") + let body = try #require(request.httpBody.flatMap { try JSONSerialization.jsonObject(with: $0) as? [String: String] }) + #expect(body == ["name": "fv team"]) + } + + @Test func theAnswerIsTheNewTeam() throws { + let team = try creation.team(from: Data(#"{"team":{"id":"t-1","name":"fv team"}}"#.utf8), status: 200) + #expect(team.id == "t-1") + #expect(team.name == "fv team") + } + + @Test func aRefusalIsAnErrorNeverASilentSuccess() { + let unavailable = Data(#"{"error":{"code":"team_creation_unavailable","message":"x"}}"#.utf8) + #expect(throws: ServiceTeamCreation.Failure.unavailable) { try creation.team(from: unavailable, status: 503) } + #expect(throws: ServiceTeamCreation.Failure.failed) { try creation.team(from: Data("{}".utf8), status: 200) } + #expect(throws: ServiceTeamCreation.Failure.failed) { try creation.team(from: Data(), status: 502) } + } +} + +/// Full verify D14: VoiceOver's time matches the row's "now". +@Suite struct RelativeStampSpokenTests { + @Test func underAMinuteAndSlightlyAheadIsNow() { + let now = Date(timeIntervalSince1970: 1_000_000) + let stamp = RelativeStamp(now: now) + #expect(stamp.spoken(for: now.addingTimeInterval(10)) == "now") + #expect(stamp.spoken(for: now.addingTimeInterval(-30)) == "now") + #expect(stamp.spoken(for: now.addingTimeInterval(-300)) != "now") + } +} diff --git a/cnx-ios/PROTOCOL.md b/cnx-ios/PROTOCOL.md index 694b05141051..6f0789ee9e78 100644 --- a/cnx-ios/PROTOCOL.md +++ b/cnx-ios/PROTOCOL.md @@ -71,6 +71,7 @@ echoes the token), | `GET /v1/me/macs` | → `{macs:[{deviceId,name,teamId,online,lastSeenAt,ownerUserId}]}`: the caller's own Macs (registered by the caller, the only Macs v1 lets it dial) in every team the caller still belongs to and in its personal space, with presence from each team's hub. Added in 0.5.0 so a phone finds its Mac whichever team either side has selected; the app polls it (every 4 s while its active team has no online Mac, else every 30 s) and moves to the team of an online Mac. `502 membership_unavailable` when Stack cannot list the caller's teams | | `GET /v1/teams/:teamId/prefs` | → `{color, emoji}` for the caller (per user per team) | | `PUT /v1/teams/:teamId/prefs` | `{color?: "#RRGGBB", emoji?: string\|null}`. `emoji` is the avatar glyph: `null` = automatic (team initial), `""` = none (color only), otherwise exactly one grapheme (letter, digit, symbol or emoji; not whitespace) | +| `POST /v1/teams` | `{name}` (1 to 64 characters, trimmed, no control characters; 3 a minute per user) → `{team:{id,name}}`: creates a Stack team with the caller as creator and first member, using the project's server key (Stack refuses client-side team creation). `503 team_creation_unavailable` when the Worker has no server key for the caller's project; `403`/`502 team_create_failed` when Stack refuses. | | `POST /v1/teams/:teamId/invites` | `{email}` → `{url, sent:true}`: Stack emails a team invitation whose link opens `url` (the configured callback) with the code. Changed from a bare link: without a Stack server key the API can only send the invitation email, not mint a shareable link. Needs the caller's `$invite_members` team permission. | | `GET /v1/ice` | → `{iceServers:[{urls:[...],username?,credential?}], ttl, turn}` from the Cloudflare TURN key (generate-ice-servers), STUN `stun:stun.cloudflare.com:3478` always included (STUN only if TURN is unavailable). `turn` (0.7.0) is `false` when no TURN server could be included: clients must say so (off-LAN WebRTC may then fail) instead of failing silently. `ttl` is 1 h (was 4 h) | | `GET /v1/me/devices` | → `{devices:[{deviceId,kind,name,teamId,createdAt,updatedAt,lastSeenAt,online,push}]}` (0.7.0): every Mac and phone the caller registered, in any team or personal space. `online` is live presence for Macs and `null` for phones; `push` is whether the phone has an APNs token | diff --git a/cnx-ios/SECURITY.md b/cnx-ios/SECURITY.md index 39aae1acfef2..b691d1d8faff 100644 --- a/cnx-ios/SECURITY.md +++ b/cnx-ios/SECURITY.md @@ -93,9 +93,20 @@ allowlist gets a local `forbidden` error line and never reaches the daemon. ## `file`: read-only previews -- **Path checks.** Only absolute paths inside `CNX_FILE_ROOTS` are served. - These are the chat roots from Settings, and there are none by default, which - refuses every read. +- **Path checks.** Only absolute paths inside `CNX_FILE_ROOTS` or one of the + Mac's workspace folders are served. + - `CNX_FILE_ROOTS` are the chat roots from Settings; there are none by default. + - The workspace folders are the folders of the tabs the Mac's daemon lists + at the time of the read (`list-workspaces`, canonicalized): the same + folders the app already shares with phones for agent chats + (`_acpmux/workspace_roots_set`, the agentcwd decision). As acpmux does, a + folder that is `/`, the home folder or any ancestor of it is dropped, so a + tab in `~` never opens `~/.ssh` or `~/Library`. They are read only when a + path is outside `CNX_FILE_ROOTS`; with no daemon answer the read is refused + (full verify D9, 2026-10-19: the Files and Artifacts list showed files the + phone's own chat edited in its workspace folder, and every preview was + refused). This does not widen what a phone can reach: an agent chat the + phone starts there can already read those folders. - The path is canonicalized (symlinks resolved) before the open. - After the open, the descriptor's real path (`F_GETPATH`) is checked again, so a component swapped for a symlink in between cannot escape. diff --git a/cnx-ios/backend/src/env.ts b/cnx-ios/backend/src/env.ts index 30910f33f93e..dd18b51c030b 100644 --- a/cnx-ios/backend/src/env.ts +++ b/cnx-ios/backend/src/env.ts @@ -28,6 +28,8 @@ export interface Env { readonly ICE_LIMIT?: RateLimit readonly INVITE_LIMIT?: RateLimit readonly NOTIFY_LIMIT?: RateLimit + /** Team creations per user (they make Stack teams with the server key). */ + readonly TEAM_LIMIT?: RateLimit // Test only (ENVIRONMENT=test). readonly STACK_TEST_JWKS?: string /** JSON {userId: [teamId]} for primary-project users, {":": [teamId]} for the others; replaces Stack membership calls. */ diff --git a/cnx-ios/backend/src/index.ts b/cnx-ios/backend/src/index.ts index f23f684b3101..c701c165aeee 100644 --- a/cnx-ios/backend/src/index.ts +++ b/cnx-ios/backend/src/index.ts @@ -1,7 +1,7 @@ import { authenticate, bearerOf, primaryProject, stackProjects, type Caller } from "./auth.ts" import type { Env } from "./env.ts" import { iceServers } from "./ice.ts" -import { sendTeamInvite, userTeams } from "./stack.ts" +import { createTeam, sendTeamInvite, userTeams } from "./stack.ts" import { apnsConfig, apnsTopics, clean, sendApns } from "./push.ts" import { openStore, PUSH_KINDS, type Device, type PushKind, type Store } from "./store.ts" import type { Attachment } from "./team-hub.ts" @@ -411,6 +411,23 @@ const routes = async (req: Request, env: Env, ctx: ExecutionContext): Promise 64 || /\p{Cc}/u.test(name)) throw new HttpError(400, "bad_request", "name must be 1 to 64 characters, no control characters") + await limit(env.TEAM_LIMIT, me, "team creations") + const r = await createTeam(env, caller, name) + if (!r.ok) { + if (r.code === "team_creation_unavailable") return fail(503, r.code, "this service can't create teams for your account yet") + return fail(r.status === 401 || r.status === 403 ? 403 : 502, "team_create_failed", `Stack refused the team (${r.code})`) + } + // The caller's cached memberships predate the new team. + teamsCache.delete(`${caller.projectId}\u0000${caller.userId}`) + return json({ team: { id: r.id, name: r.name } }) + } + const team = path.match(/^\/v1\/teams\/([^/]+)\/(macs|prefs|invites|signal|hub)$/) if (!team) return fail(404, "not_found", "no such route") const teamId = decodeURIComponent(team[1]!) diff --git a/cnx-ios/backend/src/stack.ts b/cnx-ios/backend/src/stack.ts index 10b0dace6e2a..4f8e30d86163 100644 --- a/cnx-ios/backend/src/stack.ts +++ b/cnx-ios/backend/src/stack.ts @@ -62,3 +62,31 @@ export const sendTeamInvite = async (env: Env, caller: Caller, teamId: string, e if (res.ok) return { ok: true, url: callback } return { ok: false, status: res.status, code: res.headers.get("x-stack-known-error") ?? "stack_error" } } + +export type CreateTeamResult = { ok: true; id: string; name: string } | { ok: false; status: number; code: string } + +/** + * Creates a Stack team with the caller as its creator (and so its first member). Stack refuses team + * creation from the client API in both projects (`client_team_creation_enabled` is false), so this + * needs the project's server key; without one it says so instead of pretending. + */ +export const createTeam = async (env: Env, caller: Caller, name: string): Promise => { + if (env.ENVIRONMENT === "test") { + // Tests have no server key: the primary project stands for one that has it. + return caller.projectId === primaryProject(env).id + ? { ok: true, id: `team-${crypto.randomUUID()}`, name } + : { ok: false, status: 503, code: "team_creation_unavailable" } + } + if (!usesServerKey(env, caller)) return { ok: false, status: 503, code: "team_creation_unavailable" } + const res = await fetch(`${API}/teams`, { + method: "POST", + headers: headers(env, caller), + body: JSON.stringify({ display_name: name, creator_user_id: caller.userId }), + signal: AbortSignal.timeout(10_000) + }) + // Never echo Stack's body: it can carry user data. + if (!res.ok) return { ok: false, status: res.status, code: res.headers.get("x-stack-known-error") ?? "stack_error" } + const body = (await res.json()) as { id?: unknown; display_name?: unknown } + if (typeof body.id !== "string") return { ok: false, status: 502, code: "stack_error" } + return { ok: true, id: body.id, name: typeof body.display_name === "string" ? body.display_name : name } +} diff --git a/cnx-ios/backend/test/http.test.ts b/cnx-ios/backend/test/http.test.ts index 02e33caf0365..561132c8824b 100644 --- a/cnx-ios/backend/test/http.test.ts +++ b/cnx-ios/backend/test/http.test.ts @@ -4,7 +4,7 @@ import { call, DEV_PROJECT, PROD_PROJECT, register, token } from "./helpers.ts" describe("HTTP API", () => { it("health is public", async () => { const r = await call("GET", "/v1/health", undefined) - expect(r).toMatchObject({ status: 200, json: { ok: true, version: "0.8.0", stackProjects: [DEV_PROJECT, PROD_PROJECT] } }) + expect(r).toMatchObject({ status: 200, json: { ok: true, version: "0.9.0", stackProjects: [DEV_PROJECT, PROD_PROJECT] } }) expect(r.json).toHaveProperty("colo") }) diff --git a/cnx-ios/backend/test/teams.test.ts b/cnx-ios/backend/test/teams.test.ts new file mode 100644 index 000000000000..2c096b3bcf8c --- /dev/null +++ b/cnx-ios/backend/test/teams.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest" +import { call, PROD_PROJECT, token } from "./helpers.ts" + +// Full verify D1 (2026-10-19): the phone created teams with Stack's client API, which both Stack +// projects refuse (client_team_creation_enabled is false), so no team ever appeared. The Worker +// creates the team with the project's server key and makes the caller its creator and member. +describe("POST /v1/teams", () => { + it("creates a team for the caller and returns its id and name", async () => { + const r = await call("POST", "/v1/teams", await token("alice"), { name: " fv team " }) + expect(r.status).toBe(200) + expect(r.json.team).toEqual({ id: expect.stringMatching(/^team-/), name: "fv team" }) + }) + + it("needs a name of at most 64 characters", async () => { + const tok = await token("alice") + expect((await call("POST", "/v1/teams", tok, {})).status).toBe(400) + expect((await call("POST", "/v1/teams", tok, { name: " " })).status).toBe(400) + expect((await call("POST", "/v1/teams", tok, { name: "x".repeat(65) })).status).toBe(400) + expect((await call("POST", "/v1/teams", tok, { name: "a\nb" })).status).toBe(400) + }) + + it("says so when this Worker cannot create teams for the caller's project", async () => { + const r = await call("POST", "/v1/teams", await token("pat", { project: PROD_PROJECT }), { name: "Prod team" }) + expect(r.status).toBe(503) + expect(r.json.error.code).toBe("team_creation_unavailable") + }) + + it("needs a signed-in caller", async () => { + expect((await call("POST", "/v1/teams", undefined, { name: "x" })).status).toBe(401) + }) +}) diff --git a/cnx-ios/backend/wrangler.jsonc b/cnx-ios/backend/wrangler.jsonc index b789e612db40..ed5ef8bfc608 100644 --- a/cnx-ios/backend/wrangler.jsonc +++ b/cnx-ios/backend/wrangler.jsonc @@ -44,7 +44,8 @@ { "name": "USER_LIMIT", "namespace_id": "4101", "simple": { "limit": 300, "period": 60 } }, { "name": "ICE_LIMIT", "namespace_id": "4102", "simple": { "limit": 30, "period": 60 } }, { "name": "INVITE_LIMIT", "namespace_id": "4103", "simple": { "limit": 10, "period": 60 } }, - { "name": "NOTIFY_LIMIT", "namespace_id": "4104", "simple": { "limit": 60, "period": 60 } } + { "name": "NOTIFY_LIMIT", "namespace_id": "4104", "simple": { "limit": 60, "period": 60 } }, + { "name": "TEAM_LIMIT", "namespace_id": "4105", "simple": { "limit": 3, "period": 60 } } ], "hyperdrive": [{ "binding": "HYPERDRIVE", "id": "e4fd2e88cfcd4f00841ddc5b49c3ee06" }] }, @@ -67,7 +68,8 @@ { "name": "USER_LIMIT", "namespace_id": "4201", "simple": { "limit": 300, "period": 60 } }, { "name": "ICE_LIMIT", "namespace_id": "4202", "simple": { "limit": 30, "period": 60 } }, { "name": "INVITE_LIMIT", "namespace_id": "4203", "simple": { "limit": 10, "period": 60 } }, - { "name": "NOTIFY_LIMIT", "namespace_id": "4204", "simple": { "limit": 60, "period": 60 } } + { "name": "NOTIFY_LIMIT", "namespace_id": "4204", "simple": { "limit": 60, "period": 60 } }, + { "name": "TEAM_LIMIT", "namespace_id": "4205", "simple": { "limit": 3, "period": 60 } } ], "hyperdrive": [{ "binding": "HYPERDRIVE", "id": "11158756425f41469964743aab2f6319" }] } diff --git a/cnx-ios/rust/crates/cnx-hostd/src/services.rs b/cnx-ios/rust/crates/cnx-hostd/src/services.rs index c19915393735..ce7c64516d3b 100644 --- a/cnx-ios/rust/crates/cnx-hostd/src/services.rs +++ b/cnx-ios/rust/crates/cnx-hostd/src/services.rs @@ -656,14 +656,76 @@ fn confine_open(_roots: &[PathBuf], _file: &tokio::fs::File) -> Result<(), Refus Ok(()) } +/// The workspace folders a phone may read from: the folders of the tabs the +/// Mac's daemon lists, the same folders the app shares with phones for agent +/// chats (`_acpmux/workspace_roots_set`, agentcwd). A folder that is `/`, the +/// home folder or any ancestor of it is dropped, as acpmux drops it: a tab in +/// `~` must not open `~/.ssh` to a phone. +pub fn shareable_workspace_folders(folders: Vec, home: &Path) -> Vec { + let mut out: Vec = Vec::new(); + for folder in folders { + if !home.starts_with(&folder) && !out.contains(&folder) { + out.push(folder); + } + } + out +} + +/// `path` resolved and spelled as the filesystem stores it (on macOS, `F_GETPATH`: +/// the stored case and Unicode form), as acpmux's `fs_spelling` compares roots, so a +/// tab whose shell spelled home `/users/ME` is still home. None when it can't be opened. +pub fn stored_spelling(path: &Path) -> Option { + let canonical = std::fs::canonicalize(path).ok()?; + #[cfg(target_vendor = "apple")] + { + use std::os::fd::AsRawFd; + let file = std::fs::File::open(&canonical).ok()?; + let mut buffer = vec![0u8; libc::PATH_MAX as usize]; + // SAFETY: F_GETPATH writes at most PATH_MAX bytes into `buffer`, which holds + // PATH_MAX bytes, for a descriptor this function owns. + if unsafe { libc::fcntl(file.as_raw_fd(), libc::F_GETPATH, buffer.as_mut_ptr()) } != -1 { + let end = buffer.iter().position(|&b| b == 0).unwrap_or(buffer.len()); + return Some(PathBuf::from(String::from_utf8_lossy(&buffer[..end]).into_owned())); + } + } + Some(canonical) +} + +/// `CNX_FILE_ROOTS` (the Chat Folders) and the shareable workspace folders. Without a +/// home folder that resolves, no workspace folder is shared (one might be home). +pub fn shared_file_roots(file_roots: &[PathBuf], folders: Vec, home: &Path) -> Vec { + let mut roots = file_roots.to_vec(); + let Some(home) = stored_spelling(home) else { return roots }; + let folders = folders.iter().filter_map(|f| stored_spelling(f)).collect(); + for folder in shareable_workspace_folders(folders, &home) { + if !roots.contains(&folder) { + roots.push(folder); + } + } + roots +} + /// `file`: stream one regular file inside a workspace root, then close -/// with `eof`. +/// with `eof`. The roots are the Chat Folders, then (only when the file is +/// outside them) the Mac's workspace folders as they are now. async fn file(config: &ServiceConfig, args: &Value, sender: &ChannelSender) -> Result<(), Refusal> { let requested = args .get("path") .and_then(Value::as_str) .ok_or_else(|| Refusal::new("bad_request", "missing path"))?; - let resolved = confine(&config.file_roots, Path::new(requested))?; + let (resolved, roots) = match confine(&config.file_roots, Path::new(requested)) { + Ok(resolved) => (resolved, config.file_roots.clone()), + Err(refusal) if refusal.code == "forbidden" => { + // No home folder or no daemon answer: only the Chat Folders (the refusal stands). + let Some(home) = std::env::var_os("HOME").filter(|h| !h.is_empty()).map(PathBuf::from) else { + return Err(refusal); + }; + let folders = daemon_folders(config).await.unwrap_or_default(); + let roots = shared_file_roots(&config.file_roots, folders, &home); + (confine(&roots, Path::new(requested))?, roots) + } + Err(refusal) => return Err(refusal), + }; // Non-blocking, so a FIFO inside a root cannot hold the open; the checks below // refuse anything that is not a regular file. let mut file = tokio::fs::OpenOptions::new() @@ -674,7 +736,7 @@ async fn file(config: &ServiceConfig, args: &Value, sender: &ChannelSender) -> R .map_err(|_| Refusal::new("not_found", "cannot open"))?; // A path component swapped for a symlink between the check and the open would // escape the roots: check again where the open descriptor really points. - confine_open(&config.file_roots, &file)?; + confine_open(&roots, &file)?; let metadata = file.metadata().await.map_err(|_| Refusal::new("not_found", "cannot stat"))?; if !metadata.is_file() { return Err(Refusal::new("bad_request", "not a regular file")); @@ -1175,6 +1237,38 @@ mod tests { assert!(!file.metadata().await.unwrap().is_file()); } + #[test] + fn workspace_folders_share_projects_never_home_or_above() { + // Full verify D9: the phone's chat edits files in its workspace folder, + // which the Mac shares with phones (agentcwd); home and its ancestors never. + let home = Path::new("/Users/me"); + let folders = vec![ + PathBuf::from("/Users/me/code/app"), + PathBuf::from("/Users/me"), + PathBuf::from("/Users"), + PathBuf::from("/"), + PathBuf::from("/tmp/scratch"), + PathBuf::from("/Users/me/code/app"), + ]; + assert_eq!( + shareable_workspace_folders(folders, home), + vec![PathBuf::from("/Users/me/code/app"), PathBuf::from("/tmp/scratch")] + ); + } + + #[test] + fn a_file_in_a_workspace_folder_is_inside_the_shared_roots() { + let project = tempfile::tempdir().unwrap(); + std::fs::write(project.path().join("README.md"), "r").unwrap(); + let home = tempfile::tempdir().unwrap(); + let roots = shared_file_roots(&[], vec![project.path().to_path_buf(), home.path().to_path_buf()], home.path()); + assert!(confine(&roots, &project.path().join("README.md")).is_ok()); + assert_eq!(roots.len(), 1, "home is never a root: {roots:?}"); + // A home that doesn't resolve shares no workspace folder at all. + let gone = home.path().join("missing"); + assert!(shared_file_roots(&[], vec![project.path().to_path_buf()], &gone).is_empty()); + } + #[test] fn confine_keeps_reads_inside_roots() { let root = tempfile::tempdir().unwrap(); diff --git a/cnx-ios/verify/fixes-r1-20261020.md b/cnx-ios/verify/fixes-r1-20261020.md new file mode 100644 index 000000000000..72d5f916ae84 --- /dev/null +++ b/cnx-ios/verify/fixes-r1-20261020.md @@ -0,0 +1,104 @@ +# Fixes round 1: live verification, 2026-10-20 + +Verifies the fixes for `full-verify-app-20261019.md` (D1 to D14) over the real +link: phone app > link > cnx-hostd > daemon. No local socket, mock or +standalone hostd. + +## Build under test + +- Source: `4a31def5f5a` (origin/feat-cnx-ios-fixes-r1 when this run started). + Commits after it (`903b7acd275`, `e96061c4508`, `186b62ff3a8`) are not in + this run. +- Mac rig: fleet job `fe725b381ce18bc8f760c02d`, tag `cnx-e2e-4a31def5`. + It ran hidden (`open -g -j -n`, `verify/parity/rmac.py`) on + mini-cmuxs-mac-mini-2, device id `cb292c76…ae26`. The phone shows it as + "cmux’s Mac mini (2) AE26". +- Phone: Demo configuration, built with Xcode 26.6 on mini-cmux7s-mac-mini + (`ARCHS=arm64`, `SWIFT_ACTIVE_COMPILATION_CONDITIONS=$(inherited) DEBUG CNX_DEMO` + so the packages see DEBUG too). Bundle `dev.cmux.cnx`. +- CnxFFI.xcframework: the FFI key changed from `ccc2097337090383` to + `363f2707310702be`, because cnx-hostd sits in `cnx-ios/rust`. No cache had + either key, so it was built from this SHA on mini-cmux7s. The regenerated + `CnxFFI.swift` is byte-identical to the committed copy. cnx-hostd is only a + dev-dependency of cnx-ffi. +- One headless simulator `cnx-fixr1-1` (iPhone 17 Pro, iOS 26.3) on mini-2, + driven with AXe through `verify/fullverify/fv.py`. Simulator.app was never + opened. +- Driver change: `fv.py launch` takes `FV_ENV` (JSON launch environment, used + for `CNX_FRAMELOG=1`). `fvr.sh` forwards it. + +Evidence (git-ignored): `verify/evidence/2026-10-20/fixes-r1/` +(`shots/*.png`, `video/01…08-*.mp4`, `feed-framelog.jsonl`, `cnx-hostd.log`). + +## Results + +| ID | Result | Evidence | Notes | +| --- | --- | --- | --- | +| D2 Chief loads | **Pass** | `07-chief-open.png`, `08-chief-sent.png`, `19-chief-mac-reply.png`, `video/01`, `video/03` | hostd starts with `CNX_CHIEF_SESSION=cmux-chief-d9e6c81d` and `CNX_CHIEF_SOCKET=…/cmux-chief-d9e6c81d.sock`. Home > Chief opens the real Mac conversation with no spinner. A sent message is read by the Mac. The Mac Chief's own reply ("Chief couldn't reach its model…") arrives over the link. Replies with content are blocked because the rig's Claude gets a 503 from subrouter. | +| D2 unavailable bar | **Pass, with a copy issue** | `10-chief-unavailable.png` | Chief was made unavailable by hiding only the rig's Chief socket. Quitting the owner does not work: the app respawns it within seconds. The bar shows "Chief isn't available". hostd refuses with `cmux-tui session chief: No such file or directory`, but the phone shows "didn't answer (daemon channel closed)." instead of the "isn't running on … Open cmux" copy. The refusal reason does not reach the phone's mapping. | +| D2 Try Again | **Fail** | `14-…`, `15-…`, `16-chief-after-tryagain.png`, `17-chief-after-tryagain-60s.png`, `18-chief-reenter.png`, `video/03` | After the socket was restored, Try Again opened a new `chief` channel at hostd (no refusal) and removed the bar. The open screen then stayed on `conversation.initialLoading` for more than 60 s, with no title and no transcript. Leaving and reopening Chief showed the whole conversation, including the queued message. The likely cause is that the reconnected session does not rebind the conversation the screen already shows. `186b62ff3a8` ("Try Again always ends…") may cover this; this run does not include that commit. | +| D3 | **Pass** | `11-chief-not-sent-alert.png`, `12-chief-outboxed.png`, `13-home-waiting.png`, `18-chief-reenter.png` | Send while unavailable keeps the text and shows "Message Not Sent" with Try Again, Send When Chief Connects and OK. OK keeps the text. Send When Chief Connects moves the message to the outbox, and Home reads "Waiting to send: queued hello fixr1". It was sent and read at 9:11 when Try Again connected. Reopening Chief with a queued message shows a "Message Not Sent Yet" alert with Keep and Delete. The queued message is not drawn in the transcript while it waits. | +| D9 preview | **Pass** | `36-artifacts-list.png`, `37-…`, `38-preview-notes-code.png`, `video/05` | A phone-started Codex chat in workspace `fixproj2` (`~/cnx-fixr1/ws/fixproj`) created `notes.md`. Files and Artifacts > notes.md opens; the code view shows "fixr1". The rendered Markdown view stayed blank for this one-line file (`37-preview-notes.png`), a low-severity issue. | +| D9 refusal | **Pass** | `39-preview-outside-refused.png` | `~/fixr1-outside.txt` (in home, outside every workspace folder) shows "Can't open this file … is outside the folders…". hostd logs `forbidden: path is outside the workspace roots`, and a `workspace-1` tab in `~` did not share home. | +| D4 default | **Pass** | `23-codex-model-list.png`, `23-codex-default-selected.png`, `28-codex-state.png`, `33-turn-done.png` | The chooser reads "Default" (no gpt-6.1-sol). Codex > Default runs a full turn: calc.py gets `mul`, and the reply is "done". No 400. The "model error reads as a sentence" check was not reproducible, because the default no longer fails. | +| D4 model pill | **Fail / open** | `40-composer-expanded.png` | No model pill appears in the expanded composer of a running Codex chat, either with Default or with GPT-6 Astra. The bar has only + and Send. Either the `codex` harness reports no models or modes over acpmux (`store.models` empty), or the pill condition misses it. This needs an owner check against acpmux `session/new` models. | +| D8 | **Pass (tab); "Still opening" not reproduced** | `41-claude-chooser.png`, `42-claude-start.png`, `43-claude-reply.png`, `44-still-opening.png`, `fixr1-tabs-log.txt`, `video/06` | New Agent Chat With… > Claude Code > Start Chat opens a Claude tab at once. A prompt there shows "Retrying (6 of 10, error 502)" because Claude is blocked on mini-2 by subrouter, which is a clear state. Two quick New Agent Chat requests opened two tabs; the first finished before the second arrived, so "Still opening" never showed. The device log has `tab request agent on cmux’s Mac mini (2)`. New Agent Chat With… requests do not log `tab request`. | +| D5 | **Pass, with one entry point missed** | `25-composer-typed.png`, `48-rename-typed.png`, `49-sidebar-create-team-typed.png`, `02-onboarding-team-autocap.png`, `video/08` | Soft-keyboard typing stays as typed in the agent composer ("edit calc.py … mul"), Rename Workspace ("fvrenamed"; the Mac shows `fixproj2fvrenamed`), the sidebar Create team ("fv team") and the Chief composer. **Missed:** the onboarding "Create your team" field (`CnxOnboarding/Setup.swift:455`) has neither `.autocorrectionDisabled()` nor `.textInputAutocapitalization(.never)`. It auto-capitalized "Fv team" and shows QuickType suggestions. | +| D7 | **Pass** | `05-ws-lines.png`, `06-ws-after-connect.png`, `video/02` | The fresh sign-in landed on another Mac (cmux11's, which failed pairing). Tapping Connect on the "cmux’s Mac mini (2) AE26" line moved the link to the rig: hostd logged link up, and the rig's workspaces were listed. | +| D11 | **Pass (not stressed)** | `05-ws-a.png`, `05-ws-lines.png` | Five Mac lines are drawn without overlapping placeholder rows. The loading phase was short on this run. | +| D10 | **Pass (improved)** | `feed-framelog.jsonl`, `video/07-d10-feed-scroll.mp4` | 86 Codex `Stop` events were emitted from the Mac (`cmux agent hook emit`, through the link), plus the real ones. 12 finger flings with `CNX_FRAMELOG=1`: 1940 frames, p50 16.7 ms, p95 16.7 ms, p99 34.6 ms, max 49.8 ms, 29 hitches, 31 dropped, **16.8 hitch ms/s**. The earlier run had 91.3 hitch ms/s, p99 49.2 ms, max 234.5 ms and 47 hitches. Mini-2 load was about 2 to 3 here, against about 5 in the earlier run. | +| D1 | **Open (live)** | `03-onboarding-create-team-result.png`, `50-sidebar-create-team-result.png` | The shared dev Worker does not have `POST /v1/teams`. The phone now says so: "Something went wrong. Please try again." in onboarding, and "Couldn't create the team" in the sidebar. There is no silent success. No separate Worker was deployed: the 1Password CLI is not signed in on this laptop (`op whoami`: not signed in), so the dev Stack server key was not available. What is missing: `wrangler deploy --env dev --name cnx-ios-api-fixr1` with `STACK_SECRET_SERVER_KEY` from 1Password, then both apps pointed at it. | +| D12 | **Pass** | `46-feed-d12-finished.png` | A bare Codex finish reads "Finished its turn in the terminal. Open it to see the result." | +| D13 | **Not confirmed** | `47-home-previews.png` | After a relaunch, the Home rows for the fixproj2 Codex and Claude chats show no preview line, where the newest reply "done" was expected. "You don't have a Cloud Chief yet" still flashes on Home. | +| D14 | **Pass (time label)** | `20-ws-row-now.png` | The new workspace row's label ends in "now", which matches the screen. | +| D6 | Not run | — | The chat with edits was closed by mistake (below); not repeated. | + +## Other findings + +- **Composer refilled after a link drop.** At 04:18:22 hostd's signaling WebSocket reset and the link came back. A Codex chat whose prompt was already sent then had that prompt back in its composer, with Send active, for the rest of the session (`30-acpmux-closed.png`, `33-turn-done.png`). A Send would run it twice. The "acpmux channel closed" toast showed at the same time. +- **Close All Tabs has no confirmation.** The workspace More menu puts "Close All Tabs" at the spot of the More button itself. A double tap closed every tab and so the workspace `fixproj` (operator error, `04b-close-all-tabs-strip.png`). The toast that followed read "Couldn't open an agent chat on Mac." (device log: `tab request refused on Mac: unknown pane`). +- Face ID on the simulator: Allow once falls back to the passcode until Face ID is enrolled (`notifyutil … enrollmentChanged`). After that, `pearl.match` approves. + +## Rig changes and cleanup + +- The rig app was quit and its leftover daemons (cmux-tui, optchat-chief, acpmux) killed. The simulator `cnx-fixr1-1` was shut down and deleted. `~/cnx-fixr1`, the rig's Chief home and App Support folder and `~/fixr1-outside.txt` were removed from mini-2, and `~/cnx-fixr1` from mini-cmux7s. +- `~/cnx-ios-e2e/*`, other agents' rigs (`cnx-e2e-604fc689`) and the other booted simulator on mini-2 were left alone. + +## Second pass, 2026-10-20 + +### Build under test + +- Mac rig: fleet job `8ddc14f818104818f5d51e2e`, tag `cnx-e2e-a553ef2b` + (`a553ef2b477`). `d79ea22ea42` changes nothing on the Mac side. +- Phone: Demo build of `d79ea22ea42`, made with Xcode 26.6 on mini-cmux7s + with the same flags as the first pass. It is built incrementally on the + `a553ef2b477` tree plus the nine files that differ between the two SHAs. +- CnxFFI key `86981cab09c8dd5c`, built from `a553ef2b477`. The regenerated + `CnxFFI.swift` is identical to the committed one. +- Rig: hidden on mini-cmuxs-mac-mini-2 (device `ff7bebfb…0087`), one simulator + `cnx-fixr2-1`. +- Simulator keyboard: after a hardware `axe type`, the simulator's soft + keyboard stayed hidden until `AutomaticMinimizationEnabled` and + `HardwareKeyboardLastSeen` were set to false in that simulator's + `com.apple.keyboard.preferences` and it was rebooted. + +Evidence (git-ignored): `verify/evidence/2026-10-20/fixes-r1-pass2/` +(`shots/`, `video/01…04-*.mp4`, `04-chief-unavailable-ax.json`, `cnx-hostd.log`). + +| Item | Result | Evidence | Notes | +| --- | --- | --- | --- | +| Onboarding "Create your team" (D5) | **Pass** | `shots/02-onboarding-team-typed.png`, `video/01b` | Soft keys typed "fv team" and it stayed as typed. The first pass got "Fv team". | +| Chief refusal copy | **Pass** | `shots/04-chief-unavailable.png` | With the socket hidden, the bar reads "Chief isn't running on cmux’s Mac mini (2). Open cmux on the Mac to start it, then try again." | +| Chief + and record off while unavailable | **Pass** | `04-chief-unavailable-ax.json` | `conversation.composer.plus` and `conversation.composer.record` both report `enabled: false`. | +| Try Again with the screen open (ffbcd54) | **Pass** | `shots/05-before-tryagain.png`, `06-after-tryagain.png`, `video/02` | After the socket was restored, Try Again drew the conversation (title, header) on the same screen: no spinner, and no need to leave. | +| Try Again keeps the draft (d79ea22) | **Pass** | `shots/07-draft-while-unavailable.png`, `08-after-tryagain-draft-kept.png` | Text typed while Chief was unavailable stayed in the composer after Try Again connected. | +| Codex Default (review fix) | **Pass** | `shots/09-chooser-codex-default.png`, `14-turn-done-composer-empty.png` | A turn on Default (GPT-6-Astra) added `mul` to calc.py and answered "Done — …". | +| D9 preview / refusal | **Pass** | `shots/19-preview-calc.png`, `20-preview-outside-refused.png` | calc.py in the workspace folder opens and shows `def mul`. `~/fixr2-outside.txt` gets "Can't open this file". | +| Prompt resend (1b4e275) | **Pass** | `shots/11-turn-running.png`, `12-link-dropped.png`, `13-after-reconnect.png`, `14-…`, `video/03`, `cnx-hostd.log` | Mid-turn I killed only the rig's cnx-hostd with SIGTERM at 05:09:21. The app restarted it, and the link was back at 05:09:23. The turn finished and the composer stayed empty ("Reply to Codex"). The transcript holds the prompt once, and calc.py has one `mul`. | +| Close All Tabs confirmation (d785980a15c) | **Pass, one note** | `shots/22-more-menu.png`, `23-close-all-confirm.png`, `24-after-cancel.png`, `video/04` | More > Close All Tabs opens a popover: "This can't be undone." and "Close All 2 Tabs". It has no visible Cancel row; tapping outside (VoiceOver: "dismiss popup") cancels it. Both tabs stay on the phone and the Mac. | +| D4 model menu | **Pass** | `shots/10-codex-prompt.png`, `15-model-menu.png`, `16-model-switched.png`, `17-turn-after-switch.png` | The expanded composer shows the pill "GPT-6-Astra, Default". The menu lists GPT-6-Astra, GPT-5.6-Sol, GPT-5.6-Terra, GPT-5.6-Luna and GPT-5.5, plus mode (Ask) and Reasoning effort. Picking GPT-5.6-Luna moves the check and the pill. The Mac's Codex rollout for this chat records `gpt-6-astra` for the first turn and `gpt-5.6-luna` for the turns after the switch. | +| D6 diff rows | **Partial** | `shots/21-diff-rows.png` | VoiceOver labels read "line: def add(a, b):" and "added line: def mul(a, b):"; "line 0" is gone. The gutter still has no line numbers. | +| D13 Home preview | **Pass** | `shots/25-home-preview.png` | The proj2 row reads "Codex, 10:15 PM, done", the newest reply. | +| D1 | Not touched | — | As instructed: no Worker and no keys. | + +Cleanup: the rig app was quit and its daemons killed. The simulator was deleted. `~/cnx-fixr2`, the rig's Chief home, its App Support folder and `~/fixr2-outside.txt` were removed from mini-2, and `~/cnx-fixr2` from mini-cmux7s. diff --git a/cnx-ios/verify/fullverify/fv.py b/cnx-ios/verify/fullverify/fv.py new file mode 100644 index 000000000000..415c696129d1 --- /dev/null +++ b/cnx-ios/verify/fullverify/fv.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""Fresh-verifier phone driver (verify/full-verify-app-20261019.md). Runs ON the rig mini +(mini-cmuxs-mac-mini-2) next to rmac.py and the e2e modules, one command per call: + + fv.py setup create/boot the one simulator (cnx-fv-1) + fv.py install [--fresh] install the Demo app (fresh: uninstall + keychain reset) + fv.py launch | terminate + fv.py ls [needle] visible AX elements (kind, label, id, value, frame) + fv.py tap (--label L|--ident I|--contains C|--prefix P) [--kind K] [--idx N] + fv.py press/long/tapxy X Y [SECONDS] + fv.py swipe X1 Y1 X2 Y2 [DUR] + fv.py type TEXT software keyboard taps (falls back to axe type) + fv.py shot NAME screenshot to evidence/shots/NAME.png + fv.py rec start NAME | rec stop simctl recordVideo (detached, pid file) + fv.py ocr [needle] + +Every phone action is a real HID tap or typed text through AXe; nothing reaches the +app any other way. +""" +import json +import os +import signal +import subprocess +import sys +import time + +BASE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(BASE, "e2e")) +os.environ["PATH"] = os.path.join(BASE, "bin") + ":/opt/homebrew/bin:" + os.environ.get("PATH", "") +os.environ.setdefault("DEVELOPER_DIR", "/Applications/Xcode.app/Contents/Developer") +from sim import Sim # noqa: E402 + +STATE = os.path.join(BASE, "fv-state.json") +EV = os.path.join(BASE, "evidence") +APP = os.path.join(BASE, "app/CnxApp.app") +BUNDLE = "dev.cmux.cnx" +SIM_NAME = os.environ.get("FV_SIM", "cnx-fixr1-1") + + +def st(): + return json.load(open(STATE)) if os.path.exists(STATE) else {} + + +def sim(): + return Sim(st()["udid"], os.path.join(EV, "shots")) + + +def run(*a, **k): + return subprocess.run(list(a), capture_output=True, text=True, **k) + + +def query(args): + q, kind, idx = {}, None, 0 + it = iter(args) + for a in it: + if a == "--label": q["label"] = next(it) + elif a == "--ident": q["ident"] = next(it) + elif a == "--contains": q["contains"] = next(it) + elif a == "--prefix": q["prefix"] = next(it) + elif a == "--kind": kind = next(it) + elif a == "--idx": idx = int(next(it)) + if kind: + q["kind"] = kind + return q, idx + + +def visible(e): + x, y, w, h = e.frame + return w > 0 and h > 0 and x + w > 0 and x < 402 and y + h > 0 and y < 874 + + +def main(): + cmd, args = sys.argv[1], sys.argv[2:] + if cmd == "setup": + lst = json.loads(run("xcrun", "simctl", "list", "devices", "-j").stdout) + udid = next((d["udid"] for devs in lst["devices"].values() for d in devs if d["name"] == SIM_NAME), None) + if not udid: + udid = run("xcrun", "simctl", "create", SIM_NAME, "com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro", + "com.apple.CoreSimulator.SimRuntime.iOS-26-3").stdout.strip() + run("xcrun", "simctl", "boot", udid) + run("xcrun", "simctl", "bootstatus", udid, "-b", timeout=600) + s = st(); s["udid"] = udid + json.dump(s, open(STATE, "w"), indent=2) + print(udid) + elif cmd == "install": + s = sim() + if "--fresh" in args: + s.terminate(BUNDLE) + run("xcrun", "simctl", "uninstall", s.udid, BUNDLE) + run("xcrun", "simctl", "keychain", s.udid, "reset") + r = run("xcrun", "simctl", "install", s.udid, APP) + print("installed" if r.returncode == 0 else r.stderr) + elif cmd == "launch": + # FV_ENV: launch variables for the app as JSON (e.g. {"CNX_FRAMELOG": "1"}). + print(sim().launch(BUNDLE, env=json.loads(os.environ.get("FV_ENV", "{}")))) + elif cmd == "terminate": + sim().terminate(BUNDLE) + elif cmd == "ls": + needle = (args[0].lower() if args else None) + for e in sim().tree(): + if not visible(e) or e.kind in ("Application",) or (e.kind == "Group" and not e.label and not e.ident) or e.kind == "Slider": + continue + line = f"{e.kind:<12} {e.label!r:<44.44} id={e.ident or ''} v={str(e.value or '')[:40]!r} {tuple(round(v) for v in e.frame)}" + if needle is None or needle in line.lower(): + print(line) + elif cmd == "tap": + q, idx = query(args) + s = sim() + hits = [e for e in s.find(**q) if visible(e)] + if len(hits) <= idx: + print(f"NOT FOUND {q}"); sys.exit(2) + s.tap_el(hits[idx]); print(f"tapped {hits[idx]!r}") + elif cmd == "wait": + q, idx = query(args) + e = sim().wait(timeout=float(os.environ.get("FV_TIMEOUT", "20")), **q) + print(repr(e) if e else f"TIMEOUT {q}"); sys.exit(0 if e else 2) + elif cmd == "tapxy": + sim().tap(float(args[0]), float(args[1])); print("ok") + elif cmd == "press": + sim().press(float(args[0]), float(args[1])); print("ok") + elif cmd == "long": + sim().long_press(float(args[0]), float(args[1]), float(args[2]) if len(args) > 2 else 1.0); print("ok") + elif cmd == "swipe": + a = [float(v) for v in args] + sim().swipe(a[0], a[1], a[2], a[3], a[4] if len(a) > 4 else 0.4); print("ok") + elif cmd == "drag": + a = [float(v) for v in args] + sim().drag(a[0], a[1], a[2], a[3], a[4] if len(a) > 4 else 1.0); print("ok") + elif cmd == "type": + sim().softtype(" ".join(args)); print("typed") + elif cmd == "hwtype": + run("axe", "type", " ".join(args), "--udid", sim().udid); print("typed (hw)") + elif cmd == "key": + run("axe", "key", args[0], "--udid", sim().udid); print("key") + elif cmd == "shot": + print(sim().shot(args[0])) + elif cmd == "rec": + os.makedirs(os.path.join(EV, "video"), exist_ok=True) + pidf = os.path.join(BASE, "rec.pid") + if args[0] == "start": + path = os.path.join(EV, "video", args[1] + ".mp4") + log = open(os.path.join(BASE, "rec.log"), "w") + p = subprocess.Popen(["xcrun", "simctl", "io", st()["udid"], "recordVideo", "--codec", "h264", "--force", path], + stdout=log, stderr=log, start_new_session=True) + open(pidf, "w").write(f"{p.pid} {path}") + print(f"recording {path} pid {p.pid}") + else: + if os.path.exists(pidf): + pid, path = open(pidf).read().split(" ", 1) + try: + os.kill(int(pid), signal.SIGINT) + except ProcessLookupError: + pass + for _ in range(100): + try: + os.kill(int(pid), 0) + except ProcessLookupError: + break + time.sleep(0.2) + os.remove(pidf) + print(path, os.path.getsize(path) if os.path.exists(path) else "missing") + elif cmd == "ocr": + needle = args[0].lower() if args else None + for o in sim().ocr(): + if needle is None or needle in o[4].lower(): + print(f"{o[0]:.0f},{o[1]:.0f} {o[2]:.0f}x{o[3]:.0f} {o[4]}") + else: + sys.exit(f"unknown {cmd}") + + +if __name__ == "__main__": + main() diff --git a/cnx-ios/verify/fullverify/fvr.sh b/cnx-ios/verify/fullverify/fvr.sh new file mode 100755 index 000000000000..0aab582b72f7 --- /dev/null +++ b/cnx-ios/verify/fullverify/fvr.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Laptop-side shim: run fv.py commands on the rig mini. usage: fvr.sh CMD ARGS... ; fvr.sh sh 'shell on mini' +# FV_ENV (JSON) is passed to `fv.py launch` as the app's launch environment. +host=${FV_HOST:-mini-cmuxs-mac-mini-2} +dir=${FV_DIR:-cnx-fixr1} +if [[ "$1" == sh ]]; then shift; exec ssh -o BatchMode=yes "$host" "cd ~/$dir && export PATH=\$HOME/$dir/bin:/opt/homebrew/bin:\$PATH DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer; $*"; fi +q=$(printf '%q ' "$@") +fe=$(printf '%q' "${FV_ENV:-"{}"}") +exec ssh -o BatchMode=yes "$host" "cd ~/$dir && FV_ENV=$fe python3 fv.py $q"