clustervision 0.19.2

clustervision CLI — search, read, and port AI coding-agent sessions across harnesses.
//! End-to-end tests for the deep-Claude-harness surfaces: `cv show --kind compaction_*`, `cv workflow`,
//! `cv tools`, and `cv show --pre-compaction`. Each builds a hermetic on-disk Claude session
//! (transcript + the `<sid>/workflows/` state/scripts + `<sid>/subagents/` agent transcripts +
//! journal) under a temp `$HOME`, then drives the real binary and asserts on its output.
//!
//! Hermetic through `cv_testkit::World` (own `$HOME` + `$CLUSTERVISION_HOME`, env-only, parallel-safe).

use cv_testkit::{jsonl, World};
use std::fs;

/// A workflow *state* file `<sid>/workflows/<run>.json` + its script.
fn write_workflow(w: &World, sid: &str, run: &str, state: &serde_json::Value, script: &str) {
    let wdir = w.proj.join(sid).join("workflows");
    let sdir = wdir.join("scripts");
    fs::create_dir_all(&sdir).unwrap();
    fs::write(wdir.join(format!("{run}.json")), serde_json::to_string(state).unwrap()).unwrap();
    // The launcher's convention: `scripts/<workflowName>-wf_<run>.js` — the basename's name half
    // is the workflow's name (what ghost recovery matches an orphan against).
    let name = state["workflowName"].as_str().unwrap_or("workflow");
    fs::write(sdir.join(format!("{name}-wf_{run}.js")), script).unwrap();
}

/// A workflow *agent* transcript + its meta under `<sid>/subagents/workflows/<run>/`.
fn write_workflow_agent(w: &World, sid: &str, run: &str, agent_id: &str, lines: &[serde_json::Value]) {
    let dir = w.proj.join(sid).join("subagents").join("workflows").join(run);
    fs::create_dir_all(&dir).unwrap();
    fs::write(dir.join(format!("agent-{agent_id}.jsonl")), jsonl(lines)).unwrap();
    fs::write(
        dir.join(format!("agent-{agent_id}.meta.json")),
        r#"{"agentType":"workflow-subagent"}"#,
    )
    .unwrap();
}

fn user(uuid: &str, parent: Option<&str>, text: &str) -> serde_json::Value {
    serde_json::json!({
        "type": "user", "uuid": uuid, "parentUuid": parent, "sessionId": "s",
        "timestamp": "2026-06-07T12:00:00Z", "cwd": "/work/proj",
        "message": {"role": "user", "content": text}
    })
}

fn assistant_tools(uuid: &str, ts: &str, blocks: serde_json::Value) -> serde_json::Value {
    serde_json::json!({
        "type": "assistant", "uuid": uuid, "sessionId": "s", "timestamp": ts,
        "message": {"role": "assistant", "model": "claude-test", "content": blocks}
    })
}

fn tool_use(id: &str, name: &str, input: serde_json::Value) -> serde_json::Value {
    serde_json::json!({"type": "tool_use", "id": id, "name": name, "input": input})
}

fn boundary(uuid: &str, parent: &str, trigger: &str, pre: u64) -> serde_json::Value {
    serde_json::json!({
        "type": "system", "subtype": "compact_boundary", "uuid": uuid, "parentUuid": parent,
        "sessionId": "s", "timestamp": "2026-06-07T12:00:00Z",
        "content": "Conversation compacted",
        "compactMetadata": {"trigger": trigger, "preTokens": pre, "durationMs": 90000}
    })
}

fn compact_summary(uuid: &str, parent: &str, body: &str) -> serde_json::Value {
    serde_json::json!({
        "type": "user", "uuid": uuid, "parentUuid": parent, "isCompactSummary": true,
        "sessionId": "s", "timestamp": "2026-06-07T12:00:00Z",
        "message": {"role": "user", "content": body}
    })
}

