package metrics
import (
"bytes"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
func decode(t *testing.T, line string) map[string]any {
t.Helper()
var m map[string]any
if err := json.Unmarshal([]byte(line), &m); err != nil {
t.Fatalf("line is not valid JSON (%v): %s", err, line)
}
return m
}
func TestLogWritesOneObjectPerLine(t *testing.T) {
var buf bytes.Buffer
l := NewWriter(&buf, "sess-1")
l.Log(Event{Type: "turn", Turn: 1, InputTokens: 900})
l.Log(Event{Type: "turn", Turn: 2, InputTokens: 1200})
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
if len(lines) != 2 {
t.Fatalf("got %d lines, want 2:\n%s", len(lines), buf.String())
}
first := decode(t, lines[0])
if first["session"] != "sess-1" {
t.Errorf("session = %v, want sess-1 stamped automatically", first["session"])
}
if first["ts"] == nil {
t.Error("ts must be filled in when the caller leaves it zero")
}
if first["input_tokens"].(float64) != 900 {
t.Errorf("input_tokens = %v, want 900", first["input_tokens"])
}
}
func TestExtraIsFlattened(t *testing.T) {
var buf bytes.Buffer
NewWriter(&buf, "s").Log(Event{
Type: "edit",
Tool: "edit_file",
Extra: map[string]any{"matches": 3, "path": "main.go"},
})
m := decode(t, buf.String())
if m["matches"].(float64) != 3 || m["path"] != "main.go" {
t.Errorf("Extra should sit alongside declared fields, got %v", m)
}
}
func TestExtraCannotClobberDeclaredFields(t *testing.T) {
var buf bytes.Buffer
NewWriter(&buf, "s").Log(Event{Type: "edit", Extra: map[string]any{"type": "spoofed"}})
if m := decode(t, buf.String()); m["type"] != "edit" {
t.Errorf("type = %v, want the declared value to win", m["type"])
}
}
func TestToolCallRecordsOutcome(t *testing.T) {
tests := []struct {
name string
err error
wantOK bool
wantEr string
}{
{name: "success", err: nil, wantOK: true},
{name: "failure", err: errors.New("no such file"), wantOK: false, wantEr: "no such file"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var buf bytes.Buffer
NewWriter(&buf, "s").ToolCall("read_file", 12*time.Millisecond, tc.err, nil)
m := decode(t, buf.String())
if m["type"] != "tool_call" || m["tool"] != "read_file" {
t.Fatalf("unexpected event %v", m)
}
if m["ok"] != tc.wantOK {
t.Errorf("ok = %v, want %v", m["ok"], tc.wantOK)
}
if m["dur_ms"].(float64) != 12 {
t.Errorf("dur_ms = %v, want 12", m["dur_ms"])
}
if tc.wantEr != "" && m["err"] != tc.wantEr {
t.Errorf("err = %v, want %q", m["err"], tc.wantEr)
}
})
}
}
func TestNilLoggerIsANoOp(t *testing.T) {
var l *Logger
l.Log(Event{Type: "turn"})
l.ToolCall("x", 0, nil, nil)
if l.Session() != "" {
t.Error("nil logger should report an empty session")
}
if err := l.Close(); err != nil {
t.Errorf("Close on nil logger = %v, want nil", err)
}
}
func TestOpenCreatesParentDirsAndAppends(t *testing.T) {
path := filepath.Join(t.TempDir(), ".codey", "events.jsonl")
for i := 0; i < 2; i++ {
l, err := Open(path, "s")
if err != nil {
t.Fatalf("Open: %v", err)
}
l.Log(Event{Type: "run_start"})
l.Close()
}
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if n := strings.Count(string(b), "\n"); n != 2 {
t.Errorf("got %d lines, want 2 (second Open must append, not truncate)", n)
}
}
func TestConcurrentLogLinesStayIntact(t *testing.T) {
var buf bytes.Buffer
l := NewWriter(&buf, "s")
var wg sync.WaitGroup
for i := 0; i < 50; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
l.Log(Event{Type: "tool_call", Turn: i})
}(i)
}
wg.Wait()
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
if len(lines) != 50 {
t.Fatalf("got %d lines, want 50", len(lines))
}
for _, ln := range lines {
decode(t, ln)
}
}
// Subagents write to the same log as the session that spawned them, so every
// line they produce has to say which agent it came from — otherwise four
// agents' tool calls are one indistinguishable stream.
func TestForStampsTheAgentOnEveryEvent(t *testing.T) {
var buf bytes.Buffer
l := NewWriter(&buf, "sess")
child := l.For("a1")
l.Log(Event{Type: "turn"})
child.Log(Event{Type: "turn"})
child.ToolCall("bash", time.Millisecond, nil, map[string]any{"output_bytes": 3})
lines := strings.Split(strings.TrimSpace(buf.String()), "\n")
if len(lines) != 3 {
t.Fatalf("wrote %d lines, want 3:\n%s", len(lines), buf.String())
}
if strings.Contains(lines[0], `"agent"`) {
t.Errorf("the session's own event carries an agent field: %s", lines[0])
}
for _, line := range lines[1:] {
if !strings.Contains(line, `"agent":"a1"`) {
t.Errorf("child event missing its agent: %s", line)
}
if !strings.Contains(line, `"session":"sess"`) {
t.Errorf("child event lost the session: %s", line)
}
}
}
// A derived logger is the same log, not a copy of it: everything lands in one
// file, in the order it happened.
func TestForSharesTheWriter(t *testing.T) {
var buf bytes.Buffer
l := NewWriter(&buf, "sess")
l.For("a1").Log(Event{Type: "first"})
l.Log(Event{Type: "second"})
if got := strings.Count(buf.String(), "\n"); got != 2 {
t.Fatalf("want both events in one writer, got %d lines", got)
}
if i, j := strings.Index(buf.String(), "first"), strings.Index(buf.String(), "second"); i > j {
t.Error("events landed out of order")
}
}
// The nil logger is usable everywhere, and deriving from it must stay usable.
func TestForOnANilLoggerStillDrops(t *testing.T) {
var l *Logger
l.For("a1").Log(Event{Type: "turn"})
}