Skip to main content

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}