Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified bin/opik-logger-darwin-amd64
Binary file not shown.
Binary file modified bin/opik-logger-darwin-arm64
Binary file not shown.
Binary file modified bin/opik-logger-linux-amd64
Binary file not shown.
Binary file modified bin/opik-logger-windows-amd64.exe
Binary file not shown.
455 changes: 455 additions & 0 deletions docs/metadata-schema.md

Large diffs are not rendered by default.

588 changes: 588 additions & 0 deletions docs/testing-plan.md

Large diffs are not rendered by default.

143 changes: 143 additions & 0 deletions src/attribution.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package main

// Attribution carries the per-turn token attribution that feeds the Coding
// Harness dashboard. Each FE drillKey maps to one field here. The walking
// skeleton populates Skills; the rest land as we fan out.
type Attribution struct {
Skills []SkillEvent `json:"skills,omitempty"`
}

// ExtractAttribution walks one turn's entries and returns per-category events.
// Single pass; safe to call after the existing transcript parse.
func ExtractAttribution(entries []TranscriptEntry) *Attribution {
return &Attribution{
Skills: extractSkillEvents(entries),
}
}

// BuildSkillsSnapshot returns the {summary, available, loaded} block that
// lands at `metadata.cc.skills` on every per-message span and on the trace.
// Same shape both places. Scans the full transcript because skill_listing
// lives at line ~0 and Skill invocations spread throughout the session.
func BuildSkillsSnapshot(allEntries []TranscriptEntry) map[string]interface{} {
listing := extractSkillEvents(allEntries)
loaded := extractLoadedSkills(allEntries)
menuTokens := extractSkillMenuTokens(allEntries)
if len(listing) == 0 && len(loaded) == 0 && menuTokens == 0 {
return nil
}

available := make([]map[string]interface{}, 0, len(listing))
for _, s := range listing {
e := map[string]interface{}{
"name": s.Name,
"source": s.Source,
}
if s.SHA256 != "" {
e["sha256"] = s.SHA256
}
if s.Path != "" {
e["path"] = s.Path
}
available = append(available, e)
}

loadedOut := make([]map[string]interface{}, 0, len(loaded))
loadedTokens := 0
for _, s := range loaded {
loadedTokens += s.BodyTokens
e := map[string]interface{}{
"name": s.Name,
"source": s.Source,
"sha256": s.SHA256,
"body_tokens": s.BodyTokens,
"tool_use_id": s.ToolUseID,
}
if s.Path != "" {
e["path"] = s.Path
}
loadedOut = append(loadedOut, e)
}

return map[string]interface{}{
"summary": map[string]interface{}{
"total_tokens": menuTokens + loadedTokens,
"menu_tokens": menuTokens,
"loaded_tokens": loadedTokens,
"available_count": len(available),
"loaded_count": len(loadedOut),
},
"available": available,
"loaded": loadedOut,
}
}

// tokEstimate returns a token estimate using a content-type-aware
// chars/token ratio. Calibrated against Anthropic's count_tokens API on
// 643 samples drawn from real Claude Code transcripts (skill bodies, tool
// inputs, tool results, user prompts, assistant text, etc.). Naive
// `chars/4` averaged 20.5% error; per-type ratios below bring the median
// error under 5%.
//
// When the content type isn't known, callers pass "" — we auto-detect a
// few obvious cases (JSON by leading `{` or `[`) and otherwise use 3.6
// (the overall median ratio across all sampled types).
func tokEstimate(s string) int {
return tokEstimateAs(s, "")
}

// tokEstimateAs lets the caller name the content type. Values used:
//
// "json" → 2.8 (tool_use input, MCP payload)
// "deferred_tools_payload" → 2.5 (pure JSON list of names)
// "tool_result" → 3.0 (mixed text + JSON-ish output)
// "skill_body" → 3.5 (markdown w/ code blocks)
// "assistant_text" → 3.9 (prose with occasional code)
// "skill_listing_menu" → 3.9 (name + short description lines)
// "prose" → 3.9
// "user_prompt" → 4.3 (natural English from a user)
// "" / unknown → 3.6 (overall calibrated median)
func tokEstimateAs(s, contentType string) int {
if s == "" {
return 0
}
cpt := charsPerToken(s, contentType)
n := int(float64(len(s)) / cpt)
if n == 0 {
return 1
}
return n
}

