Skip to main content

agentmux_srv/agents/translator/
gemini.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Gemini CLI `--output-format stream-json` → `AgentEvent` translator —
5//! the answer text only.
6//!
7//! Scoped to what a tool-less, one-shot turn produces (`/btw`,
8//! `server/agent_handlers/side_question.rs`). The full renderer is the
9//! frontend's `providers/gemini-translator.ts`, which this follows for the
10//! frames it handles:
11//!
12//! - `message` with `role = "assistant"` — `content` is an incremental
13//!   chunk (not accumulated text), emitted as `AssistantText`.
14//! - `result` — `Done` when `status` is `"success"`, else `Error` with the
15//!   frame's error message.
16//!
17//! `init`, user-prompt echoes, `tool_use` / `tool_result` and anything
18//! unrecognised are dropped.
19
20use serde_json::Value;
21
22use super::super::types::AgentEvent;
23
24#[derive(Default)]
25pub struct GeminiTranslator {
26    response: String,
27    terminal: bool,
28}
29
30impl GeminiTranslator {
31    pub fn new() -> Self {
32        Self::default()
33    }
34}
35
36impl super::Translator for GeminiTranslator {
37    fn translate(&mut self, frame: Value) -> Vec<AgentEvent> {
38        if self.terminal {
39            return Vec::new();
40        }
41        match frame.get("type").and_then(Value::as_str).unwrap_or("") {
42            "message" if frame.get("role").and_then(Value::as_str) == Some("assistant") => {
43                let chunk = frame.get("content").and_then(Value::as_str).unwrap_or("");
44                if chunk.is_empty() {
45                    return Vec::new();
46                }
47                self.response.push_str(chunk);
48                vec![AgentEvent::AssistantText {
49                    delta: chunk.to_string(),
50                }]
51            }
52            "result" => {
53                self.terminal = true;
54                let status = frame.get("status").and_then(Value::as_str).unwrap_or("success");
55                if status == "success" {
56                    return vec![AgentEvent::Done {
57                        response: std::mem::take(&mut self.response),
58                        transcript: Vec::new(),
59                    }];
60                }
61                let err = frame.get("error");
62                let message = err
63                    .and_then(|e| e.get("message"))
64                    .and_then(Value::as_str)
65                    .or_else(|| err.and_then(Value::as_str))
66                    .or_else(|| frame.get("message").and_then(Value::as_str))
67                    .map(str::to_string)
68                    .unwrap_or_else(|| format!("gemini turn {status}"));
69                vec![AgentEvent::Error { message }]
70            }
71            _ => Vec::new(),
72        }
73    }
74}
75
76#[cfg(test)]
77mod tests {
78    use super::super::Translator;
79    use super::*;
80    use serde_json::json;
81
82    #[test]
83    fn assistant_chunks_stream_and_a_successful_result_is_done() {
84        let mut t = GeminiTranslator::new();
85        let mut out = Vec::new();
86        for frame in [
87            json!({"type": "init", "session_id": "s1", "model": "gemini"}),
88            json!({"type": "message", "role": "user", "content": "why?"}),
89            json!({"type": "message", "role": "assistant", "content": "Because ", "delta": true}),
90            json!({"type": "message", "role": "assistant", "content": "reasons.", "delta": true}),
91            json!({"type": "result", "status": "success", "stats": {}}),
92        ] {
93            out.extend(t.translate(frame));
94        }
95        let deltas: Vec<_> = out
96            .iter()
97            .filter_map(|e| match e {
98                AgentEvent::AssistantText { delta } => Some(delta.as_str()),
99                _ => None,
100            })
101            .collect();
102        assert_eq!(deltas, ["Because ", "reasons."]);
103        match out.last() {
104            Some(AgentEvent::Done { response, .. }) => assert_eq!(response, "Because reasons."),
105            other => panic!("expected Done, got {other:?}"),
106        }
107    }
108
109    #[test]
110    fn a_failed_result_is_an_error_with_its_message() {
111        let mut t = GeminiTranslator::new();
112        let out = t.translate(json!({"type": "result", "status": "error", "error": {"type": "unknown", "message": "[API Error: quota]"}}));
113        match out.as_slice() {
114            [AgentEvent::Error { message }] => assert_eq!(message, "[API Error: quota]"),
115            other => panic!("expected one Error, got {other:?}"),
116        }
117        assert!(t.translate(json!({"type": "message", "role": "assistant", "content": "late"})).is_empty());
118    }
119
120    #[test]
121    fn tool_frames_are_dropped() {
122        let mut t = GeminiTranslator::new();
123        assert!(t.translate(json!({"type": "tool_use", "tool_name": "read_file", "tool_id": "t1"})).is_empty());
124        assert!(t.translate(json!({"type": "tool_result", "tool_id": "t1", "status": "success"})).is_empty());
125    }
126}