@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 +54 -5
- package/index.d.ts +11 -1
- package/package.json +2 -2
- package/source/CHANGELOG.md +17 -0
- package/source/Cargo.lock +1 -1
- package/source/Cargo.toml +1 -1
- package/source/README.md +54 -5
- package/source/src/configuration.rs +89 -0
- package/source/src/conversation.rs +12 -0
- package/source/src/lib.rs +3 -0
- package/source/src/native/client.rs +20 -42
- package/source/src/native/fixture.py +13 -3
- package/source/src/native/mod.rs +3 -0
- package/source/src/native/session.rs +63 -1
- package/source/src/native/tests.rs +36 -0
- package/wasm/ccht_bg.wasm +0 -0
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.
|
|
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.
|
|
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
|
-
|
|
107
|
-
|
|
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
|
|
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.
|
|
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": "
|
|
35
|
+
"gitHead": "387d2d172a285508efae68b24822a73b152b1633"
|
|
36
36
|
}
|
package/source/CHANGELOG.md
CHANGED
|
@@ -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
package/source/Cargo.toml
CHANGED
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.
|
|
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.
|
|
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
|
-
|
|
107
|
-
|
|
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
|
@@ -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,
|
|
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(
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/source/src/native/mod.rs
CHANGED
|
@@ -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
|