termesh_core/agent.rs
1//! Agent data types shared across the service boundary.
2//!
3//! These live in `core` rather than in `agent` for the same reason [`crate::fs`] does:
4//! [`crate::AppMessage`] has to carry them from the agent worker thread to the single
5//! state owner (ARCHITECTURE.md §7.1). The `AgentService` trait and its implementations
6//! stay in `agent`, which re-exports everything here so call sites see one module.
7//!
8//! Note what is *not* here: no ACP wire type. These are our own vocabulary, translated at
9//! the transport boundary, which is ADR-0003's mitigation for protocol churn.
10
11use std::path::PathBuf;
12
13use crate::{
14 AgentTerminalOperation, AgentTerminalRequestId, AgentTerminalResponse, PermissionRequestId,
15 ProposalId, ReadRequestId, SessionId, TerminalId, TerminalSpec,
16};
17
18/// The protocol's four permission responses.
19///
20/// Four, not three: the Phase-00 stub's `AllowOnce | AllowSession | Deny` could not
21/// express `RejectAlways`, which ACP requires us to round-trip.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum PermissionDecision {
24 AllowOnce,
25 AllowAlways,
26 RejectOnce,
27 RejectAlways,
28}
29
30impl PermissionDecision {
31 pub fn allows(self) -> bool {
32 matches!(self, PermissionDecision::AllowOnce | PermissionDecision::AllowAlways)
33 }
34
35 /// Whether this answer should be recorded as a standing policy for the workspace.
36 pub fn is_remembered(self) -> bool {
37 matches!(self, PermissionDecision::AllowAlways | PermissionDecision::RejectAlways)
38 }
39}
40
41/// What the agent told us it can do, read from the `initialize` result
42/// (ADR-0014 §4). Absent means absent — an agent that says nothing about a capability is
43/// assumed **not** to support it, never assumed to. Recorded and reported this phase;
44/// Phase 11 gates behaviour on it.
45#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
46pub struct AgentCapabilities {
47 /// Whether the agent supports `session/load` — resuming a session by id rather than
48 /// only ever starting a fresh one. No agent this client has spoken to advertises it
49 /// today, and this client has no `session/load` request to send even if one did
50 /// (ADR-0014 §4): recorded so the boundary is a measured fact, not an assumption.
51 pub load_session: bool,
52 pub prompt_capabilities: PromptCapabilities,
53}
54
55/// The content kinds a `session/prompt` turn may include, per the agent's own handshake.
56#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
57pub struct PromptCapabilities {
58 pub image: bool,
59 pub audio: bool,
60 pub embedded_context: bool,
61}
62
63/// Why a turn ended.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum StopReason {
66 EndTurn,
67 Cancelled,
68 Refusal,
69 MaxTokens,
70}
71
72/// Work sent *to* the agent.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum AgentRequest {
75 NewSession {
76 cwd: PathBuf,
77 },
78 /// Move the session to one of the modes the agent offered (ADR-0015).
79 ///
80 /// Only ever sent because a human asked for it. The agent's default stands until
81 /// then, including when that default forbids the edit the agent was just asked to
82 /// make — a client that widens its own permissions on refusal is not asking.
83 SetMode {
84 session: SessionId,
85 mode: String,
86 },
87 /// A user turn. `context` is the workspace snapshot rendered as text and prepended —
88 /// small and current, because everything bulky is *pulled* on demand instead
89 /// (ADR-0007 §4).
90 Prompt {
91 session: SessionId,
92 text: String,
93 context: String,
94 },
95 /// Our answer to [`AgentEvent::ReadFileRequested`], served from the live buffer.
96 /// `None` means we could not read it.
97 ///
98 /// Carries the `request` it answers rather than only the path, so two reads of the
99 /// same file in one turn cannot be confused for each other.
100 FileContents {
101 session: SessionId,
102 request: ReadRequestId,
103 path: PathBuf,
104 contents: Option<String>,
105 },
106 Permission {
107 request: PermissionRequestId,
108 decision: PermissionDecision,
109 },
110 /// Cancel a permission prompt because its owning turn/session is no longer live.
111 PermissionCancelled {
112 request: PermissionRequestId,
113 },
114 TerminalResponse {
115 request: AgentTerminalRequestId,
116 response: AgentTerminalResponse,
117 },
118 Cancel {
119 session: SessionId,
120 },
121 Shutdown,
122}
123
124/// The before-and-after text of an edit an agent is asking permission to make.
125///
126/// `old_text` is what the agent believes it is replacing. It is **not** reliably the whole
127/// file: opencode sends the entire document, Codex sends only the lines it touches, and both
128/// arrive in the same `content[]` entry. Deciding which one came is the caller's job, because
129/// only the caller holds the buffer to compare against (ADR-0016 §1a).
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct ProposedEditDiff {
132 pub path: PathBuf,
133 pub old_text: String,
134 pub new_text: String,
135}
136
137/// One entry from an agent's `availableModes`.
138///
139/// `description` is the agent's own wording for what the mode permits, which is the only
140/// trustworthy account of it: `auto` and `full-access` mean whatever that agent decided,
141/// and the client must not infer permissions from the name.
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct SessionMode {
144 pub id: String,
145 pub name: String,
146 pub description: Option<String>,
147}
148
149/// What comes back *from* the agent.
150///
151/// Exhaustive on purpose, like `FsEvent`: adding a variant should break every loop that
152/// has not decided what to do about it.
153#[derive(Debug, Clone, PartialEq, Eq)]
154pub enum AgentEvent {
155 /// The handshake completed. Emitted once, before any session, so it carries no
156 /// `SessionId` — capabilities belong to the agent connection, not to a session.
157 Ready {
158 capabilities: AgentCapabilities,
159 },
160 SessionStarted {
161 session: SessionId,
162 },
163 /// What the agent will let this session do, and which of those it started in
164 /// (ADR-0015). Absent for agents that do not offer modes, which is most of them.
165 ModesAvailable {
166 session: SessionId,
167 current: String,
168 available: Vec<SessionMode>,
169 },
170 /// The agent reports the session is now in `mode`. The agent's account is the truth,
171 /// so this is what updates the client — not the response to our own request.
172 ModeChanged {
173 session: SessionId,
174 mode: String,
175 },
176 /// Streamed assistant text.
177 MessageChunk {
178 session: SessionId,
179 text: String,
180 },
181 /// Streamed reasoning, rendered dimmer than the answer.
182 ThoughtChunk {
183 session: SessionId,
184 text: String,
185 },
186 /// The agent asked us for a file. We answer from the buffer if it is open.
187 ReadFileRequested {
188 session: SessionId,
189 request: ReadRequestId,
190 path: PathBuf,
191 },
192 /// A proposed edit, as whole-file before/after text — the shape ACP actually uses.
193 /// `old_text` is `None` when the agent is creating a new file.
194 ProposedEdit {
195 session: SessionId,
196 proposal: ProposalId,
197 path: PathBuf,
198 old_text: Option<String>,
199 new_text: String,
200 },
201 /// A tool call awaiting approval. `command` is an argv array — we never interpolate
202 /// agent output into a shell string (ARCHITECTURE.md §9.4, §11).
203 PermissionRequested {
204 session: SessionId,
205 request: PermissionRequestId,
206 summary: String,
207 command: Vec<String>,
208 /// Present only when raw ACP input supplied an exact structured command.
209 terminal_spec: Option<TerminalSpec>,
210 /// The edit this permission would authorise, when the agent described one.
211 ///
212 /// An agent that asks before editing is an agent whose edits can be reviewed, so
213 /// this is what turns an "allow?" prompt into a diff (ADR-0016 §1).
214 edit: Option<ProposedEditDiff>,
215 },
216 TerminalRequest {
217 session: SessionId,
218 request: AgentTerminalRequestId,
219 operation: AgentTerminalOperation,
220 },
221 TerminalAttached {
222 session: SessionId,
223 terminal: TerminalId,
224 },
225 TurnEnded {
226 session: SessionId,
227 reason: StopReason,
228 },
229 Failed {
230 session: SessionId,
231 message: String,
232 },
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238
239 #[test]
240 fn permission_answers_split_into_allow_and_remember() {
241 assert!(PermissionDecision::AllowOnce.allows());
242 assert!(PermissionDecision::AllowAlways.allows());
243 assert!(!PermissionDecision::RejectOnce.allows());
244 assert!(!PermissionDecision::RejectAlways.allows());
245
246 assert!(!PermissionDecision::AllowOnce.is_remembered());
247 assert!(PermissionDecision::AllowAlways.is_remembered());
248 assert!(
249 PermissionDecision::RejectAlways.is_remembered(),
250 "the variant the three-way stub could not express"
251 );
252 }
253}