Add Rust embedding API
CI / Build (push) Waiting to run
CI / Test (push) Waiting to run

This commit is contained in:
2026-06-25 06:53:13 -05:00
parent 4d021fe2ab
commit 71f84c03ce
14 changed files with 1428 additions and 22 deletions
Generated
+1 -1
View File
@@ -211,7 +211,7 @@ checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
[[package]]
name = "cassady"
version = "0.2.5"
version = "0.2.6"
dependencies = [
"anyhow",
"async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cassady"
version = "0.2.5"
version = "0.2.6"
edition = "2021"
description = "Cassady/Cass minimal terminal coding agent"
license = "MIT"
+18
View File
@@ -8,6 +8,7 @@ The project installs two equivalent commands, `cass` and `cassady`; examples use
- Provider support is OpenAI-compatible chat/completions APIs only.
- The primary interface is an interactive terminal UI.
- v0.2.6 adds an experimental Rust embedding API for headless sessions; it is useful for early integrations but not yet a stable long-term library contract.
- Config and conversation state live under `~/.cass`.
- Windows binaries are built for releases, but deeper Windows terminal, path, shell, and filesystem polish is planned for a later release.
- Cassady is not an installer, updater, or package manager.
@@ -112,12 +113,29 @@ API key references should usually be written as environment variables such as `"
Detailed bundled docs live in this repository under [`docs/`](docs/README.md) and are installed to `~/.cass/docs` at runtime.
## Experimental Rust embedding API
Rust applications can import Cassady and run headless sessions without launching the TUI:
```rust
use cassady::prelude::*;
let session = SessionBuilder::new()
.cwd(".")
.access_mode(AccessMode::ReadOnly)
.build()
.await?;
```
See [Experimental Rust embedding API](docs/embedding.md) for session creation, streamed events, approval handling, cancellation, and current limitations.
## More documentation
- [Commands](docs/commands.md)
- [Configuration](docs/configuration.md)
- [Providers and models](docs/providers.md)
- [Access modes and tool safety](docs/access-modes.md)
- [Experimental Rust embedding API](docs/embedding.md)
- [Workflows](docs/workflows.md)
- [Troubleshooting](docs/troubleshooting.md)
- [Platform notes](docs/platforms.md)
+36
View File
@@ -1,5 +1,41 @@
# Cassady (Cass) Roadmap
## v0.2.6 — Rust Embedding API ✅ Completed
This release focuses on adding the first intentional Rust library surface for embedding Cassady in other Rust projects. The goal is to provide the bones for programmatic, headless agent sessions: configure a workspace, start or resume an agent session, send turns, stream typed events, and handle approvals without launching the TUI. See `plans/V0_2_6_RUST_EMBEDDING_API_PLAN.md`.
### Experimental Public API
- [x] **Add a supported embedding module.** Provide a small `cassady::embedding` API with builder, session, turn, event, approval, and error types so callers do not need to stitch together internal modules directly.
- Mark the API experimental for v0.2.6 rather than promising long-term semver stability.
- Keep existing CLI/TUI behavior unchanged while steering library users toward the new module.
- [x] **Support host-configured agent sessions.** Let Rust callers create or resume headless sessions with explicit cwd, access mode, model/provider overrides, reasoning effort, and Cassady config root.
- Reuse existing config files, global instructions, bundled docs, security policy, and JSONL conversation storage.
- Avoid requiring callers to construct CLI-specific types.
### Headless Turn Execution
- [x] **Run agent turns programmatically.** Add a Tokio-native API for sending one user message, streaming assistant/tool/status events, and returning the updated session or conversation state.
- Prevent or clearly reject overlapping turns unless the type design makes them impossible.
- Preserve provider streaming, tool execution, prompt generation, and context behavior from the existing agent loop.
- [x] **Expose approval handling to host applications.** Allow embedded callers to approve or deny tool approval requests, especially shell commands in `workspace-edit` mode.
- Include request id, tool call id, tool name, arguments, and reason in approval events.
- Document cancellation/drop behavior for active turns.
### Documentation and Validation
- [x] **Add a minimal headless example.** Include a compilable Rust example that imports Cassady, starts a session, sends a prompt, and prints streamed assistant output.
- Note that a configured OpenAI-compatible provider and API key are still required.
- Show where to handle approval requests even if the first example defaults to `read-only`.
- [x] **Document the experimental Rust API.** Add bundled docs and README links for setup requirements, basic usage, event handling, approvals, limitations, and current non-goals.
- Make clear that multi-agent orchestration, custom providers, custom tools, daemons, and stable plugin APIs are deferred.
- [x] **Test embedding without a terminal.** Add integration tests that use temporary config/conversation roots and mock provider responses to verify session creation, turn streaming, resume, approval flow, and access-mode behavior.
- Ensure `cargo test --locked --all-targets` covers the new public API and examples.
## v0.2.4 — System Prompt Refinement
This release focuses on making Cassady's system prompt clearer, more intuitive, and more useful for everyday coding work without letting it become bulky. The target is a well-structured prompt around 1,000 tokens that gives the model enough product context, safety expectations, and workflow guidance to behave consistently across read-only, workspace-edit, and full-access sessions. See `plans/V0_2_4_SYSTEM_PROMPT_REFINEMENT_PLAN.md`.
+1
View File
@@ -10,6 +10,7 @@ Cassady tools may list, search, and read this directory. Mutating tools are bloc
- [Configuration](configuration.md): `~/.cass` files, setup, precedence, schema examples, and validation.
- [Providers and models](providers.md): built-in OpenAI-compatible providers, custom endpoints, model discovery, and reasoning metadata.
- [Access modes and tool safety](access-modes.md): what tools can read, write, edit, and run in each mode.
- [Experimental Rust embedding API](embedding.md): import Cassady from Rust, start headless sessions, stream events, and handle approvals.
- [Workflows](workflows.md): common ways to inspect code, apply edits, run checks, switch models, and resume chats.
- [Troubleshooting](troubleshooting.md): symptoms, likely causes, fixes, and verification commands.
- [Platform notes](platforms.md): macOS, Linux, and Windows environment/path notes.
+98
View File
@@ -0,0 +1,98 @@
# Experimental Rust embedding API
Cassady v0.2.6 includes an experimental Rust API for running headless agent sessions from another Rust program. The API is intended for early integrations and may change before Cassady declares a stable library contract.
The embedding API uses the same provider configuration, global instructions, prompts, access modes, tools, and JSONL conversation storage as the `cass` terminal UI. By default it reads and writes under `~/.cass`, so run `cass setup` first or create compatible `config.json`, `providers.json`, and `models.json` files programmatically.
## Minimal example
```rust
use cassady::prelude::*;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let session = SessionBuilder::new()
.cwd(std::env::current_dir()?)
.access_mode(AccessMode::ReadOnly)
.build()
.await?;
let mut turn = session
.start_turn("Summarize this project in a few sentences.")
.await?;
while let Some(event) = turn.next_event().await? {
match event {
Event::AssistantChunk(text) => print!("{text}"),
Event::Finished => break,
_ => {}
}
}
let session = turn.finish().await?;
eprintln!("\nResume chat with: cass --resume {}", session.id());
Ok(())
}
```
Add Cassady from a git checkout or path dependency, and ensure your application runs on Tokio.
## Creating or resuming sessions
Use `SessionBuilder` to set host-controlled options:
```rust
let session = SessionBuilder::new()
.config_root("/tmp/my-cass-root")
.cwd("/path/to/workspace")
.access_mode(AccessMode::WorkspaceEdit)
.model("my-model")
.base_url("https://provider.example/v1")
.api_key_env("MY_PROVIDER_KEY")
.build()
.await?;
let resumed = SessionBuilder::new()
.cwd("/path/to/workspace")
.resume(session.id())
.await?;
```
`build()` is equivalent to `new_session()`. Resumed and new sessions use Cassady's normal `conversations/*.jsonl` files, so CLI and embedded sessions can interoperate.
## Events and approvals
`Session::start_turn` consumes the session and returns a `Turn`. This type design prevents overlapping turns for the same session. Call `turn.finish().await?` after receiving `Event::Finished` to recover the updated `Session`.
Important events include:
- `AssistantChunk` and `ReasoningChunk`
- `ToolCallStarted`, `ToolOutputChunk`, and `ToolResult`
- `ApprovalRequested` and `ApprovalResolved`
- `Status`
- `Finished`
When a tool needs approval, decide in host code:
```rust
while let Some(event) = turn.next_event().await? {
match event {
Event::ApprovalRequested(request) => {
eprintln!("approval needed for {}: {}", request.name, request.reason);
turn.deny(&request.request_id)?;
}
Event::Finished => break,
_ => {}
}
}
```
The approval policy is the same as the TUI: shell is unavailable in `read-only`, requires approval in `workspace-edit`, and runs directly in `full-access` unless destructive-operation confirmation is enabled.
## Cancellation
Dropping a `Turn` aborts the underlying task. Prefer `turn.cancel().await?` when you want Cassady to repair the conversation with cancellation records before returning the session.
## Current limitations
The v0.2.6 API is intentionally small and experimental. It does not include custom provider traits, custom tools, plugin loading, multi-agent orchestration, background daemons, task queues, or a synchronous/blocking wrapper.
+33
View File
@@ -0,0 +1,33 @@
use cassady::prelude::*;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let session = SessionBuilder::new()
.cwd(std::env::current_dir()?)
.access_mode(AccessMode::ReadOnly)
.build()
.await?;
let mut turn = session
.start_turn("Summarize this project in a few sentences.")
.await?;
while let Some(event) = turn.next_event().await? {
match event {
Event::AssistantChunk(text) => print!("{text}"),
Event::ApprovalRequested(request) => {
eprintln!(
"approval requested for {}: {}; denying in this example",
request.name, request.reason
);
turn.deny(&request.request_id)?;
}
Event::Finished => break,
_ => {}
}
}
let session = turn.finish().await?;
eprintln!("\nResume chat with: cass --resume {}", session.id());
Ok(())
}
+298
View File
@@ -0,0 +1,298 @@
# v0.2.6 Rust Embedding API Implementation Plan
## Goal
v0.2.6 adds the first intentional public Rust API for embedding Cassady in another Rust project. A developer should be able to add Cassady as a dependency, configure a workspace/model/access mode, start a headless agent session, send user messages, receive streamed agent events, and handle approval requests without launching the interactive TUI.
Success statement:
> A small Rust program can import `cassady`, start a new headless session in a workspace, stream assistant/tool events from a turn, optionally approve shell requests, and inspect the updated conversation state using documented experimental APIs.
## Scope
### In scope
- Add an experimental embedding API module with cohesive public types instead of requiring callers to wire together internal modules directly.
- Support starting a new headless agent session from Rust code.
- Support resuming an existing conversation by id when using Cassady's existing conversation storage.
- Support running one turn at a time and streaming typed events to the host application.
- Expose approval handling for tools that require host/user consent, especially shell in `workspace-edit` mode.
- Reuse the existing config, provider, prompt, security, conversation, and tool execution paths used by the CLI/TUI.
- Provide simple builder/options types for cwd, access mode, model/base URL/API key overrides, reasoning effort, and Cassady config root.
- Add a crate-level `prelude` or clearly documented imports for common embedding use.
- Add docs and examples that show a minimal headless integration.
- Add integration tests that exercise the public API without a terminal.
### Out of scope
- Declaring the Rust API stable for semver compatibility. The API should be explicitly marked experimental in v0.2.6.
- Replacing the CLI/TUI as the primary user interface.
- Multi-agent orchestration, task queues, background daemons, schedulers, or distributed workers.
- Custom model provider traits or non-OpenAI-compatible protocols.
- User-defined custom tools or plugin loading.
- A synchronous/blocking API. The first embedding surface can require Tokio.
- Exposing low-level terminal UI internals as supported public API.
- Publishing to crates.io as part of this release unless separately requested.
## Context and Current State
Cassady already builds a library crate:
- `Cargo.toml` defines `[lib] name = "cassady" path = "src/lib.rs"`.
- `src/lib.rs` currently re-exports many internal modules directly and exposes `run()` for the CLI/TUI path.
- `src/agent.rs` contains the core async turn loop:
- `AgentSettings`
- `AgentEvent`
- `AgentCommand`
- `run_turn(...)`
- `run_turn_with_commands(...)`
- `src/app.rs` owns interactive startup, TUI state, chat creation/resume, cancellation, approval UI, and local slash commands.
- `src/conversation.rs` persists conversations as JSONL and can create/load/list chats.
- `src/config.rs` loads providers, models, active defaults, API key references, access mode, tool limits, and docs paths.
- `src/security.rs` centralizes access-mode decisions.
- `src/tools/*` implements the same tools that headless sessions should use.
The current crate can technically be imported, but the supported path is unclear: callers must know which internal modules to combine, how to create base prompts, how to load config safely, how to route approval commands, and how to consume events. v0.2.6 should add a thin, intentional API layer over these internals.
## Design Principles
1. **Thin wrapper over proven internals.** Reuse the same agent loop and policy code as the CLI so embedded behavior matches interactive behavior.
2. **Explicitly experimental.** Make the new API useful without promising final naming or long-term stability yet.
3. **Headless first.** The API should not depend on `ratatui`, terminal setup, crossterm event loops, or slash-command UI state.
4. **Host owns presentation.** Embedded callers receive typed events and decide how to display assistant chunks, tool calls, approvals, and errors.
5. **Safe defaults.** Default to `read-only`, environment-variable API keys, existing Cassady config files, and workspace-rooted paths.
6. **Approval is part of the API.** Hosts must be able to approve or deny requests rather than having Cassady assume a TUI is present.
7. **Keep the first surface small.** Prefer one clear session builder and one turn-running method over exposing every internal knob.
## Design
### Module layout
Add a new module, for example:
```rust
pub mod embedding;
pub mod prelude;
```
`src/embedding.rs` should be the supported experimental API. Existing internal modules can remain public in v0.2.6 for compatibility, but docs should steer new users toward `cassady::embedding` or `cassady::prelude`.
Suggested public surface:
```rust
pub struct SessionBuilder { ... }
pub struct Session { ... }
pub struct SessionOptions { ... }
pub struct Turn { ... }
pub enum Event { ... }
pub enum Command { ... }
pub struct ConversationInfo { ... }
```
The exact names can change during implementation, but they should avoid leaking TUI-specific terms.
### Builder and options
Provide a builder that covers common embedding setup:
```rust
let mut session = cassady::embedding::SessionBuilder::new()
.cwd("/path/to/project")
.access_mode(AccessMode::WorkspaceEdit)
.model("accounts/fireworks/models/qwen3p7-plus")
.build()
.await?;
```
Builder responsibilities:
- Resolve and canonicalize `cwd` like CLI startup.
- Load config from the default Cassady root unless an explicit root/path is supplied.
- Apply model/base URL/API key env overrides without requiring a `Cli` value from callers.
- Resolve API key availability before starting a turn and return a useful error.
- Install or locate bundled docs as needed by `Config::load` behavior.
- Create the base system prompt with `~/.cass/global.md` when starting a new conversation.
- Default access mode to config/default, then builder override, then `read-only` if no config exists.
Avoid requiring callers to import or construct `cli::Cli`.
### New and resumed sessions
Support at least:
```rust
let session = SessionBuilder::new().cwd(".").new_session().await?;
let session = SessionBuilder::new().cwd(".").resume("chat-id").await?;
```
A `Session` should expose lightweight metadata:
```rust
session.id();
session.cwd();
session.model();
session.access_mode();
session.conversation_path();
```
The conversation should continue to be persisted in the same JSONL format so CLI and library sessions can interoperate.
### Running a turn
Provide a headless one-turn API that streams events:
```rust
let mut turn = session.start_turn("Explain the crate layout").await?;
while let Some(event) = turn.next_event().await? {
match event {
Event::AssistantChunk(text) => print!("{text}"),
Event::ApprovalRequested(request) => {
turn.approve(request.id).await?;
}
Event::Finished => break,
_ => {}
}
}
let session = turn.finish().await?;
```
Alternative designs are acceptable, such as returning `(EventStream, CommandSink)` plus a completion handle, as long as examples are simple and approval commands are supported.
The wrapper can map `agent::AgentEvent` and `agent::AgentCommand` into public embedding types. It should avoid exposing internal channel mechanics unless that is the cleanest Tokio-native API.
### Event model
Expose typed events that are stable enough for hosts to build UI/logging around:
- assistant text chunks
- reasoning chunks, when provider/model returns them
- tool call started
- tool output chunk
- tool result
- approval requested
- approval resolved
- status
- turn finished
- error or turn failure
The public event type can wrap or re-export `agent::AgentEvent` initially, but the plan should prefer a dedicated type if it prevents low-level internals from becoming accidental API.
### Approval behavior
Approval requests should include:
- request id
- tool call id
- tool name
- arguments
- human-readable reason
The host should be able to approve or deny by request id. If the host drops the turn or never responds, cancellation/drop behavior should be documented.
For v0.2.6, keep approval policy aligned with `security.rs`:
- `read-only`: shell unavailable.
- `workspace-edit`: shell asks.
- `full-access`: shell allowed.
### Cancellation and drop behavior
The TUI already cancels by aborting the agent task and repairing pending records. The embedding API should define a basic behavior:
- Dropping an active turn should abort the underlying task if possible.
- A simple explicit `cancel()` method is preferred if practical.
- Conversation repair for cancelled turns can be minimal in v0.2.6, but pending tool calls must not corrupt resumed conversations.
If full parity with the TUI cancellation path is too large, document the limitation and add tests for the supported behavior.
### Error handling
Use a public result alias such as:
```rust
pub type Result<T> = std::result::Result<T, Error>;
```
The first pass may wrap `anyhow::Error`, but public errors should include enough context for embedding callers to distinguish:
- config load errors
- missing API key
- provider request errors
- conversation load/create errors
- active turn already running
- approval request not found or already resolved
Do not panic for ordinary configuration or runtime failures.
### Examples
Add at least one compilable example under `examples/`, for example `examples/headless_agent.rs`:
```rust
use cassady::prelude::*;
#[tokio::main]
async fn main() -> cassady::embedding::Result<()> {
let mut session = SessionBuilder::new()
.cwd(std::env::current_dir()?)
.access_mode(AccessMode::ReadOnly)
.build()
.await?;
let mut turn = session.start_turn("Summarize this project.").await?;
while let Some(event) = turn.next_event().await? {
if let Event::AssistantChunk(text) = event {
print!("{text}");
}
}
turn.finish().await?;
Ok(())
}
```
The example should be honest about requiring configured providers and API keys.
## Implementation Steps
1. **Define the experimental API shape.** Add `src/embedding.rs` with builder, session, turn, event, command/approval, and result/error types.
2. **Add non-CLI config loading helpers.** Refactor or add helpers in `src/config.rs` so library callers can apply overrides without constructing `cli::Cli`.
3. **Extract chat creation/resume helpers.** Move reusable prompt/global/conversation setup out of `src/app.rs` into functions usable by both TUI and embedding API.
4. **Wrap the existing agent loop.** Use `agent::run_turn_with_commands` internally and provide a host-friendly event stream plus approval methods.
5. **Handle turn lifecycle.** Ensure a session cannot run overlapping turns unless explicitly supported; persist and return the updated conversation after a turn finishes.
6. **Add cancellation/drop handling.** Provide at least a documented `cancel()` path and avoid leaving pending tool-call records in a corrupted state.
7. **Add examples and docs.** Create a headless example and a bundled docs page for the experimental Rust API.
8. **Update README and crate exports.** Add `embedding`/`prelude` exports and a short README section pointing to the new docs.
9. **Test the public surface.** Add integration tests with a mock OpenAI-compatible server and temporary config/conversation roots.
## Tests
- Unit tests for builder option precedence: default config, explicit cwd, access mode, model, base URL, API key env, and config root.
- Integration test that starts a new session and runs a turn against `wiremock`, asserting assistant chunks and persisted conversation records.
- Integration test that resumes an existing conversation through the embedding API.
- Integration test for approval flow in `workspace-edit` mode using a mock tool call that requests shell approval.
- Test that read-only sessions do not expose write/edit/shell tools through the embedded turn.
- Test that starting a second turn while one is active returns an error or is impossible by type design.
- Example compilation through `cargo test --examples` or equivalent.
## Documentation
- Add `docs/rust-api.md` or `docs/embedding.md` describing the experimental API, setup requirements, minimal example, event loop, approval handling, and limitations.
- Link the new page from `docs/README.md` and the README.
- Document that the API is experimental in v0.2.6 and may change before a stable 1.0-style library contract.
- Include a note that embedded sessions use the same `~/.cass` config and conversation storage by default.
- Mention how hosts should run `cass setup` or provide config programmatically before using the API.
## Acceptance Criteria
- A Rust binary in `examples/` can import `cassady`, create a headless session, run a turn, and stream assistant output without launching the TUI.
- Embedded sessions use the same provider, prompt, security, tool, and conversation paths as the CLI.
- Approval requests can be approved or denied programmatically.
- New public API docs and README links clearly label the surface experimental.
- CLI/TUI behavior remains unchanged.
- `cargo fmt` and `cargo test --locked --all-targets` pass.
+54 -20
View File
@@ -151,6 +151,34 @@ pub struct Config {
pub docs_dir: PathBuf,
}
#[derive(Debug, Clone, Default)]
pub struct ConfigOverrides {
pub model: Option<String>,
pub base_url: Option<String>,
pub api_key_env: Option<String>,
pub access_mode: Option<AccessMode>,
}
impl ConfigOverrides {
pub fn from_cli(cli: &Cli) -> Self {
let access_mode = if cli.readonly {
Some(AccessMode::ReadOnly)
} else if cli.workspace_edit {
Some(AccessMode::WorkspaceEdit)
} else if cli.full_access {
Some(AccessMode::FullAccess)
} else {
None
};
Self {
model: cli.model.clone(),
base_url: cli.base_url.clone(),
api_key_env: cli.api_key_env.clone(),
access_mode,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ApiKeyReference {
Env(String),
@@ -287,17 +315,29 @@ pub fn models_path(root: &Path) -> PathBuf {
impl Config {
pub fn load(cli: &Cli) -> Result<Self> {
Self::load_from_root(cass_root(), cli)
Self::load_with_overrides(cass_root(), ConfigOverrides::from_cli(cli))
}
pub fn load_from_root(root: PathBuf, cli: &Cli) -> Result<Self> {
pub fn load_with_overrides(root: PathBuf, overrides: ConfigOverrides) -> Result<Self> {
fs::create_dir_all(root.join("conversations"))
.with_context(|| format!("creating {}", root.join("conversations").display()))?;
let docs_dir = crate::docs::install(&root)?;
Self::load_from_root_with_docs(root, docs_dir, cli)
Self::load_from_root_with_docs_and_overrides(root, docs_dir, overrides)
}
pub fn load_from_root(root: PathBuf, cli: &Cli) -> Result<Self> {
Self::load_with_overrides(root, ConfigOverrides::from_cli(cli))
}
pub fn load_from_root_with_docs(root: PathBuf, docs_dir: PathBuf, cli: &Cli) -> Result<Self> {
Self::load_from_root_with_docs_and_overrides(root, docs_dir, ConfigOverrides::from_cli(cli))
}
pub fn load_from_root_with_docs_and_overrides(
root: PathBuf,
docs_dir: PathBuf,
overrides: ConfigOverrides,
) -> Result<Self> {
fs::create_dir_all(&root).with_context(|| format!("creating {}", root.display()))?;
let providers = load_or_create_default_provider_registry(&root)?;
let models = load_or_create_default_model_registry(&root)?;
@@ -330,19 +370,13 @@ impl Config {
}
}
if cli.readonly {
cfg.default_access_mode = AccessMode::ReadOnly;
}
if cli.workspace_edit {
cfg.default_access_mode = AccessMode::WorkspaceEdit;
}
if cli.full_access {
cfg.default_access_mode = AccessMode::FullAccess;
if let Some(access_mode) = overrides.access_mode {
cfg.default_access_mode = access_mode;
}
let requested_model = requested_model(file.as_ref(), cli);
let requested_model = requested_model(file.as_ref(), &overrides);
let provider_id_from_config = requested_provider_id(file.as_ref(), &providers);
let legacy = legacy_provider_override(file.as_ref(), cli);
let legacy = legacy_provider_override(file.as_ref(), &overrides);
let mut provider = resolve_provider(
requested_model.as_deref().unwrap_or(DEFAULT_MODEL),
@@ -353,10 +387,10 @@ impl Config {
&models,
)?;
if let Some(base_url) = &cli.base_url {
if let Some(base_url) = &overrides.base_url {
provider.base_url = base_url.clone();
}
if let Some(api_key_env) = &cli.api_key_env {
if let Some(api_key_env) = &overrides.api_key_env {
provider.api_key = format!("${api_key_env}");
}
@@ -706,8 +740,8 @@ pub fn find_model_for_provider<'a>(
.find(|m| m.provider == provider_id && m.id == model_id)
}
fn requested_model(file: Option<&ConfigFile>, cli: &Cli) -> Option<String> {
cli.model.clone().or_else(|| {
fn requested_model(file: Option<&ConfigFile>, overrides: &ConfigOverrides) -> Option<String> {
overrides.model.clone().or_else(|| {
file.and_then(|f| {
f.default_model
.clone()
@@ -738,13 +772,13 @@ struct LegacyProviderOverride {
fn legacy_provider_override(
file: Option<&ConfigFile>,
cli: &Cli,
overrides: &ConfigOverrides,
) -> Option<LegacyProviderOverride> {
let base_url = cli
let base_url = overrides
.base_url
.clone()
.or_else(|| file.and_then(|f| f.base_url.clone()));
let api_key = cli
let api_key = overrides
.api_key_env
.as_ref()
.map(|env| format!("${env}"))
+563
View File
@@ -0,0 +1,563 @@
//! Experimental Rust embedding API for running Cassady without the TUI.
//!
//! This module provides the first Rust-native surface for embedding Cassady in
//! another application. It reuses Cassady's existing runtime behavior while
//! giving the host application control over event presentation, turn lifecycle,
//! and approval decisions.
use crate::access::AccessMode;
use crate::agent::{self, AgentCommand, AgentEvent, AgentSettings};
use crate::config::{Config, ConfigOverrides, ReasoningEffort};
use crate::conversation::{self, Conversation, Record};
use crate::prompt;
use serde_json::Value;
use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
use thiserror::Error;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
const TURN_CANCELLED_MESSAGE: &str = "Turn cancelled by host.";
const TOOL_CANCELLED_MESSAGE: &str = "Tool execution cancelled by host.";
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, Error)]
pub enum Error {
#[error("configuration error: {0}")]
Config(#[source] anyhow::Error),
#[error("conversation error: {0}")]
Conversation(#[source] anyhow::Error),
#[error("agent error: {0}")]
Agent(#[source] anyhow::Error),
#[error("agent task failed: {0}")]
Join(#[source] tokio::task::JoinError),
#[error("turn is already closed")]
TurnClosed,
#[error("approval request `{0}` is not pending")]
ApprovalNotPending(String),
#[error("turn session state is unavailable")]
MissingSession,
}
impl Error {
fn config(err: anyhow::Error) -> Self {
Self::Config(err)
}
fn conversation(err: anyhow::Error) -> Self {
Self::Conversation(err)
}
fn agent(err: anyhow::Error) -> Self {
Self::Agent(err)
}
}
#[derive(Debug, Clone, Default)]
pub struct SessionBuilder {
config_root: Option<PathBuf>,
cwd: Option<PathBuf>,
access_mode: Option<AccessMode>,
model: Option<String>,
base_url: Option<String>,
api_key_env: Option<String>,
reasoning_effort: Option<ReasoningEffort>,
}
impl SessionBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn config_root(mut self, root: impl Into<PathBuf>) -> Self {
self.config_root = Some(root.into());
self
}
pub fn cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
self.cwd = Some(cwd.into());
self
}
pub fn access_mode(mut self, mode: AccessMode) -> Self {
self.access_mode = Some(mode);
self
}
pub fn model(mut self, model: impl Into<String>) -> Self {
self.model = Some(model.into());
self
}
pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = Some(base_url.into());
self
}
pub fn api_key_env(mut self, api_key_env: impl Into<String>) -> Self {
self.api_key_env = Some(api_key_env.into());
self
}
pub fn reasoning_effort(mut self, effort: ReasoningEffort) -> Self {
self.reasoning_effort = Some(effort);
self
}
pub async fn build(self) -> Result<Session> {
self.new_session().await
}
pub async fn new_session(self) -> Result<Session> {
let PreparedSession {
config,
cwd,
mode,
reasoning_effort,
} = self.prepare().await?;
let conversation = create_new_conversation(&config, &cwd)?;
Ok(Session {
config,
cwd,
mode,
reasoning_effort,
conversation,
resume_warning: None,
})
}
pub async fn resume(self, chat_id: impl AsRef<str>) -> Result<Session> {
let PreparedSession {
config,
cwd,
mode,
reasoning_effort,
} = self.prepare().await?;
let (conversation, warning) =
Conversation::load(&config.conversations_dir(), chat_id.as_ref())
.map_err(Error::conversation)?;
Ok(Session {
config,
cwd,
mode,
reasoning_effort,
conversation,
resume_warning: warning,
})
}
async fn prepare(self) -> Result<PreparedSession> {
let root = self.config_root.unwrap_or_else(crate::config::cass_root);
let overrides = ConfigOverrides {
model: self.model,
base_url: self.base_url,
api_key_env: self.api_key_env,
access_mode: self.access_mode,
};
let config = Config::load_with_overrides(root, overrides).map_err(Error::config)?;
config.resolved_api_key().map_err(Error::config)?;
let cwd = resolve_cwd(self.cwd).map_err(Error::config)?;
let mode = config.default_access_mode;
let reasoning_effort = self
.reasoning_effort
.unwrap_or(config.reasoning_effort)
.clamp_for_model(config.model_metadata.as_ref());
Ok(PreparedSession {
config,
cwd,
mode,
reasoning_effort,
})
}
}
struct PreparedSession {
config: Config,
cwd: PathBuf,
mode: AccessMode,
reasoning_effort: ReasoningEffort,
}
#[derive(Debug)]
pub struct Session {
config: Config,
cwd: PathBuf,
mode: AccessMode,
reasoning_effort: ReasoningEffort,
conversation: Conversation,
resume_warning: Option<String>,
}
impl Session {
pub fn id(&self) -> &str {
&self.conversation.id
}
pub fn cwd(&self) -> &Path {
&self.cwd
}
pub fn model(&self) -> &str {
&self.config.model
}
pub fn access_mode(&self) -> AccessMode {
self.mode
}
pub fn reasoning_effort(&self) -> ReasoningEffort {
self.reasoning_effort
}
pub fn conversation_path(&self) -> &Path {
&self.conversation.path
}
pub fn records(&self) -> &[Record] {
&self.conversation.records
}
pub fn resume_warning(&self) -> Option<&str> {
self.resume_warning.as_deref()
}
pub fn info(&self) -> ConversationInfo {
ConversationInfo {
id: self.conversation.id.clone(),
cwd: self.cwd.clone(),
model: self.config.model.clone(),
access_mode: self.mode,
reasoning_effort: self.reasoning_effort,
path: self.conversation.path.clone(),
record_count: self.conversation.records.len(),
}
}
pub async fn start_turn(self, user_message: impl Into<String>) -> Result<Turn> {
let message = user_message.into();
let turn_start_len = self.conversation.records.len();
let (event_tx, event_rx) = mpsc::unbounded_channel::<AgentEvent>();
let (command_tx, command_rx) = mpsc::unbounded_channel::<AgentCommand>();
let settings = AgentSettings {
config: self.config.clone(),
cwd: self.cwd.clone(),
mode: self.mode,
reasoning_effort: self.reasoning_effort,
};
let conversation = self.conversation.clone();
let task_message = message.clone();
let handle = tokio::spawn(agent::run_turn_with_commands(
conversation,
task_message,
settings,
event_tx,
command_rx,
));
Ok(Turn {
session: Some(self),
handle: Some(handle),
event_rx,
command_tx: Some(command_tx),
pending_approvals: BTreeSet::new(),
turn_start_len,
user_message: message,
})
}
}
#[derive(Debug, Clone)]
pub struct ConversationInfo {
pub id: String,
pub cwd: PathBuf,
pub model: String,
pub access_mode: AccessMode,
pub reasoning_effort: ReasoningEffort,
pub path: PathBuf,
pub record_count: usize,
}
#[derive(Debug)]
pub struct Turn {
session: Option<Session>,
handle: Option<JoinHandle<anyhow::Result<Conversation>>>,
event_rx: mpsc::UnboundedReceiver<AgentEvent>,
command_tx: Option<mpsc::UnboundedSender<AgentCommand>>,
pending_approvals: BTreeSet<String>,
turn_start_len: usize,
user_message: String,
}
impl Turn {
pub async fn next_event(&mut self) -> Result<Option<Event>> {
match self.event_rx.recv().await {
Some(event) => {
let event = Event::from_agent(event);
match &event {
Event::ApprovalRequested(request) => {
self.pending_approvals.insert(request.request_id.clone());
}
Event::ApprovalResolved { request_id, .. } => {
self.pending_approvals.remove(request_id);
}
_ => {}
}
Ok(Some(event))
}
None => Ok(None),
}
}
pub fn approve(&mut self, request_id: impl AsRef<str>) -> Result<()> {
self.resolve_approval(request_id.as_ref(), true)
}
pub fn deny(&mut self, request_id: impl AsRef<str>) -> Result<()> {
self.resolve_approval(request_id.as_ref(), false)
}
pub async fn finish(mut self) -> Result<Session> {
let handle = self.handle.take().ok_or(Error::TurnClosed)?;
let conversation = match handle.await.map_err(Error::Join)? {
Ok(conversation) => conversation,
Err(err) => return Err(Error::agent(err)),
};
let mut session = self.session.take().ok_or(Error::MissingSession)?;
session.conversation = conversation;
self.command_tx = None;
Ok(session)
}
pub async fn cancel(mut self) -> Result<Session> {
if let Some(handle) = &self.handle {
handle.abort();
}
if let Some(handle) = self.handle.take() {
match handle.await {
Ok(Ok(conversation)) => {
let mut session = self.session.take().ok_or(Error::MissingSession)?;
session.conversation = conversation;
self.command_tx = None;
return Ok(session);
}
Ok(Err(err)) => return Err(Error::agent(err)),
Err(err) if err.is_cancelled() => {}
Err(err) => return Err(Error::Join(err)),
}
}
let mut session = self.session.take().ok_or(Error::MissingSession)?;
session.conversation = finalize_cancelled_turn(
&session.config,
&session.conversation.id,
self.turn_start_len,
&self.user_message,
)?;
self.command_tx = None;
Ok(session)
}
fn resolve_approval(&mut self, request_id: &str, approved: bool) -> Result<()> {
if !self.pending_approvals.remove(request_id) {
return Err(Error::ApprovalNotPending(request_id.to_string()));
}
let tx = self.command_tx.as_ref().ok_or(Error::TurnClosed)?;
tx.send(AgentCommand::ApprovalDecision {
request_id: request_id.to_string(),
approved,
})
.map_err(|_| Error::TurnClosed)
}
}
impl Drop for Turn {
fn drop(&mut self) {
if let Some(handle) = &self.handle {
handle.abort();
}
}
}
#[derive(Debug, Clone)]
pub enum Event {
AssistantChunk(String),
ReasoningChunk(String),
ToolCallStarted {
id: String,
name: String,
arguments: Value,
},
ToolOutputChunk {
id: String,
name: String,
stream: String,
content: String,
},
ToolResult {
id: String,
name: String,
ok: bool,
content: String,
},
ApprovalRequested(ApprovalRequest),
ApprovalResolved {
request_id: String,
approved: bool,
},
Status(String),
Finished,
}
impl Event {
fn from_agent(event: AgentEvent) -> Self {
match event {
AgentEvent::AssistantChunk(text) => Self::AssistantChunk(text),
AgentEvent::ReasoningChunk(text) => Self::ReasoningChunk(text),
AgentEvent::ToolCallStarted {
id,
name,
arguments,
} => Self::ToolCallStarted {
id,
name,
arguments,
},
AgentEvent::ToolOutputChunk {
id,
name,
stream,
content,
} => Self::ToolOutputChunk {
id,
name,
stream,
content,
},
AgentEvent::ToolResult {
id,
name,
ok,
content,
} => Self::ToolResult {
id,
name,
ok,
content,
},
AgentEvent::ApprovalRequested {
request_id,
tool_call_id,
name,
arguments,
reason,
} => Self::ApprovalRequested(ApprovalRequest {
request_id,
tool_call_id,
name,
arguments,
reason,
}),
AgentEvent::ApprovalResolved {
request_id,
approved,
} => Self::ApprovalResolved {
request_id,
approved,
},
AgentEvent::Status(status) => Self::Status(status),
AgentEvent::TurnFinished => Self::Finished,
}
}
}
#[derive(Debug, Clone)]
pub struct ApprovalRequest {
pub request_id: String,
pub tool_call_id: String,
pub name: String,
pub arguments: Value,
pub reason: String,
}
fn resolve_cwd(cwd: Option<PathBuf>) -> anyhow::Result<PathBuf> {
let cwd = cwd.unwrap_or(std::env::current_dir()?);
cwd.canonicalize()
.map_err(anyhow::Error::from)
.map_err(|err| anyhow::anyhow!("resolving cwd {}: {err}", cwd.display()))
}
fn create_new_conversation(config: &Config, cwd: &Path) -> Result<Conversation> {
let global = fs::read_to_string(config.global_path()).ok();
let base = prompt::build_base_system_prompt(global.as_deref());
Conversation::create(&config.conversations_dir(), &config.model, cwd, base)
.map_err(Error::conversation)
}
fn finalize_cancelled_turn(
config: &Config,
chat_id: &str,
turn_start_len: usize,
turn_message: &str,
) -> Result<Conversation> {
let (mut conversation, _) =
Conversation::load(&config.conversations_dir(), chat_id).map_err(Error::conversation)?;
if conversation.records.len() <= turn_start_len {
conversation
.append(Record::User {
content: turn_message.to_string(),
ts: conversation::now_ts(),
})
.map_err(Error::conversation)?;
}
for (id, name) in pending_tool_calls(&conversation.records) {
conversation
.append(Record::Tool {
tool_call_id: id,
name,
ok: false,
content: TOOL_CANCELLED_MESSAGE.to_string(),
ts: conversation::now_ts(),
})
.map_err(Error::conversation)?;
}
if !matches!(
conversation.records.last(),
Some(Record::Assistant { content, tool_calls, .. })
if content == TURN_CANCELLED_MESSAGE && tool_calls.is_empty()
) {
conversation
.append(Record::Assistant {
content: TURN_CANCELLED_MESSAGE.to_string(),
reasoning: String::new(),
reasoning_field: None,
tool_calls: Vec::new(),
ts: conversation::now_ts(),
})
.map_err(Error::conversation)?;
}
Ok(conversation)
}
fn pending_tool_calls(records: &[Record]) -> Vec<(String, String)> {
let mut pending = Vec::new();
for record in records {
match record {
Record::Assistant { tool_calls, .. } => {
pending = tool_calls
.iter()
.map(|call| (call.id.clone(), call.name.clone()))
.collect();
}
Record::Tool { tool_call_id, .. } => {
pending.retain(|(id, _)| id != tool_call_id);
}
Record::User { .. } => pending.clear(),
_ => {}
}
}
pending
}
+2
View File
@@ -6,8 +6,10 @@ pub mod cli;
pub mod config;
pub mod conversation;
pub mod docs;
pub mod embedding;
pub mod error;
pub mod menu;
pub mod prelude;
pub mod prompt;
pub mod providers;
pub mod security;
+7
View File
@@ -0,0 +1,7 @@
//! Common imports for Cassady's experimental Rust embedding API.
pub use crate::access::AccessMode;
pub use crate::config::ReasoningEffort;
pub use crate::embedding::{
ApprovalRequest, ConversationInfo, Event, Session, SessionBuilder, Turn,
};
+1
View File
@@ -59,6 +59,7 @@ fn expected_bundled_docs_exist() {
"configuration.md",
"providers.md",
"access-modes.md",
"embedding.md",
"workflows.md",
"troubleshooting.md",
"platforms.md",
+315
View File
@@ -0,0 +1,315 @@
use cassady::access::AccessMode;
use cassady::config::ReasoningEffort;
use cassady::conversation::Record;
use cassady::embedding::{Event, SessionBuilder};
use serde_json::json;
use tempfile::tempdir;
use wiremock::matchers::{body_string_contains, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn sse(body: &str) -> ResponseTemplate {
ResponseTemplate::new(200).set_body_raw(body.as_bytes().to_vec(), "text/event-stream")
}
fn content_sse(content: &str) -> ResponseTemplate {
sse(&format!(
"data: {{\"choices\":[{{\"index\":0,\"delta\":{{\"content\":{}}}}}]}}\r\n\r\ndata: [DONE]\r\n\r\n",
serde_json::to_string(content).unwrap()
))
}
fn tool_call_sse(id: &str, name: &str, arguments: &str) -> ResponseTemplate {
sse(&format!(
"data: {{\"choices\":[{{\"index\":0,\"delta\":{{\"tool_calls\":[{{\"index\":0,\"id\":\"{id}\",\"type\":\"function\",\"function\":{{\"name\":\"{name}\",\"arguments\":{}}}}}]}}}}]}}\r\n\r\ndata: [DONE]\r\n\r\n",
serde_json::to_string(arguments).unwrap()
))
}
fn write_test_config(root: &std::path::Path, base_url: &str) {
std::fs::write(
root.join("providers.json"),
serde_json::to_string_pretty(&json!({
"providers": [{
"id": "test-provider",
"kind": "openai-compatible",
"base_url": base_url,
"api_key": "test-key",
"default_model": "test-model",
"models": ["test-model"]
}]
}))
.unwrap(),
)
.unwrap();
std::fs::write(
root.join("models.json"),
serde_json::to_string_pretty(&json!({
"models": [{
"id": "test-model",
"provider": "test-provider",
"context_length": 128,
"max_output_tokens": 64,
"reasoning": {
"supported": true,
"required": false,
"default_effort": "off",
"request_format": "reasoning_effort"
}
}]
}))
.unwrap(),
)
.unwrap();
std::fs::write(
root.join("config.json"),
serde_json::to_string_pretty(&json!({
"default_provider": "test-provider",
"default_model": "test-model",
"default_reasoning_effort": "off"
}))
.unwrap(),
)
.unwrap();
}
#[tokio::test]
async fn embedded_session_runs_turn_and_streams_events() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/chat/completions"))
.respond_with(content_sse("Hello from embedded Cassady."))
.expect(1)
.mount(&server)
.await;
let root = tempdir().unwrap();
let cwd = tempdir().unwrap();
write_test_config(root.path(), &server.uri());
let session = SessionBuilder::new()
.config_root(root.path())
.cwd(cwd.path())
.access_mode(AccessMode::ReadOnly)
.reasoning_effort(ReasoningEffort::Off)
.build()
.await
.unwrap();
assert_eq!(session.model(), "test-model");
assert_eq!(session.access_mode(), AccessMode::ReadOnly);
let mut turn = session.start_turn("say hi").await.unwrap();
let mut streamed = String::new();
while let Some(event) = turn.next_event().await.unwrap() {
match event {
Event::AssistantChunk(chunk) => streamed.push_str(&chunk),
Event::Finished => break,
_ => {}
}
}
let session = turn.finish().await.unwrap();
assert_eq!(streamed, "Hello from embedded Cassady.");
assert!(session.records().iter().any(|record| matches!(
record,
Record::Assistant { content, .. } if content == "Hello from embedded Cassady."
)));
assert!(session.conversation_path().is_file());
}
#[tokio::test]
async fn builder_overrides_config_for_model_endpoint_key_mode_and_reasoning() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/chat/completions"))
.and(body_string_contains("\"model\":\"test-model\""))
.and(body_string_contains("\"reasoning_effort\":\"low\""))
.respond_with(content_sse("Overrides worked."))
.expect(1)
.mount(&server)
.await;
let root = tempdir().unwrap();
let cwd = tempdir().unwrap();
write_test_config(root.path(), "https://wrong.example/v1");
let env_name = "CASSADY_EMBEDDING_TEST_KEY";
let old = std::env::var(env_name).ok();
std::env::set_var(env_name, "test-key-from-env");
let session = SessionBuilder::new()
.config_root(root.path())
.cwd(cwd.path())
.access_mode(AccessMode::WorkspaceEdit)
.model("test-model")
.base_url(server.uri())
.api_key_env(env_name)
.reasoning_effort(ReasoningEffort::Low)
.build()
.await
.unwrap();
assert_eq!(session.access_mode(), AccessMode::WorkspaceEdit);
assert_eq!(session.reasoning_effort(), ReasoningEffort::Low);
let mut turn = session.start_turn("check overrides").await.unwrap();
while let Some(event) = turn.next_event().await.unwrap() {
if matches!(event, Event::Finished) {
break;
}
}
let _session = turn.finish().await.unwrap();
if let Some(old) = old {
std::env::set_var(env_name, old);
} else {
std::env::remove_var(env_name);
}
}
#[tokio::test]
async fn embedded_session_can_resume_existing_conversation() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/chat/completions"))
.respond_with(content_sse("First turn."))
.expect(1)
.mount(&server)
.await;
let root = tempdir().unwrap();
let cwd = tempdir().unwrap();
write_test_config(root.path(), &server.uri());
let session = SessionBuilder::new()
.config_root(root.path())
.cwd(cwd.path())
.access_mode(AccessMode::ReadOnly)
.build()
.await
.unwrap();
let mut turn = session.start_turn("first").await.unwrap();
while let Some(event) = turn.next_event().await.unwrap() {
if matches!(event, Event::Finished) {
break;
}
}
let session = turn.finish().await.unwrap();
let id = session.id().to_string();
let record_count = session.records().len();
let resumed = SessionBuilder::new()
.config_root(root.path())
.cwd(cwd.path())
.resume(&id)
.await
.unwrap();
assert_eq!(resumed.id(), id);
assert_eq!(resumed.records().len(), record_count);
assert!(resumed.resume_warning().is_none());
}
#[tokio::test]
async fn embedded_approval_flow_can_approve_shell() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/chat/completions"))
.and(body_string_contains("exit code: 0"))
.respond_with(content_sse("Approved shell."))
.with_priority(1)
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/chat/completions"))
.respond_with(tool_call_sse(
"call_shell",
"shell",
r#"{"command":"touch marker"}"#,
))
.with_priority(10)
.expect(1)
.mount(&server)
.await;
let root = tempdir().unwrap();
let cwd = tempdir().unwrap();
write_test_config(root.path(), &server.uri());
let marker = cwd.path().join("marker");
let session = SessionBuilder::new()
.config_root(root.path())
.cwd(cwd.path())
.access_mode(AccessMode::WorkspaceEdit)
.build()
.await
.unwrap();
let mut turn = session.start_turn("run shell").await.unwrap();
let mut saw_request = false;
let mut saw_resolved = false;
while let Some(event) = turn.next_event().await.unwrap() {
match event {
Event::ApprovalRequested(request) => {
saw_request = true;
assert_eq!(request.name, "shell");
assert!(!marker.exists());
turn.approve(&request.request_id).unwrap();
}
Event::ApprovalResolved { approved, .. } => {
saw_resolved = approved;
}
Event::Finished => break,
_ => {}
}
}
let session = turn.finish().await.unwrap();
assert!(saw_request);
assert!(saw_resolved);
assert!(marker.exists());
assert!(session.records().iter().any(|record| matches!(
record,
Record::Tool { name, ok, content, .. }
if name == "shell" && *ok && content.contains("exit code: 0")
)));
}
#[tokio::test]
async fn read_only_embedding_does_not_advertise_mutating_tools() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/chat/completions"))
.respond_with(content_sse("Readonly."))
.expect(1)
.mount(&server)
.await;
let root = tempdir().unwrap();
let cwd = tempdir().unwrap();
write_test_config(root.path(), &server.uri());
let session = SessionBuilder::new()
.config_root(root.path())
.cwd(cwd.path())
.access_mode(AccessMode::ReadOnly)
.build()
.await
.unwrap();
let mut turn = session.start_turn("inspect only").await.unwrap();
while let Some(event) = turn.next_event().await.unwrap() {
if matches!(event, Event::Finished) {
break;
}
}
let _session = turn.finish().await.unwrap();
let requests = server.received_requests().await.unwrap();
let body = String::from_utf8_lossy(&requests[0].body);
assert!(body.contains("\"name\":\"ls\""));
assert!(body.contains("\"name\":\"read\""));
assert!(body.contains("\"name\":\"grep\""));
assert!(!body.contains("\"name\":\"write\""));
assert!(!body.contains("\"name\":\"edit\""));
assert!(!body.contains("\"name\":\"shell\""));
}