/// A complete session with: tool calls (orchestrator), TWO compactions, and one workflow run
/// (state json + script + two agent transcripts with their own tools).
fn build_world(tag: &str) -> World {
    let w = World::new(tag);
    let sid = "fsess";

    // Parent transcript: some orchestrator tools, then two compaction cycles.
    w.write_session(
        sid,
        &[
            user("u0", None, "start the work"),
            assistant_tools(
                "a0",
                "2026-06-07T12:00:01Z",
                serde_json::json!([
                    tool_use("t1", "Bash", serde_json::json!({"command": "cargo build"})),
                    tool_use("t2", "Workflow", serde_json::json!({"script": "wf"})),
                ]),
            ),
            user("u1", Some("a0"), "more"),
            // First compaction.
            boundary("b1", "u1", "manual", 980000),
            compact_summary("s1", "b1", "SUMMARY ONE: the early context"),
            user("u2", Some("s1"), "keep going"),
            assistant_tools(
                "a1",
                "2026-06-07T12:05:00Z",
                serde_json::json!([tool_use(
                    "t3",
                    "Edit",
                    serde_json::json!({"file_path": "x.rs", "old_string": "a", "new_string": "b"})
                )]),
            ),
            // Second compaction.
            boundary("b2", "a1", "auto", 750000),
            compact_summary("s2", "b2", "SUMMARY TWO: the middle context"),
            user("u3", Some("s2"), "finish up"),
        ],
    );

    // One workflow run: 2 phases, 2 agents.
    let run = "wf_test-0001";
    write_workflow(
        &w,
        sid,
        run,
        &serde_json::json!({
            "runId": run,
            "workflowName": "demo-workflow",
            "status": "completed",
            "summary": "demo run did the two phases",
            "defaultModel": "claude-test",
            "agentCount": 2,
            "totalTokens": 12345,
            "totalToolCalls": 7,
            "durationMs": 600000,
            "scriptPath": "/work/proj/fsess/workflows/scripts/demo-workflow-wf_test-0001.js",
            "script": "export const meta = { name: 'demo-workflow' }\n// the driving script body",
            "phases": [
                {"title": "Phase-A", "detail": "build the thing"},
                {"title": "Phase-B", "detail": "verify the thing"}
            ],
            "workflowProgress": [
                {"type": "workflow_phase", "index": 1, "title": "Phase-A"},
                {"type": "workflow_phase", "index": 2, "title": "Phase-B"},
                {"type": "workflow_agent", "index": 1, "label": "builder", "phaseIndex": 1,
                 "agentId": "aBUILD", "model": "claude-test", "state": "done",
                 "tokens": 8000, "toolCalls": 5, "durationMs": 400000,
                 "promptPreview": "build it", "resultPreview": "{\"built\":true}"},
                {"type": "workflow_agent", "index": 2, "label": "verifier", "phaseIndex": 2,
                 "agentId": "aVERIFY", "model": "claude-test", "state": "error",
                 "tokens": 4345, "toolCalls": 2, "error": "found a bug",
                 "promptPreview": "verify it", "resultPreview": "{\"ok\":false}"}
            ]
        }),
        "export const meta = { name: 'demo-workflow', phases: [] }\n// the driving script body",
    );

    // The two agents' transcripts (their tool calls become per-agent histograms).
    write_workflow_agent(
        &w,
        sid,
        run,
        "aBUILD",
        &[
            user("wu0", None, "build it"),
            assistant_tools(
                "wa0",
                "2026-06-07T12:01:00Z",
                serde_json::json!([
                    tool_use("b1", "Bash", serde_json::json!({"command": "make"})),
                    tool_use("b2", "Read", serde_json::json!({"file_path": "src/lib.rs"})),
                    tool_use(
                        "b3",
                        "Edit",
                        serde_json::json!({"file_path": "src/lib.rs", "old_string": "x", "new_string": "y"})
                    ),
                ]),
            ),
        ],
    );
    write_workflow_agent(
        &w,
        sid,
        run,
        "aVERIFY",
        &[
            user("vu0", None, "verify it"),
            assistant_tools(
                "va0",
                "2026-06-07T12:02:00Z",
                serde_json::json!([tool_use("v1", "Bash", serde_json::json!({"command": "cargo test"}))]),
            ),
        ],
    );

    w
}

#[test]
fn show_kind_compaction_lists_boundaries_and_summaries() {
    let w = build_world("compaction");
    let (out, _) = w.cv_ok(&["show", "fsess", "--kind", "compaction_boundary,compaction_summary"]);
    assert!(out.contains("compacted 2 time(s)"), "got:\n{out}");
    assert!(out.contains("manual"), "trigger surfaced:\n{out}");
    assert!(out.contains("auto"), "second trigger surfaced:\n{out}");
    assert!(out.contains("SUMMARY ONE"), "first summary head:\n{out}");

    // --summaries prints full text of both.
    let (full, _) = w.cv_ok(&[
        "show",
        "fsess",
        "--kind",
        "compaction_boundary,compaction_summary",
        "--summaries",
    ]);
    assert!(full.contains("SUMMARY ONE: the early context"));
    assert!(full.contains("SUMMARY TWO: the middle context"));

    // JSON carries pre_compaction_span.
    let (js, _) = w.cv_ok(&[
        "show",
        "fsess",
        "--kind",
        "compaction_boundary,compaction_summary",
        "--json",
    ]);
    let v: serde_json::Value = serde_json::from_str(&js).unwrap();
    assert_eq!(v.as_array().unwrap().len(), 2);
    assert!(v[0].get("pre_compaction_span").is_some(), "span in json:\n{js}");
    assert_eq!(v[0]["trigger"], "manual");
    assert_eq!(v[0]["pre_tokens"], 980000);
}

