malvin 0.2.4

Non-interactive research and coding agent
use crate::agent_phase::{PhaseState, ToolKind};
use crate::tool_summary::{
    ParsedToolUpdate, TOOL_PHASE_DONE, TOOL_PHASE_RUNNING, TOOL_PHASE_START, ToolSummaryTracker,
    execute_effective_exit, execute_stdout_failed,
};

pub(super) fn observe_tool_update_state(
    state: &mut PhaseState,
    parsed: &ParsedToolUpdate,
    tracker: &ToolSummaryTracker,
) {
    let Some(kind) = tool_kind_for(parsed, tracker) else {
        return;
    };
    state.orienting = false;
    state.reasoning = false;
    match kind {
        ToolKind::Execute => observe_execute(state, parsed, tracker),
        _ => observe_non_execute(state, kind, parsed.phase),
    }
}

fn tool_kind_for(parsed: &ParsedToolUpdate, tracker: &ToolSummaryTracker) -> Option<ToolKind> {
    let kind = tracker
        .record(&parsed.id)
        .map_or(parsed.kind.as_str(), |r| r.kind.as_str());
    match kind {
        "read" => Some(ToolKind::Read),
        "search" => Some(ToolKind::Search),
        "edit" => Some(ToolKind::Edit),
        "execute" => Some(ToolKind::Execute),
        _ => None,
    }
}

fn observe_execute(
    state: &mut PhaseState,
    parsed: &ParsedToolUpdate,
    tracker: &ToolSummaryTracker,
) {
    match parsed.phase {
        TOOL_PHASE_START => {
            state.running_shells = state.running_shells.saturating_add(1);
            state.active_tool = Some((ToolKind::Execute, parsed.phase));
            crate::herdr::notify_working();
        }
        TOOL_PHASE_RUNNING => state.active_tool = Some((ToolKind::Execute, parsed.phase)),
        TOOL_PHASE_DONE => {
            state.running_shells = state.running_shells.saturating_sub(1);
            if execute_failed(parsed) && execute_looks_like_test(parsed, tracker) {
                state.debugging = true;
            }
            state.active_tool = None;
        }
        _ => {}
    }
}

fn observe_non_execute(state: &mut PhaseState, kind: ToolKind, phase: u8) {
    if phase == TOOL_PHASE_DONE {
        if state.active_tool.is_some_and(|(k, _)| k == kind) {
            state.active_tool = None;
        }
        return;
    }
    state.active_tool = Some((kind, phase));
}

fn execute_failed(parsed: &ParsedToolUpdate) -> bool {
    let raw = parsed.raw_output.as_ref();
    let exit = execute_effective_exit(parsed, raw);
    execute_stdout_failed(parsed, exit, raw)
}

fn execute_looks_like_test(parsed: &ParsedToolUpdate, tracker: &ToolSummaryTracker) -> bool {
    let cmd = parsed
        .command
        .as_deref()
        .or_else(|| {
            tracker
                .record(&parsed.id)
                .and_then(|r| r.command.as_deref())
        })
        .or_else(|| {
            parsed
                .title
                .strip_prefix('`')
                .and_then(|t| t.strip_suffix('`'))
        })
        .unwrap_or("");
    std::env::current_dir()
        .is_ok_and(|wd| crate::repo_gates::command_matches_malvin_checks_gate(cmd, &wd))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::agent_phase::PhaseState;
    use crate::tool_summary::{ToolSummaryTracker, parse_tool_update};
    use serde_json::json;

    #[test]
    fn observe_tool_update_state_handles_read_start() {
        let mut state = PhaseState::fresh();
        let v = json!({
            "method": "session/update",
            "params": {"update": {
                "sessionUpdate": "tool_call",
                "toolCallId": "s1",
                "kind": "read",
                "status": "pending",
                "rawInput": {"path": "x.rs"}
            }}
        });
        let parsed = parse_tool_update(&v).expect("parsed");
        let tracker = ToolSummaryTracker::default();
        observe_tool_update_state(&mut state, &parsed, &tracker);
        assert!(!state.orienting);
        assert_eq!(state.active_tool.map(|(k, _)| k), Some(ToolKind::Read));
    }

    #[test]
    fn observe_execute_failed_gate_command_sets_debugging() {
        crate::test_utils::with_isolated_home(|w| {
            std::fs::create_dir_all(w.join(".malvin")).expect("mkdir");
            std::fs::write(w.join(".malvin/gates"), "pytest tests\n").expect("gates");
            let old = std::env::current_dir().expect("cwd");
            std::env::set_current_dir(w).expect("chdir");

            let mut state = PhaseState::fresh();
            let tracker = ToolSummaryTracker::default();
            let start = json!({
                "method": "session/update",
                "params": {"update": {
                    "sessionUpdate": "tool_call",
                    "toolCallId": "ex1",
                    "kind": "execute",
                    "status": "pending",
                    "rawInput": {"command": "pytest tests"}
                }}
            });
            let running = json!({
                "method": "session/update",
                "params": {"update": {
                    "sessionUpdate": "tool_call_update",
                    "toolCallId": "ex1",
                    "kind": "execute",
                    "status": "in_progress"
                }}
            });
            let done = json!({
                "method": "session/update",
                "params": {"update": {
                    "sessionUpdate": "tool_call_update",
                    "toolCallId": "ex1",
                    "kind": "execute",
                    "status": "completed",
                    "rawOutput": {"exitCode": 1, "stdout": "FAILED\n", "stderr": ""}
                }}
            });
            observe_tool_update_state(
                &mut state,
                &parse_tool_update(&start).expect("start"),
                &tracker,
            );
            assert_eq!(state.running_shells, 1);
            assert_eq!(
                state.active_tool,
                Some((ToolKind::Execute, TOOL_PHASE_START))
            );
            observe_tool_update_state(
                &mut state,
                &parse_tool_update(&running).expect("running"),
                &tracker,
            );
            assert_eq!(
                state.active_tool,
                Some((ToolKind::Execute, TOOL_PHASE_RUNNING))
            );
            observe_tool_update_state(
                &mut state,
                &parse_tool_update(&done).expect("done"),
                &tracker,
            );
            assert_eq!(state.running_shells, 0);
            assert!(state.active_tool.is_none());
            assert!(
                state.debugging,
                "failed checks-file command should enter debugging"
            );
            std::env::set_current_dir(old).expect("restore cwd");
        });
    }

    #[test]
    fn observe_non_execute_done_clears_matching_active_tool() {
        let mut state = PhaseState::fresh();
        let tracker = ToolSummaryTracker::default();
        let start = json!({
            "method": "session/update",
            "params": {"update": {
                "sessionUpdate": "tool_call",
                "toolCallId": "e1",
                "kind": "edit",
                "status": "pending",
                "rawInput": {"path": "a.rs"}
            }}
        });
        let done = json!({
            "method": "session/update",
            "params": {"update": {
                "sessionUpdate": "tool_call_update",
                "toolCallId": "e1",
                "kind": "edit",
                "status": "completed"
            }}
        });
        observe_tool_update_state(
            &mut state,
            &parse_tool_update(&start).expect("start"),
            &tracker,
        );
        assert_eq!(state.active_tool.map(|(k, _)| k), Some(ToolKind::Edit));
        observe_tool_update_state(
            &mut state,
            &parse_tool_update(&done).expect("done"),
            &tracker,
        );
        assert!(state.active_tool.is_none());
    }
}