Let a session choose how hard it thinks
Output is about an eighth of what a session costs and thinking is nearly all of it -- prose is ~1.5% of output tokens, measured over 27,015 requests of this account's own transcripts -- so the level is the largest saving available short of shortening the conversation itself. Shaped like the working directory rather than like the model: the CLI's only two setting control requests are `set_model` and `set_permission_mode` (checked against the 2.1.258 binary), so `--effort` is read when the process launches and cannot be asked of a running one. `set_session_effort` records the level and stops the process; the next message or Start launches one that has it. That is also why the picker is in the session settings dialog beside Move, and not on the bar beside the model and the mode, which take effect mid-turn. `None` is a level in its own right -- the CLI's own default -- so the picker can return to it, and a blank is normalized to it at the boundary rather than stored as a level the CLI would reject. Offered only where it means something: `DriverKind::takes_effort` reports the capability and the phone leaves the row out entirely, rather than the session-type branch this app does not have anywhere else. A llama session would otherwise get a control whose only effect is stopping its process. Verified on the emulator against the sandbox's fake CLI: the picker sets it, the server reports it, and an echo session's dialog is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e4f0935f98
commit
1ff662c7c3
8 files changed
+329
-6
No files matched your search
@@ -147,12 +147,26 @@ turn.
|
|||||||
|
|
||||||
Spawn: `claude -p --verbose --input-format stream-json --output-format
|
Spawn: `claude -p --verbose --input-format stream-json --output-format
|
||||||
stream-json --permission-mode <mode>` in the chosen working directory, plus
|
stream-json --permission-mode <mode>` in the chosen working directory, plus
|
||||||
`--model`. Wire-format notes are pinned against CLI 2.1.237 in
|
`--model` and, where one has been chosen, `--effort`. Wire-format notes are
|
||||||
`session/claude.rs`'s module doc: permissions need the hidden
|
pinned against CLI 2.1.237 in `session/claude.rs`'s module doc: permissions
|
||||||
`--permission-prompt-tool stdio` flag, AskUserQuestion answers ride
|
need the hidden `--permission-prompt-tool stdio` flag, AskUserQuestion answers
|
||||||
`updatedInput.answers` keyed by question text, and `set_model`/`interrupt`
|
ride `updatedInput.answers` keyed by question text, and `set_model`/`interrupt`
|
||||||
are control requests.
|
are control requests.
|
||||||
|
|
||||||
|
**The thinking level is settled at launch** (added 2026-09-04, because it is
|
||||||
|
the largest saving available on a long session: output is about an eighth of
|
||||||
|
what a session costs and thinking is the bulk of output, against the ~1.5% that
|
||||||
|
is prose). The CLI's only two setting control requests are `set_model` and
|
||||||
|
`set_permission_mode` -- checked against the 2.1.258 binary -- so there is no
|
||||||
|
way to ask a running process to think differently. `set_session_effort` is
|
||||||
|
therefore shaped like `set_session_cwd` rather than like `set_session_model`:
|
||||||
|
it records the level and **stops the process**, and the next message or Start
|
||||||
|
launches one that has it. It lives in the session settings dialog beside the
|
||||||
|
working directory for that reason, not on the session bar beside the model and
|
||||||
|
the mode, which do take effect mid-turn. `None` is a level in its own right --
|
||||||
|
the CLI's own default -- so the picker can return to it; a level this app named
|
||||||
|
as the default instead would be this app choosing one.
|
||||||
|
|
||||||
**`--resume` only ever runs when nothing else has that session open.** That
|
**`--resume` only ever runs when nothing else has that session open.** That
|
||||||
is the rule behind the import refusal, the single `ClaudeDriver::launch`
|
is the rule behind the import refusal, the single `ClaudeDriver::launch`
|
||||||
entry point, and the `Exited` correction below; two CLIs on one session file
|
entry point, and the `Exited` correction below; two CLIs on one session file
|
||||||
|
|||||||
@@ -142,6 +142,20 @@ data class SessionSummary(
|
|||||||
val keepsOwnTranscript: Boolean,
|
val keepsOwnTranscript: Boolean,
|
||||||
/** How much the session asks before acting; null when it was never set. */
|
/** How much the session asks before acting; null when it was never set. */
|
||||||
val permissionMode: String?,
|
val permissionMode: String?,
|
||||||
|
/**
|
||||||
|
* How hard the model thinks, or null for the CLI's own default.
|
||||||
|
*
|
||||||
|
* Null is a level somebody can choose, not only one to start in -- see [EFFORT_LEVELS]. It is
|
||||||
|
* reported rather than assumed for the same reason [permissionMode] is.
|
||||||
|
*/
|
||||||
|
val effort: String?,
|
||||||
|
/**
|
||||||
|
* Whether a thinking level does anything here -- a Claude CLI session, not a llama or echo one.
|
||||||
|
*
|
||||||
|
* Asked of the server rather than worked out from the provider's name, because this is a
|
||||||
|
* property of the driver's *kind* and the phone only has the name.
|
||||||
|
*/
|
||||||
|
val takesEffort: Boolean,
|
||||||
/**
|
/**
|
||||||
* Whether this continues a session the machine already had, which changes what deleting means.
|
* Whether this continues a session the machine already had, which changes what deleting means.
|
||||||
*/
|
*/
|
||||||
@@ -203,6 +217,8 @@ private fun parseSession(session: JSONObject) =
|
|||||||
title = session.getString("title"),
|
title = session.getString("title"),
|
||||||
model = session.optString("model").ifEmpty { null },
|
model = session.optString("model").ifEmpty { null },
|
||||||
permissionMode = session.optString("permissionMode").ifEmpty { null },
|
permissionMode = session.optString("permissionMode").ifEmpty { null },
|
||||||
|
effort = session.optString("effort").ifEmpty { null },
|
||||||
|
takesEffort = session.optBoolean("takesEffort", false),
|
||||||
imported = session.optBoolean("imported", false),
|
imported = session.optBoolean("imported", false),
|
||||||
notify = session.optBoolean("notify", true),
|
notify = session.optBoolean("notify", true),
|
||||||
cwd = session.optString("cwd").ifEmpty { null },
|
cwd = session.optString("cwd").ifEmpty { null },
|
||||||
@@ -992,6 +1008,35 @@ fun setSessionModel(settings: ServerSettings, sessionId: String, model: String)
|
|||||||
*/
|
*/
|
||||||
val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPermissions", "plan")
|
val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPermissions", "plan")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How hard the model thinks, as `claude --effort` takes them, cheapest first.
|
||||||
|
*
|
||||||
|
* Not offered alongside the model and the permission mode on the session's own bar, because it does
|
||||||
|
* not behave like them: the CLI has a control request for those two and none for this (checked
|
||||||
|
* against 2.1.258), so a level is settled when the process is launched. Changing it therefore stops
|
||||||
|
* the process, which is what the working directory beside it in this dialog does, and why it is
|
||||||
|
* here rather than on a bar whose other controls take effect mid-turn.
|
||||||
|
*/
|
||||||
|
val EFFORT_LEVELS = listOf("low", "medium", "high", "xhigh", "max")
|
||||||
|
|
||||||
|
/** What the picker shows, and sends as null, for a session that has chosen no level. */
|
||||||
|
const val DEFAULT_EFFORT = "default"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records how hard a session thinks and **stops its process**, since the level is read when the
|
||||||
|
* process is launched. The next message, or Start, runs one that has it.
|
||||||
|
*
|
||||||
|
* [level] is null for the CLI's own default.
|
||||||
|
*/
|
||||||
|
fun setSessionEffort(settings: ServerSettings, sessionId: String, level: String?) {
|
||||||
|
requestFromServer(
|
||||||
|
settings,
|
||||||
|
"/sessions/$sessionId/effort",
|
||||||
|
method = "POST",
|
||||||
|
jsonBody = JSONObject().put("effort", level ?: JSONObject.NULL).toString(),
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
/** Switches how much a running session asks before acting, also in place. */
|
/** Switches how much a running session asks before acting, also in place. */
|
||||||
fun setSessionPermissionMode(settings: ServerSettings, sessionId: String, mode: String) {
|
fun setSessionPermissionMode(settings: ServerSettings, sessionId: String, mode: String) {
|
||||||
requestFromServer(
|
requestFromServer(
|
||||||
|
|||||||
@@ -1846,6 +1846,8 @@ fun SessionScreen(
|
|||||||
settings = settings,
|
settings = settings,
|
||||||
sessionId = summary.id,
|
sessionId = summary.id,
|
||||||
title = title,
|
title = title,
|
||||||
|
effort = summary.effort.takeIf { summary.takesEffort },
|
||||||
|
takesEffort = summary.takesEffort,
|
||||||
cachedBytes = cachedBytes,
|
cachedBytes = cachedBytes,
|
||||||
// The purge finishes before the epoch moves, because the relaunched opening effect
|
// The purge finishes before the epoch moves, because the relaunched opening effect
|
||||||
// reads the same directory and would otherwise draw what is about to be deleted. The
|
// reads the same directory and would otherwise draw what is about to be deleted. The
|
||||||
@@ -2249,7 +2251,7 @@ private const val ONE_TAP_MS = 250L
|
|||||||
* session is set to without spending a second line on saying it.
|
* session is set to without spending a second line on saying it.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun PickerButton(current: String, options: List<String>, onPick: (String) -> Unit) {
|
fun PickerButton(current: String, options: List<String>, onPick: (String) -> Unit) {
|
||||||
var open by remember { mutableStateOf(false) }
|
var open by remember { mutableStateOf(false) }
|
||||||
// When an outside touch last closed the menu.
|
// When an outside touch last closed the menu.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -54,6 +54,16 @@ fun SessionSettingsDialog(
|
|||||||
*/
|
*/
|
||||||
title: String,
|
title: String,
|
||||||
onRenamed: (String) -> Unit,
|
onRenamed: (String) -> Unit,
|
||||||
|
/**
|
||||||
|
* How hard the model thinks, as the session reports it, or null for the CLI's own default.
|
||||||
|
*
|
||||||
|
* Taken from the row this dialog was opened over rather than fetched, because unlike the
|
||||||
|
* notification switch there is nothing else that changes it: the level is this app's to set and
|
||||||
|
* the server does not resolve it into something else.
|
||||||
|
*/
|
||||||
|
effort: String?,
|
||||||
|
/** Whether a level does anything here; the row is left out entirely where it does not. */
|
||||||
|
takesEffort: Boolean,
|
||||||
/**
|
/**
|
||||||
* What this phone is holding of the conversation, or null while that is being measured -- see
|
* What this phone is holding of the conversation, or null while that is being measured -- see
|
||||||
* the Reload row below, which is what would discard it.
|
* the Reload row below, which is what would discard it.
|
||||||
@@ -69,6 +79,8 @@ fun SessionSettingsDialog(
|
|||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var name by remember(sessionId) { mutableStateOf(title) }
|
var name by remember(sessionId) { mutableStateOf(title) }
|
||||||
|
var level by remember(sessionId) { mutableStateOf(effort) }
|
||||||
|
var effortError by remember { mutableStateOf<String?>(null) }
|
||||||
var saving by remember { mutableStateOf(false) }
|
var saving by remember { mutableStateOf(false) }
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
// Null until the server has been asked. The row this dialog was opened over is a snapshot of
|
// Null until the server has been asked. The row this dialog was opened over is a snapshot of
|
||||||
@@ -125,6 +137,26 @@ fun SessionSettingsDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chooses a thinking level, which ends the process the old level was launched with.
|
||||||
|
*
|
||||||
|
* Put back if the request is refused, for the reason the notification switch below gives: a
|
||||||
|
* control that stays where it was put after a refusal is stating something untrue.
|
||||||
|
*/
|
||||||
|
fun setEffort(chosen: String?) {
|
||||||
|
val was = level
|
||||||
|
level = chosen
|
||||||
|
effortError = null
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) { setSessionEffort(settings, sessionId, chosen) }
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
level = was
|
||||||
|
effortError = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Moved optimistically so the switch answers the finger that moved it, and put back if the
|
// Moved optimistically so the switch answers the finger that moved it, and put back if the
|
||||||
// request is refused -- a switch that waits for a round trip reads as broken on a slow tunnel,
|
// request is refused -- a switch that waits for a round trip reads as broken on a slow tunnel,
|
||||||
// and one that stays moved after a refusal lies.
|
// and one that stays moved after a refusal lies.
|
||||||
@@ -257,6 +289,44 @@ fun SessionSettingsDialog(
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Left out rather than disabled, the one place this dialog does that: a disabled
|
||||||
|
// control teaches what the thing can do, and a llama session cannot do this at all
|
||||||
|
// -- the row would be teaching something false about it.
|
||||||
|
if (takesEffort) {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text("Thinking", modifier = Modifier.weight(1f))
|
||||||
|
PickerButton(
|
||||||
|
current = level ?: DEFAULT_EFFORT,
|
||||||
|
// The level the CLI picks for itself is in the list as well as in the
|
||||||
|
// button, so leaving a level is not a one-way trip -- the same
|
||||||
|
// correction the model picker carries.
|
||||||
|
options = listOf(DEFAULT_EFFORT) + EFFORT_LEVELS,
|
||||||
|
onPick = { chosen ->
|
||||||
|
setEffort(chosen.takeIf { it != DEFAULT_EFFORT })
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// What it costs, said where it is about to be pressed, like Move above: the
|
||||||
|
// CLI reads the level when it launches and has no control request for
|
||||||
|
// changing one.
|
||||||
|
Text(
|
||||||
|
"Changing this stops the session's process. It starts again with the " +
|
||||||
|
"next message, or with Start.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
effortError?.let {
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
|||||||
@@ -199,6 +199,24 @@ impl DriverKind {
|
|||||||
Self::Echo | Self::LlamaCpp => false,
|
Self::Echo | Self::LlamaCpp => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a thinking level means anything to this kind, so the phone can
|
||||||
|
/// offer the control only where it does something.
|
||||||
|
///
|
||||||
|
/// Reported from here rather than decided on the phone, and asked of the
|
||||||
|
/// *kind* rather than branched on: the alternative is the session-type
|
||||||
|
/// `if` this app does not have anywhere else. `--effort` is the Claude
|
||||||
|
/// CLI's; a llama session's sampling is `params`, and echo does not think.
|
||||||
|
///
|
||||||
|
/// It matters more than a control that would simply do nothing, because
|
||||||
|
/// choosing a level stops the process -- so on a session that cannot use
|
||||||
|
/// one it is a button whose only effect is the cost.
|
||||||
|
pub fn takes_effort(self) -> bool {
|
||||||
|
match self {
|
||||||
|
Self::ClaudeCli => true,
|
||||||
|
Self::Echo | Self::LlamaCpp => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -233,6 +251,17 @@ pub struct SessionConfig {
|
|||||||
/// the CLI stays the one authority on which modes exist.
|
/// the CLI stays the one authority on which modes exist.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub permission_mode: Option<String>,
|
pub permission_mode: Option<String>,
|
||||||
|
/// How hard the model thinks, passed straight to `--effort`. A string for
|
||||||
|
/// the same reason `permission_mode` is: the CLI owns which levels exist.
|
||||||
|
///
|
||||||
|
/// Unlike the model and the mode, there is no control request that changes
|
||||||
|
/// one -- checked against 2.1.258, whose only two are `set_model` and
|
||||||
|
/// `set_permission_mode` -- so this is settled at launch and `None` means
|
||||||
|
/// whatever the CLI's own default is. That is a state the phone has to be
|
||||||
|
/// able to *choose*, not just start in, which is why it is an option
|
||||||
|
/// rather than a level with a default written here.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub effort: Option<String>,
|
||||||
/// Settings the driver interprets, chosen at spawn.
|
/// Settings the driver interprets, chosen at spawn.
|
||||||
///
|
///
|
||||||
/// Deliberately untyped: what a temperature or a context size means is the
|
/// Deliberately untyped: what a temperature or a context size means is the
|
||||||
@@ -441,6 +470,7 @@ mod tests {
|
|||||||
model: None,
|
model: None,
|
||||||
cwd: None,
|
cwd: None,
|
||||||
permission_mode: None,
|
permission_mode: None,
|
||||||
|
effort: None,
|
||||||
params: BTreeMap::new(),
|
params: BTreeMap::new(),
|
||||||
notify: true,
|
notify: true,
|
||||||
throwaway: false,
|
throwaway: false,
|
||||||
|
|||||||
@@ -42,6 +42,8 @@
|
|||||||
//! which starts again in the new one
|
//! which starts again in the new one
|
||||||
//! POST /sessions/{id}/model {model}
|
//! POST /sessions/{id}/model {model}
|
||||||
//! POST /sessions/{id}/permission-mode {permissionMode}
|
//! POST /sessions/{id}/permission-mode {permissionMode}
|
||||||
|
//! POST /sessions/{id}/effort {effort} -- null for the CLI's default;
|
||||||
|
//! settled at launch, so this stops the process
|
||||||
//! POST /sessions/{id}/command {text} -- /compact, /clear, /rename x, or the dialect's own
|
//! POST /sessions/{id}/command {text} -- /compact, /clear, /rename x, or the dialect's own
|
||||||
//! (starts the process first if it has exited)
|
//! (starts the process first if it has exited)
|
||||||
//! POST /sessions/{id}/compact
|
//! POST /sessions/{id}/compact
|
||||||
@@ -134,6 +136,7 @@ pub fn router(manager: Arc<SessionManager>) -> Router {
|
|||||||
.route("/sessions/{id}/cwd", post(set_cwd))
|
.route("/sessions/{id}/cwd", post(set_cwd))
|
||||||
.route("/sessions/{id}/model", post(set_model))
|
.route("/sessions/{id}/model", post(set_model))
|
||||||
.route("/sessions/{id}/permission-mode", post(set_permission_mode))
|
.route("/sessions/{id}/permission-mode", post(set_permission_mode))
|
||||||
|
.route("/sessions/{id}/effort", post(set_effort))
|
||||||
.route("/sessions/{id}/notify", post(set_notify))
|
.route("/sessions/{id}/notify", post(set_notify))
|
||||||
.route("/notifications", get(notifications))
|
.route("/notifications", get(notifications))
|
||||||
.route("/sessions/{id}/compact", post(compact))
|
.route("/sessions/{id}/compact", post(compact))
|
||||||
@@ -644,6 +647,8 @@ struct SpawnRequest {
|
|||||||
cwd: Option<PathBuf>,
|
cwd: Option<PathBuf>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
permission_mode: Option<String>,
|
permission_mode: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
effort: Option<String>,
|
||||||
/// Whatever the chosen driver understands -- llama.cpp's context size and
|
/// Whatever the chosen driver understands -- llama.cpp's context size and
|
||||||
/// sampling. Opaque here on purpose: see `SessionConfig::params`.
|
/// sampling. Opaque here on purpose: see `SessionConfig::params`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -831,6 +836,7 @@ async fn start_import(
|
|||||||
model: body.model.clone(),
|
model: body.model.clone(),
|
||||||
cwd: None,
|
cwd: None,
|
||||||
permission_mode: body.permission_mode.clone(),
|
permission_mode: body.permission_mode.clone(),
|
||||||
|
effort: body.effort.clone(),
|
||||||
params: std::collections::BTreeMap::new(),
|
params: std::collections::BTreeMap::new(),
|
||||||
import: Some(session.clone()),
|
import: Some(session.clone()),
|
||||||
};
|
};
|
||||||
@@ -863,6 +869,8 @@ struct ImportRequest {
|
|||||||
model: Option<String>,
|
model: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
permission_mode: Option<String>,
|
permission_mode: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
effort: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs `work` on the server, marked as in flight for as long as it takes.
|
/// Runs `work` on the server, marked as in flight for as long as it takes.
|
||||||
@@ -1013,6 +1021,7 @@ async fn spawn(manager: &Arc<SessionManager>, body: SpawnRequest) -> Result<Sess
|
|||||||
.filter(|cwd| cwd.as_os_str() != "")
|
.filter(|cwd| cwd.as_os_str() != "")
|
||||||
}),
|
}),
|
||||||
permission_mode: body.permission_mode,
|
permission_mode: body.permission_mode,
|
||||||
|
effort: body.effort,
|
||||||
params: body.params,
|
params: body.params,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1346,6 +1355,30 @@ struct PermissionModeRequest {
|
|||||||
mode: String,
|
mode: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct EffortRequest {
|
||||||
|
/// Absent or null is the CLI's own default, which is a choice somebody can
|
||||||
|
/// make rather than only a state to start in.
|
||||||
|
#[serde(default)]
|
||||||
|
effort: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records how hard this session thinks, and stops the process so the next one
|
||||||
|
/// is launched with it -- `--effort` has no control request behind it. See
|
||||||
|
/// [`SessionManager::set_session_effort`].
|
||||||
|
async fn set_effort(
|
||||||
|
State(manager): State<Arc<SessionManager>>,
|
||||||
|
UrlPath(id): UrlPath<String>,
|
||||||
|
axum::Json(body): axum::Json<EffortRequest>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
manager
|
||||||
|
.set_session_effort(&id, body.effort.as_deref())
|
||||||
|
.map_err(bad_request)?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
async fn set_permission_mode(
|
async fn set_permission_mode(
|
||||||
State(manager): State<Arc<SessionManager>>,
|
State(manager): State<Arc<SessionManager>>,
|
||||||
UrlPath(id): UrlPath<String>,
|
UrlPath(id): UrlPath<String>,
|
||||||
|
|||||||
@@ -351,6 +351,12 @@ impl ClaudeDriver {
|
|||||||
if let Some(mode) = &meta.permission_mode {
|
if let Some(mode) = &meta.permission_mode {
|
||||||
push("--permission-mode", mode);
|
push("--permission-mode", mode);
|
||||||
}
|
}
|
||||||
|
// Launch-only: see `SessionConfig::effort`. Omitted entirely when
|
||||||
|
// unset, so the CLI's own default is what an unchosen session gets
|
||||||
|
// rather than a level this app decided to call the default.
|
||||||
|
if let Some(effort) = &meta.effort {
|
||||||
|
push("--effort", effort);
|
||||||
|
}
|
||||||
// Named at birth, so this session is the same session in the CLI's own
|
// Named at birth, so this session is the same session in the CLI's own
|
||||||
// picker and in what other agents see.
|
// picker and in what other agents see.
|
||||||
//
|
//
|
||||||
|
|||||||
+124
-1
@@ -63,6 +63,8 @@ pub struct SpawnSpec {
|
|||||||
pub model: Option<String>,
|
pub model: Option<String>,
|
||||||
pub cwd: Option<PathBuf>,
|
pub cwd: Option<PathBuf>,
|
||||||
pub permission_mode: Option<String>,
|
pub permission_mode: Option<String>,
|
||||||
|
/// See `SessionConfig::effort`.
|
||||||
|
pub effort: Option<String>,
|
||||||
/// Driver-interpreted settings; see `SessionConfig::params`.
|
/// Driver-interpreted settings; see `SessionConfig::params`.
|
||||||
pub params: std::collections::BTreeMap<String, String>,
|
pub params: std::collections::BTreeMap<String, String>,
|
||||||
}
|
}
|
||||||
@@ -118,6 +120,16 @@ pub struct SessionInfo {
|
|||||||
/// were confirming.
|
/// were confirming.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub permission_mode: Option<String>,
|
pub permission_mode: Option<String>,
|
||||||
|
/// How hard it thinks; see `SessionConfig::effort`. Reported for the same
|
||||||
|
/// reason the mode is, and absent where nothing has been chosen -- which
|
||||||
|
/// the phone draws as the CLI's default rather than as a level.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub effort: Option<String>,
|
||||||
|
/// Whether a level means anything here; see `DriverKind::takes_effort`.
|
||||||
|
/// Reported beside the level because absent-and-irrelevant and
|
||||||
|
/// absent-and-unchosen are different answers, and only one of them is a
|
||||||
|
/// control worth drawing.
|
||||||
|
pub takes_effort: bool,
|
||||||
/// Whether this session continues one the machine already had.
|
/// Whether this session continues one the machine already had.
|
||||||
/// Reported because it changes what deleting *means*: an imported
|
/// Reported because it changes what deleting *means*: an imported
|
||||||
/// session's real transcript belongs to the CLI and survives, so
|
/// session's real transcript belongs to the CLI and survives, so
|
||||||
@@ -435,6 +447,7 @@ impl LiveSession {
|
|||||||
&self,
|
&self,
|
||||||
setup_name: &str,
|
setup_name: &str,
|
||||||
cwd: Option<&Path>,
|
cwd: Option<&Path>,
|
||||||
|
effort: Option<&str>,
|
||||||
imported: bool,
|
imported: bool,
|
||||||
kind: Option<DriverKind>,
|
kind: Option<DriverKind>,
|
||||||
) -> SessionInfo {
|
) -> SessionInfo {
|
||||||
@@ -446,6 +459,11 @@ impl LiveSession {
|
|||||||
title: self.shared.title.lock().unwrap().clone(),
|
title: self.shared.title.lock().unwrap().clone(),
|
||||||
model: self.shared.model.lock().unwrap().clone(),
|
model: self.shared.model.lock().unwrap().clone(),
|
||||||
permission_mode: self.shared.permission_mode.lock().unwrap().clone(),
|
permission_mode: self.shared.permission_mode.lock().unwrap().clone(),
|
||||||
|
// From the config rather than from `shared`, like the cwd beside
|
||||||
|
// it: neither can change under a running process, so there is no
|
||||||
|
// live value for one to disagree with.
|
||||||
|
effort: effort.map(str::to_string),
|
||||||
|
takes_effort: kind.is_some_and(DriverKind::takes_effort),
|
||||||
context_tokens: *self.shared.context_tokens.lock().unwrap(),
|
context_tokens: *self.shared.context_tokens.lock().unwrap(),
|
||||||
notify: *self.shared.notify.lock().unwrap(),
|
notify: *self.shared.notify.lock().unwrap(),
|
||||||
max_image_edge: kind.and_then(DriverKind::max_image_edge),
|
max_image_edge: kind.and_then(DriverKind::max_image_edge),
|
||||||
@@ -902,6 +920,7 @@ impl SessionManager {
|
|||||||
Some(session) => session.info(
|
Some(session) => session.info(
|
||||||
label_of(&inner.config, &meta.setup),
|
label_of(&inner.config, &meta.setup),
|
||||||
meta.cwd.as_deref(),
|
meta.cwd.as_deref(),
|
||||||
|
meta.effort.as_deref(),
|
||||||
import::read_cursor(&self.data_dir.join(&meta.id)).is_some(),
|
import::read_cursor(&self.data_dir.join(&meta.id)).is_some(),
|
||||||
kind_of(&inner.config, &meta.setup, &meta.provider),
|
kind_of(&inner.config, &meta.setup, &meta.provider),
|
||||||
),
|
),
|
||||||
@@ -913,6 +932,9 @@ impl SessionManager {
|
|||||||
title: meta.title.clone(),
|
title: meta.title.clone(),
|
||||||
model: meta.model.clone(),
|
model: meta.model.clone(),
|
||||||
permission_mode: meta.permission_mode.clone(),
|
permission_mode: meta.permission_mode.clone(),
|
||||||
|
effort: meta.effort.clone(),
|
||||||
|
takes_effort: kind_of(&inner.config, &meta.setup, &meta.provider)
|
||||||
|
.is_some_and(DriverKind::takes_effort),
|
||||||
context_tokens: None,
|
context_tokens: None,
|
||||||
max_image_edge: kind_of(&inner.config, &meta.setup, &meta.provider)
|
max_image_edge: kind_of(&inner.config, &meta.setup, &meta.provider)
|
||||||
.and_then(DriverKind::max_image_edge),
|
.and_then(DriverKind::max_image_edge),
|
||||||
@@ -1016,6 +1038,7 @@ impl SessionManager {
|
|||||||
model: spec.model,
|
model: spec.model,
|
||||||
cwd: spec.cwd,
|
cwd: spec.cwd,
|
||||||
permission_mode: spec.permission_mode,
|
permission_mode: spec.permission_mode,
|
||||||
|
effort: spec.effort,
|
||||||
params: spec.params,
|
params: spec.params,
|
||||||
// On by default. Not offered at spawn: a session's first turn
|
// On by default. Not offered at spawn: a session's first turn
|
||||||
// is exactly the one somebody is waiting for.
|
// is exactly the one somebody is waiting for.
|
||||||
@@ -1050,6 +1073,7 @@ impl SessionManager {
|
|||||||
let info = session.info(
|
let info = session.info(
|
||||||
&setup.name,
|
&setup.name,
|
||||||
session.meta.cwd.as_deref(),
|
session.meta.cwd.as_deref(),
|
||||||
|
session.meta.effort.as_deref(),
|
||||||
import::read_cursor(&self.data_dir.join(&id)).is_some(),
|
import::read_cursor(&self.data_dir.join(&id)).is_some(),
|
||||||
Some(provider.kind),
|
Some(provider.kind),
|
||||||
);
|
);
|
||||||
@@ -1247,6 +1271,47 @@ impl SessionManager {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sets how hard this session's model thinks.
|
||||||
|
///
|
||||||
|
/// Shaped like [`SessionManager::set_session_cwd`] rather than like
|
||||||
|
/// [`SessionManager::set_session_model`], because `--effort` is a launch
|
||||||
|
/// flag with no control request behind it: the running process cannot be
|
||||||
|
/// asked, so the choice is recorded and the process ended, and the next
|
||||||
|
/// thing said to the session starts one that has it. Announcing it as a
|
||||||
|
/// settled change instead would put a level on the phone that the process
|
||||||
|
/// still running underneath was not using.
|
||||||
|
///
|
||||||
|
/// `None` clears it, which is a level in its own right -- the CLI's own
|
||||||
|
/// default -- and the reason this takes an option rather than a string.
|
||||||
|
pub fn set_session_effort(&self, id: &str, effort: Option<&str>) -> Result<()> {
|
||||||
|
let effort = effort.map(str::trim).filter(|level| !level.is_empty());
|
||||||
|
{
|
||||||
|
let mut inner = self.inner.write().unwrap();
|
||||||
|
if !inner.config.sessions.iter().any(|meta| meta.id == id) {
|
||||||
|
bail!("no session {id}");
|
||||||
|
}
|
||||||
|
let mut candidate = inner.config.clone();
|
||||||
|
for meta in candidate.sessions.iter_mut().filter(|meta| meta.id == id) {
|
||||||
|
meta.effort = effort.map(str::to_string);
|
||||||
|
}
|
||||||
|
candidate.save(&self.config_path)?;
|
||||||
|
inner.config = candidate;
|
||||||
|
}
|
||||||
|
// Saved before the process is touched, for the reason `set_session_cwd`
|
||||||
|
// gives: a process that will not stop must not leave the session
|
||||||
|
// recorded as something nothing agrees with.
|
||||||
|
let dir = self.data_dir.join(id);
|
||||||
|
if let Some(record) = process::live(&dir) {
|
||||||
|
tracing::info!(
|
||||||
|
"session {id} effort now {} -- stopping pid {}",
|
||||||
|
effort.unwrap_or("default"),
|
||||||
|
record.pid
|
||||||
|
);
|
||||||
|
process::stop(&record, process::STOP_GRACE);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Ends this session's process, leaving the session -- its transcript,
|
/// Ends this session's process, leaving the session -- its transcript,
|
||||||
/// its place in the list, everything a phone is watching -- exactly
|
/// its place in the list, everything a phone is watching -- exactly
|
||||||
/// where it is. [`SessionManager::start_session`] is the way back.
|
/// where it is. [`SessionManager::start_session`] is the way back.
|
||||||
@@ -2256,6 +2321,7 @@ mod tests {
|
|||||||
model: None,
|
model: None,
|
||||||
cwd: None,
|
cwd: None,
|
||||||
permission_mode: None,
|
permission_mode: None,
|
||||||
|
effort: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2558,7 +2624,10 @@ mod tests {
|
|||||||
assert_eq!(first.session_id, info.id);
|
assert_eq!(first.session_id, info.id);
|
||||||
// The title travels with it, because the phone may have no screen
|
// The title travels with it, because the phone may have no screen
|
||||||
// open to look one up on.
|
// open to look one up on.
|
||||||
assert_eq!(first.title, session.info("m", None, false, None).title);
|
assert_eq!(
|
||||||
|
first.title,
|
||||||
|
session.info("m", None, None, false, None).title
|
||||||
|
);
|
||||||
|
|
||||||
manager.set_session_notify(&info.id, false).expect("off");
|
manager.set_session_notify(&info.id, false).expect("off");
|
||||||
// Subscribed before the message, or the turn can finish in the gap
|
// Subscribed before the message, or the turn can finish in the gap
|
||||||
@@ -3411,6 +3480,60 @@ mod tests {
|
|||||||
std::fs::write(path, rewritten).expect("write transcript");
|
std::fs::write(path, rewritten).expect("write transcript");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A thinking level is stored and the process **ended**, because `--effort`
|
||||||
|
/// is read when the CLI launches and has no control request behind it. A
|
||||||
|
/// session left running would go on thinking at the old level underneath a
|
||||||
|
/// phone showing the new one -- the failure this app has already had once
|
||||||
|
/// with the model, and the one a stop makes impossible rather than
|
||||||
|
/// unlikely.
|
||||||
|
///
|
||||||
|
/// Clearing it back to the CLI's own default is exercised too: that is a
|
||||||
|
/// level somebody can choose, not only one to start in, so a picker that
|
||||||
|
/// could not return to it would make leaving a level a one-way trip.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn choosing_a_thinking_level_stores_it_and_ends_the_process() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let config_path = dir.path().join("config.ron");
|
||||||
|
let data_dir = dir.path().join("sessions");
|
||||||
|
// The stand-in CLI rather than the echo driver: what is under test is
|
||||||
|
// that a *process* is ended, and an echo session has none to end.
|
||||||
|
let provider = seed_stand_in_cli(&config_path, dir.path());
|
||||||
|
let manager = SessionManager::new(
|
||||||
|
config_path.clone(),
|
||||||
|
data_dir.clone(),
|
||||||
|
data_dir.join("models"),
|
||||||
|
)
|
||||||
|
.expect("manager");
|
||||||
|
let info = manager
|
||||||
|
.spawn_session(stand_in_spec(&provider))
|
||||||
|
.expect("spawn");
|
||||||
|
assert_eq!(info.effort, None, "nothing is chosen at spawn");
|
||||||
|
let record = process::live(&data_dir.join(&info.id)).expect("the session has a process");
|
||||||
|
|
||||||
|
manager
|
||||||
|
.set_session_effort(&info.id, Some("low"))
|
||||||
|
.expect("store the level");
|
||||||
|
assert_eq!(
|
||||||
|
manager.sessions()[0].effort.as_deref(),
|
||||||
|
Some("low"),
|
||||||
|
"stored, so the next start is launched with it"
|
||||||
|
);
|
||||||
|
process::wait_gone(&[record], process::STOP_GRACE);
|
||||||
|
|
||||||
|
// Blank is the same answer as unchosen; normalized here so a caller
|
||||||
|
// clearing the field cannot store a level the CLI would reject.
|
||||||
|
manager
|
||||||
|
.set_session_effort(&info.id, Some(" "))
|
||||||
|
.expect("clear the level");
|
||||||
|
assert_eq!(
|
||||||
|
manager.sessions()[0].effort,
|
||||||
|
None,
|
||||||
|
"the CLI's own default has to be reachable again"
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.delete_session(&info.id).expect("delete");
|
||||||
|
}
|
||||||
|
|
||||||
/// A setting changed on a session with nothing running is recorded as the
|
/// A setting changed on a session with nothing running is recorded as the
|
||||||
/// session's own, rather than refused because there is no driver. The
|
/// session's own, rather than refused because there is no driver. The
|
||||||
/// config already took it, so the refusal was about the driver while
|
/// config already took it, so the refusal was about the driver while
|
||||||
|
|||||||
Reference in new issue
Block a user