Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/user/tui-and-sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,14 @@ These local commands are available only while the session is idle. `/agents` tog

`/model` opens the model selector. `/effort` opens the advertised ACP reasoning-effort selector; `/effort default|low|medium|high` selects directly. In either dialog, Tab toggles saving the selection to `~/.kit/config.toml`, Enter selects, and Esc closes. Saving `default` removes top-level `reasoning_effort`; other values update it without replacing unrelated TOML. A new or resumed process starts from the resolved CLI/TOML default unless the selection was saved.

Before changing models, Kit compares the latest provider-reported transcript occupancy with the target model's advertised context window. It adds a 20% tokenizer margin (`ceil(tokens × 1.20)`) and warns when that estimate is at least 80% of the target window. This is the latest request's occupancy, not accumulated session usage; cached input tokens are not counted twice.

The TUI warning offers **Continue anyway**, **Compact**, and **Cancel** (the default). Continue switches without compacting. Compact runs the existing compactor with the **original model** and applies the new selection only after successful completion and durable transcript replacement. A failure or cancellation keeps the original model selected; compaction can already have changed the transcript if cancellation arrives after replacement. Cancel dismisses the warning without changing the model or transcript, and preserves the input draft. Esc cancels an in-progress switch; repeated Esc is harmless, and Ctrl+C while cancellation is pending exits if it is stuck. Stale confirmations are rejected when the session, selected model, target, or transcript changes.

If the latest token count or target context window is unavailable (including fallback/custom models without catalog metadata), Kit allows an **unchecked switch** rather than inventing a limit or forcing compaction. The estimate is a warning, not a guarantee that the next provider request fits.

External ACP clients receive a confirmation-required error instead of a Kit dialog. Its extension key in error data is `kit.model_switch`; clients can resubmit the same configuration request with `_meta: {"kit.model_switch": {"token": <returned token>, "action": "continue"}}`. Both ACP versions also accept `"action": "compact"` to compact transactionally with the original model. Confirmation tokens are session-local, in-memory, one-shot decisions, not persisted configuration.

The ACP server advertises `compact` for every new session. The TUI submits `/compact` unchanged like any other prompt; the runtime consumes exactly one text part beginning with the exact raw token before model dispatch and permits other client-provided context parts. Used alone, it ends after compaction. Whitespace-trimmed text following `/compact` and any other context parts are retained as the latest user message and start the next turn after compaction. Leading whitespace, near-misses such as `/compactness`, prompts containing multiple `/compact` command parts, and unknown slash commands remain ordinary prompts. Local commands win if an advertised command has the same name.

## Persisted transcripts and session files
Expand Down
14 changes: 11 additions & 3 deletions src/compaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -763,12 +763,12 @@ impl LoopMutator for AutomaticCompactor {
}
}

fn compaction_reason(transcript: &[Item]) -> Option<CompactionReason> {
pub(crate) fn latest_context_tokens(transcript: &[Item]) -> Option<u64> {
let usage = transcript
.iter()
.rev()
.find_map(|item| item.usage.as_ref())?;
let used = usage
usage
.metadata
.get("context_used")
.and_then(serde_json::Value::as_u64)
Expand All @@ -777,7 +777,15 @@ fn compaction_reason(transcript: &[Item]) -> Option<CompactionReason> {
.tokens
.as_ref()
.and_then(|tokens| tokens.input_tokens.checked_add(tokens.output_tokens))
})?;
})
}

