Files
owlen/crates/app/cli/tests/headless.rs
vikingowl b1b95a4560 feat(M7): implement headless mode with JSON and stream-JSON output formats
Milestone M7 implementation adds programmatic output formats for automation
and machine consumption.

New features:
- --output-format flag with three modes:
  * text (default): Human-readable streaming output
  * json: Single JSON object with session_id, messages, and stats
  * stream-json: NDJSON format with event stream (session_start, chunk, session_end)

- Session tracking:
  * Unique session ID generation (timestamp-based)
  * Duration tracking (ms)
  * Token count estimation (chars / 4 approximation)

- Output structures:
  * SessionOutput: Complete session with messages and stats
  * StreamEvent: Individual events for NDJSON streaming
  * Stats: Token counts (total, prompt, completion) and duration

- Tool result formatting:
  * All tool commands (Read, Write, Edit, Glob, Grep, Bash, SlashCommand)
    support all three output formats
  * JSON mode wraps results with session metadata
  * Stream-JSON mode emits event sequences

- Chat streaming:
  * Text mode: Real-time character streaming (unchanged behavior)
  * JSON mode: Collects full response, outputs once with stats
  * Stream-JSON mode: Emits chunk events as they arrive

Tests added (5 new tests):
1. print_json_has_session_id_and_stats - Verifies JSON output structure
2. stream_json_sequence_is_well_formed - Verifies NDJSON event sequence
3. text_format_is_default - Verifies default behavior unchanged
4. json_format_with_tool_execution - Verifies tool result formatting
5. stream_json_includes_chunk_events - Verifies streaming chunks

All 68 tests passing (up from 63).

This enables programmatic usage for automation, CI/CD, and integration
with other tools.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-01 20:05:23 +01:00

146 lines
4.7 KiB
Rust

use assert_cmd::Command;
use serde_json::Value;
use std::fs;
use tempfile::tempdir;
#[test]
fn print_json_has_session_id_and_stats() {
let mut cmd = Command::cargo_bin("owlen").unwrap();
cmd.arg("--output-format")
.arg("json")
.arg("Say hello");
let output = cmd.assert().success();
let stdout = String::from_utf8_lossy(&output.get_output().stdout);
// Parse JSON output
let json: Value = serde_json::from_str(&stdout).expect("Output should be valid JSON");
// Verify session_id exists
assert!(json.get("session_id").is_some(), "JSON output should have session_id");
let session_id = json["session_id"].as_str().unwrap();
assert!(!session_id.is_empty(), "session_id should not be empty");
// Verify stats exist
assert!(json.get("stats").is_some(), "JSON output should have stats");
let stats = &json["stats"];
// Check for token counts
assert!(stats.get("total_tokens").is_some(), "stats should have total_tokens");
// Check for messages
assert!(json.get("messages").is_some(), "JSON output should have messages");
}
#[test]
fn stream_json_sequence_is_well_formed() {
let mut cmd = Command::cargo_bin("owlen").unwrap();
cmd.arg("--output-format")
.arg("stream-json")
.arg("Say hello");
let output = cmd.assert().success();
let stdout = String::from_utf8_lossy(&output.get_output().stdout);
// Stream-JSON is NDJSON - each line should be valid JSON
let lines: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect();
assert!(!lines.is_empty(), "Stream-JSON should produce at least one event");
// Each line should be valid JSON
for (i, line) in lines.iter().enumerate() {
let json: Value = serde_json::from_str(line)
.expect(&format!("Line {} should be valid JSON: {}", i, line));
// Each event should have a type
assert!(json.get("type").is_some(), "Event should have a type field");
}
// First event should be session_start
let first: Value = serde_json::from_str(lines[0]).unwrap();
assert_eq!(first["type"].as_str().unwrap(), "session_start");
assert!(first.get("session_id").is_some());
// Last event should be session_end or complete
let last: Value = serde_json::from_str(lines[lines.len() - 1]).unwrap();
let last_type = last["type"].as_str().unwrap();
assert!(
last_type == "session_end" || last_type == "complete",
"Last event should be session_end or complete, got: {}",
last_type
);
}
#[test]
fn text_format_is_default() {
let mut cmd = Command::cargo_bin("owlen").unwrap();
cmd.arg("Say hello");
let output = cmd.assert().success();
let stdout = String::from_utf8_lossy(&output.get_output().stdout);
// Text format should not be JSON
assert!(serde_json::from_str::<Value>(&stdout).is_err(),
"Default output should be text, not JSON");
}
#[test]
fn json_format_with_tool_execution() {
let dir = tempdir().unwrap();
let file = dir.path().join("test.txt");
fs::write(&file, "hello world").unwrap();
let mut cmd = Command::cargo_bin("owlen").unwrap();
cmd.arg("--mode")
.arg("code")
.arg("--output-format")
.arg("json")
.arg("read")
.arg(file.to_str().unwrap());
let output = cmd.assert().success();
let stdout = String::from_utf8_lossy(&output.get_output().stdout);
let json: Value = serde_json::from_str(&stdout).expect("Output should be valid JSON");
// Should have result
assert!(json.get("result").is_some());
// Should have tool info
assert!(json.get("tool").is_some());
assert_eq!(json["tool"].as_str().unwrap(), "Read");
}
#[test]
fn stream_json_includes_chunk_events() {
let mut cmd = Command::cargo_bin("owlen").unwrap();
cmd.arg("--output-format")
.arg("stream-json")
.arg("Say hello");
let output = cmd.assert().success();
let stdout = String::from_utf8_lossy(&output.get_output().stdout);
let lines: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect();
// Should have chunk events between session_start and session_end
let chunk_events: Vec<&str> = lines.iter()
.filter(|line| {
if let Ok(json) = serde_json::from_str::<Value>(line) {
json["type"].as_str() == Some("chunk")
} else {
false
}
})
.copied()
.collect();
assert!(!chunk_events.is_empty(), "Should have at least one chunk event");
// Each chunk should have content
for chunk_line in chunk_events {
let chunk: Value = serde_json::from_str(chunk_line).unwrap();
assert!(chunk.get("content").is_some(), "Chunk should have content");
}
}