magi-code 0.102.1

Repository-aware CLI coding agent for terminal work
Documentation
//! Claude subscription request projection. The host's current conversation, not
//! native session persistence, is the source of truth after edits and compaction.
use super::{
    ProviderConversationItem, 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::{HashMap, HashSet};

pub(super) const CLAUDE_TOOL_PREFIX: &str = "mcp__magi__";
const MAX_HOST_TOOL_NAME: usize = 50;

/// Host tool names Claude may see: nonempty ASCII identifiers of at most 50 bytes.
pub(super) fn is_valid_host_tool_name(name: &str) -> bool {
    !name.is_empty()
        && name.len() <= MAX_HOST_TOOL_NAME
        && name
            .bytes()
            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
}

pub(super) struct ClaudeProjection {
    pub(super) system: String,
    pub(super) messages: 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 mut system = body
            .get("system")
            .and_then(Value::as_str)
            .unwrap_or("")
            .to_owned();
        system.push_str("\n\n");
        system.push_str(super::claude_subscription::CLAUDE_CLI_PROMPT.trim());
        // Preserve native names, including rejected calls, across host replay.
        let native_tool_names: HashMap<&str, &str> = request
            .conversation_items_iter()
            .filter_map(|item| match item {
                ProviderConversationItem::ResponseItem(item) => Some((
                    item.get("call_id")?.as_str()?,
                    item.get("claude_tool_name")?.as_str()?,
                )),
                _ => None,
            })
            .collect();
        let source_messages = body
            .get("messages")
            .and_then(Value::as_array)
            .context("Claude conversation missing messages")?;
        let mut messages: Vec<Value> = Vec::new();
        for message in source_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();
            // Keep provider-issued reasoning intact across tool continuations. Unsigned
            // or empty blocks cannot be replayed to Anthropic.
            content.retain(|block| match block.get("type").and_then(Value::as_str) {
                Some("thinking") => block
                    .get("signature")
                    .and_then(Value::as_str)
                    .is_some_and(|signature| !signature.is_empty()),
                Some("redacted_thinking") => block
                    .get("data")
                    .and_then(Value::as_str)
                    .is_some_and(|data| !data.is_empty()),
                _ => true,
            });
            for block in &mut content {
                if block.get("type").and_then(Value::as_str) == Some("tool_use") {
                    if let Some(name) = block
                        .get("id")
                        .and_then(Value::as_str)
                        .and_then(|id| native_tool_names.get(id))
                    {
                        block["name"] = json!(name);
                        continue;
                    }
                    let name = block
                        .get("name")
                        .and_then(Value::as_str)
                        .context("Claude replayed tool call missing name")?;
                    if !is_valid_host_tool_name(name) {
                        bail!("Claude replayed tool has invalid name");
                    }
                    block["name"] = json!(format!("{CLAUDE_TOOL_PREFIX}{name}"));
                }
            }
            if content.is_empty() {
                continue;
            }
            if messages.last().and_then(|message| message["role"].as_str()) == Some(role) {
                if let Some(parts) = messages
                    .last_mut()
                    .and_then(|message| message["content"].as_array_mut())
                {
                    parts.extend(content);
                }
            } else {
                messages.push(json!({"role":role,"content":content}));
            }
        }
        if messages.last().and_then(|message| message["role"].as_str()) != Some("user")
            || messages
                .last()
                .and_then(|message| message["content"].as_array())
                .is_none_or(Vec::is_empty)
        {
            bail!("Claude conversation must end with a nonempty user or tool result message");
        }

        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 !is_valid_host_tool_name(name) || !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!("{CLAUDE_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,
            messages,
            manifest,
            extra_body,
        })
    }
}