20 Commits
Author SHA1 Message Date
darman a930537207 WIP 2026-07-09 07:25:14 +02:00
darmanandClaude Opus 4.8 230e828b81 harness-providers + app: dedicated opencode (Zen) provider
OpenAiProvider gains a custom id and a chat-only mode; `OpenAiProvider::opencode`
builds an OpenCode Zen provider (id `opencode`, defaults to Zen's gateway, always
chat-completions so even gpt-*/o* model ids route correctly). Registered from a
`providers.opencode` config block / OPENCODE_API_KEY env, so it coexists with a
real `openai` provider. Models are referenced as `opencode/<model>`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 06:59:46 +02:00
darmanandClaude Opus 4.8 7738ed55b9 harness-tui + engine: jobs pane, subtask drill-in, reminders (M4)
TUI: Ctrl+J (or /jobs) opens a jobs pane listing the current session's board —
alias, agent, state, objective, files read — populated on load and kept live via
JobUpdated events; Enter drills into a subtask's child session. Snapshot test added.

Engine: optional orchestration reminders (off by default) injected as synthetic,
non-persisted turn-start blocks and, after a file tool runs, an after-file-tool
block on the following turn. Processor reports file-tool usage via StepOutcome.

This completes M4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 06:46:31 +02:00
darmanandClaude Opus 4.8 54f91b7e4c harness-tools + app: subagent context-file reporting to the job board (M4)
A ContextReporter wired into subagent sessions lets the read tool advertise the
files it reads (≥10 lines) on its job board entry, so a completed specialist
shows what it already looked at. Root sessions report nothing (no parent board).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 06:34:04 +02:00
darmanandClaude Opus 4.8 ecb3267a50 harness-tools + app: task tool, subagent spawner, board wiring (M4)
The `task` tool delegates to a SubagentSpawner (owned by the composition root):
resolves the agent, enforces the depth limit, applies permission intersection,
filters tools per-agent, and runs the child session foreground or background.
Foreground returns the child's final text; background registers on the job board
and detaches under the parent run token. The run loop injects the board into
primary-agent requests and reconciles terminal jobs each step.

Integration tests cover foreground run + alias reuse continuing the same child
session, depth-limit and unknown-agent rejection, and board prompt injection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 06:29:12 +02:00
darmanandClaude Opus 4.8 8c859d91c9 harness-core: permission intersection + job board (M4)
evaluate_intersected() returns the more restrictive of a parent-effective
and child verdict (deny > ask > allow). JobBoard tracks subagent tasks with
per-agent aliases, reuse/LRU trimming, context-file reporting, reconcile, and
a formatForPrompt injection block; persisted to the `job` table so resume works.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 06:11:45 +02:00
darmanandClaude Opus 4.8 77873ccb69 harness-core: agent registry + bundled markdown agents (M4)
Layered agent definitions (bundled -> global -> project -> config patch),
opencode-compatible markdown frontmatter, generated {{SUBAGENTS}} routing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 06:05:53 +02:00
darmanandClaude Opus 4.8 fa417a347d harness-providers: Copilot device-flow, token exchange, provider (M3)
Offline-verifiable building blocks for GitHub Copilot; network paths are the
flagged live-verification risk and are covered by pure unit tests only.

- copilot/device_flow.rs: OAuth device flow (RFC 8628). No hardcoded client id —
  ai-harness must register its own GitHub OAuth app and pass it in. Pure
  parse_poll_response (pending/slow_down/success/failed) + slow_down interval bump;
  request_device_code/poll_once/poll_for_token (injected sleep).
- copilot/token.rs: copilot_internal/v2/token exchange with cache + single-flight
  refresh (tokio::Mutex) and direct-Bearer fallback on 401/403/404; needs_refresh
  honors a 120s skew and expires_at==0 = never.
- copilot/provider.rs: CopilotProvider over api.githubcopilot.com, routing each
  model to chat/responses/anthropic codec by its /models supported_endpoints
  (parse_models + codec_for_endpoints); headers (X-GitHub-Api-Version, Openai-Intent,
  x-initiator, Copilot-Vision-Request, anthropic-beta for anthropic models).
- 11 unit tests (device-flow parsing, token refresh math, codec routing, /models
  parsing).

Deferred (needs live API + TUI work): EngineHandle::login device-flow modal,
startup /models fetch, auth.json-backed registry wiring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 23:55:38 +02:00
darmanandClaude Opus 4.8 9301d387fa harness-providers: models.dev catalog + cost display wiring (M3)
- modelsdev.rs: ModelCatalog parses models.dev api.json → ModelInfo keyed by
  (provider, model); 24h file cache at ~/.cache/ai-harness/models.json with a
  baked assets/models-snapshot.json fallback so cost/limits work offline.
  load_cached_or_baked (no network) + refresh/refresh_default_cache (background).
- App: loads the catalog at init (cached-or-baked, never blocks), warms the cache
  in a background task, and passes the session model's pricing into RunConfig.cost
  so session cost accrues for real.
- TUI: AppState tracks session_cost/session_tokens from Session events; status bar
  shows "<tokens> · $<cost>". Snapshots updated.
- 7 modelsdev tests (parse, defaults, unknown-model, baked snapshot, cache TTL,
  cached-vs-baked load).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 23:51:23 +02:00
