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>
146 lines
4.7 KiB
Rust
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");
|
|
}
|
|
}
|