Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a59c9fbbae | ||
|
|
2845529682 | ||
|
|
bcbb9dfa1b | ||
|
|
7c84a54e6a | ||
|
|
71f84c03ce |
Generated
+128
-2
@@ -2,6 +2,12 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "adler2"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -91,6 +97,15 @@ dependencies = [
|
|||||||
"num-traits",
|
"num-traits",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arbitrary"
|
||||||
|
version = "1.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||||
|
dependencies = [
|
||||||
|
"derive_arbitrary",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "assert-json-diff"
|
name = "assert-json-diff"
|
||||||
version = "2.0.2"
|
version = "2.0.2"
|
||||||
@@ -211,7 +226,7 @@ checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cassady"
|
name = "cassady"
|
||||||
version = "0.2.5"
|
version = "0.2.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -219,6 +234,7 @@ dependencies = [
|
|||||||
"clap",
|
"clap",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
"dirs",
|
"dirs",
|
||||||
|
"flate2",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"ignore",
|
"ignore",
|
||||||
"include_dir",
|
"include_dir",
|
||||||
@@ -227,13 +243,17 @@ dependencies = [
|
|||||||
"ratatui",
|
"ratatui",
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
|
"tar",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"tokio",
|
"tokio",
|
||||||
"unicode-width 0.1.14",
|
"unicode-width 0.1.14",
|
||||||
"wiremock",
|
"wiremock",
|
||||||
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -365,6 +385,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crc32fast"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-deque"
|
name = "crossbeam-deque"
|
||||||
version = "0.8.6"
|
version = "0.8.6"
|
||||||
@@ -501,6 +530,17 @@ version = "0.5.8"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "derive_arbitrary"
|
||||||
|
version = "1.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.118",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "derive_more"
|
name = "derive_more"
|
||||||
version = "2.1.1"
|
version = "2.1.1"
|
||||||
@@ -638,6 +678,16 @@ dependencies = [
|
|||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "filetime"
|
||||||
|
version = "0.2.29"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.9"
|
version = "0.1.9"
|
||||||
@@ -656,6 +706,16 @@ version = "0.4.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
|
checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flate2"
|
||||||
|
version = "1.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||||
|
dependencies = [
|
||||||
|
"crc32fast",
|
||||||
|
"miniz_oxide",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fnv"
|
name = "fnv"
|
||||||
version = "1.0.7"
|
version = "1.0.7"
|
||||||
@@ -1376,6 +1436,16 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "miniz_oxide"
|
||||||
|
version = "0.8.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||||
|
dependencies = [
|
||||||
|
"adler2",
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
@@ -2231,6 +2301,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simd-adler32"
|
||||||
|
version = "0.3.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "siphasher"
|
name = "siphasher"
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
@@ -2346,6 +2422,17 @@ dependencies = [
|
|||||||
"syn 2.0.118",
|
"syn 2.0.118",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tar"
|
||||||
|
version = "0.4.46"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||||
|
dependencies = [
|
||||||
|
"filetime",
|
||||||
|
"libc",
|
||||||
|
"xattr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tempfile"
|
name = "tempfile"
|
||||||
version = "3.27.0"
|
version = "3.27.0"
|
||||||
@@ -2976,7 +3063,7 @@ version = "0.1.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.48.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3301,6 +3388,16 @@ version = "0.6.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "xattr"
|
||||||
|
version = "1.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"rustix",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
@@ -3404,8 +3501,37 @@ dependencies = [
|
|||||||
"syn 2.0.118",
|
"syn 2.0.118",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zip"
|
||||||
|
version = "2.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
|
||||||
|
dependencies = [
|
||||||
|
"arbitrary",
|
||||||
|
"crc32fast",
|
||||||
|
"crossbeam-utils",
|
||||||
|
"displaydoc",
|
||||||
|
"flate2",
|
||||||
|
"indexmap",
|
||||||
|
"memchr",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"zopfli",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.21"
|
version = "1.0.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zopfli"
|
||||||
|
version = "0.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||||
|
dependencies = [
|
||||||
|
"bumpalo",
|
||||||
|
"crc32fast",
|
||||||
|
"log",
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|||||||
+6
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cassady"
|
name = "cassady"
|
||||||
version = "0.2.5"
|
version = "0.2.9"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Cassady/Cass minimal terminal coding agent"
|
description = "Cassady/Cass minimal terminal coding agent"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -33,11 +33,16 @@ ratatui = { version = "0.30", default-features = false, features = ["crossterm",
|
|||||||
regex = "1"
|
regex = "1"
|
||||||
pulldown-cmark = "0.12"
|
pulldown-cmark = "0.12"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
|
||||||
|
semver = "1"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
sha2 = "0.10"
|
||||||
|
flate2 = "1"
|
||||||
|
tar = "0.4"
|
||||||
thiserror = "1"
|
thiserror = "1"
|
||||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time", "process", "io-util"] }
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time", "process", "io-util"] }
|
||||||
unicode-width = "0.1"
|
unicode-width = "0.1"
|
||||||
|
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ The project installs two equivalent commands, `cass` and `cassady`; examples use
|
|||||||
|
|
||||||
- Provider support is OpenAI-compatible chat/completions APIs only.
|
- Provider support is OpenAI-compatible chat/completions APIs only.
|
||||||
- The primary interface is an interactive terminal UI.
|
- 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`.
|
- 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.
|
- 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.
|
- `cass update` can update release-archive installs from official GitHub releases; external package managers should still be updated through their own tools.
|
||||||
|
|
||||||
## Install from source
|
## Install from source
|
||||||
|
|
||||||
@@ -33,11 +34,13 @@ Start Cassady in a project directory:
|
|||||||
cass
|
cass
|
||||||
```
|
```
|
||||||
|
|
||||||
If Cassady cannot resolve a usable provider, model, or API key, it offers to run setup before opening a chat. You can also run setup explicitly:
|
If Cassady cannot resolve a usable provider, model, or API key, it offers to run setup before opening a chat. You can also run setup or provider login explicitly:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
cass login
|
||||||
cass setup
|
cass setup
|
||||||
cass check
|
cass check
|
||||||
|
cass update --check
|
||||||
cass
|
cass
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -64,13 +67,19 @@ cass [--model MODEL] [--cwd PATH]
|
|||||||
cass --resume <chat-id>
|
cass --resume <chat-id>
|
||||||
cass --resume
|
cass --resume
|
||||||
cass check
|
cass check
|
||||||
|
cass login
|
||||||
|
cass logout
|
||||||
cass setup
|
cass setup
|
||||||
|
cass update
|
||||||
```
|
```
|
||||||
|
|
||||||
`cass --resume` without an id lists saved chats for the current directory. When Cassady exits a chat, it prints a resume command for that session.
|
`cass --resume` without an id lists saved chats for the current directory. `cass update` checks official GitHub releases and can update both `cass` and `cassady` in the current install directory. When Cassady exits a chat, it prints a resume command for that session.
|
||||||
|
|
||||||
Common in-chat commands:
|
Common in-chat commands:
|
||||||
|
|
||||||
|
- `/branch` or `/restore`: open the branch/restore menu.
|
||||||
|
- `/login`: configure or update provider login settings.
|
||||||
|
- `/logout`: remove saved provider config and associated model entries.
|
||||||
- `/model <model>`: switch to a model from `~/.cass/models.json`.
|
- `/model <model>`: switch to a model from `~/.cass/models.json`.
|
||||||
- `/new`: create a new chat for the current directory.
|
- `/new`: create a new chat for the current directory.
|
||||||
- `/resume <chat>`: resume a saved chat for the current directory.
|
- `/resume <chat>`: resume a saved chat for the current directory.
|
||||||
@@ -85,7 +94,7 @@ Helpful keys:
|
|||||||
- `Tab`: cycle reasoning effort while idle.
|
- `Tab`: cycle reasoning effort while idle.
|
||||||
- `Ctrl-O`: toggle compact/full tool output display.
|
- `Ctrl-O`: toggle compact/full tool output display.
|
||||||
- `Ctrl-Shift-R` or `Ctrl-R`: toggle reasoning display.
|
- `Ctrl-Shift-R` or `Ctrl-R`: toggle reasoning display.
|
||||||
- `Esc`: request turn cancellation while a turn is running.
|
- `Esc`: request turn cancellation while a turn is running; while idle, press twice within 1.5 seconds to open branch/restore.
|
||||||
- `Ctrl-C` twice within 1.5 seconds: exit.
|
- `Ctrl-C` twice within 1.5 seconds: exit.
|
||||||
|
|
||||||
## Safety model
|
## Safety model
|
||||||
@@ -98,6 +107,12 @@ Cassady exposes tools according to the active access mode:
|
|||||||
|
|
||||||
Use `--readonly`, `--workspace-edit`, or `--full-access` to choose a mode at launch, or press `Shift-Tab` while idle.
|
Use `--readonly`, `--workspace-edit`, or `--full-access` to choose a mode at launch, or press `Shift-Tab` while idle.
|
||||||
|
|
||||||
|
## Branch and restore
|
||||||
|
|
||||||
|
Press `Esc` twice while idle, or type `/branch`, to browse the current conversation's branch family. Selecting an earlier user message, assistant message, tool call, or tool result creates a new branch conversation instead of truncating the original chat. The menu also lets you switch back to related branches later.
|
||||||
|
|
||||||
|
Conversation-only branching is the safe default. If you choose file restore, Cassady restores only file changes it tracked from successful `write` and `edit` tools. Shell commands, manual edits, unsupported files, and hash conflicts are reported or skipped rather than overwritten blindly.
|
||||||
|
|
||||||
## Configuration and docs
|
## Configuration and docs
|
||||||
|
|
||||||
Cassady stores user-editable files in `~/.cass`:
|
Cassady stores user-editable files in `~/.cass`:
|
||||||
@@ -112,12 +127,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.
|
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
|
## More documentation
|
||||||
|
|
||||||
- [Commands](docs/commands.md)
|
- [Commands](docs/commands.md)
|
||||||
- [Configuration](docs/configuration.md)
|
- [Configuration](docs/configuration.md)
|
||||||
- [Providers and models](docs/providers.md)
|
- [Providers and models](docs/providers.md)
|
||||||
- [Access modes and tool safety](docs/access-modes.md)
|
- [Access modes and tool safety](docs/access-modes.md)
|
||||||
|
- [Experimental Rust embedding API](docs/embedding.md)
|
||||||
- [Workflows](docs/workflows.md)
|
- [Workflows](docs/workflows.md)
|
||||||
- [Troubleshooting](docs/troubleshooting.md)
|
- [Troubleshooting](docs/troubleshooting.md)
|
||||||
- [Platform notes](docs/platforms.md)
|
- [Platform notes](docs/platforms.md)
|
||||||
|
|||||||
+143
@@ -1,5 +1,148 @@
|
|||||||
# Cassady (Cass) Roadmap
|
# Cassady (Cass) Roadmap
|
||||||
|
|
||||||
|
## v0.2.9 — Provider Login Management
|
||||||
|
|
||||||
|
This release focuses on making provider configuration available from both the shell and an active Cassady chat. Users can add or update OpenAI-compatible provider/model settings with `cass login` or `/login`, and remove saved providers and their associated models with `cass logout` or `/logout`. See `plans/V0_2_9_PROVIDER_LOGIN_MANAGEMENT_PLAN.md`.
|
||||||
|
|
||||||
|
### Login Commands
|
||||||
|
|
||||||
|
- [x] **Add login commands for provider setup.** Make `cass login` and `/login` open the provider setup flow so users can configure providers without remembering that setup is the underlying implementation.
|
||||||
|
- Reuse the existing provider catalog, model discovery, model capability prompts, and safe JSON writes.
|
||||||
|
- Keep `/login` idle-only and reload active provider/model config after the menu closes.
|
||||||
|
|
||||||
|
- [x] **Keep existing setup behavior intact.** Preserve `cass setup` and first-run setup while making login language feel natural for account/provider management.
|
||||||
|
- Direct `cass login` should save configuration and exit rather than unexpectedly starting a chat.
|
||||||
|
- Existing setup validation and missing API key guidance should continue to apply.
|
||||||
|
|
||||||
|
### Logout Commands
|
||||||
|
|
||||||
|
- [x] **Add a safe provider removal menu.** Make `cass logout` and `/logout` let users choose saved providers to remove from `providers.json`.
|
||||||
|
- Remove associated `models.json` entries for selected providers.
|
||||||
|
- Confirm the removal before writing changes.
|
||||||
|
|
||||||
|
- [x] **Repair active defaults after removal.** Ensure `config.json` never points at a provider/model that was just removed.
|
||||||
|
- Select a valid remaining provider/model when possible.
|
||||||
|
- Clear active provider/model defaults when no providers remain so the next startup offers login/setup.
|
||||||
|
|
||||||
|
### Documentation and Validation
|
||||||
|
|
||||||
|
- [x] **Document provider login and logout workflows.** Update command, configuration, and workflow docs with the new shell and in-chat commands.
|
||||||
|
- Clarify that logout removes Cassady provider config, not environment variables or external provider accounts.
|
||||||
|
|
||||||
|
- [x] **Test provider management behavior.** Cover provider/model removal, active default repair, local command parsing, and autocomplete.
|
||||||
|
- Verify `cargo fmt` and `cargo test --locked --all-targets` pass before handoff.
|
||||||
|
|
||||||
|
## v0.2.8 — Conversation Branch and Restore
|
||||||
|
|
||||||
|
This release focuses on making conversation recovery safe and explorable. Pressing `Esc` twice while idle opens a branch/restore menu where users can browse prior user messages, assistant messages, and tool calls, create a new branch from a selected checkpoint, and optionally restore Cassady-tracked file edits without destroying the original conversation. See `plans/V0_2_8_CONVERSATION_BRANCH_RESTORE_PLAN.md`.
|
||||||
|
|
||||||
|
### Branch Navigation
|
||||||
|
|
||||||
|
- [x] **Open a branch/restore menu with double Esc.** Add an idle `Esc`-twice shortcut that mirrors the discoverability of double `Ctrl-C` while preserving current busy-turn cancellation and approval-denial behavior.
|
||||||
|
- Keep draft input intact when the menu is opened or cancelled.
|
||||||
|
- Provide clear status text after the first `Esc` so users know a second press opens branch/restore.
|
||||||
|
|
||||||
|
- [x] **Browse checkpoints across the related branch family.** Show user messages, assistant messages, tool-call requests, and tool results from the current chat and related branches.
|
||||||
|
- Include enough preview text, tool names, paths, timestamps, and branch labels to choose the right point.
|
||||||
|
- Allow switching back to the original conversation or another existing branch from the same menu.
|
||||||
|
|
||||||
|
### Safe Conversation Branching
|
||||||
|
|
||||||
|
- [x] **Create branches instead of destructive reverts.** Selecting a checkpoint should write a new conversation JSONL with parent/checkpoint metadata, leaving the source conversation unchanged.
|
||||||
|
- Support repeated branching so users can return to the menu later and branch or switch again.
|
||||||
|
- Keep older conversations without branch metadata loadable.
|
||||||
|
|
||||||
|
- [x] **Handle tool-call checkpoints cleanly.** Branching at a specific tool call or tool result should preserve a valid provider message history.
|
||||||
|
- Repair partial multi-tool assistant turns with synthetic cancelled/omitted tool results where needed.
|
||||||
|
- Add tests for branching at user, assistant, and tool boundaries.
|
||||||
|
|
||||||
|
### File Edit Restoration
|
||||||
|
|
||||||
|
- [x] **Journal Cassady file edits with restorable snapshots.** Record successful `write` and `edit` tool mutations outside the model-visible transcript with before/after hashes and snapshots.
|
||||||
|
- Keep restore support limited to Cassady-tracked file tools; warn that shell commands and manual edits are not automatically reversible.
|
||||||
|
- Store enough data to restore both backward and forward between tracked checkpoints.
|
||||||
|
|
||||||
|
- [x] **Offer explicit conversation-only or conversation-plus-files restore actions.** Make conversation-only branching the safe default, and require confirmation before changing workspace files.
|
||||||
|
- Preview files to update or delete, detect hash conflicts, and refuse unsafe overwrites by default.
|
||||||
|
- Use atomic writes for restored files and preserve clear status/transcript messages for skipped or conflicted paths.
|
||||||
|
|
||||||
|
### Documentation and Validation
|
||||||
|
|
||||||
|
- [x] **Document branch/restore workflows and limitations.** Update README and bundled docs with the double-`Esc` shortcut, menu controls, branch semantics, and file-restore safety model.
|
||||||
|
- Include troubleshooting for restore conflicts and unsupported shell/manual filesystem changes.
|
||||||
|
|
||||||
|
- [x] **Test the branch and restore model.** Cover branch metadata, checkpoint extraction, tool-call repair, edit journaling, restore planning, and keybinding behavior where practical.
|
||||||
|
- Verify `cargo fmt` and `cargo test --locked --all-targets` pass before release.
|
||||||
|
|
||||||
|
## v0.2.7 — Self-Update Command
|
||||||
|
|
||||||
|
This release focuses on making Cassady easy to keep current after installation. The goal is to let users run one clean command, `cass update`, to check GitHub releases, choose the recommended prebuilt binary or a source-build fallback, verify what will be installed, and update both `cass` and `cassady` safely. See `plans/V0_2_7_SELF_UPDATE_COMMAND_PLAN.md`.
|
||||||
|
|
||||||
|
### Update Command Experience
|
||||||
|
|
||||||
|
- [x] **Add a polished `cass update` command.** Check the official Cassady GitHub releases, compare the running version to the selected release, and guide the user through an interactive update flow.
|
||||||
|
- Keep update independent of provider/model setup so it works even when config is missing or invalid.
|
||||||
|
- Support script-friendly checks with flags such as `--check`, `--dry-run`, and `--yes`.
|
||||||
|
|
||||||
|
- [x] **Select the right update path.** Prefer a matching prebuilt release archive when available, with explicit `--prebuilt` and `--source` modes for users who want to choose.
|
||||||
|
- Support the same macOS, Linux, and Windows targets used by Cassady releases.
|
||||||
|
- Offer source builds for unsupported targets or users who prefer building locally.
|
||||||
|
|
||||||
|
### Safe Installation
|
||||||
|
|
||||||
|
- [x] **Verify and stage prebuilt artifacts before replacing binaries.** Download archives and SHA-256 files from the release, verify checksums, extract safely, and validate staged `cass`/`cassady` binaries.
|
||||||
|
- Reject checksum mismatches, unsafe archive paths, missing binaries, and unexpected versions.
|
||||||
|
- Show clear progress and failure messages without dumping raw implementation details.
|
||||||
|
|
||||||
|
- [x] **Replace installed binaries cleanly.** Update the current binary and same-directory companion binary when possible, using backups and rollback on failure.
|
||||||
|
- Do not auto-run `sudo` or administrator prompts.
|
||||||
|
- Handle Windows executable replacement with a staged helper or documented manual fallback if necessary.
|
||||||
|
|
||||||
|
### Source Build Fallback and Documentation
|
||||||
|
|
||||||
|
- [x] **Build from release source when requested.** Download the selected release source, validate its version, check for Rust tooling, run a locked release build, and install the resulting local binaries.
|
||||||
|
- Keep source mode tied to release tags rather than arbitrary branches.
|
||||||
|
- Do not attempt cross-compilation or Rust toolchain installation in this release.
|
||||||
|
|
||||||
|
- [x] **Document and test update behavior.** Update README and bundled docs for `cass update`, platform notes, troubleshooting, and package-manager caveats.
|
||||||
|
- Add tests for release parsing, target mapping, asset selection, checksum validation, archive extraction safety, install planning, and mocked update flows.
|
||||||
|
|
||||||
|
## 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
|
## 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`.
|
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`.
|
||||||
|
|||||||
+3
-2
@@ -6,11 +6,12 @@ Cassady tools may list, search, and read this directory. Mutating tools are bloc
|
|||||||
|
|
||||||
## Contents
|
## Contents
|
||||||
|
|
||||||
- [Commands](commands.md): CLI forms, global flags, in-chat commands, and keys.
|
- [Commands](commands.md): CLI forms, global flags, `cass update`, in-chat commands, and keys.
|
||||||
- [Configuration](configuration.md): `~/.cass` files, setup, precedence, schema examples, and validation.
|
- [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.
|
- [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.
|
- [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.
|
- [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.
|
- [Troubleshooting](troubleshooting.md): symptoms, likely causes, fixes, and verification commands.
|
||||||
- [Platform notes](platforms.md): macOS, Linux, and Windows environment/path notes.
|
- [Platform notes](platforms.md): macOS, Linux, Windows, release artifact, and update notes.
|
||||||
- [Glossary](glossary.md): short definitions for Cassady terms.
|
- [Glossary](glossary.md): short definitions for Cassady terms.
|
||||||
|
|||||||
+50
-1
@@ -8,11 +8,14 @@ Cassady installs two equivalent binaries: `cass` and `cassady`. This page uses `
|
|||||||
cass [OPTIONS]
|
cass [OPTIONS]
|
||||||
cassady [OPTIONS]
|
cassady [OPTIONS]
|
||||||
cass check [OPTIONS]
|
cass check [OPTIONS]
|
||||||
|
cass login [OPTIONS]
|
||||||
|
cass logout [OPTIONS]
|
||||||
cass setup [OPTIONS]
|
cass setup [OPTIONS]
|
||||||
|
cass update [OPTIONS]
|
||||||
cass --resume [CHAT_ID]
|
cass --resume [CHAT_ID]
|
||||||
```
|
```
|
||||||
|
|
||||||
Run `cass --help`, `cass check --help`, or `cass setup --help` for the help generated by the current binary.
|
Run `cass --help`, `cass check --help`, `cass login --help`, `cass logout --help`, `cass setup --help`, or `cass update --help` for the help generated by the current binary.
|
||||||
|
|
||||||
## Startup behavior
|
## Startup behavior
|
||||||
|
|
||||||
@@ -57,14 +60,55 @@ Validates Cassady configuration under `~/.cass`:
|
|||||||
|
|
||||||
Missing API keys for inactive providers are warnings. A missing active API key is an error and `cass check` exits with a non-zero status.
|
Missing API keys for inactive providers are warnings. A missing active API key is an error and `cass check` exits with a non-zero status.
|
||||||
|
|
||||||
|
### `cass login`
|
||||||
|
|
||||||
|
Runs the provider login/configuration wizard. This is the same provider setup flow used by `cass setup`, framed for adding or updating saved OpenAI-compatible provider access. It can configure multiple providers, discover or manually enter models, update active defaults, and validate the saved files.
|
||||||
|
|
||||||
|
`cass login` edits Cassady files under `~/.cass`; it does not sign in through a browser or create provider accounts.
|
||||||
|
|
||||||
|
### `cass logout`
|
||||||
|
|
||||||
|
Opens an interactive menu for removing saved providers from Cassady config. Removing a provider also removes its associated `models.json` entries. If the active provider is removed, Cassady chooses a remaining provider/model when possible. If no providers remain, active defaults are cleared and the next chat startup will offer setup/login again.
|
||||||
|
|
||||||
|
`cass logout` does not delete environment variables, API keys stored elsewhere, or external provider accounts.
|
||||||
|
|
||||||
### `cass setup`
|
### `cass setup`
|
||||||
|
|
||||||
Runs the interactive setup wizard in a terminal. It configures OpenAI-compatible providers, API key environment-variable references, and first models. It updates `config.json`, `providers.json`, and `models.json` while preserving unrelated entries where possible.
|
Runs the interactive setup wizard in a terminal. It configures OpenAI-compatible providers, API key environment-variable references, and first models. It updates `config.json`, `providers.json`, and `models.json` while preserving unrelated entries where possible.
|
||||||
|
|
||||||
|
### `cass update`
|
||||||
|
|
||||||
|
Checks official GitHub releases for Cassady, including Cassady prereleases, and updates the current install directory. The updater runs before provider/model config is loaded, so it can be used even if `~/.cass` is missing or invalid.
|
||||||
|
|
||||||
|
By default, Cassady selects the matching prebuilt archive for the current platform, downloads the archive and `.sha256` file, verifies SHA-256, stages both `cass` and `cassady`, and replaces same-directory binaries with rollback backups. If no prebuilt archive is available, it can build from the selected release source when you choose source mode.
|
||||||
|
|
||||||
|
Useful options:
|
||||||
|
|
||||||
|
- `--check`: check the selected release without installing.
|
||||||
|
- `--dry-run`: show the selected release, mode, asset, and install plan without downloading or installing.
|
||||||
|
- `--yes` / `-y`: accept default prompts for non-interactive use.
|
||||||
|
- `--prebuilt`: require a matching prebuilt archive.
|
||||||
|
- `--source`: build from release source even when a prebuilt archive exists.
|
||||||
|
- `--to TAG`: use a specific release tag such as `v0.2.7`.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cass update --check
|
||||||
|
cass update --dry-run
|
||||||
|
cass update
|
||||||
|
cass update --source
|
||||||
|
```
|
||||||
|
|
||||||
|
The updater does not invoke `sudo` or administrator prompts. If the install directory is not writable, rerun the update from an install location you own or update through the same package manager or manual process you originally used.
|
||||||
|
|
||||||
## In-chat commands
|
## In-chat commands
|
||||||
|
|
||||||
Type `/` to open command autocomplete.
|
Type `/` to open command autocomplete.
|
||||||
|
|
||||||
|
- `/branch` or `/restore`: open the branch/restore menu for the current conversation family.
|
||||||
|
- `/login`: configure or update provider login settings, then reload active provider/model config.
|
||||||
|
- `/logout`: remove saved providers and their associated models, then reload active provider/model config when any remain.
|
||||||
- `/model <model>`: switch the model for future turns. Autocomplete lists models from `~/.cass/models.json`.
|
- `/model <model>`: switch the model for future turns. Autocomplete lists models from `~/.cass/models.json`.
|
||||||
- `/new`: create a new chat for the current directory.
|
- `/new`: create a new chat for the current directory.
|
||||||
- `/resume <chat>`: resume a saved chat from the current directory. Autocomplete lists matching chats.
|
- `/resume <chat>`: resume a saved chat from the current directory. Autocomplete lists matching chats.
|
||||||
@@ -86,8 +130,13 @@ Local commands can be used only when the agent is idle.
|
|||||||
- `y`: approve a pending tool approval prompt.
|
- `y`: approve a pending tool approval prompt.
|
||||||
- `n` or `Esc`: deny a pending tool approval prompt.
|
- `n` or `Esc`: deny a pending tool approval prompt.
|
||||||
- `Esc`: request cancellation while a turn is running.
|
- `Esc`: request cancellation while a turn is running.
|
||||||
|
- `Esc` twice while idle: open the branch/restore menu without discarding draft input.
|
||||||
- `Ctrl-C`: request cancellation while busy; press twice within 1.5 seconds to exit.
|
- `Ctrl-C`: request cancellation while busy; press twice within 1.5 seconds to exit.
|
||||||
|
|
||||||
|
## Branch/restore notes
|
||||||
|
|
||||||
|
The branch/restore menu lists related conversations plus checkpoints for user messages, assistant messages, tool-call requests, and tool results. Selecting a checkpoint creates a new branch JSONL conversation and leaves the source conversation unchanged. Conversation-only branching leaves files untouched; branch-plus-file restore applies Cassady's tracked `write`/`edit` snapshots and skips unsafe hash conflicts.
|
||||||
|
|
||||||
## Output notes
|
## Output notes
|
||||||
|
|
||||||
Tool calls are shown compactly by default. Press `Ctrl-O` to expand full tool output. Provider-streamed reasoning is hidden by default unless `show_reasoning` is enabled in config or toggled at runtime.
|
Tool calls are shown compactly by default. Press `Ctrl-O` to expand full tool output. Provider-streamed reasoning is hidden by default unless `show_reasoning` is enabled in config or toggled at runtime.
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ cass setup
|
|||||||
|
|
||||||
Cassady also offers setup automatically when `cass` cannot resolve a usable active provider, model, or API key before starting a chat.
|
Cassady also offers setup automatically when `cass` cannot resolve a usable active provider, model, or API key before starting a chat.
|
||||||
|
|
||||||
|
For everyday provider management, `cass login` opens the same provider configuration flow with login-oriented wording. Inside an idle chat, `/login` temporarily opens that flow and reloads the active provider/model after it closes.
|
||||||
|
|
||||||
|
To remove saved provider configuration, run `cass logout` or type `/logout` while idle. Logout removes selected providers from `providers.json` and removes their associated entries from `models.json`. It does not remove environment variables, local shell profile exports, or external provider accounts.
|
||||||
|
|
||||||
The wizard uses keyboard prompts: `↑`/`↓` moves through choices, `Space` selects providers in the multi-select screen, and `Enter` submits. Text fields use the same prompt style instead of falling back to plain line input.
|
The wizard uses keyboard prompts: `↑`/`↓` moves through choices, `Space` selects providers in the multi-select screen, and `Enter` submits. Text fields use the same prompt style instead of falling back to plain line input.
|
||||||
|
|
||||||
The wizard supports configuring multiple OpenAI-compatible providers at once. If more than one provider is configured, setup asks which one should be active first. If the selected API key environment variable is set, Cassady tries to fetch models from `GET {base_url}/models` and lets you choose one. If discovery fails, it offers a retry before falling back to manual model entry. If the API key is not set, setup asks for a model id manually.
|
The wizard supports configuring multiple OpenAI-compatible providers at once. If more than one provider is configured, setup asks which one should be active first. If the selected API key environment variable is set, Cassady tries to fetch models from `GET {base_url}/models` and lets you choose one. If discovery fails, it offers a retry before falling back to manual model entry. If the API key is not set, setup asks for a model id manually.
|
||||||
@@ -169,6 +173,7 @@ cass
|
|||||||
1. Edit one file at a time.
|
1. Edit one file at a time.
|
||||||
2. Keep provider ids and model provider references in sync.
|
2. Keep provider ids and model provider references in sync.
|
||||||
3. Prefer API key env references over literal keys.
|
3. Prefer API key env references over literal keys.
|
||||||
4. Run `cass check` before starting a chat.
|
4. Prefer `cass login` and `cass logout` for routine provider changes.
|
||||||
|
5. Run `cass check` before starting a chat.
|
||||||
|
|
||||||
Invalid JSON, unknown fields, duplicate ids, and missing provider/model links are reported by `cass check` with the file that failed.
|
Invalid JSON, unknown fields, duplicate ids, and missing provider/model links are reported by `cass check` with the file that failed.
|
||||||
|
|||||||
@@ -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.
|
||||||
+15
-2
@@ -52,9 +52,22 @@ That directory contains `config.json`, `providers.json`, `models.json`, `global.
|
|||||||
## Non-interactive contexts
|
## Non-interactive contexts
|
||||||
|
|
||||||
- `cass check` is suitable for scripts and CI because it prints text and exits non-zero on errors.
|
- `cass check` is suitable for scripts and CI because it prints text and exits non-zero on errors.
|
||||||
|
- `cass update --check` and `cass update --dry-run` are suitable for scripts that only need release status or an install plan.
|
||||||
|
- `cass update --yes` accepts default prompts for scripted updates, but still fails instead of escalating privileges when the install directory is not writable.
|
||||||
- `cass setup` requires an interactive terminal.
|
- `cass setup` requires an interactive terminal.
|
||||||
- `cass` chat is an interactive terminal UI.
|
- `cass` chat is an interactive terminal UI.
|
||||||
|
|
||||||
## Release artifacts
|
## Release artifacts and updates
|
||||||
|
|
||||||
When using release archives, each archive contains both `cass` and `cassady`. Put the extracted binaries somewhere on your `PATH` or run them by explicit path. Cassady itself does not install, update, or manage PATH entries.
|
When using release archives, each archive contains both `cass` and `cassady`. Put the extracted binaries somewhere on your `PATH` or run them by explicit path.
|
||||||
|
|
||||||
|
`cass update` can update release-archive installs from official GitHub releases. It supports the same prebuilt targets as the release process:
|
||||||
|
|
||||||
|
- macOS Apple Silicon: `aarch64-apple-darwin`
|
||||||
|
- Linux x86_64: `x86_64-unknown-linux-gnu`
|
||||||
|
- Linux ARM64: `aarch64-unknown-linux-gnu`
|
||||||
|
- Windows x86_64: `x86_64-pc-windows-gnu`
|
||||||
|
|
||||||
|
On macOS and Linux, the updater replaces same-directory `cass` and `cassady` binaries with backups and rollback on failure. On Windows, replacing a running `.exe` is more constrained; if automatic replacement is unavailable, Cassady leaves staged files in place and reports manual copy guidance instead of partially modifying the install.
|
||||||
|
|
||||||
|
If Cassady is installed through a package manager in the future, prefer that package manager's update command instead of `cass update`.
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ The setup wizard offers these provider templates:
|
|||||||
|
|
||||||
There is also a custom OpenAI-compatible option. Custom setup asks for provider name, provider id, base URL, API key environment variable, and first model id.
|
There is also a custom OpenAI-compatible option. Custom setup asks for provider name, provider id, base URL, API key environment variable, and first model id.
|
||||||
|
|
||||||
|
## Login and logout
|
||||||
|
|
||||||
|
Use `cass login` to add or update provider configuration from the shell. Inside an idle chat, `/login` opens the same flow and reloads the active provider/model afterward.
|
||||||
|
|
||||||
|
Use `cass logout` or `/logout` to remove saved provider entries from Cassady config. Logout also removes model metadata entries associated with the removed providers and repairs active defaults when other providers remain. It does not delete environment variables, shell profile exports, API keys stored elsewhere, or external provider accounts.
|
||||||
|
|
||||||
## Model discovery
|
## Model discovery
|
||||||
|
|
||||||
When the selected API key environment variable is available, setup tries:
|
When the selected API key environment variable is available, setup tries:
|
||||||
|
|||||||
@@ -143,6 +143,37 @@ Likely cause: Windows CRLF line endings or invisible whitespace differences.
|
|||||||
|
|
||||||
Fix: re-read the exact target region and preserve the line endings in `old_text`, or use a smaller unique snippet.
|
Fix: re-read the exact target region and preserve the line endings in `old_text`, or use a smaller unique snippet.
|
||||||
|
|
||||||
|
## Branch/restore file conflicts
|
||||||
|
|
||||||
|
Symptom: branch-plus-file restore reports conflicts or skips paths.
|
||||||
|
|
||||||
|
Likely cause: the file changed outside Cassady after the tracked `write`/`edit`, the file is unsupported for snapshots, or the change came from a shell command or manual editor rather than a Cassady file tool.
|
||||||
|
|
||||||
|
Fix: review the restore preview, inspect conflicted files manually, and rerun the menu with conversation-only branching if you only need to revisit the chat. Cassady will not overwrite unknown current content by default. Open the branch/restore menu again with double `Esc` or `/branch` to switch back to the original branch.
|
||||||
|
|
||||||
|
## Update command problems
|
||||||
|
|
||||||
|
Symptom: `cass update` cannot complete.
|
||||||
|
|
||||||
|
Likely causes and fixes:
|
||||||
|
|
||||||
|
- Network or GitHub API failure: retry later or verify proxy/firewall settings.
|
||||||
|
- No matching prebuilt archive: use `cass update --source` if you have Rust installed, or download the release archive manually for a supported target.
|
||||||
|
- SHA-256 mismatch: do not install the archive. Retry the update; if it repeats, check the GitHub release page before proceeding.
|
||||||
|
- Missing Rust toolchain in source mode: install Rust/Cargo yourself, then rerun `cass update --source`. Cassady does not install Rust automatically.
|
||||||
|
- Non-writable install directory: update through the original install method, move Cassady to a directory you own, or manually replace the binaries. Cassady does not run `sudo` for you.
|
||||||
|
- PATH conflict: `cass update` updates the current executable directory. Run `which cass` / `which cassady` on macOS/Linux or `Get-Command cass` in PowerShell to confirm which binary your shell starts.
|
||||||
|
- Windows replacement limitation: if Cassady reports that automatic replacement is unavailable, use the staged file paths it prints and copy them after the running process exits.
|
||||||
|
|
||||||
|
Useful checks:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cass update --check
|
||||||
|
cass update --dry-run
|
||||||
|
cass --version
|
||||||
|
cassady --version
|
||||||
|
```
|
||||||
|
|
||||||
## Terminal rendering problems
|
## Terminal rendering problems
|
||||||
|
|
||||||
Symptom: the UI appears garbled or keys do not behave as expected.
|
Symptom: the UI appears garbled or keys do not behave as expected.
|
||||||
|
|||||||
@@ -57,6 +57,34 @@ Run the smallest relevant Rust test for this change, then summarize the result.
|
|||||||
|
|
||||||
When the approval prompt appears, press `y` to approve or `n`/`Esc` to deny. Shell commands run with `sh -c` from the launch cwd and default to a 30-second timeout unless the model requests another timeout.
|
When the approval prompt appears, press `y` to approve or `n`/`Esc` to deny. Shell commands run with `sh -c` from the launch cwd and default to a 30-second timeout unless the model requests another timeout.
|
||||||
|
|
||||||
|
## Manage provider login
|
||||||
|
|
||||||
|
Add or update provider configuration from the shell:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cass login
|
||||||
|
```
|
||||||
|
|
||||||
|
Inside an idle chat:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/login
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove saved provider configuration:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cass logout
|
||||||
|
```
|
||||||
|
|
||||||
|
Inside an idle chat:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/logout
|
||||||
|
```
|
||||||
|
|
||||||
|
Logout removes selected providers from Cassady's config and removes their associated model entries. It does not delete environment variables or external provider accounts.
|
||||||
|
|
||||||
## Switch model
|
## Switch model
|
||||||
|
|
||||||
Inside a chat:
|
Inside a chat:
|
||||||
@@ -103,6 +131,16 @@ Inside the UI:
|
|||||||
|
|
||||||
This creates a new chat for the same cwd and model while preserving your current configuration.
|
This creates a new chat for the same cwd and model while preserving your current configuration.
|
||||||
|
|
||||||
|
## Branch or restore a conversation point
|
||||||
|
|
||||||
|
Press `Esc` twice while idle, or type:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/branch
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the menu to select a related branch or a checkpoint from a user message, assistant message, tool-call request, or tool result. Branching creates a new chat from that point and leaves the original chat available in the same menu. Choose conversation-only branching to leave files untouched, or choose branch-plus-files to restore Cassady-tracked `write`/`edit` snapshots with conflict checks.
|
||||||
|
|
||||||
## Check status
|
## Check status
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
# v0.2.7 Self-Update Command Implementation Plan
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
v0.2.7 adds a polished `cass update` command that can update Cassady from official GitHub releases without requiring users to manually download archives, verify checksums, unpack binaries, or rebuild from source.
|
||||||
|
|
||||||
|
Success statement:
|
||||||
|
|
||||||
|
> A user can run `cass update`, see the available release, choose the recommended prebuilt binary or a source build fallback, and finish with updated `cass` and `cassady` commands in the same install location.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### In scope
|
||||||
|
|
||||||
|
- Add a `cass update` / `cassady update` subcommand.
|
||||||
|
- Query official Cassady GitHub releases from `owenqwenstarsky/cassady`.
|
||||||
|
- Compare the current binary version with the latest stable release.
|
||||||
|
- Download and install the matching prebuilt archive when available.
|
||||||
|
- Verify prebuilt archives with the shipped `.sha256` files before installing.
|
||||||
|
- Offer a source-build path that downloads release source for the selected tag and builds local binaries.
|
||||||
|
- Update both shipped binaries, `cass` and `cassady`, when possible.
|
||||||
|
- Use interactive prompts by default with clear summaries, confirmations, progress, success, and recovery messages.
|
||||||
|
- Provide non-interactive flags for check-only and yes-to-prompts usage.
|
||||||
|
- Keep `cass update` independent of model/provider setup so updates work even when `~/.cass` is missing or broken.
|
||||||
|
- Add tests for release parsing, target detection, asset selection, checksum validation, archive extraction safety, and install planning.
|
||||||
|
- Update README and bundled docs.
|
||||||
|
|
||||||
|
### Out of scope
|
||||||
|
|
||||||
|
- Publishing through Homebrew, apt, winget, Scoop, npm, or other package managers.
|
||||||
|
- Automatic background updates or prompts during normal chat startup.
|
||||||
|
- Updating Cassady when it was installed by an external package manager that should own the install directory.
|
||||||
|
- Privilege escalation, `sudo` automation, or administrator prompts.
|
||||||
|
- Code signing, notarization, or signature verification beyond existing SHA-256 files.
|
||||||
|
- Downgrading by default. Installing an older tag should require an explicit flag if supported.
|
||||||
|
- Cross-compiling in source mode. Source builds target the current host platform only.
|
||||||
|
|
||||||
|
## Context and Current State
|
||||||
|
|
||||||
|
Relevant files:
|
||||||
|
|
||||||
|
- `Cargo.toml`: package version and two binaries, `cass` and `cassady`.
|
||||||
|
- `src/cli.rs`: Clap command definitions currently include `check` and `setup`.
|
||||||
|
- `src/app.rs`: top-level command dispatch; update should run before setup/config loading.
|
||||||
|
- `src/main.rs` and `src/bin/cassady.rs`: both call `cassady::run()`.
|
||||||
|
- `README.md` and `docs/commands.md`: command documentation.
|
||||||
|
- `docs/platforms.md` and `docs/troubleshooting.md`: platform and recovery guidance.
|
||||||
|
- `AGENTS.md`: release artifacts use these names:
|
||||||
|
- `cassady-vX.Y.Z-aarch64-apple-darwin.tar.gz`
|
||||||
|
- `cassady-vX.Y.Z-x86_64-unknown-linux-gnu.tar.gz`
|
||||||
|
- `cassady-vX.Y.Z-aarch64-unknown-linux-gnu.tar.gz`
|
||||||
|
- `cassady-vX.Y.Z-x86_64-pc-windows-gnu.zip`
|
||||||
|
|
||||||
|
Current releases include both `cass` and `cassady` in each archive plus one `.sha256` file per archive. The update command should reuse that release contract instead of inventing a new distribution format.
|
||||||
|
|
||||||
|
## Design Principles
|
||||||
|
|
||||||
|
1. **Boring and recoverable.** Updating should be explicit, easy to understand, and safe to interrupt before installation starts.
|
||||||
|
2. **Use official release artifacts first.** Prefer prebuilt archives with SHA-256 verification; fall back to source builds when the user asks or no asset matches.
|
||||||
|
3. **No surprise setup coupling.** Users should not need a configured provider, model, or API key to update the CLI.
|
||||||
|
4. **Respect install ownership.** Do not auto-escalate privileges or overwrite package-manager-owned paths without clear user confirmation.
|
||||||
|
5. **Both command names stay aligned.** If the user has both `cass` and `cassady` in the install directory, update them together.
|
||||||
|
6. **Interactive by default, scriptable when requested.** The normal path should be friendly; flags should support CI/check scripts.
|
||||||
|
7. **Fail closed on integrity.** Missing or mismatched checksums for prebuilt artifacts must stop installation.
|
||||||
|
|
||||||
|
## User Experience
|
||||||
|
|
||||||
|
### Default interactive flow
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ cass update
|
||||||
|
Cassady update
|
||||||
|
|
||||||
|
Current version: v0.2.6
|
||||||
|
Latest release: v0.2.7
|
||||||
|
Install path: /usr/local/bin
|
||||||
|
Recommended: prebuilt aarch64-apple-darwin archive
|
||||||
|
|
||||||
|
Update Cassady to v0.2.7? [Y/n]
|
||||||
|
```
|
||||||
|
|
||||||
|
If the user accepts, Cassady should show concise phases:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Downloading cassady-v0.2.7-aarch64-apple-darwin.tar.gz ... 8.4 MB
|
||||||
|
Downloading cassady-v0.2.7-aarch64-apple-darwin.tar.gz.sha256 ... done
|
||||||
|
Verifying SHA-256 ... ok
|
||||||
|
Preparing cass and cassady ... ok
|
||||||
|
Installing to /usr/local/bin ... ok
|
||||||
|
Verifying installed version ... cass 0.2.7
|
||||||
|
|
||||||
|
Cassady is up to date.
|
||||||
|
```
|
||||||
|
|
||||||
|
If the current version is already latest:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cassady is already up to date.
|
||||||
|
Current version: v0.2.7
|
||||||
|
Latest release: v0.2.7
|
||||||
|
```
|
||||||
|
|
||||||
|
### Prebuilt or source selection
|
||||||
|
|
||||||
|
The default `auto` mode should choose the prebuilt release asset when a supported target is detected. If no matching prebuilt exists, prompt for source mode:
|
||||||
|
|
||||||
|
```text
|
||||||
|
No prebuilt archive is available for this platform.
|
||||||
|
Build Cassady v0.2.7 from source instead? [Y/n]
|
||||||
|
```
|
||||||
|
|
||||||
|
If both paths are available and the user asks for source mode:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cass update --source
|
||||||
|
```
|
||||||
|
|
||||||
|
Cassady should confirm prerequisites before building:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Source build requires cargo, rustc, and a working C toolchain.
|
||||||
|
Build Cassady v0.2.7 from release source now? [Y/n]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Useful flags
|
||||||
|
|
||||||
|
Add a command shape like:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cass update [OPTIONS]
|
||||||
|
```
|
||||||
|
|
||||||
|
Suggested options:
|
||||||
|
|
||||||
|
- `--check`: check GitHub for the latest release and print status without installing.
|
||||||
|
- `--yes`: accept default prompts for non-interactive use.
|
||||||
|
- `--prebuilt`: require a matching prebuilt archive; fail instead of falling back to source.
|
||||||
|
- `--source`: build from release source even when a prebuilt archive exists.
|
||||||
|
- `--to TAG`: install a specific release tag such as `v0.2.7`.
|
||||||
|
- `--dry-run`: resolve the release, target, assets, and install path without downloading or installing.
|
||||||
|
|
||||||
|
Optional later flags, only if implementation needs them:
|
||||||
|
|
||||||
|
- `--stable-only`: ignore prerelease tags during latest-release selection if Cassady later publishes both stable and prerelease channels.
|
||||||
|
- `--install-dir PATH`: install into an explicit directory. This should be advanced and carefully documented because it can conflict with PATH order.
|
||||||
|
|
||||||
|
Avoid adding a public `--repo` override unless needed for testing; tests can inject a mock client instead.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Module layout
|
||||||
|
|
||||||
|
Add a focused update module:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub mod update;
|
||||||
|
```
|
||||||
|
|
||||||
|
Suggested internal types:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct UpdateOptions { ... }
|
||||||
|
pub enum UpdateMode { Auto, Prebuilt, Source }
|
||||||
|
pub struct ReleaseInfo { ... }
|
||||||
|
pub struct ReleaseAsset { ... }
|
||||||
|
pub struct PlatformTarget { ... }
|
||||||
|
pub struct UpdatePlan { ... }
|
||||||
|
pub enum InstallAction { Replace, AddCompanion, SkipMissingCompanion }
|
||||||
|
```
|
||||||
|
|
||||||
|
`src/cli.rs` should add an `Update` subcommand with parsed flags. `src/app.rs` should dispatch it before setup/config loading:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
if let Some(Command::Update(args)) = cli.command {
|
||||||
|
return crate::update::run(args).await;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This keeps update usable even when `Config::load()` would fail.
|
||||||
|
|
||||||
|
### GitHub release discovery
|
||||||
|
|
||||||
|
Use the GitHub Releases API with an explicit user agent:
|
||||||
|
|
||||||
|
- Latest release: `GET https://api.github.com/repos/owenqwenstarsky/cassady/releases?per_page=30` and choose the highest semver non-draft tag, including prereleases because Cassady's current release process marks releases as prereleases.
|
||||||
|
- Specific tag: `GET https://api.github.com/repos/owenqwenstarsky/cassady/releases/tags/{tag}`
|
||||||
|
|
||||||
|
Parse:
|
||||||
|
|
||||||
|
- `tag_name`
|
||||||
|
- `name`
|
||||||
|
- `draft`
|
||||||
|
- `prerelease`
|
||||||
|
- `assets[].name`
|
||||||
|
- `assets[].browser_download_url`
|
||||||
|
- `assets[].size`
|
||||||
|
- `tarball_url` or `zipball_url` for source mode
|
||||||
|
|
||||||
|
Use `semver` to compare `env!("CARGO_PKG_VERSION")` with release tags after stripping a leading `v`. Draft releases should never be selected. Prereleases should be eligible by default while Cassady's official releases are marked as prereleases.
|
||||||
|
|
||||||
|
### Platform target mapping
|
||||||
|
|
||||||
|
Map the running platform to release asset targets:
|
||||||
|
|
||||||
|
| OS | Arch | Target | Archive |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| macOS | `aarch64` | `aarch64-apple-darwin` | `.tar.gz` |
|
||||||
|
| Linux | `x86_64` | `x86_64-unknown-linux-gnu` | `.tar.gz` |
|
||||||
|
| Linux | `aarch64` | `aarch64-unknown-linux-gnu` | `.tar.gz` |
|
||||||
|
| Windows | `x86_64` | `x86_64-pc-windows-gnu` | `.zip` |
|
||||||
|
|
||||||
|
Unsupported platforms should produce a clean message and offer source mode when possible.
|
||||||
|
|
||||||
|
### Prebuilt update path
|
||||||
|
|
||||||
|
For tag `vX.Y.Z` and target `TARGET`, find:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cassady-vX.Y.Z-TARGET.tar.gz
|
||||||
|
cassady-vX.Y.Z-TARGET.tar.gz.sha256
|
||||||
|
```
|
||||||
|
|
||||||
|
or on Windows:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cassady-vX.Y.Z-x86_64-pc-windows-gnu.zip
|
||||||
|
cassady-vX.Y.Z-x86_64-pc-windows-gnu.zip.sha256
|
||||||
|
```
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
|
||||||
|
1. Download archive and checksum into a temporary staging directory.
|
||||||
|
2. Parse the `.sha256` file and verify that the checksum filename matches the downloaded archive name.
|
||||||
|
3. Compute SHA-256 of the archive and compare exactly.
|
||||||
|
4. Extract into staging using path traversal checks.
|
||||||
|
5. Require the expected binaries:
|
||||||
|
- Unix: `cass`, `cassady`
|
||||||
|
- Windows: `cass.exe`, `cassady.exe`
|
||||||
|
6. Run the staged `cass --version` or `cassady --version` when possible and confirm the expected version.
|
||||||
|
7. Build an install plan for the current executable directory.
|
||||||
|
8. Confirm the final plan with the user unless `--yes` was supplied.
|
||||||
|
9. Replace binaries with backups and rollback on failure.
|
||||||
|
10. Verify installed version after replacement when possible.
|
||||||
|
|
||||||
|
Archive extraction must reject absolute paths, `..` components, symlinks that escape staging, and unexpected top-level layouts.
|
||||||
|
|
||||||
|
### Source-build update path
|
||||||
|
|
||||||
|
Source mode should still be tied to a GitHub release tag, not an arbitrary branch.
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
|
||||||
|
1. Resolve the selected release tag.
|
||||||
|
2. Download release source from `tarball_url` or `zipball_url` into staging.
|
||||||
|
3. Extract with the same path traversal protections as prebuilt archives.
|
||||||
|
4. Verify `Cargo.toml` version matches the selected tag.
|
||||||
|
5. Run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo build --release --locked --bins
|
||||||
|
```
|
||||||
|
|
||||||
|
from the extracted source tree.
|
||||||
|
6. Locate built binaries under `target/release/`.
|
||||||
|
7. Run staged `--version` checks.
|
||||||
|
8. Install using the same installer path as prebuilt updates.
|
||||||
|
|
||||||
|
Before source mode starts, check for `cargo` and `rustc` on PATH and show a clear error if they are missing. Do not attempt to install Rust automatically.
|
||||||
|
|
||||||
|
### Install planning and replacement
|
||||||
|
|
||||||
|
Determine the current executable path with `std::env::current_exe()`, then derive the install directory. The install plan should include:
|
||||||
|
|
||||||
|
- current binary path
|
||||||
|
- sibling `cass` path
|
||||||
|
- sibling `cassady` path
|
||||||
|
- which binaries currently exist
|
||||||
|
- which binaries are writable
|
||||||
|
- whether companion binaries will be updated, added, skipped, or blocked
|
||||||
|
|
||||||
|
Recommended behavior:
|
||||||
|
|
||||||
|
- Always update the currently running binary name.
|
||||||
|
- If the sibling binary exists in the same directory, update it too.
|
||||||
|
- If the sibling binary is missing and the directory is writable, ask whether to install it.
|
||||||
|
- If a target path is not writable, stop with an actionable message. Do not invoke `sudo` or administrator prompts automatically.
|
||||||
|
- Use backups such as `.cass-update-backup-v0.2.6-<timestamp>` during replacement.
|
||||||
|
- If any replacement fails, restore backups before returning an error.
|
||||||
|
|
||||||
|
Unix can generally replace a running executable via atomic rename. Windows cannot reliably overwrite the running `.exe`; implement one of these approaches during coding:
|
||||||
|
|
||||||
|
1. Preferred: stage replacements and spawn a small PowerShell or `cmd` helper that waits for the current process to exit, moves files into place, and writes a log.
|
||||||
|
2. Fallback: stage replacements and print exact manual copy commands if helper launch is unavailable.
|
||||||
|
|
||||||
|
Document any Windows limitation honestly in `docs/platforms.md` and `docs/troubleshooting.md`.
|
||||||
|
|
||||||
|
### Output and error style
|
||||||
|
|
||||||
|
Keep output concise and user-facing:
|
||||||
|
|
||||||
|
- Show current version, target version, install directory, selected mode, and asset/source name before changing files.
|
||||||
|
- Show clear phase lines for download, verify, build, install, and final verification.
|
||||||
|
- On failure, say whether anything was changed and where staging/backups are located.
|
||||||
|
- If update cannot proceed because the install path is not writable, tell the user which path failed and suggest reinstalling through the same method they originally used.
|
||||||
|
|
||||||
|
Avoid dumping raw GitHub JSON, backtraces, or Cargo logs unless the source build fails; in that case, preserve the final relevant Cargo output and staging path.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
Likely additions to `Cargo.toml`:
|
||||||
|
|
||||||
|
- `semver` for version comparison.
|
||||||
|
- `sha2` for SHA-256 verification.
|
||||||
|
- `tar` and `flate2` for `.tar.gz` extraction.
|
||||||
|
- `zip` for Windows release archives and GitHub source zips if used.
|
||||||
|
|
||||||
|
Prefer small, well-maintained crates. Reuse existing `reqwest`, `tokio`, `serde`, and `serde_json`.
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
|
||||||
|
1. Add CLI parsing for `cass update` and dispatch it before setup/config loading.
|
||||||
|
2. Add `src/update.rs` with release API types, version comparison, and target detection.
|
||||||
|
3. Implement GitHub release fetching with a testable client abstraction or injectable base URL for tests.
|
||||||
|
4. Implement asset selection for current platform and update mode.
|
||||||
|
5. Implement download, progress reporting, and checksum verification for prebuilt archives.
|
||||||
|
6. Implement safe archive extraction and staged binary validation.
|
||||||
|
7. Implement install planning from `current_exe()` and companion binary detection.
|
||||||
|
8. Implement Unix replacement with backups and rollback.
|
||||||
|
9. Implement Windows staged-helper replacement or a clearly documented manual fallback.
|
||||||
|
10. Implement source mode: source download, version validation, prerequisite checks, `cargo build --release --locked --bins`, and staged binary validation.
|
||||||
|
11. Polish interactive prompts and `--check`, `--dry-run`, `--yes`, `--prebuilt`, `--source`, and `--to` behavior.
|
||||||
|
12. Update docs and release notes template expectations if needed.
|
||||||
|
13. Add tests and run full verification.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Add focused unit tests for:
|
||||||
|
|
||||||
|
- parsing `vX.Y.Z` tags and comparing against the current version shape
|
||||||
|
- ignoring drafts and prereleases where applicable
|
||||||
|
- mapping supported and unsupported platform targets
|
||||||
|
- matching asset and checksum filenames
|
||||||
|
- parsing `.sha256` lines generated by the release process
|
||||||
|
- rejecting checksum filename mismatches and digest mismatches
|
||||||
|
- rejecting archive path traversal entries
|
||||||
|
- planning installation when only `cass`, only `cassady`, or both binaries exist
|
||||||
|
- refusing non-writable install targets in planning or dry-run mode
|
||||||
|
- source mode validating that `Cargo.toml` version matches the selected tag
|
||||||
|
|
||||||
|
Add integration-style tests with a mock HTTP server for:
|
||||||
|
|
||||||
|
- already-up-to-date response
|
||||||
|
- latest prebuilt update plan
|
||||||
|
- missing prebuilt with source fallback prompt path, where build execution can be mocked
|
||||||
|
- download checksum mismatch failure
|
||||||
|
- successful staged install into a temporary directory using fake binaries
|
||||||
|
|
||||||
|
Manual checks:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo fmt
|
||||||
|
cargo test --locked --all-targets
|
||||||
|
cargo run -- update --check
|
||||||
|
cargo run -- update --dry-run --to v0.2.7
|
||||||
|
```
|
||||||
|
|
||||||
|
For a real release candidate, test from a temporary install directory before using `cass update` on the developer's normal binary.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Update:
|
||||||
|
|
||||||
|
- `README.md`: mention `cass update` in install/update and everyday command sections.
|
||||||
|
- `docs/commands.md`: full command reference, flags, interactivity, examples, and exit behavior.
|
||||||
|
- `docs/platforms.md`: platform-specific update support and Windows replacement notes.
|
||||||
|
- `docs/troubleshooting.md`: network failures, checksum mismatch, no matching prebuilt, missing Rust toolchain, non-writable install directory, PATH conflicts, and rollback recovery.
|
||||||
|
- `docs/README.md`: add any new update-related links or summaries.
|
||||||
|
|
||||||
|
Document that users should prefer the package manager's update mechanism if Cassady was installed through a package manager in the future.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- `cass update --check` reports the current/latest release without reading provider config.
|
||||||
|
- `cass update --dry-run` shows the selected release, mode, asset/source, and install plan without modifying files.
|
||||||
|
- On supported release targets, `cass update` can download the matching official archive, verify SHA-256, stage both binaries, and update the current install directory.
|
||||||
|
- `cass update --source` can download release source, build with `cargo build --release --locked --bins`, and install the resulting local binaries.
|
||||||
|
- Checksum mismatch, missing assets, unsupported platforms, missing Rust toolchain, and non-writable install paths fail with clear messages and no partial install.
|
||||||
|
- Existing `cass` and `cassady` sibling binaries remain version-aligned after a successful update.
|
||||||
|
- README and bundled docs explain the command accurately.
|
||||||
|
- `cargo fmt` and `cargo test --locked --all-targets` pass.
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
# v0.2.8 Conversation Branch and Restore Implementation Plan
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
v0.2.8 adds an in-chat branch and restore menu opened by pressing `Esc` twice while Cassady is idle. Users should be able to browse the current conversation timeline, choose a checkpoint at a user message, assistant message, or tool call, and branch from that point without destroying the original conversation. They can optionally restore Cassady-tracked file edits to match the selected checkpoint, or branch the conversation only.
|
||||||
|
|
||||||
|
Success statement:
|
||||||
|
|
||||||
|
> A user can press `Esc` twice, select an earlier message or tool call, create a new branch from that point, optionally restore tracked file edits, and later open the same menu from either branch to switch or branch again from the related conversation history.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### In scope
|
||||||
|
|
||||||
|
- Add a double-`Esc` idle shortcut that opens a branch/restore menu, similar in feel to the double-`Ctrl-C` exit affordance.
|
||||||
|
- Keep the existing busy `Esc` behavior for turn cancellation and approval denial.
|
||||||
|
- Add a branch-aware conversation model that creates a new conversation file when restoring to a checkpoint instead of truncating or overwriting the original chat.
|
||||||
|
- Let users browse checkpoints for:
|
||||||
|
- user messages,
|
||||||
|
- assistant messages,
|
||||||
|
- assistant tool-call requests,
|
||||||
|
- completed tool results.
|
||||||
|
- Preserve valid model conversation structure when branching at or around tool calls.
|
||||||
|
- Track file mutations made by Cassady's `write` and `edit` tools with enough before/after data to restore workspace files backward or forward between tracked checkpoints.
|
||||||
|
- Offer restore actions that clearly separate conversation-only branching from conversation-plus-file restoration.
|
||||||
|
- Allow users to return to the original conversation or other related branches by opening the menu again.
|
||||||
|
- Add tests for branch metadata, checkpoint extraction, valid tool-call repair, file-edit journaling, workspace restore planning, conflict detection, and keybinding behavior where practical.
|
||||||
|
- Update README and bundled docs for the new shortcut, branch semantics, file-restore limitations, and safety prompts.
|
||||||
|
|
||||||
|
### Out of scope
|
||||||
|
|
||||||
|
- Rewriting arbitrary filesystem changes made by shell commands, editors, package managers, test runners, or the user outside Cassady's `write`/`edit` tools.
|
||||||
|
- Git integration, commits, worktrees, or automatic VCS operations.
|
||||||
|
- A visual diff editor for every file restore. v0.2.8 should show a concise restore plan and rely on safe conflict checks.
|
||||||
|
- Merging branches or replaying assistant responses across branches.
|
||||||
|
- Branching while an agent turn is running.
|
||||||
|
- Changing provider message semantics beyond the minimum repair needed for valid branched conversations.
|
||||||
|
|
||||||
|
## Context and Current State
|
||||||
|
|
||||||
|
Relevant files and behavior:
|
||||||
|
|
||||||
|
- `src/app.rs` owns the TUI event loop, current conversation, transcript blocks, double-`Ctrl-C` exit behavior, busy `Esc` cancellation, local `/new` and `/resume` commands, and turn spawning.
|
||||||
|
- `src/conversation.rs` stores conversations as append-only JSONL with `Meta`, `System`, `User`, `Assistant`, and `Tool` records. There is no branch metadata, checkpoint API, or rewrite/create-from-prefix helper yet.
|
||||||
|
- `src/agent.rs` appends user, assistant, and tool records during a turn. Assistant records can contain multiple tool calls, while each tool result is a separate `Record::Tool`.
|
||||||
|
- `src/tools/edit.rs` and `src/tools/write.rs` perform atomic writes and return user-visible summaries/diffs, but they do not persist before/after snapshots that can be used for later restore.
|
||||||
|
- `src/ui/render.rs` renders the main chat. A branch menu should be integrated as an in-TUI modal or state, not by dropping into the setup/update prompt menu in `src/menu.rs`.
|
||||||
|
|
||||||
|
The key design constraint is that restore must not mean destructive truncation. Selecting an old point creates a new branch conversation and leaves the source conversation available.
|
||||||
|
|
||||||
|
## Design Principles
|
||||||
|
|
||||||
|
1. **Branch, do not erase.** Restoring conversation state always creates or switches to a conversation branch; the original JSONL file remains intact.
|
||||||
|
2. **Make file restore explicit.** Conversation branching is safe and default. File restoration is a separate confirmation because it changes the workspace.
|
||||||
|
3. **Keep model history valid.** Branches created at tool boundaries must not leave assistant tool calls without corresponding tool records.
|
||||||
|
4. **Track only what Cassady can prove.** File restore uses durable snapshots from `write`/`edit`; unsupported shell/user changes are detected or warned about, not guessed.
|
||||||
|
5. **Recoverable navigation.** Every branch keeps parent/checkpoint metadata so the menu can show the related branch family and let users switch or branch again.
|
||||||
|
6. **Small, testable modules.** Put checkpoint extraction, branch creation, and file restore planning in dedicated modules rather than expanding the TUI loop with business logic.
|
||||||
|
|
||||||
|
## User Experience
|
||||||
|
|
||||||
|
### Shortcut behavior
|
||||||
|
|
||||||
|
- While idle, first `Esc` sets status text:
|
||||||
|
|
||||||
|
```text
|
||||||
|
press Esc again within 1.5s to branch or restore
|
||||||
|
```
|
||||||
|
|
||||||
|
- A second `Esc` within the same window opens the branch/restore menu.
|
||||||
|
- If the input box is non-empty, do not discard it silently. The first `Esc` should keep the input and show the same status; opening the menu should preserve the draft input if the user cancels.
|
||||||
|
- While an agent turn is running, keep the current behavior: `Esc` cancels the active turn. Do not open the branch menu while busy.
|
||||||
|
- During approval prompts, keep `Esc` as denial for the approval request.
|
||||||
|
|
||||||
|
### Main branch menu
|
||||||
|
|
||||||
|
The menu should show the current branch family, not only the current JSONL prefix:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Branch / Restore
|
||||||
|
|
||||||
|
Current chat: 2026-06-25-101533-abcd
|
||||||
|
|
||||||
|
Branches
|
||||||
|
• current branch
|
||||||
|
• original chat from before restore
|
||||||
|
• earlier branch: "try without refactor"
|
||||||
|
|
||||||
|
Timeline
|
||||||
|
1. user Add tests for config loading
|
||||||
|
2. assistant Proposed plan
|
||||||
|
3. tool read src/config.rs ✓
|
||||||
|
4. assistant Found config parser
|
||||||
|
5. tool edit src/config.rs ✓ file: src/config.rs
|
||||||
|
6. user Make it cleaner
|
||||||
|
```
|
||||||
|
|
||||||
|
Keyboard controls should be consistent with the main TUI: up/down or `j`/`k` move, Enter selects, `Esc` cancels, and an optional `/` filter can be deferred unless cheap.
|
||||||
|
|
||||||
|
### Checkpoint actions
|
||||||
|
|
||||||
|
After selecting a checkpoint, show an action menu:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Branch from checkpoint
|
||||||
|
|
||||||
|
Selected: tool edit src/config.rs at 10:24:11
|
||||||
|
|
||||||
|
1. Branch conversation only
|
||||||
|
2. Branch conversation and restore tracked file edits
|
||||||
|
3. Preview tracked file restore plan
|
||||||
|
4. Cancel
|
||||||
|
```
|
||||||
|
|
||||||
|
Default should be conversation-only. The branch should get a fresh chat id, copy records through the selected checkpoint, and append branch metadata. The status should make the branch explicit:
|
||||||
|
|
||||||
|
```text
|
||||||
|
branched 2026-06-25-110212-wxyz from 2026-06-25-101533-abcd at tool edit src/config.rs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Switching among related branches
|
||||||
|
|
||||||
|
Opening the menu from a branch should show its ancestors and descendants. Users can switch back to an existing branch without creating another branch:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Switch to branch
|
||||||
|
|
||||||
|
original 2026-06-25-101533-abcd 18 records
|
||||||
|
current 2026-06-25-110212-wxyz branched at tool edit src/config.rs
|
||||||
|
```
|
||||||
|
|
||||||
|
Switching branch changes the active conversation/transcript only. It should not change files unless the user explicitly chooses a file restore action.
|
||||||
|
|
||||||
|
### File restore safety
|
||||||
|
|
||||||
|
When the user chooses file restoration, show a concise plan before writing:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Restore tracked file edits to selected checkpoint?
|
||||||
|
|
||||||
|
Will update:
|
||||||
|
src/config.rs current hash matches Cassady snapshot
|
||||||
|
src/app.rs current hash differs; requires confirmation or skip
|
||||||
|
|
||||||
|
Will delete:
|
||||||
|
src/generated.rs created after the checkpoint by Cassady write
|
||||||
|
|
||||||
|
Not tracked:
|
||||||
|
shell command outputs and manual edits cannot be restored automatically
|
||||||
|
|
||||||
|
Proceed? [y/N]
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- If the current file hash matches the expected tracked hash, restore automatically after confirmation.
|
||||||
|
- If the file changed outside Cassady since the relevant snapshot, mark it as a conflict and default to skipping or cancelling the whole restore.
|
||||||
|
- For files that did not exist at the target checkpoint, delete only if the current content hash matches the tracked created-file hash.
|
||||||
|
- Never overwrite unknown current content without an explicit conflict confirmation.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Conversation branch metadata
|
||||||
|
|
||||||
|
Extend the conversation metadata in a backward-compatible way. One acceptable shape is adding optional fields to `Record::Meta` with `#[serde(default)]` and `skip_serializing_if`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
Record::Meta {
|
||||||
|
chat_id: String,
|
||||||
|
created_at: String,
|
||||||
|
model: String,
|
||||||
|
cwd: String,
|
||||||
|
parent_chat_id: Option<String>,
|
||||||
|
branch_from: Option<BranchPoint>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BranchPoint {
|
||||||
|
chat_id: String,
|
||||||
|
record_index: usize,
|
||||||
|
tool_call_id: Option<String>,
|
||||||
|
checkpoint_label: String,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Older conversations load with no parent. Descendants can be discovered by scanning `config.conversations_dir()` for `Meta.parent_chat_id` references.
|
||||||
|
|
||||||
|
Add a `conversation::create_branch(...)` helper that:
|
||||||
|
|
||||||
|
1. loads the source conversation,
|
||||||
|
2. computes a valid record prefix for the selected checkpoint,
|
||||||
|
3. writes a new JSONL file with a fresh chat id and branch metadata,
|
||||||
|
4. preserves the original `System` prompt and source metadata needed for branch navigation,
|
||||||
|
5. returns the new `Conversation` for the TUI to load immediately.
|
||||||
|
|
||||||
|
Do not truncate or rewrite the source conversation.
|
||||||
|
|
||||||
|
### Checkpoint extraction
|
||||||
|
|
||||||
|
Add a focused module such as `src/branch.rs` or `src/conversation_branch.rs` with types like:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
struct Checkpoint {
|
||||||
|
id: String,
|
||||||
|
chat_id: String,
|
||||||
|
record_index: usize,
|
||||||
|
tool_call_id: Option<String>,
|
||||||
|
kind: CheckpointKind,
|
||||||
|
label: String,
|
||||||
|
detail: String,
|
||||||
|
ts: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CheckpointKind {
|
||||||
|
User,
|
||||||
|
Assistant,
|
||||||
|
ToolCall,
|
||||||
|
ToolResult,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Checkpoint rules:
|
||||||
|
|
||||||
|
- A user checkpoint means the branch includes that user record.
|
||||||
|
- An assistant checkpoint means the branch includes that assistant record. If the assistant requested tools, the branch helper must repair or omit incomplete tool-call state before the next provider turn.
|
||||||
|
- A tool-result checkpoint means the branch includes records through that tool result.
|
||||||
|
- A tool-call checkpoint without a completed result should branch to the state immediately before executing that tool call, represented by an assistant record plus synthetic denied/cancelled tool records for any required missing calls.
|
||||||
|
|
||||||
|
Because OpenAI-compatible providers require every assistant tool call to receive a tool message before the next user message, branch creation must repair partial tool-call groups. Reuse or generalize the existing cancellation repair behavior in `src/app.rs` (`finalize_cancelled_turn` and pending tool-call handling) so branched conversations remain valid.
|
||||||
|
|
||||||
|
### File edit journal
|
||||||
|
|
||||||
|
Add a durable edit journal separate from model-visible conversation records, for example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
~/.cass/file-edits/<chat_id>.jsonl
|
||||||
|
~/.cass/file-snapshots/<chat_id>/<tool_call_id>/<hash>.bin
|
||||||
|
```
|
||||||
|
|
||||||
|
Journal entries should be written only for successful `write` and `edit` tool calls:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
struct FileEditJournalEntry {
|
||||||
|
chat_id: String,
|
||||||
|
record_index: usize,
|
||||||
|
tool_call_id: String,
|
||||||
|
tool_name: String, // write | edit
|
||||||
|
path: PathBuf,
|
||||||
|
existed_before: bool,
|
||||||
|
existed_after: bool,
|
||||||
|
before_hash: Option<String>,
|
||||||
|
after_hash: Option<String>,
|
||||||
|
before_snapshot: Option<PathBuf>,
|
||||||
|
after_snapshot: Option<PathBuf>,
|
||||||
|
ts: String,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Implementation approach:
|
||||||
|
|
||||||
|
- Add a `file_edits` module that can capture before/after bytes, hash them, store snapshots, append journal entries, and build restore plans.
|
||||||
|
- Pass chat id / record index / tool call id into tool execution context, or wrap `write`/`edit` execution in `agent.rs` so the agent captures before/after around successful file tools.
|
||||||
|
- Store full bytes, not just unified diffs, so restore works both backward and forward.
|
||||||
|
- Limit snapshots to regular files. If a path is a directory, symlink, binary too large, or otherwise unsafe, skip journaling and note that restore will not cover it.
|
||||||
|
|
||||||
|
The first implementation can treat text and binary bytes uniformly for snapshot storage, while still using existing `write`/`edit` tools for text operations.
|
||||||
|
|
||||||
|
### Restore planning
|
||||||
|
|
||||||
|
File restore should compute a target workspace state from the selected checkpoint and branch lineage:
|
||||||
|
|
||||||
|
1. Determine the selected checkpoint's branch lineage back to the root conversation.
|
||||||
|
2. Load file-edit journal entries along that lineage up to the checkpoint.
|
||||||
|
3. For every path touched by tracked edits in the relevant branch family, compute the desired state at the checkpoint:
|
||||||
|
- absent if no tracked edit existed before the checkpoint and the file was created later,
|
||||||
|
- the last `after_snapshot` at or before the checkpoint,
|
||||||
|
- the `before_snapshot` for paths whose first tracked edit happened after the checkpoint.
|
||||||
|
4. Compare the current workspace file hash to the journal's expected current hash when possible.
|
||||||
|
5. Produce a restore plan with actions: write snapshot, delete file, skip unsupported, conflict.
|
||||||
|
6. Apply only after explicit confirmation.
|
||||||
|
|
||||||
|
For v0.2.8, if cross-branch target-state computation becomes too large, keep the algorithm conservative: support full restore for the current branch's lineage and show a clear unsupported/conflict message for unrelated sibling states. The branch metadata should still be designed so broader cross-branch restore can be added later without changing saved data.
|
||||||
|
|
||||||
|
### TUI integration
|
||||||
|
|
||||||
|
Add branch-menu state to `run_tui` rather than invoking `src/menu.rs` inside the alternate-screen UI. Suggested approach:
|
||||||
|
|
||||||
|
- Add an enum such as `OverlayState::BranchMenu(BranchMenuState)` in `src/app.rs` or a new `src/ui/branch_menu.rs`.
|
||||||
|
- Extend `render::RenderState` to include an optional overlay.
|
||||||
|
- Render a centered modal with title, help text, visible items, selected row, and preview/detail panel.
|
||||||
|
- Route key events to the overlay first while it is open.
|
||||||
|
- On confirmed branch/switch/restore, update:
|
||||||
|
- `conversation`,
|
||||||
|
- `chat_id`,
|
||||||
|
- `transcript = transcript_from_loaded(...)`,
|
||||||
|
- active assistant/tool state,
|
||||||
|
- scroll/stick-to-bottom/status.
|
||||||
|
|
||||||
|
Keep the TUI loop readable by moving branch operations into functions such as:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
open_branch_menu(...)
|
||||||
|
handle_branch_menu_key(...)
|
||||||
|
apply_branch_action(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Slash command fallback
|
||||||
|
|
||||||
|
Optionally add a discoverable slash command such as `/branch` or `/restore` that opens the same menu. This is useful for users whose terminals send unusual `Esc` sequences. If added, document it as an alias for the menu rather than a separate workflow.
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
|
||||||
|
1. **Add branch metadata and helpers.** Extend `Record::Meta` compatibly, add branch point types, implement branch-family scanning and `create_branch` from a record prefix.
|
||||||
|
2. **Build checkpoint extraction.** Convert conversations into user/assistant/tool checkpoints with labels, previews, timestamps, and valid prefix calculations.
|
||||||
|
3. **Repair tool-call prefixes.** Generalize pending-tool-call repair so branches created around tool calls are valid for future provider requests.
|
||||||
|
4. **Add edit journaling.** Capture successful `write`/`edit` before/after snapshots, append a file-edit journal entry, and keep this separate from model-visible JSONL records.
|
||||||
|
5. **Implement restore planning.** Load journal entries, compute target states, detect conflicts by hash, and apply writes/deletes safely with existing atomic-write behavior.
|
||||||
|
6. **Add the in-TUI menu.** Implement double-`Esc` idle detection, overlay state, rendering, keyboard navigation, action confirmation, and branch/switch application.
|
||||||
|
7. **Wire status and recovery messages.** Make every branch, switch, restore, skip, and conflict result visible in the transcript or status line.
|
||||||
|
8. **Document the feature.** Update README and bundled docs with shortcut behavior, branch semantics, file restore coverage, and limitations around shell/manual edits.
|
||||||
|
9. **Test and polish.** Add unit/integration tests, run formatting, and verify the TUI manually in a small repository.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- `conversation` tests:
|
||||||
|
- old JSONL conversations without branch metadata still load,
|
||||||
|
- new branch metadata serializes/deserializes,
|
||||||
|
- `create_branch` leaves the source file unchanged,
|
||||||
|
- branch-family scanning finds ancestors and descendants.
|
||||||
|
- Checkpoint tests:
|
||||||
|
- user, assistant, tool-call, and tool-result checkpoints are extracted with stable labels,
|
||||||
|
- branching at a tool result keeps valid assistant/tool ordering,
|
||||||
|
- branching in the middle of multi-tool assistant output repairs missing tool results.
|
||||||
|
- File journal tests:
|
||||||
|
- `write` records absent-to-present and present-to-present snapshots,
|
||||||
|
- `edit` records before/after bytes for successful edits only,
|
||||||
|
- failed or denied tools do not create journal entries.
|
||||||
|
- Restore-plan tests:
|
||||||
|
- restore to an earlier checkpoint rewrites tracked files to prior content,
|
||||||
|
- restore to a later checkpoint can reapply tracked content from snapshots,
|
||||||
|
- created files are deleted only when hashes match,
|
||||||
|
- external modifications are reported as conflicts.
|
||||||
|
- TUI/key tests where practical:
|
||||||
|
- first idle `Esc` sets double-press status,
|
||||||
|
- second idle `Esc` opens the branch menu,
|
||||||
|
- busy `Esc` still cancels a turn,
|
||||||
|
- approval `Esc` still denies approval.
|
||||||
|
|
||||||
|
Manual checks:
|
||||||
|
|
||||||
|
- Start a chat, make a `write` edit, branch conversation-only from before the edit, confirm the original remains available.
|
||||||
|
- Open the menu from the branch and switch back to the original chat.
|
||||||
|
- Branch with file restore and verify the workspace file content matches the chosen checkpoint.
|
||||||
|
- Trigger a conflict by manually editing a tracked file before restore and confirm Cassady refuses to overwrite it by default.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Update:
|
||||||
|
|
||||||
|
- `README.md`: everyday workflow section for branching/restoring and a short safety note.
|
||||||
|
- `docs/commands.md` or the relevant TUI guide: double-`Esc`, optional `/branch`, and menu controls.
|
||||||
|
- `docs/troubleshooting.md`: conflicts, unsupported shell/manual edits, and how to switch back to the original branch.
|
||||||
|
- Any keyboard shortcut table maintained in bundled docs.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- Pressing `Esc` twice while idle opens a branch/restore menu.
|
||||||
|
- Selecting a user, assistant, or tool checkpoint creates a new branch conversation without modifying the source conversation.
|
||||||
|
- The branch menu can be opened from the new branch to switch back to the original or create another branch.
|
||||||
|
- Users can choose conversation-only branching or branch-plus-file restore.
|
||||||
|
- File restore covers successful Cassady `write`/`edit` mutations with before/after snapshots and refuses unsafe overwrites by default.
|
||||||
|
- Branches created around tool calls produce valid future model requests.
|
||||||
|
- Existing conversations remain loadable.
|
||||||
|
- `cargo fmt` and `cargo test --locked --all-targets` pass.
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# v0.2.9 Provider Login Management Implementation Plan
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
This release focuses on making provider configuration available from both the shell and an active Cassady chat. Users should be able to run `cass login` or type `/login` to configure one or more OpenAI-compatible providers, and use `cass logout` or `/logout` to remove saved providers and their model entries without hand-editing JSON files.
|
||||||
|
|
||||||
|
Success statement:
|
||||||
|
|
||||||
|
> A user can add, switch, and remove provider/model configuration from Cassady's normal command surfaces, then continue chatting with a valid active provider.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### In scope
|
||||||
|
|
||||||
|
- Add `cass login` as an alias-style command for the existing setup wizard.
|
||||||
|
- Add `/login` inside the TUI, available only while idle.
|
||||||
|
- Add `cass logout` with an interactive provider removal menu.
|
||||||
|
- Add `/logout` inside the TUI, available only while idle.
|
||||||
|
- Remove provider definitions and their associated `models.json` entries together.
|
||||||
|
- Update `config.json` active defaults after removal so they do not point at missing providers or models.
|
||||||
|
- Reload active config after login/logout inside the TUI.
|
||||||
|
- Document the new commands in bundled command/config docs.
|
||||||
|
- Add focused unit tests for provider removal and local command parsing/autocomplete.
|
||||||
|
|
||||||
|
### Out of scope
|
||||||
|
|
||||||
|
- Browser OAuth or provider-hosted account login flows.
|
||||||
|
- Storing literal API keys from the wizard by default.
|
||||||
|
- Non-OpenAI-compatible provider protocols.
|
||||||
|
- Deleting shell environment variables or secrets outside `~/.cass`.
|
||||||
|
- Publishing, tagging, or preparing release artifacts.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Cassady already has most provider setup primitives:
|
||||||
|
|
||||||
|
- `src/setup.rs` contains the interactive provider/model setup wizard, provider catalog, model discovery, and JSON upsert helpers.
|
||||||
|
- `src/config.rs` owns `config.json`, `providers.json`, `models.json`, active provider/model resolution, and validation.
|
||||||
|
- `src/app.rs` owns top-level CLI dispatch plus in-chat slash command parsing and execution.
|
||||||
|
- `docs/commands.md`, `docs/configuration.md`, and `docs/workflows.md` document the existing `cass setup`, `cass check`, and `/model` behavior.
|
||||||
|
|
||||||
|
The existing setup wizard writes provider connection definitions to `providers.json`, model metadata to `models.json`, and active defaults to `config.json`. The new login command can reuse that flow. Logout needs a new inverse operation that edits all three files consistently.
|
||||||
|
|
||||||
|
## Design Principles
|
||||||
|
|
||||||
|
1. Reuse setup behavior instead of creating a second provider configuration path.
|
||||||
|
2. Keep removal explicit and reversible by avoiding broad file deletion and by preserving unrelated providers/models.
|
||||||
|
3. Never remove API keys from the user's shell or keychain; Cassady only edits its own config files.
|
||||||
|
4. Keep in-chat provider management idle-only, because active turns depend on a stable provider config.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### CLI commands
|
||||||
|
|
||||||
|
Add these subcommands:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cass login
|
||||||
|
cass logout
|
||||||
|
```
|
||||||
|
|
||||||
|
`cass login` runs the same interactive wizard as `cass setup`, with text that frames the action as adding or updating provider login configuration. It may start a chat afterward when invoked from an otherwise normal chat startup path only if the existing setup outcome says it should; direct `cass login` should save config and exit.
|
||||||
|
|
||||||
|
`cass logout` opens a multi-select menu of saved providers:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Remove saved providers
|
||||||
|
|
||||||
|
[ ] OpenAI openai · gpt-4.1
|
||||||
|
[ ] Groq groq · llama-3.3-70b-versatile
|
||||||
|
```
|
||||||
|
|
||||||
|
After confirmation, Cassady removes the selected providers from `providers.json` and removes `models.json` entries whose `provider` matches a removed provider id. If the active provider was removed, Cassady selects the first remaining provider and one of its models. If no providers remain, `default_provider`, `default_model`, and `default_reasoning_effort` are cleared so the next `cass` run offers setup.
|
||||||
|
|
||||||
|
### In-chat commands
|
||||||
|
|
||||||
|
Add slash commands:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/login
|
||||||
|
/logout
|
||||||
|
```
|
||||||
|
|
||||||
|
Both commands are idle-only. Because the TUI uses the alternate screen and raw input mode, command execution should temporarily leave the TUI, run the existing menu-driven flow in the normal terminal, reload config, then re-enter the TUI and append a status block.
|
||||||
|
|
||||||
|
After `/login`, reload `Config` from disk and keep the current conversation open. If the active provider/model changed, future turns use the new provider and model. The status block should show the active provider and model.
|
||||||
|
|
||||||
|
After `/logout`, reload `Config` when a provider remains. If no provider remains or config cannot resolve, keep the chat open but append a clear status/error telling the user to run `/login` before sending another turn.
|
||||||
|
|
||||||
|
### Provider/model removal helper
|
||||||
|
|
||||||
|
Add reusable setup/config helpers:
|
||||||
|
|
||||||
|
- `configured_providers(root) -> Vec<ProviderLogoutCandidate>`
|
||||||
|
- `remove_providers(root, provider_ids) -> LogoutResult`
|
||||||
|
|
||||||
|
`LogoutResult` should include removed provider ids, removed model count, remaining provider count, and the new active provider/model when one exists. This keeps CLI output, TUI status, and tests deterministic.
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
|
||||||
|
1. Add the v0.2.9 roadmap entry and this plan.
|
||||||
|
2. Add `Login` and `Logout` CLI variants and dispatch them from `app::run`.
|
||||||
|
3. Refactor setup mode text as needed so `cass login` can share the setup wizard.
|
||||||
|
4. Implement provider removal helpers in `src/setup.rs` using existing config structs.
|
||||||
|
5. Add menu-driven `setup::logout(root)` for CLI and TUI use.
|
||||||
|
6. Add `/login` and `/logout` to local command parsing, autocomplete, and idle command handling.
|
||||||
|
7. Add a small terminal leave/re-enter helper around blocking login/logout menus inside the TUI.
|
||||||
|
8. Update command/config/workflow docs.
|
||||||
|
9. Add focused tests for removal behavior and command parsing/autocomplete.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- Removing one provider preserves unrelated providers and models.
|
||||||
|
- Removing the active provider chooses a valid remaining provider/model.
|
||||||
|
- Removing all providers clears active defaults.
|
||||||
|
- Removing an unknown provider id is rejected.
|
||||||
|
- `/login` and `/logout` parse only with no arguments.
|
||||||
|
- Command autocomplete lists `/login` and `/logout`.
|
||||||
|
- `cargo fmt` passes.
|
||||||
|
- `cargo test --locked --all-targets` passes when practical.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- Update `docs/commands.md` with `cass login`, `cass logout`, `/login`, and `/logout`.
|
||||||
|
- Update `docs/configuration.md` to point users toward login/logout for managed provider edits.
|
||||||
|
- Update `docs/workflows.md` with login/logout examples near model/provider workflows.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- `cass login` opens the provider setup wizard and exits after saving direct login changes.
|
||||||
|
- `cass logout` removes selected providers and their models with confirmation.
|
||||||
|
- `/login` and `/logout` work from an idle chat and reload active provider config afterward.
|
||||||
|
- Removing the active provider never leaves `config.json` pointing at a missing provider/model.
|
||||||
|
- Existing `cass setup`, first-run setup, `cass check`, and `/model` behavior still work.
|
||||||
|
- `cargo fmt` and `cargo test --locked --all-targets` pass.
|
||||||
+15
-1
@@ -304,10 +304,19 @@ pub async fn run_turn_with_commands(
|
|||||||
let (runtime_tx, mut runtime_rx) = mpsc::unbounded_channel::<ToolRuntimeEvent>();
|
let (runtime_tx, mut runtime_rx) = mpsc::unbounded_channel::<ToolRuntimeEvent>();
|
||||||
let mut call_tool_ctx = tool_ctx.clone();
|
let mut call_tool_ctx = tool_ctx.clone();
|
||||||
call_tool_ctx.runtime_tx = Some(runtime_tx);
|
call_tool_ctx.runtime_tx = Some(runtime_tx);
|
||||||
|
let file_edit_snapshot = crate::file_edits::begin_tool_edit(
|
||||||
|
&settings.config.root,
|
||||||
|
&conversation.id,
|
||||||
|
conversation.records.len(),
|
||||||
|
&call_id,
|
||||||
|
&call_name,
|
||||||
|
&call_arguments,
|
||||||
|
&call_tool_ctx,
|
||||||
|
);
|
||||||
let output = {
|
let output = {
|
||||||
let execute = tools::execute_with_approval(
|
let execute = tools::execute_with_approval(
|
||||||
&call_name,
|
&call_name,
|
||||||
call_arguments,
|
call_arguments.clone(),
|
||||||
&call_tool_ctx,
|
&call_tool_ctx,
|
||||||
approved,
|
approved,
|
||||||
);
|
);
|
||||||
@@ -325,6 +334,11 @@ pub async fn run_turn_with_commands(
|
|||||||
}
|
}
|
||||||
output
|
output
|
||||||
};
|
};
|
||||||
|
if output.ok {
|
||||||
|
if let Some(snapshot) = file_edit_snapshot {
|
||||||
|
let _ = crate::file_edits::finish_tool_edit(&settings.config.root, snapshot);
|
||||||
|
}
|
||||||
|
}
|
||||||
let _ = tx.send(AgentEvent::ToolResult {
|
let _ = tx.send(AgentEvent::ToolResult {
|
||||||
id: call_id.clone(),
|
id: call_id.clone(),
|
||||||
name: call_name.clone(),
|
name: call_name.clone(),
|
||||||
|
|||||||
+714
-3
@@ -1,5 +1,5 @@
|
|||||||
use crate::agent::{self, AgentCommand, AgentEvent, AgentSettings};
|
use crate::agent::{self, AgentCommand, AgentEvent, AgentSettings};
|
||||||
use crate::cli::{self, Command};
|
use crate::cli::{self, Cli, Command};
|
||||||
use crate::config::{self, Config, ModelDefinition, ReasoningEffort};
|
use crate::config::{self, Config, ModelDefinition, ReasoningEffort};
|
||||||
use crate::conversation::{self, Conversation, Record};
|
use crate::conversation::{self, Conversation, Record};
|
||||||
use crate::prompt;
|
use crate::prompt;
|
||||||
@@ -21,6 +21,10 @@ const TOOL_CANCELLED_MESSAGE: &str = "Tool execution cancelled by user.";
|
|||||||
|
|
||||||
pub async fn run() -> Result<()> {
|
pub async fn run() -> Result<()> {
|
||||||
let mut cli = cli::parse();
|
let mut cli = cli::parse();
|
||||||
|
if let Some(Command::Update(args)) = cli.command.clone() {
|
||||||
|
return crate::update::run(args).await;
|
||||||
|
}
|
||||||
|
|
||||||
if matches!(cli.command, Some(Command::Check)) {
|
if matches!(cli.command, Some(Command::Check)) {
|
||||||
let report = crate::check::run(&cli)?;
|
let report = crate::check::run(&cli)?;
|
||||||
print!("{}", report.render());
|
print!("{}", report.render());
|
||||||
@@ -30,6 +34,16 @@ pub async fn run() -> Result<()> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if matches!(cli.command, Some(Command::Login)) {
|
||||||
|
let _ = crate::setup::run(&cli, crate::setup::SetupMode::Login).await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches!(cli.command, Some(Command::Logout)) {
|
||||||
|
let _ = crate::setup::logout(&config::cass_root())?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
if matches!(cli.command, Some(Command::Setup)) {
|
if matches!(cli.command, Some(Command::Setup)) {
|
||||||
let outcome = crate::setup::run(&cli, crate::setup::SetupMode::Explicit).await?;
|
let outcome = crate::setup::run(&cli, crate::setup::SetupMode::Explicit).await?;
|
||||||
if !outcome.start_session {
|
if !outcome.start_session {
|
||||||
@@ -80,7 +94,7 @@ pub async fn run() -> Result<()> {
|
|||||||
(create_new_conversation(&config, &cwd)?, None)
|
(create_new_conversation(&config, &cwd)?, None)
|
||||||
};
|
};
|
||||||
|
|
||||||
run_tui(config, cwd, conversation, warning).await
|
run_tui(config, cwd, conversation, warning, cli).await
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_cwd(cwd: Option<PathBuf>) -> Result<PathBuf> {
|
fn resolve_cwd(cwd: Option<PathBuf>) -> Result<PathBuf> {
|
||||||
@@ -113,6 +127,40 @@ fn list_chats(config: &Config, cwd: &std::path::Path) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn run_login_menu_from_tui(
|
||||||
|
terminal: &mut terminal::CassTerminal,
|
||||||
|
cli: &Cli,
|
||||||
|
) -> Result<crate::setup::SetupOutcome> {
|
||||||
|
terminal::suspend(terminal)?;
|
||||||
|
let result = crate::setup::run(cli, crate::setup::SetupMode::Login).await;
|
||||||
|
let resume_result = terminal::resume(terminal);
|
||||||
|
match (result, resume_result) {
|
||||||
|
(Ok(outcome), Ok(())) => Ok(outcome),
|
||||||
|
(Err(err), Ok(())) => Err(err),
|
||||||
|
(Ok(_), Err(err)) => Err(err),
|
||||||
|
(Err(err), Err(resume_err)) => Err(err.context(format!(
|
||||||
|
"also failed to restore the chat screen: {resume_err}"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_logout_menu_from_tui(
|
||||||
|
terminal: &mut terminal::CassTerminal,
|
||||||
|
root: &Path,
|
||||||
|
) -> Result<crate::setup::LogoutResult> {
|
||||||
|
terminal::suspend(terminal)?;
|
||||||
|
let result = crate::setup::logout(root);
|
||||||
|
let resume_result = terminal::resume(terminal);
|
||||||
|
match (result, resume_result) {
|
||||||
|
(Ok(outcome), Ok(())) => Ok(outcome),
|
||||||
|
(Err(err), Ok(())) => Err(err),
|
||||||
|
(Ok(_), Err(err)) => Err(err),
|
||||||
|
(Err(err), Err(resume_err)) => Err(err.context(format!(
|
||||||
|
"also failed to restore the chat screen: {resume_err}"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn finalize_cancelled_turn(
|
fn finalize_cancelled_turn(
|
||||||
config: &Config,
|
config: &Config,
|
||||||
chat_id: &str,
|
chat_id: &str,
|
||||||
@@ -182,6 +230,7 @@ async fn run_tui(
|
|||||||
cwd: PathBuf,
|
cwd: PathBuf,
|
||||||
mut conversation: Conversation,
|
mut conversation: Conversation,
|
||||||
warning: Option<String>,
|
warning: Option<String>,
|
||||||
|
cli: Cli,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let mut terminal = terminal::enter()?;
|
let mut terminal = terminal::enter()?;
|
||||||
let mut transcript = Vec::new();
|
let mut transcript = Vec::new();
|
||||||
@@ -204,6 +253,8 @@ async fn run_tui(
|
|||||||
let mut reasoning_effort = config.reasoning_effort;
|
let mut reasoning_effort = config.reasoning_effort;
|
||||||
let mut scroll: u16 = 0;
|
let mut scroll: u16 = 0;
|
||||||
let mut last_ctrl_c: Option<Instant> = None;
|
let mut last_ctrl_c: Option<Instant> = None;
|
||||||
|
let mut last_esc: Option<Instant> = None;
|
||||||
|
let mut branch_menu: Option<BranchMenuState> = None;
|
||||||
let mut handle: Option<JoinHandle<Result<Conversation>>> = None;
|
let mut handle: Option<JoinHandle<Result<Conversation>>> = None;
|
||||||
let mut cancel_requested = false;
|
let mut cancel_requested = false;
|
||||||
let mut current_turn_start_len: Option<usize> = None;
|
let mut current_turn_start_len: Option<usize> = None;
|
||||||
@@ -215,6 +266,7 @@ async fn run_tui(
|
|||||||
let mut chat_id = conversation.id.clone();
|
let mut chat_id = conversation.id.clone();
|
||||||
let mut autofill_selected = 0usize;
|
let mut autofill_selected = 0usize;
|
||||||
let mut pending_approval: Option<PendingApproval> = None;
|
let mut pending_approval: Option<PendingApproval> = None;
|
||||||
|
let mut provider_ready = true;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
drain_agent_events(
|
drain_agent_events(
|
||||||
@@ -328,6 +380,7 @@ async fn run_tui(
|
|||||||
)?
|
)?
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let overlay_view = branch_menu.as_ref().map(BranchMenuState::overlay_view);
|
||||||
terminal.draw(|f| {
|
terminal.draw(|f| {
|
||||||
render::render(
|
render::render(
|
||||||
f,
|
f,
|
||||||
@@ -345,7 +398,12 @@ async fn run_tui(
|
|||||||
show_reasoning,
|
show_reasoning,
|
||||||
reasoning_effort,
|
reasoning_effort,
|
||||||
scroll,
|
scroll,
|
||||||
autofill: autofill.as_ref(),
|
autofill: if branch_menu.is_some() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
autofill.as_ref()
|
||||||
|
},
|
||||||
|
overlay: overlay_view.as_ref(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
@@ -354,6 +412,43 @@ async fn run_tui(
|
|||||||
match event {
|
match event {
|
||||||
Event::Key(key) if key.kind == KeyEventKind::Press => {
|
Event::Key(key) if key.kind == KeyEventKind::Press => {
|
||||||
let busy = handle.is_some();
|
let busy = handle.is_some();
|
||||||
|
if branch_menu.is_some() {
|
||||||
|
match handle_branch_menu_key(
|
||||||
|
key.code,
|
||||||
|
&mut branch_menu,
|
||||||
|
&config,
|
||||||
|
&cwd,
|
||||||
|
&mut conversation,
|
||||||
|
&mut chat_id,
|
||||||
|
&mut transcript,
|
||||||
|
&mut active_assistant,
|
||||||
|
&mut active_reasoning,
|
||||||
|
&mut active_tools,
|
||||||
|
&mut status,
|
||||||
|
) {
|
||||||
|
Ok(BranchMenuOutcome::None) => {}
|
||||||
|
Ok(BranchMenuOutcome::Changed) => {
|
||||||
|
stick_to_bottom = true;
|
||||||
|
scroll = bottom_scroll(
|
||||||
|
&terminal,
|
||||||
|
&input,
|
||||||
|
&transcript,
|
||||||
|
show_full_tools,
|
||||||
|
show_reasoning,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
branch_menu = None;
|
||||||
|
status = format!("branch menu failed: {err}");
|
||||||
|
transcript.push(TranscriptBlock {
|
||||||
|
kind: TranscriptKind::Error,
|
||||||
|
title: "branch".into(),
|
||||||
|
content: err.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if busy {
|
if busy {
|
||||||
if let Some(pending) = pending_approval.clone() {
|
if let Some(pending) = pending_approval.clone() {
|
||||||
match key.code {
|
match key.code {
|
||||||
@@ -425,11 +520,13 @@ async fn run_tui(
|
|||||||
}
|
}
|
||||||
cancel_requested = true;
|
cancel_requested = true;
|
||||||
last_ctrl_c = Some(now);
|
last_ctrl_c = Some(now);
|
||||||
|
last_esc = None;
|
||||||
status = "turn cancellation requested; press Ctrl-C again within 1.5s to exit".into();
|
status = "turn cancellation requested; press Ctrl-C again within 1.5s to exit".into();
|
||||||
} else {
|
} else {
|
||||||
input.clear();
|
input.clear();
|
||||||
autofill_selected = 0;
|
autofill_selected = 0;
|
||||||
last_ctrl_c = Some(now);
|
last_ctrl_c = Some(now);
|
||||||
|
last_esc = None;
|
||||||
status = "press Ctrl-C again within 1.5s to exit".into();
|
status = "press Ctrl-C again within 1.5s to exit".into();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -439,8 +536,31 @@ async fn run_tui(
|
|||||||
}
|
}
|
||||||
cancel_requested = true;
|
cancel_requested = true;
|
||||||
last_ctrl_c = None;
|
last_ctrl_c = None;
|
||||||
|
last_esc = None;
|
||||||
status = "turn cancellation requested".into();
|
status = "turn cancellation requested".into();
|
||||||
}
|
}
|
||||||
|
(KeyCode::Esc, _) => {
|
||||||
|
let now = Instant::now();
|
||||||
|
if last_esc
|
||||||
|
.map(|t| now.duration_since(t) <= Duration::from_millis(1500))
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
match BranchMenuState::open(&config, &conversation) {
|
||||||
|
Ok(menu) => {
|
||||||
|
branch_menu = Some(menu);
|
||||||
|
last_esc = None;
|
||||||
|
status = "branch/restore menu".into();
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
last_esc = None;
|
||||||
|
status = format!("branch menu failed: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
last_esc = Some(now);
|
||||||
|
status = "press Esc again within 1.5s to branch or restore".into();
|
||||||
|
}
|
||||||
|
}
|
||||||
(KeyCode::BackTab, _) => {
|
(KeyCode::BackTab, _) => {
|
||||||
if busy {
|
if busy {
|
||||||
status = "mode can be changed when idle".into();
|
status = "mode can be changed when idle".into();
|
||||||
@@ -576,6 +696,172 @@ async fn run_tui(
|
|||||||
input.clear();
|
input.clear();
|
||||||
} else if input.trim_start().starts_with('/') {
|
} else if input.trim_start().starts_with('/') {
|
||||||
match parse_local_command(&input) {
|
match parse_local_command(&input) {
|
||||||
|
Ok(LocalCommand::Branch) => {
|
||||||
|
if busy {
|
||||||
|
status = "branch menu can be opened when idle".into();
|
||||||
|
} else {
|
||||||
|
match BranchMenuState::open(&config, &conversation) {
|
||||||
|
Ok(menu) => {
|
||||||
|
branch_menu = Some(menu);
|
||||||
|
input.clear();
|
||||||
|
autofill_selected = 0;
|
||||||
|
status = "branch/restore menu".into();
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
status = format!("branch menu failed: {err}");
|
||||||
|
transcript.push(TranscriptBlock {
|
||||||
|
kind: TranscriptKind::Error,
|
||||||
|
title: "branch".into(),
|
||||||
|
content: err.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(LocalCommand::Login) => {
|
||||||
|
if busy {
|
||||||
|
status = "login can be opened when idle".into();
|
||||||
|
} else {
|
||||||
|
input.clear();
|
||||||
|
autofill_selected = 0;
|
||||||
|
match run_login_menu_from_tui(&mut terminal, &cli).await
|
||||||
|
{
|
||||||
|
Ok(_) => match Config::load(&cli) {
|
||||||
|
Ok(updated) => {
|
||||||
|
config = updated;
|
||||||
|
reasoning_effort = config.reasoning_effort;
|
||||||
|
provider_ready = true;
|
||||||
|
let content = format!(
|
||||||
|
"active provider: {}\nactive model: {}",
|
||||||
|
config.provider_id, config.model
|
||||||
|
);
|
||||||
|
transcript.push(TranscriptBlock {
|
||||||
|
kind: TranscriptKind::Status,
|
||||||
|
title: "login".into(),
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
status = "login updated".into();
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
provider_ready = false;
|
||||||
|
status = format!(
|
||||||
|
"login saved with issues: {err}"
|
||||||
|
);
|
||||||
|
transcript.push(TranscriptBlock {
|
||||||
|
kind: TranscriptKind::Error,
|
||||||
|
title: "login".into(),
|
||||||
|
content: format!(
|
||||||
|
"Provider config could not be loaded: {err}"
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(err) => {
|
||||||
|
status = format!("login cancelled: {err}");
|
||||||
|
transcript.push(TranscriptBlock {
|
||||||
|
kind: TranscriptKind::Error,
|
||||||
|
title: "login".into(),
|
||||||
|
content: err.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stick_to_bottom {
|
||||||
|
scroll = bottom_scroll(
|
||||||
|
&terminal,
|
||||||
|
&input,
|
||||||
|
&transcript,
|
||||||
|
show_full_tools,
|
||||||
|
show_reasoning,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(LocalCommand::Logout) => {
|
||||||
|
if busy {
|
||||||
|
status = "logout can be opened when idle".into();
|
||||||
|
} else {
|
||||||
|
input.clear();
|
||||||
|
autofill_selected = 0;
|
||||||
|
match run_logout_menu_from_tui(
|
||||||
|
&mut terminal,
|
||||||
|
&config.root,
|
||||||
|
) {
|
||||||
|
Ok(result) => {
|
||||||
|
if result.removed_provider_ids.is_empty() {
|
||||||
|
transcript.push(TranscriptBlock {
|
||||||
|
kind: TranscriptKind::Status,
|
||||||
|
title: "logout".into(),
|
||||||
|
content: "logout cancelled".into(),
|
||||||
|
});
|
||||||
|
status = "logout cancelled".into();
|
||||||
|
} else if result.remaining_provider_count == 0 {
|
||||||
|
provider_ready = false;
|
||||||
|
transcript.push(TranscriptBlock {
|
||||||
|
kind: TranscriptKind::Status,
|
||||||
|
title: "logout".into(),
|
||||||
|
content: format!(
|
||||||
|
"removed providers: {}\nremoved model entries: {}\nno providers remain; run /login before sending another message",
|
||||||
|
result.removed_provider_ids.join(", "),
|
||||||
|
result.removed_model_count
|
||||||
|
),
|
||||||
|
});
|
||||||
|
status = "no provider configured".into();
|
||||||
|
} else {
|
||||||
|
match Config::load(&cli) {
|
||||||
|
Ok(updated) => {
|
||||||
|
config = updated;
|
||||||
|
reasoning_effort =
|
||||||
|
config.reasoning_effort;
|
||||||
|
provider_ready = true;
|
||||||
|
transcript.push(TranscriptBlock {
|
||||||
|
kind: TranscriptKind::Status,
|
||||||
|
title: "logout".into(),
|
||||||
|
content: format!(
|
||||||
|
"removed providers: {}\nremoved model entries: {}\nactive provider: {}\nactive model: {}",
|
||||||
|
result.removed_provider_ids.join(", "),
|
||||||
|
result.removed_model_count,
|
||||||
|
config.provider_id,
|
||||||
|
config.model
|
||||||
|
),
|
||||||
|
});
|
||||||
|
status = "logout updated".into();
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
provider_ready = false;
|
||||||
|
status = format!(
|
||||||
|
"logout saved with issues: {err}"
|
||||||
|
);
|
||||||
|
transcript.push(TranscriptBlock {
|
||||||
|
kind: TranscriptKind::Error,
|
||||||
|
title: "logout".into(),
|
||||||
|
content: format!(
|
||||||
|
"Provider config could not be loaded: {err}"
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
status = format!("logout cancelled: {err}");
|
||||||
|
transcript.push(TranscriptBlock {
|
||||||
|
kind: TranscriptKind::Error,
|
||||||
|
title: "logout".into(),
|
||||||
|
content: err.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stick_to_bottom {
|
||||||
|
scroll = bottom_scroll(
|
||||||
|
&terminal,
|
||||||
|
&input,
|
||||||
|
&transcript,
|
||||||
|
show_full_tools,
|
||||||
|
show_reasoning,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(LocalCommand::Status) => {
|
Ok(LocalCommand::Status) => {
|
||||||
let content = chat_status(
|
let content = chat_status(
|
||||||
&chat_id,
|
&chat_id,
|
||||||
@@ -738,6 +1024,24 @@ async fn run_tui(
|
|||||||
}
|
}
|
||||||
} else if busy {
|
} else if busy {
|
||||||
status = "agent is still running".into();
|
status = "agent is still running".into();
|
||||||
|
} else if !provider_ready {
|
||||||
|
status = "run /login before sending a message".into();
|
||||||
|
transcript.push(TranscriptBlock {
|
||||||
|
kind: TranscriptKind::Error,
|
||||||
|
title: "provider".into(),
|
||||||
|
content:
|
||||||
|
"No active provider is configured. Run /login before sending another message."
|
||||||
|
.into(),
|
||||||
|
});
|
||||||
|
if stick_to_bottom {
|
||||||
|
scroll = bottom_scroll(
|
||||||
|
&terminal,
|
||||||
|
&input,
|
||||||
|
&transcript,
|
||||||
|
show_full_tools,
|
||||||
|
show_reasoning,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
let msg = input.trim_end().to_string();
|
let msg = input.trim_end().to_string();
|
||||||
current_turn_start_len = Some(conversation.records.len());
|
current_turn_start_len = Some(conversation.records.len());
|
||||||
@@ -859,6 +1163,12 @@ async fn run_tui(
|
|||||||
{
|
{
|
||||||
last_ctrl_c = None;
|
last_ctrl_c = None;
|
||||||
}
|
}
|
||||||
|
if last_esc
|
||||||
|
.map(|t| t.elapsed() > Duration::from_millis(1500))
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
last_esc = None;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -868,6 +1178,337 @@ struct PendingApproval {
|
|||||||
block_index: usize,
|
block_index: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
enum BranchMenuMode {
|
||||||
|
Main,
|
||||||
|
Actions(crate::branch::Checkpoint),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct BranchMenuState {
|
||||||
|
mode: BranchMenuMode,
|
||||||
|
selected: usize,
|
||||||
|
family: crate::branch::BranchFamily,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
enum BranchMenuItem {
|
||||||
|
Switch(String),
|
||||||
|
Checkpoint(crate::branch::Checkpoint),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum BranchMenuOutcome {
|
||||||
|
None,
|
||||||
|
Changed,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BranchMenuState {
|
||||||
|
fn open(config: &Config, conversation: &Conversation) -> Result<Self> {
|
||||||
|
let family = crate::branch::load_family(&config.conversations_dir(), conversation)?;
|
||||||
|
Ok(Self {
|
||||||
|
mode: BranchMenuMode::Main,
|
||||||
|
selected: 0,
|
||||||
|
family,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn overlay_view(&self) -> render::OverlayView {
|
||||||
|
match &self.mode {
|
||||||
|
BranchMenuMode::Main => render::OverlayView {
|
||||||
|
title: "Branch / Restore".into(),
|
||||||
|
help: "Enter select · Esc cancel · ↑/↓ move".into(),
|
||||||
|
selected: self.selected,
|
||||||
|
items: self
|
||||||
|
.main_items()
|
||||||
|
.into_iter()
|
||||||
|
.map(|item| match item {
|
||||||
|
BranchMenuItem::Switch(id) => {
|
||||||
|
let branch = self.family.branches.iter().find(|b| b.id == id);
|
||||||
|
let mut label = if branch.is_some_and(|b| b.current) {
|
||||||
|
format!("current branch {id}")
|
||||||
|
} else {
|
||||||
|
format!("switch to {id}")
|
||||||
|
};
|
||||||
|
if branch.and_then(|b| b.parent_chat_id.as_ref()).is_none() {
|
||||||
|
label.push_str(" (root)");
|
||||||
|
}
|
||||||
|
render::OverlayItem {
|
||||||
|
label,
|
||||||
|
detail: branch.and_then(|b| b.branch_label.clone()).unwrap_or_else(
|
||||||
|
|| {
|
||||||
|
branch
|
||||||
|
.map(|b| format!("{} records", b.record_count))
|
||||||
|
.unwrap_or_default()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BranchMenuItem::Checkpoint(checkpoint) => render::OverlayItem {
|
||||||
|
label: format!("{} · {}", checkpoint.chat_id, checkpoint.label),
|
||||||
|
detail: checkpoint.detail,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
},
|
||||||
|
BranchMenuMode::Actions(checkpoint) => render::OverlayView {
|
||||||
|
title: "Branch from checkpoint".into(),
|
||||||
|
help: format!(
|
||||||
|
"{} · Enter select · Esc back",
|
||||||
|
crate::branch::checkpoint_title(checkpoint)
|
||||||
|
),
|
||||||
|
selected: self.selected,
|
||||||
|
items: vec![
|
||||||
|
render::OverlayItem {
|
||||||
|
label: "Branch conversation only".into(),
|
||||||
|
detail: "safe default; leaves files unchanged".into(),
|
||||||
|
},
|
||||||
|
render::OverlayItem {
|
||||||
|
label: "Branch conversation and restore tracked files".into(),
|
||||||
|
detail: "applies safe Cassady write/edit snapshots; conflicts are skipped"
|
||||||
|
.into(),
|
||||||
|
},
|
||||||
|
render::OverlayItem {
|
||||||
|
label: "Preview tracked file restore plan".into(),
|
||||||
|
detail: "show file actions in transcript".into(),
|
||||||
|
},
|
||||||
|
render::OverlayItem {
|
||||||
|
label: "Cancel".into(),
|
||||||
|
detail: String::new(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main_items(&self) -> Vec<BranchMenuItem> {
|
||||||
|
let mut items = Vec::new();
|
||||||
|
for branch in &self.family.branches {
|
||||||
|
items.push(BranchMenuItem::Switch(branch.id.clone()));
|
||||||
|
}
|
||||||
|
for checkpoint in &self.family.checkpoints {
|
||||||
|
items.push(BranchMenuItem::Checkpoint(checkpoint.clone()));
|
||||||
|
}
|
||||||
|
items
|
||||||
|
}
|
||||||
|
|
||||||
|
fn len(&self) -> usize {
|
||||||
|
match self.mode {
|
||||||
|
BranchMenuMode::Main => self.main_items().len(),
|
||||||
|
BranchMenuMode::Actions(_) => 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn handle_branch_menu_key(
|
||||||
|
code: KeyCode,
|
||||||
|
menu: &mut Option<BranchMenuState>,
|
||||||
|
config: &Config,
|
||||||
|
_cwd: &Path,
|
||||||
|
conversation: &mut Conversation,
|
||||||
|
chat_id: &mut String,
|
||||||
|
transcript: &mut Vec<TranscriptBlock>,
|
||||||
|
active_assistant: &mut Option<usize>,
|
||||||
|
active_reasoning: &mut Option<usize>,
|
||||||
|
active_tools: &mut HashMap<String, usize>,
|
||||||
|
status: &mut String,
|
||||||
|
) -> Result<BranchMenuOutcome> {
|
||||||
|
let Some(state) = menu.as_mut() else {
|
||||||
|
return Ok(BranchMenuOutcome::None);
|
||||||
|
};
|
||||||
|
match code {
|
||||||
|
KeyCode::Esc => match state.mode {
|
||||||
|
BranchMenuMode::Main => {
|
||||||
|
*menu = None;
|
||||||
|
*status = "branch menu cancelled".into();
|
||||||
|
}
|
||||||
|
BranchMenuMode::Actions(_) => {
|
||||||
|
state.mode = BranchMenuMode::Main;
|
||||||
|
state.selected = 0;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => {
|
||||||
|
state.selected = state.selected.saturating_sub(1);
|
||||||
|
}
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => {
|
||||||
|
let max = state.len().saturating_sub(1);
|
||||||
|
state.selected = state.selected.saturating_add(1).min(max);
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
return apply_branch_menu_selection(
|
||||||
|
menu,
|
||||||
|
config,
|
||||||
|
conversation,
|
||||||
|
chat_id,
|
||||||
|
transcript,
|
||||||
|
active_assistant,
|
||||||
|
active_reasoning,
|
||||||
|
active_tools,
|
||||||
|
status,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(BranchMenuOutcome::None)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn apply_branch_menu_selection(
|
||||||
|
menu: &mut Option<BranchMenuState>,
|
||||||
|
config: &Config,
|
||||||
|
conversation: &mut Conversation,
|
||||||
|
chat_id: &mut String,
|
||||||
|
transcript: &mut Vec<TranscriptBlock>,
|
||||||
|
active_assistant: &mut Option<usize>,
|
||||||
|
active_reasoning: &mut Option<usize>,
|
||||||
|
active_tools: &mut HashMap<String, usize>,
|
||||||
|
status: &mut String,
|
||||||
|
) -> Result<BranchMenuOutcome> {
|
||||||
|
let Some(state) = menu.as_mut() else {
|
||||||
|
return Ok(BranchMenuOutcome::None);
|
||||||
|
};
|
||||||
|
match &state.mode {
|
||||||
|
BranchMenuMode::Main => {
|
||||||
|
let items = state.main_items();
|
||||||
|
let Some(item) = items.get(state.selected).cloned() else {
|
||||||
|
return Ok(BranchMenuOutcome::None);
|
||||||
|
};
|
||||||
|
match item {
|
||||||
|
BranchMenuItem::Switch(id) => {
|
||||||
|
let (loaded, warning) = Conversation::load(&config.conversations_dir(), &id)?;
|
||||||
|
*conversation = loaded;
|
||||||
|
*chat_id = conversation.id.clone();
|
||||||
|
*transcript = transcript_from_loaded(conversation, warning);
|
||||||
|
*active_assistant = None;
|
||||||
|
*active_reasoning = None;
|
||||||
|
active_tools.clear();
|
||||||
|
*status = format!("switched to branch {chat_id}");
|
||||||
|
*menu = None;
|
||||||
|
Ok(BranchMenuOutcome::Changed)
|
||||||
|
}
|
||||||
|
BranchMenuItem::Checkpoint(checkpoint) => {
|
||||||
|
state.mode = BranchMenuMode::Actions(checkpoint);
|
||||||
|
state.selected = 0;
|
||||||
|
Ok(BranchMenuOutcome::None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BranchMenuMode::Actions(checkpoint) => {
|
||||||
|
let selected = state.selected;
|
||||||
|
let checkpoint = checkpoint.clone();
|
||||||
|
match selected {
|
||||||
|
0 => branch_from_checkpoint(
|
||||||
|
menu,
|
||||||
|
config,
|
||||||
|
&checkpoint,
|
||||||
|
false,
|
||||||
|
conversation,
|
||||||
|
chat_id,
|
||||||
|
transcript,
|
||||||
|
active_assistant,
|
||||||
|
active_reasoning,
|
||||||
|
active_tools,
|
||||||
|
status,
|
||||||
|
),
|
||||||
|
1 => branch_from_checkpoint(
|
||||||
|
menu,
|
||||||
|
config,
|
||||||
|
&checkpoint,
|
||||||
|
true,
|
||||||
|
conversation,
|
||||||
|
chat_id,
|
||||||
|
transcript,
|
||||||
|
active_assistant,
|
||||||
|
active_reasoning,
|
||||||
|
active_tools,
|
||||||
|
status,
|
||||||
|
),
|
||||||
|
2 => {
|
||||||
|
let plan = crate::file_edits::plan_restore(
|
||||||
|
&config.root,
|
||||||
|
&checkpoint.chat_id,
|
||||||
|
checkpoint.record_index,
|
||||||
|
)?;
|
||||||
|
transcript.push(TranscriptBlock {
|
||||||
|
kind: TranscriptKind::Status,
|
||||||
|
title: "restore preview".into(),
|
||||||
|
content: crate::file_edits::summarize_plan(&plan),
|
||||||
|
});
|
||||||
|
*status = "restore plan previewed".into();
|
||||||
|
*menu = None;
|
||||||
|
Ok(BranchMenuOutcome::Changed)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
state.mode = BranchMenuMode::Main;
|
||||||
|
state.selected = 0;
|
||||||
|
Ok(BranchMenuOutcome::None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn branch_from_checkpoint(
|
||||||
|
menu: &mut Option<BranchMenuState>,
|
||||||
|
config: &Config,
|
||||||
|
checkpoint: &crate::branch::Checkpoint,
|
||||||
|
restore_files: bool,
|
||||||
|
conversation: &mut Conversation,
|
||||||
|
chat_id: &mut String,
|
||||||
|
transcript: &mut Vec<TranscriptBlock>,
|
||||||
|
active_assistant: &mut Option<usize>,
|
||||||
|
active_reasoning: &mut Option<usize>,
|
||||||
|
active_tools: &mut HashMap<String, usize>,
|
||||||
|
status: &mut String,
|
||||||
|
) -> Result<BranchMenuOutcome> {
|
||||||
|
let (source, _) = Conversation::load(&config.conversations_dir(), &checkpoint.chat_id)?;
|
||||||
|
let branch = crate::branch::create_branch(&config.conversations_dir(), &source, checkpoint)?;
|
||||||
|
let old_id = checkpoint.chat_id.clone();
|
||||||
|
*conversation = branch;
|
||||||
|
*chat_id = conversation.id.clone();
|
||||||
|
*transcript = transcript_from_loaded(conversation, None);
|
||||||
|
*active_assistant = None;
|
||||||
|
*active_reasoning = None;
|
||||||
|
active_tools.clear();
|
||||||
|
|
||||||
|
let mut restore_status = String::new();
|
||||||
|
if restore_files {
|
||||||
|
let plan = crate::file_edits::plan_restore(
|
||||||
|
&config.root,
|
||||||
|
&checkpoint.chat_id,
|
||||||
|
checkpoint.record_index,
|
||||||
|
)?;
|
||||||
|
let summary = crate::file_edits::summarize_plan(&plan);
|
||||||
|
let outcome = crate::file_edits::apply_restore_plan(&plan)?;
|
||||||
|
restore_status = format!(
|
||||||
|
"; restored files: {} applied, {} skipped, {} conflicts",
|
||||||
|
outcome.applied, outcome.skipped, outcome.conflicts
|
||||||
|
);
|
||||||
|
transcript.push(TranscriptBlock {
|
||||||
|
kind: if outcome.conflicts == 0 {
|
||||||
|
TranscriptKind::Status
|
||||||
|
} else {
|
||||||
|
TranscriptKind::Error
|
||||||
|
},
|
||||||
|
title: "file restore".into(),
|
||||||
|
content: format!(
|
||||||
|
"{summary}\n\nApplied: {}\nSkipped: {}\nConflicts: {}",
|
||||||
|
outcome.applied, outcome.skipped, outcome.conflicts
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
*status = format!(
|
||||||
|
"branched {chat_id} from {old_id} at {}{}",
|
||||||
|
crate::branch::checkpoint_title(checkpoint),
|
||||||
|
restore_status
|
||||||
|
);
|
||||||
|
*menu = None;
|
||||||
|
Ok(BranchMenuOutcome::Changed)
|
||||||
|
}
|
||||||
|
|
||||||
struct AgentEventContext<'a> {
|
struct AgentEventContext<'a> {
|
||||||
terminal: &'a terminal::CassTerminal,
|
terminal: &'a terminal::CassTerminal,
|
||||||
input: &'a str,
|
input: &'a str,
|
||||||
@@ -1243,6 +1884,9 @@ fn assistant_content_matches(a: &str, b: &str) -> bool {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
enum LocalCommand {
|
enum LocalCommand {
|
||||||
|
Branch,
|
||||||
|
Login,
|
||||||
|
Logout,
|
||||||
Model(String),
|
Model(String),
|
||||||
New,
|
New,
|
||||||
Resume(String),
|
Resume(String),
|
||||||
@@ -1257,6 +1901,24 @@ struct CommandSpec {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const COMMANDS: &[CommandSpec] = &[
|
const COMMANDS: &[CommandSpec] = &[
|
||||||
|
CommandSpec {
|
||||||
|
name: "branch",
|
||||||
|
usage: "/branch",
|
||||||
|
description: "open branch/restore menu",
|
||||||
|
takes_value: false,
|
||||||
|
},
|
||||||
|
CommandSpec {
|
||||||
|
name: "login",
|
||||||
|
usage: "/login",
|
||||||
|
description: "configure provider login settings",
|
||||||
|
takes_value: false,
|
||||||
|
},
|
||||||
|
CommandSpec {
|
||||||
|
name: "logout",
|
||||||
|
usage: "/logout",
|
||||||
|
description: "remove saved providers and models",
|
||||||
|
takes_value: false,
|
||||||
|
},
|
||||||
CommandSpec {
|
CommandSpec {
|
||||||
name: "model",
|
name: "model",
|
||||||
usage: "/model <model>",
|
usage: "/model <model>",
|
||||||
@@ -1507,6 +2169,24 @@ fn parse_local_command(input: &str) -> std::result::Result<LocalCommand, String>
|
|||||||
};
|
};
|
||||||
|
|
||||||
match command {
|
match command {
|
||||||
|
"/branch" | "/restore" => {
|
||||||
|
if parts.next().is_some() {
|
||||||
|
return Err("usage: /branch".into());
|
||||||
|
}
|
||||||
|
Ok(LocalCommand::Branch)
|
||||||
|
}
|
||||||
|
"/login" => {
|
||||||
|
if parts.next().is_some() {
|
||||||
|
return Err("usage: /login".into());
|
||||||
|
}
|
||||||
|
Ok(LocalCommand::Login)
|
||||||
|
}
|
||||||
|
"/logout" => {
|
||||||
|
if parts.next().is_some() {
|
||||||
|
return Err("usage: /logout".into());
|
||||||
|
}
|
||||||
|
Ok(LocalCommand::Logout)
|
||||||
|
}
|
||||||
"/model" => {
|
"/model" => {
|
||||||
let Some(model) = parts.next() else {
|
let Some(model) = parts.next() else {
|
||||||
return Err("usage: /model <model>".into());
|
return Err("usage: /model <model>".into());
|
||||||
@@ -1950,12 +2630,43 @@ mod tests {
|
|||||||
assert!(command_autofill("/new", 0).is_none());
|
assert!(command_autofill("/new", 0).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_autofill_lists_login_and_logout_commands() {
|
||||||
|
let menu = command_autofill("/log", 0).unwrap();
|
||||||
|
|
||||||
|
let labels = menu
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.map(|item| item.label.as_str())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(labels, vec!["/login", "/logout"]);
|
||||||
|
assert_eq!(menu.items[0].insert, "/login");
|
||||||
|
assert_eq!(menu.items[1].insert, "/logout");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_local_command_accepts_new_without_args() {
|
fn parse_local_command_accepts_new_without_args() {
|
||||||
assert_eq!(parse_local_command("/new").unwrap(), LocalCommand::New);
|
assert_eq!(parse_local_command("/new").unwrap(), LocalCommand::New);
|
||||||
assert_eq!(parse_local_command("/new extra"), Err("usage: /new".into()));
|
assert_eq!(parse_local_command("/new extra"), Err("usage: /new".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_local_command_accepts_login_and_logout_without_args() {
|
||||||
|
assert_eq!(parse_local_command("/login").unwrap(), LocalCommand::Login);
|
||||||
|
assert_eq!(
|
||||||
|
parse_local_command("/logout").unwrap(),
|
||||||
|
LocalCommand::Logout
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_local_command("/login extra"),
|
||||||
|
Err("usage: /login".into())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_local_command("/logout extra"),
|
||||||
|
Err("usage: /logout".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cancelled_turn_repairs_missing_tool_results() {
|
fn cancelled_turn_repairs_missing_tool_results() {
|
||||||
let root = tempdir().unwrap();
|
let root = tempdir().unwrap();
|
||||||
|
|||||||
+435
@@ -0,0 +1,435 @@
|
|||||||
|
use crate::conversation::{self, BranchPoint, Conversation, Record, StoredToolCall};
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||||
|
use std::fs::{self, File, OpenOptions};
|
||||||
|
use std::io::{BufRead, BufReader, Write};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
const TOOL_CANCELLED_MESSAGE: &str = "Tool execution cancelled by user.";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum CheckpointKind {
|
||||||
|
User,
|
||||||
|
Assistant,
|
||||||
|
ToolCall,
|
||||||
|
ToolResult,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Checkpoint {
|
||||||
|
pub id: String,
|
||||||
|
pub chat_id: String,
|
||||||
|
pub record_index: usize,
|
||||||
|
pub tool_call_id: Option<String>,
|
||||||
|
pub kind: CheckpointKind,
|
||||||
|
pub label: String,
|
||||||
|
pub detail: String,
|
||||||
|
pub ts: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct BranchSummary {
|
||||||
|
pub id: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub parent_chat_id: Option<String>,
|
||||||
|
pub branch_label: Option<String>,
|
||||||
|
pub record_count: usize,
|
||||||
|
pub current: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct BranchFamily {
|
||||||
|
pub branches: Vec<BranchSummary>,
|
||||||
|
pub checkpoints: Vec<Checkpoint>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn checkpoint_records(records: &[Record], chat_id: &str) -> Vec<Checkpoint> {
|
||||||
|
let mut checkpoints = Vec::new();
|
||||||
|
for (idx, record) in records.iter().enumerate() {
|
||||||
|
match record {
|
||||||
|
Record::User { content, ts } => checkpoints.push(Checkpoint {
|
||||||
|
id: format!("{chat_id}:{idx}:user"),
|
||||||
|
chat_id: chat_id.to_string(),
|
||||||
|
record_index: idx,
|
||||||
|
tool_call_id: None,
|
||||||
|
kind: CheckpointKind::User,
|
||||||
|
label: "user".into(),
|
||||||
|
detail: preview(content),
|
||||||
|
ts: Some(ts.clone()),
|
||||||
|
}),
|
||||||
|
Record::Assistant {
|
||||||
|
content,
|
||||||
|
reasoning,
|
||||||
|
tool_calls,
|
||||||
|
ts,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
let detail = if content.trim().is_empty() {
|
||||||
|
preview(reasoning)
|
||||||
|
} else {
|
||||||
|
preview(content)
|
||||||
|
};
|
||||||
|
checkpoints.push(Checkpoint {
|
||||||
|
id: format!("{chat_id}:{idx}:assistant"),
|
||||||
|
chat_id: chat_id.to_string(),
|
||||||
|
record_index: idx,
|
||||||
|
tool_call_id: None,
|
||||||
|
kind: CheckpointKind::Assistant,
|
||||||
|
label: "assistant".into(),
|
||||||
|
detail,
|
||||||
|
ts: Some(ts.clone()),
|
||||||
|
});
|
||||||
|
for call in tool_calls {
|
||||||
|
checkpoints.push(Checkpoint {
|
||||||
|
id: format!("{chat_id}:{idx}:tool_call:{}", call.id),
|
||||||
|
chat_id: chat_id.to_string(),
|
||||||
|
record_index: idx,
|
||||||
|
tool_call_id: Some(call.id.clone()),
|
||||||
|
kind: CheckpointKind::ToolCall,
|
||||||
|
label: format!("tool call {}", call.name),
|
||||||
|
detail: tool_call_detail(call),
|
||||||
|
ts: Some(ts.clone()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Record::Tool {
|
||||||
|
tool_call_id,
|
||||||
|
name,
|
||||||
|
ok,
|
||||||
|
content,
|
||||||
|
ts,
|
||||||
|
} => checkpoints.push(Checkpoint {
|
||||||
|
id: format!("{chat_id}:{idx}:tool_result:{tool_call_id}"),
|
||||||
|
chat_id: chat_id.to_string(),
|
||||||
|
record_index: idx,
|
||||||
|
tool_call_id: Some(tool_call_id.clone()),
|
||||||
|
kind: CheckpointKind::ToolResult,
|
||||||
|
label: format!("tool {name} {}", if *ok { "✓" } else { "✗" }),
|
||||||
|
detail: preview(content),
|
||||||
|
ts: Some(ts.clone()),
|
||||||
|
}),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checkpoints
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_family(conversations_dir: &Path, current: &Conversation) -> Result<BranchFamily> {
|
||||||
|
let metas = load_all_metas(conversations_dir)?;
|
||||||
|
let root = root_for(¤t.id, &metas);
|
||||||
|
let mut ids = BTreeSet::new();
|
||||||
|
for id in metas.keys() {
|
||||||
|
if root_for(id, &metas) == root {
|
||||||
|
ids.insert(id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ids.insert(current.id.clone());
|
||||||
|
|
||||||
|
let mut branches = Vec::new();
|
||||||
|
let mut checkpoints = Vec::new();
|
||||||
|
for id in ids {
|
||||||
|
let Ok((conversation, _)) = Conversation::load(conversations_dir, &id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let meta = conversation.meta();
|
||||||
|
branches.push(BranchSummary {
|
||||||
|
id: conversation.id.clone(),
|
||||||
|
created_at: meta
|
||||||
|
.as_ref()
|
||||||
|
.map(|m| m.created_at.clone())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
parent_chat_id: meta.as_ref().and_then(|m| m.parent_chat_id.clone()),
|
||||||
|
branch_label: meta
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|m| m.branch_from.as_ref().map(|p| p.checkpoint_label.clone())),
|
||||||
|
record_count: conversation.records.len(),
|
||||||
|
current: conversation.id == current.id,
|
||||||
|
});
|
||||||
|
checkpoints.extend(checkpoint_records(&conversation.records, &conversation.id));
|
||||||
|
}
|
||||||
|
branches.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||||
|
checkpoints.sort_by(|a, b| {
|
||||||
|
a.chat_id
|
||||||
|
.cmp(&b.chat_id)
|
||||||
|
.then(a.record_index.cmp(&b.record_index))
|
||||||
|
.then(a.id.cmp(&b.id))
|
||||||
|
});
|
||||||
|
Ok(BranchFamily {
|
||||||
|
branches,
|
||||||
|
checkpoints,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_branch(
|
||||||
|
conversations_dir: &Path,
|
||||||
|
source: &Conversation,
|
||||||
|
checkpoint: &Checkpoint,
|
||||||
|
) -> Result<Conversation> {
|
||||||
|
if source.id != checkpoint.chat_id {
|
||||||
|
bail!(
|
||||||
|
"checkpoint {} belongs to {}, not {}",
|
||||||
|
checkpoint.id,
|
||||||
|
checkpoint.chat_id,
|
||||||
|
source.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if checkpoint.record_index >= source.records.len() {
|
||||||
|
bail!("checkpoint record index is out of range");
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::create_dir_all(conversations_dir)?;
|
||||||
|
let id = conversation::new_chat_id();
|
||||||
|
let path = conversations_dir.join(format!("{id}.jsonl"));
|
||||||
|
let mut records = Vec::new();
|
||||||
|
let meta = source
|
||||||
|
.meta()
|
||||||
|
.context("source conversation is missing metadata")?;
|
||||||
|
records.push(Record::Meta {
|
||||||
|
chat_id: id.clone(),
|
||||||
|
created_at: conversation::now_ts(),
|
||||||
|
model: meta.model,
|
||||||
|
cwd: meta.cwd,
|
||||||
|
parent_chat_id: Some(source.id.clone()),
|
||||||
|
branch_from: Some(BranchPoint {
|
||||||
|
chat_id: source.id.clone(),
|
||||||
|
record_index: checkpoint.record_index,
|
||||||
|
tool_call_id: checkpoint.tool_call_id.clone(),
|
||||||
|
checkpoint_label: checkpoint_title(checkpoint),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
let prefix = valid_prefix(source, checkpoint)?;
|
||||||
|
records.extend(prefix);
|
||||||
|
repair_pending_tool_calls(&mut records);
|
||||||
|
|
||||||
|
let mut file = OpenOptions::new()
|
||||||
|
.create_new(true)
|
||||||
|
.write(true)
|
||||||
|
.open(&path)
|
||||||
|
.with_context(|| format!("creating branch conversation {}", path.display()))?;
|
||||||
|
for record in &records {
|
||||||
|
writeln!(file, "{}", serde_json::to_string(record)?)?;
|
||||||
|
}
|
||||||
|
file.flush()?;
|
||||||
|
|
||||||
|
Ok(Conversation { id, path, records })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_prefix(source: &Conversation, checkpoint: &Checkpoint) -> Result<Vec<Record>> {
|
||||||
|
let mut end = checkpoint.record_index + 1;
|
||||||
|
if matches!(checkpoint.kind, CheckpointKind::ToolCall) {
|
||||||
|
end = checkpoint.record_index + 1;
|
||||||
|
}
|
||||||
|
let mut prefix = source.records[..end].to_vec();
|
||||||
|
|
||||||
|
// Drop the source meta; the branch writes its own meta record.
|
||||||
|
if matches!(prefix.first(), Some(Record::Meta { .. })) {
|
||||||
|
prefix.remove(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For a tool-call checkpoint, keep the assistant turn but do not copy any
|
||||||
|
// later tool result. The repair step below writes cancelled tool results so
|
||||||
|
// the next provider request remains valid.
|
||||||
|
Ok(prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn repair_pending_tool_calls(records: &mut Vec<Record>) {
|
||||||
|
let mut pending: Vec<(String, String)> = Vec::new();
|
||||||
|
for record in records.iter() {
|
||||||
|
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(),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let seen: HashSet<String> = records
|
||||||
|
.iter()
|
||||||
|
.filter_map(|record| match record {
|
||||||
|
Record::Tool { tool_call_id, .. } => Some(tool_call_id.clone()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
for (id, name) in pending {
|
||||||
|
if seen.contains(&id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
records.push(Record::Tool {
|
||||||
|
tool_call_id: id,
|
||||||
|
name,
|
||||||
|
ok: false,
|
||||||
|
content: TOOL_CANCELLED_MESSAGE.to_string(),
|
||||||
|
ts: conversation::now_ts(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_all_metas(conversations_dir: &Path) -> Result<BTreeMap<String, (Option<String>, String)>> {
|
||||||
|
let mut out = BTreeMap::new();
|
||||||
|
if !conversations_dir.exists() {
|
||||||
|
return Ok(out);
|
||||||
|
}
|
||||||
|
for entry in fs::read_dir(conversations_dir)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension().and_then(|s| s.to_str()) != Some("jsonl") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(id) = path.file_stem().and_then(|s| s.to_str()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if let Ok(Some((parent, cwd))) = read_meta_parent_cwd(&path) {
|
||||||
|
out.insert(id.to_string(), (parent, cwd));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_meta_parent_cwd(path: &Path) -> Result<Option<(Option<String>, String)>> {
|
||||||
|
let file = File::open(path)?;
|
||||||
|
for line in BufReader::new(file).lines().take(10) {
|
||||||
|
let line = line?;
|
||||||
|
if line.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let record: Record = serde_json::from_str(&line)?;
|
||||||
|
if let Record::Meta {
|
||||||
|
parent_chat_id,
|
||||||
|
cwd,
|
||||||
|
..
|
||||||
|
} = record
|
||||||
|
{
|
||||||
|
return Ok(Some((parent_chat_id, cwd)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn root_for(id: &str, metas: &BTreeMap<String, (Option<String>, String)>) -> String {
|
||||||
|
let mut current = id.to_string();
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
while seen.insert(current.clone()) {
|
||||||
|
let Some((Some(parent), _)) = metas.get(¤t) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
current = parent.clone();
|
||||||
|
}
|
||||||
|
current
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn checkpoint_title(checkpoint: &Checkpoint) -> String {
|
||||||
|
if checkpoint.detail.is_empty() {
|
||||||
|
checkpoint.label.clone()
|
||||||
|
} else {
|
||||||
|
format!("{}: {}", checkpoint.label, checkpoint.detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool_call_detail(call: &StoredToolCall) -> String {
|
||||||
|
let mut detail = String::new();
|
||||||
|
if let Some(path) = call.arguments.get("path").and_then(|v| v.as_str()) {
|
||||||
|
detail = format!("file: {path}");
|
||||||
|
} else if let Some(command) = call.arguments.get("command").and_then(|v| v.as_str()) {
|
||||||
|
detail = command.to_string();
|
||||||
|
}
|
||||||
|
if detail.is_empty() {
|
||||||
|
preview(&call.arguments.to_string())
|
||||||
|
} else {
|
||||||
|
preview(&detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn preview(content: &str) -> String {
|
||||||
|
content
|
||||||
|
.lines()
|
||||||
|
.find(|line| !line.trim().is_empty())
|
||||||
|
.unwrap_or("")
|
||||||
|
.chars()
|
||||||
|
.take(96)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
fn base_records(id: &str) -> Vec<Record> {
|
||||||
|
vec![
|
||||||
|
Record::Meta {
|
||||||
|
chat_id: id.into(),
|
||||||
|
created_at: "now".into(),
|
||||||
|
model: "m".into(),
|
||||||
|
cwd: "/tmp".into(),
|
||||||
|
parent_chat_id: None,
|
||||||
|
branch_from: None,
|
||||||
|
},
|
||||||
|
Record::System {
|
||||||
|
content: "s".into(),
|
||||||
|
},
|
||||||
|
Record::User {
|
||||||
|
content: "u".into(),
|
||||||
|
ts: "t".into(),
|
||||||
|
},
|
||||||
|
Record::Assistant {
|
||||||
|
content: "a".into(),
|
||||||
|
reasoning: String::new(),
|
||||||
|
reasoning_field: None,
|
||||||
|
tool_calls: vec![StoredToolCall {
|
||||||
|
id: "call1".into(),
|
||||||
|
name: "read".into(),
|
||||||
|
arguments: json!({"path":"x"}),
|
||||||
|
}],
|
||||||
|
ts: "t".into(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn checkpoints_include_tool_calls() {
|
||||||
|
let checkpoints = checkpoint_records(&base_records("c"), "c");
|
||||||
|
assert!(checkpoints.iter().any(|c| c.kind == CheckpointKind::User));
|
||||||
|
assert!(checkpoints
|
||||||
|
.iter()
|
||||||
|
.any(|c| c.kind == CheckpointKind::Assistant));
|
||||||
|
assert!(checkpoints
|
||||||
|
.iter()
|
||||||
|
.any(|c| c.kind == CheckpointKind::ToolCall));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_branch_does_not_modify_source_and_repairs_pending_tools() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let source = Conversation {
|
||||||
|
id: "source".into(),
|
||||||
|
path: dir.path().join("source.jsonl"),
|
||||||
|
records: base_records("source"),
|
||||||
|
};
|
||||||
|
let checkpoint = checkpoint_records(&source.records, &source.id)
|
||||||
|
.into_iter()
|
||||||
|
.find(|c| c.kind == CheckpointKind::Assistant)
|
||||||
|
.unwrap();
|
||||||
|
let branch = create_branch(dir.path(), &source, &checkpoint).unwrap();
|
||||||
|
assert_ne!(branch.id, source.id);
|
||||||
|
assert!(
|
||||||
|
source
|
||||||
|
.records
|
||||||
|
.iter()
|
||||||
|
.filter(|r| matches!(r, Record::Tool { .. }))
|
||||||
|
.count()
|
||||||
|
== 0
|
||||||
|
);
|
||||||
|
assert!(branch
|
||||||
|
.records
|
||||||
|
.iter()
|
||||||
|
.any(|r| matches!(r, Record::Tool { ok: false, .. })));
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
-1
@@ -1,4 +1,4 @@
|
|||||||
use clap::{Parser, Subcommand};
|
use clap::{Args, Parser, Subcommand};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
#[derive(Debug, Parser, Clone)]
|
#[derive(Debug, Parser, Clone)]
|
||||||
@@ -44,8 +44,41 @@ pub struct Cli {
|
|||||||
pub enum Command {
|
pub enum Command {
|
||||||
/// Validate Cass config files.
|
/// Validate Cass config files.
|
||||||
Check,
|
Check,
|
||||||
|
/// Configure or update OpenAI-compatible provider login settings.
|
||||||
|
Login,
|
||||||
|
/// Remove saved providers and their models.
|
||||||
|
Logout,
|
||||||
/// Configure an OpenAI-compatible provider and first model.
|
/// Configure an OpenAI-compatible provider and first model.
|
||||||
Setup,
|
Setup,
|
||||||
|
/// Update Cassady from official GitHub releases.
|
||||||
|
Update(UpdateArgs),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Args, Clone, PartialEq, Eq)]
|
||||||
|
pub struct UpdateArgs {
|
||||||
|
/// Check the latest release without installing.
|
||||||
|
#[arg(long)]
|
||||||
|
pub check: bool,
|
||||||
|
|
||||||
|
/// Show what would be updated without downloading or installing.
|
||||||
|
#[arg(long)]
|
||||||
|
pub dry_run: bool,
|
||||||
|
|
||||||
|
/// Accept default prompts for non-interactive use.
|
||||||
|
#[arg(long, short = 'y')]
|
||||||
|
pub yes: bool,
|
||||||
|
|
||||||
|
/// Require a matching prebuilt archive and do not fall back to source.
|
||||||
|
#[arg(long, conflicts_with = "source")]
|
||||||
|
pub prebuilt: bool,
|
||||||
|
|
||||||
|
/// Build from release source even when a prebuilt archive exists.
|
||||||
|
#[arg(long, conflicts_with = "prebuilt")]
|
||||||
|
pub source: bool,
|
||||||
|
|
||||||
|
/// Install a specific release tag, such as v0.2.7.
|
||||||
|
#[arg(long, value_name = "TAG")]
|
||||||
|
pub to: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse() -> Cli {
|
pub fn parse() -> Cli {
|
||||||
|
|||||||
+54
-20
@@ -151,6 +151,34 @@ pub struct Config {
|
|||||||
pub docs_dir: PathBuf,
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum ApiKeyReference {
|
pub enum ApiKeyReference {
|
||||||
Env(String),
|
Env(String),
|
||||||
@@ -287,17 +315,29 @@ pub fn models_path(root: &Path) -> PathBuf {
|
|||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
pub fn load(cli: &Cli) -> Result<Self> {
|
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"))
|
fs::create_dir_all(root.join("conversations"))
|
||||||
.with_context(|| format!("creating {}", root.join("conversations").display()))?;
|
.with_context(|| format!("creating {}", root.join("conversations").display()))?;
|
||||||
let docs_dir = crate::docs::install(&root)?;
|
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> {
|
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()))?;
|
fs::create_dir_all(&root).with_context(|| format!("creating {}", root.display()))?;
|
||||||
let providers = load_or_create_default_provider_registry(&root)?;
|
let providers = load_or_create_default_provider_registry(&root)?;
|
||||||
let models = load_or_create_default_model_registry(&root)?;
|
let models = load_or_create_default_model_registry(&root)?;
|
||||||
@@ -330,19 +370,13 @@ impl Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if cli.readonly {
|
if let Some(access_mode) = overrides.access_mode {
|
||||||
cfg.default_access_mode = AccessMode::ReadOnly;
|
cfg.default_access_mode = access_mode;
|
||||||
}
|
|
||||||
if cli.workspace_edit {
|
|
||||||
cfg.default_access_mode = AccessMode::WorkspaceEdit;
|
|
||||||
}
|
|
||||||
if cli.full_access {
|
|
||||||
cfg.default_access_mode = AccessMode::FullAccess;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 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(
|
let mut provider = resolve_provider(
|
||||||
requested_model.as_deref().unwrap_or(DEFAULT_MODEL),
|
requested_model.as_deref().unwrap_or(DEFAULT_MODEL),
|
||||||
@@ -353,10 +387,10 @@ impl Config {
|
|||||||
&models,
|
&models,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
if let Some(base_url) = &cli.base_url {
|
if let Some(base_url) = &overrides.base_url {
|
||||||
provider.base_url = base_url.clone();
|
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}");
|
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)
|
.find(|m| m.provider == provider_id && m.id == model_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn requested_model(file: Option<&ConfigFile>, cli: &Cli) -> Option<String> {
|
fn requested_model(file: Option<&ConfigFile>, overrides: &ConfigOverrides) -> Option<String> {
|
||||||
cli.model.clone().or_else(|| {
|
overrides.model.clone().or_else(|| {
|
||||||
file.and_then(|f| {
|
file.and_then(|f| {
|
||||||
f.default_model
|
f.default_model
|
||||||
.clone()
|
.clone()
|
||||||
@@ -738,13 +772,13 @@ struct LegacyProviderOverride {
|
|||||||
|
|
||||||
fn legacy_provider_override(
|
fn legacy_provider_override(
|
||||||
file: Option<&ConfigFile>,
|
file: Option<&ConfigFile>,
|
||||||
cli: &Cli,
|
overrides: &ConfigOverrides,
|
||||||
) -> Option<LegacyProviderOverride> {
|
) -> Option<LegacyProviderOverride> {
|
||||||
let base_url = cli
|
let base_url = overrides
|
||||||
.base_url
|
.base_url
|
||||||
.clone()
|
.clone()
|
||||||
.or_else(|| file.and_then(|f| f.base_url.clone()));
|
.or_else(|| file.and_then(|f| f.base_url.clone()));
|
||||||
let api_key = cli
|
let api_key = overrides
|
||||||
.api_key_env
|
.api_key_env
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|env| format!("${env}"))
|
.map(|env| format!("${env}"))
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ pub enum Record {
|
|||||||
created_at: String,
|
created_at: String,
|
||||||
model: String,
|
model: String,
|
||||||
cwd: String,
|
cwd: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
parent_chat_id: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
branch_from: Option<BranchPoint>,
|
||||||
},
|
},
|
||||||
System {
|
System {
|
||||||
content: String,
|
content: String,
|
||||||
@@ -40,6 +44,15 @@ pub enum Record {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct BranchPoint {
|
||||||
|
pub chat_id: String,
|
||||||
|
pub record_index: usize,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub tool_call_id: Option<String>,
|
||||||
|
pub checkpoint_label: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct StoredToolCall {
|
pub struct StoredToolCall {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -92,6 +105,8 @@ impl Conversation {
|
|||||||
created_at: now_ts(),
|
created_at: now_ts(),
|
||||||
model: model.to_string(),
|
model: model.to_string(),
|
||||||
cwd: cwd.display().to_string(),
|
cwd: cwd.display().to_string(),
|
||||||
|
parent_chat_id: None,
|
||||||
|
branch_from: None,
|
||||||
})?;
|
})?;
|
||||||
convo.append(Record::System {
|
convo.append(Record::System {
|
||||||
content: base_system,
|
content: base_system,
|
||||||
@@ -161,6 +176,37 @@ impl Conversation {
|
|||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn meta(&self) -> Option<ConversationMeta> {
|
||||||
|
self.records.iter().find_map(|r| match r {
|
||||||
|
Record::Meta {
|
||||||
|
chat_id,
|
||||||
|
created_at,
|
||||||
|
model,
|
||||||
|
cwd,
|
||||||
|
parent_chat_id,
|
||||||
|
branch_from,
|
||||||
|
} => Some(ConversationMeta {
|
||||||
|
chat_id: chat_id.clone(),
|
||||||
|
created_at: created_at.clone(),
|
||||||
|
model: model.clone(),
|
||||||
|
cwd: cwd.clone(),
|
||||||
|
parent_chat_id: parent_chat_id.clone(),
|
||||||
|
branch_from: branch_from.clone(),
|
||||||
|
}),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ConversationMeta {
|
||||||
|
pub chat_id: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub model: String,
|
||||||
|
pub cwd: String,
|
||||||
|
pub parent_chat_id: Option<String>,
|
||||||
|
pub branch_from: Option<BranchPoint>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_chats(conversations_dir: &Path, cwd: &Path) -> Result<Vec<ChatSummary>> {
|
pub fn list_chats(conversations_dir: &Path, cwd: &Path) -> Result<Vec<ChatSummary>> {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,470 @@
|
|||||||
|
use crate::tools::{self, ToolContext};
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fs::{self, OpenOptions};
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
const MAX_SNAPSHOT_BYTES: u64 = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct FileEditJournalEntry {
|
||||||
|
pub chat_id: String,
|
||||||
|
pub record_index: usize,
|
||||||
|
pub tool_call_id: String,
|
||||||
|
pub tool_name: String,
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub existed_before: bool,
|
||||||
|
pub existed_after: bool,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub before_hash: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub after_hash: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub before_snapshot: Option<PathBuf>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub after_snapshot: Option<PathBuf>,
|
||||||
|
pub ts: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PendingFileEditSnapshot {
|
||||||
|
pub chat_id: String,
|
||||||
|
pub record_index: usize,
|
||||||
|
pub tool_call_id: String,
|
||||||
|
pub tool_name: String,
|
||||||
|
pub path: PathBuf,
|
||||||
|
before: SnapshotState,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
enum SnapshotState {
|
||||||
|
Missing,
|
||||||
|
File { bytes: Vec<u8>, hash: String },
|
||||||
|
Unsupported,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct RestorePlan {
|
||||||
|
pub actions: Vec<RestoreAction>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum RestoreAction {
|
||||||
|
Write {
|
||||||
|
path: PathBuf,
|
||||||
|
snapshot: PathBuf,
|
||||||
|
desired_hash: String,
|
||||||
|
expected_current_hash: Option<String>,
|
||||||
|
conflict: bool,
|
||||||
|
},
|
||||||
|
Delete {
|
||||||
|
path: PathBuf,
|
||||||
|
expected_current_hash: Option<String>,
|
||||||
|
conflict: bool,
|
||||||
|
},
|
||||||
|
Skip {
|
||||||
|
path: PathBuf,
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct RestoreOutcome {
|
||||||
|
pub applied: usize,
|
||||||
|
pub skipped: usize,
|
||||||
|
pub conflicts: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn begin_tool_edit(
|
||||||
|
cass_root: &Path,
|
||||||
|
chat_id: &str,
|
||||||
|
record_index: usize,
|
||||||
|
tool_call_id: &str,
|
||||||
|
tool_name: &str,
|
||||||
|
args: &Value,
|
||||||
|
ctx: &ToolContext,
|
||||||
|
) -> Option<PendingFileEditSnapshot> {
|
||||||
|
if !matches!(tool_name, "write" | "edit") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let path_arg = args.get("path")?.as_str()?;
|
||||||
|
let path =
|
||||||
|
tools::path::resolve_for_write(path_arg, &ctx.cwd, ctx.mode, &ctx.blocked_write_roots)
|
||||||
|
.ok()?;
|
||||||
|
let before = snapshot_state(&path).unwrap_or(SnapshotState::Unsupported);
|
||||||
|
// Ensure journal directories are creatable before executing, but do not fail
|
||||||
|
// the tool if Cassady cannot journal; restore will simply be unavailable.
|
||||||
|
let _ = fs::create_dir_all(cass_root.join("file-edits"));
|
||||||
|
let _ = fs::create_dir_all(cass_root.join("file-snapshots"));
|
||||||
|
Some(PendingFileEditSnapshot {
|
||||||
|
chat_id: chat_id.to_string(),
|
||||||
|
record_index,
|
||||||
|
tool_call_id: tool_call_id.to_string(),
|
||||||
|
tool_name: tool_name.to_string(),
|
||||||
|
path,
|
||||||
|
before,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn finish_tool_edit(cass_root: &Path, pending: PendingFileEditSnapshot) -> Result<()> {
|
||||||
|
let after = snapshot_state(&pending.path).unwrap_or(SnapshotState::Unsupported);
|
||||||
|
if matches!(pending.before, SnapshotState::Unsupported)
|
||||||
|
|| matches!(after, SnapshotState::Unsupported)
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if same_state(&pending.before, &after) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let (existed_before, before_hash, before_snapshot) = store_snapshot(
|
||||||
|
cass_root,
|
||||||
|
&pending.chat_id,
|
||||||
|
&pending.tool_call_id,
|
||||||
|
"before",
|
||||||
|
&pending.before,
|
||||||
|
)?;
|
||||||
|
let (existed_after, after_hash, after_snapshot) = store_snapshot(
|
||||||
|
cass_root,
|
||||||
|
&pending.chat_id,
|
||||||
|
&pending.tool_call_id,
|
||||||
|
"after",
|
||||||
|
&after,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let entry = FileEditJournalEntry {
|
||||||
|
chat_id: pending.chat_id.clone(),
|
||||||
|
record_index: pending.record_index,
|
||||||
|
tool_call_id: pending.tool_call_id,
|
||||||
|
tool_name: pending.tool_name,
|
||||||
|
path: pending.path,
|
||||||
|
existed_before,
|
||||||
|
existed_after,
|
||||||
|
before_hash,
|
||||||
|
after_hash,
|
||||||
|
before_snapshot,
|
||||||
|
after_snapshot,
|
||||||
|
ts: crate::conversation::now_ts(),
|
||||||
|
};
|
||||||
|
append_journal(cass_root, &pending.chat_id, &entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_journal(cass_root: &Path, chat_id: &str) -> Result<Vec<FileEditJournalEntry>> {
|
||||||
|
let path = journal_path(cass_root, chat_id);
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let content =
|
||||||
|
fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for (idx, line) in content.lines().enumerate() {
|
||||||
|
if line.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let entry: FileEditJournalEntry = serde_json::from_str(line)
|
||||||
|
.with_context(|| format!("parsing {} line {}", path.display(), idx + 1))?;
|
||||||
|
out.push(entry);
|
||||||
|
}
|
||||||
|
out.sort_by_key(|entry| entry.record_index);
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn plan_restore(
|
||||||
|
cass_root: &Path,
|
||||||
|
chat_id: &str,
|
||||||
|
target_record_index: usize,
|
||||||
|
) -> Result<RestorePlan> {
|
||||||
|
let entries = load_journal(cass_root, chat_id)?;
|
||||||
|
let mut by_path: BTreeMap<PathBuf, Vec<FileEditJournalEntry>> = BTreeMap::new();
|
||||||
|
for entry in entries {
|
||||||
|
by_path.entry(entry.path.clone()).or_default().push(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut actions = Vec::new();
|
||||||
|
for (path, mut entries) in by_path {
|
||||||
|
entries.sort_by_key(|entry| entry.record_index);
|
||||||
|
let latest = entries.last().cloned();
|
||||||
|
let desired = entries
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find(|entry| entry.record_index <= target_record_index)
|
||||||
|
.cloned();
|
||||||
|
let first_after = entries
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.record_index > target_record_index)
|
||||||
|
.cloned();
|
||||||
|
|
||||||
|
let (want_exists, want_hash, want_snapshot) = if let Some(entry) = desired {
|
||||||
|
(entry.existed_after, entry.after_hash, entry.after_snapshot)
|
||||||
|
} else if let Some(entry) = first_after {
|
||||||
|
(
|
||||||
|
entry.existed_before,
|
||||||
|
entry.before_hash,
|
||||||
|
entry.before_snapshot,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let expected_current_hash = latest.and_then(|entry| entry.after_hash);
|
||||||
|
let current_hash = hash_existing_file(&path)?;
|
||||||
|
let conflict = expected_current_hash.is_some()
|
||||||
|
&& current_hash.is_some()
|
||||||
|
&& expected_current_hash != current_hash;
|
||||||
|
|
||||||
|
if want_exists {
|
||||||
|
match (want_hash, want_snapshot) {
|
||||||
|
(Some(desired_hash), Some(snapshot)) => actions.push(RestoreAction::Write {
|
||||||
|
path,
|
||||||
|
snapshot,
|
||||||
|
desired_hash,
|
||||||
|
expected_current_hash,
|
||||||
|
conflict,
|
||||||
|
}),
|
||||||
|
_ => actions.push(RestoreAction::Skip {
|
||||||
|
path,
|
||||||
|
reason: "missing desired snapshot".into(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let conflict = conflict
|
||||||
|
|| (current_hash.is_some()
|
||||||
|
&& expected_current_hash.is_none()
|
||||||
|
&& current_hash != expected_current_hash);
|
||||||
|
actions.push(RestoreAction::Delete {
|
||||||
|
path,
|
||||||
|
expected_current_hash,
|
||||||
|
conflict,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(RestorePlan { actions })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_restore_plan(plan: &RestorePlan) -> Result<RestoreOutcome> {
|
||||||
|
let mut outcome = RestoreOutcome {
|
||||||
|
applied: 0,
|
||||||
|
skipped: 0,
|
||||||
|
conflicts: 0,
|
||||||
|
};
|
||||||
|
for action in &plan.actions {
|
||||||
|
match action {
|
||||||
|
RestoreAction::Write {
|
||||||
|
path,
|
||||||
|
snapshot,
|
||||||
|
conflict,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
if *conflict {
|
||||||
|
outcome.conflicts += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let bytes = fs::read(snapshot)
|
||||||
|
.with_context(|| format!("reading snapshot {}", snapshot.display()))?;
|
||||||
|
crate::tools::write::atomic_write(path, &bytes)
|
||||||
|
.with_context(|| format!("restoring {}", path.display()))?;
|
||||||
|
outcome.applied += 1;
|
||||||
|
}
|
||||||
|
RestoreAction::Delete { path, conflict, .. } => {
|
||||||
|
if *conflict {
|
||||||
|
outcome.conflicts += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if path.exists() {
|
||||||
|
fs::remove_file(path)
|
||||||
|
.with_context(|| format!("deleting {}", path.display()))?;
|
||||||
|
outcome.applied += 1;
|
||||||
|
} else {
|
||||||
|
outcome.skipped += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RestoreAction::Skip { .. } => outcome.skipped += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(outcome)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn summarize_plan(plan: &RestorePlan) -> String {
|
||||||
|
if plan.actions.is_empty() {
|
||||||
|
return "No tracked file edits need restoration for this checkpoint.".into();
|
||||||
|
}
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
for action in &plan.actions {
|
||||||
|
match action {
|
||||||
|
RestoreAction::Write { path, conflict, .. } => lines.push(format!(
|
||||||
|
"{} update {}",
|
||||||
|
if *conflict { "CONFLICT" } else { "will" },
|
||||||
|
path.display()
|
||||||
|
)),
|
||||||
|
RestoreAction::Delete { path, conflict, .. } => lines.push(format!(
|
||||||
|
"{} delete {}",
|
||||||
|
if *conflict { "CONFLICT" } else { "will" },
|
||||||
|
path.display()
|
||||||
|
)),
|
||||||
|
RestoreAction::Skip { path, reason } => {
|
||||||
|
lines.push(format!("skip {}: {reason}", path.display()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snapshot_state(path: &Path) -> Result<SnapshotState> {
|
||||||
|
match fs::metadata(path) {
|
||||||
|
Ok(metadata) => {
|
||||||
|
if !metadata.is_file() || metadata.len() > MAX_SNAPSHOT_BYTES {
|
||||||
|
return Ok(SnapshotState::Unsupported);
|
||||||
|
}
|
||||||
|
let bytes = fs::read(path)?;
|
||||||
|
let hash = sha256_hex(&bytes);
|
||||||
|
Ok(SnapshotState::File { bytes, hash })
|
||||||
|
}
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(SnapshotState::Missing),
|
||||||
|
Err(err) => Err(err.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn same_state(a: &SnapshotState, b: &SnapshotState) -> bool {
|
||||||
|
match (a, b) {
|
||||||
|
(SnapshotState::Missing, SnapshotState::Missing) => true,
|
||||||
|
(SnapshotState::File { hash: a, .. }, SnapshotState::File { hash: b, .. }) => a == b,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store_snapshot(
|
||||||
|
cass_root: &Path,
|
||||||
|
chat_id: &str,
|
||||||
|
tool_call_id: &str,
|
||||||
|
side: &str,
|
||||||
|
state: &SnapshotState,
|
||||||
|
) -> Result<(bool, Option<String>, Option<PathBuf>)> {
|
||||||
|
match state {
|
||||||
|
SnapshotState::Missing => Ok((false, None, None)),
|
||||||
|
SnapshotState::Unsupported => Ok((false, None, None)),
|
||||||
|
SnapshotState::File { bytes, hash } => {
|
||||||
|
let dir = cass_root
|
||||||
|
.join("file-snapshots")
|
||||||
|
.join(chat_id)
|
||||||
|
.join(tool_call_id);
|
||||||
|
fs::create_dir_all(&dir)?;
|
||||||
|
let path = dir.join(format!("{side}-{hash}.bin"));
|
||||||
|
if !path.exists() {
|
||||||
|
fs::write(&path, bytes)?;
|
||||||
|
}
|
||||||
|
Ok((true, Some(hash.clone()), Some(path)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_journal(cass_root: &Path, chat_id: &str, entry: &FileEditJournalEntry) -> Result<()> {
|
||||||
|
let path = journal_path(cass_root, chat_id);
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let mut file = OpenOptions::new().create(true).append(true).open(&path)?;
|
||||||
|
writeln!(file, "{}", serde_json::to_string(entry)?)?;
|
||||||
|
file.flush()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn journal_path(cass_root: &Path, chat_id: &str) -> PathBuf {
|
||||||
|
cass_root
|
||||||
|
.join("file-edits")
|
||||||
|
.join(format!("{chat_id}.jsonl"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_existing_file(path: &Path) -> Result<Option<String>> {
|
||||||
|
match fs::metadata(path) {
|
||||||
|
Ok(metadata) => {
|
||||||
|
if !metadata.is_file() || metadata.len() > MAX_SNAPSHOT_BYTES {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Ok(Some(sha256_hex(&fs::read(path)?)))
|
||||||
|
}
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||||
|
Err(err) => Err(err.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sha256_hex(bytes: &[u8]) -> String {
|
||||||
|
let digest = Sha256::digest(bytes);
|
||||||
|
digest.iter().map(|b| format!("{b:02x}")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::access::AccessMode;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
fn tool_ctx(cwd: &Path) -> ToolContext {
|
||||||
|
ToolContext {
|
||||||
|
mode: AccessMode::WorkspaceEdit,
|
||||||
|
cwd: cwd.to_path_buf(),
|
||||||
|
read_roots: vec![cwd.to_path_buf()],
|
||||||
|
blocked_write_roots: Vec::new(),
|
||||||
|
model_result_limit: 1000,
|
||||||
|
runtime_tx: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn journal_and_restore_rewinds_write() {
|
||||||
|
let root = tempdir().unwrap();
|
||||||
|
let work = tempdir().unwrap();
|
||||||
|
let path = work.path().join("a.txt");
|
||||||
|
fs::write(&path, "old").unwrap();
|
||||||
|
let ctx = tool_ctx(work.path());
|
||||||
|
let pending = begin_tool_edit(
|
||||||
|
root.path(),
|
||||||
|
"chat",
|
||||||
|
3,
|
||||||
|
"call",
|
||||||
|
"write",
|
||||||
|
&serde_json::json!({"path":"a.txt"}),
|
||||||
|
&ctx,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(&path, "new").unwrap();
|
||||||
|
finish_tool_edit(root.path(), pending).unwrap();
|
||||||
|
|
||||||
|
let plan = plan_restore(root.path(), "chat", 2).unwrap();
|
||||||
|
assert_eq!(plan.actions.len(), 1);
|
||||||
|
let outcome = apply_restore_plan(&plan).unwrap();
|
||||||
|
assert_eq!(outcome.applied, 1);
|
||||||
|
assert_eq!(fs::read_to_string(&path).unwrap(), "old");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restore_detects_external_conflict() {
|
||||||
|
let root = tempdir().unwrap();
|
||||||
|
let work = tempdir().unwrap();
|
||||||
|
let path = work.path().join("a.txt");
|
||||||
|
fs::write(&path, "old").unwrap();
|
||||||
|
let ctx = tool_ctx(work.path());
|
||||||
|
let pending = begin_tool_edit(
|
||||||
|
root.path(),
|
||||||
|
"chat",
|
||||||
|
3,
|
||||||
|
"call",
|
||||||
|
"write",
|
||||||
|
&serde_json::json!({"path":"a.txt"}),
|
||||||
|
&ctx,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(&path, "new").unwrap();
|
||||||
|
finish_tool_edit(root.path(), pending).unwrap();
|
||||||
|
fs::write(&path, "manual").unwrap();
|
||||||
|
let plan = plan_restore(root.path(), "chat", 2).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
&plan.actions[0],
|
||||||
|
RestoreAction::Write { conflict: true, .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,24 @@
|
|||||||
pub mod access;
|
pub mod access;
|
||||||
pub mod agent;
|
pub mod agent;
|
||||||
pub mod app;
|
pub mod app;
|
||||||
|
pub mod branch;
|
||||||
pub mod check;
|
pub mod check;
|
||||||
pub mod cli;
|
pub mod cli;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod conversation;
|
pub mod conversation;
|
||||||
pub mod docs;
|
pub mod docs;
|
||||||
|
pub mod embedding;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod file_edits;
|
||||||
pub mod menu;
|
pub mod menu;
|
||||||
|
pub mod prelude;
|
||||||
pub mod prompt;
|
pub mod prompt;
|
||||||
pub mod providers;
|
pub mod providers;
|
||||||
pub mod security;
|
pub mod security;
|
||||||
pub mod setup;
|
pub mod setup;
|
||||||
pub mod tools;
|
pub mod tools;
|
||||||
pub mod ui;
|
pub mod ui;
|
||||||
|
pub mod update;
|
||||||
|
|
||||||
pub async fn run() -> anyhow::Result<()> {
|
pub async fn run() -> anyhow::Result<()> {
|
||||||
app::run().await
|
app::run().await
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
+324
-7
@@ -18,6 +18,7 @@ use std::time::Duration;
|
|||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum SetupMode {
|
pub enum SetupMode {
|
||||||
Explicit,
|
Explicit,
|
||||||
|
Login,
|
||||||
Auto,
|
Auto,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +46,23 @@ pub struct SetupSelection {
|
|||||||
pub supports_reasoning: bool,
|
pub supports_reasoning: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ProviderLogoutCandidate {
|
||||||
|
pub id: String,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub default_model: Option<String>,
|
||||||
|
pub model_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct LogoutResult {
|
||||||
|
pub removed_provider_ids: Vec<String>,
|
||||||
|
pub removed_model_count: usize,
|
||||||
|
pub remaining_provider_count: usize,
|
||||||
|
pub active_provider: Option<String>,
|
||||||
|
pub active_model: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct ModelsResponse {
|
struct ModelsResponse {
|
||||||
data: Vec<ModelItem>,
|
data: Vec<ModelItem>,
|
||||||
@@ -60,6 +78,13 @@ fn print_banner() {
|
|||||||
println!("Configure an OpenAI-compatible provider, API key environment variable, and model.");
|
println!("Configure an OpenAI-compatible provider, API key environment variable, and model.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn print_login_banner() {
|
||||||
|
println!("Cassady login");
|
||||||
|
println!(
|
||||||
|
"Add or update an OpenAI-compatible provider, API key environment variable, and model."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn section(title: &str) {
|
fn section(title: &str) {
|
||||||
println!("\n{title}");
|
println!("\n{title}");
|
||||||
println!("{}", "─".repeat(title.chars().count().max(12).min(72)));
|
println!("{}", "─".repeat(title.chars().count().max(12).min(72)));
|
||||||
@@ -187,10 +212,16 @@ pub async fn run(cli: &Cli, mode: SetupMode) -> Result<SetupOutcome> {
|
|||||||
fs::create_dir_all(&root).with_context(|| format!("creating {}", root.display()))?;
|
fs::create_dir_all(&root).with_context(|| format!("creating {}", root.display()))?;
|
||||||
|
|
||||||
if !io::stdin().is_terminal() {
|
if !io::stdin().is_terminal() {
|
||||||
bail!("setup is interactive; run `cass setup` in a terminal");
|
match mode {
|
||||||
|
SetupMode::Login => bail!("login is interactive; run `cass login` in a terminal"),
|
||||||
|
_ => bail!("setup is interactive; run `cass setup` in a terminal"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
print_banner();
|
match mode {
|
||||||
|
SetupMode::Login => print_login_banner(),
|
||||||
|
_ => print_banner(),
|
||||||
|
}
|
||||||
|
|
||||||
match mode {
|
match mode {
|
||||||
SetupMode::Explicit => {
|
SetupMode::Explicit => {
|
||||||
@@ -206,6 +237,7 @@ pub async fn run(cli: &Cli, mode: SetupMode) -> Result<SetupOutcome> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
SetupMode::Login => {}
|
||||||
SetupMode::Auto => {
|
SetupMode::Auto => {
|
||||||
println!();
|
println!();
|
||||||
hint("Cassady needs this before starting your first chat.");
|
hint("Cassady needs this before starting your first chat.");
|
||||||
@@ -234,28 +266,128 @@ pub async fn run(cli: &Cli, mode: SetupMode) -> Result<SetupOutcome> {
|
|||||||
let report = check::run(cli)?;
|
let report = check::run(cli)?;
|
||||||
if report.has_errors() {
|
if report.has_errors() {
|
||||||
if std::env::var(&active_api_key_env).is_err() {
|
if std::env::var(&active_api_key_env).is_err() {
|
||||||
section("Setup saved");
|
section(match mode {
|
||||||
|
SetupMode::Login => "Login saved",
|
||||||
|
_ => "Setup saved",
|
||||||
|
});
|
||||||
warn("Your active provider API key is not available in this shell.");
|
warn("Your active provider API key is not available in this shell.");
|
||||||
hint(format!("Set it with: export {active_api_key_env}=..."));
|
hint(format!("Set it with: export {active_api_key_env}=..."));
|
||||||
hint("Then run: cass");
|
hint("Then run: cass");
|
||||||
} else {
|
} else {
|
||||||
section("Setup saved with issues");
|
section(match mode {
|
||||||
|
SetupMode::Login => "Login saved with issues",
|
||||||
|
_ => "Setup saved with issues",
|
||||||
|
});
|
||||||
print!("{}", report.render());
|
print!("{}", report.render());
|
||||||
hint("Run `cass setup` to try again or edit ~/.cass/config.json manually.");
|
hint(match mode {
|
||||||
|
SetupMode::Login => {
|
||||||
|
"Run `cass login` to try again or edit ~/.cass/config.json manually."
|
||||||
|
}
|
||||||
|
_ => "Run `cass setup` to try again or edit ~/.cass/config.json manually.",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return Ok(SetupOutcome {
|
return Ok(SetupOutcome {
|
||||||
start_session: false,
|
start_session: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
section("Setup complete");
|
section(match mode {
|
||||||
|
SetupMode::Login => "Login complete",
|
||||||
|
_ => "Setup complete",
|
||||||
|
});
|
||||||
success("Configuration saved and validated");
|
success("Configuration saved and validated");
|
||||||
info("Starting your first Cassady session…");
|
match mode {
|
||||||
|
SetupMode::Login => info("Provider configuration is ready."),
|
||||||
|
_ => info("Starting your first Cassady session…"),
|
||||||
|
}
|
||||||
Ok(SetupOutcome {
|
Ok(SetupOutcome {
|
||||||
start_session: true,
|
start_session: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn logout(root: &Path) -> Result<LogoutResult> {
|
||||||
|
fs::create_dir_all(root).with_context(|| format!("creating {}", root.display()))?;
|
||||||
|
if !io::stdin().is_terminal() {
|
||||||
|
bail!("logout is interactive; run `cass logout` in a terminal");
|
||||||
|
}
|
||||||
|
let candidates = configured_providers(root)?;
|
||||||
|
if candidates.is_empty() {
|
||||||
|
bail!("no providers are configured; run `cass login` to add one");
|
||||||
|
}
|
||||||
|
|
||||||
|
section("Cassady logout");
|
||||||
|
warn("This removes provider entries from Cassady config only. It does not delete environment variables or provider accounts.");
|
||||||
|
|
||||||
|
let items = candidates
|
||||||
|
.iter()
|
||||||
|
.map(|candidate| {
|
||||||
|
let label = candidate.name.as_deref().unwrap_or(&candidate.id);
|
||||||
|
let model = candidate
|
||||||
|
.default_model
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("no default model");
|
||||||
|
MenuItem::with_detail(
|
||||||
|
label.to_string(),
|
||||||
|
format!(
|
||||||
|
"{} · {} · {} model{}",
|
||||||
|
candidate.id,
|
||||||
|
model,
|
||||||
|
candidate.model_count,
|
||||||
|
if candidate.model_count == 1 { "" } else { "s" }
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let selected =
|
||||||
|
Menu::new("Remove saved providers", items).select_many(&BTreeSet::new(), true)?;
|
||||||
|
let provider_ids = selected
|
||||||
|
.into_iter()
|
||||||
|
.map(|idx| candidates[idx].id.clone())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let label = provider_ids.join(", ");
|
||||||
|
if !ask_yes_no(
|
||||||
|
&format!("Remove {label} and associated model entries?"),
|
||||||
|
false,
|
||||||
|
)? {
|
||||||
|
println!("Logout cancelled.");
|
||||||
|
return Ok(LogoutResult {
|
||||||
|
removed_provider_ids: Vec::new(),
|
||||||
|
removed_model_count: 0,
|
||||||
|
remaining_provider_count: candidates.len(),
|
||||||
|
active_provider: None,
|
||||||
|
active_model: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = remove_providers(root, &provider_ids)?;
|
||||||
|
if result.removed_provider_ids.is_empty() {
|
||||||
|
println!("No providers removed.");
|
||||||
|
} else {
|
||||||
|
success(format!(
|
||||||
|
"Removed {} provider{} and {} model entr{}",
|
||||||
|
result.removed_provider_ids.len(),
|
||||||
|
if result.removed_provider_ids.len() == 1 {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
"s"
|
||||||
|
},
|
||||||
|
result.removed_model_count,
|
||||||
|
if result.removed_model_count == 1 {
|
||||||
|
"y"
|
||||||
|
} else {
|
||||||
|
"ies"
|
||||||
|
}
|
||||||
|
));
|
||||||
|
if let Some(provider) = &result.active_provider {
|
||||||
|
let model = result.active_model.as_deref().unwrap_or("no default model");
|
||||||
|
info(format!("Active provider is now {provider} ({model})"));
|
||||||
|
} else {
|
||||||
|
warn("No providers remain. Run `cass login` before starting a chat.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct ChosenProvider {
|
struct ChosenProvider {
|
||||||
name: String,
|
name: String,
|
||||||
@@ -565,6 +697,191 @@ pub fn apply_setups(root: &Path, selections: &[SetupSelection], active_index: us
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn configured_providers(root: &Path) -> Result<Vec<ProviderLogoutCandidate>> {
|
||||||
|
let providers = load_providers_or_empty(root)?;
|
||||||
|
let models = load_models_or_empty(root)?;
|
||||||
|
Ok(providers
|
||||||
|
.providers
|
||||||
|
.into_iter()
|
||||||
|
.map(|provider| {
|
||||||
|
let model_count = models
|
||||||
|
.models
|
||||||
|
.iter()
|
||||||
|
.filter(|model| model.provider == provider.id)
|
||||||
|
.count();
|
||||||
|
ProviderLogoutCandidate {
|
||||||
|
id: provider.id,
|
||||||
|
name: provider.name,
|
||||||
|
default_model: provider.default_model,
|
||||||
|
model_count,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_providers(root: &Path, provider_ids: &[String]) -> Result<LogoutResult> {
|
||||||
|
if provider_ids.is_empty() {
|
||||||
|
bail!("select at least one provider to remove");
|
||||||
|
}
|
||||||
|
let selected: BTreeSet<String> = provider_ids
|
||||||
|
.iter()
|
||||||
|
.map(|id| id.trim().to_string())
|
||||||
|
.collect();
|
||||||
|
if selected.iter().any(|id| id.is_empty()) {
|
||||||
|
bail!("provider id must not be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::create_dir_all(root).with_context(|| format!("creating {}", root.display()))?;
|
||||||
|
let mut config_file = load_config_or_default(root)?;
|
||||||
|
let mut providers = load_providers_or_empty(root)?;
|
||||||
|
let mut models = load_models_or_empty(root)?;
|
||||||
|
|
||||||
|
let existing: BTreeSet<String> = providers
|
||||||
|
.providers
|
||||||
|
.iter()
|
||||||
|
.map(|provider| provider.id.clone())
|
||||||
|
.collect();
|
||||||
|
for id in &selected {
|
||||||
|
if !existing.contains(id) {
|
||||||
|
bail!("provider `{id}` is not configured");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let removed_provider_ids = providers
|
||||||
|
.providers
|
||||||
|
.iter()
|
||||||
|
.filter(|provider| selected.contains(&provider.id))
|
||||||
|
.map(|provider| provider.id.clone())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
providers
|
||||||
|
.providers
|
||||||
|
.retain(|provider| !selected.contains(&provider.id));
|
||||||
|
|
||||||
|
let before_models = models.models.len();
|
||||||
|
models
|
||||||
|
.models
|
||||||
|
.retain(|model| !selected.contains(&model.provider));
|
||||||
|
let removed_model_count = before_models - models.models.len();
|
||||||
|
|
||||||
|
repair_active_defaults(&mut config_file, &providers, &models);
|
||||||
|
|
||||||
|
write_json_pretty(&config::providers_path(root), &providers)?;
|
||||||
|
write_json_pretty(&config::models_path(root), &models)?;
|
||||||
|
write_json_pretty(&config::config_path(root), &config_file)?;
|
||||||
|
|
||||||
|
Ok(LogoutResult {
|
||||||
|
removed_provider_ids,
|
||||||
|
removed_model_count,
|
||||||
|
remaining_provider_count: providers.providers.len(),
|
||||||
|
active_provider: config_file.default_provider,
|
||||||
|
active_model: config_file.default_model,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn repair_active_defaults(
|
||||||
|
config_file: &mut ConfigFile,
|
||||||
|
providers: &ProvidersFile,
|
||||||
|
models: &ModelsFile,
|
||||||
|
) {
|
||||||
|
if providers.providers.is_empty() {
|
||||||
|
config_file.default_provider = None;
|
||||||
|
config_file.default_model = None;
|
||||||
|
config_file.default_reasoning_effort = None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_provider = config_file
|
||||||
|
.default_provider
|
||||||
|
.as_ref()
|
||||||
|
.filter(|id| {
|
||||||
|
providers
|
||||||
|
.providers
|
||||||
|
.iter()
|
||||||
|
.any(|provider| provider.id == **id)
|
||||||
|
})
|
||||||
|
.cloned();
|
||||||
|
let provider_id =
|
||||||
|
current_provider.unwrap_or_else(|| choose_provider_with_model(providers, models));
|
||||||
|
let model = config_file
|
||||||
|
.default_model
|
||||||
|
.as_ref()
|
||||||
|
.filter(|model| model_belongs_to_provider(models, &provider_id, model))
|
||||||
|
.cloned()
|
||||||
|
.or_else(|| default_model_for_provider(providers, models, &provider_id));
|
||||||
|
|
||||||
|
config_file.default_provider = Some(provider_id);
|
||||||
|
config_file.default_model = model;
|
||||||
|
if let Some(effort) = config_file.default_reasoning_effort {
|
||||||
|
if let Some(model_id) = config_file.default_model.as_deref() {
|
||||||
|
let model = models.models.iter().find(|model| {
|
||||||
|
model.provider == config_file.default_provider.as_deref().unwrap_or_default()
|
||||||
|
&& model.id == model_id
|
||||||
|
});
|
||||||
|
config_file.default_reasoning_effort = Some(effort.clamp_for_model(model));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn choose_provider_with_model(providers: &ProvidersFile, models: &ModelsFile) -> String {
|
||||||
|
providers
|
||||||
|
.providers
|
||||||
|
.iter()
|
||||||
|
.find(|provider| {
|
||||||
|
provider
|
||||||
|
.default_model
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|model| model_belongs_to_provider(models, &provider.id, model))
|
||||||
|
|| provider
|
||||||
|
.models
|
||||||
|
.iter()
|
||||||
|
.any(|model| model_belongs_to_provider(models, &provider.id, model))
|
||||||
|
|| models
|
||||||
|
.models
|
||||||
|
.iter()
|
||||||
|
.any(|model| model.provider == provider.id)
|
||||||
|
})
|
||||||
|
.or_else(|| providers.providers.first())
|
||||||
|
.map(|provider| provider.id.clone())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_model_for_provider(
|
||||||
|
providers: &ProvidersFile,
|
||||||
|
models: &ModelsFile,
|
||||||
|
provider_id: &str,
|
||||||
|
) -> Option<String> {
|
||||||
|
let provider = providers
|
||||||
|
.providers
|
||||||
|
.iter()
|
||||||
|
.find(|provider| provider.id == provider_id)?;
|
||||||
|
provider
|
||||||
|
.default_model
|
||||||
|
.as_ref()
|
||||||
|
.filter(|model| model_belongs_to_provider(models, provider_id, model))
|
||||||
|
.cloned()
|
||||||
|
.or_else(|| {
|
||||||
|
provider
|
||||||
|
.models
|
||||||
|
.iter()
|
||||||
|
.find(|model| model_belongs_to_provider(models, provider_id, model))
|
||||||
|
.cloned()
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
models
|
||||||
|
.models
|
||||||
|
.iter()
|
||||||
|
.find(|model| model.provider == provider_id)
|
||||||
|
.map(|model| model.id.clone())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn model_belongs_to_provider(models: &ModelsFile, provider_id: &str, model_id: &str) -> bool {
|
||||||
|
models
|
||||||
|
.models
|
||||||
|
.iter()
|
||||||
|
.any(|model| model.provider == provider_id && model.id == model_id)
|
||||||
|
}
|
||||||
|
|
||||||
fn upsert_provider(providers: &mut ProvidersFile, selection: &SetupSelection) {
|
fn upsert_provider(providers: &mut ProvidersFile, selection: &SetupSelection) {
|
||||||
let new_entry = ProviderDefinition {
|
let new_entry = ProviderDefinition {
|
||||||
id: selection.provider_id.clone(),
|
id: selection.provider_id.clone(),
|
||||||
|
|||||||
+75
-2
@@ -5,7 +5,7 @@ use crate::ui::theme;
|
|||||||
use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Parser, Tag, TagEnd};
|
use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Parser, Tag, TagEnd};
|
||||||
use ratatui::layout::{Constraint, Direction, Layout};
|
use ratatui::layout::{Constraint, Direction, Layout};
|
||||||
use ratatui::prelude::*;
|
use ratatui::prelude::*;
|
||||||
use ratatui::widgets::{Paragraph, Wrap};
|
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use unicode_width::UnicodeWidthChar;
|
use unicode_width::UnicodeWidthChar;
|
||||||
|
|
||||||
@@ -26,7 +26,20 @@ pub struct TranscriptBlock {
|
|||||||
pub content: String,
|
pub content: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct OverlayView {
|
||||||
|
pub title: String,
|
||||||
|
pub help: String,
|
||||||
|
pub items: Vec<OverlayItem>,
|
||||||
|
pub selected: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct OverlayItem {
|
||||||
|
pub label: String,
|
||||||
|
pub detail: String,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct RenderState<'a> {
|
pub struct RenderState<'a> {
|
||||||
pub app_name: &'a str,
|
pub app_name: &'a str,
|
||||||
pub chat_id: &'a str,
|
pub chat_id: &'a str,
|
||||||
@@ -42,6 +55,7 @@ pub struct RenderState<'a> {
|
|||||||
pub reasoning_effort: ReasoningEffort,
|
pub reasoning_effort: ReasoningEffort,
|
||||||
pub scroll: u16,
|
pub scroll: u16,
|
||||||
pub autofill: Option<&'a AutoFillMenu>,
|
pub autofill: Option<&'a AutoFillMenu>,
|
||||||
|
pub overlay: Option<&'a OverlayView>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn render(f: &mut Frame<'_>, state: &RenderState<'_>) {
|
pub fn render(f: &mut Frame<'_>, state: &RenderState<'_>) {
|
||||||
@@ -74,6 +88,10 @@ pub fn render(f: &mut Frame<'_>, state: &RenderState<'_>) {
|
|||||||
|
|
||||||
let footer = truncate_end(&footer_text(state), chunks[3].width as usize);
|
let footer = truncate_end(&footer_text(state), chunks[3].width as usize);
|
||||||
f.render_widget(Paragraph::new(footer).style(theme::footer()), chunks[3]);
|
f.render_widget(Paragraph::new(footer).style(theme::footer()), chunks[3]);
|
||||||
|
|
||||||
|
if let Some(overlay) = state.overlay {
|
||||||
|
render_overlay(f, f.area(), overlay);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn transcript_area(area: Rect, input: &str) -> Rect {
|
pub fn transcript_area(area: Rect, input: &str) -> Rect {
|
||||||
@@ -116,6 +134,61 @@ fn autofill_height(menu: Option<&AutoFillMenu>) -> u16 {
|
|||||||
menu.map(|menu| menu.items.len().min(6) as u16).unwrap_or(0)
|
menu.map(|menu| menu.items.len().min(6) as u16).unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_overlay(f: &mut Frame<'_>, area: Rect, overlay: &OverlayView) {
|
||||||
|
let max_width = area.width.max(1);
|
||||||
|
let preferred_width = area.width.saturating_mul(4).saturating_div(5).max(40);
|
||||||
|
let width = preferred_width.min(max_width);
|
||||||
|
let max_height = area.height.saturating_sub(2).max(1);
|
||||||
|
let preferred_height = (overlay.items.len() as u16 + 5).max(8);
|
||||||
|
let height = preferred_height.min(max_height);
|
||||||
|
let x = area.x + area.width.saturating_sub(width) / 2;
|
||||||
|
let y = area.y + area.height.saturating_sub(height) / 2;
|
||||||
|
let rect = Rect::new(x, y, width, height);
|
||||||
|
f.render_widget(Clear, rect);
|
||||||
|
let block = Block::default()
|
||||||
|
.title(overlay.title.clone())
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.style(theme::menu());
|
||||||
|
let inner = block.inner(rect);
|
||||||
|
f.render_widget(block, rect);
|
||||||
|
|
||||||
|
let visible = inner.height.saturating_sub(2) as usize;
|
||||||
|
let selected = overlay.selected.min(overlay.items.len().saturating_sub(1));
|
||||||
|
let start = if selected >= visible && visible > 0 {
|
||||||
|
selected + 1 - visible
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let end = (start + visible).min(overlay.items.len());
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
lines.push(Line::styled(
|
||||||
|
truncate_end(&overlay.help, inner.width as usize),
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
));
|
||||||
|
for idx in start..end {
|
||||||
|
let item = &overlay.items[idx];
|
||||||
|
let marker = if idx == selected { "›" } else { " " };
|
||||||
|
let mut text = format!("{marker} {}", item.label);
|
||||||
|
if !item.detail.is_empty() {
|
||||||
|
text.push_str(" ");
|
||||||
|
text.push_str(&item.detail);
|
||||||
|
}
|
||||||
|
let style = if idx == selected {
|
||||||
|
theme::selection()
|
||||||
|
} else {
|
||||||
|
theme::menu()
|
||||||
|
};
|
||||||
|
lines.push(Line::styled(
|
||||||
|
truncate_end(&text, inner.width as usize),
|
||||||
|
style,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }),
|
||||||
|
inner,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn render_autofill_menu(f: &mut Frame<'_>, area: Rect, menu: &AutoFillMenu) {
|
fn render_autofill_menu(f: &mut Frame<'_>, area: Rect, menu: &AutoFillMenu) {
|
||||||
if area.height == 0 || menu.items.is_empty() {
|
if area.height == 0 || menu.items.is_empty() {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -27,3 +27,26 @@ pub fn leave(mut terminal: CassTerminal) -> Result<()> {
|
|||||||
terminal.show_cursor()?;
|
terminal.show_cursor()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn suspend(terminal: &mut CassTerminal) -> Result<()> {
|
||||||
|
disable_raw_mode()?;
|
||||||
|
execute!(
|
||||||
|
terminal.backend_mut(),
|
||||||
|
LeaveAlternateScreen,
|
||||||
|
DisableMouseCapture
|
||||||
|
)?;
|
||||||
|
terminal.show_cursor()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resume(terminal: &mut CassTerminal) -> Result<()> {
|
||||||
|
enable_raw_mode()?;
|
||||||
|
execute!(
|
||||||
|
terminal.backend_mut(),
|
||||||
|
EnterAlternateScreen,
|
||||||
|
EnableMouseCapture
|
||||||
|
)?;
|
||||||
|
terminal.clear()?;
|
||||||
|
terminal.hide_cursor()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
+1255
File diff suppressed because it is too large
Load Diff
@@ -23,3 +23,22 @@ fn conversation_appends_loads_and_lists_by_cwd() {
|
|||||||
assert_eq!(chats[0].id, convo.id);
|
assert_eq!(chats[0].id, convo.id);
|
||||||
assert_eq!(chats[0].first_user_preview, "hello world");
|
assert_eq!(chats[0].first_user_preview, "hello world");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_meta_without_branch_fields_still_loads() {
|
||||||
|
let root = tempdir().unwrap();
|
||||||
|
let id = "legacy";
|
||||||
|
std::fs::write(
|
||||||
|
root.path().join(format!("{id}.jsonl")),
|
||||||
|
r#"{"type":"meta","chat_id":"legacy","created_at":"now","model":"m","cwd":"/tmp"}
|
||||||
|
{"type":"system","content":"base"}
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let (loaded, warning) = Conversation::load(root.path(), id).unwrap();
|
||||||
|
assert!(warning.is_none());
|
||||||
|
let meta = loaded.meta().unwrap();
|
||||||
|
assert_eq!(meta.parent_chat_id, None);
|
||||||
|
assert_eq!(meta.branch_from, None);
|
||||||
|
}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ fn expected_bundled_docs_exist() {
|
|||||||
"configuration.md",
|
"configuration.md",
|
||||||
"providers.md",
|
"providers.md",
|
||||||
"access-modes.md",
|
"access-modes.md",
|
||||||
|
"embedding.md",
|
||||||
"workflows.md",
|
"workflows.md",
|
||||||
"troubleshooting.md",
|
"troubleshooting.md",
|
||||||
"platforms.md",
|
"platforms.md",
|
||||||
|
|||||||
@@ -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\""));
|
||||||
|
}
|
||||||
@@ -197,6 +197,146 @@ fn apply_setups_writes_multiple_providers_and_active_choice() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_providers_removes_models_and_preserves_active_provider() {
|
||||||
|
let root = tempdir().unwrap();
|
||||||
|
let selections = vec![
|
||||||
|
SetupSelection {
|
||||||
|
provider_id: "openai".into(),
|
||||||
|
provider_name: "OpenAI".into(),
|
||||||
|
base_url: "https://api.openai.com/v1".into(),
|
||||||
|
api_key_env: "OPENAI_API_KEY".into(),
|
||||||
|
model_id: "gpt-4.1".into(),
|
||||||
|
supports_tools: true,
|
||||||
|
supports_reasoning: true,
|
||||||
|
},
|
||||||
|
SetupSelection {
|
||||||
|
provider_id: "groq".into(),
|
||||||
|
provider_name: "Groq".into(),
|
||||||
|
base_url: "https://api.groq.com/openai/v1".into(),
|
||||||
|
api_key_env: "GROQ_API_KEY".into(),
|
||||||
|
model_id: "llama-3.3-70b-versatile".into(),
|
||||||
|
supports_tools: true,
|
||||||
|
supports_reasoning: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
setup::apply_setups(root.path(), &selections, 0).unwrap();
|
||||||
|
|
||||||
|
let result = setup::remove_providers(root.path(), &["groq".to_string()]).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.removed_provider_ids, vec!["groq"]);
|
||||||
|
assert_eq!(result.removed_model_count, 1);
|
||||||
|
assert_eq!(result.remaining_provider_count, 1);
|
||||||
|
assert_eq!(result.active_provider.as_deref(), Some("openai"));
|
||||||
|
assert_eq!(result.active_model.as_deref(), Some("gpt-4.1"));
|
||||||
|
|
||||||
|
let providers: ProvidersFile =
|
||||||
|
serde_json::from_str(&std::fs::read_to_string(root.path().join("providers.json")).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(providers.providers.len(), 1);
|
||||||
|
assert_eq!(providers.providers[0].id, "openai");
|
||||||
|
|
||||||
|
let models: ModelsFile =
|
||||||
|
serde_json::from_str(&std::fs::read_to_string(root.path().join("models.json")).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(models.models.len(), 1);
|
||||||
|
assert_eq!(models.models[0].provider, "openai");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_active_provider_selects_remaining_provider_and_model() {
|
||||||
|
let root = tempdir().unwrap();
|
||||||
|
let selections = vec![
|
||||||
|
SetupSelection {
|
||||||
|
provider_id: "openai".into(),
|
||||||
|
provider_name: "OpenAI".into(),
|
||||||
|
base_url: "https://api.openai.com/v1".into(),
|
||||||
|
api_key_env: "OPENAI_API_KEY".into(),
|
||||||
|
model_id: "gpt-4.1".into(),
|
||||||
|
supports_tools: true,
|
||||||
|
supports_reasoning: true,
|
||||||
|
},
|
||||||
|
SetupSelection {
|
||||||
|
provider_id: "groq".into(),
|
||||||
|
provider_name: "Groq".into(),
|
||||||
|
base_url: "https://api.groq.com/openai/v1".into(),
|
||||||
|
api_key_env: "GROQ_API_KEY".into(),
|
||||||
|
model_id: "llama-3.3-70b-versatile".into(),
|
||||||
|
supports_tools: true,
|
||||||
|
supports_reasoning: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
setup::apply_setups(root.path(), &selections, 1).unwrap();
|
||||||
|
|
||||||
|
let result = setup::remove_providers(root.path(), &["groq".to_string()]).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.active_provider.as_deref(), Some("openai"));
|
||||||
|
assert_eq!(result.active_model.as_deref(), Some("gpt-4.1"));
|
||||||
|
let config: ConfigFile =
|
||||||
|
serde_json::from_str(&std::fs::read_to_string(root.path().join("config.json")).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(config.default_provider.as_deref(), Some("openai"));
|
||||||
|
assert_eq!(config.default_model.as_deref(), Some("gpt-4.1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_all_providers_clears_active_defaults() {
|
||||||
|
let root = tempdir().unwrap();
|
||||||
|
setup::apply_setup(
|
||||||
|
root.path(),
|
||||||
|
&SetupSelection {
|
||||||
|
provider_id: "openai".into(),
|
||||||
|
provider_name: "OpenAI".into(),
|
||||||
|
base_url: "https://api.openai.com/v1".into(),
|
||||||
|
api_key_env: "OPENAI_API_KEY".into(),
|
||||||
|
model_id: "gpt-4.1".into(),
|
||||||
|
supports_tools: true,
|
||||||
|
supports_reasoning: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result = setup::remove_providers(root.path(), &["openai".to_string()]).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.remaining_provider_count, 0);
|
||||||
|
assert!(result.active_provider.is_none());
|
||||||
|
assert!(result.active_model.is_none());
|
||||||
|
|
||||||
|
let config: ConfigFile =
|
||||||
|
serde_json::from_str(&std::fs::read_to_string(root.path().join("config.json")).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
assert!(config.default_provider.is_none());
|
||||||
|
assert!(config.default_model.is_none());
|
||||||
|
|
||||||
|
let models: ModelsFile =
|
||||||
|
serde_json::from_str(&std::fs::read_to_string(root.path().join("models.json")).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
assert!(models.models.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_unknown_provider_fails() {
|
||||||
|
let root = tempdir().unwrap();
|
||||||
|
setup::apply_setup(
|
||||||
|
root.path(),
|
||||||
|
&SetupSelection {
|
||||||
|
provider_id: "openai".into(),
|
||||||
|
provider_name: "OpenAI".into(),
|
||||||
|
base_url: "https://api.openai.com/v1".into(),
|
||||||
|
api_key_env: "OPENAI_API_KEY".into(),
|
||||||
|
model_id: "gpt-4.1".into(),
|
||||||
|
supports_tools: true,
|
||||||
|
supports_reasoning: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let err = setup::remove_providers(root.path(), &["missing".to_string()]).unwrap_err();
|
||||||
|
assert!(err
|
||||||
|
.to_string()
|
||||||
|
.contains("provider `missing` is not configured"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn needs_initial_setup_detects_empty_and_default_only_roots() {
|
fn needs_initial_setup_detects_empty_and_default_only_roots() {
|
||||||
let root = tempdir().unwrap();
|
let root = tempdir().unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user