fn compaction_reason(transcript: &[Item]) -> Option<CompactionReason> {
let used = latest_context_tokens(transcript)?;
let usage = transcript
.iter()
.rev()
.find_map(|item| item.usage.as_ref())?;
let window = usage
.metadata
.get("context_window")
Expand Down
130 changes: 120 additions & 10 deletions src/protocols/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ use tokio::{
use tracing::Instrument as _;

mod activity;
pub(crate) mod model_switch;
mod skill_catalog;
pub mod v2;

Expand Down Expand Up @@ -462,7 +463,9 @@ enum Command {
Cancel,
SetConfig {
request: SetSessionConfigOptionRequest,
reply: oneshot::Sender<Result<SetSessionConfigOptionResponse, AcpRuntimeError>>,
cancellation_generation: u64,
reply:
oneshot::Sender<Result<SetSessionConfigOptionResponse, agent_client_protocol::Error>>,
},
Fork {
parent_context: Option<(String, String)>,
Expand Down Expand Up @@ -1581,14 +1584,24 @@ impl Server {
async fn set_config(
&self,
request: SetSessionConfigOptionRequest,
) -> Result<SetSessionConfigOptionResponse, AcpRuntimeError> {
let sender = self.sender(&request.session_id).await?;
) -> Result<SetSessionConfigOptionResponse, agent_client_protocol::Error> {
let sender = self.sender(&request.session_id).await.map_err(sdk_error)?;
let cancellation_generation = self
.integration
.cancellation_handle(&request.session_id)
.map_err(sdk_error)?
.generation();
let (tx, rx) = oneshot::channel();
sender
.send(Command::SetConfig { request, reply: tx })
.send(Command::SetConfig {
request,
reply: tx,
cancellation_generation,
})
.await
.map_err(|_| AcpRuntimeError::ClientClosed)?;
rx.await.map_err(|_| AcpRuntimeError::ClientClosed)?
.map_err(|_| sdk_error(AcpRuntimeError::ClientClosed))?;
rx.await
.map_err(|_| sdk_error(AcpRuntimeError::ClientClosed))?
}

async fn cancel(&self, notification: CancelNotification) -> Result<(), AcpRuntimeError> {
Expand Down Expand Up @@ -1649,6 +1662,8 @@ impl Server {
&self,
session_id: &agentkit_acp::SessionId,
) -> Result<mpsc::Sender<Command>, AcpRuntimeError> {
// Publication holds this lock across registry registration and commit.
// A poisoned map cannot establish that the session lifecycle is consistent.
self.sessions
.lock()
.map_err(|_| AcpRuntimeError::ClientClosed)?
Expand Down Expand Up @@ -1750,6 +1765,7 @@ async fn session_actor<S: ModelSession>(actor: SessionActor<S>) {
mut mcp_events,
} = actor;
let mut binding = Some(binding);
let mut model_switch = model_switch::Guard::default();
loop {
tokio::select! {
// A queued cancel or close wins over a simultaneously-ready task
Expand Down Expand Up @@ -1778,8 +1794,35 @@ async fn session_actor<S: ModelSession>(actor: SessionActor<S>) {
// The server already interrupted the shared controller; this
// marker only establishes its serialized actor position.
Some(Command::Cancel) => {}
Some(Command::SetConfig { request, reply }) => {
let result = set_config(&adapter, &catalog, request);
Some(Command::SetConfig { request, reply, cancellation_generation }) => {
let result = async {
let cancellation = integration.cancellation_handle(&session_id).map_err(sdk_error)?;
if cancellation.is_cancelled_since(cancellation_generation) { return Err(model_switch::error("model change cancelled")); }
if request.config_id.to_string() == MODEL_CONFIG_ID {
let target = request.value.as_value_id().ok_or_else(|| model_switch::error("selection requires an id value"))?;
let decision = model_switch.check(
(&adapter.selection().map_err(|error| model_switch::error(&error))?, cancellation_generation),
ModelSelection::from_id(&target.to_string()).map_err(|error| model_switch::error(&error))?,
&catalog, driver.snapshot().transcript,
request.meta.as_ref().and_then(|meta| meta.get(model_switch::META)),
)?;
if decision == model_switch::Decision::Compact {
let marker = model_switch::compact_marker();
let marker_id = marker.id.clone();
driver.submit_input(vec![marker]).map_err(|error| sdk_error(record_acp_loop_failure(&session_id, &error)))?;
let reason = activity.execute(activity::ExecutionOrigin::Prompt,
drive_finalized(&session_id, &integration, &mut driver, false, None),
|reason| Some(reason.clone()),
).await.map_err(sdk_error)?;
if !model_switch::compaction_completed(&reason, &marker_id, &driver.snapshot().transcript,
cancellation.is_cancelled_since(cancellation_generation)) {
return Err(model_switch::error("compaction did not complete; model unchanged"));
}
}
}
if cancellation.is_cancelled_since(cancellation_generation) { return Err(model_switch::error("model change cancelled")); }
set_config(&adapter, &catalog, request).map_err(sdk_error)
}.await;
let _ = reply.send(result);
}
Some(Command::Fork {
Expand Down Expand Up @@ -2588,8 +2631,7 @@ fn component(
async move |request: SetSessionConfigOptionRequest, responder, cx| {
let state = Arc::clone(&state);
cx.spawn(async move {
responder
.respond_with_result(state.set_config(request).await.map_err(sdk_error))
responder.respond_with_result(state.set_config(request).await)
})?;
Ok(())
}
Expand Down Expand Up @@ -4701,6 +4743,72 @@ pub(super) mod tests {
(jobs, tasks)
}

#[tokio::test]
async fn set_config_rejects_poisoned_session_map_without_queueing_switch() {
let root = tempfile::tempdir().unwrap();
let server = Server::new(
Runtime::new(root.path(), "gpt-5.4").unwrap(),
AcpIntegration::builder()
.name("poison-test")
.approval_resolver(AutoDenyResolver)
.build()
.unwrap(),
SessionRegistry::new(),
);
let session_id = agentkit_acp::SessionId::new("poisoned-session");
let (client, _messages) = AcpClientHandle::channel();
server
.integration
.bind_session(AcpSessionBinding::new(
session_id.clone(),
AgentkitSessionId::new("poisoned-session"),
client,
))
.unwrap();
let (commands, mut received) = mpsc::channel(1);
server.sessions.lock().unwrap().insert(
session_id.clone(),
SessionHandle {
token: 1,
commands,
background_jobs: BackgroundJobs::default(),
structured_completion: false,
tasks: AsyncTaskManager::new().handle(),
},
);
assert!(
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _guard = server.sessions.lock().unwrap();
panic!("poison session map");
}))
.is_err()
);

for id in [session_id, agentkit_acp::SessionId::new("missing-session")] {
let error = timeout(
Duration::from_secs(1),
server.set_config(SetSessionConfigOptionRequest::new(
id,
MODEL_CONFIG_ID,
"openai-subscription:gpt-5.4-mini",
)),
)
.await
.expect("poison must return an error without waiting for the actor")
.unwrap_err();
assert_eq!(error.code, agent_client_protocol::ErrorCode::InternalError);
assert_eq!(
error.data,
Some(serde_json::json!(AcpRuntimeError::ClientClosed.to_string()))
);
}
assert!(server.sessions.is_poisoned());
assert!(matches!(
received.try_recv(),
Err(mpsc::error::TryRecvError::Empty)
));
}

#[tokio::test]
async fn depth_zero_cancel_leaves_detached_background_running() {
let (jobs, tasks) = start_non_cooperative_background("root-call").await;
Expand Down Expand Up @@ -5459,6 +5567,7 @@ pub(super) mod tests {
let adapter =
SelectableAdapter::new(crate::ProviderKind::OpenAiSubscription, "gpt-5.4").unwrap();
let catalog = vec![ModelGroup {
context_windows: Default::default(),
provider: crate::ProviderKind::OpenAiSubscription,
models: vec!["gpt-5.4".into(), "gpt-5.4-mini".into()],
}];
Expand All @@ -5484,6 +5593,7 @@ pub(super) mod tests {
let adapter =
SelectableAdapter::new(crate::ProviderKind::OpenAiSubscription, "gpt-5.4").unwrap();
let catalog = vec![ModelGroup {
context_windows: Default::default(),
provider: crate::ProviderKind::OpenAiSubscription,
models: vec!["gpt-5.4".into()],
}];
Expand Down
Loading
Loading