yao/agent/test/output.go
Max 9326f4b747 feat(logging): enhance logger output control and add trace diagnostics
- Updated logger methods to respect the new `config.Silent` flag, allowing suppression of console output in development mode.
- Refactored `buildTurnResponse` to utilize a shared `buildTrace` function for improved tool call association.
- Introduced JSON output capabilities in `OutputWriter` for detailed trace and duration reporting.
- Enhanced reporting mechanisms to include trace details in JSON and Markdown formats for better diagnostics.
- Added support for a new `Trace` structure to capture detailed execution information, including tool calls and completion data.
2026-04-28 10:37:04 +08:00

464 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package test
import (
"fmt"
"strings"
"time"
"github.com/fatih/color"
jsoniter "github.com/json-iterator/go"
)
// OutputWriter handles colored console output for test execution
type OutputWriter struct {
verbose bool
}
// NewOutputWriter creates a new output writer
func NewOutputWriter(verbose bool) *OutputWriter {
return &OutputWriter{verbose: verbose}
}
// Header prints a header section
func (w *OutputWriter) Header(title string) {
fmt.Println()
color.New(color.FgCyan, color.Bold).Println("═══════════════════════════════════════════════════════════════")
color.New(color.FgCyan, color.Bold).Printf(" %s\n", title)
color.New(color.FgCyan, color.Bold).Println("═══════════════════════════════════════════════════════════════")
}
// SubHeader prints a sub-header
func (w *OutputWriter) SubHeader(title string) {
fmt.Println()
color.New(color.FgWhite, color.Bold).Println("───────────────────────────────────────────────────────────────")
color.New(color.FgWhite, color.Bold).Printf(" %s\n", title)
color.New(color.FgWhite, color.Bold).Println("───────────────────────────────────────────────────────────────")
}
// Info prints an info message
func (w *OutputWriter) Info(format string, args ...interface{}) {
color.New(color.FgBlue).Printf(" ")
fmt.Printf(format+"\n", args...)
}
// Success prints a success message
func (w *OutputWriter) Success(format string, args ...interface{}) {
color.New(color.FgGreen).Printf("✓ ")
fmt.Printf(format+"\n", args...)
}
// Error prints an error message
func (w *OutputWriter) Error(format string, args ...interface{}) {
color.New(color.FgRed).Printf("✗ ")
fmt.Printf(format+"\n", args...)
}
// Warning prints a warning message
func (w *OutputWriter) Warning(format string, args ...interface{}) {
color.New(color.FgYellow).Printf("⚠ ")
fmt.Printf(format+"\n", args...)
}
// Skip prints a skip message
func (w *OutputWriter) Skip(format string, args ...interface{}) {
color.New(color.FgYellow).Printf("○ ")
fmt.Printf(format+"\n", args...)
}
// Verbose prints a verbose message (only if verbose mode is enabled)
func (w *OutputWriter) Verbose(format string, args ...interface{}) {
if w.verbose {
color.New(color.FgHiBlack).Printf(" │ ")
fmt.Printf(format+"\n", args...)
}
}
// TestStart prints test case start
func (w *OutputWriter) TestStart(id string, input string, runNum int) {
inputPreview := truncateString(input, 50)
if runNum > 1 {
color.New(color.FgWhite).Printf("► [%s] Run %d: ", id, runNum)
} else {
color.New(color.FgWhite).Printf("► [%s] ", id)
}
color.New(color.FgHiBlack).Printf("%s", inputPreview)
fmt.Print(" ")
}
// TestResult prints test case result
func (w *OutputWriter) TestResult(status Status, duration time.Duration) {
switch status {
case StatusPassed:
color.New(color.FgGreen, color.Bold).Printf("PASSED")
case StatusFailed:
color.New(color.FgRed, color.Bold).Printf("FAILED")
case StatusSkipped:
color.New(color.FgYellow).Printf("SKIPPED")
case StatusError:
color.New(color.FgRed, color.Bold).Printf("ERROR")
case StatusTimeout:
color.New(color.FgRed).Printf("TIMEOUT")
}
color.New(color.FgHiBlack).Printf(" (%s)\n", formatDuration(duration))
}
// TestError prints test error details
func (w *OutputWriter) TestError(err string) {
color.New(color.FgRed).Printf(" └─ %s\n", err)
}
// TestOutput prints test output (verbose mode)
func (w *OutputWriter) TestOutput(output string) {
if w.verbose && output != "" {
outputPreview := truncateString(output, 100)
color.New(color.FgHiBlack).Printf(" └─ Output: %s\n", outputPreview)
}
}
// Progress prints progress information
func (w *OutputWriter) Progress(current, total int) {
percentage := float64(current) / float64(total) * 100
color.New(color.FgHiBlack).Printf("\r Progress: %d/%d (%.0f%%)", current, total, percentage)
}
// Summary prints the test summary
func (w *OutputWriter) Summary(summary *Summary, duration time.Duration) {
w.SubHeader("Summary")
// Agent info
color.New(color.FgWhite).Printf(" Agent: ")
color.New(color.FgCyan).Printf("%s\n", summary.AgentID)
if summary.Connector != "" {
color.New(color.FgWhite).Printf(" Connector: ")
color.New(color.FgCyan).Printf("%s\n", summary.Connector)
}
// Results
color.New(color.FgWhite).Printf(" Total: ")
fmt.Printf("%d\n", summary.Total)
color.New(color.FgWhite).Printf(" Passed: ")
if summary.Passed > 0 {
color.New(color.FgGreen).Printf("%d\n", summary.Passed)
} else {
fmt.Printf("%d\n", summary.Passed)
}
color.New(color.FgWhite).Printf(" Failed: ")
if summary.Failed > 0 {
color.New(color.FgRed).Printf("%d\n", summary.Failed)
} else {
fmt.Printf("%d\n", summary.Failed)
}
if summary.Skipped > 0 {
color.New(color.FgWhite).Printf(" Skipped: ")
color.New(color.FgYellow).Printf("%d\n", summary.Skipped)
}
if summary.Errors > 0 {
color.New(color.FgWhite).Printf(" Errors: ")
color.New(color.FgRed).Printf("%d\n", summary.Errors)
}
if summary.Timeouts > 0 {
color.New(color.FgWhite).Printf(" Timeouts: ")
color.New(color.FgRed).Printf("%d\n", summary.Timeouts)
}
// Pass rate
passRate := float64(0)
if summary.Total > 0 {
passRate = float64(summary.Passed) / float64(summary.Total) * 100
}
color.New(color.FgWhite).Printf(" Pass Rate: ")
if passRate == 100 {
color.New(color.FgGreen, color.Bold).Printf("%.1f%%\n", passRate)
} else if passRate >= 80 {
color.New(color.FgYellow).Printf("%.1f%%\n", passRate)
} else {
color.New(color.FgRed).Printf("%.1f%%\n", passRate)
}
// Duration
color.New(color.FgWhite).Printf(" Duration: ")
fmt.Printf("%s\n", formatDuration(duration))
// Stability info (if runs > 1)
if summary.RunsPerCase > 1 {
fmt.Println()
color.New(color.FgWhite, color.Bold).Println(" Stability Analysis:")
color.New(color.FgWhite).Printf(" Runs/Case: %d\n", summary.RunsPerCase)
color.New(color.FgWhite).Printf(" Total Runs: %d\n", summary.TotalRuns)
color.New(color.FgWhite).Printf(" Stable Cases: ")
if summary.StableCases == summary.Total {
color.New(color.FgGreen).Printf("%d\n", summary.StableCases)
} else {
color.New(color.FgYellow).Printf("%d\n", summary.StableCases)
}
color.New(color.FgWhite).Printf(" Unstable: ")
if summary.UnstableCases > 0 {
color.New(color.FgRed).Printf("%d\n", summary.UnstableCases)
} else {
fmt.Printf("%d\n", summary.UnstableCases)
}
}
}
// OutputFile prints the output file path
func (w *OutputWriter) OutputFile(path string) {
fmt.Println()
color.New(color.FgWhite).Printf(" Output: ")
color.New(color.FgCyan).Printf("%s\n", path)
}
// FinalResult prints the final result banner
func (w *OutputWriter) FinalResult(passed bool) {
fmt.Println()
if passed {
color.New(color.FgGreen, color.Bold).Println("═══════════════════════════════════════════════════════════════")
color.New(color.FgGreen, color.Bold).Println(" ✨ ALL TESTS PASSED ✨")
color.New(color.FgGreen, color.Bold).Println("═══════════════════════════════════════════════════════════════")
} else {
color.New(color.FgRed, color.Bold).Println("═══════════════════════════════════════════════════════════════")
color.New(color.FgRed, color.Bold).Println(" ❌ TESTS FAILED")
color.New(color.FgRed, color.Bold).Println("═══════════════════════════════════════════════════════════════")
}
fmt.Println()
}
// DirectOutput prints the agent output directly (for development mode)
func (w *OutputWriter) DirectOutput(output interface{}) {
if output == nil {
return
}
// Try to format as JSON if it's a complex type
switch v := output.(type) {
case string:
fmt.Println(v)
case map[string]interface{}, []interface{}:
// Pretty print JSON
jsonBytes, err := jsoniter.MarshalIndent(v, "", " ")
if err != nil {
fmt.Printf("%v\n", output)
} else {
fmt.Println(string(jsonBytes))
}
default:
// Try to marshal as JSON
jsonBytes, err := jsoniter.MarshalIndent(output, "", " ")
if err != nil {
fmt.Printf("%v\n", output)
} else {
fmt.Println(string(jsonBytes))
}
}
}
// DirectOutputJSON outputs a complete JSON object with output, trace and duration.
// Designed for AI/script consumption via --json flag.
func (w *OutputWriter) DirectOutputJSON(output interface{}, trace *Trace, duration time.Duration) {
payload := map[string]interface{}{
"output": output,
"duration_ms": duration.Milliseconds(),
}
if trace != nil {
payload["trace"] = trace
}
jsonBytes, err := jsoniter.MarshalIndent(payload, "", " ")
if err != nil {
fmt.Printf("%v\n", output)
return
}
fmt.Println(string(jsonBytes))
}
// DirectTrace prints a human-readable summary of tool calls from a Trace.
// Only prints when there are tool calls; skipped when trace is nil or empty.
func (w *OutputWriter) DirectTrace(trace *Trace) {
if trace == nil || len(trace.ToolCalls) == 0 {
return
}
fmt.Println()
color.New(color.FgHiBlack).Println("--- Tool Calls ---")
for _, tc := range trace.ToolCalls {
prefix := tc.Tool
if tc.Server != "" {
prefix = tc.Server + "/" + tc.Tool
}
status := "OK"
if tc.Error != "" {
status = "ERR: " + truncateString(tc.Error, 60)
}
argsStr := ""
if tc.Arguments != nil {
if b, err := jsoniter.Marshal(tc.Arguments); err == nil {
argsStr = truncateString(string(b), 80)
}
}
if argsStr != "" {
color.New(color.FgHiBlack).Printf(" %s → %s (args: %s)\n", prefix, status, argsStr)
} else {
color.New(color.FgHiBlack).Printf(" %s → %s\n", prefix, status)
}
}
}
// ScriptOutputJSON outputs the complete script test report as JSON.
func (w *OutputWriter) ScriptOutputJSON(report *ScriptTestReport) {
jsonBytes, err := jsoniter.MarshalIndent(report, "", " ")
if err != nil {
fmt.Printf("{\"error\": %q}\n", err.Error())
return
}
fmt.Println(string(jsonBytes))
}
// ScriptTestSummary prints the script test summary
func (w *OutputWriter) ScriptTestSummary(summary *ScriptTestSummary, duration time.Duration) {
w.SubHeader("Summary")
// Results
color.New(color.FgWhite).Printf(" Total: ")
fmt.Printf("%d\n", summary.Total)
color.New(color.FgWhite).Printf(" Passed: ")
if summary.Passed > 0 {
color.New(color.FgGreen).Printf("%d\n", summary.Passed)
} else {
fmt.Printf("%d\n", summary.Passed)
}
color.New(color.FgWhite).Printf(" Failed: ")
if summary.Failed > 0 {
color.New(color.FgRed).Printf("%d\n", summary.Failed)
} else {
fmt.Printf("%d\n", summary.Failed)
}
if summary.Skipped > 0 {
color.New(color.FgWhite).Printf(" Skipped: ")
color.New(color.FgYellow).Printf("%d\n", summary.Skipped)
}
// Pass rate
passRate := float64(0)
if summary.Total > 0 {
passRate = float64(summary.Passed) / float64(summary.Total) * 100
}
color.New(color.FgWhite).Printf(" Pass Rate: ")
if passRate == 100 {
color.New(color.FgGreen, color.Bold).Printf("%.1f%%\n", passRate)
} else if passRate >= 80 {
color.New(color.FgYellow).Printf("%.1f%%\n", passRate)
} else {
color.New(color.FgRed).Printf("%.1f%%\n", passRate)
}
// Duration
color.New(color.FgWhite).Printf(" Duration: ")
fmt.Printf("%s\n", formatDuration(duration))
}
// DynamicTestStart outputs the start of a dynamic test
func (w *OutputWriter) DynamicTestStart(id string, checkpointCount int) {
color.New(color.FgWhite).Printf("► [%s] ", id)
color.New(color.FgCyan).Printf("(dynamic, %d checkpoints)\n", checkpointCount)
}
// DynamicTurn outputs a single turn in dynamic testing
func (w *OutputWriter) DynamicTurn(turn int, inputSummary string, checkpointsReached, total int) {
if w.verbose {
color.New(color.FgHiBlack).Printf("│ ├─ Turn %d: %s ", turn, inputSummary)
color.New(color.FgCyan).Printf("[%d/%d checkpoints]\n", checkpointsReached, total)
}
}
// DynamicCheckpoint outputs a checkpoint being reached
func (w *OutputWriter) DynamicCheckpoint(checkpointID string) {
if w.verbose {
color.New(color.FgGreen).Printf("│ │ └─ ✓ checkpoint: %s\n", checkpointID)
}
}
// DynamicTestResult outputs the result of a dynamic test
func (w *OutputWriter) DynamicTestResult(status Status, turns int, checkpoints int, duration time.Duration) {
color.New(color.FgHiBlack).Printf(" └─ ")
switch status {
case StatusPassed:
color.New(color.FgGreen).Printf("PASSED")
case StatusFailed:
color.New(color.FgRed).Printf("FAILED")
case StatusError:
color.New(color.FgRed).Printf("ERROR")
case StatusTimeout:
color.New(color.FgRed).Printf("TIMEOUT")
}
color.New(color.FgHiBlack).Printf(" (%d turns, %d checkpoints, %s)\n", turns, checkpoints, formatDuration(duration))
}
// StabilityResult prints stability analysis result for a test case
func (w *OutputWriter) StabilityResult(sr *StabilityResult) {
color.New(color.FgWhite).Printf(" [%s] ", sr.ID)
// Pass rate
if sr.PassRate == 100 {
color.New(color.FgGreen).Printf("%.0f%%", sr.PassRate)
} else if sr.PassRate >= 80 {
color.New(color.FgYellow).Printf("%.0f%%", sr.PassRate)
} else {
color.New(color.FgRed).Printf("%.0f%%", sr.PassRate)
}
// Classification
color.New(color.FgHiBlack).Printf(" (%d/%d) ", sr.Passed, sr.Runs)
switch sr.StabilityClass {
case StabilityStable:
color.New(color.FgGreen).Printf("Stable")
case StabilityMostlyStable:
color.New(color.FgYellow).Printf("Mostly Stable")
case StabilityUnstable:
color.New(color.FgRed).Printf("Unstable")
case StabilityHighlyUnstable:
color.New(color.FgRed, color.Bold).Printf("Highly Unstable")
}
// Timing
color.New(color.FgHiBlack).Printf(" avg:%.0fms\n", sr.AvgDurationMs)
}
// Helper functions
func truncateString(s string, maxLen int) string {
// Remove newlines and extra spaces
s = strings.ReplaceAll(s, "\n", " ")
s = strings.ReplaceAll(s, "\r", "")
s = strings.Join(strings.Fields(s), " ")
if len(s) <= maxLen {
return s
}
return s[:maxLen-3] + "..."
}
func formatDuration(d time.Duration) string {
if d < time.Millisecond {
return fmt.Sprintf("%dµs", d.Microseconds())
}
if d < time.Second {
return fmt.Sprintf("%dms", d.Milliseconds())
}
if d < time.Minute {
return fmt.Sprintf("%.1fs", d.Seconds())
}
return fmt.Sprintf("%.1fm", d.Minutes())
}