mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-07 01:21:37 +00:00
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:
co-authored by
Claude Code
HAPI
weishu
parent
5d4dc058a5
commit
b3795f7aef
@@ -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 {
|
||||
|
||||
+70
-12
@@ -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
|
||||
|
||||
+163
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+349
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user