darmanandClaude Opus 4.8 2f3dfe2305 harness-core: model pricing + per-step cost accumulation (M3)
- types::ModelCost {input, output, cache_read, cache_write} (USD per 1M tokens)
  with cost_of(usage); reasoning tokens are billed within output by our providers
  so they are not charged separately. ModelInfo gains cost + reasoning/tool_call/
  attachment capability flags (all #[serde(default)] for forward-compat).
- Engine: RunConfig.cost threads pricing into process_step; on_finish now stamps
  the real dollar cost onto the StepFinish part; StepOutcome carries per-step cost.
- run_session accumulates each step's usage and cost onto the session (previously
  never updated) and republishes SessionUpdated — best-effort, store errors logged
  not fatal.
- Store gains a single-session getter (Session cmd + get_session).
- App passes cost: None for now (real rates land with models.dev wiring).
- Tests: ModelCost::cost_of math (+ reasoning exclusion), and the multi-step
  engine test now asserts session usage/cost accumulation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 23:45:21 +02:00
darmanandClaude Opus 4.8 39d62348d8 harness-providers: auth.json credential storage (M3)
- auth.rs: AuthRecord (OAuth {access, refresh, expires} | Api {key}) with
  is_expired(now, skew) honoring expires==0 = never; AuthStorage keyed by
  provider id over ~/.local/share/ai-harness/auth.json.
- Read-modify-write on every op so concurrent refresh/login don't clobber;
  writes go through a temp-file rename set to 0600 (unix) to avoid truncated
  auth files.
- 6 tempdir tests: missing→empty, set/get/overwrite, multi-provider coexist,
  scoped remove (+ no-op on absent), expiry skew/never, 0600 perms.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 23:37:34 +02:00
darmanandClaude Opus 4.8 e2706a1560 harness-providers: OpenAI chat + responses codecs, provider, registry wiring (M3)
- codec/openai_chat.rs: /chat/completions request builder (flat messages,
  tool_calls, system → leading system message, reasoning_effort) + SSE decoder
  (content/tool_calls-by-index/reasoning_content accumulation, [DONE] sentinel,
  include_usage). Also the fallback codec for OpenAI-compatible endpoints.
- codec/openai_responses.rs: /responses request builder (input items, tool calls
  and results as top-level function_call/function_call_output, instructions,
  reasoning {effort, summary}) + SSE decoder (output_item add/done, output_text
  and reasoning_summary deltas, function_call_arguments, response.completed usage
  with separate reasoning_tokens).
- openai.rs: OpenAiProvider routing gpt-*/o-* → responses, else chat; Bearer auth;
  error classification incl. 400 context_length_exceeded → ContextOverflow.
- Registered in harness-app from config.providers["openai"] (api_key + optional
  base_url), sourced from OPENAI_API_KEY by the existing config loader.
- 21 codec/provider unit tests (decode fixtures + request-builder assertions).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 23:35:49 +02:00
darman 0812f6d94c M2 TUI: EngineHandle, ratatui app, markdown renderer, permission modal, session picker, snapshot tests
harness-app:
- EngineHandle: non-blocking multi-turn API (prompt/abort/permission_reply/
  list_sessions/session_messages/message_parts), persistent SQLite store,
  no auto-approve — TUI handles permission asks via real oneshot path
- App refactored to wrap EngineHandle; headless run -p keeps auto-approve

harness-tui:
- Terminal guard (raw mode, alternate screen, panic hook, Drop restore)
- Event loop: tokio::select! over crossterm events, bus events, 33ms render tick
- AppState with MessageView/PartView (cached Vec<Line> field), ModalState
- pulldown-cmark → ratatui markdown renderer (headings, bold, italic, code
  blocks, lists, blockquotes, links, manual word-wrap)
- Layout: header, chat viewport, input (tui-textarea), status bar
- Permission modal (y/a/n) wired to EngineHandle::permission_reply
- Session picker (Ctrl+S) with resume
- Abort (Esc), Ctrl+C×2 quit, slash commands (/new /model /agent /sessions)
- 7 TestBackend snapshot tests (empty, messages, tool cards, modals)
2026-07-08 23:35:40 +02:00
Erik Simon b6e94c67c7 harness-app + harness-tui: composition root and harness run -p debug command
App::init wires config loading, an in-memory Store, EventBus, PermissionService
with the M1 auto-approve stub frontend, the full built-in ToolRegistry, and a
ProviderRegistry populated with AnthropicProvider when an API key is available
(config or ANTHROPIC_API_KEY). App::run_prompt creates a root session, appends
the prompt as a user message, and drives engine::run_session to completion;
final_text reads back the concatenated text parts of the last message.

harness-tui's `harness` binary gains a `run -p "<prompt>" [-m provider/model]`
subcommand built on this. Manually verified end-to-end against the real
Anthropic API: an empty API key produced a genuine 401 that our SSE error
path correctly classified as ProviderError::Auth and surfaced as a clean
CLI error message (exit 1) -- confirming the full request/header/error-
handling pipeline works against the live service, not just fixtures.

This closes M1 (docs/10-milestones.md): headless core loop + Anthropic
provider, config loading, all six built-in tools, and the debug CLI.
131 tests passing across the workspace, clippy clean, fmt clean.
2026-07-08 17:28:37 +02:00
Erik Simon bbac60d744 harness-providers: Anthropic codec + provider
/v1/messages request builder (cache_control breakpoints on the first 2
system blocks + last 2 messages, tool schema -> input_schema, extended
thinking budget) and an SSE decoder built on eventsource-stream, mapping
content_block_start/delta/stop and message_delta into our normalized
LlmEvent stream (text, thinking+signature, streamed tool-call JSON
accumulated and parsed at content_block_stop, usage merged from
message_start + message_delta). AnthropicProvider wires this to reqwest
with x-api-key/anthropic-version/anthropic-beta headers and classifies
HTTP errors into RateLimited/Auth/Overloaded/Http. ProviderRegistry
resolves "provider/model" strings.