func charsPerToken(s, contentType string) float64 {
if contentType == "" {
// Auto-detect JSON-shaped content — the biggest source of error
// with chars/4. Anything starting with `{` or `[` gets the JSON
// ratio; everything else falls back to the overall median.
for i := 0; i < len(s); i++ {
c := s[i]
if c == ' ' || c == '\t' || c == '\n' || c == '\r' {
continue
}
if c == '{' || c == '[' {
contentType = "json"
}
break
}
}
switch contentType {
case "json", "tool_use_input":
return 2.8
case "deferred_tools_payload":
return 2.5
case "tool_result":
return 3.0
case "skill_body":
return 3.5
case "assistant_text", "prose", "skill_listing_menu":
return 3.9
case "user_prompt":
return 4.3
}
return 3.6
}
54 changes: 54 additions & 0 deletions src/attribution_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package main

import (
"encoding/json"
"testing"
)

func TestExtractAttributionEmptyAndNil(t *testing.T) {
if got := ExtractAttribution(nil); got == nil || len(got.Skills) != 0 {
t.Errorf("nil entries: want empty Attribution, got %+v", got)
}
if got := ExtractAttribution([]TranscriptEntry{}); got == nil || len(got.Skills) != 0 {
t.Errorf("zero entries: want empty Attribution, got %+v", got)
}
}

func TestExtractSkillEventsAgainstSampleFixture(t *testing.T) {
entries, err := ReadTranscript("../test/sample-transcript.jsonl", 0)
if err != nil {
t.Fatalf("read fixture: %v", err)
}
if len(entries) == 0 {
t.Fatal("fixture empty")
}
skills := extractSkillEvents(entries)
if len(skills) != 0 {
t.Errorf("sample fixture has no skill_listing; want 0 events, got %d", len(skills))
}
}

func TestExtractSkillEventsHandlesInlineAttachment(t *testing.T) {
// Minimal handcrafted entry to confirm attachment parsing wiring.
// Skills with no on-disk SKILL.md resolve as source=bundled with empty sha.
raw := `{"type":"attachment","uuid":"a-1","timestamp":"2026-01-01T00:00:00Z","attachment":{"type":"skill_listing","content":"menu","skillCount":2,"isInitial":true,"names":["alpha-nonexistent","beta-nonexistent"]}}`
var e TranscriptEntry
if err := json.Unmarshal([]byte(raw), &e); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if e.Attachment == nil {
t.Fatal("attachment did not parse")
}
skills := extractSkillEvents([]TranscriptEntry{e})
if len(skills) != 2 {
t.Fatalf("want 2 skills, got %d", len(skills))
}
for _, s := range skills {
if s.Source != "bundled" {
t.Errorf("expected bundled for %s, got source=%s", s.Name, s.Source)
}
if s.SHA256 != "" {
t.Errorf("bundled skill %s should have empty sha, got %s", s.Name, s.SHA256)
}
}
}
174 changes: 174 additions & 0 deletions src/dryrun_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
package main

import (
"encoding/json"
"fmt"
"os"
"strings"
"testing"
)

func TestDryRunOnTestThread(t *testing.T) {
path := os.Getenv("OPIK_DRY_TRANSCRIPT")
if path == "" {
t.Skip("set OPIK_DRY_TRANSCRIPT")
}
entries, err := ReadTranscript(path, 0)
if err != nil {
t.Fatal(err)
}
snaps := domainSnapshotsFromEntries(entries, entries)
for _, domain := range []string{"tools", "skills", "user_prompts", "tool_results", "thinking"} {
fmt.Printf("--- %s ---\n", domain)
if snaps[domain] == nil {
fmt.Println("(nil)")
continue
}
out := snaps[domain]
if domain == "tools" {
out = map[string]interface{}{"summary": snaps[domain]["summary"]}
}
b, _ := json.MarshalIndent(out, "", " ")
fmt.Println(string(b))
}
}

