newt-core 0.8.0

Newt-Agent core types, errors, and the NeMoCode-style tier router
use super::*;

struct GitPresent;
impl crate::agentic::GitTool for GitPresent {
    fn dispatch(
        &self,
        _op: &str,
        _args: &serde_json::Value,
        _caps: &crate::git_caveats::GitCaveats,
        _session: &Caveats,
    ) -> Result<String, String> {
        panic!("catalog discovery must not dispatch Git")
    }
}

#[tokio::test]
async fn scoped_act_git_discovery_does_not_advertise_legacy_operations() {
    let ws = tempfile::tempdir().unwrap();
    for fs_read in [
        Scope::All,
        Scope::only([ws.path().to_string_lossy().into_owned()]),
    ] {
        let caveats = Caveats {
            fs_read,
            ..Caveats::top()
        };
        let out = execute_tool_with_collaborators(
            "tool_search",
            &serde_json::json!({"query": "git"}),
            &ws.path().to_string_lossy(),
            false,
            20,
            &caveats,
            &mut NoMcp,
            ToolCollaborators {
                git_tool: Some(&GitPresent),
                ..Default::default()
            },
            false,
            PromptDisposition::Act,
            None,
        )
        .await
        .expect("legacy fixture has no durable writer")
        .unwrap();
        assert!(out.contains("- run_command —"), "{out}");
        assert!(!out.contains("- git —"), "legacy Git stays internal: {out}");
    }
}

#[test]
fn git_catalog_intersects_read_scope_with_prompt_disposition() {
    for scope in [Scope::All, Scope::none(), Scope::only(["/".to_owned()])] {
        for disposition in [
            PromptDisposition::Act,
            PromptDisposition::Explain,
            PromptDisposition::Research,
            PromptDisposition::Plan,
            PromptDisposition::Ask,
        ] {
            let defs = merged_tool_definitions(
                &NoMcp,
                false,
                false,
                false,
                Some(&scope),
                false,
                false,
                false,
                false,
                false,
                false,
                false,
                false,
                Default::default(),
                crate::agentic::shell_dialect_sentence(false, false),
            );
            let defs = filter_tools_for_disposition(defs, disposition);
            let git = defs
                .as_array()
                .unwrap()
                .iter()
                .find(|def| def["function"]["name"] == "git");
            if disposition == PromptDisposition::Ask {
                assert!(git.is_none());
            } else {
                let ops = &git.unwrap()["function"]["parameters"]["properties"]["op"]["enum"];
                if scope != Scope::All || disposition == PromptDisposition::Plan {
                    assert_eq!(ops, &serde_json::json!(["branch-list"]));
                } else {
                    assert!(ops
                        .as_array()
                        .unwrap()
                        .contains(&serde_json::json!("commit")));
                }
            }
        }
    }
}

#[tokio::test]
async fn scoped_git_denial_cannot_request_a_write_grant_or_dispatch() {
    let ws = tempfile::tempdir().unwrap();
    let caveats = Caveats {
        fs_read: Scope::only([ws.path().to_string_lossy().into_owned()]),
        ..caveats_rw(ws.path())
    };
    let definition = crate::agentic::git_tool_definition();
    let ops = definition["function"]["parameters"]["properties"]["op"]["enum"]
        .as_array()
        .unwrap();
    for op in ops
        .iter()
        .filter_map(serde_json::Value::as_str)
        .filter(|op| *op != "branch-list")
    {
        let mut gate = MockGate::new(true, &caveats);
        let out = run_git_gated_scope(op, &caveats, &mut gate).await;
        assert!(out.contains("scoped fs_read"), "{op}: {out}");
        assert!(
            !is_git_write_denial(&out),
            "scope errors are not grantable writes"
        );
        assert!(gate.asks.is_empty(), "{op}: {:?}", gate.asks);
    }
}

async fn run_git_gated_scope(op: &str, caveats: &Caveats, gate: &mut MockGate) -> String {
    execute_tool_with_collaborators(
        "git",
        &serde_json::json!({"op": op}),
        ".",
        false,
        20,
        caveats,
        &mut NoMcp,
        ToolCollaborators {
            git_tool: Some(&GitPresent),
            permission_gate: Some(gate),
            ..Default::default()
        },
        false,
        PromptDisposition::Act,
        None,
    )
    .await
    .expect("legacy fixture has no durable writer")
    .unwrap()
}