Also tightened processor::process_step's cancellation: the event loop now
selects the stream poll against ctx.cancel instead of only checking at the
top of the loop, so a blocked provider stream is actually interrupted by
abort (matches docs/02-engine.md's cancellation semantics).

127 tests passing, clippy clean.
2026-07-08 17:25:35 +02:00
Erik Simon bfb2dca7de harness-tools: edit tool with opencode's replacer chain, ported verbatim
Direct port of ~/repos/opencode/packages/opencode/src/tool/edit.ts: the
9-stage replacer chain (Simple, LineTrimmed, BlockAnchor, WhitespaceNormalized,
IndentationFlexible, EscapeNormalized, TrimmedBoundary, ContextAware,
MultiOccurrence), Levenshtein-based block-anchor similarity, and the
disproportionate-match guard that hard-stops rather than falling through to
the next candidate. Also ports edit.test.ts's scenarios: new-file creation,
BOM preservation, CRLF handling, replaceAll, directory/not-found/identical
errors, loose block-anchor rejection, and concurrent edits to the same file
serializing through a per-path tokio::Mutex without losing either change.

99 tests passing across the workspace, clippy clean. This lands the last
of the six M1 built-in tools (read/write/edit/bash/glob/grep).
2026-07-08 17:19:43 +02:00
Erik Simon 27ab6de4f5 harness-tools: read, write, bash, glob, grep
Five of the six M1 built-ins (edit's replacer chain is a separate port).
bash uses process_group(0) + SIGKILL-the-group on timeout/cancel; glob/grep
are gitignore-aware via ignore::WalkBuilder and don't ask permission
(read-only); read/write/bash ask through ctx.ask with opencode's coarser
"always" pattern (first word + wildcard for bash, path for edit/read).
Wired tool output through the central 30k-char truncate() in the processor
so every tool gets spill-to-disk behavior for free. 70 tests passing,
clippy clean.
2026-07-08 17:12:48 +02:00
Erik Simon a68ca02894 M1 core: tool trait, permission service, config, Provider trait, engine loop
harness-core now has everything the headless agent loop needs:
- Tool trait/ToolCtx/ToolRegistry + 30k-char head+tail output truncation
- PermissionService: async ask over a oneshot + AppEvent::PermissionAsked,
  Once/Always/Reject replies, an auto-approve stub for tests/headless runs
- Config: JSONC loading, bundled/global/project-chain/env precedence,
  {env:VAR} and {file:path} interpolation
- llm.rs: LlmEvent/LlmRequest/Provider trait, wire message/content types
- engine/: outer loop (run_session), inner stream processor (persists
  parts/messages as events arrive, executes tool calls inline), retry
  policy (retries only the pre-first-event window), doom-loop guard,
  system prompt assembly

Verified end-to-end against a scripted MockProvider: text -> tool call
(read) -> final text, with messages/parts persisted in the right shape,
plus a provider-error-before-any-event case surfacing as Errored (no
partial message left behind). 49 tests passing, clippy clean.
2026-07-08 17:05:37 +02:00
Erik Simon 5662b773b0 Remove GitHub Actions CI 2026-07-08 16:44:34 +02:00
Erik Simon 8b16348b4c M0: scaffold Cargo workspace, core types, event bus, permission engine, storage actor
Seven-crate workspace per docs/01-architecture.md. harness-core gets the
domain types (Session/Message/Part/ToolState), a broadcast EventBus, the
last-match-wins wildcard permission evaluator, and a SQLite storage actor
(dedicated thread + mpsc, JSON-blob rows) with roundtrip tests. All other
crates are compiling stubs. CI runs fmt/clippy -D warnings/test.
2026-07-08 16:28:42 +02:00
19 changed files with 762 additions and 2 deletions
Generated
+5
View File
@@ -711,7 +711,12 @@ dependencies = [
name = "harness-lsp"
version = "0.1.0"
dependencies = [
"async-trait",
"harness-core",
"serde_json",
"tempfile",
"tokio",
"tracing",
]
[[package]]
+21
View File
@@ -16,6 +16,7 @@ use harness_core::config::{self, Config, ConfigError};
use harness_core::engine::jobs::{JobBoard, JobState, LaunchSpec};
use harness_core::engine::{run_session, RunConfig, StepContext};
use harness_core::event::{AppEvent, EventBus, RunOutcome};
use harness_core::lsp::DiagnosticsSource;
use harness_core::permission::{PermissionReply, PermissionService, Rule, Ruleset};
use harness_core::store::{Store, StoreError};
use harness_core::tool::{
@@ -90,6 +91,9 @@ struct EngineInner {
providers: ProviderRegistry,
catalog: ModelCatalog,
agents: AgentRegistry,
/// LSP diagnostics pool (edit/write surface errors). Shared across sessions; servers spawn
/// lazily on first touch.
diagnostics: Option<Arc<dyn DiagnosticsSource>>,
cwd: PathBuf,
data_dir: PathBuf,
runs: Mutex<HashMap<SessionId, RunHandle>>,
@@ -196,6 +200,20 @@ impl EngineHandle {
// `init` warms the cache in the background for the next launch.
let catalog = ModelCatalog::load_cached_or_baked(&ModelCatalog::default_cache_path());
// LSP pool: built-ins present on PATH, plus any config-declared servers.
let lsp_servers: Vec<harness_lsp::ServerConfig> = config
.lsp
.iter()
.map(|(name, c)| harness_lsp::ServerConfig {
name: name.clone(),
command: c.command.clone(),
args: c.args.clone(),
extensions: c.extensions.clone(),
})
.collect();
let diagnostics: Option<Arc<dyn DiagnosticsSource>> =
Some(Arc::new(harness_lsp::LspPool::new(cwd.clone(), lsp_servers)));
let inner = Arc::new(EngineInner {
config,
store,
@@ -205,6 +223,7 @@ impl EngineHandle {
providers,
catalog,
agents,
diagnostics,
cwd,
data_dir,
runs: Mutex::new(HashMap::new()),
@@ -328,6 +347,7 @@ impl EngineHandle {
spawner: Some(self.inner.clone()),
job_board: Some(board),
context_reporter: None, // root session has no parent board to report to
diagnostics: self.inner.diagnostics.clone(),
};
let run_config = RunConfig {
agent_name: agent.name.clone(),
@@ -633,6 +653,7 @@ impl EngineInner {
spawner: Some(self.arc()),
job_board: Some(board),
context_reporter: reporter,
diagnostics: self.diagnostics.clone(),
}
}
}
+1
View File
@@ -434,6 +434,7 @@ mod tests {
spawner: None,
job_board: None,
context_reporter: None,
diagnostics: None,
}
}
@@ -72,6 +72,8 @@ pub struct StepContext {
/// Present in subagent sessions: reports read files to this session's job on the parent
/// board. `None` for root sessions (nothing to report to).
pub context_reporter: Option<Arc<dyn ContextReporter>>,
/// Language-server diagnostics source shared by the session's edit/write tool calls.
pub diagnostics: Option<Arc<dyn crate::lsp::DiagnosticsSource>>,
}
struct FlushTracker {
@@ -522,6 +524,7 @@ impl<'a> Run<'a> {
metadata: metadata_sink,
spawner: self.ctx.spawner.clone(),
context_reporter: self.ctx.context_reporter.clone(),
diagnostics: self.ctx.diagnostics.clone(),
};
let result = tokio::select! {
+1
View File
@@ -3,6 +3,7 @@ pub mod config;
pub mod engine;
pub mod event;
pub mod llm;
pub mod lsp;
pub mod permission;
pub mod store;
pub mod tool;
+46
View File
@@ -0,0 +1,46 @@
//! Seam trait for language-server diagnostics, implemented by `harness-lsp` and consumed by
//! the edit/write tools. Kept in core so `harness-tools` never links the LSP crate directly.
//! See `docs/09-integrations.md`.
use std::path::Path;
use std::time::Duration;
use async_trait::async_trait;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Error,
Warning,
Info,
Hint,
}
/// One diagnostic reported by a language server. Line/character are 1-based for display.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Diagnostic {
pub line: u32,
pub character: u32,
pub severity: Severity,
pub message: String,
pub source: Option<String>,
}
impl Diagnostic {
/// `{file}: L{line}: {message}` — the one-line form appended to tool output.
pub fn display_line(&self, file: &str) -> String {
format!("{file}: L{}: {}", self.line, self.message)
}
}
/// A source of file diagnostics (an LSP pool). All methods are best-effort: failures are
/// swallowed (logged) so a broken language server never fails a tool call.
#[async_trait]
pub trait DiagnosticsSource: Send + Sync {
/// Ensure a server for `path`'s language is running and told about the file's current
/// contents (spawn-if-needed + didOpen/didChange).
async fn touch(&self, path: &Path);
/// Diagnostics for `path`, waiting up to `wait` for the server to (re)publish after a
/// change. Returns whatever is known on timeout.
async fn diagnostics(&self, path: &Path, wait: Duration) -> Vec<Diagnostic>;
}
+3
View File
@@ -195,6 +195,9 @@ pub struct ToolCtx {
pub spawner: Option<Arc<dyn SubagentSpawner>>,
/// Present in subagent sessions: lets the read tool report files to the job board.
pub context_reporter: Option<Arc<dyn ContextReporter>>,
/// Language-server diagnostics source (edit/write surface errors after a change). `None`
/// disables LSP integration.
pub diagnostics: Option<Arc<dyn crate::lsp::DiagnosticsSource>>,
}
#[derive(Debug)]
+7
View File
@@ -6,6 +6,13 @@ license.workspace = true
[dependencies]
harness-core = { workspace = true }
tokio = { workspace = true }
async-trait = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
[lints]
workspace = true
+325
View File
@@ -0,0 +1,325 @@
//! Minimal JSON-RPC client over an LSP server's child stdio: `Content-Length` framing, a
//! request-id → oneshot map, and a diagnostics store keyed by document URI. Only the handful
//! of methods the edit/write flow needs are implemented (docs/09-integrations.md).
use std::collections::HashMap;
use std::path::Path;
use std::sync::atomic::{AtomicI64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use harness_core::lsp::{Diagnostic, Severity};
use serde_json::{json, Value};
use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, Command};
use tokio::sync::{oneshot, Notify};
type Pending = Arc<Mutex<HashMap<i64, oneshot::Sender<Value>>>>;
type DiagStore = Arc<Mutex<HashMap<String, Vec<Diagnostic>>>>;
/// A running language server plus the state needed to talk to it.
pub struct LspClient {
outgoing: tokio::sync::mpsc::UnboundedSender<String>,
next_id: AtomicI64,
pending: Pending,
diagnostics: DiagStore,
diag_notify: Arc<Notify>,
// Kept alive so the child is killed on drop (`kill_on_drop`).
_child: Child,
}
impl LspClient {
/// Spawns `command args`, performs the `initialize`/`initialized` handshake rooted at
/// `root`, and returns a ready client.
pub async fn spawn(command: &str, args: &[String], root: &Path) -> std::io::Result<Self> {
let mut child = Command::new(command)
.args(args)
.current_dir(root)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.kill_on_drop(true)
.spawn()?;
let stdin = child.stdin.take().expect("piped stdin");
let stdout = child.stdout.take().expect("piped stdout");
let (outgoing, mut out_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let pending: Pending = Arc::default();
let diagnostics: DiagStore = Arc::default();
let diag_notify = Arc::new(Notify::new());
// Writer task: frame and forward outgoing payloads.
tokio::spawn(async move {
let mut stdin = stdin;
while let Some(payload) = out_rx.recv().await {
let frame = format!("Content-Length: {}\r\n\r\n{}", payload.len(), payload);
if stdin.write_all(frame.as_bytes()).await.is_err() {
break;
}
let _ = stdin.flush().await;
}
});
// Reader task: parse frames, route responses, collect diagnostics, ack server requests.
{
let pending = pending.clone();
let diagnostics = diagnostics.clone();
let diag_notify = diag_notify.clone();
let outgoing_ack = outgoing.clone();
tokio::spawn(async move {
let mut reader = BufReader::new(stdout);
while let Some(msg) = read_message(&mut reader).await {
dispatch(msg, &pending, &diagnostics, &diag_notify, &outgoing_ack);
}
});
}
let client = Self {
outgoing,
next_id: AtomicI64::new(1),
pending,
diagnostics,
diag_notify,
_child: child,
};
client.initialize(root).await?;
Ok(client)
}
async fn initialize(&self, root: &Path) -> std::io::Result<()> {
let root_uri = path_to_uri(root);
let params = json!({
"processId": std::process::id(),
"rootUri": root_uri,
"workspaceFolders": [{ "uri": root_uri, "name": "root" }],
"capabilities": {
"textDocument": {
"publishDiagnostics": { "relatedInformation": false }
}
},
"clientInfo": { "name": "ai-harness" }
});
// A slow server (rust-analyzer indexing) can take a while to answer initialize.
let _ = self
.request("initialize", params, Duration::from_secs(30))
.await;
self.notify("initialized", json!({}));
Ok(())
}
fn send(&self, payload: Value) {
if let Ok(text) = serde_json::to_string(&payload) {
let _ = self.outgoing.send(text);
}
}
pub fn notify(&self, method: &str, params: Value) {
self.send(json!({ "jsonrpc": "2.0", "method": method, "params": params }));
}
async fn request(&self, method: &str, params: Value, timeout: Duration) -> Option<Value> {
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let (tx, rx) = oneshot::channel();
self.pending.lock().unwrap().insert(id, tx);
self.send(json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }));
match tokio::time::timeout(timeout, rx).await {
Ok(Ok(value)) => Some(value),
_ => {
self.pending.lock().unwrap().remove(&id);
None
}
}
}
pub fn did_open(&self, path: &Path, language_id: &str, version: i32, text: &str) {
self.notify(
"textDocument/didOpen",
json!({
"textDocument": {
"uri": path_to_uri(path),
"languageId": language_id,
"version": version,
"text": text,
}
}),
);
}
pub fn did_change(&self, path: &Path, version: i32, text: &str) {
self.notify(
"textDocument/didChange",
json!({
"textDocument": { "uri": path_to_uri(path), "version": version },
"contentChanges": [{ "text": text }],
}),
);
}
/// Clears the stored diagnostics for `path` so the next `wait_diagnostics` observes a fresh
/// publish rather than a stale one.
pub fn clear(&self, path: &Path) {
self.diagnostics.lock().unwrap().remove(&path_to_uri(path));
}
/// Waits up to `wait` for the server to publish diagnostics for `path`, returning whatever
/// is stored on timeout (possibly empty).
pub async fn wait_diagnostics(&self, path: &Path, wait: Duration) -> Vec<Diagnostic> {
let uri = path_to_uri(path);
let deadline = tokio::time::Instant::now() + wait;
loop {
if let Some(diags) = self.diagnostics.lock().unwrap().get(&uri) {
return diags.clone();
}
let notified = self.diag_notify.notified();
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
break;
}
if tokio::time::timeout(remaining, notified).await.is_err() {
break;
}
}
self.diagnostics
.lock()
.unwrap()
.get(&uri)
.cloned()
.unwrap_or_default()
}
}
fn dispatch(
msg: Value,
pending: &Pending,
diagnostics: &DiagStore,
diag_notify: &Arc<Notify>,
outgoing: &tokio::sync::mpsc::UnboundedSender<String>,
) {
let method = msg.get("method").and_then(|m| m.as_str());
let id = msg.get("id");
match (method, id) {
// Server → client request: ack with a null result so the server can proceed
// (e.g. client/registerCapability, window/workDoneProgress/create).
(Some(_), Some(id)) => {
let reply = json!({ "jsonrpc": "2.0", "id": id, "result": Value::Null });
if let Ok(text) = serde_json::to_string(&reply) {
let _ = outgoing.send(text);
}
}
// Notification from the server.
(Some("textDocument/publishDiagnostics"), None) => {
if let Some(params) = msg.get("params") {
if let Some((uri, diags)) = parse_diagnostics(params) {
diagnostics.lock().unwrap().insert(uri, diags);
diag_notify.notify_waiters();
}
}
}
(Some(_), None) => {}
// Response to one of our requests.
(None, Some(id)) => {
if let Some(id) = id.as_i64() {
if let Some(tx) = pending.lock().unwrap().remove(&id) {
let result = msg.get("result").cloned().unwrap_or(Value::Null);
let _ = tx.send(result);
}
}
}
(None, None) => {}
}
}
fn parse_diagnostics(params: &Value) -> Option<(String, Vec<Diagnostic>)> {
let uri = params.get("uri")?.as_str()?.to_string();
let items = params.get("diagnostics")?.as_array()?;
let diags = items
.iter()
.filter_map(|d| {
let start = d.get("range")?.get("start")?;
Some(Diagnostic {
line: start.get("line")?.as_u64().unwrap_or(0) as u32 + 1,
character: start.get("character")?.as_u64().unwrap_or(0) as u32 + 1,
severity: match d.get("severity").and_then(|s| s.as_u64()) {
Some(1) => Severity::Error,
Some(2) => Severity::Warning,
Some(3) => Severity::Info,
_ => Severity::Hint,
},
message: d.get("message")?.as_str().unwrap_or("").to_string(),
source: d
.get("source")
.and_then(|s| s.as_str())
.map(|s| s.to_string()),
})
})
.collect();
Some((uri, diags))
}
/// Reads one `Content-Length`-framed JSON-RPC message, or `None` at EOF.
async fn read_message<R: tokio::io::AsyncBufRead + Unpin>(reader: &mut R) -> Option<Value> {
use tokio::io::AsyncBufReadExt;
let mut content_length: Option<usize> = None;
loop {
let mut line = String::new();
let n = reader.read_line(&mut line).await.ok()?;
if n == 0 {
return None; // EOF
}
let trimmed = line.trim_end();
if trimmed.is_empty() {
break; // end of headers
}
if let Some(value) = trimmed.strip_prefix("Content-Length:") {
content_length = value.trim().parse().ok();
}
}
let len = content_length?;
let mut buf = vec![0u8; len];
reader.read_exact(&mut buf).await.ok()?;
serde_json::from_slice(&buf).ok()
}
/// `file://` URI for an absolute path (best-effort; assumes UTF-8, no percent-encoding needed
/// for the local paths we handle).
fn path_to_uri(path: &Path) -> String {
let s = path.to_string_lossy();
if s.starts_with('/') {
format!("file://{s}")
} else {
format!("file:///{s}")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_publish_diagnostics_into_one_based_positions() {
let params = json!({
"uri": "file:///tmp/a.rs",
"diagnostics": [{
"range": {"start": {"line": 4, "character": 8}, "end": {"line": 4, "character": 12}},
"severity": 1,
"message": "cannot find value `x`",
"source": "rustc"
}]
});
let (uri, diags) = parse_diagnostics(&params).unwrap();
assert_eq!(uri, "file:///tmp/a.rs");
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].line, 5); // 0-based 4 → 1-based 5
assert_eq!(diags[0].character, 9);
assert_eq!(diags[0].severity, Severity::Error);
assert_eq!(diags[0].source.as_deref(), Some("rustc"));
}
#[test]
fn path_to_uri_prefixes_file_scheme() {
assert_eq!(path_to_uri(Path::new("/tmp/a.rs")), "file:///tmp/a.rs");
}
}
+238 -1
View File
@@ -1 +1,238 @@
// LSP client pool + diagnostics service land here in M5.
//! LSP diagnostics pool: maps a file extension to a language server, lazily spawns one server
//! per language, and answers the core `DiagnosticsSource` seam. Built-in servers are only
//! offered when their binary is on `PATH`; config can add or override them.
//! See `docs/09-integrations.md`.
mod client;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use async_trait::async_trait;
use harness_core::lsp::{Diagnostic, DiagnosticsSource};
use client::LspClient;
/// One language server: which binary to run and which extensions it handles.
#[derive(Debug, Clone)]
pub struct ServerConfig {
pub name: String,
pub command: String,
pub args: Vec<String>,
pub extensions: Vec<String>,
}
/// Built-in servers, tried only when their binary exists on `PATH`.
fn builtin_servers() -> Vec<ServerConfig> {
vec![
ServerConfig {
name: "rust".into(),
command: "rust-analyzer".into(),
args: vec![],
extensions: vec!["rs".into()],
},
ServerConfig {
name: "typescript".into(),
command: "typescript-language-server".into(),
args: vec!["--stdio".into()],
extensions: vec!["ts".into(), "tsx".into(), "js".into(), "jsx".into()],
},
ServerConfig {
name: "go".into(),
command: "gopls".into(),
args: vec![],
extensions: vec!["go".into()],
},
ServerConfig {
name: "python".into(),
command: "pyright-langserver".into(),
args: vec!["--stdio".into()],
extensions: vec!["py".into()],
},
]
}
/// LSP `languageId` for a file extension (falls back to the extension itself).
fn language_id_for_ext(ext: &str) -> &str {
match ext {
"rs" => "rust",
"ts" => "typescript",
"tsx" => "typescriptreact",
"js" => "javascript",
"jsx" => "javascriptreact",
"go" => "go",
"py" => "python",
other => other,
}
}
/// Returns true if `command` is an absolute existing file or resolvable on `PATH`.
fn binary_exists(command: &str) -> bool {
let p = Path::new(command);
if p.is_absolute() {
return p.is_file();
}
let Some(paths) = std::env::var_os("PATH") else {
return false;
};
std::env::split_paths(&paths).any(|dir| dir.join(command).is_file())
}
enum Slot {
Ready(Arc<LspClient>),
Failed,
}
pub struct LspPool {
root: PathBuf,
servers: Vec<ServerConfig>,
/// One slot per server name; absent until first spawn attempt.
clients: tokio::sync::Mutex<HashMap<String, Slot>>,
/// Open documents → last-sent version, so `didChange` bumps monotonically.
open: Mutex<HashMap<PathBuf, i32>>,
}
impl LspPool {
/// Builds a pool: available built-ins plus any `config` servers (which override built-ins
/// by name; an empty command disables a built-in).
pub fn new(root: PathBuf, config: Vec<ServerConfig>) -> Self {
let mut by_name: HashMap<String, ServerConfig> = HashMap::new();
for server in builtin_servers() {
if binary_exists(&server.command) {
by_name.insert(server.name.clone(), server);
}
}
for server in config {
if server.command.is_empty() {
by_name.remove(&server.name);
} else {
by_name.insert(server.name.clone(), server);
}
}
Self {
root,
servers: by_name.into_values().collect(),
clients: tokio::sync::Mutex::new(HashMap::new()),
open: Mutex::new(HashMap::new()),
}
}
fn server_for(&self, path: &Path) -> Option<&ServerConfig> {
let ext = path.extension().and_then(|e| e.to_str())?;
self.servers
.iter()
.find(|s| s.extensions.iter().any(|e| e == ext))
}
/// Gets or lazily spawns the client for `server`. Caches a failure so we don't respawn a
/// broken server on every edit.
async fn client_for(&self, server: &ServerConfig) -> Option<Arc<LspClient>> {
let mut clients = self.clients.lock().await;
match clients.get(&server.name) {
Some(Slot::Ready(c)) => return Some(c.clone()),
Some(Slot::Failed) => return None,
None => {}
}
match LspClient::spawn(&server.command, &server.args, &self.root).await {
Ok(client) => {
let client = Arc::new(client);
clients.insert(server.name.clone(), Slot::Ready(client.clone()));
Some(client)
}
Err(e) => {
tracing::warn!(server = %server.name, error = %e, "LSP server failed to start");
clients.insert(server.name.clone(), Slot::Failed);
None
}
}
}
}
#[async_trait]
impl DiagnosticsSource for LspPool {
async fn touch(&self, path: &Path) {
let Some(server) = self.server_for(path).cloned() else {
return;
};
let Ok(text) = tokio::fs::read_to_string(path).await else {
return;
};
let Some(client) = self.client_for(&server).await else {
return;
};
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or_default();
let language_id = language_id_for_ext(ext);
let existing_version = {
let mut open = self.open.lock().unwrap();
match open.get_mut(path) {
Some(version) => {
*version += 1;
Some(*version)
}
None => {
open.insert(path.to_path_buf(), 1);
None
}
}
};
// Fresh diagnostics only reflect the current contents — drop any stale set first.
client.clear(path);
match existing_version {
None => client.did_open(path, language_id, 1, &text),
Some(version) => client.did_change(path, version, &text),
}
}
async fn diagnostics(&self, path: &Path, wait: Duration) -> Vec<Diagnostic> {
let Some(server) = self.server_for(path).cloned() else {
return Vec::new();
};
let Some(client) = self.client_for(&server).await else {
return Vec::new();
};
client.wait_diagnostics(path, wait).await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn language_ids_map_common_extensions() {
assert_eq!(language_id_for_ext("rs"), "rust");
assert_eq!(language_id_for_ext("tsx"), "typescriptreact");
assert_eq!(language_id_for_ext("unknown"), "unknown");
}
#[test]
fn config_server_overrides_builtin_by_name() {
let pool = LspPool::new(
std::env::temp_dir(),
vec![ServerConfig {
name: "rust".into(),
command: "my-custom-ra".into(),
args: vec!["--flag".into()],
extensions: vec!["rs".into()],
}],
);
let server = pool.server_for(Path::new("/x/a.rs")).unwrap();
assert_eq!(server.command, "my-custom-ra");
}
#[test]
fn no_server_for_unknown_extension() {
let pool = LspPool::new(std::env::temp_dir(), vec![]);
assert!(pool.server_for(Path::new("/x/a.zzz")).is_none());
}
#[test]
fn binary_exists_finds_a_known_tool() {
assert!(binary_exists("sh"));
assert!(!binary_exists("definitely-not-a-real-binary-xyz"));
}
}
+34
View File
@@ -0,0 +1,34 @@
//! End-to-end LSP test against a real `rust-analyzer`. Ignored by default (requires the binary
//! on PATH and is slow — RA indexes the project). Run with:
//! cargo test -p harness-lsp --test rust_analyzer -- --ignored --nocapture
use std::time::Duration;
use harness_core::lsp::{DiagnosticsSource, Severity};
use harness_lsp::LspPool;
#[tokio::test]
#[ignore = "requires rust-analyzer on PATH; slow"]
async fn rust_analyzer_reports_a_type_error() {
// Minimal cargo project with a deliberate type error.
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"probe\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
)
.unwrap();
std::fs::create_dir_all(dir.path().join("src")).unwrap();
let main_rs = dir.path().join("src/main.rs");
std::fs::write(&main_rs, "fn main() {\n let x: i32 = \"not an integer\";\n let _ = x;\n}\n").unwrap();
let pool = LspPool::new(dir.path().to_path_buf(), vec![]);
pool.touch(&main_rs).await;
// Generous wait: rust-analyzer must index the workspace before it reports anything.
let diags = pool.diagnostics(&main_rs, Duration::from_secs(60)).await;
println!("diagnostics: {diags:#?}");
assert!(
diags.iter().any(|d| d.severity == Severity::Error),
"expected at least one error diagnostic, got: {diags:?}"
);
}
+1
View File
@@ -151,6 +151,7 @@ mod tests {
metadata,
spawner: None,
context_reporter: None,
diagnostics: None,
}
}
+68
View File
@@ -0,0 +1,68 @@
//! Shared LSP-diagnostics reporting for the edit/write tools. After a file is written we ask
//! the (optional) diagnostics source to re-analyze it and append any error-severity items to
//! the tool output so the model sees mistakes it just introduced. Best-effort: no source, a
//! slow server, or a timeout all just mean "no diagnostics" — never a tool failure.
use std::path::Path;
use std::time::Duration;
use harness_core::lsp::Severity;
use harness_core::tool::{ToolCtx, ToolOutput};
/// docs/09-integrations.md: wait up to 1.5s for the server to (re)publish after the change.
const DIAGNOSTICS_WAIT: Duration = Duration::from_millis(1500);
/// Touches `path` in the language server and appends error-severity diagnostics to `output`
/// (both as a human-readable block in the text and the full set in metadata under `diagnostics`).
pub async fn append_diagnostics(ctx: &ToolCtx, path: &Path, display_name: &str, output: &mut ToolOutput) {
let Some(source) = &ctx.diagnostics else {
return;
};
source.touch(path).await;
let diagnostics = source.diagnostics(path, DIAGNOSTICS_WAIT).await;
if diagnostics.is_empty() {
return;
}
let errors: Vec<_> = diagnostics
.iter()
.filter(|d| d.severity == Severity::Error)
.collect();
if !errors.is_empty() {
output
.output
.push_str("\n\nLSP errors detected in this file, please fix:");
for diag in &errors {
output.output.push('\n');
output.output.push_str(&diag.display_line(display_name));
}
}
// Full set (all severities) into metadata for the TUI.
let items: Vec<serde_json::Value> = diagnostics
.iter()
.map(|d| {
serde_json::json!({
"line": d.line,
"character": d.character,
"severity": severity_str(d.severity),
"message": d.message,
"source": d.source,
})
})
.collect();
if let serde_json::Value::Object(map) = &mut output.metadata {
map.insert("diagnostics".into(), serde_json::Value::Array(items));
} else {
output.metadata = serde_json::json!({ "diagnostics": items });
}
}
fn severity_str(severity: Severity) -> &'static str {
match severity {
Severity::Error => "error",
Severity::Warning => "warning",
Severity::Info => "info",
Severity::Hint => "hint",
}
}
+3 -1
View File
@@ -196,8 +196,9 @@ impl Tool for EditTool {
.map_err(|e| ToolError::Other(format!("{}: {e}", path.display())))?;
let (added, removed) = diff_stats(&content_old, &content_new);
let mut output = ToolOutput::new(pattern, "Edit applied successfully.".to_string());
let mut output = ToolOutput::new(pattern.clone(), "Edit applied successfully.".to_string());
output.metadata = serde_json::json!({"diff": diff, "added": added, "removed": removed});
crate::diagnostics::append_diagnostics(&ctx, &path, &pattern, &mut output).await;
Ok(output)
}
}
@@ -234,6 +235,7 @@ mod tests {
metadata,
spawner: None,
context_reporter: None,
diagnostics: None,
}
}
+1
View File
@@ -132,6 +132,7 @@ mod tests {
metadata,
spawner: None,
context_reporter: None,
diagnostics: None,
}
}
+1
View File
@@ -161,6 +161,7 @@ mod tests {
metadata,
spawner: None,
context_reporter: None,
diagnostics: None,
}
}
+1
View File
@@ -1,4 +1,5 @@
mod bash;
mod diagnostics;
mod edit;
mod glob;
mod grep;
+1
View File
@@ -148,6 +148,7 @@ mod tests {
metadata,
spawner: None,
context_reporter: None,
diagnostics: None,
}
}
+2
View File
@@ -80,6 +80,7 @@ impl Tool for WriteTool {
format!("wrote {} bytes", params.content.len()),
);
output.metadata = serde_json::json!({"diff": diff, "added": added, "removed": removed});
crate::diagnostics::append_diagnostics(&ctx, &path, &params.file_path, &mut output).await;
Ok(output)
}
}
@@ -116,6 +117,7 @@ mod tests {
metadata,
spawner: None,
context_reporter: None,
diagnostics: None,
}
}