feat(android): remember new-session launch defaults per machine and agent (#2138)

* feat(android): remember new-session launch defaults per machine and agent

Port of the web launch-preference persistence
(`hapi:newSession:launchSettings:v1:<machine>:<agent>`): 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 <noreply@anthropic.com>

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(android): preserve launch selections across machine heartbeats

---------

Co-authored-by: HAPI <noreply@hapi.run>
Co-authored-by: weishu <twsxtd@gmail.com>
This commit is contained in:
admin
2026-10-26 14:34:42 +08:00
committed by GitHub
co-authored by Claude Code HAPI weishu
parent 5d4dc058a5
commit b3795f7aef
5 changed files with 685 additions and 12 deletions
@@ -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:
@@ -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<String, List<String>> = emptyMap(),
/**
* Machine id → agent flavor → remembered launch defaults (web
* `hapi:newSession:launchSettings:v1:<machine>:<agent>`). Written only
* after a successful spawn; stale shapes degrade field-by-field in
* [NewSessionLogic.resolvePreferredLaunchSettings], never crash.
*/
val launchSettings: Map<String, Map<String, PreferredLaunchSettings>> = 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 {
@@ -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<Pair<String, String>, List<MachineDirectoryEntry>>? = 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<String> =
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
@@ -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<String, Map<String, PreferredLaunchSettings>> = 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()
}
}
}
@@ -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)
}
}