From b3795f7aef18d6bb0fcaf6c1bfeeff8246cf78f1 Mon Sep 17 00:00:00 2001 From: admin <33443359+miuiadmin@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:34:42 +0800 Subject: [PATCH] feat(android): remember new-session launch defaults per machine and agent (#2138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(android): remember new-session launch defaults per machine and agent Port of the web launch-preference persistence (`hapi:newSession:launchSettings:v1::`): model, effort, reasoning effort and — for shared-permission-mode flavors only — the permission mode of a successfully spawned session are remembered per (machineId, agentFlavor) dimension and prefill the next create form for that dimension (fresh screen, agent switch, machine switch). - Storage: NewSessionPrefsData gains a nested `launchSettings` map inside the existing `hapi_prefs` DataStore blob; legacy blobs without the field decode to empty defaults, corrupt shapes keep the pre-existing whole-blob default degrade (DirectoryExpandPrefs contract). - Capture happens at spawn success only (web saves on every create-success path); grok renders the native select but its mode is deliberately not remembered (web `usesSharedPermissionModeState`). - Restore: resolvePreferredLaunchSettings validates claude's static model and effort lists, drops `xhigh` from opencode's reasoning efforts and coerces a removed permission mode to `default` — never a stale toggle; codex's dynamic model passes through for the existing settled-catalog reconciliation. - A restored in-progress draft outranks the remembered defaults until the user switches agent or machine (web `preserveRestoredDraftRef`). - Machine switches now also reset effort/reasoning effort/shared mode to the new dimension's remembered values (or sentinels), matching the web restore effect that re-runs on machineId change. Divergences from web: no `cursorSelectedBase` (no Android picker yet); agy's static model presets are not re-validated on Android (no v1 picker, the stored value is always `auto`). Tests: prefs round-trip/cold-start, legacy-blob tolerance, corrupt blob and corrupt-entry fallback (real temp-file DataStore, pure JVM); capture gating and resolve coercion logic; VM prefill per dimension, machine and agent switching, spawn-success capture, and draft precedence. Co-Authored-By: Claude Code via [HAPI](https://hapi.run) Co-Authored-By: HAPI * fix(android): preserve launch selections across machine heartbeats --------- Co-authored-by: HAPI Co-authored-by: weishu --- .../feature/newsession/NewSessionForm.kt | 74 ++++ .../feature/newsession/NewSessionPrefs.kt | 29 ++ .../feature/newsession/NewSessionViewModel.kt | 82 +++- .../feature/newsession/NewSessionPrefsTest.kt | 163 ++++++++ .../newsession/NewSessionViewModelTest.kt | 349 ++++++++++++++++++ 5 files changed, 685 insertions(+), 12 deletions(-) create mode 100644 android/app/src/test/kotlin/app/hapi/companion/feature/newsession/NewSessionPrefsTest.kt diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionForm.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionForm.kt index cec31227..dec06da8 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionForm.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionForm.kt @@ -65,6 +65,80 @@ object NewSessionLogic { fun usesNativePermissionSelect(flavor: String?): Boolean = flavor == "claude" || flavor == "grok" || usesCodexFamilyPermissionModes(flavor) + /** + * Flavors whose create-form permission choice lives in the single + * `permissionMode` field that launch preferences remember (web + * `usesSharedPermissionModeState`). Grok renders the same native select + * but its Auto option depends on a machine capability, so its mode stays + * out of the remembered launch settings on both platforms. + */ + fun usesSharedPermissionModeState(flavor: String?): Boolean = + usesNativePermissionSelect(flavor) && flavor != "grok" + + // ------------------------------------------------- launch preferences -- + + /** + * Capture of the form's launch defaults at spawn success (web builds + * `preferredLaunchSettings` inline in `handleCreate`): the model/effort + * fields verbatim, the permission mode only for shared-mode flavors. + */ + fun launchPreferenceFrom(form: NewSessionForm): PreferredLaunchSettings = PreferredLaunchSettings( + model = form.model, + effort = form.effort, + modelReasoningEffort = form.modelReasoningEffort, + permissionMode = form.permissionMode.takeIf { usesSharedPermissionModeState(form.agent) }, + ) + + /** + * Validation pass over a stored preference before prefill (web + * `resolvePreferredLaunchSettings`): + * - claude's static lists reject stale ids → sentinels; every other + * flavor passes the stored model through for the live-catalog + * reconciliation ([reconcileCodexSelections] is codex's settled-catalog + * equivalent; the remaining flavors have no v1 picker, so their value + * is always `'auto'` anyway); + * - opencode's reasoning effort validates against the static list minus + * `xhigh` (web drops that row for opencode); + * - a stored permission mode that is absent from the flavor's launch + * catalog falls back to `'default'` — never to a stale toggle; non + * shared-mode flavors resolve to null (not remembered). + */ + fun resolvePreferredLaunchSettings( + agent: String?, + stored: PreferredLaunchSettings?, + ): PreferredLaunchSettings { + val model = if (agent == "claude") { + stored?.model?.takeIf { it in NewSessionCatalogs.CLAUDE_MODELS.map(OptionItem::value) } ?: "auto" + } else { + stored?.model ?: "auto" + } + val effort = if (agent == "claude") { + stored?.effort?.takeIf { it in NewSessionCatalogs.CLAUDE_EFFORTS.map(OptionItem::value) } ?: "auto" + } else { + stored?.effort ?: "auto" + } + val modelReasoningEffort = if (agent == "opencode") { + stored?.modelReasoningEffort + ?.takeIf { it in NewSessionCatalogs.CODEX_REASONING_EFFORTS.map(OptionItem::value) - "xhigh" } + ?: "default" + } else { + stored?.modelReasoningEffort ?: "default" + } + val permissionMode = if (usesSharedPermissionModeState(agent)) { + stored?.permissionMode + ?.takeIf { mode -> PermissionModes.forLaunch(agent).any { it.wireId == mode } } + ?: "default" + } else { + null + } + return PreferredLaunchSettings( + model = model, + effort = effort, + modelReasoningEffort = modelReasoningEffort, + permissionMode = permissionMode, + ) + } + /** * Exact spawn body (`POST /api/machines/:id/spawn`), field-for-field port * of the web `handleCreate` mapping: diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionPrefs.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionPrefs.kt index ad17c07f..f8c4977a 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionPrefs.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionPrefs.kt @@ -22,6 +22,35 @@ data class NewSessionPrefsData( val lastMachineId: String? = null, /** Machine id → most-recent-first spawn directories (cap [NewSessionLogic.MAX_RECENT_PATHS]). */ val recentPaths: Map> = emptyMap(), + /** + * Machine id → agent flavor → remembered launch defaults (web + * `hapi:newSession:launchSettings:v1::`). Written only + * after a successful spawn; stale shapes degrade field-by-field in + * [NewSessionLogic.resolvePreferredLaunchSettings], never crash. + */ + val launchSettings: Map> = emptyMap(), +) + +/** + * Remembered launch defaults for one `(machineId, agentFlavor)` pair — the + * DataStore port of web `PreferredLaunchSettings` + * (`web/src/components/NewSession/preferences.ts`). Field defaults mirror the + * [NewSessionForm] sentinels; `cursorSelectedBase` is deliberately absent + * (no Android picker — web-only field). + */ +@Serializable +data class PreferredLaunchSettings( + /** `'auto'` = no explicit model. */ + val model: String = "auto", + /** Claude launch effort; `'auto'` = omit. */ + val effort: String = "auto", + /** Codex-family reasoning effort; `'default'` = omit. */ + val modelReasoningEffort: String = "default", + /** + * Only written for shared-permission-mode flavors + * ([NewSessionLogic.usesSharedPermissionModeState]); null otherwise. + */ + val permissionMode: String? = null, ) interface NewSessionPrefs { diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionViewModel.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionViewModel.kt index 917b9ec8..bf10b34e 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionViewModel.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionViewModel.kt @@ -3,9 +3,12 @@ package app.hapi.companion.feature.newsession import app.hapi.companion.feature.directorybrowser.RemoteDirectoryBrowserController import app.hapi.companion.feature.directorybrowser.RemoteDirectoryPath import app.hapi.companion.feature.newsession.NewSessionLogic.buildSpawnRequest +import app.hapi.companion.feature.newsession.NewSessionLogic.launchPreferenceFrom import app.hapi.companion.feature.newsession.NewSessionLogic.parentQuery import app.hapi.companion.feature.newsession.NewSessionLogic.pushRecent +import app.hapi.companion.feature.newsession.NewSessionLogic.resolvePreferredLaunchSettings import app.hapi.companion.feature.newsession.NewSessionLogic.usesNativePermissionSelect +import app.hapi.companion.feature.newsession.NewSessionLogic.usesSharedPermissionModeState import app.hapi.companion.feature.newsession.NewSessionLogic.worktreeNameError import app.hapi.data.api.ApiError import app.hapi.data.store.MachineListStore @@ -212,6 +215,12 @@ class NewSessionViewModel( private var spawnInFlight = false private var directoryRequestVersion = 0L + /** + * A restored draft outranks the remembered launch defaults until the + * user switches agent or machine (web `preserveRestoredDraftRef`). + */ + private var draftRestored = false + /** Parent-listing cache: retyping within the same parent re-filters locally. */ private var cachedListing: Pair, List>? = null @@ -220,16 +229,19 @@ class NewSessionViewModel( prefsData.value = runCatching { prefs.readPrefs() }.getOrDefault(NewSessionPrefsData()) val draft = runCatching { prefs.readDraft() }.getOrNull()?.let(NewSessionLogic::sanitizeDraft) var initial = draft ?: NewSessionForm() + draftRestored = draft != null if (initialMachineId != null) { if (draft?.machineId != null && draft.machineId != initialMachineId) { // Draft belongs to another machine (web `newSessionDraftMatchesMachine`). initial = NewSessionForm(machineId = initialMachineId) + draftRestored = false runCatching { prefs.clearDraft() } } else { initial = initial.copy(machineId = initialMachineId) } } form.value = initial + if (!draftRestored) applyPreferredLaunchSettings(initial.machineId, initial.agent) refreshAgentAvailability() refreshCodexModelsIfNeeded() // A restored directory should probe existence but not pop the @@ -252,7 +264,7 @@ class NewSessionViewModel( if (machines.isEmpty()) return@collect val current = form.value.machineId if (current != null && machines.any { it.id == current }) { - if (form.value.directory.isBlank()) applyMachineSelection(current, resetDirectory = true) + if (form.value.directory.isBlank()) initializeMachineDirectory(current) return@collect } val lastUsed = prefsData.value.lastMachineId @@ -329,6 +341,7 @@ class NewSessionViewModel( fun setMachine(machineId: String) { if (machineId == form.value.machineId) return + draftRestored = false applyMachineSelection(machineId, resetDirectory = true) } @@ -361,7 +374,10 @@ class NewSessionViewModel( fun setAgent(agent: String) { if (agent == form.value.agent) return // Web parity: switching agents resets every agent-dependent field - // (yolo is a cross-flavor preference and survives). + // (yolo is a cross-flavor preference and survives), then the + // remembered launch defaults for the new (machine, agent) dimension + // prefill the reset fields. + draftRestored = false form.update { it.copy( agent = agent, @@ -374,6 +390,7 @@ class NewSessionViewModel( copilotAgentMode = "interactive", ) } + applyPreferredLaunchSettings(form.value.machineId, agent) refreshCodexModelsIfNeeded() refreshGrokAutoSupportIfNeeded() } @@ -469,7 +486,7 @@ class NewSessionViewModel( val request = buildSpawnRequest(current, codexFastTierVisible(current)) val result = gateway.spawn(machineId, request) if (result.type == "success" && result.sessionId != null) { - persistOnSuccess(machineId, directory) + persistOnSuccess(machineId, directory, current) _spawned.tryEmit(result.sessionId!!) } else { spawnError.value = when (result.code) { @@ -511,6 +528,34 @@ class NewSessionViewModel( private fun recentPathsFor(machineId: String?): List = machineId?.let { prefsData.value.recentPaths[it] }.orEmpty() + /** + * Launch-preference restore (web restore effect on agent/machineId + * change): refills the agent-dependent defaults from the remembered + * `(machine, agent)` entry — or the sentinels when nothing is stored. + * The codex model/effort are validated again once the live catalog + * settles ([reconcileCodexSelections]); grok keeps its form field out of + * the remembered settings entirely. + */ + private fun applyPreferredLaunchSettings(machineId: String?, agent: String) { + if (machineId == null) return + val resolved = resolvePreferredLaunchSettings( + agent, + prefsData.value.launchSettings[machineId]?.get(agent), + ) + form.update { current -> + current.copy( + model = resolved.model, + effort = resolved.effort, + modelReasoningEffort = resolved.modelReasoningEffort, + permissionMode = if (usesSharedPermissionModeState(agent)) { + resolved.permissionMode ?: "default" + } else { + current.permissionMode + }, + ) + } + } + private fun resolveDefaultDirectory(machineId: String, fallback: String) { defaultDirectoryJob?.cancel() val recent = recentPathsFor(machineId) @@ -544,22 +589,28 @@ class NewSessionViewModel( clearGrokAutoSupport() // The seeded recent path is a pick, not typing — no dropdown. suppressSuggestions = true - val machine = machineStore.machines.value.firstOrNull { it.id == machineId } - val fallback = machine?.let(RemoteDirectoryPath::browseRoots)?.firstOrNull().orEmpty() form.update { current -> current.copy( machineId = machineId, model = "auto", - directory = if (resetDirectory) { - fallback - } else { - current.directory - }, ) } - if (resetDirectory) resolveDefaultDirectory(machineId, fallback) + // Remembered launch defaults follow the machine dimension too (web + // restore effect re-runs on machineId change) — unless a restored + // draft still owns the form. + if (!draftRestored) applyPreferredLaunchSettings(machineId, form.value.agent) refreshAgentAvailability(force = true) refreshCodexModelsIfNeeded() + if (resetDirectory) initializeMachineDirectory(machineId) else scheduleDirectoryWork() + } + + /** Roster updates may seed a directory without replacing current launch choices. */ + private fun initializeMachineDirectory(machineId: String) { + val machine = machineStore.machines.value.firstOrNull { it.id == machineId } + val fallback = machine?.let(RemoteDirectoryPath::browseRoots)?.firstOrNull().orEmpty() + suppressSuggestions = true + form.update { it.copy(directory = fallback) } + resolveDefaultDirectory(machineId, fallback) scheduleDirectoryWork() } @@ -796,11 +847,18 @@ class NewSessionViewModel( private fun worktreeNameBlocks(current: NewSessionForm): Boolean = current.sessionType == SESSION_TYPE_WORKTREE && worktreeNameError(current.worktreeName) != null - private suspend fun persistOnSuccess(machineId: String, directory: String) { + private suspend fun persistOnSuccess(machineId: String, directory: String, created: NewSessionForm) { val updated = prefsData.value.let { stored -> stored.copy( lastMachineId = machineId, recentPaths = stored.recentPaths + (machineId to pushRecent(stored.recentPaths[machineId].orEmpty(), directory)), + // Remember this (machine, agent) dimension's launch defaults + // for the next prefill (web `savePreferredLaunchSettings` on + // every create-success path). + launchSettings = stored.launchSettings + ( + machineId to ((stored.launchSettings[machineId] ?: emptyMap()) + + (created.agent to launchPreferenceFrom(created))) + ), ) } prefsData.value = updated diff --git a/android/app/src/test/kotlin/app/hapi/companion/feature/newsession/NewSessionPrefsTest.kt b/android/app/src/test/kotlin/app/hapi/companion/feature/newsession/NewSessionPrefsTest.kt new file mode 100644 index 00000000..0664926b --- /dev/null +++ b/android/app/src/test/kotlin/app/hapi/companion/feature/newsession/NewSessionPrefsTest.kt @@ -0,0 +1,163 @@ +package app.hapi.companion.feature.newsession + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.edit +import app.hapi.protocol.wire.HapiJson +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.rules.TemporaryFolder + +/** + * Launch-preference storage round-trips against a real temp-file Preferences + * DataStore (pure JVM), including the cold-start reload, legacy-blob + * tolerance (stored JSON without the new field) and corrupt-blob fallback — + * the same degradation contract as the roster/theme prefs. + */ +class NewSessionPrefsTest { + + @get:Rule + val tmp = TemporaryFolder() + + private fun newScope(): CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private fun storeFile(name: String): File = File(tmp.root, "$name.preferences_pb") + + private fun sampleLaunchSettings(): Map> = mapOf( + "m1" to mapOf( + "claude" to PreferredLaunchSettings(model = "opus", effort = "high", permissionMode = "plan"), + "codex" to PreferredLaunchSettings( + model = "gpt-5.2-codex", + modelReasoningEffort = "high", + permissionMode = "read-only", + ), + ), + "m2" to mapOf( + "claude" to PreferredLaunchSettings(), // dimension isolation: m2 has no opinion yet + ), + ) + + @Test + fun `launch settings round-trip per machine and agent and survive a cold start`() = runTest { + val file = storeFile("launch") + val data = NewSessionPrefsData( + lastMachineId = "m1", + recentPaths = mapOf("m1" to listOf("/data/github/hapi")), + launchSettings = sampleLaunchSettings(), + ) + + val firstScope = newScope() + try { + val prefs = DataStoreNewSessionPrefs(PreferenceDataStoreFactory.create(scope = firstScope) { file }) + prefs.writePrefs(data) + assertEquals(data, prefs.readPrefs()) + } finally { + firstScope.coroutineContext[Job]?.cancelAndJoin() + } + + // "Second process": a fresh DataStore over the same file. + val secondScope = newScope() + try { + val prefs = DataStoreNewSessionPrefs(PreferenceDataStoreFactory.create(scope = secondScope) { file }) + assertEquals(data, prefs.readPrefs()) + } finally { + secondScope.coroutineContext[Job]?.cancelAndJoin() + } + } + + @Test + fun `legacy blob without launch settings decodes with empty defaults`() = runTest { + val scope = newScope() + try { + val dataStore = PreferenceDataStoreFactory.create(scope = scope) { storeFile("legacy") } + // The pre-launch-prefs shape: no `launchSettings` field at all. + dataStore.edit { + it[DataStoreNewSessionPrefs.PREFS_KEY] = + """{"lastMachineId":"m1","recentPaths":{"m1":["/data/github/hapi"]}}""" + } + val prefs = DataStoreNewSessionPrefs(dataStore) + assertEquals("m1", prefs.readPrefs().lastMachineId) + assertEquals(mapOf("m1" to listOf("/data/github/hapi")), prefs.readPrefs().recentPaths) + assertEquals(emptyMap(), prefs.readPrefs().launchSettings) + } finally { + scope.coroutineContext[Job]?.cancelAndJoin() + } + } + + @Test + fun `corrupt prefs blob falls back to empty defaults`() = runTest { + val scope = newScope() + try { + val dataStore = PreferenceDataStoreFactory.create(scope = scope) { storeFile("junk") } + // Garbage JSON, and a launchSettings shape that is not a map of + // maps — both must degrade to the empty defaults, never crash. + dataStore.edit { + it[DataStoreNewSessionPrefs.PREFS_KEY] = """{"lastMachineId":"m1","launchSettings":"nope"}""" + } + assertEquals(NewSessionPrefsData(), DataStoreNewSessionPrefs(dataStore).readPrefs()) + + dataStore.edit { + it[DataStoreNewSessionPrefs.PREFS_KEY] = "not json {{{" + } + assertEquals(NewSessionPrefsData(), DataStoreNewSessionPrefs(dataStore).readPrefs()) + } finally { + scope.coroutineContext[Job]?.cancelAndJoin() + } + } + + @Test + fun `corrupt launch entry inside the map falls back to empty defaults`() = runTest { + val scope = newScope() + try { + val dataStore = PreferenceDataStoreFactory.create(scope = scope) { storeFile("entry") } + // A single malformed entry (model is a number) breaks the whole + // blob decode → full defaults, matching the DirectoryExpandPrefs + // degrade contract. + dataStore.edit { + it[DataStoreNewSessionPrefs.PREFS_KEY] = + """{"lastMachineId":"m1","launchSettings":{"m1":{"claude":{"model":42}}}}""" + } + assertEquals(NewSessionPrefsData(), DataStoreNewSessionPrefs(dataStore).readPrefs()) + } finally { + scope.coroutineContext[Job]?.cancelAndJoin() + } + } + + @Test + fun `round-tripped launch entries decode with all four fields intact`() = runTest { + val scope = newScope() + try { + val dataStore = PreferenceDataStoreFactory.create(scope = scope) { storeFile("fields") } + val prefs = DataStoreNewSessionPrefs(dataStore) + prefs.writePrefs(NewSessionPrefsData(launchSettings = sampleLaunchSettings())) + val claude = prefs.readPrefs().launchSettings.getValue("m1").getValue("claude") + assertEquals("opus", claude.model) + assertEquals("high", claude.effort) + assertEquals("default", claude.modelReasoningEffort) + assertEquals("plan", claude.permissionMode) + + // The serialized shape keeps permissionMode absent (not + // "default") for flavors that do not remember it — null + // round-trips as null, and sentinel-only entries stay compact + // (HapiJson encodes no defaults). + val json = HapiJson.encodeToString( + PreferredLaunchSettings.serializer(), + PreferredLaunchSettings(model = "sonnet"), + ) + assertEquals("""{"model":"sonnet"}""", json) + assertEquals( + PreferredLaunchSettings(model = "sonnet"), + HapiJson.decodeFromString(PreferredLaunchSettings.serializer(), json), + ) + } finally { + scope.coroutineContext[Job]?.cancelAndJoin() + } + } +} diff --git a/android/app/src/test/kotlin/app/hapi/companion/feature/newsession/NewSessionViewModelTest.kt b/android/app/src/test/kotlin/app/hapi/companion/feature/newsession/NewSessionViewModelTest.kt index 86f4444c..676f68aa 100644 --- a/android/app/src/test/kotlin/app/hapi/companion/feature/newsession/NewSessionViewModelTest.kt +++ b/android/app/src/test/kotlin/app/hapi/companion/feature/newsession/NewSessionViewModelTest.kt @@ -418,6 +418,112 @@ class NewSessionLogicTest { assertEquals(listOf("default", "read-only", "autoReview", "yolo"), app.hapi.protocol.catalog.PermissionModes.forLaunch("codex").map { it.wireId }) assertEquals("safe-yolo", NewSessionLogic.sanitizeDraft(NewSessionForm(agent = "kimi", permissionMode = "safe-yolo")).permissionMode) } + + @Test + fun `launch preference capture maps the form and gates permissionMode on shared-mode flavors`() { + // Claude is a shared-mode flavor: the picked mode is remembered. + assertEquals( + PreferredLaunchSettings(model = "opus", effort = "high", permissionMode = "plan"), + NewSessionLogic.launchPreferenceFrom( + NewSessionForm(agent = "claude", model = "opus", effort = "high", permissionMode = "plan"), + ), + ) + // Grok renders the same native select but is NOT remembered. + assertEquals( + PreferredLaunchSettings(), + NewSessionLogic.launchPreferenceFrom( + NewSessionForm(agent = "grok", model = "auto", permissionMode = "plan"), + ), + ) + // YOLO-toggle flavors (cursor) never carry a remembered mode either. + assertEquals( + PreferredLaunchSettings(), + NewSessionLogic.launchPreferenceFrom( + NewSessionForm(agent = "cursor", yolo = true, permissionMode = "plan"), + ), + ) + // Codex remembers its model/reasoning pair. + assertEquals( + PreferredLaunchSettings(model = "gpt-5.2-codex", modelReasoningEffort = "high", permissionMode = "read-only"), + NewSessionLogic.launchPreferenceFrom( + NewSessionForm( + agent = "codex", + model = "gpt-5.2-codex", + modelReasoningEffort = "high", + permissionMode = "read-only", + ), + ), + ) + } + + @Test + fun `resolve preferred launch settings validates static lists and coerces stale values`() { + // Nothing stored → sentinels (shared-mode flavors resolve 'default'). + assertEquals( + PreferredLaunchSettings(permissionMode = "default"), + NewSessionLogic.resolvePreferredLaunchSettings("claude", null), + ) + + // Claude: valid static ids survive; stale ids fall back to sentinels. + assertEquals( + PreferredLaunchSettings(model = "opus", effort = "high", permissionMode = "plan"), + NewSessionLogic.resolvePreferredLaunchSettings( + "claude", + PreferredLaunchSettings(model = "opus", effort = "high", permissionMode = "plan"), + ), + ) + assertEquals( + PreferredLaunchSettings(permissionMode = "default"), + NewSessionLogic.resolvePreferredLaunchSettings( + "claude", + PreferredLaunchSettings(model = "removed-model", effort = "ultra", permissionMode = "bogus"), + ), + ) + + // Codex: the model comes from a dynamic machine catalog, so it passes + // through untouched (the settled-catalog reconciliation re-validates); + // a permission mode outside the launch list coerces to default. + assertEquals( + PreferredLaunchSettings(model = "gpt-5.2-codex", modelReasoningEffort = "high", permissionMode = "read-only"), + NewSessionLogic.resolvePreferredLaunchSettings( + "codex", + PreferredLaunchSettings(model = "gpt-5.2-codex", modelReasoningEffort = "high", permissionMode = "read-only"), + ), + ) + assertEquals( + PreferredLaunchSettings(model = "gpt-5.2-codex", permissionMode = "default"), + NewSessionLogic.resolvePreferredLaunchSettings( + "codex", + PreferredLaunchSettings(model = "gpt-5.2-codex", permissionMode = "safe-yolo"), + ), + ) + + // Opencode's reasoning effort drops `xhigh` (web parity); opencode is a + // shared-mode flavor, so its mode resolves to the default. + assertEquals( + PreferredLaunchSettings(modelReasoningEffort = "default", permissionMode = "default"), + NewSessionLogic.resolvePreferredLaunchSettings( + "opencode", + PreferredLaunchSettings(modelReasoningEffort = "xhigh"), + ), + ) + + // Grok / cursor never resolve a remembered mode, even if one leaked in. + assertEquals( + PreferredLaunchSettings(), + NewSessionLogic.resolvePreferredLaunchSettings( + "grok", + PreferredLaunchSettings(permissionMode = "plan"), + ), + ) + assertEquals( + PreferredLaunchSettings(), + NewSessionLogic.resolvePreferredLaunchSettings( + "cursor", + PreferredLaunchSettings(permissionMode = "yolo"), + ), + ) + } } // ---------------------------------------------------------- view model flow -- @@ -1026,4 +1132,247 @@ class NewSessionViewModelTest { assertTrue(toggle is PermissionUi.YoloToggle) assertEquals("Yolo", (toggle as PermissionUi.YoloToggle).nativeModeLabel) } + + @Test + fun `spawn success remembers launch settings for the machine and agent dimension`() = runTest { + val gateway = FakeGateway() + val prefs = FakePrefs() + val vm = buildViewModel(gateway, FakeMachineStore(listOf(machine("m1"))), prefs) + advanceUntilIdle() + + vm.setModel("opus") + vm.setEffort("high") + vm.setPermissionMode("plan") + vm.setDirectory("/repo") + advanceUntilIdle() + vm.create() + advanceUntilIdle() + + assertEquals( + mapOf( + "m1" to mapOf( + "claude" to PreferredLaunchSettings(model = "opus", effort = "high", permissionMode = "plan"), + ), + ), + prefs.stored.launchSettings, + ) + } + + @Test + fun `remembered launch settings prefill a fresh form`() = runTest { + val prefs = FakePrefs( + stored = NewSessionPrefsData( + launchSettings = mapOf( + "m1" to mapOf( + "claude" to PreferredLaunchSettings(model = "opus", effort = "high", permissionMode = "plan"), + ), + ), + ), + ) + val vm = buildViewModel(FakeGateway(), FakeMachineStore(listOf(machine("m1"))), prefs) + advanceUntilIdle() + + val form = vm.uiState.value.form + assertEquals("m1", form.machineId) + assertEquals("opus", form.model) + assertEquals("high", form.effort) + assertEquals("plan", form.permissionMode) + } + + @Test + fun `same machine heartbeat preserves manual launch choices while directory is blank`() = runTest { + val machines = FakeMachineStore(listOf(machine("m1"))) + val prefs = FakePrefs(stored = NewSessionPrefsData(launchSettings = mapOf( + "m1" to mapOf("claude" to PreferredLaunchSettings( + model = "opus", effort = "high", permissionMode = "bypassPermissions", + )), + ))) + val vm = buildViewModel(FakeGateway(), machines, prefs) + advanceUntilIdle() + assertEquals("opus", vm.uiState.value.form.model) + vm.setModel("sonnet") + vm.setEffort("low") + vm.setPermissionMode("plan") + advanceUntilIdle() + val edited = vm.uiState.value.form + assertEquals("", edited.directory) + + machines.backing.value = listOf(machine("m1").copy(activeAt = 2)) + advanceUntilIdle() + assertEquals(edited, vm.uiState.value.form) + + // A later directory hint may fill the blank without restoring defaults. + machines.backing.value = listOf(machine("m1", homeDir = "/home/dev")) + advanceUntilIdle() + assertEquals(edited.copy(directory = "/home/dev"), vm.uiState.value.form) + } + + @Test + fun `same machine heartbeat keeps codex reasoning effort and a stricter permission mode`() = runTest { + val machines = FakeMachineStore(listOf(machine("m1"))) + val gateway = FakeGateway().apply { + codexResult = CodexModelsResponse(success = true, models = listOf( + CodexModelSummary( + id = "codex-model", displayName = "Codex", isDefault = true, + supportedReasoningEfforts = listOf("low", "high"), serviceTiers = listOf("standard"), + ), + )) + } + val prefs = FakePrefs(stored = NewSessionPrefsData(launchSettings = mapOf( + "m1" to mapOf("codex" to PreferredLaunchSettings( + model = "codex-model", modelReasoningEffort = "high", permissionMode = "yolo", + )), + ))) + val vm = buildViewModel(gateway, machines, prefs) + advanceUntilIdle() + vm.setAgent("codex") + advanceUntilIdle() + assertEquals("yolo", vm.uiState.value.form.permissionMode) + vm.setModel("auto") + vm.setModelReasoningEffort("low") + vm.setPermissionMode("read-only") + advanceUntilIdle() + val edited = vm.uiState.value.form + assertEquals("", edited.directory) + + machines.backing.value = listOf(machine("m1").copy(activeAt = 2)) + advanceUntilIdle() + assertEquals(edited, vm.uiState.value.form) + } + + @Test + fun `initial machine selection restores launch defaults before seeding its directory`() = runTest { + val prefs = FakePrefs(stored = NewSessionPrefsData(launchSettings = mapOf( + "m1" to mapOf("claude" to PreferredLaunchSettings(model = "opus", effort = "high", permissionMode = "plan")), + ))) + val vm = buildViewModel( + FakeGateway(), FakeMachineStore(listOf(machine("m1", homeDir = "/home/dev"))), prefs, + initialMachineId = "m1", + ) + advanceUntilIdle() + assertEquals("/home/dev", vm.uiState.value.form.directory) + assertEquals("opus", vm.uiState.value.form.model) + assertEquals("high", vm.uiState.value.form.effort) + assertEquals("plan", vm.uiState.value.form.permissionMode) + } + + @Test + fun `a restored draft with a blank directory keeps its launch choices`() = runTest { + val draft = NewSessionForm( + machineId = "m1", model = "sonnet", effort = "low", permissionMode = "plan", + ) + val prefs = FakePrefs(draft = draft) + val vm = buildViewModel( + FakeGateway(), FakeMachineStore(listOf(machine("m1", homeDir = "/home/dev"))), prefs, + ) + advanceUntilIdle() + assertEquals(draft.copy(directory = "/home/dev"), vm.uiState.value.form) + } + + @Test + fun `switching machine or agent re-applies each dimension's remembered defaults`() = runTest { + val gateway = FakeGateway().apply { + // Catalog contains the remembered codex model, so the settled + // catalog keeps it (an empty catalog would reconcile to auto). + codexResult = CodexModelsResponse( + success = true, + models = listOf( + CodexModelSummary( + id = "gpt-5.2-codex", + displayName = "GPT-5.2 Codex", + isDefault = true, + supportedReasoningEfforts = listOf("low", "medium", "high"), + serviceTiers = listOf("standard"), + ), + ), + ) + } + val prefs = FakePrefs( + stored = NewSessionPrefsData( + launchSettings = mapOf( + "m1" to mapOf( + "claude" to PreferredLaunchSettings(model = "opus", effort = "high", permissionMode = "plan"), + "codex" to PreferredLaunchSettings( + model = "gpt-5.2-codex", + modelReasoningEffort = "high", + permissionMode = "read-only", + ), + ), + ), + ), + ) + val vm = buildViewModel(gateway, FakeMachineStore(listOf(machine("m1"), machine("m2"))), prefs) + advanceUntilIdle() + + // Fresh form, m1 preselected → the m1×claude defaults prefill. + assertEquals("opus", vm.uiState.value.form.model) + assertEquals("high", vm.uiState.value.form.effort) + assertEquals("plan", vm.uiState.value.form.permissionMode) + + // Agent dimension: codex on m1 recalls its own model/effort/mode. + vm.setAgent("codex") + advanceUntilIdle() + val codexForm = vm.uiState.value.form + assertEquals("gpt-5.2-codex", codexForm.model) + assertEquals("high", codexForm.modelReasoningEffort) + assertEquals("read-only", codexForm.permissionMode) + assertEquals("auto", codexForm.effort) + + // Machine dimension: m2 has nothing remembered → sentinels. + vm.setMachine("m2") + advanceUntilIdle() + val m2Form = vm.uiState.value.form + assertEquals("auto", m2Form.model) + assertEquals("default", m2Form.modelReasoningEffort) + assertEquals("default", m2Form.permissionMode) + + // Switching back to m1 recalls the m1×codex defaults again. + vm.setMachine("m1") + advanceUntilIdle() + assertEquals("gpt-5.2-codex", vm.uiState.value.form.model) + assertEquals("read-only", vm.uiState.value.form.permissionMode) + + // Grok is a native-select flavor but never remembers a mode: even a + // leaked stored entry leaves the reset default in place. + vm.setAgent("grok") + advanceUntilIdle() + assertEquals("auto", vm.uiState.value.form.model) + assertEquals("default", vm.uiState.value.form.permissionMode) + } + + @Test + fun `restored draft wins over remembered launch settings until the user switches`() = runTest { + val prefs = FakePrefs( + stored = NewSessionPrefsData( + launchSettings = mapOf( + "m1" to mapOf( + "claude" to PreferredLaunchSettings(model = "opus", effort = "high", permissionMode = "plan"), + ), + ), + ), + draft = NewSessionForm( + machineId = "m1", + directory = "/wip", + agent = "claude", + model = "sonnet", + effort = "low", + permissionMode = "acceptEdits", + ), + ) + val vm = buildViewModel(FakeGateway(), FakeMachineStore(listOf(machine("m1"))), prefs) + advanceUntilIdle() + + // The draft's in-progress selections are not clobbered by prefs. + val form = vm.uiState.value.form + assertEquals("sonnet", form.model) + assertEquals("low", form.effort) + assertEquals("acceptEdits", form.permissionMode) + + // First user agent switch drops the draft protection → sentinels + // (nothing remembered for m1×grok), not the draft's stale values. + vm.setAgent("grok") + advanceUntilIdle() + assertEquals("auto", vm.uiState.value.form.model) + assertEquals("default", vm.uiState.value.form.permissionMode) + } }