@corbet-labs/ccht 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,14 +24,14 @@ call paid model HTTP APIs or select an API-key fallback.
24
24
 
25
25
  ```toml
26
26
  [dependencies]
27
- ccht = "0.1"
27
+ ccht = "0.2"
28
28
  tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
29
29
  ```
30
30
 
31
31
  `native` is the default feature. For the portable model alone:
32
32
 
33
33
  ```toml
34
- ccht = { version = "0.1", default-features = false }
34
+ ccht = { version = "0.2", default-features = false }
35
35
  ```
36
36
 
37
37
  ```rust
@@ -62,6 +62,16 @@ A session accepts one active prompt; another returns `Busy`. Different sessions
62
62
  have separate streams and permission identities. History load/resume and model
63
63
  selection require the agent's advertised capabilities; unsupported requests fail.
64
64
 
65
+ `session.handle().configuration()` exposes the agent's ordered model, reasoning,
66
+ mode and boolean controls. Call `set_model(id)` or `set_config_option(id, value)`
67
+ to change them. The full response replaces the advertised configuration, including
68
+ dependent options; configuration notifications update both the native handle and
69
+ the portable conversation snapshot. Unknown values fail without a fallback.
70
+ `SessionOptions::configuration` applies explicit settings when creating or restoring
71
+ a session. Retain the agent session ID and its working directory in your product
72
+ storage to resume completed conversations across process restarts. Never replay an
73
+ uncertain delivery automatically.
74
+
65
75
  Permissions default to denial. Applications that opt into asking must render the
66
76
  request, check their authority and respond with one of the advertised choices.
67
77
  A permission callback is not a sandbox: agent-owned tools may have separate runtime
@@ -103,12 +113,51 @@ render(snapshot.turns);
103
113
  conversation.free();
104
114
  ```
105
115
 
106
- Without an explicit `wasm` option, initialization loads `wasm/ccht_bg.wasm`
107
- relative to the generated module. A browser cannot launch native processes: its
116
+ Version 0.2.0 requires an explicit `wasm` URL or bytes on first initialization.
117
+ A browser cannot launch native processes: its
108
118
  application server or desktop host runs `ccht::native`. The package makes no
109
119
  network connection apart from loading its Wasm module. The host decides which
110
120
  backend is available and how users authenticate to the application.
111
121
 