// TestAttributionInvariant verifies that for every message_id group in
// the deduped parsed slice, Σ AttributedOutputTokens equals the anchor
// (block 0) Usage.OutputTokens — the D3 invariant from verify.py.
func TestAttributionInvariant(t *testing.T) {
path := os.Getenv("OPIK_DRY_TRANSCRIPT")
if path == "" {
t.Skip("set OPIK_DRY_TRANSCRIPT")
}
entries, err := ReadTranscript(path, 0)
if err != nil {
t.Fatal(err)
}
parsed := ParseAssistantMessages(entries)
DeduplicateUsage(parsed)

byMID := map[string][]ParsedEntry{}
for _, p := range parsed {
if p.MessageID == "" {
continue
}
byMID[p.MessageID] = append(byMID[p.MessageID], p)
}

mismatches := 0
checked := 0
for mid, sps := range byMID {
// Anchor: index 0 carries the usage post-dedup.
var anchor int
for _, p := range sps {
if p.Usage != nil && p.Usage.OutputTokens > anchor {
anchor = p.Usage.OutputTokens
}
}
if anchor == 0 {
continue
}
sum := 0
for _, p := range sps {
sum += p.AttributedOutputTokens
}
checked++
if sum != anchor {
mismatches++
t.Errorf("mid %s: anchor=%d Σattr=%d Δ=%d", mid[:24], anchor, sum, sum-anchor)
}
}
t.Logf("%d/%d LLM-call groups attribution-clean", checked-mismatches, checked)
if mismatches > 0 {
t.Errorf("%d mismatch(es)", mismatches)
}
}

// TestTraceNameResolution verifies findSlug finds aiTitle in the new
// transcript format (Claude Code 2.1.150+) and that traceNameFromPrompt
// produces sensible output.
func TestTraceNameResolution(t *testing.T) {
cases := []struct{ in, want string }{
{"", "claude-code"},
{" ", "claude-code"},
{"hello", "hello"},
{" hello world ", "hello world"},
}
for _, c := range cases {
got := traceNameFromPrompt(c.in)
if got != c.want {
t.Errorf("traceNameFromPrompt(%q) = %q, want %q", c.in, got, c.want)
}
}
long := "a very long prompt that exceeds the eighty character maximum and should be truncated to something readable"
got := traceNameFromPrompt(long)
if !strings.HasSuffix(got, "…") || len([]rune(got)) > 80 {
t.Errorf("traceNameFromPrompt(long) = %q (len=%d), expected ≤80 runes ending in …", got, len([]rune(got)))
}

// findSlug should pick aiTitle when present.
entries := []TranscriptEntry{
{Type: "user"},
{Type: "ai-title", AITitle: "Testing session for command execution"},
{Type: "assistant"},
}
if got := findSlug(entries); got != "Testing session for command execution" {
t.Errorf("findSlug picked %q, want aiTitle", got)
}

// Legacy slug still works when aiTitle is absent.
legacy := []TranscriptEntry{{Type: "assistant", Slug: "happy-crafting-lamport"}}
if got := findSlug(legacy); got != "happy-crafting-lamport" {
t.Errorf("findSlug picked %q, want legacy slug", got)
}
}

// TestToolResultDebug enumerates every tool_use → tool_result pair and
// flags any tool_use whose result the extractor isn't seeing.
func TestToolResultDebug(t *testing.T) {
path := os.Getenv("OPIK_DRY_TRANSCRIPT")
if path == "" {
t.Skip("set OPIK_DRY_TRANSCRIPT")
}
entries, err := ReadTranscript(path, 0)
if err != nil {
t.Fatal(err)
}

toolNames := map[string]string{}
for _, e := range entries {
if e.Type != "assistant" || e.Message == nil {
continue
}
for _, c := range e.Message.Content {
if c.Type == "tool_use" && c.ID != "" {
toolNames[c.ID] = c.Name
}
}
}

resultIDs := map[string]bool{}
for _, e := range entries {
if e.Type != "user" || e.Message == nil {
continue
}
for _, c := range e.Message.Content {
if c.Type == "tool_result" && c.ToolUseID != "" {
resultIDs[c.ToolUseID] = true
}
}
}

fmt.Printf("tool_uses=%d, tool_results=%d\n", len(toolNames), len(resultIDs))
for id, name := range toolNames {
if !resultIDs[id] {
fmt.Printf(" ✗ no result for tool_use %s (%s)\n", id, name)
}
}
for id := range resultIDs {
if _, ok := toolNames[id]; !ok {
fmt.Printf(" ✗ no tool_use for result %s\n", id)
}
}
}
Loading
Loading