agentmux_srv/agents/types.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Unified agent types shared between the agent pane (interactive)
5//! and the drone Agent block (headless). The design rationale lived in a
6//! Phase-1.5 lineage spec retired in #1928.
7//!
8//! Wire format is camelCase via `serde(rename_all)` so the TS
9//! mirror in `frontend/types/srv-types.d.ts` requires no field
10//! translation.
11
12use serde::{Deserialize, Serialize};
13
14/// Identifies "which agent." Empty-string sentinels match the
15/// existing mstore `AgentInstance` conventions. All fields optional
16/// so callers can construct anything from "blank claude with ambient
17/// creds" (all empty) up to a fully-pinned named-agent continuation.
18#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
19#[serde(rename_all = "camelCase")]
20pub struct AgentRef {
21 /// Legacy Identity-bundle id — `db_identity_bundles` was dropped in
22 /// Phase 4c of SPEC_PRESET_TO_BUNDLE_REFACTOR_2026_07_02.md, so this
23 /// field is now vestigial (never read at spawn; credential resolution
24 /// is `db_agent_identity_links`-only). Empty = none.
25 #[serde(default)]
26 pub identity_id: String,
27 /// FK to `db_bundles.id`. Empty = blank singleton (vanilla CLI,
28 /// no system instructions injected).
29 #[serde(default)]
30 pub memory_id: String,
31 /// User-chosen instance name. Empty for one-shot launches.
32 /// Non-empty triggers the named-agent continuation path: the
33 /// runner looks up an existing `AgentInstance` by name and reuses
34 /// its `working_directory` + `session_id` if present.
35 #[serde(default)]
36 pub instance_name: String,
37 /// Optional explicit working directory override. Empty falls
38 /// back to `allocate_agent_workdir()` at run time.
39 #[serde(default)]
40 pub working_directory: String,
41}
42
43/// What the agent should do, plus the variables for `{{ }}` resolution
44/// inside `prompt`. The agent pane uses `prompt=<user-typed-text>`
45/// with an empty `context`. The drone Agent block uses
46/// `prompt=<block.data.task>` resolved against `scope.outputs +
47/// scope.vars`.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49#[serde(rename_all = "camelCase")]
50pub struct AgentTask {
51 pub prompt: String,
52 /// Variable scope for template resolution inside `prompt`. Keys
53 /// are typically block ids or `var`/`env` namespaces; values are
54 /// JSON. The runner is responsible for resolution before spawn.
55 #[serde(default)]
56 pub context: serde_json::Map<String, serde_json::Value>,
57 /// Hard cap on turns. `None` = use the provider default.
58 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub max_turns: Option<u32>,
60}
61
62/// Discriminated streaming event. Same union for both the agent
63/// pane (renders into the UI) and the drone Agent block
64/// (accumulates until `Done`, returns `AgentRunResult`).
65///
66/// Provider-specific extension goes through a `Custom` variant
67/// reserved here but intentionally not shipped Phase 1.5 — leave
68/// the enum open for it. See spec §8 risks.
69#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)]
70#[ts(export, export_to = "../../../frontend/types/rpc/")]
71#[serde(tag = "type", rename_all = "snake_case", rename_all_fields = "camelCase")]
72pub enum AgentEvent {
73 /// Streaming text chunk from the assistant. Agent pane appends
74 /// to the visible transcript; drone Agent block buffers
75 /// until `Done`.
76 AssistantText {
77 delta: String,
78 },
79 /// Tool invocation about to run. `input` is the provider's raw
80 /// tool input JSON; renderers may dispatch on `tool` name.
81 ToolUse {
82 tool_use_id: String,
83 tool: String,
84 #[ts(type = "unknown")]
85 input: serde_json::Value,
86 },
87 /// Tool execution result.
88 ToolResult {
89 tool_use_id: String,
90 #[ts(type = "unknown")]
91 output: serde_json::Value,
92 #[serde(default)]
93 is_error: bool,
94 },
95 /// Final cost + token accounting. Emitted once per run, before
96 /// `Done`.
97 Cost {
98 cost_usd: f64,
99 tokens: TokenCounts,
100 },
101 /// Run completed successfully. `response` is the final assistant
102 /// message text (the drone Agent block's primary output).
103 /// `transcript` is the full ordered turn list for audit / replay.
104 Done {
105 response: String,
106 transcript: Vec<AgentTurn>,
107 },
108 /// Run failed. `message` is the user-facing error.
109 Error {
110 message: String,
111 },
112 /// Context compaction completed. Sourced from Claude Code's
113 /// `type: "system", subtype: "compact_boundary"` stream-json
114 /// frame — exact data, not a heuristic. See
115 /// `docs/specs/SPEC_COMPACTION_DETECTION_AND_HANDLING_2026_07_31.md`
116 /// §2/§4.1 for the full design rationale and the source frame
117 /// shape this is translated from.
118 CompactionBoundary {
119 trigger: CompactionTrigger,
120 #[ts(type = "number")]
121 pre_tokens: u64,
122 #[ts(type = "number")]
123 post_tokens: u64,
124 #[ts(type = "number")]
125 cumulative_dropped_tokens: u64,
126 #[ts(type = "number")]
127 duration_ms: u64,
128 },
129}
130
131/// How compaction was triggered: the same type as `compaction_started`'s trigger, which
132/// `agentmux-bashwrap` publishes, so it is declared once, in `agentmux-common`.
133pub use agentmux_common::client_events::CompactionTrigger;
134
135// Generated for the session domain (ActivitySummaryResult.tokens). The
136// hand-written global `TokenCounts` in srv-types.d.ts stays for now: four other
137// global types reference it and a global cannot import, so it can only be
138// deleted once those domains migrate too. Both shapes are identical --
139// `rename_all = "camelCase"` gives `cacheCreation`/`cacheRead`, which is what
140// the hand-written copy already says.
141#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, ts_rs::TS)]
142#[ts(export, export_to = "../../../frontend/types/rpc/")]
143#[serde(rename_all = "camelCase")]
144pub struct TokenCounts {
145 #[serde(default)]
146 #[ts(type = "number")]
147 pub input: u64,
148 #[serde(default)]
149 #[ts(type = "number")]
150 pub output: u64,
151 #[serde(default)]
152 #[ts(type = "number")]
153 pub cache_creation: u64,
154 #[serde(default)]
155 #[ts(type = "number")]
156 pub cache_read: u64,
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)]
160#[ts(export, export_to = "../../../frontend/types/rpc/")]
161#[serde(rename_all = "camelCase")]
162pub struct AgentTurn {
163 #[ts(type = r#""user" | "assistant" | "tool_result""#)]
164 pub role: String,
165 #[ts(type = "unknown")]
166 pub content: serde_json::Value,
167 #[ts(type = "number")]
168 pub timestamp_ms: i64,
169}
170
171/// Final structured result of a complete agent run — the value the
172/// drone Agent block returns to downstream blocks. The agent
173/// pane discards this (it has already rendered the stream) but
174/// constructs the same struct for the audit log.
175#[derive(Debug, Clone, Default, Serialize, Deserialize)]
176#[serde(rename_all = "camelCase")]
177pub struct AgentRunResult {
178 pub response: String,
179 pub tokens: TokenCounts,
180 pub cost_usd: f64,
181 pub transcript: Vec<AgentTurn>,
182 /// Terminal stream-json `result` frame when it reported an error
183 /// (`is_error` / `error_*` subtype). Internal only — `#[serde(skip)]`
184 /// keeps it off the IPC wire. Lets the runner fail a run that claude
185 /// reported as an error on stdout while still exiting 0. (codex P1 #1353.)
186 #[serde(skip)]
187 pub error_frame: Option<serde_json::Value>,
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193 use serde_json::json;
194
195 // ────────────────────────────────────────────────────────────────
196 // Wire format — verify camelCase on the JSON side. The TS mirror
197 // in `frontend/types/srv-types.d.ts` depends on this; any drift
198 // becomes silent type errors at the IPC seam.
199 // ────────────────────────────────────────────────────────────────
200
201 #[test]
202 fn agent_ref_serializes_camelcase() {
203 let r = AgentRef {
204 identity_id: "id1".into(),
205 memory_id: "mem1".into(),
206 instance_name: "alice".into(),
207 working_directory: "/tmp/x".into(),
208 };
209 let v = serde_json::to_value(&r).unwrap();
210 assert_eq!(
211 v,
212 json!({
213 "identityId": "id1",
214 "memoryId": "mem1",
215 "instanceName": "alice",
216 "workingDirectory": "/tmp/x"
217 })
218 );
219 }
220
221 #[test]
222 fn agent_ref_defaults_round_trip() {
223 // Empty-string sentinels match the mstore convention so the
224 // frontend can omit fields it doesn't set.
225 let r: AgentRef = serde_json::from_value(json!({})).unwrap();
226 assert_eq!(r, AgentRef::default());
227 }
228
229 #[test]
230 fn agent_event_assistant_text_shape() {
231 let ev = AgentEvent::AssistantText {
232 delta: "hi".into(),
233 };
234 let v = serde_json::to_value(&ev).unwrap();
235 assert_eq!(v, json!({ "type": "assistant_text", "delta": "hi" }));
236 }
237
238 #[test]
239 fn agent_event_tool_use_camelcase_id() {
240 let ev = AgentEvent::ToolUse {
241 tool_use_id: "tu_42".into(),
242 tool: "bash".into(),
243 input: json!({ "cmd": "ls" }),
244 };
245 let v = serde_json::to_value(&ev).unwrap();
246 assert_eq!(
247 v,
248 json!({
249 "type": "tool_use",
250 "toolUseId": "tu_42",
251 "tool": "bash",
252 "input": { "cmd": "ls" }
253 })
254 );
255 }
256
257 #[test]
258 fn agent_event_cost_shape() {
259 let ev = AgentEvent::Cost {
260 cost_usd: 0.0123,
261 tokens: TokenCounts {
262 input: 100,
263 output: 50,
264 cache_creation: 0,
265 cache_read: 200,
266 },
267 };
268 let v = serde_json::to_value(&ev).unwrap();
269 assert_eq!(
270 v,
271 json!({
272 "type": "cost",
273 "costUsd": 0.0123,
274 "tokens": {
275 "input": 100,
276 "output": 50,
277 "cacheCreation": 0,
278 "cacheRead": 200
279 }
280 })
281 );
282 }
283
284 #[test]
285 fn agent_event_roundtrips() {
286 let original = AgentEvent::Done {
287 response: "ok".into(),
288 transcript: vec![AgentTurn {
289 role: "assistant".into(),
290 content: json!("hi"),
291 timestamp_ms: 1_700_000_000_000,
292 }],
293 };
294 let s = serde_json::to_string(&original).unwrap();
295 let parsed: AgentEvent = serde_json::from_str(&s).unwrap();
296 // Match the shape, not the exact equality (transcript Vec).
297 match parsed {
298 AgentEvent::Done {
299 response,
300 transcript,
301 } => {
302 assert_eq!(response, "ok");
303 assert_eq!(transcript.len(), 1);
304 assert_eq!(transcript[0].role, "assistant");
305 assert_eq!(transcript[0].timestamp_ms, 1_700_000_000_000);
306 }
307 _ => panic!("expected Done variant"),
308 }
309 }
310
311 #[test]
312 fn agent_event_compaction_boundary_shape() {
313 let ev = AgentEvent::CompactionBoundary {
314 trigger: CompactionTrigger::Manual,
315 pre_tokens: 783_887,
316 post_tokens: 11_775,
317 cumulative_dropped_tokens: 772_112,
318 duration_ms: 231_606,
319 };
320 let v = serde_json::to_value(&ev).unwrap();
321 assert_eq!(
322 v,
323 json!({
324 "type": "compaction_boundary",
325 "trigger": "manual",
326 "preTokens": 783_887,
327 "postTokens": 11_775,
328 "cumulativeDroppedTokens": 772_112,
329 "durationMs": 231_606
330 })
331 );
332 }
333
334 #[test]
335 fn compaction_trigger_serializes_snake_case() {
336 assert_eq!(
337 serde_json::to_value(CompactionTrigger::Auto).unwrap(),
338 json!("auto")
339 );
340 assert_eq!(
341 serde_json::to_value(CompactionTrigger::Manual).unwrap(),
342 json!("manual")
343 );
344 }
345
346 #[test]
347 fn agent_run_result_shape() {
348 let r = AgentRunResult {
349 response: "hi".into(),
350 tokens: TokenCounts::default(),
351 cost_usd: 0.0,
352 transcript: vec![],
353 error_frame: None,
354 };
355 let v = serde_json::to_value(&r).unwrap();
356 // costUsd at the result level, tokens nested with camelCase.
357 assert_eq!(
358 v,
359 json!({
360 "response": "hi",
361 "tokens": {
362 "input": 0,
363 "output": 0,
364 "cacheCreation": 0,
365 "cacheRead": 0
366 },
367 "costUsd": 0.0,
368 "transcript": []
369 })
370 );
371 }
372}