agcodex-exec 0.1.0

Headless execution mode for AGCodex
Documentation
#![allow(clippy::expect_used, clippy::unwrap_used)]

use agcodex_core::CODEX_APPLY_PATCH_ARG1;
use anyhow::Context;
use assert_cmd::prelude::*;
use std::fs;
use std::process::Command;
use tempfile::tempdir;

/// While we may add an `apply-patch` subcommand to the `codex` CLI multitool
/// at some point, we must ensure that the smaller `codex-exec` CLI can still
/// emulate the `apply_patch` CLI.
#[test]
fn test_standalone_exec_cli_can_use_apply_patch() -> anyhow::Result<()> {
    let tmp = tempdir()?;
    let relative_path = "source.txt";
    let absolute_path = tmp.path().join(relative_path);
    fs::write(&absolute_path, "original content\n")?;

    Command::cargo_bin("agcodex-exec")
        .context("should find binary for codex-exec")?
        .arg(CODEX_APPLY_PATCH_ARG1)
        .arg(
            r#"*** Begin Patch
*** Update File: source.txt
@@
-original content
+modified by apply_patch
*** End Patch"#,
        )
        .current_dir(tmp.path())
        .assert()
        .success()
        .stdout("Success. Updated the following files:\nM source.txt\n")
        .stderr(predicates::str::is_empty());
    assert_eq!(
        fs::read_to_string(absolute_path)?,
        "modified by apply_patch\n"
    );
    Ok(())
}

#[cfg(not(target_os = "windows"))]
#[tokio::test]
async fn test_apply_patch_tool() -> anyhow::Result<()> {
    use core_test_support::load_sse_fixture_with_id_from_str;
    use tempfile::TempDir;
    use wiremock::Mock;
    use wiremock::MockServer;
    use wiremock::ResponseTemplate;
    use wiremock::matchers::method;
    use wiremock::matchers::path;

    const SSE_TOOL_CALL_ADD: &str = r#"[
  {
    "type": "response.output_item.done",
    "item": {
      "type": "function_call",
      "name": "apply_patch",
      "arguments": "{\n  \"input\": \"*** Begin Patch\\n*** Add File: test.md\\n+Hello world\\n*** End Patch\"\n}",
      "call_id": "__ID__"
    }
  },
  {
    "type": "response.completed",
    "response": {
      "id": "__ID__",
      "usage": {
        "input_tokens": 0,
        "input_tokens_details": null,
        "output_tokens": 0,
        "output_tokens_details": null,
        "total_tokens": 0
      },
      "output": []
    }
  }
]"#;

    const SSE_TOOL_CALL_UPDATE: &str = r#"[
  {
    "type": "response.output_item.done",
    "item": {
      "type": "function_call",
      "name": "apply_patch",
      "arguments": "{\n  \"input\": \"*** Begin Patch\\n*** Update File: test.md\\n@@\\n-Hello world\\n+Final text\\n*** End Patch\"\n}",
      "call_id": "__ID__"
    }
  },
  {
    "type": "response.completed",
    "response": {
      "id": "__ID__",
      "usage": {
        "input_tokens": 0,
        "input_tokens_details": null,
        "output_tokens": 0,
        "output_tokens_details": null,
        "total_tokens": 0
      },
      "output": []
    }
  }
]"#;

    const SSE_TOOL_CALL_COMPLETED: &str = r#"[
  {
    "type": "response.completed",
    "response": {
      "id": "__ID__",
      "usage": {
        "input_tokens": 0,
        "input_tokens_details": null,
        "output_tokens": 0,
        "output_tokens_details": null,
        "total_tokens": 0
      },
      "output": []
    }
  }
]"#;

    // Start a mock model server
    let server = MockServer::start().await;

    // First response: model calls apply_patch to create test.md
    let first = ResponseTemplate::new(200)
        .insert_header("content-type", "text/event-stream")
        .set_body_raw(
            load_sse_fixture_with_id_from_str(SSE_TOOL_CALL_ADD, "call1"),
            "text/event-stream",
        );

    Mock::given(method("POST"))
        // .and(path("/v1/responses"))
        .respond_with(first)
        .up_to_n_times(1)
        .mount(&server)
        .await;

    // Second response: model calls apply_patch to update test.md
    let second = ResponseTemplate::new(200)
        .insert_header("content-type", "text/event-stream")
        .set_body_raw(
            load_sse_fixture_with_id_from_str(SSE_TOOL_CALL_UPDATE, "call2"),
            "text/event-stream",
        );

    Mock::given(method("POST"))
        .and(path("/v1/responses"))
        .respond_with(second)
        .up_to_n_times(1)
        .mount(&server)
        .await;

    let final_completed = ResponseTemplate::new(200)
        .insert_header("content-type", "text/event-stream")
        .set_body_raw(
            load_sse_fixture_with_id_from_str(SSE_TOOL_CALL_COMPLETED, "resp3"),
            "text/event-stream",
        );

    Mock::given(method("POST"))
        // .and(path("/v1/responses"))
        .respond_with(final_completed)
        .expect(1)
        .mount(&server)
        .await;

    let tmp_cwd = TempDir::new().unwrap();
    Command::cargo_bin("agcodex-exec")
        .context("should find binary for codex-exec")?
        .current_dir(tmp_cwd.path())
        .env("CODEX_HOME", tmp_cwd.path())
        .env("OPENAI_API_KEY", "dummy")
        .env("OPENAI_BASE_URL", format!("{}/v1", server.uri()))
        .arg("--skip-git-repo-check")
        .arg("-s")
        .arg("workspace-write")
        .arg("foo")
        .assert()
        .success();

    // Verify final file contents
    let final_path = tmp_cwd.path().join("test.md");
    let contents = std::fs::read_to_string(&final_path)
        .unwrap_or_else(|e| panic!("failed reading {}: {e}", final_path.display()));
    assert_eq!(contents, "Final text\n");
    Ok(())
}