tandem-core 0.6.2

Core types and helpers for the Tandem engine
use super::*;
use serde::Deserialize;
use serde_json::Value;
use tandem_tools::approval_classifier::classify;

#[derive(Debug, Deserialize)]
struct ToolInvocationFixture {
    input: String,
    expected_tool: String,
}

fn parse_fixture_invocation(input: &str) -> Vec<(String, Value)> {
    if input.trim_start().starts_with("/tool ") {
        parse_tool_invocation(input).into_iter().collect()
    } else {
        parse_tool_invocations_from_response(input)
    }
}

#[test]
fn tool_invocation_fixture_corpus_round_trips_shared_normalization() {
    let fixtures: Vec<ToolInvocationFixture> =
        serde_json::from_str(include_str!("fixtures/tool_invocation_corpus.json"))
            .expect("fixture corpus is valid JSON");
    assert!(
        fixtures.len() >= 30,
        "TAN-206 fixture corpus must keep at least 30 cases"
    );

    for fixture in fixtures {
        let parsed = parse_fixture_invocation(&fixture.input);
        assert_eq!(
            parsed.len(),
            1,
            "expected one parsed invocation for {:?}, got {parsed:?}",
            fixture.input
        );
        let expected = tandem_types::canonical_tool_name(&fixture.expected_tool);
        assert_eq!(parsed[0].0, expected, "{:?}", fixture.input);
        assert_eq!(
            classify(&fixture.expected_tool),
            classify(&parsed[0].0),
            "classifier drift for {:?}",
            fixture.input
        );
    }
}

#[test]
fn generated_invocation_text_never_panics_or_drifts_from_shared_classification() {
    let names = [
        "read",
        "default_api:read",
        "functions.shell",
        "default_api:run_command",
        "tools.write",
        "tool.edit",
        "builtin:websearch",
        "mcp.linear.list_issues",
        "functions.mcp.linear.create_issue",
        "mcp.github.list_issues",
        "tools.mcp.github.create_issue",
        "todowrite",
        "update_todos",
    ];
    let args = [
        "path=\"README.md\"",
        "{\"path\":\"Cargo.toml\"}",
        "query=\"enterprise mcp\"",
        "command=\"echo hi\"",
        "title=\"Follow up\", description=\"Check state\"",
        "task_id=2, status=\"completed\"",
    ];

    for name in names {
        let expected = tandem_types::canonical_tool_name(name);
        for arg in args {
            let samples = [
                format!("{name}({arg})"),
                format!("Tool call: {name}({arg}) after."),
                format!(
                    r#"{{"name":"{name}","args":{{"path":"README.md","command":"echo hi","query":"enterprise mcp","title":"Follow up","description":"Check state","task_id":2,"status":"completed"}}}}"#
                ),
            ];
            for sample in samples {
                let parsed = parse_tool_invocations_from_response(&sample);
                for (tool, _) in parsed {
                    assert_eq!(tool, expected, "{sample}");
                    assert_eq!(classify(name), classify(&tool), "{sample}");
                }
            }
        }
    }

    let mut seed = 0x5eed_u64;
    for _ in 0..512 {
        seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
        let name = random_ascii_fragment(seed);
        seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
        let arg = random_ascii_fragment(seed);
        let sample = format!("{name}({arg})");
        for (tool, _) in parse_tool_invocations_from_response(&sample) {
            let canonical = tandem_types::canonical_tool_name(&tool);
            assert_eq!(tool, canonical, "{sample}");
            assert_eq!(classify(&tool), classify(&canonical), "{sample}");
        }
    }
}

#[test]
fn function_style_parser_ignores_prose_and_fenced_code() {
    let prose = r#"
The helper read(path: &str) should stay in the final answer.

```rust
fn read(path: &str) -> String {
    path.to_string()
}
```
"#;
    assert!(parse_tool_invocations_from_response(prose).is_empty());

    let explicit = r#"Tool call: read(path="README.md")"#;
    let parsed = parse_tool_invocation_from_response(explicit).expect("explicit tool call");
    assert_eq!(parsed.0, "read");
}

fn random_ascii_fragment(mut seed: u64) -> String {
    const ALPHABET: &[u8] =
        b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-. :=,{}[]\"";
    let len = (seed % 48 + 1) as usize;
    let mut out = String::with_capacity(len);
    for _ in 0..len {
        seed = seed
            .wrapping_mul(2862933555777941757)
            .wrapping_add(3037000493);
        let idx = (seed % ALPHABET.len() as u64) as usize;
        out.push(ALPHABET[idx] as char);
    }
    out
}