beam-worker 0.12.1

Per-session worker process for beam that owns terminal backends and CLI adapters
Documentation
use std::fs::File;
use std::io::{Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use std::time::Duration;

use anyhow::Result;
use async_trait::async_trait;
use beam_core::{FinalOutputKind, InitConfig};
use serde_json::Value;

use crate::adapter::{
    Adapter, PollResult, SpawnSpec, SubmitResult, TranscriptCursor, confirm_submit_loop, file_size,
};
use crate::backend::SessionBackend;

const HISTORY_LOOKBACK: u64 = 65536;

#[derive(Debug, Clone, Default)]
pub(crate) struct AntigravityState {
    history_path: PathBuf,
    cli_session_id: Option<String>,
    cursor: TranscriptCursor,
}

fn state_from_init(init: &InitConfig) -> AntigravityState {
    let home = std::env::var("HOME").unwrap_or_default();
    let history_path = PathBuf::from(format!("{}/.gemini/antigravity-cli/history.jsonl", home));
    AntigravityState {
        history_path,
        cli_session_id: init.cli_session_id.clone(),
        cursor: TranscriptCursor::new(),
    }
}

pub fn create(init: &InitConfig) -> Box<dyn Adapter> {
    Box::new(state_from_init(init))
}

#[async_trait]
impl Adapter for AntigravityState {
    fn build_spawn_spec(&self, init: &InitConfig) -> SpawnSpec {
        let mut args = Vec::new();
        if let Some(rsid) = &init.resume_session_id {
            args.push("--conversation".to_string());
            args.push(rsid.clone());
        }
        args.extend(init.cli_args.clone());
        SpawnSpec {
            bin: init.cli_bin.clone(),
            args,
        }
    }

    async fn write_input(
        &mut self,
        backend: &dyn SessionBackend,
        content: &str,
    ) -> Result<SubmitResult> {
        let base_byte = file_size(&self.history_path);

        let lines: Vec<&str> = content.split('\n').collect();
        for (index, line) in lines.iter().enumerate() {
            backend.send_text(line).await?;
            tokio::time::sleep(Duration::from_millis(30)).await;
            if index < lines.len() - 1 {
                backend.send_special_keys(&["M-Enter".to_string()]).await?;
                tokio::time::sleep(Duration::from_millis(30)).await;
            }
        }
        tokio::time::sleep(Duration::from_millis(500)).await;
        backend.send_enter().await?;

        let mut confirm = || agy_history_match(&self.history_path, base_byte, content);
        let mut confirmed = confirm_submit_loop(backend, &mut confirm).await?;
        if !confirmed {
            confirmed = confirm()?;
        }
        if confirmed {
            return Ok(SubmitResult {
                submitted: true,
                cli_session_id: self.cli_session_id.clone(),
                ..Default::default()
            });
        }
        Ok(SubmitResult {
            submitted: false,
            cli_session_id: self.cli_session_id.clone(),
            failure_reason: Some("Antigravity history did not confirm submit".to_string()),
        })
    }

    fn poll(&mut self) -> Result<PollResult> {
        let path = self.history_path.clone();
        let lines = self.cursor.drain(&path)?;

        let mut result = PollResult {
            cli_session_id: self.cli_session_id.clone(),
            ..Default::default()
        };

        for line in &lines {
            let Ok(value) = serde_json::from_str::<Value>(line) else {
                continue;
            };
            let Some(role) = value.get("role").and_then(Value::as_str) else {
                continue;
            };
            if role == "model"
                && let Some(text) = value.get("display").and_then(Value::as_str)
                && let Some(emitted) = self.cursor.emit_if_new(text)
            {
                result.final_output = Some(emitted);
                result.final_output_kind = Some(FinalOutputKind::Bridge);
                result.prompt_ready = true;
            }
        }

        Ok(result)
    }
}

fn agy_history_match(history_path: &Path, from_byte: u64, expected_text: &str) -> Result<bool> {
    if !history_path.exists() {
        return Ok(false);
    }
    let size = file_size(history_path);
    if size <= from_byte {
        return Ok(false);
    }
    let start = from_byte.saturating_sub(HISTORY_LOOKBACK);
    let mut file = File::open(history_path)?;
    file.seek(SeekFrom::Start(start))?;
    let mut text = String::new();
    file.read_to_string(&mut text)?;

    let marker = build_agy_marker(expected_text);
    Ok(text.contains(&marker))
}

fn build_agy_marker(text: &str) -> String {
    let prefix: String = text.chars().take(40).collect();
    let escaped = serde_json::to_string(&prefix).unwrap_or_default();
    let escaped = escaped.trim_matches('"');
    patched_json_escape(escaped)
}

fn patched_json_escape(s: &str) -> String {
    s.replace('<', "\\u003c")
        .replace('>', "\\u003e")
        .replace('&', "\\u0026")
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::adapter::test_support::{home_test_lock, set_home, temp_home, test_init};
    use std::fs::{self, create_dir_all};

    fn agy_init() -> InitConfig {
        InitConfig {
            cli_bin: "/bin/agy".to_string(),
            cli_session_id: Some("cli-session".to_string()),
            ..test_init("antigravity")
        }
    }

    fn write_history(path: &Path, lines: &[&str]) {
        if let Some(parent) = path.parent() {
            create_dir_all(parent).unwrap();
        }
        fs::write(path, lines.join("\n") + "\n").unwrap();
    }

    #[test]
    fn poll_emits_model_display_and_dedupes_repeats() {
        let _lock = home_test_lock()
            .lock()
            .unwrap_or_else(|poison| poison.into_inner());
        let home = temp_home("beam-agy-test");
        let _guard = set_home(&home);
        let init = agy_init();
        let mut state = state_from_init(&init);
        write_history(
            &state.history_path,
            &[
                r#"{"role":"user","display":"ignore"}"#,
                r#"{"role":"model","display":"first"}"#,
            ],
        );

        let first = state.poll().unwrap();
        assert_eq!(first.final_output.as_deref(), Some("first"));
        assert_eq!(first.final_output_kind, Some(FinalOutputKind::Bridge));
        assert!(first.prompt_ready);

        let second = state.poll().unwrap();
        assert!(second.final_output.is_none());
        assert!(!second.prompt_ready);
    }

    #[test]
    fn poll_recovers_after_truncation_and_re_emits_final_output() {
        let _lock = home_test_lock()
            .lock()
            .unwrap_or_else(|poison| poison.into_inner());
        let home = temp_home("beam-agy-truncate-test");
        let _guard = set_home(&home);
        let init = agy_init();
        let mut state = state_from_init(&init);
        write_history(
            &state.history_path,
            &[
                r#"{"role":"user","display":"noise"}"#,
                r#"{"role":"model","display":"first"}"#,
            ],
        );

        let first = state.poll().unwrap();
        assert_eq!(first.final_output.as_deref(), Some("first"));

        write_history(
            &state.history_path,
            &[r#"{"role":"model","display":"first"}"#],
        );
        let second = state.poll().unwrap();
        assert_eq!(second.final_output.as_deref(), Some("first"));
    }
}