codex-agent-indicator 0.4.16

Low-overhead Codex task status indicator for Logitech G915 G-keys
use serde::Deserialize;
use serde_json::Value;

use crate::config::AppConfig;
use crate::state::{Engine, StateKind};

use super::{EventMessage, HookInput, LifecycleTracker};

const CODEX_VERSION: &str = "0.145.0";
const SCHEMA_TAG: &str = "rust-v0.145.0";
const SCHEMA_COMMIT: &str = "25af12f7e61572b0bc18ddb1008be543b91519b0";
const SCHEMA_SNAPSHOT_DATE: &str = "2026-07-28";
const SCHEMA_SOURCE: &str =
    "https://github.com/openai/codex/tree/25af12f7e61572b0bc18ddb1008be543b91519b0/codex-rs/hooks/schema/generated";
const PARENT_COMPLETES: &str =
    include_str!("fixtures/codex-hooks-0.145.0/parent-completes.json");
const APPROVAL_REQUEST: &str =
    include_str!("fixtures/codex-hooks-0.145.0/approval-request.json");
const INPUT_REQUEST: &str = include_str!("fixtures/codex-hooks-0.145.0/input-request.json");
const PARALLEL_SUBAGENTS: &str =
    include_str!("fixtures/codex-hooks-0.145.0/parallel-subagents.json");
const INTERRUPTED_WITHOUT_STOP: &str =
    include_str!("fixtures/codex-hooks-0.145.0/interrupted-without-stop.json");
const REPLAY_FIXTURES: &[&str] = &[
    PARENT_COMPLETES,
    APPROVAL_REQUEST,
    INPUT_REQUEST,
    PARALLEL_SUBAGENTS,
    INTERRUPTED_WITHOUT_STOP,
];

#[test]
fn replays_parent_task_from_hook_json_to_slot_snapshots() {
    replay_fixture(PARENT_COMPLETES);
}

#[test]
fn replays_approval_request_until_the_tool_resumes() {
    replay_fixture(APPROVAL_REQUEST);
}

#[test]
fn replays_user_input_request_and_final_question() {
    replay_fixture(INPUT_REQUEST);
}

#[test]
fn replays_parallel_subagents_without_losing_parent_or_approval_state() {
    replay_fixture(PARALLEL_SUBAGENTS);
}

#[test]
fn replays_interruption_without_inventing_a_terminal_hook() {
    let fixture = parse_fixture(INTERRUPTED_WITHOUT_STOP);
    assert!(
        fixture.events.iter().all(|step| {
            step.input.get("hook_event_name").and_then(Value::as_str) != Some("Stop")
        }),
        "interruption fixture must preserve the observed absence of Stop"
    );
    replay_fixture(INTERRUPTED_WITHOUT_STOP);
}

#[test]
fn shipped_hook_config_covers_every_replayed_event() {
    let integration: Value = serde_json::from_str(include_str!("../../integrations/codex-hooks.json"))
        .expect("integration hook config must be valid JSON");
    let configured = integration
        .get("hooks")
        .and_then(Value::as_object)
        .expect("integration hook config must contain hooks");

    for source in REPLAY_FIXTURES {
        let fixture = parse_fixture(source);
        for step in fixture.events {
            let event_name = step
                .input
                .get("hook_event_name")
                .and_then(Value::as_str)
                .expect("fixture hook event name must be a string");
            assert!(
                configured.contains_key(event_name),
                "scenario {:?} replays unconfigured hook event {event_name}",
                fixture.scenario
            );
        }
    }
}

#[test]
fn replay_fixtures_are_privacy_scrubbed() {
    let forbidden = [
        "/Users/",
        "/home/",
        "file://",
        "gho_",
        "github_pat_",
        "PRIVATE KEY",
    ];
    for source in REPLAY_FIXTURES {
        let fixture = parse_fixture(source);
        for pattern in forbidden {
            assert!(
                !source.contains(pattern),
                "scenario {:?} contains forbidden privacy pattern {pattern:?}",
                fixture.scenario
            );
        }
    }
}

