diff --git a/Cargo.lock b/Cargo.lock
index 68922611..04a90b19 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2128,6 +2128,7 @@ dependencies = [
"diffy",
"fs2",
"futures",
+ "lru",
"pretty_assertions",
"rusqlite",
"serde",
diff --git a/Cargo.toml b/Cargo.toml
index 8ff60d08..00bb29b9 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -102,6 +102,7 @@ itertools = "0.14.0"
keyring = { version = "3.6", default-features = false }
lazy_static = "1"
libc = "0.2.182"
+lru = "0.12"
multimap = "0.10.1"
model2vec = "0.3.0"
notify = "8.2.0"
diff --git a/README.ja.md b/README.ja.md
index 4fc2d2ef..3006de98 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -267,16 +267,6 @@ TUI/CLI を使ってください。どちらの surface も同じローカル De
変更について議論するには issue または pull request を開いてください。
-## Star 履歴
-
-
-
-
-
-
-
-
-
## ライセンス
このプロジェクトは [MIT License](./LICENSE) のもとで公開されています。
diff --git a/README.md b/README.md
index adf3b2eb..5a804704 100644
--- a/README.md
+++ b/README.md
@@ -280,16 +280,6 @@ Contributions are welcome while the project is still early:
Open an issue or pull request to discuss changes.
-## Star History
-
-
-
-
-
-
-
-
-
## License
This project is licensed under the [MIT License](./LICENSE).
diff --git a/README.ru.md b/README.ru.md
index c70c5f30..fd9c9076 100644
--- a/README.ru.md
+++ b/README.ru.md
@@ -281,16 +281,6 @@ Devo runtime.
Откройте issue или pull request, чтобы обсудить изменения.
-## История звезд
-
-
-
-
-
-
-
-
-
## Лицензия
Проект распространяется по [MIT License](./LICENSE).
diff --git a/README.zh-Hans.md b/README.zh-Hans.md
index 507c49ad..055bdcc3 100644
--- a/README.zh-Hans.md
+++ b/README.zh-Hans.md
@@ -256,16 +256,6 @@ provider 与 binding,把 key 存入用户级 `auth.json`,并将 `invocation_
请打开 issue 或 pull request 讨论变更。
-## Star 历史
-
-
-
-
-
-
-
-
-
## 许可证
本项目采用 [MIT License](./LICENSE) 授权。
diff --git a/README.zh-Hant.md b/README.zh-Hant.md
index d18d70b3..5365b29a 100644
--- a/README.zh-Hant.md
+++ b/README.zh-Hant.md
@@ -256,16 +256,6 @@ provider 與 binding,把 key 存入使用者級 `auth.json`,並將 `invocati
請開啟 issue 或 pull request 討論變更。
-## Star 歷史
-
-
-
-
-
-
-
-
-
## 授權
本專案採用 [MIT License](./LICENSE) 授權。
diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts
index e2b9ead8..a61976aa 100644
--- a/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts
+++ b/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts
@@ -45,10 +45,7 @@ import type {
GoalStatusResult,
InputItem,
ThreadGoalStatus,
- TurnQueueRemoveResult,
- TurnQueueSteerResult,
TurnStartResult,
- TurnSteerResult,
RequestUserInputRespondParams,
WorkspaceChangeCoverage,
WorkspaceChangeScope,
@@ -836,8 +833,6 @@ class AcpClient {
}
turn = {
- // User requirement: busy composer follow-ups can be queued first, then converted
- // to steer from the composer status stack without creating transcript-only state.
start: async (params: {
sessionID: string
parts: PromptPartInput[]
@@ -860,37 +855,6 @@ class AcpClient {
})) as TurnStartResult
return { data: result }
},
- steer: async (params: {
- sessionID: string
- expectedTurnID: string
- parts: PromptPartInput[]
- }) => {
- const result = (await this.request("turn/steer", {
- session_id: params.sessionID,
- expected_turn_id: params.expectedTurnID,
- input: inputItemsFromPromptParts(params.parts),
- })) as TurnSteerResult
- return { data: result }
- },
- removeQueued: async (params: { sessionID: string; queuedInputID: string }) => {
- const result = (await this.request("turn/queue/remove", {
- session_id: params.sessionID,
- queued_input_id: params.queuedInputID,
- })) as TurnQueueRemoveResult
- return { data: result }
- },
- steerQueued: async (params: {
- sessionID: string
- expectedTurnID: string
- queuedInputID: string
- }) => {
- const result = (await this.request("turn/queue/steer", {
- session_id: params.sessionID,
- expected_turn_id: params.expectedTurnID,
- queued_input_id: params.queuedInputID,
- })) as TurnQueueSteerResult
- return { data: result }
- },
}
permission = {
diff --git a/apps/web/content/docs/using-devo/model-picker.mdx b/apps/web/content/docs/using-devo/model-picker.mdx
index 9a9a9a96..4386e6c6 100644
--- a/apps/web/content/docs/using-devo/model-picker.mdx
+++ b/apps/web/content/docs/using-devo/model-picker.mdx
@@ -9,8 +9,16 @@ Use `/model` to switch the active model for the current session.
/model
```
-The picker opens in the bottom pane. Use `Up` and `Down` to move, `Enter` to
-select, and `Esc` to close without changing the model.
+The picker opens in the bottom pane and replaces the composer input area:
+
+- `Up` / `Down` (or `j` / `k`) move through the model list
+- `Left` / `Right` cycle reasoning effort for the focused model when that model
+ supports it
+- `Enter` applies the focused model and selected effort together
+- `Esc` closes without changing the model
+
+When there are many saved bindings, the model list shows at most eight rows and
+scrolls so the focused row stays visible.
## What The Picker Shows
@@ -36,16 +44,16 @@ match either, Devo still sets the requested model name for the turn context.
## Reasoning Effort
-Some models expose configurable reasoning effort. When you select one of those
-models, Devo may open a second picker for reasoning effort before finalizing
-the model change.
+Some models expose configurable reasoning effort. For those models, the picker
+shows a horizontal effort strip below the model list. Changing the focused
+model updates the available options; if the previously selected effort is still
+supported it is preserved, otherwise Devo falls back to that model's default.
-The reasoning effort picker uses the model's configured capability and reasoning-effort
-options. If the model does not expose options, Devo applies the model
-immediately.
+If the focused model does not expose effort options, the strip is hidden and
+`Enter` applies the model alone.
-After selection, Devo updates the header and sends the model and reasoning effort
-selection to the runtime as turn context.
+After confirmation, Devo updates the header and sends the model and reasoning
+effort selection to the runtime as turn context.
## Session Scope
diff --git a/apps/web/content/docs/using-devo/model-picker.zh.mdx b/apps/web/content/docs/using-devo/model-picker.zh.mdx
index 64541065..041b28ba 100644
--- a/apps/web/content/docs/using-devo/model-picker.zh.mdx
+++ b/apps/web/content/docs/using-devo/model-picker.zh.mdx
@@ -9,7 +9,14 @@ description: 在已配置的 model bindings 之间切换。
/model
```
-Picker 会在底部 pane 打开。使用 `Up` 和 `Down` 移动,`Enter` 选择,`Esc` 关闭且不修改模型。
+Picker 会在底部 pane 打开,并替换 composer 输入区域:
+
+- `Up` / `Down`(或 `j` / `k`)在模型列表中移动
+- `Left` / `Right` 在当前聚焦模型支持的推理强度选项间切换
+- `Enter` 一次应用聚焦的模型与所选推理强度
+- `Esc` 关闭且不修改模型
+
+当保存的 binding 较多时,模型列表最多显示 8 行,并滚动以保持聚焦行可见。
## Picker 显示什么
@@ -29,11 +36,11 @@ Picker 来自保存的 model bindings。每个条目显示 display name,并在
## Reasoning Effort
-某些模型暴露 configurable reasoning effort。当你选择这些模型时,Devo 可能先打开第二个 picker 选择 reasoning effort,再完成模型切换。
+某些模型暴露 configurable reasoning effort。对这些模型,picker 在模型列表下方显示横向推理强度选项。切换聚焦模型时会更新可用选项;若先前选中的强度仍被支持则保留,否则回退到该模型的默认值。
-Reasoning effort picker 使用模型配置的 capability 和 reasoning-effort options。如果模型没有暴露选项,Devo 会直接应用模型。
+若聚焦模型没有暴露选项,强度行会隐藏,`Enter` 只应用模型。
-选择后,Devo 更新 header,并把 model 和 reasoning effort selection 作为 turn context 发送给 runtime。
+确认后,Devo 更新 header,并把 model 和 reasoning effort selection 作为 turn context 发送给 runtime。
## Session Scope
diff --git a/crates/cli/src/agent_command.rs b/crates/cli/src/agent_command.rs
index dd7a0be0..edbc884e 100644
--- a/crates/cli/src/agent_command.rs
+++ b/crates/cli/src/agent_command.rs
@@ -92,6 +92,8 @@ pub(crate) async fn run_agent(
reasoning_effort_selection: model_reasoning_effort_selection,
permission_preset,
sandbox_profile,
+ compaction_token_limit: app_config.compaction_token_limit,
+ default_collaboration_mode: app_config.default_collaboration_mode,
// TODO: why do we need cwd here, maybe remove it ?
cwd,
},
diff --git a/crates/cli/src/prompt_command.rs b/crates/cli/src/prompt_command.rs
index b2281414..56771e07 100644
--- a/crates/cli/src/prompt_command.rs
+++ b/crates/cli/src/prompt_command.rs
@@ -117,7 +117,7 @@ pub(crate) async fn run_prompt(
session_id: session_state.id.clone(),
transcript_path: String::new(),
cwd: cwd.clone(),
- permission_mode: Some("auto-approve".to_string()),
+ permission_mode: Some("yolo".to_string()),
agent_id: None,
agent_type: None,
},
@@ -499,7 +499,7 @@ fn write_query_event_jsonl(session_id: &str, event: &QueryEvent) -> Result<()> {
QueryEvent::ContextCompactionStarted => {
write_jsonl(&PromptJsonlEvent::ContextCompactionStarted { session_id })
}
- QueryEvent::ContextCompactionCompleted => {
+ QueryEvent::ContextCompactionCompleted { .. } => {
write_jsonl(&PromptJsonlEvent::ContextCompactionCompleted { session_id })
}
QueryEvent::ContextCompactionFailed { message } => {
diff --git a/crates/client/README.md b/crates/client/README.md
index 32fadf7b..153b286d 100644
--- a/crates/client/README.md
+++ b/crates/client/README.md
@@ -41,6 +41,7 @@ The short `ws://` listen target uses `127.0.0.1:3210`.
`session_permissions_update`, `session_sandbox_profile_update`: update session
metadata shown by clients.
- `session_compact`, `session_fork`, `session_rollback`: manage session history
+ (`session_compact` returns `TurnStartResult` for the compaction turn)
and derived sessions.
- `agent_list`, `agent_spawn`, `agent_close`: inspect and manage background
agents.
diff --git a/crates/client/src/client_core.rs b/crates/client/src/client_core.rs
index 5c4b2bb5..74ee9520 100644
--- a/crates/client/src/client_core.rs
+++ b/crates/client/src/client_core.rs
@@ -464,6 +464,13 @@ impl ServerClientCore {
.await
}
+ pub(crate) async fn session_compaction_update(
+ &mut self,
+ params: SessionCompactionUpdateParams,
+ ) -> Result {
+ self.request_devo("session/compaction/update", params).await
+ }
+
pub(crate) async fn session_sandbox_profile_update(
&mut self,
params: SessionSandboxProfileUpdateParams,
@@ -475,10 +482,17 @@ impl ServerClientCore {
pub(crate) async fn session_compact(
&mut self,
params: SessionCompactParams,
- ) -> Result {
+ ) -> Result {
self.request_devo("session/compact", params).await
}
+ pub(crate) async fn session_cancel(
+ &mut self,
+ params: AcpCancelParams,
+ ) -> Result {
+ self.request(ACP_SESSION_CANCEL_METHOD, params).await
+ }
+
pub(crate) async fn goal_create(
&mut self,
params: GoalCreateParams,
@@ -652,8 +666,46 @@ impl ServerClientCore {
self.request_devo("turn/interrupt", params).await
}
- pub(crate) async fn turn_steer(&mut self, params: TurnSteerParams) -> Result {
- self.request_devo("turn/steer", params).await
+ pub(crate) async fn session_queue_push(
+ &mut self,
+ params: canonical::rpc_turn::SessionQueuePushParams,
+ ) -> Result {
+ self.request_devo("session/queue/push", params).await
+ }
+
+ pub(crate) async fn session_queue_list(
+ &mut self,
+ params: canonical::rpc_turn::SessionQueueListParams,
+ ) -> Result {
+ self.request_devo("session/queue/list", params).await
+ }
+
+ pub(crate) async fn session_queue_update(
+ &mut self,
+ params: canonical::rpc_turn::SessionQueueUpdateParams,
+ ) -> Result {
+ self.request_devo("session/queue/update", params).await
+ }
+
+ pub(crate) async fn session_queue_remove(
+ &mut self,
+ params: canonical::rpc_turn::SessionQueueRemoveParams,
+ ) -> Result {
+ self.request_devo("session/queue/remove", params).await
+ }
+
+ pub(crate) async fn session_queue_steer(
+ &mut self,
+ params: canonical::rpc_turn::SessionQueueSteerParams,
+ ) -> Result {
+ self.request_devo("session/queue/steer", params).await
+ }
+
+ pub(crate) async fn subscription_create(
+ &mut self,
+ params: canonical::event::SubscriptionCreateParams,
+ ) -> Result {
+ self.request_devo("subscription/create", params).await
}
pub(crate) async fn reference_search_start(
@@ -1028,8 +1080,11 @@ fn acp_session_metadata_from_start_params(
prompt_token_estimate: 0,
last_query_usage: None,
last_query_total_tokens: 0,
+ last_context_occupancy: None,
status: SessionRuntimeStatus::Idle,
collaboration_mode: Default::default(),
+ effective_context_window: None,
+ permission_preset: None,
}
}
@@ -1066,8 +1121,11 @@ fn acp_session_metadata_from_session_info(session_info: &AcpSessionInfo) -> Sess
prompt_token_estimate: 0,
last_query_usage: None,
last_query_total_tokens: 0,
+ last_context_occupancy: None,
status: SessionRuntimeStatus::Idle,
collaboration_mode: Default::default(),
+ effective_context_window: None,
+ permission_preset: None,
}
}
diff --git a/crates/client/src/stdio.rs b/crates/client/src/stdio.rs
index 85ea727d..f1a7638d 100644
--- a/crates/client/src/stdio.rs
+++ b/crates/client/src/stdio.rs
@@ -184,6 +184,13 @@ impl StdioServerClient {
self.core.session_permissions_update(params).await
}
+ pub async fn session_compaction_update(
+ &mut self,
+ params: SessionCompactionUpdateParams,
+ ) -> Result {
+ self.core.session_compaction_update(params).await
+ }
+
pub async fn session_sandbox_profile_update(
&mut self,
params: SessionSandboxProfileUpdateParams,
@@ -194,10 +201,14 @@ impl StdioServerClient {
pub async fn session_compact(
&mut self,
params: SessionCompactParams,
- ) -> Result {
+ ) -> Result {
self.core.session_compact(params).await
}
+ pub async fn session_cancel(&mut self, params: AcpCancelParams) -> Result {
+ self.core.session_cancel(params).await
+ }
+
pub async fn goal_create(&mut self, params: GoalCreateParams) -> Result {
self.core.goal_create(params).await
}
@@ -357,8 +368,46 @@ impl StdioServerClient {
self.core.turn_interrupt(params).await
}
- pub async fn turn_steer(&mut self, params: TurnSteerParams) -> Result {
- self.core.turn_steer(params).await
+ pub async fn session_queue_push(
+ &mut self,
+ params: canonical::rpc_turn::SessionQueuePushParams,
+ ) -> Result {
+ self.core.session_queue_push(params).await
+ }
+
+ pub async fn session_queue_list(
+ &mut self,
+ params: canonical::rpc_turn::SessionQueueListParams,
+ ) -> Result {
+ self.core.session_queue_list(params).await
+ }
+
+ pub async fn session_queue_update(
+ &mut self,
+ params: canonical::rpc_turn::SessionQueueUpdateParams,
+ ) -> Result {
+ self.core.session_queue_update(params).await
+ }
+
+ pub async fn session_queue_remove(
+ &mut self,
+ params: canonical::rpc_turn::SessionQueueRemoveParams,
+ ) -> Result {
+ self.core.session_queue_remove(params).await
+ }
+
+ pub async fn session_queue_steer(
+ &mut self,
+ params: canonical::rpc_turn::SessionQueueSteerParams,
+ ) -> Result {
+ self.core.session_queue_steer(params).await
+ }
+
+ pub async fn subscription_create(
+ &mut self,
+ params: canonical::event::SubscriptionCreateParams,
+ ) -> Result {
+ self.core.subscription_create(params).await
}
pub async fn approval_respond(&mut self, params: ApprovalResponseParams) -> Result<()> {
@@ -691,8 +740,11 @@ mod tests {
prompt_token_estimate: 0,
last_query_usage: None,
last_query_total_tokens: 0,
+ last_context_occupancy: None,
status: SessionRuntimeStatus::Idle,
collaboration_mode: Default::default(),
+ effective_context_window: None,
+ permission_preset: None,
};
assert_eq!(session, expected);
@@ -783,8 +835,11 @@ mod tests {
prompt_token_estimate: 0,
last_query_usage: None,
last_query_total_tokens: 0,
+ last_context_occupancy: None,
status: SessionRuntimeStatus::Idle,
collaboration_mode: Default::default(),
+ effective_context_window: None,
+ permission_preset: None,
}]
);
@@ -833,8 +888,11 @@ mod tests {
prompt_token_estimate: 0,
last_query_usage: None,
last_query_total_tokens: 0,
+ last_context_occupancy: None,
status: SessionRuntimeStatus::Idle,
collaboration_mode: Default::default(),
+ effective_context_window: None,
+ permission_preset: None,
};
let mut stdout_lines = BufReader::new(stdout).lines();
diff --git a/crates/client/src/websocket.rs b/crates/client/src/websocket.rs
index db462eca..98901be1 100644
--- a/crates/client/src/websocket.rs
+++ b/crates/client/src/websocket.rs
@@ -166,6 +166,15 @@ impl WebSocketServerClient {
.await
}
+ pub async fn session_compaction_update(
+ &mut self,
+ params: SessionCompactionUpdateParams,
+ ) -> Result {
+ self.core
+ .request_devo("session/compaction/update", params)
+ .await
+ }
+
pub async fn session_sandbox_profile_update(
&mut self,
params: SessionSandboxProfileUpdateParams,
@@ -178,10 +187,16 @@ impl WebSocketServerClient {
pub async fn session_compact(
&mut self,
params: SessionCompactParams,
- ) -> Result {
+ ) -> Result {
self.core.request_devo("session/compact", params).await
}
+ pub async fn session_cancel(&mut self, params: AcpCancelParams) -> Result {
+ self.core
+ .request(devo_protocol::ACP_SESSION_CANCEL_METHOD, params)
+ .await
+ }
+
pub async fn goal_create(&mut self, params: GoalCreateParams) -> Result {
self.core.request_devo("goal/create", params).await
}
@@ -343,8 +358,46 @@ impl WebSocketServerClient {
self.core.request_devo("turn/interrupt", params).await
}
- pub async fn turn_steer(&mut self, params: TurnSteerParams) -> Result {
- self.core.request_devo("turn/steer", params).await
+ pub async fn session_queue_push(
+ &mut self,
+ params: canonical::rpc_turn::SessionQueuePushParams,
+ ) -> Result {
+ self.core.session_queue_push(params).await
+ }
+
+ pub async fn session_queue_list(
+ &mut self,
+ params: canonical::rpc_turn::SessionQueueListParams,
+ ) -> Result {
+ self.core.session_queue_list(params).await
+ }
+
+ pub async fn session_queue_update(
+ &mut self,
+ params: canonical::rpc_turn::SessionQueueUpdateParams,
+ ) -> Result {
+ self.core.session_queue_update(params).await
+ }
+
+ pub async fn session_queue_remove(
+ &mut self,
+ params: canonical::rpc_turn::SessionQueueRemoveParams,
+ ) -> Result {
+ self.core.session_queue_remove(params).await
+ }
+
+ pub async fn session_queue_steer(
+ &mut self,
+ params: canonical::rpc_turn::SessionQueueSteerParams,
+ ) -> Result {
+ self.core.session_queue_steer(params).await
+ }
+
+ pub async fn subscription_create(
+ &mut self,
+ params: canonical::event::SubscriptionCreateParams,
+ ) -> Result {
+ self.core.subscription_create(params).await
}
pub async fn approval_respond(&mut self, params: ApprovalResponseParams) -> Result<()> {
diff --git a/crates/config/src/app.rs b/crates/config/src/app.rs
index 01f909f9..0f8e0057 100644
--- a/crates/config/src/app.rs
+++ b/crates/config/src/app.rs
@@ -4,6 +4,7 @@ use std::fs;
use std::path::Path;
use std::path::PathBuf;
+use devo_protocol::CollaborationMode;
use devo_protocol::PermissionPreset;
use devo_protocol::ProviderModelBinding;
use devo_protocol::ProviderVendor;
@@ -92,6 +93,15 @@ pub struct AppConfig {
pub project_root_markers: Vec,
/// User-level settings remembered per project key.
pub projects: BTreeMap,
+ /// Global absolute auto-compaction token limit.
+ ///
+ /// When set, sessions clamp this value to each model's `context_window`.
+ /// When unset, sessions use the model effective context window.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub compaction_token_limit: Option,
+ /// Default collaboration/input mode (Build or Plan) for new sessions.
+ #[serde(default)]
+ pub default_collaboration_mode: CollaborationMode,
}
/// Settings remembered for one project.
@@ -184,6 +194,8 @@ impl Default for AppConfig {
},
project_root_markers: vec![".git".into()],
projects: BTreeMap::new(),
+ compaction_token_limit: None,
+ default_collaboration_mode: CollaborationMode::Build,
}
}
}
@@ -377,6 +389,65 @@ impl AppConfigStore {
Ok(())
}
+ /// Persists the global compaction token limit and refreshes effective config.
+ pub fn set_compaction_token_limit(&mut self, limit: u64) -> anyhow::Result<()> {
+ if limit == 0 {
+ anyhow::bail!("compaction_token_limit must be at least 1");
+ }
+
+ let target_config_file = self.user_config_file.as_path();
+ if let Some(parent) = target_config_file.parent() {
+ fs::create_dir_all(parent)?;
+ }
+ let mut document = read_provider_config_document(target_config_file)?;
+ let document = ensure_toml_table(&mut document);
+ let limit_i64 = i64::try_from(limit).map_err(|_| {
+ anyhow::anyhow!("compaction_token_limit is too large to store in config.toml")
+ })?;
+ document.insert(
+ "compaction_token_limit".to_string(),
+ toml::Value::Integer(limit_i64),
+ );
+
+ let data = toml::to_string_pretty(&document)?;
+ write_atomic(target_config_file, data.as_bytes())?;
+
+ self.config = self
+ .loader
+ .load(self.workspace_root.as_deref())
+ .map_err(|error| anyhow::anyhow!(error))?;
+ Ok(())
+ }
+
+ /// Persists the default collaboration mode and refreshes effective config.
+ pub fn set_default_collaboration_mode(
+ &mut self,
+ mode: CollaborationMode,
+ ) -> anyhow::Result<()> {
+ let target_config_file = self.user_config_file.as_path();
+ if let Some(parent) = target_config_file.parent() {
+ fs::create_dir_all(parent)?;
+ }
+ let mut document = read_provider_config_document(target_config_file)?;
+ let document = ensure_toml_table(&mut document);
+ document.insert(
+ "default_collaboration_mode".to_string(),
+ toml::Value::String(match mode {
+ CollaborationMode::Build => "build".to_string(),
+ CollaborationMode::Plan => "plan".to_string(),
+ }),
+ );
+
+ let data = toml::to_string_pretty(&document)?;
+ write_atomic(target_config_file, data.as_bytes())?;
+
+ self.config = self
+ .loader
+ .load(self.workspace_root.as_deref())
+ .map_err(|error| anyhow::anyhow!(error))?;
+ Ok(())
+ }
+
/// Persists a path-based skill enablement override in the user config.
pub fn set_skill_enabled(&mut self, path: PathBuf, enabled: bool) -> anyhow::Result<()> {
if path.as_os_str().is_empty() {
diff --git a/crates/config/src/app_store_tests.rs b/crates/config/src/app_store_tests.rs
index d02c2d44..00f1c503 100644
--- a/crates/config/src/app_store_tests.rs
+++ b/crates/config/src/app_store_tests.rs
@@ -115,3 +115,68 @@ model_reasoning_effort_selection = "medium"
let _ = fs::remove_dir_all(root);
}
+
+#[test]
+fn loader_reads_compaction_token_limit_from_user_config() {
+ let root = unique_temp_dir("compaction-limit-load");
+ let home = root.join(".devo");
+ fs::create_dir_all(&home).expect("create config dir");
+ fs::write(
+ home.join(APP_CONFIG_FILE_NAME),
+ "compaction_token_limit = 250000\n",
+ )
+ .expect("write config");
+
+ let store = AppConfigStore::load(home, None).expect("load config");
+ assert_eq!(
+ store.effective_config().compaction_token_limit,
+ Some(250_000)
+ );
+
+ let _ = fs::remove_dir_all(root);
+}
+
+#[test]
+fn set_compaction_token_limit_writes_user_config_and_refreshes() {
+ let root = unique_temp_dir("compaction-limit-set");
+ let home = root.join(".devo");
+ fs::create_dir_all(&home).expect("create config dir");
+ fs::write(home.join(APP_CONFIG_FILE_NAME), "theme = \"aurora\"\n").expect("write config");
+
+ let mut store = AppConfigStore::load(home.clone(), None).expect("load config");
+ store
+ .set_compaction_token_limit(250_000)
+ .expect("write compaction limit");
+
+ let config_text = fs::read_to_string(home.join(APP_CONFIG_FILE_NAME)).expect("read config");
+ let document: toml::Value = toml::from_str(&config_text).expect("parse config");
+ assert_eq!(document["theme"].as_str(), Some("aurora"));
+ assert_eq!(
+ document["compaction_token_limit"].as_integer(),
+ Some(250_000)
+ );
+ assert_eq!(
+ store.effective_config().compaction_token_limit,
+ Some(250_000)
+ );
+
+ let _ = fs::remove_dir_all(root);
+}
+
+#[test]
+fn set_compaction_token_limit_rejects_values_above_i64_max() {
+ let root = unique_temp_dir("compaction-limit-overflow");
+ let home = root.join(".devo");
+ fs::create_dir_all(&home).expect("create config dir");
+
+ let mut store = AppConfigStore::load(home, None).expect("load config");
+ let err = store
+ .set_compaction_token_limit(u64::MAX)
+ .expect_err("overflow must fail");
+ assert!(
+ err.to_string().contains("too large"),
+ "unexpected error: {err}"
+ );
+
+ let _ = fs::remove_dir_all(root);
+}
diff --git a/crates/config/src/mcp.rs b/crates/config/src/mcp.rs
index acdef4f4..2f002c0c 100644
--- a/crates/config/src/mcp.rs
+++ b/crates/config/src/mcp.rs
@@ -104,6 +104,31 @@ impl McpConfig {
break;
}
}
+
+ /// Returns whether two MCP configs can share a live manager/registry.
+ ///
+ /// Used by `SessionRuntimeContext::load_for_workspace` to avoid rebuilding
+ /// MCP state (and re-spawning lazy servers) for every workspace lookup.
+ ///
+ /// Workspace injection of `code_search` cwd is ignored while that server is
+ /// disabled, so process-level and workspace-level managers stay reusable.
+ /// When `code_search` is enabled, cwd must match or managers are not shared.
+ pub fn is_operationally_equivalent_to(&self, other: &Self) -> bool {
+ self.normalized_for_runtime_equivalence() == other.normalized_for_runtime_equivalence()
+ }
+
+ fn normalized_for_runtime_equivalence(&self) -> Self {
+ let mut normalized = self.clone();
+ for record in &mut normalized.servers {
+ if record.id.0 != BUNDLED_CODE_SEARCH_MCP_SERVER_ID || record.enabled {
+ continue;
+ }
+ if let McpTransportConfig::Stdio { cwd, .. } = &mut record.transport {
+ *cwd = None;
+ }
+ }
+ normalized
+ }
}
/// Returns the bundled, disabled-by-default code_search MCP server record.
@@ -442,4 +467,26 @@ mod tests {
_ => panic!("expected stdio transport"),
}
}
+
+ #[test]
+ fn operational_equivalence_ignores_disabled_code_search_cwd() {
+ let mut left = McpConfig::default();
+ let mut right = McpConfig::default();
+ left.apply_code_search_workspace_cwd(PathBuf::from("/process-cwd"));
+ right.apply_code_search_workspace_cwd(PathBuf::from("/workspace-cwd"));
+
+ assert!(left.is_operationally_equivalent_to(&right));
+ }
+
+ #[test]
+ fn operational_equivalence_requires_enabled_code_search_cwd_match() {
+ let mut left = McpConfig::default();
+ let mut right = McpConfig::default();
+ left.servers[0].enabled = true;
+ right.servers[0].enabled = true;
+ left.apply_code_search_workspace_cwd(PathBuf::from("/process-cwd"));
+ right.apply_code_search_workspace_cwd(PathBuf::from("/workspace-cwd"));
+
+ assert!(!left.is_operationally_equivalent_to(&right));
+ }
}
diff --git a/crates/config/src/tests.rs b/crates/config/src/tests.rs
index 14f92235..42010953 100644
--- a/crates/config/src/tests.rs
+++ b/crates/config/src/tests.rs
@@ -3,6 +3,7 @@ use std::path::PathBuf;
use std::time::SystemTime;
use std::time::UNIX_EPOCH;
+use devo_protocol::CollaborationMode;
use devo_protocol::PermissionPreset;
use pretty_assertions::assert_eq;
@@ -156,6 +157,8 @@ check_interval_hours = 48
},
project_root_markers: vec![".workspace".into()],
projects: BTreeMap::new(),
+ compaction_token_limit: None,
+ default_collaboration_mode: CollaborationMode::Build,
}
);
diff --git a/crates/core/src/context/mod.rs b/crates/core/src/context/mod.rs
index 1a6de0d4..c2c47a6e 100644
--- a/crates/core/src/context/mod.rs
+++ b/crates/core/src/context/mod.rs
@@ -477,6 +477,26 @@ mod tests {
assert_eq!(budget.should_compact(950_001), true);
}
+ #[test]
+ fn session_override_sets_effective_context_window_and_auto_compact() {
+ let model = Model {
+ context_window: 200_000,
+ effective_context_window_percent: Some(95),
+ max_tokens: Some(8_192),
+ ..Model::default()
+ };
+ let turn = crate::TurnConfig::new(model, None);
+ let budget = turn.token_budget_for_session(Some(100_000));
+ assert_eq!(budget.context_window, 100_000);
+ assert_eq!(budget.auto_compact_token_limit, Some(100_000));
+ assert!(!budget.should_compact(100_000));
+ assert!(budget.should_compact(100_001));
+
+ let clamped = turn.token_budget_for_session(Some(500_000));
+ assert_eq!(clamped.context_window, 200_000);
+ assert_eq!(clamped.auto_compact_token_limit, Some(200_000));
+ }
+
#[test]
fn token_budget_input_budget_saturates() {
let budget = TokenBudget::new(100, 200);
diff --git a/crates/core/src/conversation/legacy_projector.rs b/crates/core/src/conversation/legacy_projector.rs
index 89b8368c..ba27255b 100644
--- a/crates/core/src/conversation/legacy_projector.rs
+++ b/crates/core/src/conversation/legacy_projector.rs
@@ -220,6 +220,7 @@ impl LegacyProjector {
.iter()
.map(|id| legacy_uuid(id).map(ItemId::from_legacy_uuid))
.collect::>()?,
+ context_occupancy: line.context_occupancy.clone(),
}]),
RolloutLine::MessageEditRecorded(line) => {
let record = &line.record;
@@ -389,6 +390,7 @@ impl LegacyProjector {
mode: None,
sandbox_profile: (!record.sandbox_policy.is_empty())
.then(|| record.sandbox_policy.clone()),
+ effective_context_window: record.effective_context_window,
},
git_info,
preview: record.first_user_message.clone().unwrap_or_default(),
@@ -408,6 +410,8 @@ impl LegacyProjector {
session_context: record.session_context.clone(),
cli_version: record.cli_version.clone(),
source: record.source.clone(),
+ collaboration_mode: record.collaboration_mode,
+ permission_preset: record.permission_preset,
})),
}])
}
@@ -424,6 +428,7 @@ impl LegacyProjector {
request_thinking: record.request_thinking.clone(),
input_token_estimate: record.input_token_estimate,
latest_query_usage: record.latest_query_usage.clone(),
+ context_occupancy: record.context_occupancy.clone(),
stop_reason: record.stop_reason.clone(),
failure_reason: record.failure_reason,
})),
diff --git a/crates/core/src/conversation/records.rs b/crates/core/src/conversation/records.rs
index 5f78f2e8..bee20c02 100644
--- a/crates/core/src/conversation/records.rs
+++ b/crates/core/src/conversation/records.rs
@@ -9,7 +9,7 @@ use crate::{
TurnWorkspaceChangeRecordedRecord, TurnWorkspaceCheckpointRecordedRecord,
TurnWorkspaceRestoreCompletedRecord, TurnWorkspaceRestoreStartedRecord,
};
-use devo_protocol::{StopReason, TurnFailureReason};
+use devo_protocol::{CollaborationMode, PermissionPreset, StopReason, TurnFailureReason};
/// Stores persistent metadata for one session.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -58,6 +58,9 @@ pub struct SessionRecord {
pub sandbox_policy: String,
/// The active approval mode description for the session.
pub approval_mode: String,
+ /// Session override for the absolute effective context window.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub effective_context_window: Option,
/// The last observed aggregate token count for the session.
pub tokens_used: i64,
/// The first user message stored for preview or title derivation.
@@ -78,6 +81,12 @@ pub struct SessionRecord {
/// The latest turn context snapshot known for this session.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub latest_turn_context: Option,
+ /// Session-level collaboration mode override, persisted without requiring a turn.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub collaboration_mode: Option,
+ /// Session-level permission preset override.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub permission_preset: Option,
/// The schema version for persisted session metadata.
pub schema_version: u32,
}
@@ -122,6 +131,12 @@ pub struct TurnRecord {
/// have been performed for tools, retries, or delegated work.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub latest_query_usage: Option,
+ /// Context-window occupancy snapshot after this turn's latest model query.
+ ///
+ /// Used by resume/fork/rollback to restore category occupancy at a cut
+ /// turn rather than copying the session tip.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub context_occupancy: Option,
/// The terminal provider/model stop reason, when available.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stop_reason: Option,
@@ -426,6 +441,9 @@ pub struct CompactionSnapshotLine {
pub summary_item_id: ItemId,
/// The pre-existing item ids that remain after compaction, in prompt order.
pub preserved_item_ids: Vec,
+ /// Post-compaction context occupancy, when computed.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub context_occupancy: Option,
}
/// Stores an append-only rollback marker for a session rollout.
@@ -564,6 +582,7 @@ mod tests {
title_state: SessionTitleState::Unset,
sandbox_policy: "workspace-write".into(),
approval_mode: "on-request".into(),
+ effective_context_window: None,
tokens_used: 0,
first_user_message: None,
archived_at: None,
@@ -573,6 +592,8 @@ mod tests {
parent_session_id: None,
session_context: None,
latest_turn_context: None,
+ collaboration_mode: None,
+ permission_preset: None,
schema_version: 2,
};
@@ -672,6 +693,35 @@ mod tests {
assert_eq!(serialized.get("thinking"), None);
}
+ #[test]
+ fn turn_record_roundtrips_context_occupancy() {
+ use pretty_assertions::assert_eq;
+
+ let occupancy = devo_protocol::canonical::item::ContextOccupancy::from_category_tokens(
+ /*context_window_tokens*/ 100_000, /*base*/ 10_000, /*skills*/ 5_000,
+ /*tools_builtin*/ 20_000, /*tools_mcp*/ 15_000, /*conversation*/ 50_000,
+ );
+ let mut turn = make_test_turn(TurnStatus::Completed);
+ turn.context_occupancy = Some(occupancy.clone());
+ let json = serde_json::to_string(&turn).expect("serialize");
+ let restored: TurnRecord = serde_json::from_str(&json).expect("deserialize");
+ assert_eq!(restored.context_occupancy, Some(occupancy));
+ }
+
+ #[test]
+ fn turn_record_reads_legacy_record_without_context_occupancy() {
+ let expected = make_test_turn(TurnStatus::Completed);
+ let mut value = serde_json::to_value(&expected).expect("serialize value");
+ value
+ .as_object_mut()
+ .expect("turn json object")
+ .remove("context_occupancy");
+
+ let restored: TurnRecord = serde_json::from_value(value).expect("deserialize legacy");
+ assert_eq!(restored.context_occupancy, None);
+ assert_eq!(restored, expected);
+ }
+
#[test]
fn turn_cannot_transition_from_completed_to_running() {
// Per L3-BEH-CORE-001 §4: Completed → Running is ILLEGAL
@@ -900,6 +950,7 @@ mod tests {
turn_id: turn.id,
summary_item_id: item.id,
preserved_item_ids: vec![item.id],
+ context_occupancy: None,
})),
RolloutLine::SessionRollback(Box::new(SessionRollbackLine {
timestamp: Utc::now(),
@@ -1091,10 +1142,38 @@ mod tests {
turn_id: TurnId::new(),
summary_item_id: ItemId::new(),
preserved_item_ids: vec![ItemId::new(), ItemId::new(), ItemId::new()],
+ context_occupancy: Some(
+ devo_protocol::canonical::item::ContextOccupancy::from_category_tokens(
+ /*context_window_tokens*/ 80_000, /*base*/ 8_000,
+ /*skills*/ 2_000, /*tools_builtin*/ 10_000, /*tools_mcp*/ 0,
+ /*conversation*/ 20_000,
+ ),
+ ),
};
let json = serde_json::to_string(&snapshot).expect("serialize");
let restored: CompactionSnapshotLine = serde_json::from_str(&json).expect("deserialize");
assert_eq!(restored.preserved_item_ids.len(), 3);
+ assert_eq!(restored.context_occupancy, snapshot.context_occupancy);
+ }
+
+ #[test]
+ fn compaction_snapshot_reads_legacy_without_context_occupancy() {
+ let snapshot = CompactionSnapshotLine {
+ timestamp: Utc::now(),
+ session_id: SessionId::new(),
+ turn_id: TurnId::new(),
+ summary_item_id: ItemId::new(),
+ preserved_item_ids: vec![ItemId::new()],
+ context_occupancy: None,
+ };
+ let mut value = serde_json::to_value(&snapshot).expect("serialize");
+ value
+ .as_object_mut()
+ .expect("snapshot object")
+ .remove("context_occupancy");
+ let restored: CompactionSnapshotLine =
+ serde_json::from_value(value).expect("deserialize legacy");
+ assert_eq!(restored.context_occupancy, None);
}
// ── Helpers ───────────────────────────────────────────────
@@ -1121,6 +1200,7 @@ mod tests {
title_state: SessionTitleState::Provisional,
sandbox_policy: "workspace-write".into(),
approval_mode: "on-request".into(),
+ effective_context_window: None,
tokens_used: 100,
first_user_message: Some("hello".into()),
archived_at: None,
@@ -1130,6 +1210,8 @@ mod tests {
parent_session_id: None,
session_context: None,
latest_turn_context: None,
+ collaboration_mode: None,
+ permission_preset: None,
schema_version: 2,
}
}
@@ -1151,6 +1233,7 @@ mod tests {
input_token_estimate: Some(100),
usage: None,
latest_query_usage: None,
+ context_occupancy: None,
stop_reason: None,
failure_reason: None,
error: None,
diff --git a/crates/core/src/conversation/rollout_v2.rs b/crates/core/src/conversation/rollout_v2.rs
index 010505b4..9dee9d06 100644
--- a/crates/core/src/conversation/rollout_v2.rs
+++ b/crates/core/src/conversation/rollout_v2.rs
@@ -40,6 +40,12 @@ pub struct SessionPersistenceExtras {
pub cli_version: String,
/// The session source kind, such as `cli` or `api` (audit field).
pub source: String,
+ /// Session-level collaboration mode override (Build/Plan), when set.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub collaboration_mode: Option,
+ /// Session-level permission preset override, when set.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub permission_preset: Option,
}
/// Persistence-only extras on the v2 Turn line, same rationale as
@@ -61,6 +67,9 @@ pub struct TurnPersistenceExtras {
/// Provider usage of the latest model query (excludes tool/retry calls).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub latest_query_usage: Option,
+ /// Context-window occupancy after the latest model query in this turn.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub context_occupancy: Option,
/// The terminal provider/model stop reason, when available.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stop_reason: Option,
@@ -137,6 +146,8 @@ pub enum RolloutLineV2 {
turn_id: TurnId,
summary_item_id: ItemId,
preserved_item_ids: Vec,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ context_occupancy: Option,
},
/// An append-only rollback marker: the retained turns/items after the
/// in-memory history was rebuilt.
diff --git a/crates/core/src/conversation/v2_inverse.rs b/crates/core/src/conversation/v2_inverse.rs
index 4828c02c..cdea6fd4 100644
--- a/crates/core/src/conversation/v2_inverse.rs
+++ b/crates/core/src/conversation/v2_inverse.rs
@@ -127,6 +127,7 @@ impl V2InverseProjector {
turn_id,
summary_item_id,
preserved_item_ids,
+ context_occupancy,
..
} => Ok(vec![RolloutLine::CompactionSnapshot(Box::new(
CompactionSnapshotLine {
@@ -138,6 +139,7 @@ impl V2InverseProjector {
.iter()
.map(legacy_item_id)
.collect::>()?,
+ context_occupancy: context_occupancy.clone(),
},
))]),
RolloutLineV2::SessionRollback {
@@ -268,6 +270,7 @@ impl V2InverseProjector {
},
sandbox_policy: session.settings.sandbox_profile.clone().unwrap_or_default(),
approval_mode: approval_mode.into(),
+ effective_context_window: session.settings.effective_context_window,
tokens_used: session
.usage
.legacy
@@ -286,6 +289,8 @@ impl V2InverseProjector {
session_context: extras.and_then(|extras| extras.session_context.clone()),
// Internal prefix-cache cache, not carried even in the extras.
latest_turn_context: None,
+ collaboration_mode: extras.and_then(|extras| extras.collaboration_mode),
+ permission_preset: extras.and_then(|extras| extras.permission_preset),
schema_version: CURRENT_SESSION_SCHEMA_VERSION,
};
Ok(vec![RolloutLine::SessionMeta(Box::new(SessionMetaLine {
@@ -361,6 +366,7 @@ impl V2InverseProjector {
input_token_estimate: extras.and_then(|extras| extras.input_token_estimate),
usage,
latest_query_usage: extras.and_then(|extras| extras.latest_query_usage.clone()),
+ context_occupancy: extras.and_then(|extras| extras.context_occupancy.clone()),
stop_reason: extras.and_then(|extras| extras.stop_reason.clone()),
failure_reason: extras.and_then(|extras| extras.failure_reason),
error,
diff --git a/crates/core/src/history/compaction.rs b/crates/core/src/history/compaction.rs
index 690aa7d6..37eade4f 100644
--- a/crates/core/src/history/compaction.rs
+++ b/crates/core/src/history/compaction.rs
@@ -33,6 +33,7 @@ use async_trait::async_trait;
use serde::Deserialize;
use serde::Serialize;
use tokio::time::sleep;
+use tokio_util::sync::CancellationToken;
use crate::context::ContextualUserFragment;
use crate::context::TokenBudget;
@@ -71,6 +72,9 @@ pub enum CompactionError {
/// The summarizer returned an empty response.
#[error("summarizer returned empty response")]
EmptyResponse,
+ /// Compaction was canceled by the caller.
+ #[error("compaction canceled")]
+ Canceled,
/// Compaction is not possible after exhausting retries.
#[error("compaction not possible after {retries} retries")]
NotPossible {
@@ -91,7 +95,16 @@ pub enum CompactionError {
pub trait HistorySummarizer: Send + Sync {
/// Send `messages` (to-compact history followed by a developer compaction
/// prompt) to the model and return the generated summary text.
- async fn summarize(&self, messages: Vec) -> Result;
+ ///
+ /// When `cancel_token` is set, implementations should return
+ /// [`CompactionError::Canceled`] promptly if it fires while the provider
+ /// call is in flight (typically by racing the request against
+ /// `cancel_token.cancelled()`).
+ async fn summarize(
+ &self,
+ messages: Vec,
+ cancel_token: Option<&CancellationToken>,
+ ) -> Result;
}
// ---------------------------------------------------------------------------
@@ -176,6 +189,7 @@ pub async fn compact_history(
token_info: &TokenInfo,
summarizer: &dyn HistorySummarizer,
config: &CompactionConfig,
+ cancel_token: Option<&CancellationToken>,
) -> Result {
// For auto compaction, skip if already within budget.
// Proactive compaction always proceeds regardless of budget.
@@ -224,10 +238,15 @@ pub async fn compact_history(
const MAX_TRANSIENT_RETRIES: u32 = 5;
loop {
+ if cancel_token.is_some_and(CancellationToken::is_cancelled) {
+ return Err(CompactionError::Canceled);
+ }
+
let messages = summarizer_request_messages(&to_compact);
- let summary = match summarizer.summarize(messages).await {
+ let summary = match summarizer.summarize(messages, cancel_token).await {
Ok(s) => s,
+ Err(CompactionError::Canceled) => return Err(CompactionError::Canceled),
Err(CompactionError::ContextTooLong) => {
if to_compact.is_empty() {
// All items were moved to preserve — nothing to compact.
@@ -246,7 +265,7 @@ pub async fn compact_history(
}
// Exponential backoff: 2^(retries) * 100ms
let delay = Duration::from_millis(100 * (1 << transient_retries));
- sleep(delay).await;
+ sleep_cancellable(delay, cancel_token).await?;
continue;
}
};
@@ -265,6 +284,21 @@ pub async fn compact_history(
}
}
+async fn sleep_cancellable(
+ delay: Duration,
+ cancel_token: Option<&CancellationToken>,
+) -> Result<(), CompactionError> {
+ let Some(cancel_token) = cancel_token else {
+ sleep(delay).await;
+ return Ok(());
+ };
+ tokio::select! {
+ biased;
+ () = cancel_token.cancelled() => Err(CompactionError::Canceled),
+ () = sleep(delay) => Ok(()),
+ }
+}
+
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
@@ -576,6 +610,7 @@ mod tests {
async fn summarize(
&self,
_messages: Vec,
+ _cancel_token: Option<&CancellationToken>,
) -> Result {
Ok("summary".to_string())
}
@@ -602,18 +637,29 @@ mod tests {
kind: CompactionKind::Auto,
};
- let action = compact_history(&items, &token_info, &StubSummarizer, &config)
- .await
- .expect("auto compaction should succeed");
+ let action = compact_history(
+ &items,
+ &token_info,
+ &StubSummarizer,
+ &config,
+ /*cancel_token*/ None,
+ )
+ .await
+ .expect("auto compaction should succeed");
let proactive_config = CompactionConfig {
budget: config.budget.clone(),
kind: CompactionKind::Proactive,
};
- let proactive_action =
- compact_history(&items, &token_info, &StubSummarizer, &proactive_config)
- .await
- .expect("proactive compaction should succeed");
+ let proactive_action = compact_history(
+ &items,
+ &token_info,
+ &StubSummarizer,
+ &proactive_config,
+ /*cancel_token*/ None,
+ )
+ .await
+ .expect("proactive compaction should succeed");
match (action, proactive_action) {
(
@@ -647,6 +693,84 @@ mod tests {
}
}
+ /// Trace: L2-DES-AGENT-002
+ /// Verifies: cancel token aborts an in-flight history summarizer.
+ #[tokio::test]
+ async fn compact_history_returns_canceled_when_token_fires() {
+ use std::sync::Arc;
+ use std::sync::atomic::AtomicBool;
+ use std::sync::atomic::Ordering;
+
+ struct HangingSummarizer {
+ entered: Arc,
+ }
+
+ #[async_trait]
+ impl HistorySummarizer for HangingSummarizer {
+ async fn summarize(
+ &self,
+ _messages: Vec,
+ cancel_token: Option<&CancellationToken>,
+ ) -> Result {
+ self.entered.store(true, Ordering::SeqCst);
+ let Some(cancel_token) = cancel_token else {
+ std::future::pending::<()>().await;
+ unreachable!("summarizer should be canceled")
+ };
+ cancel_token.cancelled().await;
+ Err(CompactionError::Canceled)
+ }
+ }
+
+ let items = vec![
+ ResponseItem::Message(Message::user("first")),
+ ResponseItem::Message(Message::assistant_text("reply")),
+ ResponseItem::Message(Message::user("latest")),
+ ];
+ let token_info = TokenInfo {
+ input_tokens: 10,
+ cached_input_tokens: 0,
+ output_tokens: 5,
+ };
+ let config = CompactionConfig {
+ budget: TokenBudget::new(200_000, 8192),
+ kind: CompactionKind::Proactive,
+ };
+ let entered = Arc::new(AtomicBool::new(false));
+ let summarizer = HangingSummarizer {
+ entered: Arc::clone(&entered),
+ };
+ let cancel_token = CancellationToken::new();
+ let cancel_for_task = cancel_token.clone();
+ let compact = tokio::spawn(async move {
+ compact_history(
+ &items,
+ &token_info,
+ &summarizer,
+ &config,
+ Some(&cancel_for_task),
+ )
+ .await
+ });
+
+ for _ in 0..50 {
+ if entered.load(Ordering::SeqCst) {
+ break;
+ }
+ tokio::time::sleep(Duration::from_millis(10)).await;
+ }
+ assert!(
+ entered.load(Ordering::SeqCst),
+ "summarizer should start before cancel"
+ );
+ cancel_token.cancel();
+ let result = tokio::time::timeout(Duration::from_secs(2), compact)
+ .await
+ .expect("compaction should finish after cancel")
+ .expect("compaction task should not panic");
+ assert!(matches!(result, Err(CompactionError::Canceled)));
+ }
+
#[tokio::test]
async fn proactive_compaction_summarizes_all_history_and_preserves_latest_user_suffix() {
struct StubSummarizer;
@@ -656,6 +780,7 @@ mod tests {
async fn summarize(
&self,
messages: Vec,
+ _cancel_token: Option<&CancellationToken>,
) -> Result {
assert_eq!(messages.len(), 4);
let last = messages
@@ -684,9 +809,15 @@ mod tests {
kind: CompactionKind::Proactive,
};
- let action = compact_history(&items, &token_info, &StubSummarizer, &config)
- .await
- .expect("proactive compaction should succeed");
+ let action = compact_history(
+ &items,
+ &token_info,
+ &StubSummarizer,
+ &config,
+ /*cancel_token*/ None,
+ )
+ .await
+ .expect("proactive compaction should succeed");
match action {
CompactAction::Replaced(compacted) => {
diff --git a/crates/core/src/history/summarizer.rs b/crates/core/src/history/summarizer.rs
index 3e32e524..27c34f6b 100644
--- a/crates/core/src/history/summarizer.rs
+++ b/crates/core/src/history/summarizer.rs
@@ -3,6 +3,7 @@ use std::sync::Arc;
use async_trait::async_trait;
use devo_protocol::{Model, ModelRequest, RequestMessage, ResponseContent, SamplingControls};
use devo_provider::ModelProviderSDK;
+use tokio_util::sync::CancellationToken;
use tracing::debug;
use super::compaction::{CompactionError, HistorySummarizer};
@@ -69,7 +70,11 @@ fn should_keep_summary_line(line: &str) -> bool {
#[async_trait]
impl HistorySummarizer for DefaultHistorySummarizer {
- async fn summarize(&self, messages: Vec) -> Result {
+ async fn summarize(
+ &self,
+ messages: Vec,
+ cancel_token: Option<&CancellationToken>,
+ ) -> Result {
let request = ModelRequest {
model_slug: devo_protocol::ModelProfileKey::CatalogSlug(self.model_slug.clone()),
model: self.request_model.clone(),
@@ -94,7 +99,20 @@ impl HistorySummarizer for DefaultHistorySummarizer {
"sending LLM compaction request"
);
- let response = match self.provider.completion(request).await {
+ let completion = self.provider.completion(request);
+ let response = match cancel_token {
+ Some(cancel_token) => {
+ tokio::select! {
+ biased;
+ () = cancel_token.cancelled() => {
+ return Err(CompactionError::Canceled);
+ }
+ result = completion => result,
+ }
+ }
+ None => completion.await,
+ };
+ let response = match response {
Ok(r) => r,
Err(e) => {
let err_msg = e.to_string();
diff --git a/crates/core/src/permission.rs b/crates/core/src/permission.rs
index b4d43607..c0616d79 100644
--- a/crates/core/src/permission.rs
+++ b/crates/core/src/permission.rs
@@ -488,7 +488,8 @@ impl Default for ApprovalPolicy {
#[serde(rename_all = "snake_case")]
pub enum PermissionMode {
Default,
- AutoApprove,
+ #[serde(alias = "auto_approve")]
+ Yolo,
Deny,
}
@@ -571,7 +572,7 @@ pub enum AutoReviewerStatus {
/// Authorize a tool request through the four-layer pipeline.
///
-/// Layer 1 — PermissionMode override (AutoApprove/Deny)
+/// Layer 1 — PermissionMode override (Yolo/Deny)
/// Layer 2 — Profile evaluation against RuntimePermissionProfile
/// Layer 3 — Approval cache check
/// Layer 4 — Return Ask for user prompt (handled by server)
@@ -583,7 +584,7 @@ pub fn authorize_tool_request(
) -> PermissionDecision {
// Layer 1 — PermissionMode override
match policy.permission_mode {
- PermissionMode::AutoApprove => return PermissionDecision::Allow,
+ PermissionMode::Yolo => return PermissionDecision::Allow,
PermissionMode::Deny => {
return PermissionDecision::Deny {
reason: "permission mode is Deny".into(),
@@ -951,7 +952,7 @@ mod tests {
// ── authorize_tool_request ───────────────────────────────────
#[test]
- fn auto_approve_mode_allows_everything() {
+ fn yolo_mode_allows_everything() {
let request = ToolPermissionRequest {
tool_name: "shell".into(),
tool_category: ToolCategory::Command,
@@ -973,7 +974,7 @@ mod tests {
};
let mut cache = ApprovalCache::new();
let policy = ApprovalPolicy {
- permission_mode: PermissionMode::AutoApprove,
+ permission_mode: PermissionMode::Yolo,
..Default::default()
};
diff --git a/crates/core/src/query/event.rs b/crates/core/src/query/event.rs
index 0d03820d..51807aac 100644
--- a/crates/core/src/query/event.rs
+++ b/crates/core/src/query/event.rs
@@ -8,6 +8,7 @@ use std::sync::Arc;
use futures::future::BoxFuture;
use tokio_util::sync::CancellationToken;
+use crate::response_item::ResponseItem;
use crate::tools::ToolContent;
use devo_protocol::StopReason;
use devo_provider::ModelProviderSDK;
@@ -20,7 +21,12 @@ pub enum QueryEvent {
/// Context compaction is about to begin.
ContextCompactionStarted,
/// Context compaction replaced the current prompt history.
- ContextCompactionCompleted,
+ ContextCompactionCompleted {
+ /// Full compacted history (summary + preserved suffix), including tool
+ /// pairs. Callers persist snapshots from this before Message-only
+ /// prompt conversion drops non-message items.
+ compacted_items: Vec,
+ },
/// Context compaction did not replace the current prompt history.
ContextCompactionFailed {
/// Human-readable reason the compaction did not complete.
diff --git a/crates/core/src/query/mod.rs b/crates/core/src/query/mod.rs
index 6b39101e..4d53e1d2 100644
--- a/crates/core/src/query/mod.rs
+++ b/crates/core/src/query/mod.rs
@@ -17,7 +17,8 @@ pub use event::QueryOptions;
pub use event::QueryProviderRetryPhase;
pub(crate) use event::emit_query_event;
-pub(crate) use prompt_estimate::estimate_request_prompt_tokens;
+pub use prompt_estimate::RawContextBreakdown;
+pub(crate) use prompt_estimate::estimate_request_context_breakdown;
pub(crate) use provider_retry::ProviderRetryDecision;
pub(crate) use provider_retry::provider_retry_decision;
pub(crate) use provider_retry::wait_for_provider_retry;
@@ -119,14 +120,19 @@ fn hosted_tools_for_web_search(
/// budget is high; keeps a tail token window.
/// - [`CompactionKind::Proactive`]: forced compaction after provider
/// `context_too_long`; keeps from the latest user message onward.
+struct CompactionModelRequest<'a> {
+ provider: &'a Arc,
+ model_slug: &'a str,
+ request_model: &'a str,
+ max_tokens: usize,
+}
+
async fn summarize_and_compact(
session: &mut SessionState,
on_event: &Option,
- provider: &Arc,
- model_slug: &str,
- request_model: &str,
- max_tokens: usize,
+ model: CompactionModelRequest<'_>,
kind: CompactionKind,
+ cancel_token: Option<&CancellationToken>,
) {
let items: Vec = session
.prompt_source_messages()
@@ -147,19 +153,19 @@ async fn summarize_and_compact(
};
let summarizer = DefaultHistorySummarizer::with_models(
- Arc::clone(provider),
- model_slug,
- request_model,
- max_tokens,
+ Arc::clone(model.provider),
+ model.model_slug,
+ model.request_model,
+ model.max_tokens,
);
emit_query_event(on_event, QueryEvent::ContextCompactionStarted).await;
- match compact_history(&items, &token_info, &summarizer, &config).await {
+ match compact_history(&items, &token_info, &summarizer, &config, cancel_token).await {
Ok(CompactAction::Replaced(compacted_items)) => {
let new_messages: Vec = compacted_items
- .into_iter()
+ .iter()
.filter_map(|item| match item {
- ResponseItem::Message(msg) => Some(msg),
+ ResponseItem::Message(msg) => Some(msg.clone()),
_ => None,
})
.collect();
@@ -169,7 +175,11 @@ async fn summarize_and_compact(
.saturating_sub(new_messages.len());
info!("LLM compaction removed {removed} messages");
session.set_prompt_messages(new_messages);
- emit_query_event(on_event, QueryEvent::ContextCompactionCompleted).await;
+ emit_query_event(
+ on_event,
+ QueryEvent::ContextCompactionCompleted { compacted_items },
+ )
+ .await;
}
Ok(CompactAction::Skipped) => {
debug!("LLM compaction skipped, nothing to compact");
@@ -503,11 +513,14 @@ pub async fn query(
summarize_and_compact(
session,
&on_event,
- &compaction_provider,
- &compaction_model_slug,
- &compaction_request_model,
- turn_config.model.max_tokens.unwrap_or(4096) as usize,
+ CompactionModelRequest {
+ provider: &compaction_provider,
+ model_slug: &compaction_model_slug,
+ request_model: &compaction_request_model,
+ max_tokens: turn_config.model.max_tokens.unwrap_or(4096) as usize,
+ },
CompactionKind::Auto,
+ options.cancel_token.as_ref(),
)
.await;
}
@@ -608,7 +621,9 @@ pub async fn query(
reasoning_effort: request_reasoning_effort,
extra_body,
};
- session.prompt_token_estimate = estimate_request_prompt_tokens(&request);
+ let breakdown = estimate_request_context_breakdown(&request);
+ session.prompt_token_estimate = breakdown.total().try_into().unwrap_or(usize::MAX);
+ session.raw_context_breakdown = Some(breakdown);
debug!(
prompt_source_messages = prompt_source_message_count,
prompt_source_items = prompt_source_item_count,
@@ -659,11 +674,14 @@ pub async fn query(
summarize_and_compact(
session,
&on_event,
- &compaction_provider,
- &compaction_model_slug,
- &compaction_request_model,
- turn_config.model.max_tokens.unwrap_or(4096) as usize,
+ CompactionModelRequest {
+ provider: &compaction_provider,
+ model_slug: &compaction_model_slug,
+ request_model: &compaction_request_model,
+ max_tokens: turn_config.model.max_tokens.unwrap_or(4096) as usize,
+ },
CompactionKind::Proactive,
+ options.cancel_token.as_ref(),
)
.await;
session.turn_count -= 1;
diff --git a/crates/core/src/query/prompt_estimate.rs b/crates/core/src/query/prompt_estimate.rs
index 0343402d..2ffab96b 100644
--- a/crates/core/src/query/prompt_estimate.rs
+++ b/crates/core/src/query/prompt_estimate.rs
@@ -1,27 +1,213 @@
//! Prompt token estimation for assembled model requests.
use devo_protocol::ModelRequest;
+use devo_protocol::RequestContent;
+use devo_protocol::RequestMessage;
use devo_protocol::approx_tokens_from_byte_count;
-/// Rough prompt-token estimate for a fully built [`ModelRequest`].
-///
-/// Converts serialized request bytes with the shared protocol heuristic
-/// (~4 bytes/token) so query and persistence stay aligned.
-pub(crate) fn estimate_request_prompt_tokens(request: &ModelRequest) -> usize {
- let system_bytes = request.system.as_ref().map_or(0, String::len);
- let message_bytes = request
- .messages
- .iter()
- .map(|message| serde_json::to_string(message).map_or(0, |json| json.len()))
- .sum::();
- let tool_bytes = request
- .tools
- .as_ref()
- .map(|tools| serde_json::to_string(tools).map_or(0, |json| json.len()))
- .unwrap_or(0);
- let hosted_tool_bytes =
- serde_json::to_string(&request.hosted_tools).map_or(0, |json| json.len());
- approx_tokens_from_byte_count(system_bytes + message_bytes + tool_bytes + hosted_tool_bytes)
- .try_into()
- .unwrap_or(usize::MAX)
+const MCP_TOOL_PREFIX: &str = "mcp__";
+
+/// Byte-heuristic category split for an assembled request (before provider
+/// usage scaling).
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
+pub struct RawContextBreakdown {
+ pub base: u64,
+ pub skills: u64,
+ pub tools_builtin: u64,
+ pub tools_mcp: u64,
+ pub conversation: u64,
+}
+
+impl RawContextBreakdown {
+ pub fn total(self) -> u64 {
+ self.base
+ .saturating_add(self.skills)
+ .saturating_add(self.tools_builtin)
+ .saturating_add(self.tools_mcp)
+ .saturating_add(self.conversation)
+ }
+}
+
+/// Classify assembled request bytes into occupancy categories.
+pub(crate) fn estimate_request_context_breakdown(request: &ModelRequest) -> RawContextBreakdown {
+ let mut breakdown = RawContextBreakdown::default();
+
+ if let Some(system) = request.system.as_ref() {
+ breakdown.base = breakdown
+ .base
+ .saturating_add(approx_tokens_from_byte_count(system.len()));
+ }
+
+ for message in &request.messages {
+ let bytes = serde_json::to_string(message).map_or(0, |json| json.len());
+ let tokens = approx_tokens_from_byte_count(bytes);
+ match classify_message(message) {
+ MessageCategory::Base => breakdown.base = breakdown.base.saturating_add(tokens),
+ MessageCategory::Skills => breakdown.skills = breakdown.skills.saturating_add(tokens),
+ MessageCategory::Conversation => {
+ breakdown.conversation = breakdown.conversation.saturating_add(tokens);
+ }
+ }
+ }
+
+ if let Some(tools) = request.tools.as_ref() {
+ for tool in tools {
+ let bytes = serde_json::to_string(tool).map_or(0, |json| json.len());
+ let tokens = approx_tokens_from_byte_count(bytes);
+ if tool.name.starts_with(MCP_TOOL_PREFIX) {
+ breakdown.tools_mcp = breakdown.tools_mcp.saturating_add(tokens);
+ } else {
+ breakdown.tools_builtin = breakdown.tools_builtin.saturating_add(tokens);
+ }
+ }
+ }
+
+ let hosted_bytes = serde_json::to_string(&request.hosted_tools).map_or(0, |json| json.len());
+ breakdown.tools_builtin = breakdown
+ .tools_builtin
+ .saturating_add(approx_tokens_from_byte_count(hosted_bytes));
+
+ breakdown
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum MessageCategory {
+ Base,
+ Skills,
+ Conversation,
+}
+
+fn classify_message(message: &RequestMessage) -> MessageCategory {
+ for content in &message.content {
+ match content {
+ RequestContent::Text { text } => {
+ if let Some(category) = classify_text(text) {
+ return category;
+ }
+ }
+ RequestContent::ToolResult { content, .. } => {
+ if let Some(category) = classify_text(content) {
+ return category;
+ }
+ }
+ RequestContent::Reasoning { .. }
+ | RequestContent::ProviderReasoning { .. }
+ | RequestContent::HostedToolUse { .. }
+ | RequestContent::ToolUse { .. } => {}
+ }
+ }
+ MessageCategory::Conversation
+}
+
+fn classify_text(text: &str) -> Option {
+ let trimmed = text.trim_start();
+ if trimmed.starts_with("")
+ || trimmed.starts_with("")
+ || trimmed.starts_with("")
+ || trimmed.starts_with("")
+ || trimmed.starts_with("")
+ || trimmed.starts_with("")
+ || trimmed.starts_with("")
+ || trimmed.starts_with("# AGENTS.md instructions for ")
+ {
+ return Some(MessageCategory::Base);
+ }
+ None
+}
+
+#[cfg(test)]
+mod tests {
+ use pretty_assertions::assert_eq;
+
+ use super::*;
+ use devo_protocol::HostedToolDefinition;
+ use devo_protocol::ModelProfileKey;
+ use devo_protocol::SamplingControls;
+ use devo_protocol::ToolDefinition;
+
+ fn request_with(
+ system: Option<&str>,
+ messages: Vec,
+ tools: Option>,
+ ) -> ModelRequest {
+ ModelRequest {
+ model_slug: ModelProfileKey::Generic,
+ model: "test".into(),
+ system: system.map(str::to_string),
+ messages,
+ max_tokens: 128,
+ tools,
+ hosted_tools: Vec::::new(),
+ sampling: SamplingControls::default(),
+ request_thinking: None,
+ reasoning_effort: None,
+ extra_body: None,
+ }
+ }
+
+ fn text_message(role: &str, text: &str) -> RequestMessage {
+ RequestMessage {
+ role: role.into(),
+ content: vec![RequestContent::Text { text: text.into() }],
+ }
+ }
+
+ #[test]
+ fn breakdown_classifies_system_skills_tools_and_conversation() {
+ let request = request_with(
+ Some("you are a coding agent"),
+ vec![
+ text_message("user", "\nskills\n"),
+ text_message(
+ "user",
+ "\n /\n",
+ ),
+ text_message("user", "please fix the bug"),
+ text_message("assistant", "looking into it"),
+ ],
+ Some(vec![
+ ToolDefinition {
+ name: "bash".into(),
+ description: "run shell".into(),
+ input_schema: serde_json::json!({"type": "object"}),
+ output_schema: None,
+ },
+ ToolDefinition {
+ name: "mcp__docs__search".into(),
+ description: "search docs".into(),
+ input_schema: serde_json::json!({"type": "object"}),
+ output_schema: None,
+ },
+ ]),
+ );
+
+ let breakdown = estimate_request_context_breakdown(&request);
+ assert!(breakdown.base > 0);
+ assert!(breakdown.skills > 0);
+ assert!(breakdown.tools_builtin > 0);
+ assert!(breakdown.tools_mcp > 0);
+ assert!(breakdown.conversation > 0);
+ assert!(breakdown.total() > 0);
+ }
+
+ #[test]
+ fn agents_md_instructions_count_as_base() {
+ let request = request_with(
+ None,
+ vec![text_message(
+ "user",
+ "# AGENTS.md instructions for /repo\n\nrules\n",
+ )],
+ None,
+ );
+ let breakdown = estimate_request_context_breakdown(&request);
+ assert!(breakdown.base > 0);
+ assert_eq!(breakdown.skills, 0);
+ assert_eq!(breakdown.conversation, 0);
+ }
}
diff --git a/crates/core/src/query/tests.rs b/crates/core/src/query/tests.rs
index cce8f13a..62932785 100644
--- a/crates/core/src/query/tests.rs
+++ b/crates/core/src/query/tests.rs
@@ -1275,7 +1275,9 @@ fn recorded_compaction_events(events: &[QueryEvent]) -> Vec Some(RecordedCompactionEvent::Started),
- QueryEvent::ContextCompactionCompleted => Some(RecordedCompactionEvent::Completed),
+ QueryEvent::ContextCompactionCompleted { .. } => {
+ Some(RecordedCompactionEvent::Completed)
+ }
QueryEvent::ContextCompactionFailed { message } => {
Some(RecordedCompactionEvent::Failed {
message: message.clone(),
@@ -1328,11 +1330,14 @@ async fn automatic_compaction_emits_started_then_completed_when_history_is_repla
super::summarize_and_compact(
&mut session,
&on_event,
- &provider_sdk,
- "compaction-model",
- "compaction-request-model",
- /*max_tokens*/ 4096,
+ super::CompactionModelRequest {
+ provider: &provider_sdk,
+ model_slug: "compaction-model",
+ request_model: "compaction-request-model",
+ max_tokens: 4096,
+ },
CompactionKind::Auto,
+ /*cancel_token*/ None,
)
.await;
@@ -1370,11 +1375,14 @@ async fn automatic_compaction_emits_failed_when_compaction_is_skipped() {
super::summarize_and_compact(
&mut session,
&on_event,
- &provider_sdk,
- "compaction-model",
- "compaction-request-model",
- /*max_tokens*/ 4096,
+ super::CompactionModelRequest {
+ provider: &provider_sdk,
+ model_slug: "compaction-model",
+ request_model: "compaction-request-model",
+ max_tokens: 4096,
+ },
CompactionKind::Auto,
+ /*cancel_token*/ None,
)
.await;
@@ -1406,11 +1414,14 @@ async fn proactive_compaction_emits_failed_when_compaction_errors() {
super::summarize_and_compact(
&mut session,
&on_event,
- &provider_sdk,
- "compaction-model",
- "compaction-request-model",
- /*max_tokens*/ 4096,
+ super::CompactionModelRequest {
+ provider: &provider_sdk,
+ model_slug: "compaction-model",
+ request_model: "compaction-request-model",
+ max_tokens: 4096,
+ },
CompactionKind::Proactive,
+ /*cancel_token*/ None,
)
.await;
@@ -1497,7 +1508,7 @@ async fn query_retries_transient_stream_event_errors_before_content() {
.filter_map(|event| match event {
QueryEvent::ProviderRetryStatus(status) => Some(status.clone()),
QueryEvent::ContextCompactionStarted
- | QueryEvent::ContextCompactionCompleted
+ | QueryEvent::ContextCompactionCompleted { .. }
| QueryEvent::ContextCompactionFailed { .. }
| QueryEvent::TextDelta(_)
| QueryEvent::ReasoningDelta(_)
@@ -1587,7 +1598,7 @@ async fn query_waits_sixty_seconds_for_each_rate_limit_retry() {
.filter_map(|event| match event {
QueryEvent::ProviderRetryStatus(status) => Some(status.clone()),
QueryEvent::ContextCompactionStarted
- | QueryEvent::ContextCompactionCompleted
+ | QueryEvent::ContextCompactionCompleted { .. }
| QueryEvent::ContextCompactionFailed { .. }
| QueryEvent::TextDelta(_)
| QueryEvent::ReasoningDelta(_)
diff --git a/crates/core/src/session.rs b/crates/core/src/session.rs
index f71a5240..e2d2306d 100644
--- a/crates/core/src/session.rs
+++ b/crates/core/src/session.rs
@@ -29,6 +29,11 @@ use crate::state::turn::TurnState;
#[derive(Debug, Clone)]
pub struct SessionConfig {
pub token_budget: TokenBudget,
+ /// Session-scoped absolute effective context window override.
+ /// When set, turn starts merge this into the model-derived budget so hot
+ /// updates are not wiped by `TurnConfig::token_budget()`. Clamped to the
+ /// active model `context_window` at resolve time.
+ pub effective_context_window_override: Option,
pub permission_mode: PermissionMode,
pub permission_profile: RuntimePermissionProfile,
pub agents_md: AgentsMdConfig,
@@ -61,6 +66,7 @@ impl Default for SessionConfig {
RuntimePermissionProfile::from_preset(PermissionPreset::Default, cwd);
Self {
token_budget: TokenBudget::default(),
+ effective_context_window_override: None,
permission_mode: permission_profile.permission_mode(),
permission_profile,
agents_md: AgentsMdConfig::default(),
@@ -125,6 +131,24 @@ impl TurnConfig {
TokenBudget::for_model(&self.model)
}
+ /// Builds the turn token budget, applying a session effective-context
+ /// override when present so hot updates survive turn start reassignment.
+ ///
+ /// Resolved value is `min(override, model.context_window)` and is written to
+ /// both `TokenBudget.context_window` and `auto_compact_token_limit`.
+ pub fn token_budget_for_session(
+ &self,
+ effective_context_window_override: Option,
+ ) -> TokenBudget {
+ let mut budget = self.token_budget();
+ if let Some(limit) = effective_context_window_override {
+ let resolved = limit.min(self.model.context_window as usize).max(1);
+ budget.context_window = resolved;
+ budget.auto_compact_token_limit = Some(resolved);
+ }
+ budget
+ }
+
pub fn new(model: Model, reasoning_effort_selection: Option) -> Self {
let request_model = model.slug.clone();
let reasoning_effort_selection =
@@ -250,6 +274,8 @@ pub struct SessionState {
pub total_cache_creation_tokens: usize, // TODO: from Anthropic Messages API, indicate how many tokens utlized to create cache.
pub total_cache_read_tokens: usize, // TODO: same with `total_input_cached_tokens`.
pub prompt_token_estimate: usize,
+ /// Latest assembled-request category estimate (before provider scaling).
+ pub raw_context_breakdown: Option,
/// Input tokens reported by the model for the most recent turn.
pub last_input_tokens: usize,
/// Total context tokens reported by the model for the most recent turn.
@@ -290,6 +316,7 @@ impl SessionState {
total_cache_creation_tokens: 0,
total_cache_read_tokens: 0,
prompt_token_estimate: 0,
+ raw_context_breakdown: None,
last_input_tokens: 0,
last_turn_tokens: 0,
last_turn_interrupted: false,
@@ -318,6 +345,7 @@ impl SessionState {
total_cache_creation_tokens: self.total_cache_creation_tokens,
total_cache_read_tokens: self.total_cache_read_tokens,
prompt_token_estimate: self.prompt_token_estimate,
+ raw_context_breakdown: self.raw_context_breakdown,
last_input_tokens: self.last_input_tokens,
last_turn_tokens: self.last_turn_tokens,
last_turn_interrupted: self.last_turn_interrupted,
@@ -393,23 +421,6 @@ impl SessionState {
}
}
- /// Pushes a pending input to the turn queue (for execution in a future turn).
- pub fn enqueue_pending_input(&self, item: PendingInputItem) {
- self.pending_turn_queue
- .lock()
- .expect("pending turn queue mutex should not be poisoned")
- .push_back(item);
- }
-
- /// Drains all pending inputs from the turn queue.
- pub fn drain_pending_turn_queue(&self) -> Vec {
- let mut pending = self
- .pending_turn_queue
- .lock()
- .expect("pending turn queue mutex should not be poisoned");
- pending.drain(..).collect()
- }
-
/// Drains all pending inputs from the active-turn steer queue.
pub fn drain_steer_input_queue(&self) -> Vec {
let mut guard = self
@@ -420,24 +431,22 @@ impl SessionState {
}
pub fn start_turn(&mut self, kind: TurnKind) {
- let mut turn = TurnState::new(kind);
- // Drain pending turn queue into the new turn's pending input.
- let pending = self.drain_pending_turn_queue();
- turn.pending_input = pending;
- self.turn_state = Some(turn);
+ // The session turn queue is owned by the server-side drain (canonical
+ // `session/queue/*` semantics): queued entries become follow-up turns
+ // with their own drain notification and persistence bookkeeping, so
+ // they must never be absorbed into a running or starting turn here.
+ // Turn-scoped pending input starts empty and only collects transient
+ // steering fragments produced during this turn (e.g. budget notices).
+ self.turn_state = Some(TurnState::new(kind));
}
pub fn end_turn(&mut self) {
- if let Some(turn) = self.turn_state.take() {
- // Unconsumed pending input goes back to the turn queue (prepend to preserve order).
- let mut queue = self
- .pending_turn_queue
- .lock()
- .expect("pending turn queue mutex should not be poisoned");
- for item in turn.pending_input.into_iter().rev() {
- queue.push_front(item);
- }
- }
+ // Unconsumed turn-scoped pending input expires with the turn: it is
+ // transient steering produced for this turn only. It must not be
+ // re-queued into the canonical turn queue, whose entries are owned by
+ // the server-side drain (a transient item would surface as a blank
+ // queue row and bypass the drain's persistence bookkeeping).
+ self.turn_state = None;
// Steer inputs that arrived before the injection boundary but
// were not consumed before turn end degrade back into the session turn
// queue. This preserves the message; a later follow-up drain may start
@@ -461,14 +470,15 @@ impl SessionState {
}
}
- /// Merge turn-scoped pending input with both cross-thread inboxes.
- /// Order: steer inbox → turn-state pending → turn queue
+ /// Merge turn-scoped pending input with the steer inbox.
+ /// Order: steer inbox → turn-state pending. The session turn queue is
+ /// deliberately NOT drained here: its entries become follow-up turns via
+ /// the server-side drain, never silent mid-turn injections.
pub fn take_turn_pending_input(&mut self) -> Vec {
let mut result = self.drain_steer_input_queue();
if let Some(turn) = self.turn_state.as_mut() {
result.extend(turn.take_pending_input());
}
- result.extend(self.drain_pending_turn_queue());
result
}
}
@@ -672,30 +682,6 @@ mod tests {
assert_ne!(s1.id, s2.id);
}
- #[test]
- fn session_state_drains_pending_turn_queue() {
- use chrono::Utc;
- let state = SessionState::new(SessionConfig::default(), PathBuf::from("/tmp"));
- state.enqueue_pending_input(PendingInputItem::new(
- devo_protocol::PendingInputKind::UserText {
- text: "first".to_string(),
- },
- None,
- Utc::now(),
- ));
- state.enqueue_pending_input(PendingInputItem::new(
- devo_protocol::PendingInputKind::UserText {
- text: "second".to_string(),
- },
- None,
- Utc::now(),
- ));
-
- let drained = state.drain_pending_turn_queue();
- assert_eq!(drained.len(), 2);
- assert!(state.drain_pending_turn_queue().is_empty());
- }
-
#[test]
fn session_state_start_turn_creates_turn_state() {
let mut state = SessionState::new(SessionConfig::default(), PathBuf::from("/tmp"));
@@ -706,44 +692,50 @@ mod tests {
}
#[test]
- fn session_state_start_turn_drains_pending_queue() {
+ fn session_state_start_turn_leaves_pending_queue_untouched() {
use chrono::Utc;
let mut state = SessionState::new(SessionConfig::default(), PathBuf::from("/tmp"));
- state.enqueue_pending_input(PendingInputItem::new(
- devo_protocol::PendingInputKind::UserText {
- text: "queued".to_string(),
- },
- None,
- Utc::now(),
- ));
+ state
+ .pending_turn_queue
+ .lock()
+ .expect("queue lock")
+ .push_back(PendingInputItem::new(
+ devo_protocol::PendingInputKind::UserText {
+ text: "queued".to_string(),
+ },
+ None,
+ Utc::now(),
+ ));
state.start_turn(TurnKind::Regular);
+ // Queued entries become follow-up turns via the server-side drain;
+ // they are never absorbed into the starting turn.
let pending = state.take_turn_pending_input();
- assert_eq!(pending.len(), 1);
- assert!(state.pending_turn_queue.lock().unwrap().is_empty());
+ assert!(pending.is_empty());
+ assert_eq!(state.pending_turn_queue.lock().unwrap().len(), 1);
}
#[test]
- fn session_state_end_turn_moves_unconsumed_back_to_queue() {
+ fn session_state_end_turn_expires_unconsumed_turn_pending() {
use chrono::Utc;
let mut state = SessionState::new(SessionConfig::default(), PathBuf::from("/tmp"));
state.start_turn(TurnKind::Regular);
- // Push an item into the turn's pending input directly.
+ // Push a transient turn-scoped fragment (e.g. a budget notice).
if let Some(turn) = state.turn_state.as_mut() {
turn.push_pending_input(PendingInputItem::new(
- devo_protocol::PendingInputKind::UserText {
- text: "unconsumed".to_string(),
- },
+ devo_protocol::PendingInputKind::BudgetLimitSteering,
None,
Utc::now(),
));
}
state.end_turn();
assert!(state.turn_state.is_none());
- assert_eq!(state.pending_turn_queue.lock().unwrap().len(), 1);
+ // Transient fragments expire with the turn instead of leaking into
+ // the canonical turn queue owned by the server-side drain.
+ assert!(state.pending_turn_queue.lock().unwrap().is_empty());
}
#[test]
- fn session_state_take_turn_pending_merges_turn_and_inbox() {
+ fn session_state_take_turn_pending_merges_steer_and_turn_pending() {
use chrono::Utc;
let mut state = SessionState::new(SessionConfig::default(), PathBuf::from("/tmp"));
state.start_turn(TurnKind::Regular);
@@ -757,32 +749,47 @@ mod tests {
Utc::now(),
));
}
- // Push to cross-thread inbox.
- state.enqueue_pending_input(PendingInputItem::new(
- devo_protocol::PendingInputKind::UserText {
- text: "inbox-item".to_string(),
- },
- None,
- Utc::now(),
- ));
+ // Push to the steer inbox.
+ state
+ .steer_input_queue
+ .lock()
+ .expect("steer lock")
+ .push_back(PendingInputItem::new(
+ devo_protocol::PendingInputKind::UserText {
+ text: "steer-item".to_string(),
+ },
+ None,
+ Utc::now(),
+ ));
let merged = state.take_turn_pending_input();
assert_eq!(merged.len(), 2);
+ assert!(
+ matches!(&merged[0].kind, devo_protocol::PendingInputKind::UserText { text } if text == "steer-item")
+ );
+ assert!(
+ matches!(&merged[1].kind, devo_protocol::PendingInputKind::UserText { text } if text == "turn-item")
+ );
}
#[test]
- fn session_state_take_turn_pending_without_turn_drains_inbox_only() {
+ fn session_state_take_turn_pending_leaves_turn_queue_untouched() {
use chrono::Utc;
- let state = SessionState::new(SessionConfig::default(), PathBuf::from("/tmp"));
- state.enqueue_pending_input(PendingInputItem::new(
- devo_protocol::PendingInputKind::UserText {
- text: "direct".to_string(),
- },
- None,
- Utc::now(),
- ));
- // No turn started — take_turn_pending_input should still drain the inbox.
- let mut state_mut = state;
- let items = state_mut.take_turn_pending_input();
- assert_eq!(items.len(), 1);
+ let mut state = SessionState::new(SessionConfig::default(), PathBuf::from("/tmp"));
+ state
+ .pending_turn_queue
+ .lock()
+ .expect("queue lock")
+ .push_back(PendingInputItem::new(
+ devo_protocol::PendingInputKind::UserText {
+ text: "queued".to_string(),
+ },
+ None,
+ Utc::now(),
+ ));
+ // The session turn queue is drained only by the server-side follow-up
+ // scheduling, never by the running turn.
+ let items = state.take_turn_pending_input();
+ assert!(items.is_empty());
+ assert_eq!(state.pending_turn_queue.lock().unwrap().len(), 1);
}
}
diff --git a/crates/core/src/tools/registry_plan.rs b/crates/core/src/tools/registry_plan.rs
index 5bdee892..21aa5e6c 100644
--- a/crates/core/src/tools/registry_plan.rs
+++ b/crates/core/src/tools/registry_plan.rs
@@ -44,7 +44,7 @@ impl Default for ToolRegistryPlan {
}
}
-#[derive(Debug, Clone)]
+#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolPlanConfig {
pub use_shell_command: bool,
pub use_unified_exec: bool,
diff --git a/crates/core/src/tools/shell_exec/mod.rs b/crates/core/src/tools/shell_exec/mod.rs
index e6453e39..07fea36f 100644
--- a/crates/core/src/tools/shell_exec/mod.rs
+++ b/crates/core/src/tools/shell_exec/mod.rs
@@ -32,7 +32,6 @@ use pipe::run_with_pipes;
use pty::run_with_pty;
use resolve::ResolvedShellRun;
-const MAX_METADATA_LENGTH: usize = 30_000;
pub(crate) const DEFAULT_TIMEOUT_MS: u64 = 120_000;
pub(crate) const DEFAULT_YIELD_TIME_MS: u64 = 1_000;
pub(crate) const DEFAULT_MAX_OUTPUT_TOKENS: usize = 16_000;
@@ -110,17 +109,6 @@ pub(crate) async fn execute_shell_command(
}
}
-// TODO: Preview truncation belongs on the client, not the server. Move
-// `preview` (used today for short command metadata) and any remaining
-// display-oriented shortening to the client so the server returns full
-// stream text plus structured metadata without duplicating output.
-pub(crate) fn preview(text: &str) -> String {
- if text.len() <= MAX_METADATA_LENGTH {
- return text.to_string();
- }
- format!("{}\n\n...", &text[..MAX_METADATA_LENGTH])
-}
-
pub(crate) fn truncate_output(text: &str, max_output_tokens: usize) -> String {
if max_output_tokens == 0 {
return String::new();
diff --git a/crates/core/src/tools/shell_exec/pipe.rs b/crates/core/src/tools/shell_exec/pipe.rs
index 4e772435..e0797bc4 100644
--- a/crates/core/src/tools/shell_exec/pipe.rs
+++ b/crates/core/src/tools/shell_exec/pipe.rs
@@ -13,7 +13,6 @@ use crate::events::ToolProgressSender;
use crate::invocation::FunctionToolOutput;
use super::launch::SandboxLaunchPlan;
-use super::preview;
use super::resolve::ResolvedShellRun;
use super::truncate_output;
@@ -41,7 +40,6 @@ pub(crate) async fn run_with_pipes(
} = run;
info!(command = %command_to_run, shell = shell.program, "executing shell command");
- let command_preview = preview(&command_to_run);
let plan = match SandboxLaunchPlan::prepare_pipe(
sandbox_profile.as_deref(),
@@ -125,7 +123,7 @@ pub(crate) async fn run_with_pipes(
Ok(FunctionToolOutput::success_with_metadata(
result_text.clone(),
json!({
- "command": command_preview,
+ "command": command_to_run,
"exit": status.code(),
"description": description,
"cwd": workdir,
diff --git a/crates/core/src/tools/shell_exec/tests.rs b/crates/core/src/tools/shell_exec/tests.rs
index d0044adc..18e18dbb 100644
--- a/crates/core/src/tools/shell_exec/tests.rs
+++ b/crates/core/src/tools/shell_exec/tests.rs
@@ -278,7 +278,7 @@ async fn execute_shell_command_error_output_is_text_only() {
assert!(matches!(result.content, ToolContent::Text(text) if text.contains("exit code 7")));
}
-use super::{platform_shell_program, preview, resolve_shell, truncate_output};
+use super::{platform_shell_program, resolve_shell, truncate_output};
#[cfg(unix)]
#[tokio::test]
@@ -412,13 +412,6 @@ fn resolve_shell_defaults_to_platform_shell_login() {
assert_eq!(spec.program, platform_shell_program(true));
}
-#[test]
-fn preview_truncates_long_text() {
- let long = "a".repeat(30_001);
- let result = preview(&long);
- assert!(result.ends_with("\n\n..."));
-}
-
#[test]
fn truncate_output_handles_zero_tokens() {
assert_eq!(truncate_output("text", 0), "");
diff --git a/crates/core/tests/context_limit_compaction.rs b/crates/core/tests/context_limit_compaction.rs
index cf81b136..d108fc09 100644
--- a/crates/core/tests/context_limit_compaction.rs
+++ b/crates/core/tests/context_limit_compaction.rs
@@ -125,7 +125,7 @@ async fn context_limit_error_compacts_and_retries_query() {
.iter()
.filter_map(|event| match event {
QueryEvent::ContextCompactionStarted => Some(CompactionEvent::Started),
- QueryEvent::ContextCompactionCompleted => Some(CompactionEvent::Completed),
+ QueryEvent::ContextCompactionCompleted { .. } => Some(CompactionEvent::Completed),
QueryEvent::ContextCompactionFailed { .. }
| QueryEvent::ProviderRetryStatus(_)
| QueryEvent::TextDelta(_)
diff --git a/crates/core/tests/legacy_projector.rs b/crates/core/tests/legacy_projector.rs
index 82dead4b..dc65f39e 100644
--- a/crates/core/tests/legacy_projector.rs
+++ b/crates/core/tests/legacy_projector.rs
@@ -81,6 +81,7 @@ fn session_record(n: u128) -> SessionRecord {
title_state: SessionTitleState::Final(SessionTitleFinalSource::ModelGenerated),
sandbox_policy: "workspace-write".into(),
approval_mode: "on-request".into(),
+ effective_context_window: None,
tokens_used: 12345,
first_user_message: Some("Fix the flaky test".into()),
archived_at: None,
@@ -90,6 +91,8 @@ fn session_record(n: u128) -> SessionRecord {
parent_session_id: None,
session_context: None,
latest_turn_context: None,
+ collaboration_mode: None,
+ permission_preset: None,
schema_version: 2,
}
}
@@ -118,6 +121,7 @@ fn turn_record(n: u128, session: u128) -> TurnRecord {
total_tokens: Some(150),
}),
latest_query_usage: None,
+ context_occupancy: None,
stop_reason: None,
failure_reason: None,
error: None,
@@ -292,6 +296,7 @@ fn basic_session_lines() -> Vec {
turn_id: turn_id(turn),
summary_item_id: item_id(0xb9),
preserved_item_ids: vec![item_id(0xb3), item_id(0xb4)],
+ context_occupancy: None,
})),
RolloutLine::SessionRollback(Box::new(SessionRollbackLine {
timestamp: ts(32),
diff --git a/crates/core/tests/v2_roundtrip.rs b/crates/core/tests/v2_roundtrip.rs
index d3728632..eaab4b73 100644
--- a/crates/core/tests/v2_roundtrip.rs
+++ b/crates/core/tests/v2_roundtrip.rs
@@ -287,6 +287,8 @@ fn normalize_session(session: &SessionRecord) -> SessionRecord {
},
archived_at: session.archived_at.map(|_| session.created_at),
latest_turn_context: None,
+ collaboration_mode: None,
+ permission_preset: None,
schema_version: 2,
..session.clone()
}
@@ -427,6 +429,7 @@ fn live_write_lines() -> Vec {
title_state: SessionTitleState::Unset,
sandbox_policy: "workspace-write".into(),
approval_mode: "on-request".into(),
+ effective_context_window: None,
tokens_used: 0,
first_user_message: None,
archived_at: None,
@@ -436,6 +439,8 @@ fn live_write_lines() -> Vec {
parent_session_id: None,
session_context: None,
latest_turn_context: None,
+ collaboration_mode: None,
+ permission_preset: None,
schema_version: 2,
},
})),
@@ -457,6 +462,7 @@ fn live_write_lines() -> Vec {
input_token_estimate: None,
usage: None,
latest_query_usage: None,
+ context_occupancy: None,
stop_reason: None,
failure_reason: None,
error: None,
@@ -574,6 +580,7 @@ fn inverse_rejects_prefixed_canonical_ids() {
reasoning_effort: None,
mode: None,
sandbox_profile: None,
+ effective_context_window: None,
},
git_info: None,
preview: String::new(),
diff --git a/crates/protocol/README.md b/crates/protocol/README.md
index 85f54cea..d482e059 100644
--- a/crates/protocol/README.md
+++ b/crates/protocol/README.md
@@ -16,7 +16,8 @@ client-to-server ACP methods are:
- `session/prompt`: submit a prompt to an active session. The JSON-RPC response
returns when the turn completes (`AcpPromptResult.stopReason`). Streaming
progress is delivered through `session/update` notifications during the turn.
-- `session/cancel`: cancel the active session turn.
+- `session/cancel`: cancel the active session turn (including manual
+ `/compact`, which is admitted as a `ManualCompaction` turn).
Event-driven clients that need an immediate turn acknowledgement should use the
Devo extension `_devo/turn/start`, which returns `TurnStartResult::Started`
@@ -63,7 +64,8 @@ behavior that is not represented by the portable ACP method set.
- `_devo/session/metadata/update`: update session metadata such as the active
model or reasoning-effort selection.
- `_devo/session/permissions/update`: update the current permission preset.
-- `_devo/session/compact`: proactively compact a session context.
+- `_devo/session/compact`: start a manual compaction turn (`TurnStartResult`);
+ keep emitting `session/compaction/*` for UI.
- `_devo/session/fork`: fork a new session from an existing turn.
- `_devo/session/rollback`: roll back a session to a selected user turn.
diff --git a/crates/protocol/src/acp_session.rs b/crates/protocol/src/acp_session.rs
index 3f3266e4..97e76651 100644
--- a/crates/protocol/src/acp_session.rs
+++ b/crates/protocol/src/acp_session.rs
@@ -197,8 +197,11 @@ mod tests {
prompt_token_estimate: 0,
last_query_usage: None,
last_query_total_tokens: 0,
+ last_context_occupancy: None,
status: SessionRuntimeStatus::Idle,
collaboration_mode: Default::default(),
+ effective_context_window: None,
+ permission_preset: None,
};
let info = acp_session_info_from_metadata(&session);
diff --git a/crates/protocol/src/acp_ts.rs b/crates/protocol/src/acp_ts.rs
index 2fcd7091..353728f1 100644
--- a/crates/protocol/src/acp_ts.rs
+++ b/crates/protocol/src/acp_ts.rs
@@ -9,6 +9,11 @@ use serde::Serialize;
use ts_rs::Config;
use ts_rs::TS;
+use crate::canonical::item::ContextCategoryId;
+use crate::canonical::item::ContextCategoryUsage;
+use crate::canonical::item::ContextOccupancy;
+use crate::canonical::rpc_admin::ContextUsageReadParams;
+use crate::canonical::rpc_admin::ContextUsageReadResult;
use crate::canonical::rpc_admin::McpListParams;
use crate::canonical::rpc_admin::McpListResult;
use crate::canonical::rpc_admin::McpServerInfo;
@@ -198,7 +203,6 @@ pub fn generate_protocol_typescript() -> String {
push_decl::(&cfg, &mut output);
push_decl::(&cfg, &mut output);
push_decl::(&cfg, &mut output);
- push_decl::(&cfg, &mut output);
push_decl::(&cfg, &mut output);
push_decl::(&cfg, &mut output);
push_decl::(&cfg, &mut output);
@@ -223,12 +227,6 @@ pub fn generate_protocol_typescript() -> String {
push_decl::(&cfg, &mut output);
push_decl::(&cfg, &mut output);
push_decl::(&cfg, &mut output);
- push_decl::(&cfg, &mut output);
- push_decl::(&cfg, &mut output);
- push_decl::(&cfg, &mut output);
- push_decl::(&cfg, &mut output);
- push_decl::(&cfg, &mut output);
- push_decl::(&cfg, &mut output);
push_decl::(&cfg, &mut output);
push_decl::(&cfg, &mut output);
push_decl::(&cfg, &mut output);
@@ -255,6 +253,9 @@ pub fn generate_protocol_typescript() -> String {
push_decl::(&cfg, &mut output);
push_decl::(&cfg, &mut output);
push_decl::(&cfg, &mut output);
+ push_decl::(&cfg, &mut output);
+ push_decl::(&cfg, &mut output);
+ push_decl::(&cfg, &mut output);
push_decl::(&cfg, &mut output);
push_decl::(&cfg, &mut output);
@@ -293,6 +294,11 @@ pub fn generate_protocol_typescript() -> String {
push_decl::(&cfg, &mut output);
push_decl::(&cfg, &mut output);
push_decl::(&cfg, &mut output);
+ push_decl::(&cfg, &mut output);
+ push_decl::(&cfg, &mut output);
+ push_decl::(&cfg, &mut output);
+ push_decl::(&cfg, &mut output);
+ push_decl::(&cfg, &mut output);
push_decl::(&cfg, &mut output);
push_decl::(&cfg, &mut output);
@@ -637,6 +643,9 @@ fn register_devo_protocol_schemas(
schema::(schemas);
schema::(schemas);
schema::(schemas);
+ schema::(schemas);
+ schema::(schemas);
+ schema::(schemas);
schema::(schemas);
schema::(schemas);
schema::(schemas);
@@ -648,7 +657,6 @@ fn register_devo_protocol_schemas(
schema::(schemas);
schema::(schemas);
schema::(schemas);
- schema::(schemas);
schema::(schemas);
schema::(schemas);
schema::(schemas);
@@ -663,6 +671,8 @@ fn register_devo_protocol_schemas(
schema::(schemas);
schema::(schemas);
schema::(schemas);
+ schema::(schemas);
+ schema::(schemas);
schema::(schemas);
schema::(schemas);
schema::(schemas);
@@ -686,12 +696,6 @@ fn register_devo_protocol_schemas(
schema::(schemas);
schema::(schemas);
schema::(schemas);
- schema::(schemas);
- schema::(schemas);
- schema::(schemas);
- schema::(schemas);
- schema::(schemas);
- schema::(schemas);
schema::(schemas);
schema::(schemas);
schema::(schemas);
@@ -763,6 +767,14 @@ fn register_devo_protocol_schemas(
methods,
ClientMethod::SessionPermissionsUpdate,
);
+ devo_method::(
+ methods,
+ ClientMethod::SessionCompactionUpdate,
+ );
+ devo_notification::(
+ methods,
+ "session/effective_context_window/updated",
+ );
devo_method::(
methods,
ClientMethod::SessionSandboxProfileUpdate,
@@ -777,10 +789,7 @@ fn register_devo_protocol_schemas(
methods,
ClientMethod::SessionRollback,
);
- devo_method::(
- methods,
- ClientMethod::SessionCompact,
- );
+ devo_method::(methods, ClientMethod::SessionCompact);
devo_method::(methods, ClientMethod::SkillsList);
devo_method::(methods, ClientMethod::SkillsChanged);
devo_method::(
@@ -790,6 +799,10 @@ fn register_devo_protocol_schemas(
devo_method::(methods, ClientMethod::McpList);
devo_method::(methods, ClientMethod::McpTools);
devo_method::(methods, ClientMethod::McpSetEnabled);
+ devo_method::(
+ methods,
+ ClientMethod::ContextUsageRead,
+ );
devo_method::(methods, ClientMethod::ModelCatalog);
devo_method::(methods, ClientMethod::ModelConfig);
devo_method::(methods, ClientMethod::ModelConfigSet);
@@ -814,15 +827,6 @@ fn register_devo_protocol_schemas(
devo_method::(methods, ClientMethod::TurnStart);
devo_method::(methods, ClientMethod::TurnShellCommand);
devo_method::(methods, ClientMethod::TurnInterrupt);
- devo_method::(methods, ClientMethod::TurnSteer);
- devo_method::(
- methods,
- ClientMethod::TurnQueueRemove,
- );
- devo_method::(
- methods,
- ClientMethod::TurnQueueSteer,
- );
devo_method::(
methods,
ClientMethod::WorkspaceChangesRead,
diff --git a/crates/protocol/src/canonical/event.rs b/crates/protocol/src/canonical/event.rs
index c290a9ca..4326b443 100644
--- a/crates/protocol/src/canonical/event.rs
+++ b/crates/protocol/src/canonical/event.rs
@@ -25,7 +25,7 @@ use super::ids::SubscriptionId;
use super::ids::TurnId;
use super::item::ApprovalDecision;
use super::item::CompactionTrigger;
-use super::item::ContextUsage;
+use super::item::ContextOccupancy;
use super::item::ItemEnvelope;
use super::item::SpawnedWorkState;
use super::queue::QueueChange;
@@ -222,7 +222,7 @@ pub enum ServerNotification {
#[serde(rename = "context/usageUpdated")]
ContextUsageUpdated {
session_id: SessionId,
- usage: ContextUsage,
+ occupancy: ContextOccupancy,
},
#[serde(rename = "context/compactionStarted")]
ContextCompactionStarted {
diff --git a/crates/protocol/src/canonical/item.rs b/crates/protocol/src/canonical/item.rs
index 82fc2eec..403a56b4 100644
--- a/crates/protocol/src/canonical/item.rs
+++ b/crates/protocol/src/canonical/item.rs
@@ -565,6 +565,9 @@ pub enum CompactionTrigger {
/// Context-window occupancy snapshot (distinct from billing usage, see the
/// usage module). `measured = false` marks parts that were not precisely
/// metered.
+///
+/// Used on compaction items (`before` / `after`). Prefer [`ContextOccupancy`]
+/// for `context/usage/read` and `context/usageUpdated`.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct ContextUsage {
@@ -578,12 +581,221 @@ pub struct ContextUsage {
pub measured: bool,
}
+/// Category buckets for model-visible context occupancy.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, TS)]
+#[serde(rename_all = "camelCase")]
+pub enum ContextCategoryId {
+ Base,
+ Skills,
+ ToolsBuiltin,
+ ToolsMcp,
+ Conversation,
+}
+
+/// One category's share of the current context window occupancy.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
+#[serde(rename_all = "camelCase")]
+pub struct ContextCategoryUsage {
+ pub id: ContextCategoryId,
+ pub tokens: u64,
+ /// Share in basis points (0..=10_000); categories sum to 10_000 when
+ /// `total_tokens > 0`.
+ pub share_bps: u16,
+}
+
+/// Category breakdown of how full the effective context window is.
+///
+/// Distinct from billing usage: this answers "what fills the window", not
+/// "how much was spent".
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
+#[serde(rename_all = "camelCase")]
+pub struct ContextOccupancy {
+ pub total_tokens: u64,
+ /// Effective context window (`context_window * percent / 100`).
+ pub context_window_tokens: u64,
+ pub categories: Vec,
+}
+
+impl ContextOccupancy {
+ pub fn empty(context_window_tokens: u64) -> Self {
+ Self {
+ total_tokens: 0,
+ context_window_tokens,
+ categories: Vec::new(),
+ }
+ }
+
+ /// Build occupancy from absolute category token counts.
+ ///
+ /// Categories are emitted in a stable order. `share_bps` uses largest-
+ /// remainder so shares sum to 10_000 when `total > 0`.
+ pub fn from_category_tokens(
+ context_window_tokens: u64,
+ base: u64,
+ skills: u64,
+ tools_builtin: u64,
+ tools_mcp: u64,
+ conversation: u64,
+ ) -> Self {
+ let parts = [
+ (ContextCategoryId::Base, base),
+ (ContextCategoryId::Skills, skills),
+ (ContextCategoryId::ToolsBuiltin, tools_builtin),
+ (ContextCategoryId::ToolsMcp, tools_mcp),
+ (ContextCategoryId::Conversation, conversation),
+ ];
+ let total_tokens = parts.iter().map(|(_, tokens)| *tokens).sum::();
+ let categories = if total_tokens == 0 {
+ Vec::new()
+ } else {
+ let mut shares = [0u16; 5];
+ let mut remainders = [0u128; 5];
+ let mut assigned = 0u32;
+ for (idx, (_, tokens)) in parts.iter().enumerate() {
+ let product = (*tokens as u128).saturating_mul(10_000);
+ shares[idx] = u16::try_from(product.checked_div(total_tokens as u128).unwrap_or(0))
+ .unwrap_or(u16::MAX);
+ remainders[idx] = product % (total_tokens as u128);
+ assigned = assigned.saturating_add(u32::from(shares[idx]));
+ }
+ let mut leftover = 10_000u32.saturating_sub(assigned);
+ let mut order: Vec = (0..parts.len()).collect();
+ order.sort_by(|&a, &b| remainders[b].cmp(&remainders[a]).then_with(|| a.cmp(&b)));
+ for idx in order {
+ if leftover == 0 {
+ break;
+ }
+ if parts[idx].1 == 0 {
+ continue;
+ }
+ shares[idx] = shares[idx].saturating_add(1);
+ leftover = leftover.saturating_sub(1);
+ }
+ parts
+ .iter()
+ .enumerate()
+ .filter(|(_, (_, tokens))| *tokens > 0)
+ .map(|(idx, (id, tokens))| ContextCategoryUsage {
+ id: *id,
+ tokens: *tokens,
+ share_bps: shares[idx],
+ })
+ .collect()
+ };
+ Self {
+ total_tokens,
+ context_window_tokens,
+ categories,
+ }
+ }
+
+ /// Scale raw category estimates so they sum exactly to `anchor_total`.
+ pub fn scale_raw_to_total(
+ context_window_tokens: u64,
+ anchor_total: u64,
+ base: u64,
+ skills: u64,
+ tools_builtin: u64,
+ tools_mcp: u64,
+ conversation: u64,
+ ) -> Self {
+ let raw_total = base
+ .saturating_add(skills)
+ .saturating_add(tools_builtin)
+ .saturating_add(tools_mcp)
+ .saturating_add(conversation);
+ if anchor_total == 0 || raw_total == 0 {
+ return Self::empty(context_window_tokens);
+ }
+ let parts = [base, skills, tools_builtin, tools_mcp, conversation];
+ let mut scaled = [0u64; 5];
+ let mut remainders = [0u128; 5];
+ let mut assigned = 0u64;
+ for (idx, tokens) in parts.iter().enumerate() {
+ let product = (*tokens as u128).saturating_mul(anchor_total as u128);
+ scaled[idx] = (product / raw_total as u128) as u64;
+ remainders[idx] = product % raw_total as u128;
+ assigned = assigned.saturating_add(scaled[idx]);
+ }
+ let mut leftover = anchor_total.saturating_sub(assigned);
+ let mut order: Vec = (0..parts.len()).collect();
+ order.sort_by(|&a, &b| remainders[b].cmp(&remainders[a]).then_with(|| a.cmp(&b)));
+ for idx in order {
+ if leftover == 0 {
+ break;
+ }
+ if parts[idx] == 0 {
+ continue;
+ }
+ scaled[idx] = scaled[idx].saturating_add(1);
+ leftover = leftover.saturating_sub(1);
+ }
+ Self::from_category_tokens(
+ context_window_tokens,
+ scaled[0],
+ scaled[1],
+ scaled[2],
+ scaled[3],
+ scaled[4],
+ )
+ }
+
+ pub fn tokens_for(&self, id: ContextCategoryId) -> u64 {
+ self.categories
+ .iter()
+ .find(|category| category.id == id)
+ .map(|category| category.tokens)
+ .unwrap_or(0)
+ }
+}
+
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use super::*;
+ #[test]
+ fn context_occupancy_share_bps_sum_to_10000() {
+ let occupancy = ContextOccupancy::from_category_tokens(
+ /*context_window_tokens*/ 100_000, /*base*/ 10, /*skills*/ 20,
+ /*tools_builtin*/ 30, /*tools_mcp*/ 40, /*conversation*/ 0,
+ );
+ assert_eq!(occupancy.total_tokens, 100);
+ let share_sum: u32 = occupancy
+ .categories
+ .iter()
+ .map(|category| u32::from(category.share_bps))
+ .sum();
+ assert_eq!(share_sum, 10_000);
+ assert_eq!(
+ occupancy
+ .categories
+ .iter()
+ .map(|category| category.tokens)
+ .sum::(),
+ 100
+ );
+ }
+
+ #[test]
+ fn context_occupancy_scale_raw_matches_anchor() {
+ let occupancy = ContextOccupancy::scale_raw_to_total(
+ /*context_window_tokens*/ 100_000, /*anchor_total*/ 1_000, /*base*/ 1,
+ /*skills*/ 1, /*tools_builtin*/ 1, /*tools_mcp*/ 1,
+ /*conversation*/ 6,
+ );
+ assert_eq!(occupancy.total_tokens, 1_000);
+ assert_eq!(
+ occupancy
+ .categories
+ .iter()
+ .map(|category| category.tokens)
+ .sum::(),
+ 1_000
+ );
+ }
+
#[test]
fn user_message_serializes_with_camel_case_tag_and_defaults() {
let item = Item::UserMessage {
diff --git a/crates/protocol/src/canonical/rpc_admin.rs b/crates/protocol/src/canonical/rpc_admin.rs
index 6d6ac10f..04a04410 100644
--- a/crates/protocol/src/canonical/rpc_admin.rs
+++ b/crates/protocol/src/canonical/rpc_admin.rs
@@ -8,7 +8,7 @@ use serde::Serialize;
use ts_rs::TS;
use super::ids::SessionId;
-use super::item::ContextUsage;
+use super::item::ContextOccupancy;
use super::item::ToolSource;
use super::model::PermissionProfile;
@@ -216,11 +216,11 @@ pub struct ContextUsageReadParams {
pub session_id: SessionId,
}
-/// Parts that were not precisely metered are marked `measured = false`.
+/// Current context-window occupancy with category breakdown.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct ContextUsageReadResult {
- pub usage: ContextUsage,
+ pub occupancy: ContextOccupancy,
}
// ── permission/profile/* ──
diff --git a/crates/protocol/src/canonical/session.rs b/crates/protocol/src/canonical/session.rs
index b9724093..6cd5583c 100644
--- a/crates/protocol/src/canonical/session.rs
+++ b/crates/protocol/src/canonical/session.rs
@@ -136,6 +136,12 @@ pub struct SessionSettings {
/// resuming the session.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sandbox_profile: Option,
+ /// Session override for the absolute effective context window (tokens).
+ /// When set, takes precedence over the model-derived effective window and
+ /// is clamped to `model.context_window` at resolve time. Also drives the
+ /// automatic-compaction boundary.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub effective_context_window: Option,
}
/// Snapshot semantics: the current value is *copied* into the record at
diff --git a/crates/protocol/src/canonical/wire_projector.rs b/crates/protocol/src/canonical/wire_projector.rs
index 827e5805..ec22b8b6 100644
--- a/crates/protocol/src/canonical/wire_projector.rs
+++ b/crates/protocol/src/canonical/wire_projector.rs
@@ -838,10 +838,13 @@ mod tests {
#[test]
fn typed_notification_skips_non_item_events() {
- let event = ServerEvent::InputQueueUpdated(crate::InputQueueUpdatedPayload {
- session_id: crate::SessionId::new(),
- pending_count: 0,
- pending_texts: Vec::new(),
+ let event = ServerEvent::ReferenceSearchUpdated(crate::ReferenceSearchSnapshot {
+ search_id: crate::ReferenceSearchId::new(),
+ query: "q".to_string(),
+ results: Vec::new(),
+ total_file_match_count: 0,
+ scanned_file_count: 0,
+ file_search_complete: false,
});
assert_eq!(typed_item_notification_from_server_event(&event), None);
}
diff --git a/crates/protocol/src/event.rs b/crates/protocol/src/event.rs
index fae9318f..606a5cd5 100644
--- a/crates/protocol/src/event.rs
+++ b/crates/protocol/src/event.rs
@@ -188,6 +188,12 @@ pub struct TurnUsageUpdatedPayload {
pub context_window: Option,
}
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ContextUsageUpdatedPayload {
+ pub session_id: SessionId,
+ pub occupancy: crate::canonical::item::ContextOccupancy,
+}
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolCallStatusUpdatedPayload {
pub session_id: SessionId,
@@ -213,6 +219,13 @@ pub struct SessionStatusChangedPayload {
pub status: SessionRuntimeStatus,
}
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
+#[serde(rename_all = "camelCase")]
+pub struct SessionEffectiveContextWindowUpdatedPayload {
+ pub session_id: SessionId,
+ pub effective_context_window: u64,
+}
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionCompactionFailedPayload {
pub session_id: SessionId,
@@ -226,19 +239,6 @@ pub struct ServerRequestResolvedPayload {
pub turn_id: Option,
}
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct InputQueueUpdatedPayload {
- pub session_id: SessionId,
- pub pending_count: usize,
- pub pending_texts: Vec,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct SteerAcceptedPayload {
- pub session_id: SessionId,
- pub turn_id: TurnId,
-}
-
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MessageEditRecordedPayload {
pub session_id: SessionId,
@@ -389,6 +389,7 @@ pub enum ServerEvent {
SessionCompactionCompleted(SessionEventPayload),
SessionCompactionFailed(SessionCompactionFailedPayload),
SessionStatusChanged(SessionStatusChangedPayload),
+ SessionEffectiveContextWindowUpdated(SessionEffectiveContextWindowUpdatedPayload),
SessionArchived(SessionEventPayload),
SessionUnarchived(SessionEventPayload),
SessionClosed(SessionEventPayload),
@@ -400,12 +401,11 @@ pub enum ServerEvent {
TurnPlanUpdated(TurnPlanUpdatedPayload),
TurnDiffUpdated(TurnEventPayload),
TurnUsageUpdated(TurnUsageUpdatedPayload),
+ ContextUsageUpdated(ContextUsageUpdatedPayload),
TurnProviderRetryStatus(TurnProviderRetryStatusPayload),
WorkspaceChangesUpdated(WorkspaceChangesUpdatedPayload),
ToolCallStatusUpdated(ToolCallStatusUpdatedPayload),
RequestUserInput(RequestUserInputPayload),
- InputQueueUpdated(InputQueueUpdatedPayload),
- SteerAccepted(SteerAcceptedPayload),
MessageEditRecorded(MessageEditRecordedPayload),
TurnSuperseded(TurnSupersededPayload),
WorkspaceRestoreStarted(WorkspaceRestoreStartedPayload),
@@ -437,6 +437,7 @@ impl ServerEvent {
Self::SessionDeleted(payload) => Some(payload.session_id),
Self::SessionCompactionFailed(payload) => Some(payload.session_id),
Self::SessionStatusChanged(payload) => Some(payload.session_id),
+ Self::SessionEffectiveContextWindowUpdated(payload) => Some(payload.session_id),
Self::TurnStarted(payload)
| Self::TurnCompleted(payload)
| Self::TurnInterrupted(payload)
@@ -444,12 +445,11 @@ impl ServerEvent {
Self::TurnFailed(payload) => Some(payload.session_id),
Self::TurnPlanUpdated(payload) => Some(payload.session_id),
Self::TurnUsageUpdated(payload) => Some(payload.session_id),
+ Self::ContextUsageUpdated(payload) => Some(payload.session_id),
Self::TurnProviderRetryStatus(payload) => Some(payload.session_id),
Self::WorkspaceChangesUpdated(payload) => Some(payload.session_id),
Self::ToolCallStatusUpdated(payload) => Some(payload.session_id),
Self::RequestUserInput(payload) => Some(payload.request.session_id),
- Self::InputQueueUpdated(payload) => Some(payload.session_id),
- Self::SteerAccepted(payload) => Some(payload.session_id),
Self::MessageEditRecorded(payload) => Some(payload.session_id),
Self::TurnSuperseded(payload) => Some(payload.session_id),
Self::WorkspaceRestoreStarted(payload) => Some(payload.session_id),
@@ -475,6 +475,9 @@ impl ServerEvent {
Self::SessionCompactionCompleted(_) => "session/compaction/completed",
Self::SessionCompactionFailed(_) => "session/compaction/failed",
Self::SessionStatusChanged(_) => "session/status/changed",
+ Self::SessionEffectiveContextWindowUpdated(_) => {
+ "session/effective_context_window/updated"
+ }
Self::SessionArchived(_) => "session/archived",
Self::SessionUnarchived(_) => "session/unarchived",
Self::SessionClosed(_) => "session/closed",
@@ -486,12 +489,11 @@ impl ServerEvent {
Self::TurnPlanUpdated(_) => "turn/plan/updated",
Self::TurnDiffUpdated(_) => "turn/diff/updated",
Self::TurnUsageUpdated(_) => "turn/usage/updated",
+ Self::ContextUsageUpdated(_) => "context/usageUpdated",
Self::TurnProviderRetryStatus(_) => "turn/provider_retry_status",
Self::WorkspaceChangesUpdated(_) => "workspace/changes/updated",
Self::ToolCallStatusUpdated(_) => "tool_call/status_updated",
Self::RequestUserInput(_) => "item/tool/requestUserInput",
- Self::InputQueueUpdated(_) => "inputQueue/updated",
- Self::SteerAccepted(_) => "steer/accepted",
Self::MessageEditRecorded(_) => "message/edit/recorded",
Self::TurnSuperseded(_) => "turn/superseded",
Self::WorkspaceRestoreStarted(_) => "workspace_restore_started",
@@ -522,12 +524,11 @@ impl ServerEvent {
}
Self::ItemDelta { payload, .. } => payload.context.seq = seq,
Self::TurnUsageUpdated(_)
+ | Self::ContextUsageUpdated(_)
| Self::TurnProviderRetryStatus(_)
| Self::WorkspaceChangesUpdated(_)
| Self::ToolCallStatusUpdated(_)
| Self::RequestUserInput(_)
- | Self::InputQueueUpdated(_)
- | Self::SteerAccepted(_)
| Self::MessageEditRecorded(_)
| Self::TurnSuperseded(_)
| Self::WorkspaceRestoreStarted(_)
@@ -553,19 +554,6 @@ mod tests {
WorkspaceChangeStats, WorkspaceChangeViewStatus,
};
- #[test]
- fn input_queue_updated_event_roundtrips() {
- let payload = InputQueueUpdatedPayload {
- session_id: SessionId::new(),
- pending_count: 3,
- pending_texts: vec!["first".into(), "second".into()],
- };
- let json = serde_json::to_string(&payload).expect("serialize");
- let restored: InputQueueUpdatedPayload = serde_json::from_str(&json).expect("deserialize");
- assert_eq!(restored.pending_count, 3);
- assert_eq!(restored.pending_texts, vec!["first", "second"]);
- }
-
#[test]
fn turn_failed_payload_serializes_error_and_accepts_legacy_shape() {
let session_id = SessionId::new();
@@ -656,18 +644,6 @@ mod tests {
);
}
- #[test]
- fn steer_accepted_event_roundtrips() {
- let turn_id = TurnId::new();
- let payload = SteerAcceptedPayload {
- session_id: SessionId::new(),
- turn_id,
- };
- let json = serde_json::to_string(&payload).expect("serialize");
- let restored: SteerAcceptedPayload = serde_json::from_str(&json).expect("deserialize");
- assert_eq!(restored.turn_id, turn_id);
- }
-
#[test]
fn message_edit_events_roundtrip_and_report_methods() {
let session_id = SessionId::new();
@@ -749,27 +725,6 @@ mod tests {
assert_eq!(restore_completed_event.session_id(), Some(session_id));
}
- #[test]
- fn server_event_input_queue_updated_method_name() {
- let event = ServerEvent::InputQueueUpdated(InputQueueUpdatedPayload {
- session_id: SessionId::new(),
- pending_count: 0,
- pending_texts: vec![],
- });
- assert_eq!(event.method_name(), "inputQueue/updated");
- assert!(event.session_id().is_some());
- }
-
- #[test]
- fn server_event_steer_accepted_method_name() {
- let event = ServerEvent::SteerAccepted(SteerAcceptedPayload {
- session_id: SessionId::new(),
- turn_id: TurnId::new(),
- });
- assert_eq!(event.method_name(), "steer/accepted");
- assert!(event.session_id().is_some());
- }
-
#[test]
fn workspace_changes_updated_method_name() {
let session_id = SessionId::new();
diff --git a/crates/protocol/src/protocol.rs b/crates/protocol/src/protocol.rs
index 02c73d38..ac3f51d2 100644
--- a/crates/protocol/src/protocol.rs
+++ b/crates/protocol/src/protocol.rs
@@ -64,6 +64,7 @@ pub enum ClientMethod {
SessionFork,
SessionRollback,
SessionCompact,
+ SessionCompactionUpdate,
SkillsList,
SkillsChanged,
SkillsSetEnabled,
@@ -79,9 +80,6 @@ pub enum ClientMethod {
TurnStart,
TurnShellCommand,
TurnInterrupt,
- TurnSteer,
- TurnQueueRemove,
- TurnQueueSteer,
WorkspaceChangesRead,
RequestUserInputRespond,
SearchStart,
@@ -108,6 +106,7 @@ pub enum ClientMethod {
McpList,
McpTools,
McpSetEnabled,
+ ContextUsageRead,
// New Native API methods (canonical types; not part of the legacy
// `_devo/*` alias surface).
SessionTurnsList,
@@ -136,6 +135,7 @@ impl ClientMethod {
Self::SessionFork => "session/fork",
Self::SessionRollback => "session/rollback",
Self::SessionCompact => "session/compact",
+ Self::SessionCompactionUpdate => "session/compaction/update",
Self::SkillsList => "skills/list",
Self::SkillsChanged => "skills/changed",
Self::SkillsSetEnabled => "skills/set_enabled",
@@ -151,9 +151,6 @@ impl ClientMethod {
Self::TurnStart => "turn/start",
Self::TurnShellCommand => "turn/shell_command",
Self::TurnInterrupt => "turn/interrupt",
- Self::TurnSteer => "turn/steer",
- Self::TurnQueueRemove => "turn/queue/remove",
- Self::TurnQueueSteer => "turn/queue/steer",
Self::WorkspaceChangesRead => "workspace/changes/read",
Self::RequestUserInputRespond => "request_user_input/respond",
Self::SearchStart => "search/start",
@@ -180,6 +177,7 @@ impl ClientMethod {
Self::McpList => "mcp/list",
Self::McpTools => "mcp/tools",
Self::McpSetEnabled => "mcp/set_enabled",
+ Self::ContextUsageRead => "context/usage/read",
Self::SessionTurnsList => "session/turns/list",
Self::SessionItemsList => "session/items/list",
Self::SessionRollbackPreview => "session/rollback/preview",
@@ -206,6 +204,7 @@ impl ClientMethod {
"session/fork" => Self::SessionFork,
"session/rollback" => Self::SessionRollback,
"session/compact" => Self::SessionCompact,
+ "session/compaction/update" => Self::SessionCompactionUpdate,
"skills/list" => Self::SkillsList,
"skills/changed" => Self::SkillsChanged,
"skills/set_enabled" => Self::SkillsSetEnabled,
@@ -221,9 +220,6 @@ impl ClientMethod {
"turn/start" => Self::TurnStart,
"turn/shell_command" => Self::TurnShellCommand,
"turn/interrupt" => Self::TurnInterrupt,
- "turn/steer" => Self::TurnSteer,
- "turn/queue/remove" => Self::TurnQueueRemove,
- "turn/queue/steer" => Self::TurnQueueSteer,
"workspace/changes/read" => Self::WorkspaceChangesRead,
"request_user_input/respond" => Self::RequestUserInputRespond,
"search/start" => Self::SearchStart,
@@ -250,6 +246,7 @@ impl ClientMethod {
"mcp/list" => Self::McpList,
"mcp/tools" => Self::McpTools,
"mcp/set_enabled" => Self::McpSetEnabled,
+ "context/usage/read" => Self::ContextUsageRead,
"session/turns/list" => Self::SessionTurnsList,
"session/items/list" => Self::SessionItemsList,
"session/rollback/preview" => Self::SessionRollbackPreview,
@@ -774,5 +771,13 @@ mod tests {
ClientMethod::SessionSandboxProfileUpdate.as_str(),
"session/sandbox_profile/update"
);
+ assert_eq!(
+ ClientMethod::SessionCompactionUpdate.as_str(),
+ "session/compaction/update"
+ );
+ assert_eq!(
+ ClientMethod::parse("session/compaction/update"),
+ Some(ClientMethod::SessionCompactionUpdate)
+ );
}
}
diff --git a/crates/protocol/src/session.rs b/crates/protocol/src/session.rs
index e4c6ac7d..7138741d 100644
--- a/crates/protocol/src/session.rs
+++ b/crates/protocol/src/session.rs
@@ -15,6 +15,7 @@ use crate::SessionTitleState;
use crate::TurnId;
use crate::TurnUsage;
use crate::parse_command::ParsedCommand;
+use crate::permissions::PermissionPreset;
use crate::protocol::FileChange;
use crate::turn::CollaborationMode;
use crate::turn::TurnMetadata;
@@ -73,19 +74,39 @@ pub struct SessionMetadata {
/// the latest completed-query usage rather than rolling session totals.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_query_usage: Option,
- /// Legacy/derived projection of [`Self::last_query_usage`] display total.
+ /// Compatibility scalar for current context-window occupancy.
///
- /// Prefer `last_query_usage` when available. When both are set, this should
- /// equal `last_query_usage.display_total_tokens()`. Provider-reported
- /// `total_tokens` is used when available; otherwise this falls back to
- /// `input_tokens + output_tokens`.
+ /// Prefer [`Self::last_query_usage`] when available for provider query
+ /// accounting. Prefer [`Self::last_context_occupancy`] when available for
+ /// window occupancy (including category breakdown).
///
- /// This value is refreshed on every completed model invoke so the UI can
- /// show the latest completed-query usage after each request, and it remains
- /// the persisted value used when a session is resumed. While a turn is in
- /// flight, the UI may temporarily fall back to the live prompt estimate
- /// instead.
+ /// When `last_context_occupancy` is set, this should equal
+ /// `last_context_occupancy.total_tokens` (a derived projection, not a
+ /// second independent counter). When occupancy is absent (for example
+ /// before the first completed query, or older resume payloads), this may
+ /// still carry a legacy scalar from `last_query_usage.display_total_tokens()`
+ /// or remain `0`.
+ ///
+ /// Kept for TUI status, resume, and existing `SessionMetadata` consumers
+ /// until they migrate to reading `last_context_occupancy.total_tokens`.
+ /// While a turn is in flight, the UI may temporarily fall back to the live
+ /// prompt estimate instead.
pub last_query_total_tokens: usize,
+ /// Source of truth for context-window occupancy and category shares.
+ ///
+ /// Includes `total_tokens`, effective `context_window_tokens`, and
+ /// category breakdown (`base`, `skills`, `toolsBuiltin`, `toolsMcp`,
+ /// `conversation`). New clients should use this for occupancy reads
+ /// (`context/usage/read`, `context/usageUpdated`) rather than inventing a
+ /// second total.
+ ///
+ /// Refreshed on completed turn queries (categories scaled so they sum to
+ /// the latest provider query display total) and immediately after
+ /// successful compaction (conversation bucket replaced; other buckets
+ /// reused). When present, [`Self::last_query_total_tokens`] should mirror
+ /// `total_tokens` as a compatibility projection.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub last_context_occupancy: Option,
pub status: SessionRuntimeStatus,
/// Collaboration mode restored from the latest completed turn context.
///
@@ -94,6 +115,17 @@ pub struct SessionMetadata {
/// composer (Plan vs Build) without waiting for the next turn start.
#[serde(default)]
pub collaboration_mode: CollaborationMode,
+ /// Session override for the absolute effective context window (tokens).
+ ///
+ /// When set, clients should treat this as the effective Settings Hub /
+ /// compaction-threshold value on resume (clamped to the model
+ /// `context_window`). Absent means use the model-derived
+ /// `effective_context_window()`.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub effective_context_window: Option,
+ /// Permission preset restored from session durable metadata.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub permission_preset: Option,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
@@ -139,6 +171,7 @@ pub enum SessionHistoryItemKind {
CommandExecution,
Error,
TurnSummary,
+ ContextCompaction,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
@@ -260,6 +293,8 @@ pub struct SessionMetadataUpdateParams {
pub model_binding_id: Option,
#[serde(default, alias = "thinking", skip_serializing_if = "Option::is_none")]
pub reasoning_effort_selection: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub collaboration_mode: Option,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
@@ -272,9 +307,27 @@ pub struct SessionCompactParams {
pub session_id: SessionId,
}
+/// Persists a global compaction token limit and hot-applies it to live sessions.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
-pub struct SessionCompactResult {
- pub session: SessionMetadata,
+#[serde(rename_all = "camelCase")]
+pub struct SessionCompactionUpdateParams {
+ pub session_id: SessionId,
+ /// Absolute global compaction threshold in tokens. Written to user
+ /// `config.toml` as `compaction_token_limit`. Values above a session model's
+ /// `context_window` are clamped when applied to that session.
+ pub effective_context_window: u64,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
+#[serde(rename_all = "camelCase")]
+pub struct SessionCompactionUpdateResult {
+ pub session_id: SessionId,
+ /// Applied absolute window for this session after clamp.
+ pub effective_context_window: u64,
+ /// Model default (`effective_context_window()`, clamped) used as recommended.
+ pub recommended_token_limit: u64,
+ /// Raw model context window used for clamp.
+ pub context_window_tokens: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
@@ -426,8 +479,11 @@ mod tests {
total_tokens: Some(21),
}),
last_query_total_tokens: 21,
+ last_context_occupancy: None,
status: SessionRuntimeStatus::Idle,
collaboration_mode: CollaborationMode::Plan,
+ effective_context_window: None,
+ permission_preset: None,
};
let json = serde_json::to_string(&metadata).expect("serialize");
diff --git a/crates/protocol/src/slash_command.rs b/crates/protocol/src/slash_command.rs
index e3c721f6..65489804 100644
--- a/crates/protocol/src/slash_command.rs
+++ b/crates/protocol/src/slash_command.rs
@@ -16,9 +16,9 @@ pub enum SlashCommand {
Rename,
Delete,
Status,
+ Settings,
Permissions,
ShowReasoning,
- Clear,
Diff,
Exit,
Btw,
@@ -28,7 +28,7 @@ pub enum SlashCommand {
impl SlashCommand {
pub fn description(self) -> &'static str {
match self {
- SlashCommand::Theme => "switch the UI theme",
+ SlashCommand::Theme => "open appearance settings to switch the UI theme",
SlashCommand::Model => "choose the active model",
SlashCommand::Skills => "browse available skills",
SlashCommand::Mcp => "browse MCP servers",
@@ -37,14 +37,14 @@ impl SlashCommand {
SlashCommand::New => "start a new chat",
SlashCommand::Rename => "rename the current session",
SlashCommand::Delete => "delete the current session and start a new one",
- SlashCommand::Status => "show current session configuration and token usage",
+ SlashCommand::Status => "show cwd, permissions, and context window occupancy",
+ SlashCommand::Settings => "open session and appearance settings",
SlashCommand::Permissions => {
"choose what Devo is allowed to do (also sets the OS sandbox)"
}
SlashCommand::ShowReasoning => {
"choose how reasoning content is shown in the transcript"
}
- SlashCommand::Clear => "clear the current transcript",
SlashCommand::Diff => "show git diff (including untracked files)",
SlashCommand::Btw => {
"Ask a quick side question without interrupting the main conversation"
@@ -66,9 +66,9 @@ impl SlashCommand {
SlashCommand::Rename => "rename",
SlashCommand::Delete => "delete",
SlashCommand::Status => "status",
+ SlashCommand::Settings => "settings",
SlashCommand::Permissions => "permissions",
SlashCommand::ShowReasoning => "show-reasoning",
- SlashCommand::Clear => "clear",
SlashCommand::Diff => "diff",
SlashCommand::Btw => "btw",
SlashCommand::Goal => "goal",
@@ -76,6 +76,32 @@ impl SlashCommand {
}
}
+ /// Soft alternate names accepted by parsing / popup filtering.
+ ///
+ /// These are not listed as separate built-in entries; the canonical
+ /// [`SlashCommand::command`] remains the display and insertion name.
+ pub fn aliases(self) -> &'static [&'static str] {
+ match self {
+ SlashCommand::Exit => &["quit"],
+ SlashCommand::Theme
+ | SlashCommand::Model
+ | SlashCommand::Skills
+ | SlashCommand::Mcp
+ | SlashCommand::Compact
+ | SlashCommand::Resume
+ | SlashCommand::New
+ | SlashCommand::Rename
+ | SlashCommand::Delete
+ | SlashCommand::Status
+ | SlashCommand::Settings
+ | SlashCommand::Permissions
+ | SlashCommand::ShowReasoning
+ | SlashCommand::Diff
+ | SlashCommand::Btw
+ | SlashCommand::Goal => &[],
+ }
+ }
+
pub fn supports_inline_args(self) -> bool {
matches!(
self,
@@ -97,9 +123,9 @@ impl SlashCommand {
| SlashCommand::New
| SlashCommand::Delete
| SlashCommand::Status
+ | SlashCommand::Settings
| SlashCommand::Permissions
| SlashCommand::ShowReasoning
- | SlashCommand::Clear
| SlashCommand::Diff
| SlashCommand::Exit => None,
}
@@ -116,6 +142,7 @@ impl SlashCommand {
| SlashCommand::Delete
| SlashCommand::Resume
| SlashCommand::Permissions
+ | SlashCommand::Settings
)
}
@@ -136,9 +163,9 @@ impl SlashCommand {
| SlashCommand::Rename
| SlashCommand::Delete
| SlashCommand::Status
+ | SlashCommand::Settings
| SlashCommand::Permissions
| SlashCommand::ShowReasoning
- | SlashCommand::Clear
| SlashCommand::Diff
| SlashCommand::Exit
| SlashCommand::Btw => None,
@@ -160,14 +187,16 @@ impl FromStr for SlashCommand {
"new" => Ok(Self::New),
"rename" => Ok(Self::Rename),
"delete" => Ok(Self::Delete),
- "status" => Ok(Self::Status),
+ // `/context` remains a soft alias for the status panel.
+ "status" | "context" => Ok(Self::Status),
+ "settings" => Ok(Self::Settings),
"permissions" | "approvals" => Ok(Self::Permissions),
"show-reasoning" | "reasoning-view" => Ok(Self::ShowReasoning),
- "clear" => Ok(Self::Clear),
"diff" => Ok(Self::Diff),
"btw" => Ok(Self::Btw),
"goal" => Ok(Self::Goal),
- "exit" => Ok(Self::Exit),
+ // `/quit` is a soft alias for exit.
+ "exit" | "quit" => Ok(Self::Exit),
_ => Err(()),
}
}
@@ -175,7 +204,6 @@ impl FromStr for SlashCommand {
pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> {
vec![
- ("theme", SlashCommand::Theme),
("model", SlashCommand::Model),
("skills", SlashCommand::Skills),
("mcps", SlashCommand::Mcp),
@@ -185,9 +213,9 @@ pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> {
("rename", SlashCommand::Rename),
("delete", SlashCommand::Delete),
("status", SlashCommand::Status),
+ ("settings", SlashCommand::Settings),
("permissions", SlashCommand::Permissions),
("show-reasoning", SlashCommand::ShowReasoning),
- ("clear", SlashCommand::Clear),
("diff", SlashCommand::Diff),
("goal", SlashCommand::Goal),
("btw", SlashCommand::Btw),
@@ -258,6 +286,7 @@ mod tests {
assert!(!SlashCommand::Exit.available_over_acp());
assert!(!SlashCommand::Rename.available_over_acp());
assert!(!SlashCommand::Delete.available_over_acp());
+ assert!(!SlashCommand::Settings.available_over_acp());
}
#[test]
@@ -266,11 +295,63 @@ mod tests {
assert!(!SlashCommand::Permissions.available_during_task());
assert!(!SlashCommand::Theme.available_during_task());
assert!(!SlashCommand::Delete.available_during_task());
+ assert!(!SlashCommand::Settings.available_during_task());
assert!(SlashCommand::Status.available_during_task());
assert!(SlashCommand::Goal.available_during_task());
assert!(SlashCommand::Rename.available_during_task());
}
+ #[test]
+ fn theme_slash_command_is_hidden_alias_for_settings() {
+ assert_eq!("theme".parse::(), Ok(SlashCommand::Theme));
+ assert!(!SlashCommand::Theme.available_during_task());
+ assert!(
+ !built_in_slash_commands()
+ .iter()
+ .any(|(name, _)| *name == "theme")
+ );
+ assert!(
+ built_in_slash_commands()
+ .iter()
+ .any(|(name, command)| *name == "settings" && *command == SlashCommand::Settings)
+ );
+ }
+
+ #[test]
+ fn status_slash_command_parses_and_context_is_alias() {
+ assert_eq!("status".parse::(), Ok(SlashCommand::Status));
+ assert_eq!("context".parse::(), Ok(SlashCommand::Status));
+ assert!(SlashCommand::Status.available_during_task());
+ assert!(!SlashCommand::Status.available_over_acp());
+ assert_eq!(
+ SlashCommand::Status.description(),
+ "show cwd, permissions, and context window occupancy"
+ );
+ assert!(
+ built_in_slash_commands()
+ .iter()
+ .any(|(name, command)| *name == "status" && *command == SlashCommand::Status)
+ );
+ assert!(
+ !built_in_slash_commands()
+ .iter()
+ .any(|(name, _)| *name == "context")
+ );
+ }
+
+ #[test]
+ fn settings_slash_command_parses_and_is_unavailable_during_task() {
+ assert_eq!(
+ "settings".parse::(),
+ Ok(SlashCommand::Settings)
+ );
+ assert!(!SlashCommand::Settings.available_during_task());
+ assert_eq!(
+ SlashCommand::Settings.description(),
+ "open session and appearance settings"
+ );
+ }
+
#[test]
fn rename_and_delete_slash_commands_parse_and_describe() {
assert_eq!("rename".parse::(), Ok(SlashCommand::Rename));
@@ -280,4 +361,32 @@ mod tests {
assert_eq!(SlashCommand::Rename.parameter_hint(), Some(""));
assert_eq!(SlashCommand::Delete.parameter_hint(), None);
}
+
+ #[test]
+ fn exit_slash_command_parses_and_quit_is_alias() {
+ assert_eq!("exit".parse::(), Ok(SlashCommand::Exit));
+ assert_eq!("quit".parse::(), Ok(SlashCommand::Exit));
+ assert_eq!(SlashCommand::Exit.command(), "exit");
+ assert_eq!(SlashCommand::Exit.aliases(), &["quit"]);
+ assert!(
+ built_in_slash_commands()
+ .iter()
+ .any(|(name, command)| *name == "exit" && *command == SlashCommand::Exit)
+ );
+ assert!(
+ !built_in_slash_commands()
+ .iter()
+ .any(|(name, _)| *name == "quit")
+ );
+ }
+
+ #[test]
+ fn clear_slash_command_is_removed() {
+ assert_eq!("clear".parse::(), Err(()));
+ assert!(
+ !built_in_slash_commands()
+ .iter()
+ .any(|(name, _)| *name == "clear")
+ );
+ }
}
diff --git a/crates/protocol/src/turn.rs b/crates/protocol/src/turn.rs
index 723c45d3..c69986c8 100644
--- a/crates/protocol/src/turn.rs
+++ b/crates/protocol/src/turn.rs
@@ -141,10 +141,6 @@ pub enum TurnInputDisposition {
Steered,
}
-fn default_steered_disposition() -> TurnInputDisposition {
- TurnInputDisposition::Steered
-}
-
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(tag = "disposition", rename_all = "snake_case")]
pub enum TurnStartResult {
@@ -223,45 +219,6 @@ pub struct TurnInterruptResult {
pub status: TurnStatus,
}
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
-pub struct TurnSteerParams {
- pub session_id: SessionId,
- pub expected_turn_id: TurnId,
- pub input: Vec,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
-pub struct TurnSteerResult {
- pub turn_id: TurnId,
- #[serde(default = "default_steered_disposition")]
- pub disposition: TurnInputDisposition,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
-pub struct TurnQueueRemoveParams {
- pub session_id: SessionId,
- pub queued_input_id: PendingInputId,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
-pub struct TurnQueueRemoveResult {
- pub removed: bool,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
-pub struct TurnQueueSteerParams {
- pub session_id: SessionId,
- pub expected_turn_id: TurnId,
- pub queued_input_id: PendingInputId,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
-pub struct TurnQueueSteerResult {
- pub turn_id: TurnId,
- #[serde(default = "default_steered_disposition")]
- pub disposition: TurnInputDisposition,
-}
-
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Default, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
pub enum TurnKind {
@@ -554,17 +511,6 @@ mod tests {
assert_eq!(restored.collaboration_mode, CollaborationMode::Plan);
}
- #[test]
- fn turn_steer_result_defaults_to_steered_disposition() {
- let json = serde_json::json!({
- "turn_id": TurnId::new()
- });
-
- let restored: TurnSteerResult = serde_json::from_value(json).expect("deserialize");
-
- assert_eq!(restored.disposition, TurnInputDisposition::Steered);
- }
-
#[test]
fn shell_command_result_keeps_plain_started_shape() {
let result = ShellCommandResult {
diff --git a/crates/safety/src/lib.rs b/crates/safety/src/lib.rs
index 3abbeb6a..89adda67 100644
--- a/crates/safety/src/lib.rs
+++ b/crates/safety/src/lib.rs
@@ -16,8 +16,9 @@ use smol_str::SmolStr;
/// Controls how tool permission requests are handled by the runtime.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PermissionMode {
- /// Approve every request without asking.
- AutoApprove,
+ /// Bypass approvals entirely (never ask). Full-Access / yolo posture.
+ #[serde(alias = "AutoApprove")]
+ Yolo,
/// Ask the user for confirmation on each request.
Interactive,
/// Deny all requests that require permission.
@@ -90,7 +91,8 @@ pub struct RuntimePermissionProfile {
pub writable_roots: BTreeSet,
pub allow_shell_commands: bool,
pub allow_network: bool,
- pub auto_approve: bool,
+ /// When true, sensitive tools are allowed without prompts (Full-Access / yolo).
+ pub yolo: bool,
}
impl RuntimePermissionProfile {
@@ -118,7 +120,7 @@ impl RuntimePermissionProfile {
writable_roots,
allow_shell_commands: true,
allow_network: false,
- auto_approve: false,
+ yolo: false,
}
}
PermissionPreset::FullAccess => RuntimePermissionProfile {
@@ -129,7 +131,7 @@ impl RuntimePermissionProfile {
writable_roots,
allow_shell_commands: true,
allow_network: true,
- auto_approve: true,
+ yolo: true,
},
}
}
@@ -180,8 +182,8 @@ impl RuntimePermissionProfile {
}
pub fn permission_mode(&self) -> PermissionMode {
- if self.auto_approve {
- PermissionMode::AutoApprove
+ if self.yolo {
+ PermissionMode::Yolo
} else {
PermissionMode::Interactive
}
diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml
index 43100b7e..006d3df9 100644
--- a/crates/server/Cargo.toml
+++ b/crates/server/Cargo.toml
@@ -31,6 +31,7 @@ devo-util-shell-command = { workspace = true }
diffy = { workspace = true }
futures = { workspace = true }
fs2 = { workspace = true }
+lru = { workspace = true }
rusqlite = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
@@ -50,3 +51,4 @@ uuid = { workspace = true }
async-trait = { workspace = true }
pretty_assertions = "1"
tempfile = "3"
+tokio = { workspace = true, features = ["test-util"] }
diff --git a/crates/server/src/db.rs b/crates/server/src/db.rs
index 423c16e6..0019ba0c 100644
--- a/crates/server/src/db.rs
+++ b/crates/server/src/db.rs
@@ -9,6 +9,7 @@ use chrono::{TimeZone, Utc};
use rusqlite::{Connection, params, types::Type};
use serde_json;
+use devo_protocol::canonical::item::ContextOccupancy;
use devo_protocol::{
PendingInputId, PendingInputItem, PendingInputKind, SessionId, SessionMetadata,
SessionRuntimeStatus, SessionTitleState,
@@ -56,9 +57,15 @@ pub struct SessionStats {
pub total_tokens: usize,
pub total_cache_creation_tokens: usize,
pub total_cache_read_tokens: usize,
+ /// Latest **model-query** input tokens (not cumulative turn usage).
+ ///
+ /// Used for hydrate / diagnostics. Must not be confused with turn-aggregate
+ /// `TurnMetadata.usage`, which sums every completed leg in a multi-tool turn.
pub last_input_tokens: usize,
pub turn_count: usize,
pub prompt_token_estimate: usize,
+ /// Latest context-window occupancy breakdown, when known.
+ pub last_context_occupancy: Option,
}
/// One derived event row before per-stream sequencing (08 §5).
@@ -140,7 +147,8 @@ impl Database {
total_cache_read_tokens INTEGER NOT NULL DEFAULT 0,
last_input_tokens INTEGER NOT NULL DEFAULT 0,
turn_count INTEGER NOT NULL DEFAULT 0,
- prompt_token_estimate INTEGER NOT NULL DEFAULT 0
+ prompt_token_estimate INTEGER NOT NULL DEFAULT 0,
+ last_context_occupancy TEXT
);
CREATE TABLE IF NOT EXISTS pending_messages (
@@ -261,6 +269,13 @@ impl Database {
)
.context("failed to backfill total_tokens column")?;
}
+ if !table_has_column(&conn, "session_stats", "last_context_occupancy")? {
+ conn.execute(
+ "ALTER TABLE session_stats ADD COLUMN last_context_occupancy TEXT",
+ [],
+ )
+ .context("failed to add last_context_occupancy column")?;
+ }
if !sessions_has_column(&conn, "rollout_path")? {
conn.execute("ALTER TABLE sessions ADD COLUMN rollout_path TEXT", [])
.context("failed to add rollout_path column")?;
@@ -729,12 +744,18 @@ impl Database {
/// Inserts or updates session token statistics.
pub fn update_stats(&self, id: &SessionId, stats: &SessionStats) -> Result<()> {
+ let occupancy_json = stats
+ .last_context_occupancy
+ .as_ref()
+ .map(serde_json::to_string)
+ .transpose()
+ .context("failed to serialize last_context_occupancy")?;
let conn = self.conn.lock().expect("database mutex poisoned");
conn.execute(
"INSERT INTO session_stats (session_id, total_input_tokens, total_output_tokens,
total_tokens, total_cache_creation_tokens, total_cache_read_tokens, last_input_tokens,
- turn_count, prompt_token_estimate)
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
+ turn_count, prompt_token_estimate, last_context_occupancy)
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
ON CONFLICT(session_id) DO UPDATE SET
total_input_tokens = excluded.total_input_tokens,
total_output_tokens = excluded.total_output_tokens,
@@ -743,7 +764,8 @@ impl Database {
total_cache_read_tokens = excluded.total_cache_read_tokens,
last_input_tokens = excluded.last_input_tokens,
turn_count = excluded.turn_count,
- prompt_token_estimate = excluded.prompt_token_estimate",
+ prompt_token_estimate = excluded.prompt_token_estimate,
+ last_context_occupancy = excluded.last_context_occupancy",
params![
id.to_string(),
stats.total_input_tokens as i64,
@@ -754,6 +776,7 @@ impl Database {
stats.last_input_tokens as i64,
stats.turn_count as i64,
stats.prompt_token_estimate as i64,
+ occupancy_json,
],
)
.context("failed to update session stats")?;
@@ -765,10 +788,15 @@ impl Database {
let conn = self.conn.lock().expect("database mutex poisoned");
let result = conn.query_row(
"SELECT total_input_tokens, total_output_tokens, total_tokens, total_cache_creation_tokens,
- total_cache_read_tokens, last_input_tokens, turn_count, prompt_token_estimate
+ total_cache_read_tokens, last_input_tokens, turn_count, prompt_token_estimate,
+ last_context_occupancy
FROM session_stats WHERE session_id = ?1",
params![id.to_string()],
|row| {
+ let occupancy_json: Option = row.get(8)?;
+ let last_context_occupancy = occupancy_json.and_then(|json| {
+ serde_json::from_str::(&json).ok()
+ });
Ok(SessionStats {
total_input_tokens: row.get::<_, i64>(0)? as usize,
total_output_tokens: row.get::<_, i64>(1)? as usize,
@@ -778,6 +806,7 @@ impl Database {
last_input_tokens: row.get::<_, i64>(5)? as usize,
turn_count: row.get::<_, i64>(6)? as usize,
prompt_token_estimate: row.get::<_, i64>(7)? as usize,
+ last_context_occupancy,
})
},
);
@@ -1130,8 +1159,13 @@ fn parse_session_metadata_row(
prompt_token_estimate: 0,
last_query_usage: None,
last_query_total_tokens: 0,
+ last_context_occupancy: None,
status: SessionRuntimeStatus::Idle,
collaboration_mode: Default::default(),
+ // Applied compaction window is resolved from AppConfig at session
+ // hydrate / create time; DB list rows do not persist it.
+ effective_context_window: None,
+ permission_preset: None,
})
}
@@ -1415,8 +1449,11 @@ mod tests {
prompt_token_estimate: 0,
last_query_usage: None,
last_query_total_tokens: 0,
+ last_context_occupancy: None,
status: SessionRuntimeStatus::Idle,
collaboration_mode: Default::default(),
+ effective_context_window: None,
+ permission_preset: None,
}
}
@@ -1514,6 +1551,7 @@ mod tests {
last_input_tokens: 200,
turn_count: 5,
prompt_token_estimate: 800,
+ last_context_occupancy: None,
};
db.update_stats(&meta.session_id, &stats).expect("update");
@@ -1525,6 +1563,36 @@ mod tests {
assert_eq!(retrieved.turn_count, 5);
}
+ #[test]
+ fn update_and_get_stats_roundtrips_context_occupancy() {
+ let (db, _dir) = test_db();
+ let meta = sample_session("session-occupancy");
+ db.upsert_session(&meta, None).expect("upsert");
+
+ let occupancy = ContextOccupancy::from_category_tokens(
+ /*context_window_tokens*/ 100_000, /*base*/ 10_000, /*skills*/ 5_000,
+ /*tools_builtin*/ 20_000, /*tools_mcp*/ 15_000, /*conversation*/ 50_000,
+ );
+ let stats = SessionStats {
+ total_input_tokens: 1000,
+ total_output_tokens: 500,
+ total_tokens: 1500,
+ total_cache_creation_tokens: 0,
+ total_cache_read_tokens: 200,
+ last_input_tokens: 1000,
+ turn_count: 2,
+ prompt_token_estimate: 100_000,
+ last_context_occupancy: Some(occupancy.clone()),
+ };
+ db.update_stats(&meta.session_id, &stats).expect("update");
+
+ let retrieved = db
+ .get_stats(&meta.session_id)
+ .expect("get")
+ .expect("stats row");
+ assert_eq!(retrieved.last_context_occupancy, Some(occupancy));
+ }
+
#[test]
fn push_and_drain_pending() {
let (db, _dir) = test_db();
diff --git a/crates/server/src/event.rs b/crates/server/src/event.rs
index 6edb3a84..30a9578d 100644
--- a/crates/server/src/event.rs
+++ b/crates/server/src/event.rs
@@ -1,9 +1,11 @@
pub use devo_protocol::{
- ApprovalDecisionPayload, ApprovalRequestPayload, CommandExecutionPayload, EventContext,
- FileChangePayload, ItemDeltaKind, ItemDeltaPayload, ItemEnvelope, ItemEventPayload, ItemKind,
- MessageEditRecordedPayload, PendingServerRequestContext, RequestUserInputPayload, ServerEvent,
- ServerRequestKind, ServerRequestResolvedPayload, SessionCompactionFailedPayload,
- SessionEventPayload, SessionStatusChangedPayload, ToolCallPayload, ToolResultPayload,
- TurnEventPayload, TurnPlanStepPayload, TurnPlanUpdatedPayload, TurnSupersededPayload,
- TurnUsageUpdatedPayload, WorkspaceRestoreCompletedPayload, WorkspaceRestoreStartedPayload,
+ ApprovalDecisionPayload, ApprovalRequestPayload, CommandExecutionPayload,
+ ContextUsageUpdatedPayload, EventContext, FileChangePayload, ItemDeltaKind, ItemDeltaPayload,
+ ItemEnvelope, ItemEventPayload, ItemKind, MessageEditRecordedPayload,
+ PendingServerRequestContext, RequestUserInputPayload, ServerEvent, ServerRequestKind,
+ ServerRequestResolvedPayload, SessionCompactionFailedPayload,
+ SessionEffectiveContextWindowUpdatedPayload, SessionEventPayload, SessionStatusChangedPayload,
+ ToolCallPayload, ToolResultPayload, TurnEventPayload, TurnPlanStepPayload,
+ TurnPlanUpdatedPayload, TurnSupersededPayload, TurnUsageUpdatedPayload,
+ WorkspaceRestoreCompletedPayload, WorkspaceRestoreStartedPayload,
};
diff --git a/crates/server/src/event_reconcile.rs b/crates/server/src/event_reconcile.rs
index f3a992cd..12fadbf3 100644
--- a/crates/server/src/event_reconcile.rs
+++ b/crates/server/src/event_reconcile.rs
@@ -185,6 +185,7 @@ mod tests {
None,
None,
None,
+ None,
);
store.append_turn(&record, turn).expect("append turn");
let item = build_item_record(
diff --git a/crates/server/src/execution.rs b/crates/server/src/execution.rs
index a85dec4d..9b466500 100644
--- a/crates/server/src/execution.rs
+++ b/crates/server/src/execution.rs
@@ -1,5 +1,7 @@
+use std::collections::HashMap;
use std::collections::HashSet;
use std::collections::VecDeque;
+use std::num::NonZeroUsize;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
@@ -7,6 +9,8 @@ use std::sync::Mutex as StdMutex;
use devo_core::AppConfigStore;
use devo_core::ProviderVendorCatalog;
+use devo_core::normalize_canonical_path;
+use lru::LruCache;
use tokio::sync::Mutex;
use tokio::sync::oneshot;
@@ -21,6 +25,7 @@ use devo_core::SkillError;
#[cfg(test)]
use devo_core::TurnConfig;
use devo_core::TurnId;
+use devo_core::TurnRecord;
use devo_core::tools::ToolRegistry;
use devo_protocol::ApprovalDecisionValue;
use devo_protocol::PendingInputItem;
@@ -39,6 +44,19 @@ use crate::session_context::ResolvedInput;
use crate::session_context::SessionRuntimeContext;
use crate::turn::TurnMetadata;
+/// Mirrors parent-session LRU capacity so workspace contexts stay bounded.
+const WORKSPACE_CONTEXT_CACHE_CAPACITY: usize = 16;
+
+fn workspace_context_cache(capacity: usize) -> LruCache> {
+ LruCache::new(NonZeroUsize::new(capacity.max(1)).expect("workspace context cache capacity"))
+}
+
+/// Cache key for workspace contexts: canonicalize so `cwd` and `cwd/.` share one entry.
+fn canonicalize_workspace_root(path: &Path) -> PathBuf {
+ let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
+ normalize_canonical_path(canonical)
+}
+
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PersistedTurnItem {
pub(crate) turn_id: TurnId,
@@ -128,6 +146,13 @@ pub struct ServerRuntimeDependencies {
pub(crate) config_store: Arc>,
/// User-level process context used before a concrete session exists.
pub(crate) process_context: Arc,
+ /// LRU of workspace-scoped contexts (canonical cwd → context).
+ ///
+ /// Avoids rebuilding MCP/tool registry/skill catalog on every
+ /// `skills/list`, session cwd switch, or other `context_for_workspace` call.
+ /// Invalidate via [`Self::invalidate_workspace_contexts`] when user MCP,
+ /// skills, provider, or model config mutates.
+ workspace_contexts: StdMutex>>,
}
/// Builds an empty MCP manager for tests and bootstrap paths without servers.
@@ -174,6 +199,9 @@ impl ServerRuntimeDependencies {
db,
config_store,
process_context,
+ workspace_contexts: StdMutex::new(workspace_context_cache(
+ WORKSPACE_CONTEXT_CACHE_CAPACITY,
+ )),
}
}
@@ -181,18 +209,54 @@ impl ServerRuntimeDependencies {
&self,
workspace_root: &Path,
) -> anyhow::Result> {
+ let cache_key = canonicalize_workspace_root(workspace_root);
+ {
+ let mut cache = self
+ .workspace_contexts
+ .lock()
+ .expect("workspace context cache mutex should not be poisoned");
+ if let Some(cached) = cache.get(&cache_key) {
+ return Ok(Arc::clone(cached));
+ }
+ }
+
let user_config_dir = self
.config_store
.lock()
.expect("app config store mutex should not be poisoned")
.user_config_dir()
.to_path_buf();
- SessionRuntimeContext::load_for_workspace(
+ // Miss path: load workspace-merged config. MCP/registry are reused from
+ // `process_context` when operationally equivalent (see load_for_workspace).
+ let loaded = SessionRuntimeContext::load_for_workspace(
user_config_dir,
Some(workspace_root),
&self.process_context,
)
- .await
+ .await?;
+
+ let mut cache = self
+ .workspace_contexts
+ .lock()
+ .expect("workspace context cache mutex should not be poisoned");
+ // Another task may have filled the same key while we loaded.
+ if let Some(cached) = cache.get(&cache_key) {
+ return Ok(Arc::clone(cached));
+ }
+ cache.put(cache_key, Arc::clone(&loaded));
+ Ok(loaded)
+ }
+
+ /// Clears the workspace context LRU so the next lookup reloads from disk.
+ ///
+ /// Call after user-global config mutations (MCP enable, skills enable,
+ /// provider upsert, model/config/set). Already-running sessions keep their
+ /// own `Arc`; only subsequent lookups are affected.
+ pub(crate) fn invalidate_workspace_contexts(&self) {
+ self.workspace_contexts
+ .lock()
+ .expect("workspace context cache mutex should not be poisoned")
+ .clear();
}
/// Resolves the full turn configuration used by the core query loop.
@@ -223,8 +287,11 @@ impl ServerRuntimeDependencies {
enabled: bool,
workspace_root: Option<&Path>,
) -> anyhow::Result> {
- self.process_context
- .set_skill_enabled(path, enabled, workspace_root)
+ let skills = self
+ .process_context
+ .set_skill_enabled(path, enabled, workspace_root)?;
+ self.invalidate_workspace_contexts();
+ Ok(skills)
}
/// Renders turn input items and resolves any referenced skills into prompt-visible messages.
@@ -263,6 +330,8 @@ pub(crate) struct RuntimeSession {
pub(crate) persisted_turn_items: Vec,
/// Latest compaction snapshot used to rebuild the model-facing prompt view.
pub(crate) latest_compaction_snapshot: Option,
+ /// Completed turn records keyed by turn id (for fork/rollback occupancy cuts).
+ pub(crate) turn_records_by_id: HashMap,
/// Shared handle to the pending-turn queue owned by `core_session`.
pub(crate) pending_turn_queue: Arc>>,
/// Shared handle to the active-turn steer queue owned by `core_session`.
@@ -595,6 +664,122 @@ proxy_url = "http://workspace-proxy.example:8080"
let _ = std::fs::remove_dir_all(workspace);
}
+ #[tokio::test]
+ async fn context_for_workspace_caches_same_canonical_cwd() {
+ let deps = test_deps("");
+ let workspace = unique_temp_dir("session-context-cache");
+ std::fs::create_dir_all(&workspace).expect("create workspace");
+
+ let first = deps
+ .context_for_workspace(&workspace)
+ .await
+ .expect("load workspace context");
+ let via_dot = workspace.join(".");
+ let second = deps
+ .context_for_workspace(&via_dot)
+ .await
+ .expect("load cached workspace context");
+
+ assert!(Arc::ptr_eq(&first, &second));
+
+ deps.invalidate_workspace_contexts();
+ let third = deps
+ .context_for_workspace(&workspace)
+ .await
+ .expect("reload workspace context");
+ assert!(!Arc::ptr_eq(&first, &third));
+
+ let _ = std::fs::remove_dir_all(workspace);
+ }
+
+ #[tokio::test]
+ async fn context_for_workspace_reuses_mcp_manager_when_provider_configured() {
+ let deps = test_deps(
+ r#"
+[defaults]
+model_binding = "main"
+
+[providers.openrouter]
+enabled = true
+name = "OpenRouter"
+wire_apis = ["openai_chat_completions"]
+
+[model_bindings.main]
+enabled = true
+model_slug = "catalog-slug"
+provider = "openrouter"
+request_model = "vendor/model-name"
+invocation_method = "openai_chat_completions"
+"#,
+ );
+ let workspace = unique_temp_dir("session-context-mcp-reuse");
+ std::fs::create_dir_all(&workspace).expect("create workspace");
+
+ let context = deps
+ .context_for_workspace(&workspace)
+ .await
+ .expect("load workspace context");
+
+ assert!(Arc::ptr_eq(
+ &context.mcp_manager,
+ &deps.process_context.mcp_manager
+ ));
+ assert!(Arc::ptr_eq(
+ &context.tool_registry(),
+ &deps.process_context.tool_registry()
+ ));
+
+ let _ = std::fs::remove_dir_all(workspace);
+ }
+
+ #[tokio::test]
+ async fn context_for_workspace_evicts_oldest_when_over_capacity() {
+ let deps = test_deps("");
+ {
+ let mut cache = deps
+ .workspace_contexts
+ .lock()
+ .expect("workspace context cache mutex should not be poisoned");
+ *cache = workspace_context_cache(2);
+ }
+
+ let root = unique_temp_dir("session-context-lru");
+ let workspace_a = root.join("a");
+ let workspace_b = root.join("b");
+ let workspace_c = root.join("c");
+ for workspace in [&workspace_a, &workspace_b, &workspace_c] {
+ std::fs::create_dir_all(workspace).expect("create workspace");
+ }
+
+ let context_a = deps
+ .context_for_workspace(&workspace_a)
+ .await
+ .expect("load a");
+ let _context_b = deps
+ .context_for_workspace(&workspace_b)
+ .await
+ .expect("load b");
+ let _context_c = deps
+ .context_for_workspace(&workspace_c)
+ .await
+ .expect("load c");
+
+ let reloaded_a = deps
+ .context_for_workspace(&workspace_a)
+ .await
+ .expect("reload a");
+ assert!(!Arc::ptr_eq(&context_a, &reloaded_a));
+ assert_eq!(
+ deps.workspace_contexts
+ .lock()
+ .expect("workspace context cache mutex should not be poisoned")
+ .len(),
+ 2
+ );
+
+ let _ = std::fs::remove_dir_all(root);
+ }
+
#[test]
fn resolve_turn_config_preserves_catalog_slug_and_uses_binding_request_model() {
let deps = test_deps(
diff --git a/crates/server/src/persistence.rs b/crates/server/src/persistence.rs
index 42f770d0..29918581 100644
--- a/crates/server/src/persistence.rs
+++ b/crates/server/src/persistence.rs
@@ -171,6 +171,7 @@ impl RolloutStore {
title_state,
sandbox_policy: "workspace-write".into(),
approval_mode: "on-request".into(),
+ effective_context_window: None,
tokens_used: 0,
first_user_message: None,
archived_at: None,
@@ -180,6 +181,8 @@ impl RolloutStore {
parent_session_id,
session_context: None,
latest_turn_context: None,
+ collaboration_mode: None,
+ permission_preset: None,
schema_version: 2,
}
}
@@ -1027,6 +1030,7 @@ struct ReplayState {
latest_turn: Option,
latest_turn_metadata: Option,
latest_query_usage: Option,
+ latest_context_occupancy: Option,
turn_records_by_id: HashMap,
loaded_item_count: u64,
next_item_seq: u64,
@@ -1055,6 +1059,27 @@ struct ReplayState {
last_activity_at: Option>,
}
+/// Auto-compact / status pressure restored on resume.
+///
+/// Prefers post-compaction (or tip) occupancy so a prior large query total cannot
+/// re-trigger compaction after the context was already reduced. Falls back to
+/// latest-query display total, then the reconstituted prompt estimate.
+fn resume_context_pressure_tokens(
+ occupancy: Option<&devo_protocol::canonical::item::ContextOccupancy>,
+ latest_query_usage: Option<&devo_protocol::TurnUsage>,
+ prompt_token_estimate: usize,
+) -> (usize, usize) {
+ let last_turn_tokens = occupancy
+ .map(|occupancy| occupancy.total_tokens as usize)
+ .or_else(|| latest_query_usage.map(devo_protocol::TurnUsage::display_total_tokens))
+ .unwrap_or(prompt_token_estimate);
+ let last_input_tokens = latest_query_usage
+ .map(|usage| usage.input_tokens as usize)
+ .or_else(|| occupancy.map(|occupancy| occupancy.total_tokens as usize))
+ .unwrap_or(prompt_token_estimate);
+ (last_turn_tokens, last_input_tokens)
+}
+
impl ReplayState {
fn apply_line(&mut self, line: RolloutLine) -> Result<()> {
match line {
@@ -1105,6 +1130,9 @@ impl ReplayState {
self.last_turn_tokens = 0;
self.latest_query_usage = None;
}
+ if let Some(occupancy) = turn.context_occupancy.clone() {
+ self.latest_context_occupancy = Some(occupancy);
+ }
self.latest_turn_metadata = Some(turn_metadata_from_record(&turn));
self.turn_kinds_by_id.insert(turn.id, turn.kind.clone());
if let Some(session_context) = turn.session_context.clone() {
@@ -1153,6 +1181,9 @@ impl ReplayState {
self.session_context_recorded = true;
}
RolloutLine::CompactionSnapshot(line) => {
+ if let Some(occupancy) = line.context_occupancy.clone() {
+ self.latest_context_occupancy = Some(occupancy);
+ }
self.latest_compaction_snapshot = Some(*line);
}
RolloutLine::MessageEditRecorded(line) => {
@@ -1310,6 +1341,29 @@ impl ReplayState {
if let Some(latest_turn_context) = core_session.latest_turn_context.as_ref() {
core_session.collaboration_mode = latest_turn_context.collaboration_mode;
}
+ if let Some(mode) = record.collaboration_mode {
+ core_session.collaboration_mode = mode;
+ }
+ if let Some(preset) = record.permission_preset {
+ let safety_preset = match preset {
+ devo_protocol::PermissionPreset::Default => devo_safety::PermissionPreset::Default,
+ devo_protocol::PermissionPreset::AutoReview => {
+ devo_safety::PermissionPreset::AutoReview
+ }
+ devo_protocol::PermissionPreset::FullAccess => {
+ devo_safety::PermissionPreset::FullAccess
+ }
+ };
+ let profile = devo_safety::RuntimePermissionProfile::from_preset(
+ safety_preset,
+ record.cwd.clone(),
+ )
+ .with_additional_roots(record.additional_directories.clone());
+ let sandbox = Some(profile.implied_sandbox_profile().to_string());
+ core_session.config.permission_mode = profile.permission_mode();
+ core_session.config.permission_profile = profile;
+ core_session.config.sandbox_profile = sandbox;
+ }
core_session.turn_count = self.turns_seen as usize;
core_session.total_input_tokens = self.total_input_tokens;
core_session.total_output_tokens = self.total_output_tokens;
@@ -1325,16 +1379,13 @@ impl ReplayState {
devo_protocol::approx_tokens_from_byte_count(prompt_bytes)
.try_into()
.unwrap_or(usize::MAX);
- core_session.last_input_tokens = self
- .latest_query_usage
- .as_ref()
- .map(|usage| usage.input_tokens as usize)
- .unwrap_or(core_session.prompt_token_estimate);
- core_session.last_turn_tokens = self
- .latest_query_usage
- .as_ref()
- .map(devo_protocol::TurnUsage::display_total_tokens)
- .unwrap_or(core_session.prompt_token_estimate);
+ let (last_turn_tokens, last_input_tokens) = resume_context_pressure_tokens(
+ self.latest_context_occupancy.as_ref(),
+ self.latest_query_usage.as_ref(),
+ core_session.prompt_token_estimate,
+ );
+ core_session.last_input_tokens = last_input_tokens;
+ core_session.last_turn_tokens = last_turn_tokens;
let pending_turn_queue = std::sync::Arc::clone(&core_session.pending_turn_queue);
let steer_input_queue = std::sync::Arc::clone(&core_session.steer_input_queue);
let summary_model_selection = self
@@ -1387,6 +1438,24 @@ impl ReplayState {
record.model_binding_id = turn_config.model_binding_id.clone();
record.reasoning_effort_selection = summary_reasoning_effort_selection.clone();
+ let global_compaction_limit = runtime_context
+ .config_store
+ .lock()
+ .expect("app config store mutex should not be poisoned")
+ .effective_config()
+ .compaction_token_limit;
+ let applied_compaction_limit = crate::runtime::context_occupancy::resolved_compaction_limit(
+ global_compaction_limit,
+ &turn_config.model,
+ );
+ // Apply before wrapping in Mutex so resume never needs to lock a
+ // single-owner Arc that `from_runtime_session` later unwraps.
+ // Prefer the global config preference; ignore legacy session overrides.
+ crate::runtime::context_occupancy::apply_resolved_compaction_limit(
+ &mut core_session.config,
+ applied_compaction_limit as usize,
+ );
+
let summary = SessionMetadata {
session_id: record.id,
cwd: record.cwd.clone(),
@@ -1413,12 +1482,20 @@ impl ReplayState {
prompt_token_estimate: core_session.prompt_token_estimate,
last_query_usage: self.latest_query_usage.clone(),
last_query_total_tokens: self
- .latest_query_usage
+ .latest_context_occupancy
.as_ref()
- .map(devo_protocol::TurnUsage::display_total_tokens)
+ .map(|occupancy| occupancy.total_tokens as usize)
+ .or_else(|| {
+ self.latest_query_usage
+ .as_ref()
+ .map(devo_protocol::TurnUsage::display_total_tokens)
+ })
.unwrap_or(0),
+ last_context_occupancy: self.latest_context_occupancy.clone(),
status: SessionRuntimeStatus::Idle,
collaboration_mode: core_session.collaboration_mode,
+ effective_context_window: Some(applied_compaction_limit),
+ permission_preset: record.permission_preset,
};
let config = core_session.config.clone();
@@ -1434,6 +1511,7 @@ impl ReplayState {
history_items: replayed_history_items,
persisted_turn_items: replayed_persisted_turn_items,
latest_compaction_snapshot: self.latest_compaction_snapshot,
+ turn_records_by_id: self.turn_records_by_id,
pending_turn_queue,
steer_input_queue,
agent_tool_policy: Default::default(),
@@ -1632,6 +1710,7 @@ impl ReplayState {
self.last_input_tokens = 0;
self.last_turn_tokens = 0;
self.latest_query_usage = None;
+ self.latest_context_occupancy = None;
for turn_id in &self.turn_order {
let Some(turn) = self.turn_records_by_id.get(turn_id) else {
@@ -1655,6 +1734,16 @@ impl ReplayState {
self.last_turn_tokens = 0;
self.latest_query_usage = None;
}
+ if let Some(occupancy) = turn.context_occupancy.clone() {
+ self.latest_context_occupancy = Some(occupancy);
+ }
+ }
+ if let Some(occupancy) = self
+ .latest_compaction_snapshot
+ .as_ref()
+ .and_then(|snapshot| snapshot.context_occupancy.clone())
+ {
+ self.latest_context_occupancy = Some(occupancy);
}
}
@@ -2197,12 +2286,21 @@ pub(crate) fn session_metadata_from_record(
prompt_token_estimate: 0,
last_query_usage: None,
last_query_total_tokens: 0,
+ last_context_occupancy: None,
status: SessionRuntimeStatus::Idle,
collaboration_mode: record
- .latest_turn_context
- .as_ref()
- .map(|context| context.collaboration_mode)
+ .collaboration_mode
+ .or_else(|| {
+ record
+ .latest_turn_context
+ .as_ref()
+ .map(|context| context.collaboration_mode)
+ })
.unwrap_or_default(),
+ // Do not revive legacy session-record overrides. Applied window comes
+ // from AppConfig when the session is hydrated into a RuntimeSession.
+ effective_context_window: None,
+ permission_preset: record.permission_preset,
}
}
@@ -2230,6 +2328,7 @@ pub(crate) fn build_turn_record(
session_context: Option,
turn_context: Option,
latest_query_usage: Option,
+ context_occupancy: Option,
) -> TurnRecord {
TurnRecord {
id: turn.turn_id,
@@ -2247,6 +2346,7 @@ pub(crate) fn build_turn_record(
input_token_estimate: None,
usage: turn.usage.clone(),
latest_query_usage,
+ context_occupancy,
stop_reason: turn.stop_reason.clone(),
failure_reason: turn.failure_reason,
error: None,
@@ -2445,6 +2545,121 @@ mod tests {
assert_eq!(titles, vec!["assistant 1", "date"]);
}
+ #[test]
+ fn replay_prefers_compaction_occupancy_over_prior_turn() {
+ use devo_protocol::TurnUsage;
+ use pretty_assertions::assert_eq;
+
+ let now = Utc.with_ymd_and_hms(2026, 7, 8, 10, 0, 0).unwrap();
+ let session_id = SessionId::new();
+ let turn_id = TurnId::new();
+ let mut replay = ReplayState::default();
+ let turn_occupancy = devo_protocol::canonical::item::ContextOccupancy::from_category_tokens(
+ /*context_window_tokens*/ 100_000, /*base*/ 10_000, /*skills*/ 0,
+ /*tools_builtin*/ 0, /*tools_mcp*/ 0, /*conversation*/ 40_000,
+ );
+ let compact_occupancy =
+ devo_protocol::canonical::item::ContextOccupancy::from_category_tokens(
+ /*context_window_tokens*/ 100_000, /*base*/ 10_000, /*skills*/ 0,
+ /*tools_builtin*/ 0, /*tools_mcp*/ 0, /*conversation*/ 8_000,
+ );
+
+ replay
+ .apply_line(RolloutLine::Turn(Box::new(TurnLine {
+ timestamp: now,
+ turn: TurnRecord {
+ id: turn_id,
+ session_id,
+ sequence: 1,
+ started_at: now,
+ completed_at: Some(now),
+ status: TurnStatus::Completed,
+ kind: TurnKind::Regular,
+ model: "test-model".into(),
+ model_binding_id: None,
+ reasoning_effort_selection: None,
+ request_model: "test-model".into(),
+ request_thinking: None,
+ input_token_estimate: None,
+ usage: None,
+ latest_query_usage: Some(TurnUsage {
+ input_tokens: 50,
+ output_tokens: 5,
+ cache_creation_input_tokens: None,
+ cache_read_input_tokens: None,
+ reasoning_output_tokens: None,
+ total_tokens: Some(55),
+ }),
+ context_occupancy: Some(turn_occupancy.clone()),
+ stop_reason: None,
+ failure_reason: None,
+ error: None,
+ session_context: None,
+ turn_context: None,
+ schema_version: 4,
+ },
+ })))
+ .expect("turn");
+ assert_eq!(replay.latest_context_occupancy, Some(turn_occupancy));
+ replay
+ .apply_line(RolloutLine::CompactionSnapshot(Box::new(
+ CompactionSnapshotLine {
+ timestamp: now,
+ session_id,
+ turn_id,
+ summary_item_id: ItemId::new(),
+ preserved_item_ids: Vec::new(),
+ context_occupancy: Some(compact_occupancy.clone()),
+ },
+ )))
+ .expect("compaction");
+ assert_eq!(replay.latest_context_occupancy, Some(compact_occupancy));
+ }
+
+ #[test]
+ fn resume_context_pressure_prefers_compaction_occupancy_over_large_query() {
+ use pretty_assertions::assert_eq;
+
+ use devo_protocol::TurnUsage;
+ use devo_protocol::canonical::item::ContextOccupancy;
+
+ let occupancy = ContextOccupancy::from_category_tokens(
+ /*context_window_tokens*/ 250_000, /*base*/ 10_000, /*skills*/ 0,
+ /*tools_builtin*/ 0, /*tools_mcp*/ 0, /*conversation*/ 40_000,
+ );
+ let usage = TurnUsage {
+ input_tokens: 300_000,
+ output_tokens: 20_000,
+ cache_creation_input_tokens: None,
+ cache_read_input_tokens: None,
+ reasoning_output_tokens: None,
+ total_tokens: Some(320_000),
+ };
+
+ let (last_turn, last_input) = super::resume_context_pressure_tokens(
+ Some(&occupancy),
+ Some(&usage),
+ /*prompt_token_estimate*/ 12_000,
+ );
+ assert_eq!(last_turn, 50_000);
+ assert_eq!(last_input, 300_000);
+
+ let (last_turn, last_input) = super::resume_context_pressure_tokens(
+ /*occupancy*/ None,
+ Some(&usage),
+ /*prompt_token_estimate*/ 12_000,
+ );
+ assert_eq!(last_turn, 320_000);
+ assert_eq!(last_input, 300_000);
+
+ let (last_turn, last_input) = super::resume_context_pressure_tokens(
+ /*occupancy*/ None, /*latest_query_usage*/ None,
+ /*prompt_token_estimate*/ 12_000,
+ );
+ assert_eq!(last_turn, 12_000);
+ assert_eq!(last_input, 12_000);
+ }
+
#[test]
fn replay_preserves_latest_query_usage_when_latest_turn_has_no_usage() {
use devo_protocol::TurnUsage;
@@ -2480,6 +2695,7 @@ mod tests {
input_token_estimate: None,
usage: Some(usage.clone()),
latest_query_usage: Some(usage.clone()),
+ context_occupancy: None,
stop_reason: None,
failure_reason: None,
error: None,
@@ -2508,6 +2724,7 @@ mod tests {
input_token_estimate: None,
usage: None,
latest_query_usage: None,
+ context_occupancy: None,
stop_reason: None,
failure_reason: Some(devo_protocol::TurnFailureReason::MaxTurnRequests),
error: None,
@@ -2558,6 +2775,7 @@ mod tests {
input_token_estimate: None,
usage: Some(aggregate_usage),
latest_query_usage: None,
+ context_occupancy: None,
stop_reason: None,
failure_reason: None,
error: None,
@@ -2603,6 +2821,7 @@ mod tests {
input_token_estimate: None,
usage: None,
latest_query_usage: None,
+ context_occupancy: None,
stop_reason: None,
failure_reason: None,
error: None,
@@ -2691,6 +2910,7 @@ mod tests {
input_token_estimate: None,
usage: None,
latest_query_usage: None,
+ context_occupancy: None,
stop_reason: None,
failure_reason: None,
error: None,
@@ -2868,8 +3088,11 @@ mod tests {
prompt_token_estimate: 0,
last_query_usage: None,
last_query_total_tokens: 0,
+ last_context_occupancy: None,
status: SessionRuntimeStatus::Idle,
collaboration_mode: Default::default(),
+ effective_context_window: None,
+ permission_preset: None,
},
None,
)
@@ -3028,6 +3251,54 @@ mod tests {
assert_eq!(history_items[1].title, "read output");
}
+ #[test]
+ fn replay_nameless_edit_tool_result_still_emits_edited_metadata() {
+ // edit is LiveOnly: start is not persisted, so resume only sees ToolResult
+ // with tool_name lost by the v2 canonical schema. Structured output must
+ // still produce Edited metadata for transcript restore.
+ let mut messages = Vec::new();
+ let mut history_items = Vec::new();
+ let mut tool_names_by_id = HashMap::new();
+
+ apply_turn_item(
+ &mut messages,
+ &mut history_items,
+ &mut tool_names_by_id,
+ &TurnKind::Regular,
+ TurnItem::ToolResult(ToolResultItem {
+ tool_call_id: "call-edit".to_string(),
+ tool_name: None,
+ output: serde_json::json!({
+ "diff": "diff --git a/foo.txt b/foo.txt\n--- a/foo.txt\n+++ b/foo.txt\n@@ -1 +1 @@\n-old\n+new\n",
+ "files": [{
+ "path": "foo.txt",
+ "kind": "update",
+ "diff": "--- a/foo.txt\n+++ b/foo.txt\n@@ -1 +1 @@\n-old\n+new\n",
+ "oldContent": "old\n",
+ "postContent": "new\n",
+ "additions": 1,
+ "deletions": 1
+ }],
+ "output": "edited foo.txt"
+ }),
+ display_content: Some("edited foo.txt".to_string()),
+ is_error: false,
+ }),
+ );
+
+ assert_eq!(history_items.len(), 1);
+ assert_eq!(history_items[0].body, "edited foo.txt");
+ let Some(devo_protocol::SessionHistoryMetadata::Edited { changes }) =
+ &history_items[0].metadata
+ else {
+ panic!(
+ "expected Edited metadata, got {:?}",
+ history_items[0].metadata
+ );
+ };
+ assert!(changes.contains_key(&PathBuf::from("foo.txt")));
+ }
+
#[test]
fn replay_uses_display_content_for_history_but_canonical_output_for_prompt() {
let mut messages = Vec::new();
@@ -3122,6 +3393,7 @@ mod tests {
turn_id: TurnId::new(),
summary_item_id,
preserved_item_ids: vec![preserved_item_id],
+ context_occupancy: None,
},
)
.expect("prompt messages");
@@ -3205,6 +3477,7 @@ mod tests {
title_state: SessionTitleState::Unset,
sandbox_policy: "workspace-write".into(),
approval_mode: "on-request".into(),
+ effective_context_window: None,
tokens_used: 0,
first_user_message: None,
archived_at: None,
@@ -3214,6 +3487,8 @@ mod tests {
parent_session_id: None,
session_context: None,
latest_turn_context: None,
+ collaboration_mode: None,
+ permission_preset: None,
schema_version: 2,
},
})))
@@ -3237,6 +3512,7 @@ mod tests {
input_token_estimate: None,
usage: None,
latest_query_usage: None,
+ context_occupancy: None,
stop_reason: None,
failure_reason: None,
error: None,
@@ -3325,6 +3601,7 @@ mod tests {
title_state: SessionTitleState::Unset,
sandbox_policy: "workspace-write".into(),
approval_mode: "on-request".into(),
+ effective_context_window: None,
tokens_used: 0,
first_user_message: None,
archived_at: None,
@@ -3334,6 +3611,8 @@ mod tests {
parent_session_id: None,
session_context: None,
latest_turn_context: None,
+ collaboration_mode: None,
+ permission_preset: None,
schema_version: 2,
},
})))
@@ -3367,6 +3646,7 @@ mod tests {
input_token_estimate: None,
usage: None,
latest_query_usage: None,
+ context_occupancy: None,
stop_reason: None,
failure_reason: None,
error: None,
@@ -3431,6 +3711,7 @@ mod tests {
title_state: SessionTitleState::Unset,
sandbox_policy: "workspace-write".into(),
approval_mode: "on-request".into(),
+ effective_context_window: None,
tokens_used: 0,
first_user_message: None,
archived_at: None,
@@ -3440,6 +3721,8 @@ mod tests {
parent_session_id: None,
session_context: None,
latest_turn_context: None,
+ collaboration_mode: None,
+ permission_preset: None,
schema_version: 2,
},
})))
@@ -3473,6 +3756,7 @@ mod tests {
input_token_estimate: None,
usage: None,
latest_query_usage: None,
+ context_occupancy: None,
stop_reason: None,
failure_reason: None,
error: None,
@@ -3553,7 +3837,7 @@ mod tests {
.append_turn_deduped(
&record,
&mut session_context_recorded,
- super::build_turn_record(&metadata, None, None, None),
+ super::build_turn_record(&metadata, None, None, None, None),
Some(session_context.clone()),
)
.expect("append deduped turn");
@@ -3690,7 +3974,7 @@ mod tests {
.append_session_meta(&record)
.expect("append session meta");
let metadata = test_turn_metadata(record.id, TurnId::new());
- let turn = super::build_turn_record(&metadata, None, None, None);
+ let turn = super::build_turn_record(&metadata, None, None, None, None);
rollout_store
.append_turn(&record, turn)
.expect("append turn");
@@ -3742,7 +4026,7 @@ mod tests {
.append_session_meta(&record)
.expect("append session meta");
let metadata = test_turn_metadata(record.id, TurnId::new());
- let turn = super::build_turn_record(&metadata, None, None, None);
+ let turn = super::build_turn_record(&metadata, None, None, None, None);
rollout_store
.append_turn(&record, turn)
.expect("append turn");
@@ -3865,7 +4149,7 @@ mod tests {
store
.append_turn(
&record,
- super::build_turn_record(&metadata, None, None, None),
+ super::build_turn_record(&metadata, None, None, None, None),
)
.expect("append turn");
let now = Utc::now();
@@ -3949,7 +4233,7 @@ mod tests {
let restarted_store = super::RolloutStore::new(dir.path().to_path_buf(), None);
let metadata = test_turn_metadata(record.id, TurnId::new());
- let turn = super::build_turn_record(&metadata, None, None, None);
+ let turn = super::build_turn_record(&metadata, None, None, None, None);
let error = restarted_store
.append_turn(&record, turn)
.expect_err("append onto damaged history must fail");
@@ -3987,7 +4271,7 @@ mod tests {
let restarted_store = super::RolloutStore::new(dir.path().to_path_buf(), None);
let metadata = test_turn_metadata(record.id, TurnId::new());
- let turn = super::build_turn_record(&metadata, None, None, None);
+ let turn = super::build_turn_record(&metadata, None, None, None, None);
restarted_store
.append_turn(&record, turn)
.expect("crash tail is tolerated");
@@ -4021,7 +4305,7 @@ mod tests {
&[r#"{"v":2,"kind":"nope"}"#.to_string()],
);
let metadata = test_turn_metadata(record.id, TurnId::new());
- let turn = super::build_turn_record(&metadata, None, None, None);
+ let turn = super::build_turn_record(&metadata, None, None, None, None);
rollout_store
.append_turn(&record, turn)
.expect("append turn");
@@ -4093,7 +4377,7 @@ mod tests {
})),
RolloutLine::Turn(Box::new(TurnLine {
timestamp: Utc::now(),
- turn: super::build_turn_record(&metadata, None, None, None),
+ turn: super::build_turn_record(&metadata, None, None, None, None),
})),
RolloutLine::Item(ItemLine {
timestamp: Utc::now(),
@@ -4194,7 +4478,7 @@ mod tests {
.append_session_meta(&record)
.expect("append session meta");
let metadata = test_turn_metadata(record.id, TurnId::new());
- let turn = super::build_turn_record(&metadata, None, None, None);
+ let turn = super::build_turn_record(&metadata, None, None, None, None);
rollout_store
.append_turn(&record, turn)
.expect("append turn");
diff --git a/crates/server/src/projection.rs b/crates/server/src/projection.rs
index 6aeeed3c..38789689 100644
--- a/crates/server/src/projection.rs
+++ b/crates/server/src/projection.rs
@@ -176,7 +176,12 @@ pub(crate) fn history_item_from_turn_item(item: &TurnItem) -> Option None,
+ TurnItem::ContextCompaction(TextItem { .. }) => Some(SessionHistoryItem::new(
+ None,
+ SessionHistoryItemKind::ContextCompaction,
+ "Context compacted".to_string(),
+ String::new(),
+ )),
TurnItem::Reasoning(TextItem { text }) => Some(SessionHistoryItem::new(
None,
SessionHistoryItemKind::Reasoning,
@@ -216,6 +221,14 @@ pub(crate) fn history_item_from_turn_item(item: &TurnItem) -> Option {
+ // Canonical v2 ToolResult does not carry tool_name; resume may
+ // still recover Edited metadata from structured file-change output.
+ let edited_metadata = (!*is_error)
+ .then(|| parse_edited_history_metadata(output))
+ .flatten();
+ let resolved_tool_name = tool_name
+ .clone()
+ .or_else(|| edited_metadata.is_some().then(|| "edit".to_string()));
let mut item = SessionHistoryItem::new(
Some(tool_call_id.clone()),
if *is_error {
@@ -223,15 +236,15 @@ pub(crate) fn history_item_from_turn_item(item: &TurnItem) -> Option text.clone(),
other => other.to_string(),
}),
);
- if let Some(tool_name) = tool_name {
+ if let Some(tool_name) = resolved_tool_name {
item = item.with_tool_io(SessionHistoryToolIo {
- tool_name: tool_name.clone(),
+ tool_name,
input: serde_json::Value::Null,
output: Some(output.clone()),
display_content: display_content.clone(),
@@ -246,10 +259,7 @@ pub(crate) fn history_item_from_turn_item(item: &TurnItem) -> Option,
- failure_reason: Option