mirror of
https://github.com/instructkr/claw-code.git
synced 2026-06-12 01:49:30 +02:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 864a9124fc | |||
| 3220db2d6f | |||
| 54ac89e9f8 | |||
| a3e1002b7f | |||
| 2b1ccb7768 | |||
| 92b784077f | |||
| b293f37734 | |||
| cdd60faf86 | |||
| ab109f698c | |||
| e45e6d1eb0 | |||
| 5a5ff07af2 | |||
| dc12238d4a | |||
| dbb461efd2 | |||
| 5579d8faf9 | |||
| e173c4ec74 | |||
| 9113c87594 | |||
| 94e6748552 | |||
| 12182d8b3c | |||
| 821199640a | |||
| f02b21197d | |||
| d27c8b3ca6 | |||
| 2ae61f356c | |||
| 49151afe69 | |||
| 48e36d422a | |||
| 12e935b30f | |||
| 405bf0efa4 | |||
| c0929aaab5 | |||
| 2f54a3c11b | |||
| 5de4d7ec8b | |||
| 8b0bd55350 | |||
| 9e26dcec1d | |||
| 498f62823e | |||
| a74eb973bb | |||
| 76ad0a8ee9 | |||
| 35ed604654 | |||
| 2ac4a40589 | |||
| 55a1061968 |
@@ -33,6 +33,59 @@
|
||||
|
||||
---
|
||||
|
||||
## Built with oh-my-opencode
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/code-yeongyu/oh-my-openagent">
|
||||
<img src="https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/.github/assets/omo.png" width="600" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/code-yeongyu/oh-my-openagent"><strong>oh-my-opencode</strong></a> — the agent orchestration layer that makes AI coding actually work.
|
||||
<br />
|
||||
<em>Sisyphus doesn't stop until the task is done. Every test passes. Every review clears.</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/code-yeongyu/oh-my-openagent"><img src="https://img.shields.io/github/stars/code-yeongyu/oh-my-openagent?color=ffcb47&labelColor=black&style=for-the-badge&logo=github" /></a>
|
||||
<a href="https://www.npmjs.com/package/oh-my-opencode"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fohmyopenagent.com%2Fapi%2Fnpm-downloads&style=for-the-badge" /></a>
|
||||
<a href="https://discord.gg/PUwSMR9XNk"><img src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=for-the-badge" /></a>
|
||||
</p>
|
||||
|
||||
The **entire Rust port** was built by oh-my-opencode's **Sisyphus** agent in `ultrawork` mode.
|
||||
|
||||
> *"If Claude Code does in 7 days what a human does in 3 months, Sisyphus does it in 1 hour."* — B, Quant Researcher
|
||||
|
||||
> *"Oh My OpenCode Is Actually Insane"* — [YouTube - Darren Builds AI](https://www.youtube.com/watch?v=G_Snfh2M41M)
|
||||
|
||||
**Credits:** [@code-yeongyu](https://github.com/code-yeongyu) (oh-my-opencode creator) · **Sisyphus** (autonomous coding agent) · **Jobdori**
|
||||
|
||||
<p align="center"><code>npx oh-my-opencode@latest</code></p>
|
||||
|
||||
---
|
||||
|
||||
## Rust Port
|
||||
|
||||
The Rust workspace under `rust/` is the current systems-language port of the project.
|
||||
|
||||
It currently includes:
|
||||
|
||||
- `crates/api-client` — API client with provider abstraction, OAuth, and streaming support
|
||||
- `crates/runtime` — session state, compaction, MCP orchestration, prompt construction
|
||||
- `crates/tools` — tool manifest definitions and execution framework
|
||||
- `crates/commands` — slash commands, skills discovery, and config inspection
|
||||
- `crates/plugins` — plugin model, hook pipeline, and bundled plugins
|
||||
- `crates/compat-harness` — compatibility layer for upstream editor integration
|
||||
- `crates/claw-cli` — interactive REPL, markdown rendering, and project bootstrap/init flows
|
||||
|
||||
Run the Rust build:
|
||||
|
||||
```bash
|
||||
cd rust
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
## Backstory
|
||||
|
||||
At 4 AM on March 31, 2026, I woke up to my phone blowing up with notifications. The Claw Code source had been exposed, and the entire dev community was in a frenzy. My girlfriend in Korea was genuinely worried I might face legal action from the original authors just for having the code on my machine — so I did what any engineer would do under pressure: I sat down, ported the core features to Python from scratch, and pushed it before the sun came up.
|
||||
@@ -41,6 +94,8 @@ The whole thing was orchestrated end-to-end using [oh-my-codex (OmX)](https://gi
|
||||
|
||||
The result is a clean-room Python rewrite that captures the architectural patterns of Claw Code's agent harness without copying any proprietary source. I'm now actively collaborating with [@bellman_ych](https://x.com/bellman_ych) — the creator of OmX himself — to push this further. The basic Python foundation is already in place and functional, but we're just getting started. **Stay tuned — a much more capable version is on the way.**
|
||||
|
||||
The Rust port was built separately using [oh-my-opencode (OMO)](https://github.com/code-yeongyu/oh-my-opencode) by [@q_yeon_gyu_kim](https://x.com/q_yeon_gyu_kim) ([@code-yeongyu](https://github.com/code-yeongyu)), which orchestrates [opencode](https://opencode.ai) agents. **The scaffolding and architecture direction were established with [oh-my-codex (OmX)](https://github.com/Yeachan-Heo/oh-my-codex),** and the **Sisyphus** agent then handled implementation work across the API client, runtime engine, CLI, plugin system, MCP integration, and the cleanroom pass in `ultrawork` mode.
|
||||
|
||||
https://github.com/instructkr/claw-code
|
||||
|
||||

|
||||
@@ -92,6 +147,15 @@ This repository now focuses on Python porting work instead.
|
||||
│ ├── query_engine.py
|
||||
│ ├── task.py
|
||||
│ └── tools.py
|
||||
├── rust/ # Rust port (claw CLI)
|
||||
│ ├── crates/api/ # API client + streaming
|
||||
│ ├── crates/runtime/ # Session, tools, MCP, config
|
||||
│ ├── crates/claw-cli/ # Interactive CLI binary
|
||||
│ ├── crates/plugins/ # Plugin system
|
||||
│ ├── crates/commands/ # Slash commands
|
||||
│ ├── crates/server/ # HTTP/SSE server (axum)
|
||||
│ ├── crates/lsp/ # LSP client integration
|
||||
│ └── crates/tools/ # Tool specs
|
||||
├── tests/ # Python verification
|
||||
├── assets/omx/ # OmX workflow screenshots
|
||||
├── 2026-03-09-is-legal-the-same-as-legitimate-ai-reimplementation-and-the-erosion-of-copyleft.md
|
||||
@@ -152,14 +216,19 @@ python3 -m src.main tools --limit 10
|
||||
|
||||
The port now mirrors the archived root-entry file surface, top-level subsystem names, and command/tool inventories much more closely than before. However, it is **not yet** a full runtime-equivalent replacement for the original TypeScript system; the Python tree still contains fewer executable runtime slices than the archived source.
|
||||
|
||||
## Built with `oh-my-codex` and `oh-my-opencode`
|
||||
|
||||
## Built with `oh-my-codex`
|
||||
This repository's porting, cleanroom hardening, and verification workflow was AI-assisted with Yeachan Heo's tooling stack, with **oh-my-codex (OmX)** as the primary scaffolding and orchestration layer.
|
||||
|
||||
The restructuring and documentation work on this repository was AI-assisted and orchestrated with Yeachan Heo's [oh-my-codex (OmX)](https://github.com/Yeachan-Heo/oh-my-codex), layered on top of Codex.
|
||||
- [**oh-my-codex (OmX)**](https://github.com/Yeachan-Heo/oh-my-codex) — main branch credit: primary scaffolding, orchestration, and core porting workflow
|
||||
- [**oh-my-opencode (OmO)**](https://github.com/instructkr/oh-my-opencode) — implementation acceleration, cleanup passes, and verification support
|
||||
|
||||
- **`$team` mode:** used for coordinated parallel review and architectural feedback
|
||||
- **`$ralph` mode:** used for persistent execution, verification, and completion discipline
|
||||
- **Codex-driven workflow:** used to turn the main `src/` tree into a Python-first porting workspace
|
||||
Key workflow patterns used during the port:
|
||||
|
||||
- **`$team` mode:** coordinated parallel review and architectural feedback
|
||||
- **`$ralph` mode:** persistent execution, verification, and completion discipline
|
||||
- **Cleanroom passes:** naming/branding cleanup, QA, and release validation across the Rust workspace
|
||||
- **Manual and live validation:** build, test, manual QA, and real API-path verification before publish
|
||||
|
||||
### OmX workflow screenshots
|
||||
|
||||
|
||||
Generated
+221
-11
@@ -28,12 +28,86 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-stream"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
|
||||
dependencies = [
|
||||
"async-stream-impl",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-stream-impl"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
|
||||
dependencies = [
|
||||
"axum-core",
|
||||
"bytes",
|
||||
"form_urlencoded",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"itoa",
|
||||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"serde_core",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-core"
|
||||
version = "0.5.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"pin-project-lite",
|
||||
"sync_wrapper",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
@@ -49,6 +123,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.0"
|
||||
@@ -167,7 +247,7 @@ version = "0.28.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.11.0",
|
||||
"crossterm_winapi",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
@@ -262,7 +342,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -281,6 +361,15 @@ dependencies = [
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fluent-uri"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
@@ -318,6 +407,17 @@ version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.32"
|
||||
@@ -338,6 +438,7 @@ checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"memchr",
|
||||
@@ -451,6 +552,12 @@ version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.9.0"
|
||||
@@ -464,6 +571,7 @@ dependencies = [
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"smallvec",
|
||||
@@ -708,12 +816,48 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "lsp"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"lsp-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lsp-types"
|
||||
version = "0.97.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"fluent-uri",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -751,7 +895,7 @@ version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.11.0",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
@@ -775,7 +919,7 @@ version = "6.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.11.0",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"onig_sys",
|
||||
@@ -892,7 +1036,7 @@ version = "0.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.11.0",
|
||||
"getopts",
|
||||
"memchr",
|
||||
"pulldown-cmark-escape",
|
||||
@@ -1029,7 +1173,7 @@ version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1091,12 +1235,14 @@ dependencies = [
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
]
|
||||
@@ -1120,6 +1266,7 @@ name = "runtime"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"glob",
|
||||
"lsp",
|
||||
"plugins",
|
||||
"regex",
|
||||
"serde",
|
||||
@@ -1141,11 +1288,11 @@ version = "0.38.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.11.0",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.4.15",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1154,7 +1301,7 @@ version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.11.0",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
@@ -1208,7 +1355,7 @@ version = "15.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ee1e066dc922e513bda599c6ccb5f3bb2b0ea5870a579448f2622993f0a9a2f"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.11.0",
|
||||
"cfg-if",
|
||||
"clipboard-win",
|
||||
"fd-lock",
|
||||
@@ -1288,6 +1435,28 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_path_to_error"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_repr"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
@@ -1300,6 +1469,19 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "server"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"axum",
|
||||
"reqwest",
|
||||
"runtime",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
@@ -1553,6 +1735,19 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tools"
|
||||
version = "0.1.0"
|
||||
@@ -1579,6 +1774,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1587,7 +1783,7 @@ version = "0.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.11.0",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
@@ -1617,6 +1813,7 @@ version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"tracing-core",
|
||||
]
|
||||
@@ -1791,6 +1988,19 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.93"
|
||||
|
||||
@@ -9,6 +9,7 @@ license = "MIT"
|
||||
publish = false
|
||||
|
||||
[workspace.dependencies]
|
||||
lsp-types = "0.97"
|
||||
serde_json = "1"
|
||||
|
||||
[workspace.lints.rust]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::error::ApiError;
|
||||
use crate::providers::claw_provider::{self, ClawApiClient, AuthSource};
|
||||
use crate::providers::claw_provider::{self, AuthSource, ClawApiClient};
|
||||
use crate::providers::openai_compat::{self, OpenAiCompatClient, OpenAiCompatConfig};
|
||||
use crate::providers::{self, Provider, ProviderKind};
|
||||
use crate::types::{MessageRequest, MessageResponse, StreamEvent};
|
||||
|
||||
@@ -9,7 +9,7 @@ pub use client::{
|
||||
resolve_startup_auth_source, MessageStream, OAuthTokenSet, ProviderClient,
|
||||
};
|
||||
pub use error::ApiError;
|
||||
pub use providers::claw_provider::{ClawApiClient, ClawApiClient as ApiClient, AuthSource};
|
||||
pub use providers::claw_provider::{AuthSource, ClawApiClient, ClawApiClient as ApiClient};
|
||||
pub use providers::openai_compat::{OpenAiCompatClient, OpenAiCompatConfig};
|
||||
pub use providers::{
|
||||
detect_provider_kind, max_tokens_for_model, resolve_model_alias, ProviderKind,
|
||||
|
||||
@@ -652,7 +652,7 @@ mod tests {
|
||||
|
||||
use super::{
|
||||
now_unix_timestamp, oauth_token_is_expired, resolve_saved_oauth_token,
|
||||
resolve_startup_auth_source, ClawApiClient, AuthSource, OAuthTokenSet,
|
||||
resolve_startup_auth_source, AuthSource, ClawApiClient, OAuthTokenSet,
|
||||
};
|
||||
use crate::types::{ContentBlockDelta, MessageRequest};
|
||||
|
||||
|
||||
@@ -290,8 +290,7 @@ async fn live_stream_smoke_test() {
|
||||
let client = ApiClient::from_env().expect("ANTHROPIC_API_KEY must be set");
|
||||
let mut stream = client
|
||||
.stream_message(&MessageRequest {
|
||||
model: std::env::var("CLAW_MODEL")
|
||||
.unwrap_or_else(|_| "claude-sonnet-4-6".to_string()),
|
||||
model: std::env::var("CLAW_MODEL").unwrap_or_else(|_| "claude-sonnet-4-6".to_string()),
|
||||
max_tokens: 32,
|
||||
messages: vec![InputMessage::user_text(
|
||||
"Reply with exactly: hello from rust",
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::io::{self, Write};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::args::{OutputFormat, PermissionMode};
|
||||
use crate::input::{EditorMode, LineEditor, ReadOutcome};
|
||||
use crate::input::{LineEditor, ReadOutcome};
|
||||
use crate::render::{Spinner, TerminalRenderer};
|
||||
use runtime::{ConversationClient, ConversationMessage, RuntimeError, StreamEvent, UsageSummary};
|
||||
|
||||
@@ -111,7 +111,7 @@ impl CliApp {
|
||||
}
|
||||
|
||||
pub fn run_repl(&mut self) -> io::Result<()> {
|
||||
let mut editor = LineEditor::new("› ", Vec::new(), EditorMode::Emacs);
|
||||
let mut editor = LineEditor::new("› ", Vec::new());
|
||||
println!("Claw Code interactive mode");
|
||||
println!("Type /help for commands. Shift+Enter or Ctrl+J inserts a newline.");
|
||||
|
||||
@@ -162,6 +162,10 @@ impl CliApp {
|
||||
writeln!(out, "Unknown slash command: /{name}")?;
|
||||
Ok(CommandResult::Continue)
|
||||
}
|
||||
_ => {
|
||||
writeln!(out, "Slash command unavailable in this mode")?;
|
||||
Ok(CommandResult::Continue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +176,7 @@ impl CliApp {
|
||||
SlashCommand::Help => "/help",
|
||||
SlashCommand::Status => "/status",
|
||||
SlashCommand::Compact => "/compact",
|
||||
SlashCommand::Unknown(_) => continue,
|
||||
_ => continue,
|
||||
};
|
||||
writeln!(out, " {name:<9} {}", handler.summary)?;
|
||||
}
|
||||
|
||||
@@ -386,8 +386,7 @@ mod tests {
|
||||
let root = temp_dir();
|
||||
fs::create_dir_all(&root).expect("create root");
|
||||
fs::write(root.join("CLAW.md"), "custom guidance\n").expect("write existing claw md");
|
||||
fs::write(root.join(".gitignore"), ".claw/settings.local.json\n")
|
||||
.expect("write gitignore");
|
||||
fs::write(root.join(".gitignore"), ".claw/settings.local.json\n").expect("write gitignore");
|
||||
|
||||
let first = initialize_repo(&root).expect("first init should succeed");
|
||||
assert!(first
|
||||
|
||||
+1073
-197
File diff suppressed because it is too large
Load Diff
+388
-282
File diff suppressed because it is too large
Load Diff
+891
-30
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "lsp"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
lsp-types.workspace = true
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
tokio = { version = "1", features = ["io-util", "macros", "process", "rt", "rt-multi-thread", "sync", "time"] }
|
||||
url = "2"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,463 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
|
||||
use lsp_types::{
|
||||
Diagnostic, GotoDefinitionResponse, Location, LocationLink, Position, PublishDiagnosticsParams,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncReadExt, AsyncWriteExt, BufReader, BufWriter};
|
||||
use tokio::process::{Child, ChildStdin, ChildStdout, Command};
|
||||
use tokio::sync::{oneshot, Mutex};
|
||||
|
||||
use crate::error::LspError;
|
||||
use crate::types::{LspServerConfig, SymbolLocation};
|
||||
|
||||
pub(crate) struct LspClient {
|
||||
config: LspServerConfig,
|
||||
writer: Mutex<BufWriter<ChildStdin>>,
|
||||
child: Mutex<Child>,
|
||||
pending_requests: Arc<Mutex<BTreeMap<i64, oneshot::Sender<Result<Value, LspError>>>>>,
|
||||
diagnostics: Arc<Mutex<BTreeMap<String, Vec<Diagnostic>>>>,
|
||||
open_documents: Mutex<BTreeMap<PathBuf, i32>>,
|
||||
next_request_id: AtomicI64,
|
||||
}
|
||||
|
||||
impl LspClient {
|
||||
pub(crate) async fn connect(config: LspServerConfig) -> Result<Self, LspError> {
|
||||
let mut command = Command::new(&config.command);
|
||||
command
|
||||
.args(&config.args)
|
||||
.current_dir(&config.workspace_root)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.envs(config.env.clone());
|
||||
|
||||
let mut child = command.spawn()?;
|
||||
let stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| LspError::Protocol("missing LSP stdin pipe".to_string()))?;
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| LspError::Protocol("missing LSP stdout pipe".to_string()))?;
|
||||
let stderr = child.stderr.take();
|
||||
|
||||
let client = Self {
|
||||
config,
|
||||
writer: Mutex::new(BufWriter::new(stdin)),
|
||||
child: Mutex::new(child),
|
||||
pending_requests: Arc::new(Mutex::new(BTreeMap::new())),
|
||||
diagnostics: Arc::new(Mutex::new(BTreeMap::new())),
|
||||
open_documents: Mutex::new(BTreeMap::new()),
|
||||
next_request_id: AtomicI64::new(1),
|
||||
};
|
||||
|
||||
client.spawn_reader(stdout);
|
||||
if let Some(stderr) = stderr {
|
||||
client.spawn_stderr_drain(stderr);
|
||||
}
|
||||
client.initialize().await?;
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_document_open(&self, path: &Path) -> Result<(), LspError> {
|
||||
if self.is_document_open(path).await {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let contents = std::fs::read_to_string(path)?;
|
||||
self.open_document(path, &contents).await
|
||||
}
|
||||
|
||||
pub(crate) async fn open_document(&self, path: &Path, text: &str) -> Result<(), LspError> {
|
||||
let uri = file_url(path)?;
|
||||
let language_id = self
|
||||
.config
|
||||
.language_id_for(path)
|
||||
.ok_or_else(|| LspError::UnsupportedDocument(path.to_path_buf()))?;
|
||||
|
||||
self.notify(
|
||||
"textDocument/didOpen",
|
||||
json!({
|
||||
"textDocument": {
|
||||
"uri": uri,
|
||||
"languageId": language_id,
|
||||
"version": 1,
|
||||
"text": text,
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
self.open_documents
|
||||
.lock()
|
||||
.await
|
||||
.insert(path.to_path_buf(), 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn change_document(&self, path: &Path, text: &str) -> Result<(), LspError> {
|
||||
if !self.is_document_open(path).await {
|
||||
return self.open_document(path, text).await;
|
||||
}
|
||||
|
||||
let uri = file_url(path)?;
|
||||
let next_version = {
|
||||
let mut open_documents = self.open_documents.lock().await;
|
||||
let version = open_documents
|
||||
.entry(path.to_path_buf())
|
||||
.and_modify(|value| *value += 1)
|
||||
.or_insert(1);
|
||||
*version
|
||||
};
|
||||
|
||||
self.notify(
|
||||
"textDocument/didChange",
|
||||
json!({
|
||||
"textDocument": {
|
||||
"uri": uri,
|
||||
"version": next_version,
|
||||
},
|
||||
"contentChanges": [{
|
||||
"text": text,
|
||||
}],
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn save_document(&self, path: &Path) -> Result<(), LspError> {
|
||||
if !self.is_document_open(path).await {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.notify(
|
||||
"textDocument/didSave",
|
||||
json!({
|
||||
"textDocument": {
|
||||
"uri": file_url(path)?,
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn close_document(&self, path: &Path) -> Result<(), LspError> {
|
||||
if !self.is_document_open(path).await {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.notify(
|
||||
"textDocument/didClose",
|
||||
json!({
|
||||
"textDocument": {
|
||||
"uri": file_url(path)?,
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
self.open_documents.lock().await.remove(path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn is_document_open(&self, path: &Path) -> bool {
|
||||
self.open_documents.lock().await.contains_key(path)
|
||||
}
|
||||
|
||||
pub(crate) async fn go_to_definition(
|
||||
&self,
|
||||
path: &Path,
|
||||
position: Position,
|
||||
) -> Result<Vec<SymbolLocation>, LspError> {
|
||||
self.ensure_document_open(path).await?;
|
||||
let response = self
|
||||
.request::<Option<GotoDefinitionResponse>>(
|
||||
"textDocument/definition",
|
||||
json!({
|
||||
"textDocument": { "uri": file_url(path)? },
|
||||
"position": position,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(match response {
|
||||
Some(GotoDefinitionResponse::Scalar(location)) => {
|
||||
location_to_symbol_locations(vec![location])
|
||||
}
|
||||
Some(GotoDefinitionResponse::Array(locations)) => location_to_symbol_locations(locations),
|
||||
Some(GotoDefinitionResponse::Link(links)) => location_links_to_symbol_locations(links),
|
||||
None => Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn find_references(
|
||||
&self,
|
||||
path: &Path,
|
||||
position: Position,
|
||||
include_declaration: bool,
|
||||
) -> Result<Vec<SymbolLocation>, LspError> {
|
||||
self.ensure_document_open(path).await?;
|
||||
let response = self
|
||||
.request::<Option<Vec<Location>>>(
|
||||
"textDocument/references",
|
||||
json!({
|
||||
"textDocument": { "uri": file_url(path)? },
|
||||
"position": position,
|
||||
"context": {
|
||||
"includeDeclaration": include_declaration,
|
||||
},
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(location_to_symbol_locations(response.unwrap_or_default()))
|
||||
}
|
||||
|
||||
pub(crate) async fn diagnostics_snapshot(&self) -> BTreeMap<String, Vec<Diagnostic>> {
|
||||
self.diagnostics.lock().await.clone()
|
||||
}
|
||||
|
||||
pub(crate) async fn shutdown(&self) -> Result<(), LspError> {
|
||||
let _ = self.request::<Value>("shutdown", json!({})).await;
|
||||
let _ = self.notify("exit", Value::Null).await;
|
||||
|
||||
let mut child = self.child.lock().await;
|
||||
if child.kill().await.is_err() {
|
||||
let _ = child.wait().await;
|
||||
return Ok(());
|
||||
}
|
||||
let _ = child.wait().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn spawn_reader(&self, stdout: ChildStdout) {
|
||||
let diagnostics = &self.diagnostics;
|
||||
let pending_requests = &self.pending_requests;
|
||||
|
||||
let diagnostics = diagnostics.clone();
|
||||
let pending_requests = pending_requests.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(stdout);
|
||||
let result = async {
|
||||
while let Some(message) = read_message(&mut reader).await? {
|
||||
if let Some(id) = message.get("id").and_then(Value::as_i64) {
|
||||
let response = if let Some(error) = message.get("error") {
|
||||
Err(LspError::Protocol(error.to_string()))
|
||||
} else {
|
||||
Ok(message.get("result").cloned().unwrap_or(Value::Null))
|
||||
};
|
||||
|
||||
if let Some(sender) = pending_requests.lock().await.remove(&id) {
|
||||
let _ = sender.send(response);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(method) = message.get("method").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
if method != "textDocument/publishDiagnostics" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let params = message.get("params").cloned().unwrap_or(Value::Null);
|
||||
let notification = serde_json::from_value::<PublishDiagnosticsParams>(params)?;
|
||||
let mut diagnostics_map = diagnostics.lock().await;
|
||||
if notification.diagnostics.is_empty() {
|
||||
diagnostics_map.remove(¬ification.uri.to_string());
|
||||
} else {
|
||||
diagnostics_map.insert(notification.uri.to_string(), notification.diagnostics);
|
||||
}
|
||||
}
|
||||
Ok::<(), LspError>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
if let Err(error) = result {
|
||||
let mut pending = pending_requests.lock().await;
|
||||
let drained = pending
|
||||
.iter()
|
||||
.map(|(id, _)| *id)
|
||||
.collect::<Vec<_>>();
|
||||
for id in drained {
|
||||
if let Some(sender) = pending.remove(&id) {
|
||||
let _ = sender.send(Err(LspError::Protocol(error.to_string())));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn spawn_stderr_drain<R>(&self, stderr: R)
|
||||
where
|
||||
R: AsyncRead + Unpin + Send + 'static,
|
||||
{
|
||||
tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(stderr);
|
||||
let mut sink = Vec::new();
|
||||
let _ = reader.read_to_end(&mut sink).await;
|
||||
});
|
||||
}
|
||||
|
||||
async fn initialize(&self) -> Result<(), LspError> {
|
||||
let workspace_uri = file_url(&self.config.workspace_root)?;
|
||||
let _ = self
|
||||
.request::<Value>(
|
||||
"initialize",
|
||||
json!({
|
||||
"processId": std::process::id(),
|
||||
"rootUri": workspace_uri,
|
||||
"rootPath": self.config.workspace_root,
|
||||
"workspaceFolders": [{
|
||||
"uri": workspace_uri,
|
||||
"name": self.config.name,
|
||||
}],
|
||||
"initializationOptions": self.config.initialization_options.clone().unwrap_or(Value::Null),
|
||||
"capabilities": {
|
||||
"textDocument": {
|
||||
"publishDiagnostics": {
|
||||
"relatedInformation": true,
|
||||
},
|
||||
"definition": {
|
||||
"linkSupport": true,
|
||||
},
|
||||
"references": {}
|
||||
},
|
||||
"workspace": {
|
||||
"configuration": false,
|
||||
"workspaceFolders": true,
|
||||
},
|
||||
"general": {
|
||||
"positionEncodings": ["utf-16"],
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
self.notify("initialized", json!({})).await
|
||||
}
|
||||
|
||||
async fn request<T>(&self, method: &str, params: Value) -> Result<T, LspError>
|
||||
where
|
||||
T: for<'de> serde::Deserialize<'de>,
|
||||
{
|
||||
let id = self.next_request_id.fetch_add(1, Ordering::Relaxed);
|
||||
let (sender, receiver) = oneshot::channel();
|
||||
self.pending_requests.lock().await.insert(id, sender);
|
||||
|
||||
if let Err(error) = self
|
||||
.send_message(&json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"method": method,
|
||||
"params": params,
|
||||
}))
|
||||
.await
|
||||
{
|
||||
self.pending_requests.lock().await.remove(&id);
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
let response = receiver
|
||||
.await
|
||||
.map_err(|_| LspError::Protocol(format!("request channel closed for {method}")))??;
|
||||
Ok(serde_json::from_value(response)?)
|
||||
}
|
||||
|
||||
async fn notify(&self, method: &str, params: Value) -> Result<(), LspError> {
|
||||
self.send_message(&json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": method,
|
||||
"params": params,
|
||||
}))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn send_message(&self, payload: &Value) -> Result<(), LspError> {
|
||||
let body = serde_json::to_vec(payload)?;
|
||||
let mut writer = self.writer.lock().await;
|
||||
writer
|
||||
.write_all(format!("Content-Length: {}\r\n\r\n", body.len()).as_bytes())
|
||||
.await?;
|
||||
writer.write_all(&body).await?;
|
||||
writer.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_message<R>(reader: &mut BufReader<R>) -> Result<Option<Value>, LspError>
|
||||
where
|
||||
R: AsyncRead + Unpin,
|
||||
{
|
||||
let mut content_length = None;
|
||||
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
let read = reader.read_line(&mut line).await?;
|
||||
if read == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if line == "\r\n" {
|
||||
break;
|
||||
}
|
||||
|
||||
let trimmed = line.trim_end_matches(['\r', '\n']);
|
||||
if let Some((name, value)) = trimmed.split_once(':') {
|
||||
if name.eq_ignore_ascii_case("Content-Length") {
|
||||
let value = value.trim().to_string();
|
||||
content_length = Some(
|
||||
value
|
||||
.parse::<usize>()
|
||||
.map_err(|_| LspError::InvalidContentLength(value.clone()))?,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return Err(LspError::InvalidHeader(trimmed.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
let content_length = content_length.ok_or(LspError::MissingContentLength)?;
|
||||
let mut body = vec![0_u8; content_length];
|
||||
reader.read_exact(&mut body).await?;
|
||||
Ok(Some(serde_json::from_slice(&body)?))
|
||||
}
|
||||
|
||||
fn file_url(path: &Path) -> Result<String, LspError> {
|
||||
url::Url::from_file_path(path)
|
||||
.map(|url| url.to_string())
|
||||
.map_err(|()| LspError::PathToUrl(path.to_path_buf()))
|
||||
}
|
||||
|
||||
fn location_to_symbol_locations(locations: Vec<Location>) -> Vec<SymbolLocation> {
|
||||
locations
|
||||
.into_iter()
|
||||
.filter_map(|location| {
|
||||
uri_to_path(&location.uri.to_string()).map(|path| SymbolLocation {
|
||||
path,
|
||||
range: location.range,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn location_links_to_symbol_locations(links: Vec<LocationLink>) -> Vec<SymbolLocation> {
|
||||
links.into_iter()
|
||||
.filter_map(|link| {
|
||||
uri_to_path(&link.target_uri.to_string()).map(|path| SymbolLocation {
|
||||
path,
|
||||
range: link.target_selection_range,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn uri_to_path(uri: &str) -> Option<PathBuf> {
|
||||
url::Url::parse(uri).ok()?.to_file_path().ok()
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum LspError {
|
||||
Io(std::io::Error),
|
||||
Json(serde_json::Error),
|
||||
InvalidHeader(String),
|
||||
MissingContentLength,
|
||||
InvalidContentLength(String),
|
||||
UnsupportedDocument(PathBuf),
|
||||
UnknownServer(String),
|
||||
DuplicateExtension {
|
||||
extension: String,
|
||||
existing_server: String,
|
||||
new_server: String,
|
||||
},
|
||||
PathToUrl(PathBuf),
|
||||
Protocol(String),
|
||||
}
|
||||
|
||||
impl Display for LspError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Io(error) => write!(f, "{error}"),
|
||||
Self::Json(error) => write!(f, "{error}"),
|
||||
Self::InvalidHeader(header) => write!(f, "invalid LSP header: {header}"),
|
||||
Self::MissingContentLength => write!(f, "missing LSP Content-Length header"),
|
||||
Self::InvalidContentLength(value) => {
|
||||
write!(f, "invalid LSP Content-Length value: {value}")
|
||||
}
|
||||
Self::UnsupportedDocument(path) => {
|
||||
write!(f, "no LSP server configured for {}", path.display())
|
||||
}
|
||||
Self::UnknownServer(name) => write!(f, "unknown LSP server: {name}"),
|
||||
Self::DuplicateExtension {
|
||||
extension,
|
||||
existing_server,
|
||||
new_server,
|
||||
} => write!(
|
||||
f,
|
||||
"duplicate LSP extension mapping for {extension}: {existing_server} and {new_server}"
|
||||
),
|
||||
Self::PathToUrl(path) => write!(f, "failed to convert path to file URL: {}", path.display()),
|
||||
Self::Protocol(message) => write!(f, "LSP protocol error: {message}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for LspError {}
|
||||
|
||||
impl From<std::io::Error> for LspError {
|
||||
fn from(value: std::io::Error) -> Self {
|
||||
Self::Io(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for LspError {
|
||||
fn from(value: serde_json::Error) -> Self {
|
||||
Self::Json(value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
mod client;
|
||||
mod error;
|
||||
mod manager;
|
||||
mod types;
|
||||
|
||||
pub use error::LspError;
|
||||
pub use manager::LspManager;
|
||||
pub use types::{
|
||||
FileDiagnostics, LspContextEnrichment, LspServerConfig, SymbolLocation, WorkspaceDiagnostics,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use lsp_types::{DiagnosticSeverity, Position};
|
||||
|
||||
use crate::{LspManager, LspServerConfig};
|
||||
|
||||
fn temp_dir(label: &str) -> PathBuf {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("time should be after epoch")
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("lsp-{label}-{nanos}"))
|
||||
}
|
||||
|
||||
fn python3_path() -> Option<String> {
|
||||
let candidates = ["python3", "/usr/bin/python3"];
|
||||
candidates.iter().find_map(|candidate| {
|
||||
Command::new(candidate)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|output| output.status.success())
|
||||
.map(|_| (*candidate).to_string())
|
||||
})
|
||||
}
|
||||
|
||||
fn write_mock_server_script(root: &std::path::Path) -> PathBuf {
|
||||
let script_path = root.join("mock_lsp_server.py");
|
||||
fs::write(
|
||||
&script_path,
|
||||
r#"import json
|
||||
import sys
|
||||
|
||||
|
||||
def read_message():
|
||||
headers = {}
|
||||
while True:
|
||||
line = sys.stdin.buffer.readline()
|
||||
if not line:
|
||||
return None
|
||||
if line == b"\r\n":
|
||||
break
|
||||
key, value = line.decode("utf-8").split(":", 1)
|
||||
headers[key.lower()] = value.strip()
|
||||
length = int(headers["content-length"])
|
||||
body = sys.stdin.buffer.read(length)
|
||||
return json.loads(body)
|
||||
|
||||
|
||||
def write_message(payload):
|
||||
raw = json.dumps(payload).encode("utf-8")
|
||||
sys.stdout.buffer.write(f"Content-Length: {len(raw)}\r\n\r\n".encode("utf-8"))
|
||||
sys.stdout.buffer.write(raw)
|
||||
sys.stdout.buffer.flush()
|
||||
|
||||
|
||||
while True:
|
||||
message = read_message()
|
||||
if message is None:
|
||||
break
|
||||
|
||||
method = message.get("method")
|
||||
if method == "initialize":
|
||||
write_message({
|
||||
"jsonrpc": "2.0",
|
||||
"id": message["id"],
|
||||
"result": {
|
||||
"capabilities": {
|
||||
"definitionProvider": True,
|
||||
"referencesProvider": True,
|
||||
"textDocumentSync": 1,
|
||||
}
|
||||
},
|
||||
})
|
||||
elif method == "initialized":
|
||||
continue
|
||||
elif method == "textDocument/didOpen":
|
||||
document = message["params"]["textDocument"]
|
||||
write_message({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "textDocument/publishDiagnostics",
|
||||
"params": {
|
||||
"uri": document["uri"],
|
||||
"diagnostics": [
|
||||
{
|
||||
"range": {
|
||||
"start": {"line": 0, "character": 0},
|
||||
"end": {"line": 0, "character": 3},
|
||||
},
|
||||
"severity": 1,
|
||||
"source": "mock-server",
|
||||
"message": "mock error",
|
||||
}
|
||||
],
|
||||
},
|
||||
})
|
||||
elif method == "textDocument/didChange":
|
||||
continue
|
||||
elif method == "textDocument/didSave":
|
||||
continue
|
||||
elif method == "textDocument/definition":
|
||||
uri = message["params"]["textDocument"]["uri"]
|
||||
write_message({
|
||||
"jsonrpc": "2.0",
|
||||
"id": message["id"],
|
||||
"result": [
|
||||
{
|
||||
"uri": uri,
|
||||
"range": {
|
||||
"start": {"line": 0, "character": 0},
|
||||
"end": {"line": 0, "character": 3},
|
||||
},
|
||||
}
|
||||
],
|
||||
})
|
||||
elif method == "textDocument/references":
|
||||
uri = message["params"]["textDocument"]["uri"]
|
||||
write_message({
|
||||
"jsonrpc": "2.0",
|
||||
"id": message["id"],
|
||||
"result": [
|
||||
{
|
||||
"uri": uri,
|
||||
"range": {
|
||||
"start": {"line": 0, "character": 0},
|
||||
"end": {"line": 0, "character": 3},
|
||||
},
|
||||
},
|
||||
{
|
||||
"uri": uri,
|
||||
"range": {
|
||||
"start": {"line": 1, "character": 4},
|
||||
"end": {"line": 1, "character": 7},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
elif method == "shutdown":
|
||||
write_message({"jsonrpc": "2.0", "id": message["id"], "result": None})
|
||||
elif method == "exit":
|
||||
break
|
||||
"#,
|
||||
)
|
||||
.expect("mock server should be written");
|
||||
script_path
|
||||
}
|
||||
|
||||
async fn wait_for_diagnostics(manager: &LspManager) {
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
loop {
|
||||
if manager
|
||||
.collect_workspace_diagnostics()
|
||||
.await
|
||||
.expect("diagnostics snapshot should load")
|
||||
.total_diagnostics()
|
||||
> 0
|
||||
{
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("diagnostics should arrive from mock server");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn collects_diagnostics_and_symbol_navigation_from_mock_server() {
|
||||
let Some(python) = python3_path() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// given
|
||||
let root = temp_dir("manager");
|
||||
fs::create_dir_all(root.join("src")).expect("workspace root should exist");
|
||||
let script_path = write_mock_server_script(&root);
|
||||
let source_path = root.join("src").join("main.rs");
|
||||
fs::write(&source_path, "fn main() {}\nlet value = 1;\n").expect("source file should exist");
|
||||
let manager = LspManager::new(vec![LspServerConfig {
|
||||
name: "rust-analyzer".to_string(),
|
||||
command: python,
|
||||
args: vec![script_path.display().to_string()],
|
||||
env: BTreeMap::new(),
|
||||
workspace_root: root.clone(),
|
||||
initialization_options: None,
|
||||
extension_to_language: BTreeMap::from([(".rs".to_string(), "rust".to_string())]),
|
||||
}])
|
||||
.expect("manager should build");
|
||||
manager
|
||||
.open_document(&source_path, &fs::read_to_string(&source_path).expect("source read should succeed"))
|
||||
.await
|
||||
.expect("document should open");
|
||||
wait_for_diagnostics(&manager).await;
|
||||
|
||||
// when
|
||||
let diagnostics = manager
|
||||
.collect_workspace_diagnostics()
|
||||
.await
|
||||
.expect("diagnostics should be available");
|
||||
let definitions = manager
|
||||
.go_to_definition(&source_path, Position::new(0, 0))
|
||||
.await
|
||||
.expect("definition request should succeed");
|
||||
let references = manager
|
||||
.find_references(&source_path, Position::new(0, 0), true)
|
||||
.await
|
||||
.expect("references request should succeed");
|
||||
|
||||
// then
|
||||
assert_eq!(diagnostics.files.len(), 1);
|
||||
assert_eq!(diagnostics.total_diagnostics(), 1);
|
||||
assert_eq!(diagnostics.files[0].diagnostics[0].severity, Some(DiagnosticSeverity::ERROR));
|
||||
assert_eq!(definitions.len(), 1);
|
||||
assert_eq!(definitions[0].start_line(), 1);
|
||||
assert_eq!(references.len(), 2);
|
||||
|
||||
manager.shutdown().await.expect("shutdown should succeed");
|
||||
fs::remove_dir_all(root).expect("temp workspace should be removed");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn renders_runtime_context_enrichment_for_prompt_usage() {
|
||||
let Some(python) = python3_path() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// given
|
||||
let root = temp_dir("prompt");
|
||||
fs::create_dir_all(root.join("src")).expect("workspace root should exist");
|
||||
let script_path = write_mock_server_script(&root);
|
||||
let source_path = root.join("src").join("lib.rs");
|
||||
fs::write(&source_path, "pub fn answer() -> i32 { 42 }\n").expect("source file should exist");
|
||||
let manager = LspManager::new(vec![LspServerConfig {
|
||||
name: "rust-analyzer".to_string(),
|
||||
command: python,
|
||||
args: vec![script_path.display().to_string()],
|
||||
env: BTreeMap::new(),
|
||||
workspace_root: root.clone(),
|
||||
initialization_options: None,
|
||||
extension_to_language: BTreeMap::from([(".rs".to_string(), "rust".to_string())]),
|
||||
}])
|
||||
.expect("manager should build");
|
||||
manager
|
||||
.open_document(&source_path, &fs::read_to_string(&source_path).expect("source read should succeed"))
|
||||
.await
|
||||
.expect("document should open");
|
||||
wait_for_diagnostics(&manager).await;
|
||||
|
||||
// when
|
||||
let enrichment = manager
|
||||
.context_enrichment(&source_path, Position::new(0, 0))
|
||||
.await
|
||||
.expect("context enrichment should succeed");
|
||||
let rendered = enrichment.render_prompt_section();
|
||||
|
||||
// then
|
||||
assert!(rendered.contains("# LSP context"));
|
||||
assert!(rendered.contains("Workspace diagnostics: 1 across 1 file(s)"));
|
||||
assert!(rendered.contains("Definitions:"));
|
||||
assert!(rendered.contains("References:"));
|
||||
assert!(rendered.contains("mock error"));
|
||||
|
||||
manager.shutdown().await.expect("shutdown should succeed");
|
||||
fs::remove_dir_all(root).expect("temp workspace should be removed");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use lsp_types::Position;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::client::LspClient;
|
||||
use crate::error::LspError;
|
||||
use crate::types::{
|
||||
normalize_extension, FileDiagnostics, LspContextEnrichment, LspServerConfig, SymbolLocation,
|
||||
WorkspaceDiagnostics,
|
||||
};
|
||||
|
||||
pub struct LspManager {
|
||||
server_configs: BTreeMap<String, LspServerConfig>,
|
||||
extension_map: BTreeMap<String, String>,
|
||||
clients: Mutex<BTreeMap<String, Arc<LspClient>>>,
|
||||
}
|
||||
|
||||
impl LspManager {
|
||||
pub fn new(server_configs: Vec<LspServerConfig>) -> Result<Self, LspError> {
|
||||
let mut configs_by_name = BTreeMap::new();
|
||||
let mut extension_map = BTreeMap::new();
|
||||
|
||||
for config in server_configs {
|
||||
for extension in config.extension_to_language.keys() {
|
||||
let normalized = normalize_extension(extension);
|
||||
if let Some(existing_server) = extension_map.insert(normalized.clone(), config.name.clone()) {
|
||||
return Err(LspError::DuplicateExtension {
|
||||
extension: normalized,
|
||||
existing_server,
|
||||
new_server: config.name.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
configs_by_name.insert(config.name.clone(), config);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
server_configs: configs_by_name,
|
||||
extension_map,
|
||||
clients: Mutex::new(BTreeMap::new()),
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn supports_path(&self, path: &Path) -> bool {
|
||||
path.extension().is_some_and(|extension| {
|
||||
let normalized = normalize_extension(extension.to_string_lossy().as_ref());
|
||||
self.extension_map.contains_key(&normalized)
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn open_document(&self, path: &Path, text: &str) -> Result<(), LspError> {
|
||||
self.client_for_path(path).await?.open_document(path, text).await
|
||||
}
|
||||
|
||||
pub async fn sync_document_from_disk(&self, path: &Path) -> Result<(), LspError> {
|
||||
let contents = std::fs::read_to_string(path)?;
|
||||
self.change_document(path, &contents).await?;
|
||||
self.save_document(path).await
|
||||
}
|
||||
|
||||
pub async fn change_document(&self, path: &Path, text: &str) -> Result<(), LspError> {
|
||||
self.client_for_path(path).await?.change_document(path, text).await
|
||||
}
|
||||
|
||||
pub async fn save_document(&self, path: &Path) -> Result<(), LspError> {
|
||||
self.client_for_path(path).await?.save_document(path).await
|
||||
}
|
||||
|
||||
pub async fn close_document(&self, path: &Path) -> Result<(), LspError> {
|
||||
self.client_for_path(path).await?.close_document(path).await
|
||||
}
|
||||
|
||||
pub async fn go_to_definition(
|
||||
&self,
|
||||
path: &Path,
|
||||
position: Position,
|
||||
) -> Result<Vec<SymbolLocation>, LspError> {
|
||||
let mut locations = self.client_for_path(path).await?.go_to_definition(path, position).await?;
|
||||
dedupe_locations(&mut locations);
|
||||
Ok(locations)
|
||||
}
|
||||
|
||||
pub async fn find_references(
|
||||
&self,
|
||||
path: &Path,
|
||||
position: Position,
|
||||
include_declaration: bool,
|
||||
) -> Result<Vec<SymbolLocation>, LspError> {
|
||||
let mut locations = self
|
||||
.client_for_path(path)
|
||||
.await?
|
||||
.find_references(path, position, include_declaration)
|
||||
.await?;
|
||||
dedupe_locations(&mut locations);
|
||||
Ok(locations)
|
||||
}
|
||||
|
||||
pub async fn collect_workspace_diagnostics(&self) -> Result<WorkspaceDiagnostics, LspError> {
|
||||
let clients = self.clients.lock().await.values().cloned().collect::<Vec<_>>();
|
||||
let mut files = Vec::new();
|
||||
|
||||
for client in clients {
|
||||
for (uri, diagnostics) in client.diagnostics_snapshot().await {
|
||||
let Ok(path) = url::Url::parse(&uri)
|
||||
.and_then(|url| url.to_file_path().map_err(|()| url::ParseError::RelativeUrlWithoutBase))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if diagnostics.is_empty() {
|
||||
continue;
|
||||
}
|
||||
files.push(FileDiagnostics {
|
||||
path,
|
||||
uri,
|
||||
diagnostics,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
files.sort_by(|left, right| left.path.cmp(&right.path));
|
||||
Ok(WorkspaceDiagnostics { files })
|
||||
}
|
||||
|
||||
pub async fn context_enrichment(
|
||||
&self,
|
||||
path: &Path,
|
||||
position: Position,
|
||||
) -> Result<LspContextEnrichment, LspError> {
|
||||
Ok(LspContextEnrichment {
|
||||
file_path: path.to_path_buf(),
|
||||
diagnostics: self.collect_workspace_diagnostics().await?,
|
||||
definitions: self.go_to_definition(path, position).await?,
|
||||
references: self.find_references(path, position, true).await?,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn shutdown(&self) -> Result<(), LspError> {
|
||||
let mut clients = self.clients.lock().await;
|
||||
let drained = clients.values().cloned().collect::<Vec<_>>();
|
||||
clients.clear();
|
||||
drop(clients);
|
||||
|
||||
for client in drained {
|
||||
client.shutdown().await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn client_for_path(&self, path: &Path) -> Result<Arc<LspClient>, LspError> {
|
||||
let extension = path
|
||||
.extension()
|
||||
.map(|extension| normalize_extension(extension.to_string_lossy().as_ref()))
|
||||
.ok_or_else(|| LspError::UnsupportedDocument(path.to_path_buf()))?;
|
||||
let server_name = self
|
||||
.extension_map
|
||||
.get(&extension)
|
||||
.cloned()
|
||||
.ok_or_else(|| LspError::UnsupportedDocument(path.to_path_buf()))?;
|
||||
|
||||
let mut clients = self.clients.lock().await;
|
||||
if let Some(client) = clients.get(&server_name) {
|
||||
return Ok(client.clone());
|
||||
}
|
||||
|
||||
let config = self
|
||||
.server_configs
|
||||
.get(&server_name)
|
||||
.cloned()
|
||||
.ok_or_else(|| LspError::UnknownServer(server_name.clone()))?;
|
||||
let client = Arc::new(LspClient::connect(config).await?);
|
||||
clients.insert(server_name, client.clone());
|
||||
Ok(client)
|
||||
}
|
||||
}
|
||||
|
||||
fn dedupe_locations(locations: &mut Vec<SymbolLocation>) {
|
||||
let mut seen = BTreeSet::new();
|
||||
locations.retain(|location| {
|
||||
seen.insert((
|
||||
location.path.clone(),
|
||||
location.range.start.line,
|
||||
location.range.start.character,
|
||||
location.range.end.line,
|
||||
location.range.end.character,
|
||||
))
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use lsp_types::{Diagnostic, Range};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LspServerConfig {
|
||||
pub name: String,
|
||||
pub command: String,
|
||||
pub args: Vec<String>,
|
||||
pub env: BTreeMap<String, String>,
|
||||
pub workspace_root: PathBuf,
|
||||
pub initialization_options: Option<Value>,
|
||||
pub extension_to_language: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl LspServerConfig {
|
||||
#[must_use]
|
||||
pub fn language_id_for(&self, path: &Path) -> Option<&str> {
|
||||
let extension = normalize_extension(path.extension()?.to_string_lossy().as_ref());
|
||||
self.extension_to_language
|
||||
.get(&extension)
|
||||
.map(String::as_str)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct FileDiagnostics {
|
||||
pub path: PathBuf,
|
||||
pub uri: String,
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct WorkspaceDiagnostics {
|
||||
pub files: Vec<FileDiagnostics>,
|
||||
}
|
||||
|
||||
impl WorkspaceDiagnostics {
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.files.is_empty()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn total_diagnostics(&self) -> usize {
|
||||
self.files.iter().map(|file| file.diagnostics.len()).sum()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SymbolLocation {
|
||||
pub path: PathBuf,
|
||||
pub range: Range,
|
||||
}
|
||||
|
||||
impl SymbolLocation {
|
||||
#[must_use]
|
||||
pub fn start_line(&self) -> u32 {
|
||||
self.range.start.line + 1
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn start_character(&self) -> u32 {
|
||||
self.range.start.character + 1
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for SymbolLocation {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}:{}:{}",
|
||||
self.path.display(),
|
||||
self.start_line(),
|
||||
self.start_character()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct LspContextEnrichment {
|
||||
pub file_path: PathBuf,
|
||||
pub diagnostics: WorkspaceDiagnostics,
|
||||
pub definitions: Vec<SymbolLocation>,
|
||||
pub references: Vec<SymbolLocation>,
|
||||
}
|
||||
|
||||
impl LspContextEnrichment {
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.diagnostics.is_empty() && self.definitions.is_empty() && self.references.is_empty()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn render_prompt_section(&self) -> String {
|
||||
const MAX_RENDERED_DIAGNOSTICS: usize = 12;
|
||||
const MAX_RENDERED_LOCATIONS: usize = 12;
|
||||
|
||||
let mut lines = vec!["# LSP context".to_string()];
|
||||
lines.push(format!(" - Focus file: {}", self.file_path.display()));
|
||||
lines.push(format!(
|
||||
" - Workspace diagnostics: {} across {} file(s)",
|
||||
self.diagnostics.total_diagnostics(),
|
||||
self.diagnostics.files.len()
|
||||
));
|
||||
|
||||
if !self.diagnostics.files.is_empty() {
|
||||
lines.push(String::new());
|
||||
lines.push("Diagnostics:".to_string());
|
||||
let mut rendered = 0usize;
|
||||
for file in &self.diagnostics.files {
|
||||
for diagnostic in &file.diagnostics {
|
||||
if rendered == MAX_RENDERED_DIAGNOSTICS {
|
||||
lines.push(" - Additional diagnostics omitted for brevity.".to_string());
|
||||
break;
|
||||
}
|
||||
let severity = diagnostic_severity_label(diagnostic.severity);
|
||||
lines.push(format!(
|
||||
" - {}:{}:{} [{}] {}",
|
||||
file.path.display(),
|
||||
diagnostic.range.start.line + 1,
|
||||
diagnostic.range.start.character + 1,
|
||||
severity,
|
||||
diagnostic.message.replace('\n', " ")
|
||||
));
|
||||
rendered += 1;
|
||||
}
|
||||
if rendered == MAX_RENDERED_DIAGNOSTICS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !self.definitions.is_empty() {
|
||||
lines.push(String::new());
|
||||
lines.push("Definitions:".to_string());
|
||||
lines.extend(
|
||||
self.definitions
|
||||
.iter()
|
||||
.take(MAX_RENDERED_LOCATIONS)
|
||||
.map(|location| format!(" - {location}")),
|
||||
);
|
||||
if self.definitions.len() > MAX_RENDERED_LOCATIONS {
|
||||
lines.push(" - Additional definitions omitted for brevity.".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if !self.references.is_empty() {
|
||||
lines.push(String::new());
|
||||
lines.push("References:".to_string());
|
||||
lines.extend(
|
||||
self.references
|
||||
.iter()
|
||||
.take(MAX_RENDERED_LOCATIONS)
|
||||
.map(|location| format!(" - {location}")),
|
||||
);
|
||||
if self.references.len() > MAX_RENDERED_LOCATIONS {
|
||||
lines.push(" - Additional references omitted for brevity.".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn normalize_extension(extension: &str) -> String {
|
||||
if extension.starts_with('.') {
|
||||
extension.to_ascii_lowercase()
|
||||
} else {
|
||||
format!(".{}", extension.to_ascii_lowercase())
|
||||
}
|
||||
}
|
||||
|
||||
fn diagnostic_severity_label(severity: Option<lsp_types::DiagnosticSeverity>) -> &'static str {
|
||||
match severity {
|
||||
Some(lsp_types::DiagnosticSeverity::ERROR) => "error",
|
||||
Some(lsp_types::DiagnosticSeverity::WARNING) => "warning",
|
||||
Some(lsp_types::DiagnosticSeverity::INFORMATION) => "info",
|
||||
Some(lsp_types::DiagnosticSeverity::HINT) => "hint",
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ publish.workspace = true
|
||||
[dependencies]
|
||||
sha2 = "0.10"
|
||||
glob = "0.3"
|
||||
lsp = { path = "../lsp" }
|
||||
plugins = { path = "../plugins" }
|
||||
regex = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
@@ -284,11 +284,6 @@ impl RuntimeConfig {
|
||||
self.merged.get(key)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get_string(&self, key: &str) -> Option<&str> {
|
||||
self.get(key).and_then(JsonValue::as_str)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn as_json(&self) -> JsonValue {
|
||||
JsonValue::Object(self.merged.clone())
|
||||
|
||||
@@ -17,6 +17,10 @@ pub mod sandbox;
|
||||
mod session;
|
||||
mod usage;
|
||||
|
||||
pub use lsp::{
|
||||
FileDiagnostics, LspContextEnrichment, LspError, LspManager, LspServerConfig,
|
||||
SymbolLocation, WorkspaceDiagnostics,
|
||||
};
|
||||
pub use bash::{execute_bash, BashCommandInput, BashCommandOutput};
|
||||
pub use bootstrap::{BootstrapPhase, BootstrapPlan};
|
||||
pub use compact::{
|
||||
|
||||
@@ -97,12 +97,10 @@ impl McpClientTransport {
|
||||
McpServerConfig::Sdk(config) => Self::Sdk(McpSdkTransport {
|
||||
name: config.name.clone(),
|
||||
}),
|
||||
McpServerConfig::ManagedProxy(config) => {
|
||||
Self::ManagedProxy(McpManagedProxyTransport {
|
||||
url: config.url.clone(),
|
||||
id: config.id.clone(),
|
||||
})
|
||||
}
|
||||
McpServerConfig::ManagedProxy(config) => Self::ManagedProxy(McpManagedProxyTransport {
|
||||
url: config.url.clone(),
|
||||
id: config.id.clone(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1163,8 +1163,12 @@ mod tests {
|
||||
}
|
||||
|
||||
fn cleanup_script(script_path: &Path) {
|
||||
fs::remove_file(script_path).expect("cleanup script");
|
||||
fs::remove_dir_all(script_path.parent().expect("script parent")).expect("cleanup dir");
|
||||
if let Err(error) = fs::remove_file(script_path) {
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::NotFound, "cleanup script");
|
||||
}
|
||||
if let Err(error) = fs::remove_dir_all(script_path.parent().expect("script parent")) {
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::NotFound, "cleanup dir");
|
||||
}
|
||||
}
|
||||
|
||||
fn manager_server_config(
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use crate::config::{ConfigError, ConfigLoader, RuntimeConfig};
|
||||
use lsp::LspContextEnrichment;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum PromptBuildError {
|
||||
@@ -130,6 +131,15 @@ impl SystemPromptBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_lsp_context(mut self, enrichment: &LspContextEnrichment) -> Self {
|
||||
if !enrichment.is_empty() {
|
||||
self.append_sections
|
||||
.push(enrichment.render_prompt_section());
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn build(&self) -> Vec<String> {
|
||||
let mut sections = Vec::new();
|
||||
|
||||
@@ -3,10 +3,13 @@ use std::fmt::{Display, Formatter};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::json::{JsonError, JsonValue};
|
||||
use crate::usage::TokenUsage;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MessageRole {
|
||||
System,
|
||||
User,
|
||||
@@ -14,7 +17,8 @@ pub enum MessageRole {
|
||||
Tool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ContentBlock {
|
||||
Text {
|
||||
text: String,
|
||||
@@ -32,14 +36,14 @@ pub enum ContentBlock {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ConversationMessage {
|
||||
pub role: MessageRole,
|
||||
pub blocks: Vec<ContentBlock>,
|
||||
pub usage: Option<TokenUsage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct Session {
|
||||
pub version: u32,
|
||||
pub messages: Vec<ConversationMessage>,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::session::Session;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const DEFAULT_INPUT_COST_PER_MILLION: f64 = 15.0;
|
||||
const DEFAULT_OUTPUT_COST_PER_MILLION: f64 = 75.0;
|
||||
@@ -25,7 +26,7 @@ impl ModelPricing {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
|
||||
pub struct TokenUsage {
|
||||
pub input_tokens: u32,
|
||||
pub output_tokens: u32,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "server"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
async-stream = "0.3"
|
||||
axum = "0.8"
|
||||
runtime = { path = "../runtime" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "net", "time"] }
|
||||
|
||||
[dev-dependencies]
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,442 @@
|
||||
use std::collections::HashMap;
|
||||
use std::convert::Infallible;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use async_stream::stream;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use runtime::{ConversationMessage, Session as RuntimeSession};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::{broadcast, RwLock};
|
||||
|
||||
pub type SessionId = String;
|
||||
pub type SessionStore = Arc<RwLock<HashMap<SessionId, Session>>>;
|
||||
|
||||
const BROADCAST_CAPACITY: usize = 64;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
sessions: SessionStore,
|
||||
next_session_id: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sessions: Arc::new(RwLock::new(HashMap::new())),
|
||||
next_session_id: Arc::new(AtomicU64::new(1)),
|
||||
}
|
||||
}
|
||||
|
||||
fn allocate_session_id(&self) -> SessionId {
|
||||
let id = self.next_session_id.fetch_add(1, Ordering::Relaxed);
|
||||
format!("session-{id}")
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AppState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Session {
|
||||
pub id: SessionId,
|
||||
pub created_at: u64,
|
||||
pub conversation: RuntimeSession,
|
||||
events: broadcast::Sender<SessionEvent>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
fn new(id: SessionId) -> Self {
|
||||
let (events, _) = broadcast::channel(BROADCAST_CAPACITY);
|
||||
Self {
|
||||
id,
|
||||
created_at: unix_timestamp_millis(),
|
||||
conversation: RuntimeSession::new(),
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
fn subscribe(&self) -> broadcast::Receiver<SessionEvent> {
|
||||
self.events.subscribe()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum SessionEvent {
|
||||
Snapshot {
|
||||
session_id: SessionId,
|
||||
session: RuntimeSession,
|
||||
},
|
||||
Message {
|
||||
session_id: SessionId,
|
||||
message: ConversationMessage,
|
||||
},
|
||||
}
|
||||
|
||||
impl SessionEvent {
|
||||
fn event_name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Snapshot { .. } => "snapshot",
|
||||
Self::Message { .. } => "message",
|
||||
}
|
||||
}
|
||||
|
||||
fn to_sse_event(&self) -> Result<Event, serde_json::Error> {
|
||||
Ok(Event::default()
|
||||
.event(self.event_name())
|
||||
.data(serde_json::to_string(self)?))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ErrorResponse {
|
||||
error: String,
|
||||
}
|
||||
|
||||
type ApiError = (StatusCode, Json<ErrorResponse>);
|
||||
type ApiResult<T> = Result<T, ApiError>;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct CreateSessionResponse {
|
||||
pub session_id: SessionId,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SessionSummary {
|
||||
pub id: SessionId,
|
||||
pub created_at: u64,
|
||||
pub message_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ListSessionsResponse {
|
||||
pub sessions: Vec<SessionSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SessionDetailsResponse {
|
||||
pub id: SessionId,
|
||||
pub created_at: u64,
|
||||
pub session: RuntimeSession,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SendMessageRequest {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn app(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/sessions", post(create_session).get(list_sessions))
|
||||
.route("/sessions/{id}", get(get_session))
|
||||
.route("/sessions/{id}/events", get(stream_session_events))
|
||||
.route("/sessions/{id}/message", post(send_message))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn create_session(
|
||||
State(state): State<AppState>,
|
||||
) -> (StatusCode, Json<CreateSessionResponse>) {
|
||||
let session_id = state.allocate_session_id();
|
||||
let session = Session::new(session_id.clone());
|
||||
|
||||
state
|
||||
.sessions
|
||||
.write()
|
||||
.await
|
||||
.insert(session_id.clone(), session);
|
||||
|
||||
(
|
||||
StatusCode::CREATED,
|
||||
Json(CreateSessionResponse { session_id }),
|
||||
)
|
||||
}
|
||||
|
||||
async fn list_sessions(State(state): State<AppState>) -> Json<ListSessionsResponse> {
|
||||
let sessions = state.sessions.read().await;
|
||||
let mut summaries = sessions
|
||||
.values()
|
||||
.map(|session| SessionSummary {
|
||||
id: session.id.clone(),
|
||||
created_at: session.created_at,
|
||||
message_count: session.conversation.messages.len(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
summaries.sort_by(|left, right| left.id.cmp(&right.id));
|
||||
|
||||
Json(ListSessionsResponse {
|
||||
sessions: summaries,
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_session(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<SessionId>,
|
||||
) -> ApiResult<Json<SessionDetailsResponse>> {
|
||||
let sessions = state.sessions.read().await;
|
||||
let session = sessions
|
||||
.get(&id)
|
||||
.ok_or_else(|| not_found(format!("session `{id}` not found")))?;
|
||||
|
||||
Ok(Json(SessionDetailsResponse {
|
||||
id: session.id.clone(),
|
||||
created_at: session.created_at,
|
||||
session: session.conversation.clone(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn send_message(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<SessionId>,
|
||||
Json(payload): Json<SendMessageRequest>,
|
||||
) -> ApiResult<StatusCode> {
|
||||
let message = ConversationMessage::user_text(payload.message);
|
||||
let broadcaster = {
|
||||
let mut sessions = state.sessions.write().await;
|
||||
let session = sessions
|
||||
.get_mut(&id)
|
||||
.ok_or_else(|| not_found(format!("session `{id}` not found")))?;
|
||||
session.conversation.messages.push(message.clone());
|
||||
session.events.clone()
|
||||
};
|
||||
|
||||
let _ = broadcaster.send(SessionEvent::Message {
|
||||
session_id: id,
|
||||
message,
|
||||
});
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn stream_session_events(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<SessionId>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
let (snapshot, mut receiver) = {
|
||||
let sessions = state.sessions.read().await;
|
||||
let session = sessions
|
||||
.get(&id)
|
||||
.ok_or_else(|| not_found(format!("session `{id}` not found")))?;
|
||||
(
|
||||
SessionEvent::Snapshot {
|
||||
session_id: session.id.clone(),
|
||||
session: session.conversation.clone(),
|
||||
},
|
||||
session.subscribe(),
|
||||
)
|
||||
};
|
||||
|
||||
let stream = stream! {
|
||||
if let Ok(event) = snapshot.to_sse_event() {
|
||||
yield Ok::<Event, Infallible>(event);
|
||||
}
|
||||
|
||||
loop {
|
||||
match receiver.recv().await {
|
||||
Ok(event) => {
|
||||
if let Ok(sse_event) = event.to_sse_event() {
|
||||
yield Ok::<Event, Infallible>(sse_event);
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))))
|
||||
}
|
||||
|
||||
fn unix_timestamp_millis() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("system time should be after epoch")
|
||||
.as_millis() as u64
|
||||
}
|
||||
|
||||
fn not_found(message: String) -> ApiError {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse { error: message }),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
app, AppState, CreateSessionResponse, ListSessionsResponse, SessionDetailsResponse,
|
||||
};
|
||||
use reqwest::Client;
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Duration;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio::time::timeout;
|
||||
|
||||
struct TestServer {
|
||||
address: SocketAddr,
|
||||
handle: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl TestServer {
|
||||
async fn spawn() -> Self {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("test listener should bind");
|
||||
let address = listener
|
||||
.local_addr()
|
||||
.expect("listener should report local address");
|
||||
let handle = tokio::spawn(async move {
|
||||
axum::serve(listener, app(AppState::default()))
|
||||
.await
|
||||
.expect("server should run");
|
||||
});
|
||||
|
||||
Self { address, handle }
|
||||
}
|
||||
|
||||
fn url(&self, path: &str) -> String {
|
||||
format!("http://{}{}", self.address, path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestServer {
|
||||
fn drop(&mut self) {
|
||||
self.handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_session(client: &Client, server: &TestServer) -> CreateSessionResponse {
|
||||
client
|
||||
.post(server.url("/sessions"))
|
||||
.send()
|
||||
.await
|
||||
.expect("create request should succeed")
|
||||
.error_for_status()
|
||||
.expect("create request should return success")
|
||||
.json::<CreateSessionResponse>()
|
||||
.await
|
||||
.expect("create response should parse")
|
||||
}
|
||||
|
||||
async fn next_sse_frame(response: &mut reqwest::Response, buffer: &mut String) -> String {
|
||||
loop {
|
||||
if let Some(index) = buffer.find("\n\n") {
|
||||
let frame = buffer[..index].to_string();
|
||||
let remainder = buffer[index + 2..].to_string();
|
||||
*buffer = remainder;
|
||||
return frame;
|
||||
}
|
||||
|
||||
let next_chunk = timeout(Duration::from_secs(5), response.chunk())
|
||||
.await
|
||||
.expect("SSE stream should yield within timeout")
|
||||
.expect("SSE stream should remain readable")
|
||||
.expect("SSE stream should stay open");
|
||||
buffer.push_str(&String::from_utf8_lossy(&next_chunk));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn creates_and_lists_sessions() {
|
||||
let server = TestServer::spawn().await;
|
||||
let client = Client::new();
|
||||
|
||||
// given
|
||||
let created = create_session(&client, &server).await;
|
||||
|
||||
// when
|
||||
let sessions = client
|
||||
.get(server.url("/sessions"))
|
||||
.send()
|
||||
.await
|
||||
.expect("list request should succeed")
|
||||
.error_for_status()
|
||||
.expect("list request should return success")
|
||||
.json::<ListSessionsResponse>()
|
||||
.await
|
||||
.expect("list response should parse");
|
||||
let details = client
|
||||
.get(server.url(&format!("/sessions/{}", created.session_id)))
|
||||
.send()
|
||||
.await
|
||||
.expect("details request should succeed")
|
||||
.error_for_status()
|
||||
.expect("details request should return success")
|
||||
.json::<SessionDetailsResponse>()
|
||||
.await
|
||||
.expect("details response should parse");
|
||||
|
||||
// then
|
||||
assert_eq!(created.session_id, "session-1");
|
||||
assert_eq!(sessions.sessions.len(), 1);
|
||||
assert_eq!(sessions.sessions[0].id, created.session_id);
|
||||
assert_eq!(sessions.sessions[0].message_count, 0);
|
||||
assert_eq!(details.id, "session-1");
|
||||
assert!(details.session.messages.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn streams_message_events_and_persists_message_flow() {
|
||||
let server = TestServer::spawn().await;
|
||||
let client = Client::new();
|
||||
|
||||
// given
|
||||
let created = create_session(&client, &server).await;
|
||||
let mut response = client
|
||||
.get(server.url(&format!("/sessions/{}/events", created.session_id)))
|
||||
.send()
|
||||
.await
|
||||
.expect("events request should succeed")
|
||||
.error_for_status()
|
||||
.expect("events request should return success");
|
||||
let mut buffer = String::new();
|
||||
let snapshot_frame = next_sse_frame(&mut response, &mut buffer).await;
|
||||
|
||||
// when
|
||||
let send_status = client
|
||||
.post(server.url(&format!("/sessions/{}/message", created.session_id)))
|
||||
.json(&super::SendMessageRequest {
|
||||
message: "hello from test".to_string(),
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
.expect("message request should succeed")
|
||||
.status();
|
||||
let message_frame = next_sse_frame(&mut response, &mut buffer).await;
|
||||
let details = client
|
||||
.get(server.url(&format!("/sessions/{}", created.session_id)))
|
||||
.send()
|
||||
.await
|
||||
.expect("details request should succeed")
|
||||
.error_for_status()
|
||||
.expect("details request should return success")
|
||||
.json::<SessionDetailsResponse>()
|
||||
.await
|
||||
.expect("details response should parse");
|
||||
|
||||
// then
|
||||
assert_eq!(send_status, reqwest::StatusCode::NO_CONTENT);
|
||||
assert!(snapshot_frame.contains("event: snapshot"));
|
||||
assert!(snapshot_frame.contains("\"session_id\":\"session-1\""));
|
||||
assert!(message_frame.contains("event: message"));
|
||||
assert!(message_frame.contains("hello from test"));
|
||||
assert_eq!(details.session.messages.len(), 1);
|
||||
assert_eq!(
|
||||
details.session.messages[0],
|
||||
runtime::ConversationMessage::user_text("hello from test")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3449,6 +3449,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn skill_loads_local_skill_prompt() {
|
||||
let _guard = env_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let result = execute_tool(
|
||||
"Skill",
|
||||
&json!({
|
||||
|
||||
Reference in New Issue
Block a user