#[test]
#[should_panic(expected = "fixture is missing required field \"turn_id\"")]
fn pinned_schema_rejects_a_missing_required_hook_field() {
    let mut fixture = parse_fixture(PARENT_COMPLETES);
    fixture.events[0]
        .input
        .as_object_mut()
        .expect("fixture hook input must be an object")
        .remove("turn_id");
    validate_hook_input(&fixture.events[0].input);
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ReplayFixture {
    schema: FixtureSchema,
    scenario: String,
    events: Vec<ReplayStep>,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct FixtureSchema {
    codex_version: String,
    schema_tag: String,
    schema_commit: String,
    snapshot_date: String,
    source: String,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ReplayStep {
    input: Value,
    slots: Vec<ExpectedSlot>,
}

#[derive(Debug, Deserialize, Eq, PartialEq)]
#[serde(deny_unknown_fields)]
struct ExpectedSlot {
    slot: usize,
    key: String,
    session_id: String,
    state: StateKind,
}

fn replay_fixture(source: &str) {
    let fixture = parse_fixture(source);
    assert_fixture_provenance(&fixture.schema);
    assert!(!fixture.scenario.trim().is_empty(), "scenario must be named");
    assert!(!fixture.events.is_empty(), "scenario must contain events");

    let config = AppConfig::default();
    let mut lifecycle = LifecycleTracker::default();
    let mut engine = Engine::new(config.behavior.max_sessions);

    for (index, step) in fixture.events.into_iter().enumerate() {
        validate_hook_input(&step.input);
        let hook: HookInput =
            serde_json::from_value(step.input).expect("fixture must deserialize as HookInput");
        let message = hook.into_event();
        let EventMessage::Hook { session_id, .. } = &message else {
            panic!("HookInput must produce a hook event");
        };
        if let Some(state) = lifecycle.state_for_event(&message, &config) {
            engine.transition(session_id, state, (index + 1) as u64, &config);
        }

        let actual = engine
            .snapshot(&config)
            .into_iter()
            .map(|slot| ExpectedSlot {
                slot: slot.slot,
                key: slot.key,
                session_id: slot.session_id,
                state: slot.state,
            })
            .collect::<Vec<_>>();
        assert_eq!(
            actual,
            step.slots,
            "scenario {:?}, event {}",
            fixture.scenario,
            index + 1
        );
    }
}

fn parse_fixture(source: &str) -> ReplayFixture {
    serde_json::from_str(source).expect("replay fixture must be valid JSON")
}

fn assert_fixture_provenance(schema: &FixtureSchema) {
    assert_eq!(schema.codex_version, CODEX_VERSION);
    assert_eq!(schema.schema_tag, SCHEMA_TAG);
    assert_eq!(schema.schema_commit, SCHEMA_COMMIT);
    assert_eq!(schema.snapshot_date, SCHEMA_SNAPSHOT_DATE);
    assert_eq!(schema.source, SCHEMA_SOURCE);
}

fn validate_hook_input(input: &Value) {
    let object = input
        .as_object()
        .expect("each hook input must be a JSON object");
    let event_name = object
        .get("hook_event_name")
        .and_then(Value::as_str)
        .expect("hook_event_name must be a string");
    let schema_source = match event_name {
        "UserPromptSubmit" => include_str!(
            "fixtures/codex-hooks-0.145.0/schema/user-prompt-submit.command.input.schema.json"
        ),
        "PreToolUse" => include_str!(
            "fixtures/codex-hooks-0.145.0/schema/pre-tool-use.command.input.schema.json"
        ),
        "PermissionRequest" => include_str!(
            "fixtures/codex-hooks-0.145.0/schema/permission-request.command.input.schema.json"
        ),
        "PostToolUse" => include_str!(
            "fixtures/codex-hooks-0.145.0/schema/post-tool-use.command.input.schema.json"
        ),
        "SubagentStart" => include_str!(
            "fixtures/codex-hooks-0.145.0/schema/subagent-start.command.input.schema.json"
        ),
        "SubagentStop" => include_str!(
            "fixtures/codex-hooks-0.145.0/schema/subagent-stop.command.input.schema.json"
        ),
        "Stop" => include_str!(
            "fixtures/codex-hooks-0.145.0/schema/stop.command.input.schema.json"
        ),
        other => panic!("no pinned hook schema for {other}"),
    };
    let schema: Value =
        serde_json::from_str(schema_source).expect("vendored hook schema must be valid JSON");
    let schema_object = schema
        .as_object()
        .expect("pinned hook schema must be a JSON object");
    for keyword in schema_object.keys() {
        assert!(
            matches!(
                keyword.as_str(),
                "$schema"
                    | "additionalProperties"
                    | "definitions"
                    | "properties"
                    | "required"
                    | "title"
                    | "type"
            ),
            "pinned schema uses unsupported root keyword {keyword:?}"
        );
    }
    assert_eq!(
        schema.get("type").and_then(Value::as_str),
        Some("object"),
        "pinned hook schema must describe an object"
    );
    assert_eq!(
        schema.get("additionalProperties"),
        Some(&Value::Bool(false)),
        "pinned schema must reject unknown fields"
    );
    let properties = schema
        .get("properties")
        .and_then(Value::as_object)
        .expect("pinned schema must define properties");
    let required = schema
        .get("required")
        .and_then(Value::as_array)
        .expect("pinned schema must define required fields");

    for field in required {
        let field = field
            .as_str()
            .expect("required schema entries must be strings");
        assert!(
            object.contains_key(field),
            "{event_name} fixture is missing required field {field:?}"
        );
    }
    for (field, value) in object {
        let rule = properties
            .get(field)
            .unwrap_or_else(|| panic!("{event_name} fixture has unknown field {field:?}"));
        validate_schema_rule(&schema, event_name, field, rule, value);
    }
}

fn validate_schema_rule(
    schema: &Value,
    event_name: &str,
    field: &str,
    rule: &Value,
    value: &Value,
) {
    if rule == &Value::Bool(true) {
        return;
    }
    assert_ne!(
        rule,
        &Value::Bool(false),
        "{event_name}.{field} is rejected by its schema"
    );
    let rule_object = rule
        .as_object()
        .unwrap_or_else(|| panic!("{event_name}.{field} has an unsupported schema rule"));
    for keyword in rule_object.keys() {
        assert!(
            matches!(
                keyword.as_str(),
                "$ref" | "const" | "description" | "enum" | "type"
            ),
            "{event_name}.{field} uses unsupported schema keyword {keyword:?}"
        );
    }

    if let Some(reference) = rule.get("$ref").and_then(Value::as_str) {
        let pointer = reference
            .strip_prefix('#')
            .expect("only local schema references are supported");
        let resolved = schema
            .pointer(pointer)
            .unwrap_or_else(|| panic!("schema reference {reference:?} must resolve"));
        validate_schema_rule(schema, event_name, field, resolved, value);
        return;
    }
    if let Some(expected) = rule.get("const") {
        assert_eq!(
            value, expected,
            "{event_name}.{field} must match the schema constant"
        );
    }
    if let Some(allowed) = rule.get("enum").and_then(Value::as_array) {
        assert!(
            allowed.contains(value),
            "{event_name}.{field} is not in the schema enum"
        );
    }
    if let Some(types) = rule.get("type") {
        assert!(
            schema_type_matches(types, value),
            "{event_name}.{field} has the wrong JSON type"
        );
    }
}

fn schema_type_matches(types: &Value, value: &Value) -> bool {
    match types {
        Value::String(expected) => json_type_matches(expected, value),
        Value::Array(expected) => expected.iter().any(|expected| {
            expected
                .as_str()
                .is_some_and(|expected| json_type_matches(expected, value))
        }),
        _ => false,
    }
}

fn json_type_matches(expected: &str, value: &Value) -> bool {
    match expected {
        "array" => value.is_array(),
        "boolean" => value.is_boolean(),
        "integer" => value.as_i64().is_some() || value.as_u64().is_some(),
        "null" => value.is_null(),
        "number" => value.is_number(),
        "object" => value.is_object(),
        "string" => value.is_string(),
        _ => false,
    }
}