// session_stats_test.go - SessionStats wire regression guard, one test per // sub-claim: // // - sub-claim 1: Session.Stats() accumulates 5 fields accurately across turns // - sub-claim 2: first-time crossing of a threshold emits one event carrying // the full 5 fields plus threshold_usd + session_id // - sub-claim 3: already-crossed threshold is not re-emitted by later turns // that stay above it // - sub-claim 4: a single turn that jumps across multiple thresholds emits // one event per crossed tier, in ascending order // - sub-claim 5: a session whose CostUSD never reaches the first tier // emits no threshold events at all // // session_stats_test.go - SessionStats wire 回归 guard, 一 sub-claim 一测试: // // - sub-claim 1: Session.Stats() 5 字段跨多轮累加准确 // - sub-claim 2: 首次跨档 emit 一个事件, 含完整 5 字段 + threshold_usd + session_id // - sub-claim 3: 已跨档位不会被后续停留在该档之上的轮次重复 emit // - sub-claim 4: 单轮跨多档时按升序每档各 emit 一次 // - sub-claim 5: 永远到不了第一档的会话不 emit 任何档位事件 package engine import ( "context" "testing" ) // fakeStatsEngineRef is a minimal EngineRef that carries a MockObserver so // applyTurn's emit path is observable. Minimal by design: stats path only // touches Observer(), so every other method returns a zero value. // // fakeStatsEngineRef 是带 MockObserver 的最小 EngineRef, 用于观察 applyTurn // 的 emit 路径. 精简设计: 统计路径仅触碰 Observer(), 其他方法零值即可. type fakeStatsEngineRef struct { observer EventObserver } func (f *fakeStatsEngineRef) Run(ctx context.Context, prompt string, opts ...RunOption) <-chan Event { ch := make(chan Event) close(ch) return ch } func (f *fakeStatsEngineRef) Session(id string) *Session { return nil } func (f *fakeStatsEngineRef) Observer() EventObserver { return f.observer } func (f *fakeStatsEngineRef) Activity() *ActivityTracker { return nil } func (f *fakeStatsEngineRef) Context() context.Context { return context.Background() } func (f *fakeStatsEngineRef) Cwd() string { return "" } func (f *fakeStatsEngineRef) ForkSubAgent(*SubAgentConfig) *SubAgent { return nil } // TestSession_Stats_AccumulateAcrossTurns - sub-claim 1. func TestSession_Stats_AccumulateAcrossTurns(t *testing.T) { s := newSession("sess-accum", &fakeStatsEngineRef{observer: &MockObserver{}}) s.applyTurn("p1", "a1", 10, 20, 0.1) s.applyTurn("p2", "a2", 30, 40, 0.2) got := s.Stats() if got.TurnCount != 2 { t.Errorf("TurnCount=%d, want 2", got.TurnCount) } if got.InputTokens != 40 { t.Errorf("InputTokens=%d, want 40", got.InputTokens) } if got.OutputTokens != 60 { t.Errorf("OutputTokens=%d, want 60", got.OutputTokens) } // Float equality guard: 0.1+0.2 is the classic IEEE-754 fixture; compare // with epsilon to stay honest about fp accumulation. // 浮点相等守护: 0.1+0.2 是经典 IEEE-754 用例, 用 epsilon 比较. if diff := got.CostUSD - 0.3; diff < -1e-9 || diff > 1e-9 { t.Errorf("CostUSD=%v, want ~0.3", got.CostUSD) } // 2 user prompts + 2 assistant replies = 4 messages total. if got.MessageCount != 4 { t.Errorf("MessageCount=%d, want 4", got.MessageCount) } } // TestSession_CostThresholdCrossed_FirstTimeWithFullPayload - sub-claim 2. // Verifies the event fires exactly once on first crossing and carries all 5 // SessionStats fields plus threshold_usd and session_id in the payload. // // 验证首次跨档 emit 恰好一次, payload 含 5 个 SessionStats 字段 + threshold_usd + session_id. func TestSession_CostThresholdCrossed_FirstTimeWithFullPayload(t *testing.T) { obs := &MockObserver{} s := newSession("sess-first", &fakeStatsEngineRef{observer: obs}) // $0 -> $1.5 crosses exactly the $1 tier. s.applyTurn("p", "a", 100, 200, 1.5) if n := obs.EventCount("session_cost_threshold_crossed"); n != 1 { t.Fatalf("event count=%d, want 1", n) } ev := obs.LastEvent("session_cost_threshold_crossed") if ev == nil { t.Fatal("no threshold event captured") } if got, ok := ev.Data["threshold_usd"].(float64); !ok || got != 1.0 { t.Errorf("threshold_usd=%v, want 1.0", ev.Data["threshold_usd"]) } if got, ok := ev.Data["session_id"].(string); !ok || got != "sess-first" { t.Errorf("session_id=%v, want sess-first", ev.Data["session_id"]) } if got, ok := ev.Data["cost_usd"].(float64); !ok || got != 1.5 { t.Errorf("cost_usd=%v, want 1.5", ev.Data["cost_usd"]) } if got, ok := ev.Data["input_tokens"].(int); !ok || got != 100 { t.Errorf("input_tokens=%v, want 100", ev.Data["input_tokens"]) } if got, ok := ev.Data["output_tokens"].(int); !ok || got != 200 { t.Errorf("output_tokens=%v, want 200", ev.Data["output_tokens"]) } if got, ok := ev.Data["turn_count"].(int); !ok || got != 1 { t.Errorf("turn_count=%v, want 1", ev.Data["turn_count"]) } // 1 user + 1 assistant = 2 messages. if got, ok := ev.Data["message_count"].(int); !ok || got != 2 { t.Errorf("message_count=%v, want 2", ev.Data["message_count"]) } } // TestSession_CostThresholdCrossed_NotReEmittedAfterCrossed - sub-claim 3. // Once the $1 tier fires, staying above it (but below $5) must not re-fire // the same tier on subsequent turns. // // 一旦 $1 档 fire 后, 后续轮次停在 $1 ~ $5 区间内不得重复 emit 同档. func TestSession_CostThresholdCrossed_NotReEmittedAfterCrossed(t *testing.T) { obs := &MockObserver{} s := newSession("sess-once", &fakeStatsEngineRef{observer: obs}) s.applyTurn("p1", "a1", 0, 0, 1.5) // crosses $1 s.applyTurn("p2", "a2", 0, 0, 1.0) // total 2.5, still below $5, must not re-emit s.applyTurn("p3", "a3", 0, 0, 0.5) // total 3.0, still below $5, must not re-emit if n := obs.EventCount("session_cost_threshold_crossed"); n != 1 { t.Errorf("event count=%d, want 1 (only the $1 crossing)", n) } } // TestSession_CostThresholdCrossed_MultipleInOneTurn - sub-claim 4. // A turn that jumps CostUSD from $0 to $15 must fire $1, $5, $10 in that // order, each exactly once. // // 单轮 CostUSD 从 $0 跳到 $15, 按升序 emit $1, $5, $10 各一次. func TestSession_CostThresholdCrossed_MultipleInOneTurn(t *testing.T) { obs := &MockObserver{} s := newSession("sess-multi", &fakeStatsEngineRef{observer: obs}) s.applyTurn("p", "a", 0, 0, 15.0) if n := obs.EventCount("session_cost_threshold_crossed"); n != 3 { t.Fatalf("event count=%d, want 3", n) } // Collect thresholds in emission order. var got []float64 for _, e := range obs.Events { if e.Name == "session_cost_threshold_crossed" { got = append(got, e.Data["threshold_usd"].(float64)) } } want := []float64{1, 5, 10} for i, w := range want { if i >= len(got) || got[i] != w { t.Errorf("threshold[%d]=%v, want %v (full sequence %v)", i, got[i], w, got) } } } // TestSession_CostThresholdCrossed_BelowFirstTier - sub-claim 5. // A session that never reaches $1 cumulative must emit no threshold events. // // 累计始终不到 $1 的会话不得 emit 任何档位事件. func TestSession_CostThresholdCrossed_BelowFirstTier(t *testing.T) { obs := &MockObserver{} s := newSession("sess-cheap", &fakeStatsEngineRef{observer: obs}) s.applyTurn("p", "a", 10, 20, 0.3) s.applyTurn("p", "a", 10, 20, 0.4) s.applyTurn("p", "a", 10, 20, 0.2) // total 0.9, still under $1 if n := obs.EventCount("session_cost_threshold_crossed"); n != 0 { t.Errorf("event count=%d, want 0", n) } }