122
+ ## JSR
123
+
124
+ The JSR package exposes the same Rust/Wasm model:
125
+
126
+ ```ts
127
+ import { createConversation } from 'jsr:@corbet-labs/ccht@0.2.0';
128
+
129
+ const conversation = await createConversation('workspace/creator', {
130
+ wasm: new URL('https://jsr.io/@corbet-labs/ccht/0.2.0/wasm/ccht_bg.wasm'),
131
+ });
132
+ ```
133
+
134
+ In Deno, allow access with `--allow-net=jsr.io` for this Wasm download.
135
+ Applications can also supply their own Wasm bytes or URL. JSR's corresponding
136
+ source archive uses `dependencies.tar.xz` to fit its package size limit; the
137
+ source files and runtime bytes are the same as the npm distribution.
138
+
139
+ ## Python
140
+
141
+ Install from [PyPI](https://pypi.org/project/ccht/0.2.0/):
142
+
143
+ ```sh
144
+ python -m pip install ccht==0.2.0
145
+ ```
146
+
147
+ ```python
148
+ from ccht import Conversation
149
+
150
+ conversation = Conversation('workspace/creator')
151
+ # Deliver an authorized WireEvent from your application transport.
152
+ conversation.apply_event(event)
153
+ snapshot = conversation.snapshot()
154
+ ```
155
+
156
+ The Python package wraps the published Rust conversation model through PyO3.
157
+ It exposes event reduction and snapshots; native agent execution uses a Rust
158
+ host with `ccht::native`. See [py/README.md](py/README.md) for the Python API,
159
+ binary wheel requirements and source rebuild/replacement instructions.
160
+
112
161
  ## Event and persistence contract
113
162
 
114
163
  `WireEvent` version 1 contains `conversation_id`, `request_id`, a per-request
@@ -145,7 +194,7 @@ cargo build --locked --release --no-default-features --features web --target was
145
194
  cargo run --locked --release -p ccht-wasm-bundle -- target/wasm32-unknown-unknown/release/ccht.wasm web/wasm
146
195
  ```
147
196
 
148
- Published browser archives include the corresponding Rust source and generator
197
+ Published npm browser archives include the corresponding Rust source and generator
149
198
  under `source/`, original notices, and the full locked dependency sources in
150
199
  `source/dependencies.tar.gz`. Cargo's vendor checksums are preserved inside that
151
200
  archive. To rebuild from the distributed package, extract it inside `source/`:
package/index.d.ts CHANGED
@@ -35,7 +35,17 @@ export interface TurnState {
35
35
  stop_reason: string | null;
36
36
  error: { code: string; message: string } | null;
37
37
  }
38
- export interface ConversationSnapshot { conversation_id: string; turns: TurnState[] }
38
+ export interface ConfigChoice { value: string; name: string; description?: string }
39
+ export interface ConfigGroup { group: string; name: string; options: ConfigChoice[] }
40
+ export type SessionConfigOption = {
41
+ id: string; name: string; description?: string; category?: string;
42
+ } & ({ type: 'select'; currentValue: string; options: ConfigChoice[] | ConfigGroup[] }
43
+ | { type: 'boolean'; currentValue: boolean });
44
+ export interface SessionConfiguration {
45
+ options: SessionConfigOption[];
46
+ modes: null | { currentModeId: string; availableModes: Array<{id: string; name: string; description?: string}> };
47
+ }
48
+ export interface ConversationSnapshot { conversation_id: string; turns: TurnState[]; configuration: SessionConfiguration }
39
49
  export interface Conversation {
40
50
  applyEvent(event: WireEvent | string): ConversationSnapshot;
41
51
  snapshot(): ConversationSnapshot;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@corbet-labs/ccht",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Reusable conversations for your applications, powered by the shared Rust/Wasm model",
5
5
  "license": "LGPL-3.0-only",
6
6
  "type": "module",
@@ -32,5 +32,5 @@
32
32
  "access": "public"
33
33
  },
34
34
  "sideEffects": false,
35
- "gitHead": "340b9c2d41c77c2c1448666ddad8cd28307517bd"
35
+ "gitHead": "387d2d172a285508efae68b24822a73b152b1633"
36
36
  }
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 — 2026-09-12
4
+
5
+ - Expose live session configuration and validated model, select and boolean controls.
6
+ - Preserve dependent configuration changes and agent notifications across Rust/Wasm snapshots.
7
+ - Apply explicit controls when creating, loading or resuming native sessions.
8
+ - Close the native connection after an uncertain configuration timeout.
9
+
10
+
11
+ ## Additional 0.1.0 distributions
12
+
13
+ - Add JSR packaging with the original Rust/Wasm runtime and a losslessly
14
+ recompressed corresponding-source kit.
15
+ - Add Python conversation-model bindings through PyO3, with a native wheel,
16
+ source distribution and independent source-replacement checks.
17
+ - Record each additional distribution's producing source separately; the
18
+ original Rust/npm packages and core release tag remain unchanged.
19
+
3
20
  ## 0.1.0 — 2026-09-11
4
21
 
5
22
  - Rename the unpublished cllm package to ccht and license current work LGPL-3.0-only.
package/source/Cargo.lock CHANGED
@@ -226,7 +226,7 @@ dependencies = [
226
226
 
227
227
  [[package]]
228
228
  name = "ccht"
229
- version = "0.1.0"
229
+ version = "0.2.0"
230
230
  dependencies = [
231
231
  "agent-client-protocol",
232
232
  "agent-client-protocol-schema",
package/source/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "ccht"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  edition = "2024"
5
5
  rust-version = "1.88"
6
6
  license = "LGPL-3.0-only"
package/source/README.md CHANGED
@@ -24,14 +24,14 @@ call paid model HTTP APIs or select an API-key fallback.
24
24
 
25
25
  ```toml
26
26
  [dependencies]
27
- ccht = "0.1"
27
+ ccht = "0.2"
28
28
  tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
29
29
  ```
30
30
 
31
31
  `native` is the default feature. For the portable model alone:
32
32
 
33
33
  ```toml
34
- ccht = { version = "0.1", default-features = false }
34
+ ccht = { version = "0.2", default-features = false }
35
35
  ```
36
36
 
37
37
  ```rust
@@ -62,6 +62,16 @@ A session accepts one active prompt; another returns `Busy`. Different sessions
62
62
  have separate streams and permission identities. History load/resume and model
63
63
  selection require the agent's advertised capabilities; unsupported requests fail.
64
64
 
65
+ `session.handle().configuration()` exposes the agent's ordered model, reasoning,
66
+ mode and boolean controls. Call `set_model(id)` or `set_config_option(id, value)`
67
+ to change them. The full response replaces the advertised configuration, including
68
+ dependent options; configuration notifications update both the native handle and
69
+ the portable conversation snapshot. Unknown values fail without a fallback.
70
+ `SessionOptions::configuration` applies explicit settings when creating or restoring
71
+ a session. Retain the agent session ID and its working directory in your product
72
+ storage to resume completed conversations across process restarts. Never replay an
73
+ uncertain delivery automatically.
74
+
65
75
  Permissions default to denial. Applications that opt into asking must render the
66
76
  request, check their authority and respond with one of the advertised choices.
67
77
  A permission callback is not a sandbox: agent-owned tools may have separate runtime
@@ -103,12 +113,51 @@ render(snapshot.turns);
103
113
  conversation.free();
104
114
  ```
105
115
 
106
- Without an explicit `wasm` option, initialization loads `wasm/ccht_bg.wasm`
107
- relative to the generated module. A browser cannot launch native processes: its
116
+ Version 0.2.0 requires an explicit `wasm` URL or bytes on first initialization.
117
+ A browser cannot launch native processes: its
108
118
  application server or desktop host runs `ccht::native`. The package makes no
109
119
  network connection apart from loading its Wasm module. The host decides which
110
120
  backend is available and how users authenticate to the application.
111
121
 
122
+ ## JSR
123
+
124
+ The JSR package exposes the same Rust/Wasm model:
125
+
126
+ ```ts
127
+ import { createConversation } from 'jsr:@corbet-labs/ccht@0.2.0';
128
+
129
+ const conversation = await createConversation('workspace/creator', {
130
+ wasm: new URL('https://jsr.io/@corbet-labs/ccht/0.2.0/wasm/ccht_bg.wasm'),
131
+ });
132
+ ```
133
+
134
+ In Deno, allow access with `--allow-net=jsr.io` for this Wasm download.
135
+ Applications can also supply their own Wasm bytes or URL. JSR's corresponding
136
+ source archive uses `dependencies.tar.xz` to fit its package size limit; the
137
+ source files and runtime bytes are the same as the npm distribution.
138
+
139
+ ## Python
140
+
141
+ Install from [PyPI](https://pypi.org/project/ccht/0.2.0/):
142
+
143
+ ```sh
144
+ python -m pip install ccht==0.2.0
145
+ ```
146
+
147
+ ```python
148
+ from ccht import Conversation
149
+
150
+ conversation = Conversation('workspace/creator')
151
+ # Deliver an authorized WireEvent from your application transport.
152
+ conversation.apply_event(event)
153
+ snapshot = conversation.snapshot()
154
+ ```
155
+
156
+ The Python package wraps the published Rust conversation model through PyO3.
157
+ It exposes event reduction and snapshots; native agent execution uses a Rust
158
+ host with `ccht::native`. See [py/README.md](py/README.md) for the Python API,
159
+ binary wheel requirements and source rebuild/replacement instructions.
160
+
112
161
  ## Event and persistence contract
113
162
 
114
163
  `WireEvent` version 1 contains `conversation_id`, `request_id`, a per-request
@@ -145,7 +194,7 @@ cargo build --locked --release --no-default-features --features web --target was
145
194
  cargo run --locked --release -p ccht-wasm-bundle -- target/wasm32-unknown-unknown/release/ccht.wasm web/wasm
146
195
  ```
147
196
 
148
- Published browser archives include the corresponding Rust source and generator
197
+ Published npm browser archives include the corresponding Rust source and generator
149
198
  under `source/`, original notices, and the full locked dependency sources in
150
199
  `source/dependencies.tar.gz`. Cargo's vendor checksums are preserved inside that
151
200
  archive. To rebuild from the distributed package, extract it inside `source/`:
@@ -0,0 +1,89 @@
1
+ //! Agent-owned session controls shared by native, Wasm and transport consumers.
2
+
3
+ use serde::{Deserialize, Serialize};
4
+
5
+ use crate::acp::{
6
+ SessionConfigKind, SessionConfigOption, SessionConfigOptionCategory, SessionConfigOptionValue,
7
+ SessionConfigSelectOptions, SessionModeState, SessionUpdate,
8
+ };
9
+
10
+ /// An agent's current advertised configuration; option order is significant.
11
+ #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
12
+ pub struct SessionConfiguration {
13
+ /// Model, mode, reasoning and other controls supplied by the agent.
14
+ pub options: Vec<SessionConfigOption>,
15
+ /// Legacy modes for agents that have not adopted configuration options.
16
+ pub modes: Option<SessionModeState>,
17
+ }
18
+
19
+ impl SessionConfiguration {
20
+ /// The first model selector, following the agent's priority ordering.
21
+ pub fn model_option(&self) -> Option<&SessionConfigOption> {
22
+ self.options.iter().find(|option| {
23
+ option.category == Some(SessionConfigOptionCategory::Model)
24
+ || option.id.0.as_ref() == "model"
25
+ })
26
+ }
27
+
28
+ /// Whether an exact value is offered, including grouped selectors and booleans.
29
+ pub fn accepts(&self, id: &str, value: &SessionConfigOptionValue) -> bool {
30
+ let Some(option) = self
31
+ .options
32
+ .iter()
33
+ .find(|option| option.id.0.as_ref() == id)
34
+ else {
35
+ return false;
36
+ };
37
+ match (&option.kind, value) {
38
+ (SessionConfigKind::Boolean(_), SessionConfigOptionValue::Boolean { .. }) => true,
39
+ (SessionConfigKind::Select(select), SessionConfigOptionValue::ValueId { value }) => {
40
+ match &select.options {
41
+ SessionConfigSelectOptions::Ungrouped(options) => {
42
+ options.iter().any(|option| option.value == *value)
43
+ }
44
+ SessionConfigSelectOptions::Grouped(groups) => groups
45
+ .iter()
46
+ .any(|group| group.options.iter().any(|option| option.value == *value)),
47
+ _ => false,
48
+ }
49
+ }
50
+ _ => false,
51
+ }
52
+ }
53
+
54
+ /// Apply an agent update. Configuration updates replace the complete option list.
55
+ pub fn apply(&mut self, update: &SessionUpdate) {
56
+ match update {
57
+ SessionUpdate::ConfigOptionUpdate(update) => {
58
+ self.options.clone_from(&update.config_options);
59
+ }
60
+ SessionUpdate::CurrentModeUpdate(update) => {
61
+ if let Some(modes) = &mut self.modes {
62
+ modes.current_mode_id.clone_from(&update.current_mode_id);
63
+ }
64
+ }
65
+ _ => {}
66
+ }
67
+ }
68
+ }
69
+
70
+ #[cfg(test)]
71
+ mod tests {
72
+ use super::*;
73
+ #[test]
74
+ fn grouped_selectors_and_boolean_controls_preserve_exact_types() {
75
+ let settings: SessionConfiguration = serde_json::from_value(serde_json::json!({
76
+ "options": [{"id":"model","name":"Model","category":"model","type":"select",
77
+ "currentValue":"one","options":[{"group":"provider","name":"Provider","options":[
78
+ {"value":"one","name":"One"},{"value":"two","name":"Two"}]}]},
79
+ {"id":"thinking","name":"Thinking","type":"boolean","currentValue":false}],
80
+ "modes":null
81
+ }))
82
+ .unwrap();
83
+ assert!(settings.accepts("model", &"two".into()));
84
+ assert!(!settings.accepts("model", &true.into()));
85
+ assert!(settings.accepts("thinking", &true.into()));
86
+ assert!(!settings.accepts("thinking", &"true".into()));
87
+ assert!(!settings.accepts("missing", &true.into()));
88
+ }
89
+ }
@@ -274,6 +274,7 @@ impl TurnState {
274
274
  pub struct Conversation {
275
275
  conversation_id: String,
276
276
  turns: Vec<TurnState>,
277
+ configuration: crate::SessionConfiguration,
277
278
  }
278
279
 
279
280
  impl Conversation {
@@ -282,6 +283,7 @@ impl Conversation {
282
283
  Self {
283
284
  conversation_id: conversation_id.into(),
284
285
  turns: Vec::new(),
286
+ configuration: crate::SessionConfiguration::default(),
285
287
  }
286
288
  }
287
289
 
@@ -295,6 +297,11 @@ impl Conversation {
295
297
  &self.turns
296
298
  }
297
299
 
300
+ /// Latest session controls observed in the ordered event stream.
301
+ pub fn configuration(&self) -> &crate::SessionConfiguration {
302
+ &self.configuration
303
+ }
304
+
298
305
  /// Serialize the render state for a browser or another transport.
299
306
  pub fn snapshot_json(&self) -> Result<String, ConversationError> {
300
307
  serde_json::to_string(self).map_err(|_| ConversationError::Json)
@@ -349,6 +356,10 @@ impl Conversation {
349
356
  {
350
357
  return Err(ConversationError::Capacity);
351
358
  }
359
+ let mut configuration = self.configuration.clone();
360
+ if let Event::Update { update } = &wire.event {
361
+ configuration.apply(update);
362
+ }
352
363
  match wire.event {
353
364
  Event::Update { update } => turn.update(update)?,
354
365
  Event::Permission {
@@ -387,6 +398,7 @@ impl Conversation {
387
398
  {
388
399
  return Err(ConversationError::Capacity);
389
400
  }
401
+ self.configuration = configuration;
390
402
  match index {
391
403
  Some(i) => self.turns[i] = turn,
392
404
  None => self.turns.push(turn),
package/source/src/lib.rs CHANGED
@@ -17,6 +17,9 @@
17
17
  /// The official platform-independent ACP version 1 schema.
18
18
  pub use agent_client_protocol_schema::v1 as acp;
19
19
 
20
+ mod configuration;
21
+ pub use configuration::*;
22
+
20
23
  mod conversation;
21
24
  pub use conversation::*;
22
25
 
@@ -8,8 +8,7 @@ use tokio::time::timeout;
8
8
 
9
9
  use crate::acp::{
10
10
  AuthMethod, AuthenticateRequest, InitializeRequest, InitializeResponse, LoadSessionRequest,
11
- McpServer, NewSessionRequest, ResumeSessionRequest, SessionConfigKind,
12
- SessionConfigOptionCategory, SessionConfigSelectOptions, SetSessionConfigOptionRequest,
11
+ McpServer, NewSessionRequest, ResumeSessionRequest,
13
12
  };
14
13
 
15
14
  use super::{
@@ -129,7 +128,18 @@ impl NativeClient {
129
128
  .builder()
130
129
  .connect_with(agent, async move |connection| {
131
130
  let info = connection
132
- .send_request(InitializeRequest::new(ProtocolVersion::V1))
131
+ .send_request(
132
+ InitializeRequest::new(ProtocolVersion::V1).client_capabilities(
133
+ crate::acp::ClientCapabilities::new().session(
134
+ crate::acp::ClientSessionCapabilities::new().config_options(
135
+ crate::acp::SessionConfigOptionsCapabilities::new()
136
+ .boolean(
137
+ crate::acp::BooleanConfigOptionCapabilities::new(),
138
+ ),
139
+ ),
140
+ ),
141
+ ),
142
+ )
133
143
  .block_task()
134
144
  .await?;
135
145
  if info.protocol_version != ProtocolVersion::V1 {
@@ -293,47 +303,15 @@ impl NativeClient {
293
303
  session: ActiveSession<'static, Agent>,
294
304
  options: SessionOptions,
295
305
  ) -> Result<NativeSession> {
306
+ let session = NativeSession::start(self.inner.clone(), session)?;
307
+ let handle = session.handle();
296
308
  if let Some(model) = options.model {
297
- let config = session
298
- .config_options()
299
- .unwrap_or_default()
300
- .iter()
301
- .find(|option| {
302
- option.category == Some(SessionConfigOptionCategory::Model)
303
- || option.id.0.as_ref() == "model"
304
- })
305
- .ok_or(NativeError::Unsupported("model selection"))?;
306
- let SessionConfigKind::Select(select) = &config.kind else {
307
- return Err(NativeError::Unsupported("model selection"));
308
- };
309
- let available = match &select.options {
310
- SessionConfigSelectOptions::Ungrouped(options) => options
311
- .iter()
312
- .any(|option| option.value.0.as_ref() == model),
313
- SessionConfigSelectOptions::Grouped(groups) => groups.iter().any(|group| {
314
- group
315
- .options
316
- .iter()
317
- .any(|option| option.value.0.as_ref() == model)
318
- }),
319
- _ => false,
320
- };
321
- if !available {
322
- return Err(NativeError::Unsupported("the selected model"));
323
- }
324
- self.operation(
325
- self.inner
326
- .connection
327
- .send_request(SetSessionConfigOptionRequest::new(
328
- session.session_id().clone(),
329
- config.id.clone(),
330
- model.as_str(),
331
- ))
332
- .block_task(),
333
- )
334
- .await?;
309
+ handle.set_model(&model).await?;
310
+ }
311
+ for (id, value) in options.configuration {
312
+ handle.set_config_option(&id, value).await?;
335
313
  }
336
- NativeSession::start(self.inner.clone(), session)
314
+ Ok(session)
337
315
  }
338
316
 
339
317
  fn validate_session(&self, options: &SessionOptions) -> Result<()> {
@@ -8,6 +8,7 @@ import time
8
8
 
9
9
  mode = sys.argv[1]
10
10
  sessions = 0
11
+ configurations = {}
11
12
  pending = {}
12
13
  permissions = {}
13
14
  permission_id = 100
@@ -51,11 +52,20 @@ for line in sys.stdin:
51
52
  session = params.get("sessionId", "fixture-" + str(sessions))
52
53
  if method == "session/load":
53
54
  update(session, "restored history")
54
- response(request_id, {"sessionId": session, "configOptions": [{
55
+ configurations[session] = [{
55
56
  "id": "model", "name": "Model", "category": "model", "type": "select",
56
- "currentValue": "test-model", "options": [{"value": "test-model", "name": "Test"}]}]})
57
+ "currentValue": "test-model", "options": [{"value": "test-model", "name": "Test"},
58
+ {"value": "second-model", "name": "Second"}]},
59
+ {"id": "thinking", "name": "Thinking", "type": "boolean", "currentValue": False}]
60
+ response(request_id, {"sessionId": session, "configOptions": configurations[session]})
57
61
  elif method == "session/set_config_option":
58
- response(request_id, {"configOptions": []})
62
+ configuration = configurations[params["sessionId"]]
63
+ for option in configuration:
64
+ if option["id"] == params["configId"]:
65
+ option["currentValue"] = params["value"]
66
+ if params["configId"] == "model":
67
+ configuration[1]["currentValue"] = params["value"] == "second-model"
68
+ response(request_id, {"configOptions": configuration})
59
69
  elif method == "session/prompt":
60
70
  session = params["sessionId"]
61
71
  pending[session] = request_id
@@ -153,6 +153,8 @@ pub struct SessionOptions {
153
153
  pub cwd: PathBuf,
154
154
  /// Exact advertised model option value, or the agent's default when absent.
155
155
  pub model: Option<String>,
156
+ /// Agent-advertised configuration values, applied in order after model selection.
157
+ pub configuration: Vec<(String, crate::acp::SessionConfigOptionValue)>,
156
158
  /// Application-owned MCP server descriptors passed to the agent.
157
159
  pub mcp_servers: Vec<McpServer>,
158
160
  }
@@ -163,6 +165,7 @@ impl SessionOptions {
163
165
  Self {
164
166
  cwd: cwd.into(),
165
167
  model: None,
168
+ configuration: Vec::new(),
166
169
  mcp_servers: Vec::new(),
167
170
  }
168
171
  }
@@ -13,7 +13,7 @@ use crate::acp::{
13
13
  RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse, SessionId,
14
14
  SessionNotification, StopReason,
15
15
  };
16
- use crate::{Event, Prompt, SessionEvent};
16
+ use crate::{Event, Prompt, SessionConfiguration, SessionEvent};
17
17
 
18
18
  use super::client::{ClientLease, ClientState};
19
19
  use super::{NativeError, PermissionDecision, PermissionPolicy, Result, lock};
@@ -51,6 +51,8 @@ struct SessionState {
51
51
  failure: Mutex<Option<NativeError>>,
52
52
  events: Mutex<Option<mpsc::Sender<SessionEvent>>>,
53
53
  permissions: Mutex<BTreeMap<String, PendingPermission>>,
54
+ configuration: Mutex<SessionConfiguration>,
55
+ configuring: tokio::sync::Mutex<()>,
54
56
  task: Mutex<Option<JoinHandle<()>>>,
55
57
  }
56
58
 
@@ -107,6 +109,9 @@ impl SessionState {
107
109
  }
108
110
 
109
111
  fn emit(&self, event: Event) -> Result<()> {
112
+ if let Event::Update { update } = &event {
113
+ lock(&self.configuration).apply(update);
114
+ }
110
115
  let request_id = lock(&self.turn).current.clone();
111
116
  let result = lock(&self.events)
112
117
  .as_ref()
@@ -200,6 +205,11 @@ impl NativeSession {
200
205
  failure: Mutex::new(None),
201
206
  events: Mutex::new(Some(events_tx)),
202
207
  permissions: Mutex::new(BTreeMap::new()),
208
+ configuration: Mutex::new(SessionConfiguration {
209
+ options: session.config_options().unwrap_or_default().to_vec(),
210
+ modes: session.modes().cloned(),
211
+ }),
212
+ configuring: tokio::sync::Mutex::new(()),
203
213
  task: Mutex::new(None),
204
214
  });
205
215
  let worker_state = state.clone();
@@ -236,6 +246,58 @@ impl NativeSession {
236
246
  }
237
247
 
238
248
  impl SessionHandle {
249
+ /// Latest agent-advertised controls, including dependent changes and notifications.
250
+ pub fn configuration(&self) -> SessionConfiguration {
251
+ lock(&self.lease.state.configuration).clone()
252
+ }
253
+
254
+ /// Select an exact model advertised by this session, without any fallback.
255
+ pub async fn set_model(&self, model: &str) -> Result<SessionConfiguration> {
256
+ let id = self
257
+ .configuration()
258
+ .model_option()
259
+ .map(|option| option.id.to_string())
260
+ .ok_or(NativeError::Unsupported("model selection"))?;
261
+ self.set_config_option(&id, model.into()).await
262
+ }
263
+
264
+ /// Change an advertised select or boolean control and retain the full response.
265
+ /// Unknown controls and values fail before sending a request to the agent.
266
+ pub async fn set_config_option(
267
+ &self,
268
+ id: &str,
269
+ value: crate::acp::SessionConfigOptionValue,
270
+ ) -> Result<SessionConfiguration> {
271
+ let state = &self.lease.state;
272
+ let _serial = state.configuring.lock().await;
273
+ if state.is_closed() {
274
+ return Err(state.error());
275
+ }
276
+ if !self.configuration().accepts(id, &value) {
277
+ return Err(NativeError::Unsupported("the selected configuration value"));
278
+ }
279
+ let request =
280
+ crate::acp::SetSessionConfigOptionRequest::new(state.id.clone(), id.to_owned(), value);
281
+ let response = match timeout(
282
+ self.lease.client.options.operation_timeout,
283
+ state.connection.send_request(request).block_task(),
284
+ )
285
+ .await
286
+ {
287
+ Ok(result) => result?,
288
+ Err(_) => {
289
+ state.close(NativeError::Timeout, true);
290
+ return Err(NativeError::Timeout);
291
+ }
292
+ };
293
+ state.emit(Event::Update {
294
+ update: crate::acp::SessionUpdate::ConfigOptionUpdate(
295
+ crate::acp::ConfigOptionUpdate::new(response.config_options),
296
+ ),
297
+ })?;
298
+ Ok(self.configuration())
299
+ }
300
+
239
301
  /// The agent-owned session identifier, suitable for explicit load or resume.
240
302
  pub fn id(&self) -> &str {
241
303
  self.lease.state.id.0.as_ref()
@@ -425,3 +425,39 @@ async fn dropping_connect_during_initialization_really_terminates_its_children()
425
425
  assert_processes_stopped(&pids).await;
426
426
  let _ = std::fs::remove_file(path);
427
427
  }
428
+
429
+ #[tokio::test]
430
+ async fn advertised_controls_preserve_dependent_changes_and_reject_invalid_values() {
431
+ let (client, _session, handle, mut events) = session("normal", options()).await;
432
+ assert!(
433
+ handle
434
+ .configuration()
435
+ .accepts("model", &"test-model".into())
436
+ );
437
+ let changed = handle.set_model("second-model").await.unwrap();
438
+ assert!(matches!(&changed.options[1].kind,
439
+ crate::acp::SessionConfigKind::Boolean(value) if value.current_value));
440
+ let event = events.recv().await.unwrap();
441
+ assert!(event.request_id.is_none());
442
+ assert!(matches!(
443
+ event.event,
444
+ Event::Update {
445
+ update: SessionUpdate::ConfigOptionUpdate(_)
446
+ }
447
+ ));
448
+ assert!(
449
+ handle
450
+ .set_config_option("thinking", false.into())
451
+ .await
452
+ .is_ok()
453
+ );
454
+ assert!(matches!(
455
+ handle.set_model("not-offered").await,
456
+ Err(NativeError::Unsupported(_))
457
+ ));
458
+ assert!(matches!(
459
+ handle.set_config_option("thinking", "false".into()).await,
460
+ Err(NativeError::Unsupported(_))
461
+ ));
462
+ client.close().await.unwrap();
463
+ }
package/wasm/ccht_bg.wasm CHANGED
Binary file