#[test]
fn show_pre_compaction_windows_the_lost_span() {
    let w = build_world("precompact");
    // The first boundary is at msg 3 (u0,a0,u1 precede it → span 0..3, end-exclusive).
    let (out, err) = w.cv_ok(&["show", "fsess", "--pre-compaction", "1"]);
    assert!(err.contains("pre-compaction #1 of 2"), "banner:\n{err}");
    assert!(
        err.contains("messages 0..3"),
        "computed span (the 0.11 `A..B` grammar):\n{err}"
    );
    assert!(!err.contains("messages 0-3"), "the old `A-B` span must be gone:\n{err}");
    // The pre-span body holds the first user turn, not the post-compaction "finish up".
    assert!(out.contains("start the work"), "pre-span body:\n{out}");
    assert!(
        !out.contains("finish up"),
        "must not leak post-compaction content:\n{out}"
    );

    // A second boundary's span exists; an out-of-range one errors cleanly.
    let (_, err2) = w.cv_ok(&["show", "fsess", "--pre-compaction", "2"]);
    assert!(err2.contains("pre-compaction #2 of 2"), "{err2}");
    let (ok, _, _, err3) = w.cv(&["show", "fsess", "--pre-compaction", "9"]);
    assert!(!ok && err3.contains("no compaction #9"), "{err3}");
}

#[test]
fn workflow_renders_phase_tree_and_lists() {
    let w = build_world("workflow");

    // List: one workflow, the right shape.
    let (list, _) = w.cv_ok(&["workflow", "fsess"]);
    assert!(list.contains("1 workflow(s)"), "got:\n{list}");
    assert!(list.contains("demo-workflow"), "{list}");
    assert!(list.contains("2 phase(s)") && list.contains("2 agent(s)"), "{list}");

    // The phase tree: phases, agents under each, outcomes, totals.
    let (tree, _) = w.cv_ok(&["workflow", "fsess", "wf_test"]);
    assert!(tree.contains("workflow demo-workflow"), "{tree}");
    assert!(tree.contains("12,345 tokens"), "grouped token total:\n{tree}");
    assert!(tree.contains("phase 1 · Phase-A"), "{tree}");
    assert!(tree.contains("build the thing"), "phase detail:\n{tree}");
    assert!(tree.contains("builder"), "agent label:\n{tree}");
    assert!(
        tree.contains("✓ builder") && tree.contains("✗ verifier"),
        "state glyphs:\n{tree}"
    );
    assert!(tree.contains("found a bug"), "agent error surfaced:\n{tree}");
    assert!(tree.contains("\"built\":true"), "result preview:\n{tree}");

    // --script prints the driving script.
    let (scr, _) = w.cv_ok(&["workflow", "fsess", "wf_test", "--script"]);
    assert!(scr.contains("the driving script body"), "script body:\n{scr}");

    // A flag that acts on one run is refused where there is no run, not dropped.
    for args in [
        &["workflow", "fsess", "--script"][..],
        &["workflow", "fsess", "--results"][..],
        &["workflow", "fsess", "--revive"][..],
        &["workflow", "fsess", "--follow"][..],
        &["workflow", "fsess", "wf_test", "--revive-all"][..],
        &["workflow", "fsess", "wf_test", "--revive", "--json"][..],
        &["workflow", "fsess", "wf_test", "--revive", "--follow"][..],
    ] {
        let (_, code, _, err) = w.cv(args);
        assert_eq!(code, 2, "{args:?}: {err}");
    }

    // JSON round-trips the structured workflow.
    let (js, _) = w.cv_ok(&["workflow", "fsess", "wf_test", "--json"]);
    let v: serde_json::Value = serde_json::from_str(&js).unwrap();
    assert_eq!(v["name"], "demo-workflow");
    assert_eq!(v["phases"].as_array().unwrap().len(), 2);
    assert_eq!(v["phases"][0]["agents"][0]["agent_id"], "aBUILD");
}

#[test]
fn tools_cross_agent_queries() {
    let w = build_world("tools");

    // Aggregate across orchestrator + the 2 workflow agents.
    let (agg, _) = w.cv_ok(&["tools", "fsess"]);
    assert!(agg.contains("orchestrator + forest"), "{agg}");
    // Bash used by orchestrator(1) + builder(1) + verifier(1) = 3.
    assert!(agg.contains("Bash"), "{agg}");

    // Which agents used Bash: all three.
    let (which, _) = w.cv_ok(&["tools", "fsess", "--tool", "Bash"]);
    assert!(which.contains("3 agent(s) used \"Bash\""), "got:\n{which}");

    // One agent's histogram: the builder ran Bash+Read+Edit.
    let (one, _) = w.cv_ok(&["tools", "fsess", "--agent", "aBUILD"]);
    assert!(one.contains("tools used by aBUILD"), "{one}");
    assert!(
        one.contains("Bash") && one.contains("Read") && one.contains("Edit"),
        "{one}"
    );

    // Workflow-restricted per-agent breakdown (prefix resolves to the full run id).
    let (across, _) = w.cv_ok(&["tools", "fsess", "--workflow", "wf_test", "--across"]);
    assert!(across.contains("aBUILD") && across.contains("aVERIFY"), "{across}");
    assert!(
        !across.contains("orchestrator"),
        "workflow view excludes orchestrator:\n{across}"
    );

    // Timeline is chronological and tagged by agent.
    let (tl, _) = w.cv_ok(&["tools", "fsess", "--timeline"]);
    assert!(tl.contains("tool call(s) (chronological)"), "{tl}");
    assert!(tl.contains("orch") && tl.contains("aBUILD"), "agents tagged:\n{tl}");
}