termesh_agent/service.rs
1//! The `AgentService` boundary — every ACP wire type stays behind this (ADR-0007).
2//!
3//! Synchronous and object-safe, following the template ADR-0005 §3 set for
4//! `FileSystemService`: the methods block, but they are only ever called from the agent
5//! worker thread, so the non-blocking guarantee comes from *where* they run. That is what
6//! lets a scripted agent be a plain struct with a queue — no executor, no runtime — and
7//! it is why `tokio` is still not in the tree.
8
9pub use termesh_core::agent::{AgentEvent, AgentRequest, PermissionDecision, StopReason};
10
11/// How the agent is wired into the workspace (ADR-0003).
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum AgentIntegration {
14 /// Tier 0: run an AI CLI inside a terminal pane. No shared state, but free.
15 TerminalCli,
16 /// Tier 1: native ACP client with shared context and inline diff review.
17 Acp,
18}
19
20/// What the client offers to do on the agent's behalf (ADR-0007 §3).
21///
22/// These are advertised at `initialize`, and the defaults are a deliberate product
23/// decision rather than a shrug — see [`Default`].
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub struct ClientCapabilities {
26 /// Serve file contents from the live buffer, unsaved changes included. This is the
27 /// concrete mechanism behind "the agent shares your buffers" (ARCHITECTURE.md §9.2).
28 pub read_text_file: bool,
29 /// Accept writes — as *proposals*, never straight to disk.
30 pub write_text_file: bool,
31 /// Execute structured commands through model-owned PTY terminals.
32 pub terminal: bool,
33}
34
35impl Default for ClientCapabilities {
36 /// Both on.
37 ///
38 /// Advertising `write_text_file: false` is tempting and wrong: an agent told the
39 /// client cannot write files does not give up, it shells out and writes the file
40 /// itself, turning a reviewable proposal into an opaque side effect. Saying yes and
41 /// routing every write through review is what *keeps* edits in the loop.
42 fn default() -> Self {
43 Self { read_text_file: true, write_text_file: true, terminal: false }
44 }
45}
46
47/// The human's verdict while reviewing a proposal. Per hunk *and* per proposal, as
48/// ARCHITECTURE.md §9.3 requires.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum ProposalDecision {
51 AcceptAll,
52 AcceptHunk(usize),
53 RejectHunk(usize),
54 RejectAll,
55}
56
57/// What to tell the agent after a review, given how much of it the human took.
58///
59/// ACP has no `AllowPartial`, so a partial accept is answered `RejectOnce` (ADR-0007 §8).
60/// That looks wrong and is right: from the agent's side its write did not happen *as
61/// proposed*, and claiming otherwise would leave it building on a file it believes
62/// matches `new_text`. The follow-up message and a fresh read resync it to the truth.
63pub fn permission_for_review(accepted: usize, total: usize) -> PermissionDecision {
64 if total > 0 && accepted == total {
65 PermissionDecision::AllowOnce
66 } else {
67 PermissionDecision::RejectOnce
68 }
69}
70
71/// The ACP client surface.
72///
73/// Behind a trait so the wire format is isolated (ADR-0003's spec-churn mitigation), so a
74/// non-ACP backend could be substituted, and — most usefully day to day — so the whole
75/// review loop is testable against a scripted agent replaying a recorded stream.
76pub trait AgentService: Send {
77 fn integration(&self) -> AgentIntegration;
78
79 fn capabilities(&self) -> ClientCapabilities {
80 ClientCapabilities::default()
81 }
82
83 /// Queue work for the agent. Never blocks the caller.
84 fn send(&mut self, request: AgentRequest);
85
86 /// Take whatever the agent has produced since the last call.
87 ///
88 /// A drain rather than a callback so the single state owner stays in control of when
89 /// events are applied (ARCHITECTURE.md §7.1).
90 fn poll(&mut self) -> Vec<AgentEvent>;
91}
92
93/// The default when no agent is configured: Tier 0, and honest about it.
94///
95/// ADR-0003 promises agent-agnosticism, which we keep in the *default* and not just in
96/// the abstraction — no vendor is assumed, and the editor works with none configured.
97#[derive(Debug, Default)]
98pub struct NullAgent;
99
100impl AgentService for NullAgent {
101 fn integration(&self) -> AgentIntegration {
102 AgentIntegration::TerminalCli
103 }
104 fn send(&mut self, _request: AgentRequest) {}
105 fn poll(&mut self) -> Vec<AgentEvent> {
106 Vec::new()
107 }
108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113
114 #[test]
115 fn the_default_tier_needs_no_configured_agent() {
116 assert_eq!(NullAgent.integration(), AgentIntegration::TerminalCli);
117 assert!(NullAgent.poll().is_empty());
118 }
119
120 #[test]
121 fn we_advertise_both_file_capabilities() {
122 let caps = ClientCapabilities::default();
123 assert!(caps.read_text_file, "serving live buffers is the whole point");
124 assert!(caps.write_text_file, "saying no just pushes the agent to shell out");
125 assert!(!caps.terminal, "terminal support is enabled only after runtime wiring");
126 }
127
128 /// ADR-0007 §8 — the one place our UX is not expressible in the protocol.
129 #[test]
130 fn a_partial_accept_is_reported_as_a_rejection() {
131 assert_eq!(permission_for_review(3, 3), PermissionDecision::AllowOnce);
132 assert_eq!(
133 permission_for_review(2, 3),
134 PermissionDecision::RejectOnce,
135 "the agent must not think the file matches what it proposed"
136 );
137 assert_eq!(permission_for_review(0, 3), PermissionDecision::RejectOnce);
138 }
139
140 #[test]
141 fn an_empty_proposal_is_not_an_approval() {
142 assert_eq!(permission_for_review(0, 0), PermissionDecision::RejectOnce);
143 }
144}