magi-code 0.96.2

Repository-aware CLI coding agent for terminal work
Documentation
//! Claude CLI request projection. The host's current conversation, not native session
//! persistence, is the source of truth after edits and compaction.
use super::{ProviderRequest, anthropic::anthropic_messages_body_with_cache_ttl};
use crate::thinking::ThinkingLevel;
use anyhow::{Context, Result, bail};
use serde_json::{Value, json};
use std::collections::HashSet;

const TOOL_PREFIX: &str = "mcp__magi__";

pub(super) struct ClaudeProjection {
    pub(super) system: String,
    pub(super) frames: Vec<Value>,
    pub(super) manifest: Vec<Value>,
    pub(super) extra_body: Value,
}

impl ClaudeProjection {
    pub(super) fn from_request(request: &ProviderRequest) -> Result<Self> {
        let mut body = anthropic_messages_body_with_cache_ttl(
            &request.model,
            request,
            None,
            None,
            ThinkingLevel::Default,
        );
        let system = body
            .get("system")
            .and_then(Value::as_str)
            .unwrap_or("")
            .to_owned();
        let messages = body
            .get("messages")
            .and_then(Value::as_array)
            .context("Claude conversation missing messages")?;
        let mut frames: Vec<Value> = Vec::new();
        for message in messages {
            let role = message
                .get("role")
                .and_then(Value::as_str)
                .context("Claude conversation message missing role")?;
            if !matches!(role, "user" | "assistant") {
                bail!("Claude conversation has unsupported role");
            }
            let mut content = message
                .get("content")
                .and_then(Value::as_array)
                .context("Claude conversation message missing content")?
                .clone();
            // Replayed signatures cannot be trusted after host hooks or compaction rewrite
            // the visible assistant history. Native may generate new thinking each request.
            content.retain(|block| {
                !matches!(
                    block.get("type").and_then(Value::as_str),
                    Some("thinking" | "redacted_thinking")
                )
            });
            for block in &mut content {
                if block.get("type").and_then(Value::as_str) == Some("tool_use") {
                    let name = block
                        .get("name")
                        .and_then(Value::as_str)
                        .context("Claude replayed tool call missing name")?;
                    if name.is_empty()
                        || name.len() > 50
                        || !name.bytes().all(|byte| {
                            byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-'
                        })
                    {
                        bail!("Claude replayed tool has invalid name");
                    }
                    block["name"] = json!(format!("{TOOL_PREFIX}{name}"));
                }
            }
            if content.is_empty() {
                continue;
            }
            if frames
                .last()
                .and_then(|frame| frame.get("type"))
                .and_then(Value::as_str)
                == Some(role)
            {
                if let Some(parts) = frames
                    .last_mut()
                    .and_then(|frame| frame.pointer_mut("/message/content"))
                    .and_then(Value::as_array_mut)
                {
                    parts.extend(content);
                }
            } else {
                frames.push(json!({"type":role,"message":{"role":role,"content":content}}));
            }
        }
        if frames
            .last()
            .and_then(|frame| frame.get("type"))
            .and_then(Value::as_str)
            != Some("user")
            || frames
                .last()
                .and_then(|frame| frame.pointer("/message/content"))
                .and_then(Value::as_array)
                .is_none_or(Vec::is_empty)
        {
            bail!("Claude conversation must end with a nonempty user or tool result message");
        }
        let replay_count = frames.len().saturating_sub(1);
        for frame in frames.iter_mut().take(replay_count) {
            if frame["type"] == "user" {
                frame["shouldQuery"] = json!(false);
            }
        }

        let mut names = HashSet::new();
        let mut manifest = Vec::new();
        let mut tools = Vec::new();
        for tool in body
            .get("tools")
            .and_then(Value::as_array)
            .into_iter()
            .flatten()
        {
            let name = tool
                .get("name")
                .and_then(Value::as_str)
                .context("Claude tool missing name")?;
            if name.is_empty()
                || name.len() > 50
                || !name
                    .bytes()
                    .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
                || !names.insert(name)
            {
                bail!("Claude tool names must be distinct ASCII identifiers (maximum 50 bytes)");
            }
            let schema = tool
                .get("input_schema")
                .filter(|schema| schema.is_object())
                .context("Claude tool requires an object schema")?;
            if ["oneOf", "allOf", "anyOf"]
                .iter()
                .any(|key| schema.get(*key).is_some())
            {
                bail!("Claude tool schema contains unsupported top-level alternatives");
            }
            let description = tool
                .get("description")
                .and_then(Value::as_str)
                .unwrap_or("");
            manifest.push(json!({"name":name,"description":description,"inputSchema":schema}));
            tools.push(json!({"name":format!("{TOOL_PREFIX}{name}"),"description":description,"input_schema":schema}));
        }
        body["tools"] = json!(tools);
        // CLI owns its output limit; builder's 4096-token fallback is not a subscription limit.
        let extra_body = json!({"tools":body["tools"]});
        Ok(Self {
            system,
            frames,
            manifest,
            extra_body,
        })
    }
}